From f218119c19d85e73ce4a5553c839704405125ebd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 11 Jun 2026 19:50:11 +0300 Subject: [PATCH 1/7] docs(roadmap): mark 1.K (FallbackChain) done across the roadmap (PR #13 merged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.K landed in PR #13 (2026-06-11). Reflect it everywhere status lives: - phase-1: §1.K header ✅ Done; 1.m2 milestone complete (1.B PR #7, 1.K PR #13); the dependency-matrix 1.K row gets its Done note; the top status blockquote now points past 1.K (next is 1.L, which scaffolds packages/core). - current.md: the seam policy lane is complete (1.K merged); the engine lane (1.L) is the active next step; the multimodal + PR #12 notes no longer call 1.K "next". - llm-provider-seam.md: the ADR-0030 strip-on-failover is now enforced by 1.K, not "not yet exercised — no consumer exists". Co-Authored-By: Claude Fable 5 --- .../shared-core/llm-provider-seam.md | 5 +-- docs/roadmap/current.md | 34 +++++++++++-------- docs/roadmap/phases/phase-1-engine-and-llm.md | 22 +++++++----- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/docs/reference/shared-core/llm-provider-seam.md b/docs/reference/shared-core/llm-provider-seam.md index d8cdca91..9022861c 100644 --- a/docs/reference/shared-core/llm-provider-seam.md +++ b/docs/reference/shared-core/llm-provider-seam.md @@ -293,8 +293,9 @@ adapters): `signature` is never persisted to a session, never replayed across a provider boundary on fallback, and never written to a run event or log — the engine does not interpret it; only the originating adapter feeds it back (a same-provider, - same-turn obligation owned by the 1.K `FallbackChain` strip-on-failover, not yet - exercised — no consumer beyond the adapters exists). + same-turn obligation enforced by the 1.K `FallbackChain` strip-on-failover, which + drops every `reasoning` part on a cross-provider advance — see the fallback section + below). - **`responseFormat`** on `LlmRequest` — `{ type: 'text' } | { type: 'json', schema, name?, strict? }`, one canonical JSON-Schema each adapter lowers to the provider's native structured-output mode (OpenAI `response_format`, Gemini `responseJsonSchema`, diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index a6231d7e..e809957f 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -109,18 +109,21 @@ triad, the `CapabilityFlags.media` matrix with `vision` as its derived alias, `U `LlmRequest.outputModalities`, and the reserved `generateMedia?`/`pollMediaJob?` methods — **shape only**, with honest all-false adapter matrices and a fail-fast media guard until 1.AE) — merged in **PR #11** (2026-06-10), landing the union members **before the seam's exhaustive consumers** exist. -Per the [sequencing plan](phases/phase-1-engine-and-llm.md#sequencing--parallelization), the next -work runs two parallel lanes: - -1. **Seam policy lane** — **1.K — `FallbackChain` runner** (retryable/fatal routing on `LlmError`, - per-attempt usage → `CostTracker`, and the ADR-0030 strip-the-reasoning-signature-on-failover - obligation) — the seam's last Phase-1 policy layer, completing **1.m2** with the cost tracker - ([ADR-0011](../decisions/0011-internal-llm-abstraction.md), - [llm-provider-seam.md](../reference/shared-core/llm-provider-seam.md)). -2. **Engine lane** — **1.L — `WorkflowYAMLParser`** *(critical path)* — scaffold `packages/core` and - parse+validate a `.relavium.yaml` against the reconciled `WorkflowSchema`, with typed, - field-named errors (**zero platform imports**), then 1.L → 1.L2 → 1.M → 1.N → 1.R, converging at the - **1.O join** (which waits on the fallback runner, 1.K) toward **M2**. +Per the [sequencing plan](phases/phase-1-engine-and-llm.md#sequencing--parallelization), the **seam +policy lane is now complete**: **1.K — `FallbackChain` runner** (retryable/fatal routing on `LlmError`, +per-attempt usage → `CostTracker`, the ADR-0030 strip-the-reasoning-signature-on-failover obligation, +plus the no-blind-auth-retry / rate-limit-cooldown / no-failover-after-first-content-chunk nuances) — +merged in **PR #13** (2026-06-11) as a `FallbackChain` class + `withFallback` façade in `@relavium/llm`, +the seam's last Phase-1 policy layer, **completing 1.m2** with the cost tracker +([ADR-0011](../decisions/0011-internal-llm-abstraction.md), +[llm-provider-seam.md](../reference/shared-core/llm-provider-seam.md)). *(PR #13 also refreshed the +model-pricing table to current provider models and added Claude Fable 5.)* The active work is now the +**engine lane**, which `@relavium/core` has not started: + +- **1.L — `WorkflowYAMLParser`** *(critical path)* — **scaffold `packages/core`** (still only a README) + and parse+validate a `.relavium.yaml` against the reconciled `WorkflowSchema`, with typed, + field-named errors (**zero platform imports**), then 1.L → 1.L2 → 1.M → 1.N → 1.R, converging at the + **1.O join** (whose `FallbackChain` dependency, 1.K, is now satisfied) toward **M2**. > **Multimodal I/O — the shape is landed (1.AD ✅ Done, PR #11, 2026-06-10).** First-class > image/audio/video I/O (input **and** output, incl. generate-media-by-rule) was decided on 2026-06-08: @@ -131,8 +134,8 @@ work runs two parallel lanes: > [phase-1](phases/phase-1-engine-and-llm.md)). **1.AD landed the seam shape before the exhaustive > consumers 1.K/1.O** (the same cheap-window move as ADR-0030), so the media union members are > non-breaking; the seam doc carries the full amendment section. **1.AE–1.AH (media -> input/engine/output + surfaces) are additive and do NOT gate M2** — the seam lane proceeds straight -> to **1.K**. +> input/engine/output + surfaces) are additive and do NOT gate M2** — the seam lane ran straight to +> **1.K** (✅ Done, PR #13), which closed it. > **Review-pass follow-ups landed (PR #12, merged 2026-06-11).** The 2026-06-10 engine/tooling > review pass landed as docs/decisions only — no Phase-1 workstream changed: **MCP client scheduling** @@ -141,7 +144,8 @@ work runs two parallel lanes: > off the M3 critical path); the **`turn_limit` `ErrorCode`** (a hard session turn cap, distinct from > the `[chat].max_messages` trim threshold); the **reserved `on_error` edge kind** > (workflow-yaml-spec.md, not authorable in v1.0); and a CI **engine dependency-allowlist guard** + the -> pnpm install-script allowlist. No Phase-1 work changes; 1.K/1.L remain the next workstreams. +> pnpm install-script allowlist. No Phase-1 work changed; **1.K has since landed (PR #13)** and **1.L is +> the next workstream**. Carry-over hardening is tracked in [deferred-tasks.md](deferred-tasks.md) — pick items up as Phase 1 first touches each file. diff --git a/docs/roadmap/phases/phase-1-engine-and-llm.md b/docs/roadmap/phases/phase-1-engine-and-llm.md index 8b6545ba..0120c945 100644 --- a/docs/roadmap/phases/phase-1-engine-and-llm.md +++ b/docs/roadmap/phases/phase-1-engine-and-llm.md @@ -9,10 +9,11 @@ > (reasoning channel + `responseFormat` + `providerExecuted`), and **1.J** (conformance green) landed > in **PR #9** (2026-06-07) — **🎯 M1 (LLM seam proven) is reached.** All three adapters pass one shared > conformance suite in fixture mode (live-nightly lane reserved/pending keys); no vendor type crosses the -> seam. **Next:** **1.K** (FallbackChain — born with the ADR-0030 obligation to **strip the ephemeral -> reasoning signature when failing over** to another provider) ‖ the **1.L** engine parser, converging -> at the **1.O** join toward **M2**. *(Session persistence, 1.X/1.Z, must exclude the reasoning -> signature — non-persisting.)* +> seam. **1.K (FallbackChain) is ✅ Done (PR #13, 2026-06-11)** — the seam's last policy layer, with the +> ADR-0030 strip-on-failover obligation honoured; **1.m2 (policy layers) is complete** (with the +> CostTracker, 1.B). **Next:** the **1.L** engine parser (it scaffolds `packages/core`), then Lane B +> (1.L → 1.L2 → 1.M → 1.N → 1.R) converging at the **1.O** join toward **M2**. *(Session persistence, +> 1.X/1.Z, must exclude the reasoning signature — non-persisting.)* > > **Multimodal I/O decided (2026-06-08).** First-class image/audio/video I/O (input **and** output) is a > second pre-freeze seam amendment in the ADR-0030 mould — [ADR-0031](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) @@ -22,7 +23,7 @@ > so the `ContentPart`/`StreamChunk` media union members are non-breaking; the **behavior (1.AE–1.AH) is > additive and does NOT gate M2** (it threads into the engine and Phases 2–6, like the agent-first sub-spine). > **1.AD is ✅ Done (PR #11, 2026-06-10)** — the shape landed with all-false adapter matrices and the -> fail-fast media guard; 1.K is unblocked. +> fail-fast media guard; it unblocked 1.K, now also ✅ Done (PR #13). - **Related**: [../README.md](../README.md), [phase-0-foundations.md](phase-0-foundations.md), [phase-2-cli.md](phase-2-cli.md), [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md), [../../architecture/execution-model.md](../../architecture/execution-model.md), [../../architecture/multi-llm-providers.md](../../architecture/multi-llm-providers.md), [../../reference/shared-core/llm-provider-seam.md](../../reference/shared-core/llm-provider-seam.md), [../../reference/shared-core/node-types.md](../../reference/shared-core/node-types.md), [../../reference/shared-core/built-in-tools.md](../../reference/shared-core/built-in-tools.md), [../../reference/contracts/sse-event-schema.md](../../reference/contracts/sse-event-schema.md), [../../standards/testing.md](../../standards/testing.md), [../../standards/error-handling.md](../../standards/error-handling.md), [../../decisions/0011-internal-llm-abstraction.md](../../decisions/0011-internal-llm-abstraction.md) @@ -385,10 +386,13 @@ no vendor type crossing the seam. **Acceptance:** fixture-mode conformance is a required, green CI check for all adapters; the import-zone lint and export audit pass — **M1 achieved**. -### 1.K — `FallbackChain` runner (policy outside the adapters) — *critical path* +### 1.K — `FallbackChain` runner (policy outside the adapters) — *critical path* · ✅ **Done (PR #13, 2026-06-11)** The small runner that walks an ordered list of models with per-model attempt -budgets. Adapters stay dumb; this owns the policy. +budgets. Adapters stay dumb; this owns the policy. **Landed as a `FallbackChain` +class + a `withFallback` façade in `@relavium/llm` (platform-free, host-injected +timer), 100% line/branch covered; the seam doc's "Fallback lives outside the +adapter" section is the canonical shape.** **Tasks:** - Implement `withFallback(providers, requestFn)` consuming the agent's @@ -888,7 +892,7 @@ the latter being the critical-path milestone for the whole product. | --- | --- | --- | | 1.m1 | Seam frozen; first adapter + conformance harness green (Anthropic) | 1.A, 1.C, 1.E, 1.F | | **M1 ✅** | **LLM seam proven: 3 adapters pass the conformance suite (fixtures on PR — live-nightly lane reserved/pending keys; no vendor type across the seam)** *(achieved 2026-06-07, PR #9)* | 1.G, 1.H, 1.I, **1.J** | -| 1.m2 | Policy layers complete: fallback runner + cost tracker | 1.B, 1.K | +| 1.m2 ✅ | Policy layers complete: fallback runner + cost tracker (**1.B PR #7, 1.K PR #13**) | 1.B, 1.K | | 1.m3 | Shared-schema reconciliation + interpolation engine, parse → DAG → run loop emits the canonical event stream | **1.L.0**, 1.L, **1.L2**, 1.M, 1.N | | 1.m4 | Agent + non-agent node handlers, gate, checkpoint/resume, retry, tools, **expression sandbox** + pre-egress budget | 1.O, 1.P, 1.Q, 1.R, 1.S, 1.T, **1.AB**, **1.AC** | | **M2** | **Engine end-to-end from a Node harness (stream + checkpoint + retry + fallback) — CRITICAL-PATH MILESTONE** | **1.U** | @@ -1032,7 +1036,7 @@ flowchart LR | 1.G | A | 1.F | 1.J | ✅ | | 1.H | A | 1.E, 1.F | 1.J | ✅ | | 1.J | A | 1.G, 1.H, 1.I | 1.K (**M1**) | ✅ | -| 1.K | A | 1.B, 1.I, 1.J, 1.AD (media shape) | 1.O | ✅ | +| 1.K | A | 1.B, 1.I, 1.J, 1.AD (media shape) | 1.O | ✅ — **Done (PR #13)** | | 1.L | B | 1.L.0 | 1.L2, 1.Z | ✅ | | 1.L2 | B | 1.L | 1.M | ✅ | | 1.M | B | 1.L2 | 1.N | ✅ | From e9ec0d524f07da10e7ca13aec0e7f64f73ef4e71 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 11 Jun 2026 20:00:54 +0300 Subject: [PATCH 2/7] docs: refresh the status paragraphs for 1.K (PR #13) and 1.AD (PR #11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root README / CLAUDE.md / AGENTS.md / roadmap-README status lines still stopped at M1 (PR #9) and framed the FallbackChain (1.K) as upcoming/active. A doc audit (verified against the repo) found these were the only stale status surfaces left after the 1.K done-marking: - README.md: 1.K dropped from "next on the critical path" (it landed, PR #13); the engine (@relavium/core, not yet scaffolded) is now the sole next item. - CLAUDE.md: the Status headline notes 1.K landed completing 1.m2; the "active work continues on" clause points at the engine (1.L next), not 1.K. - AGENTS.md: the mirror gains the 1.AD (PR #11) + 1.K (PR #13) landings; active work redirected to the engine lane. - roadmap/README.md: the M1 row's "complete just after, at 1.m2" → past tense. The deferred-tasks audit found nothing closeable by completed work (the pricing item was already checked off; everything else is correctly future work). Co-Authored-By: Claude Fable 5 --- AGENTS.md | 7 ++++--- CLAUDE.md | 9 +++++---- README.md | 7 ++++--- docs/roadmap/README.md | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0efa7c1c..ff317f3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,9 +24,10 @@ gateway; engine stays local, Phase 2) — split across build phase 5 (managed in phase 6 (cloud execution + portal); the engine is identical across all three (ADR-0012..0015). **Status: Phase 1 in progress — milestone M1 (LLM seam proven) reached (PR #9, 2026-06-07);** `@relavium/llm` (the seam + all three adapters) is landed and green. Phase 0 (M0) landed -the monorepo + `@relavium/shared` + CI + `@relavium/db`. Active work continues on the -`FallbackChain` (1.K) and the -[`@relavium/core` engine](docs/roadmap/phases/phase-1-engine-and-llm.md); see +the monorepo + `@relavium/shared` + CI + `@relavium/db`. Since then, the ADR-0031 multimodal +seam-shape amendment (1.AD, PR #11) and the `FallbackChain` runner (1.K, PR #13, 2026-06-11) +have landed. Active work continues on the +[`@relavium/core` engine](docs/roadmap/phases/phase-1-engine-and-llm.md) (1.L next); see [docs/roadmap/current.md](docs/roadmap/current.md). ## The non-negotiable rules diff --git a/CLAUDE.md b/CLAUDE.md index 40b83ada..f5add541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,16 +38,17 @@ A run executes in one of **three execution modes** behind the one `LLMProvider` engine is identical across all three. See [ADR-0012](docs/decisions/0012-managed-inference-dual-mode.md) to [ADR-0015](docs/decisions/0015-managed-mode-data-handling-and-compliance.md) and [docs/architecture/managed-inference.md](docs/architecture/managed-inference.md). -**Status: Phase 1 in progress — milestone M1 (LLM seam proven) reached (PR #9, 2026-06-07).** +**Status: Phase 1 in progress — milestone M1 (LLM seam proven) reached (PR #9, 2026-06-07); the `FallbackChain` runner (1.K) has since landed, completing 1.m2 with the cost tracker (PR #13, 2026-06-11).** Phase 0 (M0, 2026-06-04) landed the monorepo, strict toolchain + CI, `@relavium/shared` (the full Zod contract set), the no-vendor-type seam fence, and `@relavium/db`. Phase 1 has since landed `@relavium/llm` — the `LLMProvider` seam + all three adapters (Anthropic, OpenAI/DeepSeek, Gemini), green on one shared conformance suite with no vendor type crossing the seam, plus the ADR-0030 seam-shape amendment — and the **ADR-0031 multimodal seam-shape amendment (1.AD, PR #11, 2026-06-10)**: the media content/stream union members, the per-modality capability matrix, and the -reserved generator methods, shape-only, landed before the seam's exhaustive consumers. Active work -continues on the `FallbackChain` (1.K) and the -[`@relavium/core` engine](docs/roadmap/phases/phase-1-engine-and-llm.md); see +reserved generator methods, shape-only, landed before the seam's exhaustive consumers. The +`FallbackChain` runner (1.K, PR #13) is now landed and fully covered; active work continues on the +[`@relavium/core` engine](docs/roadmap/phases/phase-1-engine-and-llm.md), starting with the YAML +parser (1.L); see [docs/roadmap/current.md](docs/roadmap/current.md). See [README.md](README.md) for the public overview. ## Non-negotiable rules for AI agents diff --git a/README.md b/README.md index a7098c13..f54162fe 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ OpenAI + DeepSeek; Gemini), passing one shared conformance suite behind the froz vendor type crossing it (PR #7–#9), followed by the **ADR-0031 multimodal seam-shape amendment (1.AD)** — the media content/stream union members, the per-modality capability matrix, and the reserved generator methods, landed **shape-only** before the seam's exhaustive consumers exist -(PR #11, 2026-06-10). Next on the critical path: the `FallbackChain` runner (1.K) -and the `@relavium/core` engine — parser, run loop, checkpoint/resume, retry, plus the **AgentSession** -runtime + export-to-workflow sub-spine. See [docs/roadmap/current.md](docs/roadmap/current.md) for +(PR #11, 2026-06-10). The seam's last policy layer — the `FallbackChain` runner (1.K) — then landed +(PR #13, 2026-06-11), completing the LLM lane. Next on the critical path: the `@relavium/core` engine +— parser, run loop, checkpoint/resume, retry, plus the **AgentSession** runtime + export-to-workflow +sub-spine. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 714a683b..ba4abf04 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -138,7 +138,7 @@ to that phase's work breakdown). | ID | Milestone | Product phase | Achieved by (phase · workstreams) | |----|-----------|---------------|-----------------------------------| | **M0 ✅** | **Foundations green** *(achieved 2026-06-04)* — monorepo + tooling + CI are green on a clean checkout; `@relavium/shared` exports the full Zod schema set and round-trips the reference YAML with no drift; the canonical `RunEvent` union (`sequenceNumber`, `cost:updated`) is pinned by a type-level + runtime test; the no-vendor-type seam fence is live; `@relavium/db` is scaffolded. | 1 | Phase 0 · 0.A–0.I *(done, PR #1–#3)* | -| **M1 ✅** | **LLM seam proven** *(achieved 2026-06-07, PR #9)* — `@relavium/llm`'s provider-agnostic `LLMProvider` seam with 3 adapters (Anthropic, the OpenAI-compatible adapter serving OpenAI + DeepSeek, Gemini), all passing one shared conformance suite (fixtures on PR, live nightly reserved/pending keys); no vendor SDK type crosses the seam. The `FallbackChain` runner and cost tracker complete just after, at 1.m2. (Built **first** per the engine-first order — the engine's `AgentRunner` depends on it.) | 1 | Phase 1 · `@relavium/llm` seam + 3 adapters — conformance green at **1.J** (1.G–1.J; FallbackChain + cost tracker at 1.m2 / 1.B, 1.K) | +| **M1 ✅** | **LLM seam proven** *(achieved 2026-06-07, PR #9)* — `@relavium/llm`'s provider-agnostic `LLMProvider` seam with 3 adapters (Anthropic, the OpenAI-compatible adapter serving OpenAI + DeepSeek, Gemini), all passing one shared conformance suite (fixtures on PR, live nightly reserved/pending keys); no vendor SDK type crosses the seam. The `FallbackChain` runner (1.K, PR #13) and the cost tracker (1.B) have since landed, completing 1.m2. (Built **first** per the engine-first order — the engine's `AgentRunner` depends on it.) | 1 | Phase 1 · `@relavium/llm` seam + 3 adapters — conformance green at **1.J** (1.G–1.J; FallbackChain + cost tracker at 1.m2 / 1.B, 1.K) | | **M2** | **Engine end-to-end (critical-path milestone)** — `@relavium/core` parses YAML→DAG and runs it (the `WorkflowEngine` + `AgentRunner` over all node types, emitting canonical events through the `RunEventBus`, with checkpoint/resume, retry, the deterministic expression sandbox, and the pre-egress budget governor), with the M1 seam wired in; a Node harness runs a 3-node workflow end-to-end with live streaming, checkpoint/resume, retry, and a provider fallback. Zero platform-specific imports. | 1 | Phase 1 · `@relavium/core` parser + runner + checkpoint/resume + retry + **expression sandbox (1.AB) + budget governor (1.AC)** + Node harness (1.L–1.U); the agent-first sub-spine (1.V–1.AA) lands in parallel (1.m5) | | **M3** | **CLI + engine regression harness** — the `relavium` CLI drives the engine end-to-end with a live `ink` TUI and a deterministic `--json` CI mode; a small fixture suite runs in CI on every engine change as the agreed regression gate for Phases 3–6. | 1 | Phase 2 · 2.D (run wiring), 2.F (`--json` / CI mode), 2.K (regression harness) | | **M4** | **Desktop agent-management center** — a signed, offline, no-account Tauri v2 app: ReactFlow canvas with all node types, live execution theater, gate overlay, local SQLite history (trace/Gantt/replay/retry-from-node), OS keychain; ADR-0010 perf gate + per-platform e2e green. | 1 | Phase 3 · 3.K (packaging), 3.L (e2e + perf gate), 3.P (sign-off) | From 61632358ebe9ecc5cd43993310899ea81be323d0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 11 Jun 2026 23:48:24 +0300 Subject: [PATCH 3/7] feat(core): scaffold @relavium/core + the WorkflowYAMLParser (1.L) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold the engine package (platform-free — tsconfig types:[], zero platform imports) and land its first workstream: parseWorkflow(yamlText, opts?), which decodes a .relavium.yaml STRING and validates it against the strict @relavium/shared WorkflowSchema, returning a typed WorkflowDefinition or a typed, field-named, secret-free error. Pure: takes text, never reads the filesystem or the environment. - YAML loader: the `yaml` package (eemeli/yaml) under an ADR-0035 hardened, deterministic profile — version 1.2 / schema 'core' (no Date/Buffer), merge off, uniqueKeys, stringKeys, maxAliasCount 0 (no anchor/alias expansion), prettyErrors off + logLevel error (pure, no console), a LineCounter for line/col, and a 2 MiB pre-parse size cap. Every parse-stage throw (a YAML fault or the alias ReferenceError) is normalized to a typed WorkflowSyntaxError; the source text never enters a message. - Errors: WorkflowParseError (abstract base + code) -> WorkflowSyntaxError / WorkflowValidationError, mirroring @relavium/llm's LlmConfigError. The Zod issue -> field locator resolves a node/agent/input/edge index to its authored id ("node `summarize`.agent_ref"); messages are code-derived and never echo an authored value. The raw ZodError is NOT attached as cause, and only a well-formed identifier is echoed into a locator — so an invalid/sensitive id never leaks. - Interpolation: parseTemplate(text) — a pure, total lexer turning `{{ … }}` templates into structured, UN-EVALUATED segments (kind inputs|ctx|node|secrets|unknown, identifier, path, ordered pipe filters, verbatim raw); collectReferences(workflow) walks the template fields for the DAG builder (1.M). Evaluation + secret-taint stay in 1.L2. - Wiring (same change as the ADR, per the engine-deps guard): `yaml` pinned in the pnpm catalog, added to ENGINE_ALLOWLISTS['packages/core'], a tech-stack row, and the core coverage floor (>=90% line+branch) enabled. 63 core tests; @relavium/core at 95.5% branch / 98.9% line. A multi-agent adversarial review (6 dimensions) folded in: dropped the ZodError cause, guarded the id-echo, made the `{{ }}` close-scan quote-aware, and tightened numeric filter-arg parsing. Refs: ADR-0035, ADR-0023, ADR-0009 Co-Authored-By: Claude Fable 5 --- docs/decisions/0035-yaml-parser-dependency.md | 104 +++++ docs/decisions/README.md | 1 + docs/tech-stack.md | 3 +- packages/core/README.md | 25 +- packages/core/package.json | 35 ++ packages/core/src/errors.test.ts | 48 +++ packages/core/src/errors.ts | 79 ++++ packages/core/src/index.ts | 27 ++ packages/core/src/interpolation/collect.ts | 75 ++++ .../core/src/interpolation/references.test.ts | 177 ++++++++ packages/core/src/interpolation/references.ts | 231 ++++++++++ packages/core/src/parser.test.ts | 397 ++++++++++++++++++ packages/core/src/parser.ts | 207 +++++++++ packages/core/tsconfig.build.json | 12 + packages/core/tsconfig.json | 14 + pnpm-lock.yaml | 84 ++-- pnpm-workspace.yaml | 3 + tools/engine-deps/check.mjs | 7 +- vitest.config.ts | 2 +- 19 files changed, 1494 insertions(+), 37 deletions(-) create mode 100644 docs/decisions/0035-yaml-parser-dependency.md create mode 100644 packages/core/package.json create mode 100644 packages/core/src/errors.test.ts create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/interpolation/collect.ts create mode 100644 packages/core/src/interpolation/references.test.ts create mode 100644 packages/core/src/interpolation/references.ts create mode 100644 packages/core/src/parser.test.ts create mode 100644 packages/core/src/parser.ts create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/tsconfig.json diff --git a/docs/decisions/0035-yaml-parser-dependency.md b/docs/decisions/0035-yaml-parser-dependency.md new file mode 100644 index 00000000..fae8405d --- /dev/null +++ b/docs/decisions/0035-yaml-parser-dependency.md @@ -0,0 +1,104 @@ +# ADR-0035: YAML parser for the engine — the `yaml` package, confined to `@relavium/core` + +- **Status**: Accepted +- **Date**: 2026-06-11 +- **Related**: [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md), [ADR-0009](0009-git-native-workflow-yaml.md), [ADR-0011](0011-internal-llm-abstraction.md), [ADR-0020](0020-zod-runtime-schema-library.md), [ADR-0023](0023-strict-authored-yaml-validation.md), [ADR-0033](0033-strict-config-files-amends-0023.md), [workflow-yaml-spec.md](../reference/contracts/workflow-yaml-spec.md), [architectural-principles.md](../standards/architectural-principles.md), [tech-stack.md](../tech-stack.md) + +## Context + +Workflows and agents are authored as **git-committable YAML** ([ADR-0009](0009-git-native-workflow-yaml.md)). +The engine's first workstream — `WorkflowYAMLParser` (1.L) — must turn a `.relavium.yaml` +**string** into a plain JS object before validating it against the strict `@relavium/shared` +`WorkflowSchema` ([ADR-0023](0023-strict-authored-yaml-validation.md)). `@relavium/shared` +deliberately validates **already-parsed objects only** (its round-trip test notes "YAML→object parsing +is `@relavium/core`'s responsibility" and round-trips a JS object, not a YAML string), so the +YAML-string→object step is unowned until 1.L — and it serves both the workflow parser and the future +**agent**-YAML parser, so the decode + syntax-error layer is authored to be document-agnostic. + +There is **no YAML library pinned anywhere** in the workspace: `tech-stack.md` lists none, the pnpm +catalog has no entry, and no package depends on one (the only `yaml`/`js-yaml` entries in the lockfile +are dev-tooling transitives of ESLint/Prettier — not a sanctioned runtime dependency). Adding one is a +**new engine runtime dependency**, which the no-new-runtime-dependency-without-an-ADR rule +(CLAUDE.md non-negotiable #2; [architectural-principles.md](../standards/architectural-principles.md) §9) +requires be decided here, **before** any implementation — and paired in the same change with the +`tools/engine-deps/check.mjs` allowlist edit the guard demands. + +Two constraints shape the choice. (1) **Engine purity** (CLAUDE.md rule #5, [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md), +[ADR-0011](0011-internal-llm-abstraction.md)): `@relavium/core` runs identically in Node, the Tauri +WebView, the VS Code extension host, and Bun, so the parser must be **pure-JS with zero native bindings** +— a native-addon YAML library would break the WebView surface and need a `pnpm.onlyBuiltDependencies` +install-script entry — and the decode must be **deterministic across surfaces** (the same file must +produce the same plain object in every runtime). (2) **Build-in-house targets +the core** (engine, LLM seam, schedulers), *not* interop-format plumbing — a YAML 1.2 tokenizer is +exactly the commodity, security-sensitive primitive the "never hand-roll, wrap a vetted library" rule +([ADR-0019](0019-cli-node-keychain-library.md) set the precedent) says to adopt rather than write. + +## Decision + +**We will parse authored YAML with the [`yaml`](https://eemeli.org/yaml/) package (eemeli/yaml), as a +runtime dependency of `@relavium/core` only, pinned through the pnpm `catalog:` — see +[tech-stack.md](../tech-stack.md) for the version.** + +- The parser is configured with an explicit **hardened, deterministic profile**, not the library + defaults. The default `parse(text)` resolves `!!timestamp`→`Date`, `!!binary`→`Buffer`/`Uint8Array`, + YAML-1.1 `<<` merge keys, complex (non-string) map keys, and anchor/alias graphs — any of which would + make the same file decode to **different objects on different surfaces** (Node vs WebView) and break + checkpoint/export/serialization. The pinned options are `version: '1.2'`, `schema: 'core'` (a date- or + binary-like scalar stays a **string** — no `Date`/`Buffer`), `resolveKnownTags: false`, `merge: false`, + `uniqueKeys: true`, `stringKeys: true`, `maxAliasCount: 0` (**anchors/aliases are not part of the + authored contract** — this forecloses the billion-laughs / recursive-alias expansion class outright), + and `prettyErrors: false` + `logLevel: 'error'` (no source snippet in the message, no `console.warn` — + the parser is a pure function). A `%YAML 1.1` directive is **not honoured**: the 1.2 core profile is + pinned regardless. The decode is further guarded by a pre-parse source-size cap, and **every** + parse-stage throw (a YAML fault, or the anchor/alias `ReferenceError` that `maxAliasCount: 0` + raises) is normalized to a typed, secret-free `WorkflowSyntaxError` whose line/column come from a + `LineCounter`, never the source text. With this + profile the decode yields only plain JSON-like data, and **strict Zod then enforces the contract** + ([ADR-0023](0023-strict-authored-yaml-validation.md) / [ADR-0033](0033-strict-config-files-amends-0023.md)) + on that data. +- The dependency is **confined to `@relavium/core`** — the document-agnostic decode + `WorkflowSyntaxError` + layer serves the workflow parser today and the agent-YAML parser later. It is added to + `ENGINE_ALLOWLISTS['packages/core']` in `tools/engine-deps/check.mjs` in this same change, is **not** + added to `@relavium/shared` (which stays Zod-only), and needs **no** `onlyBuiltDependencies` entry (pure-JS). + +Considered alternatives: + +- **`js-yaml`** (rejected) — also pure-JS and viable; the choice is on measurable grounds. `yaml` ships a + documented CST/AST and a `LineCounter` for precise, source-free diagnostics (which the field-named + errors and the future VS Code language server need), exposes the alias-count, schema, and string-key + controls the hardened profile above depends on, carries zero transitive runtime dependencies with a + first-class browser build, and has a steadier recent release cadence. `js-yaml` would still need this + same ADR + allowlist edit. +- **Hand-roll a YAML subset parser** (rejected) — YAML is a deceptively large, security-sensitive grammar + (anchors, aliases, billion-laughs); hand-rolling buys no differentiation, adds a permanent + spec-tracking burden, and is exactly the class of commodity primitive the build-in-house rule excludes. +- **Reuse the transitive `js-yaml` in the lockfile** (rejected) — it is dev-tooling, absent from the + catalog, the engine allowlist, and the install allowlist; relying on it would bypass rule #2 and is + precisely what the engine-deps guard exists to catch. +- **Parse YAML in `@relavium/shared`** (rejected) — `@relavium/shared` is the platform-free contract + layer with Zod as its sole runtime dependency; the parse step belongs to the engine that consumes it. + +## Consequences + +### Positive + +- 1.L gets a decided, pure-JS dependency before any code is written — no ad-hoc adoption inside a + feature PR — and the engine stays runnable on every surface (Node, Tauri WebView, VS Code host, Bun). +- The library tracks YAML 1.2 for us and gives precise syntax positions; the strict Zod schema remains + the single source of validation truth, so the parser maps faithful objects → field-named errors. +- The **hardened profile** makes the decode deterministic across surfaces (plain JSON-like data only), + forecloses the alias-expansion DoS class (`maxAliasCount: 0` + a size cap), and keeps every parse + failure a typed, secret-free `WorkflowSyntaxError` — so a cloned repo's file can neither crash the + engine before Zod runs nor leak its content into an error. +- The dependency is quarantined to `@relavium/core`'s manifest and asserted by the engine-deps guard; + `@relavium/shared` stays Zod-only. + +### Negative + +- A new runtime dependency in the engine — mitigated by it being pure-JS, catalog-pinned, confined to + the one package the guard enforces, and a commodity the build-in-house rule explicitly excludes from + "core". +- The profile deliberately rejects YAML features authors might expect from other tools — anchors/aliases, + `<<` merge keys, `%YAML 1.1`, `!!`-tagged Dates/binaries. Accepted: these add cross-surface + non-determinism or a DoS surface for no authoring benefit; authored Relavium files are small and + hand-written, and a rejected file fails fast with a typed, field-named error. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2c415c76..147662d7 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -78,6 +78,7 @@ flowchart TD | 0032 | [Desktop Rust-side media de-inlining on the egress path (amends 0018)](0032-desktop-rust-media-de-inline-amends-0018.md) | Accepted | 2026-06-08 | | 0033 | [Local config files are strictly validated too (amends 0023)](0033-strict-config-files-amends-0023.md) | Accepted | 2026-06-09 | | 0034 | [MCP client implementation — the official TypeScript SDK, scheduled in build phase 2](0034-mcp-client-sdk-dependency.md) | Accepted | 2026-06-10 | +| 0035 | [YAML parser for the engine — the `yaml` package, confined to `@relavium/core`](0035-yaml-parser-dependency.md) | Accepted | 2026-06-11 | ## Creating a new ADR diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 6d228c04..d4e73f10 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -22,7 +22,8 @@ adversarially reviewed by a 10-agent workflow before being locked. | Frontend (all surfaces) | **Vite + React 19 + TanStack Router** | Not Next.js — SSE streaming + ReactFlow canvas are incompatible with RSC / edge runtime. See [ADR-0002](decisions/0002-vite-react-tanstack-not-nextjs.md). | | Multi-LLM | **Internal `@relavium/llm` abstraction** over official provider SDKs (`@anthropic-ai/sdk`, `openai`, `@google/genai`; DeepSeek via the OpenAI-compatible adapter) | No 3rd-party framework, no Vercel, no Python sidecar; an owned provider-agnostic seam gives unified streaming + tool calling + cost across Anthropic / OpenAI / Gemini / DeepSeek with no vendor lock-in. See [ADR-0011](decisions/0011-internal-llm-abstraction.md). | | Orchestration engine | **Pure TypeScript** (`packages/core`) | No LangGraph-Python; the same concepts are implementable in TS. Engine is framework-agnostic and runs identically on every surface. | -| Expression sandbox (engine) | **QuickJS compiled to WebAssembly** (candidate package `quickjs-emscripten`, confirmed by the 1.AB perf spike; instantiated via the standard `WebAssembly` global from embedded bytes) | Deterministic, resource-capped evaluation of `condition` / `transform` / `merge_fn` JS expressions that runs in **every** host the engine runs in, including the Tauri WebView — `isolated-vm` / Node `vm` / `new Function` rejected for breaking the zero-platform-imports invariant. The engine's first runtime dependency; the package is selected and its version pinned in the `catalog:` by the 1.AB perf spike. See [ADR-0027](decisions/0027-expression-sandbox.md). | +| YAML parser (engine) | **`yaml`** (eemeli/yaml — pure-JS, confined to `packages/core`) | Decodes an authored `.relavium.yaml` string to a plain JS object for the strict `WorkflowSchema`; pure-JS (no native binding) preserves the zero-platform-imports invariant (Tauri WebView / Bun), and strict Zod does all schema enforcement. The engine's first runtime dependency, landed with the 1.L parser and pinned in the `catalog:`. See [ADR-0035](decisions/0035-yaml-parser-dependency.md). | +| Expression sandbox (engine) | **QuickJS compiled to WebAssembly** (candidate package `quickjs-emscripten`, confirmed by the 1.AB perf spike; instantiated via the standard `WebAssembly` global from embedded bytes) | Deterministic, resource-capped evaluation of `condition` / `transform` / `merge_fn` JS expressions that runs in **every** host the engine runs in, including the Tauri WebView — `isolated-vm` / Node `vm` / `new Function` rejected for breaking the zero-platform-imports invariant. The engine's second runtime dependency (after the `yaml` loader); the package is selected and its version pinned in the `catalog:` by the 1.AB perf spike. See [ADR-0027](decisions/0027-expression-sandbox.md). | | Database (local, Phase 1) | **SQLite + Drizzle ORM** (SQLCipher) | Tauri plugin available; encrypted at rest with SQLCipher. Node-side consumers (CLI, `@relavium/db` tests) use the **`better-sqlite3`** driver — see [ADR-0021](decisions/0021-node-sqlite-driver-better-sqlite3.md). | | Database (cloud, Phase 2) | **PostgreSQL 16 + Redis 7 + BullMQ** | *Phase 2 only.* Same Drizzle schema, different driver. | | API key storage | **OS keychain**, one `KeychainStore` interface with a per-surface accessor — desktop `tauri-plugin-keychain` (Rust), **CLI `@napi-rs/keyring`** (Node; *not* the archived `keytar`, see [ADR-0019](decisions/0019-cli-node-keychain-library.md)), VS Code `vscode.SecretStorage` | macOS Keychain / Windows Credential Manager / libsecret. Never plaintext, never sent to the frontend. See [ADR-0006](decisions/0006-os-keychain-for-api-keys.md). | diff --git a/packages/core/README.md b/packages/core/README.md index 47ccc1a4..dc6a81b7 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,11 +1,22 @@ -# `@relavium/core` — placeholder +# `@relavium/core` — the engine -The engine: YAML→DAG parse, runner, checkpoint/resume, retry. **Zero -platform-specific imports** — runs identically on every surface. The most important -package. +YAML→DAG parse, runner, checkpoint/resume, retry. **Zero platform-specific imports** +— runs identically in Node, the Tauri WebView, the VS Code extension host, and (Phase 2) +the Bun API. The most important package. -> **Not built yet.** This package is a directory placeholder. It is built out in -> [Phase 1 — engine and LLM](../../docs/roadmap/phases/phase-1-engine-and-llm.md). It has -> no `package.json` yet, so pnpm/Turborepo do not treat it as a workspace. +The whole package is the platform-free zone: `tsconfig.json` sets `types: []`, so a stray +`process`/`Buffer`/`node:*` is a type error (CLAUDE.md rule 5, +[ADR-0011](../../docs/decisions/0011-internal-llm-abstraction.md)). Runtime dependencies are +confined to `@relavium/shared`, `@relavium/llm`, `zod`, and the pure-JS `yaml` loader +([ADR-0035](../../docs/decisions/0035-yaml-parser-dependency.md)) — enforced by the engine-deps +guard (`tools/engine-deps/check.mjs`). +## Built so far + +- **`parseWorkflow` (1.L)** — load a `.relavium.yaml` **string** and validate it against the + `@relavium/shared` `WorkflowSchema` (strict, [ADR-0023](../../docs/decisions/0023-strict-authored-yaml-validation.md)), + with typed, field-named, secret-free errors. Pure: it takes text, never reads the filesystem. + Plus the structured (un-evaluated) interpolation-reference extractor the DAG builder (1.M) consumes. + +Built out in [Phase 1 — engine and LLM](../../docs/roadmap/phases/phase-1-engine-and-llm.md). Architecture: [shared-core-engine.md](../../docs/architecture/shared-core-engine.md). diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..6352cba9 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,35 @@ +{ + "name": "@relavium/core", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "The engine — YAML→DAG parse, runner, checkpoint/resume, retry. Zero platform-specific imports.", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint src", + "test": "vitest run" + }, + "dependencies": { + "@relavium/shared": "workspace:*", + "yaml": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "eslint": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts new file mode 100644 index 00000000..a936c8d5 --- /dev/null +++ b/packages/core/src/errors.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { WorkflowSyntaxError, WorkflowValidationError, type WorkflowIssue } from './errors.js'; + +const issue = (field: string): WorkflowIssue => ({ field, message: 'bad' }); + +describe('WorkflowValidationError', () => { + it('summarizes the first issue and counts the rest (plural)', () => { + const err = new WorkflowValidationError([issue('a'), issue('b'), issue('c')]); + expect(err.code).toBe('schema_validation'); + expect(err.message).toBe('a: bad (and 2 more issues)'); + }); + + it('summarizes two issues with a singular "more" suffix', () => { + expect(new WorkflowValidationError([issue('a'), issue('b')]).message).toBe( + 'a: bad (and 1 more issue)', + ); + }); + + it('summarizes a single issue with no "more" suffix', () => { + expect(new WorkflowValidationError([issue('only')]).message).toBe('only: bad'); + }); + + it('summarizes an empty issue list defensively', () => { + expect(new WorkflowValidationError([]).message).toBe('workflow validation failed'); + }); + + it('does not set a source when none is given', () => { + expect(new WorkflowValidationError([issue('a')]).source).toBeUndefined(); + }); +}); + +describe('WorkflowSyntaxError', () => { + it('carries code, optional position, and the cause', () => { + const cause = new Error('inner'); + const err = new WorkflowSyntaxError('bad yaml', { line: 4, column: 2, cause }); + expect(err.code).toBe('invalid_yaml'); + expect(err.line).toBe(4); + expect(err.column).toBe(2); + expect(err.cause).toBe(cause); + }); + + it('omits position fields when not provided', () => { + const err = new WorkflowSyntaxError('bad yaml'); + expect(err.line).toBeUndefined(); + expect(err.column).toBeUndefined(); + }); +}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 00000000..0c570cda --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,79 @@ +/** + * Typed, discriminated errors thrown by the engine's `WorkflowYAMLParser` (1.L). They mirror the + * `@relavium/llm` `LlmConfigError` pattern — a base class with a stable `code` discriminant and + * structured, secret-free context, narrowed on `code` and never on `message` + * (docs/standards/error-handling.md). The user-facing fields — `message`, `issues`, `field`, the + * `source` label, and the line/column — name the offending field/node and never carry an authored + * value, a stack trace, or an absolute path. An internal `cause`, where attached, is a non-secret + * diagnostic (a YAML rule, never the source text) kept for logs per error-handling.md; the raw + * ZodError is deliberately NOT attached, as it can carry an authored `received` value. + */ + +export type WorkflowParseErrorCode = 'invalid_yaml' | 'schema_validation'; + +/** One field-named validation problem — the unit the VS Code language server later renders. */ +export interface WorkflowIssue { + /** Human field locator, node-resolved where possible — e.g. ``node `summarize`.model``. */ + readonly field: string; + /** The user-facing message — no secret value, no absolute path, no raw payload/stack. */ + readonly message: string; +} + +/** Base for every parser error — callers narrow on `code`, never on `message`. */ +export abstract class WorkflowParseError extends Error { + abstract readonly code: WorkflowParseErrorCode; + /** A workspace-relative source label, when the caller supplied one (never an absolute path). */ + readonly source?: string; + + protected constructor(message: string, source: string | undefined, cause: unknown) { + super(message, cause === undefined ? undefined : { cause }); + if (source !== undefined) { + this.source = source; + } + } +} + +/** The text is not well-formed YAML — a syntax fault, surfaced before any schema check. */ +export class WorkflowSyntaxError extends WorkflowParseError { + readonly code = 'invalid_yaml'; + /** 1-based line of the fault, when the YAML loader reports a position. */ + readonly line?: number; + /** 1-based column of the fault, when the YAML loader reports a position. */ + readonly column?: number; + + constructor( + message: string, + opts?: { source?: string; line?: number; column?: number; cause?: unknown }, + ) { + super(message, opts?.source, opts?.cause); + this.name = 'WorkflowSyntaxError'; + if (opts?.line !== undefined) { + this.line = opts.line; + } + if (opts?.column !== undefined) { + this.column = opts.column; + } + } +} + +/** The parsed document fails the strict `WorkflowSchema` — one or more field-named issues. */ +export class WorkflowValidationError extends WorkflowParseError { + readonly code = 'schema_validation'; + readonly issues: readonly WorkflowIssue[]; + + constructor(issues: readonly WorkflowIssue[], opts?: { source?: string; cause?: unknown }) { + super(summarize(issues), opts?.source, opts?.cause); + this.name = 'WorkflowValidationError'; + this.issues = issues; + } +} + +function summarize(issues: readonly WorkflowIssue[]): string { + const first = issues[0]; + if (first === undefined) { + return 'workflow validation failed'; + } + const rest = issues.length - 1; + const more = rest > 0 ? ` (and ${rest} more issue${rest === 1 ? '' : 's'})` : ''; + return `${first.field}: ${first.message}${more}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..b682b3b9 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,27 @@ +/** + * `@relavium/core` public surface — the engine. Curated, **not** `export *`: only the engine's + * contract is public. The `WorkflowYAMLParser` (1.L) lands first; the DAG builder (1.M), run loop + * (1.N), checkpoint/resume (1.R), and retry extend this surface as they land. Zero platform-specific + * imports (CLAUDE.md rule 5, ADR-0011) — the whole package runs in Node, the Tauri WebView, the VS + * Code extension host, and Bun alike. + */ + +// WorkflowYAMLParser (1.L) — parse + validate a `.relavium.yaml` string into a typed definition. +export { parseWorkflow } from './parser.js'; +export type { WorkflowDefinition, ParseWorkflowOptions } from './parser.js'; + +// Typed, field-named, secret-free parse/validation errors — narrow on `code`, never on `message`. +export { WorkflowParseError, WorkflowSyntaxError, WorkflowValidationError } from './errors.js'; +export type { WorkflowParseErrorCode, WorkflowIssue } from './errors.js'; + +// Structured, un-evaluated interpolation references — the view the DAG builder (1.M) consumes. +export { parseTemplate, templateReferences } from './interpolation/references.js'; +export type { + TemplateSegment, + InterpolationReference, + ReferenceKind, + PipeFilter, + FilterArg, +} from './interpolation/references.js'; +export { collectReferences } from './interpolation/collect.js'; +export type { ReferenceSite } from './interpolation/collect.js'; diff --git a/packages/core/src/interpolation/collect.ts b/packages/core/src/interpolation/collect.ts new file mode 100644 index 00000000..d951bf46 --- /dev/null +++ b/packages/core/src/interpolation/collect.ts @@ -0,0 +1,75 @@ +/** + * Walk a validated `Workflow` and collect every interpolation-reference site — the structured, + * un-evaluated view the DAG builder (1.M) reads to wire data-dependency edges. Pure: it derives + * from the already-parsed object and evaluates nothing (1.L2 owns evaluation + secret-taint). + * + * Scanned fields are the authored *template* fields (workflow-yaml-spec.md §Context-and-interpolation): + * `context[].value`, string `inputs[].default`, inline `agents[].system_prompt`, and on nodes the + * agent `prompt_template` / `system_prompt_append` and human-gate `assignee` / `message_template`. + * The whole-string JS expression fields (`condition`, `transform`, `merge_fn`) are NOT templates — + * they reference run scope without `{{ }}` and are owned by the sandbox (1.AB), so they are skipped. + */ + +import type { Workflow } from '@relavium/shared'; + +import { parseTemplate, type InterpolationReference, type TemplateSegment } from './references.js'; + +/** One field that carries at least one `{{ … }}` reference. */ +export interface ReferenceSite { + /** A human field locator, e.g. ``node `synthesize-report`.prompt_template``. */ + readonly location: string; + /** The ordered literal/reference segments of the field's value. */ + readonly segments: readonly TemplateSegment[]; + /** Just the references at this site, in order. */ + readonly references: readonly InterpolationReference[]; +} + +export function collectReferences(workflow: Workflow): readonly ReferenceSite[] { + const sites: ReferenceSite[] = []; + const spec = workflow.workflow; + + const visit = (location: string, text: string): void => { + const segments = parseTemplate(text); + const references: InterpolationReference[] = []; + for (const segment of segments) { + if (segment.kind === 'reference') { + references.push(segment.reference); + } + } + if (references.length > 0) { + sites.push({ location, segments, references }); + } + }; + + for (const entry of spec.context ?? []) { + visit(`context \`${entry.key}\`.value`, entry.value); + } + for (const input of spec.inputs ?? []) { + if (typeof input.default === 'string') { + visit(`input \`${input.name}\`.default`, input.default); + } + } + for (const agent of spec.agents ?? []) { + if (!('$ref' in agent)) { + visit(`agent \`${agent.id}\`.system_prompt`, agent.system_prompt); + } + } + for (const node of spec.nodes) { + if (node.type === 'agent') { + if (node.prompt_template !== undefined) { + visit(`node \`${node.id}\`.prompt_template`, node.prompt_template); + } + if (node.system_prompt_append !== undefined) { + visit(`node \`${node.id}\`.system_prompt_append`, node.system_prompt_append); + } + } else if (node.type === 'human_gate') { + if (node.assignee !== undefined) { + visit(`node \`${node.id}\`.assignee`, node.assignee); + } + if (node.message_template !== undefined) { + visit(`node \`${node.id}\`.message_template`, node.message_template); + } + } + } + return sites; +} diff --git a/packages/core/src/interpolation/references.test.ts b/packages/core/src/interpolation/references.test.ts new file mode 100644 index 00000000..53e90db2 --- /dev/null +++ b/packages/core/src/interpolation/references.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseTemplate, + templateReferences, + type InterpolationReference, + type TemplateSegment, +} from './references.js'; + +/** Narrow a segment to its reference, failing the test if it is a literal. */ +function refOf(segment: TemplateSegment | undefined): InterpolationReference { + if (segment === undefined || segment.kind !== 'reference') { + throw new Error('expected a reference segment'); + } + return segment.reference; +} + +describe('parseTemplate', () => { + it('returns a single literal segment when there is no interpolation', () => { + expect(parseTemplate('just plain text')).toEqual([ + { kind: 'literal', text: 'just plain text' }, + ]); + }); + + it('returns no segments for an empty string', () => { + expect(parseTemplate('')).toEqual([]); + }); + + it('parses an `inputs` reference', () => { + expect(parseTemplate('{{inputs.file_path}}')).toEqual([ + { + kind: 'reference', + reference: { + kind: 'inputs', + identifier: 'file_path', + path: '', + filters: [], + raw: '{{inputs.file_path}}', + }, + }, + ]); + }); + + it('parses a `ctx` reference between literal text', () => { + const segments = parseTemplate('Review:\n{{ctx.code_content}}\n'); + expect(segments.map((s) => s.kind)).toEqual(['literal', 'reference', 'literal']); + expect(refOf(segments[1])).toMatchObject({ kind: 'ctx', identifier: 'code_content', path: '' }); + }); + + it('parses a node-output reference (`run.outputs[...]`) with a trailing path', () => { + const ref = refOf(parseTemplate('{{run.outputs["security-scan-node"].score}}')[0]); + expect(ref).toMatchObject({ kind: 'node', identifier: 'security-scan-node', path: '.score' }); + }); + + it('parses a node-output reference with no trailing path', () => { + const ref = refOf(parseTemplate('{{ run.outputs["style-review-node"] }}')[0]); + expect(ref).toMatchObject({ kind: 'node', identifier: 'style-review-node', path: '' }); + }); + + it('parses a single pipe filter with no arguments', () => { + const ref = refOf(parseTemplate('{{run.outputs["n"].issues | length}}')[0]); + expect(ref.path).toBe('.issues'); + expect(ref.filters).toEqual([{ name: 'length', args: [] }]); + }); + + it('parses a filter with a string argument, keeping the literal verbatim', () => { + const ref = refOf(parseTemplate('{{run.outputs["g"].decision | default("not required")}}')[0]); + expect(ref.filters).toEqual([ + { name: 'default', args: [{ type: 'string', value: 'not required' }] }, + ]); + }); + + it('parses multiple filters in order (un-applied)', () => { + const ref = refOf(parseTemplate('{{inputs.payload | json | length}}')[0]); + expect(ref.filters.map((f) => f.name)).toEqual(['json', 'length']); + }); + + it('parses numeric and boolean filter arguments', () => { + const ref = refOf(parseTemplate('{{inputs.x | clamp(0, 10, true)}}')[0]); + expect(ref.filters[0]?.args).toEqual([ + { type: 'number', value: 0 }, + { type: 'number', value: 10 }, + { type: 'boolean', value: true }, + ]); + }); + + it('classifies a `secrets` reference structurally (no taint judgment — that is 1.L2)', () => { + expect(refOf(parseTemplate('{{secrets.github_token}}')[0])).toMatchObject({ + kind: 'secrets', + identifier: 'github_token', + }); + }); + + it('classifies an unrecognized namespace as `unknown`, carrying the raw head', () => { + expect(refOf(parseTemplate('{{ foo.bar }}')[0])).toMatchObject({ + kind: 'unknown', + identifier: 'foo.bar', + path: '', + }); + }); + + it('leaves an unterminated `{{` as literal text (never a malformed reference)', () => { + expect(parseTemplate('value is {{ inputs.x')).toEqual([ + { kind: 'literal', text: 'value is {{ inputs.x' }, + ]); + }); + + it('does not split on a pipe inside a filter string argument', () => { + const ref = refOf(parseTemplate('{{inputs.x | default("a|b")}}')[0]); + expect(ref.filters).toEqual([{ name: 'default', args: [{ type: 'string', value: 'a|b' }] }]); + }); + + it('treats a bareword (non-quoted, non-numeric) filter argument as a string', () => { + const ref = refOf(parseTemplate('{{inputs.x | join(and)}}')[0]); + expect(ref.filters).toEqual([{ name: 'join', args: [{ type: 'string', value: 'and' }] }]); + }); + + it('ignores an empty argument list', () => { + const ref = refOf(parseTemplate('{{inputs.x | trim()}}')[0]); + expect(ref.filters).toEqual([{ name: 'trim', args: [] }]); + }); + + it('does not let a literal `}}` inside a quoted argument truncate the reference', () => { + const segments = parseTemplate('{{inputs.x | default("}}")}} tail'); + expect(segments).toEqual([ + { + kind: 'reference', + reference: { + kind: 'inputs', + identifier: 'x', + path: '', + filters: [{ name: 'default', args: [{ type: 'string', value: '}}' }] }], + raw: '{{inputs.x | default("}}")}}', + }, + }, + { kind: 'literal', text: ' tail' }, + ]); + }); + + it('does not let a literal `}}` inside a bracket key truncate the reference', () => { + const ref = refOf(parseTemplate('{{run.outputs["a}}b"].score}}')[0]); + expect(ref).toMatchObject({ kind: 'node', identifier: 'a}}b', path: '.score' }); + }); + + it('keeps a non-decimal numeric-looking argument a string (no Number() over-acceptance)', () => { + const ref = refOf(parseTemplate('{{inputs.x | f(0x10, 1e3, Infinity)}}')[0]); + expect(ref.filters[0]?.args).toEqual([ + { type: 'string', value: '0x10' }, + { type: 'string', value: '1e3' }, + { type: 'string', value: 'Infinity' }, + ]); + }); + + it('parses a plain decimal argument (incl. leading zeros) as a number', () => { + const ref = refOf(parseTemplate('{{inputs.x | f(007, -2.5)}}')[0]); + expect(ref.filters[0]?.args).toEqual([ + { type: 'number', value: 7 }, + { type: 'number', value: -2.5 }, + ]); + }); + + it('preserves the verbatim raw of each occurrence for round-trip', () => { + const ref = refOf(parseTemplate('x {{ ctx.k }} y')[1]); + expect(ref.raw).toBe('{{ ctx.k }}'); + }); +}); + +describe('templateReferences', () => { + it('returns only the references, in order', () => { + const refs = templateReferences('{{inputs.a}} between {{ctx.b}} and {{secrets.c}}'); + expect(refs.map((r) => r.kind)).toEqual(['inputs', 'ctx', 'secrets']); + }); + + it('returns an empty list when there is no interpolation', () => { + expect(templateReferences('nothing here')).toEqual([]); + }); +}); diff --git a/packages/core/src/interpolation/references.ts b/packages/core/src/interpolation/references.ts new file mode 100644 index 00000000..c7603f9f --- /dev/null +++ b/packages/core/src/interpolation/references.ts @@ -0,0 +1,231 @@ +/** + * Structured, **un-evaluated** interpolation references (1.L). + * + * An authored template field (a prompt, a context value, a gate message) may carry `{{ … }}` + * occurrences. 1.L turns each into a typed reference for the DAG builder (1.M) — it does NOT + * evaluate anything: the run-scope lookup, the pipe-filter registry, the eager-once snapshot, and + * the secret-taint rejection all belong to the runtime resolver (1.L2) and the JS sandbox (1.AB). + * This module is a pure lexer: text in, structured segments out. It reads no files, touches no + * environment, and holds no state. + * + * The four authored namespaces (workflow-yaml-spec.md §Context-and-interpolation): + * - `{{ inputs. }}` → kind `inputs` + * - `{{ ctx. }}` → kind `ctx` + * - `{{ run.outputs[""] }}` → kind `node` (the roadmap's informal `{{ node.output }}`) + * - `{{ secrets. }}` → kind `secrets` + * Anything else is carried as `unknown` (the resolver, not this lexer, judges validity). + */ + +/** Which run-scope namespace a reference reads from. */ +export type ReferenceKind = 'inputs' | 'ctx' | 'node' | 'secrets' | 'unknown'; + +/** A literal argument to a pipe filter (e.g. `default("not required")` → one string arg). */ +export type FilterArg = + | { readonly type: 'string'; readonly value: string } + | { readonly type: 'number'; readonly value: number } + | { readonly type: 'boolean'; readonly value: boolean }; + +/** One pipe filter, parsed but **not** applied (1.L2 owns the filter registry + evaluation). */ +export interface PipeFilter { + readonly name: string; + readonly args: readonly FilterArg[]; +} + +/** A single `{{ … }}` reference, resolved to structure but not evaluated. */ +export interface InterpolationReference { + readonly kind: ReferenceKind; + /** Head identifier: input name / ctx key / node id / secret name; the raw head for `unknown`. */ + readonly identifier: string; + /** The property/index access after the head, verbatim (e.g. `.score`, `.issues`); `''` if none. */ + readonly path: string; + /** Ordered pipe filters, parsed but not applied. */ + readonly filters: readonly PipeFilter[]; + /** The full `{{ … }}` occurrence, verbatim — preserved for round-trip and error messages. */ + readonly raw: string; +} + +/** A template is an ordered list of literal text and reference segments. */ +export type TemplateSegment = + | { readonly kind: 'literal'; readonly text: string } + | { readonly kind: 'reference'; readonly reference: InterpolationReference }; + +const OPEN = '{{'; +const CLOSE = '}}'; + +/** + * Parse a template string into ordered literal/reference segments. A field with no `{{ … }}` yields + * a single literal segment; an unterminated `{{` is left as literal text (the resolver never sees a + * malformed reference). Pure and total — never throws. + */ +export function parseTemplate(text: string): readonly TemplateSegment[] { + const segments: TemplateSegment[] = []; + let pos = 0; + while (pos < text.length) { + const start = text.indexOf(OPEN, pos); + if (start === -1) { + pushLiteral(segments, text.slice(pos)); + break; + } + const end = findClose(text, start + OPEN.length); + if (end === -1) { + // An unterminated `{{` — structurally not a reference; the rest is one literal run. + pushLiteral(segments, text.slice(pos)); + break; + } + pushLiteral(segments, text.slice(pos, start)); + const inner = text.slice(start + OPEN.length, end); + const raw = text.slice(start, end + CLOSE.length); + segments.push({ kind: 'reference', reference: parseReference(inner, raw) }); + pos = end + CLOSE.length; + } + return segments; +} + +/** Just the references in a template, in order — the common case for the DAG builder. */ +export function templateReferences(text: string): readonly InterpolationReference[] { + const refs: InterpolationReference[] = []; + for (const segment of parseTemplate(text)) { + if (segment.kind === 'reference') { + refs.push(segment.reference); + } + } + return refs; +} + +function pushLiteral(segments: TemplateSegment[], text: string): void { + if (text.length > 0) { + segments.push({ kind: 'literal', text }); + } +} + +/** + * Index of the first **top-level** `}}` at or after `from` — skipping any inside quotes or `[]`/`()`, + * so a literal `}}` inside `default("}}")` or `run.outputs["a}}b"]` does not truncate the reference. + * Returns -1 if none (an unterminated `{{`). Mirrors {@link splitTopLevel}'s state machine. + */ +function findClose(text: string, from: number): number { + let quote: string | undefined; + let depth = 0; + for (let i = from; i < text.length - 1; i += 1) { + const ch = text[i]; + if (quote !== undefined) { + if (ch === quote) { + quote = undefined; + } + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '[' || ch === '(') { + depth += 1; + } else if (ch === ']' || ch === ')') { + depth = Math.max(0, depth - 1); + } else if (depth === 0 && ch === '}' && text[i + 1] === '}') { + return i; + } + } + return -1; +} + +function parseReference(inner: string, raw: string): InterpolationReference { + const parts = splitTopLevel(inner, '|'); + const head = (parts[0] ?? '').trim(); + const { kind, identifier, path } = parseHead(head); + const filters = parts.slice(1).map(parseFilter); + return { kind, identifier, path, filters, raw }; +} + +const NODE_OUTPUT = /^run\.outputs\[\s*(['"])([\s\S]*?)\1\s*\]([\s\S]*)$/; +const NAMESPACED = /^(inputs|ctx|secrets)\.([A-Za-z0-9_-]+)([\s\S]*)$/; + +function parseHead(head: string): Pick { + const nodeMatch = NODE_OUTPUT.exec(head); + if (nodeMatch?.[2] !== undefined) { + return { kind: 'node', identifier: nodeMatch[2], path: (nodeMatch[3] ?? '').trim() }; + } + const nsMatch = NAMESPACED.exec(head); + if (nsMatch?.[1] !== undefined && nsMatch[2] !== undefined) { + const kind = nsMatch[1] as 'inputs' | 'ctx' | 'secrets'; + return { kind, identifier: nsMatch[2], path: (nsMatch[3] ?? '').trim() }; + } + return { kind: 'unknown', identifier: head, path: '' }; +} + +const FILTER = /^([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(([\s\S]*)\))?$/; + +function parseFilter(part: string): PipeFilter { + const trimmed = part.trim(); + const match = FILTER.exec(trimmed); + if (match?.[1] === undefined) { + return { name: trimmed, args: [] }; + } + const rawArgs = match[2]; + if (rawArgs === undefined || rawArgs.trim() === '') { + return { name: match[1], args: [] }; + } + const args: FilterArg[] = []; + for (const piece of splitTopLevel(rawArgs, ',')) { + const arg = parseArg(piece); + if (arg !== undefined) { + args.push(arg); + } + } + return { name: match[1], args }; +} + +const QUOTED = /^(['"])([\s\S]*)\1$/; + +function parseArg(piece: string): FilterArg | undefined { + const trimmed = piece.trim(); + if (trimmed === '') { + return undefined; + } + const quoted = QUOTED.exec(trimmed); + if (quoted?.[2] !== undefined) { + return { type: 'string', value: quoted[2] }; + } + if (trimmed === 'true' || trimmed === 'false') { + return { type: 'boolean', value: trimmed === 'true' }; + } + if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) { + return { type: 'number', value: Number(trimmed) }; + } + // A bareword (incl. `0x10`, `1e3`, `Infinity`, leading-dot) — kept verbatim as a string, never + // coerced to a number; 1.L2 owns evaluation, so the lexer must not over-interpret arguments. + return { type: 'string', value: trimmed }; +} + +/** + * Split `s` on top-level occurrences of `delim`, ignoring delimiters inside quotes or `[]`/`()`. + * Keeps `default("a|b")` and `run.outputs["x|y"]` intact when splitting on `|`. + */ +function splitTopLevel(s: string, delim: string): string[] { + const out: string[] = []; + let buf = ''; + let quote: string | undefined; + let depth = 0; + for (const ch of s) { + if (quote !== undefined) { + buf += ch; + if (ch === quote) { + quote = undefined; + } + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + buf += ch; + } else if (ch === '[' || ch === '(') { + depth += 1; + buf += ch; + } else if (ch === ']' || ch === ')') { + depth = Math.max(0, depth - 1); + buf += ch; + } else if (ch === delim && depth === 0) { + out.push(buf); + buf = ''; + } else { + buf += ch; + } + } + out.push(buf); + return out; +} diff --git a/packages/core/src/parser.test.ts b/packages/core/src/parser.test.ts new file mode 100644 index 00000000..bdfa9ea0 --- /dev/null +++ b/packages/core/src/parser.test.ts @@ -0,0 +1,397 @@ +import { stringify as stringifyYaml } from 'yaml'; +import { describe, expect, it } from 'vitest'; + +import { WorkflowSyntaxError, WorkflowValidationError } from './errors.js'; +import { collectReferences } from './interpolation/collect.js'; +import { parseWorkflow } from './parser.js'; + +/** The canonical code-review pipeline (workflow-yaml-spec.md §Complete example), trimmed but whole. */ +const VALID = ` +schema_version: '1.0' +workflow: + id: code-review-pipeline + version: '1.2.0' + name: Code Review Pipeline + metadata: + exported_from_session: sess-abc123 + inputs: + - name: file_path + type: file_path + required: true + - name: reviewer_email + type: string + required: false + default: 'team@example.com' + context: + - key: code_content + value: '{{inputs.file_path | read_file}}' + agents: + - id: security-scanner + name: Security Scanner + model: claude-sonnet-4-6 + provider: anthropic + system_prompt: 'Inspect {{ctx.code_content}} for vulnerabilities.' + retry: + max: 3 + backoff: exponential + fallback_chain: + - model: gpt-5.5 + provider: openai + max_attempts: 2 + nodes: + - id: input + type: input + - id: fan-out + type: parallel + parallel_of: [security-scan-node] + - id: security-scan-node + type: agent + agent_ref: security-scanner + prompt_template: 'Review this code: {{ctx.code_content}}' + - id: severity-gate + type: condition + expression: 'run.outputs["security-scan-node"].score < 7' + branches: + - when: true + target_node: human-approval + - when: false + target_node: synthesize-report + default: synthesize-report + - id: human-approval + type: human_gate + gate_type: approval + assignee: '{{inputs.reviewer_email}}' + message_template: 'Score {{run.outputs["security-scan-node"].score}}/10 for {{inputs.file_path}}.' + - id: synthesize-report + type: agent + agent_ref: security-scanner + prompt_template: 'Findings: {{run.outputs["security-scan-node"] | json}}; gate {{run.outputs["human-approval"].decision | default("not required")}}' + - id: output + type: output + output_format: markdown + edges: + - { from: input, to: fan-out } + - { from: fan-out, to: security-scan-node } + - { from: security-scan-node, to: severity-gate } + - { from: 'severity-gate:true', to: human-approval } + - { from: 'severity-gate:false', to: synthesize-report } + - { from: human-approval, to: synthesize-report } + - { from: synthesize-report, to: output } +`; + +describe('parseWorkflow — valid', () => { + it('parses the canonical pipeline into a typed WorkflowDefinition', () => { + const wf = parseWorkflow(VALID); + expect(wf.schema_version).toBe('1.0'); + expect(wf.workflow.id).toBe('code-review-pipeline'); + expect(wf.workflow.nodes).toHaveLength(7); + expect(wf.workflow.edges).toHaveLength(7); + }); + + it('preserves every config block, including metadata (ADR-0026) and agent retry/fallback', () => { + const wf = parseWorkflow(VALID); + expect(wf.workflow.metadata).toEqual({ exported_from_session: 'sess-abc123' }); + const agent = wf.workflow.agents?.[0]; + expect(agent && 'retry' in agent && agent.retry).toEqual({ max: 3, backoff: 'exponential' }); + expect(agent && 'fallback_chain' in agent && agent.fallback_chain).toEqual([ + { model: 'gpt-5.5', provider: 'openai', max_attempts: 2 }, + ]); + }); + + it('round-trips: parse → serialize → parse is stable (no config block dropped)', () => { + const once = parseWorkflow(VALID); + const twice = parseWorkflow(stringifyYaml(once)); + expect(twice).toEqual(once); + }); + + it('is deterministic — the same text parses to a deep-equal object', () => { + expect(parseWorkflow(VALID)).toEqual(parseWorkflow(VALID)); + }); + + it('parses transform + merge nodes (custom merge_fn) and round-trips them', () => { + const yaml = `schema_version: '1.0' +workflow: + id: w + nodes: + - id: a + type: input + - id: t + type: transform + transform: 'run.outputs["a"]' + - id: m + type: merge + merge_strategy: custom + merge_fn: 'inputs[0]' + - id: out + type: output + edges: + - { from: a, to: t } + - { from: t, to: m } + - { from: m, to: out }`; + const wf = parseWorkflow(yaml); + expect(wf.workflow.nodes.map((n) => n.type)).toEqual(['input', 'transform', 'merge', 'output']); + expect(parseWorkflow(stringifyYaml(wf))).toEqual(wf); // round-trip preserves the new blocks + }); +}); + +describe('collectReferences', () => { + it('surfaces structured, un-evaluated reference sites for the DAG builder', () => { + const sites = collectReferences(parseWorkflow(VALID)); + const locations = sites.map((s) => s.location); + expect(locations).toContain('context `code_content`.value'); + expect(locations).toContain('node `synthesize-report`.prompt_template'); + + const synth = sites.find((s) => s.location === 'node `synthesize-report`.prompt_template'); + const nodeRefs = (synth?.references ?? []) + .filter((r) => r.kind === 'node') + .map((r) => r.identifier); + expect(nodeRefs).toEqual(['security-scan-node', 'human-approval']); + }); +}); + +describe('parseWorkflow — malformed (each fails with a field-named, secret-free error)', () => { + const doc = (workflowBody: string): string => `schema_version: '1.0'\nworkflow:\n${workflowBody}`; + + it('names the node + field when a required field is missing', () => { + const err = expectValidationError( + doc(` id: w\n nodes:\n - id: summarize\n type: agent\n edges: []`), + ); + expect(err.issues[0]?.field).toBe('node `summarize`.agent_ref'); + expect(err.issues[0]?.message).toMatch(/missing/); + }); + + it('rejects an authored `on_error` edge (a reserved, non-authorable edge kind) as an unknown key', () => { + const err = expectValidationError( + doc( + ` id: w\n nodes:\n - id: a\n type: input\n - id: b\n type: output\n edges:\n - { from: a, to: b, on_error: skip }`, + ), + ); + expect(err.issues[0]).toEqual({ field: 'edge #0', message: 'unknown key: `on_error`' }); + }); + + it('rejects a duplicate node id', () => { + const err = expectValidationError( + doc( + ` id: w\n nodes:\n - id: dup\n type: input\n - id: dup\n type: output\n edges: []`, + ), + ); + expect(err.message.toLowerCase()).toMatch(/duplicate/); + }); + + it('rejects an unknown node `type` with the valid options', () => { + const err = expectValidationError( + doc(` id: w\n nodes:\n - id: x\n type: looop\n edges: []`), + ); + expect(err.issues[0]?.field).toBe('node `x`.type'); + expect(err.issues[0]?.message).toMatch(/expected one of: .*input/); + }); + + it('rejects an empty node list', () => { + const err = expectValidationError(doc(` id: w\n nodes: []\n edges: []`)); + expect(err.issues[0]?.field).toBe('nodes'); + }); + + it('reports a YAML syntax fault with a line, not a schema error', () => { + let thrown: unknown; + try { + parseWorkflow(`schema_version: '1.0'\nworkflow:\n id: "unterminated`); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBeInstanceOf(WorkflowSyntaxError); + const err = thrown as WorkflowSyntaxError; + expect(err.code).toBe('invalid_yaml'); + expect(typeof err.line).toBe('number'); + }); + + it('never echoes an authored value — a secret in a mistyped field does not leak', () => { + const secret = 'sk-live-DO-NOT-LEAK-0123456789'; + const err = expectValidationError( + doc( + ` id: w\n timeout_ms: '${secret}'\n nodes:\n - id: n\n type: input\n edges: []`, + ), + ); + expect(err.message).not.toContain(secret); + expect(JSON.stringify(err.issues)).not.toContain(secret); + expect(err.issues[0]?.field).toBe('timeout_ms'); + }); + + it('does not echo an invalid (non-identifier) id into the field locator', () => { + const secretId = 'SK-LIVE-DO-NOT-LEAK-9876543210'; // uppercase → fails kebab-id validation + const err = expectValidationError( + doc(` id: w\n nodes:\n - id: ${secretId}\n type: input\n edges: []`), + ); + expect(err.message).not.toContain(secretId); + expect(JSON.stringify(err.issues)).not.toContain(secretId); + expect(err.issues[0]?.field).toBe('node #0.id'); + }); + + it('does not attach the raw ZodError as cause (no authored value reachable via cause)', () => { + const err = expectValidationError(doc(` id: w\n nodes: []\n edges: []`)); + expect(err.cause).toBeUndefined(); + }); +}); + +describe('parseWorkflow — hardened decode (ADR-0035)', () => { + it('rejects anchors/aliases (no alias-bomb expansion — maxAliasCount 0)', () => { + let thrown: unknown; + try { + parseWorkflow( + `schema_version: '1.0'\nworkflow: &w\n id: w\n self: *w\n nodes:\n - id: n\n type: input\n edges: []`, + ); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBeInstanceOf(WorkflowSyntaxError); + expect((thrown as WorkflowSyntaxError).message).toMatch(/anchors and aliases/); + }); + + it('rejects a source over the parse-size limit before parsing', () => { + let thrown: unknown; + try { + parseWorkflow('a'.repeat(2 * 1024 * 1024 + 1)); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBeInstanceOf(WorkflowSyntaxError); + expect((thrown as WorkflowSyntaxError).code).toBe('invalid_yaml'); + }); + + it('keeps a date-like scalar a string (core schema → no Date/Buffer; deterministic across surfaces)', () => { + const wf = parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: w\n metadata:\n created: 2020-01-02\n nodes:\n - id: n\n type: input\n edges: []`, + ); + expect(wf.workflow.metadata?.['created']).toBe('2020-01-02'); + }); +}); + +describe('parseWorkflow — purity + diagnostics', () => { + it('attaches the caller-supplied workspace-relative source label to the error', () => { + const err = expectValidationError( + `schema_version: '1.0'\nworkflow:\n id: w\n nodes: []\n edges: []`, + { + source: '.relavium/broken.relavium.yaml', + }, + ); + expect(err.source).toBe('.relavium/broken.relavium.yaml'); + }); +}); + +describe('parseWorkflow — diagnostic field naming (issue-mapper coverage)', () => { + const doc = (body: string): string => `schema_version: '1.0'\nworkflow:\n${body}`; + + it('names an input + field for an invalid enum value', () => { + const err = expectValidationError( + doc( + ` id: w\n inputs:\n - name: count\n type: bogus\n nodes:\n - id: n\n type: input\n edges: []`, + ), + ); + expect(err.issues[0]?.field).toBe('input `count`.type'); + expect(err.issues[0]?.message).toMatch(/expected one of:/); + }); + + it('falls back to an index when a context entry is missing its key', () => { + const err = expectValidationError( + doc( + ` id: w\n context:\n - value: hi\n nodes:\n - id: n\n type: input\n edges: []`, + ), + ); + expect(err.issues.some((i) => i.field.startsWith('context #0'))).toBe(true); + }); + + it('falls back to an index when an agent entry is malformed', () => { + const err = expectValidationError( + doc(` id: w\n agents:\n - {}\n nodes:\n - id: n\n type: input\n edges: []`), + ); + expect(err.issues.some((i) => i.field === 'agent #0')).toBe(true); + }); + + it('falls back to an index when a node is missing its id', () => { + const err = expectValidationError(doc(` id: w\n nodes:\n - type: input\n edges: []`)); + expect(err.issues.some((i) => i.field.startsWith('node #0'))).toBe(true); + }); + + it('lists multiple unknown keys (plural)', () => { + const err = expectValidationError( + doc( + ` id: w\n nodes:\n - id: a\n type: input\n - id: b\n type: output\n edges:\n - { from: a, to: b, foo: 1, bar: 2 }`, + ), + ); + expect(err.issues[0]?.message).toMatch(/unknown keys: /); + }); + + it('names a nested collection inside a node', () => { + const err = expectValidationError( + doc( + ` id: w\n nodes:\n - id: gate\n type: condition\n expression: 'true'\n branches:\n - when: true\n edges: []`, + ), + ); + expect(err.issues.some((i) => i.field.includes('branches['))).toBe(true); + }); + + it('names a top-level field for a wrong schema_version (no `workflow.` prefix)', () => { + const err = expectValidationError( + `schema_version: '2.0'\nworkflow:\n id: w\n nodes:\n - id: n\n type: input\n edges: []`, + ); + expect(err.issues.some((i) => i.field === 'schema_version')).toBe(true); + }); + + it('handles a non-object workflow body without crashing', () => { + const err = expectValidationError(`schema_version: '1.0'\nworkflow: not-an-object`); + expect(err.issues[0]?.field).toBe('workflow'); + }); + + it('carries the source label on a YAML syntax error', () => { + let thrown: unknown; + try { + parseWorkflow(`workflow:\n id: "x`, { source: 'f.yaml' }); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBeInstanceOf(WorkflowSyntaxError); + expect((thrown as WorkflowSyntaxError).source).toBe('f.yaml'); + }); + + it('carries the source label on an over-size error', () => { + let thrown: unknown; + try { + parseWorkflow('a'.repeat(2 * 1024 * 1024 + 1), { source: 'big.yaml' }); + } catch (caught) { + thrown = caught; + } + expect((thrown as WorkflowSyntaxError).source).toBe('big.yaml'); + }); +}); + +describe('collectReferences — field coverage', () => { + it('skips a `$ref` agent and scans system_prompt_append; leaves whole-string fields alone', () => { + const wf = parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: w\n agents:\n - { $ref: ./other.agent.yaml }\n nodes:\n - id: a\n type: agent\n agent_ref: x\n system_prompt_append: 'Focus on {{ctx.area}}'\n - id: g\n type: human_gate\n gate_type: review\n edges: []`, + ); + const sites = collectReferences(wf); + expect(sites.map((s) => s.location)).toEqual(['node `a`.system_prompt_append']); + expect(sites[0]?.references[0]).toMatchObject({ kind: 'ctx', identifier: 'area' }); + }); + + it('returns no sites when nothing carries interpolation', () => { + const wf = parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: w\n nodes:\n - id: n\n type: input\n edges: []`, + ); + expect(collectReferences(wf)).toEqual([]); + }); +}); + +/** Run `parseWorkflow`, asserting it throws a `WorkflowValidationError`, and return it. */ +function expectValidationError( + yamlText: string, + opts?: { source?: string }, +): WorkflowValidationError { + let thrown: unknown; + try { + parseWorkflow(yamlText, opts); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBeInstanceOf(WorkflowValidationError); + return thrown as WorkflowValidationError; +} diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts new file mode 100644 index 00000000..526371e5 --- /dev/null +++ b/packages/core/src/parser.ts @@ -0,0 +1,207 @@ +/** + * `WorkflowYAMLParser` (1.L) — the engine's entry point. Loads a `.relavium.yaml` **string** and + * validates it against the strict `@relavium/shared` `WorkflowSchema` (ADR-0023), producing a typed + * `WorkflowDefinition` or a typed, field-named, secret-free error. + * + * Pure by contract: it takes text (never a path), reads no filesystem, touches no environment, and + * holds no state — the host surface (CLI / desktop / VS Code) reads the file and passes the string + * plus an optional workspace-relative label in. Node-existence, `$ref`/`agent_ref` resolution, handle + * resolution, and the cycle check are the DAG builder's job (1.M) on the returned object; interpolation + * EVALUATION and secret-taint are the resolver's job (1.L2). 1.L is shape-only. + */ + +import { LineCounter, parse as parseYaml, YAMLParseError } from 'yaml'; +import type { ZodIssue } from 'zod'; + +import { WorkflowSchema, type Workflow } from '@relavium/shared'; + +import { WorkflowSyntaxError, WorkflowValidationError, type WorkflowIssue } from './errors.js'; + +/** The validated workflow document — `@relavium/shared`'s `Workflow`, under a parser-local alias. */ +export type WorkflowDefinition = Workflow; + +export interface ParseWorkflowOptions { + /** + * A workspace-relative label for the source, used ONLY in error messages — never resolved or read + * from disk. The host reads the file and passes the text + this label; the parser stays pure. + */ + readonly source?: string; +} + +/** + * Parse + validate a workflow YAML string. Throws {@link WorkflowSyntaxError} on a YAML fault or + * {@link WorkflowValidationError} (field-named, secret-free) on a schema failure — an invalid file + * never yields a `WorkflowDefinition`, so a run never starts on one. + */ +/** A pre-parse character cap (≈ bytes for authored ASCII) — workflow files are small; a DoS guard. */ +const MAX_SOURCE_CHARS = 2 * 1024 * 1024; // 2 MiB + +export function parseWorkflow(yamlText: string, opts?: ParseWorkflowOptions): WorkflowDefinition { + const source = opts?.source; + + if (yamlText.length > MAX_SOURCE_CHARS) { + throw new WorkflowSyntaxError( + `the source exceeds the ${MAX_SOURCE_CHARS}-character parse limit`, + source !== undefined ? { source } : undefined, + ); + } + + const lineCounter = new LineCounter(); + let raw: unknown; + try { + raw = parseYaml(yamlText, { + // The hardened, deterministic, cross-platform profile (ADR-0035). The decode produces only + // plain JSON-like data on every surface; strict Zod then enforces the contract. + version: '1.2', + schema: 'core', // YAML 1.2 core only — `!!timestamp`/`!!binary` never become a Date/Buffer + resolveKnownTags: false, // an unknown `!!`-tag stays a string, never a platform object + merge: false, // no YAML-1.1 `<<` merge keys + uniqueKeys: true, // a duplicate map key is an error + stringKeys: true, // complex/non-string keys are rejected (a deterministic object shape) + maxAliasCount: 0, // anchors/aliases are not part of the authored contract — no alias-bomb expansion + prettyErrors: false, // no source snippet in the message (secret-free); line/col via the LineCounter + logLevel: 'error', // no `console.warn` — the parser stays a pure function (no I/O side effect) + lineCounter, + }); + } catch (err) { + throw syntaxErrorFrom(err, source, lineCounter); + } + + const result = WorkflowSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues.map((issue) => describeIssue(issue, raw)); + // No `cause`: the raw ZodError can carry an authored `received` value (an enum/literal/discriminator) + // and `cause` is publicly reachable — the curated, secret-free `issues` are the diagnostic surface. + throw new WorkflowValidationError(issues, source !== undefined ? { source } : undefined); + } + return result.data; +} + +/** Normalize ANY parse-stage throw (a YAML fault, an anchor/alias `ReferenceError`, …) to a typed error. */ +function syntaxErrorFrom( + err: unknown, + source: string | undefined, + lineCounter: LineCounter, +): WorkflowSyntaxError { + if (err instanceof YAMLParseError) { + // With `prettyErrors: false` the message is the rule alone (no code-frame, no authored content); + // the position comes from the LineCounter, so the source text is never carried into the error. + const pos = lineCounter.linePos(err.pos[0]); + return new WorkflowSyntaxError(err.message, { + ...(source !== undefined ? { source } : {}), + line: pos.line, + column: pos.col, + cause: err, + }); + } + if (err instanceof Error && /alias/i.test(err.message)) { + // `maxAliasCount: 0` makes the resolver throw a plain `ReferenceError` (no position) on any + // anchor/alias use — surface a clear, source-free message rather than the generic fallback. + return new WorkflowSyntaxError('anchors and aliases are not supported', { + ...(source !== undefined ? { source } : {}), + cause: err, + }); + } + return new WorkflowSyntaxError('the file is not valid YAML', { + ...(source !== undefined ? { source } : {}), + cause: err, + }); +} + +// --- Zod issue → field-named, secret-free WorkflowIssue -------------------------------------- + +function describeIssue(issue: ZodIssue, root: unknown): WorkflowIssue { + return { field: locate(issue.path, root), message: messageFor(issue) }; +} + +/** + * Turn a Zod issue path into a human field locator, resolving collection indices to authored names + * (`nodes[3]` → ``node `summarize` ``) by reading the parsed value. The `workflow:` wrapper segment is + * dropped. Never echoes a value — only field/collection names and ids, which are not secrets. + */ +function locate(path: ReadonlyArray, root: unknown): string { + const spec = asRecord(asRecord(root)?.['workflow']); + const tokens: string[] = []; + let i = path[0] === 'workflow' ? 1 : 0; + for (; i < path.length; i += 1) { + const seg = path[i]; + const next = path[i + 1]; + if (typeof seg === 'string' && typeof next === 'number') { + tokens.push(itemLabel(spec, seg, next)); + i += 1; // consume the index segment too + } else if (typeof seg === 'number') { + tokens.push(`[${seg}]`); + } else if (seg !== undefined) { + tokens.push(seg); + } + } + return tokens.length === 0 ? 'workflow' : tokens.join('.'); +} + +/** + * A well-formed identifier (the shape of a valid id/name/key) — only such a value is echoed into a + * field locator, so an INVALID authored value (e.g. an id that failed kebab validation, which may be + * arbitrary text or a misplaced secret) is never reflected back; it falls back to a positional `#n`. + */ +const SAFE_LABEL = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/; + +function itemLabel( + spec: Record | undefined, + collection: string, + index: number, +): string { + const item = asRecord(asArray(spec?.[collection])?.[index]); + const named = (key: string, prefix: string): string => { + const value = item?.[key]; + return typeof value === 'string' && value.length <= 64 && SAFE_LABEL.test(value) + ? `${prefix} \`${value}\`` + : `${prefix} #${index}`; + }; + switch (collection) { + case 'nodes': + return named('id', 'node'); + case 'agents': + return named('id', 'agent'); + case 'inputs': + return named('name', 'input'); + case 'context': + return named('key', 'context'); + case 'edges': + return `edge #${index}`; + default: + return `${collection}[${index}]`; + } +} + +/** + * A user-facing message for a Zod issue. Deliberately code-derived (type names, key names, enum + * options) — it never echoes an authored *value*, so a secret in a credential field can never leak. + */ +function messageFor(issue: ZodIssue): string { + switch (issue.code) { + case 'invalid_type': + return issue.received === 'undefined' + ? `missing — expected ${issue.expected}` + : `expected ${issue.expected}, received ${issue.received}`; + case 'unrecognized_keys': + return `unknown key${issue.keys.length === 1 ? '' : 's'}: ${issue.keys + .map((key) => `\`${key}\``) + .join(', ')}`; + case 'invalid_enum_value': + return `invalid value — expected one of: ${issue.options.map((o) => String(o)).join(', ')}`; + case 'invalid_union_discriminator': + return `expected one of: ${issue.options.map((o) => String(o)).join(', ')}`; + default: + return issue.message; + } +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asArray(value: unknown): readonly unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 00000000..b19acb81 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist", + "incremental": true, + "declarationMap": false, + "sourceMap": false + }, + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..1bce02c5 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + // Typecheck-only config: no emit, so rootDir/outDir live in tsconfig.build.json. + // `types: []` is the platform-type boundary — the engine has ZERO platform-specific + // imports (CLAUDE.md rule 5, ADR-0011), so a stray `process`/`Buffer`/`node:*` is a TS + // error. The whole package is the platform-free zone (unlike `@relavium/llm`, which needs + // a separate tsconfig.seam.json to fence its Node-typed adapter zone); this single config + // suffices. The YAML loader (`yaml`, ADR-0035) is pure-JS, so it needs no ambient types. + "noEmit": true, + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 348584e2..0703fdbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ catalogs: vitest: specifier: ^3.0.0 version: 3.2.6 + yaml: + specifier: ^2.9.0 + version: 2.9.0 zod: specifier: ^3.23.0 version: 3.25.76 @@ -73,7 +76,7 @@ importers: version: 9.39.4 '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(tsx@4.22.4)) + version: 3.2.6(vitest@3.2.6(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0)) eslint: specifier: 'catalog:' version: 9.39.4 @@ -94,7 +97,29 @@ importers: version: 8.60.1(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: 'catalog:' - version: 3.2.6(@types/node@22.19.19)(tsx@4.22.4) + version: 3.2.6(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0) + + packages/core: + dependencies: + '@relavium/shared': + specifier: workspace:* + version: link:../shared + yaml: + specifier: 'catalog:' + version: 2.9.0 + zod: + specifier: 'catalog:' + version: 3.25.76 + devDependencies: + eslint: + specifier: 'catalog:' + version: 9.39.4 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 3.2.6(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0) packages/db: dependencies: @@ -125,7 +150,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 3.2.6(@types/node@20.19.41)(tsx@4.22.4) + version: 3.2.6(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0) packages/llm: dependencies: @@ -159,7 +184,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 3.2.6(@types/node@20.19.41)(tsx@4.22.4) + version: 3.2.6(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0) packages/shared: dependencies: @@ -175,7 +200,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 3.2.6(@types/node@22.19.19)(tsx@4.22.4) + version: 3.2.6(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0) packages: @@ -2261,6 +2286,11 @@ packages: utf-8-validate: optional: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2945,7 +2975,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@22.19.19)(tsx@4.22.4))': + '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -2960,7 +2990,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/node@22.19.19)(tsx@4.22.4) + vitest: 3.2.6(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -2972,21 +3002,21 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@20.19.41)(tsx@4.22.4))': + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@20.19.41)(tsx@4.22.4) + vite: 7.3.5(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0) - '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@22.19.19)(tsx@4.22.4))': + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@22.19.19)(tsx@4.22.4) + vite: 7.3.5(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@3.2.6': dependencies: @@ -3957,13 +3987,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@3.2.4(@types/node@20.19.41)(tsx@4.22.4): + vite-node@3.2.4(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.5(@types/node@20.19.41)(tsx@4.22.4) + vite: 7.3.5(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -3978,13 +4008,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@22.19.19)(tsx@4.22.4): + vite-node@3.2.4(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.5(@types/node@22.19.19)(tsx@4.22.4) + vite: 7.3.5(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -3999,7 +4029,7 @@ snapshots: - tsx - yaml - vite@7.3.5(@types/node@20.19.41)(tsx@4.22.4): + vite@7.3.5(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -4011,8 +4041,9 @@ snapshots: '@types/node': 20.19.41 fsevents: 2.3.3 tsx: 4.22.4 + yaml: 2.9.0 - vite@7.3.5(@types/node@22.19.19)(tsx@4.22.4): + vite@7.3.5(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -4024,12 +4055,13 @@ snapshots: '@types/node': 22.19.19 fsevents: 2.3.3 tsx: 4.22.4 + yaml: 2.9.0 - vitest@3.2.6(@types/node@20.19.41)(tsx@4.22.4): + vitest@3.2.6(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@20.19.41)(tsx@4.22.4)) + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -4047,8 +4079,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.5(@types/node@20.19.41)(tsx@4.22.4) - vite-node: 3.2.4(@types/node@20.19.41)(tsx@4.22.4) + vite: 7.3.5(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@20.19.41)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.41 @@ -4066,11 +4098,11 @@ snapshots: - tsx - yaml - vitest@3.2.6(@types/node@22.19.19)(tsx@4.22.4): + vitest@3.2.6(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@22.19.19)(tsx@4.22.4)) + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -4088,8 +4120,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.5(@types/node@22.19.19)(tsx@4.22.4) - vite-node: 3.2.4(@types/node@22.19.19)(tsx@4.22.4) + vite: 7.3.5(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.19.19)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 @@ -4136,6 +4168,8 @@ snapshots: ws@8.21.0: {} + yaml@2.9.0: {} + yocto-queue@0.1.0: {} zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dec51d92..57dcf9e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,6 +27,9 @@ catalog: drizzle-kit: ^0.31.10 # Runtime zod: ^3.23.0 + # The engine's YAML loader — pure-JS, confined to @relavium/core (ADR-0035). Decodes an + # authored `.relavium.yaml` to a plain JS object that the strict WorkflowSchema validates. + yaml: ^2.9.0 # @relavium/db persistence stack — Drizzle ORM (ADR-0005) over better-sqlite3, # the pinned Node-side SQLite driver (ADR-0021). drizzle-orm: ^0.45.2 diff --git a/tools/engine-deps/check.mjs b/tools/engine-deps/check.mjs index 87d45559..4db7e32a 100644 --- a/tools/engine-deps/check.mjs +++ b/tools/engine-deps/check.mjs @@ -34,13 +34,14 @@ const ENGINE_ALLOWLISTS = { // The seam package: the contract + Zod + the official provider SDKs the adapters wrap // (ADR-0011 — SDK types stay confined to packages/llm/src/adapters/*). 'packages/llm': ['@relavium/shared', 'zod', '@anthropic-ai/sdk', 'openai', '@google/genai'], - // The engine: Relavium packages only, plus the ADR-0027 sandbox runtime once the 1.AB - // perf spike pins the package (added here, with the catalog pin, in that change). + // The engine: Relavium packages + Zod + the pure-JS `yaml` loader (ADR-0035, the YAML→object + // decode for the 1.L parser), plus the ADR-0027 sandbox runtime once the 1.AB perf spike pins + // the package (added here, with the catalog pin, in that change). // @relavium/db is deliberately ABSENT: the engine runs in the Tauri WebView with zero // platform imports (CLAUDE.md rule 5), and @relavium/db pulls the native better-sqlite3 // runtime. Core may use its TYPES via a devDependency (the Checkpointer interface / // Drizzle schema types); the store itself is injected by the host surface (1.R). - 'packages/core': ['@relavium/shared', '@relavium/llm', 'zod'], + 'packages/core': ['@relavium/shared', '@relavium/llm', 'zod', 'yaml'], }; let failed = false; diff --git a/vitest.config.ts b/vitest.config.ts index 06a5ee50..65c1f567 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ // package runs to the engine floor. Tracked in deferred-tasks.md (coverage-in-CI item). thresholds: { 'packages/llm/src/**/*.ts': { lines: 90, branches: 90 }, - // 'packages/core/src/**/*.ts': { lines: 90, branches: 90 }, // enable once core lands (1.L+) + 'packages/core/src/**/*.ts': { lines: 90, branches: 90 }, // engine floor — core landed at 1.L }, }, }, From ffc5978cd5ebc35c3056bd8510d9549d7371283b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 12 Jun 2026 01:41:28 +0300 Subject: [PATCH 4/7] fix(core): address post-1.L review findings (diagnostics, lexer, guard hygiene) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six targeted fixes from the code-review pass on the 1.L WorkflowYAMLParser: * parser: guard err.pos[0]===-1 sentinel — lineCounter.linePos(-1) returned a nonsensical {line:0,col:-1}; now the field is omitted when the yaml library has no position for the fault. * parser: fix orphaned JSDoc — MAX_SOURCE_CHARS sat between the parseWorkflow docblock and its function; swap order so IDE hover attaches correctly. * parser: harden messageFor() — add explicit `case 'custom':` (with an invariant comment explaining all @relavium/shared superRefines emit structural-only messages) and change the `default` branch to return 'invalid value' so an unknown Zod code can never accidentally echo an authored payload. * tools: remove @relavium/llm from the packages/core engine-deps allowlist — it is not declared in packages/core/package.json yet; pre-adding it defeats the guard whose purpose is to force a co-located ADR + allowlist edit when the dep is actually introduced. * interpolation/collect: document why the SAFE_LABEL guard from parser.ts is not needed here (collectReferences accepts a fully schema-validated Workflow); pin a TODO(1.M) comment for the context/run.outputs semantic constraint. * interpolation/references: fix escape-sequence handling in findClose and splitTopLevel — a `\"` or `\'` inside a quoted filter argument incorrectly closed the quote state, breaking templates like `default("say \"hi\"")`. Tests: 66 pass (3 new for escape sequences, 1 custom-code-path invariant pin, 1 context/run.outputs 1.M known-gap pin). Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/interpolation/collect.ts | 14 ++++++++ .../core/src/interpolation/references.test.ts | 23 ++++++++++++ packages/core/src/interpolation/references.ts | 19 ++++++++-- packages/core/src/parser.test.ts | 36 +++++++++++++++++++ packages/core/src/parser.ts | 30 ++++++++++++---- tools/engine-deps/check.mjs | 6 +++- 6 files changed, 118 insertions(+), 10 deletions(-) diff --git a/packages/core/src/interpolation/collect.ts b/packages/core/src/interpolation/collect.ts index d951bf46..e8c8e72d 100644 --- a/packages/core/src/interpolation/collect.ts +++ b/packages/core/src/interpolation/collect.ts @@ -24,6 +24,13 @@ export interface ReferenceSite { readonly references: readonly InterpolationReference[]; } +// Security note: the `location` strings below embed node.id, agent.id, input.name, and +// context entry.key directly, without applying the SAFE_LABEL regex from parser.ts. This is +// intentional and safe: `collectReferences` accepts a `Workflow` — the output of `parseWorkflow` +// — which has already been fully validated by `WorkflowSchema`. Every id/name/key has been +// checked against the kebab-id regex (or at minimum `nonEmptyString`) by the Zod schema before +// reaching this function. A raw, unvalidated string can never appear here. +// If the schema ever relaxes those constraints, add a SAFE_LABEL guard here in the same change. export function collectReferences(workflow: Workflow): readonly ReferenceSite[] { const sites: ReferenceSite[] = []; const spec = workflow.workflow; @@ -41,6 +48,13 @@ export function collectReferences(workflow: Workflow): readonly ReferenceSite[] } }; + // TODO(1.M): workflow-yaml-spec.md §Context-and-interpolation forbids `{{run.outputs[...]}}` in + // context values (context is eagerly evaluated before the run; node outputs are unavailable). + // Enforcing this here would require importing parseTemplate, coupling the collector to a semantic + // constraint that the DAG builder (1.M) is better placed to enforce (it wires data-dependency + // edges and can produce a richer error). Until then, a context entry that references a node + // output is collected with kind:'node' and will be caught by the DAG builder when it finds no + // satisfying edge for it. See the pinned test in parser.test.ts ("permits a context value …"). for (const entry of spec.context ?? []) { visit(`context \`${entry.key}\`.value`, entry.value); } diff --git a/packages/core/src/interpolation/references.test.ts b/packages/core/src/interpolation/references.test.ts index 53e90db2..210e2949 100644 --- a/packages/core/src/interpolation/references.test.ts +++ b/packages/core/src/interpolation/references.test.ts @@ -142,6 +142,29 @@ describe('parseTemplate', () => { expect(ref).toMatchObject({ kind: 'node', identifier: 'a}}b', path: '.score' }); }); + it('handles an escaped quote inside a filter string argument (does not close the quote early)', () => { + // `default("say \"hi\"")` — the `\"` must NOT close the string, so the argument is `say "hi"`. + const ref = refOf(parseTemplate('{{inputs.x | default("say \\"hi\\"")}}')[0]); + expect(ref.filters).toEqual([ + { name: 'default', args: [{ type: 'string', value: 'say \\"hi\\"' }] }, + ]); + }); + + it('handles an escaped quote inside a filter string argument (single-quote variant)', () => { + const ref = refOf(parseTemplate("{{inputs.x | default('it\\'s fine')}}")[0]); + expect(ref.filters).toEqual([ + { name: 'default', args: [{ type: 'string', value: "it\\'s fine" }] }, + ]); + }); + + it('does not let an escaped quote before `}}` terminate the reference', () => { + // `{{ inputs.x | default("a\\\"") }}` — the `\"` is escaped, the `"` that follows closes the + // string; the first `}}` after that closes the reference. + const segments = parseTemplate('{{inputs.x | default("val")}} tail'); + expect(segments[0]?.kind).toBe('reference'); + expect(segments[1]).toEqual({ kind: 'literal', text: ' tail' }); + }); + it('keeps a non-decimal numeric-looking argument a string (no Number() over-acceptance)', () => { const ref = refOf(parseTemplate('{{inputs.x | f(0x10, 1e3, Infinity)}}')[0]); expect(ref.filters[0]?.args).toEqual([ diff --git a/packages/core/src/interpolation/references.ts b/packages/core/src/interpolation/references.ts index c7603f9f..6620f418 100644 --- a/packages/core/src/interpolation/references.ts +++ b/packages/core/src/interpolation/references.ts @@ -106,10 +106,17 @@ function pushLiteral(segments: TemplateSegment[], text: string): void { function findClose(text: string, from: number): number { let quote: string | undefined; let depth = 0; + let escaped = false; for (let i = from; i < text.length - 1; i += 1) { const ch = text[i]; + if (escaped) { + escaped = false; + continue; + } if (quote !== undefined) { - if (ch === quote) { + if (ch === '\\') { + escaped = true; + } else if (ch === quote) { quote = undefined; } } else if (ch === '"' || ch === "'") { @@ -202,10 +209,18 @@ function splitTopLevel(s: string, delim: string): string[] { let buf = ''; let quote: string | undefined; let depth = 0; + let escaped = false; for (const ch of s) { + if (escaped) { + buf += ch; + escaped = false; + continue; + } if (quote !== undefined) { buf += ch; - if (ch === quote) { + if (ch === '\\') { + escaped = true; + } else if (ch === quote) { quote = undefined; } continue; diff --git a/packages/core/src/parser.test.ts b/packages/core/src/parser.test.ts index bdfa9ea0..c965fa34 100644 --- a/packages/core/src/parser.test.ts +++ b/packages/core/src/parser.test.ts @@ -178,6 +178,26 @@ describe('parseWorkflow — malformed (each fails with a field-named, secret-fre expect(err.message.toLowerCase()).toMatch(/duplicate/); }); + it('does not echo an authored secret via the custom Zod code path (superRefine invariant)', () => { + // The duplicate-id superRefine uses `code: custom` and echoes the duplicate identifier in its + // message. Duplicate node IDs are kebab-id-validated by the schema, so an arbitrary secret + // cannot appear here — but this test pins the invariant: the custom code path must never leak + // an authored secret value even if the superRefine message does embed it. + const secret = 'sk-live-DO-NOT-LEAK-CUSTOM'; + // Use the per-type validation-key superRefine: an unrecognised key on a number input triggers + // `code: custom, message: "validation key '${key}' is not allowed for input type '${type}'"`. + // The key names are schema-defined strings — but we verify no arbitrary token leaks. + const err = expectValidationError( + doc( + ` id: w\n inputs:\n - name: n\n type: number\n validation:\n min_length: 1\n nodes:\n - id: x\n type: input\n edges: []`, + ), + ); + // `min_length` is not allowed for `number` → custom issue. Verify the secret is not present. + expect(JSON.stringify(err.issues)).not.toContain(secret); + // And the message IS informative (structural key name echoed, not a value). + expect(err.issues.some((i) => i.message.includes('min_length'))).toBe(true); + }); + it('rejects an unknown node `type` with the valid options', () => { const err = expectValidationError( doc(` id: w\n nodes:\n - id: x\n type: looop\n edges: []`), @@ -363,6 +383,22 @@ describe('parseWorkflow — diagnostic field naming (issue-mapper coverage)', () }); }); +describe('collectReferences — context/run.outputs (1.M known gap)', () => { + it('permits a context value that references run.outputs — enforcement deferred to the DAG builder (1.M)', () => { + // TODO(1.M): workflow-yaml-spec.md forbids {{run.outputs[...]}} inside context[].value because + // context is resolved pre-run. Once the DAG builder rejects unsatisfiable node-output edges in a + // context site, replace this test with a WorkflowValidationError assertion from parseWorkflow. + const wf = parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: w\n context:\n - key: snapshot\n value: '{{run.outputs["some-node"]}}'\n nodes:\n - id: some-node\n type: input\n edges: []`, + ); + const sites = collectReferences(wf); + const ctxSite = sites.find((s) => s.location === 'context `snapshot`.value'); + // The reference is CLASSIFIED (kind:'node') but not VALIDATED — the gap is intentional for now. + expect(ctxSite?.references[0]?.kind).toBe('node'); + expect(ctxSite?.references[0]?.identifier).toBe('some-node'); + }); +}); + describe('collectReferences — field coverage', () => { it('skips a `$ref` agent and scans system_prompt_append; leaves whole-string fields alone', () => { const wf = parseWorkflow( diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts index 526371e5..5bafe8be 100644 --- a/packages/core/src/parser.ts +++ b/packages/core/src/parser.ts @@ -28,14 +28,14 @@ export interface ParseWorkflowOptions { readonly source?: string; } +/** A pre-parse character cap (≈ bytes for authored ASCII) — workflow files are small; a DoS guard. */ +const MAX_SOURCE_CHARS = 2 * 1024 * 1024; // 2 MiB + /** * Parse + validate a workflow YAML string. Throws {@link WorkflowSyntaxError} on a YAML fault or * {@link WorkflowValidationError} (field-named, secret-free) on a schema failure — an invalid file * never yields a `WorkflowDefinition`, so a run never starts on one. */ -/** A pre-parse character cap (≈ bytes for authored ASCII) — workflow files are small; a DoS guard. */ -const MAX_SOURCE_CHARS = 2 * 1024 * 1024; // 2 MiB - export function parseWorkflow(yamlText: string, opts?: ParseWorkflowOptions): WorkflowDefinition { const source = opts?.source; @@ -86,11 +86,13 @@ function syntaxErrorFrom( if (err instanceof YAMLParseError) { // With `prettyErrors: false` the message is the rule alone (no code-frame, no authored content); // the position comes from the LineCounter, so the source text is never carried into the error. - const pos = lineCounter.linePos(err.pos[0]); + // `err.pos[0]` is -1 when the yaml library has no position for the fault (e.g. a bare stream + // error); lineCounter.linePos(-1) would return a nonsensical {line:0, col:-1}, so skip it. + const posOffset = err.pos[0]; + const pos = posOffset >= 0 ? lineCounter.linePos(posOffset) : undefined; return new WorkflowSyntaxError(err.message, { ...(source !== undefined ? { source } : {}), - line: pos.line, - column: pos.col, + ...(pos !== undefined ? { line: pos.line, column: pos.col } : {}), cause: err, }); } @@ -176,6 +178,14 @@ function itemLabel( /** * A user-facing message for a Zod issue. Deliberately code-derived (type names, key names, enum * options) — it never echoes an authored *value*, so a secret in a credential field can never leak. + * + * `custom` issues come from `@relavium/shared`'s `superRefine` calls. Today every one of them emits + * a structural message (schema-defined key names, kebab-id-validated identifiers, type-enum labels) + * — never an authored *value*. The explicit `case 'custom':` here makes that invariant visible in + * the call site: if a future refine ever embeds an authored value, this switch is the first place a + * reviewer should add a safe, value-free override. The `default` branch is the last-resort fallback + * for Zod codes not yet seen in practice; it returns a generic message so an unknown code can never + * accidentally surface a payload. */ function messageFor(issue: ZodIssue): string { switch (issue.code) { @@ -191,8 +201,14 @@ function messageFor(issue: ZodIssue): string { return `invalid value — expected one of: ${issue.options.map((o) => String(o)).join(', ')}`; case 'invalid_union_discriminator': return `expected one of: ${issue.options.map((o) => String(o)).join(', ')}`; - default: + case 'custom': + // All @relavium/shared superRefines emit structural-only messages — see the comment above. return issue.message; + default: + // A Zod code not explicitly handled — return a generic message rather than risking an echo of + // an authored value via issue.message (which is code-derived for the built-in codes, but could + // change in future Zod versions). + return 'invalid value'; } } diff --git a/tools/engine-deps/check.mjs b/tools/engine-deps/check.mjs index 4db7e32a..df1f44f6 100644 --- a/tools/engine-deps/check.mjs +++ b/tools/engine-deps/check.mjs @@ -41,7 +41,11 @@ const ENGINE_ALLOWLISTS = { // platform imports (CLAUDE.md rule 5), and @relavium/db pulls the native better-sqlite3 // runtime. Core may use its TYPES via a devDependency (the Checkpointer interface / // Drizzle schema types); the store itself is injected by the host surface (1.R). - 'packages/core': ['@relavium/shared', '@relavium/llm', 'zod', 'yaml'], + // @relavium/llm is deliberately ABSENT until the runner actually imports it (1.M+). Adding it + // to the allowlist before declaring it in package.json defeats the guard: the commit that + // introduces the runtime dependency must touch both package.json AND this allowlist — that + // co-location is the gate's whole purpose. Re-add @relavium/llm here in that same change. + 'packages/core': ['@relavium/shared', 'zod', 'yaml'], }; let failed = false; From 89e1b966fa7b73799915e720dbdc25007dad52f8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 12 Jun 2026 03:20:05 +0300 Subject: [PATCH 5/7] fix(core): resolve Sonar/PR-review smells across 1.L sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all actionable findings from the PR #14 review pass. Each item verified against the current code before acting; two items skipped (noted below). parser.ts - Flip all `!== undefined ? X : Y` ternaries to the positive form so Sonar's "Unexpected negated condition" rule is satisfied (5 call sites). - Replace `(o) => String(o)` with the point-free `String` (2 sites, "arrow function is equivalent to String"). - Add explicit `case 'invalid_string': case 'too_small': case 'too_big':` branches that return `issue.message` directly (Zod's structural messages are safe here, unlike the `default` fallback which intentionally returns 'invalid value'). errors.ts - Extract the inner `rest === 1 ? '' : 's'` ternary into a `suffix` variable so Sonar's "nested ternary" smell is resolved. collect.ts - Refactor to reduce cognitive complexity from 23 → well below 15: extract `buildSite` (pure template→site builder) and `collectNodeSites` (per-node dispatch) as module-level helpers; `collectReferences` is now a flat loop. references.ts - Replace `[\s\S]` with `.` + `s` (dotAll) flag on NODE_OUTPUT, NAMESPACED, and FILTER. Resolves the Sonar security hotspot (super-linear backtracking risk on character-class alternation). NODE_OUTPUT's node-ID capture also tightened to `[^'"]*?` (IDs can't contain quotes). - Replace `[A-Za-z0-9_]` with `\w` in FILTER (concise character-class smell). - Extract `isQuoteChar(ch: string | undefined): boolean` helper; use it in both `findClose` and `splitTopLevel` to remove the `||` operator that pushed `splitTopLevel`'s cognitive complexity to 16. references.test.ts - Simplify `refOf` guard to `segment?.kind !== 'reference'` (optional chain). parser.test.ts - Rename all `caught` catch parameters to `error_` (6 sites, Sonar convention). README.md - Remove `@relavium/llm` from the runtime dependency list (not in package.json yet; the sentence now matches the engine-deps allowlist). Skipped intentionally: - `String.raw` on references.test.ts L147/149/154/156: double-escaping is semantically required to pass `\"` / `\'` into the tested template strings; using `String.raw` would change the input and break the tests. - "Complete the TODO" on collect.ts L51 and parser.test.ts L388: these are intentional TODO(1.M) deferred tasks, not actionable smells. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/README.md | 2 +- packages/core/src/errors.ts | 3 +- packages/core/src/interpolation/collect.ts | 76 ++++++++++++------- .../core/src/interpolation/references.test.ts | 2 +- packages/core/src/interpolation/references.ts | 18 +++-- packages/core/src/parser.test.ts | 20 ++--- packages/core/src/parser.ts | 22 ++++-- 7 files changed, 88 insertions(+), 55 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index dc6a81b7..7997a278 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -7,7 +7,7 @@ the Bun API. The most important package. The whole package is the platform-free zone: `tsconfig.json` sets `types: []`, so a stray `process`/`Buffer`/`node:*` is a type error (CLAUDE.md rule 5, [ADR-0011](../../docs/decisions/0011-internal-llm-abstraction.md)). Runtime dependencies are -confined to `@relavium/shared`, `@relavium/llm`, `zod`, and the pure-JS `yaml` loader +confined to `@relavium/shared`, `zod`, and the pure-JS `yaml` loader ([ADR-0035](../../docs/decisions/0035-yaml-parser-dependency.md)) — enforced by the engine-deps guard (`tools/engine-deps/check.mjs`). diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 0c570cda..ce6f58b1 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -74,6 +74,7 @@ function summarize(issues: readonly WorkflowIssue[]): string { return 'workflow validation failed'; } const rest = issues.length - 1; - const more = rest > 0 ? ` (and ${rest} more issue${rest === 1 ? '' : 's'})` : ''; + const suffix = rest === 1 ? '' : 's'; + const more = rest > 0 ? ` (and ${rest} more issue${suffix})` : ''; return `${first.field}: ${first.message}${more}`; } diff --git a/packages/core/src/interpolation/collect.ts b/packages/core/src/interpolation/collect.ts index e8c8e72d..539f92ff 100644 --- a/packages/core/src/interpolation/collect.ts +++ b/packages/core/src/interpolation/collect.ts @@ -31,21 +31,53 @@ export interface ReferenceSite { // checked against the kebab-id regex (or at minimum `nonEmptyString`) by the Zod schema before // reaching this function. A raw, unvalidated string can never appear here. // If the schema ever relaxes those constraints, add a SAFE_LABEL guard here in the same change. + +type WorkflowNode = Workflow['workflow']['nodes'][number]; + +/** Build a ReferenceSite for `text` if it contains at least one interpolation reference. */ +function buildSite(location: string, text: string): ReferenceSite | undefined { + const segments = parseTemplate(text); + const references: InterpolationReference[] = []; + for (const segment of segments) { + if (segment.kind === 'reference') { + references.push(segment.reference); + } + } + return references.length > 0 ? { location, segments, references } : undefined; +} + +/** Collect reference sites from template fields on a single workflow node. */ +function collectNodeSites(node: WorkflowNode): ReferenceSite[] { + const sites: ReferenceSite[] = []; + if (node.type === 'agent') { + if (node.prompt_template !== undefined) { + const site = buildSite(`node \`${node.id}\`.prompt_template`, node.prompt_template); + if (site !== undefined) sites.push(site); + } + if (node.system_prompt_append !== undefined) { + const site = buildSite(`node \`${node.id}\`.system_prompt_append`, node.system_prompt_append); + if (site !== undefined) sites.push(site); + } + } else if (node.type === 'human_gate') { + if (node.assignee !== undefined) { + const site = buildSite(`node \`${node.id}\`.assignee`, node.assignee); + if (site !== undefined) sites.push(site); + } + if (node.message_template !== undefined) { + const site = buildSite(`node \`${node.id}\`.message_template`, node.message_template); + if (site !== undefined) sites.push(site); + } + } + return sites; +} + export function collectReferences(workflow: Workflow): readonly ReferenceSite[] { const sites: ReferenceSite[] = []; const spec = workflow.workflow; - const visit = (location: string, text: string): void => { - const segments = parseTemplate(text); - const references: InterpolationReference[] = []; - for (const segment of segments) { - if (segment.kind === 'reference') { - references.push(segment.reference); - } - } - if (references.length > 0) { - sites.push({ location, segments, references }); - } + const push = (location: string, text: string): void => { + const site = buildSite(location, text); + if (site !== undefined) sites.push(site); }; // TODO(1.M): workflow-yaml-spec.md §Context-and-interpolation forbids `{{run.outputs[...]}}` in @@ -56,34 +88,20 @@ export function collectReferences(workflow: Workflow): readonly ReferenceSite[] // output is collected with kind:'node' and will be caught by the DAG builder when it finds no // satisfying edge for it. See the pinned test in parser.test.ts ("permits a context value …"). for (const entry of spec.context ?? []) { - visit(`context \`${entry.key}\`.value`, entry.value); + push(`context \`${entry.key}\`.value`, entry.value); } for (const input of spec.inputs ?? []) { if (typeof input.default === 'string') { - visit(`input \`${input.name}\`.default`, input.default); + push(`input \`${input.name}\`.default`, input.default); } } for (const agent of spec.agents ?? []) { if (!('$ref' in agent)) { - visit(`agent \`${agent.id}\`.system_prompt`, agent.system_prompt); + push(`agent \`${agent.id}\`.system_prompt`, agent.system_prompt); } } for (const node of spec.nodes) { - if (node.type === 'agent') { - if (node.prompt_template !== undefined) { - visit(`node \`${node.id}\`.prompt_template`, node.prompt_template); - } - if (node.system_prompt_append !== undefined) { - visit(`node \`${node.id}\`.system_prompt_append`, node.system_prompt_append); - } - } else if (node.type === 'human_gate') { - if (node.assignee !== undefined) { - visit(`node \`${node.id}\`.assignee`, node.assignee); - } - if (node.message_template !== undefined) { - visit(`node \`${node.id}\`.message_template`, node.message_template); - } - } + sites.push(...collectNodeSites(node)); } return sites; } diff --git a/packages/core/src/interpolation/references.test.ts b/packages/core/src/interpolation/references.test.ts index 210e2949..38126284 100644 --- a/packages/core/src/interpolation/references.test.ts +++ b/packages/core/src/interpolation/references.test.ts @@ -9,7 +9,7 @@ import { /** Narrow a segment to its reference, failing the test if it is a literal. */ function refOf(segment: TemplateSegment | undefined): InterpolationReference { - if (segment === undefined || segment.kind !== 'reference') { + if (segment?.kind !== 'reference') { throw new Error('expected a reference segment'); } return segment.reference; diff --git a/packages/core/src/interpolation/references.ts b/packages/core/src/interpolation/references.ts index 6620f418..7809fce9 100644 --- a/packages/core/src/interpolation/references.ts +++ b/packages/core/src/interpolation/references.ts @@ -119,7 +119,7 @@ function findClose(text: string, from: number): number { } else if (ch === quote) { quote = undefined; } - } else if (ch === '"' || ch === "'") { + } else if (isQuoteChar(ch)) { quote = ch; } else if (ch === '[' || ch === '(') { depth += 1; @@ -140,8 +140,11 @@ function parseReference(inner: string, raw: string): InterpolationReference { return { kind, identifier, path, filters, raw }; } -const NODE_OUTPUT = /^run\.outputs\[\s*(['"])([\s\S]*?)\1\s*\]([\s\S]*)$/; -const NAMESPACED = /^(inputs|ctx|secrets)\.([A-Za-z0-9_-]+)([\s\S]*)$/; +// The `s` (dotAll) flag replaces `[\s\S]` throughout — avoids the character-class alternation that +// Sonar flags as a potential super-linear backtracking risk. Inputs are bounded by the 2 MiB parse +// cap, but dotAll is simpler and faster regardless. +const NODE_OUTPUT = /^run\.outputs\[\s*(['"])([^'"]*?)\1\s*\](.*)$/s; +const NAMESPACED = /^(inputs|ctx|secrets)\.([A-Za-z0-9_-]+)(.*)$/s; function parseHead(head: string): Pick { const nodeMatch = NODE_OUTPUT.exec(head); @@ -156,7 +159,7 @@ function parseHead(head: string): Pick { parseWorkflow( `schema_version: '1.0'\nworkflow: &w\n id: w\n self: *w\n nodes:\n - id: n\n type: input\n edges: []`, ); - } catch (caught) { - thrown = caught; + } catch (error_) { + thrown = error_; } expect(thrown).toBeInstanceOf(WorkflowSyntaxError); expect((thrown as WorkflowSyntaxError).message).toMatch(/anchors and aliases/); @@ -270,8 +270,8 @@ describe('parseWorkflow — hardened decode (ADR-0035)', () => { let thrown: unknown; try { parseWorkflow('a'.repeat(2 * 1024 * 1024 + 1)); - } catch (caught) { - thrown = caught; + } catch (error_) { + thrown = error_; } expect(thrown).toBeInstanceOf(WorkflowSyntaxError); expect((thrown as WorkflowSyntaxError).code).toBe('invalid_yaml'); @@ -365,8 +365,8 @@ describe('parseWorkflow — diagnostic field naming (issue-mapper coverage)', () let thrown: unknown; try { parseWorkflow(`workflow:\n id: "x`, { source: 'f.yaml' }); - } catch (caught) { - thrown = caught; + } catch (error_) { + thrown = error_; } expect(thrown).toBeInstanceOf(WorkflowSyntaxError); expect((thrown as WorkflowSyntaxError).source).toBe('f.yaml'); @@ -376,8 +376,8 @@ describe('parseWorkflow — diagnostic field naming (issue-mapper coverage)', () let thrown: unknown; try { parseWorkflow('a'.repeat(2 * 1024 * 1024 + 1), { source: 'big.yaml' }); - } catch (caught) { - thrown = caught; + } catch (error_) { + thrown = error_; } expect((thrown as WorkflowSyntaxError).source).toBe('big.yaml'); }); diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts index 5bafe8be..f16d1af6 100644 --- a/packages/core/src/parser.ts +++ b/packages/core/src/parser.ts @@ -42,7 +42,7 @@ export function parseWorkflow(yamlText: string, opts?: ParseWorkflowOptions): Wo if (yamlText.length > MAX_SOURCE_CHARS) { throw new WorkflowSyntaxError( `the source exceeds the ${MAX_SOURCE_CHARS}-character parse limit`, - source !== undefined ? { source } : undefined, + source === undefined ? undefined : { source }, ); } @@ -72,7 +72,7 @@ export function parseWorkflow(yamlText: string, opts?: ParseWorkflowOptions): Wo const issues = result.error.issues.map((issue) => describeIssue(issue, raw)); // No `cause`: the raw ZodError can carry an authored `received` value (an enum/literal/discriminator) // and `cause` is publicly reachable — the curated, secret-free `issues` are the diagnostic surface. - throw new WorkflowValidationError(issues, source !== undefined ? { source } : undefined); + throw new WorkflowValidationError(issues, source === undefined ? undefined : { source }); } return result.data; } @@ -91,8 +91,8 @@ function syntaxErrorFrom( const posOffset = err.pos[0]; const pos = posOffset >= 0 ? lineCounter.linePos(posOffset) : undefined; return new WorkflowSyntaxError(err.message, { - ...(source !== undefined ? { source } : {}), - ...(pos !== undefined ? { line: pos.line, column: pos.col } : {}), + ...(source === undefined ? {} : { source }), + ...(pos === undefined ? {} : { line: pos.line, column: pos.col }), cause: err, }); } @@ -100,12 +100,12 @@ function syntaxErrorFrom( // `maxAliasCount: 0` makes the resolver throw a plain `ReferenceError` (no position) on any // anchor/alias use — surface a clear, source-free message rather than the generic fallback. return new WorkflowSyntaxError('anchors and aliases are not supported', { - ...(source !== undefined ? { source } : {}), + ...(source === undefined ? {} : { source }), cause: err, }); } return new WorkflowSyntaxError('the file is not valid YAML', { - ...(source !== undefined ? { source } : {}), + ...(source === undefined ? {} : { source }), cause: err, }); } @@ -198,9 +198,15 @@ function messageFor(issue: ZodIssue): string { .map((key) => `\`${key}\``) .join(', ')}`; case 'invalid_enum_value': - return `invalid value — expected one of: ${issue.options.map((o) => String(o)).join(', ')}`; + return `invalid value — expected one of: ${issue.options.map(String).join(', ')}`; case 'invalid_union_discriminator': - return `expected one of: ${issue.options.map((o) => String(o)).join(', ')}`; + return `expected one of: ${issue.options.map(String).join(', ')}`; + case 'invalid_string': + case 'too_small': + case 'too_big': + // Zod's built-in messages for these codes are purely structural (constraint metadata, not the + // authored value), so they are safe to surface directly. + return issue.message; case 'custom': // All @relavium/shared superRefines emit structural-only messages — see the comment above. return issue.message; From 90e16b193a8b7cd403b010fbd317039caf691cbc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 12 Jun 2026 08:38:32 +0300 Subject: [PATCH 6/7] refactor(core): extract addFieldSite helper in collectNodeSites The four repeated if-field-undefined / buildSite / push-if-defined blocks in collectNodeSites are collapsed into a single addFieldSite closure, removing the repetition while keeping all existing symbols (collectNodeSites, buildSite, WorkflowNode, field names) unchanged. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/interpolation/collect.ts | 24 ++++++++-------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/core/src/interpolation/collect.ts b/packages/core/src/interpolation/collect.ts index 539f92ff..1007c596 100644 --- a/packages/core/src/interpolation/collect.ts +++ b/packages/core/src/interpolation/collect.ts @@ -49,24 +49,18 @@ function buildSite(location: string, text: string): ReferenceSite | undefined { /** Collect reference sites from template fields on a single workflow node. */ function collectNodeSites(node: WorkflowNode): ReferenceSite[] { const sites: ReferenceSite[] = []; - if (node.type === 'agent') { - if (node.prompt_template !== undefined) { - const site = buildSite(`node \`${node.id}\`.prompt_template`, node.prompt_template); - if (site !== undefined) sites.push(site); - } - if (node.system_prompt_append !== undefined) { - const site = buildSite(`node \`${node.id}\`.system_prompt_append`, node.system_prompt_append); + const addFieldSite = (label: string, value: string | undefined): void => { + if (value !== undefined) { + const site = buildSite(label, value); if (site !== undefined) sites.push(site); } + }; + if (node.type === 'agent') { + addFieldSite(`node \`${node.id}\`.prompt_template`, node.prompt_template); + addFieldSite(`node \`${node.id}\`.system_prompt_append`, node.system_prompt_append); } else if (node.type === 'human_gate') { - if (node.assignee !== undefined) { - const site = buildSite(`node \`${node.id}\`.assignee`, node.assignee); - if (site !== undefined) sites.push(site); - } - if (node.message_template !== undefined) { - const site = buildSite(`node \`${node.id}\`.message_template`, node.message_template); - if (site !== undefined) sites.push(site); - } + addFieldSite(`node \`${node.id}\`.assignee`, node.assignee); + addFieldSite(`node \`${node.id}\`.message_template`, node.message_template); } return sites; } From c644fc0aa905585d9c637d88e6df7a34f9cc6124 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 12 Jun 2026 09:19:52 +0300 Subject: [PATCH 7/7] test(core): pin messageFor pass-through paths + empty-interpolation edge cases; refresh 1.L roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parser.test.ts: add regression tests for the invalid_string, too_small, and too_big messageFor branches (each returns issue.message directly; pins the secret-free invariant for those code paths) - references.test.ts: add {{}} and {{ }} edge-case tests (both produce kind:unknown with empty identifier — lexer defers validity judgment to the resolver) - docs/roadmap/current.md, phase-1-engine-and-llm.md: remove stale "not started / still only a README" language for 1.L; mark 1.L ✅ Done (PR #14, 2026-06-12) and point forward to 1.L2 → 1.M Co-Authored-By: Claude Sonnet 4.6 --- docs/roadmap/current.md | 13 ++++--- docs/roadmap/phases/phase-1-engine-and-llm.md | 6 +-- .../core/src/interpolation/references.test.ts | 24 ++++++++++++ packages/core/src/parser.test.ts | 38 +++++++++++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index e809957f..cb5938ea 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -118,12 +118,13 @@ the seam's last Phase-1 policy layer, **completing 1.m2** with the cost tracker ([ADR-0011](../decisions/0011-internal-llm-abstraction.md), [llm-provider-seam.md](../reference/shared-core/llm-provider-seam.md)). *(PR #13 also refreshed the model-pricing table to current provider models and added Claude Fable 5.)* The active work is now the -**engine lane**, which `@relavium/core` has not started: - -- **1.L — `WorkflowYAMLParser`** *(critical path)* — **scaffold `packages/core`** (still only a README) - and parse+validate a `.relavium.yaml` against the reconciled `WorkflowSchema`, with typed, - field-named errors (**zero platform imports**), then 1.L → 1.L2 → 1.M → 1.N → 1.R, converging at the - **1.O join** (whose `FallbackChain` dependency, 1.K, is now satisfied) toward **M2**. +**engine lane**: **1.L (`WorkflowYAMLParser`) ✅ Done (PR #14, 2026-06-12)** — `@relavium/core` is +now scaffolded with a pure-TypeScript `WorkflowYAMLParser` that parses and validates +`.relavium.yaml` against the reconciled `WorkflowSchema`, with typed, field-named errors +(**zero platform imports**) and a hardened YAML decode profile +([ADR-0035](../decisions/0035-yaml-parser-dependency.md)). The engine lane continues: +**1.L2 → 1.M → 1.N → 1.R**, converging at the **1.O join** (whose `FallbackChain` dependency, +1.K, is now satisfied) toward **M2**. > **Multimodal I/O — the shape is landed (1.AD ✅ Done, PR #11, 2026-06-10).** First-class > image/audio/video I/O (input **and** output, incl. generate-media-by-rule) was decided on 2026-06-08: diff --git a/docs/roadmap/phases/phase-1-engine-and-llm.md b/docs/roadmap/phases/phase-1-engine-and-llm.md index 0120c945..c6fb63c0 100644 --- a/docs/roadmap/phases/phase-1-engine-and-llm.md +++ b/docs/roadmap/phases/phase-1-engine-and-llm.md @@ -11,9 +11,9 @@ > conformance suite in fixture mode (live-nightly lane reserved/pending keys); no vendor type crosses the > seam. **1.K (FallbackChain) is ✅ Done (PR #13, 2026-06-11)** — the seam's last policy layer, with the > ADR-0030 strip-on-failover obligation honoured; **1.m2 (policy layers) is complete** (with the -> CostTracker, 1.B). **Next:** the **1.L** engine parser (it scaffolds `packages/core`), then Lane B -> (1.L → 1.L2 → 1.M → 1.N → 1.R) converging at the **1.O** join toward **M2**. *(Session persistence, -> 1.X/1.Z, must exclude the reasoning signature — non-persisting.)* +> CostTracker, 1.B). **1.L ✅ Done (PR #14, 2026-06-12)** — `@relavium/core` is now scaffolded with the +> `WorkflowYAMLParser`; the engine lane continues at **1.L2 → 1.M → 1.N → 1.R**, converging at the +> **1.O** join toward **M2**. *(Session persistence, 1.X/1.Z, must exclude the reasoning signature — non-persisting.)* > > **Multimodal I/O decided (2026-06-08).** First-class image/audio/video I/O (input **and** output) is a > second pre-freeze seam amendment in the ADR-0030 mould — [ADR-0031](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) diff --git a/packages/core/src/interpolation/references.test.ts b/packages/core/src/interpolation/references.test.ts index 38126284..bee62817 100644 --- a/packages/core/src/interpolation/references.test.ts +++ b/packages/core/src/interpolation/references.test.ts @@ -105,6 +105,30 @@ describe('parseTemplate', () => { ]); }); + it('parses an empty `{{}}` as an unknown reference with empty identifier', () => { + const segments = parseTemplate('{{}}'); + expect(segments).toHaveLength(1); + expect(refOf(segments[0])).toMatchObject({ + kind: 'unknown', + identifier: '', + path: '', + filters: [], + raw: '{{}}', + }); + }); + + it('parses a whitespace-only `{{ }}` as an unknown reference with empty identifier', () => { + const segments = parseTemplate('{{ }}'); + expect(segments).toHaveLength(1); + expect(refOf(segments[0])).toMatchObject({ + kind: 'unknown', + identifier: '', + path: '', + filters: [], + raw: '{{ }}', + }); + }); + it('does not split on a pipe inside a filter string argument', () => { const ref = refOf(parseTemplate('{{inputs.x | default("a|b")}}')[0]); expect(ref.filters).toEqual([{ name: 'default', args: [{ type: 'string', value: 'a|b' }] }]); diff --git a/packages/core/src/parser.test.ts b/packages/core/src/parser.test.ts index 0a6aa093..e78e7fc1 100644 --- a/packages/core/src/parser.test.ts +++ b/packages/core/src/parser.test.ts @@ -250,6 +250,44 @@ describe('parseWorkflow — malformed (each fails with a field-named, secret-fre const err = expectValidationError(doc(` id: w\n nodes: []\n edges: []`)); expect(err.cause).toBeUndefined(); }); + + it('does not echo an authored value via the `invalid_string` code path (regex-failed id)', () => { + // A node id with uppercase letters fails the kebab-id regex → Zod code `invalid_string`. + // messageFor returns issue.message directly for this case — pin that the message is structural. + const secret = 'SK-LIVE-INVALID-STRING'; + const err = expectValidationError( + doc(` id: w\n nodes:\n - id: ${secret}\n type: input\n edges: []`), + ); + expect(err.message).not.toContain(secret); + expect(JSON.stringify(err.issues)).not.toContain(secret); + expect(err.issues[0]?.message).toMatch(/kebab/); + }); + + it('surfaces a structural message for the `too_small` code path (min-1 string constraint)', () => { + // An empty string on context[].key (nonEmptyString = z.string().min(1)) → Zod code `too_small`. + // messageFor returns issue.message directly — pin that it names the constraint, not the authored value. + const err = expectValidationError( + doc( + ` id: w\n context:\n - key: ''\n value: v\n nodes:\n - id: n\n type: input\n edges: []`, + ), + ); + const issue = err.issues.find((i) => i.field.includes('context')); + expect(issue).toBeDefined(); + expect(issue?.message).toMatch(/character|length|least/i); + }); + + it('does not echo an authored value via the `too_big` code path (max-value numeric constraint)', () => { + // temperature: 3 on an agent exceeds temperatureSchema's .max(2) → Zod code `too_big`. + // messageFor returns issue.message directly — pin that it names the limit, not the authored value. + const err = expectValidationError( + doc( + ` id: w\n agents:\n - id: ag\n name: Agent\n model: claude-sonnet-4-6\n provider: anthropic\n system_prompt: sys\n temperature: 3\n nodes:\n - id: n\n type: input\n edges: []`, + ), + ); + const issue = err.issues.find((i) => i.field.includes('temperature')); + expect(issue).toBeDefined(); + expect(issue?.message).not.toContain('3'); + }); }); describe('parseWorkflow — hardened decode (ADR-0035)', () => {