Skip to content

fix(parser): return instead of throwing on an unterminated scope#55

Open
svyatov wants to merge 1 commit into
conventional-commits:mainfrom
svyatov:fix/scope-error-in-body
Open

fix(parser): return instead of throwing on an unterminated scope#55
svyatov wants to merge 1 commit into
conventional-commits:mainfrom
svyatov:fix/scope-error-in-body

Conversation

@svyatov

@svyatov svyatov commented Jul 21, 2026

Copy link
Copy Markdown

Put z.array(z.boolean()) at the start of a commit body line and the whole commit fails to parse. Downstream, release-please catches the throw, drops the commit from the changelog, and finishes green, which is how it found me: one of our fixes went missing from two consecutive release PRs before anyone noticed. @codyoss hit the same thing on google-cloud-go, reported in #54.

What happens

A body line gets offered to footer() first, by way of preFooter(). token() reads z.array as a type, sees the (, and hands off to scope(), which consumes z.boolean, hits the second (, and finds no ) where the grammar wants one.

Every other sub-parser in lib/parser.js returns an Error there and lets its caller decide. scope() is the only one that throws, so the throw escapes message() and takes the commit with it.

Both callers were already written for a returned error. summary() does if (s instanceof Error) s = null, and token() does if (!(s instanceof Error)) node.children.push(s). They never get asked.

The change

scope() returns the error instead of throwing. That alone is not enough: abort() rewinds only as far as the scope node, which starts one character after the ( was consumed, so the fix records the position before consuming the ( and rewinds there too. Otherwise the caller resumes mid-token.

With the error returned, token() skips the scope, footer() fails at its separator, preFooter() backtracks, and body() reads the line as plain text. Footers after such a line still parse.

What deliberately did not change

A summary like fix(a(b)): x still throws, same message, same position. The grammar says

<scope> ::= 1*<any UTF8-octets except newline or parens>

so an unclosed paren in a summary really is a syntax error, while the same characters in a body line are only text. scope() tags this error and summary() re-raises it, which keeps those two answers apart.

The existing throws error when closing ")" token is missing test therefore passes untouched, along with the rest of the suite and every snapshot. Since #54 lists the summary case among its failures, to be clear about what this PR covers: the body half is a bug, the summary half is the grammar working as written.

Tests

Five, added under <body>, using plain assertions so test/parser.js.snap stays untouched. Four of them fail without the parser change. The fifth pins the summary behavior above and passes either way, on purpose.

One is the case from #48, open since March 2024 with a failing test and no fix. I lifted its assertions, credit to @Otard95, and this closes that one too.

Locally the suite goes from 34 passing to 39, standard stays clean, and npm run coverage clears the .nycrc thresholds (branch coverage 93.0 to 95.3). The patch is a const, a method call, and a property assignment, so nothing in it needs a syntax floor above the Node 10 that CI still builds.

Closes #54, closes #48.

A body line that begins with a word followed by "(" reaches scope() through
footer() and token(). A second "(" before the closing ")" is a syntax error
there, and scope() was the one sub-parser that threw rather than returning the
Error, so the throw escaped message() and the whole commit failed to parse.

Both callers already handle an Error return. summary() treats it as "no scope",
token() skips the node. Return the error so they get the chance, and rewind past
the consumed "(" as well, since abort() rewinds only to the scope node, which
starts one character later.

summary() re-raises this specific error, because the grammar excludes parens
from a scope, so an unclosed "(" in a summary really is a syntax error. Existing
error messages and positions are unchanged.

Closes conventional-commits#54
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.

Parser throws "unexpected token '(' ... valid tokens [)]" on nested parentheses in commit body

1 participant