Skip to content

fix(extract): a missing final newline is not a partial parse - #1658

Merged
DeusData merged 2 commits into
mainfrom
fix/eof-missing-terminator
Aug 15, 2026
Merged

fix(extract): a missing final newline is not a partial parse#1658
DeusData merged 2 commits into
mainfrom
fix/eof-missing-terminator

Conversation

@DeusData

Copy link
Copy Markdown
Owner

Fixes #1610.

A file that does not end with a newline leaves the grammar's mandatory line terminator MISSING, and we counted that node — so the file was reported parse_partial with its last line as the error range.

It is not a miss. The node is zero-width and sits at EOF: the parser consumed no source for it, so by construction nothing was dropped — no construct can live in a zero-byte span. Dumping the tree for the reporter's two-line Dockerfile:

(source_file (from_instruction ...) (entrypoint_instruction ...) (MISSING "\n"))
                                                                  ^ bytes 73-73

Both instructions parsed perfectly.

It was never Dockerfile-specific

Stripping the trailing newline from the 156 linkable grammar fixtures flips 13 to has_error, and six produce regions: dockerfile, tcl, fish, gomod, hyprlang — and makefile, which is a genuinely different case (its ERROR has width; the recipe really is lost, so that flag is honest and is deliberately preserved).

The grammars that stayed silent did so for no principled reason: ini, fsharp, beancount, requirements, gitignore, sshconfig and kconfig omit the same terminator, but theirs is a hidden node, invisible to ts_node_child(). Whether a user was told their file was partially parsed came down to whether that grammar's author declared the terminator token visible.

Why it mattered beyond cosmetics

A phantom parse_partial writes a <project>::missed shadow row — and until #1609 that row made the project fail cross-repo validation as both source and target. A single absent byte could remove an entire repository from cross-repo intelligence, with no error shown anywhere.

Scope

Deliberately narrow: zero-width AND at EOF. A MISSING or ERROR node with width still counts even at EOF; anything before EOF is untouched. Both callers pass the raw root, so one source_len serves both — verified (root is bound once at cbm.c:1231 and never reassigned) rather than assumed.

Verification

Reproduce-first and revert-checked. The Dockerfile and cross-grammar tests fail on the previous tree and pass with the fix; forcing the new predicate to return false brings the identical REDs back. Two guards pin the boundary and hold in both directions — a width-bearing failure at EOF (makefile), and a real mid-file ERROR in a file that also lacks its final newline, built from C_IFDEF_SPLIT, the fixture this suite already proves is flagged.

parse_coverage 14 · extraction 276 · language 217 · infrascan 3 · grammar_regression 1 — 511 passed, 0 failed.

Root-caused by @vitaliy-shatskiy, who could not share the original file and rebuilt the property from scratch with a byte-exact script — an editor would have silently re-added the newline and hidden it.

A file that does not end with a newline leaves the grammar's mandatory line
terminator MISSING. cbm_collect_error_regions counted that node, so the file was
reported parse_partial with the last line as its error range.

It is not a miss. The node is ZERO-WIDTH and sits at EOF: the parser consumed no
source for it, so by construction nothing was dropped - no construct can live in
a zero-byte span - and every real instruction above it parsed normally. Proven
by dumping the tree: the reporter's two-line Dockerfile yields
(source_file (from_instruction ...) (entrypoint_instruction ...) (MISSING "\n"))
with both instructions intact and the MISSING node spanning bytes 73-73.

It was never Dockerfile-specific. Stripping the trailing newline from the 156
linkable grammar fixtures flips 13 of them to has_error, and SIX produce regions:
dockerfile, tcl, fish, gomod, hyprlang - and makefile, which is a genuinely
different case (its ERROR has WIDTH; the recipe really is lost).

Worse, the ones that stayed silent did so for no principled reason. ini, fsharp,
beancount, requirements, gitignore, sshconfig and kconfig omit the same
terminator, but theirs is a HIDDEN node and hidden nodes are invisible to
ts_node_child(). Whether a user was told their file was partially parsed came
down to whether that grammar's author declared the terminator visible.

The cost was not cosmetic: a phantom parse_partial writes a "<project>::missed"
shadow row, and until #1609 that row made the project fail cross-repo validation
as BOTH source and target. A single absent byte could remove an entire
repository from cross-repo intelligence with no error shown anywhere.

The suppression is deliberately narrow - zero-width AND at EOF. A MISSING or
ERROR node with width still counts even at EOF, and anything before EOF is
untouched. Both callers pass the raw root, so one source_len is correct for
both; verified rather than assumed, since root is bound once and never
reassigned.

Reported by @vitaliy-shatskiy, who could not share the original file and instead
rebuilt the property from scratch with a byte-exact script - an editor would
have silently re-added the newline and hidden it. Their isolation matrix ruled
out BOM, CRLF vs LF, exec-form vs shell-form and file length before we looked at
it once.

Reproduce-first, revert-checked: the Dockerfile and cross-grammar tests fail on
the previous tree and pass with the fix; forcing the new predicate to return
false brings the identical REDs back. Two guards pin the boundary and hold in
both directions - a width-bearing failure at EOF (makefile) and a real
mid-file ERROR in a file that ALSO lacks its final newline (built from
C_IFDEF_SPLIT, the fixture this suite already proves is flagged).

parse_coverage 14, extraction 276, language 217, infrascan 3,
grammar_regression 1 - 511 passed, 0 failed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
LeakSanitizer on CI caught all five new tests leaking their CBMFileResult:

    Indirect leak of 24 byte(s) ... ts_tree_new
      cbm_extract_file_ex cbm.c:1256
      do_extract test_parse_coverage.c:39
      test_dockerfile_missing_final_newline_not_flagged_issue1610:272
    SUMMARY: AddressSanitizer: 706504 byte(s) leaked in 189 allocation(s)

Every pre-existing test in this suite calls cbm_free_result before PASS; the new
ones did not. The local run could not have found it - LeakSanitizer reports
"detect_leaks is not supported on this platform" on macOS arm64, so this class
of defect is CI-only here.

Each test now captures what it asserts, frees, and only then decides, so the
early-FAIL paths do not leak either. The cross-grammar loop prints its
diagnostic before freeing so the failure message keeps naming the grammar.

While correcting the guard, a first attempt left ASSERT_TRUE(flagged ||
has_ranges || true) in real_error_before_eof_still_flagged - always true, and it
would have silently disarmed the guard that stops the EOF suppression from being
over-broad. Removed. The guard is re-proven binding: forcing
cbm_is_eof_terminator_miss to return true makes EIGHT tests fail, including both
guards, and restoring it returns the suite to green.

parse_coverage 14 passed.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData
DeusData merged commit 49d928b into main Aug 15, 2026
35 checks passed
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.

Dockerfile grammar flags stock .NET Dockerfiles as parse_partial

1 participant