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.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- 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)
|