diff --git a/package-lock.json b/package-lock.json index 7bd8195..1bfbbe2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2451,7 +2451,7 @@ }, "packages/create-nomark-engine": { "name": "nomark-engine", - "version": "0.1.0", + "version": "0.1.2", "license": "Apache-2.0", "bin": { "nomark-engine": "dist/index.js" @@ -2466,7 +2466,7 @@ }, "packages/engine": { "name": "@nomark-ai/engine", - "version": "0.1.1", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "zod": "^3.23.0" @@ -2494,10 +2494,10 @@ }, "packages/pro": { "name": "@nomark-ai/pro", - "version": "0.1.1", + "version": "0.2.0", "license": "BSL-1.1", "dependencies": { - "@nomark-ai/engine": "*" + "@nomark-ai/engine": "^0.2.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/pro/README.md b/packages/pro/README.md index cb2c3d5..edba263 100644 --- a/packages/pro/README.md +++ b/packages/pro/README.md @@ -123,7 +123,7 @@ const manifest = resolveAll(myEtl, ['typescript', 'python', 'rust'], { // manifest.targets[i].files = [{ path, bytes }, ...], .warnings = [string, ...] ``` -Registered target labels (7 total): +Registered target labels (9 total): | Label | Kind | Output | |---|---|---| @@ -134,10 +134,12 @@ Registered target labels (7 total): | `sql-sqlite` | compute | `*.sql` with `ON CONFLICT` upsert | | `sql-mysql` | compute | `*.sql` with `ON DUPLICATE KEY` upsert | | `react` | surface | `Dashboard.tsx` with 5 surface-verb handlers | +| `vue` | surface | `Dashboard.vue` (` + + +` + +const PREAMBLE_INTERACTIVE = ` + + +` + +/** Sort surface verbs leaf-first so each snippet is defined before any caller + * references it. Order: leaves (DISPLAY/STATUS/GUIDE/DECIDE/CONFIGURE/EXPLORE/ + * AUTHOR/ONBOARD/COLLECT, in composition order) → ARRANGE → MONITOR. */ +function leafFirst(verbs: readonly Verb[]): Verb[] { + const rank = (v: Verb): number => { + if (v.verb === 'MONITOR') return 3 + if (v.verb === 'ARRANGE') return 2 + return 1 + } + return [...verbs].sort((a, b) => { + const ra = rank(a) + const rb = rank(b) + if (ra !== rb) return ra - rb + return verbs.indexOf(a) - verbs.indexOf(b) + }) +} + +export class SvelteBackend extends BaseResolver { + readonly label = 'svelte' + + private interactive = false + + protected handlers: Partial> = { + MONITOR: (v) => this.emitMonitor(v), + ARRANGE: (v) => this.emitArrange(v), + DISPLAY: (v) => this.emitDisplay(v), + STATUS: (v) => this.emitStatus(v), + GUIDE: (v) => this.emitGuide(v), + DECIDE: (v) => this.emitDecide(v), + CONFIGURE: (v) => this.emitConfigure(v), + EXPLORE: (v) => this.emitExplore(v), + AUTHOR: (v) => this.emitAuthor(v), + ONBOARD: (v) => this.emitOnboard(v), + COLLECT: (v) => this.emitCollect(v), + } + + protected override handlerKind(): 'compute' | 'surface' { + return 'surface' + } + + protected override targetVerbs(c: Composition): { handled: Verb[]; stripped: Verb[] } { + this.interactive = hasInteractiveVerbs(c) + const { compute, surface } = partitionVerbs(c) + return { handled: leafFirst(surface), stripped: compute } + } + + protected rootFileName(_c: Composition): string { + return 'Dashboard.svelte' + } + + protected emitPreamble(c: Composition): string { + const header = [ + '', + ``, + ``, + ].join('\n') + return `${header}\n${this.interactive ? PREAMBLE_INTERACTIVE : PREAMBLE_READONLY}` + } + + protected emitPostamble(c: Composition): string { + const { surface } = partitionVerbs(c) + const monitor = surface.find((v) => v.verb === 'MONITOR') + if (!monitor) throw new Error('Svelte backend requires a MONITOR verb') + return this.interactive + ? `{@render ${monitor.id}(state, dispatch)}\n` + : `{@render ${monitor.id}(state)}\n` + } + + private snippetSig(): string { + return this.interactive + ? `(state: DashboardState, dispatch: DashboardDispatch)` + : `(state: DashboardState)` + } + + private renderArgs(): string { + return this.interactive ? 'state, dispatch' : 'state' + } + + private emitMonitor(v: Verb): string { + const p = (v.params ?? {}) as MonitorParams + const subject = p.subject ?? '' + const refresh = p.refresh ? `${p.refresh.value}${p.refresh.unit}` : '' + const siblings = this.lastComposition?.verbs ?? [] + const statuses = siblings.filter((s) => s.verb === 'STATUS') + const guides = siblings.filter((g) => g.verb === 'GUIDE') + const arrange = siblings.find((a) => a.verb === 'ARRANGE') + if (!arrange) throw new Error('Svelte backend requires an ARRANGE verb') + + const args = this.renderArgs() + const renderLines = [ + ...statuses.map((s) => ` {@render ${s.id}(${args})}`), + ...guides.map((g) => ` {@render ${g.id}(${args})}`), + ` {@render ${arrange.id}(${args})}`, + ] + + return `{#snippet ${v.id}${this.snippetSig()}} +
+${renderLines.join('\n')} +
+{/snippet} +` + } + + private emitArrange(v: Verb): string { + const p = (v.params ?? {}) as ArrangeParams + const type = p.type ?? '' + const density = p.density ?? '' + const children = p.children ?? [] + const childrenAttr = children.join(',') + const args = this.renderArgs() + const childRenders = children.map((c) => ` {@render ${c}(${args})}`) + return `{#snippet ${v.id}${this.snippetSig()}} +
+${childRenders.join('\n')} +
+{/snippet} +` + } + + private emitDisplay(v: Verb): string { + const p = (v.params ?? {}) as DisplayParams + const type = p.type ?? '' + const emphasis = p.emphasis ?? '' + const dataKey = p.data ?? '' + const openTag = `
` + if (type === 'metric') { + const label = p.label ?? '' + return `{#snippet ${v.id}${this.snippetSig()}} + ${openTag} +
${label}
+ {String(state.values['${dataKey}'] ?? '—')} +
+{/snippet} +` + } + if (type === 'chart') { + return `{#snippet ${v.id}${this.snippetSig()}} + ${openTag} +
{JSON.stringify(state.values['${dataKey}'] ?? null, null, 2)}
+ +{/snippet} +` + } + return `{#snippet ${v.id}${this.snippetSig()}} + ${openTag} + + +{/snippet} +` + } + + private emitStatus(v: Verb): string { + const p = (v.params ?? {}) as StatusParams + const type = p.type ?? '' + const message = p.message ?? '' + return `{#snippet ${v.id}${this.snippetSig()}} + {#if state.status === '${type}'} +
+ ${message} +
+ {/if} +{/snippet} +` + } + + private emitGuide(v: Verb): string { + const p = (v.params ?? {}) as GuideParams + const message = p.message ?? '' + const priority = p.priority ?? '' + return `{#snippet ${v.id}${this.snippetSig()}} + {#if state.alerts.includes('${v.id}')} +
+ ${message} +
+ {/if} +{/snippet} +` + } + + private emitDecide(v: Verb): string { + const p = (v.params ?? {}) as DecideParams + const prompt = p.prompt ?? '' + const opts = p.options ?? [] + const buttons = opts.map((o) => + ` `, + ).join('\n') + return `{#snippet ${v.id}(state: DashboardState, dispatch: DashboardDispatch)} +
+${buttons} +
+{/snippet} +` + } + + private emitConfigure(v: Verb): string { + const p = (v.params ?? {}) as ConfigureParams + const prompt = p.prompt ?? '' + const params = p.params ?? [] + const inputs = params.map((pa) => + ` `, + ).join('\n') + return `{#snippet ${v.id}(state: DashboardState, dispatch: DashboardDispatch)} +
+${inputs} + +
+{/snippet} +` + } + + private emitExplore(v: Verb): string { + const p = (v.params ?? {}) as ExploreParams + const prompt = p.prompt ?? '' + const source = p.source ?? '' + const placeholder = p.placeholder ?? '' + return `{#snippet ${v.id}(state: DashboardState, dispatch: DashboardDispatch)} +
+ dispatch({ verb: 'EXPLORE', id: '${v.id}', action: 'query', payload: (e.currentTarget as HTMLInputElement).value })} + /> + +
+{/snippet} +` + } + + private emitAuthor(v: Verb): string { + const p = (v.params ?? {}) as AuthorParams + const prompt = p.prompt ?? '' + const schema = p.schema ?? '' + const saveMode = p.save_mode ?? 'manual' + return `{#snippet ${v.id}(state: DashboardState, dispatch: DashboardDispatch)} +
+ {String(state.drafts['${v.id}'] ?? state.values['${v.id}'] ?? '')} + + +
+{/snippet} +` + } + + private emitOnboard(v: Verb): string { + const p = (v.params ?? {}) as OnboardParams + const prompt = p.prompt ?? '' + const progressType = p.progress_type ?? 'step' + const steps = p.steps ?? [] + const stepLines = steps.map((s) => + `
  • ${s.label ?? ''}
  • `, + ).join('\n') + const firstStep = steps[0]?.id ?? '' + return `{#snippet ${v.id}(state: DashboardState, dispatch: DashboardDispatch)} + {@const current = String(state.selections['${v.id}'] ?? '${firstStep}')} + +{/snippet} +` + } + + private emitCollect(v: Verb): string { + const p = (v.params ?? {}) as CollectParams + const type = p.type ?? 'text' + const label = p.label ?? '' + const name = p.name ?? '' + const required = p.required ?? false + return `{#snippet ${v.id}(state: DashboardState, dispatch: DashboardDispatch)} + +{/snippet} +` + } + + private lastComposition: Composition | undefined + + override resolve(composition: Composition) { + this.lastComposition = composition + return super.resolve(composition) + } +} diff --git a/packages/pro/src/resolvers/typescript/backend.ts b/packages/pro/src/resolvers/typescript/backend.ts index 3d53152..d13183c 100644 --- a/packages/pro/src/resolvers/typescript/backend.ts +++ b/packages/pro/src/resolvers/typescript/backend.ts @@ -1,5 +1,5 @@ import { BaseResolver, type VerbHandler } from '../core/resolver.js' -import { partitionVerbs, type Composition, type ComputeVerb, type Verb } from '../core/ir.js' +import { buildLinearChain, hasFlowVerbs, partitionVerbs, type Composition, type ComputeVerb, type Verb } from '../core/ir.js' type FetchParams = { source?: { type?: string; config?: { url?: string } } } type ValidateParams = { @@ -16,6 +16,67 @@ type PersistParams = { type EmitParams = { target?: { type?: string; config?: { url?: string; channel?: string } } } +type EnrichParams = { with?: Record } +type DeleteParams = { + sink?: { type?: string; config?: { table?: string; connection?: unknown } } + predicate?: string +} +type StreamParams = { + source?: { type?: string; config?: { table?: string; connection?: unknown } } + batch_size?: number +} +type InlineTarget = { verb: string; params?: Record } +type RetryParams = { + of?: InlineTarget + policy?: { max?: number; delay_ms?: number; backoff?: 'linear' | 'exponential'; jitter?: boolean } + error_route?: string +} +type CompensateParams = { + receipt_from?: string + reverse?: InlineTarget + idempotent?: boolean + reason?: string +} +type ErrorParams = { + of?: InlineTarget + catch?: string[] + handler?: InlineTarget + terminate_on_match?: boolean +} +type AwaitParams = { event?: { topic?: string }; timeout_ms?: number; timeout_route?: string } +type BranchParams = { + conditions?: { when?: string; then?: string }[] + default?: string +} +type SplitParams = { + strategy?: 'parallel' | 'broadcast' | 'partition' + targets?: string[] +} +type MergeParams = { + sources?: string[] + strategy?: 'all' | 'first' | 'majority' + timeout_ms?: number + combine?: string + from?: string // optional explicit SPLIT verb id whose values to read +} +type GateParams = { + actor?: { role?: string; id?: string } + prompt?: string + options?: string[] + timeout_ms?: number + default_route?: string +} +type SignalParams = { + system?: string + signal?: { type?: string; url?: string } + timeout_ms?: number + timeout_route?: string +} +type TerminateParams = { + reason?: string + status?: string + cleanup?: string[] +} const HEADER_LINE_1 = '// Generated by NOMARK Intents resolver (target: typescript)' @@ -103,6 +164,19 @@ export class TypeScriptBackend extends BaseResolver { REDUCE: (v) => this.emitReduce(v), PERSIST: (v) => this.emitPersist(v), EMIT: (v) => this.emitEmit(v), + ENRICH: (v) => this.emitEnrich(v), + DELETE: (v) => this.emitDelete(v), + STREAM: (v) => this.emitStream(v), + RETRY: (v) => this.emitRetry(v), + COMPENSATE: (v) => this.emitCompensate(v), + ERROR: (v) => this.emitError(v), + AWAIT: (v) => this.emitAwait(v), + BRANCH: (v) => this.emitBranch(v), + SPLIT: (v) => this.emitSplit(v), + MERGE: (v) => this.emitMerge(v), + GATE: (v) => this.emitGate(v), + SIGNAL: (v) => this.emitSignal(v), + TERMINATE: (v) => this.emitTerminate(v), } protected rootFileName(c: Composition): string { @@ -120,12 +194,45 @@ export class TypeScriptBackend extends BaseResolver { protected emitPostamble(c: Composition): string { const { compute } = partitionVerbs(c) - const first = compute[0]?.id ?? 'start' + if (!hasFlowVerbs(c)) { + // Linear form (BC-preserved): identical to pre-W5 emission. + const first = compute[0]?.id ?? 'start' + const cases = compute + .map((v, i) => { + const next = compute[i + 1] + const follow = next + ? `return __execute(${JSON.stringify(next.id)}, ctx);` + : 'return;' + return ` case ${JSON.stringify(v.id)}: { + ctx.values[${JSON.stringify(v.id)}] = await ${v.id}(ctx); + ${follow} + }` + }) + .join('\n') + + return `async function __execute(id: string, ctx: Ctx): Promise { + switch (id) { +${cases} + default: throw new Error(\`unknown verb id: \${id}\`); + } +} + +export async function run(input: any): Promise { + const ctx = newCtx(input); + await __execute(${JSON.stringify(first)}, ctx); + return ctx; +} +` + } + + // Graph form: linear chain skips SPLIT children + self-dispatching verbs + // have no linear next. Includes a __TerminateError class for TERMINATE. + const chain = buildLinearChain(c) const cases = compute - .map((v, i) => { - const next = compute[i + 1] - const follow = next - ? `return __execute(${JSON.stringify(next.id)}, ctx);` + .map((v) => { + const linearNext = chain.next.get(v.id) + const follow = linearNext + ? `return __execute(${JSON.stringify(linearNext)}, ctx);` : 'return;' return ` case ${JSON.stringify(v.id)}: { ctx.values[${JSON.stringify(v.id)}] = await ${v.id}(ctx); @@ -134,7 +241,10 @@ export class TypeScriptBackend extends BaseResolver { }) .join('\n') - return `async function __execute(id: string, ctx: Ctx): Promise { + return `class __TerminateError extends Error { + constructor(public reason: string, public status: string) { super(\`__TERMINATE: \${reason}\`); } +} +async function __execute(id: string, ctx: Ctx): Promise { switch (id) { ${cases} default: throw new Error(\`unknown verb id: \${id}\`); @@ -143,7 +253,11 @@ ${cases} export async function run(input: any): Promise { const ctx = newCtx(input); - await __execute(${JSON.stringify(first)}, ctx); + try { + await __execute(${JSON.stringify(chain.entry ?? 'start')}, ctx); + } catch (e) { + if (!(e instanceof __TerminateError)) throw e; + } return ctx; } ` @@ -243,6 +357,57 @@ export async function run(input: any): Promise { ` } + private emitEnrich(v: Verb): string { + const p = (v.params ?? {}) as EnrichParams + const extras = JSON.stringify(p.with ?? {}) + return `async function ${v.id}(ctx: Ctx): Promise { + const input = ctx.input; + const extras = ${extras}; + return Array.isArray(input) ? input.map(r => ({ ...r, ...extras })) : { ...input, ...extras }; +} +` + } + + private emitDelete(v: Verb): string { + const p = (v.params ?? {}) as DeleteParams + const table = p.sink?.config?.table ?? 'unknown_table' + const predicate = p.predicate ?? 'TRUE' + const connection = p.sink?.config?.connection + ? JSON.stringify(p.sink.config.connection) + : CONNECTION_DEFAULT + return `async function ${v.id}(ctx: Ctx): Promise { + const { default: pg } = await import("pg"); + const client = new pg.Client(${connection}); + await client.connect(); + try { + await client.query(\`DELETE FROM ${table} WHERE ${predicate}\`); + } finally { await client.end(); } + const receipt = __receipt("sql:${table}", false); + ctx.receipts[${JSON.stringify(v.id)}] = receipt; + return receipt; +} +` + } + + private emitStream(v: Verb): string { + const p = (v.params ?? {}) as StreamParams + const table = p.source?.config?.table ?? 'unknown_table' + const connection = p.source?.config?.connection + ? JSON.stringify(p.source.config.connection) + : CONNECTION_DEFAULT + return `async function ${v.id}(ctx: Ctx): Promise { + // STREAM: host should adapt this to async iteration over a real cursor. + const { default: pg } = await import("pg"); + const client = new pg.Client(${connection}); + await client.connect(); + try { + const r = await client.query(\`SELECT * FROM ${table}\`); + return r.rows; + } finally { await client.end(); } +} +` + } + private emitEmit(v: Verb): string { const p = (v.params ?? {}) as EmitParams const url = p.target?.config?.url ?? '' @@ -253,6 +418,250 @@ export async function run(input: any): Promise { // fire-and-forget per spec §3.6 return; } +` + } + + /** Emit the body of a target verb inline (no `async function` wrapper). + * Used by RETRY/COMPENSATE/ERROR to splat the wrapped target's behavior + * into the resilience verb's function. Indented one level (2 spaces) by + * callers; this returns raw lines without leading indentation. */ + private inlineBody(target: InlineTarget, indent: string = ' '): string { + const t = target ?? { verb: 'NOOP' } + switch (t.verb) { + case 'FETCH': { + const p = (t.params ?? {}) as FetchParams + const url = p.source?.config?.url ?? '' + return `${indent}const r = await fetch(${JSON.stringify(url)}, {}); return await r.json();` + } + case 'VALIDATE': { + const p = (t.params ?? {}) as ValidateParams + const rules = JSON.stringify(p.rules ?? []) + const action = p.on_fail?.action ?? 'flag' + return `${indent}const rules: any = ${rules}; +${indent}const v = (globalThis as any).__validator; +${indent}const ok = v ? v(rules)(ctx.input) : true; +${indent}if (!ok) throw new Error("VALIDATE failed (action=${action})"); +${indent}return ctx.input;` + } + case 'DELETE': { + const p = (t.params ?? {}) as DeleteParams + const table = p.sink?.config?.table ?? 'unknown_table' + const predicate = p.predicate ?? 'TRUE' + return `${indent}const { default: pg } = await import("pg"); +${indent}const client = new pg.Client(${CONNECTION_DEFAULT}); +${indent}await client.connect(); +${indent}try { +${indent} await client.query(\`DELETE FROM ${table} WHERE ${predicate}\`); +${indent}} finally { await client.end(); } +${indent}return __receipt("sql:${table}", false);` + } + case 'EMIT': { + const p = (t.params ?? {}) as EmitParams + const url = p.target?.config?.url ?? '' + const channel = p.target?.config?.channel ?? '' + return `${indent}const payload = ctx.input; +${indent}await fetch(${JSON.stringify(url)}, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ channel: ${JSON.stringify(channel)}, text: typeof payload === "string" ? payload : JSON.stringify(payload) }) }); +${indent}return;` + } + default: + return `${indent}// inline target verb '${t.verb}' not specialised — host required` + } + } + + private emitRetry(v: Verb): string { + const p = (v.params ?? {}) as RetryParams + const target = p.of ?? { verb: 'NOOP' } + const max = p.policy?.max ?? 3 + const delay = p.policy?.delay_ms ?? 1000 + const backoff = p.policy?.backoff ?? 'exponential' + const jitter = p.policy?.jitter ?? false + const errorRoute = p.error_route + const wrapStart = `async function ${v.id}(ctx: Ctx): Promise {` + const retryCall = ` return await __retry(async () => { +${this.inlineBody(target, ' ')} + }, ${max}, ${delay}, ${JSON.stringify(backoff)}, ${jitter});` + if (!errorRoute) { + return `${wrapStart} +${retryCall} +} +` + } + return `${wrapStart} + try { +${retryCall.replace(/^/gm, ' ').replace(/^ $/gm, '')} + } catch (e) { + await __execute(${JSON.stringify(errorRoute)}, ctx); + return ctx.values[${JSON.stringify(errorRoute)}]; + } +} +` + } + + private emitCompensate(v: Verb): string { + const p = (v.params ?? {}) as CompensateParams + const receiptFrom = p.receipt_from ?? '' + const reverse = p.reverse ?? { verb: 'NOOP' } + const reason = p.reason ?? '' + return `async function ${v.id}(ctx: Ctx): Promise { + const receipt = ctx.receipts[${JSON.stringify(receiptFrom)}]; + if (!receipt) throw new Error("COMPENSATE ${v.id}: no receipt for ${receiptFrom}"); + if (!receipt.reversible) throw new Error("COMPENSATE ${v.id}: receipt is non-reversible"); +${this.inlineBody(reverse, ' ')} +} +` + } + + private emitError(v: Verb): string { + const p = (v.params ?? {}) as ErrorParams + const target = p.of ?? { verb: 'NOOP' } + const catches = JSON.stringify(p.catch ?? []) + const handler = p.handler ?? { verb: 'NOOP' } + return `async function ${v.id}(ctx: Ctx): Promise { + try { +${this.inlineBody(target, ' ')} + } catch (e: any) { + const catches: string[] = ${catches}; + const matches = catches.some(t => e?.name === t || e?.code === t || (e?.constructor?.name === t)); + if (matches) { +${this.inlineBody(handler, ' ')} + } + throw e; + } +} +` + } + + private emitBranch(v: Verb): string { + const p = (v.params ?? {}) as BranchParams + const conds = (p.conditions ?? []).map( + (c) => + ` if (__predicate(${JSON.stringify(c.when ?? 'false')}, ctx.input)) return await __execute(${JSON.stringify(c.then ?? '')}, ctx);`, + ).join('\n') + const fallback = p.default + ? ` return await __execute(${JSON.stringify(p.default)}, ctx);` + : ' return;' + return `async function ${v.id}(ctx: Ctx): Promise { +${conds} +${fallback} +} +` + } + + private emitSplit(v: Verb): string { + const p = (v.params ?? {}) as SplitParams + const targets = JSON.stringify(p.targets ?? []) + const strategy = JSON.stringify(p.strategy ?? 'parallel') + return `async function ${v.id}(ctx: Ctx): Promise { + const targets: string[] = ${targets}; + const strategy = ${strategy}; + void strategy; // 'parallel' | 'broadcast' | 'partition' — host may inspect + const results = await Promise.all(targets.map(async (t) => { + const branchCtx: Ctx = { input: ctx.input, values: {}, receipts: {} }; + await __execute(t, branchCtx); + return branchCtx.values[t]; + })); + ctx.values[${JSON.stringify(v.id)} + ":branches"] = results; + return results; +} +` + } + + private emitMerge(v: Verb): string { + const p = (v.params ?? {}) as MergeParams + const sources = JSON.stringify(p.sources ?? []) + const strategy = p.strategy ?? 'all' + const splitId = p.from ?? '' + return `async function ${v.id}(ctx: Ctx): Promise { + const sources: string[] = ${sources}; + void sources; + const branches: any[] = ctx.values[${JSON.stringify(splitId)} + ":branches"] ?? []; + // strategy: ${strategy} + if (${JSON.stringify(strategy)} === "first") return branches[0]; + if (${JSON.stringify(strategy)} === "majority") { + const counts = new Map(); + for (const b of branches) { const k = JSON.stringify(b); counts.set(k, (counts.get(k) ?? 0) + 1); } + let best: string | undefined; let bestN = 0; + for (const [k, n] of counts) if (n > bestN) { bestN = n; best = k; } + return best ? JSON.parse(best) : null; + } + return branches.flat(); +} +` + } + + private emitGate(v: Verb): string { + const p = (v.params ?? {}) as GateParams + const prompt = p.prompt ?? '' + const options = JSON.stringify(p.options ?? []) + const actor = JSON.stringify(p.actor ?? {}) + const timeout = p.timeout_ms ?? 0 + const fallback = p.default_route ?? '' + return `async function ${v.id}(ctx: Ctx): Promise { + const gate = (globalThis as any).__gate; + const decision = gate ? await Promise.race([ + gate({ prompt: ${JSON.stringify(prompt)}, options: ${options}, actor: ${actor} }, ctx.input), + new Promise((resolve) => setTimeout(() => resolve(null), ${timeout || 'Number.MAX_SAFE_INTEGER'})), + ]) : null; + if (decision === null && ${JSON.stringify(fallback)}) { + await __execute(${JSON.stringify(fallback)}, ctx); + return ctx.values[${JSON.stringify(fallback)}]; + } + return decision; +} +` + } + + private emitAwait(v: Verb): string { + const p = (v.params ?? {}) as AwaitParams + const topic = p.event?.topic ?? '' + const timeout = p.timeout_ms ?? 0 + const route = p.timeout_route ?? '' + return `async function ${v.id}(ctx: Ctx): Promise { + const awaitFn = (globalThis as any).__await; + const ev = awaitFn ? await Promise.race([ + awaitFn({ topic: ${JSON.stringify(topic)} }, ctx), + new Promise((resolve) => setTimeout(() => resolve(null), ${timeout || 'Number.MAX_SAFE_INTEGER'})), + ]) : null; + if (ev === null && ${JSON.stringify(route)}) { + await __execute(${JSON.stringify(route)}, ctx); + return ctx.values[${JSON.stringify(route)}]; + } + return ev; +} +` + } + + private emitSignal(v: Verb): string { + const p = (v.params ?? {}) as SignalParams + const system = p.system ?? '' + const signal = JSON.stringify(p.signal ?? {}) + const timeout = p.timeout_ms ?? 0 + const route = p.timeout_route ?? '' + return `async function ${v.id}(ctx: Ctx): Promise { + const signalFn = (globalThis as any).__signal; + const r = signalFn ? await Promise.race([ + signalFn(${JSON.stringify(system)}, ${signal}, ctx), + new Promise((resolve) => setTimeout(() => resolve(null), ${timeout || 'Number.MAX_SAFE_INTEGER'})), + ]) : null; + if (r === null && ${JSON.stringify(route)}) { + await __execute(${JSON.stringify(route)}, ctx); + return ctx.values[${JSON.stringify(route)}]; + } + return r; +} +` + } + + private emitTerminate(v: Verb): string { + const p = (v.params ?? {}) as TerminateParams + const reason = p.reason ?? '' + const status = p.status ?? 'failed' + const cleanup = JSON.stringify(p.cleanup ?? []) + return `async function ${v.id}(ctx: Ctx): Promise { + const cleanup: string[] = ${cleanup}; + for (const cid of cleanup) await __execute(cid, ctx); + throw new __TerminateError(${JSON.stringify(reason)}, ${JSON.stringify(status)}); +} ` } } diff --git a/packages/pro/src/resolvers/vue/backend.ts b/packages/pro/src/resolvers/vue/backend.ts new file mode 100644 index 0000000..983f065 --- /dev/null +++ b/packages/pro/src/resolvers/vue/backend.ts @@ -0,0 +1,351 @@ +import { BaseResolver, type VerbHandler } from '../core/resolver.js' +import { hasInteractiveVerbs, partitionVerbs, type Composition, type ComputeVerb, type SurfaceVerb, type Verb } from '../core/ir.js' + +type MonitorParams = { + subject?: string + data?: unknown + refresh?: { value?: number; unit?: string } +} +type ArrangeParams = { type?: string; density?: string; children?: readonly string[] } +type DisplayParams = { type?: string; data?: string; emphasis?: string; label?: string } +type StatusParams = { type?: string; message?: string } +type GuideParams = { type?: string; message?: string; priority?: string } + +type DecideParams = { prompt?: string; options?: { value?: string; label?: string }[] } +type ConfigureParams = { prompt?: string; params?: { key?: string; type?: string; label?: string }[] } +type ExploreParams = { prompt?: string; source?: string; placeholder?: string } +type AuthorParams = { prompt?: string; schema?: string; save_mode?: string } +type OnboardParams = { prompt?: string; steps?: { id?: string; label?: string }[]; progress_type?: string } +type CollectParams = { type?: string; label?: string; name?: string; required?: boolean } + +const PREAMBLE_READONLY = `', + ] + return lines.join('\n') + '\n' + } + + const statusLines = statuses.map((s) => ` ${s.id}(props.state, props.dispatch),`) + const guideLines = guides.map((g) => ` ${g.id}(props.state, props.dispatch),`) + const childLines = children.map((childId) => ` ${childId}(props.state, props.dispatch),`) + const lines: string[] = [ + `defineRender(() => ${monitor.id}([`, + ...statusLines, + ...guideLines, + ` ${arrange.id}([`, + ...childLines, + ' ]),', + ']));', + '', + ] + return lines.join('\n') + '\n' + } + + private emitMonitor(v: Verb): string { + const p = (v.params ?? {}) as MonitorParams + const subject = p.subject ?? '' + const refresh = p.refresh ? `${p.refresh.value}${p.refresh.unit}` : '' + return `function ${v.id}(children: readonly (VNode | null)[]): VNode { + return h('main', { + 'data-monitor': '${v.id}', + 'data-subject': '${subject}', + 'data-refresh': '${refresh}', + }, children.filter((c): c is VNode => c !== null)); +} +` + } + + private emitArrange(v: Verb): string { + const p = (v.params ?? {}) as ArrangeParams + const type = p.type ?? '' + const density = p.density ?? '' + const childrenAttr = (p.children ?? []).join(',') + return `function ${v.id}(children: readonly VNode[]): VNode { + return h('div', { + 'data-arrange': '${type}', + 'data-density': '${density}', + 'data-children': '${childrenAttr}', + }, children); +} +` + } + + private displaySig(): string { + return this.interactive + ? `(state: DashboardState, dispatch: DashboardDispatch)` + : `(state: DashboardState)` + } + + private emitDisplay(v: Verb): string { + const p = (v.params ?? {}) as DisplayParams + const type = p.type ?? '' + const emphasis = p.emphasis ?? '' + const dataKey = p.data ?? '' + const propsObj = `{ 'data-display': '${v.id}', 'data-type': '${type}', 'data-emphasis': '${emphasis}' }` + const sig = this.displaySig() + if (type === 'metric') { + const label = p.label ?? '' + return `function ${v.id}${sig}: VNode { + return h('section', ${propsObj}, [ + h('header', null, '${label}'), + h('strong', null, String(state.values['${dataKey}'] ?? '—')), + ]); +} +` + } + if (type === 'chart') { + return `function ${v.id}${sig}: VNode { + return h('section', ${propsObj}, [ + h('pre', null, JSON.stringify(state.values['${dataKey}'] ?? null, null, 2)), + ]); +} +` + } + return `function ${v.id}${sig}: VNode { + return h('section', ${propsObj}, [ + /* unhandled DISPLAY.type='${type}' */ + ]); +} +` + } + + private emitStatus(v: Verb): string { + const p = (v.params ?? {}) as StatusParams + const type = p.type ?? '' + const message = p.message ?? '' + return `function ${v.id}${this.displaySig()}: VNode | null { + if (state.status !== '${type}') return null; + return h('div', { role: 'status', 'aria-live': 'polite', 'data-status': '${type}' }, '${message}'); +} +` + } + + private emitGuide(v: Verb): string { + const p = (v.params ?? {}) as GuideParams + const message = p.message ?? '' + const priority = p.priority ?? '' + return `function ${v.id}${this.displaySig()}: VNode | null { + if (!state.alerts.includes('${v.id}')) return null; + return h('div', { role: 'alert', 'data-guide': '${v.id}', 'data-priority': '${priority}' }, '${message}'); +} +` + } + + private emitDecide(v: Verb): string { + const p = (v.params ?? {}) as DecideParams + const prompt = p.prompt ?? '' + const opts = p.options ?? [] + const buttons = opts.map((o) => + ` h('button', { + type: 'button', + 'data-option': '${o.value ?? ''}', + onClick: () => dispatch({ verb: 'DECIDE', id: '${v.id}', action: 'select', payload: '${o.value ?? ''}' }), + }, '${o.label ?? ''}'),`, + ).join('\n') + return `function ${v.id}(state: DashboardState, dispatch: DashboardDispatch): VNode { + return h('form', { 'data-decide': '${v.id}', 'data-prompt': '${prompt}' }, [ +${buttons} + ]); +} +` + } + + private emitConfigure(v: Verb): string { + const p = (v.params ?? {}) as ConfigureParams + const prompt = p.prompt ?? '' + const params = p.params ?? [] + const inputs = params.map((pa) => + ` h('label', null, [ + '${pa.label ?? ''}', + h('input', { + name: '${pa.key ?? ''}', + type: '${pa.type ?? 'text'}', + value: String(state.drafts['${pa.key ?? ''}'] ?? state.values['${pa.key ?? ''}'] ?? ''), + onChange: (e: Event) => dispatch({ verb: 'CONFIGURE', id: '${v.id}', action: 'set', payload: { ${pa.key ?? ''}: (e.target as HTMLInputElement).value } }), + }), + ]),`, + ).join('\n') + return `function ${v.id}(state: DashboardState, dispatch: DashboardDispatch): VNode { + return h('form', { 'data-configure': '${v.id}', 'data-prompt': '${prompt}' }, [ +${inputs} + h('button', { type: 'button', onClick: () => dispatch({ verb: 'CONFIGURE', id: '${v.id}', action: 'apply', payload: state.drafts }) }, 'Apply'), + ]); +} +` + } + + private emitExplore(v: Verb): string { + const p = (v.params ?? {}) as ExploreParams + const prompt = p.prompt ?? '' + const source = p.source ?? '' + const placeholder = p.placeholder ?? '' + return `function ${v.id}(state: DashboardState, dispatch: DashboardDispatch): VNode { + const results = (state.values['${source}'] as readonly { id: string; label: string }[] | undefined) ?? []; + return h('section', { 'data-explore': '${v.id}', 'data-prompt': '${prompt}', 'data-source': '${source}' }, [ + h('input', { + type: 'search', + placeholder: '${placeholder}', + value: String(state.drafts['${v.id}'] ?? ''), + onChange: (e: Event) => dispatch({ verb: 'EXPLORE', id: '${v.id}', action: 'query', payload: (e.target as HTMLInputElement).value }), + }), + h('ul', { role: 'listbox' }, results.map((r) => h('li', { key: r.id, 'data-result-id': r.id }, r.label))), + ]); +} +` + } + + private emitAuthor(v: Verb): string { + const p = (v.params ?? {}) as AuthorParams + const prompt = p.prompt ?? '' + const schema = p.schema ?? '' + const saveMode = p.save_mode ?? 'manual' + return `function ${v.id}(state: DashboardState, dispatch: DashboardDispatch): VNode { + return h('form', { 'data-author': '${v.id}', 'data-prompt': '${prompt}', 'data-schema': '${schema}', 'data-save-mode': '${saveMode}' }, [ + h('output', { 'data-schema-ref': '${schema}' }, String(state.drafts['${v.id}'] ?? state.values['${v.id}'] ?? '')), + h('textarea', { + name: '${v.id}', + value: String(state.drafts['${v.id}'] ?? state.values['${v.id}'] ?? ''), + onChange: (e: Event) => dispatch({ verb: 'AUTHOR', id: '${v.id}', action: 'edit', payload: (e.target as HTMLTextAreaElement).value }), + }), + h('button', { type: 'button', onClick: () => dispatch({ verb: 'AUTHOR', id: '${v.id}', action: 'save', payload: state.drafts['${v.id}'] }) }, 'Save'), + ]); +} +` + } + + private emitOnboard(v: Verb): string { + const p = (v.params ?? {}) as OnboardParams + const prompt = p.prompt ?? '' + const progressType = p.progress_type ?? 'step' + const steps = p.steps ?? [] + const stepLines = steps.map((s) => + ` h('li', { 'data-step': '${s.id ?? ''}', 'data-active': String(current === '${s.id ?? ''}') }, '${s.label ?? ''}'),`, + ).join('\n') + const firstStep = steps[0]?.id ?? '' + return `function ${v.id}(state: DashboardState, dispatch: DashboardDispatch): VNode { + const current = String(state.selections['${v.id}'] ?? '${firstStep}'); + return h('nav', { 'data-onboard': '${v.id}', 'data-prompt': '${prompt}', 'data-progress-type': '${progressType}' }, [ + h('ol', null, [ +${stepLines} + ]), + h('button', { type: 'button', 'data-step-action': 'advance', onClick: () => dispatch({ verb: 'ONBOARD', id: '${v.id}', action: 'advance', payload: current }) }, 'Next'), + h('button', { type: 'button', 'data-step-action': 'skip', onClick: () => dispatch({ verb: 'ONBOARD', id: '${v.id}', action: 'skip', payload: current }) }, 'Skip'), + ]); +} +` + } + + private emitCollect(v: Verb): string { + const p = (v.params ?? {}) as CollectParams + const type = p.type ?? 'text' + const label = p.label ?? '' + const name = p.name ?? '' + const required = p.required ?? false + return `function ${v.id}(state: DashboardState, dispatch: DashboardDispatch): VNode { + return h('label', { 'data-collect': '${v.id}', 'data-collect-type': '${type}' }, [ + '${label}', + h('input', { + type: '${type}', + name: '${name}', + required: ${required}, + value: String(state.drafts['${name}'] ?? ''), + onChange: (e: Event) => dispatch({ verb: 'COLLECT', id: '${v.id}', action: 'input', payload: (e.target as HTMLInputElement).value }), + }), + ]); +} +` + } +} diff --git a/packages/pro/tests/api-smoke.test.ts b/packages/pro/tests/api-smoke.test.ts index 3eec5f5..ee472af 100644 --- a/packages/pro/tests/api-smoke.test.ts +++ b/packages/pro/tests/api-smoke.test.ts @@ -10,9 +10,11 @@ import { mapExpr, reduceExpr, partitionVerbs, isSurfaceVerb, } from '../src/index.js' describe('public API', () => { - it('exposes 7 registered backends including react', () => { + it('exposes 9 registered backends including react/vue/svelte', () => { expect(availableTargets()).toContain('react') - expect(availableTargets()).toHaveLength(7) + expect(availableTargets()).toContain('vue') + expect(availableTargets()).toContain('svelte') + expect(availableTargets()).toHaveLength(9) }) it('has resolve, resolveAll, registerResolver, BaseResolver as callable values', () => { expect(typeof resolve).toBe('function') diff --git a/packages/pro/tests/resolvers/__fixtures__/REACT_INTERACTIVE.md b/packages/pro/tests/resolvers/__fixtures__/REACT_INTERACTIVE.md new file mode 100644 index 0000000..ce4d550 --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/REACT_INTERACTIVE.md @@ -0,0 +1,76 @@ +# REACT_INTERACTIVE — interactive surface fixture conventions + +**Status:** APPROVED — frozen 2026-04-26 (W6 gate-2a) +**Scope:** rules the React surface backend reproduces byte-for-byte when emitting `expected-interactive/react/*.tsx` for compositions containing the 6 outcome verbs (DECIDE, CONFIGURE, EXPLORE, AUTHOR, ONBOARD, COLLECT). +**Authority:** byte-exact equality against `expected-interactive/react/Dashboard.tsx`. +**Sibling contract:** REACT_STYLE.md (frozen 2026-04-18 SUR-000) governs read-only compositions and shared React conventions. This document only documents what's *new* for interactive compositions; everything else carries over. + +This document is frozen as contract on owner ack. Changes require a new owner-approval cycle AND a matching fixture-file update in the same commit. + +## Detection rule + +A composition is "interactive" iff it contains any verb in `{DECIDE, CONFIGURE, EXPLORE, AUTHOR, ONBOARD, COLLECT}`. Detection is a pure function of the composition's verbs (no external flags). Read-only compositions (only MONITOR/ARRANGE/DISPLAY/STATUS/GUIDE) keep the existing REACT_STYLE.md emission byte-exact — verified by the W3 fixture pin. + +## DashboardState v2 + +Interactive compositions emit an extended state type: + +```ts +export type DashboardState = { + values: Record; + status: 'idle' | 'loading' | 'empty'; + alerts: readonly string[]; + selections: Record; // DECIDE/EXPLORE/ONBOARD picks + drafts: Record; // AUTHOR/COLLECT/CONFIGURE in-progress edits +}; +``` + +Read-only compositions keep the original 3-field type unchanged. + +## Dispatch convention + +```ts +export type DashboardDispatch = (event: { verb: string; id: string; action: string; payload: unknown }) => void; +``` + +`dispatch` is a positional second argument on every helper in interactive compositions: +- Top-level `Dashboard(state, dispatch)` exported from the file. +- Per-verb helpers all take `(state, dispatch)` even if they don't use dispatch (DISPLAY/STATUS/GUIDE included). Uniform call shape simplifies emission. +- Read-only compositions: helpers take `(state)` only — REACT_STYLE.md unchanged. + +## Per-verb HTML contract + +Each interactive verb emits one helper function returning `ReactNode` and rendering a single root element with `data-=''`: + +| Verb | Root element | Key data attrs | Children + behavior | +|---|---|---|---| +| `DECIDE` | `
    ` | `data-decide='' data-prompt=''` | one `` at end; per-input `change`→`set`, button → `apply` | +| `EXPLORE` | `
    ` | `data-explore='' data-prompt='' data-source=''` | `` + `
      ` of results from `state.values[]`; search `change` → `query` | +| `AUTHOR` | `` | `data-author='' data-prompt='' data-schema='' data-save-mode=''` | ``, ` + + +{/snippet} + +{#snippet admin_setup(state: DashboardState, dispatch: DashboardDispatch)} + {@const current = String(state.selections['admin_setup'] ?? 'profile')} + +{/snippet} + +{#snippet admin_email(state: DashboardState, dispatch: DashboardDispatch)} + +{/snippet} + +{#snippet tabs(state: DashboardState, dispatch: DashboardDispatch)} +
      + {@render threshold_form(state, dispatch)} + {@render approval_choice(state, dispatch)} + {@render audit_search(state, dispatch)} + {@render rule_editor(state, dispatch)} + {@render admin_setup(state, dispatch)} + {@render admin_email(state, dispatch)} +
      +{/snippet} + +{#snippet admin_view(state: DashboardState, dispatch: DashboardDispatch)} +
      + {@render tabs(state, dispatch)} +
      +{/snippet} + +{@render admin_view(state, dispatch)} diff --git a/packages/pro/tests/resolvers/__fixtures__/expected-interactive/vue/Dashboard.vue b/packages/pro/tests/resolvers/__fixtures__/expected-interactive/vue/Dashboard.vue new file mode 100644 index 0000000..155b7f7 --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected-interactive/vue/Dashboard.vue @@ -0,0 +1,135 @@ + + + + diff --git a/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/python/resilient_etl.py b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/python/resilient_etl.py new file mode 100644 index 0000000..c8010d9 --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/python/resilient_etl.py @@ -0,0 +1,168 @@ +""" +Generated by NOMARK Intents resolver (target: python). +Composition: resilient_etl v0.4.0 +ETL with retried fetch, error-handled validate, persisted records, and a compensating undo for the persist step. +""" +from __future__ import annotations +import asyncio +import dataclasses +import json +import os +import random +import re +import time +import uuid +from typing import Any, Callable, Dict, List, Optional +@dataclasses.dataclass +class Ctx: + input: Any = None + values: Dict[str, Any] = dataclasses.field(default_factory=dict) + receipts: Dict[str, Dict[str, Any]] = dataclasses.field(default_factory=dict) +async def _retry(fn: Callable[[], Any], max_attempts: int, delay_ms: int, + backoff: str = "exponential", jitter: bool = False) -> Any: + last_err: Optional[BaseException] = None + for attempt in range(max_attempts): + try: + return await fn() + except Exception as e: + last_err = e + base = delay_ms * (2 ** attempt) if backoff == "exponential" else delay_ms * (attempt + 1) + wait = base * (0.5 + random.random()) if jitter else base + await asyncio.sleep(wait / 1000.0) + raise last_err # type: ignore[misc] +def _receipt(sink: str, reversible: bool = True) -> Dict[str, Any]: + return { + "id": str(uuid.uuid4()), + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "sink": sink, + "reversible": reversible, + } +def _predicate(expr: str, row: Any) -> bool: + """Opaque expression evaluator — spec §3.1. Resolver chose Python eval.""" + return bool(eval(expr, {"__builtins__": {}}, {"row": row, "r": row})) +def _project(expr: str, input: Any) -> Any: + return eval(expr, {"__builtins__": {"len": len, "sum": sum, "min": min, "max": max}}, + {"input": input, "row": input, "r": input}) +def _reduce(expr: str, items: List[Any], initial: Any) -> Any: + m = re.match(r"^group_by\(([^)]+)\)\.(sum|count|avg|min|max)\(([^)]*)\)$", expr) + if m: + key_expr, op, field = m.group(1), m.group(2), m.group(3) + groups: Dict[Any, List[Any]] = {} + for row in items: + k = _project(key_expr, row) + groups.setdefault(k, []).append(row) + out: Dict[str, Any] = {} + for k, rows in groups.items(): + if op == "count": + out[str(k)] = len(rows) + continue + vals = [_project(field, r) for r in rows] + if op == "sum": out[str(k)] = sum(vals) + elif op == "avg": out[str(k)] = sum(vals) / len(vals) + elif op == "min": out[str(k)] = min(vals) + elif op == "max": out[str(k)] = max(vals) + return out + m2 = re.match(r"^(sum|count|avg|min|max)\(([^)]*)\)$", expr) + if m2: + op, field = m2.group(1), m2.group(2) + if op == "count": return len(items) + vals = [_project(field, r) for r in items] + if op == "sum": return sum(vals) + if op == "avg": return sum(vals) / len(vals) if vals else 0 + if op == "min": return min(vals) if vals else None + if op == "max": return max(vals) if vals else None + # generic lambda fallback: expr must name 'acc' and 'row' + acc = initial + for row in items: + acc = eval(expr, {"__builtins__": {}}, {"acc": acc, "row": row}) + return acc +async def fetch_with_retry(ctx: Ctx) -> Any: + async def _attempt(): + import httpx + async with httpx.AsyncClient() as client: + r = await client.request("GET", "https://api.example.com/records", json=None) + r.raise_for_status() + return r.json() + return await _retry(_attempt, 3, 1000, "exponential", True) + + +async def safe_validate(ctx: Ctx) -> Any: + try: + rules = [{"field": "id", "check": "required"}] + validator = globals().get("__validator__") + ok = validator(rules, ctx.input) if validator else True + if not ok: + raise ValueError("VALIDATE failed (action=reject)") + return ctx.input + except Exception as e: + catches = ["ValidationError", "ValueError"] + if any(t == type(e).__name__ or t == getattr(e, "code", None) for t in catches): + payload = ctx.input + import httpx + async with httpx.AsyncClient() as client: + body = {"channel": "#alerts", "text": payload if isinstance(payload, str) else json.dumps(payload)} + await client.post("", json=body) + return None + raise + + +async def persist_data(ctx: Ctx) -> Any: + input = ctx.input + import asyncpg + conn = await asyncpg.connect(os.environ.get("DATABASE_URL", "")) + try: + rows = input if isinstance(input, list) else [input] + for row in rows: + keys = list(row.keys()) + placeholders = ", ".join(f"$" + str(i+1) for i in range(len(keys))) + cols = ", ".join(keys) + sql = f'INSERT INTO records ({cols}) VALUES ({placeholders})' + await conn.execute(sql, *[row[k] for k in keys]) + finally: + await conn.close() + receipt = _receipt("sql:records") + ctx.receipts["persist_data"] = receipt + return receipt + + +async def rollback_persist(ctx: Ctx) -> Any: + receipt = ctx.receipts.get("persist_data") + if not receipt: + raise RuntimeError("COMPENSATE rollback_persist: no receipt for persist_data") + if not receipt.get("reversible"): + raise RuntimeError("COMPENSATE rollback_persist: receipt is non-reversible") + import asyncpg + conn = await asyncpg.connect(os.environ.get("DATABASE_URL", "")) + try: + await conn.execute('DELETE FROM records WHERE id = $1') + finally: + await conn.close() + return _receipt("sql:records", False) + +async def _execute(vid: str, ctx: Ctx) -> None: + if vid == "fetch_with_retry": + ctx.values["fetch_with_retry"] = await fetch_with_retry(ctx) + await _execute("safe_validate", ctx) + return + if vid == "safe_validate": + ctx.values["safe_validate"] = await safe_validate(ctx) + await _execute("persist_data", ctx) + return + if vid == "persist_data": + ctx.values["persist_data"] = await persist_data(ctx) + await _execute("rollback_persist", ctx) + return + if vid == "rollback_persist": + ctx.values["rollback_persist"] = await rollback_persist(ctx) + return + return + raise ValueError(f"unknown verb id: {vid}") + +async def run(input: Any = None) -> Ctx: + ctx = Ctx(input=input) + await _execute("fetch_with_retry", ctx) + return ctx + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/rust/Cargo.toml b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/rust/Cargo.toml new file mode 100644 index 0000000..3acc334 --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/rust/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "resilient_etl" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +async-recursion = "1" +chrono = { version = "0.4", features = ["clock"] } +rand = "0.8" +regex = "1" +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "json", "chrono"] } +tokio = { version = "1", features = ["full"] } +uuid = { version = "1", features = ["v4"] } diff --git a/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/rust/resilient_etl.rs b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/rust/resilient_etl.rs new file mode 100644 index 0000000..1c424f7 --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/rust/resilient_etl.rs @@ -0,0 +1,246 @@ +// Generated by NOMARK Intents resolver (target: rust) +// Composition: resilient_etl v0.4.0 +// ETL with retried fetch, error-handled validate, persisted records, and a compensating undo for the persist step. +use std::collections::HashMap; +use std::time::Duration as StdDuration; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use tokio::time::sleep; +#[derive(Debug, Clone)] +pub struct Receipt { + pub id: String, + pub timestamp: String, + pub sink: String, + pub reversible: bool, +} +#[derive(Debug, Default)] +pub struct Ctx { + pub input: Value, + pub values: HashMap, + pub receipts: HashMap, +} +fn new_receipt(sink: &str, reversible: bool) -> Receipt { + Receipt { + id: uuid::Uuid::new_v4().to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + sink: sink.to_string(), + reversible, + } +} +async fn retry(mut f: F, max: u32, delay_ms: u64, backoff: &str, jitter: bool) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let mut last: Option = None; + for attempt in 0..max { + match f().await { + Ok(v) => return Ok(v), + Err(e) => last = Some(e), + } + let base = if backoff == "exponential" { + delay_ms.saturating_mul(1u64 << attempt.min(20)) + } else { + delay_ms.saturating_mul((attempt + 1) as u64) + }; + let wait = if jitter { + let r: f64 = rand::random(); + ((base as f64) * (0.5 + r)) as u64 + } else { base }; + sleep(StdDuration::from_millis(wait)).await; + } + Err(last.unwrap_or_else(|| anyhow!("retry exhausted with no error recorded"))) +} +/// Opaque predicate evaluation. Resolver choice: evalexpr over serde_json::Value. +/// Supports comparisons, arithmetic, &&, ||, !, and dotted row.field paths. +fn predicate(expr: &str, row: &Value) -> bool { + eval_bool(expr, row).unwrap_or(false) +} +fn eval_bool(expr: &str, row: &Value) -> Option { + // Minimal predicate engine — supports: where op in == != < <= > >= and literal null check. + for op in ["==", "!=", "<=", ">=", "<", ">"] { + if let Some(idx) = expr.find(op) { + let lhs = expr[..idx].trim(); + let rhs = expr[idx + op.len()..].trim(); + let a = resolve_expr(lhs, row); + let b = resolve_expr(rhs, row); + return Some(match op { + "==" => a == b, + "!=" => a != b, + "<" => cmp(&a, &b) == Some(std::cmp::Ordering::Less), + "<=" => matches!(cmp(&a, &b), Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)), + ">" => cmp(&a, &b) == Some(std::cmp::Ordering::Greater), + ">=" => matches!(cmp(&a, &b), Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)), + _ => false, + }); + } + } + // Fallback: truthiness of row. + Some(!resolve_expr(expr.trim(), row).is_null()) +} +fn cmp(a: &Value, b: &Value) -> Option { + match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => x.partial_cmp(&y), + _ => a.as_str().and_then(|x| b.as_str().map(|y| x.cmp(y))), + } +} +fn resolve_expr(e: &str, row: &Value) -> Value { + let e = e.trim(); + if let Ok(n) = e.parse::() { return json!(n); } + if e.starts_with('"') && e.ends_with('"') { return json!(e.trim_matches('"')); } + if e == "null" { return Value::Null; } + if e == "true" { return json!(true); } + if e == "false" { return json!(false); } + // dotted path: row.field.subfield or input.field + let path = if let Some(s) = e.strip_prefix("row.") { s } + else if let Some(s) = e.strip_prefix("input.") { s } + else { e }; + let mut cur = row; + for seg in path.split('.') { + cur = match cur.get(seg) { Some(v) => v, None => return Value::Null }; + } + cur.clone() +} +fn reduce_agg(expr: &str, items: &[Value]) -> Value { + // group_by(key).op(field) + let re_group = regex::Regex::new(r"^group_by\(([^)]+)\)\.(sum|count|avg|min|max)\(([^)]*)\)$").unwrap(); + if let Some(caps) = re_group.captures(expr) { + let key = caps.get(1).unwrap().as_str(); + let op = caps.get(2).unwrap().as_str(); + let field = caps.get(3).unwrap().as_str(); + let mut groups: HashMap> = HashMap::new(); + for row in items { + let k = resolve_expr(key, row); + groups.entry(k.to_string()).or_default().push(row.clone()); + } + let mut out = serde_json::Map::new(); + for (k, rows) in groups { + let val = match op { + "count" => json!(rows.len()), + "sum" => json!(rows.iter().filter_map(|r| resolve_expr(field, r).as_f64()).sum::()), + "avg" => { + let vs: Vec = rows.iter().filter_map(|r| resolve_expr(field, r).as_f64()).collect(); + if vs.is_empty() { json!(0) } else { json!(vs.iter().sum::() / vs.len() as f64) } + }, + "min" => json!(rows.iter().filter_map(|r| resolve_expr(field, r).as_f64()).fold(f64::INFINITY, f64::min)), + "max" => json!(rows.iter().filter_map(|r| resolve_expr(field, r).as_f64()).fold(f64::NEG_INFINITY, f64::max)), + _ => Value::Null, + }; + out.insert(k, val); + } + return Value::Object(out); + } + let re_single = regex::Regex::new(r"^(sum|count|avg|min|max)\(([^)]*)\)$").unwrap(); + if let Some(caps) = re_single.captures(expr) { + let op = caps.get(1).unwrap().as_str(); + let field = caps.get(2).unwrap().as_str(); + if op == "count" { return json!(items.len()); } + let vals: Vec = items.iter().filter_map(|r| resolve_expr(field, r).as_f64()).collect(); + return match op { + "sum" => json!(vals.iter().sum::()), + "avg" => if vals.is_empty() { json!(0) } else { json!(vals.iter().sum::() / vals.len() as f64) }, + "min" => json!(vals.iter().cloned().fold(f64::INFINITY, f64::min)), + "max" => json!(vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max)), + _ => Value::Null, + }; + } + Value::Null +} +async fn fetch_with_retry(ctx: &mut Ctx) -> Result { + let attempt = || async { + let r = reqwest::Client::new().request(reqwest::Method::from_bytes("GET".as_bytes())?, "https://api.example.com/records").send().await?.error_for_status()?; + Ok(r.json::().await?) + }; + retry(attempt, 3, 1000, "exponential", true).await +} + +async fn safe_validate(ctx: &mut Ctx) -> Result { + let target = || async { + let ok = true; + if !ok { anyhow::bail!("VALIDATE failed (action=reject)"); } + Ok(ctx.input.clone()) + }; + match target().await { + Ok(v) => Ok(v), + Err(e) => { + let catches: Vec<&str> = serde_json::from_str("[\"ValidationError\",\"ValueError\"]")?; + let name = format!("{:?}", e); + if catches.iter().any(|t| name.contains(t)) { + let payload = ctx.input.clone(); + let body = json!({ "channel": "#alerts", "text": payload }); + reqwest::Client::new().post("").json(&body).send().await?; + Ok(Value::Null) + } else { Err(e) } + } + } +} + +async fn persist_data(ctx: &mut Ctx) -> Result { + let input = ctx.input.clone(); + { + let pool = sqlx::postgres::PgPoolOptions::new().connect(&std::env::var("DATABASE_URL")?).await?; + let rows = if input.is_array() { input.as_array().cloned().unwrap_or_default() } else { vec![input.clone()] }; + for row in rows { + let obj = row.as_object().ok_or_else(|| anyhow!("row not an object"))?; + let keys: Vec<&String> = obj.keys().collect(); + let placeholders: Vec = (1..=keys.len()).map(|i| format!("${i}")).collect(); + let cols = keys.iter().map(|k| k.as_str()).collect::>().join(", "); + let sql = format!("INSERT INTO records ({}) VALUES ({})", cols, placeholders.join(", ")); + let mut q = sqlx::query(&sql); + for k in &keys { + q = q.bind(obj.get(k.as_str()).cloned().unwrap_or(Value::Null)); + } + q.execute(&pool).await?; + } + } + let receipt = new_receipt("sql:records", true); + ctx.receipts.insert("persist_data".to_string(), receipt.clone()); + Ok(json!({ "id": receipt.id, "timestamp": receipt.timestamp, "sink": receipt.sink, "reversible": receipt.reversible })) +} + +async fn rollback_persist(ctx: &mut Ctx) -> Result { + let receipt = ctx.receipts.get("persist_data").cloned() + .ok_or_else(|| anyhow!("COMPENSATE rollback_persist: no receipt for persist_data"))?; + if !receipt.reversible { anyhow::bail!("COMPENSATE rollback_persist: receipt is non-reversible"); } + let pool = sqlx::postgres::PgPoolOptions::new().connect(&std::env::var("DATABASE_URL")?).await?; + sqlx::query("DELETE FROM records WHERE id = $1").execute(&pool).await?; + let receipt = new_receipt("sql:records", false); + Ok(json!({ "id": receipt.id, "timestamp": receipt.timestamp, "sink": receipt.sink, "reversible": receipt.reversible })) +} + +#[async_recursion::async_recursion] +async fn _execute(id: &str, ctx: &mut Ctx) -> Result<()> { + match id { + "fetch_with_retry" => { + let r = fetch_with_retry(ctx).await?; + ctx.values.insert("fetch_with_retry".to_string(), r); + Box::pin(_execute("safe_validate", ctx)).await?; + Ok(()) + } + "safe_validate" => { + let r = safe_validate(ctx).await?; + ctx.values.insert("safe_validate".to_string(), r); + Box::pin(_execute("persist_data", ctx)).await?; + Ok(()) + } + "persist_data" => { + let r = persist_data(ctx).await?; + ctx.values.insert("persist_data".to_string(), r); + Box::pin(_execute("rollback_persist", ctx)).await?; + Ok(()) + } + "rollback_persist" => { + let r = rollback_persist(ctx).await?; + ctx.values.insert("rollback_persist".to_string(), r); + + Ok(()) + } + other => Err(anyhow!("unknown verb id: {}", other)), + } +} + +pub async fn run(input: Value) -> Result { + let mut ctx = Ctx { input, ..Default::default() }; + _execute("fetch_with_retry", &mut ctx).await?; + Ok(ctx) +} \ No newline at end of file diff --git a/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/sql-mysql/resilient_etl.sql b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/sql-mysql/resilient_etl.sql new file mode 100644 index 0000000..1cde7d8 --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/sql-mysql/resilient_etl.sql @@ -0,0 +1,13 @@ +-- Generated by NOMARK Intents resolver (target: sql/mysql) +-- Composition: resilient_etl v0.4.0 +-- ETL with retried fetch, error-handled validate, persisted records, and a compensating undo for the persist step. +BEGIN; +-- RETRY fetch_with_retry: not expressible in SQL compute. Host runtime required. + +-- ERROR safe_validate: try/catch + handler routing handled by host runtime. + +-- PERSIST persist_data (mode: insert) +INSERT INTO `records` (`*unknown_columns*`) VALUES (?); + +-- COMPENSATE rollback_persist: receipt tracking + reverse op handled by host runtime. +COMMIT; \ No newline at end of file diff --git a/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/sql-postgres/resilient_etl.sql b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/sql-postgres/resilient_etl.sql new file mode 100644 index 0000000..29350a8 --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/sql-postgres/resilient_etl.sql @@ -0,0 +1,13 @@ +-- Generated by NOMARK Intents resolver (target: sql/postgres) +-- Composition: resilient_etl v0.4.0 +-- ETL with retried fetch, error-handled validate, persisted records, and a compensating undo for the persist step. +BEGIN; +-- RETRY fetch_with_retry: not expressible in SQL compute. Host runtime required. + +-- ERROR safe_validate: try/catch + handler routing handled by host runtime. + +-- PERSIST persist_data (mode: insert) +INSERT INTO "records" ("*unknown_columns*") VALUES ($1); + +-- COMPENSATE rollback_persist: receipt tracking + reverse op handled by host runtime. +COMMIT; \ No newline at end of file diff --git a/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/sql-sqlite/resilient_etl.sql b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/sql-sqlite/resilient_etl.sql new file mode 100644 index 0000000..c909072 --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/sql-sqlite/resilient_etl.sql @@ -0,0 +1,13 @@ +-- Generated by NOMARK Intents resolver (target: sql/sqlite) +-- Composition: resilient_etl v0.4.0 +-- ETL with retried fetch, error-handled validate, persisted records, and a compensating undo for the persist step. +BEGIN; +-- RETRY fetch_with_retry: not expressible in SQL compute. Host runtime required. + +-- ERROR safe_validate: try/catch + handler routing handled by host runtime. + +-- PERSIST persist_data (mode: insert) +INSERT INTO "records" ("*unknown_columns*") VALUES (?); + +-- COMPENSATE rollback_persist: receipt tracking + reverse op handled by host runtime. +COMMIT; \ No newline at end of file diff --git a/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/typescript/resilient_etl.ts b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/typescript/resilient_etl.ts new file mode 100644 index 0000000..abb341f --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected-resilience-verbs/typescript/resilient_etl.ts @@ -0,0 +1,157 @@ +// Generated by NOMARK Intents resolver (target: typescript) +// Composition: resilient_etl v0.4.0 +// ETL with retried fetch, error-handled validate, persisted records, and a compensating undo for the persist step. +import { setTimeout as sleep } from "node:timers/promises"; +/** Runtime context passed between verbs — values produced by prior verbs are stored here. */ +export interface Ctx { + input: any; + values: Record; + receipts: Record; +} +function newCtx(input: any): Ctx { + return { input, values: {}, receipts: {} }; +} +async function __retry(fn: () => Promise, max: number, delayMs: number, backoff: "linear"|"exponential", jitter: boolean): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < max; attempt++) { + try { return await fn(); } catch (e) { lastErr = e; } + const base = backoff === "exponential" ? delayMs * Math.pow(2, attempt) : delayMs * (attempt + 1); + const wait = jitter ? base * (0.5 + Math.random()) : base; + await sleep(wait); + } + throw lastErr; +} +function __receipt(sink: string, reversible = true) { + return { id: crypto.randomUUID(), timestamp: new Date().toISOString(), sink, reversible }; +} +/** Minimal predicate evaluator — opaque string per spec §3.1. Resolver chose JS. */ +function __predicate(expr: string, row: any): boolean { + // eslint-disable-next-line no-new-func + return Boolean(new Function("row", `return (${expr});`)(row)); +} +function __project(expr: string, input: T): any { + // eslint-disable-next-line no-new-func + return new Function("input", "row", `return (${expr});`)(input, input); +} +function __reduce(expr: string, input: any[], initial: any): any { + const m = expr.match(/^group_by\(([^)]+)\)\.(sum|count|avg|min|max)\(([^)]*)\)$/); + if (m) { + const [, key, op, field] = m; + const groups = new Map(); + for (const row of input) { + const k = __project(key, row); + if (!groups.has(k)) groups.set(k, []); + groups.get(k)!.push(row); + } + const out: Record = {}; + for (const [k, rows] of groups) { + if (op === "count") out[String(k)] = rows.length; + else { + const vals = rows.map(r => __project(field, r)); + if (op === "sum") out[String(k)] = vals.reduce((a, b) => a + b, 0); + else if (op === "avg") out[String(k)] = vals.reduce((a, b) => a + b, 0) / vals.length; + else if (op === "min") out[String(k)] = Math.min(...vals); + else if (op === "max") out[String(k)] = Math.max(...vals); + } + } + return out; + } + const m2 = expr.match(/^(sum|count|avg|min|max)\(([^)]*)\)$/); + if (m2) { + const [, op, field] = m2; + if (op === "count") return input.length; + const vals = input.map(r => __project(field, r)); + if (op === "sum") return vals.reduce((a, b) => a + b, 0); + if (op === "avg") return vals.reduce((a, b) => a + b, 0) / vals.length; + if (op === "min") return Math.min(...vals); + if (op === "max") return Math.max(...vals); + } + // generic reducer: treat expr as JS lambda "acc, row => ..." + // eslint-disable-next-line no-new-func + const fn = new Function("acc", "row", `return (${expr});`) as (a: any, r: any) => any; + return input.reduce(fn, initial); +} +async function fetch_with_retry(ctx: Ctx): Promise { + return await __retry(async () => { + const r = await fetch("https://api.example.com/records", {}); return await r.json(); + }, 3, 1000, "exponential", true); +} + +async function safe_validate(ctx: Ctx): Promise { + try { + const rules: any = [{"field":"id","check":"required"}]; + const v = (globalThis as any).__validator; + const ok = v ? v(rules)(ctx.input) : true; + if (!ok) throw new Error("VALIDATE failed (action=reject)"); + return ctx.input; + } catch (e: any) { + const catches: string[] = ["ValidationError","ValueError"]; + const matches = catches.some(t => e?.name === t || e?.code === t || (e?.constructor?.name === t)); + if (matches) { + const payload = ctx.input; + await fetch("", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ channel: "#alerts", text: typeof payload === "string" ? payload : JSON.stringify(payload) }) }); + return; + } + throw e; + } +} + +async function persist_data(ctx: Ctx): Promise { + const input = ctx.input; + const { default: pg } = await import("pg"); + const client = new pg.Client({"__rawJs":"process.env.DATABASE_URL"}); + await client.connect(); + try { + const rows: any[] = Array.isArray(input) ? input : [input]; + for (const row of rows) { + const keys = Object.keys(row); const vals = Object.values(row); + const placeholders = keys.map((_, i) => "$" + (i+1)).join(", "); + const sql = `INSERT INTO records (${keys.join(",")}) VALUES (${placeholders})`; + await client.query(sql, vals); + } + } finally { await client.end(); } + const receipt = __receipt("sql:records"); + ctx.receipts["persist_data"] = receipt; + return receipt; +} + +async function rollback_persist(ctx: Ctx): Promise { + const receipt = ctx.receipts["persist_data"]; + if (!receipt) throw new Error("COMPENSATE rollback_persist: no receipt for persist_data"); + if (!receipt.reversible) throw new Error("COMPENSATE rollback_persist: receipt is non-reversible"); + const { default: pg } = await import("pg"); + const client = new pg.Client({"__rawJs":"process.env.DATABASE_URL"}); + await client.connect(); + try { + await client.query(`DELETE FROM records WHERE id = $1`); + } finally { await client.end(); } + return __receipt("sql:records", false); +} + +async function __execute(id: string, ctx: Ctx): Promise { + switch (id) { + case "fetch_with_retry": { + ctx.values["fetch_with_retry"] = await fetch_with_retry(ctx); + return __execute("safe_validate", ctx); + } + case "safe_validate": { + ctx.values["safe_validate"] = await safe_validate(ctx); + return __execute("persist_data", ctx); + } + case "persist_data": { + ctx.values["persist_data"] = await persist_data(ctx); + return __execute("rollback_persist", ctx); + } + case "rollback_persist": { + ctx.values["rollback_persist"] = await rollback_persist(ctx); + return; + } + default: throw new Error(`unknown verb id: ${id}`); + } +} + +export async function run(input: any): Promise { + const ctx = newCtx(input); + await __execute("fetch_with_retry", ctx); + return ctx; +} diff --git a/packages/pro/tests/resolvers/__fixtures__/expected/svelte/Dashboard.svelte b/packages/pro/tests/resolvers/__fixtures__/expected/svelte/Dashboard.svelte new file mode 100644 index 0000000..44699e9 --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected/svelte/Dashboard.svelte @@ -0,0 +1,68 @@ + + + + + + + +{#snippet metric_aum(state: DashboardState)} +
      +
      Total AUM
      + {String(state.values['aum_total'] ?? '—')} +
      +{/snippet} + +{#snippet chart_flows(state: DashboardState)} +
      +
      {JSON.stringify(state.values['flow_by_fund'] ?? null, null, 2)}
      +
      +{/snippet} + +{#snippet loading(state: DashboardState)} + {#if state.status === 'loading'} +
      + Fetching fund flows... +
      + {/if} +{/snippet} + +{#snippet anomaly_hint(state: DashboardState)} + {#if state.alerts.includes('anomaly_hint')} +
      + Anomalous outflow detected +
      + {/if} +{/snippet} + +{#snippet layout(state: DashboardState)} +
      + {@render metric_aum(state)} + {@render chart_flows(state)} +
      +{/snippet} + +{#snippet outcome(state: DashboardState)} +
      + {@render loading(state)} + {@render anomaly_hint(state)} + {@render layout(state)} +
      +{/snippet} + +{@render outcome(state)} diff --git a/packages/pro/tests/resolvers/__fixtures__/expected/vue/Dashboard.vue b/packages/pro/tests/resolvers/__fixtures__/expected/vue/Dashboard.vue new file mode 100644 index 0000000..1fec68e --- /dev/null +++ b/packages/pro/tests/resolvers/__fixtures__/expected/vue/Dashboard.vue @@ -0,0 +1,62 @@ + + + + diff --git a/packages/pro/tests/resolvers/composition-data-verbs.test.ts b/packages/pro/tests/resolvers/composition-data-verbs.test.ts new file mode 100644 index 0000000..a820bf0 --- /dev/null +++ b/packages/pro/tests/resolvers/composition-data-verbs.test.ts @@ -0,0 +1,67 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolve as resolveComposition } from '../../src/resolvers/index.js' +import { resolveAll } from '../../src/resolvers/manifest.js' +import { FIXTURE_DATA_VERBS_COMPOSITION } from './__fixtures__/composition-data-verbs.js' +import { COMPUTE_LABELS } from './harness.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_ROOT = path.join(__dirname, '__fixtures__', 'expected-data-verbs') + +async function loadDataVerbsFixture(label: string): Promise<{ name: string; content: string }[]> { + const dir = path.join(FIXTURE_ROOT, label) + const entries = await readdir(dir) + const files = await Promise.all( + entries.map(async (name) => { + const buf = await readFile(path.join(dir, name)) + return { name, content: buf.toString('utf8') } + }), + ) + return files.sort((a, b) => a.name.localeCompare(b.name)) +} + +describe('W2 data-verbs fixture (ENRICH / DELETE / STREAM)', () => { + for (const label of COMPUTE_LABELS) { + it(`${label} backend matches archive_compaction fixture`, async () => { + const expected = await loadDataVerbsFixture(label) + const output = resolveComposition(FIXTURE_DATA_VERBS_COMPOSITION, label) + + expect(output.label).toBe(label) + expect( + output.files.map((f) => path.basename(f.path)).sort((a, b) => a.localeCompare(b)), + ).toEqual(expected.map((e) => e.name)) + + for (const e of expected) { + const match = output.files.find((f) => path.basename(f.path) === e.name) + if (!match) throw new Error(`missing file ${e.name} in ${label} output`) + expect(match.content).toBe(e.content) + } + }) + } + + it('procedural backends emit no warnings on the data-verbs fixture', () => { + const manifest = resolveAll(FIXTURE_DATA_VERBS_COMPOSITION, [ + 'typescript', + 'python', + 'rust', + ]) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([]) + } + }) + + it('SQL backends emit only the fixed-spec EMIT warning on the data-verbs fixture', () => { + const manifest = resolveAll(FIXTURE_DATA_VERBS_COMPOSITION, [ + 'sql-postgres', + 'sql-sqlite', + 'sql-mysql', + ]) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([ + 'verb EMIT (notify_done) cannot be expressed in SQL compute — add a host layer to drive this step', + ]) + } + }) +}) diff --git a/packages/pro/tests/resolvers/composition-flow-parallel.test.ts b/packages/pro/tests/resolvers/composition-flow-parallel.test.ts new file mode 100644 index 0000000..b27694f --- /dev/null +++ b/packages/pro/tests/resolvers/composition-flow-parallel.test.ts @@ -0,0 +1,71 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolve as resolveComposition } from '../../src/resolvers/index.js' +import { resolveAll } from '../../src/resolvers/manifest.js' +import { FIXTURE_FLOW_PARALLEL_COMPOSITION } from './__fixtures__/composition-flow-parallel.js' +import { COMPUTE_LABELS } from './harness.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_ROOT = path.join(__dirname, '__fixtures__', 'expected-flow-parallel') + +async function loadFixture(label: string): Promise<{ name: string; content: string }[]> { + const dir = path.join(FIXTURE_ROOT, label) + const entries = await readdir(dir) + const files = await Promise.all( + entries.map(async (name) => { + const buf = await readFile(path.join(dir, name)) + return { name, content: buf.toString('utf8') } + }), + ) + return files.sort((a, b) => a.name.localeCompare(b.name)) +} + +describe('W5 flow-parallel fixture (SPLIT/MERGE/AWAIT/GATE/SIGNAL)', () => { + for (const label of COMPUTE_LABELS) { + it(`${label} backend matches flow_parallel fixture`, async () => { + const expected = await loadFixture(label) + const output = resolveComposition(FIXTURE_FLOW_PARALLEL_COMPOSITION, label) + + expect(output.label).toBe(label) + expect( + output.files.map((f) => path.basename(f.path)).sort((a, b) => a.localeCompare(b)), + ).toEqual(expected.map((e) => e.name)) + + for (const e of expected) { + const match = output.files.find((f) => path.basename(f.path) === e.name) + if (!match) throw new Error(`missing file ${e.name} in ${label} output`) + expect(match.content).toBe(e.content) + } + }) + } + + it('procedural backends emit no warnings on flow_parallel', () => { + const manifest = resolveAll(FIXTURE_FLOW_PARALLEL_COMPOSITION, [ + 'typescript', + 'python', + 'rust', + ]) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([]) + } + }) + + it('SQL backends emit fixed-spec warnings for all 5 flow verbs', () => { + const manifest = resolveAll(FIXTURE_FLOW_PARALLEL_COMPOSITION, [ + 'sql-postgres', + 'sql-sqlite', + 'sql-mysql', + ]) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([ + 'verb SPLIT (fan_out) is a flow-control verb — host runtime required', + 'verb MERGE (fan_in) is a flow-control verb — host runtime required', + 'verb AWAIT (settle_wait) is a flow-control verb — host runtime required', + 'verb GATE (human_review) is a flow-control verb — host runtime required', + 'verb SIGNAL (notify_external) is a flow-control verb — host runtime required', + ]) + } + }) +}) diff --git a/packages/pro/tests/resolvers/composition-flow-routing.test.ts b/packages/pro/tests/resolvers/composition-flow-routing.test.ts new file mode 100644 index 0000000..a01364f --- /dev/null +++ b/packages/pro/tests/resolvers/composition-flow-routing.test.ts @@ -0,0 +1,68 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolve as resolveComposition } from '../../src/resolvers/index.js' +import { resolveAll } from '../../src/resolvers/manifest.js' +import { FIXTURE_FLOW_ROUTING_COMPOSITION } from './__fixtures__/composition-flow-routing.js' +import { COMPUTE_LABELS } from './harness.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_ROOT = path.join(__dirname, '__fixtures__', 'expected-flow-routing') + +async function loadFixture(label: string): Promise<{ name: string; content: string }[]> { + const dir = path.join(FIXTURE_ROOT, label) + const entries = await readdir(dir) + const files = await Promise.all( + entries.map(async (name) => { + const buf = await readFile(path.join(dir, name)) + return { name, content: buf.toString('utf8') } + }), + ) + return files.sort((a, b) => a.name.localeCompare(b.name)) +} + +describe('W5 flow-routing fixture (BRANCH + TERMINATE)', () => { + for (const label of COMPUTE_LABELS) { + it(`${label} backend matches flow_routing fixture`, async () => { + const expected = await loadFixture(label) + const output = resolveComposition(FIXTURE_FLOW_ROUTING_COMPOSITION, label) + + expect(output.label).toBe(label) + expect( + output.files.map((f) => path.basename(f.path)).sort((a, b) => a.localeCompare(b)), + ).toEqual(expected.map((e) => e.name)) + + for (const e of expected) { + const match = output.files.find((f) => path.basename(f.path) === e.name) + if (!match) throw new Error(`missing file ${e.name} in ${label} output`) + expect(match.content).toBe(e.content) + } + }) + } + + it('procedural backends emit no warnings on flow_routing', () => { + const manifest = resolveAll(FIXTURE_FLOW_ROUTING_COMPOSITION, [ + 'typescript', + 'python', + 'rust', + ]) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([]) + } + }) + + it('SQL backends emit fixed-spec warnings for BRANCH + TERMINATE', () => { + const manifest = resolveAll(FIXTURE_FLOW_ROUTING_COMPOSITION, [ + 'sql-postgres', + 'sql-sqlite', + 'sql-mysql', + ]) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([ + 'verb BRANCH (route_by_status) is a flow-control verb — host runtime required', + 'verb TERMINATE (abort) is a flow-control verb — host runtime required', + ]) + } + }) +}) diff --git a/packages/pro/tests/resolvers/composition-interactive.test.ts b/packages/pro/tests/resolvers/composition-interactive.test.ts new file mode 100644 index 0000000..ff7e190 --- /dev/null +++ b/packages/pro/tests/resolvers/composition-interactive.test.ts @@ -0,0 +1,59 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolve as resolveComposition } from '../../src/resolvers/index.js' +import { resolveAll } from '../../src/resolvers/manifest.js' +import { FIXTURE_INTERACTIVE_COMPOSITION } from './__fixtures__/composition-interactive.js' +import { SURFACE_LABELS } from './harness.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_ROOT = path.join(__dirname, '__fixtures__', 'expected-interactive') + +async function loadFixture(label: string): Promise<{ name: string; content: string }[]> { + const dir = path.join(FIXTURE_ROOT, label) + const entries = await readdir(dir) + const files = await Promise.all( + entries.map(async (name) => { + const buf = await readFile(path.join(dir, name)) + return { name, content: buf.toString('utf8') } + }), + ) + return files.sort((a, b) => a.name.localeCompare(b.name)) +} + +describe('W6 interactive fixture (DECIDE/CONFIGURE/EXPLORE/AUTHOR/ONBOARD/COLLECT)', () => { + for (const label of SURFACE_LABELS) { + it(`${label} backend matches admin_console fixture`, async () => { + const expected = await loadFixture(label) + const output = resolveComposition(FIXTURE_INTERACTIVE_COMPOSITION, label) + + expect(output.label).toBe(label) + expect( + output.files.map((f) => path.basename(f.path)).sort((a, b) => a.localeCompare(b)), + ).toEqual(expected.map((e) => e.name)) + + for (const e of expected) { + const match = output.files.find((f) => path.basename(f.path) === e.name) + if (!match) throw new Error(`missing file ${e.name} in ${label} output`) + expect(match.content).toBe(e.content) + } + }) + } + + it('all 3 surface backends emit no warnings on the interactive fixture', () => { + const manifest = resolveAll(FIXTURE_INTERACTIVE_COMPOSITION, ['react', 'vue', 'svelte']) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([]) + } + }) + + it('produces three target entries for resolveAll(interactive, [react, vue, svelte])', () => { + const manifest = resolveAll(FIXTURE_INTERACTIVE_COMPOSITION, ['react', 'vue', 'svelte']) + expect(manifest.targets).toHaveLength(3) + expect(manifest.targets.map((t) => t.label)).toEqual(['react', 'vue', 'svelte']) + expect(manifest.targets[0]?.files[0]?.path).toBe('react/Dashboard.tsx') + expect(manifest.targets[1]?.files[0]?.path).toBe('vue/Dashboard.vue') + expect(manifest.targets[2]?.files[0]?.path).toBe('svelte/Dashboard.svelte') + }) +}) diff --git a/packages/pro/tests/resolvers/composition-resilience-verbs.test.ts b/packages/pro/tests/resolvers/composition-resilience-verbs.test.ts new file mode 100644 index 0000000..a8e2c41 --- /dev/null +++ b/packages/pro/tests/resolvers/composition-resilience-verbs.test.ts @@ -0,0 +1,69 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolve as resolveComposition } from '../../src/resolvers/index.js' +import { resolveAll } from '../../src/resolvers/manifest.js' +import { FIXTURE_RESILIENCE_COMPOSITION } from './__fixtures__/composition-resilience-verbs.js' +import { COMPUTE_LABELS } from './harness.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_ROOT = path.join(__dirname, '__fixtures__', 'expected-resilience-verbs') + +async function loadResilienceFixture(label: string): Promise<{ name: string; content: string }[]> { + const dir = path.join(FIXTURE_ROOT, label) + const entries = await readdir(dir) + const files = await Promise.all( + entries.map(async (name) => { + const buf = await readFile(path.join(dir, name)) + return { name, content: buf.toString('utf8') } + }), + ) + return files.sort((a, b) => a.name.localeCompare(b.name)) +} + +describe('W4 resilience-verbs fixture (RETRY / COMPENSATE / ERROR)', () => { + for (const label of COMPUTE_LABELS) { + it(`${label} backend matches resilient_etl fixture`, async () => { + const expected = await loadResilienceFixture(label) + const output = resolveComposition(FIXTURE_RESILIENCE_COMPOSITION, label) + + expect(output.label).toBe(label) + expect( + output.files.map((f) => path.basename(f.path)).sort((a, b) => a.localeCompare(b)), + ).toEqual(expected.map((e) => e.name)) + + for (const e of expected) { + const match = output.files.find((f) => path.basename(f.path) === e.name) + if (!match) throw new Error(`missing file ${e.name} in ${label} output`) + expect(match.content).toBe(e.content) + } + }) + } + + it('procedural backends emit no warnings on the resilience fixture', () => { + const manifest = resolveAll(FIXTURE_RESILIENCE_COMPOSITION, [ + 'typescript', + 'python', + 'rust', + ]) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([]) + } + }) + + it('SQL backends emit fixed-spec warnings for RETRY/ERROR/COMPENSATE', () => { + const manifest = resolveAll(FIXTURE_RESILIENCE_COMPOSITION, [ + 'sql-postgres', + 'sql-sqlite', + 'sql-mysql', + ]) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([ + 'verb RETRY (fetch_with_retry) cannot retry in SQL compute — host required', + 'verb ERROR (safe_validate) requires error routing in host runtime', + 'verb COMPENSATE (rollback_persist) requires runtime receipt tracking — host required', + ]) + } + }) +}) diff --git a/packages/pro/tests/resolvers/dispatch.test.ts b/packages/pro/tests/resolvers/dispatch.test.ts index 895811f..961ed17 100644 --- a/packages/pro/tests/resolvers/dispatch.test.ts +++ b/packages/pro/tests/resolvers/dispatch.test.ts @@ -32,6 +32,18 @@ describe('resolve() target dispatch', () => { ).toThrow(/ambiguous target — pass a specific label string/) }) + it('throws on an ambiguous TargetTag for vue', () => { + expect(() => + resolve(FIXTURE_SURFACE_COMPOSITION, { compute: 'python', surface: 'vue' }), + ).toThrow(/ambiguous target — pass a specific label string/) + }) + + it('throws on an ambiguous TargetTag for svelte', () => { + expect(() => + resolve(FIXTURE_SURFACE_COMPOSITION, { compute: 'rust', surface: 'svelte' }), + ).toThrow(/ambiguous target — pass a specific label string/) + }) + it('throws on an empty TargetTag (neither set)', () => { expect(() => resolve(FIXTURE_COMPOSITION, {})).toThrow( /target has no compute or surface tag/, @@ -44,10 +56,10 @@ describe('resolve() target dispatch', () => { ) }) - // Intentional pin: this snapshot breaks the day someone adds an 8th backend, + // Intentional pin: this snapshot breaks the day someone adds a 10th backend, // forcing an explicit decision + README + SOLUTION.md update. Relax to // `toContain(...)` only if the registry becomes dynamically-populated. - it('exposes all 7 registered backends via availableTargets()', () => { + it('exposes all 9 registered backends via availableTargets()', () => { expect(availableTargets()).toEqual([ 'python', 'react', @@ -55,7 +67,9 @@ describe('resolve() target dispatch', () => { 'sql-mysql', 'sql-postgres', 'sql-sqlite', + 'svelte', 'typescript', + 'vue', ]) }) }) diff --git a/packages/pro/tests/resolvers/harness.ts b/packages/pro/tests/resolvers/harness.ts index 9a51605..022441b 100644 --- a/packages/pro/tests/resolvers/harness.ts +++ b/packages/pro/tests/resolvers/harness.ts @@ -19,7 +19,7 @@ export const COMPUTE_LABELS = [ 'sql-mysql', ] as const -export const SURFACE_LABELS = ['react'] as const +export const SURFACE_LABELS = ['react', 'vue', 'svelte'] as const export const FIXTURE_LABELS = [...COMPUTE_LABELS, ...SURFACE_LABELS] as const diff --git a/packages/pro/tests/resolvers/manifest.test.ts b/packages/pro/tests/resolvers/manifest.test.ts index bdcd50e..c312177 100644 --- a/packages/pro/tests/resolvers/manifest.test.ts +++ b/packages/pro/tests/resolvers/manifest.test.ts @@ -101,4 +101,24 @@ describe('resolveAll', () => { expect(react?.warnings).toEqual([]) expect(react?.files[0]?.path).toBe('react/Dashboard.tsx') }) + + it('produces three surface target entries for react+vue+svelte', () => { + const manifest = resolveAll(FIXTURE_SURFACE_COMPOSITION, ['react', 'vue', 'svelte'], { + clock: FIXTURE_CLOCK, + }) + expect(manifest.targets).toHaveLength(3) + expect(manifest.targets.map((t) => t.label)).toEqual(['react', 'vue', 'svelte']) + expect(manifest.targets.find((t) => t.label === 'react')?.files[0]?.path).toBe( + 'react/Dashboard.tsx', + ) + expect(manifest.targets.find((t) => t.label === 'vue')?.files[0]?.path).toBe( + 'vue/Dashboard.vue', + ) + expect(manifest.targets.find((t) => t.label === 'svelte')?.files[0]?.path).toBe( + 'svelte/Dashboard.svelte', + ) + for (const target of manifest.targets) { + expect(target.warnings).toEqual([]) + } + }) })