design-playbook 0.7.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/LICENSE +28 -0
- package/NOTICE +37 -0
- package/README.md +143 -0
- package/commands/design-io.md +8 -0
- package/commands/ui-review.md +8 -0
- package/commands/ux-spec.md +8 -0
- package/mcp/__init__.py +0 -0
- package/mcp/_transport.py +242 -0
- package/mcp/evidence/README.md +40 -0
- package/mcp/evidence/__init__.py +0 -0
- package/mcp/evidence/server.py +450 -0
- package/mcp/evidence/test_server_stdio.py +645 -0
- package/mcp/preview/__init__.py +0 -0
- package/mcp/preview/browser.py +661 -0
- package/mcp/preview/confirm.py +255 -0
- package/mcp/preview/control.py +1293 -0
- package/mcp/preview/i18n.py +162 -0
- package/mcp/preview/server.py +126 -0
- package/mcp/preview/test_browser_control.py +663 -0
- package/mcp/preview/test_server_stdio.py +630 -0
- package/mcp/preview/test_transaction.py +436 -0
- package/mcp/preview/transaction.py +536 -0
- package/mcp/preview/util.py +19 -0
- package/mcp/test_transport.py +39 -0
- package/package.json +42 -0
- package/skills/craft-guard/SKILL.md +59 -0
- package/skills/craft-guard/references/craft.md +29 -0
- package/skills/craft-guard/references/detectors.md +124 -0
- package/skills/design-baseline/SKILL.md +134 -0
- package/skills/design-baseline/agents/openai.yaml +4 -0
- package/skills/design-baseline/references/design-template.md +73 -0
- package/skills/design-baseline/references/extraction-guidance.md +39 -0
- package/skills/design-baseline/scripts/design_baseline.py +780 -0
- package/skills/design-playbook/SKILL.md +219 -0
- package/skills/native-craft/SKILL.md +59 -0
- package/skills/native-craft/references/native-feel.md +79 -0
- package/skills/reference-intake/SKILL.md +86 -0
- package/skills/reference-intake/references/contract-template.md +82 -0
- package/skills/ui-evaluator/SKILL.md +110 -0
- package/skills/ui-evaluator/references/rubric.md +45 -0
- package/skills/ui-picker/SKILL.md +63 -0
- package/skills/ui-picker/references/components.md +31 -0
- package/skills/ui-picker/references/design.md +21 -0
- package/skills/ui-picker/references/domain.md +26 -0
- package/skills/ui-picker/references/template.md +24 -0
- package/skills/ux-spec/SKILL.md +51 -0
- package/skills/ux-spec/references/spec-template.md +43 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prepare, confirm, and verify a project-owned DESIGN.md baseline.
|
|
3
|
+
|
|
4
|
+
The state file is a cache, not authority. Every public operation resolves paths
|
|
5
|
+
against the supplied project root and ``verify`` re-hashes both the selected
|
|
6
|
+
baseline and its first-party sources before returning a downstream binding.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Iterable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SCHEMA = "design-baseline/v1"
|
|
24
|
+
STATE_RELATIVE = Path("design-baseline/state.json")
|
|
25
|
+
EVIDENCE_RELATIVE = Path("design-baseline/evidence.json")
|
|
26
|
+
DRAFT_RELATIVE = Path("design-baseline/DESIGN.draft.md")
|
|
27
|
+
CANDIDATES = (Path("DESIGN.md"), Path(".stitch/DESIGN.md"))
|
|
28
|
+
# Provenance-minimal gate: an existing project DESIGN.md only has to carry
|
|
29
|
+
# verifiable source provenance (path + SHA-256) to be bound. The other
|
|
30
|
+
# section names are draft-template guidance (references/design-template.md),
|
|
31
|
+
# not a structural contract imposed on adopted baselines (ADR-0012). Imposing
|
|
32
|
+
# the full 9-section template on hand-written existing baselines falsely
|
|
33
|
+
# rejected them and triggered needless regeneration.
|
|
34
|
+
REQUIRED_SECTIONS = (
|
|
35
|
+
"Source Evidence & Confidence",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_SKIP_DIRECTORIES = {
|
|
39
|
+
".git",
|
|
40
|
+
".scratch",
|
|
41
|
+
".next",
|
|
42
|
+
".nuxt",
|
|
43
|
+
".svelte-kit",
|
|
44
|
+
"build",
|
|
45
|
+
"coverage",
|
|
46
|
+
"dist",
|
|
47
|
+
"node_modules",
|
|
48
|
+
"out",
|
|
49
|
+
"target",
|
|
50
|
+
"vendor",
|
|
51
|
+
}
|
|
52
|
+
_FRONTEND_SUFFIXES = {
|
|
53
|
+
".css",
|
|
54
|
+
".html",
|
|
55
|
+
".jsx",
|
|
56
|
+
".less",
|
|
57
|
+
".pcss",
|
|
58
|
+
".sass",
|
|
59
|
+
".scss",
|
|
60
|
+
".svelte",
|
|
61
|
+
".tsx",
|
|
62
|
+
".vue",
|
|
63
|
+
}
|
|
64
|
+
_CONFIG_NAMES = {
|
|
65
|
+
"tailwind.config.js",
|
|
66
|
+
"tailwind.config.cjs",
|
|
67
|
+
"tailwind.config.mjs",
|
|
68
|
+
"tailwind.config.ts",
|
|
69
|
+
"theme.js",
|
|
70
|
+
"theme.json",
|
|
71
|
+
"theme.ts",
|
|
72
|
+
"tokens.json",
|
|
73
|
+
}
|
|
74
|
+
_MAX_SOURCES = 32
|
|
75
|
+
_MAX_SOURCE_BYTES = 1024 * 1024
|
|
76
|
+
# Cap on state.json / DESIGN.md text reads (issue M2): these files live inside
|
|
77
|
+
# the project tree and are attacker-influenced; an unbounded read_text lets a
|
|
78
|
+
# planted multi-GB file OOM the verifying process. 4 MiB is far above any
|
|
79
|
+
# legitimate baseline/state payload.
|
|
80
|
+
_MAX_DOC_BYTES = 4 * 1024 * 1024
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BaselineError(RuntimeError):
|
|
84
|
+
"""Raised when a baseline cannot be safely prepared or verified."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _utc_now() -> str:
|
|
88
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _sha256(path: Path) -> str:
|
|
92
|
+
digest = hashlib.sha256()
|
|
93
|
+
with path.open("rb") as handle:
|
|
94
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
95
|
+
digest.update(chunk)
|
|
96
|
+
return digest.hexdigest()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _read_text_capped(path: Path, label: str) -> str:
|
|
100
|
+
"""Read a project-local text file with a hard size ceiling.
|
|
101
|
+
|
|
102
|
+
state.json and DESIGN.md are attacker-influenced (writable by anyone with
|
|
103
|
+
repo access); an unbounded ``read_text`` lets a planted multi-GB file OOM
|
|
104
|
+
the verifying process (issue M2). Raise ``BaselineError`` on overflow.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
size = path.stat().st_size
|
|
108
|
+
except OSError as error:
|
|
109
|
+
raise BaselineError(f"{label} cannot be sized: {error}") from error
|
|
110
|
+
if size > _MAX_DOC_BYTES:
|
|
111
|
+
raise BaselineError(f"{label} exceeds {_MAX_DOC_BYTES} bytes")
|
|
112
|
+
try:
|
|
113
|
+
return path.read_text(encoding="utf-8-sig")
|
|
114
|
+
except (OSError, UnicodeError) as error:
|
|
115
|
+
raise BaselineError(f"{label} cannot be read: {error}") from error
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _relative(path: Path, root: Path) -> str:
|
|
119
|
+
return path.relative_to(root).as_posix()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _inside(path: Path, root: Path) -> bool:
|
|
123
|
+
try:
|
|
124
|
+
path.relative_to(root)
|
|
125
|
+
except ValueError:
|
|
126
|
+
return False
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _roots(project_root: Path | str, run_root: Path | str) -> tuple[Path, Path]:
|
|
131
|
+
project = Path(project_root).resolve()
|
|
132
|
+
run = Path(run_root).resolve()
|
|
133
|
+
if not project.is_dir():
|
|
134
|
+
raise BaselineError(f"project root is not a directory: {project}")
|
|
135
|
+
if not _inside(run, project):
|
|
136
|
+
raise BaselineError(f"run root escapes project root: {run}")
|
|
137
|
+
return project, run
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _atomic_write_text(path: Path, content: str) -> None:
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
143
|
+
temporary = Path(temporary_name)
|
|
144
|
+
try:
|
|
145
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
|
146
|
+
handle.write(content)
|
|
147
|
+
handle.flush()
|
|
148
|
+
os.fsync(handle.fileno())
|
|
149
|
+
os.replace(temporary, path)
|
|
150
|
+
finally:
|
|
151
|
+
if temporary.exists():
|
|
152
|
+
temporary.unlink()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _atomic_write_json(path: Path, value: dict[str, Any]) -> None:
|
|
156
|
+
_atomic_write_text(path, json.dumps(value, indent=2, ensure_ascii=False) + "\n")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _candidate_files(project: Path) -> list[Path]:
|
|
160
|
+
candidates: list[Path] = []
|
|
161
|
+
for relative in CANDIDATES:
|
|
162
|
+
candidate = project / relative
|
|
163
|
+
if candidate.is_symlink():
|
|
164
|
+
resolved = candidate.resolve()
|
|
165
|
+
if not _inside(resolved, project):
|
|
166
|
+
raise BaselineError(f"baseline candidate escapes project root: {relative.as_posix()}")
|
|
167
|
+
raise BaselineError(f"baseline candidate must not be a symlink: {relative.as_posix()}")
|
|
168
|
+
if candidate.is_file():
|
|
169
|
+
resolved = candidate.resolve()
|
|
170
|
+
if not _inside(resolved, project):
|
|
171
|
+
raise BaselineError(f"baseline candidate escapes project root: {relative.as_posix()}")
|
|
172
|
+
candidates.append(candidate)
|
|
173
|
+
return candidates
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _iter_project_files(project: Path) -> Iterable[Path]:
|
|
177
|
+
for directory, directory_names, file_names in os.walk(project, followlinks=False):
|
|
178
|
+
directory_names[:] = sorted(name for name in directory_names if name not in _SKIP_DIRECTORIES)
|
|
179
|
+
base = Path(directory)
|
|
180
|
+
for name in sorted(file_names):
|
|
181
|
+
path = base / name
|
|
182
|
+
lower_name = name.lower()
|
|
183
|
+
if path.suffix.lower() not in _FRONTEND_SUFFIXES and lower_name not in _CONFIG_NAMES:
|
|
184
|
+
continue
|
|
185
|
+
if path.is_symlink() or not path.is_file():
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
if path.stat().st_size > _MAX_SOURCE_BYTES:
|
|
189
|
+
continue
|
|
190
|
+
except OSError:
|
|
191
|
+
continue
|
|
192
|
+
resolved = path.resolve()
|
|
193
|
+
if _inside(resolved, project):
|
|
194
|
+
yield path
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _collect_sources(project: Path) -> list[Path]:
|
|
198
|
+
# Walk order is already deterministic (sorted dirs/names). Cap at
|
|
199
|
+
# _MAX_SOURCES; no keyword ranking — ranking was speculative and did not
|
|
200
|
+
# affect verify re-hash (ADR-0012 / ponytail review).
|
|
201
|
+
sources: list[Path] = []
|
|
202
|
+
for path in _iter_project_files(project):
|
|
203
|
+
sources.append(path)
|
|
204
|
+
if len(sources) >= _MAX_SOURCES:
|
|
205
|
+
break
|
|
206
|
+
return sources
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _unique(values: Iterable[str], limit: int = 24) -> list[str]:
|
|
210
|
+
result: list[str] = []
|
|
211
|
+
seen: set[str] = set()
|
|
212
|
+
for value in values:
|
|
213
|
+
normalized = value.strip()
|
|
214
|
+
if not normalized or normalized in seen:
|
|
215
|
+
continue
|
|
216
|
+
seen.add(normalized)
|
|
217
|
+
result.append(normalized)
|
|
218
|
+
if len(result) >= limit:
|
|
219
|
+
break
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _extract_evidence(project: Path, sources: list[Path]) -> dict[str, Any]:
|
|
224
|
+
custom_properties: list[dict[str, str]] = []
|
|
225
|
+
colors: list[str] = []
|
|
226
|
+
fonts: list[str] = []
|
|
227
|
+
spacing: list[str] = []
|
|
228
|
+
radii: list[str] = []
|
|
229
|
+
motion: list[str] = []
|
|
230
|
+
components: list[str] = []
|
|
231
|
+
pages: list[str] = []
|
|
232
|
+
source_records: list[dict[str, str]] = []
|
|
233
|
+
|
|
234
|
+
color_pattern = re.compile(
|
|
235
|
+
r"(?i)(?:#[0-9a-f]{3,8}\b|(?:rgb|hsl|hwb|lab|lch|oklab|oklch)\([^;{}]+\))"
|
|
236
|
+
)
|
|
237
|
+
property_pattern = re.compile(r"--([a-zA-Z0-9_-]+)\s*:\s*([^;{}]+)")
|
|
238
|
+
font_pattern = re.compile(r"(?i)font-family\s*:\s*([^;{}]+)")
|
|
239
|
+
|
|
240
|
+
for path in sources:
|
|
241
|
+
relative = _relative(path, project)
|
|
242
|
+
source_records.append({"path": relative, "sha256": _sha256(path)})
|
|
243
|
+
try:
|
|
244
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
245
|
+
except (OSError, UnicodeError):
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
for name, value in property_pattern.findall(text):
|
|
249
|
+
record = {"name": f"--{name}", "value": value.strip(), "source": relative}
|
|
250
|
+
custom_properties.append(record)
|
|
251
|
+
lowered = name.lower()
|
|
252
|
+
if any(term in lowered for term in ("color", "background", "surface", "text", "primary", "accent")):
|
|
253
|
+
colors.append(f"--{name}: {value.strip()}")
|
|
254
|
+
if any(term in lowered for term in ("space", "gap", "padding", "margin")):
|
|
255
|
+
spacing.append(f"--{name}: {value.strip()}")
|
|
256
|
+
if any(term in lowered for term in ("radius", "round")):
|
|
257
|
+
radii.append(f"--{name}: {value.strip()}")
|
|
258
|
+
if any(term in lowered for term in ("motion", "duration", "ease", "transition")):
|
|
259
|
+
motion.append(f"--{name}: {value.strip()}")
|
|
260
|
+
|
|
261
|
+
colors.extend(color_pattern.findall(text))
|
|
262
|
+
fonts.extend(font_pattern.findall(text))
|
|
263
|
+
|
|
264
|
+
lowered_relative = relative.lower()
|
|
265
|
+
if any(term in lowered_relative for term in ("component", "primitive", "shared", "/ui/")):
|
|
266
|
+
components.append(relative)
|
|
267
|
+
if any(term in lowered_relative for term in ("page", "route", "screen", "view")):
|
|
268
|
+
pages.append(relative)
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
"schema": "design-baseline-evidence/v1",
|
|
272
|
+
"project_root": str(project),
|
|
273
|
+
"sources": source_records,
|
|
274
|
+
"tokens": custom_properties[:80],
|
|
275
|
+
"colors": _unique(colors),
|
|
276
|
+
"fonts": _unique(fonts, limit=12),
|
|
277
|
+
"spacing": _unique(spacing, limit=16),
|
|
278
|
+
"radii": _unique(radii, limit=12),
|
|
279
|
+
"motion": _unique(motion, limit=12),
|
|
280
|
+
"components": _unique(components, limit=16),
|
|
281
|
+
"pages": _unique(pages, limit=12),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _yaml_value(value: str) -> str:
|
|
286
|
+
return json.dumps(value, ensure_ascii=False)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _token_value(evidence: dict[str, Any], terms: tuple[str, ...], fallback: str) -> str:
|
|
290
|
+
for token in evidence["tokens"]:
|
|
291
|
+
if any(term in token["name"].lower() for term in terms):
|
|
292
|
+
return token["name"]
|
|
293
|
+
if evidence["colors"] and terms == ("primary", "accent"):
|
|
294
|
+
return evidence["colors"][0].split(":", 1)[0]
|
|
295
|
+
return fallback
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _observed_list(values: list[str], empty: str, limit: int = 8) -> list[str]:
|
|
299
|
+
if not values:
|
|
300
|
+
return [f"- [inferred confidence=low] {empty}"]
|
|
301
|
+
return [f"- [observed] `{value}`" for value in values[:limit]]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _render_draft(project: Path, evidence: dict[str, Any]) -> str:
|
|
305
|
+
project_name = project.name
|
|
306
|
+
background = _token_value(evidence, ("background", "canvas"), "unresolved")
|
|
307
|
+
surface = _token_value(evidence, ("surface", "card", "panel"), "unresolved")
|
|
308
|
+
text = _token_value(evidence, ("text", "foreground"), "unresolved")
|
|
309
|
+
primary = _token_value(evidence, ("primary", "accent"), "unresolved")
|
|
310
|
+
source_paths = [item["path"] for item in evidence["sources"]]
|
|
311
|
+
|
|
312
|
+
lines = [
|
|
313
|
+
"---",
|
|
314
|
+
f"name: {_yaml_value(project_name)}",
|
|
315
|
+
"colors:",
|
|
316
|
+
f" background: {_yaml_value(background)}",
|
|
317
|
+
f" surface: {_yaml_value(surface)}",
|
|
318
|
+
f" text: {_yaml_value(text)}",
|
|
319
|
+
f" primary: {_yaml_value(primary)}",
|
|
320
|
+
"---",
|
|
321
|
+
"",
|
|
322
|
+
f"# Design System: {project_name}",
|
|
323
|
+
"",
|
|
324
|
+
"## Visual Theme & Atmosphere",
|
|
325
|
+
"",
|
|
326
|
+
"- [observed] Existing first-party theme, shared component, and page sources define the current visual baseline.",
|
|
327
|
+
"- [inferred confidence=medium] Preserve the observed token vocabulary, density, and component conventions when adding new surfaces.",
|
|
328
|
+
"",
|
|
329
|
+
"## Color Palette & Roles",
|
|
330
|
+
"",
|
|
331
|
+
*_observed_list(evidence["colors"], "Color roles are not explicit in the inspected sources; confirm them before use."),
|
|
332
|
+
"",
|
|
333
|
+
"## Typography Rules",
|
|
334
|
+
"",
|
|
335
|
+
*_observed_list(evidence["fonts"], "Typography hierarchy is not explicit; infer only from representative rendered surfaces."),
|
|
336
|
+
"- [inferred confidence=medium] Reuse the existing font stack and derive hierarchy from shared components before introducing new sizes.",
|
|
337
|
+
"",
|
|
338
|
+
"## Component Stylings",
|
|
339
|
+
"",
|
|
340
|
+
*_observed_list(evidence["components"], "No shared component path was detected; treat component styling as an unresolved gap."),
|
|
341
|
+
"- [inferred confidence=medium] Prefer existing primitives and variants over page-local replacements.",
|
|
342
|
+
"",
|
|
343
|
+
"## Layout Principles",
|
|
344
|
+
"",
|
|
345
|
+
*_observed_list(evidence["spacing"], "No named spacing tokens were detected; confirm the base spacing rhythm."),
|
|
346
|
+
*[f"- [observed] Representative page: `{path}`" for path in evidence["pages"][:6]],
|
|
347
|
+
"- [inferred confidence=medium] Match the density and alignment rhythm of representative pages.",
|
|
348
|
+
"",
|
|
349
|
+
"## Motion & Interaction",
|
|
350
|
+
"",
|
|
351
|
+
*_observed_list(evidence["motion"], "No motion token was detected; keep transitions restrained until interaction evidence is available."),
|
|
352
|
+
"- [inferred confidence=medium] Preserve visible hover, focus, pressed, loading, and reduced-motion behavior from existing primitives.",
|
|
353
|
+
"",
|
|
354
|
+
"## Accessibility",
|
|
355
|
+
"",
|
|
356
|
+
"- [inferred confidence=medium] Preserve semantic controls, keyboard focus visibility, and non-color state cues present in existing primitives.",
|
|
357
|
+
"- [inferred confidence=low] Contrast, touch targets, text scaling, and reduced-motion behavior require runtime verification.",
|
|
358
|
+
"",
|
|
359
|
+
"## Source Evidence & Confidence",
|
|
360
|
+
"",
|
|
361
|
+
]
|
|
362
|
+
for source in evidence["sources"]:
|
|
363
|
+
lines.extend(
|
|
364
|
+
[
|
|
365
|
+
f"- [observed] path: `{source['path']}`",
|
|
366
|
+
f" sha256: `{source['sha256']}`",
|
|
367
|
+
" confidence: high",
|
|
368
|
+
]
|
|
369
|
+
)
|
|
370
|
+
lines.extend(
|
|
371
|
+
[
|
|
372
|
+
"",
|
|
373
|
+
"## Known Gaps & Exceptions",
|
|
374
|
+
"",
|
|
375
|
+
"- [inferred confidence=medium] Semantic intent inferred from implementation must be reviewed before this draft becomes project authority.",
|
|
376
|
+
]
|
|
377
|
+
)
|
|
378
|
+
if not source_paths:
|
|
379
|
+
lines.append("- [inferred confidence=low] No high-signal first-party frontend source was discovered.")
|
|
380
|
+
if not evidence["radii"]:
|
|
381
|
+
lines.append("- [inferred confidence=low] Shape and corner-radius conventions are unresolved.")
|
|
382
|
+
else:
|
|
383
|
+
lines.extend(f"- [observed] Shape token `{value}`" for value in evidence["radii"][:6])
|
|
384
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _state_path(run: Path) -> Path:
|
|
388
|
+
return run / STATE_RELATIVE
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _write_state(run: Path, state: dict[str, Any]) -> dict[str, Any]:
|
|
392
|
+
_atomic_write_json(_state_path(run), state)
|
|
393
|
+
return state
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _normalize_heading(value: str) -> str:
|
|
397
|
+
value = re.sub(r"^\d+[.)]\s*", "", value.strip().lower())
|
|
398
|
+
return re.sub(r"\s+", " ", value)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _parse_sections(text: str) -> dict[str, str]:
|
|
402
|
+
matches = list(re.finditer(r"(?m)^##\s+(?:\d+[.)]\s*)?(.+?)\s*$", text))
|
|
403
|
+
sections: dict[str, str] = {}
|
|
404
|
+
for index, match in enumerate(matches):
|
|
405
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
|
406
|
+
sections[_normalize_heading(match.group(1))] = text[match.end() : end].strip()
|
|
407
|
+
return sections
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _safe_relative_file(root: Path, relative: str, label: str) -> Path:
|
|
411
|
+
if not isinstance(relative, str) or not relative or "\\" in relative:
|
|
412
|
+
raise BaselineError(f"{label} must be a normalized project-relative path: {relative!r}")
|
|
413
|
+
# Reject NUL / control chars up front: PurePath accepts them at construction
|
|
414
|
+
# and only fails at lstat/open with a raw ValueError (issue L3), which
|
|
415
|
+
# escapes BaselineError handling and surfaces a traceback to the agent.
|
|
416
|
+
if any(ord(ch) < 0x20 or ch == "\x7f" for ch in relative):
|
|
417
|
+
raise BaselineError(f"{label} contains control characters: {relative!r}")
|
|
418
|
+
path_value = Path(relative)
|
|
419
|
+
if path_value.is_absolute() or ".." in path_value.parts:
|
|
420
|
+
raise BaselineError(f"{label} escapes project root: {relative}")
|
|
421
|
+
path = root / path_value
|
|
422
|
+
if path.is_symlink():
|
|
423
|
+
raise BaselineError(f"{label} must not be a symlink: {relative}")
|
|
424
|
+
resolved = path.resolve()
|
|
425
|
+
if not _inside(resolved, root):
|
|
426
|
+
raise BaselineError(f"{label} escapes project root: {relative}")
|
|
427
|
+
if not resolved.is_file():
|
|
428
|
+
raise BaselineError(f"{label} does not exist: {relative}")
|
|
429
|
+
return resolved
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _parse_sources(section: str) -> list[dict[str, str]]:
|
|
433
|
+
entries: list[dict[str, str]] = []
|
|
434
|
+
current: dict[str, str] | None = None
|
|
435
|
+
path_pattern = re.compile(
|
|
436
|
+
r"^\s*-\s+(?:\[observed\]\s+)?path:\s*[`'\"]?(.+?)[`'\"]?\s*$"
|
|
437
|
+
)
|
|
438
|
+
hash_pattern = re.compile(r"^\s+sha256:\s*[`'\"]?([0-9a-f]{64})[`'\"]?\s*$")
|
|
439
|
+
for line in section.splitlines():
|
|
440
|
+
path_match = path_pattern.match(line)
|
|
441
|
+
if path_match:
|
|
442
|
+
if current is not None:
|
|
443
|
+
entries.append(current)
|
|
444
|
+
current = {"path": path_match.group(1).strip()}
|
|
445
|
+
continue
|
|
446
|
+
hash_match = hash_pattern.match(line)
|
|
447
|
+
if hash_match and current is not None:
|
|
448
|
+
current["sha256"] = hash_match.group(1)
|
|
449
|
+
if current is not None:
|
|
450
|
+
entries.append(current)
|
|
451
|
+
return entries
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _validate_claim_labels(sections: dict[str, str]) -> None:
|
|
455
|
+
claim_pattern = re.compile(
|
|
456
|
+
r"^\s*-\s+\[(?:observed|inferred confidence=(?:high|medium|low))\]\s+\S"
|
|
457
|
+
)
|
|
458
|
+
for heading in REQUIRED_SECTIONS:
|
|
459
|
+
content = sections[_normalize_heading(heading)]
|
|
460
|
+
for line in content.splitlines():
|
|
461
|
+
if re.match(r"^\s*-\s+", line) and not claim_pattern.match(line):
|
|
462
|
+
raise BaselineError(f"unlabelled design claim in {heading}: {line.strip()}")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _validate_source_records(project: Path, sources: Any) -> list[dict[str, str]]:
|
|
466
|
+
if not isinstance(sources, list) or not sources:
|
|
467
|
+
raise BaselineError("baseline must contain at least one provenance source")
|
|
468
|
+
normalized: list[dict[str, str]] = []
|
|
469
|
+
seen: set[str] = set()
|
|
470
|
+
for source in sources:
|
|
471
|
+
if not isinstance(source, dict):
|
|
472
|
+
raise BaselineError("baseline source entry must be an object")
|
|
473
|
+
relative = source.get("path")
|
|
474
|
+
expected = source.get("sha256")
|
|
475
|
+
if not isinstance(relative, str) or not isinstance(expected, str):
|
|
476
|
+
raise BaselineError("baseline source requires path and sha256 strings")
|
|
477
|
+
if not re.fullmatch(r"[0-9a-f]{64}", expected):
|
|
478
|
+
raise BaselineError(f"invalid source SHA-256 for {relative}")
|
|
479
|
+
if relative in seen:
|
|
480
|
+
raise BaselineError(f"duplicate baseline source: {relative}")
|
|
481
|
+
seen.add(relative)
|
|
482
|
+
source_path = _safe_relative_file(project, relative, "baseline source")
|
|
483
|
+
actual = _sha256(source_path)
|
|
484
|
+
if actual != expected:
|
|
485
|
+
raise BaselineError(f"baseline source changed: {relative}")
|
|
486
|
+
normalized.append({"path": relative, "sha256": expected})
|
|
487
|
+
return normalized
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _load_state(project: Path, run: Path) -> dict[str, Any]:
|
|
491
|
+
state_path = _state_path(run)
|
|
492
|
+
if state_path.is_symlink() or not state_path.is_file():
|
|
493
|
+
raise BaselineError(f"baseline state does not exist: {state_path}")
|
|
494
|
+
try:
|
|
495
|
+
state = json.loads(_read_text_capped(state_path, "baseline state"))
|
|
496
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
497
|
+
raise BaselineError(f"cannot read baseline state: {error}") from error
|
|
498
|
+
if not isinstance(state, dict) or state.get("schema") != SCHEMA:
|
|
499
|
+
raise BaselineError("unsupported baseline state schema")
|
|
500
|
+
if state.get("project_root") != str(project):
|
|
501
|
+
raise BaselineError("baseline state is bound to a different project root")
|
|
502
|
+
return state
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _candidate_snapshot(project: Path, candidates: list[Path]) -> dict[str, str]:
|
|
506
|
+
return {_relative(path, project): _sha256(path) for path in candidates}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _assert_candidate_snapshot(project: Path, state: dict[str, Any]) -> None:
|
|
510
|
+
expected = state.get("candidate_sha256", {})
|
|
511
|
+
if not isinstance(expected, dict) or not all(
|
|
512
|
+
isinstance(path, str) and isinstance(digest, str) for path, digest in expected.items()
|
|
513
|
+
):
|
|
514
|
+
raise BaselineError("invalid candidate snapshot in baseline state")
|
|
515
|
+
actual = _candidate_snapshot(project, _candidate_files(project))
|
|
516
|
+
if actual != expected:
|
|
517
|
+
raise BaselineError("baseline candidates changed after preparation; run prepare again")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def prepare(project_root: Path | str, run_root: Path | str) -> dict[str, Any]:
|
|
521
|
+
"""Discover a valid baseline or generate a provenance-backed run-local draft."""
|
|
522
|
+
|
|
523
|
+
project, run = _roots(project_root, run_root)
|
|
524
|
+
state_directory = run / "design-baseline"
|
|
525
|
+
state_directory.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
candidates = _candidate_files(project)
|
|
527
|
+
candidate_names = [_relative(path, project) for path in candidates]
|
|
528
|
+
|
|
529
|
+
if len(candidates) > 1:
|
|
530
|
+
hashes = _candidate_snapshot(project, candidates)
|
|
531
|
+
if len(set(hashes.values())) > 1:
|
|
532
|
+
return _write_state(
|
|
533
|
+
run,
|
|
534
|
+
{
|
|
535
|
+
"schema": SCHEMA,
|
|
536
|
+
"status": "ambiguous",
|
|
537
|
+
"project_root": str(project),
|
|
538
|
+
"baseline": None,
|
|
539
|
+
"draft": None,
|
|
540
|
+
"sources": [],
|
|
541
|
+
"decision": None,
|
|
542
|
+
"candidates": candidate_names,
|
|
543
|
+
"candidate_sha256": hashes,
|
|
544
|
+
},
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Existing candidates are validated by the same strict parser used by
|
|
548
|
+
# verify(). An incomplete candidate remains untouched while a replacement
|
|
549
|
+
# proposal is generated in the run directory.
|
|
550
|
+
if candidates:
|
|
551
|
+
selected = project / CANDIDATES[0] if (project / CANDIDATES[0]) in candidates else candidates[0]
|
|
552
|
+
try:
|
|
553
|
+
sources = _validate_baseline_document(selected, project)
|
|
554
|
+
except BaselineError:
|
|
555
|
+
sources = None
|
|
556
|
+
if sources is not None:
|
|
557
|
+
state = {
|
|
558
|
+
"schema": SCHEMA,
|
|
559
|
+
"status": "ready",
|
|
560
|
+
"project_root": str(project),
|
|
561
|
+
"baseline": {
|
|
562
|
+
"path": _relative(selected, project),
|
|
563
|
+
"sha256": _sha256(selected),
|
|
564
|
+
"origin": "existing",
|
|
565
|
+
},
|
|
566
|
+
"draft": None,
|
|
567
|
+
"sources": sources,
|
|
568
|
+
"decision": {"kind": "existing", "confirmed_at": _utc_now()},
|
|
569
|
+
}
|
|
570
|
+
return _write_state(run, state)
|
|
571
|
+
|
|
572
|
+
source_files = _collect_sources(project)
|
|
573
|
+
evidence = _extract_evidence(project, source_files)
|
|
574
|
+
draft_text = _render_draft(project, evidence)
|
|
575
|
+
evidence_path = run / EVIDENCE_RELATIVE
|
|
576
|
+
draft_path = run / DRAFT_RELATIVE
|
|
577
|
+
_atomic_write_json(evidence_path, evidence)
|
|
578
|
+
_atomic_write_text(draft_path, draft_text)
|
|
579
|
+
return _write_state(
|
|
580
|
+
run,
|
|
581
|
+
{
|
|
582
|
+
"schema": SCHEMA,
|
|
583
|
+
"status": "needs_confirmation",
|
|
584
|
+
"project_root": str(project),
|
|
585
|
+
"baseline": None,
|
|
586
|
+
"draft": {"path": DRAFT_RELATIVE.as_posix(), "sha256": _sha256(draft_path)},
|
|
587
|
+
"sources": evidence["sources"],
|
|
588
|
+
"decision": None,
|
|
589
|
+
"candidates": candidate_names,
|
|
590
|
+
"candidate_sha256": _candidate_snapshot(project, candidates),
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _validate_baseline_document(path: Path, project: Path) -> list[dict[str, str]]:
|
|
596
|
+
try:
|
|
597
|
+
text = _read_text_capped(path, "baseline document").replace("\r\n", "\n")
|
|
598
|
+
except (OSError, UnicodeError) as error:
|
|
599
|
+
raise BaselineError(f"cannot read baseline document: {error}") from error
|
|
600
|
+
|
|
601
|
+
frontmatter = re.match(r"\A---\s*\n(.*?)\n---\s*(?:\n|\Z)", text, re.DOTALL)
|
|
602
|
+
if frontmatter is None:
|
|
603
|
+
raise BaselineError("baseline is missing YAML frontmatter")
|
|
604
|
+
metadata = frontmatter.group(1)
|
|
605
|
+
if not re.search(r"(?m)^name:\s*\S.*$", metadata):
|
|
606
|
+
raise BaselineError("baseline frontmatter is missing name")
|
|
607
|
+
colors = re.search(r"(?ms)^colors:\s*\n((?:[ \t]+[^\n]*(?:\n|$))*)", metadata)
|
|
608
|
+
if colors is None or not re.search(r"(?m)^\s{2,}[A-Za-z0-9_-]+:\s*\S.*$", colors.group(1)):
|
|
609
|
+
raise BaselineError("baseline frontmatter is missing color roles")
|
|
610
|
+
|
|
611
|
+
sections = _parse_sections(text)
|
|
612
|
+
for heading in REQUIRED_SECTIONS:
|
|
613
|
+
key = _normalize_heading(heading)
|
|
614
|
+
if key not in sections or not re.sub(r"[`*_#>\-\s]", "", sections[key]):
|
|
615
|
+
raise BaselineError(f"baseline is missing content for {heading}")
|
|
616
|
+
_validate_claim_labels(sections)
|
|
617
|
+
sources = _parse_sources(sections[_normalize_heading("Source Evidence & Confidence")])
|
|
618
|
+
return _validate_source_records(project, sources)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def confirm(
|
|
622
|
+
project_root: Path | str,
|
|
623
|
+
run_root: Path | str,
|
|
624
|
+
decision: str,
|
|
625
|
+
reason: str | None = None,
|
|
626
|
+
) -> dict[str, Any]:
|
|
627
|
+
"""Confirm a generated draft or explicitly waive the baseline gate."""
|
|
628
|
+
|
|
629
|
+
project, run = _roots(project_root, run_root)
|
|
630
|
+
state = _load_state(project, run)
|
|
631
|
+
normalized_decision = decision.strip().lower()
|
|
632
|
+
if state.get("status") != "needs_confirmation":
|
|
633
|
+
raise BaselineError(f"baseline state cannot be confirmed from status: {state.get('status')}")
|
|
634
|
+
|
|
635
|
+
_assert_candidate_snapshot(project, state)
|
|
636
|
+
if normalized_decision == "waive":
|
|
637
|
+
normalized_reason = reason.strip() if isinstance(reason, str) else ""
|
|
638
|
+
if not normalized_reason:
|
|
639
|
+
raise BaselineError("waiver requires a non-empty reason")
|
|
640
|
+
_validate_source_records(project, state.get("sources"))
|
|
641
|
+
state["status"] = "waived"
|
|
642
|
+
state["baseline"] = None
|
|
643
|
+
state["decision"] = {
|
|
644
|
+
"kind": "waived",
|
|
645
|
+
"reason": normalized_reason,
|
|
646
|
+
"confirmed_at": _utc_now(),
|
|
647
|
+
}
|
|
648
|
+
_write_state(run, state)
|
|
649
|
+
return verify(project, run)
|
|
650
|
+
|
|
651
|
+
if normalized_decision != "accept":
|
|
652
|
+
raise BaselineError(f"unsupported baseline decision: {decision}")
|
|
653
|
+
|
|
654
|
+
draft = state.get("draft")
|
|
655
|
+
if not isinstance(draft, dict) or draft.get("path") != DRAFT_RELATIVE.as_posix():
|
|
656
|
+
raise BaselineError("baseline state does not bind the expected draft")
|
|
657
|
+
expected_draft_hash = draft.get("sha256")
|
|
658
|
+
if not isinstance(expected_draft_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_draft_hash):
|
|
659
|
+
raise BaselineError("baseline draft has an invalid SHA-256 binding")
|
|
660
|
+
draft_path = _safe_relative_file(run, DRAFT_RELATIVE.as_posix(), "baseline draft")
|
|
661
|
+
if _sha256(draft_path) != expected_draft_hash:
|
|
662
|
+
raise BaselineError("baseline draft changed after preparation; run prepare again")
|
|
663
|
+
sources = _validate_baseline_document(draft_path, project)
|
|
664
|
+
if sources != state.get("sources"):
|
|
665
|
+
raise BaselineError("baseline draft provenance does not match prepared state")
|
|
666
|
+
|
|
667
|
+
canonical = project / CANDIDATES[0]
|
|
668
|
+
if canonical.is_symlink():
|
|
669
|
+
raise BaselineError("canonical DESIGN.md must not be a symlink")
|
|
670
|
+
draft_text = _read_text_capped(draft_path, "baseline draft")
|
|
671
|
+
_atomic_write_text(canonical, draft_text)
|
|
672
|
+
# Post-write TOCTOU hardening (issue M1): between the pre-write symlink
|
|
673
|
+
# check and os.replace, a concurrent writer could swap DESIGN.md for a
|
|
674
|
+
# symlink escaping the project root. Re-assert the canonical entry is a
|
|
675
|
+
# regular file resolving inside the project before trusting the binding;
|
|
676
|
+
# the subsequent verify() then re-hashes it as the durable authority.
|
|
677
|
+
if canonical.is_symlink() or not canonical.is_file():
|
|
678
|
+
raise BaselineError("canonical DESIGN.md was replaced during write; re-run prepare")
|
|
679
|
+
if not _inside(canonical.resolve(), project):
|
|
680
|
+
raise BaselineError("canonical DESIGN.md escapes project root after write")
|
|
681
|
+
state["status"] = "ready"
|
|
682
|
+
state["baseline"] = {
|
|
683
|
+
"path": CANDIDATES[0].as_posix(),
|
|
684
|
+
"sha256": _sha256(canonical),
|
|
685
|
+
"origin": "generated",
|
|
686
|
+
}
|
|
687
|
+
state["sources"] = sources
|
|
688
|
+
state["decision"] = {"kind": "accepted", "confirmed_at": _utc_now()}
|
|
689
|
+
state["candidate_sha256"] = _candidate_snapshot(project, _candidate_files(project))
|
|
690
|
+
_write_state(run, state)
|
|
691
|
+
return verify(project, run)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def verify(project_root: Path | str, run_root: Path | str) -> dict[str, Any]:
|
|
695
|
+
"""Return a verified binding for downstream consumers."""
|
|
696
|
+
|
|
697
|
+
project, run = _roots(project_root, run_root)
|
|
698
|
+
state = _load_state(project, run)
|
|
699
|
+
status = state.get("status")
|
|
700
|
+
decision = state.get("decision")
|
|
701
|
+
|
|
702
|
+
if status == "waived":
|
|
703
|
+
if (
|
|
704
|
+
not isinstance(decision, dict)
|
|
705
|
+
or decision.get("kind") != "waived"
|
|
706
|
+
or not isinstance(decision.get("reason"), str)
|
|
707
|
+
or not decision["reason"].strip()
|
|
708
|
+
or not isinstance(decision.get("confirmed_at"), str)
|
|
709
|
+
):
|
|
710
|
+
raise BaselineError("invalid baseline waiver decision")
|
|
711
|
+
_validate_source_records(project, state.get("sources"))
|
|
712
|
+
return state
|
|
713
|
+
|
|
714
|
+
if status != "ready":
|
|
715
|
+
raise BaselineError(f"baseline is not ready: {status}")
|
|
716
|
+
if not isinstance(decision, dict) or decision.get("kind") not in {"accepted", "existing"}:
|
|
717
|
+
raise BaselineError("ready baseline lacks a valid decision")
|
|
718
|
+
|
|
719
|
+
baseline = state.get("baseline")
|
|
720
|
+
if not isinstance(baseline, dict):
|
|
721
|
+
raise BaselineError("ready baseline lacks a binding")
|
|
722
|
+
relative = baseline.get("path")
|
|
723
|
+
expected_hash = baseline.get("sha256")
|
|
724
|
+
origin = baseline.get("origin")
|
|
725
|
+
allowed_paths = {candidate.as_posix() for candidate in CANDIDATES}
|
|
726
|
+
if relative not in allowed_paths:
|
|
727
|
+
raise BaselineError(f"unsupported baseline path: {relative}")
|
|
728
|
+
if not isinstance(expected_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
|
|
729
|
+
raise BaselineError("invalid baseline SHA-256 binding")
|
|
730
|
+
if origin not in {"existing", "generated"}:
|
|
731
|
+
raise BaselineError(f"invalid baseline origin: {origin}")
|
|
732
|
+
if origin == "generated" and decision.get("kind") != "accepted":
|
|
733
|
+
raise BaselineError("generated baseline lacks explicit acceptance")
|
|
734
|
+
|
|
735
|
+
candidates = _candidate_files(project)
|
|
736
|
+
candidate_hashes = _candidate_snapshot(project, candidates)
|
|
737
|
+
if len(set(candidate_hashes.values())) > 1:
|
|
738
|
+
raise BaselineError("project has conflicting DESIGN.md candidates")
|
|
739
|
+
if relative not in candidate_hashes:
|
|
740
|
+
raise BaselineError(f"bound baseline candidate does not exist: {relative}")
|
|
741
|
+
baseline_path = _safe_relative_file(project, relative, "baseline")
|
|
742
|
+
if _sha256(baseline_path) != expected_hash:
|
|
743
|
+
raise BaselineError(f"baseline changed after binding: {relative}")
|
|
744
|
+
document_sources = _validate_baseline_document(baseline_path, project)
|
|
745
|
+
state_sources = _validate_source_records(project, state.get("sources"))
|
|
746
|
+
if document_sources != state_sources:
|
|
747
|
+
raise BaselineError("baseline provenance does not match bound state")
|
|
748
|
+
return state
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def main(argv: list[str] | None = None) -> int:
|
|
752
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
753
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
754
|
+
for command in ("prepare", "verify"):
|
|
755
|
+
subparser = subparsers.add_parser(command)
|
|
756
|
+
subparser.add_argument("project_root", type=Path)
|
|
757
|
+
subparser.add_argument("run_root", type=Path)
|
|
758
|
+
confirm_parser = subparsers.add_parser("confirm")
|
|
759
|
+
confirm_parser.add_argument("project_root", type=Path)
|
|
760
|
+
confirm_parser.add_argument("run_root", type=Path)
|
|
761
|
+
confirm_parser.add_argument("--decision", required=True, choices=("accept", "waive"))
|
|
762
|
+
confirm_parser.add_argument("--reason")
|
|
763
|
+
args = parser.parse_args(argv)
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
if args.command == "prepare":
|
|
767
|
+
result = prepare(args.project_root, args.run_root)
|
|
768
|
+
elif args.command == "confirm":
|
|
769
|
+
result = confirm(args.project_root, args.run_root, args.decision, args.reason)
|
|
770
|
+
else:
|
|
771
|
+
result = verify(args.project_root, args.run_root)
|
|
772
|
+
except BaselineError as error:
|
|
773
|
+
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
774
|
+
return 2
|
|
775
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
776
|
+
return 0
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
if __name__ == "__main__":
|
|
780
|
+
sys.exit(main())
|