npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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/check

Os @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 TIPO

Os 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 navega

Usar 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 runtime

Nã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 subtype sobre o objeto inteiro, e o subtyping estrutural aceita largura a mais. Agora é o motor que prova, com a regra que o tsc aplica 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.n

Era 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 init nã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).