feat: validate Box participation against final parent layout

This commit is contained in:
Kinneyzhang 2026-08-26 20:42:36 +08:00
parent 5b55c00015
commit 4b409f16f4
7 changed files with 585 additions and 44 deletions

View File

@ -40,6 +40,8 @@
(cache-key signature rendered &optional side-effects)) (cache-key signature rendered &optional side-effects))
(declare-function ebox--render-with-cache (declare-function ebox--render-with-cache
"ebox-incremental" (node &optional force cache-probe)) "ebox-incremental" (node &optional force cache-probe))
(declare-function ebox-tree--register-legacy-flex-item-adapter
"ebox-tree" (adapter token source content))
(declare-function ebox-create "ebox" (&rest plist)) (declare-function ebox-create "ebox" (&rest plist))
(declare-function ebox-get "ebox" (box property)) (declare-function ebox-get "ebox" (box property))
(declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--ensure-node-id "ebox" (node))
@ -80,6 +82,14 @@
'(:order :flex :flex-grow :flex-shrink :flex-basis :align-self) '(:order :flex :flex-grow :flex-shrink :flex-basis :align-self)
"Properties that belong to flex item metadata.") "Properties that belong to flex item metadata.")
(defconst ebox--flex-item-direct-adapter-token
(make-symbol "ebox-flex-item-direct-adapter")
"Opaque provenance token for a legacy Flex item without a Box wrapper.")
(defconst ebox--flex-item-wrapper-adapter-token
(make-symbol "ebox-flex-item-wrapper-adapter")
"Opaque provenance token for a legacy Flex item with one Box wrapper.")
(defconst ebox--flex-container-layout-prop-keys (defconst ebox--flex-container-layout-prop-keys
'(:flex-direction :flex-wrap :flex-flow '(:flex-direction :flex-wrap :flex-flow
:justify-content :align-items :align-content :justify-content :align-items :align-content
@ -519,11 +529,15 @@ programmatic port and are therefore rejected here."
(not (eq (plist-get source :ebox-type) 'box)))) (not (eq (plist-get source :ebox-type) 'box))))
(defun ebox--flex-bind-source-viewport (source viewport thunk) (defun ebox--flex-bind-source-viewport (source viewport thunk)
"Call THUNK with VIEWPORT bound for composite flex item SOURCE." "Call THUNK in SOURCE's detached Flex-item render context.
VIEWPORT is bound only for composite SOURCE nodes; the Flex parent context is
always bound because SOURCE remains a direct Flex participant while rendered
as a detached geometry root."
(let ((ebox--render-root-parent-kind 'flex))
(if (and viewport (ebox--flex-composite-source-p source)) (if (and viewport (ebox--flex-composite-source-p source))
(let ((ebox-viewport-width viewport)) (let ((ebox-viewport-width viewport))
(funcall thunk)) (funcall thunk))
(funcall thunk))) (funcall thunk))))
(defvar ebox--flex-sized-render-observation-table (defvar ebox--flex-sized-render-observation-table
(make-hash-table :test 'eq :weakness 'key) (make-hash-table :test 'eq :weakness 'key)
@ -575,13 +589,18 @@ this avoids signature and retained-cost work for one-off responsive sizes."
Direct box flex items are measured intrinsically. Composite items receive Direct box flex items are measured intrinsically. Composite items receive
VIEWPORT for descendants so auto and max-width layouts can resolve against VIEWPORT for descendants so auto and max-width layouts can resolve against
their containing block, while stack/column containers do not stretch their own their containing block, while stack/column containers do not stretch their own
outer measurement width to VIEWPORT." outer measurement width to VIEWPORT."
(if (ebox--flex-composite-source-p source) (if (ebox--flex-composite-source-p source)
(let ((ebox-viewport-width viewport) (ebox--flex-bind-source-viewport
(ebox--intrinsic-layout-measurement t)) source viewport
(ebox--render-with-cache source)) (lambda ()
(let ((ebox-viewport-width nil)) (let ((ebox--intrinsic-layout-measurement t))
(ebox--render-with-cache source)))) (ebox--render-with-cache source))))
(ebox--flex-bind-source-viewport
source nil
(lambda ()
(let ((ebox-viewport-width nil))
(ebox--render-with-cache source))))))
(defun ebox--flex-measured-render-context-compatible-p (defun ebox--flex-measured-render-context-compatible-p
(source measured-viewport final-viewport) (source measured-viewport final-viewport)
@ -1115,12 +1134,14 @@ Return a sized render entry containing the rendered string and dimensions."
(ebox--flex-sized-entry-rendered fragment-entry)) (ebox--flex-sized-entry-rendered fragment-entry))
((and fragment-key ((and fragment-key
(fboundp 'ebox--render-cache-render-with-scroll-actions)) (fboundp 'ebox--render-cache-render-with-scroll-actions))
(let ((ebox-viewport-width item-viewport)) (ebox--flex-bind-source-viewport
source item-viewport
(lambda ()
(pcase-let ((`(,captured-rendered ,captured-side-effects) (pcase-let ((`(,captured-rendered ,captured-side-effects)
(ebox--render-cache-render-with-scroll-actions (ebox--render-cache-render-with-scroll-actions
(or node source)))) (or node source))))
(setq fragment-side-effects captured-side-effects) (setq fragment-side-effects captured-side-effects)
captured-rendered))) captured-rendered))))
(t (t
(ebox--flex-bind-source-viewport (ebox--flex-bind-source-viewport
source item-viewport source item-viewport
@ -1629,12 +1650,18 @@ metadata and rendering decisions."
(source (copy-sequence source)) (source (copy-sequence source))
(declarations (declarations
(append (plist-get source :ebox-style-declarations) (append (plist-get source :ebox-style-declarations)
(ebox-style-compile-declarations item-props)))) (ebox-style-compile-declarations item-props)))
(plist-put source :ebox-style-declarations declarations) (adapter (list :ebox-type 'flex-item
(list :ebox-type 'flex-item
:display '(block flow) :display '(block flow)
:node source :node source
:props item-props)))) :props item-props))
(token (if box-props
ebox--flex-item-wrapper-adapter-token
ebox--flex-item-direct-adapter-token)))
(plist-put source :ebox-style-declarations declarations)
(ebox-tree--register-legacy-flex-item-adapter
adapter token source
(and box-props (plist-get source :ebox-content-node))))))
;;;###autoload ;;;###autoload
(defun ebox-flex (&rest items) (defun ebox-flex (&rest items)

View File

@ -26,7 +26,11 @@
(declare-function ebox--replay-scroll-cache-actions (declare-function ebox--replay-scroll-cache-actions
"ebox" (actions &optional portable)) "ebox" (actions &optional portable))
(declare-function ebox-tree-validate-declarative-root (declare-function ebox-tree-validate-declarative-root
"ebox-tree" (root)) "ebox-tree"
(root &optional validate-participation-p root-parent-kind))
(declare-function ebox-tree-validate-indexed-participation
"ebox-tree"
(node-table parent-table node-ids &optional source))
(declare-function ebox-tree-clear-runtime-identities (declare-function ebox-tree-clear-runtime-identities
"ebox-tree" (root)) "ebox-tree" (root))
(declare-function ebox-tree-copy-node-structure (declare-function ebox-tree-copy-node-structure
@ -5815,9 +5819,32 @@ complete preorder first-owner and postorder last-box semantics."
:dirty-set :dirty-set
(ebox-incremental--candidate-dirty-set-from-touched (ebox-incremental--candidate-dirty-set-from-touched
old-state candidate-root touched) old-state candidate-root touched)
:participation-node-ids
(ebox-incremental--candidate-participation-node-ids touched)
:touched-count (length touched) :touched-count (length touched)
:removed-count (length removed)))) :removed-count (length removed))))
(defun ebox-incremental--candidate-participation-node-ids (touched)
"Return final nodes whose participation context changed in TOUCHED."
(let ((seen (make-hash-table :test 'equal)) result)
(cl-labels ((record
(node)
(when-let* ((node-id (and node (plist-get node :node-id))))
(unless (gethash node-id seen)
(puthash node-id t seen)
(push node-id result)))))
(dolist (entry touched)
(pcase-let ((`(,old-node ,new-node ,_parent-id ,_children-changed)
entry))
(record new-node)
;; Added/replaced children already occur in TOUCHED. Revisit every
;; direct child only when the parent's layout role itself changed.
(when (or (null old-node)
(not (eq (ebox-tree--child-layout-kind old-node 'parent)
(ebox-tree--child-layout-kind new-node 'parent))))
(ebox-tree-for-each-direct-child new-node #'record))))
(nreverse result))))
(defun ebox-incremental--candidate-local-index-delta (defun ebox-incremental--candidate-local-index-delta
(old-state candidate-root &optional path-copy-trace range-index-deltas) (old-state candidate-root &optional path-copy-trace range-index-deltas)
"Return local indexes and dirty entries for CANDIDATE-ROOT. "Return local indexes and dirty entries for CANDIDATE-ROOT.
@ -6138,6 +6165,8 @@ replace those O(n) table copies without changing this delta contract."
:native-node-postorder native-node-postorder))) :native-node-postorder native-node-postorder)))
(list :index index (list :index index
:dirty-set dirty-set :dirty-set dirty-set
:participation-node-ids
(ebox-incremental--candidate-participation-node-ids touched)
:touched-node-ids :touched-node-ids
(mapcar (mapcar
(lambda (entry) (lambda (entry)
@ -6538,7 +6567,7 @@ must be recomputed in the next publication."
(unless (and (listp next-root) (not (stringp next-root))) (unless (and (listp next-root) (not (stringp next-root)))
(error "Ebox declarative root must be an Ebox node")) (error "Ebox declarative root must be an Ebox node"))
;; Validate before copying so shared references and cycles remain visible. ;; Validate before copying so shared references and cycles remain visible.
(ebox-tree-validate-declarative-root next-root) (ebox-tree-validate-declarative-root next-root t)
(let* ((candidate-root (let* ((candidate-root
(ebox-tree-clear-runtime-identities (ebox-tree-clear-runtime-identities
(ebox-tree-copy-node-structure next-root))) (ebox-tree-copy-node-structure next-root)))
@ -6572,6 +6601,12 @@ must be recomputed in the next publication."
;; published, and path copying introduces no new sibling or ref ;; published, and path copying introduces no new sibling or ref
;; relations outside the replaced anchors. Re-walking the whole ;; relations outside the replaced anchors. Re-walking the whole
;; tree here made every commit pay one full-page validation. ;; tree here made every commit pay one full-page validation.
(let ((index (plist-get local-preparation :index)))
(ebox-tree-validate-indexed-participation
(plist-get index :node-table)
(plist-get index :parent-table)
(plist-get local-preparation :participation-node-ids)
'author))
(setq local-preparation (setq local-preparation
(plist-put local-preparation (plist-put local-preparation
:detached-identity-history detached-history)) :detached-identity-history detached-history))

View File

@ -1624,6 +1624,9 @@ Internal implementation of `ebox-render' for box nodes."
(defvar ebox--surface-materialization-active nil (defvar ebox--surface-materialization-active nil
"Non-nil while an Ebox TP producer is running raw layout.") "Non-nil while an Ebox TP producer is running raw layout.")
(defvar ebox--render-root-parent-kind nil
"Formatting context of a detached root rendered for its owning parent.")
(defun ebox--render-layout (node) (defun ebox--render-layout (node)
"Render layout NODE directly to a multi-line propertized string. "Render layout NODE directly to a multi-line propertized string.
NODE can be: NODE can be:

View File

@ -33,6 +33,7 @@
(defvar ebox--viewport-dependent-subtree-cache) (defvar ebox--viewport-dependent-subtree-cache)
(defvar ebox--viewport-height-dependent-subtree-cache) (defvar ebox--viewport-height-dependent-subtree-cache)
(defvar ebox--render-runtime-revision) (defvar ebox--render-runtime-revision)
(defvar ebox--render-root-parent-kind)
(defvar ebox--render-cache-table) (defvar ebox--render-cache-table)
(defvar ebox--render-cache-signature-cache) (defvar ebox--render-cache-signature-cache)
(defvar ebox--layout-fragments-table) (defvar ebox--layout-fragments-table)
@ -960,7 +961,8 @@ PREVIOUS-STATE's node-object table."
(and subjects (plist-get subjects :node-subject-table)) (and subjects (plist-get subjects :node-subject-table))
bindings-by-subject states-by-subject style-state-table bindings-by-subject states-by-subject style-state-table
stylesheet-signature stylesheet-signature
selector-tree-token stylesheet-subject-local-p)))) selector-tree-token stylesheet-subject-local-p
ebox--render-root-parent-kind))))
(list :surface-root surface-root (list :surface-root surface-root
:node-root node-root :node-root node-root
:objects-by-node objects-by-node :objects-by-node objects-by-node
@ -1056,7 +1058,10 @@ PREVIOUS-STATE's node-object table."
(puthash node object objects-by-node) (puthash node object objects-by-node)
(puthash node-id object objects-by-id)))) (puthash node-id object objects-by-id))))
(unless (gethash (plist-get root :node-id) objects-by-id) (unless (gethash (plist-get root :node-id) objects-by-id)
(error "Ebox native projection lost its root object"))) (error "Ebox native projection lost its root object"))
(when subject-table
(ebox-tree-validate-indexed-participation
nodes parents touched-ids 'computed)))
objects-by-node)) objects-by-node))
(defun ebox-surface--complete-projection (defun ebox-surface--complete-projection
@ -1089,7 +1094,8 @@ the projection roots a second time."
(and subjects (plist-get subjects :node-subject-table)) (and subjects (plist-get subjects :node-subject-table))
bindings-by-subject states-by-subject style-state-table bindings-by-subject states-by-subject style-state-table
stylesheet-signature stylesheet-signature
selector-tree-token stylesheet-subject-local-p) selector-tree-token stylesheet-subject-local-p
ebox--render-root-parent-kind)
projection)) projection))
(defun ebox-surface--projection-result (defun ebox-surface--projection-result
@ -1369,7 +1375,8 @@ When PRESERVE-IDENTITIES-P is non-nil, retain existing Ebox node and region
identities while always discarding TP handles and render-cache attachments." identities while always discarding TP handles and render-cache attachments."
(unless (and (listp source) (not (stringp source))) (unless (and (listp source) (not (stringp source)))
(error "Ebox surface source must be an Ebox node")) (error "Ebox surface source must be an Ebox node"))
(ebox-tree-validate-declarative-root source) (ebox-tree-validate-declarative-root
source t ebox--render-root-parent-kind)
(let ((candidate (ebox-tree-copy-node-structure source))) (let ((candidate (ebox-tree-copy-node-structure source)))
(if preserve-identities-p (if preserve-identities-p
(ebox-surface--clear-runtime-attachments candidate) (ebox-surface--clear-runtime-attachments candidate)
@ -1697,7 +1704,8 @@ dependencies, or any malformed index conservatively preserves root planning."
(defun ebox-surface--ensure-node-tree (defun ebox-surface--ensure-node-tree
(context parent node table subject-table bindings-by-subject (context parent node table subject-table bindings-by-subject
states-by-subject style-state-table stylesheet-signature states-by-subject style-state-table stylesheet-signature
selector-tree-token stylesheet-subject-local-p) selector-tree-token stylesheet-subject-local-p
&optional parent-kind parent-node grandparent-node)
"Ensure styled NODE descendants below PARENT in CONTEXT and fill TABLE." "Ensure styled NODE descendants below PARENT in CONTEXT and fill TABLE."
(let ((object (let ((object
(tp-object-ensure context parent (tp-object-ensure context parent
@ -1712,13 +1720,19 @@ dependencies, or any malformed index conservatively preserves root planning."
style-state-table style-state-table
stylesheet-signature selector-tree-token stylesheet-signature selector-tree-token
stylesheet-subject-local-p)) stylesheet-subject-local-p))
(dolist (child (ebox-tree--children-raw node)) (let ((child-kind (ebox-tree--child-layout-kind node parent-kind)))
(ebox-tree-for-each-direct-child
node
(lambda (child)
(ebox-surface--ensure-node-tree (ebox-surface--ensure-node-tree
context object child table subject-table bindings-by-subject context object child table subject-table bindings-by-subject
states-by-subject style-state-table stylesheet-signature states-by-subject style-state-table stylesheet-signature
selector-tree-token stylesheet-subject-local-p)) selector-tree-token stylesheet-subject-local-p
child-kind node parent-node))))
(when subject-table (when subject-table
(ebox-style-sync-flex-item node)) (ebox-style-sync-flex-item node)
(ebox-tree--validate-node-parent-participation
node parent-kind parent-node grandparent-node 'computed))
object)) object))
(defun ebox-surface--node-object-table (objects-by-node) (defun ebox-surface--node-object-table (objects-by-node)
@ -1951,6 +1965,9 @@ valid because their declarative layout and viewport did not change."
(binding (plist-get style-state :binding))) (binding (plist-get style-state :binding)))
(ebox-style-apply-computed node (tp-binding-read binding)) (ebox-style-apply-computed node (tp-binding-read binding))
(ebox-style-sync-flex-item node))) (ebox-style-sync-flex-item node)))
(when owner-ids
(ebox-tree-validate-indexed-participation
nodes (plist-get state :parent-table) owner-ids 'computed))
state)) state))
(defun ebox-surface--rendered-role-topology-signature (defun ebox-surface--rendered-role-topology-signature

View File

@ -2,7 +2,7 @@
;;; Commentary: ;;; Commentary:
;; Owns runtime identity, display accessors, child traversal, parent paths, and ;; Owns runtime identity, display accessors, child traversal, parent paths, and
;; flex participation metadata. It does not own layout measurement or buffer ;; parent participation metadata. It does not own layout measurement or buffer
;; editing. ;; editing.
;;; Code: ;;; Code:
@ -13,6 +13,7 @@
(require 'ecss-selector) (require 'ecss-selector)
(require 'ebox-style) (require 'ebox-style)
(require 'ebox-child-range) (require 'ebox-child-range)
(require 'ebox-layout-config)
(declare-function ebox-get "ebox" (box property)) (declare-function ebox-get "ebox" (box property))
(declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--ensure-node-id "ebox" (node))
@ -28,6 +29,11 @@
"Dynamic snapshot-detail-local node -> region ids cache. "Dynamic snapshot-detail-local node -> region ids cache.
This is an optimization for snapshot capture, not buffer runtime state.") This is an optimization for snapshot capture, not buffer runtime state.")
(defvar ebox--flex-item-prop-keys) (defvar ebox--flex-item-prop-keys)
(defvar ebox--flex-item-direct-adapter-token)
(defvar ebox--flex-item-wrapper-adapter-token)
(defvar ebox-tree--legacy-flex-item-adapter-proofs
(make-hash-table :test #'eq :weakness 'key)
"Weak adapter -> construction-shape proofs for legacy Flex items.")
(defun ebox-tree-computed-display (node) (defun ebox-tree-computed-display (node)
"Return NODE's canonical CSS-like display pair." "Return NODE's canonical CSS-like display pair."
@ -164,6 +170,44 @@ The returned nodes remain owned by their tree. Callers may inspect them but
must not modify a published runtime tree through this accessor." must not modify a published runtime tree through this accessor."
(ebox-tree--children-raw node)) (ebox-tree--children-raw node))
(defun ebox-tree-for-each-direct-child (node function)
"Call FUNCTION for each direct child of NODE without materializing Ranges."
(let ((type (and (listp node) (plist-get node :ebox-type)))
(sequence (and (listp node)
(plist-get node :ebox-child-sequence))))
(when (memq type '(flex grid))
(when-let* ((box (plist-get node :box)))
(funcall function box)))
(if sequence
(ebox-child-range--fold
sequence
(lambda (ignored child)
(funcall function child)
ignored)
nil)
(pcase type
('box
(if (plist-member node :children)
(dolist (child (plist-get node :children))
(funcall function child))
(when-let* ((child (plist-get node :ebox-content-node)))
(funcall function child))))
((or 'concat 'stack 'flex 'grid)
(dolist (child
(or (plist-get node :children)
(pcase type
('concat
(delq nil (list (plist-get node :left)
(plist-get node :right))))
('stack
(delq nil (list (plist-get node :top)
(plist-get node :bottom)))))))
(funcall function child)))
('flex-item
(when-let* ((child (plist-get node :node)))
(funcall function child))))))
node)
(defun ebox-tree-layout-children (node) (defun ebox-tree-layout-children (node)
"Return layout container children for NODE in render order. "Return layout container children for NODE in render order.
New row/column containers store flat `:children'. Legacy `ebox-concat' and New row/column containers store flat `:children'. Legacy `ebox-concat' and
@ -360,10 +404,17 @@ original object identity."
(setq copy (without (without copy :top) :bottom)))) (setq copy (without (without copy :top) :bottom))))
(plist-put copy :ebox-child-sequence (plist-put copy :ebox-child-sequence
(ebox-child-range--build (nreverse segments)))) (ebox-child-range--build (nreverse segments))))
(let ((copy
(ebox-tree-copy-with-direct-child-replacements (ebox-tree-copy-with-direct-child-replacements
node node
(mapcar (lambda (child) (cons child (copy-node child))) (mapcar (lambda (child)
(ebox-tree--children-raw node)))))))) (cons child (copy-node child)))
(ebox-tree--children-raw node)))))
;; Only this exact deep-copy traversal has proven every copied
;; child derives from the authentic source tree. Generic
;; path-copy replacements must not launder adapter provenance.
(ebox-tree--remint-legacy-flex-item-adapter-proof
node copy)))))))
(copy-node root))) (copy-node root)))
(defun ebox-tree--runtime-identity-node-shell (defun ebox-tree--runtime-identity-node-shell
@ -488,7 +539,225 @@ Host references are root-global opaque metadata and are compared with `equal'."
(visit root)) (visit root))
root)) root))
(defun ebox-tree-validate-declarative-root (root) (defconst ebox-tree--common-participation-keys
'(:order :align-self)
"Participation properties shared by Flex and Grid children.")
(defconst ebox-tree--flex-participation-keys
'(:flex :flex-grow :flex-shrink :flex-basis)
"Properties accepted only on a direct Flex child Box.")
(defconst ebox-tree--grid-participation-keys
'(:grid-column :grid-row :grid-column-span :grid-row-span :justify-self)
"Properties accepted only on a direct Grid child Box.")
(defconst ebox-tree--participation-keys
(append ebox-tree--common-participation-keys
ebox-tree--flex-participation-keys
ebox-tree--grid-participation-keys)
"Complete canonical parent-participation property domain.")
(defun ebox-tree--style-specifies-participation-p (style key)
"Return non-nil when computed STYLE has a declaration winner for KEY."
(and style
(ebox-style--specified-property-p style (ebox-style-schema-id key))))
(defun ebox-tree--declarations-specify-participation-p (node key)
"Return non-nil when NODE author declarations specify participation KEY."
(and (listp node)
(plist-member (ebox-style-node-declarations node)
(ebox-style-schema-id key))))
(defun ebox-tree--author-specifies-participation-p (node key)
"Return non-nil when NODE's author source specifies participation KEY."
(if (eq (plist-get node :ebox-type) 'flex-item)
(let* ((adapter-source (plist-get node :node))
(content (and (listp adapter-source)
(plist-get adapter-source :ebox-content-node)))
(projected-p
(or (and (listp adapter-source)
(plist-get adapter-source :ebox-computed-style))
(and (listp content)
(plist-get content :ebox-computed-style)))))
(or (ebox-tree--declarations-specify-participation-p
adapter-source key)
(ebox-tree--declarations-specify-participation-p content key)
(and (not projected-p)
(plist-member (plist-get node :props) key))))
(or (ebox-tree--declarations-specify-participation-p node key)
(and (null (plist-get node :ebox-computed-style))
(plist-member node key)))))
(defun ebox-tree--node-participation-keys (node source)
"Return NODE participation keys from AUTHOR or COMPUTED SOURCE."
(let ((flex-item-p (eq (plist-get node :ebox-type) 'flex-item))
(style (plist-get node :ebox-computed-style))
keys)
(dolist (key ebox-tree--participation-keys)
(when
(pcase source
('author
(ebox-tree--author-specifies-participation-p node key))
('computed
(if flex-item-p
(let* ((adapter-source (plist-get node :node))
(adapter-style
(and (listp adapter-source)
(plist-get adapter-source
:ebox-computed-style)))
(content (and (listp adapter-source)
(plist-get adapter-source
:ebox-content-node)))
(content-style
(and (listp content)
(plist-get content :ebox-computed-style))))
(and (plist-member (plist-get node :props) key)
(or (ebox-tree--style-specifies-participation-p
adapter-style key)
(ebox-tree--style-specifies-participation-p
content-style key))))
(and (plist-member node key)
(ebox-tree--style-specifies-participation-p style key))))
(_ (error "Unknown Ebox participation source: %S" source)))
(push key keys)))
(nreverse keys)))
(defun ebox-tree--child-layout-kind (node parent-kind)
"Return layout kind governing NODE children, preserving transparent adapters."
(pcase (plist-get node :ebox-type)
('flex-item parent-kind)
('flex 'flex)
('grid 'grid)
(_ (pcase (if (and (eq (plist-get node :ebox-kind) 'box)
(plist-get node :ebox-layout-config))
(ebox-layout-config-kind
(plist-get node :ebox-layout-config))
(ebox-tree-display-inner node))
('flow 'normal)
(kind kind)))))
(defun ebox-tree--legacy-flex-item-adapter-p (node token)
"Return non-nil when NODE has the exact legacy Flex adapter TOKEN shape."
(let ((proof (gethash node ebox-tree--legacy-flex-item-adapter-proofs))
(source (plist-get node :node)))
(and token
(eq (plist-get node :ebox-type) 'flex-item)
(vectorp proof)
(= (length proof) 3)
(eq (aref proof 0) token)
(eq (aref proof 1) source)
(listp source)
(not (stringp source))
(let ((children (ebox-tree--children-raw node)))
(and (eq (car children) source)
(null (cdr children))))
(if (eq token ebox--flex-item-wrapper-adapter-token)
(let ((content (plist-get source :ebox-content-node)))
(and (eq (plist-get source :ebox-type) 'box)
(listp content)
(not (stringp content))
(eq (aref proof 2) content)))
(null (aref proof 2))))))
(defun ebox-tree--register-legacy-flex-item-adapter
(adapter token source content)
"Register ADAPTER's exact TOKEN, SOURCE, and optional CONTENT identities."
(puthash adapter (vector token source content)
ebox-tree--legacy-flex-item-adapter-proofs)
adapter)
(defun ebox-tree--remint-legacy-flex-item-adapter-proof (old new)
"Bind NEW adapter provenance to copied children when OLD is authentic."
(cond
((ebox-tree--legacy-flex-item-adapter-p
old ebox--flex-item-direct-adapter-token)
(ebox-tree--register-legacy-flex-item-adapter
new ebox--flex-item-direct-adapter-token (plist-get new :node) nil))
((ebox-tree--legacy-flex-item-adapter-p
old ebox--flex-item-wrapper-adapter-token)
(let ((wrapper (plist-get new :node)))
(ebox-tree--register-legacy-flex-item-adapter
new ebox--flex-item-wrapper-adapter-token
wrapper (plist-get wrapper :ebox-content-node))))
(t
(remhash new ebox-tree--legacy-flex-item-adapter-proofs)
new)))
(defun ebox-tree--legacy-flex-item-duplicate-p (node parent grandparent)
"Return non-nil when NODE is an exact duplicate inside a legacy adapter."
(or
(and (ebox-tree--legacy-flex-item-adapter-p
parent ebox--flex-item-direct-adapter-token)
(eq node (plist-get parent :node)))
(and (ebox-tree--legacy-flex-item-adapter-p
parent ebox--flex-item-wrapper-adapter-token)
(let ((wrapper (plist-get parent :node)))
(and (eq node wrapper)
(eq (plist-get wrapper :ebox-type) 'box)
(listp (plist-get wrapper :ebox-content-node)))))
(and (ebox-tree--legacy-flex-item-adapter-p
grandparent ebox--flex-item-wrapper-adapter-token)
(let ((wrapper (plist-get grandparent :node)))
(and (eq parent wrapper)
(eq (plist-get wrapper :ebox-type) 'box)
(eq node (plist-get wrapper :ebox-content-node)))))))
(defun ebox-tree--validate-node-parent-participation
(node parent-kind parent grandparent source)
"Validate NODE participation under PARENT-KIND from AUTHOR or COMPUTED SOURCE."
(unless (ebox-tree--legacy-flex-item-duplicate-p node parent grandparent)
(let ((keys (ebox-tree--node-participation-keys node source)))
(when keys
(when (eq (plist-get node :ebox-kind) 'text)
(error "Ebox Text cannot carry participation properties: %S" keys))
(pcase parent-kind
('flex
(when (cl-intersection
keys ebox-tree--grid-participation-keys :test #'eq)
(error "Grid participation requires a direct Grid parent: %S"
keys)))
('grid
(when (cl-intersection
keys ebox-tree--flex-participation-keys :test #'eq)
(error "Flex participation requires a direct Flex parent: %S"
keys)))
(_ (error "Ebox participation requires a Flex or Grid parent: %S"
keys))))))
node)
(defun ebox-tree--indexed-parent-context
(node-id node-table parent-table)
"Return (PARENT-KIND PARENT GRANDPARENT) for NODE-ID in indexed tree."
(let* ((parent-id (gethash node-id parent-table))
(parent (and parent-id (gethash parent-id node-table)))
(grandparent-id (and parent-id (gethash parent-id parent-table)))
(grandparent (and grandparent-id
(gethash grandparent-id node-table)))
(layout-owner parent))
(while (and layout-owner
(eq (plist-get layout-owner :ebox-type) 'flex-item))
(let ((owner-id (plist-get layout-owner :node-id)))
(setq owner-id (and owner-id (gethash owner-id parent-table))
layout-owner (and owner-id (gethash owner-id node-table)))))
(list (and layout-owner
(ebox-tree--child-layout-kind layout-owner nil))
parent grandparent)))
(defun ebox-tree-validate-indexed-participation
(node-table parent-table node-ids &optional source)
"Validate NODE-IDS in indexed final tree using AUTHOR or COMPUTED SOURCE."
(setq source (or source 'author))
(dolist (node-id (delete-dups (copy-sequence node-ids)))
(when-let* ((node (gethash node-id node-table)))
(pcase-let ((`(,parent-kind ,parent ,grandparent)
(ebox-tree--indexed-parent-context
node-id node-table parent-table)))
(ebox-tree--validate-node-parent-participation
node parent-kind parent grandparent source))))
node-table)
(defun ebox-tree-validate-declarative-root
(root &optional validate-participation-p root-parent-kind)
"Validate ROOT for one declarative runtime commit. "Validate ROOT for one declarative runtime commit.
The runtime tree must be a proper tree: a node object cannot appear in two The runtime tree must be a proper tree: a node object cannot appear in two
@ -502,7 +771,7 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
(active (make-hash-table :test 'eq)) (active (make-hash-table :test 'eq))
(range-refs (make-hash-table :test 'equal))) (range-refs (make-hash-table :test 'equal)))
(cl-labels (cl-labels
((visit (node) ((visit (node parent-kind parent grandparent)
(when (and (listp node) (not (stringp node))) (when (and (listp node) (not (stringp node)))
(when (gethash node active) (when (gethash node active)
(error "Ebox declarative tree contains a cycle")) (error "Ebox declarative tree contains a cycle"))
@ -510,6 +779,9 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
(error "Ebox declarative tree reuses one node object")) (error "Ebox declarative tree reuses one node object"))
(puthash node t seen) (puthash node t seen)
(puthash node t active) (puthash node t active)
(when validate-participation-p
(ebox-tree--validate-node-parent-participation
node parent-kind parent grandparent 'author))
(let* ((keys (make-hash-table :test 'equal)) (let* ((keys (make-hash-table :test 'equal))
(type (plist-get node :ebox-type)) (type (plist-get node :ebox-type))
(raw (and (memq type '(concat stack flex grid)) (raw (and (memq type '(concat stack flex grid))
@ -547,9 +819,11 @@ and all explicit identities are compared with `equal'. Return ROOT on success."
(error "Ebox declarative siblings use duplicate key %S" (error "Ebox declarative siblings use duplicate key %S"
key)) key))
(puthash key t keys)) (puthash key t keys))
(visit child))) (visit child
(ebox-tree--child-layout-kind node parent-kind)
node parent)))
(remhash node active)))) (remhash node active))))
(visit root)) (visit root root-parent-kind nil nil))
root)) root))
(defun ebox-tree-clear-runtime-identities (root) (defun ebox-tree-clear-runtime-identities (root)

View File

@ -1260,6 +1260,91 @@ remain retained identities."
(when (buffer-live-p buffer) (when (buffer-live-p buffer)
(kill-buffer buffer))))) (kill-buffer buffer)))))
(ert-deftest ebox-candidate-rejects-invalid-final-parent-participation ()
"A detached replacement must be revalidated after its final graft."
(let* ((buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-participation-rollback*")
(ebox-column
(ebox-create :key 'target :host-ref 'target
:content "Stable" :width '(80)))))
(surface (with-current-buffer buffer ebox-surface--buffer-surface))
(revision (tp-surface-revision surface))
(before (ebox-commit-test--buffer-string buffer))
(candidate (ebox-candidate-begin buffer)))
(unwind-protect
(progn
;; Detached subtrees do not know their parent yet, so recording the
;; replacement is legal. The final Column graft is authoritative.
(ebox-candidate-replace-host-ref
candidate 'target
(ebox-create :key 'target :host-ref 'target
:content "Invalid" :width '(80) :flex-grow 1))
(should-error (ebox-commit buffer candidate) :type 'error)
(should (= (tp-surface-revision surface) revision))
(should (equal (ebox-commit-test--buffer-string buffer) before)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-candidate-cannot-launder-legacy-adapter-provenance ()
"A public path copy must not authenticate a replacement as adapter output."
(let* ((adapter
(ebox-flex-item
(ebox-create :host-ref 'target :content "Stable" :width '(80))))
(buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-adapter-proof-rollback*")
(ebox-column adapter)))
(surface (with-current-buffer buffer ebox-surface--buffer-surface))
(revision (tp-surface-revision surface))
(before (ebox-commit-test--buffer-string buffer))
(candidate (ebox-candidate-begin buffer))
(replacement
(ebox-create :host-ref 'target :content "Invalid" :width '(80))))
(plist-put replacement :flex-grow 1)
(unwind-protect
(progn
(ebox-candidate-replace-host-ref candidate 'target replacement)
(should-error (ebox-commit buffer candidate) :type 'error)
(should (= (tp-surface-revision surface) revision))
(should (equal (ebox-commit-test--buffer-string buffer) before)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-candidate-participation-validation-stays-changed-local ()
"One replacement must not participation-validate every sibling."
(let* ((children
(cl-loop for index below 200
collect
(if (= index 99)
(ebox-create :key index :host-ref 'target
:content (number-to-string index))
(ebox-create :key index
:content (number-to-string index)))))
(buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-participation-local*")
(apply #'ebox-column children)))
(candidate (ebox-candidate-begin buffer))
(original
(symbol-function 'ebox-tree-validate-indexed-participation))
author-node-counts)
(unwind-protect
(progn
(ebox-candidate-replace-host-ref
candidate 'target
(ebox-create :key 99 :host-ref 'target :content "changed"))
(cl-letf
(((symbol-function 'ebox-tree-validate-indexed-participation)
(lambda (node-table parent-table node-ids &optional source)
(when (eq source 'author)
(push (length node-ids) author-node-counts))
(funcall original node-table parent-table node-ids source))))
(ebox-commit buffer candidate))
(should (equal author-node-counts '(2))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-candidate-root-replacement-reuses-candidate-validity-contract () (ert-deftest ebox-candidate-root-replacement-reuses-candidate-validity-contract ()
"Root replacement rejects invalid, other-buffer, stale, and sealed use." "Root replacement rejects invalid, other-buffer, stale, and sealed use."
(let ((first (generate-new-buffer " *ebox-root-valid-first*")) (let ((first (generate-new-buffer " *ebox-root-valid-first*"))

View File

@ -421,6 +421,106 @@
(should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 5)) (should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 5))
(should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 5)))) (should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 5))))
(ert-deftest ebox-canonical-participation-validates-the-final-parent-context ()
"Participation should be legal only under its matching direct parent layout."
(cl-labels
((child (&rest props)
(apply #'ebox-box-create
:layout (ebox-normal-layout-create)
:children (list (ebox-text-create :value "A"))
props))
(parent (layout child)
(ebox-box-create :layout layout :children (list child))))
(should (stringp
(ebox-render
(parent (ebox-flex-layout-create) (child :flex-grow 1)))))
(should (stringp
(ebox-render
(parent (ebox-grid-layout-create) (child :grid-row 1)))))
(should-error
(ebox-render (child :flex-grow 1)) :type 'error)
(should-error
(ebox-render
(parent (ebox-normal-layout-create) (child :flex-grow 1)))
:type 'error)
(should-error
(ebox-render
(parent (ebox-flex-layout-create) (child :grid-row 1)))
:type 'error)
(should-error
(ebox-render
(parent (ebox-grid-layout-create) (child :flex-grow 1)))
:type 'error)
(let ((text (ebox-text-create :value "A")))
(plist-put text :flex-grow 1)
(should-error
(ebox-render (parent (ebox-flex-layout-create) text)) :type 'error))
;; The final candidate is authoritative after ECSS projection too.
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule "box" (list :flex-grow 1))
(should-error
(ebox-render
(parent (ebox-normal-layout-create) (child)))
:type 'error))
;; Detached Flex geometry retains its final parent during computed style.
(let ((ebox-style-stylesheet (ecss-stylesheet-create))
(detached (child)))
(plist-put detached :class "detached-flex-item")
(ebox-style-add-rule ".detached-flex-item" (list :flex-grow 1))
(let ((ebox--render-root-parent-kind 'flex))
(should (stringp (ebox-render detached))))
;; The dynamic context is exact and cannot authorize a later root.
(should-error (ebox-render detached) :type 'error))
(let ((ebox-style-stylesheet (ecss-stylesheet-create))
(detached (child)))
(plist-put detached :class "detached-flex-item")
(ebox-style-add-rule ".detached-flex-item" (list :grid-row 1))
(let ((ebox--render-root-parent-kind 'flex))
(should-error (ebox-render detached) :type 'error)))
;; A real legacy adapter exempts only its two exact duplicate nodes.
(let* ((illegal-grandchild (child :flex-grow 1))
(nested (parent (ebox-normal-layout-create) illegal-grandchild))
(adapter (ebox-flex-item nested :flex-grow 1 :padding 1)))
;; The obsolete mutable depth field must have no authority.
(plist-put adapter :ebox-flex-item-adapter-depth 99)
(should-error
(ebox-render (ebox-flex :width '(100) adapter))
:type 'error))
;; Provenance binds the exact constructor-time source identities.
(let* ((adapter (ebox-flex-item (ebox-create :content "original")))
(replacement (ebox-create :content "replacement")))
(plist-put replacement :flex-grow 1)
(plist-put adapter :node replacement)
(should-error
(ebox-render (ebox-column adapter)) :type 'error))
(let* ((adapter (ebox-flex-item (ebox-create :content "original")))
(source (plist-get adapter :node))
(replacement (ebox-create :content "replacement")))
(plist-put replacement :flex-grow 1)
(setq adapter
(ebox-tree-copy-with-direct-child-replacements
adapter (list (cons source replacement))))
(should-error
(ebox-render (ebox-column adapter)) :type 'error))
(let* ((adapter
(ebox-flex-item (ebox-create :content "original") :padding 1))
(wrapper (plist-get adapter :node))
(replacement (ebox-create :content "replacement")))
(plist-put replacement :flex-grow 1)
(plist-put wrapper :ebox-content-node replacement)
(should-error
(ebox-render (ebox-column adapter)) :type 'error))
;; Internal layout and parent participation are orthogonal facts.
(should
(stringp
(ebox-render
(parent
(ebox-grid-layout-create)
(ebox-box-create
:layout (ebox-flex-layout-create)
:children (list (ebox-text-create :value "A"))
:grid-row 1)))))))
(ert-deftest ebox-canonical-grid-revalidates-the-typed-config-boundary () (ert-deftest ebox-canonical-grid-revalidates-the-typed-config-boundary ()
"GridConfig must reject frame properties and cross-kind property sets." "GridConfig must reject frame properties and cross-kind property sets."
(let ((extra-frame-prop (ebox-grid-layout-create)) (let ((extra-frame-prop (ebox-grid-layout-create))