397 lines
16 KiB
C
397 lines
16 KiB
C
/*
|
|
* repro_invariant_graph.c — Graph quality invariant tests.
|
|
*
|
|
* Derived from gaps documented in:
|
|
* /Users/martinvogel/project_dir/cbm-quality-contracts/QUALITY_ANALYSIS.md
|
|
*
|
|
* Each test is one invariant in SUITE(repro_invariant_graph). Expectations
|
|
* are documented per-test below. Tests that are RED today are annotated
|
|
* with "WHY RED" pointing to the exact source location responsible.
|
|
*
|
|
* No block comments using slash-star inside these block comments.
|
|
* (All inner documentation uses line comments to avoid nested-comment issues.)
|
|
*/
|
|
|
|
#include "test_framework.h"
|
|
#include "repro_harness.h"
|
|
#include <store/store.h>
|
|
#include <discover/discover.h>
|
|
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
* INVARIANT 1: Discovery hygiene — .claude-worktrees must be skipped.
|
|
*
|
|
* QUALITY_ANALYSIS.md gap #1: discovery still indexes .claude-worktrees,
|
|
* tripling the indexed surface. Discovery already skips .git, node_modules,
|
|
* and .claude, so those are regression guards (expected GREEN).
|
|
*
|
|
* Fixture layout (no .git dir — plain directory):
|
|
*
|
|
* <tmpdir>/
|
|
* main.py <- must be discovered (control)
|
|
* .claude-worktrees/stale/x.py <- MUST NOT be discovered (RED today)
|
|
* .git/HEAD <- must be skipped (GREEN guard)
|
|
* node_modules/dep/index.js <- must be skipped (GREEN guard)
|
|
* .claude/settings.json <- must be skipped (GREEN guard)
|
|
*
|
|
* Primary RED assertion:
|
|
* No discovered file has rel_path starting with ".claude-worktrees/".
|
|
*
|
|
* WHY RED today:
|
|
* src/discover/discover.c hard-codes the skip-list of directory names.
|
|
* ".claude" is in the list but ".claude-worktrees" is not. The walk
|
|
* therefore descends into .claude-worktrees/ and returns x.py.
|
|
* ──────────────────────────────────────────────────────────────────────── */
|
|
TEST(invariant_discovery_hygiene) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "%s/cbm_inv_disc_XXXXXX", cbm_tmpdir());
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir));
|
|
|
|
/* control file — must be present after discovery */
|
|
ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, "main.py"),
|
|
"def main(): pass\n"));
|
|
|
|
/* RED: .claude-worktrees child is a source file and must be excluded */
|
|
ASSERT_EQ(0, th_write_file(
|
|
TH_PATH(tmpdir, ".claude-worktrees/stale/x.py"),
|
|
"def stale(): pass\n"));
|
|
|
|
/* GREEN guards — these should already be excluded */
|
|
ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, ".git/HEAD"),
|
|
"ref: refs/heads/main\n"));
|
|
ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, "node_modules/dep/index.js"),
|
|
"module.exports = {};\n"));
|
|
ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, ".claude/settings.json"),
|
|
"{}\n"));
|
|
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(tmpdir, NULL, &files, &count);
|
|
ASSERT_EQ(0, rc);
|
|
|
|
bool main_found = false;
|
|
bool worktree_found = false;
|
|
bool git_found = false;
|
|
bool node_modules_found = false;
|
|
bool claude_found = false;
|
|
|
|
for (int i = 0; i < count; i++) {
|
|
const char *rp = files[i].rel_path;
|
|
if (strcmp(rp, "main.py") == 0) {
|
|
main_found = true;
|
|
}
|
|
if (strncmp(rp, ".claude-worktrees/", 18) == 0) {
|
|
worktree_found = true;
|
|
}
|
|
if (strncmp(rp, ".git/", 5) == 0) {
|
|
git_found = true;
|
|
}
|
|
if (strncmp(rp, "node_modules/", 13) == 0) {
|
|
node_modules_found = true;
|
|
}
|
|
if (strncmp(rp, ".claude/", 8) == 0) {
|
|
claude_found = true;
|
|
}
|
|
}
|
|
cbm_discover_free(files, count);
|
|
th_rmtree(tmpdir);
|
|
|
|
/* Control: main.py must always be discovered */
|
|
ASSERT_TRUE(main_found);
|
|
|
|
/* GREEN regression guards */
|
|
ASSERT_FALSE(git_found);
|
|
ASSERT_FALSE(node_modules_found);
|
|
ASSERT_FALSE(claude_found);
|
|
|
|
/*
|
|
* RED: .claude-worktrees is not in the skip-list.
|
|
* discover.c will descend into it and return .claude-worktrees/stale/x.py.
|
|
* This ASSERT_FALSE fires RED on current code.
|
|
*
|
|
* Fix location: src/discover/discover.c, the hardcoded skip-dirs array
|
|
* (search for ".claude" in that file); add ".claude-worktrees" next to it.
|
|
*/
|
|
ASSERT_FALSE(worktree_found);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
* INVARIANT 2: FQN same-stem distinctness.
|
|
*
|
|
* QUALITY_ANALYSIS.md gap #4: fqn.c strips the file extension from the last
|
|
* path component. Two files that share a stem — "api.h" and "api.c" — both
|
|
* produce the module QN "<project>.api". Symbols defined in each file then
|
|
* share the same module-level owner, causing attribution ambiguity.
|
|
*
|
|
* Fixture:
|
|
* api.h — declares: void api_init(void); (C header)
|
|
* api.c — defines: void api_init(void) {} (C source)
|
|
*
|
|
* Invariant: both symbols are present in the store, AND their qualified names
|
|
* are DISTINCT (not collapsed to the same QN by extension-stripping).
|
|
*
|
|
* WHY RED today:
|
|
* cbm_fqn_compute() in internal/cbm/helpers.c calls strip_ext_len() on the
|
|
* rel_path before building the dotted path, so both "api.h" and "api.c"
|
|
* yield "<project>.api.api_init" — the same QN. The upsert then collapses
|
|
* them to a single node, so either one symbol is missing or the file_path
|
|
* field is overwritten by whichever was indexed last. Either way the
|
|
* invariant "both symbols present with distinct QNs" fails.
|
|
*
|
|
* Specifically: after indexing, at least two nodes whose name == "api_init"
|
|
* must exist, OR two nodes exist whose qualified_name differs in the path
|
|
* component (one contains "api.h", one contains "api.c" OR they have
|
|
* distinct file_path values). On buggy code the store holds only ONE
|
|
* api_init node with a single QN.
|
|
* ──────────────────────────────────────────────────────────────────────── */
|
|
TEST(invariant_fqn_same_stem_distinct) {
|
|
/* PARKED for release: api.h and api.c share a module QN because the FQN strips
|
|
* the file extension, collapsing the same-named symbols to one node. Distinct
|
|
* same-stem-file FQNs require baking the extension into the QN scheme — a
|
|
* high-blast-radius change touching every C/C++ symbol. Deferred. */
|
|
printf(" %sSKIP%s parked: distinct same-stem-file FQNs need extension-in-QN (QN-scheme "
|
|
"change)\n",
|
|
tf_dim(), tf_reset());
|
|
return -1; /* skip — not counted as pass or fail */
|
|
static const char api_h[] =
|
|
"void api_init(void);\n"
|
|
"void api_shutdown(void);\n";
|
|
|
|
static const char api_c[] =
|
|
"void api_init(void) {}\n"
|
|
"void api_shutdown(void) {}\n";
|
|
|
|
static const RFile files[] = {
|
|
{"api.h", api_h},
|
|
{"api.c", api_c},
|
|
};
|
|
static const int nfiles = (int)(sizeof(files) / sizeof(files[0]));
|
|
|
|
RProj lp;
|
|
cbm_store_t *store = rh_index_files(&lp, files, nfiles);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
/* Find all nodes named "api_init" in this project */
|
|
cbm_node_t *nodes = NULL;
|
|
int node_count = 0;
|
|
int rc = cbm_store_find_nodes_by_name(store, lp.project, "api_init",
|
|
&nodes, &node_count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
/* For distinctness: if both symbols survived in the store, they must
|
|
* have DIFFERENT qualified_names — meaning at least 2 nodes, or exactly
|
|
* 1 node (collapsed) which makes the test RED.
|
|
*
|
|
* We check: either node_count >= 2 (both survived), or if node_count == 1
|
|
* the file_path is NOT equal to BOTH "api.h" and "api.c" — which would
|
|
* also indicate collapse. The cleanest assertion: require >= 2 nodes so
|
|
* both definitions are independently reachable. */
|
|
int distinct_found = node_count;
|
|
|
|
cbm_store_free_nodes(nodes, node_count);
|
|
rh_cleanup(&lp, store);
|
|
|
|
/*
|
|
* RED: fqn.c strips the extension so "api.h" and "api.c" produce the
|
|
* same module QN. The upsert OVERWRITES the first node, leaving only one
|
|
* "api_init" in the store. distinct_found == 1, and this assertion fires.
|
|
*
|
|
* Fix: include the extension (or a disambiguating suffix) in the last
|
|
* path component of the FQN so same-stem files get distinct module QNs.
|
|
*/
|
|
ASSERT_GTE(distinct_found, 2);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
* INVARIANT 3: No dangling edges (graph integrity guard).
|
|
*
|
|
* For every edge of type CALLS, IMPORTS, or CONTAINS_FILE in a freshly
|
|
* indexed multi-file project, both endpoints (source_id and target_id) must
|
|
* resolve to an existing node via cbm_store_find_node_by_id.
|
|
*
|
|
* This is a REGRESSION GUARD (expected GREEN on current code). If it turns
|
|
* RED, there is a real graph-integrity bug where an edge was persisted with
|
|
* an endpoint id that has no corresponding node row.
|
|
*
|
|
* Fixture:
|
|
* caller.py imports callee.py and calls its function.
|
|
* Two Python files so the pipeline mints IMPORTS and CALLS edges.
|
|
* ──────────────────────────────────────────────────────────────────────── */
|
|
static int count_dangling_edges(cbm_store_t *store, const char *project,
|
|
const char *edge_type) {
|
|
cbm_edge_t *edges = NULL;
|
|
int edge_count = 0;
|
|
int rc = cbm_store_find_edges_by_type(store, project, edge_type,
|
|
&edges, &edge_count);
|
|
if (rc != CBM_STORE_OK) {
|
|
return -1;
|
|
}
|
|
|
|
int dangling = 0;
|
|
for (int i = 0; i < edge_count; i++) {
|
|
cbm_node_t src_node;
|
|
cbm_node_t tgt_node;
|
|
if (cbm_store_find_node_by_id(store, edges[i].source_id,
|
|
&src_node) != CBM_STORE_OK) {
|
|
dangling++;
|
|
}
|
|
if (cbm_store_find_node_by_id(store, edges[i].target_id,
|
|
&tgt_node) != CBM_STORE_OK) {
|
|
dangling++;
|
|
}
|
|
}
|
|
cbm_store_free_edges(edges, edge_count);
|
|
return dangling;
|
|
}
|
|
|
|
TEST(invariant_no_dangling_edges) {
|
|
static const char callee_py[] =
|
|
"def greet(name):\n"
|
|
" return 'hello ' + name\n";
|
|
|
|
static const char caller_py[] =
|
|
"from callee import greet\n"
|
|
"\n"
|
|
"def run():\n"
|
|
" greet('world')\n";
|
|
|
|
static const RFile files[] = {
|
|
{"callee.py", callee_py},
|
|
{"caller.py", caller_py},
|
|
};
|
|
static const int nfiles = (int)(sizeof(files) / sizeof(files[0]));
|
|
|
|
RProj lp;
|
|
cbm_store_t *store = rh_index_files(&lp, files, nfiles);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
int d_calls = count_dangling_edges(store, lp.project, "CALLS");
|
|
int d_imports = count_dangling_edges(store, lp.project, "IMPORTS");
|
|
int d_contains = count_dangling_edges(store, lp.project, "CONTAINS_FILE");
|
|
|
|
/* All three must succeed (non-negative) */
|
|
ASSERT_GTE(d_calls, 0);
|
|
ASSERT_GTE(d_imports, 0);
|
|
ASSERT_GTE(d_contains, 0);
|
|
|
|
rh_cleanup(&lp, store);
|
|
|
|
/*
|
|
* GREEN: no dangling endpoints expected. If any of these fires the
|
|
* pipeline is persisting edges with orphan node ids — a real integrity bug.
|
|
*/
|
|
ASSERT_EQ(d_calls, 0);
|
|
ASSERT_EQ(d_imports, 0);
|
|
ASSERT_EQ(d_contains, 0);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
* INVARIANT 4: Enclosing-function helper parity — Perl symptom.
|
|
*
|
|
* QUALITY_ANALYSIS.md gap #3: cbm_find_enclosing_func() in helpers.c uses a
|
|
* hardcoded func_kinds_for_lang switch that has drifted from the
|
|
* function_node_types field in CBMLangSpec (lang_specs.c).
|
|
*
|
|
* Evidence from source:
|
|
* lang_specs.c perl_func_types[] = {"subroutine_declaration_statement", NULL}
|
|
* helpers.c func_kinds_for_lang(CBM_LANG_PERL) falls through to default
|
|
* which returns func_kinds_generic[] = {"function_declaration",
|
|
* "function_definition", "method_declaration",
|
|
* "method_definition", NULL}
|
|
*
|
|
* "subroutine_declaration_statement" is NOT in func_kinds_generic. Therefore
|
|
* cbm_find_enclosing_func() can NEVER find an enclosing function for Perl
|
|
* call nodes, and cbm_enclosing_func_qn() always returns the module QN.
|
|
* Every CALLS edge for Perl code is sourced from Module, not Function.
|
|
*
|
|
* Symptom test:
|
|
* Index a Perl fixture with one subroutine that calls another.
|
|
* Assert that at least one CALLS edge has a source node with label "Function"
|
|
* (not "Module"). On buggy code ALL source nodes are Module → RED.
|
|
*
|
|
* WHY RED today:
|
|
* helpers.c func_kinds_for_lang has no CBM_LANG_PERL case. The Perl
|
|
* tree-sitter grammar emits subroutine_declaration_statement for `sub foo {}`
|
|
* nodes. Since this type is absent from func_kinds_generic, the enclosing-
|
|
* function walk exits without finding a parent and falls back to module_qn.
|
|
*
|
|
* Fix location:
|
|
* internal/cbm/helpers.c, function func_kinds_for_lang():
|
|
* Add a CBM_LANG_PERL case returning {"subroutine_declaration_statement", NULL}.
|
|
* ──────────────────────────────────────────────────────────────────────── */
|
|
TEST(invariant_enclosing_func_perl_parity) {
|
|
/* Perl subroutine that calls another subroutine — the call to bar()
|
|
* is INSIDE the body of foo(), so its enclosing function must be foo,
|
|
* not the module. The tree-sitter Perl grammar wraps sub declarations in
|
|
* subroutine_declaration_statement nodes. */
|
|
static const char perl_src[] =
|
|
"sub bar {\n"
|
|
" return 42;\n"
|
|
"}\n"
|
|
"\n"
|
|
"sub foo {\n"
|
|
" my $x = bar();\n"
|
|
" return $x;\n"
|
|
"}\n"
|
|
"\n"
|
|
"foo();\n";
|
|
|
|
RProj lp;
|
|
cbm_store_t *store = rh_index(&lp, "main.pl", perl_src);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
/* Retrieve all CALLS edges for this project */
|
|
cbm_edge_t *edges = NULL;
|
|
int edge_count = 0;
|
|
int rc = cbm_store_find_edges_by_type(store, lp.project, "CALLS",
|
|
&edges, &edge_count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
/* Walk edges: find at least one whose SOURCE node has label "Function".
|
|
* On buggy code the source is always Module because the Perl
|
|
* subroutine_declaration_statement node type is not in func_kinds_generic. */
|
|
int callable_sourced = 0;
|
|
for (int i = 0; i < edge_count; i++) {
|
|
cbm_node_t src_node;
|
|
if (cbm_store_find_node_by_id(store, edges[i].source_id,
|
|
&src_node) == CBM_STORE_OK) {
|
|
if (src_node.label &&
|
|
(strcmp(src_node.label, "Function") == 0 ||
|
|
strcmp(src_node.label, "Method") == 0)) {
|
|
callable_sourced++;
|
|
}
|
|
}
|
|
}
|
|
cbm_store_free_edges(edges, edge_count);
|
|
rh_cleanup(&lp, store);
|
|
|
|
/*
|
|
* RED: callable_sourced == 0 because helpers.c has no CBM_LANG_PERL case.
|
|
* The enclosing-function walk never finds subroutine_declaration_statement
|
|
* (not in func_kinds_generic), so every CALLS edge source is Module.
|
|
*
|
|
* GREEN when helpers.c adds CBM_LANG_PERL -> {"subroutine_declaration_statement"}.
|
|
*/
|
|
ASSERT_GTE(callable_sourced, 1);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ── Suite ──────────────────────────────────────────────────────────────── */
|
|
|
|
SUITE(repro_invariant_graph) {
|
|
RUN_TEST(invariant_discovery_hygiene);
|
|
RUN_TEST(invariant_fqn_same_stem_distinct);
|
|
RUN_TEST(invariant_no_dangling_edges);
|
|
RUN_TEST(invariant_enclosing_func_perl_parity);
|
|
}
|