atomadic-forge 0.3.2__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 (131) hide show
  1. atomadic_forge/__init__.py +12 -0
  2. atomadic_forge/__main__.py +5 -0
  3. atomadic_forge/a0_qk_constants/__init__.py +1 -0
  4. atomadic_forge/a0_qk_constants/agent_plan_schema.py +120 -0
  5. atomadic_forge/a0_qk_constants/commandsmith_types.py +49 -0
  6. atomadic_forge/a0_qk_constants/config_defaults.py +38 -0
  7. atomadic_forge/a0_qk_constants/emergent_types.py +77 -0
  8. atomadic_forge/a0_qk_constants/error_codes.py +296 -0
  9. atomadic_forge/a0_qk_constants/forge_types.py +89 -0
  10. atomadic_forge/a0_qk_constants/gen_language.py +116 -0
  11. atomadic_forge/a0_qk_constants/lang_extensions.py +150 -0
  12. atomadic_forge/a0_qk_constants/policy_schema.py +48 -0
  13. atomadic_forge/a0_qk_constants/receipt_schema.py +311 -0
  14. atomadic_forge/a0_qk_constants/roi_constants.py +96 -0
  15. atomadic_forge/a0_qk_constants/semantic_types.py +61 -0
  16. atomadic_forge/a0_qk_constants/sidecar_schema.py +81 -0
  17. atomadic_forge/a0_qk_constants/synergy_types.py +62 -0
  18. atomadic_forge/a0_qk_constants/tier_names.py +47 -0
  19. atomadic_forge/a1_at_functions/__init__.py +1 -0
  20. atomadic_forge/a1_at_functions/agent_context_pack.py +193 -0
  21. atomadic_forge/a1_at_functions/agent_memory.py +139 -0
  22. atomadic_forge/a1_at_functions/agent_plan_emitter.py +324 -0
  23. atomadic_forge/a1_at_functions/agent_summary.py +277 -0
  24. atomadic_forge/a1_at_functions/body_extractor.py +306 -0
  25. atomadic_forge/a1_at_functions/card_renderer.py +210 -0
  26. atomadic_forge/a1_at_functions/certify_checks.py +445 -0
  27. atomadic_forge/a1_at_functions/chat_context.py +170 -0
  28. atomadic_forge/a1_at_functions/cherry_pick.py +71 -0
  29. atomadic_forge/a1_at_functions/classify_tier.py +115 -0
  30. atomadic_forge/a1_at_functions/commandsmith_discover.py +167 -0
  31. atomadic_forge/a1_at_functions/commandsmith_render.py +267 -0
  32. atomadic_forge/a1_at_functions/compiler_feedback.py +94 -0
  33. atomadic_forge/a1_at_functions/compliance_checker.py +228 -0
  34. atomadic_forge/a1_at_functions/config_io.py +68 -0
  35. atomadic_forge/a1_at_functions/cs1_renderer.py +588 -0
  36. atomadic_forge/a1_at_functions/doc_synthesizer.py +205 -0
  37. atomadic_forge/a1_at_functions/emergent_compose.py +192 -0
  38. atomadic_forge/a1_at_functions/emergent_rank.py +116 -0
  39. atomadic_forge/a1_at_functions/emergent_signature_extract.py +242 -0
  40. atomadic_forge/a1_at_functions/emergent_synthesize.py +88 -0
  41. atomadic_forge/a1_at_functions/enforce_planner.py +208 -0
  42. atomadic_forge/a1_at_functions/error_hints.py +105 -0
  43. atomadic_forge/a1_at_functions/evolution_log.py +94 -0
  44. atomadic_forge/a1_at_functions/forge_feedback.py +433 -0
  45. atomadic_forge/a1_at_functions/generation_quality.py +322 -0
  46. atomadic_forge/a1_at_functions/import_repair.py +211 -0
  47. atomadic_forge/a1_at_functions/import_smoke.py +102 -0
  48. atomadic_forge/a1_at_functions/js_parser.py +539 -0
  49. atomadic_forge/a1_at_functions/lineage_chain.py +144 -0
  50. atomadic_forge/a1_at_functions/lineage_reader.py +107 -0
  51. atomadic_forge/a1_at_functions/llm_client.py +554 -0
  52. atomadic_forge/a1_at_functions/local_signer.py +134 -0
  53. atomadic_forge/a1_at_functions/lsp_protocol.py +379 -0
  54. atomadic_forge/a1_at_functions/manifest_diff.py +314 -0
  55. atomadic_forge/a1_at_functions/mcp_protocol.py +1066 -0
  56. atomadic_forge/a1_at_functions/patch_scorer.py +267 -0
  57. atomadic_forge/a1_at_functions/plan_adapter.py +75 -0
  58. atomadic_forge/a1_at_functions/policy_loader.py +107 -0
  59. atomadic_forge/a1_at_functions/preflight_change.py +227 -0
  60. atomadic_forge/a1_at_functions/progress_reporter.py +81 -0
  61. atomadic_forge/a1_at_functions/provider_detect.py +157 -0
  62. atomadic_forge/a1_at_functions/provider_resolver.py +48 -0
  63. atomadic_forge/a1_at_functions/receipt_emitter.py +291 -0
  64. atomadic_forge/a1_at_functions/recipes.py +186 -0
  65. atomadic_forge/a1_at_functions/repo_explainer.py +124 -0
  66. atomadic_forge/a1_at_functions/roi_calculator.py +265 -0
  67. atomadic_forge/a1_at_functions/rollback_planner.py +147 -0
  68. atomadic_forge/a1_at_functions/sbom_emitter.py +155 -0
  69. atomadic_forge/a1_at_functions/scaffold_js.py +55 -0
  70. atomadic_forge/a1_at_functions/scaffold_pyproject.py +62 -0
  71. atomadic_forge/a1_at_functions/scaffold_starter.py +94 -0
  72. atomadic_forge/a1_at_functions/scout_walk.py +309 -0
  73. atomadic_forge/a1_at_functions/sidecar_parser.py +161 -0
  74. atomadic_forge/a1_at_functions/sidecar_validator.py +202 -0
  75. atomadic_forge/a1_at_functions/stub_detector.py +158 -0
  76. atomadic_forge/a1_at_functions/synergy_detect.py +166 -0
  77. atomadic_forge/a1_at_functions/synergy_render.py +252 -0
  78. atomadic_forge/a1_at_functions/synergy_surface_extract.py +163 -0
  79. atomadic_forge/a1_at_functions/test_runner.py +196 -0
  80. atomadic_forge/a1_at_functions/test_selector.py +122 -0
  81. atomadic_forge/a1_at_functions/tier_init_rebuild.py +122 -0
  82. atomadic_forge/a1_at_functions/tool_composer.py +130 -0
  83. atomadic_forge/a1_at_functions/transcript_log.py +70 -0
  84. atomadic_forge/a1_at_functions/wire_check.py +260 -0
  85. atomadic_forge/a2_mo_composites/__init__.py +1 -0
  86. atomadic_forge/a2_mo_composites/lineage_chain_store.py +122 -0
  87. atomadic_forge/a2_mo_composites/manifest_store.py +46 -0
  88. atomadic_forge/a2_mo_composites/plan_store.py +164 -0
  89. atomadic_forge/a2_mo_composites/receipt_signer.py +231 -0
  90. atomadic_forge/a3_og_features/__init__.py +1 -0
  91. atomadic_forge/a3_og_features/commandsmith_feature.py +267 -0
  92. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/__init__.py +3 -0
  93. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a0_qk_constants/__init__.py +4 -0
  94. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a1_at_functions/__init__.py +14 -0
  95. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/conftest.py +10 -0
  96. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/test_mixed.py +18 -0
  97. atomadic_forge/a3_og_features/demo_runner.py +502 -0
  98. atomadic_forge/a3_og_features/emergent_feature.py +95 -0
  99. atomadic_forge/a3_og_features/emergent_pipeline_integration.py +154 -0
  100. atomadic_forge/a3_og_features/forge_enforce.py +107 -0
  101. atomadic_forge/a3_og_features/forge_evolve.py +176 -0
  102. atomadic_forge/a3_og_features/forge_loop.py +528 -0
  103. atomadic_forge/a3_og_features/forge_pipeline.py +295 -0
  104. atomadic_forge/a3_og_features/forge_plan_apply.py +222 -0
  105. atomadic_forge/a3_og_features/lsp_server.py +98 -0
  106. atomadic_forge/a3_og_features/mcp_server.py +160 -0
  107. atomadic_forge/a3_og_features/setup_wizard.py +337 -0
  108. atomadic_forge/a3_og_features/synergy_feature.py +65 -0
  109. atomadic_forge/a4_sy_orchestration/__init__.py +1 -0
  110. atomadic_forge/a4_sy_orchestration/cli.py +1284 -0
  111. atomadic_forge/commands/__init__.py +1 -0
  112. atomadic_forge/commands/_registry.py +36 -0
  113. atomadic_forge/commands/audit.py +142 -0
  114. atomadic_forge/commands/chat.py +133 -0
  115. atomadic_forge/commands/commandsmith.py +178 -0
  116. atomadic_forge/commands/config_cmd.py +145 -0
  117. atomadic_forge/commands/demo.py +142 -0
  118. atomadic_forge/commands/emergent.py +124 -0
  119. atomadic_forge/commands/emergent_then_synergy.py +70 -0
  120. atomadic_forge/commands/evolve.py +122 -0
  121. atomadic_forge/commands/evolve_then_iterate.py +70 -0
  122. atomadic_forge/commands/feature_then_emergent.py +111 -0
  123. atomadic_forge/commands/iterate.py +140 -0
  124. atomadic_forge/commands/synergy.py +96 -0
  125. atomadic_forge/commands/synergy_then_emergent.py +70 -0
  126. atomadic_forge-0.3.2.dist-info/METADATA +471 -0
  127. atomadic_forge-0.3.2.dist-info/RECORD +131 -0
  128. atomadic_forge-0.3.2.dist-info/WHEEL +5 -0
  129. atomadic_forge-0.3.2.dist-info/entry_points.txt +3 -0
  130. atomadic_forge-0.3.2.dist-info/licenses/LICENSE +15 -0
  131. atomadic_forge-0.3.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,306 @@
