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/vcs.py ADDED
@@ -0,0 +1,402 @@
1
+ """Commit the contract by path, and undo a write, from a source
2
+ it names out loud — two offers, one module,
3
+ because both are the Python half of "version control" for the contract and
4
+ both go through the one door, `lockwire.gitref`.
5
+
6
+ **Commit** (`commit_contract`): builds nothing about the message —
7
+ the source and the witness in it come from `edit.apply_changes`'s own
8
+ return (stitched by the app), never re-parsed out of
9
+ the YAML here. It shows the exact argv before running anything, and commits
10
+ **by path**: the contract and its sidecars, nothing else dirty in the tree.
11
+ It never pushes — pushing is the user's call, not the app's.
12
+
13
+ **Undo** (`undo_plan`/`apply_undo`): inside a
14
+ repository git *is* the undo, so this half builds no history of its
15
+ own either. What it adds is the one thing plain git does not give you: a
16
+ plan you can read before anything runs — the exact argv, and the exact list
17
+ of files it touches.
18
+
19
+ **Blocker 5.** ``git restore -- <file>`` restores from **the index**, not
20
+ from HEAD. Run it after someone staged a copy and the file comes back as
21
+ the *staged* version, silently, with a zero exit code. Every restore this
22
+ module plans therefore carries ``--source=<ref>`` explicitly; a bare
23
+ ``git restore`` is never produced, and
24
+ ``test_undo_restores_from_head_not_the_index`` holds that.
25
+
26
+ **Scope, and what is at risk.** A ``restore`` plan touches exactly the one
27
+ path in ``scope``; a change to any neighbouring file — staged, unstaged or
28
+ untracked — survives it. The working copy of the named file is what the
29
+ undo discards: that is the undo, and the sentence says so before it runs. A
30
+ ``revert`` plan can touch several files, so :func:`apply_undo` **refuses**
31
+ it while the working tree is dirty rather than putting anyone's unsaved
32
+ work under a merge.
33
+
34
+ Outside a repository nothing new is built for undo: a sibling ``<path>.bak``,
35
+ if one exists, is the only safety net (its writer, ``edit.backup_before_write``,
36
+ was cut as dead code — infra: cuts per docs/PLAN.md — so this branch is now
37
+ reachable only when something else left a ``.bak`` beside the file).
38
+
39
+ The one door to git is `lockwire.gitref`; this module adds no
40
+ subprocess call of its own.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import os
46
+ import tempfile
47
+ from pathlib import Path
48
+
49
+ from lockwire import gitref
50
+ from lockwire.edit import EditConflict, _contract_lock, contract_revision
51
+
52
+ #: What every operation in the version-control leg says when there is no
53
+ #: repository (or no `git` on the PATH) to act on — commit does nothing.
54
+ NOT_A_REPO = "not a git repository — nothing committed"
55
+
56
+ #: argv fragments that must never appear in anything this module builds —
57
+ #: swept for by `test_no_code_path_here_pushes` (pushing is the user's
58
+ #: call, never the app's).
59
+ _PUSH_MARKERS = ("push", "remote", "--set-upstream")
60
+
61
+
62
+ def _witness_present(message: str) -> bool:
63
+ """True when `message` carries a non-empty ``witness: ...`` marker.
64
+
65
+ The commit message carries source *and* witness — this is the
66
+ one check `commit_contract` makes on the message itself, since it does
67
+ not build the message (that is `apply_changes`'s job, upstream of here).
68
+ """
69
+ lower = message.lower()
70
+ idx = lower.find("witness:")
71
+ if idx == -1:
72
+ return False
73
+ after = message[idx + len("witness:") :].partition("\n")[0].strip()
74
+ return bool(after)
75
+
76
+
77
+ def _other_dirty_paths(repo: Path, committed: set[str]) -> list[str]:
78
+ """Repo-relative paths `git status --porcelain` still shows dirty.
79
+
80
+ Read before the commit runs, so it also answers a `dry_run` preview
81
+ ("these are the files this commit will leave alone").
82
+ """
83
+ others = []
84
+ for line in gitref.status_porcelain(repo).splitlines():
85
+ rest = line[3:]
86
+ if " -> " in rest: # a rename entry: "old -> new"
87
+ rest = rest.split(" -> ", 1)[1]
88
+ rest = rest.strip().strip('"')
89
+ if rest and rest not in committed:
90
+ others.append(rest)
91
+ return others
92
+
93
+
94
+ def commit_contract(
95
+ contract_path: str | Path,
96
+ *,
97
+ message: str,
98
+ paths: list[str],
99
+ dry_run: bool = False,
100
+ expected_revision: str | None = None,
101
+ ) -> dict:
102
+ """Commit `paths` (the contract and its sidecars) with `message`.
103
+
104
+ Returns ``{"argv": [...], "committed": bool, "sha": str | None,
105
+ "untouched": [...]}``. A real commit requires the full raw revision of
106
+ `contract_path`; it is compared while holding the contract lock just
107
+ before Git stages and commits. Refuses (``ValueError``) when `message`
108
+ carries no witness, or when a requested path resolves outside the
109
+ repository — both are caller mistakes, not states to report and continue past.
110
+
111
+ Outside a repository, or with no `git` on the PATH, does nothing and
112
+ returns the one sentence the rest of the version-control leg gives
113
+ (`NOT_A_REPO`) instead of an argv.
114
+
115
+ Ceilings this does not cover: a pre-commit hook that rejects the commit
116
+ (surfaces as `committed: False`, no exception), a detached HEAD or an
117
+ unborn branch (git accepts a commit in both; this function does not
118
+ special-case either), and a submodule boundary (paths inside a submodule
119
+ are outside `repo_root`'s answer for the superproject and are refused as
120
+ "outside the repository").
121
+ """
122
+ if not _witness_present(message):
123
+ raise ValueError("commit message must carry a witness — no 'witness:' found")
124
+ if not paths:
125
+ raise ValueError("commit_contract needs at least one path")
126
+
127
+ contract_path = Path(contract_path).resolve()
128
+ repo = gitref.repo_root(contract_path)
129
+ if repo is None:
130
+ return {"argv": [], "committed": False, "sha": None, "untouched": [], "reason": NOT_A_REPO}
131
+
132
+ relpaths: list[str] = []
133
+ for raw in paths:
134
+ rel = gitref.relpath_in_repo(Path(raw).resolve(), repo)
135
+ if rel is None:
136
+ raise ValueError(f"{raw} is outside the repository at {repo}")
137
+ relpaths.append(rel)
138
+
139
+ argv = ["git", "commit", "-m", message, "--", *relpaths]
140
+ # The message is free text ("relocate remote antenna"); scan only git's own argv.
141
+ joined = " ".join(a for a in argv if a != message).lower()
142
+ if any(marker in joined for marker in _PUSH_MARKERS):
143
+ raise AssertionError("commit_contract built an argv that pushes — refusing to run it")
144
+
145
+ untouched = _other_dirty_paths(repo, set(relpaths))
146
+
147
+ if dry_run:
148
+ return {"argv": argv, "committed": False, "sha": None, "untouched": untouched}
149
+ if expected_revision is None:
150
+ raise EditConflict("expected_revision is required before committing; reload first")
151
+ # A known-stale caller must not create a persistent lockfile just to refuse.
152
+ # The comparison below is authoritative for races that follow this precheck.
153
+ if expected_revision != contract_revision(contract_path):
154
+ raise EditConflict("contract changed since it was opened; reload before committing")
155
+
156
+ with _contract_lock(contract_path):
157
+ actual_revision = contract_revision(contract_path)
158
+ if expected_revision != actual_revision:
159
+ raise EditConflict("contract changed since it was opened; reload before committing")
160
+ code, _ = gitref.commit(relpaths, message=message, repo=repo)
161
+ if code != 0:
162
+ return {"argv": argv, "committed": False, "sha": None, "untouched": untouched}
163
+
164
+ sha = gitref.rev_parse("HEAD", repo=repo)
165
+ return {"argv": argv, "committed": True, "sha": sha, "untouched": untouched}
166
+
167
+
168
+ class UndoRefused(Exception):
169
+ """An undo that cannot be planned safely. The message names the reason."""
170
+
171
+
172
+ def _last_commit_touching(relpath: str, *, repo: Path) -> str | None:
173
+ code, out = gitref.run(["log", "-n", "1", "--format=%H", "--", relpath], repo=repo)
174
+ return out.strip() or None if code == 0 else None
175
+
176
+
177
+ def _files_in_commit(sha: str, *, repo: Path) -> list[str]:
178
+ code, out = gitref.run(["show", "--name-only", "--format=", sha], repo=repo)
179
+ return sorted(line for line in out.splitlines() if line.strip()) if code == 0 else []
180
+
181
+
182
+ def _is_dirty(relpath: str, *, repo: Path) -> bool:
183
+ """True when git reports any change (staged, unstaged or untracked) for this path.
184
+
185
+ Asked with a pathspec rather than by parsing the full listing, so a path with
186
+ a space or a quote in it is git's problem, not ours.
187
+ """
188
+ code, out = gitref.run(["status", "--porcelain", "--", relpath], repo=repo)
189
+ return code == 0 and bool(out.strip())
190
+
191
+
192
+ def _dirty_paths(repo: Path) -> list[str]:
193
+ """Every dirty path, for the refusal message only — never for a decision.
194
+
195
+ ponytail: porcelain v1 quotes odd names and writes renames as ``old -> new``;
196
+ this is a human sentence, so the raw field is good enough. Use ``_is_dirty``
197
+ wherever the answer changes what runs.
198
+ """
199
+ code, out = gitref.run(["status", "--porcelain"], repo=repo)
200
+ if code != 0:
201
+ return []
202
+ return sorted(line[3:] for line in out.splitlines() if line.strip())
203
+
204
+
205
+ def _resolve(path: Path | str) -> tuple[Path, Path | None, str | None]:
206
+ """(absolute path, repo root or None, repo-relative path or None)."""
207
+ target = Path(path).resolve()
208
+ repo = gitref.repo_root(target)
209
+ if repo is None:
210
+ return target, None, None
211
+ relpath = gitref.relpath_in_repo(target, repo)
212
+ if relpath is None:
213
+ raise UndoRefused(f"{target} is outside the repository at {repo} — refusing")
214
+ return target, repo, relpath
215
+
216
+
217
+ def undo_plan(contract: Path | str, *, source: str = "HEAD") -> dict:
218
+ """Plan the undo of the last write to ``contract``. Runs nothing.
219
+
220
+ Returns ``{"kind", "argv", "scope", "repo", "sentence"}`` where ``kind`` is:
221
+
222
+ ``restore`` the write is still uncommitted — put the file back to ``source``
223
+ (always ``--source=``, never the index). Touches one path.
224
+ ``revert`` the write is committed — offer ``git revert`` of that commit,
225
+ naming every other file the commit touches.
226
+ ``backup`` no repository: an existing sibling ``<path>.bak`` is the
227
+ only undo there is (nothing in this codebase writes one).
228
+ ``nothing`` no repository and no backup, or a file that does not exist at
229
+ ``source`` and was never committed.
230
+
231
+ Raises :class:`UndoRefused` when ``source`` is not a commit here, or when
232
+ ``contract`` lies outside the repository it was resolved against.
233
+ """
234
+ target, repo, relpath = _resolve(contract)
235
+
236
+ if repo is None:
237
+ backup = target.with_name(target.name + ".bak")
238
+ if backup.is_file():
239
+ return {
240
+ "kind": "backup",
241
+ "argv": [],
242
+ "scope": [str(target)],
243
+ "repo": "",
244
+ "sentence": (
245
+ f"Not a git repository. The last write left {backup.name} beside "
246
+ f"{target.name}; copying it back is the whole undo."
247
+ ),
248
+ }
249
+ return {
250
+ "kind": "nothing",
251
+ "argv": [],
252
+ "scope": [],
253
+ "repo": "",
254
+ "sentence": f"Not a git repository, and no {backup.name} — nothing to undo.",
255
+ }
256
+
257
+ if gitref.rev_parse(source, repo=repo) is None:
258
+ raise UndoRefused(f"{source!r} is not a commit in {repo} — refusing to undo")
259
+
260
+ if _is_dirty(relpath, repo=repo):
261
+ if gitref.show(source, relpath, repo=repo) is None:
262
+ return {
263
+ "kind": "nothing",
264
+ "argv": [],
265
+ "scope": [],
266
+ "repo": str(repo),
267
+ "sentence": f"{relpath} does not exist at {source} — nothing to restore it to.",
268
+ }
269
+ return {
270
+ "kind": "restore",
271
+ "argv": ["restore", f"--source={source}", "--", relpath],
272
+ "scope": [relpath],
273
+ "repo": str(repo),
274
+ "sentence": (
275
+ f"git restore --source={source} -- {relpath}\n"
276
+ f"Touches 1 file: {relpath}. Its current contents are replaced by "
277
+ f"{source}'s; nothing else in the working tree is read or written."
278
+ ),
279
+ }
280
+
281
+ sha = _last_commit_touching(relpath, repo=repo)
282
+ if sha is None:
283
+ return {
284
+ "kind": "nothing",
285
+ "argv": [],
286
+ "scope": [],
287
+ "repo": str(repo),
288
+ "sentence": f"{relpath} has no commit and no uncommitted change — nothing to undo.",
289
+ }
290
+ scope = _files_in_commit(sha, repo=repo)
291
+ scope = [relpath, *(path for path in scope if path != relpath)]
292
+ others = [p for p in scope if p != relpath]
293
+ tail = (
294
+ f" That commit also touches {len(others)}: " + ", ".join(others) + "."
295
+ if others
296
+ else " That commit touches nothing else."
297
+ )
298
+ return {
299
+ "kind": "revert",
300
+ "argv": ["revert", "--no-edit", sha],
301
+ "scope": scope,
302
+ "repo": str(repo),
303
+ "sentence": (
304
+ f"git revert --no-edit {sha[:12]}\n"
305
+ f"The write to {relpath} is committed, so undoing it is a new commit." + tail
306
+ ),
307
+ }
308
+
309
+
310
+ def apply_undo(
311
+ plan: dict,
312
+ *,
313
+ repo: Path | str | None = None,
314
+ contract: Path | str | None = None,
315
+ expected_revision: str | None = None,
316
+ ) -> dict:
317
+ """Run the plan that was shown. Returns the plan it ran.
318
+
319
+ ``restore`` runs its argv; ``backup`` copies the sibling
320
+ ``.bak`` back; ``nothing`` does nothing. A ``revert`` is **refused** while
321
+ any file in the working tree is dirty — a revert can touch several files and
322
+ must never bury someone's unsaved work in a merge.
323
+ """
324
+ planned = _planned_contract(plan, repo)
325
+ supplied = Path(contract).resolve() if contract is not None else None
326
+ if supplied is not None and planned is not None and supplied != planned:
327
+ raise UndoRefused(
328
+ f"the supplied contract {supplied} is not the contract targeted by this plan "
329
+ f"({planned}) — refusing"
330
+ )
331
+ target = supplied if supplied is not None else planned
332
+ if target is not None:
333
+ # Reject a revision already known to be stale before opening the sibling
334
+ # lock file. The comparison is repeated under the lock below: a writer
335
+ # can still change the contract between this no-touch fast path and lock
336
+ # acquisition.
337
+ if expected_revision is not None and expected_revision != contract_revision(target):
338
+ raise EditConflict(
339
+ "contract changed since it was opened; reload before applying this edit"
340
+ )
341
+ with _contract_lock(target):
342
+ actual = contract_revision(target)
343
+ if expected_revision is not None and expected_revision != actual:
344
+ raise EditConflict(
345
+ "contract changed since it was opened; reload before applying this edit"
346
+ )
347
+ return _apply_undo(plan, repo=repo, contract=target)
348
+ return _apply_undo(plan, repo=repo, contract=None)
349
+
350
+
351
+ def _planned_contract(plan: dict, repo: Path | str | None) -> Path | None:
352
+ """Find the canonical contract targeted by a plan generated in this module."""
353
+ if plan["kind"] == "backup":
354
+ return Path(plan["scope"][0]).resolve()
355
+ root = Path(repo) if repo is not None else (Path(plan["repo"]) if plan.get("repo") else None)
356
+ scope = plan.get("scope", [])
357
+ if root is not None and scope:
358
+ return (root.resolve() / scope[0]).resolve()
359
+ return None
360
+
361
+
362
+ def _apply_undo(
363
+ plan: dict, *, repo: Path | str | None = None, contract: Path | None = None
364
+ ) -> dict:
365
+ """Execute a plan after :func:`apply_undo` has locked its contract."""
366
+ kind = plan["kind"]
367
+ if kind == "nothing":
368
+ return plan
369
+ if kind == "backup":
370
+ target = Path(plan["scope"][0]).resolve()
371
+ backup = target.with_name(target.name + ".bak")
372
+ if not backup.is_file():
373
+ raise UndoRefused(f"{backup} is gone — refusing to undo")
374
+ fd, name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent, text=True)
375
+ try:
376
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
377
+ handle.write(backup.read_text(encoding="utf-8"))
378
+ os.replace(name, target)
379
+ finally:
380
+ Path(name).unlink(missing_ok=True)
381
+ return plan
382
+
383
+ root = Path(repo) if repo is not None else (Path(plan["repo"]) if plan.get("repo") else None)
384
+ root = root.resolve() if root is not None else None
385
+ if root is None or not root.is_dir():
386
+ raise UndoRefused("no git repository for this plan — refusing to run it")
387
+ if kind == "revert":
388
+ dirty = _dirty_paths(root)
389
+ if contract is not None:
390
+ lock_path = contract.with_name(f".{contract.name}.lock")
391
+ lock_relpath = gitref.relpath_in_repo(lock_path, root)
392
+ dirty = [path for path in dirty if path != lock_relpath]
393
+ if dirty:
394
+ raise UndoRefused(
395
+ "the working tree has uncommitted changes ("
396
+ + ", ".join(dirty)
397
+ + ") — commit or discard them before reverting; refusing"
398
+ )
399
+ code, _ = gitref.run(plan["argv"], repo=root)
400
+ if code != 0:
401
+ raise UndoRefused(f"git {' '.join(plan['argv'])} failed (exit {code}) — nothing changed")
402
+ return plan
lockwire/whatif.py ADDED
@@ -0,0 +1,161 @@
1
+ """Non-mutating comparisons of a contract and an edit proposal.
2
+
3
+ This is deliberately a contract-record check only. It has no robot observations,
4
+ health writes, or simulation-validity claim.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Literal
12
+
13
+ from lockwire.checks import run_record_checks
14
+ from lockwire.edit import Change, propose_changes
15
+ from lockwire.report import RecordReport
16
+ from lockwire.schema import Contract, load_contract
17
+
18
+
19
+ ProvenanceState = Literal["recorded", "missing_evidence"]
20
+ ResultKind = Literal["finding", "passed_comparison"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class FactResult:
25
+ """One contract-check result and only the provenance that check supplied.
26
+
27
+ A passed record comparison currently carries no provenance fields, so it is
28
+ explicitly marked ``missing_evidence`` rather than borrowing a source from
29
+ an unrelated fact.
30
+ """
31
+
32
+ quantity: str
33
+ kind: ResultKind
34
+ severity: str | None
35
+ source: str | None
36
+ witness: str | None
37
+ provenance_state: ProvenanceState
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ContractCheckResult:
42
+ """The existing record-check report plus per-result provenance states."""
43
+
44
+ report: RecordReport
45
+ facts: tuple[FactResult, ...]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class RecordedHealthResult:
50
+ """Historical health rows and the subset invalidated by a proposal.
51
+
52
+ ``baseline`` is only pre-existing ledger evidence. ``invalidated`` names
53
+ claims that stop supporting the proposed specification; neither field is
54
+ derived from the fresh current/proposed record checks.
55
+ """
56
+
57
+ baseline: tuple[Any, ...]
58
+ invalidated: tuple[Any, ...]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class WhatIfResult:
63
+ """Clearly separate current and proposed contract-check results."""
64
+
65
+ current: ContractCheckResult
66
+ proposed: ContractCheckResult
67
+ recorded_health: RecordedHealthResult
68
+ diff: str
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class _Bytes:
73
+ exists: bool
74
+ value: bytes | None
75
+
76
+
77
+ def _bytes(path: Path) -> _Bytes:
78
+ return _Bytes(path.exists(), path.read_bytes() if path.exists() else None)
79
+
80
+
81
+ def _assert_unchanged(path: Path, before: _Bytes, *, name: str) -> None:
82
+ if _bytes(path) != before:
83
+ raise RuntimeError(f"what-if changed the original {name}")
84
+
85
+
86
+ def _facts(report: RecordReport) -> tuple[FactResult, ...]:
87
+ facts: list[FactResult] = []
88
+ for finding in report:
89
+ recorded = finding.source is not None and finding.witness is not None
90
+ facts.append(
91
+ FactResult(
92
+ quantity=finding.quantity,
93
+ kind="finding",
94
+ severity=finding.severity,
95
+ source=finding.source,
96
+ witness=finding.witness,
97
+ provenance_state="recorded" if recorded else "missing_evidence",
98
+ )
99
+ )
100
+ for record in report.scored_records:
101
+ facts.append(
102
+ FactResult(
103
+ quantity=record.quantity,
104
+ kind="passed_comparison",
105
+ severity=None,
106
+ source=None,
107
+ witness=None,
108
+ provenance_state="missing_evidence",
109
+ )
110
+ )
111
+ return tuple(facts)
112
+
113
+
114
+ def _check(contract, contract_path: Path) -> ContractCheckResult:
115
+ report = run_record_checks(contract, contract_path=contract_path)
116
+ return ContractCheckResult(report=report, facts=_facts(report))
117
+
118
+
119
+ def what_if(
120
+ contract_path: Path | str,
121
+ changes: list[Change],
122
+ recorded_health: Callable[[Path, Contract, Contract], tuple[Any, Any]] | None = None,
123
+ watch: tuple[tuple[str, Path], ...] = (),
124
+ ) -> WhatIfResult:
125
+ """Compare current contract checks with a validated edit proposal.
126
+
127
+ The edit primitive produces the proposal in memory; record checks still
128
+ receive the original contract path so relative assets keep their real
129
+ resolution root. The original contract and every ``watch`` file (name, path)
130
+ are byte-checked in ``finally``, including refusal and interruption paths.
131
+
132
+ ``recorded_health(original, current, proposed)`` is the optional provider of
133
+ the ledger rows and the ones the proposal invalidates; without it that part
134
+ is empty, as it is for a contract with no health ledger.
135
+ """
136
+
137
+ original = Path(contract_path).resolve()
138
+ original_before = _bytes(original)
139
+ watched = [(name, path, _bytes(path)) for name, path in watch]
140
+ try:
141
+ proposal = propose_changes(original, changes)
142
+ current_contract = load_contract(original)
143
+ current = _check(current_contract, original)
144
+ proposed = _check(proposal.contract, original)
145
+ baseline, invalidated = (
146
+ recorded_health(original, current_contract, proposal.contract)
147
+ if recorded_health is not None
148
+ else ((), ())
149
+ )
150
+ return WhatIfResult(
151
+ current=current,
152
+ proposed=proposed,
153
+ recorded_health=RecordedHealthResult(
154
+ baseline=tuple(baseline), invalidated=tuple(invalidated)
155
+ ),
156
+ diff=proposal.result.diff,
157
+ )
158
+ finally:
159
+ _assert_unchanged(original, original_before, name="contract")
160
+ for name, path, before in watched:
161
+ _assert_unchanged(path, before, name=name)