Skip to main content

ts_gen/parse/
mod.rs

1//! Parser orchestration: parse `.d.ts` files into IR.
2
3pub mod classify;
4pub mod docs;
5pub mod first_pass;
6pub mod members;
7pub mod merge;
8pub mod resolve;
9pub mod scope;
10pub mod types;
11
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15use oxc_allocator::Allocator;
16use oxc_parser::Parser;
17use oxc_span::SourceType;
18
19use crate::context::GlobalContext;
20use crate::ir::Module;
21use crate::parse::scope::ScopeId;
22
23/// Parse one or more `.d.ts` files and produce a `Module` + `GlobalContext`.
24pub fn parse_dts_files(
25    paths: &[impl AsRef<Path>],
26    lib_name: Option<&str>,
27) -> Result<(Module, GlobalContext)> {
28    let mut gctx = GlobalContext::new();
29    let mut input_type_ids = Vec::new();
30    let mut input_file_scopes = Vec::new();
31
32    // Root scope: built-in types (js_sys globals, etc.)
33    let builtin = gctx.create_root_scope();
34    populate_builtin_scope(&mut gctx, builtin);
35
36    // Track which files we've already parsed to avoid cycles
37    let mut parsed_files: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
38    // All file scopes (including deps) for import resolution
39    let mut all_file_scopes = Vec::new();
40    // Map scope → source file parent directory (for relative import resolution)
41    let mut scope_dirs: std::collections::HashMap<ScopeId, PathBuf> =
42        std::collections::HashMap::new();
43
44    for path in paths {
45        let path = path.as_ref();
46        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
47        parsed_files.insert(canonical);
48
49        let source = std::fs::read_to_string(path)
50            .with_context(|| format!("Failed to read {}", path.display()))?;
51
52        let file_scope = gctx.create_child_scope(builtin);
53        input_file_scopes.push(file_scope);
54        all_file_scopes.push(file_scope);
55        if let Some(parent) = path.parent() {
56            scope_dirs.insert(file_scope, parent.to_path_buf());
57        }
58        let type_ids = parse_single_file(&source, path, lib_name, &mut gctx, file_scope)?;
59        input_type_ids.extend(type_ids);
60    }
61
62    // Resolve imports: parse dependency files for scope resolution.
63    resolve_imports(
64        &mut gctx,
65        builtin,
66        &mut all_file_scopes,
67        &mut scope_dirs,
68        &mut parsed_files,
69        lib_name,
70    )?;
71
72    Ok((
73        Module {
74            types: input_type_ids,
75            lib_name: lib_name.map(|s| s.to_string()),
76            builtin_scope: builtin,
77            file_scopes: input_file_scopes,
78        },
79        gctx,
80    ))
81}
82
83/// Parse a single `.d.ts` source string into a `Module` + `GlobalContext`.
84pub fn parse_single_source(
85    source: &str,
86    lib_name: Option<&str>,
87) -> Result<(Module, GlobalContext)> {
88    let mut gctx = GlobalContext::new();
89    let builtin = gctx.create_root_scope();
90    populate_builtin_scope(&mut gctx, builtin);
91    let file_scope = gctx.create_child_scope(builtin);
92    let path = Path::new("<input>");
93    let type_ids = parse_single_file(source, path, lib_name, &mut gctx, file_scope)?;
94    Ok((
95        Module {
96            types: type_ids,
97            lib_name: lib_name.map(|s| s.to_string()),
98            builtin_scope: builtin,
99            file_scopes: vec![file_scope],
100        },
101        gctx,
102    ))
103}
104
105fn parse_single_file(
106    source: &str,
107    path: &Path,
108    lib_name: Option<&str>,
109    gctx: &mut GlobalContext,
110    file_scope: ScopeId,
111) -> Result<Vec<crate::context::TypeId>> {
112    let allocator = Allocator::default();
113    let source_type = SourceType::d_ts();
114
115    gctx.diagnostics.set_file(path, source);
116
117    let parser_return = Parser::new(&allocator, source, source_type).parse();
118
119    if !parser_return.errors.is_empty() {
120        for error in &parser_return.errors {
121            gctx.warn(format!("Parse error in {}: {}", path.display(), error));
122        }
123    }
124
125    let program = &parser_return.program;
126    let doc_comments = docs::DocComments::new(&program.comments, source);
127
128    // Phase 1: Collect type names and populate file scope
129    // Swap out diagnostics temporarily to avoid double-borrowing gctx.
130    let mut diag = std::mem::take(&mut gctx.diagnostics);
131    let registry = first_pass::collect_type_names(program, lib_name, &mut diag, gctx, file_scope);
132    gctx.diagnostics = diag;
133
134    gctx.info(format!(
135        "Collected {} type names from {}",
136        registry.types.len(),
137        path.display()
138    ));
139
140    // Log pending imports found for this file's scope
141    let import_logs: Vec<String> = gctx
142        .pending_imports
143        .iter()
144        .filter(|imp| imp.scope == file_scope)
145        .map(|imp| {
146            format!(
147                "Import: {} from \"{}\" (pending)",
148                imp.local_name, imp.from_module
149            )
150        })
151        .collect();
152    for msg in import_logs {
153        gctx.info(msg);
154    }
155
156    // Phase 2: Populate declarations
157    // Take immutable snapshots before the mutable borrow on diagnostics.
158    let scopes_snapshot = gctx.scopes.clone();
159    let type_arena_snapshot = gctx.type_arena().to_vec();
160    let declarations = first_pass::populate_declarations(
161        program,
162        &registry,
163        lib_name,
164        &doc_comments,
165        &mut gctx.diagnostics,
166        &scopes_snapshot,
167        &type_arena_snapshot,
168        file_scope,
169    );
170
171    // Post-processing: merge, dedup, then insert into the global type arena
172    let merged = merge_class_pairs(declarations);
173    let merged = merge_namespaces(merged);
174    let merged = dedup_function_overloads(merged);
175
176    // Detect script vs module — script files have global declarations.
177    let is_script = !first_pass::is_module(program);
178
179    let type_ids: Vec<crate::context::TypeId> = merged
180        .into_iter()
181        .map(|mut decl| {
182            // Script files: all declarations are implicitly exported (global).
183            if is_script {
184                decl.exported = true;
185            }
186            let name = declaration_name(&decl.kind);
187            let type_id = gctx.insert_type(decl);
188            // Insert/upgrade the type in the scope so codegen can resolve it.
189            if let Some(ref name) = name {
190                gctx.scopes.insert(file_scope, name.clone(), type_id);
191            }
192            // Script files: hoist global declarations to the builtin scope
193            // so they're visible across all files (TypeScript "script" semantics).
194            if is_script {
195                if let Some(parent) = gctx.scopes.get(file_scope).parent {
196                    if let Some(name) = name {
197                        gctx.scopes.insert(parent, name, type_id);
198                    }
199                }
200            }
201            type_id
202        })
203        .collect();
204
205    Ok(type_ids)
206}
207
208/// Deduplicate function overloads: when multiple `FunctionDecl` share the same
209/// name and module context, keep only the one with the most parameters (the most
210/// general signature). TypeScript overloads are a single JS function at runtime.
211fn dedup_function_overloads(
212    declarations: Vec<crate::ir::TypeDeclaration>,
213) -> Vec<crate::ir::TypeDeclaration> {
214    use crate::ir::{ModuleContext, TypeDeclaration, TypeKind};
215
216    // Key: (function name, module context)
217    let mut best: std::collections::HashMap<(String, ModuleContext), usize> =
218        std::collections::HashMap::new();
219    let mut result: Vec<TypeDeclaration> = Vec::new();
220    let mut skip: std::collections::HashSet<usize> = std::collections::HashSet::new();
221
222    // First pass: find the best (most params) overload for each name
223    for (i, decl) in declarations.iter().enumerate() {
224        if let TypeKind::Function(ref f) = decl.kind {
225            let key = (f.name.clone(), decl.module_context.clone());
226            if let Some(&existing_idx) = best.get(&key) {
227                // Compare param counts — keep the one with more params
228                if let TypeKind::Function(ref existing_f) = declarations[existing_idx].kind {
229                    if f.params.len() > existing_f.params.len() {
230                        skip.insert(existing_idx);
231                        best.insert(key, i);
232                    } else {
233                        skip.insert(i);
234                    }
235                }
236            } else {
237                best.insert(key, i);
238            }
239        }
240    }
241
242    for (i, decl) in declarations.into_iter().enumerate() {
243        if !skip.contains(&i) {
244            result.push(decl);
245        }
246    }
247
248    result
249}
250
251/// Resolve unresolved imports by finding and parsing dependency files.
252///
253/// Iterates until no new files are discovered (handles transitive deps).
254fn resolve_imports(
255    gctx: &mut GlobalContext,
256    builtin: ScopeId,
257    file_scopes: &mut Vec<ScopeId>,
258    scope_dirs: &mut std::collections::HashMap<ScopeId, PathBuf>,
259    parsed_files: &mut std::collections::HashSet<PathBuf>,
260    lib_name: Option<&str>,
261) -> Result<()> {
262    // Track modules that we've already tried and failed to resolve,
263    // so we don't re-attempt them on every iteration.
264    let mut failed_modules: std::collections::HashSet<String> = std::collections::HashSet::new();
265
266    loop {
267        // Drain pending imports that haven't been resolved yet.
268        let pending: Vec<scope::PendingImport> = gctx
269            .pending_imports
270            .drain(..)
271            .filter(|p| !failed_modules.contains(&p.from_module))
272            .collect();
273
274        if pending.is_empty() {
275            break;
276        }
277
278        let mut new_files_parsed = false;
279        let mut still_pending = Vec::new();
280
281        for import in pending {
282            // Check if we already have this module registered
283            if let Some(module_id) = gctx.find_module(&import.from_module) {
284                // Look up the name in the target module's scope
285                let target_scope = gctx.get_module(module_id).scope;
286                if let Some(type_id) = gctx.scopes.resolve(target_scope, &import.original_name) {
287                    gctx.scopes
288                        .insert(import.scope, import.local_name.clone(), type_id);
289                } else {
290                    gctx.warn(format!(
291                        "Import `{}` not found in module \"{}\"",
292                        import.original_name, import.from_module
293                    ));
294                }
295                continue;
296            }
297
298            // Try to resolve the module specifier to a file.
299            let base_dir = scope_dirs
300                .get(&import.scope)
301                .cloned()
302                .unwrap_or_else(|| PathBuf::from("."));
303            let resolved_path = resolve::resolve_module(&import.from_module, &base_dir);
304
305            if let Some(path) = resolved_path {
306                let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
307
308                if !parsed_files.contains(&canonical) {
309                    gctx.info(format!(
310                        "Resolving import \"{}\" → {}",
311                        import.from_module,
312                        path.display()
313                    ));
314
315                    match std::fs::read_to_string(&path) {
316                        Ok(source) => {
317                            let dep_scope = gctx.create_child_scope(builtin);
318                            file_scopes.push(dep_scope);
319                            parsed_files.insert(canonical);
320                            if let Some(parent) = path.parent() {
321                                scope_dirs.insert(dep_scope, parent.to_path_buf());
322                            }
323
324                            let module_id =
325                                gctx.register_module(import.from_module.clone(), dep_scope);
326
327                            match parse_single_file(&source, &path, lib_name, gctx, dep_scope) {
328                                Ok(dep_type_ids) => {
329                                    gctx.get_module_mut(module_id).types = dep_type_ids;
330                                    new_files_parsed = true;
331
332                                    // Now resolve the imported name from the dep's scope
333                                    if let Some(type_id) =
334                                        gctx.scopes.resolve(dep_scope, &import.original_name)
335                                    {
336                                        gctx.scopes.insert(
337                                            import.scope,
338                                            import.local_name.clone(),
339                                            type_id,
340                                        );
341                                    } else {
342                                        gctx.warn(format!(
343                                            "Import `{}` not found in \"{}\"",
344                                            import.original_name, import.from_module
345                                        ));
346                                    }
347                                }
348                                Err(e) => {
349                                    gctx.warn(format!(
350                                        "Failed to parse dependency \"{}\" ({}): {e}",
351                                        import.from_module,
352                                        path.display()
353                                    ));
354                                    failed_modules.insert(import.from_module.clone());
355                                }
356                            }
357                        }
358                        Err(e) => {
359                            gctx.warn(format!(
360                                "Failed to read dependency \"{}\" ({}): {e}",
361                                import.from_module,
362                                path.display()
363                            ));
364                            failed_modules.insert(import.from_module.clone());
365                        }
366                    }
367                } else {
368                    // File already parsed but module wasn't found — retry next iteration
369                    still_pending.push(import);
370                }
371            } else {
372                gctx.warn(format!(
373                    "Could not resolve import \"{}\" — use --external to map this type",
374                    import.from_module
375                ));
376                failed_modules.insert(import.from_module.clone());
377            }
378        }
379
380        // Re-add imports that couldn't be resolved yet
381        gctx.pending_imports.extend(still_pending);
382
383        if !new_files_parsed {
384            break;
385        }
386    }
387
388    Ok(())
389}
390
391/// Populate the builtin scope with well-known global JS type names.
392///
393/// These are types that exist in every TypeScript environment without imports:
394/// Array, Map, Set, Promise, Error, Date, typed arrays, etc.
395/// The parser recognizes most of these and maps them to TypeRef variants directly,
396/// but registering them in the builtin scope ensures that scope resolution
397/// correctly identifies them as built-in rather than unresolved.
398fn populate_builtin_scope(gctx: &mut GlobalContext, scope: ScopeId) {
399    // js_sys types: real types available via `use js_sys::*`.
400    // Registered as opaque interfaces — resolve_alias won't look through them.
401    for &name in crate::codegen::typemap::JS_SYS_RESERVED {
402        let type_id = gctx.insert_type(crate::ir::TypeDeclaration {
403            kind: crate::ir::TypeKind::Interface(crate::ir::InterfaceDecl {
404                name: name.to_string(),
405                js_name: name.to_string(),
406                type_params: vec![],
407                extends: vec![],
408                members: vec![],
409                classification: crate::ir::InterfaceClassification::ClassLike,
410            }),
411            module_context: crate::ir::ModuleContext::Global,
412            doc: None,
413            scope_id: scope,
414            exported: false,
415        });
416        gctx.scopes.insert(scope, name.to_string(), type_id);
417    }
418
419    // Web platform built-in types (ReadableStream, Request, etc.).
420    // Registered as opaque interfaces — codegen emits them as bare identifiers.
421    // Users provide actual definitions via --external or input files.
422    for name in &[
423        "ReadableStream",
424        "WritableStream",
425        "TransformStream",
426        "Request",
427        "Response",
428        "Headers",
429        "Blob",
430        "File",
431        "FormData",
432        "URL",
433        "URLSearchParams",
434        "Event",
435        "EventTarget",
436        "AbortController",
437        "AbortSignal",
438        "WebSocket",
439        "Worker",
440        "Crypto",
441        "CryptoKey",
442        "SubtleCrypto",
443        "TextEncoder",
444        "TextDecoder",
445    ] {
446        let type_id = gctx.insert_type(crate::ir::TypeDeclaration {
447            kind: crate::ir::TypeKind::Interface(crate::ir::InterfaceDecl {
448                name: name.to_string(),
449                js_name: name.to_string(),
450                type_params: vec![],
451                extends: vec![],
452                members: vec![],
453                classification: crate::ir::InterfaceClassification::ClassLike,
454            }),
455            module_context: crate::ir::ModuleContext::Global,
456            doc: None,
457            scope_id: scope,
458            exported: false,
459        });
460        gctx.scopes.insert(scope, name.to_string(), type_id);
461    }
462}
463
464/// Merge namespace declarations with the same name into a single `NamespaceDecl`.
465///
466/// TypeScript allows multiple `namespace Foo { ... }` blocks that get merged.
467/// We consolidate them so codegen produces a single `mod foo { ... }`.
468fn merge_namespaces(
469    declarations: Vec<crate::ir::TypeDeclaration>,
470) -> Vec<crate::ir::TypeDeclaration> {
471    use crate::ir::{TypeDeclaration, TypeKind};
472    use std::collections::HashMap;
473
474    let mut ns_map: HashMap<String, usize> = HashMap::new();
475    let mut result: Vec<TypeDeclaration> = Vec::new();
476
477    for decl in declarations {
478        if let TypeKind::Namespace(ref ns_decl) = decl.kind {
479            if let Some(&existing_idx) = ns_map.get(&ns_decl.name) {
480                // Merge into existing namespace
481                if let TypeKind::Namespace(ref mut existing) = result[existing_idx].kind {
482                    existing.declarations.extend(ns_decl.declarations.clone());
483                }
484                continue;
485            }
486            let name = ns_decl.name.clone();
487            let idx = result.len();
488            ns_map.insert(name, idx);
489        }
490        result.push(decl);
491    }
492
493    result
494}
495
496/// Extract the name from a declaration kind, if it has one.
497fn declaration_name(kind: &crate::ir::TypeKind) -> Option<String> {
498    use crate::ir::TypeKind;
499    match kind {
500        TypeKind::Class(c) => Some(c.name.clone()),
501        TypeKind::Interface(i) => Some(i.name.clone()),
502        TypeKind::TypeAlias(a) => Some(a.name.clone()),
503        TypeKind::StringEnum(e) => Some(e.name.clone()),
504        TypeKind::NumericEnum(e) => Some(e.name.clone()),
505        TypeKind::Function(f) => Some(f.name.clone()),
506        TypeKind::Variable(v) => Some(v.name.clone()),
507        TypeKind::Namespace(n) => Some(n.name.clone()),
508    }
509}
510
511/// Merge two member lists, deduplicating by (kind, name).
512/// Members from `incoming` override same-named members in `base`.
513fn merge_members(base: &mut Vec<crate::ir::Member>, incoming: Vec<crate::ir::Member>) {
514    use std::collections::HashMap;
515
516    // Build a map of existing members by key
517    let mut by_key: HashMap<MemberKey, usize> = HashMap::new();
518    for (i, m) in base.iter().enumerate() {
519        by_key.insert(member_key(m), i);
520    }
521
522    for m in incoming {
523        let key = member_key(&m);
524        if let Some(&idx) = by_key.get(&key) {
525            // Override existing
526            base[idx] = m;
527        } else {
528            by_key.insert(key, base.len());
529            base.push(m);
530        }
531    }
532}
533
534/// A lightweight key for member deduplication.
535///
536/// Getters and setters for the same JS property name get distinct keys,
537/// since they are independent bindings that should not overwrite each other.
538#[derive(PartialEq, Eq, Hash)]
539enum MemberKey {
540    Constructor,
541    StaticMethod(String),
542    StaticGetter(String),
543    StaticSetter(String),
544    Proto(String),
545    ProtoGetter(String),
546    ProtoSetter(String),
547}
548
549fn member_key(member: &crate::ir::Member) -> MemberKey {
550    match member {
551        crate::ir::Member::Constructor(_) => MemberKey::Constructor,
552        crate::ir::Member::StaticMethod(m) => MemberKey::StaticMethod(m.name.clone()),
553        crate::ir::Member::StaticGetter(g) => MemberKey::StaticGetter(g.js_name.clone()),
554        crate::ir::Member::StaticSetter(s) => MemberKey::StaticSetter(s.js_name.clone()),
555        crate::ir::Member::Method(m) => MemberKey::Proto(m.name.clone()),
556        crate::ir::Member::Getter(g) => MemberKey::ProtoGetter(g.js_name.clone()),
557        crate::ir::Member::Setter(s) => MemberKey::ProtoSetter(s.js_name.clone()),
558        crate::ir::Member::IndexSignature(_) => MemberKey::Proto("[index]".to_string()),
559    }
560}
561
562/// Merge declarations with the same name:
563/// - Interface + Interface → merge members (TypeScript declaration merging)
564/// - Interface + Class → merge interface members into class (var+interface pattern)
565/// - Class + Class → merge members
566fn merge_class_pairs(
567    declarations: Vec<crate::ir::TypeDeclaration>,
568) -> Vec<crate::ir::TypeDeclaration> {
569    use crate::ir::{TypeDeclaration, TypeKind};
570    use std::collections::HashMap;
571
572    let mut class_map: HashMap<String, usize> = HashMap::new();
573    let mut iface_map: HashMap<String, usize> = HashMap::new();
574    let mut result: Vec<TypeDeclaration> = Vec::new();
575
576    for decl in declarations {
577        match &decl.kind {
578            TypeKind::Class(class_decl) => {
579                let name = class_decl.name.clone();
580
581                // Merge into existing class
582                if let Some(&existing_idx) = class_map.get(&name) {
583                    if let TypeKind::Class(ref mut existing) = result[existing_idx].kind {
584                        merge_members(&mut existing.members, class_decl.members.clone());
585                    }
586                    continue;
587                }
588
589                // Merge into existing interface (promote to class)
590                if let Some(&iface_idx) = iface_map.get(&name) {
591                    // Replace the interface with this class, merging members
592                    let mut new_class = class_decl.clone();
593                    if let TypeKind::Interface(ref iface) = result[iface_idx].kind {
594                        merge_members(&mut new_class.members, iface.members.clone());
595                        if new_class.extends.is_none() {
596                            new_class.extends = iface.extends.first().cloned();
597                        }
598                        if new_class.type_params.is_empty() {
599                            new_class.type_params = iface.type_params.clone();
600                        }
601                    }
602                    result[iface_idx] = TypeDeclaration {
603                        kind: TypeKind::Class(new_class),
604                        module_context: decl.module_context.clone(),
605                        doc: decl.doc.clone(),
606                        scope_id: decl.scope_id,
607                        exported: decl.exported,
608                    };
609                    class_map.insert(name.clone(), iface_idx);
610                    iface_map.remove(&name);
611                    continue;
612                }
613
614                let idx = result.len();
615                class_map.insert(name, idx);
616                result.push(decl);
617            }
618            TypeKind::Interface(iface_decl) => {
619                let name = iface_decl.name.clone();
620
621                // Merge into existing class
622                if let Some(&class_idx) = class_map.get(&name) {
623                    if let TypeKind::Class(ref mut class) = result[class_idx].kind {
624                        merge_members(&mut class.members, iface_decl.members.clone());
625                        if class.extends.is_none() {
626                            class.extends = iface_decl.extends.first().cloned();
627                        }
628                        if class.type_params.is_empty() {
629                            class.type_params = iface_decl.type_params.clone();
630                        }
631                    }
632                    continue;
633                }
634
635                // Merge into existing interface
636                if let Some(&existing_idx) = iface_map.get(&name) {
637                    if let TypeKind::Interface(ref mut existing) = result[existing_idx].kind {
638                        merge_members(&mut existing.members, iface_decl.members.clone());
639                        // Merge extends
640                        for ext in &iface_decl.extends {
641                            if !existing.extends.contains(ext) {
642                                existing.extends.push(ext.clone());
643                            }
644                        }
645                    }
646                    continue;
647                }
648
649                let idx = result.len();
650                iface_map.insert(name, idx);
651                result.push(decl);
652            }
653            _ => {
654                result.push(decl);
655            }
656        }
657    }
658
659    result
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use crate::ir::{GetterMember, Member, MethodMember, TypeRef};
666
667    fn method(name: &str) -> Member {
668        Member::Method(MethodMember {
669            name: name.to_string(),
670            js_name: name.to_string(),
671            type_params: vec![],
672            params: vec![],
673            return_type: TypeRef::Void,
674            optional: false,
675            doc: None,
676        })
677    }
678
679    fn getter(name: &str) -> Member {
680        Member::Getter(GetterMember {
681            js_name: name.to_string(),
682            type_ref: TypeRef::String,
683            optional: false,
684            doc: None,
685        })
686    }
687
688    #[test]
689    fn test_merge_members_dedup() {
690        let mut base = vec![method("read"), method("write"), getter("name")];
691        let incoming = vec![method("write"), method("end")];
692        merge_members(&mut base, incoming);
693
694        // write is overridden (not duplicated), end is appended
695        assert_eq!(base.len(), 4);
696        assert!(matches!(&base[0], Member::Method(m) if m.name == "read"));
697        assert!(matches!(&base[1], Member::Method(m) if m.name == "write"));
698        assert!(matches!(&base[2], Member::Getter(g) if g.js_name == "name"));
699        assert!(matches!(&base[3], Member::Method(m) if m.name == "end"));
700    }
701
702    #[test]
703    fn test_merge_members_no_overlap() {
704        let mut base = vec![method("foo")];
705        let incoming = vec![method("bar")];
706        merge_members(&mut base, incoming);
707        assert_eq!(base.len(), 2);
708    }
709
710    #[test]
711    fn test_merge_members_getter_and_method_coexist() {
712        // ProtoGetter and Proto are different keys — both survive
713        let mut base = vec![getter("data")];
714        let incoming = vec![method("data")];
715        merge_members(&mut base, incoming);
716        assert_eq!(base.len(), 2);
717        assert!(matches!(&base[0], Member::Getter(g) if g.js_name == "data"));
718        assert!(matches!(&base[1], Member::Method(m) if m.name == "data"));
719    }
720}