Skip to content

chore(repo): lint every package, not just the ones that declare a script - #646

Merged
rejifald merged 1 commit into
mainfrom
claude/github-issue-loop-b66490
Aug 5, 2026
Merged

chore(repo): lint every package, not just the ones that declare a script#646
rejifald merged 1 commit into
mainfrom
claude/github-issue-loop-b66490

Conversation

@rejifald

@rejifald rejifald commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes #457

The hole

pnpm -r check:lint fans out only to packages that define a check:lint script. Exactly one did — packages/core — so ESLint had never run against the other 35 packages/*, the repo's no-restricted-syntax ratchet included. That is why the banned ...(x !== undefined ? { k: x } : {}) spread in #451 passed every local gate while the identical pattern in core failed: core just happened to own the script.

(The issue names @stitchapi/download as the package that regressed; there is no such package on main today, so this PR covers the 35 that do exist.)

Approach: a single root pass (the issue's option b), sharded

check:lint is now node scripts/check-lint.mjs, which enumerates packages/* on disk and lints each one's src/ and test/. Coverage no longer depends on a package opting in, so a new package is gated the moment it exists — the failure mode the issue asked to close. Per-package scripts (option a) would have left the same hole one package.json edit away.

Rules move from packages/core/eslint.config.ts to a root eslint.config.ts, unchanged. The check that the move preserved core's semantics exactly: core's eslint-suppressions.json regenerates byte-for-byte identical.

Proof the ratchet works — re-introducing the #451 spread in @stitchapi/redis (a package that has never been linted):

506:14  error  Use compact({ ...obj, key: value }) to omit undefined keys …  no-restricted-syntax
✗ eslint failed in 1 of 1 package(s): redis

…and clean again when reverted.

Why one process per package, not one pass

The rules are type-aware, so a single whole-workspace pass must hold all 36 TypeScript programs in one heap. It dies with SIGABRT at ~4.7 GB RSS under Node's default heap — with a tsconfig glob and with projectService — which would have made the gate unrunnable in CI. Sharded four-up it peaks at 1.3 GB and finishes in ~10s, faster than the single pass. Per-package cwd also anchors parserOptions.project and the eslint-suppressions.json lookup, which is why core's baseline keeps working untouched.

CI and lefthook already call pnpm check:lint, so verify.yml and lefthook.yml are unchanged.

What the pass surfaced: 554 violations

Fixed — 211

Rule n How
no-restricted-syntax 1 The #451 class, in @stitchapi/vercel-ai. A pre-existing comment there already explained compact is wrong (parameters/inputSchema are required unknown keys it would optionalize), so it takes the inline disable core's own config prescribes for that case and already uses at engine.ts:1380.
require-yield 1 rtk-query's never-ending stream fake — yield undefined as never after the never-settling await.
no-prototype-builtins 3 sandbox-sim → Object.hasOwn.
unused directive 1 dead no-eval disable in openapi.
no-unused-vars 2 genuinely unused import + TextEncoder in sandbox-sim.
mechanical ~203 --fix restricted to fixers that cannot change behaviour: array-type, consistent-type-definitions, consistent-type-imports, no-unnecessary-type-arguments/-assertion/-conversion, prefer-optional-chain/-includes/-regexp-exec. Plus the same optional-chain fix hand-applied to 7 copies of one test helper the fixer wouldn't touch.

Two things worth flagging from the --fix:

  • --fix-type excludes directive. A plain --fix deletes eslint-disable comments it thinks are unused and ate ten of them — core's no-restricted-syntax justification included — on the first attempt. Caught and redone.
  • The --fix rewrote elysia's StitchContext from a type to an interface, breaking the exact constraint the comment directly above it warns about (an interface gets no implicit index signature → fails Elysia's SingletonBase). Reverted, with the disable that comment always implied. pnpm check:types catches this: elysia is green on main and was red with the rewrite.

Baselined — 343

Per package, via ESLint's bulk-suppressions ratchet — the mechanism core already used, and which git log shows being burned down rather than grown. New violations fail; these do not. No blanket eslint-disable comments were added.

They are dominated by work needing real per-package decisions, not lint edits:

  • 120 no-unsafe-member-accessfingerprint-zod/-effect walking untyped third-party schema internals. Fixing means typing those internals.
  • 41 only-throw-error — the fingerprint packages throw non-Error values. Changing that changes public behaviour, so per the brief I left it.
  • 37 no-confusing-void-expression, 32 no-empty-function, 26 no-unnecessary-condition (defensive guards for untyped callers), and a long tail.

Regenerate/burn down with node scripts/check-lint.mjs --suppress-all / --prune-suppressions (verified idempotent).

Config decisions (the complete list)

Two rule options — not suppressions — because the alternative was editing code that is already correct:

  1. no-unused-vars gets ^_ ignore patterns. Every package compiles under noUnusedLocals/noUnusedParameters, which TypeScript itself exempts _-prefixed bindings from. 54 of the 55 hits were the compiler's own convention; without this the two gates contradict each other on the same file. Same reasoning as the dot-notation tuning core already documents.
  2. vitest/expect-expect learns assertConformance, the shared store-contract runner, so those suites stop reading as assertion-free.

Three inline disables, each with a -- justification: the vercel-ai spread, the elysia type, and one deno-kv type-level test whose assertion is a @ts-expect-error (nothing to expect()).

One plain-JS accommodation: .js/.mjs/.cjs drop to the syntactic rule set, because type-aware rules throw rather than skip with no program. This keeps completions-plugin (pure .mjs, no tsconfig) in the gate instead of excluded.

Drive-by the gate required

packages/sandbox-sim/tsconfig.test.json gets "exclude": []. exclude is inherited through extends, so the base config's **/*.test.ts carried over and this project resolved to zero test files — dead config that nothing ran, despite its own comment claiming it type-checks the suite. It now covers its 9 tests and tsc -p tsconfig.test.json passes. Needed here because those tests are colocated in src/ and had no lint project.

