Skip to content

feat: fecha as brechas de assertion e adiciona configs de override (0.10.0) - #9

Merged
tianjos merged 9 commits into
mainfrom
feat/close-assertion-loopholes
Sep 17, 2026
Merged

tianjos merged 9 commits into
mainfrom
feat/close-assertion-loopholes

Conversation

@tianjos

@tianjos tianjos commented Sep 16, 2026

Copy link
Copy Markdown
Owner

0.10.0 — fecha duas brechas que deixavam o lint verde com código pior, e adiciona duas configs de override.

Important

0.10.0 já está publicado no npm e a tag v0.10.0 neste branch é a origem do que foi publicado. Fazer merge commit ou rebase merge — um squash orfanaria o commit que a tag referencia, e o pacote público ficaria sem fonte correspondente no histórico.

260 testes (eram 239), typecheck limpo, o plugin passa no próprio lint.

As brechas

no-type-assertion não cobria x!. As três formas sintáticas de passar por cima do checker são o mesmo ato, e a não-nula era a única livre — também é a mais barata de escrever e a mais cara de estar errada. Reportada com messageId próprio, porque o remédio difere de um cast: estreitar com um check que lança, ou corrigir um tipo que nunca foi nullable.

Nada impedia lavar o cast através de any. Com no-type-assertion ligado e nada mais, as T some do call site e reaparece como uma função que devolve any — o cast não desaparece, ele desce um nível e deixa de ser revisável. no-any-return fecha isso na posição de retorno, mantendo-se AST-only para não exigir lint com type info.

Observado num codebase real: um repositório que tinha acabado de chegar ao verde sob starter ainda tinha um Promise<any> que absorvera um cast removido na passagem.

no-instanceof agora permite type guard declarado

Não é afrouxamento — é preço. Sem isso, a saída mais barata da regra era duck typing estrutural ('toDate' in value no lugar de value instanceof IsoDate): passa no linter, passa para qualquer objeto que por acaso tenha o membro, e é pior que o que substituiu.

Com a exceção, o caminho honesto passa a ser o barato. instanceof é permitido dentro de função cujo retorno é value is X — a assinatura concentra a pergunta nominal numa função nomeada e greppável, uma por classe. Só a função mais interna conta, então um guard não empresta a exceção para o código seguinte. Uma função que devolve boolean não é guard. Desligável com { allowTypeGuards: false }.

configs.tests e configs.off

Overrides, não presets.

off é toda regra desligada, para arquivo que ninguém escreve à mão — migration que o CLI gera, script de build cuja interface com o operador é console. Derivada da lista de regras do próprio plugin, então regra nova nasce silenciada nesses arquivos; o equivalente hand-rolled no config do consumidor fica desatualizado no instante em que é escrito.

tests são as oito regras que um spec legitimamente dispara — e quais oito é medição, não gosto. Sobre o corpus da seção de adoção, são as que de fato reportam dentro de arquivo de teste. As regras de forma de classe ficaram deliberadamente de fora: reportam zero vezes em spec naquele corpus, então desligá-las não compra nada e custa o report no dia em que um spec finalmente merecer um.

no-null-return: só mensagem

A redação antiga ("return an explicit empty value") era lida como licença para devolver array de zero ou um elemento. Isso é um null numa caixa: o tipo promete uma lista que nunca terá mais de um item, e o chamador escreve um laço que roda uma vez. A nova diz que coleção vazia só modela ausência onde o retorno já era coleção.

🤖 Generated with Claude Code

tianjos and others added 9 commits September 15, 2026 16:54
`x!` is the third syntactic form of overriding the checker, alongside
`as T` and `<T>x`, and it was the only one passing. It is also the
cheapest to type and the most expensive to be wrong about: it compiles
whether the value is nullable, whether the driver hands back another
type, or whether the row simply has none.

Reported under its own messageId, because the remedy differs from a
cast: narrow with a check that throws, or correct a type that was never
nullable in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A function that declares `any` as its return type widens every value
that passes through it. That is a type assertion the reader cannot see:
`as T` is greppable at the call site, an `any` return is not.

