@loom-forge/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,338 @@
1
+ # @loom-forge/core
2
+
3
+ **loom** · o **kernel headless**. Zero React, zero ambiente, **zero dependências**. Transforma
4
+ `definição (+ dados)` numa `ResolvedNode` (dados puros) via `bind → resolve → plan`. É o contrato
5
+ que todos os outros packages falam.
6
+
7
+ ```bash
8
+ pnpm add @loom-forge/core
9
+ ```
10
+
11
+ ```ts
12
+ import {
13
+ createDescriptorRegistry,
14
+ createTypeResolver,
15
+ resolveTree,
16
+ fragmentPlanner,
17
+ sectionPlanner,
18
+ gridFormPlanner,
19
+ defaultPlanners,
20
+ walk,
21
+ findById,
22
+ pathToId,
23
+ mapNodes,
24
+ identityBinder,
25
+ noopValidator,
26
+ } from "@loom-forge/core";
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Modelo
32
+
33
+ ### `DefNode` — a unidade da definição
34
+
35
+ ```ts
36
+ interface DefNode {
37
+ id: string; // identidade ESTÁVEL (keys, endereço, edição). Nunca posicional.
38
+ type?: string; // discriminador → registry (ausente quando `el` dá a tag)
39
+ el?: string; // tag intrínseca — porta de emergência, não caminho (ADR-012)
40
+ props?: Record<string, unknown>;
41
+ layout?: string; // qual layout arruma os FILHOS deste nó (default: o do engine)
42
+ children?: DefNode[];
43
+ placement?: unknown; // posição DESTE nó no layout do pai — opaca pro core
44
+ events?: Record<string, EventBinding>; // events-out: nomeDaProp → intenção
45
+ fallback?: "drop" | DefNode; // forward-compat: type desconhecido → isto ("drop" = nada)
46
+ requires?: Record<string, string>; // gate de capability: `cap → versão` ou `cap → "*"` (presença)
47
+ version?: string;
48
+
49
+ // as DIRETIVAS — consumidas no caminho, nunca chegam à view
50
+ each?: unknown; // repete o nó por item (ou re-escopa, se objeto)
51
+ as?: string; // o nome que o `each` liga para o item
52
+ when?: unknown; // dropa o nó se falsy
53
+ provides?: Record<string, unknown>; // contextos para a SUBÁRVORE
54
+ commands?: Record<string, CommandAlias>; // vocabulário de comando da SUBÁRVORE
55
+
56
+ // de onde o nó veio, de fora para dentro — escrito pela expansão, nunca de autoria (ADR-017)
57
+ origins?: readonly NodeOrigin[];
58
+ }
59
+
60
+ interface NodeOrigin {
61
+ component: string; // o componente cujo documento escreveu o nó
62
+ id: string; // o id que o autor deu — o resolvido é outro
63
+ }
64
+
65
+ interface EventBinding {
66
+ action: string;
67
+ // os params da ação — e é POR AQUI que entra tudo o que a intenção recebe: a raiz `event` é
68
+ // o valor recebido (o que o widget emitiu; o `data` da ação anterior num `then`; o `error`
69
+ // num `catch`), e o autor escreve o mapeamento. Avaliados no DESPACHO, não no render.
70
+ params?: unknown;
71
+ // o que roda quando esta intenção RESOLVE: `success: true` no host (`event` = `data`), ou o
72
+ // estado escrito num update local (`event` = `undefined`)
73
+ then?: EventBinding;
74
+ // o que roda quando o despacho ao host FALHA (`event` = `error`: `{ code, message?, data? }`)
75
+ catch?: EventBinding;
76
+ // os params que o autor deixa para a camada de COMANDO preencher (token, default, drawer)
77
+ resolve?: readonly string[];
78
+ // update do componente que escreveu a intenção — marca da RESOLUÇÃO, nunca de autoria (ADR-016)
79
+ local?: true;
80
+ // o escopo do render em que o nó foi ligado — escrito pelo `bind`, nunca de autoria
81
+ scope?: Readonly<Record<string, unknown>>;
82
+ }
83
+
84
+ // o que um nome de comando executa — a forma longa é a aplicação PARCIAL
85
+ type CommandAlias = string | { action: string; params?: unknown };
86
+ ```
87
+
88
+ **Tudo o que uma intenção recebe entra pelo `params`, e o autor vê o que entra** (ADR-018 do
89
+ repo). O valor que o widget emite é a raiz `event` dos `params` — `{ campo: "cpf", valor:
90
+ "{{ event }}" }`, um sub-campo (`{{ event.valido }}`), uma transformação —, e o componente
91
+ registrado emite **valor**, nunca o evento do DOM, e declara o que emite em
92
+ `descriptor.meta.emits` (é o tipo de `event` no `check`; sem declaração, `unknown`).
93
+
94
+ O que a ação **devolve** é a próxima intenção: `then` roda no sucesso com `event` = `data`
95
+ (tipado pelo `resultOf` da porta), `catch` roda na falha com `event` = `error`. Um update local
96
+ também resolve (o estado foi escrito), então a cadeia `iniciar (local) → host → then/catch
97
+ (local)` é um mecanismo só, lida como uma promise chain. Sem `catch`, a falha vai só ao
98
+ `onActionResult` do host — e o host recebe TODO resultado, com ou sem continuação.
99
+
100
+ ```ts
101
+ runIntent(binding, event, runtime); // executa a intenção e a cadeia dela; nunca devolve nada
102
+ intentParams(binder, binding, event); // os params avaliados no despacho: escopo do render + event
103
+ intentsOf(binding); // a intenção e cada elo da cadeia, para quem roteia ou desenha grafo
104
+ dispatchIntent(port, id, params, onResult); // o despacho de UMA ação ao host, normalizado
105
+ ```
106
+
107
+ O `IntentRuntime` é o que quem executa (a view, o boundary) entrega ao `runIntent`: `params`
108
+ (o binder avaliando sobre `scope` + `event`), `apply` (o update local do componente vivo), a
109
+ `port`, o `onResult` e o `pending` — `true` do despacho ao resultado, que o boundary expõe como
110
+ raiz `pending[ação]` do escopo de um componente vivo.
111
+
112
+ ### `Descriptor` — metadado headless de um `type` (NUNCA contém o componente)
113
+
114
+ ```ts
115
+ interface Descriptor {
116
+ type: string;
117
+ defaultProps?: Record<string, unknown>;
118
+ schema?: unknown; // JSON Schema / Schema do TSLite — validação & form-builder
119
+ layout?: string; // layout default pros filhos deste type
120
+ meta?: Record<string, unknown>; // pra editores (label/categoria/ícone)
121
+ }
122
+ ```
123
+
124
+ ### `ResolvedNode` — a saída (dados puros, agnóstica de ambiente)
125
+
126
+ ```ts
127
+ type ResolvedNode =
128
+ | {
129
+ kind: "node";
130
+ id;
131
+ type;
132
+ props;
133
+ events?;
134
+ layout?: ResolvedLayout;
135
+ children: ResolvedNode[];
136
+ origins?; // a origem atravessa até a view — é o que liga a tela ao documento
137
+ }
138
+ | { kind: "drop"; id }
139
+ | { kind: "unknown"; id; type };
140
+
141
+ interface ResolvedLayout {
142
+ name: string;
143
+ plan: unknown;
144
+ } // plan pareado com o renderer por `name`
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Registry de descriptors
150
+
151
+ ```ts
152
+ createDescriptorRegistry(initial?: Descriptor[]): DescriptorRegistry
153
+ ```
154
+
155
+ | Método | Descrição |
156
+ | ----------------------------------------- | ------------------------------------------- |
157
+ | `register(descriptor)` / `register(type)` | adiciona (forma completa ou nu). Encadeável |
158
+ | `get(type)` · `has(type)` · `list()` | leitura |
159
+
160
+ ```ts
161
+ const registry = createDescriptorRegistry()
162
+ .register({ type: "Page", layout: "section" })
163
+ .register("Field");
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Resolver — estratégia de resolução
169
+
170
+ ```ts
171
+ createTypeResolver(options?: { capabilities?: Record<string, string> }): Resolver
172
+
173
+ interface Resolver { resolve(node: DefNode, ctx: { registry: DescriptorRegistry }): ResolveResult }
174
+ type ResolveResult =
175
+ | { kind: "render"; descriptor: Descriptor; props: Record<string, unknown> }
176
+ | { kind: "drop" }
177
+ | { kind: "unknown"; node: DefNode };
178
+ ```
179
+
180
+ - **Mescla** `descriptor.defaultProps` SOB `node.props`.
181
+ - **`requires`**: `{ feat: "*" }` (presença) ou `{ feat: "1.2" }` (versão mínima), contra `capabilities`.
182
+ - **`fallback`**: `"drop"` (nada) ou outro nó (resolvido no lugar); senão `unknown`.
183
+
184
+ ---
185
+
186
+ ## Pipeline
187
+
188
+ ```ts
189
+ resolveTree(node: DefNode, opts: ResolveTreeOptions, data?: unknown): ResolvedNode
190
+
191
+ interface ResolveTreeOptions {
192
+ registry: DescriptorRegistry;
193
+ resolver?: Resolver; // default: createTypeResolver()
194
+ binder?: BindingEngine; // default: identityBinder
195
+ planners?: Record<string, LayoutPlanner>; // default: defaultPlanners()
196
+ defaultLayout?: string; // default: "fragment"
197
+ }
198
+ ```
199
+
200
+ `bind` (pré-passo) → `resolve` (type→descriptor+props) → `plan` (layout). Nós `fallback:"drop"` somem
201
+ da árvore E do plano; `unknown` permanece (a view decide a degradação).
202
+
203
+ ---
204
+
205
+ ## Layout — planners (metade headless do _arrange_)
206
+
207
+ ```ts
208
+ interface PlannerChild {
209
+ id: string;
210
+ placement?: unknown;
211
+ }
212
+ interface LayoutPlanner {
213
+ name: string;
214
+ plan(children: PlannerChild[]): unknown; // dados do plano
215
+ placementSchema?: unknown; // a FORMA do placement que ele lê, em JSON Schema
216
+ }
217
+ ```
218
+
219
+ | Planner | Placement | Plan |
220
+ | -------------------------------------- | ----------------------------------------------------- | ------------------------------------- |
221
+ | `fragmentPlanner()` | `{ index? }` | `{ order: string[] }` |
222
+ | `sectionPlanner({ direction?, gap? })` | `{ index? }` | `{ direction, gap?, order }` |
223
+ | `gridFormPlanner({ columns?, gap? })` | `{ span?: number \| ResponsiveSpan, break?, index? }` | `{ columns, gap, responsive, cells }` |
224
+
225
+ ```ts
226
+ interface ResponsiveSpan {
227
+ base: number;
228
+ sm?: number;
229
+ md?: number;
230
+ lg?: number;
231
+ xl?: number;
232
+ }
233
+ interface GridFormCell {
234
+ id;
235
+ span: number;
236
+ spans: FilledSpans;
237
+ break?;
238
+ } // span = base (back-compat/inline)
239
+ ```
240
+
241
+ `defaultPlanners()` → `{ fragment, section, "grid-form" }`. O renderer correspondente (view) vive no
242
+ runtime; os dois são pareados por `name`.
243
+
244
+ O `placementSchema` é o contrato do filho no eixo de arrumação — o que o `descriptor.schema` é no de
245
+ props. Um editor monta os campos de posição a partir dele sem saber que layout é, e um layout que não
246
+ o publica deixa quem edita com o objeto cru. Os três planners daqui publicam.
247
+
248
+ ---
249
+
250
+ ## Tree — utilitários puros (pro core e pro editor)
251
+
252
+ | Função | Descrição |
253
+ | ---------------------------- | ------------------------------------------------- |
254
+ | `walk(node, visit, parent?)` | pré-ordem; `visit(node, parent)` (`null` na raiz) |
255
+ | `findById(root, id)` | primeiro nó com o id |
256
+ | `pathToId(root, id)` | caminho de ids até o alvo, ou `null` |
257
+ | `mapNodes(node, fn)` | mapeia imutável bottom-up |
258
+
259
+ ---
260
+
261
+ ## Ports — os seams (DI)
262
+
263
+ O core depende destas **interfaces**, nunca de uma lib — é por isso que ele não tem dependência
264
+ nenhuma. Cada adapter é um package à parte, e quem não usa um não paga por ele.
265
+
266
+ | Port | Contrato | Adapter |
267
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
268
+ | `BindingEngine` | `compile(node) → DefNode` (opcional) · `bind(node, scope) → DefNode` | [`@loom-forge/tslite`](../tslite/README.md) · default `identityBinder` |
269
+ | `ValidationProvider` | `validate(schema, value) → Issue[] \| null` | AJV/Zod (DI) · default `noopValidator` |
270
+ | `ActionPort` | o **Tier 0** do contrato de actions: `invoke(id, params?, ctx?) → ActionOutcome` (`void \| ActionResult \| Promise`) · `has?` · o Tier 1 opcional | o próprio runtime (`@node-actions/*`), ou um objeto do host — sem adapter (ADR-115) |
271
+ | `AddressPort` | `diff(a, b)` · `patch(base, diff)` | [`@loom-forge/address`](../address/README.md) |
272
+ | `TypeChecker` | `subtype(a, b): boolean` | [`@loom-forge/check`](../check/README.md) |
273
+ | `CapabilityPort` | `get(name)` · `has(name)` | escape imperativo (host) |
274
+
275
+ ### A porta de ação, sem adapter
276
+
277
+ O `ActionPort` é a projeção **estrutural** do Tier 0 do `@node-actions/protocol` (ADR-114): um
278
+ runtime que cumpra o contrato — o commander, a `lite`, o engine de diretivas via
279
+ `toProtocolRuntime` — é assignável a ele como está, e um objeto do host com `invoke` também. O
280
+ core não importa o protocolo (ADR-105), e não há package de adapter (ADR-115):
281
+
282
+ ```ts
283
+ createEngine({
284
+ actions: commander, // ou { invoke(id, params) { … } }
285
+ onActionResult: (id, result) => {
286
+ if (!result.success) toast(result.error?.code);
287
+ },
288
+ });
289
+ ```
290
+
291
+ - **`invoke` devolve ENVELOPE, e falha de domínio é DADO:** `{ success: false, error: { code } }`
292
+ volta pelo `onActionResult` e pelo `catch` do documento — nunca como exceção. `void` é sucesso.
293
+ - **Um runtime que LANÇA não derruba o clique:** o `dispatchIntent` converte o `throw` e a rejeição
294
+ em `{ success: false, aborted: true, abortedBy: "throw", error: { code: "UNHANDLED_ERROR" } }` —
295
+ bug de programa, não falha de domínio, e o `catch` é dele.
296
+ - **O Tier 1 é lido só quando existe** (`isAvailable`, `paramsOf`, `resultOf`, `metadataOf`): o
297
+ `check` tipa o call-site pelo `paramsOf` e o `event` de um `then` pelo `resultOf`; o grafo lê o
298
+ `isAvailable`. Quem não publica não afirmou nada, e isso é silêncio, não erro.
299
+ - **Discrimine por `error.code`, nunca por regex na mensagem.**
300
+
301
+ Ver [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) para o porquê de cada um, e
302
+ [docs/DECISIONS.md](./docs/DECISIONS.md) para os ADRs — os do caminho de efeito são o ADR-103,
303
+ 104, 114 e 115.
304
+
305
+ ---
306
+
307
+ ## Escopo de comando
308
+
309
+ ```ts
310
+ import {
311
+ commandScope,
312
+ resolveIntent,
313
+ aliasOf,
314
+ EMPTY_COMMANDS,
315
+ } from "@loom-forge/core";
316
+ ```
317
+
318
+ | Função | Descrição |
319
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
320
+ | `commandScope(herdado, nó)` | o vocabulário em vigor ABAIXO do nó — o herdado, com o `commands` dele por cima (o mais próximo ganha) |
321
+ | `resolveIntent(binding, escopo, updates?)` | a intenção roteada: nome de update local fica; alias vira o id, com os `params` dele por baixo dos do disparo; o resto passa |
322
+ | `aliasOf(alias)` | a forma curta normalizada: `"form/clear"` → `{ action: "form/clear" }` |
323
+
324
+ É a resposta de RUNTIME para "que ação este nome executa?", e quem a aplica é a expansão do
325
+ [`@loom-forge/forge`](../forge/README.md). A resposta sem rodar — que precisa saber onde o componente
326
+ monta — é do forge também. O que viaja é nome, nunca função (ADR-015 do repo).
327
+
328
+ ---
329
+
330
+ ## A tabela de slots
331
+
332
+ O core também exporta a **forma do `DefNode` como dado** — `FIELDS` (o papel de cada campo),
333
+ `SLOTS` (onde uma expressão pode morar, e o que o slot espera) e `TREE` (por onde a travessia
334
+ desce). É uma tabela exaustiva por tipo, e é o que faz a compilação, o bind e o typecheck
335
+ concordarem sobre onde cabe código sem nenhum deles farejar texto. `events` e `commands` são mapas
336
+ de intenções: o que compila é o `params` de cada entrada (shape `intentParams`). E cada slot declara
337
+ `directives` — se `{% %}` e `{# #}` são marcação ali, além do `{{ }}`; hoje é `false` em todos, e a
338
+ compilação pergunta à tabela em vez de a um padrão.