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,445 @@
|
|
|
1
|
+
"""Tier a1 — pure certification checks for a Forge-shaped repo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..a0_qk_constants.lang_extensions import (
|
|
9
|
+
ALL_SOURCE_EXTS,
|
|
10
|
+
path_parts_contain_ignored_dir,
|
|
11
|
+
)
|
|
12
|
+
from ..a0_qk_constants.tier_names import TIER_NAMES
|
|
13
|
+
from .import_smoke import import_smoke
|
|
14
|
+
from .stub_detector import detect_stubs, stub_penalty
|
|
15
|
+
from .test_runner import run_pytest
|
|
16
|
+
from .wire_check import scan_violations
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_under_ignored(rel_parts: tuple[str, ...]) -> bool:
|
|
20
|
+
"""Path-segment check against IGNORED_DIRS (used by every walk)."""
|
|
21
|
+
return path_parts_contain_ignored_dir(rel_parts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_documentation(root: Path) -> tuple[bool, dict]:
|
|
25
|
+
"""Documentation signal — markdown anywhere meaningful counts.
|
|
26
|
+
|
|
27
|
+
Recognises:
|
|
28
|
+
* README at root (``.md`` / ``.rst`` / no extension)
|
|
29
|
+
* Any ``.md`` / ``.markdown`` / ``.mdx`` under ``docs/``, ``doc/``,
|
|
30
|
+
``documentation/``, ``guides/``, ``guide/``
|
|
31
|
+
* The repo passes if README exists, OR there are ≥2 doc files
|
|
32
|
+
anywhere in the recognised doc directories.
|
|
33
|
+
"""
|
|
34
|
+
readme_names = {"README.md", "README.rst", "README.markdown",
|
|
35
|
+
"README", "readme.md"}
|
|
36
|
+
readme = any((root / n).exists() for n in readme_names)
|
|
37
|
+
|
|
38
|
+
# Find every directory anywhere in the tree whose basename matches a
|
|
39
|
+
# known doc-folder convention. Catches both top-level (./docs/) and
|
|
40
|
+
# nested (./cognition/guides/) layouts. IGNORED_DIRS still apply.
|
|
41
|
+
DOC_DIR_NAMES = {"docs", "doc", "documentation", "guides", "guide"}
|
|
42
|
+
doc_dirs_found: list[str] = []
|
|
43
|
+
doc_files: set[Path] = set()
|
|
44
|
+
samples: list[str] = []
|
|
45
|
+
seen_dirs: set[Path] = set()
|
|
46
|
+
for d in root.rglob("*"):
|
|
47
|
+
if not d.is_dir() or d.name not in DOC_DIR_NAMES:
|
|
48
|
+
continue
|
|
49
|
+
rel_parts = d.relative_to(root).parts
|
|
50
|
+
if _is_under_ignored(rel_parts):
|
|
51
|
+
continue
|
|
52
|
+
if d in seen_dirs:
|
|
53
|
+
continue
|
|
54
|
+
seen_dirs.add(d)
|
|
55
|
+
doc_dirs_found.append(d.relative_to(root).as_posix())
|
|
56
|
+
for p in d.rglob("*"):
|
|
57
|
+
if not p.is_file():
|
|
58
|
+
continue
|
|
59
|
+
rel_parts_p = p.relative_to(root).parts
|
|
60
|
+
if _is_under_ignored(rel_parts_p):
|
|
61
|
+
continue
|
|
62
|
+
if p.suffix.lower() in {".md", ".markdown", ".mdx", ".rst"}:
|
|
63
|
+
doc_files.add(p)
|
|
64
|
+
if len(samples) < 5:
|
|
65
|
+
samples.append(p.relative_to(root).as_posix())
|
|
66
|
+
|
|
67
|
+
doc_count = len(doc_files)
|
|
68
|
+
ok = readme or doc_count >= 2
|
|
69
|
+
return ok, {
|
|
70
|
+
"readme": readme,
|
|
71
|
+
"docs_md_count": doc_count,
|
|
72
|
+
"doc_dirs_found": doc_dirs_found,
|
|
73
|
+
"doc_samples": samples,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_tests_present(root: Path) -> tuple[bool, dict]:
|
|
78
|
+
"""Return (ok, detail). Recognises Python AND JS/TS test conventions.
|
|
79
|
+
|
|
80
|
+
Counts:
|
|
81
|
+
Python — ``tests/test_*.py`` or ``tests/*_test.py``
|
|
82
|
+
JS/TS — ``tests/*.test.{js,mjs,jsx,ts,tsx}`` or ``*.spec.{js,…}``,
|
|
83
|
+
plus the ``__tests__/`` directory convention.
|
|
84
|
+
"""
|
|
85
|
+
py_tests: set[Path] = set()
|
|
86
|
+
js_tests: set[Path] = set()
|
|
87
|
+
seen_dirs: set[Path] = set()
|
|
88
|
+
for d in root.rglob("tests"):
|
|
89
|
+
if not d.is_dir():
|
|
90
|
+
continue
|
|
91
|
+
rel_parts = d.relative_to(root).parts
|
|
92
|
+
if _is_under_ignored(rel_parts):
|
|
93
|
+
continue
|
|
94
|
+
if d in seen_dirs:
|
|
95
|
+
continue
|
|
96
|
+
seen_dirs.add(d)
|
|
97
|
+
py_tests.update(
|
|
98
|
+
p for p in d.rglob("test_*.py")
|
|
99
|
+
if not _is_under_ignored(p.relative_to(root).parts)
|
|
100
|
+
)
|
|
101
|
+
py_tests.update(
|
|
102
|
+
p for p in d.rglob("*_test.py")
|
|
103
|
+
if not _is_under_ignored(p.relative_to(root).parts)
|
|
104
|
+
)
|
|
105
|
+
for ext in (".js", ".mjs", ".jsx", ".cjs", ".ts", ".tsx"):
|
|
106
|
+
js_tests.update(
|
|
107
|
+
p for p in d.rglob(f"*.test{ext}")
|
|
108
|
+
if not _is_under_ignored(p.relative_to(root).parts)
|
|
109
|
+
)
|
|
110
|
+
js_tests.update(
|
|
111
|
+
p for p in d.rglob(f"*.spec{ext}")
|
|
112
|
+
if not _is_under_ignored(p.relative_to(root).parts)
|
|
113
|
+
)
|
|
114
|
+
# __tests__ convention (Jest etc.)
|
|
115
|
+
for d in root.rglob("__tests__"):
|
|
116
|
+
if not d.is_dir():
|
|
117
|
+
continue
|
|
118
|
+
rel_parts = d.relative_to(root).parts
|
|
119
|
+
if _is_under_ignored(rel_parts):
|
|
120
|
+
continue
|
|
121
|
+
for ext in (".js", ".mjs", ".jsx", ".cjs", ".ts", ".tsx"):
|
|
122
|
+
js_tests.update(
|
|
123
|
+
p for p in d.rglob(f"*{ext}")
|
|
124
|
+
if not _is_under_ignored(p.relative_to(root).parts)
|
|
125
|
+
)
|
|
126
|
+
total = len(py_tests) + len(js_tests)
|
|
127
|
+
return total > 0, {
|
|
128
|
+
"test_files_found": total,
|
|
129
|
+
"python_tests": len(py_tests),
|
|
130
|
+
"javascript_tests": len(js_tests),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _collect_tier_dirs(root: Path) -> list[str]:
|
|
135
|
+
"""Return tier directories present anywhere under ``root``.
|
|
136
|
+
|
|
137
|
+
Polyglot-aware: a tier-named directory anywhere in the tree (Python
|
|
138
|
+
``src/<pkg>/aN_*/`` OR JS-style top-level / nested ``aN_*/``) counts.
|
|
139
|
+
Each tier name is reported at most once. Honours IGNORED_DIRS so
|
|
140
|
+
vendored / cached / tooling folders never leak into the scan.
|
|
141
|
+
"""
|
|
142
|
+
found: set[str] = set()
|
|
143
|
+
for d in root.rglob("*"):
|
|
144
|
+
if not d.is_dir():
|
|
145
|
+
continue
|
|
146
|
+
rel_parts = d.relative_to(root).parts
|
|
147
|
+
if _is_under_ignored(rel_parts):
|
|
148
|
+
continue
|
|
149
|
+
if d.name in TIER_NAMES:
|
|
150
|
+
found.add(d.name)
|
|
151
|
+
if len(found) == len(TIER_NAMES):
|
|
152
|
+
break
|
|
153
|
+
return sorted(found)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def count_untiered_source_files(root: Path) -> dict:
|
|
157
|
+
"""How many SOURCE files (Python/JS/TS) live outside any tier directory?
|
|
158
|
+
|
|
159
|
+
Documentation, config, asset, and other-classed files are deliberately
|
|
160
|
+
excluded — markdown placed in ``cognition/guides/`` doesn't have a tier
|
|
161
|
+
identity and shouldn't be treated as code-out-of-place. Scoring code
|
|
162
|
+
(e.g. future stricter layout penalties) should base its judgement on
|
|
163
|
+
this count, not the raw file count.
|
|
164
|
+
"""
|
|
165
|
+
untiered: list[str] = []
|
|
166
|
+
tiered: list[str] = []
|
|
167
|
+
for p in root.rglob("*"):
|
|
168
|
+
if not p.is_file():
|
|
169
|
+
continue
|
|
170
|
+
rel_parts = p.relative_to(root).parts
|
|
171
|
+
if _is_under_ignored(rel_parts):
|
|
172
|
+
continue
|
|
173
|
+
if p.suffix.lower() not in ALL_SOURCE_EXTS:
|
|
174
|
+
continue
|
|
175
|
+
# Source file — check if any of its path segments is a tier dir.
|
|
176
|
+
in_tier = any(seg in TIER_NAMES for seg in rel_parts)
|
|
177
|
+
rel_path = p.relative_to(root).as_posix()
|
|
178
|
+
if in_tier:
|
|
179
|
+
tiered.append(rel_path)
|
|
180
|
+
else:
|
|
181
|
+
untiered.append(rel_path)
|
|
182
|
+
return {
|
|
183
|
+
"untiered_source_count": len(untiered),
|
|
184
|
+
"tiered_source_count": len(tiered),
|
|
185
|
+
"untiered_samples": untiered[:10],
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def check_tier_layout(root: Path, package: str | None = None) -> tuple[bool, dict]:
|
|
190
|
+
src = root / "src"
|
|
191
|
+
base = src if src.exists() else root
|
|
192
|
+
if package:
|
|
193
|
+
candidate = base / package
|
|
194
|
+
if candidate.exists():
|
|
195
|
+
base = candidate
|
|
196
|
+
present = [t for t in TIER_NAMES if (base / t).exists()]
|
|
197
|
+
polyglot_present: list[str] = []
|
|
198
|
+
if len(present) < 3:
|
|
199
|
+
polyglot_present = _collect_tier_dirs(root)
|
|
200
|
+
if len(polyglot_present) > len(present):
|
|
201
|
+
present = polyglot_present
|
|
202
|
+
ok = len(present) >= 3
|
|
203
|
+
return ok, {
|
|
204
|
+
"tiers_present": present,
|
|
205
|
+
"tiers_present_count": len(present),
|
|
206
|
+
"tiers_required": 3,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_no_upward_imports(root: Path, package: str | None = None) -> tuple[bool, dict]:
|
|
211
|
+
src = root / "src"
|
|
212
|
+
base = src if src.exists() else root
|
|
213
|
+
if package and (base / package).exists():
|
|
214
|
+
base = base / package
|
|
215
|
+
report = scan_violations(base)
|
|
216
|
+
return report["verdict"] == "PASS", {
|
|
217
|
+
"violation_count": report["violation_count"],
|
|
218
|
+
"samples": report["violations"][:5],
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_ci_workflow(root: Path) -> tuple[bool, dict]:
|
|
223
|
+
"""Continuous-integration evidence — ``.github/workflows/*.yml``.
|
|
224
|
+
|
|
225
|
+
Looks for at least one non-empty workflow file under
|
|
226
|
+
``.github/workflows/`` (``.yml`` or ``.yaml``). Presence of a
|
|
227
|
+
workflow is treated as evidence of automated quality gating; we
|
|
228
|
+
deliberately do NOT call out to the GitHub API to inspect run
|
|
229
|
+
history, so this remains a hermetic structural check.
|
|
230
|
+
|
|
231
|
+
A project that wires `pytest`, `forge wire`, and `forge certify`
|
|
232
|
+
into its CI pipeline gets the same 5 points as one that wires only
|
|
233
|
+
a smoke test — the axis rewards intent, not depth. The behavioural
|
|
234
|
+
axis is what rewards actual test-pass behaviour.
|
|
235
|
+
"""
|
|
236
|
+
wf_dir = root / ".github" / "workflows"
|
|
237
|
+
if not wf_dir.exists() or not wf_dir.is_dir():
|
|
238
|
+
return False, {
|
|
239
|
+
"workflow_dir_exists": False,
|
|
240
|
+
"workflow_files": [],
|
|
241
|
+
}
|
|
242
|
+
files: list[str] = []
|
|
243
|
+
for p in sorted(wf_dir.iterdir()):
|
|
244
|
+
if not p.is_file():
|
|
245
|
+
continue
|
|
246
|
+
if p.suffix.lower() not in {".yml", ".yaml"}:
|
|
247
|
+
continue
|
|
248
|
+
try:
|
|
249
|
+
if p.stat().st_size > 0:
|
|
250
|
+
files.append(p.name)
|
|
251
|
+
except OSError:
|
|
252
|
+
continue
|
|
253
|
+
return len(files) > 0, {
|
|
254
|
+
"workflow_dir_exists": True,
|
|
255
|
+
"workflow_files": files,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def check_changelog(root: Path) -> tuple[bool, dict]:
|
|
260
|
+
"""Release-discipline evidence — ``CHANGELOG.md`` or equivalent.
|
|
261
|
+
|
|
262
|
+
Looks for any of the canonical release-notes filenames at the
|
|
263
|
+
project root and credits the project iff the file is non-trivial
|
|
264
|
+
(≥ 200 bytes) — empty placeholders don't earn the points.
|
|
265
|
+
|
|
266
|
+
Recognised names: ``CHANGELOG.md``, ``CHANGELOG.rst``, ``CHANGELOG``,
|
|
267
|
+
``RELEASE_NOTES.md``, ``HISTORY.md``, ``NEWS.md``.
|
|
268
|
+
"""
|
|
269
|
+
candidates = (
|
|
270
|
+
"CHANGELOG.md", "CHANGELOG.rst", "CHANGELOG",
|
|
271
|
+
"RELEASE_NOTES.md", "HISTORY.md", "NEWS.md",
|
|
272
|
+
)
|
|
273
|
+
for name in candidates:
|
|
274
|
+
p = root / name
|
|
275
|
+
if not p.exists() or not p.is_file():
|
|
276
|
+
continue
|
|
277
|
+
try:
|
|
278
|
+
size = p.stat().st_size
|
|
279
|
+
except OSError:
|
|
280
|
+
continue
|
|
281
|
+
if size >= 200:
|
|
282
|
+
return True, {"changelog_file": name, "size_bytes": size}
|
|
283
|
+
return False, {"changelog_file": None, "size_bytes": 0}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def certify(root: Path, *, project: str = "Atomadic project",
|
|
287
|
+
package: str | None = None) -> dict:
|
|
288
|
+
docs_ok, docs_d = check_documentation(root)
|
|
289
|
+
tests_ok, tests_d = check_tests_present(root)
|
|
290
|
+
layout_ok, layout_d = check_tier_layout(root, package)
|
|
291
|
+
wire_ok, wire_d = check_no_upward_imports(root, package)
|
|
292
|
+
ci_ok, ci_d = check_ci_workflow(root)
|
|
293
|
+
changelog_ok, changelog_d = check_changelog(root)
|
|
294
|
+
|
|
295
|
+
# Stub-body detection: scan the package itself (where generated code lives).
|
|
296
|
+
# Only recurse when we have a well-defined src/ layout; falling back to the
|
|
297
|
+
# project root would include all nested forged/sources directories and produce
|
|
298
|
+
# tens of thousands of false positives from extracted-stub skeletons.
|
|
299
|
+
src_for_stubs = root / "src"
|
|
300
|
+
if package and (src_for_stubs / package).exists():
|
|
301
|
+
src_for_stubs = src_for_stubs / package
|
|
302
|
+
elif src_for_stubs.exists():
|
|
303
|
+
pass # use root/src/ as-is
|
|
304
|
+
else:
|
|
305
|
+
# No src/ layout — don't recurse into the full tree; that risks picking
|
|
306
|
+
# up forged/ or sources/ directories. Scan only Python files directly
|
|
307
|
+
# inside the project root (non-recursive).
|
|
308
|
+
src_for_stubs = None
|
|
309
|
+
stub_findings = detect_stubs(package_root=src_for_stubs) if src_for_stubs else []
|
|
310
|
+
stub_pen = stub_penalty(stub_findings)
|
|
311
|
+
no_stubs = stub_pen == 0
|
|
312
|
+
|
|
313
|
+
# Runtime import smoke — does the package actually load?
|
|
314
|
+
# Default to False when there's nothing to import; the import-points are
|
|
315
|
+
# earned by an actual successful import, not by absence.
|
|
316
|
+
smoke: dict | None = None
|
|
317
|
+
importable = False
|
|
318
|
+
if package and (root / "src" / package).exists():
|
|
319
|
+
smoke = dict(import_smoke(output_root=root, package=package))
|
|
320
|
+
importable = smoke["importable"]
|
|
321
|
+
elif not package:
|
|
322
|
+
# No package specified and we can't run a smoke — exempt from this
|
|
323
|
+
# check so legacy callers without a package layout don't see a 40-point
|
|
324
|
+
# blanket deduction.
|
|
325
|
+
importable = True
|
|
326
|
+
|
|
327
|
+
# Behavioral check — actually run pytest against the emitted tests/.
|
|
328
|
+
# This is the breakthrough signal: a package can pass wire + import and
|
|
329
|
+
# still be a no-op stub. Running its own tests catches that.
|
|
330
|
+
test_run: dict | None = None
|
|
331
|
+
test_pass_ratio = 0.0
|
|
332
|
+
if importable and (root / "tests").exists() and tests_ok:
|
|
333
|
+
test_run = dict(run_pytest(output_root=root, package=package))
|
|
334
|
+
test_pass_ratio = test_run["pass_ratio"]
|
|
335
|
+
elif not (root / "tests").exists() or not tests_ok:
|
|
336
|
+
# No tests means we can't credit the ratio — and the structural
|
|
337
|
+
# 'tests_present' check already caught the absence.
|
|
338
|
+
test_pass_ratio = 0.0
|
|
339
|
+
|
|
340
|
+
issues: list[str] = []
|
|
341
|
+
recs: list[str] = []
|
|
342
|
+
if not docs_ok:
|
|
343
|
+
issues.append("Documentation incomplete — add README.md or docs/*.md")
|
|
344
|
+
recs.append("Run `forge auto` to scaffold a docs starter.")
|
|
345
|
+
if not tests_ok:
|
|
346
|
+
issues.append("No test files under tests/")
|
|
347
|
+
recs.append("Add tests/test_*.py before claiming production-ready.")
|
|
348
|
+
if not layout_ok:
|
|
349
|
+
present_count = layout_d.get("tiers_present_count", 0)
|
|
350
|
+
present_list = ", ".join(layout_d.get("tiers_present", [])) or "none"
|
|
351
|
+
issues.append(
|
|
352
|
+
f"Tier layout missing — found {present_count} tier "
|
|
353
|
+
f"director{'y' if present_count == 1 else 'ies'} ({present_list}); "
|
|
354
|
+
"need 3+ of a0_qk_constants/a1_at_functions/a2_mo_composites/"
|
|
355
|
+
"a3_og_features/a4_sy_orchestration."
|
|
356
|
+
)
|
|
357
|
+
recs.append("Split your code into the canonical aN_* directories "
|
|
358
|
+
"(or run `forge auto` to scaffold them).")
|
|
359
|
+
if not wire_ok:
|
|
360
|
+
issues.append(f"Upward-import violations: {wire_d['violation_count']}")
|
|
361
|
+
recs.append("Run `forge wire` to inspect violations, then move imports down-tier or split modules.")
|
|
362
|
+
if not no_stubs:
|
|
363
|
+
issues.append(f"Stub bodies detected: {len(stub_findings)} "
|
|
364
|
+
"function(s) with `pass`/NotImplementedError/TODO")
|
|
365
|
+
for f in stub_findings[:5]:
|
|
366
|
+
issues.append(f" · {f['file']}:{f['lineno']} {f['qualname']} ({f['kind']})")
|
|
367
|
+
recs.append("Replace stub bodies with real implementations before shipping.")
|
|
368
|
+
if smoke is not None and not importable:
|
|
369
|
+
issues.append(f"Package fails to import: {smoke['error_kind']} — "
|
|
370
|
+
f"{smoke['error_message']}")
|
|
371
|
+
recs.append("Fix the import error so the package is loadable; the "
|
|
372
|
+
"wire scan can pass while the runtime fails.")
|
|
373
|
+
if test_run is not None and test_run.get("ran") and test_run["failed"]:
|
|
374
|
+
issues.append(
|
|
375
|
+
f"Tests failed: {test_run['failed']} of {test_run['total']} "
|
|
376
|
+
f"(pass-ratio {test_pass_ratio:.1%})"
|
|
377
|
+
)
|
|
378
|
+
for fid in (test_run.get("failure_excerpts") or [])[:5]:
|
|
379
|
+
issues.append(f" · {fid}")
|
|
380
|
+
recs.append("Fix the failing tests — wire/import alone does not "
|
|
381
|
+
"prove behavior.")
|
|
382
|
+
if not ci_ok:
|
|
383
|
+
issues.append("No CI workflow found under .github/workflows/")
|
|
384
|
+
recs.append("Add a CI workflow (.github/workflows/ci.yml) that "
|
|
385
|
+
"runs pytest + `forge wire` + `forge certify` on push.")
|
|
386
|
+
if not changelog_ok:
|
|
387
|
+
issues.append("No CHANGELOG / release-notes file at project root")
|
|
388
|
+
recs.append("Add CHANGELOG.md (Keep-a-Changelog format) so each "
|
|
389
|
+
"release documents what changed and why.")
|
|
390
|
+
|
|
391
|
+
# Score weights (sum to 100):
|
|
392
|
+
# docs / layout / wire — 10 each (30 max — structural axis)
|
|
393
|
+
# tests-present — 5 (structural axis)
|
|
394
|
+
# importable runtime — 25 (runtime axis)
|
|
395
|
+
# tests-pass-ratio — 30 max (behavioural axis — rewards actual behaviour)
|
|
396
|
+
# ci workflow — 5 (operational axis)
|
|
397
|
+
# changelog/release notes — 5 (operational axis)
|
|
398
|
+
# stub-body penalty — up to 40 deducted
|
|
399
|
+
# Total max: 35 + 25 + 30 + 10 = 100.
|
|
400
|
+
structural = (
|
|
401
|
+
(10 if docs_ok else 0)
|
|
402
|
+
+ (10 if layout_ok else 0)
|
|
403
|
+
+ (10 if wire_ok else 0)
|
|
404
|
+
+ (5 if tests_ok else 0)
|
|
405
|
+
)
|
|
406
|
+
runtime = (25 if importable else 0)
|
|
407
|
+
behavioral = 30 if test_pass_ratio == 1.0 else int(30.0 * test_pass_ratio)
|
|
408
|
+
operational = (
|
|
409
|
+
(5 if ci_ok else 0)
|
|
410
|
+
+ (5 if changelog_ok else 0)
|
|
411
|
+
)
|
|
412
|
+
score = max(0.0, float(structural + runtime + behavioral + operational) - stub_pen)
|
|
413
|
+
return {
|
|
414
|
+
"schema_version": "atomadic-forge.certify/v1",
|
|
415
|
+
"project": project,
|
|
416
|
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
417
|
+
"documentation_complete": docs_ok,
|
|
418
|
+
"tests_present": tests_ok,
|
|
419
|
+
"tier_layout_present": layout_ok,
|
|
420
|
+
"no_upward_imports": wire_ok,
|
|
421
|
+
"no_stub_bodies": no_stubs,
|
|
422
|
+
"package_importable": importable,
|
|
423
|
+
"test_pass_ratio": test_pass_ratio,
|
|
424
|
+
"ci_workflow_present": ci_ok,
|
|
425
|
+
"changelog_present": changelog_ok,
|
|
426
|
+
"score": score,
|
|
427
|
+
"score_components": {
|
|
428
|
+
"structural": structural,
|
|
429
|
+
"runtime": runtime,
|
|
430
|
+
"behavioral": behavioral,
|
|
431
|
+
"operational": operational,
|
|
432
|
+
"stub_penalty": -stub_pen,
|
|
433
|
+
},
|
|
434
|
+
"issues": issues,
|
|
435
|
+
"recommendations": recs,
|
|
436
|
+
"detail": {"docs": docs_d, "tests": tests_d, "layout": layout_d,
|
|
437
|
+
"wire": wire_d,
|
|
438
|
+
"ci": ci_d,
|
|
439
|
+
"changelog": changelog_d,
|
|
440
|
+
"stubs": {"count": len(stub_findings),
|
|
441
|
+
"findings": stub_findings[:20]},
|
|
442
|
+
"import_smoke": smoke,
|
|
443
|
+
"test_run": test_run,
|
|
444
|
+
"untiered_source": count_untiered_source_files(root)},
|
|
445
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Tier a1 — bounded repo context for the Forge chat copilot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..a0_qk_constants.lang_extensions import IGNORED_DIRS, file_class_for_path
|
|
9
|
+
|
|
10
|
+
_CONTEXT_CLASSES = {"source", "documentation", "config"}
|
|
11
|
+
_SENSITIVE_NAMES = {".env", ".env.local", ".envrc"}
|
|
12
|
+
_SENSITIVE_SUBSTRINGS = ("secret", "credential", "private_key")
|
|
13
|
+
_SENSITIVE_SUFFIXES = (".pem", ".key", ".p12", ".pfx")
|
|
14
|
+
_MAX_FILE_CHARS = 4_000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def chat_system_prompt() -> str:
|
|
18
|
+
"""System prompt for chat-only copilot sessions."""
|
|
19
|
+
return (
|
|
20
|
+
"You are Atomadic Forge's chat copilot. Help the user operate this "
|
|
21
|
+
"CLI product, understand repo structure, plan safe commands, and wire "
|
|
22
|
+
"AI-agent workflows through Forge's provider layer. Be concise, be "
|
|
23
|
+
"specific, and prefer concrete commands. If repository context is "
|
|
24
|
+
"provided, ground your answer in it. Do not claim you executed a "
|
|
25
|
+
"command; you are only responding to the chat prompt."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_chat_context(paths: list[Path], *, cwd: Path,
|
|
30
|
+
max_files: int = 12,
|
|
31
|
+
max_chars: int = 16_000) -> dict[str, Any]:
|
|
32
|
+
"""Pack selected repo files into a bounded Markdown context block."""
|
|
33
|
+
cwd = cwd.resolve()
|
|
34
|
+
files = _collect_context_files(paths, cwd=cwd)[:max_files]
|
|
35
|
+
chunks: list[str] = []
|
|
36
|
+
used = 0
|
|
37
|
+
included: list[dict[str, Any]] = []
|
|
38
|
+
for file_path in files:
|
|
39
|
+
try:
|
|
40
|
+
text = file_path.read_text(encoding="utf-8", errors="replace")
|
|
41
|
+
except OSError:
|
|
42
|
+
continue
|
|
43
|
+
truncated_file = len(text) > _MAX_FILE_CHARS
|
|
44
|
+
if truncated_file:
|
|
45
|
+
text = text[:_MAX_FILE_CHARS] + "\n... [file truncated]\n"
|
|
46
|
+
rel = _rel_display(file_path, cwd)
|
|
47
|
+
chunk = f"### {rel}\n\n```{_fence_lang(file_path)}\n{text}\n```\n"
|
|
48
|
+
remaining = max_chars - used
|
|
49
|
+
if remaining <= 0:
|
|
50
|
+
break
|
|
51
|
+
truncated_context = len(chunk) > remaining
|
|
52
|
+
if truncated_context:
|
|
53
|
+
chunk = chunk[:remaining] + "\n... [context budget exhausted]\n"
|
|
54
|
+
chunks.append(chunk)
|
|
55
|
+
used += len(chunk)
|
|
56
|
+
included.append({
|
|
57
|
+
"path": rel,
|
|
58
|
+
"chars": min(len(text), _MAX_FILE_CHARS),
|
|
59
|
+
"truncated": truncated_file or truncated_context,
|
|
60
|
+
})
|
|
61
|
+
if truncated_context:
|
|
62
|
+
break
|
|
63
|
+
return {
|
|
64
|
+
"context": "\n".join(chunks).strip(),
|
|
65
|
+
"files": included,
|
|
66
|
+
"file_count": len(included),
|
|
67
|
+
"char_count": used,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_chat_prompt(message: str, *, context: str = "",
|
|
72
|
+
history: list[dict[str, str]] | None = None) -> str:
|
|
73
|
+
"""Render one chat turn with optional context and prior turns."""
|
|
74
|
+
parts = [
|
|
75
|
+
"# User request",
|
|
76
|
+
message.strip(),
|
|
77
|
+
]
|
|
78
|
+
if history:
|
|
79
|
+
parts.extend(["", "# Prior chat"])
|
|
80
|
+
for turn in history[-8:]:
|
|
81
|
+
role = turn.get("role", "user")
|
|
82
|
+
content = turn.get("content", "").strip()
|
|
83
|
+
if content:
|
|
84
|
+
parts.append(f"{role}: {content}")
|
|
85
|
+
if context:
|
|
86
|
+
parts.extend(["", "# Repository context", context])
|
|
87
|
+
return "\n".join(parts).strip() + "\n"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _collect_context_files(paths: list[Path], *, cwd: Path) -> list[Path]:
|
|
91
|
+
out: list[Path] = []
|
|
92
|
+
seen: set[Path] = set()
|
|
93
|
+
for path in paths:
|
|
94
|
+
p = path if path.is_absolute() else cwd / path
|
|
95
|
+
if not p.exists():
|
|
96
|
+
continue
|
|
97
|
+
candidates = [p] if p.is_file() else sorted(p.rglob("*"))
|
|
98
|
+
for candidate in candidates:
|
|
99
|
+
if not candidate.is_file():
|
|
100
|
+
continue
|
|
101
|
+
if _ignored(candidate, cwd):
|
|
102
|
+
continue
|
|
103
|
+
if _sensitive(candidate):
|
|
104
|
+
continue
|
|
105
|
+
if file_class_for_path(candidate.as_posix()) not in _CONTEXT_CLASSES:
|
|
106
|
+
continue
|
|
107
|
+
resolved = candidate.resolve()
|
|
108
|
+
if resolved in seen:
|
|
109
|
+
continue
|
|
110
|
+
seen.add(resolved)
|
|
111
|
+
out.append(resolved)
|
|
112
|
+
return sorted(out, key=lambda p: (_priority(p), p.as_posix().lower()))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _ignored(path: Path, cwd: Path) -> bool:
|
|
116
|
+
try:
|
|
117
|
+
parts = path.resolve().relative_to(cwd).parts
|
|
118
|
+
except ValueError:
|
|
119
|
+
parts = path.parts
|
|
120
|
+
return any(part in IGNORED_DIRS for part in parts)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _sensitive(path: Path) -> bool:
|
|
124
|
+
name = path.name.lower()
|
|
125
|
+
return (
|
|
126
|
+
name in _SENSITIVE_NAMES
|
|
127
|
+
or name.endswith(_SENSITIVE_SUFFIXES)
|
|
128
|
+
or any(piece in name for piece in _SENSITIVE_SUBSTRINGS)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _priority(path: Path) -> tuple[int, int]:
|
|
133
|
+
name = path.name.lower()
|
|
134
|
+
stem = path.stem.lower()
|
|
135
|
+
if name.startswith("readme"):
|
|
136
|
+
return (0, len(path.parts))
|
|
137
|
+
if name in {"pyproject.toml", "package.json"}:
|
|
138
|
+
return (1, len(path.parts))
|
|
139
|
+
if "docs" in {p.lower() for p in path.parts}:
|
|
140
|
+
return (2, len(path.parts))
|
|
141
|
+
if "src" in {p.lower() for p in path.parts}:
|
|
142
|
+
return (3, len(path.parts))
|
|
143
|
+
if stem.startswith("test_") or name.endswith(".test.js"):
|
|
144
|
+
return (4, len(path.parts))
|
|
145
|
+
return (5, len(path.parts))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _rel_display(path: Path, cwd: Path) -> str:
|
|
149
|
+
try:
|
|
150
|
+
return path.relative_to(cwd).as_posix()
|
|
151
|
+
except ValueError:
|
|
152
|
+
return path.as_posix()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _fence_lang(path: Path) -> str:
|
|
156
|
+
ext = path.suffix.lower()
|
|
157
|
+
return {
|
|
158
|
+
".py": "python",
|
|
159
|
+
".js": "javascript",
|
|
160
|
+
".mjs": "javascript",
|
|
161
|
+
".cjs": "javascript",
|
|
162
|
+
".jsx": "jsx",
|
|
163
|
+
".ts": "typescript",
|
|
164
|
+
".tsx": "tsx",
|
|
165
|
+
".md": "markdown",
|
|
166
|
+
".toml": "toml",
|
|
167
|
+
".json": "json",
|
|
168
|
+
".yaml": "yaml",
|
|
169
|
+
".yml": "yaml",
|
|
170
|
+
}.get(ext, "")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Tier a1 — pure cherry-picker.
|
|
2
|
+
|
|
3
|
+
Given a scout report and a selection (names or 'all'), produce a manifest
|
|
4
|
+
that downstream assimilate consumes. Conflict resolution is deferred to
|
|
5
|
+
the assimilate phase; this layer just records intent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def select_items(scout_report: dict, *, names: Iterable[str] | None = None,
|
|
14
|
+
pick_all: bool = False, min_confidence: float = 0.0,
|
|
15
|
+
only_tier: str | None = None) -> dict:
|
|
16
|
+
"""Build a cherry_pick manifest from a scout report.
|
|
17
|
+
|
|
18
|
+
Filters:
|
|
19
|
+
* ``names`` — explicit qualnames. None ⇒ apply other filters.
|
|
20
|
+
* ``pick_all`` — take every symbol that passes the other filters.
|
|
21
|
+
* ``min_confidence`` — drop symbols with low classification confidence.
|
|
22
|
+
(Forge currently emits 0.0 for hard hits and 0.5 for fallbacks; future
|
|
23
|
+
versions will produce richer signals.)
|
|
24
|
+
* ``only_tier`` — restrict to symbols already classified to this tier.
|
|
25
|
+
"""
|
|
26
|
+
syms = scout_report.get("symbols", [])
|
|
27
|
+
wanted = set(names) if names else None
|
|
28
|
+
items: list[dict] = []
|
|
29
|
+
for s in syms:
|
|
30
|
+
if wanted is not None and s["qualname"] not in wanted and s["name"] not in wanted:
|
|
31
|
+
continue
|
|
32
|
+
if not pick_all and wanted is None:
|
|
33
|
+
continue
|
|
34
|
+
if only_tier and s["tier_guess"] != only_tier:
|
|
35
|
+
continue
|
|
36
|
+
items.append({
|
|
37
|
+
"qualname": s["qualname"],
|
|
38
|
+
"target_tier": s["tier_guess"],
|
|
39
|
+
"confidence": _confidence_of(s),
|
|
40
|
+
"reasons": _reasons_for(s),
|
|
41
|
+
})
|
|
42
|
+
return {
|
|
43
|
+
"schema_version": "atomadic-forge.cherry/v1",
|
|
44
|
+
"source_repo": scout_report.get("repo", ""),
|
|
45
|
+
"items": items,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _confidence_of(symbol: dict) -> float:
|
|
50
|
+
# High confidence when name tokens drove the decision; lower when we fell
|
|
51
|
+
# back to the kind-default (function→a1, class→a2). Cheap proxy: complexity
|
|
52
|
+
# below 800 ⇒ small hand-shaped helper, easy to reason about.
|
|
53
|
+
base = 0.7 if symbol["complexity"] < 800 else 0.6
|
|
54
|
+
if symbol["has_self_assign"]:
|
|
55
|
+
base += 0.1
|
|
56
|
+
if symbol["effects"] == ["pure"]:
|
|
57
|
+
base += 0.05
|
|
58
|
+
return min(0.95, round(base, 2))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _reasons_for(symbol: dict) -> list[str]:
|
|
62
|
+
out: list[str] = []
|
|
63
|
+
if symbol["has_self_assign"]:
|
|
64
|
+
out.append("class with mutable instance state ⇒ a2 promotion")
|
|
65
|
+
if symbol["effects"] == ["pure"]:
|
|
66
|
+
out.append("no detected I/O or state — clean composition target")
|
|
67
|
+
if "io" in symbol["effects"]:
|
|
68
|
+
out.append("I/O-heavy — needs sandboxing on absorb")
|
|
69
|
+
if not out:
|
|
70
|
+
out.append("standard tier-classification fit")
|
|
71
|
+
return out
|