design-playbook 0.20.2 → 0.21.0
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.
- package/codex/AGENTS.md +4 -2
- package/commands/doctor.md +2 -1
- package/commands/ux-spec.md +1 -1
- package/examples/reference-intake/product-analogy/manifest.json +3 -0
- package/examples/reference-intake/screenshot/manifest.json +4 -0
- package/examples/reference-intake/url/manifest.json +3 -0
- package/mcp/evidence/capture_runtime.py +223 -21
- package/mcp/evidence/containment.py +65 -24
- package/mcp/evidence/disclosure.py +285 -0
- package/mcp/evidence/handoff.py +838 -0
- package/mcp/evidence/static_handoff_page.html +114 -0
- package/mcp/evidence/test_containment.py +217 -65
- package/mcp/evidence/test_delivery_matrix.py +159 -0
- package/mcp/evidence/test_disclosure.py +309 -0
- package/mcp/evidence/test_handoff.py +512 -0
- package/mcp/evidence/test_ledger_syntax.py +0 -1
- package/mcp/preview/compatibility.py +7 -3
- package/mcp/preview/control.css +602 -534
- package/mcp/preview/control.html +222 -65
- package/mcp/preview/control.js +639 -820
- package/mcp/preview/control.py +137 -47
- package/mcp/preview/control.review.js +515 -0
- package/mcp/preview/i18n.py +142 -8
- package/mcp/preview/integrity.py +17 -2
- package/mcp/preview/owned_browser.py +278 -0
- package/mcp/preview/pin_bridge.py +450 -0
- package/mcp/preview/review_session.py +120 -423
- package/mcp/preview/test_browser_control.py +185 -70
- package/mcp/preview/test_i18n_labels.py +71 -0
- package/mcp/preview/test_transaction.py +49 -12
- package/mcp/preview/transaction.py +25 -10
- package/mcp/preview/versions.py +3 -2
- package/mcp/run_console/__init__.py +17 -0
- package/mcp/run_console/actions.py +149 -0
- package/mcp/run_console/app.css +1230 -0
- package/mcp/run_console/app.html +154 -0
- package/mcp/run_console/app.js +1720 -0
- package/mcp/run_console/contract.py +430 -0
- package/mcp/run_console/fixtures/evidence-artifact.html +11 -0
- package/mcp/run_console/fixtures/plan-profile.md +11 -0
- package/mcp/run_console/fixtures/point-back-pass-closed.md +43 -0
- package/mcp/run_console/fixtures/point-back-recirculate.md +49 -0
- package/mcp/run_console/fixtures/point-back-repeated-verdict.md +88 -0
- package/mcp/run_console/fixtures/point-back-unaudited.md +56 -0
- package/mcp/run_console/fixtures/spec-script-summary.md +48 -0
- package/mcp/run_console/http_server.py +633 -0
- package/mcp/run_console/projection.py +155 -0
- package/mcp/run_console/request_security.py +191 -0
- package/mcp/run_console/session.py +247 -0
- package/mcp/run_console/snapshot_builder.py +1181 -0
- package/mcp/run_console/snapshot_v1.schema.json +655 -0
- package/mcp/run_console/source_registry.py +576 -0
- package/mcp/run_console/test_actions.py +680 -0
- package/mcp/run_console/test_contract.py +640 -0
- package/mcp/run_console/test_diagnostic_export_gate.py +1540 -0
- package/mcp/run_console/test_http_server.py +989 -0
- package/mcp/run_console/test_parity.py +968 -0
- package/mcp/run_console/test_read_only_trial.py +581 -0
- package/mcp/run_console/test_request_security.py +325 -0
- package/mcp/run_console/test_role_attestation_gate.py +945 -0
- package/mcp/run_console/test_session.py +336 -0
- package/mcp/run_console/test_snapshot_builder.py +730 -0
- package/mcp/run_console/test_source_registry.py +950 -0
- package/mcp/run_console/test_ui.py +444 -0
- package/mcp/run_console/test_ui_actions.py +295 -0
- package/mcp/run_console/test_ui_browser.py +942 -0
- package/mcp/run_console/ui.py +82 -0
- package/mcp/util.py +18 -0
- package/package.json +1 -1
- package/scripts/audit_preferences.py +600 -0
- package/scripts/contract_v1.py +110 -1
- package/scripts/dd_entries.py +3 -3
- package/scripts/doctor.py +55 -2
- package/scripts/escalation_signals.py +5 -5
- package/scripts/finding_syntax.py +105 -0
- package/scripts/g10_design_decisions.py +3 -3
- package/scripts/g11_coverage.py +1 -1
- package/scripts/g12_tier_boundary.py +10 -13
- package/scripts/g1_spec.py +154 -8
- package/scripts/g2_g4_pointback.py +44 -84
- package/scripts/g5_preview.py +5 -4
- package/scripts/g7_contract_drift.py +25 -13
- package/scripts/interaction_dimensions.py +2 -2
- package/scripts/learning_candidates.py +7 -14
- package/scripts/pointback_projection.py +276 -0
- package/scripts/repair_rounds.py +8 -10
- package/scripts/run_console.py +79 -0
- package/scripts/run_facts.py +151 -12
- package/scripts/run_metadata.py +189 -0
- package/scripts/run_profile.py +197 -1
- package/scripts/run_status.py +41 -321
- package/scripts/stages.py +7 -5
- package/scripts/status_projection.py +576 -0
- package/scripts/validate_run.py +103 -22
- package/skills/design-baseline/SKILL.md +1 -3
- package/skills/design-baseline/scripts/design_baseline.py +1 -0
- package/skills/design-playbook/SKILL.md +99 -21
- package/skills/design-playbook/references/first-run.md +2 -2
- package/skills/design-playbook/references/preview-ops.md +5 -4
- package/skills/reference-intake/SKILL.md +19 -2
- package/skills/reference-intake/references/contract-template.md +13 -2
- package/skills/reference-intake/scripts/reference_sources.py +289 -0
- package/skills/ui-picker/SKILL.md +23 -2
- package/mcp/preview/util.py +0 -15
package/codex/AGENTS.md
CHANGED
|
@@ -71,12 +71,14 @@ adapter spawns it via `--app=`); `observe*` needs Playwright + Chromium.
|
|
|
71
71
|
|
|
72
72
|
Native desktop order: `ux-spec` → `native-craft` → `ui-picker` → `fill` → `craft-guard` → `ui-evaluator`.
|
|
73
73
|
|
|
74
|
-
Conditional entry `
|
|
74
|
+
Conditional entry `design-baseline?` (ADR-0012) runs before `reference-intake?` when the router returns `requires_baseline`. Existing-product Fill requires a valid existing baseline, an accepted generated baseline, or an explicit user waiver.
|
|
75
75
|
|
|
76
|
-
Conditional entry `
|
|
76
|
+
Conditional entry `reference-intake?` (screenshot/URL/design/product analogy, ADR-0011) runs **before** `ux-spec?` when the router returns `requires_reference_contract` — fixed orchestrator order, not reorderable. Run `native-craft` only for an explicit native-desktop/native-feel target. Web and mobile Web skip `native-craft`; if the platform is unclear, ask before choosing the order. The orchestrator owns the decision gate, render-surface seam handoff, and fail-closed behavior.
|
|
77
77
|
|
|
78
78
|
Mirror the orchestrator's skip narration (SKILL.md Steps preamble): when a step is skipped, output one line — step name + reason + how to enable, with the gate label when one applies, e.g. `-> preview*: adapter absent, skipped (G5 not triggered; enable via packages/design-playbook/mcp/preview/ or host MCP)`.
|
|
79
79
|
|
|
80
|
+
Audit preferences (ADR-0033) apply identically on Codex. Follow `skills/design-playbook/SKILL.md` § *Audit preferences* as sole authority; this bridge adds no host-specific preference rules.
|
|
81
|
+
|
|
80
82
|
## Compose
|
|
81
83
|
|
|
82
84
|
- Style DB → ui-ux-pro-max
|
package/commands/doctor.md
CHANGED
|
@@ -10,6 +10,7 @@ One packaged diagnosis entry for install/runtime capability.
|
|
|
10
10
|
python <plugin>/scripts/doctor.py
|
|
11
11
|
python <plugin>/scripts/doctor.py --json
|
|
12
12
|
python <plugin>/scripts/doctor.py --run-root .scratch/<run>
|
|
13
|
+
python <plugin>/scripts/doctor.py --repo-root <target-repo>
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
Reports `ok` / `degraded` / `broken`. Failed checks include a concrete repair. Optional adapters (Playwright, run-root env) degrade rather than hard-fail the install.
|
|
16
|
+
Reports `ok` / `degraded` / `broken`. Failed checks include a concrete repair. Optional adapters (Playwright, run-root env) degrade rather than hard-fail the install. Audit-preference state shows effective stage values, sources, asked status, and corrupt layers for target repository.
|
package/commands/ux-spec.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Six-layer spec.md via the S0-S6 shaping session (question/assumption/confirmation batches + session artifacts); stop before UI shell/code
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Run
|
|
5
|
+
Run the ux-spec protocol only. A same-named command in this plugin shadows the `ux-spec` skill in the Skill tool registry, so the Skill tool injects this text instead of the protocol. Locate this plugin's install root (the `--plugin-dir` path, or under `~/.claude/plugins/` for marketplace installs), read `skills/ux-spec/SKILL.md`, and follow its S0-S6 protocol. Emit complete `spec.md`. Do not pick templates or write UI.
|
|
6
6
|
|
|
7
7
|
Request:
|
|
8
8
|
$ARGUMENTS
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
"kind": "screenshot",
|
|
10
10
|
"locator": "assets/ops-alert-list.png",
|
|
11
11
|
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
12
|
+
"media_type": "image/png",
|
|
13
|
+
"storage": "copied",
|
|
14
|
+
"acquired_via": "local-file",
|
|
15
|
+
"captured_at": "2026-07-22T10:00:00+08:00",
|
|
12
16
|
"note": "fixture placeholder hash; real runs must hash the file"
|
|
13
17
|
}
|
|
14
18
|
]
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"kind": "url",
|
|
10
10
|
"locator": "https://example.com/docs/settings/notifications",
|
|
11
11
|
"sha256": null,
|
|
12
|
+
"storage": "remote",
|
|
13
|
+
"acquired_via": "url",
|
|
14
|
+
"captured_at": "2026-07-22T10:05:00+08:00",
|
|
12
15
|
"note": "example.com placeholder for fixture shape only"
|
|
13
16
|
}
|
|
14
17
|
]
|
|
@@ -5,17 +5,24 @@ Never writes manifest.jsonl; never accepts criterion refs (orchestrator binds).
|
|
|
5
5
|
Returns relative ``artifact`` plus absolute ``written_path`` so RUN_ROOT/cwd
|
|
6
6
|
misconfig is visible to the orchestrator.
|
|
7
7
|
"""
|
|
8
|
+
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
10
11
|
import json
|
|
11
12
|
import os
|
|
12
|
-
import sys
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any, Protocol
|
|
15
15
|
|
|
16
16
|
from design_playbook.mcp.evidence import containment
|
|
17
17
|
from design_playbook.mcp.evidence.capture_contract import parse_capture_contract
|
|
18
|
-
from design_playbook.mcp.
|
|
18
|
+
from design_playbook.mcp.evidence.disclosure import (
|
|
19
|
+
LAYOUT_PROBE_JS,
|
|
20
|
+
VIEWPORTS,
|
|
21
|
+
ViewportMetrics,
|
|
22
|
+
metric_payload,
|
|
23
|
+
probe_layout,
|
|
24
|
+
)
|
|
25
|
+
from design_playbook.mcp.util import log as _log
|
|
19
26
|
|
|
20
27
|
CAPTURE_TYPES = frozenset({"screenshot", "a11y tree", "interaction trace"})
|
|
21
28
|
ALLOWED_ARGUMENTS = frozenset(
|
|
@@ -50,6 +57,22 @@ class BrowserAdapter(Protocol):
|
|
|
50
57
|
"""Write one artifact and return the observed page state."""
|
|
51
58
|
|
|
52
59
|
|
|
60
|
+
class ProbeBrowserAdapter(Protocol):
|
|
61
|
+
"""Optional seam for capturing and probing one browser page."""
|
|
62
|
+
|
|
63
|
+
def capture_and_probe(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
url: str,
|
|
67
|
+
capture_type: str,
|
|
68
|
+
actions: list[dict[str, Any]],
|
|
69
|
+
out_path: Path,
|
|
70
|
+
viewport: dict[str, Any],
|
|
71
|
+
freeze: dict[str, Any],
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""Capture an artifact and evaluate the layout probe before teardown."""
|
|
74
|
+
|
|
75
|
+
|
|
53
76
|
def _failed(
|
|
54
77
|
artifact: str,
|
|
55
78
|
error: str,
|
|
@@ -188,16 +211,11 @@ def _reason_message(reason: str) -> str:
|
|
|
188
211
|
# payload stays compatible; resolution_failure is the one new surface (the
|
|
189
212
|
# old inline resolver propagated OSError uncaught).
|
|
190
213
|
_REASON_MESSAGES = {
|
|
191
|
-
containment.REASON_ABSOLUTE_PATH:
|
|
192
|
-
|
|
193
|
-
containment.
|
|
194
|
-
|
|
195
|
-
containment.
|
|
196
|
-
"artifact_path could not be resolved under the evidence/ subtree",
|
|
197
|
-
containment.REASON_CANONICAL_ESCAPE:
|
|
198
|
-
"artifact_path must stay under the evidence/ subtree",
|
|
199
|
-
containment.REASON_SYMLINK_ESCAPE:
|
|
200
|
-
"artifact_path symlink escapes the evidence/ subtree",
|
|
214
|
+
containment.REASON_ABSOLUTE_PATH: "artifact_path must be relative to the configured run root",
|
|
215
|
+
containment.REASON_DOTDOT_SEGMENT: "artifact_path must not contain '..' segments",
|
|
216
|
+
containment.REASON_RESOLUTION_FAILURE: "artifact_path could not be resolved under the evidence/ subtree",
|
|
217
|
+
containment.REASON_CANONICAL_ESCAPE: "artifact_path must stay under the evidence/ subtree",
|
|
218
|
+
containment.REASON_SYMLINK_ESCAPE: "artifact_path symlink escapes the evidence/ subtree",
|
|
201
219
|
}
|
|
202
220
|
|
|
203
221
|
|
|
@@ -276,8 +294,7 @@ def _action_select_option(page: Any, action: dict, index: int, do: str) -> None:
|
|
|
276
294
|
value = action.get("value")
|
|
277
295
|
label = action.get("label")
|
|
278
296
|
if value is None and label is None:
|
|
279
|
-
raise ValueError(
|
|
280
|
-
f"actions[{index}].value or label required for select_option")
|
|
297
|
+
raise ValueError(f"actions[{index}].value or label required for select_option")
|
|
281
298
|
if value is not None:
|
|
282
299
|
page.select_option(selector, value=value, timeout=10_000)
|
|
283
300
|
else:
|
|
@@ -339,6 +356,7 @@ def _read_observed_state(page: Any) -> str:
|
|
|
339
356
|
_log(f"observed_state probe failed: {exc}")
|
|
340
357
|
return "unknown"
|
|
341
358
|
|
|
359
|
+
|
|
342
360
|
def _write_screenshot(page: Any, path: Path) -> None:
|
|
343
361
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
344
362
|
page.screenshot(path=str(path), full_page=True)
|
|
@@ -389,7 +407,7 @@ class PlaywrightBrowserAdapter:
|
|
|
389
407
|
|
|
390
408
|
self._sync_playwright = sync_playwright
|
|
391
409
|
|
|
392
|
-
def
|
|
410
|
+
def _capture_page(
|
|
393
411
|
self,
|
|
394
412
|
*,
|
|
395
413
|
url: str,
|
|
@@ -398,7 +416,15 @@ class PlaywrightBrowserAdapter:
|
|
|
398
416
|
out_path: Path,
|
|
399
417
|
viewport: dict[str, Any],
|
|
400
418
|
freeze: dict[str, Any],
|
|
401
|
-
|
|
419
|
+
probe: bool,
|
|
420
|
+
) -> tuple[str, ViewportMetrics | None]:
|
|
421
|
+
"""Single Playwright capture path shared by the two adapter seams.
|
|
422
|
+
|
|
423
|
+
``probe=False`` stops after the observed state (the plain
|
|
424
|
+
:class:`BrowserAdapter` contract); ``probe=True`` additionally
|
|
425
|
+
evaluates the layout probe on the same page before teardown so the
|
|
426
|
+
screenshot and its metrics share one browser pass.
|
|
427
|
+
"""
|
|
402
428
|
with self._sync_playwright() as playwright:
|
|
403
429
|
browser = playwright.chromium.launch(headless=True)
|
|
404
430
|
try:
|
|
@@ -411,6 +437,8 @@ class PlaywrightBrowserAdapter:
|
|
|
411
437
|
color_scheme=viewport["colorScheme"],
|
|
412
438
|
)
|
|
413
439
|
page = context.new_page()
|
|
440
|
+
if viewport.get("media"):
|
|
441
|
+
page.emulate_media(media=viewport["media"])
|
|
414
442
|
wait_until = (
|
|
415
443
|
"networkidle" if freeze.get("networkIdle") else "domcontentloaded"
|
|
416
444
|
)
|
|
@@ -428,12 +456,62 @@ class PlaywrightBrowserAdapter:
|
|
|
428
456
|
elif capture_type == "a11y tree":
|
|
429
457
|
_write_a11y_tree(page, out_path)
|
|
430
458
|
|
|
431
|
-
|
|
459
|
+
observed = _read_observed_state(page)
|
|
460
|
+
if not probe:
|
|
461
|
+
return observed, None
|
|
462
|
+
raw = page.evaluate(LAYOUT_PROBE_JS)
|
|
463
|
+
metrics = probe_layout(lambda _js: raw)
|
|
464
|
+
return observed, metrics
|
|
432
465
|
finally:
|
|
433
466
|
browser.close()
|
|
434
467
|
|
|
468
|
+
def capture(
|
|
469
|
+
self,
|
|
470
|
+
*,
|
|
471
|
+
url: str,
|
|
472
|
+
capture_type: str,
|
|
473
|
+
actions: list[dict[str, Any]],
|
|
474
|
+
out_path: Path,
|
|
475
|
+
viewport: dict[str, Any],
|
|
476
|
+
freeze: dict[str, Any],
|
|
477
|
+
) -> str:
|
|
478
|
+
observed, _metrics = self._capture_page(
|
|
479
|
+
url=url,
|
|
480
|
+
capture_type=capture_type,
|
|
481
|
+
actions=actions,
|
|
482
|
+
out_path=out_path,
|
|
483
|
+
viewport=viewport,
|
|
484
|
+
freeze=freeze,
|
|
485
|
+
probe=False,
|
|
486
|
+
)
|
|
487
|
+
return observed
|
|
435
488
|
|
|
436
|
-
def
|
|
489
|
+
def capture_and_probe(
|
|
490
|
+
self,
|
|
491
|
+
*,
|
|
492
|
+
url: str,
|
|
493
|
+
capture_type: str,
|
|
494
|
+
actions: list[dict[str, Any]],
|
|
495
|
+
out_path: Path,
|
|
496
|
+
viewport: dict[str, Any],
|
|
497
|
+
freeze: dict[str, Any],
|
|
498
|
+
) -> dict[str, Any]:
|
|
499
|
+
"""Capture and probe the same page before closing its browser."""
|
|
500
|
+
observed, metrics = self._capture_page(
|
|
501
|
+
url=url,
|
|
502
|
+
capture_type=capture_type,
|
|
503
|
+
actions=actions,
|
|
504
|
+
out_path=out_path,
|
|
505
|
+
viewport=viewport,
|
|
506
|
+
freeze=freeze,
|
|
507
|
+
probe=True,
|
|
508
|
+
)
|
|
509
|
+
return {"observed_state": observed, "metrics": metrics}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _validate_runtime_object(
|
|
513
|
+
args: dict[str, Any],
|
|
514
|
+
) -> tuple[str, str, str, list[dict[str, Any]]]:
|
|
437
515
|
"""Validate Runtime Object fields and return (url, cap_type, state, actions).
|
|
438
516
|
|
|
439
517
|
Owns the field-level validation that execute_capture_plan previously
|
|
@@ -449,7 +527,7 @@ def _validate_runtime_object(args: dict[str, Any]) -> tuple[str, str, str, list[
|
|
|
449
527
|
raise ValueError("url is required")
|
|
450
528
|
if not isinstance(cap_type, str) or cap_type not in CAPTURE_TYPES:
|
|
451
529
|
raise ValueError(
|
|
452
|
-
f
|
|
530
|
+
f"type must be one of {sorted(CAPTURE_TYPES)}; got {cap_type!r}"
|
|
453
531
|
)
|
|
454
532
|
if not isinstance(state, str) or not state.strip():
|
|
455
533
|
raise ValueError("state is required")
|
|
@@ -509,8 +587,7 @@ def execute_capture_plan(
|
|
|
509
587
|
if out_path.exists() and not overwrite:
|
|
510
588
|
return _failed(
|
|
511
589
|
rel,
|
|
512
|
-
f"artifact already exists: {out_path} "
|
|
513
|
-
"(pass overwrite=true to replace)",
|
|
590
|
+
f"artifact already exists: {out_path} (pass overwrite=true to replace)",
|
|
514
591
|
abs_written,
|
|
515
592
|
request=request,
|
|
516
593
|
)
|
|
@@ -549,3 +626,128 @@ def execute_capture_plan(
|
|
|
549
626
|
)
|
|
550
627
|
|
|
551
628
|
return _captured(rel, observed, abs_written, request)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# --------------------------------------------------------------------------- #
|
|
632
|
+
# Stage 9 delivery matrix: five-viewport capture + fold/overflow metrics #
|
|
633
|
+
# --------------------------------------------------------------------------- #
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def matrix_viewport(name: str) -> dict[str, Any]:
|
|
637
|
+
"""Map a standard delivery viewport name to a capture-contract viewport.
|
|
638
|
+
|
|
639
|
+
``sw``/``innerH`` become the contract ``width``/``height``; device pixels
|
|
640
|
+
stay at 1.0 with the Light color scheme so the matrix is reproducible.
|
|
641
|
+
The ``print`` viewport additionally carries ``media: "print"`` so the
|
|
642
|
+
adapter emulates print media (page breaks, print stylesheets) rather than
|
|
643
|
+
just resizing the window. Unknown names raise ValueError so a caller cannot
|
|
644
|
+
silently capture a non-standard viewport into the disclosure matrix.
|
|
645
|
+
"""
|
|
646
|
+
ref = VIEWPORTS.get(name)
|
|
647
|
+
if ref is None:
|
|
648
|
+
raise ValueError(
|
|
649
|
+
f"unknown delivery viewport {name!r}; expected one of {sorted(VIEWPORTS)}"
|
|
650
|
+
)
|
|
651
|
+
viewport = {
|
|
652
|
+
"width": ref["sw"],
|
|
653
|
+
"height": ref["innerH"],
|
|
654
|
+
"devicePixelRatio": 1.0,
|
|
655
|
+
"colorScheme": "light",
|
|
656
|
+
}
|
|
657
|
+
if ref.get("kind") == "print":
|
|
658
|
+
viewport["media"] = "print"
|
|
659
|
+
return viewport
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def capture_delivery_matrix(
|
|
663
|
+
*,
|
|
664
|
+
url: str,
|
|
665
|
+
out_dir: Path | None = None,
|
|
666
|
+
freeze: dict[str, Any] | None = None,
|
|
667
|
+
browser_adapter: BrowserAdapter | None = None,
|
|
668
|
+
) -> dict[str, dict[str, Any]]:
|
|
669
|
+
"""Drive the five standard delivery viewports and return their metrics.
|
|
670
|
+
|
|
671
|
+
For each of ``VIEWPORTS``, captures one full-page screenshot under
|
|
672
|
+
``out_dir`` (``viewport-<name>.png``) and reads the layout probe. The
|
|
673
|
+
returned dict maps viewport name -> ``{metrics, screenshot}`` so a caller
|
|
674
|
+
can assemble the ``disclosure-review.json`` matrix and the ``/export-zip``
|
|
675
|
+
package from one pass.
|
|
676
|
+
|
|
677
|
+
``out_dir`` defaults to the spec's canonical delivery path
|
|
678
|
+
``output/playwright/static-handoff/`` (spec §5). ``browser_adapter`` uses
|
|
679
|
+
the same ``BrowserAdapter`` seam as ``execute_capture_plan`` (real
|
|
680
|
+
Playwright by default, injected fake in tests). Production adapters should
|
|
681
|
+
expose ``capture_and_probe`` so the snapshot and metrics share one page;
|
|
682
|
+
adapters without that seam remain explicitly ``unmeasured``.
|
|
683
|
+
"""
|
|
684
|
+
freeze = freeze or {"enabled": True, "waitFonts": True, "networkIdle": False}
|
|
685
|
+
if browser_adapter is None:
|
|
686
|
+
browser_adapter = PlaywrightBrowserAdapter()
|
|
687
|
+
|
|
688
|
+
if out_dir is None:
|
|
689
|
+
out_dir = Path("output/playwright/static-handoff")
|
|
690
|
+
out_dir = Path(out_dir)
|
|
691
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
692
|
+
results: dict[str, dict[str, Any]] = {}
|
|
693
|
+
for name in VIEWPORTS:
|
|
694
|
+
viewport = matrix_viewport(name)
|
|
695
|
+
out_path = out_dir / f"viewport-{name}.png"
|
|
696
|
+
try:
|
|
697
|
+
capture_and_probe = getattr(browser_adapter, "capture_and_probe", None)
|
|
698
|
+
if callable(capture_and_probe):
|
|
699
|
+
captured = capture_and_probe(
|
|
700
|
+
url=url,
|
|
701
|
+
capture_type="screenshot",
|
|
702
|
+
actions=[],
|
|
703
|
+
out_path=out_path,
|
|
704
|
+
viewport=viewport,
|
|
705
|
+
freeze=freeze,
|
|
706
|
+
)
|
|
707
|
+
measured = captured.get("metrics") if isinstance(captured, dict) else None
|
|
708
|
+
if not isinstance(measured, ViewportMetrics):
|
|
709
|
+
raise TypeError("capture_and_probe returned no ViewportMetrics")
|
|
710
|
+
metrics = metric_payload(measured)
|
|
711
|
+
else:
|
|
712
|
+
browser_adapter.capture(
|
|
713
|
+
url=url,
|
|
714
|
+
capture_type="screenshot",
|
|
715
|
+
actions=[],
|
|
716
|
+
out_path=out_path,
|
|
717
|
+
viewport=viewport,
|
|
718
|
+
freeze=freeze,
|
|
719
|
+
)
|
|
720
|
+
metrics = _unmeasured_metric_payload(name)
|
|
721
|
+
results[name] = {"metrics": metrics, "screenshot": str(out_path)}
|
|
722
|
+
except Exception as exc: # noqa: BLE001 — preserve blocked evidence
|
|
723
|
+
_log(f"matrix capture/probe failed for {name}: {exc}")
|
|
724
|
+
results[name] = {
|
|
725
|
+
"metrics": metric_payload(
|
|
726
|
+
ViewportMetrics(
|
|
727
|
+
sw=VIEWPORTS[name]["sw"],
|
|
728
|
+
innerH=VIEWPORTS[name]["innerH"],
|
|
729
|
+
hOverflow=0,
|
|
730
|
+
inFold=False,
|
|
731
|
+
measurement_status="blocked",
|
|
732
|
+
measurement_error=str(exc),
|
|
733
|
+
)
|
|
734
|
+
),
|
|
735
|
+
"screenshot": str(out_path),
|
|
736
|
+
"result": "blocked",
|
|
737
|
+
"error": str(exc),
|
|
738
|
+
}
|
|
739
|
+
return results
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _unmeasured_metric_payload(name: str) -> dict[str, Any]:
|
|
743
|
+
"""Return reference dimensions without claiming a layout measurement."""
|
|
744
|
+
ref = VIEWPORTS[name]
|
|
745
|
+
return metric_payload(
|
|
746
|
+
ViewportMetrics(
|
|
747
|
+
sw=ref["sw"],
|
|
748
|
+
innerH=ref["innerH"],
|
|
749
|
+
hOverflow=0,
|
|
750
|
+
inFold=False,
|
|
751
|
+
measurement_status="unmeasured",
|
|
752
|
+
)
|
|
753
|
+
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Single Evidence artifact containment authority (ADR-0026).
|
|
2
|
+
"""Single Evidence artifact containment authority (ADR-0026, ADR-0039).
|
|
3
3
|
|
|
4
4
|
This is the one deep module that resolves an artifact path under
|
|
5
5
|
``<run_root>/evidence/`` and rejects every escape class at resolution time.
|
|
@@ -16,6 +16,13 @@ without re-checking containment. Collapsing both callers into one mode-driven
|
|
|
16
16
|
helper was rejected because it would hide their different existence timing
|
|
17
17
|
and error contracts (ADR-0026).
|
|
18
18
|
|
|
19
|
+
ADR-0039 extends the same invariant to arbitrary roots: ``read_under``
|
|
20
|
+
exposes the canonical resolution for one existing regular file under any
|
|
21
|
+
directory, and the Run Console's source reads (run root, package root)
|
|
22
|
+
consume it instead of mirroring the escape classes. The ``evidence/``
|
|
23
|
+
operations remain the ADR-0026 contract surface - same reason codes, same
|
|
24
|
+
existence timing - now expressed as specializations of the one resolver.
|
|
25
|
+
|
|
19
26
|
Threat-model limit (ADR-0026, explicit): this module resolves and validates
|
|
20
27
|
the path; it does NOT perform the write. Path resolution alone cannot close
|
|
21
28
|
the TOCTOU gap - a concurrent untrusted filesystem actor that replaces a
|
|
@@ -73,22 +80,30 @@ class ContainmentResult:
|
|
|
73
80
|
return self.reason == ""
|
|
74
81
|
|
|
75
82
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
def _resolve_candidate(
|
|
84
|
+
candidate_root: Path,
|
|
85
|
+
relpath: str,
|
|
86
|
+
boundary_root: Path,
|
|
79
87
|
*,
|
|
80
88
|
require_existing_file: bool) -> ContainmentResult:
|
|
81
|
-
"""Canonical containment resolution shared by
|
|
89
|
+
"""Canonical containment resolution shared by every operation here.
|
|
90
|
+
|
|
91
|
+
``relpath`` joins ``candidate_root`` to form the candidate; the resolved
|
|
92
|
+
candidate (and its realpath) must stay under ``boundary_root``. The
|
|
93
|
+
evidence operations pass the run root as the candidate root and the
|
|
94
|
+
evidence subtree as the boundary (their paths are run-root-relative,
|
|
95
|
+
including the ``evidence/`` prefix); ``read_under`` passes the same root
|
|
96
|
+
for both.
|
|
82
97
|
|
|
83
98
|
Rejects, in order: native/POSIX/Windows absolute paths; any ``..``
|
|
84
99
|
segment (defence in depth before resolution); resolution failures
|
|
85
100
|
(OSError during resolve); canonical escapes (resolved candidate leaves
|
|
86
|
-
the
|
|
87
|
-
|
|
101
|
+
the boundary root); observed symlink escapes (realpath leaves the
|
|
102
|
+
boundary root - Path.resolve and os.path.realpath can disagree on
|
|
88
103
|
symlink chains across platforms). When ``require_existing_file`` is set,
|
|
89
104
|
a candidate that is not an existing regular file is rejected last.
|
|
90
105
|
"""
|
|
91
|
-
requested = Path(
|
|
106
|
+
requested = Path(relpath)
|
|
92
107
|
|
|
93
108
|
# 1. Absolute path rejection: native, POSIX, and Windows forms. Checking
|
|
94
109
|
# all three means a Windows drive path is rejected on POSIX and a POSIX
|
|
@@ -96,8 +111,8 @@ def _resolve(
|
|
|
96
111
|
# Path flavour.
|
|
97
112
|
if (
|
|
98
113
|
requested.is_absolute()
|
|
99
|
-
or PureWindowsPath(
|
|
100
|
-
or PurePosixPath(
|
|
114
|
+
or PureWindowsPath(relpath).is_absolute()
|
|
115
|
+
or PurePosixPath(relpath).is_absolute()
|
|
101
116
|
):
|
|
102
117
|
return ContainmentResult(None, REASON_ABSOLUTE_PATH)
|
|
103
118
|
|
|
@@ -106,36 +121,35 @@ def _resolve(
|
|
|
106
121
|
if any(part == ".." for part in requested.parts):
|
|
107
122
|
return ContainmentResult(None, REASON_DOTDOT_SEGMENT)
|
|
108
123
|
|
|
109
|
-
# 3. Resolution. Both the
|
|
124
|
+
# 3. Resolution. Both the boundary root and the candidate are resolved
|
|
110
125
|
# here; an OSError (e.g. a pathological symlink chain on a platform
|
|
111
126
|
# whose resolver raises) is caught and surfaced as a resolution
|
|
112
127
|
# failure rather than propagated.
|
|
113
128
|
try:
|
|
114
|
-
|
|
115
|
-
candidate = (
|
|
129
|
+
boundary = boundary_root.resolve(strict=False)
|
|
130
|
+
candidate = (candidate_root / requested).resolve(strict=False)
|
|
116
131
|
except OSError:
|
|
117
132
|
return ContainmentResult(None, REASON_RESOLUTION_FAILURE)
|
|
118
133
|
|
|
119
|
-
# 4. Canonical escape: the resolved candidate must stay under the
|
|
120
|
-
# root.
|
|
134
|
+
# 4. Canonical escape: the resolved candidate must stay under the
|
|
135
|
+
# boundary root. For the evidence operations this catches ``spec.md``
|
|
136
|
+
# and ``skills/x`` (siblings of evidence/).
|
|
121
137
|
try:
|
|
122
|
-
candidate.relative_to(
|
|
138
|
+
candidate.relative_to(boundary)
|
|
123
139
|
except ValueError:
|
|
124
140
|
return ContainmentResult(None, REASON_CANONICAL_ESCAPE)
|
|
125
141
|
|
|
126
142
|
# 5. Symlink escape (defence in depth): realpath must also stay under the
|
|
127
|
-
#
|
|
128
|
-
# symlink chains across platforms, so a symlink under
|
|
129
|
-
# resolves outside must be rejected even when step 4 passed.
|
|
143
|
+
# boundary root. Path.resolve and os.path.realpath can disagree on
|
|
144
|
+
# symlink chains across platforms, so a symlink under the boundary
|
|
145
|
+
# that resolves outside must be rejected even when step 4 passed.
|
|
130
146
|
try:
|
|
131
|
-
Path(os.path.realpath(candidate)).relative_to(
|
|
132
|
-
os.path.realpath(evidence_root)
|
|
133
|
-
)
|
|
147
|
+
Path(os.path.realpath(candidate)).relative_to(os.path.realpath(boundary))
|
|
134
148
|
except ValueError:
|
|
135
149
|
return ContainmentResult(None, REASON_SYMLINK_ESCAPE)
|
|
136
150
|
|
|
137
|
-
# 6. Read side: require an existing regular file. The write side permits
|
|
138
|
-
# nonexistent suffix and stops here (the Provider's manifest-refusal
|
|
151
|
+
# 6. Read side: require an existing regular file. The write side permits
|
|
152
|
+
# a nonexistent suffix and stops here (the Provider's manifest-refusal
|
|
139
153
|
# and overwrite checks are separate policy, not containment).
|
|
140
154
|
if require_existing_file and not candidate.is_file():
|
|
141
155
|
return ContainmentResult(None, REASON_NOT_REGULAR_FILE)
|
|
@@ -143,6 +157,33 @@ def _resolve(
|
|
|
143
157
|
return ContainmentResult(candidate, "")
|
|
144
158
|
|
|
145
159
|
|
|
160
|
+
def _resolve(
|
|
161
|
+
artifact_path: str,
|
|
162
|
+
run_root: Path,
|
|
163
|
+
*,
|
|
164
|
+
require_existing_file: bool) -> ContainmentResult:
|
|
165
|
+
"""Resolve an evidence artifact path (run-root-relative) under evidence/."""
|
|
166
|
+
return _resolve_candidate(
|
|
167
|
+
run_root,
|
|
168
|
+
artifact_path,
|
|
169
|
+
run_root / EVIDENCE_SUBDIR,
|
|
170
|
+
require_existing_file=require_existing_file,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def read_under(root: Path, relpath: str) -> ContainmentResult:
|
|
175
|
+
"""Resolve one existing regular file under an arbitrary root (ADR-0039).
|
|
176
|
+
|
|
177
|
+
The same escape classes as the evidence operations, for callers whose
|
|
178
|
+
authority root is not the evidence subtree (Run Console source reads
|
|
179
|
+
under the selected run root or the package root). ``relpath`` is
|
|
180
|
+
root-relative. Resolution-only, same TOCTOU limit as above.
|
|
181
|
+
"""
|
|
182
|
+
return _resolve_candidate(
|
|
183
|
+
root, relpath, root, require_existing_file=True
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
146
187
|
def write_target(artifact_path: str, run_root: Path) -> ContainmentResult:
|
|
147
188
|
"""Resolve a write target under ``<run_root>/evidence/``.
|
|
148
189
|
|