Skip to content

fix(configorama): load/process-mode syntax errors and named exports in file refs - #87

Merged
DavidWells merged 2 commits into
masterfrom
fix/simplify-loop-bugs-2026-10-24
Oct 24, 2026
Merged

DavidWells merged 2 commits into
masterfrom
fix/simplify-loop-bugs-2026-10-24

Conversation

@DavidWells

Copy link
Copy Markdown
Owner

Fixes two of the three bugs found during the 2026-10-24 simplify loop. The third (varRegex) is not changed; see the last section for why.

Both fixed bugs only show up with moduleCacheMode: 'load' or 'process'. In the default 'legacy' mode, .mjs goes through jiti's require, .ts through tsx and .js through Node's require, and all three already behave correctly.

1. Syntax errors in .js/.mjs/.ts resolved to { __JITI_ERROR__: ... } (2e0966f)

Bug: In load/process mode, loadExecutable compiles modules itself with jiti's transform() and runs them in vm. On a syntax error, transform() doesn't throw. It returns code that sets exports.__JITI_ERROR__ = {...}. jiti's own require checks for that marker and loadExecutable didn't, so the marker became the module's exports. ${file(./broken.mjs)} resolved to { __JITI_ERROR__: {...} }, and a broken root config loaded as that object. This also affected .js, not just .mjs/.ts.

Fix: After running the module, loadExecutable checks for the marker and throws <code>: <message> (<file>:<line>:<column>), the same message shape jiti's require uses. The existing catch drops the module from the graph, and callers wrap the error as they do in legacy mode (e.g. Error processing ESM file: ParseError: Unexpected token (/path/broken.mjs:2:0)). Nothing in the repo reads __JITI_ERROR__.

2. ${file(./x.mjs):a} / ${file(./x.ts):a} failed for a non-function named export (f99704e)

Bug: In load/process mode, a module with a default export comes back as a Proxy whose target is the default export, and a get trap serves the named exports. selectModuleExport used hasOwnProperty(fileModule, name) to decide whether :a names an export. On the proxy that check looks at the default export, so it was false. The lookup then read a from the default export and got undefined, which surfaced as "Invalid variable syntax". The .js branch reads jsFile[name] through the proxy and never checks ownership, so the same module as .js worked.

Fix: loadExecutable records each interop proxy's exports object in a WeakMap and exposes moduleExports(value). selectModuleExport checks ownership on those exports, so a named export takes precedence over the default export, as it does in legacy mode. Values are still read through the proxy.

New tests

Both are in tests/reliability/moduleLifecycle.test.js, next to the existing load/process-mode tests. Each one failed before its fix.

  • Syntax errors: .js, .mjs and .ts files with a syntax error, in load and process modes, used both as a ${file()} ref and as the root config, through both configorama() and configorama.sync(). Each case must throw an error that names the file.
  • Named exports: :a and :b.c from .js, .mjs and .ts modules (the .mjs/.ts ones also have a default export), in legacy, load and process modes, async and sync. All three file types must give the same values.

Local verification

before (3813385) after
npm test, slow suites 8 + 27 passed 8 + 27 passed
npm test, lib (src) 863 passed, 4 skipped 863 passed, 4 skipped
npm test, tests 1810 passed, 1 skipped 1812 passed, 1 skipped
npm run typecheck 0 errors 0 errors

GitHub Actions may be billing-blocked, so CI on this PR may not run.

Not changed: varRegex in configorama/parse-file (src/utils/parsing/parse.js:98)

