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
@@ -0,0 +1,164 @@
1
+ """pcp architecture-justification — auto-generated rollup of every module's
2
+ logic_tier/build_vs_buy decisions (see CLAUDE.md's Logic-Tier Selection
3
+ philosophy). Same posture as provenance.md: pure aggregation over what's
4
+ already on disk (spec.yaml + acceptance.yaml), no LLM call, never
5
+ hand-edited -- the rationale lives once, in the structured field; this is a
6
+ view over it, not a second place to author rationale."""
7
+
8
+ import json
9
+ import sys
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ import click
14
+ import yaml
15
+ from rich.console import Console
16
+
17
+ from pcp.pcp_dir import find_pcp_dir, get_modules_dir, NoPCPDir
18
+
19
+ console = Console()
20
+
21
+ TIER_LABELS = {
22
+ 1: "Deterministic", 2: "Solver", 3: "ML/statistical",
23
+ 4: "RAG", 5: "Cached reuse", 6: "Deep-think LLM",
24
+ }
25
+
26
+
27
+ def _load_yaml(path: Path) -> dict:
28
+ if not path.exists():
29
+ return {}
30
+ return yaml.safe_load(path.read_text()) or {}
31
+
32
+
33
+ def build_architecture_justification(pcp_dir: Path) -> dict:
34
+ """Pure aggregation, no LLM. Returns per-module spec-level build_vs_buy
35
+ plus per-criterion logic_tier/build_vs_buy, and a coercion-flag count so
36
+ a placeholder decision (never actually deliberated) is visible, not
37
+ indistinguishable from a real one."""
38
+ modules_dir = get_modules_dir(pcp_dir)
39
+ modules = []
40
+ flagged_count = 0
41
+ tier_counts = {t: 0 for t in TIER_LABELS}
42
+
43
+ if modules_dir.exists():
44
+ for mod_path in sorted(p for p in modules_dir.iterdir() if p.is_dir()):
45
+ spec = _load_yaml(mod_path / "spec.yaml")
46
+ acceptance = _load_yaml(mod_path / "acceptance.yaml")
47
+ if not spec and not acceptance:
48
+ continue
49
+
50
+ module_bvb = spec.get("build_vs_buy")
51
+ if module_bvb and "not specified by generator" in module_bvb.get("rationale", "").lower():
52
+ flagged_count += 1
53
+
54
+ criteria = []
55
+ for c in acceptance.get("criteria", []):
56
+ tier = c.get("logic_tier")
57
+ if tier in tier_counts:
58
+ tier_counts[tier] += 1
59
+ bvb = c.get("build_vs_buy") or {}
60
+ flagged = "not specified by generator" in bvb.get("rationale", "").lower()
61
+ if flagged:
62
+ flagged_count += 1
63
+ criteria.append({
64
+ "id": c.get("id"),
65
+ "description": c.get("description"),
66
+ "logic_tier": tier,
67
+ "build_vs_buy": bvb,
68
+ "flagged": flagged,
69
+ })
70
+
71
+ modules.append({
72
+ "module": mod_path.name,
73
+ "module_build_vs_buy": module_bvb,
74
+ "criteria": criteria,
75
+ })
76
+
77
+ return {
78
+ "modules": modules,
79
+ "tier_counts": tier_counts,
80
+ "flagged_count": flagged_count,
81
+ }
82
+
83
+
84
+ def _render_markdown(data: dict, timestamp: str) -> str:
85
+ lines = [
86
+ "# Architecture Justification",
87
+ "",
88
+ f"_Auto-generated by `pcp architecture-justification` at {timestamp}. "
89
+ "Never hand-edit — the rationale lives in each module's spec.yaml/acceptance.yaml; "
90
+ "this is a rollup view, not a second place to author it._",
91
+ "",
92
+ "## Logic-Tier Distribution",
93
+ "",
94
+ "| Tier | Label | Criteria |",
95
+ "|---|---|---|",
96
+ ]
97
+ for tier, label in TIER_LABELS.items():
98
+ lines.append(f"| {tier} | {label} | {data['tier_counts'].get(tier, 0)} |")
99
+ lines.append("")
100
+
101
+ if data["flagged_count"]:
102
+ lines.append(
103
+ f"**{data['flagged_count']} decision(s) are coerced placeholders** "
104
+ "(missing/malformed at generation time, not a real deliberation) — flagged below, review before trusting."
105
+ )
106
+ lines.append("")
107
+
108
+ if not data["modules"]:
109
+ lines.append("_No modules found — nothing to report yet._")
110
+ return "\n".join(lines)
111
+
112
+ for m in data["modules"]:
113
+ lines.append(f"## Module: `{m['module']}`")
114
+ lines.append("")
115
+ mbvb = m.get("module_build_vs_buy")
116
+ if mbvb:
117
+ lines.append(f"**Module-level build-vs-buy:** `{mbvb.get('decision')}` — {mbvb.get('rationale', '')}")
118
+ candidates = mbvb.get("candidates_considered") or []
119
+ if candidates:
120
+ lines.append(f"Candidates considered: {', '.join(candidates)}")
121
+ lines.append("")
122
+
123
+ if m["criteria"]:
124
+ lines.append("| Criterion | Tier | Build-vs-buy | Rationale |")
125
+ lines.append("|---|---|---|---|")
126
+ for c in m["criteria"]:
127
+ tier_label = TIER_LABELS.get(c["logic_tier"], "?")
128
+ bvb = c["build_vs_buy"] or {}
129
+ flag = " ⚠" if c["flagged"] else ""
130
+ lines.append(
131
+ f"| {c['id']}: {c['description']} | {c['logic_tier']} ({tier_label}){flag} "
132
+ f"| {bvb.get('decision', '?')} | {bvb.get('rationale', '')} |"
133
+ )
134
+ lines.append("")
135
+
136
+ return "\n".join(lines)
137
+
138
+
139
+ def write_architecture_justification(pcp_dir: Path) -> Path:
140
+ data = build_architecture_justification(pcp_dir)
141
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
142
+ md = _render_markdown(data, timestamp)
143
+ out = pcp_dir / "architecture_justification.md"
144
+ out.write_text(md)
145
+ return out
146
+
147
+
148
+ @click.command(name="architecture-justification")
149
+ @click.option("--path", "project_path", type=click.Path(), default=None)
150
+ @click.option("--json", "output_json", is_flag=True, help="Print raw JSON instead of writing architecture_justification.md.")
151
+ def architecture_justification(project_path: str | None, output_json: bool):
152
+ """Roll up every module's logic_tier/build_vs_buy decisions into architecture_justification.md."""
153
+ try:
154
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
155
+ except NoPCPDir as e:
156
+ console.print(f"[red]Error:[/red] {e}")
157
+ sys.exit(2)
158
+
159
+ if output_json:
160
+ click.echo(json.dumps(build_architecture_justification(pcp_dir), indent=2, default=str))
161
+ return
162
+
163
+ out_path = write_architecture_justification(pcp_dir)
164
+ console.print(f"[green]Architecture justification written[/green] -> {out_path.relative_to(pcp_dir.parent)}")
pcp/commands/audit.py ADDED
@@ -0,0 +1,371 @@
1
+ """pcp audit — advisory dead-code / bloat scan (unused exports, unreferenced funcs).
2
+
3
+ Never hard-blocks. Wraps whatever dead-code tool is already installed for the
4
+ target language (vulture for Python, knip for JS/TS). Writes .pcp/audit.md so
5
+ drift in code bloat is visible the same way drift in spec coverage is.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ import click
17
+ from rich.console import Console
18
+
19
+ from pcp.pcp_dir import find_pcp_dir, NoPCPDir
20
+
21
+ console = Console()
22
+
23
+ MAX_FINDINGS_SHOWN = 30
24
+ DEFAULT_COVERAGE_ADVISORY_THRESHOLD = 50
25
+
26
+ # ast-grep pattern for a swallowed exception: any except clause (bare, typed,
27
+ # or `as e`) whose entire body is just `pass` -- real error-handling that was
28
+ # never designed in, not paperwork (same class as the lazy-marker scan's stub
29
+ # bodies, deliberately distinct from it -- CTRL-029 catches TODO/placeholder
30
+ # text and `def f(): pass` stubs; this catches a *handled-looking* exception
31
+ # that actually discards the error). `except Exception: logger.exception(...)`
32
+ # and similar do NOT match -- only a body that is exactly `pass`.
33
+ _AST_GREP_SWALLOWED_EXCEPTION_PATTERN = "try:\n $$$BODY\nexcept $$$EXC:\n pass"
34
+
35
+
36
+ def _run_ast_grep_swallowed_exceptions(project_root: Path) -> dict | None:
37
+ """Advisory-only, deliberately kept OUT of `pcp build`'s per-criterion
38
+ hot loop -- this project's own build.py already documents removing four
39
+ checks from that loop after cost/signal-quality problems (see the
40
+ gate_calls comment there). `pcp audit` runs once per module, not once per
41
+ attempt, which is the same tradeoff CTRL-029's lazy-marker scan and the
42
+ dead-code scan already make."""
43
+ if not shutil.which("ast-grep"):
44
+ return None
45
+ result = subprocess.run(
46
+ ["ast-grep", "run", "--pattern", _AST_GREP_SWALLOWED_EXCEPTION_PATTERN,
47
+ "--lang", "python", "--json"],
48
+ capture_output=True, text=True, cwd=project_root,
49
+ )
50
+ if result.returncode not in (0, 1): # grep-style: 0 = matches found, 1 = ran clean with no matches
51
+ return {"tool": "ast-grep", "findings": []}
52
+ try:
53
+ matches = json.loads(result.stdout) if result.stdout.strip() else []
54
+ except json.JSONDecodeError:
55
+ return {"tool": "ast-grep", "findings": []}
56
+ findings = []
57
+ for m in matches:
58
+ line = m.get("range", {}).get("start", {}).get("line", 0) + 1 # 0-indexed
59
+ findings.append(f"{m.get('file', '?')}:{line}: except clause swallows the error (body is just `pass`)")
60
+ return {"tool": "ast-grep", "findings": findings}
61
+
62
+
63
+ def _run_jscpd(project_root: Path) -> dict | None:
64
+ """Duplication %, advisory-only, same posture as the dead-code scan.
65
+ jscpd writes a JSON report to a temp dir rather than stdout, so this
66
+ scopes its own output location and cleans up after itself."""
67
+ if not shutil.which("jscpd"):
68
+ return None
69
+ import tempfile
70
+ with tempfile.TemporaryDirectory() as report_dir:
71
+ subprocess.run(
72
+ ["jscpd", ".", "--reporters", "json", "--output", report_dir],
73
+ capture_output=True, text=True, cwd=project_root,
74
+ )
75
+ report_path = Path(report_dir) / "jscpd-report.json"
76
+ if not report_path.exists():
77
+ return {"tool": "jscpd", "duplication_pct": None, "findings": []}
78
+ try:
79
+ data = json.loads(report_path.read_text())
80
+ except (json.JSONDecodeError, OSError):
81
+ return {"tool": "jscpd", "duplication_pct": None, "findings": []}
82
+ stats = data.get("statistics", {}).get("total", {})
83
+ pct = stats.get("percentage")
84
+ findings = []
85
+ for dup in data.get("duplicates", [])[:MAX_FINDINGS_SHOWN]:
86
+ first = dup.get("firstFile", {})
87
+ second = dup.get("secondFile", {})
88
+ findings.append(
89
+ f"{first.get('name', '?')}:{first.get('startLoc', {}).get('line', '?')} "
90
+ f"~ {second.get('name', '?')}:{second.get('startLoc', {}).get('line', '?')} "
91
+ f"({dup.get('lines', '?')} duplicated lines)"
92
+ )
93
+ return {"tool": "jscpd", "duplication_pct": pct, "findings": findings}
94
+
95
+
96
+ def _detect_python_src(project_root: Path) -> Path | None:
97
+ for candidate in ("src", "."):
98
+ p = project_root / candidate
99
+ if candidate == "src" and p.is_dir():
100
+ return p
101
+ if (project_root / "pyproject.toml").exists() or (project_root / "setup.py").exists():
102
+ return project_root
103
+ return None
104
+
105
+
106
+ def _run_vulture(project_root: Path) -> dict | None:
107
+ if not shutil.which("vulture"):
108
+ return None
109
+ target = _detect_python_src(project_root)
110
+ if target is None:
111
+ return None
112
+ result = subprocess.run(
113
+ ["vulture", str(target), "--min-confidence", "80"],
114
+ capture_output=True, text=True, cwd=project_root,
115
+ )
116
+ findings = [l.strip() for l in result.stdout.splitlines() if l.strip()]
117
+ return {"tool": "vulture", "findings": findings}
118
+
119
+
120
+ def _run_knip(project_root: Path) -> dict | None:
121
+ knip_bin = project_root / "node_modules" / ".bin" / "knip"
122
+ if not (project_root / "package.json").exists() or not knip_bin.exists():
123
+ return None
124
+ result = subprocess.run(
125
+ [str(knip_bin), "--reporter", "compact"],
126
+ capture_output=True, text=True, cwd=project_root,
127
+ )
128
+ findings = [l.strip() for l in result.stdout.splitlines() if l.strip()]
129
+ return {"tool": "knip", "findings": findings}
130
+
131
+
132
+ def _run_coverage_check(project_root: Path) -> dict:
133
+ """Reuses `qa.run_coverage` (already exists, powers the opt-in
134
+ `pcp scan --coverage`) -- coverage % was tracked but never gated
135
+ anywhere (2026-08-02 gap analysis). This is the advisory-first flip:
136
+ run it once per module here (same cadence as the rest of `pcp audit`,
137
+ NOT the per-attempt hot loop -- a full coverage run means running the
138
+ whole test suite instrumented, and the test-suite gate is already 76%
139
+ of this project's own measured build cost), and warn when it's below
140
+ PCP_COVERAGE_ADVISORY_THRESHOLD (default 50). Advisory only -- this is
141
+ the track-record-building step before anything here could ever become
142
+ a hard gate, per PCP's own new-checks-are-advisory-first doctrine."""
143
+ from pcp import qa
144
+ result = qa.run_coverage(project_root)
145
+ threshold = int(os.environ.get("PCP_COVERAGE_ADVISORY_THRESHOLD", str(DEFAULT_COVERAGE_ADVISORY_THRESHOLD)))
146
+ result["threshold"] = threshold
147
+ result["below_threshold"] = (
148
+ result.get("percent") is not None and result["percent"] < threshold
149
+ )
150
+ return result
151
+
152
+
153
+ def _run_audit(project_root: Path) -> dict:
154
+ for runner in (_run_vulture, _run_knip):
155
+ out = runner(project_root)
156
+ if out is not None:
157
+ return out
158
+ return {"tool": None, "findings": []}
159
+
160
+
161
+ def _source_metrics(project_root: Path) -> dict:
162
+ """Cheap erosion proxies: total source lines + file count for the primary
163
+ source tree. Deterministic, tool-free."""
164
+ target = _detect_python_src(project_root) or project_root
165
+ total_lines = files = 0
166
+ for p in target.rglob("*.py"):
167
+ if any(seg in p.parts for seg in ("__pycache__", ".venv", "venv", "node_modules", ".pcp")):
168
+ continue
169
+ try:
170
+ total_lines += sum(1 for _ in open(p, errors="replace"))
171
+ files += 1
172
+ except OSError:
173
+ continue
174
+ return {"source_lines": total_lines, "source_files": files}
175
+
176
+
177
+ def _append_trend(
178
+ pcp_dir: Path, timestamp: str, result: dict, metrics: dict,
179
+ ast_grep_result: dict | None = None, jscpd_result: dict | None = None,
180
+ coverage_result: dict | None = None,
181
+ ) -> list[dict]:
182
+ """Erosion TREND, not snapshot (SlopCodeBench, arXiv:2603.24755: structural
183
+ erosion is the default trajectory of iterative agentic coding — 77% of
184
+ trajectories — so a single audit run can look fine while the slope is
185
+ bad). Plain JSONL, operational record.
186
+
187
+ ast_grep_result/jscpd_result are new fields (2026-08-02) — old rows simply
188
+ lack them, which every consumer already handles via .get()."""
189
+ import json
190
+ path = pcp_dir / "audit_trend.jsonl"
191
+ entry = {
192
+ "timestamp": timestamp, "tool": result["tool"],
193
+ "findings": len(result["findings"]), **metrics,
194
+ }
195
+ if ast_grep_result is not None:
196
+ entry["swallowed_exceptions"] = len(ast_grep_result["findings"])
197
+ if jscpd_result is not None:
198
+ entry["duplication_pct"] = jscpd_result.get("duplication_pct")
199
+ if coverage_result is not None:
200
+ entry["coverage_percent"] = coverage_result.get("percent")
201
+ with open(path, "a") as f:
202
+ f.write(json.dumps(entry) + "\n")
203
+ rows = []
204
+ for line in path.read_text().splitlines():
205
+ try:
206
+ rows.append(json.loads(line))
207
+ except json.JSONDecodeError:
208
+ continue
209
+ return rows
210
+
211
+
212
+ def _write_audit_md(
213
+ pcp_dir: Path, result: dict, timestamp: str, trend: list[dict] | None = None,
214
+ ast_grep_result: dict | None = None, jscpd_result: dict | None = None,
215
+ coverage_result: dict | None = None,
216
+ ) -> Path:
217
+ tool = result["tool"]
218
+ findings = result["findings"]
219
+ lines = [
220
+ "# Dead Code / Bloat Audit",
221
+ f"Generated: {timestamp}",
222
+ "",
223
+ ]
224
+ if tool is None:
225
+ lines += [
226
+ "_No audit tool detected._",
227
+ "",
228
+ "Install one to enable this check:",
229
+ "- Python: `pip install vulture`",
230
+ "- JS/TS: `npm install -D knip`",
231
+ ]
232
+ else:
233
+ lines += [
234
+ f"Tool: `{tool}`",
235
+ f"Findings: {len(findings)}",
236
+ "",
237
+ ]
238
+ if findings:
239
+ lines.append("## Findings")
240
+ lines.append("")
241
+ for f in findings[:MAX_FINDINGS_SHOWN]:
242
+ lines.append(f"- {f}")
243
+ if len(findings) > MAX_FINDINGS_SHOWN:
244
+ lines.append(f"- _...and {len(findings) - MAX_FINDINGS_SHOWN} more (see full tool output)_")
245
+ else:
246
+ lines.append("_No findings._")
247
+
248
+ if ast_grep_result is None:
249
+ lines += ["", "## Swallowed Exceptions (ast-grep)", "",
250
+ "_ast-grep not detected — `npm install -g @ast-grep/cli` to enable._"]
251
+ else:
252
+ ag_findings = ast_grep_result["findings"]
253
+ lines += ["", "## Swallowed Exceptions (ast-grep)", "",
254
+ f"Findings: {len(ag_findings)}", ""]
255
+ if ag_findings:
256
+ for f in ag_findings[:MAX_FINDINGS_SHOWN]:
257
+ lines.append(f"- {f}")
258
+ if len(ag_findings) > MAX_FINDINGS_SHOWN:
259
+ lines.append(f"- _...and {len(ag_findings) - MAX_FINDINGS_SHOWN} more_")
260
+ else:
261
+ lines.append("_No findings._")
262
+
263
+ if jscpd_result is None:
264
+ lines += ["", "## Duplication (jscpd)", "",
265
+ "_jscpd not detected — `npm install -g jscpd` to enable._"]
266
+ else:
267
+ pct = jscpd_result.get("duplication_pct")
268
+ jc_findings = jscpd_result["findings"]
269
+ lines += ["", "## Duplication (jscpd)", "",
270
+ f"Duplicated: {pct:.2f}%" if pct is not None else "Duplicated: unknown", ""]
271
+ if jc_findings:
272
+ for f in jc_findings[:MAX_FINDINGS_SHOWN]:
273
+ lines.append(f"- {f}")
274
+ if len(jc_findings) > MAX_FINDINGS_SHOWN:
275
+ lines.append(f"- _...and {len(jc_findings) - MAX_FINDINGS_SHOWN} more_")
276
+ else:
277
+ lines.append("_No duplicated blocks found._")
278
+
279
+ if coverage_result is None:
280
+ pass # not requested this run -- distinct from "tool not found"
281
+ elif coverage_result.get("tool") is None:
282
+ lines += ["", "## Test Coverage", "",
283
+ "_No coverage tool detected (coverage.py / npm coverage script) — skipped._"]
284
+ else:
285
+ pct = coverage_result.get("percent")
286
+ threshold = coverage_result.get("threshold")
287
+ lines += ["", "## Test Coverage", "",
288
+ f"{pct:.0f}% ({coverage_result['tool']})" if pct is not None else "unknown"]
289
+ if coverage_result.get("below_threshold"):
290
+ lines.append(f"\n⚠ Below advisory threshold ({threshold}%). Tracked, not blocking.")
291
+
292
+ if trend and len(trend) >= 2:
293
+ lines += ["", "## Erosion Trend (last runs)", "",
294
+ "| When | Findings | Source lines | Files |", "|---|---|---|---|"]
295
+ for row in trend[-8:]:
296
+ lines.append(f"| {row.get('timestamp', '')} | {row.get('findings', '')} | "
297
+ f"{row.get('source_lines', '')} | {row.get('source_files', '')} |")
298
+ first, last = trend[0], trend[-1]
299
+ if last.get("source_lines") and first.get("source_lines"):
300
+ growth = last["source_lines"] / max(first["source_lines"], 1)
301
+ lines += ["", f"Source growth since first audit: {growth:.2f}x lines; "
302
+ f"dead-code findings {first.get('findings', 0)} → {last.get('findings', 0)}. "
303
+ "Rising findings alongside faster-than-feature line growth is the erosion signature "
304
+ "(SlopCodeBench: default trajectory of iterative agentic coding, not an edge case)."]
305
+ lines.append("")
306
+ out = pcp_dir / "audit.md"
307
+ out.write_text("\n".join(lines))
308
+ return out
309
+
310
+
311
+ @click.command()
312
+ @click.option("--path", "project_path", type=click.Path(), default=None,
313
+ help="Project root (default: cwd, walks up to find .pcp/).")
314
+ @click.option("--quiet", is_flag=True, help="Suppress output.")
315
+ @click.option("--coverage", "with_coverage", is_flag=True,
316
+ help="Also run the test suite under coverage and warn if below "
317
+ "PCP_COVERAGE_ADVISORY_THRESHOLD (default 50). Opt-in: runs the "
318
+ "full suite instrumented, real cost -- same posture as `pcp scan --coverage`.")
319
+ def audit(project_path: str | None, quiet: bool, with_coverage: bool):
320
+ """Advisory dead-code / bloat scan. Writes .pcp/audit.md. Never blocks."""
321
+ try:
322
+ pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
323
+ except NoPCPDir as e:
324
+ console.print(f"[red]Error:[/red] {e}")
325
+ sys.exit(2)
326
+
327
+ project_root = pcp_dir.parent
328
+ result = _run_audit(project_root)
329
+ ast_grep_result = _run_ast_grep_swallowed_exceptions(project_root)
330
+ jscpd_result = _run_jscpd(project_root)
331
+ coverage_result = _run_coverage_check(project_root) if with_coverage else None
332
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
333
+ metrics = _source_metrics(project_root)
334
+ trend = _append_trend(pcp_dir, timestamp, result, metrics, ast_grep_result, jscpd_result, coverage_result)
335
+ out_path = _write_audit_md(pcp_dir, result, timestamp, trend, ast_grep_result, jscpd_result, coverage_result)
336
+
337
+ if quiet:
338
+ sys.exit(0)
339
+
340
+ if result["tool"] is None:
341
+ console.print("[dim]No dead-code tool detected (vulture or knip) — skipped.[/dim]")
342
+ else:
343
+ count = len(result["findings"])
344
+ color = "green" if count == 0 else "yellow"
345
+ console.print(f"[{color}]{count} dead-code finding(s)[/{color}] ([dim]{result['tool']}[/dim]) → {out_path.relative_to(project_root)}")
346
+
347
+ if ast_grep_result is None:
348
+ console.print("[dim]ast-grep not detected — swallowed-exception scan skipped.[/dim]")
349
+ else:
350
+ count = len(ast_grep_result["findings"])
351
+ color = "green" if count == 0 else "yellow"
352
+ console.print(f"[{color}]{count} swallowed-exception finding(s)[/{color}] ([dim]ast-grep[/dim])")
353
+
354
+ if jscpd_result is None:
355
+ console.print("[dim]jscpd not detected — duplication scan skipped.[/dim]")
356
+ else:
357
+ pct = jscpd_result.get("duplication_pct")
358
+ color = "green" if not pct else ("yellow" if pct < 10 else "red")
359
+ console.print(f"[{color}]{pct:.2f}% duplicated[/{color}] ([dim]jscpd[/dim])" if pct is not None
360
+ else "[dim]jscpd ran but reported no percentage.[/dim]")
361
+
362
+ if coverage_result is not None:
363
+ if coverage_result.get("tool") is None:
364
+ console.print("[dim]No coverage tool detected — coverage check skipped.[/dim]")
365
+ else:
366
+ pct = coverage_result.get("percent")
367
+ color = "red" if coverage_result.get("below_threshold") else "green"
368
+ suffix = f" — below {coverage_result['threshold']}% advisory threshold" if coverage_result.get("below_threshold") else ""
369
+ console.print(f"[{color}]{pct:.0f}% coverage[/{color}] ([dim]{coverage_result['tool']}[/dim]){suffix}")
370
+
371
+ sys.exit(0)