63f0a6c0e7
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>
388 lines
18 KiB
C
388 lines
18 KiB
C
/*
|
||
* 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 5–11; 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 5–11) 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);
|
||
}
|