Skip to content

fix: add CJS require() support to package exports#472

Merged
carlos-alm merged 9 commits intomainfrom
fix/cjs-exports-455
Mar 17, 2026
Merged

fix: add CJS require() support to package exports#472
carlos-alm merged 9 commits intomainfrom
fix/cjs-exports-455

Conversation

@carlos-alm
Copy link
Contributor

Summary

  • Add require and default conditions to the exports field in package.json so CJS consumers no longer get ERR_PACKAGE_PATH_NOT_EXPORTED
  • Add thin CJS wrapper (src/index.cjs) that delegates to ESM via module.exports = import('./index.js')
  • Add test verifying CJS wrapper resolves to the same exports as the ESM entry point

Usage

// ESM (unchanged)
import { buildGraph } from '@optave/codegraph';

// CJS (now works)
const codegraph = await require('@optave/codegraph');

Test plan

  • require('./src/index.cjs') returns a Promise that resolves to all 56 exports
  • ESM import continues to work unchanged
  • New test in tests/unit/index-exports.test.js passes
  • Lint clean

Closes #455

Add `require` and `default` conditions to the exports field and a thin
CJS wrapper (`src/index.cjs`) that delegates to ESM via dynamic
import(). CJS consumers can now `await require('@optave/codegraph')`.

Closes #455
@claude
Copy link

claude bot commented Mar 16, 2026

Claude finished @carlos-alm's task —— View job


PR Review: CJS require() Support

Todo List:

  • Read changed files and understand implementation
  • Perform root cause analysis
  • Check for configuration changes that might mask issues
  • Review test integrity and code quality
  • Assess backlog compliance per FOUNDATION.md
  • Provide final rating and recommendation

Root Cause Analysis

Problem Being Solved: Real compatibility issue where CJS consumers get ERR_PACKAGE_PATH_NOT_EXPORTED when attempting require('@optave/codegraph'). The package.json exports field only defined an import condition without require or default, making the package unusable for CJS projects.

Why This Approach: The solution uses the standard dual-module approach recommended by Node.js - adding require and default conditions to package exports, with a thin CJS wrapper that delegates to ESM via dynamic import. This is the canonical pattern for ESM-first packages that need CJS compatibility.

Risk Assessment: Low risk. The changes are minimal, additive-only, and follow Node.js best practices. The CJS wrapper uses the async pattern (module.exports = import('./index.js')) which is the safest approach for ESM→CJS bridging.

Backlog Compliance

  • Zero-dep: ✓ — No new runtime dependencies added
  • Foundation-aligned: ✓ — Fully compliant with all 8 principles, enhances embeddability (P5)
  • Problem-fit: 3/5 — Improves API accessibility but doesn't directly address the core "lost AI" problem
  • Breaking: No — Purely additive, existing ESM imports unchanged
  • Tier: 1 (zero-dep ✓ + foundation-aligned ✓)

Technical Review

Code Quality (✓ Good)

package.json changes (lines 10-11):

"require": "./src/index.cjs",
"default": "./src/index.js"
  • Standard Node.js exports pattern
  • Correct priority order: importrequiredefault

src/index.cjs implementation (line 10):

module.exports = import('./index.js');
  • Clean, minimal wrapper following Node.js async CJS→ESM pattern
  • Good documentation explaining Node 22+ sync behavior vs older versions
  • No unnecessary complexity or dependencies

Test coverage (lines 14-23):

  • Verifies CJS wrapper resolves successfully
  • Checks that all ESM exports are present in CJS result
  • Uses createRequire for proper CJS testing in ESM test environment
  • Skips default export appropriately (line 20)

Concerns Identified

Minor:

  1. Test assertion could be stronger: The test only checks property existence, not that the exports are functionally equivalent
  2. No verification of export count: The PR description mentions "56 exports" but the test doesn't verify the exact count

None critical - these are cosmetic improvements, not blockers.

Configuration Changes

No linting rule relaxation, no test expectations lowered, no build configuration modified. Changes are purely additive.

Critical Concerns

None. This is a straightforward compatibility fix using established patterns.

Final Recommendation

  • Rating: ⭐⭐⭐⭐☆ (4/5)
  • Action: APPROVE
  • Reasoning: Solid implementation of CJS compatibility using Node.js best practices. Solves a real user pain point with minimal risk. Well-tested. Only loses one star due to minor test assertion improvements that could be made.

Approval conditions: None required, but consider strengthening the test assertions in a follow-up to verify functional equivalence of exports, not just property existence.


@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 16, 2026

Greptile Summary

