chp-adapter-conformance 0.8.0__tar.gz
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.
- chp_adapter_conformance-0.8.0/.gitignore +23 -0
- chp_adapter_conformance-0.8.0/PKG-INFO +15 -0
- chp_adapter_conformance-0.8.0/README.md +0 -0
- chp_adapter_conformance-0.8.0/chp_adapter_conformance/__init__.py +11 -0
- chp_adapter_conformance-0.8.0/chp_adapter_conformance/adapter.py +600 -0
- chp_adapter_conformance-0.8.0/chp_adapter_conformance/checker.py +276 -0
- chp_adapter_conformance-0.8.0/pyproject.toml +35 -0
- chp_adapter_conformance-0.8.0/tests/test_conformance.py +353 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.yalc/
|
|
4
|
+
yalc.lock
|
|
5
|
+
*.tgz
|
|
6
|
+
.env
|
|
7
|
+
.env.*
|
|
8
|
+
coverage/
|
|
9
|
+
dashboard/
|
|
10
|
+
components/
|
|
11
|
+
.tech-hub-cache/
|
|
12
|
+
__pycache__/
|
|
13
|
+
*.py[cod]
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.chp/
|
|
16
|
+
.DS_Store
|
|
17
|
+
.coverage
|
|
18
|
+
.forge/
|
|
19
|
+
.hypothesis/
|
|
20
|
+
.claude/
|
|
21
|
+
|
|
22
|
+
# chp-agent evidence store
|
|
23
|
+
.chp-agent/sessions.sqlite
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-conformance
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: CHP capability adapter — static and runtime violation checker for capability implementations
|
|
5
|
+
Author: Auxo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: adapter,capability-host-protocol,chp,conformance,linting
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: chp-core>=0.7.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .adapter import ConformanceAdapter
|
|
2
|
+
from .checker import Violation, check_commit_message, check_registered_adapter, check_source_file, score
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"ConformanceAdapter",
|
|
6
|
+
"Violation",
|
|
7
|
+
"check_source_file",
|
|
8
|
+
"check_registered_adapter",
|
|
9
|
+
"check_commit_message",
|
|
10
|
+
"score",
|
|
11
|
+
]
|
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""ConformanceAdapter — governed capability violation checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from chp_core import BaseAdapter, capability
|
|
12
|
+
|
|
13
|
+
from .checker import (
|
|
14
|
+
check_commit_message,
|
|
15
|
+
check_registered_adapter,
|
|
16
|
+
check_source_file,
|
|
17
|
+
score,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_SESSION_FILE = Path.home() / ".chp" / "active-session.json"
|
|
21
|
+
|
|
22
|
+
# The sole adapter sanctioned to import an HTTP client directly: it IS the
|
|
23
|
+
# governed transport. Every other adapter must compose through it
|
|
24
|
+
# (ctx.ainvoke("chp.adapters.http.request", ...)).
|
|
25
|
+
_HTTP_TRANSPORT_ADAPTER = "chp.adapters.http"
|
|
26
|
+
|
|
27
|
+
# chp_core and chp_host are below/beside the adapter layer and cannot depend on
|
|
28
|
+
# chp-adapter-http without creating a circular dependency.
|
|
29
|
+
_CORE_PKG_SEGMENT = "chp_core"
|
|
30
|
+
_HOST_PKG_SEGMENT = "chp_host"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_test_file(path: Path) -> bool:
|
|
34
|
+
"""Test modules legitimately import httpx (MockTransport) etc.; they are not
|
|
35
|
+
capability code and are exempt from the I/O-isolation rules."""
|
|
36
|
+
return "tests" in path.parts or path.name.startswith("test_")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_core_file(path: Path) -> bool:
|
|
40
|
+
"""chp_core and chp_host files are below/beside the adapter layer; they
|
|
41
|
+
cannot compose through chp-adapter-http without creating a circular
|
|
42
|
+
dependency."""
|
|
43
|
+
return _CORE_PKG_SEGMENT in path.parts or _HOST_PKG_SEGMENT in path.parts
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _source_of_handler(handler: Any) -> str | None:
|
|
47
|
+
"""Follow closure vars to find the adapter source file (handlers are wrappers in chp-core)."""
|
|
48
|
+
if hasattr(handler, "__closure__") and handler.__closure__:
|
|
49
|
+
for cell in handler.__closure__:
|
|
50
|
+
try:
|
|
51
|
+
cv = cell.cell_contents
|
|
52
|
+
if callable(cv):
|
|
53
|
+
return inspect.getfile(cv)
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
pass
|
|
56
|
+
try:
|
|
57
|
+
return inspect.getfile(handler)
|
|
58
|
+
except (TypeError, OSError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ConformanceAdapter(BaseAdapter):
|
|
63
|
+
"""Static and runtime capability conformance checking."""
|
|
64
|
+
|
|
65
|
+
adapter_id = "chp.adapters.conformance"
|
|
66
|
+
adapter_name = "Conformance"
|
|
67
|
+
adapter_description = "Check capability implementations for CHP spec violations (raw I/O, missing schema, issue policy)."
|
|
68
|
+
adapter_category = "core"
|
|
69
|
+
adapter_tags = ["conformance", "linting", "policy", "quality"]
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self._host: Any = None
|
|
73
|
+
|
|
74
|
+
def on_register(self, host: Any) -> None:
|
|
75
|
+
self._host = host
|
|
76
|
+
|
|
77
|
+
@capability(
|
|
78
|
+
id="chp.adapters.conformance.check_source",
|
|
79
|
+
version="1.0.0",
|
|
80
|
+
description="Run static AST analysis on an adapter source file and report violations.",
|
|
81
|
+
category="core",
|
|
82
|
+
risk="low",
|
|
83
|
+
input_schema={
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"source_path": {"type": "string", "description": "Absolute path to a Python adapter source file"},
|
|
87
|
+
},
|
|
88
|
+
"required": ["source_path"],
|
|
89
|
+
"additionalProperties": False,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
async def check_source(self, ctx: Any, payload: dict) -> dict:
|
|
93
|
+
path = Path(payload["source_path"]).expanduser()
|
|
94
|
+
violations = check_source_file(path)
|
|
95
|
+
conformance_score = score(violations)
|
|
96
|
+
ctx.emit("source_checked", {
|
|
97
|
+
"source_path": str(path),
|
|
98
|
+
"violation_count": len(violations),
|
|
99
|
+
"error_count": sum(1 for v in violations if v.severity == "error"),
|
|
100
|
+
"warning_count": sum(1 for v in violations if v.severity == "warning"),
|
|
101
|
+
"score": conformance_score,
|
|
102
|
+
}, redacted=False)
|
|
103
|
+
return {
|
|
104
|
+
"source_path": str(path),
|
|
105
|
+
"violations": [v.to_dict() for v in violations],
|
|
106
|
+
"violation_count": len(violations),
|
|
107
|
+
"score": conformance_score,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@capability(
|
|
111
|
+
id="chp.adapters.conformance.check_adapter",
|
|
112
|
+
version="1.0.0",
|
|
113
|
+
description="Runtime introspection of a loaded adapter — checks schema, version, and metadata completeness.",
|
|
114
|
+
category="core",
|
|
115
|
+
risk="low",
|
|
116
|
+
input_schema={
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"adapter_id": {"type": "string", "description": "Adapter ID prefix, e.g. chp.adapters.messages"},
|
|
120
|
+
},
|
|
121
|
+
"required": ["adapter_id"],
|
|
122
|
+
"additionalProperties": False,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
async def check_adapter(self, ctx: Any, payload: dict) -> dict:
|
|
126
|
+
if self._host is None:
|
|
127
|
+
raise RuntimeError("ConformanceAdapter must be registered with a host")
|
|
128
|
+
|
|
129
|
+
adapter_id = payload["adapter_id"]
|
|
130
|
+
|
|
131
|
+
# Find source files from registered capability handlers
|
|
132
|
+
src_files: set[str] = set()
|
|
133
|
+
for cap_key, reg_cap in self._host._capabilities.items():
|
|
134
|
+
if not cap_key.startswith(adapter_id):
|
|
135
|
+
continue
|
|
136
|
+
src = _source_of_handler(reg_cap.handler)
|
|
137
|
+
if src:
|
|
138
|
+
src_files.add(src)
|
|
139
|
+
|
|
140
|
+
if not src_files:
|
|
141
|
+
raise KeyError(f"No capabilities found for adapter_id prefix={adapter_id!r}")
|
|
142
|
+
|
|
143
|
+
violations: list = []
|
|
144
|
+
for src in src_files:
|
|
145
|
+
violations.extend(check_source_file(src))
|
|
146
|
+
|
|
147
|
+
conformance_score = score(violations)
|
|
148
|
+
ctx.emit("adapter_checked", {
|
|
149
|
+
"adapter_id": adapter_id,
|
|
150
|
+
"violation_count": len(violations),
|
|
151
|
+
"score": conformance_score,
|
|
152
|
+
}, redacted=False)
|
|
153
|
+
return {
|
|
154
|
+
"adapter_id": adapter_id,
|
|
155
|
+
"violations": [v.to_dict() for v in violations],
|
|
156
|
+
"violation_count": len(violations),
|
|
157
|
+
"score": conformance_score,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@capability(
|
|
161
|
+
id="chp.adapters.conformance.check_all",
|
|
162
|
+
version="1.0.0",
|
|
163
|
+
description="Check all loaded adapters for violations and return a ranked summary.",
|
|
164
|
+
category="core",
|
|
165
|
+
risk="low",
|
|
166
|
+
input_schema={
|
|
167
|
+
"type": "object",
|
|
168
|
+
"properties": {},
|
|
169
|
+
"additionalProperties": False,
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
async def check_all(self, ctx: Any, payload: dict) -> dict:
|
|
173
|
+
if self._host is None:
|
|
174
|
+
raise RuntimeError("ConformanceAdapter must be registered with a host")
|
|
175
|
+
|
|
176
|
+
# Group capabilities by their source file (follow closure to original method)
|
|
177
|
+
src_to_prefix: dict[str, str] = {}
|
|
178
|
+
for cap_key, reg_cap in self._host._capabilities.items():
|
|
179
|
+
src = _source_of_handler(reg_cap.handler)
|
|
180
|
+
if src:
|
|
181
|
+
prefix = ".".join(cap_key.split(".")[:3])
|
|
182
|
+
src_to_prefix.setdefault(src, prefix)
|
|
183
|
+
|
|
184
|
+
results = []
|
|
185
|
+
for src_file, adapter_id in src_to_prefix.items():
|
|
186
|
+
violations = check_source_file(src_file)
|
|
187
|
+
conformance_score = score(violations)
|
|
188
|
+
results.append({
|
|
189
|
+
"adapter_id": adapter_id,
|
|
190
|
+
"violation_count": len(violations),
|
|
191
|
+
"score": conformance_score,
|
|
192
|
+
"violations": [v.to_dict() for v in violations],
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
results.sort(key=lambda r: r["score"])
|
|
196
|
+
total = sum(r["violation_count"] for r in results)
|
|
197
|
+
worst = [r["adapter_id"] for r in results if r["score"] < 80]
|
|
198
|
+
|
|
199
|
+
ctx.emit("all_checked", {
|
|
200
|
+
"adapter_count": len(results),
|
|
201
|
+
"total_violations": total,
|
|
202
|
+
"worst_adapters": worst,
|
|
203
|
+
}, redacted=False)
|
|
204
|
+
return {
|
|
205
|
+
"adapters": results,
|
|
206
|
+
"adapter_count": len(results),
|
|
207
|
+
"total_violations": total,
|
|
208
|
+
"worst_adapters": worst,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@capability(
|
|
212
|
+
id="chp.adapters.conformance.policy_check",
|
|
213
|
+
version="1.0.0",
|
|
214
|
+
description="Check a commit message for the Radicle issue reference policy (rad:XXXXXXX).",
|
|
215
|
+
category="core",
|
|
216
|
+
risk="low",
|
|
217
|
+
input_schema={
|
|
218
|
+
"type": "object",
|
|
219
|
+
"properties": {
|
|
220
|
+
"commit_message": {"type": "string"},
|
|
221
|
+
},
|
|
222
|
+
"required": ["commit_message"],
|
|
223
|
+
"additionalProperties": False,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
async def policy_check(self, ctx: Any, payload: dict) -> dict:
|
|
227
|
+
msg = payload["commit_message"]
|
|
228
|
+
violations = check_commit_message(msg)
|
|
229
|
+
passes = len(violations) == 0
|
|
230
|
+
ctx.emit("policy_checked", {
|
|
231
|
+
"passes": passes,
|
|
232
|
+
"violation_count": len(violations),
|
|
233
|
+
}, redacted=False)
|
|
234
|
+
return {
|
|
235
|
+
"passes": passes,
|
|
236
|
+
"violations": [v.to_dict() for v in violations],
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# Internal helpers
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
async def _run_baseline(self, ctx: Any) -> dict:
|
|
244
|
+
"""Conformance scan via ctx.ainvoke(check_source) — each check is a governed evidence event."""
|
|
245
|
+
src_to_prefix: dict[str, str] = {}
|
|
246
|
+
for cap_key, reg_cap in self._host._capabilities.items():
|
|
247
|
+
src = _source_of_handler(reg_cap.handler)
|
|
248
|
+
if src:
|
|
249
|
+
prefix = ".".join(cap_key.split(".")[:3])
|
|
250
|
+
src_to_prefix.setdefault(src, prefix)
|
|
251
|
+
|
|
252
|
+
results = []
|
|
253
|
+
for src_file, adapter_id in src_to_prefix.items():
|
|
254
|
+
result = await ctx.ainvoke(
|
|
255
|
+
"chp.adapters.conformance.check_source",
|
|
256
|
+
{"source_path": src_file},
|
|
257
|
+
)
|
|
258
|
+
if result.success:
|
|
259
|
+
data = result.data
|
|
260
|
+
results.append({
|
|
261
|
+
"adapter_id": adapter_id,
|
|
262
|
+
"violation_count": data["violation_count"],
|
|
263
|
+
"score": data["score"],
|
|
264
|
+
"violations": data["violations"],
|
|
265
|
+
})
|
|
266
|
+
else:
|
|
267
|
+
# Fallback: direct call so a broken check_source doesn't block baseline
|
|
268
|
+
violations = check_source_file(src_file)
|
|
269
|
+
conformance_score = score(violations)
|
|
270
|
+
results.append({
|
|
271
|
+
"adapter_id": adapter_id,
|
|
272
|
+
"violation_count": len(violations),
|
|
273
|
+
"score": conformance_score,
|
|
274
|
+
"violations": [v.to_dict() for v in violations],
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
results.sort(key=lambda r: r["score"])
|
|
278
|
+
total = sum(r["violation_count"] for r in results)
|
|
279
|
+
return {"adapters": results, "adapter_count": len(results), "total_violations": total}
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
# Dev session management
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
@capability(
|
|
286
|
+
id="chp.adapters.conformance.open_dev_session",
|
|
287
|
+
version="1.0.0",
|
|
288
|
+
description="Open a tracked dev session: validate Radicle issue, snapshot baseline conformance, create plan, write active-session state.",
|
|
289
|
+
category="core",
|
|
290
|
+
risk="medium",
|
|
291
|
+
input_schema={
|
|
292
|
+
"type": "object",
|
|
293
|
+
"properties": {
|
|
294
|
+
"issue_id": {"type": "string", "description": "Radicle issue short-hash (7+ chars)"},
|
|
295
|
+
"description": {"type": "string", "description": "Optional work description"},
|
|
296
|
+
},
|
|
297
|
+
"required": ["issue_id"],
|
|
298
|
+
"additionalProperties": False,
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
async def open_dev_session(self, ctx: Any, payload: dict) -> dict:
|
|
302
|
+
if self._host is None:
|
|
303
|
+
raise RuntimeError("ConformanceAdapter must be registered with a host")
|
|
304
|
+
|
|
305
|
+
issue_id = payload["issue_id"]
|
|
306
|
+
|
|
307
|
+
# 1. Validate issue exists and is open
|
|
308
|
+
issue_result = await ctx.ainvoke(
|
|
309
|
+
"chp.adapters.radicle.issue_show",
|
|
310
|
+
{"issue_id": issue_id},
|
|
311
|
+
)
|
|
312
|
+
if not issue_result.success:
|
|
313
|
+
raise ValueError(f"Issue {issue_id} not found: {issue_result.error}")
|
|
314
|
+
issue_data = issue_result.data
|
|
315
|
+
issue_title = issue_data.get("title", issue_id)
|
|
316
|
+
issue_state = str(issue_data.get("state", "open"))
|
|
317
|
+
if issue_state not in {"open", ""}:
|
|
318
|
+
raise ValueError(f"Issue {issue_id} is not open (state: {issue_state})")
|
|
319
|
+
|
|
320
|
+
# 2. Snapshot baseline conformance (governed — each file check appears in evidence)
|
|
321
|
+
baseline = await self._run_baseline(ctx)
|
|
322
|
+
|
|
323
|
+
# 3. Create a planning adapter plan tied to this session
|
|
324
|
+
plan_result = await ctx.ainvoke(
|
|
325
|
+
"chp.adapters.planning.create_plan",
|
|
326
|
+
{
|
|
327
|
+
"intent": f"Resolve rad:{issue_id[:7]} — {issue_title}",
|
|
328
|
+
"steps": [
|
|
329
|
+
{"step_id": "baseline", "description": "Review violation baseline"},
|
|
330
|
+
{"step_id": "implement", "description": "Implement fix"},
|
|
331
|
+
{"step_id": "verify", "description": "Run check_staged — no new violations"},
|
|
332
|
+
{"step_id": "close", "description": "Close dev session"},
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
plan_id = plan_result.data.get("plan_id") if plan_result.success else None
|
|
337
|
+
|
|
338
|
+
# 4. Write session state (sanctioned local state — ~/.chp/ is CHP's own state dir)
|
|
339
|
+
_SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
340
|
+
session_data = {
|
|
341
|
+
"issue_id": issue_id,
|
|
342
|
+
"issue_title": issue_title,
|
|
343
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
344
|
+
"baseline": baseline,
|
|
345
|
+
"plan_id": plan_id,
|
|
346
|
+
"session_correlation_id": ctx.correlation_id,
|
|
347
|
+
}
|
|
348
|
+
_SESSION_FILE.write_text(json.dumps(session_data, indent=2))
|
|
349
|
+
|
|
350
|
+
baseline_total = baseline["total_violations"]
|
|
351
|
+
ctx.emit("dev_session_opened", {
|
|
352
|
+
"issue_id": issue_id,
|
|
353
|
+
"issue_title": issue_title,
|
|
354
|
+
"baseline_total_violations": baseline_total,
|
|
355
|
+
"baseline_adapter_count": baseline["adapter_count"],
|
|
356
|
+
"plan_id": plan_id,
|
|
357
|
+
}, redacted=False)
|
|
358
|
+
return {
|
|
359
|
+
"issue_id": issue_id,
|
|
360
|
+
"issue_title": issue_title,
|
|
361
|
+
"baseline_total_violations": baseline_total,
|
|
362
|
+
"session_file": str(_SESSION_FILE),
|
|
363
|
+
"plan_id": plan_id,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
@capability(
|
|
367
|
+
id="chp.adapters.conformance.check_staged",
|
|
368
|
+
version="1.0.0",
|
|
369
|
+
description="Check staged Python files against the active dev session baseline. Returns new violations only — existing baseline violations are not re-flagged.",
|
|
370
|
+
category="core",
|
|
371
|
+
risk="low",
|
|
372
|
+
input_schema={
|
|
373
|
+
"type": "object",
|
|
374
|
+
"properties": {
|
|
375
|
+
"staged_files": {
|
|
376
|
+
"type": "array",
|
|
377
|
+
"items": {"type": "string"},
|
|
378
|
+
"description": "Absolute paths to staged .py files (from git diff --staged --name-only)",
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
"required": ["staged_files"],
|
|
382
|
+
"additionalProperties": False,
|
|
383
|
+
},
|
|
384
|
+
)
|
|
385
|
+
async def check_staged(self, ctx: Any, payload: dict) -> dict:
|
|
386
|
+
if self._host is None:
|
|
387
|
+
raise RuntimeError("ConformanceAdapter must be registered with a host")
|
|
388
|
+
|
|
389
|
+
staged_files = payload.get("staged_files", [])
|
|
390
|
+
|
|
391
|
+
if not _SESSION_FILE.exists():
|
|
392
|
+
raise RuntimeError("No active dev session. Run open_dev_session first.")
|
|
393
|
+
|
|
394
|
+
session_data = json.loads(_SESSION_FILE.read_text())
|
|
395
|
+
baseline_adapters = session_data.get("baseline", {}).get("adapters", [])
|
|
396
|
+
|
|
397
|
+
# Build reverse map: source_file → baseline rule set
|
|
398
|
+
baseline_rules_by_adapter: dict[str, set[str]] = {
|
|
399
|
+
a["adapter_id"]: {v["rule"] for v in a.get("violations", [])}
|
|
400
|
+
for a in baseline_adapters
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Build map: source_file → adapter_id from currently loaded capabilities,
|
|
404
|
+
# plus directory → adapter_id so sibling isolation modules (_client.py,
|
|
405
|
+
# _backends.py) are attributed to their adapter (closes the blind spot
|
|
406
|
+
# where imported non-capability files were never checked).
|
|
407
|
+
file_to_adapter: dict[str, str] = {}
|
|
408
|
+
dir_to_adapter: dict[str, str] = {}
|
|
409
|
+
for cap_key, reg_cap in self._host._capabilities.items():
|
|
410
|
+
src = _source_of_handler(reg_cap.handler)
|
|
411
|
+
if src:
|
|
412
|
+
adapter_id = ".".join(cap_key.split(".")[:3])
|
|
413
|
+
resolved_src = str(Path(src).resolve())
|
|
414
|
+
file_to_adapter.setdefault(resolved_src, adapter_id)
|
|
415
|
+
dir_to_adapter.setdefault(str(Path(resolved_src).parent), adapter_id)
|
|
416
|
+
|
|
417
|
+
new_violations: list[dict] = []
|
|
418
|
+
files_checked: list[str] = []
|
|
419
|
+
|
|
420
|
+
for file_path in staged_files:
|
|
421
|
+
path = Path(file_path)
|
|
422
|
+
if not path.exists() or path.suffix != ".py":
|
|
423
|
+
continue
|
|
424
|
+
if _is_test_file(path):
|
|
425
|
+
continue # tests may use httpx.MockTransport etc.
|
|
426
|
+
if _is_core_file(path):
|
|
427
|
+
continue # chp_core is below the adapter layer; cannot depend on adapters
|
|
428
|
+
|
|
429
|
+
violations = check_source_file(path)
|
|
430
|
+
files_checked.append(str(path))
|
|
431
|
+
|
|
432
|
+
resolved = str(path.resolve())
|
|
433
|
+
# Capability file → its adapter; otherwise a sibling module in the
|
|
434
|
+
# same package directory → that adapter.
|
|
435
|
+
adapter_id = file_to_adapter.get(resolved) or dir_to_adapter.get(str(Path(resolved).parent))
|
|
436
|
+
baseline_rules = baseline_rules_by_adapter.get(adapter_id, set()) if adapter_id else set()
|
|
437
|
+
|
|
438
|
+
for v in violations:
|
|
439
|
+
# chp.adapters.http is the sanctioned transport — it alone may
|
|
440
|
+
# import an HTTP client.
|
|
441
|
+
if v.rule == "raw_http" and adapter_id == _HTTP_TRANSPORT_ADAPTER:
|
|
442
|
+
continue
|
|
443
|
+
if v.rule not in baseline_rules:
|
|
444
|
+
new_violations.append({**v.to_dict(), "file": str(path)})
|
|
445
|
+
|
|
446
|
+
ok = len(new_violations) == 0
|
|
447
|
+
ctx.emit("staged_checked", {
|
|
448
|
+
"files_checked": len(files_checked),
|
|
449
|
+
"new_violation_count": len(new_violations),
|
|
450
|
+
"ok": ok,
|
|
451
|
+
}, redacted=False)
|
|
452
|
+
return {
|
|
453
|
+
"ok": ok,
|
|
454
|
+
"new_violations": new_violations,
|
|
455
|
+
"files_checked": files_checked,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
@capability(
|
|
459
|
+
id="chp.adapters.conformance.close_dev_session",
|
|
460
|
+
version="1.0.0",
|
|
461
|
+
description="Close the active dev session: run final conformance scan, compare vs baseline, delete session state.",
|
|
462
|
+
category="core",
|
|
463
|
+
risk="low",
|
|
464
|
+
input_schema={
|
|
465
|
+
"type": "object",
|
|
466
|
+
"properties": {
|
|
467
|
+
"outcome": {
|
|
468
|
+
"type": "string",
|
|
469
|
+
"enum": ["success", "abandoned"],
|
|
470
|
+
"description": "Whether the work was completed or abandoned",
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
"additionalProperties": False,
|
|
474
|
+
},
|
|
475
|
+
)
|
|
476
|
+
async def close_dev_session(self, ctx: Any, payload: dict) -> dict:
|
|
477
|
+
if self._host is None:
|
|
478
|
+
raise RuntimeError("ConformanceAdapter must be registered with a host")
|
|
479
|
+
|
|
480
|
+
if not _SESSION_FILE.exists():
|
|
481
|
+
raise RuntimeError("No active dev session to close")
|
|
482
|
+
|
|
483
|
+
session_data = json.loads(_SESSION_FILE.read_text())
|
|
484
|
+
issue_id = session_data["issue_id"]
|
|
485
|
+
started_at = session_data.get("started_at", "")
|
|
486
|
+
baseline_total = session_data.get("baseline", {}).get("total_violations", 0)
|
|
487
|
+
outcome = payload.get("outcome", "success")
|
|
488
|
+
|
|
489
|
+
duration_s: int | None = None
|
|
490
|
+
if started_at:
|
|
491
|
+
try:
|
|
492
|
+
start = datetime.fromisoformat(started_at)
|
|
493
|
+
duration_s = int((datetime.now(timezone.utc) - start).total_seconds())
|
|
494
|
+
except ValueError:
|
|
495
|
+
duration_s = None # malformed started_at — skip duration
|
|
496
|
+
|
|
497
|
+
final = await self._run_baseline(ctx)
|
|
498
|
+
violations_resolved = max(0, baseline_total - final["total_violations"])
|
|
499
|
+
|
|
500
|
+
# Delete session state (sanctioned local state)
|
|
501
|
+
_SESSION_FILE.unlink()
|
|
502
|
+
|
|
503
|
+
ctx.emit("dev_session_closed", {
|
|
504
|
+
"issue_id": issue_id,
|
|
505
|
+
"outcome": outcome,
|
|
506
|
+
"duration_s": duration_s,
|
|
507
|
+
"violations_resolved": violations_resolved,
|
|
508
|
+
"violations_remaining": final["total_violations"],
|
|
509
|
+
}, redacted=False)
|
|
510
|
+
return {
|
|
511
|
+
"issue_id": issue_id,
|
|
512
|
+
"outcome": outcome,
|
|
513
|
+
"duration_s": duration_s,
|
|
514
|
+
"violations_resolved": violations_resolved,
|
|
515
|
+
"violations_remaining": final["total_violations"],
|
|
516
|
+
"final_adapter_count": final["adapter_count"],
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
@capability(
|
|
520
|
+
id="chp.adapters.conformance.report_violations",
|
|
521
|
+
version="1.0.0",
|
|
522
|
+
description="Auto-open Radicle issues for adapters with conformance violations. Deduplicates against existing open issues.",
|
|
523
|
+
category="core",
|
|
524
|
+
risk="medium",
|
|
525
|
+
input_schema={
|
|
526
|
+
"type": "object",
|
|
527
|
+
"properties": {
|
|
528
|
+
"dry_run": {
|
|
529
|
+
"type": "boolean",
|
|
530
|
+
"description": "If true, report what would be opened without actually opening issues",
|
|
531
|
+
"default": False,
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
"additionalProperties": False,
|
|
535
|
+
},
|
|
536
|
+
)
|
|
537
|
+
async def report_violations(self, ctx: Any, payload: dict) -> dict:
|
|
538
|
+
if self._host is None:
|
|
539
|
+
raise RuntimeError("ConformanceAdapter must be registered with a host")
|
|
540
|
+
|
|
541
|
+
dry_run = payload.get("dry_run", False)
|
|
542
|
+
|
|
543
|
+
all_results = await self._run_baseline(ctx)
|
|
544
|
+
adapters_with_violations = [r for r in all_results["adapters"] if r["violation_count"] > 0]
|
|
545
|
+
|
|
546
|
+
# Fetch existing open issues to deduplicate
|
|
547
|
+
existing_titles: set[str] = set()
|
|
548
|
+
if not dry_run:
|
|
549
|
+
issues_result = await ctx.ainvoke(
|
|
550
|
+
"chp.adapters.radicle.issue_list",
|
|
551
|
+
{"state": "open"},
|
|
552
|
+
)
|
|
553
|
+
if issues_result.success:
|
|
554
|
+
for issue in issues_result.data.get("issues", []):
|
|
555
|
+
existing_titles.add(issue.get("title", ""))
|
|
556
|
+
|
|
557
|
+
issues_opened: list[dict] = []
|
|
558
|
+
issues_existing: list[str] = []
|
|
559
|
+
|
|
560
|
+
for adapter_result in adapters_with_violations:
|
|
561
|
+
adapter_id = adapter_result["adapter_id"]
|
|
562
|
+
title = f"fix: conformance violations in {adapter_id}"
|
|
563
|
+
|
|
564
|
+
if any(adapter_id in t for t in existing_titles):
|
|
565
|
+
issues_existing.append(adapter_id)
|
|
566
|
+
continue
|
|
567
|
+
|
|
568
|
+
violation_lines = "\n".join(
|
|
569
|
+
f"- [{v['severity'].upper()}] {v['rule']}: {v['message']} ({v['location']})"
|
|
570
|
+
for v in adapter_result["violations"]
|
|
571
|
+
)
|
|
572
|
+
body = (
|
|
573
|
+
f"Score: {adapter_result['score']}/100 "
|
|
574
|
+
f"Violations: {adapter_result['violation_count']}\n\n"
|
|
575
|
+
f"{violation_lines}"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if not dry_run:
|
|
579
|
+
open_result = await ctx.ainvoke(
|
|
580
|
+
"chp.adapters.radicle.issue_open",
|
|
581
|
+
{"title": title, "body": body, "labels": ["conformance", "p2"]},
|
|
582
|
+
)
|
|
583
|
+
opened_id = open_result.data.get("issue_id", "") if open_result.success else ""
|
|
584
|
+
issues_opened.append({"adapter_id": adapter_id, "issue_id": opened_id})
|
|
585
|
+
else:
|
|
586
|
+
issues_opened.append({"adapter_id": adapter_id, "title": title, "dry_run": True})
|
|
587
|
+
|
|
588
|
+
ctx.emit("violations_reported", {
|
|
589
|
+
"adapters_with_violations": len(adapters_with_violations),
|
|
590
|
+
"issues_opened": len(issues_opened),
|
|
591
|
+
"issues_existing": len(issues_existing),
|
|
592
|
+
"dry_run": dry_run,
|
|
593
|
+
}, redacted=False)
|
|
594
|
+
return {
|
|
595
|
+
"issues_opened": issues_opened,
|
|
596
|
+
"issues_existing": issues_existing,
|
|
597
|
+
"adapters_checked": all_results["adapter_count"],
|
|
598
|
+
"total_violations": all_results["total_violations"],
|
|
599
|
+
"dry_run": dry_run,
|
|
600
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Static (AST) and runtime capability violation checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Violation model
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Violation:
|
|
17
|
+
rule: str # e.g. "raw_io", "missing_emit", "issue_policy"
|
|
18
|
+
severity: str # "error" | "warning"
|
|
19
|
+
message: str
|
|
20
|
+
location: str = "" # "line N" or "capability_id"
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"rule": self.rule,
|
|
25
|
+
"severity": self.severity,
|
|
26
|
+
"message": self.message,
|
|
27
|
+
"location": self.location,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Static checker (AST)
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
_RAW_IO_CALLS = {"open", "read", "write"}
|
|
36
|
+
# Top-level packages that are forbidden HTTP transports.
|
|
37
|
+
# urllib.parse is NOT included — it is URL string manipulation, not a transport.
|
|
38
|
+
_FORBIDDEN_TOP = {"httpx", "requests", "aiohttp"}
|
|
39
|
+
# Specific urllib sub-packages that ARE HTTP transports.
|
|
40
|
+
_FORBIDDEN_URLLIB = {"urllib.request", "urllib.error", "urllib.response"}
|
|
41
|
+
_ISSUE_RE = re.compile(r"rad:[0-9a-f]{7,40}")
|
|
42
|
+
_MERGE_RE = re.compile(r"^Merge ")
|
|
43
|
+
_REVERT_RE = re.compile(r"^Revert ")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _CapabilityVisitor(ast.NodeVisitor):
|
|
47
|
+
"""Walk a capability method body and collect violations."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, method_name: str) -> None:
|
|
50
|
+
self.method_name = method_name
|
|
51
|
+
self.violations: list[Violation] = []
|
|
52
|
+
self._has_emit = False
|
|
53
|
+
self._in_capability = False
|
|
54
|
+
|
|
55
|
+
def _loc(self, node: ast.AST) -> str:
|
|
56
|
+
return f"line {node.lineno}" if hasattr(node, "lineno") else ""
|
|
57
|
+
|
|
58
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
59
|
+
# open() / read() / write() direct calls
|
|
60
|
+
if isinstance(node.func, ast.Name) and node.func.id in _RAW_IO_CALLS:
|
|
61
|
+
self.violations.append(Violation(
|
|
62
|
+
rule="raw_io",
|
|
63
|
+
severity="error",
|
|
64
|
+
message=f"Direct `{node.func.id}()` call — use filesystem adapter instead",
|
|
65
|
+
location=self._loc(node),
|
|
66
|
+
))
|
|
67
|
+
|
|
68
|
+
# ctx.emit() — mark as found
|
|
69
|
+
if (
|
|
70
|
+
isinstance(node.func, ast.Attribute)
|
|
71
|
+
and node.func.attr == "emit"
|
|
72
|
+
and isinstance(node.func.value, ast.Name)
|
|
73
|
+
and node.func.value.id == "ctx"
|
|
74
|
+
):
|
|
75
|
+
self._has_emit = True
|
|
76
|
+
|
|
77
|
+
# self._helper(ctx, ...) — private helper delegation counts as an emit
|
|
78
|
+
# (helpers receive ctx and are responsible for emitting on behalf of the capability)
|
|
79
|
+
if (
|
|
80
|
+
isinstance(node.func, ast.Attribute)
|
|
81
|
+
and isinstance(node.func.value, ast.Name)
|
|
82
|
+
and node.func.value.id == "self"
|
|
83
|
+
and node.func.attr.startswith("_")
|
|
84
|
+
):
|
|
85
|
+
for arg in node.args:
|
|
86
|
+
if isinstance(arg, ast.Name) and arg.id == "ctx":
|
|
87
|
+
self._has_emit = True
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
# ctx.host.<anything except sanctioned> direct access
|
|
91
|
+
if (
|
|
92
|
+
isinstance(node.func, ast.Attribute)
|
|
93
|
+
and isinstance(node.func.value, ast.Attribute)
|
|
94
|
+
and node.func.value.attr == "host"
|
|
95
|
+
and isinstance(node.func.value.value, ast.Name)
|
|
96
|
+
and node.func.value.value.id == "ctx"
|
|
97
|
+
):
|
|
98
|
+
method = node.func.attr
|
|
99
|
+
if method not in {"record_turn"}:
|
|
100
|
+
self.violations.append(Violation(
|
|
101
|
+
rule="direct_host_call",
|
|
102
|
+
severity="warning",
|
|
103
|
+
message=f"ctx.host.{method}() bypasses invocation chain — use ctx.invoke() instead",
|
|
104
|
+
location=self._loc(node),
|
|
105
|
+
))
|
|
106
|
+
# No generic_visit: outer ast.walk already visits children
|
|
107
|
+
|
|
108
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
109
|
+
# Detect bare `except: pass` or `except Exception: pass`
|
|
110
|
+
if node.body and all(isinstance(s, ast.Pass) for s in node.body):
|
|
111
|
+
self.violations.append(Violation(
|
|
112
|
+
rule="silent_error",
|
|
113
|
+
severity="warning",
|
|
114
|
+
message="Silent exception handler (except: pass) swallows errors",
|
|
115
|
+
location=self._loc(node),
|
|
116
|
+
))
|
|
117
|
+
|
|
118
|
+
def generic_visit(self, node: ast.AST) -> None:
|
|
119
|
+
# Suppress default recursion: outer ast.walk handles all traversal.
|
|
120
|
+
# Without this, nodes lacking a specific visit_* method recurse into
|
|
121
|
+
# children via the default generic_visit, causing N+1 visits per node.
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_source_file(path: str | Path) -> list[Violation]:
|
|
126
|
+
"""Parse a Python source file and return capability violations."""
|
|
127
|
+
source = Path(path).read_text()
|
|
128
|
+
try:
|
|
129
|
+
tree = ast.parse(source, filename=str(path))
|
|
130
|
+
except SyntaxError as exc:
|
|
131
|
+
return [Violation(rule="parse_error", severity="error", message=str(exc))]
|
|
132
|
+
|
|
133
|
+
violations: list[Violation] = []
|
|
134
|
+
|
|
135
|
+
# File-level: check all imports regardless of context
|
|
136
|
+
for node in ast.walk(tree):
|
|
137
|
+
if isinstance(node, ast.Import):
|
|
138
|
+
for alias in node.names:
|
|
139
|
+
top = alias.name.split(".")[0]
|
|
140
|
+
full = alias.name
|
|
141
|
+
if top in _FORBIDDEN_TOP or full in _FORBIDDEN_URLLIB:
|
|
142
|
+
violations.append(Violation(
|
|
143
|
+
rule="raw_http",
|
|
144
|
+
severity="error",
|
|
145
|
+
message=f"Direct import of `{alias.name}` — use http/transport adapter instead",
|
|
146
|
+
location=f"line {node.lineno}",
|
|
147
|
+
))
|
|
148
|
+
elif isinstance(node, ast.ImportFrom):
|
|
149
|
+
module = node.module or ""
|
|
150
|
+
top = module.split(".")[0]
|
|
151
|
+
if top in _FORBIDDEN_TOP or module in _FORBIDDEN_URLLIB:
|
|
152
|
+
violations.append(Violation(
|
|
153
|
+
rule="raw_http",
|
|
154
|
+
severity="error",
|
|
155
|
+
message=f"Direct import from `{module}` — use http/transport adapter instead",
|
|
156
|
+
location=f"line {node.lineno}",
|
|
157
|
+
))
|
|
158
|
+
|
|
159
|
+
# Capability-level: check each @capability-decorated method
|
|
160
|
+
for node in ast.walk(tree):
|
|
161
|
+
if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)):
|
|
162
|
+
continue
|
|
163
|
+
decorators = [
|
|
164
|
+
(d.func.id if isinstance(d, ast.Call) and isinstance(d.func, ast.Name) else
|
|
165
|
+
d.id if isinstance(d, ast.Name) else "")
|
|
166
|
+
for d in node.decorator_list
|
|
167
|
+
]
|
|
168
|
+
if "capability" not in decorators:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
visitor = _CapabilityVisitor(node.name)
|
|
172
|
+
for child in ast.walk(node):
|
|
173
|
+
visitor.visit(child)
|
|
174
|
+
|
|
175
|
+
if not visitor._has_emit:
|
|
176
|
+
violations.append(Violation(
|
|
177
|
+
rule="missing_emit",
|
|
178
|
+
severity="error",
|
|
179
|
+
message=f"Capability `{node.name}` never calls ctx.emit() — evidence chain incomplete",
|
|
180
|
+
location=f"line {node.lineno}",
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
violations.extend(visitor.violations)
|
|
184
|
+
|
|
185
|
+
return violations
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Runtime checker (introspection)
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def check_registered_adapter(adapter: object) -> list[Violation]:
|
|
193
|
+
"""Inspect a registered adapter instance for schema violations."""
|
|
194
|
+
violations: list[Violation] = []
|
|
195
|
+
|
|
196
|
+
if not getattr(adapter, "adapter_id", None):
|
|
197
|
+
violations.append(Violation(
|
|
198
|
+
rule="missing_adapter_id",
|
|
199
|
+
severity="error",
|
|
200
|
+
message=f"{type(adapter).__name__} has no adapter_id",
|
|
201
|
+
))
|
|
202
|
+
|
|
203
|
+
if not getattr(adapter, "adapter_category", None):
|
|
204
|
+
violations.append(Violation(
|
|
205
|
+
rule="missing_category",
|
|
206
|
+
severity="warning",
|
|
207
|
+
message=f"{type(adapter).__name__} has no adapter_category",
|
|
208
|
+
))
|
|
209
|
+
|
|
210
|
+
# Walk registered capabilities from the class
|
|
211
|
+
for attr_name in dir(type(adapter)):
|
|
212
|
+
method = getattr(type(adapter), attr_name, None)
|
|
213
|
+
if method is None:
|
|
214
|
+
continue
|
|
215
|
+
cap_meta = getattr(method, "_chp_capability", None)
|
|
216
|
+
if cap_meta is None:
|
|
217
|
+
continue
|
|
218
|
+
cap_id = getattr(cap_meta, "id", attr_name)
|
|
219
|
+
|
|
220
|
+
if not getattr(cap_meta, "input_schema", None):
|
|
221
|
+
violations.append(Violation(
|
|
222
|
+
rule="missing_schema",
|
|
223
|
+
severity="error",
|
|
224
|
+
message="No input_schema defined",
|
|
225
|
+
location=cap_id,
|
|
226
|
+
))
|
|
227
|
+
else:
|
|
228
|
+
schema = cap_meta.input_schema
|
|
229
|
+
if not isinstance(schema, dict) or schema.get("type") != "object":
|
|
230
|
+
violations.append(Violation(
|
|
231
|
+
rule="invalid_schema",
|
|
232
|
+
severity="warning",
|
|
233
|
+
message="input_schema should be an object schema",
|
|
234
|
+
location=cap_id,
|
|
235
|
+
))
|
|
236
|
+
|
|
237
|
+
if not getattr(cap_meta, "version", None):
|
|
238
|
+
violations.append(Violation(
|
|
239
|
+
rule="missing_version",
|
|
240
|
+
severity="warning",
|
|
241
|
+
message="No version declared on capability",
|
|
242
|
+
location=cap_id,
|
|
243
|
+
))
|
|
244
|
+
|
|
245
|
+
return violations
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Issue policy check
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def check_commit_message(msg: str) -> list[Violation]:
|
|
253
|
+
clean = "\n".join(l for l in msg.splitlines() if not l.startswith("#"))
|
|
254
|
+
if _ISSUE_RE.search(clean):
|
|
255
|
+
return []
|
|
256
|
+
if _MERGE_RE.match(clean.strip()):
|
|
257
|
+
return []
|
|
258
|
+
if _REVERT_RE.match(clean.strip()):
|
|
259
|
+
return []
|
|
260
|
+
return [Violation(
|
|
261
|
+
rule="issue_policy",
|
|
262
|
+
severity="error",
|
|
263
|
+
message="Commit message missing Radicle issue reference (rad:XXXXXXX)",
|
|
264
|
+
)]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Score
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def score(violations: list[Violation]) -> int:
|
|
272
|
+
"""0–100 conformance score. 100 = no violations."""
|
|
273
|
+
errors = sum(1 for v in violations if v.severity == "error")
|
|
274
|
+
warnings = sum(1 for v in violations if v.severity == "warning")
|
|
275
|
+
deductions = errors * 15 + warnings * 5
|
|
276
|
+
return max(0, 100 - deductions)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chp-adapter-conformance"
|
|
7
|
+
version = "0.8.0"
|
|
8
|
+
description = "CHP capability adapter — static and runtime violation checker for capability implementations"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Auxo" }]
|
|
13
|
+
keywords = ["chp", "capability-host-protocol", "conformance", "linting", "adapter"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"chp-core>=0.7.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.entry-points."chp.adapters"]
|
|
25
|
+
conformance = "chp_adapter_conformance:ConformanceAdapter"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8.0"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
pythonpath = ["."]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["chp_adapter_conformance"]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Tests for chp-adapter-conformance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import textwrap
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from chp_adapter_conformance import (
|
|
11
|
+
ConformanceAdapter,
|
|
12
|
+
Violation,
|
|
13
|
+
check_commit_message,
|
|
14
|
+
check_registered_adapter,
|
|
15
|
+
check_source_file,
|
|
16
|
+
score,
|
|
17
|
+
)
|
|
18
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
19
|
+
from chp_core.store import SQLiteEvidenceStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Helpers
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def _src(code: str, tmp_path: Path) -> Path:
|
|
27
|
+
p = tmp_path / "adapter.py"
|
|
28
|
+
p.write_text(textwrap.dedent(code))
|
|
29
|
+
return p
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rules(violations: list[Violation]) -> set[str]:
|
|
33
|
+
return {v.rule for v in violations}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_host() -> LocalCapabilityHost:
|
|
37
|
+
store = SQLiteEvidenceStore(":memory:")
|
|
38
|
+
host = LocalCapabilityHost(store=store)
|
|
39
|
+
register_adapter(host, ConformanceAdapter())
|
|
40
|
+
return host
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _invoke(host, cap_id, payload=None):
|
|
44
|
+
return host.invoke(cap_id, payload or {})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Static checker
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
class TestCheckSourceFile:
|
|
52
|
+
def test_clean_adapter_no_violations(self, tmp_path):
|
|
53
|
+
src = _src("""
|
|
54
|
+
from chp_core import capability
|
|
55
|
+
|
|
56
|
+
class Adapter:
|
|
57
|
+
@capability(id="x.y", version="1.0.0", input_schema={"type":"object","properties":{}})
|
|
58
|
+
async def my_cap(self, ctx, payload):
|
|
59
|
+
ctx.emit("done", {})
|
|
60
|
+
return {"ok": True}
|
|
61
|
+
""", tmp_path)
|
|
62
|
+
violations = check_source_file(src)
|
|
63
|
+
assert len(violations) == 0
|
|
64
|
+
|
|
65
|
+
def test_detects_raw_open(self, tmp_path):
|
|
66
|
+
src = _src("""
|
|
67
|
+
from chp_core import capability
|
|
68
|
+
|
|
69
|
+
class Adapter:
|
|
70
|
+
@capability(id="x.y", version="1.0.0", input_schema={"type":"object","properties":{}})
|
|
71
|
+
async def my_cap(self, ctx, payload):
|
|
72
|
+
with open("/tmp/foo") as f:
|
|
73
|
+
data = f.read()
|
|
74
|
+
ctx.emit("done", {})
|
|
75
|
+
return {"ok": True}
|
|
76
|
+
""", tmp_path)
|
|
77
|
+
violations = check_source_file(src)
|
|
78
|
+
assert "raw_io" in _rules(violations)
|
|
79
|
+
|
|
80
|
+
def test_detects_missing_emit(self, tmp_path):
|
|
81
|
+
src = _src("""
|
|
82
|
+
from chp_core import capability
|
|
83
|
+
|
|
84
|
+
class Adapter:
|
|
85
|
+
@capability(id="x.y", version="1.0.0", input_schema={"type":"object","properties":{}})
|
|
86
|
+
async def my_cap(self, ctx, payload):
|
|
87
|
+
return {"ok": True}
|
|
88
|
+
""", tmp_path)
|
|
89
|
+
violations = check_source_file(src)
|
|
90
|
+
assert "missing_emit" in _rules(violations)
|
|
91
|
+
|
|
92
|
+
def test_detects_forbidden_import(self, tmp_path):
|
|
93
|
+
src = _src("""
|
|
94
|
+
import httpx
|
|
95
|
+
from chp_core import capability
|
|
96
|
+
|
|
97
|
+
class Adapter:
|
|
98
|
+
@capability(id="x.y", version="1.0.0", input_schema={"type":"object","properties":{}})
|
|
99
|
+
async def my_cap(self, ctx, payload):
|
|
100
|
+
ctx.emit("done", {})
|
|
101
|
+
return {"ok": True}
|
|
102
|
+
""", tmp_path)
|
|
103
|
+
violations = check_source_file(src)
|
|
104
|
+
assert "raw_http" in _rules(violations)
|
|
105
|
+
|
|
106
|
+
def test_detects_silent_error(self, tmp_path):
|
|
107
|
+
src = _src("""
|
|
108
|
+
from chp_core import capability
|
|
109
|
+
|
|
110
|
+
class Adapter:
|
|
111
|
+
@capability(id="x.y", version="1.0.0", input_schema={"type":"object","properties":{}})
|
|
112
|
+
async def my_cap(self, ctx, payload):
|
|
113
|
+
try:
|
|
114
|
+
pass
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
ctx.emit("done", {})
|
|
118
|
+
return {"ok": True}
|
|
119
|
+
""", tmp_path)
|
|
120
|
+
violations = check_source_file(src)
|
|
121
|
+
assert "silent_error" in _rules(violations)
|
|
122
|
+
|
|
123
|
+
def test_detects_direct_host_call(self, tmp_path):
|
|
124
|
+
src = _src("""
|
|
125
|
+
from chp_core import capability
|
|
126
|
+
|
|
127
|
+
class Adapter:
|
|
128
|
+
@capability(id="x.y", version="1.0.0", input_schema={"type":"object","properties":{}})
|
|
129
|
+
async def my_cap(self, ctx, payload):
|
|
130
|
+
ctx.host.some_method()
|
|
131
|
+
ctx.emit("done", {})
|
|
132
|
+
return {"ok": True}
|
|
133
|
+
""", tmp_path)
|
|
134
|
+
violations = check_source_file(src)
|
|
135
|
+
assert "direct_host_call" in _rules(violations)
|
|
136
|
+
|
|
137
|
+
def test_record_turn_is_sanctioned(self, tmp_path):
|
|
138
|
+
src = _src("""
|
|
139
|
+
from chp_core import capability
|
|
140
|
+
|
|
141
|
+
class Adapter:
|
|
142
|
+
@capability(id="x.y", version="1.0.0", input_schema={"type":"object","properties":{}})
|
|
143
|
+
async def my_cap(self, ctx, payload):
|
|
144
|
+
ctx.host.record_turn(ctx.correlation_id, role="user", content="x")
|
|
145
|
+
ctx.emit("done", {})
|
|
146
|
+
return {"ok": True}
|
|
147
|
+
""", tmp_path)
|
|
148
|
+
violations = check_source_file(src)
|
|
149
|
+
assert "direct_host_call" not in _rules(violations)
|
|
150
|
+
|
|
151
|
+
def test_backfill_session_has_violations(self):
|
|
152
|
+
"""Confirm the known violation in backfill_session is detected."""
|
|
153
|
+
from pathlib import Path
|
|
154
|
+
root = Path(__file__).resolve().parents[3] # chp-dev/
|
|
155
|
+
path = root / "packages" / "chp-adapter-messages" / "chp_adapter_messages" / "adapter.py"
|
|
156
|
+
if not path.exists():
|
|
157
|
+
pytest.skip("chp-adapter-messages not available in test path")
|
|
158
|
+
violations = check_source_file(path)
|
|
159
|
+
# backfill_session now routes through ctx.ainvoke(filesystem.read_file) — no raw_io
|
|
160
|
+
assert not violations, f"Unexpected violations: {violations}"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Issue policy
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
class TestCommitPolicy:
|
|
168
|
+
def test_valid_commit_with_issue(self):
|
|
169
|
+
msg = "feat: add thing\n\nrad:72fb420abc1234567890abcdef123456789012"
|
|
170
|
+
assert check_commit_message(msg) == []
|
|
171
|
+
|
|
172
|
+
def test_short_hash_accepted(self):
|
|
173
|
+
msg = "fix: bug\n\nrad:72fb420"
|
|
174
|
+
assert check_commit_message(msg) == []
|
|
175
|
+
|
|
176
|
+
def test_merge_commit_exempt(self):
|
|
177
|
+
msg = "Merge branch 'main' into feat/x"
|
|
178
|
+
assert check_commit_message(msg) == []
|
|
179
|
+
|
|
180
|
+
def test_revert_commit_exempt(self):
|
|
181
|
+
msg = "Revert \"feat: something\""
|
|
182
|
+
assert check_commit_message(msg) == []
|
|
183
|
+
|
|
184
|
+
def test_missing_issue_fails(self):
|
|
185
|
+
msg = "fix: something without an issue"
|
|
186
|
+
violations = check_commit_message(msg)
|
|
187
|
+
assert len(violations) == 1
|
|
188
|
+
assert violations[0].rule == "issue_policy"
|
|
189
|
+
|
|
190
|
+
def test_comment_lines_ignored(self):
|
|
191
|
+
msg = "fix: thing\n# rad:72fb420 this is a comment\nno issue ref"
|
|
192
|
+
violations = check_commit_message(msg)
|
|
193
|
+
assert "issue_policy" in {v.rule for v in violations}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Score
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
class TestScore:
|
|
201
|
+
def test_no_violations_is_100(self):
|
|
202
|
+
assert score([]) == 100
|
|
203
|
+
|
|
204
|
+
def test_one_error_deducts_15(self):
|
|
205
|
+
v = Violation(rule="raw_io", severity="error", message="x")
|
|
206
|
+
assert score([v]) == 85
|
|
207
|
+
|
|
208
|
+
def test_one_warning_deducts_5(self):
|
|
209
|
+
v = Violation(rule="missing_category", severity="warning", message="x")
|
|
210
|
+
assert score([v]) == 95
|
|
211
|
+
|
|
212
|
+
def test_score_floors_at_zero(self):
|
|
213
|
+
errors = [Violation(rule="raw_io", severity="error", message="x")] * 10
|
|
214
|
+
assert score(errors) == 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Capability round-trips
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
class TestConformanceCapabilities:
|
|
222
|
+
def test_capabilities_registered(self):
|
|
223
|
+
host = _make_host()
|
|
224
|
+
caps = set(host._capabilities.keys())
|
|
225
|
+
assert any("check_source" in k for k in caps)
|
|
226
|
+
assert any("check_adapter" in k for k in caps)
|
|
227
|
+
assert any("check_all" in k for k in caps)
|
|
228
|
+
assert any("policy_check" in k for k in caps)
|
|
229
|
+
assert any("open_dev_session" in k for k in caps)
|
|
230
|
+
assert any("check_staged" in k for k in caps)
|
|
231
|
+
assert any("close_dev_session" in k for k in caps)
|
|
232
|
+
assert any("report_violations" in k for k in caps)
|
|
233
|
+
|
|
234
|
+
def test_policy_check_via_capability(self):
|
|
235
|
+
host = _make_host()
|
|
236
|
+
r = _invoke(host, "chp.adapters.conformance.policy_check", {
|
|
237
|
+
"commit_message": "feat: thing\n\nrad:72fb420"
|
|
238
|
+
})
|
|
239
|
+
assert r.success
|
|
240
|
+
assert r.data["passes"] is True
|
|
241
|
+
|
|
242
|
+
def test_policy_check_fails_without_issue(self):
|
|
243
|
+
host = _make_host()
|
|
244
|
+
r = _invoke(host, "chp.adapters.conformance.policy_check", {
|
|
245
|
+
"commit_message": "feat: thing without issue"
|
|
246
|
+
})
|
|
247
|
+
assert r.success
|
|
248
|
+
assert r.data["passes"] is False
|
|
249
|
+
|
|
250
|
+
def test_check_source_via_capability(self, tmp_path):
|
|
251
|
+
src = _src("""
|
|
252
|
+
from chp_core import capability
|
|
253
|
+
|
|
254
|
+
class Adapter:
|
|
255
|
+
@capability(id="x.y", version="1.0.0", input_schema={"type":"object","properties":{}})
|
|
256
|
+
async def my_cap(self, ctx, payload):
|
|
257
|
+
open("/tmp/foo")
|
|
258
|
+
ctx.emit("done", {})
|
|
259
|
+
return {}
|
|
260
|
+
""", tmp_path)
|
|
261
|
+
host = _make_host()
|
|
262
|
+
r = _invoke(host, "chp.adapters.conformance.check_source", {
|
|
263
|
+
"source_path": str(src)
|
|
264
|
+
})
|
|
265
|
+
assert r.success
|
|
266
|
+
assert r.data["violation_count"] >= 1
|
|
267
|
+
assert r.data["score"] < 100
|
|
268
|
+
|
|
269
|
+
def test_check_all_via_capability(self):
|
|
270
|
+
host = _make_host()
|
|
271
|
+
r = _invoke(host, "chp.adapters.conformance.check_all")
|
|
272
|
+
assert r.success
|
|
273
|
+
assert r.data["adapter_count"] >= 1
|
|
274
|
+
|
|
275
|
+
def test_check_staged_no_session_raises(self, tmp_path):
|
|
276
|
+
"""check_staged raises if no active session file exists."""
|
|
277
|
+
import json
|
|
278
|
+
from pathlib import Path
|
|
279
|
+
session = Path.home() / ".chp" / "active-session.json"
|
|
280
|
+
existed = session.exists()
|
|
281
|
+
backup = session.read_text() if existed else None
|
|
282
|
+
if existed:
|
|
283
|
+
session.unlink()
|
|
284
|
+
try:
|
|
285
|
+
host = _make_host()
|
|
286
|
+
r = _invoke(host, "chp.adapters.conformance.check_staged", {"staged_files": []})
|
|
287
|
+
assert not r.success or "No active dev session" in str(r.error)
|
|
288
|
+
finally:
|
|
289
|
+
if backup is not None:
|
|
290
|
+
session.write_text(backup)
|
|
291
|
+
|
|
292
|
+
def test_check_staged_empty_files_is_ok(self, tmp_path):
|
|
293
|
+
"""check_staged with no staged files returns ok=True."""
|
|
294
|
+
import json
|
|
295
|
+
from pathlib import Path
|
|
296
|
+
session = Path.home() / ".chp" / "active-session.json"
|
|
297
|
+
existed = session.exists()
|
|
298
|
+
backup = session.read_text() if existed else None
|
|
299
|
+
session.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
session.write_text(json.dumps({
|
|
301
|
+
"issue_id": "test0001",
|
|
302
|
+
"baseline": {"adapters": [], "adapter_count": 0, "total_violations": 0},
|
|
303
|
+
}))
|
|
304
|
+
try:
|
|
305
|
+
host = _make_host()
|
|
306
|
+
r = _invoke(host, "chp.adapters.conformance.check_staged", {"staged_files": []})
|
|
307
|
+
assert r.success
|
|
308
|
+
assert r.data["ok"] is True
|
|
309
|
+
assert r.data["new_violations"] == []
|
|
310
|
+
finally:
|
|
311
|
+
if backup is not None:
|
|
312
|
+
session.write_text(backup)
|
|
313
|
+
elif session.exists():
|
|
314
|
+
session.unlink()
|
|
315
|
+
|
|
316
|
+
def test_check_staged_detects_new_violation(self, tmp_path):
|
|
317
|
+
"""check_staged flags violations in staged files that aren't in baseline."""
|
|
318
|
+
import json
|
|
319
|
+
from pathlib import Path
|
|
320
|
+
# Write a Python file with a raw_io violation
|
|
321
|
+
bad_file = tmp_path / "bad_adapter.py"
|
|
322
|
+
bad_file.write_text(textwrap.dedent("""
|
|
323
|
+
from chp_core import capability
|
|
324
|
+
|
|
325
|
+
class Adapter:
|
|
326
|
+
@capability(id="x.y", version="1.0.0", input_schema={"type":"object","properties":{}})
|
|
327
|
+
async def my_cap(self, ctx, payload):
|
|
328
|
+
open("/tmp/foo")
|
|
329
|
+
ctx.emit("done", {})
|
|
330
|
+
return {}
|
|
331
|
+
"""))
|
|
332
|
+
session = Path.home() / ".chp" / "active-session.json"
|
|
333
|
+
existed = session.exists()
|
|
334
|
+
backup = session.read_text() if existed else None
|
|
335
|
+
session.parent.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
session.write_text(json.dumps({
|
|
337
|
+
"issue_id": "test0001",
|
|
338
|
+
"baseline": {"adapters": [], "adapter_count": 0, "total_violations": 0},
|
|
339
|
+
}))
|
|
340
|
+
try:
|
|
341
|
+
host = _make_host()
|
|
342
|
+
r = _invoke(host, "chp.adapters.conformance.check_staged", {
|
|
343
|
+
"staged_files": [str(bad_file)],
|
|
344
|
+
})
|
|
345
|
+
assert r.success
|
|
346
|
+
assert r.data["ok"] is False
|
|
347
|
+
rules = {v["rule"] for v in r.data["new_violations"]}
|
|
348
|
+
assert "raw_io" in rules
|
|
349
|
+
finally:
|
|
350
|
+
if backup is not None:
|
|
351
|
+
session.write_text(backup)
|
|
352
|
+
elif session.exists():
|
|
353
|
+
session.unlink()
|