Files
Martin Vogel 63f0a6c0e7 test(parse-coverage): free the extraction results in the #1610 tests
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>
2026-08-15 17:22:59 +02:00

388 lines
18 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* test_parse_coverage.c — Reproduce-first suite for the best-effort
* parse-coverage signal (#963, Signal A).
*
* ── The gap being reproduced ────────────────────────────────────────────────
* When tree-sitter hits a construct it cannot parse (ERROR/MISSING nodes in
* the tree), extraction silently drops every definition inside the failed
* region — the file looks fully indexed but is not. `ts_node_has_error(root)`
* detects this, yet nothing consumed it: CBMFileResult gained the fields
* parse_incomplete / error_ranges / error_region_count, but the parse site in
* cbm_extract_file_impl never sets them.
*
* Canonical trigger: the preprocessor-blind #ifdef-split-brace pattern in C —
* both branches open `fn(...) {` and share ONE closing brace, so the raw text
* is brace-unbalanced → ERROR node → the guarded function never becomes a
* Function node while neighbors extract fine.
*
* ── The contract these tests enforce ────────────────────────────────────────
* RED (unfixed): parse_incomplete is never set → flagged-file tests fail.
* GREEN (fixed): cbm_extract_file sets parse_incomplete=true iff the tree
* contains ERROR/MISSING nodes, records the 1-based line
* ranges of the TOP-MOST error regions ("start-end,..."),
* bounded by the 64-region cap, and clean files stay
* completely unflagged (no false positives).
*
* BEST-EFFORT framing (must never be weakened the other way): a flag means
* "constructs here were dropped — prefer grep"; the ABSENCE of a flag is NOT
* a completeness guarantee. These tests only pin down the detectable class.
*/
#include "test_framework.h"
#include "cbm.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Convenience extract wrapper (same shape as test_extraction_imports.c). */
static CBMFileResult *do_extract(const char *src, CBMLanguage lang, const char *path) {
return cbm_extract_file(src, (int)strlen(src), lang, "covproj", path, 0, NULL, NULL);
}
/* Return 1 if any extracted definition has the given short name. */
static int has_def(CBMFileResult *r, const char *name) {
for (int i = 0; i < r->defs.count; i++) {
if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0) {
return 1;
}
}
return 0;
}
/* ── Fixtures ─────────────────────────────────────────────────────────────── */
/* #ifdef-split-brace: both branches open `guarded(...) {`, one shared `}`.
* Preprocessor-blind parse sees unbalanced braces → ERROR region around
* lines 511; ok_before/ok_after remain extractable. */
static const char *C_IFDEF_SPLIT = "#include <stdio.h>\n" /* 1 */
"\n" /* 2 */
"void ok_before(void) { printf(\"a\"); }\n" /* 3 */
"\n" /* 4 */
"#ifdef FEATURE_A\n" /* 5 */
"static int guarded(int x) {\n" /* 6 */
"#else\n" /* 7 */
"static int guarded_alt(int x) {\n" /* 8 */
"#endif\n" /* 9 */
" return x + 1;\n" /* 10 */
"}\n" /* 11 */
"\n" /* 12 */
"void ok_after(void) { printf(\"b\"); }\n"; /* 13 */
static const char *C_CLEAN = "#include <stdio.h>\n"
"\n"
"void alpha(void) { printf(\"a\"); }\n"
"\n"
"static int beta(int x) {\n"
" return x + 1;\n"
"}\n";
/* `def broken(:` parses with an ERROR region, but tree-sitter error recovery
* still yields the `broken` function def — a DEFINITELY RECOVERED miss. */
static const char *PY_BROKEN_RECOVERED = "def ok():\n"
" return 1\n"
"\n"
"def broken(:\n"
" pass\n"
"\n"
"def ok2():\n"
" return 2\n";
/* Pure operator garbage between defs: an ERROR region no def walker can
* recover anything from — a genuine, unrecovered miss. */
static const char *PY_GARBAGE = "def ok():\n"
" return 1\n"
"\n"
"%%% ((( garbage ))) %%%\n"
"??? !!!\n"
"\n"
"def ok2():\n"
" return 2\n";
static const char *PY_CLEAN = "def ok():\n"
" return 1\n"
"\n"
"def ok2():\n"
" return 2\n";
/* ── Tests ────────────────────────────────────────────────────────────────── */
TEST(c_ifdef_split_brace_sets_parse_incomplete) {
CBMFileResult *r = do_extract(C_IFDEF_SPLIT, CBM_LANG_C, "split.c");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error); /* parse succeeded — this is the silent-partial class */
ASSERT_TRUE(r->parse_incomplete);
ASSERT_GTE(r->error_region_count, 1);
ASSERT_NOT_NULL(r->error_ranges);
ASSERT_GT((int)strlen(r->error_ranges), 0);
cbm_free_result(r);
PASS();
}
TEST(c_ifdef_split_brace_neighbors_still_extracted) {
/* Documents WHY the flag matters: the file is partially indexed —
* neighbors extract, so nothing else hints at the dropped region. */
CBMFileResult *r = do_extract(C_IFDEF_SPLIT, CBM_LANG_C, "split.c");
ASSERT_NOT_NULL(r);
ASSERT_TRUE(has_def(r, "ok_before"));
ASSERT_TRUE(r->parse_incomplete);
cbm_free_result(r);
PASS();
}
TEST(c_error_range_points_at_failed_region) {
/* The recorded range must overlap the #ifdef construct (lines 511) so an
* agent can be pointed at the exact unparsed region. Format is
* "start-end[,start-end...]", 1-based, inclusive. */
CBMFileResult *r = do_extract(C_IFDEF_SPLIT, CBM_LANG_C, "split.c");
ASSERT_NOT_NULL(r);
ASSERT_TRUE(r->parse_incomplete);
ASSERT_NOT_NULL(r->error_ranges);
unsigned int start = 0;
unsigned int end = 0;
ASSERT_EQ(sscanf(r->error_ranges, "%u-%u", &start, &end), 2);
ASSERT_GTE(start, 1u);
ASSERT_LTE(start, 11u); /* starts at or before the region's last line */
ASSERT_GTE(end, 5u); /* ends at or after the region's first line */
ASSERT_LTE(end, 13u); /* never past EOF */
ASSERT_LTE(start, end);
cbm_free_result(r);
PASS();
}
TEST(c_clean_file_not_flagged) {
/* No false positives: a clean parse must stay completely unflagged. */
CBMFileResult *r = do_extract(C_CLEAN, CBM_LANG_C, "clean.c");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_FALSE(r->parse_incomplete);
ASSERT_EQ(r->error_region_count, 0);
ASSERT_NULL(r->error_ranges);
ASSERT_TRUE(has_def(r, "alpha"));
ASSERT_TRUE(has_def(r, "beta"));
cbm_free_result(r);
PASS();
}
TEST(py_unrecovered_garbage_sets_parse_incomplete) {
CBMFileResult *r = do_extract(PY_GARBAGE, CBM_LANG_PYTHON, "garbage.py");
ASSERT_NOT_NULL(r);
ASSERT_TRUE(r->parse_incomplete);
ASSERT_GTE(r->error_region_count, 1);
ASSERT_NOT_NULL(r->error_ranges);
ASSERT_TRUE(has_def(r, "ok")); /* partial: clean defs still extracted */
cbm_free_result(r);
PASS();
}
TEST(py_recovered_def_not_flagged) {
/* Recovery subtraction: `def broken(:` produces an ERROR region, but the
* def walker still recovers `broken` covering the whole region — the
* construct IS in the graph, so flagging it would be a false miss. */
CBMFileResult *r = do_extract(PY_BROKEN_RECOVERED, CBM_LANG_PYTHON, "broken.py");
ASSERT_NOT_NULL(r);
ASSERT_TRUE(has_def(r, "broken")); /* the recovery that justifies unflagging */
ASSERT_FALSE(r->parse_incomplete);
ASSERT_EQ(r->error_region_count, 0);
ASSERT_NULL(r->error_ranges);
cbm_free_result(r);
PASS();
}
TEST(py_clean_file_not_flagged) {
CBMFileResult *r = do_extract(PY_CLEAN, CBM_LANG_PYTHON, "clean.py");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->parse_incomplete);
ASSERT_EQ(r->error_region_count, 0);
ASSERT_NULL(r->error_ranges);
cbm_free_result(r);
PASS();
}
TEST(error_region_cap_is_honored) {
/* Pathological input: many separate unrecoverable garbage blocks
* interleaved with valid defs. The collector must stay bounded by its
* 64-region cap (matches CBM_MAX_ERROR_REGIONS in cbm.c) — pathological
* input can't blow up the report, and the flag itself still fires. */
enum { GARBAGE_BLOCKS = 200, LINE_CAP = 64 };
char *src = (char *)malloc(GARBAGE_BLOCKS * 96 + 1);
ASSERT_NOT_NULL(src);
size_t off = 0;
for (int i = 0; i < GARBAGE_BLOCKS; i++) {
off += (size_t)snprintf(
src + off, 96, "def ok%d():\n return %d\n%%%%%% garbage%d ((( %%%%%%\n", i, i, i);
}
CBMFileResult *r = do_extract(src, CBM_LANG_PYTHON, "many_errors.py");
free(src);
ASSERT_NOT_NULL(r);
ASSERT_TRUE(r->parse_incomplete);
ASSERT_GTE(r->error_region_count, 1);
ASSERT_LTE(r->error_region_count, LINE_CAP);
ASSERT_NOT_NULL(r->error_ranges);
cbm_free_result(r);
PASS();
}
/* Trailing recovered functions AFTER the failed #ifdef region must not
* unflag it: recovery evidence must originate INSIDE the region, and the
* unrecovered lines (the first branch's `guarded`) keep it flagged. */
TEST(c_trailing_recovered_defs_keep_flag) {
const char *src = "void ok_before(void) { }\n"
"#ifdef A\n"
"static int guarded(int x) {\n"
"#else\n"
"static int guarded_alt(int x) {\n"
"#endif\n"
" return x + 1;\n"
"}\n"
"void ok_after(void) { }\n"
"static int nested_ok(int y) { return y; }\n";
CBMFileResult *r = do_extract(src, CBM_LANG_C, "probe.c");
ASSERT_NOT_NULL(r);
ASSERT_TRUE(has_def(r, "guarded_alt")); /* partial recovery inside the region */
ASSERT_TRUE(r->parse_incomplete); /* ...but `guarded` is still lost */
ASSERT_GTE(r->error_region_count, 1);
cbm_free_result(r);
PASS();
}
/* ── Suite ────────────────────────────────────────────────────────────────── */
/* ── #1610: a missing FINAL NEWLINE is not a parse failure ────────────────────
*
* A file that does not end with "\n" leaves the grammar's mandatory line
* terminator MISSING. That 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. Every instruction still parses.
*
* Reported on #1610 for Dockerfile, where a reporter proved with a byte-exact
* matrix that the trigger is independent of BOM, CRLF/LF, exec-form vs
* shell-form and file length — it is purely the absent final newline.
*
* It was never Dockerfile-specific: tcl, fish, gomod and hyprlang flag the same
* way, while ini, fsharp, beancount and others do NOT — only because those
* grammars declare the terminator token hidden rather than visible. Whether a
* user saw a phantom parse_partial came down to a grammar-authoring accident.
*
* The cost was not cosmetic: a phantom flag writes a "<project>::missed" shadow
* row, and until #1609 that row removed the whole project from cross-repo
* linking, as source AND as target. */
TEST(dockerfile_missing_final_newline_not_flagged_issue1610) {
const char *src = "FROM mcr.microsoft.com/dotnet/aspnet:8.0\n"
"ENTRYPOINT [\"dotnet\", \"App.dll\"]"; /* deliberately no \n */
CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
ASSERT_NOT_NULL(r);
bool flagged = r->parse_incomplete;
cbm_free_result(r);
if (flagged) {
FAIL("a Dockerfile lacking only its final newline must not be parse_partial");
}
PASS();
}
/* The same bytes WITH the newline must stay clean — pins the equivalence the
* reporter's matrix proved, so a future change cannot "fix" one by breaking the
* other. */
TEST(dockerfile_with_final_newline_still_clean_issue1610) {
const char *src = "FROM mcr.microsoft.com/dotnet/aspnet:8.0\n"
"ENTRYPOINT [\"dotnet\", \"App.dll\"]\n";
CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
ASSERT_NOT_NULL(r);
bool flagged = r->parse_incomplete;
cbm_free_result(r);
if (flagged) {
FAIL("a terminated Dockerfile must not be parse_partial");
}
PASS();
}
/* Language-general, not a Dockerfile patch: these four were each proven to flag
* on a stripped trailing newline. */
TEST(missing_final_newline_not_flagged_across_grammars_issue1610) {
struct {
const char *src;
CBMLanguage lang;
const char *path;
} cases[] = {
{"proc foo {} {}\nproc bar {} {}", CBM_LANG_TCL, "a.tcl"},
{"function foo\n echo hi\nend", CBM_LANG_FISH, "a.fish"},
{"module example.com/m\n\ngo 1.21", CBM_LANG_GOMOD, "go.mod"},
{"general {\n gaps_in = 5\n}", CBM_LANG_HYPRLANG, "hypr.conf"},
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
CBMFileResult *r = do_extract(cases[i].src, cases[i].lang, cases[i].path);
ASSERT_NOT_NULL(r);
bool flagged = r->parse_incomplete;
if (flagged) {
fprintf(stderr, " %s flagged: ranges=%s\n", cases[i].path,
r->error_ranges ? r->error_ranges : "(none)");
}
cbm_free_result(r);
if (flagged) {
FAIL("an unterminated final line must not be parse_partial in any grammar");
}
}
PASS();
}
/* GUARD (the reason this suppression is safe rather than convenient): the rule
* is ZERO-WIDTH AT EOF only. A real failure earlier in the file must still be
* reported, and its range must name the broken line — not be swallowed along
* with the terminator. */
TEST(real_error_before_eof_still_flagged_without_final_newline_issue1610) {
/* Built from C_IFDEF_SPLIT, the fixture this suite already proves is
* flagged, with its trailing newline removed. Two conditions now hold at
* once: a genuine width-bearing ERROR mid-file, AND an unterminated last
* line. Suppressing the EOF terminator must not swallow the real one. */
size_t n = strlen(C_IFDEF_SPLIT);
char *unterminated = (char *)malloc(n + 1);
ASSERT_NOT_NULL(unterminated);
memcpy(unterminated, C_IFDEF_SPLIT, n);
unterminated[n - 1] = '\0'; /* drop the final newline */
CBMFileResult *r = do_extract(unterminated, CBM_LANG_C, "split.c");
free(unterminated);
ASSERT_NOT_NULL(r);
bool flagged = r->parse_incomplete;
bool has_ranges = r->error_ranges != NULL;
cbm_free_result(r);
if (!flagged) {
FAIL("a real mid-file parse failure must still be reported when the file also lacks its final newline");
}
if (!has_ranges) {
FAIL("a reported failure must still name its line range");
}
PASS();
}
/* GUARD: a MISSING/ERROR node WITH WIDTH at EOF is a genuine loss and must
* still be flagged. A Makefile whose final recipe line lacks its newline really
* does drop the recipe from the tree — cbm's flag is honest there. */
TEST(width_bearing_error_at_eof_still_flagged_issue1610) {
const char *src = "all:\n\techo hi"; /* no trailing newline; recipe is lost */
CBMFileResult *r = do_extract(src, CBM_LANG_MAKEFILE, "Makefile");
ASSERT_NOT_NULL(r);
bool flagged = r->parse_incomplete;
cbm_free_result(r);
if (!flagged) {
FAIL("a width-bearing parse failure at EOF must still be reported");
}
PASS();
}
SUITE(parse_coverage) {
RUN_TEST(c_ifdef_split_brace_sets_parse_incomplete);
RUN_TEST(c_ifdef_split_brace_neighbors_still_extracted);
RUN_TEST(c_error_range_points_at_failed_region);
RUN_TEST(c_clean_file_not_flagged);
RUN_TEST(py_unrecovered_garbage_sets_parse_incomplete);
RUN_TEST(py_recovered_def_not_flagged);
RUN_TEST(py_clean_file_not_flagged);
RUN_TEST(error_region_cap_is_honored);
RUN_TEST(c_trailing_recovered_defs_keep_flag);
RUN_TEST(dockerfile_missing_final_newline_not_flagged_issue1610);
RUN_TEST(dockerfile_with_final_newline_still_clean_issue1610);
RUN_TEST(missing_final_newline_not_flagged_across_grammars_issue1610);
RUN_TEST(real_error_before_eof_still_flagged_without_final_newline_issue1610);
RUN_TEST(width_bearing_error_at_eof_still_flagged_issue1610);
}