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