---
name: lint-against-cjs-in-esm-scripts
classification: hygiene
success_metric: future helper scripts that re-introduce CJS `require()` calls under `package.json` "type": "module" fail at lint time, not at first execution
status: shipped
created: 2026-05-26
---

# lint-against-cjs-in-esm-scripts (P3 polish — parent-owned)

**Priority:** P3 polish (one-line ESLint rule; no architect required)
**Convoy owner:** parent
**Opened:** 2026-05-26

## Background — the recurring bug pattern

Since `bump-next-js` flipped `package.json` to `"type": "module"`,
any helper script under `scripts/` that uses CJS `require()` throws
`ReferenceError: require is not defined` on Node 22.x at first run.
The same bug has now bitten the repo twice in two convoys:

1. **`drop-public-setup` Brief 2** (commit `b63b509`, 2026-05-23):
   `scripts/setup-neon-db.js` was still CJS post-`bump-next-js`; `npm
   run setup-db` was silently broken until Brief 2 swept it to ESM
   imports. The convoy retro called this out as "the seed script
   silently stopped executing after `bump-next-js`."
2. **`fix-reset-db-script` Brief 1** (commit `3ab9bf8`, PR #25,
   2026-05-26): three `require()` calls in `scripts/reset-db.js`
   (lines 10, 12, 142) — same bug, same blast radius (`npm run
   reset-db` throws `ReferenceError`), same fix shape (verbatim
   mirror of post-`drop-public-setup` `setup-neon-db.js`).

Both bugs were caught at first run, not at lint time. A small
ESLint rule scoped to `scripts/**/*.js` would have caught both at
PR time and is cheap insurance against a third recurrence.

## Design decision — `scripts/**` only (NOT all `.js`)

Two reasonable scopes:

- **`scripts/**/*.js` (chosen):** matches the actual blast radius —
  every observed instance of the bug has been in a helper script.
  Per-file-block override in `eslint.config.mjs` via a second flat-
  config entry. Zero impact on `pages/api/**` (already correctly
  ESM-imported throughout) and zero impact on the root `*.config.js`
  files (which are intentionally CJS-shaped and which the next-config
  base rules already handle correctly).