Deferred

  • The 343 baselined violations above.
  • Non-standard directories stay out of scope: eval-harness's runner/score/tasks, core's bin/scripts/test-d. The runner covers src + test, per the issue.
  • react-hooks/exhaustive-deps is referenced by a disable comment in @stitchapi/react but the plugin isn't installed; baselined rather than adding a dependency in this PR.
  • scripts/check-companion-exports.mjs has the same opt-in bug — a hardcoded 8-package list. Untouched here.

Verification

Every command run from the repo root, all green:

Command Result
pnpm check:lint ✓ eslint clean across 36 packages (was: core only)
pnpm check:types ✅ exit 0
pnpm test ✅ exit 0, 34 suites
pnpm check:format ✅ exit 0 (after pnpm format)
pnpm check:contract ✅ exit 0
pnpm check:unknown-keys ✅ exit 0
pnpm check:types-d ✅ exit 0
pnpm check:exports ✅ exit 0
pnpm check:exports:companions ✅ attw clean, 8/8
pnpm check:changelog / check:release / check:docs-links ✅ exit 0

Baseline confirmed by stashing: check:format and check:types were green on origin/main before this branch, so the reformatting and the elysia catch are attributable to this change, not pre-existing drift.

🤖 Generated with Claude Code

`pnpm -r check:lint` fans out only to packages that DEFINE a `check:lint`
script. Exactly one did — core — so ESLint had never run against the other 35
`packages/*`, including the repo's own `no-restricted-syntax` ratchet. A banned
`...(x !== undefined ? { k: x } : {})` spread in a companion passed every local
gate (#451); the identical pattern in core failed, purely because core happened
to own the script.

`check:lint` is now `node scripts/check-lint.mjs`, which enumerates
`packages/*` on DISK and lints each one's `src/` and `test/`. Coverage no longer
depends on a package opting in, so a new package is gated the moment it exists.
Verified by probe: re-introducing the #451 spread in `@stitchapi/redis` fails
the gate, and passes again when reverted.

Rules move from `packages/core/eslint.config.ts` to a root `eslint.config.ts`,
unchanged. Core's `eslint-suppressions.json` regenerates byte-for-byte
identical under the new setup, which is the check that the move preserved its
semantics exactly.

One eslint process per package, not one whole-workspace pass. The rules are
type-aware, so a single pass has to hold all 36 TypeScript programs in one heap:
that dies with SIGABRT at ~4.7 GB RSS under Node's default heap — with a
tsconfig glob AND with `projectService` — which would have made the gate
unrunnable in CI. Sharded, it peaks at 1.3 GB and finishes in ~10s (faster than
the single pass, since the shards run four-up). Per-package cwd also anchors
`parserOptions.project` and the `eslint-suppressions.json` lookup, which is why
core's baseline keeps working untouched.

The pass surfaced 554 violations. 211 are fixed here:

- The `no-restricted-syntax` spread in `@stitchapi/vercel-ai` — the #451 class.
  A pre-existing comment already explained that `compact` is wrong here
  (`parameters`/`inputSchema` are required `unknown` keys it would optionalize),
  so it takes the inline disable core's config prescribes for that case and
  already uses at engine.ts:1380.
- `require-yield` in rtk-query's never-ending stream fake; `no-prototype-builtins`
  in sandbox-sim (→ `Object.hasOwn`); a dead `no-eval` directive in openapi; an
  unused import and an unused `TextEncoder` in sandbox-sim.
- ~200 mechanical fixes from a `--fix` pass restricted to fixers that cannot
  change behaviour (`array-type`, `consistent-type-definitions`,
  `consistent-type-imports`, `no-unnecessary-type-arguments`/`-assertion`/
  `-conversion`, `prefer-optional-chain`/`-includes`/`-regexp-exec`), plus the
  same optional-chain fix hand-applied to seven copies of one test helper.
  `--fix-type` excludes `directive`, because a plain `--fix` deletes
  `eslint-disable` comments it considers unused and ate ten of them — core's
  included — on the first attempt.

The `--fix` also rewrote elysia's `StitchContext` from a `type` to an
`interface`, breaking the constraint the comment directly above it warns about
(an interface gets no implicit index signature, so it fails Elysia's
`SingletonBase`). Reverted, with the disable that comment always implied.
`pnpm check:types` catches it — elysia is green on main and was red with the
rewrite.

The remaining 343 are baselined per package with ESLint's bulk-suppressions
ratchet — the mechanism core already used, and which the history shows being
burned down rather than grown. They are dominated by work that needs real
per-package decisions, not lint edits: 120 `no-unsafe-member-access` from the
fingerprint packages walking untyped third-party schema internals, and 41
`only-throw-error` whose fix would change what those packages throw, i.e. public
behaviour. New violations fail; these do not.

Two rule options rather than suppressions, both because the alternative was
editing code that is already correct:

- `no-unused-vars` gets `^_` ignore patterns. Every package compiles under
  `noUnusedLocals`/`noUnusedParameters`, which TypeScript itself exempts
  `_`-prefixed bindings from — so 54 of the 55 hits were the compiler's own
  convention, and the two gates disagreed about the same files.
- `vitest/expect-expect` learns `assertConformance`, the shared store-contract
  runner, so those suites stop reading as assertion-free.

Also here because the gate needs them: `sandbox-sim/tsconfig.test.json` gets
`"exclude": []` — `exclude` is inherited through `extends`, so the base config's
`**/*.test.ts` carried over and the project resolved to ZERO test files, making
it dead config that nothing ran. It now covers its 9 tests and typechecks clean.
Plain-JS packages (`completions-plugin`, .mjs with no tsconfig) drop to the
syntactic rule set instead of being excluded, since the type-aware rules throw
rather than skip when there is no program.

CI and the lefthook hooks already call `pnpm check:lint`, so nothing in
verify.yml or lefthook.yml changes.

Closes #457

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rejifald
rejifald merged commit 2398e91 into main Aug 5, 2026
12 checks passed
@rejifald
rejifald deleted the claude/github-issue-loop-b66490 branch August 5, 2026 14:36
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.

chore(repo): companion packages aren't covered by pnpm -r check:lint

1 participant