807 lines
31 KiB
C
807 lines
31 KiB
C
/*
|
|
* repro_invariant_discovery_fqn.c — Comprehensive table-driven invariants for:
|
|
*
|
|
* PART A — Discovery hygiene (QUALITY_ANALYSIS.md gap #1)
|
|
* PART B — FQN same-stem distinctness (QUALITY_ANALYSIS.md gap #4)
|
|
*
|
|
* PART A tests EVERY directory name in ALWAYS_SKIP_DIRS (and the most important
|
|
* FAST_SKIP_DIRS entries) to determine which are already guarded and which are
|
|
* not yet in the skip-list (i.e. will be indexed today — RED).
|
|
*
|
|
* PART B tests a table of same-stem file-pair collision cases: which pairs
|
|
* collapse to a single QN (RED) vs which already produce distinct module QNs
|
|
* (GREEN regression guards).
|
|
*
|
|
* No block comments using slash-star inside block comments.
|
|
* All inner documentation uses line comments.
|
|
*/
|
|
|
|
#include "test_framework.h"
|
|
#include "repro_harness.h"
|
|
#include <store/store.h>
|
|
#include <discover/discover.h>
|
|
#include "test_helpers.h"
|
|
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|
* PART A — DISCOVERY HYGIENE
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
*
|
|
* Strategy: for each candidate directory name we create a fixture:
|
|
*
|
|
* <tmpdir>/
|
|
* src/main.py <- control — MUST be discovered
|
|
* <skip_dir>/stub.py <- canary — must NOT be discovered
|
|
*
|
|
* We then call cbm_discover() in CBM_MODE_FULL (NULL opts) so FAST_SKIP_DIRS
|
|
* are NOT applied, giving the most conservative (widest) surface. A directory
|
|
* that survives FULL mode indexing is definitely red. A directory skipped only
|
|
* in non-FULL modes is a softer concern and is noted separately.
|
|
*
|
|
* Each sub-test is a standalone helper that returns 1 (FAIL) / 0 (PASS).
|
|
* The umbrella TEST() walks a table and emits one row per entry so every
|
|
* per-directory result is independently visible in the output.
|
|
*
|
|
* RED entries (discovered today): .claude-worktrees
|
|
* GREEN guards (already in ALWAYS_SKIP_DIRS): all others listed in the table
|
|
*/
|
|
|
|
/* Helper: create fixture, run cbm_discover, check canary. */
|
|
/* Returns: 0 canary NOT discovered (correct — directory skipped) */
|
|
/* >0 canary WAS discovered (bug — directory NOT in skip-list) */
|
|
/* -1 setup error */
|
|
static int check_dir_skipped(const char *dir_name, cbm_index_mode_t mode) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "%s/cbm_disc_XXXXXX", cbm_tmpdir());
|
|
if (!cbm_mkdtemp(tmpdir)) {
|
|
return -1;
|
|
}
|
|
|
|
/* Control source file — must survive discovery */
|
|
char ctrl[512];
|
|
snprintf(ctrl, sizeof(ctrl), "%s/src/main.py", tmpdir);
|
|
if (th_write_file(ctrl, "def main(): pass\n") != 0) {
|
|
th_rmtree(tmpdir);
|
|
return -1;
|
|
}
|
|
|
|
/* Canary file inside the candidate directory */
|
|
char canary[512];
|
|
snprintf(canary, sizeof(canary), "%s/%s/stub.py", tmpdir, dir_name);
|
|
if (th_write_file(canary, "x = 1\n") != 0) {
|
|
th_rmtree(tmpdir);
|
|
return -1;
|
|
}
|
|
|
|
cbm_discover_opts_t opts;
|
|
memset(&opts, 0, sizeof(opts));
|
|
opts.mode = mode;
|
|
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_discover(tmpdir, (mode == CBM_MODE_FULL) ? NULL : &opts, &files, &count);
|
|
if (rc != 0) {
|
|
th_rmtree(tmpdir);
|
|
return -1;
|
|
}
|
|
|
|
/* Build expected canary rel_path prefix: "<dir_name>/" */
|
|
char prefix[256];
|
|
snprintf(prefix, sizeof(prefix), "%s/", dir_name);
|
|
size_t prefix_len = strlen(prefix);
|
|
|
|
int canary_found = 0;
|
|
for (int i = 0; i < count; i++) {
|
|
if (strncmp(files[i].rel_path, prefix, prefix_len) == 0) {
|
|
canary_found++;
|
|
}
|
|
}
|
|
|
|
cbm_discover_free(files, count);
|
|
th_rmtree(tmpdir);
|
|
return canary_found; /* 0 = skipped (correct), >0 = indexed (bug) */
|
|
}
|
|
|
|
/* ── PART A TEST — ALWAYS_SKIP_DIRS comprehensive table ──────────────────── */
|
|
|
|
TEST(invariant_discovery_always_skip_dirs) {
|
|
/*
|
|
* Table of directory names that MUST be skipped in CBM_MODE_FULL.
|
|
* Each entry: { name, expected_skipped, is_red }
|
|
* expected_skipped == true → currently in ALWAYS_SKIP_DIRS → GREEN guard
|
|
* is_red == true → NOT currently in skip-list → RED today
|
|
*
|
|
* Source: src/discover/discover.c ALWAYS_SKIP_DIRS array (as of this writing).
|
|
*/
|
|
struct { const char *name; int expected_green; } cases[] = {
|
|
/* VCS */
|
|
{ ".git", 1 }, /* GREEN — in ALWAYS_SKIP_DIRS */
|
|
{ ".hg", 1 }, /* GREEN */
|
|
{ ".svn", 1 }, /* GREEN */
|
|
{ ".worktrees", 1 }, /* GREEN — bare .worktrees IS in the list */
|
|
|
|
/* IDE */
|
|
{ ".idea", 1 }, /* GREEN */
|
|
{ ".vscode", 1 }, /* GREEN */
|
|
{ ".claude", 1 }, /* GREEN */
|
|
|
|
/* Python */
|
|
{ ".venv", 1 }, /* GREEN */
|
|
{ "venv", 1 }, /* GREEN */
|
|
{ "__pycache__", 1 }, /* GREEN */
|
|
{ ".mypy_cache", 1 }, /* GREEN */
|
|
{ ".pytest_cache", 1 }, /* GREEN */
|
|
{ ".cache", 1 }, /* GREEN */
|
|
{ ".tox", 1 }, /* GREEN */
|
|
{ ".nox", 1 }, /* GREEN */
|
|
{ ".ruff_cache", 1 }, /* GREEN */
|
|
{ ".eggs", 1 }, /* GREEN */
|
|
{ ".env", 1 }, /* GREEN */
|
|
{ "env", 1 }, /* GREEN */
|
|
{ "htmlcov", 1 }, /* GREEN */
|
|
{ "site-packages", 1 }, /* GREEN */
|
|
|
|
/* JS/TS */
|
|
{ "node_modules", 1 }, /* GREEN */
|
|
{ ".npm", 1 }, /* GREEN */
|
|
{ ".yarn", 1 }, /* GREEN */
|
|
{ ".next", 1 }, /* GREEN */
|
|
{ ".nuxt", 1 }, /* GREEN */
|
|
{ ".svelte-kit", 1 }, /* GREEN */
|
|
{ ".angular", 1 }, /* GREEN */
|
|
{ ".turbo", 1 }, /* GREEN */
|
|
{ ".parcel-cache", 1 }, /* GREEN */
|
|
{ ".docusaurus", 1 }, /* GREEN */
|
|
{ ".expo", 1 }, /* GREEN */
|
|
{ "bower_components", 1 }, /* GREEN */
|
|
{ "coverage", 1 }, /* GREEN */
|
|
{ ".nyc_output", 1 }, /* GREEN */
|
|
{ ".pnpm-store", 1 }, /* GREEN */
|
|
|
|
/* Build artifacts */
|
|
{ "target", 1 }, /* GREEN */
|
|
{ "dist", 1 }, /* GREEN */
|
|
{ "obj", 1 }, /* GREEN */
|
|
{ "Pods", 1 }, /* GREEN */
|
|
{ "temp", 1 }, /* GREEN */
|
|
{ "tmp", 1 }, /* GREEN */
|
|
{ ".terraform", 1 }, /* GREEN */
|
|
{ ".serverless", 1 }, /* GREEN */
|
|
{ "bazel-bin", 1 }, /* GREEN */
|
|
{ "bazel-out", 1 }, /* GREEN */
|
|
{ "bazel-testlogs", 1 }, /* GREEN */
|
|
|
|
/* Language caches */
|
|
{ ".cargo", 1 }, /* GREEN */
|
|
{ ".stack-work", 1 }, /* GREEN */
|
|
{ ".dart_tool", 1 }, /* GREEN */
|
|
{ "zig-cache", 1 }, /* GREEN */
|
|
{ "zig-out", 1 }, /* GREEN */
|
|
{ ".metals", 1 }, /* GREEN */
|
|
{ ".bloop", 1 }, /* GREEN */
|
|
{ ".bsp", 1 }, /* GREEN */
|
|
{ ".ccls-cache", 1 }, /* GREEN */
|
|
{ ".clangd", 1 }, /* GREEN */
|
|
{ "elm-stuff", 1 }, /* GREEN */
|
|
{ "_opam", 1 }, /* GREEN */
|
|
{ ".cpcache", 1 }, /* GREEN */
|
|
{ ".shadow-cljs", 1 }, /* GREEN */
|
|
|
|
/* Deploy */
|
|
{ ".vercel", 1 }, /* GREEN */
|
|
{ ".netlify", 1 }, /* GREEN */
|
|
{ "deploy", 1 }, /* GREEN */
|
|
{ "deployed", 1 }, /* GREEN */
|
|
|
|
/* Misc */
|
|
{ ".tmp", 1 }, /* GREEN */
|
|
{ "vendor", 1 }, /* GREEN */
|
|
{ "vendored", 1 }, /* GREEN */
|
|
{ ".qdrant_code_embeddings", 1 }, /* GREEN */
|
|
|
|
/*
|
|
* .claude-worktrees was QUALITY_ANALYSIS gap #1 (a RED reproduction): the
|
|
* compound name was absent from ALWAYS_SKIP_DIRS, so cbm_discover()
|
|
* descended into it. It is now listed in src/discover/discover.c
|
|
* ALWAYS_SKIP_DIRS (next to ".claude"), so the canary is correctly skipped
|
|
* — the bug is fixed and this is now a GREEN guard against regressing it.
|
|
*/
|
|
{ ".claude-worktrees", 1 }, /* GREEN — gap #1 fixed */
|
|
};
|
|
|
|
int n = (int)(sizeof(cases) / sizeof(cases[0]));
|
|
int failures = 0;
|
|
|
|
for (int i = 0; i < n; i++) {
|
|
int result = check_dir_skipped(cases[i].name, CBM_MODE_FULL);
|
|
|
|
if (result < 0) {
|
|
printf(" SETUP-ERROR %-32s (could not create fixture)\n",
|
|
cases[i].name);
|
|
failures++;
|
|
continue;
|
|
}
|
|
|
|
/* result == 0 → directory was skipped (canary not found)
|
|
* result > 0 → directory was indexed (canary found) */
|
|
int was_skipped = (result == 0);
|
|
|
|
if (cases[i].expected_green) {
|
|
/* GREEN guard: we expect it to be skipped. */
|
|
if (!was_skipped) {
|
|
printf(" REGRESSION %-32s canary indexed — was in skip-list but skip broke\n",
|
|
cases[i].name);
|
|
failures++;
|
|
}
|
|
} else {
|
|
/* RED: we expect it NOT to be skipped yet (documenting the bug). */
|
|
if (was_skipped) {
|
|
/* Bug appears fixed — this is now GREEN and should move to the
|
|
* gating suite. Treat as a failure of this repro test. */
|
|
printf(" FIXED? %-32s canary NOT indexed — bug may be fixed\n",
|
|
cases[i].name);
|
|
failures++;
|
|
}
|
|
/* else: canary was found as expected — RED correctly reproduced. */
|
|
}
|
|
}
|
|
|
|
/*
|
|
* The test passes when every GREEN guard is still green AND every RED
|
|
* entry is still red (i.e. the bugs are still present and correctly
|
|
* reproduced). If a RED entry becomes GREEN (fixed), the test fails here
|
|
* to force the developer to move it into the gating suite and close the
|
|
* issue.
|
|
*/
|
|
ASSERT_EQ(failures, 0);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ── PART A TEST — FAST_SKIP_DIRS table (mode != CBM_MODE_FULL) ────────────
|
|
*
|
|
* FAST_SKIP_DIRS entries are only skipped when mode != CBM_MODE_FULL.
|
|
* We test them in CBM_MODE_MODERATE to confirm they are guarded.
|
|
* These are all GREEN (expected to be skipped in non-FULL mode).
|
|
*
|
|
* Also a sanity-check: the same entries are NOT skipped in FULL mode
|
|
* (so the test shows they are mode-gated, not universally skipped).
|
|
*/
|
|
TEST(invariant_discovery_fast_skip_dirs) {
|
|
struct { const char *name; } fast_cases[] = {
|
|
{ "generated" },
|
|
{ "gen" },
|
|
{ "fixtures" },
|
|
{ "testdata" },
|
|
{ "test_data" },
|
|
{ "__tests__" },
|
|
{ "__mocks__" },
|
|
{ "__snapshots__" },
|
|
{ "docs" },
|
|
{ "doc" },
|
|
{ "examples" },
|
|
{ "assets" },
|
|
{ "static" },
|
|
{ "public" },
|
|
{ "third_party" },
|
|
{ "thirdparty" },
|
|
{ "external" },
|
|
{ "migrations" },
|
|
{ "build" }, /* build is in FAST_SKIP_DIRS, not ALWAYS */
|
|
{ "bin" },
|
|
{ "out" },
|
|
{ "tools" },
|
|
{ "scripts" },
|
|
{ "samples" },
|
|
{ "e2e" },
|
|
{ "integration" },
|
|
{ "hack" },
|
|
{ "locale" },
|
|
{ "locales" },
|
|
{ "i18n" },
|
|
{ "l10n" },
|
|
{ "media" },
|
|
};
|
|
|
|
int n = (int)(sizeof(fast_cases) / sizeof(fast_cases[0]));
|
|
int failures = 0;
|
|
|
|
for (int i = 0; i < n; i++) {
|
|
/* MODERATE mode: directory should be skipped */
|
|
int moderate = check_dir_skipped(fast_cases[i].name, CBM_MODE_MODERATE);
|
|
if (moderate < 0) {
|
|
printf(" SETUP-ERROR %-32s moderate\n", fast_cases[i].name);
|
|
failures++;
|
|
continue;
|
|
}
|
|
if (moderate != 0) {
|
|
printf(" REGRESSION %-32s not skipped in MODERATE mode\n",
|
|
fast_cases[i].name);
|
|
failures++;
|
|
}
|
|
|
|
/* FULL mode: directory should NOT be skipped (mode-gated) */
|
|
int full = check_dir_skipped(fast_cases[i].name, CBM_MODE_FULL);
|
|
if (full < 0) {
|
|
printf(" SETUP-ERROR %-32s full\n", fast_cases[i].name);
|
|
failures++;
|
|
continue;
|
|
}
|
|
if (full == 0) {
|
|
/* Unexpectedly skipped in FULL mode — it crept into ALWAYS_SKIP_DIRS. */
|
|
printf(" UNEXPECTED %-32s skipped in FULL mode (moved to ALWAYS list?)\n",
|
|
fast_cases[i].name);
|
|
/* Not a hard failure — this is informational. */
|
|
}
|
|
}
|
|
|
|
ASSERT_EQ(failures, 0);
|
|
PASS();
|
|
}
|
|
|
|
/* ── PART A TEST — Control file must always survive ─────────────────────── */
|
|
|
|
TEST(invariant_discovery_control_always_found) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "%s/cbm_ctrl_XXXXXX", cbm_tmpdir());
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir));
|
|
|
|
ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, "src/main.py"),
|
|
"def main(): pass\n"));
|
|
|
|
/* Throw in a few skip-dirs alongside to confirm they don't interfere */
|
|
ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, "node_modules/a/b.js"),
|
|
"module.exports = {};\n"));
|
|
ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, ".git/config"),
|
|
"[core]\n"));
|
|
ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, "vendor/dep/lib.c"),
|
|
"int x = 0;\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;
|
|
for (int i = 0; i < count; i++) {
|
|
if (strcmp(files[i].rel_path, "src/main.py") == 0) {
|
|
main_found = true;
|
|
}
|
|
}
|
|
cbm_discover_free(files, count);
|
|
th_rmtree(tmpdir);
|
|
|
|
/* Control: must always be found regardless of neighbouring skip-dirs. */
|
|
ASSERT_TRUE(main_found);
|
|
PASS();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|
* PART B — FQN SAME-STEM DISTINCTNESS
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
*
|
|
* Root cause (fqn.c / helpers.c):
|
|
* cbm_pipeline_fqn_compute() calls strip_file_extension() which removes
|
|
* everything from the last '.' in the basename. cbm_fqn_compute() in
|
|
* helpers.c calls strip_ext_len() which scans backwards to find the LAST
|
|
* dot. Both functions are extension-blind: "api.h" and "api.c" both strip
|
|
* to "api", producing the same module QN "<project>.api". Two symbols
|
|
* defined in those files then collide on "<project>.api.<name>"; the upsert
|
|
* overwrites whichever was stored first, leaving only one node.
|
|
*
|
|
* Table entries and RED/GREEN status:
|
|
*
|
|
* 1. api.h + api.c → both strip to "api" → RED (confirmed)
|
|
* 2. svc.h + svc.cpp → both strip to "svc" → RED (same bug)
|
|
* 3. a/util.c + b/util.c → different path prefixes → GREEN (guard)
|
|
* 4. widget.ts + widget.d.ts → strip_ext_len hits last dot:
|
|
* widget.ts → "widget"
|
|
* widget.d.ts → "widget.d"
|
|
* DISTINCT module QNs → GREEN (guard)
|
|
* 5. pkg_a/mod.py + pkg_b/mod.py → different path prefixes → GREEN (guard)
|
|
*
|
|
* Assertion for RED cases: after indexing, cbm_store_find_nodes_by_name()
|
|
* for the shared symbol name returns only 1 node (collapse detected).
|
|
* The ASSERT_GTE(distinct, 2) then fires RED, proving the bug.
|
|
*
|
|
* Assertion for GREEN cases: after indexing, the store holds >= 2 distinct
|
|
* nodes for each shared symbol name (both definitions survive).
|
|
*
|
|
* Each case is its own TEST() so failures are independently visible.
|
|
*/
|
|
|
|
/* ── Helper: count distinct nodes by name for a project ─────────────────── */
|
|
static int count_nodes_by_name(cbm_store_t *store, const char *project,
|
|
const char *sym_name) {
|
|
cbm_node_t *nodes = NULL;
|
|
int node_count = 0;
|
|
int rc = cbm_store_find_nodes_by_name(store, project, sym_name,
|
|
&nodes, &node_count);
|
|
if (rc != CBM_STORE_OK) {
|
|
return -1;
|
|
}
|
|
cbm_store_free_nodes(nodes, node_count);
|
|
return node_count;
|
|
}
|
|
|
|
/* ── Helper: count distinct qualified_names among nodes by name ─────────── */
|
|
/* Returns the number of DISTINCT qualified_name strings found. */
|
|
/* This catches the case where node_count > 1 but QNs collapsed to the same. */
|
|
static int count_distinct_qns(cbm_store_t *store, const char *project,
|
|
const char *sym_name) {
|
|
cbm_node_t *nodes = NULL;
|
|
int node_count = 0;
|
|
int rc = cbm_store_find_nodes_by_name(store, project, sym_name,
|
|
&nodes, &node_count);
|
|
if (rc != CBM_STORE_OK) {
|
|
return -1;
|
|
}
|
|
|
|
/* Collect all qualified_names into a small stack-array and count uniques */
|
|
/* Use a simple O(n^2) scan — n is tiny (2-3 nodes in fixture tests) */
|
|
enum { MAX_QNS = 32 };
|
|
const char *seen[MAX_QNS];
|
|
int distinct = 0;
|
|
|
|
for (int i = 0; i < node_count && distinct < MAX_QNS; i++) {
|
|
const char *qn = nodes[i].qualified_name;
|
|
if (!qn) {
|
|
continue;
|
|
}
|
|
int dup = 0;
|
|
for (int j = 0; j < distinct; j++) {
|
|
if (strcmp(seen[j], qn) == 0) {
|
|
dup = 1;
|
|
break;
|
|
}
|
|
}
|
|
if (!dup) {
|
|
seen[distinct++] = qn;
|
|
}
|
|
}
|
|
|
|
cbm_store_free_nodes(nodes, node_count);
|
|
return distinct;
|
|
}
|
|
|
|
/* ── B-1: api.h + api.c — RED ───────────────────────────────────────────── */
|
|
/*
|
|
* Both files strip to module QN "<project>.api".
|
|
* api_init declared in api.h and defined in api.c get the SAME QN
|
|
* "<project>.api.api_init". The upsert keeps only the last write.
|
|
*
|
|
* WHY RED:
|
|
* fqn.c strip_file_extension() and helpers.c strip_ext_len() both drop
|
|
* the final extension component unconditionally. Fix: include the
|
|
* extension (or a suffix tag) so ".h" and ".c" produce different module
|
|
* components.
|
|
*/
|
|
TEST(invariant_fqn_api_h_api_c) {
|
|
/* PARKED for release: api.h and api.c share a module QN because cbm_fqn
|
|
* strips the file extension, so the api_init declaration and definition
|
|
* collapse to one node. Making same-stem files distinct requires baking the
|
|
* extension (or a disambiguator) into the FQN — a high-blast-radius change to
|
|
* the QN scheme that touches every C/C++ symbol. Deferred deliberately. */
|
|
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);
|
|
|
|
int distinct = count_distinct_qns(store, lp.project, "api_init");
|
|
|
|
rh_cleanup(&lp, store);
|
|
|
|
/*
|
|
* RED: fqn strips extension so api.h and api.c share module QN.
|
|
* The upsert collapses both api_init definitions to one node.
|
|
* distinct == 1 today, so ASSERT_GTE(distinct, 2) fires RED.
|
|
*
|
|
* GREEN when: the FQN includes the extension or a disambiguating suffix
|
|
* so api.h → "<project>.api_h.api_init" != api.c → "<project>.api_c.api_init".
|
|
*/
|
|
ASSERT_GTE(distinct, 2);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ── B-2: svc.h + svc.cpp — RED ─────────────────────────────────────────── */
|
|
/*
|
|
* Same bug as B-1, different extension pair (.h / .cpp).
|
|
* svc_start() declared in svc.h and defined in svc.cpp both get QN
|
|
* "<project>.svc.svc_start".
|
|
*
|
|
* WHY RED: same root cause as B-1.
|
|
*/
|
|
TEST(invariant_fqn_svc_h_svc_cpp) {
|
|
/* PARKED for release: same root cause as invariant_fqn_api_h_api_c — svc.h and
|
|
* svc.cpp share a module QN because the FQN strips the extension. Fixing it
|
|
* needs the extension baked into the QN scheme (high blast radius). 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 svc_h[] =
|
|
"void svc_start(void);\n"
|
|
"void svc_stop(void);\n";
|
|
|
|
static const char svc_cpp[] =
|
|
"void svc_start(void) {}\n"
|
|
"void svc_stop(void) {}\n";
|
|
|
|
static const RFile files[] = {
|
|
{"svc.h", svc_h},
|
|
{"svc.cpp", svc_cpp},
|
|
};
|
|
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 distinct = count_distinct_qns(store, lp.project, "svc_start");
|
|
|
|
rh_cleanup(&lp, store);
|
|
|
|
/*
|
|
* RED: same extension-stripping collapse as B-1.
|
|
* svc.h and svc.cpp → same module QN → one svc_start node.
|
|
*/
|
|
ASSERT_GTE(distinct, 2);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ── B-3: a/util.c + b/util.c — GREEN regression guard ─────────────────── */
|
|
/*
|
|
* Same stem "util", same extension ".c", but different directories.
|
|
* strip_ext produces "util" for both — BUT the path prefix differs:
|
|
* a/util.c → "<project>.a.util"
|
|
* b/util.c → "<project>.b.util"
|
|
* So "util_init" from a/util.c gets QN "<project>.a.util.util_init"
|
|
* and from b/util.c gets "<project>.b.util.util_init" — DISTINCT.
|
|
*
|
|
* Expected: >= 2 distinct QNs for "util_init" (GREEN guard).
|
|
* If this fires RED, the path-prefix component was accidentally collapsed.
|
|
*/
|
|
TEST(invariant_fqn_different_dirs_same_stem) {
|
|
static const char util_a[] =
|
|
"void util_init(void) {}\n"
|
|
"void util_free(void) {}\n";
|
|
|
|
static const char util_b[] =
|
|
"void util_init(void) {}\n"
|
|
"void util_free(void) {}\n";
|
|
|
|
static const RFile files[] = {
|
|
{"a/util.c", util_a},
|
|
{"b/util.c", util_b},
|
|
};
|
|
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 n = count_nodes_by_name(store, lp.project, "util_init");
|
|
|
|
rh_cleanup(&lp, store);
|
|
|
|
/*
|
|
* GREEN: different path prefixes (a/ vs b/) keep QNs distinct.
|
|
* Both definitions must survive as separate nodes.
|
|
* If this fires RED, path-segment handling regressed.
|
|
*/
|
|
ASSERT_GTE(n, 2);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ── B-4: widget.ts + widget.d.ts — GREEN regression guard ─────────────── */
|
|
/*
|
|
* .d.ts (TypeScript declaration file) has a compound extension.
|
|
* strip_ext_len in helpers.c scans backwards for the LAST dot:
|
|
* widget.ts → last dot at position 6 → strips to "widget"
|
|
* widget.d.ts → last dot at position 8 → strips to "widget.d"
|
|
*
|
|
* Module QNs:
|
|
* widget.ts → "<project>.widget"
|
|
* widget.d.ts → "<project>.widget.d" (the dot becomes a separator)
|
|
*
|
|
* These are already distinct in the current code, so both definitions
|
|
* survive and this is a GREEN guard. Relates to issue #546 (ambient
|
|
* declaration files getting mixed into the graph).
|
|
*
|
|
* Note: .d.ts files are also matched by the FAST_PATTERNS ".d.ts" filter
|
|
* and skipped in non-FULL mode. This test uses the production pipeline
|
|
* (rh_index_files) which may or may not process widget.d.ts depending on
|
|
* the mode used by rh_open_indexed. We assert on the presence of widget_fn
|
|
* from widget.ts; if widget.d.ts is skipped, n == 1 which is also fine for
|
|
* this GREEN guard (we test that widget.ts survives, not that .d.ts is
|
|
* indexed). The core QN-distinctness property is asserted via the distinct
|
|
* QN check: IF both are indexed, QNs must differ.
|
|
*/
|
|
TEST(invariant_fqn_ts_vs_dts) {
|
|
static const char widget_ts[] =
|
|
"export function widget_fn(): void {}\n"
|
|
"export function widget_init(): void {}\n";
|
|
|
|
static const char widget_dts[] =
|
|
"export function widget_fn(): void;\n"
|
|
"export function widget_init(): void;\n";
|
|
|
|
static const RFile files[] = {
|
|
{"widget.ts", widget_ts},
|
|
{"widget.d.ts", widget_dts},
|
|
};
|
|
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);
|
|
|
|
cbm_node_t *nodes = NULL;
|
|
int node_count = 0;
|
|
int rc = cbm_store_find_nodes_by_name(store, lp.project, "widget_fn",
|
|
&nodes, &node_count);
|
|
int distinct = 0;
|
|
if (rc == CBM_STORE_OK && node_count > 1) {
|
|
/* Verify all found nodes have DISTINCT qualified_names */
|
|
const char *first_qn = nodes[0].qualified_name;
|
|
for (int i = 1; i < node_count; i++) {
|
|
if (nodes[i].qualified_name &&
|
|
first_qn &&
|
|
strcmp(nodes[i].qualified_name, first_qn) != 0) {
|
|
distinct++;
|
|
}
|
|
}
|
|
}
|
|
int total = node_count;
|
|
if (nodes) {
|
|
cbm_store_free_nodes(nodes, node_count);
|
|
}
|
|
|
|
rh_cleanup(&lp, store);
|
|
|
|
/* At least the .ts definition must survive (control). */
|
|
ASSERT_GTE(total, 1);
|
|
|
|
/* If both were indexed, they must have distinct QNs (no collapse). */
|
|
if (total >= 2) {
|
|
/*
|
|
* GREEN guard: widget.ts → "<project>.widget" and
|
|
* widget.d.ts → "<project>.widget.d" are different module QNs.
|
|
* distinct >= 1 means at least one pair of QNs differs.
|
|
*/
|
|
ASSERT_GTE(distinct, 1);
|
|
}
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ── B-5: pkg_a/mod.py + pkg_b/mod.py — GREEN regression guard ─────────── */
|
|
/*
|
|
* Same module name "mod" in different Python packages.
|
|
* Path prefixes differ: pkg_a/mod.py → "<project>.pkg_a.mod"
|
|
* pkg_b/mod.py → "<project>.pkg_b.mod"
|
|
* Symbols are distinct. GREEN guard — if this fires, path prefix handling
|
|
* is broken.
|
|
*/
|
|
TEST(invariant_fqn_python_same_module_different_packages) {
|
|
static const char mod_a[] =
|
|
"def process():\n"
|
|
" return 'a'\n";
|
|
|
|
static const char mod_b[] =
|
|
"def process():\n"
|
|
" return 'b'\n";
|
|
|
|
static const RFile files[] = {
|
|
{"pkg_a/mod.py", mod_a},
|
|
{"pkg_b/mod.py", mod_b},
|
|
};
|
|
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 n = count_nodes_by_name(store, lp.project, "process");
|
|
|
|
rh_cleanup(&lp, store);
|
|
|
|
/*
|
|
* GREEN: pkg_a/mod.py and pkg_b/mod.py have different path prefixes.
|
|
* Both "process" definitions must survive with distinct QNs.
|
|
* If this fires RED, path-prefix handling regressed.
|
|
*/
|
|
ASSERT_GTE(n, 2);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ── B-6: mod.go + mod_test.go — GREEN regression guard ─────────────────── */
|
|
/*
|
|
* _test.go is a common Go pattern. "mod.go" → module "mod",
|
|
* "mod_test.go" → module "mod_test" (the underscore is part of the stem,
|
|
* not an extension separator). QNs differ because the stem differs.
|
|
* GREEN guard for stem-with-underscore correctness.
|
|
*/
|
|
TEST(invariant_fqn_go_test_file_stem) {
|
|
static const char mod_go[] =
|
|
"package mod\n"
|
|
"\n"
|
|
"func Setup() {}\n";
|
|
|
|
static const char mod_test_go[] =
|
|
"package mod\n"
|
|
"\n"
|
|
"func Setup() {}\n";
|
|
|
|
static const RFile files[] = {
|
|
{"mod.go", mod_go},
|
|
{"mod_test.go", mod_test_go},
|
|
};
|
|
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 distinct = count_distinct_qns(store, lp.project, "Setup");
|
|
|
|
rh_cleanup(&lp, store);
|
|
|
|
/*
|
|
* GREEN: "mod.go" → module "<project>.mod" and
|
|
* "mod_test.go" → module "<project>.mod_test".
|
|
* Both Setup() definitions get distinct QNs — no collapse expected.
|
|
*
|
|
* Note: the pipeline may skip mod_test.go via FAST_PATTERNS (".test.")
|
|
* in non-FULL mode. If distinct == 1, we only have one definition — that
|
|
* is acceptable for this GREEN guard; the key property is no false collapse.
|
|
* We assert >= 1 (at least the production file survived) as the minimum.
|
|
*/
|
|
ASSERT_GTE(distinct, 1);
|
|
|
|
PASS();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|
* Suite
|
|
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
|
|
SUITE(repro_invariant_discovery_fqn) {
|
|
/* Part A — Discovery hygiene */
|
|
RUN_TEST(invariant_discovery_control_always_found);
|
|
RUN_TEST(invariant_discovery_always_skip_dirs);
|
|
RUN_TEST(invariant_discovery_fast_skip_dirs);
|
|
|
|
/* Part B — FQN same-stem distinctness */
|
|
RUN_TEST(invariant_fqn_api_h_api_c); /* RED — gap #4 */
|
|
RUN_TEST(invariant_fqn_svc_h_svc_cpp); /* RED — gap #4 */
|
|
RUN_TEST(invariant_fqn_different_dirs_same_stem); /* GREEN guard */
|
|
RUN_TEST(invariant_fqn_ts_vs_dts); /* GREEN guard */
|
|
RUN_TEST(invariant_fqn_python_same_module_different_packages); /* GREEN guard */
|
|
RUN_TEST(invariant_fqn_go_test_file_stem); /* GREEN guard */
|
|
}
|