The option is dead, but what it should do isn't defined anywhere, so it is left as is for a decision:

  • ee6f9e4 (2024-02) added it as YAML.preProcess(fileContents, varRegex). preProcess has never had a second parameter, though: it hardcodes ${ and only has a commented-out console.log(varRegex). df57855 (2025-12, "expose parse-file") added the regex = varRegex || DEFAULT_VAR_SYNTAX fallback. c6e9d98 (2025-12, "fix type errors") then dropped the argument from the preProcess calls. The option has never had any effect.
  • The only description of it is the JSDoc ("Variable syntax regex (defaults to configorama syntax)"). The README's parse-file section and index.d.ts don't mention it. The follow-up note in docs/plans/onepassword-resolver-plugin.md assumes DEFAULT_VAR_SYNTAX matters for parse-file, but it doesn't.
  • The only effect it could plausibly have is letting the YAML pre-processor quote bare custom-syntax variables. That would fix real breakage: with syntax: buildVariableSyntax('{{', '}}'), the README example apiKey: {{env:API_KEY}} parses as {"[object Object]": null} today, and [a, <env:X>] and [#{env:X}] fail.
  • Making it work would mean new behaviour in the hand-tuned flow-collection scanner. Custom syntaxes nest, and a regex only matches the innermost variable. Some prefixes (#, %, @, {, [) are YAML indicators. main.js also passes varRegex: this.variableSyntax, so the change would alter YAML parsing on the main path for every syntax user.

Options: (A) implement regex-driven quoting in YAML.preProcess for non-${ syntaxes, (B) remove the dead option and DEFAULT_VAR_SYNTAX and document that parse-file only knows ${}, or (C) leave it.

…process mode

With moduleCacheMode 'load' or 'process', loadExecutable compiles each
.js/.mjs/.ts file itself with jiti's transform() and runs the result in
vm. On a syntax error transform() does not throw: it returns code that
sets `exports.__JITI_ERROR__ = { filename, line, column, code, message }`.
jiti's own require checks for that marker and throws; loadExecutable did
not, so the marker object became the module's exports. A
`${file(./broken.mjs)}` ref resolved to `{ __JITI_ERROR__: ... }` and a
broken root config loaded as that object.

loadExecutable now checks for the marker after running the module and
throws `<code>: <message> (<file>:<line>:<column>)`, the same message
shape jiti's require uses. The existing catch then drops the module
from the graph, so the next load re-reads the file. The resolver and
parseFileContents wrap the error as they do for legacy mode
("Error processing ESM file: ...", "Failed to execute TypeScript file
<path>: ..."). Legacy mode is unchanged: it goes through jiti's require
(.mjs), tsx (.ts) or Node's require (.js), which all throw already.

Nothing in the repo reads __JITI_ERROR__; it is jiti's internal marker.

Test: tests/reliability/moduleLifecycle.test.js covers .js, .mjs and .ts
in load and process modes, as a ${file()} ref and as the root config,
through both configorama() and configorama.sync().
…rocess mode

In moduleCacheMode 'load' or 'process', loadExecutable returns a module
that has a default export as a Proxy whose target is the default export;
its get trap serves named exports from the module's exports. So
`proxy.a` works, but `hasOwnProperty(proxy, 'a')` checks the default
export and is false for a named export.

selectModuleExport used that hasOwnProperty check to decide whether
`:a` names an export. For a non-function named export it fell through
to the main-export branch, read `a` from the default export, got
undefined, and `${file(./x.mjs):a}` / `${file(./x.ts):a}` failed with
"Invalid variable syntax". A named function export happened to work
through the "function on the main export" branch. The .js branch never
checks ownership (it reads `jsFile[name]` through the proxy), so the
same module as .js returned the value.

loadExecutable now records each interop proxy's exports object in a
WeakMap and exposes `moduleExports(value)`, which returns those exports
(or the value itself when it is not a proxy). selectModuleExport checks
ownership on that, so a named export wins over the default export as it
does in legacy mode, where jiti's own interop proxy targets the module
namespace. Values are still read through the proxy; nothing else about
the proxy changes.

Test: tests/reliability/moduleLifecycle.test.js resolves `:a` and `:b.c`
from .js, .mjs and .ts modules (the .mjs/.ts ones with a default export)
in legacy, load and process modes, async and sync, and expects the same
values for all three.
@DavidWells
DavidWells merged commit 04e3ec5 into master Oct 24, 2026
4 checks passed
@DavidWells
DavidWells deleted the fix/simplify-loop-bugs-2026-10-24 branch October 24, 2026 20:02
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.

1 participant