Skip to main content

arete_interpreter/
typescript.rs

1use crate::ast::*;
2use crate::identifiers::{typescript as ts_ident, IdentifierCase};
3use crate::stack_types::{
4    entity_idl, entity_program_name, resolved_type_namespaces, ResolvedTypeClaim,
5    StackResolvedTypes,
6};
7use arete_idl::utils::to_snake_case as idl_to_snake_case;
8use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
9
10/// Output structure for TypeScript generation
11#[derive(Debug, Clone)]
12pub struct TypeScriptOutput {
13    pub interfaces: String,
14    pub stack_definition: String,
15    pub imports: String,
16    pub schema_names: Vec<String>,
17}
18
19impl TypeScriptOutput {
20    pub fn full_file(&self) -> String {
21        format!(
22            "{}\n\n{}\n\n{}",
23            self.imports, self.interfaces, self.stack_definition
24        )
25    }
26}
27
28/// Configuration for TypeScript generation
29#[derive(Debug, Clone)]
30pub struct TypeScriptConfig {
31    pub package_name: String,
32    pub generate_helpers: bool,
33    pub interface_prefix: String,
34    pub export_const_name: String,
35    /// WebSocket URL for the stack. If None, generates a placeholder comment.
36    pub url: Option<String>,
37}
38
39impl Default for TypeScriptConfig {
40    fn default() -> Self {
41        Self {
42            package_name: "@usearete/react".to_string(),
43            generate_helpers: true,
44            interface_prefix: "".to_string(),
45            export_const_name: "STACK".to_string(),
46            url: None,
47        }
48    }
49}
50
51/// Trait for generating TypeScript code from AST components
52pub trait TypeScriptGenerator {
53    fn generate_typescript(&self, config: &TypeScriptConfig) -> String;
54}
55
56/// Trait for generating TypeScript interfaces
57pub trait TypeScriptInterfaceGenerator {
58    fn generate_interface(&self, name: &str, config: &TypeScriptConfig) -> String;
59}
60
61/// Trait for generating TypeScript type mappings
62pub trait TypeScriptTypeMapper {
63    fn to_typescript_type(&self) -> String;
64}
65
66/// Main TypeScript compiler for stream specs
67pub struct TypeScriptCompiler<S> {
68    spec: TypedStreamSpec<S>,
69    entity_name: String,
70    config: TypeScriptConfig,
71    idl: Option<serde_json::Value>, // IDL for enum type generation
72    handlers_json: Option<serde_json::Value>, // Raw handlers for event interface generation
73    views: Vec<ViewDef>,            // View definitions for derived views
74    already_emitted_types: HashSet<String>,
75    /// Resolved types earlier entities of the same stack module declared.
76    stack_types: StackResolvedTypes,
77    /// The program the entity's data comes from, when known.
78    program_name: Option<String>,
79    /// Extra JSDoc for entity fields by wire path (`state.total_deployed`),
80    /// such as the unit of a token amount.
81    field_notes: BTreeMap<String, String>,
82}
83
84/// Emitted names of an entity's resolved types.
85struct ResolvedTypeNames {
86    /// Resolved type name -> emitted TypeScript name.
87    names: HashMap<String, String>,
88    /// Emitted names an earlier entity of the stack already declared
89    /// identically; referenced, never declared again.
90    shared: HashSet<String>,
91}
92
93impl ResolvedTypeNames {
94    fn record(&mut self, resolved: &ResolvedStructType, claim: ResolvedTypeClaim) {
95        if claim.is_shared() {
96            self.shared.insert(claim.name().to_string());
97        }
98        self.names
99            .insert(resolved.type_name.clone(), claim.into_name());
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104struct StateViewKeyDefinition {
105    field_name: String,
106    typescript_type: String,
107}
108
109impl StateViewKeyDefinition {
110    fn object_type(&self) -> String {
111        format!(
112            "{{ {}: {} }}",
113            render_ts_property_name_literal(&self.field_name),
114            self.typescript_type
115        )
116    }
117
118    fn fields_literal(&self) -> String {
119        format!("['{}']", escape_ts_single_quotes(&self.field_name))
120    }
121}
122
123impl<S> TypeScriptCompiler<S> {
124    pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
125        Self {
126            spec,
127            entity_name,
128            config: TypeScriptConfig::default(),
129            idl: None,
130            handlers_json: None,
131            views: Vec::new(),
132            already_emitted_types: HashSet::new(),
133            stack_types: StackResolvedTypes::default(),
134            program_name: None,
135            field_notes: BTreeMap::new(),
136        }
137    }
138
139    pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
140        self.config = config;
141        self
142    }
143
144    pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
145        self.idl = idl;
146        self
147    }
148
149    pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
150        self.handlers_json = handlers;
151        self
152    }
153
154    pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
155        self.views = views;
156        self
157    }
158
159    pub fn with_already_emitted_types(mut self, types: HashSet<String>) -> Self {
160        self.already_emitted_types = types;
161        self
162    }
163
164    /// Extra JSDoc for entity fields, keyed by wire path
165    /// (`state.total_deployed`, or `ore_metadata` for a root field).
166    pub fn with_field_notes(mut self, notes: BTreeMap<String, String>) -> Self {
167        self.field_notes = notes;
168        self
169    }
170
171    fn with_stack_resolved_types(
172        mut self,
173        types: StackResolvedTypes,
174        program_name: Option<String>,
175    ) -> Self {
176        self.stack_types = types;
177        self.program_name = program_name;
178        self
179    }
180
181    pub fn compile(&self) -> TypeScriptOutput {
182        self.try_compile()
183            .expect("TypeScript SDK generation failed")
184    }
185
186    pub fn try_compile(&self) -> Result<TypeScriptOutput, String> {
187        let state_view_key = state_view_key_definition(
188            &self.entity_name,
189            &self.spec.identity,
190            &self.spec.field_mappings,
191            &self.spec.sections,
192        )?;
193        let imports = self.generate_imports();
194        let interfaces = self.generate_interfaces();
195        let schema_output = self.generate_schemas();
196        let combined_interfaces = if schema_output.definitions.is_empty() {
197            interfaces
198        } else if interfaces.is_empty() {
199            schema_output.definitions.clone()
200        } else {
201            format!("{}\n\n{}", interfaces, schema_output.definitions)
202        };
203        let stack_definition = self.generate_stack_definition(&state_view_key);
204
205        Ok(TypeScriptOutput {
206            imports,
207            interfaces: combined_interfaces,
208            stack_definition,
209            schema_names: schema_output.names,
210        })
211    }
212
213    fn generate_imports(&self) -> String {
214        "import { z } from 'zod';".to_string()
215    }
216
217    fn generate_view_helpers(&self) -> String {
218        generate_view_helpers_static()
219    }
220
221    fn generate_interfaces(&self) -> String {
222        let mut interfaces = Vec::new();
223        let mut processed_types = HashSet::new();
224        let all_sections = self.collect_interface_sections();
225
226        // Deduplicate fields within each section and generate interfaces
227        // Skip root section - its fields will be flattened into main entity interface
228        for (section_name, fields) in all_sections {
229            if !is_root_section(&section_name) && processed_types.insert(section_name.clone()) {
230                let deduplicated_fields = self.deduplicate_fields(fields);
231                let interface =
232                    self.generate_interface_from_fields(&section_name, &deduplicated_fields);
233                interfaces.push(interface);
234            }
235        }
236
237        // Generate main entity interface
238        let main_interface = self.generate_main_entity_interface();
239        interfaces.push(main_interface);
240
241        let nested_interfaces = self.generate_nested_interfaces();
242        interfaces.extend(nested_interfaces);
243
244        let builtin_interfaces = self.generate_builtin_resolver_interfaces();
245        interfaces.extend(builtin_interfaces);
246
247        if self.should_emit_capture_wrapper() {
248            interfaces.push(self.generate_capture_wrapper_interface());
249        }
250
251        if self.has_event_types() {
252            interfaces.push(self.generate_event_wrapper_interface());
253        }
254
255        interfaces.join("\n\n")
256    }
257
258    fn collect_interface_sections(&self) -> BTreeMap<String, Vec<TypeScriptField>> {
259        let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
260
261        // Collect all interface sections from all handlers
262        for handler in &self.spec.handlers {
263            let interface_sections = self.extract_interface_sections_from_handler(handler);
264
265            for (section_name, mut fields) in interface_sections {
266                all_sections
267                    .entry(section_name)
268                    .or_default()
269                    .append(&mut fields);
270            }
271        }
272
273        // Add unmapped fields from spec.sections ONCE (not per handler)
274        // These are fields without #[map] or #[event] attributes
275        self.add_unmapped_fields(&mut all_sections);
276
277        all_sections
278    }
279
280    fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
281        let mut seen = HashSet::new();
282        let mut unique_fields = Vec::new();
283
284        // Sort fields by name for consistent output
285        fields.sort_by(|a, b| a.name.cmp(&b.name));
286
287        for field in fields {
288            if seen.insert(field.name.clone()) {
289                unique_fields.push(field);
290            }
291        }
292
293        unique_fields
294    }
295
296    fn extract_interface_sections_from_handler(
297        &self,
298        handler: &TypedHandlerSpec<S>,
299    ) -> BTreeMap<String, Vec<TypeScriptField>> {
300        let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
301
302        for mapping in &handler.mappings {
303            if !mapping.emit {
304                continue;
305            }
306            let parts: Vec<&str> = mapping.target_path.split('.').collect();
307
308            if parts.len() > 1 {
309                let section_name = parts[0];
310                let field_name = parts[1];
311
312                let ts_field = TypeScriptField::patch(
313                    field_name.to_string(),
314                    self.mapping_to_typescript_type(mapping),
315                    self.is_field_nullable(mapping),
316                );
317
318                sections
319                    .entry(section_name.to_string())
320                    .or_default()
321                    .push(ts_field);
322            } else {
323                let ts_field = TypeScriptField::patch(
324                    mapping.target_path.clone(),
325                    self.mapping_to_typescript_type(mapping),
326                    self.is_field_nullable(mapping),
327                );
328
329                sections
330                    .entry("Root".to_string())
331                    .or_default()
332                    .push(ts_field);
333            }
334        }
335
336        sections
337    }
338
339    fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
340        // NEW: Enhanced approach using AST type information if available
341        if !self.spec.sections.is_empty() {
342            // Use type information from the enhanced AST
343            for section in &self.spec.sections {
344                let section_fields = sections.entry(section.name.clone()).or_default();
345
346                for field_info in &section.fields {
347                    if !field_info.emit {
348                        continue;
349                    }
350                    // Check if field is already mapped
351                    let already_exists = section_fields
352                        .iter()
353                        .any(|f| f.name == field_info.field_name);
354
355                    if !already_exists {
356                        // For computed fields, check field_mappings for resolver type info
357                        let field_path = format!("{}.{}", section.name, field_info.field_name);
358                        let effective_field_info =
359                            if let Some(mapping) = self.spec.field_mappings.get(&field_path) {
360                                // Use mapping's inner_type if it's a resolver output type
361                                if mapping
362                                    .inner_type
363                                    .as_ref()
364                                    .is_some_and(|t| is_builtin_resolver_type(t))
365                                {
366                                    mapping
367                                } else {
368                                    field_info
369                                }
370                            } else {
371                                field_info
372                            };
373                        let (raw_name, canonical_name) = localize_section_field_names(
374                            section.name.as_str(),
375                            effective_field_info,
376                        );
377
378                        section_fields.push(TypeScriptField::from_names(
379                            raw_name,
380                            canonical_name,
381                            self.field_type_info_to_typescript(effective_field_info),
382                            effective_field_info.is_optional,
383                            FieldPresence::Patch,
384                        ));
385                    }
386                }
387            }
388        } else {
389            // FALLBACK: Use field mappings from spec if sections aren't available yet
390            for (field_path, field_type_info) in &self.spec.field_mappings {
391                if !field_type_info.emit {
392                    continue;
393                }
394                let parts: Vec<&str> = field_path.split('.').collect();
395                if parts.len() > 1 {
396                    let section_name = parts[0];
397                    let field_name = parts[1];
398
399                    let section_fields = sections.entry(section_name.to_string()).or_default();
400
401                    let already_exists = section_fields.iter().any(|f| f.name == field_name);
402
403                    if !already_exists {
404                        section_fields.push(TypeScriptField::from_names(
405                            field_name.to_string(),
406                            field_type_info.canonical_field_name(),
407                            self.base_type_to_typescript(
408                                &field_type_info.base_type,
409                                field_type_info.effective_integer_kind(),
410                                field_type_info.is_array,
411                            ),
412                            field_type_info.is_optional,
413                            FieldPresence::Patch,
414                        ));
415                    }
416                }
417            }
418        }
419    }
420
421    fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
422        let interface_name = self.section_interface_name(name);
423        let fields = self.documented_entity_fields(Some(name), fields);
424        render_interface_from_ts_fields(&interface_name, &fields, true)
425    }
426
427    /// `fields` of the entity's `section` (`None`: its root), each with the
428    /// JSDoc [`Self::entity_field_doc`] gives it.
429    fn documented_entity_fields(
430        &self,
431        section: Option<&str>,
432        fields: &[TypeScriptField],
433    ) -> Vec<TypeScriptField> {
434        fields
435            .iter()
436            .map(|field| TypeScriptField {
437                description: self.entity_field_doc(section, field),
438                ..field.clone()
439            })
440            .collect()
441    }
442
443    /// The JSDoc of an entity field: its wire name (what raw frames and CLI
444    /// and MCP output call it) when that is not its TypeScript name, why it
445    /// is a `bigint`, and any note the generator was given for it, such as
446    /// the unit of a token amount.
447    fn entity_field_doc(&self, section: Option<&str>, field: &TypeScriptField) -> Option<String> {
448        let wire = match section {
449            Some(section) if !is_root_section(section) => format!("{section}.{}", field.raw_name),
450            _ => field.raw_name.clone(),
451        };
452        let mut parts = Vec::new();
453        if field.raw_name != field.name {
454            parts.push(format!("Wire name `{wire}` (CLI and MCP output)."));
455        }
456        if field.ts_type.contains("bigint") {
457            parts.push(BIGINT_FIELD_DOC.to_string());
458        }
459        if let Some(note) = self.field_notes.get(&wire) {
460            parts.push(note.clone());
461        }
462        (!parts.is_empty()).then(|| parts.join(" "))
463    }
464
465    fn section_interface_name(&self, name: &str) -> String {
466        section_interface_name(&self.config.interface_prefix, &self.entity_name, name)
467    }
468
469    fn generate_main_entity_interface(&self) -> String {
470        let entity_name = to_pascal_case(&self.entity_name);
471
472        let main_fields = self.collect_main_entity_fields();
473        if main_fields.is_empty() {
474            return format!(
475                "export interface {} {{\n  // Generated interface - extend as needed\n}}",
476                entity_name
477            );
478        }
479
480        let main_fields = self.documented_entity_fields(None, &main_fields);
481        render_interface_from_ts_fields(&entity_name, &main_fields, true)
482    }
483
484    fn generate_schemas(&self) -> SchemaOutput {
485        let patch_schema_types = self.patch_schema_type_names();
486        let mut definitions = Vec::new();
487        let mut names = Vec::new();
488        let mut seen = HashSet::new();
489
490        let mut push_schema = |schema_name: String, definition: String, export_name: bool| {
491            if seen.insert(schema_name.clone()) {
492                if export_name {
493                    names.push(schema_name);
494                }
495                definitions.push(definition);
496            }
497        };
498
499        for (schema_name, definition) in self.generate_builtin_resolver_schemas() {
500            push_schema(schema_name, definition, true);
501        }
502
503        if self.has_event_types() {
504            push_schema(
505                "EventWrapperSchema".to_string(),
506                self.generate_event_wrapper_schema(),
507                true,
508            );
509        }
510
511        if self.should_emit_capture_wrapper() {
512            push_schema(
513                "CaptureWrapperSchema".to_string(),
514                self.generate_capture_wrapper_schema(),
515                false,
516            );
517        }
518
519        for (schema_name, definition) in self.generate_resolved_type_schemas(&patch_schema_types) {
520            push_schema(schema_name, definition, true);
521        }
522
523        for (schema_name, definition) in
524            self.generate_resolved_type_patch_schemas(&patch_schema_types)
525        {
526            push_schema(schema_name, definition, false);
527        }
528
529        for (schema_name, definition) in self.generate_event_schemas() {
530            push_schema(schema_name, definition, true);
531        }
532
533        for (schema_name, definition) in self.generate_idl_enum_schemas() {
534            push_schema(schema_name, definition, true);
535        }
536
537        let all_sections = self.collect_interface_sections();
538
539        for (section_name, fields) in &all_sections {
540            if is_root_section(section_name) {
541                continue;
542            }
543            let deduplicated_fields = self.deduplicate_fields(fields.clone());
544            let interface_name = self.section_interface_name(section_name);
545            let schema_definition = self.generate_schema_for_fields(
546                &interface_name,
547                &deduplicated_fields,
548                true,
549                SchemaMode::Canonical,
550                &patch_schema_types,
551            );
552            push_schema(format!("{}Schema", interface_name), schema_definition, true);
553
554            let patch_schema_definition = self.generate_schema_for_fields(
555                &interface_name,
556                &deduplicated_fields,
557                false,
558                SchemaMode::Patch,
559                &patch_schema_types,
560            );
561            push_schema(
562                format!("{}PatchSchema", interface_name),
563                patch_schema_definition,
564                false,
565            );
566        }
567
568        let entity_name = to_pascal_case(&self.entity_name);
569        let main_fields = self.collect_main_entity_fields();
570        let entity_schema = self.generate_schema_for_fields(
571            &entity_name,
572            &main_fields,
573            true,
574            SchemaMode::Canonical,
575            &patch_schema_types,
576        );
577        push_schema(format!("{}Schema", entity_name), entity_schema, true);
578
579        let patch_schema = self.generate_schema_for_fields(
580            &entity_name,
581            &main_fields,
582            false,
583            SchemaMode::Patch,
584            &patch_schema_types,
585        );
586        push_schema(format!("{}PatchSchema", entity_name), patch_schema, false);
587
588        let completed_schema =
589            self.generate_completed_entity_schema(&entity_name, &patch_schema_types);
590        push_schema(
591            format!("{}CompletedSchema", entity_name),
592            completed_schema,
593            true,
594        );
595
596        SchemaOutput {
597            definitions: definitions.join("\n\n"),
598            names,
599        }
600    }
601
602    fn generate_event_wrapper_schema(&self) -> String {
603        r#"export const EventWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
604  timestamp: z.number(),
605  data,
606  slot: z.number().optional(),
607  signature: z.string().optional(),
608  event_index: z.number().optional(),
609  ix_path: z.string().optional(),
610});"#
611            .to_string()
612    }
613
614    fn generate_capture_wrapper_schema(&self) -> String {
615        r#"export const CaptureWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
616  timestamp: z.number(),
617  account_address: z.string(),
618  data,
619  slot: z.number().optional(),
620  signature: z.string().optional(),
621}).transform((value) => ({
622  timestamp: value.timestamp,
623  accountAddress: value.account_address,
624  data: value.data,
625  ...(value.slot !== undefined ? { slot: value.slot } : {}),
626  ...(value.signature !== undefined ? { signature: value.signature } : {}),
627}));"#
628            .to_string()
629    }
630
631    fn generate_builtin_resolver_schemas(&self) -> Vec<(String, String)> {
632        let mut schemas = Vec::new();
633        let registry = crate::resolvers::builtin_resolver_registry();
634
635        for resolver in registry.definitions() {
636            let output_type = resolver.output_type();
637            let should_emit = self.uses_builtin_type(output_type)
638                && !self.already_emitted_types.contains(output_type);
639
640            // Also check if any types from the resolver's typescript_schema are used
641            let extra_types_used = if let Some(ts_schema) = resolver.typescript_schema() {
642                // Extract type names from export statements (simple string parsing)
643                ts_schema.definition.lines().any(|line| {
644                    let line = line.trim();
645                    // Match "export const TypeNameSchema"
646                    if let Some(rest) = line.strip_prefix("export const ") {
647                        let parts: Vec<&str> = rest.split_whitespace().collect();
648                        if parts.len() >= 2 && parts[1] == "=" {
649                            // Extract the base type name from "TypeNameSchema"
650                            let schema_name = parts[0];
651                            if let Some(type_name) = schema_name.strip_suffix("Schema") {
652                                return self.uses_builtin_type(type_name)
653                                    && !self.already_emitted_types.contains(type_name);
654                            }
655                        }
656                    }
657                    false
658                })
659            } else {
660                false
661            };
662
663            if (should_emit || extra_types_used)
664                && !self.already_emitted_types.contains(output_type)
665            {
666                if let Some(schema) = resolver.typescript_schema() {
667                    schemas.push((schema.name.to_string(), schema.definition.to_string()));
668                }
669            }
670        }
671
672        schemas
673    }
674
675    fn uses_builtin_type(&self, type_name: &str) -> bool {
676        // Check section fields
677        for section in &self.spec.sections {
678            for field in &section.fields {
679                if field.inner_type.as_deref() == Some(type_name) {
680                    return true;
681                }
682            }
683        }
684        // Check field_mappings for computed fields (they may have resolver types not in sections)
685        for field_info in self.spec.field_mappings.values() {
686            if field_info.inner_type.as_deref() == Some(type_name) {
687                return true;
688            }
689        }
690        false
691    }
692
693    fn generate_builtin_resolver_interfaces(&self) -> Vec<String> {
694        let mut interfaces = Vec::new();
695        let registry = crate::resolvers::builtin_resolver_registry();
696
697        for resolver in registry.definitions() {
698            let output_type = resolver.output_type();
699            let should_emit = self.uses_builtin_type(output_type)
700                && !self.already_emitted_types.contains(output_type);
701
702            // Also check if any types from the resolver's typescript_interface are used
703            let extra_types_used = if let Some(ts_interface) = resolver.typescript_interface() {
704                // Extract type names from export statements (simple string parsing)
705                ts_interface.lines().any(|line| {
706                    let line = line.trim();
707                    // Match "export type TypeName" or "export interface TypeName"
708                    if let Some(rest) = line.strip_prefix("export type ") {
709                        if let Some(type_name) = rest.split_whitespace().next() {
710                            return self.uses_builtin_type(type_name)
711                                && !self.already_emitted_types.contains(type_name);
712                        }
713                    } else if let Some(rest) = line.strip_prefix("export interface ") {
714                        if let Some(type_name) = rest.split_whitespace().next() {
715                            return self.uses_builtin_type(type_name)
716                                && !self.already_emitted_types.contains(type_name);
717                        }
718                    }
719                    false
720                })
721            } else {
722                false
723            };
724
725            if should_emit || extra_types_used {
726                if let Some(interface) = resolver.typescript_interface() {
727                    interfaces.push(interface.to_string());
728                }
729            }
730        }
731
732        interfaces
733    }
734
735    fn collect_main_entity_fields(&self) -> Vec<TypeScriptField> {
736        let mut sections = BTreeMap::new();
737
738        for handler in &self.spec.handlers {
739            for mapping in &handler.mappings {
740                if !mapping.emit {
741                    continue;
742                }
743                let parts: Vec<&str> = mapping.target_path.split('.').collect();
744                if parts.len() > 1 {
745                    sections.insert(parts[0], true);
746                }
747            }
748        }
749
750        if !self.spec.sections.is_empty() {
751            for section in &self.spec.sections {
752                if section.fields.iter().any(|field| field.emit) {
753                    sections.insert(&section.name, true);
754                }
755            }
756        } else {
757            for mapping in &self.spec.handlers {
758                for field_mapping in &mapping.mappings {
759                    if !field_mapping.emit {
760                        continue;
761                    }
762                    let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
763                    if parts.len() > 1 {
764                        sections.insert(parts[0], true);
765                    }
766                }
767            }
768        }
769
770        let mut fields = Vec::new();
771
772        for section in sections.keys() {
773            if !is_root_section(section) {
774                let base_name = if self.entity_name.contains("Game") {
775                    "Game"
776                } else {
777                    &self.entity_name
778                };
779                let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
780                fields.push(TypeScriptField::patch(
781                    section.to_string(),
782                    section_interface_name,
783                    false,
784                ));
785            }
786        }
787
788        fields.extend(self.root_entity_fields());
789        fields
790    }
791
792    /// The fields of the entity's root section, which the main entity
793    /// interface declares beside its section objects.
794    fn root_entity_fields(&self) -> Vec<TypeScriptField> {
795        let mut fields = Vec::new();
796        for section in &self.spec.sections {
797            if is_root_section(&section.name) {
798                for field in &section.fields {
799                    if !field.emit {
800                        continue;
801                    }
802                    fields.push(TypeScriptField::from_names(
803                        field.raw_field_name().to_string(),
804                        field.canonical_field_name(),
805                        self.field_type_info_to_typescript(field),
806                        field.is_optional,
807                        FieldPresence::Patch,
808                    ));
809                }
810            }
811        }
812        fields
813    }
814
815    /// Every field of the entity's row as its TypeScript interfaces declare
816    /// it: section fields as `section.field`, then root fields.
817    fn entity_field_references(&self) -> Vec<TypeScriptFieldReference> {
818        let mut references = Vec::new();
819        let sections = self.collect_interface_sections();
820        for field in self.collect_main_entity_fields() {
821            let Some(section_fields) = sections
822                .get(&field.raw_name)
823                .filter(|_| !is_root_section(&field.raw_name))
824            else {
825                continue;
826            };
827            for member in self.deduplicate_fields(section_fields.clone()) {
828                references.push(TypeScriptFieldReference {
829                    path: format!("{}.{}", field.name, member.name),
830                    wire: format!("{}.{}", field.raw_name, member.raw_name),
831                    ts_type: member.ts_type.clone(),
832                    nullable: member.nullable,
833                });
834            }
835        }
836        for field in self.root_entity_fields() {
837            references.push(TypeScriptFieldReference {
838                path: field.name.clone(),
839                wire: field.raw_name.clone(),
840                ts_type: field.ts_type.clone(),
841                nullable: field.nullable,
842            });
843        }
844        references
845    }
846
847    fn generate_schema_for_fields(
848        &self,
849        name: &str,
850        fields: &[TypeScriptField],
851        required: bool,
852        mode: SchemaMode,
853        patch_schema_types: &HashSet<String>,
854    ) -> String {
855        if fields.is_empty() {
856            return format!(
857                "export const {} = z.object({{}});",
858                schema_constant_name(name, mode)
859            );
860        }
861
862        let mut field_definitions = Vec::new();
863        let mut transform_fields = Vec::new();
864
865        for field in fields {
866            let base_schema = field.zod_schema.clone().unwrap_or_else(|| {
867                self.typescript_type_to_zod_for_schema(&field.ts_type, mode, patch_schema_types)
868            });
869            let with_nullable = if field.nullable {
870                format!("{}.nullable()", base_schema)
871            } else {
872                base_schema
873            };
874            let nullable_keys_can_be_absent = matches!(mode, SchemaMode::Canonical);
875            let schema = if required || matches!(field.presence, FieldPresence::Required) {
876                if field.nullable && nullable_keys_can_be_absent {
877                    // Nullable entity fields are projected with LastWrite semantics and may be
878                    // absent until a value exists. Keep them nullable and key-optional in
879                    // completed schemas so partial hydration never rejects the entity.
880                    format!("{}.optional()", with_nullable)
881                } else {
882                    with_nullable
883                }
884            } else {
885                format!("{}.optional()", with_nullable)
886            };
887
888            field_definitions.push(format!("  {}: {},", field.raw_name, schema));
889            if mode == SchemaMode::Patch {
890                transform_fields.push(format!(
891                    "  ...(value.{raw_name} !== undefined ? {{ {field_name}: value.{raw_name} }} : {{}}),",
892                    raw_name = field.raw_name,
893                    field_name = field.name,
894                ));
895            } else {
896                transform_fields.push(format!("  {}: value.{},", field.name, field.raw_name));
897            }
898        }
899
900        format!(
901            "export const {} = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
902            schema_constant_name(name, mode),
903            field_definitions.join("\n"),
904            transform_fields.join("\n")
905        )
906    }
907
908    fn generate_completed_entity_schema(
909        &self,
910        entity_name: &str,
911        patch_schema_types: &HashSet<String>,
912    ) -> String {
913        let main_fields = self.collect_main_entity_fields();
914        self.generate_schema_for_fields(
915            &format!("{}Completed", entity_name),
916            &main_fields,
917            true,
918            SchemaMode::Canonical,
919            patch_schema_types,
920        )
921    }
922
923    fn generate_resolved_type_schemas(
924        &self,
925        patch_schema_types: &HashSet<String>,
926    ) -> Vec<(String, String)> {
927        let mut schemas = Vec::new();
928        let resolved_type_names = self.resolved_type_names();
929        // Types an earlier entity declared are referenced, not redeclared.
930        let mut generated_types = resolved_type_names.shared.clone();
931        let resolved_name_map = resolved_type_names.names;
932
933        for section in &self.spec.sections {
934            for field_info in &section.fields {
935                if let Some(resolved) = &field_info.resolved_type {
936                    let type_name =
937                        self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
938
939                    if !generated_types.insert(type_name.clone()) {
940                        continue;
941                    }
942
943                    if resolved.is_enum {
944                        let variants: Vec<String> = resolved
945                            .enum_variants
946                            .iter()
947                            .map(|v| format!("\"{}\"", to_pascal_case(v)))
948                            .collect();
949                        let schema = if variants.is_empty() {
950                            format!("export const {}Schema = z.string();", type_name)
951                        } else {
952                            format!(
953                                "export const {}Schema = z.enum([{}]);",
954                                type_name,
955                                variants.join(", ")
956                            )
957                        };
958                        schemas.push((format!("{}Schema", type_name), schema));
959                        continue;
960                    }
961
962                    let schema = self.generate_schema_for_fields(
963                        &type_name,
964                        &self.resolved_fields_to_typescript_fields(&resolved.fields),
965                        true,
966                        SchemaMode::Canonical,
967                        patch_schema_types,
968                    );
969                    schemas.push((format!("{}Schema", type_name), schema));
970                }
971            }
972        }
973
974        schemas
975    }
976
977    fn generate_resolved_type_patch_schemas(
978        &self,
979        patch_schema_types: &HashSet<String>,
980    ) -> Vec<(String, String)> {
981        let mut schemas = Vec::new();
982        let resolved_type_names = self.resolved_type_names();
983        // Types an earlier entity declared are referenced, not redeclared.
984        let mut generated_types = resolved_type_names.shared.clone();
985        let resolved_name_map = resolved_type_names.names;
986
987        for section in &self.spec.sections {
988            for field_info in &section.fields {
989                if let Some(resolved) = &field_info.resolved_type {
990                    let type_name =
991                        self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
992
993                    if !generated_types.insert(type_name.clone()) || resolved.is_enum {
994                        continue;
995                    }
996
997                    let schema = self.generate_schema_for_fields(
998                        &type_name,
999                        &self.resolved_fields_to_typescript_fields(&resolved.fields),
1000                        false,
1001                        SchemaMode::Patch,
1002                        patch_schema_types,
1003                    );
1004                    schemas.push((format!("{}PatchSchema", type_name), schema));
1005                }
1006            }
1007        }
1008
1009        schemas
1010    }
1011
1012    fn generate_event_schemas(&self) -> Vec<(String, String)> {
1013        let mut schemas = Vec::new();
1014        let mut generated_types = HashSet::new();
1015
1016        let handlers = match &self.handlers_json {
1017            Some(h) => h.as_array(),
1018            None => return schemas,
1019        };
1020
1021        let handlers_array = match handlers {
1022            Some(arr) => arr,
1023            None => return schemas,
1024        };
1025
1026        for handler in handlers_array {
1027            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1028                for mapping in mappings {
1029                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1030                        if target_path.contains(".events.") || target_path.starts_with("events.") {
1031                            if let Some(source) = mapping.get("source") {
1032                                if let Some(event_data) = self.extract_event_data(source) {
1033                                    if let Some(handler_source) = handler.get("source") {
1034                                        if let Some(instruction_name) =
1035                                            self.extract_instruction_name(handler_source)
1036                                        {
1037                                            let event_field_name =
1038                                                target_path.split('.').next_back().unwrap_or("");
1039                                            let interface_name = format!(
1040                                                "{}Event",
1041                                                to_pascal_case(event_field_name)
1042                                            );
1043
1044                                            if generated_types.insert(interface_name.clone()) {
1045                                                if let Some(schema) = self
1046                                                    .generate_event_schema_from_idl(
1047                                                        &interface_name,
1048                                                        &instruction_name,
1049                                                        &event_data,
1050                                                    )
1051                                                {
1052                                                    schemas.push((
1053                                                        format!("{}Schema", interface_name),
1054                                                        schema,
1055                                                    ));
1056                                                }
1057                                            }
1058                                        }
1059                                    }
1060                                }
1061                            }
1062                        }
1063                    }
1064                }
1065            }
1066        }
1067
1068        schemas
1069    }
1070
1071    fn generate_event_schema_from_idl(
1072        &self,
1073        interface_name: &str,
1074        rust_instruction_name: &str,
1075        captured_fields: &[(String, Option<String>)],
1076    ) -> Option<String> {
1077        if captured_fields.is_empty() {
1078            return Some(format!(
1079                "export const {}Schema = z.object({{}});",
1080                interface_name
1081            ));
1082        }
1083
1084        let idl_value = self.idl.as_ref()?;
1085        let instructions = idl_value.get("instructions")?.as_array()?;
1086
1087        let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1088        let args = instruction.get("args")?.as_array()?;
1089
1090        let mut fields = Vec::new();
1091        for (field_name, transform) in captured_fields {
1092            for arg in args {
1093                if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1094                    if arg_name == field_name {
1095                        if let Some(arg_type) = arg.get("type") {
1096                            let ts_type =
1097                                self.idl_type_to_typescript(arg_type, transform.as_deref());
1098                            fields.push(TypeScriptField::patch(
1099                                field_name.to_string(),
1100                                ts_type,
1101                                false,
1102                            ));
1103                        }
1104                        break;
1105                    }
1106                }
1107            }
1108        }
1109
1110        Some(render_schema_from_ts_fields(interface_name, &fields, true))
1111    }
1112
1113    /// Enums emitted by the legacy raw-IDL compiler also need every type
1114    /// reachable through their payloads, even without an account reference.
1115    fn generate_idl_enum_artifacts(&self) -> Vec<(String, String, String)> {
1116        let definitions: Vec<IdlTypeDefSnapshot> = self
1117            .idl
1118            .as_ref()
1119            .and_then(|idl| idl.get("types"))
1120            .and_then(|types| types.as_array())
1121            .into_iter()
1122            .flatten()
1123            .filter_map(|def| serde_json::from_value(def.clone()).ok())
1124            .collect();
1125        let type_defs = definitions
1126            .iter()
1127            .map(|def| (def.name.clone(), def))
1128            .collect();
1129        let mut required = BTreeSet::new();
1130        for def in &definitions {
1131            if matches!(def.type_def, IdlTypeDefKindSnapshot::Enum { .. }) {
1132                collect_required_defined_types(
1133                    &IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
1134                        defined: IdlDefinedInnerSnapshot::Simple(def.name.clone()),
1135                    }),
1136                    &type_defs,
1137                    &HashSet::new(),
1138                    &mut required,
1139                );
1140            }
1141        }
1142        let name_map: BTreeMap<_, _> = required
1143            .iter()
1144            .map(|name| (name.clone(), to_pascal_case(name)))
1145            .collect();
1146        required
1147            .into_iter()
1148            .filter(|name| !self.already_emitted_types.contains(&name_map[name]))
1149            .filter_map(|name| {
1150                let mut def = (*type_defs.get(&name)?).clone();
1151                if let IdlTypeDefKindSnapshot::Enum { variants, .. } = &mut def.type_def {
1152                    for variant in variants {
1153                        variant.name = to_pascal_case(&variant.name);
1154                    }
1155                }
1156                generate_type_defs_from_idl_type(&def, &name_map)
1157            })
1158            .collect()
1159    }
1160
1161    fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> {
1162        self.generate_idl_enum_artifacts()
1163            .into_iter()
1164            .map(|(_, name, schema)| (name, schema))
1165            .collect()
1166    }
1167
1168    fn typescript_type_to_zod_for_schema(
1169        &self,
1170        ts_type: &str,
1171        mode: SchemaMode,
1172        patch_schema_types: &HashSet<String>,
1173    ) -> String {
1174        typescript_type_to_zod_for_schema_static(ts_type, mode, patch_schema_types)
1175    }
1176
1177    fn patch_schema_type_names(&self) -> HashSet<String> {
1178        let mut names = HashSet::new();
1179        let resolved_name_map = self.build_resolved_type_name_map();
1180
1181        for section in &self.spec.sections {
1182            if !is_root_section(&section.name) && section.fields.iter().any(|field| field.emit) {
1183                names.insert(self.section_interface_name(&section.name));
1184            }
1185
1186            for field in &section.fields {
1187                let Some(resolved) = &field.resolved_type else {
1188                    continue;
1189                };
1190
1191                if resolved.is_enum {
1192                    continue;
1193                }
1194
1195                names.insert(
1196                    self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map),
1197                );
1198            }
1199        }
1200
1201        names.insert("TokenMetadata".to_string());
1202        names
1203    }
1204
1205    fn generate_stack_definition(&self, state_view_key: &StateViewKeyDefinition) -> String {
1206        let stack_name = to_kebab_case(&self.entity_name);
1207        let entity_pascal = to_pascal_case(&self.entity_name);
1208        let export_name = format!(
1209            "{}_{}",
1210            self.entity_name.to_uppercase(),
1211            self.config.export_const_name
1212        );
1213
1214        let view_helpers = self.generate_view_helpers();
1215        let derived_views = self.generate_derived_view_entries();
1216        let schema_names = self.generate_schemas().names;
1217        let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
1218        for name in schema_names {
1219            unique_schemas.insert(name);
1220        }
1221        let schemas_block = if unique_schemas.is_empty() {
1222            String::new()
1223        } else {
1224            let schema_entries: Vec<String> = unique_schemas
1225                .iter()
1226                .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
1227                .map(|name| format!("    {}: {},", name.trim_end_matches("Schema"), name))
1228                .collect();
1229            if schema_entries.is_empty() {
1230                String::new()
1231            } else {
1232                format!("\n  schemas: {{\n{}\n  }},", schema_entries.join("\n"))
1233            }
1234        };
1235
1236        let patch_schemas_block = format!(
1237            "\n  patchSchemas: {{\n    {entity}: {entity}PatchSchema,\n  }},",
1238            entity = entity_pascal
1239        );
1240
1241        // Generate URL line - either actual URL or placeholder comment
1242        let url_line = match &self.config.url {
1243            Some(url) => format!("  url: '{}',", url),
1244            None => "  url: '', // TODO: Set after first deployment or pass useArete(..., { url })"
1245                .to_string(),
1246        };
1247
1248        format!(
1249            r#"{}
1250
1251// ============================================================================
1252// Stack Definition
1253// ============================================================================
1254
1255/** Stack definition for {} */
1256export const {} = {{
1257  name: '{}',
1258{}
1259  views: {{
1260    {}: {{
1261      state: stateView<{}, {}>('{}/state', {}),
1262      list: listView<{}>('{}/list'),{}
1263    }},
1264  }},{}{}
1265}} as const;
1266
1267/** Type alias for the stack */
1268export type {}Stack = typeof {};
1269
1270/** Default export for convenience */
1271export default {};"#,
1272            view_helpers,
1273            entity_pascal,
1274            export_name,
1275            stack_name,
1276            url_line,
1277            self.entity_name,
1278            entity_pascal,
1279            state_view_key.object_type(),
1280            self.entity_name,
1281            state_view_key.fields_literal(),
1282            entity_pascal,
1283            self.entity_name,
1284            derived_views,
1285            schemas_block,
1286            patch_schemas_block,
1287            entity_pascal,
1288            export_name,
1289            export_name
1290        )
1291    }
1292
1293    fn generate_derived_view_entries(&self) -> String {
1294        let derived_views: Vec<&ViewDef> = self
1295            .views
1296            .iter()
1297            .filter(|v| {
1298                !v.id.ends_with("/state")
1299                    && !v.id.ends_with("/list")
1300                    && v.id.starts_with(&self.entity_name)
1301            })
1302            .collect();
1303
1304        if derived_views.is_empty() {
1305            return String::new();
1306        }
1307
1308        let entity_pascal = to_pascal_case(&self.entity_name);
1309        let mut entries = Vec::new();
1310
1311        for view in derived_views {
1312            let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1313
1314            entries.push(format!(
1315                "\n      {}: listView<{}>('{}'),",
1316                view_name, entity_pascal, view.id
1317            ));
1318        }
1319
1320        entries.join("")
1321    }
1322
1323    fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
1324        // First, try to resolve from AST field mappings
1325        if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
1326            let ts_type = self.field_type_info_to_typescript(field_info);
1327
1328            // If it's an Append strategy, wrap in array
1329            if matches!(mapping.population, PopulationStrategy::Append) {
1330                return if ts_type.ends_with("[]") {
1331                    ts_type
1332                } else {
1333                    format!("{}[]", ts_type)
1334                };
1335            }
1336
1337            return ts_type;
1338        }
1339
1340        // Fallback to legacy inference
1341        match &mapping.population {
1342            PopulationStrategy::Append => {
1343                // For arrays, try to infer the element type
1344                match &mapping.source {
1345                    MappingSource::AsEvent { .. } => "any[]".to_string(),
1346                    _ => "any[]".to_string(),
1347                }
1348            }
1349            _ => {
1350                // Infer type from source and field name
1351                let base_type = match &mapping.source {
1352                    MappingSource::FromSource { .. } => {
1353                        self.infer_type_from_field_name(&mapping.target_path)
1354                    }
1355                    MappingSource::Constant(value) => value_to_typescript_type(value),
1356                    MappingSource::AsEvent { .. } => "any".to_string(),
1357                    _ => "any".to_string(),
1358                };
1359
1360                // Apply transformations to type
1361                if let Some(transform) = &mapping.transform {
1362                    match transform {
1363                        Transformation::HexEncode | Transformation::HexDecode => {
1364                            "string".to_string()
1365                        }
1366                        Transformation::Base58Encode | Transformation::Base58Decode => {
1367                            "string".to_string()
1368                        }
1369                        Transformation::ToString => "string".to_string(),
1370                        Transformation::ToNumber => "number".to_string(),
1371                    }
1372                } else {
1373                    base_type
1374                }
1375            }
1376        }
1377    }
1378
1379    fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
1380        if let Some(resolved) = &field_info.resolved_type {
1381            let interface_name = self.resolved_type_to_interface_name(resolved);
1382
1383            let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
1384            {
1385                format!("EventWrapper<{}>", interface_name)
1386            } else if resolved.is_account && self.is_capture_field(field_info) {
1387                format!("CaptureWrapper<{}>", interface_name)
1388            } else {
1389                interface_name
1390            };
1391
1392            let with_array = if field_info.is_array {
1393                format!("{}[]", base_type)
1394            } else {
1395                base_type
1396            };
1397
1398            return with_array;
1399        }
1400
1401        if let Some(inner_type) = &field_info.inner_type {
1402            if is_builtin_resolver_type(inner_type) {
1403                return inner_type.clone();
1404            }
1405        }
1406
1407        if let Some(ts_type) = typescript_integer_type(
1408            field_info.effective_integer_kind(),
1409            field_info
1410                .inner_type
1411                .as_deref()
1412                .or(Some(field_info.rust_type_name.as_str())),
1413        ) {
1414            return if field_info.is_array {
1415                format!("{}[]", ts_type)
1416            } else {
1417                ts_type.to_string()
1418            };
1419        }
1420
1421        // Arrays of scalar non-integer primitives (e.g. Vec<f64> display
1422        // fields) map to the corresponding primitive array instead of any[].
1423        if field_info.base_type == BaseType::Array && field_info.is_array {
1424            if let Some(element) = field_info
1425                .inner_type
1426                .as_deref()
1427                .and_then(typescript_scalar_array_element)
1428            {
1429                return format!("{}[]", element);
1430            }
1431        }
1432
1433        if field_info.base_type == BaseType::Any
1434            || (field_info.base_type == BaseType::Array
1435                && field_info.inner_type.as_deref() == Some("Value"))
1436        {
1437            if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
1438                return if field_info.is_array {
1439                    format!("{}[]", event_type)
1440                } else {
1441                    event_type
1442                };
1443            }
1444        }
1445
1446        self.base_type_to_typescript(
1447            &field_info.base_type,
1448            field_info.effective_integer_kind(),
1449            field_info.is_array,
1450        )
1451    }
1452
1453    /// Find the generated event interface name for a given field
1454    fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
1455        // Use the raw JSON handlers if available
1456        let handlers = self.handlers_json.as_ref()?.as_array()?;
1457
1458        // Look through handlers to find event mappings for this field
1459        for handler in handlers {
1460            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1461                for mapping in mappings {
1462                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1463                        // Check if this mapping targets our field (e.g., "events.created")
1464                        let target_parts: Vec<&str> = target_path.split('.').collect();
1465                        if let Some(target_field) = target_parts.last() {
1466                            if *target_field == field_name {
1467                                // Check if this is an event mapping
1468                                if let Some(source) = mapping.get("source") {
1469                                    if self.extract_event_data(source).is_some() {
1470                                        // Generate the interface name (e.g., "created" -> "CreatedEvent")
1471                                        return Some(format!(
1472                                            "{}Event",
1473                                            to_pascal_case(field_name)
1474                                        ));
1475                                    }
1476                                }
1477                            }
1478                        }
1479                    }
1480                }
1481            }
1482        }
1483        None
1484    }
1485
1486    /// Generate TypeScript interface name from resolved type
1487    fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
1488        self.build_resolved_type_name_map()
1489            .get(&resolved.type_name)
1490            .cloned()
1491            .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1492    }
1493
1494    /// Generate nested interfaces for all resolved types in the AST
1495    fn generate_nested_interfaces(&self) -> Vec<String> {
1496        let mut interfaces = Vec::new();
1497        let resolved_type_names = self.resolved_type_names();
1498        // Types an earlier entity declared are referenced, not redeclared.
1499        let mut generated_types = self.already_emitted_types.clone();
1500        generated_types.extend(resolved_type_names.shared);
1501        let resolved_name_map = resolved_type_names.names;
1502
1503        // Collect all resolved types from all sections
1504        for section in &self.spec.sections {
1505            for field_info in &section.fields {
1506                if let Some(resolved) = &field_info.resolved_type {
1507                    let type_name =
1508                        self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
1509
1510                    // Only generate each type once
1511                    if generated_types.insert(type_name) {
1512                        let interface = self.generate_interface_for_resolved_type(resolved);
1513                        interfaces.push(interface);
1514                    }
1515                }
1516            }
1517        }
1518
1519        // Generate event interfaces from instruction handlers
1520        interfaces.extend(self.generate_event_interfaces(&mut generated_types));
1521
1522        for (interface, schema_name, _) in self.generate_idl_enum_artifacts() {
1523            let type_name = schema_name.trim_end_matches("Schema");
1524            if generated_types.insert(type_name.to_string()) {
1525                interfaces.push(interface);
1526            }
1527        }
1528
1529        interfaces
1530    }
1531
1532    /// Generate TypeScript interfaces for event types from instruction handlers
1533    fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
1534        let mut interfaces = Vec::new();
1535
1536        // Use the raw JSON handlers if available
1537        let handlers = match &self.handlers_json {
1538            Some(h) => h.as_array(),
1539            None => return interfaces,
1540        };
1541
1542        let handlers_array = match handlers {
1543            Some(arr) => arr,
1544            None => return interfaces,
1545        };
1546
1547        // Look through handlers to find instruction-based event mappings
1548        for handler in handlers_array {
1549            // Check if this handler has event mappings
1550            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1551                for mapping in mappings {
1552                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1553                        // Check if the target is an event field (contains ".events." or starts with "events.")
1554                        if target_path.contains(".events.") || target_path.starts_with("events.") {
1555                            // Check if the source is AsEvent
1556                            if let Some(source) = mapping.get("source") {
1557                                if let Some(event_data) = self.extract_event_data(source) {
1558                                    // Extract instruction name from handler source
1559                                    if let Some(handler_source) = handler.get("source") {
1560                                        if let Some(instruction_name) =
1561                                            self.extract_instruction_name(handler_source)
1562                                        {
1563                                            // Generate interface name from target path (e.g., "events.created" -> "CreatedEvent")
1564                                            let event_field_name =
1565                                                target_path.split('.').next_back().unwrap_or("");
1566                                            let interface_name = format!(
1567                                                "{}Event",
1568                                                to_pascal_case(event_field_name)
1569                                            );
1570
1571                                            // Only generate once
1572                                            if generated_types.insert(interface_name.clone()) {
1573                                                if let Some(interface) = self
1574                                                    .generate_event_interface_from_idl(
1575                                                        &interface_name,
1576                                                        &instruction_name,
1577                                                        &event_data,
1578                                                    )
1579                                                {
1580                                                    interfaces.push(interface);
1581                                                }
1582                                            }
1583                                        }
1584                                    }
1585                                }
1586                            }
1587                        }
1588                    }
1589                }
1590            }
1591        }
1592
1593        interfaces
1594    }
1595
1596    /// Extract event field data from a mapping source
1597    fn extract_event_data(
1598        &self,
1599        source: &serde_json::Value,
1600    ) -> Option<Vec<(String, Option<String>)>> {
1601        if let Some(as_event) = source.get("AsEvent") {
1602            if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
1603                let mut event_fields = Vec::new();
1604                for field in fields {
1605                    if let Some(from_source) = field.get("FromSource") {
1606                        if let Some(path) = from_source
1607                            .get("path")
1608                            .and_then(|p| p.get("segments"))
1609                            .and_then(|s| s.as_array())
1610                        {
1611                            // Get the last segment as the field name (e.g., ["data", "game_id"] -> "game_id")
1612                            if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
1613                                let transform = from_source
1614                                    .get("transform")
1615                                    .and_then(|t| t.as_str())
1616                                    .map(|s| s.to_string());
1617                                event_fields.push((field_name.to_string(), transform));
1618                            }
1619                        }
1620                    }
1621                }
1622                return Some(event_fields);
1623            }
1624        }
1625        None
1626    }
1627
1628    /// Extract instruction name from handler source, returning the raw PascalCase name
1629    fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
1630        if let Some(source_obj) = source.get("Source") {
1631            if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
1632                let instruction_part =
1633                    crate::event_type_helpers::strip_event_type_suffix(type_name);
1634                return Some(instruction_part.to_string());
1635            }
1636        }
1637        None
1638    }
1639
1640    /// Find an instruction in the IDL by name, handling different naming conventions.
1641    /// IDLs may use snake_case (pumpfun: "admin_set_creator") or camelCase (ore: "claimSol").
1642    /// The input name comes from Rust types which are PascalCase ("AdminSetCreator", "ClaimSol").
1643    fn find_instruction_in_idl<'a>(
1644        &self,
1645        instructions: &'a [serde_json::Value],
1646        rust_name: &str,
1647    ) -> Option<&'a serde_json::Value> {
1648        let normalized_search = normalize_for_comparison(rust_name);
1649
1650        for instruction in instructions {
1651            if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
1652                if normalize_for_comparison(idl_name) == normalized_search {
1653                    return Some(instruction);
1654                }
1655            }
1656        }
1657        None
1658    }
1659
1660    /// Generate a TypeScript interface for an event from IDL instruction data
1661    fn generate_event_interface_from_idl(
1662        &self,
1663        interface_name: &str,
1664        rust_instruction_name: &str,
1665        captured_fields: &[(String, Option<String>)],
1666    ) -> Option<String> {
1667        if captured_fields.is_empty() {
1668            return Some(format!("export interface {} {{}}", interface_name));
1669        }
1670
1671        let idl_value = self.idl.as_ref()?;
1672        let instructions = idl_value.get("instructions")?.as_array()?;
1673
1674        let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1675        let args = instruction.get("args")?.as_array()?;
1676
1677        let mut fields = Vec::new();
1678        for (field_name, transform) in captured_fields {
1679            for arg in args {
1680                if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1681                    if arg_name == field_name {
1682                        if let Some(arg_type) = arg.get("type") {
1683                            let ts_type =
1684                                self.idl_type_to_typescript(arg_type, transform.as_deref());
1685                            fields.push(TypeScriptField::patch(
1686                                field_name.to_string(),
1687                                ts_type,
1688                                false,
1689                            ));
1690                        }
1691                        break;
1692                    }
1693                }
1694            }
1695        }
1696
1697        if !fields.is_empty() {
1698            return Some(render_interface_from_ts_fields(
1699                interface_name,
1700                &fields,
1701                true,
1702            ));
1703        }
1704
1705        None
1706    }
1707
1708    /// Convert an IDL type (from JSON) to TypeScript, considering transforms
1709    fn idl_type_to_typescript(
1710        &self,
1711        idl_type: &serde_json::Value,
1712        transform: Option<&str>,
1713    ) -> String {
1714        #![allow(clippy::only_used_in_recursion)]
1715        // If there's a HexEncode transform, the result is always a string
1716        if transform == Some("HexEncode") {
1717            return "string".to_string();
1718        }
1719
1720        // Handle different IDL type formats
1721        if let Some(type_str) = idl_type.as_str() {
1722            return match type_str {
1723                "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
1724                "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => "number".to_string(),
1725                "f32" | "f64" => "number".to_string(),
1726                "bool" => "boolean".to_string(),
1727                "string" => "string".to_string(),
1728                "pubkey" | "publicKey" => "string".to_string(),
1729                "bytes" => "string".to_string(),
1730                _ => "any".to_string(),
1731            };
1732        }
1733
1734        // Handle complex types (option, vec, etc.)
1735        if let Some(type_obj) = idl_type.as_object() {
1736            if let Some(option_type) = type_obj.get("option") {
1737                let inner = self.idl_type_to_typescript(option_type, None);
1738                return format!("{} | null", inner);
1739            }
1740            if let Some(vec_type) = type_obj.get("vec") {
1741                let inner = self.idl_type_to_typescript(vec_type, None);
1742                return format!("{}[]", inner);
1743            }
1744        }
1745
1746        "any".to_string()
1747    }
1748
1749    /// Generate a TypeScript interface from a resolved struct type
1750    fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
1751        let interface_name = self.resolved_type_to_interface_name(resolved);
1752
1753        // Handle enums as TypeScript union types
1754        if resolved.is_enum {
1755            let variants: Vec<String> = resolved
1756                .enum_variants
1757                .iter()
1758                .map(|v| format!("\"{}\"", to_pascal_case(v)))
1759                .collect();
1760
1761            return format!("export type {} = {};", interface_name, variants.join(" | "));
1762        }
1763
1764        render_interface_from_ts_fields(
1765            &interface_name,
1766            &self.resolved_fields_to_typescript_fields(&resolved.fields),
1767            true,
1768        )
1769    }
1770
1771    /// Convert a resolved field to TypeScript type
1772    fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
1773        if let Some(ts_type) =
1774            typescript_integer_type(field.effective_integer_kind(), Some(&field.field_type))
1775        {
1776            return if field.is_array {
1777                format!("{}[]", ts_type)
1778            } else {
1779                ts_type.to_string()
1780            };
1781        }
1782        let base_ts =
1783            self.base_type_to_typescript(&field.base_type, field.effective_integer_kind(), false);
1784
1785        if field.is_array {
1786            format!("{}[]", base_ts)
1787        } else {
1788            base_ts
1789        }
1790    }
1791
1792    fn resolved_fields_to_typescript_fields(
1793        &self,
1794        fields: &[ResolvedField],
1795    ) -> Vec<TypeScriptField> {
1796        fields
1797            .iter()
1798            .map(|field| {
1799                TypeScriptField::from_names(
1800                    field.raw_field_name().to_string(),
1801                    field.canonical_field_name(),
1802                    self.resolved_field_to_typescript(field),
1803                    field.is_optional,
1804                    FieldPresence::Patch,
1805                )
1806            })
1807            .collect()
1808    }
1809
1810    /// Check if the spec has any event types
1811    fn has_event_types(&self) -> bool {
1812        for section in &self.spec.sections {
1813            for field_info in &section.fields {
1814                if let Some(resolved) = &field_info.resolved_type {
1815                    if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1816                        return true;
1817                    }
1818                }
1819            }
1820        }
1821        false
1822    }
1823
1824    fn has_capture_types(&self) -> bool {
1825        self.spec
1826            .sections
1827            .iter()
1828            .flat_map(|section| &section.fields)
1829            .any(|field| self.is_capture_field(field))
1830    }
1831
1832    fn should_emit_capture_wrapper(&self) -> bool {
1833        self.has_capture_types() && !self.already_emitted_types.contains("CaptureWrapper")
1834    }
1835
1836    fn is_capture_field(&self, field_info: &FieldTypeInfo) -> bool {
1837        let raw_name = field_info.raw_field_name();
1838        self.spec.handlers.iter().any(|handler| {
1839            handler.mappings.iter().any(|mapping| {
1840                matches!(&mapping.source, MappingSource::AsCapture { .. })
1841                    && (mapping.target_path == field_info.field_name
1842                        || mapping.target_path == raw_name)
1843            })
1844        })
1845    }
1846
1847    fn build_resolved_type_name_map(&self) -> HashMap<String, String> {
1848        self.resolved_type_names().names
1849    }
1850
1851    fn resolved_type_names(&self) -> ResolvedTypeNames {
1852        let mut reserved_names = self.already_emitted_types.clone();
1853        reserved_names.insert(to_pascal_case(&self.entity_name));
1854
1855        for section in &self.spec.sections {
1856            if !is_root_section(&section.name) && section.fields.iter().any(|field| field.emit) {
1857                reserved_names.insert(self.section_interface_name(&section.name));
1858            }
1859        }
1860
1861        let namespaces = self.resolved_type_namespaces();
1862        let mut plan = ResolvedTypeNames {
1863            names: HashMap::new(),
1864            shared: HashSet::new(),
1865        };
1866
1867        for section in &self.spec.sections {
1868            for field_info in &section.fields {
1869                if !field_info.emit {
1870                    continue;
1871                }
1872
1873                let Some(resolved) = &field_info.resolved_type else {
1874                    continue;
1875                };
1876
1877                if plan.names.contains_key(&resolved.type_name) {
1878                    continue;
1879                }
1880
1881                let emitted_name = unique_resolved_type_name_ts(resolved, &mut reserved_names);
1882                let claim = self.stack_types.claim(
1883                    resolved,
1884                    emitted_name,
1885                    &to_pascal_case(&resolved.type_name),
1886                    &namespaces,
1887                    &mut reserved_names,
1888                );
1889                plan.record(resolved, claim);
1890            }
1891        }
1892
1893        // Types only hidden fields use keep their plain name without reserving
1894        // it, unless an earlier entity of the stack already declared that name.
1895        for section in &self.spec.sections {
1896            for field_info in &section.fields {
1897                let Some(resolved) = &field_info.resolved_type else {
1898                    continue;
1899                };
1900                let plain_name = to_pascal_case(&resolved.type_name);
1901                if field_info.emit
1902                    || plan.names.contains_key(&resolved.type_name)
1903                    || !self.stack_types.is_declared(&plain_name)
1904                {
1905                    continue;
1906                }
1907                let claim = self.stack_types.claim(
1908                    resolved,
1909                    plain_name.clone(),
1910                    &plain_name,
1911                    &namespaces,
1912                    &mut reserved_names,
1913                );
1914                plan.record(resolved, claim);
1915            }
1916        }
1917
1918        plan
1919    }
1920
1921    fn resolved_type_namespaces(&self) -> Vec<String> {
1922        let program_name = self.program_name.as_deref().or_else(|| {
1923            self.idl
1924                .as_ref()
1925                .and_then(|idl| idl.get("name"))
1926                .and_then(|name| name.as_str())
1927        });
1928        resolved_type_namespaces(program_name, &self.entity_name)
1929    }
1930
1931    /// The resolved types this entity declares (every one it emits that an
1932    /// earlier entity of the stack did not already declare identically).
1933    fn declared_resolved_types(&self) -> Vec<(String, ResolvedStructType)> {
1934        let plan = self.resolved_type_names();
1935        let mut declared = Vec::new();
1936        let mut seen = HashSet::new();
1937        for section in &self.spec.sections {
1938            for field_info in &section.fields {
1939                let Some(resolved) = &field_info.resolved_type else {
1940                    continue;
1941                };
1942                let name = self.resolved_type_to_interface_name_with_map(resolved, &plan.names);
1943                if !plan.shared.contains(&name) && seen.insert(name.clone()) {
1944                    declared.push((name, resolved.clone()));
1945                }
1946            }
1947        }
1948        declared
1949    }
1950
1951    fn resolved_type_to_interface_name_with_map(
1952        &self,
1953        resolved: &ResolvedStructType,
1954        resolved_name_map: &HashMap<String, String>,
1955    ) -> String {
1956        resolved_name_map
1957            .get(&resolved.type_name)
1958            .cloned()
1959            .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1960    }
1961
1962    /// Generate the EventWrapper interface
1963    fn generate_event_wrapper_interface(&self) -> String {
1964        r#"/**
1965 * Wrapper for event data that includes context metadata.
1966 * Events are automatically wrapped in this structure at runtime.
1967 */
1968export interface EventWrapper<T> {
1969  /** Unix timestamp when the event was processed */
1970  timestamp: number;
1971  /** The event-specific data */
1972  data: T;
1973  /** Optional blockchain slot number */
1974  slot?: number;
1975  /** Optional transaction signature */
1976  signature?: string;
1977  /** Absolute log-line index; set only for events decoded from a log */
1978  event_index?: number;
1979  /** 0-based instruction path within the transaction (e.g. "0.1") */
1980  ix_path?: string;
1981}"#
1982        .to_string()
1983    }
1984
1985    fn generate_capture_wrapper_interface(&self) -> String {
1986        r#"/**
1987 * Wrapper for account data captured with context metadata.
1988 */
1989export interface CaptureWrapper<T> {
1990  /** Unix timestamp when the account was captured */
1991  timestamp: number;
1992  /** Base58 account address */
1993  accountAddress: string;
1994  /** Captured account data */
1995  data: T;
1996  /** Optional blockchain slot number */
1997  slot?: number;
1998  /** Optional transaction signature */
1999  signature?: string;
2000}"#
2001        .to_string()
2002    }
2003
2004    fn infer_type_from_field_name(&self, field_name: &str) -> String {
2005        let lower_name = field_name.to_lowercase();
2006
2007        // Special case for event fields - these are typically Option<Value> and should be 'any'
2008        if lower_name.contains("events.") {
2009            // For fields in the events section, default to 'any' since they're typically Option<Value>
2010            return "any".to_string();
2011        }
2012
2013        // Common patterns for type inference
2014        if lower_name.contains("id")
2015            || lower_name.contains("count")
2016            || lower_name.contains("number")
2017            || lower_name.contains("timestamp")
2018            || lower_name.contains("time")
2019            || lower_name.contains("at")
2020            || lower_name.contains("volume")
2021            || lower_name.contains("amount")
2022            || lower_name.contains("ev")
2023            || lower_name.contains("fee")
2024            || lower_name.contains("payout")
2025            || lower_name.contains("distributed")
2026            || lower_name.contains("claimable")
2027            || lower_name.contains("total")
2028            || lower_name.contains("rate")
2029            || lower_name.contains("ratio")
2030            || lower_name.contains("current")
2031            || lower_name.contains("state")
2032        {
2033            "number".to_string()
2034        } else if lower_name.contains("status")
2035            || lower_name.contains("hash")
2036            || lower_name.contains("address")
2037            || lower_name.contains("key")
2038        {
2039            "string".to_string()
2040        } else {
2041            "any".to_string()
2042        }
2043    }
2044
2045    fn is_field_nullable(&self, mapping: &TypedFieldMapping<S>) -> bool {
2046        // Stream mappings produce patch-shaped objects, so this bool only captures
2047        // whether the field can be explicitly null in the payload.
2048        match &mapping.source {
2049            // Constants are typically non-optional
2050            MappingSource::Constant(_) => false,
2051            // Events are typically optional (Option<Value>)
2052            MappingSource::AsEvent { .. } => true,
2053            // For source fields, default to optional since most Rust fields are Option<T>
2054            MappingSource::FromSource { .. } => true,
2055            // Other cases default to optional
2056            _ => true,
2057        }
2058    }
2059
2060    /// Convert language-agnostic base types to TypeScript types
2061    fn base_type_to_typescript(
2062        &self,
2063        base_type: &BaseType,
2064        integer_kind: Option<IntegerKind>,
2065        is_array: bool,
2066    ) -> String {
2067        let base_ts_type = match base_type {
2068            BaseType::Integer => integer_kind
2069                .map(integer_kind_to_typescript)
2070                .unwrap_or("number"),
2071            BaseType::Float => "number",
2072            BaseType::String => "string",
2073            BaseType::Boolean => "boolean",
2074            BaseType::Timestamp => integer_kind
2075                .map(integer_kind_to_typescript)
2076                .unwrap_or("number"),
2077            BaseType::Binary => "string", // Base64 encoded strings
2078            BaseType::Pubkey => "string", // Solana public keys as Base58 strings
2079            BaseType::Array => "any[]",   // Default array type
2080            BaseType::Object => "Record<string, any>", // Generic object
2081            BaseType::Any => "any",
2082        };
2083
2084        if is_array && !matches!(base_type, BaseType::Array) {
2085            format!("{}[]", base_ts_type)
2086        } else {
2087            base_ts_type.to_string()
2088        }
2089    }
2090}
2091
2092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2093enum FieldPresence {
2094    Patch,
2095    Required,
2096}
2097
2098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2099enum SchemaMode {
2100    Canonical,
2101    Patch,
2102}
2103
2104fn schema_constant_name(name: &str, mode: SchemaMode) -> String {
2105    match mode {
2106        SchemaMode::Canonical => format!("{}Schema", name),
2107        SchemaMode::Patch => format!("{}PatchSchema", name),
2108    }
2109}
2110
2111fn localize_section_field_names(
2112    section_name: &str,
2113    field_info: &FieldTypeInfo,
2114) -> (String, String) {
2115    let raw_name = field_info.raw_field_name();
2116    if is_root_section(section_name) {
2117        return (raw_name.to_string(), field_info.canonical_field_name());
2118    }
2119
2120    let prefix = format!("{}.", section_name);
2121    if let Some(local_raw_name) = raw_name.strip_prefix(&prefix) {
2122        return (local_raw_name.to_string(), to_camel_case(local_raw_name));
2123    }
2124
2125    (raw_name.to_string(), field_info.canonical_field_name())
2126}
2127
2128/// JSDoc of a `bigint` entity field.
2129const BIGINT_FIELD_DOC: &str =
2130    "`bigint` (64-bit or wider): convert with `Number()`/`String()` before `JSON.stringify`.";
2131
2132/// Represents a TypeScript field in an interface
2133#[derive(Debug, Clone)]
2134struct TypeScriptField {
2135    name: String,
2136    raw_name: String,
2137    ts_type: String,
2138    nullable: bool,
2139    presence: FieldPresence,
2140    zod_schema: Option<String>,
2141    /// JSDoc rendered above the field in its interface.
2142    description: Option<String>,
2143}
2144
2145impl TypeScriptField {
2146    fn patch(raw_name: String, ts_type: String, nullable: bool) -> Self {
2147        Self::from_names(
2148            raw_name.clone(),
2149            to_camel_case(&raw_name),
2150            ts_type,
2151            nullable,
2152            FieldPresence::Patch,
2153        )
2154    }
2155
2156    fn required_with_schema(
2157        raw_name: String,
2158        canonical_name: String,
2159        ts_type: String,
2160        nullable: bool,
2161        zod_schema: String,
2162    ) -> Self {
2163        let mut field = Self::from_names(
2164            raw_name,
2165            canonical_name,
2166            ts_type,
2167            nullable,
2168            FieldPresence::Required,
2169        );
2170        field.zod_schema = Some(zod_schema);
2171        field
2172    }
2173
2174    fn from_names(
2175        raw_name: String,
2176        canonical_name: String,
2177        ts_type: String,
2178        nullable: bool,
2179        presence: FieldPresence,
2180    ) -> Self {
2181        Self {
2182            name: canonical_name,
2183            raw_name,
2184            ts_type,
2185            nullable,
2186            presence,
2187            zod_schema: None,
2188            description: None,
2189        }
2190    }
2191
2192    fn rendered_ts_type(&self) -> String {
2193        if self.nullable {
2194            format!("{} | null", self.ts_type)
2195        } else {
2196            self.ts_type.clone()
2197        }
2198    }
2199}
2200
2201#[derive(Debug, Clone)]
2202struct SchemaOutput {
2203    definitions: String,
2204    names: Vec<String>,
2205}
2206
2207#[derive(Debug, Default)]
2208struct IdlAccountArtifacts {
2209    code: String,
2210    schema_names: Vec<String>,
2211    type_names: HashSet<String>,
2212    account_type_names: BTreeMap<(String, String), String>,
2213}
2214
2215/// Convert serde_json::Value to TypeScript type string
2216fn value_to_typescript_type(value: &serde_json::Value) -> String {
2217    match value {
2218        serde_json::Value::Number(_) => "number".to_string(),
2219        serde_json::Value::String(_) => "string".to_string(),
2220        serde_json::Value::Bool(_) => "boolean".to_string(),
2221        serde_json::Value::Array(_) => "any[]".to_string(),
2222        serde_json::Value::Object(_) => "Record<string, any>".to_string(),
2223        serde_json::Value::Null => "null".to_string(),
2224    }
2225}
2226
2227fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet<String> {
2228    let mut names = HashSet::new();
2229    let registry = crate::resolvers::builtin_resolver_registry();
2230    for resolver in registry.definitions() {
2231        let output_type = resolver.output_type();
2232        for section in &spec.sections {
2233            for field in &section.fields {
2234                if field.inner_type.as_deref() == Some(output_type) {
2235                    names.insert(output_type.to_string());
2236                }
2237            }
2238        }
2239    }
2240    names
2241}
2242
2243fn generate_idl_account_artifacts(
2244    idls: &[IdlSnapshot],
2245    reserved_type_names: &HashSet<String>,
2246) -> IdlAccountArtifacts {
2247    let mut used_type_names = IdlTypeNames::new(reserved_type_names);
2248    let mut emitted_type_names = HashSet::new();
2249    let mut declared_type_names = HashSet::new();
2250    let mut seen_schema_names = HashSet::new();
2251    let mut interface_blocks = Vec::new();
2252    let mut schema_blocks = Vec::new();
2253    let mut schema_names = Vec::new();
2254    let mut account_type_names = BTreeMap::new();
2255
2256    for idl in idls {
2257        let program_key = to_camel_case(&idl.name);
2258        let program_prefix = ts_ident::identifier_stem(&idl.name, IdentifierCase::Pascal);
2259        let type_defs: BTreeMap<String, &IdlTypeDefSnapshot> = idl
2260            .types
2261            .iter()
2262            .map(|type_def| (type_def.name.clone(), type_def))
2263            .collect();
2264        let account_names: HashSet<String> = idl
2265            .accounts
2266            .iter()
2267            .map(|account| account.name.clone())
2268            .collect();
2269        let mut local_name_map = BTreeMap::new();
2270
2271        for account in &idl.accounts {
2272            let unique_name = unique_idl_type_name(
2273                &account.name,
2274                &program_prefix,
2275                IDL_ACCOUNT_ROLE,
2276                &mut used_type_names,
2277            );
2278            emitted_type_names.insert(unique_name.clone());
2279            local_name_map.insert(account.name.clone(), unique_name.clone());
2280            account_type_names.insert((program_key.clone(), account.name.clone()), unique_name);
2281        }
2282
2283        let mut required_defined_types = BTreeSet::new();
2284        for account in &idl.accounts {
2285            for field in resolve_idl_account_fields(account, &type_defs) {
2286                collect_required_defined_types(
2287                    &field.type_,
2288                    &type_defs,
2289                    &account_names,
2290                    &mut required_defined_types,
2291                );
2292            }
2293        }
2294
2295        for type_name in &required_defined_types {
2296            if local_name_map.contains_key(type_name) {
2297                continue;
2298            }
2299            let unique_name = unique_idl_type_name(
2300                type_name,
2301                &program_prefix,
2302                IDL_TYPE_ROLE,
2303                &mut used_type_names,
2304            );
2305            emitted_type_names.insert(unique_name.clone());
2306            local_name_map.insert(type_name.clone(), unique_name);
2307        }
2308
2309        for type_name in &required_defined_types {
2310            if account_names.contains(type_name) {
2311                continue;
2312            }
2313            if let Some(type_def) = type_defs.get(type_name) {
2314                if let Some((interface_def, schema_name, schema_def)) =
2315                    generate_type_defs_from_idl_type(type_def, &local_name_map)
2316                {
2317                    declared_type_names.extend(local_name_map.get(type_name).cloned());
2318                    interface_blocks.push(interface_def);
2319                    if seen_schema_names.insert(schema_name.clone()) {
2320                        schema_names.push(schema_name.clone());
2321                        schema_blocks.push(schema_def);
2322                    }
2323                }
2324            }
2325        }
2326
2327        for account in &idl.accounts {
2328            let Some(type_name) = local_name_map.get(&account.name) else {
2329                continue;
2330            };
2331            let account_fields = resolve_idl_account_fields(account, &type_defs);
2332            declared_type_names.insert(type_name.clone());
2333            interface_blocks.push(generate_interface_from_idl_fields(
2334                type_name,
2335                account_fields,
2336                &local_name_map,
2337            ));
2338            let schema_name = format!("{}Schema", type_name);
2339            if seen_schema_names.insert(schema_name.clone()) {
2340                schema_names.push(schema_name.clone());
2341                schema_blocks.push(generate_schema_from_idl_fields(
2342                    type_name,
2343                    account_fields,
2344                    &local_name_map,
2345                ));
2346            }
2347        }
2348    }
2349
2350    // Deprecated aliases for the numeric names earlier releases emitted,
2351    // after the schemas they point at. The schema alias keeps the old key in
2352    // the stack's `schemas` map too.
2353    let mut alias_blocks = Vec::new();
2354    for (legacy, canonical) in &used_type_names.aliases {
2355        if !declared_type_names.contains(canonical) {
2356            continue;
2357        }
2358        let mut block =
2359            format!("/** @deprecated Use {canonical}. */\nexport type {legacy} = {canonical};");
2360        let canonical_schema = format!("{canonical}Schema");
2361        let legacy_schema = format!("{legacy}Schema");
2362        if seen_schema_names.contains(&canonical_schema)
2363            && seen_schema_names.insert(legacy_schema.clone())
2364        {
2365            block.push_str(&format!(
2366                "\n/** @deprecated Use {canonical_schema}. */\nexport const {legacy_schema} = {canonical_schema};"
2367            ));
2368            schema_names.push(legacy_schema);
2369        }
2370        emitted_type_names.insert(legacy.clone());
2371        alias_blocks.push(block);
2372    }
2373    schema_blocks.extend(alias_blocks);
2374
2375    let code = if interface_blocks.is_empty() && schema_blocks.is_empty() {
2376        String::new()
2377    } else if schema_blocks.is_empty() {
2378        interface_blocks.join("\n\n")
2379    } else if interface_blocks.is_empty() {
2380        schema_blocks.join("\n\n")
2381    } else {
2382        format!(
2383            "{}\n\n{}",
2384            interface_blocks.join("\n\n"),
2385            schema_blocks.join("\n\n")
2386        )
2387    };
2388
2389    IdlAccountArtifacts {
2390        code,
2391        schema_names,
2392        type_names: emitted_type_names,
2393        account_type_names,
2394    }
2395}
2396
2397fn resolve_idl_account_fields<'a>(
2398    account: &'a IdlAccountSnapshot,
2399    type_defs: &'a BTreeMap<String, &'a IdlTypeDefSnapshot>,
2400) -> &'a [IdlFieldSnapshot] {
2401    if !account.fields.is_empty() {
2402        return &account.fields;
2403    }
2404
2405    let Some(type_def) = type_defs.get(&account.name) else {
2406        return &account.fields;
2407    };
2408
2409    match &type_def.type_def {
2410        IdlTypeDefKindSnapshot::Struct { fields, .. } => fields,
2411        _ => &account.fields,
2412    }
2413}
2414
2415/// Role suffixes for IDL types whose plain and program-prefixed names are
2416/// both taken (for example by stack entity types).
2417const IDL_ACCOUNT_ROLE: &str = "Account";
2418const IDL_TYPE_ROLE: &str = "Type";
2419
2420/// Names claimed by IDL account and defined types, plus the names releases
2421/// up to 0.23 gave the same types (a numeric suffix where the role suffix is
2422/// now used), kept as deprecated aliases.
2423struct IdlTypeNames {
2424    used: HashSet<String>,
2425    legacy: HashSet<String>,
2426    /// `(legacy, canonical)` pairs whose names differ.
2427    aliases: Vec<(String, String)>,
2428}
2429
2430impl IdlTypeNames {
2431    fn new(reserved: &HashSet<String>) -> Self {
2432        Self {
2433            used: reserved.clone(),
2434            legacy: reserved.clone(),
2435            aliases: Vec::new(),
2436        }
2437    }
2438}
2439
2440/// Name an IDL type: its own name, else the program-prefixed name
2441/// (`OreBoard`), else the prefixed name with its role (`OreBoardAccount`,
2442/// `OreHeaderType`), numbered only if that is taken too. Where earlier
2443/// releases named the type differently (`OreBoard2`), that name is recorded
2444/// as a deprecated alias so code written against it keeps compiling.
2445fn unique_idl_type_name(
2446    raw_name: &str,
2447    program_prefix: &str,
2448    role: &str,
2449    names: &mut IdlTypeNames,
2450) -> String {
2451    let legacy = numbered_idl_type_name(raw_name, program_prefix, &mut names.legacy);
2452    let base_name = to_pascal_case(raw_name);
2453    let prefixed = format!("{}{}", program_prefix, base_name);
2454    let role_named = format!("{prefixed}{role}");
2455    let canonical = [base_name, prefixed]
2456        .into_iter()
2457        .find(|candidate| names.used.insert(candidate.clone()))
2458        .unwrap_or_else(|| {
2459            (1..)
2460                .map(|index| match index {
2461                    1 => role_named.clone(),
2462                    index => format!("{role_named}{index}"),
2463                })
2464                .find(|candidate| names.used.insert(candidate.clone()))
2465                .expect("an unused numbered name exists")
2466        });
2467    if legacy != canonical && names.used.insert(legacy.clone()) {
2468        names.aliases.push((legacy, canonical.clone()));
2469    }
2470    canonical
2471}
2472
2473/// The naming earlier releases used: plain, program-prefixed, then numbered.
2474fn numbered_idl_type_name(
2475    raw_name: &str,
2476    program_prefix: &str,
2477    used_type_names: &mut HashSet<String>,
2478) -> String {
2479    let base_name = to_pascal_case(raw_name);
2480    if used_type_names.insert(base_name.clone()) {
2481        return base_name;
2482    }
2483
2484    let prefixed = format!("{}{}", program_prefix, base_name);
2485    if used_type_names.insert(prefixed.clone()) {
2486        return prefixed;
2487    }
2488
2489    let mut index = 2;
2490    loop {
2491        let candidate = format!("{}{}", prefixed, index);
2492        if used_type_names.insert(candidate.clone()) {
2493            return candidate;
2494        }
2495        index += 1;
2496    }
2497}
2498
2499fn collect_required_defined_types(
2500    idl_type: &IdlTypeSnapshot,
2501    type_defs: &BTreeMap<String, &IdlTypeDefSnapshot>,
2502    account_names: &HashSet<String>,
2503    output: &mut BTreeSet<String>,
2504) {
2505    match idl_type {
2506        IdlTypeSnapshot::Simple(_) => {}
2507        IdlTypeSnapshot::Array(array_type) => {
2508            for element in &array_type.array {
2509                if let IdlArrayElementSnapshot::Type(inner) = element {
2510                    collect_required_defined_types(inner, type_defs, account_names, output);
2511                }
2512            }
2513        }
2514        IdlTypeSnapshot::Option(option_type) => {
2515            collect_required_defined_types(&option_type.option, type_defs, account_names, output);
2516        }
2517        IdlTypeSnapshot::Vec(vec_type) => {
2518            collect_required_defined_types(&vec_type.vec, type_defs, account_names, output);
2519        }
2520        IdlTypeSnapshot::HashMap(hash_map_type) => {
2521            collect_required_defined_types(
2522                &hash_map_type.hash_map.0,
2523                type_defs,
2524                account_names,
2525                output,
2526            );
2527            collect_required_defined_types(
2528                &hash_map_type.hash_map.1,
2529                type_defs,
2530                account_names,
2531                output,
2532            );
2533        }
2534        IdlTypeSnapshot::Tuple(tuple_type) => {
2535            for element in &tuple_type.tuple {
2536                collect_required_defined_types(element, type_defs, account_names, output);
2537            }
2538        }
2539        IdlTypeSnapshot::Defined(defined_type) => {
2540            let type_name = match &defined_type.defined {
2541                IdlDefinedInnerSnapshot::Named { name } => name,
2542                IdlDefinedInnerSnapshot::Simple(name) => name,
2543            };
2544
2545            if !output.insert(type_name.clone()) {
2546                return;
2547            }
2548
2549            if account_names.contains(type_name) {
2550                return;
2551            }
2552
2553            let Some(type_def) = type_defs.get(type_name) else {
2554                return;
2555            };
2556
2557            match &type_def.type_def {
2558                IdlTypeDefKindSnapshot::Struct { fields, .. } => {
2559                    for field in fields {
2560                        collect_required_defined_types(
2561                            &field.type_,
2562                            type_defs,
2563                            account_names,
2564                            output,
2565                        );
2566                    }
2567                }
2568                IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2569                    for field in fields {
2570                        collect_required_defined_types(field, type_defs, account_names, output);
2571                    }
2572                }
2573                IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2574                    for variant in variants {
2575                        for field in &variant.fields {
2576                            match field {
2577                                IdlEnumVariantFieldSnapshot::Named(named) => {
2578                                    collect_required_defined_types(
2579                                        &named.type_,
2580                                        type_defs,
2581                                        account_names,
2582                                        output,
2583                                    );
2584                                }
2585                                IdlEnumVariantFieldSnapshot::Tuple(tuple) => {
2586                                    collect_required_defined_types(
2587                                        tuple,
2588                                        type_defs,
2589                                        account_names,
2590                                        output,
2591                                    );
2592                                }
2593                            }
2594                        }
2595                    }
2596                }
2597            }
2598        }
2599    }
2600}
2601
2602fn generate_type_defs_from_idl_type(
2603    type_def: &IdlTypeDefSnapshot,
2604    local_name_map: &BTreeMap<String, String>,
2605) -> Option<(String, String, String)> {
2606    let type_name = local_name_map
2607        .get(&type_def.name)
2608        .cloned()
2609        .unwrap_or_else(|| to_pascal_case(&type_def.name));
2610    let schema_name = format!("{}Schema", type_name);
2611
2612    match &type_def.type_def {
2613        IdlTypeDefKindSnapshot::Struct { fields, .. } => Some((
2614            generate_interface_from_idl_fields(&type_name, fields, local_name_map),
2615            schema_name,
2616            generate_schema_from_idl_fields(&type_name, fields, local_name_map),
2617        )),
2618        IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2619            let interface = format!(
2620                "export type {} = [{}];",
2621                type_name,
2622                fields
2623                    .iter()
2624                    .map(|field| idl_snapshot_type_to_typescript(field, local_name_map))
2625                    .collect::<Vec<_>>()
2626                    .join(", ")
2627            );
2628            let schema = format!(
2629                "export const {} = z.tuple([{}]);",
2630                schema_name,
2631                fields
2632                    .iter()
2633                    .map(|field| idl_snapshot_type_to_zod(field, local_name_map))
2634                    .collect::<Vec<_>>()
2635                    .join(", ")
2636            );
2637            Some((interface, schema_name, schema))
2638        }
2639        IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2640            if !variants.is_empty() && variants.iter().all(|variant| variant.fields.is_empty()) {
2641                let labels = variants
2642                    .iter()
2643                    .map(|variant| serde_json::to_string(&variant.name).expect("variant label"))
2644                    .collect::<Vec<_>>();
2645                return Some((
2646                    format!("export type {type_name} = {};", labels.join(" | ")),
2647                    schema_name.clone(),
2648                    format!(
2649                        "export const {schema_name} = z.enum([{}]);",
2650                        labels.join(", ")
2651                    ),
2652                ));
2653            }
2654            let mut type_variants = Vec::new();
2655            let mut schema_variants = Vec::new();
2656            for variant in variants {
2657                let label = serde_json::to_string(&variant.name).expect("variant label");
2658                if variant.fields.is_empty() {
2659                    type_variants.push(label.clone());
2660                    schema_variants.push(format!("z.literal({label})"));
2661                    continue;
2662                }
2663                let named: Vec<_> = variant
2664                    .fields
2665                    .iter()
2666                    .filter_map(|field| match field {
2667                        IdlEnumVariantFieldSnapshot::Named(field) => Some(field),
2668                        _ => None,
2669                    })
2670                    .collect();
2671                let (payload_type, payload_schema) = if named.len() == variant.fields.len() {
2672                    let fields: Vec<_> = named.into_iter().cloned().collect();
2673                    let fields = normalize_idl_fields(&fields, local_name_map);
2674                    let ty = fields
2675                        .iter()
2676                        .map(|field| {
2677                            format!(
2678                                "{}: {}",
2679                                field.name,
2680                                if field.nullable {
2681                                    format!("{} | null", field.ts_type)
2682                                } else {
2683                                    field.ts_type.clone()
2684                                }
2685                            )
2686                        })
2687                        .collect::<Vec<_>>()
2688                        .join("; ");
2689                    let declaration = render_schema_from_ts_fields("EnumPayload", &fields, true);
2690                    let schema = declaration
2691                        .strip_prefix("export const EnumPayloadSchema = ")
2692                        .expect("schema declaration")
2693                        .trim_end_matches(';')
2694                        .to_string();
2695                    (format!("{{ {ty} }}"), schema)
2696                } else if named.is_empty() {
2697                    let types: Vec<_> = variant
2698                        .fields
2699                        .iter()
2700                        .map(|field| match field {
2701                            IdlEnumVariantFieldSnapshot::Tuple(ty) => ty,
2702                            _ => unreachable!(),
2703                        })
2704                        .collect();
2705                    if types.len() == 1 {
2706                        (
2707                            idl_snapshot_type_to_typescript(types[0], local_name_map),
2708                            idl_snapshot_type_to_zod(types[0], local_name_map),
2709                        )
2710                    } else {
2711                        (
2712                            format!(
2713                                "[{}]",
2714                                types
2715                                    .iter()
2716                                    .map(|ty| idl_snapshot_type_to_typescript(ty, local_name_map))
2717                                    .collect::<Vec<_>>()
2718                                    .join(", ")
2719                            ),
2720                            format!(
2721                                "z.tuple([{}])",
2722                                types
2723                                    .iter()
2724                                    .map(|ty| idl_snapshot_type_to_zod(ty, local_name_map))
2725                                    .collect::<Vec<_>>()
2726                                    .join(", ")
2727                            ),
2728                        )
2729                    }
2730                } else {
2731                    // Unsupported layouts must never turn into valid-looking fieldless labels.
2732                    type_variants.push("never /* unsupported mixed enum payload */".into());
2733                    schema_variants.push("z.never()".into());
2734                    continue;
2735                };
2736                type_variants.push(format!("{{ {label}: {payload_type} }}"));
2737                schema_variants.push(format!(
2738                    "z.object({{ {label}: z.lazy(() => {payload_schema}) }})"
2739                ));
2740            }
2741            let interface = format!(
2742                "export type {} = {};",
2743                type_name,
2744                if type_variants.is_empty() {
2745                    "never".into()
2746                } else {
2747                    type_variants.join(" | ")
2748                }
2749            );
2750            let schema_expr = match schema_variants.len() {
2751                0 => "z.never()".into(),
2752                1 => schema_variants.remove(0),
2753                _ => format!("z.union([{}])", schema_variants.join(", ")),
2754            };
2755            Some((
2756                interface,
2757                schema_name.clone(),
2758                format!("export const {schema_name} = {schema_expr};"),
2759            ))
2760        }
2761    }
2762}
2763
2764fn generate_interface_from_idl_fields(
2765    name: &str,
2766    fields: &[IdlFieldSnapshot],
2767    local_name_map: &BTreeMap<String, String>,
2768) -> String {
2769    render_interface_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2770}
2771
2772fn generate_schema_from_idl_fields(
2773    name: &str,
2774    fields: &[IdlFieldSnapshot],
2775    local_name_map: &BTreeMap<String, String>,
2776) -> String {
2777    render_schema_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2778}
2779
2780fn normalize_idl_fields(
2781    fields: &[IdlFieldSnapshot],
2782    local_name_map: &BTreeMap<String, String>,
2783) -> Vec<TypeScriptField> {
2784    let canonical_names = canonical_idl_field_names(fields);
2785    let mut normalized = Vec::with_capacity(fields.len());
2786
2787    for (field, canonical_name) in fields.iter().zip(canonical_names) {
2788        let (normalized_type, nullable) = strip_nullable_idl_type(&field.type_);
2789
2790        normalized.push(TypeScriptField::required_with_schema(
2791            idl_field_wire_name(&field.name),
2792            canonical_name,
2793            idl_snapshot_type_to_typescript(normalized_type, local_name_map),
2794            nullable,
2795            idl_snapshot_type_to_zod(normalized_type, local_name_map),
2796        ));
2797    }
2798
2799    normalized
2800}
2801
2802/// Produce stable, distinct TypeScript property names for IDL fields.
2803///
2804/// Most fields use the existing camel-case projection. When distinct wire
2805/// names collapse to the same projection, preserve leading underscores first
2806/// (`padding_0` -> `padding0`, `_padding_0` -> `_padding0`) and fall back to a
2807/// numeric suffix for less structured collisions. This keeps account codegen
2808/// total for valid binary layouts instead of panicking during generation.
2809fn canonical_idl_field_names(fields: &[IdlFieldSnapshot]) -> Vec<String> {
2810    let base_names = fields
2811        .iter()
2812        .map(|field| to_camel_case(&field.name))
2813        .collect::<Vec<_>>();
2814    let mut base_counts = BTreeMap::<String, usize>::new();
2815    for base_name in &base_names {
2816        *base_counts.entry(base_name.clone()).or_default() += 1;
2817    }
2818
2819    let mut used_names = HashSet::new();
2820    fields
2821        .iter()
2822        .zip(base_names)
2823        .map(|(field, base_name)| {
2824            let leading_underscores = field.name.chars().take_while(|ch| *ch == '_').count();
2825            let preferred = if base_counts.get(base_name.as_str()).copied().unwrap_or(0) > 1
2826                && leading_underscores > 0
2827            {
2828                format!("{}{}", "_".repeat(leading_underscores), base_name)
2829            } else {
2830                base_name
2831            };
2832
2833            if used_names.insert(preferred.clone()) {
2834                return preferred;
2835            }
2836
2837            let mut suffix = 2;
2838            loop {
2839                let candidate = format!("{}{}", preferred, suffix);
2840                if used_names.insert(candidate.clone()) {
2841                    return candidate;
2842                }
2843                suffix += 1;
2844            }
2845        })
2846        .collect()
2847}
2848
2849fn idl_field_wire_name(field_name: &str) -> String {
2850    idl_to_snake_case(field_name)
2851}
2852
2853fn idl_snapshot_type_to_typescript(
2854    idl_type: &IdlTypeSnapshot,
2855    local_name_map: &BTreeMap<String, String>,
2856) -> String {
2857    match idl_type {
2858        IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2859            "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
2860            "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "number".to_string(),
2861            "bool" => "boolean".to_string(),
2862            "string" => "string".to_string(),
2863            "pubkey" | "publicKey" => "string".to_string(),
2864            "bytes" => "number[]".to_string(),
2865            _ => "any".to_string(),
2866        },
2867        IdlTypeSnapshot::Array(array_type) => {
2868            let (inner_ts, size) = match array_type.array.as_slice() {
2869                [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => (
2870                    Some(idl_snapshot_type_to_typescript(inner, local_name_map)),
2871                    Some(*size),
2872                ),
2873                [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2874                    (
2875                        Some(idl_snapshot_type_to_typescript(
2876                            &IdlTypeSnapshot::Simple(inner.clone()),
2877                            local_name_map,
2878                        )),
2879                        Some(*size),
2880                    )
2881                }
2882                _ => (None, None),
2883            };
2884            let inner_ts = inner_ts.unwrap_or_else(|| "any".to_string());
2885            if let Some(size) = size {
2886                format!(
2887                    "{}[]",
2888                    if size == 0 {
2889                        "never".to_string()
2890                    } else {
2891                        inner_ts
2892                    }
2893                )
2894            } else {
2895                format!("{}[]", inner_ts)
2896            }
2897        }
2898        IdlTypeSnapshot::Option(option_type) => {
2899            format!(
2900                "{} | null",
2901                idl_snapshot_type_to_typescript(&option_type.option, local_name_map)
2902            )
2903        }
2904        IdlTypeSnapshot::Vec(vec_type) => {
2905            format!(
2906                "{}[]",
2907                idl_snapshot_type_to_typescript(&vec_type.vec, local_name_map)
2908            )
2909        }
2910        IdlTypeSnapshot::HashMap(hash_map_type) => {
2911            format!(
2912                "Record<string, {}>",
2913                idl_snapshot_type_to_typescript(&hash_map_type.hash_map.1, local_name_map)
2914            )
2915        }
2916        IdlTypeSnapshot::Tuple(tuple_type) => {
2917            format!(
2918                "[{}]",
2919                tuple_type
2920                    .tuple
2921                    .iter()
2922                    .map(|element| idl_snapshot_type_to_typescript(element, local_name_map))
2923                    .collect::<Vec<_>>()
2924                    .join(", ")
2925            )
2926        }
2927        IdlTypeSnapshot::Defined(defined_type) => {
2928            let type_name = match &defined_type.defined {
2929                IdlDefinedInnerSnapshot::Named { name } => name,
2930                IdlDefinedInnerSnapshot::Simple(name) => name,
2931            };
2932            local_name_map
2933                .get(type_name)
2934                .cloned()
2935                .unwrap_or_else(|| to_pascal_case(type_name))
2936        }
2937    }
2938}
2939
2940fn idl_snapshot_type_to_zod(
2941    idl_type: &IdlTypeSnapshot,
2942    local_name_map: &BTreeMap<String, String>,
2943) -> String {
2944    match idl_type {
2945        IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2946            "u64" | "u128" | "i64" | "i128" => bigint_zod(),
2947            "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "z.number()".to_string(),
2948            "bool" => "z.boolean()".to_string(),
2949            "string" => "z.string()".to_string(),
2950            "pubkey" | "publicKey" => "z.string()".to_string(),
2951            "bytes" => "z.array(z.number())".to_string(),
2952            _ => "z.any()".to_string(),
2953        },
2954        IdlTypeSnapshot::Array(array_type) => match array_type.array.as_slice() {
2955            [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => {
2956                format!(
2957                    "z.array({}).length({})",
2958                    idl_snapshot_type_to_zod(inner, local_name_map),
2959                    size
2960                )
2961            }
2962            [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2963                format!(
2964                    "z.array({}).length({})",
2965                    idl_snapshot_type_to_zod(
2966                        &IdlTypeSnapshot::Simple(inner.clone()),
2967                        local_name_map
2968                    ),
2969                    size
2970                )
2971            }
2972            _ => "z.array(z.any())".to_string(),
2973        },
2974        IdlTypeSnapshot::Option(option_type) => {
2975            format!(
2976                "{}.nullable()",
2977                idl_snapshot_type_to_zod(&option_type.option, local_name_map)
2978            )
2979        }
2980        IdlTypeSnapshot::Vec(vec_type) => {
2981            format!(
2982                "z.array({})",
2983                idl_snapshot_type_to_zod(&vec_type.vec, local_name_map)
2984            )
2985        }
2986        IdlTypeSnapshot::HashMap(hash_map_type) => {
2987            format!(
2988                "z.record({})",
2989                idl_snapshot_type_to_zod(&hash_map_type.hash_map.1, local_name_map)
2990            )
2991        }
2992        IdlTypeSnapshot::Tuple(tuple_type) => {
2993            format!(
2994                "z.tuple([{}])",
2995                tuple_type
2996                    .tuple
2997                    .iter()
2998                    .map(|element| idl_snapshot_type_to_zod(element, local_name_map))
2999                    .collect::<Vec<_>>()
3000                    .join(", ")
3001            )
3002        }
3003        IdlTypeSnapshot::Defined(defined_type) => {
3004            let type_name = match &defined_type.defined {
3005                IdlDefinedInnerSnapshot::Named { name } => name,
3006                IdlDefinedInnerSnapshot::Simple(name) => name,
3007            };
3008            let resolved_name = local_name_map
3009                .get(type_name)
3010                .cloned()
3011                .unwrap_or_else(|| to_pascal_case(type_name));
3012            format!("z.lazy(() => {}Schema)", resolved_name)
3013        }
3014    }
3015}
3016
3017fn typescript_integer_type_from_rust(rust_type: &str) -> Option<&'static str> {
3018    IntegerKind::from_rust_type(rust_type).map(integer_kind_to_typescript)
3019}
3020
3021fn typescript_integer_type(
3022    integer_kind: Option<IntegerKind>,
3023    rust_type: Option<&str>,
3024) -> Option<&'static str> {
3025    integer_kind
3026        .map(integer_kind_to_typescript)
3027        .or_else(|| rust_type.and_then(typescript_integer_type_from_rust))
3028}
3029
3030/// Map the element of a `Vec<T>` scalar array to its TypeScript primitive.
3031/// Accepts both stored forms of the inner type (`"Vec < f64 >"` and the bare
3032/// `"f64"`), returning `None` for non-scalar or non-array elements.
3033fn typescript_scalar_array_element(inner_type: &str) -> Option<&'static str> {
3034    let trimmed = inner_type.trim();
3035    let element = trimmed
3036        .strip_prefix("Vec <")
3037        .and_then(|rest| rest.strip_suffix('>'))
3038        .or_else(|| {
3039            trimmed
3040                .strip_prefix("Vec<")
3041                .and_then(|rest| rest.strip_suffix('>'))
3042        })
3043        .map(str::trim)
3044        .unwrap_or(trimmed);
3045    match element {
3046        "f32" | "f64" => Some("number"),
3047        "bool" => Some("boolean"),
3048        "String" | "&str" | "str" => Some("string"),
3049        _ => None,
3050    }
3051}
3052
3053fn integer_kind_to_typescript(integer_kind: IntegerKind) -> &'static str {
3054    if integer_kind.is_bigint() {
3055        "bigint"
3056    } else {
3057        "number"
3058    }
3059}
3060
3061fn strip_nullable_idl_type(mut idl_type: &IdlTypeSnapshot) -> (&IdlTypeSnapshot, bool) {
3062    let mut nullable = false;
3063    while let IdlTypeSnapshot::Option(option_type) = idl_type {
3064        nullable = true;
3065        idl_type = &option_type.option;
3066    }
3067    (idl_type, nullable)
3068}
3069
3070fn typescript_type_to_zod_static(ts_type: &str) -> String {
3071    let trimmed = ts_type.trim();
3072
3073    if let Some(inner) = trimmed.strip_suffix("[]") {
3074        return format!("z.array({})", typescript_type_to_zod_static(inner));
3075    }
3076
3077    if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
3078        if let Some(inner) = inner.strip_suffix('>') {
3079            return format!(
3080                "EventWrapperSchema({})",
3081                typescript_type_to_zod_static(inner)
3082            );
3083        }
3084    }
3085
3086    if let Some(inner) = trimmed.strip_prefix("CaptureWrapper<") {
3087        if let Some(inner) = inner.strip_suffix('>') {
3088            return format!(
3089                "CaptureWrapperSchema({})",
3090                typescript_type_to_zod_static(inner)
3091            );
3092        }
3093    }
3094
3095    match trimmed {
3096        "string" => "z.string()".to_string(),
3097        "number" => "z.number()".to_string(),
3098        "bigint" => bigint_zod(),
3099        "boolean" => "z.boolean()".to_string(),
3100        "any" => "z.any()".to_string(),
3101        "Record<string, any>" => "z.record(z.any())".to_string(),
3102        _ => format!("{}Schema", trimmed),
3103    }
3104}
3105
3106fn typescript_type_to_zod_for_schema_static(
3107    ts_type: &str,
3108    mode: SchemaMode,
3109    patch_schema_types: &HashSet<String>,
3110) -> String {
3111    let trimmed = ts_type.trim();
3112
3113    if let Some(inner) = trimmed.strip_suffix("[]") {
3114        return format!(
3115            "z.array({})",
3116            typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
3117        );
3118    }
3119
3120    if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
3121        if let Some(inner) = inner.strip_suffix('>') {
3122            return format!(
3123                "EventWrapperSchema({})",
3124                typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
3125            );
3126        }
3127    }
3128
3129    if let Some(inner) = trimmed.strip_prefix("CaptureWrapper<") {
3130        if let Some(inner) = inner.strip_suffix('>') {
3131            return format!(
3132                "CaptureWrapperSchema({})",
3133                typescript_type_to_zod_for_schema_static(
3134                    inner,
3135                    SchemaMode::Canonical,
3136                    patch_schema_types,
3137                )
3138            );
3139        }
3140    }
3141
3142    match trimmed {
3143        "string" => "z.string()".to_string(),
3144        "number" => "z.number()".to_string(),
3145        "bigint" => bigint_zod(),
3146        "boolean" => "z.boolean()".to_string(),
3147        "any" => "z.any()".to_string(),
3148        "Record<string, any>" => "z.record(z.any())".to_string(),
3149        _ => {
3150            if mode == SchemaMode::Patch && patch_schema_types.contains(trimmed) {
3151                format!("{}PatchSchema", trimmed)
3152            } else {
3153                format!("{}Schema", trimmed)
3154            }
3155        }
3156    }
3157}
3158
3159fn render_interface_from_ts_fields(
3160    name: &str,
3161    fields: &[TypeScriptField],
3162    force_required: bool,
3163) -> String {
3164    if fields.is_empty() {
3165        return format!("export interface {} {{\n}}", name);
3166    }
3167
3168    let field_definitions = fields
3169        .iter()
3170        .map(|field| {
3171            let optional = if force_required || matches!(field.presence, FieldPresence::Required) {
3172                ""
3173            } else {
3174                "?"
3175            };
3176            let doc = field
3177                .description
3178                .as_deref()
3179                .map(|doc| format!("  /** {} */\n", doc.replace("*/", "*\\/")))
3180                .unwrap_or_default();
3181            format!(
3182                "{doc}  {}{}: {};",
3183                field.name,
3184                optional,
3185                field.rendered_ts_type()
3186            )
3187        })
3188        .collect::<Vec<_>>();
3189
3190    format!(
3191        "export interface {} {{\n{}\n}}",
3192        name,
3193        field_definitions.join("\n")
3194    )
3195}
3196
3197fn render_schema_from_ts_fields(
3198    name: &str,
3199    fields: &[TypeScriptField],
3200    force_required: bool,
3201) -> String {
3202    if fields.is_empty() {
3203        return format!("export const {}Schema = z.object({{}});", name);
3204    }
3205
3206    let field_definitions = fields
3207        .iter()
3208        .map(|field| {
3209            let base_schema = field
3210                .zod_schema
3211                .clone()
3212                .unwrap_or_else(|| typescript_type_to_zod_static(&field.ts_type));
3213            let with_nullable = if field.nullable {
3214                format!("{}.nullable()", base_schema)
3215            } else {
3216                base_schema
3217            };
3218            let schema = if force_required || matches!(field.presence, FieldPresence::Required) {
3219                with_nullable
3220            } else {
3221                format!("{}.optional()", with_nullable)
3222            };
3223            format!("  {}: {},", field.raw_name, schema)
3224        })
3225        .collect::<Vec<_>>();
3226
3227    let transform_fields = fields
3228        .iter()
3229        .map(|field| format!("  {}: value.{},", field.name, field.raw_name))
3230        .collect::<Vec<_>>();
3231
3232    format!(
3233        "export const {}Schema = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
3234        name,
3235        field_definitions.join("\n"),
3236        transform_fields.join("\n")
3237    )
3238}
3239
3240fn bigint_zod() -> String {
3241    "z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value))"
3242        .to_string()
3243}
3244
3245fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet<String> {
3246    let mut names = HashSet::new();
3247    if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) {
3248        for type_def in types_array {
3249            if let (Some(type_name), Some(type_obj)) = (
3250                type_def.get("name").and_then(|v| v.as_str()),
3251                type_def.get("type").and_then(|v| v.as_object()),
3252            ) {
3253                if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
3254                    names.insert(to_pascal_case(type_name));
3255                }
3256            }
3257        }
3258    }
3259    names
3260}
3261
3262/// Extract enum type names that were actually emitted in the generated interfaces.
3263/// Looks for patterns like `export const DirectionKindSchema = z.enum([...])`
3264fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet<String> {
3265    let mut names = HashSet::new();
3266
3267    // Get all enum type names from the IDL
3268    let idl_enum_names: HashSet<String> = idl
3269        .and_then(|idl| serde_json::to_value(idl).ok())
3270        .map(|v| extract_idl_enum_type_names(&v))
3271        .unwrap_or_default();
3272
3273    // Look for emitted enum schemas in the interfaces
3274    // Pattern: export const DirectionKindSchema = z.enum([...]) or z.string() for empty variants
3275    for line in interfaces.lines() {
3276        if let Some(start) = line.find("export const ") {
3277            let end = line
3278                .find("Schema = z.enum")
3279                .or_else(|| line.find("Schema = z.string()"))
3280                .or_else(|| line.find("Schema = "));
3281            if let Some(end) = end {
3282                let schema_name = line[start + 13..end].trim();
3283                // Check if this schema name corresponds to an IDL enum type
3284                if idl_enum_names.contains(schema_name) {
3285                    names.insert(schema_name.to_string());
3286                }
3287            }
3288        }
3289    }
3290
3291    names
3292}
3293
3294fn unique_resolved_type_name_ts(
3295    resolved: &ResolvedStructType,
3296    reserved_names: &mut HashSet<String>,
3297) -> String {
3298    let base_name = to_pascal_case(&resolved.type_name);
3299    if reserved_names.insert(base_name.clone()) {
3300        return base_name;
3301    }
3302
3303    let suffix = if resolved.is_account {
3304        "Account"
3305    } else if resolved.is_event {
3306        "Event"
3307    } else if resolved.is_instruction {
3308        "Instruction"
3309    } else {
3310        "Type"
3311    };
3312
3313    let preferred = format!("{}{}", base_name, suffix);
3314    if reserved_names.insert(preferred.clone()) {
3315        return preferred;
3316    }
3317
3318    let mut index = 2;
3319    loop {
3320        let candidate = format!("{}{}{}", base_name, suffix, index);
3321        if reserved_names.insert(candidate.clone()) {
3322            return candidate;
3323        }
3324        index += 1;
3325    }
3326}
3327
3328fn section_interface_name(interface_prefix: &str, entity_name: &str, name: &str) -> String {
3329    if name == "Root" {
3330        format!("{}{}", interface_prefix, to_pascal_case(entity_name))
3331    } else {
3332        // Create compound names like GameEvents, GameStatus, etc.
3333        // Extract the base name (e.g., "Game" from "TestGame" or "SettlementGame")
3334        let base_name = if entity_name.contains("Game") {
3335            "Game"
3336        } else {
3337            entity_name
3338        };
3339        format!("{}{}{}", interface_prefix, base_name, to_pascal_case(name))
3340    }
3341}
3342
3343/// Convert snake_case to PascalCase
3344pub(crate) fn to_pascal_case(s: &str) -> String {
3345    s.split(['_', '-', '.', ':'])
3346        .map(|word| {
3347            let mut chars = word.chars();
3348            match chars.next() {
3349                None => String::new(),
3350                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
3351            }
3352        })
3353        .collect()
3354}
3355
3356fn to_camel_case(s: &str) -> String {
3357    let pascal = to_pascal_case(s);
3358    let mut chars = pascal.chars();
3359    match chars.next() {
3360        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
3361        None => pascal,
3362    }
3363}
3364
3365/// Normalize a name for case-insensitive comparison across naming conventions.
3366/// Removes underscores and converts to lowercase: "claim_sol", "claimSol", "ClaimSol" all become "claimsol"
3367fn normalize_for_comparison(s: &str) -> String {
3368    s.chars()
3369        .filter(|c| *c != '_')
3370        .flat_map(|c| c.to_lowercase())
3371        .collect()
3372}
3373
3374fn is_root_section(name: &str) -> bool {
3375    name.eq_ignore_ascii_case("root")
3376}
3377
3378fn state_view_key_definition(
3379    entity_name: &str,
3380    identity: &IdentitySpec,
3381    field_mappings: &BTreeMap<String, FieldTypeInfo>,
3382    sections: &[EntitySection],
3383) -> Result<StateViewKeyDefinition, String> {
3384    let mut seen = HashSet::new();
3385    let distinct_keys: Vec<&str> = identity
3386        .primary_keys
3387        .iter()
3388        .map(String::as_str)
3389        .filter(|key| seen.insert(*key))
3390        .collect();
3391
3392    if distinct_keys.len() > 1 {
3393        return Err(format!(
3394            "TypeScript SDK generation does not support composite state keys for entity '{}': distinct identity.primary_keys are [{}]",
3395            entity_name,
3396            distinct_keys.join(", ")
3397        ));
3398    }
3399
3400    let key_path = distinct_keys.first().copied().ok_or_else(|| {
3401        format!(
3402            "TypeScript SDK generation requires a primary key for entity '{}'",
3403            entity_name
3404        )
3405    })?;
3406    let key_leaf = key_path.rsplit('.').next().unwrap_or(key_path);
3407    let field_info = field_mappings.get(key_path).or_else(|| {
3408        sections.iter().find_map(|section| {
3409            section.fields.iter().find(|field| {
3410                field.raw_field_name() == key_path
3411                    || field.raw_field_name() == key_leaf
3412                    || field.field_name == key_path
3413                    || field.field_name == key_leaf
3414            })
3415        })
3416    });
3417
3418    let field_name = field_info
3419        .map(FieldTypeInfo::canonical_field_name)
3420        .unwrap_or_else(|| to_camel_case(key_leaf));
3421    let typescript_type = match field_info {
3422        Some(field) if field.is_array => {
3423            return Err(format!(
3424                "TypeScript SDK generation does not support array state key '{}' for entity '{}'",
3425                key_path, entity_name
3426            ));
3427        }
3428        Some(field) => match field.base_type {
3429            BaseType::String | BaseType::Binary | BaseType::Pubkey => "string".to_string(),
3430            BaseType::Integer | BaseType::Timestamp => field
3431                .effective_integer_kind()
3432                .map(integer_kind_to_typescript)
3433                .unwrap_or("number")
3434                .to_string(),
3435            _ => {
3436                return Err(format!(
3437                    "TypeScript SDK generation does not support state key '{}' with type '{}' for entity '{}'",
3438                    key_path, field.rust_type_name, entity_name
3439                ));
3440            }
3441        },
3442        // Legacy ASTs may omit field metadata; retain their existing string wire key.
3443        None => "string".to_string(),
3444    };
3445
3446    Ok(StateViewKeyDefinition {
3447        field_name,
3448        typescript_type,
3449    })
3450}
3451
3452fn is_builtin_resolver_type(type_name: &str) -> bool {
3453    crate::resolvers::is_resolver_output_type(type_name)
3454}
3455
3456/// Convert PascalCase/camelCase to kebab-case
3457fn to_kebab_case(s: &str) -> String {
3458    let mut result = String::new();
3459
3460    for ch in s.chars() {
3461        if ch.is_uppercase() && !result.is_empty() {
3462            result.push('-');
3463        }
3464        result.push(ch.to_lowercase().next().unwrap());
3465    }
3466
3467    result
3468}
3469
3470/// CLI-friendly function to generate TypeScript from a spec function
3471/// This will be used by the CLI tool to generate TypeScript from discovered specs
3472pub fn generate_typescript_from_spec_fn<F, S>(
3473    spec_fn: F,
3474    entity_name: String,
3475    config: Option<TypeScriptConfig>,
3476) -> Result<TypeScriptOutput, String>
3477where
3478    F: Fn() -> TypedStreamSpec<S>,
3479{
3480    let spec = spec_fn();
3481    let compiler =
3482        TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
3483
3484    compiler.try_compile()
3485}
3486
3487/// Write TypeScript output to a file
3488pub fn write_typescript_to_file(
3489    output: &TypeScriptOutput,
3490    path: &std::path::Path,
3491) -> Result<(), std::io::Error> {
3492    std::fs::write(path, output.full_file())
3493}
3494
3495/// Generate TypeScript from a SerializableStreamSpec (for CLI use)
3496/// This allows the CLI to compile TypeScript without needing the typed spec
3497pub fn compile_serializable_spec(
3498    spec: SerializableStreamSpec,
3499    entity_name: String,
3500    config: Option<TypeScriptConfig>,
3501) -> Result<TypeScriptOutput, String> {
3502    entity_compiler(spec, entity_name, config, HashSet::new()).try_compile()
3503}
3504
3505fn entity_compiler(
3506    spec: SerializableStreamSpec,
3507    entity_name: String,
3508    config: Option<TypeScriptConfig>,
3509    already_emitted_types: HashSet<String>,
3510) -> TypeScriptCompiler<()> {
3511    let idl = spec
3512        .idl
3513        .as_ref()
3514        .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
3515
3516    let handlers = serde_json::to_value(&spec.handlers).ok();
3517    let views = spec.views.clone();
3518
3519    let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
3520
3521    TypeScriptCompiler::new(typed_spec, entity_name)
3522        .with_idl(idl)
3523        .with_handlers_json(handlers)
3524        .with_views(views)
3525        .with_config(config.unwrap_or_default())
3526        .with_already_emitted_types(already_emitted_types)
3527}
3528
3529/// Names a stack module declares for its entities and their sections, and
3530/// the runtime envelopes and builtin resolver types entities emit. A resolved
3531/// type renamed around a same-named, different type never takes one of them.
3532fn stack_entity_type_names(entities: &[SerializableStreamSpec]) -> Vec<String> {
3533    let mut names = vec!["EventWrapper".to_string(), "CaptureWrapper".to_string()];
3534    names.extend(
3535        crate::resolvers::builtin_resolver_registry()
3536            .definitions()
3537            .map(|resolver| resolver.output_type().to_string()),
3538    );
3539    for entity in entities {
3540        let entity_name = to_pascal_case(&entity.state_name);
3541        names.push(format!("{entity_name}Completed"));
3542        names.push(format!("{entity_name}Patch"));
3543        let section_names = entity
3544            .sections
3545            .iter()
3546            .map(|section| section.name.clone())
3547            .chain(entity.handlers.iter().flat_map(|handler| {
3548                handler
3549                    .mappings
3550                    .iter()
3551                    .filter_map(|mapping| mapping.target_path.split_once('.'))
3552                    .map(|(section, _)| section.to_string())
3553            }))
3554            .filter(|section| !is_root_section(section))
3555            .collect::<BTreeSet<_>>();
3556        for section in section_names {
3557            let section_name = section_interface_name("", &entity.state_name, &section);
3558            names.push(format!("{section_name}Patch"));
3559            names.push(section_name);
3560        }
3561        names.push(entity_name);
3562    }
3563    names
3564}
3565
3566#[derive(Debug, Clone, PartialEq, Eq)]
3567pub struct TypeScriptProgramDefinitionMetadata {
3568    pub program_id: String,
3569    /// Output metadata. Generation recomputes this from emitted content; input values are ignored.
3570    pub sdk_definition_hash: Option<String>,
3571    pub program_spec_hash: String,
3572    pub idl_content_hash: String,
3573    pub normalized_idl_hash: String,
3574    /// The program package release this program SDK was generated from.
3575    /// Registry installs set it; local and path builds leave it unset. It is
3576    /// never emitted into the core module: identity describes the finished
3577    /// program SDK, so the SDK's entry module stamps it with
3578    /// `withProgramIdentity` after applying the package's own extension and
3579    /// read descriptor. The core definition, which lacks those, carries no
3580    /// identity, and `sdkDefinitionHash` is independent of it.
3581    pub package_release_hash: Option<String>,
3582}
3583
3584#[derive(Debug, Clone, PartialEq, Eq)]
3585pub struct TypeScriptProgramReleaseReference {
3586    pub program_release_hash: String,
3587    pub program_spec_hash: String,
3588}
3589
3590#[derive(Debug, Clone, PartialEq)]
3591pub struct TypeScriptProgramReadBinding {
3592    pub endpoint: String,
3593    pub program_read_binding_id: String,
3594    pub auth: serde_json::Value,
3595}
3596
3597#[derive(Debug, Clone, PartialEq)]
3598pub enum TypeScriptProgramReadTransport {
3599    LocalHttp,
3600    HostedBinding(TypeScriptProgramReadBinding),
3601}
3602
3603#[derive(Debug, Clone, PartialEq)]
3604pub struct TypeScriptProgramConfig {
3605    pub definition: TypeScriptProgramDefinitionMetadata,
3606    pub release: TypeScriptProgramReleaseReference,
3607    pub transport: TypeScriptProgramReadTransport,
3608    /// Managed-hosting transports carried by a standalone program cartridge.
3609    pub gateway: Option<serde_json::Value>,
3610}
3611
3612impl From<&arete_hash::OssProgramIdentityV1> for TypeScriptProgramConfig {
3613    fn from(identity: &arete_hash::OssProgramIdentityV1) -> Self {
3614        Self {
3615            definition: TypeScriptProgramDefinitionMetadata {
3616                program_id: identity.program_spec.program_id.clone(),
3617                sdk_definition_hash: None,
3618                program_spec_hash: identity.program_spec_hash.to_string(),
3619                idl_content_hash: identity.program_spec.idl_content_hash.to_string(),
3620                normalized_idl_hash: identity.program_spec.normalized_idl_hash.to_string(),
3621                package_release_hash: None,
3622            },
3623            release: TypeScriptProgramReleaseReference {
3624                program_release_hash: identity.release_hash.to_string(),
3625                program_spec_hash: identity.program_spec_hash.to_string(),
3626            },
3627            transport: TypeScriptProgramReadTransport::LocalHttp,
3628            gateway: None,
3629        }
3630    }
3631}
3632
3633#[derive(Debug, Clone)]
3634pub struct TypeScriptStackConfig {
3635    pub package_name: String,
3636    pub generate_helpers: bool,
3637    pub export_const_name: String,
3638    pub websocket_url: Option<String>,
3639    pub http_url: Option<String>,
3640    pub extension_import: Option<String>,
3641    /// Hosted metadata in exact AST program order. Local generation derives this from
3642    /// `SerializableStackSpec::program_specs` instead.
3643    pub programs: Option<Vec<TypeScriptProgramConfig>>,
3644    /// Managed-hosting transports. Local generation leaves this unset.
3645    pub gateway: Option<serde_json::Value>,
3646    /// Served version emitted as the stack definition's `release`. Only
3647    /// StackManifest generation for a hosted endpoint sets it; without it the
3648    /// generated definition is unchanged.
3649    pub release: Option<crate::public_artifacts::StackRelease>,
3650    /// Extra JSDoc for entity fields: entity name, then wire path
3651    /// (`state.total_deployed`) to the note, such as a token amount's unit.
3652    pub field_notes: BTreeMap<String, BTreeMap<String, String>>,
3653}
3654
3655impl Default for TypeScriptStackConfig {
3656    fn default() -> Self {
3657        Self {
3658            package_name: "@usearete/react".to_string(),
3659            generate_helpers: true,
3660            export_const_name: "STACK".to_string(),
3661            websocket_url: None,
3662            http_url: None,
3663            extension_import: None,
3664            programs: None,
3665            gateway: None,
3666            release: None,
3667            field_notes: BTreeMap::new(),
3668        }
3669    }
3670}
3671
3672#[derive(Debug, Clone)]
3673pub struct TypeScriptStackOutput {
3674    pub interfaces: String,
3675    pub stack_definition: String,
3676    pub imports: String,
3677    /// Non-fatal codegen warnings (skipped instructions, PDAs degraded to
3678    /// user-provided accounts). Callers should surface these to the user.
3679    pub warnings: Vec<String>,
3680    /// Structured PDA degradations for summary reporting.
3681    pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
3682}
3683
3684#[derive(Debug, Clone, Default)]
3685pub struct TypeScriptLiveEndpoints {
3686    pub websocket_url: Option<String>,
3687    pub http_url: Option<String>,
3688}
3689
3690#[derive(Debug, Clone, Default)]
3691pub struct TypeScriptCompositionConfig {
3692    pub stack: TypeScriptStackConfig,
3693    pub live_endpoints: BTreeMap<String, TypeScriptLiveEndpoints>,
3694    /// The served version of an alias whose endpoint is a deployment of
3695    /// another StackManifest, such as a composed stack's alias reading its
3696    /// source stack's deployment. Other bound aliases serve their own alias
3697    /// of this manifest.
3698    pub live_releases: BTreeMap<String, crate::public_artifacts::StackRelease>,
3699    pub live_module_imports: BTreeMap<String, String>,
3700    pub program_module_imports: BTreeMap<String, String>,
3701    /// Program SDK entry modules keyed by ProgramSpec hash (relative import
3702    /// paths such as `./programs/ore/__arete-program.js`). A program with an
3703    /// entry module is that module in the session's `programs` and in every
3704    /// live stack that bundles it, so the session exposes one program SDK
3705    /// surface whichever member it is reached through.
3706    pub program_entry_imports: BTreeMap<String, String>,
3707    /// A stack-level extension applied to the composition.
3708    pub extensions: Option<TypeScriptCompositionExtensions>,
3709}
3710
3711/// A stack-level extension for a composition. A composition has no single
3712/// stack, so the extension binds what it extends by name: a
3713/// `defineStackExtensions<typeof X.stacks.<alias>>()` export extends that
3714/// live alias's stack, and a `defineProgramExtensions<typeof
3715/// X.programs.<key>>()` export extends that session program.
3716#[derive(Debug, Clone, Default)]
3717pub struct TypeScriptCompositionExtensions {
3718    /// Relative import of the extension entry module (`./stack-extensions.js`).
3719    pub import_path: String,
3720    /// `(live alias, export name)` pairs, applied with `extendStack`.
3721    pub stack_bindings: Vec<(String, String)>,
3722    /// `(session program key, export name)` pairs, applied with
3723    /// `extendPrograms`.
3724    pub program_bindings: Vec<(String, String)>,
3725}
3726
3727#[derive(Debug, Clone)]
3728pub struct TypeScriptAliasedStackOutput {
3729    pub alias: String,
3730    pub module_name: String,
3731    pub output: TypeScriptStackOutput,
3732}
3733
3734#[derive(Debug, Clone)]
3735pub struct TypeScriptProgramCollectionOutput {
3736    pub module_name: String,
3737    pub output: TypeScriptStackOutput,
3738    pub members: Vec<(String, String)>,
3739}
3740
3741#[derive(Debug, Clone)]
3742pub struct TypeScriptCompositionOutput {
3743    pub name: String,
3744    pub live_stacks: Vec<TypeScriptAliasedStackOutput>,
3745    pub program_collection: Option<TypeScriptProgramCollectionOutput>,
3746    pub session_definition: String,
3747    pub warnings: Vec<String>,
3748    pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
3749}
3750
3751impl TypeScriptStackOutput {
3752    pub fn full_file(&self) -> String {
3753        let mut parts = Vec::new();
3754        if !self.imports.is_empty() {
3755            parts.push(self.imports.as_str());
3756        }
3757        if !self.interfaces.is_empty() {
3758            parts.push(self.interfaces.as_str());
3759        }
3760        if !self.stack_definition.is_empty() {
3761            parts.push(self.stack_definition.as_str());
3762        }
3763        parts.join("\n\n")
3764    }
3765}
3766
3767fn resolve_program_configs(
3768    stack_spec: &SerializableStackSpec,
3769    configured: Option<&[TypeScriptProgramConfig]>,
3770    allow_view_only: bool,
3771) -> Result<Vec<TypeScriptProgramConfig>, String> {
3772    if stack_spec.idls.is_empty() {
3773        if stack_spec.program_specs.is_empty()
3774            && configured.is_none_or(|programs| programs.is_empty())
3775        {
3776            return Ok(Vec::new());
3777        }
3778        return Err(format!(
3779            "Stack '{}' has program metadata but no ordered IDL list",
3780            stack_spec.stack_name
3781        ));
3782    }
3783
3784    if stack_spec.program_specs.is_empty() {
3785        let view_only = stack_spec.program_ids.is_empty()
3786            && stack_spec.instructions.is_empty()
3787            && stack_spec.pdas.is_empty()
3788            && stack_spec
3789                .idls
3790                .iter()
3791                .all(|idl| idl.accounts.is_empty() && idl.instructions.is_empty());
3792        if allow_view_only && view_only && configured.is_none_or(|programs| programs.is_empty()) {
3793            return Ok(Vec::new());
3794        }
3795        return Err(format!(
3796            "Stack '{}' uses program SDK/account definitions but its compiled input has no exact public ProgramSpecV1 values. Rebuild the ProgramSpec and StackManifest artifact closure with the current arete-macros before generating an SDK.",
3797            stack_spec.stack_name
3798        ));
3799    }
3800
3801    let expected = stack_spec.idls.len();
3802    if stack_spec.program_ids.len() != expected || stack_spec.program_specs.len() != expected {
3803        return Err(format!(
3804            "Stack '{}' program metadata is not aligned: program_ids={}, idls={}, program_specs={}. Rebuild the ProgramSpec and StackManifest artifact closure with the current arete-macros.",
3805            stack_spec.stack_name,
3806            stack_spec.program_ids.len(),
3807            stack_spec.idls.len(),
3808            stack_spec.program_specs.len(),
3809        ));
3810    }
3811
3812    let mut exact_definitions = Vec::with_capacity(expected);
3813    let mut program_keys = BTreeMap::new();
3814    for (index, ((program_id, idl), program_spec)) in stack_spec
3815        .program_ids
3816        .iter()
3817        .zip(&stack_spec.idls)
3818        .zip(&stack_spec.program_specs)
3819        .enumerate()
3820    {
3821        program_spec.validate().map_err(|error| {
3822            format!(
3823                "Stack '{}' program_specs[{index}] is invalid: {error}",
3824                stack_spec.stack_name
3825            )
3826        })?;
3827        if program_id != &program_spec.program_id {
3828            return Err(format!(
3829                "Stack '{}' program ID mismatch at index {index}: program_ids has '{}', ProgramSpecV1 has '{}'",
3830                stack_spec.stack_name, program_id, program_spec.program_id
3831            ));
3832        }
3833        if idl.program_id.as_deref() != Some(program_spec.program_id.as_str()) {
3834            return Err(format!(
3835                "Stack '{}' IDL program ID mismatch at index {index}: expected '{}'",
3836                stack_spec.stack_name, program_spec.program_id
3837            ));
3838        }
3839        if idl.name != program_spec.idl_snapshot.snapshot.name {
3840            return Err(format!(
3841                "Stack '{}' IDL/ProgramSpec name mismatch at index {index}: '{}' != '{}'",
3842                stack_spec.stack_name, idl.name, program_spec.idl_snapshot.snapshot.name
3843            ));
3844        }
3845        let program_key = to_camel_case(&idl.name);
3846        if let Some(existing) = program_keys.insert(program_key.clone(), idl.name.as_str()) {
3847            return Err(format!(
3848                "Stack '{}' has an ambiguous duplicate generated program key '{}': programs '{}' and '{}' both generate it; rename one of them",
3849                stack_spec.stack_name, program_key, existing, idl.name
3850            ));
3851        }
3852        exact_definitions.push(TypeScriptProgramDefinitionMetadata {
3853            program_id: program_spec.program_id.clone(),
3854            sdk_definition_hash: None,
3855            program_spec_hash: program_spec
3856                .hash()
3857                .map_err(|error| {
3858                    format!(
3859                        "Stack '{}' could not hash ProgramSpecV1 at index {index}: {error}",
3860                        stack_spec.stack_name
3861                    )
3862                })?
3863                .to_string(),
3864            idl_content_hash: program_spec.idl_content_hash.to_string(),
3865            normalized_idl_hash: program_spec.normalized_idl_hash.to_string(),
3866            package_release_hash: None,
3867        });
3868    }
3869
3870    let Some(configured) = configured else {
3871        return stack_spec
3872            .program_specs
3873            .iter()
3874            .enumerate()
3875            .map(|(index, program_spec)| {
3876                arete_hash::OssProgramIdentityV1::new(program_spec.clone())
3877                    .map(|identity| TypeScriptProgramConfig::from(&identity))
3878                    .map_err(|error| {
3879                        format!(
3880                            "Stack '{}' could not derive the OSS release for program index {index}: {error}",
3881                            stack_spec.stack_name
3882                        )
3883                    })
3884            })
3885            .collect();
3886    };
3887    if configured.len() != expected {
3888        return Err(format!(
3889            "Stack '{}' hosted descriptor count mismatch: expected {expected}, received {}",
3890            stack_spec.stack_name,
3891            configured.len()
3892        ));
3893    }
3894
3895    for (index, (hosted, exact)) in configured.iter().zip(&exact_definitions).enumerate() {
3896        if hosted.definition.program_id != exact.program_id {
3897            return Err(format!(
3898                "Stack '{}' hosted descriptor program ID mismatch at index {index}: expected '{}', received '{}'",
3899                stack_spec.stack_name,
3900                exact.program_id,
3901                hosted.definition.program_id
3902            ));
3903        }
3904        if hosted.definition.program_spec_hash != exact.program_spec_hash {
3905            return Err(format!(
3906                "Stack '{}' hosted descriptor programSpecHash mismatch at index {index}: expected '{}', received '{}'",
3907                stack_spec.stack_name,
3908                exact.program_spec_hash,
3909                hosted.definition.program_spec_hash
3910            ));
3911        }
3912        if hosted.definition.idl_content_hash != exact.idl_content_hash
3913            || hosted.definition.normalized_idl_hash != exact.normalized_idl_hash
3914        {
3915            return Err(format!(
3916                "Stack '{}' hosted descriptor definition hashes mismatch at index {index}",
3917                stack_spec.stack_name
3918            ));
3919        }
3920        if hosted.release.program_spec_hash != hosted.definition.program_spec_hash {
3921            return Err(format!(
3922                "Stack '{}' hosted descriptor release programSpecHash mismatch at index {index}",
3923                stack_spec.stack_name
3924            ));
3925        }
3926        if let TypeScriptProgramReadTransport::HostedBinding(binding) = &hosted.transport {
3927            let target_kind = binding
3928                .auth
3929                .get("targetKind")
3930                .and_then(serde_json::Value::as_str);
3931            let session_endpoint = binding
3932                .auth
3933                .get("sessionEndpoint")
3934                .and_then(serde_json::Value::as_str);
3935            if binding.endpoint.trim().is_empty()
3936                || binding.program_read_binding_id.trim().is_empty()
3937                || target_kind != Some("program-read-binding")
3938                || session_endpoint.is_none_or(|value| value.trim().is_empty())
3939            {
3940                return Err(format!(
3941                    "Stack '{}' hosted descriptor binding is incomplete at index {index}",
3942                    stack_spec.stack_name
3943                ));
3944            }
3945        }
3946    }
3947
3948    Ok(configured.to_vec())
3949}
3950
3951/// Compile a full SerializableStackSpec (multi-entity) into a single TypeScript file.
3952///
3953/// Generates:
3954/// - Interfaces for ALL entities (OreRound, OreTreasury, OreMiner, etc.)
3955/// - A single unified stack definition with nested views per entity
3956/// - View helpers (stateView, listView)
3957pub fn compile_stack_spec(
3958    stack_spec: SerializableStackSpec,
3959    config: Option<TypeScriptStackConfig>,
3960) -> Result<TypeScriptStackOutput, String> {
3961    compile_stack_spec_with_view_selection(stack_spec, config, false)
3962}
3963
3964fn compile_stack_spec_with_view_selection(
3965    stack_spec: SerializableStackSpec,
3966    config: Option<TypeScriptStackConfig>,
3967    exact_views: bool,
3968) -> Result<TypeScriptStackOutput, String> {
3969    let config = config.unwrap_or_default();
3970    let program_configs = resolve_program_configs(&stack_spec, config.programs.as_deref(), true)?;
3971    let stack_name = &stack_spec.stack_name;
3972    let stack_kebab = to_kebab_case(stack_name);
3973    validate_entity_identifiers(&stack_spec.entities)?;
3974
3975    // 1. Compile each entity's interfaces using existing per-entity compiler
3976    let mut all_interfaces = Vec::new();
3977    let mut entity_names = Vec::new();
3978    let mut schema_names: Vec<String> = Vec::new();
3979    let mut emitted_types: HashSet<String> = HashSet::new();
3980    let mut stack_types = StackResolvedTypes::default();
3981    stack_types.reserve(stack_entity_type_names(&stack_spec.entities));
3982
3983    for entity_spec in &stack_spec.entities {
3984        let mut spec = entity_spec.clone();
3985        let program_name = entity_program_name(entity_spec, &stack_spec.idls).map(str::to_string);
3986        // Type the entity against its own program's IDL, not the stack's first.
3987        if spec.idl.is_none() {
3988            spec.idl = entity_idl(entity_spec, &stack_spec.idls).cloned();
3989        }
3990        let entity_name = spec.state_name.clone();
3991        entity_names.push(entity_name.clone());
3992
3993        let per_entity_config = TypeScriptConfig {
3994            package_name: config.package_name.clone(),
3995            generate_helpers: false,
3996            interface_prefix: String::new(),
3997            export_const_name: config.export_const_name.clone(),
3998            url: config.websocket_url.clone(),
3999        };
4000
4001        // Collect builtin type names before spec is consumed
4002        let builtin_type_names = extract_builtin_resolver_type_names(&spec);
4003        // Clone IDL before spec is moved so we can check which enums were emitted
4004        let idl_for_check = spec.idl.clone();
4005
4006        let field_notes = config
4007            .field_notes
4008            .get(&entity_name)
4009            .cloned()
4010            .unwrap_or_default();
4011        let compiler = entity_compiler(
4012            spec,
4013            entity_name,
4014            Some(per_entity_config),
4015            emitted_types.clone(),
4016        )
4017        .with_stack_resolved_types(stack_types.clone(), program_name)
4018        .with_field_notes(field_notes);
4019        let output = compiler.try_compile()?;
4020        for (name, resolved) in compiler.declared_resolved_types() {
4021            stack_types.declare(&name, &resolved);
4022        }
4023
4024        // Track shared types for cross-entity dedup
4025        // Only track enum types that were actually emitted (found in output.interfaces)
4026        let emitted_enum_names =
4027            extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref());
4028        emitted_types.extend(emitted_enum_names);
4029        // Supporting structs emitted for enum payloads are shared by later
4030        // entities too, just like the enum declaration itself.
4031        emitted_types.extend(
4032            compiler
4033                .generate_idl_enum_artifacts()
4034                .into_iter()
4035                .map(|(_, schema_name, _)| schema_name.strip_suffix("Schema").unwrap().to_string()),
4036        );
4037        emitted_types.extend(builtin_type_names);
4038        if output
4039            .interfaces
4040            .contains("export interface CaptureWrapper<T>")
4041        {
4042            emitted_types.insert("CaptureWrapper".to_string());
4043        }
4044
4045        // Only take the interfaces part (not the stack_definition — we generate our own)
4046        if !output.interfaces.is_empty() {
4047            all_interfaces.push(output.interfaces);
4048        }
4049
4050        schema_names.extend(output.schema_names);
4051    }
4052
4053    let mut interfaces = all_interfaces.join("\n\n");
4054
4055    // 2. Generate instruction-construction handlers from the stack spec.
4056    // Program errors live once at the stack level (in the IDL snapshots) and
4057    // are scoped per program by the instruction codegen. Entity interface
4058    // names are reserved so defined-type interfaces cannot collide with them.
4059    let mut reserved_type_names: std::collections::HashSet<String> =
4060        std::collections::HashSet::new();
4061    for line in interfaces.lines() {
4062        for prefix in ["export interface ", "export type "] {
4063            if let Some(rest) = line.strip_prefix(prefix) {
4064                let name: String = rest
4065                    .chars()
4066                    .take_while(|c| c.is_alphanumeric() || *c == '_')
4067                    .collect();
4068                if !name.is_empty() {
4069                    reserved_type_names.insert(name);
4070                }
4071            }
4072        }
4073    }
4074    let idl_account_artifacts =
4075        generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
4076    for type_name in &idl_account_artifacts.type_names {
4077        reserved_type_names.insert(type_name.clone());
4078    }
4079    if !idl_account_artifacts.code.is_empty() {
4080        if interfaces.is_empty() {
4081            interfaces = idl_account_artifacts.code.clone();
4082        } else {
4083            interfaces = format!("{}\n\n{}", interfaces, idl_account_artifacts.code);
4084        }
4085    }
4086    schema_names.extend(idl_account_artifacts.schema_names.clone());
4087
4088    let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
4089        stack_name,
4090        &stack_spec.instructions,
4091        &stack_spec.idls,
4092        &stack_spec.pdas,
4093        &stack_spec.program_ids,
4094        &reserved_type_names,
4095    );
4096    if !instructions_codegen.code.is_empty() {
4097        if interfaces.is_empty() {
4098            interfaces = instructions_codegen.code.clone();
4099        } else {
4100            interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
4101        }
4102    }
4103
4104    let imports = assemble_sdk_imports(
4105        collect_emitted_pda_imports(&stack_spec.idls, &stack_spec.pdas),
4106        !idl_account_artifacts.account_type_names.is_empty(),
4107        &instructions_codegen,
4108    );
4109    let program_configs = program_content_identities(
4110        &ProgramGenerationContext {
4111            pdas: &stack_spec.pdas,
4112            program_ids: &stack_spec.program_ids,
4113            instruction_entries: &instructions_codegen.stack_entries,
4114            schema_names: &schema_names.iter().cloned().collect(),
4115            account_type_names: &idl_account_artifacts.account_type_names,
4116            programs: &program_configs,
4117        },
4118        &stack_spec.idls,
4119        &imports,
4120        &interfaces,
4121    )?;
4122
4123    // 3. Generate unified stack definition with all entity views and attached program SDKs.
4124    let stack_definition = generate_stack_definition_multi(
4125        stack_name,
4126        &stack_kebab,
4127        &stack_spec.entities,
4128        &entity_names,
4129        &stack_spec.idls,
4130        &stack_spec.pdas,
4131        &stack_spec.program_ids,
4132        &schema_names,
4133        &idl_account_artifacts.account_type_names,
4134        &instructions_codegen.stack_entries,
4135        &program_configs,
4136        &config,
4137        exact_views,
4138    )?;
4139
4140    let output = TypeScriptStackOutput {
4141        imports,
4142        interfaces,
4143        stack_definition,
4144        warnings: instructions_codegen.warnings,
4145        pda_degradations: instructions_codegen.pda_degradations,
4146    };
4147    ts_ident::check_module_declarations(
4148        &output.full_file(),
4149        &format!("The TypeScript SDK for stack '{stack_name}'"),
4150    )?;
4151    Ok(output)
4152}
4153
4154/// One field of an entity row as the generated TypeScript declares it.
4155#[derive(Debug, Clone, PartialEq, Eq)]
4156pub struct TypeScriptFieldReference {
4157    /// The field's path on the row: `id.roundId`.
4158    pub path: String,
4159    /// Its path in raw frames and in CLI and MCP output: `id.round_id`.
4160    pub wire: String,
4161    /// Its TypeScript type, without `| null`.
4162    pub ts_type: String,
4163    /// Whether the type is `ts_type | null`.
4164    pub nullable: bool,
4165}
4166
4167/// An entity of a stack as its generated TypeScript declares it.
4168#[derive(Debug, Clone, PartialEq, Eq)]
4169pub struct TypeScriptEntityReference {
4170    /// The entity name: its view ids' prefix and its key under `views`.
4171    pub entity: String,
4172    /// The row interface.
4173    pub type_name: String,
4174    /// The `state` view's key field and its TypeScript type, when the
4175    /// entity has a single-field key.
4176    pub state_key: Option<(String, String)>,
4177    pub fields: Vec<TypeScriptFieldReference>,
4178}
4179
4180/// The row of every entity of `stack_spec` as [`compile_stack_spec`]
4181/// declares it: field paths in TypeScript and on the wire, with their types.
4182pub fn typescript_entity_references(
4183    stack_spec: &SerializableStackSpec,
4184) -> Result<Vec<TypeScriptEntityReference>, String> {
4185    validate_entity_identifiers(&stack_spec.entities)?;
4186    let mut stack_types = StackResolvedTypes::default();
4187    stack_types.reserve(stack_entity_type_names(&stack_spec.entities));
4188    let mut references = Vec::new();
4189    for entity_spec in &stack_spec.entities {
4190        let mut spec = entity_spec.clone();
4191        let program_name = entity_program_name(entity_spec, &stack_spec.idls).map(str::to_string);
4192        if spec.idl.is_none() {
4193            spec.idl = entity_idl(entity_spec, &stack_spec.idls).cloned();
4194        }
4195        let entity_name = spec.state_name.clone();
4196        let state_key = state_view_key_definition(
4197            &entity_name,
4198            &spec.identity,
4199            &spec.field_mappings,
4200            &spec.sections,
4201        )
4202        .ok()
4203        .map(|key| (key.field_name, key.typescript_type));
4204        let compiler = entity_compiler(spec, entity_name.clone(), None, HashSet::new())
4205            .with_stack_resolved_types(stack_types.clone(), program_name);
4206        references.push(TypeScriptEntityReference {
4207            type_name: to_pascal_case(&entity_name),
4208            entity: entity_name,
4209            state_key,
4210            fields: compiler.entity_field_references(),
4211        });
4212        for (name, resolved) in compiler.declared_resolved_types() {
4213            stack_types.declare(&name, &resolved);
4214        }
4215    }
4216    Ok(references)
4217}
4218
4219/// Entity names are the stems of every entity type (`TokenAccount`,
4220/// `TokenAccountSchema`, ...) and stay verbatim in view IDs, so their
4221/// PascalCase form must already be an identifier. Both authoring paths
4222/// guarantee this (Rust structs, and Stack Source entity names matching
4223/// `[A-Za-z][A-Za-z0-9_]*`); report anything else instead of emitting a module
4224/// that does not parse.
4225fn validate_entity_identifiers(entities: &[SerializableStreamSpec]) -> Result<(), String> {
4226    match entities
4227        .iter()
4228        .find(|entity| !ts_ident::is_identifier(&to_pascal_case(&entity.state_name)))
4229    {
4230        Some(entity) => Err(format!(
4231            "entity '{}' cannot be used as a TypeScript type name; entity names must be identifiers such as `TokenAccount`",
4232            entity.state_name
4233        )),
4234        None => Ok(()),
4235    }
4236}
4237
4238/// Compile a stack model whose `views` have already been projected by a
4239/// StackManifest selected-view allowlist.
4240pub fn compile_stack_spec_with_exact_views(
4241    stack_spec: SerializableStackSpec,
4242    config: Option<TypeScriptStackConfig>,
4243) -> Result<TypeScriptStackOutput, String> {
4244    compile_stack_spec_with_view_selection(stack_spec, config, true)
4245}
4246
4247/// Compile an explicit StackManifest and its exact public dependencies.
4248pub fn compile_public_artifacts(
4249    programs: &[arete_artifacts::ProgramSpecArtifact],
4250    live_spec: &arete_artifacts::LiveSpecArtifact,
4251    manifest: &arete_artifacts::StackManifestArtifact,
4252    config: Option<TypeScriptStackConfig>,
4253) -> Result<TypeScriptStackOutput, String> {
4254    let stack_spec =
4255        crate::public_artifacts::stack_spec_from_artifacts(programs, live_spec, manifest)?;
4256    compile_stack_spec(stack_spec, config)
4257}
4258
4259/// Compile typed V2 public artifacts through the current single-live generator.
4260pub fn compile_public_artifacts_v2(
4261    programs: &[arete_artifacts::ProgramSpecArtifact],
4262    live_spec: &arete_artifacts::LiveSpecArtifactV2,
4263    manifest: &arete_artifacts::StackManifestArtifactV2,
4264    config: Option<TypeScriptStackConfig>,
4265) -> Result<TypeScriptStackOutput, String> {
4266    let stack_spec =
4267        crate::public_artifacts::stack_spec_from_artifacts_v2(programs, live_spec, manifest)?;
4268    let mut config = config.unwrap_or_default();
4269    // The served version belongs to the stack bound to a WebSocket endpoint.
4270    if config.release.is_none() && config.websocket_url.is_some() {
4271        config.release = crate::public_artifacts::StackRelease::for_single_live(manifest);
4272    }
4273    compile_stack_spec_with_view_selection(stack_spec, Some(config), true)
4274}
4275
4276/// Compile each aliased LiveSpec into an independent module and generate a
4277/// manifest-level `createSession` definition that preserves exact alias keys.
4278///
4279/// Each alias bound to a WebSocket endpoint in `live_endpoints` is generated
4280/// with its served version (`release`, overridden by `live_releases`);
4281/// unbound aliases get none.
4282pub fn compile_composed_public_artifacts_v2(
4283    programs: &[arete_artifacts::ProgramSpecArtifact],
4284    live_specs: &[(String, arete_artifacts::LiveSpecArtifactV2)],
4285    manifest: &arete_artifacts::StackManifestArtifactV2,
4286    config: Option<TypeScriptCompositionConfig>,
4287) -> Result<TypeScriptCompositionOutput, String> {
4288    let composed =
4289        crate::public_artifacts::stack_specs_from_artifacts_v2(programs, live_specs, manifest)?;
4290    if composed.live_specs.is_empty() {
4291        return Err(
4292            "TypeScript session generation requires at least one aliased LiveSpec".to_string(),
4293        );
4294    }
4295    let config = config.unwrap_or_default();
4296    let live_aliases = composed
4297        .live_specs
4298        .iter()
4299        .map(|live| live.alias.as_str())
4300        .collect::<BTreeSet<_>>();
4301    if let Some(alias) = config
4302        .live_module_imports
4303        .keys()
4304        .find(|alias| !live_aliases.contains(alias.as_str()))
4305    {
4306        return Err(format!(
4307            "composition module import references unknown LiveSpec alias '{alias}'"
4308        ));
4309    }
4310    let mut outputs = Vec::with_capacity(composed.live_specs.len());
4311    let mut warnings = Vec::new();
4312    let mut pda_degradations = Vec::new();
4313
4314    let live_program_hashes = live_specs
4315        .iter()
4316        .flat_map(|(_, live)| &live.payload.programs)
4317        .map(|requirement| requirement.program_spec_hash.to_string())
4318        .collect::<BTreeSet<_>>();
4319    let independent_programs = programs
4320        .iter()
4321        .filter(|program| !live_program_hashes.contains(&program.artifact_hash.to_string()))
4322        .cloned()
4323        .collect::<Vec<_>>();
4324    // Session `programs` keys are sanitized program names; two programs that
4325    // map to the same key would silently overwrite one another.
4326    let mut program_key_owners = crate::identifiers::IdentifierScope::new("TypeScript");
4327    let mut independent_program_keys = BTreeSet::new();
4328    for program in &independent_programs {
4329        let name = &program.payload.idl_snapshot.snapshot.name;
4330        let public = composition_program_key(program, &to_camel_case(name));
4331        program_key_owners.claim(
4332            &public,
4333            &format!("program '{name}' ({})", program.payload.program_id),
4334        )?;
4335        independent_program_keys.insert(public);
4336    }
4337    if let Some(alias) = config
4338        .program_module_imports
4339        .keys()
4340        .find(|alias| !independent_program_keys.contains(alias.as_str()))
4341    {
4342        return Err(format!(
4343            "composition program module import references unknown independent program alias '{alias}'"
4344        ));
4345    }
4346    let program_collection = if independent_programs.is_empty() {
4347        None
4348    } else {
4349        let program_stack = crate::public_artifacts::stack_spec_from_program_artifacts(
4350            format!("{}Programs", composed.name),
4351            &independent_programs,
4352        )?;
4353        let mut program_config = config.stack.clone();
4354        program_config.websocket_url = None;
4355        program_config.http_url = None;
4356        program_config.release = None;
4357        program_config.programs =
4358            subset_program_configs(&program_stack, config.stack.programs.as_deref())?;
4359        let output =
4360            compile_stack_spec_with_view_selection(program_stack, Some(program_config), true)?;
4361        warnings.extend(output.warnings.iter().cloned());
4362        pda_degradations.extend(output.pda_degradations.iter().cloned());
4363        Some(TypeScriptProgramCollectionOutput {
4364            module_name: format!("{}-programs", to_kebab_case(&composed.name)),
4365            output,
4366            members: independent_programs
4367                .iter()
4368                .map(|program| {
4369                    let source = to_camel_case(&program.payload.idl_snapshot.snapshot.name);
4370                    let public = composition_program_key(program, &source);
4371                    (public, source)
4372                })
4373                .collect(),
4374        })
4375    };
4376
4377    let mut promoted_programs = Vec::new();
4378    let mut promoted_hashes = BTreeMap::<String, String>::new();
4379    // Session program key -> ProgramSpec hash, for program entry imports.
4380    let mut session_program_hashes = independent_programs
4381        .iter()
4382        .map(|program| {
4383            let source = to_camel_case(&program.payload.idl_snapshot.snapshot.name);
4384            (
4385                composition_program_key(program, &source),
4386                program.artifact_hash.to_string(),
4387            )
4388        })
4389        .collect::<BTreeMap<_, _>>();
4390    // Live alias -> the bundled program keys replaced by an entry module.
4391    let mut stack_program_overrides = BTreeMap::<String, Vec<(String, String)>>::new();
4392    for live in composed.live_specs {
4393        let mut stack_config = config.stack.clone();
4394        if let Some(endpoints) = config.live_endpoints.get(&live.alias) {
4395            stack_config.websocket_url = endpoints.websocket_url.clone();
4396            stack_config.http_url = endpoints.http_url.clone();
4397        } else {
4398            stack_config.websocket_url = None;
4399            stack_config.http_url = None;
4400        }
4401        stack_config.release = stack_config.websocket_url.is_some().then(|| {
4402            config
4403                .live_releases
4404                .get(&live.alias)
4405                .cloned()
4406                .unwrap_or_else(|| {
4407                    crate::public_artifacts::StackRelease::for_alias(manifest, &live.alias)
4408                })
4409        });
4410        stack_config.programs =
4411            subset_program_configs(&live.stack_spec, config.stack.programs.as_deref())?;
4412        for program in &live.stack_spec.program_specs {
4413            let source = to_camel_case(&program.idl_snapshot.snapshot.name);
4414            let hash = program
4415                .hash()
4416                .map_err(|error| error.to_string())?
4417                .to_string();
4418            if config.program_entry_imports.contains_key(&hash) {
4419                stack_program_overrides
4420                    .entry(live.alias.clone())
4421                    .or_default()
4422                    .push((source.clone(), hash.clone()));
4423            }
4424            if let Some(existing_hash) = promoted_hashes.get(&source) {
4425                if existing_hash != &hash {
4426                    return Err(format!(
4427                        "composition programs use duplicate generated key '{source}' for different ProgramSpecs"
4428                    ));
4429                }
4430                continue;
4431            }
4432            program_key_owners.claim(
4433                &source,
4434                &format!(
4435                    "program '{}' ({})",
4436                    program.idl_snapshot.snapshot.name, program.program_id
4437                ),
4438            )?;
4439            session_program_hashes.insert(source.clone(), hash.clone());
4440            promoted_hashes.insert(source.clone(), hash);
4441            promoted_programs.push((source.clone(), live.alias.clone(), source));
4442        }
4443        let module_name = typescript_module_name(&live.alias);
4444        let output =
4445            compile_stack_spec_with_view_selection(live.stack_spec, Some(stack_config), true)?;
4446        warnings.extend(output.warnings.iter().cloned());
4447        pda_degradations.extend(output.pda_degradations.iter().cloned());
4448        outputs.push(TypeScriptAliasedStackOutput {
4449            alias: live.alias,
4450            module_name,
4451            output,
4452        });
4453    }
4454
4455    let program_entries = resolve_program_entry_imports(
4456        &config.program_entry_imports,
4457        &session_program_hashes,
4458        &config.program_module_imports,
4459    )?;
4460    if let Some(extensions) = &config.extensions {
4461        validate_composition_extensions(
4462            extensions,
4463            &outputs
4464                .iter()
4465                .map(|live| live.alias.clone())
4466                .collect::<BTreeSet<_>>(),
4467            &session_program_hashes,
4468        )?;
4469    }
4470    let session_definition = generate_session_definition(SessionDefinitionInput {
4471        manifest_name: &composed.name,
4472        live_stacks: &outputs,
4473        promoted_programs: &promoted_programs,
4474        program_collection: program_collection.as_ref(),
4475        live_module_imports: &config.live_module_imports,
4476        program_module_imports: &config.program_module_imports,
4477        gateway: config.stack.gateway.as_ref(),
4478        program_entries: &program_entries,
4479        stack_program_overrides: &stack_program_overrides,
4480        extensions: config.extensions.as_ref(),
4481    });
4482    ts_ident::check_module_declarations(
4483        &session_definition,
4484        &format!("The TypeScript session for stack '{}'", composed.name),
4485    )?;
4486    Ok(TypeScriptCompositionOutput {
4487        name: composed.name,
4488        live_stacks: outputs,
4489        program_collection,
4490        session_definition,
4491        warnings,
4492        pda_degradations,
4493    })
4494}
4495
4496/// Session program key -> program entry import, for every configured entry
4497/// module. Every entry module must name a program of the composition, and a
4498/// program takes at most one replacement module.
4499fn resolve_program_entry_imports(
4500    entry_imports: &BTreeMap<String, String>,
4501    session_program_hashes: &BTreeMap<String, String>,
4502    program_module_imports: &BTreeMap<String, String>,
4503) -> Result<BTreeMap<String, String>, String> {
4504    let mut entries = BTreeMap::new();
4505    for (hash, import) in entry_imports {
4506        let keys = session_program_hashes
4507            .iter()
4508            .filter(|(_, program_hash)| *program_hash == hash)
4509            .map(|(key, _)| key.clone())
4510            .collect::<Vec<_>>();
4511        if keys.is_empty() {
4512            return Err(format!(
4513                "composition program entry module '{import}' references unknown ProgramSpec {hash}"
4514            ));
4515        }
4516        for key in keys {
4517            if program_module_imports.contains_key(&key) {
4518                return Err(format!(
4519                    "composition program '{key}' has both a program module import and a program SDK entry module"
4520                ));
4521            }
4522            entries.insert(key, import.clone());
4523        }
4524    }
4525    Ok(entries)
4526}
4527
4528fn validate_composition_extensions(
4529    extensions: &TypeScriptCompositionExtensions,
4530    live_aliases: &BTreeSet<String>,
4531    session_program_hashes: &BTreeMap<String, String>,
4532) -> Result<(), String> {
4533    let mut stacks = BTreeSet::new();
4534    for (alias, export) in &extensions.stack_bindings {
4535        if !live_aliases.contains(alias) {
4536            return Err(format!(
4537                "composition stack extension '{export}' extends unknown live alias '{alias}'"
4538            ));
4539        }
4540        if !stacks.insert(alias) {
4541            return Err(format!(
4542                "composition stack extensions bind live alias '{alias}' more than once"
4543            ));
4544        }
4545    }
4546    let mut programs = BTreeSet::new();
4547    for (key, export) in &extensions.program_bindings {
4548        if !session_program_hashes.contains_key(key) {
4549            return Err(format!(
4550                "composition program extension '{export}' extends unknown program '{key}'"
4551            ));
4552        }
4553        if !programs.insert(key) {
4554            return Err(format!(
4555                "composition stack extensions bind program '{key}' more than once"
4556            ));
4557        }
4558    }
4559    if stacks.is_empty() && programs.is_empty() {
4560        return Err(
4561            "composition stack extension declares no per-alias stack or per-program bindings"
4562                .to_string(),
4563        );
4564    }
4565    Ok(())
4566}
4567
4568/// `.key` when `key` is an identifier, otherwise `["key"]`.
4569fn typescript_member_access(key: &str) -> String {
4570    let property = typescript_property_key(key);
4571    if property == key {
4572        format!(".{key}")
4573    } else {
4574        format!("[{property}]")
4575    }
4576}
4577
4578fn subset_program_configs(
4579    stack_spec: &SerializableStackSpec,
4580    configured: Option<&[TypeScriptProgramConfig]>,
4581) -> Result<Option<Vec<TypeScriptProgramConfig>>, String> {
4582    let Some(configured) = configured else {
4583        return Ok(None);
4584    };
4585    let by_hash = configured
4586        .iter()
4587        .map(|program| {
4588            (
4589                program.definition.program_spec_hash.as_str(),
4590                program.clone(),
4591            )
4592        })
4593        .collect::<BTreeMap<_, _>>();
4594    stack_spec
4595        .program_specs
4596        .iter()
4597        .map(|program| {
4598            let hash = program
4599                .hash()
4600                .map_err(|error| error.to_string())?
4601                .to_string();
4602            by_hash.get(hash.as_str()).cloned().ok_or_else(|| {
4603                format!("missing configured program descriptor for ProgramSpec {hash}")
4604            })
4605        })
4606        .collect::<Result<Vec<_>, _>>()
4607        .map(Some)
4608}
4609
4610struct SessionDefinitionInput<'a> {
4611    manifest_name: &'a str,
4612    live_stacks: &'a [TypeScriptAliasedStackOutput],
4613    promoted_programs: &'a [(String, String, String)],
4614    program_collection: Option<&'a TypeScriptProgramCollectionOutput>,
4615    live_module_imports: &'a BTreeMap<String, String>,
4616    program_module_imports: &'a BTreeMap<String, String>,
4617    gateway: Option<&'a serde_json::Value>,
4618    /// Session program key -> program SDK entry module import.
4619    program_entries: &'a BTreeMap<String, String>,
4620    /// Live alias -> `(bundled program key, ProgramSpec hash)` replaced by
4621    /// the program's entry module.
4622    stack_program_overrides: &'a BTreeMap<String, Vec<(String, String)>>,
4623    extensions: Option<&'a TypeScriptCompositionExtensions>,
4624}
4625
4626fn program_entry_binding(key: &str) -> String {
4627    format!("{}ProgramSdk", safe_pascal_identifier(key))
4628}
4629
4630fn generate_session_definition(input: SessionDefinitionInput<'_>) -> String {
4631    let SessionDefinitionInput {
4632        manifest_name,
4633        live_stacks,
4634        promoted_programs,
4635        program_collection,
4636        live_module_imports,
4637        program_module_imports,
4638        gateway,
4639        program_entries,
4640        stack_program_overrides,
4641        extensions,
4642    } = input;
4643    let manifest_pascal = safe_pascal_identifier(manifest_name);
4644    let manifest_screaming =
4645        ts_ident::identifier_stem(&manifest_pascal, IdentifierCase::ScreamingSnake);
4646    let definition_name = format!("{manifest_screaming}_SESSION_DEFINITION");
4647    // With a stack-level extension the generated definition is the extension
4648    // base, and the exported definition applies the extension to it.
4649    let base_name = if extensions.is_some() {
4650        format!("{definition_name}_CORE")
4651    } else {
4652        definition_name.clone()
4653    };
4654    let imports = live_stacks
4655        .iter()
4656        .map(|live| {
4657            let import = live_module_imports
4658                .get(&live.alias)
4659                .cloned()
4660                .unwrap_or_else(|| format!("./{}.js", live.module_name));
4661            format!(
4662                "import {}Stack from '{}';",
4663                safe_pascal_identifier(&live.alias),
4664                import
4665            )
4666        })
4667        .collect::<Vec<_>>()
4668        .join("\n");
4669    let program_import = program_collection
4670        .map(|programs| {
4671            format!(
4672                "import {manifest_pascal}Programs from './{}.js';",
4673                programs.module_name
4674            )
4675        })
4676        .unwrap_or_default();
4677    let program_module_import_lines =
4678        program_module_imports
4679            .iter()
4680            .map(|(alias, import)| {
4681                format!(
4682                    "import {}Program from '{}';",
4683                    safe_pascal_identifier(alias),
4684                    import
4685                )
4686            })
4687            .chain(program_entries.iter().map(|(key, import)| {
4688                format!("import {} from '{import}';", program_entry_binding(key))
4689            }))
4690            .chain(extensions.map(|extensions| {
4691                let names = extensions
4692                    .stack_bindings
4693                    .iter()
4694                    .chain(&extensions.program_bindings)
4695                    .map(|(_, export)| export.as_str())
4696                    .collect::<BTreeSet<_>>()
4697                    .into_iter()
4698                    .collect::<Vec<_>>()
4699                    .join(", ");
4700                format!("import {{ {names} }} from '{}';", extensions.import_path)
4701            }))
4702            .collect::<Vec<_>>()
4703            .join("\n");
4704    let program_members = program_collection
4705        .map(|programs| {
4706            let definitions = programs
4707                .members
4708                .iter()
4709                .map(|(public, source)| {
4710                    let value = if program_module_imports.contains_key(public) {
4711                        format!("{}Program", safe_pascal_identifier(public))
4712                    } else if program_entries.contains_key(public) {
4713                        program_entry_binding(public)
4714                    } else {
4715                        format!(
4716                            "{manifest_pascal}Programs.programs.{}",
4717                            typescript_property_key(source)
4718                        )
4719                    };
4720                    format!("    {}: {value},", typescript_property_key(public))
4721                })
4722                .collect::<Vec<_>>()
4723                .join("\n");
4724            let reads = programs
4725                .members
4726                .iter()
4727                .map(|(public, source)| {
4728                    format!(
4729                        "    {}: {manifest_pascal}Programs.programReads.{},",
4730                        typescript_property_key(public),
4731                        typescript_property_key(source)
4732                    )
4733                })
4734                .collect::<Vec<_>>()
4735                .join("\n");
4736            (definitions, reads)
4737        })
4738        .unwrap_or_default();
4739    let promoted_definitions = promoted_programs
4740        .iter()
4741        .map(|(public, live_alias, source)| {
4742            let value = if program_entries.contains_key(public) {
4743                program_entry_binding(public)
4744            } else {
4745                format!(
4746                    "{}Stack.programs.{}",
4747                    safe_pascal_identifier(live_alias),
4748                    typescript_property_key(source)
4749                )
4750            };
4751            format!("    {}: {value},", typescript_property_key(public))
4752        })
4753        .collect::<Vec<_>>()
4754        .join("\n");
4755    let promoted_reads = promoted_programs
4756        .iter()
4757        .map(|(public, live_alias, source)| {
4758            format!(
4759                "    {}: {}Stack.programReads.{},",
4760                typescript_property_key(public),
4761                safe_pascal_identifier(live_alias),
4762                typescript_property_key(source)
4763            )
4764        })
4765        .collect::<Vec<_>>()
4766        .join("\n");
4767    let definitions = [promoted_definitions, program_members.0]
4768        .into_iter()
4769        .filter(|members| !members.is_empty())
4770        .collect::<Vec<_>>()
4771        .join("\n");
4772    let reads = [promoted_reads, program_members.1]
4773        .into_iter()
4774        .filter(|members| !members.is_empty())
4775        .collect::<Vec<_>>()
4776        .join("\n");
4777    let program_members =
4778        format!("  programs: {{\n{definitions}\n  }},\n  programReads: {{\n{reads}\n  }},");
4779    let members = live_stacks
4780        .iter()
4781        .map(|live| {
4782            let stack = format!("{}Stack", safe_pascal_identifier(&live.alias));
4783            let value = match stack_program_overrides.get(&live.alias) {
4784                Some(overrides) if !overrides.is_empty() => {
4785                    let programs = overrides
4786                        .iter()
4787                        .map(|(key, _)| {
4788                            format!(
4789                                "{}: {}",
4790                                typescript_property_key(key),
4791                                program_entry_binding(key)
4792                            )
4793                        })
4794                        .collect::<Vec<_>>()
4795                        .join(", ");
4796                    format!("{{ ...{stack}, programs: {{ ...{stack}.programs, {programs} }} }}")
4797                }
4798                _ => stack,
4799            };
4800            format!("    {}: {value},", typescript_property_key(&live.alias))
4801        })
4802        .collect::<Vec<_>>()
4803        .join("\n");
4804    let gateway_member = gateway
4805        .map(|gateway| format!("  gateway: {},", gateway))
4806        .unwrap_or_default();
4807    let mut sdk_imports = vec!["createSession"];
4808    let extended_definition = extensions
4809        .map(|extensions| {
4810            let mut layers = Vec::new();
4811            if !extensions.stack_bindings.is_empty() {
4812                sdk_imports.push("extendStack");
4813                let stacks = extensions
4814                    .stack_bindings
4815                    .iter()
4816                    .map(|(alias, export)| {
4817                        format!(
4818                            "    {}: extendStack({base_name}.stacks{}, {export}),",
4819                            typescript_property_key(alias),
4820                            typescript_member_access(alias)
4821                        )
4822                    })
4823                    .collect::<Vec<_>>()
4824                    .join("\n");
4825                layers.push(format!(
4826                    "  stacks: {{\n    ...{base_name}.stacks,\n{stacks}\n  }},"
4827                ));
4828            }
4829            if !extensions.program_bindings.is_empty() {
4830                sdk_imports.push("extendPrograms");
4831                let programs = extensions
4832                    .program_bindings
4833                    .iter()
4834                    .map(|(key, export)| format!("    {}: {export},", typescript_property_key(key)))
4835                    .collect::<Vec<_>>()
4836                    .join("\n");
4837                layers.push(format!(
4838                    "  programs: extendPrograms({base_name}.programs, {{\n{programs}\n  }}),"
4839                ));
4840            }
4841            format!(
4842                "\nexport const {definition_name} = {{\n  ...{base_name},\n{}\n}} as const;\n",
4843                layers.join("\n")
4844            )
4845        })
4846        .unwrap_or_default();
4847    sdk_imports.sort_unstable();
4848    format!(
4849        r#"import {{ {sdk_imports}, type CompositionSessionOptions }} from '@usearete/sdk';
4850{imports}
4851{program_import}
4852{program_module_import_lines}
4853
4854export const {base_name} = {{
4855  mode: 'composition',
4856{gateway_member}
4857  stacks: {{
4858{members}
4859  }},
4860{program_members}
4861}} as const;
4862{extended_definition}
4863export type {manifest_pascal}SessionDefinition = typeof {definition_name};
4864export const {manifest_screaming}_SDK = {definition_name};
4865export type {manifest_pascal}Sdk = {manifest_pascal}SessionDefinition;
4866
4867export function create{manifest_pascal}Session(
4868  options: CompositionSessionOptions<{manifest_pascal}SessionDefinition>
4869) {{
4870  return createSession({definition_name}, options);
4871}}
4872"#,
4873        sdk_imports = sdk_imports.join(", "),
4874        imports = imports,
4875        program_import = program_import,
4876        program_module_import_lines = program_module_import_lines,
4877        definition_name = definition_name,
4878        base_name = base_name,
4879        gateway_member = gateway_member,
4880        members = members,
4881        program_members = program_members,
4882        extended_definition = extended_definition,
4883        manifest_pascal = manifest_pascal,
4884        manifest_screaming = manifest_screaming,
4885    )
4886}
4887
4888/// PascalCase stem for composition manifest names and aliases; a name without
4889/// any letter or digit falls back to `Manifest`.
4890pub fn safe_pascal_identifier(value: &str) -> String {
4891    if !value
4892        .chars()
4893        .any(|character| character.is_ascii_alphanumeric())
4894    {
4895        return "Manifest".to_string();
4896    }
4897    ts_ident::identifier_stem(value, IdentifierCase::Pascal)
4898}
4899
4900fn typescript_module_name(alias: &str) -> String {
4901    let module = alias
4902        .chars()
4903        .map(|character| {
4904            if character.is_ascii_alphanumeric() {
4905                character.to_ascii_lowercase()
4906            } else {
4907                '-'
4908            }
4909        })
4910        .collect::<String>();
4911    format!("{module}-stack")
4912}
4913
4914fn composition_program_key(
4915    program: &arete_artifacts::ProgramSpecArtifact,
4916    generated_key: &str,
4917) -> String {
4918    match program.payload.program_id.as_str() {
4919        "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" => "splToken".to_string(),
4920        "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" => "splAta".to_string(),
4921        _ => generated_key.to_string(),
4922    }
4923}
4924
4925/// Assemble the `zod` + `@usearete/sdk` import lines based on which runtime
4926/// helpers the emitted code references.
4927fn assemble_sdk_imports(
4928    pda_imports: PdaImportUsage,
4929    has_account_reads: bool,
4930    instructions_codegen: &crate::typescript_instructions::InstructionsCodegen,
4931) -> String {
4932    let mut sdk_named: Vec<String> = Vec::new();
4933    for (needed, helper) in [
4934        (pda_imports.pda, "pda"),
4935        (pda_imports.literal, "literal"),
4936        (pda_imports.account, "account"),
4937        (pda_imports.arg, "arg"),
4938        (pda_imports.bytes, "bytes"),
4939    ] {
4940        if needed {
4941            sdk_named.push(helper.to_string());
4942        }
4943    }
4944    if has_account_reads {
4945        sdk_named.push("programAccountRead".to_string());
4946    }
4947    if instructions_codegen.needs_runtime_import {
4948        sdk_named.push("createInstructionHandler".to_string());
4949        sdk_named.push("type ErrorMetadata".to_string());
4950    }
4951    if !instructions_codegen.stack_entries.is_empty() {
4952        sdk_named.push("buildInstruction".to_string());
4953    }
4954    if instructions_codegen.needs_build_options {
4955        sdk_named.push("type BuildOptions".to_string());
4956    }
4957    if instructions_codegen.needs_program_runtime_extensions {
4958        sdk_named.push("PROGRAM_OPERATION_EXTENSIONS".to_string());
4959        sdk_named.push("instructionOperation".to_string());
4960        sdk_named.push("createPreparedInstruction".to_string());
4961    }
4962    if instructions_codegen.needs_operation_context {
4963        sdk_named.push("type ProgramOperationContext".to_string());
4964    }
4965    if instructions_codegen.needs_amount_input {
4966        sdk_named.push("type AmountInput".to_string());
4967    }
4968    if instructions_codegen.needs_resolve_amount_to_raw {
4969        sdk_named.push("resolveAmountToRaw".to_string());
4970    }
4971    if instructions_codegen.needs_to_raw_amount {
4972        sdk_named.push("toRawAmount".to_string());
4973    }
4974    if sdk_named.is_empty() {
4975        "import { z } from 'zod';".to_string()
4976    } else {
4977        format!(
4978            "import {{ z }} from 'zod';\nimport {{ {} }} from '@usearete/sdk';",
4979            sdk_named.join(", ")
4980        )
4981    }
4982}
4983
4984#[derive(Debug, Clone, Copy, Default)]
4985struct PdaImportUsage {
4986    pda: bool,
4987    literal: bool,
4988    account: bool,
4989    arg: bool,
4990    bytes: bool,
4991}
4992
4993fn collect_emitted_pda_imports(
4994    idls: &[IdlSnapshot],
4995    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4996) -> PdaImportUsage {
4997    let mut usage = PdaImportUsage::default();
4998
4999    for idl in idls {
5000        let Some(program_pdas) = pdas
5001            .get(&idl.name)
5002            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
5003            .filter(|program_pdas| !program_pdas.is_empty())
5004        else {
5005            continue;
5006        };
5007
5008        usage.pda = true;
5009        for seed in program_pdas.values().flat_map(|pda| &pda.seeds) {
5010            match seed {
5011                PdaSeedDef::Literal { .. } => usage.literal = true,
5012                PdaSeedDef::AccountRef { .. } => usage.account = true,
5013                PdaSeedDef::ArgRef { .. } => usage.arg = true,
5014                PdaSeedDef::Bytes { .. } => usage.bytes = true,
5015            }
5016        }
5017    }
5018
5019    usage
5020}
5021
5022/// Compile only the program-SDK surface of a stack spec — account types +
5023/// Zod schemas, instruction handlers, and standalone per-program consts.
5024/// No entities, views, or stack const are emitted, and the spec's `entities`
5025/// may be empty. Used by `a4 sdk create --ts --program-only`.
5026pub fn compile_program_modules(
5027    stack_spec: SerializableStackSpec,
5028    config: Option<TypeScriptStackConfig>,
5029) -> Result<TypeScriptStackOutput, String> {
5030    let config = config.unwrap_or_default();
5031    let program_configs = resolve_program_configs(&stack_spec, config.programs.as_deref(), false)?;
5032    let stack_name = &stack_spec.stack_name;
5033
5034    if stack_spec.idls.is_empty() {
5035        return Err(format!(
5036            "Stack '{}' carries no IDLs; a program-only SDK has nothing to emit",
5037            stack_name
5038        ));
5039    }
5040
5041    let mut reserved_type_names: std::collections::HashSet<String> =
5042        std::collections::HashSet::new();
5043    let idl_account_artifacts =
5044        generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
5045    for type_name in &idl_account_artifacts.type_names {
5046        reserved_type_names.insert(type_name.clone());
5047    }
5048
5049    let mut interfaces = idl_account_artifacts.code.clone();
5050
5051    let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
5052        stack_name,
5053        &stack_spec.instructions,
5054        &stack_spec.idls,
5055        &stack_spec.pdas,
5056        &stack_spec.program_ids,
5057        &reserved_type_names,
5058    );
5059    if !instructions_codegen.code.is_empty() {
5060        if interfaces.is_empty() {
5061            interfaces = instructions_codegen.code.clone();
5062        } else {
5063            interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
5064        }
5065    }
5066
5067    let unique_schemas: BTreeSet<String> =
5068        idl_account_artifacts.schema_names.iter().cloned().collect();
5069
5070    let imports = assemble_sdk_imports(
5071        collect_emitted_pda_imports(&stack_spec.idls, &stack_spec.pdas),
5072        !idl_account_artifacts.account_type_names.is_empty(),
5073        &instructions_codegen,
5074    );
5075
5076    let program_context = ProgramGenerationContext {
5077        pdas: &stack_spec.pdas,
5078        program_ids: &stack_spec.program_ids,
5079        instruction_entries: &instructions_codegen.stack_entries,
5080        schema_names: &unique_schemas,
5081        account_type_names: &idl_account_artifacts.account_type_names,
5082        programs: &program_configs,
5083    };
5084    let program_configs =
5085        program_content_identities(&program_context, &stack_spec.idls, &imports, &interfaces)?;
5086    let program_context = ProgramGenerationContext {
5087        programs: &program_configs,
5088        ..program_context
5089    };
5090    let stack_definition =
5091        generate_program_definitions(stack_name, &stack_spec.idls, &program_context);
5092
5093    let output = TypeScriptStackOutput {
5094        imports,
5095        interfaces,
5096        stack_definition,
5097        warnings: instructions_codegen.warnings,
5098        pda_degradations: instructions_codegen.pda_degradations,
5099    };
5100    ts_ident::check_module_declarations(
5101        &output.full_file(),
5102        &format!("The TypeScript program SDK for '{stack_name}'"),
5103    )?;
5104    Ok(output)
5105}
5106
5107/// Compile standalone program SDK modules directly from ProgramSpec artifacts.
5108pub fn compile_program_artifacts(
5109    name: impl Into<String>,
5110    programs: &[arete_artifacts::ProgramSpecArtifact],
5111    config: Option<TypeScriptStackConfig>,
5112) -> Result<TypeScriptStackOutput, String> {
5113    let stack_spec = crate::public_artifacts::stack_spec_from_program_artifacts(name, programs)?;
5114    compile_program_modules(stack_spec, config)
5115}
5116
5117/// Write stack-level TypeScript output to a file
5118pub fn write_stack_typescript_to_file(
5119    output: &TypeScriptStackOutput,
5120    path: &std::path::Path,
5121) -> Result<(), std::io::Error> {
5122    std::fs::write(path, output.full_file())
5123}
5124
5125/// Generate a unified stack definition for multiple entities.
5126///
5127/// Produces something like:
5128/// ```typescript
5129/// export const ORE_STACK = {
5130///   name: 'ore',
5131///   url: 'wss://ore.stack.arete.run',
5132///   views: {
5133///     OreRound: {
5134///       state: stateView<OreRound>('OreRound/state'),
5135///       list: listView<OreRound>('OreRound/list'),
5136///       latest: listView<OreRound>('OreRound/latest'),
5137///     },
5138///     OreTreasury: {
5139///       state: stateView<OreTreasury>('OreTreasury/state'),
5140///     },
5141///     OreMiner: {
5142///       state: stateView<OreMiner>('OreMiner/state'),
5143///       list: listView<OreMiner>('OreMiner/list'),
5144///     },
5145///   },
5146/// } as const;
5147/// ```
5148#[allow(clippy::too_many_arguments)]
5149fn generate_stack_definition_multi(
5150    stack_name: &str,
5151    stack_kebab: &str,
5152    entities: &[SerializableStreamSpec],
5153    entity_names: &[String],
5154    idls: &[IdlSnapshot],
5155    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
5156    program_ids: &[String],
5157    schema_names: &[String],
5158    account_type_names: &BTreeMap<(String, String), String>,
5159    instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
5160    program_configs: &[TypeScriptProgramConfig],
5161    config: &TypeScriptStackConfig,
5162    exact_views: bool,
5163) -> Result<String, String> {
5164    let export_name = format!(
5165        "{}_{}",
5166        ts_ident::identifier_stem(stack_name, IdentifierCase::ScreamingSnake),
5167        config.export_const_name
5168    );
5169    let core_export_name = format!("{}_CORE", export_name);
5170    let stack_type_prefix = ts_ident::stack_type_stem(stack_name);
5171
5172    let view_helpers = generate_view_helpers_static();
5173
5174    let websocket_endpoint = match &config.websocket_url {
5175        Some(url) => format!("    ws: '{}',", url),
5176        None => "    ws: '', // TODO: Set after first deployment or pass useArete(..., { url })"
5177            .to_string(),
5178    };
5179    let http_endpoint = match &config.http_url {
5180        Some(url) => format!("    http: '{}',", url),
5181        None => {
5182            "    http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })"
5183                .to_string()
5184        }
5185    };
5186    let endpoints_block = format!(
5187        "  endpoints: {{\n{}\n{}\n  }},",
5188        websocket_endpoint, http_endpoint
5189    );
5190    let release_block = config
5191        .release
5192        .as_ref()
5193        .map(|release| {
5194            format!(
5195                "\n  release: {{\n    stackManifestHash: {},\n    liveAlias: {},\n  }},",
5196                ts_ident::single_quoted(&release.stack_manifest_hash),
5197                ts_ident::single_quoted(&release.live_alias),
5198            )
5199        })
5200        .unwrap_or_default();
5201    let gateway_block = config
5202        .gateway
5203        .as_ref()
5204        .map(|gateway| {
5205            format!(
5206                "\n  gateway: {},",
5207                serde_json::to_string(gateway).expect("gateway descriptor must serialize")
5208            )
5209        })
5210        .unwrap_or_default();
5211
5212    // Generate views block for each entity
5213    let mut entity_view_blocks = Vec::new();
5214    for (i, entity_spec) in entities.iter().enumerate() {
5215        let entity_name = &entity_names[i];
5216        let entity_pascal = to_pascal_case(entity_name);
5217        let mut view_entries = Vec::new();
5218
5219        if !exact_views
5220            || entity_spec
5221                .views
5222                .iter()
5223                .any(|view| view.id == format!("{entity_name}/state"))
5224        {
5225            let state_view_key = state_view_key_definition(
5226                entity_name,
5227                &entity_spec.identity,
5228                &entity_spec.field_mappings,
5229                &entity_spec.sections,
5230            )?;
5231            view_entries.push(format!(
5232                "      state: stateView<{entity}, {key_type}>('{entity_name}/state', {key_fields}),",
5233                entity = entity_pascal,
5234                entity_name = entity_name,
5235                key_type = state_view_key.object_type(),
5236                key_fields = state_view_key.fields_literal(),
5237            ));
5238        }
5239
5240        if !exact_views
5241            || entity_spec
5242                .views
5243                .iter()
5244                .any(|view| view.id == format!("{entity_name}/list"))
5245        {
5246            view_entries.push(format!(
5247                "      list: listView<{entity}>('{entity_name}/list'),",
5248                entity = entity_pascal,
5249                entity_name = entity_name
5250            ));
5251        }
5252
5253        for view in &entity_spec.views {
5254            if !view.id.ends_with("/state")
5255                && !view.id.ends_with("/list")
5256                && view.id.starts_with(entity_name)
5257            {
5258                let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
5259                view_entries.push(format!(
5260                    "      {}: listView<{entity}>('{}'),",
5261                    typescript_property_key(view_name),
5262                    view.id,
5263                    entity = entity_pascal
5264                ));
5265            }
5266        }
5267
5268        if !view_entries.is_empty() {
5269            entity_view_blocks.push(format!(
5270                "    {}: {{\n{}\n    }},",
5271                typescript_property_key(entity_name),
5272                view_entries.join("\n")
5273            ));
5274        }
5275    }
5276
5277    let views_body = entity_view_blocks.join("\n");
5278
5279    let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
5280    for name in schema_names {
5281        unique_schemas.insert(name.clone());
5282    }
5283    let schemas_block = if unique_schemas.is_empty() {
5284        String::new()
5285    } else {
5286        let schema_entries: Vec<String> = unique_schemas
5287            .iter()
5288            .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
5289            .map(|name| format!("    {}: {},", name.trim_end_matches("Schema"), name))
5290            .collect();
5291        if schema_entries.is_empty() {
5292            String::new()
5293        } else {
5294            format!("\n  schemas: {{\n{}\n  }},", schema_entries.join("\n"))
5295        }
5296    };
5297    let patch_schema_entries: Vec<String> = entity_names
5298        .iter()
5299        .map(|entity_name| {
5300            let entity_pascal = to_pascal_case(entity_name);
5301            format!("    {}: {}PatchSchema,", entity_pascal, entity_pascal)
5302        })
5303        .collect();
5304    let patch_schemas_block = if patch_schema_entries.is_empty() {
5305        String::new()
5306    } else {
5307        format!(
5308            "\n  patchSchemas: {{\n{}\n  }},",
5309            patch_schema_entries.join("\n")
5310        )
5311    };
5312
5313    let program_context = ProgramGenerationContext {
5314        pdas,
5315        program_ids,
5316        instruction_entries,
5317        schema_names: &unique_schemas,
5318        account_type_names,
5319        programs: program_configs,
5320    };
5321    let programs_block = generate_programs_block(idls, &program_context);
5322    let program_reads_block = generate_program_reads_block(idls, &program_context);
5323    let addresses_block = generate_stack_addresses_block(idls, pdas, program_ids);
5324
5325    let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
5326
5327    let stack_export = format!(
5328        r#"export const {core_export_name} = {{
5329  name: {stack_kebab},
5330{endpoints_block}{release_block}{gateway_block}
5331  views: {{
5332{views_body}
5333  }},{schemas_section}{patch_schemas_section}{programs_section}{program_reads_section}{addresses_section}
5334}} as const;"#,
5335        core_export_name = core_export_name,
5336        stack_kebab = ts_ident::single_quoted(stack_kebab),
5337        endpoints_block = endpoints_block,
5338        release_block = release_block,
5339        gateway_block = gateway_block,
5340        views_body = views_body,
5341        schemas_section = schemas_block,
5342        patch_schemas_section = patch_schemas_block,
5343        programs_section = programs_block,
5344        program_reads_section = program_reads_block,
5345        addresses_section = addresses_block,
5346    );
5347
5348    Ok(format!(
5349        r#"{view_helpers}
5350
5351// ============================================================================
5352// Stack Definition
5353// ============================================================================
5354
5355/** Stack definition for {stack_comment} with {entity_count} entities */
5356{stack_export}
5357
5358/** Type alias for the core stack */
5359export type {stack_type_prefix}CoreStack = typeof {core_export_name};
5360
5361/** Entity types in this stack */
5362export type {stack_type_prefix}Entity = {entity_union};
5363
5364/** Default export for convenience */
5365export default {core_export_name};"#,
5366        view_helpers = view_helpers,
5367        stack_comment = ts_ident::comment_text(stack_name),
5368        stack_type_prefix = stack_type_prefix,
5369        entity_count = entities.len(),
5370        core_export_name = core_export_name,
5371        stack_export = stack_export,
5372        entity_union = if entity_types.is_empty() {
5373            "never".to_string()
5374        } else {
5375            entity_types.join(" | ")
5376        },
5377    ))
5378}
5379
5380/// Render `value` as an object property key, quoting it when it is not an
5381/// identifier.
5382pub fn typescript_property_key(value: &str) -> String {
5383    let mut characters = value.chars();
5384    let valid = characters
5385        .next()
5386        .is_some_and(|character| character.is_ascii_alphabetic() || matches!(character, '_' | '$'))
5387        && characters
5388            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '$'));
5389    if valid {
5390        value.to_string()
5391    } else {
5392        serde_json::to_string(value).expect("string property serialization cannot fail")
5393    }
5394}
5395
5396struct ProgramGenerationContext<'a> {
5397    pdas: &'a BTreeMap<String, BTreeMap<String, PdaDefinition>>,
5398    program_ids: &'a [String],
5399    instruction_entries: &'a [crate::typescript_instructions::StackInstructionEntry],
5400    schema_names: &'a BTreeSet<String>,
5401    account_type_names: &'a BTreeMap<(String, String), String>,
5402    programs: &'a [TypeScriptProgramConfig],
5403}
5404
5405/// Frozen content projection for the TypeScript program runtime contract.
5406/// Includes the emitted imports, shared declarations (schemas and instruction
5407/// implementations), and this program's literal body with sdkDefinitionHash
5408/// absent. Release/read bindings and stack wrappers are separate identities.
5409/// Shared declarations deliberately make this conservative within one module.
5410const TYPESCRIPT_PROGRAM_RUNTIME_CONTRACT: &str = "@usearete/sdk/program-definition-v1";
5411
5412fn program_content_identities(
5413    context: &ProgramGenerationContext<'_>,
5414    idls: &[IdlSnapshot],
5415    imports: &str,
5416    declarations: &str,
5417) -> Result<Vec<TypeScriptProgramConfig>, String> {
5418    use arete_hash::{hash_artifact_tree, ArtifactTreeEntry, SdkDefinitionV2, SdkOutputTree};
5419
5420    let mut unhashed = context.programs.to_vec();
5421    for program in &mut unhashed {
5422        program.definition.sdk_definition_hash = None;
5423    }
5424    let unhashed_context = ProgramGenerationContext {
5425        programs: &unhashed,
5426        ..*context
5427    };
5428    let mut identified = context.programs.to_vec();
5429    for (index, program) in identified.iter_mut().enumerate() {
5430        let (_, sections) =
5431            generate_single_program_sections(&idls[index], index, &unhashed_context);
5432        let definition = sections.join("\n");
5433        let output_tree_hash = hash_artifact_tree::<SdkOutputTree>(&[
5434            ArtifactTreeEntry::file("imports.ts", imports.as_bytes()),
5435            ArtifactTreeEntry::file("declarations.ts", declarations.as_bytes()),
5436            ArtifactTreeEntry::file("definition.ts", definition.as_bytes()),
5437        ])
5438        .map_err(|error| error.to_string())?;
5439        let input_hash = program
5440            .definition
5441            .program_spec_hash
5442            .parse()
5443            .map_err(|error: arete_hash::HashError| error.to_string())?;
5444        program.definition.sdk_definition_hash = Some(
5445            SdkDefinitionV2::new(
5446                input_hash,
5447                "typescript",
5448                TYPESCRIPT_PROGRAM_RUNTIME_CONTRACT,
5449                output_tree_hash,
5450            )
5451            .hash()
5452            .map_err(|error| error.to_string())?
5453            .to_string(),
5454        );
5455    }
5456    Ok(identified)
5457}
5458
5459/// Build one program's `{ name, programId, pdas?, accounts?, instructions? }`
5460/// literal body. Sections are indented for nesting inside a stack const
5461/// (`programs: { <key>: { ... } }`); callers emitting top-level program
5462/// consts dedent them.
5463fn generate_single_program_sections(
5464    idl: &IdlSnapshot,
5465    index: usize,
5466    context: &ProgramGenerationContext<'_>,
5467) -> (String, Vec<String>) {
5468    let program_key = to_camel_case(&idl.name);
5469    let metadata = &context.programs[index];
5470    let multi_program = context.program_ids.len() > 1
5471        || context
5472            .instruction_entries
5473            .iter()
5474            .any(|entry| entry.program_key.is_some());
5475    let program_id = context
5476        .program_ids
5477        .get(index)
5478        .cloned()
5479        .or_else(|| idl.program_id.clone())
5480        .unwrap_or_default();
5481
5482    let instruction_entries_for_program: Vec<
5483        &crate::typescript_instructions::StackInstructionEntry,
5484    > = context
5485        .instruction_entries
5486        .iter()
5487        .filter(|entry| {
5488            if multi_program {
5489                entry.program_key.as_deref() == Some(program_key.as_str())
5490            } else {
5491                true
5492            }
5493        })
5494        .collect();
5495    let instruction_entry_literals: Vec<String> = instruction_entries_for_program
5496        .iter()
5497        .map(|entry| {
5498            format!(
5499                "        {}: {},",
5500                entry.instruction_name, entry.handler_const
5501            )
5502        })
5503        .collect();
5504
5505    let account_entries: Vec<String> = idl
5506        .accounts
5507        .iter()
5508        .filter_map(|account| {
5509            let type_name = context
5510                .account_type_names
5511                .get(&(program_key.clone(), account.name.clone()))?
5512                .clone();
5513            let schema_name = format!("{}Schema", type_name);
5514            if !context.schema_names.contains(&schema_name) {
5515                return None;
5516            }
5517            Some((account.name.clone(), type_name, schema_name))
5518        })
5519        .map(|account| {
5520            format!(
5521                "        {account_name}: programAccountRead<{type_name}>({{ account: '{account_name}', schema: {schema_name} }}),",
5522                account_name = account.0,
5523                type_name = account.1,
5524                schema_name = account.2,
5525            )
5526        })
5527        .collect();
5528
5529    let program_pdas = context
5530        .pdas
5531        .get(&idl.name)
5532        .or_else(|| context.pdas.get(&program_key))
5533        .filter(|program_pdas| !program_pdas.is_empty());
5534
5535    let mut sections: Vec<String> = vec![
5536        format!("      name: '{}',", idl.name),
5537        format!("      programId: '{}',", program_id),
5538    ];
5539    if let Some(definition_hash) = &metadata.definition.sdk_definition_hash {
5540        sections.push(format!("      sdkDefinitionHash: '{}',", definition_hash));
5541    }
5542    sections.extend([
5543        format!(
5544            "      programSpecHash: '{}',",
5545            metadata.definition.program_spec_hash
5546        ),
5547        format!(
5548            "      idlContentHash: '{}',",
5549            metadata.definition.idl_content_hash
5550        ),
5551        format!(
5552            "      normalizedIdlHash: '{}',",
5553            metadata.definition.normalized_idl_hash
5554        ),
5555    ]);
5556    if let Some(gateway) = &metadata.gateway {
5557        sections.push(format!(
5558            "      gateway: {},",
5559            serde_json::to_string(gateway).expect("gateway descriptor must serialize")
5560        ));
5561    }
5562
5563    if let Some(program_pdas) = program_pdas {
5564        let pda_entries = generate_program_pda_entries(program_pdas, &program_id, "        ");
5565        if !pda_entries.is_empty() {
5566            sections.push(format!(
5567                "      pdas: {{\n{}\n      }},",
5568                pda_entries.join("\n")
5569            ));
5570            sections.push(format!(
5571                "      addresses: {{\n{}\n      }},",
5572                pda_entries.join("\n")
5573            ));
5574        }
5575    }
5576
5577    if !account_entries.is_empty() {
5578        sections.push(format!(
5579            "      accounts: {{\n{}\n      }},",
5580            account_entries.join("\n")
5581        ));
5582    }
5583
5584    if !instruction_entry_literals.is_empty() {
5585        sections.push(format!(
5586            "      rawInstructions: {{\n{}\n      }},",
5587            instruction_entry_literals.join("\n")
5588        ));
5589        if let Some(semantic_block) =
5590            generate_program_semantic_instructions_block(&instruction_entries_for_program, "      ")
5591        {
5592            sections.push(semantic_block);
5593        }
5594    }
5595
5596    (program_key, sections)
5597}
5598
5599fn generate_programs_block(idls: &[IdlSnapshot], context: &ProgramGenerationContext<'_>) -> String {
5600    if idls.is_empty() || context.programs.is_empty() {
5601        return String::new();
5602    }
5603
5604    let mut program_blocks = Vec::new();
5605
5606    for (index, idl) in idls.iter().enumerate() {
5607        let (program_key, sections) = generate_single_program_sections(idl, index, context);
5608
5609        program_blocks.push(format!(
5610            "    {}: {{\n{}\n    }},",
5611            typescript_property_key(&program_key),
5612            sections.join("\n")
5613        ));
5614    }
5615
5616    if program_blocks.is_empty() {
5617        return String::new();
5618    }
5619
5620    format!("\n  programs: {{\n{}\n  }},", program_blocks.join("\n"))
5621}
5622
5623fn generate_program_read_sections(metadata: &TypeScriptProgramConfig, indent: &str) -> Vec<String> {
5624    let mut sections = vec![format!(
5625        "{indent}release: {{ programReleaseHash: {release_hash}, programSpecHash: {spec_hash} }},",
5626        release_hash = serde_json::to_string(&metadata.release.program_release_hash)
5627            .expect("program release hash must serialize"),
5628        spec_hash = serde_json::to_string(&metadata.release.program_spec_hash)
5629            .expect("program spec hash must serialize"),
5630    )];
5631    sections.push(match &metadata.transport {
5632        TypeScriptProgramReadTransport::LocalHttp => format!(
5633            "{indent}transport: {{ kind: 'local-http', endpointSource: 'connect-http-url' }},"
5634        ),
5635        TypeScriptProgramReadTransport::HostedBinding(binding) => format!(
5636            "{indent}transport: {{ kind: 'hosted-binding', binding: {{ endpoint: {endpoint}, programReadBindingId: {binding_id}, auth: {auth} }} }},",
5637            endpoint = serde_json::to_string(&binding.endpoint)
5638                .expect("program endpoint must serialize"),
5639            binding_id = serde_json::to_string(&binding.program_read_binding_id)
5640                .expect("program binding ID must serialize"),
5641            auth = serde_json::to_string(&binding.auth)
5642                .expect("program auth metadata must serialize"),
5643        ),
5644    });
5645    sections
5646}
5647
5648fn generate_program_reads_block(
5649    idls: &[IdlSnapshot],
5650    context: &ProgramGenerationContext<'_>,
5651) -> String {
5652    if idls.is_empty() || context.programs.is_empty() {
5653        return String::new();
5654    }
5655
5656    let entries = idls
5657        .iter()
5658        .zip(context.programs)
5659        .map(|(idl, metadata)| {
5660            format!(
5661                "    {}: {{\n{}\n    }},",
5662                typescript_property_key(&to_camel_case(&idl.name)),
5663                generate_program_read_sections(metadata, "      ").join("\n")
5664            )
5665        })
5666        .collect::<Vec<_>>();
5667    format!("\n  programReads: {{\n{}\n  }},", entries.join("\n"))
5668}
5669
5670/// Strip up to `spaces` leading spaces from every line.
5671fn dedent_lines(text: &str, spaces: usize) -> String {
5672    text.lines()
5673        .map(|line| {
5674            let strip = line
5675                .char_indices()
5676                .take_while(|(i, c)| *i < spaces && *c == ' ')
5677                .count();
5678            &line[strip..]
5679        })
5680        .collect::<Vec<_>>()
5681        .join("\n")
5682}
5683
5684/// Emit standalone per-program consts plus a combined `<STACK>_PROGRAMS` map:
5685///
5686/// ```typescript
5687/// export const SQUADS_MULTISIG_PROGRAM = { name, programId, pdas, accounts, instructions } as const;
5688/// export const SQUADS_V4_PROGRAMS = { squadsMultisigProgram: SQUADS_MULTISIG_PROGRAM } as const;
5689/// ```
5690///
5691/// Each const structurally satisfies the runtime's `ProgramSdkDefinition`, so
5692/// the map can be dropped straight into `createSession({ programs: ... })`.
5693fn generate_program_definitions(
5694    stack_name: &str,
5695    idls: &[IdlSnapshot],
5696    context: &ProgramGenerationContext<'_>,
5697) -> String {
5698    let mut program_consts = Vec::new();
5699    let mut map_entries = Vec::new();
5700    let mut program_read_consts = Vec::new();
5701    let mut read_map_entries = Vec::new();
5702
5703    for (index, idl) in idls.iter().enumerate() {
5704        let (program_key, sections) = generate_single_program_sections(idl, index, context);
5705        let const_name = program_const_name(&idl.name);
5706        let program_key = typescript_property_key(&program_key);
5707        let body = dedent_lines(&sections.join("\n"), 4);
5708        program_consts.push(format!(
5709            "/** Standalone program SDK for '{name}' */\nexport const {const_name} = {{\n{body}\n}} as const;",
5710            name = ts_ident::comment_text(&idl.name),
5711            const_name = const_name,
5712            body = body,
5713        ));
5714        map_entries.push(format!("  {}: {},", program_key, const_name));
5715        let read_const_name = format!("{}_READ", const_name);
5716        let read_body = dedent_lines(
5717            &generate_program_read_sections(&context.programs[index], "    ").join("\n"),
5718            4,
5719        );
5720        program_read_consts.push(format!(
5721            "/** Release and explicit read transport for '{name}' */\nexport const {read_const_name} = {{\n{read_body}\n}} as const;",
5722            name = ts_ident::comment_text(&idl.name),
5723        ));
5724        read_map_entries.push(format!("  {}: {},", program_key, read_const_name));
5725    }
5726
5727    let stack_screaming = ts_ident::identifier_stem(stack_name, IdentifierCase::ScreamingSnake);
5728    let map_name = format!("{stack_screaming}_PROGRAMS");
5729    let reads_map_name = format!("{stack_screaming}_PROGRAM_READS");
5730    let type_name = format!(
5731        "{}Programs",
5732        ts_ident::identifier_stem(stack_name, IdentifierCase::Pascal)
5733    );
5734
5735    format!(
5736        r#"// ============================================================================
5737// Program Definitions
5738// ============================================================================
5739
5740{program_consts}
5741
5742{program_read_consts}
5743
5744/** All portable programs from the {stack_comment} stack */
5745export const {map_name} = {{
5746{map_entries}
5747}} as const;
5748
5749/** Parallel release/read metadata keyed identically to {map_name} */
5750export const {reads_map_name} = {{
5751{read_map_entries}
5752}} as const;
5753
5754export type {type_name} = typeof {map_name};
5755
5756export default {map_name};"#,
5757        program_consts = program_consts.join("\n\n"),
5758        program_read_consts = program_read_consts.join("\n\n"),
5759        stack_comment = ts_ident::comment_text(stack_name),
5760        map_name = map_name,
5761        map_entries = map_entries.join("\n"),
5762        reads_map_name = reads_map_name,
5763        read_map_entries = read_map_entries.join("\n"),
5764        type_name = type_name,
5765    )
5766}
5767
5768fn generate_program_pda_entries(
5769    program_pdas: &BTreeMap<String, PdaDefinition>,
5770    default_program_id: &str,
5771    indent: &str,
5772) -> Vec<String> {
5773    if program_pdas.is_empty() {
5774        return Vec::new();
5775    }
5776
5777    program_pdas
5778        .iter()
5779        .map(|(pda_name, pda_def)| {
5780            let seeds_str = pda_def
5781                .seeds
5782                .iter()
5783                .map(|seed| match seed {
5784                    PdaSeedDef::Literal { value } => format!("literal('{}')", value),
5785                    PdaSeedDef::AccountRef { account_name } => {
5786                        format!("account('{}')", account_name)
5787                    }
5788                    PdaSeedDef::ArgRef { arg_name, arg_type } => {
5789                        if let Some(t) = arg_type {
5790                            format!("arg('{}', '{}')", arg_name, t)
5791                        } else {
5792                            format!("arg('{}')", arg_name)
5793                        }
5794                    }
5795                    PdaSeedDef::Bytes { value } => {
5796                        let bytes_arr: Vec<String> = value.iter().map(|b| b.to_string()).collect();
5797                        format!("bytes(new Uint8Array([{}]))", bytes_arr.join(", "))
5798                    }
5799                })
5800                .collect::<Vec<_>>()
5801                .join(", ");
5802
5803            let program = match (&pda_def.program_id, &pda_def.program) {
5804                (Some(pid), _) => format!("'{}'", pid),
5805                (None, Some(PdaProgramDef::AccountRef { account_name })) => {
5806                    format!("{{ type: 'accountRef', accountName: '{}' }}", account_name)
5807                }
5808                (None, Some(PdaProgramDef::ArgRef { arg_name })) => {
5809                    format!("{{ type: 'argRef', argName: '{}' }}", arg_name)
5810                }
5811                (None, None) => format!("'{}'", default_program_id),
5812            };
5813            let rendered_seeds = if seeds_str.is_empty() {
5814                String::new()
5815            } else {
5816                format!(", {}", seeds_str)
5817            };
5818            format!(
5819                "{}{}: pda({}{}),",
5820                indent, pda_name, program, rendered_seeds
5821            )
5822        })
5823        .collect()
5824}
5825
5826fn generate_stack_addresses_block(
5827    idls: &[IdlSnapshot],
5828    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
5829    program_ids: &[String],
5830) -> String {
5831    if idls.is_empty() {
5832        return String::new();
5833    }
5834
5835    if idls.len() == 1 {
5836        let idl = &idls[0];
5837        let program_id = program_ids
5838            .first()
5839            .cloned()
5840            .or_else(|| idl.program_id.clone())
5841            .unwrap_or_default();
5842        let Some(program_pdas) = pdas
5843            .get(&idl.name)
5844            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
5845        else {
5846            return String::new();
5847        };
5848        let entries = generate_program_pda_entries(program_pdas, &program_id, "    ");
5849        if entries.is_empty() {
5850            return String::new();
5851        }
5852        return format!("\n  addresses: {{\n{}\n  }},", entries.join("\n"));
5853    }
5854
5855    let mut blocks = Vec::new();
5856    for (index, idl) in idls.iter().enumerate() {
5857        let program_id = program_ids
5858            .get(index)
5859            .cloned()
5860            .or_else(|| idl.program_id.clone())
5861            .unwrap_or_default();
5862        let Some(program_pdas) = pdas
5863            .get(&idl.name)
5864            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
5865        else {
5866            continue;
5867        };
5868        let entries = generate_program_pda_entries(program_pdas, &program_id, "      ");
5869        if entries.is_empty() {
5870            continue;
5871        }
5872        blocks.push(format!(
5873            "    {}: {{\n{}\n    }},",
5874            typescript_property_key(&to_camel_case(&idl.name)),
5875            entries.join("\n")
5876        ));
5877    }
5878
5879    if blocks.is_empty() {
5880        String::new()
5881    } else {
5882        format!("\n  addresses: {{\n{}\n  }},", blocks.join("\n"))
5883    }
5884}
5885
5886fn is_valid_ts_identifier(name: &str) -> bool {
5887    name.chars()
5888        .next()
5889        .map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
5890        .unwrap_or(false)
5891        && name
5892            .chars()
5893            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
5894}
5895
5896fn escape_ts_single_quotes(value: &str) -> String {
5897    value
5898        .replace('\\', "\\\\")
5899        .replace('\'', "\\'")
5900        .replace(['\n', '\r'], " ")
5901}
5902
5903fn render_ts_property_name_literal(name: &str) -> String {
5904    if is_valid_ts_identifier(name) {
5905        name.to_string()
5906    } else {
5907        format!("'{}'", escape_ts_single_quotes(name))
5908    }
5909}
5910
5911fn render_program_semantic_instruction_entry(
5912    entry: &crate::typescript_instructions::StackInstructionEntry,
5913    indent: &str,
5914) -> Option<String> {
5915    let semantic_params_type = entry.semantic_params_type.as_ref()?;
5916    entry.runtime_program_key.as_ref()?;
5917
5918    if entry.semantic_amount_args.is_empty() {
5919        return Some(format!(
5920            "{indent}{instruction_name}: instructionOperation(async (params: {semantic_params_type}) => {{\n{indent}  const instruction = buildInstruction({handler_const}, params as unknown as Record<string, unknown>);\n{indent}  return createPreparedInstruction({{\n{indent}    name: '{instruction_name}',\n{indent}    instruction,\n{indent}    artifacts: {{ instruction }},\n{indent}    errors: {handler_const}.errors,\n{indent}  }});\n{indent}}}),",
5921            indent = indent,
5922            instruction_name = entry.instruction_name,
5923            semantic_params_type = semantic_params_type,
5924            handler_const = entry.handler_const,
5925        ));
5926    }
5927
5928    let raw_params_setup = if entry.semantic_extra_params.is_empty() {
5929        format!(
5930            "{indent}    const {{ build, ...rawParams }} = params;",
5931            indent = indent
5932        )
5933    } else {
5934        format!(
5935            "{indent}    const {{ build, {extras}, ...rawParams }} = params;",
5936            indent = indent,
5937            extras = entry.semantic_extra_params.join(", "),
5938        )
5939    };
5940    let resolution_lines: Vec<String> = entry
5941        .semantic_amount_args
5942        .iter()
5943        .map(|amount_arg| {
5944            format!(
5945                "{indent}    const {binding_name} = {raw_expression};",
5946                indent = indent,
5947                binding_name = amount_arg.binding_name,
5948                raw_expression = amount_arg.raw_expression,
5949            )
5950        })
5951        .collect();
5952    let raw_assignments: Vec<String> = entry
5953        .semantic_amount_args
5954        .iter()
5955        .map(|amount_arg| {
5956            format!(
5957                "{indent}      {}: {},",
5958                render_ts_property_name_literal(&amount_arg.arg_name),
5959                amount_arg.binding_name,
5960                indent = indent,
5961            )
5962        })
5963        .collect();
5964
5965    Some(format!(
5966        "{indent}{instruction_name}: instructionOperation(async (params: {semantic_params_type}) => {{\n{raw_params_setup}\n{resolutions}\n{indent}    const instruction = buildInstruction({handler_const}, {{\n{indent}      ...rawParams,\n{assignments}\n{indent}    }} as unknown as Record<string, unknown>, build);\n{indent}    return createPreparedInstruction({{\n{indent}      name: '{instruction_name}',\n{indent}      instruction,\n{indent}      artifacts: {{ instruction }},\n{indent}      errors: {handler_const}.errors,\n{indent}    }});\n{indent}  }}),",
5967        indent = indent,
5968        instruction_name = entry.instruction_name,
5969        semantic_params_type = semantic_params_type,
5970        raw_params_setup = raw_params_setup,
5971        resolutions = resolution_lines.join("\n"),
5972        handler_const = entry.handler_const,
5973        assignments = raw_assignments.join("\n"),
5974    ))
5975}
5976
5977fn generate_program_semantic_instructions_block(
5978    instruction_entries: &[&crate::typescript_instructions::StackInstructionEntry],
5979    indent: &str,
5980) -> Option<String> {
5981    let entry_indent = format!("{}      ", indent);
5982    let entries: Vec<String> = instruction_entries
5983        .iter()
5984        .filter_map(|entry| render_program_semantic_instruction_entry(entry, &entry_indent))
5985        .collect();
5986    if entries.is_empty() {
5987        return None;
5988    }
5989
5990    let context_param = if instruction_entries
5991        .iter()
5992        .any(|entry| entry.uses_operation_context)
5993    {
5994        "context: ProgramOperationContext"
5995    } else {
5996        ""
5997    };
5998
5999    Some(format!(
6000        "{indent}[PROGRAM_OPERATION_EXTENSIONS]: {{\n{indent}  createOperations({context_param}) {{\n{indent}    return {{\n{indent}      instructions: {{\n{entries}\n{indent}      }},\n{indent}    }};\n{indent}  }},\n{indent}}},",
6001        indent = indent,
6002        context_param = context_param,
6003        entries = entries.join("\n"),
6004    ))
6005}
6006
6007fn generate_view_helpers_static() -> String {
6008    r#"// ============================================================================
6009// View Definition Types (framework-agnostic)
6010// ============================================================================
6011
6012export type ViewKeyFields<TKey> = unknown extends TKey
6013  ? readonly string[]
6014  : TKey extends object
6015    ? readonly Extract<keyof TKey, string>[]
6016    : readonly string[];
6017
6018/** View definition with embedded entity and state-key types */
6019export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown> {
6020  readonly mode: TMode;
6021  readonly view: string;
6022  readonly keyFields?: ViewKeyFields<TKey>;
6023  /** Phantom field for type inference - not present at runtime */
6024  readonly _entity?: T;
6025  readonly _key?: TKey;
6026}
6027
6028/** Helper to create typed state view definitions (keyed lookups) */
6029function stateView<T, TKey = unknown>(
6030  view: string,
6031  keyFields: ViewKeyFields<TKey>
6032): ViewDef<T, 'state', TKey> {
6033  return { mode: 'state', view, keyFields } as const;
6034}
6035
6036/** Helper to create typed list view definitions (collections) */
6037function listView<T>(view: string): ViewDef<T, 'list'> {
6038  return { mode: 'list', view } as const;
6039}"#
6040    .to_string()
6041}
6042
6043/// Key a program is registered under in `programs` / `programReads`
6044/// (`ore`, `pumpAmm`).
6045pub fn program_key(idl_name: &str) -> String {
6046    to_camel_case(idl_name)
6047}
6048
6049/// Name of the per-program constant (`ORE`, `PUMP_AMM`) that program-only
6050/// modules export and CLI entry modules import.
6051pub fn program_const_name(idl_name: &str) -> String {
6052    ts_ident::identifier(idl_name, IdentifierCase::ScreamingSnake)
6053}
6054
6055#[cfg(test)]
6056mod tests {
6057    use super::*;
6058
6059    fn demo_program_spec() -> arete_hash::ProgramSpecV1 {
6060        arete_hash::build_program_spec_v1_from_bytes(
6061            br#"{
6062              "address":"Prog111",
6063              "version":"0.1.0",
6064              "name":"demo",
6065              "instructions":[],
6066              "accounts":[],
6067              "types":[],
6068              "events":[],
6069              "errors":[]
6070            }"#,
6071            None,
6072        )
6073        .expect("test ProgramSpecV1")
6074    }
6075
6076    fn named_program_spec(name: &str, program_id: &str) -> arete_hash::ProgramSpecV1 {
6077        arete_hash::build_program_spec_v1_from_bytes(
6078            format!(
6079                r#"{{
6080                  "address":"{program_id}",
6081                  "version":"0.1.0",
6082                  "name":"{name}",
6083                  "instructions":[],
6084                  "accounts":[],
6085                  "types":[],
6086                  "events":[],
6087                  "errors":[]
6088                }}"#
6089            )
6090            .as_bytes(),
6091            None,
6092        )
6093        .expect("named test ProgramSpecV1")
6094    }
6095
6096    fn two_program_test_spec() -> SerializableStackSpec {
6097        let specs = vec![
6098            named_program_spec("second_program", "Program222"),
6099            named_program_spec("first_program", "Program111"),
6100        ];
6101        SerializableStackSpec {
6102            ast_version: CURRENT_AST_VERSION.to_string(),
6103            stack_name: "OrderedStream".to_string(),
6104            program_ids: specs.iter().map(|spec| spec.program_id.clone()).collect(),
6105            idls: specs
6106                .iter()
6107                .map(|spec| spec.idl_snapshot.snapshot.clone())
6108                .collect(),
6109            program_specs: specs,
6110            entities: vec![],
6111            pdas: BTreeMap::new(),
6112            instructions: vec![],
6113            content_hash: None,
6114        }
6115    }
6116
6117    fn program_only_test_spec(
6118        pdas: BTreeMap<String, BTreeMap<String, PdaDefinition>>,
6119        instructions: Vec<InstructionDef>,
6120    ) -> SerializableStackSpec {
6121        let program_spec = demo_program_spec();
6122        SerializableStackSpec {
6123            ast_version: CURRENT_AST_VERSION.to_string(),
6124            stack_name: "DemoStream".to_string(),
6125            program_ids: vec!["Prog111".to_string()],
6126            idls: vec![program_spec.idl_snapshot.snapshot.clone()],
6127            program_specs: vec![program_spec],
6128            entities: vec![],
6129            pdas,
6130            instructions,
6131            content_hash: None,
6132        }
6133    }
6134
6135    fn test_instruction(amount_hint: Option<InstructionAmountHint>) -> InstructionDef {
6136        InstructionDef {
6137            name: "deposit".to_string(),
6138            discriminator: vec![9],
6139            discriminator_size: 1,
6140            accounts: vec![],
6141            args: amount_hint
6142                .map(|amount_hint| InstructionArgDef {
6143                    name: "amount".to_string(),
6144                    arg_type: "u64".to_string(),
6145                    docs: vec![],
6146                    amount_hint: Some(amount_hint),
6147                })
6148                .into_iter()
6149                .collect(),
6150            errors: vec![],
6151            program_id: Some("Prog111".to_string()),
6152            docs: vec![],
6153        }
6154    }
6155
6156    fn state_key_test_spec(primary_keys: Vec<&str>) -> SerializableStreamSpec {
6157        let round_id = FieldTypeInfo::new("round_id".to_string(), "u64".to_string());
6158        SerializableStreamSpec {
6159            ast_version: CURRENT_AST_VERSION.to_string(),
6160            state_name: "OreRound".to_string(),
6161            program_id: None,
6162            idl: None,
6163            identity: IdentitySpec {
6164                primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
6165                lookup_indexes: vec![],
6166            },
6167            handlers: vec![],
6168            sections: vec![EntitySection {
6169                name: "id".to_string(),
6170                fields: vec![round_id.clone()],
6171                is_nested_struct: false,
6172                parent_field: None,
6173            }],
6174            field_mappings: BTreeMap::from([("id.round_id".to_string(), round_id)]),
6175            resolver_hooks: vec![],
6176            instruction_hooks: vec![],
6177            resolver_specs: vec![],
6178            computed_fields: vec![],
6179            computed_field_specs: vec![],
6180            content_hash: None,
6181            views: vec![],
6182        }
6183    }
6184
6185    fn endpoint_test_spec() -> SerializableStackSpec {
6186        SerializableStackSpec {
6187            ast_version: CURRENT_AST_VERSION.to_string(),
6188            stack_name: "EndpointStream".to_string(),
6189            program_ids: vec![],
6190            idls: vec![],
6191            program_specs: vec![],
6192            entities: vec![state_key_test_spec(vec!["id.round_id"])],
6193            pdas: BTreeMap::new(),
6194            instructions: vec![],
6195            content_hash: None,
6196        }
6197    }
6198
6199    #[test]
6200    fn test_case_conversions() {
6201        assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
6202        assert_eq!(
6203            to_pascal_case("sb_on_demand::actions::Submission"),
6204            "SbOnDemandActionsSubmission"
6205        );
6206        assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
6207    }
6208
6209    #[test]
6210    fn idl_field_normalization_disambiguates_leading_underscore_collisions() {
6211        let fields = vec![
6212            IdlFieldSnapshot {
6213                name: "_padding_0".to_string(),
6214                type_: IdlTypeSnapshot::Simple("u8".to_string()),
6215                amount_hint: None,
6216            },
6217            IdlFieldSnapshot {
6218                name: "padding_0".to_string(),
6219                type_: IdlTypeSnapshot::Simple("u8".to_string()),
6220                amount_hint: None,
6221            },
6222        ];
6223
6224        let normalized = normalize_idl_fields(&fields, &BTreeMap::new());
6225        assert_eq!(
6226            normalized
6227                .iter()
6228                .map(|field| field.name.as_str())
6229                .collect::<Vec<_>>(),
6230            vec!["_padding0", "padding0"]
6231        );
6232
6233        let interface =
6234            generate_interface_from_idl_fields("CollisionLayout", &fields, &BTreeMap::new());
6235        assert!(interface.contains("_padding0: number;"));
6236        assert!(interface.contains("padding0: number;"));
6237
6238        let schema = generate_schema_from_idl_fields("CollisionLayout", &fields, &BTreeMap::new());
6239        assert!(schema.contains("_padding_0: z.number(),"));
6240        assert!(schema.contains("padding_0: z.number(),"));
6241        assert!(schema.contains("_padding0: value._padding_0,"));
6242        assert!(schema.contains("padding0: value.padding_0,"));
6243    }
6244
6245    #[test]
6246    fn local_stack_codegen_is_endpointless_by_default() {
6247        let output = compile_stack_spec(endpoint_test_spec(), None)
6248            .expect("local stack generation should succeed");
6249
6250        assert!(output.stack_definition.contains(
6251            "  endpoints: {\n    ws: '', // TODO: Set after first deployment or pass useArete(..., { url })\n    http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })\n  },"
6252        ));
6253    }
6254
6255    #[test]
6256    fn stack_codegen_emits_independent_endpoints_exactly() {
6257        let websocket_url = "wss://stream.example.test/ws/v2?tenant=endpoint";
6258        let http_url = "https://reads.unrelated.test/api/arete/v3";
6259        let output = compile_stack_spec(
6260            endpoint_test_spec(),
6261            Some(TypeScriptStackConfig {
6262                websocket_url: Some(websocket_url.to_string()),
6263                http_url: Some(http_url.to_string()),
6264                ..TypeScriptStackConfig::default()
6265            }),
6266        )
6267        .expect("configured stack generation should succeed");
6268
6269        assert!(output.stack_definition.contains(&format!(
6270            "  endpoints: {{\n    ws: '{}',\n    http: '{}',\n  }},",
6271            websocket_url, http_url
6272        )));
6273        assert!(!output
6274            .stack_definition
6275            .contains("https://stream.example.test/ws/v2"));
6276    }
6277
6278    #[test]
6279    fn explicit_local_websocket_does_not_derive_http() {
6280        let output = compile_stack_spec(
6281            endpoint_test_spec(),
6282            Some(TypeScriptStackConfig {
6283                websocket_url: Some("ws://127.0.0.1:8878/socket".to_string()),
6284                ..TypeScriptStackConfig::default()
6285            }),
6286        )
6287        .expect("configured local stack generation should succeed");
6288
6289        assert!(output
6290            .stack_definition
6291            .contains("    ws: 'ws://127.0.0.1:8878/socket',"));
6292        assert!(output.stack_definition.contains(
6293            "    http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })"
6294        ));
6295        assert!(!output
6296            .stack_definition
6297            .contains("http://127.0.0.1:8878/socket"));
6298    }
6299
6300    #[test]
6301    fn test_normalize_for_comparison() {
6302        assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
6303        assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
6304        assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
6305        assert_eq!(
6306            normalize_for_comparison("admin_set_creator"),
6307            "adminsetcreator"
6308        );
6309        assert_eq!(
6310            normalize_for_comparison("AdminSetCreator"),
6311            "adminsetcreator"
6312        );
6313    }
6314
6315    #[test]
6316    fn test_value_to_typescript_type() {
6317        assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
6318        assert_eq!(
6319            value_to_typescript_type(&serde_json::json!("hello")),
6320            "string"
6321        );
6322        assert_eq!(
6323            value_to_typescript_type(&serde_json::json!(true)),
6324            "boolean"
6325        );
6326        assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
6327    }
6328
6329    #[test]
6330    fn test_typescript_scalar_array_element() {
6331        assert_eq!(
6332            typescript_scalar_array_element("Vec < f64 >"),
6333            Some("number")
6334        );
6335        assert_eq!(typescript_scalar_array_element("Vec<f32>"), Some("number"));
6336        assert_eq!(typescript_scalar_array_element("f64"), Some("number"));
6337        assert_eq!(
6338            typescript_scalar_array_element("Vec < bool >"),
6339            Some("boolean")
6340        );
6341        assert_eq!(
6342            typescript_scalar_array_element("Vec < String >"),
6343            Some("string")
6344        );
6345        assert_eq!(typescript_scalar_array_element("Vec < u64 >"), None);
6346        assert_eq!(typescript_scalar_array_element("Vec < Pubkey >"), None);
6347    }
6348
6349    #[test]
6350    fn state_view_codegen_emits_exact_key_type_and_deduped_runtime_metadata() {
6351        let output = compile_serializable_spec(
6352            state_key_test_spec(vec!["id.round_id", "id.round_id"]),
6353            "OreRound".to_string(),
6354            None,
6355        )
6356        .expect("duplicate identical keys should compile");
6357
6358        assert!(output.stack_definition.contains(
6359            "export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown>"
6360        ));
6361        assert!(output.stack_definition.contains(
6362            "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
6363        ));
6364        assert_eq!(output.stack_definition.matches("['roundId']").count(), 1);
6365    }
6366
6367    #[test]
6368    fn state_view_codegen_rejects_distinct_composite_keys() {
6369        let mut spec = state_key_test_spec(vec!["id.round_id", "id.authority"]);
6370        spec.field_mappings.insert(
6371            "id.authority".to_string(),
6372            FieldTypeInfo::new("authority".to_string(), "String".to_string()),
6373        );
6374
6375        let error = compile_serializable_spec(spec, "OreRound".to_string(), None)
6376            .expect_err("distinct composite keys must fail generation");
6377
6378        assert!(error.contains("does not support composite state keys"));
6379        assert!(error.contains("id.round_id, id.authority"));
6380    }
6381
6382    #[test]
6383    fn pda_imports_follow_emitted_seed_variants() {
6384        let empty_seed_pdas = BTreeMap::from([(
6385            "demo".to_string(),
6386            BTreeMap::from([(
6387                "singleton".to_string(),
6388                PdaDefinition {
6389                    name: "singleton".to_string(),
6390                    seeds: vec![],
6391                    program_id: None,
6392                    program: None,
6393                },
6394            )]),
6395        )]);
6396        let output = compile_program_modules(program_only_test_spec(empty_seed_pdas, vec![]), None)
6397            .expect("empty-seed PDA generation should succeed");
6398        assert_eq!(
6399            output.imports,
6400            "import { z } from 'zod';\nimport { pda } from '@usearete/sdk';"
6401        );
6402        assert!(output
6403            .stack_definition
6404            .contains("singleton: pda('Prog111'),"));
6405        assert!(!output.stack_definition.contains("pda('Prog111', )"));
6406
6407        let literal_account_pdas = BTreeMap::from([(
6408            "demo".to_string(),
6409            BTreeMap::from([(
6410                "vault".to_string(),
6411                PdaDefinition {
6412                    name: "vault".to_string(),
6413                    seeds: vec![
6414                        PdaSeedDef::Literal {
6415                            value: "vault".to_string(),
6416                        },
6417                        PdaSeedDef::AccountRef {
6418                            account_name: "authority".to_string(),
6419                        },
6420                    ],
6421                    program_id: None,
6422                    program: None,
6423                },
6424            )]),
6425        )]);
6426        let output =
6427            compile_program_modules(program_only_test_spec(literal_account_pdas, vec![]), None)
6428                .expect("literal/account PDA generation should succeed");
6429        assert_eq!(
6430            output.imports,
6431            "import { z } from 'zod';\nimport { pda, literal, account } from '@usearete/sdk';"
6432        );
6433
6434        let arg_bytes_pdas = BTreeMap::from([(
6435            "demo".to_string(),
6436            BTreeMap::from([(
6437                "position".to_string(),
6438                PdaDefinition {
6439                    name: "position".to_string(),
6440                    seeds: vec![
6441                        PdaSeedDef::ArgRef {
6442                            arg_name: "roundId".to_string(),
6443                            arg_type: Some("u64".to_string()),
6444                        },
6445                        PdaSeedDef::Bytes {
6446                            value: vec![0, 255],
6447                        },
6448                    ],
6449                    program_id: None,
6450                    program: None,
6451                },
6452            )]),
6453        )]);
6454        let output = compile_program_modules(program_only_test_spec(arg_bytes_pdas, vec![]), None)
6455            .expect("arg/bytes PDA generation should succeed");
6456        assert_eq!(
6457            output.imports,
6458            "import { z } from 'zod';\nimport { pda, arg, bytes } from '@usearete/sdk';"
6459        );
6460    }
6461
6462    fn emitted_definition_hash(output: &TypeScriptStackOutput) -> &str {
6463        output
6464            .stack_definition
6465            .lines()
6466            .find(|line| line.trim_start().starts_with("sdkDefinitionHash:"))
6467            .unwrap()
6468            .trim()
6469    }
6470
6471    #[test]
6472    fn program_content_identity_tracks_generated_pda_behavior_with_the_same_input_hash() {
6473        let spec = program_only_test_spec(BTreeMap::new(), vec![]);
6474        let initial = compile_program_modules(spec.clone(), None).unwrap();
6475        let mut changed = spec.clone();
6476        changed.pdas.insert(
6477            "demo".into(),
6478            BTreeMap::from([(
6479                "position".into(),
6480                PdaDefinition {
6481                    name: "position".into(),
6482                    seeds: vec![PdaSeedDef::Bytes {
6483                        value: vec![1, 2, 3],
6484                    }],
6485                    program_id: None,
6486                    program: None,
6487                },
6488            )]),
6489        );
6490        assert_eq!(
6491            spec.program_specs[0].hash().unwrap(),
6492            changed.program_specs[0].hash().unwrap()
6493        );
6494        let changed = compile_program_modules(changed, None).unwrap();
6495        assert_ne!(
6496            emitted_definition_hash(&initial),
6497            emitted_definition_hash(&changed)
6498        );
6499        assert_eq!(
6500            emitted_definition_hash(&initial),
6501            emitted_definition_hash(&compile_program_modules(spec, None).unwrap())
6502        );
6503    }
6504
6505    #[test]
6506    fn program_modules_recompute_content_identity_instead_of_trusting_input() {
6507        let stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
6508        let mut program = TypeScriptProgramConfig::from(
6509            &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
6510        );
6511        let initial = compile_program_modules(stack_spec.clone(), None).unwrap();
6512        program.definition.sdk_definition_hash = Some("definition-v1".to_string());
6513        program.release.program_release_hash =
6514            "arete:h1:program-release:sha256:different-release".into();
6515        let output = compile_program_modules(
6516            stack_spec,
6517            Some(TypeScriptStackConfig {
6518                programs: Some(vec![program]),
6519                ..TypeScriptStackConfig::default()
6520            }),
6521        )
6522        .expect("program definition generation should succeed");
6523
6524        assert_eq!(
6525            emitted_definition_hash(&initial),
6526            emitted_definition_hash(&output)
6527        );
6528        assert_ne!(initial.full_file(), output.full_file());
6529        assert!(!output.stack_definition.contains("definition-v1"));
6530        assert!(output
6531            .stack_definition
6532            .contains("sdkDefinitionHash: 'arete:h1:sdk-definition:sha256:"));
6533    }
6534
6535    #[test]
6536    fn program_modules_separate_portable_definition_from_release_reference() {
6537        let mut stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
6538        let mut local = TypeScriptProgramConfig::from(
6539            &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
6540        );
6541        local.definition.sdk_definition_hash = Some("portable-definition".to_string());
6542        let hosted_release = "arete:h1:program-release:sha256:hosted";
6543        let output = compile_program_modules(
6544            stack_spec.clone(),
6545            Some(TypeScriptStackConfig {
6546                programs: Some(vec![TypeScriptProgramConfig {
6547                    release: TypeScriptProgramReleaseReference {
6548                        program_release_hash: hosted_release.to_string(),
6549                        program_spec_hash: local.definition.program_spec_hash.clone(),
6550                    },
6551                    ..local.clone()
6552                }]),
6553                ..TypeScriptStackConfig::default()
6554            }),
6555        )
6556        .expect("program definition generation should succeed");
6557        let definition = output.stack_definition;
6558
6559        assert!(definition.contains("sdkDefinitionHash: 'arete:h1:sdk-definition:sha256:"));
6560        assert!(definition.contains(&format!(
6561            "programSpecHash: '{}',",
6562            local.definition.program_spec_hash
6563        )));
6564        let programs = definition
6565            .split("/** Release and explicit read transport")
6566            .next()
6567            .unwrap();
6568        assert!(!programs.contains("programReleaseHash"));
6569        assert!(!programs.contains("decoderEngineId"));
6570        assert!(definition.contains(&format!("programReleaseHash: \"{hosted_release}\"")));
6571        assert!(!definition.contains("decoderEngineId"));
6572        stack_spec.program_specs.clear();
6573        assert!(compile_program_modules(stack_spec, None)
6574            .unwrap_err()
6575            .contains("Rebuild the ProgramSpec and StackManifest artifact closure"));
6576    }
6577
6578    #[test]
6579    fn program_package_release_stays_out_of_the_core_definition() {
6580        let stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
6581        let mut program = TypeScriptProgramConfig::from(
6582            &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
6583        );
6584        let local = compile_program_modules(
6585            stack_spec.clone(),
6586            Some(TypeScriptStackConfig {
6587                programs: Some(vec![program.clone()]),
6588                ..TypeScriptStackConfig::default()
6589            }),
6590        )
6591        .unwrap();
6592        let release = format!(
6593            "arete:registry-package-release:v2:sha256:{}",
6594            "7".repeat(64)
6595        );
6596        program.definition.package_release_hash = Some(release.clone());
6597        let registry = compile_program_modules(
6598            stack_spec,
6599            Some(TypeScriptStackConfig {
6600                programs: Some(vec![program]),
6601                ..TypeScriptStackConfig::default()
6602            }),
6603        )
6604        .unwrap();
6605
6606        // The entry module stamps identity after the package's own extension;
6607        // the core definition lacks that extension, so it carries none.
6608        assert!(!local.stack_definition.contains("packageReleaseHash"));
6609        assert!(!registry.stack_definition.contains("packageReleaseHash"));
6610        assert_eq!(local.stack_definition, registry.stack_definition);
6611        assert_eq!(
6612            emitted_definition_hash(&local),
6613            emitted_definition_hash(&registry),
6614            "the same generated content keeps one definition hash"
6615        );
6616    }
6617
6618    /// Address Lookup Table: `lookup_table` is derived on create only; every
6619    /// other instruction takes it from the caller, and `extend_lookup_table`
6620    /// keeps its bincode `u64` vector length prefix.
6621    #[test]
6622    fn address_lookup_table_program_sdk_derives_lookup_table_on_create_only() {
6623        let spec = crate::program_sdk::build_program_only_stack_spec_from_idl_bytes(
6624            include_bytes!("../../arete-idl/tests/fixtures/address-lookup-table.json"),
6625            None,
6626            "AddressLookupTable",
6627        )
6628        .expect("ALT program-only stack spec should build");
6629        let output =
6630            compile_program_modules(spec, None).expect("typescript program SDK generation");
6631        // Instruction handlers (discriminators, account metas, arg schemas) are
6632        // emitted with the interfaces; the stack definition only wires them up.
6633        let code = output.interfaces;
6634
6635        for tag in 0u8..5 {
6636            assert!(
6637                code.contains(&format!("discriminator: [{tag}, 0, 0, 0],")),
6638                "instruction tag {tag} must be a u32-LE bincode discriminator:\n{code}"
6639            );
6640        }
6641        assert!(
6642            code.contains("vecU64Len"),
6643            "extend_lookup_table must keep its u64 vector length prefix:\n{code}"
6644        );
6645        assert!(
6646            output.pda_degradations.is_empty(),
6647            "no PDA should degrade: {:?}",
6648            output.warnings
6649        );
6650
6651        let lookup_table_accounts: Vec<&str> = code
6652            .lines()
6653            .filter(|line| {
6654                line.contains("category: '")
6655                    && line
6656                        .to_ascii_lowercase()
6657                        .replace('_', "")
6658                        .contains("name: 'lookuptable'")
6659            })
6660            .collect();
6661        assert_eq!(lookup_table_accounts.len(), 5, "{code}");
6662        let derived = lookup_table_accounts
6663            .iter()
6664            .filter(|line| line.contains("category: 'pda', pdaConfig:"))
6665            .count();
6666        let caller_provided = lookup_table_accounts
6667            .iter()
6668            .filter(|line| line.contains("category: 'userProvided'"))
6669            .count();
6670        assert_eq!((derived, caller_provided), (1, 4), "{code}");
6671    }
6672
6673    #[test]
6674    fn program_account_codegen_is_semantic_and_release_lives_in_program_reads() {
6675        let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
6676            include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
6677            None,
6678        )
6679        .expect("fixture identity");
6680        let stack_spec =
6681            crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
6682        let output = compile_program_modules(stack_spec, None).expect("program SDK generation");
6683
6684        assert!(output.stack_definition.contains(
6685            "programAccountRead<Presale>({ account: 'Presale', schema: PresaleSchema })"
6686        ));
6687        assert!(!output.stack_definition.contains("path:"));
6688        assert!(output.stack_definition.contains(&format!(
6689            "programReleaseHash: \"{}\"",
6690            identity.release_hash
6691        )));
6692        assert!(output
6693            .stack_definition
6694            .contains("transport: { kind: 'local-http', endpointSource: 'connect-http-url' }"));
6695        assert!(!output.stack_definition.contains("path:"));
6696    }
6697
6698    #[test]
6699    fn legacy_account_reader_ast_without_exact_program_specs_fails_closed() {
6700        let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
6701            include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
6702            None,
6703        )
6704        .expect("fixture identity");
6705        let mut stack_spec =
6706            crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
6707        assert!(!stack_spec.idls[0].accounts.is_empty());
6708        stack_spec.program_specs.clear();
6709
6710        let error = compile_program_modules(stack_spec, None).unwrap_err();
6711        assert!(error.contains("no exact public ProgramSpecV1 values"));
6712        assert!(error.contains("Rebuild the ProgramSpec and StackManifest artifact closure"));
6713    }
6714
6715    #[test]
6716    fn hosted_program_configs_preserve_order_releases_endpoints_and_auth() {
6717        let stack_spec = two_program_test_spec();
6718        let programs = stack_spec
6719            .program_specs
6720            .iter()
6721            .enumerate()
6722            .map(|(index, spec)| {
6723                let identity = arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap();
6724                let mut config = TypeScriptProgramConfig::from(&identity);
6725                config.release.program_release_hash = format!("hosted-release-{index}");
6726                let binding_id = format!("prb_{index:032}");
6727                config.transport =
6728                    TypeScriptProgramReadTransport::HostedBinding(TypeScriptProgramReadBinding {
6729                        endpoint: format!("https://reads.example.test/exact/{index}/"),
6730                        program_read_binding_id: binding_id.clone(),
6731                        auth: serde_json::json!({
6732                            "targetKind": "program-read-binding",
6733                            "targetId": binding_id,
6734                            "sessionEndpoint": format!("https://auth.example.test/{index}"),
6735                            "index": index
6736                        }),
6737                    });
6738                config
6739            })
6740            .collect::<Vec<_>>();
6741
6742        let output = compile_stack_spec(
6743            stack_spec,
6744            Some(TypeScriptStackConfig {
6745                programs: Some(programs),
6746                ..Default::default()
6747            }),
6748        )
6749        .expect("ordered hosted descriptors should compile");
6750        let generated = output.stack_definition;
6751        let portable = generated
6752            .split("  programs: {")
6753            .nth(1)
6754            .expect("portable programs block")
6755            .split("  programReads: {")
6756            .next()
6757            .expect("portable programs block end");
6758        let reads = generated
6759            .split("  programReads: {")
6760            .nth(1)
6761            .expect("parallel program reads block");
6762
6763        assert!(portable.find("secondProgram:").unwrap() < portable.find("firstProgram:").unwrap());
6764        assert!(!portable.contains("programReleaseHash"));
6765        assert!(!portable.contains("endpoint"));
6766        assert!(reads.find("secondProgram:").unwrap() < reads.find("firstProgram:").unwrap());
6767        assert!(reads.contains("programReleaseHash: \"hosted-release-0\""));
6768        assert!(reads.contains("programReleaseHash: \"hosted-release-1\""));
6769        assert!(reads.contains("kind: 'hosted-binding'"));
6770        assert!(reads.contains("endpoint: \"https://reads.example.test/exact/0/\""));
6771        assert!(reads.contains("programReadBindingId: \"prb_00000000000000000000000000000001\""));
6772        assert!(reads.contains(
6773            "auth: {\"index\":0,\"sessionEndpoint\":\"https://auth.example.test/0\",\"targetId\":\"prb_00000000000000000000000000000000\",\"targetKind\":\"program-read-binding\"}"
6774        ));
6775    }
6776
6777    #[test]
6778    fn hosted_program_config_mismatches_fail_by_index_without_name_fallback() {
6779        let stack_spec = two_program_test_spec();
6780        let local = stack_spec
6781            .program_specs
6782            .iter()
6783            .map(|spec| {
6784                TypeScriptProgramConfig::from(
6785                    &arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap(),
6786                )
6787            })
6788            .collect::<Vec<_>>();
6789
6790        let count_error = compile_program_modules(
6791            stack_spec.clone(),
6792            Some(TypeScriptStackConfig {
6793                programs: Some(vec![local[0].clone()]),
6794                ..Default::default()
6795            }),
6796        )
6797        .unwrap_err();
6798        assert!(count_error.contains("descriptor count mismatch"));
6799
6800        let mut swapped = local.clone();
6801        swapped.swap(0, 1);
6802        let order_error = compile_program_modules(
6803            stack_spec.clone(),
6804            Some(TypeScriptStackConfig {
6805                programs: Some(swapped),
6806                ..Default::default()
6807            }),
6808        )
6809        .unwrap_err();
6810        assert!(order_error.contains("program ID mismatch at index 0"));
6811
6812        let mut bad_hash = local;
6813        bad_hash[1].definition.program_spec_hash = "wrong-spec".to_string();
6814        let hash_error = compile_program_modules(
6815            stack_spec,
6816            Some(TypeScriptStackConfig {
6817                programs: Some(bad_hash),
6818                ..Default::default()
6819            }),
6820        )
6821        .unwrap_err();
6822        assert!(hash_error.contains("programSpecHash mismatch at index 1"));
6823    }
6824
6825    #[test]
6826    fn raw_operations_omit_semantic_only_types_and_context() {
6827        let output = compile_program_modules(
6828            program_only_test_spec(BTreeMap::new(), vec![test_instruction(None)]),
6829            None,
6830        )
6831        .expect("raw operation generation should succeed");
6832        let file = output.full_file();
6833
6834        assert!(!output.imports.contains("BuildOptions"));
6835        assert!(!output.imports.contains("ProgramOperationContext"));
6836        assert!(file.contains("createOperations()"));
6837        assert!(!file.contains("build?: BuildOptions;"));
6838    }
6839
6840    #[test]
6841    fn context_free_amount_operations_import_build_options_only() {
6842        let output = compile_program_modules(
6843            program_only_test_spec(
6844                BTreeMap::new(),
6845                vec![test_instruction(Some(InstructionAmountHint {
6846                    decimals_source: AmountDecimalsSource::Constant { decimals: 9 },
6847                }))],
6848            ),
6849            None,
6850        )
6851        .expect("constant amount operation generation should succeed");
6852        let file = output.full_file();
6853
6854        assert!(output.imports.contains("type BuildOptions"));
6855        assert!(output.imports.contains("toRawAmount"));
6856        assert!(!output.imports.contains("ProgramOperationContext"));
6857        assert!(file.contains("build?: BuildOptions;"));
6858        assert!(file.contains("createOperations()"));
6859        assert!(!file.contains("context.chain"));
6860    }
6861
6862    #[test]
6863    fn streamed_entity_codegen_normalizes_canonical_names_and_schemas() {
6864        let spec = SerializableStreamSpec {
6865            ast_version: CURRENT_AST_VERSION.to_string(),
6866            state_name: "TokenPosition".to_string(),
6867            program_id: None,
6868            idl: None,
6869            identity: IdentitySpec {
6870                primary_keys: vec!["id.address".to_string()],
6871                lookup_indexes: vec![],
6872            },
6873            handlers: vec![],
6874            sections: vec![
6875                EntitySection {
6876                    name: "root".to_string(),
6877                    fields: vec![FieldTypeInfo::new(
6878                        "total_deposit".to_string(),
6879                        "u64".to_string(),
6880                    )],
6881                    is_nested_struct: false,
6882                    parent_field: None,
6883                },
6884                EntitySection {
6885                    name: "metrics".to_string(),
6886                    fields: vec![FieldTypeInfo::new(
6887                        "last_updated_at".to_string(),
6888                        "i64".to_string(),
6889                    )],
6890                    is_nested_struct: false,
6891                    parent_field: None,
6892                },
6893            ],
6894            field_mappings: BTreeMap::new(),
6895            resolver_hooks: vec![],
6896            resolver_specs: vec![],
6897            instruction_hooks: vec![],
6898            computed_fields: vec![],
6899            computed_field_specs: vec![],
6900            content_hash: None,
6901            views: vec![],
6902        };
6903
6904        let output = compile_serializable_spec(spec, "TokenPosition".to_string(), None)
6905            .expect("should compile");
6906        let file = output.full_file();
6907
6908        assert!(
6909            file.contains("export interface TokenPosition {"),
6910            "missing main interface:\n{}",
6911            file
6912        );
6913        assert!(
6914            file.contains("totalDeposit: bigint;"),
6915            "missing root canonical field:\n{}",
6916            file
6917        );
6918        assert!(
6919            file.contains("metrics: TokenPositionMetrics;"),
6920            "missing nested canonical section field:\n{}",
6921            file
6922        );
6923        assert!(
6924            file.contains("export interface TokenPositionMetrics {"),
6925            "missing nested interface:\n{}",
6926            file
6927        );
6928        assert!(
6929            file.contains("lastUpdatedAt: bigint;"),
6930            "missing nested canonical field:\n{}",
6931            file
6932        );
6933
6934        let bigint_schema = bigint_zod();
6935        assert!(
6936            file.contains("export const TokenPositionSchema = z.object({"),
6937            "missing main schema:\n{}",
6938            file
6939        );
6940        assert!(
6941            file.contains(&format!("total_deposit: {},", bigint_schema)),
6942            "missing raw root schema field:\n{}",
6943            file
6944        );
6945        assert!(
6946            file.contains("metrics: TokenPositionMetricsSchema,"),
6947            "missing nested schema ref:\n{}",
6948            file
6949        );
6950        assert!(
6951            file.contains("totalDeposit: value.total_deposit,"),
6952            "missing root transform:\n{}",
6953            file
6954        );
6955        assert!(
6956            file.contains("metrics: value.metrics,"),
6957            "missing nested transform:\n{}",
6958            file
6959        );
6960
6961        assert!(
6962            file.contains("export const TokenPositionMetricsSchema = z.object({"),
6963            "missing nested schema:\n{}",
6964            file
6965        );
6966        assert!(
6967            file.contains(&format!("last_updated_at: {},", bigint_schema)),
6968            "missing raw nested schema field:\n{}",
6969            file
6970        );
6971        assert!(
6972            file.contains("lastUpdatedAt: value.last_updated_at,"),
6973            "missing nested transform:\n{}",
6974            file
6975        );
6976
6977        assert!(
6978            file.contains("export const TokenPositionPatchSchema = z.object({"),
6979            "missing patch schema:\n{}",
6980            file
6981        );
6982        assert!(
6983            file.contains(&format!("total_deposit: {}.optional(),", bigint_schema)),
6984            "missing sparse patch field:\n{}",
6985            file
6986        );
6987        assert!(
6988            file.contains("metrics: TokenPositionMetricsPatchSchema.optional(),"),
6989            "missing nested patch schema ref:\n{}",
6990            file
6991        );
6992        assert!(
6993            file.contains("...(value.total_deposit !== undefined ? { totalDeposit: value.total_deposit } : {}),"),
6994            "missing sparse patch transform:\n{}",
6995            file
6996        );
6997        assert!(
6998            file.contains("export const TokenPositionMetricsPatchSchema = z.object({"),
6999            "missing nested patch schema:\n{}",
7000            file
7001        );
7002        assert!(
7003            file.contains("patchSchemas: {\n    TokenPosition: TokenPositionPatchSchema,"),
7004            "missing stack patch schema map:\n{}",
7005            file
7006        );
7007    }
7008
7009    #[test]
7010    fn captured_accounts_keep_the_runtime_envelope_and_full_inner_schema() {
7011        let miner_snapshot = FieldTypeInfo {
7012            field_name: "miner_snapshot".to_string(),
7013            raw_name: Some("miner_snapshot".to_string()),
7014            canonical_name: Some("minerSnapshot".to_string()),
7015            rust_type_name: "Option<Miner>".to_string(),
7016            base_type: BaseType::Object,
7017            integer_kind: None,
7018            is_optional: true,
7019            is_array: false,
7020            inner_type: Some("Miner".to_string()),
7021            source_path: None,
7022            resolved_type: Some(ResolvedStructType {
7023                type_name: "Miner".to_string(),
7024                fields: vec![
7025                    ResolvedField {
7026                        field_name: "deployed".to_string(),
7027                        raw_name: Some("deployed".to_string()),
7028                        canonical_name: Some("deployed".to_string()),
7029                        field_type: "u64".to_string(),
7030                        base_type: BaseType::Integer,
7031                        integer_kind: Some(IntegerKind::U64),
7032                        is_optional: false,
7033                        is_array: true,
7034                    },
7035                    ResolvedField {
7036                        field_name: "round_id".to_string(),
7037                        raw_name: Some("round_id".to_string()),
7038                        canonical_name: Some("roundId".to_string()),
7039                        field_type: "u64".to_string(),
7040                        base_type: BaseType::Integer,
7041                        integer_kind: Some(IntegerKind::U64),
7042                        is_optional: false,
7043                        is_array: false,
7044                    },
7045                ],
7046                is_instruction: false,
7047                is_account: true,
7048                is_event: false,
7049                is_enum: false,
7050                enum_variants: vec![],
7051            }),
7052            emit: true,
7053        };
7054        let spec = SerializableStreamSpec {
7055            ast_version: CURRENT_AST_VERSION.to_string(),
7056            state_name: "OreMiner".to_string(),
7057            program_id: None,
7058            idl: None,
7059            identity: IdentitySpec {
7060                primary_keys: vec!["id.authority".to_string()],
7061                lookup_indexes: vec![],
7062            },
7063            handlers: vec![SerializableHandlerSpec {
7064                source: SourceSpec::Source {
7065                    program_id: None,
7066                    discriminator: None,
7067                    type_name: "Miner".to_string(),
7068                    serialization: None,
7069                    is_account: true,
7070                },
7071                key_resolution: KeyResolutionStrategy::Embedded {
7072                    primary_field: FieldPath::new(&["authority"]),
7073                },
7074                mappings: vec![SerializableFieldMapping {
7075                    target_path: "miner_snapshot".to_string(),
7076                    source: MappingSource::AsCapture {
7077                        field_transforms: BTreeMap::new(),
7078                    },
7079                    transform: None,
7080                    population: PopulationStrategy::LastWrite,
7081                    condition: None,
7082                    when: None,
7083                    stop: None,
7084                    emit: true,
7085                }],
7086                conditions: vec![],
7087                emit: true,
7088            }],
7089            sections: vec![
7090                EntitySection {
7091                    name: "id".to_string(),
7092                    fields: vec![FieldTypeInfo::new(
7093                        "authority".to_string(),
7094                        "String".to_string(),
7095                    )],
7096                    is_nested_struct: false,
7097                    parent_field: None,
7098                },
7099                EntitySection {
7100                    name: "root".to_string(),
7101                    fields: vec![miner_snapshot],
7102                    is_nested_struct: false,
7103                    parent_field: None,
7104                },
7105            ],
7106            field_mappings: BTreeMap::new(),
7107            resolver_hooks: vec![],
7108            resolver_specs: vec![],
7109            instruction_hooks: vec![],
7110            computed_fields: vec![],
7111            computed_field_specs: vec![],
7112            content_hash: None,
7113            views: vec![],
7114        };
7115
7116        let output = compile_serializable_spec(spec, "OreMiner".to_string(), None)
7117            .expect("captured account generation should succeed");
7118        let file = output.full_file();
7119
7120        assert!(file.contains("minerSnapshot: CaptureWrapper<Miner> | null;"));
7121        assert!(file.contains("export interface CaptureWrapper<T> {"));
7122        assert!(file.contains("accountAddress: string;"));
7123        assert!(file.contains("account_address: z.string(),"));
7124        assert!(file.contains("accountAddress: value.account_address,"));
7125        assert!(file
7126            .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
7127        assert!(file
7128            .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
7129        assert!(!file.contains("CaptureWrapperSchema(MinerPatchSchema)"));
7130    }
7131
7132    #[test]
7133    fn streamed_builtin_token_metadata_codegen_is_canonical_and_sparse() {
7134        let spec = SerializableStreamSpec {
7135            ast_version: CURRENT_AST_VERSION.to_string(),
7136            state_name: "TokenHolder".to_string(),
7137            program_id: None,
7138            idl: None,
7139            identity: IdentitySpec {
7140                primary_keys: vec!["id.address".to_string()],
7141                lookup_indexes: vec![],
7142            },
7143            handlers: vec![],
7144            sections: vec![EntitySection {
7145                name: "root".to_string(),
7146                fields: vec![FieldTypeInfo {
7147                    field_name: "base_token_metadata".to_string(),
7148                    raw_name: Some("base_token_metadata".to_string()),
7149                    canonical_name: Some("baseTokenMetadata".to_string()),
7150                    rust_type_name: "Option<TokenMetadata>".to_string(),
7151                    base_type: BaseType::Object,
7152                    integer_kind: None,
7153                    is_optional: true,
7154                    is_array: false,
7155                    inner_type: Some("TokenMetadata".to_string()),
7156                    source_path: None,
7157                    resolved_type: None,
7158                    emit: true,
7159                }],
7160                is_nested_struct: false,
7161                parent_field: None,
7162            }],
7163            field_mappings: BTreeMap::new(),
7164            resolver_hooks: vec![],
7165            resolver_specs: vec![],
7166            instruction_hooks: vec![],
7167            computed_fields: vec![],
7168            computed_field_specs: vec![],
7169            content_hash: None,
7170            views: vec![],
7171        };
7172
7173        let output = compile_serializable_spec(spec, "TokenHolder".to_string(), None)
7174            .expect("should compile");
7175        let file = output.full_file();
7176
7177        assert!(
7178            file.contains("export interface TokenMetadata {"),
7179            "missing builtin interface:\n{}",
7180            file
7181        );
7182        assert!(
7183            file.contains("logoUri?: string | null;"),
7184            "missing canonical builtin field:\n{}",
7185            file
7186        );
7187        assert!(
7188            file.contains("export const TokenMetadataSchema = z.object({"),
7189            "missing builtin schema:\n{}",
7190            file
7191        );
7192        assert!(
7193            file.contains("logo_uri: z.string().nullable().optional(),"),
7194            "missing raw builtin input field:\n{}",
7195            file
7196        );
7197        assert!(
7198            file.contains("...(value.logo_uri !== undefined ? { logoUri: value.logo_uri } : {}),"),
7199            "missing canonical builtin transform:\n{}",
7200            file
7201        );
7202        assert!(
7203            file.contains("export const TokenMetadataPatchSchema = z.object({"),
7204            "missing builtin patch schema:\n{}",
7205            file
7206        );
7207        assert!(
7208            file.contains("base_token_metadata: TokenMetadataPatchSchema.nullable().optional(),"),
7209            "missing patch schema usage for builtin field:\n{}",
7210            file
7211        );
7212    }
7213
7214    #[test]
7215    fn streamed_section_codegen_localizes_prefixed_raw_field_names() {
7216        let spec = SerializableStreamSpec {
7217            ast_version: CURRENT_AST_VERSION.to_string(),
7218            state_name: "OreRound".to_string(),
7219            program_id: None,
7220            idl: None,
7221            identity: IdentitySpec {
7222                primary_keys: vec!["id.round_id".to_string()],
7223                lookup_indexes: vec![],
7224            },
7225            handlers: vec![],
7226            sections: vec![EntitySection {
7227                name: "results".to_string(),
7228                fields: vec![FieldTypeInfo {
7229                    field_name: "results.expires_at_slot_hash".to_string(),
7230                    raw_name: Some("results.expires_at_slot_hash".to_string()),
7231                    canonical_name: Some("resultsExpiresAtSlotHash".to_string()),
7232                    rust_type_name: "Option<String>".to_string(),
7233                    base_type: BaseType::String,
7234                    integer_kind: None,
7235                    is_optional: true,
7236                    is_array: false,
7237                    inner_type: Some("String".to_string()),
7238                    source_path: None,
7239                    resolved_type: None,
7240                    emit: true,
7241                }],
7242                is_nested_struct: false,
7243                parent_field: None,
7244            }],
7245            field_mappings: BTreeMap::new(),
7246            resolver_hooks: vec![],
7247            resolver_specs: vec![],
7248            instruction_hooks: vec![],
7249            computed_fields: vec![],
7250            computed_field_specs: vec![],
7251            content_hash: None,
7252            views: vec![],
7253        };
7254
7255        let output =
7256            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
7257        let file = output.full_file();
7258
7259        assert!(
7260            file.contains("export interface OreRoundResults {"),
7261            "missing section interface:\n{}",
7262            file
7263        );
7264        assert!(
7265            file.contains("expiresAtSlotHash: string | null;"),
7266            "missing localized canonical field:\n{}",
7267            file
7268        );
7269        assert!(
7270            file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
7271            "missing localized canonical schema field:\n{}",
7272            file
7273        );
7274        assert!(
7275            file.contains("expiresAtSlotHash: value.expires_at_slot_hash,"),
7276            "missing localized transform:\n{}",
7277            file
7278        );
7279        assert!(
7280            file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
7281            "missing localized patch schema field:\n{}",
7282            file
7283        );
7284        assert!(
7285            file.contains("...(value.expires_at_slot_hash !== undefined ? { expiresAtSlotHash: value.expires_at_slot_hash } : {}),"),
7286            "missing localized sparse transform:\n{}",
7287            file
7288        );
7289    }
7290
7291    #[test]
7292    fn test_streamed_completed_schema_allows_unwritten_nullable_fields() {
7293        let mut optional_count = FieldTypeInfo::new("count".to_string(), "u64".to_string());
7294        optional_count.is_optional = true;
7295        let spec = SerializableStreamSpec {
7296            ast_version: CURRENT_AST_VERSION.to_string(),
7297            state_name: "OreRound".to_string(),
7298            program_id: None,
7299            idl: None,
7300            identity: IdentitySpec {
7301                primary_keys: vec!["id.round_id".to_string()],
7302                lookup_indexes: vec![],
7303            },
7304            handlers: vec![],
7305            sections: vec![EntitySection {
7306                name: "state".to_string(),
7307                fields: vec![optional_count],
7308                is_nested_struct: false,
7309                parent_field: None,
7310            }],
7311            field_mappings: BTreeMap::new(),
7312            resolver_hooks: vec![],
7313            resolver_specs: vec![],
7314            instruction_hooks: vec![],
7315            computed_fields: vec![],
7316            computed_field_specs: vec![],
7317            content_hash: None,
7318            views: vec![],
7319        };
7320
7321        let output =
7322            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
7323        let file = output.full_file();
7324        assert!(
7325            file.contains("count: z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value)).nullable().optional(),"),
7326            "completed schema should allow absent nullable fields:\n{}",
7327            file
7328        );
7329    }
7330
7331    #[test]
7332    fn test_derived_view_codegen() {
7333        let spec = SerializableStreamSpec {
7334            ast_version: CURRENT_AST_VERSION.to_string(),
7335            state_name: "OreRound".to_string(),
7336            program_id: None,
7337            idl: None,
7338            identity: IdentitySpec {
7339                primary_keys: vec!["id".to_string()],
7340                lookup_indexes: vec![],
7341            },
7342            handlers: vec![],
7343            sections: vec![],
7344            field_mappings: BTreeMap::new(),
7345            resolver_hooks: vec![],
7346            resolver_specs: vec![],
7347            instruction_hooks: vec![],
7348            computed_fields: vec![],
7349            computed_field_specs: vec![],
7350            content_hash: None,
7351            views: vec![
7352                ViewDef {
7353                    id: "OreRound/latest".to_string(),
7354                    source: ViewSource::Entity {
7355                        name: "OreRound".to_string(),
7356                    },
7357                    pipeline: vec![ViewTransform::Last],
7358                    output: ViewOutput::Single,
7359                },
7360                ViewDef {
7361                    id: "OreRound/top10".to_string(),
7362                    source: ViewSource::Entity {
7363                        name: "OreRound".to_string(),
7364                    },
7365                    pipeline: vec![ViewTransform::Take { count: 10 }],
7366                    output: ViewOutput::Collection,
7367                },
7368            ],
7369        };
7370
7371        let output =
7372            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
7373
7374        let stack_def = &output.stack_definition;
7375
7376        assert!(
7377            stack_def.contains("listView<OreRound>('OreRound/latest')"),
7378            "Expected 'latest' derived view using listView, got:\n{}",
7379            stack_def
7380        );
7381        assert!(
7382            stack_def.contains("listView<OreRound>('OreRound/top10')"),
7383            "Expected 'top10' derived view using listView, got:\n{}",
7384            stack_def
7385        );
7386        assert!(
7387            stack_def.contains("latest:"),
7388            "Expected 'latest' key, got:\n{}",
7389            stack_def
7390        );
7391        assert!(
7392            stack_def.contains("top10:"),
7393            "Expected 'top10' key, got:\n{}",
7394            stack_def
7395        );
7396        assert!(
7397            stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
7398            "Expected listView helper function, got:\n{}",
7399            stack_def
7400        );
7401    }
7402
7403    #[test]
7404    fn test_account_type_collision_uses_account_suffix() {
7405        let plan_field = FieldTypeInfo {
7406            field_name: "plan".to_string(),
7407            raw_name: Some("plan".to_string()),
7408            canonical_name: Some("plan".to_string()),
7409            rust_type_name: "Option<serde_json::Value>".to_string(),
7410            base_type: BaseType::Object,
7411            integer_kind: None,
7412            is_optional: false,
7413            is_array: false,
7414            inner_type: Some("Value".to_string()),
7415            source_path: None,
7416            resolved_type: Some(ResolvedStructType {
7417                type_name: "plan".to_string(),
7418                fields: vec![],
7419                is_instruction: false,
7420                is_account: true,
7421                is_event: false,
7422                is_enum: false,
7423                enum_variants: vec![],
7424            }),
7425            emit: true,
7426        };
7427
7428        let spec = SerializableStreamSpec {
7429            ast_version: CURRENT_AST_VERSION.to_string(),
7430            state_name: "Plan".to_string(),
7431            program_id: None,
7432            idl: None,
7433            identity: IdentitySpec {
7434                primary_keys: vec!["id.address".to_string()],
7435                lookup_indexes: vec![],
7436            },
7437            handlers: vec![],
7438            sections: vec![
7439                EntitySection {
7440                    name: "id".to_string(),
7441                    fields: vec![FieldTypeInfo::new(
7442                        "address".to_string(),
7443                        "String".to_string(),
7444                    )],
7445                    is_nested_struct: false,
7446                    parent_field: None,
7447                },
7448                EntitySection {
7449                    name: "plan".to_string(),
7450                    fields: vec![plan_field],
7451                    is_nested_struct: false,
7452                    parent_field: None,
7453                },
7454            ],
7455            field_mappings: BTreeMap::new(),
7456            resolver_hooks: vec![],
7457            instruction_hooks: vec![],
7458            resolver_specs: vec![],
7459            computed_fields: vec![],
7460            computed_field_specs: vec![],
7461            content_hash: None,
7462            views: vec![],
7463        };
7464
7465        let output = compile_serializable_spec(spec, "Plan".to_string(), None)
7466            .expect("typescript sdk generation should succeed");
7467
7468        assert!(
7469            output.interfaces.contains("export interface PlanPlan {"),
7470            "expected PlanPlan section interface, got:\n{}",
7471            output.interfaces
7472        );
7473        assert!(
7474            output.interfaces.contains("plan: PlanAccount;"),
7475            "expected PlanAccount field reference, got:\n{}",
7476            output.interfaces
7477        );
7478        assert!(
7479            output.interfaces.contains("export interface PlanAccount {"),
7480            "expected PlanAccount interface, got:\n{}",
7481            output.interfaces
7482        );
7483    }
7484
7485    #[test]
7486    fn test_multi_entity_enum_dedup_uses_pascal_case_name_matching() {
7487        let shared_idl = serde_json::json!({
7488            "name": "subscriptions",
7489            "version": "0.1.0",
7490            "accounts": [],
7491            "instructions": [],
7492            "types": [
7493                {
7494                    "name": "planStatus",
7495                    "type": {
7496                        "kind": "enum",
7497                        "variants": [{ "name": "sunset" }, { "name": "active" }]
7498                    }
7499                }
7500            ],
7501            "events": [],
7502            "errors": [],
7503            "discriminant_size": 8
7504        });
7505
7506        let idl_snapshot: IdlSnapshot =
7507            serde_json::from_value(shared_idl).expect("idl snapshot should deserialize");
7508
7509        let make_entity = |name: &str| SerializableStreamSpec {
7510            ast_version: CURRENT_AST_VERSION.to_string(),
7511            state_name: name.to_string(),
7512            program_id: None,
7513            idl: None,
7514            identity: IdentitySpec {
7515                primary_keys: vec!["id.address".to_string()],
7516                lookup_indexes: vec![],
7517            },
7518            handlers: vec![],
7519            sections: vec![EntitySection {
7520                name: "id".to_string(),
7521                fields: vec![FieldTypeInfo::new(
7522                    "address".to_string(),
7523                    "String".to_string(),
7524                )],
7525                is_nested_struct: false,
7526                parent_field: None,
7527            }],
7528            field_mappings: BTreeMap::new(),
7529            resolver_hooks: vec![],
7530            instruction_hooks: vec![],
7531            resolver_specs: vec![],
7532            computed_fields: vec![],
7533            computed_field_specs: vec![],
7534            content_hash: None,
7535            views: vec![],
7536        };
7537
7538        let stack_spec = SerializableStackSpec {
7539            ast_version: CURRENT_AST_VERSION.to_string(),
7540            stack_name: "Subscriptions".to_string(),
7541            program_ids: vec![],
7542            idls: vec![idl_snapshot],
7543            program_specs: vec![],
7544            entities: vec![make_entity("Plan"), make_entity("Subscription")],
7545            pdas: BTreeMap::new(),
7546            instructions: vec![],
7547            content_hash: None,
7548        };
7549
7550        let output =
7551            compile_stack_spec(stack_spec, None).expect("stack compilation should succeed");
7552        let file = output.full_file();
7553        let count = output
7554            .interfaces
7555            .matches("export type PlanStatus =")
7556            .count();
7557
7558        assert_eq!(
7559            count, 1,
7560            "expected shared enum type to be emitted once, got:\n{}",
7561            output.interfaces
7562        );
7563        assert!(
7564            file.contains("_STACK_CORE = {"),
7565            "core export missing:\n{}",
7566            file
7567        );
7568        assert!(
7569            !file.contains("extendStack"),
7570            "no extension wiring expected:\n{}",
7571            file
7572        );
7573    }
7574
7575    #[test]
7576    fn golden_ore_stack_json_compiles_program_modules_without_entities() {
7577        let mut spec = crate::public_artifacts::ore_stack_spec_from_exact_artifacts();
7578
7579        // Program-only emission must not depend on entities at all.
7580        spec.entities.clear();
7581
7582        let output =
7583            compile_program_modules(spec, None).expect("program-module compilation should succeed");
7584        let file = output.full_file();
7585
7586        // Standalone per-program consts plus the combined map.
7587        assert!(
7588            file.contains("export const ORE = {"),
7589            "ore const missing:\n{}",
7590            file
7591        );
7592        assert!(
7593            file.contains("export const ENTROPY = {"),
7594            "entropy const missing"
7595        );
7596        assert!(
7597            file.contains("export const ORE_STREAM_PROGRAMS = {"),
7598            "combined program map missing"
7599        );
7600        assert!(file.contains("  ore: ORE,"));
7601        assert!(file.contains("  entropy: ENTROPY,"));
7602        assert!(file.contains("export default ORE_STREAM_PROGRAMS;"));
7603        assert!(file.contains("All portable programs from the OreStream stack"));
7604        assert!(file.contains("export const ORE_STREAM_PROGRAM_READS = {"));
7605
7606        // Program bodies keep the full SDK surface...
7607        assert!(file.contains("createInstructionHandler"));
7608        assert!(file.contains("pdas: {"));
7609        assert!(file.contains("addresses: {"));
7610        assert!(file.contains("instructions: {"));
7611        assert!(file.contains("createPreparedInstruction({"));
7612        assert!(file.contains("buildInstruction("));
7613
7614        // ...but nothing stack- or view-shaped is emitted.
7615        assert!(!file.contains("stateView"), "no view helpers expected");
7616        assert!(!file.contains("listView"), "no view helpers expected");
7617        assert!(!file.contains("views:"), "no views block expected");
7618        assert!(!file.contains("endpoints:"), "no endpoints block expected");
7619        assert!(
7620            !file.contains("extendStack"),
7621            "no extension wiring expected"
7622        );
7623    }
7624
7625    #[test]
7626    fn golden_ore_stack_json_emits_typed_state_view_keys() {
7627        let spec = crate::public_artifacts::ore_stack_spec_from_exact_artifacts();
7628
7629        let output = compile_stack_spec(spec, None).expect("ore stack should compile");
7630        let stack = output.stack_definition;
7631
7632        assert!(stack.contains(
7633            "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
7634        ));
7635        assert!(stack.contains(
7636            "state: stateView<OreBoard, { address: string }>('OreBoard/state', ['address'])"
7637        ));
7638        assert!(stack.contains(
7639            "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
7640        ));
7641        assert_eq!(
7642            stack.matches(
7643                "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
7644            )
7645            .count(),
7646            1
7647        );
7648    }
7649
7650    #[test]
7651    fn golden_ore_entity_fields_document_wire_names_bigints_and_notes() {
7652        let spec = crate::public_artifacts::ore_stack_spec_from_exact_artifacts();
7653
7654        let references = typescript_entity_references(&spec).expect("ore entities");
7655        let round = references
7656            .iter()
7657            .find(|entity| entity.entity == "OreRound")
7658            .expect("OreRound");
7659        assert_eq!(round.type_name, "OreRound");
7660        assert_eq!(
7661            round.state_key,
7662            Some(("roundId".to_string(), "bigint".to_string()))
7663        );
7664        let round_id = round
7665            .fields
7666            .iter()
7667            .find(|field| field.path == "id.roundId")
7668            .expect("id.roundId");
7669        assert_eq!(round_id.wire, "id.round_id");
7670        assert_eq!(round_id.ts_type, "bigint");
7671        assert!(round_id.nullable);
7672        assert!(round
7673            .fields
7674            .iter()
7675            .any(|field| field.path == "oreMetadata" && field.wire == "ore_metadata"));
7676
7677        let notes = BTreeMap::from([(
7678            "OreRound".to_string(),
7679            BTreeMap::from([(
7680                "state.total_deployed".to_string(),
7681                "Token amount in whole units (raw / 10^9).".to_string(),
7682            )]),
7683        )]);
7684        let output = compile_stack_spec(
7685            spec,
7686            Some(TypeScriptStackConfig {
7687                field_notes: notes,
7688                ..TypeScriptStackConfig::default()
7689            }),
7690        )
7691        .expect("ore stack should compile");
7692        let interfaces = output.interfaces;
7693        // Every field of the generated rows says what the wire calls it and
7694        // why it is a bigint; a note the generator was given follows.
7695        assert!(interfaces.contains(&format!(
7696            "  /** Wire name `id.round_id` (CLI and MCP output). {BIGINT_FIELD_DOC} */\n  roundId: bigint | null;"
7697        )));
7698        assert!(interfaces.contains(
7699            "  /** Wire name `state.total_deployed` (CLI and MCP output). Token amount in whole units (raw / 10^9). */\n  totalDeployed: number | null;"
7700        ));
7701        assert!(interfaces.contains(
7702            "  /** Wire name `ore_metadata` (CLI and MCP output). */\n  oreMetadata: TokenMetadata | null;"
7703        ));
7704        // Same name on the wire, no bigint, no note: no comment.
7705        assert!(interfaces.contains("  id: OreRoundId;\n"));
7706    }
7707
7708    #[test]
7709    fn compile_program_modules_emits_amount_aware_semantic_instruction_wrappers() {
7710        let stack_spec = SerializableStackSpec {
7711            ast_version: CURRENT_AST_VERSION.to_string(),
7712            stack_name: "DemoStream".to_string(),
7713            program_ids: vec!["Prog111".to_string()],
7714            idls: vec![IdlSnapshot {
7715                name: "demo".to_string(),
7716                program_id: Some("Prog111".to_string()),
7717                version: "0.1.0".to_string(),
7718                accounts: vec![],
7719                instructions: vec![IdlInstructionSnapshot {
7720                    name: "deposit".to_string(),
7721                    discriminator: vec![9],
7722                    discriminant: None,
7723                    docs: vec![],
7724                    accounts: vec![],
7725                    args: vec![
7726                        IdlFieldSnapshot {
7727                            name: "amount".to_string(),
7728                            type_: IdlTypeSnapshot::Simple("u64".to_string()),
7729                            amount_hint: None,
7730                        },
7731                        IdlFieldSnapshot {
7732                            name: "mint".to_string(),
7733                            type_: IdlTypeSnapshot::Simple("publicKey".to_string()),
7734                            amount_hint: None,
7735                        },
7736                    ],
7737                }],
7738                types: vec![],
7739                events: vec![],
7740                errors: vec![],
7741                discriminant_size: 1,
7742            }],
7743            program_specs: vec![demo_program_spec()],
7744            entities: vec![],
7745            pdas: BTreeMap::new(),
7746            instructions: vec![InstructionDef {
7747                name: "deposit".to_string(),
7748                discriminator: vec![9],
7749                discriminator_size: 1,
7750                accounts: vec![],
7751                args: vec![
7752                    InstructionArgDef {
7753                        name: "amount".to_string(),
7754                        arg_type: "u64".to_string(),
7755                        docs: vec![],
7756                        amount_hint: Some(InstructionAmountHint {
7757                            decimals_source: AmountDecimalsSource::ArgMint {
7758                                arg_name: "mint".to_string(),
7759                            },
7760                        }),
7761                    },
7762                    InstructionArgDef {
7763                        name: "mint".to_string(),
7764                        arg_type: "solana_pubkey::Pubkey".to_string(),
7765                        docs: vec![],
7766                        amount_hint: None,
7767                    },
7768                ],
7769                errors: vec![],
7770                program_id: Some("Prog111".to_string()),
7771                docs: vec![],
7772            }],
7773            content_hash: None,
7774        };
7775
7776        let output = compile_program_modules(stack_spec, None)
7777            .expect("program-module compilation should succeed");
7778        let file = output.full_file();
7779
7780        assert!(
7781            file.contains("type AmountInput"),
7782            "amount import missing:\n{}",
7783            file
7784        );
7785        assert!(
7786            file.contains("PROGRAM_OPERATION_EXTENSIONS"),
7787            "runtime extension import missing"
7788        );
7789        assert!(
7790            output.imports.contains("type BuildOptions"),
7791            "build options import missing"
7792        );
7793        assert!(
7794            output.imports.contains("type ProgramOperationContext"),
7795            "operation context import missing"
7796        );
7797        assert!(
7798            file.contains("resolveAmountToRaw"),
7799            "amount resolver import missing"
7800        );
7801        assert!(file.contains("export interface DepositSemanticParams"));
7802        assert!(file.contains("build?: BuildOptions;"));
7803        assert!(file.contains("[PROGRAM_OPERATION_EXTENSIONS]: {"));
7804        assert!(file.contains("createOperations(context: ProgramOperationContext)"));
7805        assert!(file
7806            .contains("deposit: instructionOperation(async (params: DepositSemanticParams) => {"));
7807        assert!(file.contains("const { build, amountDecimals, ...rawParams } = params;"));
7808        assert!(file.contains("resolveAmountToRaw(context.chain"));
7809        assert!(file.contains("const instruction = buildInstruction(depositInstruction, {"));
7810        assert!(file.contains("createPreparedInstruction({"));
7811    }
7812
7813    #[test]
7814    fn golden_ore_stack_json_compiles_stack_with_root_helper_namespaces() {
7815        let spec = crate::public_artifacts::ore_stack_spec_from_exact_artifacts();
7816
7817        let output = compile_stack_spec(spec, None).expect("stack compilation should succeed");
7818        let file = output.full_file();
7819
7820        assert!(
7821            file.contains("endpoints:"),
7822            "stack endpoints missing:\n{}",
7823            file
7824        );
7825        assert!(
7826            file.contains("programs: {"),
7827            "program block missing:\n{}",
7828            file
7829        );
7830        assert!(
7831            file.contains("addresses: {"),
7832            "root addresses missing:\n{}",
7833            file
7834        );
7835        assert!(
7836            file.contains("instructions: {"),
7837            "program instructions missing:\n{}",
7838            file
7839        );
7840        assert!(
7841            file.contains("buildInstruction("),
7842            "instruction builders missing:\n{}",
7843            file
7844        );
7845    }
7846
7847    #[test]
7848    fn account_codegen_normalizes_raw_keys_and_nested_types() {
7849        let idl_snapshot = IdlSnapshot {
7850            name: "presale".to_string(),
7851            program_id: None,
7852            version: "0.1.0".to_string(),
7853            accounts: vec![IdlAccountSnapshot {
7854                name: "Presale".to_string(),
7855                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
7856                docs: vec![],
7857                serialization: None,
7858                fields: vec![
7859                    IdlFieldSnapshot {
7860                        name: "owner".to_string(),
7861                        type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
7862                        amount_hint: None,
7863                    },
7864                    IdlFieldSnapshot {
7865                        name: "total_deposit".to_string(),
7866                        type_: IdlTypeSnapshot::Simple("u64".to_string()),
7867                        amount_hint: None,
7868                    },
7869                    IdlFieldSnapshot {
7870                        name: "optional_authority".to_string(),
7871                        type_: IdlTypeSnapshot::Option(IdlOptionTypeSnapshot {
7872                            option: Box::new(IdlTypeSnapshot::Simple("pubkey".to_string())),
7873                        }),
7874                        amount_hint: None,
7875                    },
7876                    IdlFieldSnapshot {
7877                        name: "createKey".to_string(),
7878                        type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
7879                        amount_hint: None,
7880                    },
7881                    IdlFieldSnapshot {
7882                        name: "member".to_string(),
7883                        type_: IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
7884                            defined: IdlDefinedInnerSnapshot::Simple("MemberConfig".to_string()),
7885                        }),
7886                        amount_hint: None,
7887                    },
7888                ],
7889                type_def: None,
7890            }],
7891            instructions: vec![],
7892            types: vec![IdlTypeDefSnapshot {
7893                name: "MemberConfig".to_string(),
7894                docs: vec![],
7895                serialization: None,
7896                type_def: IdlTypeDefKindSnapshot::Struct {
7897                    kind: "struct".to_string(),
7898                    fields: vec![
7899                        IdlFieldSnapshot {
7900                            name: "last_updated_at".to_string(),
7901                            type_: IdlTypeSnapshot::Simple("i128".to_string()),
7902                            amount_hint: None,
7903                        },
7904                        IdlFieldSnapshot {
7905                            name: "authority_key".to_string(),
7906                            type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
7907                            amount_hint: None,
7908                        },
7909                    ],
7910                },
7911            }],
7912            events: vec![],
7913            errors: vec![],
7914            discriminant_size: 8,
7915        };
7916
7917        let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
7918        let account_bigint = bigint_zod();
7919
7920        assert!(artifacts.code.contains("export interface Presale {"));
7921        assert!(
7922            artifacts.code.contains("owner: string;"),
7923            "missing owner field:\n{}",
7924            artifacts.code
7925        );
7926        assert!(
7927            artifacts.code.contains("totalDeposit: bigint;"),
7928            "missing totalDeposit field:\n{}",
7929            artifacts.code
7930        );
7931        assert!(
7932            artifacts.code.contains("optionalAuthority: string | null;"),
7933            "missing optionalAuthority field:\n{}",
7934            artifacts.code
7935        );
7936        assert!(
7937            artifacts.code.contains("createKey: string;"),
7938            "missing createKey field:\n{}",
7939            artifacts.code
7940        );
7941        assert!(
7942            artifacts.code.contains("member: MemberConfig;"),
7943            "missing nested type field:\n{}",
7944            artifacts.code
7945        );
7946        assert!(artifacts.code.contains("export interface MemberConfig {"));
7947        assert!(
7948            artifacts.code.contains("lastUpdatedAt: bigint;"),
7949            "missing nested bigint field:\n{}",
7950            artifacts.code
7951        );
7952        assert!(
7953            artifacts.code.contains("authorityKey: string;"),
7954            "missing nested camelCase field:\n{}",
7955            artifacts.code
7956        );
7957
7958        assert!(artifacts
7959            .code
7960            .contains("export const PresaleSchema = z.object({"));
7961        assert!(
7962            artifacts.code.contains("owner: z.string(),"),
7963            "missing owner schema field:\n{}",
7964            artifacts.code
7965        );
7966        assert!(
7967            artifacts
7968                .code
7969                .contains(&format!("total_deposit: {},", account_bigint)),
7970            "missing total_deposit schema field:\n{}",
7971            artifacts.code
7972        );
7973        assert!(
7974            artifacts
7975                .code
7976                .contains("optional_authority: z.string().nullable(),"),
7977            "missing optional_authority schema field:\n{}",
7978            artifacts.code
7979        );
7980        assert!(
7981            artifacts.code.contains("create_key: z.string(),"),
7982            "missing create_key schema field:\n{}",
7983            artifacts.code
7984        );
7985        assert!(
7986            artifacts
7987                .code
7988                .contains("member: z.lazy(() => MemberConfigSchema),"),
7989            "missing nested schema field:\n{}",
7990            artifacts.code
7991        );
7992        assert!(
7993            artifacts.code.contains("owner: value.owner,"),
7994            "missing owner transform:\n{}",
7995            artifacts.code
7996        );
7997        assert!(
7998            artifacts
7999                .code
8000                .contains("totalDeposit: value.total_deposit,"),
8001            "missing totalDeposit transform:\n{}",
8002            artifacts.code
8003        );
8004        assert!(
8005            artifacts
8006                .code
8007                .contains("optionalAuthority: value.optional_authority,"),
8008            "missing optionalAuthority transform:\n{}",
8009            artifacts.code
8010        );
8011        assert!(
8012            artifacts.code.contains("createKey: value.create_key,"),
8013            "missing createKey transform:\n{}",
8014            artifacts.code
8015        );
8016        assert!(
8017            artifacts.code.contains("member: value.member,"),
8018            "missing nested transform:\n{}",
8019            artifacts.code
8020        );
8021
8022        assert!(artifacts
8023            .code
8024            .contains("export const MemberConfigSchema = z.object({"));
8025        assert!(
8026            artifacts
8027                .code
8028                .contains(&format!("last_updated_at: {},", bigint_zod())),
8029            "missing nested bigint schema field:\n{}",
8030            artifacts.code
8031        );
8032        assert!(
8033            artifacts.code.contains("authority_key: z.string(),"),
8034            "missing nested schema field:\n{}",
8035            artifacts.code
8036        );
8037        assert!(
8038            artifacts
8039                .code
8040                .contains("lastUpdatedAt: value.last_updated_at,"),
8041            "missing nested bigint transform:\n{}",
8042            artifacts.code
8043        );
8044        assert!(
8045            artifacts
8046                .code
8047                .contains("authorityKey: value.authority_key,"),
8048            "missing nested camelCase transform:\n{}",
8049            artifacts.code
8050        );
8051    }
8052
8053    #[test]
8054    fn account_codegen_falls_back_to_same_named_type_def_when_account_fields_are_empty() {
8055        let idl_snapshot = IdlSnapshot {
8056            name: "presale".to_string(),
8057            program_id: None,
8058            version: "0.1.0".to_string(),
8059            accounts: vec![IdlAccountSnapshot {
8060                name: "Presale".to_string(),
8061                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
8062                docs: vec![],
8063                serialization: None,
8064                fields: vec![],
8065                type_def: None,
8066            }],
8067            instructions: vec![],
8068            types: vec![IdlTypeDefSnapshot {
8069                name: "Presale".to_string(),
8070                docs: vec![],
8071                serialization: None,
8072                type_def: IdlTypeDefKindSnapshot::Struct {
8073                    kind: "struct".to_string(),
8074                    fields: vec![
8075                        IdlFieldSnapshot {
8076                            name: "owner".to_string(),
8077                            type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
8078                            amount_hint: None,
8079                        },
8080                        IdlFieldSnapshot {
8081                            name: "total_deposit".to_string(),
8082                            type_: IdlTypeSnapshot::Simple("u64".to_string()),
8083                            amount_hint: None,
8084                        },
8085                    ],
8086                },
8087            }],
8088            events: vec![],
8089            errors: vec![],
8090            discriminant_size: 8,
8091        };
8092
8093        let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
8094
8095        assert!(artifacts.code.contains("export interface Presale {"));
8096        assert!(
8097            artifacts.code.contains("owner: string;"),
8098            "missing owner field:\n{}",
8099            artifacts.code
8100        );
8101        assert!(
8102            artifacts.code.contains("totalDeposit: bigint;"),
8103            "missing totalDeposit field:\n{}",
8104            artifacts.code
8105        );
8106        assert!(
8107            artifacts
8108                .code
8109                .contains("export const PresaleSchema = z.object({"),
8110            "missing schema:\n{}",
8111            artifacts.code
8112        );
8113        assert!(
8114            artifacts.code.contains("owner: z.string(),"),
8115            "missing owner schema field:\n{}",
8116            artifacts.code
8117        );
8118        assert!(
8119            artifacts
8120                .code
8121                .contains(&format!("total_deposit: {},", bigint_zod())),
8122            "missing total_deposit schema field:\n{}",
8123            artifacts.code
8124        );
8125        assert!(
8126            artifacts.code.contains("owner: value.owner,"),
8127            "missing owner transform:\n{}",
8128            artifacts.code
8129        );
8130        assert!(
8131            artifacts
8132                .code
8133                .contains("totalDeposit: value.total_deposit,"),
8134            "missing totalDeposit transform:\n{}",
8135            artifacts.code
8136        );
8137    }
8138
8139    #[test]
8140    fn reserved_idl_type_names_take_a_role_suffix_and_keep_numeric_aliases() {
8141        let field = |name: &str, type_name: &str| IdlFieldSnapshot {
8142            name: name.to_string(),
8143            type_: IdlTypeSnapshot::Simple(type_name.to_string()),
8144            amount_hint: None,
8145        };
8146        let idl_snapshot = IdlSnapshot {
8147            name: "ore".to_string(),
8148            program_id: None,
8149            version: "0.1.0".to_string(),
8150            accounts: vec![
8151                IdlAccountSnapshot {
8152                    name: "Board".to_string(),
8153                    discriminator: vec![1, 0, 0, 0, 0, 0, 0, 0],
8154                    docs: vec![],
8155                    serialization: None,
8156                    fields: vec![field("round_id", "u64")],
8157                    type_def: None,
8158                },
8159                IdlAccountSnapshot {
8160                    name: "Config".to_string(),
8161                    discriminator: vec![2, 0, 0, 0, 0, 0, 0, 0],
8162                    docs: vec![],
8163                    serialization: None,
8164                    fields: vec![IdlFieldSnapshot {
8165                        name: "header".to_string(),
8166                        type_: IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
8167                            defined: IdlDefinedInnerSnapshot::Simple("Header".to_string()),
8168                        }),
8169                        amount_hint: None,
8170                    }],
8171                    type_def: None,
8172                },
8173            ],
8174            instructions: vec![],
8175            types: vec![IdlTypeDefSnapshot {
8176                name: "Header".to_string(),
8177                docs: vec![],
8178                serialization: None,
8179                type_def: IdlTypeDefKindSnapshot::Struct {
8180                    kind: "struct".to_string(),
8181                    fields: vec![field("version", "u8")],
8182                },
8183            }],
8184            events: vec![],
8185            errors: vec![],
8186            discriminant_size: 8,
8187        };
8188        // Stack entity types already use the plain and prefixed names.
8189        let reserved = HashSet::from([
8190            "Board".to_string(),
8191            "OreBoard".to_string(),
8192            "Header".to_string(),
8193            "OreHeader".to_string(),
8194        ]);
8195
8196        let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &reserved);
8197
8198        assert_eq!(
8199            artifacts
8200                .account_type_names
8201                .get(&("ore".to_string(), "Board".to_string()))
8202                .map(String::as_str),
8203            Some("OreBoardAccount")
8204        );
8205        assert_eq!(
8206            artifacts
8207                .account_type_names
8208                .get(&("ore".to_string(), "Config".to_string()))
8209                .map(String::as_str),
8210            Some("Config")
8211        );
8212        let code = &artifacts.code;
8213        assert!(
8214            code.contains("export interface OreBoardAccount {"),
8215            "{code}"
8216        );
8217        assert!(
8218            code.contains("export const OreBoardAccountSchema = z.object({"),
8219            "{code}"
8220        );
8221        assert!(code.contains("export interface OreHeaderType {"), "{code}");
8222        assert!(code.contains("header: OreHeaderType;"), "{code}");
8223        assert!(
8224            code.contains(
8225                "/** @deprecated Use OreBoardAccount. */\nexport type OreBoard2 = OreBoardAccount;\n/** @deprecated Use OreBoardAccountSchema. */\nexport const OreBoard2Schema = OreBoardAccountSchema;"
8226            ),
8227            "{code}"
8228        );
8229        assert!(
8230            code.contains("export type OreHeader2 = OreHeaderType;")
8231                && code.contains("export const OreHeader2Schema = OreHeaderTypeSchema;"),
8232            "{code}"
8233        );
8234        // Aliases follow the schemas they reference.
8235        assert!(
8236            code.find("export const OreBoard2Schema").unwrap()
8237                > code.find("export const OreBoardAccountSchema").unwrap()
8238        );
8239        for name in [
8240            "OreBoardAccount",
8241            "OreBoard2",
8242            "OreHeaderType",
8243            "OreHeader2",
8244        ] {
8245            assert!(artifacts.type_names.contains(name), "{name}");
8246        }
8247        for name in [
8248            "OreBoardAccountSchema",
8249            "OreBoard2Schema",
8250            "OreHeader2Schema",
8251        ] {
8252            assert!(
8253                artifacts.schema_names.iter().any(|schema| schema == name),
8254                "{name}"
8255            );
8256        }
8257
8258        // Without reserved names nothing is renamed or aliased.
8259        let unreserved = generate_idl_account_artifacts(
8260            &[IdlSnapshot {
8261                name: "ore".to_string(),
8262                program_id: None,
8263                version: "0.1.0".to_string(),
8264                accounts: vec![],
8265                instructions: vec![],
8266                types: vec![],
8267                events: vec![],
8268                errors: vec![],
8269                discriminant_size: 8,
8270            }],
8271            &HashSet::new(),
8272        );
8273        assert!(!unreserved.code.contains("@deprecated"));
8274    }
8275
8276    #[test]
8277    fn compile_program_modules_rejects_specs_without_idls() {
8278        let stack_spec = SerializableStackSpec {
8279            ast_version: CURRENT_AST_VERSION.to_string(),
8280            stack_name: "Empty".to_string(),
8281            program_ids: vec![],
8282            idls: vec![],
8283            program_specs: vec![],
8284            entities: vec![],
8285            pdas: BTreeMap::new(),
8286            instructions: vec![],
8287            content_hash: None,
8288        };
8289
8290        let error =
8291            compile_program_modules(stack_spec, None).expect_err("no IDLs should be an error");
8292        assert!(error.contains("no IDLs"), "unexpected error: {}", error);
8293    }
8294}