This is the shape no-type-assertion pushes code into when nothing
catches it — the cast does not disappear, it moves one call deeper and
stops being reviewable. Observed twice on a codebase that had just been
brought to green under the preset.

Return position only, so it stays AST-only and ships in the preset
without requiring type-aware linting. `any` on a parameter is a
different defect and belongs to @typescript-eslint/no-explicit-any.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Some classes are nominal and offer no discriminant to switch on: a
framework exception, a value object from another module, an Error
subclass. The check has to happen somewhere, and a `value is X`
signature is the one place it states what it is doing — the answer
leaves as a narrowed type instead of a bare boolean, and the project
ends up with one greppable guard per class.

Without this, the cheapest way out of the rule was structural duck
typing: `'toDate' in value` instead of `value instanceof IsoDate`. That
passes the linter, passes for any object that happens to carry the
member, and is strictly worse than what it replaced. The exemption makes
the honest path the cheap one.

Only the innermost enclosing function counts, so a guard cannot lend its
exemption to the code that follows it. Off via `{ allowTypeGuards:
false }`. The message now names the class, so the guard it asks for
comes with a signature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… out

The old wording was read as licence to return a zero-or-one array, which
is a null in a box: the type promises a list it will never have more
than one of, and every caller writes a loop that runs once.

An empty collection models absence only where the return type was
already a collection. Otherwise: throw, or return an object that answers
for the absent case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds no-any-return to the rules table and rule details, spells out the
non-null operator under no-type-assertion, and documents allowTypeGuards
with the duck-typing workaround it exists to price out.

The adoption section says plainly that these two are not in the
1,261-file measurement, and carries the numbers actually measured: 2
reports for `x!` and 0 for no-any-return over a fourth service, and one
`Promise<any>` found on a tree that already linted clean under starter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…judge

Two override configs, alongside the recommended/starter presets.

`off` is every rule this plugin ships, disabled, for files nobody writes
by hand — a migration a CLI scaffolds, a build script whose interface
with the operator is `console`. Derived from the plugin's own rule list,
so a rule added later arrives already silent there; the hand-rolled
equivalent, mapping over Object.keys(elegant.rules) in a consumer's
config, goes stale the moment it is written.

`tests` is the eight rules a spec legitimately trips. Which eight is a
measurement, not a taste: over the corpus in "Adopting on an existing
codebase", these are the rules that report inside test files, each for a
reason that holds there and nowhere else. The class-shape rules are
deliberately left out — they report zero times in specs on that corpus,
so disabling them buys nothing and costs the report on the day a spec
finally earns one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewrites "Relaxing rules in test files" around the measured list rather
than the guessed one, and says why the rules left out were left out. Adds
"Generated and scaffolded files" for `off`.

Also answers the case no-logic-in-constructor could not: on a class a DI
container builds there is no static factory to move the work to, so the
rule's stated remedy does not exist. The move people reach for — a
lifecycle hook — trades the rule for a worse invariant, a field that is
no longer readonly and is empty until the hook runs. Resolve the config
in the module's useFactory and inject the result instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uma tag não é prova de que a versão não foi publicada: dá para publicar
de uma máquina antes de empurrar a tag, ou reempurrar a tag depois de um
run que falhou. Foi o que aconteceu com a v0.10.0 — publicada à mão, e o
job morreu em "cannot publish over the previously published versions",
que lê como build quebrado quando nada está quebrado.

Consulta o registry antes e pula o publish se a versão já existe. Um
`npm view` que falhe por qualquer outro motivo deixa a flag sem valor, e
o publish roda e falha alto — o default seguro. O skip fica registrado no
summary do run, para não virar sucesso silencioso.

Não conserta o run que já falhou: para tag push o GitHub usa o workflow
do commit taggeado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tianjos
tianjos merged commit 96ff7d7 into main Sep 17, 2026
1 check passed
@tianjos
tianjos deleted the feat/close-assertion-loopholes branch September 17, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant