7112 lines
256 KiB
C
7112 lines
256 KiB
C
/*
|
||
* test_pipeline.c — Integration tests for the indexing pipeline.
|
||
*
|
||
* Tests pipeline lifecycle, structure pass, and end-to-end indexing
|
||
* on a temporary directory with known file layout.
|
||
*/
|
||
#include "../src/foundation/compat.h"
|
||
#include "foundation/platform.h" // cbm_normalize_path_sep (drive-canonicalization regression)
|
||
#include "test_framework.h"
|
||
#include "test_helpers.h"
|
||
#include "foundation/mem.h" // cbm_mem_init/budget (back-pressure futile-nap test)
|
||
#include "pipeline/pipeline.h"
|
||
#include "pipeline/pipeline_internal.h"
|
||
#include "store/store.h"
|
||
#include "git/git_context.h"
|
||
#include "foundation/dump_verify.h"
|
||
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <stdatomic.h>
|
||
#include "foundation/compat_thread.h"
|
||
#include <fcntl.h>
|
||
#include <sys/stat.h>
|
||
#include <unistd.h>
|
||
#include "graph_buffer/graph_buffer.h"
|
||
#include "yyjson/yyjson.h"
|
||
#include "sqlite3.h" /* vendored/sqlite3 — PRAGMA integrity_check on dumped DBs */
|
||
|
||
/* ── Helper: create temp test repo with known layout ───────────── */
|
||
|
||
static char g_tmpdir[256];
|
||
|
||
/* Create:
|
||
* /tmp/cbm_test_XXXXXX/
|
||
* main.go (empty)
|
||
* pkg/
|
||
* service.go (empty)
|
||
* util/
|
||
* helper.go (empty)
|
||
*/
|
||
static int setup_test_repo(void) {
|
||
snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/cbm_test_XXXXXX");
|
||
if (!cbm_mkdtemp(g_tmpdir))
|
||
return -1;
|
||
|
||
char path[512];
|
||
|
||
/* main.go — calls Serve() from pkg */
|
||
snprintf(path, sizeof(path), "%s/main.go", g_tmpdir);
|
||
FILE *f = fopen(path, "w");
|
||
if (!f)
|
||
return -1;
|
||
fprintf(f, "package main\n\n"
|
||
"import \"pkg\"\n\n"
|
||
"func main() {\n"
|
||
"\tpkg.Serve()\n"
|
||
"}\n");
|
||
fclose(f);
|
||
|
||
/* pkg/ */
|
||
snprintf(path, sizeof(path), "%s/pkg", g_tmpdir);
|
||
cbm_mkdir(path);
|
||
|
||
/* pkg/service.go — calls Help() from util */
|
||
snprintf(path, sizeof(path), "%s/pkg/service.go", g_tmpdir);
|
||
f = fopen(path, "w");
|
||
if (!f)
|
||
return -1;
|
||
fprintf(f, "package pkg\n\n"
|
||
"import \"pkg/util\"\n\n"
|
||
"func Serve() {\n"
|
||
"\tutil.Help()\n"
|
||
"}\n");
|
||
fclose(f);
|
||
|
||
/* pkg/util/ */
|
||
snprintf(path, sizeof(path), "%s/pkg/util", g_tmpdir);
|
||
cbm_mkdir(path);
|
||
|
||
/* pkg/util/helper.go */
|
||
snprintf(path, sizeof(path), "%s/pkg/util/helper.go", g_tmpdir);
|
||
f = fopen(path, "w");
|
||
if (!f)
|
||
return -1;
|
||
fprintf(f, "package util\n\nfunc Help() {}\n");
|
||
fclose(f);
|
||
|
||
return 0;
|
||
}
|
||
|
||
/* Recursive remove (simple, not production-grade) */
|
||
static void rm_rf(const char *path) {
|
||
th_rmtree(path);
|
||
}
|
||
|
||
static void teardown_test_repo(void) {
|
||
if (g_tmpdir[0])
|
||
rm_rf(g_tmpdir);
|
||
g_tmpdir[0] = '\0';
|
||
}
|
||
|
||
/* ── Lifecycle tests ─────────────────────────────────────────────── */
|
||
|
||
TEST(pipeline_create_free) {
|
||
cbm_pipeline_t *p = cbm_pipeline_new("/some/path", NULL, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_STR_EQ(cbm_pipeline_project_name(p), "some-path");
|
||
cbm_pipeline_free(p);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_null_repo) {
|
||
cbm_pipeline_t *p = cbm_pipeline_new(NULL, NULL, CBM_MODE_FULL);
|
||
ASSERT_NULL(p);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_free_null) {
|
||
cbm_pipeline_free(NULL); /* should not crash */
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_cancel) {
|
||
cbm_pipeline_t *p = cbm_pipeline_new("/some/path", NULL, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
cbm_pipeline_cancel(p);
|
||
/* Running a cancelled pipeline should return -1 immediately */
|
||
int rc = cbm_pipeline_run(p);
|
||
/* Note: it may fail because /some/path doesn't exist, not because of cancel.
|
||
* This test just verifies cancel doesn't crash. */
|
||
(void)rc;
|
||
cbm_pipeline_free(p);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_cancel_null) {
|
||
cbm_pipeline_cancel(NULL); /* should not crash */
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_run_null) {
|
||
int rc = cbm_pipeline_run(NULL);
|
||
ASSERT_EQ(rc, -1);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Focused: file-backed store persistence ─────────────────────── */
|
||
|
||
TEST(store_file_persistence) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/persist_test.db", g_tmpdir);
|
||
|
||
/* Write data */
|
||
cbm_store_t *s1 = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s1);
|
||
cbm_store_upsert_project(s1, "proj", "/tmp");
|
||
cbm_node_t n = {.project = "proj",
|
||
.label = "Function",
|
||
.name = "foo",
|
||
.qualified_name = "proj.foo",
|
||
.file_path = "f.go"};
|
||
int64_t id = cbm_store_upsert_node(s1, &n);
|
||
ASSERT_GT(id, 0);
|
||
int cnt1 = cbm_store_count_nodes(s1, "proj");
|
||
ASSERT_EQ(cnt1, 1);
|
||
cbm_store_checkpoint(s1);
|
||
cbm_store_close(s1);
|
||
|
||
/* Reopen and verify */
|
||
cbm_store_t *s2 = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s2);
|
||
int cnt2 = cbm_store_count_nodes(s2, "proj");
|
||
ASSERT_EQ(cnt2, 1);
|
||
cbm_store_close(s2);
|
||
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(store_bulk_persistence) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/bulk_test.db", g_tmpdir);
|
||
|
||
/* Verify: begin_bulk + explicit txn + end_bulk persists to file */
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
|
||
cbm_store_upsert_project(s, "proj", "/tmp");
|
||
cbm_store_begin_bulk(s);
|
||
cbm_store_begin(s);
|
||
cbm_node_t n = {.project = "proj",
|
||
.label = "Function",
|
||
.name = "foo",
|
||
.qualified_name = "proj.foo",
|
||
.file_path = "f.go"};
|
||
int64_t id = cbm_store_upsert_node(s, &n);
|
||
ASSERT_GT(id, 0);
|
||
ASSERT_EQ(cbm_store_commit(s), 0);
|
||
cbm_store_end_bulk(s);
|
||
cbm_store_checkpoint(s);
|
||
cbm_store_close(s);
|
||
|
||
/* Reopen and verify data survived */
|
||
cbm_store_t *s2 = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s2);
|
||
ASSERT_EQ(cbm_store_count_nodes(s2, "proj"), 1);
|
||
cbm_store_close(s2);
|
||
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* ── Integration: structure pass on temp repo ────────────────────── */
|
||
|
||
TEST(pipeline_structure_nodes) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
/* Verify results by opening the store */
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* Should have nodes: 1 Project + 2 Folders + 3 Files + N Functions */
|
||
int node_count = cbm_store_count_nodes(s, project);
|
||
ASSERT_GTE(node_count, 9); /* 6 structure + at least 3 definitions */
|
||
|
||
/* Verify project node exists */
|
||
cbm_node_t proj_node = {0};
|
||
rc = cbm_store_find_node_by_qn(s, project, project, &proj_node);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_STR_EQ(proj_node.label, "Project");
|
||
cbm_node_free_fields(&proj_node);
|
||
|
||
/* Verify folder nodes */
|
||
cbm_node_t *folders = NULL;
|
||
int folder_count = 0;
|
||
rc = cbm_store_find_nodes_by_label(s, project, "Folder", &folders, &folder_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_GTE(folder_count, 2); /* pkg, pkg/util */
|
||
cbm_store_free_nodes(folders, folder_count);
|
||
|
||
/* Verify file nodes */
|
||
cbm_node_t *file_nodes = NULL;
|
||
int file_count = 0;
|
||
rc = cbm_store_find_nodes_by_label(s, project, "File", &file_nodes, &file_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_GTE(file_count, 3); /* main.go, service.go, helper.go */
|
||
cbm_store_free_nodes(file_nodes, file_count);
|
||
|
||
/* Verify edges exist */
|
||
int edge_count = cbm_store_count_edges(s, project);
|
||
ASSERT_GTE(edge_count, 5); /* CONTAINS_FOLDER + CONTAINS_FILE edges */
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* Issue #516: an ADR stored via manage_adr (project_summaries) must survive a
|
||
* full re-index. A full re-index deletes the DB and rebuilds it from the graph
|
||
* buffer, which writes an empty project_summaries table; the fix captures the
|
||
* ADR before the delete and restores it after the rebuild. Reproduce-first:
|
||
* index, store an ADR, force a full re-index by adding files, assert the ADR
|
||
* is still present and unchanged. */
|
||
TEST(pipeline_adr_survives_full_reindex) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test.db", tmp);
|
||
|
||
/* Initial index with a single source file. */
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/main.py", tmp);
|
||
FILE *f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "def foo():\n pass\n");
|
||
fclose(f);
|
||
|
||
cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p1);
|
||
ASSERT_EQ(cbm_pipeline_run(p1), 0);
|
||
const char *project = cbm_pipeline_project_name(p1);
|
||
char project_copy[256];
|
||
snprintf(project_copy, sizeof(project_copy), "%s", project);
|
||
cbm_pipeline_free(p1);
|
||
|
||
/* Store an ADR. */
|
||
const char *adr_text = "# Decision\nWe chose X over Y.";
|
||
cbm_store_t *s1 = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s1);
|
||
ASSERT_EQ(cbm_store_adr_store(s1, project_copy, adr_text), CBM_STORE_OK);
|
||
cbm_store_close(s1);
|
||
|
||
/* Force a full re-index: add enough files to exceed the incremental
|
||
* threshold so the DB is deleted and rebuilt. */
|
||
for (int i = 0; i < 4; i++) {
|
||
snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i);
|
||
f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "def g%d():\n return %d\n", i, i);
|
||
fclose(f);
|
||
}
|
||
|
||
cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p2);
|
||
ASSERT_EQ(cbm_pipeline_run(p2), 0);
|
||
cbm_pipeline_free(p2);
|
||
|
||
/* The ADR must still be present and unchanged. */
|
||
cbm_store_t *s2 = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s2);
|
||
cbm_adr_t adr = {0};
|
||
int rc = cbm_store_adr_get(s2, project_copy, &adr);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_NOT_NULL(adr.content);
|
||
ASSERT_STR_EQ(adr.content, adr_text);
|
||
cbm_store_adr_free(&adr);
|
||
cbm_store_close(s2);
|
||
|
||
rm_rf(tmp);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_structure_edges) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* Check CONTAINS_FILE edges */
|
||
int cf_count = cbm_store_count_edges_by_type(s, project, "CONTAINS_FILE");
|
||
/* Check CONTAINS_FOLDER edges */
|
||
int cd_count = cbm_store_count_edges_by_type(s, project, "CONTAINS_FOLDER");
|
||
|
||
/* Cleanup before assertions (so failures don't leak) */
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
|
||
ASSERT_GTE(cf_count, 3); /* project->main.go, pkg->service.go, util->helper.go */
|
||
ASSERT_GTE(cd_count, 1); /* project->pkg (pkg->util may merge on some platforms) */
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_branch_root_structure) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_branch_root.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
char branch_qn[1024];
|
||
snprintf(branch_qn, sizeof(branch_qn), "%s.__branch__.working-tree", project);
|
||
|
||
cbm_node_t project_node = {0};
|
||
cbm_node_t branch_node = {0};
|
||
cbm_node_t root_file_node = {0};
|
||
cbm_node_t root_folder_node = {0};
|
||
rc = cbm_store_find_node_by_qn(s, project, project, &project_node);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
rc = cbm_store_find_node_by_qn(s, project, branch_qn, &branch_node);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_STR_EQ(branch_node.label, "Branch");
|
||
ASSERT_STR_EQ(branch_node.name, "working-tree");
|
||
ASSERT_NOT_NULL(strstr(branch_node.properties_json, "\"is_git\":false"));
|
||
char *root_folder_qn = cbm_pipeline_fqn_folder(project, "pkg");
|
||
char *root_file_qn = cbm_pipeline_fqn_compute(project, "main.go", "__file__");
|
||
ASSERT_NOT_NULL(root_folder_qn);
|
||
ASSERT_NOT_NULL(root_file_qn);
|
||
rc = cbm_store_find_node_by_qn(s, project, root_folder_qn, &root_folder_node);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
rc = cbm_store_find_node_by_qn(s, project, root_file_qn, &root_file_node);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
|
||
cbm_edge_t *has_branch = NULL;
|
||
int has_branch_count = 0;
|
||
rc = cbm_store_find_edges_by_source_type(s, project_node.id, "HAS_BRANCH", &has_branch,
|
||
&has_branch_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_EQ(has_branch_count, 1);
|
||
ASSERT_EQ(has_branch[0].target_id, branch_node.id);
|
||
|
||
cbm_edge_t *project_files = NULL;
|
||
int project_file_count = 0;
|
||
rc = cbm_store_find_edges_by_source_type(s, project_node.id, "CONTAINS_FILE", &project_files,
|
||
&project_file_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_EQ(project_file_count, 0);
|
||
|
||
cbm_edge_t *branch_files = NULL;
|
||
int branch_file_count = 0;
|
||
rc = cbm_store_find_edges_by_source_type(s, branch_node.id, "CONTAINS_FILE", &branch_files,
|
||
&branch_file_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_EQ(branch_file_count, 1);
|
||
ASSERT_EQ(branch_files[0].target_id, root_file_node.id);
|
||
|
||
cbm_edge_t *branch_folders = NULL;
|
||
int branch_folder_count = 0;
|
||
rc = cbm_store_find_edges_by_source_type(s, branch_node.id, "CONTAINS_FOLDER", &branch_folders,
|
||
&branch_folder_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_EQ(branch_folder_count, 1);
|
||
ASSERT_EQ(branch_folders[0].target_id, root_folder_node.id);
|
||
|
||
cbm_store_free_edges(has_branch, has_branch_count);
|
||
cbm_store_free_edges(project_files, project_file_count);
|
||
cbm_store_free_edges(branch_files, branch_file_count);
|
||
cbm_store_free_edges(branch_folders, branch_folder_count);
|
||
cbm_node_free_fields(&project_node);
|
||
cbm_node_free_fields(&branch_node);
|
||
cbm_node_free_fields(&root_file_node);
|
||
cbm_node_free_fields(&root_folder_node);
|
||
free(root_folder_qn);
|
||
free(root_file_qn);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_project_name_derived) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, NULL, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
|
||
/* Project name should be derived from tmpdir path */
|
||
const char *name = cbm_pipeline_project_name(p);
|
||
ASSERT_NOT_NULL(name);
|
||
ASSERT_TRUE(strlen(name) > 0);
|
||
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_fast_mode) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_fast.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FAST);
|
||
ASSERT_NOT_NULL(p);
|
||
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
/* Just verify it completes without error in fast mode */
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
int node_count = cbm_store_count_nodes(s, project);
|
||
ASSERT_GT(node_count, 0);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* ── Definitions pass tests ──────────────────────────────────────── */
|
||
|
||
TEST(pipeline_definitions_function_nodes) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_defs.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* Verify Function nodes extracted from Go source files */
|
||
cbm_node_t *funcs = NULL;
|
||
int func_count = 0;
|
||
rc = cbm_store_find_nodes_by_label(s, project, "Function", &funcs, &func_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_GTE(func_count, 3); /* main, Serve, Help */
|
||
|
||
/* Verify each expected function exists */
|
||
int found_main = 0, found_serve = 0, found_help = 0;
|
||
for (int i = 0; i < func_count; i++) {
|
||
if (strcmp(funcs[i].name, "main") == 0)
|
||
found_main = 1;
|
||
else if (strcmp(funcs[i].name, "Serve") == 0)
|
||
found_serve = 1;
|
||
else if (strcmp(funcs[i].name, "Help") == 0)
|
||
found_help = 1;
|
||
}
|
||
ASSERT_TRUE(found_main);
|
||
ASSERT_TRUE(found_serve);
|
||
ASSERT_TRUE(found_help);
|
||
|
||
cbm_store_free_nodes(funcs, func_count);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_definitions_defines_edges) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_defs_edges.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* DEFINES edges: File → Function (one per extracted definition) */
|
||
int defines_count = cbm_store_count_edges_by_type(s, project, "DEFINES");
|
||
ASSERT_GTE(defines_count, 3); /* main.go→main, service.go→Serve, helper.go→Help */
|
||
|
||
/* CONTAINS_FILE edges should still exist from structure pass */
|
||
int cf_count = cbm_store_count_edges_by_type(s, project, "CONTAINS_FILE");
|
||
ASSERT_GTE(cf_count, 3);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_definitions_properties) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_defs_props.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* Verify a function has valid properties (complexity, lines, etc.) */
|
||
cbm_node_t *funcs = NULL;
|
||
int func_count = 0;
|
||
rc = cbm_store_find_nodes_by_label(s, project, "Function", &funcs, &func_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
ASSERT_GT(func_count, 0);
|
||
|
||
/* Check that Serve (exported) has is_exported:true in properties */
|
||
for (int i = 0; i < func_count; i++) {
|
||
if (strcmp(funcs[i].name, "Serve") == 0) {
|
||
ASSERT_NOT_NULL(funcs[i].properties_json);
|
||
ASSERT_TRUE(strstr(funcs[i].properties_json, "\"is_exported\":true") != NULL);
|
||
}
|
||
/* All functions should have file_path set */
|
||
ASSERT_NOT_NULL(funcs[i].file_path);
|
||
}
|
||
|
||
cbm_store_free_nodes(funcs, func_count);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* Node properties must remain VALID JSON even when a definition's serialized
|
||
* properties exceed the fixed 2 KB build buffer. Found on the Linux kernel:
|
||
* 135 nodes (50-param functions with struct-typed signatures) had properties
|
||
* truncated mid-string at 2047 bytes — malformed JSON that aborts EVERY
|
||
* json_extract()-based consumer (arch_entry_points, partial indexes, user
|
||
* Cypher on properties). Oversized optional fields must be dropped whole,
|
||
* never cut mid-value. */
|
||
TEST(pipeline_def_props_valid_json_when_oversized) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
/* Sweep of C functions with growing signatures. The corruption fires only
|
||
* when the serialized position lands in a narrow window just under the
|
||
* buffer margin as the param_types array starts — the array is then cut
|
||
* mid-item (`"param_types":["enum`), exactly the kernel failure shape.
|
||
* Sweeping 10..69 params deterministically hits the window (pre-fix:
|
||
* sweep_fn_30 -> 2047-byte malformed properties). ONE FUNCTION PER FILE so
|
||
* the file count exceeds MIN_FILES_FOR_PARALLEL (50) and the test covers
|
||
* the PARALLEL pipeline's duplicated props builder (pass_parallel.c) — the
|
||
* path large repos take; the serial twin lives in pass_definitions.c. */
|
||
for (int n = 10; n < 70; n++) {
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/sweep_%02d.c", g_tmpdir, n);
|
||
FILE *f = fopen(path, "w");
|
||
if (!f) {
|
||
teardown_test_repo();
|
||
FAIL("failed to write sweep file");
|
||
}
|
||
fprintf(f, "int sweep_fn_%02d(", n);
|
||
for (int i = 0; i < n; i++) {
|
||
fprintf(f, "%sstruct long_struct_type_name_padding_padding_%02d *par_%02d",
|
||
i ? ", " : "", i, i);
|
||
}
|
||
fprintf(f, ") { return 0; }\n");
|
||
fclose(f);
|
||
}
|
||
|
||
/* Multi-line parameter declarations: param_types items then carry raw
|
||
* newline/tab bytes from the source slice. The array appender's inline
|
||
* escape loop only handled quote/backslash, so the raw control bytes made
|
||
* the JSON invalid (118 Linux-kernel rows of this shape). */
|
||
{
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/sweep_nl.c", g_tmpdir);
|
||
FILE *f = fopen(path, "w");
|
||
if (!f) {
|
||
teardown_test_repo();
|
||
FAIL("failed to write sweep_nl.c");
|
||
}
|
||
fprintf(f, "int sweep_fn_nl(struct\n\t\t\t\treally_long_struct_name *a,\n"
|
||
" enum\n\tweird_enum b) { return 0; }\n");
|
||
fclose(f);
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_huge_props.db", g_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *funcs = NULL;
|
||
int func_count = 0;
|
||
ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "Function", &funcs, &func_count),
|
||
CBM_STORE_OK);
|
||
int checked = 0;
|
||
for (int i = 0; i < func_count; i++) {
|
||
if (strncmp(funcs[i].name, "sweep_fn_", 9) != 0) {
|
||
continue;
|
||
}
|
||
ASSERT_NOT_NULL(funcs[i].properties_json);
|
||
yyjson_doc *doc =
|
||
yyjson_read(funcs[i].properties_json, strlen(funcs[i].properties_json), 0);
|
||
if (!doc) {
|
||
printf(" INVALID properties JSON for %s (%zu bytes): ...%s\n", funcs[i].name,
|
||
strlen(funcs[i].properties_json),
|
||
funcs[i].properties_json + (strlen(funcs[i].properties_json) > 60
|
||
? strlen(funcs[i].properties_json) - 60
|
||
: 0));
|
||
}
|
||
ASSERT_NOT_NULL(doc); /* valid JSON for EVERY sweep size */
|
||
yyjson_doc_free(doc);
|
||
checked++;
|
||
}
|
||
ASSERT_EQ(checked, 61); /* all sweep functions present (60 sizes + nl case) */
|
||
|
||
cbm_store_free_nodes(funcs, func_count);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* Edge properties must be VALID JSON. Decorator source text (quotes, raw
|
||
* newlines: @register.tag("block"), multi-line @override_settings) was
|
||
* interpolated raw into the DECORATES properties — django produced 3826
|
||
* malformed edges, and any json_extract-based consumer (including the
|
||
* url_path_gen generated-column evaluation during PRAGMA integrity_check)
|
||
* aborts on them. Usage/call emit sites had the same hole for sliced source
|
||
* text. */
|
||
TEST(pipeline_edge_props_valid_json) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/deco.py", g_tmpdir);
|
||
FILE *f = fopen(path, "w");
|
||
if (!f) {
|
||
teardown_test_repo();
|
||
FAIL("failed to write deco.py");
|
||
}
|
||
fprintf(f, "from x import register\n"
|
||
"@register.tag(\"block\")\n"
|
||
"def do_block(parser):\n"
|
||
" return parser\n"
|
||
"@register.tag(\"extends\")\n"
|
||
"def do_extends(parser):\n"
|
||
" return parser\n");
|
||
fclose(f);
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_edge_props.db", g_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
cbm_edge_t *edges = NULL;
|
||
int edge_count = 0;
|
||
ASSERT_EQ(cbm_store_find_edges_by_type(s, project, "DECORATES", &edges, &edge_count),
|
||
CBM_STORE_OK);
|
||
ASSERT_GT(edge_count, 0); /* the decorators must produce DECORATES edges */
|
||
for (int i = 0; i < edge_count; i++) {
|
||
const char *pj = edges[i].properties_json;
|
||
if (!pj) {
|
||
continue;
|
||
}
|
||
yyjson_doc *doc = yyjson_read(pj, strlen(pj), 0);
|
||
if (!doc) {
|
||
printf(" INVALID edge properties JSON: %.80s\n", pj);
|
||
}
|
||
ASSERT_NOT_NULL(doc);
|
||
yyjson_doc_free(doc);
|
||
}
|
||
|
||
cbm_store_free_edges(edges, edge_count);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* ── Calls pass tests ──────────────────────────────────────────── */
|
||
|
||
TEST(pipeline_calls_resolution) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_calls.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* main() calls pkg.Serve(), Serve() calls util.Help() → at least 2 CALLS edges */
|
||
int calls_count = cbm_store_count_edges_by_type(s, project, "CALLS");
|
||
ASSERT_GTE(calls_count, 1); /* at least some calls resolved */
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* True iff a CALLS edge exists from a node named src_name to a node named
|
||
* tgt_name. Used to assert cross-file call resolution survives a reindex. */
|
||
static bool cross_file_call_exists(cbm_store_t *s, const char *project, const char *src_name,
|
||
const char *tgt_name) {
|
||
cbm_node_t *srcs = NULL;
|
||
cbm_node_t *tgts = NULL;
|
||
int sc = 0;
|
||
int tc = 0;
|
||
cbm_store_find_nodes_by_name(s, project, src_name, &srcs, &sc);
|
||
cbm_store_find_nodes_by_name(s, project, tgt_name, &tgts, &tc);
|
||
bool found = false;
|
||
for (int i = 0; i < sc && !found; i++) {
|
||
cbm_edge_t *edges = NULL;
|
||
int ec = 0;
|
||
cbm_store_find_edges_by_source_type(s, srcs[i].id, "CALLS", &edges, &ec);
|
||
for (int j = 0; j < ec && !found; j++) {
|
||
for (int k = 0; k < tc; k++) {
|
||
if (edges[j].target_id == tgts[k].id) {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (edges) {
|
||
cbm_store_free_edges(edges, ec);
|
||
}
|
||
}
|
||
if (srcs) {
|
||
cbm_store_free_nodes(srcs, sc);
|
||
}
|
||
if (tgts) {
|
||
cbm_store_free_nodes(tgts, tc);
|
||
}
|
||
return found;
|
||
}
|
||
|
||
/* Regression: incremental re-index of an edited file must NOT drop inbound
|
||
* cross-file CALLS edges whose source lives in an UNCHANGED file.
|
||
*
|
||
* Repro: Serve() (pkg/service.go) CALLS Help() (pkg/util/helper.go). Editing
|
||
* helper.go purges Help's node; before the fix the cascade deleted the
|
||
* Serve->Help edge and never regenerated it (helper.go's callers are not
|
||
* re-parsed), so the edge silently vanished on every edit and the incremental
|
||
* graph diverged from a full reindex. */
|
||
TEST(pipeline_incremental_preserves_cross_file_calls) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_incr_calls.db", g_tmpdir);
|
||
|
||
/* 1. Full index. */
|
||
cbm_pipeline_t *p1 = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p1);
|
||
ASSERT_EQ(cbm_pipeline_run(p1), 0);
|
||
const char *project = cbm_pipeline_project_name(p1);
|
||
|
||
cbm_store_t *s1 = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s1);
|
||
int calls_before = cbm_store_count_edges_by_type(s1, project, "CALLS");
|
||
ASSERT_GTE(calls_before, 2); /* main->Serve and Serve->Help at minimum */
|
||
ASSERT_TRUE(cross_file_call_exists(s1, project, "Serve", "Help"));
|
||
cbm_store_close(s1);
|
||
cbm_pipeline_free(p1);
|
||
|
||
/* 2. Edit the callee's file so the incremental classifier marks it changed
|
||
* (mtime+size differ). Help's symbol + qualified name are unchanged. */
|
||
char helper[512];
|
||
snprintf(helper, sizeof(helper), "%s/pkg/util/helper.go", g_tmpdir);
|
||
ASSERT_EQ(th_append_file(helper, "\n// incremental regression marker\n"), 0);
|
||
|
||
/* 3. Re-run on the SAME db_path → auto-routes to incremental re-index. */
|
||
cbm_pipeline_t *p2 = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p2);
|
||
ASSERT_EQ(cbm_pipeline_run(p2), 0);
|
||
|
||
/* 4. The inbound cross-file CALLS edge must survive and the total CALLS
|
||
* count must not regress. (Before the fix: Serve->Help is dropped.)
|
||
* NOTE: query with p2's project name — p1 (and the `project` pointer it
|
||
* owned) was freed above; p1 and p2 derive the same name from g_tmpdir. */
|
||
const char *project2 = cbm_pipeline_project_name(p2);
|
||
cbm_store_t *s2 = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s2);
|
||
int calls_after = cbm_store_count_edges_by_type(s2, project2, "CALLS");
|
||
ASSERT_EQ(calls_after, calls_before);
|
||
ASSERT_TRUE(cross_file_call_exists(s2, project2, "Serve", "Help"));
|
||
cbm_store_close(s2);
|
||
cbm_pipeline_free(p2);
|
||
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* TS/JS receiver-aware weak-strategy suppression (#592/#606 direction; Perl
|
||
* precedent #477). A member call x.foo() whose receiver TYPE the TS-LSP cannot
|
||
* resolve (a regex literal `re.test()`) must NOT be bound to a same-named
|
||
* project method by a weak short-name strategy — that fabricates a CALLS edge.
|
||
* Type-resolved receivers (`c.test()` on a typed SalesforceRestClient) and bare
|
||
* local calls must still resolve. < 50 files → sequential path (pass_calls.c).
|
||
* RED before the fix: checkFormat->test exists via unique_name/suffix_match. */
|
||
static void write_temp_file(const char *dir, const char *name, const char *content);
|
||
TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_recv_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
/* The lone project symbol named "test" — a real method. */
|
||
write_temp_file(tmp, "src/client.ts",
|
||
"export class SalesforceRestClient {\n"
|
||
" test(): boolean {\n"
|
||
" return true;\n"
|
||
" }\n"
|
||
"}\n");
|
||
/* Regex receiver: `re.test(s)` calls RegExp.prototype.test, NOT the method.
|
||
* The TS-LSP cannot bind it to a project symbol → the registry would guess
|
||
* "test" by short name (weak). This is the false edge to suppress. */
|
||
write_temp_file(tmp, "src/caller.ts",
|
||
"const re = /^[a-z]+$/;\n"
|
||
"export function checkFormat(s: string): boolean {\n"
|
||
" return re.test(s);\n"
|
||
"}\n");
|
||
/* Typed receiver: `c` is annotated SalesforceRestClient, so the TS-LSP
|
||
* resolves c.test() to the method (lsp_ts_method, conf 0.95) BEFORE the
|
||
* registry runs — the guard's explicit drop-list keeps every lsp_* edge. */
|
||
write_temp_file(tmp, "src/typed.ts",
|
||
"import { SalesforceRestClient } from './client';\n"
|
||
"export function runTyped(c: SalesforceRestClient): boolean {\n"
|
||
" return c.test();\n"
|
||
"}\n");
|
||
/* Bare local call: same-module resolution, unaffected by the guard. */
|
||
write_temp_file(tmp, "src/local.ts",
|
||
"function localHelper(): number {\n"
|
||
" return 1;\n"
|
||
"}\n"
|
||
"export function callsLocal(): number {\n"
|
||
" return localHelper();\n"
|
||
"}\n");
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/tsjs_recv.db", tmp);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
|
||
/* (1) The false edge is suppressed (reproduce-first: RED before the fix). */
|
||
ASSERT_FALSE(cross_file_call_exists(s, project, "checkFormat", "test"));
|
||
/* (2) The type-resolved receiver call survives (LSP wins before the guard). */
|
||
ASSERT_TRUE(cross_file_call_exists(s, project, "runTyped", "test"));
|
||
/* (3) The bare local call survives (same-module / lsp_ts_local). */
|
||
ASSERT_TRUE(cross_file_call_exists(s, project, "callsLocal", "localHelper"));
|
||
/* (4) breadth insurance: the real edges are still emitted. */
|
||
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "CALLS"), 2);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* Count nodes with the given exact name in the project (e.g. a Route path). */
|
||
static int count_nodes_named(cbm_store_t *s, const char *project, const char *name) {
|
||
cbm_node_t *ns = NULL;
|
||
int n = 0;
|
||
cbm_store_find_nodes_by_name(s, project, name, &ns, &n);
|
||
if (ns) {
|
||
cbm_store_free_nodes(ns, n);
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/* Parallel-resolver regression for the TS/JS receiver guard (>= 50 files forces
|
||
* pass_parallel.c's resolve_file_calls). The guard must not drop a weak member
|
||
* match before the service classification runs — it suppresses ONLY the plain
|
||
* CALLS fall-through, so every service edge (HTTP_CALLS via the #523 callee
|
||
* bypass or emit_service_edge's unconditional detect_url_in_args, Route via the
|
||
* ROUTE_REG fall-through, …) is emitted exactly as on main. These callees are
|
||
* classified by main's verb-suffix + URL-arg heuristic, NOT by an HTTP library
|
||
* name in the callee — a duplicated predicate keyed on the resolved QN lost them
|
||
* (axios.get, api.patch on a renamed-axios instance, supertest request(app).get).
|
||
* The regex false edge must stay suppressed in parallel too. CBM_WORKERS forces
|
||
* >1 worker so the parallel path is taken regardless of the host core count. */
|
||
TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_par_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
/* The lone project symbols named get()/patch()/test() — weak short-name
|
||
* targets the registry mis-binds the member calls below to. */
|
||
write_temp_file(tmp, "src/thing.ts",
|
||
"export class ApiThing {\n"
|
||
" get(): number {\n"
|
||
" return 1;\n"
|
||
" }\n"
|
||
" patch(): number {\n"
|
||
" return 2;\n"
|
||
" }\n"
|
||
" load(): number {\n"
|
||
" return 3;\n"
|
||
" }\n"
|
||
"}\n"
|
||
"export class Other {\n"
|
||
" test(): boolean {\n"
|
||
" return true;\n"
|
||
" }\n"
|
||
"}\n");
|
||
/* Untyped axios: the HTTP signal is in the callee name, not the resolved QN
|
||
* (the registry mis-binds "get" to ApiThing.get). Must yield HTTP_CALLS. */
|
||
write_temp_file(tmp, "src/http.ts",
|
||
"export function callApi() {\n"
|
||
" return axios.get('/api/orders');\n"
|
||
"}\n");
|
||
/* Renamed axios instance `api`: no HTTP lib id in the callee — classified by
|
||
* the .patch verb suffix + URL arg. Must yield HTTP_CALLS (was lost when the
|
||
* exemption keyed on the resolved QN only). */
|
||
write_temp_file(tmp, "src/http2.ts",
|
||
"export function callPatch(): unknown {\n"
|
||
" return api.patch('/plans/:id', {});\n"
|
||
"}\n");
|
||
/* Supertest-style chained receiver `request(app).get('/y')` — untyped, verb
|
||
* suffix + URL arg. */
|
||
write_temp_file(tmp, "src/supertest.ts",
|
||
"export function callSup(app: unknown): unknown {\n"
|
||
" return request(app).get('/y');\n"
|
||
"}\n");
|
||
/* `dev.load('/data')`: `.load` is NOT a route suffix and `dev` is not an HTTP
|
||
* lib, so main classifies it via the unconditional detect_url_in_args (URL
|
||
* arg) -> HTTP_CALLS, while the weak plain match to ApiThing.load is the false
|
||
* edge that must be suppressed. This is exactly the detect_url_in_args path
|
||
* the previous (predicate-duplicating) guard skipped by dropping the call
|
||
* before emit_service_edge ran — the class of ~399 HTTP_CALLS it lost. */
|
||
write_temp_file(tmp, "src/load.ts",
|
||
"export function callLoad(dev: unknown): unknown {\n"
|
||
" return dev.load('/api/data');\n"
|
||
"}\n");
|
||
/* Route registration by callee suffix + a '/'-path arg. Must yield a Route. */
|
||
write_temp_file(tmp, "src/routes.ts",
|
||
"function handler() {}\n"
|
||
"export function reg(router: any) {\n"
|
||
" router.get('/users', handler);\n"
|
||
"}\n");
|
||
/* Regex receiver: the false edge that must stay suppressed in parallel too. */
|
||
write_temp_file(tmp, "src/re.ts",
|
||
"const re = /^[a-z]+$/;\n"
|
||
"export function checkFormat(s: string): boolean {\n"
|
||
" return re.test(s);\n"
|
||
"}\n");
|
||
/* Pad past MIN_FILES_FOR_PARALLEL (50) so the parallel resolver runs. */
|
||
for (int i = 0; i < 52; i++) {
|
||
char name[64];
|
||
char body[128];
|
||
snprintf(name, sizeof(name), "src/filler%d.ts", i);
|
||
snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i,
|
||
i);
|
||
write_temp_file(tmp, name, body);
|
||
}
|
||
|
||
char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved = old_workers ? strdup(old_workers) : NULL;
|
||
cbm_setenv("CBM_WORKERS", "4", 1); /* force parallel regardless of host cores */
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/tsjs_par.db", tmp);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
|
||
/* (1) Genuine HTTP_CALLS survive under the guard (>= 3):
|
||
* - axios.get('/api/orders') -> 2 edges (recognized lib #523 callee bypass
|
||
* + detect_url_in_args), and
|
||
* - dev.load('/api/data') -> 1 edge via detect_url_in_args, which runs
|
||
* unconditionally after emit_service_edge's branch even when the plain
|
||
* fall-through is suppressed.
|
||
* dev.load is the class the predicate-duplicating guard lost: `.load` is not
|
||
* a route suffix and `dev` is not an HTTP lib, so it was dropped before
|
||
* emit_service_edge ran (RED on that guard: only axios's 2). */
|
||
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 3);
|
||
/* (2) The verb-suffix + route-path member calls keep their route
|
||
* registrations (edge type CALLS -> a Route node named by the path). These
|
||
* classify as route_registration on main, NOT HTTP_CALLS — Option A preserves
|
||
* that by construction. Assert the three Route paths survive:
|
||
* api.patch('/plans/:id'), request(app).get('/y'), router.get('/users'). */
|
||
ASSERT_GTE(count_nodes_named(s, project, "/plans/:id"), 1);
|
||
ASSERT_GTE(count_nodes_named(s, project, "/y"), 1);
|
||
ASSERT_GTE(count_nodes_named(s, project, "/users"), 1);
|
||
/* (3) The false plain-CALLS edges are suppressed in parallel: the regex
|
||
* receiver and the dev.load weak match to ApiThing.load. */
|
||
ASSERT_FALSE(cross_file_call_exists(s, project, "checkFormat", "test"));
|
||
ASSERT_FALSE(cross_file_call_exists(s, project, "callLoad", "load"));
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
if (saved) {
|
||
cbm_setenv("CBM_WORKERS", saved, 1);
|
||
free(saved);
|
||
} else {
|
||
cbm_unsetenv("CBM_WORKERS");
|
||
}
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* Native `fetch()` (#856), sequential path (< 50 files → pass_calls.c). A bare
|
||
* unqualified call to the global fetch API has no import and no local
|
||
* definition anywhere in this project, so registry resolution comes back
|
||
* empty; classify it as HTTP_CALLS via the same #523-style empty-resolution
|
||
* fallback used for axios/requests. A member call on an unrelated receiver
|
||
* (`repo.fetch()`) must NOT be swept in — its callee_name is "repo.fetch",
|
||
* not the bare "fetch" this check matches exactly. */
|
||
TEST(pipeline_native_fetch_classified_as_http_calls) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
write_temp_file(tmp, "src/api.ts",
|
||
"export function loadData(): unknown {\n"
|
||
" return fetch('/api/data');\n"
|
||
"}\n");
|
||
/* `repo.fetch()` — a method call, not the global. Must not over-match. */
|
||
write_temp_file(tmp, "src/repo.ts",
|
||
"export function useRepo(repo: { fetch: () => unknown }): unknown {\n"
|
||
" return repo.fetch();\n"
|
||
"}\n");
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/fetch.db", tmp);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
|
||
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
|
||
/* Exactly the bare call, not the method call too. */
|
||
ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* Native `fetch()` (#856), parallel path (>= 50 files -> pass_parallel.c's
|
||
* resolve_file_calls). Mirrors pipeline_native_fetch_classified_as_http_calls
|
||
* but forces the parallel resolver, since the empty-resolution fallback is a
|
||
* separate implementation there (resolve_file_calls calls
|
||
* emit_http_async_service_edge directly rather than through emit_service_edge,
|
||
* which would otherwise re-derive CBM_SVC_NONE for "fetch" and silently fall
|
||
* through to a plain CALLS edge). */
|
||
TEST(pipeline_native_fetch_parallel_classified_as_http_calls) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_par_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
write_temp_file(tmp, "src/api.ts",
|
||
"export function loadData(): unknown {\n"
|
||
" return fetch('/api/data');\n"
|
||
"}\n");
|
||
for (int i = 0; i < 52; i++) {
|
||
char name[64];
|
||
char body[128];
|
||
snprintf(name, sizeof(name), "src/filler%d.ts", i);
|
||
snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i,
|
||
i);
|
||
write_temp_file(tmp, name, body);
|
||
}
|
||
|
||
char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved = old_workers ? strdup(old_workers) : NULL;
|
||
cbm_setenv("CBM_WORKERS", "4", 1); /* force parallel regardless of host cores */
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/fetch_par.db", tmp);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
|
||
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
if (saved) {
|
||
cbm_setenv("CBM_WORKERS", saved, 1);
|
||
free(saved);
|
||
} else {
|
||
cbm_unsetenv("CBM_WORKERS");
|
||
}
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* Native `fetch()` (#856): a LOCAL `fetch` definition must win over the
|
||
* global-API guess. Registry resolution finds this project's own top-level
|
||
* `fetch` function, so the call is a plain CALLS edge to it — never
|
||
* HTTP_CALLS. Isolated in its own project: the registry's project-wide
|
||
* unique-name fallback means a local `fetch` anywhere in a project can
|
||
* legitimately capture bare `fetch()` calls project-wide, so this must not
|
||
* share a project with the genuine-native-fetch test above. */
|
||
TEST(pipeline_local_fetch_shadow_not_classified_as_http) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_shadow_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
write_temp_file(tmp, "src/local_fetch.ts",
|
||
"function fetch(url: string): string {\n"
|
||
" return 'mock:' + url;\n"
|
||
"}\n"
|
||
"export function useLocalFetch(): string {\n"
|
||
" return fetch('/api/data');\n"
|
||
"}\n");
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/fetch_shadow.db", tmp);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
|
||
ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 0);
|
||
ASSERT_TRUE(cross_file_call_exists(s, project, "useLocalFetch", "fetch"));
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Git history pass tests ─────────────────────────────────────── */
|
||
|
||
TEST(githistory_is_trackable) {
|
||
/* Source files → trackable */
|
||
ASSERT_TRUE(cbm_is_trackable_file("main.go"));
|
||
ASSERT_TRUE(cbm_is_trackable_file("src/app.py"));
|
||
ASSERT_TRUE(cbm_is_trackable_file("README.md"));
|
||
|
||
/* Non-trackable: skip prefixes */
|
||
ASSERT_FALSE(cbm_is_trackable_file("node_modules/foo/bar.js"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("vendor/lib/dep.go"));
|
||
ASSERT_FALSE(cbm_is_trackable_file(".git/config"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("__pycache__/mod.pyc"));
|
||
|
||
/* Non-trackable: lock files */
|
||
ASSERT_FALSE(cbm_is_trackable_file("package-lock.json"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("go.sum"));
|
||
|
||
/* Non-trackable: binary/minified extensions */
|
||
ASSERT_FALSE(cbm_is_trackable_file("image.png"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("src/style.min.css"));
|
||
|
||
PASS();
|
||
}
|
||
|
||
TEST(githistory_compute_coupling) {
|
||
/* 5 commits with overlapping files */
|
||
char *files_0[] = {"a.go", "b.go", "c.go"};
|
||
char *files_1[] = {"a.go", "b.go"};
|
||
char *files_2[] = {"a.go", "b.go"};
|
||
char *files_3[] = {"a.go", "c.go"};
|
||
char *files_4[] = {"d.go", "e.go"};
|
||
|
||
cbm_commit_files_t commits[] = {
|
||
{files_0, 3, 0}, {files_1, 2, 0}, {files_2, 2, 0}, {files_3, 2, 0}, {files_4, 2, 0},
|
||
};
|
||
|
||
cbm_change_coupling_t results[100];
|
||
int n = cbm_compute_change_coupling(commits, 5, results, 100);
|
||
|
||
/* a.go + b.go co-change 3 times → should appear */
|
||
bool found_ab = false;
|
||
for (int i = 0; i < n; i++) {
|
||
if ((strcmp(results[i].file_a, "a.go") == 0 && strcmp(results[i].file_b, "b.go") == 0) ||
|
||
(strcmp(results[i].file_a, "b.go") == 0 && strcmp(results[i].file_b, "a.go") == 0)) {
|
||
found_ab = true;
|
||
ASSERT_EQ(results[i].co_change_count, 3);
|
||
ASSERT_TRUE(results[i].coupling_score >= 0.7);
|
||
}
|
||
}
|
||
ASSERT_TRUE(found_ab);
|
||
|
||
/* d.go + e.go co-change only 1 time → below threshold, should NOT appear */
|
||
for (int i = 0; i < n; i++) {
|
||
ASSERT_FALSE(strcmp(results[i].file_a, "d.go") == 0 ||
|
||
strcmp(results[i].file_b, "d.go") == 0);
|
||
}
|
||
|
||
PASS();
|
||
}
|
||
|
||
TEST(githistory_coupling_carries_last_co_change) {
|
||
/* Coupling output must surface the most recent commit timestamp at which
|
||
* a pair co-changed, so callers can score recency in addition to
|
||
* frequency. The pair (a.go, b.go) co-changes at three timestamps; the
|
||
* resulting last_co_change must be the maximum (newest) of those. */
|
||
char *files_old[] = {"a.go", "b.go"};
|
||
char *files_mid[] = {"a.go", "b.go"};
|
||
char *files_new[] = {"a.go", "b.go"};
|
||
/* Unrelated co-change in the middle to make sure we don't accidentally
|
||
* pick up that pair's timestamp by index. */
|
||
char *files_other[] = {"c.go", "d.go"};
|
||
|
||
cbm_commit_files_t commits[] = {
|
||
{files_old, 2, 1700000000LL}, /* oldest a.go/b.go co-change */
|
||
{files_other, 2, 1750000000LL}, /* unrelated pair */
|
||
{files_mid, 2, 1720000000LL},
|
||
{files_new, 2, 1800000000LL}, /* newest a.go/b.go co-change */
|
||
};
|
||
|
||
cbm_change_coupling_t results[16];
|
||
int n = cbm_compute_change_coupling(commits, 4, results, 16);
|
||
ASSERT_GTE(n, 1);
|
||
|
||
bool found_ab = false;
|
||
for (int i = 0; i < n; i++) {
|
||
bool is_ab =
|
||
(strcmp(results[i].file_a, "a.go") == 0 && strcmp(results[i].file_b, "b.go") == 0) ||
|
||
(strcmp(results[i].file_a, "b.go") == 0 && strcmp(results[i].file_b, "a.go") == 0);
|
||
if (!is_ab) {
|
||
continue;
|
||
}
|
||
ASSERT_EQ(results[i].co_change_count, 3);
|
||
/* last_co_change must be the max of the three a.go/b.go timestamps. */
|
||
ASSERT_EQ(results[i].last_co_change, 1800000000LL);
|
||
found_ab = true;
|
||
}
|
||
ASSERT_TRUE(found_ab);
|
||
PASS();
|
||
}
|
||
|
||
TEST(githistory_skip_large_commits) {
|
||
/* A single commit with 25 files → should be skipped (>20) */
|
||
char *files[25];
|
||
char bufs[25][32];
|
||
for (int i = 0; i < 25; i++) {
|
||
snprintf(bufs[i], sizeof(bufs[i]), "file%d.go", i);
|
||
files[i] = bufs[i];
|
||
}
|
||
|
||
cbm_commit_files_t commits[] = {{files, 25, 0}};
|
||
|
||
cbm_change_coupling_t results[100];
|
||
int n = cbm_compute_change_coupling(commits, 1, results, 100);
|
||
ASSERT_EQ(n, 0);
|
||
|
||
PASS();
|
||
}
|
||
|
||
TEST(githistory_limits_to_max) {
|
||
/* Create many commits to generate >100 couplings */
|
||
/* 50 files, each pair has 3 commits → C(50,2)=1225 pairs, but only
|
||
* pairs with ≥3 co-changes and score≥0.3 pass the threshold */
|
||
int nfiles = 50;
|
||
int npairs = nfiles * (nfiles - 1) / 2;
|
||
int ncommits = npairs * 3;
|
||
|
||
cbm_commit_files_t *commits = calloc(ncommits, sizeof(cbm_commit_files_t));
|
||
char **file_strs = calloc(nfiles, sizeof(char *));
|
||
for (int i = 0; i < nfiles; i++) {
|
||
file_strs[i] = malloc(32);
|
||
snprintf(file_strs[i], 32, "f%d.go", i);
|
||
}
|
||
|
||
int ci = 0;
|
||
for (int i = 0; i < nfiles; i++) {
|
||
for (int j = i + 1; j < nfiles; j++) {
|
||
for (int k = 0; k < 3; k++) {
|
||
commits[ci].files = malloc(2 * sizeof(char *));
|
||
commits[ci].files[0] = file_strs[i];
|
||
commits[ci].files[1] = file_strs[j];
|
||
commits[ci].count = 2;
|
||
ci++;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* max_out = 100 → should cap at 100 */
|
||
cbm_change_coupling_t results[100];
|
||
int n = cbm_compute_change_coupling(commits, ncommits, results, 100);
|
||
ASSERT_TRUE(n <= 100);
|
||
|
||
/* Cleanup */
|
||
for (int i = 0; i < ncommits; i++) {
|
||
free(commits[i].files);
|
||
}
|
||
for (int i = 0; i < nfiles; i++) {
|
||
free(file_strs[i]);
|
||
}
|
||
free(file_strs);
|
||
free(commits);
|
||
|
||
PASS();
|
||
}
|
||
|
||
/* ── Test detection tests ──────────────────────────────────────── */
|
||
|
||
TEST(testdetect_is_test_file) {
|
||
/* Test file patterns (all languages) */
|
||
ASSERT_TRUE(cbm_is_test_path("foo_test.go"));
|
||
ASSERT_TRUE(cbm_is_test_path("test_handler.py"));
|
||
ASSERT_TRUE(cbm_is_test_path("handler.test.js"));
|
||
ASSERT_TRUE(cbm_is_test_path("handler.spec.ts"));
|
||
ASSERT_TRUE(cbm_is_test_path("Component.test.tsx"));
|
||
ASSERT_TRUE(cbm_is_test_path("OrderTest.java"));
|
||
ASSERT_TRUE(cbm_is_test_path("handler_test.rs"));
|
||
ASSERT_TRUE(cbm_is_test_path("handler_test.cpp"));
|
||
ASSERT_TRUE(cbm_is_test_path("OrderTest.cs"));
|
||
ASSERT_TRUE(cbm_is_test_path("OrderTest.php"));
|
||
ASSERT_TRUE(cbm_is_test_path("OrderSpec.scala"));
|
||
ASSERT_TRUE(cbm_is_test_path("OrderTest.kt"));
|
||
ASSERT_TRUE(cbm_is_test_path("handler_test.lua"));
|
||
|
||
/* Non-test file patterns */
|
||
ASSERT_FALSE(cbm_is_test_path("foo.go"));
|
||
ASSERT_FALSE(cbm_is_test_path("handler.py"));
|
||
ASSERT_FALSE(cbm_is_test_path("handler.js"));
|
||
ASSERT_FALSE(cbm_is_test_path("handler.ts"));
|
||
ASSERT_FALSE(cbm_is_test_path("Component.tsx"));
|
||
ASSERT_FALSE(cbm_is_test_path("Order.java"));
|
||
ASSERT_FALSE(cbm_is_test_path("handler.rs"));
|
||
ASSERT_FALSE(cbm_is_test_path("handler.cpp"));
|
||
ASSERT_FALSE(cbm_is_test_path("Order.cs"));
|
||
ASSERT_FALSE(cbm_is_test_path("Order.php"));
|
||
ASSERT_FALSE(cbm_is_test_path("Order.scala"));
|
||
ASSERT_FALSE(cbm_is_test_path("Order.kt"));
|
||
ASSERT_FALSE(cbm_is_test_path("handler.lua"));
|
||
|
||
PASS();
|
||
}
|
||
|
||
TEST(testdetect_is_test_function) {
|
||
/* Test function patterns */
|
||
ASSERT_TRUE(cbm_is_test_func_name("TestCreate")); /* Go */
|
||
ASSERT_TRUE(cbm_is_test_func_name("test_create")); /* Python/Rust/Lua */
|
||
ASSERT_TRUE(cbm_is_test_func_name("test")); /* JS/TS */
|
||
ASSERT_TRUE(cbm_is_test_func_name("describe")); /* JS/TS */
|
||
ASSERT_TRUE(cbm_is_test_func_name("it")); /* JS/TS */
|
||
ASSERT_TRUE(cbm_is_test_func_name("testCreate")); /* Java/PHP/Scala/Kotlin */
|
||
ASSERT_TRUE(cbm_is_test_func_name("TestCreate")); /* C++/C# */
|
||
|
||
/* Non-test function patterns */
|
||
ASSERT_FALSE(cbm_is_test_func_name("create"));
|
||
ASSERT_FALSE(cbm_is_test_func_name("handleRequest"));
|
||
ASSERT_FALSE(cbm_is_test_func_name("process"));
|
||
|
||
PASS();
|
||
}
|
||
|
||
/* ── Implements pass tests (graph buffer based) ────────────────── */
|
||
|
||
TEST(implements_creates_override) {
|
||
/* Port of TestPassImplementsCreatesOverrideEdges.
|
||
* Set up Interface+methods, Class+methods, verify IMPLEMENTS+OVERRIDE. */
|
||
cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
|
||
ASSERT_NOT_NULL(gb);
|
||
|
||
/* Interface "Reader" with methods "Read" and "Close" */
|
||
int64_t iface_id =
|
||
cbm_gbuf_upsert_node(gb, "Interface", "Reader", "pkg.Reader", "pkg/reader.go", 1, 5, "{}");
|
||
ASSERT_GT(iface_id, 0);
|
||
|
||
int64_t read_method_id =
|
||
cbm_gbuf_upsert_node(gb, "Method", "Read", "pkg.Reader.Read", "pkg/reader.go", 2, 2, "{}");
|
||
int64_t close_method_id = cbm_gbuf_upsert_node(gb, "Method", "Close", "pkg.Reader.Close",
|
||
"pkg/reader.go", 3, 3, "{}");
|
||
ASSERT_GT(read_method_id, 0);
|
||
ASSERT_GT(close_method_id, 0);
|
||
|
||
/* DEFINES_METHOD edges */
|
||
cbm_gbuf_insert_edge(gb, iface_id, read_method_id, "DEFINES_METHOD", "{}");
|
||
cbm_gbuf_insert_edge(gb, iface_id, close_method_id, "DEFINES_METHOD", "{}");
|
||
|
||
/* Class "FileReader" with matching methods */
|
||
int64_t class_id = cbm_gbuf_upsert_node(gb, "Class", "FileReader", "pkg.FileReader",
|
||
"pkg/filereader.go", 1, 10, "{}");
|
||
ASSERT_GT(class_id, 0);
|
||
|
||
int64_t fr_read_id =
|
||
cbm_gbuf_upsert_node(gb, "Method", "Read", "pkg.FileReader.Read", "pkg/filereader.go", 2, 4,
|
||
"{\"receiver\":\"(f *FileReader)\"}");
|
||
int64_t fr_close_id =
|
||
cbm_gbuf_upsert_node(gb, "Method", "Close", "pkg.FileReader.Close", "pkg/filereader.go", 5,
|
||
7, "{\"receiver\":\"(f *FileReader)\"}");
|
||
ASSERT_GT(fr_read_id, 0);
|
||
ASSERT_GT(fr_close_id, 0);
|
||
|
||
/* Run Go-style implements detection */
|
||
atomic_int cancelled = 0;
|
||
cbm_pipeline_ctx_t ctx = {
|
||
.project_name = "test-proj",
|
||
.repo_path = "/tmp/test",
|
||
.gbuf = gb,
|
||
.registry = NULL,
|
||
.cancelled = &cancelled,
|
||
};
|
||
int edges_created = cbm_pipeline_implements_go(&ctx);
|
||
ASSERT_GT(edges_created, 0);
|
||
|
||
/* Verify IMPLEMENTS edge: FileReader → Reader */
|
||
const cbm_gbuf_edge_t **impl_edges = NULL;
|
||
int impl_count = 0;
|
||
int rc =
|
||
cbm_gbuf_find_edges_by_source_type(gb, class_id, "IMPLEMENTS", &impl_edges, &impl_count);
|
||
ASSERT_EQ(rc, 0);
|
||
ASSERT_EQ(impl_count, 1);
|
||
ASSERT_EQ(impl_edges[0]->target_id, iface_id);
|
||
|
||
/* Verify OVERRIDE edges: FileReader.Read → Reader.Read */
|
||
const cbm_gbuf_edge_t **read_overrides = NULL;
|
||
int read_override_count = 0;
|
||
rc = cbm_gbuf_find_edges_by_source_type(gb, fr_read_id, "OVERRIDE", &read_overrides,
|
||
&read_override_count);
|
||
ASSERT_EQ(rc, 0);
|
||
ASSERT_EQ(read_override_count, 1);
|
||
ASSERT_EQ(read_overrides[0]->target_id, read_method_id);
|
||
|
||
/* Verify OVERRIDE edge: FileReader.Close → Reader.Close */
|
||
const cbm_gbuf_edge_t **close_overrides = NULL;
|
||
int close_override_count = 0;
|
||
rc = cbm_gbuf_find_edges_by_source_type(gb, fr_close_id, "OVERRIDE", &close_overrides,
|
||
&close_override_count);
|
||
ASSERT_EQ(rc, 0);
|
||
ASSERT_EQ(close_override_count, 1);
|
||
ASSERT_EQ(close_overrides[0]->target_id, close_method_id);
|
||
|
||
cbm_gbuf_free(gb);
|
||
PASS();
|
||
}
|
||
|
||
TEST(implements_no_match) {
|
||
/* Port of TestPassImplementsNoOverrideWithoutMatch.
|
||
* Interface requires Read+Write, struct only has Read → no edges. */
|
||
cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
|
||
ASSERT_NOT_NULL(gb);
|
||
|
||
/* Interface "ReadWriter" with methods "Read" and "Write" */
|
||
int64_t iface_id = cbm_gbuf_upsert_node(gb, "Interface", "ReadWriter", "pkg.ReadWriter",
|
||
"pkg/rw.go", 1, 5, "{}");
|
||
int64_t read_id =
|
||
cbm_gbuf_upsert_node(gb, "Method", "Read", "pkg.ReadWriter.Read", "pkg/rw.go", 2, 2, "{}");
|
||
int64_t write_id = cbm_gbuf_upsert_node(gb, "Method", "Write", "pkg.ReadWriter.Write",
|
||
"pkg/rw.go", 3, 3, "{}");
|
||
cbm_gbuf_insert_edge(gb, iface_id, read_id, "DEFINES_METHOD", "{}");
|
||
cbm_gbuf_insert_edge(gb, iface_id, write_id, "DEFINES_METHOD", "{}");
|
||
|
||
/* Struct "OnlyReader" with only "Read" (missing "Write") */
|
||
int64_t class_id = cbm_gbuf_upsert_node(gb, "Class", "OnlyReader", "pkg.OnlyReader",
|
||
"pkg/onlyreader.go", 1, 10, "{}");
|
||
int64_t or_read_id =
|
||
cbm_gbuf_upsert_node(gb, "Method", "Read", "pkg.OnlyReader.Read", "pkg/onlyreader.go", 2, 4,
|
||
"{\"receiver\":\"(o *OnlyReader)\"}");
|
||
|
||
/* Run Go-style implements detection */
|
||
atomic_int cancelled = 0;
|
||
cbm_pipeline_ctx_t ctx = {
|
||
.project_name = "test-proj",
|
||
.repo_path = "/tmp/test",
|
||
.gbuf = gb,
|
||
.registry = NULL,
|
||
.cancelled = &cancelled,
|
||
};
|
||
int edges_created = cbm_pipeline_implements_go(&ctx);
|
||
ASSERT_EQ(edges_created, 0);
|
||
|
||
/* Verify NO IMPLEMENTS edge */
|
||
const cbm_gbuf_edge_t **impl_edges = NULL;
|
||
int impl_count = 0;
|
||
cbm_gbuf_find_edges_by_source_type(gb, class_id, "IMPLEMENTS", &impl_edges, &impl_count);
|
||
ASSERT_EQ(impl_count, 0);
|
||
|
||
/* Verify NO OVERRIDE edge */
|
||
const cbm_gbuf_edge_t **override_edges = NULL;
|
||
int override_count = 0;
|
||
cbm_gbuf_find_edges_by_source_type(gb, or_read_id, "OVERRIDE", &override_edges,
|
||
&override_count);
|
||
ASSERT_EQ(override_count, 0);
|
||
|
||
/* Suppress unused warnings */
|
||
(void)iface_id;
|
||
(void)read_id;
|
||
(void)write_id;
|
||
|
||
cbm_gbuf_free(gb);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Usages pass tests (full pipeline integration) ──────────────── */
|
||
|
||
/* Helper to create a temp dir with a single source file */
|
||
static char g_usages_tmpdir[256];
|
||
|
||
static int setup_usages_repo(const char *filename, const char *content, const char *extra_file,
|
||
const char *extra_content) {
|
||
snprintf(g_usages_tmpdir, sizeof(g_usages_tmpdir), "/tmp/cbm_usage_XXXXXX");
|
||
if (!cbm_mkdtemp(g_usages_tmpdir))
|
||
return -1;
|
||
|
||
/* Check if filename has subdirectory */
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/%s", g_usages_tmpdir, filename);
|
||
|
||
/* Create subdirectories if needed */
|
||
char dir_path[512];
|
||
snprintf(dir_path, sizeof(dir_path), "%s", path);
|
||
char *last_slash = strrchr(dir_path, '/');
|
||
if (last_slash) {
|
||
*last_slash = '\0';
|
||
th_mkdir_p(dir_path);
|
||
}
|
||
|
||
FILE *f = fopen(path, "w");
|
||
if (!f)
|
||
return -1;
|
||
fprintf(f, "%s", content);
|
||
fclose(f);
|
||
|
||
if (extra_file && extra_content) {
|
||
snprintf(path, sizeof(path), "%s/%s", g_usages_tmpdir, extra_file);
|
||
f = fopen(path, "w");
|
||
if (!f)
|
||
return -1;
|
||
fprintf(f, "%s", extra_content);
|
||
fclose(f);
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
static void teardown_usages_repo(void) {
|
||
if (g_usages_tmpdir[0])
|
||
rm_rf(g_usages_tmpdir);
|
||
g_usages_tmpdir[0] = '\0';
|
||
}
|
||
|
||
TEST(usages_creates_edges) {
|
||
/* Port of TestPassUsagesCreatesEdges.
|
||
* Go source with callback reference → USAGE edge. */
|
||
const char *go_source = "package mypkg\n\n"
|
||
"func Process(data string) string {\n"
|
||
"\treturn data\n"
|
||
"}\n\n"
|
||
"func Register() {\n"
|
||
"\thandler := Process\n"
|
||
"\t_ = handler\n"
|
||
"}\n";
|
||
|
||
if (setup_usages_repo("mypkg/main.go", go_source, "go.mod", "module testmod\ngo 1.21\n") != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_usages.db", g_usages_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* Check for USAGE edges */
|
||
cbm_edge_t *edges = NULL;
|
||
int edge_count = 0;
|
||
rc = cbm_store_find_edges_by_type(s, project, "USAGE", &edges, &edge_count);
|
||
|
||
bool found_usage = false;
|
||
for (int i = 0; i < edge_count; i++) {
|
||
cbm_node_t src = {0}, tgt = {0};
|
||
if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK &&
|
||
cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK) {
|
||
if (strcmp(src.name, "Register") == 0 && strcmp(tgt.name, "Process") == 0) {
|
||
found_usage = true;
|
||
}
|
||
}
|
||
cbm_node_free_fields(&src);
|
||
cbm_node_free_fields(&tgt);
|
||
}
|
||
if (edges)
|
||
cbm_store_free_edges(edges, edge_count);
|
||
ASSERT_TRUE(found_usage);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_usages_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(usages_no_duplicate_calls) {
|
||
/* Port of TestPassUsagesDoesNotDuplicateCalls.
|
||
* A direct function call should produce CALLS but not USAGE. */
|
||
const char *go_source = "package mypkg\n\n"
|
||
"func Helper() string {\n"
|
||
"\treturn \"ok\"\n"
|
||
"}\n\n"
|
||
"func Main() {\n"
|
||
"\tHelper()\n"
|
||
"}\n";
|
||
|
||
if (setup_usages_repo("mypkg/main.go", go_source, "go.mod", "module testmod\ngo 1.21\n") != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_no_dup.db", g_usages_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* Should have CALLS edge from Main to Helper */
|
||
cbm_edge_t *call_edges = NULL;
|
||
int call_count = 0;
|
||
cbm_store_find_edges_by_type(s, project, "CALLS", &call_edges, &call_count);
|
||
|
||
bool found_call = false;
|
||
for (int i = 0; i < call_count; i++) {
|
||
cbm_node_t src = {0}, tgt = {0};
|
||
if (cbm_store_find_node_by_id(s, call_edges[i].source_id, &src) == CBM_STORE_OK &&
|
||
cbm_store_find_node_by_id(s, call_edges[i].target_id, &tgt) == CBM_STORE_OK) {
|
||
if (strcmp(src.name, "Main") == 0 && strcmp(tgt.name, "Helper") == 0) {
|
||
found_call = true;
|
||
}
|
||
}
|
||
cbm_node_free_fields(&src);
|
||
cbm_node_free_fields(&tgt);
|
||
}
|
||
if (call_edges)
|
||
cbm_store_free_edges(call_edges, call_count);
|
||
ASSERT_TRUE(found_call);
|
||
|
||
/* Should NOT have USAGE edge from Main to Helper */
|
||
cbm_edge_t *usage_edges = NULL;
|
||
int usage_count = 0;
|
||
cbm_store_find_edges_by_type(s, project, "USAGE", &usage_edges, &usage_count);
|
||
|
||
for (int i = 0; i < usage_count; i++) {
|
||
cbm_node_t src = {0}, tgt = {0};
|
||
if (cbm_store_find_node_by_id(s, usage_edges[i].source_id, &src) == CBM_STORE_OK &&
|
||
cbm_store_find_node_by_id(s, usage_edges[i].target_id, &tgt) == CBM_STORE_OK) {
|
||
ASSERT_FALSE(strcmp(src.name, "Main") == 0 && strcmp(tgt.name, "Helper") == 0);
|
||
}
|
||
cbm_node_free_fields(&src);
|
||
cbm_node_free_fields(&tgt);
|
||
}
|
||
if (usage_edges)
|
||
cbm_store_free_edges(usage_edges, usage_count);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_usages_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(calls_edge_carries_call_site_line) {
|
||
/* Regression guard for #503: a CALLS edge must persist the source line of
|
||
* the call site in its JSON props as "line":N. Helper() is invoked on
|
||
* line 8 (1-based) of the source below. */
|
||
const char *go_source = "package mypkg\n"
|
||
"\n"
|
||
"func Helper() string {\n"
|
||
"\treturn \"ok\"\n"
|
||
"}\n"
|
||
"\n"
|
||
"func Main() {\n"
|
||
"\tHelper()\n"
|
||
"}\n";
|
||
|
||
if (setup_usages_repo("mypkg/main.go", go_source, "go.mod", "module testmod\ngo 1.21\n") != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_call_line.db", g_usages_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
cbm_edge_t *call_edges = NULL;
|
||
int call_count = 0;
|
||
cbm_store_find_edges_by_type(s, project, "CALLS", &call_edges, &call_count);
|
||
|
||
bool found_line = false;
|
||
for (int i = 0; i < call_count; i++) {
|
||
cbm_node_t src = {0}, tgt = {0};
|
||
if (cbm_store_find_node_by_id(s, call_edges[i].source_id, &src) == CBM_STORE_OK &&
|
||
cbm_store_find_node_by_id(s, call_edges[i].target_id, &tgt) == CBM_STORE_OK) {
|
||
if (strcmp(src.name, "Main") == 0 && strcmp(tgt.name, "Helper") == 0) {
|
||
ASSERT_NOT_NULL(call_edges[i].properties_json);
|
||
ASSERT_TRUE(strstr(call_edges[i].properties_json, "\"line\":8}") != NULL);
|
||
found_line = true;
|
||
}
|
||
}
|
||
cbm_node_free_fields(&src);
|
||
cbm_node_free_fields(&tgt);
|
||
}
|
||
if (call_edges)
|
||
cbm_store_free_edges(call_edges, call_count);
|
||
ASSERT_TRUE(found_line);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_usages_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(usages_kotlin_creates_edges) {
|
||
/* Port of TestPassUsagesKotlinCreatesEdges.
|
||
* Kotlin source with callback reference → USAGE edge. */
|
||
const char *kt_source = "fun process(data: String): String {\n"
|
||
" return data\n"
|
||
"}\n\n"
|
||
"fun register() {\n"
|
||
" val handler = ::process\n"
|
||
"}\n";
|
||
|
||
if (setup_usages_repo("Main.kt", kt_source, NULL, NULL) != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_kt_usage.db", g_usages_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* Check USAGE edges exist (Kotlin extraction may or may not produce them
|
||
* depending on extraction support — just verify no crash and valid counts) */
|
||
cbm_edge_t *edges = NULL;
|
||
int edge_count = 0;
|
||
rc = cbm_store_find_edges_by_type(s, project, "USAGE", &edges, &edge_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
/* Note: The Go test just logs, doesn't assert — same behavior here */
|
||
if (edges)
|
||
cbm_store_free_edges(edges, edge_count);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_usages_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(usages_kotlin_no_duplicate_calls) {
|
||
/* Port of TestPassUsagesKotlinDoesNotDuplicateCalls.
|
||
* Direct call in Kotlin → CALLS but not USAGE. */
|
||
const char *kt_source = "fun helper(): String {\n"
|
||
" return \"ok\"\n"
|
||
"}\n\n"
|
||
"fun main() {\n"
|
||
" helper()\n"
|
||
"}\n";
|
||
|
||
if (setup_usages_repo("Main.kt", kt_source, NULL, NULL) != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/test_kt_nodup.db", g_usages_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
/* Should have CALLS edge from main to helper */
|
||
cbm_edge_t *call_edges = NULL;
|
||
int call_count = 0;
|
||
cbm_store_find_edges_by_type(s, project, "CALLS", &call_edges, &call_count);
|
||
|
||
bool found_call = false;
|
||
for (int i = 0; i < call_count; i++) {
|
||
cbm_node_t src = {0}, tgt = {0};
|
||
if (cbm_store_find_node_by_id(s, call_edges[i].source_id, &src) == CBM_STORE_OK &&
|
||
cbm_store_find_node_by_id(s, call_edges[i].target_id, &tgt) == CBM_STORE_OK) {
|
||
if (strcmp(src.name, "main") == 0 && strcmp(tgt.name, "helper") == 0) {
|
||
found_call = true;
|
||
}
|
||
}
|
||
cbm_node_free_fields(&src);
|
||
cbm_node_free_fields(&tgt);
|
||
}
|
||
if (call_edges)
|
||
cbm_store_free_edges(call_edges, call_count);
|
||
ASSERT_TRUE(found_call);
|
||
|
||
/* Should NOT have USAGE edge from main to helper */
|
||
cbm_edge_t *usage_edges = NULL;
|
||
int usage_count = 0;
|
||
cbm_store_find_edges_by_type(s, project, "USAGE", &usage_edges, &usage_count);
|
||
|
||
for (int i = 0; i < usage_count; i++) {
|
||
cbm_node_t src = {0}, tgt = {0};
|
||
if (cbm_store_find_node_by_id(s, usage_edges[i].source_id, &src) == CBM_STORE_OK &&
|
||
cbm_store_find_node_by_id(s, usage_edges[i].target_id, &tgt) == CBM_STORE_OK) {
|
||
ASSERT_FALSE(strcmp(src.name, "main") == 0 && strcmp(tgt.name, "helper") == 0);
|
||
}
|
||
cbm_node_free_fields(&src);
|
||
cbm_node_free_fields(&tgt);
|
||
}
|
||
if (usage_edges)
|
||
cbm_store_free_edges(usage_edges, usage_count);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_usages_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* ── Pipeline language integration tests ────────────────────────── */
|
||
|
||
/* General helper: create temp dir and write multiple files */
|
||
static char g_lang_tmpdir[256];
|
||
|
||
static int setup_lang_repo(const char **filenames, const char **contents, int count) {
|
||
snprintf(g_lang_tmpdir, sizeof(g_lang_tmpdir), "/tmp/cbm_lang_XXXXXX");
|
||
if (!cbm_mkdtemp(g_lang_tmpdir))
|
||
return -1;
|
||
|
||
for (int i = 0; i < count; i++) {
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/%s", g_lang_tmpdir, filenames[i]);
|
||
|
||
/* Create parent directories */
|
||
char dir[512];
|
||
snprintf(dir, sizeof(dir), "%s", path);
|
||
char *slash = strrchr(dir, '/');
|
||
if (slash) {
|
||
*slash = '\0';
|
||
th_mkdir_p(dir);
|
||
}
|
||
|
||
FILE *f = fopen(path, "wb");
|
||
if (!f)
|
||
return -1;
|
||
fprintf(f, "%s", contents[i]);
|
||
fclose(f);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static void teardown_lang_repo(void) {
|
||
if (g_lang_tmpdir[0])
|
||
rm_rf(g_lang_tmpdir);
|
||
g_lang_tmpdir[0] = '\0';
|
||
}
|
||
|
||
TEST(pipeline_python_project) {
|
||
/* Port of TestPipelinePythonProject */
|
||
const char *files[] = {"main.py", "utils.py"};
|
||
const char *contents[] = {
|
||
"def greet(name):\n return f\"Hello, {name}\"\n\n"
|
||
"def process():\n result = greet(\"world\")\n return result\n",
|
||
|
||
"API_URL = \"https://example.com/api\"\nMAX_RETRIES = 3\n\n"
|
||
"def fetch_data(url):\n pass\n\n"
|
||
"class DataProcessor:\n def transform(self, data):\n return data\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 2) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *funcs = NULL;
|
||
int fc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Function", &funcs, &fc);
|
||
ASSERT_GTE(fc, 3); /* greet, process, fetch_data */
|
||
cbm_store_free_nodes(funcs, fc);
|
||
|
||
cbm_node_t *cls = NULL;
|
||
int cc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Class", &cls, &cc);
|
||
ASSERT_GTE(cc, 1); /* DataProcessor */
|
||
cbm_store_free_nodes(cls, cc);
|
||
|
||
cbm_node_t *methods = NULL;
|
||
int mc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Method", &methods, &mc);
|
||
ASSERT_GTE(mc, 1); /* transform */
|
||
cbm_store_free_nodes(methods, mc);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* #768 end-to-end: `import { A, B } from './lib'` must survive the REAL
|
||
* pipeline (extract -> gbuf dedup -> raw SQLite dump) as TWO IMPORTS edges
|
||
* with distinct local_name — and the dumped DB must satisfy its own schema.
|
||
* With only the graph-buffer half of the fix, both edges reach the dump but
|
||
* violate an unwidened UNIQUE(source_id,target_id,type), which PRAGMA
|
||
* integrity_check flags as a non-unique autoindex entry. */
|
||
TEST(pipeline_imports_multi_symbol_edges) {
|
||
const char *files[] = {"consumer.ts", "lib.ts"};
|
||
const char *contents[] = {
|
||
"import { A, B } from './lib';\n\nexport function useBoth() {\n return A() + B();\n}\n",
|
||
"export function A() {\n return 1;\n}\nexport function B() {\n return 2;\n}\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 2) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
/* The dumped DB must pass SQLite's own full integrity check — this is
|
||
* what catches a buffer-only fix shipping DBs that violate their own
|
||
* UNIQUE constraint. */
|
||
sqlite3 *raw = NULL;
|
||
ASSERT_EQ(sqlite3_open(db, &raw), SQLITE_OK);
|
||
sqlite3_stmt *stmt = NULL;
|
||
sqlite3_prepare_v2(raw, "PRAGMA integrity_check", -1, &stmt, NULL);
|
||
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
|
||
const char *integrity = (const char *)sqlite3_column_text(stmt, 0);
|
||
ASSERT_STR_EQ(integrity, "ok");
|
||
sqlite3_finalize(stmt);
|
||
sqlite3_close(raw);
|
||
|
||
/* Both named imports must be queryable as separate IMPORTS edges. */
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_edge_t *edges = NULL;
|
||
int count = 0;
|
||
ASSERT_EQ(cbm_store_find_edges_by_type(s, proj, "IMPORTS", &edges, &count), CBM_STORE_OK);
|
||
ASSERT_EQ(count, 2);
|
||
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"A\"") != NULL ||
|
||
strstr(edges[1].properties_json, "\"local_name\":\"A\"") != NULL);
|
||
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"B\"") != NULL ||
|
||
strstr(edges[1].properties_json, "\"local_name\":\"B\"") != NULL);
|
||
cbm_store_free_edges(edges, count);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_go_cross_package_call) {
|
||
/* Port of TestGoCrossPackageCallViaImport */
|
||
const char *files[] = {"main.go", "svc/handler.go"};
|
||
const char *contents[] = {
|
||
"package main\n\nimport \"example.com/myapp/svc\"\n\n"
|
||
"func run() {\n\tsvc.ProcessOrder(\"123\")\n}\n",
|
||
|
||
"package svc\n\nfunc ProcessOrder(id string) error {\n\treturn nil\n}\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 2) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
/* Verify ProcessOrder exists */
|
||
cbm_node_t *targets = NULL;
|
||
int tc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "ProcessOrder", &targets, &tc);
|
||
ASSERT_GT(tc, 0);
|
||
|
||
/* Verify run() exists */
|
||
cbm_node_t *callers = NULL;
|
||
int clc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "run", &callers, &clc);
|
||
ASSERT_GT(clc, 0);
|
||
|
||
/* Check CALLS edge from run to ProcessOrder */
|
||
cbm_edge_t *edges = NULL;
|
||
int ec = 0;
|
||
cbm_store_find_edges_by_source_type(s, callers[0].id, "CALLS", &edges, &ec);
|
||
bool found = false;
|
||
for (int i = 0; i < ec; i++) {
|
||
if (edges[i].target_id == targets[0].id)
|
||
found = true;
|
||
}
|
||
ASSERT_TRUE(found);
|
||
|
||
if (edges)
|
||
cbm_store_free_edges(edges, ec);
|
||
cbm_store_free_nodes(targets, tc);
|
||
cbm_store_free_nodes(callers, clc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_python_cross_module_call) {
|
||
/* Port of TestPythonCrossModuleCallViaImport */
|
||
const char *files[] = {"utils.py", "main.py"};
|
||
const char *contents[] = {
|
||
"def fetch_data(url):\n return {\"status\": \"ok\"}\n",
|
||
|
||
"from utils import fetch_data\n\n"
|
||
"def process():\n result = fetch_data(\"https://example.com\")\n return result\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 2) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *targets = NULL;
|
||
int tc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "fetch_data", &targets, &tc);
|
||
ASSERT_GT(tc, 0);
|
||
|
||
cbm_node_t *callers = NULL;
|
||
int clc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "process", &callers, &clc);
|
||
ASSERT_GT(clc, 0);
|
||
|
||
cbm_edge_t *edges = NULL;
|
||
int ec = 0;
|
||
cbm_store_find_edges_by_source_type(s, callers[0].id, "CALLS", &edges, &ec);
|
||
bool found = false;
|
||
for (int i = 0; i < ec; i++) {
|
||
if (edges[i].target_id == targets[0].id)
|
||
found = true;
|
||
}
|
||
ASSERT_TRUE(found);
|
||
|
||
if (edges)
|
||
cbm_store_free_edges(edges, ec);
|
||
cbm_store_free_nodes(targets, tc);
|
||
cbm_store_free_nodes(callers, clc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_go_type_classification) {
|
||
/* Port of TestGoTypeClassification */
|
||
const char *files[] = {"types.go"};
|
||
const char *contents[] = {"package types\n\n"
|
||
"type Reader interface {\n\tRead(p []byte) (n int, err error)\n}\n\n"
|
||
"type Writer interface {\n\tWrite(p []byte) (n int, err error)\n}\n\n"
|
||
"type Config struct {\n\tHost string\n\tPort int\n}\n\n"
|
||
"type ID = string\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
/* Should have 2 Interface nodes (Reader, Writer) */
|
||
cbm_node_t *ifaces = NULL;
|
||
int ic = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Interface", &ifaces, &ic);
|
||
ASSERT_EQ(ic, 2);
|
||
cbm_store_free_nodes(ifaces, ic);
|
||
|
||
/* Should have 1 Struct node (Config struct) */
|
||
cbm_node_t *cls = NULL;
|
||
int cc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Struct", &cls, &cc);
|
||
ASSERT_EQ(cc, 1);
|
||
ASSERT_STR_EQ(cls[0].name, "Config");
|
||
cbm_store_free_nodes(cls, cc);
|
||
|
||
/* Should have 1 Type node (ID alias) */
|
||
cbm_node_t *types = NULL;
|
||
int tc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Type", &types, &tc);
|
||
ASSERT_EQ(tc, 1);
|
||
ASSERT_STR_EQ(types[0].name, "ID");
|
||
cbm_store_free_nodes(types, tc);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_go_grouped_types) {
|
||
/* Port of TestGoGroupedTypeDeclaration */
|
||
const char *files[] = {"models.go"};
|
||
const char *contents[] = {"package models\n\n"
|
||
"type (\n"
|
||
"\tRequest struct {\n\t\tURL string\n\t}\n\n"
|
||
"\tResponse struct {\n\t\tStatus int\n\t}\n\n"
|
||
"\tHandler interface {\n\t\tHandle(req Request) Response\n\t}\n"
|
||
")\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *cls = NULL;
|
||
int cc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Struct", &cls, &cc);
|
||
ASSERT_EQ(cc, 2); /* Request, Response */
|
||
cbm_store_free_nodes(cls, cc);
|
||
|
||
cbm_node_t *ifaces = NULL;
|
||
int ic = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Interface", &ifaces, &ic);
|
||
ASSERT_EQ(ic, 1); /* Handler */
|
||
cbm_store_free_nodes(ifaces, ic);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_kotlin_project) {
|
||
/* Port of TestPipelineKotlinProject */
|
||
const char *files[] = {"Main.kt", "Service.kt"};
|
||
const char *contents[] = {
|
||
"fun greet(name: String): String {\n return \"Hello, $name\"\n}\n\n"
|
||
"fun main() {\n val result = greet(\"world\")\n println(result)\n}\n",
|
||
|
||
"class OrderService {\n"
|
||
" fun processOrder(id: String): Boolean {\n return true\n }\n\n"
|
||
" fun submitOrder(order: String): Boolean {\n"
|
||
" return processOrder(order)\n }\n}\n\n"
|
||
"object Config {\n val API_URL = \"https://example.com/api\"\n}\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 2) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *funcs = NULL;
|
||
int fc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Function", &funcs, &fc);
|
||
ASSERT_GTE(fc, 2); /* greet, main */
|
||
cbm_store_free_nodes(funcs, fc);
|
||
|
||
cbm_node_t *cls = NULL;
|
||
int cc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Class", &cls, &cc);
|
||
ASSERT_GTE(cc, 1); /* OrderService */
|
||
cbm_store_free_nodes(cls, cc);
|
||
|
||
cbm_node_t *methods = NULL;
|
||
int mc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Method", &methods, &mc);
|
||
ASSERT_GTE(mc, 2); /* processOrder, submitOrder */
|
||
cbm_store_free_nodes(methods, mc);
|
||
|
||
int edge_count = cbm_store_count_edges(s, proj);
|
||
ASSERT_GT(edge_count, 0);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_lua_anonymous_functions) {
|
||
/* Port of TestLuaAnonymousFunctionExtraction */
|
||
const char *files[] = {"app.lua"};
|
||
const char *contents[] = {"local run_before_filter\n"
|
||
"run_before_filter = function(filter, r)\n"
|
||
" return filter(r)\n"
|
||
"end\n\n"
|
||
"local validate = function(data)\n"
|
||
" return data ~= nil\n"
|
||
"end\n\n"
|
||
"function named_func(x)\n"
|
||
" return x + 1\n"
|
||
"end\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *funcs = NULL;
|
||
int fc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Function", &funcs, &fc);
|
||
ASSERT_GTE(fc, 3); /* run_before_filter, validate, named_func */
|
||
|
||
/* Verify specific functions exist */
|
||
const char *expected[] = {"run_before_filter", "validate", "named_func"};
|
||
for (int e = 0; e < 3; e++) {
|
||
cbm_node_t *found = NULL;
|
||
int fnc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, expected[e], &found, &fnc);
|
||
ASSERT_GT(fnc, 0);
|
||
cbm_store_free_nodes(found, fnc);
|
||
}
|
||
|
||
cbm_store_free_nodes(funcs, fc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_csharp_modern) {
|
||
/* Port of TestCSharpModernFeatures */
|
||
const char *files[] = {"Controller.cs", "Model.cs"};
|
||
const char *contents[] = {"namespace Conduit.Features;\n\n"
|
||
"public class UsersController {\n"
|
||
"\tpublic void Get() {}\n"
|
||
"\tpublic void Create(string name) {}\n"
|
||
"}\n",
|
||
|
||
"namespace Conduit.Models {\n"
|
||
"\tclass User {\n"
|
||
"\t\tpublic string Name { get; set; }\n"
|
||
"\t\tpublic int GetAge() { return 0; }\n"
|
||
"\t}\n"
|
||
"}\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 2) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *modules = NULL;
|
||
int modc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Module", &modules, &modc);
|
||
ASSERT_GTE(modc, 2);
|
||
cbm_store_free_nodes(modules, modc);
|
||
|
||
cbm_node_t *cls = NULL;
|
||
int cc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Class", &cls, &cc);
|
||
ASSERT_GTE(cc, 2); /* UsersController, User */
|
||
cbm_store_free_nodes(cls, cc);
|
||
|
||
cbm_node_t *methods = NULL;
|
||
int mc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Method", &methods, &mc);
|
||
ASSERT_GTE(mc, 3); /* Get, Create, GetAge */
|
||
cbm_store_free_nodes(methods, mc);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_bom_stripping) {
|
||
/* Port of TestBOMStripping — UTF-8 BOM prefix should be handled */
|
||
snprintf(g_lang_tmpdir, sizeof(g_lang_tmpdir), "/tmp/cbm_bom_XXXXXX");
|
||
if (!cbm_mkdtemp(g_lang_tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/bom.go", g_lang_tmpdir);
|
||
FILE *f = fopen(path, "wb");
|
||
ASSERT_NOT_NULL(f);
|
||
/* Write UTF-8 BOM (0xEF 0xBB 0xBF) followed by Go source */
|
||
unsigned char bom[] = {0xEF, 0xBB, 0xBF};
|
||
fwrite(bom, 1, 3, f);
|
||
fprintf(f, "package main\n\nfunc BOMFunc() {}\n");
|
||
fclose(f);
|
||
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *found = NULL;
|
||
int fc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "BOMFunc", &found, &fc);
|
||
ASSERT_GT(fc, 0); /* BOMFunc should be found despite BOM */
|
||
cbm_store_free_nodes(found, fc);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_form_call_resolution) {
|
||
/* Port of TestFORMProcedureCallResolution */
|
||
const char *files[] = {"calc.frm"};
|
||
const char *contents[] = {"#procedure callee(x)\n"
|
||
" id x = 0;\n"
|
||
"#endprocedure\n"
|
||
"#procedure caller()\n"
|
||
" #call callee(1)\n"
|
||
"#endprocedure\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
/* Verify CALLS edge exists to callee */
|
||
cbm_edge_t *edges = NULL;
|
||
int ec = 0;
|
||
cbm_store_find_edges_by_type(s, proj, "CALLS", &edges, &ec);
|
||
bool found = false;
|
||
for (int i = 0; i < ec; i++) {
|
||
cbm_node_t tgt = {0};
|
||
if (cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK &&
|
||
strcmp(tgt.name, "callee") == 0) {
|
||
found = true;
|
||
}
|
||
cbm_node_free_fields(&tgt);
|
||
}
|
||
ASSERT_TRUE(found);
|
||
if (edges)
|
||
cbm_store_free_edges(edges, ec);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_python_type_inference) {
|
||
/* Port of TestPythonMethodDispatchViaTypeInference
|
||
* p = DataProcessor() then p.transform() → CALLS DataProcessor.transform */
|
||
const char *files[] = {"processor.py", "main.py"};
|
||
const char *contents[] = {"class DataProcessor:\n"
|
||
" def transform(self, data):\n"
|
||
" return data.upper()\n"
|
||
"\n"
|
||
" def validate(self, data):\n"
|
||
" return len(data) > 0\n",
|
||
|
||
"from processor import DataProcessor\n"
|
||
"\n"
|
||
"def run():\n"
|
||
" p = DataProcessor()\n"
|
||
" result = p.transform(\"hello\")\n"
|
||
" return result\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 2) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
/* Verify DataProcessor.transform exists as a Method */
|
||
cbm_node_t *methods = NULL;
|
||
int mc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "transform", &methods, &mc);
|
||
ASSERT_GT(mc, 0);
|
||
|
||
/* Verify run() exists */
|
||
cbm_node_t *callers = NULL;
|
||
int clc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "run", &callers, &clc);
|
||
ASSERT_GT(clc, 0);
|
||
|
||
/* Check CALLS edge from run() to DataProcessor.transform */
|
||
cbm_edge_t *edges = NULL;
|
||
int ec = 0;
|
||
cbm_store_find_edges_by_source_type(s, callers[0].id, "CALLS", &edges, &ec);
|
||
bool found = false;
|
||
for (int i = 0; i < ec; i++) {
|
||
if (edges[i].target_id == methods[0].id)
|
||
found = true;
|
||
}
|
||
/* Type inference may or may not resolve — log but don't fail hard */
|
||
if (!found) {
|
||
/* At minimum, verify the method exists and run() has some calls */
|
||
}
|
||
if (edges)
|
||
cbm_store_free_edges(edges, ec);
|
||
|
||
cbm_store_free_nodes(methods, mc);
|
||
cbm_store_free_nodes(callers, clc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
* Docstring integration (port of TestDocstringIntegration)
|
||
* ═══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(pipeline_docstring_go_function) {
|
||
/* Go function with // comment docstring */
|
||
const char *files[] = {"main.go"};
|
||
const char *contents[] = {"package main\n\n"
|
||
"// Compute does something.\n"
|
||
"func Compute() {}\n"};
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *nodes = NULL;
|
||
int nc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "Compute", &nodes, &nc);
|
||
ASSERT_GT(nc, 0);
|
||
|
||
/* Check properties_json contains docstring */
|
||
bool found_docstring = false;
|
||
for (int i = 0; i < nc; i++) {
|
||
if (strcmp(nodes[i].label, "Function") == 0 && nodes[i].properties_json &&
|
||
strstr(nodes[i].properties_json, "docstring") &&
|
||
strstr(nodes[i].properties_json, "Compute does something")) {
|
||
found_docstring = true;
|
||
}
|
||
}
|
||
ASSERT_TRUE(found_docstring);
|
||
|
||
cbm_store_free_nodes(nodes, nc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_docstring_python_function) {
|
||
/* Python function with triple-quoted docstring */
|
||
const char *files[] = {"main.py"};
|
||
const char *contents[] = {"def compute():\n"
|
||
"\t\"\"\"Does something.\"\"\"\n"
|
||
"\tpass\n"};
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *nodes = NULL;
|
||
int nc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "compute", &nodes, &nc);
|
||
ASSERT_GT(nc, 0);
|
||
|
||
bool found_docstring = false;
|
||
for (int i = 0; i < nc; i++) {
|
||
if (strcmp(nodes[i].label, "Function") == 0 && nodes[i].properties_json &&
|
||
strstr(nodes[i].properties_json, "docstring") &&
|
||
strstr(nodes[i].properties_json, "Does something")) {
|
||
found_docstring = true;
|
||
}
|
||
}
|
||
ASSERT_TRUE(found_docstring);
|
||
|
||
cbm_store_free_nodes(nodes, nc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_docstring_java_method) {
|
||
/* Java method with Javadoc comment */
|
||
const char *files[] = {"A.java"};
|
||
const char *contents[] = {"class A {\n"
|
||
"\t/** Computes result. */\n"
|
||
"\tvoid compute() {}\n"
|
||
"}\n"};
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *nodes = NULL;
|
||
int nc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "compute", &nodes, &nc);
|
||
ASSERT_GT(nc, 0);
|
||
|
||
bool found_docstring = false;
|
||
for (int i = 0; i < nc; i++) {
|
||
if (strcmp(nodes[i].label, "Method") == 0 && nodes[i].properties_json &&
|
||
strstr(nodes[i].properties_json, "docstring") &&
|
||
strstr(nodes[i].properties_json, "Computes result")) {
|
||
found_docstring = true;
|
||
}
|
||
}
|
||
ASSERT_TRUE(found_docstring);
|
||
|
||
cbm_store_free_nodes(nodes, nc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_docstring_kotlin_function) {
|
||
/* Kotlin function with KDoc comment */
|
||
const char *files[] = {"main.kt"};
|
||
const char *contents[] = {"/** Computes result. */\n"
|
||
"fun compute() {}\n"};
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *nodes = NULL;
|
||
int nc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "compute", &nodes, &nc);
|
||
ASSERT_GT(nc, 0);
|
||
|
||
bool found_docstring = false;
|
||
for (int i = 0; i < nc; i++) {
|
||
if (strcmp(nodes[i].label, "Function") == 0 && nodes[i].properties_json &&
|
||
strstr(nodes[i].properties_json, "docstring") &&
|
||
strstr(nodes[i].properties_json, "Computes result")) {
|
||
found_docstring = true;
|
||
}
|
||
}
|
||
ASSERT_TRUE(found_docstring);
|
||
|
||
cbm_store_free_nodes(nodes, nc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_docstring_go_class) {
|
||
/* Go struct with // comment docstring */
|
||
const char *files[] = {"main.go"};
|
||
const char *contents[] = {"package main\n\n"
|
||
"// MyStruct is documented.\n"
|
||
"type MyStruct struct{}\n"};
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *nodes = NULL;
|
||
int nc = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "MyStruct", &nodes, &nc);
|
||
ASSERT_GT(nc, 0);
|
||
|
||
bool found_docstring = false;
|
||
for (int i = 0; i < nc; i++) {
|
||
if (strcmp(nodes[i].label, "Struct") == 0 && nodes[i].properties_json &&
|
||
strstr(nodes[i].properties_json, "docstring") &&
|
||
strstr(nodes[i].properties_json, "MyStruct is documented")) {
|
||
found_docstring = true;
|
||
}
|
||
}
|
||
ASSERT_TRUE(found_docstring);
|
||
|
||
cbm_store_free_nodes(nodes, nc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(project_name_from_path) {
|
||
/* Port of TestProjectNameFromPath — more cases than integ_pipeline_project_name */
|
||
struct {
|
||
const char *path;
|
||
const char *want;
|
||
} cases[] = {
|
||
{"/tmp/bench/erlang/lib/stdlib/src", "tmp-bench-erlang-lib-stdlib-src"},
|
||
{"/Users/martin/projects/myapp", "Users-martin-projects-myapp"},
|
||
{"/home/user/repo", "home-user-repo"},
|
||
{"/single", "single"},
|
||
};
|
||
|
||
for (int i = 0; i < 4; i++) {
|
||
char *got = cbm_project_name_from_path(cases[i].path);
|
||
ASSERT_NOT_NULL(got);
|
||
ASSERT_STR_EQ(got, cases[i].want);
|
||
free(got);
|
||
}
|
||
PASS();
|
||
}
|
||
|
||
/* Regression for #394/#227/#367: a Windows drive letter is case-insensitive, so
|
||
* "c:/repo" and "C:/repo" must canonicalize to the SAME project key. Otherwise
|
||
* agent CWDs (which report lowercase "c:\...") produce a distinct key + colliding
|
||
* cache file that clobbers the good index, and the lowercase index self-deletes.
|
||
* Pure string logic, so it reproduces on any platform. */
|
||
TEST(project_name_drive_letter_case_insensitive_issue394) {
|
||
char *lower = cbm_project_name_from_path("c:/WEBDEV/Cardio-Cloud");
|
||
char *upper = cbm_project_name_from_path("C:/WEBDEV/Cardio-Cloud");
|
||
ASSERT_NOT_NULL(lower);
|
||
ASSERT_NOT_NULL(upper);
|
||
/* Both must fold to the upper-case-drive key, e.g. "C-WEBDEV-Cardio-Cloud". */
|
||
ASSERT_STR_EQ(lower, upper);
|
||
ASSERT_EQ(lower[0], 'C');
|
||
free(lower);
|
||
free(upper);
|
||
|
||
/* And the normalizer itself upper-cases the drive root in place. */
|
||
char buf1[32];
|
||
snprintf(buf1, sizeof(buf1), "%s", "c:/x");
|
||
cbm_normalize_path_sep(buf1);
|
||
ASSERT_STR_EQ(buf1, "C:/x");
|
||
char buf2[32];
|
||
snprintf(buf2, sizeof(buf2), "%s", "d:\\proj\\sub");
|
||
cbm_normalize_path_sep(buf2);
|
||
ASSERT_STR_EQ(buf2, "D:/proj/sub");
|
||
PASS();
|
||
}
|
||
|
||
static const char *test_null_dev(void) {
|
||
#ifdef _WIN32
|
||
return "NUL";
|
||
#else
|
||
return "/dev/null";
|
||
#endif
|
||
}
|
||
|
||
static bool git_available(void) {
|
||
char cmd[128];
|
||
snprintf(cmd, sizeof(cmd), "git --version >%s 2>&1", test_null_dev());
|
||
int rc = system(cmd);
|
||
return rc == 0;
|
||
}
|
||
|
||
static int run_cmd(const char *cmd) {
|
||
return system(cmd);
|
||
}
|
||
|
||
TEST(git_context_non_git_path) {
|
||
char *tmp = th_mktempdir("cbm_gitctx_nongit");
|
||
ASSERT_NOT_NULL(tmp);
|
||
|
||
cbm_git_context_t ctx = {0};
|
||
ASSERT_EQ(cbm_git_context_resolve(tmp, &ctx), 0);
|
||
ASSERT_FALSE(ctx.is_git);
|
||
ASSERT_TRUE(ctx.root_exists);
|
||
|
||
char *qn = cbm_git_context_branch_qn("proj", &ctx);
|
||
ASSERT_NOT_NULL(qn);
|
||
ASSERT_STR_EQ(qn, "proj.__branch__.working-tree");
|
||
free(qn);
|
||
|
||
char json[1024];
|
||
ASSERT_GT(cbm_git_context_props_json(&ctx, json, sizeof(json)), 0);
|
||
ASSERT_NOT_NULL(strstr(json, "\"is_git\":false"));
|
||
ASSERT_NOT_NULL(strstr(json, "\"root_exists\":true"));
|
||
|
||
char long_value[1200];
|
||
memset(long_value, 'a', sizeof(long_value) - 1);
|
||
long_value[sizeof(long_value) - 1] = '\0';
|
||
cbm_git_context_t long_ctx = {
|
||
.root_exists = true,
|
||
.canonical_root = long_value,
|
||
};
|
||
char small_json[64];
|
||
ASSERT_EQ(cbm_git_context_props_json(&long_ctx, small_json, sizeof(small_json)), 0);
|
||
|
||
cbm_git_context_free(&ctx);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
TEST(git_context_linked_worktree) {
|
||
if (!git_available()) {
|
||
FAIL("git unavailable");
|
||
}
|
||
|
||
char *tmp = th_mktempdir("cbm_gitctx_repo");
|
||
ASSERT_NOT_NULL(tmp);
|
||
|
||
char repo[512], wt[512], cmd[2048];
|
||
int n = snprintf(repo, sizeof(repo), "%s/repo with space", tmp);
|
||
ASSERT_TRUE(n > 0 && n < (int)sizeof(repo));
|
||
n = snprintf(wt, sizeof(wt), "%s/wt with space", tmp);
|
||
ASSERT_TRUE(n > 0 && n < (int)sizeof(wt));
|
||
ASSERT_EQ(th_mkdir_p(repo), 0);
|
||
|
||
const char *null_dev = test_null_dev();
|
||
snprintf(cmd, sizeof(cmd), "git -C \"%s\" init >%s 2>&1", repo, null_dev);
|
||
ASSERT_EQ(run_cmd(cmd), 0);
|
||
snprintf(cmd, sizeof(cmd), "git -C \"%s\" checkout -b main >%s 2>&1", repo, null_dev);
|
||
ASSERT_EQ(run_cmd(cmd), 0);
|
||
ASSERT_EQ(th_write_file(TH_PATH(repo, "file.txt"), "hello\n"), 0);
|
||
snprintf(cmd, sizeof(cmd), "git -C \"%s\" add file.txt >%s 2>&1", repo, null_dev);
|
||
ASSERT_EQ(run_cmd(cmd), 0);
|
||
snprintf(cmd, sizeof(cmd),
|
||
"git -C \"%s\" -c user.name=\"CBM Test\" -c user.email=\"cbm@example.invalid\" "
|
||
"commit -m \"initial\" >%s 2>&1",
|
||
repo, null_dev);
|
||
ASSERT_EQ(run_cmd(cmd), 0);
|
||
snprintf(cmd, sizeof(cmd), "git -C \"%s\" worktree add -b feature/git-context \"%s\" >%s 2>&1",
|
||
repo, wt, null_dev);
|
||
ASSERT_EQ(run_cmd(cmd), 0);
|
||
|
||
cbm_git_context_t main_ctx = {0};
|
||
cbm_git_context_t wt_ctx = {0};
|
||
ASSERT_EQ(cbm_git_context_resolve(repo, &main_ctx), 0);
|
||
ASSERT_EQ(cbm_git_context_resolve(wt, &wt_ctx), 0);
|
||
|
||
ASSERT_TRUE(main_ctx.is_git);
|
||
ASSERT_FALSE(main_ctx.is_worktree);
|
||
ASSERT_TRUE(wt_ctx.is_git);
|
||
ASSERT_TRUE(wt_ctx.is_worktree);
|
||
ASSERT_STR_EQ(main_ctx.canonical_root, wt_ctx.canonical_root);
|
||
ASSERT_NOT_NULL(wt_ctx.branch);
|
||
ASSERT_STR_EQ(wt_ctx.branch, "feature/git-context");
|
||
ASSERT_STR_EQ(wt_ctx.branch_slug, "feature-git-context");
|
||
ASSERT_NOT_NULL(wt_ctx.head_sha);
|
||
|
||
char *qn = cbm_git_context_branch_qn("proj", &wt_ctx);
|
||
ASSERT_NOT_NULL(qn);
|
||
ASSERT_STR_EQ(qn, "proj.__branch__.feature-git-context");
|
||
free(qn);
|
||
|
||
char json[2048];
|
||
ASSERT_GT(cbm_git_context_props_json(&wt_ctx, json, sizeof(json)), 0);
|
||
ASSERT_NOT_NULL(strstr(json, "\"is_git\":true"));
|
||
ASSERT_NOT_NULL(strstr(json, "\"is_worktree\":true"));
|
||
ASSERT_NOT_NULL(strstr(json, "\"branch\":\"feature/git-context\""));
|
||
|
||
cbm_git_context_free(&main_ctx);
|
||
cbm_git_context_free(&wt_ctx);
|
||
|
||
snprintf(cmd, sizeof(cmd), "git -C \"%s\" checkout --detach HEAD >%s 2>&1", repo, null_dev);
|
||
ASSERT_EQ(run_cmd(cmd), 0);
|
||
cbm_git_context_t detached_ctx = {0};
|
||
ASSERT_EQ(cbm_git_context_resolve(repo, &detached_ctx), 0);
|
||
ASSERT_TRUE(detached_ctx.is_detached);
|
||
ASSERT_STR_EQ(detached_ctx.branch_slug, "detached");
|
||
cbm_git_context_free(&detached_ctx);
|
||
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
TEST(project_name_uniqueness) {
|
||
/* Port of TestProjectNameUniqueness */
|
||
char *a = cbm_project_name_from_path("/tmp/bench/zig/lib/std");
|
||
char *b = cbm_project_name_from_path("/tmp/bench/erlang/lib/stdlib/src");
|
||
ASSERT_NOT_NULL(a);
|
||
ASSERT_NOT_NULL(b);
|
||
ASSERT_TRUE(strcmp(a, b) != 0);
|
||
free(a);
|
||
free(b);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Git diff helpers (pass_gitdiff.c) ─────────────────────────── */
|
||
|
||
TEST(gitdiff_parse_range_with_count) {
|
||
int start, count;
|
||
cbm_parse_range("10,5", &start, &count);
|
||
ASSERT_EQ(start, 10);
|
||
ASSERT_EQ(count, 5);
|
||
PASS();
|
||
}
|
||
|
||
TEST(gitdiff_parse_range_no_count) {
|
||
int start, count;
|
||
cbm_parse_range("10", &start, &count);
|
||
ASSERT_EQ(start, 10);
|
||
ASSERT_EQ(count, 1);
|
||
PASS();
|
||
}
|
||
|
||
TEST(gitdiff_parse_range_zero_count) {
|
||
int start, count;
|
||
cbm_parse_range("1,0", &start, &count);
|
||
ASSERT_EQ(start, 1);
|
||
ASSERT_EQ(count, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(gitdiff_parse_range_large) {
|
||
int start, count;
|
||
cbm_parse_range("52,2", &start, &count);
|
||
ASSERT_EQ(start, 52);
|
||
ASSERT_EQ(count, 2);
|
||
PASS();
|
||
}
|
||
|
||
TEST(gitdiff_parse_name_status) {
|
||
const char *input = "M\tinternal/store/nodes.go\n"
|
||
"A\tnew_file.go\n"
|
||
"D\told_file.go\n"
|
||
"R100\tsrc/old.go\tsrc/new.go\n";
|
||
|
||
cbm_changed_file_t files[16];
|
||
int n = cbm_parse_name_status(input, files, 16);
|
||
|
||
ASSERT_EQ(n, 4);
|
||
ASSERT_STR_EQ(files[0].status, "M");
|
||
ASSERT_STR_EQ(files[0].path, "internal/store/nodes.go");
|
||
ASSERT_STR_EQ(files[1].status, "A");
|
||
ASSERT_STR_EQ(files[1].path, "new_file.go");
|
||
ASSERT_STR_EQ(files[2].status, "D");
|
||
ASSERT_STR_EQ(files[2].path, "old_file.go");
|
||
ASSERT_STR_EQ(files[3].status, "R");
|
||
ASSERT_STR_EQ(files[3].path, "src/new.go");
|
||
ASSERT_STR_EQ(files[3].old_path, "src/old.go");
|
||
PASS();
|
||
}
|
||
|
||
TEST(gitdiff_parse_name_status_filters_untrackable) {
|
||
const char *input = "M\tpackage-lock.json\n"
|
||
"M\tsrc/main.go\n"
|
||
"M\tvendor/lib.go\n";
|
||
|
||
cbm_changed_file_t files[16];
|
||
int n = cbm_parse_name_status(input, files, 16);
|
||
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(files[0].path, "src/main.go");
|
||
PASS();
|
||
}
|
||
|
||
TEST(gitdiff_parse_hunks) {
|
||
const char *input = "diff --git a/main.go b/main.go\n"
|
||
"index abc1234..def5678 100644\n"
|
||
"--- a/main.go\n"
|
||
"+++ b/main.go\n"
|
||
"@@ -10,3 +10,5 @@ func main() {\n"
|
||
"+\tnewLine1()\n"
|
||
"+\tnewLine2()\n"
|
||
"@@ -50,0 +52,2 @@ func helper() {\n"
|
||
"+\tanother()\n"
|
||
"+\tline()\n"
|
||
"diff --git a/binary.png b/binary.png\n"
|
||
"Binary files a/binary.png and b/binary.png differ\n"
|
||
"diff --git a/utils.go b/utils.go\n"
|
||
"--- a/utils.go\n"
|
||
"+++ b/utils.go\n"
|
||
"@@ -1 +1 @@ package utils\n"
|
||
"-old\n"
|
||
"+new\n";
|
||
|
||
cbm_changed_hunk_t hunks[16];
|
||
int n = cbm_parse_hunks(input, hunks, 16);
|
||
|
||
ASSERT_EQ(n, 3);
|
||
ASSERT_STR_EQ(hunks[0].path, "main.go");
|
||
ASSERT_EQ(hunks[0].start_line, 10);
|
||
ASSERT_EQ(hunks[0].end_line, 14);
|
||
|
||
ASSERT_STR_EQ(hunks[1].path, "main.go");
|
||
ASSERT_EQ(hunks[1].start_line, 52);
|
||
ASSERT_EQ(hunks[1].end_line, 53);
|
||
|
||
ASSERT_STR_EQ(hunks[2].path, "utils.go");
|
||
ASSERT_EQ(hunks[2].start_line, 1);
|
||
ASSERT_EQ(hunks[2].end_line, 1);
|
||
PASS();
|
||
}
|
||
|
||
TEST(gitdiff_parse_hunks_no_newline_marker) {
|
||
const char *input = "diff --git a/file.go b/file.go\n"
|
||
"--- a/file.go\n"
|
||
"+++ b/file.go\n"
|
||
"@@ -5,2 +5,3 @@ func foo() {\n"
|
||
"+\tbar()\n"
|
||
"\\ No newline at end of file\n";
|
||
|
||
cbm_changed_hunk_t hunks[4];
|
||
int n = cbm_parse_hunks(input, hunks, 4);
|
||
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_EQ(hunks[0].start_line, 5);
|
||
ASSERT_EQ(hunks[0].end_line, 7);
|
||
PASS();
|
||
}
|
||
|
||
TEST(gitdiff_parse_hunks_mode_change) {
|
||
const char *input = "diff --git a/script.sh b/script.sh\n"
|
||
"old mode 100644\n"
|
||
"new mode 100755\n";
|
||
|
||
cbm_changed_hunk_t hunks[4];
|
||
int n = cbm_parse_hunks(input, hunks, 4);
|
||
ASSERT_EQ(n, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(gitdiff_parse_hunks_deletion) {
|
||
const char *input = "diff --git a/file.go b/file.go\n"
|
||
"--- a/file.go\n"
|
||
"+++ b/file.go\n"
|
||
"@@ -10,3 +10,0 @@ func foo() {\n";
|
||
|
||
cbm_changed_hunk_t hunks[4];
|
||
int n = cbm_parse_hunks(input, hunks, 4);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_EQ(hunks[0].start_line, 10);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Config helpers (pass_configures.c) ───────────────────────── */
|
||
|
||
TEST(configures_is_env_var_name) {
|
||
ASSERT(cbm_is_env_var_name("DATABASE_URL"));
|
||
ASSERT(cbm_is_env_var_name("API_KEY"));
|
||
ASSERT(cbm_is_env_var_name("PORT"));
|
||
ASSERT(!cbm_is_env_var_name("A")); /* too short */
|
||
ASSERT(!cbm_is_env_var_name("port")); /* lowercase */
|
||
ASSERT(!cbm_is_env_var_name("apiKey")); /* camelCase */
|
||
ASSERT(cbm_is_env_var_name("DB_2")); /* with digit */
|
||
ASSERT(!cbm_is_env_var_name("__")); /* no uppercase */
|
||
ASSERT(!cbm_is_env_var_name("")); /* empty */
|
||
PASS();
|
||
}
|
||
|
||
TEST(configures_normalize_config_key) {
|
||
char norm[256];
|
||
int tokens;
|
||
|
||
tokens = cbm_normalize_config_key("max_connections", norm, sizeof(norm));
|
||
ASSERT_STR_EQ(norm, "max_connections");
|
||
ASSERT_EQ(tokens, 2);
|
||
|
||
tokens = cbm_normalize_config_key("maxConnections", norm, sizeof(norm));
|
||
ASSERT_STR_EQ(norm, "max_connections");
|
||
ASSERT_EQ(tokens, 2);
|
||
|
||
tokens = cbm_normalize_config_key("DATABASE_HOST", norm, sizeof(norm));
|
||
ASSERT_STR_EQ(norm, "database_host");
|
||
ASSERT_EQ(tokens, 2);
|
||
|
||
tokens = cbm_normalize_config_key("database.host", norm, sizeof(norm));
|
||
ASSERT_STR_EQ(norm, "database_host");
|
||
ASSERT_EQ(tokens, 2);
|
||
|
||
tokens = cbm_normalize_config_key("port", norm, sizeof(norm));
|
||
ASSERT_STR_EQ(norm, "port");
|
||
ASSERT_EQ(tokens, 1);
|
||
|
||
tokens = cbm_normalize_config_key("maxRetryCount", norm, sizeof(norm));
|
||
ASSERT_STR_EQ(norm, "max_retry_count");
|
||
ASSERT_EQ(tokens, 3);
|
||
PASS();
|
||
}
|
||
|
||
TEST(configures_has_config_extension) {
|
||
ASSERT(cbm_has_config_extension("config.toml"));
|
||
ASSERT(cbm_has_config_extension("settings.yaml"));
|
||
ASSERT(cbm_has_config_extension("config.yml"));
|
||
ASSERT(cbm_has_config_extension(".env"));
|
||
ASSERT(cbm_has_config_extension("config.ini"));
|
||
ASSERT(cbm_has_config_extension("data.json"));
|
||
ASSERT(cbm_has_config_extension("pom.xml"));
|
||
ASSERT(!cbm_has_config_extension("main.go"));
|
||
ASSERT(!cbm_has_config_extension("app.py"));
|
||
ASSERT(!cbm_has_config_extension("data.csv"));
|
||
PASS();
|
||
}
|
||
|
||
/* ── Config integration tests (configures_test.go ports) ──────── */
|
||
|
||
TEST(configures_env_var_in_config) {
|
||
/* Port of TestBuildEnvIndex_ConfigVariableAdded:
|
||
* config.toml has DATABASE_URL, main.go does os.Getenv("DATABASE_URL")
|
||
* → CONFIGURES edges should link them. */
|
||
const char *files[] = {"config.toml", "main.go"};
|
||
const char *contents[] = {"DATABASE_URL = \"postgresql://localhost/db\"\n",
|
||
|
||
"package main\n\n"
|
||
"import \"os\"\n\n"
|
||
"func main() {\n"
|
||
"\turl := os.Getenv(\"DATABASE_URL\")\n"
|
||
"\t_ = url\n"
|
||
"}\n"};
|
||
if (setup_lang_repo(files, contents, 2) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
/* Verify CONFIGURES edges were created */
|
||
cbm_edge_t *edges = NULL;
|
||
int ec = 0;
|
||
cbm_store_find_edges_by_type(s, proj, "CONFIGURES", &edges, &ec);
|
||
/* At minimum the pipeline should not crash. Edge count depends on
|
||
* extraction matching env var accesses to config variables. */
|
||
if (edges)
|
||
cbm_store_free_edges(edges, ec);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(configures_lowercase_key_skipped) {
|
||
/* Port of TestBuildEnvIndex_LowercaseKeySkipped:
|
||
* config.toml has lowercase key — should NOT produce env var CONFIGURES edges. */
|
||
const char *files[] = {"config.toml", "main.go"};
|
||
const char *contents[] = {"database_host = \"localhost\"\n",
|
||
|
||
"package main\n\nfunc main() {}\n"};
|
||
if (setup_lang_repo(files, contents, 2) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
/* Pipeline ran successfully — no crash from lowercase config keys */
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(configures_non_config_file_skipped) {
|
||
/* Port of TestBuildEnvIndex_NonConfigFileSkipped:
|
||
* Only Go file, no config file — no config-derived CONFIGURES edges. */
|
||
const char *files[] = {"main.go"};
|
||
const char *contents[] = {"package main\n\n"
|
||
"var API_URL = \"https://api.example.com\"\n\n"
|
||
"func main() {}\n"};
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
/* No config file → buildEnvIndex should not create config-derived entries */
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(configures_full_pipeline_integration) {
|
||
/* Port of TestConfigIntegration_FullPipeline:
|
||
* TOML + INI + JSON config files + Go code → Class & Variable nodes from
|
||
* config files, plus CONFIGURES edges. */
|
||
const char *files[] = {"config.toml", "settings.ini", "config.json", "main.go"};
|
||
const char *contents[] = {"[database]\n"
|
||
"host = \"localhost\"\n"
|
||
"port = 5432\n"
|
||
"max_connections = 100\n\n"
|
||
"[server]\n"
|
||
"bind_address = \"0.0.0.0\"\n",
|
||
|
||
"[database]\n"
|
||
"host = localhost\n"
|
||
"port = 5432\n",
|
||
|
||
"{\"appName\": \"test\", \"maxRetries\": 3}",
|
||
|
||
"package main\n\n"
|
||
"import \"os\"\n\n"
|
||
"func getMaxConnections() int { return 100 }\n\n"
|
||
"func loadConfig() {\n"
|
||
"\tcfg := readFile(\"config.toml\")\n"
|
||
"\t_ = cfg\n"
|
||
"\tdbURL := os.Getenv(\"DATABASE_URL\")\n"
|
||
"\t_ = dbURL\n"
|
||
"}\n\n"
|
||
"func readFile(path string) string { return \"\" }\n"};
|
||
if (setup_lang_repo(files, contents, 4) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
/* Should have Class nodes (database, server sections from TOML) */
|
||
cbm_node_t *classes = NULL;
|
||
int cc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Class", &classes, &cc);
|
||
ASSERT_GT(cc, 0);
|
||
if (classes)
|
||
cbm_store_free_nodes(classes, cc);
|
||
|
||
/* Should have Variable nodes from config files */
|
||
cbm_node_t *vars = NULL;
|
||
int vc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Variable", &vars, &vc);
|
||
ASSERT_GT(vc, 0);
|
||
if (vars)
|
||
cbm_store_free_nodes(vars, vc);
|
||
|
||
/* Should have Function nodes from Go code */
|
||
cbm_node_t *funcs = NULL;
|
||
int fc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Function", &funcs, &fc);
|
||
ASSERT_GT(fc, 0);
|
||
if (funcs)
|
||
cbm_store_free_nodes(funcs, fc);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
TEST(enrichment_split_camel_case) {
|
||
char *parts[8];
|
||
int n;
|
||
|
||
n = cbm_split_camel_case("GetMapping", parts, 8);
|
||
ASSERT_EQ(n, 2);
|
||
ASSERT_STR_EQ(parts[0], "Get");
|
||
ASSERT_STR_EQ(parts[1], "Mapping");
|
||
for (int i = 0; i < n; i++)
|
||
free(parts[i]);
|
||
|
||
n = cbm_split_camel_case("getMessage", parts, 8);
|
||
ASSERT_EQ(n, 2);
|
||
ASSERT_STR_EQ(parts[0], "get");
|
||
ASSERT_STR_EQ(parts[1], "Message");
|
||
for (int i = 0; i < n; i++)
|
||
free(parts[i]);
|
||
|
||
n = cbm_split_camel_case("cache", parts, 8);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(parts[0], "cache");
|
||
for (int i = 0; i < n; i++)
|
||
free(parts[i]);
|
||
|
||
n = cbm_split_camel_case("HTMLParser", parts, 8);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(parts[0], "HTMLParser");
|
||
for (int i = 0; i < n; i++)
|
||
free(parts[i]);
|
||
|
||
n = cbm_split_camel_case("", parts, 8);
|
||
ASSERT_EQ(n, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(enrichment_tokenize_decorator) {
|
||
char *tokens[16];
|
||
int n;
|
||
|
||
n = cbm_tokenize_decorator("@Override", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "override");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
n = cbm_tokenize_decorator("@Deprecated", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "deprecated");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
n = cbm_tokenize_decorator("@Test", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "test");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
n = cbm_tokenize_decorator("@login_required", tokens, 16);
|
||
ASSERT_EQ(n, 2);
|
||
ASSERT_STR_EQ(tokens[0], "login");
|
||
ASSERT_STR_EQ(tokens[1], "required");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
n = cbm_tokenize_decorator("@cache", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "cache");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
n = cbm_tokenize_decorator("@pytest.fixture", tokens, 16);
|
||
ASSERT_EQ(n, 2);
|
||
ASSERT_STR_EQ(tokens[0], "pytest");
|
||
ASSERT_STR_EQ(tokens[1], "fixture");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
/* "get" is stopword → only "mapping" */
|
||
n = cbm_tokenize_decorator("@GetMapping(\"/api\")", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "mapping");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
/* "post" passes, "mapping" passes */
|
||
n = cbm_tokenize_decorator("@PostMapping(\"/api\")", tokens, 16);
|
||
ASSERT_EQ(n, 2);
|
||
ASSERT_STR_EQ(tokens[0], "post");
|
||
ASSERT_STR_EQ(tokens[1], "mapping");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
n = cbm_tokenize_decorator("@Transactional", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "transactional");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
n = cbm_tokenize_decorator("@MessageMapping(\"/chat\")", tokens, 16);
|
||
ASSERT_EQ(n, 2);
|
||
ASSERT_STR_EQ(tokens[0], "message");
|
||
ASSERT_STR_EQ(tokens[1], "mapping");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
/* Rust-style #[test] */
|
||
n = cbm_tokenize_decorator("#[test]", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "test");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
/* #[derive(Debug)] */
|
||
n = cbm_tokenize_decorator("#[derive(Debug)]", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "derive");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
/* Both "app" and "get" are stopwords → empty */
|
||
n = cbm_tokenize_decorator("@app.get(\"/api\")", tokens, 16);
|
||
ASSERT_EQ(n, 0);
|
||
|
||
/* "router" is stopword, "post" passes */
|
||
n = cbm_tokenize_decorator("@router.post(\"/api\")", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "post");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
/* Too short after filtering */
|
||
n = cbm_tokenize_decorator("@x", tokens, 16);
|
||
ASSERT_EQ(n, 0);
|
||
|
||
/* Empty */
|
||
n = cbm_tokenize_decorator("", tokens, 16);
|
||
ASSERT_EQ(n, 0);
|
||
|
||
n = cbm_tokenize_decorator("@click.command", tokens, 16);
|
||
ASSERT_EQ(n, 2);
|
||
ASSERT_STR_EQ(tokens[0], "click");
|
||
ASSERT_STR_EQ(tokens[1], "command");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
|
||
n = cbm_tokenize_decorator("@celery.task", tokens, 16);
|
||
ASSERT_EQ(n, 2);
|
||
ASSERT_STR_EQ(tokens[0], "celery");
|
||
ASSERT_STR_EQ(tokens[1], "task");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Decorator tags integration tests (enrichment_test.go ports) ─ */
|
||
|
||
/* Helper: check if a node's properties_json contains a specific decorator_tag */
|
||
static bool has_decorator_tag(const char *properties_json, const char *tag) {
|
||
if (!properties_json || !tag)
|
||
return false;
|
||
yyjson_doc *doc = yyjson_read(properties_json, strlen(properties_json), 0);
|
||
if (!doc)
|
||
return false;
|
||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||
yyjson_val *tags = yyjson_obj_get(root, "decorator_tags");
|
||
if (!tags || !yyjson_is_arr(tags)) {
|
||
yyjson_doc_free(doc);
|
||
return false;
|
||
}
|
||
yyjson_val *item;
|
||
yyjson_arr_iter iter;
|
||
yyjson_arr_iter_init(tags, &iter);
|
||
while ((item = yyjson_arr_iter_next(&iter))) {
|
||
if (yyjson_is_str(item) && strcmp(yyjson_get_str(item), tag) == 0) {
|
||
yyjson_doc_free(doc);
|
||
return true;
|
||
}
|
||
}
|
||
yyjson_doc_free(doc);
|
||
return false;
|
||
}
|
||
|
||
TEST(decorator_tags_python_auto_discovery) {
|
||
/* Port of TestDecoratorTagAutoDiscovery:
|
||
* Python file with repeated decorators (@login_required on 2 funcs,
|
||
* @cache on 2 funcs, @unique_helper on 1 func).
|
||
* Words on 2+ nodes become tags; unique words do not. */
|
||
const char *files[] = {"views.py"};
|
||
const char *contents[] = {"from functools import cache\n\n"
|
||
"@login_required\n"
|
||
"def list_orders():\n"
|
||
" pass\n\n"
|
||
"@login_required\n"
|
||
"def get_order():\n"
|
||
" pass\n\n"
|
||
"@cache\n"
|
||
"def compute_total():\n"
|
||
" pass\n\n"
|
||
"@cache\n"
|
||
"def compute_tax():\n"
|
||
" pass\n\n"
|
||
"@unique_helper\n"
|
||
"def special():\n"
|
||
" pass\n"};
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
/* Find functions by name and check decorator_tags */
|
||
cbm_node_t *funcs = NULL;
|
||
int fc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Function", &funcs, &fc);
|
||
|
||
/* Build name→properties_json map */
|
||
const char *list_orders_props = NULL;
|
||
const char *get_order_props = NULL;
|
||
const char *compute_total_props = NULL;
|
||
const char *compute_tax_props = NULL;
|
||
const char *special_props = NULL;
|
||
for (int i = 0; i < fc; i++) {
|
||
if (strcmp(funcs[i].name, "list_orders") == 0)
|
||
list_orders_props = funcs[i].properties_json;
|
||
else if (strcmp(funcs[i].name, "get_order") == 0)
|
||
get_order_props = funcs[i].properties_json;
|
||
else if (strcmp(funcs[i].name, "compute_total") == 0)
|
||
compute_total_props = funcs[i].properties_json;
|
||
else if (strcmp(funcs[i].name, "compute_tax") == 0)
|
||
compute_tax_props = funcs[i].properties_json;
|
||
else if (strcmp(funcs[i].name, "special") == 0)
|
||
special_props = funcs[i].properties_json;
|
||
}
|
||
|
||
/* "login" and "required" appear on 2 nodes → should be tags */
|
||
ASSERT_TRUE(has_decorator_tag(list_orders_props, "login"));
|
||
ASSERT_TRUE(has_decorator_tag(list_orders_props, "required"));
|
||
ASSERT_TRUE(has_decorator_tag(get_order_props, "login"));
|
||
ASSERT_TRUE(has_decorator_tag(get_order_props, "required"));
|
||
|
||
/* "cache" appears on 2 nodes → should be a tag */
|
||
ASSERT_TRUE(has_decorator_tag(compute_total_props, "cache"));
|
||
ASSERT_TRUE(has_decorator_tag(compute_tax_props, "cache"));
|
||
|
||
/* "unique" and "helper" appear on only 1 node → should NOT be tags */
|
||
ASSERT_FALSE(has_decorator_tag(special_props, "unique"));
|
||
ASSERT_FALSE(has_decorator_tag(special_props, "helper"));
|
||
|
||
if (funcs)
|
||
cbm_store_free_nodes(funcs, fc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(decorator_tags_java_class_methods) {
|
||
/* Port of TestDecoratorTagJavaClassMethods:
|
||
* Java class with @GetMapping, @PostMapping, @Transactional annotations.
|
||
* "mapping" appears on all 4 → tag. "post" on 2 → tag. */
|
||
const char *files[] = {"Controller.java"};
|
||
const char *contents[] = {"class OwnerController {\n"
|
||
" @GetMapping(\"/owners\")\n"
|
||
" public void listOwners() {}\n\n"
|
||
" @GetMapping(\"/owners/{id}\")\n"
|
||
" public void showOwner() {}\n\n"
|
||
" @PostMapping(\"/owners\")\n"
|
||
" public void createOwner() {}\n\n"
|
||
" @Transactional\n"
|
||
" @PostMapping(\"/owners/{id}\")\n"
|
||
" public void updateOwner() {}\n"
|
||
"}\n"};
|
||
if (setup_lang_repo(files, contents, 1) != 0)
|
||
FAIL("tmpdir");
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
/* Find methods */
|
||
cbm_node_t *methods = NULL;
|
||
int mc = 0;
|
||
cbm_store_find_nodes_by_label(s, proj, "Method", &methods, &mc);
|
||
|
||
/* "mapping" appears on all 4 methods → should be a tag */
|
||
for (int i = 0; i < mc; i++) {
|
||
if (strcmp(methods[i].name, "listOwners") == 0 ||
|
||
strcmp(methods[i].name, "showOwner") == 0 ||
|
||
strcmp(methods[i].name, "createOwner") == 0 ||
|
||
strcmp(methods[i].name, "updateOwner") == 0) {
|
||
ASSERT_TRUE(has_decorator_tag(methods[i].properties_json, "mapping"));
|
||
}
|
||
}
|
||
|
||
/* "post" appears on createOwner + updateOwner → should be a tag */
|
||
for (int i = 0; i < mc; i++) {
|
||
if (strcmp(methods[i].name, "createOwner") == 0 ||
|
||
strcmp(methods[i].name, "updateOwner") == 0) {
|
||
ASSERT_TRUE(has_decorator_tag(methods[i].properties_json, "post"));
|
||
}
|
||
}
|
||
|
||
/* "transactional" appears on only 1 method → should NOT be a tag */
|
||
for (int i = 0; i < mc; i++) {
|
||
if (strcmp(methods[i].name, "updateOwner") == 0) {
|
||
ASSERT_FALSE(has_decorator_tag(methods[i].properties_json, "transactional"));
|
||
}
|
||
}
|
||
|
||
if (methods)
|
||
cbm_store_free_nodes(methods, mc);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* ── Compile commands helpers (pass_compile_commands.c) ────────── */
|
||
|
||
TEST(compile_commands_split_command) {
|
||
char *args[16];
|
||
int n;
|
||
|
||
n = cbm_split_command("gcc -c main.c", args, 16);
|
||
ASSERT_EQ(n, 3);
|
||
ASSERT_STR_EQ(args[0], "gcc");
|
||
ASSERT_STR_EQ(args[1], "-c");
|
||
ASSERT_STR_EQ(args[2], "main.c");
|
||
for (int i = 0; i < n; i++)
|
||
free(args[i]);
|
||
|
||
n = cbm_split_command("gcc -DFOO=\"bar baz\" -c main.c", args, 16);
|
||
ASSERT_EQ(n, 4);
|
||
for (int i = 0; i < n; i++)
|
||
free(args[i]);
|
||
|
||
n = cbm_split_command("g++ -I/usr/include -std=c++17 -o out -c in.cpp", args, 16);
|
||
ASSERT_EQ(n, 7);
|
||
for (int i = 0; i < n; i++)
|
||
free(args[i]);
|
||
PASS();
|
||
}
|
||
|
||
TEST(compile_commands_extract_flags) {
|
||
const char *args[] = {"g++", "-I", "/abs/include", "-I/rel/include", "-isystem",
|
||
"/sys/include", "-DFOO", "-DBAR=42", "-std=c++20", "-O2",
|
||
"-Wall", "-c", "main.cpp"};
|
||
|
||
cbm_compile_flags_t *f = cbm_extract_flags(args, 13, "/project");
|
||
ASSERT_NOT_NULL(f);
|
||
ASSERT_EQ(f->include_count, 3);
|
||
ASSERT_EQ(f->define_count, 2);
|
||
ASSERT_STR_EQ(f->standard, "c++20");
|
||
cbm_compile_flags_free(f);
|
||
PASS();
|
||
}
|
||
|
||
TEST(compile_commands_parse_json) {
|
||
const char *json = "[\n"
|
||
" {\n"
|
||
" \"directory\": \"/home/user/project/build\",\n"
|
||
" \"command\": \"gcc -I/home/user/project/include "
|
||
"-I/home/user/project/src -DDEBUG=1 -DVERSION=\\\"1.0\\\" "
|
||
"-std=c11 -o main.o -c /home/user/project/src/main.c\",\n"
|
||
" \"file\": \"/home/user/project/src/main.c\"\n"
|
||
" },\n"
|
||
" {\n"
|
||
" \"directory\": \"/home/user/project/build\",\n"
|
||
" \"arguments\": [\"g++\", \"-I/home/user/project/include\", "
|
||
"\"-isystem\", \"/home/user/project/third_party\", "
|
||
"\"-DUSE_SSL\", \"-std=c++17\", \"-c\", "
|
||
"\"/home/user/project/src/server.cpp\"],\n"
|
||
" \"file\": \"/home/user/project/src/server.cpp\"\n"
|
||
" },\n"
|
||
" {\n"
|
||
" \"directory\": \"/home/user/project/build\",\n"
|
||
" \"command\": \"gcc -c /outside/repo/file.c\",\n"
|
||
" \"file\": \"/outside/repo/file.c\"\n"
|
||
" }\n"
|
||
"]";
|
||
|
||
char **paths = NULL;
|
||
cbm_compile_flags_t **flags = NULL;
|
||
int n = cbm_parse_compile_commands(json, "/home/user/project", &paths, &flags);
|
||
ASSERT(n >= 2); /* At least main.c and server.cpp, outside file excluded */
|
||
|
||
/* Find main.c */
|
||
int main_idx = -1, server_idx = -1;
|
||
for (int i = 0; i < n; i++) {
|
||
if (strcmp(paths[i], "src/main.c") == 0)
|
||
main_idx = i;
|
||
if (strcmp(paths[i], "src/server.cpp") == 0)
|
||
server_idx = i;
|
||
}
|
||
|
||
ASSERT(main_idx >= 0);
|
||
ASSERT_EQ(flags[main_idx]->include_count, 2);
|
||
ASSERT_EQ(flags[main_idx]->define_count, 2);
|
||
ASSERT_STR_EQ(flags[main_idx]->standard, "c11");
|
||
|
||
ASSERT(server_idx >= 0);
|
||
ASSERT_EQ(flags[server_idx]->include_count, 2);
|
||
ASSERT_EQ(flags[server_idx]->define_count, 1);
|
||
ASSERT_STR_EQ(flags[server_idx]->standard, "c++17");
|
||
|
||
/* Verify outside-repo file excluded */
|
||
for (int i = 0; i < n; i++) {
|
||
ASSERT(strstr(paths[i], "outside") == NULL);
|
||
}
|
||
|
||
/* Cleanup */
|
||
for (int i = 0; i < n; i++) {
|
||
free(paths[i]);
|
||
cbm_compile_flags_free(flags[i]);
|
||
}
|
||
free(paths);
|
||
free(flags);
|
||
PASS();
|
||
}
|
||
|
||
TEST(compile_commands_parse_empty) {
|
||
char **paths = NULL;
|
||
cbm_compile_flags_t **flags = NULL;
|
||
int n = cbm_parse_compile_commands("[]", "/repo", &paths, &flags);
|
||
ASSERT_EQ(n, 0);
|
||
free(paths);
|
||
free(flags);
|
||
PASS();
|
||
}
|
||
|
||
TEST(compile_commands_parse_invalid) {
|
||
char **paths = NULL;
|
||
cbm_compile_flags_t **flags = NULL;
|
||
int n = cbm_parse_compile_commands("not json", "/repo", &paths, &flags);
|
||
ASSERT(n < 0);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Suite ─────────────────────────────────────────────────────── */
|
||
|
||
/* ── Infrascan: file identification ──────────────────────────────── */
|
||
|
||
TEST(infra_is_compose_file) {
|
||
/* Port of TestIsComposeFile (8 cases) */
|
||
ASSERT(cbm_is_compose_file("docker-compose.yml"));
|
||
ASSERT(cbm_is_compose_file("docker-compose.yaml"));
|
||
ASSERT(cbm_is_compose_file("docker-compose.prod.yml"));
|
||
ASSERT(cbm_is_compose_file("compose.yml"));
|
||
ASSERT(cbm_is_compose_file("compose.yaml"));
|
||
ASSERT(!cbm_is_compose_file("mycompose.yml"));
|
||
ASSERT(!cbm_is_compose_file("docker-compose.txt"));
|
||
ASSERT(!cbm_is_compose_file("Dockerfile"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_is_cloudbuild_file) {
|
||
/* Port of TestIsCloudbuildFile (5 cases) */
|
||
ASSERT(cbm_is_cloudbuild_file("cloudbuild.yaml"));
|
||
ASSERT(cbm_is_cloudbuild_file("cloudbuild.yml"));
|
||
ASSERT(cbm_is_cloudbuild_file("cloudbuild-prod.yaml"));
|
||
ASSERT(cbm_is_cloudbuild_file("Cloudbuild.yml"));
|
||
ASSERT(!cbm_is_cloudbuild_file("build.yaml"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_is_shell_script) {
|
||
/* Port of TestIsShellScript (5 cases) */
|
||
ASSERT(cbm_is_shell_script("run.sh", ".sh"));
|
||
ASSERT(cbm_is_shell_script("deploy.bash", ".bash"));
|
||
ASSERT(cbm_is_shell_script("init.zsh", ".zsh"));
|
||
ASSERT(!cbm_is_shell_script("main.py", ".py"));
|
||
ASSERT(!cbm_is_shell_script("Dockerfile", ""));
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_is_dockerfile) {
|
||
ASSERT(cbm_is_dockerfile("Dockerfile"));
|
||
ASSERT(cbm_is_dockerfile("dockerfile"));
|
||
ASSERT(cbm_is_dockerfile("Dockerfile.prod"));
|
||
ASSERT(cbm_is_dockerfile("app.dockerfile"));
|
||
ASSERT(!cbm_is_dockerfile("docker-compose.yml"));
|
||
ASSERT(!cbm_is_dockerfile("main.go"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_is_kustomize_file) {
|
||
ASSERT(cbm_is_kustomize_file("kustomization.yaml"));
|
||
ASSERT(cbm_is_kustomize_file("kustomization.yml"));
|
||
ASSERT(cbm_is_kustomize_file("KUSTOMIZATION.YAML")); /* case-insensitive */
|
||
ASSERT(!cbm_is_kustomize_file("deployment.yaml"));
|
||
ASSERT(!cbm_is_kustomize_file("kustomize.yaml"));
|
||
ASSERT(!cbm_is_kustomize_file(NULL));
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_is_k8s_manifest) {
|
||
const char *deploy = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n";
|
||
const char *plain = "name: foo\nvalue: bar\n";
|
||
const char *kust = "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n";
|
||
|
||
ASSERT(cbm_is_k8s_manifest("deployment.yaml", deploy));
|
||
ASSERT(!cbm_is_k8s_manifest("deployment.yaml", plain));
|
||
/* kustomize file should return false even if it has apiVersion */
|
||
ASSERT(!cbm_is_k8s_manifest("kustomization.yaml", kust));
|
||
ASSERT(!cbm_is_k8s_manifest(NULL, deploy));
|
||
ASSERT(!cbm_is_k8s_manifest("deployment.yaml", NULL));
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_is_env_file) {
|
||
ASSERT(cbm_is_env_file(".env"));
|
||
ASSERT(cbm_is_env_file(".env.local"));
|
||
ASSERT(cbm_is_env_file("prod.env"));
|
||
ASSERT(!cbm_is_env_file("main.go"));
|
||
ASSERT(!cbm_is_env_file("env.txt"));
|
||
PASS();
|
||
}
|
||
|
||
/* ── Infrascan: cleanJSONBrackets ───────────────────────────────── */
|
||
|
||
TEST(infra_clean_json_brackets) {
|
||
/* Port of TestCleanJSONBrackets (4 cases) */
|
||
char out[256];
|
||
|
||
cbm_clean_json_brackets("[\"./server\"]", out, sizeof(out));
|
||
ASSERT_STR_EQ(out, "./server");
|
||
|
||
cbm_clean_json_brackets("[\"python\", \"main.py\"]", out, sizeof(out));
|
||
ASSERT_STR_EQ(out, "python main.py");
|
||
|
||
cbm_clean_json_brackets("./server", out, sizeof(out));
|
||
ASSERT_STR_EQ(out, "./server");
|
||
|
||
cbm_clean_json_brackets("[\"./app\", \"--flag\", \"value\"]", out, sizeof(out));
|
||
ASSERT_STR_EQ(out, "./app --flag value");
|
||
|
||
PASS();
|
||
}
|
||
|
||
/* ── Infrascan: secret detection ────────────────────────────────── */
|
||
|
||
TEST(infra_secret_detection) {
|
||
/* Key-based detection */
|
||
ASSERT(cbm_is_secret_binding("JWT_SECRET", "anything"));
|
||
ASSERT(cbm_is_secret_binding("API_KEY", "anything"));
|
||
ASSERT(cbm_is_secret_binding("my_password", "anything"));
|
||
ASSERT(cbm_is_secret_binding("AUTH_TOKEN", "anything"));
|
||
ASSERT(!cbm_is_secret_binding("DATABASE_URL", "https://db.example.com"));
|
||
|
||
/* Value-based detection */
|
||
ASSERT(cbm_is_secret_value("sk-1234567890abcdef12345"));
|
||
ASSERT(cbm_is_secret_value("-----BEGIN RSA PRIVATE KEY-----"));
|
||
ASSERT(!cbm_is_secret_value("https://db.example.com"));
|
||
ASSERT(!cbm_is_secret_value("hello world"));
|
||
ASSERT(!cbm_is_secret_value("8080"));
|
||
|
||
/* isSecretBinding checks both */
|
||
ASSERT(cbm_is_secret_binding("ANYTHING", "sk-1234567890abcdef12345"));
|
||
ASSERT(!cbm_is_secret_binding("PORT", "8080"));
|
||
|
||
PASS();
|
||
}
|
||
|
||
/* ── Infrascan: Dockerfile parser ───────────────────────────────── */
|
||
|
||
/* Helper: find env var by key in result */
|
||
static const char *find_env_var(const cbm_env_kv_t *vars, int count, const char *key) {
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(vars[i].key, key) == 0)
|
||
return vars[i].value;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* Helper: check if string array contains value */
|
||
static bool str_array_contains(const char (*arr)[32], int count, const char *val) {
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(arr[i], val) == 0)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool str_array_128_contains(const char (*arr)[128], int count, const char *val) {
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(arr[i], val) == 0)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool str_array_256_contains(const char (*arr)[256], int count, const char *val) {
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(arr[i], val) == 0)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
TEST(infra_parse_dockerfile_multistage) {
|
||
/* Port of TestParseDockerfile "multi-stage with all directives" */
|
||
const char *src = "FROM golang:1.23-alpine AS builder\n"
|
||
"WORKDIR /app\n"
|
||
"ARG SSH_PRIVATE_KEY\n"
|
||
"RUN go build -o server .\n"
|
||
"\n"
|
||
"FROM alpine:3.19\n"
|
||
"WORKDIR /usr/app\n"
|
||
"ENV PORT=8080\n"
|
||
"ENV PYTHONUNBUFFERED=1\n"
|
||
"EXPOSE 8080 443\n"
|
||
"USER appuser\n"
|
||
"CMD [\"./server\"]\n"
|
||
"HEALTHCHECK CMD wget http://localhost:8080/health\n";
|
||
|
||
cbm_dockerfile_result_t r;
|
||
ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0);
|
||
ASSERT_STR_EQ(r.base_image, "alpine:3.19");
|
||
ASSERT_EQ(r.stage_count, 2);
|
||
ASSERT_STR_EQ(r.stage_images[0], "golang:1.23-alpine");
|
||
ASSERT_STR_EQ(r.stage_images[1], "alpine:3.19");
|
||
ASSERT(str_array_contains(r.exposed_ports, r.port_count, "8080"));
|
||
ASSERT(str_array_contains(r.exposed_ports, r.port_count, "443"));
|
||
ASSERT_STR_EQ(r.workdir, "/usr/app");
|
||
ASSERT_STR_EQ(r.user, "appuser");
|
||
ASSERT_STR_EQ(r.cmd, "./server");
|
||
ASSERT_STR_EQ(r.healthcheck, "wget http://localhost:8080/health");
|
||
|
||
ASSERT_NOT_NULL(find_env_var(r.env_vars, r.env_count, "PORT"));
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PORT"), "8080");
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PYTHONUNBUFFERED"), "1");
|
||
|
||
ASSERT(str_array_128_contains(r.build_args, r.build_arg_count, "SSH_PRIVATE_KEY"));
|
||
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_dockerfile_entrypoint) {
|
||
/* Port of TestParseDockerfile "single stage with entrypoint" */
|
||
const char *src = "FROM python:3.9-slim\n"
|
||
"ENTRYPOINT [\"python\", \"main.py\"]\n";
|
||
|
||
cbm_dockerfile_result_t r;
|
||
ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0);
|
||
ASSERT_STR_EQ(r.base_image, "python:3.9-slim");
|
||
ASSERT_STR_EQ(r.entrypoint, "python main.py");
|
||
ASSERT_EQ(r.stage_count, 1);
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_dockerfile_secret_filtered) {
|
||
/* Port of TestParseDockerfile "secret env vars filtered" */
|
||
const char *src = "FROM node:20\n"
|
||
"ENV API_KEY=sk-1234567890abcdef12345\n"
|
||
"ENV DATABASE_URL=https://db.example.com\n"
|
||
"ENV JWT_SECRET=supersecret\n";
|
||
|
||
cbm_dockerfile_result_t r;
|
||
ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0);
|
||
|
||
/* API_KEY and JWT_SECRET should be filtered */
|
||
ASSERT(find_env_var(r.env_vars, r.env_count, "API_KEY") == NULL);
|
||
ASSERT(find_env_var(r.env_vars, r.env_count, "JWT_SECRET") == NULL);
|
||
|
||
/* DATABASE_URL should remain */
|
||
ASSERT_NOT_NULL(find_env_var(r.env_vars, r.env_count, "DATABASE_URL"));
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_URL"), "https://db.example.com");
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_dockerfile_expose_protocol) {
|
||
/* Port of TestParseDockerfile "expose with protocol suffix" */
|
||
const char *src = "FROM nginx:latest\n"
|
||
"EXPOSE 80/tcp 443/tcp\n";
|
||
|
||
cbm_dockerfile_result_t r;
|
||
ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0);
|
||
ASSERT(str_array_contains(r.exposed_ports, r.port_count, "80"));
|
||
ASSERT(str_array_contains(r.exposed_ports, r.port_count, "443"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_dockerfile_env_space) {
|
||
/* Port of TestParseDockerfile "ENV space-separated format" */
|
||
const char *src = "FROM python:3.9\n"
|
||
"ENV PYTHONPATH /usr/app\n";
|
||
|
||
cbm_dockerfile_result_t r;
|
||
ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0);
|
||
ASSERT_NOT_NULL(find_env_var(r.env_vars, r.env_count, "PYTHONPATH"));
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PYTHONPATH"), "/usr/app");
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_dockerfile_empty) {
|
||
/* Port of TestParseDockerfileEmpty */
|
||
cbm_dockerfile_result_t r;
|
||
ASSERT_EQ(cbm_parse_dockerfile_source("# just a comment\n", &r), -1);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Infrascan: Dotenv parser ───────────────────────────────────── */
|
||
|
||
TEST(infra_parse_dotenv) {
|
||
/* Port of TestParseDotenvFile */
|
||
const char *src = "# Database config\n"
|
||
"DATABASE_HOST=localhost\n"
|
||
"DATABASE_PORT=5432\n"
|
||
"DATABASE_NAME=mydb\n"
|
||
"API_SECRET=should-not-appear\n"
|
||
"PLAIN_VALUE=hello world\n";
|
||
|
||
cbm_dotenv_result_t r;
|
||
ASSERT_EQ(cbm_parse_dotenv_source(src, &r), 0);
|
||
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_HOST"), "localhost");
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_PORT"), "5432");
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PLAIN_VALUE"), "hello world");
|
||
|
||
/* API_SECRET should be filtered */
|
||
ASSERT(find_env_var(r.env_vars, r.env_count, "API_SECRET") == NULL);
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_dotenv_quoted) {
|
||
/* Port of TestParseDotenvQuotedValues */
|
||
const char *src = "KEY1=\"quoted value\"\n"
|
||
"KEY2='single quoted'\n";
|
||
|
||
cbm_dotenv_result_t r;
|
||
ASSERT_EQ(cbm_parse_dotenv_source(src, &r), 0);
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "KEY1"), "quoted value");
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "KEY2"), "single quoted");
|
||
PASS();
|
||
}
|
||
|
||
/* ── Infrascan: Shell script parser ─────────────────────────────── */
|
||
|
||
TEST(infra_parse_shell) {
|
||
/* Port of TestParseShellScript */
|
||
const char *src = "#!/bin/bash\n"
|
||
"set -e\n"
|
||
"\n"
|
||
"# Configuration\n"
|
||
"YOUR_CONTAINER_NAME=\"order-email-extractor-endpoint\"\n"
|
||
"DOCKERFILE_PATH=\"/path/to/dockerfile\"\n"
|
||
"\n"
|
||
"export ENVIRONMENT=\"development\"\n"
|
||
"export USE_STACKDRIVER=\"false\"\n"
|
||
"\n"
|
||
"# Shut down existing containers\n"
|
||
"./shut-down-docker-container.sh\n"
|
||
"\n"
|
||
"docker build -t \"$YOUR_CONTAINER_NAME\" \"$DOCKERFILE_PATH\"\n"
|
||
"docker run -d --name \"$YOUR_CONTAINER_NAME\" \"$YOUR_CONTAINER_NAME\"\n"
|
||
"docker-compose up -d\n";
|
||
|
||
cbm_shell_result_t r;
|
||
ASSERT_EQ(cbm_parse_shell_source(src, &r), 0);
|
||
ASSERT_STR_EQ(r.shebang, "/bin/bash");
|
||
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "ENVIRONMENT"), "development");
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "YOUR_CONTAINER_NAME"),
|
||
"order-email-extractor-endpoint");
|
||
|
||
ASSERT(str_array_256_contains(r.docker_cmds, r.docker_cmd_count, "docker build"));
|
||
ASSERT(str_array_256_contains(r.docker_cmds, r.docker_cmd_count, "docker run"));
|
||
ASSERT(str_array_256_contains(r.docker_cmds, r.docker_cmd_count, "docker-compose up"));
|
||
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_shell_with_source) {
|
||
/* Port of TestParseShellScriptWithSource */
|
||
const char *src = "#!/usr/bin/env bash\n"
|
||
"source ./config.sh\n"
|
||
". /etc/profile.d/env.sh\n";
|
||
|
||
cbm_shell_result_t r;
|
||
ASSERT_EQ(cbm_parse_shell_source(src, &r), 0);
|
||
ASSERT_STR_EQ(r.shebang, "/usr/bin/env bash");
|
||
ASSERT(str_array_256_contains(r.sources, r.source_count, "./config.sh"));
|
||
ASSERT(str_array_256_contains(r.sources, r.source_count, "/etc/profile.d/env.sh"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_shell_secret_filtered) {
|
||
/* Port of TestParseShellScriptSecretFiltered */
|
||
const char *src = "#!/bin/bash\n"
|
||
"export API_SECRET=\"should-not-appear\"\n"
|
||
"export DATABASE_URL=\"https://db.example.com\"\n";
|
||
|
||
cbm_shell_result_t r;
|
||
ASSERT_EQ(cbm_parse_shell_source(src, &r), 0);
|
||
ASSERT(find_env_var(r.env_vars, r.env_count, "API_SECRET") == NULL);
|
||
ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_URL"), "https://db.example.com");
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_shell_shebang_only) {
|
||
/* Port of TestParseShellScriptShebanOnly */
|
||
const char *src = "#!/bin/bash\n# just comments\n";
|
||
cbm_shell_result_t r;
|
||
ASSERT_EQ(cbm_parse_shell_source(src, &r), 0);
|
||
ASSERT_STR_EQ(r.shebang, "/bin/bash");
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_shell_truly_empty) {
|
||
/* Port of TestParseShellScriptTrulyEmpty */
|
||
cbm_shell_result_t r;
|
||
ASSERT_EQ(cbm_parse_shell_source("# no shebang, just comments\n", &r), -1);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Infrascan: Terraform parser ────────────────────────────────── */
|
||
|
||
TEST(infra_parse_terraform_full) {
|
||
/* Port of TestParseTerraformFile */
|
||
const char *src = "\n"
|
||
"terraform {\n"
|
||
" required_providers {\n"
|
||
" google = {\n"
|
||
" source = \"hashicorp/google\"\n"
|
||
" version = \"~> 6.35.0\"\n"
|
||
" }\n"
|
||
" }\n"
|
||
" backend \"gcs\" {\n"
|
||
" bucket = \"example-tf\"\n"
|
||
" prefix = \"state\"\n"
|
||
" }\n"
|
||
"}\n"
|
||
"\n"
|
||
"variable \"project_id\" {\n"
|
||
" description = \"The GCP project ID\"\n"
|
||
" type = string\n"
|
||
" default = \"example-cloud\"\n"
|
||
"}\n"
|
||
"\n"
|
||
"variable \"region\" {\n"
|
||
" description = \"The region\"\n"
|
||
" type = string\n"
|
||
"}\n"
|
||
"\n"
|
||
"resource \"google_cloud_run_service\" \"main\" {\n"
|
||
" name = \"my-service\"\n"
|
||
" location = var.region\n"
|
||
"}\n"
|
||
"\n"
|
||
"resource \"google_compute_address\" \"nat_ip\" {\n"
|
||
" name = \"nat-ip\"\n"
|
||
" region = var.region\n"
|
||
"}\n"
|
||
"\n"
|
||
"output \"service_url\" {\n"
|
||
" value = google_cloud_run_service.main.status[0].url\n"
|
||
"}\n"
|
||
"\n"
|
||
"data \"google_project\" \"project\" {\n"
|
||
"}\n"
|
||
"\n"
|
||
"module \"vpc\" {\n"
|
||
" source = \"./modules/vpc\"\n"
|
||
"}\n"
|
||
"\n"
|
||
"locals {\n"
|
||
" env = \"prod\"\n"
|
||
"}\n";
|
||
|
||
cbm_terraform_result_t r;
|
||
ASSERT_EQ(cbm_parse_terraform_source(src, &r), 0);
|
||
ASSERT_STR_EQ(r.backend, "gcs");
|
||
|
||
/* Resources */
|
||
ASSERT_EQ(r.resource_count, 2);
|
||
bool found_cloud_run = false;
|
||
for (int i = 0; i < r.resource_count; i++) {
|
||
if (strcmp(r.resources[i].type, "google_cloud_run_service") == 0 &&
|
||
strcmp(r.resources[i].name, "main") == 0) {
|
||
found_cloud_run = true;
|
||
}
|
||
}
|
||
ASSERT(found_cloud_run);
|
||
|
||
/* Variables */
|
||
ASSERT_EQ(r.variable_count, 2);
|
||
bool found_project_id = false;
|
||
for (int i = 0; i < r.variable_count; i++) {
|
||
if (strcmp(r.variables[i].name, "project_id") == 0) {
|
||
ASSERT_STR_EQ(r.variables[i].default_val, "example-cloud");
|
||
ASSERT_STR_EQ(r.variables[i].type, "string");
|
||
ASSERT_STR_EQ(r.variables[i].description, "The GCP project ID");
|
||
found_project_id = true;
|
||
}
|
||
}
|
||
ASSERT(found_project_id);
|
||
|
||
/* Outputs */
|
||
ASSERT_EQ(r.output_count, 1);
|
||
ASSERT_STR_EQ(r.outputs[0], "service_url");
|
||
|
||
/* Data sources */
|
||
ASSERT_EQ(r.data_source_count, 1);
|
||
ASSERT_STR_EQ(r.data_sources[0].type, "google_project");
|
||
ASSERT_STR_EQ(r.data_sources[0].name, "project");
|
||
|
||
/* Modules */
|
||
ASSERT_EQ(r.module_count, 1);
|
||
ASSERT_STR_EQ(r.modules[0].tf_name, "vpc");
|
||
ASSERT_STR_EQ(r.modules[0].source, "./modules/vpc");
|
||
|
||
/* Locals */
|
||
ASSERT(r.has_locals);
|
||
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_terraform_variables_only) {
|
||
/* Port of TestParseTerraformVariablesOnly — secret default filtered */
|
||
const char *src = "\n"
|
||
"variable \"project_id\" {\n"
|
||
" description = \"The GCP project ID\"\n"
|
||
" type = string\n"
|
||
" default = \"example-cloud\"\n"
|
||
"}\n"
|
||
"\n"
|
||
"variable \"secret_key\" {\n"
|
||
" description = \"A secret\"\n"
|
||
" type = string\n"
|
||
" default = \"sk-1234567890abcdef12345\"\n"
|
||
"}\n";
|
||
|
||
cbm_terraform_result_t r;
|
||
ASSERT_EQ(cbm_parse_terraform_source(src, &r), 0);
|
||
ASSERT_EQ(r.variable_count, 2);
|
||
|
||
/* secret_key default should be filtered */
|
||
for (int i = 0; i < r.variable_count; i++) {
|
||
if (strcmp(r.variables[i].name, "secret_key") == 0) {
|
||
ASSERT_STR_EQ(r.variables[i].default_val, "");
|
||
}
|
||
}
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_parse_terraform_empty) {
|
||
/* Port of TestParseTerraformEmpty */
|
||
cbm_terraform_result_t r;
|
||
ASSERT_EQ(cbm_parse_terraform_source("# just comments\n", &r), -1);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Helm Chart.yaml dependency parsing (#338) ──────────────────── */
|
||
|
||
TEST(helm_parse_chart_dependencies_issue338) {
|
||
const char *src = "apiVersion: v2\n"
|
||
"name: mychart\n"
|
||
"version: 1.0.0\n"
|
||
"dependencies:\n"
|
||
" - name: postgresql\n"
|
||
" repository: https://charts.bitnami.com/bitnami\n"
|
||
" version: 12.x.x\n"
|
||
" - name: redis\n"
|
||
" repository: https://charts.bitnami.com/bitnami\n"
|
||
"maintainers:\n"
|
||
" - name: alice\n"; /* not a dependency — outside the block */
|
||
cbm_helm_chart_t hc;
|
||
ASSERT_EQ(cbm_parse_helm_chart(src, &hc), 0);
|
||
ASSERT_STR_EQ(hc.chart_name, "mychart");
|
||
ASSERT_EQ(hc.dep_count, 2);
|
||
ASSERT_STR_EQ(hc.deps[0], "postgresql");
|
||
ASSERT_STR_EQ(hc.deps[1], "redis");
|
||
PASS();
|
||
}
|
||
|
||
TEST(helm_parse_chart_no_deps_issue338) {
|
||
cbm_helm_chart_t hc;
|
||
ASSERT_EQ(cbm_parse_helm_chart("name: solo\nversion: 0.1.0\n", &hc), 0);
|
||
ASSERT_STR_EQ(hc.chart_name, "solo");
|
||
ASSERT_EQ(hc.dep_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Infrascan: infra QN helper ─────────────────────────────────── */
|
||
|
||
/* ── Function Registry / Resolver tests ─────────────────────────── */
|
||
|
||
TEST(registry_resolve_single_candidate) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function");
|
||
cbm_registry_add(reg, "ValidateOrder", "svcB.validators.ValidateOrder", "Function");
|
||
|
||
/* Normal resolve unique name */
|
||
cbm_resolution_t r = cbm_registry_resolve(reg, "CreateOrder", "svcC.caller", NULL, NULL, 0);
|
||
ASSERT_STR_EQ(r.qualified_name, "svcA.handlers.CreateOrder");
|
||
|
||
/* Fuzzy resolve with unknown prefix */
|
||
cbm_fuzzy_result_t fr =
|
||
cbm_registry_fuzzy_resolve(reg, "unknownPkg.CreateOrder", "svcC.caller", NULL, NULL, 0);
|
||
ASSERT_TRUE(fr.ok);
|
||
ASSERT_STR_EQ(fr.result.qualified_name, "svcA.handlers.CreateOrder");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_fuzzy_nonexistent) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function");
|
||
|
||
cbm_fuzzy_result_t fr =
|
||
cbm_registry_fuzzy_resolve(reg, "NonExistent", "svcC.caller", NULL, NULL, 0);
|
||
ASSERT_FALSE(fr.ok);
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_fuzzy_multiple_best_by_distance) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Process", "svcA.handlers.Process", "Function");
|
||
cbm_registry_add(reg, "Process", "svcB.handlers.Process", "Function");
|
||
|
||
/* Caller in svcA → prefer svcA */
|
||
cbm_fuzzy_result_t fr =
|
||
cbm_registry_fuzzy_resolve(reg, "unknown.Process", "svcA.other", NULL, NULL, 0);
|
||
ASSERT_TRUE(fr.ok);
|
||
ASSERT_STR_EQ(fr.result.qualified_name, "svcA.handlers.Process");
|
||
|
||
/* Caller in svcB → prefer svcB */
|
||
fr = cbm_registry_fuzzy_resolve(reg, "unknown.Process", "svcB.other", NULL, NULL, 0);
|
||
ASSERT_TRUE(fr.ok);
|
||
ASSERT_STR_EQ(fr.result.qualified_name, "svcB.handlers.Process");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_fuzzy_simple_name_extraction) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "DoWork", "myproject.utils.DoWork", "Function");
|
||
|
||
/* Deeply qualified name → extract "DoWork" */
|
||
cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(reg, "some.deep.module.DoWork",
|
||
"myproject.caller", NULL, NULL, 0);
|
||
ASSERT_TRUE(fr.ok);
|
||
ASSERT_STR_EQ(fr.result.qualified_name, "myproject.utils.DoWork");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_fuzzy_empty) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
|
||
cbm_fuzzy_result_t fr =
|
||
cbm_registry_fuzzy_resolve(reg, "SomeFunc", "myproject.caller", NULL, NULL, 0);
|
||
ASSERT_FALSE(fr.ok);
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_exists) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Foo", "pkg.module.Foo", "Function");
|
||
cbm_registry_add(reg, "Bar", "pkg.module.Bar", "Method");
|
||
|
||
ASSERT_TRUE(cbm_registry_exists(reg, "pkg.module.Foo"));
|
||
ASSERT_TRUE(cbm_registry_exists(reg, "pkg.module.Bar"));
|
||
ASSERT_FALSE(cbm_registry_exists(reg, "pkg.module.Missing"));
|
||
ASSERT_FALSE(cbm_registry_exists(reg, ""));
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_confidence_import_map) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function");
|
||
|
||
const char *keys[] = {"other"};
|
||
const char *vals[] = {"proj.other"};
|
||
cbm_resolution_t r = cbm_registry_resolve(reg, "other.Foo", "proj.pkg", keys, vals, 1);
|
||
ASSERT_STR_EQ(r.qualified_name, "proj.other.Foo");
|
||
ASSERT(r.confidence > 0.90 && r.confidence <= 1.0);
|
||
ASSERT_STR_EQ(r.strategy, "import_map");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_confidence_import_map_suffix) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Foo", "proj.other.sub.Foo", "Function");
|
||
|
||
const char *keys[] = {"other"};
|
||
const char *vals[] = {"proj.other"};
|
||
cbm_resolution_t r = cbm_registry_resolve(reg, "other.Foo", "proj.pkg", keys, vals, 1);
|
||
ASSERT_STR_EQ(r.qualified_name, "proj.other.sub.Foo");
|
||
ASSERT(r.confidence > 0.80 && r.confidence <= 0.90);
|
||
ASSERT_STR_EQ(r.strategy, "import_map_suffix");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_confidence_same_module) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function");
|
||
|
||
cbm_resolution_t r = cbm_registry_resolve(reg, "Foo", "proj.pkg", NULL, NULL, 0);
|
||
ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Foo");
|
||
ASSERT(r.confidence > 0.85 && r.confidence <= 0.95);
|
||
ASSERT_STR_EQ(r.strategy, "same_module");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_confidence_unique_name) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function");
|
||
|
||
cbm_resolution_t r = cbm_registry_resolve(reg, "Bar", "proj.unrelated", NULL, NULL, 0);
|
||
ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Bar");
|
||
ASSERT(r.confidence > 0.70 && r.confidence <= 0.80);
|
||
ASSERT_STR_EQ(r.strategy, "unique_name");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_confidence_suffix_match) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function");
|
||
cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function");
|
||
|
||
cbm_resolution_t r = cbm_registry_resolve(reg, "Process", "proj.svcA.caller", NULL, NULL, 0);
|
||
ASSERT_STR_EQ(r.qualified_name, "proj.svcA.Process");
|
||
ASSERT(r.confidence > 0.50 && r.confidence <= 0.60);
|
||
ASSERT_STR_EQ(r.strategy, "suffix_match");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_fuzzy_confidence_single) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Handler", "proj.svc.Handler", "Function");
|
||
|
||
cbm_fuzzy_result_t fr =
|
||
cbm_registry_fuzzy_resolve(reg, "unknownPkg.Handler", "proj.caller", NULL, NULL, 0);
|
||
ASSERT_TRUE(fr.ok);
|
||
ASSERT(fr.result.confidence > 0.35 && fr.result.confidence <= 0.45);
|
||
ASSERT_STR_EQ(fr.result.strategy, "fuzzy");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_fuzzy_confidence_distance) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function");
|
||
cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function");
|
||
|
||
cbm_fuzzy_result_t fr =
|
||
cbm_registry_fuzzy_resolve(reg, "unknownPkg.Process", "proj.svcA.other", NULL, NULL, 0);
|
||
ASSERT_TRUE(fr.ok);
|
||
ASSERT(fr.result.confidence > 0.25 && fr.result.confidence <= 0.35);
|
||
ASSERT_STR_EQ(fr.result.strategy, "fuzzy");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_negative_import_rejects) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Process", "proj.billing.Process", "Function");
|
||
cbm_registry_add(reg, "Process", "proj.handler.Process", "Function");
|
||
|
||
/* Import only handler's module → should prefer handler */
|
||
const char *keys[] = {"handler"};
|
||
const char *vals[] = {"proj.handler"};
|
||
cbm_resolution_t r = cbm_registry_resolve(reg, "Process", "proj.caller", keys, vals, 1);
|
||
ASSERT_STR_EQ(r.qualified_name, "proj.handler.Process");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_fuzzy_import_penalty) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function");
|
||
|
||
/* Has imports but billing not imported → confidence halved */
|
||
const char *keys[] = {"other"};
|
||
const char *vals[] = {"proj.other"};
|
||
cbm_fuzzy_result_t fr =
|
||
cbm_registry_fuzzy_resolve(reg, "unknown.Handler", "proj.caller", keys, vals, 1);
|
||
ASSERT_TRUE(fr.ok);
|
||
/* 0.40 * 0.5 = 0.20 */
|
||
ASSERT(fr.result.confidence > 0.15 && fr.result.confidence <= 0.25);
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_fuzzy_no_import_map_passthrough) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function");
|
||
|
||
/* NULL import map → no penalty, full fuzzy confidence */
|
||
cbm_fuzzy_result_t fr =
|
||
cbm_registry_fuzzy_resolve(reg, "unknown.Handler", "proj.caller", NULL, NULL, 0);
|
||
ASSERT_TRUE(fr.ok);
|
||
ASSERT(fr.result.confidence > 0.35 && fr.result.confidence <= 0.45);
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_find_by_name) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function");
|
||
cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function");
|
||
cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function");
|
||
cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method");
|
||
|
||
/* FindByName returns all entries for "Foo" */
|
||
const char **foos = NULL;
|
||
int foos_count = 0;
|
||
cbm_registry_find_by_name(reg, "Foo", &foos, &foos_count);
|
||
ASSERT_EQ(foos_count, 2);
|
||
|
||
/* FindByName for unique "Bar" */
|
||
const char **bars = NULL;
|
||
int bars_count = 0;
|
||
cbm_registry_find_by_name(reg, "Bar", &bars, &bars_count);
|
||
ASSERT_EQ(bars_count, 1);
|
||
ASSERT_STR_EQ(bars[0], "proj.pkg.Bar");
|
||
|
||
/* FindByName for "transform" */
|
||
const char **transforms = NULL;
|
||
int trans_count = 0;
|
||
cbm_registry_find_by_name(reg, "transform", &transforms, &trans_count);
|
||
ASSERT_EQ(trans_count, 1);
|
||
ASSERT_STR_EQ(transforms[0], "proj.utils.DataProcessor.transform");
|
||
|
||
/* label_of */
|
||
ASSERT_STR_EQ(cbm_registry_label_of(reg, "proj.utils.DataProcessor.transform"), "Method");
|
||
ASSERT_STR_EQ(cbm_registry_label_of(reg, "proj.pkg.Foo"), "Function");
|
||
|
||
/* Total size */
|
||
ASSERT_EQ(cbm_registry_size(reg), 4);
|
||
|
||
/* Resolve same-module */
|
||
cbm_resolution_t r = cbm_registry_resolve(reg, "Foo", "proj.pkg", NULL, NULL, 0);
|
||
ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Foo");
|
||
|
||
/* Resolve via import map */
|
||
const char *keys[] = {"other"};
|
||
const char *vals[] = {"proj.other"};
|
||
r = cbm_registry_resolve(reg, "other.Foo", "proj.pkg", keys, vals, 1);
|
||
ASSERT_STR_EQ(r.qualified_name, "proj.other.Foo");
|
||
|
||
/* Resolve unique name */
|
||
r = cbm_registry_resolve(reg, "Bar", "proj.unrelated", NULL, NULL, 0);
|
||
ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Bar");
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
TEST(registry_confidence_band) {
|
||
ASSERT_STR_EQ(cbm_confidence_band(0.95), "high");
|
||
ASSERT_STR_EQ(cbm_confidence_band(0.70), "high");
|
||
ASSERT_STR_EQ(cbm_confidence_band(0.55), "medium");
|
||
ASSERT_STR_EQ(cbm_confidence_band(0.45), "medium");
|
||
ASSERT_STR_EQ(cbm_confidence_band(0.40), "speculative");
|
||
ASSERT_STR_EQ(cbm_confidence_band(0.25), "speculative");
|
||
ASSERT_STR_EQ(cbm_confidence_band(0.20), "");
|
||
ASSERT_STR_EQ(cbm_confidence_band(0.0), "");
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_qn_helper) {
|
||
/* Port of TestInfraQN */
|
||
|
||
/* Regular infra file → __infra__ suffix */
|
||
char *qn = cbm_infra_qn("myproject", "docker-images/service/Dockerfile", "dockerfile", NULL);
|
||
ASSERT_NOT_NULL(qn);
|
||
ASSERT(strstr(qn, ".__infra__") != NULL);
|
||
free(qn);
|
||
|
||
/* Compose service → ::service_name suffix */
|
||
qn = cbm_infra_qn("myproject", "docker-compose.yml", "compose-service", "web");
|
||
ASSERT_NOT_NULL(qn);
|
||
ASSERT(strstr(qn, "::web") != NULL);
|
||
free(qn);
|
||
|
||
PASS();
|
||
}
|
||
|
||
/* ── Infrascan integration tests ────────────────────────────────── */
|
||
|
||
TEST(infra_pipeline_integration) {
|
||
/* Port of TestPassInfraFilesIntegration (Dockerfile + .env parts).
|
||
* Tests parse functions on source text (pipeline infrascan pass not
|
||
* wired yet — compose YAML also blocked on YAML parser). */
|
||
|
||
/* Parse Dockerfile */
|
||
cbm_dockerfile_result_t dr;
|
||
ASSERT_EQ(cbm_parse_dockerfile_source("FROM alpine:3.19\nEXPOSE 8080\n", &dr), 0);
|
||
ASSERT_STR_EQ(dr.base_image, "alpine:3.19");
|
||
ASSERT_GTE(dr.port_count, 1);
|
||
|
||
/* Parse .env */
|
||
cbm_dotenv_result_t er;
|
||
ASSERT_EQ(cbm_parse_dotenv_source("APP_PORT=8080\nDEBUG=true\n", &er), 0);
|
||
ASSERT_GTE(er.env_count, 1);
|
||
/* APP_PORT should be present */
|
||
bool found_port = false;
|
||
for (int i = 0; i < er.env_count; i++) {
|
||
if (strcmp(er.env_vars[i].key, "APP_PORT") == 0 &&
|
||
strcmp(er.env_vars[i].value, "8080") == 0)
|
||
found_port = true;
|
||
}
|
||
ASSERT_TRUE(found_port);
|
||
|
||
PASS();
|
||
}
|
||
|
||
TEST(infra_pipeline_idempotent) {
|
||
/* Port of TestPassInfraFilesIdempotent:
|
||
* Parsing same source twice should produce identical results. */
|
||
const char *src = "FROM alpine:3.19\nEXPOSE 8080\nENV PORT=8080\n";
|
||
cbm_dockerfile_result_t r1, r2;
|
||
ASSERT_EQ(cbm_parse_dockerfile_source(src, &r1), 0);
|
||
ASSERT_EQ(cbm_parse_dockerfile_source(src, &r2), 0);
|
||
|
||
ASSERT_STR_EQ(r1.base_image, r2.base_image);
|
||
ASSERT_EQ(r1.port_count, r2.port_count);
|
||
ASSERT_EQ(r1.env_count, r2.env_count);
|
||
|
||
PASS();
|
||
}
|
||
|
||
/* ── K8s / Kustomize extraction tests ──────────────────────────── */
|
||
|
||
TEST(k8s_extract_kustomize) {
|
||
const char *src = "apiVersion: kustomize.config.k8s.io/v1beta1\n"
|
||
"kind: Kustomization\n"
|
||
"resources:\n"
|
||
" - deployment.yaml\n"
|
||
" - service.yaml\n";
|
||
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_KUSTOMIZE, "myproj",
|
||
"base/kustomization.yaml", 0, NULL, NULL);
|
||
ASSERT(r != NULL);
|
||
ASSERT_GTE(r->imports.count, 2);
|
||
|
||
bool found_deploy = false, found_svc = false;
|
||
for (int i = 0; i < r->imports.count; i++) {
|
||
if (r->imports.items[i].module_path &&
|
||
strcmp(r->imports.items[i].module_path, "deployment.yaml") == 0)
|
||
found_deploy = true;
|
||
if (r->imports.items[i].module_path &&
|
||
strcmp(r->imports.items[i].module_path, "service.yaml") == 0)
|
||
found_svc = true;
|
||
}
|
||
ASSERT_TRUE(found_deploy);
|
||
ASSERT_TRUE(found_svc);
|
||
|
||
cbm_free_result(r);
|
||
PASS();
|
||
}
|
||
|
||
TEST(k8s_extract_manifest) {
|
||
const char *src = "apiVersion: apps/v1\n"
|
||
"kind: Deployment\n"
|
||
"metadata:\n"
|
||
" name: my-app\n"
|
||
" namespace: production\n";
|
||
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, "myproj",
|
||
"k8s/deployment.yaml", 0, NULL, NULL);
|
||
ASSERT(r != NULL);
|
||
ASSERT_GTE(r->defs.count, 1);
|
||
|
||
bool found_resource = false;
|
||
for (int d = 0; d < r->defs.count; d++) {
|
||
if (r->defs.items[d].label && strcmp(r->defs.items[d].label, "Resource") == 0 &&
|
||
r->defs.items[d].name && strstr(r->defs.items[d].name, "Deployment") != NULL)
|
||
found_resource = true;
|
||
}
|
||
ASSERT_TRUE(found_resource);
|
||
|
||
cbm_free_result(r);
|
||
PASS();
|
||
}
|
||
|
||
TEST(k8s_extract_manifest_no_name) {
|
||
const char *src = "apiVersion: apps/v1\nkind: Deployment\n";
|
||
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, "myproj",
|
||
"k8s/deploy.yaml", 0, NULL, NULL);
|
||
ASSERT(r != NULL);
|
||
/* No crash — defs count may be 0 because metadata.name is absent */
|
||
ASSERT(!r->has_error);
|
||
cbm_free_result(r);
|
||
PASS();
|
||
}
|
||
|
||
TEST(k8s_extract_manifest_multidoc) {
|
||
/* Two-document YAML separated by "---".
|
||
* extract_k8s_manifest contains a "break" after the first successful push,
|
||
* so it processes only the first document that has both kind and
|
||
* metadata.name. This test pins that behaviour: the first document's
|
||
* resource must be present and no crash must occur.
|
||
*
|
||
* Note: with some tree-sitter YAML grammar versions the root stream may
|
||
* expose both documents as siblings; the break still fires after the first
|
||
* successful def push, so defs.count must be exactly 1. */
|
||
const char *src = "apiVersion: apps/v1\n"
|
||
"kind: Deployment\n"
|
||
"metadata:\n"
|
||
" name: my-app\n"
|
||
"---\n"
|
||
"apiVersion: v1\n"
|
||
"kind: Service\n"
|
||
"metadata:\n"
|
||
" name: my-svc\n";
|
||
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, "myproj",
|
||
"k8s/multi.yaml", 0, NULL, NULL);
|
||
ASSERT(r != NULL);
|
||
ASSERT(!r->has_error);
|
||
/* First document's resource must be present */
|
||
int found = 0;
|
||
for (int i = 0; i < r->defs.count; i++) {
|
||
if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Resource") == 0 &&
|
||
r->defs.items[i].name && strcmp(r->defs.items[i].name, "Deployment/my-app") == 0) {
|
||
found = 1;
|
||
}
|
||
}
|
||
ASSERT(found);
|
||
/* At least one def, no more than one (only first document processed) */
|
||
ASSERT(r->defs.count >= 1);
|
||
cbm_free_result(r);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Envscan tests (port of envscan_test.go) ───────────────────── */
|
||
|
||
/* Helper: write a file inside a temp dir */
|
||
static void write_temp_file(const char *dir, const char *name, const char *content) {
|
||
char path[512];
|
||
/* Create subdirectories if needed */
|
||
snprintf(path, sizeof(path), "%s/%s", dir, name);
|
||
char *slash = strrchr(path, '/');
|
||
if (slash) {
|
||
char parent[512];
|
||
size_t plen = slash - path;
|
||
memcpy(parent, path, plen);
|
||
parent[plen] = '\0';
|
||
/* mkdir -p (simple version, one level) */
|
||
cbm_mkdir(parent);
|
||
}
|
||
FILE *f = fopen(path, "w");
|
||
if (f) {
|
||
fputs(content, f);
|
||
fclose(f);
|
||
}
|
||
}
|
||
|
||
/* Helper: find binding by key in results */
|
||
static const cbm_env_binding_t *find_binding_by_key(const cbm_env_binding_t *bindings, int count,
|
||
const char *key) {
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(bindings[i].key, key) == 0)
|
||
return &bindings[i];
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* Helper: find binding by value in results */
|
||
static int has_binding_value(const cbm_env_binding_t *bindings, int count, const char *value) {
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(bindings[i].value, value) == 0)
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
TEST(envscan_dockerfile_env_urls) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_dock_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, "Dockerfile",
|
||
"FROM python:3.9-slim\n"
|
||
"ENV ORDER_URL=https://api.example.com/api/orders\n"
|
||
"ENV DB_HOST=localhost\n"
|
||
"ARG WEBHOOK_URL=https://hooks.example.com/webhook\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "ORDER_URL"));
|
||
ASSERT_STR_EQ(find_binding_by_key(bindings, count, "ORDER_URL")->value,
|
||
"https://api.example.com/api/orders");
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "WEBHOOK_URL"));
|
||
ASSERT_STR_EQ(find_binding_by_key(bindings, count, "WEBHOOK_URL")->value,
|
||
"https://hooks.example.com/webhook");
|
||
/* DB_HOST=localhost is NOT a URL → should be absent */
|
||
ASSERT_TRUE(find_binding_by_key(bindings, count, "DB_HOST") == NULL);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_shell_env_urls) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_sh_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, "setup.sh",
|
||
"#!/bin/bash\n"
|
||
"export DB_URL=\"https://db.example.com/api/sync\"\n"
|
||
"APP_NAME=\"my-service\"\n"
|
||
"CALLBACK_URL=https://hooks.example.com/notify\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "DB_URL"));
|
||
ASSERT_STR_EQ(find_binding_by_key(bindings, count, "DB_URL")->value,
|
||
"https://db.example.com/api/sync");
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CALLBACK_URL"));
|
||
/* APP_NAME is NOT a URL → absent */
|
||
ASSERT_TRUE(find_binding_by_key(bindings, count, "APP_NAME") == NULL);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_env_file_urls) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_env_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, ".env",
|
||
"\nAPI_URL=https://api.example.com/v1\n"
|
||
"DEBUG=true\n"
|
||
"SERVICE_URL=https://service.example.com/api\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "API_URL"));
|
||
ASSERT_STR_EQ(find_binding_by_key(bindings, count, "API_URL")->value,
|
||
"https://api.example.com/v1");
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "SERVICE_URL"));
|
||
/* DEBUG=true is NOT a URL */
|
||
ASSERT_TRUE(find_binding_by_key(bindings, count, "DEBUG") == NULL);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_toml_urls) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_toml_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, "config.toml",
|
||
"[service]\n"
|
||
"base_url = \"https://api.example.com\"\n"
|
||
"name = \"my-service\"\n"
|
||
"callback_url = \"https://hooks.example.com/notify\"\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "base_url"));
|
||
ASSERT_STR_EQ(find_binding_by_key(bindings, count, "base_url")->value,
|
||
"https://api.example.com");
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "callback_url"));
|
||
/* name="my-service" is NOT a URL */
|
||
ASSERT_TRUE(find_binding_by_key(bindings, count, "name") == NULL);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_yaml_urls) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_yaml_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, "config.yaml",
|
||
"service:\n"
|
||
" service_url: \"https://api.internal.com/api/process\"\n"
|
||
" timeout: 30\n"
|
||
" callback_url: \"https://hooks.internal.com/callback\"\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "service_url"));
|
||
ASSERT_STR_EQ(find_binding_by_key(bindings, count, "service_url")->value,
|
||
"https://api.internal.com/api/process");
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "callback_url"));
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_terraform_urls) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_tf_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, "variables.tf",
|
||
"variable \"webhook_url\" {\n"
|
||
" description = \"Webhook endpoint\"\n"
|
||
" default = \"https://api.example.com/webhook\"\n"
|
||
"}\n\n"
|
||
"variable \"region\" {\n"
|
||
" default = \"us-east-1\"\n"
|
||
"}\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
ASSERT_GTE(count, 1);
|
||
ASSERT_TRUE(has_binding_value(bindings, count, "https://api.example.com/webhook"));
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_properties_urls) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_prop_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, "app.properties",
|
||
"api.url=https://api.example.com/health\n"
|
||
"app.name=myapp\n"
|
||
"service.endpoint=https://service.example.com/api\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
ASSERT_TRUE(has_binding_value(bindings, count, "https://api.example.com/health"));
|
||
ASSERT_TRUE(has_binding_value(bindings, count, "https://service.example.com/api"));
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_secret_key_exclusion) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_skey_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, "Dockerfile",
|
||
"FROM node:18\n"
|
||
"ENV SECRET_TOKEN=https://api.example.com/api\n"
|
||
"ENV API_KEY=https://api.example.com/v1\n"
|
||
"ENV PASSWORD=https://auth.example.com/login\n"
|
||
"ENV NORMAL_URL=https://api.example.com/orders\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
/* Secret keys should be excluded */
|
||
ASSERT_TRUE(find_binding_by_key(bindings, count, "SECRET_TOKEN") == NULL);
|
||
ASSERT_TRUE(find_binding_by_key(bindings, count, "API_KEY") == NULL);
|
||
ASSERT_TRUE(find_binding_by_key(bindings, count, "PASSWORD") == NULL);
|
||
/* Normal key should be present */
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "NORMAL_URL"));
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_secret_value_exclusion) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_sval_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(
|
||
tmpdir, "deploy.sh",
|
||
"#!/bin/bash\n"
|
||
"export GH_URL=\"https://ghp_FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE@github.com/repo\"\n"
|
||
"export NORMAL_ENDPOINT=\"https://api.example.com/orders\"\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
/* ghp_ token URL should be excluded */
|
||
ASSERT_TRUE(find_binding_by_key(bindings, count, "GH_URL") == NULL);
|
||
/* Normal URL should be present */
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "NORMAL_ENDPOINT"));
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_secret_file_exclusion) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_sfile_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
/* Secret file should be skipped */
|
||
write_temp_file(tmpdir, "credentials.sh",
|
||
"#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n");
|
||
/* Normal file should be scanned */
|
||
write_temp_file(tmpdir, "setup.sh",
|
||
"#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
/* Should find binding from setup.sh but not credentials.sh */
|
||
int from_credentials = 0;
|
||
int from_setup = 0;
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(bindings[i].file_path, "credentials.sh") == 0)
|
||
from_credentials = 1;
|
||
if (strcmp(bindings[i].file_path, "setup.sh") == 0)
|
||
from_setup = 1;
|
||
}
|
||
ASSERT_EQ(from_credentials, 0);
|
||
ASSERT_EQ(from_setup, 1);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_skips_ignored_dirs) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_ign_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
/* File inside .git should be skipped */
|
||
char gitdir[512];
|
||
snprintf(gitdir, sizeof(gitdir), "%s/.git", tmpdir);
|
||
cbm_mkdir(gitdir);
|
||
write_temp_file(tmpdir, ".git/config.sh",
|
||
"#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n");
|
||
|
||
/* File inside node_modules should be skipped */
|
||
char nmdir[512];
|
||
snprintf(nmdir, sizeof(nmdir), "%s/node_modules", tmpdir);
|
||
cbm_mkdir(nmdir);
|
||
char nmpkg[512];
|
||
snprintf(nmpkg, sizeof(nmpkg), "%s/node_modules/pkg", tmpdir);
|
||
cbm_mkdir(nmpkg);
|
||
write_temp_file(tmpdir, "node_modules/pkg/config.sh",
|
||
"#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n");
|
||
|
||
/* File at root level should be scanned */
|
||
write_temp_file(tmpdir, "deploy.sh",
|
||
"#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
int from_git = 0, from_nm = 0, from_root = 0;
|
||
for (int i = 0; i < count; i++) {
|
||
if (strncmp(bindings[i].file_path, ".git/", 5) == 0)
|
||
from_git = 1;
|
||
if (strncmp(bindings[i].file_path, "node_modules/", 13) == 0)
|
||
from_nm = 1;
|
||
if (strcmp(bindings[i].file_path, "deploy.sh") == 0)
|
||
from_root = 1;
|
||
}
|
||
ASSERT_EQ(from_git, 0);
|
||
ASSERT_EQ(from_nm, 0);
|
||
ASSERT_EQ(from_root, 1);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(envscan_non_url_values_skipped) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_nurl_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, "Dockerfile",
|
||
"FROM python:3.9\n"
|
||
"ENV APP_NAME=my-service\n"
|
||
"ENV PORT=8080\n"
|
||
"ENV DEBUG=true\n"
|
||
"ENV LOG_LEVEL=info\n");
|
||
write_temp_file(tmpdir, "config.sh",
|
||
"#!/bin/bash\n"
|
||
"export REGION=\"us-east-1\"\n"
|
||
"export COUNT=42\n");
|
||
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
|
||
ASSERT_EQ(count, 0);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Discovery-exclusion plumbing in auxiliary repo walks (#792) ── */
|
||
|
||
/* Boundary semantics of the shared exclusion predicate: anchored at the
|
||
* repo root, matches the excluded dir itself and its subtree, but never
|
||
* sibling names sharing a prefix. Regression guard for issue #792. */
|
||
TEST(pipeline_relpath_excluded_boundary) {
|
||
char *excluded[] = {(char *)"vendor_big", (char *)"packages/big"};
|
||
|
||
/* Exact match and subtree paths are excluded. */
|
||
ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("vendor_big", excluded, 2));
|
||
ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("vendor_big/lib/package.json", excluded, 2));
|
||
ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("packages/big", excluded, 2));
|
||
ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("packages/big/src/x.ts", excluded, 2));
|
||
|
||
/* Sibling names sharing the prefix are NOT excluded ('/'-boundary). */
|
||
ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_bigger", excluded, 2));
|
||
ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor", excluded, 2));
|
||
ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("packages/bigger/x.ts", excluded, 2));
|
||
|
||
/* Exclusions are root-anchored prefixes, not substring matches. */
|
||
ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("src/vendor_big/x.c", excluded, 2));
|
||
|
||
/* NULL / empty safety. */
|
||
ASSERT_FALSE(cbm_pipeline_relpath_is_excluded(NULL, excluded, 2));
|
||
ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("", excluded, 2));
|
||
ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_big", NULL, 0));
|
||
ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_big", excluded, 0));
|
||
char *with_empty[] = {(char *)""};
|
||
ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_big", with_empty, 1));
|
||
PASS();
|
||
}
|
||
|
||
/* Helper: does the entries array contain a package with this name? */
|
||
static int pkg_entries_has_name(const cbm_pkg_entries_t *e, const char *name) {
|
||
for (int i = 0; i < e->count; i++) {
|
||
if (e->items[i].pkg_name && strcmp(e->items[i].pkg_name, name) == 0)
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* The pkgmap repo walk must honor discovery exclusions (issue #792: a
|
||
* gitignored huge subtree kept the pkgmap walk busy for 15 minutes).
|
||
* Control run first (no exclusions → BOTH manifests parsed) so the
|
||
* exclusion assertion below cannot pass vacuously. */
|
||
TEST(pkgmap_scan_repo_honors_discovery_exclusions) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pkgmap_excl_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
char dir[512];
|
||
snprintf(dir, sizeof(dir), "%s/packages", tmpdir);
|
||
cbm_mkdir(dir);
|
||
snprintf(dir, sizeof(dir), "%s/packages/app", tmpdir);
|
||
cbm_mkdir(dir);
|
||
snprintf(dir, sizeof(dir), "%s/vendor_big", tmpdir);
|
||
cbm_mkdir(dir);
|
||
snprintf(dir, sizeof(dir), "%s/vendor_big/lib", tmpdir);
|
||
cbm_mkdir(dir);
|
||
|
||
write_temp_file(tmpdir, "packages/app/package.json",
|
||
"{\"name\":\"@org/app\",\"main\":\"index.js\"}\n");
|
||
write_temp_file(tmpdir, "vendor_big/lib/package.json",
|
||
"{\"name\":\"@org/vendored\",\"main\":\"index.js\"}\n");
|
||
|
||
/* Control: NULL exclusion list — the walk reaches and parses BOTH
|
||
* manifests (proves the excluded one is reachable + parseable). */
|
||
cbm_pkg_entries_t control;
|
||
cbm_pkg_entries_init(&control);
|
||
cbm_pkgmap_scan_repo(tmpdir, &control, NULL, 0);
|
||
ASSERT_TRUE(pkg_entries_has_name(&control, "@org/app"));
|
||
ASSERT_TRUE(pkg_entries_has_name(&control, "@org/vendored"));
|
||
cbm_pkg_entries_free(&control);
|
||
|
||
/* With vendor_big excluded (as discovery reports for a gitignored
|
||
* subtree): the walk must not descend into it. */
|
||
char *excluded[] = {(char *)"vendor_big"};
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
cbm_pkgmap_scan_repo(tmpdir, &entries, excluded, 1);
|
||
ASSERT_TRUE(pkg_entries_has_name(&entries, "@org/app"));
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "@org/vendored"));
|
||
cbm_pkg_entries_free(&entries);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
/* The env-URL walk must honor discovery exclusions the same way (#792).
|
||
* Control run first via the NULL-exclusion wrapper so the exclusion
|
||
* assertion cannot pass vacuously. */
|
||
TEST(envscan_walk_honors_discovery_exclusions) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_excl_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
char dir[512];
|
||
snprintf(dir, sizeof(dir), "%s/big_generated", tmpdir);
|
||
cbm_mkdir(dir);
|
||
|
||
write_temp_file(tmpdir, "deploy.sh",
|
||
"#!/bin/bash\nexport CONTROL_URL=\"https://api.example.com/v1\"\n");
|
||
write_temp_file(tmpdir, "big_generated/env.sh",
|
||
"#!/bin/bash\nexport EXCLUDED_URL=\"https://excluded.example.com/v1\"\n");
|
||
|
||
/* Control: the NULL-exclusion wrapper sees both bindings. */
|
||
cbm_env_binding_t bindings[32];
|
||
int count = cbm_scan_project_env_urls(tmpdir, bindings, 32);
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CONTROL_URL"));
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "EXCLUDED_URL"));
|
||
|
||
/* With big_generated excluded, its binding must disappear. */
|
||
char *excluded[] = {(char *)"big_generated"};
|
||
count = cbm_scan_project_env_urls_excluded(tmpdir, bindings, 32, excluded, 1);
|
||
ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CONTROL_URL"));
|
||
ASSERT_TRUE(find_binding_by_key(bindings, count, "EXCLUDED_URL") == NULL);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Git history tests (port of githistory_test.go) ────────────── */
|
||
|
||
/* Port of Go TestIsTrackableFile from githistory_test.go */
|
||
TEST(githistory_is_trackable_file) {
|
||
/* Source files — trackable */
|
||
ASSERT_TRUE(cbm_is_trackable_file("main.go"));
|
||
ASSERT_TRUE(cbm_is_trackable_file("src/app.py"));
|
||
ASSERT_TRUE(cbm_is_trackable_file("README.md"));
|
||
|
||
/* node_modules — not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("node_modules/foo/bar.js"));
|
||
/* vendor — not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("vendor/lib/dep.go"));
|
||
/* Lock files — not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("package-lock.json"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("go.sum"));
|
||
/* Binary/assets — not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("image.png"));
|
||
/* .git directory — not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file(".git/config"));
|
||
/* __pycache__ — not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("__pycache__/mod.pyc"));
|
||
/* Minified files — not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("src/style.min.css"));
|
||
PASS();
|
||
}
|
||
|
||
/* Port of Go TestComputeChangeCoupling from githistory_test.go */
|
||
TEST(githistory_compute_change_coupling) {
|
||
/* 5 commits:
|
||
* aaa: a.go, b.go, c.go
|
||
* bbb: a.go, b.go
|
||
* ccc: a.go, b.go
|
||
* ddd: a.go, c.go
|
||
* eee: d.go, e.go
|
||
*/
|
||
char *files_aaa[] = {"a.go", "b.go", "c.go"};
|
||
char *files_bbb[] = {"a.go", "b.go"};
|
||
char *files_ccc[] = {"a.go", "b.go"};
|
||
char *files_ddd[] = {"a.go", "c.go"};
|
||
char *files_eee[] = {"d.go", "e.go"};
|
||
|
||
cbm_commit_files_t commits[5] = {
|
||
{files_aaa, 3, 0}, {files_bbb, 2, 0}, {files_ccc, 2, 0},
|
||
{files_ddd, 2, 0}, {files_eee, 2, 0},
|
||
};
|
||
|
||
cbm_change_coupling_t out[100];
|
||
int count = cbm_compute_change_coupling(commits, 5, out, 100);
|
||
|
||
/* a.go + b.go co-change 3 times → should be in results */
|
||
bool found_ab = false;
|
||
for (int i = 0; i < count; i++) {
|
||
if ((strcmp(out[i].file_a, "a.go") == 0 && strcmp(out[i].file_b, "b.go") == 0) ||
|
||
(strcmp(out[i].file_a, "b.go") == 0 && strcmp(out[i].file_b, "a.go") == 0)) {
|
||
found_ab = true;
|
||
ASSERT_EQ(out[i].co_change_count, 3);
|
||
ASSERT(out[i].coupling_score >= 0.9);
|
||
}
|
||
}
|
||
ASSERT_TRUE(found_ab);
|
||
|
||
/* d.go + e.go co-change only 1 time → below threshold of 3 */
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(out[i].file_a, "d.go") == 0 || strcmp(out[i].file_b, "d.go") == 0) {
|
||
ASSERT(0); /* d.go should not appear */
|
||
}
|
||
}
|
||
PASS();
|
||
}
|
||
|
||
/* Port of Go TestComputeChangeCouplingSkipsLargeCommits from githistory_test.go */
|
||
TEST(githistory_coupling_skips_large_commits) {
|
||
/* 25 files in one commit → exceeds 20-file threshold */
|
||
char *files[25];
|
||
char bufs[25][32];
|
||
for (int i = 0; i < 25; i++) {
|
||
snprintf(bufs[i], sizeof(bufs[i]), "file%d.go", i);
|
||
files[i] = bufs[i];
|
||
}
|
||
cbm_commit_files_t commits[1] = {{files, 25, 0}};
|
||
|
||
cbm_change_coupling_t out[100];
|
||
int count = cbm_compute_change_coupling(commits, 1, out, 100);
|
||
ASSERT_EQ(count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* Port of Go TestComputeChangeCouplingLimitsTo100 from githistory_test.go */
|
||
TEST(githistory_coupling_limits_output) {
|
||
/* Generate many small commits to create >100 couplings.
|
||
* 50 files, each pair committed 3 times. max_out=100. */
|
||
int idx = 0;
|
||
char *pair_files[2450][2]; /* 50*49/2 pairs * 3 repetitions = 3675 commits */
|
||
char pair_bufs[2450][2][32];
|
||
cbm_commit_files_t commits[3675];
|
||
int ci = 0;
|
||
for (int i = 0; i < 50 && ci < 3675; i++) {
|
||
for (int j = i + 1; j < 50 && ci < 3675; j++) {
|
||
for (int k = 0; k < 3 && ci < 3675; k++) {
|
||
snprintf(pair_bufs[idx][0], 32, "f%d.go", i);
|
||
snprintf(pair_bufs[idx][1], 32, "f%d.go", j);
|
||
pair_files[idx][0] = pair_bufs[idx][0];
|
||
pair_files[idx][1] = pair_bufs[idx][1];
|
||
commits[ci].files = pair_files[idx];
|
||
commits[ci].count = 2;
|
||
ci++;
|
||
idx++;
|
||
if (idx >= 2450)
|
||
idx = 0; /* reuse buffer space */
|
||
}
|
||
}
|
||
}
|
||
|
||
cbm_change_coupling_t out[200];
|
||
int count = cbm_compute_change_coupling(commits, ci, out, 100);
|
||
ASSERT(count <= 100);
|
||
PASS();
|
||
}
|
||
|
||
/* Port of Go TestIsImportReachable from resolver_test.go */
|
||
TEST(registry_is_import_reachable) {
|
||
const char *import_vals[] = {"proj.handler", "proj.shared.utils"};
|
||
|
||
/* Exact match: proj.handler.Process → true */
|
||
ASSERT_TRUE(cbm_registry_is_import_reachable("proj.handler.Process", import_vals, 2));
|
||
/* Sub-package: proj.handler.sub.Process → true (handler contains handler) */
|
||
ASSERT_TRUE(cbm_registry_is_import_reachable("proj.handler.sub.Process", import_vals, 2));
|
||
/* Nested match: proj.shared.utils.Helper → true */
|
||
ASSERT_TRUE(cbm_registry_is_import_reachable("proj.shared.utils.Helper", import_vals, 2));
|
||
/* Unrelated: proj.billing.Process → false */
|
||
ASSERT_FALSE(cbm_registry_is_import_reachable("proj.billing.Process", import_vals, 2));
|
||
/* Completely unrelated: unrelated.pkg.Func → false */
|
||
ASSERT_FALSE(cbm_registry_is_import_reachable("unrelated.pkg.Func", import_vals, 2));
|
||
PASS();
|
||
}
|
||
|
||
/* Port of FindEndingWith portion from Go TestFunctionRegistry in pipeline_test.go */
|
||
TEST(registry_find_ending_with) {
|
||
cbm_registry_t *reg = cbm_registry_new();
|
||
cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function");
|
||
cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function");
|
||
cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function");
|
||
cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method");
|
||
|
||
/* FindEndingWith "DataProcessor.transform" → 1 match */
|
||
const char **matches = NULL;
|
||
int count = cbm_registry_find_ending_with(reg, "DataProcessor.transform", &matches);
|
||
ASSERT_EQ(count, 1);
|
||
ASSERT_STR_EQ(matches[0], "proj.utils.DataProcessor.transform");
|
||
free(matches);
|
||
|
||
/* FindEndingWith "Foo" → 2 matches */
|
||
matches = NULL;
|
||
count = cbm_registry_find_ending_with(reg, "Foo", &matches);
|
||
ASSERT_EQ(count, 2);
|
||
/* Both should be present (order may vary) */
|
||
bool found_pkg = false, found_other = false;
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(matches[i], "proj.pkg.Foo") == 0)
|
||
found_pkg = true;
|
||
if (strcmp(matches[i], "proj.other.Foo") == 0)
|
||
found_other = true;
|
||
}
|
||
ASSERT_TRUE(found_pkg);
|
||
ASSERT_TRUE(found_other);
|
||
free(matches);
|
||
|
||
/* FindEndingWith "Nonexistent" → 0 matches */
|
||
matches = NULL;
|
||
count = cbm_registry_find_ending_with(reg, "Nonexistent", &matches);
|
||
ASSERT_EQ(count, 0);
|
||
|
||
cbm_registry_free(reg);
|
||
PASS();
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
* Incremental reindex tests
|
||
* ═══════════════════════════════════════════════════════════════════ */
|
||
|
||
/* Helper: create a simple 2-file Go project for incremental tests */
|
||
static char g_incr_tmpdir[256];
|
||
static char g_incr_dbpath[512];
|
||
|
||
static int setup_incremental_repo(void) {
|
||
snprintf(g_incr_tmpdir, sizeof(g_incr_tmpdir), "/tmp/cbm_incr_XXXXXX");
|
||
if (!cbm_mkdtemp(g_incr_tmpdir)) {
|
||
return -1;
|
||
}
|
||
snprintf(g_incr_dbpath, sizeof(g_incr_dbpath), "%s/test.db", g_incr_tmpdir);
|
||
|
||
char path[512];
|
||
FILE *f;
|
||
|
||
/* main.go — calls Helper() */
|
||
snprintf(path, sizeof(path), "%s/main.go", g_incr_tmpdir);
|
||
f = fopen(path, "w");
|
||
if (!f) {
|
||
return -1;
|
||
}
|
||
fprintf(f, "package main\n\nfunc main() {\n\tHelper()\n}\n");
|
||
fclose(f);
|
||
|
||
/* helper.go — defines Helper() */
|
||
snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir);
|
||
f = fopen(path, "w");
|
||
if (!f) {
|
||
return -1;
|
||
}
|
||
fprintf(f, "package main\n\nfunc Helper() string {\n\treturn \"hello\"\n}\n");
|
||
fclose(f);
|
||
|
||
return 0;
|
||
}
|
||
|
||
static void cleanup_incremental_repo(void) {
|
||
th_rmtree(g_incr_tmpdir);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
* FastAPI Depends() edge tracking (PR #66, fix #27)
|
||
* ═══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(pipeline_fastapi_depends_edges) {
|
||
/* Depends(get_current_user) should produce a CALLS edge from the
|
||
* endpoint to the dependency function. */
|
||
const char *files[] = {"auth.py", "routes.py"};
|
||
const char *contents[] = {/* auth.py: defines get_current_user */
|
||
"def get_current_user(token: str):\n"
|
||
" return decode_token(token)\n",
|
||
/* routes.py: endpoint depends on get_current_user */
|
||
"from fastapi import Depends\n"
|
||
"from auth import get_current_user\n\n"
|
||
"def get_profile(user = Depends(get_current_user)):\n"
|
||
" return {\"user\": user}\n"};
|
||
if (setup_lang_repo(files, contents, 2) != 0) {
|
||
FAIL("tmpdir");
|
||
}
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *proj = cbm_pipeline_project_name(p);
|
||
|
||
/* Check CALLS edges for fastapi_depends strategy */
|
||
cbm_edge_t *edges = NULL;
|
||
int edge_count = 0;
|
||
cbm_store_find_edges_by_type(s, proj, "CALLS", &edges, &edge_count);
|
||
|
||
bool found_depends_edge = false;
|
||
for (int i = 0; i < edge_count; i++) {
|
||
if (edges[i].properties_json && strstr(edges[i].properties_json, "fastapi_depends")) {
|
||
found_depends_edge = true;
|
||
break;
|
||
}
|
||
}
|
||
if (edges) {
|
||
cbm_store_free_edges(edges, edge_count);
|
||
}
|
||
ASSERT_TRUE(found_depends_edge);
|
||
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
teardown_lang_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* DLL resolve test removed — feature removed due to Windows Defender
|
||
* false positive (Wacatac.B!ml). See issue #89. */
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
* Incremental reindex
|
||
* ═══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(incremental_full_then_noop) {
|
||
/* Full index, then re-run → should detect no changes and skip */
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
|
||
/* First: full index */
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
char *project = strdup(cbm_pipeline_project_name(p));
|
||
cbm_pipeline_free(p);
|
||
|
||
/* Verify nodes exist */
|
||
cbm_store_t *s = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
int nodes_before = cbm_store_count_nodes(s, project);
|
||
ASSERT_GT(nodes_before, 0);
|
||
cbm_store_close(s);
|
||
|
||
/* Second: incremental — nothing changed → should be no-op */
|
||
p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
cbm_pipeline_free(p);
|
||
|
||
s = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
int nodes_after = cbm_store_count_nodes(s, project);
|
||
/* Node count should be same (no duplicates, no loss) */
|
||
ASSERT_EQ(nodes_after, nodes_before);
|
||
cbm_store_close(s);
|
||
free(project);
|
||
|
||
cleanup_incremental_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(incremental_detects_changed_file) {
|
||
/* Full index, modify one file, re-index → changed file re-parsed */
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
|
||
/* First: full index */
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
char *project = strdup(cbm_pipeline_project_name(p));
|
||
cbm_pipeline_free(p);
|
||
|
||
/* Modify helper.go — add a new function */
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir);
|
||
FILE *f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "package main\n\n"
|
||
"func Helper() string {\n\treturn \"hello\"\n}\n\n"
|
||
"func NewFunc() int {\n\treturn 42\n}\n");
|
||
fclose(f);
|
||
|
||
/* Second: incremental — should detect change and re-index */
|
||
p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
/* Verify node count increased (NewFunc was added) */
|
||
cbm_store_t *s = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
int nodes_after = cbm_store_count_nodes(s, project);
|
||
ASSERT_GT(nodes_after, 0);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
free(project);
|
||
|
||
cleanup_incremental_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(incremental_detects_deleted_file) {
|
||
/* Full index, delete a file, re-index → deleted file's nodes removed */
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
|
||
/* First: full index */
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
char *project = strdup(cbm_pipeline_project_name(p));
|
||
cbm_pipeline_free(p);
|
||
|
||
/* Delete helper.go */
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir);
|
||
unlink(path);
|
||
|
||
/* Second: incremental — should remove Helper nodes */
|
||
p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
/* Verify node count decreased (Helper's file was deleted) */
|
||
cbm_store_t *s = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
int nodes_after = cbm_store_count_nodes(s, project);
|
||
ASSERT_GT(nodes_after, 0); /* still has main.go nodes */
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
free(project);
|
||
|
||
cleanup_incremental_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(incremental_new_file_added) {
|
||
/* Full index, add a new file, re-index → new file's nodes appear */
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
|
||
/* First: full index */
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
char *project = strdup(cbm_pipeline_project_name(p));
|
||
cbm_pipeline_free(p);
|
||
|
||
/* Add extra.go */
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/extra.go", g_incr_tmpdir);
|
||
FILE *f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "package main\n\nfunc Extra() bool {\n\treturn true\n}\n");
|
||
fclose(f);
|
||
|
||
/* Second: incremental — should pick up Extra */
|
||
p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
int nodes_after = cbm_store_count_nodes(s, project);
|
||
ASSERT_GT(nodes_after, 0);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
free(project);
|
||
|
||
cleanup_incremental_repo();
|
||
PASS();
|
||
}
|
||
|
||
TEST(incremental_fast_preserves_mode_skipped_tools_dir) {
|
||
/* Regression: 2026-04-13. A fast-mode reindex after a full-mode index
|
||
* was silently destroying every file under FAST_SKIP_DIRS directories
|
||
* (`tools`, `scripts`, `bin`, `build`, `docs`, ...) by classifying them
|
||
* as deleted in find_deleted_files even though they still existed on
|
||
* disk. The Skyline graph lost packages/mcp/src/tools/ (18 files / ~500
|
||
* nodes) mid-session when a concurrent /develop run obediently called
|
||
* mode='fast'. This test pins the additive semantics: lesser-mode
|
||
* reindexes must NOT delete files that are merely outside the current
|
||
* pass's discovery scope. Fix: find_deleted_files now stat()s each
|
||
* stored-but-missing file and only purges it if it is truly absent
|
||
* from disk. */
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_modeskip_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
char dbpath[512];
|
||
snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir);
|
||
|
||
char path[512];
|
||
FILE *f;
|
||
|
||
/* main.go — root-level production code (visible in fast and full) */
|
||
snprintf(path, sizeof(path), "%s/main.go", tmpdir);
|
||
f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "package main\n\nfunc main() {\n}\n");
|
||
fclose(f);
|
||
|
||
/* tools/util.go — production code under a FAST_SKIP_DIRS directory.
|
||
* Full mode indexes it; fast mode skips it via the discover.c heuristic. */
|
||
char tools_dir[512];
|
||
snprintf(tools_dir, sizeof(tools_dir), "%s/tools", tmpdir);
|
||
cbm_mkdir_p(tools_dir, 0755);
|
||
snprintf(path, sizeof(path), "%s/tools/util.go", tmpdir);
|
||
f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "package tools\n\nfunc Util() string {\n\treturn \"u\"\n}\n");
|
||
fclose(f);
|
||
|
||
/* Step 1: full-mode index — both files should be present */
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
char *project = strdup(cbm_pipeline_project_name(p));
|
||
cbm_pipeline_free(p);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
cbm_node_t *tools_nodes_before = NULL;
|
||
int tools_count_before = 0;
|
||
cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_before,
|
||
&tools_count_before);
|
||
ASSERT_GT(tools_count_before, 0); /* full mode must see tools/util.go */
|
||
cbm_store_free_nodes(tools_nodes_before, tools_count_before);
|
||
int total_before = cbm_store_count_nodes(s, project);
|
||
cbm_store_close(s);
|
||
|
||
/* Step 2: fast-mode reindex — tools/util.go MUST survive (additive semantics) */
|
||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
cbm_pipeline_free(p);
|
||
|
||
s = cbm_store_open_path(dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
cbm_node_t *tools_nodes_after = NULL;
|
||
int tools_count_after = 0;
|
||
cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_after,
|
||
&tools_count_after);
|
||
/* The critical assertion: tools/util.go nodes must still be present after
|
||
* a fast-mode reindex that skipped the tools/ directory. Before the fix,
|
||
* this was 0. */
|
||
ASSERT_GT(tools_count_after, 0);
|
||
ASSERT_EQ(tools_count_after, tools_count_before); /* same nodes, untouched */
|
||
cbm_store_free_nodes(tools_nodes_after, tools_count_after);
|
||
|
||
/* Sanity: total node count should not have collapsed by ~the size of tools/ */
|
||
int total_after = cbm_store_count_nodes(s, project);
|
||
ASSERT_GTE(total_after, total_before); /* additive — never less */
|
||
cbm_store_close(s);
|
||
|
||
/* Step 3: mutate main.go and fast reindex — forces dump_and_persist to
|
||
* run (instead of the noop early-return path that step 2 hit). This is
|
||
* the real dangerous path: the gbuf gets loaded, mutated for main.go,
|
||
* dumped back to disk. tools/util.go must survive THAT cycle, not just
|
||
* the trivial noop path. Audit finding from 2026-04-13. */
|
||
snprintf(path, sizeof(path), "%s/main.go", tmpdir);
|
||
f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "package main\n\nfunc main() {\n\tprintln(\"changed\")\n}\n");
|
||
fclose(f);
|
||
/* Bump mtime explicitly — some filesystems have coarse mtime resolution
|
||
* and the rewrite could land in the same tick as the original write. */
|
||
#ifndef _WIN32
|
||
struct stat mst;
|
||
if (stat(path, &mst) == 0) {
|
||
struct timespec times[2];
|
||
times[0].tv_sec = mst.st_atime;
|
||
times[0].tv_nsec = 0;
|
||
times[1].tv_sec = mst.st_mtime + 5;
|
||
times[1].tv_nsec = 0;
|
||
utimensat(AT_FDCWD, path, times, 0);
|
||
}
|
||
#endif /* !_WIN32 */
|
||
|
||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
cbm_pipeline_free(p);
|
||
|
||
s = cbm_store_open_path(dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
cbm_node_t *tools_nodes_run3 = NULL;
|
||
int tools_count_run3 = 0;
|
||
cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_run3, &tools_count_run3);
|
||
/* tools/util.go nodes must STILL be present after a fast reindex that
|
||
* actually ran the full dump_and_persist cycle (not the noop fast-path). */
|
||
ASSERT_EQ(tools_count_run3, tools_count_before);
|
||
cbm_store_free_nodes(tools_nodes_run3, tools_count_run3);
|
||
cbm_store_close(s);
|
||
|
||
/* Step 4: actually delete tools/util.go from disk and full-reindex.
|
||
* Now it really is gone, so its nodes should be purged. This pins the
|
||
* other half of the contract: the stat-based check correctly identifies
|
||
* truly-deleted files as deleted. */
|
||
snprintf(path, sizeof(path), "%s/tools/util.go", tmpdir);
|
||
unlink(path);
|
||
|
||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
cbm_pipeline_free(p);
|
||
|
||
s = cbm_store_open_path(dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
cbm_node_t *tools_nodes_deleted = NULL;
|
||
int tools_count_deleted = 0;
|
||
cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_deleted,
|
||
&tools_count_deleted);
|
||
ASSERT_EQ(tools_count_deleted, 0); /* truly deleted → purged */
|
||
cbm_store_free_nodes(tools_nodes_deleted, tools_count_deleted);
|
||
cbm_store_close(s);
|
||
|
||
free(project);
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(incremental_k8s_manifest_indexed) {
|
||
/* Full index with a k8s manifest, then add a new manifest via incremental.
|
||
* Verifies that cbm_pipeline_pass_k8s() runs during incremental re-index. */
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_k8s_incr_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
char dbpath[512];
|
||
snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir);
|
||
char path[512];
|
||
FILE *f;
|
||
|
||
/* Initial manifest */
|
||
snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir);
|
||
f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n");
|
||
fclose(f);
|
||
|
||
/* Full index */
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
char *project = strdup(cbm_pipeline_project_name(p));
|
||
cbm_pipeline_free(p);
|
||
|
||
/* Verify Resource node created by full index */
|
||
cbm_store_t *s = cbm_store_open_path(dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
cbm_node_t *nodes = NULL;
|
||
int count = 0;
|
||
cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count);
|
||
ASSERT_GT(count, 0);
|
||
cbm_store_free_nodes(nodes, count);
|
||
cbm_store_close(s);
|
||
|
||
/* Add a second manifest — incremental should pick it up */
|
||
snprintf(path, sizeof(path), "%s/svc.yaml", tmpdir);
|
||
f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "apiVersion: v1\nkind: Service\nmetadata:\n name: my-svc\n");
|
||
fclose(f);
|
||
|
||
/* Incremental re-index */
|
||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
cbm_pipeline_free(p);
|
||
|
||
/* Verify both Resource nodes now present */
|
||
s = cbm_store_open_path(dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
nodes = NULL;
|
||
count = 0;
|
||
cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count);
|
||
ASSERT_GTE(count, 2);
|
||
cbm_store_free_nodes(nodes, count);
|
||
cbm_store_close(s);
|
||
|
||
free(project);
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
TEST(incremental_kustomize_module_indexed) {
|
||
/* Verifies that a kustomization.yaml added after the initial full index
|
||
* gets a Module node via the incremental k8s pass. */
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_kust_incr_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
char dbpath[512];
|
||
snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir);
|
||
char path[512];
|
||
FILE *f;
|
||
|
||
/* Initial resource manifest (gives full index something to find) */
|
||
snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir);
|
||
f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n");
|
||
fclose(f);
|
||
|
||
/* Full index */
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
char *project = strdup(cbm_pipeline_project_name(p));
|
||
cbm_pipeline_free(p);
|
||
|
||
/* Add kustomization.yaml */
|
||
snprintf(path, sizeof(path), "%s/kustomization.yaml", tmpdir);
|
||
f = fopen(path, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "apiVersion: kustomize.config.k8s.io/v1beta1\n"
|
||
"kind: Kustomization\n"
|
||
"resources:\n"
|
||
" - deploy.yaml\n");
|
||
fclose(f);
|
||
|
||
/* Incremental re-index */
|
||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
cbm_pipeline_free(p);
|
||
|
||
/* Verify Module node created for the kustomization overlay */
|
||
cbm_store_t *s = cbm_store_open_path(dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
cbm_node_t *nodes = NULL;
|
||
int count = 0;
|
||
cbm_store_find_nodes_by_label(s, project, "Module", &nodes, &count);
|
||
bool found_kust = false;
|
||
for (int i = 0; i < count; i++) {
|
||
if (nodes[i].properties_json && strstr(nodes[i].properties_json, "kustomize")) {
|
||
found_kust = true;
|
||
break;
|
||
}
|
||
}
|
||
cbm_store_free_nodes(nodes, count);
|
||
cbm_store_close(s);
|
||
ASSERT_TRUE(found_kust);
|
||
|
||
free(project);
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Index lock tests ───────────────────────────────────────────── */
|
||
|
||
TEST(pipeline_lock_try_acquire) {
|
||
/* First try-lock should succeed */
|
||
ASSERT_TRUE(cbm_pipeline_try_lock());
|
||
/* Second try-lock should fail (already held) */
|
||
ASSERT_FALSE(cbm_pipeline_try_lock());
|
||
/* Release, then re-acquire should succeed */
|
||
cbm_pipeline_unlock();
|
||
ASSERT_TRUE(cbm_pipeline_try_lock());
|
||
cbm_pipeline_unlock();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_lock_blocking) {
|
||
/* Lock, then unlock — basic sanity */
|
||
cbm_pipeline_lock();
|
||
cbm_pipeline_unlock();
|
||
/* Should be immediately re-acquirable */
|
||
cbm_pipeline_lock();
|
||
cbm_pipeline_unlock();
|
||
PASS();
|
||
}
|
||
|
||
/* Thread function that tries to acquire the lock and records result */
|
||
static atomic_int g_thread_acquired = 0;
|
||
static atomic_int g_thread_done = 0;
|
||
|
||
static void *try_lock_thread(void *arg) {
|
||
(void)arg;
|
||
if (cbm_pipeline_try_lock()) {
|
||
atomic_store(&g_thread_acquired, 1);
|
||
cbm_pipeline_unlock();
|
||
} else {
|
||
atomic_store(&g_thread_acquired, 0);
|
||
}
|
||
atomic_store(&g_thread_done, 1);
|
||
return NULL;
|
||
}
|
||
|
||
TEST(pipeline_lock_contention) {
|
||
/* Main thread holds lock, spawned thread should fail try_lock */
|
||
cbm_pipeline_lock();
|
||
atomic_store(&g_thread_acquired, -1);
|
||
atomic_store(&g_thread_done, 0);
|
||
|
||
cbm_thread_t tid;
|
||
int rc = cbm_thread_create(&tid, 0, try_lock_thread, NULL);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
/* Wait for thread to finish */
|
||
cbm_thread_join(&tid);
|
||
|
||
/* Thread should NOT have acquired the lock */
|
||
ASSERT_EQ(atomic_load(&g_thread_acquired), 0);
|
||
cbm_pipeline_unlock();
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_lock_release_allows_contender) {
|
||
/* Main thread acquires and releases, then spawned thread should succeed */
|
||
cbm_pipeline_lock();
|
||
cbm_pipeline_unlock();
|
||
|
||
atomic_store(&g_thread_acquired, -1);
|
||
atomic_store(&g_thread_done, 0);
|
||
|
||
cbm_thread_t tid;
|
||
int rc = cbm_thread_create(&tid, 0, try_lock_thread, NULL);
|
||
ASSERT_EQ(rc, 0);
|
||
cbm_thread_join(&tid);
|
||
|
||
/* Thread SHOULD have acquired the lock */
|
||
ASSERT_EQ(atomic_load(&g_thread_acquired), 1);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Resource management & internal helper tests ─────────────────── */
|
||
|
||
TEST(pipeline_empty_path) {
|
||
/* Empty string repo path — should handle gracefully */
|
||
cbm_pipeline_t *p = cbm_pipeline_new("", NULL, CBM_MODE_FULL);
|
||
/* Implementation may return NULL or a valid pipeline with empty project name.
|
||
* Either behavior is acceptable — the key is no crash. */
|
||
if (p) {
|
||
cbm_pipeline_free(p);
|
||
}
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_project_name_content) {
|
||
/* Verify project name is derived from the repo_path */
|
||
cbm_pipeline_t *p = cbm_pipeline_new("/home/user/my-project", NULL, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
const char *name = cbm_pipeline_project_name(p);
|
||
ASSERT_NOT_NULL(name);
|
||
ASSERT_TRUE(strlen(name) > 0);
|
||
/* Should contain "my-project" as part of the derived name */
|
||
ASSERT_TRUE(strstr(name, "my-project") != NULL);
|
||
cbm_pipeline_free(p);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_cancel_sets_flag) {
|
||
/* Verify cancel sets the flag so subsequent run exits early */
|
||
cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
/* Cancel before run */
|
||
cbm_pipeline_cancel(p);
|
||
/* Cancelled pipeline should return quickly (either -1 from cancel or from
|
||
* missing path — both are acceptable; key is no hang) */
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, -1);
|
||
cbm_pipeline_free(p);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_double_cancel) {
|
||
/* Calling cancel twice should not crash */
|
||
cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
cbm_pipeline_cancel(p);
|
||
cbm_pipeline_cancel(p);
|
||
cbm_pipeline_free(p);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_double_free_prevention) {
|
||
/* free(NULL) after free should not crash. We can't truly double-free
|
||
* the same pointer, but we verify NULL is safe as documented. */
|
||
cbm_pipeline_free(NULL);
|
||
cbm_pipeline_free(NULL);
|
||
PASS();
|
||
}
|
||
|
||
TEST(trackable_source_files) {
|
||
/* Common source extensions are trackable */
|
||
ASSERT_TRUE(cbm_is_trackable_file("main.go"));
|
||
ASSERT_TRUE(cbm_is_trackable_file("src/handler.py"));
|
||
ASSERT_TRUE(cbm_is_trackable_file("lib/server.js"));
|
||
ASSERT_TRUE(cbm_is_trackable_file("components/App.tsx"));
|
||
ASSERT_TRUE(cbm_is_trackable_file("pkg/svc.rs"));
|
||
ASSERT_TRUE(cbm_is_trackable_file("config.yaml"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(trackable_rejects_images) {
|
||
/* Image and binary files are not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("logo.png"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("photo.jpg"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("icon.gif"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("diagram.svg"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(trackable_rejects_minified) {
|
||
/* Minified files are not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("bundle.min.js"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("style.min.css"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(trackable_rejects_vendor_dirs) {
|
||
/* Files in vendor/node_modules/__pycache__ are not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("vendor/github.com/lib/dep.go"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("node_modules/lodash/index.js"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("__pycache__/module.pyc"));
|
||
ASSERT_FALSE(cbm_is_trackable_file(".git/objects/pack/data"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(trackable_rejects_lock_files) {
|
||
/* Lock files are not trackable */
|
||
ASSERT_FALSE(cbm_is_trackable_file("package-lock.json"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("go.sum"));
|
||
ASSERT_FALSE(cbm_is_trackable_file("yarn.lock"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(test_path_directory_patterns) {
|
||
/* Test directory patterns: __tests__, test/, tests/, spec/ */
|
||
ASSERT_TRUE(cbm_is_test_path("__tests__/Component.js"));
|
||
ASSERT_TRUE(cbm_is_test_path("tests/test_main.py"));
|
||
ASSERT_TRUE(cbm_is_test_path("spec/handler_spec.rb"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(test_path_suffix_patterns) {
|
||
/* Various language-specific test suffix patterns */
|
||
ASSERT_TRUE(cbm_is_test_path("handler.spec.ts"));
|
||
ASSERT_TRUE(cbm_is_test_path("handler.test.tsx"));
|
||
ASSERT_TRUE(cbm_is_test_path("handler_test.go"));
|
||
ASSERT_TRUE(cbm_is_test_path("test_handler.py"));
|
||
/* Non-test files */
|
||
ASSERT_FALSE(cbm_is_test_path("handler.ts"));
|
||
ASSERT_FALSE(cbm_is_test_path("contest.go"));
|
||
ASSERT_FALSE(cbm_is_test_path("latest.py"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(test_func_name_go_patterns) {
|
||
/* Go test function names: Test + uppercase char */
|
||
ASSERT_TRUE(cbm_is_test_func_name("TestFoo"));
|
||
ASSERT_TRUE(cbm_is_test_func_name("TestHTTPHandler"));
|
||
/* Non-test: "Test" alone or Test + lowercase */
|
||
ASSERT_FALSE(cbm_is_test_func_name("Testable")); /* lowercase 'a' after Test */
|
||
PASS();
|
||
}
|
||
|
||
TEST(test_func_name_js_helpers) {
|
||
/* JS/TS test helper function names */
|
||
ASSERT_TRUE(cbm_is_test_func_name("it"));
|
||
ASSERT_TRUE(cbm_is_test_func_name("describe"));
|
||
ASSERT_TRUE(cbm_is_test_func_name("test"));
|
||
ASSERT_TRUE(cbm_is_test_func_name("beforeEach"));
|
||
ASSERT_TRUE(cbm_is_test_func_name("afterEach"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(env_var_name_valid) {
|
||
/* Valid env var names: uppercase + underscores, at least 2 chars with uppercase */
|
||
ASSERT_TRUE(cbm_is_env_var_name("DATABASE_URL"));
|
||
ASSERT_TRUE(cbm_is_env_var_name("API_KEY"));
|
||
ASSERT_TRUE(cbm_is_env_var_name("PORT"));
|
||
ASSERT_TRUE(cbm_is_env_var_name("DB_2"));
|
||
ASSERT_TRUE(cbm_is_env_var_name("MY_VAR_123"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(env_var_name_invalid) {
|
||
/* Invalid: too short, lowercase, mixed case, empty */
|
||
ASSERT_FALSE(cbm_is_env_var_name("A")); /* single char */
|
||
ASSERT_FALSE(cbm_is_env_var_name("port")); /* lowercase */
|
||
ASSERT_FALSE(cbm_is_env_var_name("apiKey")); /* camelCase */
|
||
ASSERT_FALSE(cbm_is_env_var_name("__")); /* no uppercase */
|
||
ASSERT_FALSE(cbm_is_env_var_name("")); /* empty */
|
||
ASSERT_FALSE(cbm_is_env_var_name("123")); /* digits only */
|
||
PASS();
|
||
}
|
||
|
||
TEST(config_ext_positive) {
|
||
/* Config file extensions recognized */
|
||
ASSERT_TRUE(cbm_has_config_extension(".env"));
|
||
ASSERT_TRUE(cbm_has_config_extension("config.yaml"));
|
||
ASSERT_TRUE(cbm_has_config_extension("config.yml"));
|
||
ASSERT_TRUE(cbm_has_config_extension("settings.toml"));
|
||
ASSERT_TRUE(cbm_has_config_extension("app.ini"));
|
||
ASSERT_TRUE(cbm_has_config_extension("data.json"));
|
||
ASSERT_TRUE(cbm_has_config_extension("app.properties"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(config_ext_negative) {
|
||
/* Non-config extensions rejected */
|
||
ASSERT_FALSE(cbm_has_config_extension("main.go"));
|
||
ASSERT_FALSE(cbm_has_config_extension("app.py"));
|
||
ASSERT_FALSE(cbm_has_config_extension("handler.rs"));
|
||
ASSERT_FALSE(cbm_has_config_extension("data.csv"));
|
||
ASSERT_FALSE(cbm_has_config_extension("README.md"));
|
||
PASS();
|
||
}
|
||
|
||
TEST(split_camel_basic) {
|
||
/* Basic camelCase splitting */
|
||
char *parts[16];
|
||
int n;
|
||
|
||
n = cbm_split_camel_case("getCamelCase", parts, 16);
|
||
ASSERT_EQ(n, 3);
|
||
ASSERT_STR_EQ(parts[0], "get");
|
||
ASSERT_STR_EQ(parts[1], "Camel");
|
||
ASSERT_STR_EQ(parts[2], "Case");
|
||
for (int i = 0; i < n; i++)
|
||
free(parts[i]);
|
||
|
||
PASS();
|
||
}
|
||
|
||
TEST(split_camel_single_word) {
|
||
/* Single lowercase word — no splits */
|
||
char *parts[16];
|
||
int n = cbm_split_camel_case("hello", parts, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(parts[0], "hello");
|
||
for (int i = 0; i < n; i++)
|
||
free(parts[i]);
|
||
PASS();
|
||
}
|
||
|
||
TEST(split_camel_empty) {
|
||
/* Empty string — should return 0 or 1 empty part */
|
||
char *parts[16];
|
||
int n = cbm_split_camel_case("", parts, 16);
|
||
for (int i = 0; i < n; i++)
|
||
free(parts[i]);
|
||
/* Either 0 parts or 1 empty part is acceptable */
|
||
ASSERT_TRUE(n >= 0 && n <= 1);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tokenize_decorator_login_required) {
|
||
/* @login_required → ["login", "required"] */
|
||
char *tokens[16];
|
||
int n = cbm_tokenize_decorator("@login_required", tokens, 16);
|
||
ASSERT_EQ(n, 2);
|
||
ASSERT_STR_EQ(tokens[0], "login");
|
||
ASSERT_STR_EQ(tokens[1], "required");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tokenize_decorator_single) {
|
||
/* @Override → ["override"] */
|
||
char *tokens[16];
|
||
int n = cbm_tokenize_decorator("@Override", tokens, 16);
|
||
ASSERT_EQ(n, 1);
|
||
ASSERT_STR_EQ(tokens[0], "override");
|
||
for (int i = 0; i < n; i++)
|
||
free(tokens[i]);
|
||
PASS();
|
||
}
|
||
|
||
TEST(split_command_basic) {
|
||
/* Basic command splitting with spaces */
|
||
char *args[16];
|
||
int n = cbm_split_command("gcc -c main.c -o main.o", args, 16);
|
||
ASSERT_EQ(n, 5);
|
||
ASSERT_STR_EQ(args[0], "gcc");
|
||
ASSERT_STR_EQ(args[1], "-c");
|
||
ASSERT_STR_EQ(args[2], "main.c");
|
||
ASSERT_STR_EQ(args[3], "-o");
|
||
ASSERT_STR_EQ(args[4], "main.o");
|
||
for (int i = 0; i < n; i++)
|
||
free(args[i]);
|
||
PASS();
|
||
}
|
||
|
||
TEST(split_command_quoted) {
|
||
/* Command with quoted arguments */
|
||
char *args[16];
|
||
int n = cbm_split_command("gcc -DFOO=\"bar baz\" main.c", args, 16);
|
||
ASSERT_GTE(n, 3);
|
||
ASSERT_STR_EQ(args[0], "gcc");
|
||
for (int i = 0; i < n; i++)
|
||
free(args[i]);
|
||
PASS();
|
||
}
|
||
|
||
TEST(split_command_empty) {
|
||
/* Empty command string */
|
||
char *args[16];
|
||
int n = cbm_split_command("", args, 16);
|
||
ASSERT_EQ(n, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_range_with_count) {
|
||
/* "10,5" → start=10, count=5 */
|
||
int start, count;
|
||
cbm_parse_range("10,5", &start, &count);
|
||
ASSERT_EQ(start, 10);
|
||
ASSERT_EQ(count, 5);
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_range_single) {
|
||
/* "42" → start=42, count=1 */
|
||
int start, count;
|
||
cbm_parse_range("42", &start, &count);
|
||
ASSERT_EQ(start, 42);
|
||
ASSERT_EQ(count, 1);
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_range_zero_count) {
|
||
/* "100,0" → start=100, count=0 */
|
||
int start, count;
|
||
cbm_parse_range("100,0", &start, &count);
|
||
ASSERT_EQ(start, 100);
|
||
ASSERT_EQ(count, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_name_status_basic) {
|
||
/* Parse standard git diff --name-status output */
|
||
const char *input = "M\tsrc/main.go\n"
|
||
"A\tsrc/new.go\n"
|
||
"D\tsrc/old.go\n";
|
||
cbm_changed_file_t files[16];
|
||
int n = cbm_parse_name_status(input, files, 16);
|
||
/* Exact count depends on which files pass cbm_is_trackable_file */
|
||
ASSERT_GTE(n, 0);
|
||
ASSERT_LTE(n, 3);
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_name_status_empty) {
|
||
/* Empty input */
|
||
cbm_changed_file_t files[16];
|
||
int n = cbm_parse_name_status("", files, 16);
|
||
ASSERT_EQ(n, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_hunks_empty) {
|
||
/* Empty input */
|
||
cbm_changed_hunk_t hunks[16];
|
||
int n = cbm_parse_hunks("", hunks, 16);
|
||
ASSERT_EQ(n, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(coupling_empty_commits) {
|
||
/* Zero commits → zero couplings */
|
||
cbm_change_coupling_t results[16];
|
||
int n = cbm_compute_change_coupling(NULL, 0, results, 16);
|
||
ASSERT_EQ(n, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(coupling_single_file_commit) {
|
||
/* Commits with single files → no pairs → zero couplings */
|
||
char *f1[] = {"a.go"};
|
||
char *f2[] = {"b.go"};
|
||
cbm_commit_files_t commits[] = {{f1, 1, 0}, {f2, 1, 0}};
|
||
cbm_change_coupling_t results[16];
|
||
int n = cbm_compute_change_coupling(commits, 2, results, 16);
|
||
ASSERT_EQ(n, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(clean_json_brackets_array) {
|
||
/* JSON array → space-joined */
|
||
char out[256];
|
||
cbm_clean_json_brackets("[\"./app\", \"--flag\"]", out, sizeof(out));
|
||
ASSERT_TRUE(strstr(out, "./app") != NULL);
|
||
ASSERT_TRUE(strstr(out, "--flag") != NULL);
|
||
PASS();
|
||
}
|
||
|
||
TEST(clean_json_brackets_plain) {
|
||
/* Plain string → unchanged */
|
||
char out[256];
|
||
cbm_clean_json_brackets("./app --flag", out, sizeof(out));
|
||
ASSERT_STR_EQ(out, "./app --flag");
|
||
PASS();
|
||
}
|
||
|
||
TEST(clean_json_brackets_empty) {
|
||
/* Empty string → empty output */
|
||
char out[256];
|
||
cbm_clean_json_brackets("", out, sizeof(out));
|
||
ASSERT_STR_EQ(out, "");
|
||
PASS();
|
||
}
|
||
|
||
TEST(fqn_compute_basic) {
|
||
/* Basic FQN: project.dir.name */
|
||
char *fqn = cbm_pipeline_fqn_compute("proj", "pkg/handler.go", "Serve");
|
||
ASSERT_NOT_NULL(fqn);
|
||
ASSERT_TRUE(strstr(fqn, "proj") != NULL);
|
||
ASSERT_TRUE(strstr(fqn, "Serve") != NULL);
|
||
free(fqn);
|
||
PASS();
|
||
}
|
||
|
||
TEST(fqn_compute_strips_ext) {
|
||
/* FQN should strip file extension */
|
||
char *fqn = cbm_pipeline_fqn_compute("proj", "main.go", "main");
|
||
ASSERT_NOT_NULL(fqn);
|
||
/* Should not contain ".go" */
|
||
ASSERT_TRUE(strstr(fqn, ".go") == NULL);
|
||
ASSERT_TRUE(strstr(fqn, "main") != NULL);
|
||
free(fqn);
|
||
PASS();
|
||
}
|
||
|
||
TEST(fqn_module_basic) {
|
||
/* Module QN: project.dir.parts */
|
||
char *mod = cbm_pipeline_fqn_module("proj", "pkg/util/helper.go");
|
||
ASSERT_NOT_NULL(mod);
|
||
ASSERT_TRUE(strstr(mod, "proj") != NULL);
|
||
free(mod);
|
||
PASS();
|
||
}
|
||
|
||
TEST(fqn_folder_basic) {
|
||
/* Folder QN from directory path */
|
||
char *folder = cbm_pipeline_fqn_folder("proj", "pkg/util");
|
||
ASSERT_NOT_NULL(folder);
|
||
ASSERT_TRUE(strstr(folder, "proj") != NULL);
|
||
free(folder);
|
||
PASS();
|
||
}
|
||
|
||
TEST(project_name_special_chars) {
|
||
/* Project name with colons, slashes — should be normalized */
|
||
char *name = cbm_project_name_from_path("/home/user/my:project");
|
||
ASSERT_NOT_NULL(name);
|
||
/* Colons should be converted to dashes */
|
||
ASSERT_TRUE(strchr(name, ':') == NULL);
|
||
ASSERT_TRUE(strchr(name, '/') == NULL);
|
||
free(name);
|
||
PASS();
|
||
}
|
||
|
||
TEST(project_name_trailing_slash) {
|
||
/* Trailing slash should not affect result */
|
||
char *a = cbm_project_name_from_path("/home/user/project");
|
||
ASSERT_NOT_NULL(a);
|
||
free(a);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Complexity propagation pass tests (Tier B) ────────────────── */
|
||
|
||
/* Find the first Function/Method node with `name` in a label set. Returns a
|
||
* borrowed pointer into `funcs` or NULL. */
|
||
static const cbm_node_t *find_node_named(cbm_node_t *funcs, int count, const char *name) {
|
||
for (int i = 0; i < count; i++) {
|
||
if (strcmp(funcs[i].name, name) == 0)
|
||
return &funcs[i];
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* The complexity pass propagates loop_depth along CALLS edges into
|
||
* transitive_loop_depth and flags call-graph cycles as recursive. Caller and
|
||
* callee live in one file so the calls resolve intra-file (most reliable). */
|
||
TEST(pipeline_complexity_transitive_loop_depth) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_cx_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
|
||
write_temp_file(tmpdir, "cx.go",
|
||
"package p\n\n"
|
||
"func work() {}\n\n"
|
||
"func inner() {\n"
|
||
"\tfor i := 0; i < 10; i++ {\n"
|
||
"\t\tfor j := 0; j < 10; j++ {\n"
|
||
"\t\t\twork()\n"
|
||
"\t\t}\n"
|
||
"\t}\n"
|
||
"}\n\n"
|
||
"func outer() {\n"
|
||
"\tfor i := 0; i < 10; i++ {\n"
|
||
"\t\tinner()\n"
|
||
"\t}\n"
|
||
"}\n\n"
|
||
"func recur(n int) {\n"
|
||
"\tif n > 0 {\n"
|
||
"\t\trecur(n - 1)\n"
|
||
"\t}\n"
|
||
"}\n");
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/cx.db", tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
|
||
cbm_node_t *funcs = NULL;
|
||
int func_count = 0;
|
||
ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "Function", &funcs, &func_count),
|
||
CBM_STORE_OK);
|
||
ASSERT_GT(func_count, 0);
|
||
|
||
/* inner: two nested loops, callee work() has depth 0 → tld == 2 */
|
||
const cbm_node_t *inner = find_node_named(funcs, func_count, "inner");
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(inner->properties_json);
|
||
ASSERT_TRUE(strstr(inner->properties_json, "\"transitive_loop_depth\":2") != NULL);
|
||
|
||
/* outer: own depth 1 + inner's tld 2 → tld == 3 (interprocedural) */
|
||
const cbm_node_t *outer = find_node_named(funcs, func_count, "outer");
|
||
ASSERT_NOT_NULL(outer);
|
||
ASSERT_NOT_NULL(outer->properties_json);
|
||
ASSERT_TRUE(strstr(outer->properties_json, "\"transitive_loop_depth\":3") != NULL);
|
||
|
||
/* recur: self-recursion → recursive:true flagged by the cycle guard */
|
||
const cbm_node_t *recur = find_node_named(funcs, func_count, "recur");
|
||
ASSERT_NOT_NULL(recur);
|
||
ASSERT_NOT_NULL(recur->properties_json);
|
||
ASSERT_TRUE(strstr(recur->properties_json, "\"recursive\":true") != NULL);
|
||
|
||
cbm_store_free_nodes(funcs, func_count);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
/* Regression for #334: the plausibility gate compares committed (extracted)
|
||
* node count against persisted rows. committed_nodes must be captured BEFORE
|
||
* cbm_gbuf_dump_to_sqlite frees the gbuf node index — otherwise it reads 0 and
|
||
* the gate is silently inert. Drives the real pipeline (not a synthetic count)
|
||
* and asserts committed_nodes is populated and matches what was persisted. */
|
||
TEST(pipeline_committed_counts_match_persisted) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/committed_test.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
int committed_nodes = -1;
|
||
int committed_edges = -1;
|
||
cbm_pipeline_get_committed_counts(p, &committed_nodes, &committed_edges);
|
||
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(s);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
int persisted_nodes = cbm_store_count_nodes(s, project);
|
||
cbm_store_close(s);
|
||
|
||
/* The bug captured committed_nodes after the node index was freed → 0. */
|
||
ASSERT_GT(committed_nodes, 0);
|
||
/* A faithful full dump persists exactly what it committed. */
|
||
ASSERT_EQ(committed_nodes, persisted_nodes);
|
||
/* The gate must NOT flag a healthy full index as degraded. */
|
||
ASSERT_FALSE(cbm_dump_verify_is_degraded(committed_nodes, persisted_nodes,
|
||
CBM_DUMP_VERIFY_DEFAULT_RATIO,
|
||
CBM_DUMP_VERIFY_MIN_FLOOR));
|
||
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* Reproduce-first (perf, linux-kernel finding): the extraction back-pressure
|
||
* gate must stop re-paying the full collect+nap tax on every file pull once a
|
||
* full nap cycle has failed to reclaim under budget. With CBM_MEM_BUDGET_MB=1
|
||
* the test process RSS is permanently over budget and NOTHING napping can
|
||
* change that (the resident floor IS the process) — napping is provably futile.
|
||
* OLD behavior: one full 40-spin nap cycle per pulled file (kernel index: ~63k
|
||
* pulls × ~120 ms ÷ 12 workers ≈ 390 s of idle workers at 79% avg CPU).
|
||
* FIXED: the first futile cycle flips a shared flag; later pulls proceed with
|
||
* the designed soft overshoot. Cycle count then can't exceed one cycle per
|
||
* worker (workers already inside the gate when the flag flips) plus re-probes.
|
||
* RED on the unfixed gate: cycles == file count (64) > cores+2.
|
||
* The counter (cbm_pp_bp_nap_cycles) makes this deterministic — no timing.
|
||
*
|
||
* The gate lives ONLY in the parallel extract path, so the fixture MUST exceed
|
||
* MIN_FILES_FOR_PARALLEL (50) — else the run routes sequential, the gate never
|
||
* fires, and the test would pass vacuously (cycles==0). The engagement assert
|
||
* below (cycles >= 1) is a hard guard against that regressing silently. */
|
||
TEST(pipeline_backpressure_futile_nap_disengages) {
|
||
/* 64 tiny files: > MIN_FILES_FOR_PARALLEL (50) so the parallel path (and its
|
||
* back-pressure gate) actually runs; old-code cycles (~64) >> the bound. */
|
||
snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/cbm_test_XXXXXX");
|
||
if (!cbm_mkdtemp(g_tmpdir)) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
for (int i = 0; i < 64; i++) {
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/f%02d.go", g_tmpdir, i);
|
||
FILE *f = fopen(path, "w");
|
||
if (!f) {
|
||
FAIL("failed to create fixture file");
|
||
}
|
||
fprintf(f, "package main\n\nfunc F%02d() int {\n\treturn %d\n}\n", i, i);
|
||
fclose(f);
|
||
}
|
||
|
||
/* 1 MB budget: over-budget on every pull, unreclaimable by napping.
|
||
* Set via the test hook, NOT setenv + cbm_mem_init: the init-once guard
|
||
* makes any re-init keep whatever budget the FIRST in-process init
|
||
* computed. The old env dance either failed to apply the 1 MB budget
|
||
* (some earlier test's init won the guard) or applied it permanently
|
||
* (this test's init won) — the "restore" re-init was then a silent
|
||
* no-op and the 1 MB budget leaked into every later budget consumer
|
||
* in the runner (mem_over_budget_low_rss went red suite-order-wide). */
|
||
size_t saved_budget = cbm_mem_budget();
|
||
cbm_mem_set_budget_for_tests((size_t)1024 * 1024);
|
||
ASSERT_TRUE(cbm_mem_budget() > 0);
|
||
ASSERT_TRUE(cbm_mem_over_budget());
|
||
|
||
cbm_pp_bp_nap_cycles_reset();
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, NULL, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
long cycles = cbm_pp_bp_nap_cycles();
|
||
|
||
/* Restore the caller-visible budget BEFORE asserting. */
|
||
cbm_mem_set_budget_for_tests(saved_budget);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
|
||
ASSERT_EQ(rc, 0);
|
||
/* Engagement guard (anti-vacuous): the gate must have actually run — the
|
||
* parallel path taken and the 1 MB budget exceeded on every pull. cycles==0
|
||
* means the fixture routed sequential (or the gate was compiled out) and
|
||
* this test proved NOTHING; fail loudly rather than pass vacuously. */
|
||
if (cycles < 1) {
|
||
FAIL("back-pressure gate never engaged (cycles==0) — fixture routed sequential?");
|
||
}
|
||
/* Futile napping must disengage: at most one in-flight cycle per worker
|
||
* plus a small margin, never one per file (64). */
|
||
long bound = (long)cbm_system_info().total_cores + 2;
|
||
if (cycles > bound) {
|
||
char msg[128];
|
||
snprintf(msg, sizeof(msg), "nap cycles %ld > bound %ld (gate re-paid per pull)", cycles,
|
||
bound);
|
||
FAIL(msg);
|
||
}
|
||
PASS();
|
||
}
|
||
|
||
/* TS cross-registry test hooks (ts_lsp.c) — extern to avoid pulling the
|
||
* tree-sitter-typed ts_lsp.h into this store-level test. */
|
||
extern long cbm_ts_full_registry_builds(void);
|
||
extern void cbm_ts_full_registry_builds_reset(void);
|
||
|
||
/* Reproduce-first (ms-typescript finding, 2026-07-07): the SEQUENTIAL
|
||
* cross-LSP driver must resolve TS files through the SHARED prebuilt
|
||
* registry, never a full per-file build. cbm_run_ts_lsp_cross registers
|
||
* stdlib + EVERY cross-file def + finalizes once PER FILE — O(files x defs).
|
||
* On an 81k-file TS corpus that ground one core for hours (74% of stack
|
||
* samples inside build_qn_index), and when the supervisor's quiet-timeout
|
||
* killed the crawl mid-pass, the stale extraction marker blamed innocent
|
||
* files, quarantining four of them one 15-minute retry at a time.
|
||
*
|
||
* The fixture stays UNDER MIN_FILES_FOR_PARALLEL (50) so the pipeline
|
||
* routes through the sequential driver — the path that lacked the
|
||
* shared-registry prepare.
|
||
* RED on the unfixed driver: full builds == TS file count (40).
|
||
* GREEN: full builds == 0 AND the cross-file TS call still resolves
|
||
* (quality guard — the shared path must not lose the edge). */
|
||
TEST(pipeline_seq_ts_cross_uses_shared_registry) {
|
||
snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/cbm_test_XXXXXX");
|
||
if (!cbm_mkdtemp(g_tmpdir)) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/shared.ts", g_tmpdir);
|
||
FILE *f = fopen(path, "w");
|
||
if (!f) {
|
||
FAIL("failed to create fixture file");
|
||
}
|
||
fputs("export function sharedHelper(): number {\n return 42;\n}\n", f);
|
||
fclose(f);
|
||
for (int i = 0; i < 39; i++) {
|
||
snprintf(path, sizeof(path), "%s/caller%02d.ts", g_tmpdir, i);
|
||
f = fopen(path, "w");
|
||
if (!f) {
|
||
FAIL("failed to create fixture file");
|
||
}
|
||
fprintf(f,
|
||
"import { sharedHelper } from \"./shared\";\n"
|
||
"export function caller%02d(): number {\n return sharedHelper();\n}\n",
|
||
i);
|
||
fclose(f);
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/seqts.db", g_tmpdir);
|
||
cbm_ts_full_registry_builds_reset();
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int rc = cbm_pipeline_run(p);
|
||
long builds = cbm_ts_full_registry_builds();
|
||
|
||
/* Quality guard FIRST: the cross-file call must resolve either way. */
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
bool linked = false;
|
||
if (s) {
|
||
linked =
|
||
cross_file_call_exists(s, cbm_pipeline_project_name(p), "caller00", "sharedHelper");
|
||
cbm_store_close(s);
|
||
}
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
|
||
ASSERT_EQ(rc, 0);
|
||
ASSERT_TRUE(linked);
|
||
/* The point: ZERO full per-file registry builds on the sequential path. */
|
||
ASSERT_EQ(builds, 0);
|
||
PASS();
|
||
}
|
||
|
||
SUITE(pipeline) {
|
||
/* Index lock */
|
||
RUN_TEST(pipeline_lock_try_acquire);
|
||
RUN_TEST(pipeline_lock_blocking);
|
||
RUN_TEST(pipeline_lock_contention);
|
||
RUN_TEST(pipeline_lock_release_allows_contender);
|
||
/* Lifecycle */
|
||
RUN_TEST(pipeline_create_free);
|
||
RUN_TEST(pipeline_null_repo);
|
||
RUN_TEST(pipeline_free_null);
|
||
RUN_TEST(pipeline_cancel);
|
||
RUN_TEST(pipeline_cancel_null);
|
||
RUN_TEST(pipeline_run_null);
|
||
/* Extraction back-pressure */
|
||
RUN_TEST(pipeline_backpressure_futile_nap_disengages);
|
||
/* Sequential cross-LSP shared registry (ms-typescript quadratic) */
|
||
RUN_TEST(pipeline_seq_ts_cross_uses_shared_registry);
|
||
/* File persistence */
|
||
RUN_TEST(store_file_persistence);
|
||
RUN_TEST(store_bulk_persistence);
|
||
/* Integration: structure pass */
|
||
RUN_TEST(pipeline_structure_nodes);
|
||
RUN_TEST(pipeline_committed_counts_match_persisted);
|
||
RUN_TEST(pipeline_adr_survives_full_reindex);
|
||
RUN_TEST(pipeline_structure_edges);
|
||
RUN_TEST(pipeline_branch_root_structure);
|
||
RUN_TEST(pipeline_project_name_derived);
|
||
RUN_TEST(pipeline_fast_mode);
|
||
/* Definitions pass */
|
||
RUN_TEST(pipeline_definitions_function_nodes);
|
||
RUN_TEST(pipeline_definitions_defines_edges);
|
||
RUN_TEST(pipeline_definitions_properties);
|
||
RUN_TEST(pipeline_def_props_valid_json_when_oversized);
|
||
RUN_TEST(pipeline_edge_props_valid_json);
|
||
/* Complexity propagation pass (Tier B) */
|
||
RUN_TEST(pipeline_complexity_transitive_loop_depth);
|
||
/* Calls pass */
|
||
RUN_TEST(pipeline_calls_resolution);
|
||
RUN_TEST(pipeline_incremental_preserves_cross_file_calls);
|
||
RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge);
|
||
RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges);
|
||
RUN_TEST(pipeline_native_fetch_classified_as_http_calls);
|
||
RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls);
|
||
RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http);
|
||
/* Git history pass */
|
||
RUN_TEST(githistory_is_trackable);
|
||
RUN_TEST(githistory_compute_coupling);
|
||
RUN_TEST(githistory_coupling_carries_last_co_change);
|
||
RUN_TEST(githistory_skip_large_commits);
|
||
RUN_TEST(githistory_limits_to_max);
|
||
/* Test detection */
|
||
RUN_TEST(testdetect_is_test_file);
|
||
RUN_TEST(testdetect_is_test_function);
|
||
/* Implements pass (graph buffer based) */
|
||
RUN_TEST(implements_creates_override);
|
||
RUN_TEST(implements_no_match);
|
||
/* Usages pass (full pipeline integration) */
|
||
RUN_TEST(usages_creates_edges);
|
||
RUN_TEST(usages_no_duplicate_calls);
|
||
RUN_TEST(calls_edge_carries_call_site_line);
|
||
RUN_TEST(usages_kotlin_creates_edges);
|
||
RUN_TEST(usages_kotlin_no_duplicate_calls);
|
||
/* Language integration tests */
|
||
RUN_TEST(pipeline_python_project);
|
||
RUN_TEST(pipeline_imports_multi_symbol_edges);
|
||
RUN_TEST(pipeline_go_cross_package_call);
|
||
RUN_TEST(pipeline_python_cross_module_call);
|
||
RUN_TEST(pipeline_go_type_classification);
|
||
RUN_TEST(pipeline_go_grouped_types);
|
||
RUN_TEST(pipeline_kotlin_project);
|
||
RUN_TEST(pipeline_lua_anonymous_functions);
|
||
RUN_TEST(pipeline_csharp_modern);
|
||
RUN_TEST(pipeline_bom_stripping);
|
||
RUN_TEST(pipeline_form_call_resolution);
|
||
RUN_TEST(pipeline_python_type_inference);
|
||
/* Docstring integration (port of TestDocstringIntegration) */
|
||
RUN_TEST(pipeline_docstring_go_function);
|
||
RUN_TEST(pipeline_docstring_python_function);
|
||
RUN_TEST(pipeline_docstring_java_method);
|
||
RUN_TEST(pipeline_docstring_kotlin_function);
|
||
RUN_TEST(pipeline_docstring_go_class);
|
||
/* Project name */
|
||
RUN_TEST(project_name_from_path);
|
||
RUN_TEST(project_name_drive_letter_case_insensitive_issue394);
|
||
RUN_TEST(git_context_non_git_path);
|
||
RUN_TEST(git_context_linked_worktree);
|
||
RUN_TEST(project_name_uniqueness);
|
||
/* Git diff helpers */
|
||
RUN_TEST(gitdiff_parse_range_with_count);
|
||
RUN_TEST(gitdiff_parse_range_no_count);
|
||
RUN_TEST(gitdiff_parse_range_zero_count);
|
||
RUN_TEST(gitdiff_parse_range_large);
|
||
RUN_TEST(gitdiff_parse_name_status);
|
||
RUN_TEST(gitdiff_parse_name_status_filters_untrackable);
|
||
RUN_TEST(gitdiff_parse_hunks);
|
||
RUN_TEST(gitdiff_parse_hunks_no_newline_marker);
|
||
RUN_TEST(gitdiff_parse_hunks_mode_change);
|
||
RUN_TEST(gitdiff_parse_hunks_deletion);
|
||
/* Config helpers */
|
||
RUN_TEST(configures_is_env_var_name);
|
||
RUN_TEST(configures_normalize_config_key);
|
||
RUN_TEST(configures_has_config_extension);
|
||
/* Config integration tests */
|
||
RUN_TEST(configures_env_var_in_config);
|
||
RUN_TEST(configures_lowercase_key_skipped);
|
||
RUN_TEST(configures_non_config_file_skipped);
|
||
RUN_TEST(configures_full_pipeline_integration);
|
||
/* HTTP link pipeline integration */
|
||
/* Enrichment helpers */
|
||
RUN_TEST(enrichment_split_camel_case);
|
||
RUN_TEST(enrichment_tokenize_decorator);
|
||
/* Decorator tags integration */
|
||
RUN_TEST(decorator_tags_python_auto_discovery);
|
||
RUN_TEST(decorator_tags_java_class_methods);
|
||
/* Compile commands helpers */
|
||
RUN_TEST(compile_commands_split_command);
|
||
RUN_TEST(compile_commands_extract_flags);
|
||
RUN_TEST(compile_commands_parse_json);
|
||
RUN_TEST(compile_commands_parse_empty);
|
||
RUN_TEST(compile_commands_parse_invalid);
|
||
/* Infrascan helpers */
|
||
RUN_TEST(infra_is_compose_file);
|
||
RUN_TEST(infra_is_cloudbuild_file);
|
||
RUN_TEST(infra_is_shell_script);
|
||
RUN_TEST(infra_is_dockerfile);
|
||
RUN_TEST(infra_is_kustomize_file);
|
||
RUN_TEST(infra_is_k8s_manifest);
|
||
RUN_TEST(infra_is_env_file);
|
||
RUN_TEST(infra_clean_json_brackets);
|
||
RUN_TEST(infra_secret_detection);
|
||
/* Infrascan: Dockerfile parser */
|
||
RUN_TEST(infra_parse_dockerfile_multistage);
|
||
RUN_TEST(infra_parse_dockerfile_entrypoint);
|
||
RUN_TEST(infra_parse_dockerfile_secret_filtered);
|
||
RUN_TEST(infra_parse_dockerfile_expose_protocol);
|
||
RUN_TEST(infra_parse_dockerfile_env_space);
|
||
RUN_TEST(infra_parse_dockerfile_empty);
|
||
/* Infrascan: Dotenv parser */
|
||
RUN_TEST(infra_parse_dotenv);
|
||
RUN_TEST(infra_parse_dotenv_quoted);
|
||
/* Infrascan: Shell script parser */
|
||
RUN_TEST(infra_parse_shell);
|
||
RUN_TEST(infra_parse_shell_with_source);
|
||
RUN_TEST(infra_parse_shell_secret_filtered);
|
||
RUN_TEST(infra_parse_shell_shebang_only);
|
||
RUN_TEST(infra_parse_shell_truly_empty);
|
||
/* Infrascan: Terraform parser */
|
||
RUN_TEST(infra_parse_terraform_full);
|
||
RUN_TEST(infra_parse_terraform_variables_only);
|
||
RUN_TEST(infra_parse_terraform_empty);
|
||
RUN_TEST(helm_parse_chart_dependencies_issue338);
|
||
RUN_TEST(helm_parse_chart_no_deps_issue338);
|
||
/* Infrascan: QN helper */
|
||
RUN_TEST(infra_qn_helper);
|
||
/* Infrascan: pipeline integration */
|
||
RUN_TEST(infra_pipeline_integration);
|
||
RUN_TEST(infra_pipeline_idempotent);
|
||
/* K8s / Kustomize extraction */
|
||
RUN_TEST(k8s_extract_kustomize);
|
||
RUN_TEST(k8s_extract_manifest);
|
||
RUN_TEST(k8s_extract_manifest_no_name);
|
||
RUN_TEST(k8s_extract_manifest_multidoc);
|
||
/* Env URL scanning */
|
||
RUN_TEST(envscan_dockerfile_env_urls);
|
||
RUN_TEST(envscan_shell_env_urls);
|
||
RUN_TEST(envscan_env_file_urls);
|
||
RUN_TEST(envscan_toml_urls);
|
||
RUN_TEST(envscan_yaml_urls);
|
||
RUN_TEST(envscan_terraform_urls);
|
||
RUN_TEST(envscan_properties_urls);
|
||
RUN_TEST(envscan_secret_key_exclusion);
|
||
RUN_TEST(envscan_secret_value_exclusion);
|
||
RUN_TEST(envscan_secret_file_exclusion);
|
||
RUN_TEST(envscan_skips_ignored_dirs);
|
||
RUN_TEST(envscan_non_url_values_skipped);
|
||
/* Discovery-exclusion plumbing in auxiliary repo walks (#792) */
|
||
RUN_TEST(pipeline_relpath_excluded_boundary);
|
||
RUN_TEST(pkgmap_scan_repo_honors_discovery_exclusions);
|
||
RUN_TEST(envscan_walk_honors_discovery_exclusions);
|
||
/* Function registry / resolver */
|
||
RUN_TEST(registry_resolve_single_candidate);
|
||
RUN_TEST(registry_fuzzy_nonexistent);
|
||
RUN_TEST(registry_fuzzy_multiple_best_by_distance);
|
||
RUN_TEST(registry_fuzzy_simple_name_extraction);
|
||
RUN_TEST(registry_fuzzy_empty);
|
||
RUN_TEST(registry_exists);
|
||
RUN_TEST(registry_confidence_import_map);
|
||
RUN_TEST(registry_confidence_import_map_suffix);
|
||
RUN_TEST(registry_confidence_same_module);
|
||
RUN_TEST(registry_confidence_unique_name);
|
||
RUN_TEST(registry_confidence_suffix_match);
|
||
RUN_TEST(registry_fuzzy_confidence_single);
|
||
RUN_TEST(registry_fuzzy_confidence_distance);
|
||
RUN_TEST(registry_negative_import_rejects);
|
||
RUN_TEST(registry_fuzzy_import_penalty);
|
||
RUN_TEST(registry_fuzzy_no_import_map_passthrough);
|
||
RUN_TEST(registry_find_by_name);
|
||
RUN_TEST(registry_confidence_band);
|
||
RUN_TEST(registry_is_import_reachable);
|
||
RUN_TEST(registry_find_ending_with);
|
||
/* Git history */
|
||
RUN_TEST(githistory_is_trackable_file);
|
||
RUN_TEST(githistory_compute_change_coupling);
|
||
RUN_TEST(githistory_coupling_skips_large_commits);
|
||
RUN_TEST(githistory_coupling_limits_output);
|
||
/* Incremental reindex */
|
||
/* FastAPI Depends edge tracking (PR #66 port) */
|
||
RUN_TEST(pipeline_fastapi_depends_edges);
|
||
/* Incremental */
|
||
RUN_TEST(incremental_full_then_noop);
|
||
RUN_TEST(incremental_detects_changed_file);
|
||
RUN_TEST(incremental_detects_deleted_file);
|
||
RUN_TEST(incremental_new_file_added);
|
||
RUN_TEST(incremental_fast_preserves_mode_skipped_tools_dir);
|
||
RUN_TEST(incremental_k8s_manifest_indexed);
|
||
RUN_TEST(incremental_kustomize_module_indexed);
|
||
/* Resource management & internal helper tests */
|
||
RUN_TEST(pipeline_empty_path);
|
||
RUN_TEST(pipeline_project_name_content);
|
||
RUN_TEST(pipeline_cancel_sets_flag);
|
||
RUN_TEST(pipeline_double_cancel);
|
||
RUN_TEST(pipeline_double_free_prevention);
|
||
/* Trackable file tests */
|
||
RUN_TEST(trackable_source_files);
|
||
RUN_TEST(trackable_rejects_images);
|
||
RUN_TEST(trackable_rejects_minified);
|
||
RUN_TEST(trackable_rejects_vendor_dirs);
|
||
RUN_TEST(trackable_rejects_lock_files);
|
||
/* Test path detection */
|
||
RUN_TEST(test_path_directory_patterns);
|
||
RUN_TEST(test_path_suffix_patterns);
|
||
/* Test function name detection */
|
||
RUN_TEST(test_func_name_go_patterns);
|
||
RUN_TEST(test_func_name_js_helpers);
|
||
/* Env var name detection */
|
||
RUN_TEST(env_var_name_valid);
|
||
RUN_TEST(env_var_name_invalid);
|
||
/* Config extension detection */
|
||
RUN_TEST(config_ext_positive);
|
||
RUN_TEST(config_ext_negative);
|
||
/* Camel case splitting */
|
||
RUN_TEST(split_camel_basic);
|
||
RUN_TEST(split_camel_single_word);
|
||
RUN_TEST(split_camel_empty);
|
||
/* Decorator tokenization */
|
||
RUN_TEST(tokenize_decorator_login_required);
|
||
RUN_TEST(tokenize_decorator_single);
|
||
/* Command splitting */
|
||
RUN_TEST(split_command_basic);
|
||
RUN_TEST(split_command_quoted);
|
||
RUN_TEST(split_command_empty);
|
||
/* Range parsing */
|
||
RUN_TEST(parse_range_with_count);
|
||
RUN_TEST(parse_range_single);
|
||
RUN_TEST(parse_range_zero_count);
|
||
/* Name status parsing */
|
||
RUN_TEST(parse_name_status_basic);
|
||
RUN_TEST(parse_name_status_empty);
|
||
/* Hunk parsing */
|
||
RUN_TEST(parse_hunks_empty);
|
||
/* Change coupling */
|
||
RUN_TEST(coupling_empty_commits);
|
||
RUN_TEST(coupling_single_file_commit);
|
||
/* JSON bracket cleaning */
|
||
RUN_TEST(clean_json_brackets_array);
|
||
RUN_TEST(clean_json_brackets_plain);
|
||
RUN_TEST(clean_json_brackets_empty);
|
||
/* FQN computation */
|
||
RUN_TEST(fqn_compute_basic);
|
||
RUN_TEST(fqn_compute_strips_ext);
|
||
RUN_TEST(fqn_module_basic);
|
||
RUN_TEST(fqn_folder_basic);
|
||
/* Project name edge cases */
|
||
RUN_TEST(project_name_special_chars);
|
||
RUN_TEST(project_name_trailing_slash);
|
||
}
|