@loom-forge/check
v0.6.0
Published
forge · a camada de TIPOS (dev/CI, nunca no render): checkTree (props resolvidas ≤ descriptor.schema), a álgebra de tipo de um componente (componentType/componentAssignable) e o typecheck por lowering para TSL (buildEnv/checkComponent/checkExpression/anal
Maintainers
Readme
@loom-forge/check
A camada de TIPOS. Dev/CI, nunca no hot path de render (ADR-206). Prova compatibilidade
estrutural por subtype do TSLite — é type-check, não validação de valor (ajv/zod).
pnpm add -D @loom-forge/checkOs @tslite/* que ele usa são peer (ADR-021): a aplicação fornece uma instância do TSLite, a mesma para todo repo que a compõe. npm 7+ e pnpm 8+ os instalam sozinhos; uma faixa que não cruza vira aviso na instalação, e não uma segunda cópia.
Três superfícies, e elas respondem perguntas diferentes — duas precisam de dados, uma não.
| superfície | pergunta | precisa de dados? |
| --------------------------------------- | ---------------------------------------------------------------- | ----------------- |
| checkTree | as props RESOLVIDAS cabem no descriptor.schema? | sim |
| checkComponent / checkExpression | as EXPRESSÕES tipam — e o call-site passa o que o chamado exige? | não |
| componentType / componentAssignable | a ÁLGEBRA de tipo de um componente (a base dos slots tipados) | não |
Com dados: checkTree — props resolvidas ≤ descriptor.schema
import { createTsliteChecker, checkTree } from "@loom-forge/check";
const checker = createTsliteChecker();
checker.assignable({ name: "Ana" }, propsJsonSchema); // forma do valor ≤ tipo do schema?
checker.subtype(typeA, typeB); // A atribuível a B (Schema do TSLite)
checkTree(resolved, registry, checker); // props de cada nó ≤ descriptor.schema| Membro | O quê |
| -------------------------------------------------------- | -------------------------------------------------------------- |
| subtype(a, b) | atribuibilidade estrutural (port TypeChecker) |
| assignable(value, jsonSchema) | inferValue(value) ≤ fromJsonSchema(jsonSchema) |
| inferType · fromJsonSchema/toJsonSchema · format | pontes e diagnóstico |
| checkTree(node, registry, checker) | walk + valida props vs descriptor.schema → DataflowIssue[] |
Roda sobre a árvore RESOLVIDA, então infere a forma dos valores que de fato chegaram — pega inclusive o que veio de expressão. Em troca, precisa de dados (uma amostra, uma fixture).
Sem dados: checkComponent — as EXPRESSÕES, estaticamente
import { buildEnv, checkComponent, checkExpression } from "@loom-forge/check";
const env = buildEnv(def); // as raízes: props, state (e `data`, se você declarar o tipo)
checkComponent(def, env); // → CheckIssue[]Não se reimplementa typecheck: o componente é baixado num corpo de função TSL e o
@tslite/checker tipa. Daí saem de graça o scope-flow e a inferência — o tipo do item de um
each vem inferido da coleção, sem anotação nenhuma.
{
"each": "{{ props.itens }}",
"as": "item",
"props": { "n": "{{ item.nomee }}" },
}
// → no-such-member: Property 'nomee' does not exist on type '{ nome: string; ativo: boolean }'| pega | exemplo |
| --------------------------------------- | -------------------------------------------------------- |
| campo inexistente numa raiz | {{ props.tituloo }} · {{ state.abertoo }} |
| membro inexistente no item de um each | {{ item.nomee }} — tipo inferido da coleção |
| nome sem raiz | {{ titulo }} → unknown-name (não há escopo achatado) |
| when que não rende boolean | not-assignable — em vez de um nó sempre-visível |
| método de protótipo | {{ xs.length }} → erro; a forma é {{ length(xs) }} |
Cada issue traz três níveis de proveniência, e eles são independentes:
| campo | o quê | quando existe |
| --------- | ---------------------------------------------------------------------- | ----------------------------------- |
| path | o campo, como LISTA — [0, 1, "props", "children"] | sempre |
| address | o mesmo, como o autor o lê — #0.1.props.children. Derivado de path | sempre |
| source | o texto do campo, como o autor o escreveu | quando o campo era TEXTO |
| span | { from, to } dentro de source (a palavra) | idem, e se o nó acusado tem posição |
No path, índice e chave são distintos no tipo (2 anda em array, "2" em objeto), e é ele
que um serviço de linguagem traduz para a coordenada do documento; o address é exibição, e
ninguém o lê de volta. Na árvore a raiz é o nó 0 (#0.1…); fora dela o caminho é o do slot
(["updates", "somar", "set", "n"], updates.somar.set.n).
source.slice(span.from, span.to) é o trecho aceso — sem compensação nenhuma, inclusive numa
string mista: o span já leva o deslocamento da ilha dentro do texto, e é a forma do Span do
@tslite/language-service, que é o que uma superfície consome. Uma definição
já compilada também tipa (a ilha é decodificada em nós nossos), e aí o issue vem com endereço
e sem span: a posição não existe, e inventá-la seria pior que não tê-la.
A string é derivada uma vez. O check tipa a definição COMPILADA — a mesma que o bind executa — e as ilhas em memória que a compilação guardou, com posição no texto do campo. Quem já compilou (a bancada, um serviço de linguagem) passa o artefato, e nada é derivado de novo:
import { compile } from "@loom-forge/tslite/compile";
import { compileDefinition } from "@loom-forge/forge";
const compiled = compileDefinition(def, compile); // { def, diagnostics, islands }
checkComponent(def, env, { compiled }); // só o que é de TIPOOs diagnósticos da compilação (sintaxe, ilha vazia, o que o profile do deploy recusa) são de quem
compilou: com compiled, o check não os repete. Sem compiled, ele compila — sem profile, porque o
eixo de sintaxe não é dele — e os reporta junto. Um campo que não compilou entra como ausência de
valor, não como o texto que ficou nele: tipá-lo seria uma cascata do erro que a compilação já
acusou.
checkExpression(source, env, path, { resultType, limits }) é o cano por slot — o que um
drawer de editor liga quando o autor abre um campo. Roda por tecla digitada, daí os limits; e
sintaxe quebrada vira diagnóstico (parse-error), nunca exceção na cara de quem está digitando.
analyzeComponent(def, env, opts) é o checkComponent sem a projeção: devolve os issues e
o que os produziu — o Lowered, o CheckResult do corpo (o overlay de tipo e escopo por nó) e
o Env dos init. É o que um serviço de linguagem consome para responder o escopo de um
campo sem tipar de novo: o Lowered.frames diz, por nó (#0.1), por update (updates.x) e por elo
de intenção (#0.1.events.onClick.then, effects.x), o
bloco do corpo baixado em que os slots daquele lugar foram emitidos, e o scopeAt do
@tslite/checker sobre esse bloco é o escopo — o item do each, os params, o event — pela mesma
passada. É o que o @loom-forge/language-service faz.
lowerComponent(def, { compiled }) é exportado para prova e tooling: devolve o program (o AST
baixado), as anchors (nó → caminho, por identidade), os texts (endereço → o texto do campo),
os frames e as islands (as raízes tipadas). Para LER o que está sendo tipado,
print(program) do @tslite/codegen — imprimir é saída, nunca passo do pipeline: o corpo é
construído com builders e entregue direto ao checker, e é isso que faz a âncora funcionar por
identidade de nó (ADR-210, ADR-218, ADR-219).
A fronteira do unknown — o asserter, não o cast
Não saber o tipo de algo não é errado; any é. O certo é unknown, e a única ponte de
unknown para tipado é o asserter: tipa em build e prova em runtime. any não é
declarável (ADR-221), e as não existe aqui — seria tipar sem prova —, e o checker o recusa: {{ (props.bruto as Endereco).cep }} é
narrowing-cast-not-allowed no campo, e as any é any-not-allowed. A régua vem do
types.strict do profile do deploy (checkComponent(def, env, { profile }), default o
DEFAULT_PROFILE do bind); do profile o check aplica só o eixo de tipos — o que a expressão pode
conter é acusado pela compilação (ADR-217). O veredito é o mesmo nos dois canos — o campo por
tecla (checkExpression) e o componente inteiro —, porque a ilha da definição compilada é o nó
como está, com o as aninhado dentro. Só um cast na raiz ({{ props.x as T }}) não chega a
tipo nenhum: a compilação o recusa como island-not-a-value, porque um cast não computa nada.
Passe o catálogo de tipos nomeados e ele nasce:
const Address = s.object({ cep: { type: s.string }, city: { type: s.string } });
const env = buildEnv(def, { types: { Address } });
// {{ parseAs(Address, props.bruto).city }} → string ✅
// {{ parseAs(Address, props.bruto).cidade }} → no-such-member ← o checker volta a morder
// {{ props.bruto.cidade }} → unknown-value ← sem a ponte, não navegaUsar um unknown como objeto é erro (@tslite/checker ≥ 1.1, o TS2571 do tsc): ler dele,
chamá-lo ou indexá-lo é unknown-value, e a ponte deixou de ser conselho para virar cobrança. O
que não presume forma nenhuma continua livre — igualdade, !, ternário, template, e passar o
valor adiante —, e o caminho de volta é o de sempre: declarar, estreitar (typeof) ou provar
(parseAs).
Errar a ponte deixou de ser silêncio (@tslite/checker ≥ 0.9): cada forma tem código no nó do
argumento — unknown-name (o nome não existe), type-as-value, not-a-schema,
asserter-needs-name, missing-argument. Um as que tome o nome de um tipo do catálogo é
shadows-schema, apontando o campo as — o checker o vê porque o lowering faz do as um
parâmetro do corpo baixado. E a prova que só pode falhar é acusada em build:
parseAs(Address, { cep: 1 }) é unprovable-value quando o tipo do valor não se sobrepõe ao alvo.
parseAs, e não parse: a stdlib já tem um parse — o JSON.parse, (text) => unknown. O
nome é o ASSERTER que o @tslite/validate publica (reexportado como DEFAULT_ASSERTER), e um
tipo do catálogo com o nome de um operador ou de uma raiz é recusado na montagem do env
(BoundaryError) — erro de quem monta o sistema, não diagnóstico do programa. Sem types, não há
asserter no env e a guarda custa zero.
A outra metade é do binder, e é do chamador. Tipar é metade; provar é a outra. As duas saem do
MESMO catálogo, pelo createBoundary do @tslite/validate: a metade do checker é o que o
buildEnv monta por dentro; a de runtime, o host entrega ao binder:
import { createBoundary } from "@tslite/validate";
import { vanilla } from "@tslite/std";
const boundary = createBoundary({
types: { Address },
reserved: Object.keys(vanilla),
});
createTsliteBinder({ boundary }); // `parseAs(Address, x)` valida em runtimeNão embutimos isso: quem não usa asserter não deve pagar o validador, e provisão é do host — a
mesma doutrina do ActionPort.
O call-site — props passadas ≤ props declaradas
A tese é "B exige/muda um param → A, que compõe B, quebra". Ligue o resolvedor e ela fecha:
import { buildEnv, checkComponent, declaredPropsOf } from "@loom-forge/check";
checkComponent(def, buildEnv(def), { propsOf: declaredPropsOf(systemIR) });{ "type": "Greeting", "props": { "name": "{{ props.qtd }}" } }
// → not-assignable @ #0.props.name — `number` não cabe em `name: string`| o que passava como any | agora |
| -------------------------------------------------------- | ------------------------------------------ |
| prop vinda de {{ }} com o tipo errado | not-assignable, no CAMPO, com o span |
| a mesma prop dentro de um each (usando o item do laço) | idem — o escopo do laço existe no lowering |
| prop que o chamado NÃO declara | excess-property, ancorado na chave |
| prop obrigatória faltando | not-assignable, no OBJETO (é sobre ele) |
| a mesma coisa numa definição já COMPILADA | idem, com endereço e sem span |
Sem propsOf, nenhum call-site é tipado — e não "todos falham". Quem baixa o componente só
para ver o corpo não tem sistema para perguntar, e inventar um erro ali esconderia o que essa
pessoa foi olhar.
[!WARNING] Prop extra deixou de ser aceita. Antes o call-site era provado por
subtypesobre o objeto inteiro, e o subtyping estrutural aceita largura a mais. Agora é o motor que prova, com a regra que otscaplica a um literal de objeto: uma prop que o chamado não declara éexcess-property. Num nocode ela é peso morto que o autor não consegue ver — o componente simplesmente a ignora.
Varrer o sistema inteiro é o laço, e ele é curto — cada componente tem o seu env:
for (const def of registry.list())
issues.push(...checkComponent(def, buildEnv(def), { propsOf }));As DECLARAÇÕES — um TypeRef que não resolve é erro, não any
{ "props": { "n": "numero" } }
// → unknown-type-name @ props.nEra o furo mais caro que restava: o shorthand com typo virava any, e any desliga toda a
prova daquela prop — call-site, membro, span. Um erro de digitação na declaração apagava a
máquina inteira sem dizer nada. Agora o que não resolve vira unknown (o programa se obriga a
lidar) e o checkDeclarations diz qual nome não existe, no endereço da declaração. A
superfície conferida é o contrato inteiro: props.n, state.aberto e updates.add.params.q.
Um nome do catálogo vale como TypeRef: com types: { Address }, "props": { "addr":
"Address" } resolve. Sem o catálogo, o mesmo nome é reportado — e isso é o certo, porque dali
ele de fato não existe.
E any não é declarável (ADR-221)
{ "props": { "bruto": "any" } }
// → any-not-allowed @ props.bruto — e o campo vale `unknown`unknown e any não são dois graus da mesma coisa. unknown é não sei, e obriga a atravessar
pelo asserter; any é não me confira, e apaga a máquina. Então any não está no vocabulário
de declaração — nem pelo shorthand, nem por um Schema { t: "any" }: ele é lido como unknown
e acusado com o mesmo código que o motor usa para um as any autorado. O eject segue a leitura
e emite unknown no TSX.
O init do estado — provado no escopo que SEMEIA
{
"state": {
"email": { "type": "string", "init": "{{ props.perfil.email }}" },
},
}O init é expressão, avaliada uma vez, no mount — a semântica do useState(props.x). Ele é
tipado num Env PRÓPRIO (buildInitEnv): só props e ctx, sem state (é o que está sendo
definido) e sem params (não há chamada). É a mesma projeção que o runtime passa ao initState,
e é por isso que ele não divide o env do corpo:
| escrito no init | o que acontece |
| --------------------- | ------------------------------------------------------------ |
| {{ props.inicial }} | tipa contra o type declarado, no endereço state.<k>.init |
| 0 | constante atravessa intacta |
| {{ state.valor }} | unknown-name — em runtime ele nem existe ali |
[!WARNING] Antes disto, o
initnão passava por nada: nem compilação, nem bind, nem typecheck. Um"{{ props.x }}"ali virava texto literal na tela, e{ "type": "number", "init": "abc" }passava. Os dois silêncios fecharam juntos.
Os UPDATES LOCAIS — o set é um call-site sobre o ESTADO
É a perna que fechava o anel: props entram, state é lido, e aqui ele é escrito.
{
"state": { "count": { "type": "number", "init": 0 } },
"updates": {
"add": {
"params": { "n": "number" },
"set": { "count": "{{ state.count + params.n }}" },
},
},
}| erro | onde pousa |
| ---------------------------------------------- | ---------------------------------- |
| valor do tipo errado | updates.add.set.count |
| campo que o estado não declara | updates.add.set.<chave> |
| o evento passa um param do tipo errado | #0.events.onClick.params.n |
| o evento passa um param que a ação não declara | #0.events.onClick.params.<chave> |
O set é parcial: escrever um campo não obriga a escrever os outros.
params é dado de FORA. Sem params declarado ele vale unknown — e escrevê-lo direto num
estado tipado não passa. Não é aspereza: é a mesma fronteira das props, e declarar é a saída (e
de quebra tipa o call-site do evento que dispara a ação).
A ação do HOST também é provada — passe a porta
checkComponent(def, buildEnv(def), { actions: port });A assinatura vem do paramsOf do Tier 1 (o ActionPort, o commander, ou qualquer objeto com o
método), em JSON Schema — a borda que o TypeRef já atravessa. Com ela, events.params de uma
ação do host vira o mesmo call-site do update local: obrigatória faltando, tipo errado,
excess-property na chave. E a ordem de resolução é a do RUNTIME — update local primeiro, host
depois —, porque um componente com um update chamado salvar não alcança a action salvar da
aplicação, e provar contra ela apontaria para um contrato que aquele nome nem toca.
Sem a porta, a ação do host não é tipada — e isso é silêncio, não erro. Quem não publica
assinatura não afirmou nada. A porta tem a outra metade do contrato, o resultOf — a forma
do RETORNO —, e é ela que tipa o event de um then (abaixo).
Sem a chave params, o call-site continua existindo. { "action": "lead/open" } é conferido
como params: {}: a obrigatória que falta é not-assignable em #0.events.onClick.params, para a
ação do host e para o update local. Omitir a chave é o jeito mais natural de esquecer uma
obrigatória.
A ação que NÃO existe — unknown-action
checkComponent(def, buildEnv(def), {
actions: port, // com `has` — o do Tier 0 (`ActionPort.has`)
mounting: mountingOf(sys.components.values()),
});A assinatura prova o que existe; o has diz o que existe. Com os dois — a porta e a montagem
do sistema —, um id que chega à porta e ela não tem é unknown-action, no campo que o
ESCREVEU:
| escrito | onde pousa |
| -------------------------------------------------------------- | ------------------------------------ |
| erro de digitação no id do host | #0.events.onClick.action |
| erro de digitação no nome de um update local | idem — não resolve aqui, cai na porta |
| num elo da cadeia, num efeito, num cleanup | o action daquele elo |
| um alias de commands apontando para o nada | #0.commands.abrir.action |
| …na forma curta ("abrir": "lead/opne") | #0.commands.abrir |
O disparo que escreveu só o nome do alias não é acusado de novo: quem escreveu o id foi o alias. O alvo de um alias que é update do próprio componente não é perguntado à porta.
Sem o has, ou sem a mounting, nada muda. A porta que não disse o que tem não contraria
ninguém. E sem o sistema, um nome que o documento não declara pode ser vocabulário de OUTRO
componente — o $slot de uma sessão o resolve onde este monta —, e acusá-lo seria afirmar o que
não se sabe. Com o sistema, o nome que algum vocabulário declara depende de onde monta e não é
julgado; o que nenhum declara só pode chegar à porta. Ver ADR-222.
resolve — o que a camada de COMANDO preenche
{ "events": { "onClick": { "action": "lead/open", "resolve": ["id"] } } }Um comando resolve param por token ($focus), por default, ou perguntando — abrindo um drawer,
um seletor. Isso é legítimo, e é diferente de esquecer. Então se declara:
| o param… | o que acontece |
| ------------------------------ | ------------------------------------- |
| foi passado | é provado contra o tipo |
| está em resolve | conta como preenchido, e não falta |
| não está em nenhum dos dois | erro — obrigatória faltando |
| está em resolve e não existe | excess-property, no campo resolve |
É o que impede o default virar sorte. Em runtime o resolve não faz nada: ele é declaração, e
quem preenche é o commander, com os resolvers dele.
O nome de COMANDO — provado contra a ação que ele executa
{
"id": "s",
"type": "Section",
"commands": {
"abrir": { "action": "lead/open", "params": { "motivo": "atalho" } },
},
"children": [
{
"id": "b",
"type": "Button",
"events": { "onClick": { "action": "abrir" } },
},
],
}O evento é provado contra a ação que o nome EXECUTA, nos degraus da expansão: update local, vocabulário de onde o nó monta, porta (ADR-015 do repo). O que o alias já liga conta como preenchido. E o próprio alias é provado onde foi escrito, contra a assinatura com tudo opcional, porque ele é aplicação parcial: faltar é legítimo, sobrar e errar o tipo não são.
| escrito | onde pousa |
| ------------------------------------------------------- | ------------------------------------ |
| o alias liga uma chave com o tipo errado | #0.commands.abrir.params.<chave> |
| o alias liga uma chave que a ação não declara | idem, excess-property |
| o disparo não passa a obrigatória que o alias não ligou | #0.0.events.onClick.params |
| o disparo passa uma chave que a ação não declara | #0.0.events.onClick.params.<chave> |
Passe a montagem do sistema, e um filho de slot é provado pelo vocabulário da sessão onde monta:
import { mountingOf } from "@loom-forge/forge";
checkComponent(def, buildEnv(def), {
actions: port,
mounting: mountingOf(sys.components.values()),
});Sem ela, o documento é o sistema: o que ele declara resolve dentro dele, e o resto vale como o id da porta. Com ela, um nome que algum vocabulário declara e o documento não resolve depende de onde o componente monta — e não é provado contra assinatura nenhuma. Ver ADR-216.
O event de uma intenção — a ENTRADA de dado, provada (ADR-018 do repo)
{
"events": {
"onChange": {
"action": "editar",
"params": { "campo": "cpf", "valor": "{{ event }}" },
},
},
}Tudo o que uma intenção recebe entra pelo params, e event é a raiz que o carrega: o que o
widget emitiu, o data da ação anterior num then, o error num catch. O lowering abre um
frame por intenção com event como parâmetro tipado, e os params são tipados dentro dele:
| onde | o tipo de event vem de | sem declaração |
| -------------------------- | ---------------------------------------------------------------------------------- | -------------- |
| events.onX num widget | descriptor.meta.emits.onX (seam emitsOf) | unknown |
| events.onX num el | — | unknown |
| then de uma ação do host | resultOf(action) da porta (actions) | unknown |
| then de um update local | undefined — o estado foi escrito, e só | — |
| catch | { code: string; message?: string; data?: unknown }, o contrato do ActionResult | — |
| um efeito (e o cleanup) | undefined — nada o emitiu; o then/catch dele seguem as linhas acima | — |
E unknown num param tipado é not-assignable: quem não declarou o que emite (o kit) ou o
que devolve (a porta) não ganha um tipo de graça — a leniência que o as tinha saiu com ele. O
autor sai disso declarando ou provando (parseAs). Cada elo de uma cadeia é um call-site (o
params contra a assinatura da ação que ele executa), e cada then/catch abre o próprio
event, que sombreia o de fora — como numa promise chain. Os caminhos seguem a cadeia:
#0.events.onClick.then.params.valido, #0.events.onClick.catch.params.motivo na árvore;
effects.carregar.then.params.user, effects.carregar.cleanup.catch.params.motivo fora dela — é
o mesmo caminho em que a compilação do forge os deixa (componentSlotsOf). Um efeito com
then é "carregar no mount e guardar o que carregou", e o event desse then é o resultOf
da ação, como num evento.
pending[ação] é raiz do env de um componente vivo (updates ou effects):
Record<string, boolean>, true do despacho ao resultado. Num componente puro é unknown-name,
e o remédio é o que o torna vivo.
A álgebra de tipo de um componente
import {
componentType,
componentAssignable,
declaredPropsOf,
} from "@loom-forge/check";
componentType(ir); // { props: Schema, category?, tags, entry }
componentAssignable(child, { props }); // child ≤ Component<C> no eixo de props
declaredPropsOf(systemIR); // o resolvedor que liga o call-site (acima)TypeRef (o que a declaração escreve) resolve para Schema do TSLite, que é o SSOT: shorthand
("string"), Schema literal, ou JSON Schema só na borda (via @tslite/json-schema). Prop com
nome terminado em ? é opcional, TS-like — e opcional aqui significa T | undefined, porque
props viajam como JSON e em JSON undefined não existe: {} e { body: undefined } são o mesmo
documento (ADR-214).
