agent-suite-conformance 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""CLI contract v1 conformance kit (Plan 018 WI-2).
|
|
2
|
+
|
|
3
|
+
One centrally versioned package, owned by agent-suite, consumed pinned by
|
|
4
|
+
every component — never copied, so there is exactly one kit to drift from
|
|
5
|
+
(the identifier-gate distribution model applied to conformance).
|
|
6
|
+
|
|
7
|
+
``KIT_VERSION`` identifies the kit in recorded conformance results
|
|
8
|
+
(``data/cli-conformance.json``); ``CLI_CONTRACT_VERSION`` is the contract
|
|
9
|
+
revision the kit enforces (``docs/cli-contract.md``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from agent_suite.conformance.envelope import (
|
|
13
|
+
build_envelope,
|
|
14
|
+
emit_error,
|
|
15
|
+
validate_envelope,
|
|
16
|
+
)
|
|
17
|
+
from agent_suite.conformance.kit import (
|
|
18
|
+
BrokenPipeCase,
|
|
19
|
+
ErrorCase,
|
|
20
|
+
Framing,
|
|
21
|
+
SuccessCase,
|
|
22
|
+
UsageCase,
|
|
23
|
+
run_broken_pipe_case,
|
|
24
|
+
run_error_case,
|
|
25
|
+
run_success_case,
|
|
26
|
+
run_usage_case,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
KIT_VERSION = "1.0.0"
|
|
30
|
+
CLI_CONTRACT_VERSION = 1
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"KIT_VERSION",
|
|
34
|
+
"CLI_CONTRACT_VERSION",
|
|
35
|
+
"BrokenPipeCase",
|
|
36
|
+
"ErrorCase",
|
|
37
|
+
"Framing",
|
|
38
|
+
"SuccessCase",
|
|
39
|
+
"UsageCase",
|
|
40
|
+
"build_envelope",
|
|
41
|
+
"emit_error",
|
|
42
|
+
"run_broken_pipe_case",
|
|
43
|
+
"run_error_case",
|
|
44
|
+
"run_success_case",
|
|
45
|
+
"run_usage_case",
|
|
46
|
+
"validate_envelope",
|
|
47
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The common CLI error envelope (CLI contract v1 §3).
|
|
2
|
+
|
|
3
|
+
``build_envelope`` constructs the shape, ``validate_envelope`` checks a
|
|
4
|
+
parsed document against it (stdlib-only — the schema at
|
|
5
|
+
``data/cli-error-envelope.schema.json`` is the normative statement; this
|
|
6
|
+
validator mirrors it for consumers that must stay dependency-free), and
|
|
7
|
+
``emit_error`` is the helper a conforming CLI calls on every error path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
_CODE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_envelope(
|
|
21
|
+
code: str,
|
|
22
|
+
message: str,
|
|
23
|
+
*,
|
|
24
|
+
detail: str | None = None,
|
|
25
|
+
retryable: bool = False,
|
|
26
|
+
partial: dict[str, Any] | None = None,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Construct a contract-v1 error envelope."""
|
|
29
|
+
if not _CODE_RE.match(code):
|
|
30
|
+
raise ValueError(f"error code {code!r} is not SCREAMING_SNAKE")
|
|
31
|
+
return {
|
|
32
|
+
"ok": False,
|
|
33
|
+
"error": {
|
|
34
|
+
"code": code,
|
|
35
|
+
"message": message,
|
|
36
|
+
"detail": detail,
|
|
37
|
+
"retryable": retryable,
|
|
38
|
+
"partial": partial,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_envelope(document: Any) -> list[str]:
|
|
44
|
+
"""Return contract violations for a parsed error document (empty = valid)."""
|
|
45
|
+
violations: list[str] = []
|
|
46
|
+
if not isinstance(document, dict):
|
|
47
|
+
return ["envelope is not a JSON object"]
|
|
48
|
+
if document.get("ok") is not False:
|
|
49
|
+
violations.append("envelope 'ok' is not false")
|
|
50
|
+
error = document.get("error")
|
|
51
|
+
if not isinstance(error, dict):
|
|
52
|
+
return violations + ["envelope 'error' is not an object"]
|
|
53
|
+
code = error.get("code")
|
|
54
|
+
if not isinstance(code, str) or not _CODE_RE.match(code):
|
|
55
|
+
violations.append(f"error 'code' {code!r} is not a SCREAMING_SNAKE string")
|
|
56
|
+
if not isinstance(error.get("message"), str):
|
|
57
|
+
violations.append("error 'message' is not a string")
|
|
58
|
+
if "detail" in error and not isinstance(error["detail"], (str, type(None))):
|
|
59
|
+
violations.append("error 'detail' is not a string or null")
|
|
60
|
+
if not isinstance(error.get("retryable"), bool):
|
|
61
|
+
violations.append("error 'retryable' is not a boolean")
|
|
62
|
+
partial = error.get("partial")
|
|
63
|
+
if partial is not None:
|
|
64
|
+
if not isinstance(partial, dict):
|
|
65
|
+
violations.append("error 'partial' is not an object or null")
|
|
66
|
+
else:
|
|
67
|
+
succeeded = partial.get("succeeded")
|
|
68
|
+
failed = partial.get("failed")
|
|
69
|
+
if not isinstance(succeeded, int) or succeeded < 0:
|
|
70
|
+
violations.append("partial 'succeeded' is not a non-negative integer")
|
|
71
|
+
if not isinstance(failed, int) or failed < 1:
|
|
72
|
+
violations.append("partial 'failed' is not a positive integer")
|
|
73
|
+
unknown = set(error) - {"code", "message", "detail", "retryable", "partial"}
|
|
74
|
+
if unknown:
|
|
75
|
+
violations.append(f"error object has unknown fields: {sorted(unknown)}")
|
|
76
|
+
unknown_top = set(document) - {"ok", "error"}
|
|
77
|
+
if unknown_top:
|
|
78
|
+
violations.append(f"envelope has unknown fields: {sorted(unknown_top)}")
|
|
79
|
+
return violations
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def emit_error(
|
|
83
|
+
code: str,
|
|
84
|
+
message: str,
|
|
85
|
+
*,
|
|
86
|
+
detail: str | None = None,
|
|
87
|
+
retryable: bool = False,
|
|
88
|
+
partial: dict[str, Any] | None = None,
|
|
89
|
+
json_mode: bool = False,
|
|
90
|
+
) -> int:
|
|
91
|
+
"""Report an error per the contract and return the exit code (1).
|
|
92
|
+
|
|
93
|
+
Under ``--json`` the envelope is the single stdout document; otherwise
|
|
94
|
+
the message (and detail) go to stderr. Either way the caller returns
|
|
95
|
+
the value returned here — no path prints an error and exits 0.
|
|
96
|
+
"""
|
|
97
|
+
if json_mode:
|
|
98
|
+
print(
|
|
99
|
+
json.dumps(
|
|
100
|
+
build_envelope(
|
|
101
|
+
code,
|
|
102
|
+
message,
|
|
103
|
+
detail=detail,
|
|
104
|
+
retryable=retryable,
|
|
105
|
+
partial=partial,
|
|
106
|
+
),
|
|
107
|
+
indent=2,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
print(f"error: {message}", file=sys.stderr)
|
|
112
|
+
if detail:
|
|
113
|
+
print(f" {detail}", file=sys.stderr)
|
|
114
|
+
return 1
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Behavioral conformance checks for CLI contract v1 (Plan 018 WI-2).
|
|
2
|
+
|
|
3
|
+
Each check runs a real subprocess against the component's CLI and returns
|
|
4
|
+
a list of contract violations (empty = conformant). Components declare
|
|
5
|
+
cases (fixtures) and parameterize pytest over them; the checks themselves
|
|
6
|
+
live only here, centrally versioned.
|
|
7
|
+
|
|
8
|
+
The checks prove the contract, not the implementation: stream discipline
|
|
9
|
+
(§1), exit-code taxonomy (§2), the error envelope (§3), traceback and
|
|
10
|
+
broken-pipe robustness (§4), and secret redaction (§3). Output honesty
|
|
11
|
+
(§5) and manifest discovery (§6) are P1 — cases for them extend these
|
|
12
|
+
dataclasses rather than forking the kit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from enum import Enum
|
|
23
|
+
from typing import Mapping
|
|
24
|
+
|
|
25
|
+
from agent_suite.conformance.envelope import validate_envelope
|
|
26
|
+
|
|
27
|
+
_TRACEBACK_MARKER = "Traceback (most recent call last)"
|
|
28
|
+
|
|
29
|
+
# Value planted in per-case env vars to prove error paths never echo
|
|
30
|
+
# secret material (contract §3 redaction). Cases list the env var *names*
|
|
31
|
+
# whose values must never surface; the kit plants this sentinel there.
|
|
32
|
+
SENTINEL_SECRET = "conformance-sentinel-3f9d2c"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Framing(Enum):
|
|
36
|
+
DOCUMENT = "document"
|
|
37
|
+
NDJSON = "ndjson"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class SuccessCase:
|
|
42
|
+
"""A verb invocation that must succeed with pure JSON stdout."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
argv: tuple[str, ...]
|
|
46
|
+
framing: Framing = Framing.DOCUMENT
|
|
47
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
48
|
+
unset_env: tuple[str, ...] = ()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ErrorCase:
|
|
53
|
+
"""A documented failure that must exit nonzero with the envelope."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
argv: tuple[str, ...]
|
|
57
|
+
expect_code: str | None = None
|
|
58
|
+
json_mode: bool = True
|
|
59
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
60
|
+
unset_env: tuple[str, ...] = ()
|
|
61
|
+
secret_env_names: tuple[str, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class UsageCase:
|
|
66
|
+
"""A malformed invocation that must exit 2 (argparse usage error)."""
|
|
67
|
+
|
|
68
|
+
name: str
|
|
69
|
+
argv: tuple[str, ...]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class BrokenPipeCase:
|
|
74
|
+
"""A verb whose stdout is closed early; it must not traceback."""
|
|
75
|
+
|
|
76
|
+
name: str
|
|
77
|
+
argv: tuple[str, ...]
|
|
78
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
CASE_TIMEOUT: float = 60.0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _run(
|
|
85
|
+
argv: tuple[str, ...],
|
|
86
|
+
env: Mapping[str, str],
|
|
87
|
+
unset_env: tuple[str, ...] = (),
|
|
88
|
+
timeout: float = CASE_TIMEOUT,
|
|
89
|
+
) -> subprocess.CompletedProcess[str]:
|
|
90
|
+
merged = {**os.environ, "PYTHONIOENCODING": "utf-8", **env}
|
|
91
|
+
for name in unset_env:
|
|
92
|
+
merged.pop(name, None)
|
|
93
|
+
try:
|
|
94
|
+
return subprocess.run(
|
|
95
|
+
list(argv),
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
check=False,
|
|
99
|
+
env=merged,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
)
|
|
102
|
+
except subprocess.TimeoutExpired:
|
|
103
|
+
return subprocess.CompletedProcess(
|
|
104
|
+
args=list(argv),
|
|
105
|
+
returncode=124,
|
|
106
|
+
stdout="",
|
|
107
|
+
stderr=f"conformance: timed out after {timeout}s",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_success_case(case: SuccessCase) -> list[str]:
|
|
112
|
+
"""Contract §1 + §2: exit 0, stdout is pure JSON, no traceback."""
|
|
113
|
+
proc = _run(case.argv, case.env, case.unset_env)
|
|
114
|
+
violations: list[str] = []
|
|
115
|
+
if proc.returncode != 0:
|
|
116
|
+
violations.append(
|
|
117
|
+
f"exit {proc.returncode}, expected 0; stderr: {proc.stderr[-500:]!r}"
|
|
118
|
+
)
|
|
119
|
+
if _TRACEBACK_MARKER in proc.stderr:
|
|
120
|
+
violations.append("traceback on a documented success path")
|
|
121
|
+
stdout = proc.stdout.strip()
|
|
122
|
+
if not stdout:
|
|
123
|
+
violations.append("empty stdout on a JSON success path")
|
|
124
|
+
return violations
|
|
125
|
+
match case.framing:
|
|
126
|
+
case Framing.DOCUMENT:
|
|
127
|
+
try:
|
|
128
|
+
json.loads(stdout)
|
|
129
|
+
except json.JSONDecodeError as exc:
|
|
130
|
+
violations.append(
|
|
131
|
+
f"stdout is not a single JSON document ({exc}); "
|
|
132
|
+
f"first 200 bytes: {stdout[:200]!r}"
|
|
133
|
+
)
|
|
134
|
+
case Framing.NDJSON:
|
|
135
|
+
for lineno, line in enumerate(stdout.splitlines(), start=1):
|
|
136
|
+
if not line.strip():
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
json.loads(line)
|
|
140
|
+
except json.JSONDecodeError:
|
|
141
|
+
violations.append(
|
|
142
|
+
f"NDJSON line {lineno} is not JSON: {line[:200]!r}"
|
|
143
|
+
)
|
|
144
|
+
return violations
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_error_case(case: ErrorCase) -> list[str]:
|
|
148
|
+
"""Contract §2 + §3: nonzero exit, envelope on stdout, no leaks."""
|
|
149
|
+
env = dict(case.env)
|
|
150
|
+
for name in case.secret_env_names:
|
|
151
|
+
env[name] = SENTINEL_SECRET
|
|
152
|
+
proc = _run(case.argv, env, case.unset_env)
|
|
153
|
+
violations: list[str] = []
|
|
154
|
+
if proc.returncode == 0:
|
|
155
|
+
violations.append(
|
|
156
|
+
"exit 0 on a documented error path (the fail-open class); "
|
|
157
|
+
f"stdout: {proc.stdout[:200]!r} stderr: {proc.stderr[:200]!r}"
|
|
158
|
+
)
|
|
159
|
+
if proc.returncode == 2:
|
|
160
|
+
violations.append("exit 2 (usage) on an operational error path")
|
|
161
|
+
if _TRACEBACK_MARKER in proc.stderr or _TRACEBACK_MARKER in proc.stdout:
|
|
162
|
+
violations.append("traceback on a documented error path")
|
|
163
|
+
if case.secret_env_names and SENTINEL_SECRET in proc.stdout + proc.stderr:
|
|
164
|
+
violations.append("secret material leaked into error output")
|
|
165
|
+
if case.json_mode:
|
|
166
|
+
stdout = proc.stdout.strip()
|
|
167
|
+
if not stdout:
|
|
168
|
+
violations.append("no envelope on stdout for a --json error path")
|
|
169
|
+
return violations
|
|
170
|
+
try:
|
|
171
|
+
document = json.loads(stdout)
|
|
172
|
+
except json.JSONDecodeError:
|
|
173
|
+
violations.append(
|
|
174
|
+
f"--json error stdout is not JSON: {stdout[:200]!r}"
|
|
175
|
+
)
|
|
176
|
+
return violations
|
|
177
|
+
violations.extend(validate_envelope(document))
|
|
178
|
+
if case.expect_code is not None and not violations:
|
|
179
|
+
actual = document["error"]["code"]
|
|
180
|
+
if actual != case.expect_code:
|
|
181
|
+
violations.append(
|
|
182
|
+
f"error code {actual!r}, expected {case.expect_code!r}"
|
|
183
|
+
)
|
|
184
|
+
return violations
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run_usage_case(case: UsageCase) -> list[str]:
|
|
188
|
+
"""Contract §2: malformed invocations exit 2, without traceback."""
|
|
189
|
+
proc = _run(case.argv, {})
|
|
190
|
+
violations: list[str] = []
|
|
191
|
+
if proc.returncode != 2:
|
|
192
|
+
violations.append(f"exit {proc.returncode}, expected 2 (usage error)")
|
|
193
|
+
if _TRACEBACK_MARKER in proc.stderr:
|
|
194
|
+
violations.append("traceback on a usage error")
|
|
195
|
+
return violations
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run_broken_pipe_case(case: BrokenPipeCase) -> list[str]:
|
|
199
|
+
"""Contract §4: closing stdout early must not produce a traceback."""
|
|
200
|
+
if sys.platform == "win32":
|
|
201
|
+
return [] # SIGPIPE semantics don't apply; nothing to prove here.
|
|
202
|
+
with subprocess.Popen(
|
|
203
|
+
list(case.argv),
|
|
204
|
+
stdout=subprocess.PIPE,
|
|
205
|
+
stderr=subprocess.PIPE,
|
|
206
|
+
env={**os.environ, **case.env},
|
|
207
|
+
) as proc:
|
|
208
|
+
assert proc.stdout is not None
|
|
209
|
+
proc.stdout.read(1)
|
|
210
|
+
proc.stdout.close()
|
|
211
|
+
stderr = proc.stderr.read() if proc.stderr else b""
|
|
212
|
+
proc.wait(timeout=120)
|
|
213
|
+
if _TRACEBACK_MARKER.encode() in stderr:
|
|
214
|
+
return ["traceback on broken stdout pipe"]
|
|
215
|
+
return []
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-suite-conformance
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI contract v1 conformance kit for the agent suite (Plan 018 WI-2 / Plan 019 B1).
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: agent-suite,cli-contract,conformance,testing
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# agent-suite-conformance
|
|
11
|
+
|
|
12
|
+
The CLI contract v1 conformance kit for the agent suite (Plan 018 WI-2). One
|
|
13
|
+
centrally versioned, stdlib-only package that every suite
|
|
14
|
+
component depends on as a normal pinned version — so there is exactly one kit,
|
|
15
|
+
never per-repo copies to drift.
|
|
16
|
+
|
|
17
|
+
## What it provides
|
|
18
|
+
|
|
19
|
+
`agent_suite.conformance` — success/error/usage/broken-pipe case runners and the
|
|
20
|
+
common error-envelope validator that hold each suite CLI to the contract in
|
|
21
|
+
`docs/cli-contract.md`:
|
|
22
|
+
|
|
23
|
+
- stdout under `--json` is exactly one JSON document (or documented NDJSON),
|
|
24
|
+
zero non-JSON bytes;
|
|
25
|
+
- documented error paths exit nonzero with the common error envelope;
|
|
26
|
+
- usage errors exit 2; broken pipe exits without a traceback;
|
|
27
|
+
- error output carries no secret material.
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
```toml
|
|
32
|
+
# in a component's dev/test dependencies
|
|
33
|
+
"agent-suite-conformance==1.0.0"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from agent_suite.conformance import (
|
|
38
|
+
KIT_VERSION, CLI_CONTRACT_VERSION,
|
|
39
|
+
SuccessCase, ErrorCase, UsageCase,
|
|
40
|
+
run_success_case, run_error_case, run_usage_case,
|
|
41
|
+
validate_envelope,
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The kit discovers what to test from each component's CLI manifest
|
|
46
|
+
(`<tool> contract --json`); see the agent-suite CLI contract for the manifest
|
|
47
|
+
shape.
|
|
48
|
+
|
|
49
|
+
## Provenance
|
|
50
|
+
|
|
51
|
+
Built from the single source of truth at `src/agent_suite/conformance/` in the
|
|
52
|
+
agent-suite repository. `version` here equals `agent_suite.conformance.KIT_VERSION`;
|
|
53
|
+
a guard test fails CI if they diverge.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
agent_suite/conformance/__init__.py,sha256=tLw3hDbCRttlWMiaChqtCDjJ0jXwc6lp18r8zsd8S1o,1150
|
|
2
|
+
agent_suite/conformance/envelope.py,sha256=efpArYbwx4TjjwZzg5gvlZd57ME_lv8XOntGyQYI9sY,4043
|
|
3
|
+
agent_suite/conformance/kit.py,sha256=j-FlIHj4reinUQIPVAELAkSpUr9kENPA_UelrBnrIjI,7289
|
|
4
|
+
agent_suite_conformance-1.0.0.dist-info/METADATA,sha256=RjpJmt88SbNZpmZx8ekXrmo_qObHHIYu1bG6qyfuk-Y,1724
|
|
5
|
+
agent_suite_conformance-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
agent_suite_conformance-1.0.0.dist-info/RECORD,,
|