chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user