design-playbook 0.12.0 → 0.14.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 +16 -2
- package/design_playbook.py +20 -0
- package/lib/index.js +172 -0
- package/mcp/evidence/capture_contract.py +268 -0
- package/mcp/evidence/capture_runtime.py +552 -0
- package/mcp/evidence/containment.py +164 -0
- package/mcp/evidence/ledger_syntax.py +131 -0
- package/mcp/evidence/server.py +16 -570
- package/mcp/evidence/test_capture_contract.py +355 -0
- package/mcp/evidence/test_containment.py +581 -0
- package/mcp/evidence/test_ledger_syntax.py +252 -0
- package/mcp/evidence/test_server_stdio.py +254 -426
- package/mcp/preview/compatibility.py +158 -0
- package/mcp/preview/control.js +7 -9
- package/mcp/preview/control.py +3 -7
- package/mcp/preview/integrity.py +383 -0
- package/mcp/preview/{browser.py → review_session.py} +63 -16
- package/mcp/preview/server.py +26 -10
- package/mcp/preview/test_browser_control.py +194 -177
- package/mcp/preview/test_integrity.py +208 -0
- package/mcp/preview/test_server_stdio.py +11 -450
- package/mcp/preview/test_transaction.py +24 -19
- package/mcp/preview/test_versions.py +81 -28
- package/mcp/preview/test_versions_freeze.py +176 -0
- package/mcp/preview/transaction.py +81 -104
- package/mcp/preview/util.py +1 -21
- package/mcp/preview/versions.py +78 -132
- package/mcp/test_transport.py +6 -2
- package/package.json +13 -2
- package/scripts/__init__.py +0 -0
- package/scripts/g1_spec.py +81 -0
- package/scripts/g2_g4_pointback.py +310 -0
- package/scripts/g5_preview.py +265 -0
- package/scripts/g6_evidence.py +252 -0
- package/scripts/g6_records.py +56 -0
- package/scripts/g6_warnings.py +104 -0
- package/scripts/g7_contract_drift.py +2 -2
- package/scripts/run_facts.py +212 -0
- package/scripts/run_status.py +127 -147
- package/scripts/stages.py +90 -0
- package/scripts/test_verdict_syntax.py +239 -0
- package/scripts/validate_run.py +67 -978
- package/scripts/verdict_syntax.py +96 -0
- package/mcp/preview/test_anchor_v2.py +0 -69
- package/scripts/_preview_integrity.py +0 -288
package/codex/AGENTS.md
CHANGED
|
@@ -36,10 +36,14 @@ python packages/design-playbook/codex/install_skills.py --force
|
|
|
36
36
|
# @packages/design-playbook/skills/design-playbook/SKILL.md
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Register MCP directly (fallback when `codex plugin add` is unavailable):
|
|
40
|
+
|
|
41
|
+
`codex plugin add` depends on a healthy codex marketplace subsystem. If `codex doctor`
|
|
42
|
+
or `codex plugin marketplace list` fails (e.g. a stale marketplace source path), register
|
|
43
|
+
the servers directly in `~/.codex/config.toml` (or your `CODEX_HOME`):
|
|
40
44
|
|
|
41
45
|
```toml
|
|
42
|
-
# ~/.codex/config.toml
|
|
46
|
+
# ~/.codex/config.toml (or $CODEX_HOME/config.toml)
|
|
43
47
|
[mcp_servers.design-playbook-preview]
|
|
44
48
|
command = "python"
|
|
45
49
|
args = ["<abs>/packages/design-playbook/mcp/preview/server.py"]
|
|
@@ -50,6 +54,16 @@ args = ["<abs>/packages/design-playbook/mcp/evidence/server.py"]
|
|
|
50
54
|
# evidence also needs: pip install playwright && playwright install chromium
|
|
51
55
|
```
|
|
52
56
|
|
|
57
|
+
Verify: `codex mcp list` should list both. `preview*` needs a system Edge/Chrome (the
|
|
58
|
+
adapter spawns it via `--app=`); `observe*` needs Playwright + Chromium.
|
|
59
|
+
|
|
60
|
+
> **`preview*` silently skips when `preview_prototype` is absent.** If preview does not
|
|
61
|
+
> appear, the orchestrator probed `tools/list`, found no `preview_prototype`, and skipped
|
|
62
|
+
> G5 - this is designed skip behaviour, not a crash. Confirm the tool is registered
|
|
63
|
+
> (`codex mcp list`) before treating it as a preview failure. Codex end-to-end preview
|
|
64
|
+
> smoke is not yet validated (v0.4.4 deferred the codex E2E smoke; only evidence/G6 was
|
|
65
|
+
> server-level smoked).
|
|
66
|
+
|
|
53
67
|
## Load order
|
|
54
68
|
|
|
55
69
|
1. `skills/design-playbook/SKILL.md`
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Namespace alias: absolute ``design_playbook.*`` imports inside this plugin.
|
|
2
|
+
|
|
3
|
+
The plugin directory is ``design-playbook`` (hyphen), so Python's import
|
|
4
|
+
system cannot load it under the identifier ``design_playbook`` — FileFinder
|
|
5
|
+
matches directory names literally. This module aliases the package root onto
|
|
6
|
+
``design_playbook.__path__`` so that, once the one bootstrap (ADR-0022) puts
|
|
7
|
+
the package root on ``sys.path``, absolute imports such as
|
|
8
|
+
``design_playbook.mcp.preview.integrity`` and
|
|
9
|
+
``design_playbook.scripts.stages`` resolve to the real ``mcp/`` and
|
|
10
|
+
``scripts/`` trees below.
|
|
11
|
+
|
|
12
|
+
The alias also keeps the import seam namespaced: a bare ``mcp.*`` import
|
|
13
|
+
would collide with the PyPI ``mcp`` SDK when the host has it installed.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
__path__ = [str(Path(__file__).resolve().parent)]
|
|
20
|
+
__all__: list[str] = []
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* design-playbook dsh plugin
|
|
4
|
+
*
|
|
5
|
+
* Registers two contributions on the DSH context:
|
|
6
|
+
*
|
|
7
|
+
* 1. A skill provider (ctx.skills) whose candidates are this package's
|
|
8
|
+
* `skills/` directory. The plugin locates the directory via `__dirname`,
|
|
9
|
+
* so no `!!js` expression and no cwd-dependent resolution is involved.
|
|
10
|
+
*
|
|
11
|
+
* 2. Six slash commands (ctx.commands) — `design-io`, `doctor`,
|
|
12
|
+
* `run-review`, `run-status`, `ui-review`, `ux-spec` — that load the
|
|
13
|
+
* matching `commands/<name>.md` prompt, substitute `$ARGUMENTS` with the
|
|
14
|
+
* raw trailing input, and inject it as a user-role follow-up turn via
|
|
15
|
+
* `agent.followup()`.
|
|
16
|
+
*
|
|
17
|
+
* The Cordis `!!js` evaluation scope provides no `require` (only Node globals
|
|
18
|
+
* plus ctx-provided values like dshHomePath/loader), so pointing a
|
|
19
|
+
* skill-filesystem customSkillDirs row at package resources via
|
|
20
|
+
* `require.resolve` does not work. The plugin route is the supported way for
|
|
21
|
+
* a package to contribute its own skills and commands.
|
|
22
|
+
*
|
|
23
|
+
* Requires `ctx.skills` (the skill registry from @deepseek-ai/dsh-skill),
|
|
24
|
+
* `ctx.commands` (from @deepseek-ai/dsh-commands), and `@deepseek-ai/dsh-llm`
|
|
25
|
+
* (a core DSH dependency, always present in a booted profile).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs')
|
|
29
|
+
const path = require('node:path')
|
|
30
|
+
|
|
31
|
+
const SKILLS_DIR = path.join(__dirname, '..', 'skills')
|
|
32
|
+
const COMMANDS_DIR = path.join(__dirname, '..', 'commands')
|
|
33
|
+
|
|
34
|
+
/** Parse a minimal frontmatter block: `---\nkey: value\n...\n---\nbody`. */
|
|
35
|
+
function parseSkillFile(filePath) {
|
|
36
|
+
const text = fs.readFileSync(filePath, 'utf8')
|
|
37
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
|
|
38
|
+
if (!m) return null
|
|
39
|
+
const front = {}
|
|
40
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
41
|
+
const kv = /^([a-zA-Z0-9-]+):\s*(.*)$/.exec(line)
|
|
42
|
+
if (kv) front[kv[1]] = kv[2]
|
|
43
|
+
}
|
|
44
|
+
return { meta: front, content: m[2].trim() }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a user-role message carrying one text block.
|
|
49
|
+
*
|
|
50
|
+
* @deepseek-ai/dsh-llm is a core DSH dependency, but it lives in the DSH
|
|
51
|
+
* installation's node_modules, not in this package's node_modules.
|
|
52
|
+
* require.resolve from this package may fail depending on hoisting; the
|
|
53
|
+
* fallback plain object has the same shape DSH's inbox reads
|
|
54
|
+
* ({ id, role, content, source }) and is safe in all runtimes.
|
|
55
|
+
* @param {string} text - the prepared prompt body.
|
|
56
|
+
* @returns {object} a user-role message.
|
|
57
|
+
*/
|
|
58
|
+
function createUserMessageFromPrompt(text) {
|
|
59
|
+
try {
|
|
60
|
+
const { createUserMessage } = require('@deepseek-ai/dsh-llm')
|
|
61
|
+
return createUserMessage({
|
|
62
|
+
content: [{ type: 'text', text }],
|
|
63
|
+
source: { kind: 'user' },
|
|
64
|
+
})
|
|
65
|
+
} catch {
|
|
66
|
+
const crypto = require('node:crypto')
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
id: crypto.randomUUID(),
|
|
69
|
+
role: 'user',
|
|
70
|
+
content: [{ type: 'text', text }],
|
|
71
|
+
source: { kind: 'user' },
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
exports.name = 'design-playbook'
|
|
77
|
+
exports.inject = ['skills', 'commands']
|
|
78
|
+
|
|
79
|
+
// Exported for test_commands.js to avoid duplicating the helpers.
|
|
80
|
+
exports.parseSkillFile = parseSkillFile
|
|
81
|
+
exports.createUserMessageFromPrompt = createUserMessageFromPrompt
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The six slash commands this plugin registers. Each maps to a
|
|
85
|
+
* `commands/<name>.md` prompt file; the file's frontmatter `description`
|
|
86
|
+
* becomes the command's discovery metadata, and the body (with `$ARGUMENTS`
|
|
87
|
+
* substituted) is injected as a user follow-up turn.
|
|
88
|
+
*/
|
|
89
|
+
const COMMAND_NAMES = [
|
|
90
|
+
'design-io',
|
|
91
|
+
'doctor',
|
|
92
|
+
'run-review',
|
|
93
|
+
'run-status',
|
|
94
|
+
'ui-review',
|
|
95
|
+
'ux-spec',
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
exports.apply = function (ctx) {
|
|
99
|
+
// ---- skills provider (P1) ----
|
|
100
|
+
if (fs.existsSync(SKILLS_DIR)) {
|
|
101
|
+
ctx.skills.registerProvider(() => {
|
|
102
|
+
const candidates = fs
|
|
103
|
+
.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
104
|
+
.filter((e) => e.isDirectory())
|
|
105
|
+
.map((e) => {
|
|
106
|
+
const skillPath = path.join(SKILLS_DIR, e.name, 'SKILL.md')
|
|
107
|
+
if (!fs.existsSync(skillPath)) return null
|
|
108
|
+
const parsed = parseSkillFile(skillPath)
|
|
109
|
+
if (!parsed) return null
|
|
110
|
+
const meta = parsed.meta
|
|
111
|
+
const name = meta.name
|
|
112
|
+
const description = meta.description
|
|
113
|
+
if (!name || !description) return null
|
|
114
|
+
return {
|
|
115
|
+
name,
|
|
116
|
+
description,
|
|
117
|
+
...(meta.whenToUse ? { whenToUse: meta.whenToUse } : {}),
|
|
118
|
+
rank: 600, // bundled rank (matches skill-filesystem's bundled rank)
|
|
119
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
120
|
+
source: 'bundled',
|
|
121
|
+
provider: 'design-playbook',
|
|
122
|
+
resourceBase: { kind: 'directory', path: path.join(SKILLS_DIR, e.name) },
|
|
123
|
+
locator: skillPath,
|
|
124
|
+
path: skillPath,
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
name: 'design-playbook',
|
|
131
|
+
list: async () => candidates,
|
|
132
|
+
get: async (candidate) => {
|
|
133
|
+
const parsed = parseSkillFile(candidate.locator)
|
|
134
|
+
if (!parsed) return undefined
|
|
135
|
+
return { ...candidate, content: parsed.content }
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- commands (P2) ----
|
|
142
|
+
// Each command loads its prompt from commands/<name>.md, substitutes
|
|
143
|
+
// $ARGUMENTS, and injects the result as a user-role follow-up turn. The
|
|
144
|
+
// handler returns a CommandResult immediately — the actual model work
|
|
145
|
+
// happens in the turn opened by agent.followup().
|
|
146
|
+
if (ctx.commands && fs.existsSync(COMMANDS_DIR)) {
|
|
147
|
+
for (const name of COMMAND_NAMES) {
|
|
148
|
+
const filePath = path.join(COMMANDS_DIR, `${name}.md`)
|
|
149
|
+
if (!fs.existsSync(filePath)) continue
|
|
150
|
+
const parsed = parseSkillFile(filePath)
|
|
151
|
+
if (!parsed || !parsed.meta.description) continue
|
|
152
|
+
|
|
153
|
+
// Capture the parsed body once — the handler substitutes $ARGUMENTS
|
|
154
|
+
// on the already-parsed content instead of re-reading the file.
|
|
155
|
+
const promptBody = parsed.content
|
|
156
|
+
ctx.commands.register({
|
|
157
|
+
name,
|
|
158
|
+
description: parsed.meta.description,
|
|
159
|
+
handler: (invocation) => {
|
|
160
|
+
const prompt = promptBody.replace(/\$ARGUMENTS/g, invocation.rawInput)
|
|
161
|
+
const message = createUserMessageFromPrompt(prompt)
|
|
162
|
+
// followup() queues an ordinary turn and wakes the driver — a
|
|
163
|
+
// slash command is an explicit user action, so followup is the
|
|
164
|
+
// right boundary (not inject(), which seeds context without
|
|
165
|
+
// waking).
|
|
166
|
+
invocation.agent.followup(message)
|
|
167
|
+
return { kind: 'success' }
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Capture contract v1 rules, owned by the bundled Evidence runtime.
|
|
3
|
+
|
|
4
|
+
ADR-0018 enforcement site 1: this module is the single owner of the v1
|
|
5
|
+
contract surface — the write-side parse/normalize authority, the read-side
|
|
6
|
+
full-shape snapshot validator, and the contract-fields JSON Schema fragment
|
|
7
|
+
the provider tool schema composes. The provider (server.py) keeps only
|
|
8
|
+
Runtime Object fields, path/overwrite boundaries, and Playwright I/O; G6
|
|
9
|
+
(scripts/validate_run.py) validates bound manifest request snapshots through
|
|
10
|
+
``validate_capture_snapshot`` instead of hand-written partial checks.
|
|
11
|
+
|
|
12
|
+
Named ``capture_contract.py`` to avoid collision with
|
|
13
|
+
``scripts/contract_v1.py`` (the persistent contract, ADR-0017).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
CAPTURE_SCHEMA_VERSION = 1
|
|
22
|
+
COLOR_SCHEMES = frozenset({"light", "dark", "no-preference"})
|
|
23
|
+
MIN_VIEWPORT_DPR = 0.1
|
|
24
|
+
RECAPTURE_HINT = "recapture with capture contract schemaVersion=1"
|
|
25
|
+
FREEZE_DEFAULTS = {
|
|
26
|
+
"enabled": True,
|
|
27
|
+
"waitFonts": True,
|
|
28
|
+
"networkIdle": False,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class CaptureFact:
|
|
34
|
+
"""One host-neutral capture-contract violation (empty facts = valid)."""
|
|
35
|
+
|
|
36
|
+
code: str
|
|
37
|
+
detail: str
|
|
38
|
+
expected: str = ""
|
|
39
|
+
actual: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_schema_version(value: object) -> bool:
|
|
43
|
+
return type(value) is int and value == CAPTURE_SCHEMA_VERSION
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bad_viewport(viewport: dict[str, Any]) -> str | None:
|
|
47
|
+
"""First malformed viewport field, or None when the shape is valid.
|
|
48
|
+
|
|
49
|
+
Mirrors the parser's field rules exactly so the read side and write side
|
|
50
|
+
cannot disagree on what a valid viewport is.
|
|
51
|
+
"""
|
|
52
|
+
width = viewport.get("width")
|
|
53
|
+
height = viewport.get("height")
|
|
54
|
+
dpr = viewport.get("devicePixelRatio")
|
|
55
|
+
scheme = viewport.get("colorScheme")
|
|
56
|
+
if type(width) is not int or width < 1:
|
|
57
|
+
return "viewport.width must be a positive integer"
|
|
58
|
+
if type(height) is not int or height < 1:
|
|
59
|
+
return "viewport.height must be a positive integer"
|
|
60
|
+
if (
|
|
61
|
+
type(dpr) not in (int, float)
|
|
62
|
+
or not math.isfinite(dpr)
|
|
63
|
+
or dpr < MIN_VIEWPORT_DPR
|
|
64
|
+
):
|
|
65
|
+
return (
|
|
66
|
+
"viewport.devicePixelRatio must be a number greater than or equal "
|
|
67
|
+
f"to {MIN_VIEWPORT_DPR}"
|
|
68
|
+
)
|
|
69
|
+
if not isinstance(scheme, str) or scheme not in COLOR_SCHEMES:
|
|
70
|
+
return (
|
|
71
|
+
f"viewport.colorScheme must be one of {sorted(COLOR_SCHEMES)}; "
|
|
72
|
+
f"got {scheme!r}"
|
|
73
|
+
)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _bad_freeze(freeze: dict[str, Any]) -> str | None:
|
|
78
|
+
"""First malformed freeze field, or None when the shape is valid."""
|
|
79
|
+
for key in FREEZE_DEFAULTS:
|
|
80
|
+
if not isinstance(freeze.get(key), bool):
|
|
81
|
+
return f"freeze.{key} must be a boolean"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_capture_contract(args: dict[str, Any]) -> dict[str, Any]:
|
|
86
|
+
"""Validate capture contract v1 fields and return a normalized request.
|
|
87
|
+
|
|
88
|
+
Write authority (ADR-0018): raises ValueError with a recapture instruction
|
|
89
|
+
for missing/unknown versions or an incomplete viewport. Pure — no browser
|
|
90
|
+
side effects. The normalized output is what the provider echoes into the
|
|
91
|
+
manifest request snapshot, so real snapshots always carry freeze defaults.
|
|
92
|
+
"""
|
|
93
|
+
if "schemaVersion" not in args:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"capture contract schemaVersion is required; {RECAPTURE_HINT}"
|
|
96
|
+
)
|
|
97
|
+
version = args.get("schemaVersion")
|
|
98
|
+
if not _is_schema_version(version):
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"unsupported capture schemaVersion {version!r}; {RECAPTURE_HINT}"
|
|
101
|
+
)
|
|
102
|
+
viewport = args.get("viewport")
|
|
103
|
+
if not isinstance(viewport, dict):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"viewport object is required for schemaVersion=1; {RECAPTURE_HINT}"
|
|
106
|
+
)
|
|
107
|
+
bad_viewport = _bad_viewport(viewport)
|
|
108
|
+
if bad_viewport is not None:
|
|
109
|
+
raise ValueError(bad_viewport)
|
|
110
|
+
width = viewport["width"]
|
|
111
|
+
height = viewport["height"]
|
|
112
|
+
dpr = viewport["devicePixelRatio"]
|
|
113
|
+
scheme = viewport["colorScheme"]
|
|
114
|
+
|
|
115
|
+
freeze_raw = args.get("freeze")
|
|
116
|
+
if freeze_raw is None:
|
|
117
|
+
freeze_raw = {}
|
|
118
|
+
if not isinstance(freeze_raw, dict):
|
|
119
|
+
raise ValueError("freeze must be an object when provided")
|
|
120
|
+
freeze = {
|
|
121
|
+
key: freeze_raw.get(key, FREEZE_DEFAULTS[key])
|
|
122
|
+
for key in FREEZE_DEFAULTS
|
|
123
|
+
}
|
|
124
|
+
bad_freeze = _bad_freeze(freeze)
|
|
125
|
+
if bad_freeze is not None:
|
|
126
|
+
raise ValueError(bad_freeze)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
"schemaVersion": CAPTURE_SCHEMA_VERSION,
|
|
130
|
+
"viewport": {
|
|
131
|
+
"width": width,
|
|
132
|
+
"height": height,
|
|
133
|
+
"devicePixelRatio": float(dpr),
|
|
134
|
+
"colorScheme": scheme,
|
|
135
|
+
},
|
|
136
|
+
"freeze": freeze,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def validate_capture_snapshot(snapshot: object) -> list[CaptureFact]:
|
|
141
|
+
"""Read authority: full-shape validation of a bound manifest snapshot.
|
|
142
|
+
|
|
143
|
+
Host-neutral — never raises, returns facts (empty list = valid). Strict on
|
|
144
|
+
the v1 full shape: schemaVersion=1, a complete typed viewport, and a
|
|
145
|
+
complete boolean freeze. The parser normalizes defaults at capture time;
|
|
146
|
+
the read side requires the recorded snapshot to be self-contained so the
|
|
147
|
+
manifest alone can reproduce the capture (ADR-0018). Malformed viewport
|
|
148
|
+
shape or missing freeze therefore fail closed (sanctioned correction; was
|
|
149
|
+
lax in the old hand-written G6 checks). Unknown extra keys are tolerated
|
|
150
|
+
(host-neutral forward compatibility).
|
|
151
|
+
"""
|
|
152
|
+
if not isinstance(snapshot, dict):
|
|
153
|
+
return [CaptureFact(
|
|
154
|
+
"missing_schema_version",
|
|
155
|
+
"no request snapshot on the bound entry",
|
|
156
|
+
expected="schemaVersion=1 with viewport and freeze",
|
|
157
|
+
actual=("None" if snapshot is None else type(snapshot).__name__),
|
|
158
|
+
)]
|
|
159
|
+
version = snapshot.get("schemaVersion")
|
|
160
|
+
if not _is_schema_version(version):
|
|
161
|
+
return [CaptureFact(
|
|
162
|
+
("missing_schema_version" if version is None
|
|
163
|
+
else "unsupported_schema_version"),
|
|
164
|
+
("missing schemaVersion" if version is None
|
|
165
|
+
else f"unsupported schemaVersion {version!r}"),
|
|
166
|
+
expected="schemaVersion=1",
|
|
167
|
+
actual=repr(version),
|
|
168
|
+
)]
|
|
169
|
+
viewport = snapshot.get("viewport")
|
|
170
|
+
if not isinstance(viewport, dict):
|
|
171
|
+
return [CaptureFact(
|
|
172
|
+
"missing_viewport",
|
|
173
|
+
"viewport object is required for schemaVersion=1",
|
|
174
|
+
expected="viewport width/height/devicePixelRatio/colorScheme",
|
|
175
|
+
actual=type(viewport).__name__ if viewport is not None else "missing",
|
|
176
|
+
)]
|
|
177
|
+
bad_viewport = _bad_viewport(viewport)
|
|
178
|
+
if bad_viewport is not None:
|
|
179
|
+
return [CaptureFact(
|
|
180
|
+
"bad_viewport_shape",
|
|
181
|
+
bad_viewport,
|
|
182
|
+
expected="viewport width/height/devicePixelRatio/colorScheme",
|
|
183
|
+
actual=bad_viewport,
|
|
184
|
+
)]
|
|
185
|
+
freeze = snapshot.get("freeze")
|
|
186
|
+
if not isinstance(freeze, dict):
|
|
187
|
+
return [CaptureFact(
|
|
188
|
+
"missing_freeze",
|
|
189
|
+
"freeze snapshot is required on the bound entry",
|
|
190
|
+
expected="freeze enabled/waitFonts/networkIdle booleans",
|
|
191
|
+
actual=type(freeze).__name__ if freeze is not None else "missing",
|
|
192
|
+
)]
|
|
193
|
+
bad_freeze = _bad_freeze(freeze)
|
|
194
|
+
if bad_freeze is not None:
|
|
195
|
+
return [CaptureFact(
|
|
196
|
+
"bad_freeze_shape",
|
|
197
|
+
bad_freeze,
|
|
198
|
+
expected="freeze enabled/waitFonts/networkIdle booleans",
|
|
199
|
+
actual=bad_freeze,
|
|
200
|
+
)]
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def capture_contract_schema_fragment() -> dict[str, Any]:
|
|
205
|
+
"""JSON Schema fragment for the contract fields (schemaVersion/viewport/freeze).
|
|
206
|
+
|
|
207
|
+
The provider composes this into its tool schema alongside its Runtime
|
|
208
|
+
Object fields. const/enum/required/default all come from the same module
|
|
209
|
+
constants the parser uses, so the schema and the parser cannot drift.
|
|
210
|
+
"""
|
|
211
|
+
return {
|
|
212
|
+
"properties": {
|
|
213
|
+
"schemaVersion": {
|
|
214
|
+
"type": "integer",
|
|
215
|
+
"description": "Capture contract version. Only 1 is supported.",
|
|
216
|
+
"const": CAPTURE_SCHEMA_VERSION,
|
|
217
|
+
},
|
|
218
|
+
"viewport": {
|
|
219
|
+
"type": "object",
|
|
220
|
+
"description": (
|
|
221
|
+
"Required capture viewport. Provider does not invent "
|
|
222
|
+
"desktop defaults."
|
|
223
|
+
),
|
|
224
|
+
"properties": {
|
|
225
|
+
"width": {"type": "integer", "minimum": 1},
|
|
226
|
+
"height": {"type": "integer", "minimum": 1},
|
|
227
|
+
"devicePixelRatio": {
|
|
228
|
+
"type": "number",
|
|
229
|
+
"minimum": MIN_VIEWPORT_DPR,
|
|
230
|
+
},
|
|
231
|
+
"colorScheme": {
|
|
232
|
+
"type": "string",
|
|
233
|
+
"enum": sorted(COLOR_SCHEMES),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
"required": [
|
|
237
|
+
"width",
|
|
238
|
+
"height",
|
|
239
|
+
"devicePixelRatio",
|
|
240
|
+
"colorScheme",
|
|
241
|
+
],
|
|
242
|
+
"additionalProperties": False,
|
|
243
|
+
},
|
|
244
|
+
"freeze": {
|
|
245
|
+
"type": "object",
|
|
246
|
+
"description": (
|
|
247
|
+
"Deterministic freeze controls. Defaults: "
|
|
248
|
+
"enabled=true, waitFonts=true, networkIdle=false."
|
|
249
|
+
),
|
|
250
|
+
"properties": {
|
|
251
|
+
"enabled": {
|
|
252
|
+
"type": "boolean",
|
|
253
|
+
"default": FREEZE_DEFAULTS["enabled"],
|
|
254
|
+
},
|
|
255
|
+
"waitFonts": {
|
|
256
|
+
"type": "boolean",
|
|
257
|
+
"default": FREEZE_DEFAULTS["waitFonts"],
|
|
258
|
+
},
|
|
259
|
+
"networkIdle": {
|
|
260
|
+
"type": "boolean",
|
|
261
|
+
"default": FREEZE_DEFAULTS["networkIdle"],
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
"additionalProperties": False,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
"required": ["schemaVersion", "viewport"],
|
|
268
|
+
}
|