core-cli 2.0.0__tar.gz → 2.1.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.
- {core_cli-2.0.0 → core_cli-2.1.0}/PKG-INFO +7 -1
- {core_cli-2.0.0 → core_cli-2.1.0}/README.md +6 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/pyproject.toml +1 -1
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/client.py +33 -3
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/manage.py +114 -1
- {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_client.py +53 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_proposals.py +96 -18
- {core_cli-2.0.0 → core_cli-2.1.0}/.github/workflows/ci.yml +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/.gitignore +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/LICENSE +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/__init__.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/command.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/main.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/__init__.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/__init__.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/actions.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/audit_duplicates.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/bridges.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/check_imports.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/docstrings.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/fix_atomic.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/format.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/hub.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/integrity.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/lint.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/logging.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/code/test.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/__init__.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/claim.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/list.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/next.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/lane/propose.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/project/__init__.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/project/onboard.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/project/scout.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/__init__.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/create.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/integrate.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/proposals/list.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/__init__.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/audit.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/fix_ids.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/hub.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/symbols/resolve_duplicates.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/vectors/__init__.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/vectors/hub.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/src/core_cli/resources/vectors/query.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/tests/__init__.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_code.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_contract.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_lane.py +0 -0
- {core_cli-2.0.0 → core_cli-2.1.0}/tests/test_project.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: core-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Consumer governance CLI for CORE — an HTTP client for governing repositories against CORE constitutional rules.
|
|
5
5
|
Project-URL: Homepage, https://github.com/DariuszNewecki/CORE
|
|
6
6
|
Project-URL: Repository, https://github.com/DariuszNewecki/core-cli
|
|
@@ -66,6 +66,12 @@ Its only dependencies are `typer`, `rich`, `httpx` and `PyYAML`.
|
|
|
66
66
|
export CORE_API_URL=http://127.0.0.1:8000
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
On the machine running CORE, `core` can use CORE's Unix socket instead of TCP. On that socket the kernel tells CORE who is calling; over TCP it cannot:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
export CORE_API_URL=unix:///run/core/api.sock
|
|
73
|
+
```
|
|
74
|
+
|
|
69
75
|
## Use
|
|
70
76
|
|
|
71
77
|
```bash
|
|
@@ -37,6 +37,12 @@ Its only dependencies are `typer`, `rich`, `httpx` and `PyYAML`.
|
|
|
37
37
|
export CORE_API_URL=http://127.0.0.1:8000
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
On the machine running CORE, `core` can use CORE's Unix socket instead of TCP. On that socket the kernel tells CORE who is calling; over TCP it cannot:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export CORE_API_URL=unix:///run/core/api.sock
|
|
44
|
+
```
|
|
45
|
+
|
|
40
46
|
## Use
|
|
41
47
|
|
|
42
48
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "core-cli"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
description = "Consumer governance CLI for CORE — an HTTP client for governing repositories against CORE constitutional rules."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -6,7 +6,9 @@ here is in CORE's published OpenAPI contract, ``docs/reference/openapi.json``
|
|
|
6
6
|
in the CORE repository; ``tests/test_contract.py`` checks that.
|
|
7
7
|
|
|
8
8
|
The base URL comes from the ``CORE_API_URL`` environment variable, defaulting
|
|
9
|
-
to the loopback address CORE binds to.
|
|
9
|
+
to the loopback address CORE binds to. ``unix:///path/to/api.sock`` selects
|
|
10
|
+
CORE's Unix socket instead of TCP. On that socket the kernel tells CORE who is
|
|
11
|
+
calling (ADR-132 D10.1 in the CORE repository); over TCP it cannot.
|
|
10
12
|
"""
|
|
11
13
|
|
|
12
14
|
from __future__ import annotations
|
|
@@ -19,12 +21,36 @@ import httpx
|
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
DEFAULT_BASE_URL = "http://127.0.0.1:8000"
|
|
24
|
+
UNIX_SCHEME = "unix://"
|
|
25
|
+
# HTTP needs a host for the request line and Host header; over a Unix socket
|
|
26
|
+
# it names nothing, and CORE does not route on it.
|
|
27
|
+
_UNIX_HTTP_BASE = "http://localhost"
|
|
22
28
|
_TIMEOUT_SECONDS = 30.0
|
|
23
29
|
_LONG_TIMEOUT_SECONDS = 300.0
|
|
24
30
|
_POLL_INTERVAL_SECONDS = 1.0
|
|
25
31
|
_POLL_TERMINAL_STATES = frozenset({"completed", "failed"})
|
|
26
32
|
|
|
27
33
|
|
|
34
|
+
# ID: b50ad322-810a-4014-813e-a02a71cd55e7
|
|
35
|
+
def parse_base_url(base_url: str) -> tuple[str, str | None]:
|
|
36
|
+
"""Split a ``CORE_API_URL`` value into (HTTP base URL, Unix socket path).
|
|
37
|
+
|
|
38
|
+
``unix:///run/core/api.sock`` gives ``("http://localhost", "/run/core/api.sock")``;
|
|
39
|
+
any other value is an HTTP base URL and gives ``(base_url, None)``. The
|
|
40
|
+
socket path must be absolute, so a mistyped ``unix://`` URL fails here
|
|
41
|
+
rather than as a confusing connection error.
|
|
42
|
+
"""
|
|
43
|
+
if not base_url.startswith(UNIX_SCHEME):
|
|
44
|
+
return base_url, None
|
|
45
|
+
socket_path = base_url[len(UNIX_SCHEME) :]
|
|
46
|
+
if not socket_path.startswith("/"):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"CORE_API_URL {base_url!r}: a Unix socket URL needs an absolute "
|
|
49
|
+
"path, e.g. unix:///run/core/api.sock"
|
|
50
|
+
)
|
|
51
|
+
return _UNIX_HTTP_BASE, socket_path
|
|
52
|
+
|
|
53
|
+
|
|
28
54
|
# ID: e5aeda69-d26e-4353-81eb-83e998e968e9
|
|
29
55
|
class CoreApiError(httpx.HTTPStatusError):
|
|
30
56
|
"""An error response (status >= 400) from the CORE API.
|
|
@@ -59,6 +85,7 @@ class CoreApiClient:
|
|
|
59
85
|
|
|
60
86
|
def __init__(self, base_url: str | None = None) -> None:
|
|
61
87
|
self.base_url = base_url or os.environ.get("CORE_API_URL") or DEFAULT_BASE_URL
|
|
88
|
+
self._http_base, self.socket_path = parse_base_url(self.base_url)
|
|
62
89
|
self.inspect = _Inspect(self)
|
|
63
90
|
self.lane = _Lane(self)
|
|
64
91
|
self.project = _Project(self)
|
|
@@ -73,8 +100,11 @@ class CoreApiClient:
|
|
|
73
100
|
timeout: float = _TIMEOUT_SECONDS,
|
|
74
101
|
**kwargs: Any,
|
|
75
102
|
) -> dict:
|
|
76
|
-
|
|
77
|
-
|
|
103
|
+
client_kwargs: dict[str, Any] = {"timeout": timeout}
|
|
104
|
+
if self.socket_path is not None:
|
|
105
|
+
client_kwargs["transport"] = httpx.AsyncHTTPTransport(uds=self.socket_path)
|
|
106
|
+
async with httpx.AsyncClient(**client_kwargs) as http:
|
|
107
|
+
response = await http.request(method, f"{self._http_base}{path}", **kwargs)
|
|
78
108
|
if response.status_code >= 400:
|
|
79
109
|
raise CoreApiError(response)
|
|
80
110
|
return response.json()
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
@@ -62,6 +63,102 @@ def _print_detailed_info(p: dict) -> None:
|
|
|
62
63
|
console.print(f"\n[red]Failure Reason: {failure_reason}[/red]")
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
def _mark(ok: object) -> str:
|
|
67
|
+
return "[green]yes[/green]" if ok else "[red]NO[/red]"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _print_review(p: dict) -> None:
|
|
71
|
+
"""The approver's view (CORE ADR-168 Amendment 2026-10-10 R3/R4): who
|
|
72
|
+
wrote it and why, what it retires, what CORE checked, what resembles it,
|
|
73
|
+
which decisions touch it. Shown only when the proposal carries them."""
|
|
74
|
+
constraints = p.get("constitutional_constraints") or {}
|
|
75
|
+
prov = constraints.get("provenance")
|
|
76
|
+
if not isinstance(prov, dict):
|
|
77
|
+
console.print(
|
|
78
|
+
"\n[yellow]This proposal predates who-and-why records "
|
|
79
|
+
"(no provenance).[/yellow]"
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
console.print("\n[bold]Why it exists:[/bold]")
|
|
83
|
+
console.print(f" Anchor: {prov.get('anchor_kind')}")
|
|
84
|
+
for ref in prov.get("anchor_refs") or []:
|
|
85
|
+
console.print(f" {ref}")
|
|
86
|
+
console.print(f" Problem owner: {prov.get('problem_owner')}")
|
|
87
|
+
console.print(f" Written by: {prov.get('producer')}")
|
|
88
|
+
|
|
89
|
+
step_zero = constraints.get("step_zero") or {}
|
|
90
|
+
retires = step_zero.get("retires")
|
|
91
|
+
console.print("\n[bold]What it retires:[/bold]")
|
|
92
|
+
if not retires:
|
|
93
|
+
console.print(" nothing claimed")
|
|
94
|
+
for row in retires or []:
|
|
95
|
+
console.print(
|
|
96
|
+
f" {_mark(row.get('verified'))} {row.get('entry')} - {row.get('reason')}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
results = p.get("validation_results") or {}
|
|
100
|
+
if results:
|
|
101
|
+
console.print("\n[bold]What CORE checked:[/bold]")
|
|
102
|
+
for check, ok in sorted(results.items()):
|
|
103
|
+
console.print(f" {_mark(ok)} {check}")
|
|
104
|
+
|
|
105
|
+
look = step_zero.get("look_alikes") or {}
|
|
106
|
+
if look:
|
|
107
|
+
console.print("\n[bold]Similar existing code:[/bold]")
|
|
108
|
+
status = look.get("status")
|
|
109
|
+
if status == "unavailable":
|
|
110
|
+
console.print(f" [yellow]not searched: {look.get('reason')}[/yellow]")
|
|
111
|
+
elif status == "nothing_new":
|
|
112
|
+
console.print(" no new public function or class")
|
|
113
|
+
for sym in look.get("symbols") or []:
|
|
114
|
+
console.print(f" {sym.get('file')}::{sym.get('symbol')}")
|
|
115
|
+
for m in (sym.get("matches") or [])[:3]:
|
|
116
|
+
console.print(
|
|
117
|
+
f" {m.get('score')} {m.get('file')}::{m.get('symbol')}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
decisions = step_zero.get("decisions") or {}
|
|
121
|
+
mentions = decisions.get("mentions") or {}
|
|
122
|
+
history = decisions.get("history") or {}
|
|
123
|
+
if mentions or history:
|
|
124
|
+
console.print("\n[bold]Decisions touching these files:[/bold]")
|
|
125
|
+
for path in sorted(set(mentions) | set(history)):
|
|
126
|
+
named = [
|
|
127
|
+
f"{a.get('id')} ({a.get('status')})" for a in mentions.get(path) or []
|
|
128
|
+
]
|
|
129
|
+
cited = history.get(path)
|
|
130
|
+
cited_text = (
|
|
131
|
+
"history unreadable"
|
|
132
|
+
if cited is None and path in history
|
|
133
|
+
else ", ".join(cited or [])
|
|
134
|
+
)
|
|
135
|
+
console.print(f" {path}")
|
|
136
|
+
if named:
|
|
137
|
+
console.print(f" mentioned in: {', '.join(named)}")
|
|
138
|
+
if cited_text:
|
|
139
|
+
console.print(f" cited by past commits: {cited_text}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _typed_confirmation(proposal_id: str) -> bool:
|
|
143
|
+
"""CORE ADR-168 R2: approval is typed by the human at a real terminal.
|
|
144
|
+
|
|
145
|
+
A speed bump, not identity proof (CORE #942): a session without a
|
|
146
|
+
terminal is refused; a person must type the proposal's short id.
|
|
147
|
+
"""
|
|
148
|
+
if not sys.stdin.isatty():
|
|
149
|
+
console.print(
|
|
150
|
+
"[red]Approval needs a person at a real terminal (CORE ADR-168 R2). "
|
|
151
|
+
"Not approved.[/red]"
|
|
152
|
+
)
|
|
153
|
+
return False
|
|
154
|
+
expected = proposal_id[:8]
|
|
155
|
+
typed = typer.prompt(f"Type {expected} to approve this proposal", default="")
|
|
156
|
+
if typed.strip() != expected:
|
|
157
|
+
console.print("[yellow]Did not match. Not approved.[/yellow]")
|
|
158
|
+
return False
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
65
162
|
def _print_execution_summary(result: dict) -> None:
|
|
66
163
|
if not result.get("ok") and "actions_executed" not in result:
|
|
67
164
|
console.print(f"Error: {result.get('error', 'Unknown error')}")
|
|
@@ -92,6 +189,7 @@ async def show_proposal(proposal_id: str = typer.Argument(...)) -> None:
|
|
|
92
189
|
raise typer.Exit(1) from exc
|
|
93
190
|
raise
|
|
94
191
|
_print_detailed_info(proposal)
|
|
192
|
+
_print_review(proposal)
|
|
95
193
|
|
|
96
194
|
|
|
97
195
|
@core_command(dangerous=False)
|
|
@@ -105,8 +203,23 @@ async def approve_proposal(
|
|
|
105
203
|
help="Authority under which approval is granted (URS NFR.5).",
|
|
106
204
|
),
|
|
107
205
|
) -> None:
|
|
108
|
-
"""Authorize a pending proposal for execution.
|
|
206
|
+
"""Authorize a pending proposal for execution.
|
|
207
|
+
|
|
208
|
+
Shows the proposal and its review, then asks the person at the terminal
|
|
209
|
+
to type the proposal's short id. Refused without a terminal.
|
|
210
|
+
"""
|
|
109
211
|
client = CoreApiClient()
|
|
212
|
+
try:
|
|
213
|
+
proposal = await client.get_proposal(proposal_id)
|
|
214
|
+
except httpx.HTTPStatusError as exc:
|
|
215
|
+
if exc.response.status_code == 404:
|
|
216
|
+
console.print(f"[red]Proposal {proposal_id} not found.[/red]")
|
|
217
|
+
raise typer.Exit(1) from exc
|
|
218
|
+
raise
|
|
219
|
+
_print_detailed_info(proposal)
|
|
220
|
+
_print_review(proposal)
|
|
221
|
+
if not _typed_confirmation(proposal["proposal_id"]):
|
|
222
|
+
raise typer.Exit(1)
|
|
110
223
|
try:
|
|
111
224
|
response = await client.approve_proposal(
|
|
112
225
|
proposal_id, approved_by=by, approval_authority=authority
|
|
@@ -8,6 +8,7 @@ raises what those handlers catch.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import asyncio
|
|
11
12
|
from unittest.mock import patch
|
|
12
13
|
|
|
13
14
|
import httpx
|
|
@@ -52,3 +53,55 @@ async def test_lane_next_404_reports_empty_lane(capsys: pytest.CaptureFixture) -
|
|
|
52
53
|
with _serve(404, {"detail": "no delegated findings"}):
|
|
53
54
|
await next_finding.__wrapped__()
|
|
54
55
|
assert "Lane is empty" in capsys.readouterr().out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# -- Unix socket transport (CORE_API_URL=unix:///...) ------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_parse_base_url_http_is_unchanged() -> None:
|
|
62
|
+
assert client_mod.parse_base_url("http://10.0.0.5:8000") == (
|
|
63
|
+
"http://10.0.0.5:8000",
|
|
64
|
+
None,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_parse_base_url_unix_gives_socket_path() -> None:
|
|
69
|
+
assert client_mod.parse_base_url("unix:///run/core/api.sock") == (
|
|
70
|
+
"http://localhost",
|
|
71
|
+
"/run/core/api.sock",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_parse_base_url_unix_relative_path_is_refused() -> None:
|
|
76
|
+
with pytest.raises(ValueError, match="absolute path"):
|
|
77
|
+
client_mod.parse_base_url("unix://run/core/api.sock")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_env_unix_url_selects_socket(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
81
|
+
monkeypatch.setenv("CORE_API_URL", "unix:///run/core/api.sock")
|
|
82
|
+
assert CoreApiClient().socket_path == "/run/core/api.sock"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def test_request_travels_over_the_unix_socket(
|
|
86
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""A real HTTP exchange over a real Unix socket, not a mocked transport."""
|
|
89
|
+
socket_path = tmp_path_factory.mktemp("uds") / "api.sock"
|
|
90
|
+
seen: list[bytes] = []
|
|
91
|
+
|
|
92
|
+
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
93
|
+
seen.append(await reader.readuntil(b"\r\n\r\n"))
|
|
94
|
+
body = b'{"proposals": []}'
|
|
95
|
+
writer.write(
|
|
96
|
+
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
|
|
97
|
+
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
|
98
|
+
b"Connection: close\r\n\r\n" + body
|
|
99
|
+
)
|
|
100
|
+
await writer.drain()
|
|
101
|
+
writer.close()
|
|
102
|
+
|
|
103
|
+
server = await asyncio.start_unix_server(handle, path=str(socket_path))
|
|
104
|
+
async with server:
|
|
105
|
+
client = CoreApiClient(f"unix://{socket_path}")
|
|
106
|
+
assert await client.list_proposals() == {"proposals": []}
|
|
107
|
+
assert seen[0].startswith(b"GET /v1/proposals?limit=50 HTTP/1.1")
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
from contextlib import contextmanager
|
|
7
8
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
@@ -168,16 +169,36 @@ async def test_show_proposal_not_found() -> None:
|
|
|
168
169
|
# ---------------------------------------------------------------------------
|
|
169
170
|
|
|
170
171
|
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
@contextmanager
|
|
173
|
+
def _terminal(typed: str | None = "abc123de", tty: bool = True):
|
|
174
|
+
"""A person at a real terminal typing *typed* (CORE ADR-168 R2)."""
|
|
175
|
+
stdin = MagicMock()
|
|
176
|
+
stdin.isatty.return_value = tty
|
|
177
|
+
with (
|
|
178
|
+
patch("core_cli.resources.proposals.manage.sys.stdin", stdin),
|
|
179
|
+
patch(
|
|
180
|
+
"core_cli.resources.proposals.manage.typer.prompt", return_value=typed
|
|
181
|
+
) as prompt,
|
|
182
|
+
):
|
|
183
|
+
yield prompt
|
|
184
|
+
|
|
173
185
|
|
|
186
|
+
async def _approve(mock_client: AsyncMock, proposal_id: str = "abc123") -> None:
|
|
174
187
|
with patch(_MANAGE_CLIENT, return_value=mock_client):
|
|
175
188
|
await approve_proposal.__wrapped__(
|
|
176
|
-
proposal_id=
|
|
189
|
+
proposal_id=proposal_id,
|
|
177
190
|
by="cli_admin",
|
|
178
191
|
authority="principal.governor",
|
|
179
192
|
)
|
|
180
193
|
|
|
194
|
+
|
|
195
|
+
async def test_approve_proposal_success() -> None:
|
|
196
|
+
mock_client = _make_client()
|
|
197
|
+
|
|
198
|
+
with _terminal() as prompt:
|
|
199
|
+
await _approve(mock_client)
|
|
200
|
+
|
|
201
|
+
assert "abc123de" in prompt.call_args.args[0]
|
|
181
202
|
mock_client.approve_proposal.assert_called_once_with(
|
|
182
203
|
"abc123",
|
|
183
204
|
approved_by="cli_admin",
|
|
@@ -185,36 +206,93 @@ async def test_approve_proposal_success() -> None:
|
|
|
185
206
|
)
|
|
186
207
|
|
|
187
208
|
|
|
209
|
+
async def test_approve_refused_without_a_terminal() -> None:
|
|
210
|
+
"""A session with no terminal (e.g. a model's shell) cannot approve."""
|
|
211
|
+
mock_client = _make_client()
|
|
212
|
+
|
|
213
|
+
with _terminal(tty=False) as prompt, pytest.raises(typer.Exit) as exc_info:
|
|
214
|
+
await _approve(mock_client)
|
|
215
|
+
|
|
216
|
+
assert exc_info.value.exit_code == 1
|
|
217
|
+
prompt.assert_not_called()
|
|
218
|
+
mock_client.approve_proposal.assert_not_called()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def test_approve_refused_when_the_typed_id_does_not_match() -> None:
|
|
222
|
+
mock_client = _make_client()
|
|
223
|
+
|
|
224
|
+
with _terminal(typed="yes"), pytest.raises(typer.Exit):
|
|
225
|
+
await _approve(mock_client)
|
|
226
|
+
|
|
227
|
+
mock_client.approve_proposal.assert_not_called()
|
|
228
|
+
|
|
229
|
+
|
|
188
230
|
async def test_approve_proposal_not_found() -> None:
|
|
189
231
|
mock_client = _make_client()
|
|
190
|
-
mock_client.
|
|
232
|
+
mock_client.get_proposal.side_effect = _http_error(404)
|
|
191
233
|
|
|
192
|
-
with
|
|
193
|
-
|
|
194
|
-
await approve_proposal.__wrapped__(
|
|
195
|
-
proposal_id="missing",
|
|
196
|
-
by="cli_admin",
|
|
197
|
-
authority="principal.governor",
|
|
198
|
-
)
|
|
234
|
+
with _terminal(), pytest.raises(typer.Exit) as exc_info:
|
|
235
|
+
await _approve(mock_client, "missing")
|
|
199
236
|
|
|
200
237
|
assert exc_info.value.exit_code == 1
|
|
238
|
+
mock_client.approve_proposal.assert_not_called()
|
|
201
239
|
|
|
202
240
|
|
|
203
241
|
async def test_approve_proposal_bad_request_prints_detail() -> None:
|
|
204
242
|
mock_client = _make_client()
|
|
205
243
|
mock_client.approve_proposal.side_effect = _http_error(400, "already approved")
|
|
206
244
|
|
|
207
|
-
with
|
|
208
|
-
|
|
209
|
-
await approve_proposal.__wrapped__(
|
|
210
|
-
proposal_id="abc123",
|
|
211
|
-
by="cli_admin",
|
|
212
|
-
authority="principal.governor",
|
|
213
|
-
)
|
|
245
|
+
with _terminal(), pytest.raises(typer.Exit) as exc_info:
|
|
246
|
+
await _approve(mock_client)
|
|
214
247
|
|
|
215
248
|
assert exc_info.value.exit_code == 1
|
|
216
249
|
|
|
217
250
|
|
|
251
|
+
async def test_show_renders_who_why_and_step_zero(capsys) -> None:
|
|
252
|
+
proposal = _sample_proposal()
|
|
253
|
+
proposal["validation_results"] = {"full_audit": True, "tests": False}
|
|
254
|
+
proposal["constitutional_constraints"] = {
|
|
255
|
+
"provenance": {
|
|
256
|
+
"anchor_kind": "governor_request",
|
|
257
|
+
"anchor_refs": ["add a status line"],
|
|
258
|
+
"problem_owner": "governor",
|
|
259
|
+
"producer": "claude-session:core-darek",
|
|
260
|
+
"retires": ["src/old.py"],
|
|
261
|
+
},
|
|
262
|
+
"step_zero": {
|
|
263
|
+
"retires": [
|
|
264
|
+
{
|
|
265
|
+
"entry": "src/old.py",
|
|
266
|
+
"verified": True,
|
|
267
|
+
"reason": "deleted by the patch",
|
|
268
|
+
}
|
|
269
|
+
],
|
|
270
|
+
"look_alikes": {"status": "unavailable", "reason": "qdrant down"},
|
|
271
|
+
"decisions": {
|
|
272
|
+
"mentions": {"src/old.py": [{"id": "ADR-005", "status": "accepted"}]},
|
|
273
|
+
"history": {"src/old.py": None},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
mock_client = _make_client()
|
|
278
|
+
mock_client.get_proposal.return_value = proposal
|
|
279
|
+
|
|
280
|
+
with patch(_MANAGE_CLIENT, return_value=mock_client):
|
|
281
|
+
await show_proposal.__wrapped__(proposal_id="abc123")
|
|
282
|
+
|
|
283
|
+
out = capsys.readouterr().out
|
|
284
|
+
for text in (
|
|
285
|
+
"add a status line",
|
|
286
|
+
"claude-session:core-darek",
|
|
287
|
+
"deleted by the patch",
|
|
288
|
+
"full_audit",
|
|
289
|
+
"not searched: qdrant down",
|
|
290
|
+
"ADR-005 (accepted)",
|
|
291
|
+
"history unreadable",
|
|
292
|
+
):
|
|
293
|
+
assert text in out, text
|
|
294
|
+
|
|
295
|
+
|
|
218
296
|
# ---------------------------------------------------------------------------
|
|
219
297
|
# execute_proposal
|
|
220
298
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|