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/commands/check.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""pcp check — Layer 1 pre-commit gate (deterministic, no LLM, <1s)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import yaml
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from pcp.pcp_dir import find_pcp_dir, get_modules_dir, NoPCPDir
|
|
13
|
+
from pcp.schema.validator import validate_file, load_yaml
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
BYPASS_MARKER = re.compile(r"\[pcp-bypass:\s*(.+?)\]", re.IGNORECASE)
|
|
18
|
+
BYPASS_LOG = "bypass_log.yaml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Rule ID shape is the schema's own constraint (ci_rules.schema.json:
|
|
22
|
+
# "^[A-Z]+_?[0-9]+$") -- R001, SEC_002, MOD_A003, etc. Matching only "R\d+"
|
|
23
|
+
# would silently fail to scope a bypass for any project using the SEC_/MOD_
|
|
24
|
+
# convention, which real ci_rules.yaml files do.
|
|
25
|
+
_SCOPED_BYPASS_PREFIX = re.compile(
|
|
26
|
+
r"^((?:[A-Z]+_?[0-9]+)(?:\s*,\s*[A-Z]+_?[0-9]+)*)\s*:\s*(.+)$", re.DOTALL,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_bypass_reason(commit_msg_file: Path | None) -> tuple[str, list[str] | None] | None:
|
|
31
|
+
"""(reason, scoped_rule_ids) or None. scoped_rule_ids is None for a blanket
|
|
32
|
+
bypass (skips every rule -- the original, still-default behaviour) or a list
|
|
33
|
+
for `[pcp-bypass: R008: reason]` / `[pcp-bypass: R003,R008: reason]`, which
|
|
34
|
+
skips ONLY the named rule(s) and still runs everything else.
|
|
35
|
+
|
|
36
|
+
Scoping added 2026-07-30 after a real incident: an `ast_pattern` rule
|
|
37
|
+
(R008) matched its own text inside PCP's generated telemetry.jsonl -- a
|
|
38
|
+
false positive against a file that was never supposed to be scanned (see
|
|
39
|
+
pcp/operational.py) -- and because bypass was all-or-nothing, the one
|
|
40
|
+
genuine false positive silently disabled R001 through R010 together for
|
|
41
|
+
that commit. A human writing `[pcp-bypass: R008: ...]` almost always means
|
|
42
|
+
"this one rule is wrong here", not "skip Layer 1 entirely" -- the blanket
|
|
43
|
+
form remains available for when that IS what's meant, but is no longer the
|
|
44
|
+
only option.
|
|
45
|
+
|
|
46
|
+
Only recognizes the marker when it occupies an ENTIRE line by itself (any
|
|
47
|
+
line in the message, not just the last one). Confirmed bug, twice: a
|
|
48
|
+
paragraph-scoped version of this still self-triggered on a commit message
|
|
49
|
+
whose body was one unbroken multi-line block (no blank line inside it)
|
|
50
|
+
that merely *mentioned* the marker mid-sentence while describing this
|
|
51
|
+
exact fix. Requiring a full-line match is both simpler and tighter: prose
|
|
52
|
+
like "...scope the [pcp-bypass: reason] match to..." shares its line with
|
|
53
|
+
other text and can never match, while genuine usage -- the marker alone
|
|
54
|
+
on its own line, anywhere in the message -- always does."""
|
|
55
|
+
if not commit_msg_file or not commit_msg_file.exists():
|
|
56
|
+
return None
|
|
57
|
+
msg = commit_msg_file.read_text()
|
|
58
|
+
for line in msg.splitlines():
|
|
59
|
+
if line.lstrip().startswith("#"):
|
|
60
|
+
continue
|
|
61
|
+
m = BYPASS_MARKER.fullmatch(line.strip())
|
|
62
|
+
if not m:
|
|
63
|
+
continue
|
|
64
|
+
body = m.group(1).strip()
|
|
65
|
+
scoped = _SCOPED_BYPASS_PREFIX.match(body)
|
|
66
|
+
if scoped:
|
|
67
|
+
rule_ids = [r.strip().upper() for r in scoped.group(1).split(",")]
|
|
68
|
+
return scoped.group(2).strip(), rule_ids
|
|
69
|
+
return body, None
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _log_bypass(pcp_dir: Path, reason: str, rules_checked: list[str],
|
|
74
|
+
files: list[str] | None = None, modules: list[str] | None = None) -> None:
|
|
75
|
+
from datetime import datetime, timezone
|
|
76
|
+
from pcp.evidence_chain import chain_entry
|
|
77
|
+
|
|
78
|
+
log_path = pcp_dir / BYPASS_LOG
|
|
79
|
+
existing = []
|
|
80
|
+
if log_path.exists():
|
|
81
|
+
data = yaml.safe_load(log_path.read_text()) or {}
|
|
82
|
+
existing = data.get("bypasses", [])
|
|
83
|
+
|
|
84
|
+
prev_hash = existing[-1].get("entry_hash") if existing else None
|
|
85
|
+
fields = {
|
|
86
|
+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
87
|
+
"reason": reason,
|
|
88
|
+
"rules_bypassed": rules_checked,
|
|
89
|
+
"files": files or [],
|
|
90
|
+
"modules": modules or [],
|
|
91
|
+
}
|
|
92
|
+
existing.append(chain_entry(prev_hash, fields))
|
|
93
|
+
|
|
94
|
+
with open(log_path, "w") as f:
|
|
95
|
+
yaml.dump({"bypasses": existing}, f, default_flow_style=False)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _attributed_modules(project_root: Path, pcp_dir: Path, staged_files: list[str],
|
|
99
|
+
module_names: list[str]) -> list[str]:
|
|
100
|
+
"""Map staged files to the module(s) they belong to, so a bypass entry can
|
|
101
|
+
be placed on that module's own docs/changelog.md timeline instead of
|
|
102
|
+
sitting as an unattributed global entry (the gap CLAUDE.md's Per-Module
|
|
103
|
+
Doc Kit section names explicitly -- bypass_log.yaml has no file/module
|
|
104
|
+
field, so changelog.md excludes bypasses today).
|
|
105
|
+
|
|
106
|
+
Two match strategies, both cheap/deterministic (no LLM):
|
|
107
|
+
1. Direct spec-dir match -- a staged file under strategy/modules/<name>/
|
|
108
|
+
belongs to <name>.
|
|
109
|
+
2. Criterion target match -- a staged source file matches a criterion's
|
|
110
|
+
declared `target` path in that module's acceptance.yaml.
|
|
111
|
+
"""
|
|
112
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
113
|
+
matched: set[str] = set()
|
|
114
|
+
|
|
115
|
+
for rel_path in staged_files:
|
|
116
|
+
for name in module_names:
|
|
117
|
+
prefix = f"strategy/modules/{name}/"
|
|
118
|
+
if rel_path.startswith(prefix) or rel_path.startswith(".pcp/" + prefix):
|
|
119
|
+
matched.add(name)
|
|
120
|
+
|
|
121
|
+
for name in module_names:
|
|
122
|
+
acceptance_path = modules_dir / name / "acceptance.yaml"
|
|
123
|
+
if not acceptance_path.exists():
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
acc_data = yaml.safe_load(acceptance_path.read_text()) or {}
|
|
127
|
+
except yaml.YAMLError:
|
|
128
|
+
continue
|
|
129
|
+
targets = {c.get("target") for c in acc_data.get("criteria", []) if c.get("target")}
|
|
130
|
+
if not targets:
|
|
131
|
+
continue
|
|
132
|
+
for rel_path in staged_files:
|
|
133
|
+
if rel_path in targets:
|
|
134
|
+
matched.add(name)
|
|
135
|
+
|
|
136
|
+
return sorted(matched)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _match_scope(file_path: str, scope_patterns: list[str]) -> bool:
|
|
140
|
+
"""Return True if file_path matches any scope glob."""
|
|
141
|
+
if not scope_patterns:
|
|
142
|
+
return True
|
|
143
|
+
from fnmatch import fnmatch
|
|
144
|
+
return any(fnmatch(file_path, pat) for pat in scope_patterns)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _run_ast_rule(rule: dict, staged_files: list[str], project_root: Path) -> list[str]:
|
|
148
|
+
"""Return list of violation messages for this rule."""
|
|
149
|
+
pattern = re.compile(rule["pattern"], re.MULTILINE)
|
|
150
|
+
scope = rule.get("scope", [])
|
|
151
|
+
violations = []
|
|
152
|
+
|
|
153
|
+
for rel_path in staged_files:
|
|
154
|
+
if not _match_scope(rel_path, scope) and scope:
|
|
155
|
+
continue
|
|
156
|
+
full_path = project_root / rel_path
|
|
157
|
+
if not full_path.exists() or not full_path.is_file():
|
|
158
|
+
continue
|
|
159
|
+
try:
|
|
160
|
+
content = full_path.read_text(errors="replace")
|
|
161
|
+
except OSError:
|
|
162
|
+
continue
|
|
163
|
+
for m in pattern.finditer(content):
|
|
164
|
+
line_no = content[: m.start()].count("\n") + 1
|
|
165
|
+
violations.append(f"{rel_path}:{line_no}: matched pattern /{rule['pattern']}/")
|
|
166
|
+
|
|
167
|
+
return violations
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _run_ast_required_rule(rule: dict, project_root: Path) -> list[str]:
|
|
171
|
+
"""Violations for a check:ast_pattern rule with require_present: true --
|
|
172
|
+
inverted from ast_pattern's default 'block if pattern found anywhere in a
|
|
173
|
+
changed file' semantics. This is 'block if pattern is found NOWHERE in
|
|
174
|
+
scope' instead -- e.g. 'agent.py must call policy.strip() somewhere', not
|
|
175
|
+
'agent.py must never contain X'. Project-wide like file_exists, not
|
|
176
|
+
diff-scoped like the default ast_pattern rules: this represents a standing
|
|
177
|
+
invariant on the file's current content, checked every time, not only
|
|
178
|
+
when that file happens to be part of the current commit."""
|
|
179
|
+
pattern = re.compile(rule["pattern"], re.MULTILINE)
|
|
180
|
+
scope = rule.get("scope", [])
|
|
181
|
+
if not scope:
|
|
182
|
+
return [f"[{rule['id']}] require_present rule has no scope — cannot check"]
|
|
183
|
+
|
|
184
|
+
matched_any_file = False
|
|
185
|
+
for pat in scope:
|
|
186
|
+
for full_path in project_root.glob(pat):
|
|
187
|
+
if not full_path.is_file():
|
|
188
|
+
continue
|
|
189
|
+
matched_any_file = True
|
|
190
|
+
try:
|
|
191
|
+
content = full_path.read_text(errors="replace")
|
|
192
|
+
except OSError:
|
|
193
|
+
continue
|
|
194
|
+
if pattern.search(content):
|
|
195
|
+
return [] # found somewhere in scope -- satisfied
|
|
196
|
+
|
|
197
|
+
if not matched_any_file:
|
|
198
|
+
return [f"scope {scope} matched no files — required pattern /{rule['pattern']}/ cannot be verified"]
|
|
199
|
+
return [f"required pattern /{rule['pattern']}/ not found in any file matching {scope}"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _git_show_head(project_root: Path, rel_path: str) -> str | None:
|
|
203
|
+
import subprocess
|
|
204
|
+
result = subprocess.run(
|
|
205
|
+
["git", "show", f"HEAD:{rel_path}"], cwd=project_root, capture_output=True, text=True,
|
|
206
|
+
)
|
|
207
|
+
return result.stdout if result.returncode == 0 else None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _dequote(text: str) -> str:
|
|
211
|
+
return re.sub(r"""['"\\]""", "", text)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def is_syntax_only_yaml_fix(old_text: str | None, new_text: str) -> bool:
|
|
215
|
+
"""True only if new_text is valid YAML AND differs from old_text purely
|
|
216
|
+
by quote/escape characters -- e.g. wrapping an unquoted bullet containing
|
|
217
|
+
a stray colon in quotes so it parses. Deterministic, not an agent's own
|
|
218
|
+
say-so: a real parse-error fix is verifiable by a YAML parser plus a
|
|
219
|
+
de-quoted character comparison, the same way an ast_pattern rule is
|
|
220
|
+
verifiable by a regex rather than trusted on request.
|
|
221
|
+
|
|
222
|
+
Returns False (never a "safe" fix) for: new_text still doesn't parse,
|
|
223
|
+
old_text doesn't exist yet (a brand new protected file -- that's real
|
|
224
|
+
content creation, not a fix), or the de-quoted text differs at all
|
|
225
|
+
(a real, non-syntax change hiding inside what's claimed to be a
|
|
226
|
+
formatting fix). Known limitation: doesn't handle a fix that also needed
|
|
227
|
+
to escape a pre-existing internal quote character -- de-quoting both
|
|
228
|
+
sides can't distinguish "added a quote" from "added an escaped quote" in
|
|
229
|
+
that case. Good enough for the reported case (wrapping a colon-containing
|
|
230
|
+
bullet in quotes); flagged here rather than silently over-trusted."""
|
|
231
|
+
try:
|
|
232
|
+
yaml.safe_load(new_text)
|
|
233
|
+
except yaml.YAMLError:
|
|
234
|
+
return False
|
|
235
|
+
if old_text is None:
|
|
236
|
+
return False
|
|
237
|
+
return _dequote(old_text) == _dequote(new_text)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def run_protected_path_rule(rule: dict, staged_files: list[str], project_root: Path | None = None) -> list[str]:
|
|
241
|
+
"""Violations for a check:protected_path ci_rule. Only enforced inside a
|
|
242
|
+
pcp-build agent session (PCP_AGENT_SESSION=1 in the environment, set by
|
|
243
|
+
build.py before spawning the coding agent) — a human's own interactive
|
|
244
|
+
commit (pcp pm, direct editing) never sets this and is never blocked.
|
|
245
|
+
|
|
246
|
+
Carve-out, added 2026-07-08 after a real recurrence in Project O:
|
|
247
|
+
a protected file that's currently invalid YAML (blocking validate-strategy/
|
|
248
|
+
architect-review project-wide) and an agent's fix touches ONLY quoting/
|
|
249
|
+
escaping is allowed through -- verified by is_syntax_only_yaml_fix(),
|
|
250
|
+
not by trusting the agent's own claim that "it's just a syntax fix"."""
|
|
251
|
+
if os.environ.get("PCP_AGENT_SESSION") != "1":
|
|
252
|
+
return []
|
|
253
|
+
scope = rule.get("scope", [])
|
|
254
|
+
violations = []
|
|
255
|
+
for rel_path in staged_files:
|
|
256
|
+
if not _match_scope(rel_path, scope):
|
|
257
|
+
continue
|
|
258
|
+
if project_root is not None:
|
|
259
|
+
new_path = project_root / rel_path
|
|
260
|
+
if new_path.exists():
|
|
261
|
+
old_text = _git_show_head(project_root, rel_path)
|
|
262
|
+
new_text = new_path.read_text(errors="replace")
|
|
263
|
+
if is_syntax_only_yaml_fix(old_text, new_text):
|
|
264
|
+
continue
|
|
265
|
+
violations.append(
|
|
266
|
+
f"{rel_path}: protected spec file modified by an agent session "
|
|
267
|
+
"(human-approved: use `pcp correct-objective` / `pcp pm` / "
|
|
268
|
+
"`pcp amend` — diff shown, human approves, then written)"
|
|
269
|
+
)
|
|
270
|
+
return violations
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def get_module_names(pcp_dir: Path) -> list[str]:
|
|
274
|
+
modules_dir = get_modules_dir(pcp_dir)
|
|
275
|
+
if not modules_dir.exists():
|
|
276
|
+
return []
|
|
277
|
+
return sorted(p.name for p in modules_dir.iterdir() if p.is_dir() and (p / "spec.yaml").exists())
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def run_file_exists_rule(rule: dict, project_root: Path, module_names: list[str]) -> list[str]:
|
|
281
|
+
"""Violations for a check:file_exists ci_rule. Resolves {module}/{MODULE}
|
|
282
|
+
placeholders per-module if present in the target; otherwise checks the
|
|
283
|
+
literal target once. Project-wide structural check, not diff-scoped."""
|
|
284
|
+
target_template = rule.get("target", "")
|
|
285
|
+
violations = []
|
|
286
|
+
if "{module}" in target_template or "{MODULE}" in target_template:
|
|
287
|
+
for name in module_names:
|
|
288
|
+
target = target_template.replace("{module}", name).replace("{MODULE}", name.upper())
|
|
289
|
+
if not (project_root / target).exists():
|
|
290
|
+
violations.append(f"{target}: required file missing (module '{name}')")
|
|
291
|
+
else:
|
|
292
|
+
if target_template and not (project_root / target_template).exists():
|
|
293
|
+
violations.append(f"{target_template}: required file missing")
|
|
294
|
+
return violations
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _get_staged_files() -> list[str]:
|
|
298
|
+
"""Staged files a gate should actually evaluate.
|
|
299
|
+
|
|
300
|
+
PCP's own generated output is excluded. It is machine-written record *about*
|
|
301
|
+
the code — telemetry, ledgers, scans, evidence — never authored content, and
|
|
302
|
+
evaluating a rule against it is not just noise, it is circular: telemetry
|
|
303
|
+
records the findings of the rules, so a rule's own pattern text ends up
|
|
304
|
+
written into the file the next commit stages and scans.
|
|
305
|
+
|
|
306
|
+
That is not hypothetical. Project O, 2026-07-30, from bypass_log.yaml:
|
|
307
|
+
|
|
308
|
+
reason: R008 matched its own rule text quoted inside generated
|
|
309
|
+
telemetry.jsonl, not a real property_hints persistence
|
|
310
|
+
|
|
311
|
+
And the blast radius was total, because a `[pcp-bypass]` is all-or-nothing
|
|
312
|
+
across rules: that single self-match bypassed **R001 through R010 together**,
|
|
313
|
+
on an unattended run, with nobody reading the output. One false positive from
|
|
314
|
+
a file PCP wrote itself voided the entire Layer 1 gate for that commit.
|
|
315
|
+
|
|
316
|
+
See pcp/operational.py for the path list and why it lives in its own module.
|
|
317
|
+
"""
|
|
318
|
+
import subprocess
|
|
319
|
+
|
|
320
|
+
from pcp.operational import filter_operational
|
|
321
|
+
|
|
322
|
+
result = subprocess.run(
|
|
323
|
+
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
|
324
|
+
capture_output=True,
|
|
325
|
+
text=True,
|
|
326
|
+
)
|
|
327
|
+
if result.returncode != 0:
|
|
328
|
+
return []
|
|
329
|
+
staged = [f.strip() for f in result.stdout.splitlines() if f.strip()]
|
|
330
|
+
keep, skipped = filter_operational(staged)
|
|
331
|
+
if skipped:
|
|
332
|
+
# Say what was skipped. A gate that silently narrows its own scope is
|
|
333
|
+
# indistinguishable from one that found nothing.
|
|
334
|
+
console.print(
|
|
335
|
+
f"[dim]Layer 1: skipping {len(skipped)} PCP-generated file(s) — "
|
|
336
|
+
f"machine-written records, not authored content "
|
|
337
|
+
f"({', '.join(skipped[:4])}{'…' if len(skipped) > 4 else ''}).[/dim]"
|
|
338
|
+
)
|
|
339
|
+
return keep
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@click.command()
|
|
343
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
344
|
+
help="Project root (default: cwd, walks up to find .pcp/).")
|
|
345
|
+
@click.option("--commit-msg-file", type=click.Path(), default=None,
|
|
346
|
+
help="Path to commit message file (set by git hook).")
|
|
347
|
+
@click.option("--files", "file_list", default=None,
|
|
348
|
+
help="Comma-separated file list to check (default: git staged files).")
|
|
349
|
+
@click.option("--baseline", is_flag=True,
|
|
350
|
+
help="Brownfield: scan all files, write baseline_violations.yaml. Does not block.")
|
|
351
|
+
@click.option("--staged-only", is_flag=True,
|
|
352
|
+
help="Brownfield: check staged changes only, exclude baseline_violations.yaml violations.")
|
|
353
|
+
def check(project_path: str | None, commit_msg_file: str | None, file_list: str | None,
|
|
354
|
+
baseline: bool, staged_only: bool):
|
|
355
|
+
"""Layer 1 pre-commit gate — YAML schema + AST pattern rules. No LLM."""
|
|
356
|
+
try:
|
|
357
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
358
|
+
except NoPCPDir as e:
|
|
359
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
360
|
+
sys.exit(2)
|
|
361
|
+
|
|
362
|
+
project_root = pcp_dir.parent
|
|
363
|
+
ci_rules_path = pcp_dir / "ci_rules.yaml"
|
|
364
|
+
|
|
365
|
+
if not ci_rules_path.exists():
|
|
366
|
+
console.print("[dim]No ci_rules.yaml found — skipping check.[/dim]")
|
|
367
|
+
sys.exit(0)
|
|
368
|
+
|
|
369
|
+
# Validate ci_rules.yaml schema first
|
|
370
|
+
schema_errors = validate_file(ci_rules_path, "ci_rules")
|
|
371
|
+
if schema_errors:
|
|
372
|
+
console.print("[red]ci_rules.yaml schema errors:[/red]")
|
|
373
|
+
for e in schema_errors:
|
|
374
|
+
console.print(f" {e}")
|
|
375
|
+
sys.exit(1)
|
|
376
|
+
|
|
377
|
+
data = load_yaml(ci_rules_path)
|
|
378
|
+
rules = [r for r in data.get("rules", []) if r.get("check") == "ast_pattern" and not r.get("require_present")]
|
|
379
|
+
required_rules = [r for r in data.get("rules", []) if r.get("check") == "ast_pattern" and r.get("require_present")]
|
|
380
|
+
file_rules = [r for r in data.get("rules", []) if r.get("check") == "file_exists"]
|
|
381
|
+
protected_rules = [r for r in data.get("rules", []) if r.get("check") == "protected_path"]
|
|
382
|
+
module_names = get_module_names(pcp_dir)
|
|
383
|
+
|
|
384
|
+
if not rules and not required_rules and not file_rules and not protected_rules:
|
|
385
|
+
console.print("[dim]No ast_pattern, file_exists, or protected_path rules in ci_rules.yaml.[/dim]")
|
|
386
|
+
sys.exit(0)
|
|
387
|
+
|
|
388
|
+
# Check for bypass
|
|
389
|
+
msg_file = Path(commit_msg_file) if commit_msg_file else None
|
|
390
|
+
bypass_parsed = _read_bypass_reason(msg_file)
|
|
391
|
+
if bypass_parsed:
|
|
392
|
+
bypass_reason, scoped_rule_ids = bypass_parsed
|
|
393
|
+
from pcp import policy
|
|
394
|
+
decision = policy.evaluate(pcp_dir, "data.pcp.bypass.approved", {"reason": bypass_reason})
|
|
395
|
+
if decision.get("available") and not decision.get("undefined") and decision.get("value") is False:
|
|
396
|
+
console.print(
|
|
397
|
+
f"[red]pcp-bypass rejected:[/red] '{bypass_reason}' reads as a placeholder, "
|
|
398
|
+
"not a real reason (policy: .pcp/policies/bypass_approval.rego)."
|
|
399
|
+
)
|
|
400
|
+
console.print("[dim]Give a specific, verifiable reason — not \"reason\"/\"todo\"/\"test\"/\"fixme\".[/dim]")
|
|
401
|
+
sys.exit(1)
|
|
402
|
+
|
|
403
|
+
all_rules = rules + required_rules + file_rules + protected_rules
|
|
404
|
+
all_ids = {r["id"] for r in all_rules}
|
|
405
|
+
if scoped_rule_ids is None:
|
|
406
|
+
bypassed_ids = [r["id"] for r in all_rules]
|
|
407
|
+
else:
|
|
408
|
+
unknown = [r for r in scoped_rule_ids if r not in all_ids]
|
|
409
|
+
if unknown:
|
|
410
|
+
console.print(
|
|
411
|
+
f"[yellow]pcp-bypass warning:[/yellow] {', '.join(unknown)} not found in "
|
|
412
|
+
f"ci_rules.yaml — nothing to bypass for {'that id' if len(unknown) == 1 else 'those ids'}."
|
|
413
|
+
)
|
|
414
|
+
bypassed_ids = [r for r in scoped_rule_ids if r in all_ids]
|
|
415
|
+
|
|
416
|
+
bypass_files = file_list.split(",") if file_list else _get_staged_files()
|
|
417
|
+
bypass_modules = _attributed_modules(project_root, pcp_dir, bypass_files, module_names)
|
|
418
|
+
_log_bypass(pcp_dir, bypass_reason, bypassed_ids, files=bypass_files, modules=bypass_modules)
|
|
419
|
+
from pcp import telemetry
|
|
420
|
+
telemetry.record(
|
|
421
|
+
pcp_dir, cycle="qa", cycle_number=None, check="layer1-bypass",
|
|
422
|
+
control_id="CTRL-004", module=(bypass_modules[0] if bypass_modules else None),
|
|
423
|
+
submodule=None, criterion_id=None,
|
|
424
|
+
files=bypass_files,
|
|
425
|
+
result="bypassed", errors=[f"reason: {bypass_reason}"] + [f"rule bypassed: {r}" for r in bypassed_ids],
|
|
426
|
+
error_count=len(bypassed_ids),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if scoped_rule_ids is None:
|
|
430
|
+
console.print(f"[yellow]pcp-bypass:[/yellow] {bypass_reason} (logged to bypass_log.yaml)")
|
|
431
|
+
sys.exit(0)
|
|
432
|
+
|
|
433
|
+
# Scoped: drop only the named rule(s) and keep going -- everything else
|
|
434
|
+
# in this commit is still checked normally. This is the whole point of
|
|
435
|
+
# scoping; exiting here would silently re-create the all-or-nothing bug.
|
|
436
|
+
console.print(
|
|
437
|
+
f"[yellow]pcp-bypass ({', '.join(bypassed_ids)}):[/yellow] {bypass_reason} "
|
|
438
|
+
"(logged to bypass_log.yaml — other rules still run)"
|
|
439
|
+
)
|
|
440
|
+
rules = [r for r in rules if r["id"] not in bypassed_ids]
|
|
441
|
+
required_rules = [r for r in required_rules if r["id"] not in bypassed_ids]
|
|
442
|
+
file_rules = [r for r in file_rules if r["id"] not in bypassed_ids]
|
|
443
|
+
protected_rules = [r for r in protected_rules if r["id"] not in bypassed_ids]
|
|
444
|
+
|
|
445
|
+
# Get files to check
|
|
446
|
+
if file_list:
|
|
447
|
+
staged = [f.strip() for f in file_list.split(",") if f.strip()]
|
|
448
|
+
else:
|
|
449
|
+
staged = _get_staged_files()
|
|
450
|
+
|
|
451
|
+
# ── Baseline mode: scan everything, write baseline_violations.yaml ────────
|
|
452
|
+
if baseline:
|
|
453
|
+
import subprocess
|
|
454
|
+
all_files_result = subprocess.run(
|
|
455
|
+
["git", "ls-files"],
|
|
456
|
+
capture_output=True, text=True, cwd=project_root
|
|
457
|
+
)
|
|
458
|
+
all_files = [f.strip() for f in all_files_result.stdout.splitlines() if f.strip()]
|
|
459
|
+
all_violations = []
|
|
460
|
+
for rule in rules:
|
|
461
|
+
violations = _run_ast_rule(rule, all_files, project_root)
|
|
462
|
+
for v in violations:
|
|
463
|
+
all_violations.append({"rule_id": rule["id"], "file": v.split(":")[0], "detail": v})
|
|
464
|
+
for rule in file_rules:
|
|
465
|
+
violations = run_file_exists_rule(rule, project_root, module_names)
|
|
466
|
+
for v in violations:
|
|
467
|
+
all_violations.append({"rule_id": rule["id"], "file": v.split(":")[0], "detail": v})
|
|
468
|
+
for rule in required_rules:
|
|
469
|
+
violations = _run_ast_required_rule(rule, project_root)
|
|
470
|
+
for v in violations:
|
|
471
|
+
all_violations.append({"rule_id": rule["id"], "file": None, "detail": v})
|
|
472
|
+
|
|
473
|
+
from datetime import datetime, timezone
|
|
474
|
+
baseline_path = pcp_dir / "baseline_violations.yaml"
|
|
475
|
+
baseline_data = {
|
|
476
|
+
"violations": all_violations,
|
|
477
|
+
"total": len(all_violations),
|
|
478
|
+
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
479
|
+
}
|
|
480
|
+
baseline_path.write_text(yaml.dump(baseline_data, default_flow_style=False))
|
|
481
|
+
console.print(f"[dim]Baseline scan: {len(all_violations)} pre-existing violations → "
|
|
482
|
+
f"baseline_violations.yaml[/dim]")
|
|
483
|
+
console.print("[dim]These violations are excluded from hard gates (brownfield grace mode).[/dim]")
|
|
484
|
+
sys.exit(0)
|
|
485
|
+
|
|
486
|
+
# ── Staged-only mode: exclude violations already in baseline ─────────────
|
|
487
|
+
baseline_keys: set[str] = set()
|
|
488
|
+
if staged_only:
|
|
489
|
+
baseline_path = pcp_dir / "baseline_violations.yaml"
|
|
490
|
+
if baseline_path.exists():
|
|
491
|
+
bd = yaml.safe_load(baseline_path.read_text()) or {}
|
|
492
|
+
for v in bd.get("violations", []):
|
|
493
|
+
baseline_keys.add(v.get("detail", ""))
|
|
494
|
+
|
|
495
|
+
if not staged and not file_rules and not required_rules:
|
|
496
|
+
sys.exit(0)
|
|
497
|
+
|
|
498
|
+
hard_violations = []
|
|
499
|
+
advisory_violations = []
|
|
500
|
+
|
|
501
|
+
for rule in rules:
|
|
502
|
+
violations = _run_ast_rule(rule, staged, project_root)
|
|
503
|
+
if staged_only and baseline_keys:
|
|
504
|
+
violations = [v for v in violations if v not in baseline_keys]
|
|
505
|
+
if not violations:
|
|
506
|
+
continue
|
|
507
|
+
severity = rule.get("severity", "advisory")
|
|
508
|
+
entry = {"rule": rule, "violations": violations}
|
|
509
|
+
if severity == "hard_block":
|
|
510
|
+
hard_violations.append(entry)
|
|
511
|
+
else:
|
|
512
|
+
advisory_violations.append(entry)
|
|
513
|
+
|
|
514
|
+
# protected_path rules are diff-scoped, like ast_pattern — only fire inside
|
|
515
|
+
# a pcp-build agent session (see run_protected_path_rule's env-var check).
|
|
516
|
+
for rule in protected_rules:
|
|
517
|
+
violations = run_protected_path_rule(rule, staged, project_root)
|
|
518
|
+
if staged_only and baseline_keys:
|
|
519
|
+
violations = [v for v in violations if v not in baseline_keys]
|
|
520
|
+
if not violations:
|
|
521
|
+
continue
|
|
522
|
+
severity = rule.get("severity", "advisory")
|
|
523
|
+
entry = {"rule": rule, "violations": violations}
|
|
524
|
+
if severity == "hard_block":
|
|
525
|
+
hard_violations.append(entry)
|
|
526
|
+
else:
|
|
527
|
+
advisory_violations.append(entry)
|
|
528
|
+
|
|
529
|
+
# file_exists rules are structural (project-wide), not diff-scoped — always evaluated.
|
|
530
|
+
for rule in file_rules:
|
|
531
|
+
violations = run_file_exists_rule(rule, project_root, module_names)
|
|
532
|
+
if staged_only and baseline_keys:
|
|
533
|
+
violations = [v for v in violations if v not in baseline_keys]
|
|
534
|
+
if not violations:
|
|
535
|
+
continue
|
|
536
|
+
severity = rule.get("severity", "advisory")
|
|
537
|
+
entry = {"rule": rule, "violations": violations}
|
|
538
|
+
if severity == "hard_block":
|
|
539
|
+
hard_violations.append(entry)
|
|
540
|
+
else:
|
|
541
|
+
advisory_violations.append(entry)
|
|
542
|
+
|
|
543
|
+
# require_present ast_pattern rules are structural (project-wide), like file_exists.
|
|
544
|
+
for rule in required_rules:
|
|
545
|
+
violations = _run_ast_required_rule(rule, project_root)
|
|
546
|
+
if staged_only and baseline_keys:
|
|
547
|
+
violations = [v for v in violations if v not in baseline_keys]
|
|
548
|
+
if not violations:
|
|
549
|
+
continue
|
|
550
|
+
severity = rule.get("severity", "advisory")
|
|
551
|
+
entry = {"rule": rule, "violations": violations}
|
|
552
|
+
if severity == "hard_block":
|
|
553
|
+
hard_violations.append(entry)
|
|
554
|
+
else:
|
|
555
|
+
advisory_violations.append(entry)
|
|
556
|
+
|
|
557
|
+
if advisory_violations:
|
|
558
|
+
console.print("[yellow]Advisory violations:[/yellow]")
|
|
559
|
+
for entry in advisory_violations:
|
|
560
|
+
r = entry["rule"]
|
|
561
|
+
console.print(f" [{r['id']}] {r['name']}")
|
|
562
|
+
for v in entry["violations"][:3]:
|
|
563
|
+
console.print(f" {v}")
|
|
564
|
+
if r.get("message"):
|
|
565
|
+
console.print(f" [dim]Fix: {r['message']}[/dim]")
|
|
566
|
+
|
|
567
|
+
if hard_violations:
|
|
568
|
+
console.print("[red bold]BLOCKED — hard rule violations:[/red bold]")
|
|
569
|
+
for entry in hard_violations:
|
|
570
|
+
r = entry["rule"]
|
|
571
|
+
console.print(f" [{r['id']}] {r['name']}")
|
|
572
|
+
for v in entry["violations"][:3]:
|
|
573
|
+
console.print(f" {v}")
|
|
574
|
+
if r.get("message"):
|
|
575
|
+
console.print(f" [dim]Fix: {r['message']}[/dim]")
|
|
576
|
+
console.print(
|
|
577
|
+
"\n[dim]To bypass: add [pcp-bypass: reason] to your commit message.[/dim]"
|
|
578
|
+
)
|
|
579
|
+
sys.exit(1)
|
|
580
|
+
|
|
581
|
+
if not advisory_violations:
|
|
582
|
+
console.print("[green]✓ All rules passed.[/green]")
|
|
583
|
+
|
|
584
|
+
sys.exit(0)
|