Skip to main content

pdf_xfa/
dynamic.rs

1// Copyright (c) 2026 Innovation Trigger B.V.
2//
3// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
4// the PDFluent Commercial Licence. See the LICENSE file in this repository --
5// that file travels with the copy you received, which a URL does not.
6
7use std::collections::HashMap;
8
9use crate::error::{Result, XfaError};
10use crate::javascript_policy::{self, JavaScriptEntryPoint};
11use crate::js_runtime::{
12    activity_allowed_for_sandbox_with_gate, presave_during_flatten_enabled, NullRuntime,
13    RuntimeMetadata, SandboxError, XfaJsRuntime,
14};
15use formcalc_interpreter::{
16    interpreter::Interpreter, lexer::tokenize, parser, som_bridge::SomResolver,
17    value::Value as FormCalcValue,
18};
19use xfa_dom_resolver::som::{parse_som, SomExpression, SomIndex, SomRoot, SomSelector};
20use xfa_layout_engine::form::{
21    EventScript, FormNodeId, FormNodeType, FormTree, GroupKind, Presence, ScriptLanguage,
22};
23
24// XFA Spec 3.3 §9.3 — Dynamic Forms Re-Layout: after script execution the
25// layout processor must re-run layout.  The spec does not prescribe a fixed
26// pass limit; Adobe typically converges in 2-3 passes.  Our limit of 3 is a
27// pragmatic cap that matches observed Adobe behavior.
28const MAX_SCRIPT_PASSES: usize = 3;
29
30/// Controls only the pre-flight handling of parsed JavaScript-bearing XFA
31/// event hooks. JavaScript execution remains denied by `javascript_policy`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum JsExecutionMode {
34    /// Abort before script execution when any JavaScript or unsupported script
35    /// language is present. This preserves the original policy-gate behavior.
36    Strict,
37    /// Skip JavaScript and unsupported-language scripts, then continue running
38    /// FormCalc and the layout pipeline. Skipped scripts are reported in the
39    /// returned outcome.
40    #[default]
41    BestEffortStatic,
42    /// **M3-B Phase B opt-in.** Route JavaScript scripts through the sandboxed
43    /// runtime adapter (`crate::js_runtime`). Requires the Cargo feature
44    /// `xfa-js-sandboxed` to be compiled in for any script to actually
45    /// execute; without the feature the runtime returns
46    /// [`SandboxError::NotCompiledIn`] and the dispatch path falls back to
47    /// the same skip behaviour as [`Self::BestEffortStatic`] while incrementing
48    /// `js_runtime_errors` so callers can observe the dead-code state.
49    /// Phase B registers no host bindings; see
50    /// `benchmarks/runs/M3B_HOST_BINDINGS_MINIMUM_SET.md` for the Phase C
51    /// roadmap.
52    SandboxedRuntime,
53}
54/// Fidelity level of the flattened output relative to the source XFA data.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum OutputQuality {
57    /// All data was bound and rendered without skipping any scripts or content.
58    #[default]
59    Exact,
60    /// Some scripts were skipped (e.g. JavaScript with `BestEffortStatic` mode);
61    /// output may differ from a full Adobe Reader render.
62    BestEffort,
63    /// All JavaScript scripts on the document executed inside the sandbox
64    /// without runtime / timeout / OOM errors (requires `xfa-js-sandboxed` feature).
65    Sandboxed,
66}
67
68impl OutputQuality {
69    /// Return a short lowercase string label suitable for logging and metrics.
70    pub fn as_str(self) -> &'static str {
71        match self {
72            Self::Exact => "exact",
73            Self::BestEffort => "best_effort",
74            Self::Sandboxed => "sandboxed",
75        }
76    }
77}
78/// Aggregate outcome of the dynamic script processing pass.
79///
80/// Returned by [`flatten_xfa_to_pdf_with_metadata`](crate::flatten_xfa_to_pdf_with_metadata)
81/// and embedded in [`FlattenMetadata`](crate::FlattenMetadata).
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct DynamicScriptOutcome {
84    /// Number of form field values that were mutated by scripts.
85    pub changes: usize,
86    /// True when the document contains at least one JavaScript event hook.
87    pub js_present: bool,
88    /// Number of JavaScript scripts that were skipped (not executed).
89    pub js_skipped: usize,
90    /// Number of scripts in unsupported languages (not FormCalc, not JavaScript) skipped.
91    pub other_skipped: usize,
92    /// Number of FormCalc scripts that ran successfully.
93    pub formcalc_run: usize,
94    /// Number of FormCalc scripts that produced an error.
95    pub formcalc_errors: usize,
96    /// Overall output quality after script processing.
97    pub output_quality: OutputQuality,
98    /// **M3-B Phase B.** Scripts that ran to completion in the sandboxed
99    /// runtime. Always 0 when mode != [`JsExecutionMode::SandboxedRuntime`]
100    /// or when the `xfa-js-sandboxed` feature is not compiled in.
101    pub js_executed: usize,
102    /// **M3-B Phase B.** Sandbox errors that did not fall under timeout / OOM
103    /// (parse error, throw, missing host binding in Phase B, FFI panic). The
104    /// dispatch path treats these as a script skip; the parent flatten never
105    /// aborts because of them (S-17 fail-open).
106    pub js_runtime_errors: usize,
107    /// **M3-B Phase B.** Per-script time-budget exhaustions.
108    pub js_timeouts: usize,
109    /// **M3-B Phase B.** Per-document memory-budget exhaustions.
110    pub js_oom: usize,
111    /// **M3-B Phase C.** Host-binding invocations.
112    pub js_host_calls: usize,
113    /// **M3-B Phase C.** Successful host-side `field.rawValue` writes.
114    pub js_mutations: usize,
115    /// **M3-B Phase D.** Successful host-side instanceManager writes.
116    pub js_instance_writes: usize,
117    /// **M3-B Phase D-β.** Successful host-side listbox clearItems / addItem writes.
118    pub js_list_writes: usize,
119    /// **M3-B Phase C.** Binding-level failures.
120    pub js_binding_errors: usize,
121    /// **M3-B Phase C.** SOM resolution misses / failures.
122    pub js_resolve_failures: usize,
123    /// **M3-B Phase D-γ.** Successful DataDom reads (children / value / child-by-name).
124    pub js_data_reads: usize,
125    /// **M3-B Phase E (XFA-JS-HOST-STUBS).** Scripts touched a host capability
126    /// that requires real viewer / user interaction (UI dialogs, signature,
127    /// submit, openList, beep, ...). The stub returned a deterministic safe
128    /// default so the script kept running; this counter records how often
129    /// such a touch happened so callers can distinguish "would-have-been
130    /// interactive" from genuine runtime errors.
131    pub js_unsupported_host_calls: usize,
132    /// **M3-B Phase D-θ.2.** Strict probe calls skipped because
133    /// `parentIds.length == 1 && chain.length == 1` (no same-name
134    /// sibling ambiguity possible). Each skipped call saves one
135    /// `resolveWithFullChainStrict` host round-trip.
136    pub js_probe_skips: usize,
137    /// **D3 (trace-only).** `<variables>` `<script>` objects collected.
138    pub variables_scripts_collected: usize,
139    /// **D3 (trace-only).** `<variables>` `<text>` data items collected.
140    pub variables_data_items_collected: usize,
141    /// **D3 (trace-only).** Script objects / data items whose registration
142    /// bound a namespace (JS-side `setVariables*` returned success).
143    pub script_objects_registered: usize,
144    /// **D3 (trace-only).** Script objects / data items that did NOT register
145    /// (Rust skip or JS-side eval failure). Observability only.
146    pub script_objects_register_failed: usize,
147    /// **D3 (trace-only).** Script objects collected under a nested subform
148    /// scope (registered to `subformVariables` only — not bare-ident visible).
149    pub script_objects_subform_scoped: usize,
150    /// **D4.** Total SOM lookups at the host resolve boundary.
151    pub som_lookups_total: usize,
152    /// **D4.** SOM lookups that resolved.
153    pub som_lookup_successes: usize,
154    /// **D4.** SOM lookups that returned NoMatch.
155    pub som_lookup_failures: usize,
156    /// **D4.** Subform-script names withheld for ambiguity (fail-closed).
157    pub som_lookup_ambiguous: usize,
158    /// **D4.** Subform-scoped script objects exposed to bare-identifier lookup.
159    pub som_subform_scripts_exposed: usize,
160    /// **D4 (trace-only).** `occur`-path SOM references (classified, not resolved).
161    pub som_occur_path_refs: usize,
162    /// **D5.** `node.occur` handle accesses (successes + failures).
163    pub occur_lookups_total: usize,
164    /// **D5.** `node.occur` accesses where the node handle was live.
165    pub occur_lookup_successes: usize,
166    /// **D5.** `node.occur` accesses where the node handle was not live.
167    pub occur_lookup_failures: usize,
168    /// **D5.** Reads of an occur property (`min`/`max`/`initial`).
169    pub occur_property_reads: usize,
170    /// **D5.** Writes to an occur property (captured, not applied).
171    pub occur_property_writes: usize,
172    /// **D5.** Writes specifically to `occur.min`.
173    pub occur_min_writes: usize,
174    /// **D5.** Writes specifically to `occur.max`.
175    pub occur_max_writes: usize,
176    /// **D5.** Occur mutations captured as intent (no layout effect).
177    pub occur_mutations_captured: usize,
178    /// **D5/D6.** Occur mutations APPLIED to layout (D5: 0; D6: >0 under `XFA_OCCUR_APPLY`).
179    pub occur_mutations_applied: usize,
180    /// **D6.** Captured occur mutations not applied (gate off / rollback / unsupported).
181    pub occur_mutations_skipped: usize,
182    /// **D6.** Captured occur mutations skipped because the target is not a
183    /// repeatable container (fail-closed).
184    pub occur_application_ambiguous: usize,
185    /// **D6.** Distinct nodes whose occur was applied.
186    pub occur_application_targets: usize,
187    /// **D7.** Presence retry was active (sandboxed + both flags + no rollback).
188    pub presence_retry_enabled: bool,
189    /// **D7.** Hidden/invisible/inactive nodes considered under occur targets.
190    pub presence_retry_candidates: usize,
191    /// **D7.** Nodes admitted (Hidden/Invisible -> Visible).
192    pub presence_retry_admitted: usize,
193    /// **D7.** Candidates skipped (e.g. Inactive, fail-closed).
194    pub presence_retry_skipped: usize,
195    /// **D7.** Total nodes under admitted subtrees (recovery breadth).
196    pub presence_retry_nodes_under_admitted: usize,
197    /// **D7.** Field/draw nodes under admitted subtrees.
198    pub presence_retry_text_nodes_admitted: usize,
199
200    // ---- Epic A: runtime observability enrichment (XFA_FLATTEN_TRACE / XFA_RUNTIME_DIAG) ----
201    /// **E-1 (XFA_FLATTEN_TRACE).** Per-script lifecycle entries (capped at 500).
202    /// Each entry records the script index, host node id/name, activity, language,
203    /// and outcome (executed|skipped_activity|skipped_mode|error|timeout).
204    pub script_lifecycle: Vec<ScriptLifecycleEntry>,
205
206    /// **E-6 (XFA_FLATTEN_TRACE).** Per-activity tally of JS scripts that were
207    /// skipped (not routed through the sandbox).
208    pub skipped_activities: SkippedActivities,
209
210    /// **E-2 (XFA_RUNTIME_DIAG).** SOM resolution misses logged at the host-binding
211    /// boundary. Capped at 200 entries.
212    pub som_fail_log: Vec<SomFailEntry>,
213
214    /// **E-3 (XFA_RUNTIME_DIAG).** Per-write instanceManager mutation log (capped at 200).
215    pub instance_write_log: Vec<InstanceWriteEntry>,
216
217    /// **E-4 (XFA_RUNTIME_DIAG).** Presence mutations observed during FormCalc
218    /// script execution (capped at 200). JS-side presence writes go through the
219    /// host binding; FormCalc goes through `set_presence` in dynamic.rs — this
220    /// captures the FormCalc path.
221    pub presence_mutation_log: Vec<PresenceMutationEntry>,
222
223    /// **E-5 (XFA_FLATTEN_TRACE).** Number of named subforms hidden by
224    /// `apply_form_dom_presence` because they had no matching form-DOM entry.
225    pub form_dom_match_failures: usize,
226
227    /// **E-5 (XFA_RUNTIME_DIAG).** Per-suppression entries from
228    /// `apply_form_dom_presence` (capped at 200).
229    pub form_dom_match_log: Vec<FormDomMatchEntry>,
230}
231
232// ---- Epic A support types ----
233
234/// E-1: one entry in `script.lifecycle[]`.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct ScriptLifecycleEntry {
237    /// Zero-based index of this script in document execution order.
238    pub script_idx: usize,
239    /// FormNodeId that owns the script.
240    pub node_id: usize,
241    /// `name` attribute of the owning node (may be empty).
242    pub node_name: String,
243    /// `activity` attribute of the event element (e.g. `"initialize"`), or empty.
244    pub activity: String,
245    /// Script language.
246    pub lang: &'static str,
247    /// Execution outcome.
248    pub outcome: &'static str,
249}
250
251/// E-6: per-activity skip tallies for JavaScript scripts.
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
253pub struct SkippedActivities {
254    /// Scripts with `activity="initialize"` skipped.
255    pub initialize: usize,
256    /// Scripts with `activity="calculate"` skipped.
257    pub calculate: usize,
258    /// Scripts with `activity="click"` skipped.
259    pub click: usize,
260    /// Scripts with `activity="docReady"` skipped.
261    pub doc_ready: usize,
262    /// Scripts with `activity="layoutReady"` skipped.
263    pub layout_ready: usize,
264    /// Scripts with any other (or absent) activity attribute skipped.
265    pub other: usize,
266}
267
268impl SkippedActivities {
269    fn bump(&mut self, activity: Option<&str>) {
270        match activity {
271            Some("initialize") => self.initialize += 1,
272            Some("calculate") => self.calculate += 1,
273            Some("click") => self.click += 1,
274            Some("docReady") => self.doc_ready += 1,
275            Some("layoutReady") => self.layout_ready += 1,
276            _ => self.other += 1,
277        }
278    }
279}
280
281/// E-2: one SOM-miss entry.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct SomFailEntry {
284    /// The SOM path that failed to resolve.
285    pub path: String,
286    /// Miss category (e.g. `"resolve_miss"`, `"formcalc_miss"`).
287    pub kind: String,
288    /// Script index (matches `ScriptLifecycleEntry::script_idx`).
289    pub script_idx: usize,
290    /// Activity at the time of the miss.
291    pub activity: String,
292}
293
294/// E-3: one instanceManager write entry.
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct InstanceWriteEntry {
297    /// Script index.
298    pub script_idx: usize,
299    /// Activity.
300    pub activity: String,
301    /// Parent container node id.
302    pub parent_node_id: usize,
303    /// Parent container node name.
304    pub parent_node_name: String,
305    /// Prototype node name (the repeated child template).
306    pub prototype_node_name: String,
307    /// Instance count before the write.
308    pub old_count: usize,
309    /// Instance count after the write.
310    pub new_count: usize,
311}
312
313/// E-4: one presence-mutation observation.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct PresenceMutationEntry {
316    /// Script index at mutation time (best-effort; may be 0 for FormCalc).
317    pub script_idx: usize,
318    /// Activity at mutation time.
319    pub activity: String,
320    /// Node id.
321    pub node_id: usize,
322    /// Node name.
323    pub node_name: String,
324    /// Presence before the change.
325    pub old_presence: String,
326    /// Presence after the change.
327    pub new_presence: String,
328}
329
330/// E-5: one form-DOM match-failure / suppression entry.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct FormDomMatchEntry {
333    /// Template FormNodeId of the hidden subform.
334    pub template_node_id: usize,
335    /// Name of the suppressed subform.
336    pub template_node_name: String,
337    /// Short reason string.
338    pub reason: String,
339}
340
341impl Default for DynamicScriptOutcome {
342    fn default() -> Self {
343        Self {
344            changes: 0,
345            js_present: false,
346            js_skipped: 0,
347            other_skipped: 0,
348            formcalc_run: 0,
349            formcalc_errors: 0,
350            output_quality: OutputQuality::Exact,
351            js_executed: 0,
352            js_runtime_errors: 0,
353            js_timeouts: 0,
354            js_oom: 0,
355            js_host_calls: 0,
356            js_mutations: 0,
357            js_instance_writes: 0,
358            js_list_writes: 0,
359            js_binding_errors: 0,
360            js_resolve_failures: 0,
361            js_data_reads: 0,
362            js_unsupported_host_calls: 0,
363            js_probe_skips: 0,
364            variables_scripts_collected: 0,
365            variables_data_items_collected: 0,
366            script_objects_registered: 0,
367            script_objects_register_failed: 0,
368            script_objects_subform_scoped: 0,
369            som_lookups_total: 0,
370            som_lookup_successes: 0,
371            som_lookup_failures: 0,
372            som_lookup_ambiguous: 0,
373            som_subform_scripts_exposed: 0,
374            som_occur_path_refs: 0,
375            occur_lookups_total: 0,
376            occur_lookup_successes: 0,
377            occur_lookup_failures: 0,
378            occur_property_reads: 0,
379            occur_property_writes: 0,
380            occur_min_writes: 0,
381            occur_max_writes: 0,
382            occur_mutations_captured: 0,
383            occur_mutations_applied: 0,
384            occur_mutations_skipped: 0,
385            occur_application_ambiguous: 0,
386            occur_application_targets: 0,
387            presence_retry_enabled: false,
388            presence_retry_candidates: 0,
389            presence_retry_admitted: 0,
390            presence_retry_skipped: 0,
391            presence_retry_nodes_under_admitted: 0,
392            presence_retry_text_nodes_admitted: 0,
393            script_lifecycle: Vec::new(),
394            skipped_activities: SkippedActivities::default(),
395            som_fail_log: Vec::new(),
396            instance_write_log: Vec::new(),
397            presence_mutation_log: Vec::new(),
398            form_dom_match_failures: 0,
399            form_dom_match_log: Vec::new(),
400        }
401    }
402}
403
404/// Snapshot of field values, presence states, and structural shape used for
405/// rollback after a failed script pass.
406///
407/// XFA-INST-MGR (2026-05-17): the snapshot now also captures every node's
408/// children list plus the total node count. When `restore_snapshot` runs it
409/// truncates any clones added by `instanceManager.addInstance` /
410/// `setInstances` and restores the original child ordering, so a rollback is
411/// structurally consistent end-to-end — not just at the field-value layer.
412/// This keeps the layout pass from seeing half-applied script mutations when
413/// scripts produced enough errors to invalidate the pass.
414///
415/// NOTE: The rollback policy itself is our own heuristic — the XFA spec does
416/// not define one. It protects against scripts that blank out all fields
417/// (broken SOM resolution, etc.) or that add structural clones we cannot
418/// safely keep after rejecting the pass.
419struct FormSnapshot {
420    field_values: Vec<(usize, String)>,
421    presences: Vec<(usize, Presence)>,
422    /// Per-node children list at snapshot time.  Indexed by `form.nodes`
423    /// position; `children[i]` is the saved `children` vec for node `i`.
424    children: Vec<Vec<FormNodeId>>,
425    /// Total node count at snapshot time.  On rollback `form.nodes` and
426    /// `form.metadata` are truncated back to this length, evicting any
427    /// runtime-created clones from the form tree.
428    node_count: usize,
429    populated_count: usize,
430}
431
432fn snapshot_form(form: &FormTree) -> FormSnapshot {
433    let mut field_values = Vec::new();
434    let mut presences = Vec::new();
435    let mut children = Vec::with_capacity(form.nodes.len());
436    let mut populated_count = 0usize;
437    for (idx, node) in form.nodes.iter().enumerate() {
438        if let FormNodeType::Field { value } = &node.node_type {
439            field_values.push((idx, value.clone()));
440            if !value.trim().is_empty() {
441                populated_count += 1;
442            }
443        }
444        presences.push((idx, form.metadata[idx].presence));
445        children.push(node.children.clone());
446    }
447    FormSnapshot {
448        field_values,
449        presences,
450        children,
451        node_count: form.nodes.len(),
452        populated_count,
453    }
454}
455
456fn restore_snapshot(form: &mut FormTree, snapshot: &FormSnapshot) {
457    for (idx, value) in &snapshot.field_values {
458        if let FormNodeType::Field { value: fv } = &mut form.nodes[*idx].node_type {
459            *fv = value.clone();
460        }
461    }
462    for (idx, presence) in &snapshot.presences {
463        form.metadata[*idx].presence = *presence;
464    }
465    // XFA-INST-MGR: drop any runtime-created clones first, THEN restore the
466    // original children lists.  Truncating must happen before assignment
467    // because the saved children vec may reference indices that the snapshot
468    // already covers (clones never receive an `xfa_id`, so `node_ids` does
469    // not need pruning — see `host::clone_subtree`).
470    if form.nodes.len() > snapshot.node_count {
471        form.nodes.truncate(snapshot.node_count);
472        form.metadata.truncate(snapshot.node_count);
473    }
474    for (idx, saved_children) in snapshot.children.iter().enumerate() {
475        if let Some(node) = form.nodes.get_mut(idx) {
476            node.children = saved_children.clone();
477        }
478    }
479}
480
481fn should_rollback(
482    form: &FormTree,
483    snapshot: &FormSnapshot,
484    errors: usize,
485    successes: usize,
486) -> bool {
487    if errors > 0 && errors > successes {
488        return true;
489    }
490    if snapshot.populated_count >= 2 {
491        let mut now_empty = 0usize;
492        for (idx, old_value) in &snapshot.field_values {
493            if old_value.trim().is_empty() {
494                continue;
495            }
496            if let FormNodeType::Field { value } = &form.nodes[*idx].node_type {
497                if value.trim().is_empty() {
498                    now_empty += 1;
499                }
500            }
501        }
502        if now_empty * 2 > snapshot.populated_count {
503            return true;
504        }
505    }
506    false
507}
508// XFA Spec 3.3 §9.3 — Dynamic Forms: after data binding, scripts run in
509// two phases: (1) initialize events fire once, (2) calculate events may
510// iterate until stable (convergence) or MAX_SCRIPT_PASSES is reached.
511// The spec (§14.3.2) defines the event model; our implementation runs
512// initialize then calculate, matching Adobe's processing order.
513//
514// NOTE: §10.6 Rule 3 states the merge-completion order as:
515//   value calcs → property calcs → validations → initialize events.
516// Our order (initialize first) differs from the spec but matches Adobe's
517// observed behavior on our 20K test corpus (97%+ SSIM). §28.2 (p1231)
518// documents Adobe's event execution insert-at-position-2 algorithm.
519//
520// The default JavaScript handling is best-effort static flattening: parsed
521// JavaScript and unsupported-language scripts are skipped and reported, while
522// FormCalc continues to run. Use `apply_dynamic_scripts_with_mode(..., Strict)`
523// when callers need the legacy whole-form JavaScript policy gate.
524
525/// Convenience entry point: runs the script pipeline with the default
526/// [`JsExecutionMode`] (currently [`JsExecutionMode::BestEffortStatic`]).
527///
528/// Prefer [`apply_dynamic_scripts_with_runtime`] when you need explicit
529/// runtime injection (e.g. in tests or when using the sandboxed runtime).
530#[doc(hidden)]
531pub fn apply_dynamic_scripts(
532    form: &mut FormTree,
533    root_id: FormNodeId,
534) -> Result<DynamicScriptOutcome> {
535    apply_dynamic_scripts_with_mode(form, root_id, JsExecutionMode::default())
536}
537
538/// Runs the script pipeline with an explicit [`JsExecutionMode`], using the
539/// internal [`NullRuntime`] (or the compiled-in QuickJS runtime for
540/// [`JsExecutionMode::SandboxedRuntime`]).
541///
542/// This is an intermediate convenience wrapper. The canonical low-level entry
543/// point is [`apply_dynamic_scripts_with_runtime`], which accepts any
544/// [`XfaJsRuntime`] implementation.
545#[doc(hidden)]
546pub fn apply_dynamic_scripts_with_mode(
547    form: &mut FormTree,
548    root_id: FormNodeId,
549    mode: JsExecutionMode,
550) -> Result<DynamicScriptOutcome> {
551    // M3-B Phase C-α (2026-05-03): when the caller asks for SandboxedRuntime
552    // and the `xfa-js-sandboxed` feature is compiled in, instantiate the
553    // real QuickJS-backed adapter. Without the feature, fall back to
554    // NullRuntime which surfaces `SandboxError::NotCompiledIn` per script
555    // (existing Phase B fallback semantics).
556    #[cfg(feature = "xfa-js-sandboxed")]
557    {
558        if mode == JsExecutionMode::SandboxedRuntime {
559            match crate::js_runtime::SandboxedRuntime::new() {
560                Ok(mut rt) => {
561                    return apply_dynamic_scripts_with_runtime(form, root_id, mode, &mut rt);
562                }
563                Err(_e) => {
564                    // QuickJS init failure is rare; fall through to NullRuntime
565                    // so the dispatch path can still record per-script errors
566                    // and the flatten succeeds in best-effort mode.
567                }
568            }
569        }
570    }
571    apply_dynamic_scripts_with_runtime(form, root_id, mode, &mut NullRuntime::new())
572}
573
574/// D6: opt-in gate for applying captured `occur.min` mutations to layout.
575/// Default OFF. Even in sandboxed mode, occur application only happens when
576/// `XFA_OCCUR_APPLY=1`, so the committed default (and default sandboxed
577/// capture-only) behaviour is unchanged.
578fn occur_apply_enabled() -> bool {
579    std::env::var("XFA_OCCUR_APPLY").ok().as_deref() == Some("1")
580}
581
582/// D7: opt-in gate for presence/visibility retry. Default OFF. Requires
583/// `XFA_PRESENCE_RETRY=1`; the dispatch path additionally requires
584/// `XFA_OCCUR_APPLY=1` (occur application is the prerequisite for the retry).
585fn presence_retry_enabled() -> bool {
586    std::env::var("XFA_PRESENCE_RETRY").ok().as_deref() == Some("1")
587}
588
589/// Epic A: true when `XFA_RUNTIME_DIAG` is set to `"1"`.  Gates the verbose
590/// per-entry log arrays (E-2, E-3, E-4, E-5-log).  Default OFF so normal
591/// flattens are byte-identical and zero-cost.
592pub(crate) fn runtime_diag_enabled() -> bool {
593    std::env::var("XFA_RUNTIME_DIAG").ok().as_deref() == Some("1")
594}
595
596// Epic A E-4: thread-local accumulator for FormCalc presence mutations.
597// Active only during `apply_dynamic_scripts_with_runtime` when
598// `XFA_RUNTIME_DIAG=1`.  Using thread-local avoids threading an extra Vec
599// through `run_script_phase` / `write_formcalc_value` / `set_presence`.
600std::thread_local! {
601    static PRESENCE_MUT_LOG: std::cell::RefCell<Option<Vec<PresenceMutationEntry>>> =
602        const { std::cell::RefCell::new(None) };
603}
604
605/// Push a presence mutation entry into the thread-local log.
606/// Called from `set_presence_inner` when an entry was created.
607fn push_presence_mutation(
608    node_id: usize,
609    node_name: &str,
610    old_presence: &'static str,
611    new_presence: &'static str,
612) {
613    PRESENCE_MUT_LOG.with(|cell| {
614        if let Some(ref mut log) = *cell.borrow_mut() {
615            if log.len() < 200 {
616                log.push(PresenceMutationEntry {
617                    // script_idx/activity are not readily available at this call
618                    // depth — report 0/"" (best-effort per Epic A spec note).
619                    script_idx: 0,
620                    activity: String::new(),
621                    node_id,
622                    node_name: node_name.to_string(),
623                    old_presence: old_presence.to_string(),
624                    new_presence: new_presence.to_string(),
625                });
626            }
627        }
628    });
629}
630
631/// Phase B entry point that lets the caller inject a sandboxed runtime
632/// adapter. When `mode == JsExecutionMode::SandboxedRuntime` the supplied
633/// `runtime` is consulted for every JavaScript script whose `<event activity>`
634/// is in [`crate::js_runtime::SANDBOX_ACTIVITY_ALLOWLIST`]. UI / submission
635/// activities skip the runtime entirely and are recorded as `js_skipped`,
636/// matching `BestEffortStatic` behaviour for those scripts.
637///
638/// Other modes ignore `runtime` entirely; callers that just need the
639/// existing strict / best-effort behaviour should use
640/// [`apply_dynamic_scripts_with_mode`] (which routes through
641/// [`crate::js_runtime::NullRuntime`]).
642pub fn apply_dynamic_scripts_with_runtime(
643    form: &mut FormTree,
644    root_id: FormNodeId,
645    mode: JsExecutionMode,
646    runtime: &mut dyn XfaJsRuntime,
647) -> Result<DynamicScriptOutcome> {
648    let parents = build_parent_map(form, root_id);
649    let all_scripts: Vec<(FormNodeId, Vec<EventScript>)> = form
650        .nodes
651        .iter()
652        .enumerate()
653        .filter_map(|(idx, _)| {
654            let node_id = FormNodeId(idx);
655            let scripts = form.meta(node_id).event_scripts.clone();
656            (!scripts.is_empty()).then_some((node_id, scripts))
657        })
658        .collect();
659
660    let has_unsupported_script = all_scripts.iter().any(|(_, node_scripts)| {
661        node_scripts
662            .iter()
663            .any(|script| script.language != ScriptLanguage::FormCalc)
664    });
665
666    if mode == JsExecutionMode::Strict && has_unsupported_script {
667        return Err(javascript_policy::reject_execution(
668            JavaScriptEntryPoint::XfaEventHook,
669        ));
670    }
671
672    let mut js_skipped = 0usize;
673    let mut other_skipped = 0usize;
674    let mut sandbox_metadata = RuntimeMetadata::default();
675    let mut scripts = Vec::new();
676    let sandbox_active = mode == JsExecutionMode::SandboxedRuntime;
677    let snapshot = snapshot_form(form);
678
679    // D1.B (XFA Product Quality Wave 3 — preSave gated allow). The env-var
680    // gate is read ONCE per flatten so all scripts in this document see the
681    // same decision. Default OFF; flipping requires
682    // `XFA_PRESAVE_DURING_FLATTEN=1`. The host-binding layer is informed via
683    // `set_presave_gate` so dispatch and host stay in lock-step (defence-
684    // in-depth §2 of the policy doc).
685    let presave_gate = sandbox_active && presave_during_flatten_enabled();
686
687    // Epic A E-1/E-6: read gates once; zero cost unless env is set.
688    let trace_enabled = crate::flatten_trace::enabled();
689    let diag_enabled = runtime_diag_enabled();
690
691    // E-1: lifecycle log (cap 500).
692    let mut script_lifecycle: Vec<ScriptLifecycleEntry> = Vec::new();
693    // E-6: per-activity skip tally.
694    let mut skipped_activities = SkippedActivities::default();
695    // Running script index (mirrors host.rs next_script_idx for JS; for
696    // FormCalc scripts we share the same monotonic counter).
697    let mut dispatch_script_idx: usize = 0;
698
699    if sandbox_active {
700        // Best-effort init / reset; init failures are non-fatal — the
701        // dispatch path will record them as runtime_errors per script.
702        let _ = runtime.init();
703        let _ = runtime.reset_for_new_document();
704        let _ = runtime.set_form_handle(form as *mut FormTree, root_id);
705        runtime.set_presave_gate(presave_gate);
706    }
707
708    for (node_id, node_scripts) in all_scripts {
709        let node = form.get(node_id);
710        let node_name = if trace_enabled || diag_enabled {
711            node.name.clone()
712        } else {
713            String::new()
714        };
715        let mut formcalc_scripts = Vec::new();
716        for script in node_scripts {
717            match script.language {
718                ScriptLanguage::FormCalc => formcalc_scripts.push(script),
719                ScriptLanguage::JavaScript => {
720                    let activity_str = script.activity.as_deref().unwrap_or("");
721                    if sandbox_active
722                        && activity_allowed_for_sandbox_with_gate(
723                            script.activity.as_deref(),
724                            presave_gate,
725                        )
726                    {
727                        let this_idx = dispatch_script_idx;
728                        dispatch_script_idx += 1;
729                        let _ = runtime.reset_per_script(node_id, script.activity.as_deref());
730                        let outcome_str = match runtime
731                            .execute_script(script.activity.as_deref(), &script.script)
732                        {
733                            Ok(_) => {
734                                // Counter increment lives on `take_metadata()`.
735                                "executed"
736                            }
737                            Err(SandboxError::Timeout) => {
738                                js_skipped += 1;
739                                "timeout"
740                            }
741                            Err(SandboxError::OutOfMemory) => {
742                                js_skipped += 1;
743                                "error"
744                            }
745                            Err(e) => {
746                                // M3-B Phase C-α: surface the per-script error
747                                // class via `log::debug!` for normal builds and
748                                // also via stderr when `XFA_JS_DEBUG=1` is set
749                                // (operator triage aid; xfa-cli does not init
750                                // a logger that respects RUST_LOG).
751                                log::debug!(
752                                    "sandbox script error on activity={:?}: {}",
753                                    script.activity.as_deref(),
754                                    e
755                                );
756                                if std::env::var("XFA_JS_DEBUG").ok().as_deref() == Some("1") {
757                                    eprintln!(
758                                        "XFA_JS_DEBUG sandbox script error on activity={:?}: {}",
759                                        script.activity.as_deref(),
760                                        e
761                                    );
762                                }
763                                js_skipped += 1;
764                                "error"
765                            }
766                        };
767                        // E-1: record lifecycle entry when tracing is on.
768                        if trace_enabled && script_lifecycle.len() < 500 {
769                            script_lifecycle.push(ScriptLifecycleEntry {
770                                script_idx: this_idx,
771                                node_id: node_id.0,
772                                node_name: node_name.clone(),
773                                activity: activity_str.to_string(),
774                                lang: "javascript",
775                                outcome: outcome_str,
776                            });
777                        }
778                    } else {
779                        js_skipped += 1;
780                        // E-1: record skipped lifecycle entry.
781                        if trace_enabled && script_lifecycle.len() < 500 {
782                            let skip_reason = if sandbox_active {
783                                "skipped_activity"
784                            } else {
785                                "skipped_mode"
786                            };
787                            script_lifecycle.push(ScriptLifecycleEntry {
788                                script_idx: dispatch_script_idx,
789                                node_id: node_id.0,
790                                node_name: node_name.clone(),
791                                activity: activity_str.to_string(),
792                                lang: "javascript",
793                                outcome: skip_reason,
794                            });
795                        }
796                        // E-6: tally per-activity skips.
797                        if trace_enabled {
798                            skipped_activities.bump(script.activity.as_deref());
799                        }
800                        dispatch_script_idx += 1;
801                    }
802                }
803                ScriptLanguage::Other => {
804                    other_skipped += 1;
805                    // E-1: log other-language skips.
806                    if trace_enabled && script_lifecycle.len() < 500 {
807                        script_lifecycle.push(ScriptLifecycleEntry {
808                            script_idx: dispatch_script_idx,
809                            node_id: node_id.0,
810                            node_name: node_name.clone(),
811                            activity: script.activity.as_deref().unwrap_or("").to_string(),
812                            lang: "other",
813                            outcome: "skipped_mode",
814                        });
815                    }
816                    dispatch_script_idx += 1;
817                }
818            }
819        }
820        if !formcalc_scripts.is_empty() {
821            scripts.push((node_id, formcalc_scripts));
822        }
823    }
824
825    let mut captured_occur: Vec<(usize, String, i64)> = Vec::new();
826    // Epic A E-2/E-3: logs drained from host bindings.
827    let mut sandbox_diag = crate::js_runtime::RuntimeDiagLogs::default();
828    if sandbox_active {
829        let _ = runtime.set_form_handle(std::ptr::null_mut(), root_id);
830        sandbox_metadata = runtime.take_metadata();
831        captured_occur = runtime.take_occur_mutations();
832        if diag_enabled {
833            sandbox_diag = runtime.take_diag_logs();
834        }
835    }
836
837    let mut stats = ScriptStats::default();
838
839    // Epic A E-4: arm the thread-local presence mutation log.
840    if diag_enabled {
841        PRESENCE_MUT_LOG.with(|cell| {
842            *cell.borrow_mut() = Some(Vec::new());
843        });
844    }
845
846    let mut changes = sandbox_metadata
847        .mutations
848        .saturating_add(sandbox_metadata.instance_writes)
849        + run_script_phase(
850            form,
851            root_id,
852            &parents,
853            &scripts,
854            ScriptPhase::Initialize,
855            1,
856            &mut stats,
857        )?
858        + run_script_phase(
859            form,
860            root_id,
861            &parents,
862            &scripts,
863            ScriptPhase::Calculate,
864            MAX_SCRIPT_PASSES,
865            &mut stats,
866        )?;
867
868    // E-4: drain and disarm.
869    let presence_mutation_log = if diag_enabled {
870        PRESENCE_MUT_LOG.with(|cell| cell.borrow_mut().take().unwrap_or_default())
871    } else {
872        Vec::new()
873    };
874
875    let sandbox_rollback_errors = sandbox_metadata
876        .runtime_errors
877        .saturating_add(sandbox_metadata.timeouts)
878        .saturating_add(sandbox_metadata.oom)
879        .saturating_add(sandbox_metadata.binding_errors);
880    let rollback_errors = stats.errors.saturating_add(sandbox_rollback_errors);
881    let rollback_successes = stats.successes.saturating_add(sandbox_metadata.executed);
882
883    let rolled_back = should_rollback(form, &snapshot, rollback_errors, rollback_successes);
884    if rolled_back {
885        restore_snapshot(form, &snapshot);
886        changes = 0;
887    }
888
889    // D6: apply captured `occur.min` mutations to the form before layout.
890    // Strictly gated: sandboxed mode + the opt-in `XFA_OCCUR_APPLY=1` flag +
891    // the script pass did NOT roll back. Default flatten and the default
892    // sandboxed (capture-only) behaviour are unchanged. Each captured write is
893    // applied only to a live, repeatable container node (Subform/Area/ExclGroup);
894    // everything else fails closed (counted, not applied). `occur.max` writes
895    // are captured but not applied in D6 (min-only scope).
896    let mut occur_applied_targets: std::collections::HashSet<usize> =
897        std::collections::HashSet::new();
898    if sandbox_active && !rolled_back && occur_apply_enabled() && !captured_occur.is_empty() {
899        for (idx, prop, value) in &captured_occur {
900            if prop != "min" {
901                // occur.max captured but not applied in D6.
902                sandbox_metadata.occur_mutations_skipped =
903                    sandbox_metadata.occur_mutations_skipped.saturating_add(1);
904                continue;
905            }
906            if *value < 0 || *idx >= form.nodes.len() {
907                sandbox_metadata.occur_mutations_skipped =
908                    sandbox_metadata.occur_mutations_skipped.saturating_add(1);
909                continue;
910            }
911            let nid = FormNodeId(*idx);
912            let is_repeatable = matches!(
913                form.get(nid).node_type,
914                FormNodeType::Subform | FormNodeType::Area | FormNodeType::ExclGroup
915            );
916            if !is_repeatable {
917                // Fail closed: only repeatable containers may have occur applied.
918                sandbox_metadata.occur_application_ambiguous = sandbox_metadata
919                    .occur_application_ambiguous
920                    .saturating_add(1);
921                sandbox_metadata.occur_mutations_skipped =
922                    sandbox_metadata.occur_mutations_skipped.saturating_add(1);
923                continue;
924            }
925            let v = *value as u32;
926            let node = form.get_mut(nid);
927            node.occur.min = v;
928            if node.occur.initial < v {
929                node.occur.initial = v;
930            }
931            if let Some(m) = node.occur.max {
932                if m < v {
933                    node.occur.max = Some(v);
934                }
935            }
936            sandbox_metadata.occur_mutations_applied =
937                sandbox_metadata.occur_mutations_applied.saturating_add(1);
938            occur_applied_targets.insert(*idx);
939        }
940        sandbox_metadata.occur_application_targets = sandbox_metadata
941            .occur_application_targets
942            .saturating_add(occur_applied_targets.len());
943    } else if !captured_occur.is_empty() {
944        // Captured but the apply gate is closed (default capture-only path):
945        // count them as skipped for observability without touching layout.
946        sandbox_metadata.occur_mutations_skipped = sandbox_metadata
947            .occur_mutations_skipped
948            .saturating_add(captured_occur.len());
949    }
950
951    // D7: opt-in presence/visibility retry. Strictly gated: sandboxed +
952    // `XFA_PRESENCE_RETRY=1` + `XFA_OCCUR_APPLY=1` (occur application is the
953    // prerequisite) + no rollback. Admits (`Hidden`/`Invisible` -> `Visible`)
954    // ONLY nodes that are an occur-applied target or a descendant of one
955    // (occur-related, unambiguous). `Inactive` is skipped (fail-closed). Default
956    // and default-sandboxed behaviour are unchanged.
957    let mut pr_candidates = 0usize;
958    let mut pr_admitted = 0usize;
959    let mut pr_skipped = 0usize;
960    let mut pr_nodes_under_admitted = 0usize;
961    let mut pr_text_nodes_admitted = 0usize;
962    let presence_retry_active = sandbox_active
963        && !rolled_back
964        && occur_apply_enabled()
965        && presence_retry_enabled()
966        && !occur_applied_targets.is_empty();
967    if presence_retry_active {
968        let mut visited: std::collections::HashSet<usize> = std::collections::HashSet::new();
969        let mut stack: Vec<FormNodeId> = occur_applied_targets
970            .iter()
971            .map(|&i| FormNodeId(i))
972            .collect();
973        let mut admitted_ids: std::collections::HashSet<usize> = std::collections::HashSet::new();
974        while let Some(nid) = stack.pop() {
975            if nid.0 >= form.nodes.len() || !visited.insert(nid.0) {
976                continue;
977            }
978            let presence = form.meta(nid).presence;
979            if presence == Presence::Hidden || presence == Presence::Invisible {
980                pr_candidates += 1;
981                // Fail-closed safety: never un-hide `Inactive` (intentionally
982                // removed); only `Hidden`/`Invisible` are admitted here.
983                form.meta_mut(nid).presence = Presence::Visible;
984                pr_admitted += 1;
985                admitted_ids.insert(nid.0);
986            } else if presence == Presence::Inactive {
987                pr_candidates += 1;
988                pr_skipped += 1;
989            }
990            for &c in &form.get(nid).children {
991                stack.push(c);
992            }
993        }
994        // Count content under admitted subtrees (observability of recovery).
995        for &aid in &admitted_ids {
996            let mut s = vec![FormNodeId(aid)];
997            let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
998            while let Some(n) = s.pop() {
999                if n.0 >= form.nodes.len() || !seen.insert(n.0) {
1000                    continue;
1001                }
1002                pr_nodes_under_admitted += 1;
1003                if let FormNodeType::Draw(_) | FormNodeType::Field { .. } = form.get(n).node_type {
1004                    pr_text_nodes_admitted += 1;
1005                }
1006                for &c in &form.get(n).children {
1007                    s.push(c);
1008                }
1009            }
1010        }
1011    }
1012
1013    let js_seen_count = js_skipped + sandbox_metadata.executed;
1014    let js_present = js_seen_count > 0;
1015    let output_quality = if sandbox_active && js_present && sandbox_metadata.is_clean() {
1016        OutputQuality::Sandboxed
1017    } else if (sandbox_active && js_present) || js_skipped > 0 || other_skipped > 0 {
1018        OutputQuality::BestEffort
1019    } else {
1020        OutputQuality::Exact
1021    };
1022
1023    Ok(DynamicScriptOutcome {
1024        changes,
1025        js_present,
1026        js_skipped,
1027        other_skipped,
1028        formcalc_run: stats.formcalc_run,
1029        formcalc_errors: stats.formcalc_errors,
1030        output_quality,
1031        js_executed: sandbox_metadata.executed,
1032        js_runtime_errors: sandbox_metadata.runtime_errors,
1033        js_timeouts: sandbox_metadata.timeouts,
1034        js_oom: sandbox_metadata.oom,
1035        js_host_calls: sandbox_metadata.host_calls,
1036        js_mutations: sandbox_metadata.mutations,
1037        js_instance_writes: sandbox_metadata.instance_writes,
1038        js_list_writes: sandbox_metadata.list_writes,
1039        js_binding_errors: sandbox_metadata.binding_errors,
1040        js_resolve_failures: sandbox_metadata.resolve_failures,
1041        js_data_reads: sandbox_metadata.data_reads,
1042        js_unsupported_host_calls: sandbox_metadata.unsupported_host_calls,
1043        js_probe_skips: sandbox_metadata.probe_skips,
1044        variables_scripts_collected: sandbox_metadata.variables_scripts_collected,
1045        variables_data_items_collected: sandbox_metadata.variables_data_items_collected,
1046        script_objects_registered: sandbox_metadata.script_objects_registered,
1047        script_objects_register_failed: sandbox_metadata.script_objects_register_failed,
1048        script_objects_subform_scoped: sandbox_metadata.script_objects_subform_scoped,
1049        som_lookups_total: sandbox_metadata.som_lookups_total,
1050        som_lookup_successes: sandbox_metadata.som_lookup_successes,
1051        som_lookup_failures: sandbox_metadata.som_lookup_failures,
1052        som_lookup_ambiguous: sandbox_metadata.som_lookup_ambiguous,
1053        som_subform_scripts_exposed: sandbox_metadata.som_subform_scripts_exposed,
1054        som_occur_path_refs: sandbox_metadata.som_occur_path_refs,
1055        occur_lookups_total: sandbox_metadata.occur_lookups_total,
1056        occur_lookup_successes: sandbox_metadata.occur_lookup_successes,
1057        occur_lookup_failures: sandbox_metadata.occur_lookup_failures,
1058        occur_property_reads: sandbox_metadata.occur_property_reads,
1059        occur_property_writes: sandbox_metadata.occur_property_writes,
1060        occur_min_writes: sandbox_metadata.occur_min_writes,
1061        occur_max_writes: sandbox_metadata.occur_max_writes,
1062        occur_mutations_captured: sandbox_metadata.occur_mutations_captured,
1063        occur_mutations_applied: sandbox_metadata.occur_mutations_applied,
1064        occur_mutations_skipped: sandbox_metadata.occur_mutations_skipped,
1065        occur_application_ambiguous: sandbox_metadata.occur_application_ambiguous,
1066        occur_application_targets: sandbox_metadata.occur_application_targets,
1067        presence_retry_enabled: presence_retry_active,
1068        presence_retry_candidates: pr_candidates,
1069        presence_retry_admitted: pr_admitted,
1070        presence_retry_skipped: pr_skipped,
1071        presence_retry_nodes_under_admitted: pr_nodes_under_admitted,
1072        presence_retry_text_nodes_admitted: pr_text_nodes_admitted,
1073        // Epic A fields.
1074        script_lifecycle,
1075        skipped_activities,
1076        som_fail_log: sandbox_diag.som_fail_log,
1077        instance_write_log: sandbox_diag.instance_write_log,
1078        presence_mutation_log,
1079        form_dom_match_failures: 0,
1080        form_dom_match_log: Vec::new(),
1081    })
1082}
1083
1084// ===========================================================================
1085// Phase 2 — interactive commit loop
1086//
1087// The static-flatten pipeline above approximates the runtime form state for a
1088// one-shot render. The interactive path below is the *real* runtime: it fires
1089// user-edit events (`change`/`click`) and the open-time lifecycle with
1090// `field.presence` writes landing in the tree (the host gate is armed via
1091// `runtime.set_interactive(true)`), so the caller can re-layout and observe
1092// the dynamic visibility model. It deliberately omits the static heuristics
1093// (rollback, occur-apply, presence-retry) — those exist only to fake what we
1094// now do for real.
1095// ===========================================================================
1096
1097/// Which user-edit event an interactive commit dispatches on the edited field.
1098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1099#[non_exhaustive]
1100pub enum InteractiveTrigger {
1101    /// `change` — a value edit was committed (the dominant UEA visibility driver).
1102    Change,
1103    /// `click` — a button / checkbox press.
1104    Click,
1105}
1106
1107impl InteractiveTrigger {
1108    fn activity(self) -> &'static str {
1109        match self {
1110            InteractiveTrigger::Change => "change",
1111            InteractiveTrigger::Click => "click",
1112        }
1113    }
1114}
1115
1116/// One presence transition observed across an interactive episode.
1117#[derive(Debug, Clone)]
1118pub struct PresenceChange {
1119    /// The node whose presence changed.
1120    pub node: FormNodeId,
1121    /// The node's name (for diagnostics).
1122    pub name: String,
1123    /// Presence before the episode.
1124    pub before: Presence,
1125    /// Presence after the episode.
1126    pub after: Presence,
1127}
1128
1129/// Why a script did not complete in an interactive episode.
1130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1131#[non_exhaustive]
1132pub enum XfaScriptErrorKind {
1133    /// The script ran and threw, or failed to parse.
1134    Threw,
1135    /// The script used up its time budget and was stopped.
1136    Timeout,
1137    /// The sandbox would not run the script: too large, a rejected pattern,
1138    /// a denied activity, a resource limit, or no runtime compiled in.
1139    Refused,
1140}
1141
1142impl XfaScriptErrorKind {
1143    fn of(error: &SandboxError) -> Self {
1144        match error {
1145            SandboxError::Timeout | SandboxError::WallTimeExceeded(_) => Self::Timeout,
1146            SandboxError::ScriptError(_) | SandboxError::PanicCaptured(_) => Self::Threw,
1147            _ => Self::Refused,
1148        }
1149    }
1150}
1151
1152/// One script that did not complete, and how.
1153#[derive(Debug, Clone, PartialEq, Eq)]
1154#[non_exhaustive]
1155pub struct XfaScriptError {
1156    /// `<node name>:<activity>#<position among the node's scripts>`. Stable for
1157    /// a given template, so a host can dedupe or point at the script.
1158    pub script_id: String,
1159    /// How it failed.
1160    pub kind: XfaScriptErrorKind,
1161}
1162
1163/// Scripts the episode did not run, by language.
1164#[derive(Debug, Clone, Default, PartialEq, Eq)]
1165#[non_exhaustive]
1166pub struct XfaScriptsSkipped {
1167    /// JavaScript scripts that failed (they are listed in `runtime_errors`).
1168    pub javascript: usize,
1169    /// FormCalc scripts (declared, or the XFA default when no language is
1170    /// given). The sandboxed runtime runs JavaScript only.
1171    pub formcalc: usize,
1172    /// Scripts in any other language.
1173    pub other: usize,
1174}
1175
1176/// What interactive episodes did not run, and why. Each script is counted
1177/// once however many calculate passes or episodes visit it.
1178#[derive(Debug, Clone, Default, PartialEq, Eq)]
1179#[non_exhaustive]
1180pub struct XfaScriptReport {
1181    /// Scripts not run, per language.
1182    pub skipped: XfaScriptsSkipped,
1183    /// Scripts that threw, timed out or were refused.
1184    pub runtime_errors: Vec<XfaScriptError>,
1185    /// Every script counted so far: id -> language.
1186    counted: std::collections::BTreeMap<String, ScriptLanguage>,
1187}
1188
1189impl XfaScriptReport {
1190    /// Fold another report into this one; a script already counted here is not
1191    /// counted twice.
1192    pub fn merge(&mut self, other: XfaScriptReport) {
1193        for (id, language) in &other.counted {
1194            self.skip(id, language);
1195        }
1196        for error in other.runtime_errors {
1197            if !self
1198                .runtime_errors
1199                .iter()
1200                .any(|e| e.script_id == error.script_id)
1201            {
1202                self.runtime_errors.push(error);
1203            }
1204        }
1205    }
1206
1207    /// True when every script that was due ran.
1208    pub fn is_clean(&self) -> bool {
1209        self.runtime_errors.is_empty() && self.counted.is_empty()
1210    }
1211
1212    fn skip(&mut self, id: &str, language: &ScriptLanguage) -> bool {
1213        if self.counted.contains_key(id) {
1214            return false;
1215        }
1216        self.counted.insert(id.to_string(), *language);
1217        match language {
1218            ScriptLanguage::JavaScript => self.skipped.javascript += 1,
1219            ScriptLanguage::FormCalc => self.skipped.formcalc += 1,
1220            _ => self.skipped.other += 1,
1221        }
1222        true
1223    }
1224
1225    fn fail(&mut self, id: &str, error: &SandboxError) {
1226        if self.skip(id, &ScriptLanguage::JavaScript) {
1227            self.runtime_errors.push(XfaScriptError {
1228                script_id: id.to_string(),
1229                kind: XfaScriptErrorKind::of(error),
1230            });
1231        }
1232    }
1233}
1234
1235/// Outcome of [`apply_interactive_open`] or [`apply_interactive_commit`].
1236#[derive(Debug, Clone, Default)]
1237pub struct InteractiveOutcome {
1238    /// Scripts the runtime executed.
1239    pub scripts_executed: usize,
1240    /// Scripts skipped (denied activity, unsupported, or no runtime compiled in).
1241    pub scripts_skipped: usize,
1242    /// Host mutations (value + presence writes) the scripts performed.
1243    pub host_mutations: usize,
1244    /// instanceManager add/remove/setInstances writes.
1245    pub instance_writes: usize,
1246    /// Runtime errors (timeouts, OOM, exceptions).
1247    pub runtime_errors: usize,
1248    /// Per-node presence transitions (only nodes that actually changed).
1249    pub presence_changes: Vec<PresenceChange>,
1250}
1251
1252/// Run the interactive open lifecycle: `initialize` → `docReady` →
1253/// `calculate` (bounded) across the whole tree, with interactive mode armed so
1254/// `field.presence` writes land. Establishes the correct initial visible state
1255/// for a dynamic form before any user edit. The caller re-layouts afterwards.
1256pub fn apply_interactive_open(
1257    form: &mut FormTree,
1258    root_id: FormNodeId,
1259    runtime: &mut dyn XfaJsRuntime,
1260) -> Result<InteractiveOutcome> {
1261    apply_interactive_open_reported(form, root_id, runtime).map(|(outcome, _)| outcome)
1262}
1263
1264/// [`apply_interactive_open`] plus the scripts it did not run.
1265pub fn apply_interactive_open_reported(
1266    form: &mut FormTree,
1267    root_id: FormNodeId,
1268    runtime: &mut dyn XfaJsRuntime,
1269) -> Result<(InteractiveOutcome, XfaScriptReport)> {
1270    run_interactive_episode(form, root_id, runtime, None, &["initialize", "docReady"])
1271}
1272
1273/// Run an interactive commit: dispatch the edited field's `change`/`click`
1274/// scripts, then a bounded global `calculate` pass so calculated dependents and
1275/// their presence toggles update. Presence/value writes land directly in
1276/// `form` via the host; the caller re-layouts to realise them.
1277///
1278/// `changed` is the field node the user edited; its value must already be set
1279/// in `form` (the session writes it before calling this) so the change script
1280/// reads the new value through `this.rawValue`.
1281pub fn apply_interactive_commit(
1282    form: &mut FormTree,
1283    root_id: FormNodeId,
1284    changed: FormNodeId,
1285    triggers: &[InteractiveTrigger],
1286    runtime: &mut dyn XfaJsRuntime,
1287) -> Result<InteractiveOutcome> {
1288    apply_interactive_commit_reported(form, root_id, changed, triggers, runtime)
1289        .map(|(outcome, _)| outcome)
1290}
1291
1292/// [`apply_interactive_commit`] plus the scripts it did not run.
1293pub fn apply_interactive_commit_reported(
1294    form: &mut FormTree,
1295    root_id: FormNodeId,
1296    changed: FormNodeId,
1297    triggers: &[InteractiveTrigger],
1298    runtime: &mut dyn XfaJsRuntime,
1299) -> Result<(InteractiveOutcome, XfaScriptReport)> {
1300    run_interactive_episode(form, root_id, runtime, Some((changed, triggers)), &[])
1301}
1302
1303/// Shared interactive driver. `focused` (when set) dispatches the given node's
1304/// scripts for the trigger activities first; then `pre_calculate_phases` run
1305/// for ALL nodes once each (open lifecycle), and finally a bounded global
1306/// `calculate` pass runs for ALL nodes until it converges.
1307fn run_interactive_episode(
1308    form: &mut FormTree,
1309    root_id: FormNodeId,
1310    runtime: &mut dyn XfaJsRuntime,
1311    focused: Option<(FormNodeId, &[InteractiveTrigger])>,
1312    pre_calculate_phases: &[&str],
1313) -> Result<(InteractiveOutcome, XfaScriptReport)> {
1314    let before_presence: Vec<Presence> = (0..form.nodes.len())
1315        .map(|idx| form.meta(FormNodeId(idx)).presence)
1316        .collect();
1317
1318    let all_scripts = collect_node_scripts(form);
1319    // Names are read before the runtime holds the form.
1320    let names: HashMap<FormNodeId, String> = all_scripts
1321        .iter()
1322        .map(|(id, _)| (*id, form.get(*id).name.clone()))
1323        .collect();
1324    let mut report = XfaScriptReport::default();
1325
1326    // Arm the runtime: install the form handle and the interactive gate AFTER
1327    // reset so `change`/`click` dispatch and `setPresence` writes are accepted.
1328    let _ = runtime.init();
1329    let _ = runtime.reset_for_new_document();
1330    let _ = runtime.set_form_handle(form as *mut FormTree, root_id);
1331    runtime.set_interactive(true);
1332
1333    let mut outcome = InteractiveOutcome::default();
1334
1335    // 1. Focused user-edit dispatch on the changed field only.
1336    if let Some((node, triggers)) = focused {
1337        if let Some((_, scripts)) = all_scripts.iter().find(|(id, _)| *id == node) {
1338            for trigger in triggers {
1339                dispatch_node_activity(
1340                    runtime,
1341                    scripts,
1342                    node,
1343                    &names[&node],
1344                    trigger.activity(),
1345                    &mut outcome,
1346                    &mut report,
1347                );
1348            }
1349        }
1350    }
1351
1352    // 2. Open-lifecycle phases (initialize/docReady), each once for all nodes.
1353    for phase in pre_calculate_phases {
1354        for (node_id, scripts) in &all_scripts {
1355            dispatch_node_activity(
1356                runtime,
1357                scripts,
1358                *node_id,
1359                &names[node_id],
1360                phase,
1361                &mut outcome,
1362                &mut report,
1363            );
1364        }
1365    }
1366
1367    // Drain the focused + lifecycle metadata before the calculate loop so the
1368    // per-pass convergence check sees only that pass's mutations.
1369    let pre_meta = runtime.take_metadata();
1370    let mut cumulative_mutations = pre_meta.mutations;
1371    let mut cumulative_instances = pre_meta.instance_writes;
1372    outcome.runtime_errors += pre_meta.runtime_errors + pre_meta.timeouts + pre_meta.oom;
1373
1374    // 3. Bounded global calculate until it stops mutating (Adobe converges in
1375    //    a few passes; MAX_SCRIPT_PASSES caps it).
1376    for _ in 0..MAX_SCRIPT_PASSES {
1377        for (node_id, scripts) in &all_scripts {
1378            dispatch_node_activity(
1379                runtime,
1380                scripts,
1381                *node_id,
1382                &names[node_id],
1383                "calculate",
1384                &mut outcome,
1385                &mut report,
1386            );
1387        }
1388        let pass_meta = runtime.take_metadata();
1389        cumulative_mutations += pass_meta.mutations;
1390        cumulative_instances += pass_meta.instance_writes;
1391        outcome.runtime_errors += pass_meta.runtime_errors + pass_meta.timeouts + pass_meta.oom;
1392        if pass_meta.mutations == 0 && pass_meta.instance_writes == 0 {
1393            break;
1394        }
1395    }
1396
1397    // Detach the form handle and record the cumulative totals.
1398    let _ = runtime.set_form_handle(std::ptr::null_mut(), root_id);
1399    outcome.host_mutations = cumulative_mutations;
1400    outcome.instance_writes = cumulative_instances;
1401
1402    // Diff presence to report what the episode revealed/hid.
1403    for (idx, &before) in before_presence.iter().enumerate() {
1404        let node = FormNodeId(idx);
1405        let after = form.meta(node).presence;
1406        if before != after {
1407            outcome.presence_changes.push(PresenceChange {
1408                node,
1409                name: form.get(node).name.clone(),
1410                before,
1411                after,
1412            });
1413        }
1414    }
1415
1416    Ok((outcome, report))
1417}
1418
1419/// Collect `(node, scripts)` for every node carrying event scripts.
1420fn collect_node_scripts(form: &FormTree) -> Vec<(FormNodeId, Vec<EventScript>)> {
1421    form.nodes
1422        .iter()
1423        .enumerate()
1424        .filter_map(|(idx, _)| {
1425            let node_id = FormNodeId(idx);
1426            let scripts = form.meta(node_id).event_scripts.clone();
1427            (!scripts.is_empty()).then_some((node_id, scripts))
1428        })
1429        .collect()
1430}
1431
1432/// Find the script list for `node` in the collected set (focused dispatch).
1433fn dispatch_node_activity(
1434    runtime: &mut dyn XfaJsRuntime,
1435    scripts: &[EventScript],
1436    node_id: FormNodeId,
1437    node_name: &str,
1438    activity: &str,
1439    outcome: &mut InteractiveOutcome,
1440    report: &mut XfaScriptReport,
1441) {
1442    for (position, script) in scripts.iter().enumerate() {
1443        if script.activity.as_deref() != Some(activity) {
1444            continue;
1445        }
1446        let id = format!("{node_name}:{activity}#{position}");
1447        if script.language != ScriptLanguage::JavaScript {
1448            report.skip(&id, &script.language);
1449            continue;
1450        }
1451        let _ = runtime.reset_per_script(node_id, Some(activity));
1452        match runtime.execute_script(Some(activity), &script.script) {
1453            Ok(_) => outcome.scripts_executed += 1,
1454            Err(error) => {
1455                outcome.scripts_skipped += 1;
1456                report.fail(&id, &error);
1457            }
1458        }
1459    }
1460}
1461
1462fn has_hidden_ancestor(
1463    form: &FormTree,
1464    parents: &HashMap<FormNodeId, FormNodeId>,
1465    node_id: FormNodeId,
1466) -> bool {
1467    let mut cursor = parents.get(&node_id).copied();
1468    while let Some(ancestor) = cursor {
1469        if form.meta(ancestor).presence.is_not_visible() {
1470            return true;
1471        }
1472        cursor = parents.get(&ancestor).copied();
1473    }
1474    false
1475}
1476
1477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1478enum ScriptPhase {
1479    Initialize,
1480    Calculate,
1481}
1482
1483#[derive(Default)]
1484struct ScriptStats {
1485    errors: usize,
1486    successes: usize,
1487    formcalc_run: usize,
1488    formcalc_errors: usize,
1489}
1490
1491#[derive(Debug)]
1492struct ScriptResult {
1493    changes: usize,
1494    error: bool,
1495}
1496
1497fn run_script_phase(
1498    form: &mut FormTree,
1499    root_id: FormNodeId,
1500    parents: &HashMap<FormNodeId, FormNodeId>,
1501    scripts: &[(FormNodeId, Vec<EventScript>)],
1502    phase: ScriptPhase,
1503    max_passes: usize,
1504    stats: &mut ScriptStats,
1505) -> Result<usize> {
1506    let mut total_changes = 0;
1507
1508    for _ in 0..max_passes {
1509        let mut pass_changes = 0;
1510
1511        for (node_id, node_scripts) in scripts {
1512            if has_hidden_ancestor(form, parents, *node_id) {
1513                continue;
1514            }
1515
1516            for script in node_scripts
1517                .iter()
1518                .filter(|script| should_run_script(script, phase))
1519            {
1520                let result = execute_event_script(form, root_id, parents, *node_id, script, phase)?;
1521                stats.formcalc_run += 1;
1522                if result.error {
1523                    stats.errors += 1;
1524                    stats.formcalc_errors += 1;
1525                } else {
1526                    stats.successes += 1;
1527                }
1528                pass_changes += result.changes;
1529            }
1530        }
1531
1532        total_changes += pass_changes;
1533        if pass_changes == 0 {
1534            break;
1535        }
1536    }
1537
1538    Ok(total_changes)
1539}
1540
1541fn should_run_script(script: &EventScript, phase: ScriptPhase) -> bool {
1542    match phase {
1543        ScriptPhase::Initialize => script.activity.as_deref() == Some("initialize"),
1544        ScriptPhase::Calculate => script.activity.as_deref() == Some("calculate"),
1545    }
1546}
1547
1548fn execute_event_script(
1549    form: &mut FormTree,
1550    root_id: FormNodeId,
1551    parents: &HashMap<FormNodeId, FormNodeId>,
1552    current_id: FormNodeId,
1553    script: &EventScript,
1554    phase: ScriptPhase,
1555) -> Result<ScriptResult> {
1556    match script.language {
1557        ScriptLanguage::FormCalc => Ok(execute_formcalc_script(
1558            form, root_id, parents, current_id, script, phase,
1559        )),
1560        ScriptLanguage::JavaScript => Err(javascript_policy::reject_execution(
1561            JavaScriptEntryPoint::XfaEventHook,
1562        )),
1563        ScriptLanguage::Other => Err(XfaError::UnsupportedFeature("script language".to_string())),
1564    }
1565}
1566
1567/// Emit a stderr line when `XFA_FORMCALC_DEBUG=1` is set so that residual-scan
1568/// tooling can aggregate FormCalc failures by stage (`lexer` / `parser` /
1569/// `interpreter`) and message. Off by default; emits nothing otherwise.
1570///
1571/// Mirrors the `XFA_JS_DEBUG` opt-in pattern used by the sandbox JS runtime.
1572fn formcalc_debug_emit(stage: &str, message: &str, script: &EventScript) {
1573    if std::env::var("XFA_FORMCALC_DEBUG").ok().as_deref() != Some("1") {
1574        return;
1575    }
1576    let activity = script.activity.as_deref().unwrap_or("?");
1577    // Single-line, double-quoted message so the scan script's regex can parse
1578    // it like `XFA_JS_DEBUG resolve_*` lines. Newlines collapsed defensively.
1579    let one_line = message.replace(['\n', '\r'], " ");
1580    eprintln!("XFA_FORMCALC_DEBUG stage={stage} activity=\"{activity}\" message=\"{one_line}\"");
1581}
1582
1583fn execute_formcalc_script(
1584    form: &mut FormTree,
1585    root_id: FormNodeId,
1586    parents: &HashMap<FormNodeId, FormNodeId>,
1587    current_id: FormNodeId,
1588    script: &EventScript,
1589    phase: ScriptPhase,
1590) -> ScriptResult {
1591    let tokens = match tokenize(&script.script) {
1592        Ok(t) => t,
1593        Err(err) => {
1594            formcalc_debug_emit("lexer", &format!("{err}"), script);
1595            return ScriptResult {
1596                changes: 0,
1597                error: true,
1598            };
1599        }
1600    };
1601    let ast = match parser::parse(tokens) {
1602        Ok(a) => a,
1603        Err(err) => {
1604            formcalc_debug_emit("parser", &format!("{err}"), script);
1605            return ScriptResult {
1606                changes: 0,
1607                error: true,
1608            };
1609        }
1610    };
1611
1612    let mut interpreter = Interpreter::new();
1613    let mut resolver = FormTreeSomResolver::new(form, root_id, parents, current_id);
1614    let result = match interpreter.exec_with_resolver(&ast, &mut resolver) {
1615        Ok(r) => r,
1616        Err(err) => {
1617            formcalc_debug_emit("interpreter", &format!("{err}"), script);
1618            return ScriptResult {
1619                changes: resolver.changes,
1620                error: true,
1621            };
1622        }
1623    };
1624
1625    if matches!(phase, ScriptPhase::Calculate) {
1626        resolver.changes += write_formcalc_value(
1627            resolver.form,
1628            current_id,
1629            ResolvedProperty::RawValue,
1630            result,
1631        );
1632    }
1633
1634    ScriptResult {
1635        changes: resolver.changes,
1636        error: false,
1637    }
1638}
1639
1640#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1641enum ResolvedProperty {
1642    RawValue,
1643    Presence,
1644    SomExpression,
1645}
1646
1647#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1648struct ResolvedTarget {
1649    node_id: FormNodeId,
1650    property: ResolvedProperty,
1651}
1652
1653struct FormTreeSomResolver<'a> {
1654    form: &'a mut FormTree,
1655    root_id: FormNodeId,
1656    parents: &'a HashMap<FormNodeId, FormNodeId>,
1657    current_id: FormNodeId,
1658    changes: usize,
1659}
1660
1661impl<'a> FormTreeSomResolver<'a> {
1662    fn new(
1663        form: &'a mut FormTree,
1664        root_id: FormNodeId,
1665        parents: &'a HashMap<FormNodeId, FormNodeId>,
1666        current_id: FormNodeId,
1667    ) -> Self {
1668        Self {
1669            form,
1670            root_id,
1671            parents,
1672            current_id,
1673            changes: 0,
1674        }
1675    }
1676
1677    fn resolve_target(&self, path: &str) -> Option<ResolvedTarget> {
1678        let trimmed = path.trim();
1679        if trimmed.is_empty() {
1680            return None;
1681        }
1682
1683        if matches!(trimmed, "rawValue" | "presence" | "somExpression") {
1684            return Some(ResolvedTarget {
1685                node_id: self.current_id,
1686                property: parse_property_name(trimmed)?,
1687            });
1688        }
1689
1690        let (expr, property) = split_property_path(trimmed)?;
1691        let node_id = self.resolve_expression(&expr)?.into_iter().next()?;
1692        Some(ResolvedTarget { node_id, property })
1693    }
1694
1695    fn count_targets(&self, path: &str) -> usize {
1696        let trimmed = path.trim();
1697        if trimmed.is_empty() {
1698            return 0;
1699        }
1700        if matches!(trimmed, "rawValue" | "presence" | "somExpression") {
1701            return 1;
1702        }
1703        let Some((expr, _property)) = split_property_path(trimmed) else {
1704            return 0;
1705        };
1706        self.resolve_expression(&expr)
1707            .map_or(0, |nodes| nodes.len())
1708    }
1709
1710    fn resolve_expression(&self, expr: &SomExpression) -> Option<Vec<FormNodeId>> {
1711        match expr.root {
1712            SomRoot::Data | SomRoot::Record | SomRoot::Template => None,
1713            SomRoot::CurrentContainer => {
1714                if expr.segments.is_empty() {
1715                    Some(vec![self.current_id])
1716                } else {
1717                    Some(self.follow_absolute(vec![self.current_id], &expr.segments))
1718                }
1719            }
1720            SomRoot::Form => {
1721                if expr.segments.is_empty() {
1722                    Some(vec![self.root_id])
1723                } else {
1724                    Some(self.follow_absolute(vec![self.root_id], &expr.segments))
1725                }
1726            }
1727            SomRoot::Xfa => {
1728                let segments = strip_xfa_form_prefix(&expr.segments);
1729                if segments.is_empty() {
1730                    Some(vec![self.root_id])
1731                } else {
1732                    Some(self.follow_absolute(vec![self.root_id], segments))
1733                }
1734            }
1735            SomRoot::Unqualified => {
1736                if expr.segments.is_empty() {
1737                    Some(vec![self.current_id])
1738                } else {
1739                    Some(self.follow_unqualified(&expr.segments))
1740                }
1741            }
1742        }
1743    }
1744
1745    fn follow_absolute(
1746        &self,
1747        mut current: Vec<FormNodeId>,
1748        segments: &[xfa_dom_resolver::som::SomSegment],
1749    ) -> Vec<FormNodeId> {
1750        for (idx, segment) in segments.iter().enumerate() {
1751            let allow_self = idx == 0;
1752            current = current
1753                .into_iter()
1754                .flat_map(|node_id| self.step_from_node(node_id, segment, allow_self))
1755                .collect();
1756            if current.is_empty() {
1757                break;
1758            }
1759        }
1760        current
1761    }
1762
1763    fn follow_unqualified(
1764        &self,
1765        segments: &[xfa_dom_resolver::som::SomSegment],
1766    ) -> Vec<FormNodeId> {
1767        let Some((first, rest)) = segments.split_first() else {
1768            return vec![self.current_id];
1769        };
1770
1771        let mut scope = Some(self.current_id);
1772        while let Some(scope_id) = scope {
1773            let anchors: Vec<_> = descendants_inclusive(self.form, scope_id)
1774                .into_iter()
1775                .filter(|node_id| self.node_matches_segment(*node_id, first))
1776                .collect();
1777            let matched = self.follow_remaining(anchors, rest);
1778            if !matched.is_empty() {
1779                return matched;
1780            }
1781            scope = self.parents.get(&scope_id).copied();
1782        }
1783
1784        let anchors: Vec<_> = descendants_inclusive(self.form, self.root_id)
1785            .into_iter()
1786            .filter(|node_id| self.node_matches_segment(*node_id, first))
1787            .collect();
1788        self.follow_remaining(anchors, rest)
1789    }
1790
1791    fn follow_remaining(
1792        &self,
1793        mut current: Vec<FormNodeId>,
1794        segments: &[xfa_dom_resolver::som::SomSegment],
1795    ) -> Vec<FormNodeId> {
1796        for segment in segments {
1797            current = current
1798                .into_iter()
1799                .flat_map(|node_id| self.step_from_node(node_id, segment, false))
1800                .collect();
1801            if current.is_empty() {
1802                break;
1803            }
1804        }
1805        current
1806    }
1807
1808    fn step_from_node(
1809        &self,
1810        node_id: FormNodeId,
1811        segment: &xfa_dom_resolver::som::SomSegment,
1812        allow_self: bool,
1813    ) -> Vec<FormNodeId> {
1814        // XFA-F3-06: `..` (parent) navigation — a segment whose name is an
1815        // empty string (produced by the `.` separator after `..` in the raw path)
1816        // or literally ".." navigates to the parent node.
1817        if let SomSelector::Name(name) = &segment.selector {
1818            if name == ".." {
1819                // Navigate to parent
1820                if let Some(&parent_id) = self.parents.get(&node_id) {
1821                    return apply_index_to_single(parent_id, segment.index);
1822                }
1823                return Vec::new();
1824            }
1825        }
1826
1827        if allow_self && self.node_matches_selector(node_id, &segment.selector) {
1828            return apply_index_to_single(node_id, segment.index);
1829        }
1830
1831        let matches: Vec<_> = self
1832            .form
1833            .get(node_id)
1834            .children
1835            .iter()
1836            .copied()
1837            .filter(|child_id| self.node_matches_selector(*child_id, &segment.selector))
1838            .collect();
1839
1840        apply_index(matches, segment.index)
1841    }
1842
1843    fn node_matches_segment(
1844        &self,
1845        node_id: FormNodeId,
1846        segment: &xfa_dom_resolver::som::SomSegment,
1847    ) -> bool {
1848        if !self.node_matches_selector(node_id, &segment.selector) {
1849            return false;
1850        }
1851
1852        match segment.index {
1853            SomIndex::All => true,
1854            SomIndex::None => self.sibling_position(node_id, &segment.selector) == Some(0),
1855            SomIndex::Specific(idx) => {
1856                self.sibling_position(node_id, &segment.selector) == Some(idx)
1857            }
1858        }
1859    }
1860
1861    fn sibling_position(&self, node_id: FormNodeId, selector: &SomSelector) -> Option<usize> {
1862        let Some(parent_id) = self.parents.get(&node_id).copied() else {
1863            return self.node_matches_selector(node_id, selector).then_some(0);
1864        };
1865
1866        self.form
1867            .get(parent_id)
1868            .children
1869            .iter()
1870            .copied()
1871            .filter(|candidate| self.node_matches_selector(*candidate, selector))
1872            .position(|candidate| candidate == node_id)
1873    }
1874
1875    fn node_matches_selector(&self, node_id: FormNodeId, selector: &SomSelector) -> bool {
1876        match selector {
1877            SomSelector::Name(name) => self.form.get(node_id).name == *name,
1878            SomSelector::Class(class_name) => self.node_matches_class(node_id, class_name),
1879            SomSelector::AllChildren => true,
1880        }
1881    }
1882
1883    fn node_matches_class(&self, node_id: FormNodeId, class_name: &str) -> bool {
1884        let class_name = class_name.to_ascii_lowercase();
1885        match class_name.as_str() {
1886            "subform" => matches!(
1887                self.form.get(node_id).node_type,
1888                FormNodeType::Root | FormNodeType::Subform
1889            ),
1890            "pageset" => {
1891                matches!(self.form.get(node_id).node_type, FormNodeType::PageSet)
1892            }
1893            "pagearea" => matches!(
1894                self.form.get(node_id).node_type,
1895                FormNodeType::PageArea { .. }
1896            ),
1897            "field" => matches!(self.form.get(node_id).node_type, FormNodeType::Field { .. }),
1898            "draw" => matches!(
1899                self.form.get(node_id).node_type,
1900                FormNodeType::Draw(_) | FormNodeType::Image { .. }
1901            ),
1902            "exclgroup" => self.form.meta(node_id).group_kind == GroupKind::ExclusiveChoice,
1903            _ => false,
1904        }
1905    }
1906}
1907
1908impl SomResolver for FormTreeSomResolver<'_> {
1909    fn resolve_path(
1910        &mut self,
1911        path: &str,
1912    ) -> formcalc_interpreter::error::Result<Option<FormCalcValue>> {
1913        let Some(target) = self.resolve_target(path) else {
1914            // XFA-F3-06: log a warning instead of silently returning None so
1915            // that SOM path failures are diagnosable.
1916            if !path.trim().is_empty() {
1917                log::warn!("SOM bridge: path not resolved: {:?}", path.trim());
1918            }
1919            return Ok(None);
1920        };
1921        Ok(Some(read_formcalc_value(
1922            self.form,
1923            self.root_id,
1924            self.parents,
1925            target,
1926        )))
1927    }
1928
1929    fn assign_path(
1930        &mut self,
1931        path: &str,
1932        value: FormCalcValue,
1933    ) -> formcalc_interpreter::error::Result<bool> {
1934        let Some(target) = self.resolve_target(path) else {
1935            // XFA-F3-06: descriptive warning on assignment failure.
1936            if !path.trim().is_empty() {
1937                log::warn!("SOM bridge: assignment target not found: {:?}", path.trim());
1938            }
1939            return Ok(false);
1940        };
1941        self.changes += write_formcalc_value(self.form, target.node_id, target.property, value);
1942        Ok(true)
1943    }
1944
1945    fn count_path_matches(&mut self, path: &str) -> formcalc_interpreter::error::Result<usize> {
1946        Ok(self.count_targets(path))
1947    }
1948}
1949
1950fn split_property_path(path: &str) -> Option<(SomExpression, ResolvedProperty)> {
1951    let normalized = if let Some(rest) = path.strip_prefix("this.") {
1952        format!("$.{rest}")
1953    } else if path == "this" {
1954        "$".to_string()
1955    } else {
1956        path.to_string()
1957    };
1958
1959    let mut expr = parse_som(&normalized).ok()?;
1960    let property = if let Some(last) = expr.segments.last() {
1961        match &last.selector {
1962            SomSelector::Name(name) => {
1963                parse_property_name(name).unwrap_or(ResolvedProperty::RawValue)
1964            }
1965            _ => ResolvedProperty::RawValue,
1966        }
1967    } else {
1968        ResolvedProperty::RawValue
1969    };
1970
1971    if matches!(
1972        expr.segments.last().map(|segment| &segment.selector),
1973        Some(SomSelector::Name(name)) if parse_property_name(name).is_some()
1974    ) {
1975        expr.segments.pop();
1976    }
1977
1978    Some((expr, property))
1979}
1980
1981fn parse_property_name(name: &str) -> Option<ResolvedProperty> {
1982    match name {
1983        "rawValue" => Some(ResolvedProperty::RawValue),
1984        "presence" => Some(ResolvedProperty::Presence),
1985        "somExpression" => Some(ResolvedProperty::SomExpression),
1986        _ => None,
1987    }
1988}
1989
1990fn strip_xfa_form_prefix(
1991    segments: &[xfa_dom_resolver::som::SomSegment],
1992) -> &[xfa_dom_resolver::som::SomSegment] {
1993    match segments.first() {
1994        Some(segment)
1995            if matches!(&segment.selector, SomSelector::Name(name) if name == "form")
1996                && matches!(segment.index, SomIndex::None) =>
1997        {
1998            &segments[1..]
1999        }
2000        _ => segments,
2001    }
2002}
2003
2004fn apply_index(matches: Vec<FormNodeId>, index: SomIndex) -> Vec<FormNodeId> {
2005    match index {
2006        SomIndex::None => matches.into_iter().take(1).collect(),
2007        SomIndex::Specific(idx) => matches.get(idx).copied().into_iter().collect(),
2008        SomIndex::All => matches,
2009    }
2010}
2011
2012fn apply_index_to_single(node_id: FormNodeId, index: SomIndex) -> Vec<FormNodeId> {
2013    match index {
2014        SomIndex::None | SomIndex::Specific(0) | SomIndex::All => vec![node_id],
2015        SomIndex::Specific(_) => Vec::new(),
2016    }
2017}
2018
2019fn read_formcalc_value(
2020    form: &FormTree,
2021    root_id: FormNodeId,
2022    parents: &HashMap<FormNodeId, FormNodeId>,
2023    target: ResolvedTarget,
2024) -> FormCalcValue {
2025    match target.property {
2026        ResolvedProperty::RawValue => get_formcalc_raw_value(form, target.node_id),
2027        ResolvedProperty::Presence => FormCalcValue::String(
2028            match form.meta(target.node_id).presence {
2029                Presence::Visible => "visible",
2030                Presence::Hidden => "hidden",
2031                Presence::Invisible => "invisible",
2032                Presence::Inactive => "inactive",
2033            }
2034            .to_string(),
2035        ),
2036        ResolvedProperty::SomExpression => {
2037            FormCalcValue::String(build_som_expression(form, root_id, parents, target.node_id))
2038        }
2039    }
2040}
2041
2042fn get_formcalc_raw_value(form: &FormTree, node_id: FormNodeId) -> FormCalcValue {
2043    match &form.get(node_id).node_type {
2044        FormNodeType::Field { value } => string_to_formcalc_value(value),
2045        _ if form.meta(node_id).group_kind == GroupKind::ExclusiveChoice => {
2046            for &child_id in &form.get(node_id).children {
2047                if let FormNodeType::Field { value } = &form.get(child_id).node_type {
2048                    if !value.is_empty() {
2049                        let selected = form.meta(child_id).item_value.as_deref().unwrap_or(value);
2050                        return string_to_formcalc_value(selected);
2051                    }
2052                }
2053            }
2054            FormCalcValue::Null
2055        }
2056        _ => FormCalcValue::Null,
2057    }
2058}
2059
2060fn write_formcalc_value(
2061    form: &mut FormTree,
2062    node_id: FormNodeId,
2063    property: ResolvedProperty,
2064    value: FormCalcValue,
2065) -> usize {
2066    match property {
2067        ResolvedProperty::RawValue => set_raw_value(form, node_id, formcalc_to_script_value(value)),
2068        ResolvedProperty::Presence => {
2069            set_presence(form, node_id, ScriptValue::String(value.to_string_val()))
2070        }
2071        ResolvedProperty::SomExpression => 0,
2072    }
2073}
2074
2075fn string_to_formcalc_value(value: &str) -> FormCalcValue {
2076    let trimmed = value.trim();
2077    if trimmed.is_empty() {
2078        FormCalcValue::Null
2079    } else if let Ok(number) = trimmed.parse::<f64>() {
2080        FormCalcValue::Number(number)
2081    } else {
2082        FormCalcValue::String(value.to_string())
2083    }
2084}
2085
2086fn formcalc_to_script_value(value: FormCalcValue) -> ScriptValue {
2087    match value {
2088        FormCalcValue::Null => ScriptValue::Null,
2089        FormCalcValue::Number(number) => ScriptValue::String(normalize_number(number)),
2090        FormCalcValue::String(value) => ScriptValue::String(value),
2091    }
2092}
2093
2094fn build_som_expression(
2095    form: &FormTree,
2096    root_id: FormNodeId,
2097    parents: &HashMap<FormNodeId, FormNodeId>,
2098    node_id: FormNodeId,
2099) -> String {
2100    let mut parts = Vec::new();
2101    let mut cursor = Some(node_id);
2102    while let Some(current) = cursor {
2103        let node = form.get(current);
2104        if !node.name.is_empty() {
2105            let index = if let Some(parent_id) = parents.get(&current).copied() {
2106                form.get(parent_id)
2107                    .children
2108                    .iter()
2109                    .copied()
2110                    .filter(|sibling_id| form.get(*sibling_id).name == node.name)
2111                    .position(|sibling_id| sibling_id == current)
2112                    .unwrap_or(0)
2113            } else {
2114                0
2115            };
2116            parts.push(format!("{}[{index}]", node.name));
2117        }
2118        if current == root_id {
2119            break;
2120        }
2121        cursor = parents.get(&current).copied();
2122    }
2123    parts.reverse();
2124
2125    if parts.is_empty() {
2126        "$form".to_string()
2127    } else {
2128        format!("$form.{}", parts.join("."))
2129    }
2130}
2131
2132fn descendants_inclusive(form: &FormTree, root_id: FormNodeId) -> Vec<FormNodeId> {
2133    let mut out = Vec::new();
2134    collect_descendants(form, root_id, &mut out);
2135    out
2136}
2137
2138fn collect_descendants(form: &FormTree, node_id: FormNodeId, out: &mut Vec<FormNodeId>) {
2139    out.push(node_id);
2140    for &child_id in &form.get(node_id).children {
2141        collect_descendants(form, child_id, out);
2142    }
2143}
2144
2145fn build_parent_map(form: &FormTree, root_id: FormNodeId) -> HashMap<FormNodeId, FormNodeId> {
2146    let mut parents = HashMap::new();
2147    populate_parent_map(form, root_id, &mut parents);
2148    parents
2149}
2150
2151fn populate_parent_map(
2152    form: &FormTree,
2153    node_id: FormNodeId,
2154    parents: &mut HashMap<FormNodeId, FormNodeId>,
2155) {
2156    for &child_id in &form.get(node_id).children {
2157        parents.insert(child_id, node_id);
2158        populate_parent_map(form, child_id, parents);
2159    }
2160}
2161
2162fn set_raw_value(form: &mut FormTree, node_id: FormNodeId, value: ScriptValue) -> usize {
2163    let value = match value {
2164        ScriptValue::Null => String::new(),
2165        ScriptValue::String(value) => value,
2166    };
2167
2168    if form.meta(node_id).group_kind == GroupKind::ExclusiveChoice {
2169        let mut changes = 0;
2170        for &child_id in &form.get(node_id).children.clone() {
2171            let item_value = form.meta(child_id).item_value.clone();
2172            let next = if item_value.as_deref() == Some(value.as_str()) {
2173                value.clone()
2174            } else {
2175                String::new()
2176            };
2177            if let FormNodeType::Field { value: field_value } =
2178                &mut form.get_mut(child_id).node_type
2179            {
2180                if *field_value != next {
2181                    *field_value = next;
2182                    changes += 1;
2183                }
2184            }
2185        }
2186        return changes;
2187    }
2188
2189    if let FormNodeType::Field { value: field_value } = &mut form.get_mut(node_id).node_type {
2190        if *field_value != value {
2191            *field_value = value;
2192            return 1;
2193        }
2194    }
2195
2196    0
2197}
2198
2199fn set_presence(form: &mut FormTree, node_id: FormNodeId, value: ScriptValue) -> usize {
2200    set_presence_inner(form, node_id, value).0
2201}
2202
2203/// Returns `(change_count, Option<(old_str, new_str)>)` for E-4 observability.
2204fn set_presence_inner(
2205    form: &mut FormTree,
2206    node_id: FormNodeId,
2207    value: ScriptValue,
2208) -> (usize, Option<(&'static str, &'static str)>) {
2209    let value = match value {
2210        ScriptValue::Null => return (0, None),
2211        ScriptValue::String(value) => value,
2212    };
2213    let normalized = value.trim().to_ascii_lowercase();
2214    let new_presence = match normalized.as_str() {
2215        "visible" | "open" => Presence::Visible,
2216        "hidden" => Presence::Hidden,
2217        "invisible" => Presence::Invisible,
2218        "inactive" => Presence::Inactive,
2219        _ => return (0, None),
2220    };
2221
2222    let meta = form.meta_mut(node_id);
2223    let old_presence = meta.presence;
2224    if old_presence == new_presence {
2225        return (0, None);
2226    }
2227    meta.presence = new_presence;
2228
2229    fn pres_str(p: Presence) -> &'static str {
2230        match p {
2231            Presence::Visible => "visible",
2232            Presence::Hidden => "hidden",
2233            Presence::Invisible => "invisible",
2234            Presence::Inactive => "inactive",
2235        }
2236    }
2237    let mutation = (pres_str(old_presence), pres_str(new_presence));
2238    if runtime_diag_enabled() {
2239        let node_name = form.get(node_id).name.clone();
2240        push_presence_mutation(node_id.0, &node_name, mutation.0, mutation.1);
2241    }
2242    (1, Some(mutation))
2243}
2244
2245fn normalize_number(number: f64) -> String {
2246    if number.fract().abs() < f64::EPSILON {
2247        (number as i64).to_string()
2248    } else {
2249        number.to_string()
2250    }
2251}
2252
2253#[derive(Debug, Clone, PartialEq, Eq)]
2254enum ScriptValue {
2255    Null,
2256    String(String),
2257}
2258
2259#[cfg(test)]
2260mod tests {
2261    use super::*;
2262    use xfa_layout_engine::form::{
2263        FieldKind, FormNode, FormNodeMeta, FormNodeStyle, GroupKind, Occur,
2264    };
2265    use xfa_layout_engine::text::FontMetrics;
2266    use xfa_layout_engine::types::{BoxModel, LayoutStrategy};
2267
2268    fn add_node(tree: &mut FormTree, name: &str, node_type: FormNodeType) -> FormNodeId {
2269        tree.add_node(FormNode {
2270            name: name.to_string(),
2271            node_type,
2272            box_model: BoxModel::default(),
2273            layout: LayoutStrategy::TopToBottom,
2274            children: Vec::new(),
2275            occur: Occur::once(),
2276            font: FontMetrics::default(),
2277            calculate: None,
2278            validate: None,
2279            column_widths: Vec::new(),
2280            col_span: 1,
2281        })
2282    }
2283
2284    fn empty_meta() -> FormNodeMeta {
2285        FormNodeMeta {
2286            field_kind: FieldKind::Text,
2287            group_kind: GroupKind::None,
2288            style: FormNodeStyle::default(),
2289            ..Default::default()
2290        }
2291    }
2292
2293    fn formcalc_script(script: &str, activity: &str) -> EventScript {
2294        EventScript::formcalc(script, Some(activity))
2295    }
2296
2297    fn javascript_script(script: &str, activity: &str) -> EventScript {
2298        EventScript::javascript(script, Some(activity))
2299    }
2300
2301    fn other_script(script: &str, activity: &str) -> EventScript {
2302        EventScript::new(
2303            script.to_string(),
2304            ScriptLanguage::Other,
2305            Some(activity.to_string()),
2306            None,
2307            None,
2308        )
2309    }
2310
2311    fn script_policy_fixture(include_js: bool) -> (FormTree, FormNodeId, FormNodeId) {
2312        let mut tree = FormTree::new();
2313        let root = add_node(&mut tree, "root", FormNodeType::Root);
2314        let js_hook = add_node(
2315            &mut tree,
2316            "JsHook",
2317            FormNodeType::Field {
2318                value: String::new(),
2319            },
2320        );
2321        let runner = add_node(
2322            &mut tree,
2323            "Runner",
2324            FormNodeType::Field {
2325                value: String::new(),
2326            },
2327        );
2328        let target = add_node(
2329            &mut tree,
2330            "Target",
2331            FormNodeType::Field {
2332                value: String::new(),
2333            },
2334        );
2335
2336        tree.get_mut(root).children = vec![js_hook, runner, target];
2337        if include_js {
2338            tree.meta_mut(js_hook).event_scripts = vec![javascript_script(
2339                "xfa.host.messageBox('skip');",
2340                "initialize",
2341            )];
2342        }
2343        tree.meta_mut(runner).event_scripts =
2344            vec![formcalc_script(r#"Target.rawValue = "ran""#, "initialize")];
2345
2346        (tree, root, target)
2347    }
2348
2349    fn other_language_policy_fixture() -> (FormTree, FormNodeId, FormNodeId) {
2350        let mut tree = FormTree::new();
2351        let root = add_node(&mut tree, "root", FormNodeType::Root);
2352        let other = add_node(&mut tree, "OtherHook", FormNodeType::Subform);
2353        let runner = add_node(&mut tree, "Runner", FormNodeType::Subform);
2354        let target = add_node(
2355            &mut tree,
2356            "Target",
2357            FormNodeType::Field {
2358                value: String::new(),
2359            },
2360        );
2361
2362        tree.get_mut(root).children = vec![other, runner, target];
2363        tree.meta_mut(other).event_scripts = vec![other_script("MsgBox \"skip\"", "initialize")];
2364        tree.meta_mut(runner).event_scripts =
2365            vec![formcalc_script(r#"Target.rawValue = "ran""#, "initialize")];
2366
2367        (tree, root, target)
2368    }
2369
2370    fn field_value(tree: &FormTree, node_id: FormNodeId) -> &str {
2371        match &tree.get(node_id).node_type {
2372            FormNodeType::Field { value } => value,
2373            _ => panic!("expected field"),
2374        }
2375    }
2376
2377    #[test]
2378    fn change_event_toggles_relative_hidden_subform() {
2379        let mut tree = FormTree::new();
2380        let root = add_node(&mut tree, "root", FormNodeType::Root);
2381        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
2382        let group = add_node(&mut tree, "Choice", FormNodeType::Subform);
2383        let option1 = add_node(
2384            &mut tree,
2385            "Option1",
2386            FormNodeType::Field {
2387                value: "1".to_string(),
2388            },
2389        );
2390        let option2 = add_node(
2391            &mut tree,
2392            "Option2",
2393            FormNodeType::Field {
2394                value: String::new(),
2395            },
2396        );
2397        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
2398
2399        tree.get_mut(root).children = vec![section];
2400        tree.get_mut(section).children = vec![group, details];
2401        tree.get_mut(group).children = vec![option1, option2];
2402
2403        tree.meta_mut(group).group_kind = GroupKind::ExclusiveChoice;
2404        tree.meta_mut(group).event_scripts = vec![formcalc_script(
2405            r#"
2406Details.presence = "hidden"
2407if (this.rawValue == 1) then
2408  Details.presence = "visible"
2409endif
2410"#,
2411            "initialize",
2412        )];
2413        tree.meta_mut(option1).item_value = Some("1".into());
2414        tree.meta_mut(option2).item_value = Some("2".into());
2415        tree.meta_mut(details).presence = Presence::Hidden;
2416
2417        apply_dynamic_scripts(&mut tree, root).unwrap();
2418
2419        assert_eq!(tree.meta(details).presence, Presence::Visible);
2420    }
2421
2422    #[test]
2423    fn calculate_script_on_hidden_block_uses_sibling_values() {
2424        let mut tree = FormTree::new();
2425        let root = add_node(&mut tree, "root", FormNodeType::Root);
2426        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
2427        let option1 = add_node(
2428            &mut tree,
2429            "Opt1",
2430            FormNodeType::Field {
2431                value: "1".to_string(),
2432            },
2433        );
2434        let option2 = add_node(
2435            &mut tree,
2436            "Opt2",
2437            FormNodeType::Field {
2438                value: String::new(),
2439            },
2440        );
2441        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
2442
2443        tree.get_mut(root).children = vec![section];
2444        tree.get_mut(section).children = vec![option1, option2, details];
2445        tree.meta_mut(details).presence = Presence::Hidden;
2446        tree.meta_mut(details).event_scripts = vec![formcalc_script(
2447            r#"
2448this.presence = "hidden"
2449if ((Opt1.rawValue == 1) or (Opt2.rawValue == 1)) then
2450  this.presence = "visible"
2451endif
2452"#,
2453            "calculate",
2454        )];
2455
2456        apply_dynamic_scripts(&mut tree, root).unwrap();
2457
2458        assert_eq!(tree.meta(details).presence, Presence::Visible);
2459    }
2460
2461    #[test]
2462    fn multi_pass_scripts_propagate_raw_values() {
2463        let mut tree = FormTree::new();
2464        let root = add_node(&mut tree, "root", FormNodeType::Root);
2465        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
2466        let controller = add_node(
2467            &mut tree,
2468            "Controller",
2469            FormNodeType::Field {
2470                value: "1".to_string(),
2471            },
2472        );
2473        let target = add_node(
2474            &mut tree,
2475            "Target",
2476            FormNodeType::Field {
2477                value: String::new(),
2478            },
2479        );
2480        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
2481
2482        tree.get_mut(root).children = vec![section];
2483        tree.get_mut(section).children = vec![controller, target, details];
2484
2485        tree.meta_mut(controller).event_scripts = vec![formcalc_script(
2486            r#"
2487if (this.rawValue == 1) then
2488  Target.rawValue = 1
2489endif
2490"#,
2491            "calculate",
2492        )];
2493        tree.meta_mut(details).presence = Presence::Hidden;
2494        tree.meta_mut(details).event_scripts = vec![formcalc_script(
2495            r#"
2496this.presence = "hidden"
2497if (Target.rawValue == 1) then
2498  this.presence = "visible"
2499endif
2500"#,
2501            "calculate",
2502        )];
2503
2504        apply_dynamic_scripts(&mut tree, root).unwrap();
2505
2506        if let FormNodeType::Field { value } = &tree.get(target).node_type {
2507            assert_eq!(value, "1");
2508        } else {
2509            panic!("expected field");
2510        }
2511        assert_eq!(tree.meta(details).presence, Presence::Visible);
2512    }
2513
2514    // ─── #1097: FormCalc SOM bridge hardening ────────────────────────────────
2515
2516    /// SOM path `form1.#subform[0].field1.rawValue` resolves correctly on a
2517    /// simple form tree.
2518    #[test]
2519    fn som_path_resolves_on_simple_form_tree() {
2520        let mut tree = FormTree::new();
2521        let root = add_node(&mut tree, "root", FormNodeType::Root);
2522        let form1 = add_node(&mut tree, "form1", FormNodeType::Subform);
2523        let subform = add_node(&mut tree, "subform1", FormNodeType::Subform);
2524        let field1 = add_node(
2525            &mut tree,
2526            "field1",
2527            FormNodeType::Field {
2528                value: "hello".to_string(),
2529            },
2530        );
2531
2532        tree.get_mut(root).children = vec![form1];
2533        tree.get_mut(form1).children = vec![subform];
2534        tree.get_mut(subform).children = vec![field1];
2535
2536        // Use a calculate script to read the value via absolute SOM path
2537        tree.meta_mut(root).event_scripts = vec![formcalc_script(
2538            "form1.subform1.field1.rawValue",
2539            "calculate",
2540        )];
2541
2542        let parents = super::build_parent_map(&tree, root);
2543        let resolver = FormTreeSomResolver::new(&mut tree, root, &parents, root);
2544        let target = resolver.resolve_target("form1.subform1.field1.rawValue");
2545        assert!(target.is_some(), "SOM path must resolve to a node");
2546        let target = target.unwrap();
2547        let val = super::read_formcalc_value(&tree, root, &parents, target);
2548        match val {
2549            formcalc_interpreter::value::Value::String(s) => assert_eq!(s, "hello"),
2550            formcalc_interpreter::value::Value::Number(n) => {
2551                // number coercion: not expected here
2552                panic!("expected string, got number {n}")
2553            }
2554            _ => panic!("expected string value"),
2555        }
2556    }
2557
2558    /// An invalid SOM path returns `None` (descriptive non-panic failure).
2559    #[test]
2560    fn invalid_som_path_returns_none_not_panic() {
2561        let mut tree = FormTree::new();
2562        let root = add_node(&mut tree, "root", FormNodeType::Root);
2563        let parents = super::build_parent_map(&tree, root);
2564        let resolver = FormTreeSomResolver::new(&mut tree, root, &parents, root);
2565
2566        // This should not panic — it should return None
2567        let result = resolver.resolve_target("nonexistent.deep.path.rawValue");
2568        assert!(
2569            result.is_none(),
2570            "invalid SOM path must return None, not panic"
2571        );
2572    }
2573
2574    #[test]
2575    fn best_effort_skips_javascript_and_runs_formcalc() {
2576        let (mut tree, root, target) = script_policy_fixture(true);
2577
2578        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
2579
2580        assert_eq!(field_value(&tree, target), "ran");
2581        assert!(outcome.js_present);
2582        assert_eq!(outcome.js_skipped, 1);
2583        assert_eq!(outcome.other_skipped, 0);
2584        assert_eq!(outcome.formcalc_run, 1);
2585        assert_eq!(outcome.formcalc_errors, 0);
2586        assert_eq!(outcome.output_quality, OutputQuality::BestEffort);
2587    }
2588
2589    #[test]
2590    fn strict_mode_preserves_javascript_reject() {
2591        let (mut tree, root, target) = script_policy_fixture(true);
2592
2593        let err =
2594            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
2595
2596        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2597        assert_eq!(field_value(&tree, target), "");
2598    }
2599
2600    #[test]
2601    fn formcalc_only_reports_exact_quality() {
2602        let (mut tree, root, target) = script_policy_fixture(false);
2603
2604        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
2605
2606        assert_eq!(field_value(&tree, target), "ran");
2607        assert!(!outcome.js_present);
2608        assert_eq!(outcome.js_skipped, 0);
2609        assert_eq!(outcome.other_skipped, 0);
2610        assert_eq!(outcome.formcalc_run, 1);
2611        assert_eq!(outcome.formcalc_errors, 0);
2612        assert_eq!(outcome.output_quality, OutputQuality::Exact);
2613    }
2614
2615    #[test]
2616    fn other_language_scripts_skip_in_best_effort_and_reject_in_strict() {
2617        let (mut tree, root, target) = other_language_policy_fixture();
2618        let (mut strict_tree, strict_root, _) = other_language_policy_fixture();
2619
2620        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
2621        assert_eq!(field_value(&tree, target), "ran");
2622        assert_eq!(outcome.js_skipped, 0);
2623        assert_eq!(outcome.other_skipped, 1);
2624        assert_eq!(outcome.formcalc_run, 1);
2625        assert_eq!(outcome.output_quality, OutputQuality::BestEffort);
2626
2627        let err =
2628            apply_dynamic_scripts_with_mode(&mut strict_tree, strict_root, JsExecutionMode::Strict)
2629                .unwrap_err();
2630        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2631    }
2632
2633    #[test]
2634    fn javascript_direct_executor_call_is_still_denied() {
2635        let mut tree = FormTree::new();
2636        let root = add_node(&mut tree, "root", FormNodeType::Root);
2637        let trigger = add_node(
2638            &mut tree,
2639            "Trigger",
2640            FormNodeType::Field {
2641                value: String::new(),
2642            },
2643        );
2644        tree.get_mut(root).children = vec![trigger];
2645
2646        let parents = build_parent_map(&tree, root);
2647        let script = javascript_script("xfa.host.messageBox('deny');", "initialize");
2648        let err = execute_event_script(
2649            &mut tree,
2650            root,
2651            &parents,
2652            trigger,
2653            &script,
2654            ScriptPhase::Initialize,
2655        )
2656        .unwrap_err();
2657
2658        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2659    }
2660
2661    #[test]
2662    fn javascript_resolve_node_call_is_explicitly_denied() {
2663        let mut tree = FormTree::new();
2664        let root = add_node(&mut tree, "root", FormNodeType::Root);
2665        let form = add_node(&mut tree, "formulier1", FormNodeType::Subform);
2666        let admin = add_node(&mut tree, "ADMIN", FormNodeType::Subform);
2667        let lock = add_node(
2668            &mut tree,
2669            "LockForm_AD",
2670            FormNodeType::Field {
2671                value: "1".to_string(),
2672            },
2673        );
2674        let reset = add_node(
2675            &mut tree,
2676            "Reset",
2677            FormNodeType::Field {
2678                value: "1".to_string(),
2679            },
2680        );
2681
2682        tree.get_mut(root).children = vec![form];
2683        tree.get_mut(form).children = vec![admin, reset];
2684        tree.get_mut(admin).children = vec![lock];
2685        tree.meta_mut(reset).event_scripts = vec![javascript_script(
2686            r#"xfa.resolveNode("formulier1.ADMIN.LockForm_AD").rawValue = 0;"#,
2687            "initialize",
2688        )];
2689
2690        let err =
2691            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
2692        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2693
2694        if let FormNodeType::Field { value } = &tree.get(lock).node_type {
2695            assert_eq!(value, "1");
2696        } else {
2697            panic!("expected field");
2698        }
2699    }
2700
2701    #[test]
2702    fn javascript_utils_hide_if_empty_is_explicitly_denied() {
2703        let mut tree = FormTree::new();
2704        let root = add_node(&mut tree, "root", FormNodeType::Root);
2705        let empty = add_node(
2706            &mut tree,
2707            "EmptyField",
2708            FormNodeType::Field {
2709                value: String::new(),
2710            },
2711        );
2712        tree.get_mut(root).children = vec![empty];
2713        tree.meta_mut(empty).event_scripts =
2714            vec![javascript_script("Utils.hideIfEmpty(this);", "initialize")];
2715
2716        let err =
2717            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
2718        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2719
2720        assert!(!tree.meta(empty).presence.is_not_visible());
2721    }
2722
2723    #[test]
2724    fn malformed_javascript_payload_is_explicitly_denied_without_panic() {
2725        let mut tree = FormTree::new();
2726        let root = add_node(&mut tree, "root", FormNodeType::Root);
2727        let container = add_node(&mut tree, "Container", FormNodeType::Subform);
2728        let empty = add_node(
2729            &mut tree,
2730            "EmptyField",
2731            FormNodeType::Field {
2732                value: String::new(),
2733            },
2734        );
2735
2736        tree.get_mut(root).children = vec![container];
2737        tree.get_mut(container).children = vec![empty];
2738        tree.meta_mut(empty).event_scripts = vec![javascript_script(
2739            "\0}{{not.valid.javascript(",
2740            "initialize",
2741        )];
2742
2743        let err =
2744            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
2745        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2746
2747        assert!(!tree.meta(container).presence.is_not_visible());
2748    }
2749
2750    #[test]
2751    fn default_meta_helper_is_constructible() {
2752        let meta = empty_meta();
2753        assert_eq!(meta.group_kind, GroupKind::None);
2754    }
2755
2756    #[test]
2757    fn calculate_event_applies_formcalc_return_value() {
2758        let mut tree = FormTree::new();
2759        let root = add_node(&mut tree, "root", FormNodeType::Root);
2760        let total = add_node(
2761            &mut tree,
2762            "Total",
2763            FormNodeType::Field {
2764                value: String::new(),
2765            },
2766        );
2767
2768        tree.get_mut(root).children = vec![total];
2769        tree.meta_mut(total).event_scripts = vec![formcalc_script("40 + 2", "calculate")];
2770
2771        apply_dynamic_scripts(&mut tree, root).unwrap();
2772
2773        match &tree.get(total).node_type {
2774            FormNodeType::Field { value } => assert_eq!(value, "42"),
2775            _ => panic!("expected field"),
2776        }
2777    }
2778
2779    #[test]
2780    fn calculate_event_resolves_bare_field_names_as_raw_values() {
2781        let mut tree = FormTree::new();
2782        let root = add_node(&mut tree, "root", FormNodeType::Root);
2783        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
2784        let number1 = add_node(
2785            &mut tree,
2786            "Number1",
2787            FormNodeType::Field {
2788                value: "40".to_string(),
2789            },
2790        );
2791        let number2 = add_node(
2792            &mut tree,
2793            "Number2",
2794            FormNodeType::Field {
2795                value: "2".to_string(),
2796            },
2797        );
2798        let total = add_node(
2799            &mut tree,
2800            "Total",
2801            FormNodeType::Field {
2802                value: String::new(),
2803            },
2804        );
2805
2806        tree.get_mut(root).children = vec![section];
2807        tree.get_mut(section).children = vec![number1, number2, total];
2808        tree.meta_mut(total).event_scripts =
2809            vec![formcalc_script("Number1 + Number2", "calculate")];
2810
2811        apply_dynamic_scripts(&mut tree, root).unwrap();
2812
2813        match &tree.get(total).node_type {
2814            FormNodeType::Field { value } => assert_eq!(value, "42"),
2815            _ => panic!("expected field"),
2816        }
2817    }
2818
2819    #[test]
2820    fn click_events_are_skipped_during_flatten() {
2821        let mut tree = FormTree::new();
2822        let root = add_node(&mut tree, "root", FormNodeType::Root);
2823        let trigger = add_node(
2824            &mut tree,
2825            "Trigger",
2826            FormNodeType::Field {
2827                value: "1".to_string(),
2828            },
2829        );
2830        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
2831
2832        tree.get_mut(root).children = vec![trigger, details];
2833        tree.meta_mut(details).presence = Presence::Hidden;
2834        tree.meta_mut(trigger).event_scripts = vec![formcalc_script(
2835            r#"
2836Details.presence = "visible"
2837"#,
2838            "click",
2839        )];
2840
2841        apply_dynamic_scripts(&mut tree, root).unwrap();
2842
2843        assert_eq!(tree.meta(details).presence, Presence::Hidden);
2844    }
2845
2846    #[test]
2847    fn rollback_when_scripts_mostly_error() {
2848        // Set up a form with fields that have values.  Attach scripts that
2849        // will fail to parse so that errors > successes.  After
2850        // apply_dynamic_scripts the field values must be unchanged.
2851        let mut tree = FormTree::new();
2852        let root = add_node(&mut tree, "root", FormNodeType::Root);
2853        let field_a = add_node(
2854            &mut tree,
2855            "FieldA",
2856            FormNodeType::Field {
2857                value: "hello".to_string(),
2858            },
2859        );
2860        let field_b = add_node(
2861            &mut tree,
2862            "FieldB",
2863            FormNodeType::Field {
2864                value: "world".to_string(),
2865            },
2866        );
2867
2868        tree.get_mut(root).children = vec![field_a, field_b];
2869
2870        // Two scripts that fail parsing (invalid FormCalc), zero successes.
2871        tree.meta_mut(field_a).event_scripts = vec![formcalc_script("@@INVALID@@", "initialize")];
2872        tree.meta_mut(field_b).event_scripts =
2873            vec![formcalc_script("@@ALSO_BROKEN@@", "initialize")];
2874
2875        apply_dynamic_scripts(&mut tree, root).unwrap();
2876
2877        // Fields should retain their original values (rollback).
2878        match &tree.get(field_a).node_type {
2879            FormNodeType::Field { value } => assert_eq!(value, "hello"),
2880            _ => panic!("expected field"),
2881        }
2882        match &tree.get(field_b).node_type {
2883            FormNodeType::Field { value } => assert_eq!(value, "world"),
2884            _ => panic!("expected field"),
2885        }
2886    }
2887
2888    #[test]
2889    fn rollback_when_populated_fields_go_empty() {
2890        // Calculate scripts returning Null clear field values. When >50%
2891        // of populated fields go empty, the rollback heuristic fires.
2892        let mut tree = FormTree::new();
2893        let root = add_node(&mut tree, "root", FormNodeType::Root);
2894        let field_a = add_node(
2895            &mut tree,
2896            "FieldA",
2897            FormNodeType::Field {
2898                value: "keep".to_string(),
2899            },
2900        );
2901        let field_b = add_node(
2902            &mut tree,
2903            "FieldB",
2904            FormNodeType::Field {
2905                value: "also_keep".to_string(),
2906            },
2907        );
2908
2909        tree.get_mut(root).children = vec![field_a, field_b];
2910
2911        // Calculate scripts whose return value (Null) is written to the field,
2912        // blanking it.  The expression `Null()` is not a real FormCalc builtin,
2913        // but `0` would set the field to "0" (not empty).  Instead we use the
2914        // snapshot/rollback logic directly.
2915        // We test the heuristic by manually setting up the condition.
2916        let snapshot = super::snapshot_form(&tree);
2917
2918        // Simulate scripts clearing both fields.
2919        if let FormNodeType::Field { value } = &mut tree.get_mut(field_a).node_type {
2920            *value = String::new();
2921        }
2922        if let FormNodeType::Field { value } = &mut tree.get_mut(field_b).node_type {
2923            *value = String::new();
2924        }
2925
2926        assert!(super::should_rollback(&tree, &snapshot, 0, 2));
2927
2928        super::restore_snapshot(&mut tree, &snapshot);
2929
2930        match &tree.get(field_a).node_type {
2931            FormNodeType::Field { value } => assert_eq!(value, "keep"),
2932            _ => panic!("expected field"),
2933        }
2934        match &tree.get(field_b).node_type {
2935            FormNodeType::Field { value } => assert_eq!(value, "also_keep"),
2936            _ => panic!("expected field"),
2937        }
2938    }
2939
2940    #[test]
2941    fn no_rollback_when_scripts_succeed() {
2942        // A single working script with no errors → no rollback, change persists.
2943        let mut tree = FormTree::new();
2944        let root = add_node(&mut tree, "root", FormNodeType::Root);
2945        let total = add_node(
2946            &mut tree,
2947            "Total",
2948            FormNodeType::Field {
2949                value: String::new(),
2950            },
2951        );
2952
2953        tree.get_mut(root).children = vec![total];
2954        tree.meta_mut(total).event_scripts = vec![formcalc_script("40 + 2", "calculate")];
2955
2956        apply_dynamic_scripts(&mut tree, root).unwrap();
2957
2958        match &tree.get(total).node_type {
2959            FormNodeType::Field { value } => assert_eq!(value, "42"),
2960            _ => panic!("expected field"),
2961        }
2962    }
2963
2964    /// QF1-C regression: `formcalc_errors` counter MUST tick when a FormCalc
2965    /// script invokes an unknown function. This is the canonical signal the
2966    /// QF1-C residual scan (`scripts/xfa_formcalc_residual_scan.py`) aggregates
2967    /// off the `XFA script metadata:` stderr line.
2968    #[test]
2969    fn formcalc_unknown_function_increments_error_counter() {
2970        let mut tree = FormTree::new();
2971        let root = add_node(&mut tree, "root", FormNodeType::Root);
2972        let total = add_node(
2973            &mut tree,
2974            "Total",
2975            FormNodeType::Field {
2976                value: String::new(),
2977            },
2978        );
2979
2980        tree.get_mut(root).children = vec![total];
2981        // `definitelyNotAFormCalcBuiltin(1)` triggers `FormCalcError::UnknownFunction`
2982        // in `crates/formcalc-interpreter/src/interpreter.rs`.
2983        tree.meta_mut(total).event_scripts = vec![formcalc_script(
2984            "definitelyNotAFormCalcBuiltin(1)",
2985            "calculate",
2986        )];
2987
2988        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
2989        assert_eq!(outcome.formcalc_run, 1, "the script must be attempted");
2990        assert_eq!(
2991            outcome.formcalc_errors, 1,
2992            "unknown-function failure must increment formcalc_errors"
2993        );
2994    }
2995
2996    // ─── Epic A enrichment tests ──────────────────────────────────────────────
2997    //
2998    // Environment variable writes are not `std::sync::atomic` — we serialise all
2999    // three tests with a process-wide `Mutex` so they cannot race on the env.
3000    static ENV_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
3001
3002    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
3003        ENV_LOCK
3004            .get_or_init(|| std::sync::Mutex::new(()))
3005            .lock()
3006            .unwrap_or_else(|e| e.into_inner())
3007    }
3008
3009    /// E-1: `script_lifecycle` is populated when `XFA_FLATTEN_TRACE=1`.
3010    ///
3011    /// Builds a FormTree with a single JavaScript `initialize` event on a field.
3012    /// In `BestEffortStatic` mode the script is skipped but still recorded in
3013    /// the lifecycle vec with outcome `"skipped_mode"`.
3014    #[test]
3015    fn script_lifecycle_populated_when_trace_enabled() {
3016        let _guard = env_lock();
3017        std::env::set_var("XFA_FLATTEN_TRACE", "1");
3018
3019        let mut tree = FormTree::new();
3020        let root = add_node(&mut tree, "root", FormNodeType::Root);
3021        let field = add_node(
3022            &mut tree,
3023            "TraceField",
3024            FormNodeType::Field {
3025                value: String::new(),
3026            },
3027        );
3028        tree.get_mut(root).children = vec![field];
3029        tree.meta_mut(field).event_scripts = vec![javascript_script(
3030            "xfa.host.messageBox('trace');",
3031            "initialize",
3032        )];
3033
3034        let outcome =
3035            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::BestEffortStatic)
3036                .unwrap();
3037
3038        std::env::remove_var("XFA_FLATTEN_TRACE");
3039
3040        assert!(
3041            !outcome.script_lifecycle.is_empty(),
3042            "script_lifecycle must have at least one entry when XFA_FLATTEN_TRACE=1"
3043        );
3044        let entry = &outcome.script_lifecycle[0];
3045        assert_eq!(entry.node_name, "TraceField");
3046        assert_eq!(entry.activity, "initialize");
3047        assert_eq!(entry.lang, "javascript");
3048        // BestEffortStatic skips JS entirely — outcome must reflect that.
3049        assert_eq!(
3050            entry.outcome, "skipped_mode",
3051            "BestEffortStatic JS must appear as skipped_mode in lifecycle"
3052        );
3053    }
3054
3055    /// E-6: `skipped_activities` tallies correctly for a `click` script.
3056    ///
3057    /// A JavaScript script with `activity="click"` cannot be executed during
3058    /// flatten (not in the sandbox allowlist and skipped by BestEffortStatic).
3059    /// When `XFA_FLATTEN_TRACE=1` the `skipped_activities.click` counter must
3060    /// increment by exactly 1.
3061    #[test]
3062    fn skipped_activities_tallies_click_correctly() {
3063        let _guard = env_lock();
3064        std::env::set_var("XFA_FLATTEN_TRACE", "1");
3065
3066        let mut tree = FormTree::new();
3067        let root = add_node(&mut tree, "root", FormNodeType::Root);
3068        let btn = add_node(
3069            &mut tree,
3070            "ClickBtn",
3071            FormNodeType::Field {
3072                value: String::new(),
3073            },
3074        );
3075        tree.get_mut(root).children = vec![btn];
3076        // `click` activity — never executed during flatten regardless of mode.
3077        tree.meta_mut(btn).event_scripts = vec![javascript_script(
3078            "xfa.host.messageBox('clicked');",
3079            "click",
3080        )];
3081
3082        let outcome =
3083            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::BestEffortStatic)
3084                .unwrap();
3085
3086        std::env::remove_var("XFA_FLATTEN_TRACE");
3087
3088        assert_eq!(
3089            outcome.skipped_activities.click, 1,
3090            "exactly one click-activity JS script must be tallied in skipped_activities.click"
3091        );
3092        // Other buckets must stay at zero.
3093        assert_eq!(outcome.skipped_activities.initialize, 0);
3094        assert_eq!(outcome.skipped_activities.calculate, 0);
3095        assert_eq!(outcome.skipped_activities.other, 0);
3096    }
3097
3098    /// E-5: `form_dom_match_failures` increments when `apply_form_dom_presence`
3099    /// suppresses a named subform absent from the form DOM.
3100    ///
3101    /// The FormTree contains a named `Subform` child ("Ghost") that does NOT
3102    /// appear in the form XML packet.  The form XML contains a sibling child
3103    /// ("Present") so that the "has_subform_children" guard inside
3104    /// `apply_form_dom_presence` fires and suppression logic runs.
3105    #[test]
3106    fn form_dom_match_failures_increments_for_unmatched_subform() {
3107        use crate::flatten::{apply_form_dom_presence, XfaRenderingPolicy};
3108
3109        let _guard = env_lock();
3110        // E-5 diag log is armed when XFA_FLATTEN_TRACE or XFA_RUNTIME_DIAG is set.
3111        std::env::set_var("XFA_FLATTEN_TRACE", "1");
3112
3113        let mut tree = FormTree::new();
3114        let root = add_node(&mut tree, "root", FormNodeType::Root);
3115        // "form1" is the top-level subform that the form XML root-subform matches.
3116        let form1 = add_node(&mut tree, "form1", FormNodeType::Subform);
3117        // "Present" appears in the form DOM — will be matched.
3118        let present = add_node(&mut tree, "Present", FormNodeType::Subform);
3119        // "Ghost" is NOT in the form DOM — will be suppressed.
3120        let ghost = add_node(&mut tree, "Ghost", FormNodeType::Subform);
3121
3122        tree.get_mut(root).children = vec![form1];
3123        tree.get_mut(form1).children = vec![present, ghost];
3124
3125        // Minimal form XML: root subform "form1" contains one child "Present"
3126        // but no "Ghost" child.  Because "Present" is listed, the
3127        // `has_subform_children` guard fires and "Ghost" is suppressed.
3128        let form_xml = r#"<form>
3129  <subform name="form1">
3130    <subform name="Present"/>
3131  </subform>
3132</form>"#;
3133
3134        let (_admitted, match_failures, match_log) = apply_form_dom_presence(
3135            &mut tree,
3136            root,
3137            form_xml,
3138            XfaRenderingPolicy::SavedStateFaithful,
3139            false,
3140        );
3141
3142        std::env::remove_var("XFA_FLATTEN_TRACE");
3143
3144        assert_eq!(
3145            match_failures, 1,
3146            "exactly one unmatched named subform (Ghost) must be counted"
3147        );
3148        assert_eq!(match_log.len(), 1, "match_log must contain the Ghost entry");
3149        assert_eq!(match_log[0].template_node_name, "Ghost");
3150        assert_eq!(match_log[0].reason, "formdom_unmatched_suppressed");
3151        // Verify presence was actually suppressed on the FormTree node.
3152        assert!(
3153            tree.meta(ghost).presence.is_not_visible(),
3154            "Ghost subform must have been hidden by apply_form_dom_presence"
3155        );
3156    }
3157}