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/vcs.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""What git says about a project directory: commit, repository root and local changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
GIT_TIMEOUT_SECONDS = 20
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _git(directory: Path, *arguments: str) -> str | None:
|
|
13
|
+
git = shutil.which("git")
|
|
14
|
+
if git is None:
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
done = subprocess.run(
|
|
18
|
+
[git, *arguments],
|
|
19
|
+
cwd=directory,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
check=False,
|
|
23
|
+
timeout=GIT_TIMEOUT_SECONDS,
|
|
24
|
+
)
|
|
25
|
+
except (OSError, subprocess.SubprocessError):
|
|
26
|
+
return None
|
|
27
|
+
return done.stdout.strip() if done.returncode == 0 else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def commit(directory: Path) -> str | None:
|
|
31
|
+
"""Return the commit the working copy is at (None outside a repository)."""
|
|
32
|
+
return _git(directory, "rev-parse", "HEAD")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def root(directory: Path) -> Path | None:
|
|
36
|
+
"""Return the top directory of the repository."""
|
|
37
|
+
top = _git(directory, "rev-parse", "--show-toplevel")
|
|
38
|
+
return Path(top).resolve() if top else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def dirty(directory: Path) -> bool:
|
|
42
|
+
"""Return whether files below `directory` differ from the commit (untracked ones count)."""
|
|
43
|
+
changes = _git(directory, "status", "--porcelain", "--", ".")
|
|
44
|
+
return bool(changes)
|
wireforge/vectors.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Requirement test vectors: the user's expectations checked against the reference codec.
|
|
2
|
+
|
|
3
|
+
A vector is bytes from the ICD with what they must decode to (or what a value must encode to).
|
|
4
|
+
`run()` plays every vector through the reference codec in each profile it applies to and `check()`
|
|
5
|
+
reports the disagreements as SR-22, so a wrong vector or a wrong model is found before code is
|
|
6
|
+
generated. The codec never replaces the user's expectation: the generated C++ test compares with
|
|
7
|
+
what the vector says, not with what the codec answered.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import struct
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from wireforge import expr
|
|
17
|
+
from wireforge.codec import CodecError, ReferenceCodec
|
|
18
|
+
from wireforge.expr import ExprError
|
|
19
|
+
from wireforge.issues import Issue, errors_only
|
|
20
|
+
from wireforge.loader import load_project
|
|
21
|
+
from wireforge.validate import Validator
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from wireforge.codec import ChecksumPlugin
|
|
27
|
+
from wireforge.model import Field, Project, Struct, Vector
|
|
28
|
+
|
|
29
|
+
OK = "Ok"
|
|
30
|
+
NO_PLUGIN = "no plugin for custom checksum"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class VectorRun:
|
|
35
|
+
"""One vector played through the reference codec in one profile.
|
|
36
|
+
|
|
37
|
+
`status` is the codec's answer in `phase` (`decode` or `encode`): the first direction that
|
|
38
|
+
failed, or the last one that ran when all agree (`Ok`). `decoded` is the decoded value and
|
|
39
|
+
`encoded` the produced bytes.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
vector: Vector
|
|
43
|
+
profile: str | None
|
|
44
|
+
phase: str
|
|
45
|
+
status: str
|
|
46
|
+
detail: str
|
|
47
|
+
decoded: dict[str, Any] | None
|
|
48
|
+
encoded: bytes | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def profiles_of(project: Project, vector: Vector) -> list[str | None]:
|
|
52
|
+
"""Return the profiles a vector applies to (all of them unless the vector names one)."""
|
|
53
|
+
if vector.profile is not None:
|
|
54
|
+
return [vector.profile]
|
|
55
|
+
return list(project.profiles) or [None]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def f32(value: float) -> float:
|
|
59
|
+
"""Round a number to the nearest 32-bit float."""
|
|
60
|
+
return float(struct.unpack(">f", struct.pack(">f", value))[0])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _holds(codec: ReferenceCodec, message: Struct) -> bool:
|
|
64
|
+
if message.condition is None:
|
|
65
|
+
return True
|
|
66
|
+
try:
|
|
67
|
+
return expr.evaluate(expr.parse_condition(message.condition), codec.variant.scope) != 0
|
|
68
|
+
except ExprError:
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _decode(codec: ReferenceCodec, vector: Vector, data: bytes) -> tuple[str, str, Any]:
|
|
73
|
+
try:
|
|
74
|
+
if vector.interface is None:
|
|
75
|
+
return OK, "", codec.decode_message(vector.message, data)
|
|
76
|
+
frame = codec.decode_frame(vector.interface, data)
|
|
77
|
+
except CodecError as error:
|
|
78
|
+
return error.status, str(error), None
|
|
79
|
+
if frame.message != vector.message:
|
|
80
|
+
return "UnknownMessageId", f"the frame carries message {frame.message}", None
|
|
81
|
+
return OK, "", frame.value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _encode(codec: ReferenceCodec, vector: Vector) -> tuple[str, str, bytes | None]:
|
|
85
|
+
value = vector.fields or {}
|
|
86
|
+
try:
|
|
87
|
+
if vector.interface is None:
|
|
88
|
+
return OK, "", codec.encode_message(vector.message, value)
|
|
89
|
+
return OK, "", codec.encode_frame(vector.interface, vector.message, value)
|
|
90
|
+
except CodecError as error:
|
|
91
|
+
return error.status, str(error), None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run(project: Project, plugins: dict[str, ChecksumPlugin] | None = None) -> list[VectorRun]:
|
|
95
|
+
"""Play every well-formed vector through the reference codec, once per profile."""
|
|
96
|
+
runs: list[VectorRun] = []
|
|
97
|
+
for vector in project.vectors.values():
|
|
98
|
+
message = project.messages.get(vector.message)
|
|
99
|
+
known = vector.interface is None or vector.interface in project.interfaces
|
|
100
|
+
if message is None or vector.data is None or not known:
|
|
101
|
+
continue # the validator reports it (SR-22)
|
|
102
|
+
for profile in profiles_of(project, vector):
|
|
103
|
+
if profile is not None and profile not in project.profiles:
|
|
104
|
+
continue
|
|
105
|
+
codec = ReferenceCodec(project, profile, plugins)
|
|
106
|
+
if not _holds(codec, message):
|
|
107
|
+
continue
|
|
108
|
+
runs.append(_play(codec, vector, vector.data, profile))
|
|
109
|
+
return runs
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _play(codec: ReferenceCodec, vector: Vector, data: bytes, profile: str | None) -> VectorRun:
|
|
113
|
+
phase, status, detail, decoded, encoded = "decode", OK, "", None, None
|
|
114
|
+
if vector.direction in {"decode", "both"}:
|
|
115
|
+
status, detail, decoded = _decode(codec, vector, data)
|
|
116
|
+
if vector.direction in {"encode", "both"} and status == OK:
|
|
117
|
+
phase = "encode"
|
|
118
|
+
status, detail, encoded = _encode(codec, vector)
|
|
119
|
+
return VectorRun(vector, profile, phase, status, detail, decoded, encoded)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- comparison --------------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _expected_value(project: Project, item: Field, value: object) -> object:
|
|
126
|
+
"""Round the floats the way the codec does (f32 fields hold 32-bit values)."""
|
|
127
|
+
inner = project.types.get(item.type)
|
|
128
|
+
if isinstance(value, list):
|
|
129
|
+
return [_expected_value(project, _single(item), element) for element in value]
|
|
130
|
+
if inner is not None and isinstance(value, dict):
|
|
131
|
+
return _expected_fields(project, inner, value)
|
|
132
|
+
if item.type == "f32" and isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
133
|
+
return f32(float(value))
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _single(item: Field) -> Field:
|
|
138
|
+
"""Return the field as one element (arrays are compared element by element)."""
|
|
139
|
+
return type(item)(item.name, item.type, item.source)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _expected_fields(project: Project, struct_: Struct, values: dict[str, Any]) -> dict[str, Any]:
|
|
143
|
+
by_name = {item.name: item for item in struct_.fields}
|
|
144
|
+
return {
|
|
145
|
+
name: _expected_value(project, by_name[name], value) if name in by_name else value
|
|
146
|
+
for name, value in values.items()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _differences(expected: object, actual: object, path: str) -> list[str]:
|
|
151
|
+
"""List where `actual` differs from `expected` (a mapping is compared by its keys only)."""
|
|
152
|
+
if isinstance(expected, dict):
|
|
153
|
+
if not isinstance(actual, dict):
|
|
154
|
+
return [f"{path or 'value'} is {actual!r}, expected {expected!r}"]
|
|
155
|
+
found: list[str] = []
|
|
156
|
+
for key, value in expected.items():
|
|
157
|
+
if key not in actual:
|
|
158
|
+
found.append(f"{path}{key} is absent, expected {value!r}")
|
|
159
|
+
else:
|
|
160
|
+
found += _differences(value, actual[key], f"{path}{key}.")
|
|
161
|
+
return found
|
|
162
|
+
if isinstance(expected, list):
|
|
163
|
+
if not isinstance(actual, list) or len(actual) != len(expected):
|
|
164
|
+
size = len(actual) if isinstance(actual, list) else actual
|
|
165
|
+
return [f"{path.rstrip('.')} has {size} elements, expected {len(expected)}"]
|
|
166
|
+
found = []
|
|
167
|
+
for index, value in enumerate(expected):
|
|
168
|
+
found += _differences(value, actual[index], f"{path.rstrip('.')}[{index}].")
|
|
169
|
+
return found
|
|
170
|
+
if expected != actual or isinstance(expected, bool) != isinstance(actual, bool):
|
|
171
|
+
return [f"{path.rstrip('.')} is {actual!r}, expected {expected!r}"]
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _problems(project: Project, item: VectorRun) -> list[str]:
|
|
176
|
+
vector = item.vector
|
|
177
|
+
if vector.status is not None:
|
|
178
|
+
if item.status == vector.status:
|
|
179
|
+
return []
|
|
180
|
+
return [f"decodes with status {item.status}, expected {vector.status}"]
|
|
181
|
+
if item.status != OK:
|
|
182
|
+
word = "decodes" if item.phase == "decode" else "encodes"
|
|
183
|
+
return [f"{word} with status {item.status}, expected Ok"]
|
|
184
|
+
problems: list[str] = []
|
|
185
|
+
if item.decoded is not None and vector.fields is not None:
|
|
186
|
+
message = project.messages[vector.message]
|
|
187
|
+
expected = _expected_fields(project, message, vector.fields)
|
|
188
|
+
problems += [f"field {text}" for text in _differences(expected, item.decoded, "")]
|
|
189
|
+
if item.encoded is not None and item.encoded != vector.data:
|
|
190
|
+
problems.append(f"encodes to {item.encoded.hex(' ')}, expected {vector.text.strip()}")
|
|
191
|
+
return problems
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check(project: Project, plugins: dict[str, ChecksumPlugin] | None = None) -> list[Issue]:
|
|
195
|
+
"""Return SR-22 issues where the codec and a vector disagree.
|
|
196
|
+
|
|
197
|
+
Without plugins a vector that needs a custom checksum is skipped with a warning.
|
|
198
|
+
"""
|
|
199
|
+
issues: list[Issue] = []
|
|
200
|
+
for item in run(project, plugins):
|
|
201
|
+
source = item.vector.source
|
|
202
|
+
label = f"vector {item.vector.name}"
|
|
203
|
+
label += f" (profile {item.profile})" if item.profile else ""
|
|
204
|
+
if NO_PLUGIN in item.detail:
|
|
205
|
+
text = f"[SR-22] {label}: skipped, a custom checksum needs its plugin"
|
|
206
|
+
issues.append(Issue(source.file, source.line, source.pointer, text, "warning"))
|
|
207
|
+
continue
|
|
208
|
+
issues += [source.issue("SR-22", f"{label}: {text}") for text in _problems(project, item)]
|
|
209
|
+
return issues
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def validate_project(
|
|
213
|
+
location: Path, plugins: dict[str, ChecksumPlugin] | None = None
|
|
214
|
+
) -> list[Issue]:
|
|
215
|
+
"""Load a project and return its structural, semantic and vector issues."""
|
|
216
|
+
result = load_project(location)
|
|
217
|
+
if result.project is None:
|
|
218
|
+
return result.issues
|
|
219
|
+
issues = result.issues + Validator(result.project).run()
|
|
220
|
+
if not errors_only(issues):
|
|
221
|
+
issues += check(result.project, plugins)
|
|
222
|
+
return issues
|
wireforge/versioning.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""File-format identity, compatibility policy and migrations.
|
|
2
|
+
|
|
3
|
+
Policy (rule SR-19):
|
|
4
|
+
- major newer than this tool supports -> error (upgrade WireForge)
|
|
5
|
+
- major older -> error unless imported through `migrate_document`
|
|
6
|
+
- same major, minor newer than supported -> error
|
|
7
|
+
- generator newer than this tool -> warning only
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from wireforge import __version__
|
|
17
|
+
from wireforge.issues import Issue
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable, Iterator, MutableMapping
|
|
21
|
+
|
|
22
|
+
FORMAT_MAJOR = 1
|
|
23
|
+
FORMAT_MINOR = 2
|
|
24
|
+
GENERATOR_NAME = "wireforge"
|
|
25
|
+
TOOL_VERSION = __version__
|
|
26
|
+
|
|
27
|
+
_MIGRATIONS: dict[int, Callable[[MutableMapping[str, object]], None]] = {}
|
|
28
|
+
_SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register_migration(
|
|
32
|
+
from_major: int, step: Callable[[MutableMapping[str, object]], None]
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Register the step upgrading a document from `from_major` to `from_major + 1` in place."""
|
|
35
|
+
_MIGRATIONS[from_major] = step
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@contextmanager
|
|
39
|
+
def temporary_migration(
|
|
40
|
+
from_major: int, step: Callable[[MutableMapping[str, object]], None]
|
|
41
|
+
) -> Iterator[None]:
|
|
42
|
+
"""Register a migration for the duration of a `with` block (tests and tooling)."""
|
|
43
|
+
previous = _MIGRATIONS.get(from_major)
|
|
44
|
+
_MIGRATIONS[from_major] = step
|
|
45
|
+
try:
|
|
46
|
+
yield
|
|
47
|
+
finally:
|
|
48
|
+
if previous is None:
|
|
49
|
+
_MIGRATIONS.pop(from_major, None)
|
|
50
|
+
else:
|
|
51
|
+
_MIGRATIONS[from_major] = previous
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def migration_path(from_major: int) -> list[int] | None:
|
|
55
|
+
"""Majors whose steps must run to reach FORMAT_MAJOR, or None when a step is missing."""
|
|
56
|
+
path = list(range(from_major, FORMAT_MAJOR))
|
|
57
|
+
return path if all(major in _MIGRATIONS for major in path) else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _semver_tuple(text: object) -> tuple[int, int, int] | None:
|
|
61
|
+
match = _SEMVER.match(text) if isinstance(text, str) else None
|
|
62
|
+
return None if match is None else (int(match[1]), int(match[2]), int(match[3]))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check_document(
|
|
66
|
+
document: MutableMapping[str, object], *, file: str, line: int | None = None
|
|
67
|
+
) -> list[Issue]:
|
|
68
|
+
"""Apply the compatibility policy to the version stamp of one document."""
|
|
69
|
+
version = document.get("schema_version")
|
|
70
|
+
if isinstance(version, bool) or not isinstance(version, int):
|
|
71
|
+
message = "[SR-19] missing or invalid schema_version: the file does not say its format"
|
|
72
|
+
return [Issue(file, line, "/schema_version", message)]
|
|
73
|
+
if version > FORMAT_MAJOR:
|
|
74
|
+
message = (
|
|
75
|
+
f"[SR-19] file uses format version {version} but this WireForge {TOOL_VERSION} "
|
|
76
|
+
f"supports up to {FORMAT_MAJOR}; upgrade WireForge"
|
|
77
|
+
)
|
|
78
|
+
return [Issue(file, line, "/schema_version", message)]
|
|
79
|
+
if version < FORMAT_MAJOR:
|
|
80
|
+
hint = (
|
|
81
|
+
"run `wireforge import` to migrate it"
|
|
82
|
+
if migration_path(version) is not None
|
|
83
|
+
else "no migration path is available"
|
|
84
|
+
)
|
|
85
|
+
message = f"[SR-19] file uses older format version {version}; {hint}"
|
|
86
|
+
return [Issue(file, line, "/schema_version", message)]
|
|
87
|
+
issues: list[Issue] = []
|
|
88
|
+
minor = document.get("schema_minor", 0)
|
|
89
|
+
if isinstance(minor, int) and minor > FORMAT_MINOR:
|
|
90
|
+
message = (
|
|
91
|
+
f"[SR-19] file needs format {version}.{minor} but this WireForge supports "
|
|
92
|
+
f"{FORMAT_MAJOR}.{FORMAT_MINOR}"
|
|
93
|
+
)
|
|
94
|
+
issues.append(Issue(file, line, "/schema_minor", message))
|
|
95
|
+
generator = document.get("generator")
|
|
96
|
+
generator_version = generator.get("version") if isinstance(generator, dict) else None
|
|
97
|
+
written = _semver_tuple(generator_version)
|
|
98
|
+
current = _semver_tuple(TOOL_VERSION)
|
|
99
|
+
if written is not None and current is not None and written[:2] > current[:2]:
|
|
100
|
+
message = (
|
|
101
|
+
f"file was written by WireForge {generator_version}, newer than {TOOL_VERSION}; "
|
|
102
|
+
"unknown features may be rejected"
|
|
103
|
+
)
|
|
104
|
+
issues.append(Issue(file, line, "/generator", message, severity="warning"))
|
|
105
|
+
return issues
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def migrate_document(document: MutableMapping[str, object]) -> list[str]:
|
|
109
|
+
"""Upgrade `document` in place to the current format; returns the applied steps.
|
|
110
|
+
|
|
111
|
+
Raises TypeError without an integer schema_version, ValueError when the file is newer than
|
|
112
|
+
supported or a migration step is missing.
|
|
113
|
+
"""
|
|
114
|
+
version = document.get("schema_version")
|
|
115
|
+
if isinstance(version, bool) or not isinstance(version, int):
|
|
116
|
+
message = "document has no integer schema_version"
|
|
117
|
+
raise TypeError(message)
|
|
118
|
+
if version > FORMAT_MAJOR:
|
|
119
|
+
message = f"format {version} is newer than supported {FORMAT_MAJOR}"
|
|
120
|
+
raise ValueError(message)
|
|
121
|
+
path = migration_path(version)
|
|
122
|
+
if path is None:
|
|
123
|
+
message = f"no migration path from format {version} to {FORMAT_MAJOR}"
|
|
124
|
+
raise ValueError(message)
|
|
125
|
+
applied = []
|
|
126
|
+
for major in path:
|
|
127
|
+
_MIGRATIONS[major](document)
|
|
128
|
+
applied.append(f"format {major} -> {major + 1}")
|
|
129
|
+
stamp(document)
|
|
130
|
+
return applied
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def stamp(document: MutableMapping[str, object]) -> None:
|
|
134
|
+
"""Write the current format and generator stamp into `document`."""
|
|
135
|
+
document["schema_version"] = FORMAT_MAJOR
|
|
136
|
+
document["schema_minor"] = FORMAT_MINOR
|
|
137
|
+
document["generator"] = {"name": GENERATOR_NAME, "version": TOOL_VERSION}
|