termwright 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.
termwright/validate.py ADDED
@@ -0,0 +1,846 @@
1
+ """Snapshot validation.
2
+
3
+ A structural port of ``validate.ts``: same invariants, same error codes, same
4
+ order of checks, so a snapshot rejected here is rejected by the driver and vice
5
+ versa. Never raises on hostile input — failures come back as a result object.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import math
12
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
13
+
14
+ from .errors import ProtocolViolation
15
+ from .framing import encode_json, project_dto
16
+ from .limits import DEFAULT_LIMITS, ProtocolLimits
17
+ from .roles import ACTION_SET, ROLE_SET
18
+
19
+ VALIDATION_ERROR_CODES = (
20
+ "schema",
21
+ "unknown-role",
22
+ "duplicate-id",
23
+ "missing-parent",
24
+ "cycle",
25
+ "depth",
26
+ "count",
27
+ "string-bytes",
28
+ "bad-rect",
29
+ "revision",
30
+ "bytes",
31
+ )
32
+
33
+ _MAX_SAFE_INTEGER = 2**53 - 1
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ValidationResult:
38
+ """Outcome of :func:`validate_snapshot`."""
39
+
40
+ ok: bool
41
+ snapshot: Optional[Dict[str, Any]] = None
42
+ code: Optional[str] = None
43
+ detail: str = ""
44
+
45
+
46
+ def _fail(code: str, detail: str) -> ValidationResult:
47
+ return ValidationResult(ok=False, code=code, detail=detail)
48
+
49
+
50
+ class _Issue(Exception):
51
+ """A schema-level defect, carrying the path zod would have reported."""
52
+
53
+ def __init__(self, path: Sequence[str], message: str, too_big: bool = False) -> None:
54
+ super().__init__(message)
55
+ self.path: Tuple[str, ...] = tuple(path)
56
+ self.message = message
57
+ self.too_big = too_big
58
+
59
+ @property
60
+ def code(self) -> str:
61
+ if "role" in self.path:
62
+ return "unknown-role"
63
+ if "revision" in self.path:
64
+ return "revision"
65
+ if "bounds" in self.path or "rect" in self.path:
66
+ return "bad-rect"
67
+ if self.too_big and ("nodes" in self.path or "rootIds" in self.path):
68
+ return "count"
69
+ if "UTF-8 bytes" in self.message:
70
+ return "string-bytes"
71
+ return "schema"
72
+
73
+ @property
74
+ def detail(self) -> str:
75
+ where = ".".join(self.path) if self.path else "<root>"
76
+ return f"{where}: {self.message}"
77
+
78
+
79
+ # --------------------------------------------------------------------------
80
+ # Schema layer — mirrors the zod schema, field for field.
81
+ # --------------------------------------------------------------------------
82
+
83
+
84
+ def _obj(value: Any, path: Sequence[str]) -> Mapping[str, Any]:
85
+ if not isinstance(value, dict):
86
+ raise _Issue(path, "expected an object")
87
+ return value
88
+
89
+
90
+ def _strict(value: Mapping[str, Any], allowed: Sequence[str], path: Sequence[str]) -> None:
91
+ unknown = [key for key in value if key not in allowed]
92
+ if unknown:
93
+ raise _Issue(path, f"Unrecognized key(s) in object: {', '.join(repr(k) for k in unknown)}")
94
+
95
+
96
+ def _safe_int(value: Any, path: Sequence[str]) -> int:
97
+ if isinstance(value, bool) or not isinstance(value, int) or abs(value) > _MAX_SAFE_INTEGER:
98
+ raise _Issue(path, "expected a safe integer")
99
+ return value
100
+
101
+
102
+ def _non_negative_int(value: Any, path: Sequence[str]) -> int:
103
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > _MAX_SAFE_INTEGER:
104
+ raise _Issue(path, "expected a non-negative safe integer")
105
+ return value
106
+
107
+
108
+ def _positive_int(value: Any, path: Sequence[str]) -> int:
109
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0 or value > _MAX_SAFE_INTEGER:
110
+ raise _Issue(path, "expected a positive safe integer")
111
+ return value
112
+
113
+
114
+ def _text(value: Any, path: Sequence[str], limits: ProtocolLimits) -> str:
115
+ if not isinstance(value, str):
116
+ raise _Issue(path, "expected a string")
117
+ if len(value.encode("utf-8", "surrogatepass")) > limits.maxStringBytes:
118
+ raise _Issue(path, f"expected at most {limits.maxStringBytes} UTF-8 bytes")
119
+ return value
120
+
121
+
122
+ def _bool(value: Any, path: Sequence[str]) -> bool:
123
+ if not isinstance(value, bool):
124
+ raise _Issue(path, "expected a boolean")
125
+ return value
126
+
127
+
128
+ def _rect(value: Any, path: Sequence[str]) -> Mapping[str, int]:
129
+ rect = _obj(value, path)
130
+ _strict(rect, ("row", "column", "width", "height"), path)
131
+ for key in ("row", "column"):
132
+ if key not in rect:
133
+ raise _Issue(tuple(path) + (key,), "expected a safe integer")
134
+ _safe_int(rect[key], tuple(path) + (key,))
135
+ for key in ("width", "height"):
136
+ if key not in rect:
137
+ raise _Issue(tuple(path) + (key,), "expected a non-negative safe integer")
138
+ _non_negative_int(rect[key], tuple(path) + (key,))
139
+ return rect
140
+
141
+
142
+ _STATE_BOOLS = (
143
+ "disabled",
144
+ "focused",
145
+ "selected",
146
+ "expanded",
147
+ "modal",
148
+ "busy",
149
+ "hidden",
150
+ "offscreen",
151
+ "readonly",
152
+ "multiline",
153
+ )
154
+ _STATE_KEYS = _STATE_BOOLS + (
155
+ "checked",
156
+ "orientation",
157
+ "level",
158
+ "positionInSet",
159
+ "setSize",
160
+ "scrollOffset",
161
+ "scrollExtent",
162
+ )
163
+
164
+
165
+ def _state(value: Any, path: Sequence[str]) -> None:
166
+ state = _obj(value, path)
167
+ _strict(state, _STATE_KEYS, path)
168
+ for key in _STATE_BOOLS:
169
+ if key in state:
170
+ _bool(state[key], tuple(path) + (key,))
171
+ if "checked" in state and not (isinstance(state["checked"], bool) or state["checked"] == "mixed"):
172
+ raise _Issue(tuple(path) + ("checked",), "expected a boolean or 'mixed'")
173
+ if "orientation" in state and state["orientation"] not in ("horizontal", "vertical"):
174
+ raise _Issue(tuple(path) + ("orientation",), "expected 'horizontal' or 'vertical'")
175
+ for key in ("level", "positionInSet"):
176
+ if key in state:
177
+ _positive_int(state[key], tuple(path) + (key,))
178
+ for key in ("setSize", "scrollOffset", "scrollExtent"):
179
+ if key in state:
180
+ _non_negative_int(state[key], tuple(path) + (key,))
181
+
182
+
183
+ _NODE_KEYS = (
184
+ "id",
185
+ "parentId",
186
+ "role",
187
+ "name",
188
+ "description",
189
+ "value",
190
+ "bounds",
191
+ "state",
192
+ "extended",
193
+ "actions",
194
+ "labelledBy",
195
+ "describedBy",
196
+ "textRanges",
197
+ "testId",
198
+ "frameworkType",
199
+ "occlusion",
200
+ "p",
201
+ "px",
202
+ )
203
+ _NODE_V2_KEYS = tuple(key for key in _NODE_KEYS if key not in ("bounds", "occlusion")) + ("geometry",)
204
+
205
+ _OBSERVATION_EVIDENCE = ("adapter", "probe", "terminal-grid", "viewport-clip", "paint-order", "hit-grid", "legacy-v1")
206
+
207
+
208
+ def _observation(value: Any, path: Sequence[str], known, limits: ProtocolLimits) -> None:
209
+ item = _obj(value, path)
210
+ status = item.get("status")
211
+ if status == "known":
212
+ _strict(item, ("status", "value", "evidence"), path)
213
+ if "value" not in item or item.get("evidence") not in _OBSERVATION_EVIDENCE:
214
+ raise _Issue(path, "known observation requires value and evidence")
215
+ known(item["value"], tuple(path) + ("value",))
216
+ elif status == "absent":
217
+ _strict(item, ("status", "reason"), path)
218
+ if item.get("reason") not in ("detached", "not-displayed", "not-laid-out"):
219
+ raise _Issue(tuple(path) + ("reason",), "invalid absent reason")
220
+ elif status == "unknown":
221
+ _strict(item, ("status", "reason"), path)
222
+ if item.get("reason") not in ("not-reported", "temporary", "clip-unobservable", "legacy-unqualified"):
223
+ raise _Issue(tuple(path) + ("reason",), "invalid unknown reason")
224
+ elif status == "unsupported":
225
+ _strict(item, ("status", "capability", "reason"), path)
226
+ _text(item.get("capability"), tuple(path) + ("capability",), limits)
227
+ if item.get("reason") not in ("capability", "framework-unobservable", "not-negotiated"):
228
+ raise _Issue(tuple(path) + ("reason",), "invalid unsupported reason")
229
+ else:
230
+ raise _Issue(tuple(path) + ("status",), "invalid observation status")
231
+
232
+ #: Where a semantic fact came from. Closed set, so an unknown source is a
233
+ #: rejection rather than a silently ignored annotation.
234
+ PROVENANCE_SOURCES = (
235
+ "annotation",
236
+ "recognizer",
237
+ "framework",
238
+ "correlation",
239
+ "heuristic",
240
+ )
241
+
242
+ #: Whether the producer can say if a node's cells are covered. A producer that
243
+ #: cannot see paint order says `unknown`, and the driver refuses to click it.
244
+ OCCLUSION_VALUES = ("known", "unknown")
245
+
246
+
247
+ def _extended(value: Any, path: Sequence[str], limits: ProtocolLimits) -> None:
248
+ if value is None or isinstance(value, bool):
249
+ return
250
+ if isinstance(value, str):
251
+ _text(value, path, limits)
252
+ return
253
+ if isinstance(value, (int, float)):
254
+ if not math.isfinite(value) or abs(value) > _MAX_SAFE_INTEGER:
255
+ raise _Issue(path, "expected a finite JSON number in the safe range")
256
+ return
257
+ if isinstance(value, list):
258
+ if len(value) > limits.maxRelationTargets:
259
+ raise _Issue(path, f"expected at most {limits.maxRelationTargets} items", too_big=True)
260
+ for index, item in enumerate(value):
261
+ _extended(item, tuple(path) + (str(index),), limits)
262
+ return
263
+ if isinstance(value, dict):
264
+ if len(value) > limits.maxRelationTargets:
265
+ raise _Issue(path, f"expected at most {limits.maxRelationTargets} properties", too_big=True)
266
+ for key, item in value.items():
267
+ _text(key, tuple(path) + (str(key),), limits)
268
+ _extended(item, tuple(path) + (str(key),), limits)
269
+ return
270
+ raise _Issue(path, "expected JSON scalar, array or object")
271
+
272
+
273
+ def _relations(value: Any, path: Sequence[str], limits: ProtocolLimits) -> None:
274
+ if not isinstance(value, list):
275
+ raise _Issue(path, "expected an array")
276
+ if len(value) > limits.maxRelationTargets:
277
+ raise _Issue(path, f"expected at most {limits.maxRelationTargets} items", too_big=True)
278
+ for index, item in enumerate(value):
279
+ _text(item, tuple(path) + (str(index),), limits)
280
+
281
+
282
+ def _node_schema(value: Any, path: Sequence[str], limits: ProtocolLimits, v: int = 1) -> None:
283
+ node = _obj(value, path)
284
+ if v == 2 and "bounds" in node:
285
+ raise _Issue(tuple(path) + ("bounds",), "legacy bounds are forbidden in v2")
286
+ _strict(node, _NODE_V2_KEYS if v == 2 else _NODE_KEYS, path)
287
+
288
+ if "id" not in node:
289
+ raise _Issue(tuple(path) + ("id",), "expected a string")
290
+ if _text(node["id"], tuple(path) + ("id",), limits) == "":
291
+ raise _Issue(tuple(path) + ("id",), "node id must not be empty")
292
+ if "parentId" in node:
293
+ _text(node["parentId"], tuple(path) + ("parentId",), limits)
294
+ if node.get("role") not in ROLE_SET:
295
+ raise _Issue(tuple(path) + ("role",), "expected one of the v1 semantic roles")
296
+ if "name" not in node:
297
+ raise _Issue(tuple(path) + ("name",), "expected a string")
298
+ _text(node["name"], tuple(path) + ("name",), limits)
299
+ for key in ("description", "value", "testId", "frameworkType"):
300
+ if key in node:
301
+ _text(node[key], tuple(path) + (key,), limits)
302
+ if "occlusion" in node and node["occlusion"] not in OCCLUSION_VALUES:
303
+ raise _Issue(tuple(path) + ("occlusion",), "expected 'known' or 'unknown'")
304
+ if "p" in node and node["p"] not in PROVENANCE_SOURCES:
305
+ raise _Issue(tuple(path) + ("p",), "expected one of the provenance sources")
306
+ if "px" in node:
307
+ per_field = node["px"]
308
+ if not isinstance(per_field, dict):
309
+ raise _Issue(tuple(path) + ("px",), "expected an object")
310
+ for field, source in per_field.items():
311
+ _text(field, tuple(path) + ("px", str(field)), limits)
312
+ if source not in PROVENANCE_SOURCES:
313
+ raise _Issue(
314
+ tuple(path) + ("px", str(field)),
315
+ "expected one of the provenance sources",
316
+ )
317
+ if node.get("role") == "generic" and not node.get("frameworkType"):
318
+ # An unrecognised widget must at least name what the framework called
319
+ # it. An empty string carries no more than its absence, so both fail.
320
+ raise _Issue(
321
+ tuple(path) + ("frameworkType",),
322
+ f"node {node.get('id')} has role 'generic' without a frameworkType; "
323
+ "an unrecognised widget must name what the framework called it",
324
+ )
325
+ if "bounds" in node:
326
+ _rect(node["bounds"], tuple(path) + ("bounds",))
327
+ if v == 2:
328
+ geometry = _obj(node.get("geometry"), tuple(path) + ("geometry",))
329
+ _strict(geometry, ("displayed", "intendedRect", "visibleRect"), tuple(path) + ("geometry",))
330
+ _observation(geometry.get("displayed"), tuple(path) + ("geometry", "displayed"), _bool, limits)
331
+ _observation(geometry.get("intendedRect"), tuple(path) + ("geometry", "intendedRect"), _rect, limits)
332
+ _observation(geometry.get("visibleRect"), tuple(path) + ("geometry", "visibleRect"), _rect, limits)
333
+ if "state" in node:
334
+ _state(node["state"], tuple(path) + ("state",))
335
+ state = node["state"]
336
+ if isinstance(state, dict) and state.get("offscreen") is True and state.get("hidden") is not True:
337
+ # Every cell outside the visible area and the node still visible
338
+ # cannot both be true. Refusing the pair keeps `offscreen` a claim
339
+ # about scrolling rather than a second, weaker way of saying hidden.
340
+ raise _Issue(
341
+ tuple(path) + ("state", "offscreen"),
342
+ f"node {node.get('id')}: state.offscreen implies state.hidden — every cell is "
343
+ "outside the visible area, so the node cannot also be visible",
344
+ )
345
+ if "extended" in node:
346
+ if not isinstance(node["extended"], dict):
347
+ raise _Issue(tuple(path) + ("extended",), "expected an object")
348
+ _extended(node["extended"], tuple(path) + ("extended",), limits)
349
+ if "actions" in node:
350
+ actions = node["actions"]
351
+ if not isinstance(actions, list):
352
+ raise _Issue(tuple(path) + ("actions",), "expected an array")
353
+ if len(actions) > len(ACTION_SET):
354
+ raise _Issue(tuple(path) + ("actions",), "too many actions", too_big=True)
355
+ for index, action in enumerate(actions):
356
+ if action not in ACTION_SET:
357
+ raise _Issue(
358
+ tuple(path) + ("actions", str(index)), "expected one of the v1 semantic actions"
359
+ )
360
+ for key in ("labelledBy", "describedBy"):
361
+ if key in node:
362
+ _relations(node[key], tuple(path) + (key,), limits)
363
+ if "textRanges" in node:
364
+ ranges = node["textRanges"]
365
+ if not isinstance(ranges, list):
366
+ raise _Issue(tuple(path) + ("textRanges",), "expected an array")
367
+ if len(ranges) > limits.maxRelationTargets:
368
+ raise _Issue(tuple(path) + ("textRanges",), "too many text ranges", too_big=True)
369
+ for index, item in enumerate(ranges):
370
+ item_path = tuple(path) + ("textRanges", str(index))
371
+ entry = _obj(item, item_path)
372
+ _strict(entry, ("startOffset", "endOffset", "rect"), item_path)
373
+ for key in ("startOffset", "endOffset"):
374
+ if key not in entry:
375
+ raise _Issue(item_path + (key,), "expected a non-negative safe integer")
376
+ _non_negative_int(entry[key], item_path + (key,))
377
+ if "rect" not in entry:
378
+ raise _Issue(item_path + ("rect",), "expected an object")
379
+ _rect(entry["rect"], item_path + ("rect",))
380
+
381
+
382
+ def _cursor(value: Any, path: Sequence[str]) -> None:
383
+ cursor = _obj(value, path)
384
+ _strict(cursor, ("row", "column", "visible", "shape"), path)
385
+ for key in ("row", "column"):
386
+ if key not in cursor:
387
+ raise _Issue(tuple(path) + (key,), "expected a non-negative safe integer")
388
+ _non_negative_int(cursor[key], tuple(path) + (key,))
389
+ if "visible" not in cursor:
390
+ raise _Issue(tuple(path) + ("visible",), "expected a boolean")
391
+ _bool(cursor["visible"], tuple(path) + ("visible",))
392
+ if "shape" in cursor and cursor["shape"] not in ("block", "underline", "bar"):
393
+ raise _Issue(tuple(path) + ("shape",), "expected 'block', 'underline' or 'bar'")
394
+
395
+
396
+ _SNAPSHOT_KEYS = ("v", "sessionId", "revision", "columns", "rows", "cursor", "rootIds", "nodes")
397
+ _SNAPSHOT_V2_KEYS = _SNAPSHOT_KEYS + ("coordinateSpace", "hitGrid")
398
+
399
+
400
+ def _snapshot_schema(value: Any, limits: ProtocolLimits) -> None:
401
+ snapshot = _obj(value, ())
402
+ version = snapshot.get("v")
403
+ _strict(snapshot, _SNAPSHOT_V2_KEYS if version == 2 else _SNAPSHOT_KEYS, ())
404
+
405
+ if version not in (1, 2):
406
+ raise _Issue(("v",), "expected the literal 1 or 2")
407
+ if "sessionId" not in snapshot:
408
+ raise _Issue(("sessionId",), "expected a string")
409
+ if _text(snapshot["sessionId"], ("sessionId",), limits) == "":
410
+ raise _Issue(("sessionId",), "sessionId must not be empty")
411
+ if "revision" not in snapshot:
412
+ raise _Issue(("revision",), "expected a positive safe integer")
413
+ _positive_int(snapshot["revision"], ("revision",))
414
+ for key in ("columns", "rows"):
415
+ if key not in snapshot:
416
+ raise _Issue((key,), "expected a positive safe integer")
417
+ _positive_int(snapshot[key], (key,))
418
+ if "cursor" in snapshot:
419
+ _cursor(snapshot["cursor"], ("cursor",))
420
+
421
+ root_ids = snapshot.get("rootIds")
422
+ if not isinstance(root_ids, list):
423
+ raise _Issue(("rootIds",), "expected an array")
424
+ if len(root_ids) > limits.maxNodes:
425
+ raise _Issue(("rootIds",), f"expected at most {limits.maxNodes} items", too_big=True)
426
+ for index, item in enumerate(root_ids):
427
+ _text(item, ("rootIds", str(index)), limits)
428
+
429
+ nodes = snapshot.get("nodes")
430
+ if not isinstance(nodes, list):
431
+ raise _Issue(("nodes",), "expected an array")
432
+ if len(nodes) > limits.maxNodes:
433
+ raise _Issue(("nodes",), f"expected at most {limits.maxNodes} items", too_big=True)
434
+ for index, node in enumerate(nodes):
435
+ _node_schema(node, ("nodes", str(index)), limits, version)
436
+ if version == 2:
437
+ _observation(snapshot.get("coordinateSpace"), ("coordinateSpace",), lambda value, path: value in ("viewport-cells", "framework-local-cells") or (_ for _ in ()).throw(_Issue(path, "invalid coordinate space")), limits)
438
+ def _grid(value: Any, path: Sequence[str]) -> None:
439
+ grid = _obj(value, path)
440
+ _strict(grid, ("regions",), path)
441
+ regions = grid.get("regions")
442
+ if not isinstance(regions, list) or len(regions) > limits.maxNodes:
443
+ raise _Issue(tuple(path) + ("regions",), "invalid hit regions")
444
+ previous = None
445
+ for index, region_value in enumerate(regions):
446
+ region_path = tuple(path) + ("regions", str(index))
447
+ region = _obj(region_value, region_path)
448
+ _strict(region, ("rect", "recipientId"), region_path)
449
+ rect = _rect(region.get("rect"), region_path + ("rect",))
450
+ if rect["width"] <= 0 or rect["height"] != 1:
451
+ raise _Issue(
452
+ region_path + ("rect",), "hit regions must be non-empty row runs"
453
+ )
454
+ if previous is not None and (
455
+ rect["row"] < previous["row"]
456
+ or (
457
+ rect["row"] == previous["row"]
458
+ and rect["column"] < previous["column"] + previous["width"]
459
+ )
460
+ ):
461
+ raise _Issue(
462
+ region_path + ("rect",),
463
+ "hit regions must be non-overlapping row-major runs",
464
+ )
465
+ previous = rect
466
+ _text(region.get("recipientId"), region_path + ("recipientId",), limits)
467
+ _observation(snapshot.get("hitGrid"), ("hitGrid",), _grid, limits)
468
+
469
+
470
+ # --------------------------------------------------------------------------
471
+ # Structural layer
472
+ # --------------------------------------------------------------------------
473
+
474
+
475
+ def _rect_intersects_viewport(rect: Mapping[str, int], columns: int, rows: int) -> bool:
476
+ if rect["width"] == 0 or rect["height"] == 0:
477
+ return False
478
+ return (
479
+ rect["column"] < columns
480
+ and rect["row"] < rows
481
+ and rect["column"] + rect["width"] > 0
482
+ and rect["row"] + rect["height"] > 0
483
+ )
484
+
485
+
486
+ def _check_node_shape(
487
+ node: Mapping[str, Any],
488
+ snapshot: Mapping[str, Any],
489
+ ids: Set[str],
490
+ limits: ProtocolLimits,
491
+ ) -> Optional[ValidationResult]:
492
+ bounds = node.get("bounds")
493
+ if bounds is not None:
494
+ if (
495
+ abs(bounds["row"] + bounds["height"]) > _MAX_SAFE_INTEGER
496
+ or abs(bounds["column"] + bounds["width"]) > _MAX_SAFE_INTEGER
497
+ ):
498
+ return _fail("bad-rect", f"node {node['id']}: bounds overflow the safe-integer range")
499
+ hidden = (node.get("state") or {}).get("hidden") is True
500
+ if not hidden and not _rect_intersects_viewport(bounds, snapshot["columns"], snapshot["rows"]):
501
+ return _fail(
502
+ "bad-rect",
503
+ f"node {node['id']}: bounds do not intersect the "
504
+ f"{snapshot['columns']}x{snapshot['rows']} viewport and the node is not hidden",
505
+ )
506
+
507
+ for text_range in node.get("textRanges") or []:
508
+ if text_range["endOffset"] < text_range["startOffset"]:
509
+ return _fail("bad-rect", f"node {node['id']}: text range ends before it starts")
510
+ rect = text_range["rect"]
511
+ if abs(rect["row"] + rect["height"]) > _MAX_SAFE_INTEGER:
512
+ return _fail(
513
+ "bad-rect", f"node {node['id']}: text range rect overflows the safe-integer range"
514
+ )
515
+
516
+ for field_name in ("labelledBy", "describedBy"):
517
+ targets = node.get(field_name)
518
+ if targets is None:
519
+ continue
520
+ if len(targets) > limits.maxRelationTargets:
521
+ return _fail(
522
+ "count", f"node {node['id']}: {field_name} exceeds {limits.maxRelationTargets} targets"
523
+ )
524
+ for target in targets:
525
+ if target not in ids:
526
+ return _fail(
527
+ "missing-parent",
528
+ f"node {node['id']}: {field_name} references unknown node {target}",
529
+ )
530
+ return None
531
+
532
+
533
+ def _compute_depths(
534
+ nodes: Sequence[Mapping[str, Any]], by_id: Mapping[str, Mapping[str, Any]]
535
+ ) -> Tuple[Optional[Dict[str, int]], Optional[str]]:
536
+ """Depth of every node (roots at 1), or the id where a parent chain closes."""
537
+ depths: Dict[str, int] = {}
538
+ for start in nodes:
539
+ if start["id"] in depths:
540
+ continue
541
+ chain: List[str] = []
542
+ on_chain: Set[str] = set()
543
+ current: Optional[Mapping[str, Any]] = start
544
+ while current is not None and current["id"] not in depths:
545
+ if current["id"] in on_chain:
546
+ return None, current["id"]
547
+ on_chain.add(current["id"])
548
+ chain.append(current["id"])
549
+ parent_id = current.get("parentId")
550
+ current = None if parent_id is None else by_id.get(parent_id)
551
+ depth = 0 if current is None else depths[current["id"]]
552
+ for node_id in reversed(chain):
553
+ depth += 1
554
+ depths[node_id] = depth
555
+ return depths, None
556
+
557
+
558
+ DELTA_KEYS = ("type", "baseRevision", "revision", "changed", "removed", "rootIds", "cursor")
559
+
560
+
561
+ def validate_tree_delta(value: Any, limits: ProtocolLimits = DEFAULT_LIMITS) -> ValidationResult:
562
+ """Validate the SHAPE of a `tree-delta` message.
563
+
564
+ Only the shape is checkable here. A delta carries no ``columns``/``rows``,
565
+ so whether a parent exists, whether the tree stays acyclic and within the
566
+ depth ceiling, and whether bounds or the cursor fall inside the viewport
567
+ can only be judged once the delta is applied to its base — put the
568
+ assembled tree through :func:`validate_snapshot` for that.
569
+
570
+ What is checkable without the base: sizes, node shape, unique ids, a
571
+ revision that moves forward, and the same id never both upserted and
572
+ removed by one delta.
573
+ """
574
+ try:
575
+ projected = project_dto(value, limits.maxDepth)
576
+ except ProtocolViolation as error:
577
+ return _fail("depth" if error.code == "dto-depth" else "schema", str(error))
578
+
579
+ try:
580
+ serialised = encode_json(projected)
581
+ except ProtocolViolation:
582
+ return _fail("schema", "delta is not JSON-serialisable")
583
+ if len(serialised) > limits.maxSnapshotBytes:
584
+ return _fail(
585
+ "bytes", f"delta is {len(serialised)} bytes, ceiling is {limits.maxSnapshotBytes}"
586
+ )
587
+
588
+ try:
589
+ _tree_delta_schema(projected, limits)
590
+ except _Issue as issue:
591
+ return _fail(issue.code, issue.detail)
592
+
593
+ delta: Dict[str, Any] = projected
594
+ changed_ids: Set[str] = set()
595
+ for index, node in enumerate(delta["changed"]):
596
+ if node["id"] in changed_ids:
597
+ return _fail("duplicate-id", f"node id {node['id']} appears twice in changed")
598
+ changed_ids.add(node["id"])
599
+ if node.get("parentId") == node["id"]:
600
+ return _fail("cycle", f"node {node['id']} is its own parent")
601
+ _ = index
602
+
603
+ removed_ids: Set[str] = set()
604
+ for node_id in delta["removed"]:
605
+ if node_id in removed_ids:
606
+ return _fail("duplicate-id", f"node id {node_id} appears twice in removed")
607
+ removed_ids.add(node_id)
608
+
609
+ both = changed_ids & removed_ids
610
+ if both:
611
+ # Removals apply before upserts, so this would be a delta arguing with
612
+ # itself about one id rather than moving a node between parents.
613
+ return _fail(
614
+ "schema", f"node id {sorted(both)[0]} is both changed and removed by one delta"
615
+ )
616
+
617
+ if "rootIds" in delta:
618
+ seen: Set[str] = set()
619
+ for node_id in delta["rootIds"]:
620
+ if node_id in seen:
621
+ return _fail("duplicate-id", f"root id {node_id} appears more than once")
622
+ seen.add(node_id)
623
+
624
+ return ValidationResult(ok=True, snapshot=delta)
625
+
626
+
627
+ def _tree_delta_schema(value: Any, limits: ProtocolLimits) -> None:
628
+ delta = _obj(value, ())
629
+ _strict(delta, DELTA_KEYS, ())
630
+
631
+ base = _positive_int(delta.get("baseRevision"), ("baseRevision",))
632
+ revision = _positive_int(delta.get("revision"), ("revision",))
633
+ if revision <= base:
634
+ raise _Issue(
635
+ ("revision",), f"revision {revision} must move forward from base {base}"
636
+ )
637
+
638
+ changed = delta.get("changed")
639
+ if not isinstance(changed, list):
640
+ raise _Issue(("changed",), "expected an array")
641
+ if len(changed) > limits.maxNodes:
642
+ raise _Issue(("changed",), f"expected at most {limits.maxNodes} items", too_big=True)
643
+ for index, node in enumerate(changed):
644
+ _node_schema(node, ("changed", str(index)), limits)
645
+
646
+ removed = delta.get("removed")
647
+ if not isinstance(removed, list):
648
+ raise _Issue(("removed",), "expected an array")
649
+ if len(removed) > limits.maxNodes:
650
+ raise _Issue(("removed",), f"expected at most {limits.maxNodes} items", too_big=True)
651
+ for index, node_id in enumerate(removed):
652
+ if _text(node_id, ("removed", str(index)), limits) == "":
653
+ raise _Issue(("removed", str(index)), "node id must not be empty")
654
+
655
+ if "rootIds" in delta:
656
+ root_ids = delta["rootIds"]
657
+ if not isinstance(root_ids, list):
658
+ raise _Issue(("rootIds",), "expected an array")
659
+ if len(root_ids) > limits.maxNodes:
660
+ raise _Issue(("rootIds",), f"expected at most {limits.maxNodes} items", too_big=True)
661
+ for index, node_id in enumerate(root_ids):
662
+ _text(node_id, ("rootIds", str(index)), limits)
663
+
664
+ if "cursor" in delta:
665
+ _cursor(delta["cursor"], ("cursor",))
666
+
667
+
668
+ def validate_snapshot(value: Any, limits: ProtocolLimits = DEFAULT_LIMITS) -> ValidationResult:
669
+ """Validate an untrusted snapshot against ``limits``.
670
+
671
+ :returns: ``ValidationResult(ok=True, snapshot=...)`` with a projected plain
672
+ copy, or ``ValidationResult(ok=False, code=..., detail=...)``. Never raises.
673
+ """
674
+ try:
675
+ projected = project_dto(value, limits.maxDepth)
676
+ except ProtocolViolation as error:
677
+ return _fail("depth" if error.code == "dto-depth" else "schema", str(error))
678
+
679
+ try:
680
+ serialised = encode_json(projected)
681
+ except ProtocolViolation:
682
+ return _fail("schema", "snapshot is not JSON-serialisable")
683
+ if len(serialised) > limits.maxSnapshotBytes:
684
+ return _fail(
685
+ "bytes", f"snapshot is {len(serialised)} bytes, ceiling is {limits.maxSnapshotBytes}"
686
+ )
687
+
688
+ try:
689
+ _snapshot_schema(projected, limits)
690
+ except _Issue as issue:
691
+ return _fail(issue.code, issue.detail)
692
+
693
+ snapshot: Dict[str, Any] = projected
694
+ nodes: List[Dict[str, Any]] = snapshot["nodes"]
695
+
696
+ if len(nodes) > limits.maxNodes:
697
+ return _fail("count", f"snapshot carries {len(nodes)} nodes, ceiling is {limits.maxNodes}")
698
+
699
+ by_id: Dict[str, Dict[str, Any]] = {}
700
+ for node in nodes:
701
+ if node["id"] in by_id:
702
+ return _fail("duplicate-id", f"node id {node['id']} appears more than once")
703
+ by_id[node["id"]] = node
704
+
705
+ root_ids: Set[str] = set()
706
+ for node_id in snapshot["rootIds"]:
707
+ if node_id in root_ids:
708
+ return _fail("duplicate-id", f"root id {node_id} appears more than once")
709
+ root_ids.add(node_id)
710
+ node = by_id.get(node_id)
711
+ if node is None:
712
+ return _fail("missing-parent", f"rootIds references unknown node {node_id}")
713
+ if node.get("parentId") is not None:
714
+ return _fail("schema", f"root node {node_id} declares a parent")
715
+
716
+ ids = set(by_id)
717
+
718
+ if snapshot["v"] == 2 and snapshot["hitGrid"]["status"] == "known":
719
+ for region in snapshot["hitGrid"]["value"]["regions"]:
720
+ recipient_id = region["recipientId"]
721
+ if recipient_id not in ids:
722
+ return _fail(
723
+ "missing-parent", f"hitGrid references unknown recipient {recipient_id}"
724
+ )
725
+ if not _rect_intersects_viewport(
726
+ region["rect"], snapshot["columns"], snapshot["rows"]
727
+ ):
728
+ return _fail(
729
+ "bad-rect",
730
+ f"hitGrid region for {recipient_id} does not intersect the viewport",
731
+ )
732
+
733
+ for node in nodes:
734
+ parent_id = node.get("parentId")
735
+ if parent_id is None:
736
+ if node["id"] not in root_ids:
737
+ return _fail("schema", f"parentless node {node['id']} is missing from rootIds")
738
+ elif parent_id not in by_id:
739
+ return _fail("missing-parent", f"node {node['id']} references unknown parent {parent_id}")
740
+ elif parent_id == node["id"]:
741
+ return _fail("cycle", f"node {node['id']} is its own parent")
742
+
743
+ problem = _check_node_shape(node, snapshot, ids, limits)
744
+ if problem is not None:
745
+ return problem
746
+
747
+ depths, cycle_at = _compute_depths(nodes, by_id)
748
+ if cycle_at is not None:
749
+ return _fail("cycle", f"parent chain through node {cycle_at} is cyclic")
750
+ assert depths is not None
751
+ for node_id, depth in depths.items():
752
+ if depth > limits.maxDepth:
753
+ return _fail("depth", f"node {node_id} sits at depth {depth}, ceiling is {limits.maxDepth}")
754
+
755
+ cursor = snapshot.get("cursor")
756
+ if cursor is not None:
757
+ if cursor["row"] >= snapshot["rows"] or cursor["column"] >= snapshot["columns"]:
758
+ return _fail(
759
+ "bad-rect", f"cursor ({cursor['row']}, {cursor['column']}) lies outside the viewport"
760
+ )
761
+
762
+ return ValidationResult(ok=True, snapshot=snapshot)
763
+
764
+
765
+ def apply_tree_delta(
766
+ base: Mapping[str, Any], delta: Mapping[str, Any], limits: ProtocolLimits = DEFAULT_LIMITS
767
+ ) -> ValidationResult:
768
+ """Compose a delta onto the snapshot it names, then validate the result.
769
+
770
+ The four composition rules, in the order they are applied:
771
+
772
+ 1. ``removed`` takes each id **with its whole subtree**. The cascade is
773
+ what keeps a delta small — dropping a dialog is one id, not one per
774
+ descendant — and it is the only rule that leaves no orphans behind.
775
+ 2. Removals happen **before** upserts, so one delta can move a node out of
776
+ a subtree it is deleting.
777
+ 3. ``changed`` upserts by id, **replacing a node wholesale**. Merging would
778
+ need a third state meaning "clear this optional field", which the wire
779
+ cannot express.
780
+ 4. ``rootIds`` present replaces the list; absent inherits the base's minus
781
+ whatever the removals took. Adding a new root therefore *requires*
782
+ sending ``rootIds`` — otherwise the parentless node is missing from the
783
+ root list and validation says so, loudly.
784
+
785
+ An absent ``cursor`` is inherited; there is no way to remove one, and none
786
+ is needed, because hiding it is ``visible: false``.
787
+
788
+ A base that disagrees is reported rather than patched around: the caller
789
+ asks for a full snapshot instead of guessing (§8.3).
790
+
791
+ The composed tree then goes through :func:`validate_snapshot`, because a
792
+ delta is trusted to *describe* a valid tree, never to produce one.
793
+ """
794
+ if delta.get("baseRevision") != base.get("revision"):
795
+ return _fail(
796
+ "revision",
797
+ f"delta is based on revision {delta.get('baseRevision')} but the held snapshot "
798
+ f"is revision {base.get('revision')}; request a full snapshot instead of patching",
799
+ )
800
+
801
+ by_id: Dict[str, Any] = {node["id"]: node for node in base["nodes"]}
802
+
803
+ children_of: Dict[str, List[str]] = {}
804
+ for node in base["nodes"]:
805
+ parent = node.get("parentId")
806
+ if parent is not None:
807
+ children_of.setdefault(parent, []).append(node["id"])
808
+
809
+ for node_id in delta["removed"]:
810
+ if node_id not in by_id:
811
+ return _fail(
812
+ "missing-parent",
813
+ f"delta removes unknown node {node_id}; the producer's base disagrees with "
814
+ "ours, so the tree must be resynchronised rather than patched",
815
+ )
816
+ # Iterative descent: a hostile delta must not be able to blow the stack.
817
+ pending = [node_id]
818
+ while pending:
819
+ current = pending.pop()
820
+ if by_id.pop(current, None) is None:
821
+ continue
822
+ pending.extend(children_of.get(current, ()))
823
+
824
+ for node in delta["changed"]:
825
+ by_id[node["id"]] = node
826
+
827
+ if "rootIds" in delta:
828
+ root_ids = list(delta["rootIds"])
829
+ else:
830
+ root_ids = [node_id for node_id in base["rootIds"] if node_id in by_id]
831
+
832
+ cursor = delta.get("cursor", base.get("cursor"))
833
+
834
+ composed: Dict[str, Any] = {
835
+ "v": 1,
836
+ "sessionId": base["sessionId"],
837
+ "revision": delta["revision"],
838
+ "columns": base["columns"],
839
+ "rows": base["rows"],
840
+ }
841
+ if cursor is not None:
842
+ composed["cursor"] = cursor
843
+ composed["rootIds"] = root_ids
844
+ composed["nodes"] = list(by_id.values())
845
+
846
+ return validate_snapshot(composed, limits)