diff --git a/changelog/unreleased/1577.md b/changelog/unreleased/1577.md new file mode 100644 index 000000000..da8a9b936 --- /dev/null +++ b/changelog/unreleased/1577.md @@ -0,0 +1,3 @@ +- **BREAKING CHANGES:** `djs`: `fjs compile` exits with code `1` instead of `0` + when the input cannot be read or fails to parse, so a failed compile is + detectable from the exit status diff --git a/fjs/djs/module.f.mjs b/fjs/djs/module.f.mjs index 4cbb35737..c8b251e42 100644 --- a/fjs/djs/module.f.mjs +++ b/fjs/djs/module.f.mjs @@ -18,7 +18,16 @@ import { writeUtf8File, error } from '../effects/node/module.f.mjs' /** @typedef {ReadFile | WriteFile | Write} _CompileOp */ -/** @type {(args: readonly string[]) => Effect<_CompileOp, number>} */ +/** + * Compiles the DJS module `args[0]` into `args[1]`, serializing as a JSON tree + * when the output name ends with `.json` and as a module otherwise. + * + * Returns the process exit code: `0` once the output file is written, `1` on + * every failure — too few arguments, a missing input file, or a parse error — + * so a caller can detect a failed compile from the exit status alone. + * + * @type {(args: readonly string[]) => Effect<_CompileOp, number>} + */ export const compile = args => { if (args.length < 2) { return step( @@ -35,7 +44,7 @@ export const compile = args => { const metadata = result[1].metadata return step( error(`${metadata?.path}:${metadata?.line}:${metadata?.column} - error: ${result[1].message}`), - () => pure(0)) + () => pure(1)) } const content = outputFileName.endsWith('.json') ? stringifyAsTree(sort)(result[1]) diff --git a/fjs/djs/proof.f.mjs b/fjs/djs/proof.f.mjs index 37d346efa..89a2ad819 100644 --- a/fjs/djs/proof.f.mjs +++ b/fjs/djs/proof.f.mjs @@ -43,13 +43,15 @@ export const proof = { }, fileNotFound: () => { const [state, code] = virtual(emptyState)(compile(['missing.f.js', 'output.f.js'])) - assertEq(code, 0) + assertEq(code, 1) assert(state.stderr.includes('file not found'), state.stderr) + assertEq(state.root['output.f.js'], undefined) }, parseError: () => { const root = { 'bad.f.js': [utf8('export default @')] } const [state, code] = virtual({ ...emptyState, root })(compile(['bad.f.js', 'output.f.js'])) - assertEq(code, 0) + assertEq(code, 1) assert(state.stderr !== '', 'expected error output') + assertEq(state.root['output.f.js'], undefined) }, } diff --git a/fjs/djs/todo/compile-error-exit-code.md b/fjs/djs/todo/compile-error-exit-code.md deleted file mode 100644 index 2d0bfc851..000000000 --- a/fjs/djs/todo/compile-error-exit-code.md +++ /dev/null @@ -1,43 +0,0 @@ -# `fjs compile` exits 0 on a parse error - -**Priority:** P2 -**Status:** open - -## Problem - -When compilation fails, `compile` in `fjs/djs/module.f.mjs` prints the error -message but then returns `pure(0)`, so the process exits with code 0: - -```js -if (result[0] === 'error') { - const metadata = result[1].metadata - return step( - error(`${metadata?.path}:${metadata?.line}:${metadata?.column} - error: ${result[1].message}`), - () => pure(0)) -} -``` - -The argument-count check just above correctly returns `pure(1)`, so this branch -is the odd one out. As a result, scripts and CI cannot detect a failed compile -from the exit status: - -```sh -$ printf 'console.log("hi")\nexport default 1\n' > x.f.mjs -$ fjs compile x.f.mjs out.mjs; echo $? -x.f.mjs:1:1 - error: const not found -0 -``` - -The output file is (correctly) not written in this case, so the only failure -signal is the message text. - -## Fix - -Return `pure(1)` from the parse-error branch. Check whether any caller or test -depends on the current exit code. - -## Related - -- `file not found` errors from `transpile` take the same branch and are also - reported with exit code 0 (and with `undefined:undefined:undefined` as the - location, since `metadata` is `null` — a second, cosmetic issue). diff --git a/fjs/djs/todo/parse-error-location-format.md b/fjs/djs/todo/parse-error-location-format.md new file mode 100644 index 000000000..115e81058 --- /dev/null +++ b/fjs/djs/todo/parse-error-location-format.md @@ -0,0 +1,62 @@ +## parse-error-location-format. `compile`: `undefined:undefined:undefined` when a `ParseError` has no metadata + +**Priority:** P4 +**Status:** open + +### Problem + +`compile` in `fjs/djs/module.f.mjs` formats every `ParseError` as +`:: - error: `, reading the three fields off +`result[1].metadata`: + +```js +const metadata = result[1].metadata +return step( + error(`${metadata?.path}:${metadata?.line}:${metadata?.column} - error: ${result[1].message}`), + () => pure(1)) +``` + +`ParseError.metadata` is `TokenMetadata | null`, and the transpiler raises two +errors with no token to point at — `file not found` (`transpiler/module.f.mjs:41`) +and `circular dependency` (`:80`) — both of which carry `metadata: null`. The +optional chaining then prints the literal string `undefined` three times: + +```sh +$ fjs compile nope.f.mjs out.mjs +undefined:undefined:undefined - error: file not found +``` + +The exit code is correct (`1`); only the location prefix is wrong. It is noise +for a human and a trap for anything parsing the line as `path:line:column`. + +### Proposal + +Emit the prefix only when there is a location to report: + +```js +const { metadata, message } = result[1] +const location = metadata === null + ? '' + : `${metadata.path}:${metadata.line}:${metadata.column} - ` +return step(error(`${location}error: ${message}`), () => pure(1)) +``` + +A metadata-less error then reads `error: file not found`. An alternative worth +weighing first: give these two errors real metadata — the importing module's +path is known at both sites — which fixes the message *and* tells the user which +import failed. That is the better output but a larger change, since +`TokenMetadata` also wants a line and column. + +### Tasks + +- [ ] Pick one of the two shapes above and implement it. +- [ ] Assert the exact `stderr` text in `fjs/djs/proof.f.mjs`'s `fileNotFound`, + and cover the metadata-carrying branch too. +- [ ] `npx tsc` clean; `fjs t` passes. + +### Related + +- `fjs/djs/module.f.mjs` — the formatting site. +- `fjs/djs/transpiler/module.f.mjs:41`, `:80` — the two `metadata: null` errors. +- Split out of the `compile` exit-code issue, which fixed the exit status of + this same branch and left the cosmetic half open.