docs: close M0b public contract drift

This commit is contained in:
Kinneyzhang 2026-08-31 13:43:23 +08:00
parent 162f4b83fd
commit 46ffc69c5f
13 changed files with 561 additions and 156 deletions

View File

@ -64,7 +64,7 @@ Properties must be complete before the first child. A child can be a string, a n
(text :font-weight 'bold "Hello")
(text
:color "#687386"
(expr :value (if ready "Ready" "Waiting")))))
(expr (if ready "Ready" "Waiting")))))
```
An attribute appearing after a child is invalid because the two regions may
@ -77,18 +77,17 @@ not be interleaved.
The rule is simple:
1. Structural View positions are not quoted. This includes `etaf-view`, Hosts, Component calls, children, slot forms, and static Component styles.
2. Elisp expression positions follow normal Elisp evaluation. This includes attribute values, `:key`, `:on-*`, `:use`, `expr :value`, `:setup`, Context values, Behavior constructors, and Actions.
2. Elisp expression positions follow normal Elisp evaluation. This includes attribute values, `:key`, `:on-*`, `:use`, `expr`, `:setup`, Context values, Behavior constructors, and Actions.
```elisp
(etaf-view
(text
:color (if dark "#F4F6FB" "#1F2328")
(expr :value label)))
(expr label)))
(etaf-view
(column
(expr
:value
(when open
(etaf-view
(text :font-weight 'bold "Details"))))))
@ -103,30 +102,37 @@ Attribute values do not need an `expr` wrapper. `expr` exists only because the c
`expr` accepts exactly one property and no children:
```elisp
(expr :value ELISP-EXPRESSION)
(expr ELISP-EXPRESSION)
```
It evaluates the expression, then accepts a string, View, sequence, or `nil`. It creates no Ebox wrapper, identity, lifecycle, watcher, or effect. `if`, `when`, `cond`, `let`, `mapcar`, `cl-loop`, and other Elisp forms remain ordinary Elisp inside `:value`.
It evaluates the expression, then accepts a string, typed View, proper typed
View sequence, or `nil`. It creates no Ebox wrapper, identity, lifecycle,
watcher, or effect. `if`, `when`, `cond`, `let`, `mapcar`, `cl-loop`, and other
Elisp forms remain ordinary Elisp inside the form.
The current core has no file-facing `.etaf` pair loader. `etaf-define-component` is the structure/style/behavior unit: its View defines structure, `:styles` owns presentation rules, and `:setup` owns retained state, events, and lifecycle behavior. A future `.etaf` single-file component format belongs in a compiler layer that lowers into this same public View and Component contract; it is not a second runtime grammar.
## 4. Components
The public definition macro has exactly three keywords:
The public definition macro has four keywords. Choose exactly one frontend;
the other two clauses are optional:
```elisp
```text
(etaf-define-component NAME (&key PROPS)
DOCSTRING?
:setup OPAQUE-STATE-FORM
:view VIEW
:styles (styles RULE...))
(etaf-define-component NAME (&key PROPS)
DOCSTRING?
:setup SETUP
:setup OPAQUE-STATE-FORM
:render ORDINARY-ELISP
:styles (styles RULE...))
```
`:view` and `:setup` are mutually exclusive. `:styles` is optional and may appear once. Props are the only declared business inputs; ordinary trailing children and named slots are normalized separately into the Component's slot collection.
`:view` and `:render` are mutually exclusive and exactly one is required.
`:setup` and `:styles` are optional and may each appear once. Props are the only declared business inputs; ordinary trailing children and named slots are normalized separately into the Component's slot collection.
The Component definition is the current structure/style/behavior boundary. Keep dynamic state, Action callbacks, and lifecycle work in `:setup`; keep static presentation in `:styles`. A future `.etaf` SFC compiler may produce these definitions, but the Runtime does not load `.etaf` files directly.
@ -138,31 +144,34 @@ The Component definition is the current structure/style/behavior boundary. Keep
:view
(text
:font-weight 'bold
(expr :value label)))
(expr label)))
```
```elisp
(etaf-define-component disclosure (&key title)
"Render a retained disclosure."
:setup
(let ((open (etaf-ref nil)))
(lambda ()
(etaf-view
(column
(text
:role 'button
:on-press
(lambda ()
(setf (etaf-value open)
(not (etaf-value open))))
(expr :value (if (etaf-value open) "Hide" "Show")))
(expr
:value
(when (etaf-value open)
(etaf-view (text (expr :value title))))))))))
(etaf-ref nil)
:view
(column
(text
:role 'button
:on-press
(let ((open (etaf-state)))
(lambda ()
(setf (etaf-value open)
(not (etaf-value open)))))
(expr (if (etaf-value (etaf-state)) "Hide" "Show")))
(expr
(when (etaf-value (etaf-state))
(etaf-view (text (expr title)))))))
```
`:setup` runs once for a retained Component instance and must return a zero-argument render function. Re-render reads current props and refs without rerunning setup. Setup is the owner for local refs, computed values, watches, Effects, and cleanup registration.
`:setup` runs once for a retained Component instance and returns one opaque state
value. `etaf-state` returns that exact value during `:view` or `:render`.
Re-render reads current props and state without rerunning setup. Setup is the
owner for local refs, computed values, watches, Effects, and cleanup
registration; it never returns a render function.
`:key` is stable identity metadata, not a business prop. On a Component call it selects the retained Component instance within the sibling scope; on a Host it is forwarded as the Ebox node key. If a render candidate fails, the Runtime restores the previous instance, handlers, behaviors, and buffer.
@ -296,6 +305,19 @@ Use `etaf-action-define` and `etaf-dispatch` when the mutation is named and shar
`etaf-behavior-create` accepts the reserved `:install` attribute for an optional zero-argument installer. The installer may return a cleanup function; `etaf-current-behavior-context` exposes the current Runtime, structural path, and Host props while it runs. Installer state is disposed when the Behavior is replaced or its owner is unmounted.
Interaction composition is deterministic. An explicit Host `:on-*` callback
runs first, followed by Behavior callbacks in declaration order; an error
short-circuits the remaining callbacks. For non-event attributes the explicit
Host value wins, otherwise the first declaring Behavior wins. Behavior names
must be unique on one Host before any installer runs. Stable installer identity
is reused, and every installed cleanup runs exactly once. Events dispatch only
to the exact Host reference: ETAF has no capture or bubble phase.
Action names should be application- or feature-prefixed symbols. Duplicate
registration is an error by default. `etaf-action-redefine-run` is the explicit
authoring/reload boundary; replacement affects future name-based dispatch and
does not flush a mounted Runtime.
Runtime events are dispatched through `etaf-dispatch-event`, and focus/hit testing use public Ebox Host-reference queries through `etaf-activate`, `etaf-focus`, `etaf-focus-next`, `etaf-host-ref-bounds`, and `etaf-host-ref-position`.
## 9. Context, Theme, Data, and Resource
@ -310,14 +332,13 @@ Context is an inherited Component Scope environment:
:setup
(let ((service (etaf-ref "demo-service")))
(etaf-provide 'service service)
(lambda () (etaf-view (slot)))))
service)
:view (slot))
(etaf-define-component service-consumer ()
"Read the inherited service."
:setup
(let ((service (etaf-inject 'service nil t)))
(lambda ()
(etaf-view (text (expr :value (etaf-value service)))))))
:setup (etaf-inject 'service nil t)
:view (text (expr (etaf-value (etaf-state)))))
(etaf-view (service-provider (service-consumer)))
```
@ -328,10 +349,9 @@ Keys are stable ordinary symbols. The nearest ancestor wins; a missing required
(etaf-define-component themed-shell ()
"Provide default text colors to a subtree."
:setup
(progn
(etaf-theme-provide
'(:color "#F4F6FB" :bgcolor "#202634"))
(lambda () (etaf-view (slot)))))
(etaf-theme-provide
'(:color "#F4F6FB" :bgcolor "#202634"))
:view (slot))
```
Palette resolution remains a Theme concern, not a UI catalog concern. Core

View File

@ -64,7 +64,7 @@ ATTRIBUTE = :KEY VALUE
(text :font-weight 'bold "Hello")
(text
:color "#687386"
(expr :value (if ready "Ready" "Waiting")))))
(expr (if ready "Ready" "Waiting")))))
```
属性出现在子节点之后时,属性区和子节点区被交错,属于非法结构。
@ -76,18 +76,17 @@ ATTRIBUTE = :KEY VALUE
规则只有两条:
1. 结构性 View 位置不需要 quote包括 `etaf-view`、Host、Component 调用、子节点、slot 和静态 Component styles。
2. Elisp 表达式位置遵循普通 Elisp 求值,包括属性值、`:key`、`:on-*`、`:use`、`expr :value`、`:setup`、Context 值、Behavior 构造器和 Action。
2. Elisp 表达式位置遵循普通 Elisp 求值,包括属性值、`:key`、`:on-*`、`:use`、`expr`、`:setup`、Context 值、Behavior 构造器和 Action。
```elisp
(etaf-view
(text
:color (if dark "#F4F6FB" "#1F2328")
(expr :value label)))
(expr label)))
(etaf-view
(column
(expr
:value
(when open
(etaf-view
(text :font-weight 'bold "Details"))))))
@ -102,30 +101,34 @@ ATTRIBUTE = :KEY VALUE
`expr` 只接受一个属性且不能有子节点:
```elisp
(expr :value ELISP-EXPRESSION)
(expr ELISP-EXPRESSION)
```
它执行表达式然后接受字符串、View、View 序列或 `nil`。它不创建 Ebox wrapper、identity、生命周期、watch 或 effect。`if`、`when`、`cond`、`let`、`mapcar`、`cl-loop` 等 Elisp 形式仍然只是 `:value` 中的普通 Elisp。
它执行表达式然后接受字符串、typed View、typed View proper sequence 或 `nil`
它不创建 Ebox wrapper、identity、生命周期、watch 或 effect。`if`、`when`、`cond`、
`let`、`mapcar`、`cl-loop` 等仍是 form 中的普通 Elisp。
当前 core 没有面向文件的 `.etaf` pair loader。`etaf-define-component` 才是结构/样式/行为单元View 定义结构,`:styles` 负责 presentation`:setup` 负责 retained state、事件和生命周期行为。未来的 `.etaf` SFC 属于把结果 lowering 到同一套公共 View/Component 契约的 compiler layer而不是第二套 Runtime 语法。
## 4. Component
公共定义宏只有三个关键字
公共定义宏有四个关键字。两个 frontend 必须二选一,另外两个 clause 可选
```elisp
```text
(etaf-define-component NAME (&key PROPS)
DOCSTRING?
:setup OPAQUE-STATE-FORM
:view VIEW
:styles (styles RULE...))
(etaf-define-component NAME (&key PROPS)
DOCSTRING?
:setup SETUP
:setup OPAQUE-STATE-FORM
:render ORDINARY-ELISP
:styles (styles RULE...))
```
`:view``:setup` 互斥;`:styles` 可选且最多出现一次。Props 是唯一需要声明的业务输入;普通尾部子节点和命名 slot 会被规范化为 Component 的 slot 集合。
`:view``:render` 互斥且必须恰好出现一个;`:setup` 与 `:styles` 可选且各最多出现一次。Props 是唯一需要声明的业务输入;普通尾部子节点和命名 slot 会被规范化为 Component 的 slot 集合。
Component definition 是当前结构/样式/行为边界动态状态、Action callback 和生命周期工作放进 `:setup`,静态 presentation 放进 `:styles`。未来 `.etaf` SFC compiler 可以生成这些 definition但 Runtime 不会直接加载 `.etaf` 文件。
@ -137,31 +140,33 @@ Component definition 是当前结构/样式/行为边界动态状态、Action
:view
(text
:font-weight 'bold
(expr :value label)))
(expr label)))
```
```elisp
(etaf-define-component disclosure (&key title)
"Render a retained disclosure."
:setup
(let ((open (etaf-ref nil)))
(lambda ()
(etaf-view
(column
(text
:role 'button
:on-press
(lambda ()
(setf (etaf-value open)
(not (etaf-value open))))
(expr :value (if (etaf-value open) "Hide" "Show")))
(expr
:value
(when (etaf-value open)
(etaf-view (text (expr :value title))))))))))
(etaf-ref nil)
:view
(column
(text
:role 'button
:on-press
(let ((open (etaf-state)))
(lambda ()
(setf (etaf-value open)
(not (etaf-value open)))))
(expr (if (etaf-value (etaf-state)) "Hide" "Show")))
(expr
(when (etaf-value (etaf-state))
(etaf-view (text (expr title)))))))
```
`:setup` 对一个 retained Component instance 只运行一次,并且必须返回零参数 render 函数。重新渲染读取当前 props 和 ref不重新运行 setup。setup 负责局部 ref、computed、watch、Effect 和 cleanup 的创建。
`:setup` 对一个 retained Component instance 只运行一次,返回一个 opaque 状态值。
`:view``:render` 中用 `etaf-state` 取得这个准确值。重新渲染读取当前 props 和
state不重新运行 setup。setup 负责局部 ref、computed、watch、Effect 和 cleanup
不返回 render 函数。
`:key` 是稳定的 identity metadata不是业务 prop。放在 Component 调用上时,它选择同级作用域内要保留的 Component instance放在 Host 上时,它会作为 Ebox node key 向下传递。候选渲染失败时Runtime 恢复旧 instance、handlers、Behaviors 和 buffer。
@ -295,6 +300,16 @@ Behavior = 安装多个非视觉能力的可复用 bundle
`etaf-behavior-create` 接受保留的 `:install` 属性,用于可选的零参数 installer。Installer 可以返回 cleanup运行期间可通过 `etaf-current-behavior-context` 读取当前 Runtime、结构路径和 Host props。Behavior 被替换或 owner 卸载时installer 状态会被释放。
交互组合是确定的:显式 Host `:on-*` callback 先运行,然后按声明顺序运行 Behavior
callback任一错误会 short-circuit 后续 callback。非事件属性由显式 Host 值优先,
否则第一个声明该属性的 Behavior 获胜first-wins。同一 Host 的 Behavior name 必须在任何
installer 运行前保持唯一。稳定 installer identity 会复用,每个已安装 cleanup
exactly-once。事件只 dispatch 给准确 Host refETAF 没有 capture 或 bubble 阶段。
Action name 应使用 application/feature-prefixed symbol。重复注册默认报错。
`etaf-action-redefine-run` 是显式 authoring/reload 边界;替换只影响未来按 name 的
dispatch不会 flush 已挂载 Runtime。
Runtime 事件通过 `etaf-dispatch-event` 进入;命中测试和 focus 通过 Ebox Host 引用查询,并由 `etaf-activate`、`etaf-focus`、`etaf-focus-next`、`etaf-host-ref-bounds` 和 `etaf-host-ref-position` 提供公共入口。
## 9. Context、Theme、Data 与 Resource
@ -309,14 +324,13 @@ Context 是继承的 Component Scope 环境:
:setup
(let ((service (etaf-ref "demo-service")))
(etaf-provide 'service service)
(lambda () (etaf-view (slot)))))
service)
:view (slot))
(etaf-define-component service-consumer ()
"Read the inherited service."
:setup
(let ((service (etaf-inject 'service nil t)))
(lambda ()
(etaf-view (text (expr :value (etaf-value service)))))))
:setup (etaf-inject 'service nil t)
:view (text (expr (etaf-value (etaf-state)))))
(etaf-view (service-provider (service-consumer)))
```
@ -327,10 +341,9 @@ key 使用稳定的普通 symbol。最近的祖先优先缺失的 required ke
(etaf-define-component themed-shell ()
"Provide default text colors to a subtree."
:setup
(progn
(etaf-theme-provide
'(:color "#F4F6FB" :bgcolor "#202634"))
(lambda () (etaf-view (slot)))))
(etaf-theme-provide
'(:color "#F4F6FB" :bgcolor "#202634"))
:view (slot))
```
Palette 解析属于 Theme而不是 UI 目录。core 提供

View File

@ -18,7 +18,7 @@ The repository is complete for the agreed unified architecture when the mandator
| Milestone | Delivered responsibility | Evidence |
| --- | --- | --- |
| P0 grammar | Unified View shape, property-first parsing, `etaf-view`, `expr :value`, core Hosts, aliases | `tests/etaf-tests.el` structural and syntax tests |
| P0 grammar | Unified View shape, property-first parsing, `etaf-view`, `(expr FORM)`, core Hosts, aliases | `tests/etaf-tests.el` structural and syntax tests |
| P1 Components | `:view`, `:setup`, props, default/named slots, retained instances, lifecycle, `:key`, raw Ebox escape | Component, slot, mount, prop-update, raw-node, and rollback tests |
| P2 Runtime | refs, computed, effects, watches, Scope cleanup, Context, Theme, Behaviors, events, focus, Actions | Mounted event/focus tests, reactive failure rollback, cleanup tests |
| P3 presentation | scoped styles, selector matching, Theme precedence, inline text runs, Resource and error boundary | Style, Theme, text-surface, Resource, and error tests |
@ -55,16 +55,19 @@ Do not split a file merely to create a shorter name. Split only when a stable re
- The only structural form is `(NAME :PROPERTY VALUE ... CHILD ...)`.
- All properties precede children.
- Attribute values are ordinary Elisp expressions.
- The only child computation bridge is `(expr :value ELISP-EXPRESSION)`.
- The only child computation bridge is `(expr ELISP-EXPRESSION)`; its result
may be text, a typed View/proper typed View sequence, or `nil`.
- A dynamic View returned by Elisp is constructed with `(etaf-view ...)` inside `expr`; quoted structural data is never executed.
- The six visible Host forms are `text`, `box`, `row`, `column`, `flex`, and `grid`; `fragment`, `slot`, and `expr` are transparent framework structure.
- There is no raw Ebox View escape hatch. Framework integrations use Ebox's evaluated typed-constructor port outside the author DSL.
### 4.2 Component and slots
- `etaf-define-component` accepts only `:view`, `:setup`, and optional `:styles`.
- `:view` and `:setup` are mutually exclusive.
- `:setup` runs once per retained identity and returns a zero-argument render function.
- `etaf-define-component` chooses exactly one of `:view` or `:render`; `:setup`
and `:styles` are optional.
- `:view` and `:render` are mutually exclusive frontends.
- `:setup` runs once per retained identity and returns opaque state read with
`etaf-state` during either frontend.
- Props update render without rerunning setup.
- Trailing children are `slots.default`; named slot input uses `(slot :name 'NAME CHILD...)`.
- Default outlet shorthand is `(slot)` or `(slot FALLBACK...)`.

View File

@ -18,7 +18,7 @@
| 里程碑 | 已交付职责 | 证据 |
| --- | --- | --- |
| P0 grammar | 统一 View 形状、属性优先解析、`etaf-view`、`expr :value`、核心 Host、alias | `tests/etaf-tests.el` 结构和语法测试 |
| P0 grammar | 统一 View 形状、属性优先解析、`etaf-view`、`(expr FORM)`、核心 Host、alias | `tests/etaf-tests.el` 结构和语法测试 |
| P1 Component | `:view`、`:setup`、props、默认/命名 slot、retained instance、生命周期、`:key`、raw Ebox 出口 | Component、slot、mount、prop 更新、raw node、rollback 测试 |
| P2 Runtime | ref、computed、effect、watch、Scope cleanup、Context、Theme、Behavior、事件、focus、Action | 挂载事件/focus、响应式失败回滚和 cleanup 测试 |
| P3 presentation | 作用域样式、selector、Theme 优先级、inline text runs、Resource 和 error boundary | 样式、Theme、文本 surface、Resource、error 测试 |
@ -55,16 +55,19 @@
- 唯一结构形式是 `(NAME :PROPERTY VALUE ... CHILD ...)`
- 所有属性必须先于所有子节点。
- 属性值是普通 Elisp 表达式。
- 唯一的子节点计算桥接是 `(expr :value ELISP-EXPRESSION)`
- 唯一的子节点计算桥接是 `(expr ELISP-EXPRESSION)`结果可以是文本、typed View、
typed View proper sequence 或 `nil`
- Elisp 返回动态 View 时,在 `expr` 中用 `(etaf-view ...)` 构造;被 quote 的结构数据永远不会被执行。
- 六个可见 Host form 是 `text`、`box`、`row`、`column`、`flex`、`grid``fragment`、`slot`、`expr` 是透明的框架结构。
- View 作者 DSL 不提供 raw Ebox 逃生口;框架集成在作者 DSL 之外使用 Ebox evaluated typed-constructor 端口。
### 4.2 Component 与 slot
- `etaf-define-component` 只接受 `:view`、`:setup` 和可选 `:styles`
- `:view``:setup` 互斥。
- `:setup` 对每个 retained identity 只运行一次,返回零参数 render 函数。
- `etaf-define-component` 必须在 `:view``:render` 中恰好选择一个;`:setup` 与
`:styles` 可选。
- `:view``:render` 是互斥 frontend。
- `:setup` 对每个 retained identity 只运行一次,返回 opaque 状态,并由两个 frontend
中的 `etaf-state` 读取。
- Props 更新只重新 render不重新运行 setup。
- 尾部 children 是 `slots.default`;命名 slot 输入使用 `(slot :name 'NAME CHILD...)`
- 默认 outlet 简写是 `(slot)``(slot FALLBACK...)`

View File

@ -1,10 +1,10 @@
# ETAF Component 定义:目标设计
# ETAF Component 定义:历史目标设计
> 状态:已完成架构审查的目标设计,尚未实现。
> **SUPERSEDED / historical本文不再是当前公共合同。**
>
> 本文只定义目标公共契约,不代表当前版本已经支持这些写法。当前实现差距见
> “实现状态与迁移”一节。审查通过并完成代码、测试、GUI 与性能门禁后,稳定内容
> 才能进入用户指南
> 当前公共合同以 [docs/architecture.zh.md](../architecture.zh.md) 与
> [docs/user-guide.zh.md](../user-guide.zh.md) 为准。本文保留为设计决策历史,不再以
> 创建时的“当前状态”表覆盖已经交付的实现
本文只拥有 **Component 作者语法和作者可见语义**。属性 owner、跨包依赖、
transaction 权限、Ebox/TP/Rust 边界仍以
@ -1016,11 +1016,12 @@ grammar也不要求 IR 保存 frontend provenance。
| topology compiler | 是 | 否 |
| Runtime/identity/lifecycle | 同一套 | 同一套 |
## 21. 实现状态与迁移
## 21. 历史实现快照(非当前状态)
截至本文创建时,当前代码的真实状态是:
下表只记录本文创建时的历史差距,不能用于判断当前版本。当前实现与迁移状态见
[implementation-plan.zh.md](../implementation-plan.zh.md)。
| 能力 | 当前状态 | 目标动作 |
| 能力 | 历史状态 | 当时目标动作 |
| --- | --- | --- |
| `etaf-define-component :view` | 已实现 | 按新 DSL grammar 收敛。 |
| `:setup` | 已实现,但返回零参数 render function | 删除旧语义;改为只返回 state。 |

View File

@ -824,7 +824,10 @@ complete chain, for example:
`etaf-component`:
- defines Component props, `:view`, `:setup`, and `:styles`;
- defines Component props, mutually exclusive `:view`/`:render` frontends,
optional `:setup` returning opaque state, the sole `etaf-state` accessor,
and `:styles`;
- defines the `etaf-node` code frontend at the same typed View ABI boundary;
- does not schedule Runtime or call Ebox.
`etaf-runtime`:

View File

@ -80,7 +80,7 @@ Attribute values are ordinary Elisp expressions. They do not need an extra `expr
(text
:color (if dark "#F4F6FB" "#1F2328")
:background-color "#20242B"
(expr :value label))))
(expr label))))
```
The child region is structural. `expr` is the one explicit bridge for ordinary Elisp computation:
@ -88,14 +88,15 @@ The child region is structural. `expr` is the one explicit bridge for ordinary E
```elisp
(etaf-view
(column
(expr :value (if loading "Loading..." "Ready"))
(expr (if loading "Loading..." "Ready"))
(expr
:value
(when open
(etaf-view (text :font-style 'italic "Details"))))))
```
`expr` accepts exactly `:value` and no children. Its result can be a string, View, sequence, or `nil`. `if`, `when`, `cond`, `let`, `mapcar`, and `cl-loop` remain normal Elisp inside the value.
`expr` accepts exactly one ordinary Elisp form and no structural children. Its
result can be a string, typed View, proper typed View sequence, or `nil`.
`if`, `when`, `cond`, `let`, `mapcar`, and `cl-loop` remain normal Elisp.
Quote has one ordinary Elisp meaning:
@ -134,7 +135,7 @@ The beginner form is a stateless `:view` Component:
:view
(text
:font-weight 'bold
(expr :value label)))
(expr label)))
(etaf-mount
"*etaf-status*"
@ -154,8 +155,9 @@ The definition macro accepts only these keywords:
| Keyword | Meaning |
| --- | --- |
| `:view` | The stateless View producer; mutually exclusive with `:setup` |
| `:setup` | One-time Component initialization returning a zero-argument render function |
| `:view` | Declarative View frontend; mutually exclusive with `:render` |
| `:render` | Ordinary-Elisp frontend returning one typed View through `etaf-node` |
| `:setup` | Optional one-time initialization returning opaque state read with `etaf-state` |
| `:styles` | Optional static scoped style declaration |
There is no separate declaration block for children, slots, events, state, or variants. Props are declared with `(&key ...)`; children and slots are implicit content.
@ -173,18 +175,24 @@ Use `:setup` when the Component owns local state:
(lambda () (message "%s mounted" title)))
(etaf-on-unmounted
(lambda () (message "%s unmounted" title)))
(lambda ()
(etaf-view
(column
(text :font-weight 'bold (expr :value title))
(text (expr :value (format "Count: %d" (etaf-value count))))
(text
:role 'button
:on-press (lambda () (cl-incf (etaf-value count)))
"Increment"))))))
count)
:view
(column
(text :font-weight 'bold (expr title))
(text (expr (format "Count: %d" (etaf-value (etaf-state)))))
(text
:role 'button
:on-press
(let ((count (etaf-state)))
(lambda () (cl-incf (etaf-value count))))
"Increment")))
```
Setup runs once for the retained instance. Its returned render function runs on each update. `etaf-on-mounted`, `etaf-on-updated`, and `etaf-on-unmounted` register lifecycle callbacks for that Component instance. Scope disposal automatically stops reactive effects and cleanup.
Setup runs once for the retained instance and returns one opaque state value.
The selected `:view` or `:render` frontend runs on each update and reads that
exact value with `etaf-state`. `etaf-on-mounted`, `etaf-on-updated`, and
`etaf-on-unmounted` register lifecycle callbacks for that Component instance.
Scope disposal automatically stops reactive effects and cleanup.
The small reactive API is:
@ -219,7 +227,7 @@ Trailing children are the anonymous/default slot:
"Render a titled panel."
:view
(column
(text :font-weight 'bold (expr :value title))
(text :font-weight 'bold (expr title))
(slot (text :color "#687386" "No content"))))
(etaf-view
@ -236,7 +244,7 @@ Named slots use `:name` and must use a stable non-keyword symbol:
:view
(column
(slot :name 'header
(text :font-weight 'bold (expr :value title)))
(text :font-weight 'bold (expr title)))
(slot (text :color "#687386" "No body"))))
(etaf-view
@ -248,6 +256,22 @@ Named slots use `:name` and must use a stable non-keyword symbol:
The two default-slot shorthands are `(slot)` and `(slot FALLBACK...)`. The normalized spelling is `(slot :name 'default FALLBACK...)`. At a call site, ordinary children fill `default`; a named input uses `(slot :name 'header CHILD...)`. An explicit empty `(slot :name 'header)` suppresses the fallback. Strings, numbers, variables, and runtime expressions are not valid slot names.
`expr` may return a typed View or a proper sequence of typed Views at a
structural boundary. It never exposes or accepts ETAF's private structs. The
same Component can combine a keyed `:for`, a structural expression, and a
named footer slot:
```elisp
(etaf-define-component etaf-docs-collection-card (&key items footer-view)
"Render keyed rows, one dynamic typed View, and a footer slot."
:view
(column
(row :for (item items) :key (car item)
(text (expr (cdr item))))
(expr footer-view)
(slot :name 'footer (text "No footer"))))
```
## 7. Styles and themes
Static Component styles use one declaration form:
@ -291,10 +315,9 @@ Theme is a Context convenience, not another runtime object:
(etaf-define-component themed-shell ()
"Provide default text colors to a subtree."
:setup
(progn
(etaf-theme-provide
'(:color "#F4F6FB" :bgcolor "#202634"))
(lambda () (etaf-view (slot)))))
(etaf-theme-provide
'(:color "#F4F6FB" :bgcolor "#202634"))
:view (slot))
```
For a light/dark application palette, keep the semantic roles in one palette
@ -372,6 +395,18 @@ For a reusable installer, reserve `:install` for the cleanup-producing part of t
The installer can call `etaf-current-behavior-context` when it needs the current Runtime or Host path. Replacing the Behavior runs the old cleanup before the new state becomes current. Behavior equality keeps function and reactive-value identity with `eq`; a newly-created installer closure is therefore a deliberate replacement, not an accidental reuse. The replacement is staged under the mounted resource registry and becomes authoritative only when its generation commits.
Composition rules are fixed: the Host callback runs before Behavior callbacks,
Behaviors follow declaration order, and a callback error short-circuits the
rest. For non-event attributes the Host wins, then the first Behavior wins.
Duplicate Behavior names fail before installation. Stable installers are reused
and each installed cleanup runs exactly once. Dispatch targets one exact Host;
there is no capture or bubble phase.
Use application- or feature-prefixed Action names. Duplicate Action
registration is an error. During deliberate reload, wrap the replacement in
`etaf-action-redefine-run`; it changes future name-based dispatch without
flushing the mounted Runtime.
Focus and hit testing are Runtime operations:
```elisp
@ -394,15 +429,14 @@ Use Context for a dependency shared across component depth, not for ordinary pro
:setup
(let ((service (etaf-ref "demo-service")))
(etaf-provide 'service service)
(lambda () (etaf-view (slot)))))
service)
:view (slot))
(etaf-define-component service-label ()
"Read the inherited service."
:setup
(let ((service (etaf-inject 'service nil t)))
(lambda ()
(etaf-view
(text (expr :value (format "Service: %s" (etaf-value service)))))))
:setup (etaf-inject 'service nil t)
:view
(text (expr (format "Service: %s" (etaf-value (etaf-state))))))
(etaf-mount
"*etaf-context*"
@ -469,8 +503,9 @@ updates only when that identity enters or leaves the main selection, including
when application code writes `etaf-data-selection` directly. The default
DataGrid path uses these refs with keyed retained row owners, so a single-select
change invalidates the old and new rows rather than the complete visible page.
Custom `:row-selected-p` and `:selected-key` contracts remain available when
selection is owned outside the Controller.
Custom `:row-selected-p` remains available when selection is owned outside the
Controller. Stable identity still comes from the required `:row-key`; there is
no second selection-key DataGrid prop.
`etaf-data-controller` accepts `:item-key` for stable selected-row lookup. When
created inside a Component setup, its internal effect Scope is owned by the
@ -662,7 +697,7 @@ Most applications need only `etaf-view`, `etaf-mount`, `etaf-define-component`,
- Put every property before the first child.
- Use `:font-weight 'bold`, not `:font-weight :bold`; the weight is an Elisp symbol value, not a property keyword.
- Do not quote a structural View form.
- Use `expr :value` for `if`, `when`, `let`, `mapcar`, or a View returned by ordinary Elisp.
- Use `(expr FORM)` for `if`, `when`, `let`, `mapcar`, or a typed View returned by ordinary Elisp.
- Use `(slot)` or `(slot FALLBACK...)` for the default outlet; use `:name 'header` for named slot content.
- Keep writes out of rendering; use an event, Action, watch callback, or Effect.
- Use `etaf-ui` Components for product controls; core Hosts are the structural foundation.

View File

@ -80,7 +80,7 @@ View 含有状态型 Component、响应式数据、事件或生命周期时
(text
:color (if dark "#F4F6FB" "#1F2328")
:background-color "#20242B"
(expr :value label))))
(expr label))))
```
子节点区是结构语法。`expr` 是执行普通 Elisp 的唯一明确桥接:
@ -88,14 +88,15 @@ View 含有状态型 Component、响应式数据、事件或生命周期时
```elisp
(etaf-view
(column
(expr :value (if loading "Loading..." "Ready"))
(expr (if loading "Loading..." "Ready"))
(expr
:value
(when open
(etaf-view (text :font-style 'italic "Details"))))))
```
`expr` 只接受 `:value`不能有子节点。返回值可以是字符串、View、序列或 `nil`。`if`、`when`、`cond`、`let`、`mapcar` 和 `cl-loop` 仍然是 value 中的普通 Elisp。
`expr` 只接受一个普通 Elisp form不能有结构子节点。返回值可以是字符串、typed
View、typed View proper sequence 或 `nil`。`if`、`when`、`cond`、`let`、`mapcar` 和
`cl-loop` 仍是普通 Elisp。
quote 只有普通 Elisp 的含义:
@ -134,7 +135,7 @@ quote 只有普通 Elisp 的含义:
:view
(text
:font-weight 'bold
(expr :value label)))
(expr label)))
(etaf-mount
"*etaf-status*"
@ -154,8 +155,9 @@ Component 的规范名称可以带 `etaf-` 前缀:
| 关键字 | 作用 |
| --- | --- |
| `:view` | 无状态 Component 的 View 生产者,与 `:setup` 互斥 |
| `:setup` | 一次性初始化,返回零参数 render 函数 |
| `:view` | 声明式 View frontend`:render` 互斥 |
| `:render` | 普通 Elisp frontend通过 `etaf-node` 返回一个 typed View |
| `:setup` | 可选的一次性初始化,返回由 `etaf-state` 读取的 opaque 状态 |
| `:styles` | 可选的静态作用域样式声明 |
没有单独的 children、slot、event、state 或 variant 声明块。业务 props 通过 `(&key ...)` 声明children 和 slot 是隐式内容。
@ -173,18 +175,23 @@ Component 自己拥有状态时使用 `:setup`
(lambda () (message "%s mounted" title)))
(etaf-on-unmounted
(lambda () (message "%s unmounted" title)))
(lambda ()
(etaf-view
(column
(text :font-weight 'bold (expr :value title))
(text (expr :value (format "Count: %d" (etaf-value count))))
(text
:role 'button
:on-press (lambda () (cl-incf (etaf-value count)))
"Increment"))))))
count)
:view
(column
(text :font-weight 'bold (expr title))
(text (expr (format "Count: %d" (etaf-value (etaf-state)))))
(text
:role 'button
:on-press
(let ((count (etaf-state)))
(lambda () (cl-incf (etaf-value count))))
"Increment")))
```
Setup 对 retained instance 只执行一次,返回的 render 函数在更新时重复运行。`etaf-on-mounted`、`etaf-on-updated` 和 `etaf-on-unmounted` 注册该 Component 的生命周期 callback。Scope 释放时会自动停止响应式 effect 并运行 cleanup。
Setup 对 retained instance 只执行一次,返回一个 opaque 状态值。选定的 `:view`
`:render` frontend 在更新时运行,并通过 `etaf-state` 读取这个准确值。
`etaf-on-mounted`、`etaf-on-updated` 和 `etaf-on-unmounted` 注册该 Component 的生命周期
callback。Scope 释放时会自动停止响应式 effect 并运行 cleanup。
响应式 API 只有一套模型:
@ -219,7 +226,7 @@ Setup 对 retained instance 只执行一次,返回的 render 函数在更新
"Render a titled panel."
:view
(column
(text :font-weight 'bold (expr :value title))
(text :font-weight 'bold (expr title))
(slot (text :color "#687386" "No content"))))
(etaf-view
@ -236,7 +243,7 @@ Setup 对 retained instance 只执行一次,返回的 render 函数在更新
:view
(column
(slot :name 'header
(text :font-weight 'bold (expr :value title)))
(text :font-weight 'bold (expr title)))
(slot (text :color "#687386" "No body"))))
(etaf-view
@ -248,6 +255,21 @@ Setup 对 retained instance 只执行一次,返回的 render 函数在更新
默认 slot 的两个用户简写是 `(slot)``(slot FALLBACK...)`。内部统一形式是 `(slot :name 'default FALLBACK...)`。调用处的普通子节点填充 `default`;命名内容写成 `(slot :name 'header CHILD...)`。显式空的 `(slot :name 'header)` 会抑制 fallback。字符串、数字、变量和运行时表达式都不是合法 slot name。
在结构边界,`expr` 可以返回 typed View 或 typed View 的 proper sequence但它不暴露、
也不接受 ETAF 私有 struct。同一个 Component 可以组合 keyed `:for`、结构表达式和
命名 footer slot
```elisp
(etaf-define-component etaf-docs-collection-card (&key items footer-view)
"Render keyed rows, one dynamic typed View, and a footer slot."
:view
(column
(row :for (item items) :key (car item)
(text (expr (cdr item))))
(expr footer-view)
(slot :name 'footer (text "No footer"))))
```
## 7. 样式与 Theme
静态 Component 样式只有一种声明形式:
@ -290,10 +312,9 @@ Theme 是 Context 的便捷形式,不是另一个 Runtime 对象:
(etaf-define-component themed-shell ()
"Provide default text colors to a subtree."
:setup
(progn
(etaf-theme-provide
'(:color "#F4F6FB" :bgcolor "#202634"))
(lambda () (etaf-view (slot)))))
(etaf-theme-provide
'(:color "#F4F6FB" :bgcolor "#202634"))
:view (slot))
```
如果应用有亮/暗两套 palette应把语义 role 集中放在一份 palette plist 中,
@ -369,6 +390,15 @@ Behavior 用来打包可复用的非视觉属性和 cleanup
Installer 需要 Runtime 或 Host path 时,可以调用 `etaf-current-behavior-context`。Behavior 被替换时,旧 cleanup 会在新状态成为当前状态前运行。Behavior equality 对 function 和 reactive value 使用 `eq`;因此新建的 installer closure 会被视为有意替换,而不是错误复用。替换状态先以 mounted resource registry 的 staged resource 保存generation commit 后才成为 authority。
组合规则固定Host callback 先于 Behavior callbackBehavior 按声明顺序运行callback
error 会 short-circuit 剩余 callback。非事件属性先由 Host 获胜,否则由第一个
Behavior 获胜first-wins。重复 Behavior name 在 installer 前失败;稳定 installer 会复用,每个
已安装 cleanup exactly-once。dispatch 只命中准确 Host不存在 capture 或 bubble。
Action name 使用 application/feature-prefixed symbol重复 Action 注册默认报错。显式
reload 用 `etaf-action-redefine-run` 包住替换,它只改变未来按 name 的 dispatch不会
flush 已挂载 Runtime。
Focus 和 hit testing 是 Runtime 操作:
```elisp
@ -391,15 +421,14 @@ Context 适合跨多层共享依赖,不适合普通 label
:setup
(let ((service (etaf-ref "demo-service")))
(etaf-provide 'service service)
(lambda () (etaf-view (slot)))))
service)
:view (slot))
(etaf-define-component service-label ()
"Read the inherited service."
:setup
(let ((service (etaf-inject 'service nil t)))
(lambda ()
(etaf-view
(text (expr :value (format "Service: %s" (etaf-value service)))))))
:setup (etaf-inject 'service nil t)
:view
(text (expr (format "Service: %s" (etaf-value (etaf-state))))))
(etaf-mount
"*etaf-context*"
@ -465,8 +494,8 @@ Controller 通过 `etaf-data-items`、`etaf-data-status`、`etaf-data-error`、`
identity 进入或离开主 selection 时它才会变化,应用直接写
`etaf-data-selection` 时也一样。DataGrid 默认把这些 ref 与 keyed retained row
owner 配合使用,因此单选变化只会使旧行和新行失效,而不是整个可见页。
selection 由 Controller 外部拥有时,仍可使用自定义 `:row-selected-p`
`:selected-key` 契约
selection 由 Controller 外部拥有时,仍可使用自定义 `:row-selected-p`。稳定 identity
继续来自必选的 `:row-key`DataGrid 没有第二个 selection-key prop
`etaf-data-controller` 支持用于稳定选中行查找的 `:item-key`。在 Component
setup 中创建时,它的内部 effect Scope 会自动归当前 Component Scope 所有;如果
@ -650,7 +679,7 @@ observer不修改任何函数。
- 所有属性必须放在第一个子节点之前。
- 使用 `:font-weight 'bold`,不要使用 `:font-weight :bold`weight 是 Elisp symbol 值,不是属性 keyword。
- 结构性 View form 不要 quote。
- `if`、`when`、`let`、`mapcar` 或 Elisp 返回 View 时,使用 `expr :value`。
- `if`、`when`、`let`、`mapcar` 或 Elisp 返回 typed View 时,使用 `(expr FORM)`。
- 默认 outlet 使用 `(slot)``(slot FALLBACK...)`;命名内容使用 `:name 'header`
- 不要在 render 中写状态使用事件、Action、watch callback 或 Effect。
- 产品级控件使用 `etaf-ui` Componentcore Host 只是结构基础。

View File

@ -0,0 +1,111 @@
;;; etaf-m0b-component-manifest.el --- Observed Component contract manifest -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; Emit a machine-readable M0b manifest that keeps Component business props,
;; forwarded Host attributes, and the single-root forwarding guarantee in
;; separate fields. The manifest is emitted only after a real mounted probe.
;;; Code:
(require 'cl-lib)
(require 'json)
(require 'etaf)
(defconst etaf-m0b-component-manifest--dsl-name
'etaf-m0b-manifest-dsl-card)
(defconst etaf-m0b-component-manifest--render-name
'etaf-m0b-manifest-render-card)
(defun etaf-m0b-component-manifest--define-fixtures ()
"Define the two manifest probe Components through the public authoring API."
(etaf-component-redefine-run
(lambda ()
(eval
'(etaf-define-component etaf-m0b-manifest-dsl-card (&key title)
:view
(box :class "definition" (text (expr title)))))
(eval
'(etaf-define-component etaf-m0b-manifest-render-card (&key label)
:render
(etaf-node 'box (list :class "definition")
(list (etaf-node 'text nil (list label)))))))))
(defun etaf-m0b-component-manifest--spec-props (name)
"Return declared business props for Component NAME."
(let ((spec (gethash name etaf--view-registry)))
(unless (etaf--component-spec-p spec)
(error "Manifest fixture is not a Component: %S" name))
(mapcar #'symbol-name (etaf--component-spec-props spec))))
(defun etaf-m0b-component-manifest--mounted-probe (name prop value ref text)
"Mount NAME with PROP VALUE and verify forwarded attrs at REF and TEXT."
(let ((buffer (generate-new-buffer-name " *etaf-m0b-manifest*")))
(unwind-protect
(progn
(etaf-mount
buffer
(etaf-node name
(list prop value :class "caller"
:aria-label "manifest-probe" :ref ref)
nil))
(let* ((runtime (etaf-runtime-for-buffer buffer))
(props (etaf-runtime-host-props-for runtime ref)))
(unless (and (string-match-p (regexp-quote text)
(with-current-buffer buffer
(buffer-string)))
(member "caller" (plist-get props :class))
(equal "manifest-probe"
(plist-get props :aria-label)))
(error "Mounted Component forwarding probe failed: %S" name)))
t)
(when-let* ((runtime (etaf-runtime-for-buffer buffer)))
(etaf-unmount runtime))
(when-let* ((live (get-buffer buffer)))
(kill-buffer live)))))
(defun etaf-m0b-component-manifest-data ()
"Return the observed M0b Component manifest as an alist."
(etaf-m0b-component-manifest--define-fixtures)
(let ((dsl-ok
(etaf-m0b-component-manifest--mounted-probe
etaf-m0b-component-manifest--dsl-name :title "DSL" 'manifest-dsl
"DSL"))
(render-ok
(etaf-m0b-component-manifest--mounted-probe
etaf-m0b-component-manifest--render-name :label "Render"
'manifest-render "Render"))
(attrs '("class" "style/presentation" "layout" "ref"
"role/tab-index" "aria-*" "on-*" "use")))
`((schema-version . 1)
(evidence-mode . "observed")
(components
. (((name . ,(symbol-name etaf-m0b-component-manifest--dsl-name))
(frontend . "view")
(declared-business-props
. ,(etaf-m0b-component-manifest--spec-props
etaf-m0b-component-manifest--dsl-name))
(forwarded-host-attrs . ,attrs)
(root-shape-forwarding-guarantee
. "single attr-capable root only; fragment, text, and multi-root results reject forwarded attrs")
(mounted-validation . ,dsl-ok))
((name . ,(symbol-name etaf-m0b-component-manifest--render-name))
(frontend . "render")
(declared-business-props
. ,(etaf-m0b-component-manifest--spec-props
etaf-m0b-component-manifest--render-name))
(forwarded-host-attrs . ,attrs)
(root-shape-forwarding-guarantee
. "single attr-capable root only; fragment, text, and multi-root results reject forwarded attrs")
(mounted-validation . ,render-ok)))))))
(defun etaf-m0b-component-manifest-write-json ()
"Write the observed M0b Component manifest as JSON."
(princ (json-encode (etaf-m0b-component-manifest-data))))
(provide 'etaf-m0b-component-manifest)
;;; etaf-m0b-component-manifest.el ends here

View File

@ -6,6 +6,12 @@
(require 'ert)
(require 'cl-lib)
(require 'json)
(require 'macroexp)
(require 'etaf)
(declare-function etaf-m0b-component-manifest-write-json
"etaf-m0b-component-manifest" ())
(defconst etaf-docs-test--root
(file-name-directory
@ -30,7 +36,7 @@
(unless (re-search-forward "^```[[:space:]]*$" nil t)
(error "Unclosed Elisp documentation block"))
(push (buffer-substring-no-properties start
(line-beginning-position))
(match-beginning 0))
blocks))))
(nreverse blocks)))
@ -47,6 +53,42 @@
(end-of-file count)
(error (error "Cannot read documentation form: %S" err))))))
(defun etaf-docs-test--forms (source)
"Read and return every form in documentation SOURCE."
(with-temp-buffer
(insert source)
(goto-char (point-min))
(let (forms)
(condition-case nil
(while t (push (read (current-buffer)) forms))
(end-of-file (nreverse forms))))))
(defun etaf-docs-test--mounted-collection-probe (forms)
"Load exact documentation FORMS and mount the collection composition."
(etaf-component-redefine-run
(lambda ()
(dolist (form forms)
(eval form t))))
(let ((buffer (generate-new-buffer " *etaf-docs-m0b*")))
(unwind-protect
(progn
(let ((view
(eval
'(etaf-view
(etaf-docs-collection-card
:items '((1 . "One") (2 . "Two"))
:footer-view (etaf-view (text "Dynamic"))
(slot :name 'footer (text "Footer"))))
t)))
(etaf-mount buffer view))
(let ((text (with-current-buffer buffer (buffer-string))))
(dolist (expected '("One" "Two" "Dynamic" "Footer"))
(should (string-match-p expected text)))))
(when-let* ((runtime (etaf-runtime-for-buffer buffer)))
(etaf-unmount runtime))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest etaf-docs-have-paired-long-lived-files ()
"Keep the public architecture, guide, and plan in both languages."
(dolist (file '("README.md"
@ -95,6 +137,119 @@
(etaf-docs-test--read file)))
(should (> (etaf-docs-test--read-all block) 0)))))
(ert-deftest etaf-docs-component-contract-is-current ()
"Keep public Component prose aligned with the authoritative macro contract."
(dolist (file '("docs/architecture.en.md" "docs/architecture.zh.md"
"docs/user-guide.en.md" "docs/user-guide.zh.md"
"docs/implementation-plan.en.md"
"docs/implementation-plan.zh.md"))
(let ((contents (etaf-docs-test--read file)))
(should (string-match-p (regexp-quote ":view") contents))
(should (string-match-p (regexp-quote ":render") contents))
(should (string-match-p (regexp-quote ":setup") contents))
(should (string-match-p (regexp-opt '("opaque state" "opaque 状态"))
contents))
(should-not
(string-match-p
(regexp-opt '("setup returns a zero-argument render function"
":setup` runs once per retained identity and returns a zero-argument render function"
":setup` runs once for a retained Component instance and must return a zero-argument render function"
"返回零参数 render 函数"
"必须返回零参数 render 函数"))
contents)))))
(ert-deftest etaf-docs-data-grid-selection-contract-is-current ()
"Document only selection inputs actually declared by `etaf-data-grid'."
(dolist (file '("docs/user-guide.en.md" "docs/user-guide.zh.md"))
(let ((contents (etaf-docs-test--read file)))
(should (string-match-p (regexp-quote ":row-selected-p") contents))
(should-not (string-match-p (regexp-quote ":selected-key") contents)))))
(ert-deftest etaf-docs-superseded-proposals-not-current-contract ()
"Keep the old Component proposal explicitly historical."
(let ((contents
(etaf-docs-test--read "docs/proposals/component-definition.zh.md")))
(should (string-match-p "SUPERSEDED" contents))
(should (string-match-p "historical" contents))
(should (string-match-p (regexp-quote "docs/architecture.zh.md") contents))
(should-not (string-match-p "尚未实现" contents))))
(ert-deftest etaf-docs-module-boundaries-target-vocabulary-is-paired ()
"Keep the English and Chinese target Component vocabulary equivalent."
(dolist (file '("docs/proposals/module-boundaries.en.md"
"docs/proposals/module-boundaries.zh.md"))
(let ((contents (etaf-docs-test--read file)))
(dolist (token '(":view" ":render" ":setup" "etaf-state" "etaf-node"
"opaque"))
(should (string-match-p (regexp-quote token) contents))))))
(ert-deftest etaf-docs-interaction-contract-is-current ()
"Keep the public Action/Behavior/event composition contract explicit."
(dolist (file '("docs/architecture.en.md" "docs/architecture.zh.md"
"docs/user-guide.en.md" "docs/user-guide.zh.md"))
(let ((contents (etaf-docs-test--read file)))
(dolist (token '("Host" "Behavior" "short-circuit" "first"
"capture" "bubble" "application" "feature"
"etaf-action-redefine-run" "dispatch"))
(should (string-match-p (regexp-quote token) contents))))))
(ert-deftest etaf-docs-component-manifest-is-machine-readable ()
"Validate the three-column Component manifest and observed evidence."
(let* ((script (expand-file-name "scripts/etaf-m0b-component-manifest.el"
etaf-docs-test--root))
(json-object-type 'alist)
(json-array-type 'list)
(manifest
(with-temp-buffer
(let ((standard-output (current-buffer)))
(load script nil t)
(etaf-m0b-component-manifest-write-json))
(goto-char (point-min))
(json-read))))
(should (equal 1 (alist-get 'schema-version manifest)))
(should (equal "observed" (alist-get 'evidence-mode manifest)))
(dolist (entry (alist-get 'components manifest))
(should (alist-get 'declared-business-props entry))
(should (alist-get 'forwarded-host-attrs entry))
(should (alist-get 'root-shape-forwarding-guarantee entry))
(should (eq t (alist-get 'mounted-validation entry))))))
(ert-deftest etaf-docs-executable-suite-is-fail-closed ()
"Classify exact blocks before macroexpansion, loading, or mounted smoke."
(let ((fixture (expand-file-name
"tests/fixtures/etaf-m0b-doc-examples.sexp"
etaf-docs-test--root)))
(should (file-readable-p fixture))
(let ((records (with-temp-buffer
(insert-file-contents fixture)
(goto-char (point-min))
(read (current-buffer)))))
(should records)
(dolist (record records)
(let* ((file (plist-get record :file))
(index (plist-get record :block))
(classification (plist-get record :classification))
(blocks (etaf-docs-test--elisp-blocks
(etaf-docs-test--read file)))
(source (nth (1- index) blocks)))
(should source)
(should (equal (secure-hash 'sha256 source)
(plist-get record :sha256)))
(should (memq classification
'(macroexpand-only load-safe mounted-smoke)))
(pcase classification
('macroexpand-only
(should (> (etaf-docs-test--read-all source) 0)))
((or 'load-safe 'mounted-smoke)
(let ((forms (etaf-docs-test--forms source)))
(dolist (form forms)
(should (macroexpand-all (copy-tree form))))
(pcase (plist-get record :probe)
('collection-composition
(etaf-docs-test--mounted-collection-probe forms))
('nil (dolist (form forms) (eval form t)))
(_ (ert-fail "Unknown safe documentation probe")))))))))))
(ert-deftest etaf-source-uses-only-public-ebox-names ()
"Keep the ETAF implementation independent of Ebox private functions."
(dolist (file

View File

@ -0,0 +1,26 @@
;;; etaf-m0b-component-manifest-tests.el --- M0b manifest probes -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Code:
(require 'ert)
(require 'etaf-m0b-component-manifest)
(ert-deftest etaf-m0b-component-manifest-is-derived-and-mounted ()
"Derive business props and prove Host-attribute forwarding by mounting."
(let* ((manifest (etaf-m0b-component-manifest-data))
(components (alist-get 'components manifest)))
(should (= 2 (length components)))
(should (equal '("title")
(alist-get 'declared-business-props (car components))))
(should (equal '("label")
(alist-get 'declared-business-props (cadr components))))
(dolist (entry components)
(should (alist-get 'forwarded-host-attrs entry))
(should (string-match-p "single attr-capable root"
(alist-get 'root-shape-forwarding-guarantee
entry)))
(should (eq t (alist-get 'mounted-validation entry))))))
;;; etaf-m0b-component-manifest-tests.el ends here

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,5 @@
((:file "docs/user-guide.en.md"
:block 18
:sha256 "79390863a3fdf604969cc4f424b36a9a466e569bd0ac740e61c580b2518cb2da"
:classification mounted-smoke
:probe collection-composition))