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
|
@@ -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)
|