Skip to content

Commit fc7cc59

Browse files
test(coverage): pin the truncation marker, the partial ceiling and the grammar limit (#963)
Phase 5. Four test groups, each checked RED before it was kept. - The Studio Export range join puts ONE ",+<N>" marker at the end with the summed drop count. A marker left mid-string makes every reader stop there and silently lose the ranges after it. Reaching the join through the pipeline needs an export file with 256+ error regions across two <Class> elements, so it goes through a test seam, following the pattern already in this repo (CBM_COVERAGE_MARKER_TEST_API). - check_index_coverage emits every range in front of a marker, never turns the marker's digits into a range, and reports "truncated" from BOTH caps — the producer's and its own 256 limit. - test_index_resilience now has a ceiling beside its floor: exactly one of the two fixture files is flagged, the clean neighbour is absent, and the range does not cover the whole file. - The three _Thread_local forms are pinned as measured. Only the array form fails today; the plan's Phase 0 also listed the pointer form, and that is wrong on the grammar shipped now. Also fixes 13 clang-format violations the earlier commits on this branch left in cbm.c, mcp.c and pass_definitions.c. `make -f Makefile.cbm lint-format` would have failed CI. The changes are whitespace only — the two reflowed tool descriptions concatenate byte-identically, so no output moved. Full suite: 7735 passed, 28 failed, 7 skipped. The 28 are the pre-existing cli install/uninstall failures, identical at clean HEAD. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VgDHuhXmjdrwzowPN68wsC
1 parent fccf821 commit fc7cc59

9 files changed

Lines changed: 260 additions & 20 deletions

File tree

Makefile.cbm

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ endif
9898
KOTLIN_DEDUP_TEST_DEFINE = -DCBM_KOTLIN_DEDUP_TEST_API=1
9999
CALL_REFERENCE_LOOKUP_TEST_DEFINE = -DCBM_CALL_REFERENCE_LOOKUP_TEST_API=1
100100
INCREMENTAL_TEST_DEFINE = -DCBM_INCREMENTAL_TEST_API=1
101+
COVERAGE_MARKER_TEST_DEFINE = -DCBM_COVERAGE_MARKER_TEST_API=1
101102
CFLAGS_TEST = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(SANITIZED_DEFINE) \
102103
$(KOTLIN_DEDUP_TEST_DEFINE) $(CALL_REFERENCE_LOOKUP_TEST_DEFINE) \
103-
$(INCREMENTAL_TEST_DEFINE) -g -O1 $(SANITIZE)
104+
$(INCREMENTAL_TEST_DEFINE) $(COVERAGE_MARKER_TEST_DEFINE) -g -O1 $(SANITIZE)
104105
CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) $(CXX_STDLIB_FLAGS)
105106

106107
# TSan (can't combine with ASan)
@@ -118,6 +119,7 @@ TSAN_SANITIZE = -fsanitize=thread -fno-omit-frame-pointer
118119
# macro of ours.
119120
CFLAGS_TSAN = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(KOTLIN_DEDUP_TEST_DEFINE) \
120121
$(CALL_REFERENCE_LOOKUP_TEST_DEFINE) $(INCREMENTAL_TEST_DEFINE) \
122+
$(COVERAGE_MARKER_TEST_DEFINE) \
121123
-DCBM_SANITIZED_BUILD=1 -g -O1 $(TSAN_SANITIZE)
122124
CXXFLAGS_TSAN = $(CXXFLAGS_COMMON) -DCBM_SANITIZED_BUILD=1 -g -O1 \
123125
$(TSAN_SANITIZE)