This PR adds CJS require() support to @optave/codegraph by introducing a thin async wrapper (src/index.cjs) and extending the exports map with require and default conditions. ESM consumers are unaffected. All feedback from the previous review round has been incorporated: the default condition now points to the CJS wrapper, the JSDoc documents the always-async behaviour and warns against inline destructuring, and the test suite now performs a symmetric bidirectional key comparison with typeof verification.

Key changes:

  • package.json: require and default conditions added under exports["."], both resolving to ./src/index.cjs
  • src/index.cjs: Single-line module.exports = import('./index.js') with thorough JSDoc covering async-always behaviour, destructuring pitfall, and Promise-caching trade-off
  • tests/unit/index-exports.test.js: Three tests — exports-map assertion, ESM smoke test, and CJS-vs-ESM symmetry check with typeof guards and bidirectional key comparison

One remaining documentation concern: the JSDoc usage examples show bare await require(...) calls without an enclosing async function. In CommonJS, top-level await is a SyntaxError; the examples should demonstrate an async IIFE or .then() to avoid confusing consumers.

Confidence Score: 4/5

  • This PR is safe to merge; the one open issue is a documentation nit in the JSDoc that could mislead CJS consumers with an invalid top-level await example.
  • The implementation is correct and all previous review feedback has been addressed. The only remaining issue is that the JSDoc examples in src/index.cjs show await require() as a bare statement, which is a SyntaxError at the top level of a CJS file. This doesn't affect runtime behaviour of the package itself, but it is misleading documentation that could cause confusion for consumers who copy the example verbatim.
  • src/index.cjs — JSDoc usage example uses top-level await which is invalid in CJS

Important Files Changed

Filename Overview
src/index.cjs Thin CJS→ESM wrapper using dynamic import(); all previous review concerns addressed (promise caching note, destructuring warning, async-always note). JSDoc examples show await require() without an async wrapper, which is invalid at CJS top-level.
package.json Adds require and default conditions pointing to ./src/index.cjs; default was corrected from ESM to CJS wrapper (per prior review). The ./cli export intentionally omits a require condition. Looks correct.
tests/unit/index-exports.test.js Three well-structured tests: package.json exports-map assertion, existing ESM smoke test, and new CJS symmetry test with typeof checks and bidirectional key comparison. All prior review feedback has been applied.

Sequence Diagram

sequenceDiagram
    participant CJSConsumer as CJS Consumer
    participant NodeResolver as Node.js Resolver
    participant IndexCJS as src/index.cjs
    participant IndexJS as src/index.js (ESM)

    CJSConsumer->>NodeResolver: require('@optave/codegraph')
    NodeResolver->>NodeResolver: match "require" condition in exports["."]
    NodeResolver->>IndexCJS: load ./src/index.cjs
    IndexCJS->>IndexJS: import('./index.js')
    IndexJS-->>IndexCJS: Promise<ModuleNamespace>
    IndexCJS-->>NodeResolver: module.exports = Promise
    NodeResolver-->>CJSConsumer: Promise<ModuleNamespace>
    CJSConsumer->>CJSConsumer: await promise (inside async fn)
    CJSConsumer->>IndexJS: resolved module namespace

    note over CJSConsumer,IndexCJS: ESM consumers bypass index.cjs entirely<br/>via the "import" condition → src/index.js
Loading

Comments Outside Diff (1)

  1. src/index.cjs, line 5-11 (link)

    await at CJS top-level is a SyntaxError

    The JSDoc examples show await require(...) as a bare statement, but in CommonJS files await is only valid inside an async function — there is no top-level await in CJS. A consumer who copies these examples verbatim into a .js (CJS) or .cjs file at module scope will get:

    SyntaxError: await is only valid in async functions and the top level bodies of modules
    

    Consider updating the examples to show the valid CJS patterns — either an async IIFE or .then():

Last reviewed commit: 491a629