1
+ """Source body extraction and made_of graph — Phase 3 pure logic (ported from atomadic-forge-v1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ import textwrap
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ _MAX_FILE_BYTES = 2_000_000
13
+
14
+
15
+ @dataclass
16
+ class ExtractedBody:
17
+ source_path: str
18
+ source_line: int
19
+ symbol_name: str
20
+ language: str
21
+ body: str
22
+ imports: list[str]
23
+ callers_of: list[str]
24
+ exceptions_raised: list[str]
25
+ # Sibling top-level names defined in the same source file that are
26
+ # referenced from inside the extracted body. After extraction these
27
+ # become cross-file references and must be re-imported by the materializer.
28
+ sibling_refs: list[str] = None # type: ignore[assignment]
29
+ # Tier-classification hints harvested from the body itself. Two cheap
30
+ # signals: ``has_self_assign`` (mutable instance state) and
31
+ # ``has_class_attr_collections`` (mutable class-level dict/list/set).
32
+ has_self_assign: bool = False
33
+ has_class_attr_collections: bool = False
34
+
35
+ def __post_init__(self) -> None:
36
+ if self.sibling_refs is None:
37
+ self.sibling_refs = []
38
+
39
+
40
+ def _collect_top_level_names(tree: ast.Module) -> set[str]:
41
+ """Return every name bound at module top level (classes, functions, vars)."""
42
+ names: set[str] = set()
43
+ for node in tree.body:
44
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
45
+ names.add(node.name)
46
+ elif isinstance(node, ast.Assign):
47
+ for target in node.targets:
48
+ if isinstance(target, ast.Name):
49
+ names.add(target.id)
50
+ elif isinstance(node, ast.AnnAssign):
51
+ if isinstance(node.target, ast.Name):
52
+ names.add(node.target.id)
53
+ elif isinstance(node, ast.Import | ast.ImportFrom):
54
+ for alias in node.names:
55
+ names.add(alias.asname or alias.name.split(".", 1)[0])
56
+ return names
57
+
58
+
59
+ def _detect_state_markers(target: ast.AST) -> tuple[bool, bool]:
60
+ """Return ``(has_self_assign, has_class_attr_collections)``.
61
+
62
+ ``has_self_assign``: the class/function body contains ``self.<attr> = …``
63
+ (mutable instance state — strong signal for tier a2).
64
+ ``has_class_attr_collections``: a class-level assignment like
65
+ ``foo: list[str] = []`` (also a state signal).
66
+ """
67
+ has_self = False
68
+ has_class_attr = False
69
+ if isinstance(target, ast.ClassDef):
70
+ for node in ast.walk(target):
71
+ if isinstance(node, ast.Assign):
72
+ for t in node.targets:
73
+ if (isinstance(t, ast.Attribute)
74
+ and isinstance(t.value, ast.Name)
75
+ and t.value.id == "self"):
76
+ has_self = True
77
+ for node in target.body:
78
+ if isinstance(node, ast.Assign | ast.AnnAssign):
79
+ value = getattr(node, "value", None)
80
+ if isinstance(value, ast.List | ast.Dict | ast.Set | ast.ListComp | ast.DictComp | ast.SetComp):
81
+ has_class_attr = True
82
+ return has_self, has_class_attr
83
+
84
+
85
+ def _extract_python_body(source_text: str, symbol_name: str) -> ExtractedBody | None:
86
+ try:
87
+ tree = ast.parse(source_text)
88
+ except SyntaxError:
89
+ return None
90
+
91
+ imports: list[str] = []
92
+ for node in ast.walk(tree):
93
+ if isinstance(node, ast.Import):
94
+ imports.extend(alias.name for alias in node.names)
95
+ elif isinstance(node, ast.ImportFrom):
96
+ mod = node.module or ""
97
+ imports.extend(f"{mod}.{alias.name}" if mod else alias.name for alias in node.names)
98
+
99
+ target: ast.AST | None = None
100
+ for node in ast.walk(tree):
101
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
102
+ if node.name == symbol_name:
103
+ target = node
104
+ break
105
+ if target is None:
106
+ return None
107
+
108
+ lines = source_text.splitlines(keepends=True)
109
+ start = max(0, target.lineno - 1)
110
+ end = getattr(target, "end_lineno", None) or start + 1
111
+ body = textwrap.dedent("".join(lines[start:end]))
112
+
113
+ callers: list[str] = []
114
+ for node in ast.walk(target):
115
+ if isinstance(node, ast.Call):
116
+ func = node.func
117
+ if isinstance(func, ast.Name):
118
+ callers.append(func.id)
119
+ elif isinstance(func, ast.Attribute):
120
+ callers.append(func.attr)
121
+
122
+ raised: list[str] = []
123
+ for node in ast.walk(target):
124
+ if isinstance(node, ast.Raise) and node.exc is not None:
125
+ if isinstance(node.exc, ast.Name):
126
+ raised.append(node.exc.id)
127
+ elif isinstance(node.exc, ast.Call) and isinstance(node.exc.func, ast.Name):
128
+ raised.append(node.exc.func.id)
129
+
130
+ # ── Sibling references — names defined at module top level in the same
131
+ # source file that are referenced from inside the extracted body.
132
+ # After single-symbol extraction these become cross-file references.
133
+ top_names = _collect_top_level_names(tree)
134
+ referenced: set[str] = set()
135
+ locally_bound: set[str] = {symbol_name}
136
+ if isinstance(target, ast.FunctionDef | ast.AsyncFunctionDef):
137
+ for arg in target.args.args + target.args.kwonlyargs:
138
+ locally_bound.add(arg.arg)
139
+ if target.args.vararg:
140
+ locally_bound.add(target.args.vararg.arg)
141
+ if target.args.kwarg:
142
+ locally_bound.add(target.args.kwarg.arg)
143
+ for node in ast.walk(target):
144
+ if isinstance(node, ast.Name):
145
+ referenced.add(node.id)
146
+ elif isinstance(node, ast.Assign):
147
+ for t in node.targets:
148
+ if isinstance(t, ast.Name):
149
+ locally_bound.add(t.id)
150
+ elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
151
+ locally_bound.add(node.name)
152
+ for arg in node.args.args + node.args.kwonlyargs:
153
+ locally_bound.add(arg.arg)
154
+ sibling_refs = sorted(
155
+ n for n in referenced
156
+ if n in top_names and n != symbol_name and n not in locally_bound
157
+ )
158
+
159
+ has_self_assign, has_class_attr_collections = _detect_state_markers(target)
160
+
161
+ return ExtractedBody(
162
+ source_path="",
163
+ source_line=target.lineno,
164
+ symbol_name=symbol_name,
165
+ language="python",
166
+ body=body,
167
+ imports=imports,
168
+ callers_of=list(dict.fromkeys(callers)),
169
+ exceptions_raised=list(dict.fromkeys(raised)),
170
+ sibling_refs=sibling_refs,
171
+ has_self_assign=has_self_assign,
172
+ has_class_attr_collections=has_class_attr_collections,
173
+ )
174
+
175
+
176
+ def _extract_regex_body(
177
+ source_text: str, symbol_name: str, language: str
178
+ ) -> ExtractedBody | None:
179
+ pattern = re.compile(rf"\b{re.escape(symbol_name)}\b", re.MULTILINE)
180
+ match = pattern.search(source_text)
181
+ if not match:
182
+ return None
183
+ lines = source_text.splitlines(keepends=True)
184
+ match_line = source_text.count("\n", 0, match.start())
185
+ start = max(0, match_line - 2)
186
+ end = min(len(lines), match_line + 40)
187
+ body = textwrap.dedent("".join(lines[start:end]))
188
+ body = re.sub(
189
+ r"(?m)^\s*// Extracted from .*\n"
190
+ r"(?:\s*// Component id: .*\n)?"
191
+ r"\s*\n?",
192
+ "",
193
+ body,
194
+ )
195
+ if language == "rust":
196
+ import_pat = re.compile(r"^\s*use\s+([\w:]+);", re.MULTILINE)
197
+ else:
198
+ import_pat = re.compile(
199
+ r"^\s*(?:import|export)\s.*?from\s+['\"]([^'\"]+)", re.MULTILINE
200
+ )
201
+ imports = import_pat.findall(source_text)
202
+ return ExtractedBody(
203
+ source_path="",
204
+ source_line=match_line + 1,
205
+ symbol_name=symbol_name,
206
+ language=language,
207
+ body=body,
208
+ imports=list(dict.fromkeys(imports)),
209
+ callers_of=[],
210
+ exceptions_raised=[],
211
+ )
212
+
213
+
214
+ def extract_body(
215
+ source_path: str | Path, symbol_name: str, language: str = "python"
216
+ ) -> ExtractedBody | None:
217
+ """Extract the body of one symbol from one source file. Returns None on failure."""
218
+ p = Path(source_path)
219
+ if not p.exists() or p.stat().st_size > _MAX_FILE_BYTES:
220
+ return None
221
+ try:
222
+ text = p.read_text(encoding="utf-8", errors="replace")
223
+ except OSError:
224
+ return None
225
+
226
+ if language == "python" or p.suffix == ".py":
227
+ result = _extract_python_body(text, symbol_name)
228
+ else:
229
+ result = _extract_regex_body(text, symbol_name, language)
230
+ if result is not None:
231
+ result.source_path = p.as_posix()
232
+ return result
233
+
234
+
235
+ def enrich_components_with_bodies(
236
+ plan: dict[str, Any],
237
+ *,
238
+ max_body_chars: int | None = None,
239
+ ) -> dict[str, Any]:
240
+ """Attach extracted source bodies to every proposed component. Mutates ``plan``.
241
+
242
+ Also recomputes the tier when body extraction reveals a stateful class
243
+ that was originally classified at a1 by name heuristics alone. The
244
+ recompute is conservative — it only PROMOTES (a1 → a2), never demotes.
245
+ """
246
+ for prop in plan.get("proposed_components") or []:
247
+ sym = prop.get("source_symbol") or {}
248
+ src_path = sym.get("path") or ""
249
+ name = sym.get("name") or prop.get("name")
250
+ lang = sym.get("language") or "python"
251
+ if not src_path or not name:
252
+ continue
253
+ extracted = extract_body(src_path, str(name), str(lang))
254
+ if extracted is None:
255
+ continue
256
+ if max_body_chars is None:
257
+ prop["body"] = extracted.body
258
+ prop["body_truncated"] = False
259
+ else:
260
+ prop["body"] = extracted.body[:max_body_chars]
261
+ prop["body_truncated"] = len(extracted.body) > max_body_chars
262
+ prop["imports"] = extracted.imports
263
+ prop["callers_of"] = extracted.callers_of
264
+ prop["exceptions_raised"] = extracted.exceptions_raised
265
+ prop["sibling_refs"] = extracted.sibling_refs
266
+ prop["has_self_assign"] = extracted.has_self_assign
267
+ prop["has_class_attr_collections"] = extracted.has_class_attr_collections
268
+
269
+ # Body-aware tier promotion: a class with mutable instance state
270
+ # belongs in a2_mo_composites, not a1_at_functions. Only promote;
271
+ # don't demote (the assimilator may have legitimately placed it
272
+ # higher because of name signals).
273
+ kind = str(sym.get("kind") or "").lower()
274
+ if (kind in ("class", "type")
275
+ and (extracted.has_self_assign
276
+ or extracted.has_class_attr_collections)
277
+ and prop.get("tier") == "a1_at_functions"):
278
+ prop["tier"] = "a2_mo_composites"
279
+ prop["tier_promotion_reason"] = (
280
+ "body has self.<attr> = … or class-level mutable collection — "
281
+ "ASS-ADE law promotes from a1 to a2"
282
+ )
283
+ # Re-stamp the component id so downstream stem/manifest paths
284
+ # match the new tier prefix.
285
+ cid = str(prop.get("id") or "")
286
+ if cid.startswith("a1."):
287
+ prop["id"] = "a2." + cid[3:]
288
+ return plan
289
+
290
+
291
+ def derive_made_of_graph(plan: dict[str, Any]) -> dict[str, Any]:
292
+ """Populate each component's ``made_of`` from call-graph analysis. Mutates ``plan``."""
293
+ by_name: dict[str, str] = {}
294
+ for prop in plan.get("proposed_components") or []:
295
+ name = (prop.get("name") or "").lower()
296
+ if name:
297
+ by_name[name] = str(prop["id"])
298
+
299
+ for prop in plan.get("proposed_components") or []:
300
+ made_of: list[str] = list(prop.get("made_of") or [])
301
+ for callee in prop.get("callers_of") or []:
302
+ target_id = by_name.get((callee or "").lower())
303
+ if target_id and target_id != prop["id"] and target_id not in made_of:
304
+ made_of.append(target_id)
305
+ prop["made_of"] = made_of
306
+ return plan
@@ -0,0 +1,210 @@
1
+ """Tier a1 — render a Forge Receipt as a 60×24 box-drawing card.
2
+
3
+ Golden Path Lane A W1 (paired with ``receipt_emitter.py``). This
4
+ renderer is what the "62 → 5" 30-second viral demo (Lane E W2)
5
+ screen-grabs and what ``forge auto`` / ``forge certify`` print at the
6
+ bottom of a successful run.
7
+
8
+ Pure: takes a Receipt dict and returns a string. No I/O, no imports
9
+ above a0. Snapshot-tested at ~60 columns wide; height is variable
10
+ but bounded (target ≤ 24 rows for 'fits in one mobile screenshot').
11
+
12
+ Style notes (so the output stays visually consistent across releases):
13
+ * Single-line box drawing characters (U+2500..U+257F)
14
+ * Title bar uses U+2550 (heavy horizontal) for visual weight
15
+ * Verdict color hint emitted as a leading symbol (✓ / ✗ / ↻ / ⏸)
16
+ so terminals without color can still distinguish the four
17
+ verdicts. Caller may wrap with ANSI if desired.
18
+ * Numeric fields right-aligned; labels left-aligned; one column of
19
+ padding per side. No tabs, no trailing whitespace per row.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from ..a0_qk_constants.receipt_schema import VALID_VERDICTS, ForgeReceiptV1
26
+
27
+ _VERDICT_GLYPH: dict[str, str] = {
28
+ "PASS": "✓",
29
+ "FAIL": "✗",
30
+ "REFINE": "↻",
31
+ "QUARANTINE": "⏸",
32
+ }
33
+
34
+
35
+ def _truncate(text: str, max_len: int) -> str:
36
+ """Truncate ``text`` to ``max_len`` characters, adding an ellipsis
37
+ when shortened. Returns ``text`` unchanged when already short enough.
38
+ """
39
+ if max_len <= 1:
40
+ return text[:max_len]
41
+ return text if len(text) <= max_len else text[: max_len - 1] + "…"
42
+
43
+
44
+ def _row(left: str, right: str, *, width: int) -> str:
45
+ """Format a single content row inside a card of total ``width``.
46
+
47
+ Layout: │ <left> .... <right> │
48
+ Inner width = width - 4 (two box chars + two single-space pads).
49
+ """
50
+ inner = width - 4
51
+ if inner <= 0:
52
+ return ""
53
+ if not right:
54
+ return f"│ {_truncate(left, inner):<{inner}} │"
55
+ pad = inner - len(left) - len(right)
56
+ if pad < 1:
57
+ # Right-align right; truncate left.
58
+ max_left = max(0, inner - len(right) - 1)
59
+ left = _truncate(left, max_left)
60
+ pad = inner - len(left) - len(right)
61
+ pad = max(1, pad)
62
+ return f"│ {left}{' ' * pad}{right} │"
63
+
64
+
65
+ def _hr(width: int, *, heavy: bool = False) -> str:
66
+ char = "═" if heavy else "─"
67
+ return ("╔" if heavy else "├") + char * (width - 2) + ("╗" if heavy else "┤")
68
+
69
+
70
+ def _top(width: int) -> str:
71
+ return "╔" + "═" * (width - 2) + "╗"
72
+
73
+
74
+ def _bottom(width: int) -> str:
75
+ return "╚" + "═" * (width - 2) + "╝"
76
+
77
+
78
+ def _mid(width: int) -> str:
79
+ return "├" + "─" * (width - 2) + "┤"
80
+
81
+
82
+ def _verdict_line(receipt: ForgeReceiptV1, *, width: int) -> str:
83
+ verdict = str(receipt.get("verdict", "FAIL"))
84
+ glyph = _VERDICT_GLYPH.get(verdict, "?")
85
+ return _row(f"{glyph} {verdict}",
86
+ f"forge {receipt.get('forge_version', '?')}",
87
+ width=width)
88
+
89
+
90
+ def _certify_lines(receipt: ForgeReceiptV1, *, width: int) -> list[str]:
91
+ cert: dict[str, Any] = dict(receipt.get("certify", {})) # type: ignore[arg-type]
92
+ score = cert.get("score", 0.0)
93
+ axes: dict[str, Any] = dict(cert.get("axes", {})) # type: ignore[arg-type]
94
+ flag_glyph = lambda b: "✓" if b else "✗" # noqa: E731
95
+ rows: list[str] = []
96
+ rows.append(_row("CERTIFY", f"{score:>5.1f} / 100", width=width))
97
+ rows.append(_row(
98
+ f" docs {flag_glyph(axes.get('documentation_complete'))} "
99
+ f"tests {flag_glyph(axes.get('tests_present'))} "
100
+ f"layout {flag_glyph(axes.get('tier_layout_present'))} "
101
+ f"wire {flag_glyph(axes.get('no_upward_imports'))}",
102
+ "",
103
+ width=width,
104
+ ))
105
+ return rows
106
+
107
+
108
+ def _wire_line(receipt: ForgeReceiptV1, *, width: int) -> str:
109
+ wire: dict[str, Any] = dict(receipt.get("wire", {})) # type: ignore[arg-type]
110
+ verdict = wire.get("verdict", "FAIL")
111
+ n = wire.get("violation_count", 0)
112
+ fixable = wire.get("auto_fixable", 0)
113
+ if fixable:
114
+ return _row("WIRE", f"{verdict} ({n} viol, {fixable} auto-fix)",
115
+ width=width)
116
+ return _row("WIRE", f"{verdict} ({n} violation{'' if n == 1 else 's'})",
117
+ width=width)
118
+
119
+
120
+ def _scout_line(receipt: ForgeReceiptV1, *, width: int) -> str:
121
+ scout: dict[str, Any] = dict(receipt.get("scout", {})) # type: ignore[arg-type]
122
+ sym = scout.get("symbol_count", 0)
123
+ lang = scout.get("primary_language", "?")
124
+ tiers = scout.get("tier_distribution", {}) or {}
125
+ tier_total = sum(int(v) for v in tiers.values())
126
+ return _row(
127
+ f"SCOUT {sym} symbol{'' if sym == 1 else 's'} ({lang})",
128
+ f"{tier_total} tier-placed",
129
+ width=width,
130
+ )
131
+
132
+
133
+ def _project_line(receipt: ForgeReceiptV1, *, width: int) -> str:
134
+ proj: dict[str, Any] = dict(receipt.get("project", {})) # type: ignore[arg-type]
135
+ name = str(proj.get("name", "?"))
136
+ vcs: dict[str, Any] = dict(proj.get("vcs") or {}) # type: ignore[arg-type]
137
+ short = vcs.get("short_sha") or vcs.get("head_sha", "")[:7]
138
+ branch = vcs.get("branch")
139
+ if short and branch:
140
+ right = f"{branch}@{short}"
141
+ elif short:
142
+ right = short
143
+ else:
144
+ right = ""
145
+ return _row(name, right, width=width)
146
+
147
+
148
+ def _attestation_line(receipt: ForgeReceiptV1, *, width: int) -> str:
149
+ att: dict[str, Any] = dict(receipt.get("lean4_attestation") or {}) # type: ignore[arg-type]
150
+ total = att.get("total_theorems", 0)
151
+ if not total:
152
+ return _row("LEAN4 (no attestation)", "", width=width)
153
+ corpora = att.get("corpora") or []
154
+ corpus_count = len(corpora)
155
+ return _row(
156
+ f"LEAN4 {total} theorem{'' if total == 1 else 's'} "
157
+ f"across {corpus_count} corpus{'' if corpus_count == 1 else 'es'}",
158
+ "0 sorry",
159
+ width=width,
160
+ )
161
+
162
+
163
+ def _signature_line(receipt: ForgeReceiptV1, *, width: int) -> str:
164
+ sigs: dict[str, Any] = dict(receipt.get("signatures") or {}) # type: ignore[arg-type]
165
+ has_sigstore = bool(sigs.get("sigstore"))
166
+ has_nexus = bool(sigs.get("aaaa_nexus"))
167
+ if has_sigstore and has_nexus:
168
+ return _row("SIGNED Sigstore + AAAA-Nexus", "", width=width)
169
+ if has_sigstore:
170
+ return _row("SIGNED Sigstore", "", width=width)
171
+ if has_nexus:
172
+ return _row("SIGNED AAAA-Nexus", "", width=width)
173
+ return _row("UNSIGNED (run --sign to attest)", "", width=width)
174
+
175
+
176
+ def render_receipt_card(
177
+ receipt: ForgeReceiptV1,
178
+ *,
179
+ width: int = 60,
180
+ ) -> str:
181
+ """Render a Receipt as a multi-line box-drawing card.
182
+
183
+ ``width`` defaults to 60. Terminal widths < 40 will look cramped
184
+ (every label gets truncated); the function never raises on small
185
+ widths but the output is best-effort below 40.
186
+ """
187
+ if width < 20:
188
+ raise ValueError("card width must be >= 20")
189
+ verdict = str(receipt.get("verdict", "FAIL"))
190
+ if verdict not in VALID_VERDICTS:
191
+ verdict = "FAIL"
192
+
193
+ lines: list[str] = []
194
+ lines.append(_top(width))
195
+ title = "Atomadic Forge Receipt"
196
+ lines.append(_row(title, receipt.get("schema_version", "?"), width=width))
197
+ lines.append(_mid(width))
198
+ lines.append(_verdict_line(receipt, width=width))
199
+ lines.append(_project_line(receipt, width=width))
200
+ lines.append(_mid(width))
201
+ lines.extend(_certify_lines(receipt, width=width))
202
+ lines.append(_wire_line(receipt, width=width))
203
+ lines.append(_scout_line(receipt, width=width))
204
+ lines.append(_mid(width))
205
+ lines.append(_attestation_line(receipt, width=width))
206
+ lines.append(_signature_line(receipt, width=width))
207
+ ts = str(receipt.get("generated_at_utc", "?"))
208
+ lines.append(_row("emitted", ts, width=width))
209
+ lines.append(_bottom(width))
210
+ return "\n".join(lines)