capability-compiler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. capability_compiler/__init__.py +76 -0
  2. capability_compiler/_version.py +8 -0
  3. capability_compiler/adapters/__init__.py +70 -0
  4. capability_compiler/adapters/base.py +166 -0
  5. capability_compiler/adapters/browser/__init__.py +7 -0
  6. capability_compiler/adapters/browser/adapter.py +474 -0
  7. capability_compiler/adapters/browser/aria.py +147 -0
  8. capability_compiler/adapters/browser/elements.py +185 -0
  9. capability_compiler/adapters/desktop/__init__.py +9 -0
  10. capability_compiler/adapters/desktop/base.py +90 -0
  11. capability_compiler/adapters/fake_adapter.py +242 -0
  12. capability_compiler/benchmark/__init__.py +42 -0
  13. capability_compiler/benchmark/data/capability_bench.json +81 -0
  14. capability_compiler/benchmark/data/seed.json +148 -0
  15. capability_compiler/benchmark/report.py +164 -0
  16. capability_compiler/benchmark/spec.py +249 -0
  17. capability_compiler/benchmark/suite.py +396 -0
  18. capability_compiler/cli/__init__.py +5 -0
  19. capability_compiler/cli/_common.py +135 -0
  20. capability_compiler/cli/_register.py +35 -0
  21. capability_compiler/cli/benchmark_cmd.py +284 -0
  22. capability_compiler/cli/config_cmds.py +230 -0
  23. capability_compiler/cli/execute_cmd.py +196 -0
  24. capability_compiler/cli/learn_cmd.py +134 -0
  25. capability_compiler/cli/main.py +184 -0
  26. capability_compiler/cli/registry_cmds.py +127 -0
  27. capability_compiler/cli/serve_cmd.py +124 -0
  28. capability_compiler/compiler.py +203 -0
  29. capability_compiler/config.py +282 -0
  30. capability_compiler/errors.py +348 -0
  31. capability_compiler/exploration/__init__.py +33 -0
  32. capability_compiler/exploration/effects.py +227 -0
  33. capability_compiler/exploration/engine.py +347 -0
  34. capability_compiler/exploration/semantics.py +257 -0
  35. capability_compiler/logging.py +228 -0
  36. capability_compiler/models/__init__.py +127 -0
  37. capability_compiler/models/action.py +216 -0
  38. capability_compiler/models/capability.py +389 -0
  39. capability_compiler/models/observation.py +281 -0
  40. capability_compiler/models/state.py +49 -0
  41. capability_compiler/models/transition.py +124 -0
  42. capability_compiler/models/verification.py +85 -0
  43. capability_compiler/models/versioning.py +77 -0
  44. capability_compiler/perception/__init__.py +22 -0
  45. capability_compiler/perception/pipeline.py +70 -0
  46. capability_compiler/perception/semantic.py +245 -0
  47. capability_compiler/providers/__init__.py +60 -0
  48. capability_compiler/providers/anthropic.py +132 -0
  49. capability_compiler/providers/base.py +185 -0
  50. capability_compiler/providers/http.py +216 -0
  51. capability_compiler/providers/mock.py +158 -0
  52. capability_compiler/providers/ollama.py +124 -0
  53. capability_compiler/providers/openai_compat.py +153 -0
  54. capability_compiler/recording/__init__.py +6 -0
  55. capability_compiler/recording/recorder.py +255 -0
  56. capability_compiler/recording/replay.py +174 -0
  57. capability_compiler/refinement/__init__.py +11 -0
  58. capability_compiler/refinement/engine.py +188 -0
  59. capability_compiler/refinement/repair.py +254 -0
  60. capability_compiler/registry/__init__.py +24 -0
  61. capability_compiler/registry/permissions.py +191 -0
  62. capability_compiler/registry/registry.py +251 -0
  63. capability_compiler/runtime/__init__.py +17 -0
  64. capability_compiler/runtime/assertions.py +114 -0
  65. capability_compiler/runtime/executor.py +422 -0
  66. capability_compiler/server/__init__.py +114 -0
  67. capability_compiler/server/auth.py +141 -0
  68. capability_compiler/server/executor_factory.py +75 -0
  69. capability_compiler/server/server.py +555 -0
  70. capability_compiler/server/tool_defs.py +98 -0
  71. capability_compiler/storage/__init__.py +7 -0
  72. capability_compiler/storage/base.py +62 -0
  73. capability_compiler/storage/filesystem.py +236 -0
  74. capability_compiler/storage/sqlite.py +284 -0
  75. capability_compiler/synthesis/__init__.py +20 -0
  76. capability_compiler/synthesis/engine.py +549 -0
  77. capability_compiler/synthesis/templating.py +84 -0
  78. capability_compiler/types.py +97 -0
  79. capability_compiler/verification/__init__.py +42 -0
  80. capability_compiler/verification/base.py +97 -0
  81. capability_compiler/verification/file_visual.py +224 -0
  82. capability_compiler/verification/runner.py +79 -0
  83. capability_compiler/verification/verifiers.py +292 -0
  84. capability_compiler-0.1.0.dist-info/METADATA +261 -0
  85. capability_compiler-0.1.0.dist-info/RECORD +88 -0
  86. capability_compiler-0.1.0.dist-info/WHEEL +4 -0
  87. capability_compiler-0.1.0.dist-info/entry_points.txt +2 -0
  88. capability_compiler-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,474 @@
