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.
Files changed (109) hide show
  1. pcp/__init__.py +3 -0
  2. pcp/assertions.py +152 -0
  3. pcp/attest.py +111 -0
  4. pcp/build_loop_bypass.py +76 -0
  5. pcp/build_report.py +54 -0
  6. pcp/capture.py +339 -0
  7. pcp/cli.py +104 -0
  8. pcp/commands/__init__.py +0 -0
  9. pcp/commands/amend.py +283 -0
  10. pcp/commands/architect_review.py +291 -0
  11. pcp/commands/architecture_justification.py +164 -0
  12. pcp/commands/audit.py +371 -0
  13. pcp/commands/build.py +4523 -0
  14. pcp/commands/build_plan.py +153 -0
  15. pcp/commands/build_status.py +83 -0
  16. pcp/commands/capture.py +72 -0
  17. pcp/commands/check.py +584 -0
  18. pcp/commands/context.py +151 -0
  19. pcp/commands/control_audit_cmd.py +54 -0
  20. pcp/commands/correct_objective.py +160 -0
  21. pcp/commands/dashboard.py +732 -0
  22. pcp/commands/deploy.py +199 -0
  23. pcp/commands/deploy_check.py +134 -0
  24. pcp/commands/design_audit.py +323 -0
  25. pcp/commands/diff.py +153 -0
  26. pcp/commands/diff_reduce.py +355 -0
  27. pcp/commands/docs.py +538 -0
  28. pcp/commands/doctor.py +820 -0
  29. pcp/commands/escalations_cmd.py +64 -0
  30. pcp/commands/gate.py +209 -0
  31. pcp/commands/import_project.py +404 -0
  32. pcp/commands/init.py +1634 -0
  33. pcp/commands/install_hook.py +283 -0
  34. pcp/commands/install_skill.py +48 -0
  35. pcp/commands/kickoff.py +772 -0
  36. pcp/commands/narrative_lint.py +54 -0
  37. pcp/commands/objective_conflicts_cmd.py +68 -0
  38. pcp/commands/pm.py +504 -0
  39. pcp/commands/pressure_test_cmd.py +72 -0
  40. pcp/commands/provenance.py +313 -0
  41. pcp/commands/prune.py +179 -0
  42. pcp/commands/report.py +49 -0
  43. pcp/commands/run_log_cmd.py +122 -0
  44. pcp/commands/scan.py +346 -0
  45. pcp/commands/self_update.py +125 -0
  46. pcp/commands/status.py +180 -0
  47. pcp/commands/takeover.py +55 -0
  48. pcp/commands/telemetry_cmd.py +167 -0
  49. pcp/commands/validate_module.py +153 -0
  50. pcp/commands/validate_strategy.py +413 -0
  51. pcp/commands/verify.py +166 -0
  52. pcp/commands/verify_syntax_fix.py +74 -0
  53. pcp/commands/watch.py +372 -0
  54. pcp/config_audit.py +141 -0
  55. pcp/context_map.py +124 -0
  56. pcp/control_audit.py +159 -0
  57. pcp/coupling.py +178 -0
  58. pcp/coverage_audit.py +77 -0
  59. pcp/decision_log.py +134 -0
  60. pcp/discovery/__init__.py +0 -0
  61. pcp/discovery/clusters.py +124 -0
  62. pcp/discovery/graph.py +110 -0
  63. pcp/discovery/scanner.py +109 -0
  64. pcp/escalations.py +193 -0
  65. pcp/evidence.py +30 -0
  66. pcp/evidence_chain.py +56 -0
  67. pcp/impact.py +164 -0
  68. pcp/install_approvals.py +44 -0
  69. pcp/integrity_audit.py +176 -0
  70. pcp/librarian.py +89 -0
  71. pcp/llm/__init__.py +0 -0
  72. pcp/llm/client.py +183 -0
  73. pcp/llm/coding_agent_contract.py +104 -0
  74. pcp/llm/harness/__init__.py +12 -0
  75. pcp/llm/harness/agy.py +121 -0
  76. pcp/llm/harness/agy_coding_loop.py +180 -0
  77. pcp/llm/harness/claude.py +241 -0
  78. pcp/llm/ledger.py +47 -0
  79. pcp/narrative_lint.py +229 -0
  80. pcp/nav_graph.py +226 -0
  81. pcp/objective_conflicts.py +129 -0
  82. pcp/operational.py +70 -0
  83. pcp/orphaned_work.py +262 -0
  84. pcp/pcp_dir.py +35 -0
  85. pcp/pcp_status.py +313 -0
  86. pcp/policy.py +81 -0
  87. pcp/pressure_test.py +196 -0
  88. pcp/qa.py +445 -0
  89. pcp/run_log.py +225 -0
  90. pcp/schema/__init__.py +0 -0
  91. pcp/schema/ci_rules.schema.json +106 -0
  92. pcp/schema/controls.schema.json +39 -0
  93. pcp/schema/module_acceptance.schema.json +144 -0
  94. pcp/schema/module_spec.schema.json +78 -0
  95. pcp/schema/sdlc_phase.schema.json +52 -0
  96. pcp/schema/validator.py +77 -0
  97. pcp/skill_data/pcp/SKILL.md +1897 -0
  98. pcp/spec_write.py +269 -0
  99. pcp/spend.py +77 -0
  100. pcp/symbols.py +86 -0
  101. pcp/telemetry.py +308 -0
  102. pcp/uat.py +271 -0
  103. pcp/version_drift.py +222 -0
  104. program_context_protocol-0.12.4.dist-info/METADATA +123 -0
  105. program_context_protocol-0.12.4.dist-info/RECORD +109 -0
  106. program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
  107. program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
  108. program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
  109. 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)