nativegate 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.
- nativegate/__init__.py +1 -0
- nativegate/__main__.py +4 -0
- nativegate/buildinfo.py +344 -0
- nativegate/cli.py +2007 -0
- nativegate/config.py +991 -0
- nativegate/declared_invariants.py +565 -0
- nativegate/discovery.py +167 -0
- nativegate/driverbuild.py +626 -0
- nativegate/drivers/__init__.py +5 -0
- nativegate/drivers/cpp.py +616 -0
- nativegate/drivers/fortran.py +507 -0
- nativegate/generators/__init__.py +0 -0
- nativegate/generators/cmake_gen.py +101 -0
- nativegate/generators/docker_gen.py +614 -0
- nativegate/generators/error_gen.py +104 -0
- nativegate/generators/f2py_gen.py +91 -0
- nativegate/generators/gateway_gen.py +110 -0
- nativegate/generators/golden_gen.py +50 -0
- nativegate/generators/k8s_gen.py +212 -0
- nativegate/generators/mcp_gen.py +281 -0
- nativegate/generators/middleware_gen.py +717 -0
- nativegate/generators/pybind_gen.py +406 -0
- nativegate/generators/pyproject_gen.py +61 -0
- nativegate/generators/python_pkg_gen.py +1164 -0
- nativegate/generators/test_gen.py +160 -0
- nativegate/golden.py +747 -0
- nativegate/invariants.py +532 -0
- nativegate/ir.py +789 -0
- nativegate/lattice.py +350 -0
- nativegate/locking.py +216 -0
- nativegate/oracle.py +904 -0
- nativegate/parsers/__init__.py +0 -0
- nativegate/parsers/cpp.py +105 -0
- nativegate/parsers/cpp_ast.py +1652 -0
- nativegate/parsers/cpp_regex.py +812 -0
- nativegate/parsers/fixed_form.py +868 -0
- nativegate/parsers/fortran.py +157 -0
- nativegate/parsers/fortran_fparser.py +1116 -0
- nativegate/parsers/fortran_regex.py +686 -0
- nativegate/preprocess.py +335 -0
- nativegate/structural_invariants.py +762 -0
- nativegate/suggest.py +208 -0
- nativegate/templates/__init__.py +20 -0
- nativegate/templates/golden_test_template.py +248 -0
- nativegate/wire.py +438 -0
- nativegate-0.1.0.dist-info/METADATA +547 -0
- nativegate-0.1.0.dist-info/RECORD +50 -0
- nativegate-0.1.0.dist-info/WHEEL +5 -0
- nativegate-0.1.0.dist-info/entry_points.txt +3 -0
- nativegate-0.1.0.dist-info/top_level.txt +1 -0
nativegate/config.py
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
"""Loader for a service's nativegate.yaml (design.md section 9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import warnings
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Union
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .ir import ModuleIR
|
|
15
|
+
|
|
16
|
+
CONFIG_FILENAME = "nativegate.yaml"
|
|
17
|
+
|
|
18
|
+
SUPPORTED_LANGUAGES = ("cpp", "fortran")
|
|
19
|
+
|
|
20
|
+
# Closed vocabulary of declared invariant properties (design-verification-layers.md
|
|
21
|
+
# section 3.3). This set is exhaustive and deliberately NOT extensible via an
|
|
22
|
+
# eval'd expression string or any other escape hatch: "if a property does not
|
|
23
|
+
# fit the vocabulary, the vocabulary grows in a reviewed commit" (spec verbatim).
|
|
24
|
+
# Never add a generic/expression form here — that is a spec change, not a task
|
|
25
|
+
# decision, and the spec is emphatic that this must never happen.
|
|
26
|
+
INVARIANT_VOCABULARY = (
|
|
27
|
+
"bounds",
|
|
28
|
+
"monotone",
|
|
29
|
+
"sum_to_one",
|
|
30
|
+
"symmetric_in",
|
|
31
|
+
"scales_linearly_in",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
MONOTONE_DIRECTIONS = ("nondecreasing", "nonincreasing")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConfigError(ValueError):
|
|
38
|
+
"""nativegate.yaml is missing something nativegate refuses to guess."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExposeWarning(UserWarning):
|
|
42
|
+
"""An empty `expose:` block was read as "bind everything" without an opt-in."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ExposeConfig:
|
|
47
|
+
classes: list[str] = field(default_factory=list)
|
|
48
|
+
functions: list[str] = field(default_factory=list)
|
|
49
|
+
# Explicit opt-in to "bind the whole public surface": `expose: all` or
|
|
50
|
+
# `expose: {all: true}` in nativegate.yaml. None means "not stated" — which
|
|
51
|
+
# is still permissive for direct construction (the parsers rely on that,
|
|
52
|
+
# falling back to `[[nativegate::expose]]` annotations), but ServiceConfig.load
|
|
53
|
+
# warns about it so the intent isn't left implicit in a yaml file.
|
|
54
|
+
all: bool | None = None
|
|
55
|
+
|
|
56
|
+
def is_exposed(self, name: str) -> bool:
|
|
57
|
+
if self.all:
|
|
58
|
+
return True
|
|
59
|
+
if not self.classes and not self.functions:
|
|
60
|
+
# Nothing named: permissive unless the user explicitly said `all: false`.
|
|
61
|
+
return self.all is not False
|
|
62
|
+
return name in self.classes or name in self.functions
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_empty(self) -> bool:
|
|
66
|
+
return not self.classes and not self.functions
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ClangConfig:
|
|
71
|
+
"""Compiler flags the C++ AST parser needs to read this service's headers.
|
|
72
|
+
|
|
73
|
+
A real front end has to be told what a compiler would be told: where the
|
|
74
|
+
other headers live, which macros the build defines, which standard the
|
|
75
|
+
code is written against. Getting these wrong doesn't fail loudly — clang
|
|
76
|
+
recovers from an unknown type by pretending it was `int` — so nativegate
|
|
77
|
+
reports every parse error it sees rather than binding the wreckage.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
std: str = "c++17"
|
|
81
|
+
include_paths: list[str] = field(default_factory=list)
|
|
82
|
+
defines: list[str] = field(default_factory=list)
|
|
83
|
+
extra_args: list[str] = field(default_factory=list)
|
|
84
|
+
# Names of extern "C" functions whose bare `T*` arguments are SCALARS
|
|
85
|
+
# passed by reference — the Fortran-linkage convention. Opt-in per
|
|
86
|
+
# function, never inferred: `double* x` is also how C passes an array
|
|
87
|
+
# whose length travels through COMMON or a PARAMETER, and binding an
|
|
88
|
+
# array as one scalar hands the callee a pointer it reads past. FLASH2's
|
|
89
|
+
# XLIQ(NCMAX) looks exactly like PVTRS's scalar `p` in a C prototype —
|
|
90
|
+
# only the person who read the Fortran knows which is which, so they say
|
|
91
|
+
# so here. "*" asserts it for every extern "C" function in the service.
|
|
92
|
+
scalar_ref_functions: list[str] = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ApiConfig:
|
|
97
|
+
"""How the generated HTTP API authenticates callers.
|
|
98
|
+
|
|
99
|
+
Only the *mode* lives here. Keys never do: `nativegate.yaml` is committed,
|
|
100
|
+
and a credential in a committed file is a credential in every clone and
|
|
101
|
+
every image layer. The generated middleware reads keys from
|
|
102
|
+
`NATIVEGATE_API_KEYS` at startup, and refuses to start if the mode requires
|
|
103
|
+
them and none are present — failing closed, because a service that
|
|
104
|
+
silently drops authentication is discovered by an attacker rather than by
|
|
105
|
+
whoever deployed it.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# "none" (open — logged loudly at startup) or "api_key".
|
|
109
|
+
auth: str = "none"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class BoundsProperty:
|
|
114
|
+
"""`bounds: {min: ..., max: ...}` — at least one of min/max is required."""
|
|
115
|
+
|
|
116
|
+
min: float | None = None
|
|
117
|
+
max: float | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class MonotoneProperty:
|
|
122
|
+
"""`monotone: {in: <param>, direction: nondecreasing|nonincreasing}`.
|
|
123
|
+
|
|
124
|
+
Compared with raw `>=`/`<=` at run time, no tolerance (spec §3.4) — this
|
|
125
|
+
dataclass carries no tolerance field, and none may be added.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
parameter: str
|
|
129
|
+
direction: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class SumToOneProperty:
|
|
134
|
+
"""`sum_to_one: [a, b, c]` with an optional sibling `tolerance:`."""
|
|
135
|
+
|
|
136
|
+
fields: list[str] = field(default_factory=list)
|
|
137
|
+
tolerance: float = 0.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class SymmetricInProperty:
|
|
142
|
+
"""`symmetric_in: [a, b]` — the result is unchanged under permuting these
|
|
143
|
+
named parameters."""
|
|
144
|
+
|
|
145
|
+
parameters: list[str] = field(default_factory=list)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class ScalesLinearlyInProperty:
|
|
150
|
+
"""`scales_linearly_in: {in: <param>}` — the result scales linearly with
|
|
151
|
+
the named parameter, all others held fixed."""
|
|
152
|
+
|
|
153
|
+
parameter: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# The typed union every declared property parses into. Runners (T9/T10/T11)
|
|
157
|
+
# switch on the concrete type rather than on a string tag, so a new vocabulary
|
|
158
|
+
# word cannot be half-added (a string literal with no matching dataclass).
|
|
159
|
+
InvariantProperty = Union[
|
|
160
|
+
BoundsProperty,
|
|
161
|
+
MonotoneProperty,
|
|
162
|
+
SumToOneProperty,
|
|
163
|
+
SymmetricInProperty,
|
|
164
|
+
ScalesLinearlyInProperty,
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class StateConfig:
|
|
170
|
+
"""`state:` — declared mutators (design-verification-layers.md §3.5).
|
|
171
|
+
|
|
172
|
+
Structural purity checks (`idempotent`, `order_independent`) apply to
|
|
173
|
+
every entry point NOT listed in `mutating`; applying them universally
|
|
174
|
+
would condemn `petro_api`'s own contract (`pvt_set_fluid` mutates COMMON
|
|
175
|
+
state by design). Declaring this here is what makes an *undeclared*
|
|
176
|
+
mutator mechanically detectable instead of a matter of trust.
|
|
177
|
+
|
|
178
|
+
Design decision (not literal spec text, design-verification-layers.md
|
|
179
|
+
§3.5 does not say this explicitly -- flagged here rather than decided
|
|
180
|
+
silently): the declared `error_flag` accessor is ALSO implicitly
|
|
181
|
+
excluded from `idempotent` and from being chosen as the interposed `g`
|
|
182
|
+
routine in `order_independent`, the same way `mutating` routines are.
|
|
183
|
+
`error_flag` names a clear-on-read accessor (e.g. petro_api's
|
|
184
|
+
`last_error`/`PVTERR`, which returns the stored error code and then
|
|
185
|
+
resets it) -- calling it twice legitimately returns different bits on
|
|
186
|
+
the second call, which would make `idempotent` fail not because of a
|
|
187
|
+
bug but because clear-on-read is not idempotent by construction. This
|
|
188
|
+
is the same "the check would condemn the API's own contract" reasoning
|
|
189
|
+
already applied to `mutating` above, just for a different kind of
|
|
190
|
+
intentional statefulness. See `structural_invariants.py`'s exemption
|
|
191
|
+
logic for where this is enforced.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
setup: list[str] = field(default_factory=list)
|
|
195
|
+
mutating: list[str] = field(default_factory=list)
|
|
196
|
+
error_flag: str | None = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class RangeDeclaration:
|
|
201
|
+
"""`ranges: {pressure: [lo, hi]}` — one entry, already validated (lo <
|
|
202
|
+
hi, both finite)."""
|
|
203
|
+
|
|
204
|
+
lo: float
|
|
205
|
+
hi: float
|
|
206
|
+
|
|
207
|
+
def __iter__(self):
|
|
208
|
+
return iter((self.lo, self.hi))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class ScatterDeclaration:
|
|
213
|
+
"""`lattice.scatter: {seed: ..., count: ...}` (spec §3.4 item 3).
|
|
214
|
+
|
|
215
|
+
`seed` is required once `count` > 0 -- `lattice.build_entry_lattice`
|
|
216
|
+
raises `ValueError` for a positive count with no seed, and this loader
|
|
217
|
+
catches the same mistake earlier, at config-load time, with a message
|
|
218
|
+
that names the file.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
seed: int | None = None
|
|
222
|
+
count: int = 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class LatticeConfig:
|
|
227
|
+
"""`lattice:` — declares the YAML surface for `lattice.py`'s (T9)
|
|
228
|
+
sweep point count, scatter seed/count, and per-function corners (spec
|
|
229
|
+
§3.4 items 1-3).
|
|
230
|
+
|
|
231
|
+
Design decision (not literal spec text, flagged here rather than
|
|
232
|
+
decided silently): `lattice.py`'s `build_entry_lattice`/`scatter`
|
|
233
|
+
already implement corners and scatter sampling and accept them as bare
|
|
234
|
+
keyword arguments, but until this task there was no `nativegate.yaml`
|
|
235
|
+
surface to *declare* them -- only explicit Python callers (tests) could
|
|
236
|
+
populate them. This dataclass is that surface. `corners` is keyed by
|
|
237
|
+
exposed function name, each value a list of full positional argument
|
|
238
|
+
tuples passed through to `lattice.build_entry_lattice`'s `corners=`
|
|
239
|
+
verbatim (see that function's docstring: "passed through completely
|
|
240
|
+
unmodified... this function does not validate their arity or clamp them
|
|
241
|
+
to any range") -- this loader does not validate arity either, only that
|
|
242
|
+
the function name is exposed.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
n: int | None = None
|
|
246
|
+
scatter: ScatterDeclaration = field(default_factory=ScatterDeclaration)
|
|
247
|
+
corners: dict[str, list[tuple]] = field(default_factory=dict)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def is_empty(self) -> bool:
|
|
251
|
+
return self.n is None and self.scatter.count == 0 and self.scatter.seed is None and not self.corners
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class VerificationConfig:
|
|
256
|
+
"""Parsed `state:` / `invariants:` / `ranges:` / `lattice:` blocks
|
|
257
|
+
(design-verification-layers.md §3.2-3.5).
|
|
258
|
+
|
|
259
|
+
This is the single place declared invariant semantics live; T9/T10/T11's
|
|
260
|
+
lattice and runners consume these typed objects rather than re-parsing
|
|
261
|
+
YAML themselves.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
state: StateConfig = field(default_factory=StateConfig)
|
|
265
|
+
invariants: dict[str, list[InvariantProperty]] = field(default_factory=dict)
|
|
266
|
+
ranges: dict[str, RangeDeclaration] = field(default_factory=dict)
|
|
267
|
+
lattice: LatticeConfig = field(default_factory=LatticeConfig)
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def is_empty(self) -> bool:
|
|
271
|
+
return (
|
|
272
|
+
not self.state.setup
|
|
273
|
+
and not self.state.mutating
|
|
274
|
+
and self.state.error_flag is None
|
|
275
|
+
and not self.invariants
|
|
276
|
+
and not self.ranges
|
|
277
|
+
and self.lattice.is_empty
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def has_range(self, parameter: str) -> bool:
|
|
281
|
+
"""False for a swept parameter with NO declared range.
|
|
282
|
+
|
|
283
|
+
Spec §3.4: "there is no default range — a swept parameter with no
|
|
284
|
+
declared range is an error, not a guess, recorded under `uncovered`."
|
|
285
|
+
That `uncovered` bookkeeping is T12's job; this method exists so a
|
|
286
|
+
later runner can distinguish "absent" cleanly, which is this task's
|
|
287
|
+
job (parse-time, `ranges:` must not silently default to anything).
|
|
288
|
+
"""
|
|
289
|
+
return parameter in self.ranges
|
|
290
|
+
|
|
291
|
+
def validate_against_ir(self, module: "ModuleIR") -> None:
|
|
292
|
+
"""Second-phase validation once a parsed signature is available.
|
|
293
|
+
|
|
294
|
+
`ServiceConfig.load` validates everything checkable from the YAML
|
|
295
|
+
and the `expose:` block alone (closed vocabulary, enum values,
|
|
296
|
+
required sub-keys, functions named in `expose:`). Whether
|
|
297
|
+
`monotone.in` actually names a *parameter* of that function needs the
|
|
298
|
+
parsed signature (`ModuleIR`), which `ServiceConfig.load` does not
|
|
299
|
+
build (it would give every config load a parser dependency it
|
|
300
|
+
doesn't otherwise have). Callers that have already parsed the
|
|
301
|
+
service's IR (the CLI, T9/T10/T11 runners, or a test that parses the
|
|
302
|
+
real source with `parsers.fortran`) call this explicitly.
|
|
303
|
+
"""
|
|
304
|
+
functions_by_name = {fn.name: fn for fn in module.functions}
|
|
305
|
+
for fn_name, properties in self.invariants.items():
|
|
306
|
+
fn = functions_by_name.get(fn_name)
|
|
307
|
+
if fn is None:
|
|
308
|
+
continue # not this module; another IR may cover it
|
|
309
|
+
param_names = {p.name for p in fn.parameters}
|
|
310
|
+
for prop in properties:
|
|
311
|
+
if isinstance(prop, MonotoneProperty) and prop.parameter not in param_names:
|
|
312
|
+
raise ConfigError(
|
|
313
|
+
f"invariants.{fn_name}: monotone.in '{prop.parameter}' is not "
|
|
314
|
+
f"a parameter of '{fn_name}' (parameters: "
|
|
315
|
+
f"{', '.join(sorted(param_names)) or '(none)'})."
|
|
316
|
+
)
|
|
317
|
+
if isinstance(prop, ScalesLinearlyInProperty) and prop.parameter not in param_names:
|
|
318
|
+
raise ConfigError(
|
|
319
|
+
f"invariants.{fn_name}: scales_linearly_in.in "
|
|
320
|
+
f"'{prop.parameter}' is not a parameter of '{fn_name}' "
|
|
321
|
+
f"(parameters: {', '.join(sorted(param_names)) or '(none)'})."
|
|
322
|
+
)
|
|
323
|
+
if isinstance(prop, SymmetricInProperty):
|
|
324
|
+
unknown = [p for p in prop.parameters if p not in param_names]
|
|
325
|
+
if unknown:
|
|
326
|
+
raise ConfigError(
|
|
327
|
+
f"invariants.{fn_name}: symmetric_in names "
|
|
328
|
+
f"{unknown!r}, not parameters of '{fn_name}' "
|
|
329
|
+
f"(parameters: {', '.join(sorted(param_names)) or '(none)'})."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _load_verification(
|
|
334
|
+
data, config_path: Path, known_functions: set[str] | None
|
|
335
|
+
) -> VerificationConfig:
|
|
336
|
+
"""Read `state:`, `invariants:`, `ranges:` — all three optional blocks.
|
|
337
|
+
|
|
338
|
+
`known_functions` is the service's `expose.functions` list: everything
|
|
339
|
+
`state:`/`invariants:` may name. Parameter-level checks (`monotone.in`)
|
|
340
|
+
are NOT done here — see `VerificationConfig.validate_against_ir`.
|
|
341
|
+
"""
|
|
342
|
+
state = _load_state(data.get("state"), config_path, known_functions)
|
|
343
|
+
invariants = _load_invariants(data.get("invariants"), config_path, known_functions)
|
|
344
|
+
ranges = _load_ranges(data.get("ranges"), config_path)
|
|
345
|
+
lattice_config = _load_lattice(data.get("lattice"), config_path, known_functions)
|
|
346
|
+
return VerificationConfig(
|
|
347
|
+
state=state, invariants=invariants, ranges=ranges, lattice=lattice_config
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _load_state(
|
|
352
|
+
state_data, config_path: Path, known_functions: set[str] | None
|
|
353
|
+
) -> StateConfig:
|
|
354
|
+
state_data = state_data or {}
|
|
355
|
+
if not isinstance(state_data, dict):
|
|
356
|
+
raise ConfigError(
|
|
357
|
+
f"{config_path}: `state:` must be a block, got {type(state_data).__name__}."
|
|
358
|
+
)
|
|
359
|
+
_reject_unknown_keys(state_data, {"setup", "mutating", "error_flag"}, "state:", config_path)
|
|
360
|
+
|
|
361
|
+
setup = _require_known_functions(
|
|
362
|
+
list(state_data.get("setup") or []), "state.setup", config_path, known_functions
|
|
363
|
+
)
|
|
364
|
+
mutating = _require_known_functions(
|
|
365
|
+
list(state_data.get("mutating") or []), "state.mutating", config_path, known_functions
|
|
366
|
+
)
|
|
367
|
+
error_flag = state_data.get("error_flag")
|
|
368
|
+
if error_flag is not None:
|
|
369
|
+
error_flag = str(error_flag)
|
|
370
|
+
_require_known_functions([error_flag], "state.error_flag", config_path, known_functions)
|
|
371
|
+
return StateConfig(setup=setup, mutating=mutating, error_flag=error_flag)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _reject_unknown_keys(data: dict, allowed: set[str], where: str, config_path: Path) -> None:
|
|
375
|
+
"""Closed-key check shared by every `{where}:` block in this module.
|
|
376
|
+
|
|
377
|
+
Every declared block (`state:`, `bounds`, `monotone`,
|
|
378
|
+
`scales_linearly_in`, `lattice:`, `lattice.scatter:`, ...) rejects any
|
|
379
|
+
key outside its own fixed set the same way -- factored here so the
|
|
380
|
+
wording only needs to be right, and only needs updating, in one place.
|
|
381
|
+
"""
|
|
382
|
+
unknown_keys = set(data) - allowed
|
|
383
|
+
if unknown_keys:
|
|
384
|
+
raise ConfigError(
|
|
385
|
+
f"{config_path}: `{where}` has unrecognised key(s) "
|
|
386
|
+
f"{sorted(unknown_keys)}. Only {sorted(allowed)} are understood."
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _require_known_functions(
|
|
391
|
+
names: list[str], where: str, config_path: Path, known_functions: set[str] | None
|
|
392
|
+
) -> list[str]:
|
|
393
|
+
if known_functions is None:
|
|
394
|
+
return list(names)
|
|
395
|
+
for name in names:
|
|
396
|
+
if name not in known_functions:
|
|
397
|
+
raise ConfigError(
|
|
398
|
+
f"{config_path}: `{where}` names '{name}', which is not in "
|
|
399
|
+
f"`expose.functions:`. Only exposed functions may be "
|
|
400
|
+
"declared here."
|
|
401
|
+
)
|
|
402
|
+
return list(names)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _load_invariants(
|
|
406
|
+
invariants_data, config_path: Path, known_functions: set[str] | None
|
|
407
|
+
) -> dict[str, list[InvariantProperty]]:
|
|
408
|
+
invariants_data = invariants_data or {}
|
|
409
|
+
if not isinstance(invariants_data, dict):
|
|
410
|
+
raise ConfigError(
|
|
411
|
+
f"{config_path}: `invariants:` must be a mapping of function name "
|
|
412
|
+
f"to a list of properties, got {type(invariants_data).__name__}."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
result: dict[str, list[InvariantProperty]] = {}
|
|
416
|
+
for fn_name, entries in invariants_data.items():
|
|
417
|
+
if known_functions is not None and fn_name not in known_functions:
|
|
418
|
+
raise ConfigError(
|
|
419
|
+
f"{config_path}: `invariants.{fn_name}` names a function not "
|
|
420
|
+
"in `expose.functions:`. Only exposed functions may carry "
|
|
421
|
+
"declared invariants."
|
|
422
|
+
)
|
|
423
|
+
if not isinstance(entries, list) or not entries:
|
|
424
|
+
raise ConfigError(
|
|
425
|
+
f"{config_path}: `invariants.{fn_name}` must be a non-empty "
|
|
426
|
+
f"list of property declarations, got {entries!r}."
|
|
427
|
+
)
|
|
428
|
+
properties: list[InvariantProperty] = []
|
|
429
|
+
for entry in entries:
|
|
430
|
+
properties.append(
|
|
431
|
+
_load_property(entry, fn_name, config_path)
|
|
432
|
+
)
|
|
433
|
+
result[fn_name] = properties
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _load_property(entry, fn_name: str, config_path: Path) -> InvariantProperty:
|
|
438
|
+
"""Parse one `- word: {...}` entry against the CLOSED vocabulary.
|
|
439
|
+
|
|
440
|
+
No branch here ever evaluates a string as code, and none may be added:
|
|
441
|
+
design-verification-layers.md §3.3 is emphatic that the vocabulary is
|
|
442
|
+
fixed and any pressure to add an eval-based escape hatch is a spec
|
|
443
|
+
change, not a task decision.
|
|
444
|
+
"""
|
|
445
|
+
if not isinstance(entry, dict) or not entry:
|
|
446
|
+
raise ConfigError(
|
|
447
|
+
f"{config_path}: invariants.{fn_name} entry {entry!r} must be a "
|
|
448
|
+
"mapping naming exactly one property "
|
|
449
|
+
f"({', '.join(INVARIANT_VOCABULARY)})."
|
|
450
|
+
)
|
|
451
|
+
vocabulary_keys = [k for k in entry if k in INVARIANT_VOCABULARY]
|
|
452
|
+
if len(vocabulary_keys) != 1:
|
|
453
|
+
raise ConfigError(
|
|
454
|
+
f"{config_path}: invariants.{fn_name} entry {entry!r} must name "
|
|
455
|
+
f"exactly one property from the closed vocabulary "
|
|
456
|
+
f"({', '.join(INVARIANT_VOCABULARY)}). No expression/eval form "
|
|
457
|
+
"exists; if this genuinely does not fit, that is a spec change, "
|
|
458
|
+
"not something to work around here."
|
|
459
|
+
)
|
|
460
|
+
word = vocabulary_keys[0]
|
|
461
|
+
payload = entry[word]
|
|
462
|
+
# `sum_to_one` is the one form with a sibling key in the same mapping
|
|
463
|
+
# entry (spec §3.2's example: `- sum_to_one: [sw, so, sg]` /
|
|
464
|
+
# ` tolerance: 1e-12`). Every other form must be the entry's only key.
|
|
465
|
+
extra_keys = set(entry) - {word} - ({"tolerance"} if word == "sum_to_one" else set())
|
|
466
|
+
if extra_keys:
|
|
467
|
+
raise ConfigError(
|
|
468
|
+
f"{config_path}: invariants.{fn_name} entry {entry!r} has "
|
|
469
|
+
f"unexpected key(s) {sorted(extra_keys)} alongside '{word}'."
|
|
470
|
+
)
|
|
471
|
+
where = f"invariants.{fn_name}.{word}"
|
|
472
|
+
|
|
473
|
+
if word == "bounds":
|
|
474
|
+
return _load_bounds(payload, where, config_path)
|
|
475
|
+
if word == "monotone":
|
|
476
|
+
return _load_monotone(payload, where, config_path)
|
|
477
|
+
if word == "sum_to_one":
|
|
478
|
+
return _load_sum_to_one(payload, entry, where, config_path)
|
|
479
|
+
if word == "symmetric_in":
|
|
480
|
+
return _load_symmetric_in(payload, where, config_path)
|
|
481
|
+
if word == "scales_linearly_in":
|
|
482
|
+
return _load_scales_linearly_in(payload, where, config_path)
|
|
483
|
+
raise AssertionError(f"unreachable: {word!r} passed the vocabulary check")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _load_bounds(payload, where: str, config_path: Path) -> BoundsProperty:
|
|
487
|
+
if not isinstance(payload, dict):
|
|
488
|
+
raise ConfigError(f"{config_path}: `{where}` must be a mapping, got {payload!r}.")
|
|
489
|
+
minimum = payload.get("min")
|
|
490
|
+
maximum = payload.get("max")
|
|
491
|
+
if minimum is None and maximum is None:
|
|
492
|
+
raise ConfigError(
|
|
493
|
+
f"{config_path}: `{where}` needs at least one of `min`/`max`."
|
|
494
|
+
)
|
|
495
|
+
_reject_unknown_keys(payload, {"min", "max"}, where, config_path)
|
|
496
|
+
return BoundsProperty(
|
|
497
|
+
min=float(minimum) if minimum is not None else None,
|
|
498
|
+
max=float(maximum) if maximum is not None else None,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _load_monotone(payload, where: str, config_path: Path) -> MonotoneProperty:
|
|
503
|
+
if not isinstance(payload, dict):
|
|
504
|
+
raise ConfigError(f"{config_path}: `{where}` must be a mapping, got {payload!r}.")
|
|
505
|
+
_reject_unknown_keys(payload, {"in", "direction"}, where, config_path)
|
|
506
|
+
parameter = payload.get("in")
|
|
507
|
+
direction = payload.get("direction")
|
|
508
|
+
if not parameter or not isinstance(parameter, str):
|
|
509
|
+
raise ConfigError(f"{config_path}: `{where}.in` must name a parameter (a string).")
|
|
510
|
+
if direction not in MONOTONE_DIRECTIONS:
|
|
511
|
+
raise ConfigError(
|
|
512
|
+
f"{config_path}: `{where}.direction` must be one of "
|
|
513
|
+
f"{MONOTONE_DIRECTIONS}, got {direction!r}."
|
|
514
|
+
)
|
|
515
|
+
return MonotoneProperty(parameter=parameter, direction=direction)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _load_sum_to_one(payload, raw_entry: dict, where: str, config_path: Path) -> SumToOneProperty:
|
|
519
|
+
# `sum_to_one:` takes a bare list; an optional sibling `tolerance:` key
|
|
520
|
+
# sits next to it in the same mapping entry (per the spec's example:
|
|
521
|
+
# `- sum_to_one: [sw, so, sg]` / ` tolerance: 1e-12`), not nested inside
|
|
522
|
+
# the payload, so it is read from raw_entry rather than payload.
|
|
523
|
+
if not isinstance(payload, list) or not payload:
|
|
524
|
+
raise ConfigError(f"{config_path}: `{where}` must be a non-empty list of field names.")
|
|
525
|
+
fields = [str(f) for f in payload]
|
|
526
|
+
if len(set(fields)) != len(fields):
|
|
527
|
+
raise ConfigError(f"{config_path}: `{where}` lists a field more than once: {fields!r}.")
|
|
528
|
+
tolerance_raw = raw_entry.get("tolerance", 0.0)
|
|
529
|
+
try:
|
|
530
|
+
tolerance = float(tolerance_raw)
|
|
531
|
+
except (TypeError, ValueError):
|
|
532
|
+
raise ConfigError(f"{config_path}: `{where}` tolerance must be numeric, got {tolerance_raw!r}.")
|
|
533
|
+
if tolerance < 0 or not math.isfinite(tolerance):
|
|
534
|
+
raise ConfigError(f"{config_path}: `{where}` tolerance must be finite and >= 0.")
|
|
535
|
+
return SumToOneProperty(fields=fields, tolerance=tolerance)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _load_symmetric_in(payload, where: str, config_path: Path) -> SymmetricInProperty:
|
|
539
|
+
if not isinstance(payload, list) or len(payload) < 2:
|
|
540
|
+
raise ConfigError(
|
|
541
|
+
f"{config_path}: `{where}` must be a list of at least two parameter names."
|
|
542
|
+
)
|
|
543
|
+
parameters = [str(p) for p in payload]
|
|
544
|
+
return SymmetricInProperty(parameters=parameters)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _load_scales_linearly_in(payload, where: str, config_path: Path) -> ScalesLinearlyInProperty:
|
|
548
|
+
if isinstance(payload, str):
|
|
549
|
+
parameter = payload
|
|
550
|
+
elif isinstance(payload, dict):
|
|
551
|
+
_reject_unknown_keys(payload, {"in"}, where, config_path)
|
|
552
|
+
parameter = payload.get("in")
|
|
553
|
+
else:
|
|
554
|
+
raise ConfigError(
|
|
555
|
+
f"{config_path}: `{where}` must be a parameter name or `{{in: <param>}}`, "
|
|
556
|
+
f"got {payload!r}."
|
|
557
|
+
)
|
|
558
|
+
if not parameter or not isinstance(parameter, str):
|
|
559
|
+
raise ConfigError(f"{config_path}: `{where}.in` must name a parameter (a string).")
|
|
560
|
+
return ScalesLinearlyInProperty(parameter=parameter)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _load_ranges(ranges_data, config_path: Path) -> dict[str, RangeDeclaration]:
|
|
564
|
+
ranges_data = ranges_data or {}
|
|
565
|
+
if not isinstance(ranges_data, dict):
|
|
566
|
+
raise ConfigError(
|
|
567
|
+
f"{config_path}: `ranges:` must be a mapping of parameter name to "
|
|
568
|
+
f"[lo, hi], got {type(ranges_data).__name__}."
|
|
569
|
+
)
|
|
570
|
+
result: dict[str, RangeDeclaration] = {}
|
|
571
|
+
for parameter, bounds in ranges_data.items():
|
|
572
|
+
if (
|
|
573
|
+
not isinstance(bounds, (list, tuple))
|
|
574
|
+
or len(bounds) != 2
|
|
575
|
+
):
|
|
576
|
+
raise ConfigError(
|
|
577
|
+
f"{config_path}: `ranges.{parameter}` must be `[lo, hi]`, got {bounds!r}."
|
|
578
|
+
)
|
|
579
|
+
try:
|
|
580
|
+
lo, hi = float(bounds[0]), float(bounds[1])
|
|
581
|
+
except (TypeError, ValueError):
|
|
582
|
+
raise ConfigError(f"{config_path}: `ranges.{parameter}` values must be numeric, got {bounds!r}.")
|
|
583
|
+
if not math.isfinite(lo) or not math.isfinite(hi):
|
|
584
|
+
raise ConfigError(f"{config_path}: `ranges.{parameter}` must be finite, got {bounds!r}.")
|
|
585
|
+
if not lo < hi:
|
|
586
|
+
raise ConfigError(
|
|
587
|
+
f"{config_path}: `ranges.{parameter}` must have lo < hi, got [{lo}, {hi}]."
|
|
588
|
+
)
|
|
589
|
+
result[str(parameter)] = RangeDeclaration(lo=lo, hi=hi)
|
|
590
|
+
return result
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _load_lattice(
|
|
594
|
+
lattice_data, config_path: Path, known_functions: set[str] | None
|
|
595
|
+
) -> LatticeConfig:
|
|
596
|
+
"""Read the `lattice:` block: `n`, `scatter: {seed, count}`, `corners:`.
|
|
597
|
+
|
|
598
|
+
See `LatticeConfig`'s docstring for why this exists. Matches the rest of
|
|
599
|
+
this module's style: closed set of recognised keys, explicit errors
|
|
600
|
+
naming what's wrong, functions referenced in `corners:` must be exposed
|
|
601
|
+
(mirroring `_require_known_functions`'s treatment of `state:`/
|
|
602
|
+
`invariants:`).
|
|
603
|
+
"""
|
|
604
|
+
lattice_data = lattice_data or {}
|
|
605
|
+
if not isinstance(lattice_data, dict):
|
|
606
|
+
raise ConfigError(
|
|
607
|
+
f"{config_path}: `lattice:` must be a block, got {type(lattice_data).__name__}."
|
|
608
|
+
)
|
|
609
|
+
_reject_unknown_keys(lattice_data, {"n", "scatter", "corners"}, "lattice:", config_path)
|
|
610
|
+
|
|
611
|
+
n = None
|
|
612
|
+
if "n" in lattice_data and lattice_data["n"] is not None:
|
|
613
|
+
n_raw = lattice_data["n"]
|
|
614
|
+
if isinstance(n_raw, bool) or not isinstance(n_raw, int):
|
|
615
|
+
raise ConfigError(f"{config_path}: `lattice.n` must be an integer, got {n_raw!r}.")
|
|
616
|
+
if n_raw < 2:
|
|
617
|
+
raise ConfigError(
|
|
618
|
+
f"{config_path}: `lattice.n` must be >= 2 (need both endpoints), got {n_raw!r}."
|
|
619
|
+
)
|
|
620
|
+
n = n_raw
|
|
621
|
+
|
|
622
|
+
scatter = _load_scatter(lattice_data.get("scatter"), config_path)
|
|
623
|
+
corners = _load_corners(lattice_data.get("corners"), config_path, known_functions)
|
|
624
|
+
|
|
625
|
+
return LatticeConfig(n=n, scatter=scatter, corners=corners)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _load_scatter(scatter_data, config_path: Path) -> ScatterDeclaration:
|
|
629
|
+
scatter_data = scatter_data or {}
|
|
630
|
+
if not isinstance(scatter_data, dict):
|
|
631
|
+
raise ConfigError(
|
|
632
|
+
f"{config_path}: `lattice.scatter:` must be a block, got "
|
|
633
|
+
f"{type(scatter_data).__name__}."
|
|
634
|
+
)
|
|
635
|
+
_reject_unknown_keys(scatter_data, {"seed", "count"}, "lattice.scatter:", config_path)
|
|
636
|
+
|
|
637
|
+
seed_raw = scatter_data.get("seed")
|
|
638
|
+
seed = None
|
|
639
|
+
if seed_raw is not None:
|
|
640
|
+
if isinstance(seed_raw, bool) or not isinstance(seed_raw, int):
|
|
641
|
+
raise ConfigError(
|
|
642
|
+
f"{config_path}: `lattice.scatter.seed` must be an integer, got {seed_raw!r}."
|
|
643
|
+
)
|
|
644
|
+
seed = seed_raw
|
|
645
|
+
|
|
646
|
+
count_raw = scatter_data.get("count", 0)
|
|
647
|
+
if isinstance(count_raw, bool) or not isinstance(count_raw, int):
|
|
648
|
+
raise ConfigError(
|
|
649
|
+
f"{config_path}: `lattice.scatter.count` must be an integer, got {count_raw!r}."
|
|
650
|
+
)
|
|
651
|
+
if count_raw < 0:
|
|
652
|
+
raise ConfigError(
|
|
653
|
+
f"{config_path}: `lattice.scatter.count` must be >= 0, got {count_raw!r}."
|
|
654
|
+
)
|
|
655
|
+
if count_raw > 0 and seed is None:
|
|
656
|
+
raise ConfigError(
|
|
657
|
+
f"{config_path}: `lattice.scatter.count` is {count_raw!r} but "
|
|
658
|
+
"`lattice.scatter.seed` is missing. A positive count needs a "
|
|
659
|
+
"seed to be reproducible -- there is no default."
|
|
660
|
+
)
|
|
661
|
+
return ScatterDeclaration(seed=seed, count=count_raw)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _load_corners(
|
|
665
|
+
corners_data, config_path: Path, known_functions: set[str] | None
|
|
666
|
+
) -> dict[str, list[tuple]]:
|
|
667
|
+
corners_data = corners_data or {}
|
|
668
|
+
if not isinstance(corners_data, dict):
|
|
669
|
+
raise ConfigError(
|
|
670
|
+
f"{config_path}: `lattice.corners:` must be a mapping of function "
|
|
671
|
+
f"name to a list of argument tuples, got {type(corners_data).__name__}."
|
|
672
|
+
)
|
|
673
|
+
result: dict[str, list[tuple]] = {}
|
|
674
|
+
for fn_name, entries in corners_data.items():
|
|
675
|
+
_require_known_functions(
|
|
676
|
+
[fn_name], "lattice.corners", config_path, known_functions
|
|
677
|
+
)
|
|
678
|
+
if not isinstance(entries, list) or not entries:
|
|
679
|
+
raise ConfigError(
|
|
680
|
+
f"{config_path}: `lattice.corners.{fn_name}` must be a "
|
|
681
|
+
f"non-empty list of argument tuples, got {entries!r}."
|
|
682
|
+
)
|
|
683
|
+
tuples: list[tuple] = []
|
|
684
|
+
for entry in entries:
|
|
685
|
+
if not isinstance(entry, (list, tuple)):
|
|
686
|
+
raise ConfigError(
|
|
687
|
+
f"{config_path}: `lattice.corners.{fn_name}` entry {entry!r} "
|
|
688
|
+
"must be a list of positional argument values (one full "
|
|
689
|
+
"call's worth), passed through verbatim."
|
|
690
|
+
)
|
|
691
|
+
tuples.append(tuple(entry))
|
|
692
|
+
result[str(fn_name)] = tuples
|
|
693
|
+
return result
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _dump_property(prop: InvariantProperty) -> dict:
|
|
697
|
+
"""Serialize one typed property back to its `nativegate.yaml` shape.
|
|
698
|
+
|
|
699
|
+
Inverse of `_load_property`, used by `ServiceConfig.save` so a
|
|
700
|
+
load/validate/save round trip is lossless.
|
|
701
|
+
"""
|
|
702
|
+
if isinstance(prop, BoundsProperty):
|
|
703
|
+
payload = {}
|
|
704
|
+
if prop.min is not None:
|
|
705
|
+
payload["min"] = prop.min
|
|
706
|
+
if prop.max is not None:
|
|
707
|
+
payload["max"] = prop.max
|
|
708
|
+
return {"bounds": payload}
|
|
709
|
+
if isinstance(prop, MonotoneProperty):
|
|
710
|
+
return {"monotone": {"in": prop.parameter, "direction": prop.direction}}
|
|
711
|
+
if isinstance(prop, SumToOneProperty):
|
|
712
|
+
entry = {"sum_to_one": list(prop.fields)}
|
|
713
|
+
if prop.tolerance:
|
|
714
|
+
entry["tolerance"] = prop.tolerance
|
|
715
|
+
return entry
|
|
716
|
+
if isinstance(prop, SymmetricInProperty):
|
|
717
|
+
return {"symmetric_in": list(prop.parameters)}
|
|
718
|
+
if isinstance(prop, ScalesLinearlyInProperty):
|
|
719
|
+
return {"scales_linearly_in": {"in": prop.parameter}}
|
|
720
|
+
raise AssertionError(f"unreachable: no dump form for {prop!r}")
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _dump_verification(verification: VerificationConfig) -> dict:
|
|
724
|
+
data: dict = {}
|
|
725
|
+
state = verification.state
|
|
726
|
+
if state.setup or state.mutating or state.error_flag is not None:
|
|
727
|
+
state_block = {}
|
|
728
|
+
if state.setup:
|
|
729
|
+
state_block["setup"] = list(state.setup)
|
|
730
|
+
if state.mutating:
|
|
731
|
+
state_block["mutating"] = list(state.mutating)
|
|
732
|
+
if state.error_flag is not None:
|
|
733
|
+
state_block["error_flag"] = state.error_flag
|
|
734
|
+
data["state"] = state_block
|
|
735
|
+
if verification.invariants:
|
|
736
|
+
data["invariants"] = {
|
|
737
|
+
fn_name: [_dump_property(p) for p in properties]
|
|
738
|
+
for fn_name, properties in verification.invariants.items()
|
|
739
|
+
}
|
|
740
|
+
if verification.ranges:
|
|
741
|
+
data["ranges"] = {
|
|
742
|
+
name: [rng.lo, rng.hi] for name, rng in verification.ranges.items()
|
|
743
|
+
}
|
|
744
|
+
lattice_config = verification.lattice
|
|
745
|
+
if not lattice_config.is_empty:
|
|
746
|
+
lattice_block: dict = {}
|
|
747
|
+
if lattice_config.n is not None:
|
|
748
|
+
lattice_block["n"] = lattice_config.n
|
|
749
|
+
if lattice_config.scatter.count or lattice_config.scatter.seed is not None:
|
|
750
|
+
lattice_block["scatter"] = {
|
|
751
|
+
"seed": lattice_config.scatter.seed,
|
|
752
|
+
"count": lattice_config.scatter.count,
|
|
753
|
+
}
|
|
754
|
+
if lattice_config.corners:
|
|
755
|
+
lattice_block["corners"] = {
|
|
756
|
+
fn_name: [list(corner) for corner in corners]
|
|
757
|
+
for fn_name, corners in lattice_config.corners.items()
|
|
758
|
+
}
|
|
759
|
+
data["lattice"] = lattice_block
|
|
760
|
+
return data
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@dataclass
|
|
764
|
+
class ServiceConfig:
|
|
765
|
+
name: str
|
|
766
|
+
language: str
|
|
767
|
+
expose: ExposeConfig = field(default_factory=ExposeConfig)
|
|
768
|
+
# "auto" (Clang AST when libclang is importable, else the regex reader),
|
|
769
|
+
# "clang" (require the AST parser), or "regex" (force the fallback).
|
|
770
|
+
parser: str = "auto"
|
|
771
|
+
clang: ClangConfig = field(default_factory=ClangConfig)
|
|
772
|
+
# Shared native libraries under libraries/ that this service links
|
|
773
|
+
# against (design.md section 4). Each entry is a directory name, e.g.
|
|
774
|
+
# "common-cpp" -> libraries/common-cpp/ with its own CMakeLists.txt.
|
|
775
|
+
libraries: list[str] = field(default_factory=list)
|
|
776
|
+
# Directories searched for Fortran INCLUDE files (.INC), relative to the
|
|
777
|
+
# repo root. Legacy F77 keeps COMMON blocks and IMPLICIT statements there.
|
|
778
|
+
include_paths: list[str] = field(default_factory=list)
|
|
779
|
+
# Authentication for the generated HTTP API. See ApiConfig.
|
|
780
|
+
api: ApiConfig = field(default_factory=ApiConfig)
|
|
781
|
+
# -D flags for preprocessed Fortran (.F90/.F, or #ifdef in lowercase
|
|
782
|
+
# files), applied when nativegate runs gfortran's preprocessor to produce
|
|
783
|
+
# the `_expanded` copy. Same reason clang.defines exists for C++: the
|
|
784
|
+
# branches differ, and only the build knows which one is live.
|
|
785
|
+
fortran_defines: list[str] = field(default_factory=list)
|
|
786
|
+
# `state:`/`invariants:`/`ranges:` — layer 3 declarations
|
|
787
|
+
# (design-verification-layers.md §3.2-3.5). Optional: a service with no
|
|
788
|
+
# invariants.json coverage simply has an empty VerificationConfig.
|
|
789
|
+
verification: VerificationConfig = field(default_factory=VerificationConfig)
|
|
790
|
+
|
|
791
|
+
@classmethod
|
|
792
|
+
def load(cls, service_dir: Path) -> "ServiceConfig":
|
|
793
|
+
config_path = service_dir / CONFIG_FILENAME
|
|
794
|
+
if not config_path.exists():
|
|
795
|
+
raise FileNotFoundError(
|
|
796
|
+
f"No {CONFIG_FILENAME} found in {service_dir}. "
|
|
797
|
+
"Run `ngate create-service` first."
|
|
798
|
+
)
|
|
799
|
+
data = yaml.safe_load(config_path.read_text()) or {}
|
|
800
|
+
expose = _load_expose(data.get("expose"), config_path)
|
|
801
|
+
clang_data = data.get("clang") or {}
|
|
802
|
+
# `None` means "no restriction": `expose: all` or an empty `expose:`
|
|
803
|
+
# block (the historical permissive default) both bind whatever the
|
|
804
|
+
# parser finds, so there is no fixed name list to check `state:`/
|
|
805
|
+
# `invariants:` references against. An explicit `expose.functions:`
|
|
806
|
+
# list IS that fixed list, and is enforced.
|
|
807
|
+
known_functions = None if (expose.all or expose.is_empty) else set(expose.functions)
|
|
808
|
+
return cls(
|
|
809
|
+
name=data.get("name", service_dir.name),
|
|
810
|
+
language=_load_language(data.get("language"), service_dir, config_path),
|
|
811
|
+
expose=expose,
|
|
812
|
+
parser=str(data.get("parser") or "auto"),
|
|
813
|
+
clang=ClangConfig(
|
|
814
|
+
std=str(clang_data.get("std") or "c++17"),
|
|
815
|
+
include_paths=list(clang_data.get("include_paths") or []),
|
|
816
|
+
defines=list(clang_data.get("defines") or []),
|
|
817
|
+
extra_args=list(clang_data.get("extra_args") or []),
|
|
818
|
+
scalar_ref_functions=list(clang_data.get("scalar_ref_functions") or []),
|
|
819
|
+
),
|
|
820
|
+
libraries=list(data.get("libraries") or []),
|
|
821
|
+
include_paths=list(data.get("include_paths") or []),
|
|
822
|
+
api=_load_api(data.get("api"), config_path),
|
|
823
|
+
fortran_defines=list((data.get("fortran") or {}).get("defines") or []),
|
|
824
|
+
verification=_load_verification(data, config_path, known_functions),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
def save(self, service_dir: Path) -> None:
|
|
828
|
+
data = {
|
|
829
|
+
"name": self.name,
|
|
830
|
+
"language": self.language,
|
|
831
|
+
"expose": {
|
|
832
|
+
"classes": self.expose.classes,
|
|
833
|
+
"functions": self.expose.functions,
|
|
834
|
+
},
|
|
835
|
+
}
|
|
836
|
+
if self.expose.all is not None:
|
|
837
|
+
data["expose"]["all"] = self.expose.all
|
|
838
|
+
if self.parser != "auto":
|
|
839
|
+
data["parser"] = self.parser
|
|
840
|
+
clang = {
|
|
841
|
+
key: value
|
|
842
|
+
for key, value in {
|
|
843
|
+
"std": self.clang.std if self.clang.std != "c++17" else None,
|
|
844
|
+
"include_paths": self.clang.include_paths,
|
|
845
|
+
"defines": self.clang.defines,
|
|
846
|
+
"extra_args": self.clang.extra_args,
|
|
847
|
+
}.items()
|
|
848
|
+
if value
|
|
849
|
+
}
|
|
850
|
+
if clang:
|
|
851
|
+
data["clang"] = clang
|
|
852
|
+
if self.libraries:
|
|
853
|
+
data["libraries"] = self.libraries
|
|
854
|
+
if self.include_paths:
|
|
855
|
+
data["include_paths"] = self.include_paths
|
|
856
|
+
# Only written when it differs from the default, so an existing
|
|
857
|
+
# nativegate.yaml does not grow a key that says nothing.
|
|
858
|
+
if self.api.auth != "none":
|
|
859
|
+
data["api"] = {"auth": self.api.auth}
|
|
860
|
+
verification = _dump_verification(self.verification)
|
|
861
|
+
if verification:
|
|
862
|
+
data.update(verification)
|
|
863
|
+
(service_dir / CONFIG_FILENAME).write_text(yaml.dump(data, sort_keys=False))
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _load_api(api_data, config_path: Path) -> ApiConfig:
|
|
867
|
+
"""Read the `api:` block.
|
|
868
|
+
|
|
869
|
+
An unrecognised auth mode is an error, not a fallback to `none`. Quietly
|
|
870
|
+
treating `api: {auth: apikey}` (a plausible typo) as "no authentication"
|
|
871
|
+
would produce exactly the silently-open service this setting exists to
|
|
872
|
+
prevent.
|
|
873
|
+
"""
|
|
874
|
+
api_data = api_data or {}
|
|
875
|
+
if not isinstance(api_data, dict):
|
|
876
|
+
raise ConfigError(
|
|
877
|
+
f"{config_path}: `api:` must be a block, got {type(api_data).__name__}."
|
|
878
|
+
)
|
|
879
|
+
auth = str(api_data.get("auth") or "none").strip().lower()
|
|
880
|
+
if auth not in ("none", "api_key"):
|
|
881
|
+
raise ConfigError(
|
|
882
|
+
f"{config_path}: `api.auth: {auth}` is not understood. Use "
|
|
883
|
+
"`none` or `api_key`. Refusing to default to `none`, which would "
|
|
884
|
+
"leave the service open on a typo."
|
|
885
|
+
)
|
|
886
|
+
return ApiConfig(auth=auth)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _load_expose(expose_data, config_path: Path) -> ExposeConfig:
|
|
890
|
+
"""Read an `expose:` block, requiring an explicit opt-in for "bind everything".
|
|
891
|
+
|
|
892
|
+
`expose: all` (or `expose: {all: true}`) states the intent. An empty block
|
|
893
|
+
keeps the historical permissive behaviour — the C++ parsers depend on it,
|
|
894
|
+
and on `[[nativegate::expose]]` annotations in the source — but says so out
|
|
895
|
+
loud rather than binding a whole header on an unstated default.
|
|
896
|
+
"""
|
|
897
|
+
if isinstance(expose_data, str):
|
|
898
|
+
if expose_data.strip().lower() != "all":
|
|
899
|
+
raise ConfigError(
|
|
900
|
+
f"{config_path}: `expose: {expose_data}` is not understood. Use "
|
|
901
|
+
"`expose: all`, or a block with `classes:`/`functions:` lists."
|
|
902
|
+
)
|
|
903
|
+
return ExposeConfig(all=True)
|
|
904
|
+
|
|
905
|
+
# Checked before the `or {}` below, which cannot tell `False` from an
|
|
906
|
+
# omitted key — `False or {}` is `{}`. That coercion turned an explicit
|
|
907
|
+
# `expose: false` ("bind nothing") into "not stated", which then took the
|
|
908
|
+
# permissive branch and bound the entire native API: the exact opposite of
|
|
909
|
+
# what was written, with only a warning suggesting the user say `expose:
|
|
910
|
+
# all`.
|
|
911
|
+
if isinstance(expose_data, bool):
|
|
912
|
+
return ExposeConfig(all=expose_data)
|
|
913
|
+
|
|
914
|
+
expose_data = expose_data or {}
|
|
915
|
+
if not isinstance(expose_data, dict):
|
|
916
|
+
raise ConfigError(
|
|
917
|
+
f"{config_path}: `expose:` must be a mapping or the word `all`, "
|
|
918
|
+
f"not {type(expose_data).__name__}."
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
all_value = expose_data.get("all")
|
|
922
|
+
if all_value is not None and not isinstance(all_value, bool):
|
|
923
|
+
raise ConfigError(
|
|
924
|
+
f"{config_path}: `expose.all` must be true or false, got {all_value!r}."
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
expose = ExposeConfig(
|
|
928
|
+
classes=list(expose_data.get("classes") or []),
|
|
929
|
+
functions=list(expose_data.get("functions") or []),
|
|
930
|
+
all=all_value,
|
|
931
|
+
)
|
|
932
|
+
if expose.is_empty and expose.all is None:
|
|
933
|
+
warnings.warn(
|
|
934
|
+
f"{config_path}: `expose:` is empty, so every symbol nativegate finds "
|
|
935
|
+
"will be bound unless the source carries [[nativegate::expose]] "
|
|
936
|
+
"annotations. Say so explicitly with `expose: all`, or list the "
|
|
937
|
+
"classes/functions you want under `expose.classes:` / "
|
|
938
|
+
"`expose.functions:`.",
|
|
939
|
+
ExposeWarning,
|
|
940
|
+
stacklevel=3,
|
|
941
|
+
)
|
|
942
|
+
return expose
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _load_language(value, service_dir: Path, config_path: Path) -> str:
|
|
946
|
+
"""Require `language:`, or infer it from the sources — never default to cpp."""
|
|
947
|
+
from .discovery import detect_language # local: discovery has no config deps
|
|
948
|
+
|
|
949
|
+
def discovered() -> set[str]:
|
|
950
|
+
# Only native/ — bindings/generated/ holds generated .cpp for every
|
|
951
|
+
# service, Fortran ones included, and would poison the inference.
|
|
952
|
+
native_dir = service_dir / "native"
|
|
953
|
+
if not native_dir.is_dir():
|
|
954
|
+
return set()
|
|
955
|
+
return {
|
|
956
|
+
lang
|
|
957
|
+
for path in native_dir.rglob("*")
|
|
958
|
+
if path.is_file() and (lang := detect_language(path)) is not None
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if value is None:
|
|
962
|
+
found = discovered()
|
|
963
|
+
if len(found) == 1:
|
|
964
|
+
return found.pop()
|
|
965
|
+
if not found:
|
|
966
|
+
raise ConfigError(
|
|
967
|
+
f"{config_path}: `language:` is missing and no native sources were "
|
|
968
|
+
f"found under {service_dir} to infer it from. Set `language:` to one "
|
|
969
|
+
f"of {', '.join(SUPPORTED_LANGUAGES)}."
|
|
970
|
+
)
|
|
971
|
+
raise ConfigError(
|
|
972
|
+
f"{config_path}: `language:` is missing and the sources under "
|
|
973
|
+
f"{service_dir} are mixed ({', '.join(sorted(found))}). Set `language:` "
|
|
974
|
+
"explicitly."
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
language = str(value).strip().lower()
|
|
978
|
+
if language not in SUPPORTED_LANGUAGES:
|
|
979
|
+
raise ConfigError(
|
|
980
|
+
f"{config_path}: unsupported `language: {value}`. Supported languages "
|
|
981
|
+
f"are {', '.join(SUPPORTED_LANGUAGES)}."
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
found = discovered()
|
|
985
|
+
if found and language not in found:
|
|
986
|
+
raise ConfigError(
|
|
987
|
+
f"{config_path}: `language: {language}` conflicts with the sources "
|
|
988
|
+
f"under {service_dir}, which are {', '.join(sorted(found))}. Fix "
|
|
989
|
+
"`language:` or remove the sources that do not belong."
|
|
990
|
+
)
|
|
991
|
+
return language
|