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/reporter.py ADDED
@@ -0,0 +1,346 @@
1
+ """Report generation for Serenecode verification results.
2
+
3
+ This module provides pure formatting functions that convert CheckResult
4
+ objects into human-readable terminal output or JSON strings matching
5
+ the spec output format.
6
+
7
+ This is a core module — no I/O imports are permitted.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from datetime import datetime, timezone
14
+
15
+ import icontract
16
+
17
+ from serenecode.models import CheckResult, CheckStatus, FunctionResult
18
+
19
+
20
+ @icontract.require(
21
+ lambda check_result: isinstance(check_result, CheckResult),
22
+ "check_result must be a CheckResult",
23
+ )
24
+ @icontract.ensure(
25
+ lambda result: isinstance(result, str),
26
+ "output must be a string",
27
+ )
28
+ def format_human(check_result: CheckResult) -> str:
29
+ """Format a CheckResult as human-readable terminal output.
30
+
31
+ Condenses passing files into a single summary line and only
32
+ expands files that contain failures or skips with details.
33
+
34
+ Args:
35
+ check_result: The verification result to format.
36
+
37
+ Returns:
38
+ A formatted string suitable for terminal display.
39
+ """
40
+ lines: list[str] = []
41
+
42
+ # Header
43
+ status_marker = "PASSED" if check_result.passed else "FAILED"
44
+ lines.append(f"Serenecode Check — {status_marker}")
45
+ lines.append("=" * 50)
46
+ lines.append("")
47
+
48
+ # Group results by file
49
+ by_file: dict[str, list[FunctionResult]] = {}
50
+ # Loop invariant: by_file contains all results from results[0..i] grouped by file
51
+ for func_result in check_result.results:
52
+ by_file.setdefault(func_result.file, []).append(func_result)
53
+
54
+ # Loop invariant: lines contains formatted output for all files processed so far
55
+ for file_path, func_results in sorted(by_file.items()):
56
+ passed = [r for r in func_results if r.status == CheckStatus.PASSED]
57
+ failed = [r for r in func_results if r.status == CheckStatus.FAILED]
58
+ skipped = [r for r in func_results if r.status == CheckStatus.SKIPPED]
59
+ exempt = [r for r in func_results if r.status == CheckStatus.EXEMPT]
60
+
61
+ # Compact summary for all-pass files
62
+ if not failed and not skipped and not exempt:
63
+ lines.append(f" {file_path} — {len(passed)} passed")
64
+ continue
65
+
66
+ # Compact summary for exempt-only files
67
+ if not failed and not skipped and not passed:
68
+ lines.append(f" {file_path} — exempt")
69
+ continue
70
+
71
+ # File header with counts
72
+ parts = []
73
+ if passed:
74
+ parts.append(f"{len(passed)} passed")
75
+ if failed:
76
+ parts.append(f"{len(failed)} failed")
77
+ if skipped:
78
+ parts.append(f"{len(skipped)} skipped")
79
+ if exempt:
80
+ parts.append(f"{len(exempt)} exempt")
81
+ lines.append(f" {file_path} — {', '.join(parts)}")
82
+
83
+ # Only show non-passing results with details
84
+ # Loop invariant: lines contains output for non-passing func_results[0..j]
85
+ for func_result in func_results:
86
+ if func_result.status in (CheckStatus.PASSED, CheckStatus.EXEMPT):
87
+ continue
88
+ marker = "FAIL" if func_result.status == CheckStatus.FAILED else "SKIP"
89
+ lines.append(f" [{marker}] {func_result.function} (line {func_result.line})")
90
+
91
+ # Loop invariant: lines contains all details for details[0..k]
92
+ for detail in func_result.details:
93
+ lines.append(f" {detail.message}")
94
+ if detail.suggestion:
95
+ lines.append(f" -> {detail.suggestion}")
96
+ if detail.counterexample:
97
+ lines.append(f" counterexample: {detail.counterexample}")
98
+
99
+ lines.append("")
100
+
101
+ # Summary
102
+ lines.append("-" * 50)
103
+ summary = check_result.summary
104
+ summary_parts = [
105
+ f"{summary.total_functions} checked",
106
+ f"{summary.passed_count} passed",
107
+ f"{summary.failed_count} failed",
108
+ f"{summary.skipped_count} skipped",
109
+ ]
110
+ if summary.exempt_count > 0:
111
+ summary_parts.append(f"{summary.exempt_count} exempt")
112
+ lines.append(", ".join(summary_parts))
113
+ lines.append(f"Duration: {summary.duration_seconds:.3f}s")
114
+
115
+ return "\n".join(lines)
116
+
117
+
118
+ @icontract.require(
119
+ lambda check_result: isinstance(check_result, CheckResult),
120
+ "check_result must be a CheckResult",
121
+ )
122
+ @icontract.ensure(
123
+ lambda result: isinstance(result, str),
124
+ "output must be a string",
125
+ )
126
+ def format_json(check_result: CheckResult) -> str:
127
+ """Format a CheckResult as JSON matching the spec Section 4.3 format.
128
+
129
+ Args:
130
+ check_result: The verification result to format.
131
+
132
+ Returns:
133
+ A JSON string matching the specification output format.
134
+ """
135
+ timestamp = datetime.now(timezone.utc).isoformat()
136
+ base = check_result.to_dict()
137
+
138
+ output: dict[str, object] = {
139
+ "version": base["version"],
140
+ "timestamp": timestamp,
141
+ "passed": base["passed"],
142
+ "level_requested": base["level_requested"],
143
+ "level_achieved": base["level_achieved"],
144
+ "summary": base["summary"],
145
+ "results": base["results"],
146
+ }
147
+
148
+ return json.dumps(output, indent=2)
149
+
150
+
151
+ @icontract.require(
152
+ lambda check_result: isinstance(check_result, CheckResult),
153
+ "check_result must be a CheckResult",
154
+ )
155
+ @icontract.ensure(
156
+ lambda result: isinstance(result, str),
157
+ "output must be a string",
158
+ )
159
+ def format_html(check_result: CheckResult) -> str:
160
+ """Format a CheckResult as an HTML verification report.
161
+
162
+ Produces a self-contained HTML document with expandable sections,
163
+ verification level badges, and styled results suitable for
164
+ compliance documentation or CI/CD artifacts.
165
+
166
+ Args:
167
+ check_result: The verification result to format.
168
+
169
+ Returns:
170
+ A complete HTML document as a string.
171
+ """
172
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
173
+ status_class = "passed" if check_result.passed else "failed"
174
+ status_text = "PASSED" if check_result.passed else "FAILED"
175
+ summary = check_result.summary
176
+
177
+ # Group results by file
178
+ by_file: dict[str, list[FunctionResult]] = {}
179
+ # Loop invariant: by_file contains results grouped for results[0..i]
180
+ for func_result in check_result.results:
181
+ by_file.setdefault(func_result.file, []).append(func_result)
182
+
183
+ # Build file sections
184
+ file_sections: list[str] = []
185
+ # Loop invariant: file_sections contains HTML for files processed so far
186
+ for file_path, func_results in sorted(by_file.items()):
187
+ file_passed = all(r.status in (CheckStatus.PASSED, CheckStatus.EXEMPT) for r in func_results)
188
+ file_class = "passed" if file_passed else "failed"
189
+ file_icon = "✔" if file_passed else "✘"
190
+
191
+ rows: list[str] = []
192
+ # Loop invariant: rows contains table rows for func_results[0..j]
193
+ for fr in func_results:
194
+ row_class = "pass-row" if fr.status in (CheckStatus.PASSED, CheckStatus.EXEMPT) else "fail-row"
195
+ status_badge = _level_badge(fr.level_achieved)
196
+ detail_html = ""
197
+ if fr.details:
198
+ detail_parts: list[str] = []
199
+ # Loop invariant: detail_parts contains detail HTML for details[0..k]
200
+ for d in fr.details:
201
+ part = f'<div class="detail">{_escape_html(d.message)}'
202
+ if d.suggestion:
203
+ escaped_suggestion = _escape_html(d.suggestion)
204
+ if "\n" in d.suggestion:
205
+ part += f'<br><pre class="suggestion">{escaped_suggestion}</pre>'
206
+ else:
207
+ part += f'<br><span class="suggestion">&#x27A1; {escaped_suggestion}</span>'
208
+ if d.counterexample is not None and d.counterexample:
209
+ try:
210
+ ce_text = json.dumps(d.counterexample, default=str)
211
+ except (TypeError, ValueError):
212
+ ce_text = str(d.counterexample)
213
+ part += f'<br><span class="counterexample">Counterexample: {_escape_html(ce_text)}</span>'
214
+ part += "</div>"
215
+ detail_parts.append(part)
216
+ detail_html = "".join(detail_parts)
217
+
218
+ rows.append(
219
+ f'<tr class="{row_class}">'
220
+ f'<td>{_escape_html(fr.function)}</td>'
221
+ f"<td>{fr.line}</td>"
222
+ f"<td>{status_badge}</td>"
223
+ f"<td>{fr.status.value}</td>"
224
+ f"<td>{detail_html}</td>"
225
+ f"</tr>"
226
+ )
227
+
228
+ table_html = "\n".join(rows)
229
+ file_sections.append(
230
+ f'<details class="file-section {file_class}">'
231
+ f"<summary>{file_icon} {_escape_html(file_path)}</summary>"
232
+ f'<table class="results-table">'
233
+ f"<thead><tr><th>Function</th><th>Line</th><th>Level</th><th>Status</th><th>Details</th></tr></thead>"
234
+ f"<tbody>{table_html}</tbody>"
235
+ f"</table>"
236
+ f"</details>"
237
+ )
238
+
239
+ files_html = "\n".join(file_sections)
240
+
241
+ return f"""\
242
+ <!DOCTYPE html>
243
+ <html lang="en">
244
+ <head>
245
+ <meta charset="UTF-8">
246
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
247
+ <title>Serenecode Verification Report</title>
248
+ <style>
249
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; color: #24292f; background: #f6f8fa; }}
250
+ .header {{ background: #fff; border: 1px solid #d0d7de; border-radius: 6px; padding: 1.5rem; margin-bottom: 1.5rem; }}
251
+ .header h1 {{ margin: 0 0 0.5rem; font-size: 1.5rem; }}
252
+ .status {{ display: inline-block; padding: 0.25rem 0.75rem; border-radius: 20px; font-weight: 600; font-size: 0.9rem; }}
253
+ .status.passed {{ background: #dafbe1; color: #116329; }}
254
+ .status.failed {{ background: #ffebe9; color: #82071e; }}
255
+ .summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; margin-top: 1rem; }}
256
+ .summary-item {{ text-align: center; }}
257
+ .summary-item .number {{ font-size: 2rem; font-weight: 700; }}
258
+ .summary-item .label {{ color: #656d76; font-size: 0.85rem; }}
259
+ .file-section {{ background: #fff; border: 1px solid #d0d7de; border-radius: 6px; margin-bottom: 0.75rem; }}
260
+ .file-section summary {{ padding: 0.75rem 1rem; cursor: pointer; font-weight: 500; }}
261
+ .file-section.failed summary {{ color: #82071e; }}
262
+ .file-section.passed summary {{ color: #116329; }}
263
+ .results-table {{ width: 100%; border-collapse: collapse; margin: 0; }}
264
+ .results-table th {{ background: #f6f8fa; padding: 0.5rem; text-align: left; border-bottom: 1px solid #d0d7de; font-size: 0.85rem; }}
265
+ .results-table td {{ padding: 0.5rem; border-bottom: 1px solid #eee; font-size: 0.85rem; vertical-align: top; }}
266
+ .pass-row {{ background: #f0fff4; }}
267
+ .fail-row {{ background: #fff5f5; }}
268
+ .badge {{ display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.75rem; font-weight: 600; }}
269
+ .badge-1 {{ background: #ddf4ff; color: #0550ae; }}
270
+ .badge-2 {{ background: #dafbe1; color: #116329; }}
271
+ .badge-3 {{ background: #fff8c5; color: #4d2d00; }}
272
+ .badge-4 {{ background: #fbefff; color: #5e3a8a; }}
273
+ .badge-5 {{ background: #ffebe9; color: #82071e; }}
274
+ .badge-6 {{ background: #fff0f0; color: #6e0b14; }}
275
+ .detail {{ margin: 0.25rem 0; }}
276
+ .suggestion {{ color: #0550ae; font-style: italic; }}
277
+ pre.suggestion {{ background: #f6f8fa; padding: 0.5rem; border-radius: 4px; font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap; }}
278
+ .counterexample {{ color: #82071e; font-family: monospace; font-size: 0.8rem; }}
279
+ .footer {{ margin-top: 1.5rem; color: #656d76; font-size: 0.8rem; text-align: center; }}
280
+ </style>
281
+ </head>
282
+ <body>
283
+ <div class="header">
284
+ <h1>Serenecode Verification Report</h1>
285
+ <span class="status {status_class}">{status_text}</span>
286
+ <span style="margin-left: 1rem; color: #656d76;">Generated {timestamp}</span>
287
+ <div class="summary">
288
+ <div class="summary-item"><div class="number">{summary.total_functions}</div><div class="label">Total</div></div>
289
+ <div class="summary-item"><div class="number" style="color:#116329">{summary.passed_count}</div><div class="label">Passed</div></div>
290
+ <div class="summary-item"><div class="number" style="color:#82071e">{summary.failed_count}</div><div class="label">Failed</div></div>
291
+ <div class="summary-item"><div class="number" style="color:#656d76">{summary.skipped_count}</div><div class="label">Skipped</div></div>
292
+ <div class="summary-item"><div class="number" style="color:#8b6914">{summary.exempt_count}</div><div class="label">Exempt</div></div>
293
+ <div class="summary-item"><div class="number">{summary.duration_seconds:.2f}s</div><div class="label">Duration</div></div>
294
+ </div>
295
+ </div>
296
+ {files_html}
297
+ <div class="footer">
298
+ Serenecode v{check_result.version} &mdash; Formal verification for AI-generated Python code
299
+ </div>
300
+ </body>
301
+ </html>"""
302
+
303
+
304
+ @icontract.require(lambda level: isinstance(level, int), "level must be an integer")
305
+ @icontract.ensure(lambda result: isinstance(result, str), "result must be a string")
306
+ def _level_badge(level: int) -> str:
307
+ """Generate an HTML badge for a verification level.
308
+
309
+ Args:
310
+ level: The verification level (0-5).
311
+
312
+ Returns:
313
+ An HTML span element with the level badge.
314
+ """
315
+ level_names = {
316
+ 0: "None",
317
+ 1: "L1 Structural",
318
+ 2: "L2 Types",
319
+ 3: "L3 Coverage",
320
+ 4: "L4 Properties",
321
+ 5: "L5 Symbolic",
322
+ 6: "L6 Compositional",
323
+ }
324
+ name = level_names.get(level, f"L{level}")
325
+ badge_class = f"badge-{min(level, 6)}" if level > 0 else "badge-1"
326
+ return f'<span class="badge {badge_class}">{name}</span>'
327
+
328
+
329
+ @icontract.require(lambda text: isinstance(text, str), "text must be a string")
330
+ @icontract.ensure(lambda result: isinstance(result, str), "result must be a string")
331
+ def _escape_html(text: str) -> str:
332
+ """Escape HTML special characters.
333
+
334
+ Args:
335
+ text: Raw text to escape.
336
+
337
+ Returns:
338
+ HTML-safe text.
339
+ """
340
+ return (
341
+ text.replace("&", "&amp;")
342
+ .replace("<", "&lt;")
343
+ .replace(">", "&gt;")
344
+ .replace('"', "&quot;")
345
+ .replace("'", "&#x27;")
346
+ )
@@ -0,0 +1,319 @@
1
+ """Helpers for finding configuration files and building SourceFile objects.
2
+
3
+ This module keeps the CLI and library API in sync when they discover
4
+ source files, derive module references for higher verification levels,
5
+ and locate SERENECODE.md in parent directories.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import keyword
11
+ import os
12
+
13
+ import icontract
14
+
15
+ from serenecode.contracts.predicates import is_non_empty_string
16
+ from serenecode.core.exceptions import ConfigurationError
17
+ from serenecode.core.pipeline import SourceFile
18
+ from serenecode.ports.file_system import FileReader
19
+
20
+ _ARCHITECTURE_DIR_NAMES = frozenset({
21
+ "adapters",
22
+ "checker",
23
+ "contracts",
24
+ "core",
25
+ "ports",
26
+ })
27
+
28
+ _PROJECT_MARKERS = (
29
+ "SERENECODE.md",
30
+ "pyproject.toml",
31
+ ".git",
32
+ )
33
+
34
+
35
+ @icontract.require(
36
+ lambda search_root: is_non_empty_string(search_root),
37
+ "search_root must be a non-empty string",
38
+ )
39
+ @icontract.require(
40
+ lambda reader: reader is not None,
41
+ "reader must be provided",
42
+ )
43
+ @icontract.require(
44
+ lambda file_paths: all(is_non_empty_string(file_path) for file_path in file_paths),
45
+ "file_paths must contain only non-empty paths",
46
+ )
47
+ @icontract.ensure(
48
+ lambda result: isinstance(result, tuple),
49
+ "result must be a tuple",
50
+ )
51
+ def build_source_files(
52
+ file_paths: list[str],
53
+ reader: FileReader,
54
+ search_root: str,
55
+ ) -> tuple[SourceFile, ...]:
56
+ """Build SourceFile objects from file paths.
57
+
58
+ Args:
59
+ file_paths: Paths to Python files.
60
+ reader: File reader for reading contents.
61
+ search_root: Root path originally requested by the user.
62
+
63
+ Returns:
64
+ Tuple of SourceFile objects.
65
+
66
+ Raises:
67
+ ConfigurationError: If any discovered file cannot be read.
68
+ """
69
+ source_files: list[SourceFile] = []
70
+ normalized_root = _determine_context_root(search_root)
71
+
72
+ # Loop invariant: source_files contains SourceFile objects for file_paths[0..i]
73
+ for file_path in file_paths:
74
+ try:
75
+ source = reader.read_file(file_path)
76
+ except Exception as exc:
77
+ raise ConfigurationError(
78
+ f"Cannot prepare source file '{file_path}': {exc}"
79
+ ) from exc
80
+
81
+ source_files.append(SourceFile(
82
+ file_path=file_path,
83
+ module_path=_derive_module_path(file_path, normalized_root),
84
+ source=source,
85
+ importable_module=_derive_module_reference(file_path, normalized_root),
86
+ import_search_paths=_derive_import_search_paths(file_path, normalized_root),
87
+ ))
88
+
89
+ return tuple(source_files)
90
+
91
+
92
+ @icontract.require(
93
+ lambda path: is_non_empty_string(path),
94
+ "path must be a non-empty string",
95
+ )
96
+ @icontract.require(
97
+ lambda reader: reader is not None,
98
+ "reader must be provided",
99
+ )
100
+ @icontract.ensure(
101
+ lambda result: result is None or result.endswith("SERENECODE.md"),
102
+ "result must be a SERENECODE.md path when present",
103
+ )
104
+ def find_serenecode_md(path: str, reader: FileReader) -> str | None:
105
+ """Find SERENECODE.md by searching up from the given path."""
106
+ current = _normalize_search_root(path)
107
+
108
+ # Loop invariant: no ancestor directory checked so far contains SERENECODE.md
109
+ while True:
110
+ candidate = os.path.join(current, "SERENECODE.md")
111
+ if reader.file_exists(candidate):
112
+ return candidate
113
+ parent = os.path.dirname(current)
114
+ if parent == current:
115
+ break
116
+ current = parent
117
+
118
+ return None
119
+
120
+
121
+ @icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
122
+ @icontract.ensure(lambda result: is_non_empty_string(result), "result must be a non-empty string")
123
+ def _normalize_search_root(path: str) -> str:
124
+ """Normalize a user-supplied search path to a directory path."""
125
+ absolute = os.path.abspath(path)
126
+ if os.path.isdir(absolute):
127
+ return absolute
128
+ return os.path.dirname(absolute)
129
+
130
+
131
+ @icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
132
+ @icontract.ensure(lambda result: is_non_empty_string(result), "result must be a non-empty string")
133
+ def _determine_context_root(path: str) -> str:
134
+ """Determine the stable root used for module-path derivation."""
135
+ search_root = _normalize_search_root(path)
136
+
137
+ current = search_root
138
+ # Loop invariant: current is an ancestor of search_root already checked for markers
139
+ while True:
140
+ if _has_project_marker(current):
141
+ return current
142
+
143
+ if os.path.basename(current) == "src":
144
+ return os.path.dirname(current)
145
+
146
+ parent = os.path.dirname(current)
147
+ if parent == current:
148
+ break
149
+ current = parent
150
+
151
+ if os.path.isfile(os.path.join(search_root, "__init__.py")):
152
+ return _walk_package_root(search_root)
153
+
154
+ if os.path.basename(search_root) in _ARCHITECTURE_DIR_NAMES:
155
+ return os.path.dirname(search_root)
156
+
157
+ return search_root
158
+
159
+
160
+ @icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
161
+ @icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
162
+ def _has_project_marker(path: str) -> bool:
163
+ """Check whether a directory looks like a project root."""
164
+ # Loop invariant: no marker from _PROJECT_MARKERS[0..i] exists in path
165
+ for marker in _PROJECT_MARKERS:
166
+ if os.path.exists(os.path.join(path, marker)):
167
+ return True
168
+ return False
169
+
170
+
171
+ @icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
172
+ @icontract.ensure(lambda result: is_non_empty_string(result), "result must be a non-empty string")
173
+ def _walk_package_root(path: str) -> str:
174
+ """Walk up package directories and return the import root above them."""
175
+ current = os.path.abspath(path)
176
+
177
+ # Loop invariant: current is the directory above the deepest package chain seen so far
178
+ while os.path.isfile(os.path.join(current, "__init__.py")):
179
+ parent = os.path.dirname(current)
180
+ if parent == current:
181
+ break
182
+ current = parent
183
+
184
+ return current
185
+
186
+
187
+ @icontract.require(lambda file_path: is_non_empty_string(file_path), "file_path must be a non-empty string")
188
+ @icontract.require(lambda search_root: is_non_empty_string(search_root), "search_root must be a non-empty string")
189
+ @icontract.ensure(lambda result: is_non_empty_string(result), "result must be a non-empty string")
190
+ def _derive_module_path(file_path: str, search_root: str) -> str:
191
+ """Derive a normalized module path for structural/compositional checks."""
192
+ relative = _relative_to_root(file_path, search_root)
193
+ normalized = relative.replace(os.sep, "/")
194
+
195
+ if normalized.startswith("src/"):
196
+ return normalized[4:]
197
+
198
+ return normalized
199
+
200
+
201
+ @icontract.require(lambda file_path: is_non_empty_string(file_path), "file_path must be a non-empty string")
202
+ @icontract.require(lambda search_root: is_non_empty_string(search_root), "search_root must be a non-empty string")
203
+ @icontract.ensure(lambda result: result is None or isinstance(result, str), "result must be a string or None")
204
+ def _derive_module_reference(file_path: str, search_root: str) -> str | None:
205
+ """Derive a module reference for Level 3/4 backends.
206
+
207
+ Returns dotted module names for common relative/project-local layouts and
208
+ absolute file paths for standalone files that are not importable packages.
209
+ """
210
+ if not file_path.endswith(".py"):
211
+ return None
212
+
213
+ relative = _relative_to_root(file_path, search_root)
214
+ normalized_relative = relative.replace(os.sep, "/")
215
+ normalized_file = os.path.abspath(file_path).replace(os.sep, "/")
216
+
217
+ if normalized_relative.startswith("src/"):
218
+ module_ref = _module_reference_from_relative(normalized_relative[4:])
219
+ if module_ref is not None:
220
+ return module_ref
221
+
222
+ module_ref = _module_reference_from_relative(normalized_relative)
223
+ if module_ref is not None:
224
+ return module_ref
225
+
226
+ return normalized_file
227
+
228
+
229
+ @icontract.require(lambda file_path: is_non_empty_string(file_path), "file_path must be a non-empty string")
230
+ @icontract.require(lambda search_root: is_non_empty_string(search_root), "search_root must be a non-empty string")
231
+ @icontract.ensure(lambda result: isinstance(result, tuple), "result must be a tuple")
232
+ def _derive_import_search_paths(file_path: str, search_root: str) -> tuple[str, ...]:
233
+ """Derive sys.path roots needed to import a discovered module."""
234
+ absolute_file = os.path.abspath(file_path)
235
+ normalized_relative = _relative_to_root(file_path, search_root).replace(os.sep, "/")
236
+
237
+ candidates: list[str] = []
238
+ if normalized_relative.startswith("src/"):
239
+ candidates.append(os.path.join(search_root, "src"))
240
+ else:
241
+ candidates.append(search_root)
242
+
243
+ candidates.append(_infer_import_root(absolute_file))
244
+
245
+ roots: list[str] = []
246
+ # Loop invariant: roots contains unique, existing paths from candidates[0..i]
247
+ for candidate in candidates:
248
+ absolute_candidate = os.path.abspath(candidate)
249
+ if not os.path.isdir(absolute_candidate):
250
+ continue
251
+ if any(
252
+ os.path.commonpath([absolute_candidate, root]) == root
253
+ for root in roots
254
+ ):
255
+ continue
256
+ if absolute_candidate not in roots:
257
+ roots.append(absolute_candidate)
258
+
259
+ return tuple(roots)
260
+
261
+
262
+ @icontract.require(lambda relative_path: is_non_empty_string(relative_path), "relative_path must be a non-empty string")
263
+ @icontract.ensure(lambda result: result is None or isinstance(result, str), "result must be a string or None")
264
+ def _module_reference_from_relative(relative_path: str) -> str | None:
265
+ """Build a dotted module name from a root-relative path when valid."""
266
+ if not relative_path.endswith(".py"):
267
+ return None
268
+
269
+ module_part = relative_path[:-3]
270
+ if module_part.endswith("/__init__"):
271
+ module_part = module_part[:-9]
272
+
273
+ if not module_part:
274
+ return None
275
+
276
+ segments = [segment for segment in module_part.split("/") if segment]
277
+ if not segments:
278
+ return None
279
+
280
+ # Loop invariant: all segments in segments[0..i] are valid identifiers
281
+ for segment in segments:
282
+ if not segment.isidentifier() or keyword.iskeyword(segment):
283
+ return None
284
+
285
+ return ".".join(segments)
286
+
287
+
288
+ @icontract.require(lambda absolute_file: is_non_empty_string(absolute_file), "absolute_file must be a non-empty string")
289
+ @icontract.ensure(lambda result: is_non_empty_string(result), "result must be a non-empty string")
290
+ def _infer_import_root(absolute_file: str) -> str:
291
+ """Infer the import root by walking above package directories."""
292
+ current = os.path.dirname(absolute_file)
293
+
294
+ # Loop invariant: current is the directory above the deepest package chain seen so far
295
+ while os.path.isfile(os.path.join(current, "__init__.py")):
296
+ parent = os.path.dirname(current)
297
+ if parent == current:
298
+ break
299
+ current = parent
300
+
301
+ return current
302
+
303
+
304
+ @icontract.require(lambda file_path: is_non_empty_string(file_path), "file_path must be a non-empty string")
305
+ @icontract.require(lambda search_root: is_non_empty_string(search_root), "search_root must be a non-empty string")
306
+ @icontract.ensure(lambda result: is_non_empty_string(result), "result must be a non-empty string")
307
+ def _relative_to_root(file_path: str, search_root: str) -> str:
308
+ """Compute a root-relative path when possible."""
309
+ absolute_file = os.path.abspath(file_path)
310
+
311
+ try:
312
+ common = os.path.commonpath([absolute_file, search_root])
313
+ except ValueError:
314
+ common = ""
315
+
316
+ if common == search_root:
317
+ return os.path.relpath(absolute_file, search_root)
318
+
319
+ return os.path.relpath(absolute_file)
@@ -0,0 +1,5 @@
1
+ """Template files for SERENECODE.md initialization.
2
+
3
+ This package contains the SERENECODE.md template content as embedded
4
+ string constants, avoiding the need for file I/O at runtime.
5
+ """