Files
codebase-memory-mcp/tests/test_parse_coverage.c
T
Martin Vogel 17b5a43263 fix(perl): update grammar for format declarations
Refresh the complete generated Perl grammar bundle from upstream v1.2.1 at c3e17b31179bf8f658c9f37c7a3ea6a202212d5a, raising it from ABI 14 to supported ABI 15. Preserve the existing license and record the source and per-file provenance.

Add deterministic coverage for a valid format followed by a named sub, while retaining a malformed-source control.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-30 16:48:41 +02:00

495 lines
22 KiB
C
Raw 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 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";
/* Perl formats have a line-oriented body terminated by a lone dot. The
* following named sub pins the important recovery boundary: a grammar must
* both accept the format and resume normal declaration parsing afterwards. */
static const char *PERL_FORMAT_WITH_FOLLOWING_SUB = "package Report;\n"
"format REPORT =\n"
"@<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<\n"
"$headline\n"
".\n"
"sub after_format { return 1; }\n";
/* A grammar refresh must not hide real Perl syntax loss. */
static const char *PERL_MALFORMED = "package Broken;\n"
"sub before_error { return 1; }\n"
"} ] } ]\n"
"sub after_error { 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();
}
/* #1746: on Windows, a Dockerfile whose final instruction is followed by one
* ASCII space and then EOF was reported as parse_partial. */
TEST(dockerfile_trailing_space_at_eof_not_flagged_issue1746) {
const char *src = "FROM scratch\nENTRYPOINT [\"a\"] ";
CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
ASSERT_NOT_NULL(r);
bool flagged = r->parse_incomplete;
if (flagged) {
fprintf(stderr, " exact issue #1746 fixture flagged: ranges=%s\n",
r->error_ranges ? r->error_ranges : "(none)");
}
cbm_free_result(r);
if (flagged) {
FAIL("trailing horizontal whitespace at Dockerfile EOF must not be parse_partial");
}
PASS();
}
/* The same bytes WITH the LF must stay clean. This is a separate test so the
* control executes even while the exact affected fixture is RED. */
TEST(dockerfile_trailing_space_with_final_newline_clean_issue1746) {
const char *src = "FROM scratch\nENTRYPOINT [\"a\"] \n";
CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->parse_incomplete);
cbm_free_result(r);
PASS();
}
/* Removing the trailing space while retaining EOF must also stay clean. */
TEST(dockerfile_without_trailing_space_at_eof_clean_issue1746) {
const char *src = "FROM scratch\nENTRYPOINT [\"a\"]";
CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->parse_incomplete);
cbm_free_result(r);
PASS();
}
/* The reporter also observed the same failure when the first line uses CRLF. */
TEST(dockerfile_crlf_trailing_space_at_eof_not_flagged_issue1746) {
const char *src = "FROM scratch\r\nENTRYPOINT [\"a\"] ";
CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->parse_incomplete);
cbm_free_result(r);
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();
}
/* #1838: tree-sitter-perl v1.0.0 rejects a valid line-oriented format and
* reports the file as partial. The supported upstream v1.2.1 grammar accepts
* the format and preserves declaration extraction beyond its dot terminator. */
TEST(perl_format_followed_by_named_sub_is_complete_issue1838) {
CBMFileResult *r = do_extract(PERL_FORMAT_WITH_FOLLOWING_SUB, CBM_LANG_PERL, "report.pl");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
bool partial = r->parse_incomplete;
int error_regions = r->error_region_count;
bool has_error_ranges = r->error_ranges != NULL;
bool has_following_sub = has_def(r, "after_format");
if (partial || error_regions != 0 || has_error_ranges || !has_following_sub) {
fprintf(stderr,
" Perl format result: partial=%d regions=%d ranges=%s following_sub=%d\n",
partial,
error_regions,
r->error_ranges ? r->error_ranges : "(none)",
has_following_sub);
cbm_free_result(r);
FAIL("a valid Perl format and its following named sub must parse completely");
}
cbm_free_result(r);
PASS();
}
TEST(perl_malformed_source_remains_partial_issue1838) {
CBMFileResult *r = do_extract(PERL_MALFORMED, CBM_LANG_PERL, "broken.pl");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_TRUE(r->parse_incomplete);
ASSERT_GTE(r->error_region_count, 1);
ASSERT_NOT_NULL(r->error_ranges);
cbm_free_result(r);
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(dockerfile_trailing_space_at_eof_not_flagged_issue1746);
RUN_TEST(dockerfile_trailing_space_with_final_newline_clean_issue1746);
RUN_TEST(dockerfile_without_trailing_space_at_eof_clean_issue1746);
RUN_TEST(dockerfile_crlf_trailing_space_at_eof_not_flagged_issue1746);
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);
RUN_TEST(perl_format_followed_by_named_sub_is_complete_issue1838);
RUN_TEST(perl_malformed_source_remains_partial_issue1838);
}