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