program-context-protocol 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
pcp/telemetry.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Per-build-cycle telemetry — file/line/language/qa-result granularity for analysis.
|
|
2
|
+
|
|
3
|
+
Distinct from token_ledger.yaml (flat call-level cost rollup feeding pcp.md).
|
|
4
|
+
This is JSONL — one record per build-cycle event (a coding attempt, or a QA
|
|
5
|
+
check against that attempt) — so it loads straight into pandas/duckdb/jq for
|
|
6
|
+
analysis without parsing nested YAML. Auto-appended by `pcp build`. Never edit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from datetime import date, datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pcp.evidence_chain import chain_entry
|
|
15
|
+
|
|
16
|
+
LANGUAGE_BY_EXT = {
|
|
17
|
+
".py": "Python", ".ts": "TypeScript", ".tsx": "TypeScript", ".js": "JavaScript",
|
|
18
|
+
".jsx": "JavaScript", ".go": "Go", ".rs": "Rust", ".java": "Java", ".rb": "Ruby",
|
|
19
|
+
".yaml": "YAML", ".yml": "YAML", ".json": "JSON", ".md": "Markdown",
|
|
20
|
+
".sql": "SQL", ".sh": "Shell", ".css": "CSS", ".html": "HTML", ".c": "C",
|
|
21
|
+
".cpp": "C++", ".h": "C/C++ header", ".swift": "Swift", ".kt": "Kotlin",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def infer_languages(file_paths: list[str]) -> list[str]:
|
|
26
|
+
langs = set()
|
|
27
|
+
for f in file_paths:
|
|
28
|
+
ext = Path(f).suffix
|
|
29
|
+
langs.add(LANGUAGE_BY_EXT.get(ext, ext.lstrip(".") or "unknown"))
|
|
30
|
+
return sorted(langs)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def count_diff_lines(diff: str) -> tuple[int, int]:
|
|
34
|
+
"""(lines_added, lines_removed) from a unified diff, excluding +++/--- headers."""
|
|
35
|
+
added = sum(1 for l in diff.splitlines() if l.startswith("+") and not l.startswith("+++"))
|
|
36
|
+
removed = sum(1 for l in diff.splitlines() if l.startswith("-") and not l.startswith("---"))
|
|
37
|
+
return added, removed
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def record(pcp_dir: Path, **fields) -> None:
|
|
41
|
+
"""Append one JSONL record to .pcp/telemetry.jsonl.
|
|
42
|
+
|
|
43
|
+
Suggested fields (not enforced — callers pass whatever's available):
|
|
44
|
+
module, submodule, criterion_id, cycle ("build"|"qa"), cycle_number (attempt #),
|
|
45
|
+
check (for qa: "layer1"|"architect-review"|"gate"), result ("pass"|"block"|"error"),
|
|
46
|
+
errors (list of finding strings), files (list of paths touched), languages,
|
|
47
|
+
lines_added, lines_removed, model, session_id, token_input, token_output,
|
|
48
|
+
token_cache_read, token_cache_creation, cost_usd, duration_ms.
|
|
49
|
+
"""
|
|
50
|
+
fields = {"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), **fields}
|
|
51
|
+
path = Path(pcp_dir) / "telemetry.jsonl"
|
|
52
|
+
entry = chain_entry(_last_entry_hash(path), fields)
|
|
53
|
+
with open(path, "a") as f:
|
|
54
|
+
f.write(json.dumps(entry) + "\n")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _last_entry_hash(path: Path) -> str | None:
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return None
|
|
60
|
+
last_line = None
|
|
61
|
+
for line in path.read_text().splitlines():
|
|
62
|
+
line = line.strip()
|
|
63
|
+
if line:
|
|
64
|
+
last_line = line
|
|
65
|
+
if not last_line:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(last_line).get("entry_hash")
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load(pcp_dir: Path) -> list[dict]:
|
|
74
|
+
path = Path(pcp_dir) / "telemetry.jsonl"
|
|
75
|
+
if not path.exists():
|
|
76
|
+
return []
|
|
77
|
+
records = []
|
|
78
|
+
for line in path.read_text().splitlines():
|
|
79
|
+
line = line.strip()
|
|
80
|
+
if not line:
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
records.append(json.loads(line))
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
continue
|
|
86
|
+
return records
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def productivity_by_week(records: list[dict]) -> list[dict]:
|
|
90
|
+
"""Spend and net lines written per ISO week, plus $ per net line.
|
|
91
|
+
|
|
92
|
+
Nothing in PCP reported output-per-dollar over time, so a real 6x degradation
|
|
93
|
+
went unseen. Measured by hand on Project O 2026-07-30: 07-16..07-22
|
|
94
|
+
produced +4,954 net non-test LOC for $364.77 (~$0.07/line), while 07-23..07-30
|
|
95
|
+
produced +596 for $263.89 (~$0.44/line) -- while commits/day ROSE. Commit count
|
|
96
|
+
and output per dollar were pointing in opposite directions and only the flattering
|
|
97
|
+
one was visible anywhere.
|
|
98
|
+
|
|
99
|
+
Deliberately built from telemetry's own `lines_added`/`lines_removed`, not from
|
|
100
|
+
git, so this stays a pure aggregation over data PCP already owns and needs no
|
|
101
|
+
repo access. The honest reading of `net_lines` is therefore "lines this build
|
|
102
|
+
loop wrote, net" -- it counts every attempt's diff, including work a later
|
|
103
|
+
attempt superseded, so it is an upper bound on repo growth, not a measurement
|
|
104
|
+
of it. `$/net line` is a trend signal, not an accounting figure: watch whether
|
|
105
|
+
it moves, not what it equals.
|
|
106
|
+
|
|
107
|
+
Weeks with no recorded lines report None rather than a division-by-zero-shaped
|
|
108
|
+
number -- a week that spent money and wrote nothing is a real state and must not
|
|
109
|
+
be rendered as "$0.00/line".
|
|
110
|
+
"""
|
|
111
|
+
by_week: dict[str, dict] = defaultdict(
|
|
112
|
+
lambda: {"cost": 0.0, "lines_added": 0, "lines_removed": 0, "attempts": 0}
|
|
113
|
+
)
|
|
114
|
+
for r in records:
|
|
115
|
+
ts = str(r.get("timestamp") or "")
|
|
116
|
+
if len(ts) < 10:
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
year, week, _ = date.fromisoformat(ts[:10]).isocalendar()
|
|
120
|
+
except ValueError:
|
|
121
|
+
continue
|
|
122
|
+
w = by_week[f"{year}-W{week:02d}"]
|
|
123
|
+
w["cost"] += r.get("cost_usd") or 0
|
|
124
|
+
w["lines_added"] += r.get("lines_added") or 0
|
|
125
|
+
w["lines_removed"] += r.get("lines_removed") or 0
|
|
126
|
+
if r.get("cycle") == "build":
|
|
127
|
+
w["attempts"] += 1
|
|
128
|
+
|
|
129
|
+
out = []
|
|
130
|
+
for label in sorted(by_week):
|
|
131
|
+
w = by_week[label]
|
|
132
|
+
net = w["lines_added"] - w["lines_removed"]
|
|
133
|
+
out.append({
|
|
134
|
+
"week": label,
|
|
135
|
+
"cost_usd": round(w["cost"], 2),
|
|
136
|
+
"net_lines": net,
|
|
137
|
+
"attempts": w["attempts"],
|
|
138
|
+
"usd_per_net_line": round(w["cost"] / net, 3) if net > 0 else None,
|
|
139
|
+
})
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_EMPTY_REPO_LINES = {"by_week": {}, "bulk_commits_skipped": {}, "bulk_threshold": 0}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def repo_net_lines_by_week(project_root: Path, exclude_tests: bool = True,
|
|
147
|
+
bulk_commit_threshold: int = 5000) -> dict:
|
|
148
|
+
"""Net authored source lines that actually LANDED in the repo, per ISO week.
|
|
149
|
+
|
|
150
|
+
Returns {"by_week": {week: net}, "bulk_commits_skipped": {week: n},
|
|
151
|
+
"bulk_threshold": n}. Callers must surface `bulk_commits_skipped` -- a metric
|
|
152
|
+
that quietly drops data is the failure mode this whole module is trying to fix.
|
|
153
|
+
|
|
154
|
+
This exists because `productivity_by_week` alone is misleading, and shipping it
|
|
155
|
+
alone would have repeated the exact failure this session kept finding: a metric
|
|
156
|
+
that reads healthy while the situation it describes is not.
|
|
157
|
+
|
|
158
|
+
On Project O, week 2026-W31: telemetry recorded **+12,342 net lines
|
|
159
|
+
written** at $0.018/line, which looks like the most productive week of the run.
|
|
160
|
+
Git says non-test code in the repo grew by **+599** over the same window. Both
|
|
161
|
+
are true. Telemetry counts every attempt's diff, so superseded attempts, reverted
|
|
162
|
+
work, rewrites and test code all inflate it; git counts what survived.
|
|
163
|
+
|
|
164
|
+
Neither number is the interesting one. **The ratio is** — roughly 5% of what the
|
|
165
|
+
loop wrote survived as net non-test product code. That is the number worth
|
|
166
|
+
watching, and no single-source metric can express it.
|
|
167
|
+
|
|
168
|
+
**Vendored third-party source is the hard case and the reason for
|
|
169
|
+
`bulk_commit_threshold`.** Extension filtering is not enough: Project O
|
|
170
|
+
committed an entire drawio distribution under `web/drawio-site/` and
|
|
171
|
+
`web/public/drawio/` in one week and moved it the next -- ~450,000 lines of
|
|
172
|
+
third-party `.js` sitting in no conventionally-named vendor directory. That
|
|
173
|
+
produced survival rates of 107372% and -11249% before this filter existed.
|
|
174
|
+
|
|
175
|
+
No path heuristic catches that reliably, so the discriminator is size: a single
|
|
176
|
+
commit churning more than `bulk_commit_threshold` authored-source lines is a
|
|
177
|
+
vendor import, a bulk move, or a generated dump -- not one criterion's work.
|
|
178
|
+
Such commits are excluded WHOLE, and the count is returned per week so the
|
|
179
|
+
caller can say so out loud. Never silently.
|
|
180
|
+
|
|
181
|
+
Best-effort: returns empty structures when git is unavailable or the call fails,
|
|
182
|
+
so callers degrade to the telemetry-only view rather than breaking.
|
|
183
|
+
"""
|
|
184
|
+
import subprocess
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
proc = subprocess.run(
|
|
188
|
+
["git", "log", "--numstat", "--date=short", "--pretty=format:__C__%cd"],
|
|
189
|
+
cwd=project_root, capture_output=True, text=True, timeout=120,
|
|
190
|
+
)
|
|
191
|
+
except (OSError, subprocess.SubprocessError):
|
|
192
|
+
return _EMPTY_REPO_LINES
|
|
193
|
+
if proc.returncode != 0:
|
|
194
|
+
return _EMPTY_REPO_LINES
|
|
195
|
+
|
|
196
|
+
# Per-commit first, so a bulk vendor import can be excluded as a whole.
|
|
197
|
+
per_commit: list[tuple[str, int, int]] = [] # (week, net, churn)
|
|
198
|
+
current: str | None = None
|
|
199
|
+
net = churn = 0
|
|
200
|
+
|
|
201
|
+
def flush():
|
|
202
|
+
if current is not None:
|
|
203
|
+
per_commit.append((current, net, churn))
|
|
204
|
+
|
|
205
|
+
for line in proc.stdout.splitlines():
|
|
206
|
+
if line.startswith("__C__"):
|
|
207
|
+
flush()
|
|
208
|
+
net = churn = 0
|
|
209
|
+
try:
|
|
210
|
+
year, week, _ = date.fromisoformat(line[5:].strip()).isocalendar()
|
|
211
|
+
current = f"{year}-W{week:02d}"
|
|
212
|
+
except ValueError:
|
|
213
|
+
current = None
|
|
214
|
+
continue
|
|
215
|
+
if not current or "\t" not in line:
|
|
216
|
+
continue
|
|
217
|
+
parts = line.split("\t")
|
|
218
|
+
# Binary files report "-" for both counts; skip rather than crash.
|
|
219
|
+
if len(parts) != 3 or not parts[0].isdigit() or not parts[1].isdigit():
|
|
220
|
+
continue
|
|
221
|
+
path = parts[2]
|
|
222
|
+
if not _is_authored_source(path):
|
|
223
|
+
continue
|
|
224
|
+
if exclude_tests and (path.startswith("tests/") or "/test_" in path
|
|
225
|
+
or path.split("/")[-1].startswith("test_")):
|
|
226
|
+
continue
|
|
227
|
+
added, removed = int(parts[0]), int(parts[1])
|
|
228
|
+
net += added - removed
|
|
229
|
+
churn += added + removed
|
|
230
|
+
flush()
|
|
231
|
+
|
|
232
|
+
by_week: dict[str, int] = defaultdict(int)
|
|
233
|
+
skipped: dict[str, int] = defaultdict(int)
|
|
234
|
+
for week, cnet, cchurn in per_commit:
|
|
235
|
+
if cchurn > bulk_commit_threshold:
|
|
236
|
+
skipped[week] += 1
|
|
237
|
+
continue
|
|
238
|
+
by_week[week] += cnet
|
|
239
|
+
return {"by_week": dict(by_week), "bulk_commits_skipped": dict(skipped),
|
|
240
|
+
"bulk_threshold": bulk_commit_threshold}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Extensions a human or an agent actually authors. Everything else -- lockfiles,
|
|
244
|
+
# bundles, vendored trees, snapshots, data -- is generated or copied, and counting
|
|
245
|
+
# it destroys the metric rather than enriching it.
|
|
246
|
+
_SOURCE_EXTS = frozenset({
|
|
247
|
+
".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".rb", ".kt",
|
|
248
|
+
".swift", ".c", ".cpp", ".h", ".cs", ".php", ".scala", ".ex", ".exs",
|
|
249
|
+
".sh", ".sql", ".vue", ".svelte", ".css", ".scss", ".html",
|
|
250
|
+
})
|
|
251
|
+
_GENERATED_DIR_MARKERS = (
|
|
252
|
+
"node_modules/", ".venv/", "venv/", "vendor/", "dist/", "build/", "site-packages/",
|
|
253
|
+
"__pycache__/", ".next/", "coverage/", "migrations/", "__snapshots__/", "generated/",
|
|
254
|
+
)
|
|
255
|
+
_GENERATED_NAMES = frozenset({
|
|
256
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock",
|
|
257
|
+
"Cargo.lock", "go.sum", "uv.lock", "composer.lock",
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _is_authored_source(path: str) -> bool:
|
|
262
|
+
"""Is this a file a person or agent wrote, rather than one a tool emitted?
|
|
263
|
+
|
|
264
|
+
Without this filter the metric is not merely noisy, it is nonsense. Run against
|
|
265
|
+
Project O it reported **+1,523,5xx lines landed** in one week and
|
|
266
|
+
**-1,470,6xx** in another, for survival rates of 362745% and -38701% -- lockfiles
|
|
267
|
+
and generated bundles swamping the signal by three orders of magnitude.
|
|
268
|
+
|
|
269
|
+
Worth stating plainly: that output was produced by a first version of this
|
|
270
|
+
function and caught only by running it against a real repo before shipping.
|
|
271
|
+
A metric that confidently reports a wrong number is worse than no metric, which
|
|
272
|
+
is the same defect class as every other finding in this session's audit.
|
|
273
|
+
"""
|
|
274
|
+
name = path.split("/")[-1]
|
|
275
|
+
if name in _GENERATED_NAMES:
|
|
276
|
+
return False
|
|
277
|
+
if any(marker in path for marker in _GENERATED_DIR_MARKERS):
|
|
278
|
+
return False
|
|
279
|
+
dot = name.rfind(".")
|
|
280
|
+
return dot > 0 and name[dot:].lower() in _SOURCE_EXTS
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def aggregate(records: list[dict]) -> dict:
|
|
284
|
+
"""Roll up build/qa records per module. Shared by `pcp telemetry`, end-of-build
|
|
285
|
+
summary, and the pcp.md 'Build Efficiency' section — one aggregation, three views."""
|
|
286
|
+
build_records = [r for r in records if r.get("cycle") == "build"]
|
|
287
|
+
qa_records = [r for r in records if r.get("cycle") == "qa"]
|
|
288
|
+
|
|
289
|
+
by_module = defaultdict(lambda: {
|
|
290
|
+
"attempts": 0, "criteria": set(), "tokens_in": 0, "tokens_out": 0,
|
|
291
|
+
"tokens_cache_read": 0, "cost": 0.0, "qa_blocks": 0, "qa_total": 0, "languages": set(),
|
|
292
|
+
})
|
|
293
|
+
for r in build_records:
|
|
294
|
+
m = by_module[r.get("module") or "?"]
|
|
295
|
+
m["attempts"] += 1
|
|
296
|
+
m["criteria"].add(r.get("criterion_id"))
|
|
297
|
+
m["tokens_in"] += r.get("token_input", 0)
|
|
298
|
+
m["tokens_out"] += r.get("token_output", 0)
|
|
299
|
+
m["tokens_cache_read"] += r.get("token_cache_read", 0)
|
|
300
|
+
m["cost"] += r.get("cost_usd") or 0
|
|
301
|
+
m["languages"].update(r.get("languages") or [])
|
|
302
|
+
for r in qa_records:
|
|
303
|
+
m = by_module[r.get("module") or "?"]
|
|
304
|
+
m["qa_total"] += 1
|
|
305
|
+
if r.get("result") == "block":
|
|
306
|
+
m["qa_blocks"] += 1
|
|
307
|
+
|
|
308
|
+
return {"by_module": by_module, "build_records": build_records, "qa_records": qa_records}
|
pcp/uat.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""UAT checks — `url_responds`, `dom_contains`, and `visual` acceptance criteria,
|
|
2
|
+
plus two advisory checks build.py runs on top of a rendered screenshot:
|
|
3
|
+
`check_axe` (deterministic a11y scan) and `check_visual_quality` (checklist-
|
|
4
|
+
anchored VLM judge).
|
|
5
|
+
|
|
6
|
+
Honest scope: `url_responds`/`dom_contains` are deterministic, no browser
|
|
7
|
+
involved — `dom_contains` fetches the raw HTML response and searches it as
|
|
8
|
+
text, so it does NOT execute JavaScript and content only rendered
|
|
9
|
+
client-side (a typical SPA) won't be found even if a real browser would
|
|
10
|
+
show it. `visual` (check_visual) closes part of that gap with a real
|
|
11
|
+
headless browser via Playwright — an OPTIONAL dependency
|
|
12
|
+
(`pip install program-context-protocol[visual]`), never a hard requirement
|
|
13
|
+
of this package. It proves the page renders without crashing/timing out
|
|
14
|
+
and saves a screenshot for human review.
|
|
15
|
+
|
|
16
|
+
**Updated 2026-07-18** — layout-break detection via a vision LLM is now
|
|
17
|
+
built (`check_visual_quality`), closing the gap this docstring used to name
|
|
18
|
+
as out of scope. What changed: `llm/client.py` gained image-input plumbing
|
|
19
|
+
(`call_with_image`/`call_json_with_image`, via `claude -p`'s
|
|
20
|
+
`--input-format stream-json` multimodal message shape). Deliberately
|
|
21
|
+
**checklist-anchored, not a freeform "does this look good" prompt** —
|
|
22
|
+
research (ArtifactsBench, 2026) found a checklist-anchored VLM judge hits
|
|
23
|
+
~94% human-correlation vs. ~21% for a bare Nielsen-heuristics-style review;
|
|
24
|
+
the checklist is what does the work, not the model. Same advisory,
|
|
25
|
+
never-a-hard-block posture as `check_visual`'s baseline-diff note below —
|
|
26
|
+
a screen scoring poorly on the checklist is a review signal, not proof the
|
|
27
|
+
screen is wrong.
|
|
28
|
+
|
|
29
|
+
Reconnaissance-then-action pattern (wait for `networkidle` before reading
|
|
30
|
+
DOM state) is a reference-pattern borrowed from Anthropic's own
|
|
31
|
+
`webapp-testing` skill (anthropics/skills) after a real prior-art miss
|
|
32
|
+
found post-hoc: `check_visual` shipped without checking for it first,
|
|
33
|
+
initially screenshotting right after `goto()` with no settle-wait — on a
|
|
34
|
+
slow-loading SPA that can capture a blank/loading state and still report
|
|
35
|
+
"rendered successfully," exactly the failure mode this check exists to
|
|
36
|
+
catch. `webapp-testing` itself is a full agentic skill (server lifecycle,
|
|
37
|
+
selector discovery, browser console logs) meant for an interactive Claude
|
|
38
|
+
session — not adopted wholesale, since `check_visual` runs inside `pcp
|
|
39
|
+
scan`'s deterministic, zero-LLM evaluation loop and turning that into an
|
|
40
|
+
agent invocation per UI criterion would break Token Discipline. Only the
|
|
41
|
+
underlying technique was reused; `pcp build`'s coding agent is pointed at
|
|
42
|
+
the real skill separately for its own in-build UI verification.
|
|
43
|
+
|
|
44
|
+
Same tool-wrapping shape as qa.py: never raises, degrades to a clear
|
|
45
|
+
failure detail instead.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import re
|
|
49
|
+
import shutil
|
|
50
|
+
import subprocess
|
|
51
|
+
import urllib.error
|
|
52
|
+
import urllib.request
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
TIMEOUT_SEC = 10
|
|
56
|
+
TIMEOUT_AXE = 120
|
|
57
|
+
|
|
58
|
+
# Checklist-anchored, per the research finding above -- deliberately generic
|
|
59
|
+
# and small rather than exhaustive, same "advisory signal, not proof" posture
|
|
60
|
+
# as check_visual's baseline diff. A criterion's own design_justification
|
|
61
|
+
# (design_system.md tokens, jtbd_framing) is NOT folded in here as additional
|
|
62
|
+
# checklist items -- that field is a self-report the agent fills in, judging
|
|
63
|
+
# a screen against the agent's own claims about itself would be circular.
|
|
64
|
+
DEFAULT_VISUAL_CHECKLIST = [
|
|
65
|
+
"layout is not visibly broken (no overlapping elements, no obvious clipping/overflow)",
|
|
66
|
+
"text is legible (adequate contrast against its background, not truncated where it shouldn't be)",
|
|
67
|
+
"primary action or focal element is visually prominent and easy to locate",
|
|
68
|
+
"spacing/alignment reads as intentional, not haphazard",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_url_responds(url: str) -> tuple[bool, str]:
|
|
73
|
+
"""Pass if the URL returns a 2xx/3xx status. No content is inspected."""
|
|
74
|
+
if not url:
|
|
75
|
+
return False, "no url configured for url_responds check"
|
|
76
|
+
try:
|
|
77
|
+
req = urllib.request.Request(url, method="GET")
|
|
78
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
|
|
79
|
+
ok = 200 <= resp.status < 400
|
|
80
|
+
return ok, f"{url} responded {resp.status}"
|
|
81
|
+
except urllib.error.HTTPError as e:
|
|
82
|
+
return False, f"{url} responded {e.code}"
|
|
83
|
+
except Exception as e:
|
|
84
|
+
return False, f"{url} did not respond: {e}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_dom_contains(url: str, selector: str) -> tuple[bool, str]:
|
|
88
|
+
"""Pass if `selector` (plain text, or a regex if it fails to match literally)
|
|
89
|
+
appears in the URL's raw HTML response. Static content only — see module
|
|
90
|
+
docstring for the JS-rendering limitation."""
|
|
91
|
+
if not url:
|
|
92
|
+
return False, "no url configured for dom_contains check"
|
|
93
|
+
if not selector:
|
|
94
|
+
return False, "no selector/text configured for dom_contains check"
|
|
95
|
+
try:
|
|
96
|
+
req = urllib.request.Request(url, method="GET")
|
|
97
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
|
|
98
|
+
body = resp.read().decode(errors="replace")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return False, f"{url} did not respond: {e}"
|
|
101
|
+
|
|
102
|
+
if selector in body:
|
|
103
|
+
return True, f"'{selector}' found in {url} (static HTML)"
|
|
104
|
+
try:
|
|
105
|
+
if re.search(selector, body):
|
|
106
|
+
return True, f"pattern '{selector}' matched in {url} (static HTML)"
|
|
107
|
+
except re.error:
|
|
108
|
+
pass
|
|
109
|
+
return False, f"'{selector}' not found in {url}'s static HTML (JS-rendered content won't show here)"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_visual(url: str, screenshot_path: Path | None = None) -> tuple[bool | None, str]:
|
|
113
|
+
"""Loads `url` in a real headless browser (Playwright/Chromium) and
|
|
114
|
+
captures a screenshot. Returns (None, detail) — not (False, detail) —
|
|
115
|
+
when playwright isn't installed: this means "could not check", not
|
|
116
|
+
"failed". Callers must preserve whatever status a criterion already had
|
|
117
|
+
rather than downgrading it on a missing optional dependency, the same
|
|
118
|
+
posture a manual/visual criterion without this check already gets in
|
|
119
|
+
scan.py."""
|
|
120
|
+
if not url:
|
|
121
|
+
return False, "no url configured for visual check"
|
|
122
|
+
try:
|
|
123
|
+
from playwright.sync_api import sync_playwright
|
|
124
|
+
except ImportError:
|
|
125
|
+
return None, (
|
|
126
|
+
"playwright not installed -- visual check skipped, not failed. "
|
|
127
|
+
"Install with: pip install program-context-protocol[visual] && playwright install chromium"
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
with sync_playwright() as p:
|
|
131
|
+
browser = p.chromium.launch()
|
|
132
|
+
page = browser.new_page()
|
|
133
|
+
page.goto(url, timeout=TIMEOUT_SEC * 1000)
|
|
134
|
+
# Reconnaissance-then-action pattern (reference: anthropics/skills'
|
|
135
|
+
# webapp-testing) -- goto() alone returns once the initial HTML
|
|
136
|
+
# response lands, before a typical SPA's JS has actually rendered
|
|
137
|
+
# content. Without this wait, a screenshot on a slow-loading SPA
|
|
138
|
+
# can capture a blank/loading state and still report "rendered
|
|
139
|
+
# successfully" -- exactly the failure mode this check exists to
|
|
140
|
+
# catch (dom_contains's own JS-rendering gap). networkidle waits
|
|
141
|
+
# for in-flight requests to settle before the check reads DOM state.
|
|
142
|
+
page.wait_for_load_state("networkidle", timeout=TIMEOUT_SEC * 1000)
|
|
143
|
+
if screenshot_path:
|
|
144
|
+
screenshot_path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
146
|
+
browser.close()
|
|
147
|
+
except Exception as e:
|
|
148
|
+
return False, f"{url} failed to render in a headless browser: {e}"
|
|
149
|
+
|
|
150
|
+
detail = f"{url} rendered successfully in a headless browser"
|
|
151
|
+
if screenshot_path:
|
|
152
|
+
detail += f" -- screenshot: {screenshot_path}"
|
|
153
|
+
detail += _baseline_note(screenshot_path)
|
|
154
|
+
return True, detail
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _baseline_note(screenshot_path: Path) -> str:
|
|
158
|
+
"""Baseline comparison (Chromatic reference pattern, 2026-07-17, build
|
|
159
|
+
plan 3.5) — closes part of check_visual's own stated 'not visual
|
|
160
|
+
regression testing' gap. First successful capture becomes the baseline
|
|
161
|
+
(`<name>_baseline.png`); later captures are compared by content hash.
|
|
162
|
+
HONEST SCOPE: hash inequality means "pixels changed since the accepted
|
|
163
|
+
baseline", not "layout broke" — a changed screenshot is a review signal,
|
|
164
|
+
never a failure. Accept a new baseline by deleting the old one."""
|
|
165
|
+
import hashlib
|
|
166
|
+
baseline = screenshot_path.with_name(screenshot_path.stem + "_baseline.png")
|
|
167
|
+
try:
|
|
168
|
+
current = hashlib.sha256(screenshot_path.read_bytes()).hexdigest()
|
|
169
|
+
if not baseline.exists():
|
|
170
|
+
baseline.write_bytes(screenshot_path.read_bytes())
|
|
171
|
+
return " -- baseline established (first capture)"
|
|
172
|
+
if hashlib.sha256(baseline.read_bytes()).hexdigest() == current:
|
|
173
|
+
return " -- matches accepted baseline"
|
|
174
|
+
return (f" -- CHANGED vs accepted baseline ({baseline.name}); review the two "
|
|
175
|
+
"screenshots and delete the baseline to accept the new look")
|
|
176
|
+
except OSError as e:
|
|
177
|
+
return f" -- baseline comparison skipped: {e}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def check_axe(url: str) -> tuple[bool | None, str]:
|
|
181
|
+
"""Deterministic WCAG a11y scan via `@axe-core/cli` (npx, auto-installed
|
|
182
|
+
on first run same as the Context7 MCP entry doctor.py already scaffolds
|
|
183
|
+
via npx -- no new hard dependency added to this package). Returns
|
|
184
|
+
(None, detail) when npx isn't on PATH -- "could not check", same
|
|
185
|
+
could-not-check-vs-failed distinction check_visual already makes for a
|
|
186
|
+
missing optional dependency. --exit makes the CLI process exit 1 if any
|
|
187
|
+
rule fails; --stdout silences everything but the results/errors so the
|
|
188
|
+
tail of stdout is a usable detail message on failure."""
|
|
189
|
+
if not url:
|
|
190
|
+
return False, "no url configured for axe a11y check"
|
|
191
|
+
if not shutil.which("npx"):
|
|
192
|
+
return None, "npx not found -- axe-core a11y scan skipped, not failed"
|
|
193
|
+
try:
|
|
194
|
+
result = subprocess.run(
|
|
195
|
+
["npx", "--yes", "@axe-core/cli", url, "--exit", "--stdout"],
|
|
196
|
+
capture_output=True, text=True, timeout=TIMEOUT_AXE,
|
|
197
|
+
)
|
|
198
|
+
except subprocess.TimeoutExpired:
|
|
199
|
+
return False, f"axe-core scan of {url} timed out after {TIMEOUT_AXE}s"
|
|
200
|
+
except Exception as e:
|
|
201
|
+
return False, f"axe-core scan of {url} failed to run: {e}"
|
|
202
|
+
|
|
203
|
+
output = (result.stdout + result.stderr).strip()
|
|
204
|
+
if result.returncode == 0:
|
|
205
|
+
return True, f"axe-core: no violations found at {url}"
|
|
206
|
+
return False, f"axe-core found violation(s) at {url}:\n{output[-3000:]}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def check_visual_quality(
|
|
210
|
+
screenshot_path: Path,
|
|
211
|
+
checklist: list[str] | None = None,
|
|
212
|
+
reference_image_path: Path | None = None,
|
|
213
|
+
model: str | None = None,
|
|
214
|
+
pcp_dir: Path | None = None,
|
|
215
|
+
) -> tuple[bool | None, str, list[dict]]:
|
|
216
|
+
"""Checklist-anchored VLM judge over a screenshot check_visual already
|
|
217
|
+
captured. Returns (None, detail, []) if the screenshot doesn't exist
|
|
218
|
+
(nothing to judge -- same could-not-check posture as a missing optional
|
|
219
|
+
dependency elsewhere in this module) or if the judge call itself errors
|
|
220
|
+
(advisory check, never let an LLM-call failure read as a verdict).
|
|
221
|
+
|
|
222
|
+
reference_image_path, if given, is attached as a second inline image so
|
|
223
|
+
the judge can compare against it -- research finding: screenshot +
|
|
224
|
+
reference beats either alone as grounding context. Comparison is
|
|
225
|
+
layout/structure, not pixel-perfect match; the prompt says so explicitly
|
|
226
|
+
so the judge doesn't fail a screen for a legitimate content difference.
|
|
227
|
+
"""
|
|
228
|
+
if not screenshot_path or not screenshot_path.exists():
|
|
229
|
+
return None, "no screenshot available to judge (check_visual must run first)", []
|
|
230
|
+
|
|
231
|
+
from pcp.llm import client as llm
|
|
232
|
+
|
|
233
|
+
items = checklist or DEFAULT_VISUAL_CHECKLIST
|
|
234
|
+
checklist_text = "\n".join(f"- {item}" for item in items)
|
|
235
|
+
system = (
|
|
236
|
+
"You judge a rendered UI screenshot against a fixed checklist. For EACH "
|
|
237
|
+
"checklist item, decide pass/fail and give a one-sentence reason grounded "
|
|
238
|
+
"in what you actually see in the image -- never invent a defect that isn't "
|
|
239
|
+
"visible. If a reference image is also attached, use it only to judge "
|
|
240
|
+
"layout/structure similarity, not pixel-perfect match; a legitimate content "
|
|
241
|
+
"difference (different copy, different data) is not a failure."
|
|
242
|
+
)
|
|
243
|
+
user = (
|
|
244
|
+
f"Checklist:\n{checklist_text}\n\n"
|
|
245
|
+
"Respond with JSON: "
|
|
246
|
+
'{"items": [{"item": "<checklist item text>", "passed": true|false, "reason": "..."}], '
|
|
247
|
+
'"overall_passed": true|false}. overall_passed is true only if every item passed.'
|
|
248
|
+
)
|
|
249
|
+
if reference_image_path and reference_image_path.exists():
|
|
250
|
+
user += "\n\nA reference image is attached second, after the rendered screenshot, for comparison."
|
|
251
|
+
|
|
252
|
+
image_paths = [screenshot_path]
|
|
253
|
+
if reference_image_path and reference_image_path.exists():
|
|
254
|
+
image_paths.append(reference_image_path)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
verdict = llm.call_json_with_images(
|
|
258
|
+
system, user, image_paths,
|
|
259
|
+
model=model or llm.JUDGE_MODEL, pcp_dir=pcp_dir, command="uat.check_visual_quality",
|
|
260
|
+
)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
return None, f"visual-quality judge call failed: {e}", []
|
|
263
|
+
|
|
264
|
+
checked_items = verdict.get("items", [])
|
|
265
|
+
overall = bool(verdict.get("overall_passed", all(i.get("passed") for i in checked_items)))
|
|
266
|
+
failed = [i for i in checked_items if not i.get("passed")]
|
|
267
|
+
if not overall and failed:
|
|
268
|
+
detail = "; ".join(f"{i.get('item', '?')}: {i.get('reason', '')}" for i in failed)
|
|
269
|
+
else:
|
|
270
|
+
detail = "all checklist items passed"
|
|
271
|
+
return overall, detail, checked_items
|