proofside 0.1.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.
proofside/cli.py ADDED
@@ -0,0 +1,423 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ast
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from pathlib import Path
11
+
12
+ from .artifacts import candidate_contract_path
13
+ from .contracts import (
14
+ ContractError,
15
+ build_annotated_source,
16
+ load_contract,
17
+ render_human,
18
+ validate_contract,
19
+ )
20
+
21
+
22
+ class Status(str, Enum):
23
+ VERIFIED = "VERIFIED"
24
+ FAILED = "FAILED"
25
+ UNSUPPORTED = "UNSUPPORTED"
26
+ ERROR = "ERROR"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CheckResult:
31
+ status: Status
32
+ detail: str
33
+ contract_text: str | None = None
34
+
35
+
36
+ def parse_selector(selector: str) -> tuple[Path, str]:
37
+ if selector.count("::") != 1:
38
+ raise ValueError("selector must have the form path/to/file.py::function_name")
39
+
40
+ file_text, function_name = selector.split("::")
41
+ if not file_text or Path(file_text).suffix != ".py":
42
+ raise ValueError("selector must name a Python file ending in .py")
43
+ if not function_name.isidentifier():
44
+ raise ValueError("selector must end with one unqualified function name")
45
+ return Path(file_text), function_name
46
+
47
+
48
+ def _function_arguments(function: ast.FunctionDef) -> list[ast.arg]:
49
+ arguments = [*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs]
50
+ if function.args.vararg:
51
+ arguments.append(function.args.vararg)
52
+ if function.args.kwarg:
53
+ arguments.append(function.args.kwarg)
54
+ return arguments
55
+
56
+
57
+ def load_target(
58
+ file_path: Path,
59
+ function_name: str,
60
+ require_inline_contract: bool = True,
61
+ ) -> tuple[str, ast.FunctionDef] | CheckResult:
62
+ if not file_path.is_file():
63
+ return CheckResult(Status.ERROR, f"file does not exist: {file_path}")
64
+
65
+ try:
66
+ source = file_path.read_text(encoding="utf-8")
67
+ except OSError as error:
68
+ return CheckResult(Status.ERROR, f"could not read {file_path}: {error}")
69
+
70
+ try:
71
+ tree = ast.parse(source, filename=str(file_path))
72
+ except SyntaxError as error:
73
+ location = f"line {error.lineno}" if error.lineno else "unknown line"
74
+ return CheckResult(Status.ERROR, f"cannot parse {file_path} ({location}): {error.msg}")
75
+
76
+ functions = [
77
+ node for node in tree.body
78
+ if isinstance(node, ast.FunctionDef) and node.name == function_name
79
+ ]
80
+ async_functions = [
81
+ node for node in tree.body
82
+ if isinstance(node, ast.AsyncFunctionDef) and node.name == function_name
83
+ ]
84
+
85
+ if len(functions) + len(async_functions) > 1:
86
+ return CheckResult(Status.UNSUPPORTED, f"multiple top-level functions are named {function_name}")
87
+ if async_functions:
88
+ return CheckResult(Status.UNSUPPORTED, "async functions are not supported")
89
+ if not functions:
90
+ nested_match = any(
91
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
92
+ and node.name == function_name
93
+ for node in ast.walk(tree)
94
+ )
95
+ if nested_match:
96
+ return CheckResult(
97
+ Status.UNSUPPORTED,
98
+ "the selected function is nested or is a method; only top-level functions are supported",
99
+ )
100
+ return CheckResult(Status.ERROR, f"top-level function not found: {function_name}")
101
+
102
+ function = functions[0]
103
+ if function.decorator_list:
104
+ return CheckResult(Status.UNSUPPORTED, "decorated functions are not supported")
105
+
106
+ arguments = _function_arguments(function)
107
+ if function.returns is None or any(argument.annotation is None for argument in arguments):
108
+ return CheckResult(Status.UNSUPPORTED, "the function must have complete parameter and return annotations")
109
+
110
+ if require_inline_contract:
111
+ contract_names = {"Requires", "Ensures"}
112
+ has_contract = any(
113
+ isinstance(statement, ast.Expr)
114
+ and isinstance(statement.value, ast.Call)
115
+ and isinstance(statement.value.func, ast.Name)
116
+ and statement.value.func.id in contract_names
117
+ for statement in function.body
118
+ )
119
+ if not has_contract:
120
+ return CheckResult(Status.UNSUPPORTED, "no direct Requires or Ensures contract call was found")
121
+
122
+ return source, function
123
+
124
+
125
+ def classify_nagini(return_code: int, stdout: str, stderr: str) -> CheckResult:
126
+ if return_code == 0 and "Verification successful" in stdout:
127
+ return CheckResult(Status.VERIFIED, "Nagini/Viper discharged the declared proof obligations.")
128
+
129
+ if return_code != 0 and stdout.startswith("Verification failed"):
130
+ diagnostic_lines = stdout.splitlines()[2:]
131
+ if diagnostic_lines and diagnostic_lines[-1].startswith("Verification took "):
132
+ diagnostic_lines.pop()
133
+ return CheckResult(Status.FAILED, "\n".join(diagnostic_lines).strip())
134
+
135
+ diagnostic = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
136
+ return CheckResult(Status.ERROR, diagnostic or f"Nagini exited unexpectedly with status {return_code}")
137
+
138
+
139
+ def run_nagini(file_path: Path, function_name: str) -> CheckResult:
140
+ nagini = shutil.which("nagini")
141
+ if not nagini:
142
+ return CheckResult(Status.ERROR, "Nagini executable not found; activate the verification environment")
143
+
144
+ try:
145
+ completed = subprocess.run(
146
+ [
147
+ nagini,
148
+ "--verifier",
149
+ "silicon",
150
+ "--select",
151
+ function_name,
152
+ str(file_path.resolve()),
153
+ ],
154
+ capture_output=True,
155
+ text=True,
156
+ check=False,
157
+ )
158
+ except OSError as error:
159
+ return CheckResult(Status.ERROR, f"could not start Nagini: {error}")
160
+
161
+ return classify_nagini(completed.returncode, completed.stdout, completed.stderr)
162
+
163
+
164
+ def check(selector: str, contract_path: Path | None = None) -> CheckResult:
165
+ try:
166
+ file_path, function_name = parse_selector(selector)
167
+ except ValueError as error:
168
+ return CheckResult(Status.ERROR, str(error))
169
+
170
+ target = load_target(file_path, function_name, require_inline_contract=contract_path is None)
171
+ if isinstance(target, CheckResult):
172
+ return target
173
+ source, function = target
174
+
175
+ if contract_path is None:
176
+ return run_nagini(file_path, function_name)
177
+
178
+ try:
179
+ contract = load_contract(contract_path)
180
+ parameters = {argument.arg for argument in _function_arguments(function)}
181
+ validate_contract(contract, parameters)
182
+ contract_text = render_human(contract)
183
+ annotated_source = build_annotated_source(source, function, contract)
184
+ except ContractError as error:
185
+ return CheckResult(Status.ERROR, f"invalid contract: {error}")
186
+
187
+ try:
188
+ with tempfile.TemporaryDirectory(prefix="proofside-") as directory:
189
+ verification_path = Path(directory, f"{file_path.stem}_verification.py")
190
+ verification_path.write_text(annotated_source, encoding="utf-8")
191
+ result = run_nagini(verification_path, function_name)
192
+ except OSError as error:
193
+ return CheckResult(Status.ERROR, f"could not create temporary verification source: {error}")
194
+
195
+ return CheckResult(result.status, result.detail, contract_text)
196
+
197
+
198
+ def render_proof_boundary(result: CheckResult) -> str:
199
+ if result.contract_text is None:
200
+ return ""
201
+ if result.status is Status.VERIFIED:
202
+ lines = [
203
+ "Proof boundary",
204
+ "- The selected implementation satisfies the displayed guarantees under the displayed assumptions, according to Nagini/Viper.",
205
+ "- Preconditions remain assumptions about valid callers and inputs.",
206
+ "- The proof covers only the displayed contract and the supported verification semantics.",
207
+ "- It does not establish scientific validity, empirical correspondence, usefulness, or optimality.",
208
+ ]
209
+ return "\n".join(lines)
210
+ if result.status is Status.FAILED:
211
+ lines = [
212
+ "Proof boundary",
213
+ "- One or more displayed proof obligations were not established.",
214
+ "- FAILED does not by itself mean that a concrete counterexample was produced.",
215
+ "- No conclusion about scientific validity follows from this failed proof attempt.",
216
+ ]
217
+ return "\n".join(lines)
218
+ return ""
219
+
220
+
221
+ def render_output(result: CheckResult) -> str:
222
+ sections = []
223
+ if result.contract_text:
224
+ sections.append(f"Contract\n\n{result.contract_text}")
225
+ status = result.status.value
226
+ if result.detail:
227
+ status += f"\n{result.detail}"
228
+ sections.append(status)
229
+ boundary = render_proof_boundary(result)
230
+ if boundary:
231
+ sections.append(boundary)
232
+ return "\n\n".join(sections)
233
+
234
+
235
+ def main(argv: list[str] | None = None) -> int:
236
+ parser = argparse.ArgumentParser(
237
+ prog="proofside",
238
+ description="State the math, review the contract, and verify typed Python.",
239
+ )
240
+ subparsers = parser.add_subparsers(dest="command", required=True)
241
+ check_parser = subparsers.add_parser(
242
+ "check",
243
+ help="verify one contracted top-level function",
244
+ description=(
245
+ "Verify one typed top-level function with a JSON sidecar contract, "
246
+ "or with handwritten Nagini contracts when --contract is omitted."
247
+ ),
248
+ )
249
+ check_parser.add_argument("selector", help="path/to/file.py::function_name")
250
+ check_parser.add_argument(
251
+ "--contract",
252
+ type=Path,
253
+ help="structured JSON sidecar; omit for handwritten Nagini contracts",
254
+ )
255
+ check_all_parser = subparsers.add_parser(
256
+ "check-all",
257
+ help="verify Proofside-marked functions using source-adjacent contracts",
258
+ description=(
259
+ "Independently verify marked functions with accepted source-adjacent "
260
+ "contracts. This command does not propose or accept contracts."
261
+ ),
262
+ )
263
+ check_all_parser.add_argument("targets", nargs="+", type=Path, help="Python files or directories")
264
+ check_all_parser.add_argument(
265
+ "--allow-unreviewed",
266
+ action="store_true",
267
+ help="fall back to candidate contracts; still returns a nonzero exit status",
268
+ )
269
+ propose_parser = subparsers.add_parser(
270
+ "propose",
271
+ help="ask an explicitly selected model for an unverified candidate contract",
272
+ description=(
273
+ "Ask an explicitly selected model for one unverified candidate. "
274
+ "This command never accepts the candidate or runs verification."
275
+ ),
276
+ )
277
+ propose_parser.add_argument("selector", help="path/to/file.py::function_name")
278
+ propose_parser.add_argument(
279
+ "--model-source",
280
+ choices=("api", "local"),
281
+ required=True,
282
+ help="api sends selected context remotely; local sends it to the chosen local endpoint",
283
+ )
284
+ propose_parser.add_argument("--model", required=True, help="explicit model name")
285
+ propose_parser.add_argument(
286
+ "--out",
287
+ type=Path,
288
+ help="new candidate JSON path; defaults beside the source in .proofside/",
289
+ )
290
+ propose_parser.add_argument(
291
+ "--base-url",
292
+ help="OpenAI-compatible base URL; API is remote and local defaults to localhost",
293
+ )
294
+ propose_parser.add_argument(
295
+ "--api-key-env",
296
+ help="credential environment-variable name required for a custom API base URL",
297
+ )
298
+ propose_parser.add_argument(
299
+ "--source",
300
+ action="append",
301
+ choices=("equation", "intent", "implementation"),
302
+ help="repeatable specification source; defaults to available equation/intent annotations",
303
+ )
304
+ propose_all_parser = subparsers.add_parser(
305
+ "propose-all",
306
+ help="propose candidates with up to one model request per marked function",
307
+ description=(
308
+ "Independently propose unverified candidates for marked functions, "
309
+ "using deterministic source-adjacent paths. Nothing is accepted or verified."
310
+ ),
311
+ )
312
+ propose_all_parser.add_argument("targets", nargs="+", type=Path, help="Python files or directories")
313
+ propose_all_parser.add_argument(
314
+ "--model-source",
315
+ choices=("api", "local"),
316
+ required=True,
317
+ help="api sends selected context remotely; local sends it to the chosen local endpoint",
318
+ )
319
+ propose_all_parser.add_argument("--model", required=True, help="explicit model name")
320
+ propose_all_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
321
+ propose_all_parser.add_argument(
322
+ "--api-key-env",
323
+ help="credential environment-variable name required for a custom API base URL",
324
+ )
325
+ propose_all_parser.add_argument(
326
+ "--source",
327
+ action="append",
328
+ choices=("equation", "intent", "implementation"),
329
+ help="repeatable specification source; defaults per function to equation/intent annotations",
330
+ )
331
+ accept_parser = subparsers.add_parser(
332
+ "accept",
333
+ help="explicitly accept a candidate contract for later verification",
334
+ description=(
335
+ "Validate a candidate and record it as accepted for verification. "
336
+ "Acceptance does not assert correctness or run verification."
337
+ ),
338
+ )
339
+ accept_parser.add_argument("selector", help="path/to/file.py::function_name")
340
+ accept_parser.add_argument(
341
+ "--candidate",
342
+ type=Path,
343
+ help="candidate JSON path; defaults beside the source in .proofside/",
344
+ )
345
+ accept_parser.add_argument(
346
+ "--replace",
347
+ action="store_true",
348
+ help="replace an existing accepted contract after successful validation",
349
+ )
350
+ arguments = parser.parse_args(argv)
351
+
352
+ if arguments.command == "check":
353
+ result = check(arguments.selector, arguments.contract)
354
+ print(render_output(result))
355
+ return 0 if result.status is Status.VERIFIED else 1
356
+
357
+ if arguments.command == "check-all":
358
+ from .batch import batch_succeeded, render_batch_output, run_batch_checks
359
+
360
+ results, discovery_errors = run_batch_checks(
361
+ tuple(arguments.targets),
362
+ arguments.allow_unreviewed,
363
+ )
364
+ print(render_batch_output(results, discovery_errors))
365
+ return 0 if batch_succeeded(results, discovery_errors) else 1
366
+
367
+ if arguments.command == "propose-all":
368
+ from .batch import (
369
+ batch_proposal_succeeded,
370
+ render_batch_proposal_output,
371
+ run_batch_proposals,
372
+ )
373
+
374
+ results, discovery_errors = run_batch_proposals(
375
+ tuple(arguments.targets),
376
+ arguments.model_source,
377
+ arguments.model,
378
+ arguments.base_url,
379
+ arguments.api_key_env,
380
+ tuple(arguments.source) if arguments.source else None,
381
+ )
382
+ print(render_batch_proposal_output(results, discovery_errors))
383
+ return 0 if batch_proposal_succeeded(results, discovery_errors) else 1
384
+
385
+ if arguments.command == "accept":
386
+ from .acceptance import AcceptanceError, accept_contract, render_acceptance_output
387
+
388
+ try:
389
+ contract_text, candidate_path, accepted_path = accept_contract(
390
+ arguments.selector,
391
+ arguments.candidate,
392
+ arguments.replace,
393
+ )
394
+ except AcceptanceError as error:
395
+ print("ACCEPTANCE REJECTED")
396
+ print(error)
397
+ return 1
398
+ print(render_acceptance_output(contract_text, candidate_path, accepted_path))
399
+ return 0
400
+
401
+ from .proposal import ProposalError, propose_contract, render_proposal_output
402
+
403
+ try:
404
+ contract_text, sources = propose_contract(
405
+ arguments.selector,
406
+ arguments.model_source,
407
+ arguments.model,
408
+ arguments.out,
409
+ arguments.base_url,
410
+ arguments.api_key_env,
411
+ tuple(arguments.source) if arguments.source else None,
412
+ )
413
+ except ProposalError as error:
414
+ print("PROPOSAL REJECTED")
415
+ print(error)
416
+ return 1
417
+ output_path = arguments.out
418
+ if output_path is None:
419
+ file_path, function_name = parse_selector(arguments.selector)
420
+ output_path = candidate_contract_path(file_path, function_name)
421
+ print(render_proposal_output(arguments.selector, output_path, contract_text, sources))
422
+ return 0
423
+