wireforge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wireforge/__init__.py +3 -0
- wireforge/_data/cmake/WireForgeConfig.cmake +29 -0
- wireforge/_data/cmake/WireForgeConfigVersion.cmake +15 -0
- wireforge/_data/cmake/WireForgeGenerate.cmake +64 -0
- wireforge/_data/runtime/LICENSE +21 -0
- wireforge/_data/runtime/include/wireforge/array_access.hpp +35 -0
- wireforge/_data/runtime/include/wireforge/bit_utils.hpp +226 -0
- wireforge/_data/runtime/include/wireforge/byte_io.hpp +387 -0
- wireforge/_data/runtime/include/wireforge/checksum.hpp +201 -0
- wireforge/_data/runtime/include/wireforge/codec_support.hpp +412 -0
- wireforge/_data/runtime/include/wireforge/config.hpp +41 -0
- wireforge/_data/runtime/include/wireforge/delegate.hpp +101 -0
- wireforge/_data/runtime/include/wireforge/dispatcher.hpp +339 -0
- wireforge/_data/runtime/include/wireforge/expr_support.hpp +38 -0
- wireforge/_data/runtime/include/wireforge/frame_decoder.hpp +262 -0
- wireforge/_data/runtime/include/wireforge/frame_layout.hpp +20 -0
- wireforge/_data/runtime/include/wireforge/link.hpp +223 -0
- wireforge/_data/runtime/include/wireforge/lock.hpp +146 -0
- wireforge/_data/runtime/include/wireforge/ring_buffer.hpp +226 -0
- wireforge/_data/runtime/include/wireforge/span.hpp +116 -0
- wireforge/_data/runtime/include/wireforge/static_vector.hpp +264 -0
- wireforge/_data/runtime/include/wireforge/status.hpp +75 -0
- wireforge/_data/runtime/include/wireforge/transport.hpp +95 -0
- wireforge/_data/runtime/include/wireforge/validation.hpp +153 -0
- wireforge/_data/runtime/include/wireforge/version.hpp +22 -0
- wireforge/_data/runtime/requirements.yaml +438 -0
- wireforge/_data/schema/deviations.schema.json +47 -0
- wireforge/_data/schema/fragment.schema.json +79 -0
- wireforge/_data/schema/llr_ids.schema.json +25 -0
- wireforge/_data/schema/project.schema.json +1240 -0
- wireforge/_data/schema/runtime_requirements.schema.json +34 -0
- wireforge/_data/ui/assets/index-6Pomu6U4.js +44 -0
- wireforge/_data/ui/assets/index-GVdDyUAG.css +1 -0
- wireforge/_data/ui/index.html +13 -0
- wireforge/bundle.py +177 -0
- wireforge/ci_check.py +155 -0
- wireforge/cli.py +617 -0
- wireforge/codec.py +897 -0
- wireforge/compat.py +536 -0
- wireforge/coverage_points.py +64 -0
- wireforge/cpp.py +273 -0
- wireforge/cpp_codec.py +440 -0
- wireforge/cpp_interface.py +874 -0
- wireforge/cpp_tests.py +1993 -0
- wireforge/differential.py +586 -0
- wireforge/docs_markdown.py +72 -0
- wireforge/expr.py +598 -0
- wireforge/findings.py +187 -0
- wireforge/fmt.py +351 -0
- wireforge/generate.py +644 -0
- wireforge/golden.py +253 -0
- wireforge/hlr_import.py +112 -0
- wireforge/impact.py +249 -0
- wireforge/importers.py +402 -0
- wireforge/issues.py +27 -0
- wireforge/layout.py +681 -0
- wireforge/lifecycle.py +1266 -0
- wireforge/lifecycle_results.py +205 -0
- wireforge/lifecycle_rules.py +78 -0
- wireforge/lifecycle_runtime.py +375 -0
- wireforge/llr.py +693 -0
- wireforge/llr_ids.py +104 -0
- wireforge/loader.py +422 -0
- wireforge/mcp_server.py +357 -0
- wireforge/model.py +322 -0
- wireforge/plain.py +15 -0
- wireforge/py.typed +0 -0
- wireforge/reqif.py +289 -0
- wireforge/resources.py +43 -0
- wireforge/results.py +377 -0
- wireforge/runtime_reqs.py +147 -0
- wireforge/schema.py +151 -0
- wireforge/server/__init__.py +1 -0
- wireforge/server/app.py +619 -0
- wireforge/server/oidc.py +99 -0
- wireforge/server/storage.py +146 -0
- wireforge/service.py +1354 -0
- wireforge/templates/command/project.yaml +38 -0
- wireforge/templates/raw/project.yaml +28 -0
- wireforge/templates/telemetry/project.yaml +51 -0
- wireforge/test_cases.py +475 -0
- wireforge/test_project.py +478 -0
- wireforge/trace.py +442 -0
- wireforge/validate.py +1241 -0
- wireforge/vcs.py +44 -0
- wireforge/vectors.py +222 -0
- wireforge/versioning.py +137 -0
- wireforge/wireshark.py +792 -0
- wireforge/wireshark_runtime.lua +725 -0
- wireforge-0.1.0.dist-info/METADATA +91 -0
- wireforge-0.1.0.dist-info/RECORD +96 -0
- wireforge-0.1.0.dist-info/WHEEL +5 -0
- wireforge-0.1.0.dist-info/entry_points.txt +2 -0
- wireforge-0.1.0.dist-info/licenses/LICENSE +98 -0
- wireforge-0.1.0.dist-info/licenses/runtime/LICENSE +21 -0
- wireforge-0.1.0.dist-info/top_level.txt +1 -0
wireforge/mcp_server.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""MCP server: WireForge tools for AI agents over a workspace of projects.
|
|
2
|
+
|
|
3
|
+
Every tool is a thin wrapper of `wireforge.service` and has a CLI equivalent (docs/MCP.md). Tools
|
|
4
|
+
that write accept `dry_run` and return the unified diff of the change; every result carries
|
|
5
|
+
`ok`, `issues`, `data` and `text`. Run with `wireforge mcp --workspace <dir>` (stdio transport).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from mcp.server.mcpserver import MCPServer
|
|
15
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
16
|
+
|
|
17
|
+
from wireforge import golden, hlr_import, schema, service, test_project
|
|
18
|
+
from wireforge.codec import ReferenceCodec
|
|
19
|
+
from wireforge.loader import SECTIONS, load_project
|
|
20
|
+
from wireforge.service import CodecTarget, EditOptions, Result, ServiceError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
|
|
25
|
+
INSTRUCTIONS = """\
|
|
26
|
+
WireForge defines binary messages in YAML and generates C++17 code and tests from them.
|
|
27
|
+
Typical flow: create_project, then upsert_entry (checksums, enums, interfaces, profiles) and
|
|
28
|
+
upsert_struct (types, messages), validate_project until it reports no issues, generate_code,
|
|
29
|
+
run_tests, lifecycle_docs (requirements, design, test and traceability documents). Use
|
|
30
|
+
dry_run=true to preview a change as a diff. Field definitions follow
|
|
31
|
+
schema/project.schema.json: {name, type, bits?, min?, max?, default?, occurs?: {max, count?,
|
|
32
|
+
fill?}, checksum?: {use, covers: {scope}}, if?, description?}.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Tools:
|
|
37
|
+
"""The tool implementations, bound to a workspace directory."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, workspace: Path) -> None:
|
|
40
|
+
"""Serve the projects below `workspace` (one directory per project)."""
|
|
41
|
+
self.workspace = workspace
|
|
42
|
+
self.output = workspace / ".wireforge" / "generated"
|
|
43
|
+
|
|
44
|
+
def project_dir(self, project: str) -> Path:
|
|
45
|
+
"""Directory of a project of the workspace."""
|
|
46
|
+
directory = (self.workspace / project).resolve()
|
|
47
|
+
if self.workspace.resolve() not in directory.parents:
|
|
48
|
+
message = f"{project!r} is not a project of the workspace"
|
|
49
|
+
raise ServiceError(message)
|
|
50
|
+
if not (directory / schema.ROOT_FILE_NAME).exists():
|
|
51
|
+
message = f"no project {project!r} in the workspace"
|
|
52
|
+
raise ServiceError(message)
|
|
53
|
+
return directory
|
|
54
|
+
|
|
55
|
+
def list_projects(self) -> dict[str, Any]:
|
|
56
|
+
"""Return the projects of the workspace."""
|
|
57
|
+
names = sorted(
|
|
58
|
+
path.name
|
|
59
|
+
for path in self.workspace.iterdir()
|
|
60
|
+
if (path / schema.ROOT_FILE_NAME).exists()
|
|
61
|
+
)
|
|
62
|
+
return Result(ok=True, data={"projects": names}, text="\n".join(names)).to_dict()
|
|
63
|
+
|
|
64
|
+
def create_project(
|
|
65
|
+
self,
|
|
66
|
+
project: str,
|
|
67
|
+
name: str,
|
|
68
|
+
namespace: str | None = None,
|
|
69
|
+
version: str = "0.1.0",
|
|
70
|
+
template: str | None = None,
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Create a project directory with its root file, empty or from a template.
|
|
73
|
+
|
|
74
|
+
`template` is one of `telemetry`, `command` or `raw` (a working first project to edit).
|
|
75
|
+
"""
|
|
76
|
+
directory = self.workspace / project
|
|
77
|
+
if template is not None:
|
|
78
|
+
return service.init_project(directory, template, name, namespace, version).to_dict()
|
|
79
|
+
if directory.exists():
|
|
80
|
+
message = f"{project} already exists"
|
|
81
|
+
raise ServiceError(message)
|
|
82
|
+
directory.mkdir(parents=True)
|
|
83
|
+
lines = ["schema_version: 1", f"project: {name}", f'version: "{version}"']
|
|
84
|
+
if namespace:
|
|
85
|
+
lines.append(f"namespace: {namespace}")
|
|
86
|
+
(directory / schema.ROOT_FILE_NAME).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
87
|
+
return service.validate(directory).to_dict()
|
|
88
|
+
|
|
89
|
+
def list_messages(self, project: str) -> dict[str, Any]:
|
|
90
|
+
"""Return the messages (with ids), types and interfaces of a project."""
|
|
91
|
+
loaded = load_project(self.project_dir(project))
|
|
92
|
+
model = loaded.project
|
|
93
|
+
if model is None:
|
|
94
|
+
return Result(ok=False, issues=loaded.issues).to_dict()
|
|
95
|
+
messages = [
|
|
96
|
+
{"name": item.name, "id": item.message.id if item.message else None}
|
|
97
|
+
for item in model.messages.values()
|
|
98
|
+
]
|
|
99
|
+
data = {
|
|
100
|
+
"messages": messages,
|
|
101
|
+
"types": list(model.types),
|
|
102
|
+
"user_types": list(model.user_types),
|
|
103
|
+
"interfaces": list(model.interfaces),
|
|
104
|
+
}
|
|
105
|
+
text = "\n".join(f"{item['name']} id={item['id']}" for item in messages)
|
|
106
|
+
return Result(ok=True, data=data, text=text).to_dict()
|
|
107
|
+
|
|
108
|
+
def get_message(self, project: str, name: str) -> dict[str, Any]:
|
|
109
|
+
"""Return the definition of a message (or of a type) as written in the files."""
|
|
110
|
+
directory = self.project_dir(project)
|
|
111
|
+
for section in ("messages", "types"):
|
|
112
|
+
try:
|
|
113
|
+
result = service.get(directory, f"/{section}/{name}")
|
|
114
|
+
except ServiceError:
|
|
115
|
+
continue
|
|
116
|
+
result.data["section"] = section
|
|
117
|
+
return result.to_dict()
|
|
118
|
+
message = f"no message or type {name!r}"
|
|
119
|
+
raise ServiceError(message)
|
|
120
|
+
|
|
121
|
+
def upsert_struct(
|
|
122
|
+
self,
|
|
123
|
+
project: str,
|
|
124
|
+
section: str,
|
|
125
|
+
name: str,
|
|
126
|
+
definition: dict[str, Any],
|
|
127
|
+
*,
|
|
128
|
+
dry_run: bool = False,
|
|
129
|
+
) -> dict[str, Any]:
|
|
130
|
+
"""Create or update a type or message ({description?, id?, fields: [...]})."""
|
|
131
|
+
if section not in {"types", "messages"}:
|
|
132
|
+
message = "section must be 'types' or 'messages'"
|
|
133
|
+
raise ServiceError(message)
|
|
134
|
+
options = EditOptions(dry_run=dry_run)
|
|
135
|
+
return service.upsert(
|
|
136
|
+
self.project_dir(project), section, name, definition, options
|
|
137
|
+
).to_dict()
|
|
138
|
+
|
|
139
|
+
def upsert_entry(
|
|
140
|
+
self,
|
|
141
|
+
project: str,
|
|
142
|
+
section: str,
|
|
143
|
+
name: str,
|
|
144
|
+
definition: object,
|
|
145
|
+
*,
|
|
146
|
+
dry_run: bool = False,
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
"""Create or update an entry of any section (checksums, enums, interfaces, ...)."""
|
|
149
|
+
if section not in SECTIONS:
|
|
150
|
+
message = f"section must be one of {', '.join(SECTIONS)}"
|
|
151
|
+
raise ServiceError(message)
|
|
152
|
+
options = EditOptions(dry_run=dry_run)
|
|
153
|
+
pointer = f"/{section}/{name}"
|
|
154
|
+
return service.set_value(self.project_dir(project), pointer, definition, options).to_dict()
|
|
155
|
+
|
|
156
|
+
def validate_project(self, project: str) -> dict[str, Any]:
|
|
157
|
+
"""Validate a project; issues carry file, line and JSON pointer."""
|
|
158
|
+
return service.validate(self.project_dir(project)).to_dict()
|
|
159
|
+
|
|
160
|
+
def generate_code(self, project: str, *, tests: bool = True) -> dict[str, Any]:
|
|
161
|
+
"""Generate the C++ code (and the GoogleTest project) below the workspace output."""
|
|
162
|
+
directory = self.project_dir(project)
|
|
163
|
+
options = test_project.TestOptions(plugins=service.find_plugins(directory))
|
|
164
|
+
result = service.generate_code(directory, self.output, options if tests else None)
|
|
165
|
+
name = str(result.data.get("project", ""))
|
|
166
|
+
target = self.output / name
|
|
167
|
+
files = sorted(
|
|
168
|
+
path.relative_to(target).as_posix() for path in target.rglob("*") if path.is_file()
|
|
169
|
+
)
|
|
170
|
+
result.data["files"] = files if result.ok else []
|
|
171
|
+
return result.to_dict()
|
|
172
|
+
|
|
173
|
+
def run_tests(
|
|
174
|
+
self, project: str, *, coverage: bool = False, coverage_runtime: bool = False
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
"""Generate, build and run the generated tests (with `coverage` the coverage target).
|
|
177
|
+
|
|
178
|
+
`coverage_runtime` adds the runtime headers to the coverage report. The results are written
|
|
179
|
+
below the returned `build_dir`, which `lifecycle_docs` accepts as `results_dir`.
|
|
180
|
+
"""
|
|
181
|
+
directory = self.project_dir(project)
|
|
182
|
+
options = test_project.TestOptions(plugins=service.find_plugins(directory))
|
|
183
|
+
build = service.BuildOptions(
|
|
184
|
+
coverage=coverage or coverage_runtime, coverage_runtime=coverage_runtime
|
|
185
|
+
)
|
|
186
|
+
result = service.run_tests(directory, self.output, options, build)
|
|
187
|
+
return result.to_dict()
|
|
188
|
+
|
|
189
|
+
def wireshark_dissector(self, project: str) -> dict[str, Any]:
|
|
190
|
+
"""Write the Wireshark Lua dissector plugin of a project below the workspace output."""
|
|
191
|
+
target = self.output / "wireshark" / f"{project}.lua"
|
|
192
|
+
return service.wireshark_dissector(self.project_dir(project), target).to_dict()
|
|
193
|
+
|
|
194
|
+
def lifecycle_docs(
|
|
195
|
+
self,
|
|
196
|
+
project: str,
|
|
197
|
+
*,
|
|
198
|
+
strict: bool = False,
|
|
199
|
+
results_dir: str | None = None,
|
|
200
|
+
rule_findings: list[str] | None = None,
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
"""Write the requirements, design, test and traceability documents of a project.
|
|
203
|
+
|
|
204
|
+
`results_dir` is the `build_dir` that `run_tests` returned; it must lie below the workspace
|
|
205
|
+
output, so a tool call cannot read other places. `rule_findings` lists SARIF or cppcheck
|
|
206
|
+
XML reports of your coding-rule checker; they must lie inside the workspace.
|
|
207
|
+
"""
|
|
208
|
+
reports = []
|
|
209
|
+
for name in rule_findings or []:
|
|
210
|
+
report = Path(name).resolve()
|
|
211
|
+
if self.workspace.resolve() not in report.parents:
|
|
212
|
+
message = "rule_findings must be files inside the workspace"
|
|
213
|
+
raise ServiceError(message)
|
|
214
|
+
reports.append(report)
|
|
215
|
+
target = self.output.parent / "docs" / project
|
|
216
|
+
results = None
|
|
217
|
+
if results_dir is not None:
|
|
218
|
+
results = Path(results_dir).resolve()
|
|
219
|
+
if self.output.resolve() not in results.parents:
|
|
220
|
+
message = "results_dir must be a build directory below the workspace output"
|
|
221
|
+
raise ServiceError(message)
|
|
222
|
+
options = service.DocsOptions(rule_findings=tuple(reports))
|
|
223
|
+
return service.lifecycle_docs(
|
|
224
|
+
self.project_dir(project), target, strict=strict, results=results, options=options
|
|
225
|
+
).to_dict()
|
|
226
|
+
|
|
227
|
+
def requirements_import(
|
|
228
|
+
self, project: str, path: str, baseline: str, tool: str | None = None
|
|
229
|
+
) -> dict[str, Any]:
|
|
230
|
+
"""Import the high-level requirements of a ReqIF or CSV file inside the workspace."""
|
|
231
|
+
source = Path(path).resolve()
|
|
232
|
+
if self.workspace.resolve() not in source.parents:
|
|
233
|
+
message = "path must be a file inside the workspace"
|
|
234
|
+
raise ServiceError(message)
|
|
235
|
+
options = hlr_import.ImportOptions(baseline, tool)
|
|
236
|
+
directory = self.project_dir(project)
|
|
237
|
+
return service.requirements_import(directory, source, options).to_dict()
|
|
238
|
+
|
|
239
|
+
def requirements_lock(self, project: str) -> dict[str, Any]:
|
|
240
|
+
"""Write llr_ids.lock, which numbers the low-level requirements (append-only)."""
|
|
241
|
+
return service.requirements_lock(self.project_dir(project)).to_dict()
|
|
242
|
+
|
|
243
|
+
def analyze_layout(
|
|
244
|
+
self, project: str, profile: str | None = None, export: str = "markdown"
|
|
245
|
+
) -> dict[str, Any]:
|
|
246
|
+
"""Return the layout report: sizes, offsets, bit maps, frame sizes and link RAM."""
|
|
247
|
+
if export not in {"markdown", "csv", "json"}:
|
|
248
|
+
message = "export must be markdown, csv or json"
|
|
249
|
+
raise ServiceError(message)
|
|
250
|
+
return service.analyze(self.project_dir(project), profile, export).to_dict()
|
|
251
|
+
|
|
252
|
+
def compat_check(
|
|
253
|
+
self, project: str, old_project: str | None = None, revision: str | None = None
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
"""Compare an older version (another project or a git revision) with the project."""
|
|
256
|
+
directory = self.project_dir(project)
|
|
257
|
+
if old_project is not None:
|
|
258
|
+
return service.compatibility(self.project_dir(old_project), directory).to_dict()
|
|
259
|
+
if revision is None or revision.startswith("-"):
|
|
260
|
+
message = "give old_project or a git revision"
|
|
261
|
+
raise ServiceError(message)
|
|
262
|
+
return service.compatibility_with_revision(directory, revision).to_dict()
|
|
263
|
+
|
|
264
|
+
def _target(
|
|
265
|
+
self, directory: Path, message: str | None, interface: str | None, profile: str | None
|
|
266
|
+
) -> CodecTarget:
|
|
267
|
+
plugins = service.load_plugins(service.find_plugins(directory))
|
|
268
|
+
return CodecTarget(message, interface, profile, plugins)
|
|
269
|
+
|
|
270
|
+
def encode_sample(
|
|
271
|
+
self,
|
|
272
|
+
project: str,
|
|
273
|
+
message: str,
|
|
274
|
+
value: dict[str, Any] | None = None,
|
|
275
|
+
interface: str | None = None,
|
|
276
|
+
profile: str | None = None,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
"""Encode a message value; without `value` a sample with every field set is used."""
|
|
279
|
+
directory = self.project_dir(project)
|
|
280
|
+
target = self._target(directory, message, interface, profile)
|
|
281
|
+
if value is None:
|
|
282
|
+
model = load_project(directory).project
|
|
283
|
+
if model is None or message not in model.messages:
|
|
284
|
+
text = f"no message {message!r}"
|
|
285
|
+
raise ServiceError(text)
|
|
286
|
+
codec = ReferenceCodec(model, profile, target.plugins)
|
|
287
|
+
value = golden.Sampler(codec).struct(model.messages[message].fields)
|
|
288
|
+
result = service.encode(directory, value, target)
|
|
289
|
+
result.data["value"] = value
|
|
290
|
+
return result.to_dict()
|
|
291
|
+
|
|
292
|
+
def decode_hex(
|
|
293
|
+
self,
|
|
294
|
+
project: str,
|
|
295
|
+
hex_bytes: str,
|
|
296
|
+
message: str | None = None,
|
|
297
|
+
interface: str | None = None,
|
|
298
|
+
profile: str | None = None,
|
|
299
|
+
) -> dict[str, Any]:
|
|
300
|
+
"""Decode hex bytes: a payload with `message`, otherwise a complete frame of `interface`."""
|
|
301
|
+
directory = self.project_dir(project)
|
|
302
|
+
try:
|
|
303
|
+
data = bytes.fromhex(hex_bytes)
|
|
304
|
+
except ValueError as error:
|
|
305
|
+
text = f"{hex_bytes!r} is not hex"
|
|
306
|
+
raise ServiceError(text) from error
|
|
307
|
+
return service.decode(
|
|
308
|
+
directory, data, self._target(directory, message, interface, profile)
|
|
309
|
+
).to_dict()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
TOOL_NAMES = (
|
|
313
|
+
"list_projects",
|
|
314
|
+
"create_project",
|
|
315
|
+
"list_messages",
|
|
316
|
+
"get_message",
|
|
317
|
+
"upsert_struct",
|
|
318
|
+
"upsert_entry",
|
|
319
|
+
"validate_project",
|
|
320
|
+
"generate_code",
|
|
321
|
+
"run_tests",
|
|
322
|
+
"analyze_layout",
|
|
323
|
+
"wireshark_dissector",
|
|
324
|
+
"lifecycle_docs",
|
|
325
|
+
"requirements_import",
|
|
326
|
+
"requirements_lock",
|
|
327
|
+
"compat_check",
|
|
328
|
+
"encode_sample",
|
|
329
|
+
"decode_hex",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _guarded(function: Callable[..., dict[str, Any]]) -> Callable[..., dict[str, Any]]:
|
|
334
|
+
"""Report service errors (bad names, missing projects, ...) as tool errors."""
|
|
335
|
+
|
|
336
|
+
@functools.wraps(function)
|
|
337
|
+
def call(*args: object, **kwargs: object) -> dict[str, Any]:
|
|
338
|
+
try:
|
|
339
|
+
return function(*args, **kwargs)
|
|
340
|
+
except (ServiceError, OSError, ValueError) as error:
|
|
341
|
+
raise ToolError(str(error)) from error
|
|
342
|
+
|
|
343
|
+
return call
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def create_server(workspace: Path) -> MCPServer:
|
|
347
|
+
"""Return the MCP server with every tool bound to `workspace`."""
|
|
348
|
+
server = MCPServer("wireforge", instructions=INSTRUCTIONS)
|
|
349
|
+
tools = Tools(workspace)
|
|
350
|
+
for name in TOOL_NAMES:
|
|
351
|
+
server.add_tool(_guarded(getattr(tools, name)), name=name)
|
|
352
|
+
return server
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def main(workspace: Path) -> None:
|
|
356
|
+
"""Run the server on stdio."""
|
|
357
|
+
create_server(workspace).run("stdio")
|
wireforge/model.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""In-memory project model built by `wireforge.loader`.
|
|
2
|
+
|
|
3
|
+
The model keeps the values as written (expressions stay strings) together with their source
|
|
4
|
+
location, so the validator and later stages can report `file:line: pointer` for every element.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from wireforge.issues import Issue
|
|
13
|
+
|
|
14
|
+
# Wire width in bits of every builtin type.
|
|
15
|
+
BUILTIN_BITS = {
|
|
16
|
+
"bool": 8,
|
|
17
|
+
"char": 8,
|
|
18
|
+
"u8": 8,
|
|
19
|
+
"u16": 16,
|
|
20
|
+
"u32": 32,
|
|
21
|
+
"u64": 64,
|
|
22
|
+
"i8": 8,
|
|
23
|
+
"i16": 16,
|
|
24
|
+
"i32": 32,
|
|
25
|
+
"i64": 64,
|
|
26
|
+
"f32": 32,
|
|
27
|
+
"f64": 64,
|
|
28
|
+
}
|
|
29
|
+
UNSIGNED_TYPES = frozenset({"u8", "u16", "u32", "u64"})
|
|
30
|
+
SIGNED_TYPES = frozenset({"i8", "i16", "i32", "i64"})
|
|
31
|
+
INTEGER_TYPES = UNSIGNED_TYPES | SIGNED_TYPES
|
|
32
|
+
FLOAT_TYPES = frozenset({"f32", "f64"})
|
|
33
|
+
|
|
34
|
+
# Raw value of a model attribute that accepts a number or an expression string.
|
|
35
|
+
Value = int | float | str
|
|
36
|
+
Scalar = int | float | bool | str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Source:
|
|
41
|
+
"""Where an element was written."""
|
|
42
|
+
|
|
43
|
+
file: str
|
|
44
|
+
line: int | None
|
|
45
|
+
pointer: str
|
|
46
|
+
|
|
47
|
+
def issue(self, rule: str, message: str, suffix: str = "") -> Issue:
|
|
48
|
+
"""Error located at this element (optionally at a sub-pointer)."""
|
|
49
|
+
return Issue(self.file, self.line, self.pointer + suffix, f"[{rule}] {message}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Occurs:
|
|
54
|
+
"""How often a field occurs: optional (max 1), fixed or counted array, or payload fill."""
|
|
55
|
+
|
|
56
|
+
max: Value
|
|
57
|
+
min: Value | None = None
|
|
58
|
+
count: Value | None = None
|
|
59
|
+
fill: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ChecksumUse:
|
|
64
|
+
"""`checksum: {use, covers}` of a field."""
|
|
65
|
+
|
|
66
|
+
use: str
|
|
67
|
+
scope: str | None = None
|
|
68
|
+
first: str | None = None
|
|
69
|
+
last: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Field:
|
|
74
|
+
"""One field of a struct, message, header or trailer."""
|
|
75
|
+
|
|
76
|
+
name: str
|
|
77
|
+
type: str
|
|
78
|
+
source: Source
|
|
79
|
+
bits: int | None = None
|
|
80
|
+
min: Value | None = None
|
|
81
|
+
max: Value | None = None
|
|
82
|
+
default: Scalar | list[Scalar] | None = None
|
|
83
|
+
const: Scalar | None = None
|
|
84
|
+
reserved: bool = False
|
|
85
|
+
occurs: Occurs | None = None
|
|
86
|
+
checksum: ChecksumUse | None = None
|
|
87
|
+
role: str | None = None
|
|
88
|
+
condition: str | None = None
|
|
89
|
+
description: str | None = None
|
|
90
|
+
units: str | None = None
|
|
91
|
+
reqs: tuple[str, ...] = ()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class MessageInfo:
|
|
96
|
+
"""Attributes only messages carry."""
|
|
97
|
+
|
|
98
|
+
id: int | None = None
|
|
99
|
+
endian: str | None = None
|
|
100
|
+
bit_order: str | None = None
|
|
101
|
+
range_check: dict[str, bool] | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class Struct:
|
|
106
|
+
"""A `types` entry, or a message when `message` is set."""
|
|
107
|
+
|
|
108
|
+
name: str
|
|
109
|
+
fields: tuple[Field, ...]
|
|
110
|
+
source: Source
|
|
111
|
+
condition: str | None = None
|
|
112
|
+
message: MessageInfo | None = None
|
|
113
|
+
description: str | None = None
|
|
114
|
+
reqs: tuple[str, ...] = ()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class Enum:
|
|
119
|
+
"""Enumeration with an integer underlying type."""
|
|
120
|
+
|
|
121
|
+
name: str
|
|
122
|
+
type: str
|
|
123
|
+
values: dict[str, int]
|
|
124
|
+
source: Source
|
|
125
|
+
reqs: tuple[str, ...] = ()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Checksum:
|
|
130
|
+
"""Checksum algorithm declaration."""
|
|
131
|
+
|
|
132
|
+
name: str
|
|
133
|
+
algo: str
|
|
134
|
+
source: Source
|
|
135
|
+
width: int | None = None
|
|
136
|
+
poly: int | None = None
|
|
137
|
+
init: int = 0
|
|
138
|
+
refin: bool = False
|
|
139
|
+
refout: bool = False
|
|
140
|
+
xorout: int = 0
|
|
141
|
+
check: int | None = None
|
|
142
|
+
impl: str | None = None
|
|
143
|
+
include: str | None = None
|
|
144
|
+
size_bytes: int | None = None
|
|
145
|
+
reqs: tuple[str, ...] = ()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class Framing:
|
|
150
|
+
"""Framing of one interface."""
|
|
151
|
+
|
|
152
|
+
kind: str = "none"
|
|
153
|
+
frame_size: Value | None = None
|
|
154
|
+
max_frame_size: Value | None = None
|
|
155
|
+
length_counts: str = "payload"
|
|
156
|
+
resync: bool = False
|
|
157
|
+
reqs: tuple[str, ...] = ()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class InterfaceMessage:
|
|
162
|
+
"""Entry of an interface `messages` list."""
|
|
163
|
+
|
|
164
|
+
name: str
|
|
165
|
+
source: Source
|
|
166
|
+
id: int | None = None
|
|
167
|
+
condition: str | None = None
|
|
168
|
+
reqs: tuple[str, ...] = ()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class Interface:
|
|
173
|
+
"""One communication endpoint. `header` is None for `header: none`."""
|
|
174
|
+
|
|
175
|
+
name: str
|
|
176
|
+
version: str
|
|
177
|
+
direction: str
|
|
178
|
+
source: Source
|
|
179
|
+
messages: tuple[InterfaceMessage, ...]
|
|
180
|
+
header: tuple[Field, ...] | None = None
|
|
181
|
+
trailer: tuple[Field, ...] = ()
|
|
182
|
+
framing: Framing = field(default_factory=Framing)
|
|
183
|
+
buffers: dict[str, Value] = field(default_factory=dict)
|
|
184
|
+
endian: str | None = None
|
|
185
|
+
bit_order: str | None = None
|
|
186
|
+
allow_trailing_bytes: bool = False
|
|
187
|
+
description: str | None = None
|
|
188
|
+
reqs: tuple[str, ...] = ()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class Requirement:
|
|
193
|
+
"""High-level requirement of the user's catalog (`requirements` section)."""
|
|
194
|
+
|
|
195
|
+
id: str
|
|
196
|
+
text: str
|
|
197
|
+
source: Source
|
|
198
|
+
reference: str | None = None
|
|
199
|
+
verification: str = "test"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class Vector:
|
|
204
|
+
"""A requirement test vector: bytes from the ICD and what they must decode to or encode from.
|
|
205
|
+
|
|
206
|
+
`data` is None when `text` is not valid hex (reported by the validator).
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
name: str
|
|
210
|
+
message: str
|
|
211
|
+
text: str
|
|
212
|
+
data: bytes | None
|
|
213
|
+
source: Source
|
|
214
|
+
interface: str | None = None
|
|
215
|
+
profile: str | None = None
|
|
216
|
+
fields: dict[str, Any] | None = None
|
|
217
|
+
status: str | None = None
|
|
218
|
+
direction: str = "decode"
|
|
219
|
+
reqs: tuple[str, ...] = ()
|
|
220
|
+
description: str | None = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True)
|
|
224
|
+
class Profile:
|
|
225
|
+
"""Named set of define values."""
|
|
226
|
+
|
|
227
|
+
name: str
|
|
228
|
+
defines: dict[str, int]
|
|
229
|
+
source: Source
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class UserConstant:
|
|
234
|
+
"""Integer constant owned by application code."""
|
|
235
|
+
|
|
236
|
+
name: str
|
|
237
|
+
cpp: str
|
|
238
|
+
source: Source
|
|
239
|
+
value: int | None = None
|
|
240
|
+
profile_values: dict[str, int] = field(default_factory=dict)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True)
|
|
244
|
+
class UserType:
|
|
245
|
+
"""A field type whose wire form is opaque.
|
|
246
|
+
|
|
247
|
+
`size_bytes` raw bytes, backed by the user's own C++ type and its own `encode`/`decode` free
|
|
248
|
+
functions (found by ADL, like a generated struct).
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
name: str
|
|
252
|
+
cpp: str
|
|
253
|
+
size_bytes: int
|
|
254
|
+
source: Source
|
|
255
|
+
include: str | None = None
|
|
256
|
+
style: str = "quote"
|
|
257
|
+
description: str | None = None
|
|
258
|
+
reqs: tuple[str, ...] = ()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True)
|
|
262
|
+
class UserHeader:
|
|
263
|
+
"""Header included by the generated user prelude."""
|
|
264
|
+
|
|
265
|
+
path: str
|
|
266
|
+
source: Source
|
|
267
|
+
style: str = "quote"
|
|
268
|
+
condition: str | None = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True)
|
|
272
|
+
class IdMap:
|
|
273
|
+
"""Numbers of the low-level requirements (`llr_ids.lock`): key -> displayed id.
|
|
274
|
+
|
|
275
|
+
`retired` keeps the numbers of requirements that no longer exist; a number is never reused.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
format: str
|
|
279
|
+
next: int
|
|
280
|
+
ids: dict[str, str]
|
|
281
|
+
retired: dict[str, str]
|
|
282
|
+
source: Source
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True)
|
|
286
|
+
class Define:
|
|
287
|
+
"""Named integer constant emitted as an overridable macro."""
|
|
288
|
+
|
|
289
|
+
name: str
|
|
290
|
+
value: int
|
|
291
|
+
source: Source
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass
|
|
295
|
+
class Project:
|
|
296
|
+
"""A loaded project: root file plus merged fragments."""
|
|
297
|
+
|
|
298
|
+
name: str
|
|
299
|
+
source: Source
|
|
300
|
+
version: str | None = None
|
|
301
|
+
namespace: str | None = None
|
|
302
|
+
defaults: dict[str, object] = field(default_factory=dict)
|
|
303
|
+
requirements: dict[str, Requirement] = field(default_factory=dict)
|
|
304
|
+
defines: dict[str, Define] = field(default_factory=dict)
|
|
305
|
+
profiles: dict[str, Profile] = field(default_factory=dict)
|
|
306
|
+
checksums: dict[str, Checksum] = field(default_factory=dict)
|
|
307
|
+
enums: dict[str, Enum] = field(default_factory=dict)
|
|
308
|
+
types: dict[str, Struct] = field(default_factory=dict)
|
|
309
|
+
user_types: dict[str, UserType] = field(default_factory=dict)
|
|
310
|
+
messages: dict[str, Struct] = field(default_factory=dict)
|
|
311
|
+
interfaces: dict[str, Interface] = field(default_factory=dict)
|
|
312
|
+
vectors: dict[str, Vector] = field(default_factory=dict)
|
|
313
|
+
constants: dict[str, UserConstant] = field(default_factory=dict)
|
|
314
|
+
llr_ids: IdMap | None = None
|
|
315
|
+
requirements_source: dict[str, str] | None = None
|
|
316
|
+
headers: list[UserHeader] = field(default_factory=list)
|
|
317
|
+
include_dirs: list[tuple[str, Source]] = field(default_factory=list)
|
|
318
|
+
files: list[str] = field(default_factory=list)
|
|
319
|
+
|
|
320
|
+
def struct(self, name: str) -> Struct | None:
|
|
321
|
+
"""Return the `types` entry with this name (messages are not usable as field types)."""
|
|
322
|
+
return self.types.get(name)
|
wireforge/plain.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Plain Python values from the objects ruamel.yaml loads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def to_plain(value: object) -> object:
|
|
7
|
+
"""Convert ruamel scalar subclasses (HexInt, ScalarFloat, ...) to plain Python values."""
|
|
8
|
+
if isinstance(value, list):
|
|
9
|
+
return [to_plain(item) for item in value]
|
|
10
|
+
if isinstance(value, dict):
|
|
11
|
+
return {str(key): to_plain(item) for key, item in value.items()}
|
|
12
|
+
for kind in (bool, int, float, str):
|
|
13
|
+
if isinstance(value, kind):
|
|
14
|
+
return kind(value)
|
|
15
|
+
return value
|
wireforge/py.typed
ADDED
|
File without changes
|