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:
Martin Vogel committed 2026-07-08 19:13:25 +02:00
1 parent f9d7f4aa61
commit 664313e4ac
14 files changed
+464 -90

No files matched your search

+26
View File
@@ -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">
+9 -2
View File
@@ -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}
+13 -3
View File
@@ -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
View File
@@ -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(&regs, root); /* whole file unparseable */
} else {
cbm_collect_error_regions(root, &regs);
}
result->error_region_count = regs.count;
result->error_ranges = cbm_error_ranges_str(a, &regs);
}
// 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(&regs, root); /* whole file unparseable */
} else {
cbm_collect_error_regions(root, &regs);
}
cbm_subtract_recovered_regions(&regs, &result->defs);
if (regs.count > 0) {
result->parse_incomplete = true;
result->error_region_count = regs.count;
result->error_ranges = cbm_error_ranges_str(a, &regs);
}
}
result->imports_count = result->imports.count;
// Accumulate profiling counters
+8 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
+23 -7
View File
@@ -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
View File
@@ -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);
}
+4 -6
View File
@@ -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);