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,242 @@
1
+ """Tier a1 — pure signature harvesting for the Emergent Scan.
2
+
3
+ Walks a tier-organized package and emits a :class:`SymbolSignatureCard` per
4
+ public callable. ``inputs`` and ``output`` are the type annotation texts as
5
+ they appear in source — we keep them as strings so the matcher can do
6
+ loose, normalized compatibility comparisons later (no real type system).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import re
13
+ from collections.abc import Iterable
14
+ from pathlib import Path
15
+
16
+ from ..a0_qk_constants.emergent_types import SymbolSignatureCard
17
+
18
+ _TIERS = (
19
+ "a0_qk_constants", "a1_at_functions", "a2_mo_composites",
20
+ "a3_og_features", "a4_sy_orchestration",
21
+ )
22
+ _PUBLIC = lambda name: not name.startswith("_") or name == "__init__" # noqa: E731
23
+
24
+ # Effects we treat as "impure" for the heuristic. Any function whose body
25
+ # touches one of these is marked ``is_pure=False``.
26
+ _IMPURE_HINTS = {
27
+ "open", "print", "input", "exec", "eval",
28
+ "Path", "subprocess", "socket", "requests", "urllib",
29
+ "logging", "os.system", "os.environ",
30
+ }
31
+
32
+
33
+ def _module_for(file: Path, src_root: Path, package: str) -> str:
34
+ rel = file.relative_to(src_root).with_suffix("")
35
+ parts = list(rel.parts)
36
+ if parts and parts[-1] == "__init__":
37
+ parts.pop()
38
+ return ".".join([package, *parts]) if parts else package
39
+
40
+
41
+ def _tier_of(file: Path, src_root: Path) -> str:
42
+ parts = file.relative_to(src_root).parts
43
+ for p in parts:
44
+ if p in _TIERS:
45
+ return p
46
+ return ""
47
+
48
+
49
+ def _domain_of(stem: str) -> str:
50
+ """Heuristic domain tag: pull the last meaningful slug from the file stem.
51
+
52
+ ``a1_source_atomadic_v2_cherrypicker`` → ``cherrypicker``.
53
+ ``commandsmith_render`` → ``commandsmith``. ``ingest`` → ``ingest``.
54
+ """
55
+ cleaned = re.sub(r"^a\d_(?:source_)?", "", stem)
56
+ cleaned = re.sub(r"^(atomadic_forge_seed_|atomadic_v2_)", "", cleaned)
57
+ parts = cleaned.split("_")
58
+ return parts[0].lower() if parts else "misc"
59
+
60
+
61
+ def _ann_text(node: ast.AST | None) -> str:
62
+ if node is None:
63
+ return "Any"
64
+ try:
65
+ return ast.unparse(node)
66
+ except Exception:
67
+ return "Any"
68
+
69
+
70
+ def _is_pure(fn: ast.AST) -> bool:
71
+ for child in ast.walk(fn):
72
+ if isinstance(child, ast.Call):
73
+ f = child.func
74
+ if isinstance(f, ast.Name) and f.id in _IMPURE_HINTS:
75
+ return False
76
+ if isinstance(f, ast.Attribute):
77
+ root = f
78
+ while isinstance(root, ast.Attribute):
79
+ root = root.value
80
+ if isinstance(root, ast.Name) and root.id in _IMPURE_HINTS:
81
+ return False
82
+ if isinstance(child, ast.Global | ast.Nonlocal):
83
+ return False
84
+ return True
85
+
86
+
87
+ def _docstring_first_line(node: ast.AST) -> str:
88
+ doc = ast.get_docstring(node) or ""
89
+ return (doc.strip().split("\n", 1)[0] if doc else "").strip()
90
+
91
+
92
+ def _harvest_function(fn: ast.AST, *, module: str, tier: str, domain: str,
93
+ class_qualifier: str = "") -> SymbolSignatureCard | None:
94
+ if not isinstance(fn, ast.FunctionDef | ast.AsyncFunctionDef):
95
+ return None
96
+ if not _PUBLIC(fn.name):
97
+ return None
98
+ name = fn.name if not class_qualifier else f"{class_qualifier}.{fn.name}"
99
+ inputs: list[tuple[str, str]] = []
100
+ for arg in fn.args.args:
101
+ if arg.arg in ("self", "cls"):
102
+ continue
103
+ inputs.append((arg.arg, _ann_text(arg.annotation)))
104
+ for arg in fn.args.kwonlyargs:
105
+ inputs.append((arg.arg, _ann_text(arg.annotation)))
106
+ output = _ann_text(fn.returns)
107
+ return SymbolSignatureCard(
108
+ name=name,
109
+ qualname=f"{module}.{name}",
110
+ module=module,
111
+ tier=tier,
112
+ domain=domain,
113
+ inputs=inputs,
114
+ output=output,
115
+ is_pure=_is_pure(fn),
116
+ docstring=_docstring_first_line(fn),
117
+ )
118
+
119
+
120
+ def _harvest_one_file(py_file: Path, *, module: str, tier: str,
121
+ domain: str) -> list[SymbolSignatureCard]:
122
+ cards: list[SymbolSignatureCard] = []
123
+ try:
124
+ tree = ast.parse(py_file.read_text(encoding="utf-8"))
125
+ except (SyntaxError, OSError):
126
+ return cards
127
+ for node in tree.body:
128
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
129
+ card = _harvest_function(node, module=module, tier=tier, domain=domain)
130
+ if card:
131
+ cards.append(card)
132
+ elif isinstance(node, ast.ClassDef) and _PUBLIC(node.name):
133
+ for sub in node.body:
134
+ if isinstance(sub, ast.FunctionDef | ast.AsyncFunctionDef):
135
+ card = _harvest_function(
136
+ sub, module=module, tier=tier, domain=domain,
137
+ class_qualifier=node.name,
138
+ )
139
+ if card:
140
+ cards.append(card)
141
+ return cards
142
+
143
+
144
+ def _flat_tier_for(py_file: Path) -> str:
145
+ """Best-effort tier guess for repos without a*_ folders.
146
+
147
+ Uses pure naming heuristics from :func:`atomadic_forge.a1_at_functions.ingest.\
148
+ classify_tier` shape — but works on the file alone (good enough for the
149
+ composition graph; mis-tier doesn't break compatibility).
150
+ """
151
+ name = py_file.stem.lower()
152
+ if any(t in name for t in ("constant", "schema", "manifest", "type", "enum")):
153
+ return "a0_qk_constants"
154
+ if any(t in name for t in ("util", "helper", "validator", "parse", "format")):
155
+ return "a1_at_functions"
156
+ if any(t in name for t in ("client", "store", "registry", "manager", "core")):
157
+ return "a2_mo_composites"
158
+ if any(t in name for t in ("feature", "service", "pipeline", "gate", "tool")):
159
+ return "a3_og_features"
160
+ if any(t in name for t in ("cli", "main", "runner", "cmd", "orchestrat")):
161
+ return "a4_sy_orchestration"
162
+ return "a1_at_functions"
163
+
164
+
165
+ def harvest_signatures(
166
+ src_root: Path,
167
+ package: str = "atomadic_forge",
168
+ tiers: Iterable[str] = _TIERS,
169
+ *,
170
+ skip_generated: bool = True,
171
+ ) -> list[SymbolSignatureCard]:
172
+ """Walk every ``a*/`` tier under ``src_root`` and harvest signatures.
173
+
174
+ Falls back to **flat mode** if no tier folder is found under
175
+ ``src_root/package/`` — so emergent-scan also works on legacy / non-ASS-ADE
176
+ repositories (atomadic-v2-style flat layouts, the ``foo/bar.py`` shape, …).
177
+ In flat mode each file's tier is guessed from its name and the catalogue
178
+ contains every public callable under ``src_root``.
179
+
180
+ ``skip_generated``: when True, skip files whose stems start with
181
+ ``a*_source_*`` (verbatim assimilator output).
182
+ """
183
+ src_root = Path(src_root)
184
+ cards: list[SymbolSignatureCard] = []
185
+ pkg_root = src_root / package
186
+ have_any_tier = pkg_root.exists() and any(
187
+ (pkg_root / t).exists() for t in tiers
188
+ )
189
+
190
+ if have_any_tier:
191
+ for tier in tiers:
192
+ tdir = pkg_root / tier
193
+ if not tdir.exists():
194
+ continue
195
+ for py_file in sorted(tdir.glob("*.py")):
196
+ if py_file.name.startswith("_"):
197
+ continue
198
+ if skip_generated and py_file.stem.startswith((
199
+ "a0_source_", "a1_source_", "a2_source_",
200
+ "a3_source_", "a4_source_")):
201
+ continue
202
+ module = _module_for(py_file, src_root, package)
203
+ domain = _domain_of(py_file.stem)
204
+ cards.extend(_harvest_one_file(py_file, module=module,
205
+ tier=tier, domain=domain))
206
+ return cards
207
+
208
+ # ── Flat mode — walk every .py, infer tier from filename heuristics.
209
+ walk_root = src_root
210
+ package_for_module = package
211
+ if not pkg_root.exists():
212
+ # Caller passed a repo root, not a src layout. Use the repo as the
213
+ # walk root and fabricate a one-level package alias from the directory.
214
+ walk_root = src_root
215
+ package_for_module = src_root.name.replace("-", "_")
216
+ for py_file in sorted(walk_root.rglob("*.py")):
217
+ if py_file.name.startswith("_"):
218
+ continue
219
+ # Use parts RELATIVE to walk_root so a hidden parent like
220
+ # ``.pytest_basetemp`` doesn't accidentally exclude every test fixture.
221
+ try:
222
+ rel_parts = py_file.relative_to(walk_root).parts
223
+ except ValueError:
224
+ rel_parts = py_file.parts
225
+ if any(part.startswith((".", "__pycache__")) for part in rel_parts):
226
+ continue
227
+ if any(part in {"tests", "test", "build", "dist", ".venv", "venv"}
228
+ for part in rel_parts):
229
+ continue
230
+ try:
231
+ rel = py_file.relative_to(walk_root).with_suffix("")
232
+ module_parts = list(rel.parts)
233
+ if module_parts and module_parts[-1] == "__init__":
234
+ module_parts.pop()
235
+ module = ".".join([package_for_module, *module_parts]) if module_parts else package_for_module
236
+ except ValueError:
237
+ module = package_for_module
238
+ tier = _flat_tier_for(py_file)
239
+ domain = _domain_of(py_file.stem)
240
+ cards.extend(_harvest_one_file(py_file, module=module,
241
+ tier=tier, domain=domain))
242
+ return cards
@@ -0,0 +1,88 @@
1
+ """Tier a1 — pure source synthesiser for an :class:`EmergentCandidateCard`.
2
+
3
+ Renders a Python module that wires the chain's components together as a new
4
+ feature. The generator is conservative: it imports each step by qualname,
5
+ calls them in order, and returns a typed result dict. Manual review is
6
+ expected — this is scaffolding, not a finished feature.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ..a0_qk_constants.emergent_types import EmergentCandidateCard
12
+
13
+
14
+ def _imports_for(chain_qualnames: list[str]) -> list[str]:
15
+ out: list[str] = []
16
+ seen: set[str] = set()
17
+ for q in chain_qualnames:
18
+ module, _, name = q.rpartition(".")
19
+ # qualnames may include a class-qualified method (``Mod.Class.method``).
20
+ if "." in name: # method case
21
+ continue
22
+ line = f"from {module} import {name}"
23
+ if line in seen:
24
+ continue
25
+ seen.add(line)
26
+ out.append(line)
27
+ return out
28
+
29
+
30
+ def render_emergent_feature(card: EmergentCandidateCard) -> str:
31
+ chain = card["chain"]
32
+ qualnames = chain["chain"]
33
+ bridges = chain["bridges"]
34
+ name = card["name"].replace("-", "_")
35
+ imports = _imports_for(qualnames)
36
+ summary = card["summary"].replace('"""', "'''")
37
+ novelty = "; ".join(card["novelty_signals"]) or "(none)"
38
+ score = card["score"]
39
+ breakdown = ", ".join(f"{k}={v:g}" for k, v in card["score_breakdown"].items())
40
+
41
+ lines: list[str] = [
42
+ '"""',
43
+ f"Auto-synthesized by ``atomadic-forge emergent synthesize {card['candidate_id']}``.",
44
+ "",
45
+ f"Suggested name: {card['name']}",
46
+ f"Suggested tier: {card['suggested_tier']}",
47
+ f"Score: {score:.0f} ({breakdown})",
48
+ f"Novelty: {novelty}",
49
+ "",
50
+ "Composition chain:",
51
+ ]
52
+ for i, q in enumerate(qualnames):
53
+ lines.append(f" {i+1}. {q}")
54
+ if i < len(bridges):
55
+ lines.append(f" -- output: {bridges[i]} -->")
56
+ lines.extend([
57
+ '"""',
58
+ "",
59
+ "from __future__ import annotations",
60
+ "",
61
+ "from typing import Any",
62
+ "",
63
+ ])
64
+ lines.extend(imports)
65
+ lines.append("")
66
+ lines.append(f"def run_{name}(seed: Any) -> dict[str, Any]:")
67
+ lines.append(f' """Wired composition for {card["name"]}.')
68
+ lines.append("")
69
+ lines.append(f" Summary: {summary}")
70
+ lines.append(' """')
71
+ lines.append(" trace: list[Any] = []")
72
+ lines.append(" value = seed")
73
+ for i, q in enumerate(qualnames):
74
+ _module, _, callable_name = q.rpartition(".")
75
+ if "." in callable_name:
76
+ # method case — fall back to a noop trace step rather than guessing
77
+ # the receiver. Manual wiring required.
78
+ lines.append(
79
+ f" # NOTE: step {i+1} is a method ({callable_name}); "
80
+ "wire the receiver manually."
81
+ )
82
+ lines.append(f" trace.append(({q!r}, 'unwired-method'))")
83
+ continue
84
+ lines.append(f" value = {callable_name}(value)")
85
+ lines.append(f" trace.append(({q!r}, value))")
86
+ lines.append(" return {'final': value, 'trace': trace}")
87
+ lines.append("")
88
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,208 @@
1
+ """Tier a1 — pure planner for ``forge enforce``.
2
+
3
+ Golden Path Lane A W6 deliverable. Consumes a wire report with W5
4
+ F-codes attached and emits a list of mechanical fix actions. Pure:
5
+ the planner reads the wire report and inspects file paths only — it
6
+ does NOT mutate anything. Application is a separate concern (see
7
+ ``a3/forge_enforce.py``).
8
+
9
+ Action shape:
10
+ {
11
+ "f_code": "F0042",
12
+ "action": "move_file_up",
13
+ "src": "a1_at_functions/helper.py",
14
+ "dest": "a3_og_features/helper.py",
15
+ "violations": [<wire violation dict>, ...],
16
+ "auto_apply": true,
17
+ "warnings": [str, ...], # populated when the move has risks
18
+ }
19
+
20
+ The planner today implements one action: ``move_file_up`` for the six
21
+ canonical upward-import F-codes (F0041–F0046). F0040 (a0 importing
22
+ anything) and F0049 (unknown-shape) are emitted as ``review_manually``
23
+ actions with no auto_apply.
24
+
25
+ Pre-flight risks the planner detects (and surfaces as warnings, not
26
+ exceptions):
27
+ * the destination path already exists (would clobber)
28
+ * the source file has multiple distinct destination tiers across
29
+ its violations (ambiguous; safest is review)
30
+ * the source file is itself imported by other files in the package
31
+ (those callers' imports would need rewriting — out of scope for
32
+ W6 v1; W7 will own this)
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import ast
37
+ from collections import defaultdict
38
+ from pathlib import Path
39
+ from typing import TypedDict
40
+
41
+
42
+ class EnforceAction(TypedDict, total=False):
43
+ """One proposed mechanical fix. See module docstring."""
44
+ f_code: str
45
+ action: str # 'move_file_up' | 'review_manually'
46
+ src: str # repo-relative posix path
47
+ dest: str # repo-relative posix path (move target)
48
+ violations: list[dict]
49
+ auto_apply: bool
50
+ warnings: list[str]
51
+
52
+
53
+ def _file_imports_in_package(
54
+ package_root: Path,
55
+ target_relpath: str,
56
+ ) -> list[str]:
57
+ """Return repo-relative paths of files that import the target.
58
+
59
+ Heuristic: looks for ``from <…>.target_module_name import``
60
+ or ``from <…>.target_module_name`` patterns where target_module_name
61
+ is the file stem of ``target_relpath``. Cheap regex-quality
62
+ proxy for a real symbol-resolution pass; the W6 acceptance is
63
+ "smoke covers 7 fix paths", not "full inbound-edge analysis".
64
+
65
+ Used purely to populate ``warnings`` when a move could break
66
+ inbound callers — the planner does not block on this signal.
67
+ """
68
+ target = Path(target_relpath)
69
+ stem = target.stem
70
+ if not stem or stem.startswith("_"):
71
+ return []
72
+ out: list[str] = []
73
+ for f in package_root.rglob("*.py"):
74
+ if f.is_dir():
75
+ continue
76
+ rel = f.relative_to(package_root).as_posix()
77
+ if rel == target_relpath:
78
+ continue
79
+ try:
80
+ text = f.read_text(encoding="utf-8", errors="replace")
81
+ except OSError:
82
+ continue
83
+ if f"import {stem}" not in text and f".{stem}" not in text:
84
+ continue
85
+ # Confirm via AST so we don't trip on string literals.
86
+ try:
87
+ tree = ast.parse(text, filename=str(f))
88
+ except SyntaxError:
89
+ continue
90
+ for node in ast.walk(tree):
91
+ if isinstance(node, ast.ImportFrom):
92
+ module = (node.module or "").split(".")
93
+ if stem in module:
94
+ out.append(rel)
95
+ break
96
+ if any(alias.name == stem for alias in node.names):
97
+ out.append(rel)
98
+ break
99
+ elif isinstance(node, ast.Import):
100
+ if any(alias.name.endswith("." + stem) or alias.name == stem
101
+ for alias in node.names):
102
+ out.append(rel)
103
+ break
104
+ return sorted(set(out))
105
+
106
+
107
+ def plan_actions(
108
+ wire_report: dict,
109
+ *,
110
+ package_root: Path | None = None,
111
+ ) -> list[EnforceAction]:
112
+ """Group violations by file and emit one EnforceAction per file.
113
+
114
+ A file with multiple violations to the same higher tier yields a
115
+ single move action covering them all. A file with violations to
116
+ DIFFERENT tiers gets a review_manually action with warnings.
117
+
118
+ ``package_root`` (optional): when provided, the planner inspects
119
+ inbound imports across the package and adds warnings; without it
120
+ the action's warnings list stays empty.
121
+ """
122
+ by_file: dict[str, list[dict]] = defaultdict(list)
123
+ for v in wire_report.get("violations", []) or []:
124
+ by_file[v["file"]].append(v)
125
+
126
+ actions: list[EnforceAction] = []
127
+ for file_path, viols in sorted(by_file.items()):
128
+ f_codes = {v.get("f_code", "") for v in viols}
129
+ to_tiers = {v["to_tier"] for v in viols}
130
+ # F0040 / F0049: not auto-fixable.
131
+ if "F0040" in f_codes or "F0049" in f_codes or not f_codes:
132
+ actions.append(EnforceAction(
133
+ f_code=next(iter(f_codes)) if f_codes else "F0049",
134
+ action="review_manually",
135
+ src=file_path,
136
+ dest="",
137
+ violations=viols,
138
+ auto_apply=False,
139
+ warnings=[
140
+ "violation requires manual review (a0 special-case "
141
+ "or non-canonical tier shape)",
142
+ ],
143
+ ))
144
+ continue
145
+ # Multiple distinct destination tiers: ambiguous.
146
+ if len(to_tiers) > 1:
147
+ actions.append(EnforceAction(
148
+ f_code=sorted(f_codes)[0],
149
+ action="review_manually",
150
+ src=file_path,
151
+ dest="",
152
+ violations=viols,
153
+ auto_apply=False,
154
+ warnings=[
155
+ f"file imports from multiple higher tiers ({sorted(to_tiers)}); "
156
+ "no single mechanical destination — review manually",
157
+ ],
158
+ ))
159
+ continue
160
+ # Single canonical move.
161
+ target_tier = next(iter(to_tiers))
162
+ src = file_path
163
+ dest = f"{target_tier}/{Path(src).name}"
164
+ warnings: list[str] = []
165
+ if package_root is not None:
166
+ try:
167
+ inbound = _file_imports_in_package(package_root, src)
168
+ except OSError:
169
+ inbound = []
170
+ if inbound:
171
+ warnings.append(
172
+ f"{len(inbound)} other file(s) import this module — "
173
+ "their imports will need rewriting after the move: "
174
+ + ", ".join(inbound[:5])
175
+ + ("…" if len(inbound) > 5 else "")
176
+ )
177
+ dest_path = package_root / dest
178
+ if dest_path.exists():
179
+ warnings.append(
180
+ f"destination {dest} already exists; move would clobber"
181
+ )
182
+ actions.append(EnforceAction(
183
+ f_code=sorted(f_codes)[0],
184
+ action="move_file_up",
185
+ src=src,
186
+ dest=dest,
187
+ violations=viols,
188
+ auto_apply=not warnings,
189
+ warnings=warnings,
190
+ ))
191
+ return actions
192
+
193
+
194
+ def summarize_plan(actions: list[EnforceAction]) -> dict:
195
+ """Reduce a plan to summary stats. Used by CLI human + JSON output."""
196
+ auto = sum(1 for a in actions if a.get("auto_apply"))
197
+ review = sum(1 for a in actions if not a.get("auto_apply"))
198
+ by_fcode: dict[str, int] = defaultdict(int)
199
+ for a in actions:
200
+ by_fcode[a.get("f_code", "F????")] += 1
201
+ return {
202
+ "schema_version": "atomadic-forge.enforce.plan/v1",
203
+ "action_count": len(actions),
204
+ "auto_apply_count": auto,
205
+ "review_count": review,
206
+ "by_fcode": dict(sorted(by_fcode.items())),
207
+ "actions": list(actions),
208
+ }
@@ -0,0 +1,105 @@
1
+ """Tier a1 — named error-message templates with concrete recovery commands.
2
+
3
+ Audit pain point (Lane B4): every CLI error should end with a suggested
4
+ next step. ``GEMINI_API_KEY not set`` becomes ``GEMINI_API_KEY not set
5
+ — here are three ways to recover``. The point is: a developer who
6
+ just installed Forge should never have to alt-tab to Stack Overflow.
7
+
8
+ This module is pure: it returns formatted strings. The CLI layer
9
+ decides whether to ``typer.echo`` them, fold them into a
10
+ ``typer.BadParameter``, or pipe them through structured JSON output.
11
+
12
+ Templates are looked up by stable name (e.g. ``provider_missing_key``)
13
+ so call sites can pass a dict of substitutions and we centralize wording
14
+ without touching every command. Adding a hint:
15
+
16
+ HINT_TEMPLATES["my_new_hint"] = '''… {var} …'''
17
+ format_hint("my_new_hint", var="x")
18
+ """
19
+ from __future__ import annotations
20
+
21
+ HINT_TEMPLATES: dict[str, str] = {
22
+ # ----- LLM provider errors -------------------------------------------
23
+ "provider_missing_key": (
24
+ "{provider!s} is selected but its API key is not set.\n"
25
+ "\n"
26
+ "Recovery options:\n"
27
+ " 1. Set the key: export {env_var!s}=<your-key>\n"
28
+ " 2. Get a free Gemini key: https://aistudio.google.com/apikey\n"
29
+ " 3. Use local Ollama: export FORGE_OLLAMA=1 && "
30
+ "ollama pull qwen2.5-coder:7b\n"
31
+ " 4. Use the offline stub: --provider stub (for tests / CI)\n"
32
+ "\n"
33
+ "Verify with: forge config test --provider {provider!s}"
34
+ ),
35
+ # ----- Wire / tier errors --------------------------------------------
36
+ "no_tier_dirs": (
37
+ "No tier directories were found at {path!s}.\n"
38
+ "\n"
39
+ "Expected at least three of:\n"
40
+ " a0_qk_constants/ a1_at_functions/ a2_mo_composites/\n"
41
+ " a3_og_features/ a4_sy_orchestration/\n"
42
+ "\n"
43
+ "Did you forget to materialize? Try:\n"
44
+ " forge auto <source-repo> {path!s} --apply --package <name>\n"
45
+ ),
46
+ "wire_fail_with_violations": (
47
+ "Wire scan found {count} upward-import violation(s).\n"
48
+ "\n"
49
+ "Recovery options:\n"
50
+ " 1. Get repair suggestions: forge wire {path!s} --suggest-repairs\n"
51
+ " 2. Get a JSON report: forge wire {path!s} --json > wire.json\n"
52
+ " 3. Gate this in CI: forge wire {path!s} --fail-on-violations\n"
53
+ ),
54
+ # ----- Certify errors -------------------------------------------------
55
+ "certify_below_threshold": (
56
+ "Certify score {score}/100 is below the {threshold}/100 gate.\n"
57
+ "\n"
58
+ "Inspect what failed and the cheapest path to recover:\n"
59
+ " forge certify {path!s} --json | python -m json.tool\n"
60
+ "\n"
61
+ "Common quick wins:\n"
62
+ " - documentation: add a README.md (free 25 points)\n"
63
+ " - tests: add tests/test_*.py (free 25 points)\n"
64
+ " - imports: forge wire {path!s} --suggest-repairs\n"
65
+ ),
66
+ "fail_under_out_of_range": (
67
+ "--fail-under must be between 0 and 100. Got: {value!r}\n"
68
+ "\n"
69
+ "Common values:\n"
70
+ " --fail-under 75 # team-grade target\n"
71
+ " --fail-under 90 # release-grade target\n"
72
+ ),
73
+ # ----- Manifest / file errors ----------------------------------------
74
+ "not_a_forge_manifest": (
75
+ "{path!s}: not a Forge JSON manifest.\n"
76
+ "\n"
77
+ "Expected a JSON object whose top-level `schema_version` field "
78
+ "starts with `atomadic-forge.` (e.g. `atomadic-forge.scout/v1`, "
79
+ "`atomadic-forge.wire/v1`, etc.).\n"
80
+ "\n"
81
+ "Forge manifests are written under .atomadic-forge/ when you run\n"
82
+ " forge auto / forge recon / forge cherry / forge finalize\n"
83
+ "with --apply or pass through ManifestStore."
84
+ ),
85
+ }
86
+
87
+
88
+ def format_hint(name: str, /, **fmt: object) -> str:
89
+ """Return the named hint template with ``fmt`` substituted in.
90
+
91
+ Raises KeyError on unknown hint names so typos surface in tests
92
+ rather than at runtime in front of users.
93
+ """
94
+ if name not in HINT_TEMPLATES:
95
+ raise KeyError(f"unknown hint template: {name!r}")
96
+ return HINT_TEMPLATES[name].format(**fmt)
97
+
98
+
99
+ def hint_lines(name: str, /, **fmt: object) -> list[str]:
100
+ """Same as ``format_hint`` but returns the lines as a list.
101
+
102
+ Useful when a caller wants to prepend `` `` to indent the hint
103
+ inside a larger error block.
104
+ """
105
+ return format_hint(name, **fmt).splitlines()