@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.
Files changed (108) hide show
  1. package/dist/cli/composer-commands.d.ts +37 -0
  2. package/dist/cli/composer-commands.d.ts.map +1 -0
  3. package/dist/cli/composer-commands.js +396 -0
  4. package/dist/cli/composer-commands.js.map +1 -0
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +2 -0
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/cli/validate-runtime-config.mjs +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. 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)}