Comment on lines +19 to +21
for (const key of Object.keys(esm)) {
if (key === 'default') continue;
expect(cjs).toHaveProperty(key);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test only asserts key presence, not value identity
The test checks that each ESM named export key exists on the CJS result (toHaveProperty), but it doesn't verify that the values are the same functions/objects. A broken re-export that maps a key to undefined or a wrong value would pass this test. Consider strengthening the assertion:

Suggested change
for (const key of Object.keys(esm)) {
if (key === 'default') continue;
expect(cjs).toHaveProperty(key);
for (const key of Object.keys(esm)) {
if (key === 'default') continue;
expect(cjs[key]).toBe(esm[key]);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — replaced \ with \ + \ check to verify each CJS export is defined and has the same type as its ESM counterpart. Reference equality () isn't possible here because the CJS wrapper uses , which produces a separate module namespace with distinct function references. The new assertion catches \ re-exports that the old \ would miss.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 39092fd — replaced toHaveProperty with toBeDefined() + typeof check. Reference equality (toBe) is not possible because the CJS wrapper uses import(), producing a separate module namespace with distinct function references. The new assertion catches undefined re-exports that toHaveProperty would miss.

@carlos-alm
Copy link
Contributor Author

@greptileai


it('CJS wrapper resolves to the same exports', async () => {
const require = createRequire(import.meta.url);
const cjs = await require('../../src/index.cjs');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test bypasses the package exports map

The test resolves the CJS wrapper by relative file path (../../src/index.cjs) rather than by the published package name (@optave/codegraph). This means a typo or misconfiguration in the "require" condition of package.json's exports field would go undetected — the test would still pass even if consumers got ERR_PACKAGE_PATH_NOT_EXPORTED in production.

Consider resolving through the package name to exercise the full exports resolution:

Suggested change
const cjs = await require('../../src/index.cjs');
const cjs = await require('@optave/codegraph');

Copy link
Contributor Author

@carlos-alm carlos-alm Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Using require("@optave/codegraph") does not work reliably in-repo since the package is not in node_modules. Instead, added an assertion that verifies the exports["."].require field in package.json points to the correct CJS wrapper path, catching exports map misconfigurations without needing self-referencing imports.

@carlos-alm
Copy link
Contributor Author

@greptileai

src/index.cjs Outdated
Comment on lines +7 to +8
* If you are on Node >= 22, synchronous require() of ESM may work
* automatically. On older versions, await the result.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node >= 22 sync-require comment is inaccurate

The comment claims that on Node >= 22 "synchronous require() of ESM may work automatically," but this does not apply here. Node 22's sync-ESM-require feature only kicks in when a CJS caller does require('./esm-file.js') directly, letting the runtime load the ESM module synchronously (provided it has no top-level await). In this setup the exports map routes every CJS require('@optave/codegraph') call to this .cjs wrapper file — Node never has the opportunity to sync-load the ESM directly. The wrapper itself then executes module.exports = import('./index.js'), and import() is unconditionally asynchronous in every Node version, including 22. The result is that consumers on all supported Node versions (20, 21, and 22) will receive a Promise from require() and must await it; no version provides a synchronous result.

Suggested change
* If you are on Node >= 22, synchronous require() of ESM may work
* automatically. On older versions, await the result.
* If you are on Node >= 22, synchronous require() of ESM may work
* automatically. On older versions, await the result.

should be replaced with something like:

 * Note: require() always returns a Promise here  no Node version provides
 * a synchronous result through this wrapper. Always await the call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — updated the JSDoc to clarify that require() always returns a Promise on all Node versions, since this wrapper uses import() which is unconditionally async.

* If you are on Node >= 22, synchronous require() of ESM may work
* automatically. On older versions, await the result.
*/
module.exports = import('./index.js');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Destructured require() silently returns undefined for all named exports

Because module.exports is set to the Promise returned by import(), any CJS consumer that uses the most common require pattern will get silent undefineds:

// This is what most CJS consumers will try first:
const { buildGraph } = require('@optave/codegraph');
buildGraph(...); // TypeError: buildGraph is not a function

buildGraph destructures from the Promise object, not from the resolved module namespace, so it comes back undefined. The failure is silent — no error is thrown at the require() call site.

The comment and PR description only show the await require(...) pattern. Consider adding an explicit warning in the JSDoc comment to prevent this common pitfall:

Suggested change
module.exports = import('./index.js');
module.exports = import('./index.js');

A note like // Named destructuring at require-time does NOT work — always await the full result first. would save consumers from a hard-to-diagnose bug.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added an explicit warning comment that named destructuring at require-time does not work and consumers must always await the result first.

Clarify that import() always returns a Promise on all Node versions,
and warn that named destructuring at require-time silently returns
undefined.
@carlos-alm
Copy link
Contributor Author

@greptileai

package.json Outdated
"import": "./src/index.js"
"import": "./src/index.js",
"require": "./src/index.cjs",
"default": "./src/index.js"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"default" condition points to ESM file, unsafe fallback in CJS contexts

The "default" condition is the last-resort catch-all used by tools that don't match a more specific condition. Here it resolves to ./src/index.js, which is an ESM-only file (the package uses "type": "module"). If any bundler or tool resolves via "default" in a CJS context — for example an older version of webpack, esbuild in CJS output mode, or Jest without ESM support — it will receive the raw ESM file and throw ERR_REQUIRE_ESM.

Node.js itself will never reach "default" here through a require() call because the "require" condition is present and takes priority. However, third-party tooling that evaluates conditions top-to-bottom without context awareness would fall through to "default" and pick up the ESM file.

A safer value for the "default" condition is the CJS wrapper, matching the "require" path:

Suggested change
"default": "./src/index.js"
"default": "./src/index.cjs"

This ensures any tool that can only use the fallback condition still gets a loadable file rather than an ESM module it cannot consume.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — changed the default condition to point to ./src/index.cjs so CJS-only tools get a loadable wrapper.

* // BAD: const { buildGraph } = require('@optave/codegraph'); // buildGraph is undefined
* // GOOD: const { buildGraph } = await require('@optave/codegraph');
*/
module.exports = import('./index.js');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise rejected on import failure is permanently cached by CJS module system

module.exports = import('./index.js') assigns the Promise at module evaluation time. If ./index.js fails to load (e.g., a missing transitive dependency, a native addon that fails to compile), Node.js caches the module object — including the rejected Promise — as the module.exports value.

Any subsequent require('@optave/codegraph') in the same process will return the same cached, already-rejected Promise without re-attempting the load. This means a transient startup failure (e.g., a file-lock race on cold start) will permanently poison the module for the entire process lifetime, rather than failing fresh on each call as a non-wrapped require() error normally would.

This is an accepted trade-off for the standard async CJS→ESM wrapper pattern, but worth documenting in a comment so future maintainers don't expect a retry-on-next-require behaviour:

Suggested change
module.exports = import('./index.js');
// Note: if import() rejects (e.g. missing dependency), the rejected Promise is cached
// by the CJS module system and every subsequent require() call will re-surface the same
// rejection without re-attempting the load.
module.exports = import('./index.js');

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added a comment documenting the Promise caching behavior.

Comment on lines +33 to +37
for (const key of Object.keys(esm)) {
if (key === 'default') continue;
expect(cjs[key], `CJS export "${key}" is missing or undefined`).toBeDefined();
expect(typeof cjs[key]).toBe(typeof esm[key]);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test does not check for extra keys on the CJS side

The loop iterates Object.keys(esm) and checks that every ESM export is present in cjs. This confirms there are no missing exports on the CJS side, but it does not catch the inverse: extra keys on cjs that don't exist on esm.

In practice this shouldn't happen here because cjs is the resolved namespace of the same file, but a future refactor that inadvertently adds a synthetic property to the CJS re-export would go undetected. A symmetric check would close this gap:

Suggested change
for (const key of Object.keys(esm)) {
if (key === 'default') continue;
expect(cjs[key], `CJS export "${key}" is missing or undefined`).toBeDefined();
expect(typeof cjs[key]).toBe(typeof esm[key]);
}
const esmKeys = new Set(Object.keys(esm).filter(k => k !== 'default'));
const cjsKeys = new Set(Object.keys(cjs).filter(k => k !== 'default'));
expect(cjsKeys).toEqual(esmKeys);
for (const key of esmKeys) {
expect(cjs[key], `CJS export "${key}" is missing or undefined`).toBeDefined();
expect(typeof cjs[key]).toBe(typeof esm[key]);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added symmetric key check to verify CJS has no extra keys beyond ESM exports.

- Point `default` condition in exports map to `./src/index.cjs` so
  CJS-only tools get a loadable wrapper instead of ESM
- Document that rejected import() Promises are cached by the CJS
  module system
- Add symmetric key check in test to verify CJS has no extra exports
@carlos-alm
Copy link
Contributor Author

@greptileai

@carlos-alm
Copy link
Contributor Author

Regarding the \ subpath having no CJS fallback: this is intentional. The CLI entry point () is a standalone executable invoked via \ or the \ field — it's not a programmatic API that consumers would . Adding a \ condition would imply CJS programmatic usage is supported for the CLI, which it isn't. JSON doesn't support inline comments, so documenting this in code isn't possible, but the asymmetry is by design.

@carlos-alm
Copy link
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit 74aefd1 into main Mar 17, 2026
15 checks passed
@carlos-alm carlos-alm deleted the fix/cjs-exports-455 branch March 17, 2026 00:47
@github-actions github-actions bot locked and limited conversation to collaborators Mar 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: CJS require('@optave/codegraph') fails with ERR_PACKAGE_PATH_NOT_EXPORTED

1 participant