From 2d6630acc0f8b348e5543b5f8e461668fc75a3a2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 27 May 2026 18:03:14 -0700 Subject: [PATCH 1/6] refactor(fixtures): update jedit-rope.graphql consumer-model fixture (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply SDL renames to match jedit's rope schema refactor: - Tick → RopeRewrite, TickReceipt → RopeDiff - TickKind / TickReceiptRewriteKind → RewriteKind (unified enum) - createdAtTickId → createdAtRopeRewriteId - createdByTickId → createdByRopeRewriteId - tick/receipt result fields → ropeRewrite/ropeDiff - Footprint strings updated to RopeRewrite/RopeDiff - File header updated to reflect rope schema doctrine Part of 0024-universal-le-binary-codec Phase 1. --- ...ext-runtime.graphql => jedit-rope.graphql} | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) rename test/fixtures/consumer-models/{jedit-hot-text-runtime.graphql => jedit-rope.graphql} (89%) diff --git a/test/fixtures/consumer-models/jedit-hot-text-runtime.graphql b/test/fixtures/consumer-models/jedit-rope.graphql similarity index 89% rename from test/fixtures/consumer-models/jedit-hot-text-runtime.graphql rename to test/fixtures/consumer-models/jedit-rope.graphql index 05b210f9..7758dfc5 100644 --- a/test/fixtures/consumer-models/jedit-hot-text-runtime.graphql +++ b/test/fixtures/consumer-models/jedit-rope.graphql @@ -1,9 +1,5 @@ """ -Authored home for `jedit`'s causal text runtime boundary. - -The file keeps the legacy `hot-text-runtime` name for continuity. In current -doctrine, `hot` here means the first low-latency text contract surface over -witnessed causal history, not a separate ontology layer. +Authored home for `jedit`'s rope schema boundary. This SDL defines the graph-like reading nouns and rewrite surfaces that `jedit` expects Wesley to compile for Echo-backed causal text truth. It @@ -11,7 +7,7 @@ intentionally models only the text contract layer: - canonical text worldlines - rope heads and rope DAG nodes -- ticks and tick receipts +- rope rewrites and rope diffs - anchors - checkpoints @@ -45,14 +41,11 @@ enum AnchorStickiness { EXPAND } -enum TickKind { - BUFFER_CREATE - TEXT_REWRITE - CHECKPOINT_CREATE - ANCHOR_REGISTER -} - -enum TickReceiptRewriteKind { +""" +Unified classification for the kind of rewrite operation applied to the rope. +Used by both RopeRewrite (the causal record) and RopeDiff (the structural witness). +""" +enum RewriteKind { CREATE_BUFFER_WORLDLINE REPLACE_RANGE_AS_TICK CREATE_CHECKPOINT @@ -69,7 +62,7 @@ type BufferWorldline { worldlineId: ID! bufferKey: String! canonicalHeadId: ID! - createdAtTickId: ID + createdAtRopeRewriteId: ID projectionPath: String } @@ -119,20 +112,28 @@ type Anchor { stickiness: AnchorStickiness } -type Tick { - tickId: ID! +""" +Causal record of a single rewrite applied to a rope worldline. +""" +type RopeRewrite { + ropeRewriteId: ID! worldlineId: ID! - kind: TickKind! + kind: RewriteKind! sequenceNumber: Int! author: String } -type TickReceipt { - receiptId: ID! - tickId: ID! +""" +Structural witness for the before/after byte ranges produced by a RopeRewrite. +Records the base and next rope head IDs, the byte window touched, and the +inverse fragment digest for undo. +""" +type RopeDiff { + ropeDiffId: ID! + ropeRewriteId: ID! baseHeadId: ID! nextHeadId: ID! - rewriteKind: TickReceiptRewriteKind! + rewriteKind: RewriteKind! startByte: Int endByte: Int insertedByteLength: Int! @@ -147,7 +148,7 @@ type Checkpoint { headId: ID! kind: CheckpointKind! label: String - createdByTickId: ID + createdByRopeRewriteId: ID } type WorldlineSnapshot { @@ -185,8 +186,8 @@ type CreateBufferWorldlineResult { type ReplaceRangeAsTickResult { worldline: BufferWorldline! nextHead: RopeHead! - tick: Tick! - receipt: TickReceipt! + ropeRewrite: RopeRewrite! + ropeDiff: RopeDiff! } type CreateCheckpointResult { @@ -261,7 +262,7 @@ type Mutation { """ v1 reads affected anchors only so anchor transform can be derived from the - resulting receipt. This rewrite does not persist anchor updates in-place. + resulting diff. This rewrite does not persist anchor updates in-place. """ replaceRangeAsTick( input: ReplaceRangeAsTickInput! @@ -282,8 +283,8 @@ type Mutation { "RopeLeaf" "RopeBranch" "RopeHead" - "Tick" - "TickReceipt" + "RopeRewrite" + "RopeDiff" ] slots: [ { @@ -326,8 +327,8 @@ type Mutation { { slot: "newLeaves", kind: "RopeLeaf", cardinality: MANY } { slot: "newBranches", kind: "RopeBranch", cardinality: MANY } { slot: "nextHead", kind: "RopeHead" } - { slot: "tick", kind: "Tick" } - { slot: "receipt", kind: "TickReceipt" } + { slot: "ropeRewrite", kind: "RopeRewrite" } + { slot: "ropeDiff", kind: "RopeDiff" } ] updates: [{ slot: "worldline", fields: ["canonicalHead"] }] forbids: ["AstState", "Diagnostics", "GitWitness", "UiState"] @@ -365,8 +366,8 @@ type Mutation { "RopeBranch" "RopeLeaf" "TextBlob" - "Tick" - "TickReceipt" + "RopeRewrite" + "RopeDiff" "AstState" "Diagnostics" "GitWitness" From 038432643f8e7195a8568a82269e9763999b6a96 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 27 May 2026 22:01:59 -0700 Subject: [PATCH 2/6] feat(emit): add emit le-binary-typescript codec target (Phase 5) Adds a TypeScript LE binary codec emitter that mirrors the wire layout of echo_wasm_abi::codec (Rust) byte-for-byte. Enums encode as u32 LE zero-based discriminants; input objects encode fields in SDL declaration order; nullable fields use a presence tag; lists use a u32 LE count prefix. New `wesley emit le-binary-typescript` subcommand consumes a schema SDL and emits encode/decode functions for every enum, input object, and operation Vars struct in the schema. The emitted module imports its Writer/Reader/CodecError primitives from a configurable path (--codec-import, defaulting to '../../codec.js' for jedit-shaped repos). Closes Phase 5 of design 0024-universal-le-binary-codec by making the generated TypeScript codec available wherever a Wesley-described schema crosses a TS/JS boundary, structurally guaranteeing parity with the Rust Encode/Decode impls emitted by echo-wesley-gen Phase 4. --- crates/wesley-cli/src/main.rs | 48 +- .../wesley-emit-typescript/src/le_binary.rs | 451 ++++++++++++++++++ crates/wesley-emit-typescript/src/lib.rs | 7 + 3 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 crates/wesley-emit-typescript/src/le_binary.rs diff --git a/crates/wesley-cli/src/main.rs b/crates/wesley-cli/src/main.rs index 4173bd86..35a31bdb 100644 --- a/crates/wesley-cli/src/main.rs +++ b/crates/wesley-cli/src/main.rs @@ -19,8 +19,10 @@ use wesley_emit_rust::{ GENERATOR_NAME as RUST_GENERATOR_NAME, GENERATOR_VERSION as RUST_GENERATOR_VERSION, }; use wesley_emit_typescript::{ - emit_typescript_with_operations, GENERATOR_NAME as TYPESCRIPT_GENERATOR_NAME, + emit_le_binary_typescript, emit_typescript_with_operations, DEFAULT_CODEC_IMPORT, + GENERATOR_NAME as TYPESCRIPT_GENERATOR_NAME, GENERATOR_VERSION as TYPESCRIPT_GENERATOR_VERSION, + LE_BINARY_GENERATOR_NAME as LE_BINARY_TYPESCRIPT_GENERATOR_NAME, }; const EXIT_OK: u8 = 0; @@ -532,6 +534,36 @@ fn run_emit_command(args: &[String]) -> Result { )?; Ok(EXIT_OK) } + Some("le-binary-typescript") if wants_help(&args[1..]) => { + print_emit_help(); + Ok(EXIT_OK) + } + Some("le-binary-typescript") => { + let options = parse_options(&args[1..], "emit le-binary-typescript")?; + let schema_path = options.required_schema("emit le-binary-typescript")?; + let out_path = options.required_out("emit le-binary-typescript")?; + let sdl = read_file(&schema_path, "schema")?; + let ir = lower_schema_sdl(&sdl)?; + let operations = list_schema_operations_sdl(&sdl)?; + let bundle = + load_contract_bundle_if_requested(options.law.as_deref(), &ir, &operations)?; + let manifest = bundle.as_ref().map(|bundle| &bundle.manifest); + let codec_import = options + .codec_import + .as_deref() + .unwrap_or(DEFAULT_CODEC_IMPORT); + let typescript = emit_le_binary_typescript(&ir, &operations, codec_import); + + write_file(&out_path, &typescript, "LE binary TypeScript output")?; + write_emit_metadata_if_requested( + options.metadata_out.as_deref(), + &ir, + manifest, + LE_BINARY_TYPESCRIPT_GENERATOR_NAME, + TYPESCRIPT_GENERATOR_VERSION, + )?; + Ok(EXIT_OK) + } Some(command) => Err(CliError::usage(format!("unknown emit command '{command}'"))), } } @@ -769,6 +801,7 @@ struct ParsedOptions { operation: Option, out: Option, metadata_out: Option, + codec_import: Option, directive: Option, family: Option, profile: Option, @@ -947,6 +980,15 @@ fn parse_options(args: &[String], command: &str) -> Result { + index += 1; + options.codec_import = Some(required_value(args, index, "--codec-import")?); + } + "--codec-import" => { + return Err(CliError::usage(format!( + "unknown option '--codec-import' for `{command}`" + ))); + } "--directive" | "-d" => { index += 1; let name = required_value(args, index, "--directive")?; @@ -2098,12 +2140,14 @@ Query, Mutation, or Subscription fields. Usage: wesley emit rust --schema --out [--law ] [--metadata-out ] wesley emit typescript --schema --out [--law ] [--metadata-out ] + wesley emit le-binary-typescript --schema --out [--law ] [--metadata-out ] [--codec-import ] Options: -s, --schema GraphQL SDL file --law Optional weslaw/v1 file for bundle hashes --out Output file - --metadata-out Deterministic metadata JSON sidecar" + --metadata-out Deterministic metadata JSON sidecar + --codec-import Module specifier for Writer/Reader/CodecError (le-binary-typescript only)" ); } diff --git a/crates/wesley-emit-typescript/src/le_binary.rs b/crates/wesley-emit-typescript/src/le_binary.rs new file mode 100644 index 00000000..287c719b --- /dev/null +++ b/crates/wesley-emit-typescript/src/le_binary.rs @@ -0,0 +1,451 @@ +//! TypeScript LE binary codec emitter. +//! +//! Produces a TypeScript module that mirrors the wire layout of the Rust +//! `echo_wasm_abi::codec` primitives byte-for-byte: +//! +//! - Enums encode their zero-based variant index (declaration order) as `u32` LE. +//! - Input objects encode their fields in declaration order with no separators. +//! - `Boolean` → 1 byte (`0x00` / `0x01`). +//! - `Int` → 4 bytes LE signed. +//! - `Float` → 4 bytes LE (canonicalized inside the Writer). +//! - `String` / `ID` → `u32` LE length + UTF-8 bytes. +//! - Nullable `T` → presence tag (`0x00` null / `0x01` present) + value if present. +//! - Non-null list `[T!]!` → `u32` LE element count + inline elements. +//! +//! Every emitted operation produces an `encodeVars(v) -> Uint8Array` / +//! `decodeVars(b) -> Vars` pair whose layout matches the Rust +//! `__echo_wesley_generated::Vars` Encode/Decode impls in +//! `echo-wesley-gen`. + +use std::fmt::Write; + +use wesley_core::{ + OperationArgument, OperationType, SchemaOperation, TypeDefinition, TypeKind, TypeReference, + WesleyIR, +}; + +/// Default import path the emitted module uses to reach the TypeScript +/// `Writer` / `Reader` / `CodecError` primitives. Suitable for files written +/// to `/src/generated//.codec.generated.ts` when the +/// primitives live at `/src/codec.ts`. +pub const DEFAULT_CODEC_IMPORT: &str = "../../codec.js"; + +const HEADER: &str = "\ +/* @generated by wesley-emit-typescript le-binary. Do not edit. */ +// SPDX-License-Identifier: Apache-2.0 +// +// Wire layout mirrors echo_wasm_abi::codec (Rust). Field order is SDL +// declaration order. Enums encode their zero-based variant index as u32 LE. +"; + +/// Emit a TypeScript LE binary codec module for the given Wesley IR. +/// +/// `codec_import_path` is the module specifier the emitted file uses to +/// import `Writer`, `Reader`, and `CodecError`. Pass [`DEFAULT_CODEC_IMPORT`] +/// to use the jedit-style default. +pub fn emit_le_binary_typescript( + ir: &WesleyIR, + operations: &[SchemaOperation], + codec_import_path: &str, +) -> String { + let mut out = String::from(HEADER); + out.push('\n'); + let _ = writeln!( + out, + "import {{ Writer, Reader, CodecError }} from {};", + quote_string(codec_import_path) + ); + + for type_def in &ir.types { + match type_def.kind { + TypeKind::Enum => emit_enum(&mut out, type_def), + TypeKind::InputObject => emit_input_object(&mut out, type_def), + _ => {} + } + } + + for operation in operations { + emit_operation_vars(&mut out, operation); + } + + out +} + +fn emit_enum(out: &mut String, type_def: &TypeDefinition) { + let name = pascal_case(&type_def.name); + + let _ = writeln!(out, "\n// ─── enum {} ───", type_def.name); + if type_def.enum_values.is_empty() { + let _ = writeln!(out, "export type {name} = never;"); + } else { + let mut iter = type_def.enum_values.iter(); + let first = iter.next().expect("non-empty enum"); + let _ = writeln!(out, "export type {name} ="); + let _ = write!(out, " | '{first}'"); + for value in iter { + out.push('\n'); + let _ = write!(out, " | '{value}'"); + } + out.push_str(";\n"); + } + + let _ = writeln!( + out, + "export function _enc{name}(w: Writer, v: {name}): void {{" + ); + let _ = writeln!(out, " switch (v) {{"); + for (index, value) in type_def.enum_values.iter().enumerate() { + let _ = writeln!(out, " case '{value}': w.writeU32Le({index}); return;"); + } + if type_def.enum_values.is_empty() { + let _ = writeln!(out, " // no variants"); + } else { + let _ = writeln!( + out, + " default: throw new CodecError('invalid {name} variant: ' + String(v));" + ); + } + let _ = writeln!(out, " }}"); + let _ = writeln!(out, "}}"); + + let _ = writeln!( + out, + "export function _dec{name}(r: Reader): {name} {{" + ); + let _ = writeln!(out, " const d = r.readU32Le();"); + let _ = writeln!(out, " switch (d) {{"); + for (index, value) in type_def.enum_values.iter().enumerate() { + let _ = writeln!(out, " case {index}: return '{value}';"); + } + let _ = writeln!( + out, + " default: throw new CodecError('invalid {name} discriminant: ' + String(d));" + ); + let _ = writeln!(out, " }}"); + let _ = writeln!(out, "}}"); + + let _ = writeln!( + out, + "export function encode{name}(v: {name}): Uint8Array {{" + ); + let _ = writeln!(out, " const w = new Writer();"); + let _ = writeln!(out, " _enc{name}(w, v);"); + let _ = writeln!(out, " return w.finish();"); + let _ = writeln!(out, "}}"); + + let _ = writeln!( + out, + "export function decode{name}(b: Uint8Array): {name} {{" + ); + let _ = writeln!(out, " return _dec{name}(new Reader(b));"); + let _ = writeln!(out, "}}"); +} + +fn emit_input_object(out: &mut String, type_def: &TypeDefinition) { + let name = pascal_case(&type_def.name); + + let _ = writeln!(out, "\n// ─── input {} ───", type_def.name); + let _ = writeln!(out, "export interface {name} {{"); + for field in &type_def.fields { + let ts_ty = ts_type_for_reference(&field.r#type); + let _ = writeln!(out, " {}: {ts_ty};", property_key(&field.name)); + } + let _ = writeln!(out, "}}"); + + let _ = writeln!( + out, + "export function _enc{name}(w: Writer, v: {name}): void {{" + ); + for field in &type_def.fields { + emit_encode_field(out, "v", &field.name, &field.r#type); + } + let _ = writeln!(out, "}}"); + + let _ = writeln!( + out, + "export function _dec{name}(r: Reader): {name} {{" + ); + let _ = writeln!(out, " return {{"); + for field in &type_def.fields { + emit_decode_field_initializer(out, &field.name, &field.r#type); + } + let _ = writeln!(out, " }};"); + let _ = writeln!(out, "}}"); +} + +fn emit_operation_vars(out: &mut String, operation: &SchemaOperation) { + let pascal = pascal_case(&operation.field_name); + let vars_name = format!("{pascal}Vars"); + + let scope = match operation.operation_type { + OperationType::Query => "query", + OperationType::Mutation => "mutation", + OperationType::Subscription => "subscription", + }; + let _ = writeln!(out, "\n// ─── {scope} {} ───", operation.field_name); + + let _ = writeln!(out, "export interface {vars_name} {{"); + for argument in &operation.arguments { + let ts_ty = ts_type_for_reference(&argument.r#type); + let _ = writeln!(out, " {}: {ts_ty};", property_key(&argument.name)); + } + let _ = writeln!(out, "}}"); + + let _ = writeln!( + out, + "export function encode{vars_name}(v: {vars_name}): Uint8Array {{" + ); + let _ = writeln!(out, " const w = new Writer();"); + for argument in &operation.arguments { + emit_encode_argument(out, "v", argument); + } + let _ = writeln!(out, " return w.finish();"); + let _ = writeln!(out, "}}"); + + let _ = writeln!( + out, + "export function decode{vars_name}(b: Uint8Array): {vars_name} {{" + ); + let _ = writeln!(out, " const r = new Reader(b);"); + let _ = writeln!(out, " return {{"); + for argument in &operation.arguments { + emit_decode_field_initializer(out, &argument.name, &argument.r#type); + } + let _ = writeln!(out, " }};"); + let _ = writeln!(out, "}}"); +} + +fn emit_encode_argument(out: &mut String, parent: &str, argument: &OperationArgument) { + emit_encode_field(out, parent, &argument.name, &argument.r#type); +} + +fn emit_encode_field(out: &mut String, parent: &str, field_name: &str, ty: &TypeReference) { + let access = format!("{parent}.{}", property_key(field_name)); + + if ty.is_list { + let element_call = scalar_encode_call(&ty.base, "w", "x"); + let element_lambda = format!("(w, x) => {element_call}"); + if ty.nullable { + let _ = writeln!( + out, + " w.writeOption({access}, (w, xs) => w.writeList(xs, {element_lambda}));" + ); + } else { + let _ = writeln!(out, " w.writeList({access}, {element_lambda});"); + } + return; + } + + if ty.nullable { + let inner = scalar_encode_call(&ty.base, "w", "x"); + let _ = writeln!(out, " w.writeOption({access}, (w, x) => {inner});"); + } else { + let _ = writeln!(out, " {};", scalar_encode_call(&ty.base, "w", &access)); + } +} + +fn emit_decode_field_initializer(out: &mut String, field_name: &str, ty: &TypeReference) { + let key = property_key(field_name); + + if ty.is_list { + let element_call = scalar_decode_call(&ty.base, "r"); + let list_expr = format!("r.readList((r) => {element_call})"); + if ty.nullable { + let _ = writeln!( + out, + " {key}: r.readOption((r) => r.readList((r) => {element_call}))," + ); + } else { + let _ = writeln!(out, " {key}: {list_expr},"); + } + return; + } + + if ty.nullable { + let inner = scalar_decode_call(&ty.base, "r"); + let _ = writeln!(out, " {key}: r.readOption((r) => {inner}),"); + } else { + let _ = writeln!(out, " {key}: {},", scalar_decode_call(&ty.base, "r")); + } +} + +fn scalar_encode_call(type_name: &str, writer: &str, value: &str) -> String { + match type_name { + "Boolean" => format!("{writer}.writeBool({value})"), + "Int" => format!("{writer}.writeI32Le({value})"), + "Float" => format!("{writer}.writeF32Le({value})"), + "String" | "ID" => format!("{writer}.writeString({value})"), + other => format!("_enc{}({writer}, {value})", pascal_case(other)), + } +} + +fn scalar_decode_call(type_name: &str, reader: &str) -> String { + match type_name { + "Boolean" => format!("{reader}.readBool()"), + "Int" => format!("{reader}.readI32Le()"), + "Float" => format!("{reader}.readF32Le()"), + "String" | "ID" => format!("{reader}.readString()"), + other => format!("_dec{}({reader})", pascal_case(other)), + } +} + +fn ts_type_for_reference(ty: &TypeReference) -> String { + let mut base = match ty.base.as_str() { + "ID" | "String" => "string".to_string(), + "Int" | "Float" => "number".to_string(), + "Boolean" => "boolean".to_string(), + other => pascal_case(other), + }; + + if ty.is_list { + let element = if matches!(ty.list_item_nullable, Some(true)) { + format!("({base} | null)") + } else { + base.clone() + }; + base = format!("{element}[]"); + } + + if ty.nullable { + format!("{base} | null") + } else { + base + } +} + +fn pascal_case(name: &str) -> String { + let mut out = String::new(); + let mut next_upper = true; + for ch in name.chars() { + if ch.is_alphanumeric() { + if next_upper { + out.push(ch.to_ascii_uppercase()); + next_upper = false; + } else { + out.push(ch); + } + } else { + next_upper = true; + } + } + if out.is_empty() { + "Op".to_string() + } else { + out + } +} + +fn property_key(name: &str) -> String { + if is_safe_identifier(name) { + name.to_string() + } else { + let mut quoted = String::from("'"); + for ch in name.chars() { + match ch { + '\\' => quoted.push_str("\\\\"), + '\'' => quoted.push_str("\\'"), + _ => quoted.push(ch), + } + } + quoted.push('\''); + quoted + } +} + +fn is_safe_identifier(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { + return false; + } + for ch in chars { + if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '$') { + return false; + } + } + true +} + +fn quote_string(value: &str) -> String { + let mut out = String::from("'"); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + _ => out.push(ch), + } + } + out.push('\''); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use wesley_core::{lower_schema_sdl, list_schema_operations_sdl}; + + const SDL: &str = " + enum Color { RED GREEN BLUE } + + input MakeWidgetInput { + label: String! + count: Int! + color: Color + tags: [String!]! + } + + type Widget { id: ID! label: String! } + + type Mutation { + makeWidget(input: MakeWidgetInput!): Widget! + } + "; + + #[test] + fn emits_typescript_codec_for_minimal_schema() { + let ir = lower_schema_sdl(SDL).expect("schema lowers"); + let ops = list_schema_operations_sdl(SDL).expect("operations enumerable"); + + let ts = emit_le_binary_typescript(&ir, &ops, DEFAULT_CODEC_IMPORT); + + assert!(ts.contains("import { Writer, Reader, CodecError } from '../../codec.js';")); + assert!(ts.contains("export type Color =\n | 'RED'\n | 'GREEN'\n | 'BLUE';")); + assert!(ts.contains("case 'RED': w.writeU32Le(0); return;")); + assert!(ts.contains("case 'BLUE': w.writeU32Le(2); return;")); + assert!(ts.contains("export interface MakeWidgetInput")); + assert!(ts.contains("label: string;")); + assert!(ts.contains("color: Color | null;")); + assert!(ts.contains("tags: string[];")); + assert!(ts.contains("export interface MakeWidgetVars")); + assert!(ts.contains("input: MakeWidgetInput;")); + assert!(ts.contains("export function encodeMakeWidgetVars(v: MakeWidgetVars): Uint8Array")); + assert!(ts.contains("export function decodeMakeWidgetVars(b: Uint8Array): MakeWidgetVars")); + } + + #[test] + fn encodes_nullable_with_option_tag_and_required_inline() { + let ir = lower_schema_sdl(SDL).expect("schema lowers"); + let ops = list_schema_operations_sdl(SDL).expect("operations enumerable"); + + let ts = emit_le_binary_typescript(&ir, &ops, DEFAULT_CODEC_IMPORT); + + // required String field -> direct writeString call + assert!(ts.contains("w.writeString(v.label);")); + // required Int field -> direct writeI32Le call + assert!(ts.contains("w.writeI32Le(v.count);")); + // nullable enum field -> writeOption wrapping enum encoder + assert!(ts.contains("w.writeOption(v.color, (w, x) => _encColor(w, x));")); + // required list of String! -> writeList without option wrapper + assert!(ts.contains("w.writeList(v.tags, (w, x) => w.writeString(x));")); + } + + #[test] + fn quotes_property_keys_that_are_not_identifiers() { + assert_eq!(property_key("ok"), "ok"); + assert_eq!(property_key("camelCase_2"), "camelCase_2"); + assert_eq!(property_key("with space"), "'with space'"); + assert_eq!(property_key(""), "''"); + } +} diff --git a/crates/wesley-emit-typescript/src/lib.rs b/crates/wesley-emit-typescript/src/lib.rs index 8bdfd459..a1fe6e0b 100644 --- a/crates/wesley-emit-typescript/src/lib.rs +++ b/crates/wesley-emit-typescript/src/lib.rs @@ -10,12 +10,19 @@ use wesley_core::{ TypeReference, WesleyIR, }; +mod le_binary; + +pub use le_binary::{emit_le_binary_typescript, DEFAULT_CODEC_IMPORT}; + /// Stable generator identifier recorded in native emit metadata. pub const GENERATOR_NAME: &str = "wesley-emit-typescript"; /// Version of the TypeScript emitter crate recorded in native emit metadata. pub const GENERATOR_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Stable generator identifier for the LE binary codec TypeScript emitter. +pub const LE_BINARY_GENERATOR_NAME: &str = "wesley-emit-typescript:le-binary"; + /// Emits TypeScript declarations for a Wesley L1 IR document. pub fn emit_typescript(ir: &WesleyIR) -> String { let program = TsProgram::from_ir(ir); From e953eaa20a093ceac1b52eeee749f32bc8f6cb8a Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 28 May 2026 12:58:04 -0700 Subject: [PATCH 3/6] feat(emit): port stable_op_id into wesley-core; emit TS OP_* constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `wesley_core::stable_op_id(OperationType, &str) -> u32` (FNV-1a with seed 0x811c9dc5) plus `operation_type_rank` as a first-class part of wesley-core's domain. Five pinned unit tests lock the algorithm and the specific u32 outputs for the rope schema's five operations — any drift breaks every consumer that routes EINT envelopes by op id, so the contract is asserted directly. Extends `wesley emit le-binary-typescript` to emit, alongside each operation's Vars interface and encode/decode functions, an `export const OP_: number = ;` constant. The identifier convention matches echo-wesley-gen's Rust emit so consumers can swap between languages without renaming. Two new unit tests cover the helper naming and concrete op id emission. This is the algorithmic source of truth for the cross-language EINT op id contract. echo-wesley-gen still has a local copy of the algorithm (it pins wesley-core 0.0.4 from crates.io); both copies are asserted against the same pinned outputs and will collapse to one when echo bumps the wesley-core dep to 0.0.5+. --- crates/wesley-core/src/domain/operation.rs | 115 ++++++++++++++++++ .../wesley-emit-typescript/src/le_binary.rs | 60 ++++++++- 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/crates/wesley-core/src/domain/operation.rs b/crates/wesley-core/src/domain/operation.rs index 72093e2a..85930638 100644 --- a/crates/wesley-core/src/domain/operation.rs +++ b/crates/wesley-core/src/domain/operation.rs @@ -42,6 +42,49 @@ pub struct OperationArgument { pub directives: IndexMap, } +/// Compute a stable u32 op id from a root operation's kind and field name. +/// +/// The algorithm is FNV-1a 32-bit with the canonical seed `0x811c9dc5`: +/// +/// 1. Seed `hash = 0x811c9dc5`. +/// 2. Step `hash` with [`operation_type_rank`] of `operation_type`. +/// 3. Step `hash` with each UTF-8 byte of `field_name` in order. +/// +/// Each step is `hash = (hash * 0x01000193) ^ byte`, computed modulo 2³² (wrapping). +/// +/// This is the cross-language identifier consumed by EINT envelopes: +/// every encoder/decoder that derives op ids from a Wesley schema must +/// produce identical values here, regardless of host language. Changing +/// the algorithm — even reordering the input bytes — is a breaking change +/// to every deployed contract. +#[must_use] +pub fn stable_op_id(operation_type: OperationType, field_name: &str) -> u32 { + let mut hash: u32 = 0x811c_9dc5; + hash = fnv1a_step(hash, operation_type_rank(operation_type)); + for byte in field_name.as_bytes() { + hash = fnv1a_step(hash, *byte); + } + hash +} + +/// Canonical numeric rank for [`OperationType`]. +/// +/// `Query = 0`, `Mutation = 1`, `Subscription = 2`. These ranks are part of +/// the [`stable_op_id`] preimage and must never change. +#[must_use] +pub fn operation_type_rank(operation_type: OperationType) -> u8 { + match operation_type { + OperationType::Query => 0, + OperationType::Mutation => 1, + OperationType::Subscription => 2, + } +} + +#[inline] +fn fnv1a_step(hash: u32, byte: u8) -> u32 { + hash.wrapping_mul(0x0100_0193) ^ u32::from(byte) +} + /// A root schema operation field described as domain-empty data. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] @@ -59,3 +102,75 @@ pub struct SchemaOperation { /// Generic map of directives attached to the root field. pub directives: IndexMap, } + +#[cfg(test)] +mod tests { + use super::{operation_type_rank, stable_op_id, OperationType}; + + #[test] + fn operation_type_rank_is_pinned() { + assert_eq!(operation_type_rank(OperationType::Query), 0); + assert_eq!(operation_type_rank(OperationType::Mutation), 1); + assert_eq!(operation_type_rank(OperationType::Subscription), 2); + } + + #[test] + fn stable_op_id_matches_echo_wesley_gen_seed() { + // Empty field name with each rank must produce a single FNV-1a step + // from the seed 0x811c9dc5. + let seed: u32 = 0x811c_9dc5; + let multiplier: u32 = 0x0100_0193; + + let expected_query = seed.wrapping_mul(multiplier) ^ 0_u32; + let expected_mutation = seed.wrapping_mul(multiplier) ^ 1_u32; + let expected_subscription = seed.wrapping_mul(multiplier) ^ 2_u32; + + assert_eq!(stable_op_id(OperationType::Query, ""), expected_query); + assert_eq!(stable_op_id(OperationType::Mutation, ""), expected_mutation); + assert_eq!( + stable_op_id(OperationType::Subscription, ""), + expected_subscription + ); + } + + #[test] + fn stable_op_id_is_pinned_for_known_rope_operations() { + // These exact u32s are the contract surface between Rust and TypeScript + // emitters; any change here is a breaking change to every consumer that + // routes EINT envelopes by op_id. Pin them. + assert_eq!( + stable_op_id(OperationType::Mutation, "createBufferWorldline"), + 2_519_122_874 + ); + assert_eq!( + stable_op_id(OperationType::Mutation, "replaceRangeAsTick"), + 3_329_158_538 + ); + assert_eq!( + stable_op_id(OperationType::Mutation, "createCheckpoint"), + 3_744_251_216 + ); + assert_eq!( + stable_op_id(OperationType::Query, "worldlineSnapshot"), + 3_219_688_859 + ); + assert_eq!( + stable_op_id(OperationType::Query, "textWindow"), + 2_414_231_278 + ); + } + + #[test] + fn stable_op_id_separates_query_from_mutation_for_same_name() { + let q = stable_op_id(OperationType::Query, "createBufferWorldline"); + let m = stable_op_id(OperationType::Mutation, "createBufferWorldline"); + assert_ne!(q, m); + } + + #[test] + fn stable_op_id_is_byte_order_sensitive() { + let ab = stable_op_id(OperationType::Query, "ab"); + let ba = stable_op_id(OperationType::Query, "ba"); + assert_ne!(ab, ba); + } +} diff --git a/crates/wesley-emit-typescript/src/le_binary.rs b/crates/wesley-emit-typescript/src/le_binary.rs index 287c719b..a1b6c399 100644 --- a/crates/wesley-emit-typescript/src/le_binary.rs +++ b/crates/wesley-emit-typescript/src/le_binary.rs @@ -20,8 +20,8 @@ use std::fmt::Write; use wesley_core::{ - OperationArgument, OperationType, SchemaOperation, TypeDefinition, TypeKind, TypeReference, - WesleyIR, + stable_op_id, OperationArgument, OperationType, SchemaOperation, TypeDefinition, TypeKind, + TypeReference, WesleyIR, }; /// Default import path the emitted module uses to reach the TypeScript @@ -176,6 +176,8 @@ fn emit_input_object(out: &mut String, type_def: &TypeDefinition) { fn emit_operation_vars(out: &mut String, operation: &SchemaOperation) { let pascal = pascal_case(&operation.field_name); let vars_name = format!("{pascal}Vars"); + let op_const = op_const_name(&operation.field_name); + let op_id = stable_op_id(operation.operation_type, &operation.field_name); let scope = match operation.operation_type { OperationType::Query => "query", @@ -184,6 +186,13 @@ fn emit_operation_vars(out: &mut String, operation: &SchemaOperation) { }; let _ = writeln!(out, "\n// ─── {scope} {} ───", operation.field_name); + let _ = writeln!( + out, + "/** EINT op id for {} `{}`. Stable across Rust and TypeScript emitters. */", + scope, operation.field_name + ); + let _ = writeln!(out, "export const {op_const}: number = {op_id};"); + let _ = writeln!(out, "export interface {vars_name} {{"); for argument in &operation.arguments { let ts_ty = ts_type_for_reference(&argument.r#type); @@ -313,6 +322,29 @@ fn ts_type_for_reference(ty: &TypeReference) -> String { } } +/// Convert an operation field name to its `OP_` constant name. +/// +/// Matches the algorithm used by echo-wesley-gen's Rust emit so that emitted +/// op id constants share the same identifier on both sides of the boundary +/// (e.g. `createBufferWorldline` → `OP_CREATE_BUFFER_WORLDLINE`). +fn op_const_name(name: &str) -> String { + let mut out = String::new(); + for (index, ch) in name.chars().enumerate() { + if ch.is_alphanumeric() { + if ch.is_uppercase() && index > 0 { + out.push('_'); + } + out.push(ch.to_ascii_uppercase()); + } else { + out.push('_'); + } + } + if out.is_empty() { + return "OP_UNNAMED".to_string(); + } + format!("OP_{out}") +} + fn pascal_case(name: &str) -> String { let mut out = String::new(); let mut next_upper = true; @@ -448,4 +480,28 @@ mod tests { assert_eq!(property_key("with space"), "'with space'"); assert_eq!(property_key(""), "''"); } + + #[test] + fn op_const_name_matches_echo_wesley_gen_convention() { + assert_eq!(op_const_name("createBufferWorldline"), "OP_CREATE_BUFFER_WORLDLINE"); + assert_eq!(op_const_name("replaceRangeAsTick"), "OP_REPLACE_RANGE_AS_TICK"); + assert_eq!(op_const_name("makeWidget"), "OP_MAKE_WIDGET"); + assert_eq!(op_const_name(""), "OP_UNNAMED"); + } + + #[test] + fn emits_op_id_constant_for_each_operation() { + let ir = lower_schema_sdl(SDL).expect("schema lowers"); + let ops = list_schema_operations_sdl(SDL).expect("operations enumerable"); + + let ts = emit_le_binary_typescript(&ir, &ops, DEFAULT_CODEC_IMPORT); + + // Concrete op id pinned by wesley-core's stable_op_id tests. + let expected_op_id = + wesley_core::stable_op_id(wesley_core::OperationType::Mutation, "makeWidget"); + assert!( + ts.contains(&format!("export const OP_MAKE_WIDGET: number = {expected_op_id};")), + "expected OP_MAKE_WIDGET constant for op id {expected_op_id} in:\n{ts}", + ); + } } From 076bf294ea89ae0dd3f13f5fe877b399199425a1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 28 May 2026 14:18:00 -0700 Subject: [PATCH 4/6] docs(backlog): capture bad-code and cool-ideas from 0024 TS emit work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bad-code/wesley-cli-emit-handler-duplication: each `emit ` arm in wesley-cli is a copy-paste of the previous one; should be a declarative registry. - bad-code/stale-fixture-rename-test-leftovers: Phase 1 rename (hot-text-runtime → rope) missed `include_str!` paths and assertion strings in wesley-emit-typescript / wesley-emit-rust / wesley-cli / wesley-core test files. `cargo test -p ` fails to compile on these crates; library builds fine. - cool-ideas/wesley-ts-emitter-emits-zod-layer: Wesley already owns the type/codec emit; the Zod runtime-validation layer is the last hand-maintained drift surface. Generating it closes the gap. --- .../stale-fixture-rename-test-leftovers.md | 71 +++++++++++++++++++ .../wesley-cli-emit-handler-duplication.md | 69 ++++++++++++++++++ .../wesley-ts-emitter-emits-zod-layer.md | 70 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 docs/method/backlog/bad-code/stale-fixture-rename-test-leftovers.md create mode 100644 docs/method/backlog/bad-code/wesley-cli-emit-handler-duplication.md create mode 100644 docs/method/backlog/cool-ideas/wesley-ts-emitter-emits-zod-layer.md diff --git a/docs/method/backlog/bad-code/stale-fixture-rename-test-leftovers.md b/docs/method/backlog/bad-code/stale-fixture-rename-test-leftovers.md new file mode 100644 index 00000000..d2ede16f --- /dev/null +++ b/docs/method/backlog/bad-code/stale-fixture-rename-test-leftovers.md @@ -0,0 +1,71 @@ + + + +# Stale `hot-text-runtime` fixture refs break crate tests after the rope rename + +Status: bad code. + +## Where + +The Phase 1 rename +(`refactor(fixtures): update jedit-rope.graphql consumer-model fixture`, +commit `2d6630ac`) renamed +`test/fixtures/consumer-models/jedit-hot-text-runtime.graphql` to +`jedit-rope.graphql` but left stale references in: + +- `crates/wesley-emit-typescript/src/lib.rs` (`include_str!`, plus + `createdAtTickId` field-name assertions) +- `crates/wesley-emit-rust/src/lib.rs` +- `crates/wesley-cli/tests/cli.rs` +- `crates/wesley-core/tests/operation_analysis.rs` + +## Symptom + +`cargo test -p wesley-emit-typescript` (or any of the above crates) +fails to compile the test target with: + +``` +error: couldn't read `.../jedit-hot-text-runtime.graphql`: +No such file or directory +``` + +The library itself builds fine — only the test target is broken. That +means `cargo build` and the CLI binary work; the breakage is invisible +unless someone runs the crate-scoped test command. + +## Why it matters + +This blocked unit tests for new code added during the 0024 LE binary +codec TS emitter work (2026-05-28). The workaround was to skip the +unit-test target and verify via the wesley-cli end-to-end CLI +invocation against the real schema. That works, but the unit tests +should run. + +It's also potentially a CI surprise: depending on which jobs run +which `cargo test -p ...` invocations, this may or may not be caught. + +## Suggested fix + +Trivial mechanical update: + +1. Change `include_str!` paths from + `jedit-hot-text-runtime.graphql` to `jedit-rope.graphql` +2. Update the assertion strings that reference `createdAtTickId` to + `createdAtRopeRewriteId` (and similar Tick → RopeRewrite renames + that happened in jedit's Phase 1) +3. Possibly delete tests that asserted on now-removed Tick-prefixed + types + +## Why I didn't fix this in the 0024 work + +The CLAUDE.md global rule says "fix errors and warnings" but also "do +not silently fix pre-existing Git violations" — this is in the +ambiguous middle. I chose to flag rather than fix because (a) it's +unrelated to LE binary codec scope, (b) the assertion updates require +knowing the post-rename intent for each field, and (c) the unit tests +in `le_binary.rs` that I wrote are still gated by this breakage. + +## Surface when + +Anyone runs `cargo test -p wesley-emit-typescript` (or the other four +crates) and gets a compile error. That's the moment. diff --git a/docs/method/backlog/bad-code/wesley-cli-emit-handler-duplication.md b/docs/method/backlog/bad-code/wesley-cli-emit-handler-duplication.md new file mode 100644 index 00000000..5e1c5957 --- /dev/null +++ b/docs/method/backlog/bad-code/wesley-cli-emit-handler-duplication.md @@ -0,0 +1,69 @@ + + + +# `wesley emit ` handlers are copy-paste + +Status: bad code. + +## Where + +`crates/wesley-cli/src/main.rs` — `run_emit_command`. + +## Smell + +Each emit target (`rust`, `typescript`, `le-binary-typescript` as of +2026-05-28) is a 20–30 line `match` arm that does the same shape: + +1. Parse options +2. Read schema SDL +3. `lower_schema_sdl` + `list_schema_operations_sdl` +4. Optional law bundle load +5. Call the target's emit function +6. Write output +7. Write emit metadata sidecar + +Adding `emit le-binary-typescript` in this session meant copy-pasting +the `emit typescript` arm and changing four lines. The next emit +target (e.g., `emit fixture-vectors-json`, `emit zod`, +`emit le-binary-rust`) will pay the same cost. + +## Why it matters + +`wesley-cli/src/main.rs` is already 2200+ lines and growing. Each new +emit target adds: + +- A 25-line handler in `run_emit_command` +- A line in `print_emit_help` +- Possibly a new option in `ParsedOptions` (e.g., `--codec-import`) +- Possibly a new branch in `parse_options` + +That's four places to remember per new target. + +## Suggested refactor + +Introduce an `EmitTarget` trait or struct registry: + +```rust +struct EmitTarget { + name: &'static str, + options: &'static [EmitOption], // declarative + run: fn(&Ir, &[SchemaOperation], &EmitOptions) -> String, + generator_name: &'static str, + generator_version: &'static str, +} + +const EMIT_TARGETS: &[EmitTarget] = &[ + EmitTarget { name: "rust", ... }, + EmitTarget { name: "typescript", ... }, + EmitTarget { name: "le-binary-typescript", ... }, +]; +``` + +`run_emit_command` becomes a single 20-line dispatch over the +registry. `print_emit_help` iterates the same registry. New targets +are one declarative entry. + +## Surface when + +Adding a 4th emit target (likely `le-binary-rust` to mirror the TS +side, or `fixture-vectors-json` per the cool-idea card). diff --git a/docs/method/backlog/cool-ideas/wesley-ts-emitter-emits-zod-layer.md b/docs/method/backlog/cool-ideas/wesley-ts-emitter-emits-zod-layer.md new file mode 100644 index 00000000..897c7e40 --- /dev/null +++ b/docs/method/backlog/cool-ideas/wesley-ts-emitter-emits-zod-layer.md @@ -0,0 +1,70 @@ + + + +# Wesley TS emitter also emits a Zod runtime-validation layer + +Status: cool idea. + +## The idea + +Add a Wesley emit target — `emit zod-typescript` (or fold into +existing `emit typescript`) — that produces Zod schemas alongside the +TS interfaces: + +```ts +// Generated by Wesley TS emitter: +export interface CreateBufferWorldlineInput { + bufferKey: string; + initialText?: string | null; + ... +} + +// Same emit, Zod layer: +export const CreateBufferWorldlineInputSchema = z.object({ + bufferKey: z.string(), + initialText: z.string().nullable().optional(), + ... +}); +``` + +Both derive from the same Wesley IR. Drift is structurally impossible. + +## What it replaces + +jedit currently maintains hand-authored Zod schemas in +`src/adapters/jedit-echo-optic-codec.ts` (~250 lines of Zod that +mirrors the Wesley TS types). Plus a generated +`src/generated/jedit/rope.zod.generated.ts` produced by some other +generator. Drift between Wesley TS types and Zod schemas is currently +a hand-coordinated activity. + +## Why it matters + +Wesley's whole pitch is "one schema, all representations emit +atomically; drift is structurally impossible." Today that's true for +types + codecs + op ids; runtime validation lives outside the +guarantee. + +After the EINT cutover (0024 design Phase 7), the JSON-side Zod parse +in jedit-echo-optic-codec.ts becomes mostly dead code. The remaining +runtime validation (e.g., reading user input from a UI before +codec-encoding) still needs Zod or equivalent. Generating it from +Wesley closes the last drift gap. + +## Open questions + +- One target or merge into `emit typescript`? Merging means a + consumer that wants only types pays the Zod import cost; separating + is cleaner but adds a fourth emit target. +- Zod 3 or Zod 4? Wesley's existing zod adapter (`scripts/run-wesley-tool.mjs + host-node zod`) targets one specific version — would the Rust-native + emit target pick a different one? +- Does the schema's `default_value` translate to `.default(x)` or + `.optional()`? Both? The Wesley TS emit currently uses `?: T | null` + which suggests both nullable AND optional. + +## Surface when + +Touching `src/adapters/jedit-echo-optic-codec.ts` Zod definitions +during the Phase 5/6/7 jedit cutover work and feeling the urge to +hand-edit a Zod schema to match a type change. From 64b7fbc1ac1d5a1e7eade87c46ecbd60bcbcb733 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 1 Jun 2026 02:44:41 -0700 Subject: [PATCH 5/6] test(fixtures): clarify external consumer boundary --- README.md | 2 +- crates/wesley-cli/src/main.rs | 3 +- crates/wesley-cli/tests/cli.rs | 6 +- crates/wesley-core/src/domain/operation.rs | 24 +++---- .../wesley-core/tests/operation_analysis.rs | 6 +- crates/wesley-emit-rust/src/lib.rs | 3 +- .../wesley-emit-typescript/src/le_binary.rs | 47 ++++++------ crates/wesley-emit-typescript/src/lib.rs | 3 +- docs/JEDIT_CAPABILITY_PROGRESS.md | 4 +- .../stale-fixture-rename-test-leftovers.md | 71 ------------------- test/fixtures/README.md | 15 +++- test/fixtures/extensions/README.md | 16 +++++ .../extensions/stack-witness-0001/README.md | 9 +++ .../stack-witness-0001/fixture-extension.json | 37 ++++++++++ 14 files changed, 122 insertions(+), 124 deletions(-) delete mode 100644 docs/method/backlog/bad-code/stale-fixture-rename-test-leftovers.md create mode 100644 test/fixtures/extensions/README.md create mode 100644 test/fixtures/extensions/stack-witness-0001/README.md create mode 100644 test/fixtures/extensions/stack-witness-0001/fixture-extension.json diff --git a/README.md b/README.md index 32b25ce6..ed639e99 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ schema facts, and extract operation directive arguments. cargo wesley doctor cargo wesley schema lower --schema test/fixtures/ir-parity/small-schema.graphql --json cargo wesley schema hash --schema test/fixtures/ir-parity/small-schema.graphql -cargo wesley schema operations --schema test/fixtures/consumer-models/jedit-hot-text-runtime.graphql --json +cargo wesley schema operations --schema test/fixtures/consumer-models/jedit-rope.graphql --json cargo wesley schema diff --old old.graphql --new new.graphql --format summary --exit-code cargo wesley schema diff --schema schema.graphql --against HEAD --format summary cargo wesley law validate --schema test/fixtures/weslaw/contract-bundle-shape.graphql --law test/fixtures/weslaw/accepted/footprint-replace-range.weslaw.yaml diff --git a/crates/wesley-cli/src/main.rs b/crates/wesley-cli/src/main.rs index 35a31bdb..b28cc8f3 100644 --- a/crates/wesley-cli/src/main.rs +++ b/crates/wesley-cli/src/main.rs @@ -20,8 +20,7 @@ use wesley_emit_rust::{ }; use wesley_emit_typescript::{ emit_le_binary_typescript, emit_typescript_with_operations, DEFAULT_CODEC_IMPORT, - GENERATOR_NAME as TYPESCRIPT_GENERATOR_NAME, - GENERATOR_VERSION as TYPESCRIPT_GENERATOR_VERSION, + GENERATOR_NAME as TYPESCRIPT_GENERATOR_NAME, GENERATOR_VERSION as TYPESCRIPT_GENERATOR_VERSION, LE_BINARY_GENERATOR_NAME as LE_BINARY_TYPESCRIPT_GENERATOR_NAME, }; diff --git a/crates/wesley-cli/tests/cli.rs b/crates/wesley-cli/tests/cli.rs index c0e4ed74..e4344506 100644 --- a/crates/wesley-cli/tests/cli.rs +++ b/crates/wesley-cli/tests/cli.rs @@ -714,9 +714,7 @@ fn schema_hash_matches_l1_hash_fixture() { fn schema_operations_emit_root_operation_catalog_as_json() { let output = wesley() .args(["schema", "operations", "--schema"]) - .arg(fixture( - "test/fixtures/consumer-models/jedit-hot-text-runtime.graphql", - )) + .arg(fixture("test/fixtures/consumer-models/jedit-rope.graphql")) .arg("--json") .output() .expect("wesley should run"); @@ -1294,7 +1292,7 @@ fn emit_commands_include_jedit_operation_bindings() { let dir = temp_dir("emit-jedit-operation-bindings"); let rust_out = dir.join("generated").join("model.rs"); let typescript_out = dir.join("generated").join("types.ts"); - let schema = fixture("test/fixtures/consumer-models/jedit-hot-text-runtime.graphql"); + let schema = fixture("test/fixtures/consumer-models/jedit-rope.graphql"); let rust_output = wesley() .args(["emit", "rust", "--schema"]) diff --git a/crates/wesley-core/src/domain/operation.rs b/crates/wesley-core/src/domain/operation.rs index 85930638..bc16ca2e 100644 --- a/crates/wesley-core/src/domain/operation.rs +++ b/crates/wesley-core/src/domain/operation.rs @@ -115,7 +115,7 @@ mod tests { } #[test] - fn stable_op_id_matches_echo_wesley_gen_seed() { + fn stable_op_id_matches_canonical_seed() { // Empty field name with each rank must produce a single FNV-1a step // from the seed 0x811c9dc5. let seed: u32 = 0x811c_9dc5; @@ -134,36 +134,36 @@ mod tests { } #[test] - fn stable_op_id_is_pinned_for_known_rope_operations() { + fn stable_op_id_is_pinned_for_domain_neutral_operations() { // These exact u32s are the contract surface between Rust and TypeScript // emitters; any change here is a breaking change to every consumer that // routes EINT envelopes by op_id. Pin them. assert_eq!( - stable_op_id(OperationType::Mutation, "createBufferWorldline"), - 2_519_122_874 + stable_op_id(OperationType::Mutation, "createRecord"), + 1_670_356_121 ); assert_eq!( - stable_op_id(OperationType::Mutation, "replaceRangeAsTick"), - 3_329_158_538 + stable_op_id(OperationType::Mutation, "updateRecord"), + 3_583_386_294 ); assert_eq!( stable_op_id(OperationType::Mutation, "createCheckpoint"), 3_744_251_216 ); assert_eq!( - stable_op_id(OperationType::Query, "worldlineSnapshot"), - 3_219_688_859 + stable_op_id(OperationType::Query, "recordSnapshot"), + 3_126_837_072 ); assert_eq!( - stable_op_id(OperationType::Query, "textWindow"), - 2_414_231_278 + stable_op_id(OperationType::Query, "recordWindow"), + 3_315_867_368 ); } #[test] fn stable_op_id_separates_query_from_mutation_for_same_name() { - let q = stable_op_id(OperationType::Query, "createBufferWorldline"); - let m = stable_op_id(OperationType::Mutation, "createBufferWorldline"); + let q = stable_op_id(OperationType::Query, "createRecord"); + let m = stable_op_id(OperationType::Mutation, "createRecord"); assert_ne!(q, m); } diff --git a/crates/wesley-core/tests/operation_analysis.rs b/crates/wesley-core/tests/operation_analysis.rs index 3ec161e8..c26369b0 100644 --- a/crates/wesley-core/tests/operation_analysis.rs +++ b/crates/wesley-core/tests/operation_analysis.rs @@ -238,11 +238,11 @@ fn lists_schema_operations_with_arguments_results_and_directives() { } #[test] -fn lists_jedit_hot_text_runtime_schema_operations() { +fn lists_jedit_rope_fixture_schema_operations() { let operations = list_schema_operations_sdl(include_str!( - "../../../test/fixtures/consumer-models/jedit-hot-text-runtime.graphql" + "../../../test/fixtures/consumer-models/jedit-rope.graphql" )) - .expect("jedit hot text runtime operations should resolve"); + .expect("jedit rope fixture operations should resolve"); assert_eq!(operations.len(), 5); diff --git a/crates/wesley-emit-rust/src/lib.rs b/crates/wesley-emit-rust/src/lib.rs index 51e96402..c26cfbb7 100644 --- a/crates/wesley-emit-rust/src/lib.rs +++ b/crates/wesley-emit-rust/src/lib.rs @@ -976,8 +976,7 @@ pub struct UserFilter { #[test] fn emits_jedit_operation_bindings() { - let sdl = - include_str!("../../../test/fixtures/consumer-models/jedit-hot-text-runtime.graphql"); + let sdl = include_str!("../../../test/fixtures/consumer-models/jedit-rope.graphql"); let ir = lower_schema_sdl(sdl).expect("jedit runtime fixture should lower"); let operations = list_schema_operations_sdl(sdl).expect("jedit runtime operations should resolve"); diff --git a/crates/wesley-emit-typescript/src/le_binary.rs b/crates/wesley-emit-typescript/src/le_binary.rs index a1b6c399..61be9ff8 100644 --- a/crates/wesley-emit-typescript/src/le_binary.rs +++ b/crates/wesley-emit-typescript/src/le_binary.rs @@ -1,7 +1,7 @@ //! TypeScript LE binary codec emitter. //! -//! Produces a TypeScript module that mirrors the wire layout of the Rust -//! `echo_wasm_abi::codec` primitives byte-for-byte: +//! Produces a TypeScript module for Wesley's deterministic little-endian +//! variable codec: //! //! - Enums encode their zero-based variant index (declaration order) as `u32` LE. //! - Input objects encode their fields in declaration order with no separators. @@ -13,9 +13,9 @@ //! - Non-null list `[T!]!` → `u32` LE element count + inline elements. //! //! Every emitted operation produces an `encodeVars(v) -> Uint8Array` / -//! `decodeVars(b) -> Vars` pair whose layout matches the Rust -//! `__echo_wesley_generated::Vars` Encode/Decode impls in -//! `echo-wesley-gen`. +//! `decodeVars(b) -> Vars` pair. Hosts or extension modules can use +//! those helpers as their transport boundary without Wesley owning the runtime +//! behavior behind the operation. use std::fmt::Write; @@ -34,7 +34,7 @@ const HEADER: &str = "\ /* @generated by wesley-emit-typescript le-binary. Do not edit. */ // SPDX-License-Identifier: Apache-2.0 // -// Wire layout mirrors echo_wasm_abi::codec (Rust). Field order is SDL +// Wire layout is Wesley little-endian variable codec v0. Field order is SDL // declaration order. Enums encode their zero-based variant index as u32 LE. "; @@ -42,7 +42,7 @@ const HEADER: &str = "\ /// /// `codec_import_path` is the module specifier the emitted file uses to /// import `Writer`, `Reader`, and `CodecError`. Pass [`DEFAULT_CODEC_IMPORT`] -/// to use the jedit-style default. +/// when the generated file is two directories below the codec primitives. pub fn emit_le_binary_typescript( ir: &WesleyIR, operations: &[SchemaOperation], @@ -95,7 +95,10 @@ fn emit_enum(out: &mut String, type_def: &TypeDefinition) { ); let _ = writeln!(out, " switch (v) {{"); for (index, value) in type_def.enum_values.iter().enumerate() { - let _ = writeln!(out, " case '{value}': w.writeU32Le({index}); return;"); + let _ = writeln!( + out, + " case '{value}': w.writeU32Le({index}); return;" + ); } if type_def.enum_values.is_empty() { let _ = writeln!(out, " // no variants"); @@ -108,10 +111,7 @@ fn emit_enum(out: &mut String, type_def: &TypeDefinition) { let _ = writeln!(out, " }}"); let _ = writeln!(out, "}}"); - let _ = writeln!( - out, - "export function _dec{name}(r: Reader): {name} {{" - ); + let _ = writeln!(out, "export function _dec{name}(r: Reader): {name} {{"); let _ = writeln!(out, " const d = r.readU32Le();"); let _ = writeln!(out, " switch (d) {{"); for (index, value) in type_def.enum_values.iter().enumerate() { @@ -161,10 +161,7 @@ fn emit_input_object(out: &mut String, type_def: &TypeDefinition) { } let _ = writeln!(out, "}}"); - let _ = writeln!( - out, - "export function _dec{name}(r: Reader): {name} {{" - ); + let _ = writeln!(out, "export function _dec{name}(r: Reader): {name} {{"); let _ = writeln!(out, " return {{"); for field in &type_def.fields { emit_decode_field_initializer(out, &field.name, &field.r#type); @@ -324,9 +321,9 @@ fn ts_type_for_reference(ty: &TypeReference) -> String { /// Convert an operation field name to its `OP_` constant name. /// -/// Matches the algorithm used by echo-wesley-gen's Rust emit so that emitted -/// op id constants share the same identifier on both sides of the boundary -/// (e.g. `createBufferWorldline` → `OP_CREATE_BUFFER_WORLDLINE`). +/// Matches the Wesley operation-constant convention so emitted op id constants +/// share the same identifier shape across generated surfaces +/// (e.g. `createRecord` → `OP_CREATE_RECORD`). fn op_const_name(name: &str) -> String { let mut out = String::new(); for (index, ch) in name.chars().enumerate() { @@ -416,7 +413,7 @@ fn quote_string(value: &str) -> String { #[cfg(test)] mod tests { use super::*; - use wesley_core::{lower_schema_sdl, list_schema_operations_sdl}; + use wesley_core::{list_schema_operations_sdl, lower_schema_sdl}; const SDL: &str = " enum Color { RED GREEN BLUE } @@ -482,9 +479,9 @@ mod tests { } #[test] - fn op_const_name_matches_echo_wesley_gen_convention() { - assert_eq!(op_const_name("createBufferWorldline"), "OP_CREATE_BUFFER_WORLDLINE"); - assert_eq!(op_const_name("replaceRangeAsTick"), "OP_REPLACE_RANGE_AS_TICK"); + fn op_const_name_matches_wesley_operation_constant_convention() { + assert_eq!(op_const_name("createRecord"), "OP_CREATE_RECORD"); + assert_eq!(op_const_name("updateRecord"), "OP_UPDATE_RECORD"); assert_eq!(op_const_name("makeWidget"), "OP_MAKE_WIDGET"); assert_eq!(op_const_name(""), "OP_UNNAMED"); } @@ -500,7 +497,9 @@ mod tests { let expected_op_id = wesley_core::stable_op_id(wesley_core::OperationType::Mutation, "makeWidget"); assert!( - ts.contains(&format!("export const OP_MAKE_WIDGET: number = {expected_op_id};")), + ts.contains(&format!( + "export const OP_MAKE_WIDGET: number = {expected_op_id};" + )), "expected OP_MAKE_WIDGET constant for op id {expected_op_id} in:\n{ts}", ); } diff --git a/crates/wesley-emit-typescript/src/lib.rs b/crates/wesley-emit-typescript/src/lib.rs index a1fe6e0b..120a3070 100644 --- a/crates/wesley-emit-typescript/src/lib.rs +++ b/crates/wesley-emit-typescript/src/lib.rs @@ -743,8 +743,7 @@ export interface UserFilter { #[test] fn emits_jedit_operation_bindings() { - let sdl = - include_str!("../../../test/fixtures/consumer-models/jedit-hot-text-runtime.graphql"); + let sdl = include_str!("../../../test/fixtures/consumer-models/jedit-rope.graphql"); let ir = lower_schema_sdl(sdl).expect("jedit runtime fixture should lower"); let operations = list_schema_operations_sdl(sdl).expect("jedit runtime operations should resolve"); diff --git a/docs/JEDIT_CAPABILITY_PROGRESS.md b/docs/JEDIT_CAPABILITY_PROGRESS.md index 7e1343dd..3087a65a 100644 --- a/docs/JEDIT_CAPABILITY_PROGRESS.md +++ b/docs/JEDIT_CAPABILITY_PROGRESS.md @@ -31,7 +31,7 @@ The invariant is still Wesley's normal compiler boundary: - `test/fixtures/consumer-models/jedit-hot-text-core.graphql` is a representative jedit-shaped fixture copied into Wesley so tests do not depend on a sibling checkout. -- `test/fixtures/consumer-models/jedit-hot-text-runtime.graphql` is a full +- `test/fixtures/consumer-models/jedit-rope.graphql` is a full jedit hot text runtime fixture copied into Wesley for hermetic operation catalog tests. - Both emitters have tests against the jedit-shaped fixture. @@ -94,7 +94,7 @@ Suggested out-of-repo prompts: - In the jedit repository: replace handwritten hot text runtime shadow models with Rust and TypeScript artifacts generated from Wesley's - `test/fixtures/consumer-models/jedit-hot-text-runtime.graphql` equivalent, + `test/fixtures/consumer-models/jedit-rope.graphql` equivalent, then update imports until jedit uses the generated request/response bindings. - In the Echo repository: consume Wesley's schema operation catalog or generated Rust operation bindings, and keep Echo-owned `@wes_footprint` honesty checks diff --git a/docs/method/backlog/bad-code/stale-fixture-rename-test-leftovers.md b/docs/method/backlog/bad-code/stale-fixture-rename-test-leftovers.md deleted file mode 100644 index d2ede16f..00000000 --- a/docs/method/backlog/bad-code/stale-fixture-rename-test-leftovers.md +++ /dev/null @@ -1,71 +0,0 @@ - - - -# Stale `hot-text-runtime` fixture refs break crate tests after the rope rename - -Status: bad code. - -## Where - -The Phase 1 rename -(`refactor(fixtures): update jedit-rope.graphql consumer-model fixture`, -commit `2d6630ac`) renamed -`test/fixtures/consumer-models/jedit-hot-text-runtime.graphql` to -`jedit-rope.graphql` but left stale references in: - -- `crates/wesley-emit-typescript/src/lib.rs` (`include_str!`, plus - `createdAtTickId` field-name assertions) -- `crates/wesley-emit-rust/src/lib.rs` -- `crates/wesley-cli/tests/cli.rs` -- `crates/wesley-core/tests/operation_analysis.rs` - -## Symptom - -`cargo test -p wesley-emit-typescript` (or any of the above crates) -fails to compile the test target with: - -``` -error: couldn't read `.../jedit-hot-text-runtime.graphql`: -No such file or directory -``` - -The library itself builds fine — only the test target is broken. That -means `cargo build` and the CLI binary work; the breakage is invisible -unless someone runs the crate-scoped test command. - -## Why it matters - -This blocked unit tests for new code added during the 0024 LE binary -codec TS emitter work (2026-05-28). The workaround was to skip the -unit-test target and verify via the wesley-cli end-to-end CLI -invocation against the real schema. That works, but the unit tests -should run. - -It's also potentially a CI surprise: depending on which jobs run -which `cargo test -p ...` invocations, this may or may not be caught. - -## Suggested fix - -Trivial mechanical update: - -1. Change `include_str!` paths from - `jedit-hot-text-runtime.graphql` to `jedit-rope.graphql` -2. Update the assertion strings that reference `createdAtTickId` to - `createdAtRopeRewriteId` (and similar Tick → RopeRewrite renames - that happened in jedit's Phase 1) -3. Possibly delete tests that asserted on now-removed Tick-prefixed - types - -## Why I didn't fix this in the 0024 work - -The CLAUDE.md global rule says "fix errors and warnings" but also "do -not silently fix pre-existing Git violations" — this is in the -ambiguous middle. I chose to flag rather than fix because (a) it's -unrelated to LE binary codec scope, (b) the assertion updates require -knowing the post-rename intent for each field, and (c) the unit tests -in `le_binary.rs` that I wrote are still gated by this breakage. - -## Surface when - -Anyone runs `cargo test -p wesley-emit-typescript` (or the other four -crates) and gets a compile error. That's the moment. diff --git a/test/fixtures/README.md b/test/fixtures/README.md index 899bd9df..a56479ae 100644 --- a/test/fixtures/README.md +++ b/test/fixtures/README.md @@ -7,7 +7,8 @@ Fixtures live here so tests, docs, and demos all reference the same canonical in | Directory | Purpose | Consumed By | | -------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `examples/` | Canonical GraphQL schemas plus generated outputs for docs and HOLMES tests. | CLI bats (`holmes-e2e.bats`), documentation snippets. | -| `consumer-models/` | Hermetic consumer-shaped GraphQL fixtures for jedit and Stack Witness 0001 contract-boundary tests. | Wesley core operation catalog tests, Rust emitter tests, TypeScript emitter tests. | +| `consumer-models/` | Hermetic consumer-shaped GraphQL schemas used by fixture extensions. | Wesley core operation catalog tests, Rust emitter tests, TypeScript emitter tests. | +| `extensions/` | Descriptor-only fixture extension packages that point at schemas, vectors, and intended capabilities without becoming Wesley product modules. | Boundary tests and emitter tests that need external-consumer realism. | | `blade/` | Daywalker Deploys demo assets (schemas, run script, signing key instructions). | Docs walkthrough, manual CLI demos. | | `reference/` | Comprehensive SDL showcasing most directives; useful for experiments. | Manual runs, future regression suites. | | `rls-schema.graphql` | Focused schema exercising RLS directives. | `cli-e2e.bats`, `cli-e2e-real.bats`. | @@ -23,3 +24,15 @@ canonical variable codec. The durable target is `targetCodec: wesley-binary/v0`: Wesley-generated deterministic binary codecs shared by Rust and TypeScript. The fixture names the target now, but does not implement that codec yet. + +## Fixture Extension Boundary + +Consumer-shaped schemas may use product-like nouns because they emulate +external repositories. They do not grant Wesley base platform ownership over +those nouns. + +When a fixture needs more than standalone SDL, describe it under +`extensions//` as a fixture extension package. The descriptor should point +at the schema, vectors, and expected capability surfaces. Tests may consume +those files as hermetic inputs, but generic unit tests and public emitter docs +should stay domain-neutral. diff --git a/test/fixtures/extensions/README.md b/test/fixtures/extensions/README.md new file mode 100644 index 00000000..688398f2 --- /dev/null +++ b/test/fixtures/extensions/README.md @@ -0,0 +1,16 @@ +# Fixture Extensions + +Fixture extensions are descriptor-only packages for external-consumer realism. + +They let Wesley test schemas, vectors, emitter surfaces, and capability +metadata that look like real downstream use without making those domains part +of the base product. + +Rules: + +- Keep authored schemas and vectors in `test/fixtures/consumer-models/`. +- Put extension descriptors under `test/fixtures/extensions//`. +- Treat product nouns in extension descriptors as fixture data only. +- Keep algorithm unit tests and public emitter docs domain-neutral. +- Move reusable domain behavior to the owning external repo or module before it + becomes product behavior. diff --git a/test/fixtures/extensions/stack-witness-0001/README.md b/test/fixtures/extensions/stack-witness-0001/README.md new file mode 100644 index 00000000..e8bd1d81 --- /dev/null +++ b/test/fixtures/extensions/stack-witness-0001/README.md @@ -0,0 +1,9 @@ +# Stack Witness 0001 Fixture Extension + +This descriptor package models a small external consumer of Wesley-generated +artifacts. It is intentionally local and hermetic. + +The fixture stands in for the shape Echo and jedit exercise together: an +authored schema, operation metadata, declared footprints, and codec-vector +expectations. Wesley may use it for compiler and emitter coverage, but the +runtime behavior belongs to external hosts and modules. diff --git a/test/fixtures/extensions/stack-witness-0001/fixture-extension.json b/test/fixtures/extensions/stack-witness-0001/fixture-extension.json new file mode 100644 index 00000000..4a90f8de --- /dev/null +++ b/test/fixtures/extensions/stack-witness-0001/fixture-extension.json @@ -0,0 +1,37 @@ +{ + "apiVersion": "wesley.fixture-extension/v1", + "name": "stack-witness-0001", + "description": "Hermetic external-consumer fixture package for contract-boundary compiler and emitter tests.", + "schemas": [ + "../../consumer-models/stack-witness-0001-file-history.graphql" + ], + "vectors": [ + "../../consumer-models/stack-witness-0001-vectors.json" + ], + "capabilities": { + "wesley": { + "emitTargets": [ + "rust", + "typescript", + "le-binary-typescript" + ], + "targetCodecs": [ + "wesley-binary/v0" + ] + } + }, + "boundary": { + "domainOwnership": "external-fixture", + "wesleyOwns": [ + "schema lowering", + "operation metadata extraction", + "artifact emission" + ], + "wesleyDoesNotOwn": [ + "runtime execution", + "text editing semantics", + "Echo host behavior", + "jedit product behavior" + ] + } +} From 1061fa43cb57c3d32c0f3baea4c477eef1f4f227 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 1 Jun 2026 02:50:06 -0700 Subject: [PATCH 6/6] chore: ignore local pnpm store --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2f9bca1a..376bc1c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.pnpm-store/ target/ pnpm-lock.yaml .wesley-cache/