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/emit_ts.py ADDED
@@ -0,0 +1,150 @@
1
+ """Emit `app/src/shared/api.gen.ts` directly from the payload union (e1-d3).
2
+
3
+ Plan v8 line 265, verbatim: *"There is no TypeScript generator in the list,
4
+ deliberately: the mainstream generator's own documentation says a
5
+ discriminated union like ours comes out in a shape TypeScript cannot narrow.
6
+ The Python step that builds the union emits the TypeScript directly (about
7
+ forty lines), and CI fails if the committed file is stale."*
8
+
9
+ Measured 2026-09-08: the alternative (`api-schema`'s JSON Schema piped through
10
+ the `openapi-typescript` npm package, per `packaging/generate-types.md`) does
11
+ not run on this repo's toolchain today — `openapi-typescript@7.13.0` declares
12
+ a peer on `typescript@^5.x` and crashes (`Cannot read properties of undefined
13
+ (reading 'createKeywordTypeNode')`) against `app/package.json`'s already-pinned
14
+ `typescript@7.0.2`. That is a second, independent reason (beyond v8 line 265)
15
+ this box adds no npm dependency.
16
+
17
+ this walks `api_payload.ALL_MEMBERS` and each model's own
18
+ `model_json_schema()` — the same schema `api_payload.export_openapi` already
19
+ assembles for `lockwire api-schema` — rather than re-enumerating fields or
20
+ commands by hand. One walk, reused.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from lockwire import plugins
31
+ from lockwire.api_payload import ALL_MEMBERS, MEMBERS
32
+ from lockwire.report import VERDICT_EXIT_CODES, verdict_word
33
+
34
+ #: Every consumer must see this before touching the file by hand.
35
+ SENTINEL = (
36
+ "// GENERATED — do not edit by hand.\n"
37
+ "// `uv run lockwire api-types --out app/src/shared/api.gen.ts` regenerates this\n"
38
+ "// file from `src/lockwire/api_payload.py`. CI regenerates and diffs on every run;\n"
39
+ "// a stale committed file fails the build (packaging/generate-types.md).\n"
40
+ )
41
+
42
+
43
+ PLUGIN_SENTINEL = (
44
+ "// GENERATED — do not edit by hand.\n"
45
+ "// Written next to `api.gen.ts` by the same `lockwire api-types` run, from the payload\n"
46
+ "// members a plugin registers. A stale committed file fails the build.\n"
47
+ )
48
+
49
+
50
+ def _schemas(models: tuple[type, ...] = ALL_MEMBERS) -> dict[str, Any]:
51
+ """The same `{name: schema}` map `api_payload.export_openapi` builds — one
52
+ call per union member, `$defs` folded in beside it."""
53
+ schemas: dict[str, Any] = {}
54
+ for model in models:
55
+ schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
56
+ schemas.update(schema.pop("$defs", {}))
57
+ schemas[model.__name__] = schema
58
+ return schemas
59
+
60
+
61
+ def _ts_type(node: dict[str, Any]) -> str:
62
+ """One JSON-Schema node -> one TypeScript type expression."""
63
+ if "$ref" in node:
64
+ return node["$ref"].rsplit("/", 1)[-1]
65
+ if "const" in node:
66
+ return json.dumps(node["const"])
67
+ if "enum" in node:
68
+ return " | ".join(json.dumps(v) for v in node["enum"])
69
+ if "anyOf" in node:
70
+ return " | ".join(_ts_type(n) for n in node["anyOf"])
71
+ kind = node.get("type")
72
+ if kind == "string":
73
+ return "string"
74
+ if kind in ("integer", "number"):
75
+ return "number"
76
+ if kind == "boolean":
77
+ return "boolean"
78
+ if kind == "null":
79
+ return "null"
80
+ if kind == "array":
81
+ return f"({_ts_type(node['items'])})[]"
82
+ if kind == "object":
83
+ values = node.get("additionalProperties")
84
+ return f"Record<string, {_ts_type(values)}>" if isinstance(values, dict) else "Record<string, unknown>"
85
+ return "unknown" # pragma: no cover — no union member emits an untyped leaf today
86
+
87
+
88
+ def _interface(name: str, schema: dict[str, Any]) -> str:
89
+ required = set(schema.get("required", []))
90
+ lines = [f"export interface {name} {{"]
91
+ for field, node in schema.get("properties", {}).items():
92
+ optional = "" if field in required else "?"
93
+ lines.append(f" {field}{optional}: {_ts_type(node)};")
94
+ lines.append("}")
95
+ return "\n".join(lines)
96
+
97
+
98
+ def _declaration(name: str, schema: dict[str, Any]) -> str:
99
+ """Emit objects as interfaces and root schemas as named type aliases."""
100
+ if schema.get("type") == "object" or "properties" in schema:
101
+ return _interface(name, schema)
102
+ return f"export type {name} = {_ts_type(schema)};"
103
+
104
+
105
+ def emit() -> str:
106
+ """The whole file, as text — every union member's interface plus the
107
+ discriminated `MachineDocument` alias TypeScript narrows on `artifact_type`."""
108
+ schemas = _schemas()
109
+ parts = [SENTINEL]
110
+ for name in sorted(schemas):
111
+ parts.append(_declaration(name, schemas[name]))
112
+ verdict_exit_codes = {
113
+ verdict_word(code): exit_code for code, exit_code in VERDICT_EXIT_CODES.items()
114
+ }
115
+ parts.append(
116
+ "export const VERDICT_EXIT_CODES = "
117
+ + json.dumps(verdict_exit_codes, indent=2)
118
+ + ' as const satisfies Record<CheckPayload["verdict"], number>;'
119
+ )
120
+ members = " | ".join(model.__name__ for model in MEMBERS.values())
121
+ parts.append(f"export type MachineDocument = {members};")
122
+ return "\n\n".join(parts) + "\n"
123
+
124
+
125
+ def emit_plugin() -> str | None:
126
+ """The file for members a plugin registered (`plugins.PAYLOAD_*`), or None when
127
+ there are none. Types the free file already defines are imported from it."""
128
+ plugins.ensure()
129
+ models = (*plugins.PAYLOAD_MEMBERS.values(), *plugins.PAYLOAD_EXTRA)
130
+ if not models:
131
+ return None
132
+ free = _schemas()
133
+ own = {name: s for name, s in _schemas(models).items() if name not in free}
134
+ refs = set(re.findall(r'"\$ref": "#/components/schemas/(\w+)"', json.dumps(own)))
135
+ parts = [PLUGIN_SENTINEL]
136
+ if used := sorted(refs & set(free)):
137
+ parts.append("import type { " + ", ".join(used) + ' } from "../api.gen";')
138
+ parts += [_declaration(name, own[name]) for name in sorted(own)]
139
+ members = " | ".join(model.__name__ for model in plugins.PAYLOAD_MEMBERS.values())
140
+ parts.append(f"export type MachineDocumentPlugin = {members};")
141
+ return "\n\n".join(parts) + "\n"
142
+
143
+
144
+ def write(path: Path | str) -> None:
145
+ """The free file at `path`; a plugin's members, if any, at `<dir>/pro/<name>`."""
146
+ Path(path).write_text(emit(), encoding="utf-8")
147
+ if (extra := emit_plugin()) is not None:
148
+ out = Path(path).parent / "pro" / Path(path).name
149
+ out.parent.mkdir(exist_ok=True)
150
+ out.write_text(extra, encoding="utf-8")
lockwire/export_mhs.py ADDED
@@ -0,0 +1,115 @@
1
+ """The MHS bridge, outward only.
2
+
3
+ Renders each declared device into the converging device-manifest shape five independent
4
+ third-party designs settled on (``device_id``, ``capabilities[]``, ``safety_limits[]`` with
5
+ ``min``/``max``): what it measures, what it adjusts, what it refuses. ``min``/``max`` are
6
+ aliased straight from ``Band.lo``/``Band.hi`` — nothing here is invented, predicted or guessed.
7
+ Read-only, derived from the contract, never the truth.
8
+
9
+ Import of a third-party manifest into a contract is refused elsewhere (``lockwire import``): the inbound direction is refused until
10
+ an open standard is published, because import is where lock-in happens. This module
11
+ builds the outward direction only — no reader for the inbound shape lives here.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from lockwire.checks import power_rollup
17
+ from lockwire.describe import _role
18
+ from lockwire.schema import Contract, DeviceInstance, Provenance
19
+
20
+ # ponytail: the schema's witness enum (`Provenance.witness`, schema.py:191) is
21
+ # "w0..w6 | live | human" — no literal "fixture"/"sim" value exists yet. "human"
22
+ # (author-asserted, not measured off real hardware or a bag) is the closest proxy for
23
+ # "this number is a stand-in, not a real measurement". Revisit if a real witness value
24
+ # for fixture/sim data lands.
25
+ _SIMULATED_WITNESSES = {"human"}
26
+
27
+
28
+ def _provenance_fields(provenance: Provenance | None) -> dict[str, object]:
29
+ if provenance is None:
30
+ return {"witness": None, "simulated": False}
31
+ return {
32
+ "witness": provenance.witness,
33
+ "simulated": provenance.witness in _SIMULATED_WITNESSES,
34
+ }
35
+
36
+
37
+ def _safety_limits(instance: DeviceInstance, model) -> list[dict[str, object]]:
38
+ """Every band this device declares, each tagged with where it came from.
39
+
40
+ A datasheet rating (``model.hard_limits``) never reads the same as a fitted band
41
+ (statistically evidenced, ``envelope.fitted_bands``) or an inferred one (observed,
42
+ no fit evidence) — collapsing the three into one field is a loss this module refuses.
43
+ """
44
+ limits: list[dict[str, object]] = []
45
+ if model is not None and model.hard_limits:
46
+ prov = _provenance_fields(model.provenance)
47
+ for channel, band in sorted(model.hard_limits.items()):
48
+ limits.append(
49
+ {
50
+ "channel": channel,
51
+ "min": band.lo,
52
+ "max": band.hi,
53
+ "unit": band.unit,
54
+ "kind": "datasheet",
55
+ **prov,
56
+ }
57
+ )
58
+ for envelope in instance.envelopes:
59
+ prov = _provenance_fields(envelope.provenance)
60
+ for name, signal in sorted(envelope.signals.items()):
61
+ kind = "fitted" if f"signals/{name}" in envelope.fitted_bands else "inferred"
62
+ limits.append(
63
+ {
64
+ "channel": f"{envelope.context}/{name}",
65
+ "min": signal.band.lo,
66
+ "max": signal.band.hi,
67
+ "unit": signal.band.unit,
68
+ "kind": kind,
69
+ **prov,
70
+ }
71
+ )
72
+ return limits
73
+
74
+
75
+ def _capabilities(model, instance: DeviceInstance) -> list[str]:
76
+ role = _role(model, instance) # reuse describe.py's walk rather than re-derive it
77
+ if role == "measures and adjusts":
78
+ return ["measures", "adjusts"]
79
+ if role in ("measures", "adjusts"):
80
+ return [role]
81
+ return []
82
+
83
+
84
+ def render_device(contract: Contract, instance: DeviceInstance) -> dict[str, object]:
85
+ """One device's converging-manifest entry."""
86
+ model = contract.device_model(instance.model)
87
+ prov = _provenance_fields(instance.provenance)
88
+ return {
89
+ "device_id": instance.id,
90
+ "capabilities": _capabilities(model, instance),
91
+ "safety_limits": _safety_limits(instance, model),
92
+ **prov,
93
+ }
94
+
95
+
96
+ def _loss_report(contract: Contract) -> list[str]:
97
+ """What a per-device manifest cannot carry, named rather than generic."""
98
+ rail_totals, *_ = power_rollup(contract, "nominal")
99
+ rails = ", ".join(sorted(rail_totals)) if rail_totals else "none declared"
100
+ return [
101
+ f"shared-rail power budgets are not carried: this contract rolls up {rails}; "
102
+ "a per-device manifest has no field for a rail's capacity or headroom.",
103
+ "frame references are not carried: joint/link membership and the URDF frame tree "
104
+ "(mount frames, drives_joint pairings) have no place in a per-device manifest.",
105
+ "provenance/enforcement metadata beyond witness/simulated is not carried: bag "
106
+ "hashes, lock dates, and the write-layer verdicts are dropped.",
107
+ ]
108
+
109
+
110
+ def export_mhs(contract: Contract) -> dict[str, object]:
111
+ """Render the whole contract into the converging device-manifest shape. Outward only."""
112
+ return {
113
+ "devices": [render_device(contract, device) for device in contract.devices],
114
+ "loss_report": _loss_report(contract),
115
+ }
lockwire/factdiff.py ADDED
@@ -0,0 +1,254 @@
1
+ """The fact diff: what changed in the facts, not in the text.
2
+
3
+ Two loaded contracts in, a diff document out. Nothing here reads git (that is
4
+ ``gitref.py``) and nothing here prints (``cli.py`` does that) — this module
5
+ only compares.
6
+
7
+ The comparison is over the *validated* contract, not the YAML text, so a
8
+ reordered device list, a re-indented block or a rewritten comment is not a
9
+ change. Every declared scalar is flattened to a ``/``-separated path
10
+ (``topology/rails/rail.5v/capacity/value``), list rows keyed by their own
11
+ ``id``/``name``/``context`` rather than by position.
12
+ """
13
+
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from tempfile import NamedTemporaryFile
17
+ from typing import Any
18
+
19
+ from lockwire.checks import run_record_checks
20
+ from lockwire.schema import Contract, load_contract
21
+
22
+ # Path segments whose value *is* a declared ceiling. A number growing under one
23
+ # of these is a limit being raised — the edit that makes a finding go away
24
+ # without the robot changing, so it is called out rather than listed as one
25
+ # more field change.
26
+ LIMIT_SEGMENTS = frozenset(
27
+ {
28
+ "capacity",
29
+ "limit",
30
+ "hard_limits",
31
+ "current_limit",
32
+ "effort",
33
+ "velocity",
34
+ "torque_peak",
35
+ "torque_continuous",
36
+ "fade_margin_required",
37
+ "position_tracking_max_error",
38
+ "feedback_deadline_max",
39
+ "feedback_loss_max",
40
+ "clock_alignment_max_error",
41
+ "prediction_position_rmse_max",
42
+ "prediction_position_peak_max",
43
+ }
44
+ )
45
+
46
+ # The entity collections a person thinks in. An added or removed row here is
47
+ # reported once, by id — not as forty field changes underneath it.
48
+ ENTITY_KINDS = (
49
+ "devices",
50
+ "device_models",
51
+ "families",
52
+ "budgets",
53
+ "assumptions",
54
+ "rails",
55
+ "buses",
56
+ "wires",
57
+ "links",
58
+ "actuator_cases",
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class FieldChange:
64
+ path: str
65
+ old: Any # None when the field was not declared before
66
+ new: Any # None when the field is no longer declared
67
+ witness: str | None = None
68
+ source: str | None = None
69
+ limit_raised: bool = False
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class EntityChange:
74
+ kind: str # one of ENTITY_KINDS
75
+ id: str
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class FindingRef:
80
+ severity: str
81
+ quantity: str
82
+
83
+
84
+ @dataclass
85
+ class FactDiff:
86
+ changes: list[FieldChange] = field(default_factory=list)
87
+ added: list[EntityChange] = field(default_factory=list)
88
+ removed: list[EntityChange] = field(default_factory=list)
89
+ findings_new: list[FindingRef] = field(default_factory=list)
90
+ findings_gone: list[FindingRef] = field(default_factory=list)
91
+
92
+ @property
93
+ def changed(self) -> bool:
94
+ return bool(self.changes or self.added or self.removed)
95
+
96
+ def as_json(self) -> dict[str, Any]:
97
+ return {
98
+ "changed": self.changed,
99
+ "changes": [
100
+ {
101
+ "path": c.path,
102
+ "old": c.old,
103
+ "new": c.new,
104
+ "witness": c.witness,
105
+ "source": c.source,
106
+ "limit_raised": c.limit_raised,
107
+ }
108
+ for c in self.changes
109
+ ],
110
+ "added": [{"kind": e.kind, "id": e.id} for e in self.added],
111
+ "removed": [{"kind": e.kind, "id": e.id} for e in self.removed],
112
+ "findings_new": [
113
+ {"severity": f.severity, "quantity": f.quantity} for f in self.findings_new
114
+ ],
115
+ "findings_gone": [
116
+ {"severity": f.severity, "quantity": f.quantity} for f in self.findings_gone
117
+ ],
118
+ }
119
+
120
+
121
+ def _key_for(row: Any, index: int) -> str:
122
+ if isinstance(row, dict):
123
+ for field_name in ("id", "name", "context"):
124
+ value = row.get(field_name)
125
+ if isinstance(value, str) and value:
126
+ return value
127
+ return str(index)
128
+
129
+
130
+ def _flatten(node: Any, prefix: str, out: dict[str, Any]) -> None:
131
+ if isinstance(node, dict):
132
+ for key, value in node.items():
133
+ _flatten(value, f"{prefix}/{key}" if prefix else str(key), out)
134
+ elif isinstance(node, list):
135
+ for index, row in enumerate(node):
136
+ _flatten(row, f"{prefix}/{_key_for(row, index)}", out)
137
+ else:
138
+ out[prefix] = node
139
+
140
+
141
+ def flatten(contract: Contract) -> dict[str, Any]:
142
+ """Every declared scalar in the contract, by path."""
143
+ out: dict[str, Any] = {}
144
+ _flatten(contract.model_dump(mode="json", exclude_none=True), "", out)
145
+ return out
146
+
147
+
148
+ def _entities(contract: Contract) -> dict[str, set[str]]:
149
+ topology = contract.topology
150
+ model = contract.model
151
+ return {
152
+ "devices": {row.id for row in contract.devices},
153
+ "device_models": {row.id for row in contract.device_models},
154
+ "families": {row.id for row in contract.families},
155
+ "budgets": {row.id for row in contract.budgets},
156
+ "assumptions": {row.id for row in contract.assumptions},
157
+ "rails": {row.id for row in topology.rails} if topology else set(),
158
+ "buses": {row.id for row in topology.buses} if topology else set(),
159
+ "wires": {row.id for row in topology.wires} if topology else set(),
160
+ "links": {row.name for row in model.links} if model else set(),
161
+ "actuator_cases": {row.id for row in contract.actuator_cases},
162
+ }
163
+
164
+
165
+ def _limit_raised(path: str, old: Any, new: Any) -> bool:
166
+ segments = path.split("/")
167
+ if segments[-1] == "lo": # raising a floor narrows the band, it does not relax it
168
+ return False
169
+ if not LIMIT_SEGMENTS.intersection(segments):
170
+ return False
171
+ if not isinstance(old, (int, float)) or not isinstance(new, (int, float)):
172
+ return False
173
+ return bool(new > old)
174
+
175
+
176
+ def _provenance_for(path: str, flat: dict[str, Any]) -> tuple[str | None, str | None]:
177
+ """Witness and source of the nearest enclosing entry that declares provenance."""
178
+ segments = path.split("/")
179
+ for cut in range(len(segments) - 1, 0, -1):
180
+ stem = "/".join(segments[:cut])
181
+ witness = flat.get(f"{stem}/provenance/witness")
182
+ if witness is not None:
183
+ return witness, flat.get(f"{stem}/provenance/source")
184
+ return None, None
185
+
186
+
187
+ def _findings(contract: Contract) -> set[FindingRef]:
188
+ return {FindingRef(f.severity, f.quantity) for f in run_record_checks(contract)}
189
+
190
+
191
+ def fact_diff(before: Contract, after: Contract) -> FactDiff:
192
+ """Compare two contracts: declared values, entries, and the verdict delta."""
193
+ diff = FactDiff()
194
+
195
+ old_entities, new_entities = _entities(before), _entities(after)
196
+ touched: set[str] = set()
197
+ for kind in ENTITY_KINDS:
198
+ for entity_id in sorted(new_entities[kind] - old_entities[kind]):
199
+ diff.added.append(EntityChange(kind, entity_id))
200
+ touched.add(entity_id)
201
+ for entity_id in sorted(old_entities[kind] - new_entities[kind]):
202
+ diff.removed.append(EntityChange(kind, entity_id))
203
+ touched.add(entity_id)
204
+
205
+ flat_old, flat_new = flatten(before), flatten(after)
206
+ for path in sorted(set(flat_old) | set(flat_new)):
207
+ old, new = flat_old.get(path), flat_new.get(path)
208
+ if old == new:
209
+ continue
210
+ if touched.intersection(path.split("/")):
211
+ continue # already reported once, as an added/removed entry
212
+ witness, source = _provenance_for(path, flat_new if new is not None else flat_old)
213
+ diff.changes.append(
214
+ FieldChange(path, old, new, witness, source, _limit_raised(path, old, new))
215
+ )
216
+
217
+ old_findings, new_findings = _findings(before), _findings(after)
218
+ key = lambda finding: (finding.severity, finding.quantity) # noqa: E731
219
+ diff.findings_new = sorted(new_findings - old_findings, key=key)
220
+ diff.findings_gone = sorted(old_findings - new_findings, key=key)
221
+ return diff
222
+
223
+
224
+ def render_lines(diff: FactDiff) -> list[str]:
225
+ """The fact diff as plain lines, in the order a person reads them."""
226
+ if not diff.changed and not diff.findings_new and not diff.findings_gone:
227
+ return ["no fact changes"]
228
+ lines: list[str] = []
229
+ for change in diff.changes:
230
+ provenance = ""
231
+ if change.witness or change.source:
232
+ provenance = f" [{change.witness or '?'} · {change.source or '?'}]"
233
+ flag = " limit raised" if change.limit_raised else ""
234
+ lines.append(f"~ {change.path}: {change.old} -> {change.new}{provenance}{flag}")
235
+ for entry in diff.added:
236
+ lines.append(f"+ {entry.kind}/{entry.id}")
237
+ for entry in diff.removed:
238
+ lines.append(f"- {entry.kind}/{entry.id}")
239
+ for finding in diff.findings_new:
240
+ lines.append(f"! new {finding.severity}: {finding.quantity}")
241
+ for finding in diff.findings_gone:
242
+ lines.append(f"o gone {finding.severity}: {finding.quantity}")
243
+ return lines
244
+
245
+
246
+ def load_contract_text(text: str) -> Contract:
247
+ """Load a contract held in memory (the version git handed back)."""
248
+ with NamedTemporaryFile("w", suffix=".yaml", encoding="utf-8", delete=False) as handle:
249
+ handle.write(text)
250
+ temp = Path(handle.name)
251
+ try:
252
+ return load_contract(temp)
253
+ finally:
254
+ temp.unlink(missing_ok=True)