Files
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

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);
}