ESM Merge Coverage (apps/app)
apps/app is native ESM since v8 (master is the v8 mainline), so every merge from a
pre-ESM branch reopens the migration for the files it carries. A git merge copies
source verbatim — it does not run any codemod, lint, or build step — so code written in
the old style (require, module.exports, __dirname, .js in specifiers) lands
unconverted and only fails later at build:server type-check, verify-dist-resolution,
or runtime (ERR_MODULE_NOT_FOUND, Cannot access 'X' before initialization).
This skill is the coverage pass that runs after such a merge: detect the incoming non-ESM sources, convert them with the migration's own tooling, isolate what must stay CJS, and drive the verification gates to green — finishing the merge in one pass while flagging the few spots that need human judgment.
Premise: the tooling listed under Tools is present on the target branch. This skill orchestrates that tooling; it does not reimplement it. If a referenced tool is missing, stop and say so rather than improvising a substitute (
tool-manifest.spec.tsnext to this file fails CI when a path drifts).
When to use
- Right after
git merge <pre-ESM-branch>into master or any v8 branch, before committing the merge or opening a PR. - When
lint:no-cjs,lint:import-convention,build:server, orverify-dist-resolutionstart failing on files that were green before a merge. - Not for net-new code authored on an ESM branch — that is covered by the normal lint gates
and
.claude/rules/esm-authoring.md. This skill is specifically for imported code that predates the convention.
Operating mode
Autonomous: detect → convert → isolate → configure → verify, then report. Apply the mechanical transforms without asking. Only pause to ask for the genuinely ambiguous spots listed under Stop and ask — central-router factory DI, new intentional cycle-breakers, and circular-dependency hazards.
Step 1 — Scope the incoming files
Diff against the merge base, not the working tree, so you only touch what the merge brought in:
# files added/changed by the merged branch relative to the common ancestor
git diff --name-only "$(git merge-base HEAD MERGE_HEAD)"..MERGE_HEAD -- apps/app
# if the merge is already committed, use the first parent as the ESM base:
git diff --name-only HEAD^1..HEAD -- apps/app
Partition the result:
| Bucket | Glob | Treatment |
|---|---|---|
| Server source | apps/app/src/server/**/*.{ts,js} |
Full CJS→ESM conversion (Step 2) |
| Other src | apps/app/src/**/*.{ts,tsx} (client, states, stores, utils) |
Import-convention only (Step 3) |
| Config consumed by CJS tools | apps/app/config/*.js, new *.config.js |
CJS isolation — .cjs (Step 4) |
| Migrations | apps/app/src/migrations/*.js |
Convert to ESM with named exports (Step 4) |
| Package manifests | new packages/*/package.json, apps/*/package.json |
"type": "module" (Step 5) |
| Build config | next.config.ts, pnpm-workspace.yaml |
transpilePackages / overrides re-eval (Step 5) |
Skip node_modules and generated dirs. src/migrations/** gets its own codemod
(Step 4) — do not run the server codemod over it.
Step 2 — Convert server CJS → ESM
Run the migration codemod on the server buckets, leaf→root (models/events first, central routers last) so circular-dependency breakage surfaces in the smallest possible step:
cd apps/app
node tools/codemod/cjs-to-esm.cjs src/server/models src/server/events
node tools/codemod/cjs-to-esm.cjs src/server/service
node tools/codemod/cjs-to-esm.cjs src/server/middlewares src/server/util src/server/pageserv
node tools/codemod/cjs-to-esm.cjs src/server/routes # central routers (index.js) last
node tools/codemod/cjs-to-esm.cjs src/server/crowi
cjs-to-esm.cjs handles 8 patterns:
module.exports = …/exports.x = …→export …(named / default)const x = require('./x')→import x from './x'require('./x')(crowi, app)factory invoke →import { setup } from './x'+ explicitconst x = setup(crowi, app)- ternary × factory invoke (non-async enclosing scope) → top-level hoisted
import+ invoke per branch const { x } = require('pkg')→import { x } from 'pkg'require('pkg').member(…)→import { member } from 'pkg'require(dynamicVar)(ctx)→(await import(dynamicVar)).default(ctx)— do NOT add instance memoization (the ESM loader caches modules; memoizinggetUploader()broke thesetUpFileUpload(isForceUpdate=true)re-init contract once already)- exclusion list — intentional lazy
requires are skipped (e.g.crowi/index.tssetupMailer's~/server/service/mail)
Then fix what the codemod intentionally leaves to a human:
__dirname/__filename→import.meta.dirname/import.meta.filename(manual; e.g.crowi/index.ts,crowi/dev.js,service/i18next.ts).- New intentional lazy
requireacting as a cycle-breaker → keep it, and add it to theEXCLUSION_LISTintools/codemod/cjs-to-esm.cjsso future runs leave it alone. - Config specifiers to
~/config/migrate-mongo-config→ ensure they carry.cjs(the codemod rewrites these; verify after). The i18next configs are.mjsand need no suffix in the specifier.
Circular-dependency rule (do not skip)
crowi/index.ts is the dependency hub — most server cycles route through it. Under CJS,
require's lazy eval hid these; under ESM static hoisting they throw
ReferenceError: Cannot access 'X' before initialization at boot.
Invariant: a service/event/model file must never import the Crowi class directly. It
receives the Crowi instance as an argument (factory DI). If a merged file imports Crowi
at module top level to read a member, that is the bug — rewrite it to take crowi as a
parameter. If a new cycle cannot be broken by argument-passing, split the shared types into
an interfaces.ts (as search-delegator did) rather than lazy-loading on a hot path
(auth/ACL per-request).
Step 3 — Normalize import convention (no extensions)
Pre-ESM branches usually have no extensions (fine), but merge-conflict resolutions and
branches that were themselves partway through an ESM conversion frequently reintroduce
.js/.jsx. Strip them across all touched src files:
cd apps/app
node tools/codemod/normalize-import-convention.cjs src # strips .js/.jsx, normalizes /index barrels
Extensions are only ever added to build output, by
bin/add-js-extensions.tsoverdist/. In source you only ever strip.
The hard rule (apps/app/.claude/rules/import-convention.md): never write .js/.jsx in a
relative (./, ../) or ~/ specifier — value and type-only alike. .js is added only in
the build output by bin/add-js-extensions.ts, never in source. normalize-import-convention.cjs
is purely lexical: it strips extensions and normalizes ./sub/index.js → ./sub, ./index.js → .,
while preserving each specifier's authored alias-vs-relative form. The alias-vs-relative
choice is a readability matter and is not linted — follow the natural convention by hand
(nearby = relative, distant/cross-area = ~/).
Step 4 — Convert migrations, isolate what genuinely must stay CJS
New migrations (
src/migrations/*.js) → convert to ESM. Migrations are ESM on this branch (there is nosrc/migrations/package.jsonisolation any more), andmigrate-mongoreadsmigration.up/migration.downoff the loaded module, so they need named exports —export async function up() {}, nevermodule.exports = {…}(which throws undertype: module) and neverexport default { up, down }(which leavesmigration.upundefined).cd apps/app node tools/codemod/migrations-cjs-to-esm.cjs src/migrations # requires jscodeshift installedA merge typically leaves a hybrid file — ESM
importat the top plus a CJSmodule.exportsat the bottom — which passes review by eye and dies at run time. Ifjscodeshiftis not installed in the current environment, apply the codemod's transformation by hand and verify withnode --check.New config files consumed by a CJS-only CLI (
migrate-mongois the remaining one) → keep them.cjs, with a hand-written.d.ctssibling, and update every importer specifier to.cjs. Config consumed by ESM-capable tooling should be.mjs(config/i18next.config.mjs,config/next-i18next.config.mjsare the precedent).
Step 5 — Package & build config
- New buildable package without
"type"→ add"type": "module"(unless it must stay CJS, which then needs.cjsentry points). New deps inbin/workspace default to CJS and need no change. - New runtime deps pulled in by the merge → re-evaluate
next.config.tsgetTranspilePackages()(remove anything that resolves natively as ESM; keep + inline-comment what genuinely needs it) andpnpm-workspace.yamloverrides (only CJS-pin entries — never touchaxiosor other security pins).
Step 6 — Verification gates (the safety net)
Run in order; a clean run is the proof the coverage pass is complete. Do not declare done on conversion alone — these gates catch what the codemods missed.
cd apps/app
pnpm run lint:no-cjs # residual require/module.exports in src/server
pnpm run lint:import-convention # any .js/.jsx left in relative/~ specifiers
pnpm run lint:route-guard # central-router top-level invariant
pnpm run build:server # Bundler type-check of extensionless source (tsgo/tspc)
pnpm run postbuild:server # add-js-extensions over dist
node bin/verify-dist-resolution.ts dist # exhaustive: every dist import points to a real file
pnpm run server:ci # boot smoke — loads every module (catches init-time cycles)
verify-dist-resolution is the strongest gate: it checks the emitted dist/ graph
exhaustively (including lazy/conditional imports) and does not false-positive on dead
.tsx→.jsx emit. A single unresolved entry fails CI — chase it back to the source specifier.
Stop and ask
Pause and use AskUserQuestion (do not guess) when:
- A central router (
routes/index.js,routes/apiv3/index.js) factory-DI conversion is ambiguous — these concentrate dozens of injected setups and a wrong rewrite can silently drop a middleware or change an auth path. - You find a new circular dependency that argument-passing alone can't break (needs a structural split decision).
- A merged file mixes ESM and CJS in a way the 8 patterns don't cover, or a
requirelooks intentional-lazy but isn't in the exclusion list.
Report
Summarize: files converted (by bucket), specifiers normalized, configs isolated, gates run
and their result, and an explicit list of unresolved / human-judgment items
(verify-dist-resolution unresolved entries, new exclusion-list additions, central-router
conversions, cycle splits). The merge is not done until the gates are green and that list is
empty or explicitly accepted.
Tools
The tools live in the repo (inside Biome's and CI's reach), not in this skill directory;
this list is the binding between them and tool-manifest.spec.ts fails when a path
drifts. All paths are relative to apps/app/.
| Tool | Role |
|---|---|
tools/codemod/cjs-to-esm.cjs |
CJS→ESM conversion of server source (the 8 patterns above). Migration-only: it exists for pre-ESM merges |
tools/codemod/migrations-cjs-to-esm.cjs |
migrate-mongo migrations → ESM with named up/down |
tools/codemod/normalize-import-convention.cjs |
Strips .js/.jsx from relative / ~/ specifiers, normalises /index barrels (also the everyday batch fixer for the lint below) |
tools/lint/import-extension-guard.cjs |
lint:import-convention — fails on any .js/.jsx in a relative / ~/ specifier |
tools/lint/route-top-level-guard.cjs |
lint:route-guard (central-router top-level invariant) and lint:no-cjs (--cjs-only) |
bin/add-js-extensions.ts |
Post-build: adds .js / /index.js / .jsx to dist/ specifiers |
bin/verify-dist-resolution.ts |
CI: every relative import in dist/ points at a real file |
bin/postbuild-server.ts |
postbuild:server — moves transpiled/ → dist/ and invokes add-js-extensions |
The codemods need jscodeshift (a devDependency); it is occasionally missing in a fresh
environment, in which case both the codemods and lint:import-convention fail to run —
install first rather than hand-editing at scale.
Related documentation
apps/app/.claude/rules/import-convention.md— the no-extension convention (canonical).apps/app/.claude/rules/esm-authoring.md— native-ESM traps that survive build and boot (JSON import attributes,__dirname, CJS default-import interop, TS2742, the no-Crowi-import cycle invariant). Read it before converting anything by hand.apps/app/.claude/skills/app-commands/SKILL.md— smoke-testing procedures, including the authorization-matrix regression check and the external-plugin install smoke.