1
+ """The Playwright browser adapter — Capability Compiler's first adapter.
2
+
3
+ Wraps (never reimplements) Playwright's async API. Responsibilities:
4
+
5
+ * **Observe**: one normalized :class:`Observation` per call — aria snapshot
6
+ tree (state identity), DOM-scan interactive elements with geometry
7
+ (actionability), screenshot artifact, URL/title/DOM hash.
8
+ * **Execute**: map domain actions to Playwright calls through semantic-first
9
+ target resolution (ref → role+name+ordinal → selector → coordinates),
10
+ letting Playwright's five actionability checks do the waiting.
11
+ * **Ref stability**: ``e1..eN`` ids are positional within an observation;
12
+ execution always re-resolves against a fresh observation, so recorded
13
+ capabilities survive DOM churn better than raw handles.
14
+
15
+ Security:
16
+ * Navigation is **deny-by-default for the network**: with
17
+ ``security.allow_network = false`` (the default) only ``file:``/``data:``
18
+ ``about:`` and loopback URLs are permitted. Remote navigation requires the
19
+ explicit opt-in.
20
+ * Page content (names, DOM, screenshots) is untrusted data. Nothing from the
21
+ page is ever evaluated as code by this adapter.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import hashlib
28
+ import time
29
+ from typing import TYPE_CHECKING, Any, cast
30
+ from urllib.parse import urlparse
31
+
32
+ from capability_compiler.adapters.browser.aria import parse_aria_snapshot
33
+ from capability_compiler.adapters.browser.elements import _SCAN_JS, parse_scan
34
+ from capability_compiler.errors import (
35
+ ActionError,
36
+ ActionValidationError,
37
+ AdapterError,
38
+ AdapterNotConnectedError,
39
+ FailureCategory,
40
+ ObservationError,
41
+ PermissionDeniedError,
42
+ )
43
+ from capability_compiler.logging import StructuredLogAdapter, get_logger
44
+ from capability_compiler.models import (
45
+ ActionResult,
46
+ EnvironmentMetadata,
47
+ ImageData,
48
+ Observation,
49
+ StructuralObservation,
50
+ )
51
+ from capability_compiler.models.action import (
52
+ ClickAction,
53
+ HoverAction,
54
+ KeyPressAction,
55
+ NavigateAction,
56
+ ScrollAction,
57
+ SelectAction,
58
+ TypeAction,
59
+ WaitAction,
60
+ )
61
+ from capability_compiler.types import new_id
62
+
63
+ if TYPE_CHECKING:
64
+ from capability_compiler.config import CompilerSettings
65
+ from capability_compiler.models import Action
66
+ from capability_compiler.models.action import ActionTarget
67
+
68
+ from playwright.async_api import Error as PlaywrightError
69
+ from playwright.async_api import Page, async_playwright
70
+ from playwright.async_api import TimeoutError as PlaywrightTimeoutError
71
+
72
+
73
+ def _text_digest(text: str | None) -> str | None:
74
+ """Whitespace-normalized digest of visible page text (deterministic)."""
75
+ if text is None:
76
+ return None
77
+ normalized = " ".join(text.split())
78
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
79
+
80
+
81
+ _LOCAL_SCHEMES = {"file", "data", "about", "blob"}
82
+ _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "[::1]", "::1"}
83
+
84
+
85
+ def url_allowed(url: str, *, allow_network: bool) -> bool:
86
+ """Navigation policy: local schemes always; loopback always; anything
87
+ else requires ``allow_network``. Unparseable/unknown-scheme URLs deny."""
88
+ try:
89
+ parsed = urlparse(url)
90
+ except ValueError:
91
+ return False
92
+ scheme = (parsed.scheme or "").lower()
93
+ if not scheme:
94
+ return False
95
+ if scheme in _LOCAL_SCHEMES:
96
+ return True
97
+ if scheme in {"http", "https"}:
98
+ if parsed.hostname and parsed.hostname.lower() in _LOOPBACK_HOSTS:
99
+ return True
100
+ return allow_network
101
+ return False
102
+
103
+
104
+ class BrowserAdapter:
105
+ """Playwright-backed ``EnvironmentAdapter`` (kind ``"browser"``)."""
106
+
107
+ kind = "browser"
108
+
109
+ def __init__(self, settings: CompilerSettings | None = None) -> None:
110
+ from capability_compiler.config import CompilerSettings as _Settings
111
+
112
+ self.settings = settings or _Settings()
113
+ self._log = StructuredLogAdapter(get_logger("adapters.browser"), {})
114
+ self._playwright: Any = None
115
+ self._browser: Any = None
116
+ self._context: Any = None
117
+ self._page: Page | None = None
118
+ self._start_url: str | None = None
119
+ self._last_observation: Observation | None = None
120
+
121
+ # -- properties --------------------------------------------------------
122
+
123
+ @property
124
+ def connected(self) -> bool:
125
+ return self._browser is not None and self._browser.is_connected()
126
+
127
+ @property
128
+ def page(self) -> Page:
129
+ if self._page is None:
130
+ raise AdapterNotConnectedError("browser adapter has no page")
131
+ return self._page
132
+
133
+ # -- lifecycle ----------------------------------------------------------
134
+
135
+ async def connect(self, url: str | None = None) -> None:
136
+ """Launch the browser; optionally navigate to *url*."""
137
+ if self.connected:
138
+ return
139
+ browser_cfg = self.settings.browser
140
+ try:
141
+ self._playwright = await async_playwright().start()
142
+ launch_kwargs: dict[str, Any] = {
143
+ "headless": browser_cfg.headless,
144
+ "slow_mo": browser_cfg.slow_mo_ms or None,
145
+ }
146
+ if browser_cfg.extra_launch_args:
147
+ launch_kwargs["args"] = list(browser_cfg.extra_launch_args)
148
+ self._browser = await getattr(self._playwright, browser_cfg.browser).launch(
149
+ **launch_kwargs
150
+ )
151
+ context_kwargs: dict[str, Any] = {
152
+ "viewport": {
153
+ "width": browser_cfg.viewport_width,
154
+ "height": browser_cfg.viewport_height,
155
+ },
156
+ }
157
+ if browser_cfg.user_agent:
158
+ context_kwargs["user_agent"] = browser_cfg.user_agent
159
+ self._context = await self._browser.new_context(**context_kwargs)
160
+ self._context.set_default_timeout(browser_cfg.default_timeout_ms)
161
+ self._page = await self._context.new_page()
162
+ except PlaywrightError as exc:
163
+ raise AdapterError(
164
+ f"failed to launch {browser_cfg.browser}: {exc}",
165
+ category=FailureCategory.ENVIRONMENT_ERROR,
166
+ suggestions=["run: python -m playwright install " + browser_cfg.browser],
167
+ ) from exc
168
+ target = url or "about:blank"
169
+ await self._goto(target)
170
+ self._start_url = target
171
+
172
+ async def disconnect(self) -> None:
173
+ for closer in (
174
+ getattr(self._context, "close", None),
175
+ getattr(self._browser, "close", None),
176
+ getattr(self._playwright, "stop", None),
177
+ ):
178
+ if closer is not None:
179
+ with contextlib.suppress(PlaywrightError): # teardown races
180
+ await closer()
181
+ self._page = None
182
+ self._context = None
183
+ self._browser = None
184
+ self._playwright = None
185
+
186
+ async def reset(self) -> None:
187
+ """Return to the start URL (best effort; pages with side effects may
188
+ not fully reset — capability preconditions are the real guard)."""
189
+ if self._start_url:
190
+ await self._goto(self._start_url)
191
+
192
+ # -- observation ---------------------------------------------------------
193
+
194
+ async def observe(self, *, screenshot: bool = True) -> Observation:
195
+ """Capture one normalized observation of the current page."""
196
+ page = self.page
197
+ try:
198
+ snapshot_yaml = await page.aria_snapshot()
199
+ raw_elements = await page.evaluate(_SCAN_JS)
200
+ url = page.url
201
+ title = await page.title()
202
+ content = await page.content()
203
+ visible_text = await page.evaluate("() => document.body.innerText")
204
+ except PlaywrightError as exc:
205
+ raise ObservationError(
206
+ f"failed to observe page: {exc}",
207
+ category=FailureCategory.ENVIRONMENT_ERROR,
208
+ ) from exc
209
+
210
+ visual = None
211
+ if screenshot:
212
+ visual = await self._capture_screenshot()
213
+
214
+ observation = Observation(
215
+ observation_id=new_id("obs"),
216
+ sequence_number=self._next_sequence(),
217
+ visual=visual,
218
+ structural=StructuralObservation(
219
+ url=url,
220
+ title=title,
221
+ dom_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
222
+ text_digest=_text_digest(visible_text),
223
+ viewport_width=self.settings.browser.viewport_width,
224
+ viewport_height=self.settings.browser.viewport_height,
225
+ ),
226
+ accessibility_tree=parse_aria_snapshot(snapshot_yaml),
227
+ interactive_elements=parse_scan(raw_elements),
228
+ environment=EnvironmentMetadata(
229
+ adapter_kind=self.kind,
230
+ adapter_version="playwright",
231
+ extra={"browser": self.settings.browser.browser},
232
+ ),
233
+ )
234
+ self._last_observation = observation
235
+ return observation
236
+
237
+ async def _capture_screenshot(self) -> ImageData | None:
238
+ page = self.page
239
+ try:
240
+ png = await page.screenshot(full_page=False)
241
+ except PlaywrightError as exc:
242
+ self._log.warning(
243
+ "screenshot.failed", extra={"extra_fields": {"error": str(exc)[:120]}}
244
+ )
245
+ return None
246
+ path: str | None = None
247
+ artifacts = self.settings.storage.artifacts_dir
248
+ try:
249
+ artifacts.mkdir(parents=True, exist_ok=True)
250
+ target = artifacts / f"{new_id('shot')}.png"
251
+ target.write_bytes(png)
252
+ path = str(target)
253
+ except OSError:
254
+ path = None
255
+ return ImageData.from_bytes(
256
+ png,
257
+ width=self.settings.browser.viewport_width,
258
+ height=self.settings.browser.viewport_height,
259
+ path=path,
260
+ )
261
+
262
+ def _next_sequence(self) -> int:
263
+ return (self._last_observation.sequence_number + 1) if self._last_observation else 0
264
+
265
+ # -- execution ------------------------------------------------------------
266
+
267
+ async def execute(self, action: Action) -> ActionResult:
268
+ """Apply *action*; action-level failures are returned, not raised."""
269
+ if not self.connected or self._page is None:
270
+ raise AdapterNotConnectedError("browser adapter is not connected")
271
+ started = time.perf_counter()
272
+ try:
273
+ await self._dispatch(action)
274
+ duration_ms = (time.perf_counter() - started) * 1000.0
275
+ return ActionResult(
276
+ action_id=action.action_id,
277
+ success=True,
278
+ duration_ms=duration_ms,
279
+ adapter_details={"browser": self.settings.browser.browser},
280
+ )
281
+ except PlaywrightTimeoutError as exc:
282
+ return ActionResult.failure(
283
+ action.action_id,
284
+ FailureCategory.TIMEOUT,
285
+ "action.timeout",
286
+ f"action timed out: {str(exc).splitlines()[0][:200]}",
287
+ )
288
+ except (ActionError, ActionValidationError, PermissionDeniedError) as exc:
289
+ return ActionResult.failure(
290
+ action.action_id,
291
+ exc.category,
292
+ exc.code,
293
+ exc.message,
294
+ )
295
+ except PlaywrightError as exc:
296
+ return ActionResult.failure(
297
+ action.action_id,
298
+ FailureCategory.ACTION_FAILED,
299
+ "action.playwright_error",
300
+ str(exc).splitlines()[0][:300],
301
+ )
302
+
303
+ async def _dispatch(self, action: Action) -> None:
304
+ import asyncio
305
+
306
+ if isinstance(action, ClickAction):
307
+ locator = await self.resolve_target(action.target, role_hint="button")
308
+ if action.double:
309
+ await locator.dblclick(button=action.button)
310
+ else:
311
+ await locator.click(button=action.button)
312
+ elif isinstance(action, TypeAction):
313
+ locator = await self.resolve_target(action.target)
314
+ await locator.fill(action.text)
315
+ if action.submit:
316
+ await locator.press("Enter")
317
+ elif isinstance(action, SelectAction):
318
+ locator = await self.resolve_target(action.target)
319
+ await locator.select_option(action.value)
320
+ elif isinstance(action, ScrollAction):
321
+ dy = {"up": -action.amount_px, "down": action.amount_px}.get(action.direction, 0)
322
+ dx = {"left": -action.amount_px, "right": action.amount_px}.get(action.direction, 0)
323
+ if action.target is not None:
324
+ locator = await self.resolve_target(action.target)
325
+ await locator.scroll_into_view_if_needed()
326
+ await self.page.mouse.wheel(dx, dy)
327
+ elif isinstance(action, HoverAction):
328
+ locator = await self.resolve_target(action.target)
329
+ await locator.hover()
330
+ elif isinstance(action, KeyPressAction):
331
+ await self.page.keyboard.press(action.key)
332
+ if action.hold_ms:
333
+ await asyncio.sleep(action.hold_ms / 1000)
334
+ elif isinstance(action, NavigateAction):
335
+ await self._goto(action.url)
336
+ elif isinstance(action, WaitAction):
337
+ await self.page.wait_for_timeout(action.milliseconds)
338
+ else: # pragma: no cover - discriminated union is closed
339
+ raise ActionValidationError(
340
+ f"unsupported action kind: {type(action).__name__}",
341
+ context={"kind": getattr(action, "kind", "?")},
342
+ )
343
+
344
+ async def _goto(self, url: str) -> None:
345
+ if not url_allowed(url, allow_network=self.settings.security.allow_network):
346
+ raise PermissionDeniedError(
347
+ f"navigation to {url!r} requires allow_network=true",
348
+ context={"url": url},
349
+ suggestions=[
350
+ "cc config: set security.allow_network=true",
351
+ "or use a file:/// URL for local content",
352
+ ],
353
+ )
354
+ try:
355
+ await self.page.goto(url, wait_until="domcontentloaded")
356
+ except PlaywrightError as exc:
357
+ raise AdapterError(
358
+ f"navigation failed: {str(exc).splitlines()[0][:200]}",
359
+ category=FailureCategory.ENVIRONMENT_ERROR,
360
+ ) from exc
361
+
362
+ # -- target resolution ------------------------------------------------------
363
+
364
+ async def resolve_target(
365
+ self,
366
+ target: ActionTarget | None,
367
+ *,
368
+ role_hint: str | None = None,
369
+ _retried: bool = False,
370
+ ) -> Any:
371
+ """Resolve a semantic target to a Playwright locator.
372
+
373
+ Order: observation ``ref`` (role+name+ordinal) → explicit role+name →
374
+ selector → coordinates. When a ``ref`` misses the cached observation
375
+ (stale page, first action after navigation), the observation is
376
+ refreshed once and the lookup retried — the seed of Phase-6
377
+ re-anchoring: refs are positional, roles+names are semantic.
378
+ """
379
+ if target is None:
380
+ raise ActionValidationError("action has no target")
381
+ page = self.page
382
+
383
+ if target.ref:
384
+ element = (
385
+ self._last_observation.find_element(target.ref)
386
+ if self._last_observation is not None
387
+ else None
388
+ )
389
+ if element is None and not _retried:
390
+ # No cached observation, or it is stale (navigation since).
391
+ await self.observe(screenshot=False)
392
+ return await self.resolve_target(target, role_hint=role_hint, _retried=True)
393
+ if element is not None and self._ref_agrees(target, element):
394
+ locator = self._locator_for_element(page, element)
395
+ if locator is not None:
396
+ return locator
397
+ # fall through when the ref is stale OR disagrees with the
398
+ # recorded semantics (drifted DOM renumbered the positional
399
+ # refs) — role+name below re-anchors to the right element.
400
+
401
+ if target.role or target.name:
402
+ role = str(target.role or role_hint or "button")
403
+ locator = page.get_by_role(cast("Any", role), name=target.name or "")
404
+ return locator.first if not target.name else locator
405
+
406
+ if target.selector:
407
+ return page.locator(target.selector).first
408
+
409
+ if target.coordinates is not None:
410
+ return page.locator("body") # caller uses coordinates via mouse
411
+
412
+ raise ActionValidationError(
413
+ "target cannot be resolved",
414
+ context={"semantic_key": target.semantic_key()},
415
+ )
416
+
417
+ @staticmethod
418
+ def _ref_agrees(target: ActionTarget, element: Any) -> bool:
419
+ """True when the cached element for a ref matches the recorded
420
+ semantics. Recorded capabilities carry role+name alongside the ref;
421
+ when they disagree the DOM has drifted and the ref must not be
422
+ trusted (it now points at a different element)."""
423
+ if not target.role and not target.name:
424
+ return True # ref-only targeting: nothing to cross-check
425
+ if target.role and element.role.lower() != target.role.lower():
426
+ return False
427
+ return not (target.name and target.name.lower() not in (element.name or "").lower())
428
+
429
+ def _locator_for_element(self, page: Page, element: Any) -> Any:
430
+ """Build a locator for a scanned element, disambiguated by ordinal."""
431
+ assert self._last_observation is not None
432
+ elements = self._last_observation.interactive_elements
433
+ same = [e for e in elements if e.role == element.role and e.name == element.name]
434
+ ordinal = same.index(element)
435
+ test_id = element.attributes.get("data-testid")
436
+ if test_id:
437
+ base = page.get_by_test_id(test_id)
438
+ if len(same) == 1:
439
+ return base
440
+ if element.name:
441
+ locator = page.get_by_role(cast("Any", element.role), name=element.name, exact=True)
442
+ if len(same) > 1:
443
+ locator = locator.nth(ordinal)
444
+ return locator
445
+ if element.attributes.get("id"):
446
+ return page.locator(f"[id='{element.attributes['id']}']")
447
+ if test_id:
448
+ return (
449
+ page.get_by_test_id(test_id).nth(ordinal)
450
+ if len(same) > 1
451
+ else page.get_by_test_id(test_id)
452
+ )
453
+ # Nameless element: tag + ordinal among same-tag elements.
454
+ tag = element.tag or element.role
455
+ same_tag = [e for e in elements if (e.tag or e.role) == tag]
456
+ return page.locator(tag).nth(same_tag.index(element))
457
+
458
+ def capabilities_description(self) -> list[str]:
459
+ return ["click", "type", "select", "scroll", "hover", "key_press", "navigate", "wait"]
460
+
461
+
462
+ def register() -> None:
463
+ """Register the browser adapter (idempotent)."""
464
+ from capability_compiler.adapters.base import available_adapters, register_adapter
465
+
466
+ if "browser" not in available_adapters():
467
+ register_adapter(
468
+ "browser",
469
+ BrowserAdapter,
470
+ description="Playwright browser adapter (chromium/firefox/webkit)",
471
+ )
472
+
473
+
474
+ __all__ = ["BrowserAdapter", "register", "url_allowed"]
@@ -0,0 +1,147 @@
1
+ """Parser for Playwright's ``aria_snapshot()`` YAML format.
2
+
3
+ Empirically validated against Playwright 1.62 (2026-09-01). The format is
4
+ line-oriented YAML, two-space indentation per level::
5
+
6
+ - heading "Documents" [level=1]
7
+ - button "Export"
8
+ - textbox "Search":
9
+ - /placeholder: Search docs
10
+ - combobox:
11
+ - option "PDF" [selected]
12
+ - option "PNG"
13
+
14
+ Grammar per line::
15
+
16
+ "- " <role> [ '"' <name> '"' ] [ '[' <attr>* ']' ] [ ':' [ ' ' <text> ] ]
17
+
18
+ where ``attr`` is ``key=value`` or a bare flag (``selected``, ``disabled``,
19
+ ``checked``, ``expanded``, ``invalid``, ``level=N``, ``pressed``). Children
20
+ beginning with ``/`` are pseudo-attributes (``/url``, ``/placeholder``).
21
+
22
+ The parser is dependency-free and deterministic: same input → identical
23
+ :class:`~capability_compiler.models.AccessibilityNode` tree. It never
24
+ executes or evaluates anything from the snapshot (page content is untrusted
25
+ data — see docs/security).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+
32
+ from capability_compiler.errors import ObservationError
33
+ from capability_compiler.models import AccessibilityNode
34
+
35
+ _LINE_RE = re.compile(r"""^(?P<prefix>[-\s]*)- (?P<rest>.*)$""")
36
+ _ATTR_RE = re.compile(r"\[([^\]]*)\]")
37
+
38
+
39
+ def parse_aria_snapshot(snapshot_yaml: str) -> AccessibilityNode:
40
+ """Parse an aria snapshot into a normalized tree (virtual root included).
41
+
42
+ The returned root node has ``role="root"``; children are the top-level
43
+ page nodes in document order.
44
+
45
+ Raises:
46
+ ObservationError: on structurally invalid input (never on merely
47
+ unknown roles or attributes — forward compatible).
48
+ """
49
+ root = AccessibilityNode(role="root", name="")
50
+ # Stack of (indent_level, node). Root sits at level -1 conceptually.
51
+ stack: list[tuple[int, AccessibilityNode]] = [(-1, root)]
52
+ for line_number, raw_line in enumerate(snapshot_yaml.splitlines(), start=1):
53
+ if not raw_line.strip():
54
+ continue
55
+ match = _LINE_RE.match(raw_line)
56
+ if not match or not raw_line.lstrip().startswith("-"):
57
+ msg = f"unparseable aria snapshot line {line_number}: {raw_line[:80]!r}"
58
+ raise ObservationError(msg, context={"line": line_number})
59
+ indent = len(match.group("prefix").replace("-", "", 1))
60
+ if indent % 2 != 0:
61
+ msg = f"odd indentation on line {line_number}"
62
+ raise ObservationError(msg, context={"line": line_number})
63
+ level = indent // 2
64
+
65
+ node, parent = _parse_node(match.group("rest"), line_number)
66
+ while stack and stack[-1][0] >= level:
67
+ stack.pop()
68
+ if not stack:
69
+ msg = f"indentation skips a level on line {line_number}"
70
+ raise ObservationError(msg, context={"line": line_number})
71
+ parent = stack[-1][1]
72
+ parent.children.append(node)
73
+ stack.append((level, node))
74
+ return root
75
+
76
+
77
+ def _parse_node(rest: str, line_number: int) -> tuple[AccessibilityNode, AccessibilityNode]:
78
+ """Parse the text after ``- `` into a node; returns (node, dummy_parent)."""
79
+ text_after_colon: str | None = None
80
+ if ":" in rest:
81
+ # The colon separates node header from its text content (or a
82
+ # child-bearing marker when nothing follows).
83
+ head, _, tail = rest.partition(":")
84
+ text_after_colon = tail.strip() or None
85
+ rest = head.rstrip()
86
+
87
+ role = rest
88
+ name = ""
89
+ properties: dict[str, object] = {}
90
+
91
+ # Quoted name after the role.
92
+ name_match = re.search(r' "([^"]*)"', rest)
93
+ if name_match:
94
+ name = name_match.group(1)
95
+ role = (rest[: name_match.start()] + rest[name_match.end() :]).strip()
96
+
97
+ # Bracketed attributes (may be several: [level=1][disabled] is unusual
98
+ # but legal; Playwright emits combined [a] [b] forms).
99
+ attr_blob = " ".join(_ATTR_RE.findall(role) or [])
100
+ role = _ATTR_RE.sub(" ", role).strip()
101
+ for part in attr_blob.split():
102
+ if "=" in part:
103
+ key, _, value = part.partition("=")
104
+ properties[key] = _coerce(value)
105
+ else:
106
+ properties[part] = True
107
+
108
+ if not role:
109
+ msg = f"missing role on line {line_number}"
110
+ raise ObservationError(msg, context={"line": line_number})
111
+
112
+ if text_after_colon is not None and "text" not in properties:
113
+ properties["text"] = text_after_colon
114
+
115
+ node = AccessibilityNode(role=role, name=name, properties=properties)
116
+ return node, node
117
+
118
+
119
+ def _coerce(value: str) -> object:
120
+ if value.lower() in {"true", "false"}:
121
+ return value.lower() == "true"
122
+ try:
123
+ return int(value)
124
+ except ValueError:
125
+ return value
126
+
127
+
128
+ def format_node_line(node: AccessibilityNode) -> str:
129
+ """Render one node back to snapshot-line form (debugging/tests only)."""
130
+ parts = [node.role]
131
+ if node.name:
132
+ parts.append(f'"{node.name}"')
133
+ flags = []
134
+ for key, value in sorted(node.properties.items()):
135
+ if value is True:
136
+ flags.append(key)
137
+ elif value is not False and key != "text":
138
+ flags.append(f"{key}={value}")
139
+ line = "- " + " ".join(parts)
140
+ if flags:
141
+ line += " [" + " ".join(flags) + "]"
142
+ if node.properties.get("text"):
143
+ line += f": {node.properties['text']}"
144
+ return line
145
+
146
+
147
+ __all__ = ["format_node_line", "parse_aria_snapshot"]