mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
feat(index): missed graph in UI + read-time coverage hook + recovery subtraction
Four refinements to the coverage signal:
- extraction: subtract DEFINITE recovery before flagging. Tree-sitter
error recovery plus the ERROR-descending def walker often re-extract
constructs inside a failed region (verified: `def broken(:` comes
back as a def); a region whose every line is covered by definitions
that START inside it is not a miss, and a fully recovered file is not
flagged at all. Container defs (Module/Package) don't count as
evidence, and partially covered regions stay flagged — the
#ifdef-split case keeps its flag because the first branch's function
is genuinely lost
- naming: the query_graph option is graph="missed" (the graph shows
ONLY misses — "coverage" was misleading); shadow project renamed to
"<name>::missed"; tool descriptions updated so agents discover both
the option and its semantics
- hook: the CLI-installed PreToolUse augmenter now also matches Read
and injects a coverage note when the file being read is listed as not
fully indexed ("line ranges X-Y could not be parsed — the file
content you are reading is ground truth"). Safe against the old
issue-362 hazard: the augmenter is structurally non-blocking (always
exit 0, additionalContext only), mirroring the Gemini matcher that
already includes read_file; matcher upgrade bookkeeping updated
- ui: "Missed files" toggle in the graph sidebar renders the miss
graph as a second graph option — /api/layout gains graph=missed
(same db file, shadow-project scoping; base project name validated
as before)
Tests: recovery-subtraction cases (recovered def unflagged, garbage
region flagged, trailing recovered defs keep the flag), CLI matcher
tests updated, e2e resilience fixtures switched to an unrecovered
miss; verified end-to-end: hook emits the note for a flagged file and
stays silent otherwise, /api/layout?graph=missed serves the miss graph
and the code graph is unchanged, frontend build + tests green.
Refs #963
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
f9d7f4aa61
commit
664313e4ac
14 files changed
+464
-90
No files matched your search
@@ -22,6 +22,9 @@ interface FilterPanelProps {
|
||||
onToggleShowOnlyDead: () => void;
|
||||
onToggleHideEntryPoints: () => void;
|
||||
onToggleHideTests: () => void;
|
||||
/* Missed graph (#963): file structure of not-fully-indexed files */
|
||||
missedView: boolean;
|
||||
onToggleMissedView: () => void;
|
||||
}
|
||||
|
||||
/* Checkbox row matching the existing "Show labels" toggle style */
|
||||
@@ -76,6 +79,8 @@ export function FilterPanel({
|
||||
onToggleShowOnlyDead,
|
||||
onToggleHideEntryPoints,
|
||||
onToggleHideTests,
|
||||
missedView,
|
||||
onToggleMissedView,
|
||||
}: FilterPanelProps) {
|
||||
const { labelCounts, edgeTypeCounts, statusCounts } = useMemo(() => {
|
||||
const lc = new Map<string, number>();
|
||||
@@ -163,6 +168,27 @@ export function FilterPanel({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Missed graph (#963): swaps the layout to the file structure of files
|
||||
the indexer could not fully cover (best-effort signal). */}
|
||||
<div className="px-4 pt-2 border-t border-border/30 space-y-2 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-foreground/30 uppercase tracking-widest">
|
||||
Graph
|
||||
</span>
|
||||
</div>
|
||||
<CheckRow
|
||||
checked={missedView}
|
||||
onToggle={onToggleMissedView}
|
||||
label="Missed files (not fully indexed)"
|
||||
/>
|
||||
{missedView && (
|
||||
<p className="text-[9px] leading-snug text-foreground/30">
|
||||
Best-effort: files with unparseable regions or skips. Empty = no
|
||||
known misses (not a completeness guarantee).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dead-code view */}
|
||||
<div className="px-4 pt-2 border-t border-border/30 space-y-2 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -102,6 +102,11 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
const [enabledLabels, setEnabledLabels] = useState<Set<string>>(new Set());
|
||||
const [enabledEdgeTypes, setEnabledEdgeTypes] = useState<Set<string>>(new Set());
|
||||
|
||||
/* Missed graph (#963): render the file structure of files the indexer
|
||||
* could not fully cover instead of the code graph. Server-side option
|
||||
* (`graph=missed` on /api/layout) — toggling refetches. */
|
||||
const [missedView, setMissedView] = useState(false);
|
||||
|
||||
/* Dead-code view: recolor by status + status-based filters */
|
||||
const [deadCodeView, setDeadCodeView] = useState(false);
|
||||
const [showOnlyDead, setShowOnlyDead] = useState(false);
|
||||
@@ -184,11 +189,11 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
/* …and fetch only once budget and project agree (one fetch per change). */
|
||||
useEffect(() => {
|
||||
if (project && budget.project === project) {
|
||||
fetchOverview(project, budget.value);
|
||||
fetchOverview(project, budget.value, missedView ? "missed" : "code");
|
||||
setHighlightedIds(null);
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}, [project, budget, fetchOverview]);
|
||||
}, [project, budget, missedView, fetchOverview]);
|
||||
|
||||
/* Fetch git remote metadata for GitHub deep-links */
|
||||
useEffect(() => {
|
||||
@@ -351,6 +356,8 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
onToggleShowOnlyDead={() => setShowOnlyDead((v) => !v)}
|
||||
onToggleHideEntryPoints={() => setHideEntryPoints((v) => !v)}
|
||||
onToggleHideTests={() => setHideTests((v) => !v)}
|
||||
missedView={missedView}
|
||||
onToggleMissedView={() => setMissedView((v) => !v)}
|
||||
/>
|
||||
<Sidebar
|
||||
nodes={filteredData.nodes}
|
||||
|
||||
@@ -11,7 +11,11 @@ interface UseGraphDataResult {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
progress: LoadProgress;
|
||||
fetchOverview: (project: string, maxNodes?: number) => void;
|
||||
fetchOverview: (
|
||||
project: string,
|
||||
maxNodes?: number,
|
||||
graph?: "code" | "missed",
|
||||
) => void;
|
||||
fetchDetail: (project: string, centerNode: string) => void;
|
||||
}
|
||||
|
||||
@@ -32,12 +36,18 @@ export function clampNodeBudget(value: number): number {
|
||||
return stepped;
|
||||
}
|
||||
|
||||
/** Which graph to lay out: the code graph (default) or the missed graph —
|
||||
* only files the indexer could not fully cover, as their file structure. */
|
||||
export type GraphVariant = "code" | "missed";
|
||||
|
||||
export async function fetchLayout(
|
||||
project: string,
|
||||
maxNodes = GRAPH_RENDER_NODE_LIMIT,
|
||||
onProgress?: (progress: LoadProgress) => void,
|
||||
graph: GraphVariant = "code",
|
||||
): Promise<GraphData> {
|
||||
const params = new URLSearchParams({ project, max_nodes: String(maxNodes) });
|
||||
if (graph === "missed") params.set("graph", "missed");
|
||||
const res = await fetch(`/api/layout?${params}`);
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -83,12 +93,12 @@ export function useGraphData(): UseGraphDataResult {
|
||||
const [progress, setProgress] = useState<LoadProgress>(NO_PROGRESS);
|
||||
|
||||
const fetchOverview = useCallback(
|
||||
async (project: string, maxNodes?: number) => {
|
||||
async (project: string, maxNodes?: number, graph: GraphVariant = "code") => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgress(NO_PROGRESS);
|
||||
try {
|
||||
const result = await fetchLayout(project, maxNodes, setProgress);
|
||||
const result = await fetchLayout(project, maxNodes, setProgress, graph);
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to fetch layout");
|
||||
|
||||
+86
-16
@@ -725,6 +725,72 @@ static void cbm_collect_error_regions(TSNode n, cbm_error_regions_t *acc) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Recovery subtraction (#963): tree-sitter error recovery plus the
|
||||
* ERROR-descending def walker often still extract constructs INSIDE a failed
|
||||
* region (verified: a function in an #ifdef-split ERROR region and even a
|
||||
* `def broken(:` both came back as defs). A region whose every line is
|
||||
* covered by definitions that START inside it is definitely recovered — its
|
||||
* constructs ARE in the graph — so flagging it would be a false miss.
|
||||
* Container defs (Module/Package) are ignored: a file-spanning Module node is
|
||||
* not evidence the region's constructs survived. Conservative: partially
|
||||
* covered regions stay flagged. */
|
||||
static bool cbm_region_is_recovered(uint32_t rs, uint32_t re, const CBMDefArray *defs) {
|
||||
enum { MAX_COVER_DEFS = 256 };
|
||||
uint32_t starts[MAX_COVER_DEFS];
|
||||
uint32_t ends[MAX_COVER_DEFS];
|
||||
int n = 0;
|
||||
for (int i = 0; i < defs->count && n < MAX_COVER_DEFS; i++) {
|
||||
const CBMDefinition *d = &defs->items[i];
|
||||
if (!d->label || strcmp(d->label, "Module") == 0 || strcmp(d->label, "Package") == 0) {
|
||||
continue;
|
||||
}
|
||||
if (d->start_line < rs || d->start_line > re) {
|
||||
continue; /* recovery evidence must originate inside the region */
|
||||
}
|
||||
starts[n] = d->start_line;
|
||||
ends[n] = d->end_line < d->start_line ? d->start_line : d->end_line;
|
||||
n++;
|
||||
}
|
||||
if (n == 0) {
|
||||
return false;
|
||||
}
|
||||
/* Insertion-sort by start, then sweep for gaps in [rs, re]. */
|
||||
for (int i = 1; i < n; i++) {
|
||||
uint32_t s = starts[i];
|
||||
uint32_t e = ends[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && starts[j] > s) {
|
||||
starts[j + 1] = starts[j];
|
||||
ends[j + 1] = ends[j];
|
||||
j--;
|
||||
}
|
||||
starts[j + 1] = s;
|
||||
ends[j + 1] = e;
|
||||
}
|
||||
uint32_t covered_to = rs - 1;
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (starts[i] > covered_to + 1) {
|
||||
return false; /* uncovered gap */
|
||||
}
|
||||
if (ends[i] > covered_to) {
|
||||
covered_to = ends[i];
|
||||
}
|
||||
}
|
||||
return covered_to >= re;
|
||||
}
|
||||
|
||||
static void cbm_subtract_recovered_regions(cbm_error_regions_t *regs, const CBMDefArray *defs) {
|
||||
int kept = 0;
|
||||
for (int i = 0; i < regs->count; i++) {
|
||||
if (!cbm_region_is_recovered(regs->starts[i], regs->ends[i], defs)) {
|
||||
regs->starts[kept] = regs->starts[i];
|
||||
regs->ends[kept] = regs->ends[i];
|
||||
kept++;
|
||||
}
|
||||
}
|
||||
regs->count = kept;
|
||||
}
|
||||
|
||||
/* Serialize collected regions as "start-end,start-end,..." into the arena. */
|
||||
static const char *cbm_error_ranges_str(CBMArena *a, const cbm_error_regions_t *regs) {
|
||||
if (regs->count <= 0) {
|
||||
@@ -843,22 +909,6 @@ static CBMFileResult *cbm_extract_file_impl(const char *source, int source_len,
|
||||
|
||||
TSNode root = ts_tree_root_node(tree);
|
||||
|
||||
/* Best-effort parse-coverage signal (#963): flag files whose tree contains
|
||||
* ERROR/MISSING nodes — constructs inside those regions are silently absent
|
||||
* from the graph. Detection aid only: the absence of this flag is NOT a
|
||||
* completeness guarantee. */
|
||||
if (ts_node_has_error(root)) {
|
||||
result->parse_incomplete = true;
|
||||
cbm_error_regions_t regs = {{0}, {0}, 0};
|
||||
if (strcmp(ts_node_type(root), "ERROR") == 0) {
|
||||
cbm_error_regions_push(®s, root); /* whole file unparseable */
|
||||
} else {
|
||||
cbm_collect_error_regions(root, ®s);
|
||||
}
|
||||
result->error_region_count = regs.count;
|
||||
result->error_ranges = cbm_error_ranges_str(a, ®s);
|
||||
}
|
||||
|
||||
// Compute module QN. Java/Go derive the module from the CONTAINING
|
||||
// DIRECTORY (package semantics) rather than baking the filename stem in,
|
||||
// so def QNs, the LSP caller_qn, and the textual calls-enclosing QN all
|
||||
@@ -1108,6 +1158,26 @@ static CBMFileResult *cbm_extract_file_impl(const char *source, int source_len,
|
||||
|
||||
uint64_t t2 = now_ns();
|
||||
|
||||
/* Best-effort parse-coverage signal (#963): flag files whose tree contains
|
||||
* ERROR/MISSING regions. Computed AFTER extraction so definite recovery is
|
||||
* subtracted first — a region fully re-extracted as definitions is not a
|
||||
* miss, and a fully recovered file is not flagged at all. Detection aid
|
||||
* only: the absence of this flag is NOT a completeness guarantee. */
|
||||
if (ts_node_has_error(root)) {
|
||||
cbm_error_regions_t regs = {{0}, {0}, 0};
|
||||
if (strcmp(ts_node_type(root), "ERROR") == 0) {
|
||||
cbm_error_regions_push(®s, root); /* whole file unparseable */
|
||||
} else {
|
||||
cbm_collect_error_regions(root, ®s);
|
||||
}
|
||||
cbm_subtract_recovered_regions(®s, &result->defs);
|
||||
if (regs.count > 0) {
|
||||
result->parse_incomplete = true;
|
||||
result->error_region_count = regs.count;
|
||||
result->error_ranges = cbm_error_ranges_str(a, ®s);
|
||||
}
|
||||
}
|
||||
|
||||
result->imports_count = result->imports.count;
|
||||
|
||||
// Accumulate profiling counters
|
||||
|
||||
+8
-5
@@ -1789,10 +1789,13 @@ int cbm_remove_junie_mcp(const char *config_path) {
|
||||
|
||||
/* ── Claude Code pre-tool hooks ───────────────────────────────── */
|
||||
|
||||
/* Matcher intentionally excludes Read: gating Read breaks Claude Code's
|
||||
* read-before-edit invariant (issue #362). The hook is a non-blocking
|
||||
* augmenter, never a gate. */
|
||||
#define CMM_HOOK_MATCHER "Grep|Glob"
|
||||
/* Matcher includes Read for the indexing-coverage note (#963): when the agent
|
||||
* reads a file the indexer could not fully cover, the hook injects a warning
|
||||
* as additionalContext. The issue-#362 hazard (a GATING hook denying Read and
|
||||
* breaking the read-before-edit invariant) cannot recur: the augmenter is
|
||||
* structurally non-blocking — it always exits 0 and only ever ADDS context —
|
||||
* mirroring the Gemini matcher, which already includes read_file. */
|
||||
#define CMM_HOOK_MATCHER "Grep|Glob|Read"
|
||||
/* Basename only; the full command path is resolved at install time via
|
||||
* cbm_resolve_hook_command so $CLAUDE_CONFIG_DIR is honored. */
|
||||
#define CMM_HOOK_GATE_SCRIPT "cbm-code-discovery-gate"
|
||||
@@ -1805,7 +1808,7 @@ int cbm_remove_junie_mcp(const char *config_path) {
|
||||
* Per-agent lists (no shared global): each caller passes its own. */
|
||||
static const char *const cmm_claude_old_matchers[] = {
|
||||
"Grep|Glob|Read|Search",
|
||||
"Grep|Glob|Read",
|
||||
"Grep|Glob", /* pre-#963 matcher — Read re-added for the coverage note */
|
||||
NULL,
|
||||
};
|
||||
static const char *const cmm_gemini_old_matchers[] = {
|
||||
|
||||
+179
-1
@@ -225,6 +225,153 @@ static char *ha_format_context(const char *envelope, const char *token, bool *is
|
||||
return text;
|
||||
}
|
||||
|
||||
/* ── Read coverage note (#963) ────────────────────────────────────
|
||||
* For Read calls: if the file being read is listed in the project's
|
||||
* index_coverage table (parse_partial or a skip), inject a note so the agent
|
||||
* knows the knowledge graph may under-report this file. Best-effort and
|
||||
* non-blocking like everything else here — no entry, no output. */
|
||||
|
||||
/* Parse a get_index_coverage envelope and return a note when `rel` is listed.
|
||||
* *is_error is set for MCP errors (project not indexed) → caller climbs. */
|
||||
static char *ha_coverage_context(const char *envelope, const char *rel, bool *is_error) {
|
||||
*is_error = false;
|
||||
yyjson_doc *edoc = yyjson_read(envelope, strlen(envelope), 0);
|
||||
if (!edoc) {
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *eroot = yyjson_doc_get_root(edoc);
|
||||
yyjson_val *err = yyjson_obj_get(eroot, "isError");
|
||||
if (err && yyjson_is_true(err)) {
|
||||
*is_error = true;
|
||||
yyjson_doc_free(edoc);
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *content = yyjson_obj_get(eroot, "content");
|
||||
yyjson_val *item0 = (content && yyjson_is_arr(content)) ? yyjson_arr_get(content, 0) : NULL;
|
||||
const char *inner = ha_obj_str(item0, "text");
|
||||
if (!inner) {
|
||||
yyjson_doc_free(edoc);
|
||||
return NULL;
|
||||
}
|
||||
yyjson_doc *idoc = yyjson_read(inner, strlen(inner), 0);
|
||||
if (!idoc) {
|
||||
yyjson_doc_free(edoc);
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *iroot = yyjson_doc_get_root(idoc);
|
||||
char *text = NULL;
|
||||
|
||||
yyjson_val *pp = yyjson_obj_get(iroot, "parse_partial");
|
||||
yyjson_val *files = pp ? yyjson_obj_get(pp, "files") : NULL;
|
||||
size_t idx;
|
||||
size_t maxn;
|
||||
yyjson_val *fe;
|
||||
if (files && yyjson_is_arr(files)) {
|
||||
yyjson_arr_foreach(files, idx, maxn, fe) {
|
||||
const char *fp = ha_obj_str(fe, "path");
|
||||
if (fp && strcmp(fp, rel) == 0) {
|
||||
const char *ranges = ha_obj_str(fe, "error_ranges");
|
||||
text = malloc(1024);
|
||||
if (text) {
|
||||
snprintf(text, 1024,
|
||||
"[codebase-memory] Coverage note: this file was only PARTIALLY "
|
||||
"indexed — line range(s) %s could not be parsed, so constructs "
|
||||
"there may be missing from the knowledge graph. The file content "
|
||||
"you are reading is ground truth; graph queries may under-report "
|
||||
"this file. (best-effort signal)",
|
||||
ranges && ranges[0] ? ranges : "?");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text) {
|
||||
yyjson_val *sk = yyjson_obj_get(iroot, "skipped");
|
||||
files = sk ? yyjson_obj_get(sk, "files") : NULL;
|
||||
if (files && yyjson_is_arr(files)) {
|
||||
yyjson_arr_foreach(files, idx, maxn, fe) {
|
||||
const char *fp = ha_obj_str(fe, "path");
|
||||
if (fp && strcmp(fp, rel) == 0) {
|
||||
const char *phase = ha_obj_str(fe, "phase");
|
||||
const char *reason = ha_obj_str(fe, "reason");
|
||||
text = malloc(1024);
|
||||
if (text) {
|
||||
snprintf(text, 1024,
|
||||
"[codebase-memory] Coverage note: this file was NOT indexed "
|
||||
"(%s%s%s) — the knowledge graph has no data for it. "
|
||||
"(best-effort signal)",
|
||||
phase ? phase : "skipped", reason && reason[0] ? ": " : "",
|
||||
reason ? reason : "");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
yyjson_doc_free(idoc);
|
||||
yyjson_doc_free(edoc);
|
||||
return text;
|
||||
}
|
||||
|
||||
/* Strip the last path component in place. Returns false at a filesystem or
|
||||
* drive root (nothing left to strip). */
|
||||
static bool ha_strip_last_component(char *dir) {
|
||||
char *slash = strrchr(dir, '/');
|
||||
if (!slash || slash == dir) {
|
||||
return false; /* POSIX root "/" */
|
||||
}
|
||||
if (slash == dir + 2 && dir[1] == ':') {
|
||||
return false; /* Windows drive root "X:/" — don't strip to "X:" */
|
||||
}
|
||||
*slash = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Walk up from the file's parent directory to find the indexed project, then
|
||||
* check whether the file (repo-relative) is listed in its coverage report.
|
||||
* Mirrors ha_resolve_and_query: an MCP error means "not indexed here" →
|
||||
* climb; a valid project with no entry for this file → stop, no output. */
|
||||
static char *ha_resolve_coverage(cbm_mcp_server_t *srv, const char *file_path) {
|
||||
char dir[4096];
|
||||
snprintf(dir, sizeof(dir), "%s", file_path);
|
||||
if (!ha_strip_last_component(dir)) {
|
||||
return NULL; /* file directly at a root — nothing to resolve against */
|
||||
}
|
||||
|
||||
for (int level = 0; level < HA_MAX_WALKUP && cbm_hook_path_is_abs(dir); level++) {
|
||||
char *project = cbm_project_name_from_path(dir);
|
||||
if (project) {
|
||||
yyjson_mut_doc *adoc = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *aroot = yyjson_mut_obj(adoc);
|
||||
yyjson_mut_doc_set_root(adoc, aroot);
|
||||
yyjson_mut_obj_add_str(adoc, aroot, "project", project);
|
||||
char *args = yyjson_mut_write(adoc, 0, NULL);
|
||||
yyjson_mut_doc_free(adoc);
|
||||
free(project);
|
||||
if (args) {
|
||||
char *res = cbm_mcp_handle_tool(srv, "get_index_coverage", args);
|
||||
free(args);
|
||||
if (res) {
|
||||
bool is_error = false;
|
||||
const char *rel = file_path + strlen(dir) + 1;
|
||||
char *ctx = ha_coverage_context(res, rel, &is_error);
|
||||
free(res);
|
||||
if (ctx) {
|
||||
return ctx; /* listed → note */
|
||||
}
|
||||
if (!is_error) {
|
||||
return NULL; /* indexed project, file not listed → stop */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ha_strip_last_component(dir)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Emit the PreToolUse additionalContext payload to stdout (exactly once). */
|
||||
static void ha_emit(const char *text) {
|
||||
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
||||
@@ -314,13 +461,44 @@ int cbm_cmd_hook_augment(void) {
|
||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||
|
||||
const char *tool = ha_obj_str(root, "tool_name");
|
||||
if (!tool || (strcmp(tool, "Grep") != 0 && strcmp(tool, "Glob") != 0)) {
|
||||
if (!tool ||
|
||||
(strcmp(tool, "Grep") != 0 && strcmp(tool, "Glob") != 0 && strcmp(tool, "Read") != 0)) {
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
|
||||
yyjson_val *tin = yyjson_obj_get(root, "tool_input");
|
||||
|
||||
/* Read → coverage note (#963): warn when the file being read is listed as
|
||||
* not fully indexed. Independent of the Grep/Glob symbol augment below. */
|
||||
if (strcmp(tool, "Read") == 0) {
|
||||
const char *fp = ha_obj_str(tin, "file_path");
|
||||
char fpbuf[4096];
|
||||
if (fp) {
|
||||
snprintf(fpbuf, sizeof(fpbuf), "%s", fp);
|
||||
for (char *p = fpbuf; *p; p++) {
|
||||
if (*p == '\\') {
|
||||
*p = '/';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fp && cbm_hook_path_is_abs(fpbuf)) {
|
||||
cbm_mcp_server_t *rsrv = cbm_mcp_server_new(NULL);
|
||||
if (rsrv) {
|
||||
char *note = ha_resolve_coverage(rsrv, fpbuf);
|
||||
if (note) {
|
||||
ha_emit(note);
|
||||
free(note);
|
||||
}
|
||||
cbm_mcp_server_free(rsrv);
|
||||
}
|
||||
}
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *pattern = ha_obj_str(tin, "pattern");
|
||||
char token[HA_MAX_TOKEN + 1];
|
||||
if (!ha_extract_token(pattern, token, sizeof(token))) {
|
||||
|
||||
+12
-12
@@ -321,7 +321,7 @@ static const tool_def_t TOOLS[] = {
|
||||
"indexed at all: oversized/read/parse failures) and 'parse_partial' (indexed, but "
|
||||
"constructs inside the listed line ranges could not be parsed and MAY be missing from "
|
||||
"the graph). Query the persisted signal any time via the get_index_coverage tool or "
|
||||
"structurally via query_graph(graph=\"coverage\"). Both signals are best-effort: absence "
|
||||
"structurally via query_graph(graph=\"missed\"). Both signals are best-effort: absence "
|
||||
"of a flag is NOT a completeness guarantee; prefer grep inside flagged ranges.",
|
||||
"{\"type\":\"object\",\"properties\":{\"repo_path\":{\"type\":\"string\",\"description\":"
|
||||
"\"Path to the repository\"},"
|
||||
@@ -395,8 +395,8 @@ static const tool_def_t TOOLS[] = {
|
||||
"Find all hot-path candidates in one query, e.g. MATCH (f:Function) WHERE "
|
||||
"f.transitive_loop_depth >= 3 OR f.linear_scan_in_loop >= 1 RETURN f.qualified_name, "
|
||||
"f.transitive_loop_depth, f.linear_scan_in_loop ORDER BY f.transitive_loop_depth DESC. "
|
||||
"COVERAGE GRAPH: pass graph=\"coverage\" to query the best-effort miss graph instead — "
|
||||
"the file structure of files the indexer could NOT fully cover (Project → Folder → "
|
||||
"MISSED GRAPH: pass graph=\"missed\" to query the best-effort miss graph instead — the "
|
||||
"file structure of ONLY the files the indexer could NOT fully index (Project → Folder → "
|
||||
"File nodes with CONTAINS_FOLDER/CONTAINS_FILE edges; each File carries kind "
|
||||
"(\"parse_partial\" = indexed but constructs in the flagged line ranges MAY be missing; "
|
||||
"or a skip phase) and detail (the line ranges / reason)). Example: MATCH (f:File) WHERE "
|
||||
@@ -404,9 +404,9 @@ static const tool_def_t TOOLS[] = {
|
||||
"NOT a completeness guarantee.",
|
||||
"{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\",\"description\":\"Cypher "
|
||||
"query\"},\"project\":{\"type\":\"string\"},"
|
||||
"\"graph\":{\"type\":\"string\",\"enum\":[\"code\",\"coverage\"],\"default\":\"code\","
|
||||
"\"graph\":{\"type\":\"string\",\"enum\":[\"code\",\"missed\"],\"default\":\"code\","
|
||||
"\"description\":\"Which graph to query: the code knowledge graph (default) or the "
|
||||
"indexing-coverage miss graph (files not fully indexed, as a file-structure graph).\"},"
|
||||
"missed graph (only files not fully indexed, laid out as their file structure).\"},"
|
||||
"\"max_rows\":{\"type\":\"integer\","
|
||||
"\"description\":"
|
||||
"\"Optional row limit. Default: unlimited up to a 100k row "
|
||||
@@ -507,7 +507,7 @@ static const tool_def_t TOOLS[] = {
|
||||
"completeness on a file: if a file is listed, ALSO grep it (especially the flagged ranges). "
|
||||
"IMPORTANT: absence from this list is NOT a completeness guarantee — the signal only marks "
|
||||
"what the indexer can detect. For structural queries over the misses use "
|
||||
"query_graph(graph=\"coverage\").",
|
||||
"query_graph(graph=\"missed\").",
|
||||
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
|
||||
"\"project\"]}"},
|
||||
|
||||
@@ -2087,20 +2087,20 @@ static char *handle_query_graph(cbm_mcp_server_t *srv, const char *args) {
|
||||
cbm_store_t *store = resolve_store(srv, project);
|
||||
int max_rows = cbm_mcp_get_int_arg(args, "max_rows", 0);
|
||||
|
||||
/* graph="coverage" (#963): run the SAME cypher against the derived
|
||||
* miss-graph view (shadow project "<project>::coverage") instead of the
|
||||
/* graph="missed" (#963): run the SAME cypher against the derived
|
||||
* miss-graph view (shadow project "<project>::missed") instead of the
|
||||
* code graph — file structure of not-fully-indexed files only. */
|
||||
char *graph_arg = cbm_mcp_get_string_arg(args, "graph");
|
||||
bool coverage_graph = graph_arg && strcmp(graph_arg, "coverage") == 0;
|
||||
bool missed_graph = graph_arg && strcmp(graph_arg, "missed") == 0;
|
||||
free(graph_arg);
|
||||
|
||||
if (!query) {
|
||||
free(project);
|
||||
return cbm_mcp_text_result("query is required", true);
|
||||
}
|
||||
if (coverage_graph && !project) {
|
||||
if (missed_graph && !project) {
|
||||
free(query);
|
||||
return cbm_mcp_text_result("project is required when graph=\"coverage\"", true);
|
||||
return cbm_mcp_text_result("project is required when graph=\"missed\"", true);
|
||||
}
|
||||
if (!store) {
|
||||
char *_err = build_project_list_error("project not found or not indexed");
|
||||
@@ -2120,7 +2120,7 @@ static char *handle_query_graph(cbm_mcp_server_t *srv, const char *args) {
|
||||
|
||||
char covproj[CBM_SZ_512];
|
||||
const char *cypher_project = project;
|
||||
if (coverage_graph) {
|
||||
if (missed_graph) {
|
||||
cbm_store_coverage_shadow_project(covproj, sizeof(covproj), project);
|
||||
cypher_project = covproj;
|
||||
}
|
||||
|
||||
+3
-3
@@ -1849,7 +1849,7 @@ int cbm_store_delete_file_hashes(cbm_store_t *s, const char *project) {
|
||||
/* ── Index coverage (#963) ──────────────────────────────────────── */
|
||||
|
||||
void cbm_store_coverage_shadow_project(char *dst, size_t dstsz, const char *project) {
|
||||
snprintf(dst, dstsz, "%s::coverage", project);
|
||||
snprintf(dst, dstsz, "%s::missed", project);
|
||||
}
|
||||
|
||||
/* Minimal JSON string escape (quotes, backslashes, control chars → space). */
|
||||
@@ -1870,9 +1870,9 @@ static void cov_json_escape(char *dst, size_t dstsz, const char *src) {
|
||||
}
|
||||
|
||||
/* Rebuild the derived miss-GRAPH view under the shadow project
|
||||
* "<project>::coverage": ONLY the not-fully-indexed files, laid out as the
|
||||
* "<project>::missed": ONLY the not-fully-indexed files, laid out as the
|
||||
* file structure (Project → Folder chain → File), each File carrying
|
||||
* {kind, detail}. Queryable via query_graph(graph="coverage") with zero
|
||||
* {kind, detail}. Queryable via query_graph(graph="missed") with zero
|
||||
* cypher-engine changes; the real project's graph gains no rows. Derived
|
||||
* data — rebuilt from the authoritative (post-prune) table contents. */
|
||||
static void cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) {
|
||||
|
||||
+1
-1
@@ -423,7 +423,7 @@ int cbm_store_coverage_replace(cbm_store_t *s, const char *project, const cbm_co
|
||||
int cbm_store_coverage_get(cbm_store_t *s, const char *project, cbm_coverage_row_t **out,
|
||||
int *count);
|
||||
|
||||
/* Name of the derived miss-graph shadow project ("<project>::coverage").
|
||||
/* Name of the derived miss-graph shadow project ("<project>::missed").
|
||||
* cbm_store_coverage_replace materializes the coverage rows as a file-
|
||||
* structure graph (Project → Folder → File{kind, detail}) under this project
|
||||
* name — queryable via the normal cypher path without touching the real
|
||||
|
||||
+17
-1
@@ -1369,6 +1369,7 @@ static double layout_radius(const cbm_layout_result_t *r) {
|
||||
static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
char project[256] = {0};
|
||||
char max_str[32] = {0};
|
||||
char graph_str[32] = {0};
|
||||
|
||||
if (!cbm_http_query_param(req->query, "project", project, (int)sizeof(project)) ||
|
||||
project[0] == '\0') {
|
||||
@@ -1383,6 +1384,21 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
max_nodes = v;
|
||||
}
|
||||
|
||||
/* graph=missed (#963): lay out the derived miss graph (shadow project
|
||||
* "<name>::missed" inside the SAME db file) instead of the code graph —
|
||||
* only files the indexer could not fully cover, as their file structure.
|
||||
* The db file still resolves from the validated base project name. */
|
||||
bool missed_graph = false;
|
||||
if (cbm_http_query_param(req->query, "graph", graph_str, (int)sizeof(graph_str))) {
|
||||
missed_graph = strcmp(graph_str, "missed") == 0;
|
||||
}
|
||||
char scoped_project[320];
|
||||
if (missed_graph) {
|
||||
cbm_store_coverage_shadow_project(scoped_project, sizeof(scoped_project), project);
|
||||
} else {
|
||||
snprintf(scoped_project, sizeof(scoped_project), "%s", project);
|
||||
}
|
||||
|
||||
char db_path[1024];
|
||||
db_path_for_project(project, db_path, sizeof(db_path));
|
||||
|
||||
@@ -1398,7 +1414,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
}
|
||||
|
||||
cbm_layout_result_t *layout =
|
||||
cbm_layout_compute(store, project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes);
|
||||
cbm_layout_compute(store, scoped_project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes);
|
||||
|
||||
/* Find linked projects from CROSS_* edges. Keep `store` open through the
|
||||
* linked-projects loop below so we can resolve target Route QNs against
|
||||
|
||||
+11
-13
@@ -2425,11 +2425,10 @@ TEST(cli_upsert_claude_hook_fresh) {
|
||||
const char *data = read_test_file(settingspath);
|
||||
ASSERT_NOT_NULL(data);
|
||||
ASSERT(strstr(data, "PreToolUse") != NULL);
|
||||
/* Matcher excludes Read per issue #362 (gating Read breaks the
|
||||
* read-before-edit invariant). Assert exact matcher value AND that no
|
||||
* Read-chained matcher slipped back in. */
|
||||
ASSERT(strstr(data, "\"Grep|Glob\"") != NULL);
|
||||
ASSERT(strstr(data, "Glob|Read") == NULL);
|
||||
/* Matcher includes Read for the coverage note (#963). Safe against the
|
||||
* issue-#362 gate hazard: the augmenter is structurally non-blocking
|
||||
* (always exit 0, additionalContext only). */
|
||||
ASSERT(strstr(data, "\"Grep|Glob|Read\"") != NULL);
|
||||
ASSERT(strstr(data, "cbm-code-discovery-gate") != NULL);
|
||||
|
||||
test_rmdir_r(tmpdir);
|
||||
@@ -2499,9 +2498,8 @@ TEST(cli_upsert_claude_hook_existing) {
|
||||
|
||||
const char *data = read_test_file(settingspath);
|
||||
ASSERT_NOT_NULL(data);
|
||||
/* Our hook added with the non-blocking matcher (issue #362). */
|
||||
ASSERT(strstr(data, "\"Grep|Glob\"") != NULL);
|
||||
ASSERT(strstr(data, "Glob|Read") == NULL);
|
||||
/* Our hook added with the current matcher (Read included for #963). */
|
||||
ASSERT(strstr(data, "\"Grep|Glob|Read\"") != NULL);
|
||||
/* Existing hook preserved */
|
||||
ASSERT(strstr(data, "Bash") != NULL);
|
||||
ASSERT(strstr(data, "firewall") != NULL);
|
||||
@@ -2518,9 +2516,9 @@ TEST(cli_upsert_claude_hook_replace) {
|
||||
|
||||
char settingspath[512];
|
||||
snprintf(settingspath, sizeof(settingspath), "%s/settings.json", tmpdir);
|
||||
/* Pre-existing CMM hook with old message */
|
||||
/* Pre-existing CMM hook with an OLD matcher (pre-#963) + old message */
|
||||
write_test_file(settingspath,
|
||||
"{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Grep|Glob|Read\","
|
||||
"{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Grep|Glob\","
|
||||
"\"hooks\":[{\"type\":\"command\",\"command\":\"echo old-cmm-message\"}]}]}}");
|
||||
|
||||
int rc = cbm_upsert_claude_hooks(settingspath);
|
||||
@@ -3049,9 +3047,9 @@ TEST(cli_sha256_file_matches_known_vector) {
|
||||
"", 0, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
|
||||
ASSERT_TRUE(sha256_vector_ok(
|
||||
"abc", 3, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"));
|
||||
ASSERT_TRUE(sha256_vector_ok(
|
||||
"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", 56,
|
||||
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"));
|
||||
ASSERT_TRUE(
|
||||
sha256_vector_ok("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", 56,
|
||||
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
|
||||
@@ -390,12 +390,12 @@ TEST(index_parse_partial_reported) {
|
||||
ASSERT_NOT_NULL(strstr(qresp, "parse_partial"));
|
||||
free(qresp);
|
||||
|
||||
/* The miss GRAPH: the same signal is queryable as a file-structure graph
|
||||
* via query_graph(graph="coverage") — exactly the query the tool
|
||||
/* The missed GRAPH: the same signal is queryable as a file-structure
|
||||
* graph via query_graph(graph="missed") — exactly the query the tool
|
||||
* description advertises — and it lives under the shadow project, so the
|
||||
* REAL code graph gained no coverage rows. */
|
||||
snprintf(qargs, sizeof(qargs),
|
||||
"{\"project\":\"%s\",\"graph\":\"coverage\",\"query\":\"MATCH (f:File) WHERE "
|
||||
"{\"project\":\"%s\",\"graph\":\"missed\",\"query\":\"MATCH (f:File) WHERE "
|
||||
"f.kind = \\\"parse_partial\\\" RETURN f.file_path, f.detail\"}",
|
||||
lp.project);
|
||||
char *gresp = cbm_mcp_handle_tool(lp.srv, "query_graph", qargs);
|
||||
@@ -435,7 +435,19 @@ TEST(index_parse_partial_clears_on_fix) {
|
||||
}
|
||||
rh_to_fwd_slashes(lp.tmpdir);
|
||||
|
||||
ri_write_text(lp.tmpdir, "flaky.py", "def ok():\n return 1\n\ndef broken(:\n pass\n");
|
||||
/* The #ifdef-split-brace pattern: an UNRECOVERED miss (the first branch's
|
||||
* `guarded` never becomes a def), so the flag survives recovery
|
||||
* subtraction. A `def broken(:`-style fixture would NOT work here — its
|
||||
* def is recovered and the region is dropped. */
|
||||
ri_write_text(lp.tmpdir, "flaky.c",
|
||||
"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");
|
||||
ri_write_text(lp.tmpdir, "good.py", "def alpha():\n return 1\n");
|
||||
|
||||
char *resp = NULL;
|
||||
@@ -454,13 +466,17 @@ TEST(index_parse_partial_clears_on_fix) {
|
||||
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
|
||||
char *cov1 = cbm_mcp_handle_tool(lp.srv, "get_index_coverage", qargs);
|
||||
ASSERT_NOT_NULL(cov1);
|
||||
ASSERT_NOT_NULL(strstr(cov1, "flaky.py"));
|
||||
ASSERT_NOT_NULL(strstr(cov1, "flaky.c"));
|
||||
free(cov1);
|
||||
|
||||
/* Fix the file; ensure a newer mtime so change detection can't miss it. */
|
||||
struct timespec ts = {0, INCR_FIX_SLEEP_NS};
|
||||
nanosleep(&ts, NULL);
|
||||
ri_write_text(lp.tmpdir, "flaky.py", "def ok():\n return 1\n\ndef fixed():\n return 2\n");
|
||||
ri_write_text(lp.tmpdir, "flaky.c",
|
||||
"void ok_before(void) { }\n"
|
||||
"static int fixed(int x) {\n"
|
||||
" return x + 1;\n"
|
||||
"}\n");
|
||||
|
||||
/* Re-index WITHOUT deleting the DB → routes through the incremental path. */
|
||||
char iargs[700];
|
||||
@@ -471,7 +487,7 @@ TEST(index_parse_partial_clears_on_fix) {
|
||||
|
||||
char *cov2 = cbm_mcp_handle_tool(lp.srv, "get_index_coverage", qargs);
|
||||
ASSERT_NOT_NULL(cov2);
|
||||
ASSERT_NULL(strstr(cov2, "flaky.py"));
|
||||
ASSERT_NULL(strstr(cov2, "flaky.c"));
|
||||
free(cov2);
|
||||
|
||||
store = cbm_store_open_path(lp.dbpath);
|
||||
|
||||
+72
-20
@@ -76,14 +76,27 @@ static const char *C_CLEAN = "#include <stdio.h>\n"
|
||||
" return x + 1;\n"
|
||||
"}\n";
|
||||
|
||||
static const char *PY_BROKEN = "def ok():\n"
|
||||
" return 1\n"
|
||||
"\n"
|
||||
"def broken(:\n"
|
||||
" pass\n"
|
||||
"\n"
|
||||
"def ok2():\n"
|
||||
" return 2\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"
|
||||
@@ -150,8 +163,8 @@ TEST(c_clean_file_not_flagged) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(py_syntax_error_sets_parse_incomplete) {
|
||||
CBMFileResult *r = do_extract(PY_BROKEN, CBM_LANG_PYTHON, "broken.py");
|
||||
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);
|
||||
@@ -161,6 +174,20 @@ TEST(py_syntax_error_sets_parse_incomplete) {
|
||||
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);
|
||||
@@ -172,17 +199,17 @@ TEST(py_clean_file_not_flagged) {
|
||||
}
|
||||
|
||||
TEST(error_region_cap_is_honored) {
|
||||
/* Pathological input: many separate broken defs interleaved with valid
|
||||
* ones. 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 { BROKEN_DEFS = 200, LINE_CAP = 64 };
|
||||
char *src = (char *)malloc(BROKEN_DEFS * 96 + 1);
|
||||
/* 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 < BROKEN_DEFS; i++) {
|
||||
off += (size_t)snprintf(src + off, 96,
|
||||
"def ok%d():\n return %d\ndef broken%d(:\n pass\n", i, i, i);
|
||||
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);
|
||||
@@ -195,6 +222,29 @@ TEST(error_region_cap_is_honored) {
|
||||
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 ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(parse_coverage) {
|
||||
@@ -202,7 +252,9 @@ SUITE(parse_coverage) {
|
||||
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_syntax_error_sets_parse_incomplete);
|
||||
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);
|
||||
}
|
||||
@@ -1618,12 +1618,11 @@ TEST(store_coverage_roundtrip_prune_shadow) {
|
||||
ASSERT_STR_EQ(got[0].detail, "4-7");
|
||||
cbm_store_free_coverage(got, n);
|
||||
|
||||
/* Shadow miss-graph materialized under "test::coverage":
|
||||
/* Shadow miss-graph materialized under "test::missed":
|
||||
* Project → Folder(src) → File(a.py){kind,detail}. */
|
||||
cbm_node_t *nodes = NULL;
|
||||
int nc = 0;
|
||||
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::coverage", "File", &nodes, &nc),
|
||||
CBM_STORE_OK);
|
||||
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::missed", "File", &nodes, &nc), CBM_STORE_OK);
|
||||
ASSERT_EQ(nc, 1);
|
||||
ASSERT_STR_EQ(nodes[0].file_path, "src/a.py");
|
||||
ASSERT_NOT_NULL(strstr(nodes[0].properties_json, "\"kind\":\"parse_partial\""));
|
||||
@@ -1631,7 +1630,7 @@ TEST(store_coverage_roundtrip_prune_shadow) {
|
||||
cbm_store_free_nodes(nodes, nc);
|
||||
nodes = NULL;
|
||||
nc = 0;
|
||||
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::coverage", "Folder", &nodes, &nc),
|
||||
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::missed", "Folder", &nodes, &nc),
|
||||
CBM_STORE_OK);
|
||||
ASSERT_EQ(nc, 1);
|
||||
ASSERT_STR_EQ(nodes[0].name, "src");
|
||||
@@ -1646,8 +1645,7 @@ TEST(store_coverage_roundtrip_prune_shadow) {
|
||||
free(got);
|
||||
nodes = NULL;
|
||||
nc = 0;
|
||||
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::coverage", "File", &nodes, &nc),
|
||||
CBM_STORE_OK);
|
||||
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::missed", "File", &nodes, &nc), CBM_STORE_OK);
|
||||
ASSERT_EQ(nc, 0);
|
||||
cbm_store_free_nodes(nodes, nc);
|
||||
|
||||
|
||||
Reference in new issue
Block a user