serenecode 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.
Files changed (39) hide show
  1. serenecode/__init__.py +281 -0
  2. serenecode/adapters/__init__.py +6 -0
  3. serenecode/adapters/coverage_adapter.py +1173 -0
  4. serenecode/adapters/crosshair_adapter.py +1069 -0
  5. serenecode/adapters/hypothesis_adapter.py +1824 -0
  6. serenecode/adapters/local_fs.py +169 -0
  7. serenecode/adapters/module_loader.py +492 -0
  8. serenecode/adapters/mypy_adapter.py +161 -0
  9. serenecode/checker/__init__.py +6 -0
  10. serenecode/checker/compositional.py +2216 -0
  11. serenecode/checker/coverage.py +186 -0
  12. serenecode/checker/properties.py +154 -0
  13. serenecode/checker/structural.py +1504 -0
  14. serenecode/checker/symbolic.py +178 -0
  15. serenecode/checker/types.py +148 -0
  16. serenecode/cli.py +478 -0
  17. serenecode/config.py +711 -0
  18. serenecode/contracts/__init__.py +6 -0
  19. serenecode/contracts/predicates.py +176 -0
  20. serenecode/core/__init__.py +6 -0
  21. serenecode/core/exceptions.py +38 -0
  22. serenecode/core/pipeline.py +807 -0
  23. serenecode/init.py +307 -0
  24. serenecode/models.py +308 -0
  25. serenecode/ports/__init__.py +6 -0
  26. serenecode/ports/coverage_analyzer.py +124 -0
  27. serenecode/ports/file_system.py +95 -0
  28. serenecode/ports/property_tester.py +69 -0
  29. serenecode/ports/symbolic_checker.py +70 -0
  30. serenecode/ports/type_checker.py +66 -0
  31. serenecode/reporter.py +346 -0
  32. serenecode/source_discovery.py +319 -0
  33. serenecode/templates/__init__.py +5 -0
  34. serenecode/templates/content.py +337 -0
  35. serenecode-0.1.0.dist-info/METADATA +298 -0
  36. serenecode-0.1.0.dist-info/RECORD +39 -0
  37. serenecode-0.1.0.dist-info/WHEEL +4 -0
  38. serenecode-0.1.0.dist-info/entry_points.txt +2 -0
  39. serenecode-0.1.0.dist-info/licenses/LICENSE +21 -0
