Skip to content
6 min read · 1,257 words

ESLint Rules

Here is what nobody tells you about TypeScript: it catches the mistakes it can see, and the rest are apparently your problem. A validator.any() that quietly accepts the null you spent a whole schema trying to forbid compiles fine, green check, no notes — because as far as the type system is concerned that is a perfectly valid schema, and what it actually does at runtime is, and I quote, not the type system's department. A Thought carrying a vendor payload with no replay tag — a thing that can never be replayed, dead on arrival — type-checks without so much as a raised eyebrow. So the code is correct and the code is wrong at the same time, and you get to find out which at the worst possible moment.

That gap — between "compiles" and "actually works" — is the entire reason these rules exist. Each one is a documented footgun turned into a lint error, so the mistake fails your lint instead of your afternoon, rather than waiting to surface later as a runtime throw, a silent fail-open, or output that is confidently, articulately wrong.

Why these are lint rules and not types

Every rule here guards an invariant TypeScript structurally cannot express — a cross-field requirement, a value rejected only by a runtime Joi .custom() / .forbidden() check, or a "do not call that from in here" boundary. If the type system could have caught it, believe me, we would have let it, and saved ourselves the trouble of writing a rule.

Install

@nhtio/adk/eslint needs eslint and @typescript-eslint/utils. Both are optional peer dependencies of @nhtio/adk — the main library never imports them, so you only install them if you lint with this plugin.

sh
npm install -D eslint @typescript-eslint/utils

Use it

The plugin is a normal flat-config ESLint plugin. Register it under the adk namespace and turn on the rules you want — or take the whole set with the bundled recommended config.

js
// eslint.config.js
import adk from '@nhtio/adk/eslint'

export default [
  // Registers the plugin as `adk` and enables every rule at `error`.
  adk.configs.recommended,
]
js
// eslint.config.js
import adk from '@nhtio/adk/eslint'

export default [
  {
    plugins: { adk: adk.plugin },
    rules: {
      'adk/require-validator-any-required': 'error',
      'adk/thought-payload-requires-replay-tag': 'error',
      // …enable only what you want
    },
  },
]

Rules are report-only — no autofix, and that is on purpose. Each message tells you exactly which contract you broke and how to fix it, and then it stops, because the fix is a decision and the decision is yours; a linter that silently rewrites a contract violation it does not actually understand is not help, it is a second bug with good intentions. When you genuinely need to keep a flagged shape, carve out the exception inline with a reason, so the decision is auditable in the diff instead of buried in a config exception list nobody will ever read again:

ts
// eslint-disable-next-line adk/require-validator-any-required -- type-arg only, never validated
const schema = validator.array().items(validator.any())

The rules

adk/require-validator-any-required

In @nhtio/validation, validator.any() admits null and undefined unless you make it .required(). Read that again, because it is the opposite of what you assumed: the schema you wrote to reject missing values is, by default, waving them right through, and every .custom() refinement you layered on top gets skipped entirely the moment the value is absent. The truly infuriating case is validator.any().valid(null) — you wrote "must be exactly null," a human being would read it as "must be exactly null," and it accepts null andundefined, because .any() lets undefined past the door before .valid() is even awake.

The fix is to say what you meant. Every .any() must end in one of .required() (reject null/undefined), .optional() (deliberately allow them), .default(x) (allow absence with a fallback), or .forbidden() (must be absent) — the rule does not care which, only that you picked one on purpose. This holds even when the .any() is nested inside items(...) or alternatives(...): an enclosing .required() governs the enclosure, not the .any() sitting inside it, however much you might hope otherwise.

adk/thought-payload-requires-replay-tag

If you set payload on a Thought, you must also set replayCompatibility. payload is the vendor-shaped opaque blob — OpenAI encrypted_content, Anthropic signed reasoning, Gemini thought signatures. These are not portable. They cannot be replayed into a different provider's API without knowing the wire format they were produced for. replayCompatibility is that knowledge, captured as a tag like 'openai-responses-encrypted-content-2025-10'. Without it, the payload is a frozen vendor-locked artifact that might silently fail or silently produce wrong output when replayed.

The ADK does not know what vendor shapes exist, and it is not going to guess. All it knows is that a payload with no tag is a Thought that can never be safely replayed — a sealed box with no label, that you will hand to some other provider months from now and watch detonate. The constructor rejects it at runtime; this rule catches it at the construction site, before it has the chance.

adk/token-encoding-requires-context-window

The Chat Completions batteries only do local token counting and context-overflow protection when both tokenEncoding (how to count) and contextWindow (the budget to count against) are set. Set tokenEncoding by itself and you have built the most pointless machine in the building: it counts tokens diligently, against no limit, enforcing nothing — all of the cost, none of the protection, the overflow guard standing there with nothing to guard. The adapters throw on this mismatch at iteration time; this rule flags it on the adapter options literal, before it ever gets that far.

adk/artifact-tool-forbids-artifact-constructor

An ArtifactTool answers queries against a spooled artifact — it emits a Tokenizable directly and must not itself return a SpooledArtifact. Because think about what happens if it does: the result gets wrapped into another artifact, whose query tools are themselves ArtifactTools, which return artifacts, which get wrapped, forever, all the way down, like a hall of mirrors nobody asked for. The base Tool accepts an artifactConstructor; ArtifactTool explicitly forbids it, and the constructor rejects it at runtime. This rule flags new ArtifactTool({ artifactConstructor }) at the construction site, before the recursion gets a chance to be clever.

adk/no-model-in-tool-handler

The executor owns the dispatch loop. A tool handler runs as one step the model proposed — it does work, it returns a result, that is the job. What it does not do is quietly start a second reasoning loop of its own, off the books, where the harness can't see it: no token accounting, no event surfacing, no abort propagation, just a model call freelancing inside a tool call. The one legitimate exception is a tool that is genuinely a sub-agent, running its own scoped dispatch — so this rule allows a handler that constructs a new TurnRunner(...) or calls the lower-level DispatchRunner.dispatch(...), and flags provider SDK constructors and chat/completion/message calls everywhere else inside a Tool / ArtifactTool handler.

The rules we deliberately didn't write

You will notice there is no "no model call in pipeline middleware" rule and no "executor must signal exactly once" rule. Those are real contracts — see Pipelines and BYO LLM — and we left them alone on purpose, because the AST cannot actually decide either one. Pipeline middleware calls models for perfectly good reasons (tool routers, planners, query rewriters), so a rule banning it would spend most of its life yelling at correct code; and "exactly once on every path" needs control-flow analysis a linter does not have and has no business pretending to. And here is the thing about a rule that cries wolf: you start ignoring it, and then you ignore it the one time it was right. A lint rule you have trained yourself to scroll past is worse than no rule at all. So those two stayed prose, where the judgment they require actually lives.