- **All `.js` files at repo root (rejected):** broader-than-necessary
  blast radius. `pages/api/**` already uses ESM `import` everywhere
  (`add-rate-limiting`, `cors-tighten`, and `add-route` skill all
  verified this in the last three months). A repo-wide ban would
  produce zero true positives outside `scripts/**` today and would
  risk breaking config-file shapes that legitimately use CJS
  (`postcss.config.js`, `tailwind.config.js` are flagged by
  `import/no-anonymous-default-export` today but read CJS-style
  exports under the hood — see also Gotcha #9 + #10).

The scoped rule is a 7-line flat-config block; the broader rule
would require explicit allowlist for every config file, which is
strictly more code and more maintenance.

## The fix

Add a new flat-config block at the end of `eslint.config.mjs` (after
the existing `globalIgnores(...)` call, NOT inside the root rules
block) targeting only `scripts/**/*.js`:

```js
{
  files: ['scripts/**/*.js'],
  rules: {
    'no-restricted-syntax': ['error', {
      selector: 'CallExpression[callee.name="require"]',
      message: 'Use ESM `import` syntax. `package.json` has "type": "module"; require() throws ReferenceError at runtime. See .convoys/fix-reset-db-script.md.',
    }],
  },
},
```

The error message points at `.convoys/fix-reset-db-script.md` so that
the next agent / contributor who triggers the rule gets a 1-click path
to the exemplar fix (ESM top-level imports for `dotenv`, `neon`,
`bcrypt`) instead of having to re-derive it.

`scripts/migrations/**` is already in `globalIgnores` (from
`pick-a-name` Brief 2's migration script) and stays ignored — the
rule does not fire there even though the migration script is ESM and
correctly uses `import` (no need to re-lint files already excluded).

The rule fires on `CallExpression[callee.name="require"]` — the AST
shape of a plain `require('foo')` call. It does NOT fire on
`createRequire(import.meta.url)` patterns (which use `Module.createRequire`)
should one ever be needed; the AST callee is `createRequire`, not
`require`. If a future helper script legitimately needs CJS interop,
the right path is `await import('foo')` (ESM dynamic import) — the
rule will not block that either.

## Verification plan

1. `node --check eslint.config.mjs` → exit 0 (config parses).
2. `npm run lint` → exit 1 with **128 problems (81 errors, 47
   warnings)** — verbatim match of the pre-convoy baseline (no
   regression, no new false positives in the current tree).
3. **Negative test (apply, run, revert):** prepend
   `const x = require('fs');` to `scripts/reset-db.js`, run
   `npm run lint`, confirm exit 1 with the new rule firing at the
   expected line/column and the documented message, then revert.
4. `npm run test:run` → 21/21 pass (no test surface touched; runs
   only to confirm vitest is still green).
5. `rg "require\(" scripts/ --type js` → 0 hits (sanity check
   confirming the current tree is clean and the rule has zero
   positives to silence on day 1).

## Risks

- **False positives if anyone legitimately needs `require()` in
  `scripts/**`.** None today (verified by step 5 — zero `require(`
  hits across all helper scripts in the current tree after PR #25
  and the `drop-public-setup` B2 sweep). If a future script
  legitimately needs CJS interop (e.g. a dependency that only
  exports CJS without an ESM wrapper), the fix is `await
  import('foo')` — ESM dynamic import works in any ESM script and
  is not flagged by the rule. If that's somehow not viable, the
  escape hatch is a per-line `// eslint-disable-next-line
  no-restricted-syntax` with a comment explaining why ESM doesn't
  work; lint baseline tracking will catch the disable directive in
  review.
- **Rule scope drift.** If someone adds a new top-level scripts
  directory (`tools/`, `cli/`, etc.) the rule won't fire there. Low
  risk — this repo has consolidated on `scripts/` since inception
  and there's no signal of a second scripts directory being added.
  Tracked here so the next refactor that reshapes the helper-script
  layout knows to extend the `files:` glob.
- **ESLint v10 bump.** When `bump-eslint-10` lands (currently
  upstream-blocked per Gotcha #10), re-verify this rule's selector
  syntax against the v10 AST behavior. `no-restricted-syntax` is a
  stable core rule going back to ESLint v1; no v10 deprecation is
  expected, but the smoke check is cheap.

## Acceptance criteria

- `node --check eslint.config.mjs` exit 0
- `npm run lint` exit 1 with 128 problems (baseline preserved)
- `npm run test:run` 21/21 pass
- Negative test passes (rule fires on synthetic `require()` insertion,
  reverts cleanly to 128 problems after the synthetic edit is
  removed)
- Grep: 0 `require(` occurrences in `scripts/**/*.js` (current tree
  is clean — rule starts with zero positives to silence)

## Out of scope

- Sweeping any other `scripts/**` file — current tree is clean
  (verified by the grep step above). The rule is preventative,
  not retroactive.
- Broadening the rule to all `.js` files at repo root — see
  § Design decision; `pages/api/**` is already correctly ESM and
  the config files (`postcss.config.js`, `tailwind.config.js`,
  `next.config.js`) intentionally use CJS-style exports that the
  next-config base rules handle correctly.
- Bumping any deps (ESLint stays at v9 per Gotcha #10; no
  `typescript-eslint` interaction since the rule is a core rule).
- The `purge-weak-creds-from-helpers` follow-up (the
  `scripts/create-test-users.js` portion remains queued; this
  convoy only adds the lint rule, not the weak-creds sweep).

## Owns

Parent (single-file ESLint config edit; no architect or implementer
subagent required — proven-pattern follow-up to PR #25).

## As-shipped

Single squash commit `13d6210` (PR #29, merged 2026-05-27T03:53:31Z
UTC / local 2026-05-26). Parent-owned end-to-end per the convoy spec
— no architect, no implementer subagent dispatched. Single-file
ESLint config edit following a proven-pattern follow-up shape; no
mid-execution surprises.

**Diff: 2 files, +185 / -0.** `eslint.config.mjs` (the 7-line
flat-config block added after the existing `globalIgnores(...)` call,
plus a leading comment block referencing the two motivating bugs) +
`.convoys/lint-against-cjs-in-esm-scripts.md` (the planning document,
committed atomically with the rule).

**The change shipped exactly as designed.** A new flat-config block
appended to `eslint.config.mjs`:

```js
{
  files: ['scripts/**/*.js'],
  rules: {
    'no-restricted-syntax': ['error', {
      selector: 'CallExpression[callee.name="require"]',
      message: 'Use ESM `import` syntax. `package.json` has "type": "module"; require() throws ReferenceError at runtime. See .convoys/fix-reset-db-script.md.',
    }],
  },
},
```

The error message points at `.convoys/fix-reset-db-script.md` so a
future contributor / agent who trips the rule gets a 1-click path to
the exemplar ESM fix shape (top-level `import dotenv from 'dotenv'`,
`import { neon } from '@neondatabase/serverless'`, `import bcrypt from
'bcryptjs'`) instead of having to re-derive it.

`scripts/migrations/**` is already in `globalIgnores` (from
`pick-a-name` Brief 2's migration script) and stays ignored — the
rule does not fire there even though the migration script is ESM and
correctly uses `import`. No need to re-lint files already excluded.

**Verification (all gates green at merge):**
- `node --check eslint.config.mjs` → exit 0 (config parses)
- `npm run lint` → exit 1 with **125 problems** (post-PR-#31 baseline
  preserved; **zero new false positives** in the current tree because
  both motivating bugs were already fixed: `setup-neon-db.js` swept
  by `drop-public-setup` B2 and `reset-db.js` swept by
  `fix-reset-db-script` PR #25)
- **Negative test verified (apply, run, revert):** prepending
  `const x = require('fs');` to `scripts/reset-db.js` fired the rule
  at the expected line/column with the documented message; reverting
  returned to a clean 125-problem lint. The rule starts with zero
  positives to silence on day 1, which is the documented success
  shape — preventative, not retroactive.
- `npm run test:run` → 21/21 pass (no test surface touched; verification
  only)
- `rg "require\(" scripts/ --type js` → 0 hits (sanity check
  confirming the current tree is clean)
- CI on PR #29: Lint ✓ | Vitest 21/21 ✓ | Playwright smoke 3/3 ✓ |
  `forbidden-endpoints` ✓ | `forbidden-cors-headers` ✓ | Vercel
  preview deploy ✓ | Aggregate gate ✓
- `Screenshot diff`: not triggered (PR #29 touches only
  `eslint.config.mjs` + this convoy file — neither matches the
  visual-diff `paths:` filter)

**Both motivating bugs WOULD have been caught at lint time.** Both
`drop-public-setup` Brief 2's pre-fix `scripts/setup-neon-db.js`
(three `require()` calls at lines 1-3 pre-fix) and `fix-reset-db-script`'s
pre-fix `scripts/reset-db.js` (three `require()` calls at lines 10,
12, 142 pre-fix) would have triggered the new rule at PR time
instead of throwing `ReferenceError: require is not defined` at
first execution. This is the exact "would have caught both bugs"
shape that motivated the queue entry in
`.convoys/ship-readiness.md`.

**Operator action required going forward:** **none.** The rule is
self-defending; no env vars, no secrets, no infra changes. Future
helper scripts under `scripts/**` that re-introduce CJS `require()`
fail at lint time with the documented message + the exemplar pointer
to `.convoys/fix-reset-db-script.md`.

**Spec deviation:** none. The flat-config block shipped exactly as
the convoy file's § The fix described it. Selector, message, scope
(`scripts/**/*.js` only — NOT all `.js`), and `globalIgnores`
interaction all match the spec verbatim.

**No follow-up surfaced.** Pairs naturally with the queued
`lint-against-lib-database` follow-up from `.convoys/single-sql-client.md`
(both are static-source guards added to `eslint.config.mjs`); they
could fold into a `harden-eslint-static-guards` convoy if more such
guards accumulate.
