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/qa.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""QA gates — test suite, lint, SAST/secret-scan.
|
|
2
|
+
|
|
3
|
+
Wraps whatever's already installed for the project's language, same philosophy
|
|
4
|
+
as audit.py's dead-code wrapper: detect a real tool, shell out to it, skip
|
|
5
|
+
gracefully (never block) if nothing's installed. Called from pcp build's
|
|
6
|
+
per-criterion loop, per docs/greenfield.md Phase 3.
|
|
7
|
+
|
|
8
|
+
Honest scope: this verifies "tests pass and lint/SAST is clean before the
|
|
9
|
+
criterion is accepted" — a real, checkable contract. It does NOT enforce
|
|
10
|
+
RED-then-GREEN ordering (test written and failing before code exists) — that
|
|
11
|
+
would require snapshotting agent state mid-session, a different and bigger
|
|
12
|
+
feature than what's built here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# Real incident, 2026-07-18 (Project O dogfood): these were bare
|
|
23
|
+
# module constants with no env override -- a real project's test suite
|
|
24
|
+
# legitimately taking 420-550s against a real Postgres backend always got
|
|
25
|
+
# falsely marked "timed out" against the 300s default, burning a build
|
|
26
|
+
# retry every time. A local hand-patch to the constant had nowhere durable
|
|
27
|
+
# to live and got silently wiped on every reinstall/reset. Same env-override
|
|
28
|
+
# pattern as llm/client.py's _timeout() (PCP_LLM_TIMEOUT) and
|
|
29
|
+
# PCP_BUILD_AGENT_TIMEOUT_SEC -- read lazily so a test can monkeypatch the
|
|
30
|
+
# env var and see the effect without reimporting this module.
|
|
31
|
+
def _timeout_test() -> int:
|
|
32
|
+
return int(os.environ.get("PCP_QA_TEST_TIMEOUT_SEC", "300"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_timeout_info() -> tuple[int, bool]:
|
|
36
|
+
"""(effective timeout seconds, True if PCP_QA_TEST_TIMEOUT_SEC is unset
|
|
37
|
+
and the 300s default is silently in effect) -- lets a build run print
|
|
38
|
+
this loud instead of a wrong-environment failure (e.g. the DB the tests
|
|
39
|
+
hit isn't the one intended) surfacing as an indistinguishable "timed
|
|
40
|
+
out", same masking this module's own docstring above already documents
|
|
41
|
+
for the slow-suite case."""
|
|
42
|
+
return _timeout_test(), "PCP_QA_TEST_TIMEOUT_SEC" not in os.environ
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _partial_output(exc: subprocess.TimeoutExpired) -> str:
|
|
46
|
+
"""Whatever the killed process had already written before the timeout.
|
|
47
|
+
|
|
48
|
+
`TimeoutExpired` carries the partial `stdout`/`stderr` captured up to the
|
|
49
|
+
kill, and every handler here discarded it -- the exception wasn't even bound
|
|
50
|
+
to a name. Measured on Project O 2026-07-30: a 900s pytest timeout
|
|
51
|
+
produced a **311-byte** evidence file containing nothing but PCP's own
|
|
52
|
+
advice message. Twelve of that run's nineteen test-gate blocks were
|
|
53
|
+
timeouts, so twelve full retries were spent, ~$37 of a $78 run, and a human
|
|
54
|
+
opening the evidence could not tell which test hung.
|
|
55
|
+
|
|
56
|
+
The message below is good prose about what to DO and says nothing about what
|
|
57
|
+
HAPPENED. pytest's own partial output names the last test it started, which
|
|
58
|
+
is the one thing that identifies the hang."""
|
|
59
|
+
parts = []
|
|
60
|
+
for label, stream in (("stdout", exc.stdout), ("stderr", exc.stderr)):
|
|
61
|
+
if not stream:
|
|
62
|
+
continue
|
|
63
|
+
text = stream.decode("utf-8", "replace") if isinstance(stream, bytes) else str(stream)
|
|
64
|
+
text = text.strip()
|
|
65
|
+
if text:
|
|
66
|
+
parts.append(f"--- partial {label} before the kill (last 4000 chars) ---\n{text[-4000:]}")
|
|
67
|
+
if not parts:
|
|
68
|
+
return (
|
|
69
|
+
"\n\n--- no partial output was captured before the kill ---\n"
|
|
70
|
+
"The process produced nothing on stdout/stderr, which itself is a signal: it "
|
|
71
|
+
"hung before writing even a collection header, so suspect a blocking connection "
|
|
72
|
+
"at import/fixture time rather than a slow test."
|
|
73
|
+
)
|
|
74
|
+
return "\n\n" + "\n\n".join(parts)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _timeout_message(tool: str, exc: subprocess.TimeoutExpired | None = None) -> str:
|
|
78
|
+
"""A bare "timed out" in the evidence file reads as "the test suite failed"
|
|
79
|
+
to whoever opens it next -- it names neither the limit that was hit nor the
|
|
80
|
+
knob that changes it, so the natural next move is to go debug tests that
|
|
81
|
+
may well be fine. Say which limit, and say the default is a default.
|
|
82
|
+
|
|
83
|
+
Also append whatever the process actually managed to say -- see
|
|
84
|
+
_partial_output for why advice-without-data was not enough.
|
|
85
|
+
|
|
86
|
+
The limit is read off the exception when one is given. `test_timeout_info()`
|
|
87
|
+
reports the limit in effect *now*, which is not necessarily the one that
|
|
88
|
+
killed this process -- `exc.timeout` is the value that actually applied."""
|
|
89
|
+
seconds, is_default = test_timeout_info()
|
|
90
|
+
if exc is not None and exc.timeout:
|
|
91
|
+
actual = int(exc.timeout)
|
|
92
|
+
is_default = is_default and actual == seconds
|
|
93
|
+
seconds = actual
|
|
94
|
+
suffix = " (PCP default — not tuned for this project)" if is_default else ""
|
|
95
|
+
msg = (
|
|
96
|
+
f"{tool} exceeded the {seconds}s PCP_QA_TEST_TIMEOUT_SEC limit{suffix} and was killed. "
|
|
97
|
+
f"No test result was produced — this is NOT a test failure. Either the suite genuinely "
|
|
98
|
+
f"needs longer (raise PCP_QA_TEST_TIMEOUT_SEC) or it is blocked on something that never "
|
|
99
|
+
f"returns (a hung DB connection, a wrong-environment target, a lock)."
|
|
100
|
+
)
|
|
101
|
+
return msg + (_partial_output(exc) if exc is not None else "")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _timeout_lint() -> int:
|
|
105
|
+
return int(os.environ.get("PCP_QA_LINT_TIMEOUT_SEC", "60"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _timeout_sast() -> int:
|
|
109
|
+
return int(os.environ.get("PCP_QA_SAST_TIMEOUT_SEC", "120"))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def project_tool(project_root: Path, name: str) -> str | None:
|
|
113
|
+
"""Resolve a dev tool from the PROJECT's virtualenv before falling back to PATH.
|
|
114
|
+
|
|
115
|
+
PCP shelled out to a bare `pytest`, which resolves from PATH. A user who
|
|
116
|
+
runs `pcp build` without activating the project's venv therefore had their
|
|
117
|
+
project tested by whatever pytest happened to be global -- a different
|
|
118
|
+
interpreter, different installed packages, and results that describe some
|
|
119
|
+
other environment. It stayed invisible because activating the venv is the
|
|
120
|
+
normal habit; nothing failed loudly when it wasn't.
|
|
121
|
+
|
|
122
|
+
That is the same shape as two other bugs found on 2026-07-27: the Postgres
|
|
123
|
+
check counting schemas on whatever answered port 5432 (a shadow container,
|
|
124
|
+
for four days), and `pcp --version` reporting metadata while the code was
|
|
125
|
+
something else. A check aimed confidently at the wrong target reads exactly
|
|
126
|
+
like a passing check.
|
|
127
|
+
|
|
128
|
+
Order: the project's own venv, then PATH. `PCP_TOOL_FROM_PATH=1` forces the
|
|
129
|
+
old behaviour for projects that deliberately use globally-installed tools.
|
|
130
|
+
"""
|
|
131
|
+
if os.environ.get("PCP_TOOL_FROM_PATH") != "1":
|
|
132
|
+
for venv in (".venv", "venv", "env"):
|
|
133
|
+
for sub in ("bin", "Scripts"):
|
|
134
|
+
candidate = project_root / venv / sub / name
|
|
135
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
136
|
+
return str(candidate)
|
|
137
|
+
exe = candidate.with_suffix(".exe")
|
|
138
|
+
if exe.is_file():
|
|
139
|
+
return str(exe)
|
|
140
|
+
return shutil.which(name)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def testmon_available(project_root: Path) -> bool:
|
|
144
|
+
"""Is pytest-testmon installed in the environment pytest will run from?
|
|
145
|
+
|
|
146
|
+
Detected, never depended on -- the same contract this module already has
|
|
147
|
+
with ruff and semgrep, so PCP neither vendors it nor inherits its
|
|
148
|
+
packaging. Checked by asking pytest itself rather than importing testmon
|
|
149
|
+
here, because the interpreter running PCP is frequently not the one running
|
|
150
|
+
the project's tests (PCP is commonly installed globally while the project
|
|
151
|
+
has its own venv).
|
|
152
|
+
|
|
153
|
+
PCP_QA_NO_TESTMON=1 turns it off.
|
|
154
|
+
"""
|
|
155
|
+
if os.environ.get("PCP_QA_NO_TESTMON") == "1":
|
|
156
|
+
return False
|
|
157
|
+
pytest_bin = project_tool(project_root, "pytest")
|
|
158
|
+
if not pytest_bin:
|
|
159
|
+
return False
|
|
160
|
+
try:
|
|
161
|
+
r = subprocess.run([pytest_bin, "--help"], capture_output=True, text=True,
|
|
162
|
+
cwd=project_root, timeout=30)
|
|
163
|
+
except (OSError, subprocess.SubprocessError):
|
|
164
|
+
return False
|
|
165
|
+
return "--testmon" in (r.stdout + r.stderr)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _run_pytest(project_root: Path, test_paths: list[str] | None = None,
|
|
169
|
+
incremental: bool = False) -> dict | None:
|
|
170
|
+
"""Run pytest, optionally letting testmon skip tests it knows are unaffected.
|
|
171
|
+
|
|
172
|
+
Path scoping (impact.py) reduces BREADTH -- which tests are eligible. It
|
|
173
|
+
does nothing about REPETITION, which is the larger term: between one
|
|
174
|
+
criterion and the next a handful of files change and nearly every eligible
|
|
175
|
+
test re-executes against identical code, up to 3 attempts per criterion.
|
|
176
|
+
Measured 2026-07-27 on Project O, scoping alone still left an
|
|
177
|
+
average of 37% of a 1,279-test suite per run, and 99% for the hub module
|
|
178
|
+
every other module depends on.
|
|
179
|
+
|
|
180
|
+
testmon tracks per-test dependencies from actual coverage, so it also fixes
|
|
181
|
+
the two cases scoping structurally cannot: a hub module whose declared
|
|
182
|
+
blast radius really is the whole project, and a greenfield module with no
|
|
183
|
+
source yet to attribute changed files to. It derives dependencies from
|
|
184
|
+
execution rather than from the declared module graph -- which this project's
|
|
185
|
+
own telemetry showed to be fiction (`standards_interop` broke in 69% of
|
|
186
|
+
blocking runs while nothing declared a dependency on it).
|
|
187
|
+
|
|
188
|
+
`incremental` is only ever set for the per-criterion gate. The wave-merge
|
|
189
|
+
gate deliberately runs the full suite with NO testmon: an incremental
|
|
190
|
+
runner is a cache, and the wave boundary is exactly where a cache should be
|
|
191
|
+
distrusted and the real answer computed.
|
|
192
|
+
"""
|
|
193
|
+
pytest_bin = project_tool(project_root, "pytest")
|
|
194
|
+
if not pytest_bin:
|
|
195
|
+
return None
|
|
196
|
+
use_testmon = incremental and testmon_available(project_root)
|
|
197
|
+
args = [pytest_bin, "-q"] + (["--testmon"] if use_testmon else []) + list(test_paths or [])
|
|
198
|
+
try:
|
|
199
|
+
result = subprocess.run(
|
|
200
|
+
args, capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
|
|
201
|
+
)
|
|
202
|
+
except subprocess.TimeoutExpired as e:
|
|
203
|
+
return {"tool": "pytest", "passed": False, "output": _timeout_message("pytest", e)}
|
|
204
|
+
|
|
205
|
+
# Exit code 5 = no tests collected. Under testmon that is the SUCCESS case
|
|
206
|
+
# -- "nothing this change touches" -- and it is also what a broken testmon
|
|
207
|
+
# database looks like. Re-run without it rather than reporting a pass on
|
|
208
|
+
# zero tests from a cache PCP cannot verify.
|
|
209
|
+
if use_testmon and result.returncode not in (0, 1):
|
|
210
|
+
plain = subprocess.run(
|
|
211
|
+
[pytest_bin, "-q", *(test_paths or [])],
|
|
212
|
+
capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
|
|
213
|
+
)
|
|
214
|
+
return {
|
|
215
|
+
"tool": "pytest", "passed": plain.returncode in (0, 5),
|
|
216
|
+
"output": (plain.stdout + plain.stderr)[-3000:],
|
|
217
|
+
"scoped_to": test_paths or None, "incremental": False,
|
|
218
|
+
"pytest_bin": pytest_bin,
|
|
219
|
+
"testmon_fallback": f"testmon exited {result.returncode}; re-ran full scope",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
passed = result.returncode == 0 or result.returncode == 5
|
|
223
|
+
return {
|
|
224
|
+
"tool": "pytest", "passed": passed, "output": (result.stdout + result.stderr)[-3000:],
|
|
225
|
+
"scoped_to": test_paths or None, "incremental": use_testmon,
|
|
226
|
+
"pytest_bin": pytest_bin,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _run_npm_test(project_root: Path) -> dict | None:
|
|
231
|
+
pkg = project_root / "package.json"
|
|
232
|
+
if not pkg.exists() or not shutil.which("npm"):
|
|
233
|
+
return None
|
|
234
|
+
try:
|
|
235
|
+
data = json.loads(pkg.read_text())
|
|
236
|
+
except Exception:
|
|
237
|
+
return None
|
|
238
|
+
if "test" not in data.get("scripts", {}):
|
|
239
|
+
return None
|
|
240
|
+
try:
|
|
241
|
+
result = subprocess.run(
|
|
242
|
+
["npm", "test", "--silent"], capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
|
|
243
|
+
)
|
|
244
|
+
except subprocess.TimeoutExpired as e:
|
|
245
|
+
return {"tool": "npm test", "passed": False, "output": _timeout_message("npm test", e)}
|
|
246
|
+
return {"tool": "npm test", "passed": result.returncode == 0, "output": (result.stdout + result.stderr)[-3000:]}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _run_go_test(project_root: Path) -> dict | None:
|
|
250
|
+
if not (project_root / "go.mod").exists() or not shutil.which("go"):
|
|
251
|
+
return None
|
|
252
|
+
try:
|
|
253
|
+
result = subprocess.run(
|
|
254
|
+
["go", "test", "./..."], capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
|
|
255
|
+
)
|
|
256
|
+
except subprocess.TimeoutExpired as e:
|
|
257
|
+
return {"tool": "go test", "passed": False, "output": _timeout_message("go test", e)}
|
|
258
|
+
return {"tool": "go test", "passed": result.returncode == 0, "output": (result.stdout + result.stderr)[-3000:]}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def full_suite_forced() -> bool:
|
|
262
|
+
"""Escape hatch, not a feature flag.
|
|
263
|
+
|
|
264
|
+
Impact scoping used to be opt-in behind PCP_QA_TEST_SELECTION=impact, off
|
|
265
|
+
by default, while `_run_test_suite_check`'s own docstring already described
|
|
266
|
+
scoped-per-criterion + full-suite-at-wave-merge as the design. The design
|
|
267
|
+
was documented and disabled, so every criterion attempt ran the entire
|
|
268
|
+
suite -- 1,098 tests, ~7m46s, on Project O, re-running the same
|
|
269
|
+
tests up to three times per criterion.
|
|
270
|
+
|
|
271
|
+
Scoping is now the norm. PCP_QA_FULL_SUITE=1 forces the old behaviour for
|
|
272
|
+
anyone who wants it back.
|
|
273
|
+
"""
|
|
274
|
+
return os.environ.get("PCP_QA_FULL_SUITE") == "1"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def run_test_suite(project_root: Path, pcp_dir: Path | None = None, changed_files: list[str] | None = None) -> dict:
|
|
278
|
+
"""Scoped to the blast radius of the change when that can be determined,
|
|
279
|
+
full regression suite otherwise.
|
|
280
|
+
|
|
281
|
+
Given pcp_dir/changed_files, scopes pytest to the modules impacted by the
|
|
282
|
+
change plus the modularity drop-tests (see impact.py:
|
|
283
|
+
the changed module(s) plus every module that transitively depends on
|
|
284
|
+
them, via the same dependency graph coupling.py already trusts for
|
|
285
|
+
coupling_score — not a separate ad hoc mechanism). Only pytest
|
|
286
|
+
supports scoping today; npm/go test always run in full, an honest scope
|
|
287
|
+
limit, not a silent gap. Falls back to the full suite whenever impact.py
|
|
288
|
+
can't confidently narrow the scope — never silently runs zero tests.
|
|
289
|
+
|
|
290
|
+
The unscoped full suite is the WAVE-MERGE gate's job (see
|
|
291
|
+
_run_wave_merge_gate), which is where cross-module regression risk is
|
|
292
|
+
genuinely resolved — not on every one of up to three attempts per
|
|
293
|
+
criterion. PCP_QA_FULL_SUITE=1 restores the old always-full behaviour."""
|
|
294
|
+
test_paths = None
|
|
295
|
+
if not full_suite_forced() and pcp_dir is not None and changed_files:
|
|
296
|
+
from pcp.impact import blast_radius_test_paths
|
|
297
|
+
try:
|
|
298
|
+
test_paths = blast_radius_test_paths(pcp_dir, project_root, changed_files)
|
|
299
|
+
except Exception:
|
|
300
|
+
test_paths = None # scoping failed -- fall back to the full suite, don't propagate
|
|
301
|
+
|
|
302
|
+
if test_paths and project_tool(project_root, "pytest"):
|
|
303
|
+
out = _run_pytest(project_root, test_paths, incremental=True)
|
|
304
|
+
if out is not None:
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
for runner in (_run_pytest, _run_npm_test, _run_go_test):
|
|
308
|
+
out = runner(project_root)
|
|
309
|
+
if out is not None:
|
|
310
|
+
return out
|
|
311
|
+
return {"tool": None, "passed": True, "output": ""}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _run_ruff(project_root: Path, changed_files: list[str]) -> dict | None:
|
|
315
|
+
ruff_bin = project_tool(project_root, "ruff")
|
|
316
|
+
if not ruff_bin:
|
|
317
|
+
return None
|
|
318
|
+
py_files = [f for f in changed_files if f.endswith(".py")]
|
|
319
|
+
if not py_files:
|
|
320
|
+
return {"tool": "ruff", "passed": True, "issues": []}
|
|
321
|
+
try:
|
|
322
|
+
result = subprocess.run(
|
|
323
|
+
[ruff_bin, "check", *py_files], capture_output=True, text=True, cwd=project_root, timeout=_timeout_lint(),
|
|
324
|
+
)
|
|
325
|
+
except subprocess.TimeoutExpired:
|
|
326
|
+
return {"tool": "ruff", "passed": True, "issues": [], "skipped": "timed out"}
|
|
327
|
+
except Exception as e:
|
|
328
|
+
# Previously uncaught -- a ruff crash/misconfig took down the whole
|
|
329
|
+
# gate-evaluation thread instead of degrading to a skip, worse than
|
|
330
|
+
# the silent-skip failure mode this is meant to guard against.
|
|
331
|
+
return {"tool": "ruff", "passed": True, "issues": [], "skipped": str(e)}
|
|
332
|
+
issues = [l for l in result.stdout.splitlines() if l.strip()]
|
|
333
|
+
return {"tool": "ruff", "passed": result.returncode == 0, "issues": issues}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _run_eslint(project_root: Path, changed_files: list[str]) -> dict | None:
|
|
337
|
+
eslint_bin = project_root / "node_modules" / ".bin" / "eslint"
|
|
338
|
+
if not eslint_bin.exists():
|
|
339
|
+
return None
|
|
340
|
+
js_files = [f for f in changed_files if f.endswith((".js", ".jsx", ".ts", ".tsx"))]
|
|
341
|
+
if not js_files:
|
|
342
|
+
return {"tool": "eslint", "passed": True, "issues": []}
|
|
343
|
+
try:
|
|
344
|
+
result = subprocess.run(
|
|
345
|
+
[str(eslint_bin), *js_files], capture_output=True, text=True, cwd=project_root, timeout=_timeout_lint(),
|
|
346
|
+
)
|
|
347
|
+
except subprocess.TimeoutExpired:
|
|
348
|
+
return {"tool": "eslint", "passed": True, "issues": [], "skipped": "timed out"}
|
|
349
|
+
except Exception as e:
|
|
350
|
+
return {"tool": "eslint", "passed": True, "issues": [], "skipped": str(e)}
|
|
351
|
+
issues = [l for l in result.stdout.splitlines() if l.strip()]
|
|
352
|
+
return {"tool": "eslint", "passed": result.returncode == 0, "issues": issues}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def run_lint(project_root: Path, changed_files: list[str]) -> dict:
|
|
356
|
+
"""Scoped to changed files only — fast, PR-diff-style check."""
|
|
357
|
+
for runner in (_run_ruff, _run_eslint):
|
|
358
|
+
out = runner(project_root, changed_files)
|
|
359
|
+
if out is not None:
|
|
360
|
+
return out
|
|
361
|
+
return {"tool": None, "passed": True, "issues": []}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _run_python_coverage(project_root: Path) -> dict | None:
|
|
365
|
+
coverage_bin = project_tool(project_root, "coverage")
|
|
366
|
+
pytest_bin = project_tool(project_root, "pytest")
|
|
367
|
+
if not coverage_bin or not pytest_bin:
|
|
368
|
+
return None
|
|
369
|
+
try:
|
|
370
|
+
subprocess.run(
|
|
371
|
+
[coverage_bin, "run", "-m", "pytest", "-q"],
|
|
372
|
+
capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
|
|
373
|
+
)
|
|
374
|
+
result = subprocess.run(
|
|
375
|
+
[coverage_bin, "report", "--format=total"],
|
|
376
|
+
capture_output=True, text=True, cwd=project_root, timeout=30,
|
|
377
|
+
)
|
|
378
|
+
except subprocess.TimeoutExpired:
|
|
379
|
+
return {"tool": "coverage", "percent": None}
|
|
380
|
+
try:
|
|
381
|
+
return {"tool": "coverage", "percent": float(result.stdout.strip())}
|
|
382
|
+
except ValueError:
|
|
383
|
+
return {"tool": "coverage", "percent": None}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _run_npm_coverage(project_root: Path) -> dict | None:
|
|
387
|
+
pkg = project_root / "package.json"
|
|
388
|
+
if not pkg.exists() or not shutil.which("npm"):
|
|
389
|
+
return None
|
|
390
|
+
try:
|
|
391
|
+
data = json.loads(pkg.read_text())
|
|
392
|
+
except Exception:
|
|
393
|
+
return None
|
|
394
|
+
if "coverage" not in data.get("scripts", {}):
|
|
395
|
+
return None
|
|
396
|
+
try:
|
|
397
|
+
result = subprocess.run(
|
|
398
|
+
["npm", "run", "coverage", "--silent"],
|
|
399
|
+
capture_output=True, text=True, cwd=project_root, timeout=_timeout_test(),
|
|
400
|
+
)
|
|
401
|
+
except subprocess.TimeoutExpired:
|
|
402
|
+
return {"tool": "npm coverage", "percent": None}
|
|
403
|
+
# istanbul/nyc/jest all print a summary row starting with "All files"
|
|
404
|
+
m = re.search(r"All files\s*\|\s*([\d.]+)", result.stdout)
|
|
405
|
+
return {"tool": "npm coverage", "percent": float(m.group(1)) if m else None}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def run_coverage(project_root: Path) -> dict:
|
|
409
|
+
"""Best-effort test coverage %. Returns {"tool": None, "percent": None} if
|
|
410
|
+
no coverage tool is detected — never blocks, this is a tracked metric only."""
|
|
411
|
+
for runner in (_run_python_coverage, _run_npm_coverage):
|
|
412
|
+
out = runner(project_root)
|
|
413
|
+
if out is not None:
|
|
414
|
+
return out
|
|
415
|
+
return {"tool": None, "percent": None}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def run_sast(project_root: Path, changed_files: list[str]) -> dict:
|
|
419
|
+
"""SAST + secret-scan via semgrep, if installed. Scoped to changed files."""
|
|
420
|
+
semgrep_bin = project_tool(project_root, "semgrep")
|
|
421
|
+
if not semgrep_bin or not changed_files:
|
|
422
|
+
return {"tool": None, "passed": True, "findings": []}
|
|
423
|
+
try:
|
|
424
|
+
result = subprocess.run(
|
|
425
|
+
[semgrep_bin, "--config=auto", "--quiet", "--error", *changed_files],
|
|
426
|
+
capture_output=True, text=True, cwd=project_root, timeout=_timeout_sast(),
|
|
427
|
+
)
|
|
428
|
+
except subprocess.TimeoutExpired:
|
|
429
|
+
return {"tool": "semgrep", "passed": True, "findings": [], "skipped": "timed out"}
|
|
430
|
+
except Exception as e:
|
|
431
|
+
return {"tool": "semgrep", "passed": True, "findings": [], "skipped": str(e)}
|
|
432
|
+
findings = [l for l in result.stdout.splitlines() if l.strip()]
|
|
433
|
+
if result.returncode != 0 and not findings:
|
|
434
|
+
# semgrep's --error flag exits non-zero for EITHER real findings OR a
|
|
435
|
+
# tool-level failure (network fetch for --config=auto, a scan error)
|
|
436
|
+
# -- indistinguishable by exit code alone. Real 2026-07-21 incident:
|
|
437
|
+
# this surfaced as "SAST found issues" with an empty evidence file,
|
|
438
|
+
# since nothing landed on stdout to write. Skip (don't block) rather
|
|
439
|
+
# than block on a phantom finding -- same posture as the timeout/
|
|
440
|
+
# exception branches above.
|
|
441
|
+
return {
|
|
442
|
+
"tool": "semgrep", "passed": True, "findings": [],
|
|
443
|
+
"skipped": (result.stderr or "semgrep exited non-zero with no findings on stdout")[-500:],
|
|
444
|
+
}
|
|
445
|
+
return {"tool": "semgrep", "passed": result.returncode == 0, "findings": findings}
|
pcp/run_log.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""run_log.py — pre/post audit bracket around any development or test run.
|
|
2
|
+
|
|
3
|
+
Answers "where is the bluffing happening" by recording a PRE entry before a
|
|
4
|
+
run starts and a POST entry after it ends, linked by run_id, hash-chained
|
|
5
|
+
(same mechanism as telemetry.jsonl/decision_log.jsonl). The anomaly_flags on
|
|
6
|
+
a POST entry are computed deterministically from git state and the PRE
|
|
7
|
+
entry's own data -- never an LLM judgment about itself. A run claiming
|
|
8
|
+
"success" with no new commit, no test evidence, and only LLM-judged checks
|
|
9
|
+
having run is exactly the pattern this exists to make visible instead of
|
|
10
|
+
indistinguishable from real work in the same ledger.
|
|
11
|
+
|
|
12
|
+
Two ways an entry gets written:
|
|
13
|
+
1. `pcp run-log start`/`end` -- a human or an interactive Claude Code session
|
|
14
|
+
brackets its own manual/direct-edit work. Token/cost fields here are
|
|
15
|
+
self-reported (self_reported_usage=True) -- there is no independent
|
|
16
|
+
source for an interactive session's own usage, and this module does not
|
|
17
|
+
pretend otherwise.
|
|
18
|
+
2. build.py's `_build_one_criterion` calls start_run()/end_run() directly,
|
|
19
|
+
wired from the real `claude -p --output-format json` envelope
|
|
20
|
+
(self_reported_usage=False) -- the same real numbers token_ledger.yaml
|
|
21
|
+
already gets, just also bracketed here with git-state proof.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import subprocess
|
|
27
|
+
import uuid
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from pcp.evidence_chain import chain_entry
|
|
32
|
+
from pcp.objective_conflicts import objective_hash
|
|
33
|
+
|
|
34
|
+
_TS_FMT = "%Y-%m-%dT%H:%M:%SZ"
|
|
35
|
+
|
|
36
|
+
_DETERMINISTIC_CHECKS = {
|
|
37
|
+
"tests", "lint", "sast", "l1", "scope", "design_consistency",
|
|
38
|
+
"bvb_justification", "customization", "lazy_marker", "a11y",
|
|
39
|
+
}
|
|
40
|
+
_LLM_JUDGED_CHECKS = {"arch", "gate", "design_justification", "visual_quality"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now() -> str:
|
|
44
|
+
return datetime.now(timezone.utc).strftime(_TS_FMT)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _git_head(project_root: Path) -> str | None:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["git", "rev-parse", "HEAD"], cwd=project_root, capture_output=True, text=True,
|
|
50
|
+
)
|
|
51
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _pushed_to_remote(project_root: Path, sha: str | None) -> bool:
|
|
55
|
+
if not sha:
|
|
56
|
+
return False
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["git", "branch", "-r", "--contains", sha], cwd=project_root, capture_output=True, text=True,
|
|
59
|
+
)
|
|
60
|
+
return result.returncode == 0 and bool(result.stdout.strip())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ledger_path(pcp_dir: Path) -> Path:
|
|
64
|
+
return Path(pcp_dir) / "run_ledger.jsonl"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load(path: Path) -> list[dict]:
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return []
|
|
70
|
+
records = []
|
|
71
|
+
for line in path.read_text().splitlines():
|
|
72
|
+
line = line.strip()
|
|
73
|
+
if not line:
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
records.append(json.loads(line))
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
continue
|
|
79
|
+
return records
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _append(pcp_dir: Path, fields: dict) -> dict:
|
|
83
|
+
path = _ledger_path(pcp_dir)
|
|
84
|
+
records = _load(path)
|
|
85
|
+
prev_hash = records[-1].get("entry_hash") if records else None
|
|
86
|
+
entry = chain_entry(prev_hash, fields)
|
|
87
|
+
with open(path, "a") as f:
|
|
88
|
+
f.write(json.dumps(entry) + "\n")
|
|
89
|
+
return entry
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def start_run(
|
|
93
|
+
pcp_dir: Path, *, module: str, feature: str, run_type: str, actor: str,
|
|
94
|
+
business_objective: str | None = None, model: str | None = None,
|
|
95
|
+
criterion_id: str | None = None, wave_number: int | None = None,
|
|
96
|
+
logic_tier: str | None = None, build_vs_buy: str | None = None,
|
|
97
|
+
internal_deps_declared: list[str] | None = None, internal_deps_real: list[str] | None = None,
|
|
98
|
+
) -> str:
|
|
99
|
+
"""PRE record. Returns run_id -- pass it to end_run() to close the bracket.
|
|
100
|
+
|
|
101
|
+
criterion_id/wave_number/logic_tier/build_vs_buy/internal_deps_declared
|
|
102
|
+
(2026-07-31): the "build capsule" fields -- BRD/module/submodule/wave/
|
|
103
|
+
scope/deps/completed/tested requested as a time-series record of what
|
|
104
|
+
got built. This ledger already had module/feature/tests/commit/cost/
|
|
105
|
+
objective_hash; these fill in the remaining self-declared fields at the
|
|
106
|
+
point they're known (kickoff/pm time), not recomputed later.
|
|
107
|
+
internal_deps_declared is exactly that -- DECLARED, not verified against
|
|
108
|
+
real coupling (see validate_strategy.py's hidden_coupling for the
|
|
109
|
+
actual-usage signal; the declared graph is known to drift from it)."""
|
|
110
|
+
project_root = Path(pcp_dir).parent
|
|
111
|
+
run_id = str(uuid.uuid4())
|
|
112
|
+
obj_text = business_objective
|
|
113
|
+
if obj_text is None:
|
|
114
|
+
obj_path = Path(pcp_dir) / "objective.md"
|
|
115
|
+
obj_text = obj_path.read_text()[:300] if obj_path.exists() else ""
|
|
116
|
+
_append(pcp_dir, {
|
|
117
|
+
"run_id": run_id, "phase": "pre", "timestamp": _now(),
|
|
118
|
+
"business_objective": obj_text, "objective_hash": objective_hash(Path(pcp_dir)),
|
|
119
|
+
"module": module, "feature": feature, "run_type": run_type, "actor": actor,
|
|
120
|
+
"start_time": _now(), "model": model, "pre_commit_sha": _git_head(project_root),
|
|
121
|
+
"criterion_id": criterion_id, "wave_number": wave_number,
|
|
122
|
+
"logic_tier": logic_tier, "build_vs_buy": build_vs_buy,
|
|
123
|
+
"internal_deps_declared": internal_deps_declared or [],
|
|
124
|
+
# internal_deps_real: actual git co-change coupling (coupling.py's
|
|
125
|
+
# compute_change_coupling, cached via validate-strategy's last run in
|
|
126
|
+
# hidden_coupling.json) -- distinct from internal_deps_declared,
|
|
127
|
+
# which is self-reported and known to drift from what's real in this
|
|
128
|
+
# codebase (see the standards_interop finding: 69% of blocking test
|
|
129
|
+
# failures in one dogfood project came from modules with ZERO
|
|
130
|
+
# declared dependency on it). Empty if validate-strategy has never
|
|
131
|
+
# run, or this module has no confirmed hidden coupling -- absence
|
|
132
|
+
# here means "not yet measured or none found", not "verified zero".
|
|
133
|
+
"internal_deps_real": internal_deps_real or [],
|
|
134
|
+
})
|
|
135
|
+
return run_id
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def end_run(
|
|
139
|
+
pcp_dir: Path, run_id: str, *, result: str,
|
|
140
|
+
model: str | None = None, token_input: int = 0, token_output: int = 0,
|
|
141
|
+
token_cache_read: int = 0, cost_usd: float | None = None,
|
|
142
|
+
tests_ran: bool | None = None, tests_passed: bool | None = None,
|
|
143
|
+
real_gates_passed: list[str] | None = None, llm_judged_gates_passed: list[str] | None = None,
|
|
144
|
+
note: str | None = None, self_reported_usage: bool = False,
|
|
145
|
+
external_deps: list[str] | None = None,
|
|
146
|
+
) -> dict:
|
|
147
|
+
"""POST record, same run_id. anomaly_flags are computed here,
|
|
148
|
+
deterministically, from git state + the PRE record -- never from an LLM
|
|
149
|
+
asked to grade the run it just did."""
|
|
150
|
+
project_root = Path(pcp_dir).parent
|
|
151
|
+
records = _load(_ledger_path(pcp_dir))
|
|
152
|
+
pre = next(
|
|
153
|
+
(r for r in reversed(records) if r.get("run_id") == run_id and r.get("phase") == "pre"), None,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
post_commit_sha = _git_head(project_root)
|
|
157
|
+
pre_commit_sha = pre.get("pre_commit_sha") if pre else None
|
|
158
|
+
committed = bool(post_commit_sha and post_commit_sha != pre_commit_sha)
|
|
159
|
+
pushed = _pushed_to_remote(project_root, post_commit_sha) if committed else False
|
|
160
|
+
real_gates_passed = real_gates_passed or []
|
|
161
|
+
llm_judged_gates_passed = llm_judged_gates_passed or []
|
|
162
|
+
|
|
163
|
+
proof = {
|
|
164
|
+
"tests_ran": tests_ran, "tests_passed": tests_passed,
|
|
165
|
+
"real_gates_passed": real_gates_passed, "llm_judged_gates_passed": llm_judged_gates_passed,
|
|
166
|
+
"committed": committed, "pushed": pushed, "post_commit_sha": post_commit_sha,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
anomalies = []
|
|
170
|
+
if not committed:
|
|
171
|
+
anomalies.append("no_commit: claimed run produced no new commit")
|
|
172
|
+
if not real_gates_passed and llm_judged_gates_passed:
|
|
173
|
+
anomalies.append("all_self_judged: only LLM-judged checks ran, zero deterministic verification")
|
|
174
|
+
if tests_ran is False or tests_ran is None:
|
|
175
|
+
anomalies.append("no_test_evidence: no test-suite result recorded for this run")
|
|
176
|
+
if pre is not None and objective_hash(Path(pcp_dir)) != pre.get("objective_hash"):
|
|
177
|
+
anomalies.append("objective_drifted: objective.md changed mid-run — result may target a stale spec")
|
|
178
|
+
if result == "success" and not committed and not (tests_ran and tests_passed):
|
|
179
|
+
anomalies.append("unverified_success: claimed success with no commit AND no passing test evidence")
|
|
180
|
+
|
|
181
|
+
fields = {
|
|
182
|
+
"run_id": run_id, "phase": "post", "timestamp": _now(), "end_time": _now(),
|
|
183
|
+
"duration_sec": None, "model": model,
|
|
184
|
+
"token_input": token_input, "token_output": token_output, "token_cache_read": token_cache_read,
|
|
185
|
+
"cost_usd": cost_usd, "self_reported_usage": self_reported_usage,
|
|
186
|
+
"result": result, "proof_of_delivery": proof, "note": note, "anomaly_flags": anomalies,
|
|
187
|
+
"external_deps": external_deps or [],
|
|
188
|
+
}
|
|
189
|
+
if pre is not None:
|
|
190
|
+
try:
|
|
191
|
+
start_dt = datetime.strptime(pre["start_time"], _TS_FMT).replace(tzinfo=timezone.utc)
|
|
192
|
+
end_dt = datetime.strptime(fields["end_time"], _TS_FMT).replace(tzinfo=timezone.utc)
|
|
193
|
+
fields["duration_sec"] = (end_dt - start_dt).total_seconds()
|
|
194
|
+
except (KeyError, ValueError):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
return _append(pcp_dir, fields)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def load(pcp_dir: Path) -> list[dict]:
|
|
201
|
+
return _load(_ledger_path(pcp_dir))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def pair_runs(records: list[dict]) -> list[dict]:
|
|
205
|
+
"""One row per completed run (pre+post merged), newest last. A pre with
|
|
206
|
+
no matching post yet (run still open, or the process died mid-run)
|
|
207
|
+
is surfaced separately by callers that want it -- never silently
|
|
208
|
+
dropped here."""
|
|
209
|
+
pres = {r["run_id"]: r for r in records if r.get("phase") == "pre" and r.get("run_id")}
|
|
210
|
+
pairs = []
|
|
211
|
+
for r in records:
|
|
212
|
+
if r.get("phase") != "post":
|
|
213
|
+
continue
|
|
214
|
+
pre = pres.get(r.get("run_id"), {})
|
|
215
|
+
pairs.append({**pre, **r})
|
|
216
|
+
return pairs
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def open_runs(records: list[dict]) -> list[dict]:
|
|
220
|
+
"""PRE records with no matching POST -- a run that started and never
|
|
221
|
+
closed (crash, forgotten `end`, killed process). Not an error by
|
|
222
|
+
itself, but worth surfacing: an open run is one more way real work can
|
|
223
|
+
go unaccounted."""
|
|
224
|
+
closed_ids = {r["run_id"] for r in records if r.get("phase") == "post" and r.get("run_id")}
|
|
225
|
+
return [r for r in records if r.get("phase") == "pre" and r.get("run_id") not in closed_ids]
|
pcp/schema/__init__.py
ADDED
|
File without changes
|