its-magic 0.1.2-43 → 0.1.2-49
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/README.md +119 -0
- package/package.json +1 -1
- package/scripts/check_intake_template_parity.py +90 -2
- package/scripts/guard_installer_publish.py +19 -1
- package/template/.cursor/commands/architecture.md +16 -6
- package/template/.cursor/commands/auto.md +42 -0
- package/template/.cursor/commands/execute.md +49 -0
- package/template/.cursor/commands/qa.md +37 -0
- package/template/.cursor/commands/release.md +11 -0
- package/template/.cursor/commands/verify-work.md +43 -1
- package/template/.cursor/rules/caveman.mdc +43 -0
- package/template/.cursor/scratchpad.local.example.md +39 -4
- package/template/.cursor/scratchpad.md +34 -4
- package/template/.github/workflows/ci.yml +4 -114
- package/template/README.md +113 -0
- package/template/docs/engineering/auto-orchestration-reference.md +62 -1
- package/template/docs/engineering/context/installer-owned-paths.manifest +12 -0
- package/template/docs/engineering/context/readme-section-affinity.json +30 -0
- package/template/docs/engineering/runbook.md +151 -5
- package/template/scripts/auto_outer_driver.py +521 -0
- package/template/scripts/check_downstream_ci_guard.py +67 -0
- package/template/scripts/check_intake_template_parity.py +90 -2
- package/template/scripts/downstream_ci_guard_lib.py +222 -0
- package/template/scripts/enforce-triad-hot-surface.py +135 -8
- package/template/scripts/readme_feature_coverage_lib.py +608 -0
- package/template/scripts/uat_probe_lib.py +868 -0
- package/template/scripts/validate_readme_feature_coverage.py +140 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
UAT probe resolver (US-0092 / DEC-0078; US-0093 / DEC-0079).
|
|
4
|
+
|
|
5
|
+
Shared by /verify-work and /qa for self-verify acceptance steps.
|
|
6
|
+
Fail-closed — no silent PASS. Lib never invokes browser MCP (BUG-0006).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
UAT_PROBE_UNRESOLVED = "UAT_PROBE_UNRESOLVED"
|
|
22
|
+
UAT_STACK_PROFILE_UNKNOWN = "UAT_STACK_PROFILE_UNKNOWN"
|
|
23
|
+
UAT_PROBE_TIMEOUT = "UAT_PROBE_TIMEOUT"
|
|
24
|
+
UAT_PROBE_FAILED = "UAT_PROBE_FAILED"
|
|
25
|
+
UAT_PROBE_FORBIDDEN = "UAT_PROBE_FORBIDDEN"
|
|
26
|
+
UAT_PROBE_PASS = "UAT_PROBE_PASS"
|
|
27
|
+
UAT_BROWSER_UNAVAILABLE = "UAT_BROWSER_UNAVAILABLE"
|
|
28
|
+
UAT_BROWSER_PROBE_FAILED = "UAT_BROWSER_PROBE_FAILED"
|
|
29
|
+
UAT_BROWSER_PROBE_TIMEOUT = "UAT_BROWSER_PROBE_TIMEOUT"
|
|
30
|
+
|
|
31
|
+
PROBE_KINDS = (
|
|
32
|
+
"build",
|
|
33
|
+
"test",
|
|
34
|
+
"api_health",
|
|
35
|
+
"process_health",
|
|
36
|
+
"browser_smoke",
|
|
37
|
+
"cli_smoke",
|
|
38
|
+
"manual_operator",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
FORBIDDEN_PATH_TOKENS = (".env", "intake_evidence", "handoffs/intake_evidence")
|
|
42
|
+
SECRET_FORBIDDEN_TOKENS = ("password", "credential", "api key")
|
|
43
|
+
|
|
44
|
+
JUDGMENT_DENY_TOKENS = (
|
|
45
|
+
"visually",
|
|
46
|
+
"aesthetically",
|
|
47
|
+
"operator confirms",
|
|
48
|
+
"subjective",
|
|
49
|
+
"human judgment",
|
|
50
|
+
"eyeball",
|
|
51
|
+
"manually verify appearance",
|
|
52
|
+
"approve layout",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
AUTOMATABLE_UI_TOKENS = (
|
|
56
|
+
"click",
|
|
57
|
+
"fill",
|
|
58
|
+
"navigate",
|
|
59
|
+
"smoke",
|
|
60
|
+
"form",
|
|
61
|
+
"submit",
|
|
62
|
+
"button",
|
|
63
|
+
"page load",
|
|
64
|
+
"scroll",
|
|
65
|
+
"type into",
|
|
66
|
+
"select",
|
|
67
|
+
"checkbox",
|
|
68
|
+
"dropdown",
|
|
69
|
+
"ui",
|
|
70
|
+
"browser",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
BROWSER_PROBE_MODES = ("cursor", "http_fallback", "playwright_fallback")
|
|
74
|
+
|
|
75
|
+
DEFAULT_PROBE_TIMEOUT = 120
|
|
76
|
+
DEFAULT_POLL_SECONDS = 60
|
|
77
|
+
DEFAULT_POLL_INTERVAL = 2
|
|
78
|
+
MAX_SCREENSHOTS = 5
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _merge_scratchpad(repo: Path) -> dict[str, str]:
|
|
82
|
+
values: dict[str, str] = {}
|
|
83
|
+
for name in (".cursor/scratchpad.md", ".cursor/scratchpad.local.md"):
|
|
84
|
+
path = repo / name
|
|
85
|
+
if not path.is_file():
|
|
86
|
+
continue
|
|
87
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
88
|
+
stripped = line.strip()
|
|
89
|
+
if not stripped or stripped.startswith("#"):
|
|
90
|
+
continue
|
|
91
|
+
if "=" in stripped:
|
|
92
|
+
key, _, val = stripped.partition("=")
|
|
93
|
+
values[key.strip()] = val.strip()
|
|
94
|
+
return values
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_int(merged: dict[str, str], key: str, default: int) -> int:
|
|
98
|
+
raw = merged.get(key, "")
|
|
99
|
+
if not raw:
|
|
100
|
+
return default
|
|
101
|
+
try:
|
|
102
|
+
return max(1, int(raw))
|
|
103
|
+
except ValueError:
|
|
104
|
+
return default
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_browser_probe_mode(repo: Path) -> str:
|
|
108
|
+
merged = _merge_scratchpad(repo)
|
|
109
|
+
mode = merged.get("UAT_BROWSER_PROBE_MODE", "cursor").strip()
|
|
110
|
+
return mode if mode in BROWSER_PROBE_MODES else "cursor"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_fallback_chain(repo: Path) -> bool:
|
|
114
|
+
merged = _merge_scratchpad(repo)
|
|
115
|
+
raw = merged.get("UAT_BROWSER_FALLBACK_CHAIN", "1").strip()
|
|
116
|
+
if raw in ("0", "1"):
|
|
117
|
+
return raw == "1"
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read_poll_settings(repo: Path) -> tuple[int, int]:
|
|
122
|
+
merged = _merge_scratchpad(repo)
|
|
123
|
+
cap = _read_int(merged, "UAT_PROCESS_HEALTH_POLL_SECONDS", DEFAULT_POLL_SECONDS)
|
|
124
|
+
interval = _read_int(
|
|
125
|
+
merged, "UAT_PROCESS_HEALTH_POLL_INTERVAL_SECONDS", DEFAULT_POLL_INTERVAL
|
|
126
|
+
)
|
|
127
|
+
return cap, interval
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def detect_stack_profile(repo: Path) -> str | None:
|
|
131
|
+
if (repo / "package.json").is_file():
|
|
132
|
+
return "node"
|
|
133
|
+
if (repo / "pyproject.toml").is_file() or (repo / "setup.py").is_file():
|
|
134
|
+
return "python"
|
|
135
|
+
if (repo / "go.mod").is_file():
|
|
136
|
+
return "go"
|
|
137
|
+
if list(repo.glob("*.csproj")):
|
|
138
|
+
return "dotnet"
|
|
139
|
+
if (repo / "pom.xml").is_file():
|
|
140
|
+
return "java"
|
|
141
|
+
readme = repo / "README.md"
|
|
142
|
+
if readme.is_file() and "generated" in readme.read_text(encoding="utf-8").lower():
|
|
143
|
+
return "generated"
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _forbidden(step_text: str) -> bool:
|
|
148
|
+
lower = step_text.lower()
|
|
149
|
+
if any(tok in lower for tok in FORBIDDEN_PATH_TOKENS):
|
|
150
|
+
return True
|
|
151
|
+
return any(tok in lower for tok in SECRET_FORBIDDEN_TOKENS)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _has_judgment_deny(lower: str) -> bool:
|
|
155
|
+
return any(tok in lower for tok in JUDGMENT_DENY_TOKENS)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _has_automatable_ui(lower: str) -> bool:
|
|
159
|
+
return any(tok in lower for tok in AUTOMATABLE_UI_TOKENS)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _read_test_command(repo: Path) -> str | None:
|
|
163
|
+
runbook = repo / "docs" / "engineering" / "runbook.md"
|
|
164
|
+
if runbook.is_file():
|
|
165
|
+
m = re.search(r"^TEST_COMMAND:\s*(.+)$", runbook.read_text(encoding="utf-8"), re.M)
|
|
166
|
+
if m:
|
|
167
|
+
cmd = m.group(1).strip()
|
|
168
|
+
if cmd and "not configured" not in cmd.lower():
|
|
169
|
+
return cmd
|
|
170
|
+
merged = _merge_scratchpad(repo)
|
|
171
|
+
return merged.get("TEST_COMMAND") or None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _read_build_command(repo: Path, profile: str | None) -> str | None:
|
|
175
|
+
if profile == "node" and (repo / "package.json").is_file():
|
|
176
|
+
try:
|
|
177
|
+
pkg = json.loads((repo / "package.json").read_text(encoding="utf-8"))
|
|
178
|
+
scripts = pkg.get("scripts", {})
|
|
179
|
+
if "build" in scripts:
|
|
180
|
+
return "npm run build"
|
|
181
|
+
except (json.JSONDecodeError, OSError):
|
|
182
|
+
pass
|
|
183
|
+
if profile == "python" and (repo / "pyproject.toml").is_file():
|
|
184
|
+
return "python -m build"
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _read_health_url(repo: Path) -> str | None:
|
|
189
|
+
rc = repo / "docs" / "engineering" / "runtime-connectivity.md"
|
|
190
|
+
if rc.is_file():
|
|
191
|
+
m = re.search(r"https?://[^\s\)]+", rc.read_text(encoding="utf-8"))
|
|
192
|
+
if m:
|
|
193
|
+
return m.group(0).rstrip(".,)")
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def resolve_browser_url(repo: Path) -> str | None:
|
|
198
|
+
url = _read_health_url(repo)
|
|
199
|
+
if url:
|
|
200
|
+
return url
|
|
201
|
+
merged = _merge_scratchpad(repo)
|
|
202
|
+
port = merged.get("DEV_SERVER_PORT", "").strip()
|
|
203
|
+
if port:
|
|
204
|
+
return f"http://localhost:{port}/"
|
|
205
|
+
if (repo / "package.json").is_file():
|
|
206
|
+
try:
|
|
207
|
+
pkg = json.loads((repo / "package.json").read_text(encoding="utf-8"))
|
|
208
|
+
if pkg.get("scripts", {}).get("start") or pkg.get("scripts", {}).get("dev"):
|
|
209
|
+
default_port = port or "3000"
|
|
210
|
+
return f"http://localhost:{default_port}/"
|
|
211
|
+
except (json.JSONDecodeError, OSError):
|
|
212
|
+
pass
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def is_mcp_unavailable() -> bool:
|
|
217
|
+
ci = os.environ.get("CI", "").lower()
|
|
218
|
+
if ci in ("1", "true", "yes"):
|
|
219
|
+
return True
|
|
220
|
+
return os.environ.get("GITHUB_ACTIONS", "").lower() == "true"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def classify_step(step_text: str, repo: Path) -> tuple[str | None, str]:
|
|
224
|
+
if _forbidden(step_text):
|
|
225
|
+
return None, UAT_PROBE_FORBIDDEN
|
|
226
|
+
lower = step_text.lower()
|
|
227
|
+
profile = detect_stack_profile(repo)
|
|
228
|
+
|
|
229
|
+
if _has_judgment_deny(lower):
|
|
230
|
+
return "manual_operator", UAT_PROBE_UNRESOLVED
|
|
231
|
+
|
|
232
|
+
if any(w in lower for w in ("build", "compile", "bundle")):
|
|
233
|
+
if _read_build_command(repo, profile):
|
|
234
|
+
return "build", ""
|
|
235
|
+
return None, UAT_PROBE_UNRESOLVED
|
|
236
|
+
|
|
237
|
+
if any(w in lower for w in ("test", "pytest", "unit test", "integration test")):
|
|
238
|
+
if _read_test_command(repo) or profile in ("python", "node", "go", "generated"):
|
|
239
|
+
return "test", ""
|
|
240
|
+
return None, UAT_PROBE_UNRESOLVED
|
|
241
|
+
|
|
242
|
+
if any(w in lower for w in ("api", "health", "endpoint", "http", "rest")):
|
|
243
|
+
if _read_health_url(repo):
|
|
244
|
+
return "api_health", ""
|
|
245
|
+
return None, UAT_PROBE_UNRESOLVED
|
|
246
|
+
|
|
247
|
+
if any(w in lower for w in ("process", "startup", "server start", "readiness")):
|
|
248
|
+
if _extract_startup_command(step_text, repo) and (
|
|
249
|
+
_read_health_url(repo) or resolve_browser_url(repo)
|
|
250
|
+
):
|
|
251
|
+
return "process_health", ""
|
|
252
|
+
return "process_health", UAT_PROBE_UNRESOLVED
|
|
253
|
+
|
|
254
|
+
if _has_automatable_ui(lower):
|
|
255
|
+
if resolve_browser_url(repo):
|
|
256
|
+
return "browser_smoke", ""
|
|
257
|
+
return None, UAT_PROBE_UNRESOLVED
|
|
258
|
+
|
|
259
|
+
if any(w in lower for w in ("browser", "playwright", "smoke", "ui")):
|
|
260
|
+
if resolve_browser_url(repo):
|
|
261
|
+
return "browser_smoke", ""
|
|
262
|
+
return None, UAT_PROBE_UNRESOLVED
|
|
263
|
+
|
|
264
|
+
if any(w in lower for w in ("cli", "command line", "exit code")):
|
|
265
|
+
if _extract_backtick_command(step_text):
|
|
266
|
+
return "cli_smoke", ""
|
|
267
|
+
return "cli_smoke", UAT_PROBE_UNRESOLVED
|
|
268
|
+
|
|
269
|
+
if any(w in lower for w in ("manual", "operator", "human", "judgment")):
|
|
270
|
+
return "manual_operator", UAT_PROBE_UNRESOLVED
|
|
271
|
+
|
|
272
|
+
if profile is None:
|
|
273
|
+
return None, UAT_STACK_PROFILE_UNKNOWN
|
|
274
|
+
return None, UAT_PROBE_UNRESOLVED
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _extract_backtick_command(text: str) -> str | None:
|
|
278
|
+
m = re.search(r"`([^`]+)`", text)
|
|
279
|
+
return m.group(1).strip() if m else None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _extract_stdout_expectation(text: str) -> str | None:
|
|
283
|
+
m = re.search(r'expect\s+"([^"]+)"', text, re.I)
|
|
284
|
+
if m:
|
|
285
|
+
return m.group(1)
|
|
286
|
+
m = re.search(r'output contains\s+"([^"]+)"', text, re.I)
|
|
287
|
+
return m.group(1) if m else None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _extract_startup_command(step_text: str, repo: Path) -> str | None:
|
|
291
|
+
merged = _merge_scratchpad(repo)
|
|
292
|
+
override = merged.get("DEV_SERVER_COMMAND", "").strip()
|
|
293
|
+
if override:
|
|
294
|
+
return override
|
|
295
|
+
cmd = _extract_backtick_command(step_text)
|
|
296
|
+
if cmd:
|
|
297
|
+
return cmd
|
|
298
|
+
m = re.search(r'"([^"]+(?:start|dev|serve)[^"]*)"', step_text, re.I)
|
|
299
|
+
if m:
|
|
300
|
+
return m.group(1)
|
|
301
|
+
m = re.search(
|
|
302
|
+
r"(npm\s+(?:run\s+)?(?:start|dev)|yarn\s+(?:start|dev)|python\s+-m\s+\w+)",
|
|
303
|
+
step_text,
|
|
304
|
+
re.I,
|
|
305
|
+
)
|
|
306
|
+
if m:
|
|
307
|
+
return m.group(1)
|
|
308
|
+
profile = detect_stack_profile(repo)
|
|
309
|
+
if profile == "node" and (repo / "package.json").is_file():
|
|
310
|
+
try:
|
|
311
|
+
pkg = json.loads((repo / "package.json").read_text(encoding="utf-8"))
|
|
312
|
+
scripts = pkg.get("scripts", {})
|
|
313
|
+
if "dev" in scripts:
|
|
314
|
+
return "npm run dev"
|
|
315
|
+
if "start" in scripts:
|
|
316
|
+
return "npm run start"
|
|
317
|
+
except (json.JSONDecodeError, OSError):
|
|
318
|
+
pass
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _http_get_probe(url: str, timeout: int) -> tuple[bool, str, dict[str, object]]:
|
|
323
|
+
meta: dict[str, object] = {"url": url}
|
|
324
|
+
try:
|
|
325
|
+
req = urllib.request.Request(url, method="GET")
|
|
326
|
+
with urllib.request.urlopen(req, timeout=min(timeout, 30)) as resp:
|
|
327
|
+
meta["status_code"] = resp.status
|
|
328
|
+
if 200 <= resp.status < 400:
|
|
329
|
+
return True, UAT_PROBE_PASS, meta
|
|
330
|
+
return False, UAT_BROWSER_PROBE_FAILED, meta
|
|
331
|
+
except urllib.error.URLError as exc:
|
|
332
|
+
meta["error"] = type(exc).__name__
|
|
333
|
+
return False, UAT_BROWSER_PROBE_FAILED, meta
|
|
334
|
+
except TimeoutError:
|
|
335
|
+
return False, UAT_BROWSER_PROBE_TIMEOUT, meta
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _playwright_probe(url: str, timeout: int) -> tuple[bool, str, dict[str, object]]:
|
|
339
|
+
meta: dict[str, object] = {"url": url}
|
|
340
|
+
script = (
|
|
341
|
+
"const { chromium } = require('playwright');"
|
|
342
|
+
"(async () => {"
|
|
343
|
+
f" const b = await chromium.launch(); const p = await b.newPage();"
|
|
344
|
+
f" await p.goto({json.dumps(url)}, {{ timeout: {min(timeout, 30) * 1000} }});"
|
|
345
|
+
" await b.close(); process.exit(0);"
|
|
346
|
+
"})().catch(() => process.exit(1));"
|
|
347
|
+
)
|
|
348
|
+
try:
|
|
349
|
+
proc = subprocess.run(
|
|
350
|
+
["node", "-e", script],
|
|
351
|
+
capture_output=True,
|
|
352
|
+
text=True,
|
|
353
|
+
timeout=min(timeout, 60),
|
|
354
|
+
)
|
|
355
|
+
meta["exit_code"] = proc.returncode
|
|
356
|
+
if proc.returncode == 0:
|
|
357
|
+
return True, UAT_PROBE_PASS, meta
|
|
358
|
+
return False, UAT_BROWSER_PROBE_FAILED, meta
|
|
359
|
+
except subprocess.TimeoutExpired:
|
|
360
|
+
return False, UAT_BROWSER_PROBE_TIMEOUT, meta
|
|
361
|
+
except OSError as exc:
|
|
362
|
+
meta["error"] = str(exc)
|
|
363
|
+
return False, UAT_BROWSER_UNAVAILABLE, meta
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _run_fallback_chain(
|
|
367
|
+
url: str,
|
|
368
|
+
repo: Path,
|
|
369
|
+
timeout: int,
|
|
370
|
+
*,
|
|
371
|
+
dom_interaction: bool = False,
|
|
372
|
+
) -> tuple[bool, str, dict[str, object], str]:
|
|
373
|
+
mode = read_browser_probe_mode(repo)
|
|
374
|
+
chain = read_fallback_chain(repo)
|
|
375
|
+
details: dict[str, object] = {"url": url, "execution_tier": "stdlib"}
|
|
376
|
+
|
|
377
|
+
if mode == "http_fallback":
|
|
378
|
+
ok, code, meta = _http_get_probe(url, timeout)
|
|
379
|
+
details.update(meta)
|
|
380
|
+
return ok, code, details, "http_fallback"
|
|
381
|
+
|
|
382
|
+
if mode == "playwright_fallback":
|
|
383
|
+
ok, code, meta = _playwright_probe(url, timeout)
|
|
384
|
+
details.update(meta)
|
|
385
|
+
if ok:
|
|
386
|
+
return ok, code, details, "playwright_fallback"
|
|
387
|
+
if chain:
|
|
388
|
+
ok, code, meta = _http_get_probe(url, timeout)
|
|
389
|
+
details.update(meta)
|
|
390
|
+
details["fallback_from"] = "playwright_fallback"
|
|
391
|
+
return ok, code, details, "http_fallback"
|
|
392
|
+
return ok, UAT_BROWSER_UNAVAILABLE, details, "playwright_fallback"
|
|
393
|
+
|
|
394
|
+
if dom_interaction and chain:
|
|
395
|
+
ok, code, meta = _playwright_probe(url, timeout)
|
|
396
|
+
if ok:
|
|
397
|
+
details.update(meta)
|
|
398
|
+
return ok, code, details, "playwright_fallback"
|
|
399
|
+
|
|
400
|
+
ok, code, meta = _http_get_probe(url, timeout)
|
|
401
|
+
details.update(meta)
|
|
402
|
+
if ok:
|
|
403
|
+
return ok, code, details, "http_fallback"
|
|
404
|
+
if chain and dom_interaction:
|
|
405
|
+
ok, code, meta = _playwright_probe(url, timeout)
|
|
406
|
+
details.update(meta)
|
|
407
|
+
details["fallback_from"] = "http_fallback"
|
|
408
|
+
return ok, code, details, "playwright_fallback"
|
|
409
|
+
return ok, code, details, "http_fallback"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def execute_browser_smoke(
|
|
413
|
+
step_text: str,
|
|
414
|
+
repo: Path,
|
|
415
|
+
*,
|
|
416
|
+
timeout: int = DEFAULT_PROBE_TIMEOUT,
|
|
417
|
+
) -> dict[str, object]:
|
|
418
|
+
mode = read_browser_probe_mode(repo)
|
|
419
|
+
url = resolve_browser_url(repo)
|
|
420
|
+
dom_interaction = _has_automatable_ui(step_text.lower())
|
|
421
|
+
result: dict[str, object] = {
|
|
422
|
+
"probe_kind": "browser_smoke",
|
|
423
|
+
"step": step_text[:200],
|
|
424
|
+
"stack_profile": detect_stack_profile(repo) or "unknown",
|
|
425
|
+
"probe_mode": mode,
|
|
426
|
+
"passed": False,
|
|
427
|
+
"reason_code": UAT_PROBE_UNRESOLVED,
|
|
428
|
+
"target_url": url,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if not url:
|
|
432
|
+
result["reason_code"] = UAT_PROBE_UNRESOLVED
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
if mode == "cursor":
|
|
436
|
+
if is_mcp_unavailable():
|
|
437
|
+
result["reason_code"] = UAT_BROWSER_UNAVAILABLE
|
|
438
|
+
result["execution_tier"] = "stdlib"
|
|
439
|
+
if read_fallback_chain(repo):
|
|
440
|
+
ok, code, meta, fb_mode = _run_fallback_chain(
|
|
441
|
+
url, repo, timeout, dom_interaction=dom_interaction
|
|
442
|
+
)
|
|
443
|
+
result.update(meta)
|
|
444
|
+
result["probe_mode"] = fb_mode
|
|
445
|
+
result["passed"] = ok
|
|
446
|
+
result["reason_code"] = code if ok else UAT_BROWSER_PROBE_FAILED
|
|
447
|
+
if not ok and code == UAT_BROWSER_UNAVAILABLE:
|
|
448
|
+
result["reason_code"] = UAT_BROWSER_UNAVAILABLE
|
|
449
|
+
return result
|
|
450
|
+
result["execution_tier"] = "agent"
|
|
451
|
+
result["reason_code"] = UAT_PROBE_UNRESOLVED
|
|
452
|
+
result["agent_plan"] = {
|
|
453
|
+
"sequence": "browser_navigate → interact → screenshot → console/network → evidence",
|
|
454
|
+
"evidence_dir": "sprints/Sxxxx/evidence/browser/",
|
|
455
|
+
}
|
|
456
|
+
return result
|
|
457
|
+
|
|
458
|
+
result["execution_tier"] = "stdlib"
|
|
459
|
+
ok, code, meta, fb_mode = _run_fallback_chain(
|
|
460
|
+
url, repo, timeout, dom_interaction=dom_interaction
|
|
461
|
+
)
|
|
462
|
+
result.update(meta)
|
|
463
|
+
result["probe_mode"] = fb_mode
|
|
464
|
+
result["passed"] = ok
|
|
465
|
+
result["reason_code"] = code
|
|
466
|
+
return result
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def execute_process_health(
|
|
470
|
+
step_text: str,
|
|
471
|
+
repo: Path,
|
|
472
|
+
*,
|
|
473
|
+
timeout: int = DEFAULT_PROBE_TIMEOUT,
|
|
474
|
+
poll_cap: int | None = None,
|
|
475
|
+
poll_interval: int | None = None,
|
|
476
|
+
) -> dict[str, object]:
|
|
477
|
+
cap, interval = read_poll_settings(repo)
|
|
478
|
+
if poll_cap is not None:
|
|
479
|
+
cap = poll_cap
|
|
480
|
+
if poll_interval is not None:
|
|
481
|
+
interval = poll_interval
|
|
482
|
+
|
|
483
|
+
startup = _extract_startup_command(step_text, repo)
|
|
484
|
+
health_url = _read_health_url(repo) or resolve_browser_url(repo)
|
|
485
|
+
result: dict[str, object] = {
|
|
486
|
+
"probe_kind": "process_health",
|
|
487
|
+
"step": step_text[:200],
|
|
488
|
+
"stack_profile": detect_stack_profile(repo) or "unknown",
|
|
489
|
+
"execution_tier": "stdlib",
|
|
490
|
+
"passed": False,
|
|
491
|
+
"reason_code": UAT_PROBE_UNRESOLVED,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if not startup or not health_url:
|
|
495
|
+
return result
|
|
496
|
+
|
|
497
|
+
result["startup_command"] = startup
|
|
498
|
+
result["health_url"] = health_url
|
|
499
|
+
proc: subprocess.Popen[str] | None = None
|
|
500
|
+
try:
|
|
501
|
+
proc = subprocess.Popen(
|
|
502
|
+
startup,
|
|
503
|
+
shell=True,
|
|
504
|
+
cwd=str(repo),
|
|
505
|
+
stdout=subprocess.DEVNULL,
|
|
506
|
+
stderr=subprocess.DEVNULL,
|
|
507
|
+
)
|
|
508
|
+
deadline = time.monotonic() + cap
|
|
509
|
+
while time.monotonic() < deadline:
|
|
510
|
+
ok, code, meta = _http_get_probe(health_url, min(timeout, interval + 2))
|
|
511
|
+
if ok:
|
|
512
|
+
result.update(meta)
|
|
513
|
+
result["passed"] = True
|
|
514
|
+
result["reason_code"] = UAT_PROBE_PASS
|
|
515
|
+
return result
|
|
516
|
+
time.sleep(interval)
|
|
517
|
+
result["reason_code"] = UAT_PROBE_TIMEOUT
|
|
518
|
+
except OSError as exc:
|
|
519
|
+
result["reason_code"] = UAT_PROBE_FAILED
|
|
520
|
+
result["error"] = str(exc)
|
|
521
|
+
finally:
|
|
522
|
+
if proc is not None:
|
|
523
|
+
proc.terminate()
|
|
524
|
+
try:
|
|
525
|
+
proc.wait(timeout=5)
|
|
526
|
+
except subprocess.TimeoutExpired:
|
|
527
|
+
proc.kill()
|
|
528
|
+
return result
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def execute_cli_smoke(
|
|
532
|
+
step_text: str,
|
|
533
|
+
repo: Path,
|
|
534
|
+
*,
|
|
535
|
+
timeout: int = DEFAULT_PROBE_TIMEOUT,
|
|
536
|
+
) -> dict[str, object]:
|
|
537
|
+
cmd = _extract_backtick_command(step_text)
|
|
538
|
+
expect_out = _extract_stdout_expectation(step_text)
|
|
539
|
+
result: dict[str, object] = {
|
|
540
|
+
"probe_kind": "cli_smoke",
|
|
541
|
+
"step": step_text[:200],
|
|
542
|
+
"stack_profile": detect_stack_profile(repo) or "unknown",
|
|
543
|
+
"execution_tier": "stdlib",
|
|
544
|
+
"passed": False,
|
|
545
|
+
"reason_code": UAT_PROBE_UNRESOLVED,
|
|
546
|
+
}
|
|
547
|
+
if not cmd:
|
|
548
|
+
return result
|
|
549
|
+
result["command"] = cmd
|
|
550
|
+
try:
|
|
551
|
+
proc = subprocess.run(
|
|
552
|
+
cmd,
|
|
553
|
+
shell=True,
|
|
554
|
+
cwd=str(repo),
|
|
555
|
+
timeout=timeout,
|
|
556
|
+
capture_output=True,
|
|
557
|
+
text=True,
|
|
558
|
+
)
|
|
559
|
+
result["exit_code"] = proc.returncode
|
|
560
|
+
if proc.returncode != 0:
|
|
561
|
+
result["reason_code"] = UAT_PROBE_FAILED
|
|
562
|
+
return result
|
|
563
|
+
if expect_out and expect_out not in proc.stdout:
|
|
564
|
+
result["reason_code"] = UAT_PROBE_FAILED
|
|
565
|
+
return result
|
|
566
|
+
result["reason_code"] = UAT_PROBE_PASS
|
|
567
|
+
result["passed"] = True
|
|
568
|
+
except subprocess.TimeoutExpired:
|
|
569
|
+
result["reason_code"] = UAT_PROBE_TIMEOUT
|
|
570
|
+
except OSError as exc:
|
|
571
|
+
result["reason_code"] = UAT_PROBE_FAILED
|
|
572
|
+
result["error"] = str(exc)
|
|
573
|
+
return result
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def validate_browser_evidence(result: dict[str, object], mode: str) -> dict[str, object]:
|
|
577
|
+
out = dict(result)
|
|
578
|
+
if not out.get("passed"):
|
|
579
|
+
return out
|
|
580
|
+
if mode != "cursor":
|
|
581
|
+
return out
|
|
582
|
+
refs = out.get("browser_evidence_refs")
|
|
583
|
+
if not isinstance(refs, dict):
|
|
584
|
+
out["passed"] = False
|
|
585
|
+
out["reason_code"] = UAT_BROWSER_PROBE_FAILED
|
|
586
|
+
return out
|
|
587
|
+
nav = str(refs.get("navigation_url") or "").strip()
|
|
588
|
+
screenshots = refs.get("screenshots")
|
|
589
|
+
if not isinstance(screenshots, list):
|
|
590
|
+
screenshots = []
|
|
591
|
+
console = refs.get("console_summary")
|
|
592
|
+
network = refs.get("network_summary")
|
|
593
|
+
console_path = ""
|
|
594
|
+
network_path = ""
|
|
595
|
+
if isinstance(console, dict):
|
|
596
|
+
console_path = str(console.get("summary_path") or "")
|
|
597
|
+
if isinstance(network, dict):
|
|
598
|
+
network_path = str(network.get("summary_path") or "")
|
|
599
|
+
has_evidence = bool(nav) and (
|
|
600
|
+
bool(screenshots) or bool(console_path) or bool(network_path)
|
|
601
|
+
)
|
|
602
|
+
if len(screenshots) > MAX_SCREENSHOTS:
|
|
603
|
+
has_evidence = False
|
|
604
|
+
if not has_evidence:
|
|
605
|
+
out["passed"] = False
|
|
606
|
+
out["reason_code"] = UAT_BROWSER_PROBE_FAILED
|
|
607
|
+
return out
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def merge_result_fragment(fragment: dict[str, object], repo: Path) -> dict[str, object]:
|
|
611
|
+
mode = read_browser_probe_mode(repo)
|
|
612
|
+
if "probe_mode" not in fragment:
|
|
613
|
+
fragment = dict(fragment)
|
|
614
|
+
fragment["probe_mode"] = mode
|
|
615
|
+
return validate_browser_evidence(fragment, mode)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def execute_probe(
|
|
619
|
+
kind: str,
|
|
620
|
+
step_text: str,
|
|
621
|
+
repo: Path,
|
|
622
|
+
*,
|
|
623
|
+
timeout: int = DEFAULT_PROBE_TIMEOUT,
|
|
624
|
+
) -> dict[str, object]:
|
|
625
|
+
profile = detect_stack_profile(repo)
|
|
626
|
+
result: dict[str, object] = {
|
|
627
|
+
"probe_kind": kind,
|
|
628
|
+
"step": step_text[:200],
|
|
629
|
+
"stack_profile": profile or "unknown",
|
|
630
|
+
"reason_code": UAT_PROBE_UNRESOLVED,
|
|
631
|
+
"passed": False,
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if kind == "manual_operator":
|
|
635
|
+
result["reason_code"] = UAT_PROBE_UNRESOLVED
|
|
636
|
+
return result
|
|
637
|
+
|
|
638
|
+
if kind == "build":
|
|
639
|
+
cmd = _read_build_command(repo, profile)
|
|
640
|
+
if not cmd:
|
|
641
|
+
result["reason_code"] = UAT_PROBE_UNRESOLVED
|
|
642
|
+
return result
|
|
643
|
+
result["execution_tier"] = "stdlib"
|
|
644
|
+
return _run_subprocess(cmd, repo, timeout, result)
|
|
645
|
+
|
|
646
|
+
if kind == "test":
|
|
647
|
+
cmd = _read_test_command(repo)
|
|
648
|
+
if not cmd:
|
|
649
|
+
if profile == "python":
|
|
650
|
+
cmd = "python -m pytest -q"
|
|
651
|
+
elif profile == "node":
|
|
652
|
+
cmd = "npm test"
|
|
653
|
+
elif profile == "generated":
|
|
654
|
+
cmd = "python -m pytest -q"
|
|
655
|
+
else:
|
|
656
|
+
result["reason_code"] = UAT_PROBE_UNRESOLVED
|
|
657
|
+
return result
|
|
658
|
+
result["execution_tier"] = "stdlib"
|
|
659
|
+
return _run_subprocess(cmd, repo, timeout, result)
|
|
660
|
+
|
|
661
|
+
if kind == "api_health":
|
|
662
|
+
url = _read_health_url(repo)
|
|
663
|
+
if not url:
|
|
664
|
+
result["reason_code"] = UAT_PROBE_UNRESOLVED
|
|
665
|
+
return result
|
|
666
|
+
result["execution_tier"] = "stdlib"
|
|
667
|
+
ok, code, meta = _http_get_probe(url, timeout)
|
|
668
|
+
result.update(meta)
|
|
669
|
+
result["passed"] = ok
|
|
670
|
+
result["reason_code"] = code if ok else UAT_PROBE_FAILED
|
|
671
|
+
return result
|
|
672
|
+
|
|
673
|
+
if kind == "process_health":
|
|
674
|
+
return execute_process_health(step_text, repo, timeout=timeout)
|
|
675
|
+
|
|
676
|
+
if kind == "browser_smoke":
|
|
677
|
+
return execute_browser_smoke(step_text, repo, timeout=timeout)
|
|
678
|
+
|
|
679
|
+
if kind == "cli_smoke":
|
|
680
|
+
return execute_cli_smoke(step_text, repo, timeout=timeout)
|
|
681
|
+
|
|
682
|
+
result["reason_code"] = UAT_PROBE_UNRESOLVED
|
|
683
|
+
return result
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _run_subprocess(
|
|
687
|
+
cmd: str,
|
|
688
|
+
repo: Path,
|
|
689
|
+
timeout: int,
|
|
690
|
+
result: dict[str, object],
|
|
691
|
+
) -> dict[str, object]:
|
|
692
|
+
result["command"] = cmd
|
|
693
|
+
try:
|
|
694
|
+
proc = subprocess.run(
|
|
695
|
+
cmd,
|
|
696
|
+
shell=True,
|
|
697
|
+
cwd=str(repo),
|
|
698
|
+
timeout=timeout,
|
|
699
|
+
capture_output=True,
|
|
700
|
+
text=True,
|
|
701
|
+
)
|
|
702
|
+
result["exit_code"] = proc.returncode
|
|
703
|
+
if proc.returncode == 0:
|
|
704
|
+
result["reason_code"] = UAT_PROBE_PASS
|
|
705
|
+
result["passed"] = True
|
|
706
|
+
else:
|
|
707
|
+
result["reason_code"] = UAT_PROBE_FAILED
|
|
708
|
+
except subprocess.TimeoutExpired:
|
|
709
|
+
result["reason_code"] = UAT_PROBE_TIMEOUT
|
|
710
|
+
except OSError as exc:
|
|
711
|
+
result["reason_code"] = UAT_PROBE_FAILED
|
|
712
|
+
result["error"] = str(exc)
|
|
713
|
+
return result
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def resolve_and_probe(step_text: str, repo: Path) -> dict[str, object]:
|
|
717
|
+
kind, pre_reason = classify_step(step_text, Path(repo))
|
|
718
|
+
if kind is None:
|
|
719
|
+
return {
|
|
720
|
+
"probe_kind": None,
|
|
721
|
+
"reason_code": pre_reason or UAT_PROBE_UNRESOLVED,
|
|
722
|
+
"passed": False,
|
|
723
|
+
}
|
|
724
|
+
if pre_reason == UAT_PROBE_FORBIDDEN:
|
|
725
|
+
return {"probe_kind": kind, "reason_code": UAT_PROBE_FORBIDDEN, "passed": False}
|
|
726
|
+
if pre_reason == UAT_PROBE_UNRESOLVED and kind in ("manual_operator",):
|
|
727
|
+
return {
|
|
728
|
+
"probe_kind": kind,
|
|
729
|
+
"reason_code": UAT_PROBE_UNRESOLVED,
|
|
730
|
+
"passed": False,
|
|
731
|
+
}
|
|
732
|
+
return execute_probe(kind, step_text, Path(repo))
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def probe_steps(steps: list[str], repo: Path) -> list[dict[str, object]]:
|
|
736
|
+
return [resolve_and_probe(step, repo) for step in steps]
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def self_test() -> None:
|
|
740
|
+
repo = Path(__file__).resolve().parents[1]
|
|
741
|
+
assert "build" in PROBE_KINDS
|
|
742
|
+
|
|
743
|
+
r = classify_step("run unit tests", repo)
|
|
744
|
+
assert r[0] == "test" or r[1] in (UAT_PROBE_UNRESOLVED, "")
|
|
745
|
+
|
|
746
|
+
r2 = classify_step("read secrets from .env file", repo)
|
|
747
|
+
assert r2[1] == UAT_PROBE_FORBIDDEN
|
|
748
|
+
|
|
749
|
+
r3 = classify_step("enter password in login form", repo)
|
|
750
|
+
assert r3[1] == UAT_PROBE_FORBIDDEN
|
|
751
|
+
|
|
752
|
+
r4 = classify_step("operator visually confirms button click", repo)
|
|
753
|
+
assert r4[0] == "manual_operator"
|
|
754
|
+
assert r4[1] == UAT_PROBE_UNRESOLVED
|
|
755
|
+
|
|
756
|
+
r5 = classify_step("click submit button on home page", repo)
|
|
757
|
+
assert r5[0] == "browser_smoke" or r5[1] == UAT_PROBE_UNRESOLVED
|
|
758
|
+
|
|
759
|
+
r6 = classify_step("operator verifies compliance manually", repo)
|
|
760
|
+
assert r6[0] == "manual_operator"
|
|
761
|
+
|
|
762
|
+
mode = read_browser_probe_mode(repo)
|
|
763
|
+
assert mode in BROWSER_PROBE_MODES
|
|
764
|
+
|
|
765
|
+
bs = execute_browser_smoke("browser smoke test ui", repo)
|
|
766
|
+
assert bs.get("probe_mode") == "cursor"
|
|
767
|
+
assert bs["passed"] is False
|
|
768
|
+
if bs.get("target_url"):
|
|
769
|
+
assert bs.get("execution_tier") == "agent"
|
|
770
|
+
assert bs["reason_code"] == UAT_PROBE_UNRESOLVED
|
|
771
|
+
else:
|
|
772
|
+
assert bs["reason_code"] == UAT_PROBE_UNRESOLVED
|
|
773
|
+
|
|
774
|
+
bad_pass = validate_browser_evidence(
|
|
775
|
+
{"passed": True, "reason_code": UAT_PROBE_PASS},
|
|
776
|
+
"cursor",
|
|
777
|
+
)
|
|
778
|
+
assert bad_pass["passed"] is False
|
|
779
|
+
assert bad_pass["reason_code"] == UAT_BROWSER_PROBE_FAILED
|
|
780
|
+
|
|
781
|
+
good_pass = validate_browser_evidence(
|
|
782
|
+
{
|
|
783
|
+
"passed": True,
|
|
784
|
+
"reason_code": UAT_PROBE_PASS,
|
|
785
|
+
"browser_evidence_refs": {
|
|
786
|
+
"navigation_url": "http://localhost:3000/",
|
|
787
|
+
"screenshots": ["sprints/S0082/evidence/browser/pr-01.png"],
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
"cursor",
|
|
791
|
+
)
|
|
792
|
+
assert good_pass["passed"] is True
|
|
793
|
+
|
|
794
|
+
cli = execute_cli_smoke('run CLI `python -c "import sys; sys.exit(0)"` exit code 0', repo)
|
|
795
|
+
assert cli["passed"] is True
|
|
796
|
+
assert cli["reason_code"] == UAT_PROBE_PASS
|
|
797
|
+
|
|
798
|
+
ph = execute_process_health(
|
|
799
|
+
"wait for server startup readiness",
|
|
800
|
+
repo,
|
|
801
|
+
poll_cap=1,
|
|
802
|
+
poll_interval=1,
|
|
803
|
+
)
|
|
804
|
+
assert ph["reason_code"] in (UAT_PROBE_UNRESOLVED, UAT_PROBE_TIMEOUT, UAT_PROBE_FAILED)
|
|
805
|
+
|
|
806
|
+
merged = merge_result_fragment(
|
|
807
|
+
{"passed": True, "probe_kind": "browser_smoke"},
|
|
808
|
+
repo,
|
|
809
|
+
)
|
|
810
|
+
assert merged["passed"] is False
|
|
811
|
+
|
|
812
|
+
assert detect_stack_profile(repo) in ("python", "node", None, "generated")
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def main() -> int:
|
|
816
|
+
import argparse
|
|
817
|
+
|
|
818
|
+
parser = argparse.ArgumentParser(description="UAT probe resolver (US-0092 / US-0093).")
|
|
819
|
+
parser.add_argument("--repo", default=".")
|
|
820
|
+
parser.add_argument("--step", action="append", default=[], help="Acceptance step text.")
|
|
821
|
+
parser.add_argument("--self-test", action="store_true")
|
|
822
|
+
parser.add_argument("--report", action="store_true", help="JSON probe results to stdout.")
|
|
823
|
+
parser.add_argument(
|
|
824
|
+
"--merge-result",
|
|
825
|
+
metavar="FRAGMENT.json",
|
|
826
|
+
help="Validate browser evidence fragment JSON (US-0093).",
|
|
827
|
+
)
|
|
828
|
+
args = parser.parse_args()
|
|
829
|
+
repo = Path(args.repo).resolve()
|
|
830
|
+
|
|
831
|
+
if args.self_test:
|
|
832
|
+
try:
|
|
833
|
+
self_test()
|
|
834
|
+
except AssertionError as exc:
|
|
835
|
+
print(f"self-test failed: {exc}", file=sys.stderr)
|
|
836
|
+
return 2
|
|
837
|
+
print("[UAT_PROBE_LIB_SELF_TEST_OK]")
|
|
838
|
+
return 0
|
|
839
|
+
|
|
840
|
+
if args.merge_result:
|
|
841
|
+
frag_path = Path(args.merge_result)
|
|
842
|
+
if not frag_path.is_file():
|
|
843
|
+
print(f"merge-result: file not found: {frag_path}", file=sys.stderr)
|
|
844
|
+
return 2
|
|
845
|
+
try:
|
|
846
|
+
fragment = json.loads(frag_path.read_text(encoding="utf-8"))
|
|
847
|
+
except json.JSONDecodeError as exc:
|
|
848
|
+
print(f"merge-result: invalid JSON: {exc}", file=sys.stderr)
|
|
849
|
+
return 2
|
|
850
|
+
validated = merge_result_fragment(fragment, repo)
|
|
851
|
+
print(json.dumps(validated, sort_keys=True, separators=(",", ":")))
|
|
852
|
+
return 0 if validated.get("passed") else 1
|
|
853
|
+
|
|
854
|
+
if args.report or args.step:
|
|
855
|
+
results = probe_steps(args.step or ["run tests"], repo)
|
|
856
|
+
print(json.dumps(results, sort_keys=True, separators=(",", ":")))
|
|
857
|
+
if any(r.get("reason_code") == UAT_PROBE_UNRESOLVED for r in results):
|
|
858
|
+
return 1
|
|
859
|
+
if any(not r.get("passed") for r in results):
|
|
860
|
+
return 1
|
|
861
|
+
return 0
|
|
862
|
+
|
|
863
|
+
parser.print_help()
|
|
864
|
+
return 2
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
if __name__ == "__main__":
|
|
868
|
+
sys.exit(main())
|