internal/cbm/cbm.c

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,16 +1775,15 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
17751775
orig_lines++;
17761776
}
17771777
}
1778-
uint8_t *map =
1779-
(uint8_t *)cbm_arena_alloc(a, (size_t)orig_lines + 2);
1778+
uint8_t *map = (uint8_t *)cbm_arena_alloc(a, (size_t)orig_lines + 2);
17801779
int exp_lines = preprocessed->expanded_line_count;
17811780
uint8_t *bad_rows =
17821781
exp_lines > 0 ? (uint8_t *)calloc((size_t)exp_lines + 2, 1) : NULL;
17831782
if (map && bad_rows) {
17841783
memset(map, 0, (size_t)orig_lines + 2);
17851784
cbm_mark_no_code_lines(source, source_len, map, orig_lines);
1786-
cbm_mark_pp_error_rows(pp_root, bad_rows, (uint32_t)exp_lines,
1787-
expanded, expanded_len);
1785+
cbm_mark_pp_error_rows(pp_root, bad_rows, (uint32_t)exp_lines, expanded,
1786+
expanded_len);
17881787
/* Walk the expanded text once. An expanded line
17891788
* only vouches for its original line when it
17901789
* actually HAS content: the preprocessor emits a

src/mcp/mcp.c

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,8 @@ static const tool_def_t TOOLS[] = {
492492
"File nodes with CONTAINS_FOLDER/CONTAINS_FILE edges; each File carries kind "
493493
"(\"parse_partial\" = indexed but constructs in the flagged line ranges MAY be missing; "
494494
"\"parse_unusable\" = indexed but the ranges cover nearly the whole file, so read the "
495-
"source; or a skip phase) and detail (the line ranges / reason)). Example: MATCH (f:File) WHERE "
495+
"source; or a skip phase) and detail (the line ranges / reason)). "
496+
"Example: MATCH (f:File) WHERE "
496497
"f.kind = \\\"parse_partial\\\" RETURN f.file_path, f.detail. Absence from this graph is "
497498
"NOT a completeness guarantee.",
498499
"{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"description\":\"Cypher "
@@ -645,8 +646,8 @@ static const tool_def_t TOOLS[] = {
645646
"indexing-COVERAGE report — which files the indexer could NOT fully cover (best-effort "
646647
"signal): 'parse_partial' files WERE indexed but contain line ranges tree-sitter could not "
647648
"parse — constructs there MAY be missing from the graph (some are still recovered); "
648-
"'parse_unusable' files WERE indexed too, but one range covers 80 percent or more of the file, so "
649-
"the ranges are useless advice — read the source; "
649+
"'parse_unusable' files WERE indexed too, but one range covers 80 percent or more of "
650+
"the file, so the ranges are useless advice — read the source; "
650651
"'skipped' files were not indexed at all (oversized/read/parse failure). Use this before "
651652
"trusting graph completeness on a file: if a file is listed, ALSO grep it (especially the "
652653
"flagged ranges). IMPORTANT: absence from these lists is NOT a completeness guarantee — the "
@@ -4855,8 +4856,8 @@ static void coverage_add_row_json(yyjson_mut_doc *doc, yyjson_mut_val *array,
48554856
doc, item, "match",
48564857
row->rel_path && strcmp(row->rel_path, requested_path) == 0 ? "exact" : "ancestor");
48574858
}
4858-
if (row->kind && (strcmp(row->kind, "parse_partial") == 0 ||
4859-
strcmp(row->kind, "parse_unusable") == 0)) {
4859+
if (row->kind &&
4860+
(strcmp(row->kind, "parse_partial") == 0 || strcmp(row->kind, "parse_unusable") == 0)) {
48604861
coverage_add_ranges(doc, item, row->detail);
48614862
}
48624863
yyjson_mut_arr_add_val(array, item);

src/pipeline/pass_definitions.c

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -561,9 +561,9 @@ static int objectscript_export_split_range_marker(const char *ranges, size_t *bo
561561
if (i == len || i == 0 || ranges[i - 1] != '+') {
562562
return 0;
563563
}
564-
size_t marker = i - 1; /* index of '+' */
564+
size_t marker = i - 1; /* index of '+' */
565565
if (marker > 0 && ranges[marker - 1] == ',') {
566-
marker--; /* drop the separator too */
566+
marker--; /* drop the separator too */
567567
}
568568
*body_len = marker;
569569
return atoi(ranges + i);
@@ -612,8 +612,8 @@ static bool objectscript_export_append_error_ranges(CBMFileResult *aggregate,
612612
return false;
613613
}
614614
if (dropped > 0) {
615-
combined = cbm_arena_sprintf(&aggregate->arena, "%s%s+%d", combined,
616-
combined[0] ? "," : "", dropped);
615+
combined = cbm_arena_sprintf(&aggregate->arena, "%s%s+%d", combined, combined[0] ? "," : "",
616+
dropped);
617617
if (!combined) {
618618
return false;
619619
}
@@ -622,6 +622,16 @@ static bool objectscript_export_append_error_ranges(CBMFileResult *aggregate,
622622
return true;
623623
}
624624

625+
#if defined(CBM_COVERAGE_MARKER_TEST_API) && CBM_COVERAGE_MARKER_TEST_API
626+
/* Test seam. This join only fires for a Studio Export file holding several
627+
* <Class> elements where a class overruns the 256-region cap — hard to reach
628+
* through the pipeline, easy to get wrong, and a wrong result hides ranges
629+
* without saying so. Expose the join so the marker rules can be pinned. */
630+
bool cbm_pipeline_coverage_marker_test_join(CBMFileResult *aggregate, const CBMFileResult *part) {
631+
return objectscript_export_append_error_ranges(aggregate, part);
632+
}
633+
#endif
634+
625635
/* Studio Export files may contain multiple <Class> elements, while the
626636
* pipeline cache has one slot per physical file. Extract each generated UDL
627637
* class independently (preserving the upstream parser behavior), then compose
@@ -842,10 +852,9 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t
842852
} else if (result->parse_incomplete) {
843853
/* Best-effort parse-coverage signal (#963): indexed, but with
844854
* ERROR/MISSING regions — see pass_parallel.c (keep in sync). */
845-
cbm_pipeline_add_file_error(ctx->pipeline, rel,
846-
result->error_ranges ? result->error_ranges : "unknown",
847-
result->parse_unusable ? "parse_unusable"
848-
: "parse_partial");
855+
cbm_pipeline_add_file_error(
856+
ctx->pipeline, rel, result->error_ranges ? result->error_ranges : "unknown",
857+
result->parse_unusable ? "parse_unusable" : "parse_partial");
849858
}
850859

851860
/* Create nodes for each definition */

src/pipeline/pipeline_internal.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,12 @@ void cbm_pp_bp_nap_cycles_reset(void);
856856
uint64_t cbm_pp_lsp_linear_fallback_rows(void);
857857
void cbm_pp_lsp_linear_fallback_rows_reset(void);
858858

859+
#if defined(CBM_COVERAGE_MARKER_TEST_API) && CBM_COVERAGE_MARKER_TEST_API
860+
/* Test-only view of the Studio Export range join, so the ",+<N>" truncation
861+
* marker rules can be checked without building a 256-region export file. */
862+
bool cbm_pipeline_coverage_marker_test_join(CBMFileResult *aggregate, const CBMFileResult *part);
863+
#endif
864+
859865
#if defined(CBM_CALL_REFERENCE_LOOKUP_TEST_API) && CBM_CALL_REFERENCE_LOOKUP_TEST_API
860866
/* Deterministic test-only operation count for the shared semantic-reference
861867
* matcher used by both sequential and fused-parallel usage materialization. */

tests/test_index_resilience.c

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,16 @@ TEST(index_parse_partial_reported) {
337337
ASSERT_STR_EQ("indexed", status);
338338
ASSERT_EQ(yyjson_get_int(yyjson_obj_get(sc, "skipped_count")), 0);
339339

340-
/* The coverage signal is surfaced with ranges + the best-effort note. */
340+
/* The coverage signal is surfaced with ranges + the best-effort note.
341+
* Both bounds matter. The floor catches the signal going missing. The
342+
* ceiling catches the opposite failure: exactly one of the two files has
343+
* a gap, so a count above 1 means the clean Python neighbour got flagged
344+
* as well, which is how over-flagging looks from the outside. */
341345
ASSERT_GTE(yyjson_get_int(yyjson_obj_get(sc, "parse_partial_count")), 1);
346+
ASSERT_EQ(yyjson_get_int(yyjson_obj_get(sc, "parse_partial_count")), 1);
347+
/* A local gap is not a whole-file failure, so the other coverage kind
348+
* must stay empty here. */
349+
ASSERT_EQ(yyjson_get_int(yyjson_obj_get(sc, "parse_unusable_count")), 0);
342350
yyjson_val *pp = yyjson_obj_get(sc, "parse_partial");
343351
ASSERT_NOT_NULL(pp);
344352
yyjson_val *files = yyjson_obj_get(pp, "files");
@@ -354,7 +362,13 @@ TEST(index_parse_partial_reported) {
354362
found_split = 1;
355363
ASSERT_NOT_NULL(ranges);
356364
ASSERT_GT((int)strlen(ranges), 0);
365+
/* The gap is the two-header block, not the whole file. An
366+
* 8-line file reported as 1-8 would be the old whole-file
367+
* blame coming back. */
368+
ASSERT_NULL(strstr(ranges, "1-8"));
357369
}
370+
/* The clean file must not appear in the list at all. */
371+
ASSERT_NULL(fp ? strstr(fp, "good.py") : NULL);
358372
}
359373
ASSERT_TRUE(found_split);
360374
const char *note = yyjson_get_str(yyjson_obj_get(pp, "note"));

tests/test_mcp.c

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2750,6 +2750,86 @@ TEST(tool_check_index_coverage_finds_path_beyond_status_cap) {
27502750
PASS();
27512751
}
27522752

2753+
/* The range string can carry a trailing ",+<N>" marker saying the producer hit
2754+
* its own cap and threw ranges away. The reader must emit every range in front
2755+
* of the marker, must not turn the marker itself into a range, and must say
2756+
* "truncated" so nobody reads a short list as a complete one. The reader has a
2757+
* second cap of its own, and that one must report itself the same way. */
2758+
TEST(tool_check_index_coverage_reports_truncation_marker_issue963) {
2759+
enum { WIDE_RANGE_COUNT = 300 };
2760+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
2761+
ASSERT_NOT_NULL(srv);
2762+
cbm_store_t *st = cbm_mcp_server_store(srv);
2763+
ASSERT_NOT_NULL(st);
2764+
2765+
const char *project = "coverage-marker";
2766+
ASSERT_EQ(cbm_store_upsert_project(st, project, "/tmp/coverage-marker"), CBM_STORE_OK);
2767+
cbm_mcp_server_set_project(srv, project);
2768+
2769+
/* 300 one-line ranges — past the reader's own 256 limit. */
2770+
char *wide = calloc(1, WIDE_RANGE_COUNT * 12 + 1);
2771+
ASSERT_NOT_NULL(wide);
2772+
size_t off = 0;
2773+
for (int i = 0; i < WIDE_RANGE_COUNT; i++) {
2774+
off += (size_t)snprintf(wide + off, WIDE_RANGE_COUNT * 12 + 1 - off, "%s%d-%d",
2775+
i ? "," : "", i * 3 + 1, i * 3 + 1);
2776+
}
2777+
2778+
cbm_coverage_row_t rows[3] = {
2779+
{.rel_path = "src/marked.c", .kind = "parse_partial", .detail = "3-4,9-9,+12"},
2780+
{.rel_path = "src/plain.c", .kind = "parse_partial", .detail = "3-4,9-9"},
2781+
{.rel_path = "src/wide.c", .kind = "parse_partial", .detail = wide},
2782+
};
2783+
for (int i = 0; i < 3; i++) {
2784+
ASSERT_EQ(cbm_store_upsert_file_hash(st, project, rows[i].rel_path, "fixture", i + 1, 10),
2785+
CBM_STORE_OK);
2786+
}
2787+
ASSERT_EQ(cbm_store_coverage_replace(st, project, rows, 3), CBM_STORE_OK);
2788+
2789+
/* The marked file: both real ranges survive, the marker is flagged, and the
2790+
* "12" from the marker never becomes a range of its own. */
2791+
char *marked =
2792+
cbm_mcp_handle_tool(srv, "check_index_coverage",
2793+
"{\"project\":\"coverage-marker\",\"paths\":[\"src/marked.c\"]}");
2794+
ASSERT_NOT_NULL(marked);
2795+
char *marked_inner = extract_text_content(marked);
2796+
ASSERT_NOT_NULL(marked_inner);
2797+
ASSERT_NOT_NULL(strstr(marked_inner, "\"start\":3"));
2798+
ASSERT_NOT_NULL(strstr(marked_inner, "\"start\":9"));
2799+
ASSERT_NULL(strstr(marked_inner, "\"start\":12"));
2800+
ASSERT_NOT_NULL(strstr(marked_inner, "\"truncated\":true"));
2801+
free(marked_inner);
2802+
free(marked);
2803+
2804+
/* The same ranges without a marker must NOT be reported as truncated. */
2805+
char *plain =
2806+
cbm_mcp_handle_tool(srv, "check_index_coverage",
2807+
"{\"project\":\"coverage-marker\",\"paths\":[\"src/plain.c\"]}");
2808+
ASSERT_NOT_NULL(plain);
2809+
char *plain_inner = extract_text_content(plain);
2810+
ASSERT_NOT_NULL(plain_inner);
2811+
ASSERT_NOT_NULL(strstr(plain_inner, "\"start\":3"));
2812+
ASSERT_NULL(strstr(plain_inner, "\"truncated\":true"));
2813+
free(plain_inner);
2814+
free(plain);
2815+
2816+
/* The reader's own limit stops the list early, so it must say so even
2817+
* though the producer sent no marker. */
2818+
char *widest =
2819+
cbm_mcp_handle_tool(srv, "check_index_coverage",
2820+
"{\"project\":\"coverage-marker\",\"paths\":[\"src/wide.c\"]}");
2821+
ASSERT_NOT_NULL(widest);
2822+
char *wide_inner = extract_text_content(widest);
2823+
ASSERT_NOT_NULL(wide_inner);
2824+
ASSERT_NOT_NULL(strstr(wide_inner, "\"truncated\":true"));
2825+
free(wide_inner);
2826+
free(widest);
2827+
2828+
free(wide);
2829+
cbm_mcp_server_free(srv);
2830+
PASS();
2831+
}
2832+
27532833
TEST(tool_check_index_coverage_reports_paths_scopes_and_ranges) {
27542834
char tmp[256];
27552835
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
@@ -13571,6 +13651,7 @@ SUITE(mcp) {
1357113651
RUN_TEST(tool_query_graph_basic);
1357213652
RUN_TEST(tool_index_status_no_project);
1357313653
RUN_TEST(tool_check_index_coverage_finds_path_beyond_status_cap);
13654+
RUN_TEST(tool_check_index_coverage_reports_truncation_marker_issue963);
1357413655
RUN_TEST(tool_check_index_coverage_reports_paths_scopes_and_ranges);
1357513656
RUN_TEST(tool_check_index_coverage_preserves_multiple_scope_labels);
1357613657
RUN_TEST(tool_check_index_coverage_accepts_truncated_ignored_catalog_for_fresh_path_issue1613);

tests/test_parse_coverage.c

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
* GREEN (fixed): cbm_extract_file sets parse_incomplete=true iff the tree
2121
* contains ERROR/MISSING nodes, records the 1-based line
2222
* ranges of the TOP-MOST error regions ("start-end,..."),
23-
* bounded by the 64-region cap, and clean files stay
23+
* bounded by the 256-region cap, and clean files stay
2424
* completely unflagged (no false positives).
2525
*
2626
* BEST-EFFORT framing (must never be weakened the other way): a flag means
@@ -668,6 +668,49 @@ TEST(c_ifdef_split_is_partial_never_unusable) {
668668
PASS();
669669
}
670670

671+
/* Phase 0 finding 2, pinned so a tree-sitter bump cannot change it quietly.
672+
*
673+
* The C grammar handles `_Thread_local` unevenly, and these are the three
674+
* forms measured on the grammar shipped today:
675+
*
676+
* static _Thread_local int x = 0; parses clean
677+
* static _Thread_local int *p; parses clean
678+
* static _Thread_local char b[8]; fails — flagged as range 1-1
679+
*
680+
* The array line really is missing from the graph, so flagging it is the
681+
* honest answer, not a false positive. This test exists to make a grammar
682+
* bump visible: if a newer grammar fixes the array form, this goes red and
683+
* says so, instead of leaving a wrong note in the plan. (The plan's Phase 0
684+
* also listed the pointer form as failing. It does not fail today.) */
685+
TEST(c_thread_local_grammar_limit_is_pinned_issue963) {
686+
CBMFileResult *ok = do_extract("static _Thread_local int x = 0;\n"
687+
"void f(void) { x = 1; }\n",
688+
CBM_LANG_C, "tls_init.c");
689+
ASSERT_NOT_NULL(ok);
690+
ASSERT_FALSE(ok->parse_incomplete);
691+
cbm_free_result(ok);
692+
693+
CBMFileResult *ptr = do_extract("static _Thread_local int *p;\n"
694+
"void f(void) { p = 0; }\n",
695+
CBM_LANG_C, "tls_ptr.c");
696+
ASSERT_NOT_NULL(ptr);
697+
ASSERT_FALSE(ptr->parse_incomplete);
698+
cbm_free_result(ptr);
699+
700+
CBMFileResult *arr = do_extract("static _Thread_local char b[8];\n"
701+
"void f(void) { b[0] = 0; }\n",
702+
CBM_LANG_C, "tls_arr.c");
703+
ASSERT_NOT_NULL(arr);
704+
ASSERT_TRUE(arr->parse_incomplete);
705+
ASSERT_NOT_NULL(arr->error_ranges);
706+
/* The range names the one broken line, not the whole file. */
707+
ASSERT_STR_EQ("1-1", arr->error_ranges);
708+
/* The clean function below it still reaches the graph. */
709+
ASSERT_TRUE(has_def(arr, "f"));
710+
cbm_free_result(arr);
711+
PASS();
712+
}
713+
671714
SUITE(parse_coverage) {
672715
RUN_TEST(c_ifdef_split_brace_sets_parse_incomplete);
673716
RUN_TEST(c_ifdef_split_brace_neighbors_still_extracted);
@@ -698,4 +741,5 @@ SUITE(parse_coverage) {
698741
RUN_TEST(c_ifdef_split_range_never_starts_on_a_directive);
699742
RUN_TEST(c_refinement_does_not_suppress_real_garbage);
700743
RUN_TEST(c_clean_file_stays_unflagged_after_refinement);
744+
RUN_TEST(c_thread_local_grammar_limit_is_pinned_issue963);
701745
}

0 commit comments

Comments
 (0)