@loom-forge/forge 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,278 @@
1
+ # @loom-forge/forge
2
+
3
+ **forge** · o CONSTRUTOR de componentes, headless. O loom compõe componentes que já existem;
4
+ o forge **declara componentes novos** em JSON — com props tipadas, estado local, slots com
5
+ contrato e um grafo de dependência entre definições. Sem React e sem linguagem de expressão: o
6
+ binder entra pelo port.
7
+
8
+ ```bash
9
+ pnpm add @loom-forge/forge
10
+ ```
11
+
12
+ ```jsonc
13
+ {
14
+ "component": "UserCard",
15
+ "props": { "id": "string", "titulo?": "string" },
16
+ "state": { "aberto": { "type": "boolean", "init": false } },
17
+ "updates": { "alternar": { "set": { "aberto": "{{ !state.aberto }}" } } },
18
+ // um update pode declarar os ARGUMENTOS que aceita: `params: { n: "number" }`
19
+ "body": {
20
+ "id": "card",
21
+ "type": "Card",
22
+ "props": { "title": "{{ props.titulo }}" },
23
+ "events": { "onClick": { "action": "alternar" } },
24
+ "children": [{ "id": "slot", "type": "$slot" }],
25
+ },
26
+ }
27
+ ```
28
+
29
+ **Um componente é uma função — exatamente como uma action é uma função.** É desse isomorfismo que
30
+ sai o resto: entry × privado, puro × sujo, `requires`/`unable`, grafo de dependência, propagação
31
+ de tags. Ver [docs/DESIGN.md](./docs/DESIGN.md).
32
+
33
+ ---
34
+
35
+ ## As camadas
36
+
37
+ | camada | o quê | arquivo |
38
+ | -------------- | ------------------------------------------------------------------ | ---------------------------- |
39
+ | **definição** | `ComponentDef` + o registry (`createComponentDefRegistry`) | `ir.ts` |
40
+ | **compilação** | a definição COMPILADA: texto → ilha, na árvore e fora dela | `compile.ts` |
41
+ | **análise** | IR + grafo + regras + slots — o relógio LENTO, data-independente | `ir`/`graph`/`rules`/`slots` |
42
+ | **expansão** | o seam sobre o compositor: custom → árvore pura, no relógio RÁPIDO | `expand.ts` |
43
+
44
+ ### A definição compilada
45
+
46
+ ```ts
47
+ import {
48
+ compileDefinition,
49
+ compileNodeOf,
50
+ componentSlotsOf,
51
+ } from "@loom-forge/forge";
52
+
53
+ // pelo PORT do binder (runtime): só a forma
54
+ const { def: compilada } = compileDefinition(userCard, compileNodeOf(binder));
55
+ // pela LINGUAGEM (autoria): a forma, os diagnósticos e as ilhas em memória
56
+ const { def, diagnostics, islands } = compileDefinition(userCard, compile);
57
+ ```
58
+
59
+ Uma definição tem expressão **fora da árvore**: o `provides`, os `params` de um alias de
60
+ `commands`, o `init` do estado, o `set` de um update, os `params` de um efeito e do `cleanup` — e
61
+ os de cada elo `then`/`catch` da cadeia deles (ADR-018). O `compile()` da linguagem recebe um nó
62
+ e só alcança a árvore — então cada consumidor compilava o resto por conta própria, com um
63
+ nó-fantasma escrito na mão: um no `applyUpdate`, um no `EffectRunner`, um no eject. Três cópias
64
+ da mesma pergunta.
65
+
66
+ `componentSlotsOf(def)` responde onde eles estão, e o **caminho é o endereço**
67
+ (`updates.somar.set`, `effects.carregar.then.params`, `effects.carregar.cleanup.params`). A tabela `COMPONENT_FIELDS` classifica
68
+ todo campo da definição e é exaustiva por `keyof ComponentDef`: campo novo não compila sem ser
69
+ classificado.
70
+
71
+ **A divisão de conhecimento não muda:** o forge diz ONDE a expressão mora e a linguagem entra como
72
+ função (`CompileNode`) — nenhum dos dois importa o outro. O tradutor devolve a forma e, quando tem
73
+ o que dizer, os diagnósticos e as ilhas do nó; o forge os **reancora** no caminho do slot
74
+ (`updates.somar.set.n`, `state.n.init`), e é assim que um erro de sintaxe num `set` chega à
75
+ autoria com endereço. O port do binder fala só `DefNode`, e `compileNodeOf(binder)` é o tradutor
76
+ estável dele — a memória do `compileDefinition` é por definição **e** por tradutor, então uma
77
+ arrow nova por render nunca a acertaria.
78
+
79
+ > [!WARNING]
80
+ > **O runtime assume a definição COMPILADA.** O `applyUpdate` e o `bindPayload` só ligam; quem
81
+ > compila é o `defineComponent`, uma vez, na entrada do registry. Um `set` que chegue cru vira o
82
+ > próprio texto no estado, porque o binder trata string como dado (ADR-111).
83
+
84
+ ### Definição e IR
85
+
86
+ ```ts
87
+ import {
88
+ createComponentDefRegistry,
89
+ compileComponent,
90
+ buildSystemIR,
91
+ } from "@loom-forge/forge";
92
+
93
+ const defs = createComponentDefRegistry().register(userCard);
94
+ const ir = compileComponent(userCard, { components: defs.names() });
95
+ const system = buildSystemIR([ir]); // grafo: outgoing/incoming, tags, ciclos, missing-deps
96
+ ```
97
+
98
+ O IR é **derivado, nunca editado à mão, nunca persistido como verdade** — recomputado do source.
99
+ E ele carrega só a árvore **normalizada**: o que se calcula sobre ela (arestas, deps, tags,
100
+ ciclos) é artefato **lateral** — `deriveFacts` / `factsOf` / `nodeFactsOf` —, com as camadas
101
+ transitivas opt-in. O binder deriva, o checker julga.
102
+ A unidade do grafo é a **definição** (`UserCard`), não o nó-instância; as arestas são induzidas por
103
+ nós dentro da árvore (`type` custom → `uses`; uma intenção → `actions`, com o id que ela
104
+ EXECUTA — ver [Comando](#comando)). **Containment não é dependência.** Ver
105
+ [docs/IR.md](./docs/IR.md).
106
+
107
+ ### Grafo
108
+
109
+ O motor é o **[`@tslite/graph`](https://www.npmjs.com/package/@tslite/graph)** — ponto-fixo
110
+ monótono por worklist, Tarjan iterativo, zero dependências. Não o reexportamos: quem precisa das
111
+ primitivas importa de lá, direto. O que o `buildSystemIR` faz é montar o grafo do DOMÍNIO (quais
112
+ são os nós, quais arestas contam) e instanciar os reticulados:
113
+
114
+ | fato | de onde vem | o quê |
115
+ | ---------------- | --------------------------- | --------------------------------------------- |
116
+ | `facts.tags` | `propagateUnion` | tags efetivas/transitivas, canônicas |
117
+ | `facts.incoming` | `invertEdges` | "quem usa isto" — por onde o dirty sobe |
118
+ | `facts.cycles` | `detectCycles` | componentes fortemente conexos |
119
+ | `computeUnable` | `reachable` (em `rules.ts`) | availability transitivo (o Razor das Actions) |
120
+
121
+ A Linguagem não tem ciclo — o call graph dela é um DAG —, mas o grafo de quem a consome tem: um
122
+ componente compõe outro que o compõe de volta. Sobre esse grafo não existe ordem topológica,
123
+ existe ponto-fixo; e essa peça, que todo consumidor não-trivial acabava **copiando**, desceu para o
124
+ TSLite. Este package copiava uma versão quadrática dela (1000 nós em 58 ms, 8000 em 4,7 s), e a
125
+ cópia morreu quando o dono publicou a peça.
126
+
127
+ ### Regras e slots
128
+
129
+ ```ts
130
+ import {
131
+ checkSlots,
132
+ checkCategoryPolicy,
133
+ computeUnable,
134
+ checkForbiddenTags,
135
+ checkIdentity,
136
+ } from "@loom-forge/forge";
137
+ ```
138
+
139
+ `checkIdentity` acusa um id repetido no documento de um componente (`duplicate-id`, com os endereços).
140
+ O id é a identidade do nó — é por ele que o editor muta, que o patch acha o alvo e que a tela liga o
141
+ foco ao documento —, e dois nós com o mesmo id fazem os três apontarem para o primeiro, sem erro
142
+ nenhum. Componentes diferentes repetem id à vontade: a expansão os separa por instância.
143
+
144
+ `checkSlots` é o que o React não tem: o **pai declara o contrato** do que aceita como children
145
+ (categoria, tags, leaf, cardinalidade). Composição ≠ dependência — A não conhece B, só o contrato,
146
+ então isso NÃO cria aresta A→B, e a checagem mora no compositor (quem pôs B dentro de A).
147
+
148
+ O `computeUnable` aceita a **porta de ação** e pergunta a ela, em vez de exigir a lista de
149
+ indisponíveis pronta:
150
+
151
+ ```ts
152
+ computeUnable(sys, { port });
153
+ // ação indisponível → quem a invoca é fonte, e todo dependente herda `unable`
154
+ ```
155
+
156
+ Ele pergunta só pelo que o grafo de fato invoca — o eixo `actions` tem só o que SAI, porque update
157
+ local é chamada interna (ADR-014), e tem o id RESOLVIDO: um apelido de sessão nunca é perguntado. Porta sem `isAvailable` não torna nada unable: **não saber não é
158
+ saber que não.** E é um RETRATO: disponibilidade muda em runtime, e isto é o relógio lento — quem
159
+ quiser acompanhar, recomputa.
160
+
161
+ ### Expansão
162
+
163
+ ```ts
164
+ import { resolveComponentTree } from "@loom-forge/forge";
165
+
166
+ resolveComponentTree(node, { registry, binder, componentDefs: defs }, data);
167
+ // bind (dados) → expand (custom → árvore pura) → resolveTree (compositor)
168
+ ```
169
+
170
+ A expansão é um **pré-passo**, e é isso que mantém o compositor puro: o `resolveTree` do core não
171
+ sabe o que é um `ComponentDef`. Cada nó de tipo custom vira o corpo do componente ligado contra as
172
+ props, com ids namespaced por instância; os children do call-site são injetados onde o corpo
173
+ declara `{ "type": "$slot" }` — resolvidos no escopo do COMPOSITOR, opacos para o componente.
174
+
175
+ Ela roda **sempre**, mesmo sem `componentDefs`: é também onde as diretivas de escopo (`provides`,
176
+ `commands`) são consumidas. `bodyOf(def)` é o corpo com o açúcar do componente — o `provides` e o
177
+ `commands` dele juntam com os da raiz, e a raiz ganha no mesmo nome —, e é o que o boundary de um
178
+ componente vivo expande, com `updates` dizendo quais nomes resolvem ali dentro.
179
+
180
+ Cada nó sai com a **origem** (`origins`): o componente e o id que o autor deu, do mais de fora para o
181
+ mais de dentro. O `bodyOf` carimba antes do bind, então a cópia de um `each` preserva o id autorado; a
182
+ raiz de um corpo responde também pela chamada que ocupa; e a árvore do host não tem origem. É o que
183
+ liga a tela de volta ao documento (ADR-017 do repo).
184
+
185
+ ### Contexto
186
+
187
+ ```ts
188
+ import { deriveContext, checkContexts } from "@loom-forge/forge";
189
+
190
+ const ctx = deriveContext(sys.components.values());
191
+ checkContexts(sys, ctx, { ambient: ["@acme/auth"] });
192
+ ```
193
+
194
+ `uses` não é atributo, é **dívida**: ela sobe pelo grafo até alguém prover, e quem exige sem
195
+ declarar está mentindo — erro, no nó por onde escapou. Qualquer nó provê (`provides`), o
196
+ `ComponentDef.provides` é açúcar para a raiz do corpo, e o contrato tem de ser dado serializável.
197
+ O que está provido num ponto é o que ele enxerga depois de MONTADO: um filho de slot enxerga o que o
198
+ chamado provê no `$slot`. Ver [docs/CONTEXT.md](./docs/CONTEXT.md).
199
+
200
+ ### Comando
201
+
202
+ ```jsonc
203
+ {
204
+ "component": "FormSection",
205
+ "commands": {
206
+ "submeter": { "action": "form/submit", "params": { "canal": "email" } },
207
+ "limpar": "form/clear",
208
+ },
209
+ "body": {
210
+ "id": "sec",
211
+ "type": "Section",
212
+ "children": [{ "id": "slot", "type": "$slot" }],
213
+ },
214
+ }
215
+ ```
216
+
217
+ Um nó declara o **vocabulário** da subárvore — `nome local → ação`. Um botão dispara `submeter` e
218
+ não sabe o que isso executa: quem decide é o lugar onde ele monta. A resolução tem três degraus,
219
+ **update local → vocabulário mais próximo → porta raiz**, e segue as regras do `provides`: o nó
220
+ instala para baixo, o corpo do chamado é a subárvore do nó de chamada, e o filho de slot enxerga o
221
+ vocabulário do `$slot` onde monta. A expansão consome a diretiva, então a view recebe o id. Os
222
+ `params` do alias são aplicação parcial: entram por baixo dos do disparo. E o efeito resolve pelo
223
+ vocabulário de onde o componente montou.
224
+
225
+ A mesma pergunta tem resposta sem rodar:
226
+
227
+ ```ts
228
+ import { mountingOf, settledRoute } from "@loom-forge/forge";
229
+
230
+ buildSystemIR(irs, { commands: host }); // o eixo `actions` já sai com o id resolvido
231
+ const mounting = mountingOf(sys.components.values(), { host }); // o que cada um instala no slot
232
+ ```
233
+
234
+ Um nome que nenhum vocabulário do sistema declara só pode chegar à porta, e é aresta de quem o
235
+ escreveu. Um que algum vocabulário declara depende de onde o componente monta: ele **escapa**, como
236
+ o `uses` de um contexto, e vira aresta de quem o resolve — na borda, é o vocabulário do host
237
+ (`commands`) que responde. `routeIntent`/`settledRoute` são a resposta para UMA intenção, e é delas
238
+ que o `check` e o `eject` perguntam. Ver o ADR-015 do repo.
239
+
240
+ ### Efeito
241
+
242
+ ```ts
243
+ import { checkEffects } from "@loom-forge/forge";
244
+ ```
245
+
246
+ Um efeito é um **events-out cujo gatilho é uma mudança**, não um gesto: mesma `action`, mesmos
247
+ `params`, mesma aresta no grafo, mesma tipagem — e a mesma cadeia: `then`/`catch` valem num efeito
248
+ (e no `cleanup` dele), e é assim que "carregar no mount" guarda o que carregou (ADR-018 do repo). O
249
+ `event` do efeito em si é `undefined`; o do `then`, o que a ação devolveu. Declarar efeito torna o componente **vivo**, e
250
+ infere a tag `effectful`, que propaga. E o laço — observar o que a própria ação escreve — se
251
+ **prova sem rodar**, porque os dois lados são declarados. Ver
252
+ [docs/STATE.md §7](./docs/STATE.md).
253
+
254
+ ### Estado local
255
+
256
+ ```ts
257
+ import {
258
+ isLive,
259
+ initState,
260
+ applyUpdate,
261
+ STATEFUL_TYPE,
262
+ } from "@loom-forge/forge";
263
+ ```
264
+
265
+ Componente **puro** (sem `updates` nem `effects`) expande estaticamente. Componente **vivo** não expande: emite
266
+ o marcador `$stateful` (dado, headless) e quem o materializa é a view —
267
+ [`@loom-forge/forge-react`](../forge-react/README.md). O estado é sempre **local à instância**;
268
+ 100 `<Counter/>` são 100 escopos isolados. Nunca global. Ver [docs/STATE.md](./docs/STATE.md).
269
+
270
+ ---
271
+
272
+ ## Os vizinhos
273
+
274
+ O forge **depende do compositor**, nunca o contrário. Ele não conhece React (a view é o
275
+ [`@loom-forge/forge-react`](../forge-react/README.md)), não conhece a linguagem de expressão (o
276
+ binder entra pelo port `BindingEngine`, implementado pelo
277
+ [`@loom-forge/tslite`](../tslite/README.md)) e não conhece a camada de tipos (que é o
278
+ [`@loom-forge/check`](../check/README.md), e depende DELE, não o contrário).