Skip to main content

pdf_xfa/js_runtime/
host.rs

1//! Phase C host bindings for the sandboxed JavaScript runtime.
2//!
3//! The adapter exposes a narrow, in-process view of the merged Form DOM:
4//! bounded SOM resolution plus `field.rawValue` reads/writes. It never changes
5//! tree structure or layout metadata.
6
7// Copyright (c) 2026 Innovation Trigger B.V.
8//
9// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
10// the PDFluent Commercial Licence. See the LICENSE file in this repository --
11// that file travels with the copy you received, which a URL does not.
12
13use std::collections::{HashMap, HashSet};
14
15use xfa_dom_resolver::data_dom::{DataDom, DataNodeId};
16use xfa_dom_resolver::som::{
17    parse_som, resolve_data_path, SomExpression, SomIndex, SomRoot, SomSelector,
18};
19use xfa_layout_engine::form::{FormNodeId, FormNodeType, FormTree, GroupKind, Presence};
20
21use super::RuntimeMetadata;
22
23/// Maximum successful `rawValue` writes recorded for one document.
24pub const MAX_MUTATIONS_PER_DOC: usize = 4096;
25/// Maximum live instances allowed for one script-managed subform run.
26pub const MAX_INSTANCES_PER_SUBFORM: u32 = 256;
27/// Maximum items allowed in a single runtime-populated listbox.
28pub const MAX_ITEMS_PER_LISTBOX: u32 = 4096;
29/// Maximum SOM resolution calls a single script may perform.
30pub const MAX_RESOLVE_CALLS_PER_SCRIPT: u32 = 1024;
31/// Maximum handles returned from one `xfa.resolveNodes` call.
32pub const MAX_RESOLVE_RESULTS: usize = 256;
33/// Maximum SOM segment depth accepted by the sandbox binding.
34pub const MAX_SOM_DEPTH: usize = 16;
35
36/// One successful `field.rawValue` write.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct MutationLogEntry {
39    /// Mutated form node.
40    pub node_id: FormNodeId,
41    /// Zero-based script invocation index within the current document.
42    pub script_idx: usize,
43    /// Value before the write.
44    pub before: String,
45    /// Value after the write.
46    pub after: String,
47}
48
49/// Host-side state shared by QuickJS binding closures.
50#[derive(Debug)]
51pub struct HostBindings {
52    form: *mut FormTree,
53    root_id: FormNodeId,
54    current_id: Option<FormNodeId>,
55    current_activity: Option<String>,
56    current_script_idx: usize,
57    next_script_idx: usize,
58    generation: u64,
59    mutation_log: Vec<MutationLogEntry>,
60    mutation_count_this_doc: usize,
61    resolve_count_this_script: u32,
62    metadata: RuntimeMetadata,
63    static_page_count: u32,
64    zero_instance_runs: HashMap<(FormNodeId, String), u64>,
65    /// Phase D-γ: read-only pointer to the DataDom for the current document.
66    /// Set from a stack reference in `flatten.rs` that outlives script execution.
67    /// `None` when no data packet is present or the feature is inactive.
68    data_dom: Option<*const DataDom>,
69    /// **D1.B gated allow.** Set per-flatten via
70    /// [`HostBindings::set_presave_gate`]. When true, `write_activity_allowed`
71    /// also accepts `Some("preSave")`. Every other denylist activity
72    /// (`preSubmit`, `click`, …) stays denied.
73    ///
74    /// Reset to `false` on every `reset_per_document` so a previous-document
75    /// gate decision cannot leak across the cross-document isolation boundary
76    /// (§5 of the policy doc).
77    presave_gate: bool,
78    /// D6: captured `occur.min`/`occur.max` write intents `(node, prop, value)`
79    /// recorded during the script pass. Drained by the dispatch path after the
80    /// rollback decision and (only when `XFA_OCCUR_APPLY=1`) applied to the form
81    /// before layout. Cleared per document.
82    captured_occur_mutations: Vec<(FormNodeId, String, i64)>,
83    /// Epic A E-2: SOM resolution misses (capped at 200 entries). Only
84    /// populated when `XFA_RUNTIME_DIAG=1`.
85    som_fail_log: Vec<crate::dynamic::SomFailEntry>,
86    /// Epic A E-3: instanceManager write entries (capped at 200). Only
87    /// populated when `XFA_RUNTIME_DIAG=1`.
88    instance_write_log: Vec<crate::dynamic::InstanceWriteEntry>,
89    /// BE-1 tranche #1 (benign zero-instance SOM): names of structural
90    /// containers (`subform`/`subformSet`/`exclGroup`/`area`) declared by the
91    /// template. Installed per-document by `flatten.rs` via
92    /// [`HostBindings::set_declared_subform_names`] before script execution,
93    /// mirroring `data_dom`. Used by [`HostBindings::is_declared_absent_node`]
94    /// so the sandboxed runtime can return a benign empty-node façade for a
95    /// declared-but-absent SOM reference instead of `undefined` (Adobe
96    /// semantics). Empty when the feature/runtime is inactive.
97    declared_subform_names: HashSet<String>,
98    /// **Phase 2 interactive mode.** Off during static flatten (the default),
99    /// so the corpus-parity path is byte-identical. When the interactive
100    /// commit loop ([`crate::dynamic::apply_interactive_commit`]) sets it,
101    /// `set_presence` is allowed to mutate the tree and `change`/`click`
102    /// activities are accepted for value/presence writes. Never set by the
103    /// flatten pipeline.
104    interactive: bool,
105}
106
107impl Default for HostBindings {
108    fn default() -> Self {
109        Self {
110            form: std::ptr::null_mut(),
111            root_id: FormNodeId(0),
112            current_id: None,
113            current_activity: None,
114            current_script_idx: 0,
115            next_script_idx: 0,
116            generation: 0,
117            mutation_log: Vec::new(),
118            mutation_count_this_doc: 0,
119            resolve_count_this_script: 0,
120            metadata: RuntimeMetadata::default(),
121            static_page_count: 0,
122            zero_instance_runs: HashMap::new(),
123            data_dom: None,
124            presave_gate: false,
125            captured_occur_mutations: Vec::new(),
126            som_fail_log: Vec::new(),
127            instance_write_log: Vec::new(),
128            declared_subform_names: HashSet::new(),
129            interactive: false,
130        }
131    }
132}
133
134impl HostBindings {
135    /// Create empty host-binding state.
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    /// Install or clear the form pointer used by host bindings.
141    pub fn set_form_handle(&mut self, form: *mut FormTree, root_id: FormNodeId) {
142        self.form = form;
143        self.root_id = root_id;
144        if form.is_null() {
145            self.current_id = None;
146            self.current_activity = None;
147            self.current_script_idx = 0;
148            self.zero_instance_runs.clear();
149        }
150    }
151
152    /// Reset counters and invalidate all existing handles for a new document.
153    ///
154    /// Note: `data_dom` is intentionally NOT cleared here. The caller sets it
155    /// explicitly via `set_data_handle` before calling
156    /// `apply_dynamic_scripts_with_runtime`, and `reset_for_new_document` (which
157    /// calls this) runs inside the dispatch function — after `set_data_handle`.
158    /// Clearing it here would wipe the pointer before any scripts execute.
159    /// The caller is responsible for managing DataDom lifetime.
160    pub fn reset_per_document(&mut self) {
161        self.form = std::ptr::null_mut();
162        self.root_id = FormNodeId(0);
163        self.current_id = None;
164        self.current_activity = None;
165        self.current_script_idx = 0;
166        self.next_script_idx = 0;
167        self.generation = self.generation.wrapping_add(1);
168        self.mutation_log.clear();
169        self.mutation_count_this_doc = 0;
170        self.resolve_count_this_script = 0;
171        self.metadata = RuntimeMetadata::default();
172        self.static_page_count = 0;
173        self.zero_instance_runs.clear();
174        self.captured_occur_mutations.clear();
175        // data_dom is NOT reset here — see doc comment above.
176        // D1.B gate is cleared so a previous-document opt-in cannot leak
177        // across the cross-document isolation boundary (§5 of policy v5).
178        // The dispatch path re-installs it via set_presave_gate before any
179        // script runs for the next document.
180        self.presave_gate = false;
181        // Phase 2: interactive mode is likewise per-episode. The interactive
182        // dispatch re-installs it after this reset; the static flatten path
183        // never sets it, so it stays false there.
184        self.interactive = false;
185    }
186
187    /// Phase D-γ: install the DataDom pointer for the current document.
188    /// # Safety
189    /// `dom` must outlive all script execution for this document.
190    pub fn set_data_handle(&mut self, dom: *const DataDom) {
191        self.data_dom = Some(dom);
192    }
193
194    /// BE-1 tranche #1: install the set of template-declared container names for
195    /// the current document. Like `set_data_handle`, the caller installs this
196    /// before script execution; it is intentionally NOT cleared in
197    /// [`HostBindings::reset_per_document`].
198    pub fn set_declared_subform_names(&mut self, names: HashSet<String>) {
199        self.declared_subform_names = names;
200    }
201
202    /// BE-1 tranche #1: true when `name` is a container declared by the template
203    /// (`subform`/`subformSet`/`exclGroup`/`area`). The sandboxed runtime calls
204    /// this only after the implicit scope resolve already returned no nodes, so
205    /// a `true` result means "declared structural node, absent from the current
206    /// scope" → the runtime substitutes a benign empty-node façade (Adobe
207    /// semantics) instead of `undefined`. Returns `false` for any undeclared
208    /// name, preserving the `A.B === undefined` byte-identity contract for
209    /// genuine misses.
210    pub fn is_declared_absent_node(&self, name: &str) -> bool {
211        self.declared_subform_names.contains(name)
212    }
213
214    /// Reset per-script state and install the current event context.
215    pub fn reset_per_script(&mut self, current_id: FormNodeId, activity: Option<&str>) {
216        self.current_id = Some(current_id);
217        self.current_activity = activity.map(str::to_string);
218        self.current_script_idx = self.next_script_idx;
219        self.next_script_idx = self.next_script_idx.saturating_add(1);
220        self.resolve_count_this_script = 0;
221    }
222
223    /// Cache the page count visible to read-only page-count bindings.
224    pub fn set_static_page_count(&mut self, page_count: u32) {
225        self.static_page_count = page_count;
226    }
227
228    /// **D1.B gated allow.** Install the per-flatten `preSave` opt-in. See
229    /// [`super::XfaJsRuntime::set_presave_gate`] for the full contract.
230    ///
231    /// Default false. `reset_per_document` clears it back to false so a
232    /// previous-document decision cannot leak.
233    pub fn set_presave_gate(&mut self, enabled: bool) {
234        self.presave_gate = enabled;
235    }
236
237    /// Read-only view of the current D1.B gate (test helper).
238    #[doc(hidden)]
239    pub fn presave_gate(&self) -> bool {
240        self.presave_gate
241    }
242
243    /// Current handle generation. Handles capture this and are invalid after a
244    /// document reset.
245    pub fn generation(&self) -> u64 {
246        self.generation
247    }
248
249    /// Current script node for `this`.
250    pub fn current_node(&self) -> Option<FormNodeId> {
251        self.current_id
252    }
253
254    /// XFA-DATA-M3C: true when `node_id` is a container (root, subform,
255    /// area, subformSet, exclGroup) — the node types for which the
256    /// underscore-shorthand instanceManager pattern is well-defined per
257    /// XFA 3.3 §6.4.3.2. Fields and draws should return `undefined` for
258    /// `_<NAME>` access rather than an empty-manager sentinel.
259    pub fn node_is_container(&mut self, node_id: FormNodeId, generation: u64) -> bool {
260        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
261        if !self.handle_is_live(node_id, generation) {
262            return false;
263        }
264        let Some(form) = self.form_ref() else {
265            return false;
266        };
267        is_instance_node(&form.get(node_id).node_type)
268    }
269
270    /// XFA 3.3 §5.4 / Adobe SDK: form-tree parent of an arbitrary node.
271    ///
272    /// Used by the JS proxy to materialise the bare global `parent` (and the
273    /// `<handle>.parent` chain segment) without exposing the internal
274    /// parent-map representation to scripts. Returns `None` for the root, for
275    /// stale handles, and when no `FormTree` is installed.
276    pub fn parent_of_node(&mut self, node_id: FormNodeId, generation: u64) -> Option<FormNodeId> {
277        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
278        if !self.handle_is_live(node_id, generation) {
279            return None;
280        }
281        let form = self.form_ref()?;
282        if self.root_id.0 >= form.nodes.len() {
283            return None;
284        }
285        let parents = build_parent_map(form, self.root_id);
286        parents.get(&node_id).copied()
287    }
288
289    /// Read and clear host metadata counters.
290    pub fn take_metadata(&mut self) -> RuntimeMetadata {
291        std::mem::take(&mut self.metadata)
292    }
293
294    /// Epic A E-2/E-3: drain the verbose diagnostic log vectors.
295    pub fn take_diag_logs(&mut self) -> crate::js_runtime::RuntimeDiagLogs {
296        crate::js_runtime::RuntimeDiagLogs {
297            som_fail_log: std::mem::take(&mut self.som_fail_log),
298            instance_write_log: std::mem::take(&mut self.instance_write_log),
299        }
300    }
301
302    /// Mutation log for tests and debug reporting.
303    pub fn mutation_log(&self) -> &[MutationLogEntry] {
304        &self.mutation_log
305    }
306
307    /// Read `field.rawValue`; returns `None` for stale handles, missing nodes,
308    /// and non-field nodes.
309    pub fn get_raw_value(&mut self, node_id: FormNodeId, generation: u64) -> Option<String> {
310        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
311        if !self.handle_is_live(node_id, generation) {
312            return None;
313        }
314        let Some(form) = self.form_ref() else {
315            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
316            return None;
317        };
318        if let FormNodeType::Field { value } = &form.get(node_id).node_type {
319            return Some(value.clone());
320        }
321        // XFA 3.3 §7.2: an exclusive (radio) group's value is the on-value of
322        // whichever member is currently selected. The merger represents such a
323        // group as a container (Subform/ExclGroup) with `group_kind ==
324        // ExclusiveChoice` and stores the selection on the member fields (the
325        // selected one carries its on-value, the rest are cleared), so derive it
326        // here. Interactive-gated: static flatten keeps the historical `None`
327        // for containers (corpus-parity byte-identity).
328        if self.interactive && form.meta(node_id).group_kind == GroupKind::ExclusiveChoice {
329            return form.get(node_id).children.iter().find_map(|&child| {
330                match &form.get(child).node_type {
331                    FormNodeType::Field { value } if !value.is_empty() => Some(value.clone()),
332                    _ => None,
333                }
334            });
335        }
336        None
337    }
338
339    /// Return the `name` attribute of any live node. Used by D-ι.2 to expose
340    /// `subformHandle.variables` as the subform's own variables namespace.
341    pub fn node_name(&self, node_id: FormNodeId, generation: u64) -> Option<String> {
342        if !self.handle_is_live(node_id, generation) {
343            return None;
344        }
345        let form = self.form_ref()?;
346        Some(form.get(node_id).name.clone())
347    }
348
349    /// D5: resolve a `node.occur` handle. Trace-only liveness check that bumps
350    /// the occur-lookup counters; returns whether the node handle is live.
351    pub fn occur_resolve(&mut self, node_id: FormNodeId, generation: u64) -> bool {
352        self.metadata.occur_lookups_total = self.metadata.occur_lookups_total.saturating_add(1);
353        let live = self.handle_is_live(node_id, generation);
354        if live {
355            self.metadata.occur_lookup_successes =
356                self.metadata.occur_lookup_successes.saturating_add(1);
357        } else {
358            self.metadata.occur_lookup_failures =
359                self.metadata.occur_lookup_failures.saturating_add(1);
360        }
361        live
362    }
363
364    /// D5: read an `occur` property (`min`/`max`/`initial`) from the structural
365    /// template. Returns `-1` for unlimited (`max == None`), an unknown
366    /// property, or a dead handle. Bumps `occur_property_reads`.
367    pub fn occur_read(&mut self, node_id: FormNodeId, generation: u64, prop: &str) -> i64 {
368        self.metadata.occur_property_reads = self.metadata.occur_property_reads.saturating_add(1);
369        if !self.handle_is_live(node_id, generation) {
370            return -1;
371        }
372        let Some(form) = self.form_ref() else {
373            return -1;
374        };
375        let occur = &form.get(node_id).occur;
376        match prop {
377            "min" => occur.min as i64,
378            "max" => occur.max.map(|m| m as i64).unwrap_or(-1),
379            "initial" => occur.initial as i64,
380            _ => -1,
381        }
382    }
383
384    /// D5: capture a write to an `occur` property (`min`/`max`). Records the
385    /// mutation **intent** in metadata WITHOUT changing layout/pagination
386    /// (`occur_mutations_applied` is never bumped in D5). Returns `true` so the
387    /// JS assignment is accepted and the script proceeds. `node_id`/`generation`
388    /// are accepted for the next milestone's apply path; D5 does not use them.
389    pub fn occur_capture(
390        &mut self,
391        node_id: FormNodeId,
392        generation: u64,
393        prop: &str,
394        _value: i64,
395    ) -> bool {
396        let _ = generation;
397        self.metadata.occur_property_writes = self.metadata.occur_property_writes.saturating_add(1);
398        match prop {
399            "min" => {
400                self.metadata.occur_min_writes = self.metadata.occur_min_writes.saturating_add(1)
401            }
402            "max" => {
403                self.metadata.occur_max_writes = self.metadata.occur_max_writes.saturating_add(1)
404            }
405            _ => {}
406        }
407        self.metadata.occur_mutations_captured =
408            self.metadata.occur_mutations_captured.saturating_add(1);
409        // D6: record the write intent so the dispatch path can apply it after the
410        // rollback decision (only when XFA_OCCUR_APPLY=1). `occur_mutations_applied`
411        // is bumped by the dispatch apply step, never here (capture-only at host).
412        if prop == "min" || prop == "max" {
413            self.captured_occur_mutations
414                .push((node_id, prop.to_string(), _value));
415        }
416        true
417    }
418
419    /// D6: drain the captured `occur.min`/`occur.max` write intents recorded
420    /// during the script pass. Called once by the dispatch path after the
421    /// rollback decision.
422    pub fn take_occur_mutations(&mut self) -> Vec<(FormNodeId, String, i64)> {
423        std::mem::take(&mut self.captured_occur_mutations)
424    }
425
426    /// Write `field.rawValue` when the activity and target are permitted.
427    pub fn set_raw_value(&mut self, node_id: FormNodeId, value: String, generation: u64) -> bool {
428        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
429        if !self.write_activity_allowed()
430            || !self.handle_is_live(node_id, generation)
431            || self.mutation_count_this_doc >= MAX_MUTATIONS_PER_DOC
432        {
433            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
434            return false;
435        }
436
437        let Some((before, after)) = self.write_field_value(node_id, value) else {
438            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
439            return false;
440        };
441
442        self.mutation_log.push(MutationLogEntry {
443            node_id,
444            script_idx: self.current_script_idx,
445            before,
446            after,
447        });
448        self.metadata.mutations = self.metadata.mutations.saturating_add(1);
449        self.mutation_count_this_doc = self.mutation_count_this_doc.saturating_add(1);
450        true
451    }
452
453    /// Enable or disable Phase 2 interactive mode. Set by the interactive
454    /// commit dispatch after `reset_per_document`; never set by static flatten.
455    pub fn set_interactive(&mut self, enabled: bool) {
456        self.interactive = enabled;
457    }
458
459    /// Whether interactive mode is active (read by the QuickJS `execute_script`
460    /// gate to accept `change`/`click`).
461    pub fn interactive(&self) -> bool {
462        self.interactive
463    }
464
465    /// Write `field.presence` (Phase 2, interactive mode only).
466    ///
467    /// Parses the presence string exactly like
468    /// [`crate::dynamic::set_presence_inner`] (`visible`/`open`/`hidden`/
469    /// `invisible`/`inactive`) and writes `meta.presence`. Gated on interactive
470    /// mode so the static-flatten corpus-parity path can never apply a
471    /// script-driven presence mutation. Returns `true` when the tree changed.
472    pub fn set_presence(&mut self, node_id: FormNodeId, value: String, generation: u64) -> bool {
473        // Static-flatten parity: when not interactive this binding is fully
474        // inert — no metadata is touched, so the corpus path is byte-identical
475        // to the pre-Phase-2 behaviour (presence writes were silently absorbed).
476        // The JS layer also gates the call on `host.isInteractive()`; this is
477        // defence-in-depth.
478        if !self.interactive {
479            return false;
480        }
481        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
482        if !self.write_activity_allowed()
483            || !self.handle_is_live(node_id, generation)
484            || self.mutation_count_this_doc >= MAX_MUTATIONS_PER_DOC
485        {
486            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
487            return false;
488        }
489
490        let new_presence = match value.trim().to_ascii_lowercase().as_str() {
491            "visible" | "open" => Presence::Visible,
492            "hidden" => Presence::Hidden,
493            "invisible" => Presence::Invisible,
494            "inactive" => Presence::Inactive,
495            _ => {
496                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
497                return false;
498            }
499        };
500
501        let Some(form) = self.form_mut() else {
502            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
503            return false;
504        };
505        let meta = form.meta_mut(node_id);
506        if meta.presence == new_presence {
507            return false;
508        }
509        meta.presence = new_presence;
510        self.metadata.mutations = self.metadata.mutations.saturating_add(1);
511        self.mutation_count_this_doc = self.mutation_count_this_doc.saturating_add(1);
512        true
513    }
514
515    /// Read `field.presence` as the XFA keyword string. Defaults to `"visible"`
516    /// for a dead/invalid handle so scripts reading `this.presence` never throw.
517    ///
518    /// Inert when not interactive (the JS layer gates the call on
519    /// `host.isInteractive()`); the early return keeps static-flatten metadata
520    /// byte-identical even if a future caller reaches this directly.
521    pub fn get_presence(&mut self, node_id: FormNodeId, generation: u64) -> String {
522        if !self.interactive {
523            return "visible".to_string();
524        }
525        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
526        if !self.handle_is_live(node_id, generation) {
527            return "visible".to_string();
528        }
529        let Some(form) = self.form_ref() else {
530            return "visible".to_string();
531        };
532        match form.meta(node_id).presence {
533            Presence::Visible => "visible",
534            Presence::Hidden => "hidden",
535            Presence::Invisible => "invisible",
536            Presence::Inactive => "inactive",
537        }
538        .to_string()
539    }
540
541    /// D4 (trace-only): record a SOM lookup outcome at the host resolve
542    /// boundary. `resolved` is true when the lookup matched at least one node.
543    /// On a miss, an `occur`-path reference is additionally classified (it is
544    /// only counted, never resolved or mutated in D4). Pure observability — not
545    /// folded into `is_clean`/rollback.
546    fn note_som(&mut self, resolved: bool, path: &str) {
547        self.metadata.som_lookups_total = self.metadata.som_lookups_total.saturating_add(1);
548        if resolved {
549            self.metadata.som_lookup_successes =
550                self.metadata.som_lookup_successes.saturating_add(1);
551        } else {
552            self.metadata.som_lookup_failures = self.metadata.som_lookup_failures.saturating_add(1);
553            if path == "occur" || path.starts_with("occur.") || path.starts_with("occur[") {
554                self.metadata.som_occur_path_refs =
555                    self.metadata.som_occur_path_refs.saturating_add(1);
556            }
557        }
558    }
559
560    /// Resolve a SOM path to the first field node.
561    pub fn resolve_node(&mut self, path: &str) -> Option<FormNodeId> {
562        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
563        let nodes = match self.resolve_path(path) {
564            ResolveOutcome::Ok(nodes) => {
565                self.note_som(true, path);
566                nodes
567            }
568            ResolveOutcome::NoMatch => {
569                self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
570                self.note_som(false, path);
571                self.debug_log_resolve_miss("resolve_node:NoMatch", path);
572                return None;
573            }
574            ResolveOutcome::BindingError => {
575                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
576                self.debug_log_resolve_miss("resolve_node:BindingError", path);
577                return None;
578            }
579        };
580
581        let Some(form) = self.form_ref() else {
582            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
583            return None;
584        };
585        let found = nodes
586            .into_iter()
587            .find(|node_id| matches!(form.get(*node_id).node_type, FormNodeType::Field { .. }));
588        if found.is_none() {
589            self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
590            self.debug_log_resolve_miss("resolve_node:NoField", path);
591        }
592        found
593    }
594
595    /// Resolve a SOM path to field handles, capped at
596    /// [`MAX_RESOLVE_RESULTS`].
597    pub fn resolve_nodes(&mut self, path: &str) -> Vec<FormNodeId> {
598        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
599        let nodes = match self.resolve_path(path) {
600            ResolveOutcome::Ok(nodes) => nodes,
601            ResolveOutcome::NoMatch => {
602                self.debug_log_resolve_miss("resolve_nodes:NoMatch", path);
603                return Vec::new();
604            }
605            ResolveOutcome::BindingError => {
606                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
607                self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
608                self.debug_log_resolve_miss("resolve_nodes:BindingError", path);
609                return Vec::new();
610            }
611        };
612
613        let Some(form) = self.form_ref() else {
614            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
615            return Vec::new();
616        };
617        nodes
618            .into_iter()
619            .filter(|node_id| matches!(form.get(*node_id).node_type, FormNodeType::Field { .. }))
620            .take(MAX_RESOLVE_RESULTS)
621            .collect()
622    }
623
624    /// Resolve an implicit JavaScript identifier from the current XFA scope.
625    ///
626    /// Adobe's XFA JavaScript environment makes sibling and ancestor-scoped
627    /// SOM nodes visible as bare identifiers. This method searches from the
628    /// supplied current node upward, returning the first descendant with the
629    /// requested name at each scope.
630    pub fn resolve_implicit(&mut self, current_id: FormNodeId, name: &str) -> Option<FormNodeId> {
631        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
632        match self.resolve_implicit_inner(current_id, name) {
633            ResolveOutcome::Ok(nodes) => {
634                self.note_som(true, name);
635                nodes.into_iter().next()
636            }
637            ResolveOutcome::NoMatch => {
638                self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
639                self.note_som(false, name);
640                self.debug_log_resolve_miss("resolve_implicit:NoMatch", name);
641                None
642            }
643            ResolveOutcome::BindingError => {
644                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
645                None
646            }
647        }
648    }
649
650    /// Resolve all viable implicit JavaScript identifier candidates from the
651    /// current XFA scope. The first candidate is identical to the result of
652    /// `resolve_implicit`; later candidates preserve same-name alternatives
653    /// so the JS proxy can filter them when a chained property supplies the
654    /// next SOM segment.
655    pub fn resolve_implicit_candidates(
656        &mut self,
657        current_id: FormNodeId,
658        name: &str,
659    ) -> Vec<FormNodeId> {
660        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
661        match self.resolve_implicit_inner(current_id, name) {
662            ResolveOutcome::Ok(nodes) => {
663                self.note_som(true, name);
664                nodes
665            }
666            ResolveOutcome::NoMatch => {
667                self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
668                self.note_som(false, name);
669                self.debug_log_resolve_miss("resolve_implicit_candidates:NoMatch", name);
670                Vec::new()
671            }
672            ResolveOutcome::BindingError => {
673                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
674                Vec::new()
675            }
676        }
677    }
678
679    /// XFA-DATA-M3C: quiet probe variant of [`resolve_implicit_candidates`].
680    /// Same lookup semantics, but a miss does not bump `resolve_failures`.
681    /// Used by the JS proxy when checking the existence of a bare
682    /// underscore-shorthand global (`_<Name>`) before deciding whether to
683    /// surface an instance-manager sentinel; a miss is the expected
684    /// schema-optional path and would otherwise inflate the metric.
685    pub fn resolve_implicit_candidates_quiet(
686        &mut self,
687        current_id: FormNodeId,
688        name: &str,
689    ) -> Vec<FormNodeId> {
690        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
691        match self.resolve_implicit_inner(current_id, name) {
692            ResolveOutcome::Ok(nodes) => nodes,
693            ResolveOutcome::NoMatch => Vec::new(),
694            ResolveOutcome::BindingError => {
695                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
696                Vec::new()
697            }
698        }
699    }
700
701    /// Resolve a direct child node for chained dotted JavaScript access.
702    pub fn resolve_child(&mut self, parent_id: FormNodeId, name: &str) -> Option<FormNodeId> {
703        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
704        match self.resolve_child_inner(parent_id, name) {
705            ResolveOutcome::Ok(nodes) => {
706                self.note_som(true, name);
707                nodes.into_iter().next()
708            }
709            ResolveOutcome::NoMatch => {
710                self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
711                self.note_som(false, name);
712                self.debug_log_resolve_miss("resolve_child:NoMatch", name);
713                None
714            }
715            ResolveOutcome::BindingError => {
716                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
717                None
718            }
719        }
720    }
721
722    /// Resolve chained child candidates from an ordered parent candidate set.
723    ///
724    /// Direct children are preferred. If none match, this uses the same
725    /// bounded descendant heuristic as the implicit resolver inside each
726    /// parent, which matches the historical permissiveness of XFA SOM dotted
727    /// access without inventing handles when the form structure is absent.
728    pub fn resolve_child_candidates(
729        &mut self,
730        parent_ids: &[FormNodeId],
731        name: &str,
732    ) -> Vec<FormNodeId> {
733        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
734        match self.resolve_child_candidates_inner(parent_ids, name) {
735            ResolveOutcome::Ok(nodes) => {
736                self.note_som(true, name);
737                nodes
738            }
739            ResolveOutcome::NoMatch => {
740                self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
741                self.note_som(false, name);
742                self.debug_log_resolve_miss("resolve_child_candidates:NoMatch", name);
743                Vec::new()
744            }
745            ResolveOutcome::BindingError => {
746                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
747                Vec::new()
748            }
749        }
750    }
751
752    /// XFA-DATA-M3C: quiet variant of [`resolve_child_candidates`] that does
753    /// not bump the `resolve_failures` counter on a no-match. Used by the JS
754    /// proxy when an expected-to-miss probe (e.g. underscore-shorthand for an
755    /// optional schema-bound subform) should not be reported as a script
756    /// failure. Misses still surface to the caller as an empty vector.
757    pub fn resolve_child_candidates_quiet(
758        &mut self,
759        parent_ids: &[FormNodeId],
760        name: &str,
761    ) -> Vec<FormNodeId> {
762        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
763        match self.resolve_child_candidates_inner(parent_ids, name) {
764            ResolveOutcome::Ok(nodes) => nodes,
765            ResolveOutcome::NoMatch => Vec::new(),
766            ResolveOutcome::BindingError => {
767                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
768                Vec::new()
769            }
770        }
771    }
772
773    /// Resolve a property name from each candidate's own implicit scope.
774    ///
775    /// This is the last fallback used by JS-side candidate filtering, covering
776    /// forms that author a later segment as an ancestor-scoped implicit name
777    /// rather than as a direct child of the previous segment.
778    pub fn resolve_scoped_candidates(
779        &mut self,
780        scope_ids: &[FormNodeId],
781        name: &str,
782    ) -> Vec<FormNodeId> {
783        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
784        match self.resolve_scoped_candidates_inner(scope_ids, name) {
785            ResolveOutcome::Ok(nodes) => {
786                self.note_som(true, name);
787                nodes
788            }
789            ResolveOutcome::NoMatch => {
790                self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
791                self.note_som(false, name);
792                self.debug_log_resolve_miss("resolve_scoped_candidates:NoMatch", name);
793                Vec::new()
794            }
795            ResolveOutcome::BindingError => {
796                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
797                Vec::new()
798            }
799        }
800    }
801
802    /// Phase D-θ: implicit-scope resolution with single-segment lookahead.
803    ///
804    /// First runs the standard implicit walk. When `next_hint` is non-empty
805    /// it filters those candidates to ones whose subtree contains the hint
806    /// name; if at least one survives that becomes the result. If the
807    /// nearest-scope candidate set is hint-empty, the search is **widened**:
808    /// every ancestor scope is rescanned for same-name candidates and only
809    /// those satisfying the hint are kept. This is what unlocks chains like
810    /// `F.P1.X.rawValue` where the nearest `F` lacks a `P1.X` descendant but
811    /// an `F` higher in the tree does. When even the widened search finds
812    /// nothing, the un-hinted baseline is returned so single-token reads
813    /// behave identically to [`resolve_implicit_candidates`].
814    pub fn resolve_implicit_candidates_hinted(
815        &mut self,
816        current_id: FormNodeId,
817        name: &str,
818        next_hint: &str,
819    ) -> Vec<FormNodeId> {
820        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
821        let name = name.trim();
822        let hint = next_hint.trim();
823        if name.is_empty() || !self.consume_resolve_call() {
824            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
825            return Vec::new();
826        }
827        let Some(form) = self.form_ref() else {
828            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
829            return Vec::new();
830        };
831        if current_id.0 >= form.nodes.len() || self.root_id.0 >= form.nodes.len() {
832            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
833            return Vec::new();
834        }
835        let parents = build_parent_map(form, self.root_id);
836        let baseline = match resolve_implicit_candidates_in_scope(form, &parents, current_id, name)
837        {
838            ResolveOutcome::Ok(nodes) => nodes,
839            ResolveOutcome::NoMatch => {
840                self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
841                self.debug_log_resolve_miss("resolve_implicit_candidates_hinted:NoMatch", name);
842                return Vec::new();
843            }
844            ResolveOutcome::BindingError => {
845                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
846                return Vec::new();
847            }
848        };
849        if hint.is_empty() {
850            return baseline;
851        }
852        let baseline_hit: Vec<FormNodeId> = baseline
853            .iter()
854            .copied()
855            .filter(|n| subtree_contains_name(form, *n, hint, MAX_SOM_DEPTH))
856            .collect();
857        if !baseline_hit.is_empty() {
858            return baseline_hit;
859        }
860        // Widen: walk every ancestor scope, collect same-name candidates,
861        // keep only those whose subtree contains the hint. Bounded by
862        // MAX_SOM_DEPTH and MAX_RESOLVE_CANDIDATES.
863        let mut widened: Vec<FormNodeId> = Vec::new();
864        let mut scope = Some(current_id);
865        let mut depth = 0usize;
866        while let Some(scope_id) = scope {
867            if depth > MAX_SOM_DEPTH || scope_id.0 >= form.nodes.len() {
868                break;
869            }
870            let mut candidates =
871                collect_named_descendant_candidates(form, scope_id, name, MAX_SOM_DEPTH);
872            order_candidates(form, &mut candidates);
873            for node_id in candidates {
874                if !widened.contains(&node_id)
875                    && subtree_contains_name(form, node_id, hint, MAX_SOM_DEPTH)
876                {
877                    widened.push(node_id);
878                    if widened.len() >= MAX_RESOLVE_CANDIDATES {
879                        return widened;
880                    }
881                }
882            }
883            scope = parents.get(&scope_id).copied();
884            depth += 1;
885        }
886        if widened.is_empty() {
887            baseline
888        } else {
889            widened
890        }
891    }
892
893    /// Phase D-θ: child resolution with single-segment lookahead.
894    ///
895    /// Returns the same candidates as [`resolve_child_candidates`] but,
896    /// when `next_hint` is non-empty, keeps only candidates whose subtree
897    /// contains a node named `next_hint`. Falls back to the un-hinted list
898    /// when the filter would empty the result.
899    pub fn resolve_child_candidates_hinted(
900        &mut self,
901        parent_ids: &[FormNodeId],
902        name: &str,
903        next_hint: &str,
904    ) -> Vec<FormNodeId> {
905        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
906        let candidates = match self.resolve_child_candidates_inner(parent_ids, name) {
907            ResolveOutcome::Ok(nodes) => nodes,
908            ResolveOutcome::NoMatch => {
909                self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
910                return Vec::new();
911            }
912            ResolveOutcome::BindingError => {
913                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
914                return Vec::new();
915            }
916        };
917        self.apply_lookahead_filter(candidates, next_hint)
918    }
919
920    fn apply_lookahead_filter(
921        &self,
922        candidates: Vec<FormNodeId>,
923        next_hint: &str,
924    ) -> Vec<FormNodeId> {
925        let hint = next_hint.trim();
926        if hint.is_empty() || candidates.len() <= 1 {
927            return candidates;
928        }
929        let Some(form) = self.form_ref() else {
930            return candidates;
931        };
932        let filtered: Vec<FormNodeId> = candidates
933            .iter()
934            .copied()
935            .filter(|node_id| subtree_contains_name(form, *node_id, hint, MAX_SOM_DEPTH))
936            .collect();
937        if filtered.is_empty() {
938            candidates
939        } else {
940            filtered
941        }
942    }
943
944    /// Phase D-θ.2: full-chain resolution with backtracking.
945    ///
946    /// Walks `chain` segment-by-segment starting from `parent_ids` (or the
947    /// implicit scope of `implicit_origin` when `parent_ids` is empty), and
948    /// returns the candidate set that resolves the deepest into the chain.
949    ///
950    /// Rules:
951    /// - Entry: `parent_ids` non-empty → child resolution (direct child first,
952    ///   then bounded descendant DFS). `parent_ids` empty → implicit scope
953    ///   walk anchored at `implicit_origin` (mirrors `resolve_implicit_*`).
954    /// - Per step (k ≥ 1): for the running candidate set, prefer direct
955    ///   children whose name matches; if none, fall back to scoped-implicit
956    ///   resolution inside each candidate. Drop candidates that cannot
957    ///   advance and keep the survivors as the next layer.
958    /// - When the chain runs out of survivors mid-way and `strict` is
959    ///   false, the function returns the layer reached at the previous step
960    ///   (best-effort) so callers resolving a terminal property can still
961    ///   degrade gracefully. When `strict` is true, dead-end chains return
962    ///   an empty vec — used by the JS chain proxy to decide whether to
963    ///   expose a proxy for `A.B` when `A.B` cannot be reached at all.
964    /// - When `chain` is empty, the function returns the entry layer as-is.
965    /// - Costs exactly one `consume_resolve_call` slot for the whole chain so
966    ///   long terminal chains do not exhaust per-script budgets faster than
967    ///   the existing single-hop hinted resolvers.
968    /// - Returns at most [`MAX_RESOLVE_CANDIDATES`] candidates per layer.
969    pub fn resolve_with_full_chain(
970        &mut self,
971        parent_ids: &[FormNodeId],
972        chain: &[String],
973        implicit_origin: Option<FormNodeId>,
974    ) -> Vec<FormNodeId> {
975        self.resolve_with_full_chain_impl(parent_ids, chain, implicit_origin, false)
976    }
977
978    /// Phase D-θ.2: strict variant — return empty when the chain cannot be
979    /// completed at full depth. See [`resolve_with_full_chain`] for the
980    /// non-strict (best-effort) semantics.
981    pub fn resolve_with_full_chain_strict(
982        &mut self,
983        parent_ids: &[FormNodeId],
984        chain: &[String],
985        implicit_origin: Option<FormNodeId>,
986    ) -> Vec<FormNodeId> {
987        self.resolve_with_full_chain_impl(parent_ids, chain, implicit_origin, true)
988    }
989
990    fn resolve_with_full_chain_impl(
991        &mut self,
992        parent_ids: &[FormNodeId],
993        chain: &[String],
994        implicit_origin: Option<FormNodeId>,
995        strict: bool,
996    ) -> Vec<FormNodeId> {
997        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
998        if !self.consume_resolve_call() {
999            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1000            return Vec::new();
1001        }
1002        let Some(form) = self.form_ref() else {
1003            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1004            return Vec::new();
1005        };
1006        if self.root_id.0 >= form.nodes.len() {
1007            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1008            return Vec::new();
1009        }
1010        if parent_ids
1011            .iter()
1012            .any(|node_id| node_id.0 >= form.nodes.len())
1013        {
1014            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1015            return Vec::new();
1016        }
1017        if chain.is_empty() {
1018            return parent_ids.to_vec();
1019        }
1020        if chain.len() > MAX_SOM_DEPTH {
1021            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1022            return Vec::new();
1023        }
1024        for seg in chain {
1025            if seg.trim().is_empty() {
1026                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1027                return Vec::new();
1028            }
1029        }
1030
1031        let parents = build_parent_map(form, self.root_id);
1032
1033        // Step 0: entry layer.
1034        let entry_name = chain[0].trim();
1035        let initial: Vec<FormNodeId> = if parent_ids.is_empty() {
1036            let Some(origin) = implicit_origin else {
1037                return Vec::new();
1038            };
1039            if origin.0 >= form.nodes.len() {
1040                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1041                return Vec::new();
1042            }
1043            match resolve_implicit_candidates_in_scope(form, &parents, origin, entry_name) {
1044                ResolveOutcome::Ok(nodes) => nodes,
1045                ResolveOutcome::NoMatch => return Vec::new(),
1046                ResolveOutcome::BindingError => {
1047                    self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1048                    return Vec::new();
1049                }
1050            }
1051        } else {
1052            // Entry from a known parent set: prefer direct children, fall back
1053            // to bounded descendant DFS — same shape as
1054            // `resolve_child_candidates_inner` but inline to avoid double
1055            // resolve-call accounting.
1056            let mut direct = Vec::new();
1057            for &parent_id in parent_ids {
1058                for &child_id in &form.get(parent_id).children {
1059                    if form.get(child_id).name == entry_name {
1060                        push_unique_candidate(&mut direct, child_id);
1061                        if direct.len() >= MAX_RESOLVE_CANDIDATES {
1062                            break;
1063                        }
1064                    }
1065                }
1066                if direct.len() >= MAX_RESOLVE_CANDIDATES {
1067                    break;
1068                }
1069            }
1070            if !direct.is_empty() {
1071                direct
1072            } else {
1073                let mut descendants = Vec::new();
1074                for &parent_id in parent_ids {
1075                    let mut local = collect_named_descendant_candidates(
1076                        form,
1077                        parent_id,
1078                        entry_name,
1079                        MAX_SOM_DEPTH,
1080                    );
1081                    order_candidates(form, &mut local);
1082                    for node_id in local {
1083                        push_unique_candidate(&mut descendants, node_id);
1084                        if descendants.len() >= MAX_RESOLVE_CANDIDATES {
1085                            break;
1086                        }
1087                    }
1088                    if descendants.len() >= MAX_RESOLVE_CANDIDATES {
1089                        break;
1090                    }
1091                }
1092                descendants
1093            }
1094        };
1095
1096        if initial.is_empty() {
1097            return Vec::new();
1098        }
1099
1100        // Walk the remaining segments. At each step, advance every candidate
1101        // by its named child/scoped-implicit, keeping the survivors. If no
1102        // candidate advances, return the previous layer (best-effort).
1103        let mut layer = initial;
1104        for seg in chain.iter().skip(1) {
1105            let seg_name = seg.trim();
1106            let mut next_layer: Vec<FormNodeId> = Vec::new();
1107            for &node_id in &layer {
1108                // Direct children first.
1109                let mut found_any = false;
1110                for &child_id in &form.get(node_id).children {
1111                    if form.get(child_id).name == seg_name {
1112                        push_unique_candidate(&mut next_layer, child_id);
1113                        found_any = true;
1114                        if next_layer.len() >= MAX_RESOLVE_CANDIDATES {
1115                            break;
1116                        }
1117                    }
1118                }
1119                if !found_any {
1120                    // Scoped-implicit fallback within this subtree only — same
1121                    // behaviour as `resolve_scoped_candidates` per-anchor pass.
1122                    if let ResolveOutcome::Ok(scoped) =
1123                        resolve_implicit_candidates_in_scope(form, &parents, node_id, seg_name)
1124                    {
1125                        for cand in scoped {
1126                            push_unique_candidate(&mut next_layer, cand);
1127                            if next_layer.len() >= MAX_RESOLVE_CANDIDATES {
1128                                break;
1129                            }
1130                        }
1131                    }
1132                }
1133                if next_layer.len() >= MAX_RESOLVE_CANDIDATES {
1134                    break;
1135                }
1136            }
1137            if next_layer.is_empty() {
1138                // Dead end: in strict mode, return empty — the chain cannot
1139                // be completed and the JS proxy must surface `undefined`.
1140                // In non-strict mode, return the previous layer untouched
1141                // so terminal-property access still has something to read
1142                // off (graceful degradation, mirrors D-θ.1's
1143                // `apply_lookahead_filter`).
1144                if strict {
1145                    return Vec::new();
1146                }
1147                return layer;
1148            }
1149            layer = next_layer;
1150        }
1151        layer
1152    }
1153
1154    /// Count live sibling instances with the same name as `parent_id`.
1155    pub fn instance_count(&mut self, parent_id: FormNodeId) -> u32 {
1156        self.instance_count_inner(parent_id, None)
1157    }
1158
1159    /// Count live sibling instances for a JS handle with generation checking.
1160    pub fn instance_count_for_handle(&mut self, parent_id: FormNodeId, generation: u64) -> u32 {
1161        self.instance_count_inner(parent_id, Some(generation))
1162    }
1163
1164    /// Return the zero-based sibling index among instances with the same name.
1165    pub fn instance_index(&mut self, node_id: FormNodeId) -> u32 {
1166        self.instance_index_inner(node_id, None)
1167    }
1168
1169    /// Return the zero-based sibling index for a JS handle.
1170    pub fn instance_index_for_handle(&mut self, node_id: FormNodeId, generation: u64) -> u32 {
1171        self.instance_index_inner(node_id, Some(generation))
1172    }
1173
1174    /// Whether `parent_id._name` refers to an instance run that was explicitly
1175    /// set to zero during this document. This lets the JS shorthand return a
1176    /// read-only empty manager only for a real prior instance run, while
1177    /// keeping unrelated private-looking `_id` properties hidden.
1178    pub fn has_zero_instance_run(
1179        &mut self,
1180        parent_id: FormNodeId,
1181        generation: u64,
1182        name: &str,
1183    ) -> bool {
1184        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1185        let name = name.trim();
1186        if name.is_empty() || !self.consume_resolve_call() {
1187            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1188            return false;
1189        }
1190        let Some(form) = self.form_ref() else {
1191            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1192            return false;
1193        };
1194        if generation != self.generation || parent_id.0 >= form.nodes.len() {
1195            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1196            return false;
1197        }
1198        self.zero_instance_runs
1199            .contains_key(&(parent_id, name.to_string()))
1200    }
1201
1202    /// Replace the live same-name sibling run with exactly `n` instances,
1203    /// clamped to the prototype's occur limits and the sandbox safety cap.
1204    #[allow(clippy::result_unit_err)]
1205    pub fn instance_set(&mut self, parent_id: FormNodeId, n: u32) -> Result<u32, ()> {
1206        self.instance_set_inner(parent_id, None, n)
1207    }
1208
1209    /// Generation-checked variant used by the QuickJS bridge.
1210    #[allow(clippy::result_unit_err)]
1211    pub fn instance_set_for_handle(
1212        &mut self,
1213        parent_id: FormNodeId,
1214        generation: u64,
1215        n: u32,
1216    ) -> Result<u32, ()> {
1217        self.instance_set_inner(parent_id, Some(generation), n)
1218    }
1219
1220    /// Append one cloned instance to the end of the live same-name sibling run.
1221    #[allow(clippy::result_unit_err)]
1222    pub fn instance_add(&mut self, parent_id: FormNodeId) -> Result<FormNodeId, ()> {
1223        self.instance_add_inner(parent_id, None)
1224    }
1225
1226    /// Generation-checked variant used by the QuickJS bridge.
1227    #[allow(clippy::result_unit_err)]
1228    pub fn instance_add_for_handle(
1229        &mut self,
1230        parent_id: FormNodeId,
1231        generation: u64,
1232    ) -> Result<FormNodeId, ()> {
1233        self.instance_add_inner(parent_id, Some(generation))
1234    }
1235
1236    /// Remove one live same-name sibling instance by zero-based index.
1237    #[allow(clippy::result_unit_err)]
1238    pub fn instance_remove(&mut self, parent_id: FormNodeId, index: u32) -> Result<(), ()> {
1239        self.instance_remove_inner(parent_id, None, index)
1240    }
1241
1242    /// Generation-checked variant used by the QuickJS bridge.
1243    #[allow(clippy::result_unit_err)]
1244    pub fn instance_remove_for_handle(
1245        &mut self,
1246        parent_id: FormNodeId,
1247        generation: u64,
1248        index: u32,
1249    ) -> Result<(), ()> {
1250        self.instance_remove_inner(parent_id, Some(generation), index)
1251    }
1252
1253    /// Clear all runtime-populated listbox items on a field.
1254    #[allow(clippy::result_unit_err)]
1255    pub fn list_clear(&mut self, field_id: FormNodeId) -> Result<(), ()> {
1256        self.list_clear_inner(field_id, None)
1257    }
1258
1259    /// Generation-checked variant used by the QuickJS bridge.
1260    #[allow(clippy::result_unit_err)]
1261    pub fn list_clear_for_handle(
1262        &mut self,
1263        field_id: FormNodeId,
1264        generation: u64,
1265    ) -> Result<(), ()> {
1266        self.list_clear_inner(field_id, Some(generation))
1267    }
1268
1269    /// Append one item to a field's runtime listbox options.
1270    #[allow(clippy::result_unit_err)]
1271    pub fn list_add(
1272        &mut self,
1273        field_id: FormNodeId,
1274        display: String,
1275        save: Option<String>,
1276    ) -> Result<(), ()> {
1277        self.list_add_inner(field_id, None, display, save)
1278    }
1279
1280    /// Generation-checked variant used by the QuickJS bridge.
1281    #[allow(clippy::result_unit_err)]
1282    pub fn list_add_for_handle(
1283        &mut self,
1284        field_id: FormNodeId,
1285        generation: u64,
1286        display: String,
1287        save: Option<String>,
1288    ) -> Result<(), ()> {
1289        self.list_add_inner(field_id, Some(generation), display, save)
1290    }
1291
1292    /// XFA 3.3 §App A `boundItem` — listbox display→save lookup.
1293    ///
1294    /// Returns the save value associated with `display_value` for a listbox
1295    /// or dropdown field. Lookup order:
1296    /// 1. Runtime listbox items populated via D-β `addItem` (matched first).
1297    /// 2. Static `<items>` parsed from the template at merge time.
1298    ///
1299    /// Adobe's documented behaviour returns the input unchanged when no
1300    /// match exists (passthrough). Empty input returns empty string. Stale
1301    /// or non-field handles return the input unchanged.
1302    pub fn bound_item_for_handle(
1303        &mut self,
1304        field_id: FormNodeId,
1305        generation: u64,
1306        display_value: String,
1307    ) -> String {
1308        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1309        if !self.handle_is_live(field_id, generation) {
1310            return display_value;
1311        }
1312        let Some(form) = self.form_ref() else {
1313            return display_value;
1314        };
1315        if !matches!(form.get(field_id).node_type, FormNodeType::Field { .. }) {
1316            return display_value;
1317        }
1318        let meta = form.meta(field_id);
1319        for (display, save) in &meta.runtime_listbox_items {
1320            if display == &display_value {
1321                return save.clone();
1322            }
1323        }
1324        for (idx, display) in meta.display_items.iter().enumerate() {
1325            if display == &display_value {
1326                return meta
1327                    .save_items
1328                    .get(idx)
1329                    .cloned()
1330                    .unwrap_or_else(|| display_value.clone());
1331            }
1332        }
1333        display_value
1334    }
1335
1336    /// Read-only static page count visible to Phase C scripts.
1337    pub fn num_pages(&mut self) -> u32 {
1338        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1339        self.static_page_count
1340    }
1341
1342    /// Record a binding-level failure for explicit no-op stubs.
1343    pub fn metadata_binding_error(&mut self) {
1344        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1345        self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1346    }
1347
1348    /// Record use of an intentionally approximate read-only stub.
1349    pub fn metadata_resolve_failure(&mut self) {
1350        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1351        self.metadata.resolve_failures = self.metadata.resolve_failures.saturating_add(1);
1352    }
1353
1354    /// XFA-JS-HOST-STUBS — Record a call into a host capability that
1355    /// requires genuine viewer / user interaction (UI dialog, signature
1356    /// panel, document submit, etc.). The sandbox returned a safe default
1357    /// value to keep the script running; this counter exists so the dispatch
1358    /// site can surface a "would-have-been-interactive" signal without
1359    /// inflating `runtime_errors`.
1360    pub fn metadata_unsupported_host_call(&mut self) {
1361        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1362        self.metadata.unsupported_host_calls =
1363            self.metadata.unsupported_host_calls.saturating_add(1);
1364    }
1365
1366    /// Record a D-θ.2 probe call that was skipped because
1367    /// `parentIds.length == 1 && chain.length == 1` (no same-name
1368    /// ambiguity is possible with a single parent and a single-segment
1369    /// chain — the host would return the same eagerIds regardless).
1370    pub fn metadata_probe_skip(&mut self) {
1371        self.metadata.probe_skips = self.metadata.probe_skips.saturating_add(1);
1372    }
1373
1374    /// BE-1: Record a successful `$data` bare-global intercept.
1375    pub fn metadata_som_data_root_hit(&mut self) {
1376        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1377        self.metadata.som_data_root_hits = self.metadata.som_data_root_hits.saturating_add(1);
1378    }
1379
1380    /// BE-1: Record a successful `#items` property access (non-empty list returned).
1381    pub fn metadata_som_items_path_hit(&mut self) {
1382        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1383        self.metadata.som_items_path_hits = self.metadata.som_items_path_hits.saturating_add(1);
1384    }
1385
1386    fn consume_resolve_call(&mut self) -> bool {
1387        if self.resolve_count_this_script >= MAX_RESOLVE_CALLS_PER_SCRIPT {
1388            return false;
1389        }
1390        self.resolve_count_this_script = self.resolve_count_this_script.saturating_add(1);
1391        true
1392    }
1393
1394    fn resolve_path(&mut self, path: &str) -> ResolveOutcome {
1395        if !self.consume_resolve_call() {
1396            return ResolveOutcome::BindingError;
1397        }
1398
1399        let Some(form) = self.form_ref() else {
1400            return ResolveOutcome::BindingError;
1401        };
1402        let Some(current_id) = self.current_id else {
1403            return ResolveOutcome::BindingError;
1404        };
1405        if current_id.0 >= form.nodes.len() || self.root_id.0 >= form.nodes.len() {
1406            return ResolveOutcome::BindingError;
1407        }
1408
1409        let normalized = normalize_resolve_path(path.trim());
1410        let mut expr = match parse_som(&normalized) {
1411            Ok(expr) => expr,
1412            Err(_) => return ResolveOutcome::BindingError,
1413        };
1414        if expr.segments.len() > MAX_SOM_DEPTH {
1415            return ResolveOutcome::BindingError;
1416        }
1417        if matches!(
1418            expr.segments.last().map(|segment| &segment.selector),
1419            Some(SomSelector::Name(name)) if name == "rawValue"
1420        ) {
1421            expr.segments.pop();
1422        }
1423
1424        let parents = build_parent_map(form, self.root_id);
1425        let resolver = HostSomResolver {
1426            form,
1427            root_id: self.root_id,
1428            parents: &parents,
1429            current_id,
1430        };
1431        match resolver.resolve_expression(&expr) {
1432            Some(nodes) if !nodes.is_empty() => ResolveOutcome::Ok(nodes),
1433            _ => ResolveOutcome::NoMatch,
1434        }
1435    }
1436
1437    fn resolve_implicit_inner(&mut self, current_id: FormNodeId, name: &str) -> ResolveOutcome {
1438        let name = name.trim();
1439        if name.is_empty() || !self.consume_resolve_call() {
1440            return ResolveOutcome::BindingError;
1441        }
1442
1443        let Some(form) = self.form_ref() else {
1444            return ResolveOutcome::BindingError;
1445        };
1446        if current_id.0 >= form.nodes.len() || self.root_id.0 >= form.nodes.len() {
1447            return ResolveOutcome::BindingError;
1448        }
1449
1450        let parents = build_parent_map(form, self.root_id);
1451        resolve_implicit_candidates_in_scope(form, &parents, current_id, name)
1452    }
1453
1454    fn resolve_child_inner(&mut self, parent_id: FormNodeId, name: &str) -> ResolveOutcome {
1455        self.resolve_child_candidates_inner(&[parent_id], name)
1456    }
1457
1458    fn resolve_child_candidates_inner(
1459        &mut self,
1460        parent_ids: &[FormNodeId],
1461        name: &str,
1462    ) -> ResolveOutcome {
1463        let name = name.trim();
1464        if name.is_empty() || !self.consume_resolve_call() {
1465            return ResolveOutcome::BindingError;
1466        }
1467
1468        let Some(form) = self.form_ref() else {
1469            return ResolveOutcome::BindingError;
1470        };
1471        if parent_ids.is_empty()
1472            || parent_ids
1473                .iter()
1474                .any(|node_id| node_id.0 >= form.nodes.len())
1475        {
1476            return ResolveOutcome::BindingError;
1477        }
1478
1479        let mut direct = Vec::new();
1480        for &parent_id in parent_ids {
1481            for &child_id in &form.get(parent_id).children {
1482                if form.get(child_id).name == name {
1483                    push_unique_candidate(&mut direct, child_id);
1484                    if direct.len() >= MAX_RESOLVE_CANDIDATES {
1485                        return ResolveOutcome::Ok(direct);
1486                    }
1487                }
1488            }
1489        }
1490        if !direct.is_empty() {
1491            return ResolveOutcome::Ok(direct);
1492        }
1493
1494        let mut descendants = Vec::new();
1495        for &parent_id in parent_ids {
1496            let mut local =
1497                collect_named_descendant_candidates(form, parent_id, name, MAX_SOM_DEPTH);
1498            order_candidates(form, &mut local);
1499            for node_id in local {
1500                push_unique_candidate(&mut descendants, node_id);
1501                if descendants.len() >= MAX_RESOLVE_CANDIDATES {
1502                    return ResolveOutcome::Ok(descendants);
1503                }
1504            }
1505        }
1506        if descendants.is_empty() {
1507            ResolveOutcome::NoMatch
1508        } else {
1509            ResolveOutcome::Ok(descendants)
1510        }
1511    }
1512
1513    fn resolve_scoped_candidates_inner(
1514        &mut self,
1515        scope_ids: &[FormNodeId],
1516        name: &str,
1517    ) -> ResolveOutcome {
1518        let name = name.trim();
1519        if name.is_empty() || !self.consume_resolve_call() {
1520            return ResolveOutcome::BindingError;
1521        }
1522
1523        let Some(form) = self.form_ref() else {
1524            return ResolveOutcome::BindingError;
1525        };
1526        if scope_ids.is_empty()
1527            || self.root_id.0 >= form.nodes.len()
1528            || scope_ids
1529                .iter()
1530                .any(|node_id| node_id.0 >= form.nodes.len())
1531        {
1532            return ResolveOutcome::BindingError;
1533        }
1534
1535        let parents = build_parent_map(form, self.root_id);
1536        let mut out = Vec::new();
1537        for &scope_id in scope_ids {
1538            if let ResolveOutcome::Ok(nodes) =
1539                resolve_implicit_candidates_in_scope(form, &parents, scope_id, name)
1540            {
1541                for node_id in nodes {
1542                    push_unique_candidate(&mut out, node_id);
1543                    if out.len() >= MAX_RESOLVE_CANDIDATES {
1544                        return ResolveOutcome::Ok(out);
1545                    }
1546                }
1547            }
1548        }
1549        if out.is_empty() {
1550            ResolveOutcome::NoMatch
1551        } else {
1552            ResolveOutcome::Ok(out)
1553        }
1554    }
1555
1556    fn instance_count_inner(&mut self, parent_id: FormNodeId, generation: Option<u64>) -> u32 {
1557        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1558        let Some(run) = self.read_instance_run(parent_id, generation) else {
1559            return 0;
1560        };
1561        run.nodes.len() as u32
1562    }
1563
1564    fn instance_index_inner(&mut self, node_id: FormNodeId, generation: Option<u64>) -> u32 {
1565        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1566        let Some(run) = self.read_instance_run(node_id, generation) else {
1567            return 0;
1568        };
1569        run.nodes
1570            .iter()
1571            .position(|candidate| *candidate == node_id)
1572            .unwrap_or(0) as u32
1573    }
1574
1575    fn instance_set_inner(
1576        &mut self,
1577        parent_id: FormNodeId,
1578        generation: Option<u64>,
1579        n: u32,
1580    ) -> Result<u32, ()> {
1581        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1582        if !self.write_activity_allowed()
1583            || self.mutation_count_this_doc >= MAX_MUTATIONS_PER_DOC
1584            || !self.consume_resolve_call()
1585        {
1586            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1587            return Err(());
1588        }
1589
1590        let Some(run) = self.live_instance_run(parent_id, generation) else {
1591            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1592            return Err(());
1593        };
1594        let Some(target_count) = self.clamped_instance_count(run.prototype_id, n) else {
1595            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1596            return Err(());
1597        };
1598
1599        let target_count = target_count as usize;
1600        let prototype_id = run.prototype_id;
1601        let parent_id = run.parent_id;
1602        let first_pos = run.first_position;
1603        let Some(prototype_name) = self
1604            .form_ref()
1605            .and_then(|form| form.nodes.get(prototype_id.0))
1606            .map(|node| node.name.clone())
1607        else {
1608            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1609            return Err(());
1610        };
1611        let remove_ids = run.nodes;
1612        let old_count_set = remove_ids.len();
1613        // E-3: capture parent name before taking form_mut.
1614        let parent_name_set = self
1615            .form_ref()
1616            .and_then(|form| form.nodes.get(parent_id.0))
1617            .map(|n| n.name.clone())
1618            .unwrap_or_default();
1619
1620        let mut new_ids = Vec::with_capacity(target_count);
1621        if target_count > 0 {
1622            self.normalize_instance_occurrence(prototype_id);
1623            new_ids.push(prototype_id);
1624            for _ in 1..target_count {
1625                let cloned_id = self.clone_subtree(prototype_id)?;
1626                new_ids.push(cloned_id);
1627            }
1628        }
1629
1630        let Some(form) = self.form_mut() else {
1631            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1632            return Err(());
1633        };
1634        let parent = form.get_mut(parent_id);
1635        parent
1636            .children
1637            .retain(|child_id| !remove_ids.contains(child_id));
1638        let insert_pos = first_pos.min(parent.children.len());
1639        for (offset, node_id) in new_ids.iter().copied().enumerate() {
1640            parent.children.insert(insert_pos + offset, node_id);
1641        }
1642
1643        let key = (parent_id, prototype_name.clone());
1644        if target_count == 0 {
1645            self.zero_instance_runs.insert(key, self.generation);
1646        } else {
1647            self.zero_instance_runs.remove(&key);
1648        }
1649        self.record_instance_write();
1650        self.record_instance_write_detail(
1651            parent_id,
1652            &parent_name_set,
1653            &prototype_name,
1654            old_count_set,
1655            target_count,
1656        );
1657        Ok(target_count as u32)
1658    }
1659
1660    fn instance_add_inner(
1661        &mut self,
1662        parent_id: FormNodeId,
1663        generation: Option<u64>,
1664    ) -> Result<FormNodeId, ()> {
1665        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1666        if !self.write_activity_allowed()
1667            || self.mutation_count_this_doc >= MAX_MUTATIONS_PER_DOC
1668            || !self.consume_resolve_call()
1669        {
1670            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1671            return Err(());
1672        }
1673
1674        let Some(run) = self.live_instance_run(parent_id, generation) else {
1675            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1676            return Err(());
1677        };
1678        let zero_key = self
1679            .form_ref()
1680            .and_then(|form| form.nodes.get(run.prototype_id.0))
1681            .map(|node| (run.parent_id, node.name.clone()));
1682        // E-3: capture details before mutable borrow.
1683        let add_proto_name = self
1684            .form_ref()
1685            .and_then(|form| form.nodes.get(run.prototype_id.0))
1686            .map(|n| n.name.clone())
1687            .unwrap_or_default();
1688        let add_parent_name = self
1689            .form_ref()
1690            .and_then(|form| form.nodes.get(run.parent_id.0))
1691            .map(|n| n.name.clone())
1692            .unwrap_or_default();
1693        let add_old_count = run.nodes.len();
1694        let add_parent_id = run.parent_id;
1695        let Some(max_allowed) = self.max_instances_for(run.prototype_id) else {
1696            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1697            return Err(());
1698        };
1699        if run.nodes.len() as u32 >= max_allowed {
1700            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1701            return Err(());
1702        }
1703
1704        let cloned_id = self.clone_subtree(run.prototype_id)?;
1705        let Some(form) = self.form_mut() else {
1706            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1707            return Err(());
1708        };
1709        form.get_mut(run.parent_id)
1710            .children
1711            .insert(run.last_position + 1, cloned_id);
1712
1713        if let Some(key) = zero_key {
1714            self.zero_instance_runs.remove(&key);
1715        }
1716        self.record_instance_write();
1717        self.record_instance_write_detail(
1718            add_parent_id,
1719            &add_parent_name,
1720            &add_proto_name,
1721            add_old_count,
1722            add_old_count + 1,
1723        );
1724        Ok(cloned_id)
1725    }
1726
1727    fn instance_remove_inner(
1728        &mut self,
1729        parent_id: FormNodeId,
1730        generation: Option<u64>,
1731        index: u32,
1732    ) -> Result<(), ()> {
1733        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1734        if !self.write_activity_allowed()
1735            || self.mutation_count_this_doc >= MAX_MUTATIONS_PER_DOC
1736            || !self.consume_resolve_call()
1737        {
1738            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1739            return Err(());
1740        }
1741
1742        let Some(run) = self.live_instance_run(parent_id, generation) else {
1743            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1744            return Err(());
1745        };
1746        let zero_key = self
1747            .form_ref()
1748            .and_then(|form| form.nodes.get(run.prototype_id.0))
1749            .map(|node| (run.parent_id, node.name.clone()));
1750        // E-3: capture details before mutable borrow.
1751        let rm_proto_name = self
1752            .form_ref()
1753            .and_then(|form| form.nodes.get(run.prototype_id.0))
1754            .map(|n| n.name.clone())
1755            .unwrap_or_default();
1756        let rm_parent_name = self
1757            .form_ref()
1758            .and_then(|form| form.nodes.get(run.parent_id.0))
1759            .map(|n| n.name.clone())
1760            .unwrap_or_default();
1761        let rm_old_count = run.nodes.len();
1762        let rm_parent_id = run.parent_id;
1763        let min_allowed = self
1764            .form_ref()
1765            .and_then(|form| form.nodes.get(run.prototype_id.0))
1766            .map(|node| node.occur.min)
1767            .unwrap_or(1);
1768        if run.nodes.len() as u32 <= min_allowed {
1769            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1770            return Err(());
1771        }
1772        let Some(remove_position) = run.positions.get(index as usize).copied() else {
1773            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1774            return Err(());
1775        };
1776
1777        let Some(form) = self.form_mut() else {
1778            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1779            return Err(());
1780        };
1781        form.get_mut(run.parent_id).children.remove(remove_position);
1782
1783        if let Some(key) = zero_key {
1784            if run.nodes.len() == 1 {
1785                self.zero_instance_runs.insert(key, self.generation);
1786            } else {
1787                self.zero_instance_runs.remove(&key);
1788            }
1789        }
1790        self.record_instance_write();
1791        self.record_instance_write_detail(
1792            rm_parent_id,
1793            &rm_parent_name,
1794            &rm_proto_name,
1795            rm_old_count,
1796            rm_old_count.saturating_sub(1),
1797        );
1798        Ok(())
1799    }
1800
1801    fn read_instance_run(
1802        &mut self,
1803        node_id: FormNodeId,
1804        generation: Option<u64>,
1805    ) -> Option<InstanceRun> {
1806        if !self.consume_resolve_call() {
1807            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1808            return None;
1809        }
1810        let Some(form) = self.form_ref() else {
1811            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1812            return None;
1813        };
1814        if generation.is_some_and(|value| value != self.generation) {
1815            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1816            return None;
1817        }
1818        if node_id.0 >= form.nodes.len() || self.root_id.0 >= form.nodes.len() {
1819            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1820            return None;
1821        }
1822
1823        let parents = build_parent_map(form, self.root_id);
1824        if !parents.contains_key(&node_id) {
1825            if node_id == self.root_id {
1826                return Some(InstanceRun {
1827                    parent_id: node_id,
1828                    positions: vec![0],
1829                    nodes: vec![node_id],
1830                    prototype_id: node_id,
1831                    first_position: 0,
1832                    last_position: 0,
1833                });
1834            }
1835            return None;
1836        }
1837
1838        build_instance_run(form, &parents, node_id)
1839    }
1840
1841    fn live_instance_run(
1842        &self,
1843        node_id: FormNodeId,
1844        generation: Option<u64>,
1845    ) -> Option<InstanceRun> {
1846        let form = self.form_ref()?;
1847        if generation.is_some_and(|value| value != self.generation)
1848            || node_id.0 >= form.nodes.len()
1849            || self.root_id.0 >= form.nodes.len()
1850            || !is_instance_node(&form.get(node_id).node_type)
1851        {
1852            return None;
1853        }
1854        let parents = build_parent_map(form, self.root_id);
1855        build_instance_run(form, &parents, node_id)
1856    }
1857
1858    fn clamped_instance_count(&self, prototype_id: FormNodeId, requested: u32) -> Option<u32> {
1859        let min_allowed = self.form_ref()?.get(prototype_id).occur.min;
1860        let max_allowed = self.max_instances_for(prototype_id)?;
1861        if min_allowed > max_allowed {
1862            return None;
1863        }
1864        Some(requested.clamp(min_allowed, max_allowed))
1865    }
1866
1867    fn max_instances_for(&self, prototype_id: FormNodeId) -> Option<u32> {
1868        let occur = &self.form_ref()?.get(prototype_id).occur;
1869        let max = occur.max.unwrap_or(u32::MAX);
1870        Some(max.min(MAX_INSTANCES_PER_SUBFORM))
1871    }
1872
1873    fn clone_subtree(&mut self, source_id: FormNodeId) -> Result<FormNodeId, ()> {
1874        let (mut new_node, mut new_meta, child_ids) = {
1875            let Some(form) = self.form_ref() else {
1876                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1877                return Err(());
1878            };
1879            if source_id.0 >= form.nodes.len() {
1880                self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1881                return Err(());
1882            }
1883            (
1884                form.get(source_id).clone(),
1885                form.meta(source_id).clone(),
1886                form.get(source_id).children.clone(),
1887            )
1888        };
1889
1890        let mut new_children = Vec::with_capacity(child_ids.len());
1891        for child_id in child_ids {
1892            new_children.push(self.clone_subtree(child_id)?);
1893        }
1894        new_node.children = new_children;
1895        // Runtime-created instances are represented as concrete siblings, so
1896        // each physical clone should lay out once while retaining min/max.
1897        new_node.occur.initial = 1;
1898        new_meta.xfa_id = None;
1899
1900        let Some(form) = self.form_mut() else {
1901            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1902            return Err(());
1903        };
1904        Ok(form.add_node_with_meta(new_node, new_meta))
1905    }
1906
1907    fn normalize_instance_occurrence(&mut self, node_id: FormNodeId) {
1908        if let Some(form) = self.form_mut() {
1909            if let Some(node) = form.nodes.get_mut(node_id.0) {
1910                // See clone_subtree: live instance count is encoded by
1911                // sibling multiplicity after an instanceManager write.
1912                node.occur.initial = 1;
1913            }
1914        }
1915    }
1916
1917    fn record_instance_write(&mut self) {
1918        self.metadata.instance_writes = self.metadata.instance_writes.saturating_add(1);
1919        self.mutation_count_this_doc = self.mutation_count_this_doc.saturating_add(1);
1920    }
1921
1922    /// Epic A E-3: record instance write with structural detail.  Called from
1923    /// the three instance mutation sites after the plain `record_instance_write`
1924    /// bump.  Only adds to `instance_write_log` when `XFA_RUNTIME_DIAG=1`.
1925    fn record_instance_write_detail(
1926        &mut self,
1927        parent_id: FormNodeId,
1928        parent_name: &str,
1929        proto_name: &str,
1930        old_count: usize,
1931        new_count: usize,
1932    ) {
1933        if !crate::dynamic::runtime_diag_enabled() || self.instance_write_log.len() >= 200 {
1934            return;
1935        }
1936        let parent_node_id = parent_id.0;
1937        self.instance_write_log
1938            .push(crate::dynamic::InstanceWriteEntry {
1939                script_idx: self.current_script_idx,
1940                activity: self.current_activity.as_deref().unwrap_or("").to_string(),
1941                parent_node_id,
1942                parent_node_name: parent_name.to_string(),
1943                prototype_node_name: proto_name.to_string(),
1944                old_count,
1945                new_count,
1946            });
1947    }
1948
1949    fn record_list_write(&mut self) {
1950        self.metadata.list_writes = self.metadata.list_writes.saturating_add(1);
1951        self.mutation_count_this_doc = self.mutation_count_this_doc.saturating_add(1);
1952    }
1953
1954    fn list_clear_inner(
1955        &mut self,
1956        field_id: FormNodeId,
1957        generation: Option<u64>,
1958    ) -> Result<(), ()> {
1959        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1960        if !self.write_activity_allowed()
1961            || self.mutation_count_this_doc >= MAX_MUTATIONS_PER_DOC
1962            || !self.consume_resolve_call()
1963        {
1964            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1965            return Err(());
1966        }
1967        let current_generation = self.generation;
1968        let Some(form) = self.form_mut() else {
1969            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1970            return Err(());
1971        };
1972        if generation.is_some_and(|value| value != current_generation)
1973            || field_id.0 >= form.nodes.len()
1974        {
1975            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1976            return Err(());
1977        }
1978        if !matches!(form.get(field_id).node_type, FormNodeType::Field { .. }) {
1979            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
1980            return Err(());
1981        }
1982        form.meta_mut(field_id).runtime_listbox_items.clear();
1983        self.record_list_write();
1984        Ok(())
1985    }
1986
1987    fn list_add_inner(
1988        &mut self,
1989        field_id: FormNodeId,
1990        generation: Option<u64>,
1991        display: String,
1992        save: Option<String>,
1993    ) -> Result<(), ()> {
1994        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
1995        if !self.write_activity_allowed()
1996            || self.mutation_count_this_doc >= MAX_MUTATIONS_PER_DOC
1997            || !self.consume_resolve_call()
1998        {
1999            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
2000            return Err(());
2001        }
2002        let current_generation = self.generation;
2003        let Some(form) = self.form_mut() else {
2004            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
2005            return Err(());
2006        };
2007        if generation.is_some_and(|value| value != current_generation)
2008            || field_id.0 >= form.nodes.len()
2009        {
2010            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
2011            return Err(());
2012        }
2013        if !matches!(form.get(field_id).node_type, FormNodeType::Field { .. }) {
2014            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
2015            return Err(());
2016        }
2017        let meta = form.meta_mut(field_id);
2018        if meta.runtime_listbox_items.len() >= MAX_ITEMS_PER_LISTBOX as usize {
2019            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
2020            return Err(());
2021        }
2022        let save_value = save.unwrap_or_else(|| display.clone());
2023        meta.runtime_listbox_items.push((display, save_value));
2024        self.record_list_write();
2025        Ok(())
2026    }
2027
2028    fn write_activity_allowed(&self) -> bool {
2029        let base = matches!(
2030            self.current_activity.as_deref(),
2031            Some("initialize")
2032                | Some("calculate")
2033                | Some("validate")
2034                | Some("docReady")
2035                | Some("layoutReady")
2036        );
2037        if base {
2038            return true;
2039        }
2040        // Phase 2 interactive mode: a real user edit fires `change`/`click`
2041        // (and the focus `exit`/`enter` pair). These mutate value/presence and
2042        // are accepted ONLY when the interactive commit loop set the flag; the
2043        // static-flatten path keeps them denied (interactive == false).
2044        if self.interactive
2045            && matches!(
2046                self.current_activity.as_deref(),
2047                Some("change") | Some("click") | Some("exit") | Some("enter")
2048            )
2049        {
2050            return true;
2051        }
2052        // D1.B gated allow: when the per-flatten opt-in is ON, mirror the
2053        // dispatch gate by accepting `preSave` for mutating host calls.
2054        // Hard-stop: ONLY `preSave` is unlocked here. `preSubmit`, `click`,
2055        // and every other denylist activity stay denied even with the gate.
2056        // Cross-ref: docs/INST_MGR_ACTIVITY_POLICY.md v5 §6.1.
2057        self.presave_gate && matches!(self.current_activity.as_deref(), Some("preSave"))
2058    }
2059
2060    fn handle_is_live(&self, node_id: FormNodeId, generation: u64) -> bool {
2061        generation == self.generation
2062            && self
2063                .form_ref()
2064                .is_some_and(|form| node_id.0 < form.nodes.len())
2065    }
2066
2067    fn write_field_value(
2068        &mut self,
2069        node_id: FormNodeId,
2070        value: String,
2071    ) -> Option<(String, String)> {
2072        let form = self.form_mut()?;
2073        let node = form.nodes.get_mut(node_id.0)?;
2074        let FormNodeType::Field { value: field_value } = &mut node.node_type else {
2075            return None;
2076        };
2077        let before = field_value.clone();
2078        *field_value = value;
2079        Some((before, field_value.clone()))
2080    }
2081
2082    fn form_ref(&self) -> Option<&FormTree> {
2083        if self.form.is_null() {
2084            None
2085        } else {
2086            // SAFETY: `dynamic.rs` installs a pointer derived from its live
2087            // `&mut FormTree` before script dispatch and clears it before
2088            // returning. Host methods never store references derived from it.
2089            unsafe { self.form.as_ref() }
2090        }
2091    }
2092
2093    fn form_mut(&mut self) -> Option<&mut FormTree> {
2094        if self.form.is_null() {
2095            None
2096        } else {
2097            // SAFETY: See `form_ref`; the dispatch path is the sole owner of
2098            // the mutable form borrow while QuickJS closures execute.
2099            unsafe { self.form.as_mut() }
2100        }
2101    }
2102
2103    /// Phase D-γ: obtain a read-only reference to the DataDom.
2104    fn data_dom_ref(&self) -> Option<&DataDom> {
2105        // SAFETY: `data_dom` is set from `&data_dom` in flatten.rs where the
2106        // DataDom lives on the stack and outlives all script execution. We only
2107        // ever read through this pointer, never write.
2108        self.data_dom.map(|ptr| unsafe { &*ptr })
2109    }
2110
2111    /// Phase D-γ: children of a DataDom node, returned as raw indices.
2112    /// Capped at `MAX_RESOLVE_RESULTS`. Returns empty vec on invalid input.
2113    pub fn data_children(&mut self, raw_id: usize) -> Vec<usize> {
2114        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
2115        // Scope the borrow so we can increment metadata after.
2116        let result = {
2117            let Some(dom) = self.data_dom_ref() else {
2118                return Vec::new();
2119            };
2120            let id = DataNodeId::from_raw(raw_id);
2121            if dom.get(id).is_none() {
2122                return Vec::new();
2123            }
2124            dom.children(id)
2125                .iter()
2126                .take(MAX_RESOLVE_RESULTS)
2127                .map(|c| c.as_raw())
2128                .collect::<Vec<_>>()
2129        };
2130        self.metadata.data_reads = self.metadata.data_reads.saturating_add(1);
2131        result
2132    }
2133
2134    /// Phase D-γ: text value of a DataValue node.
2135    /// Returns `None` for DataGroup nodes or out-of-bounds indices.
2136    pub fn data_value(&mut self, raw_id: usize) -> Option<String> {
2137        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
2138        let result = {
2139            let dom = self.data_dom_ref()?;
2140            let id = DataNodeId::from_raw(raw_id);
2141            dom.get(id)?;
2142            dom.value(id).ok().map(|s| s.to_owned())
2143        };
2144        if result.is_some() {
2145            self.metadata.data_reads = self.metadata.data_reads.saturating_add(1);
2146        }
2147        result
2148    }
2149
2150    /// Phase D-γ: first child of `parent_raw` whose name matches `name`.
2151    /// Returns `None` if not found or parent is out-of-bounds.
2152    pub fn data_child_by_name(&mut self, parent_raw: usize, name: &str) -> Option<usize> {
2153        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
2154        let result = {
2155            let dom = self.data_dom_ref()?;
2156            let parent = DataNodeId::from_raw(parent_raw);
2157            dom.get(parent)?;
2158            dom.children_by_name(parent, name)
2159                .into_iter()
2160                .next()
2161                .map(|id| id.as_raw())
2162        };
2163        if result.is_some() {
2164            self.metadata.data_reads = self.metadata.data_reads.saturating_add(1);
2165        }
2166        result
2167    }
2168
2169    /// Phase D-γ: raw DataDom index bound to a FormTree node.
2170    /// Returns `None` when the node is unbound or the handle is stale.
2171    pub fn data_bound_record(
2172        &mut self,
2173        form_node_id: FormNodeId,
2174        generation: u64,
2175    ) -> Option<usize> {
2176        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
2177        if generation != self.generation {
2178            return None;
2179        }
2180        let form = self.form_ref()?;
2181        if form_node_id.0 >= form.nodes.len() {
2182            return None;
2183        }
2184        form.meta(form_node_id).bound_data_node
2185    }
2186
2187    /// BE-1: Return the display-label strings for a choice-list field.
2188    ///
2189    /// Priority order (mirrors Adobe Reader behaviour):
2190    /// 1. Runtime-populated items from D-β `addItem` calls (display strings only).
2191    /// 2. Static `<items>` display strings parsed from the template at merge time.
2192    ///
2193    /// Returns an empty `Vec` when the handle is stale, the node is not a
2194    /// field, or neither list is populated.  The `som_items_path_hits` counter
2195    /// is **not** bumped here — the JS layer bumps it after receiving the
2196    /// non-empty result so the counter accurately reflects "did the script get
2197    /// useful data" rather than "was the host called".
2198    pub fn get_display_items(&mut self, field_id: FormNodeId, generation: u64) -> Vec<String> {
2199        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
2200        if !self.handle_is_live(field_id, generation) {
2201            return Vec::new();
2202        }
2203        let Some(form) = self.form_ref() else {
2204            return Vec::new();
2205        };
2206        if !matches!(form.get(field_id).node_type, FormNodeType::Field { .. }) {
2207            return Vec::new();
2208        }
2209        let meta = form.meta(field_id);
2210        if !meta.runtime_listbox_items.is_empty() {
2211            return meta
2212                .runtime_listbox_items
2213                .iter()
2214                .map(|(display, _save)| display.clone())
2215                .collect();
2216        }
2217        meta.display_items.clone()
2218    }
2219
2220    /// Phase D-γ: resolve a data SOM path to the first matching node.
2221    /// Consumes one resolve-call budget slot. Returns `None` on budget
2222    /// exhaustion, parse failure, or no match.
2223    pub fn data_resolve_node(&mut self, path: &str) -> Option<usize> {
2224        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
2225        if !self.consume_resolve_call() {
2226            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
2227            return None;
2228        }
2229        // Scope borrow so we can use self after.
2230        {
2231            let dom = self.data_dom_ref()?;
2232            resolve_data_path(dom, path, None)
2233                .ok()?
2234                .into_iter()
2235                .next()
2236                .map(|id| id.as_raw())
2237        }
2238    }
2239
2240    /// Phase D-γ: resolve a data SOM path to all matching nodes (capped).
2241    /// Consumes one resolve-call budget slot.
2242    pub fn data_resolve_nodes(&mut self, path: &str) -> Vec<usize> {
2243        self.metadata.host_calls = self.metadata.host_calls.saturating_add(1);
2244        if !self.consume_resolve_call() {
2245            self.metadata.binding_errors = self.metadata.binding_errors.saturating_add(1);
2246            return Vec::new();
2247        }
2248        {
2249            let Some(dom) = self.data_dom_ref() else {
2250                return Vec::new();
2251            };
2252            resolve_data_path(dom, path, None)
2253                .unwrap_or_default()
2254                .into_iter()
2255                .take(MAX_RESOLVE_RESULTS)
2256                .map(|id| id.as_raw())
2257                .collect()
2258        }
2259    }
2260}
2261
2262enum ResolveOutcome {
2263    Ok(Vec<FormNodeId>),
2264    NoMatch,
2265    BindingError,
2266}
2267
2268#[derive(Debug, Clone)]
2269struct InstanceRun {
2270    parent_id: FormNodeId,
2271    positions: Vec<usize>,
2272    nodes: Vec<FormNodeId>,
2273    prototype_id: FormNodeId,
2274    first_position: usize,
2275    last_position: usize,
2276}
2277
2278fn build_instance_run(
2279    form: &FormTree,
2280    parents: &HashMap<FormNodeId, FormNodeId>,
2281    node_id: FormNodeId,
2282) -> Option<InstanceRun> {
2283    let parent_id = parents.get(&node_id).copied()?;
2284    let name = form.get(node_id).name.clone();
2285    let parent = form.get(parent_id);
2286    let mut positions = Vec::new();
2287    let mut nodes = Vec::new();
2288    for (position, child_id) in parent.children.iter().copied().enumerate() {
2289        if form.get(child_id).name == name {
2290            positions.push(position);
2291            nodes.push(child_id);
2292        }
2293    }
2294    if !nodes.contains(&node_id) {
2295        return None;
2296    }
2297    Some(InstanceRun {
2298        parent_id,
2299        prototype_id: nodes[0],
2300        first_position: positions[0],
2301        last_position: *positions.last()?,
2302        positions,
2303        nodes,
2304    })
2305}
2306
2307fn is_instance_node(node_type: &FormNodeType) -> bool {
2308    matches!(
2309        node_type,
2310        FormNodeType::Root
2311            | FormNodeType::Subform
2312            | FormNodeType::Area
2313            | FormNodeType::ExclGroup
2314            | FormNodeType::SubformSet
2315    )
2316}
2317
2318impl HostBindings {
2319    /// XFA-DATA-M3C diagnostic: log the SOM path that failed to resolve when
2320    /// the `XFA_JS_DEBUG` env var is set to `1`. Off by default so the dispatch
2321    /// path stays silent in normal operation.
2322    ///
2323    /// Epic A E-2: also pushes an entry to `self.som_fail_log` when
2324    /// `XFA_RUNTIME_DIAG=1` (capped at 200).
2325    fn debug_log_resolve_miss(&mut self, kind: &str, path: &str) {
2326        if std::env::var("XFA_JS_DEBUG").ok().as_deref() == Some("1") {
2327            eprintln!("XFA_JS_DEBUG {kind} path={path:?}");
2328        }
2329        if crate::dynamic::runtime_diag_enabled() && self.som_fail_log.len() < 200 {
2330            self.som_fail_log.push(crate::dynamic::SomFailEntry {
2331                path: path.to_string(),
2332                kind: kind.to_string(),
2333                script_idx: self.current_script_idx,
2334                activity: self.current_activity.as_deref().unwrap_or("").to_string(),
2335            });
2336        }
2337    }
2338}
2339
2340fn normalize_resolve_path(path: &str) -> String {
2341    if let Some(rest) = path.strip_prefix("this.") {
2342        format!("$.{rest}")
2343    } else if path == "this" {
2344        "$".to_string()
2345    } else {
2346        path.to_string()
2347    }
2348}
2349
2350struct HostSomResolver<'a> {
2351    form: &'a FormTree,
2352    root_id: FormNodeId,
2353    parents: &'a HashMap<FormNodeId, FormNodeId>,
2354    current_id: FormNodeId,
2355}
2356
2357impl HostSomResolver<'_> {
2358    fn resolve_expression(&self, expr: &SomExpression) -> Option<Vec<FormNodeId>> {
2359        match expr.root {
2360            SomRoot::Data | SomRoot::Record | SomRoot::Template => None,
2361            SomRoot::CurrentContainer => {
2362                if expr.segments.is_empty() {
2363                    Some(vec![self.current_id])
2364                } else {
2365                    Some(self.follow_absolute(vec![self.current_id], &expr.segments))
2366                }
2367            }
2368            SomRoot::Form => {
2369                if expr.segments.is_empty() {
2370                    Some(vec![self.root_id])
2371                } else {
2372                    Some(self.follow_absolute(vec![self.root_id], &expr.segments))
2373                }
2374            }
2375            SomRoot::Xfa => {
2376                let segments = strip_xfa_form_prefix(&expr.segments);
2377                if segments.is_empty() {
2378                    Some(vec![self.root_id])
2379                } else {
2380                    Some(self.follow_absolute(vec![self.root_id], segments))
2381                }
2382            }
2383            SomRoot::Unqualified => {
2384                if expr.segments.is_empty() {
2385                    Some(vec![self.current_id])
2386                } else {
2387                    Some(self.follow_unqualified(&expr.segments))
2388                }
2389            }
2390        }
2391    }
2392
2393    fn follow_absolute(
2394        &self,
2395        mut current: Vec<FormNodeId>,
2396        segments: &[xfa_dom_resolver::som::SomSegment],
2397    ) -> Vec<FormNodeId> {
2398        for (idx, segment) in segments.iter().enumerate() {
2399            let allow_self = idx == 0;
2400            current = current
2401                .into_iter()
2402                .flat_map(|node_id| self.step_from_node(node_id, segment, allow_self))
2403                .collect();
2404            if current.is_empty() {
2405                break;
2406            }
2407        }
2408        current
2409    }
2410
2411    fn follow_unqualified(
2412        &self,
2413        segments: &[xfa_dom_resolver::som::SomSegment],
2414    ) -> Vec<FormNodeId> {
2415        let Some((first, rest)) = segments.split_first() else {
2416            return vec![self.current_id];
2417        };
2418
2419        let mut scope = Some(self.current_id);
2420        while let Some(scope_id) = scope {
2421            let anchors: Vec<_> = descendants_inclusive(self.form, scope_id)
2422                .into_iter()
2423                .filter(|node_id| self.node_matches_segment(*node_id, first))
2424                .collect();
2425            let matched = self.follow_remaining(anchors, rest);
2426            if !matched.is_empty() {
2427                return matched;
2428            }
2429            scope = self.parents.get(&scope_id).copied();
2430        }
2431
2432        let anchors: Vec<_> = descendants_inclusive(self.form, self.root_id)
2433            .into_iter()
2434            .filter(|node_id| self.node_matches_segment(*node_id, first))
2435            .collect();
2436        self.follow_remaining(anchors, rest)
2437    }
2438
2439    fn follow_remaining(
2440        &self,
2441        mut current: Vec<FormNodeId>,
2442        segments: &[xfa_dom_resolver::som::SomSegment],
2443    ) -> Vec<FormNodeId> {
2444        for segment in segments {
2445            current = current
2446                .into_iter()
2447                .flat_map(|node_id| self.step_from_node(node_id, segment, false))
2448                .collect();
2449            if current.is_empty() {
2450                break;
2451            }
2452        }
2453        current
2454    }
2455
2456    fn step_from_node(
2457        &self,
2458        node_id: FormNodeId,
2459        segment: &xfa_dom_resolver::som::SomSegment,
2460        allow_self: bool,
2461    ) -> Vec<FormNodeId> {
2462        if let SomSelector::Name(name) = &segment.selector {
2463            if name == ".." {
2464                if let Some(&parent_id) = self.parents.get(&node_id) {
2465                    return apply_index_to_single(parent_id, segment.index);
2466                }
2467                return Vec::new();
2468            }
2469        }
2470
2471        if allow_self && self.node_matches_selector(node_id, &segment.selector) {
2472            return apply_index_to_single(node_id, segment.index);
2473        }
2474
2475        let matches: Vec<_> = self
2476            .form
2477            .get(node_id)
2478            .children
2479            .iter()
2480            .copied()
2481            .filter(|child_id| self.node_matches_selector(*child_id, &segment.selector))
2482            .collect();
2483
2484        apply_index(matches, segment.index)
2485    }
2486
2487    fn node_matches_segment(
2488        &self,
2489        node_id: FormNodeId,
2490        segment: &xfa_dom_resolver::som::SomSegment,
2491    ) -> bool {
2492        if !self.node_matches_selector(node_id, &segment.selector) {
2493            return false;
2494        }
2495
2496        match segment.index {
2497            SomIndex::All => true,
2498            SomIndex::None => self.sibling_position(node_id, &segment.selector) == Some(0),
2499            SomIndex::Specific(idx) => {
2500                self.sibling_position(node_id, &segment.selector) == Some(idx)
2501            }
2502        }
2503    }
2504
2505    fn sibling_position(&self, node_id: FormNodeId, selector: &SomSelector) -> Option<usize> {
2506        let Some(parent_id) = self.parents.get(&node_id).copied() else {
2507            return self.node_matches_selector(node_id, selector).then_some(0);
2508        };
2509
2510        self.form
2511            .get(parent_id)
2512            .children
2513            .iter()
2514            .copied()
2515            .filter(|candidate| self.node_matches_selector(*candidate, selector))
2516            .position(|candidate| candidate == node_id)
2517    }
2518
2519    fn node_matches_selector(&self, node_id: FormNodeId, selector: &SomSelector) -> bool {
2520        match selector {
2521            SomSelector::Name(name) => self.form.get(node_id).name == *name,
2522            SomSelector::Class(class_name) => self.node_matches_class(node_id, class_name),
2523            SomSelector::AllChildren => true,
2524        }
2525    }
2526
2527    fn node_matches_class(&self, node_id: FormNodeId, class_name: &str) -> bool {
2528        let class_name = class_name.to_ascii_lowercase();
2529        match class_name.as_str() {
2530            "subform" => matches!(
2531                self.form.get(node_id).node_type,
2532                FormNodeType::Root | FormNodeType::Subform
2533            ),
2534            "pageset" => matches!(self.form.get(node_id).node_type, FormNodeType::PageSet),
2535            "pagearea" => matches!(
2536                self.form.get(node_id).node_type,
2537                FormNodeType::PageArea { .. }
2538            ),
2539            "field" => matches!(self.form.get(node_id).node_type, FormNodeType::Field { .. }),
2540            "draw" => matches!(
2541                self.form.get(node_id).node_type,
2542                FormNodeType::Draw(_) | FormNodeType::Image { .. }
2543            ),
2544            "exclgroup" => self.form.meta(node_id).group_kind == GroupKind::ExclusiveChoice,
2545            _ => false,
2546        }
2547    }
2548}
2549
2550fn strip_xfa_form_prefix(
2551    segments: &[xfa_dom_resolver::som::SomSegment],
2552) -> &[xfa_dom_resolver::som::SomSegment] {
2553    match segments.first() {
2554        Some(segment)
2555            if matches!(&segment.selector, SomSelector::Name(name) if name == "form")
2556                && matches!(segment.index, SomIndex::None) =>
2557        {
2558            &segments[1..]
2559        }
2560        _ => segments,
2561    }
2562}
2563
2564fn apply_index(matches: Vec<FormNodeId>, index: SomIndex) -> Vec<FormNodeId> {
2565    match index {
2566        SomIndex::None => matches.into_iter().take(1).collect(),
2567        SomIndex::Specific(idx) => matches.get(idx).copied().into_iter().collect(),
2568        SomIndex::All => matches,
2569    }
2570}
2571
2572fn apply_index_to_single(node_id: FormNodeId, index: SomIndex) -> Vec<FormNodeId> {
2573    match index {
2574        SomIndex::None | SomIndex::Specific(0) | SomIndex::All => vec![node_id],
2575        SomIndex::Specific(_) => Vec::new(),
2576    }
2577}
2578
2579fn descendants_inclusive(form: &FormTree, root_id: FormNodeId) -> Vec<FormNodeId> {
2580    let mut out = Vec::new();
2581    collect_descendants(form, root_id, &mut out);
2582    out
2583}
2584
2585fn collect_descendants(form: &FormTree, node_id: FormNodeId, out: &mut Vec<FormNodeId>) {
2586    out.push(node_id);
2587    for &child_id in &form.get(node_id).children {
2588        collect_descendants(form, child_id, out);
2589    }
2590}
2591
2592/// Maximum same-name candidates collected during one bare-identifier
2593/// resolution. XFA forms commonly include multiple subforms with the same
2594/// `name` (e.g. layout-only stubs, signed-data placeholders, real content).
2595/// 32 is far beyond any real corpus we have observed.
2596const MAX_RESOLVE_CANDIDATES: usize = 32;
2597
2598/// Locate a same-name descendant of `scope_id`, biased toward the candidate
2599/// most likely to be the script-meant target.
2600///
2601/// XFA Spec 3.3 §S15 implicit identifier resolution: when a script reads
2602/// `Foo.Bar.Baz`, the leading `Foo` walks the scope chain upward and runs
2603/// a depth-first search for a descendant named `Foo` at each scope. Real
2604/// XFA templates frequently contain multiple subforms named `Foo` —
2605/// typically one empty layout-stub plus one populated content node —
2606/// because authoring tools split layout and data definitions. A naive
2607/// first-hit DFS lands on whichever appears first in the merged form
2608/// tree, which is usually the empty stub.
2609///
2610/// We collect up to [`MAX_RESOLVE_CANDIDATES`] same-name descendants and
2611/// pick the one with the most direct children, breaking ties by encounter
2612/// order (DFS preorder). This keeps single-candidate behaviour identical
2613/// to the prior first-hit semantics while disambiguating the multi-stub
2614/// case to the populated branch.
2615///
2616/// Phase D-η: name-collision tie-break for the implicit resolver.
2617fn collect_named_descendant_candidates(
2618    form: &FormTree,
2619    scope_id: FormNodeId,
2620    name: &str,
2621    max_depth: usize,
2622) -> Vec<FormNodeId> {
2623    let mut candidates: Vec<FormNodeId> = Vec::new();
2624    find_named_descendant_inner(form, scope_id, name, 0, max_depth, &mut candidates);
2625    candidates
2626}
2627
2628fn order_candidates(form: &FormTree, candidates: &mut [FormNodeId]) {
2629    // Phase D-η refinement (Codex review feedback): when same-name
2630    // candidates include a `Field` and a sibling subform/container,
2631    // prefer the Field. Fields have zero children, so naïve children-
2632    // bias would pick the container — but bare `Amount.rawValue` reads
2633    // and writes mean the field, not the container. Picking the
2634    // container would silently return null for reads and refuse writes
2635    // via `set_raw_value`. Preserve first-DFS-hit semantics among
2636    // Field candidates so the pre-D-η field-resolution behaviour is
2637    // unchanged when a field candidate exists at all.
2638    candidates.sort_by(|left, right| {
2639        let left_is_field = matches!(form.get(*left).node_type, FormNodeType::Field { .. });
2640        let right_is_field = matches!(form.get(*right).node_type, FormNodeType::Field { .. });
2641        match (left_is_field, right_is_field) {
2642            (true, false) => std::cmp::Ordering::Less,
2643            (false, true) => std::cmp::Ordering::Greater,
2644            (true, true) => std::cmp::Ordering::Equal,
2645            (false, false) => form
2646                .get(*right)
2647                .children
2648                .len()
2649                .cmp(&form.get(*left).children.len()),
2650        }
2651    });
2652}
2653
2654fn push_unique_candidate(candidates: &mut Vec<FormNodeId>, node_id: FormNodeId) {
2655    if candidates.len() < MAX_RESOLVE_CANDIDATES && !candidates.contains(&node_id) {
2656        candidates.push(node_id);
2657    }
2658}
2659
2660fn resolve_implicit_candidates_in_scope(
2661    form: &FormTree,
2662    parents: &HashMap<FormNodeId, FormNodeId>,
2663    current_id: FormNodeId,
2664    name: &str,
2665) -> ResolveOutcome {
2666    // Phase D-η resolution. The implicit-identifier scope walk has three
2667    // passes; the first hit wins. Phase D-κ keeps that first candidate
2668    // byte-for-byte compatible for one-token reads, but returns same-scope
2669    // alternatives after it so the JS proxy can filter chained SOM access.
2670    let mut scope = Some(current_id);
2671    let mut depth = 0usize;
2672    while let Some(scope_id) = scope {
2673        if depth > MAX_SOM_DEPTH {
2674            return ResolveOutcome::BindingError;
2675        }
2676        if scope_id.0 >= form.nodes.len() {
2677            return ResolveOutcome::BindingError;
2678        }
2679        let mut candidates =
2680            collect_named_descendant_candidates(form, scope_id, name, MAX_SOM_DEPTH);
2681        if !candidates.is_empty() {
2682            order_candidates(form, &mut candidates);
2683            if !form.get(candidates[0]).children.is_empty() {
2684                return ResolveOutcome::Ok(candidates);
2685            }
2686        }
2687        scope = parents.get(&scope_id).copied();
2688        depth += 1;
2689    }
2690
2691    // Pass 2: ancestor self-name walk. Only reached when no scope's
2692    // children-biased descendant DFS produced a populated node.
2693    let mut scope = Some(current_id);
2694    let mut depth = 0usize;
2695    while let Some(scope_id) = scope {
2696        if depth > MAX_SOM_DEPTH {
2697            return ResolveOutcome::BindingError;
2698        }
2699        if scope_id.0 >= form.nodes.len() {
2700            return ResolveOutcome::BindingError;
2701        }
2702        if form.get(scope_id).name == name {
2703            return ResolveOutcome::Ok(vec![scope_id]);
2704        }
2705        scope = parents.get(&scope_id).copied();
2706        depth += 1;
2707    }
2708
2709    // Pass 3: accept a stub descendant as last resort.
2710    let mut scope = Some(current_id);
2711    let mut depth = 0usize;
2712    while let Some(scope_id) = scope {
2713        if depth > MAX_SOM_DEPTH {
2714            return ResolveOutcome::BindingError;
2715        }
2716        if scope_id.0 >= form.nodes.len() {
2717            return ResolveOutcome::BindingError;
2718        }
2719        let mut candidates =
2720            collect_named_descendant_candidates(form, scope_id, name, MAX_SOM_DEPTH);
2721        if !candidates.is_empty() {
2722            order_candidates(form, &mut candidates);
2723            return ResolveOutcome::Ok(candidates);
2724        }
2725        scope = parents.get(&scope_id).copied();
2726        depth += 1;
2727    }
2728
2729    ResolveOutcome::NoMatch
2730}
2731
2732/// Phase D-θ: bounded subtree check used by lookahead disambiguation.
2733///
2734/// Returns true when `node_id`'s subtree contains a descendant named `name`
2735/// (or a direct child) within `MAX_SOM_DEPTH` levels. The starting node
2736/// itself is excluded — only descendants count. Recursion is bounded by
2737/// [`MAX_SOM_DEPTH`] so malformed templates cannot starve the sandbox.
2738fn subtree_contains_name(
2739    form: &FormTree,
2740    node_id: FormNodeId,
2741    name: &str,
2742    max_depth: usize,
2743) -> bool {
2744    if node_id.0 >= form.nodes.len() {
2745        return false;
2746    }
2747    subtree_contains_name_inner(form, node_id, name, 0, max_depth)
2748}
2749
2750fn subtree_contains_name_inner(
2751    form: &FormTree,
2752    node_id: FormNodeId,
2753    name: &str,
2754    depth: usize,
2755    max_depth: usize,
2756) -> bool {
2757    if depth >= max_depth {
2758        return false;
2759    }
2760    for &child_id in &form.get(node_id).children {
2761        if form.get(child_id).name == name {
2762            return true;
2763        }
2764        if subtree_contains_name_inner(form, child_id, name, depth + 1, max_depth) {
2765            return true;
2766        }
2767    }
2768    false
2769}
2770
2771fn find_named_descendant_inner(
2772    form: &FormTree,
2773    node_id: FormNodeId,
2774    name: &str,
2775    depth: usize,
2776    max_depth: usize,
2777    candidates: &mut Vec<FormNodeId>,
2778) {
2779    if depth >= max_depth || candidates.len() >= MAX_RESOLVE_CANDIDATES {
2780        return;
2781    }
2782    for &child_id in &form.get(node_id).children {
2783        if candidates.len() >= MAX_RESOLVE_CANDIDATES {
2784            return;
2785        }
2786        if form.get(child_id).name == name {
2787            candidates.push(child_id);
2788            // Mirror prior semantics: do not recurse INTO a matched node;
2789            // continue scanning siblings so all top-level same-name hits
2790            // at this scope are visible to the bias selection.
2791        } else {
2792            find_named_descendant_inner(form, child_id, name, depth + 1, max_depth, candidates);
2793        }
2794    }
2795}
2796
2797fn build_parent_map(form: &FormTree, root_id: FormNodeId) -> HashMap<FormNodeId, FormNodeId> {
2798    let mut parents = HashMap::new();
2799    populate_parent_map(form, root_id, &mut parents);
2800    parents
2801}
2802
2803fn populate_parent_map(
2804    form: &FormTree,
2805    node_id: FormNodeId,
2806    parents: &mut HashMap<FormNodeId, FormNodeId>,
2807) {
2808    for &child_id in &form.get(node_id).children {
2809        parents.insert(child_id, node_id);
2810        populate_parent_map(form, child_id, parents);
2811    }
2812}
2813
2814#[cfg(test)]
2815mod tests {
2816    use super::*;
2817    use xfa_layout_engine::form::{FormNode, Occur};
2818    use xfa_layout_engine::text::FontMetrics;
2819    use xfa_layout_engine::types::{BoxModel, LayoutStrategy};
2820
2821    fn add_node(tree: &mut FormTree, name: &str, node_type: FormNodeType) -> FormNodeId {
2822        tree.add_node(FormNode {
2823            name: name.to_string(),
2824            node_type,
2825            box_model: BoxModel::default(),
2826            layout: LayoutStrategy::TopToBottom,
2827            children: Vec::new(),
2828            occur: Occur::once(),
2829            font: FontMetrics::default(),
2830            calculate: None,
2831            validate: None,
2832            column_widths: Vec::new(),
2833            col_span: 1,
2834        })
2835    }
2836
2837    #[test]
2838    fn raw_value_get_set_and_generation_guard() {
2839        let mut tree = FormTree::new();
2840        let root = add_node(&mut tree, "root", FormNodeType::Root);
2841        let field = add_node(
2842            &mut tree,
2843            "Field1",
2844            FormNodeType::Field {
2845                value: "old".to_string(),
2846            },
2847        );
2848        tree.get_mut(root).children = vec![field];
2849
2850        let mut host = HostBindings::new();
2851        host.reset_per_document();
2852        host.set_form_handle(&mut tree as *mut FormTree, root);
2853        host.reset_per_script(field, Some("calculate"));
2854        let generation = host.generation();
2855
2856        assert_eq!(
2857            host.get_raw_value(field, generation),
2858            Some("old".to_string())
2859        );
2860        assert!(host.set_raw_value(field, "new".to_string(), generation));
2861        assert_eq!(
2862            host.get_raw_value(field, generation),
2863            Some("new".to_string())
2864        );
2865
2866        host.reset_per_document();
2867        assert_eq!(host.get_raw_value(field, generation), None);
2868    }
2869
2870    #[test]
2871    fn multi_segment_som_chain_lookahead() {
2872        // Phase D-θ: when two same-named siblings exist (`P1` × 2) but only
2873        // one contains a child called `X`, hinted child resolution must
2874        // collapse onto the P1 that owns `X`. Without lookahead the proxy
2875        // chain may pick the empty P1 (D-η ordering ties) and lose access
2876        // to `X.rawValue`.
2877        let mut tree = FormTree::new();
2878        let root = add_node(&mut tree, "root", FormNodeType::Root);
2879        let f = add_node(&mut tree, "F", FormNodeType::Subform);
2880        let p1_empty = add_node(&mut tree, "P1", FormNodeType::Subform);
2881        let stub = add_node(&mut tree, "Stub", FormNodeType::Subform);
2882        let p1_rich = add_node(&mut tree, "P1", FormNodeType::Subform);
2883        let x = add_node(
2884            &mut tree,
2885            "X",
2886            FormNodeType::Field {
2887                value: "answer".to_string(),
2888            },
2889        );
2890        tree.get_mut(p1_empty).children = vec![stub];
2891        tree.get_mut(p1_rich).children = vec![x];
2892        tree.get_mut(f).children = vec![p1_empty, p1_rich];
2893        tree.get_mut(root).children = vec![f];
2894
2895        let mut host = HostBindings::new();
2896        host.reset_per_document();
2897        host.set_form_handle(&mut tree as *mut FormTree, root);
2898        host.reset_per_script(root, Some("calculate"));
2899
2900        // Without a hint, both P1's are returned in scope order.
2901        let plain = host.resolve_child_candidates(&[f], "P1");
2902        assert!(plain.contains(&p1_empty) && plain.contains(&p1_rich));
2903
2904        // With the hint "X" the lookahead must keep ONLY the P1 that
2905        // actually has an X descendant.
2906        let hinted = host.resolve_child_candidates_hinted(&[f], "P1", "X");
2907        assert_eq!(hinted, vec![p1_rich]);
2908
2909        // Hint that no candidate satisfies must fall back to the un-hinted
2910        // candidate set so chained access still has something to walk.
2911        let hinted_none = host.resolve_child_candidates_hinted(&[f], "P1", "Nope");
2912        assert!(hinted_none.contains(&p1_empty) && hinted_none.contains(&p1_rich));
2913
2914        // Implicit-scope variant: from root the hint must still pin to the
2915        // populated P1 branch.
2916        let implicit = host.resolve_implicit_candidates_hinted(root, "P1", "X");
2917        assert_eq!(implicit, vec![p1_rich]);
2918    }
2919
2920    #[test]
2921    fn resolve_node_rejects_over_depth() {
2922        let mut tree = FormTree::new();
2923        let root = add_node(&mut tree, "root", FormNodeType::Root);
2924        let field = add_node(
2925            &mut tree,
2926            "Field1",
2927            FormNodeType::Field {
2928                value: String::new(),
2929            },
2930        );
2931        tree.get_mut(root).children = vec![field];
2932
2933        let mut host = HostBindings::new();
2934        host.reset_per_document();
2935        host.set_form_handle(&mut tree as *mut FormTree, root);
2936        host.reset_per_script(root, Some("calculate"));
2937        let long_path = (0..=MAX_SOM_DEPTH)
2938            .map(|idx| format!("n{idx}"))
2939            .collect::<Vec<_>>()
2940            .join(".");
2941
2942        assert_eq!(host.resolve_node(&long_path), None);
2943        assert_eq!(host.take_metadata().binding_errors, 1);
2944    }
2945
2946    /// Build a tree that exercises θ.2's backtracking:
2947    ///   root
2948    ///     A1 (subform with B1 only, no C)
2949    ///     A2 (subform with B2 -> C -> X field)
2950    ///     A3 (subform with B3 -> Z field)
2951    /// Both A1 and A3 contain a B but neither has B.C.X. Only A2 does.
2952    /// A single-segment hint on "B" cannot prefer A2 over A3 because both
2953    /// have a B child; only the full chain ["A","B","C","X"] can.
2954    fn build_chain_disambiguation_tree() -> (
2955        FormTree,
2956        FormNodeId, // root
2957        FormNodeId, // A2 (the winning branch)
2958        FormNodeId, // X field (terminal)
2959    ) {
2960        let mut tree = FormTree::new();
2961        let root = add_node(&mut tree, "root", FormNodeType::Root);
2962
2963        let a1 = add_node(&mut tree, "A", FormNodeType::Subform);
2964        let b1 = add_node(&mut tree, "B", FormNodeType::Subform);
2965        tree.get_mut(b1).children = vec![];
2966        tree.get_mut(a1).children = vec![b1];
2967
2968        let a2 = add_node(&mut tree, "A", FormNodeType::Subform);
2969        let b2 = add_node(&mut tree, "B", FormNodeType::Subform);
2970        let c2 = add_node(&mut tree, "C", FormNodeType::Subform);
2971        let x2 = add_node(
2972            &mut tree,
2973            "X",
2974            FormNodeType::Field {
2975                value: "winner".to_string(),
2976            },
2977        );
2978        tree.get_mut(c2).children = vec![x2];
2979        tree.get_mut(b2).children = vec![c2];
2980        tree.get_mut(a2).children = vec![b2];
2981
2982        let a3 = add_node(&mut tree, "A", FormNodeType::Subform);
2983        let b3 = add_node(&mut tree, "B", FormNodeType::Subform);
2984        let z3 = add_node(
2985            &mut tree,
2986            "Z",
2987            FormNodeType::Field {
2988                value: "decoy".to_string(),
2989            },
2990        );
2991        tree.get_mut(b3).children = vec![z3];
2992        tree.get_mut(a3).children = vec![b3];
2993
2994        tree.get_mut(root).children = vec![a1, a2, a3];
2995        (tree, root, a2, x2)
2996    }
2997
2998    #[test]
2999    fn full_chain_implicit_pins_winning_branch() {
3000        // Phase D-θ.2: full-chain implicit resolution must pick the only A
3001        // whose subtree completes A.B.C.X — neither A1 (no C) nor A3 (no C)
3002        // can satisfy the chain, so the result must equal the winning x2.
3003        let (mut tree, root, _a2, x2) = build_chain_disambiguation_tree();
3004        let mut host = HostBindings::new();
3005        host.reset_per_document();
3006        host.set_form_handle(&mut tree as *mut FormTree, root);
3007        host.reset_per_script(root, Some("calculate"));
3008
3009        let chain = vec![
3010            "A".to_string(),
3011            "B".to_string(),
3012            "C".to_string(),
3013            "X".to_string(),
3014        ];
3015        let result = host.resolve_with_full_chain(&[], &chain, Some(root));
3016        assert_eq!(result, vec![x2], "chain must pin onto the X under A2");
3017    }
3018
3019    #[test]
3020    fn full_chain_child_entry_uses_parent_ids() {
3021        // When parent_ids is provided, the entry segment must perform child
3022        // resolution from those parents (not an implicit walk). The chain
3023        // disambiguates the same way as the implicit variant.
3024        let (mut tree, root, _a2, x2) = build_chain_disambiguation_tree();
3025        let mut host = HostBindings::new();
3026        host.reset_per_document();
3027        host.set_form_handle(&mut tree as *mut FormTree, root);
3028        host.reset_per_script(root, Some("calculate"));
3029
3030        let chain = vec![
3031            "A".to_string(),
3032            "B".to_string(),
3033            "C".to_string(),
3034            "X".to_string(),
3035        ];
3036        let result = host.resolve_with_full_chain(&[root], &chain, None);
3037        assert_eq!(result, vec![x2]);
3038    }
3039
3040    #[test]
3041    fn full_chain_dead_end_returns_deepest_layer() {
3042        // If the chain runs into a dead end mid-way (e.g. "Bogus" at depth
3043        // 2) the function returns the previous (deepest reachable) layer so
3044        // the JS proxy still has something to resolve terminal props on.
3045        let (mut tree, root, _a2, _x2) = build_chain_disambiguation_tree();
3046        let mut host = HostBindings::new();
3047        host.reset_per_document();
3048        host.set_form_handle(&mut tree as *mut FormTree, root);
3049        host.reset_per_script(root, Some("calculate"));
3050
3051        let chain = vec![
3052            "A".to_string(),
3053            "B".to_string(),
3054            "C".to_string(),
3055            "Bogus".to_string(),
3056        ];
3057        let result = host.resolve_with_full_chain(&[], &chain, Some(root));
3058        // We expect to reach C under A2 (only branch that has C). The dead
3059        // end at "Bogus" returns that layer unchanged.
3060        assert_eq!(result.len(), 1);
3061    }
3062
3063    #[test]
3064    fn full_chain_no_match_returns_empty() {
3065        // When the entry segment itself has no candidate, return empty.
3066        let (mut tree, root, _a2, _x2) = build_chain_disambiguation_tree();
3067        let mut host = HostBindings::new();
3068        host.reset_per_document();
3069        host.set_form_handle(&mut tree as *mut FormTree, root);
3070        host.reset_per_script(root, Some("calculate"));
3071
3072        let chain = vec!["Nope".to_string(), "X".to_string()];
3073        let result = host.resolve_with_full_chain(&[], &chain, Some(root));
3074        assert!(result.is_empty());
3075    }
3076
3077    #[test]
3078    fn full_chain_rejects_overdepth_and_empty_segments() {
3079        let (mut tree, root, _a2, _x2) = build_chain_disambiguation_tree();
3080        let mut host = HostBindings::new();
3081        host.reset_per_document();
3082        host.set_form_handle(&mut tree as *mut FormTree, root);
3083        host.reset_per_script(root, Some("calculate"));
3084
3085        let overlong: Vec<String> = (0..=MAX_SOM_DEPTH).map(|i| format!("S{i}")).collect();
3086        let result = host.resolve_with_full_chain(&[], &overlong, Some(root));
3087        assert!(result.is_empty());
3088        // Empty segment in the middle.
3089        let with_empty = vec!["A".to_string(), "".to_string()];
3090        let result = host.resolve_with_full_chain(&[], &with_empty, Some(root));
3091        assert!(result.is_empty());
3092    }
3093
3094    #[test]
3095    fn full_chain_empty_chain_returns_parents() {
3096        // A zero-length chain trivially returns the entry layer (parent_ids).
3097        // This lets the JS proxy resolve terminal props on the entry handle
3098        // before any property accumulator has been pushed.
3099        let (mut tree, root, _a2, _x2) = build_chain_disambiguation_tree();
3100        let mut host = HostBindings::new();
3101        host.reset_per_document();
3102        host.set_form_handle(&mut tree as *mut FormTree, root);
3103        host.reset_per_script(root, Some("calculate"));
3104
3105        let result = host.resolve_with_full_chain(&[root], &[], None);
3106        assert_eq!(result, vec![root]);
3107    }
3108}