serenecode/cli.py ADDED
@@ -0,0 +1,478 @@
1
+ """CLI entry point for Serenecode.
2
+
3
+ This module is the composition root for the command-line interface.
4
+ It wires adapters to ports and delegates to core logic. As a thin
5
+ adapter layer, it is exempt from full contract requirements but
6
+ must have type annotations and pass mypy.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import time
13
+
14
+ import click
15
+ import icontract
16
+
17
+ from serenecode.adapters.local_fs import LocalFileReader, LocalFileWriter
18
+ from serenecode.config import parse_serenecode_md
19
+ from serenecode.contracts.predicates import (
20
+ is_non_empty_string,
21
+ is_positive_int,
22
+ is_valid_exit_code,
23
+ is_valid_template_name,
24
+ is_valid_verification_level,
25
+ )
26
+ from serenecode.core.pipeline import run_pipeline
27
+ from serenecode.init import initialize_project
28
+ from serenecode.models import CheckResult, ExitCode
29
+ from serenecode.reporter import format_html, format_human, format_json
30
+ from serenecode.source_discovery import build_source_files, find_serenecode_md
31
+
32
+ _TRUST_REQUIRED_MESSAGE = (
33
+ "Levels 3-6 import and execute project modules. "
34
+ "Re-run with --allow-code-execution only for trusted code."
35
+ )
36
+
37
+
38
+ @click.group()
39
+ @icontract.ensure(lambda result: result is None, "CLI entrypoint returns None")
40
+ def main() -> None:
41
+ """Serenecode — formal verification for AI-generated Python code."""
42
+
43
+
44
+ @main.command()
45
+ @click.option("--strict", "template", flag_value="strict", help="Use strict template (all rules mandatory)")
46
+ @click.option("--minimal", "template", flag_value="minimal", help="Use minimal template (contracts + types only)")
47
+ @click.argument("path", default=".")
48
+ @icontract.require(
49
+ lambda template: template is None or is_valid_template_name(template),
50
+ "template must be a recognized template name when provided",
51
+ )
52
+ @icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
53
+ @icontract.ensure(lambda result: result is None, "CLI commands return None")
54
+ def init(template: str | None, path: str) -> None:
55
+ """Initialize a Serenecode project."""
56
+ if template is None:
57
+ template = "default"
58
+ reader = LocalFileReader()
59
+ writer = LocalFileWriter()
60
+
61
+ def confirm(message: str) -> bool:
62
+ return click.confirm(message, default=True)
63
+
64
+ result = initialize_project(
65
+ directory=path,
66
+ template=template,
67
+ file_reader=reader,
68
+ file_writer=writer,
69
+ confirm_callback=confirm,
70
+ )
71
+
72
+ if result.serenecode_md_created:
73
+ click.echo(f"Created SERENECODE.md ({template} template)")
74
+ if result.claude_md_created:
75
+ click.echo("Created CLAUDE.md with Serenecode directive")
76
+ if result.claude_md_updated:
77
+ click.echo("Updated CLAUDE.md with Serenecode directive")
78
+
79
+ click.echo("Serenecode project initialized.")
80
+
81
+
82
+ @main.command()
83
+ @click.argument("path", default=".")
84
+ @click.option("--level", type=click.IntRange(1, 6), default=None, help="Verification level (1-6, default: from config template)")
85
+ @click.option(
86
+ "--format",
87
+ "output_format",
88
+ type=click.Choice(["human", "json"]),
89
+ default="human",
90
+ help="Output format",
91
+ )
92
+ @click.option("--structural", is_flag=True, help="Run only structural check (Level 1)")
93
+ @click.option("--verify", is_flag=True, help="Run Levels 3-6 only")
94
+ @click.option("--per-condition-timeout", type=int, default=30, show_default=True, help="Timeout in seconds per condition for symbolic verification (Level 5)")
95
+ @click.option("--per-path-timeout", type=int, default=10, show_default=True, help="Timeout in seconds per execution path for symbolic verification (Level 5)")
96
+ @click.option("--module-timeout", type=int, default=300, show_default=True, help="Timeout in seconds per module for symbolic verification (Level 5)")
97
+ @click.option("--workers", type=int, default=4, show_default=True, help="Number of parallel workers for symbolic verification (Level 5)")
98
+ @click.option(
99
+ "--allow-code-execution",
100
+ is_flag=True,
101
+ help="Allow Levels 3-6 to import and execute project modules",
102
+ )
103
+ @icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
104
+ @icontract.require(
105
+ lambda level: level is None or is_valid_verification_level(level),
106
+ "level must be between 1 and 6 when provided",
107
+ )
108
+ @icontract.require(
109
+ lambda output_format: output_format in {"human", "json"},
110
+ "output_format must be human or json",
111
+ )
112
+ @icontract.require(
113
+ lambda per_condition_timeout: is_positive_int(per_condition_timeout),
114
+ "per_condition_timeout must be at least 1",
115
+ )
116
+ @icontract.require(
117
+ lambda per_path_timeout: is_positive_int(per_path_timeout),
118
+ "per_path_timeout must be at least 1",
119
+ )
120
+ @icontract.require(
121
+ lambda module_timeout: is_positive_int(module_timeout),
122
+ "module_timeout must be at least 1",
123
+ )
124
+ @icontract.require(
125
+ lambda workers: is_positive_int(workers),
126
+ "workers must be at least 1",
127
+ )
128
+ @icontract.ensure(lambda result: result is None, "CLI commands return None")
129
+ def check(
130
+ path: str,
131
+ level: int | None,
132
+ output_format: str,
133
+ structural: bool,
134
+ verify: bool,
135
+ per_condition_timeout: int,
136
+ per_path_timeout: int,
137
+ module_timeout: int,
138
+ workers: int,
139
+ allow_code_execution: bool,
140
+ ) -> None:
141
+ """Run verification checks on Python source files."""
142
+ wall_start = time.monotonic()
143
+ reader = LocalFileReader()
144
+
145
+ # Load config first (needed to resolve default level)
146
+ serenecode_md_path = find_serenecode_md(path, reader)
147
+ if serenecode_md_path:
148
+ config_content = reader.read_file(serenecode_md_path)
149
+ config = parse_serenecode_md(config_content)
150
+ else:
151
+ from serenecode.config import default_config
152
+ config = default_config()
153
+ click.echo("Warning: No SERENECODE.md found, using default configuration.", err=True)
154
+
155
+ # Determine effective level
156
+ if structural:
157
+ effective_level = 1
158
+ elif level is not None:
159
+ effective_level = level
160
+ if verify:
161
+ effective_level = max(effective_level, 3)
162
+ else:
163
+ effective_level = config.recommended_level
164
+ if verify:
165
+ effective_level = max(effective_level, 3)
166
+ level = effective_level
167
+ start_level = 3 if verify and not structural else 1
168
+
169
+ if level >= 3 and not allow_code_execution:
170
+ click.echo(f"Error: {_TRUST_REQUIRED_MESSAGE}", err=True)
171
+ sys.exit(ExitCode.INTERNAL)
172
+
173
+ # List files
174
+ try:
175
+ files = reader.list_python_files(path)
176
+ except Exception as exc:
177
+ click.echo(f"Error: {exc}", err=True)
178
+ sys.exit(ExitCode.INTERNAL)
179
+
180
+ if not files:
181
+ click.echo("No Python files found.")
182
+ sys.exit(ExitCode.PASSED)
183
+
184
+ # Build source file objects
185
+ try:
186
+ source_files = build_source_files(files, reader, path)
187
+ except Exception as exc:
188
+ click.echo(f"Error: {exc}", err=True)
189
+ sys.exit(ExitCode.INTERNAL)
190
+
191
+ # Wire up adapters for higher levels
192
+ type_checker = None
193
+ coverage_analyzer = None
194
+ property_tester = None
195
+ symbolic_checker = None
196
+
197
+ if level >= 2:
198
+ try:
199
+ from serenecode.adapters.mypy_adapter import MypyTypeChecker
200
+ type_checker = MypyTypeChecker()
201
+ except ImportError:
202
+ click.echo("Warning: mypy not available for Level 2 checks.", err=True)
203
+
204
+ if level >= 3:
205
+ try:
206
+ from serenecode.adapters.coverage_adapter import CoverageAnalyzerAdapter
207
+ coverage_analyzer = CoverageAnalyzerAdapter(allow_code_execution=True)
208
+ except ImportError:
209
+ click.echo("Warning: coverage not available for Level 3 checks.", err=True)
210
+
211
+ if level >= 4:
212
+ try:
213
+ from serenecode.adapters.hypothesis_adapter import HypothesisPropertyTester
214
+ property_tester = HypothesisPropertyTester(allow_code_execution=True)
215
+ except ImportError:
216
+ click.echo("Warning: Hypothesis not available for Level 4 checks.", err=True)
217
+
218
+ if level >= 5:
219
+ try:
220
+ from serenecode.adapters.crosshair_adapter import CrossHairSymbolicChecker
221
+ symbolic_checker = CrossHairSymbolicChecker(
222
+ per_condition_timeout=per_condition_timeout,
223
+ per_path_timeout=per_path_timeout,
224
+ module_timeout=module_timeout,
225
+ allow_code_execution=True,
226
+ )
227
+ except ImportError:
228
+ click.echo("Warning: CrossHair not available for Level 5 checks.", err=True)
229
+
230
+ # Run pipeline with progress callback
231
+ def _progress(msg: str) -> None:
232
+ click.echo(msg, err=True)
233
+
234
+ final_result = run_pipeline(
235
+ source_files=source_files,
236
+ level=level,
237
+ start_level=start_level,
238
+ config=config,
239
+ type_checker=type_checker,
240
+ coverage_analyzer=coverage_analyzer,
241
+ property_tester=property_tester,
242
+ symbolic_checker=symbolic_checker,
243
+ progress=_progress,
244
+ max_workers=workers,
245
+ )
246
+
247
+ # Format and output
248
+ if output_format == "json":
249
+ click.echo(format_json(final_result))
250
+ else:
251
+ click.echo(format_human(final_result))
252
+
253
+ wall_elapsed = time.monotonic() - wall_start
254
+ minutes, seconds = divmod(wall_elapsed, 60)
255
+ if minutes >= 1:
256
+ click.echo(f"Total wall time: {int(minutes)}m {seconds:.1f}s", err=True)
257
+ else:
258
+ click.echo(f"Total wall time: {seconds:.1f}s", err=True)
259
+
260
+ # Exit with appropriate code
261
+ if final_result.passed:
262
+ sys.exit(ExitCode.PASSED)
263
+ else:
264
+ # Find the lowest failing level from the results
265
+ exit_code = _determine_exit_code(final_result)
266
+ sys.exit(exit_code)
267
+
268
+
269
+ @main.command()
270
+ @click.argument("path", default=".")
271
+ @click.option(
272
+ "--format",
273
+ "output_format",
274
+ type=click.Choice(["human", "json"]),
275
+ default="human",
276
+ help="Output format",
277
+ )
278
+ @icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
279
+ @icontract.require(
280
+ lambda output_format: output_format in {"human", "json"},
281
+ "output_format must be human or json",
282
+ )
283
+ @icontract.ensure(lambda result: result is None, "CLI commands return None")
284
+ def status(path: str, output_format: str) -> None:
285
+ """Show verification status of the codebase."""
286
+ reader = LocalFileReader()
287
+
288
+ # Load config
289
+ serenecode_md_path = find_serenecode_md(path, reader)
290
+ if serenecode_md_path:
291
+ config_content = reader.read_file(serenecode_md_path)
292
+ config = parse_serenecode_md(config_content)
293
+ else:
294
+ from serenecode.config import default_config
295
+ config = default_config()
296
+
297
+ # List and check files
298
+ try:
299
+ files = reader.list_python_files(path)
300
+ except Exception as exc:
301
+ click.echo(f"Error: {exc}", err=True)
302
+ sys.exit(ExitCode.INTERNAL)
303
+
304
+ if not files:
305
+ click.echo("No Python files found.")
306
+ return
307
+
308
+ try:
309
+ source_files = build_source_files(files, reader, path)
310
+ except Exception as exc:
311
+ click.echo(f"Error: {exc}", err=True)
312
+ sys.exit(ExitCode.INTERNAL)
313
+ result = run_pipeline(source_files, level=1, start_level=1, config=config)
314
+
315
+ if output_format == "json":
316
+ click.echo(format_json(result))
317
+ else:
318
+ click.echo(format_human(result))
319
+
320
+
321
+ @main.command()
322
+ @click.argument("path", default=".")
323
+ @click.option(
324
+ "--format",
325
+ "output_format",
326
+ type=click.Choice(["human", "json", "html"]),
327
+ default="human",
328
+ help="Report format",
329
+ )
330
+ @click.option("--output", "output_file", default=None, help="Write report to file")
331
+ @click.option(
332
+ "--allow-code-execution",
333
+ is_flag=True,
334
+ help="Allow deep reports to import and execute project modules",
335
+ )
336
+ @icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
337
+ @icontract.require(
338
+ lambda output_format: output_format in {"human", "json", "html"},
339
+ "output_format must be human, json, or html",
340
+ )
341
+ @icontract.require(
342
+ lambda output_file: output_file is None or is_non_empty_string(output_file),
343
+ "output_file must be a non-empty string when provided",
344
+ )
345
+ @icontract.ensure(lambda result: result is None, "CLI commands return None")
346
+ def report(
347
+ path: str,
348
+ output_format: str,
349
+ output_file: str | None,
350
+ allow_code_execution: bool,
351
+ ) -> None:
352
+ """Generate a verification report for the entire project."""
353
+ reader = LocalFileReader()
354
+
355
+ # Load config
356
+ serenecode_md_path = find_serenecode_md(path, reader)
357
+ if serenecode_md_path:
358
+ config_content = reader.read_file(serenecode_md_path)
359
+ config = parse_serenecode_md(config_content)
360
+ else:
361
+ from serenecode.config import default_config
362
+ config = default_config()
363
+
364
+ # List and check files
365
+ try:
366
+ files = reader.list_python_files(path)
367
+ except Exception as exc:
368
+ click.echo(f"Error: {exc}", err=True)
369
+ sys.exit(ExitCode.INTERNAL)
370
+
371
+ if not files:
372
+ click.echo("No Python files found.")
373
+ return
374
+
375
+ try:
376
+ source_files = build_source_files(files, reader, path)
377
+ except Exception as exc:
378
+ click.echo(f"Error: {exc}", err=True)
379
+ sys.exit(ExitCode.INTERNAL)
380
+ # Reports use the project's recommended verification depth rather than
381
+ # silently truncating to structural checks only.
382
+ level = config.recommended_level
383
+ if level >= 3 and not allow_code_execution:
384
+ click.echo(f"Error: {_TRUST_REQUIRED_MESSAGE}", err=True)
385
+ sys.exit(ExitCode.INTERNAL)
386
+ type_checker = None
387
+ coverage_analyzer = None
388
+ property_tester = None
389
+ symbolic_checker = None
390
+
391
+ if level >= 2:
392
+ try:
393
+ from serenecode.adapters.mypy_adapter import MypyTypeChecker
394
+ type_checker = MypyTypeChecker()
395
+ except ImportError:
396
+ pass
397
+
398
+ if level >= 3:
399
+ try:
400
+ from serenecode.adapters.coverage_adapter import CoverageAnalyzerAdapter
401
+ coverage_analyzer = CoverageAnalyzerAdapter(allow_code_execution=True)
402
+ except ImportError:
403
+ click.echo("Warning: coverage not available for Level 3 checks.", err=True)
404
+
405
+ if level >= 4:
406
+ try:
407
+ from serenecode.adapters.hypothesis_adapter import HypothesisPropertyTester
408
+ property_tester = HypothesisPropertyTester(allow_code_execution=True)
409
+ except ImportError:
410
+ pass
411
+
412
+ if level >= 5:
413
+ try:
414
+ from serenecode.adapters.crosshair_adapter import CrossHairSymbolicChecker
415
+ symbolic_checker = CrossHairSymbolicChecker(allow_code_execution=True)
416
+ except ImportError:
417
+ pass
418
+
419
+ final_result = run_pipeline(
420
+ source_files,
421
+ level=level,
422
+ start_level=1,
423
+ config=config,
424
+ type_checker=type_checker,
425
+ coverage_analyzer=coverage_analyzer,
426
+ property_tester=property_tester,
427
+ symbolic_checker=symbolic_checker,
428
+ )
429
+
430
+ # Format output
431
+ if output_format == "json":
432
+ formatted = format_json(final_result)
433
+ elif output_format == "html":
434
+ formatted = format_html(final_result)
435
+ else:
436
+ formatted = format_human(final_result)
437
+
438
+ # Write to file or stdout
439
+ if output_file:
440
+ writer = LocalFileWriter()
441
+ writer.write_file(output_file, formatted)
442
+ click.echo(f"Report written to {output_file}")
443
+ else:
444
+ click.echo(formatted)
445
+
446
+
447
+ @icontract.require(lambda check_result: check_result is not None, "result must be provided")
448
+ @icontract.ensure(lambda result: is_valid_exit_code(result), "exit code must be valid")
449
+ def _determine_exit_code(check_result: CheckResult) -> int:
450
+ """Determine the CLI exit code from a failed CheckResult.
451
+
452
+ Uses the verification level of the first failure to determine
453
+ the appropriate exit code per spec Section 4.2.
454
+
455
+ Args:
456
+ check_result: A CheckResult with failures.
457
+
458
+ Returns:
459
+ An exit code integer (1-6 or 10).
460
+ """
461
+ from serenecode.models import CheckStatus
462
+
463
+ # Find the lowest failing level across all failed results
464
+ min_level = 10 # start above any valid level
465
+ # Loop invariant: min_level is the lowest failure level seen in results[0..i]
466
+ for func_result in check_result.results:
467
+ if func_result.status == CheckStatus.FAILED:
468
+ # Loop invariant: checked details[0..j] for level
469
+ for detail in func_result.details:
470
+ level_val = detail.level.value
471
+ if 1 <= level_val <= 6 and level_val < min_level:
472
+ min_level = level_val
473
+
474
+ if min_level <= 6:
475
+ return min_level
476
+ if check_result.level_achieved < check_result.level_requested:
477
+ return min(check_result.level_achieved + 1, ExitCode.COMPOSITIONAL)
478
+ return ExitCode.STRUCTURAL # default to structural