@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548
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.
- package/dist/cli/composer-commands.d.ts +37 -0
- package/dist/cli/composer-commands.d.ts.map +1 -0
- package/dist/cli/composer-commands.js +396 -0
- package/dist/cli/composer-commands.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-runtime-config.mjs +190 -0
- package/dist/composer/AUTHORING.md +435 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +59 -0
- package/dist/composer/authoring/answers.py +297 -0
- package/dist/composer/authoring/archetypes/__init__.py +26 -0
- package/dist/composer/authoring/archetypes/_common.py +410 -0
- package/dist/composer/authoring/archetypes/breakout.py +134 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +76 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
- package/dist/composer/authoring/builder.py +191 -0
- package/dist/composer/authoring/emit.py +264 -0
- package/dist/composer/authoring/gaps.py +115 -0
- package/dist/composer/authoring/generate.py +131 -0
- package/dist/composer/authoring/presets.py +168 -0
- package/dist/composer/authoring/runtime_emit.py +266 -0
- package/dist/composer/authoring/strategy_manifest.py +94 -0
- package/dist/composer/cli.py +487 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +739 -0
- package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
- package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
- package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
- package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
- package/dist/composer/generated/README.md +22 -0
- package/dist/composer/generated/catalog.md +118 -0
- package/dist/composer/generated/describe.md +1168 -0
- package/dist/composer/generated/graph.schema.json +1946 -0
- package/dist/composer/primitives/__init__.py +320 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +363 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +326 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +979 -0
- package/dist/composer/primitives/stateful.py +254 -0
- package/dist/composer/pure_fn/__init__.py +34 -0
- package/dist/composer/pure_fn/checks.py +39 -0
- package/dist/composer/pure_fn/models.py +177 -0
- package/dist/composer/pure_fn/purity.py +311 -0
- package/dist/composer/pure_fn/runtests.py +155 -0
- package/dist/composer/registry/__init__.py +40 -0
- package/dist/composer/registry/generate.py +425 -0
- package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
- package/dist/composer/registry/mcp_closure.py +311 -0
- package/dist/composer/registry/models.py +417 -0
- package/dist/composer/registry/nodes.py +1297 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/scaffold_path.py +118 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +34 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +227 -0
- package/dist/composer/validator/graph_checks.py +443 -0
- package/dist/composer/validator/loader.py +110 -0
- package/dist/composer/validator/schema_checks.py +147 -0
- package/dist/composer/validator/validate.py +86 -0
- package/dist/composer/vendoring.py +103 -0
- package/dist/composer/verbs/__init__.py +49 -0
- package/dist/composer/verbs/_box.py +308 -0
- package/dist/composer/verbs/check.py +254 -0
- package/dist/composer/verbs/close.py +494 -0
- package/dist/composer/verbs/deploy.py +279 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/fund.py +498 -0
- package/dist/composer/verbs/install.py +586 -0
- package/dist/composer/verbs/runtime_validate.py +422 -0
- package/dist/composer/verbs/smoke.py +134 -0
- package/dist/composer/verbs/status.py +383 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime/load-runtime-config.d.ts +17 -0
- package/dist/runtime/load-runtime-config.d.ts.map +1 -1
- package/dist/runtime/load-runtime-config.js +2 -2
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime-api/config.d.ts +2 -2
- package/dist/runtime-api/config.js +4 -4
- package/dist/runtime-api/config.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +5 -14
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +2 -8
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +1 -3
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -3
- package/package.json +4 -2
- package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
- package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-mutex.js +0 -18
- package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""runtime_validate — the Zod-oracle stage: judge the emitted runtime.yaml (slice 9 steps 4+5).
|
|
2
|
+
|
|
3
|
+
Two layers, mirroring where the truth lives:
|
|
4
|
+
|
|
5
|
+
1. **Composer POLICY (pure Python, always runs)** — PROTECTION BY DEFAULT. A unit with no
|
|
6
|
+
``exit:`` section and no explicit ``unmanaged`` opt-out is RED (CMP120). This is the
|
|
7
|
+
composer's own rule (the runtime schema legally allows exit-less configs), so it never
|
|
8
|
+
needs node/dist and runs even when the Zod oracle can't.
|
|
9
|
+
|
|
10
|
+
2. **The REAL Zod + DSL oracle (spawned node, needs dist/)** — the emitted runtime.yaml is
|
|
11
|
+
validated by the exact schema + DSL parser the runtime boots with, via the rim entry
|
|
12
|
+
``src/cli/validate-runtime-config.mjs``. Its structured ``{path, message, code}`` issues
|
|
13
|
+
are mapped to the CMP1xx teaching-error class by the table below. When node or dist is
|
|
14
|
+
absent (CI runs pytest without ``npm run build``), the oracle is SKIPPED with an actionable
|
|
15
|
+
reason — NOT RED — so the policy layer still gates and the caller/tests can skip cleanly.
|
|
16
|
+
|
|
17
|
+
The Python↔node spawn mirrors ``src/cli/composer-commands.ts`` (COMPOSER_NODE bin, dist
|
|
18
|
+
resolution, ``detectEnvError`` actionable one-liner); see design §5.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
from composer.validator.errors import Location, TeachingError
|
|
33
|
+
|
|
34
|
+
# repo root = the dir CONTAINING the `composer/` package (…/composer/verbs/ -> parents[2]).
|
|
35
|
+
_ROOT = Path(__file__).resolve().parents[2]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _dist_dir() -> Path:
|
|
39
|
+
override = os.environ.get("COMPOSER_RUNTIME_DIST")
|
|
40
|
+
if override:
|
|
41
|
+
return Path(override)
|
|
42
|
+
dev = _ROOT / "dist"
|
|
43
|
+
if (dev / "runtime" / "runtime-schema.js").is_file():
|
|
44
|
+
return dev
|
|
45
|
+
# Shipped package: the composer lives INSIDE the runtime dist (dist/composer/), so
|
|
46
|
+
# _ROOT *is* the dist dir. runtime/runtime-schema.js under _ROOT is unambiguous — a
|
|
47
|
+
# dev-checkout root keeps it only under dist/ — so both layouts resolve with no env.
|
|
48
|
+
if (_ROOT / "runtime" / "runtime-schema.js").is_file():
|
|
49
|
+
return _ROOT
|
|
50
|
+
return dev
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _rim_entry() -> Optional[Path]:
|
|
54
|
+
# Candidates track _dist_dir() so COMPOSER_RUNTIME_DIST resolves the rim AND the schema
|
|
55
|
+
# from the same place end-to-end. On a dist-only claw box the composer ships to
|
|
56
|
+
# dist/composer/, so _ROOT is the dist dir itself and the rim is _dist_dir()/cli/… —
|
|
57
|
+
# NOT _ROOT/dist/cli/…; without this the Zod oracle silently skips even with the env set.
|
|
58
|
+
for cand in (
|
|
59
|
+
_ROOT / "src" / "cli" / "validate-runtime-config.mjs", # dev checkout (source)
|
|
60
|
+
_dist_dir() / "cli" / "validate-runtime-config.mjs", # built/shipped (honors env)
|
|
61
|
+
):
|
|
62
|
+
if cand.is_file():
|
|
63
|
+
return cand
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _node_bin() -> str:
|
|
68
|
+
return os.environ.get("COMPOSER_NODE", "node")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def oracle_available() -> bool:
|
|
72
|
+
"""True when the real Zod+DSL oracle can run (dist built, rim entry present, node on PATH)."""
|
|
73
|
+
return (
|
|
74
|
+
(_dist_dir() / "runtime" / "runtime-schema.js").is_file()
|
|
75
|
+
and _rim_entry() is not None
|
|
76
|
+
and shutil.which(_node_bin()) is not None
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class RuntimeValidateResult:
|
|
82
|
+
ok: bool
|
|
83
|
+
errors: list[TeachingError]
|
|
84
|
+
skipped: bool = False # the Zod oracle couldn't run (node/dist absent) — not RED
|
|
85
|
+
skip_reason: Optional[str] = None
|
|
86
|
+
oracle_ran: bool = False # the node oracle actually executed
|
|
87
|
+
info_messages: list[str] = None # non-error acknowledgements (e.g. expected ops bindings)
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
if self.info_messages is None:
|
|
91
|
+
self.info_messages = []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------------- CMP1xx mapper
|
|
95
|
+
|
|
96
|
+
def _map_oracle_issue(path: str, message: str, code: str) -> TeachingError:
|
|
97
|
+
"""Table-driven map of one raw Zod/DSL issue -> a CMP1xx teaching error (design §8).
|
|
98
|
+
|
|
99
|
+
Keyed so a NEW Zod message can never produce an unlabelled error: unrecognised issues
|
|
100
|
+
fall to CMP199 (still located + hinted), never to a bare pass-through."""
|
|
101
|
+
p = path or ""
|
|
102
|
+
loc = Location(path=p)
|
|
103
|
+
|
|
104
|
+
# F1: misplaced top-level dsl_preset (schema superRefine at runtime-schema.ts:378-395).
|
|
105
|
+
if code == "custom" and p == "dsl_preset":
|
|
106
|
+
return TeachingError(
|
|
107
|
+
"CMP111", "`dsl_preset` is at the top level; the runtime reads only `exit.dsl_preset`.",
|
|
108
|
+
"Nest the preset under `exit:` — `exit:\\n dsl_preset:\\n ...`. The composer "
|
|
109
|
+
"emits this shape automatically; do not hand-place `dsl_preset`.",
|
|
110
|
+
loc,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# risk block is .strict() — unknown / renamed-legacy key.
|
|
114
|
+
if code in ("unrecognized_keys",) or (p.startswith("risk") and "renamed" in message):
|
|
115
|
+
return TeachingError(
|
|
116
|
+
"CMP101", f"the `risk` block has an unknown/renamed key: {message}",
|
|
117
|
+
"Use only the v2 keys; `data_retention_hours` -> `data_retention_seconds`, "
|
|
118
|
+
"`cooldown_minutes` -> `cooldown_seconds`. Let the composer emit the risk block.",
|
|
119
|
+
loc,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# A3 install-time-parity classes. These machine codes are ALSO uppercase, so they MUST be
|
|
123
|
+
# matched before the generic `code.isupper()` DSL-preset fallback below or they'd map to CMP110.
|
|
124
|
+
|
|
125
|
+
# F2: DSL exit active but position-tracking wiring missing/broken (Runtime.start() gate).
|
|
126
|
+
if code == "DSL_POSITION_TRACKING":
|
|
127
|
+
return TeachingError(
|
|
128
|
+
"CMP112", f"the DSL exit is active but position-tracking wiring is missing/broken: {message}",
|
|
129
|
+
"A DSL-managed unit needs a `position_tracker` scanner AND a `POSITION_TRACKER` action "
|
|
130
|
+
"whose `scanners:` lists that tracker. The composer emits both automatically; regenerate "
|
|
131
|
+
"with `composer deploy` rather than removing, renaming, or unwiring them.",
|
|
132
|
+
loc,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# F3: duplicate scanner or action name (loadRuntimeConfig post-Zod assert).
|
|
136
|
+
if code in ("DUP_SCANNER_NAME", "DUP_ACTION_NAME"):
|
|
137
|
+
return TeachingError(
|
|
138
|
+
"CMP113", f"a scanner or action name is duplicated: {message}",
|
|
139
|
+
"Scanner names and action names must each be unique within one runtime. Rename the "
|
|
140
|
+
"duplicate; the composer emits unique names, so a collision is a hand-edit.",
|
|
141
|
+
loc,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# F4: unresolvable action decision_prompt placeholder (loadRuntimeConfig post-Zod assert).
|
|
145
|
+
if code == "UNRESOLVED_PROMPT_PLACEHOLDER":
|
|
146
|
+
return TeachingError(
|
|
147
|
+
"CMP114", f"an action `decision_prompt` references an unresolvable placeholder: {message}",
|
|
148
|
+
"Every {{placeholder}} must be provided by a declared context entry (signal_<scanner>, "
|
|
149
|
+
"context_<scanner>, strategy_<value>, asset_trend_<value>) or a param. The composer emits "
|
|
150
|
+
"rule-mode actions with no prompt, so a stray placeholder is a hand-edit.",
|
|
151
|
+
loc,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# DSL preset parse errors (uppercase DSL codes) — breaches, tier order, preset config.
|
|
155
|
+
if code.isupper() and code not in ("", None):
|
|
156
|
+
return TeachingError(
|
|
157
|
+
"CMP110", f"the exit DSL preset is invalid: {message} ({code}).",
|
|
158
|
+
"Use a named preset (protected_standard | protected_tight | protected_wide); its "
|
|
159
|
+
"phase1/phase2 always parse. If you edited the tiers, keep trigger_pct ascending "
|
|
160
|
+
"and consecutive_breaches_required an integer >= 1.",
|
|
161
|
+
loc,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Missing required top-level field.
|
|
165
|
+
if p in ("name", "strategy", "strategy.wallet") and code in ("invalid_type", "too_small", "invalid_string"):
|
|
166
|
+
return TeachingError(
|
|
167
|
+
"CMP100", f"missing/invalid required runtime field `{p}`: {message}",
|
|
168
|
+
"`name` (non-empty) and `strategy.wallet` (a `${...}` env placeholder) are "
|
|
169
|
+
"mandatory. The composer binds both from the strategy id.",
|
|
170
|
+
loc,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# guard_rails range issue.
|
|
174
|
+
if p.startswith("risk.guard_rails") and code in ("too_small", "too_big"):
|
|
175
|
+
return TeachingError(
|
|
176
|
+
"CMP102", f"a `risk.guard_rails` value is out of range: {message}",
|
|
177
|
+
"Respect the field bounds (e.g. drawdown_halt_pct 0–100, cooldown_seconds >= 60). "
|
|
178
|
+
"The composer's presets stay in-bounds; a risk knob override must too.",
|
|
179
|
+
loc,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Anything else the schema rejected — still labelled + located.
|
|
183
|
+
return TeachingError(
|
|
184
|
+
"CMP199", f"runtime.yaml failed schema validation at `{p or '(root)'}`: {message} [{code}].",
|
|
185
|
+
"Regenerate the runtime.yaml with `composer deploy` (it emits a Zod-valid unit); if "
|
|
186
|
+
"you hand-edited it, revert the offending field to the composer's shape.",
|
|
187
|
+
loc,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --------------------------------------------------------------------------- placeholder policy
|
|
192
|
+
|
|
193
|
+
# Mirrors src/runtime/env-resolve.ts VAR_PATTERN exactly: ${VAR} and ${VAR:-default}.
|
|
194
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{([^}:]+)(?::-([^}]*))?\}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _iter_strings(obj):
|
|
198
|
+
"""Yield every string VALUE in a parsed-YAML object (mirrors resolveEnvInObject's walk —
|
|
199
|
+
values only, never keys or comments; env resolution never touches those)."""
|
|
200
|
+
if isinstance(obj, str):
|
|
201
|
+
yield obj
|
|
202
|
+
elif isinstance(obj, dict):
|
|
203
|
+
for v in obj.values():
|
|
204
|
+
yield from _iter_strings(v)
|
|
205
|
+
elif isinstance(obj, (list, tuple)):
|
|
206
|
+
for v in obj:
|
|
207
|
+
yield from _iter_strings(v)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _placeholder_policy(yaml_text: str) -> tuple[list[TeachingError], list[str]]:
|
|
211
|
+
"""CMP115 policy: unbound ``${VAR}`` scan (pure Python, runs even when the oracle is skipped).
|
|
212
|
+
|
|
213
|
+
The real loader resolves env BEFORE Zod (load-runtime-config.ts:237 -> resolveEnvInObject),
|
|
214
|
+
turning any UNSET ``${VAR}`` into ``""`` (env-resolve.ts:16-20) — an install-time failure the
|
|
215
|
+
Zod rim can't see because it validates the raw, unresolved YAML. So this string-level scan
|
|
216
|
+
lives on the Python side (where the loader's pre-Zod substitution conceptually happens).
|
|
217
|
+
|
|
218
|
+
Policy: the sanctioned ops binding class ``${..._WALLET}`` (the composer emits exactly one,
|
|
219
|
+
``strategy.wallet``; deploy binds it) is ACKNOWLEDGED, not flagged. ``${VAR:-default}`` is
|
|
220
|
+
safe (never resolves empty). Every other bare ``${VAR}`` is CMP115.
|
|
221
|
+
|
|
222
|
+
Returns ``(errors, acknowledged_bindings)``.
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
import yaml
|
|
226
|
+
|
|
227
|
+
doc = yaml.safe_load(yaml_text)
|
|
228
|
+
except Exception: # noqa: BLE001 — a YAML/parse defect is surfaced by the oracle, not here
|
|
229
|
+
return [], []
|
|
230
|
+
|
|
231
|
+
errors: list[TeachingError] = []
|
|
232
|
+
acknowledged: list[str] = []
|
|
233
|
+
seen_err: set[str] = set()
|
|
234
|
+
seen_ack: set[str] = set()
|
|
235
|
+
for s in _iter_strings(doc):
|
|
236
|
+
for m in _ENV_VAR_PATTERN.finditer(s):
|
|
237
|
+
name, default = m.group(1), m.group(2)
|
|
238
|
+
token = m.group(0)
|
|
239
|
+
if default is not None:
|
|
240
|
+
continue # ${VAR:-default} always resolves to something non-empty
|
|
241
|
+
if name.endswith("_WALLET"):
|
|
242
|
+
if token not in seen_ack:
|
|
243
|
+
seen_ack.add(token)
|
|
244
|
+
acknowledged.append(token)
|
|
245
|
+
continue
|
|
246
|
+
if token in seen_err:
|
|
247
|
+
continue
|
|
248
|
+
seen_err.add(token)
|
|
249
|
+
errors.append(
|
|
250
|
+
TeachingError(
|
|
251
|
+
"CMP115",
|
|
252
|
+
f"unbound env placeholder `{token}` would resolve to empty (\"\") at install.",
|
|
253
|
+
"Only the `${<STRATEGY>_WALLET}` placeholder is bound by ops at deploy; every "
|
|
254
|
+
"other `${VAR}` is substituted with \"\" at load time and breaks the config. "
|
|
255
|
+
"Remove it, hard-code the value, or give it a default (`${VAR:-default}`). The "
|
|
256
|
+
"composer emits only the wallet placeholder.",
|
|
257
|
+
Location(path=""), # a value-level defect; no single Zod dot-path locates it
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
return errors, acknowledged
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def bare_env_placeholders(yaml_text: str) -> list[str]:
|
|
264
|
+
"""Every bare ``${VAR}`` (no ``:-default``) string token in the YAML VALUES, deduped in
|
|
265
|
+
first-seen order.
|
|
266
|
+
|
|
267
|
+
STRICTER than :func:`_placeholder_policy`: this counts ``${..._WALLET}`` too. It is the
|
|
268
|
+
post-bind gate for ``composer install`` — after the deploy-time wallet placeholder is bound
|
|
269
|
+
to a real address, ANY remaining bare ``${VAR}`` (wallet or not) would resolve to ``""`` at
|
|
270
|
+
load and is an error. ``${VAR:-default}`` stays exempt (never resolves empty)."""
|
|
271
|
+
try:
|
|
272
|
+
import yaml
|
|
273
|
+
|
|
274
|
+
doc = yaml.safe_load(yaml_text)
|
|
275
|
+
except Exception: # noqa: BLE001 — a YAML defect is the oracle's job to surface, not this scan
|
|
276
|
+
return []
|
|
277
|
+
seen: set[str] = set()
|
|
278
|
+
out: list[str] = []
|
|
279
|
+
for s in _iter_strings(doc):
|
|
280
|
+
for m in _ENV_VAR_PATTERN.finditer(s):
|
|
281
|
+
if m.group(2) is not None:
|
|
282
|
+
continue # ${VAR:-default} always resolves non-empty
|
|
283
|
+
token = m.group(0)
|
|
284
|
+
if token not in seen:
|
|
285
|
+
seen.add(token)
|
|
286
|
+
out.append(token)
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _run_node_oracle(runtime_yaml_path: Path) -> RuntimeValidateResult:
|
|
291
|
+
"""Judge a runtime.yaml for INSTALLABILITY: the CMP115 unbound-placeholder policy (pure
|
|
292
|
+
Python, always) + the real Zod+DSL+install-parity oracle (spawned node; SKIPPED not RED when
|
|
293
|
+
node/dist absent). The placeholder policy is folded in here so BOTH entry points (``check``
|
|
294
|
+
via temp file and deploy via the staged file) enforce it identically, even on a skip."""
|
|
295
|
+
ph_errors, acknowledged = ([], [])
|
|
296
|
+
try:
|
|
297
|
+
text = runtime_yaml_path.read_text(encoding="utf-8")
|
|
298
|
+
ph_errors, acknowledged = _placeholder_policy(text)
|
|
299
|
+
except OSError:
|
|
300
|
+
pass # unreadable file is reported by the oracle's own read path below
|
|
301
|
+
|
|
302
|
+
res = _run_zod_oracle(runtime_yaml_path)
|
|
303
|
+
if ph_errors:
|
|
304
|
+
res.errors = ph_errors + res.errors
|
|
305
|
+
res.ok = not res.errors
|
|
306
|
+
if acknowledged:
|
|
307
|
+
res.info_messages.append(
|
|
308
|
+
"expected ops bindings (not errors; deploy binds these): " + ", ".join(acknowledged)
|
|
309
|
+
)
|
|
310
|
+
return res
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _run_zod_oracle(runtime_yaml_path: Path) -> RuntimeValidateResult:
|
|
314
|
+
"""Spawn the node Zod+install-parity oracle over a runtime.yaml file. Returns SKIPPED (not
|
|
315
|
+
RED) when the node/dist environment is absent (mirrors composer-commands.ts detectEnvError)."""
|
|
316
|
+
dist = _dist_dir()
|
|
317
|
+
schema_js = dist / "runtime" / "runtime-schema.js"
|
|
318
|
+
rim = _rim_entry()
|
|
319
|
+
if not schema_js.is_file():
|
|
320
|
+
return RuntimeValidateResult(
|
|
321
|
+
ok=True, errors=[], skipped=True,
|
|
322
|
+
skip_reason=(
|
|
323
|
+
f"Zod oracle skipped: runtime dist not built ({schema_js} absent). "
|
|
324
|
+
f"Run `npm run build`, or set COMPOSER_RUNTIME_DIST. Protection policy still enforced."
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
if rim is None:
|
|
328
|
+
return RuntimeValidateResult(
|
|
329
|
+
ok=True, errors=[], skipped=True,
|
|
330
|
+
skip_reason="Zod oracle skipped: rim entry validate-runtime-config.mjs not found under src/cli or dist/cli.",
|
|
331
|
+
)
|
|
332
|
+
node = _node_bin()
|
|
333
|
+
if shutil.which(node) is None:
|
|
334
|
+
return RuntimeValidateResult(
|
|
335
|
+
ok=True, errors=[], skipped=True,
|
|
336
|
+
skip_reason=f"Zod oracle skipped: '{node}' not on PATH (set COMPOSER_NODE). Protection policy still enforced.",
|
|
337
|
+
)
|
|
338
|
+
try:
|
|
339
|
+
proc = subprocess.run(
|
|
340
|
+
[node, str(rim), str(runtime_yaml_path), str(dist)],
|
|
341
|
+
capture_output=True, text=True, timeout=60,
|
|
342
|
+
)
|
|
343
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
344
|
+
return RuntimeValidateResult(
|
|
345
|
+
ok=True, errors=[], skipped=True,
|
|
346
|
+
skip_reason=f"Zod oracle skipped: node spawn failed ({exc}).",
|
|
347
|
+
)
|
|
348
|
+
try:
|
|
349
|
+
verdict = json.loads(proc.stdout.strip().splitlines()[-1]) if proc.stdout.strip() else {}
|
|
350
|
+
except (json.JSONDecodeError, IndexError):
|
|
351
|
+
return RuntimeValidateResult(
|
|
352
|
+
ok=True, errors=[], skipped=True,
|
|
353
|
+
skip_reason=f"Zod oracle skipped: unparseable output (stderr: {proc.stderr.strip()[:200]}).",
|
|
354
|
+
)
|
|
355
|
+
if verdict.get("env_absent"):
|
|
356
|
+
return RuntimeValidateResult(
|
|
357
|
+
ok=True, errors=[], skipped=True,
|
|
358
|
+
skip_reason=f"Zod oracle skipped: {verdict.get('message', 'runtime dist not importable')}.",
|
|
359
|
+
)
|
|
360
|
+
if verdict.get("ok"):
|
|
361
|
+
return RuntimeValidateResult(ok=True, errors=[], oracle_ran=True)
|
|
362
|
+
errors = [
|
|
363
|
+
_map_oracle_issue(e.get("path", ""), e.get("message", ""), e.get("code", ""))
|
|
364
|
+
for e in verdict.get("errors", [])
|
|
365
|
+
]
|
|
366
|
+
return RuntimeValidateResult(ok=not errors, errors=errors, oracle_ran=True)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def validate_runtime_yaml_file(path) -> RuntimeValidateResult:
|
|
370
|
+
"""Run ONLY the Zod oracle over an existing runtime.yaml file (deploy uses this to prove
|
|
371
|
+
the staged unit validates). The policy layer lives in ``check_runtime_unit``."""
|
|
372
|
+
return _run_node_oracle(Path(path))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _protection_policy_error(answers) -> Optional[TeachingError]:
|
|
376
|
+
"""CMP120: PROTECTION BY DEFAULT. A unit with no exit and no explicit opt-out is RED.
|
|
377
|
+
|
|
378
|
+
The composer always resolves a preset, so an emitted unit is protected unless the author
|
|
379
|
+
deliberately set ``unmanaged``. This gate exists so that opt-out is a conscious, logged
|
|
380
|
+
choice — never an accident of an absent field."""
|
|
381
|
+
if getattr(answers, "unmanaged", False):
|
|
382
|
+
return None
|
|
383
|
+
if not getattr(answers, "exit_preset", None):
|
|
384
|
+
return TeachingError(
|
|
385
|
+
"CMP120",
|
|
386
|
+
"this strategy would run UNMANAGED — no exit protection — but you did not opt out.",
|
|
387
|
+
"Set an exit_preset (protected_standard | protected_tight | protected_wide) to "
|
|
388
|
+
"protect open positions, OR pass `unmanaged=true` to deliberately opt out (a loud "
|
|
389
|
+
"warning is emitted). Protection is the default; opting out must be explicit.",
|
|
390
|
+
Location(path="exit"),
|
|
391
|
+
)
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def check_runtime_unit(answers) -> tuple[RuntimeValidateResult, list[TeachingError]]:
|
|
396
|
+
"""The full ``check`` runtime_validate stage: PROTECTION policy (CMP120, always) + the
|
|
397
|
+
real Zod+DSL oracle over the emitted runtime.yaml. Returns ``(oracle_result, policy_errors)``.
|
|
398
|
+
|
|
399
|
+
Emits the runtime.yaml to a temp file (the artifact the oracle judges is exactly what
|
|
400
|
+
deploy stages). When ``unmanaged`` is set, a warning is surfaced by the caller, not an error.
|
|
401
|
+
"""
|
|
402
|
+
import tempfile
|
|
403
|
+
|
|
404
|
+
from composer.authoring.runtime_emit import render_runtime_yaml
|
|
405
|
+
|
|
406
|
+
policy_errors: list[TeachingError] = []
|
|
407
|
+
err = _protection_policy_error(answers)
|
|
408
|
+
if err is not None:
|
|
409
|
+
policy_errors.append(err)
|
|
410
|
+
|
|
411
|
+
text = render_runtime_yaml(answers)
|
|
412
|
+
with tempfile.NamedTemporaryFile("w", suffix=".runtime.yaml", delete=False, encoding="utf-8") as fh:
|
|
413
|
+
fh.write(text)
|
|
414
|
+
tmp = fh.name
|
|
415
|
+
try:
|
|
416
|
+
oracle = _run_node_oracle(Path(tmp))
|
|
417
|
+
finally:
|
|
418
|
+
try:
|
|
419
|
+
os.unlink(tmp)
|
|
420
|
+
except OSError:
|
|
421
|
+
pass
|
|
422
|
+
return oracle, policy_errors
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""smoke — the fixture-smoke stage of ``composer check`` (promoted from the pytest fixtures).
|
|
2
|
+
|
|
3
|
+
``composer check`` must prove the emitted ``scan.py`` doesn't merely compile but IMPORTS and
|
|
4
|
+
RUNS: a graph can typecheck yet still blow up on the first tick (a bad primitive call, a
|
|
5
|
+
port wired to the wrong shape). The slice-3 golden tests already exercised this through
|
|
6
|
+
``python-tests/composer/fixtures/composed.py`` (a real scaffold ``ScanContext`` +
|
|
7
|
+
``StateManager`` + a fake MCP), but that machinery lived under the test tree. This module
|
|
8
|
+
promotes the MINIMAL slice of it into ``composer/`` proper so ``check`` runs the same smoke
|
|
9
|
+
OUTSIDE pytest:
|
|
10
|
+
|
|
11
|
+
* import the emitted module (exec the compiled source as a live module),
|
|
12
|
+
* build a real frozen ``ScanContext`` over a temp ``StateManager`` and a benign stub MCP
|
|
13
|
+
(``StubMcp`` — every corpus read returns ``None`` / empty, which every source
|
|
14
|
+
normalizer tolerates by dropping the asset; the tick therefore runs clean and returns
|
|
15
|
+
``[]``),
|
|
16
|
+
* call ``scan(inputs, ctx)`` ONCE and assert it returns a list.
|
|
17
|
+
|
|
18
|
+
The point is liveness, not correctness (correctness is the golden tests): a green smoke
|
|
19
|
+
means "this artifact loads and one tick executes end-to-end without raising". ``scaffold``
|
|
20
|
+
is made importable the same way the pytest run does it (pyproject ``pythonpath`` =
|
|
21
|
+
``src/scanners/external``) by putting that dir on ``sys.path`` — via
|
|
22
|
+
:mod:`composer.scaffold_path`, which resolves it in BOTH the dev checkout (``src/scanners/
|
|
23
|
+
external``) and the claw dist install (``dist/scanners/external``, no ``src/`` segment) with no
|
|
24
|
+
manual ``PYTHONPATH``, honoring a ``COMPOSER_SCAFFOLD_DIR`` override. So the verb works when
|
|
25
|
+
invoked as a plain CLI with no pytest/conftest in the picture, on either layout.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import contextlib
|
|
31
|
+
import importlib.util
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
import uuid
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from composer.scaffold_path import ScaffoldNotFound, ensure_scaffold_on_syspath
|
|
38
|
+
from composer.vendoring import emitted_scan_on_syspath
|
|
39
|
+
|
|
40
|
+
# Best-effort: put `scaffold` on sys.path at import time when it resolves. The AUTHORITATIVE
|
|
41
|
+
# resolution (and the teaching failure when it is genuinely absent) happens in run_smoke, so a
|
|
42
|
+
# missing scaffold surfaces as a RED smoke stage — never a bare `No module named 'scaffold'`
|
|
43
|
+
# that misleads the reader toward the unrelated PyPI `scaffold` package.
|
|
44
|
+
with contextlib.suppress(ScaffoldNotFound):
|
|
45
|
+
ensure_scaffold_on_syspath()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StubMcp:
|
|
49
|
+
"""A benign ``ctx.senpi_mcp``: every corpus read returns ``None``.
|
|
50
|
+
|
|
51
|
+
Source normalizers (``composer.primitives.sources``) all treat a falsy/None response as
|
|
52
|
+
"drop this asset", and the adapter swallows read errors — so a tick over this stub runs
|
|
53
|
+
to completion and emits nothing. That is exactly the liveness signal smoke wants: the
|
|
54
|
+
emitted code's control flow executes over empty-but-valid data without raising.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self.calls: list = []
|
|
59
|
+
|
|
60
|
+
def call_tool(self, name: str, args: dict):
|
|
61
|
+
self.calls.append((name, args))
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SmokeError(Exception):
|
|
66
|
+
"""The emitted module failed to import or its first ``scan()`` tick raised/misbehaved."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _import_scan(source: str, module_name: str):
|
|
70
|
+
"""Exec the compiled ``scan.py`` source as a live module; return its ``scan`` callable.
|
|
71
|
+
|
|
72
|
+
The emitted module imports ``strategy_primitives`` (the vendored snapshot), so smoke stages
|
|
73
|
+
that package beside the temp ``scan.py`` and puts the dir on ``sys.path`` — the on-box
|
|
74
|
+
scanner-dir layout — via :func:`composer.vendoring.emitted_scan_on_syspath`. This is what
|
|
75
|
+
makes smoke exercise the SAME self-contained artifact `deploy` stages.
|
|
76
|
+
"""
|
|
77
|
+
with tempfile.TemporaryDirectory(prefix="composer-smoke-") as tmp:
|
|
78
|
+
with emitted_scan_on_syspath(source, tmp, module_filename=f"{module_name}.py") as path:
|
|
79
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
80
|
+
module = importlib.util.module_from_spec(spec)
|
|
81
|
+
# Register so a traceback inside scan() can resolve the module frame.
|
|
82
|
+
sys.modules[module_name] = module
|
|
83
|
+
try:
|
|
84
|
+
spec.loader.exec_module(module)
|
|
85
|
+
except Exception as exc: # noqa: BLE001 — surfaced as a teaching-shaped smoke failure
|
|
86
|
+
raise SmokeError(f"emitted module failed to import: {exc!r}") from exc
|
|
87
|
+
finally:
|
|
88
|
+
sys.modules.pop(module_name, None)
|
|
89
|
+
scan = getattr(module, "scan", None)
|
|
90
|
+
if not callable(scan):
|
|
91
|
+
raise SmokeError("emitted module has no callable `scan(inputs, ctx)`.")
|
|
92
|
+
return scan
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_smoke(scan_source: str, *, scanner_name: str = "composer-smoke") -> dict:
|
|
96
|
+
"""Import the emitted source and run one ``scan()`` tick over a stub ctx.
|
|
97
|
+
|
|
98
|
+
Returns a small detail dict (``{signals, mcp_calls}``) on success; raises
|
|
99
|
+
:class:`SmokeError` if import fails, the tick raises, or the return is not a list.
|
|
100
|
+
"""
|
|
101
|
+
# Resolve `scaffold` across dev-checkout and claw-dist layouts BEFORE importing it, so a
|
|
102
|
+
# missing harness is a teaching-shaped RED smoke stage (naming the build step / env var and
|
|
103
|
+
# warning off `pip install scaffold`) rather than an opaque `No module named 'scaffold'`.
|
|
104
|
+
try:
|
|
105
|
+
ensure_scaffold_on_syspath()
|
|
106
|
+
except ScaffoldNotFound as exc:
|
|
107
|
+
raise SmokeError(str(exc)) from exc
|
|
108
|
+
from scaffold.ctx import ScanContext
|
|
109
|
+
from scaffold.state_manager import StateManager
|
|
110
|
+
|
|
111
|
+
module_name = f"composer_smoke_{uuid.uuid4().hex}"
|
|
112
|
+
scan = _import_scan(scan_source, module_name)
|
|
113
|
+
|
|
114
|
+
with tempfile.TemporaryDirectory(prefix="composer-smoke-state-") as tmp:
|
|
115
|
+
state = StateManager(str(Path(tmp) / "state.json"), state_history_max_count=50)
|
|
116
|
+
mcp = StubMcp()
|
|
117
|
+
ctx = ScanContext(
|
|
118
|
+
state=state,
|
|
119
|
+
wallet="0xSMOKE",
|
|
120
|
+
scanner_name=scanner_name,
|
|
121
|
+
interval_seconds=3600,
|
|
122
|
+
senpi_mcp=mcp,
|
|
123
|
+
)
|
|
124
|
+
try:
|
|
125
|
+
result = scan({}, ctx)
|
|
126
|
+
except Exception as exc: # noqa: BLE001
|
|
127
|
+
raise SmokeError(f"first scan() tick raised {exc!r}.") from exc
|
|
128
|
+
|
|
129
|
+
if not isinstance(result, list):
|
|
130
|
+
raise SmokeError(
|
|
131
|
+
f"scan() returned {type(result).__name__}, expected list[dict] (the "
|
|
132
|
+
f"external_scanner contract)."
|
|
133
|
+
)
|
|
134
|
+
return {"signals": len(result), "mcp_calls": len(mcp.calls)}
|