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/report_html.py
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"""Deterministic, self-contained HTML views for lockwire contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from html import escape
|
|
10
|
+
from pathlib import Path, PureWindowsPath
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from lockwire import __version__, gitref
|
|
14
|
+
from lockwire.checks import power_rollup, run_record_checks, unknown_draw_message
|
|
15
|
+
from lockwire.report import (
|
|
16
|
+
CGReport,
|
|
17
|
+
Finding,
|
|
18
|
+
GRAPH_LAYOUT_JS,
|
|
19
|
+
RecordReport,
|
|
20
|
+
_SEVERITY_RANK,
|
|
21
|
+
_cg_coverage,
|
|
22
|
+
_cluster_members,
|
|
23
|
+
_cluster_root_finding,
|
|
24
|
+
_presentation_groups,
|
|
25
|
+
limit_raised_mark,
|
|
26
|
+
verdict,
|
|
27
|
+
)
|
|
28
|
+
from lockwire.schema import Contract
|
|
29
|
+
from lockwire.units import convert
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _redact_witness(value: str | None) -> str | None:
|
|
33
|
+
"""A device owner or a declare/lock witness — a person's or supplier's
|
|
34
|
+
name. Dropped outright, not shortened: there is no partial form of a name
|
|
35
|
+
that stays useful and stops identifying someone (--redact-paths)."""
|
|
36
|
+
return None if value else value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _redact_path(value: str | None) -> str | None:
|
|
40
|
+
"""Shorten an absolute filesystem path to its basename; anything else (a
|
|
41
|
+
URL, a prose citation, a relative path) is returned unchanged
|
|
42
|
+
(--redact-paths)."""
|
|
43
|
+
if value and Path(value).is_absolute():
|
|
44
|
+
return Path(value).name
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --redact-paths' per-field calls above (source/owner/witness) only
|
|
49
|
+
# catch the fields their author thought of; a five-minute adversarial pass
|
|
50
|
+
# found two more (cli_line, and the free-text observed/expected/action
|
|
51
|
+
# fields) sitting one hop away. Rather than grow that allowlist field by
|
|
52
|
+
# field forever, this sweeps every string in the ASSEMBLED payload dict —
|
|
53
|
+
# the single point where the report becomes JSON — for an embedded absolute
|
|
54
|
+
# path, so a field added to the payload later is covered by construction
|
|
55
|
+
# instead of by someone remembering to redact it. A whole-string match (the
|
|
56
|
+
# per-field helpers above) can't do this: a "source" field IS a path, but
|
|
57
|
+
# "observed"/"action" are prose with a path embedded in a sentence.
|
|
58
|
+
# POSIX absolute (/...), Windows drive-letter (C:\...), and UNC (\\server\...)
|
|
59
|
+
# forms. The POSIX-only pattern passed on a Mac and leaked a
|
|
60
|
+
# username through an unmatched `C:\Users\alice\...` — the same lesson
|
|
61
|
+
# this code illustrates (a matcher only covers the shapes its author pictured)
|
|
62
|
+
# recurring on its own author. Checked by feeding each shape through this
|
|
63
|
+
# function, not by inspection (an inspected-only ceiling claimed
|
|
64
|
+
# `\\?\` uncovered when it wasn't, which is worse than no claim at all on a
|
|
65
|
+
# security surface). Still NOT covered, confirmed leaking: a drive-relative
|
|
66
|
+
# Windows path with no drive letter (`\Users\alice\...`), and a path with
|
|
67
|
+
# mixed `/`/`\` separators (e.g. `C:/Users\alice\...`, copy-pasted from a
|
|
68
|
+
# mixed-shell log). A `\\?\` extended-length prefix IS redacted, but only
|
|
69
|
+
# INCIDENTALLY — the broad UNC alternative (`\\\\...`) happens to swallow it
|
|
70
|
+
# whole and `PureWindowsPath` still resolves its basename; nothing here
|
|
71
|
+
# targets that shape deliberately, so a future narrowing of the UNC pattern
|
|
72
|
+
# (to be less broad, say) could silently drop this coverage and no test
|
|
73
|
+
# would notice.
|
|
74
|
+
_ABS_PATH_IN_TEXT_RE = re.compile(r"/[^\s\"'<>]+" r"|[A-Za-z]:\\[^\s\"'<>]+" r"|\\\\[^\s\"'<>]+")
|
|
75
|
+
_ABS_PATH_TRAILING_PUNCTUATION = ".,;:!?)]}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _redact_paths_in_text(value: str) -> str:
|
|
79
|
+
def _sub(match: re.Match[str]) -> str:
|
|
80
|
+
token = match.group(0)
|
|
81
|
+
trailing = ""
|
|
82
|
+
while token and token[-1] in _ABS_PATH_TRAILING_PUNCTUATION:
|
|
83
|
+
trailing = token[-1] + trailing
|
|
84
|
+
token = token[:-1]
|
|
85
|
+
if not token:
|
|
86
|
+
return match.group(0)
|
|
87
|
+
if "\\" in token:
|
|
88
|
+
# Windows drive-letter or UNC form. `Path` on this (POSIX) host
|
|
89
|
+
# never treats `\` as a separator, so a plain `Path(token).name`
|
|
90
|
+
# would return the whole token unshortened, silently failing to
|
|
91
|
+
# redact — use `PureWindowsPath` explicitly regardless of host OS.
|
|
92
|
+
wpath = PureWindowsPath(token)
|
|
93
|
+
if not wpath.is_absolute():
|
|
94
|
+
return match.group(0)
|
|
95
|
+
return wpath.name + trailing
|
|
96
|
+
path = Path(token)
|
|
97
|
+
if not path.is_absolute():
|
|
98
|
+
return match.group(0)
|
|
99
|
+
return path.name + trailing
|
|
100
|
+
|
|
101
|
+
return _ABS_PATH_IN_TEXT_RE.sub(_sub, value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _redact_paths_in_payload(value: Any) -> Any:
|
|
105
|
+
"""Recurse through the payload's dicts/lists and scrub every string.
|
|
106
|
+
|
|
107
|
+
Applied once, at the end of :func:`_payload`, so it covers the whole
|
|
108
|
+
document — not the fields ``--redact-paths`` currently names.
|
|
109
|
+
|
|
110
|
+
Ceiling: this over-redacts. Any string that merely *looks*
|
|
111
|
+
like an absolute path is rewritten, including a deliberately-named id
|
|
112
|
+
such as ``/foo/bar`` that carries no personal information at all. That
|
|
113
|
+
trade is accepted on purpose — a --redact-paths report is handed to
|
|
114
|
+
someone outside, and a stray shortened id is a cosmetic cost while a
|
|
115
|
+
surviving real path is a leak — but if a real id shape starts colliding
|
|
116
|
+
with this in practice, the fix is to scope the sweep back to known
|
|
117
|
+
path-shaped fields (or a stricter path regex) rather than widen the
|
|
118
|
+
allowlist forever.
|
|
119
|
+
|
|
120
|
+
No recursion depth bound: a contract-derived payload (robot → devices →
|
|
121
|
+
findings, a few levels deep, no cycles) is nowhere near stack limits, so
|
|
122
|
+
this is fine for the shapes this function actually receives; it is not
|
|
123
|
+
guarded against a self-referential structure.
|
|
124
|
+
"""
|
|
125
|
+
if isinstance(value, str):
|
|
126
|
+
return _redact_paths_in_text(value)
|
|
127
|
+
if isinstance(value, dict):
|
|
128
|
+
return {key: _redact_paths_in_payload(item) for key, item in value.items()}
|
|
129
|
+
if isinstance(value, list):
|
|
130
|
+
return [_redact_paths_in_payload(item) for item in value]
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _budget_rows(
|
|
135
|
+
contract: Contract, findings: list[Finding], *, redact_paths: bool = False
|
|
136
|
+
) -> list[dict[str, Any]]:
|
|
137
|
+
failed = {
|
|
138
|
+
finding.quantity.removeprefix("budgets/").split("/", 1)[0]
|
|
139
|
+
for finding in findings
|
|
140
|
+
if finding.severity == "violation" and finding.quantity.startswith("budgets/")
|
|
141
|
+
}
|
|
142
|
+
rows: list[dict[str, Any]] = []
|
|
143
|
+
for budget in contract.budgets:
|
|
144
|
+
value: float | None
|
|
145
|
+
unit: str
|
|
146
|
+
direction = "max"
|
|
147
|
+
note: str | None = None
|
|
148
|
+
# Each budget selects its own draw mode (default "nominal"); roll power
|
|
149
|
+
# through the same draw-mode-aware gate function the check uses, not a
|
|
150
|
+
# nominal-only copy, or a named-mode budget shows the wrong rail total.
|
|
151
|
+
(
|
|
152
|
+
rail_totals,
|
|
153
|
+
rail_witnesses,
|
|
154
|
+
battery_w,
|
|
155
|
+
_battery_witnesses,
|
|
156
|
+
rail_unknown,
|
|
157
|
+
battery_unknown,
|
|
158
|
+
) = power_rollup(contract, budget.draw_mode)
|
|
159
|
+
if budget.kind == "rail_power":
|
|
160
|
+
value, unit = rail_totals.get(budget.rail or "", 0.0), "W"
|
|
161
|
+
# A rail with zero contributing witnesses was never measured — 0 W is
|
|
162
|
+
# a vacuous "pass", not a healthy reading.
|
|
163
|
+
if not rail_witnesses.get(budget.rail or "", set()):
|
|
164
|
+
value = None
|
|
165
|
+
note = "no draw declared on this rail"
|
|
166
|
+
unknown_consumers = rail_unknown.get(budget.rail or "", set())
|
|
167
|
+
if unknown_consumers:
|
|
168
|
+
# any consumer of unknown draw makes this rail unsafe to sum.
|
|
169
|
+
value = None
|
|
170
|
+
note = unknown_draw_message(
|
|
171
|
+
sorted(unknown_consumers)[0],
|
|
172
|
+
contract.device(sorted(unknown_consumers)[0]).power.rail,
|
|
173
|
+
)
|
|
174
|
+
elif budget.kind == "mass_total":
|
|
175
|
+
value, unit = 0.0, "kg"
|
|
176
|
+
if budget.sources in ("devices", "links+devices"):
|
|
177
|
+
value += sum(
|
|
178
|
+
device.physical.mass.base()
|
|
179
|
+
for device in contract.devices
|
|
180
|
+
if device.physical is not None and device.physical.mass is not None
|
|
181
|
+
)
|
|
182
|
+
if budget.sources in ("links", "links+devices") and contract.model is not None:
|
|
183
|
+
value += sum(
|
|
184
|
+
link.mass.base() for link in contract.model.links if link.mass is not None
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
unit, direction = "min", "min"
|
|
188
|
+
battery = contract.topology.battery if contract.topology is not None else None
|
|
189
|
+
value = battery.capacity.base() / battery_w * 60.0 if battery and battery_w else None
|
|
190
|
+
# Zero declared draw with a battery present is unmeasured, not a
|
|
191
|
+
# missing-battery violation — same vacuous-pass shape as the empty
|
|
192
|
+
# rail budget above. A missing battery
|
|
193
|
+
# is its own violation and stays a "breach" row, not this note.
|
|
194
|
+
if battery is not None and not battery_w:
|
|
195
|
+
note = "no draw declared"
|
|
196
|
+
if battery is not None and battery_unknown:
|
|
197
|
+
# an unknown consumer reaching the battery makes endurance
|
|
198
|
+
# unsafe to compute — never sum it as 0 W.
|
|
199
|
+
value = None
|
|
200
|
+
device_id = sorted(battery_unknown)[0]
|
|
201
|
+
note = unknown_draw_message(device_id, contract.device(device_id).power.rail)
|
|
202
|
+
limit = convert(budget.limit.value, budget.limit.unit, unit)
|
|
203
|
+
if note is not None:
|
|
204
|
+
state = "not_scored"
|
|
205
|
+
elif budget.id in failed:
|
|
206
|
+
state = "breach"
|
|
207
|
+
else:
|
|
208
|
+
state = "pass"
|
|
209
|
+
rows.append(
|
|
210
|
+
{
|
|
211
|
+
"id": budget.id,
|
|
212
|
+
"kind": budget.kind,
|
|
213
|
+
"value": value,
|
|
214
|
+
"limit": limit,
|
|
215
|
+
"unit": unit,
|
|
216
|
+
"direction": direction,
|
|
217
|
+
"state": state,
|
|
218
|
+
"note": note,
|
|
219
|
+
# A limit without its provenance is an assertion, not a locked
|
|
220
|
+
# constraint. The same three fields the findings already
|
|
221
|
+
# carry, read off the same `budget.provenance` the checks read.
|
|
222
|
+
"source": budget.provenance.source if budget.provenance is not None else None,
|
|
223
|
+
"witness": budget.provenance.witness if budget.provenance is not None else None,
|
|
224
|
+
"locked_at": (
|
|
225
|
+
budget.provenance.locked_at.isoformat()
|
|
226
|
+
if budget.provenance is not None and budget.provenance.locked_at is not None
|
|
227
|
+
else None
|
|
228
|
+
),
|
|
229
|
+
# the standing mark when this limit was raised to clear a
|
|
230
|
+
# violation. Rendered, never scored — no verdict or exit code moves.
|
|
231
|
+
# rendered regardless of `state` — the mark must survive a
|
|
232
|
+
# later re-breach (see report.limit_raised_mark's docstring).
|
|
233
|
+
"limit_raised": limit_raised_mark(budget.provenance),
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
if redact_paths:
|
|
237
|
+
last = rows[-1]
|
|
238
|
+
last["source"] = _redact_path(last["source"])
|
|
239
|
+
last["witness"] = _redact_witness(last["witness"])
|
|
240
|
+
return rows
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _cg_row(report: CGReport) -> dict[str, Any]:
|
|
244
|
+
if report.cg_m is None:
|
|
245
|
+
result = "not computable"
|
|
246
|
+
else:
|
|
247
|
+
vector = "(" + ", ".join(f"{value:.3f}" for value in report.cg_m) + ") m"
|
|
248
|
+
result = vector if report.complete else f"partial {vector}"
|
|
249
|
+
return {
|
|
250
|
+
"quantity": report.quantity,
|
|
251
|
+
"result": result,
|
|
252
|
+
"frame": report.frame or "unresolved root frame",
|
|
253
|
+
"sources": report.sources,
|
|
254
|
+
# Coverage against the DECLARED total, not against the model itself — the
|
|
255
|
+
# same signal the terminal prints. See report._cg_coverage.
|
|
256
|
+
"mass": _cg_coverage(report),
|
|
257
|
+
"limitation": report.limitation,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
#: The only place a lens's title/hint is authored. The GUI and
|
|
262
|
+
#: report JS read `graph[lens].label`/`.caption` off the payload; neither
|
|
263
|
+
#: composes a lens word itself.
|
|
264
|
+
_LENS_META: dict[str, dict[str, str]] = {
|
|
265
|
+
"off": {"label": "off", "caption": "Drag to orbit, scroll to zoom"},
|
|
266
|
+
"mechanical": {
|
|
267
|
+
"label": "Mechanical",
|
|
268
|
+
"caption": "What's bolted to what, and how heavy it is",
|
|
269
|
+
},
|
|
270
|
+
"power": {
|
|
271
|
+
"label": "Power",
|
|
272
|
+
"caption": "What feeds what, and how many watts down each wire",
|
|
273
|
+
},
|
|
274
|
+
"data": {
|
|
275
|
+
"label": "Data",
|
|
276
|
+
"caption": "Which computer talks to which part, over which cable",
|
|
277
|
+
},
|
|
278
|
+
"health": {
|
|
279
|
+
"label": "Health",
|
|
280
|
+
"caption": "Which parts the health ledger logs healthy, degrading, or stale",
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _graph(contract: Contract) -> dict[str, dict[str, Any]]:
|
|
286
|
+
topology = contract.topology
|
|
287
|
+
links = contract.model.links if contract.model is not None else []
|
|
288
|
+
joints = contract.model.joints if contract.model is not None else []
|
|
289
|
+
|
|
290
|
+
mechanical_nodes = [
|
|
291
|
+
{
|
|
292
|
+
"id": link.name,
|
|
293
|
+
"label": link.name,
|
|
294
|
+
"sub": f"{link.mass.base():.3f} kg" if link.mass is not None else "mass unknown",
|
|
295
|
+
"kind": "link",
|
|
296
|
+
}
|
|
297
|
+
for link in links
|
|
298
|
+
]
|
|
299
|
+
mechanical_nodes += [
|
|
300
|
+
{
|
|
301
|
+
"id": device.id,
|
|
302
|
+
"label": device.id,
|
|
303
|
+
"sub": (
|
|
304
|
+
f"{device.physical.mass.base():.3f} kg"
|
|
305
|
+
if device.physical is not None and device.physical.mass is not None
|
|
306
|
+
else "mass unknown"
|
|
307
|
+
),
|
|
308
|
+
"kind": "device",
|
|
309
|
+
}
|
|
310
|
+
for device in contract.devices
|
|
311
|
+
]
|
|
312
|
+
mechanical_edges = [
|
|
313
|
+
{"from": joint.parent, "to": joint.child, "label": joint.name} for joint in joints
|
|
314
|
+
]
|
|
315
|
+
mechanical_edges += [
|
|
316
|
+
{"from": device.physical.mount_frame, "to": device.id, "label": "mount"}
|
|
317
|
+
for device in contract.devices
|
|
318
|
+
if device.physical is not None and device.physical.mount_frame is not None
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
rails = topology.rails if topology is not None else []
|
|
322
|
+
|
|
323
|
+
def _rail_sub(rail: object) -> str:
|
|
324
|
+
sub = (
|
|
325
|
+
f"{rail.voltage.base():g} V · cap {rail.capacity.base():.2f} W"
|
|
326
|
+
if rail.voltage is not None
|
|
327
|
+
else f"voltage unknown · cap {rail.capacity.base():.2f} W"
|
|
328
|
+
)
|
|
329
|
+
if rail.voltage_range is not None:
|
|
330
|
+
sub += f" · range {rail.voltage_range.min.base():g}-{rail.voltage_range.max.base():g} V"
|
|
331
|
+
if rail.current_limit is not None:
|
|
332
|
+
sub += f" · limit {rail.current_limit.base():g} A"
|
|
333
|
+
return sub
|
|
334
|
+
|
|
335
|
+
power_nodes = [
|
|
336
|
+
{
|
|
337
|
+
"id": rail.id,
|
|
338
|
+
"label": rail.id,
|
|
339
|
+
"sub": _rail_sub(rail),
|
|
340
|
+
"kind": "rail",
|
|
341
|
+
}
|
|
342
|
+
for rail in rails
|
|
343
|
+
]
|
|
344
|
+
if topology is not None and topology.battery is not None:
|
|
345
|
+
power_nodes.insert(
|
|
346
|
+
0,
|
|
347
|
+
{
|
|
348
|
+
"id": "battery",
|
|
349
|
+
"label": "battery",
|
|
350
|
+
"sub": f"{topology.battery.capacity.base():.2f} Wh",
|
|
351
|
+
"kind": "source",
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
power_nodes += [
|
|
355
|
+
{
|
|
356
|
+
"id": device.id,
|
|
357
|
+
"label": device.id,
|
|
358
|
+
"sub": (
|
|
359
|
+
f"{device.power.draw.base():.2f} W"
|
|
360
|
+
if device.power.draw is not None
|
|
361
|
+
else "draw unknown"
|
|
362
|
+
),
|
|
363
|
+
"kind": "device",
|
|
364
|
+
}
|
|
365
|
+
for device in contract.devices
|
|
366
|
+
if device.power is not None
|
|
367
|
+
]
|
|
368
|
+
power_edges = [
|
|
369
|
+
{"from": rail.upstream, "to": rail.id, "label": f"η {rail.efficiency:.2f}"}
|
|
370
|
+
for rail in rails
|
|
371
|
+
if rail.upstream is not None
|
|
372
|
+
]
|
|
373
|
+
power_edges += [
|
|
374
|
+
{
|
|
375
|
+
"from": device.power.rail,
|
|
376
|
+
"to": device.id,
|
|
377
|
+
"label": (
|
|
378
|
+
f"{device.power.draw.base():.2f} W"
|
|
379
|
+
if device.power.draw is not None
|
|
380
|
+
else "draw unknown"
|
|
381
|
+
),
|
|
382
|
+
}
|
|
383
|
+
for device in contract.devices
|
|
384
|
+
if device.power is not None
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
hosts = topology.hosts if topology is not None else []
|
|
388
|
+
buses = topology.buses if topology is not None else []
|
|
389
|
+
data_nodes = [
|
|
390
|
+
{"id": host.id, "label": host.id, "sub": host.notes or "host", "kind": "host"}
|
|
391
|
+
for host in hosts
|
|
392
|
+
]
|
|
393
|
+
data_nodes += [
|
|
394
|
+
{
|
|
395
|
+
"id": bus.id,
|
|
396
|
+
"label": bus.id,
|
|
397
|
+
"sub": f"{bus.kind} · {bus.bitrate:g} bit/s" if bus.bitrate else bus.kind,
|
|
398
|
+
"kind": "bus",
|
|
399
|
+
}
|
|
400
|
+
for bus in buses
|
|
401
|
+
]
|
|
402
|
+
data_nodes += [
|
|
403
|
+
{
|
|
404
|
+
"id": device.id,
|
|
405
|
+
"label": device.id,
|
|
406
|
+
"sub": (
|
|
407
|
+
device.driver.topic
|
|
408
|
+
if device.driver is not None and device.driver.topic is not None
|
|
409
|
+
else "device"
|
|
410
|
+
),
|
|
411
|
+
"kind": "device",
|
|
412
|
+
}
|
|
413
|
+
for device in contract.devices
|
|
414
|
+
if device.identity is not None
|
|
415
|
+
]
|
|
416
|
+
data_edges = [{"from": bus.host, "to": bus.id, "label": bus.kind} for bus in buses]
|
|
417
|
+
data_edges += [
|
|
418
|
+
{"from": device.identity.bus_ref, "to": device.id, "label": "identity"}
|
|
419
|
+
for device in contract.devices
|
|
420
|
+
if device.identity is not None
|
|
421
|
+
]
|
|
422
|
+
return {
|
|
423
|
+
"off": {"nodes": [], "edges": [], **_LENS_META["off"]},
|
|
424
|
+
"mechanical": {
|
|
425
|
+
"nodes": mechanical_nodes,
|
|
426
|
+
"edges": mechanical_edges,
|
|
427
|
+
**_LENS_META["mechanical"],
|
|
428
|
+
},
|
|
429
|
+
"power": {"nodes": power_nodes, "edges": power_edges, **_LENS_META["power"]},
|
|
430
|
+
"data": {"nodes": data_nodes, "edges": data_edges, **_LENS_META["data"]},
|
|
431
|
+
"health": {"nodes": [], "edges": [], **_LENS_META["health"]},
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _finding_rows(findings: list[Finding], *, redact_paths: bool = False) -> list[dict[str, Any]]:
|
|
436
|
+
"""Return deterministic presentation rows shared by both HTML views."""
|
|
437
|
+
rendered_findings: list[dict[str, Any]] = []
|
|
438
|
+
for group in _presentation_groups(findings):
|
|
439
|
+
row = group[0].model_dump(mode="json")
|
|
440
|
+
members = _cluster_members(group)
|
|
441
|
+
if group[0].cluster_root is not None and len(members) > 1:
|
|
442
|
+
root_finding = _cluster_root_finding(group)
|
|
443
|
+
row["source"] = root_finding.source if root_finding is not None else None
|
|
444
|
+
row["witness"] = root_finding.witness if root_finding is not None else None
|
|
445
|
+
row["locked_at"] = (
|
|
446
|
+
root_finding.locked_at.isoformat()
|
|
447
|
+
if root_finding is not None and root_finding.locked_at is not None
|
|
448
|
+
else None
|
|
449
|
+
)
|
|
450
|
+
row["cluster_members"] = members
|
|
451
|
+
row["member_findings"] = [finding.model_dump(mode="json") for finding in group]
|
|
452
|
+
if redact_paths:
|
|
453
|
+
row["source"] = _redact_path(row["source"])
|
|
454
|
+
row["owner"] = _redact_witness(row["owner"])
|
|
455
|
+
row["witness"] = _redact_witness(row["witness"])
|
|
456
|
+
for member in row.get("member_findings", []):
|
|
457
|
+
member["source"] = _redact_path(member["source"])
|
|
458
|
+
member["owner"] = _redact_witness(member["owner"])
|
|
459
|
+
member["witness"] = _redact_witness(member["witness"])
|
|
460
|
+
rendered_findings.append(row)
|
|
461
|
+
return rendered_findings
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
#: Plain-word verdict badges this page has always shown: NO-GO and NOT SCORED
|
|
465
|
+
#: are distinguished, GO/GO-with-notes render identically here.
|
|
466
|
+
_HTML_VERDICT_TEXT = {
|
|
467
|
+
"REFUSED": "NO-GO",
|
|
468
|
+
"NO_GO": "NO-GO",
|
|
469
|
+
"GO_WITH_NOTES": "GO",
|
|
470
|
+
"NOT_SCORED": "NOT SCORED",
|
|
471
|
+
"GO": "GO",
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _payload(
|
|
476
|
+
contract: Contract,
|
|
477
|
+
report: RecordReport,
|
|
478
|
+
*,
|
|
479
|
+
redact_paths: bool = False,
|
|
480
|
+
cli_line: str | None = None,
|
|
481
|
+
) -> dict[str, Any]:
|
|
482
|
+
findings = sorted(
|
|
483
|
+
report, key=lambda finding: (_SEVERITY_RANK[finding.severity], finding.quantity)
|
|
484
|
+
)
|
|
485
|
+
decision = verdict(report)
|
|
486
|
+
outline: list[dict[str, str]] = []
|
|
487
|
+
if contract.topology is not None:
|
|
488
|
+
outline.extend({"id": rail.id, "type": "rail"} for rail in contract.topology.rails)
|
|
489
|
+
outline.extend({"id": bus.id, "type": "bus"} for bus in contract.topology.buses)
|
|
490
|
+
outline.extend({"id": device.id, "type": "device"} for device in contract.devices)
|
|
491
|
+
payload = {
|
|
492
|
+
"robot": contract.robot.name,
|
|
493
|
+
"contract_id": contract.robot.id or "contract",
|
|
494
|
+
"verdict": _HTML_VERDICT_TEXT[decision.code],
|
|
495
|
+
"violations": decision.violations,
|
|
496
|
+
"warnings": decision.warnings,
|
|
497
|
+
# the page's summary line was a pair (to fix · notes), which
|
|
498
|
+
# leaves "not checked" — the count that makes a pass conditional —
|
|
499
|
+
# invisible. Three named parts, counted off the same `decision` the
|
|
500
|
+
# verdict itself comes from; the verdict word is untouched.
|
|
501
|
+
"headline": {
|
|
502
|
+
"adds_up": len(report.scored_records),
|
|
503
|
+
"does_not_add_up": decision.violations,
|
|
504
|
+
"not_checked": decision.not_scored,
|
|
505
|
+
},
|
|
506
|
+
"outline": outline,
|
|
507
|
+
"budgets": _budget_rows(contract, findings, redact_paths=redact_paths),
|
|
508
|
+
"findings": _finding_rows(findings, redact_paths=redact_paths),
|
|
509
|
+
"adds_up": [record.model_dump(mode="json") for record in report.scored_records],
|
|
510
|
+
"information": [_cg_row(item) for item in report.information],
|
|
511
|
+
"graph": _graph(contract),
|
|
512
|
+
# The exact command line the page was made with, so a reader can tell
|
|
513
|
+
# a redacted page from a full one.
|
|
514
|
+
"cli_line": cli_line,
|
|
515
|
+
}
|
|
516
|
+
if redact_paths:
|
|
517
|
+
# boundary sweep — catches cli_line and the free-text
|
|
518
|
+
# observed/expected/action fields the per-field calls above miss.
|
|
519
|
+
# See _redact_paths_in_payload.
|
|
520
|
+
payload = _redact_paths_in_payload(payload)
|
|
521
|
+
return payload
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _report_identity(contract: Contract) -> str:
|
|
525
|
+
"""The report's own identity: which commit produced this document,
|
|
526
|
+
or — outside a repository — a content hash of the contract, and the
|
|
527
|
+
document says which. `render_html`/`render_preflight_html` are documented
|
|
528
|
+
byte-stable, so this stays a pure function of the contract and the
|
|
529
|
+
repository's current state, never the wall clock.
|
|
530
|
+
|
|
531
|
+
Reuses `gitref`'s one door to git rather than a second subprocess
|
|
532
|
+
call; the repo it asks about is wherever the process runs, which for a
|
|
533
|
+
`lockwire report`/`lockwire preflight` invocation is the caller's own
|
|
534
|
+
contract repo.
|
|
535
|
+
"""
|
|
536
|
+
repo = gitref.repo_root(Path.cwd())
|
|
537
|
+
if repo is not None:
|
|
538
|
+
sha = gitref.rev_parse("HEAD", repo=repo)
|
|
539
|
+
if sha is not None:
|
|
540
|
+
dirty = bool(gitref.status_porcelain(repo).strip())
|
|
541
|
+
return f"commit {sha[:12]}" + ("-dirty" if dirty else "")
|
|
542
|
+
digest = hashlib.sha256(contract.model_dump_json(exclude_none=True).encode()).hexdigest()[:12]
|
|
543
|
+
return f"content hash {digest} (no repository)"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _safe_json(value: Any) -> str:
|
|
547
|
+
"""Embed JSON in a script without permitting an HTML end-tag breakout."""
|
|
548
|
+
return (
|
|
549
|
+
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
550
|
+
.replace("&", "\\u0026")
|
|
551
|
+
.replace("<", "\\u003c")
|
|
552
|
+
.replace(">", "\\u003e")
|
|
553
|
+
.replace("\u2028", "\\u2028")
|
|
554
|
+
.replace("\u2029", "\\u2029")
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _now_utc() -> str:
|
|
559
|
+
"""The instant "now", in the footer's format. A seam, not a cache: a
|
|
560
|
+
long-lived caller (`lockwire serve`, which dispatches `report`/`gui`
|
|
561
|
+
per-request for the process's whole life — `serve.py`) must get a fresh
|
|
562
|
+
stamp on every document, not the process's start time (caching it at
|
|
563
|
+
import would make every document under `serve` lie about when it was
|
|
564
|
+
generated). Tests pin it with `monkeypatch.setattr` instead of
|
|
565
|
+
relying on two calls landing in the same wall-clock second.
|
|
566
|
+
"""
|
|
567
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def document_identity(contract_path: Path | None) -> str:
|
|
571
|
+
"""``contract path · commit/hash · lockwire <version> · generated <UTC>``.
|
|
572
|
+
|
|
573
|
+
Git-native, the way git/GitHub already identify things: inside a repo the
|
|
574
|
+
full commit sha plus ``git describe`` (through :mod:`lockwire.gitref`, the
|
|
575
|
+
one door); outside a repo, or with ``git`` unavailable, a labelled
|
|
576
|
+
sha256 content hash of the contract file so the reader never has to guess
|
|
577
|
+
which kind of identity they are looking at. Display-only — no contract
|
|
578
|
+
field, no ``schema.py`` change.
|
|
579
|
+
|
|
580
|
+
The "generated" stamp is captured once per call, so a document
|
|
581
|
+
rendered now says now — under a long-lived process (`lockwire serve`) two
|
|
582
|
+
documents rendered a second apart carry different stamps, as they must.
|
|
583
|
+
"""
|
|
584
|
+
path_label = str(contract_path) if contract_path is not None else "(no contract path)"
|
|
585
|
+
return (
|
|
586
|
+
f"{path_label} · {_repo_or_hash_identity(contract_path)} · "
|
|
587
|
+
f"lockwire {__version__} · generated {_now_utc()}"
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _repo_or_hash_identity(contract_path: Path | None) -> str:
|
|
592
|
+
start = contract_path if contract_path is not None else Path.cwd()
|
|
593
|
+
repo = gitref.repo_root(start)
|
|
594
|
+
if repo is not None:
|
|
595
|
+
sha = gitref.rev_parse("HEAD", repo=repo)
|
|
596
|
+
if sha is not None:
|
|
597
|
+
described = gitref.describe(repo=repo)
|
|
598
|
+
return f"commit {sha[:12]} ({described})" if described else f"commit {sha[:12]}"
|
|
599
|
+
if contract_path is not None and contract_path.exists():
|
|
600
|
+
digest = hashlib.sha256(contract_path.read_bytes()).hexdigest()[:12]
|
|
601
|
+
return f"hash {digest}"
|
|
602
|
+
return "hash unavailable"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def render_html(
|
|
606
|
+
contract: Contract,
|
|
607
|
+
contract_path: Path | None = None,
|
|
608
|
+
*,
|
|
609
|
+
redact_paths: bool = False,
|
|
610
|
+
cli_line: str | None = None,
|
|
611
|
+
) -> str:
|
|
612
|
+
"""Return a byte-stable report document for ``contract``."""
|
|
613
|
+
report = run_record_checks(contract)
|
|
614
|
+
data = _safe_json(_payload(contract, report, redact_paths=redact_paths, cli_line=cli_line))
|
|
615
|
+
return (
|
|
616
|
+
_DOCUMENT.replace("__LOCKWIRE_DATA__", data)
|
|
617
|
+
.replace("__GRAPH_LAYOUT_JS__", GRAPH_LAYOUT_JS)
|
|
618
|
+
.replace("__DOCUMENT_IDENTITY__", escape(document_identity(contract_path)))
|
|
619
|
+
.replace("__IDENTITY__", escape(_report_identity(contract)))
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def write_html_report(
|
|
624
|
+
contract: Contract,
|
|
625
|
+
out: Path,
|
|
626
|
+
*,
|
|
627
|
+
contract_path: Path | None = None,
|
|
628
|
+
redact_paths: bool = False,
|
|
629
|
+
cli_line: str | None = None,
|
|
630
|
+
) -> None:
|
|
631
|
+
"""Write a self-contained report, replacing ``out`` if it exists."""
|
|
632
|
+
out.write_text(
|
|
633
|
+
render_html(contract, contract_path, redact_paths=redact_paths, cli_line=cli_line),
|
|
634
|
+
encoding="utf-8",
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
_DOCUMENT = """<!doctype html>
|
|
639
|
+
<html lang="en">
|
|
640
|
+
<head>
|
|
641
|
+
<meta charset="utf-8">
|
|
642
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
643
|
+
<title>lockwire contract report</title>
|
|
644
|
+
<style>
|
|
645
|
+
:root{color-scheme:light;--bg:#fafbfc;--surface:#fff;--ink:#1b1f24;--muted:#6b737c;--line:#e2e5e9;--accent:#2563eb;--bad:#dc2626;--warn:#d97706;--pass:#059669;--track:#eef0f3;--plan:#f3f4f6;--node:#fff;--edge:#9aa3ad;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
646
|
+
@media(prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#0c0e12;--surface:#14181f;--ink:#e8ecf1;--muted:#8b939e;--line:#262b33;--accent:#60a5fa;--bad:#f87171;--warn:#fbbf24;--pass:#34d399;--track:#1c2129;--plan:#10141a;--node:#14181f;--edge:#5a6470}}
|
|
647
|
+
:root[data-theme=light]{color-scheme:light;--bg:#fafbfc;--surface:#fff;--ink:#1b1f24;--muted:#6b737c;--line:#e2e5e9;--accent:#2563eb;--bad:#dc2626;--warn:#d97706;--pass:#059669;--track:#eef0f3;--plan:#f3f4f6;--node:#fff;--edge:#9aa3ad}
|
|
648
|
+
:root[data-theme=dark]{color-scheme:dark;--bg:#0c0e12;--surface:#14181f;--ink:#e8ecf1;--muted:#8b939e;--line:#262b33;--accent:#60a5fa;--bad:#f87171;--warn:#fbbf24;--pass:#34d399;--track:#1c2129;--plan:#10141a;--node:#14181f;--edge:#5a6470}
|
|
649
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.4 var(--sans)}body{overflow-x:hidden}.mono{font-family:var(--mono)}button{font:inherit}.app{min-height:100vh}.top{height:54px;position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--surface);border-bottom:1px solid var(--line)}.brand{min-width:0;flex:1}.brand b,.brand span{display:block;font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.brand span{font-size:11px;color:var(--muted)}.badge{padding:4px 8px;background:var(--plan);border:1px solid var(--line);font:700 10px var(--mono);letter-spacing:.08em}.status{padding:9px 12px;border:1px solid;color:var(--pass);font:700 12px var(--mono)}.status.bad{color:var(--bad)}.status.warn{color:var(--warn)}.theme{min-width:44px;height:44px;border:1px solid var(--line);background:var(--bg);color:var(--ink);cursor:pointer}.shell{display:flex;flex-direction:column}.tree,.side{background:var(--surface);padding:12px}.tree{border-bottom:1px solid var(--line)}.side{background:var(--bg);display:flex;flex-direction:column;gap:12px}.head{display:flex;justify-content:space-between;color:var(--muted);font:700 10px var(--mono);letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px}.tree ul{list-style:none;padding:0;margin:0}.tree li{display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:6px;padding:7px 5px;border-left:3px solid transparent;font:11px var(--mono)}.tree li span:nth-child(2){overflow:hidden;text-overflow:ellipsis}.tree li.bad{border-left-color:var(--bad);background:color-mix(in srgb,var(--bad) 8%,transparent)}.mark{color:var(--pass);font-weight:800}.bad .mark{color:var(--bad)}.stage{min-width:0;padding:12px}.plan{background:var(--plan);border:1px solid var(--line);padding:10px 12px;margin-bottom:10px;overflow:auto;font:11px/1.55 var(--mono)}.plan b{display:block;color:var(--ink);margin-bottom:4px}.plan .bad{color:var(--bad)}.plan .warn{color:var(--warn)}.plan .note{color:var(--muted)}.plan .ok{color:var(--pass)}.tabs{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line);margin-bottom:8px}.tabs button{height:40px;border:0;border-right:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}.tabs button:last-child{border:0}.tabs button[aria-pressed=true]{background:var(--ink);color:var(--bg);font-weight:700}.graphbox,.panel{background:var(--surface);border:1px solid var(--line);overflow:hidden}.caption,.panelhead{display:flex;justify-content:space-between;padding:8px 10px;border-bottom:1px solid var(--line);font:11px var(--mono);color:var(--muted)}.caption b{color:var(--ink)}svg{display:block;width:100%;height:460px;min-width:0}.edge{fill:none;stroke:var(--edge);stroke-width:1.25}.edge.hit{stroke:var(--accent);stroke-width:2}.node rect{fill:var(--node);stroke:var(--line);stroke-width:1.2}.node text{fill:var(--ink);font:600 10px var(--mono)}.node text.sub{fill:var(--muted);font-size:8.5px;font-weight:400}.node.hit rect{stroke:var(--accent);stroke-width:2.5}.node.breach rect{stroke:var(--bad);stroke-width:2}.dim{opacity:.16}.budget{display:grid;grid-template-columns:20px minmax(0,1fr) auto;gap:4px 8px;padding:11px;border-top:1px solid var(--line);font:11px var(--mono)}.budget:first-child{border:0}.budget.breach{padding-top:15px;padding-bottom:15px;background:color-mix(in srgb,var(--bad) 8%,transparent)}.budget .sign{color:var(--pass);font-weight:800}.budget.breach .sign{color:var(--bad)}.budget.not_scored .sign{color:var(--muted)}.budget.not_scored .fill{background:var(--muted)}.budget .raised{grid-column:1/4;color:var(--warn)}.bar{grid-column:2/4;height:6px;background:var(--track);overflow:hidden}.fill{height:100%;background:var(--pass)}.breach .fill{background:var(--bad)}.empty,.info{padding:12px;color:var(--muted);font:11px/1.5 var(--mono)}.empty b{display:block;color:var(--pass)}.finding{width:100%;text-align:left;padding:12px;border:0;border-top:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer}.finding:first-child{border-top:0}.finding[aria-pressed=true]{box-shadow:inset 3px 0 var(--accent);background:color-mix(in srgb,var(--accent) 6%,var(--surface))}.finding h3{margin:0 0 5px;font:700 11px var(--mono);color:var(--bad)}.finding.warning h3{color:var(--warn)}.finding.not_scored h3{color:var(--muted)}.finding p{margin:3px 0;font:11px/1.45 var(--mono);overflow-wrap:anywhere}.finding .meta{color:var(--muted)}.finding .hint{color:var(--accent);margin-top:8px;font-weight:700}.info b{color:var(--ink)}.identity{padding:8px 12px;border-top:1px solid var(--line);color:var(--muted);font:11px var(--mono);overflow-wrap:anywhere}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
650
|
+
@media(min-width:1000px){.shell{display:grid;grid-template-columns:260px minmax(0,1fr) 320px;min-height:calc(100vh - 54px)}.tree{border:0;border-right:1px solid var(--line)}.stage{padding:14px 16px}.side{border-left:1px solid var(--line);padding:14px}.tree,.stage,.side{overflow-y:auto;max-height:calc(100vh - 54px)}}
|
|
651
|
+
@media(max-width:420px){.badge{display:none}.top{gap:6px}.status{padding:8px}.stage,.tree,.side{padding-left:8px;padding-right:8px}svg{height:390px}}
|
|
652
|
+
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
|
|
653
|
+
.budget{overflow-x:auto}
|
|
654
|
+
.budget .meta{grid-column:2/4;color:var(--muted);overflow-wrap:anywhere}
|
|
655
|
+
.footer{padding:9px 12px;border-top:1px solid var(--line);color:var(--muted);font:11px var(--mono);overflow-wrap:anywhere}
|
|
656
|
+
.provenance-bar{padding:6px 16px;color:var(--muted);font:10px var(--mono);border-top:1px solid var(--line)}
|
|
657
|
+
@media print{.top{position:static}.budget,.plan,.tree,.stage,.side{overflow:visible}svg{height:auto;max-height:none}.provenance-bar{position:fixed;bottom:0;left:0;right:0;background:var(--surface)}}
|
|
658
|
+
</style>
|
|
659
|
+
</head>
|
|
660
|
+
<body>
|
|
661
|
+
<div class="app">
|
|
662
|
+
<header class="top"><div class="brand"><b>lockwire</b><span id="subtitle"></span></div><span class="badge">REPORT</span><span id="status" class="status"></span><button id="theme" class="theme" aria-label="Toggle color theme">◐</button></header>
|
|
663
|
+
<div class="shell">
|
|
664
|
+
<aside class="tree"><div class="head"><span>Contract</span><span id="treeCount"></span></div><ul id="outline"></ul></aside>
|
|
665
|
+
<main class="stage">
|
|
666
|
+
<div class="plan" id="plan"></div>
|
|
667
|
+
<div class="tabs" role="tablist" aria-label="Contract lens"><button data-lens="mechanical">Mechanical</button><button data-lens="power" aria-pressed="true">Power</button><button data-lens="data">Data</button></div>
|
|
668
|
+
<section class="graphbox"><div class="caption"><b id="lensTitle">Power</b><span id="lensDesc">battery → rails → devices · watts</span></div><svg id="graph" role="img" aria-label="Robot contract graph"></svg></section>
|
|
669
|
+
</main>
|
|
670
|
+
<aside class="side">
|
|
671
|
+
<section class="panel"><div class="panelhead"><b>Budgets</b><span id="budgetCount"></span></div><div id="budgets"></div></section>
|
|
672
|
+
<section class="panel"><div class="panelhead"><b>Findings</b><span id="findingCount"></span></div><div id="findings"></div></section>
|
|
673
|
+
<section class="panel"><div class="panelhead"><b>What adds up</b><span id="addsUpCount"></span></div><div id="addsup"></div></section>
|
|
674
|
+
<section class="panel"><div class="panelhead"><b>Derived information</b><span>honest lane</span></div><div id="information"></div></section>
|
|
675
|
+
</aside>
|
|
676
|
+
</div>
|
|
677
|
+
<footer class="identity" id="identity">__DOCUMENT_IDENTITY__</footer>
|
|
678
|
+
<footer class="footer">ran: <span id="cliline" class="mono"></span></footer>
|
|
679
|
+
<footer class="provenance-bar mono">source: __IDENTITY__</footer>
|
|
680
|
+
</div>
|
|
681
|
+
<script>
|
|
682
|
+
(function(){"use strict";
|
|
683
|
+
const DATA=__LOCKWIRE_DATA__;
|
|
684
|
+
const NS="http://www.w3.org/2000/svg";
|
|
685
|
+
const $=id=>document.getElementById(id);
|
|
686
|
+
let lens="power",active=null;
|
|
687
|
+
function text(tag,value,cls){const e=document.createElement(tag);if(cls)e.className=cls;e.textContent=value;return e}
|
|
688
|
+
function implicated(f){const values=[f.quantity,f.dependent||"",...(f.cluster_members||[])].join(" ");const hits=new Set();Object.values(DATA.graph).forEach(g=>g.nodes.forEach(n=>{if(values.includes(n.id)||f.quantity.split("/").includes(n.id))hits.add(n.id)}));DATA.budgets.forEach(b=>{if(f.quantity.includes(b.id)&&b.kind==="rail_power"){const match=DATA.graph.power.nodes.find(n=>n.id.includes(b.id.split(".").slice(-1)[0]));if(match)hits.add(match.id)}});return hits}
|
|
689
|
+
function findingMark(s){return s==="violation"?"✗ ":s==="warning"?"~ ":"○ "}
|
|
690
|
+
function findingCls(s){return s==="violation"?"bad":s==="warning"?"warn":"note"}
|
|
691
|
+
function renderOutline(){const ul=$("outline");ul.replaceChildren();DATA.outline.forEach(row=>{const bad=DATA.findings.some(f=>f.severity==="violation"&&implicated(f).has(row.id));const li=document.createElement("li");if(bad)li.className="bad";li.append(text("span",bad?"!":"+", "mark"),text("span",row.id),text("span",row.type));ul.append(li)});$("treeCount").textContent=DATA.outline.length+" entries"}
|
|
692
|
+
function renderPlan(){const box=$("plan");box.replaceChildren();box.append(text("b","lockwire plan · "+DATA.headline.adds_up+" add up · "+DATA.headline.does_not_add_up+" do not add up · "+DATA.headline.not_checked+" not checked"));if(!DATA.findings.length)box.append(text("div","✓ records agree · no findings","ok"));DATA.findings.forEach(f=>box.append(text("div",findingMark(f.severity)+f.quantity+" "+f.observed+" / "+f.expected,findingCls(f.severity))))}
|
|
693
|
+
function fmt(v){return v===null?"not computable":Number(v).toFixed(2)}
|
|
694
|
+
function renderBudgets(){const root=$("budgets");root.replaceChildren();let pass=0,notScored=0;DATA.budgets.forEach(b=>{if(b.state==="pass")pass++;if(b.state==="not_scored")notScored++;const row=document.createElement("div");row.className="budget "+b.state;const ratio=b.value===null?0:(b.direction==="max"?b.value/b.limit:b.limit/b.value);const sign=b.state==="breach"?"!":b.state==="not_scored"?"·":"✓";row.append(text("span",sign,"sign"),text("b",b.id),text("span",b.note||(fmt(b.value)+" / "+fmt(b.limit)+" "+b.unit)));const bar=document.createElement("div");bar.className="bar";const fill=document.createElement("div");fill.className="fill";fill.style.width=Math.min(100,Math.max(0,ratio*100))+"%";bar.append(fill);row.append(bar);if(b.limit_raised)row.append(text("span",b.limit_raised,"raised"));const prov=[["witness",b.witness],["source",b.source],["locked_at",b.locked_at]].filter(([,v])=>v).map(([k,v])=>k+": "+v).join(" · ");row.append(text("span",prov||"limit has no provenance","meta"));root.append(row)});if(!DATA.budgets.length)root.append(text("div","No budgets declared.","empty"));const scoredTotal=DATA.budgets.length-notScored;$("budgetCount").textContent=pass+" / "+scoredTotal+" pass"+(notScored?" · "+notScored+" not scored":"")}
|
|
695
|
+
function renderFindings(){const root=$("findings");root.replaceChildren();$("findingCount").textContent=DATA.findings.length;if(!DATA.findings.length){const e=text("div","","empty");e.append(text("b","✓ GO — records agree"),text("span","No findings."));root.append(e);return}DATA.findings.forEach((f,i)=>{const b=document.createElement("button");b.type="button";b.className="finding "+f.severity;b.setAttribute("aria-pressed",String(active===i));const clustered=f.cluster_root&&f.cluster_members&&f.cluster_members.length>1;b.append(text("h3",findingMark(f.severity)+(clustered?f.cluster_root+" subsystem: "+f.cluster_members.length+" members affected — "+f.cluster_members.join(", "):f.quantity)));if(clustered)f.member_findings.forEach(m=>b.append(text("p",m.quantity+": observed "+m.observed+", expected "+m.expected,"meta")));else b.append(text("p","observed "+f.observed+", expected "+f.expected+(f.delta?" (Δ "+f.delta+")":"")));[["dependent",f.dependent],["owner",f.owner],["source",f.source],["witness",f.witness],["locked_at",f.locked_at],["action",f.action]].forEach(([k,v])=>{if(v)b.append(text("p",k+": "+v,"meta"))});b.append(text("p",active===i?"Highlighting · tap again to clear":"Tap to highlight on graph","hint"));b.onclick=()=>{active=active===i?null:i;renderFindings();renderGraph()};root.append(b)})}
|
|
696
|
+
function renderAddsUp(){const root=$("addsup");root.replaceChildren();$("addsUpCount").textContent=DATA.adds_up.length;if(!DATA.adds_up.length){root.append(text("div","Nothing scored yet.","empty"));return}DATA.adds_up.forEach(r=>{const row=text("div","","info");row.append(text("b",r.quantity),text("div","observed "+r.observed+", expected "+r.expected),text("div","margin: "+r.margin));root.append(row)})}
|
|
697
|
+
function renderInfo(){const root=$("information");root.replaceChildren();if(!DATA.information.length){root.append(text("div","No derived information.","empty"));return}DATA.information.forEach(i=>{const row=text("div","","info");row.append(text("b",i.quantity+" — "+i.result),text("div","frame: "+i.frame+" · sources: "+i.sources),text("div",i.mass));if(i.limitation)row.append(text("div","limitation: "+i.limitation));root.append(row)})}
|
|
698
|
+
__GRAPH_LAYOUT_JS__
|
|
699
|
+
function svgEl(tag,attrs){const e=document.createElementNS(NS,tag);Object.entries(attrs).forEach(([k,v])=>e.setAttribute(k,v));return e}
|
|
700
|
+
function renderGraph(){const source=DATA.graph[lens],svg=$("graph"),box=svg.getBoundingClientRect();const W=Math.max(560,box.width||900),H=Math.max(320,box.height||460);const graph=layout({nodes:source.nodes.map(n=>({...n})),edges:source.edges},W,H);svg.setAttribute("viewBox","0 0 "+W+" "+H);svg.replaceChildren();const hit=active===null?null:implicated(DATA.findings[active]);graph.edges.forEach(e=>{const a=graph.byId[e.from],b=graph.byId[e.to];if(!a||!b)return;const yes=hit&&(hit.has(a.id)||hit.has(b.id));const path=svgEl("path",{d:edgePath(a,b,graph.nodes),class:"edge"+(yes?" hit":"")+(hit&&!yes?" dim":"")});svg.append(path)});graph.nodes.forEach(n=>{const yes=hit&&hit.has(n.id);const g=svgEl("g",{class:"node "+(yes?"hit ":"")+(hit&&!yes?"dim ":"")+(DATA.findings.some(f=>f.severity==="violation"&&implicated(f).has(n.id))?"breach":"")});g.append(svgEl("rect",{x:n.x-n.w/2,y:n.y-n.h/2,width:n.w,height:n.h,rx:4}));const label=svgEl("text",{x:n.x,y:n.y-2,"text-anchor":"middle"});label.textContent=n.label;g.append(label);const sub=svgEl("text",{x:n.x,y:n.y+13,"text-anchor":"middle",class:"sub"});sub.textContent=n.sub;g.append(sub);svg.append(g)})}
|
|
701
|
+
function setLens(value){lens=value;document.querySelectorAll("[data-lens]").forEach(b=>b.setAttribute("aria-pressed",String(b.dataset.lens===lens)));const meta={mechanical:["Mechanical","links · joints · mounts"],power:["Power","battery → rails → devices · watts"],data:["Data","hosts · buses · devices"]}[lens];$("lensTitle").textContent=meta[0];$("lensDesc").textContent=meta[1];renderGraph()}
|
|
702
|
+
$("subtitle").textContent=DATA.contract_id+" · "+DATA.robot;$("cliline").textContent=DATA.cli_line||"(no CLI line recorded)";$("status").textContent=DATA.verdict;if(DATA.verdict==="NO-GO")$("status").classList.add("bad");else if(DATA.verdict==="NOT SCORED")$("status").classList.add("warn");document.querySelectorAll("[data-lens]").forEach(b=>b.onclick=()=>setLens(b.dataset.lens));$("theme").onclick=()=>{const r=document.documentElement,c=r.getAttribute("data-theme");r.setAttribute("data-theme",c==="dark"?"light":c==="light"?"dark":matchMedia("(prefers-color-scheme: dark)").matches?"light":"dark")};renderOutline();renderPlan();renderBudgets();renderFindings();renderAddsUp();renderInfo();setLens("power");
|
|
703
|
+
})();
|
|
704
|
+
</script>
|
|
705
|
+
</body>
|
|
706
|
+
</html>
|
|
707
|
+
"""
|