lockwire 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/edit.py ADDED
@@ -0,0 +1,2098 @@
1
+ """``lockwire declare`` / ``lockwire apply`` / ``lockwire add`` — the edit primitive.
2
+
3
+ The YAML is the truth. Every edit lands as one declared fact carrying a
4
+ source and a witness, written through a ruamel round-trip load/modify/dump
5
+ so existing comments, key order and quoting style survive untouched outside the
6
+ edited lines. Nothing is written unless the edited document validates through
7
+ ``Contract`` first — the same byte cap and anchor/alias refusal the rest of the
8
+ codebase applies to a lockfile apply here too.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import datetime as dt
14
+ import difflib
15
+ import fcntl
16
+ import hashlib
17
+ import io
18
+ import json
19
+ import math
20
+ import os
21
+ import re
22
+ import tempfile
23
+ from collections.abc import Iterator
24
+ from contextlib import contextmanager
25
+ from contextvars import ContextVar
26
+ from dataclasses import dataclass, field
27
+ from functools import wraps
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from ruamel.yaml import YAML, CommentedMap
32
+ from ruamel.yaml.constructor import RoundTripConstructor
33
+ from ruamel.yaml.representer import RoundTripRepresenter
34
+ from ruamel.yaml.scalarfloat import ScalarFloat
35
+
36
+ from lockwire.schema import (
37
+ Contract,
38
+ MAX_CONTRACT_BYTES,
39
+ _guard_contract_raw,
40
+ _load_yaml_guarded,
41
+ _model_structure_hash,
42
+ _reject_yaml_anchors_aliases,
43
+ SCHEMA_VERSION,
44
+ SUPPORTED_SCHEMA_VERSIONS,
45
+ UnsupportedSchemaVersion,
46
+ load_contract,
47
+ )
48
+ from lockwire import units
49
+ from lockwire.report import LIMIT_RAISED_MARK
50
+
51
+
52
+ class EditRefusal(ValueError):
53
+ """A declare/apply/add refused before writing anything."""
54
+
55
+
56
+ class EditConflict(EditRefusal):
57
+ """The bytes a caller edited are no longer the bytes on disk."""
58
+
59
+
60
+ class EditRecoveryError(OSError):
61
+ """A failed edit left recovery bytes in a named artifact."""
62
+
63
+ def __init__(
64
+ self,
65
+ recovery_artifact: Path,
66
+ replace_error: BaseException,
67
+ recovery_error: BaseException,
68
+ *,
69
+ contract_state: str | None = None,
70
+ backup_state: str | None = None,
71
+ recovery_action: str = "rollback",
72
+ ):
73
+ self.recovery_artifact = recovery_artifact
74
+ self.contract_state = contract_state
75
+ self.backup_state = backup_state
76
+ self.recovery_action = recovery_action
77
+ if contract_state is None:
78
+ message = (
79
+ f"contract replacement failed ({replace_error}); backup rollback failed "
80
+ f"({recovery_error}); prior undo bytes are at {recovery_artifact}"
81
+ )
82
+ else:
83
+ message = (
84
+ f"contract replacement failed ({replace_error}); backup {recovery_action} failed "
85
+ f"({recovery_error}); contract is {contract_state}; {backup_state} at "
86
+ f"{recovery_artifact}"
87
+ )
88
+ super().__init__(message)
89
+
90
+
91
+ _locked_contracts: ContextVar[frozenset[Path]] = ContextVar("locked_contracts", default=frozenset())
92
+
93
+
94
+ def contract_revision(path: Path | str) -> str:
95
+ """Opaque revision for the exact contract bytes, not its semantic hash."""
96
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
97
+
98
+
99
+ @contextmanager
100
+ def _contract_lock(path: Path) -> Iterator[None]:
101
+ """Serialize cooperating writers with a sibling advisory lock."""
102
+ lock_path = path.with_name(f".{path.name}.lock")
103
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
104
+ try:
105
+ fcntl.flock(fd, fcntl.LOCK_EX)
106
+ yield
107
+ finally:
108
+ fcntl.flock(fd, fcntl.LOCK_UN)
109
+ os.close(fd)
110
+
111
+
112
+ def _locked_write(function: Any) -> Any:
113
+ """Lock, then compare raw bytes before any writer reads its document."""
114
+
115
+ @wraps(function)
116
+ def wrapped(
117
+ contract_path: Path, *args: Any, expected_revision: str | None = None, **kwargs: Any
118
+ ) -> Any:
119
+ resolved = contract_path.resolve()
120
+ if resolved in _locked_contracts.get():
121
+ return function(contract_path, *args, expected_revision=expected_revision, **kwargs)
122
+ with _contract_lock(resolved):
123
+ actual = contract_revision(resolved)
124
+ if expected_revision is not None and expected_revision != actual:
125
+ raise EditConflict(
126
+ "contract changed since it was opened; reload before applying this edit"
127
+ )
128
+ token = _locked_contracts.set(_locked_contracts.get() | {resolved})
129
+ try:
130
+ return function(resolved, *args, expected_revision=expected_revision, **kwargs)
131
+ finally:
132
+ _locked_contracts.reset(token)
133
+
134
+ return wrapped
135
+
136
+
137
+ # the style `init` writes when no existing list tells us otherwise —
138
+ # schema.py's `save_contract` uses the same setting, so the two agree.
139
+ _DEFAULT_SEQUENCE_STYLE = (4, 2) # (sequence indent, dash offset)
140
+
141
+ _SEQUENCE_STYLE_RE = re.compile(r"^([ \t]*)\S[^\n:]*:[ \t]*\n\1([ \t]*)-(?:[ \t]|$)", re.MULTILINE)
142
+
143
+
144
+ def _sniff_sequence_style(text: str) -> tuple[int, int]:
145
+ """This file's own list style: (sequence indent, dash offset).
146
+
147
+ An `init`-written contract opens a list right under its key
148
+ (``links:\\n - name:``, offset 0); a file the editor already touched, or
149
+ one written by hand at the tool's other historic default, indents the
150
+ dash under its key (``devices:\\n - id:``, offset 2). ruamel's indent
151
+ setting is document-wide, not per-key, so ANY block list in the file
152
+ (top-level or nested) tells us the whole file's style — read it from the
153
+ first ``key:`` line immediately followed by a ``- `` item at the same or
154
+ deeper indent. No block list anywhere (a brand-new/list-less file) falls
155
+ back to the same style `init` writes, so the two always agree.
156
+ """
157
+ match = _SEQUENCE_STYLE_RE.search(text)
158
+ if not match:
159
+ return _DEFAULT_SEQUENCE_STYLE
160
+ offset = len(match.group(2))
161
+ return offset + 2, offset
162
+
163
+
164
+ _SRC_TEXT_ATTR = "_lockwire_src_text"
165
+
166
+
167
+ def _construct_yaml_float_keeping_src(constructor: Any, node: Any) -> Any:
168
+ """Load a float the normal ruamel way, then remember its exact source text.
169
+
170
+ `ScalarFloat` does not keep the characters it was parsed from — only
171
+ width/precision/exponent metadata, which its own representer uses to
172
+ *reconstruct* a string. That reconstruction is lossy: on a full
173
+ double-precision mantissa it can drift the last digit by one ULP even
174
+ though the field was never touched (seen on a
175
+ real `init anymal_d.urdf` -> `add family`: 10 untouched floats drifted —
176
+ comparing the reconstructed text's parsed *value* to the original
177
+ misses all of them, since a drifted string still parses back to
178
+ the same float64). Stashing the raw text here and having the representer
179
+ (below) emit it verbatim for an unchanged node is the only reliable fix.
180
+ """
181
+ value = RoundTripConstructor.construct_yaml_float(constructor, node)
182
+ if isinstance(value, ScalarFloat):
183
+ setattr(value, _SRC_TEXT_ATTR, node.value)
184
+ return value
185
+
186
+
187
+ def _represent_scalar_float_keeping_src(representer: Any, data: Any) -> Any:
188
+ """Emit a `ScalarFloat`'s ORIGINAL text verbatim when it carries one.
189
+
190
+ Only a node untouched since load keeps `_lockwire_src_text` (it lives on
191
+ the same `ScalarFloat` instance `_construct_yaml_float_keeping_src`
192
+ tagged); anything `declare`/`add` set is a plain float/int/new
193
+ `ScalarFloat` from `parse_value` and goes through ruamel's normal
194
+ (reconstructing) path instead — which is fine, it has no "original" text
195
+ to preserve.
196
+ """
197
+ node = RoundTripRepresenter.represent_scalar_float(representer, data)
198
+ src = getattr(data, _SRC_TEXT_ATTR, None)
199
+ if src is not None:
200
+ node.value = src
201
+ return node
202
+
203
+
204
+ def _yaml(sequence: int = 4, offset: int = 2) -> YAML:
205
+ """This module's own round-trip settings — never schema.py's loader.
206
+
207
+ Preserve existing quote characters, the file's own sniffed list style
208
+ (see `_sniff_sequence_style`), and never re-wrap a long line (ruamel's
209
+ default width is 80 and counts against every scalar, not just prose — a
210
+ long provenance note would otherwise get rewrapped on every write that
211
+ merely touches a sibling key). The float constructor/representer pair is
212
+ registered so an untouched float scalar survives byte-for-byte.
213
+ """
214
+ y = YAML()
215
+ y.default_flow_style = False
216
+ y.preserve_quotes = True
217
+ y.indent(mapping=2, sequence=sequence, offset=offset)
218
+ y.width = 10_000
219
+ y.constructor.add_constructor("tag:yaml.org,2002:float", _construct_yaml_float_keeping_src)
220
+ y.representer.add_representer(ScalarFloat, _represent_scalar_float_keeping_src)
221
+ return y
222
+
223
+
224
+ # ---------------------------------------------------------------- value parsing
225
+
226
+ _BOOL = {"true": True, "false": False}
227
+ _UNIT_CHARS = r"[A-Za-z0-9µ/*^%]+"
228
+ _NUM = r"-?\d+(?:\.\d+)?"
229
+ _SINGLE_RE = re.compile(rf"^({_NUM})\s*({_UNIT_CHARS})$")
230
+ _TRIPLE_RE = re.compile(rf"^\[?\s*({_NUM})[,\s]+({_NUM})[,\s]+({_NUM})\s*\]?\s+({_UNIT_CHARS})$")
231
+
232
+
233
+ def _flow_quantity(value: float, unit: str) -> CommentedMap:
234
+ """A `{value, unit}` quantity written flow-style, matching every one in the fixtures."""
235
+ q = CommentedMap([("value", value), ("unit", unit)])
236
+ q.fa.set_flow_style()
237
+ return q
238
+
239
+
240
+ def parse_value(raw: str, *, field_name: str) -> Any:
241
+ """Parse a `declare`/`apply` VALUE string into a YAML-writable python value.
242
+
243
+ Grammar: a bare number -> number; "25 W" -> {value, unit}; a triple
244
+ ("0.12 0 0.05 m" or "[0.12, 0, 0.05] m") -> a 3-tuple of quantities, only
245
+ for ``mount_xyz``; true/false -> bool; a quoted string -> string; anything
246
+ else is kept as a plain string.
247
+ """
248
+ text = raw.strip()
249
+ if field_name == "mount_shape":
250
+ try:
251
+ value = json.loads(text)
252
+ except json.JSONDecodeError as error:
253
+ raise EditRefusal(
254
+ "mount_shape must be a JSON object with kind and dimensions_mm"
255
+ ) from error
256
+ if not isinstance(value, dict):
257
+ raise EditRefusal("mount_shape must be a JSON object")
258
+ return value
259
+ if text.lower() in _BOOL:
260
+ return _BOOL[text.lower()]
261
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'":
262
+ return text[1:-1]
263
+ try:
264
+ return int(text)
265
+ except ValueError:
266
+ pass
267
+ try:
268
+ return float(text)
269
+ except ValueError:
270
+ pass
271
+ if field_name == "mount_xyz":
272
+ triple = _TRIPLE_RE.match(text)
273
+ if triple:
274
+ x, y, z, unit = triple.groups()
275
+ if not units.known(unit):
276
+ raise EditRefusal(f"unknown unit {unit!r} in {raw!r}")
277
+ return [
278
+ _flow_quantity(float(x), unit),
279
+ _flow_quantity(float(y), unit),
280
+ _flow_quantity(float(z), unit),
281
+ ]
282
+ single = _SINGLE_RE.match(text)
283
+ if single:
284
+ value, unit = single.groups()
285
+ if units.known(unit):
286
+ return _flow_quantity(float(value), unit)
287
+ raise EditRefusal(f"unknown unit {unit!r} in {raw!r}")
288
+ return text
289
+
290
+
291
+ def _attach_unit(value: Any, unit: str) -> Any:
292
+ """Merge a change entry's sibling `unit:` key into its `value:`.
293
+
294
+ `lockwire scene import` writes the raw model numbers this way: a bare
295
+ 3-list for a mount_xyz triple, or a bare number for a single quantity —
296
+ the schema's `{value, unit}` shape is the schema layer's job, not the importer's.
297
+ """
298
+ if not units.known(unit):
299
+ raise EditRefusal(f"unknown unit {unit!r}")
300
+ if (
301
+ isinstance(value, list)
302
+ and len(value) == 3
303
+ and all(isinstance(v, int | float) for v in value)
304
+ ):
305
+ return [_flow_quantity(float(v), unit) for v in value]
306
+ if isinstance(value, int | float):
307
+ return _flow_quantity(float(value), unit)
308
+ return value
309
+
310
+
311
+ def format_value(value: Any) -> str:
312
+ """Render a parsed value the way `declared ...` and diffs describe it."""
313
+ if isinstance(value, bool):
314
+ return "true" if value else "false"
315
+ if isinstance(value, dict) and "value" in value and "unit" in value:
316
+ return f"{value['value']:g} {value['unit']}"
317
+ if (
318
+ isinstance(value, list)
319
+ and value
320
+ and all(isinstance(v, dict) and "value" in v and "unit" in v for v in value)
321
+ ):
322
+ nums = ", ".join(f"{v['value']:g}" for v in value)
323
+ return f"[{nums}] {value[0]['unit']}"
324
+ return str(value)
325
+
326
+
327
+ # ---------------------------------------------------------------- path grammar
328
+
329
+ _TOPOLOGY_OWNER_COLLECTIONS = {"rails", "buses", "hosts", "wires"}
330
+ _TOP_LEVEL_ID_COLLECTIONS = {"devices", "device_models", "budgets", "families"}
331
+
332
+
333
+ def split_path(path: str) -> list[str]:
334
+ segments = [s for s in path.split("/") if s]
335
+ if not segments:
336
+ raise EditRefusal(f"empty path {path!r}")
337
+ return segments
338
+
339
+
340
+ def _navigate(doc: Any, segments: list[str], *, create: bool) -> Any:
341
+ """Walk `doc` along `segments`; create missing dict children when `create`.
342
+
343
+ Never creates a new list entry — reaching a list segment that no id/name
344
+ matches is always a refusal (that is `lockwire add`'s job, not declare's).
345
+ """
346
+ node = doc
347
+ walked: list[str] = []
348
+ for seg in segments:
349
+ walked.append(seg)
350
+ if isinstance(node, dict):
351
+ if seg not in node or node[seg] is None:
352
+ if not create:
353
+ raise EditRefusal(f"no key {seg!r} at {'/'.join(walked)}")
354
+ node[seg] = CommentedMap()
355
+ node = node[seg]
356
+ elif isinstance(node, list):
357
+ hit = next(
358
+ (
359
+ x
360
+ for x in node
361
+ if isinstance(x, dict) and (x.get("id") == seg or x.get("name") == seg)
362
+ ),
363
+ None,
364
+ )
365
+ if hit is None:
366
+ raise EditRefusal(
367
+ f"no entry with id/name {seg!r} at {'/'.join(walked)} "
368
+ "(use `lockwire add` to create a new device/rail/bus)"
369
+ )
370
+ node = hit
371
+ else:
372
+ raise EditRefusal(f"{'/'.join(walked)} reached a leaf, not a container")
373
+ return node
374
+
375
+
376
+ def provenance_owner_path(segments: list[str]) -> list[str]:
377
+ """Prefix of `segments` naming the block whose `provenance` owns this fact."""
378
+ if (
379
+ len(segments) >= 3
380
+ and segments[0] == "topology"
381
+ and segments[1] in _TOPOLOGY_OWNER_COLLECTIONS
382
+ ):
383
+ return segments[:3]
384
+ if len(segments) >= 2 and segments[0] in _TOP_LEVEL_ID_COLLECTIONS:
385
+ return segments[:2]
386
+ if len(segments) >= 2 and segments[0] == "topology" and segments[1] == "battery":
387
+ return segments[:2]
388
+ # `model/links` is a name-addressed list, same shape as the
389
+ # topology owner collections above -- the owning block is the individual
390
+ # link entry (`model/links/<name>`), not the `links` list itself. Checked
391
+ # BEFORE the general `model/...` fallback below so a link's own name-scoped
392
+ # owner wins over the whole-record catch-all that would otherwise claim it.
393
+ if len(segments) >= 3 and segments[0] == "model" and segments[1] == "links":
394
+ return segments[:3]
395
+ # The model record owns every OTHER fact under it. `model/joints` (and
396
+ # `model/links` before the branch above existed) are LISTS, and neither
397
+ # `Joint` nor `Link` carries a `provenance` field (schema.py, frozen),
398
+ # so the default `segments[:2]` resolved the owner to a list and
399
+ # refused every `model/...` declare outright:
400
+ # `declare model/joints/imu_mount/limits/effort 3.0` exited 2 with "cannot
401
+ # stamp provenance at model/joints". `ModelRecord.provenance` is the block
402
+ # that already covers the structure, so it is the owner.
403
+ if segments[0] == "model":
404
+ return ["model"]
405
+ return segments[: min(2, len(segments))]
406
+
407
+
408
+ # owners with no `provenance` field of their own (schema.py forbids
409
+ # extras — a field is only added by a schema change) get a comment stamp instead.
410
+ # `Link` and `ActuatorCase` likewise have no provenance field; their changed
411
+ # facts receive source/witness comments rather than invalid extra schema keys.
412
+ # Entries are matched by PREFIX because the resolved owner includes its name
413
+ # or id while the family tuple stays fixed.
414
+ _UNSTRUCTURED_PROVENANCE_OWNERS = {
415
+ ("topology", "battery"),
416
+ ("model", "links"),
417
+ ("actuator_cases",),
418
+ }
419
+
420
+
421
+ def _is_unstructured_owner(owner_segments: list[str]) -> bool:
422
+ return any(
423
+ tuple(owner_segments[: len(family)]) == family for family in _UNSTRUCTURED_PROVENANCE_OWNERS
424
+ )
425
+
426
+
427
+ def _stamp_provenance(
428
+ doc: Any,
429
+ owner_segments: list[str],
430
+ *,
431
+ field_leaf: str,
432
+ source: str,
433
+ witness: str,
434
+ note: str | None,
435
+ locked_at: dt.date,
436
+ ) -> None:
437
+ """Stamp the owning block's provenance without erasing another fact's.
438
+
439
+ A device/rail/bus has ONE `provenance` block for every fact it carries
440
+ (mass, radio numbers, mount point, ...) — the honest fix is provenance
441
+ per fact, a schema change. Until then:
442
+ a fresh provenance is written plainly; an EXISTING one keeps its
443
+ witness/source (the facts it already covers) and gets the new fact
444
+ appended to `source` and `note` instead of overwritten.
445
+ """
446
+ owner = _navigate(doc, owner_segments, create=False)
447
+ if not isinstance(owner, dict):
448
+ raise EditRefusal(f"cannot stamp provenance at {'/'.join(owner_segments)}")
449
+ if _is_unstructured_owner(owner_segments):
450
+ if owner_segments[0] == "actuator_cases" and doc.fa.flow_style():
451
+ # A JSON-shaped contract cannot serialize a nested YAML comment
452
+ # while its root/list stay flow-styled. The proposal must expose
453
+ # the authored source and witness in its reviewable diff.
454
+ doc.fa.set_block_style()
455
+ doc["actuator_cases"].fa.set_block_style()
456
+ owner.fa.set_block_style()
457
+ _stamp_comment_provenance(
458
+ owner,
459
+ field_leaf=field_leaf,
460
+ source=source,
461
+ witness=witness,
462
+ note=note,
463
+ locked_at=locked_at,
464
+ )
465
+ return
466
+ prov = owner.get("provenance")
467
+ stamp = f"{locked_at.isoformat()}: {field_leaf} declared, witness {witness}"
468
+ if prov is None:
469
+ prov = CommentedMap()
470
+ owner["provenance"] = prov
471
+ prov["witness"] = witness
472
+ prov["source"] = source
473
+ prov["note"] = stamp
474
+ else:
475
+ if not prov.get("witness"):
476
+ prov["witness"] = witness
477
+ old_source = prov.get("source")
478
+ appended = f"{field_leaf} from {source} ({locked_at.isoformat()})"
479
+ prov["source"] = f"{old_source}; {appended}" if old_source else source
480
+ old_note = prov.get("note")
481
+ prov["note"] = f"{old_note}\n{stamp}" if old_note else stamp
482
+ prov["locked_at"] = locked_at
483
+ if note:
484
+ prov["note"] = f"{prov['note']}\n{locked_at.isoformat()}: {note}"
485
+
486
+
487
+ def _stamp_comment_provenance(
488
+ owner: Any,
489
+ *,
490
+ field_leaf: str,
491
+ source: str,
492
+ witness: str,
493
+ note: str | None,
494
+ locked_at: dt.date,
495
+ ) -> None:
496
+ """Record a fact's source/witness as a YAML comment when the owner has no
497
+ `provenance` field of its own (`_UNSTRUCTURED_PROVENANCE_OWNERS`).
498
+
499
+ Stamped directly above `field_leaf`'s own key inside `owner` (the block
500
+ that was just navigated to and already carries the newly-written field) --
501
+ works whether `owner` is a dict-shaped singleton reached by a fixed key
502
+ (`topology/battery`) or a name-addressed list entry reached by its own
503
+ `name` (`model/links/x500_frame`); both are plain `CommentedMap`s once
504
+ resolved, so the same call handles either.
505
+
506
+ `yaml_set_comment_before_after_key` is additive, not a replace — each
507
+ call adds one more comment line above the key, so successive stamps
508
+ accumulate the same way a structured provenance's `source`/`note` do.
509
+ Unstructured and best-effort: nothing here is read back by `check` or
510
+ `describe`.
511
+ """
512
+ stamp = f"{locked_at.isoformat()}: {field_leaf} declared, witness {witness}, source {source}"
513
+ if note:
514
+ stamp += f"; {note}"
515
+ owner.yaml_set_comment_before_after_key(field_leaf, before=stamp, indent=2)
516
+
517
+
518
+ # ---------------------------------------------------------------- change model
519
+
520
+
521
+ # ---------------------------------------------------------------- the standing mark
522
+
523
+
524
+ def _budget_limit_changes(changes: list["Change"]) -> dict[str, "Change"]:
525
+ """The changes that declare a `budgets/<id>/limit`, keyed by budget id."""
526
+ out: dict[str, Change] = {}
527
+ for change in changes:
528
+ segments = split_path(change.path)
529
+ if len(segments) == 3 and segments[0] == "budgets" and segments[2] == "limit":
530
+ out[segments[1]] = change
531
+ return out
532
+
533
+
534
+ def _cleared_by_raising(before: Any, after: Any) -> set[str]:
535
+ """Budget ids whose limit GREW and whose violation went away with it.
536
+
537
+ The two halves are both load-bearing: a limit that grew but left the finding
538
+ standing is not a cleared violation, and a violation that went away while the
539
+ limit fell was cleared by shedding real draw, which needs no mark. Violations
540
+ are matched by their full contract path, so the raised-limit case that trades
541
+ an overload for a *different* finding (draw > limit becomes limit > rail
542
+ capacity) is still a cleared overload and is still marked.
543
+ """
544
+ from lockwire.checks import check_budgets # local: checks.py imports report/scene
545
+
546
+ old_limits = {budget.id: budget.limit.base() for budget in before.budgets}
547
+ raised = {
548
+ budget.id
549
+ for budget in after.budgets
550
+ if budget.id in old_limits and budget.limit.base() > old_limits[budget.id]
551
+ }
552
+ if not raised:
553
+ return set()
554
+ gone = {
555
+ finding.quantity for finding in check_budgets(before) if finding.severity == "violation"
556
+ } - {finding.quantity for finding in check_budgets(after) if finding.severity == "violation"}
557
+ return {
558
+ budget_id
559
+ for budget_id in raised
560
+ if any(
561
+ quantity == f"budgets/{budget_id}" or quantity.startswith(f"budgets/{budget_id}/")
562
+ for quantity in gone
563
+ )
564
+ }
565
+
566
+
567
+ def _stamp_limit_raised(doc: Any, budget_id: str, *, witness: str, locked_at: dt.date) -> bool:
568
+ """Append the standing mark to the budget's provenance note. No new field:
569
+ `Provenance.note` is a schema field and the mark is text in it."""
570
+ for entry in doc.get("budgets") or []:
571
+ if not isinstance(entry, dict) or entry.get("id") != budget_id:
572
+ continue
573
+ prov = entry.get("provenance")
574
+ if not isinstance(prov, dict):
575
+ return False
576
+ mark = f"{LIMIT_RAISED_MARK}{locked_at.isoformat()}, witness {witness}"
577
+ old_note = prov.get("note")
578
+ prov["note"] = f"{old_note}\n{mark}" if old_note else mark
579
+ return True
580
+ return False
581
+
582
+
583
+ @dataclass
584
+ class Change:
585
+ path: str
586
+ value: Any # raw VALUE string, or an already-structured python value
587
+ source: str
588
+ witness: str
589
+ note: str | None = None
590
+ locked_at: dt.date | None = None
591
+ unit: str | None = None # sibling `unit:` key (lockwire scene import's change-file shape)
592
+
593
+
594
+ @dataclass
595
+ class EditResult:
596
+ diff: str
597
+ lines: list[str] = field(default_factory=list)
598
+ written: bool = False
599
+
600
+
601
+ @dataclass
602
+ class EditProposal:
603
+ """A validated edit that has not touched the filesystem."""
604
+
605
+ result: EditResult
606
+ contract: Contract
607
+ text: str
608
+ original_text: str
609
+
610
+
611
+ def _dump(doc: Any, style: tuple[int, int] = _DEFAULT_SEQUENCE_STYLE) -> str:
612
+ buf = io.StringIO()
613
+ _yaml(*style).dump(doc, buf)
614
+ return buf.getvalue()
615
+
616
+
617
+ def _load_doc_guarded(contract_path: Path) -> tuple[str, Any, tuple[int, int]]:
618
+ _guard_contract_raw(contract_path)
619
+ return _load_doc_bytes_guarded(contract_path.read_bytes())
620
+
621
+
622
+ def _load_doc_bytes_guarded(raw_bytes: bytes) -> tuple[str, Any, tuple[int, int]]:
623
+ size = len(raw_bytes)
624
+ if size > MAX_CONTRACT_BYTES:
625
+ raise ValueError(f"contract file exceeds size limit ({size} bytes > {MAX_CONTRACT_BYTES})")
626
+ text = raw_bytes.decode("utf-8")
627
+ style = _sniff_sequence_style(text)
628
+ doc = _load_yaml_guarded(_yaml(*style), io.StringIO(text))
629
+ _reject_yaml_anchors_aliases(doc)
630
+ return text, doc, style
631
+
632
+
633
+ def _loc_to_path(doc: Any, loc: tuple[Any, ...]) -> str:
634
+ """Best-effort readable path for a pydantic error location (index -> id)."""
635
+ node = doc
636
+ parts: list[str] = []
637
+ for seg in loc:
638
+ parts.append(str(seg))
639
+ if isinstance(node, dict):
640
+ node = node.get(seg)
641
+ elif isinstance(node, list) and isinstance(seg, int) and 0 <= seg < len(node):
642
+ item = node[seg]
643
+ if isinstance(item, dict) and "id" in item:
644
+ parts[-1] = str(item["id"])
645
+ node = item
646
+ else:
647
+ node = None
648
+ return "/".join(parts)
649
+
650
+
651
+ def _apply_collection_append(doc: Any, index: int, change: Change, collection_key: str) -> str:
652
+ """`apply` entry whose path IS a top-level collection: append each row.
653
+
654
+ `apply` could stamp a fact inside a
655
+ device/rail/bus, but a whole new `budgets`/`families`/`device_models` row
656
+ has no owning block yet to stamp provenance onto — `declare`'s generic
657
+ "set a leaf, stamp the parent's provenance" model does not fit "create the
658
+ parent". Each row gets its own provenance (kept as authored if the row
659
+ already carries one, e.g. from a hand-written change file; stamped from
660
+ this entry's --source/--witness otherwise).
661
+ """
662
+ rows = change.value
663
+ if not isinstance(rows, list) or not rows:
664
+ raise EditRefusal(f"entry {index} ({change.path}): expected a non-empty list of entries")
665
+ collection = _ensure_list(doc, [collection_key])
666
+ locked_at = change.locked_at or dt.date.today()
667
+ added: list[str] = []
668
+ for row in rows:
669
+ if not isinstance(row, dict) or not row.get("id"):
670
+ raise EditRefusal(f"entry {index} ({change.path}): every row needs an id")
671
+ _refuse_duplicate(collection, row["id"], collection_key)
672
+ entry = CommentedMap(row) if not isinstance(row, CommentedMap) else row
673
+ if not entry.get("provenance"):
674
+ prov = CommentedMap()
675
+ prov["witness"] = change.witness
676
+ prov["source"] = change.source
677
+ prov["locked_at"] = locked_at
678
+ if change.note:
679
+ prov["note"] = f"{locked_at.isoformat()}: {change.note}"
680
+ entry["provenance"] = prov
681
+ collection.append(entry)
682
+ added.append(str(entry["id"]))
683
+ return f"added {collection_key} {', '.join(added)} (witness {change.witness}, source {change.source})"
684
+
685
+
686
+ _ASSISTANT_NAME_DENYLIST = frozenset(
687
+ {"claude", "gpt", "chatgpt", "codex", "copilot", "gemini", "assistant", "ai"}
688
+ )
689
+
690
+
691
+ def _is_inside_git_repo(path: Path) -> bool:
692
+ """Walk up from ``path``'s parent looking for a ``.git`` dir or file.
693
+
694
+ No subprocess (cheaper than ``git rev-parse``); a ``.git`` file covers
695
+ worktrees and submodules, a ``.git`` dir covers the ordinary case.
696
+ """
697
+ for parent in (path.resolve().parent, *path.resolve().parent.parents):
698
+ if (parent / ".git").exists():
699
+ return True
700
+ return False
701
+
702
+
703
+ def _backup_temp(path: Path, contents: bytes) -> Path:
704
+ """Stage backup bytes beside their destination without changing it."""
705
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
706
+ tmp_path = Path(tmp_name)
707
+ try:
708
+ with os.fdopen(fd, "wb") as handle:
709
+ handle.write(contents)
710
+ except BaseException:
711
+ tmp_path.unlink(missing_ok=True)
712
+ raise
713
+ return tmp_path
714
+
715
+
716
+ def _replace_then_backup(tmp_path: Path, contract_path: Path, original_text: str) -> None:
717
+ """Pair a contract replacement with its non-git undo baseline."""
718
+ if _is_inside_git_repo(contract_path):
719
+ os.replace(tmp_path, contract_path)
720
+ return
721
+
722
+ backup_path = contract_path.with_name(contract_path.name + ".bak")
723
+ previous_backup = backup_path.read_bytes() if backup_path.exists() else None
724
+ staged_backup = _backup_temp(backup_path, original_text.encode("utf-8"))
725
+ try:
726
+ # Keep the old undo bytes on disk before changing either destination.
727
+ restore_backup = (
728
+ _backup_temp(backup_path, previous_backup) if previous_backup is not None else None
729
+ )
730
+ retain_restore = False
731
+ try:
732
+ # Install a complete prospective baseline before the contract replacement.
733
+ # If that replacement fails, restore the exact prior backup under the same lock.
734
+ os.replace(staged_backup, backup_path)
735
+ try:
736
+ os.replace(tmp_path, contract_path)
737
+ except BaseException as replace_error:
738
+ try:
739
+ if restore_backup is None:
740
+ backup_path.unlink(missing_ok=True)
741
+ else:
742
+ os.replace(restore_backup, backup_path)
743
+ except BaseException as recovery_error:
744
+ # The prior backup remains a complete sibling file even
745
+ # when the destination cannot be restored right now.
746
+ if restore_backup is not None:
747
+ retain_restore = True
748
+ raise EditRecoveryError(
749
+ restore_backup, replace_error, recovery_error
750
+ ) from recovery_error
751
+ raise EditRecoveryError(
752
+ backup_path,
753
+ replace_error,
754
+ recovery_error,
755
+ contract_state="unchanged",
756
+ backup_state=(
757
+ "the prospective .bak contains the contract's pre-write bytes"
758
+ ),
759
+ recovery_action="cleanup",
760
+ ) from recovery_error
761
+ raise
762
+ finally:
763
+ if restore_backup is not None and not retain_restore:
764
+ restore_backup.unlink(missing_ok=True)
765
+ finally:
766
+ staged_backup.unlink(missing_ok=True)
767
+
768
+
769
+ def _is_valid_witness_name(witness: str) -> bool:
770
+ """True for a name ``validate_witness`` would accept: non-blank, not "you",
771
+ not an assistant name. Shared so a config default gets the identical bar a
772
+ hand-typed ``--witness`` does — a bad default must fail open into
773
+ the existing refusal, never bypass it under the "(default)" mark.
774
+ """
775
+ if not witness or not witness.strip():
776
+ return False
777
+ return witness.strip().lower() not in _ASSISTANT_NAME_DENYLIST | {"you"}
778
+
779
+
780
+ def validate_witness(witness: str, *, refusal: str) -> None:
781
+ """Refuse a witness that is blank, the bare word "you", or an assistant name.
782
+
783
+ Shared by both write paths (``_apply_change`` and ``add_entry``) so the
784
+ refusal list lives in one place. Does not pick a default witness itself —
785
+ that is ``resolve_witness``'s job; this still refuses a blank/"you"
786
+ string whether it arrived as-is or as an unmarked default.
787
+ """
788
+ if not _is_valid_witness_name(witness):
789
+ raise EditRefusal(refusal)
790
+
791
+
792
+ _WITNESS_CONFIG_ENV = "LOCKWIRE_WITNESS_CONFIG"
793
+ _WITNESS_DEFAULT_MARK = " (default)"
794
+
795
+
796
+ def default_witness_config_path() -> Path:
797
+ """Where the per-machine witness default lives.
798
+
799
+ ``LOCKWIRE_WITNESS_CONFIG`` overrides the path outright — tests use this so
800
+ a run never reads or depends on a real file of the user's (a test never
801
+ touches a real file by default). Otherwise the app
802
+ and CLI share one XDG-style location.
803
+ """
804
+ override = os.environ.get(_WITNESS_CONFIG_ENV)
805
+ if override:
806
+ return Path(override)
807
+ config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
808
+ return Path(config_home) / "lockwire" / "config.toml"
809
+
810
+
811
+ def read_default_witness() -> str | None:
812
+ """The per-machine config's ``witness`` value, or ``None`` if unset.
813
+
814
+ Missing file, missing key, a blank value, or a file that fails to parse
815
+ all read the same way: no default, so the caller falls back to the
816
+ existing refusal. A default mechanism failing open into "no default" can
817
+ never itself cause a bad write; it can only cause the same refusal that
818
+ already existed before the default.
819
+ """
820
+ path = default_witness_config_path()
821
+ if not path.is_file():
822
+ return None
823
+ try:
824
+ import tomllib
825
+
826
+ with path.open("rb") as handle:
827
+ data = tomllib.load(handle)
828
+ except (OSError, ValueError):
829
+ return None
830
+ value = data.get("witness")
831
+ if not isinstance(value, str) or not value.strip():
832
+ return None
833
+ return value.strip()
834
+
835
+
836
+ def resolve_witness(witness: str | None) -> str:
837
+ """An explicit ``--witness`` wins; an absent/blank one falls back to the
838
+ per-machine config default, marked "(default)".
839
+
840
+ The mark is written straight into the value that ends up stored — the
841
+ same string lands in the file's ``witness:`` field, in ``describe``'s
842
+ per-device page, and (once merged) in ``blame``'s row for this record —
843
+ so a defaulted witness can never render identically to a hand-entered
844
+ one on any surface that just echoes ``provenance.witness``. Does not
845
+ itself refuse: a resolved value that is still blank, or a config value
846
+ that is itself blank/"you"/an assistant name, reaches ``validate_witness``
847
+ unmarked and is refused exactly as before.
848
+
849
+ Only OMISSION (``witness is None``) taking the default. An
850
+ explicit blank — ``--witness ""`` or all-whitespace — is a different act:
851
+ the caller deliberately passed nothing, so silently attributing a
852
+ default witness to them would be wrong even though the string looks the
853
+ same as an omission once ``.strip``-ed. That case is refused here,
854
+ before the default is ever consulted, with the same wording
855
+ used for a hand-typed blank/"you"/assistant name.
856
+ """
857
+ if witness is not None and not witness.strip():
858
+ raise EditRefusal(f"--witness must name a real person, not {witness!r}")
859
+ if witness and witness.strip():
860
+ return witness
861
+ default = read_default_witness()
862
+ if default and _is_valid_witness_name(default):
863
+ return f"{default}{_WITNESS_DEFAULT_MARK}"
864
+ return witness or ""
865
+
866
+
867
+ _URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]+:")
868
+
869
+
870
+ def source_is_absolute(source: str) -> bool:
871
+ """True when ``source`` names a location only one machine has.
872
+
873
+ Lexical, no filesystem touched: a POSIX absolute path, a ``~`` home path, a
874
+ backslash-rooted or UNC Windows path, or a drive path (``C:``, ``C:/``,
875
+ ``C:\\``). A URL is *not* absolute in this sense — it travels with the
876
+ contract — and the scheme length is what separates the two: ``https:`` is a
877
+ URL, ``C:`` is a drive letter.
878
+ """
879
+ text = source.strip()
880
+ if text.startswith(("/", "~", "\\")):
881
+ return True
882
+ return len(text) >= 2 and text[0].isalpha() and text[1] == ":" and text[2:3] in ("", "/", "\\")
883
+
884
+
885
+ def validate_source(source: str, *, prefix: str = "") -> None:
886
+ """Refuse a blank source, and refuse an absolute path.
887
+
888
+ Shared by every write door — ``_apply_change`` (for ``--source`` and, via
889
+ ``iter_written_sources``, for every source the change's *value* lands),
890
+ ``add_entry`` (which every ``_build_*`` and ``_add_battery_entry`` runs
891
+ through) and ``scene_import.write_change_file`` — so the rule lives in one
892
+ place, the way ``validate_witness`` does. A source that survives this is relative, and a
893
+ relative source resolves from the contract's own directory
894
+ (``resolve_source``), so the evidence travels with the contract.
895
+ """
896
+ if not source or not source.strip():
897
+ raise EditRefusal(f"{prefix}--source is required")
898
+ if source_is_absolute(source):
899
+ raise EditRefusal(
900
+ f"{prefix}--source must be a path relative to the contract, a URL or a cited "
901
+ f"title, not the absolute path {source.strip()!r} — an absolute path names a "
902
+ f"file only this machine has, so the evidence cannot travel with the "
903
+ f"contract"
904
+ )
905
+
906
+
907
+ def iter_written_sources(path: str, value: Any) -> Iterator[tuple[str, str]]:
908
+ """Every source string a change would land, with the path it lands at.
909
+
910
+ ``--source`` is the witness's citation for the edit; this is the other half —
911
+ the value side. ``declare devices/x/provenance/source /Users/me/ds.pdf`` and an
912
+ appended row carrying its own ``provenance.source`` both write a source field
913
+ without ever touching ``--source``, so both are walked here and validated by the
914
+ same guard.
915
+ """
916
+ if isinstance(value, str):
917
+ if path.rsplit("/", 1)[-1] == "source":
918
+ yield path, value
919
+ elif isinstance(value, dict):
920
+ for key, item in value.items():
921
+ yield from iter_written_sources(f"{path}/{key}", item)
922
+ elif isinstance(value, (list, tuple)):
923
+ for item in value:
924
+ yield from iter_written_sources(path, item)
925
+
926
+
927
+ def relative_source(contract_path: Path, file_path: Path) -> str:
928
+ """The portable spelling of a file a write path was handed as a path.
929
+
930
+ ``lockwire start /home/me/robot.urdf`` must not stamp ``/home/me`` into the
931
+ contract — nobody else has that directory. The path is re-read from the
932
+ contract's own directory instead, so the model file travels with the
933
+ contract. Falls back to the bare filename when no relative spelling exists
934
+ (a different Windows drive).
935
+ """
936
+ try:
937
+ return os.path.relpath(file_path.resolve(), contract_path.resolve().parent)
938
+ except ValueError:
939
+ return file_path.name
940
+
941
+
942
+ def resolve_source(contract_path: Path, source: str) -> Path | None:
943
+ """Where a relative file source points, read from the contract's own directory.
944
+
945
+ A source is a file beside the contract, a URL or a cited title. Only
946
+ the first kind resolves — a URL, a cited title and (refused on write, but
947
+ still found in older contracts) an absolute path return ``None``. The
948
+ contract's directory is the anchor, never the working directory, so the same
949
+ contract resolves the same way from anywhere.
950
+ """
951
+ text = source.strip()
952
+ if not text or source_is_absolute(text) or _URL_SCHEME.match(text) or " " in text:
953
+ return None
954
+ return contract_path.resolve().parent / text
955
+
956
+
957
+ def _apply_change(doc: Any, index: int, change: Change) -> str:
958
+ validate_source(change.source, prefix=f"entry {index} ({change.path}): ")
959
+ for where, written in iter_written_sources(change.path, change.value):
960
+ validate_source(written, prefix=f"entry {index} ({where}): ")
961
+ validate_witness(
962
+ change.witness,
963
+ refusal=f"entry {index} ({change.path}): --witness must name a real person, not {change.witness!r}",
964
+ )
965
+ segments = split_path(change.path)
966
+ if len(segments) == 1 and segments[0] in _COLLECTION_APPEND_KINDS:
967
+ return _apply_collection_append(doc, index, change, segments[0])
968
+ try:
969
+ parent = _navigate(doc, segments[:-1], create=True)
970
+ except EditRefusal as error:
971
+ raise EditRefusal(f"entry {index} ({change.path}): {error}") from error
972
+ if not isinstance(parent, dict):
973
+ raise EditRefusal(f"entry {index} ({change.path}): not a container")
974
+ key = segments[-1]
975
+ try:
976
+ value = (
977
+ parse_value(change.value, field_name=key)
978
+ if isinstance(change.value, str)
979
+ else change.value
980
+ )
981
+ if change.unit is not None:
982
+ value = _attach_unit(value, change.unit)
983
+ except EditRefusal as error:
984
+ raise EditRefusal(f"entry {index} ({change.path}): {error}") from error
985
+ parent[key] = value
986
+ locked_at = change.locked_at or dt.date.today()
987
+ owner_segments = provenance_owner_path(segments)
988
+ try:
989
+ _stamp_provenance(
990
+ doc,
991
+ owner_segments,
992
+ field_leaf=key,
993
+ source=change.source,
994
+ witness=change.witness,
995
+ note=change.note,
996
+ locked_at=locked_at,
997
+ )
998
+ except EditRefusal as error:
999
+ raise EditRefusal(f"entry {index} ({change.path}): {error}") from error
1000
+ return f"declared {change.path} = {format_value(value)} (witness {change.witness}, source {change.source})"
1001
+
1002
+
1003
+ def propose_changes_from_bytes(
1004
+ contract_path: Path, raw_bytes: bytes, changes: list[Change]
1005
+ ) -> EditProposal:
1006
+ """Build a proposal from caller-bound raw bytes without reading the path."""
1007
+ if not changes:
1008
+ raise EditRefusal("no changes given")
1009
+ original_text, doc, style = _load_doc_bytes_guarded(raw_bytes)
1010
+
1011
+ # a declare that clears a violation by RAISING the limit rather
1012
+ # than lowering the draw leaves a standing mark. Reading the contract as it
1013
+ # stands is the only way to know which side moved; a file that does not
1014
+ # validate today has no violations to clear, so no mark can be owed.
1015
+ limit_changes = _budget_limit_changes(changes)
1016
+ before: Any | None = None
1017
+ if limit_changes:
1018
+ try:
1019
+ declared = doc.get("schema_version", SCHEMA_VERSION) if isinstance(doc, dict) else None
1020
+ if declared not in SUPPORTED_SCHEMA_VERSIONS:
1021
+ raise UnsupportedSchemaVersion(declared, SUPPORTED_SCHEMA_VERSIONS)
1022
+ before = Contract.model_validate(doc)
1023
+ except Exception: # noqa: BLE001 — an unloadable "before" owes no mark
1024
+ before = None
1025
+
1026
+ lines: list[str] = []
1027
+ for index, change in enumerate(changes, start=1):
1028
+ lines.append(_apply_change(doc, index, change))
1029
+
1030
+ new_text = _dump(doc, style)
1031
+ # The file already declared an unsupported ``schema_version`` before this
1032
+ # edit touched it, so the edit is not the cause: let the typed refusal
1033
+ # through as itself rather than letting the correlator blame an entry.
1034
+ try:
1035
+ declared = doc.get("schema_version", SCHEMA_VERSION) if isinstance(doc, dict) else None
1036
+ if declared not in SUPPORTED_SCHEMA_VERSIONS:
1037
+ raise UnsupportedSchemaVersion(declared, SUPPORTED_SCHEMA_VERSIONS)
1038
+ after = Contract.model_validate(doc)
1039
+ except UnsupportedSchemaVersion:
1040
+ raise
1041
+ except Exception as error:
1042
+ culprit = _correlate_failure(doc, changes, error)
1043
+ raise EditRefusal(culprit) from error
1044
+
1045
+ if before is not None:
1046
+ marked = False
1047
+ for budget_id in sorted(_cleared_by_raising(before, after)):
1048
+ change = limit_changes[budget_id]
1049
+ if _stamp_limit_raised(
1050
+ doc,
1051
+ budget_id,
1052
+ witness=change.witness,
1053
+ locked_at=change.locked_at or dt.date.today(),
1054
+ ):
1055
+ marked = True
1056
+ lines.append(f"marked budgets/{budget_id}: the limit was raised past a violation")
1057
+ if marked:
1058
+ new_text = _dump(doc, style)
1059
+ after = Contract.model_validate(doc)
1060
+
1061
+ diff = "".join(
1062
+ difflib.unified_diff(
1063
+ original_text.splitlines(keepends=True),
1064
+ new_text.splitlines(keepends=True),
1065
+ fromfile=str(contract_path),
1066
+ tofile=str(contract_path),
1067
+ )
1068
+ )
1069
+ return EditProposal(EditResult(diff=diff, lines=lines), after, new_text, original_text)
1070
+
1071
+
1072
+ def propose_changes(contract_path: Path, changes: list[Change]) -> EditProposal:
1073
+ """Build and validate an edit proposal without creating any files."""
1074
+ _guard_contract_raw(contract_path)
1075
+ return propose_changes_from_bytes(contract_path, contract_path.read_bytes(), changes)
1076
+
1077
+
1078
+ @_locked_write
1079
+ def apply_changes(
1080
+ contract_path: Path,
1081
+ changes: list[Change],
1082
+ *,
1083
+ dry_run: bool = False,
1084
+ expected_revision: str | None = None,
1085
+ ) -> EditResult:
1086
+ """Apply every change to the contract, validate once, write atomically.
1087
+
1088
+ Nothing is written when any entry is malformed or the resulting document
1089
+ fails to validate — the raised ``EditRefusal`` names the failing entry.
1090
+ """
1091
+ proposal = propose_changes(contract_path, changes)
1092
+ if dry_run:
1093
+ return proposal.result
1094
+ fd, tmp_name = tempfile.mkstemp(
1095
+ prefix=f".{contract_path.name}.", suffix=".tmp", dir=str(contract_path.parent)
1096
+ )
1097
+ tmp_path = Path(tmp_name)
1098
+ try:
1099
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
1100
+ handle.write(proposal.text)
1101
+ _replace_then_backup(tmp_path, contract_path, proposal.original_text)
1102
+ proposal.result.written = True
1103
+ return proposal.result
1104
+ finally:
1105
+ tmp_path.unlink(missing_ok=True)
1106
+
1107
+
1108
+ @_locked_write
1109
+ def apply_review(
1110
+ contract_path: Path,
1111
+ path: str,
1112
+ state: str,
1113
+ *,
1114
+ witness: str,
1115
+ note: str | None = None,
1116
+ locked_at: dt.date | None = None,
1117
+ dry_run: bool = False,
1118
+ expected_revision: str | None = None,
1119
+ ) -> EditResult:
1120
+ """Approve or question one already-declared value, appending a review note
1121
+ to the owning block's provenance, atomically.
1122
+
1123
+ Same load -> mutate -> validate -> write shape as ``apply_changes`` — one
1124
+ writer, not a second one. The witness-blank/assistant-name refusal
1125
+ and the "questioned needs a note" refusal are not duplicated here: they
1126
+ are ``schema.ReviewNote``'s own validators, reached through the reload
1127
+ below exactly the way ``apply_changes`` reaches ``Contract``'s. That is
1128
+ the one gate every review door (CLI, MCP, library) shares.
1129
+ """
1130
+ original_text, doc, style = _load_doc_guarded(contract_path)
1131
+ segments = split_path(path)
1132
+ owner_segments = provenance_owner_path(segments)
1133
+ owner = _navigate(doc, owner_segments, create=False)
1134
+ if not isinstance(owner, dict) or not isinstance(owner.get("provenance"), dict):
1135
+ raise EditRefusal(f"cannot review {path}: nothing declared there yet (no provenance)")
1136
+ prov = owner["provenance"]
1137
+ entry = CommentedMap()
1138
+ entry["state"] = state
1139
+ entry["witness"] = witness
1140
+ entry["at"] = locked_at or dt.date.today()
1141
+ if note:
1142
+ entry["note"] = note
1143
+ reviews = prov.get("reviews")
1144
+ if reviews is None:
1145
+ reviews = []
1146
+ prov["reviews"] = reviews
1147
+ reviews.append(entry)
1148
+
1149
+ new_text = _dump(doc, style)
1150
+ fd, tmp_name = tempfile.mkstemp(
1151
+ prefix=f".{contract_path.name}.", suffix=".tmp", dir=str(contract_path.parent)
1152
+ )
1153
+ tmp_path = Path(tmp_name)
1154
+ try:
1155
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
1156
+ handle.write(new_text)
1157
+ try:
1158
+ load_contract(tmp_path)
1159
+ except UnsupportedSchemaVersion:
1160
+ raise
1161
+ except Exception as error:
1162
+ raise EditRefusal(f"review of {path}: {error}") from error
1163
+ diff = "".join(
1164
+ difflib.unified_diff(
1165
+ original_text.splitlines(keepends=True),
1166
+ new_text.splitlines(keepends=True),
1167
+ fromfile=str(contract_path),
1168
+ tofile=str(contract_path),
1169
+ )
1170
+ )
1171
+ line = f"reviewed {path}: {state} (witness {witness})"
1172
+ if dry_run:
1173
+ return EditResult(diff=diff, lines=[line], written=False)
1174
+ _replace_then_backup(tmp_path, contract_path, original_text)
1175
+ return EditResult(diff=diff, lines=[line], written=True)
1176
+ finally:
1177
+ tmp_path.unlink(missing_ok=True)
1178
+
1179
+
1180
+ def _correlate_failure(doc: Any, changes: list[Change], error: Exception) -> str:
1181
+ """Turn a validation failure into "entry N (path): message" when possible."""
1182
+ errors = getattr(error, "errors", None)
1183
+ if callable(errors):
1184
+ for pydantic_error in errors():
1185
+ loc_path = _loc_to_path(doc, pydantic_error.get("loc", ()))
1186
+ for index, change in enumerate(changes, start=1):
1187
+ if (
1188
+ loc_path == change.path
1189
+ or loc_path.startswith(change.path + "/")
1190
+ or (change.path.startswith(loc_path + "/") if loc_path else False)
1191
+ ):
1192
+ return f"entry {index} ({change.path}): {pydantic_error.get('msg', error)}"
1193
+ return f"contract invalid after {len(changes)} change(s): {error}"
1194
+
1195
+
1196
+ # ------------------------------------------------- add device|rail|bus|family|model|budget|gearbox
1197
+
1198
+ _ADD_KINDS = ("device", "rail", "bus", "family", "model", "budget", "battery", "gearbox")
1199
+ _COLLECTION_APPEND_KINDS = ("families", "device_models", "budgets")
1200
+
1201
+
1202
+ @_locked_write
1203
+ def add_entry(
1204
+ contract_path: Path,
1205
+ kind: str,
1206
+ fields: dict[str, Any],
1207
+ *,
1208
+ source: str,
1209
+ witness: str,
1210
+ note: str | None = None,
1211
+ locked_at: dt.date | None = None,
1212
+ dry_run: bool = False,
1213
+ expected_revision: str | None = None,
1214
+ ) -> EditResult:
1215
+ """Create a new device/rail/bus block, with provenance, atomically."""
1216
+ if kind not in _ADD_KINDS:
1217
+ raise EditRefusal(f"unknown add kind {kind!r}; expected one of {_ADD_KINDS}")
1218
+ validate_source(source)
1219
+ validate_witness(witness, refusal=f"--witness must name a real person, not {witness!r}")
1220
+
1221
+ original_text, doc, style = _load_doc_guarded(contract_path)
1222
+ locked_at = locked_at or dt.date.today()
1223
+
1224
+ if kind == "battery":
1225
+ return _add_battery_entry(
1226
+ contract_path,
1227
+ doc,
1228
+ fields,
1229
+ original_text=original_text,
1230
+ style=style,
1231
+ source=source,
1232
+ witness=witness,
1233
+ note=note,
1234
+ locked_at=locked_at,
1235
+ dry_run=dry_run,
1236
+ )
1237
+
1238
+ new_id = fields.get("id")
1239
+ if not new_id:
1240
+ raise EditRefusal("--id is required")
1241
+
1242
+ # "one datasheet, one submit" — a device whose --family/--model
1243
+ # name records that do not exist yet gets them created alongside it, in
1244
+ # this same in-memory doc, so the eventual disk write is one transaction
1245
+ # (nothing lands unless the whole thing validates — see the tail below).
1246
+ # A device whose family/model already exist behaves exactly as before.
1247
+ created_with_device: list[tuple[str, CommentedMap]] = []
1248
+ if kind == "device":
1249
+ family_ref = fields.get("family")
1250
+ model_ref = fields.get("model")
1251
+ if model_ref and family_ref:
1252
+ existing_model = next(
1253
+ (
1254
+ m
1255
+ for m in doc.get("device_models", []) or []
1256
+ if isinstance(m, dict) and m.get("id") == model_ref
1257
+ ),
1258
+ None,
1259
+ )
1260
+ if existing_model is not None and existing_model.get("family") != family_ref:
1261
+ raise EditRefusal(
1262
+ f"model {model_ref!r} belongs to family {existing_model.get('family')!r}, "
1263
+ f"not {family_ref!r}"
1264
+ )
1265
+ families = _ensure_list(doc, ["families"])
1266
+ if not any(isinstance(f, dict) and f.get("id") == family_ref for f in families):
1267
+ family_entry = _build_family(
1268
+ {
1269
+ "id": family_ref,
1270
+ "name": fields.get("name") or _default_family_name(family_ref),
1271
+ }
1272
+ )
1273
+ families.append(family_entry)
1274
+ created_with_device.append(("family", family_entry))
1275
+ models = _ensure_list(doc, ["device_models"])
1276
+ if not any(isinstance(m, dict) and m.get("id") == model_ref for m in models):
1277
+ model_entry = _build_model({"id": model_ref, "family": family_ref}, doc)
1278
+ models.append(model_entry)
1279
+ created_with_device.append(("model", model_entry))
1280
+ collection = _ensure_list(doc, ["devices"])
1281
+ _refuse_duplicate(collection, new_id, "device")
1282
+ entry = _build_device(fields)
1283
+ elif kind in ("rail", "bus"):
1284
+ list_key = "rails" if kind == "rail" else "buses"
1285
+ collection = _ensure_list(doc, ["topology", list_key])
1286
+ _refuse_duplicate(collection, new_id, kind)
1287
+ entry = _build_rail(fields) if kind == "rail" else _build_bus(fields)
1288
+ elif kind == "family":
1289
+ collection = _ensure_list(doc, ["families"])
1290
+ _refuse_duplicate(collection, new_id, "family")
1291
+ entry = _build_family(fields)
1292
+ elif kind == "model":
1293
+ collection = _ensure_list(doc, ["device_models"])
1294
+ _refuse_duplicate(collection, new_id, "model")
1295
+ entry = _build_model(fields, doc)
1296
+ elif kind == "gearbox":
1297
+ collection = _ensure_list(doc, ["gearboxes"])
1298
+ _refuse_duplicate(collection, new_id, "gearbox")
1299
+ entry = _build_gearbox(fields, doc)
1300
+ else: # budget
1301
+ collection = _ensure_list(doc, ["budgets"])
1302
+ _refuse_duplicate(collection, new_id, "budget")
1303
+ entry = _build_budget(fields, doc)
1304
+
1305
+ if kind == "device":
1306
+ rail_ref = fields.get("power_rail")
1307
+ if rail_ref is not None:
1308
+ rails = (
1309
+ doc.get("topology", {}).get("rails", [])
1310
+ if isinstance(doc.get("topology"), dict)
1311
+ else []
1312
+ )
1313
+ if not any(isinstance(r, dict) and r.get("id") == rail_ref for r in rails):
1314
+ raise EditRefusal(f"device references unknown rail {rail_ref!r}")
1315
+ bus_ref = fields.get("identity_bus_ref")
1316
+ if bus_ref is not None:
1317
+ buses = (
1318
+ doc.get("topology", {}).get("buses", [])
1319
+ if isinstance(doc.get("topology"), dict)
1320
+ else []
1321
+ )
1322
+ if not any(isinstance(b, dict) and b.get("id") == bus_ref for b in buses):
1323
+ raise EditRefusal(f"device references unknown bus {bus_ref!r}")
1324
+ model_ref = fields.get("model")
1325
+ models = doc.get("device_models", []) or []
1326
+ if not any(isinstance(m, dict) and m.get("id") == model_ref for m in models):
1327
+ existing = ", ".join(
1328
+ sorted(m["id"] for m in models if isinstance(m, dict) and m.get("id"))
1329
+ )
1330
+ raise EditRefusal(
1331
+ f"model {model_ref!r} is not declared — declare it first: "
1332
+ f"lockwire add model {contract_path} --id {model_ref} --family <fam.id> "
1333
+ "--source ... --witness ... "
1334
+ f"(or use an existing id: {existing or 'none declared yet'})"
1335
+ )
1336
+
1337
+ # One shared stamper for every record this call writes ("one
1338
+ # witness line" for the three records) — a family/model created *because*
1339
+ # this device named them is not itself witnessed a second time; its
1340
+ # provenance points at the device that brought it in instead of repeating
1341
+ # the name. A family/model that already existed is untouched, unstamped.
1342
+ lines: list[str] = []
1343
+ for supporting_kind, supporting_entry in created_with_device:
1344
+ pointer = f"see devices/{new_id} (added together)"
1345
+ _stamp_add_provenance(supporting_entry, source=source, witness=pointer, locked_at=locked_at)
1346
+ lines.append(
1347
+ f"added {supporting_kind} {supporting_entry['id']} (witness {pointer}, source {source})"
1348
+ )
1349
+ _stamp_add_provenance(entry, source=source, witness=witness, locked_at=locked_at, note=note)
1350
+ collection.append(entry)
1351
+ lines.append(f"added {kind} {new_id} (witness {witness}, source {source})")
1352
+
1353
+ new_text = _dump(doc, style)
1354
+ fd, tmp_name = tempfile.mkstemp(
1355
+ prefix=f".{contract_path.name}.", suffix=".tmp", dir=str(contract_path.parent)
1356
+ )
1357
+ tmp_path = Path(tmp_name)
1358
+ try:
1359
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
1360
+ handle.write(new_text)
1361
+ try:
1362
+ load_contract(tmp_path)
1363
+ except UnsupportedSchemaVersion: # in the file before the edit; see apply_changes
1364
+ raise
1365
+ except Exception as error:
1366
+ raise EditRefusal(f"add {kind} {new_id!r} invalid: {error}") from error
1367
+
1368
+ diff = "".join(
1369
+ difflib.unified_diff(
1370
+ original_text.splitlines(keepends=True),
1371
+ new_text.splitlines(keepends=True),
1372
+ fromfile=str(contract_path),
1373
+ tofile=str(contract_path),
1374
+ )
1375
+ )
1376
+ if dry_run:
1377
+ return EditResult(diff=diff, lines=lines, written=False)
1378
+ _replace_then_backup(tmp_path, contract_path, original_text)
1379
+ return EditResult(diff=diff, lines=lines, written=True)
1380
+ finally:
1381
+ tmp_path.unlink(missing_ok=True)
1382
+
1383
+
1384
+ def _add_battery_entry(
1385
+ contract_path: Path,
1386
+ doc: Any,
1387
+ fields: dict[str, Any],
1388
+ *,
1389
+ original_text: str,
1390
+ style: tuple[int, int],
1391
+ source: str,
1392
+ witness: str,
1393
+ note: str | None,
1394
+ locked_at: dt.date,
1395
+ dry_run: bool,
1396
+ ) -> EditResult:
1397
+ """Create or update `topology/battery` — a singleton, not an id'd collection.
1398
+
1399
+ `Battery` (schema.py) has no `provenance` field and forbids extras
1400
+ (schema fields are added by a schema change) — source/witness/chemistry
1401
+ are recorded as a YAML comment on the `battery:` key instead of a
1402
+ structured block. Unstructured: `check`/`describe` never read it back.
1403
+ """
1404
+ capacity = fields.get("capacity")
1405
+ if not capacity:
1406
+ raise EditRefusal("battery requires --capacity")
1407
+ topology = _navigate(doc, ["topology"], create=True)
1408
+ battery = topology.get("battery")
1409
+ if not isinstance(battery, dict):
1410
+ battery = CommentedMap()
1411
+ topology["battery"] = battery
1412
+ battery["capacity"] = parse_value(capacity, field_name="capacity")
1413
+ if fields.get("nominal_voltage"):
1414
+ battery["voltage"] = parse_value(fields["nominal_voltage"], field_name="voltage")
1415
+
1416
+ stamp = f"{locked_at.isoformat()}: battery declared, witness {witness}, source {source}"
1417
+ if fields.get("chemistry"):
1418
+ stamp += f"; chemistry {fields['chemistry']}"
1419
+ if note:
1420
+ stamp += f"; {note}"
1421
+ topology.yaml_set_comment_before_after_key("battery", before=stamp, indent=2)
1422
+
1423
+ new_text = _dump(doc, style)
1424
+ fd, tmp_name = tempfile.mkstemp(
1425
+ prefix=f".{contract_path.name}.", suffix=".tmp", dir=str(contract_path.parent)
1426
+ )
1427
+ tmp_path = Path(tmp_name)
1428
+ try:
1429
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
1430
+ handle.write(new_text)
1431
+ try:
1432
+ load_contract(tmp_path)
1433
+ except UnsupportedSchemaVersion: # in the file before the edit; see apply_changes
1434
+ raise
1435
+ except Exception as error:
1436
+ raise EditRefusal(f"battery invalid: {error}") from error
1437
+
1438
+ diff = "".join(
1439
+ difflib.unified_diff(
1440
+ original_text.splitlines(keepends=True),
1441
+ new_text.splitlines(keepends=True),
1442
+ fromfile=str(contract_path),
1443
+ tofile=str(contract_path),
1444
+ )
1445
+ )
1446
+ line = f"declared battery capacity (witness {witness}, source {source})"
1447
+ if dry_run:
1448
+ return EditResult(diff=diff, lines=[line], written=False)
1449
+ _replace_then_backup(tmp_path, contract_path, original_text)
1450
+ return EditResult(diff=diff, lines=[line], written=True)
1451
+ finally:
1452
+ tmp_path.unlink(missing_ok=True)
1453
+
1454
+
1455
+ def _ensure_list(doc: Any, segments: list[str]) -> list:
1456
+ parent = _navigate(doc, segments[:-1], create=True)
1457
+ key = segments[-1]
1458
+ if key not in parent or parent[key] is None:
1459
+ parent[key] = []
1460
+ node = parent[key]
1461
+ if not isinstance(node, list):
1462
+ raise EditRefusal(f"{'/'.join(segments)} is not a list")
1463
+ return node
1464
+
1465
+
1466
+ def _refuse_duplicate(collection: list, new_id: str, kind: str) -> None:
1467
+ if any(isinstance(x, dict) and x.get("id") == new_id for x in collection):
1468
+ raise EditRefusal(f"{kind} {new_id!r} already exists")
1469
+
1470
+
1471
+ def _stamp_add_provenance(
1472
+ entry: CommentedMap, *, source: str, witness: str, locked_at: dt.date, note: str | None = None
1473
+ ) -> None:
1474
+ """The one shared provenance stamp every ``add``-written record goes
1475
+ through (mutation bite: stamping per record with duplicated inline code
1476
+ instead of this one function is exactly the bug its test catches).
1477
+
1478
+ A one-submit device add calls this 2-3 times with the SAME ``locked_at``
1479
+ object; ruamel anchors/aliases a repeated node by identity, and this repo
1480
+ refuses anchors on read (``_reject_yaml_anchors_aliases``) — so each call
1481
+ gets its own fresh ``date``, never the caller's shared one.
1482
+ """
1483
+ prov = CommentedMap()
1484
+ prov["witness"] = witness
1485
+ prov["source"] = source
1486
+ prov["locked_at"] = dt.date.fromordinal(locked_at.toordinal())
1487
+ if note:
1488
+ prov["note"] = f"{locked_at.isoformat()}: {note}"
1489
+ entry["provenance"] = prov
1490
+
1491
+
1492
+ def _default_family_name(family_id: str) -> str:
1493
+ """A readable ``DeviceFamily.name`` when ``add device``'s one-submit path
1494
+ creates a family from only an id and no explicit ``--name``."""
1495
+ tail = family_id.removeprefix("fam.")
1496
+ readable = tail.replace(".", " ").replace("-", " ").replace("_", " ").strip()
1497
+ return readable.title() if readable else family_id
1498
+
1499
+
1500
+ def _build_device(fields: dict[str, Any]) -> CommentedMap:
1501
+ for required in ("id", "model", "owner"):
1502
+ if not fields.get(required):
1503
+ raise EditRefusal(f"device requires --{required.replace('_', '-')}")
1504
+ entry = CommentedMap()
1505
+ entry["id"] = fields["id"]
1506
+ entry["model"] = fields["model"]
1507
+ if fields.get("identity_bus_ref") or fields.get("identity_host"):
1508
+ if not (fields.get("identity_bus_ref") and fields.get("identity_host")):
1509
+ raise EditRefusal("device identity needs both --bus-ref and --host")
1510
+ entry["identity"] = CommentedMap(
1511
+ [("bus_ref", fields["identity_bus_ref"]), ("host", fields["identity_host"])]
1512
+ )
1513
+ if fields.get("physical_mass") or fields.get("physical_mount_frame"):
1514
+ # mass and mount_frame are independently optional — declare either alone.
1515
+ physical = CommentedMap()
1516
+ if fields.get("physical_mass"):
1517
+ physical["mass"] = parse_value(fields["physical_mass"], field_name="mass")
1518
+ if fields.get("physical_mount_frame"):
1519
+ physical["mount_frame"] = fields["physical_mount_frame"]
1520
+ entry["physical"] = physical
1521
+ if fields.get("power_rail") or fields.get("power_draw"):
1522
+ if fields.get("power_draw") and not fields.get("power_rail"):
1523
+ raise EditRefusal("device power draw needs --rail")
1524
+ power = CommentedMap()
1525
+ power["rail"] = fields["power_rail"]
1526
+ # draw is optional — a device may declare a rail with unknown draw.
1527
+ if fields.get("power_draw"):
1528
+ power["draw"] = parse_value(fields["power_draw"], field_name="draw")
1529
+ entry["power"] = power
1530
+ entry["owner"] = fields["owner"]
1531
+ return entry
1532
+
1533
+
1534
+ def _build_rail(fields: dict[str, Any]) -> CommentedMap:
1535
+ for required in ("id", "capacity"):
1536
+ if not fields.get(required):
1537
+ raise EditRefusal(f"rail requires --{required}")
1538
+ entry = CommentedMap()
1539
+ entry["id"] = fields["id"]
1540
+ # voltage is optional — capacity alone still describes a rail.
1541
+ if fields.get("voltage"):
1542
+ entry["voltage"] = parse_value(fields["voltage"], field_name="voltage")
1543
+ entry["capacity"] = parse_value(fields["capacity"], field_name="capacity")
1544
+ if fields.get("upstream"):
1545
+ entry["upstream"] = fields["upstream"]
1546
+ if fields.get("efficiency"):
1547
+ entry["efficiency"] = parse_value(fields["efficiency"], field_name="efficiency")
1548
+ return entry
1549
+
1550
+
1551
+ def _build_bus(fields: dict[str, Any]) -> CommentedMap:
1552
+ for required in ("id", "kind", "host"):
1553
+ if not fields.get(required):
1554
+ raise EditRefusal(f"bus requires --{required}")
1555
+ entry = CommentedMap()
1556
+ entry["id"] = fields["id"]
1557
+ entry["kind"] = fields["kind"]
1558
+ entry["host"] = fields["host"]
1559
+ if fields.get("bitrate"):
1560
+ entry["bitrate"] = parse_value(fields["bitrate"], field_name="bitrate")
1561
+ return entry
1562
+
1563
+
1564
+ def _build_family(fields: dict[str, Any]) -> CommentedMap:
1565
+ for required in ("id", "name"):
1566
+ if not fields.get(required):
1567
+ raise EditRefusal(f"family requires --{required}")
1568
+ entry = CommentedMap()
1569
+ entry["id"] = fields["id"]
1570
+ entry["name"] = fields["name"]
1571
+ if fields.get("min_capture_window_s"):
1572
+ entry["min_capture_window_s"] = parse_value(
1573
+ fields["min_capture_window_s"], field_name="min_capture_window_s"
1574
+ )
1575
+ if fields.get("notes"):
1576
+ entry["notes"] = fields["notes"]
1577
+ return entry
1578
+
1579
+
1580
+ def _build_model(fields: dict[str, Any], doc: Any) -> CommentedMap:
1581
+ for required in ("id", "family"):
1582
+ if not fields.get(required):
1583
+ raise EditRefusal(f"model requires --{required}")
1584
+ family_ref = fields["family"]
1585
+ families = doc.get("families", []) or []
1586
+ if not any(isinstance(f, dict) and f.get("id") == family_ref for f in families):
1587
+ existing = ", ".join(
1588
+ sorted(f["id"] for f in families if isinstance(f, dict) and f.get("id"))
1589
+ )
1590
+ raise EditRefusal(
1591
+ f"family {family_ref!r} is not declared — declare it first: "
1592
+ "lockwire add family ... --id <fam.id> --name ... --source ... --witness ... "
1593
+ f"(or use an existing id: {existing or 'none declared yet'})"
1594
+ )
1595
+ entry = CommentedMap()
1596
+ entry["id"] = fields["id"]
1597
+ entry["family"] = family_ref
1598
+ input_min = fields.get("input_voltage_min")
1599
+ input_max = fields.get("input_voltage_max")
1600
+ if input_min or input_max:
1601
+ if not (input_min and input_max):
1602
+ raise EditRefusal(
1603
+ "model input voltage range needs both --input-voltage-min and --input-voltage-max"
1604
+ )
1605
+ entry["input_voltage_range"] = CommentedMap(
1606
+ [
1607
+ ("min", parse_value(input_min, field_name="min")),
1608
+ ("max", parse_value(input_max, field_name="max")),
1609
+ ]
1610
+ )
1611
+ return entry
1612
+
1613
+
1614
+ def _build_budget(fields: dict[str, Any], doc: Any) -> CommentedMap:
1615
+ for required in ("id", "budget_kind", "limit", "owner"):
1616
+ if not fields.get(required):
1617
+ raise EditRefusal(f"budget requires --{required.replace('budget_', '')}")
1618
+ kind = fields["budget_kind"]
1619
+ if kind not in ("rail_power", "endurance", "mass_total"):
1620
+ raise EditRefusal(
1621
+ f"budget --kind must be one of rail_power|endurance|mass_total, got {kind!r}"
1622
+ )
1623
+ rail_ref = fields.get("rail")
1624
+ if kind == "rail_power":
1625
+ if not rail_ref:
1626
+ raise EditRefusal("budget --kind rail_power requires --rail")
1627
+ rails = (
1628
+ doc.get("topology", {}).get("rails", [])
1629
+ if isinstance(doc.get("topology"), dict)
1630
+ else []
1631
+ )
1632
+ if not any(isinstance(r, dict) and r.get("id") == rail_ref for r in rails):
1633
+ raise EditRefusal(f"budget references unknown rail {rail_ref!r}")
1634
+ elif rail_ref:
1635
+ raise EditRefusal(f"budget --kind {kind} takes no --rail")
1636
+
1637
+ entry = CommentedMap()
1638
+ entry["id"] = fields["id"]
1639
+ entry["kind"] = kind
1640
+ entry["limit"] = parse_value(fields["limit"], field_name="limit")
1641
+ if rail_ref:
1642
+ entry["rail"] = rail_ref
1643
+ if fields.get("draw_mode"):
1644
+ entry["draw_mode"] = fields["draw_mode"]
1645
+ if fields.get("sources"):
1646
+ entry["sources"] = fields["sources"]
1647
+ entry["owner"] = fields["owner"]
1648
+ return entry
1649
+
1650
+
1651
+ def _build_gearbox(fields: dict[str, Any], doc: Any) -> CommentedMap:
1652
+ for required in ("id", "ratio", "input_joint", "output_joint"):
1653
+ if fields.get(required) in (None, ""):
1654
+ raise EditRefusal(f"gearbox requires --{required.replace('_', '-')}")
1655
+ joints = doc.get("model", {}).get("joints", []) if isinstance(doc.get("model"), dict) else []
1656
+ joint_names = {j.get("name") for j in joints if isinstance(j, dict)}
1657
+ for role in ("input_joint", "output_joint"):
1658
+ ref = fields[role]
1659
+ if ref not in joint_names:
1660
+ raise EditRefusal(f"gearbox {role} references unknown joint {ref!r}")
1661
+ try:
1662
+ ratio = float(fields["ratio"])
1663
+ except (TypeError, ValueError) as error:
1664
+ raise EditRefusal(f"gearbox --ratio must be a number, got {fields['ratio']!r}") from error
1665
+ if ratio <= 0:
1666
+ raise EditRefusal("gearbox --ratio must be > 0")
1667
+ entry = CommentedMap()
1668
+ entry["id"] = fields["id"]
1669
+ entry["ratio"] = ratio
1670
+ entry["input_joint"] = fields["input_joint"]
1671
+ entry["output_joint"] = fields["output_joint"]
1672
+ return entry
1673
+
1674
+
1675
+ # ------------------------------------------------------- pose edits (move/rotate/reparent)
1676
+ #
1677
+ # The "editable minor CAD changes". Each one is a FILE operation on
1678
+ # the contract, never a mutation of a live scene object: `scene.ScenePart` is a
1679
+ # frozen dataclass of baked world-space vertices with no pose, no parent and no
1680
+ # mesh reference (measured, `scene.py:38-48`), so the scene is a render product
1681
+ # and the contract is the thing that can be edited. The scene is rebuilt from the
1682
+ # contract afterwards by the code that already builds it.
1683
+ #
1684
+ # All three go through `apply_changes` — the same writer `lockwire declare` calls
1685
+ # — so source, witness and provenance are stamped by one gate, and a
1686
+ # refusal raised here happens before `apply_changes` opens the file at all, which
1687
+ # is what leaves a refused edit byte-identical.
1688
+
1689
+ #: Accepted rotation units and their factor to radians. `JointOrigin.rpy` is a
1690
+ #: bare radian triple (URDF-native), so radians is what gets written.
1691
+ #: `units.py` is deliberately NOT the table here: its `angle` dimension is based
1692
+ #: on degrees and carries no `rad` entry, and adding one would change every angle
1693
+ #: comparison in the codebase for the sake of a field that stores no unit at all.
1694
+ _ROTATION_UNITS = {"rad": 1.0, "deg": math.pi / 180.0}
1695
+
1696
+
1697
+ def _require_unit(unit: str | None, accepted: tuple[str, ...], *, what: str) -> str:
1698
+ """Refuse a missing or wrong-dimension unit rather than guessing one.
1699
+
1700
+ The joint-limit rule read across to pose: a number without its unit is
1701
+ refused, never defaulted. Defaulting is the failure this exists to prevent —
1702
+ ``--rpy 0,0,90`` meant in degrees and silently read as radians is a 90-radian
1703
+ rotation that no later check can tell from an intended one.
1704
+ """
1705
+ if unit is None or not unit.strip():
1706
+ raise EditRefusal(
1707
+ f"--unit is required for {what}: give one of {', '.join(accepted)}. "
1708
+ "A bare number has no meaning without it and lockwire does not guess."
1709
+ )
1710
+ if unit.strip() not in accepted:
1711
+ raise EditRefusal(
1712
+ f"--unit {unit.strip()!r} is not a {what} unit; give one of {', '.join(accepted)}"
1713
+ )
1714
+ return unit.strip()
1715
+
1716
+
1717
+ def _length_units() -> tuple[str, ...]:
1718
+ """Every length unit `units.py` knows, so the two never drift apart."""
1719
+ return tuple(u for u in ("m", "cm", "mm") if units.known(u))
1720
+
1721
+
1722
+ def _link_tree(contract: Any) -> tuple[dict[str, str], dict[str, str], set[str]]:
1723
+ """ONE walk of the recorded model: child->parent, child->joint name, link names.
1724
+
1725
+ Every pose refusal below reads this one map. A second tree walk that
1726
+ disagreed with this one is exactly how a cycle would get into the file,
1727
+ and a cycle in the file makes `checks._fixed_frame_transforms` return no
1728
+ transform for the whole subtree — the CG composition would go quiet rather
1729
+ than wrong, and nothing else in the repo would notice.
1730
+
1731
+ ponytail: a link named as the child of two joints keeps the last one seen.
1732
+ The schema forbids duplicate joint *names*, not duplicate children, so that
1733
+ model is already outside the tree these operations claim to edit; the
1734
+ root/orphan check below refuses it in practice by failing to reach the root.
1735
+ """
1736
+ if contract.model is None:
1737
+ raise EditRefusal(
1738
+ "this contract records no model, so there is no link tree to edit — "
1739
+ "run `lockwire start <urdf>` or `lockwire init` against a model first"
1740
+ )
1741
+ parent_of: dict[str, str] = {}
1742
+ joint_of: dict[str, str] = {}
1743
+ for joint in contract.model.joints:
1744
+ parent_of[joint.child] = joint.parent
1745
+ joint_of[joint.child] = joint.name
1746
+ return parent_of, joint_of, {link.name for link in contract.model.links}
1747
+
1748
+
1749
+ def _chain_to_root(parent_of: dict[str, str], link: str) -> list[str]:
1750
+ """``link`` then every ancestor above it, nearest first, ending at its root.
1751
+
1752
+ Stops if it meets a link twice, so a cycle already present in the file makes
1753
+ this terminate rather than hang.
1754
+ """
1755
+ chain, seen = [link], {link}
1756
+ while link in parent_of:
1757
+ link = parent_of[link]
1758
+ if link in seen:
1759
+ break
1760
+ chain.append(link)
1761
+ seen.add(link)
1762
+ return chain
1763
+
1764
+
1765
+ def _resolve_pose_target(contract: Any, target: str) -> tuple[str, str]:
1766
+ """``("device", id)`` or ``("link", name)`` — what ``target`` names, or a refusal."""
1767
+ if any(device.id == target for device in contract.devices):
1768
+ return "device", target
1769
+ if contract.model is not None and any(link.name == target for link in contract.model.links):
1770
+ return "link", target
1771
+ raise EditRefusal(
1772
+ f"{target!r} is neither a declared device id nor a link in the recorded model; "
1773
+ "a pose edit addresses one of those two"
1774
+ )
1775
+
1776
+
1777
+ def _joint_placing(contract: Any, link: str, *, verb: str) -> str:
1778
+ """The joint whose child is ``link`` — the joint that places it in its parent."""
1779
+ _parent_of, joint_of, _links = _link_tree(contract)
1780
+ if link not in joint_of:
1781
+ raise EditRefusal(
1782
+ f"link {link!r} is the model root: no joint makes it a child, so there is "
1783
+ f"no recorded pose to {verb}. Its frame is the model's own origin."
1784
+ )
1785
+ return joint_of[link]
1786
+
1787
+
1788
+ def _pose_change(
1789
+ path: str,
1790
+ value: Any,
1791
+ *,
1792
+ source: str,
1793
+ witness: str,
1794
+ note: str | None,
1795
+ locked_at: dt.date | None,
1796
+ unit: str | None = None,
1797
+ ) -> Change:
1798
+ return Change(
1799
+ path=path,
1800
+ value=value,
1801
+ source=source,
1802
+ witness=witness,
1803
+ note=note,
1804
+ locked_at=locked_at,
1805
+ unit=unit,
1806
+ )
1807
+
1808
+
1809
+ @_locked_write
1810
+ def place(
1811
+ contract_path: Path,
1812
+ target: str,
1813
+ on: str,
1814
+ offset: tuple[float, float, float],
1815
+ *,
1816
+ unit: str | None,
1817
+ source: str,
1818
+ witness: str,
1819
+ note: str | None = None,
1820
+ locked_at: dt.date | None = None,
1821
+ dry_run: bool = False,
1822
+ expected_revision: str | None = None,
1823
+ ) -> EditResult:
1824
+ """Place an existing device on a model link in one atomic contract write."""
1825
+ contract = load_contract(contract_path)
1826
+ kind, name = _resolve_pose_target(contract, target)
1827
+ if kind != "device":
1828
+ raise EditRefusal(
1829
+ f"{name!r} is a model link and already has a placing joint; "
1830
+ "use move, rotate or reparent to edit it"
1831
+ )
1832
+ _parent_of, _joint_of, links = _link_tree(contract)
1833
+ if on not in links:
1834
+ raise EditRefusal(
1835
+ f"placement parent {on!r} does not resolve: the recorded model has no link by that name"
1836
+ )
1837
+ unit = _require_unit(unit, _length_units(), what="an offset")
1838
+ return apply_changes(
1839
+ contract_path,
1840
+ [
1841
+ _pose_change(
1842
+ f"devices/{name}/physical/mount_frame",
1843
+ f'"{on}"',
1844
+ source=source,
1845
+ witness=witness,
1846
+ note=note,
1847
+ locked_at=locked_at,
1848
+ ),
1849
+ _pose_change(
1850
+ f"devices/{name}/physical/mount_xyz",
1851
+ [float(v) for v in offset],
1852
+ unit=unit,
1853
+ source=source,
1854
+ witness=witness,
1855
+ note=note,
1856
+ locked_at=locked_at,
1857
+ ),
1858
+ ],
1859
+ dry_run=dry_run,
1860
+ expected_revision=expected_revision,
1861
+ )
1862
+
1863
+
1864
+ @_locked_write
1865
+ def set_mount_shape(
1866
+ contract_path: Path,
1867
+ target: str,
1868
+ shape: dict[str, Any],
1869
+ *,
1870
+ source: str,
1871
+ witness: str,
1872
+ note: str | None = None,
1873
+ locked_at: dt.date | None = None,
1874
+ dry_run: bool = False,
1875
+ expected_revision: str | None = None,
1876
+ ) -> EditResult:
1877
+ """Write one device mount shape through the same atomic gate as declare."""
1878
+ contract = load_contract(contract_path)
1879
+ kind, name = _resolve_pose_target(contract, target)
1880
+ if kind != "device":
1881
+ raise EditRefusal(f"{name!r} is a model link; mount_shape belongs to device physical")
1882
+ return apply_changes(
1883
+ contract_path,
1884
+ [
1885
+ _pose_change(
1886
+ f"devices/{name}/physical/mount_shape",
1887
+ shape,
1888
+ source=source,
1889
+ witness=witness,
1890
+ note=note,
1891
+ locked_at=locked_at,
1892
+ )
1893
+ ],
1894
+ dry_run=dry_run,
1895
+ expected_revision=expected_revision,
1896
+ )
1897
+
1898
+
1899
+ @_locked_write
1900
+ def move(
1901
+ contract_path: Path,
1902
+ target: str,
1903
+ offset: tuple[float, float, float],
1904
+ *,
1905
+ unit: str | None,
1906
+ source: str,
1907
+ witness: str,
1908
+ note: str | None = None,
1909
+ locked_at: dt.date | None = None,
1910
+ dry_run: bool = False,
1911
+ expected_revision: str | None = None,
1912
+ ) -> EditResult:
1913
+ """Set where ``target`` sits in the frame that already holds it.
1914
+
1915
+ A device moves in its ``mount_frame`` (``devices/<id>/physical/mount_xyz``);
1916
+ a link moves in its parent link's frame, which is its placing joint's origin
1917
+ (``model/joints/<joint>/origin/xyz``, meters, URDF-native).
1918
+ """
1919
+ contract = load_contract(contract_path)
1920
+ kind, name = _resolve_pose_target(contract, target)
1921
+ unit = _require_unit(unit, _length_units(), what="an offset")
1922
+ if kind == "device":
1923
+ change = _pose_change(
1924
+ f"devices/{name}/physical/mount_xyz",
1925
+ [float(v) for v in offset],
1926
+ source=source,
1927
+ witness=witness,
1928
+ note=note,
1929
+ locked_at=locked_at,
1930
+ unit=unit,
1931
+ )
1932
+ else:
1933
+ joint = _joint_placing(contract, name, verb="move")
1934
+ change = _pose_change(
1935
+ f"model/joints/{joint}/origin/xyz",
1936
+ [units.convert(float(v), unit, "m") for v in offset],
1937
+ source=source,
1938
+ witness=witness,
1939
+ note=note,
1940
+ locked_at=locked_at,
1941
+ )
1942
+ return apply_changes(
1943
+ contract_path, [change], dry_run=dry_run, expected_revision=expected_revision
1944
+ )
1945
+
1946
+
1947
+ @_locked_write
1948
+ def rotate(
1949
+ contract_path: Path,
1950
+ target: str,
1951
+ rpy: tuple[float, float, float],
1952
+ *,
1953
+ unit: str | None,
1954
+ source: str,
1955
+ witness: str,
1956
+ note: str | None = None,
1957
+ locked_at: dt.date | None = None,
1958
+ dry_run: bool = False,
1959
+ expected_revision: str | None = None,
1960
+ ) -> EditResult:
1961
+ """Set ``target``'s orientation in the frame that already holds it.
1962
+
1963
+ Worth stating plainly: **a device has no recorded orientation.**
1964
+ ``schema.Physical`` carries mass, placement and mount shape, but no orientation. The only rotation
1965
+ the contract records is a joint's ``origin.rpy``, so ``rotate`` addresses a
1966
+ link, and a device target is refused in words that say why rather than
1967
+ inventing a field.
1968
+ """
1969
+ contract = load_contract(contract_path)
1970
+ kind, name = _resolve_pose_target(contract, target)
1971
+ unit = _require_unit(unit, tuple(_ROTATION_UNITS), what="a rotation")
1972
+ if kind == "device":
1973
+ raise EditRefusal(
1974
+ f"{name!r} is a device, and the contract records no orientation for a device: "
1975
+ "`physical` carries mass, placement and mount shape, but no orientation. "
1976
+ "Rotate the link it is mounted to instead, and if a device really "
1977
+ "needs its own orientation that is a schema change (open an issue to propose it)."
1978
+ )
1979
+ joint = _joint_placing(contract, name, verb="rotate")
1980
+ factor = _ROTATION_UNITS[unit]
1981
+ change = _pose_change(
1982
+ f"model/joints/{joint}/origin/rpy",
1983
+ [float(v) * factor for v in rpy],
1984
+ source=source,
1985
+ witness=witness,
1986
+ note=note,
1987
+ locked_at=locked_at,
1988
+ )
1989
+ return apply_changes(
1990
+ contract_path, [change], dry_run=dry_run, expected_revision=expected_revision
1991
+ )
1992
+
1993
+
1994
+ @_locked_write
1995
+ def reparent(
1996
+ contract_path: Path,
1997
+ target: str,
1998
+ to: str,
1999
+ *,
2000
+ source: str,
2001
+ witness: str,
2002
+ note: str | None = None,
2003
+ locked_at: dt.date | None = None,
2004
+ dry_run: bool = False,
2005
+ expected_revision: str | None = None,
2006
+ ) -> EditResult:
2007
+ """Hang ``target`` off ``to`` instead of whatever holds it now.
2008
+
2009
+ A device changes the link it is mounted to (``physical/mount_frame``); a link
2010
+ changes its placing joint's ``parent``, which moves the link tree and
2011
+ therefore ``structure_hash`` — that move is PRINTED, never silent, because a
2012
+ silent one makes every later model import look like drift
2013
+ (``urdf.describe_structure_drift``).
2014
+
2015
+ Three refusals, each naming which one it is rather than failing generically:
2016
+ a parent that does not resolve, a re-parent that closes a CYCLE (``to`` is
2017
+ ``target`` or sits below it), and one that would ORPHAN ``target``'s subtree
2018
+ (``to`` is not connected to the root ``target`` currently hangs from).
2019
+ """
2020
+ contract = load_contract(contract_path)
2021
+ kind, name = _resolve_pose_target(contract, target)
2022
+ parent_of, joint_of, links = _link_tree(contract)
2023
+ if to not in links:
2024
+ raise EditRefusal(
2025
+ f"parent {to!r} does not resolve: the recorded model has no link by that name "
2026
+ f"(it has {', '.join(sorted(links)) or 'no links'})"
2027
+ )
2028
+ if kind == "device":
2029
+ change = _pose_change(
2030
+ f"devices/{name}/physical/mount_frame",
2031
+ f'"{to}"',
2032
+ source=source,
2033
+ witness=witness,
2034
+ note=note,
2035
+ locked_at=locked_at,
2036
+ )
2037
+ return apply_changes(
2038
+ contract_path, [change], dry_run=dry_run, expected_revision=expected_revision
2039
+ )
2040
+
2041
+ if to == name:
2042
+ raise EditRefusal(
2043
+ f"re-parenting link {name!r} onto itself closes a CYCLE in the link tree: "
2044
+ "a link cannot be its own parent"
2045
+ )
2046
+ chain = _chain_to_root(parent_of, to)
2047
+ if name in chain:
2048
+ below = " -> ".join(reversed(chain[: chain.index(name) + 1]))
2049
+ raise EditRefusal(
2050
+ f"re-parenting link {name!r} onto {to!r} closes a CYCLE in the link tree: "
2051
+ f"{to!r} already sits below {name!r} ({below}). A cycle in the link tree makes "
2052
+ "every later tree walk in the contract either stop early or report no transform "
2053
+ "at all, so it is refused rather than written"
2054
+ )
2055
+ anchor = _chain_to_root(parent_of, name)[-1]
2056
+ if chain[-1] != anchor:
2057
+ raise EditRefusal(
2058
+ f"re-parenting link {name!r} onto {to!r} would ORPHAN {name!r} and everything "
2059
+ f"below it: {to!r} is not connected to the model root {anchor!r} (it reaches "
2060
+ f"{chain[-1]!r} instead), so the moved subtree would hang off nothing"
2061
+ )
2062
+
2063
+ joint = _joint_placing(contract, name, verb="re-parent")
2064
+ model = contract.model
2065
+ assert model is not None # _link_tree already refused a contract with no model
2066
+ old_hash = model.structure_hash
2067
+ new_hash = _model_structure_hash(
2068
+ model.links,
2069
+ [j.model_copy(update={"parent": to}) if j.name == joint else j for j in model.joints],
2070
+ )
2071
+ result = apply_changes(
2072
+ contract_path,
2073
+ [
2074
+ _pose_change(
2075
+ f"model/joints/{joint}/parent",
2076
+ f'"{to}"',
2077
+ source=source,
2078
+ witness=witness,
2079
+ note=note,
2080
+ locked_at=locked_at,
2081
+ ),
2082
+ _pose_change(
2083
+ "model/structure_hash",
2084
+ f'"{new_hash}"',
2085
+ source=source,
2086
+ witness=witness,
2087
+ note=note,
2088
+ locked_at=locked_at,
2089
+ ),
2090
+ ],
2091
+ dry_run=dry_run,
2092
+ expected_revision=expected_revision,
2093
+ )
2094
+ result.lines.append(
2095
+ f"structure hash moved: {old_hash} -> {new_hash} "
2096
+ f"(the link tree changed, so every later model import compares against the new one)"
2097
+ )
2098
+ return result