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))
(declare-function ebox--render-with-cache
"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-get "ebox" (box property))
(declare-function ebox--ensure-node-id "ebox" (node))
@ -80,6 +82,14 @@
'(:order :flex :flex-grow :flex-shrink :flex-basis :align-self)
"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
'(:flex-direction :flex-wrap :flex-flow
: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))))
(defun ebox--flex-bind-source-viewport (source viewport thunk)
"Call THUNK with VIEWPORT bound for composite flex item SOURCE."
(if (and viewport (ebox--flex-composite-source-p source))
(let ((ebox-viewport-width viewport))
(funcall thunk))
(funcall thunk)))
"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))
(let ((ebox-viewport-width viewport))
(funcall thunk))
(funcall thunk))))
(defvar ebox--flex-sized-render-observation-table
(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
VIEWPORT for descendants so auto and max-width layouts can resolve against
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)
(let ((ebox-viewport-width viewport)
(ebox--intrinsic-layout-measurement t))
(ebox--render-with-cache source))
(let ((ebox-viewport-width nil))
(ebox--render-with-cache source))))
(ebox--flex-bind-source-viewport
source viewport
(lambda ()
(let ((ebox--intrinsic-layout-measurement t))
(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
(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))
((and fragment-key
(fboundp 'ebox--render-cache-render-with-scroll-actions))
(let ((ebox-viewport-width item-viewport))
(pcase-let ((`(,captured-rendered ,captured-side-effects)
(ebox--render-cache-render-with-scroll-actions
(or node source))))
(setq fragment-side-effects captured-side-effects)
captured-rendered)))
(ebox--flex-bind-source-viewport
source item-viewport
(lambda ()
(pcase-let ((`(,captured-rendered ,captured-side-effects)
(ebox--render-cache-render-with-scroll-actions
(or node source))))
(setq fragment-side-effects captured-side-effects)
captured-rendered))))
(t
(ebox--flex-bind-source-viewport
source item-viewport
@ -1629,12 +1650,18 @@ metadata and rendering decisions."
(source (copy-sequence source))
(declarations
(append (plist-get source :ebox-style-declarations)
(ebox-style-compile-declarations item-props))))
(ebox-style-compile-declarations item-props)))
(adapter (list :ebox-type 'flex-item
:display '(block flow)
:node source
: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)
(list :ebox-type 'flex-item
:display '(block flow)
:node source
:props item-props))))
(ebox-tree--register-legacy-flex-item-adapter
adapter token source
(and box-props (plist-get source :ebox-content-node))))))
;;;###autoload
(defun ebox-flex (&rest items)

View File

@ -26,7 +26,11 @@
(declare-function ebox--replay-scroll-cache-actions
"ebox" (actions &optional portable))
(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
"ebox-tree" (root))
(declare-function ebox-tree-copy-node-structure
@ -5815,9 +5819,32 @@ complete preorder first-owner and postorder last-box semantics."
:dirty-set
(ebox-incremental--candidate-dirty-set-from-touched
old-state candidate-root touched)
:participation-node-ids
(ebox-incremental--candidate-participation-node-ids touched)
:touched-count (length touched)
: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
(old-state candidate-root &optional path-copy-trace range-index-deltas)
"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)))
(list :index index
:dirty-set dirty-set
:participation-node-ids
(ebox-incremental--candidate-participation-node-ids touched)
:touched-node-ids
(mapcar
(lambda (entry)
@ -6538,7 +6567,7 @@ must be recomputed in the next publication."
(unless (and (listp next-root) (not (stringp next-root)))
(error "Ebox declarative root must be an Ebox node"))
;; 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
(ebox-tree-clear-runtime-identities
(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
;; relations outside the replaced anchors. Re-walking the whole
;; 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
(plist-put local-preparation
: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
"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)
"Render layout NODE directly to a multi-line propertized string.
NODE can be:

View File

@ -33,6 +33,7 @@
(defvar ebox--viewport-dependent-subtree-cache)
(defvar ebox--viewport-height-dependent-subtree-cache)
(defvar ebox--render-runtime-revision)
(defvar ebox--render-root-parent-kind)
(defvar ebox--render-cache-table)
(defvar ebox--render-cache-signature-cache)
(defvar ebox--layout-fragments-table)
@ -960,7 +961,8 @@ PREVIOUS-STATE's node-object table."
(and subjects (plist-get subjects :node-subject-table))
bindings-by-subject states-by-subject style-state-table
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
:node-root node-root
:objects-by-node objects-by-node
@ -1056,7 +1058,10 @@ PREVIOUS-STATE's node-object table."
(puthash node object objects-by-node)
(puthash node-id object 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))
(defun ebox-surface--complete-projection
@ -1089,7 +1094,8 @@ the projection roots a second time."
(and subjects (plist-get subjects :node-subject-table))
bindings-by-subject states-by-subject style-state-table
stylesheet-signature
selector-tree-token stylesheet-subject-local-p)
selector-tree-token stylesheet-subject-local-p
ebox--render-root-parent-kind)
projection))
(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."
(unless (and (listp source) (not (stringp source)))
(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)))
(if preserve-identities-p
(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
(context parent node table subject-table bindings-by-subject
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."
(let ((object
(tp-object-ensure context parent
@ -1712,13 +1720,19 @@ dependencies, or any malformed index conservatively preserves root planning."
style-state-table
stylesheet-signature selector-tree-token
stylesheet-subject-local-p))
(dolist (child (ebox-tree--children-raw node))
(ebox-surface--ensure-node-tree
context object child table subject-table bindings-by-subject
states-by-subject style-state-table stylesheet-signature
selector-tree-token stylesheet-subject-local-p))
(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
context object child table subject-table bindings-by-subject
states-by-subject style-state-table stylesheet-signature
selector-tree-token stylesheet-subject-local-p
child-kind node parent-node))))
(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))
(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)))
(ebox-style-apply-computed node (tp-binding-read binding))
(ebox-style-sync-flex-item node)))
(when owner-ids
(ebox-tree-validate-indexed-participation
nodes (plist-get state :parent-table) owner-ids 'computed))
state))
(defun ebox-surface--rendered-role-topology-signature

View File

@ -2,7 +2,7 @@
;;; Commentary:
;; 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.
;;; Code:
@ -13,6 +13,7 @@
(require 'ecss-selector)
(require 'ebox-style)
(require 'ebox-child-range)
(require 'ebox-layout-config)
(declare-function ebox-get "ebox" (box property))
(declare-function ebox--ensure-node-id "ebox" (node))
@ -28,6 +29,11 @@
"Dynamic snapshot-detail-local node -> region ids cache.
This is an optimization for snapshot capture, not buffer runtime state.")
(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)
"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."
(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)
"Return layout container children for NODE in render order.
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))))
(plist-put copy :ebox-child-sequence
(ebox-child-range--build (nreverse segments))))
(ebox-tree-copy-with-direct-child-replacements
node
(mapcar (lambda (child) (cons child (copy-node child)))
(ebox-tree--children-raw node))))))))
(let ((copy
(ebox-tree-copy-with-direct-child-replacements
node
(mapcar (lambda (child)
(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)))
(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))
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.
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))
(range-refs (make-hash-table :test 'equal)))
(cl-labels
((visit (node)
((visit (node parent-kind parent grandparent)
(when (and (listp node) (not (stringp node)))
(when (gethash node active)
(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"))
(puthash node t seen)
(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))
(type (plist-get node :ebox-type))
(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"
key))
(puthash key t keys))
(visit child)))
(visit child
(ebox-tree--child-layout-kind node parent-kind)
node parent)))
(remhash node active))))
(visit root))
(visit root root-parent-kind nil nil))
root))
(defun ebox-tree-clear-runtime-identities (root)

View File

@ -1260,6 +1260,91 @@ remain retained identities."
(when (buffer-live-p 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 ()
"Root replacement rejects invalid, other-buffer, stale, and sealed use."
(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 #'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 ()
"GridConfig must reject frame properties and cross-kind property sets."
(let ((extra-frame-prop (ebox-grid-layout-create))