# Linting & validation

Annex of [`AGENTS.md`](../AGENTS.md). Read this file when adding lint rules or weighing an `eslint-disable-next-line`.

ESLint v10 flat config lives at `src/eslint.config.js`. Run from any cwd:

- `pnpm lint`, lints every workspace that declares a `lint` script (today: `src/` only; `ui/` joins later).
- `pnpm lint:fix`, same with `--fix`.
- `pnpm validate`, semantic alias for "all static checks". Currently delegates to lint across workspaces; expand here when more static checks land (typecheck-all, doctest, etc.).

CI (`.github/workflows/ci.yml` → `build-test` job) runs `pnpm validate` after typecheck, before build. Both errors AND warnings block CI, there are no `'warn'` rules in the config.

**Rule severity policy**: every quality rule is `'error'` (post-complexity-sweep). The categories below cover what's enforced; new rules should land at `'error'` from day one or come with a justified disable plan.

- **Architectural invariants** (kernel-isolation): no `console.*` in `kernel/**`; no `process.cwd` / `process.env` in `kernel/**`; no `cli/**` imports from `kernel/**`; relative ESM imports terminate in `.js`. Cannot regress.
- **Legacy hand-curated rules**: `complexity` (max 8), `eqeqeq`, `no-var`, `no-eval`, `no-throw-literal`, `block-scoped-var`, `no-fallthrough`, `no-useless-return`, `no-else-return`, `no-extra-boolean-cast`, `curly`, `no-console` (allow `[warn,error,log]` in CLI).
- **Quality rules**: `@typescript-eslint/explicit-module-boundary-types`, `@typescript-eslint/no-empty-function`, `preserve-caught-error`, `no-irregular-whitespace` (with `skipStrings/Comments/RegExps/Templates`), `no-useless-assignment`.
- **Stylistic**: `@stylistic/quotes` single, `@stylistic/semi`, `@stylistic/linebreak-style` unix, `@stylistic/no-multi-spaces`, `@stylistic/newline-per-chained-call` depth 4.

**When `eslint-disable-next-line` is acceptable**: only the categories below, each with a comment explaining which one. Anything else means the function should be split or the code rewritten, not disabled.

1. **CLI orchestrators with multi-flag handling**: `execute()` methods on Clipanion `Command` classes that fan out over many flags (each `if (this.flag) ...` is one cyclomatic branch). Splitting per branch scatters the validation away from the flag it gates. Examples: `cli/commands/{scan,db,history,refresh,init,sidecar,config,...}.ts:execute`.
2. **Char-by-char parsers / state machines**: each character produces a state transition; splitting per state mode hides the dispatcher loop. Examples: `kernel/scan/query.ts:parseExportQuery`. (`plugin-migrations-validator.ts:splitStatements` and `objectName` were split into per-mode scanner helpers in the 2026-05-12 sqlite sweep, the original disable was lift-able.)
3. **Multi-accumulator folds**: one row contributes to N independent accumulators in one pass. Examples: `conformance/index.ts:applyJsonPathComparator`. (`history.ts:accumulateExecutionRow` was split into 4 per-rollup accumulators in the 2026-05-12 sweep; the per-rollup shape is small enough to stand on its own when the accumulators are loosely coupled.)
4. **Migration runners with per-file safe-apply**: each iteration is `BEGIN / try / catch + rollback / COMMIT`; the structure IS the contract. (`migrations.ts:applyMigrations` and `plugin-migrations.ts:applyPluginMigrations` were split in the 2026-05-12 sweep, the per-migration try/catch landed in `applyOneMigration` / `applyOnePluginMigration`; the loop body in the runner shrinks to one call. Use this category only when the runner body itself drives multiple distinct stages, e.g. validate-all-then-run-all in `applyPluginMigrations` was reducible.)
5. **Pure column mappers**: object literals where every `??` adds a cyclomatic branch despite there being zero control flow. The 2026-05-12 sweep showed that splitting per logical cluster (`projectSidecarPresence`, `projectAnnotationColumns`, `projectTokenCounts`, etc., each returning a `Pick<Insertable<…>>`) is cheap and keeps the main mapper readable, prefer that over `eslint-disable`. Examples: `kernel/adapters/sqlite/scan-persistence.ts:nodeToRow` / `linkToRow` (post-sweep, see how the projectors compose via spread).
6. **Discriminated-union dispatchers**: one branch per shape variant (`switch (a.type) { case ... }`). Splitting per case scatters the central dispatch table. Examples: `conformance/index.ts:evaluateAssertion`, `extensions/rules/{trigger-collision,link-conflict}/index.ts:evaluate`, `kernel/extensions/hook-dispatcher.ts:buildHookContext`.
7. **Recursive type-discriminator walkers**: one function recurses over an arbitrary JSON-shaped value with per-type branches (primitive vs array vs object vs null) that ARE the type table. Splitting per branch rebuilds the discriminator elsewhere. Examples: `cli/commands/config.ts:iterDotPaths`, `kernel/config/loader.ts:recordSources`, `kernel/scan/walk-content.ts:walkRoot`, `conformance/index.ts:deepEqual`, `built-in-plugins/analyzers/unknown-field/index.ts:evaluate` (classifies each sidecar key against catalog / plugin namespace / unknown root buckets).

If a function does not fit one of those seven and is above the threshold, **split it** (see commits `91fea6a` … `aa550a6` for the canonical split patterns: extract per-branch helpers, return discriminated unions for early exits, use small focused render-section helpers for output assembly). The 2026-05-12 sweep added a sixth pattern explicitly: **bundle multi-accumulator state into a typed bag** (`IWalkAccumulators` in `kernel/orchestrator/walk.ts`) so per-phase helpers take 2 args instead of 10+; lets a 380-LOC monolithic loop body decompose into linear `processRawNode → applyFullCacheHit | applyExtractPath` dispatch.

**Where to document the exception**: every `eslint-disable-next-line complexity` MUST carry a 1-to-3-line rationale immediately above the disable. The rationale states which category from the list above applies and one phrase on why splitting would hurt readability. Audited inventory landed in the 2026-05-12 sweep, no orphan disables remain.

**`ui/` workspace**: no lint config yet. When configuring it, add `lint` / `lint:fix` scripts to `ui/package.json` and an `ui/eslint.config.js` (probably with `@angular-eslint`). `pnpm validate` picks them up automatically via `pnpm -r --if-present`.
