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.
- atomadic_forge/__init__.py +12 -0
- atomadic_forge/__main__.py +5 -0
- atomadic_forge/a0_qk_constants/__init__.py +1 -0
- atomadic_forge/a0_qk_constants/agent_plan_schema.py +120 -0
- atomadic_forge/a0_qk_constants/commandsmith_types.py +49 -0
- atomadic_forge/a0_qk_constants/config_defaults.py +38 -0
- atomadic_forge/a0_qk_constants/emergent_types.py +77 -0
- atomadic_forge/a0_qk_constants/error_codes.py +296 -0
- atomadic_forge/a0_qk_constants/forge_types.py +89 -0
- atomadic_forge/a0_qk_constants/gen_language.py +116 -0
- atomadic_forge/a0_qk_constants/lang_extensions.py +150 -0
- atomadic_forge/a0_qk_constants/policy_schema.py +48 -0
- atomadic_forge/a0_qk_constants/receipt_schema.py +311 -0
- atomadic_forge/a0_qk_constants/roi_constants.py +96 -0
- atomadic_forge/a0_qk_constants/semantic_types.py +61 -0
- atomadic_forge/a0_qk_constants/sidecar_schema.py +81 -0
- atomadic_forge/a0_qk_constants/synergy_types.py +62 -0
- atomadic_forge/a0_qk_constants/tier_names.py +47 -0
- atomadic_forge/a1_at_functions/__init__.py +1 -0
- atomadic_forge/a1_at_functions/agent_context_pack.py +193 -0
- atomadic_forge/a1_at_functions/agent_memory.py +139 -0
- atomadic_forge/a1_at_functions/agent_plan_emitter.py +324 -0
- atomadic_forge/a1_at_functions/agent_summary.py +277 -0
- atomadic_forge/a1_at_functions/body_extractor.py +306 -0
- atomadic_forge/a1_at_functions/card_renderer.py +210 -0
- atomadic_forge/a1_at_functions/certify_checks.py +445 -0
- atomadic_forge/a1_at_functions/chat_context.py +170 -0
- atomadic_forge/a1_at_functions/cherry_pick.py +71 -0
- atomadic_forge/a1_at_functions/classify_tier.py +115 -0
- atomadic_forge/a1_at_functions/commandsmith_discover.py +167 -0
- atomadic_forge/a1_at_functions/commandsmith_render.py +267 -0
- atomadic_forge/a1_at_functions/compiler_feedback.py +94 -0
- atomadic_forge/a1_at_functions/compliance_checker.py +228 -0
- atomadic_forge/a1_at_functions/config_io.py +68 -0
- atomadic_forge/a1_at_functions/cs1_renderer.py +588 -0
- atomadic_forge/a1_at_functions/doc_synthesizer.py +205 -0
- atomadic_forge/a1_at_functions/emergent_compose.py +192 -0
- atomadic_forge/a1_at_functions/emergent_rank.py +116 -0
- atomadic_forge/a1_at_functions/emergent_signature_extract.py +242 -0
- atomadic_forge/a1_at_functions/emergent_synthesize.py +88 -0
- atomadic_forge/a1_at_functions/enforce_planner.py +208 -0
- atomadic_forge/a1_at_functions/error_hints.py +105 -0
- atomadic_forge/a1_at_functions/evolution_log.py +94 -0
- atomadic_forge/a1_at_functions/forge_feedback.py +433 -0
- atomadic_forge/a1_at_functions/generation_quality.py +322 -0
- atomadic_forge/a1_at_functions/import_repair.py +211 -0
- atomadic_forge/a1_at_functions/import_smoke.py +102 -0
- atomadic_forge/a1_at_functions/js_parser.py +539 -0
- atomadic_forge/a1_at_functions/lineage_chain.py +144 -0
- atomadic_forge/a1_at_functions/lineage_reader.py +107 -0
- atomadic_forge/a1_at_functions/llm_client.py +554 -0
- atomadic_forge/a1_at_functions/local_signer.py +134 -0
- atomadic_forge/a1_at_functions/lsp_protocol.py +379 -0
- atomadic_forge/a1_at_functions/manifest_diff.py +314 -0
- atomadic_forge/a1_at_functions/mcp_protocol.py +1066 -0
- atomadic_forge/a1_at_functions/patch_scorer.py +267 -0
- atomadic_forge/a1_at_functions/plan_adapter.py +75 -0
- atomadic_forge/a1_at_functions/policy_loader.py +107 -0
- atomadic_forge/a1_at_functions/preflight_change.py +227 -0
- atomadic_forge/a1_at_functions/progress_reporter.py +81 -0
- atomadic_forge/a1_at_functions/provider_detect.py +157 -0
- atomadic_forge/a1_at_functions/provider_resolver.py +48 -0
- atomadic_forge/a1_at_functions/receipt_emitter.py +291 -0
- atomadic_forge/a1_at_functions/recipes.py +186 -0
- atomadic_forge/a1_at_functions/repo_explainer.py +124 -0
- atomadic_forge/a1_at_functions/roi_calculator.py +265 -0
- atomadic_forge/a1_at_functions/rollback_planner.py +147 -0
- atomadic_forge/a1_at_functions/sbom_emitter.py +155 -0
- atomadic_forge/a1_at_functions/scaffold_js.py +55 -0
- atomadic_forge/a1_at_functions/scaffold_pyproject.py +62 -0
- atomadic_forge/a1_at_functions/scaffold_starter.py +94 -0
- atomadic_forge/a1_at_functions/scout_walk.py +309 -0
- atomadic_forge/a1_at_functions/sidecar_parser.py +161 -0
- atomadic_forge/a1_at_functions/sidecar_validator.py +202 -0
- atomadic_forge/a1_at_functions/stub_detector.py +158 -0
- atomadic_forge/a1_at_functions/synergy_detect.py +166 -0
- atomadic_forge/a1_at_functions/synergy_render.py +252 -0
- atomadic_forge/a1_at_functions/synergy_surface_extract.py +163 -0
- atomadic_forge/a1_at_functions/test_runner.py +196 -0
- atomadic_forge/a1_at_functions/test_selector.py +122 -0
- atomadic_forge/a1_at_functions/tier_init_rebuild.py +122 -0
- atomadic_forge/a1_at_functions/tool_composer.py +130 -0
- atomadic_forge/a1_at_functions/transcript_log.py +70 -0
- atomadic_forge/a1_at_functions/wire_check.py +260 -0
- atomadic_forge/a2_mo_composites/__init__.py +1 -0
- atomadic_forge/a2_mo_composites/lineage_chain_store.py +122 -0
- atomadic_forge/a2_mo_composites/manifest_store.py +46 -0
- atomadic_forge/a2_mo_composites/plan_store.py +164 -0
- atomadic_forge/a2_mo_composites/receipt_signer.py +231 -0
- atomadic_forge/a3_og_features/__init__.py +1 -0
- atomadic_forge/a3_og_features/commandsmith_feature.py +267 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/__init__.py +3 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a0_qk_constants/__init__.py +4 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a1_at_functions/__init__.py +14 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/conftest.py +10 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/test_mixed.py +18 -0
- atomadic_forge/a3_og_features/demo_runner.py +502 -0
- atomadic_forge/a3_og_features/emergent_feature.py +95 -0
- atomadic_forge/a3_og_features/emergent_pipeline_integration.py +154 -0
- atomadic_forge/a3_og_features/forge_enforce.py +107 -0
- atomadic_forge/a3_og_features/forge_evolve.py +176 -0
- atomadic_forge/a3_og_features/forge_loop.py +528 -0
- atomadic_forge/a3_og_features/forge_pipeline.py +295 -0
- atomadic_forge/a3_og_features/forge_plan_apply.py +222 -0
- atomadic_forge/a3_og_features/lsp_server.py +98 -0
- atomadic_forge/a3_og_features/mcp_server.py +160 -0
- atomadic_forge/a3_og_features/setup_wizard.py +337 -0
- atomadic_forge/a3_og_features/synergy_feature.py +65 -0
- atomadic_forge/a4_sy_orchestration/__init__.py +1 -0
- atomadic_forge/a4_sy_orchestration/cli.py +1284 -0
- atomadic_forge/commands/__init__.py +1 -0
- atomadic_forge/commands/_registry.py +36 -0
- atomadic_forge/commands/audit.py +142 -0
- atomadic_forge/commands/chat.py +133 -0
- atomadic_forge/commands/commandsmith.py +178 -0
- atomadic_forge/commands/config_cmd.py +145 -0
- atomadic_forge/commands/demo.py +142 -0
- atomadic_forge/commands/emergent.py +124 -0
- atomadic_forge/commands/emergent_then_synergy.py +70 -0
- atomadic_forge/commands/evolve.py +122 -0
- atomadic_forge/commands/evolve_then_iterate.py +70 -0
- atomadic_forge/commands/feature_then_emergent.py +111 -0
- atomadic_forge/commands/iterate.py +140 -0
- atomadic_forge/commands/synergy.py +96 -0
- atomadic_forge/commands/synergy_then_emergent.py +70 -0
- atomadic_forge-0.3.2.dist-info/METADATA +471 -0
- atomadic_forge-0.3.2.dist-info/RECORD +131 -0
- atomadic_forge-0.3.2.dist-info/WHEEL +5 -0
- atomadic_forge-0.3.2.dist-info/entry_points.txt +3 -0
- atomadic_forge-0.3.2.dist-info/licenses/LICENSE +15 -0
- 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
|