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/ 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 4173bd86..b28cc8f3 100644 --- a/crates/wesley-cli/src/main.rs +++ b/crates/wesley-cli/src/main.rs @@ -19,8 +19,9 @@ 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, - GENERATOR_VERSION as TYPESCRIPT_GENERATOR_VERSION, + 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 +533,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 +800,7 @@ struct ParsedOptions { operation: Option, out: Option, metadata_out: Option, + codec_import: Option, directive: Option, family: Option, profile: Option, @@ -947,6 +979,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 +2139,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-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 72093e2a..bc16ca2e 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_canonical_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_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, "createRecord"), + 1_670_356_121 + ); + assert_eq!( + 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, "recordSnapshot"), + 3_126_837_072 + ); + assert_eq!( + 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, "createRecord"); + let m = stable_op_id(OperationType::Mutation, "createRecord"); + 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-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 new file mode 100644 index 00000000..61be9ff8 --- /dev/null +++ b/crates/wesley-emit-typescript/src/le_binary.rs @@ -0,0 +1,506 @@ +//! TypeScript LE binary codec emitter. +//! +//! 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. +//! - `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. 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; + +use wesley_core::{ + stable_op_id, 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 is Wesley little-endian variable codec v0. 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`] +/// when the generated file is two directories below the codec primitives. +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 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", + OperationType::Mutation => "mutation", + OperationType::Subscription => "subscription", + }; + 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); + 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 + } +} + +/// Convert an operation field name to its `OP_` constant name. +/// +/// 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() { + 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; + 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::{list_schema_operations_sdl, lower_schema_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(""), "''"); + } + + #[test] + 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"); + } + + #[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}", + ); + } +} diff --git a/crates/wesley-emit-typescript/src/lib.rs b/crates/wesley-emit-typescript/src/lib.rs index 8bdfd459..120a3070 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); @@ -736,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/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. 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/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" 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" + ] + } +}