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,163 @@
1
+ """Tier a1 — pure feature-surface extractor for the Synergy Scan.
2
+
3
+ Walks ``commands/`` modules + the unified CLI to build a
4
+ :class:`FeatureSurfaceCard` per CLI verb. The card captures:
5
+
6
+ * CLI option names
7
+ * file-pattern args (anything ending in ``.json`` / ``.yaml`` / ``.md`` …)
8
+ * product mentions in help text (``--json-out``, ``--save``, ``MANIFEST``)
9
+ * schema strings (``atomadic-forge.<x>/v1``) referenced in source
10
+ * a phase hint (``recon`` / ``ingest`` / ``materialize`` / ``emit`` / …)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from ..a0_qk_constants.synergy_types import FeatureSurfaceCard
20
+
21
+ _PHASE_VOCAB = {
22
+ "recon": {"recon", "scout", "inventory", "discover"},
23
+ "ingest": {"ingest", "scan", "harvest", "extract"},
24
+ "plan": {"plan", "policy", "blueprint"},
25
+ "materialize": {"materialize", "rebuild", "synth", "synthesise", "synthesize",
26
+ "wrap", "generate"},
27
+ "certify": {"certify", "verify", "audit", "smoke", "lint"},
28
+ "emit": {"emit", "save", "publish", "report", "manifest", "json-out"},
29
+ "register": {"register", "wire", "sync", "install"},
30
+ }
31
+
32
+ _FILE_RE = re.compile(r"\b([\w\-./]+\.(?:json|yaml|yml|md|toml|txt))\b")
33
+ _SCHEMA_RE = re.compile(r"atomadic-forge\.[a-z0-9._\-]+/v\d+")
34
+
35
+
36
+ def _docstring(node: ast.AST) -> str:
37
+ return (ast.get_docstring(node) or "").strip()
38
+
39
+
40
+ def _split_words(text: str) -> set[str]:
41
+ return {w.lower() for w in re.findall(r"[A-Za-z][A-Za-z0-9_\-]+", text or "")
42
+ if len(w) >= 3}
43
+
44
+
45
+ def _phase_hint(words: set[str], help_text: str, name: str) -> str:
46
+ haystack = words | {name.lower()}
47
+ haystack |= _split_words(help_text)
48
+ best, score = "misc", 0
49
+ for phase, terms in _PHASE_VOCAB.items():
50
+ hits = len(terms & haystack)
51
+ if hits > score:
52
+ best, score = phase, hits
53
+ return best
54
+
55
+
56
+ def _option_args(fn: ast.FunctionDef) -> tuple[list[str], list[str]]:
57
+ """Return (canonical option names, file-pattern hints)."""
58
+ opts: list[str] = []
59
+ files: list[str] = []
60
+ for arg in fn.args.args:
61
+ if arg.arg in ("self", "cls", "ctx"):
62
+ continue
63
+ opts.append(arg.arg.replace("_", "-"))
64
+ ann = ast.unparse(arg.annotation) if arg.annotation else ""
65
+ if "Path" in ann or "json" in ann.lower():
66
+ files.append(arg.arg)
67
+ return opts, files
68
+
69
+
70
+ def _typer_decorator_name(dec: ast.AST) -> str:
71
+ if isinstance(dec, ast.Call):
72
+ return _typer_decorator_name(dec.func)
73
+ if isinstance(dec, ast.Attribute):
74
+ return dec.attr
75
+ if isinstance(dec, ast.Name):
76
+ return dec.id
77
+ return ""
78
+
79
+
80
+ def _is_typer_command(fn: ast.FunctionDef) -> bool:
81
+ for d in fn.decorator_list:
82
+ if _typer_decorator_name(d) == "command":
83
+ return True
84
+ return False
85
+
86
+
87
+ def _harvest_module(py_file: Path, module: str) -> list[FeatureSurfaceCard]:
88
+ try:
89
+ text = py_file.read_text(encoding="utf-8")
90
+ tree = ast.parse(text)
91
+ except (SyntaxError, OSError):
92
+ return []
93
+ schemas = sorted(set(_SCHEMA_RE.findall(text)))
94
+ cards: list[FeatureSurfaceCard] = []
95
+ module_help = _docstring(tree).split("\n", 1)[0].strip()
96
+ # If module exposes its own ``app: typer.Typer`` (whole sub-CLI), make it
97
+ # one feature whose 'verbs' are the @app.command()-decorated functions.
98
+ has_typer_app = any(
99
+ isinstance(node, ast.Assign)
100
+ and any(isinstance(t, ast.Name) and t.id == "app" for t in node.targets)
101
+ for node in tree.body
102
+ )
103
+ inputs: list[str] = []
104
+ input_files: list[str] = []
105
+ outputs: list[str] = []
106
+ output_files: list[str] = []
107
+ vocab: set[str] = _split_words(module_help)
108
+ for node in tree.body:
109
+ if isinstance(node, ast.FunctionDef):
110
+ if not _is_typer_command(node) and node.name != "register":
111
+ continue
112
+ opts, files = _option_args(node)
113
+ inputs.extend(opts)
114
+ input_files.extend(files)
115
+ doc = _docstring(node)
116
+ vocab |= _split_words(doc)
117
+ for hit in _FILE_RE.findall(doc):
118
+ if any(t in opts for t in ("save", "out", "json")):
119
+ output_files.append(hit)
120
+ else:
121
+ input_files.append(hit)
122
+ for opt in opts:
123
+ if "out" in opt or "save" in opt or "report" in opt or "json" in opt:
124
+ outputs.append(opt)
125
+ if has_typer_app or any(node for node in tree.body
126
+ if isinstance(node, ast.FunctionDef)
127
+ and _is_typer_command(node)):
128
+ name = py_file.stem
129
+ cards.append(FeatureSurfaceCard(
130
+ name=name.replace("_", "-").rstrip("-cli"),
131
+ module=module,
132
+ help_text=module_help,
133
+ inputs=sorted(set(inputs)),
134
+ input_files=sorted(set(input_files)),
135
+ outputs=sorted(set(outputs)),
136
+ output_files=sorted(set(output_files)),
137
+ schemas=schemas,
138
+ vocabulary=sorted(vocab),
139
+ phase_hint=_phase_hint(vocab, module_help, name),
140
+ ))
141
+ return cards
142
+
143
+
144
+ def harvest_feature_surfaces(
145
+ package_root: Path,
146
+ package: str = "atomadic_forge",
147
+ ) -> list[FeatureSurfaceCard]:
148
+ """Walk ``<package_root>/<package>/commands/`` and harvest one card per file."""
149
+ base = package_root / package / "commands"
150
+ if not base.exists():
151
+ return []
152
+ out: list[FeatureSurfaceCard] = []
153
+ for py in sorted(base.rglob("*.py")):
154
+ if py.name.startswith("_"):
155
+ continue
156
+ rel = py.relative_to(package_root).with_suffix("")
157
+ module = ".".join(rel.parts)
158
+ out.extend(_harvest_module(py, module))
159
+ # Also include the unified CLI's hand-wired top-level commands.
160
+ unified = package_root / package / "a4_sy_orchestration" / "unified_cli.py"
161
+ if unified.exists():
162
+ out.extend(_harvest_module(unified, f"{package}.a4_sy_orchestration.unified_cli"))
163
+ return out
@@ -0,0 +1,196 @@
1
+ """Tier a1 — pure pytest runner for the generated package.
2
+
3
+ After every iterate turn, Forge runs ``pytest tests/`` against the
4
+ emitted code and credits the certify score by the pass-ratio. This is
5
+ the *behavioral* check — wire/import says the package loads, but only a
6
+ running test says it actually does what was asked.
7
+
8
+ The runner is subprocess-based so it isolates from the parent process
9
+ state. Returns a structured report with passed/failed/error counts plus
10
+ the trimmed pytest stdout/stderr for LLM feedback.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import TypedDict
21
+
22
+
23
+ class TestRunReport(TypedDict):
24
+ schema_version: str # "atomadic-forge.test_run/v1"
25
+ tests_dir: str
26
+ ran: bool # were any tests collected?
27
+ passed: int
28
+ failed: int
29
+ errors: int
30
+ skipped: int
31
+ total: int
32
+ pass_ratio: float # 0.0 .. 1.0
33
+ duration_ms: int
34
+ pytest_summary: str # last ~30 lines of stdout
35
+ failure_excerpts: list[str] # top-N failed test names + first lines of traceback
36
+
37
+
38
+ _SUMMARY_RE = re.compile(
39
+ r"(?:(\d+)\s+failed)?,?\s*(?:(\d+)\s+passed)?,?\s*"
40
+ r"(?:(\d+)\s+error)?,?\s*(?:(\d+)\s+skipped)?",
41
+ re.IGNORECASE,
42
+ )
43
+ # Pytest's terminal summary line — matches both decorated and -q forms:
44
+ # "===== 3 passed, 1 failed in 0.42s ====="
45
+ # "1 passed in 0.03s"
46
+ # "2 failed, 3 passed in 1.12s"
47
+ _FINAL_LINE_RE = re.compile(
48
+ r"=*\s*"
49
+ r"(?:(\d+)\s+failed)?,?\s*"
50
+ r"(?:(\d+)\s+passed)?,?\s*"
51
+ r"(?:(\d+)\s+error[s]?)?,?\s*"
52
+ r"(?:(\d+)\s+skipped)?\s*"
53
+ r"in\s+[\d.]+s",
54
+ re.IGNORECASE,
55
+ )
56
+
57
+
58
+ def _parse_pytest_summary(stdout: str) -> tuple[int, int, int, int]:
59
+ """Return ``(passed, failed, errors, skipped)`` parsed from pytest stdout."""
60
+ passed = failed = errors = skipped = 0
61
+ for line in reversed(stdout.splitlines()):
62
+ m = _FINAL_LINE_RE.search(line)
63
+ if m:
64
+ failed = int(m.group(1) or 0)
65
+ passed = int(m.group(2) or 0)
66
+ errors = int(m.group(3) or 0)
67
+ skipped = int(m.group(4) or 0)
68
+ break
69
+ return passed, failed, errors, skipped
70
+
71
+
72
+ def _extract_failure_excerpts(stdout: str, max_failures: int = 5) -> list[str]:
73
+ """Pull the FAILED test ids + a short excerpt of each traceback."""
74
+ out: list[str] = []
75
+ failed_ids: list[str] = []
76
+ for line in stdout.splitlines():
77
+ if line.startswith("FAILED "):
78
+ # format: "FAILED tests/test_x.py::test_y - AssertionError: ..."
79
+ failed_ids.append(line.replace("FAILED ", "").strip())
80
+ for fid in failed_ids[:max_failures]:
81
+ out.append(fid)
82
+ return out
83
+
84
+
85
+ def _filter_tests_to_package(tests_dir: Path, package: str) -> list[str]:
86
+ """Return list of test file paths whose imports reference ``package``.
87
+
88
+ Closes the wrong-package gameability hole: an LLM that emits files into
89
+ `forge_greeter` but tests for `forge_greeter` shouldn't credit the
90
+ behavioural score against a request for `mdconv`. This requires every
91
+ counted test file to import the requested package.
92
+ """
93
+ out: list[str] = []
94
+ for f in sorted(tests_dir.rglob("test_*.py")):
95
+ if "__pycache__" in f.parts:
96
+ continue
97
+ try:
98
+ text = f.read_text(encoding="utf-8", errors="replace")
99
+ except OSError:
100
+ continue
101
+ # Match `from <pkg>.…` or `import <pkg>` (word-boundary).
102
+ if re.search(rf"\b(?:from|import)\s+{re.escape(package)}\b", text):
103
+ out.append(str(f))
104
+ return out
105
+
106
+
107
+ def run_pytest(*, output_root: Path, package: str | None = None,
108
+ tests_subdir: str = "tests",
109
+ timeout_s: float = 60.0) -> TestRunReport:
110
+ """Run ``pytest <tests_subdir>`` inside ``output_root``.
111
+
112
+ When ``package`` is given, ONLY tests that import that package count
113
+ toward the pass-ratio. This prevents wrong-package gaming where the
114
+ LLM emits code into ``forge_greeter`` but the request was ``mdconv`` —
115
+ forge_greeter's tests would otherwise credit the score.
116
+
117
+ ``PYTHONPATH`` is set so ``src/`` is importable without a pip install.
118
+ Skips cleanly when the tests dir doesn't exist.
119
+ """
120
+ import time
121
+ output_root = Path(output_root).resolve()
122
+ tests_dir = output_root / tests_subdir
123
+ src_root = output_root / "src"
124
+ if not tests_dir.exists():
125
+ return TestRunReport(
126
+ schema_version="atomadic-forge.test_run/v1",
127
+ tests_dir=str(tests_dir), ran=False, passed=0, failed=0,
128
+ errors=0, skipped=0, total=0, pass_ratio=0.0, duration_ms=0,
129
+ pytest_summary="(no tests/ directory)",
130
+ failure_excerpts=[],
131
+ )
132
+
133
+ # Filter to tests that actually target the requested package.
134
+ targeted: list[str] | None = None
135
+ if package:
136
+ targeted = _filter_tests_to_package(tests_dir, package)
137
+ if not targeted:
138
+ return TestRunReport(
139
+ schema_version="atomadic-forge.test_run/v1",
140
+ tests_dir=str(tests_dir), ran=True, passed=0, failed=0,
141
+ errors=0, skipped=0, total=0, pass_ratio=0.0, duration_ms=0,
142
+ pytest_summary=(
143
+ f"(no test files import package {package!r} — refused to "
144
+ "credit unrelated tests)"
145
+ ),
146
+ failure_excerpts=[],
147
+ )
148
+ env = {**os.environ}
149
+ pp = env.get("PYTHONPATH", "")
150
+ sep = ";" if sys.platform == "win32" else ":"
151
+ new_pp = str(src_root) if not pp else f"{src_root}{sep}{pp}"
152
+ env["PYTHONPATH"] = new_pp
153
+ env["PYTHONIOENCODING"] = "utf-8"
154
+ env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1"
155
+
156
+ pytest_targets = targeted if targeted else [str(tests_dir)]
157
+ start = time.perf_counter()
158
+ try:
159
+ proc = subprocess.run(
160
+ [sys.executable, "-m", "pytest", *pytest_targets,
161
+ "-o", "addopts=", "--tb=line", "-q", "--no-header",
162
+ "-p", "no:cacheprovider"],
163
+ cwd=str(output_root), env=env,
164
+ capture_output=True, text=True, timeout=timeout_s,
165
+ encoding="utf-8", errors="replace",
166
+ )
167
+ duration_ms = int((time.perf_counter() - start) * 1000)
168
+ except subprocess.TimeoutExpired:
169
+ return TestRunReport(
170
+ schema_version="atomadic-forge.test_run/v1",
171
+ tests_dir=str(tests_dir), ran=True, passed=0, failed=0,
172
+ errors=1, skipped=0, total=1, pass_ratio=0.0,
173
+ duration_ms=int(timeout_s * 1000),
174
+ pytest_summary=f"timeout after {timeout_s}s",
175
+ failure_excerpts=[],
176
+ )
177
+
178
+ stdout = proc.stdout or ""
179
+ stderr = proc.stderr or ""
180
+ passed, failed, errors, skipped = _parse_pytest_summary(stdout)
181
+ total = passed + failed + errors # skipped doesn't count toward ratio
182
+ ratio = (passed / total) if total > 0 else 0.0
183
+
184
+ # The combined output is what we pass back to the LLM.
185
+ summary_lines = (stdout + ("\n" + stderr if stderr else "")).splitlines()
186
+ summary = "\n".join(summary_lines[-40:]).strip()
187
+
188
+ return TestRunReport(
189
+ schema_version="atomadic-forge.test_run/v1",
190
+ tests_dir=str(tests_dir),
191
+ ran=total > 0 or errors > 0,
192
+ passed=passed, failed=failed, errors=errors, skipped=skipped,
193
+ total=total, pass_ratio=ratio, duration_ms=duration_ms,
194
+ pytest_summary=summary,
195
+ failure_excerpts=_extract_failure_excerpts(stdout),
196
+ )
@@ -0,0 +1,122 @@
1
+ """Tier a1 — select_tests: per-intent test selection (Codex #7).
2
+
3
+ Codex's prescription:
4
+
5
+ > Agents over-run tests or under-run them. Add select_tests({
6
+ > changed_files, intent }). Returns minimum test set, full-
7
+ > confidence test set, commands, why those tests matter.
8
+
9
+ Pure: takes paths + intent, walks tests/ to match by name + path
10
+ heuristics. The 'minimum' set is the mirror-style direct match
11
+ (tests/test_<stem>.py); the 'full' set adds tier-mate tests + any
12
+ tests under the proposed file's tier.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import TypedDict
18
+
19
+ SCHEMA_VERSION_TEST_SELECT_V1 = "atomadic-forge.test_select/v1"
20
+
21
+ _TIER_NAMES = (
22
+ "a0_qk_constants", "a1_at_functions", "a2_mo_composites",
23
+ "a3_og_features", "a4_sy_orchestration",
24
+ )
25
+
26
+
27
+ class TestSelection(TypedDict, total=False):
28
+ schema_version: str
29
+ intent: str
30
+ changed_files: list[str]
31
+ minimum_tests: list[str]
32
+ full_tests: list[str]
33
+ minimum_command: str
34
+ full_command: str
35
+ rationale: list[str]
36
+
37
+
38
+ def _detect_tier(path: str) -> str | None:
39
+ for part in path.split("/"):
40
+ if part in _TIER_NAMES:
41
+ return part
42
+ return None
43
+
44
+
45
+ def _list_tests(project_root: Path) -> list[str]:
46
+ candidates: list[str] = []
47
+ for sub in ("tests", "test"):
48
+ d = project_root / sub
49
+ if not d.is_dir():
50
+ continue
51
+ for f in sorted(d.rglob("test_*.py")):
52
+ candidates.append(str(f.relative_to(project_root).as_posix()))
53
+ for f in sorted(d.rglob("*_test.py")):
54
+ candidates.append(str(f.relative_to(project_root).as_posix()))
55
+ return sorted(set(candidates))
56
+
57
+
58
+ def select_tests(
59
+ *,
60
+ intent: str,
61
+ changed_files: list[str],
62
+ project_root: Path,
63
+ ) -> TestSelection:
64
+ """Compute minimum + full test sets for a proposed change."""
65
+ project_root = Path(project_root).resolve()
66
+ all_tests = _list_tests(project_root)
67
+ minimum: set[str] = set()
68
+ full: set[str] = set()
69
+ rationale: list[str] = []
70
+
71
+ for path in changed_files:
72
+ stem = Path(path).stem
73
+ tier = _detect_tier(path)
74
+ # Direct mirror match
75
+ for t in all_tests:
76
+ tname = Path(t).stem
77
+ if tname in (f"test_{stem}", f"{stem}_test"):
78
+ minimum.add(t)
79
+ full.add(t)
80
+ # Tier-mate matches
81
+ if tier:
82
+ for t in all_tests:
83
+ if f"/{tier}/" in t or t.endswith(f"/{tier}"):
84
+ full.add(t)
85
+ # Any test mentioning the stem in its filename gets full.
86
+ for t in all_tests:
87
+ if stem and stem in Path(t).stem and stem != Path(t).stem:
88
+ full.add(t)
89
+ if not minimum and all_tests:
90
+ # No mirror match — the safe minimum is full + a clear note.
91
+ rationale.append(
92
+ "no mirror-name test match for any changed file; "
93
+ "minimum_tests promoted to the full set so coverage is "
94
+ "preserved."
95
+ )
96
+ minimum = set(full or all_tests)
97
+ if not full:
98
+ full = set(all_tests)
99
+ rationale.append(
100
+ "no tier-mate matches found; full_tests defaulted to the "
101
+ "complete tests/ tree."
102
+ )
103
+ rationale.append(
104
+ f"intent: {intent[:200]}" if intent
105
+ else "no intent provided — selection is path-based only."
106
+ )
107
+
108
+ minimum_list = sorted(minimum)
109
+ full_list = sorted(full)
110
+ return TestSelection(
111
+ schema_version=SCHEMA_VERSION_TEST_SELECT_V1,
112
+ intent=intent,
113
+ changed_files=list(changed_files),
114
+ minimum_tests=minimum_list,
115
+ full_tests=full_list,
116
+ minimum_command=(
117
+ "python -m pytest " + " ".join(minimum_list)
118
+ if minimum_list else "python -m pytest"
119
+ ),
120
+ full_command="python -m pytest",
121
+ rationale=rationale,
122
+ )
@@ -0,0 +1,122 @@
1
+ """Tier a1 — pure rebuilder for each tier's ``__init__.py`` re-exports.
2
+
3
+ After every iterate turn, Forge regenerates each ``aN_*/__init__.py`` so
4
+ every public name in the tier becomes importable via the tier path:
5
+
6
+ from pkg.a1_at_functions import generate_slug, normalize_url
7
+
8
+ That style is what most LLMs (and humans) reach for, but it doesn't work
9
+ unless the tier's ``__init__.py`` re-exports. Forge handles this
10
+ deterministically so the LLM's emitted code 'just works'.
11
+
12
+ Idempotent: running multiple times produces the same output. The
13
+ generated banner ``# Auto-managed by atomadic-forge`` lets future runs
14
+ detect and refresh; hand-written init files without that banner are left
15
+ alone.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ast
21
+ from pathlib import Path
22
+
23
+ _BANNER = "# Auto-managed by atomadic-forge — re-exports every public name in this tier."
24
+ _INIT_TEMPLATE = (
25
+ '"""Tier {tier} — re-exports."""\n'
26
+ "{banner}\n"
27
+ "\n"
28
+ "{imports}"
29
+ "\n"
30
+ '__all__ = [{all_list}]\n'
31
+ )
32
+
33
+
34
+ def _public_names_in_module(py_file: Path) -> list[str]:
35
+ """Return public function/class/constant names defined at module top level."""
36
+ try:
37
+ tree = ast.parse(py_file.read_text(encoding="utf-8"))
38
+ except (SyntaxError, OSError):
39
+ return []
40
+ names: list[str] = []
41
+ for node in tree.body:
42
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
43
+ if not node.name.startswith("_"):
44
+ names.append(node.name)
45
+ elif isinstance(node, ast.Assign):
46
+ for t in node.targets:
47
+ if isinstance(t, ast.Name) and not t.id.startswith("_"):
48
+ names.append(t.id)
49
+ elif isinstance(node, ast.AnnAssign):
50
+ if isinstance(node.target, ast.Name) and not node.target.id.startswith("_"):
51
+ names.append(node.target.id)
52
+ # de-dupe preserving order
53
+ seen: set[str] = set()
54
+ out: list[str] = []
55
+ for n in names:
56
+ if n not in seen:
57
+ seen.add(n)
58
+ out.append(n)
59
+ return out
60
+
61
+
62
+ def render_tier_init(tier: str, *, modules: dict[str, list[str]]) -> str:
63
+ """Render an ``__init__.py`` for one tier.
64
+
65
+ ``modules`` is ``{module_stem: [public_name, ...]}`` for every ``.py``
66
+ file in that tier dir (excluding ``__init__``).
67
+ """
68
+ if not modules:
69
+ return f'"""Tier {tier} — empty."""\n'
70
+ import_lines: list[str] = []
71
+ flat_names: list[str] = []
72
+ for module_stem in sorted(modules):
73
+ names = sorted(modules[module_stem])
74
+ if not names:
75
+ continue
76
+ joined = ", ".join(names)
77
+ import_lines.append(f"from .{module_stem} import {joined}")
78
+ flat_names.extend(names)
79
+ if not import_lines:
80
+ return f'"""Tier {tier} — empty."""\n'
81
+ all_list = ", ".join(repr(n) for n in sorted(set(flat_names)))
82
+ return _INIT_TEMPLATE.format(
83
+ tier=tier,
84
+ banner=_BANNER,
85
+ imports="\n".join(import_lines) + "\n",
86
+ all_list=all_list,
87
+ )
88
+
89
+
90
+ def rebuild_tier_inits(package_root: Path) -> dict[str, str]:
91
+ """Rebuild every ``aN_*/__init__.py`` under ``package_root``.
92
+
93
+ Returns a map of ``relative_path -> new_content`` for the files that
94
+ Forge re-generated (skips files without the banner — those are
95
+ user-edited and we leave them alone).
96
+ """
97
+ package_root = Path(package_root)
98
+ written: dict[str, str] = {}
99
+ for tier_dir in sorted(package_root.iterdir()):
100
+ if not tier_dir.is_dir():
101
+ continue
102
+ if not tier_dir.name.startswith(("a0_", "a1_", "a2_", "a3_", "a4_")):
103
+ continue
104
+ modules: dict[str, list[str]] = {}
105
+ for py in sorted(tier_dir.glob("*.py")):
106
+ if py.name == "__init__.py":
107
+ continue
108
+ names = _public_names_in_module(py)
109
+ if names:
110
+ modules[py.stem] = names
111
+ new_text = render_tier_init(tier_dir.name, modules=modules)
112
+ init = tier_dir / "__init__.py"
113
+ # Don't clobber a hand-written init (no banner ⇒ assume user-managed).
114
+ if init.exists():
115
+ current = init.read_text(encoding="utf-8")
116
+ if _BANNER not in current and current.strip() not in (
117
+ "", f'"""{tier_dir.name}."""',
118
+ ):
119
+ continue
120
+ init.write_text(new_text, encoding="utf-8")
121
+ written[init.relative_to(package_root).as_posix()] = new_text
122
+ return written