Skip to content

--check silently passes a .js file with a syntax error when the file contains ESM syntax #65202

Description

@MerlijnW70

Version

v22.22.1

Platform

Microsoft Windows NT 10.0.26200.0 x64

Subsystem

module / cli (lib/internal/main/check_syntax.js)

What steps will reproduce the bug?

Create broken.js in a directory with no package.json:

import fs from "node:fs";
var = ;

Then:

$ node --check broken.js ; echo "exit: $?"
exit: 0

$ node broken.js
var = ;
    ^
SyntaxError: Unexpected token '='

--check reports nothing and exits 0 for a file that is not valid JavaScript under either goal
symbol.

How often does it reproduce? Is there a required condition?

Always, given the condition: the file's format must be ambiguous — a .js extension with no
"type" in the nearest package.json — and the source must contain import or export.
Everything that states its format is handled correctly:

the file node --check
.mjs, or "type": "module" correct — reports the error
"type": "commonjs" correct — Cannot use import statement outside a module
.js, no "type", containing import/export exit 0, nothing reported
.js whose only ESM signal is top-level await correct — reports the error

What is the expected behavior? Why is that the expected behavior?

A non-zero exit and the SyntaxError, as with every other invalid file. The documentation for
-c, --check is "Syntax check the script without executing", with no caveat about module
detection — and the same source reports the error correctly the moment its format is stated.

This matters in practice because node --check is a common cheap gate in CI, and a plain .js
file containing import is the shape most projects have — so the sources most likely to be
checked are the ones that silently pass.

What do you see instead?

Exit code 0 and no output.

Additional information

lib/internal/main/check_syntax.js parses under the Module goal only when defaultGetFormat
answers 'module', which an ambiguous .js does not:

if (format === 'module') {
  const { ModuleWrap } = internalBinding('module_wrap');
  new ModuleWrap(filename, undefined, source, 0, 0);
  return;
}

wrapSafe(filename, source, undefined, format);

So the CommonJS branch runs. Inside wrapSafe, cjsModuleInstance is undefined here, so
shouldDetectModule falls through to --require-module — on by default — and
compileFunctionForCJSLoader detects ESM instead of throwing. Its result is then discarded, and
the source is never parsed under either goal.

Module.prototype._compile handles exactly this case correctly a little further down the same
file, by reading the flag the detection returns:

if (format !== 'module') {
  const result = wrapSafe(filename, content, this, format);
  compiledWrapper = result.function;
  if (result.canParseAsESM) {
    format = 'module';
  }
}

if (format === 'module') {
  loadESMFromCJS(this, filename, format, content);
  return;
}

checkSyntax looks like it wants the same canParseAsESM branch, falling back to the ModuleWrap
parse it already has above rather than returning.

The relevant part of check_syntax.js is unchanged on main; the file was last touched
2024-12-02. The behaviour most likely dates from module: unflag detect-module (2024-07-20), which
made detection the default for this path.

Test coverage. test/sequential/test-cli-syntax-bad.js checks bad_syntax.js, bad_syntax,
bad_syntax_shebang.js and bad_syntax_shebang — all CommonJS.
test/fixtures/syntax/bad_syntax.mjs exists but is used only by test-esm-error-cache.js and
test-compile-cache-bad-syntax.js, not by either --check test. So neither a .mjs file nor a
detected-ESM .js file is currently asserted to fail --check.

I am happy to open a PR with the fix and a fixture plus test if that shape is welcome.

Found while differential-testing Node against
ViperJS, an embeddable JavaScript engine in safe Rust.
Sweeping eight repositories, the two disagreed about 18 files; every one of them turned out to be
genuinely invalid JavaScript, and node --check had reported nine as fine. V8 itself is right
about all eighteen — it is only --check that never asks it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions