Updates ship-readiness.md, AGENTS.md, and 7 convoy files to reflect the as-shipped state of the 2026-05-26 7-convoy multitask wave: - PR #26 tighten-visual-diff-path-filter (P3) - PR #27 purge-weak-creds-from-helpers (P2, closes the umbrella) - PR #28 cleanup-mobile-nav-dead-props (P3) - PR #29 lint-against-cjs-in-esm-scripts (P3, surfaced by PR #25) - PR #30 single-sql-client (P1 #8 RESOLVED) - PR #31 single-auth-provider (P1 #9 RESOLVED) - PR #32 migration-tool (P1 #11 RESOLVED) Milestone: 5 of 6 P1 quality items RESOLVED. Only fix-lint-baseline (P1 #11.5) remains in the P1 lane. Newly queued follow-ups: - purge-quick-login-from-loginpage (surfaced by PR #27) - purge-neondatabase-serverless-fully (surfaced by PR #30, unblocked by PR #32's migration tool adoption) Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
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: open
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:
drop-public-setupBrief 2 (commitb63b509, 2026-05-23):scripts/setup-neon-db.jswas still CJS post-bump-next-js;npm run setup-dbwas silently broken until Brief 2 swept it to ESM imports. The convoy retro called this out as "the seed script silently stopped executing afterbump-next-js."fix-reset-db-scriptBrief 1 (commit3ab9bf8, PR #25, 2026-05-26): threerequire()calls inscripts/reset-db.js(lines 10, 12, 142) — same bug, same blast radius (npm run reset-dbthrowsReferenceError), same fix shape (verbatim mirror of post-drop-public-setupsetup-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 ineslint.config.mjsvia a second flat- config entry. Zero impact onpages/api/**(already correctly ESM-imported throughout) and zero impact on the root*.config.jsfiles (which are intentionally CJS-shaped and which the next-config base rules already handle correctly).- All
.jsfiles at repo root (rejected): broader-than-necessary blast radius.pages/api/**already uses ESMimporteverywhere (add-rate-limiting,cors-tighten, andadd-routeskill all verified this in the last three months). A repo-wide ban would produce zero true positives outsidescripts/**today and would risk breaking config-file shapes that legitimately use CJS (postcss.config.js,tailwind.config.jsare flagged byimport/no-anonymous-default-exporttoday 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:
{
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
node --check eslint.config.mjs→ exit 0 (config parses).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).- Negative test (apply, run, revert): prepend
const x = require('fs');toscripts/reset-db.js, runnpm run lint, confirm exit 1 with the new rule firing at the expected line/column and the documented message, then revert. npm run test:run→ 21/21 pass (no test surface touched; runs only to confirm vitest is still green).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()inscripts/**. None today (verified by step 5 — zerorequire(hits across all helper scripts in the current tree after PR #25 and thedrop-public-setupB2 sweep). If a future script legitimately needs CJS interop (e.g. a dependency that only exports CJS without an ESM wrapper), the fix isawait 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-syntaxwith 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 onscripts/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 thefiles:glob. - ESLint v10 bump. When
bump-eslint-10lands (currently upstream-blocked per Gotcha #10), re-verify this rule's selector syntax against the v10 AST behavior.no-restricted-syntaxis 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.mjsexit 0npm run lintexit 1 with 128 problems (baseline preserved)npm run test:run21/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 inscripts/**/*.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
.jsfiles 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-eslintinteraction since the rule is a core rule). - The
purge-weak-creds-from-helpersfollow-up (thescripts/create-test-users.jsportion 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:
{
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.jsswept bydrop-public-setupB2 andreset-db.jsswept byfix-reset-db-scriptPR #25)- Negative test verified (apply, run, revert): prepending
const x = require('fs');toscripts/reset-db.jsfired 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 onlyeslint.config.mjs+ this convoy file — neither matches the visual-diffpaths: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.