43ecc0988c
A dbt model is an ordinary .sql file whose dependencies are written
{{ ref('other_model') }} or {{ source('group','table') }}, never as
literal table names. The SQL grammar cannot read those -- FROM {{ ref(
'x') }} is a parse error to it -- so the dependency structure of an
entire dbt project was invisible to the graph.
extract_dbt.c runs as a sub-extractor inside the normal indexing
pipeline. Per qualifying file it emits one Model definition (named by
the file stem, dbt's own model identity) plus one usage per ref()/
source() call; pass_usages resolves those into model -> relation
lineage edges like any other reference.
Model is a relation label alongside Table and View, which is what makes
the rest work without new plumbing: registry seeding, the central
relation veto, the incremental surface hash, search ranking and the
architecture queries all pick it up from cbm_label_is_relation. Sharing
one label class also means a model's source('raw','customers') resolves
onto a Table declared in a plain DDL migration in the same repository,
so dbt lineage and SQL lineage form one graph rather than two, while
the veto keeps model names out of every non-lineage consumer.
The pass is self-gating: SQL files only, and only those carrying a real
ref()/source() call. The dbt builtins are themselves the evidence, which
is cheaper than a dbt_project.yml lookup and more precise -- templated
SQL that is not dbt (an Airflow {{ ds }} parameter) produces no Model
node and no usages even inside a dbt repository.
{% macro %} definitions are deliberately excluded: the vendored
tree-sitter-jinja2 grammar has no node types for {% %} statements, so
they can only be recovered by a hand-written scanner that cannot see
comments or find {% endmacro %} for a correct span. Filed as follow-up
rather than approximated.
Tests cover the lineage, the last-string-argument semantics of both
builtins, the gate (against a plain-SQL control extraction), plain DDL
staying untouched, and an end-to-end pipeline case asserting model ->
model across files, model -> Table onto plain DDL, and cross-language
isolation. Disabling the pass reddens three of them; removing Model from
the relation set breaks lineage outright.
Implements the lineage half of #575.
Co-authored-by: alexisperinger-ux <alexis.peringer@iss-stoxx.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
12421 lines
488 KiB
C
12421 lines
488 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 "pipeline/artifact.h"
|
||
#include "store/store.h"
|
||
#include "git/git_context.h"
|
||
#include "foundation/dump_verify.h"
|
||
#include "foundation/sha256.h"
|
||
#include "foundation/compat_fs.h"
|
||
#include "foundation/log.h"
|
||
#include "foundation/win_utf8.h" // cbm_utf8_to_wide (Windows pipeline_test_set_mtime); no-op elsewhere
|
||
#include "discover/userconfig.h"
|
||
|
||
#include <stdlib.h>
|
||
#include <stdarg.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;
|
||
}
|
||
|
||
/* True iff the exact named CALLS edge exists and its serialized strategy
|
||
* contains `strategy_fragment`. Parallel synthetic-carrier regressions use
|
||
* this on a separate ordinary-call control: it proves the cross-file LSP ran
|
||
* successfully for the language/target, rather than letting a registry-only
|
||
* edge make a broken fixture look healthy. */
|
||
static bool cross_file_call_has_strategy(cbm_store_t *s, const char *project, const char *src_name,
|
||
const char *tgt_name, const char *strategy_fragment) {
|
||
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 && edges[j].properties_json &&
|
||
strstr(edges[j].properties_json, strategy_fragment)) {
|
||
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;
|
||
}
|
||
|
||
/* Nix attrpath qualification, end to end. A call inside a scoped binding must
|
||
* source to the QUALIFIED definition.
|
||
*
|
||
* This has to be a pipeline test rather than an extraction one. Definition QNs
|
||
* (extract_defs.c) and call-scope QNs (extract_unified.c) are computed by two
|
||
* separate functions. If they disagree by even one segment, the CALLS edge names
|
||
* a source node that was never minted and is dropped at write — with no error,
|
||
* and with every extraction-level assertion still passing. Only the store sees it.
|
||
*
|
||
* Both callers below are scoped, one by an enclosing attrset and one by a dotted
|
||
* attrpath, so this covers both routes into the qualified name. */
|
||
TEST(pipeline_nix_scoped_binding_calls_resolve) {
|
||
if (setup_test_repo() != 0) {
|
||
FAIL("failed to create temp dir");
|
||
}
|
||
|
||
char nix_path[512];
|
||
snprintf(nix_path, sizeof(nix_path), "%s/lib.nix", g_tmpdir);
|
||
FILE *nf = fopen(nix_path, "w");
|
||
if (!nf) {
|
||
teardown_test_repo();
|
||
FAIL("failed to write nix fixture");
|
||
}
|
||
fprintf(nf, "{ prelude }:\n"
|
||
"let\n"
|
||
" nixTarget = t: t + 1;\n"
|
||
"in\n"
|
||
"{\n"
|
||
" setA = { nixCaller = x: nixTarget x; };\n"
|
||
" outer.inner.nixDeepCaller = y: nixTarget y;\n"
|
||
"}\n");
|
||
fclose(nf);
|
||
|
||
char nix_db[512];
|
||
snprintf(nix_db, sizeof(nix_db), "%s/test_nix_calls.db", g_tmpdir);
|
||
|
||
cbm_pipeline_t *np = cbm_pipeline_new(g_tmpdir, nix_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(np);
|
||
ASSERT_EQ(cbm_pipeline_run(np), 0);
|
||
|
||
cbm_store_t *ns = cbm_store_open_path(nix_db);
|
||
ASSERT_NOT_NULL(ns);
|
||
const char *nix_project = cbm_pipeline_project_name(np);
|
||
|
||
/* Scoped by an enclosing attrset: the def QN is proj.lib.setA.nixCaller. */
|
||
ASSERT(cross_file_call_exists(ns, nix_project, "nixCaller", "nixTarget"));
|
||
/* Scoped by a dotted attrpath: proj.lib.outer.inner.nixDeepCaller. */
|
||
ASSERT(cross_file_call_exists(ns, nix_project, "nixDeepCaller", "nixTarget"));
|
||
|
||
cbm_store_close(ns);
|
||
cbm_pipeline_free(np);
|
||
teardown_test_repo();
|
||
PASS();
|
||
}
|
||
|
||
/* 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);
|
||
|
||
typedef struct {
|
||
const char *source_name;
|
||
const char *target_name;
|
||
} PreciseReferencePair;
|
||
|
||
typedef struct {
|
||
bool database_opened;
|
||
bool query_ready;
|
||
bool query_completed;
|
||
int row_count;
|
||
int valid_json_count;
|
||
int exact_callee_count;
|
||
} NamedEdgePropertyObservation;
|
||
|
||
/* Read one named edge's persisted properties through SQLite's JSON functions.
|
||
* The guarded json_extract is intentional: on the RED revision the parallel
|
||
* usage emitter truncates the final `}` from a boundary-sized callee property.
|
||
* json_valid must report that row without json_extract aborting the statement,
|
||
* while the fixed revision must round-trip the complete callee value. */
|
||
static NamedEdgePropertyObservation observe_named_edge_callee_property(
|
||
const char *db_path, const char *project, const char *edge_type, const char *source_name,
|
||
const char *target_name, const char *expected_callee) {
|
||
NamedEdgePropertyObservation observation = {0};
|
||
sqlite3 *db = NULL;
|
||
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) {
|
||
if (db) {
|
||
sqlite3_close(db);
|
||
}
|
||
return observation;
|
||
}
|
||
observation.database_opened = true;
|
||
|
||
static const char sql[] =
|
||
"SELECT e.properties, json_valid(e.properties), "
|
||
"CASE WHEN json_valid(e.properties) "
|
||
"THEN json_extract(e.properties, '$.callee') END "
|
||
"FROM edges e "
|
||
"JOIN nodes src ON src.id=e.source_id AND src.project=e.project "
|
||
"JOIN nodes tgt ON tgt.id=e.target_id AND tgt.project=e.project "
|
||
"WHERE e.project=?1 AND e.type=?2 AND src.name=?3 AND tgt.name=?4;";
|
||
sqlite3_stmt *stmt = NULL;
|
||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK ||
|
||
sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||
sqlite3_bind_text(stmt, 2, edge_type, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||
sqlite3_bind_text(stmt, 3, source_name, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||
sqlite3_bind_text(stmt, 4, target_name, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||
sqlite3_finalize(stmt);
|
||
sqlite3_close(db);
|
||
return observation;
|
||
}
|
||
observation.query_ready = true;
|
||
|
||
int step_rc = SQLITE_OK;
|
||
while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
|
||
observation.row_count++;
|
||
if (sqlite3_column_int(stmt, 1) != 0) {
|
||
observation.valid_json_count++;
|
||
}
|
||
const unsigned char *callee = sqlite3_column_text(stmt, 2);
|
||
if (callee && strcmp((const char *)callee, expected_callee) == 0) {
|
||
observation.exact_callee_count++;
|
||
}
|
||
}
|
||
observation.query_completed = step_rc == SQLITE_DONE;
|
||
sqlite3_finalize(stmt);
|
||
sqlite3_close(db);
|
||
return observation;
|
||
}
|
||
|
||
/* Count only edges of `edge_type` between the exact named source/target nodes.
|
||
* The parity and incremental guards deliberately use unique symbol names, so
|
||
* this canonicalizes across independent stores without depending on node IDs
|
||
* or project-prefixed qualified names. */
|
||
static int named_edge_count(cbm_store_t *s, const char *project, const char *edge_type,
|
||
const char *source_name, const char *target_name) {
|
||
cbm_edge_t *edges = NULL;
|
||
int edge_count = 0;
|
||
if (cbm_store_find_edges_by_type(s, project, edge_type, &edges, &edge_count) != CBM_STORE_OK) {
|
||
return -1;
|
||
}
|
||
|
||
int matches = 0;
|
||
for (int i = 0; i < edge_count; i++) {
|
||
cbm_node_t source = {0};
|
||
cbm_node_t target = {0};
|
||
int source_ok = cbm_store_find_node_by_id(s, edges[i].source_id, &source) == CBM_STORE_OK;
|
||
int target_ok = cbm_store_find_node_by_id(s, edges[i].target_id, &target) == CBM_STORE_OK;
|
||
if (source_ok && target_ok && source.name && target.name &&
|
||
strcmp(source.name, source_name) == 0 && strcmp(target.name, target_name) == 0) {
|
||
matches++;
|
||
}
|
||
cbm_node_free_fields(&source);
|
||
cbm_node_free_fields(&target);
|
||
}
|
||
if (edges) {
|
||
cbm_store_free_edges(edges, edge_count);
|
||
}
|
||
return matches;
|
||
}
|
||
|
||
/* Like named_edge_count, but distinguish same-named targets by their source
|
||
* file. Semantic-control fixtures intentionally keep the exported short name
|
||
* identical in two modules so a project-wide unique-name fallback cannot make
|
||
* an alias-mapping assertion pass accidentally. */
|
||
static int named_edge_to_file_count(cbm_store_t *s, const char *project, const char *edge_type,
|
||
const char *source_name, const char *target_name,
|
||
const char *target_file_path) {
|
||
cbm_edge_t *edges = NULL;
|
||
int edge_count = 0;
|
||
if (cbm_store_find_edges_by_type(s, project, edge_type, &edges, &edge_count) != CBM_STORE_OK) {
|
||
return -1;
|
||
}
|
||
|
||
int matches = 0;
|
||
for (int i = 0; i < edge_count; i++) {
|
||
cbm_node_t source = {0};
|
||
cbm_node_t target = {0};
|
||
int source_ok = cbm_store_find_node_by_id(s, edges[i].source_id, &source) == CBM_STORE_OK;
|
||
int target_ok = cbm_store_find_node_by_id(s, edges[i].target_id, &target) == CBM_STORE_OK;
|
||
if (source_ok && target_ok && source.name && target.name && target.file_path &&
|
||
strcmp(source.name, source_name) == 0 && strcmp(target.name, target_name) == 0 &&
|
||
strcmp(target.file_path, target_file_path) == 0) {
|
||
matches++;
|
||
}
|
||
cbm_node_free_fields(&source);
|
||
cbm_node_free_fields(&target);
|
||
}
|
||
if (edges) {
|
||
cbm_store_free_edges(edges, edge_count);
|
||
}
|
||
return matches;
|
||
}
|
||
|
||
/* Count exact-name nodes without depending on project-prefixed qualified names.
|
||
* Export-XML relationship tests use this as an anti-vacuous guard: the
|
||
* transcoded methods must exist even when their extracted relationships were
|
||
* accidentally discarded. */
|
||
static int named_node_count(cbm_store_t *s, const char *project, const char *name) {
|
||
cbm_node_t *nodes = NULL;
|
||
int count = 0;
|
||
if (cbm_store_find_nodes_by_name(s, project, name, &nodes, &count) != CBM_STORE_OK) {
|
||
return -1;
|
||
}
|
||
if (nodes) {
|
||
cbm_store_free_nodes(nodes, count);
|
||
}
|
||
return count;
|
||
}
|
||
|
||
typedef struct {
|
||
int run_rc;
|
||
bool store_opened;
|
||
int run_nodes;
|
||
int execute_nodes;
|
||
int run_execute_calls;
|
||
int shared_payload_nodes;
|
||
int run_shared_payload_usages;
|
||
} ObjectScriptExportObservation;
|
||
|
||
static ObjectScriptExportObservation observe_objectscript_export(const char *repo_path,
|
||
const char *db_name) {
|
||
ObjectScriptExportObservation observation = {
|
||
.run_rc = -1,
|
||
.store_opened = false,
|
||
.run_nodes = -1,
|
||
.execute_nodes = -1,
|
||
.run_execute_calls = -1,
|
||
.shared_payload_nodes = -1,
|
||
.run_shared_payload_usages = -1,
|
||
};
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s", repo_path, db_name);
|
||
cbm_pipeline_t *pipeline = cbm_pipeline_new(repo_path, db_path, CBM_MODE_FULL);
|
||
if (!pipeline) {
|
||
return observation;
|
||
}
|
||
|
||
observation.run_rc = cbm_pipeline_run(pipeline);
|
||
const char *project = cbm_pipeline_project_name(pipeline);
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
observation.store_opened = store != NULL;
|
||
if (store && project) {
|
||
observation.run_nodes = named_node_count(store, project, "Run");
|
||
observation.execute_nodes = named_node_count(store, project, "Execute");
|
||
observation.run_execute_calls = named_edge_count(store, project, "CALLS", "Run", "Execute");
|
||
observation.shared_payload_nodes = named_node_count(store, project, "SharedPayload");
|
||
observation.run_shared_payload_usages =
|
||
named_edge_count(store, project, "USAGE", "Run", "SharedPayload");
|
||
}
|
||
if (store) {
|
||
cbm_store_close(store);
|
||
}
|
||
cbm_pipeline_free(pipeline);
|
||
return observation;
|
||
}
|
||
|
||
/* Studio Export XML is transformed into ObjectScript UDL during extraction.
|
||
* The transformed definitions already survive, but the old sequential and
|
||
* fused-parallel special cases freed each transformed result after inserting
|
||
* only its definitions. Calls/usages therefore vanished even though direct
|
||
* extraction of the same generated UDL found them.
|
||
*
|
||
* One shared multi-class fixture is indexed through both execution paths. The
|
||
* 50 filler files only select the fused path; CBM_INDEX_SINGLE_THREAD forces
|
||
* the first run through the sequential path without duplicating setup.
|
||
* RED before the fix: Run and Execute each exist once, Run -> Execute is 0 in
|
||
* both stores. GREEN requires exactly one CALLS edge in each store. */
|
||
TEST(pipeline_objectscript_export_preserves_calls_sequential_parallel) {
|
||
static const char export_xml[] = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||
"<Export generator=\"Cache\" version=\"25\">\n"
|
||
"<Class name=\"Test.Worker\">\n"
|
||
"<Method name=\"Execute\">\n"
|
||
"<ClassMethod>1</ClassMethod>\n"
|
||
"<FormalSpec>pValue:%String</FormalSpec>\n"
|
||
"<ReturnType>%Status</ReturnType>\n"
|
||
"<Implementation><![CDATA[\n"
|
||
" Quit $$$OK\n"
|
||
"]]></Implementation>\n"
|
||
"</Method>\n"
|
||
"</Class>\n"
|
||
"<Class name=\"Test.Caller\">\n"
|
||
"<Method name=\"Run\">\n"
|
||
"<ClassMethod>1</ClassMethod>\n"
|
||
"<FormalSpec>pPayload:%String</FormalSpec>\n"
|
||
"<ReturnType>%Status</ReturnType>\n"
|
||
"<Implementation><![CDATA[\n"
|
||
" Do ##class(Test.Worker).Execute(pPayload)\n"
|
||
" Quit $$$OK\n"
|
||
"]]></Implementation>\n"
|
||
"</Method>\n"
|
||
"</Class>\n"
|
||
"</Export>\n";
|
||
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_export_rel_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
char export_path[512];
|
||
snprintf(export_path, sizeof(export_path), "%s/studio-export.xml", tmp);
|
||
if (th_write_file(export_path, export_xml) != 0) {
|
||
th_rmtree(tmp);
|
||
FAIL("failed to write Studio Export fixture");
|
||
}
|
||
|
||
/* Export XML plus 50 trackable fillers exceeds MIN_FILES_FOR_PARALLEL. */
|
||
for (int i = 0; i < 50; i++) {
|
||
char filename[64];
|
||
char path[512];
|
||
char source[128];
|
||
snprintf(filename, sizeof(filename), "export_pad_%02d.ts", i);
|
||
snprintf(path, sizeof(path), "%s/%s", tmp, filename);
|
||
snprintf(source, sizeof(source), "export function exportPad%02d(): number { return %d; }\n",
|
||
i, i);
|
||
if (th_write_file(path, source) != 0) {
|
||
th_rmtree(tmp);
|
||
FAIL("failed to write parallel-selection fixture");
|
||
}
|
||
}
|
||
|
||
char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
|
||
char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
|
||
char *saved_single = old_single ? strdup(old_single) : NULL;
|
||
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1);
|
||
ObjectScriptExportObservation sequential =
|
||
observe_objectscript_export(tmp, "export-sequential.db");
|
||
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
cbm_setenv("CBM_WORKERS", "4", 1);
|
||
ObjectScriptExportObservation parallel = observe_objectscript_export(tmp, "export-parallel.db");
|
||
|
||
if (saved_workers) {
|
||
cbm_setenv("CBM_WORKERS", saved_workers, 1);
|
||
free(saved_workers);
|
||
} else {
|
||
cbm_unsetenv("CBM_WORKERS");
|
||
}
|
||
if (saved_single) {
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1);
|
||
free(saved_single);
|
||
} else {
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(sequential.run_rc, 0);
|
||
ASSERT_TRUE(sequential.store_opened);
|
||
ASSERT_EQ(sequential.run_nodes, 1);
|
||
ASSERT_EQ(sequential.execute_nodes, 1);
|
||
|
||
ASSERT_EQ(parallel.run_rc, 0);
|
||
ASSERT_TRUE(parallel.store_opened);
|
||
ASSERT_EQ(parallel.run_nodes, 1);
|
||
ASSERT_EQ(parallel.execute_nodes, 1);
|
||
if (sequential.run_execute_calls != 1 || parallel.run_execute_calls != 1) {
|
||
fprintf(stderr,
|
||
" [objectscript-export] invariant=transformed_calls_preserved "
|
||
"expected=1/1 actual=%d/%d\n",
|
||
sequential.run_execute_calls, parallel.run_execute_calls);
|
||
}
|
||
ASSERT_TRUE(sequential.run_execute_calls == 1 && parallel.run_execute_calls == 1);
|
||
PASS();
|
||
}
|
||
|
||
static int format_objectscript_export_lifecycle_fixture(char *xml, size_t xml_capacity,
|
||
const char *default_value) {
|
||
int written = snprintf(xml, xml_capacity,
|
||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||
"<Export generator=\"Cache\" version=\"25\">\n"
|
||
"<Class name=\"Test.Worker\">\n"
|
||
"<Method name=\"Execute\">\n"
|
||
"<ClassMethod>1</ClassMethod>\n"
|
||
"<FormalSpec>pValue:%%String</FormalSpec>\n"
|
||
"<ReturnType>%%Status</ReturnType>\n"
|
||
"<Implementation><![CDATA[\n"
|
||
" Quit $$$OK\n"
|
||
"]]></Implementation>\n"
|
||
"</Method>\n"
|
||
"</Class>\n"
|
||
"<Class name=\"Test.Caller\">\n"
|
||
"<Parameter name=\"SharedPayload\"><Default>%s</Default></Parameter>\n"
|
||
"<Method name=\"Run\">\n"
|
||
"<ClassMethod>1</ClassMethod>\n"
|
||
"<FormalSpec>watched:%%String</FormalSpec>\n"
|
||
"<ReturnType>%%Status</ReturnType>\n"
|
||
"<Implementation><![CDATA[\n"
|
||
" Do ##class(Test.Worker).Execute(watched)\n"
|
||
" Quit $$$OK\n"
|
||
"]]></Implementation>\n"
|
||
"</Method>\n"
|
||
"</Class>\n"
|
||
"</Export>\n",
|
||
default_value);
|
||
if (written <= 0 || (size_t)written >= xml_capacity) {
|
||
return -1;
|
||
}
|
||
return written;
|
||
}
|
||
|
||
static int write_objectscript_export_lifecycle_fixture(const char *path,
|
||
const char *default_value) {
|
||
char xml[4096];
|
||
int written = format_objectscript_export_lifecycle_fixture(xml, sizeof(xml), default_value);
|
||
if (written < 0) {
|
||
return -1;
|
||
}
|
||
return th_write_file(path, xml);
|
||
}
|
||
|
||
/* A changed Studio Export file must keep its transformed carriers alive for
|
||
* every incremental resolution pass. Compare the incrementally rebuilt graph
|
||
* with a clean full sequential build of the same replacement XML. Direct
|
||
* extraction separately proves that the transformed aggregate retains the
|
||
* method-argument USAGE carrier; formal parameters are not graph targets, so
|
||
* graph resolution of that carrier is outside this lifecycle regression.
|
||
* ObjectScript has no per-file or cross-file LSP dispatcher, so it cannot
|
||
* produce a resolved_calls semantic carrier to assert here.
|
||
*
|
||
* RED before the incremental cache lifecycle fix: definitions survived, but
|
||
* the changed Export was re-read as raw XML by later passes, so Run->Execute
|
||
* CALLS was absent from the incremental graph. */
|
||
TEST(pipeline_objectscript_export_incremental_matches_full_relationships) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_export_incr_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
char export_path[512];
|
||
snprintf(export_path, sizeof(export_path), "%s/studio-export.xml", tmp);
|
||
if (write_objectscript_export_lifecycle_fixture(export_path, "initial") != 0) {
|
||
th_rmtree(tmp);
|
||
FAIL("failed to write initial Studio Export fixture");
|
||
}
|
||
|
||
char carrier_xml[4096];
|
||
int carrier_xml_len =
|
||
format_objectscript_export_lifecycle_fixture(carrier_xml, sizeof(carrier_xml), "initial");
|
||
if (carrier_xml_len < 0) {
|
||
th_rmtree(tmp);
|
||
FAIL("failed to format Studio Export carrier fixture");
|
||
}
|
||
cbm_init();
|
||
CBMFileResult *carrier_result = cbm_pipeline_extract_objectscript_export(
|
||
carrier_xml, carrier_xml_len, "export-lifecycle", "studio-export.xml", NULL, NULL);
|
||
if (!carrier_result) {
|
||
th_rmtree(tmp);
|
||
FAIL("failed to extract Studio Export carrier fixture");
|
||
}
|
||
bool found_argument_usage = false;
|
||
for (int i = 0; i < carrier_result->usages.count; i++) {
|
||
const CBMUsage *usage = &carrier_result->usages.items[i];
|
||
if (usage->ref_name && strcmp(usage->ref_name, "watched") == 0 &&
|
||
usage->enclosing_func_qn && strstr(usage->enclosing_func_qn, ".Run")) {
|
||
found_argument_usage = true;
|
||
break;
|
||
}
|
||
}
|
||
cbm_free_result(carrier_result);
|
||
if (!found_argument_usage) {
|
||
th_rmtree(tmp);
|
||
FAIL("Studio Export aggregate lost method-argument USAGE carrier");
|
||
}
|
||
|
||
char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
|
||
char *saved_single = old_single ? strdup(old_single) : NULL;
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1);
|
||
|
||
ObjectScriptExportObservation initial =
|
||
observe_objectscript_export(tmp, "export-incremental.db");
|
||
|
||
if (write_objectscript_export_lifecycle_fixture(export_path,
|
||
"replacement-with-a-different-size") != 0) {
|
||
if (saved_single) {
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1);
|
||
free(saved_single);
|
||
} else {
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
}
|
||
th_rmtree(tmp);
|
||
FAIL("failed to write replacement Studio Export fixture");
|
||
}
|
||
|
||
/* Same DB routes through incremental; a fresh DB is the full reference. */
|
||
ObjectScriptExportObservation incremental =
|
||
observe_objectscript_export(tmp, "export-incremental.db");
|
||
ObjectScriptExportObservation full =
|
||
observe_objectscript_export(tmp, "export-full-reference.db");
|
||
|
||
if (saved_single) {
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1);
|
||
free(saved_single);
|
||
} else {
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(initial.run_rc, 0);
|
||
ASSERT_TRUE(initial.store_opened);
|
||
ASSERT_EQ(initial.run_execute_calls, 1);
|
||
|
||
ASSERT_EQ(incremental.run_rc, 0);
|
||
ASSERT_TRUE(incremental.store_opened);
|
||
ASSERT_EQ(full.run_rc, 0);
|
||
ASSERT_TRUE(full.store_opened);
|
||
ASSERT_EQ(incremental.run_nodes, full.run_nodes);
|
||
ASSERT_EQ(incremental.execute_nodes, full.execute_nodes);
|
||
ASSERT_EQ(incremental.shared_payload_nodes, full.shared_payload_nodes);
|
||
ASSERT_EQ(incremental.run_execute_calls, full.run_execute_calls);
|
||
ASSERT_EQ(incremental.run_shared_payload_usages, full.run_shared_payload_usages);
|
||
ASSERT_EQ(full.run_nodes, 1);
|
||
ASSERT_EQ(full.execute_nodes, 1);
|
||
ASSERT_EQ(full.shared_payload_nodes, 1);
|
||
ASSERT_EQ(full.run_execute_calls, 1);
|
||
PASS();
|
||
}
|
||
|
||
static bool test_buffer_appendf(char *buffer, size_t capacity, size_t *used, const char *format,
|
||
...) {
|
||
if (!buffer || !used || *used >= capacity) {
|
||
return false;
|
||
}
|
||
va_list args;
|
||
va_start(args, format);
|
||
int written = vsnprintf(buffer + *used, capacity - *used, format, args);
|
||
va_end(args);
|
||
if (written < 0 || (size_t)written >= capacity - *used) {
|
||
return false;
|
||
}
|
||
*used += (size_t)written;
|
||
return true;
|
||
}
|
||
|
||
/* Each extracted class owns a separate arena. A CBMCall includes eight argument
|
||
* slots, so growing its call array through the 513th item requires at least four
|
||
* arena blocks per class. Sixty-four classes therefore exceed the aggregate's
|
||
* fixed 256-block adoption table even before aggregate-array growth is counted,
|
||
* while staying within the Studio Export transcoder's existing 64-class cap.
|
||
*
|
||
* RED with fixed-block ownership transfer: aggregation returns NULL before all
|
||
* 64 classes can be composed. GREEN requires one result containing every class,
|
||
* method, and call carrier without raising the class limit. */
|
||
TEST(pipeline_objectscript_export_aggregate_exceeds_arena_block_table) {
|
||
enum {
|
||
STRESS_CLASS_COUNT = 64,
|
||
STRESS_CALLS_PER_CLASS = 513,
|
||
STRESS_XML_CAPACITY = 4 * 1024 * 1024,
|
||
};
|
||
char *xml = (char *)malloc(STRESS_XML_CAPACITY);
|
||
if (!xml) {
|
||
FAIL("stress XML allocation");
|
||
}
|
||
size_t used = 0;
|
||
bool complete = test_buffer_appendf(xml, STRESS_XML_CAPACITY, &used,
|
||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||
"<Export generator=\"Cache\" version=\"25\">\n");
|
||
for (int ci = 0; ci < STRESS_CLASS_COUNT && complete; ci++) {
|
||
complete = test_buffer_appendf(xml, STRESS_XML_CAPACITY, &used,
|
||
"<Class name=\"Arena.Stress%02d\">\n"
|
||
"<Method name=\"Run%02d\">\n"
|
||
"<ClassMethod>1</ClassMethod>\n"
|
||
"<Implementation><![CDATA[\n",
|
||
ci, ci);
|
||
for (int call = 0; call < STRESS_CALLS_PER_CLASS && complete; call++) {
|
||
complete = test_buffer_appendf(xml, STRESS_XML_CAPACITY, &used,
|
||
" Do ##class(Arena.Target).Execute()\n");
|
||
}
|
||
complete = complete && test_buffer_appendf(xml, STRESS_XML_CAPACITY, &used,
|
||
" Quit\n"
|
||
"]]></Implementation>\n"
|
||
"</Method>\n"
|
||
"</Class>\n");
|
||
}
|
||
complete = complete && test_buffer_appendf(xml, STRESS_XML_CAPACITY, &used, "</Export>\n");
|
||
if (!complete) {
|
||
free(xml);
|
||
FAIL("stress XML capacity");
|
||
}
|
||
|
||
cbm_init();
|
||
CBMFileResult *aggregate = cbm_pipeline_extract_objectscript_export(
|
||
xml, (int)used, "arena-stress", "studio-export.xml", NULL, NULL);
|
||
free(xml);
|
||
ASSERT_NOT_NULL(aggregate);
|
||
|
||
bool found_last_method = false;
|
||
for (int i = 0; i < aggregate->defs.count; i++) {
|
||
const CBMDefinition *def = &aggregate->defs.items[i];
|
||
if (def->name && strcmp(def->name, "Run63") == 0) {
|
||
found_last_method = true;
|
||
break;
|
||
}
|
||
}
|
||
int expected_calls = STRESS_CLASS_COUNT * STRESS_CALLS_PER_CLASS;
|
||
int actual_calls = aggregate->calls.count;
|
||
int actual_defs = aggregate->defs.count;
|
||
cbm_free_result(aggregate);
|
||
|
||
ASSERT_TRUE(found_last_method);
|
||
ASSERT_EQ(actual_calls, expected_calls);
|
||
ASSERT_GTE(actual_defs, STRESS_CLASS_COUNT * 2);
|
||
PASS();
|
||
}
|
||
|
||
typedef struct {
|
||
int run_rc;
|
||
bool store_opened;
|
||
int source_nodes;
|
||
int target_nodes;
|
||
int edge_count;
|
||
} NamedEdgeObservation;
|
||
|
||
static NamedEdgeObservation observe_named_edge(const char *repo_path, const char *db_name,
|
||
const char *edge_type, const char *source_name,
|
||
const char *target_name) {
|
||
NamedEdgeObservation observation = {
|
||
.run_rc = -1,
|
||
.store_opened = false,
|
||
.source_nodes = -1,
|
||
.target_nodes = -1,
|
||
.edge_count = -1,
|
||
};
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s", repo_path, db_name);
|
||
cbm_pipeline_t *pipeline = cbm_pipeline_new(repo_path, db_path, CBM_MODE_FULL);
|
||
if (!pipeline) {
|
||
return observation;
|
||
}
|
||
observation.run_rc = cbm_pipeline_run(pipeline);
|
||
const char *project = cbm_pipeline_project_name(pipeline);
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
observation.store_opened = store != NULL;
|
||
if (store && project) {
|
||
observation.source_nodes = named_node_count(store, project, source_name);
|
||
observation.target_nodes = named_node_count(store, project, target_name);
|
||
observation.edge_count =
|
||
named_edge_count(store, project, edge_type, source_name, target_name);
|
||
}
|
||
if (store) {
|
||
cbm_store_close(store);
|
||
}
|
||
cbm_pipeline_free(pipeline);
|
||
return observation;
|
||
}
|
||
|
||
/* env_accesses are extracted independently from calls and must materialize the
|
||
* same EnvVar + CONFIGURES relationship in both pipeline implementations.
|
||
*
|
||
* RED before the fused-path consumer is added: sequential definition insertion
|
||
* creates envParityRead -> CBM_PARITY_TOKEN, while cbm_build_registry_from_cache
|
||
* ignores result->env_accesses and the parallel graph contains neither target
|
||
* nor edge. */
|
||
TEST(pipeline_env_access_configures_sequential_parallel_parity) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_env_parity_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
char source_path[512];
|
||
snprintf(source_path, sizeof(source_path), "%s/env.go", tmp);
|
||
if (th_write_file(source_path, "package envparity\n\n"
|
||
"import \"os\"\n\n"
|
||
"func envParityRead() string {\n"
|
||
" return os.Getenv(\"CBM_PARITY_TOKEN\")\n"
|
||
"}\n") != 0) {
|
||
th_rmtree(tmp);
|
||
FAIL("failed to write env-access fixture");
|
||
}
|
||
for (int i = 0; i < 50; i++) {
|
||
char filename[64];
|
||
char path[512];
|
||
char source[128];
|
||
snprintf(filename, sizeof(filename), "env_pad_%02d.ts", i);
|
||
snprintf(path, sizeof(path), "%s/%s", tmp, filename);
|
||
snprintf(source, sizeof(source), "export function envPad%02d(): number { return %d; }\n", i,
|
||
i);
|
||
if (th_write_file(path, source) != 0) {
|
||
th_rmtree(tmp);
|
||
FAIL("failed to write env parity filler");
|
||
}
|
||
}
|
||
|
||
char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
|
||
char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
|
||
char *saved_single = old_single ? strdup(old_single) : NULL;
|
||
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1);
|
||
NamedEdgeObservation sequential = observe_named_edge(tmp, "env-sequential.db", "CONFIGURES",
|
||
"envParityRead", "CBM_PARITY_TOKEN");
|
||
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
cbm_setenv("CBM_WORKERS", "4", 1);
|
||
NamedEdgeObservation parallel = observe_named_edge(tmp, "env-parallel.db", "CONFIGURES",
|
||
"envParityRead", "CBM_PARITY_TOKEN");
|
||
|
||
if (saved_workers) {
|
||
cbm_setenv("CBM_WORKERS", saved_workers, 1);
|
||
free(saved_workers);
|
||
} else {
|
||
cbm_unsetenv("CBM_WORKERS");
|
||
}
|
||
if (saved_single) {
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1);
|
||
free(saved_single);
|
||
} else {
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(sequential.run_rc, 0);
|
||
ASSERT_TRUE(sequential.store_opened);
|
||
ASSERT_EQ(sequential.source_nodes, 1);
|
||
ASSERT_EQ(sequential.target_nodes, 1);
|
||
ASSERT_EQ(sequential.edge_count, 1);
|
||
|
||
ASSERT_EQ(parallel.run_rc, 0);
|
||
ASSERT_TRUE(parallel.store_opened);
|
||
ASSERT_EQ(parallel.source_nodes, sequential.source_nodes);
|
||
ASSERT_EQ(parallel.target_nodes, sequential.target_nodes);
|
||
ASSERT_EQ(parallel.edge_count, sequential.edge_count);
|
||
PASS();
|
||
}
|
||
|
||
/* Sequential and fused-parallel resolution must materialize the same precise
|
||
* CALL_REFERENCE edge set. The table covers each independently implemented
|
||
* resolver family involved in callable-value/reference semantics; sibling
|
||
* frontends share those resolvers (JS/TSX -> TS, C++/CUDA -> C).
|
||
*
|
||
* Every pair uses a globally unique source and target name. That lets the test
|
||
* compare the semantic edge multiset across stores while ignoring unstable DB
|
||
* IDs and project-name prefixes. The fixture exceeds MIN_FILES_FOR_PARALLEL;
|
||
* CBM_INDEX_SINGLE_THREAD forces the first run through the sequential path and
|
||
* CBM_WORKERS forces the second through pass_parallel.c. */
|
||
TEST(pipeline_call_reference_sequential_parallel_edge_set_parity) {
|
||
static const PreciseReferencePair pairs[] = {
|
||
{"goReferenceSite", "goReferenceTarget"},
|
||
{"pythonReferenceSite", "pythonReferenceTarget"},
|
||
{"cReferenceSite", "cReferenceTarget"},
|
||
{"rustReferenceSite", "rustReferenceTarget"},
|
||
{"csharpReferenceSite", "csharpReferenceTarget"},
|
||
{"javascriptReferenceSite", "javascriptReferenceTarget"},
|
||
{"typescriptReferenceSite", "typescriptReferenceTarget"},
|
||
{"tsxReferenceSite", "tsxReferenceTarget"},
|
||
{"cppReferenceSite", "cppReferenceTarget"},
|
||
{"cudaReferenceSite", "cudaReferenceTarget"},
|
||
{"javaReferenceSite", "javaReferenceTarget"},
|
||
{"kotlinReferenceSite", "kotlinReferenceTarget"},
|
||
{"phpReferenceSite", "phpReferenceTarget"},
|
||
{"perlReferenceSite", "perlReferenceTarget"},
|
||
};
|
||
enum { pair_count = (int)(sizeof(pairs) / sizeof(pairs[0])) };
|
||
static const PreciseReferencePair shadow_pairs[] = {
|
||
{"pythonShadowSite", "pythonShadowTarget"},
|
||
{"javascriptShadowSite", "javascriptShadowTarget"},
|
||
{"typescriptShadowSite", "typescriptShadowTarget"},
|
||
};
|
||
static const PreciseReferencePair shadow_controls[] = {
|
||
{"pythonShadowSite", "pythonShadowAccept"},
|
||
{"javascriptShadowSite", "javascriptShadowAccept"},
|
||
{"typescriptShadowSite", "typescriptShadowAccept"},
|
||
};
|
||
enum { shadow_count = (int)(sizeof(shadow_pairs) / sizeof(shadow_pairs[0])) };
|
||
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_ref_parity_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
write_temp_file(tmp, "parity.go",
|
||
"package parity\n"
|
||
"func goReferenceTarget() {}\n"
|
||
"func goReferenceAccept(callback func()) {}\n"
|
||
"func goReferenceSite() { goReferenceAccept(goReferenceTarget) }\n");
|
||
/* `{"callee":""}` contributes 13 bytes. A 243-byte identifier therefore
|
||
* produces 256 bytes of JSON content: it fits the usage emitter's escaped
|
||
* value buffer but needs 257 bytes including NUL for the wrapper. The
|
||
* sequential 512-byte wrapper is valid; the parallel 256-byte wrapper is
|
||
* RED because snprintf drops the closing brace. */
|
||
enum { LONG_REFERENCE_NAME_LEN = 243 };
|
||
char long_reference_name[LONG_REFERENCE_NAME_LEN + 1];
|
||
memset(long_reference_name, 'x', LONG_REFERENCE_NAME_LEN);
|
||
long_reference_name[0] = 'l';
|
||
long_reference_name[LONG_REFERENCE_NAME_LEN] = '\0';
|
||
char long_reference_source[1024];
|
||
int long_reference_source_len = snprintf(
|
||
long_reference_source, sizeof(long_reference_source),
|
||
"package parity\n"
|
||
"func %s() {}\n"
|
||
"func longPropertiesReferenceAccept(callback func()) {}\n"
|
||
"func longPropertiesReferenceSite() { longPropertiesReferenceAccept(%s) }\n",
|
||
long_reference_name, long_reference_name);
|
||
if (long_reference_source_len <= 0 ||
|
||
(size_t)long_reference_source_len >= sizeof(long_reference_source)) {
|
||
th_rmtree(tmp);
|
||
FAIL("long reference source buffer");
|
||
}
|
||
write_temp_file(tmp, "long_properties.go", long_reference_source);
|
||
write_temp_file(tmp, "python_target.py",
|
||
"def pythonReferenceTarget():\n"
|
||
" pass\n");
|
||
write_temp_file(tmp, "parity.py",
|
||
"from python_target import pythonReferenceTarget as pythonReferenceAlias\n"
|
||
"def pythonReferenceAccept(callback):\n"
|
||
" pass\n"
|
||
"def pythonReferenceSite():\n"
|
||
" pythonReferenceAccept(pythonReferenceAlias)\n");
|
||
write_temp_file(tmp, "parity.c",
|
||
"typedef void (*c_reference_callback_t)(void);\n"
|
||
"void cReferenceTarget(void) {}\n"
|
||
"void cReferenceAccept(c_reference_callback_t callback) {}\n"
|
||
"void cReferenceSite(void) { cReferenceAccept(cReferenceTarget); }\n");
|
||
write_temp_file(tmp, "parity.rs",
|
||
"fn rustReferenceTarget() {}\n"
|
||
"fn rustReferenceAccept(_callback: fn()) {}\n"
|
||
"fn rustReferenceSite() { rustReferenceAccept(rustReferenceTarget); }\n");
|
||
write_temp_file(tmp, "Parity.cs",
|
||
"using System;\n"
|
||
"class CSharpParity {\n"
|
||
" static void csharpReferenceTarget() {}\n"
|
||
" static void csharpReferenceAccept(Action callback) {}\n"
|
||
" static void csharpReferenceSite() { "
|
||
"csharpReferenceAccept(csharpReferenceTarget); }\n"
|
||
"}\n");
|
||
write_temp_file(tmp, "parity.js",
|
||
"function javascriptReferenceTarget() {}\n"
|
||
"function javascriptReferenceAccept(callback) {}\n"
|
||
"function javascriptReferenceSite() {\n"
|
||
" javascriptReferenceAccept(javascriptReferenceTarget);\n"
|
||
"}\n");
|
||
write_temp_file(tmp, "parity.ts",
|
||
"class TypeScriptParity { typescriptReferenceTarget(): void {} }\n"
|
||
"function typescriptReferenceAccept(callback: () => void): void {}\n"
|
||
"function typescriptReferenceSite(service: TypeScriptParity): void {\n"
|
||
" typescriptReferenceAccept(service.typescriptReferenceTarget);\n"
|
||
"}\n");
|
||
write_temp_file(tmp, "parity.tsx",
|
||
"function tsxReferenceTarget(): void {}\n"
|
||
"function tsxReferenceAccept(callback: () => void): void {}\n"
|
||
"function tsxReferenceSite(): void {\n"
|
||
" tsxReferenceAccept(tsxReferenceTarget);\n"
|
||
"}\n");
|
||
write_temp_file(tmp, "parity.cpp",
|
||
"using cpp_reference_callback_t = void (*)();\n"
|
||
"void cppReferenceTarget() {}\n"
|
||
"void cppReferenceAccept(cpp_reference_callback_t callback) {}\n"
|
||
"void cppReferenceSite() { cppReferenceAccept(cppReferenceTarget); }\n");
|
||
write_temp_file(tmp, "parity.cu",
|
||
"typedef void (*cuda_reference_callback_t)(void);\n"
|
||
"__device__ void cudaReferenceTarget(void) {}\n"
|
||
"__device__ void cudaReferenceAccept(cuda_reference_callback_t callback) {}\n"
|
||
"__device__ void cudaReferenceSite(void) {\n"
|
||
" cudaReferenceAccept(cudaReferenceTarget);\n"
|
||
"}\n");
|
||
write_temp_file(tmp, "JavaParity.java",
|
||
"interface JavaParityTask { void run(); }\n"
|
||
"class JavaParity {\n"
|
||
" static void javaReferenceTarget() {}\n"
|
||
" static void javaReferenceAccept(JavaParityTask callback) {}\n"
|
||
" static void javaReferenceSite() { "
|
||
"javaReferenceAccept(JavaParity::javaReferenceTarget); }\n"
|
||
"}\n");
|
||
write_temp_file(tmp, "parity.kt",
|
||
"fun kotlinReferenceTarget(): Unit {}\n"
|
||
"fun kotlinReferenceAccept(callback: () -> Unit): Unit {}\n"
|
||
"fun kotlinReferenceSite(): Unit { "
|
||
"kotlinReferenceAccept(::kotlinReferenceTarget) }\n");
|
||
write_temp_file(tmp, "parity.php",
|
||
"<?php\n"
|
||
"function phpReferenceTarget(): void {}\n"
|
||
"function phpReferenceAccept(callable $callback): void {}\n"
|
||
"function phpReferenceSite(): void { "
|
||
"phpReferenceAccept(phpReferenceTarget(...)); }\n");
|
||
write_temp_file(tmp, "parity.pl",
|
||
"sub perlReferenceTarget {}\n"
|
||
"sub perlReferenceAccept { my ($callback) = @_; }\n"
|
||
"sub perlReferenceSite { "
|
||
"perlReferenceAccept(\\&perlReferenceTarget); }\n");
|
||
|
||
write_temp_file(tmp, "shadow.py",
|
||
"def pythonShadowTarget():\n"
|
||
" pass\n"
|
||
"def pythonShadowAccept(callback):\n"
|
||
" pass\n"
|
||
"def pythonShadowSite():\n"
|
||
" pythonShadowAccept(pythonShadowTarget)\n"
|
||
" pythonShadowTarget = lambda: None\n");
|
||
write_temp_file(tmp, "shadow.js",
|
||
"function javascriptShadowTarget() {}\n"
|
||
"function javascriptShadowAccept(callback) {}\n"
|
||
"function javascriptShadowSite() {\n"
|
||
" javascriptShadowAccept(javascriptShadowTarget);\n"
|
||
" const javascriptShadowTarget = () => {};\n"
|
||
"}\n");
|
||
write_temp_file(tmp, "shadow.ts",
|
||
"function typescriptShadowTarget(): void {}\n"
|
||
"function typescriptShadowAccept(callback: () => void): void {}\n"
|
||
"function typescriptShadowSite(): void {\n"
|
||
" typescriptShadowAccept(typescriptShadowTarget);\n"
|
||
" const typescriptShadowTarget = (): void => {};\n"
|
||
"}\n");
|
||
|
||
/* Fifteen positive and three shadow fixtures plus 34 source fillers exceed
|
||
* the 50-file fused threshold without making the test larger than needed. */
|
||
for (int i = 0; i < 34; i++) {
|
||
char name[64];
|
||
char body[128];
|
||
snprintf(name, sizeof(name), "parity_pad_%02d.ts", i);
|
||
snprintf(body, sizeof(body), "export function parityPad%02d(): number { return %d; }\n", i,
|
||
i);
|
||
write_temp_file(tmp, name, body);
|
||
}
|
||
|
||
int sequential_reference[pair_count];
|
||
int sequential_usage[pair_count];
|
||
int sequential_calls[pair_count];
|
||
int parallel_reference[pair_count];
|
||
int parallel_usage[pair_count];
|
||
int parallel_calls[pair_count];
|
||
int sequential_shadow_leaks[shadow_count];
|
||
int parallel_shadow_leaks[shadow_count];
|
||
int sequential_shadow_controls[shadow_count];
|
||
int parallel_shadow_controls[shadow_count];
|
||
for (int i = 0; i < pair_count; i++) {
|
||
sequential_reference[i] = sequential_usage[i] = sequential_calls[i] = -1;
|
||
parallel_reference[i] = parallel_usage[i] = parallel_calls[i] = -1;
|
||
}
|
||
for (int i = 0; i < shadow_count; i++) {
|
||
sequential_shadow_leaks[i] = parallel_shadow_leaks[i] = -1;
|
||
sequential_shadow_controls[i] = parallel_shadow_controls[i] = -1;
|
||
}
|
||
int sequential_total = -1;
|
||
int parallel_total = -1;
|
||
int sequential_long_reference = -1;
|
||
int sequential_long_usage = -1;
|
||
int sequential_long_calls = -1;
|
||
int parallel_long_reference = -1;
|
||
int parallel_long_usage = -1;
|
||
int parallel_long_calls = -1;
|
||
NamedEdgePropertyObservation sequential_long_property = {0};
|
||
NamedEdgePropertyObservation parallel_long_property = {0};
|
||
|
||
char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
|
||
char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
|
||
char *saved_single = old_single ? strdup(old_single) : NULL;
|
||
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1);
|
||
char sequential_db_path[512];
|
||
snprintf(sequential_db_path, sizeof(sequential_db_path), "%s/reference_sequential.db", tmp);
|
||
cbm_pipeline_t *sequential = cbm_pipeline_new(tmp, sequential_db_path, CBM_MODE_FULL);
|
||
int sequential_run_rc = sequential ? cbm_pipeline_run(sequential) : -1;
|
||
const char *sequential_project = sequential ? cbm_pipeline_project_name(sequential) : NULL;
|
||
cbm_store_t *sequential_store = cbm_store_open_path(sequential_db_path);
|
||
bool sequential_store_opened = sequential_store != NULL;
|
||
if (sequential_store && sequential_project) {
|
||
sequential_total =
|
||
cbm_store_count_edges_by_type(sequential_store, sequential_project, "CALL_REFERENCE");
|
||
for (int i = 0; i < pair_count; i++) {
|
||
sequential_reference[i] =
|
||
named_edge_count(sequential_store, sequential_project, "CALL_REFERENCE",
|
||
pairs[i].source_name, pairs[i].target_name);
|
||
sequential_usage[i] = named_edge_count(sequential_store, sequential_project, "USAGE",
|
||
pairs[i].source_name, pairs[i].target_name);
|
||
sequential_calls[i] = named_edge_count(sequential_store, sequential_project, "CALLS",
|
||
pairs[i].source_name, pairs[i].target_name);
|
||
}
|
||
for (int i = 0; i < shadow_count; i++) {
|
||
sequential_shadow_leaks[i] =
|
||
named_edge_count(sequential_store, sequential_project, "CALL_REFERENCE",
|
||
shadow_pairs[i].source_name, shadow_pairs[i].target_name) +
|
||
named_edge_count(sequential_store, sequential_project, "USAGE",
|
||
shadow_pairs[i].source_name, shadow_pairs[i].target_name) +
|
||
named_edge_count(sequential_store, sequential_project, "CALLS",
|
||
shadow_pairs[i].source_name, shadow_pairs[i].target_name);
|
||
sequential_shadow_controls[i] =
|
||
named_edge_count(sequential_store, sequential_project, "CALLS",
|
||
shadow_controls[i].source_name, shadow_controls[i].target_name);
|
||
}
|
||
sequential_long_reference = named_edge_count(
|
||
sequential_store, sequential_project, "CALL_REFERENCE", "longPropertiesReferenceSite",
|
||
long_reference_name);
|
||
sequential_long_usage = named_edge_count(sequential_store, sequential_project, "USAGE",
|
||
"longPropertiesReferenceSite",
|
||
long_reference_name);
|
||
sequential_long_calls = named_edge_count(sequential_store, sequential_project, "CALLS",
|
||
"longPropertiesReferenceSite",
|
||
long_reference_name);
|
||
sequential_long_property = observe_named_edge_callee_property(
|
||
sequential_db_path, sequential_project, "CALL_REFERENCE",
|
||
"longPropertiesReferenceSite", long_reference_name, long_reference_name);
|
||
cbm_store_close(sequential_store);
|
||
}
|
||
cbm_pipeline_free(sequential);
|
||
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
cbm_setenv("CBM_WORKERS", "4", 1);
|
||
char parallel_db_path[512];
|
||
snprintf(parallel_db_path, sizeof(parallel_db_path), "%s/reference_parallel.db", tmp);
|
||
cbm_pipeline_t *parallel = cbm_pipeline_new(tmp, parallel_db_path, CBM_MODE_FULL);
|
||
int parallel_run_rc = parallel ? cbm_pipeline_run(parallel) : -1;
|
||
const char *parallel_project = parallel ? cbm_pipeline_project_name(parallel) : NULL;
|
||
cbm_store_t *parallel_store = cbm_store_open_path(parallel_db_path);
|
||
bool parallel_store_opened = parallel_store != NULL;
|
||
if (parallel_store && parallel_project) {
|
||
parallel_total =
|
||
cbm_store_count_edges_by_type(parallel_store, parallel_project, "CALL_REFERENCE");
|
||
for (int i = 0; i < pair_count; i++) {
|
||
parallel_reference[i] =
|
||
named_edge_count(parallel_store, parallel_project, "CALL_REFERENCE",
|
||
pairs[i].source_name, pairs[i].target_name);
|
||
parallel_usage[i] = named_edge_count(parallel_store, parallel_project, "USAGE",
|
||
pairs[i].source_name, pairs[i].target_name);
|
||
parallel_calls[i] = named_edge_count(parallel_store, parallel_project, "CALLS",
|
||
pairs[i].source_name, pairs[i].target_name);
|
||
}
|
||
for (int i = 0; i < shadow_count; i++) {
|
||
parallel_shadow_leaks[i] =
|
||
named_edge_count(parallel_store, parallel_project, "CALL_REFERENCE",
|
||
shadow_pairs[i].source_name, shadow_pairs[i].target_name) +
|
||
named_edge_count(parallel_store, parallel_project, "USAGE",
|
||
shadow_pairs[i].source_name, shadow_pairs[i].target_name) +
|
||
named_edge_count(parallel_store, parallel_project, "CALLS",
|
||
shadow_pairs[i].source_name, shadow_pairs[i].target_name);
|
||
parallel_shadow_controls[i] =
|
||
named_edge_count(parallel_store, parallel_project, "CALLS",
|
||
shadow_controls[i].source_name, shadow_controls[i].target_name);
|
||
}
|
||
parallel_long_reference = named_edge_count(
|
||
parallel_store, parallel_project, "CALL_REFERENCE", "longPropertiesReferenceSite",
|
||
long_reference_name);
|
||
parallel_long_usage = named_edge_count(parallel_store, parallel_project, "USAGE",
|
||
"longPropertiesReferenceSite", long_reference_name);
|
||
parallel_long_calls = named_edge_count(parallel_store, parallel_project, "CALLS",
|
||
"longPropertiesReferenceSite", long_reference_name);
|
||
parallel_long_property = observe_named_edge_callee_property(
|
||
parallel_db_path, parallel_project, "CALL_REFERENCE", "longPropertiesReferenceSite",
|
||
long_reference_name, long_reference_name);
|
||
cbm_store_close(parallel_store);
|
||
}
|
||
cbm_pipeline_free(parallel);
|
||
|
||
if (saved_workers) {
|
||
cbm_setenv("CBM_WORKERS", saved_workers, 1);
|
||
free(saved_workers);
|
||
} else {
|
||
cbm_unsetenv("CBM_WORKERS");
|
||
}
|
||
if (saved_single) {
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1);
|
||
free(saved_single);
|
||
} else {
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(sequential_run_rc, 0);
|
||
ASSERT_TRUE(sequential_store_opened);
|
||
ASSERT_EQ(parallel_run_rc, 0);
|
||
ASSERT_TRUE(parallel_store_opened);
|
||
ASSERT_EQ(sequential_long_reference, 1);
|
||
ASSERT_EQ(sequential_long_usage, 0);
|
||
ASSERT_EQ(sequential_long_calls, 0);
|
||
ASSERT_TRUE(sequential_long_property.database_opened);
|
||
ASSERT_TRUE(sequential_long_property.query_ready);
|
||
ASSERT_TRUE(sequential_long_property.query_completed);
|
||
ASSERT_EQ(sequential_long_property.row_count, 1);
|
||
ASSERT_EQ(sequential_long_property.valid_json_count, 1);
|
||
ASSERT_EQ(sequential_long_property.exact_callee_count, 1);
|
||
ASSERT_EQ(parallel_long_reference, 1);
|
||
ASSERT_EQ(parallel_long_usage, 0);
|
||
ASSERT_EQ(parallel_long_calls, 0);
|
||
ASSERT_TRUE(parallel_long_property.database_opened);
|
||
ASSERT_TRUE(parallel_long_property.query_ready);
|
||
ASSERT_TRUE(parallel_long_property.query_completed);
|
||
ASSERT_EQ(parallel_long_property.row_count, 1);
|
||
ASSERT_EQ(parallel_long_property.valid_json_count, 1);
|
||
ASSERT_EQ(parallel_long_property.exact_callee_count, 1);
|
||
ASSERT_EQ(parallel_total, sequential_total);
|
||
int expected_total = 0;
|
||
for (int i = 0; i < pair_count; i++) {
|
||
ASSERT_GT(sequential_reference[i], 0);
|
||
ASSERT_EQ(parallel_reference[i], sequential_reference[i]);
|
||
ASSERT_EQ(sequential_usage[i], 0);
|
||
ASSERT_EQ(parallel_usage[i], 0);
|
||
ASSERT_EQ(sequential_calls[i], 0);
|
||
ASSERT_EQ(parallel_calls[i], 0);
|
||
expected_total += sequential_reference[i];
|
||
}
|
||
expected_total += sequential_long_reference;
|
||
for (int i = 0; i < shadow_count; i++) {
|
||
ASSERT_EQ(sequential_shadow_controls[i], 1);
|
||
ASSERT_EQ(parallel_shadow_controls[i], 1);
|
||
ASSERT_EQ(sequential_shadow_leaks[i], 0);
|
||
ASSERT_EQ(parallel_shadow_leaks[i], 0);
|
||
}
|
||
ASSERT_EQ(sequential_total, expected_total);
|
||
PASS();
|
||
}
|
||
|
||
#ifdef _WIN32
|
||
/* utimensat/AT_FDCWD do not exist on Windows. Set the same instant through
|
||
* SetFileTime: FILETIME is 100ns ticks since 1601, the same representation
|
||
* cbm_path_info_utf8 reads back, so the round-trip loses nothing the
|
||
* incremental pipeline can observe. */
|
||
static int pipeline_test_set_mtime(const char *path, time_t seconds, long nanoseconds) {
|
||
uint64_t ticks = (uint64_t)seconds * UINT64_C(10000000) + (uint64_t)nanoseconds / 100U +
|
||
UINT64_C(116444736000000000);
|
||
FILETIME ft = {.dwLowDateTime = (DWORD)(ticks & 0xFFFFFFFFU),
|
||
.dwHighDateTime = (DWORD)(ticks >> 32)};
|
||
wchar_t *wpath = cbm_utf8_to_wide(path);
|
||
if (!wpath) {
|
||
return -1;
|
||
}
|
||
HANDLE h = CreateFileW(wpath, FILE_WRITE_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL,
|
||
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
|
||
free(wpath);
|
||
if (h == INVALID_HANDLE_VALUE) {
|
||
return -1;
|
||
}
|
||
BOOL ok = SetFileTime(h, NULL, &ft, &ft);
|
||
CloseHandle(h);
|
||
return ok ? 0 : -1;
|
||
}
|
||
#else
|
||
static int pipeline_test_set_mtime(const char *path, time_t seconds, long nanoseconds) {
|
||
struct timespec times[2] = {{.tv_sec = seconds, .tv_nsec = nanoseconds},
|
||
{.tv_sec = seconds, .tv_nsec = nanoseconds}};
|
||
return utimensat(AT_FDCWD, path, times, 0);
|
||
}
|
||
#endif
|
||
|
||
/* Reindexing a changed callable-value occurrence must replace its exact target,
|
||
* not preserve the old CALL_REFERENCE alongside the new one or regress the new
|
||
* occurrence to USAGE/CALLS. Equal-length source generations use the exact same
|
||
* mtime so metadata heuristics cannot make the test pass: byte hashes must route
|
||
* the semantic change to a full rebuild. A metadata-only mtime change is a true
|
||
* no-op and must not rebuild. */
|
||
TEST(pipeline_incremental_repoints_call_reference_without_stale_edge) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_ref_incr_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
static const char initial_source[] = "package refs\n"
|
||
"func alphaReferenceTarget() {}\n"
|
||
"func bravoReferenceTarget() {}\n"
|
||
"func incrementalReferenceAccept(callback func()) {}\n"
|
||
"func incrementalReferenceSite() { "
|
||
"incrementalReferenceAccept(alphaReferenceTarget) }\n";
|
||
static const char replacement_source[] = "package refs\n"
|
||
"func alphaReferenceTarget() {}\n"
|
||
"func bravoReferenceTarget() {}\n"
|
||
"func incrementalReferenceAccept(callback func()) {}\n"
|
||
"func incrementalReferenceSite() { "
|
||
"incrementalReferenceAccept(bravoReferenceTarget) }\n";
|
||
ASSERT_EQ(sizeof(initial_source), sizeof(replacement_source));
|
||
write_temp_file(tmp, "refs.go", initial_source);
|
||
char source_path[512];
|
||
snprintf(source_path, sizeof(source_path), "%s/refs.go", tmp);
|
||
const time_t fixed_mtime = 1700000000;
|
||
ASSERT_EQ(pipeline_test_set_mtime(source_path, fixed_mtime, 123456789L), 0);
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/reference_incremental.db", tmp);
|
||
cbm_pipeline_t *first = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(first);
|
||
ASSERT_EQ(cbm_pipeline_run(first), 0);
|
||
const char *first_project = cbm_pipeline_project_name(first);
|
||
cbm_store_t *first_store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(first_store);
|
||
ASSERT_EQ(named_edge_count(first_store, first_project, "CALL_REFERENCE",
|
||
"incrementalReferenceSite", "alphaReferenceTarget"),
|
||
1);
|
||
ASSERT_EQ(named_edge_count(first_store, first_project, "CALL_REFERENCE",
|
||
"incrementalReferenceSite", "bravoReferenceTarget"),
|
||
0);
|
||
ASSERT_EQ(named_edge_count(first_store, first_project, "USAGE", "incrementalReferenceSite",
|
||
"alphaReferenceTarget"),
|
||
0);
|
||
ASSERT_EQ(named_edge_count(first_store, first_project, "CALLS", "incrementalReferenceSite",
|
||
"alphaReferenceTarget"),
|
||
0);
|
||
cbm_file_hash_t stored_hash = {0};
|
||
ASSERT_EQ(cbm_store_get_file_hash(first_store, first_project, "refs.go", &stored_hash),
|
||
CBM_STORE_OK);
|
||
char expected_hash[CBM_SHA256_HEX_LEN + 1];
|
||
cbm_sha256_hex(initial_source, strlen(initial_source), expected_hash);
|
||
ASSERT_NOT_NULL(stored_hash.sha256);
|
||
ASSERT_STR_EQ(stored_hash.sha256, expected_hash);
|
||
cbm_store_clear_file_hash(&stored_hash);
|
||
cbm_coverage_meta_t baseline_meta = {0};
|
||
ASSERT_EQ(cbm_store_coverage_meta_get(first_store, first_project, &baseline_meta),
|
||
CBM_STORE_OK);
|
||
ASSERT_EQ(baseline_meta.coverage_version, CBM_SEMANTIC_INDEX_VERSION);
|
||
ASSERT_TRUE(baseline_meta.hash_records_complete);
|
||
cbm_store_coverage_meta_clear(&baseline_meta);
|
||
cbm_store_close(first_store);
|
||
cbm_pipeline_free(first);
|
||
|
||
/* Same bytes, metadata only: exact manifest equality is a no-op. */
|
||
ASSERT_EQ(pipeline_test_set_mtime(source_path, fixed_mtime + 1, 123456789L), 0);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *metadata_only = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(metadata_only);
|
||
ASSERT_EQ(cbm_pipeline_run(metadata_only), 0);
|
||
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_NOOP);
|
||
cbm_pipeline_free(metadata_only);
|
||
|
||
write_temp_file(tmp, "refs.go", replacement_source);
|
||
ASSERT_EQ(pipeline_test_set_mtime(source_path, fixed_mtime, 123456789L), 0);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *second = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(second);
|
||
ASSERT_EQ(cbm_pipeline_run(second), 0);
|
||
/* Since closure repair landed, a single-file content change with no
|
||
* added names routes to CLOSURE_REPAIR instead of a forced full
|
||
* rebuild; the convergence assertions below are unchanged and now pin
|
||
* the repaired graph against the same expectations. */
|
||
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||
const char *second_project = cbm_pipeline_project_name(second);
|
||
cbm_store_t *second_store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(second_store);
|
||
ASSERT_EQ(named_edge_count(second_store, second_project, "CALL_REFERENCE",
|
||
"incrementalReferenceSite", "alphaReferenceTarget"),
|
||
0);
|
||
ASSERT_EQ(named_edge_count(second_store, second_project, "CALL_REFERENCE",
|
||
"incrementalReferenceSite", "bravoReferenceTarget"),
|
||
1);
|
||
ASSERT_EQ(named_edge_count(second_store, second_project, "USAGE", "incrementalReferenceSite",
|
||
"bravoReferenceTarget"),
|
||
0);
|
||
ASSERT_EQ(named_edge_count(second_store, second_project, "CALLS", "incrementalReferenceSite",
|
||
"bravoReferenceTarget"),
|
||
0);
|
||
cbm_store_close(second_store);
|
||
cbm_pipeline_free(second);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* SQL DDL becomes first-class Table/View nodes wired into FROM/JOIN lineage,
|
||
* while the shared name registry must NOT leak those relations into other
|
||
* languages' textual resolution: a Python call or identifier sharing the
|
||
* table's name (`users`) would otherwise unique-name-bind a false CALLS/USAGE
|
||
* edge into the lineage layer. Pins the resolve-time relation veto
|
||
* (cbm_registry_resolve) together with the lineage opt-in
|
||
* (cbm_registry_resolve_lineage). */
|
||
TEST(pipeline_sql_lineage_and_relation_isolation) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_sql_lineage_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
write_temp_file(tmp, "schema.sql",
|
||
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);\n"
|
||
"CREATE VIEW active_users AS SELECT * FROM users;\n");
|
||
/* `users` exists project-wide ONLY as the SQL table, so without the
|
||
* relation veto the cross-file unique-name fallback would bind both the
|
||
* call and the bare reference below straight to the Table node. */
|
||
write_temp_file(tmp, "app.py",
|
||
"def load_users():\n"
|
||
" return users()\n"
|
||
"\n"
|
||
"def show_users():\n"
|
||
" return users\n");
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/sql_lineage.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);
|
||
/* Positive control: the view's FROM emits real lineage. */
|
||
ASSERT_EQ(named_edge_count(s, project, "USAGE", "active_users", "users"), 1);
|
||
/* Isolation: no Python edge of any kind reaches the Table. */
|
||
ASSERT_EQ(named_edge_count(s, project, "CALLS", "load_users", "users"), 0);
|
||
ASSERT_EQ(named_edge_count(s, project, "USAGE", "load_users", "users"), 0);
|
||
ASSERT_EQ(named_edge_count(s, project, "USAGE", "show_users", "users"), 0);
|
||
ASSERT_EQ(named_edge_count(s, project, "READS", "show_users", "users"), 0);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* dbt lineage end-to-end. A dbt project's dependency structure lives entirely
|
||
* in Jinja ({{ ref('x') }}), which the SQL grammar cannot read, so this is the
|
||
* whole value: model -> model edges across files, plus the join onto a Table
|
||
* declared in ordinary DDL — Model and Table are both relation labels, so one
|
||
* lineage layer spans both. The Python file is the isolation control: `stg_orders`
|
||
* exists project-wide only as a dbt model, and the registry's relation veto must
|
||
* keep a same-named call out of the lineage layer. */
|
||
TEST(pipeline_dbt_jinja_lineage) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dbt_lineage_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
write_temp_file(tmp, "raw_schema.sql", "CREATE TABLE customers (id INTEGER, name TEXT);\n");
|
||
write_temp_file(tmp, "stg_orders.sql",
|
||
"SELECT id, customer_id FROM {{ source('raw', 'customers') }}\n");
|
||
write_temp_file(tmp, "orders_enriched.sql",
|
||
"SELECT o.id, c.name\n"
|
||
"FROM {{ ref('stg_orders') }} o\n"
|
||
"JOIN {{ ref('stg_orders') }} c ON c.id = o.customer_id\n");
|
||
write_temp_file(tmp, "app.py", "def load():\n return stg_orders()\n");
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/dbt.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);
|
||
/* model -> model: the ref() lineage the SQL grammar cannot see */
|
||
ASSERT_TRUE(named_edge_count(s, project, "USAGE", "orders_enriched", "stg_orders") >= 1);
|
||
/* model -> table: source() joining dbt onto plain DDL in the same repo */
|
||
ASSERT_EQ(named_edge_count(s, project, "USAGE", "stg_orders", "customers"), 1);
|
||
/* isolation: the Python call must not reach the model */
|
||
ASSERT_EQ(named_edge_count(s, project, "CALLS", "load", "stg_orders"), 0);
|
||
ASSERT_EQ(named_edge_count(s, project, "USAGE", "load", "stg_orders"), 0);
|
||
cbm_store_close(s);
|
||
cbm_pipeline_free(p);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* Renaming a table must drop lineage from DEPENDENT (unchanged) SQL files on
|
||
* the incremental path. Table/View participate in the per-file LSP surface
|
||
* hash as registry-only labels (lsp_surface.c), so tables.sql's def change
|
||
* invalidates views.sql's resolution instead of slipping the early cutoff and
|
||
* leaving a stale view -> old-table USAGE edge. */
|
||
TEST(pipeline_incremental_sql_table_rename_drops_stale_lineage) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_sql_rename_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
write_temp_file(tmp, "tables.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);\n");
|
||
write_temp_file(tmp, "views.sql", "CREATE VIEW active AS SELECT * FROM users;\n");
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/sql_rename.db", tmp);
|
||
cbm_pipeline_t *first = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(first);
|
||
ASSERT_EQ(cbm_pipeline_run(first), 0);
|
||
const char *first_project = cbm_pipeline_project_name(first);
|
||
cbm_store_t *first_store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(first_store);
|
||
ASSERT_EQ(named_edge_count(first_store, first_project, "USAGE", "active", "users"), 1);
|
||
cbm_store_close(first_store);
|
||
cbm_pipeline_free(first);
|
||
|
||
write_temp_file(tmp, "tables.sql", "CREATE TABLE people (id INTEGER PRIMARY KEY);\n");
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *second = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(second);
|
||
ASSERT_EQ(cbm_pipeline_run(second), 0);
|
||
const char *second_project = cbm_pipeline_project_name(second);
|
||
cbm_store_t *second_store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(second_store);
|
||
/* The view's FROM still says `users`, which no longer exists: the old
|
||
* edge must be gone (no stale lineage), and the unchanged dependent must
|
||
* not have been rebound to the renamed table either. */
|
||
ASSERT_EQ(named_edge_count(second_store, second_project, "USAGE", "active", "users"), 0);
|
||
ASSERT_EQ(named_edge_count(second_store, second_project, "USAGE", "active", "people"), 0);
|
||
cbm_store_close(second_store);
|
||
cbm_pipeline_free(second);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* Re-indexing only the caller must retain cross-file semantic proof for a
|
||
* callable value whose definition lives in an unchanged file. A fresh full
|
||
* index of the edited sources is the convergence oracle: incremental output
|
||
* must not downgrade the exact CALL_REFERENCE to a generic USAGE. */
|
||
TEST(pipeline_incremental_cross_file_call_reference_matches_fresh_full) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_ref_cross_incr_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
static const char target_source[] = "export function importedIncrementalHandler(): void {}\n";
|
||
static const char initial_caller_source[] =
|
||
"import { importedIncrementalHandler } from './plugin';\n"
|
||
"function acceptIncrementalReference(callback: () => void): void {}\n"
|
||
"export function incrementalImportedArgument(): void {\n"
|
||
" acceptIncrementalReference(importedIncrementalHandler);\n"
|
||
"}\n";
|
||
static const char edited_caller_source[] =
|
||
"import { importedIncrementalHandler } from './plugin';\n"
|
||
"function acceptIncrementalReference(callback: () => void): void {}\n"
|
||
"export function incrementalImportedArgument(): void {\n"
|
||
" acceptIncrementalReference(importedIncrementalHandler);\n"
|
||
"}\n"
|
||
"// caller-only size-changing edit\n";
|
||
|
||
write_temp_file(tmp, "plugin.ts", target_source);
|
||
write_temp_file(tmp, "main.ts", initial_caller_source);
|
||
|
||
char incremental_db[512];
|
||
snprintf(incremental_db, sizeof(incremental_db), "%s/cross-reference-incremental.db", tmp);
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, incremental_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
const char *baseline_project = cbm_pipeline_project_name(baseline);
|
||
cbm_store_t *baseline_store = cbm_store_open_path(incremental_db);
|
||
ASSERT_NOT_NULL(baseline_store);
|
||
ASSERT_EQ(named_edge_count(baseline_store, baseline_project, "CALL_REFERENCE",
|
||
"incrementalImportedArgument", "importedIncrementalHandler"),
|
||
1);
|
||
ASSERT_EQ(named_edge_count(baseline_store, baseline_project, "USAGE",
|
||
"incrementalImportedArgument", "importedIncrementalHandler"),
|
||
0);
|
||
ASSERT_EQ(named_edge_count(baseline_store, baseline_project, "CALLS",
|
||
"incrementalImportedArgument", "importedIncrementalHandler"),
|
||
0);
|
||
cbm_store_close(baseline_store);
|
||
cbm_pipeline_free(baseline);
|
||
|
||
write_temp_file(tmp, "main.ts", edited_caller_source);
|
||
|
||
cbm_pipeline_t *incremental = cbm_pipeline_new(tmp, incremental_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(incremental);
|
||
ASSERT_EQ(cbm_pipeline_run(incremental), 0);
|
||
const char *incremental_project = cbm_pipeline_project_name(incremental);
|
||
cbm_store_t *incremental_store = cbm_store_open_path(incremental_db);
|
||
ASSERT_NOT_NULL(incremental_store);
|
||
int incremental_reference =
|
||
named_edge_count(incremental_store, incremental_project, "CALL_REFERENCE",
|
||
"incrementalImportedArgument", "importedIncrementalHandler");
|
||
int incremental_usage =
|
||
named_edge_count(incremental_store, incremental_project, "USAGE",
|
||
"incrementalImportedArgument", "importedIncrementalHandler");
|
||
int incremental_calls =
|
||
named_edge_count(incremental_store, incremental_project, "CALLS",
|
||
"incrementalImportedArgument", "importedIncrementalHandler");
|
||
cbm_store_close(incremental_store);
|
||
cbm_pipeline_free(incremental);
|
||
|
||
char full_db[512];
|
||
snprintf(full_db, sizeof(full_db), "%s/cross-reference-full.db", tmp);
|
||
cbm_pipeline_t *full = cbm_pipeline_new(tmp, full_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(full);
|
||
ASSERT_EQ(cbm_pipeline_run(full), 0);
|
||
const char *full_project = cbm_pipeline_project_name(full);
|
||
cbm_store_t *full_store = cbm_store_open_path(full_db);
|
||
ASSERT_NOT_NULL(full_store);
|
||
int full_reference =
|
||
named_edge_count(full_store, full_project, "CALL_REFERENCE", "incrementalImportedArgument",
|
||
"importedIncrementalHandler");
|
||
int full_usage = named_edge_count(full_store, full_project, "USAGE",
|
||
"incrementalImportedArgument", "importedIncrementalHandler");
|
||
int full_calls = named_edge_count(full_store, full_project, "CALLS",
|
||
"incrementalImportedArgument", "importedIncrementalHandler");
|
||
cbm_store_close(full_store);
|
||
cbm_pipeline_free(full);
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(full_reference, 1);
|
||
ASSERT_EQ(full_usage, 0);
|
||
ASSERT_EQ(full_calls, 0);
|
||
ASSERT_EQ(incremental_reference, full_reference);
|
||
ASSERT_EQ(incremental_usage, full_usage);
|
||
ASSERT_EQ(incremental_calls, full_calls);
|
||
PASS();
|
||
}
|
||
|
||
/* A semantic control file can change an unchanged source file's exact target.
|
||
* Both target modules deliberately export the same short name: without the
|
||
* tsconfig path mapping, project-wide unique-name fallback is ambiguous and
|
||
* cannot make this test pass. The clean full index after the config-only edit
|
||
* is therefore an exact oracle for which module owns the CALL_REFERENCE.
|
||
*
|
||
* RED: incremental hashing tracks source inputs but not the semantic influence
|
||
* of tsconfig.json. Changing only the alias mapping leaves the same-DB graph
|
||
* pointed at target_a.ts while a fresh full index points at target_b.ts. */
|
||
/* ── Closure-repair routing matrix + convergence ─────────────────
|
||
*
|
||
* Every test asserts BOTH the route taken and equality with a fresh full
|
||
* index. Route equality matters because full-rebuild would satisfy any
|
||
* convergence assertion vacuously; graph equality matters because a wrong
|
||
* closure that still routes as CLOSURE_REPAIR is precisely the bug class
|
||
* this feature must never ship. */
|
||
|
||
static void closure_probe_repo(const char *tmp) {
|
||
write_temp_file(tmp, "lib.ts",
|
||
"export function closureProbeHelper(x: number): string {\n"
|
||
" return String(x);\n"
|
||
"}\n");
|
||
/* The callback-pass shape produces the exact-value CALL_REFERENCE edge
|
||
* (same idiom as the tsconfig alias convergence test). */
|
||
write_temp_file(tmp, "caller.ts",
|
||
"import { closureProbeHelper } from './lib';\n"
|
||
"function acceptClosureProbe(callback: (x: number) => string): void {\n"
|
||
" void callback;\n"
|
||
"}\n"
|
||
"export function closureProbeCaller(): void {\n"
|
||
" acceptClosureProbe(closureProbeHelper);\n"
|
||
"}\n");
|
||
write_temp_file(tmp, "bystander.ts",
|
||
"export function closureBystander(): number {\n"
|
||
" return 7;\n"
|
||
"}\n");
|
||
}
|
||
|
||
/* Fresh full reference build of the same tree into its own DB. */
|
||
static void closure_fresh_full(const char *tmp, const char *db_path, int *out_nodes,
|
||
int *out_edges, int *out_ref_edges, const char *project_hint) {
|
||
*out_nodes = -1;
|
||
*out_edges = -2;
|
||
*out_ref_edges = -3;
|
||
cbm_unlink(db_path);
|
||
cbm_remove_db_sidecars(db_path);
|
||
cbm_pipeline_t *full = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
if (!full) {
|
||
return;
|
||
}
|
||
int rc = cbm_pipeline_run(full);
|
||
const char *project = cbm_pipeline_project_name(full);
|
||
if (rc == 0) {
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
if (store) {
|
||
*out_nodes = cbm_store_count_nodes(store, project);
|
||
*out_edges = cbm_store_count_edges(store, project);
|
||
*out_ref_edges = named_edge_to_file_count(store, project, "CALL_REFERENCE",
|
||
"closureProbeCaller", "closureProbeHelper",
|
||
"lib.ts");
|
||
cbm_store_close(store);
|
||
}
|
||
}
|
||
(void)project_hint;
|
||
cbm_pipeline_free(full);
|
||
}
|
||
|
||
/* The manifest hash fans out across workers above a file-count threshold;
|
||
* below it the serial path runs. Byte-identity of the parallel build is
|
||
* what the exact no-op route stands on, so pin it with a repo big enough
|
||
* to take the parallel path on BOTH builds: unchanged bytes must route
|
||
* NOOP, and one changed file must still classify exactly. */
|
||
TEST(pipeline_parallel_manifest_is_byte_stable_above_threshold) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_par_manifest_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
for (int i = 0; i < 72; i++) {
|
||
char name[64];
|
||
char body[128];
|
||
snprintf(name, sizeof(name), "mod_%02d.py", i);
|
||
snprintf(body, sizeof(body), "def par_manifest_%02d():\n return %d\n", i, i);
|
||
write_temp_file(tmp, name, body);
|
||
}
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/manifest.db", tmp);
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
cbm_pipeline_free(baseline);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *unchanged = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(unchanged);
|
||
ASSERT_EQ(cbm_pipeline_run(unchanged), 0);
|
||
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_NOOP);
|
||
cbm_pipeline_free(unchanged);
|
||
|
||
write_temp_file(tmp, "mod_07.py", "def par_manifest_07():\n return 700\n");
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *edited = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(edited);
|
||
ASSERT_EQ(cbm_pipeline_run(edited), 0);
|
||
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||
cbm_pipeline_free(edited);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_closure_body_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
closure_probe_repo(tmp);
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/closure.db", tmp);
|
||
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
/* Body edit only: same exported surface, different body. */
|
||
write_temp_file(tmp, "lib.ts",
|
||
"export function closureProbeHelper(x: number): string {\n"
|
||
" const doubled = x + x;\n"
|
||
" return String(doubled);\n"
|
||
"}\n");
|
||
|
||
cbm_pipeline_t *incr = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(incr);
|
||
ASSERT_EQ(cbm_pipeline_run(incr), 0);
|
||
cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(incr);
|
||
ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||
|
||
int repaired_nodes = -1;
|
||
int repaired_edges = -1;
|
||
int repaired_refs = -1;
|
||
cbm_store_t *store = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(store);
|
||
repaired_nodes = cbm_store_count_nodes(store, project);
|
||
repaired_edges = cbm_store_count_edges(store, project);
|
||
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE",
|
||
"closureProbeCaller", "closureProbeHelper", "lib.ts");
|
||
cbm_store_close(store);
|
||
|
||
char full_db[512];
|
||
snprintf(full_db, sizeof(full_db), "%s/reference-full.db", tmp);
|
||
int full_nodes;
|
||
int full_edges;
|
||
int full_refs;
|
||
closure_fresh_full(tmp, full_db, &full_nodes, &full_edges, &full_refs, project);
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(repaired_refs, 1);
|
||
ASSERT_EQ(repaired_nodes, full_nodes);
|
||
ASSERT_EQ(repaired_edges, full_edges);
|
||
ASSERT_EQ(repaired_refs, full_refs);
|
||
PASS();
|
||
}
|
||
|
||
/* Removing a definition (no additions) keeps the closure route AND must drop
|
||
* the dependent's stale CALL_REFERENCE. This is the assertion legacy partial
|
||
* could never pass: its QN-keyed re-link resurrected yesterday's edge. */
|
||
TEST(pipeline_closure_repair_removed_def_drops_dependent_edge) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_closure_removed_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
closure_probe_repo(tmp);
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/closure.db", tmp);
|
||
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
cbm_store_t *pre = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(pre);
|
||
ASSERT_EQ(named_edge_to_file_count(pre, project, "CALL_REFERENCE", "closureProbeCaller",
|
||
"closureProbeHelper", "lib.ts"),
|
||
1);
|
||
cbm_store_close(pre);
|
||
|
||
/* The helper vanishes; nothing is added. The caller keeps calling a name
|
||
* that no longer resolves anywhere. */
|
||
write_temp_file(tmp, "lib.ts", "export const closureProbeHelper = undefined;\n");
|
||
|
||
cbm_pipeline_t *incr = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(incr);
|
||
ASSERT_EQ(cbm_pipeline_run(incr), 0);
|
||
cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(incr);
|
||
|
||
int repaired_refs = -1;
|
||
cbm_store_t *store = cbm_store_open_path(db);
|
||
ASSERT_NOT_NULL(store);
|
||
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE",
|
||
"closureProbeCaller", "closureProbeHelper", "lib.ts");
|
||
int repaired_nodes = cbm_store_count_nodes(store, project);
|
||
int repaired_edges = cbm_store_count_edges(store, project);
|
||
cbm_store_close(store);
|
||
|
||
char full_db[512];
|
||
snprintf(full_db, sizeof(full_db), "%s/reference-full.db", tmp);
|
||
int full_nodes;
|
||
int full_edges;
|
||
int full_refs;
|
||
closure_fresh_full(tmp, full_db, &full_nodes, &full_edges, &full_refs, project);
|
||
th_rmtree(tmp);
|
||
|
||
/* Function def -> const def is a label/type change, not an added NAME, so
|
||
* the closure route must hold — and the stale edge must be gone. */
|
||
ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||
ASSERT_EQ(repaired_refs, full_refs);
|
||
ASSERT_EQ(repaired_nodes, full_nodes);
|
||
ASSERT_EQ(repaired_edges, full_edges);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_closure_repair_added_name_declines_to_full) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_closure_added_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
closure_probe_repo(tmp);
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/closure.db", tmp);
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
cbm_pipeline_free(baseline);
|
||
|
||
/* A NEW name: yesterday's graph cannot know who would now resolve to
|
||
* it, so the planner must decline. */
|
||
write_temp_file(tmp, "lib.ts",
|
||
"export function closureProbeHelper(x: number): string {\n"
|
||
" return String(x);\n"
|
||
"}\n"
|
||
"export function closureProbeExtra(): number {\n"
|
||
" return 1;\n"
|
||
"}\n");
|
||
cbm_pipeline_t *incr = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(incr);
|
||
ASSERT_EQ(cbm_pipeline_run(incr), 0);
|
||
cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(incr);
|
||
th_rmtree(tmp);
|
||
ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_closure_repair_new_file_declines_to_full) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_closure_newfile_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
closure_probe_repo(tmp);
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/closure.db", tmp);
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
cbm_pipeline_free(baseline);
|
||
|
||
write_temp_file(tmp, "newcomer.ts", "export function closureNewcomer(): void {}\n");
|
||
cbm_pipeline_t *incr = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(incr);
|
||
ASSERT_EQ(cbm_pipeline_run(incr), 0);
|
||
cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(incr);
|
||
th_rmtree(tmp);
|
||
ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_closure_repair_budget_declines_to_full) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_closure_budget_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
closure_probe_repo(tmp);
|
||
for (int i = 0; i < 12; i++) {
|
||
char name[64];
|
||
char body[256];
|
||
snprintf(name, sizeof(name), "budget_%02d.ts", i);
|
||
snprintf(body, sizeof(body),
|
||
"export function closureBudget%02d(): number {\n return %d;\n}\n", i, i);
|
||
write_temp_file(tmp, name, body);
|
||
}
|
||
for (int i = 0; i < 18; i++) {
|
||
char name[64];
|
||
char body[128];
|
||
snprintf(name, sizeof(name), "filler_%02d.ts", i);
|
||
snprintf(body, sizeof(body), "export const closureFiller%02d = %d;\n", i, i);
|
||
write_temp_file(tmp, name, body);
|
||
}
|
||
char db[512];
|
||
snprintf(db, sizeof(db), "%s/closure.db", tmp);
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
cbm_pipeline_free(baseline);
|
||
|
||
/* Body-edit enough files to clear the absolute floor AND the percentage:
|
||
* 12 of 33 files is 36%% with 12 > CLOSURE_BUDGET_FLOOR_FILES, so the
|
||
* planner concedes to a full rebuild. */
|
||
for (int i = 0; i < 12; i++) {
|
||
char name[64];
|
||
char body[256];
|
||
snprintf(name, sizeof(name), "budget_%02d.ts", i);
|
||
snprintf(body, sizeof(body),
|
||
"export function closureBudget%02d(): number {\n return %d + 1;\n}\n", i, i);
|
||
write_temp_file(tmp, name, body);
|
||
}
|
||
cbm_pipeline_t *incr = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(incr);
|
||
ASSERT_EQ(cbm_pipeline_run(incr), 0);
|
||
cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(incr);
|
||
th_rmtree(tmp);
|
||
ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_incremental_tsconfig_alias_change_matches_fresh_full) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_ref_tsconfig_incr_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
static const char target_a_source[] = "export function semanticControlHandler(): void {\n"
|
||
" const selected = 'a';\n"
|
||
" void selected;\n"
|
||
"}\n";
|
||
static const char target_b_source[] = "export function semanticControlHandler(): void {\n"
|
||
" const selected = 'b';\n"
|
||
" void selected;\n"
|
||
"}\n";
|
||
static const char caller_source[] =
|
||
"import { semanticControlHandler } from '@semantic-target';\n"
|
||
"function acceptSemanticControl(callback: () => void): void {\n"
|
||
" void callback;\n"
|
||
"}\n"
|
||
"export function semanticControlCaller(): void {\n"
|
||
" acceptSemanticControl(semanticControlHandler);\n"
|
||
"}\n";
|
||
static const char initial_tsconfig[] =
|
||
"{\n"
|
||
" \"compilerOptions\": {\n"
|
||
" \"baseUrl\": \".\",\n"
|
||
" \"paths\": {\"@semantic-target\": [\"./target_a\"]}\n"
|
||
" }\n"
|
||
"}\n";
|
||
static const char replacement_tsconfig[] =
|
||
"{\n"
|
||
" \"compilerOptions\": {\n"
|
||
" \"baseUrl\": \".\",\n"
|
||
" \"paths\": {\"@semantic-target\": [\"./target_b\"]}\n"
|
||
" }\n"
|
||
"}\n";
|
||
static const char initial_jsconfig[] = "{\"compilerOptions\":{\"baseUrl\":\"./one\"}}\n";
|
||
static const char replacement_jsconfig[] = "{\"compilerOptions\":{\"baseUrl\":\"./two\"}}\n";
|
||
ASSERT_EQ(sizeof(initial_tsconfig), sizeof(replacement_tsconfig));
|
||
ASSERT_EQ(sizeof(initial_jsconfig), sizeof(replacement_jsconfig));
|
||
|
||
write_temp_file(tmp, "target_a.ts", target_a_source);
|
||
write_temp_file(tmp, "target_b.ts", target_b_source);
|
||
write_temp_file(tmp, "caller.ts", caller_source);
|
||
write_temp_file(tmp, "tsconfig.json", initial_tsconfig);
|
||
write_temp_file(tmp, "jsconfig.json", initial_jsconfig);
|
||
char tsconfig_path[512];
|
||
char jsconfig_path[512];
|
||
snprintf(tsconfig_path, sizeof(tsconfig_path), "%s/tsconfig.json", tmp);
|
||
snprintf(jsconfig_path, sizeof(jsconfig_path), "%s/jsconfig.json", tmp);
|
||
const time_t fixed_config_mtime = 1700000100;
|
||
ASSERT_EQ(pipeline_test_set_mtime(tsconfig_path, fixed_config_mtime, 222222222L), 0);
|
||
ASSERT_EQ(pipeline_test_set_mtime(jsconfig_path, fixed_config_mtime, 333333333L), 0);
|
||
|
||
char incremental_db[512];
|
||
snprintf(incremental_db, sizeof(incremental_db), "%s/tsconfig-reference-incremental.db", tmp);
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, incremental_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
const char *baseline_project = cbm_pipeline_project_name(baseline);
|
||
cbm_store_t *baseline_store = cbm_store_open_path(incremental_db);
|
||
ASSERT_NOT_NULL(baseline_store);
|
||
ASSERT_EQ(named_node_count(baseline_store, baseline_project, "semanticControlHandler"), 2);
|
||
ASSERT_EQ(named_edge_to_file_count(baseline_store, baseline_project, "CALL_REFERENCE",
|
||
"semanticControlCaller", "semanticControlHandler",
|
||
"target_a.ts"),
|
||
1);
|
||
ASSERT_EQ(named_edge_to_file_count(baseline_store, baseline_project, "CALL_REFERENCE",
|
||
"semanticControlCaller", "semanticControlHandler",
|
||
"target_b.ts"),
|
||
0);
|
||
ASSERT_EQ(named_edge_count(baseline_store, baseline_project, "USAGE", "semanticControlCaller",
|
||
"semanticControlHandler"),
|
||
0);
|
||
ASSERT_EQ(named_edge_count(baseline_store, baseline_project, "CALLS", "semanticControlCaller",
|
||
"semanticControlHandler"),
|
||
0);
|
||
cbm_file_hash_t selected_config_hash = {0};
|
||
ASSERT_EQ(cbm_store_get_file_hash(baseline_store, baseline_project, "tsconfig.json",
|
||
&selected_config_hash),
|
||
CBM_STORE_OK);
|
||
char expected_config_hash[CBM_SHA256_HEX_LEN + 1];
|
||
cbm_sha256_hex(initial_tsconfig, strlen(initial_tsconfig), expected_config_hash);
|
||
ASSERT_NOT_NULL(selected_config_hash.sha256);
|
||
ASSERT_STR_EQ(selected_config_hash.sha256, expected_config_hash);
|
||
cbm_store_clear_file_hash(&selected_config_hash);
|
||
cbm_file_hash_t unselected_config_hash = {0};
|
||
ASSERT_EQ(cbm_store_get_file_hash(baseline_store, baseline_project, "jsconfig.json",
|
||
&unselected_config_hash),
|
||
CBM_STORE_NOT_FOUND);
|
||
cbm_store_close(baseline_store);
|
||
cbm_pipeline_free(baseline);
|
||
|
||
/* The lower-priority jsconfig is not a semantic input while tsconfig is
|
||
* present in the same directory. Its byte change must remain a no-op. */
|
||
write_temp_file(tmp, "jsconfig.json", replacement_jsconfig);
|
||
ASSERT_EQ(pipeline_test_set_mtime(jsconfig_path, fixed_config_mtime, 333333333L), 0);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *unselected = cbm_pipeline_new(tmp, incremental_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(unselected);
|
||
ASSERT_EQ(cbm_pipeline_run(unselected), 0);
|
||
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_NOOP);
|
||
cbm_pipeline_free(unselected);
|
||
|
||
/* The source files remain byte-for-byte unchanged; selected control bytes
|
||
* change at the same length and mtime. */
|
||
write_temp_file(tmp, "tsconfig.json", replacement_tsconfig);
|
||
ASSERT_EQ(pipeline_test_set_mtime(tsconfig_path, fixed_config_mtime, 222222222L), 0);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *incremental = cbm_pipeline_new(tmp, incremental_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(incremental);
|
||
ASSERT_EQ(cbm_pipeline_run(incremental), 0);
|
||
/* Config-mediated retargeting is the closure route's hardest case: no
|
||
* source file changed, yet the caller's CALL_REFERENCE must move from
|
||
* target_a.ts to target_b.ts. Since alias-config governance landed this
|
||
* runs as a closure repair, and the convergence assertions below now
|
||
* prove that route rather than being satisfied by a full rebuild. */
|
||
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(),
|
||
CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||
const char *incremental_project = cbm_pipeline_project_name(incremental);
|
||
cbm_store_t *incremental_store = cbm_store_open_path(incremental_db);
|
||
ASSERT_NOT_NULL(incremental_store);
|
||
int incremental_reference_a =
|
||
named_edge_to_file_count(incremental_store, incremental_project, "CALL_REFERENCE",
|
||
"semanticControlCaller", "semanticControlHandler", "target_a.ts");
|
||
int incremental_reference_b =
|
||
named_edge_to_file_count(incremental_store, incremental_project, "CALL_REFERENCE",
|
||
"semanticControlCaller", "semanticControlHandler", "target_b.ts");
|
||
int incremental_usage = named_edge_count(incremental_store, incremental_project, "USAGE",
|
||
"semanticControlCaller", "semanticControlHandler");
|
||
int incremental_calls = named_edge_count(incremental_store, incremental_project, "CALLS",
|
||
"semanticControlCaller", "semanticControlHandler");
|
||
cbm_store_close(incremental_store);
|
||
cbm_pipeline_free(incremental);
|
||
|
||
char full_db[512];
|
||
snprintf(full_db, sizeof(full_db), "%s/tsconfig-reference-full.db", tmp);
|
||
cbm_pipeline_t *full = cbm_pipeline_new(tmp, full_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(full);
|
||
ASSERT_EQ(cbm_pipeline_run(full), 0);
|
||
const char *full_project = cbm_pipeline_project_name(full);
|
||
cbm_store_t *full_store = cbm_store_open_path(full_db);
|
||
ASSERT_NOT_NULL(full_store);
|
||
int full_reference_a =
|
||
named_edge_to_file_count(full_store, full_project, "CALL_REFERENCE",
|
||
"semanticControlCaller", "semanticControlHandler", "target_a.ts");
|
||
int full_reference_b =
|
||
named_edge_to_file_count(full_store, full_project, "CALL_REFERENCE",
|
||
"semanticControlCaller", "semanticControlHandler", "target_b.ts");
|
||
int full_usage = named_edge_count(full_store, full_project, "USAGE", "semanticControlCaller",
|
||
"semanticControlHandler");
|
||
int full_calls = named_edge_count(full_store, full_project, "CALLS", "semanticControlCaller",
|
||
"semanticControlHandler");
|
||
cbm_store_close(full_store);
|
||
cbm_pipeline_free(full);
|
||
th_rmtree(tmp);
|
||
|
||
/* Prove the fresh oracle changed only because the alias now selects B. */
|
||
ASSERT_EQ(full_reference_a, 0);
|
||
ASSERT_EQ(full_reference_b, 1);
|
||
ASSERT_EQ(full_usage, 0);
|
||
ASSERT_EQ(full_calls, 0);
|
||
|
||
/* Incremental output must converge exactly on the fresh target and kind. */
|
||
ASSERT_EQ(incremental_reference_a, full_reference_a);
|
||
ASSERT_EQ(incremental_reference_b, full_reference_b);
|
||
ASSERT_EQ(incremental_usage, full_usage);
|
||
ASSERT_EQ(incremental_calls, full_calls);
|
||
PASS();
|
||
}
|
||
|
||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||
static void observe_named_generation(const char *db_path, const char *project,
|
||
const char *before_name, const char *after_name,
|
||
int *before_count, int *after_count) {
|
||
*before_count = -1;
|
||
*after_count = -1;
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
if (!store) {
|
||
return;
|
||
}
|
||
*before_count = named_node_count(store, project, before_name);
|
||
*after_count = named_node_count(store, project, after_name);
|
||
cbm_store_close(store);
|
||
}
|
||
|
||
static int count_generation_stage_artifacts(const char *dir_path, const char *db_basename) {
|
||
cbm_dir_t *dir = cbm_opendir(dir_path);
|
||
if (!dir) {
|
||
return -1;
|
||
}
|
||
char prefix[256];
|
||
snprintf(prefix, sizeof(prefix), "%s.stage.", db_basename);
|
||
size_t prefix_len = strlen(prefix);
|
||
int count = 0;
|
||
cbm_dirent_t *entry;
|
||
while ((entry = cbm_readdir(dir)) != NULL) {
|
||
if (strncmp(entry->name, prefix, prefix_len) == 0) {
|
||
count++;
|
||
}
|
||
}
|
||
cbm_closedir(dir);
|
||
return count;
|
||
}
|
||
|
||
typedef struct {
|
||
const char *path;
|
||
const char *replacement;
|
||
int write_rc;
|
||
} manifest_race_mutation_t;
|
||
|
||
static void mutate_semantic_input_before_final_manifest(void *userdata) {
|
||
manifest_race_mutation_t *mutation = (manifest_race_mutation_t *)userdata;
|
||
mutation->write_rc = th_write_file(mutation->path, mutation->replacement);
|
||
}
|
||
|
||
/* A graph and its exact manifest are one generation. If source bytes change
|
||
* after extraction but before publication, the mixed generation must be
|
||
* rejected and the previous live DB preserved. */
|
||
TEST(pipeline_source_mutation_before_publication_preserves_previous_generation) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_generation_race_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
char source_path[512];
|
||
char db_path[512];
|
||
snprintf(source_path, sizeof(source_path), "%s/generation.py", tmp);
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
ASSERT_EQ(th_write_file(source_path, "def StableGeneration():\n return 1\n"), 0);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
ASSERT_EQ(th_write_file(source_path, "def ExtractedGeneration():\n return 2\n"), 0);
|
||
manifest_race_mutation_t mutation = {
|
||
.path = source_path,
|
||
.replacement = "def FinalGeneration():\n return 3\n",
|
||
.write_rc = -1,
|
||
};
|
||
cbm_pipeline_incremental_test_before_final_manifest_once(
|
||
mutate_semantic_input_before_final_manifest, &mutation);
|
||
cbm_pipeline_t *raced = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(raced);
|
||
int raced_rc = cbm_pipeline_run(raced);
|
||
cbm_pipeline_free(raced);
|
||
int raced_stable = -1;
|
||
int raced_extracted = -1;
|
||
observe_named_generation(db_path, project, "StableGeneration", "ExtractedGeneration",
|
||
&raced_stable, &raced_extracted);
|
||
int raced_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
|
||
cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(retry);
|
||
int retry_rc = cbm_pipeline_run(retry);
|
||
cbm_pipeline_free(retry);
|
||
int retry_stable = -1;
|
||
int retry_final = -1;
|
||
observe_named_generation(db_path, project, "StableGeneration", "FinalGeneration", &retry_stable,
|
||
&retry_final);
|
||
int retry_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(mutation.write_rc, 0);
|
||
ASSERT_EQ(raced_rc, CBM_PIPELINE_ABORT_PRESERVE_DB);
|
||
ASSERT_EQ(raced_stable, 1);
|
||
ASSERT_EQ(raced_extracted, 0);
|
||
ASSERT_EQ(raced_stage_count, 0);
|
||
ASSERT_EQ(retry_rc, 0);
|
||
ASSERT_EQ(retry_stable, 0);
|
||
ASSERT_EQ(retry_final, 1);
|
||
ASSERT_EQ(retry_stage_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* Publication must never write through a name another local process can
|
||
* predict. The staging database used to be "<db>.stage.<pid>.<counter>",
|
||
* which anyone can compute in advance; publication then unlinked that name
|
||
* and wrote it, so a symlink planted in the gap redirects the write to a
|
||
* target of the attacker's choosing.
|
||
*
|
||
* Winning that timing window is not something a test should attempt -- a
|
||
* test that has to win a race is a coin flip, not a gate. The property that
|
||
* REMOVES the window is deterministic: publication must not touch any
|
||
* predictable name at all. Canaries occupy every name the old scheme could
|
||
* have picked, and all must survive. Against the old code exactly one is
|
||
* consumed, whichever serial the counter had reached.
|
||
*
|
||
* This calls cbm_pipeline_publish_generation directly. The only in-tree
|
||
* caller sits behind CBM_INCREMENTAL_TEST_API, so going through the pipeline
|
||
* would never reach the code under test. */
|
||
TEST(pipeline_publication_never_uses_a_predictable_staging_path) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_predictable_stage_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
|
||
enum { PREDICTABLE_CANARIES = 32 };
|
||
static const char canary[] = "canary-must-survive\n";
|
||
char canary_path[PREDICTABLE_CANARIES][640];
|
||
for (int i = 0; i < PREDICTABLE_CANARIES; i++) {
|
||
snprintf(canary_path[i], sizeof(canary_path[i]), "%s.stage.%ld.%d", db_path,
|
||
(long)getpid(), i + 1);
|
||
ASSERT_EQ(th_write_file(canary_path[i], canary), 0);
|
||
}
|
||
|
||
cbm_gbuf_t *gb = cbm_gbuf_new("predictable-stage-proj", tmp);
|
||
ASSERT_NOT_NULL(gb);
|
||
cbm_pipeline_generation_t generation = {
|
||
.gbuf = gb,
|
||
.final_db_path = db_path,
|
||
.project = "predictable-stage-proj",
|
||
.cancelled = NULL,
|
||
.manifest = NULL,
|
||
.manifest_count = 0,
|
||
.adr_content = NULL,
|
||
.coverage = NULL,
|
||
.coverage_count = 0,
|
||
};
|
||
int publish_rc = cbm_pipeline_publish_generation(&generation);
|
||
cbm_gbuf_free(gb);
|
||
|
||
int survived = 0;
|
||
int intact = 0;
|
||
for (int i = 0; i < PREDICTABLE_CANARIES; i++) {
|
||
FILE *f = cbm_fopen(canary_path[i], "rb");
|
||
if (!f) {
|
||
continue;
|
||
}
|
||
survived++;
|
||
char buf[64] = {0};
|
||
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
|
||
(void)fclose(f);
|
||
if (n == strlen(canary) && memcmp(buf, canary, n) == 0) {
|
||
intact++;
|
||
}
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(publish_rc, 0);
|
||
/* Not one may be unlinked, truncated, or written through. */
|
||
ASSERT_EQ(survived, PREDICTABLE_CANARIES);
|
||
ASSERT_EQ(intact, PREDICTABLE_CANARIES);
|
||
PASS();
|
||
}
|
||
|
||
/* Discovery and extraction must describe the same immutable generation. A
|
||
* source file created after extraction is not present in the original file
|
||
* list, so merely re-hashing that list cannot detect the race. Publication
|
||
* must abort, leave the old DB intact, and let a clean retry discover both
|
||
* files. */
|
||
TEST(pipeline_source_addition_before_publication_preserves_previous_generation) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_addition_race_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
char source_path[512];
|
||
char late_path[512];
|
||
char db_path[512];
|
||
snprintf(source_path, sizeof(source_path), "%s/generation.py", tmp);
|
||
snprintf(late_path, sizeof(late_path), "%s/late.py", tmp);
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
ASSERT_EQ(th_write_file(source_path, "def StableAdditionGeneration():\n return 1\n"), 0);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
ASSERT_EQ(th_write_file(source_path, "def ExtractedAdditionGeneration():\n return 2\n"), 0);
|
||
manifest_race_mutation_t mutation = {
|
||
.path = late_path,
|
||
.replacement = "def LateDiscoveredGeneration():\n return 3\n",
|
||
.write_rc = -1,
|
||
};
|
||
cbm_pipeline_incremental_test_before_final_manifest_once(
|
||
mutate_semantic_input_before_final_manifest, &mutation);
|
||
cbm_pipeline_t *raced = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(raced);
|
||
int raced_rc = cbm_pipeline_run(raced);
|
||
cbm_pipeline_free(raced);
|
||
|
||
int raced_stable = -1;
|
||
int raced_extracted = -1;
|
||
int raced_late = -1;
|
||
cbm_store_t *raced_store = cbm_store_open_path(db_path);
|
||
if (raced_store) {
|
||
raced_stable = named_node_count(raced_store, project, "StableAdditionGeneration");
|
||
raced_extracted = named_node_count(raced_store, project, "ExtractedAdditionGeneration");
|
||
raced_late = named_node_count(raced_store, project, "LateDiscoveredGeneration");
|
||
cbm_store_close(raced_store);
|
||
}
|
||
int raced_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(retry);
|
||
int retry_rc = cbm_pipeline_run(retry);
|
||
cbm_incremental_route_t retry_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(retry);
|
||
|
||
int retry_stable = -1;
|
||
int retry_extracted = -1;
|
||
int retry_late = -1;
|
||
cbm_store_t *retry_store = cbm_store_open_path(db_path);
|
||
if (retry_store) {
|
||
retry_stable = named_node_count(retry_store, project, "StableAdditionGeneration");
|
||
retry_extracted = named_node_count(retry_store, project, "ExtractedAdditionGeneration");
|
||
retry_late = named_node_count(retry_store, project, "LateDiscoveredGeneration");
|
||
cbm_store_close(retry_store);
|
||
}
|
||
int retry_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(mutation.write_rc, 0);
|
||
ASSERT_EQ(raced_rc, CBM_PIPELINE_ABORT_PRESERVE_DB);
|
||
ASSERT_EQ(raced_stable, 1);
|
||
ASSERT_EQ(raced_extracted, 0);
|
||
ASSERT_EQ(raced_late, 0);
|
||
ASSERT_EQ(raced_stage_count, 0);
|
||
ASSERT_EQ(retry_rc, 0);
|
||
ASSERT_EQ(retry_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(retry_stable, 0);
|
||
ASSERT_EQ(retry_extracted, 1);
|
||
ASSERT_EQ(retry_late, 1);
|
||
ASSERT_EQ(retry_stage_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* A selected path-alias config is an extraction input, not just manifest
|
||
* metadata. Rewriting it after extraction must not publish an A-resolved graph
|
||
* beside a B-hashed manifest; that combination would make the next run a
|
||
* false no-op and permanently retain the stale target. */
|
||
TEST(pipeline_tsconfig_mutation_before_publication_preserves_previous_generation) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_tsconfig_race_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
|
||
static const char target_a_source[] = "export function publicationRaceHandler(): void {\n"
|
||
" const selected = 'a';\n"
|
||
" void selected;\n"
|
||
"}\n";
|
||
static const char target_b_source[] = "export function publicationRaceHandler(): void {\n"
|
||
" const selected = 'b';\n"
|
||
" void selected;\n"
|
||
"}\n";
|
||
static const char stable_caller_source[] =
|
||
"import { publicationRaceHandler } from '@publication-race';\n"
|
||
"function acceptPublicationRace(callback: () => void): void {\n"
|
||
" void callback;\n"
|
||
"}\n"
|
||
"export function PublicationRaceStableCaller(): void {\n"
|
||
" acceptPublicationRace(publicationRaceHandler);\n"
|
||
"}\n";
|
||
static const char extracted_caller_source[] =
|
||
"import { publicationRaceHandler } from '@publication-race';\n"
|
||
"function acceptPublicationRace(callback: () => void): void {\n"
|
||
" void callback;\n"
|
||
"}\n"
|
||
"export function PublicationRaceExtractedCaller(): void {\n"
|
||
" acceptPublicationRace(publicationRaceHandler);\n"
|
||
"}\n";
|
||
static const char tsconfig_a[] = "{\n"
|
||
" \"compilerOptions\": {\n"
|
||
" \"baseUrl\": \".\",\n"
|
||
" \"paths\": {\"@publication-race\": [\"./target_a\"]}\n"
|
||
" }\n"
|
||
"}\n";
|
||
static const char tsconfig_b[] = "{\n"
|
||
" \"compilerOptions\": {\n"
|
||
" \"baseUrl\": \".\",\n"
|
||
" \"paths\": {\"@publication-race\": [\"./target_b\"]}\n"
|
||
" }\n"
|
||
"}\n";
|
||
|
||
write_temp_file(tmp, "target_a.ts", target_a_source);
|
||
write_temp_file(tmp, "target_b.ts", target_b_source);
|
||
write_temp_file(tmp, "caller.ts", stable_caller_source);
|
||
write_temp_file(tmp, "tsconfig.json", tsconfig_a);
|
||
char caller_path[512];
|
||
char tsconfig_path[512];
|
||
char db_path[512];
|
||
snprintf(caller_path, sizeof(caller_path), "%s/caller.ts", tmp);
|
||
snprintf(tsconfig_path, sizeof(tsconfig_path), "%s/tsconfig.json", tmp);
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
ASSERT_EQ(th_write_file(caller_path, extracted_caller_source), 0);
|
||
manifest_race_mutation_t mutation = {
|
||
.path = tsconfig_path,
|
||
.replacement = tsconfig_b,
|
||
.write_rc = -1,
|
||
};
|
||
cbm_pipeline_incremental_test_before_final_manifest_once(
|
||
mutate_semantic_input_before_final_manifest, &mutation);
|
||
cbm_pipeline_t *raced = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(raced);
|
||
int raced_rc = cbm_pipeline_run(raced);
|
||
cbm_pipeline_free(raced);
|
||
|
||
int raced_stable_a = -1;
|
||
int raced_stable_b = -1;
|
||
int raced_extracted_nodes = -1;
|
||
cbm_store_t *raced_store = cbm_store_open_path(db_path);
|
||
if (raced_store) {
|
||
raced_stable_a = named_edge_to_file_count(raced_store, project, "CALL_REFERENCE",
|
||
"PublicationRaceStableCaller",
|
||
"publicationRaceHandler", "target_a.ts");
|
||
raced_stable_b = named_edge_to_file_count(raced_store, project, "CALL_REFERENCE",
|
||
"PublicationRaceStableCaller",
|
||
"publicationRaceHandler", "target_b.ts");
|
||
raced_extracted_nodes =
|
||
named_node_count(raced_store, project, "PublicationRaceExtractedCaller");
|
||
cbm_store_close(raced_store);
|
||
}
|
||
int raced_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(retry);
|
||
int retry_rc = cbm_pipeline_run(retry);
|
||
cbm_incremental_route_t retry_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(retry);
|
||
|
||
int retry_stable_nodes = -1;
|
||
int retry_extracted_a = -1;
|
||
int retry_extracted_b = -1;
|
||
int retry_extracted_usage = -1;
|
||
int retry_extracted_calls = -1;
|
||
cbm_store_t *retry_store = cbm_store_open_path(db_path);
|
||
if (retry_store) {
|
||
retry_stable_nodes = named_node_count(retry_store, project, "PublicationRaceStableCaller");
|
||
retry_extracted_a = named_edge_to_file_count(retry_store, project, "CALL_REFERENCE",
|
||
"PublicationRaceExtractedCaller",
|
||
"publicationRaceHandler", "target_a.ts");
|
||
retry_extracted_b = named_edge_to_file_count(retry_store, project, "CALL_REFERENCE",
|
||
"PublicationRaceExtractedCaller",
|
||
"publicationRaceHandler", "target_b.ts");
|
||
retry_extracted_usage =
|
||
named_edge_count(retry_store, project, "USAGE", "PublicationRaceExtractedCaller",
|
||
"publicationRaceHandler");
|
||
retry_extracted_calls =
|
||
named_edge_count(retry_store, project, "CALLS", "PublicationRaceExtractedCaller",
|
||
"publicationRaceHandler");
|
||
cbm_store_close(retry_store);
|
||
}
|
||
int retry_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(mutation.write_rc, 0);
|
||
ASSERT_EQ(raced_rc, CBM_PIPELINE_ABORT_PRESERVE_DB);
|
||
ASSERT_EQ(raced_stable_a, 1);
|
||
ASSERT_EQ(raced_stable_b, 0);
|
||
ASSERT_EQ(raced_extracted_nodes, 0);
|
||
ASSERT_EQ(raced_stage_count, 0);
|
||
ASSERT_EQ(retry_rc, 0);
|
||
ASSERT_EQ(retry_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(retry_stable_nodes, 0);
|
||
ASSERT_EQ(retry_extracted_a, 0);
|
||
ASSERT_EQ(retry_extracted_b, 1);
|
||
ASSERT_EQ(retry_extracted_usage, 0);
|
||
ASSERT_EQ(retry_extracted_calls, 0);
|
||
ASSERT_EQ(retry_stage_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* Metadata participates in exact-input compatibility. Old coverage schema or
|
||
* an upgrade to a more comprehensive discovery/index mode must force a
|
||
* complete replacement even when every semantic-input byte is unchanged; the
|
||
* replacement must write current metadata so the next identical run is a
|
||
* true no-op. Cheaper requests retain fuller published coverage separately. */
|
||
TEST(pipeline_exact_inputs_migrate_coverage_metadata_and_index_mode) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_manifest_metadata_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
write_temp_file(tmp, "generation.py", "def ExactMetadataGeneration():\n return 1\n");
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *exact_before_migration = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST);
|
||
ASSERT_NOT_NULL(exact_before_migration);
|
||
ASSERT_EQ(cbm_pipeline_run(exact_before_migration), 0);
|
||
cbm_incremental_route_t exact_before_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(exact_before_migration);
|
||
|
||
cbm_store_t *metadata_store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(metadata_store);
|
||
cbm_coverage_row_t *coverage_rows = NULL;
|
||
int coverage_count = 0;
|
||
ASSERT_EQ(cbm_store_coverage_get(metadata_store, project, &coverage_rows, &coverage_count),
|
||
CBM_STORE_OK);
|
||
cbm_coverage_meta_t current_meta = {0};
|
||
ASSERT_EQ(cbm_store_coverage_meta_get(metadata_store, project, ¤t_meta), CBM_STORE_OK);
|
||
cbm_coverage_meta_t legacy_meta = current_meta;
|
||
legacy_meta.coverage_version = 1;
|
||
ASSERT_EQ(cbm_store_coverage_replace_ex(metadata_store, project, coverage_rows, coverage_count,
|
||
&legacy_meta),
|
||
CBM_STORE_OK);
|
||
cbm_store_free_coverage(coverage_rows, coverage_count);
|
||
cbm_store_coverage_meta_clear(¤t_meta);
|
||
cbm_store_close(metadata_store);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *migration = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST);
|
||
ASSERT_NOT_NULL(migration);
|
||
ASSERT_EQ(cbm_pipeline_run(migration), 0);
|
||
cbm_incremental_route_t migration_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(migration);
|
||
|
||
metadata_store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(metadata_store);
|
||
cbm_coverage_meta_t migrated_meta = {0};
|
||
ASSERT_EQ(cbm_store_coverage_meta_get(metadata_store, project, &migrated_meta), CBM_STORE_OK);
|
||
int migrated_version = migrated_meta.coverage_version;
|
||
bool migrated_hashes_complete = migrated_meta.hash_records_complete;
|
||
char migrated_mode[32];
|
||
snprintf(migrated_mode, sizeof(migrated_mode), "%s",
|
||
migrated_meta.index_mode ? migrated_meta.index_mode : "");
|
||
cbm_store_coverage_meta_clear(&migrated_meta);
|
||
cbm_store_close(metadata_store);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *mode_change = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(mode_change);
|
||
ASSERT_EQ(cbm_pipeline_run(mode_change), 0);
|
||
cbm_incremental_route_t mode_change_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(mode_change);
|
||
|
||
metadata_store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(metadata_store);
|
||
cbm_coverage_meta_t full_meta = {0};
|
||
ASSERT_EQ(cbm_store_coverage_meta_get(metadata_store, project, &full_meta), CBM_STORE_OK);
|
||
int full_version = full_meta.coverage_version;
|
||
bool full_hashes_complete = full_meta.hash_records_complete;
|
||
char full_mode[32];
|
||
snprintf(full_mode, sizeof(full_mode), "%s", full_meta.index_mode ? full_meta.index_mode : "");
|
||
cbm_store_coverage_meta_clear(&full_meta);
|
||
cbm_store_close(metadata_store);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *exact_full = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(exact_full);
|
||
ASSERT_EQ(cbm_pipeline_run(exact_full), 0);
|
||
cbm_incremental_route_t exact_full_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(exact_full);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(exact_before_route, CBM_INCREMENTAL_ROUTE_NOOP);
|
||
ASSERT_EQ(migration_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(migrated_version, CBM_SEMANTIC_INDEX_VERSION);
|
||
ASSERT_TRUE(migrated_hashes_complete);
|
||
ASSERT_STR_EQ(migrated_mode, "fast");
|
||
ASSERT_EQ(mode_change_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(full_version, CBM_SEMANTIC_INDEX_VERSION);
|
||
ASSERT_TRUE(full_hashes_complete);
|
||
ASSERT_STR_EQ(full_mode, "full");
|
||
ASSERT_EQ(exact_full_route, CBM_INCREMENTAL_ROUTE_NOOP);
|
||
PASS();
|
||
}
|
||
|
||
typedef struct {
|
||
bool published;
|
||
int rename_calls;
|
||
int export_count;
|
||
int exports_before_publish;
|
||
} artifact_publish_observer_t;
|
||
|
||
static artifact_publish_observer_t *g_artifact_publish_observer;
|
||
|
||
static void observe_artifact_publish_log(const char *line) {
|
||
if (g_artifact_publish_observer && line && strstr(line, "msg=artifact.export")) {
|
||
g_artifact_publish_observer->export_count++;
|
||
if (!g_artifact_publish_observer->published) {
|
||
g_artifact_publish_observer->exports_before_publish++;
|
||
}
|
||
}
|
||
}
|
||
|
||
static int observe_successful_publish_rename(const char *staging_path, const char *final_path,
|
||
void *arg) {
|
||
artifact_publish_observer_t *observer = (artifact_publish_observer_t *)arg;
|
||
observer->rename_calls++;
|
||
int rc = cbm_rename_replace(staging_path, final_path);
|
||
observer->published = rc == 0;
|
||
return rc;
|
||
}
|
||
|
||
static int run_observing_artifact_publish(cbm_pipeline_t *pipeline,
|
||
artifact_publish_observer_t *observer) {
|
||
cbm_pipeline_set_rename_hook_for_tests(pipeline, observe_successful_publish_rename, observer);
|
||
CBMLogLevel previous_level = cbm_log_get_level();
|
||
CBMLogFormat previous_format = cbm_log_get_format();
|
||
g_artifact_publish_observer = observer;
|
||
cbm_log_set_level(CBM_LOG_INFO);
|
||
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
|
||
cbm_log_set_sink_ex(observe_artifact_publish_log, CBM_LOG_SINK_REPLACE);
|
||
int rc = cbm_pipeline_run(pipeline);
|
||
cbm_log_set_sink(NULL);
|
||
cbm_log_set_format(previous_format);
|
||
cbm_log_set_level(previous_level);
|
||
g_artifact_publish_observer = NULL;
|
||
return rc;
|
||
}
|
||
|
||
static bool pipeline_reports_excluded_dir(cbm_pipeline_t *pipeline, const char *rel_path) {
|
||
char **excluded = NULL;
|
||
int excluded_count = 0;
|
||
cbm_pipeline_get_excluded(pipeline, &excluded, &excluded_count);
|
||
for (int i = 0; i < excluded_count; i++) {
|
||
if (excluded[i] && strcmp(excluded[i], rel_path) == 0) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
typedef struct {
|
||
int rc;
|
||
cbm_incremental_route_t route;
|
||
bool tools_excluded;
|
||
artifact_publish_observer_t publish;
|
||
} observed_fast_run_t;
|
||
|
||
static observed_fast_run_t run_observed_fast_pipeline(const char *repo_path, const char *db_path) {
|
||
observed_fast_run_t result = {.rc = CBM_NOT_FOUND};
|
||
cbm_pipeline_t *pipeline = cbm_pipeline_new(repo_path, db_path, CBM_MODE_FAST);
|
||
if (!pipeline) {
|
||
return result;
|
||
}
|
||
result.rc = run_observing_artifact_publish(pipeline, &result.publish);
|
||
result.route = cbm_pipeline_incremental_test_last_route();
|
||
result.tools_excluded = pipeline_reports_excluded_dir(pipeline, "tools");
|
||
cbm_pipeline_free(pipeline);
|
||
return result;
|
||
}
|
||
|
||
typedef struct {
|
||
int rc;
|
||
int before_nodes;
|
||
int after_nodes;
|
||
} imported_generation_t;
|
||
|
||
static imported_generation_t import_artifact_generation(const char *repo_path,
|
||
const char *import_path,
|
||
const char *project) {
|
||
imported_generation_t result = {
|
||
.rc = cbm_artifact_import(repo_path, import_path),
|
||
.before_nodes = -1,
|
||
.after_nodes = -1,
|
||
};
|
||
if (result.rc == 0) {
|
||
observe_named_generation(import_path, project, "StoredBefore", "StoredAfter",
|
||
&result.before_nodes, &result.after_nodes);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
static bool stored_mode_is_full(const char *db_path, const char *project) {
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
if (!store) {
|
||
return false;
|
||
}
|
||
cbm_coverage_meta_t meta = {0};
|
||
bool full = cbm_store_coverage_meta_get(store, project, &meta) == CBM_STORE_OK &&
|
||
meta.index_mode && strcmp(meta.index_mode, "full") == 0;
|
||
cbm_store_coverage_meta_clear(&meta);
|
||
cbm_store_close(store);
|
||
return full;
|
||
}
|
||
|
||
/* Once a repository contains a shared artifact, every subsequently published
|
||
* full generation must refresh it, even when persistence was not explicitly
|
||
* requested on that invocation. Otherwise the live DB advances while a clean
|
||
* teammate import silently restores the previous graph. */
|
||
TEST(pipeline_existing_artifact_refreshes_after_default_forced_full_reindex) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_artifact_refresh_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
char source_path[512];
|
||
char db_path[512];
|
||
char imported_path[512];
|
||
snprintf(source_path, sizeof(source_path), "%s/generation.py", tmp);
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
snprintf(imported_path, sizeof(imported_path), "%s/imported.db", tmp);
|
||
ASSERT_EQ(th_write_file(source_path, "def ArtifactGenerationBefore():\n return 1\n"), 0);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
artifact_publish_observer_t baseline_observer = {0};
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
cbm_pipeline_set_persistence(baseline, true);
|
||
int baseline_rc = run_observing_artifact_publish(baseline, &baseline_observer);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
ASSERT_TRUE(cbm_artifact_exists(tmp));
|
||
|
||
ASSERT_EQ(th_write_file(source_path, "def ArtifactGenerationAfter():\n return 2\n"), 0);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
artifact_publish_observer_t reindex_observer = {0};
|
||
cbm_pipeline_t *default_reindex = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(default_reindex);
|
||
int reindex_rc = run_observing_artifact_publish(default_reindex, &reindex_observer);
|
||
cbm_incremental_route_t reindex_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(default_reindex);
|
||
|
||
/* A derived artifact must not become an input that forces another rebuild,
|
||
* and refreshing it must not switch the authoritative DB back to WAL. */
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
artifact_publish_observer_t explicit_observer = {0};
|
||
cbm_pipeline_t *explicit_noop = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(explicit_noop);
|
||
cbm_pipeline_set_persistence(explicit_noop, true);
|
||
int explicit_rc = run_observing_artifact_publish(explicit_noop, &explicit_observer);
|
||
cbm_incremental_route_t explicit_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(explicit_noop);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
artifact_publish_observer_t observer = {0};
|
||
cbm_pipeline_t *unchanged = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(unchanged);
|
||
int unchanged_rc = run_observing_artifact_publish(unchanged, &observer);
|
||
cbm_incremental_route_t unchanged_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(unchanged);
|
||
|
||
char journal_mode[16] = {0};
|
||
sqlite3 *raw = NULL;
|
||
sqlite3_stmt *journal_stmt = NULL;
|
||
int journal_ok =
|
||
sqlite3_open_v2(db_path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK &&
|
||
sqlite3_prepare_v2(raw, "PRAGMA journal_mode;", -1, &journal_stmt, NULL) == SQLITE_OK &&
|
||
sqlite3_step(journal_stmt) == SQLITE_ROW;
|
||
if (journal_ok) {
|
||
const unsigned char *mode = sqlite3_column_text(journal_stmt, 0);
|
||
if (!mode || snprintf(journal_mode, sizeof(journal_mode), "%s", (const char *)mode) >=
|
||
(int)sizeof(journal_mode)) {
|
||
journal_ok = 0;
|
||
}
|
||
}
|
||
sqlite3_finalize(journal_stmt);
|
||
if (raw) {
|
||
sqlite3_close(raw);
|
||
}
|
||
|
||
int live_before = -1;
|
||
int live_after = -1;
|
||
cbm_store_t *live_store = cbm_store_open_path(db_path);
|
||
if (live_store) {
|
||
live_before = named_node_count(live_store, project, "ArtifactGenerationBefore");
|
||
live_after = named_node_count(live_store, project, "ArtifactGenerationAfter");
|
||
cbm_store_close(live_store);
|
||
}
|
||
|
||
int import_rc = cbm_artifact_import(tmp, imported_path);
|
||
int artifact_before = -1;
|
||
int artifact_after = -1;
|
||
cbm_store_t *artifact_store = import_rc == 0 ? cbm_store_open_path(imported_path) : NULL;
|
||
if (artifact_store) {
|
||
artifact_before = named_node_count(artifact_store, project, "ArtifactGenerationBefore");
|
||
artifact_after = named_node_count(artifact_store, project, "ArtifactGenerationAfter");
|
||
cbm_store_close(artifact_store);
|
||
}
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(baseline_rc, 0);
|
||
ASSERT_EQ(baseline_observer.rename_calls, 1);
|
||
ASSERT_EQ(baseline_observer.exports_before_publish, 0);
|
||
ASSERT_EQ(baseline_observer.export_count, 1);
|
||
ASSERT_EQ(reindex_rc, 0);
|
||
ASSERT_EQ(reindex_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(reindex_observer.rename_calls, 1);
|
||
ASSERT_EQ(reindex_observer.exports_before_publish, 0);
|
||
ASSERT_EQ(reindex_observer.export_count, 1);
|
||
ASSERT_EQ(explicit_rc, 0);
|
||
ASSERT_EQ(explicit_route, CBM_INCREMENTAL_ROUTE_NOOP);
|
||
ASSERT_EQ(explicit_observer.rename_calls, 1);
|
||
ASSERT_EQ(explicit_observer.exports_before_publish, 0);
|
||
ASSERT_EQ(explicit_observer.export_count, 1);
|
||
ASSERT_EQ(unchanged_rc, 0);
|
||
ASSERT_EQ(unchanged_route, CBM_INCREMENTAL_ROUTE_NOOP);
|
||
ASSERT_EQ(observer.rename_calls, 1);
|
||
ASSERT_EQ(observer.exports_before_publish, 0);
|
||
ASSERT_EQ(observer.export_count, 1);
|
||
ASSERT_TRUE(journal_ok);
|
||
ASSERT_STR_EQ(journal_mode, "delete");
|
||
ASSERT_EQ(live_before, 0);
|
||
ASSERT_EQ(live_after, 1);
|
||
ASSERT_EQ(import_rc, 0);
|
||
ASSERT_EQ(artifact_before, 0);
|
||
ASSERT_EQ(artifact_after, 1);
|
||
PASS();
|
||
}
|
||
|
||
/* Cancellation observed after predump must be an explicit preserved abort,
|
||
* never a false success. The retry then publishes the edited generation. */
|
||
TEST(pipeline_full_cancel_after_predump_preserves_previous_generation) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_cancel_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
static const char before_source[] = "def BeforeCancel():\n return 1\n";
|
||
static const char after_source[] = "def AfterCancel():\n return 2\n# changed\n";
|
||
write_temp_file(tmp, "generation.py", before_source);
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
write_temp_file(tmp, "generation.py", after_source);
|
||
cbm_pipeline_incremental_test_cancel_after_predump_once();
|
||
cbm_pipeline_t *cancelled = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(cancelled);
|
||
int cancelled_rc = cbm_pipeline_run(cancelled);
|
||
cbm_incremental_route_t cancelled_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(cancelled);
|
||
int cancelled_before = -1;
|
||
int cancelled_after = -1;
|
||
observe_named_generation(db_path, project, "BeforeCancel", "AfterCancel", &cancelled_before,
|
||
&cancelled_after);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(retry);
|
||
int retry_rc = cbm_pipeline_run(retry);
|
||
cbm_incremental_route_t retry_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(retry);
|
||
int retry_before = -1;
|
||
int retry_after = -1;
|
||
observe_named_generation(db_path, project, "BeforeCancel", "AfterCancel", &retry_before,
|
||
&retry_after);
|
||
int stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(cancelled_rc, CBM_PIPELINE_ABORT_PRESERVE_DB);
|
||
ASSERT_EQ(cancelled_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(cancelled_before, 1);
|
||
ASSERT_EQ(cancelled_after, 0);
|
||
ASSERT_EQ(retry_rc, 0);
|
||
ASSERT_EQ(retry_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(retry_before, 0);
|
||
ASSERT_EQ(retry_after, 1);
|
||
ASSERT_EQ(stage_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* Cancellation can arrive while the destination DB is being quiesced. It must
|
||
* still be observed at the final commit boundary, before the atomic rename. */
|
||
TEST(pipeline_full_cancel_after_destination_prepare_preserves_previous_generation) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_late_cancel_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
write_temp_file(tmp, "generation.py", "def BeforeLateCancel():\n return 1\n");
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
write_temp_file(tmp, "generation.py", "def AfterLateCancel():\n return 2\n# changed\n");
|
||
cbm_pipeline_incremental_test_cancel_after_destination_prepare_once();
|
||
cbm_pipeline_t *cancelled = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(cancelled);
|
||
int cancelled_rc = cbm_pipeline_run(cancelled);
|
||
cbm_pipeline_free(cancelled);
|
||
|
||
int before_count = -1;
|
||
int after_count = -1;
|
||
observe_named_generation(db_path, project, "BeforeLateCancel", "AfterLateCancel", &before_count,
|
||
&after_count);
|
||
int stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(cancelled_rc, CBM_PIPELINE_ABORT_PRESERVE_DB);
|
||
ASSERT_EQ(before_count, 1);
|
||
ASSERT_EQ(after_count, 0);
|
||
ASSERT_EQ(stage_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* A failure after graph serialization but before metadata/publication must
|
||
* discard only the staged generation. The final path remains the complete old
|
||
* graph and a clean retry converges. */
|
||
TEST(pipeline_full_persist_failure_after_stage_dump_preserves_previous_generation) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_full_fail_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
write_temp_file(tmp, "generation.py", "def BeforeFullPersist():\n return 1\n");
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
write_temp_file(tmp, "generation.py",
|
||
"def AfterFullPersist():\n return 2\n# changed generation\n");
|
||
cbm_pipeline_incremental_test_fail_after_stage_dump_once();
|
||
cbm_pipeline_t *faulted = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(faulted);
|
||
int faulted_rc = cbm_pipeline_run(faulted);
|
||
cbm_pipeline_free(faulted);
|
||
int faulted_before = -1;
|
||
int faulted_after = -1;
|
||
observe_named_generation(db_path, project, "BeforeFullPersist", "AfterFullPersist",
|
||
&faulted_before, &faulted_after);
|
||
int faulted_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(retry);
|
||
int retry_rc = cbm_pipeline_run(retry);
|
||
cbm_pipeline_free(retry);
|
||
int retry_before = -1;
|
||
int retry_after = -1;
|
||
observe_named_generation(db_path, project, "BeforeFullPersist", "AfterFullPersist",
|
||
&retry_before, &retry_after);
|
||
int retry_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(faulted_rc, CBM_PIPELINE_PERSIST_FAILED);
|
||
ASSERT_EQ(faulted_before, 1);
|
||
ASSERT_EQ(faulted_after, 0);
|
||
ASSERT_EQ(faulted_stage_count, 0);
|
||
ASSERT_EQ(retry_rc, 0);
|
||
ASSERT_EQ(retry_before, 0);
|
||
ASSERT_EQ(retry_after, 1);
|
||
ASSERT_EQ(retry_stage_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* Keep the legacy partial route test-only so its fail-closed behavior remains
|
||
* covered even though production semantic changes now force a full rebuild. */
|
||
TEST(pipeline_incremental_persist_failure_preserves_previous_generation_and_retries) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_incr_fail_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
write_temp_file(tmp, "generation.py", "def BeforeIncrementalPersist():\n return 1\n");
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
write_temp_file(tmp, "generation.py",
|
||
"def AfterIncrementalPersist():\n return 2\n# changed generation\n");
|
||
cbm_pipeline_incremental_test_force_legacy_partial_once();
|
||
cbm_pipeline_incremental_test_fail_after_stage_dump_once();
|
||
cbm_pipeline_t *faulted = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(faulted);
|
||
int faulted_rc = cbm_pipeline_run(faulted);
|
||
cbm_incremental_route_t faulted_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(faulted);
|
||
int faulted_before = -1;
|
||
int faulted_after = -1;
|
||
observe_named_generation(db_path, project, "BeforeIncrementalPersist",
|
||
"AfterIncrementalPersist", &faulted_before, &faulted_after);
|
||
int faulted_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(retry);
|
||
int retry_rc = cbm_pipeline_run(retry);
|
||
cbm_incremental_route_t retry_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(retry);
|
||
int retry_before = -1;
|
||
int retry_after = -1;
|
||
observe_named_generation(db_path, project, "BeforeIncrementalPersist",
|
||
"AfterIncrementalPersist", &retry_before, &retry_after);
|
||
int retry_stage_count = count_generation_stage_artifacts(tmp, "generation.db");
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(faulted_rc, CBM_PIPELINE_PERSIST_FAILED);
|
||
ASSERT_EQ(faulted_route, CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL);
|
||
ASSERT_EQ(faulted_before, 1);
|
||
ASSERT_EQ(faulted_after, 0);
|
||
ASSERT_EQ(faulted_stage_count, 0);
|
||
ASSERT_EQ(retry_rc, 0);
|
||
ASSERT_EQ(retry_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(retry_before, 0);
|
||
ASSERT_EQ(retry_after, 1);
|
||
ASSERT_EQ(retry_stage_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
TEST(pipeline_incremental_successful_publication_preserves_adr) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_incr_adr_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
write_temp_file(tmp, "generation.py", "def BeforeAdrIncremental():\n return 1\n");
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
static const char adr_text[] = "# Decision\nPreserve across publication.";
|
||
cbm_store_t *adr_store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(adr_store);
|
||
ASSERT_EQ(cbm_store_adr_store(adr_store, project, adr_text), CBM_STORE_OK);
|
||
cbm_store_close(adr_store);
|
||
|
||
write_temp_file(tmp, "generation.py",
|
||
"def AfterAdrIncremental():\n return 2\n# changed generation\n");
|
||
cbm_pipeline_incremental_test_force_legacy_partial_once();
|
||
cbm_pipeline_t *incremental = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(incremental);
|
||
int run_rc = cbm_pipeline_run(incremental);
|
||
cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(incremental);
|
||
cbm_store_t *published = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(published);
|
||
cbm_adr_t adr = {0};
|
||
int adr_rc = cbm_store_adr_get(published, project, &adr);
|
||
bool adr_matches = adr_rc == CBM_STORE_OK && adr.content && strcmp(adr.content, adr_text) == 0;
|
||
cbm_store_adr_free(&adr);
|
||
cbm_store_close(published);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(run_rc, 0);
|
||
ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL);
|
||
ASSERT_TRUE(adr_matches);
|
||
PASS();
|
||
}
|
||
|
||
/* A forced-full rebuild must never erase an ADR merely because the old
|
||
* generation could not be read completely. The capture is part of the
|
||
* publication transaction: failure preserves both graph and ADR. */
|
||
TEST(pipeline_full_adr_capture_failure_preserves_previous_generation) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_adr_capture_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
write_temp_file(tmp, "generation.py", "def BeforeAdrCapture():\n return 1\n");
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
static const char adr_text[] = "# Decision\nADR capture is fail-closed.";
|
||
cbm_store_t *adr_store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(adr_store);
|
||
ASSERT_EQ(cbm_store_adr_store(adr_store, project, adr_text), CBM_STORE_OK);
|
||
cbm_store_close(adr_store);
|
||
|
||
write_temp_file(tmp, "generation.py", "def AfterAdrCapture():\n return 2\n");
|
||
cbm_pipeline_incremental_test_fail_adr_capture_once();
|
||
cbm_pipeline_t *faulted = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(faulted);
|
||
int faulted_rc = cbm_pipeline_run(faulted);
|
||
cbm_pipeline_free(faulted);
|
||
|
||
int faulted_before = -1;
|
||
int faulted_after = -1;
|
||
observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture",
|
||
&faulted_before, &faulted_after);
|
||
cbm_store_t *preserved = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(preserved);
|
||
cbm_adr_t preserved_adr = {0};
|
||
int preserved_adr_rc = cbm_store_adr_get(preserved, project, &preserved_adr);
|
||
bool preserved_adr_matches = preserved_adr_rc == CBM_STORE_OK && preserved_adr.content &&
|
||
strcmp(preserved_adr.content, adr_text) == 0;
|
||
cbm_store_adr_free(&preserved_adr);
|
||
cbm_store_close(preserved);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(retry);
|
||
int retry_rc = cbm_pipeline_run(retry);
|
||
cbm_pipeline_free(retry);
|
||
int retry_before = -1;
|
||
int retry_after = -1;
|
||
observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture", &retry_before,
|
||
&retry_after);
|
||
cbm_store_t *published = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(published);
|
||
cbm_adr_t published_adr = {0};
|
||
int published_adr_rc = cbm_store_adr_get(published, project, &published_adr);
|
||
bool published_adr_matches = published_adr_rc == CBM_STORE_OK && published_adr.content &&
|
||
strcmp(published_adr.content, adr_text) == 0;
|
||
cbm_store_adr_free(&published_adr);
|
||
cbm_store_close(published);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(faulted_rc, CBM_PIPELINE_ABORT_PRESERVE_DB);
|
||
ASSERT_EQ(faulted_before, 1);
|
||
ASSERT_EQ(faulted_after, 0);
|
||
ASSERT_TRUE(preserved_adr_matches);
|
||
ASSERT_EQ(retry_rc, 0);
|
||
ASSERT_EQ(retry_before, 0);
|
||
ASSERT_EQ(retry_after, 1);
|
||
ASSERT_TRUE(published_adr_matches);
|
||
PASS();
|
||
}
|
||
|
||
/* An exact semantic manifest must fail closed when its repository root cannot
|
||
* be traversed. A regular file is a deterministic cross-platform opendir
|
||
* failure that does not depend on process permissions. */
|
||
TEST(pipeline_semantic_manifest_rejects_non_directory_root) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_manifest_not_dir_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
write_temp_file(tmp, "not-a-directory", "manifest root sentinel\n");
|
||
char root_path[512];
|
||
snprintf(root_path, sizeof(root_path), "%s/not-a-directory", tmp);
|
||
|
||
cbm_file_hash_t *manifest = NULL;
|
||
int manifest_count = -1;
|
||
int rc = cbm_pipeline_build_semantic_manifest("manifest-fail-closed", root_path, NULL, 0, NULL,
|
||
0, NULL, NULL, &manifest, &manifest_count);
|
||
cbm_pipeline_free_semantic_manifest(manifest, manifest_count > 0 ? manifest_count : 0);
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_TRUE(rc != 0);
|
||
ASSERT_EQ(manifest_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* A fully validated staged graph must be able to recover from a definitely
|
||
* non-SQLite destination without deleting evidence or overwriting an earlier
|
||
* quarantine. The replacement happens only after the corrupt bytes are moved. */
|
||
TEST(pipeline_full_reindex_quarantines_corrupt_destination_without_overwrite) {
|
||
static const char corrupt_bytes[] = "not a sqlite database\n";
|
||
static const char previous_backup[] = "older quarantine\n";
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_corrupt_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
write_temp_file(tmp, "fresh.py", "def FreshAfterCorruption():\n return 1\n");
|
||
write_temp_file(tmp, "generation.db", corrupt_bytes);
|
||
write_temp_file(tmp, "generation.db.corrupt", previous_backup);
|
||
|
||
char db_path[512];
|
||
char old_backup_path[512];
|
||
char new_backup_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
snprintf(old_backup_path, sizeof(old_backup_path), "%s.corrupt", db_path);
|
||
snprintf(new_backup_path, sizeof(new_backup_path), "%s.corrupt.1", db_path);
|
||
|
||
cbm_pipeline_t *pipeline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(pipeline);
|
||
int run_rc = cbm_pipeline_run(pipeline);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(pipeline));
|
||
cbm_pipeline_free(pipeline);
|
||
|
||
char old_observed[64] = {0};
|
||
char new_observed[64] = {0};
|
||
FILE *old_backup_file = cbm_fopen(old_backup_path, "rb");
|
||
FILE *new_backup_file = cbm_fopen(new_backup_path, "rb");
|
||
size_t old_size =
|
||
old_backup_file ? fread(old_observed, 1, sizeof(old_observed) - 1, old_backup_file) : 0;
|
||
size_t new_size =
|
||
new_backup_file ? fread(new_observed, 1, sizeof(new_observed) - 1, new_backup_file) : 0;
|
||
if (old_backup_file) {
|
||
fclose(old_backup_file);
|
||
}
|
||
if (new_backup_file) {
|
||
fclose(new_backup_file);
|
||
}
|
||
|
||
int fresh_nodes = -1;
|
||
cbm_store_t *store = cbm_store_open_path_query(db_path);
|
||
if (store) {
|
||
fresh_nodes = named_node_count(store, project, "FreshAfterCorruption");
|
||
cbm_store_close(store);
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(run_rc, 0);
|
||
ASSERT_EQ((int)old_size, (int)strlen(previous_backup));
|
||
ASSERT_STR_EQ(old_observed, previous_backup);
|
||
ASSERT_EQ((int)new_size, (int)strlen(corrupt_bytes));
|
||
ASSERT_STR_EQ(new_observed, corrupt_bytes);
|
||
ASSERT_EQ(fresh_nodes, 1);
|
||
PASS();
|
||
}
|
||
|
||
/* Readable legacy graphs are replaceable generations, not corruption. The
|
||
* publication boundary must neither run current-schema migration against the
|
||
* old edges table nor require an ADR table that did not exist yet. */
|
||
TEST(pipeline_full_reindex_replaces_legacy_schema_without_quarantine) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_legacy_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
write_temp_file(tmp, "fresh.py", "def FreshAfterLegacy():\n return 1\n");
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
|
||
sqlite3 *legacy = NULL;
|
||
ASSERT_EQ(sqlite3_open_v2(db_path, &legacy, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL),
|
||
SQLITE_OK);
|
||
const char *legacy_schema =
|
||
"CREATE TABLE projects(name TEXT PRIMARY KEY,indexed_at TEXT NOT NULL,root_path TEXT NOT "
|
||
"NULL);"
|
||
"CREATE TABLE file_hashes(project TEXT NOT NULL,rel_path TEXT NOT NULL,sha256 TEXT NOT "
|
||
"NULL,mtime_ns INTEGER NOT NULL DEFAULT 0,size INTEGER NOT NULL DEFAULT 0,PRIMARY KEY "
|
||
"(project,rel_path));"
|
||
"CREATE TABLE nodes(id INTEGER PRIMARY KEY,project TEXT NOT NULL,label TEXT NOT NULL,name "
|
||
"TEXT NOT NULL,qualified_name TEXT NOT NULL,file_path TEXT,start_line INTEGER,end_line "
|
||
"INTEGER,properties TEXT DEFAULT '{}');"
|
||
"CREATE TABLE edges(id INTEGER PRIMARY KEY,project TEXT NOT NULL,source_id INTEGER NOT "
|
||
"NULL,target_id INTEGER NOT NULL,type TEXT NOT NULL,properties TEXT DEFAULT '{}',UNIQUE "
|
||
"(source_id,target_id,type));";
|
||
ASSERT_EQ(sqlite3_exec(legacy, legacy_schema, NULL, NULL, NULL), SQLITE_OK);
|
||
sqlite3_stmt *insert_project = NULL;
|
||
ASSERT_EQ(sqlite3_prepare_v2(
|
||
legacy, "INSERT INTO projects(name,indexed_at,root_path) VALUES(?1,'legacy',?2);",
|
||
-1, &insert_project, NULL),
|
||
SQLITE_OK);
|
||
const char *project_name = strrchr(tmp, '/');
|
||
project_name = project_name ? project_name + 1 : tmp;
|
||
ASSERT_EQ(sqlite3_bind_text(insert_project, 1, project_name, -1, SQLITE_TRANSIENT), SQLITE_OK);
|
||
ASSERT_EQ(sqlite3_bind_text(insert_project, 2, tmp, -1, SQLITE_TRANSIENT), SQLITE_OK);
|
||
ASSERT_EQ(sqlite3_step(insert_project), SQLITE_DONE);
|
||
sqlite3_finalize(insert_project);
|
||
sqlite3_close(legacy);
|
||
|
||
cbm_pipeline_t *pipeline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(pipeline);
|
||
int run_rc = cbm_pipeline_run(pipeline);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(pipeline));
|
||
cbm_pipeline_free(pipeline);
|
||
|
||
int fresh_nodes = -1;
|
||
cbm_store_t *store = cbm_store_open_path_query(db_path);
|
||
if (store) {
|
||
fresh_nodes = named_node_count(store, project, "FreshAfterLegacy");
|
||
cbm_store_close(store);
|
||
}
|
||
int generated_column_count = 0;
|
||
sqlite3 *current = NULL;
|
||
sqlite3_stmt *column_query = NULL;
|
||
if (sqlite3_open_v2(db_path, ¤t, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK &&
|
||
sqlite3_prepare_v2(current,
|
||
"SELECT count(*) FROM pragma_table_xinfo('edges') "
|
||
"WHERE name='local_name_gen';",
|
||
-1, &column_query, NULL) == SQLITE_OK &&
|
||
sqlite3_step(column_query) == SQLITE_ROW) {
|
||
generated_column_count = sqlite3_column_int(column_query, 0);
|
||
}
|
||
sqlite3_finalize(column_query);
|
||
if (current) {
|
||
sqlite3_close(current);
|
||
}
|
||
char quarantine_path[512];
|
||
snprintf(quarantine_path, sizeof(quarantine_path), "%s.corrupt", db_path);
|
||
cbm_path_info_t quarantine_info;
|
||
bool quarantined = cbm_path_info_utf8(quarantine_path, &quarantine_info) == 0;
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(run_rc, 0);
|
||
ASSERT_EQ(fresh_nodes, 1);
|
||
ASSERT_EQ(generated_column_count, 1);
|
||
ASSERT_FALSE(quarantined);
|
||
PASS();
|
||
}
|
||
#endif
|
||
|
||
/* Re-indexing only a target whose qualified name survives a callable-to-value
|
||
* edit must not restore the unchanged caller's stale inbound CALL_REFERENCE
|
||
* snapshot. A fresh full index of the edited sources is the convergence oracle:
|
||
* the now-non-callable identifier remains a USAGE, never a call relationship. */
|
||
TEST(pipeline_incremental_changed_target_invalidates_stale_inbound_call_reference) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_ref_stale_inbound_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
static const char callable_target_source[] = "export function staleSemanticTarget(): void {}\n";
|
||
static const char value_target_source[] = "export const staleSemanticTarget = 42;\n";
|
||
static const char caller_source[] =
|
||
"import { staleSemanticTarget } from './target';\n"
|
||
"function acceptStaleSemanticReference(callback: () => void): void {}\n"
|
||
"export function staleSemanticCaller(): void {\n"
|
||
" acceptStaleSemanticReference(staleSemanticTarget);\n"
|
||
"}\n";
|
||
|
||
write_temp_file(tmp, "target.ts", callable_target_source);
|
||
write_temp_file(tmp, "caller.ts", caller_source);
|
||
|
||
char incremental_db[512];
|
||
snprintf(incremental_db, sizeof(incremental_db), "%s/stale-inbound-incremental.db", tmp);
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, incremental_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
const char *baseline_project = cbm_pipeline_project_name(baseline);
|
||
cbm_store_t *baseline_store = cbm_store_open_path(incremental_db);
|
||
ASSERT_NOT_NULL(baseline_store);
|
||
ASSERT_EQ(named_edge_count(baseline_store, baseline_project, "CALL_REFERENCE",
|
||
"staleSemanticCaller", "staleSemanticTarget"),
|
||
1);
|
||
ASSERT_EQ(named_edge_count(baseline_store, baseline_project, "USAGE", "staleSemanticCaller",
|
||
"staleSemanticTarget"),
|
||
0);
|
||
ASSERT_EQ(named_edge_count(baseline_store, baseline_project, "CALLS", "staleSemanticCaller",
|
||
"staleSemanticTarget"),
|
||
0);
|
||
cbm_store_close(baseline_store);
|
||
cbm_pipeline_free(baseline);
|
||
|
||
write_temp_file(tmp, "target.ts", value_target_source);
|
||
|
||
cbm_pipeline_t *incremental = cbm_pipeline_new(tmp, incremental_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(incremental);
|
||
ASSERT_EQ(cbm_pipeline_run(incremental), 0);
|
||
const char *incremental_project = cbm_pipeline_project_name(incremental);
|
||
cbm_store_t *incremental_store = cbm_store_open_path(incremental_db);
|
||
ASSERT_NOT_NULL(incremental_store);
|
||
int incremental_reference =
|
||
named_edge_count(incremental_store, incremental_project, "CALL_REFERENCE",
|
||
"staleSemanticCaller", "staleSemanticTarget");
|
||
int incremental_usage = named_edge_count(incremental_store, incremental_project, "USAGE",
|
||
"staleSemanticCaller", "staleSemanticTarget");
|
||
int incremental_calls = named_edge_count(incremental_store, incremental_project, "CALLS",
|
||
"staleSemanticCaller", "staleSemanticTarget");
|
||
cbm_store_close(incremental_store);
|
||
cbm_pipeline_free(incremental);
|
||
|
||
char full_db[512];
|
||
snprintf(full_db, sizeof(full_db), "%s/stale-inbound-full.db", tmp);
|
||
cbm_pipeline_t *full = cbm_pipeline_new(tmp, full_db, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(full);
|
||
ASSERT_EQ(cbm_pipeline_run(full), 0);
|
||
const char *full_project = cbm_pipeline_project_name(full);
|
||
cbm_store_t *full_store = cbm_store_open_path(full_db);
|
||
ASSERT_NOT_NULL(full_store);
|
||
int full_reference = named_edge_count(full_store, full_project, "CALL_REFERENCE",
|
||
"staleSemanticCaller", "staleSemanticTarget");
|
||
int full_usage = named_edge_count(full_store, full_project, "USAGE", "staleSemanticCaller",
|
||
"staleSemanticTarget");
|
||
int full_calls = named_edge_count(full_store, full_project, "CALLS", "staleSemanticCaller",
|
||
"staleSemanticTarget");
|
||
cbm_store_close(full_store);
|
||
cbm_pipeline_free(full);
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(full_reference, 0);
|
||
ASSERT_EQ(full_usage, 1);
|
||
ASSERT_EQ(full_calls, 0);
|
||
ASSERT_EQ(incremental_reference, full_reference);
|
||
ASSERT_EQ(incremental_usage, full_usage);
|
||
ASSERT_EQ(incremental_calls, full_calls);
|
||
PASS();
|
||
}
|
||
|
||
/* Registry construction in incremental-parallel mode can materialize serial
|
||
* resource nodes after extraction establishes the workers' shared-ID
|
||
* watermark. A subsequent worker-created synthetic node must not reuse that
|
||
* ID: duplicate temporary IDs make dump-time edge remapping point CONFIGURES
|
||
* or DECORATES at the wrong target. The second run changes all 51 files so the
|
||
* production incremental path crosses MIN_FILES_FOR_PARALLEL_INCR. */
|
||
TEST(pipeline_incremental_parallel_registry_nodes_advance_shared_ids) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_incr_ids_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
char collision_path[512];
|
||
snprintf(collision_path, sizeof(collision_path), "%s/00_collision.py", tmp);
|
||
int setup_ok =
|
||
th_write_file(collision_path, "import os\n"
|
||
"def incrementalCollisionTarget(): return 1\n"
|
||
"def incrementalCollisionAccept(callback): pass\n"
|
||
"@old_marker\n"
|
||
"def incrementalCollisionCarrier():\n"
|
||
" incrementalCollisionTarget()\n"
|
||
" incrementalCollisionAccept(incrementalCollisionTarget)\n"
|
||
" return os.getenv(\"OLD_TOKEN\")\n") == 0;
|
||
for (int i = 0; setup_ok && i < 50; i++) {
|
||
char path[512];
|
||
char source[128];
|
||
snprintf(path, sizeof(path), "%s/z_pad_%02d.ts", tmp, i);
|
||
snprintf(source, sizeof(source), "// initial incremental ID pad %02d\n", i);
|
||
setup_ok = th_write_file(path, source) == 0;
|
||
}
|
||
|
||
char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
|
||
char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
|
||
char *saved_single = old_single ? strdup(old_single) : NULL;
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
cbm_setenv("CBM_WORKERS", "4", 1);
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/incremental-ids.db", tmp);
|
||
int first_rc = -1;
|
||
int second_rc = -1;
|
||
int env_edge_count = -1;
|
||
int decorator_edge_count = -1;
|
||
int misdirected_env_edge_count = -1;
|
||
int reference_edge_count = -1;
|
||
int env_node_count = -1;
|
||
int decorator_node_count = -1;
|
||
|
||
if (setup_ok) {
|
||
cbm_pipeline_t *first = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
if (first) {
|
||
first_rc = cbm_pipeline_run(first);
|
||
cbm_pipeline_free(first);
|
||
}
|
||
}
|
||
|
||
int update_ok =
|
||
setup_ok && first_rc == 0 &&
|
||
th_write_file(collision_path, "import os\n"
|
||
"def incrementalCollisionTarget(): return 1\n"
|
||
"def incrementalCollisionAccept(callback): pass\n"
|
||
"@new_marker\n"
|
||
"def incrementalCollisionCarrier():\n"
|
||
" incrementalCollisionTarget()\n"
|
||
" incrementalCollisionAccept(incrementalCollisionTarget)\n"
|
||
" return os.getenv(\"NEW_TOKEN\")\n"
|
||
"# incremental size marker\n") == 0;
|
||
for (int i = 0; update_ok && i < 50; i++) {
|
||
char path[512];
|
||
char source[160];
|
||
snprintf(path, sizeof(path), "%s/z_pad_%02d.ts", tmp, i);
|
||
snprintf(source, sizeof(source), "// changed incremental ID pad %02d with marker\n", i);
|
||
update_ok = th_write_file(path, source) == 0;
|
||
}
|
||
|
||
if (update_ok) {
|
||
cbm_pipeline_t *second = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
if (second) {
|
||
second_rc = cbm_pipeline_run(second);
|
||
const char *project = cbm_pipeline_project_name(second);
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
if (store && project) {
|
||
env_edge_count = named_edge_count(store, project, "CONFIGURES",
|
||
"incrementalCollisionCarrier", "NEW_TOKEN");
|
||
decorator_edge_count = named_edge_count(
|
||
store, project, "DECORATES", "incrementalCollisionCarrier", "new_marker");
|
||
misdirected_env_edge_count = named_edge_count(
|
||
store, project, "CONFIGURES", "incrementalCollisionCarrier", "new_marker");
|
||
reference_edge_count =
|
||
named_edge_count(store, project, "CALL_REFERENCE",
|
||
"incrementalCollisionCarrier", "incrementalCollisionTarget");
|
||
env_node_count = named_node_count(store, project, "NEW_TOKEN");
|
||
decorator_node_count = named_node_count(store, project, "new_marker");
|
||
}
|
||
if (store) {
|
||
cbm_store_close(store);
|
||
}
|
||
cbm_pipeline_free(second);
|
||
}
|
||
}
|
||
|
||
if (saved_workers) {
|
||
cbm_setenv("CBM_WORKERS", saved_workers, 1);
|
||
free(saved_workers);
|
||
} else {
|
||
cbm_unsetenv("CBM_WORKERS");
|
||
}
|
||
if (saved_single) {
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1);
|
||
free(saved_single);
|
||
} else {
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
if (env_node_count != 1 || decorator_node_count != 1 || env_edge_count != 1 ||
|
||
decorator_edge_count != 1 || misdirected_env_edge_count != 0 || reference_edge_count != 1) {
|
||
printf(" incremental ID diagnostic: env_node=%d decorator_node=%d "
|
||
"configures_env=%d decorates=%d configures_decorator=%d call_reference=%d\n",
|
||
env_node_count, decorator_node_count, env_edge_count, decorator_edge_count,
|
||
misdirected_env_edge_count, reference_edge_count);
|
||
}
|
||
|
||
ASSERT_TRUE(setup_ok);
|
||
ASSERT_EQ(first_rc, 0);
|
||
ASSERT_TRUE(update_ok);
|
||
ASSERT_EQ(second_rc, 0);
|
||
ASSERT_EQ(env_node_count, 1);
|
||
ASSERT_EQ(decorator_node_count, 1);
|
||
ASSERT_EQ(env_edge_count, 1);
|
||
ASSERT_EQ(decorator_edge_count, 1);
|
||
ASSERT_EQ(misdirected_env_edge_count, 0);
|
||
ASSERT_EQ(reference_edge_count, 1);
|
||
PASS();
|
||
}
|
||
|
||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||
static void observe_incremental_generation(const char *db_path, const char *project,
|
||
int generation_size, int *before_count,
|
||
int *after_count) {
|
||
*before_count = -1;
|
||
*after_count = -1;
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
if (!store || !project) {
|
||
if (store) {
|
||
cbm_store_close(store);
|
||
}
|
||
return;
|
||
}
|
||
|
||
int before = 0;
|
||
int after = 0;
|
||
for (int i = 0; i < generation_size; i++) {
|
||
char before_name[32];
|
||
char after_name[32];
|
||
snprintf(before_name, sizeof(before_name), "Before%02d", i);
|
||
snprintf(after_name, sizeof(after_name), "After%02d", i);
|
||
int before_nodes = named_node_count(store, project, before_name);
|
||
int after_nodes = named_node_count(store, project, after_name);
|
||
if (before_nodes < 0 || after_nodes < 0) {
|
||
cbm_store_close(store);
|
||
return;
|
||
}
|
||
before += before_nodes;
|
||
after += after_nodes;
|
||
}
|
||
cbm_store_close(store);
|
||
*before_count = before;
|
||
*after_count = after;
|
||
}
|
||
|
||
/* A parallel incremental run must preserve its prior database when the
|
||
* per-file result cache cannot be allocated. A clean retry must then converge
|
||
* to the new generation.
|
||
*
|
||
* RED before fail-closed propagation: the injected allocation failure skips
|
||
* extract/resolve, but the caller dumps the already-purged graph, advances all
|
||
* file hashes, and returns 0. The second graph is empty and the third run is a
|
||
* no-op instead of recovering the missing definitions. */
|
||
TEST(pipeline_incremental_parallel_result_cache_alloc_failure_preserves_db_and_retries) {
|
||
enum { GENERATION_SIZE = 51 };
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_incr_cache_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
|
||
char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
|
||
char *saved_single = old_single ? strdup(old_single) : NULL;
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
cbm_setenv("CBM_WORKERS", "4", 1);
|
||
|
||
int setup_ok = 1;
|
||
for (int i = 0; setup_ok && i < GENERATION_SIZE; i++) {
|
||
char path[512];
|
||
char source[128];
|
||
snprintf(path, sizeof(path), "%s/generation_%02d.py", tmp, i);
|
||
snprintf(source, sizeof(source), "def Before%02d():\n return %d\n", i, i);
|
||
setup_ok = th_write_file(path, source) == 0;
|
||
}
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/incremental-cache.db", tmp);
|
||
int first_rc = -1;
|
||
int baseline_before = -1;
|
||
int baseline_after = -1;
|
||
if (setup_ok) {
|
||
cbm_pipeline_t *first = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
if (first) {
|
||
first_rc = cbm_pipeline_run(first);
|
||
observe_incremental_generation(db_path, cbm_pipeline_project_name(first),
|
||
GENERATION_SIZE, &baseline_before, &baseline_after);
|
||
cbm_pipeline_free(first);
|
||
}
|
||
}
|
||
|
||
int edit_ok = setup_ok && first_rc == 0;
|
||
for (int i = 0; edit_ok && i < GENERATION_SIZE; i++) {
|
||
char path[512];
|
||
char source[160];
|
||
snprintf(path, sizeof(path), "%s/generation_%02d.py", tmp, i);
|
||
snprintf(source, sizeof(source),
|
||
"def After%02d():\n return %d\n# edited generation %02d\n", i, i, i);
|
||
edit_ok = th_write_file(path, source) == 0;
|
||
}
|
||
|
||
int second_rc = -1;
|
||
int second_before = -1;
|
||
int second_after = -1;
|
||
if (edit_ok) {
|
||
cbm_pipeline_incremental_test_fail_result_cache_alloc_once();
|
||
cbm_pipeline_incremental_test_force_legacy_partial_once();
|
||
cbm_pipeline_t *second = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
if (second) {
|
||
second_rc = cbm_pipeline_run(second);
|
||
observe_incremental_generation(db_path, cbm_pipeline_project_name(second),
|
||
GENERATION_SIZE, &second_before, &second_after);
|
||
cbm_pipeline_free(second);
|
||
}
|
||
}
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
|
||
int third_rc = -1;
|
||
int retry_before = -1;
|
||
int retry_after = -1;
|
||
if (edit_ok) {
|
||
cbm_pipeline_t *third = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
if (third) {
|
||
third_rc = cbm_pipeline_run(third);
|
||
observe_incremental_generation(db_path, cbm_pipeline_project_name(third),
|
||
GENERATION_SIZE, &retry_before, &retry_after);
|
||
cbm_pipeline_free(third);
|
||
}
|
||
}
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
|
||
if (saved_workers) {
|
||
cbm_setenv("CBM_WORKERS", saved_workers, 1);
|
||
free(saved_workers);
|
||
} else {
|
||
cbm_unsetenv("CBM_WORKERS");
|
||
}
|
||
if (saved_single) {
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1);
|
||
free(saved_single);
|
||
} else {
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
bool second_is_preserved_failure = second_rc == CBM_PIPELINE_ABORT_PRESERVE_DB &&
|
||
second_before == GENERATION_SIZE && second_after == 0;
|
||
if (!second_is_preserved_failure) {
|
||
printf(" incremental cache failure diagnostic: rc=%d before=%d after=%d\n", second_rc,
|
||
second_before, second_after);
|
||
}
|
||
|
||
ASSERT_TRUE(setup_ok);
|
||
ASSERT_EQ(first_rc, 0);
|
||
ASSERT_EQ(baseline_before, GENERATION_SIZE);
|
||
ASSERT_EQ(baseline_after, 0);
|
||
ASSERT_TRUE(edit_ok);
|
||
ASSERT_TRUE(second_is_preserved_failure);
|
||
ASSERT_EQ(third_rc, 0);
|
||
ASSERT_EQ(retry_before, 0);
|
||
ASSERT_EQ(retry_after, GENERATION_SIZE);
|
||
PASS();
|
||
}
|
||
#endif
|
||
|
||
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();
|
||
}
|
||
|
||
/* Reproduce-first: pass_parallel's fused cross-LSP eligibility currently counts
|
||
* only parser-backed calls/call references. A Python binary operator has no
|
||
* parser CBMCall; its __add__ semantic record and carrier are created together
|
||
* only by the cross resolver. Consequently combine.py looks like it has zero
|
||
* semantic sites and the fused pass skips it before that carrier can exist.
|
||
*
|
||
* `visible()` is deliberately in a different file. Its ordinary method-call
|
||
* carrier makes THAT file cross-LSP eligible and its lsp_* edge proves that the
|
||
* Alpha definition, import map, shared registry, and cross resolver are all
|
||
* healthy. Putting the control call in combine.py would hide the bug by making
|
||
* the broken file eligible. The 52 fillers plus three fixture files exceed
|
||
* MIN_FILES_FOR_PARALLEL (50), and CBM_WORKERS=4 forces the production fused
|
||
* path independent of host core count. */
|
||
TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_syn_par_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
write_temp_file(tmp, "alpha.py",
|
||
"class Alpha:\n"
|
||
" def __add__(self, other):\n"
|
||
" return self\n");
|
||
write_temp_file(tmp, "visible.py",
|
||
"from .alpha import Alpha\n\n"
|
||
"def visible(left: Alpha, right: Alpha):\n"
|
||
" return left.__add__(right)\n");
|
||
write_temp_file(tmp, "combine.py",
|
||
"from .alpha import Alpha\n\n"
|
||
"def combine(left: Alpha, right: Alpha):\n"
|
||
" return left + right\n");
|
||
for (int i = 0; i < 52; i++) {
|
||
char name[64];
|
||
char body[128];
|
||
snprintf(name, sizeof(name), "py_pad_%02d.py", i);
|
||
snprintf(body, sizeof(body), "def py_pad_%02d():\n return %d\n", i, i);
|
||
write_temp_file(tmp, name, body);
|
||
}
|
||
|
||
char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
|
||
cbm_setenv("CBM_WORKERS", "4", 1);
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/python_parallel.db", tmp);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int run_rc = cbm_pipeline_run(p);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
bool store_opened = s != NULL;
|
||
bool control_lsp = false;
|
||
bool combine_to_add = false;
|
||
if (s) {
|
||
control_lsp =
|
||
cross_file_call_has_strategy(s, project, "visible", "__add__", "\"strategy\":\"lsp_");
|
||
combine_to_add = cross_file_call_exists(s, project, "combine", "__add__");
|
||
cbm_store_close(s);
|
||
}
|
||
|
||
cbm_pipeline_free(p);
|
||
if (saved_workers) {
|
||
cbm_setenv("CBM_WORKERS", saved_workers, 1);
|
||
free(saved_workers);
|
||
} else {
|
||
cbm_unsetenv("CBM_WORKERS");
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(run_rc, 0);
|
||
ASSERT_TRUE(store_opened);
|
||
ASSERT_TRUE(control_lsp); /* non-vacuity: cross LSP + target are healthy */
|
||
ASSERT_TRUE(combine_to_add); /* RED: fused eligibility skips combine.py */
|
||
PASS();
|
||
}
|
||
|
||
/* Same fused-eligibility hole for Rust built-in macro arguments. The parser
|
||
* exposes format! but not render() inside its token tree. Per-file LSP already
|
||
* qualifies every parser-backed site in hidden.rs, so the fused heuristic sees
|
||
* no remaining work and never runs the cross pass that would discover both the
|
||
* semantic render target and its exact synthetic carrier.
|
||
*
|
||
* The exact same 55-file fixture is indexed once through the sequential path
|
||
* first. A hidden() -> render CALLS edge there is a strong non-vacuity control:
|
||
* only the sequential cross-LSP pass can discover both the macro-hidden call
|
||
* and its synthetic carrier. The parallel run must preserve that result. */
|
||
TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_rs_syn_par_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("tmpdir");
|
||
}
|
||
|
||
write_temp_file(tmp, "lib.rs", "pub fn render() -> &'static str { \"ok\" }\n");
|
||
write_temp_file(tmp, "main.rs",
|
||
"mod lib;\n\n"
|
||
"pub fn hidden() -> String { format!(\"{}\", lib::render()) }\n");
|
||
for (int i = 0; i < 53; i++) {
|
||
char name[64];
|
||
char body[128];
|
||
snprintf(name, sizeof(name), "rust_pad_%02d.rs", i);
|
||
snprintf(body, sizeof(body), "pub fn rust_pad_%02d() -> i32 { %d }\n", i, i);
|
||
write_temp_file(tmp, name, body);
|
||
}
|
||
|
||
char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
|
||
char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
|
||
char *saved_single = old_single ? strdup(old_single) : NULL;
|
||
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1);
|
||
char seq_db_path[512];
|
||
snprintf(seq_db_path, sizeof(seq_db_path), "%s/rust_sequential.db", tmp);
|
||
cbm_pipeline_t *seq = cbm_pipeline_new(tmp, seq_db_path, CBM_MODE_FULL);
|
||
int seq_run_rc = seq ? cbm_pipeline_run(seq) : -1;
|
||
const char *seq_project = seq ? cbm_pipeline_project_name(seq) : NULL;
|
||
cbm_store_t *seq_store = cbm_store_open_path(seq_db_path);
|
||
bool seq_store_opened = seq_store != NULL;
|
||
bool seq_hidden_to_render = false;
|
||
if (seq_store && seq_project) {
|
||
seq_hidden_to_render = cross_file_call_exists(seq_store, seq_project, "hidden", "render");
|
||
cbm_store_close(seq_store);
|
||
}
|
||
cbm_pipeline_free(seq);
|
||
|
||
/* The parallel half must not inherit a caller-level single-thread override,
|
||
* even when the surrounding test process was launched with one. Restore it
|
||
* after both halves complete. */
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
cbm_setenv("CBM_WORKERS", "4", 1);
|
||
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/rust_parallel.db", tmp);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
int run_rc = cbm_pipeline_run(p);
|
||
const char *project = cbm_pipeline_project_name(p);
|
||
cbm_store_t *s = cbm_store_open_path(db_path);
|
||
bool store_opened = s != NULL;
|
||
bool render_target_present = false;
|
||
bool hidden_to_render = false;
|
||
if (s) {
|
||
render_target_present = count_nodes_named(s, project, "render") > 0;
|
||
hidden_to_render = cross_file_call_exists(s, project, "hidden", "render");
|
||
cbm_store_close(s);
|
||
}
|
||
|
||
cbm_pipeline_free(p);
|
||
if (saved_workers) {
|
||
cbm_setenv("CBM_WORKERS", saved_workers, 1);
|
||
free(saved_workers);
|
||
} else {
|
||
cbm_unsetenv("CBM_WORKERS");
|
||
}
|
||
if (saved_single) {
|
||
cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1);
|
||
free(saved_single);
|
||
} else {
|
||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
|
||
}
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(seq_run_rc, 0);
|
||
ASSERT_TRUE(seq_store_opened);
|
||
ASSERT_TRUE(seq_hidden_to_render); /* same fixture is semantically resolvable */
|
||
ASSERT_EQ(run_rc, 0);
|
||
ASSERT_TRUE(store_opened);
|
||
ASSERT_TRUE(render_target_present); /* target is present in the parallel graph */
|
||
ASSERT_TRUE(hidden_to_render); /* RED: fused eligibility skips hidden.rs */
|
||
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.
|
||
* Explicit Kotlin callable syntax produces CALL_REFERENCE, not USAGE. */
|
||
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);
|
||
|
||
/* The explicit reference is resolved and classified exclusively. */
|
||
cbm_edge_t *edges = NULL;
|
||
int edge_count = 0;
|
||
rc = cbm_store_find_edges_by_type(s, project, "CALL_REFERENCE", &edges, &edge_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
bool found_reference = 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 && src.name &&
|
||
tgt.name && strcmp(src.name, "register") == 0 && strcmp(tgt.name, "process") == 0) {
|
||
found_reference = true;
|
||
}
|
||
cbm_node_free_fields(&src);
|
||
cbm_node_free_fields(&tgt);
|
||
}
|
||
if (edges)
|
||
cbm_store_free_edges(edges, edge_count);
|
||
ASSERT_TRUE(found_reference);
|
||
|
||
edges = NULL;
|
||
edge_count = 0;
|
||
rc = cbm_store_find_edges_by_type(s, project, "USAGE", &edges, &edge_count);
|
||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||
bool duplicated_as_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 && src.name &&
|
||
tgt.name && strcmp(src.name, "register") == 0 && strcmp(tgt.name, "process") == 0) {
|
||
duplicated_as_usage = true;
|
||
}
|
||
cbm_node_free_fields(&src);
|
||
cbm_node_free_fields(&tgt);
|
||
}
|
||
if (edges)
|
||
cbm_store_free_edges(edges, edge_count);
|
||
ASSERT_FALSE(duplicated_as_usage);
|
||
|
||
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();
|
||
}
|
||
|
||
/* End-to-end (issue #551 item 1): two SwiftPM packages, Core and App,
|
||
* indexed under one root. App declares a local path dependency on Core and
|
||
* a target dependency on Core's product; App.swift does a bare
|
||
* `import Core`. This only resolves because Core's OWN Package.swift
|
||
* self-registers "Core" -> Core/Sources/Core in the shared pkgmap, landing
|
||
* App.swift's IMPORTS edge on that directory's Folder node -- proving real
|
||
* cross-package resolution. Asserts the EXACT provider node (the
|
||
* Core/Sources/Core Folder, found by its real QN, not a substring guess)
|
||
* and the exact edge (from App.swift's own file node to that provider) --
|
||
* stronger than repro_issue408.c/repro_issue56.c's own count/substring
|
||
* checks, which this test intentionally does not settle for. */
|
||
TEST(pipeline_swift_cross_package_import) {
|
||
const char *files[] = {
|
||
"Core/Package.swift",
|
||
"Core/Sources/Core/Core.swift",
|
||
"App/Package.swift",
|
||
"App/Sources/App/App.swift",
|
||
};
|
||
const char *contents[] = {
|
||
("let package = Package(\n"
|
||
" name: \"Core\",\n"
|
||
" products: [.library(name: \"Core\", targets: [\"Core\"])],\n"
|
||
" targets: [.target(name: \"Core\", dependencies: [])]\n"
|
||
")\n"),
|
||
|
||
"public func coreValue() -> Int {\n return 1\n}\n",
|
||
|
||
("let package = Package(\n"
|
||
" name: \"App\",\n"
|
||
" dependencies: [.package(path: \"../Core\")],\n"
|
||
" targets: [.target(name: \"App\", dependencies: [\n"
|
||
" .product(name: \"Core\", package: \"Core\")\n"
|
||
" ])]\n"
|
||
")\n"),
|
||
|
||
("import Core\n\n"
|
||
"func run() -> Int {\n return coreValue()\n}\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);
|
||
|
||
/* The exact provider node: the Folder for Core/Sources/Core, which is
|
||
* exactly what Core/Package.swift's OWN self-registration resolves to
|
||
* (see cbm_pkgmap_build: pkg name "Core" -> fqn_module(entry_rel)).
|
||
* cbm_pipeline_fqn_module and cbm_pipeline_fqn_folder agree on this
|
||
* path (no extension on any segment to strip), so this is the same QN
|
||
* the structural pass gave the real Folder node -- not a guess. */
|
||
char *provider_qn = cbm_pipeline_fqn_folder(proj, "Core/Sources/Core");
|
||
ASSERT_NOT_NULL(provider_qn);
|
||
cbm_node_t provider = {0};
|
||
ASSERT_EQ(cbm_store_find_node_by_qn(s, proj, provider_qn, &provider), CBM_STORE_OK);
|
||
ASSERT_STR_EQ(provider.label, "Folder");
|
||
free(provider_qn);
|
||
|
||
/* The exact importing file node: App/Sources/App/App.swift. */
|
||
char *importer_qn = cbm_pipeline_fqn_compute(proj, "App/Sources/App/App.swift", "__file__");
|
||
ASSERT_NOT_NULL(importer_qn);
|
||
cbm_node_t importer = {0};
|
||
ASSERT_EQ(cbm_store_find_node_by_qn(s, proj, importer_qn, &importer), CBM_STORE_OK);
|
||
free(importer_qn);
|
||
|
||
/* The IMPORTS edge must run from THAT importer to THAT provider --
|
||
* not merely "some edge lands on a QN containing Core" (a node named
|
||
* e.g. "AppCore" would have false-passed the old substring check). */
|
||
cbm_edge_t *edges = NULL;
|
||
int ec = 0;
|
||
ASSERT_EQ(cbm_store_find_edges_by_source_type(s, importer.id, "IMPORTS", &edges, &ec),
|
||
CBM_STORE_OK);
|
||
|
||
bool found_exact_edge = false;
|
||
for (int i = 0; i < ec; i++) {
|
||
if (edges[i].target_id == provider.id) {
|
||
found_exact_edge = true;
|
||
}
|
||
}
|
||
ASSERT_TRUE(found_exact_edge);
|
||
|
||
if (edges)
|
||
cbm_store_free_edges(edges, ec);
|
||
cbm_node_free_fields(&provider);
|
||
cbm_node_free_fields(&importer);
|
||
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();
|
||
}
|
||
|
||
/* #725: two same-named symbols across languages must not share CALLS edges.
|
||
* Python Store.commit is the real callee of save(); the JS Editor.commit
|
||
* function is a distinct binding and must have no inbound CALLS from Python.
|
||
* unique_name (candidates==1) is #1572 and is not this claim. */
|
||
TEST(pipeline_cross_language_same_name_does_not_share_calls_issue725) {
|
||
const char *files[] = {"store.py", "app.py", "web/src/pages/Editor.js"};
|
||
const char *contents[] = {
|
||
"class Store:\n"
|
||
" def commit(self):\n"
|
||
" return True\n",
|
||
|
||
"from store import Store\n"
|
||
"\n"
|
||
"def save():\n"
|
||
" return Store().commit()\n",
|
||
|
||
"export function commit() {\n"
|
||
" return 1;\n"
|
||
"}\n"};
|
||
|
||
if (setup_lang_repo(files, contents, 3) != 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 *commits = NULL;
|
||
int ncommit = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "commit", &commits, &ncommit);
|
||
ASSERT_GTE(ncommit, 2);
|
||
|
||
int64_t js_id = 0;
|
||
int64_t py_id = 0;
|
||
for (int i = 0; i < ncommit; i++) {
|
||
if (commits[i].file_path && strstr(commits[i].file_path, "Editor.js"))
|
||
js_id = commits[i].id;
|
||
if (commits[i].file_path && strstr(commits[i].file_path, "store.py"))
|
||
py_id = commits[i].id;
|
||
}
|
||
ASSERT_TRUE(js_id != 0);
|
||
ASSERT_TRUE(py_id != 0);
|
||
|
||
cbm_node_t *saves = NULL;
|
||
int nsave = 0;
|
||
cbm_store_find_nodes_by_name(s, proj, "save", &saves, &nsave);
|
||
ASSERT_GT(nsave, 0);
|
||
|
||
cbm_edge_t *from_save = NULL;
|
||
int nfrom = 0;
|
||
cbm_store_find_edges_by_source_type(s, saves[0].id, "CALLS", &from_save, &nfrom);
|
||
bool save_calls_py = false;
|
||
bool save_calls_js = false;
|
||
for (int i = 0; i < nfrom; i++) {
|
||
if (from_save[i].target_id == py_id)
|
||
save_calls_py = true;
|
||
if (from_save[i].target_id == js_id)
|
||
save_calls_js = true;
|
||
}
|
||
ASSERT_TRUE(save_calls_py);
|
||
ASSERT_FALSE(save_calls_js);
|
||
|
||
cbm_edge_t *into_js = NULL;
|
||
int njs = 0;
|
||
cbm_store_find_edges_by_target_type(s, js_id, "CALLS", &into_js, &njs);
|
||
ASSERT_EQ(njs, 0);
|
||
|
||
if (from_save)
|
||
cbm_store_free_edges(from_save, nfrom);
|
||
if (into_js)
|
||
cbm_store_free_edges(into_js, njs);
|
||
cbm_store_free_nodes(commits, ncommit);
|
||
cbm_store_free_nodes(saves, nsave);
|
||
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();
|
||
}
|
||
|
||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||
static int branch_head_match_count(cbm_store_t *store, const char *project, const char *head_sha) {
|
||
cbm_node_t *branches = NULL;
|
||
int branch_count = 0;
|
||
if (!store || !project || !head_sha ||
|
||
cbm_store_find_nodes_by_label(store, project, "Branch", &branches, &branch_count) !=
|
||
CBM_STORE_OK) {
|
||
return -1;
|
||
}
|
||
int matches = 0;
|
||
for (int i = 0; i < branch_count; i++) {
|
||
if (branches[i].properties_json && strstr(branches[i].properties_json, head_sha)) {
|
||
matches++;
|
||
}
|
||
}
|
||
cbm_store_free_nodes(branches, branch_count);
|
||
return matches;
|
||
}
|
||
|
||
/* Git context is graph input even when every repository file is unchanged.
|
||
* Construct the second pipeline before moving HEAD so the test also proves
|
||
* that run start refreshes the snapshot captured by cbm_pipeline_new(). */
|
||
TEST(pipeline_git_context_change_forces_full_and_refreshes_branch) {
|
||
if (!git_available()) {
|
||
FAIL("git unavailable");
|
||
}
|
||
|
||
char tmp[256];
|
||
char *created = th_mktempdir("cbm_manifest_git_context");
|
||
ASSERT_NOT_NULL(created);
|
||
snprintf(tmp, sizeof(tmp), "%s", created);
|
||
|
||
char repo[512], db_path[512], cmd[2048];
|
||
snprintf(repo, sizeof(repo), "%s/repo", tmp);
|
||
snprintf(db_path, sizeof(db_path), "%s/context.db", tmp);
|
||
ASSERT_EQ(th_mkdir_p(repo), 0);
|
||
ASSERT_EQ(th_write_file(TH_PATH(repo, "stable.py"), "def StableGitContext():\n return 1\n"),
|
||
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\" add stable.py >%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);
|
||
|
||
cbm_git_context_t initial_ctx = {0};
|
||
ASSERT_EQ(cbm_git_context_resolve(repo, &initial_ctx), 0);
|
||
ASSERT_NOT_NULL(initial_ctx.head_sha);
|
||
char initial_head[128];
|
||
snprintf(initial_head, sizeof(initial_head), "%s", initial_ctx.head_sha);
|
||
cbm_git_context_free(&initial_ctx);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(repo, db_path, CBM_MODE_FAST);
|
||
ASSERT_NOT_NULL(baseline);
|
||
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
|
||
char project[256];
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
|
||
cbm_pipeline_t *after_head_move = cbm_pipeline_new(repo, db_path, CBM_MODE_FAST);
|
||
ASSERT_NOT_NULL(after_head_move);
|
||
snprintf(cmd, sizeof(cmd),
|
||
"git -C \"%s\" -c user.name=\"CBM Test\" "
|
||
"-c user.email=\"cbm@example.invalid\" commit --allow-empty "
|
||
"-m \"head-only change\" >%s 2>&1",
|
||
repo, null_dev);
|
||
ASSERT_EQ(run_cmd(cmd), 0);
|
||
cbm_git_context_t changed_ctx = {0};
|
||
ASSERT_EQ(cbm_git_context_resolve(repo, &changed_ctx), 0);
|
||
ASSERT_NOT_NULL(changed_ctx.head_sha);
|
||
char changed_head[128];
|
||
snprintf(changed_head, sizeof(changed_head), "%s", changed_ctx.head_sha);
|
||
cbm_git_context_free(&changed_ctx);
|
||
ASSERT_TRUE(strcmp(initial_head, changed_head) != 0);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
int changed_rc = cbm_pipeline_run(after_head_move);
|
||
cbm_incremental_route_t changed_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(after_head_move);
|
||
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
int changed_branch_matches = store ? branch_head_match_count(store, project, changed_head) : -1;
|
||
int stale_branch_matches = store ? branch_head_match_count(store, project, initial_head) : -1;
|
||
cbm_file_hash_t git_input = {0};
|
||
int git_input_rc =
|
||
store ? cbm_store_get_file_hash(
|
||
store, project, ".codebase-memory/.semantic-input/git-context-v1", &git_input)
|
||
: CBM_STORE_NOT_FOUND;
|
||
cbm_store_clear_file_hash(&git_input);
|
||
if (store) {
|
||
cbm_store_close(store);
|
||
}
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *unchanged = cbm_pipeline_new(repo, db_path, CBM_MODE_FAST);
|
||
ASSERT_NOT_NULL(unchanged);
|
||
int unchanged_rc = cbm_pipeline_run(unchanged);
|
||
cbm_incremental_route_t unchanged_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(unchanged);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(changed_rc, 0);
|
||
ASSERT_EQ(changed_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(changed_branch_matches, 1);
|
||
ASSERT_EQ(stale_branch_matches, 0);
|
||
ASSERT_EQ(git_input_rc, CBM_STORE_OK);
|
||
ASSERT_EQ(unchanged_rc, 0);
|
||
ASSERT_EQ(unchanged_route, CBM_INCREMENTAL_ROUTE_NOOP);
|
||
PASS();
|
||
}
|
||
|
||
/* The global extension config lives outside the repository but changes both
|
||
* discovery and parsing. Its exact consumed bytes therefore belong to the
|
||
* same semantic generation as the graph they selected. */
|
||
TEST(pipeline_global_extension_config_change_forces_full) {
|
||
char tmp[256];
|
||
char *created = th_mktempdir("cbm_manifest_global_config");
|
||
ASSERT_NOT_NULL(created);
|
||
snprintf(tmp, sizeof(tmp), "%s", created);
|
||
|
||
char repo[512], config_root[512], app_dir[768], config_path[1024], db_path[512];
|
||
snprintf(repo, sizeof(repo), "%s/repo", tmp);
|
||
snprintf(config_root, sizeof(config_root), "%s/config-root", tmp);
|
||
snprintf(app_dir, sizeof(app_dir), "%s/codebase-memory-mcp", config_root);
|
||
snprintf(config_path, sizeof(config_path), "%s/config.json", app_dir);
|
||
snprintf(db_path, sizeof(db_path), "%s/config.db", tmp);
|
||
ASSERT_EQ(th_mkdir_p(repo), 0);
|
||
ASSERT_EQ(th_mkdir_p(app_dir), 0);
|
||
ASSERT_EQ(th_write_file(TH_PATH(repo, "fixture.cbmfixture"),
|
||
"def GlobalExtensionTarget():\n return 1\n"),
|
||
0);
|
||
ASSERT_EQ(th_write_file(config_path, "{\"extra_extensions\":{\".cbmfixture\":\"python\"}}\n"),
|
||
0);
|
||
|
||
#ifdef _WIN32
|
||
const char *env_name = "APPDATA";
|
||
#else
|
||
const char *env_name = "XDG_CONFIG_HOME";
|
||
#endif
|
||
char old_env[CBM_SZ_1K] = {0};
|
||
bool had_old_env = cbm_safe_getenv(env_name, old_env, sizeof(old_env), NULL) != NULL;
|
||
|
||
int setenv_rc = cbm_setenv(env_name, config_root, 1);
|
||
int baseline_rc = -1;
|
||
int initial_nodes = -1;
|
||
int changed_rc = -1;
|
||
cbm_incremental_route_t changed_route = CBM_INCREMENTAL_ROUTE_NONE;
|
||
int changed_nodes = -1;
|
||
int config_input_rc = CBM_STORE_NOT_FOUND;
|
||
int unchanged_rc = -1;
|
||
cbm_incremental_route_t unchanged_route = CBM_INCREMENTAL_ROUTE_NONE;
|
||
char project[256] = {0};
|
||
|
||
if (setenv_rc == 0) {
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *baseline = cbm_pipeline_new(repo, db_path, CBM_MODE_FAST);
|
||
if (baseline) {
|
||
baseline_rc = cbm_pipeline_run(baseline);
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline));
|
||
cbm_pipeline_free(baseline);
|
||
}
|
||
cbm_store_t *baseline_store = cbm_store_open_path(db_path);
|
||
if (baseline_store) {
|
||
initial_nodes = named_node_count(baseline_store, project, "GlobalExtensionTarget");
|
||
cbm_store_close(baseline_store);
|
||
}
|
||
|
||
if (th_write_file(config_path, "{\"extra_extensions\":{\".cbmfixture\":\"json\"}}\n") ==
|
||
0) {
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *changed = cbm_pipeline_new(repo, db_path, CBM_MODE_FAST);
|
||
if (changed) {
|
||
changed_rc = cbm_pipeline_run(changed);
|
||
changed_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(changed);
|
||
}
|
||
cbm_store_t *changed_store = cbm_store_open_path(db_path);
|
||
if (changed_store) {
|
||
changed_nodes = named_node_count(changed_store, project, "GlobalExtensionTarget");
|
||
cbm_file_hash_t config_input = {0};
|
||
config_input_rc = cbm_store_get_file_hash(
|
||
changed_store, project,
|
||
".codebase-memory/.semantic-input/global-extension-config-v1", &config_input);
|
||
cbm_store_clear_file_hash(&config_input);
|
||
cbm_store_close(changed_store);
|
||
}
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_t *unchanged = cbm_pipeline_new(repo, db_path, CBM_MODE_FAST);
|
||
if (unchanged) {
|
||
unchanged_rc = cbm_pipeline_run(unchanged);
|
||
unchanged_route = cbm_pipeline_incremental_test_last_route();
|
||
cbm_pipeline_free(unchanged);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (had_old_env) {
|
||
cbm_setenv(env_name, old_env, 1);
|
||
} else {
|
||
cbm_unsetenv(env_name);
|
||
}
|
||
cbm_set_user_lang_config(NULL);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
th_rmtree(tmp);
|
||
|
||
ASSERT_EQ(setenv_rc, 0);
|
||
ASSERT_EQ(baseline_rc, 0);
|
||
ASSERT_EQ(initial_nodes, 1);
|
||
ASSERT_EQ(changed_rc, 0);
|
||
ASSERT_EQ(changed_route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_EQ(changed_nodes, 0);
|
||
ASSERT_EQ(config_input_rc, CBM_STORE_OK);
|
||
ASSERT_EQ(unchanged_rc, 0);
|
||
ASSERT_EQ(unchanged_route, CBM_INCREMENTAL_ROUTE_NOOP);
|
||
PASS();
|
||
}
|
||
#endif
|
||
|
||
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);
|
||
}
|
||
/* Binary, matching test_helpers.h/repro_harness.h: text mode turns "\n"
|
||
* into "\r\n" on Windows, so the bytes on disk stop matching the source
|
||
* string the test hashes or measures -- the semantic-manifest tests
|
||
* compare cbm_sha256_hex(string) against the pipeline's hash of this
|
||
* file, and the quarantine test compares byte sizes. */
|
||
FILE *f = cbm_fopen(path, "wb");
|
||
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;
|
||
}
|
||
|
||
/* Helper: return the entry_rel registered for `name`, or NULL. */
|
||
static const char *pkg_entries_entry_for(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 e->items[i].entry_rel;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* ── SwiftPM Package.swift manifest resolution (issue #551 item 1) ──
|
||
*
|
||
* parse_package_swift is a literal pattern-extractor (mirrors
|
||
* parse_cargo_toml), not a Swift evaluator. These call cbm_pkgmap_try_parse
|
||
* directly, covering the RED categories the maintainer asked for (local
|
||
* path deps, remote identities, products not aliasing, targets, target-name
|
||
* deps, literal + computed `path:`, and comment/string false positives)
|
||
* plus fail-closed ambiguous-name cases. See pipeline_swift_cross_package_import
|
||
* above for the full end-to-end proof. */
|
||
|
||
TEST(pkgmap_swift_targets_registers_module) {
|
||
static const char src[] =
|
||
"// swift-tools-version:5.9\n"
|
||
"import PackageDescription\n"
|
||
"let package = Package(\n"
|
||
" name: \"Core\",\n"
|
||
" targets: [.target(name: \"Core\", dependencies: [])]\n"
|
||
")\n";
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
||
(int)strlen(src), &entries);
|
||
ASSERT_TRUE(ok);
|
||
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
||
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/Core");
|
||
cbm_pkg_entries_free(&entries);
|
||
PASS();
|
||
}
|
||
|
||
/* Products deliberately do NOT self-register a separate alias: a product
|
||
* name is not generally an importable module (SwiftPM lets it alias
|
||
* multiple targets, or none sharing its own name), so only the underlying
|
||
* target -- under its OWN name -- registers. */
|
||
TEST(pkgmap_swift_products_do_not_register_alias) {
|
||
static const char src[] =
|
||
"let package = Package(\n"
|
||
" name: \"Core\",\n"
|
||
" products: [.library(name: \"CoreKit\", targets: [\"CoreImpl\"])],\n"
|
||
" targets: [.target(name: \"CoreImpl\", dependencies: [])]\n"
|
||
")\n";
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
||
(int)strlen(src), &entries);
|
||
ASSERT_TRUE(ok);
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "CoreKit"));
|
||
ASSERT_TRUE(pkg_entries_has_name(&entries, "CoreImpl"));
|
||
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "CoreImpl"), "Core/Sources/CoreImpl");
|
||
ASSERT_EQ(entries.count, 1);
|
||
cbm_pkg_entries_free(&entries);
|
||
PASS();
|
||
}
|
||
|
||
/* Regression: a target whose `name:` is the LAST argument, immediately
|
||
* followed by the call's own closing ')' with no trailing comma, must still
|
||
* register. swift_quoted_literal's terminator check used to compare against
|
||
* `end` with a strict '<', but every caller passes the wrapping call's own
|
||
* ')' position AS `end` -- so the literal's closing quote landing exactly
|
||
* on that boundary was wrongly rejected as "unterminated". Every other
|
||
* fixture in this file happens to follow `name:` with `dependencies:` or a
|
||
* comma, so this specific shape was previously untested and unnoticed. */
|
||
TEST(pkgmap_swift_target_name_immediately_before_close_paren) {
|
||
static const char src[] =
|
||
"let package = Package(\n"
|
||
" name: \"Core\",\n"
|
||
" targets: [.target(name: \"Core\")]\n"
|
||
")\n";
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
||
(int)strlen(src), &entries);
|
||
ASSERT_TRUE(ok);
|
||
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
||
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/Core");
|
||
ASSERT_EQ(entries.count, 1);
|
||
cbm_pkg_entries_free(&entries);
|
||
PASS();
|
||
}
|
||
|
||
/* A literal `path:` argument overrides the Sources/<name> convention. */
|
||
TEST(pkgmap_swift_target_honors_literal_path) {
|
||
static const char src[] =
|
||
"let package = Package(\n"
|
||
" name: \"Core\",\n"
|
||
" targets: [.target(name: \"Core\", path: \"Vendor/CoreLegacy\")]\n"
|
||
")\n";
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
||
(int)strlen(src), &entries);
|
||
ASSERT_TRUE(ok);
|
||
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
||
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Vendor/CoreLegacy");
|
||
cbm_pkg_entries_free(&entries);
|
||
PASS();
|
||
}
|
||
|
||
/* A `path:` argument that IS present but not a bare literal (computed) is
|
||
* unknowable -- SwiftPM would not use the Sources/<name> convention here,
|
||
* so guessing it anyway would mint a location likely to be wrong. Skip the
|
||
* target entirely (fail closed), even though its `name:` is a valid
|
||
* literal. */
|
||
TEST(pkgmap_swift_target_computed_path_fails_closed) {
|
||
static const char src[] =
|
||
"let customPath = computePath()\n"
|
||
"let package = Package(\n"
|
||
" name: \"Core\",\n"
|
||
" targets: [.target(name: \"Core\", path: customPath)]\n"
|
||
")\n";
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
||
(int)strlen(src), &entries);
|
||
ASSERT_TRUE(ok);
|
||
ASSERT_EQ(entries.count, 0);
|
||
cbm_pkg_entries_free(&entries);
|
||
PASS();
|
||
}
|
||
|
||
/* A `.target(` spelled inside a `//` line comment, a nesting-aware
|
||
* slash-star block comment, or a string literal must never be mistaken for a live
|
||
* declaration -- the bug a raw strstr scan cannot avoid. Only the one real
|
||
* target registers. */
|
||
TEST(pkgmap_swift_target_in_comment_or_string_not_registered) {
|
||
static const char src[] =
|
||
"// .target(name: \"Decoy\")\n"
|
||
"/* outer /* nested */ still a comment: .target(name: \"NestedDecoy\") */\n"
|
||
"let manifestSnippet = \".target(name: \\\"StringDecoy\\\")\"\n"
|
||
"let package = Package(\n"
|
||
" name: \"App\",\n"
|
||
" targets: [.target(name: \"App\", dependencies: [])]\n"
|
||
")\n";
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src,
|
||
(int)strlen(src), &entries);
|
||
ASSERT_TRUE(ok);
|
||
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "Decoy"));
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "NestedDecoy"));
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "StringDecoy"));
|
||
ASSERT_EQ(entries.count, 1);
|
||
cbm_pkg_entries_free(&entries);
|
||
PASS();
|
||
}
|
||
|
||
/* Local path + remote url dependencies (`.package(path:)` / `.package(url:)`)
|
||
* mint NO entries of their own -- mirroring package.json/Cargo.toml, only a
|
||
* manifest's OWN products/targets self-register. A local sibling's name is
|
||
* produced by ITS OWN Package.swift when the repo-wide walk reaches it
|
||
* (see repro_issue408.c's JS-workspace analog); a remote dependency has no
|
||
* local path to point at, so nothing is minted (fail-closed). */
|
||
TEST(pkgmap_swift_dependencies_do_not_leak_entries) {
|
||
static const char src[] =
|
||
"let package = Package(\n"
|
||
" name: \"App\",\n"
|
||
" dependencies: [\n"
|
||
" .package(path: \"../Core\"),\n"
|
||
" .package(url: \"https://github.com/example/RemoteKit.git\", from: \"1.0.0\")\n"
|
||
" ],\n"
|
||
" targets: [.target(name: \"App\", dependencies: [])]\n"
|
||
")\n";
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src,
|
||
(int)strlen(src), &entries);
|
||
ASSERT_TRUE(ok);
|
||
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "Core"));
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "RemoteKit"));
|
||
ASSERT_EQ(entries.count, 1);
|
||
cbm_pkg_entries_free(&entries);
|
||
PASS();
|
||
}
|
||
|
||
/* Only App itself (the declaring target) registers -- a bare same-package
|
||
* target-name dependency ("Core") and a cross-package product dependency
|
||
* (Utils/UtilsPkg) name OTHER modules, not this manifest's own
|
||
* products/targets, so neither mints an entry. */
|
||
TEST(pkgmap_swift_target_name_dependency_does_not_leak_entry) {
|
||
static const char src[] =
|
||
"let package = Package(\n"
|
||
" name: \"App\",\n"
|
||
" targets: [.target(name: \"App\", dependencies: [\n"
|
||
" \"Core\",\n"
|
||
" .product(name: \"Utils\", package: \"UtilsPkg\")\n"
|
||
" ])]\n"
|
||
")\n";
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src,
|
||
(int)strlen(src), &entries);
|
||
ASSERT_TRUE(ok);
|
||
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "Core"));
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "Utils"));
|
||
ASSERT_FALSE(pkg_entries_has_name(&entries, "UtilsPkg"));
|
||
ASSERT_EQ(entries.count, 1);
|
||
cbm_pkg_entries_free(&entries);
|
||
PASS();
|
||
}
|
||
|
||
/* Fail-closed on any `name:` that is not a bare literal: a computed
|
||
* variable, and a literal concatenated with a dynamic suffix (which a
|
||
* naive quote-scan would wrongly accept as "App"). Neither mints an entry,
|
||
* though the manifest is still recognized and parsed. */
|
||
TEST(pkgmap_swift_ambiguous_target_name_fails_closed) {
|
||
static const char dynamic_src[] =
|
||
"let generatedName = \"App\" + String(buildNumber)\n"
|
||
"let package = Package(\n"
|
||
" name: \"App\",\n"
|
||
" targets: [.target(name: generatedName, dependencies: [])]\n"
|
||
")\n";
|
||
static const char concat_src[] =
|
||
"let package = Package(\n"
|
||
" name: \"App\",\n"
|
||
" targets: [.target(name: \"App\" + suffix, dependencies: [])]\n"
|
||
")\n";
|
||
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
ASSERT_TRUE(cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", dynamic_src,
|
||
(int)strlen(dynamic_src), &entries));
|
||
ASSERT_EQ(entries.count, 0);
|
||
cbm_pkg_entries_free(&entries);
|
||
|
||
cbm_pkg_entries_init(&entries);
|
||
ASSERT_TRUE(cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", concat_src,
|
||
(int)strlen(concat_src), &entries));
|
||
ASSERT_EQ(entries.count, 0);
|
||
cbm_pkg_entries_free(&entries);
|
||
PASS();
|
||
}
|
||
|
||
/* The repo-wide manifest walker must also recognize "Package.swift" -- a
|
||
* second code path (is_pkgmap_manifest_basename) from the direct
|
||
* cbm_pkgmap_try_parse calls above. */
|
||
TEST(pkgmap_swift_scan_repo_finds_nested_manifest) {
|
||
char tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pkgmap_swift_XXXXXX");
|
||
if (!cbm_mkdtemp(tmpdir))
|
||
FAIL("tmpdir");
|
||
char dir[512];
|
||
snprintf(dir, sizeof(dir), "%s/Core", tmpdir);
|
||
cbm_mkdir(dir);
|
||
write_temp_file(tmpdir, "Core/Package.swift",
|
||
"let package = Package(\n"
|
||
" name: \"Core\",\n"
|
||
" targets: [.target(name: \"Core\", dependencies: [])]\n"
|
||
")\n");
|
||
|
||
cbm_pkg_entries_t entries;
|
||
cbm_pkg_entries_init(&entries);
|
||
cbm_pkgmap_scan_repo(tmpdir, &entries, NULL, 0);
|
||
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
||
cbm_pkg_entries_free(&entries);
|
||
|
||
th_rmtree(tmpdir);
|
||
PASS();
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
|
||
/* Atomic-publish cancellation seam. Production invokes this hook after the
|
||
* staging database is complete, closed, and integrity-valid, but immediately
|
||
* before it can replace the last committed database. Keeping the hook on the
|
||
* pipeline instance avoids process-global failpoints and makes cancellation
|
||
* tests deterministic even when test runners gain concurrency. */
|
||
extern void cbm_pipeline_set_before_publish_hook_for_tests(
|
||
cbm_pipeline_t *p, void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx);
|
||
|
||
typedef struct {
|
||
const char *project;
|
||
const char *candidate;
|
||
char staging_path[768];
|
||
int calls;
|
||
bool staging_existed;
|
||
bool staging_was_valid;
|
||
int staged_candidates;
|
||
} publish_cancel_ctx_t;
|
||
|
||
typedef struct {
|
||
char staging_path[CBM_SZ_4K];
|
||
int calls;
|
||
bool staging_was_valid;
|
||
} publish_observe_ctx_t;
|
||
|
||
static void observe_publish_boundary(cbm_pipeline_t *p, const char *staging_path, void *arg) {
|
||
(void)p;
|
||
publish_observe_ctx_t *ctx = (publish_observe_ctx_t *)arg;
|
||
ctx->calls++;
|
||
if (!staging_path) {
|
||
return;
|
||
}
|
||
int n = snprintf(ctx->staging_path, sizeof(ctx->staging_path), "%s", staging_path);
|
||
if (n < 0 || (size_t)n >= sizeof(ctx->staging_path)) {
|
||
ctx->staging_path[0] = '\0';
|
||
return;
|
||
}
|
||
cbm_store_t *staging = cbm_store_open_path_existing(staging_path);
|
||
if (staging) {
|
||
ctx->staging_was_valid = cbm_store_check_integrity(staging);
|
||
cbm_store_close(staging);
|
||
}
|
||
}
|
||
|
||
typedef struct {
|
||
int calls;
|
||
} publish_rename_fail_ctx_t;
|
||
|
||
static int fail_publish_rename(const char *staging_path, const char *final_path, void *arg) {
|
||
publish_rename_fail_ctx_t *ctx = (publish_rename_fail_ctx_t *)arg;
|
||
ctx->calls++;
|
||
return staging_path && final_path ? CBM_NOT_FOUND : 0;
|
||
}
|
||
|
||
static bool pipeline_fixture_file_equals(const char *path, const char *expected) {
|
||
FILE *f = cbm_fopen(path, "rb");
|
||
if (!f) {
|
||
return false;
|
||
}
|
||
size_t expected_len = strlen(expected);
|
||
char actual[64];
|
||
size_t n = fread(actual, 1, sizeof(actual), f);
|
||
bool ok = n == expected_len && memcmp(actual, expected, expected_len) == 0 && fgetc(f) == EOF;
|
||
(void)fclose(f);
|
||
return ok;
|
||
}
|
||
|
||
static void cancel_at_publish_boundary(cbm_pipeline_t *p, const char *staging_path, void *arg) {
|
||
publish_cancel_ctx_t *ctx = (publish_cancel_ctx_t *)arg;
|
||
ctx->calls++;
|
||
if (staging_path && staging_path[0]) {
|
||
snprintf(ctx->staging_path, sizeof(ctx->staging_path), "%s", staging_path);
|
||
struct stat st;
|
||
ctx->staging_existed = stat(staging_path, &st) == 0;
|
||
if (ctx->staging_existed) {
|
||
cbm_store_t *staging = cbm_store_open_path(staging_path);
|
||
if (staging) {
|
||
ctx->staging_was_valid = cbm_store_check_integrity(staging);
|
||
ctx->staged_candidates = count_nodes_named(staging, ctx->project, ctx->candidate);
|
||
cbm_store_close(staging);
|
||
}
|
||
}
|
||
}
|
||
cbm_pipeline_cancel(p);
|
||
}
|
||
|
||
static bool sqlite_artifacts_absent(const char *db_path) {
|
||
struct stat st;
|
||
if (!db_path || !db_path[0] || stat(db_path, &st) == 0) {
|
||
return false;
|
||
}
|
||
char sidecar[832];
|
||
snprintf(sidecar, sizeof(sidecar), "%s-wal", db_path);
|
||
if (stat(sidecar, &st) == 0) {
|
||
return false;
|
||
}
|
||
snprintf(sidecar, sizeof(sidecar), "%s-shm", db_path);
|
||
if (stat(sidecar, &st) == 0) {
|
||
return false;
|
||
}
|
||
snprintf(sidecar, sizeof(sidecar), "%s-journal", db_path);
|
||
return stat(sidecar, &st) != 0;
|
||
}
|
||
|
||
static bool write_go_file(const char *dir, const char *name, const char *source) {
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/%s", dir, name);
|
||
FILE *f = fopen(path, "w");
|
||
if (!f) {
|
||
return false;
|
||
}
|
||
bool ok = fputs(source, f) >= 0;
|
||
fclose(f);
|
||
return ok;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
* 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 after the associated builds received a Windows
|
||
* Defender Wacatac.B!ml verdict. The opaque verdict did not attribute the
|
||
* result to this feature; see issue #89 for the historical observation. */
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════
|
||
* 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(full_reindex_recovers_when_previous_coverage_is_unreadable) {
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
|
||
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);
|
||
|
||
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);
|
||
/* Simulate an unreadable prior coverage generation while leaving the
|
||
* graph and file hashes healthy enough to otherwise run incrementally. */
|
||
ASSERT_EQ(
|
||
cbm_store_exec(s, "ALTER TABLE index_coverage RENAME COLUMN detail TO broken_detail;"),
|
||
CBM_STORE_OK);
|
||
cbm_store_close(s);
|
||
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir);
|
||
FILE *f = fopen(path, "a");
|
||
ASSERT_NOT_NULL(f);
|
||
fprintf(f, "\nfunc MustNotBeIndexed() int { return 7 }\n");
|
||
fclose(f);
|
||
|
||
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);
|
||
|
||
/* An exact-manifest delta routes to an isolated full generation, which
|
||
* does not depend on the damaged coverage table and repairs it atomically. */
|
||
s = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(s);
|
||
ASSERT_GT(cbm_store_count_nodes(s, project), nodes_before);
|
||
cbm_node_t *recovered = NULL;
|
||
int recovered_count = 0;
|
||
ASSERT_EQ(
|
||
cbm_store_find_nodes_by_name(s, project, "MustNotBeIndexed", &recovered, &recovered_count),
|
||
CBM_STORE_OK);
|
||
ASSERT_EQ(recovered_count, 1);
|
||
cbm_store_free_nodes(recovered, recovered_count);
|
||
cbm_store_close(s);
|
||
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();
|
||
}
|
||
|
||
/* Cancellation at the final publish boundary must never destroy the last good
|
||
* full index. Adding two files to the two-file baseline deliberately exceeds
|
||
* the incremental router's 1.5x file-count bound (4 > 2 + 2/2), forcing the
|
||
* full-reindex path while an existing committed DB is present. */
|
||
TEST(cancelled_full_reindex_preserves_committed_db) {
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
|
||
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);
|
||
ASSERT_NOT_NULL(project);
|
||
|
||
cbm_store_t *live = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(live);
|
||
ASSERT_TRUE(cbm_store_check_integrity(live));
|
||
int baseline_nodes = cbm_store_count_nodes(live, project);
|
||
int baseline_helper = count_nodes_named(live, project, "Helper");
|
||
ASSERT_GT(baseline_nodes, 0);
|
||
ASSERT_GT(baseline_helper, 0);
|
||
ASSERT_EQ(count_nodes_named(live, project, "CandidateFull"), 0);
|
||
cbm_store_close(live);
|
||
|
||
ASSERT_TRUE(write_go_file(g_incr_tmpdir, "candidate_full.go",
|
||
"package main\n\nfunc CandidateFull() int { return 41 }\n"));
|
||
ASSERT_TRUE(write_go_file(g_incr_tmpdir, "force_full.go",
|
||
"package main\n\nfunc ForceFullRoute() int { return 42 }\n"));
|
||
|
||
publish_cancel_ctx_t hook = {
|
||
.project = project,
|
||
.candidate = "CandidateFull",
|
||
};
|
||
p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
cbm_pipeline_set_before_publish_hook_for_tests(p, cancel_at_publish_boundary, &hook);
|
||
int rc = cbm_pipeline_run(p);
|
||
cbm_pipeline_free(p);
|
||
|
||
live = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(live);
|
||
bool live_valid = cbm_store_check_integrity(live);
|
||
int nodes_after = cbm_store_count_nodes(live, project);
|
||
int helper_after = count_nodes_named(live, project, "Helper");
|
||
int published_candidates = count_nodes_named(live, project, "CandidateFull");
|
||
cbm_store_close(live);
|
||
bool staging_cleaned = sqlite_artifacts_absent(hook.staging_path);
|
||
|
||
free(project);
|
||
cleanup_incremental_repo();
|
||
|
||
ASSERT_EQ(hook.calls, 1);
|
||
ASSERT_TRUE(hook.staging_existed);
|
||
ASSERT_TRUE(hook.staging_was_valid);
|
||
ASSERT_GT(hook.staged_candidates, 0); /* anti-vacuous: new full output was staged */
|
||
/* Cancelling at the publish boundary aborts before the rename, so the
|
||
* committed database is untouched -- which is exactly what this code says.
|
||
* It was a bare -1 while every publish failure collapsed to one value. */
|
||
ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB);
|
||
ASSERT_TRUE(live_valid);
|
||
ASSERT_EQ(nodes_after, baseline_nodes);
|
||
ASSERT_EQ(helper_after, baseline_helper);
|
||
ASSERT_EQ(published_candidates, 0);
|
||
ASSERT_TRUE(staging_cleaned);
|
||
PASS();
|
||
}
|
||
|
||
/* The same contract applies to the incremental path: the modified file is
|
||
* present in a complete staging DB at the hook, but cancellation leaves the
|
||
* prior committed snapshot queryable and removes every staging artifact. */
|
||
TEST(cancelled_incremental_reindex_preserves_committed_db) {
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
|
||
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);
|
||
ASSERT_NOT_NULL(project);
|
||
|
||
cbm_store_t *live = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(live);
|
||
ASSERT_TRUE(cbm_store_check_integrity(live));
|
||
int baseline_nodes = cbm_store_count_nodes(live, project);
|
||
int baseline_helper = count_nodes_named(live, project, "Helper");
|
||
ASSERT_GT(baseline_nodes, 0);
|
||
ASSERT_GT(baseline_helper, 0);
|
||
ASSERT_EQ(count_nodes_named(live, project, "CandidateIncremental"), 0);
|
||
cbm_store_close(live);
|
||
|
||
ASSERT_TRUE(write_go_file(g_incr_tmpdir, "helper.go",
|
||
"package main\n\n"
|
||
"func Helper() string { return \"hello\" }\n\n"
|
||
"func CandidateIncremental() int { return 43 }\n"));
|
||
|
||
publish_cancel_ctx_t hook = {
|
||
.project = project,
|
||
.candidate = "CandidateIncremental",
|
||
};
|
||
p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
cbm_pipeline_set_before_publish_hook_for_tests(p, cancel_at_publish_boundary, &hook);
|
||
int rc = cbm_pipeline_run(p);
|
||
cbm_pipeline_free(p);
|
||
|
||
live = cbm_store_open_path(g_incr_dbpath);
|
||
ASSERT_NOT_NULL(live);
|
||
bool live_valid = cbm_store_check_integrity(live);
|
||
int nodes_after = cbm_store_count_nodes(live, project);
|
||
int helper_after = count_nodes_named(live, project, "Helper");
|
||
int published_candidates = count_nodes_named(live, project, "CandidateIncremental");
|
||
cbm_store_close(live);
|
||
bool staging_cleaned = sqlite_artifacts_absent(hook.staging_path);
|
||
|
||
free(project);
|
||
cleanup_incremental_repo();
|
||
|
||
ASSERT_EQ(hook.calls, 1);
|
||
ASSERT_TRUE(hook.staging_existed);
|
||
ASSERT_TRUE(hook.staging_was_valid);
|
||
ASSERT_GT(hook.staged_candidates, 0); /* anti-vacuous: changed output was staged */
|
||
/* As above: an incremental run cancelled at the publish boundary preserves
|
||
* the committed database, and now reports that specifically. */
|
||
ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB);
|
||
ASSERT_TRUE(live_valid);
|
||
ASSERT_EQ(nodes_after, baseline_nodes);
|
||
ASSERT_EQ(helper_after, baseline_helper);
|
||
ASSERT_EQ(published_candidates, 0);
|
||
ASSERT_TRUE(staging_cleaned);
|
||
PASS();
|
||
}
|
||
|
||
TEST(backup_failed_publish_failure_preserves_final_sidecars) {
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
|
||
char final_path[512];
|
||
char wal_path[544];
|
||
char shm_path[544];
|
||
char journal_path[544];
|
||
snprintf(final_path, sizeof(final_path), "%s/blocked.db", g_incr_tmpdir);
|
||
snprintf(wal_path, sizeof(wal_path), "%s-wal", final_path);
|
||
snprintf(shm_path, sizeof(shm_path), "%s-shm", final_path);
|
||
snprintf(journal_path, sizeof(journal_path), "%s-journal", final_path);
|
||
|
||
/* Invalid SQLite makes backup fail. The live sidecars may contain the only
|
||
* recoverable state, so the rebuilt generation must be refused without
|
||
* changing any member of the old database family. */
|
||
ASSERT_EQ(th_write_file(final_path, "corrupt-main"), 0);
|
||
ASSERT_EQ(th_write_file(wal_path, "live-wal"), 0);
|
||
ASSERT_EQ(th_write_file(shm_path, "live-shm"), 0);
|
||
ASSERT_EQ(th_write_file(journal_path, "live-journal"), 0);
|
||
|
||
publish_observe_ctx_t hook = {0};
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, final_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &hook);
|
||
int rc = cbm_pipeline_run(p);
|
||
cbm_pipeline_free(p);
|
||
|
||
bool final_preserved = pipeline_fixture_file_equals(final_path, "corrupt-main");
|
||
bool wal_preserved = pipeline_fixture_file_equals(wal_path, "live-wal");
|
||
bool shm_preserved = pipeline_fixture_file_equals(shm_path, "live-shm");
|
||
bool journal_preserved = pipeline_fixture_file_equals(journal_path, "live-journal");
|
||
|
||
(void)cbm_unlink(final_path);
|
||
(void)cbm_unlink(wal_path);
|
||
(void)cbm_unlink(shm_path);
|
||
(void)cbm_unlink(journal_path);
|
||
cleanup_incremental_repo();
|
||
|
||
ASSERT_EQ(hook.calls, 1);
|
||
ASSERT_TRUE(hook.staging_was_valid);
|
||
ASSERT_TRUE(rc != 0);
|
||
ASSERT_TRUE(final_preserved);
|
||
ASSERT_TRUE(wal_preserved);
|
||
ASSERT_TRUE(shm_preserved);
|
||
ASSERT_TRUE(journal_preserved);
|
||
PASS();
|
||
}
|
||
|
||
TEST(backup_failed_rename_failure_preserves_corrupt_main) {
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
|
||
char final_path[512];
|
||
snprintf(final_path, sizeof(final_path), "%s/corrupt.db", g_incr_tmpdir);
|
||
ASSERT_EQ(th_write_file(final_path, "corrupt-main-before-rename"), 0);
|
||
|
||
publish_observe_ctx_t observe = {0};
|
||
publish_rename_fail_ctx_t rename_fail = {0};
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, final_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &observe);
|
||
cbm_pipeline_set_rename_hook_for_tests(p, fail_publish_rename, &rename_fail);
|
||
int rc = cbm_pipeline_run(p);
|
||
cbm_pipeline_free(p);
|
||
|
||
bool final_preserved = pipeline_fixture_file_equals(final_path, "corrupt-main-before-rename");
|
||
(void)cbm_unlink(final_path);
|
||
cleanup_incremental_repo();
|
||
|
||
ASSERT_EQ(observe.calls, 1);
|
||
ASSERT_TRUE(observe.staging_was_valid);
|
||
ASSERT_EQ(rename_fail.calls, 1);
|
||
ASSERT_TRUE(rc != 0);
|
||
ASSERT_TRUE(final_preserved);
|
||
PASS();
|
||
}
|
||
|
||
#ifdef __linux__
|
||
static void cleanup_long_db_fixture(char *deep_dir, const char *root, const char *db_path,
|
||
const char *staging_path) {
|
||
(void)cbm_unlink(db_path);
|
||
(void)cbm_remove_db_sidecars(db_path);
|
||
|
||
/* The unfixed full-dump path writes to the first 1023 bytes of the
|
||
* staging name. Remove that sibling so the RED test cleans up after
|
||
* itself as well as the fixed implementation. */
|
||
if (staging_path && strlen(staging_path) >= CBM_SZ_1K) {
|
||
char truncated[CBM_SZ_1K];
|
||
memcpy(truncated, staging_path, sizeof(truncated) - 1);
|
||
truncated[sizeof(truncated) - 1] = '\0';
|
||
(void)cbm_unlink(truncated);
|
||
(void)cbm_remove_db_sidecars(truncated);
|
||
}
|
||
|
||
size_t root_len = strlen(root);
|
||
while (strlen(deep_dir) > root_len) {
|
||
(void)cbm_rmdir(deep_dir);
|
||
char *slash = strrchr(deep_dir, '/');
|
||
if (!slash) {
|
||
break;
|
||
}
|
||
*slash = '\0';
|
||
}
|
||
(void)cbm_rmdir(root);
|
||
}
|
||
|
||
TEST(full_reindex_preserves_exact_long_db_path) {
|
||
if (setup_incremental_repo() != 0) {
|
||
FAIL("setup failed");
|
||
}
|
||
char *created_root = th_mktempdir("cbm_long_db");
|
||
ASSERT_NOT_NULL(created_root);
|
||
char root[256];
|
||
snprintf(root, sizeof(root), "%s", created_root);
|
||
|
||
char deep_dir[1600];
|
||
snprintf(deep_dir, sizeof(deep_dir), "%s", root);
|
||
static const char component[] =
|
||
"/segment_abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789";
|
||
while (strlen(deep_dir) < 1100) {
|
||
size_t used = strlen(deep_dir);
|
||
ASSERT_TRUE(used + sizeof(component) < sizeof(deep_dir));
|
||
memcpy(deep_dir + used, component, sizeof(component));
|
||
}
|
||
ASSERT_TRUE(cbm_mkdir_p(deep_dir, 0755));
|
||
|
||
char db_path[1600];
|
||
int db_n = snprintf(db_path, sizeof(db_path), "%s/graph.db", deep_dir);
|
||
ASSERT_TRUE(db_n > CBM_SZ_1K && (size_t)db_n < sizeof(db_path));
|
||
|
||
publish_observe_ctx_t hook = {0};
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
char *project = strdup(cbm_pipeline_project_name(p));
|
||
ASSERT_NOT_NULL(project);
|
||
cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &hook);
|
||
int rc = cbm_pipeline_run(p);
|
||
cbm_pipeline_free(p);
|
||
|
||
int node_count = -1;
|
||
cbm_store_t *published = rc == 0 ? cbm_store_open_path_existing(db_path) : NULL;
|
||
if (published) {
|
||
node_count = cbm_store_count_nodes(published, project);
|
||
cbm_store_close(published);
|
||
}
|
||
bool stray_truncated_db = false;
|
||
if (strlen(hook.staging_path) >= CBM_SZ_1K) {
|
||
char truncated[CBM_SZ_1K];
|
||
memcpy(truncated, hook.staging_path, sizeof(truncated) - 1);
|
||
truncated[sizeof(truncated) - 1] = '\0';
|
||
stray_truncated_db = access(truncated, F_OK) == 0;
|
||
}
|
||
|
||
free(project);
|
||
cleanup_long_db_fixture(deep_dir, root, db_path, hook.staging_path);
|
||
cleanup_incremental_repo();
|
||
|
||
ASSERT_EQ(hook.calls, 1);
|
||
ASSERT_TRUE(strlen(hook.staging_path) >= CBM_SZ_1K);
|
||
ASSERT_EQ(rc, 0);
|
||
ASSERT_GT(node_count, 0);
|
||
ASSERT_FALSE(stray_truncated_db);
|
||
PASS();
|
||
}
|
||
#endif
|
||
|
||
TEST(incremental_downgrade_preserves_scope_and_artifact_across_change_noop_delete) {
|
||
char tmpdir[256];
|
||
char artifact_tmpdir[256];
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_mode_scope_XXXXXX");
|
||
snprintf(artifact_tmpdir, sizeof(artifact_tmpdir), "/tmp/cbm_mode_artifact_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir));
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(artifact_tmpdir));
|
||
|
||
char dbpath[512];
|
||
char cancelled_import_path[512];
|
||
char retry_import_path[512];
|
||
char deleted_import_path[512];
|
||
snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir);
|
||
snprintf(cancelled_import_path, sizeof(cancelled_import_path), "%s/cancelled.db",
|
||
artifact_tmpdir);
|
||
snprintf(retry_import_path, sizeof(retry_import_path), "%s/retry.db", artifact_tmpdir);
|
||
snprintf(deleted_import_path, sizeof(deleted_import_path), "%s/deleted.db", artifact_tmpdir);
|
||
ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "main.go"), "package main\n\nfunc main() {}\n"), 0);
|
||
ASSERT_TRUE(cbm_mkdir_p(TH_PATH(tmpdir, "tools"), 0755));
|
||
ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "tools/util.go"),
|
||
"package tools\n\nfunc StoredBefore() string { return \"old\" }\n"),
|
||
0);
|
||
|
||
cbm_pipeline_t *pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(pipeline);
|
||
cbm_pipeline_set_persistence(pipeline, true);
|
||
ASSERT_EQ(cbm_pipeline_run(pipeline), 0);
|
||
char *project = strdup(cbm_pipeline_project_name(pipeline));
|
||
ASSERT_NOT_NULL(project);
|
||
cbm_pipeline_free(pipeline);
|
||
ASSERT_TRUE(cbm_artifact_exists(tmpdir));
|
||
|
||
ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "tools/util.go"),
|
||
"package tools\n\nfunc StoredAfter() string { return \"new\" }\n"),
|
||
0);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
cbm_pipeline_incremental_test_cancel_after_predump_once();
|
||
observed_fast_run_t cancelled = run_observed_fast_pipeline(tmpdir, dbpath);
|
||
int cancelled_live_before;
|
||
int cancelled_live_after;
|
||
observe_named_generation(dbpath, project, "StoredBefore", "StoredAfter", &cancelled_live_before,
|
||
&cancelled_live_after);
|
||
imported_generation_t cancelled_artifact =
|
||
import_artifact_generation(tmpdir, cancelled_import_path, project);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
observed_fast_run_t retry = run_observed_fast_pipeline(tmpdir, dbpath);
|
||
int retry_live_before;
|
||
int retry_live_after;
|
||
observe_named_generation(dbpath, project, "StoredBefore", "StoredAfter", &retry_live_before,
|
||
&retry_live_after);
|
||
bool retry_full_mode = stored_mode_is_full(dbpath, project);
|
||
imported_generation_t retry_artifact =
|
||
import_artifact_generation(tmpdir, retry_import_path, project);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
observed_fast_run_t noop = run_observed_fast_pipeline(tmpdir, dbpath);
|
||
|
||
ASSERT_EQ(cbm_unlink(TH_PATH(tmpdir, "tools/util.go")), 0);
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
observed_fast_run_t deleted = run_observed_fast_pipeline(tmpdir, dbpath);
|
||
int deleted_live_before;
|
||
int deleted_live_after;
|
||
observe_named_generation(dbpath, project, "StoredBefore", "StoredAfter", &deleted_live_before,
|
||
&deleted_live_after);
|
||
bool delete_full_mode = stored_mode_is_full(dbpath, project);
|
||
|
||
cbm_store_t *store = cbm_store_open_path(dbpath);
|
||
ASSERT_NOT_NULL(store);
|
||
cbm_file_hash_t deleted_hash = {0};
|
||
int deleted_hash_rc = cbm_store_get_file_hash(store, project, "tools/util.go", &deleted_hash);
|
||
cbm_store_clear_file_hash(&deleted_hash);
|
||
cbm_store_close(store);
|
||
imported_generation_t deleted_artifact =
|
||
import_artifact_generation(tmpdir, deleted_import_path, project);
|
||
|
||
cbm_pipeline_incremental_test_reset_faults();
|
||
free(project);
|
||
th_rmtree(artifact_tmpdir);
|
||
th_rmtree(tmpdir);
|
||
|
||
ASSERT_EQ(cancelled.rc, CBM_PIPELINE_ABORT_PRESERVE_DB);
|
||
ASSERT_EQ(cancelled.route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_TRUE(cancelled.tools_excluded);
|
||
ASSERT_EQ(cancelled_live_before, 1);
|
||
ASSERT_EQ(cancelled_live_after, 0);
|
||
ASSERT_EQ(cancelled.publish.rename_calls, 0);
|
||
ASSERT_EQ(cancelled.publish.export_count, 0);
|
||
ASSERT_EQ(cancelled_artifact.rc, 0);
|
||
ASSERT_EQ(cancelled_artifact.before_nodes, 1);
|
||
ASSERT_EQ(cancelled_artifact.after_nodes, 0);
|
||
|
||
ASSERT_EQ(retry.rc, 0);
|
||
ASSERT_EQ(retry.route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_TRUE(retry.tools_excluded);
|
||
ASSERT_TRUE(retry_full_mode);
|
||
ASSERT_EQ(retry_live_before, 0);
|
||
ASSERT_EQ(retry_live_after, 1);
|
||
ASSERT_EQ(retry.publish.rename_calls, 1);
|
||
ASSERT_EQ(retry.publish.exports_before_publish, 0);
|
||
ASSERT_EQ(retry.publish.export_count, 1);
|
||
ASSERT_EQ(retry_artifact.rc, 0);
|
||
ASSERT_EQ(retry_artifact.before_nodes, 0);
|
||
ASSERT_EQ(retry_artifact.after_nodes, 1);
|
||
|
||
ASSERT_EQ(noop.rc, 0);
|
||
ASSERT_EQ(noop.route, CBM_INCREMENTAL_ROUTE_NOOP);
|
||
ASSERT_TRUE(noop.tools_excluded);
|
||
ASSERT_EQ(noop.publish.rename_calls, 1);
|
||
ASSERT_EQ(noop.publish.exports_before_publish, 0);
|
||
ASSERT_EQ(noop.publish.export_count, 1);
|
||
|
||
ASSERT_EQ(deleted.rc, 0);
|
||
ASSERT_EQ(deleted.route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||
ASSERT_TRUE(deleted.tools_excluded);
|
||
ASSERT_EQ(deleted.publish.rename_calls, 1);
|
||
ASSERT_EQ(deleted.publish.exports_before_publish, 0);
|
||
ASSERT_EQ(deleted.publish.export_count, 1);
|
||
ASSERT_EQ(deleted_hash_rc, CBM_STORE_NOT_FOUND);
|
||
ASSERT_TRUE(delete_full_mode);
|
||
ASSERT_EQ(deleted_live_before, 0);
|
||
ASSERT_EQ(deleted_live_after, 0);
|
||
ASSERT_EQ(deleted_artifact.rc, 0);
|
||
ASSERT_EQ(deleted_artifact.before_nodes, 0);
|
||
ASSERT_EQ(deleted_artifact.after_nodes, 0);
|
||
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.
|
||
* Four workers keep this semantic regression deterministic under TSan while
|
||
* still exercising the parallel path. RED on the unfixed gate: cycles == file
|
||
* count (64) > workers+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();
|
||
char db_path[512];
|
||
snprintf(db_path, sizeof(db_path), "%s/backpressure.db", g_tmpdir);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
|
||
/* This test measures the shared futility latch, not allocator scalability.
|
||
* High-core TSan runs can turn unrelated slab bookkeeping into an 18-way
|
||
* lock convoy. Four workers preserve the parallel semantic while making its
|
||
* bound host-independent. cbm_pipeline_run joins its workers before return,
|
||
* so restoring the process environment after freeing p cannot race them. */
|
||
enum { TEST_WORKERS = 4 };
|
||
const char *old_workers = getenv("CBM_WORKERS");
|
||
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
|
||
if (old_workers && !saved_workers) {
|
||
cbm_mem_set_budget_for_tests(saved_budget);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
FAIL("failed to save CBM_WORKERS");
|
||
}
|
||
if (cbm_setenv("CBM_WORKERS", "4", 1) != 0) {
|
||
free(saved_workers);
|
||
cbm_mem_set_budget_for_tests(saved_budget);
|
||
cbm_pipeline_free(p);
|
||
teardown_test_repo();
|
||
FAIL("failed to set CBM_WORKERS");
|
||
}
|
||
|
||
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);
|
||
int restore_workers_rc =
|
||
saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS");
|
||
free(saved_workers);
|
||
teardown_test_repo();
|
||
|
||
ASSERT_EQ(restore_workers_rc, 0);
|
||
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 = TEST_WORKERS + 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();
|
||
}
|
||
|
||
/* The closure-repair route lives and dies by two properties of the persisted
|
||
* per-file LSP surface: a BODY edit must leave the surface_sha unchanged (the
|
||
* early cutoff -- no dependent recomputation owed), while a SIGNATURE edit
|
||
* must change it (dependents owe re-resolution). Each run here is a fresh
|
||
* full index (DB removed in between) so what is compared is the published
|
||
* surface of the code as it stands, not any incremental behaviour. */
|
||
TEST(pipeline_lsp_surface_persisted_and_body_edit_invariant) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_surface_persist_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
char src_a[512];
|
||
char db_path[512];
|
||
snprintf(src_a, sizeof(src_a), "%s/a.py", tmp);
|
||
snprintf(db_path, sizeof(db_path), "%s/surface.db", tmp);
|
||
ASSERT_EQ(th_write_file(src_a, "def surface_probe(x):\n return 1\n"), 0);
|
||
|
||
char sha_baseline[128] = {0};
|
||
char json_probe[64] = {0};
|
||
char project[256] = {0};
|
||
|
||
for (int round = 0; round < 3; round++) {
|
||
if (round == 1) {
|
||
/* Body edit: same def surface, different body. */
|
||
ASSERT_EQ(th_write_file(src_a, "def surface_probe(x):\n return 2 + x\n"), 0);
|
||
} else if (round == 2) {
|
||
/* Signature edit: the surface other files consume changes. */
|
||
ASSERT_EQ(th_write_file(src_a, "def surface_probe(x, y):\n return 1\n"), 0);
|
||
}
|
||
cbm_unlink(db_path);
|
||
cbm_remove_db_sidecars(db_path);
|
||
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
|
||
ASSERT_NOT_NULL(p);
|
||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||
if (round == 0) {
|
||
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p));
|
||
}
|
||
cbm_pipeline_free(p);
|
||
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(store);
|
||
cbm_lsp_surface_row_t *rows = NULL;
|
||
int row_count = 0;
|
||
ASSERT_EQ(cbm_store_get_lsp_surfaces(store, project, &rows, &row_count), CBM_STORE_OK);
|
||
cbm_store_close(store);
|
||
|
||
const cbm_lsp_surface_row_t *row_a = NULL;
|
||
for (int i = 0; i < row_count; i++) {
|
||
if (strcmp(rows[i].rel_path, "a.py") == 0) {
|
||
row_a = &rows[i];
|
||
}
|
||
}
|
||
ASSERT_NOT_NULL(row_a);
|
||
ASSERT_TRUE(row_a->surface_sha && row_a->surface_sha[0]);
|
||
if (round == 0) {
|
||
snprintf(sha_baseline, sizeof(sha_baseline), "%s", row_a->surface_sha);
|
||
/* The row is the versioned codec envelope with the def present. */
|
||
snprintf(json_probe, sizeof(json_probe), "%.20s", row_a->defs_json);
|
||
ASSERT_TRUE(strstr(row_a->defs_json, "\"v\":1") != NULL);
|
||
ASSERT_TRUE(strstr(row_a->defs_json, "surface_probe") != NULL);
|
||
} else if (round == 1) {
|
||
ASSERT_STR_EQ(row_a->surface_sha, sha_baseline);
|
||
} else {
|
||
ASSERT_TRUE(strcmp(row_a->surface_sha, sha_baseline) != 0);
|
||
}
|
||
cbm_store_free_lsp_surfaces(rows, row_count);
|
||
}
|
||
(void)json_probe;
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
SUITE(pipeline) {
|
||
RUN_TEST(pipeline_lsp_surface_persisted_and_body_edit_invariant);
|
||
/* 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_nix_scoped_binding_calls_resolve);
|
||
RUN_TEST(pipeline_incremental_preserves_cross_file_calls);
|
||
RUN_TEST(pipeline_objectscript_export_preserves_calls_sequential_parallel);
|
||
RUN_TEST(pipeline_objectscript_export_incremental_matches_full_relationships);
|
||
RUN_TEST(pipeline_objectscript_export_aggregate_exceeds_arena_block_table);
|
||
RUN_TEST(pipeline_env_access_configures_sequential_parallel_parity);
|
||
RUN_TEST(pipeline_call_reference_sequential_parallel_edge_set_parity);
|
||
RUN_TEST(pipeline_incremental_cross_file_call_reference_matches_fresh_full);
|
||
RUN_TEST(pipeline_incremental_changed_target_invalidates_stale_inbound_call_reference);
|
||
RUN_TEST(pipeline_incremental_parallel_registry_nodes_advance_shared_ids);
|
||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||
RUN_TEST(pipeline_incremental_parallel_result_cache_alloc_failure_preserves_db_and_retries);
|
||
#endif
|
||
RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge);
|
||
RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges);
|
||
RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier);
|
||
RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier);
|
||
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_swift_cross_package_import);
|
||
RUN_TEST(pipeline_python_cross_module_call);
|
||
RUN_TEST(pipeline_cross_language_same_name_does_not_share_calls_issue725);
|
||
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);
|
||
/* SwiftPM Package.swift manifest resolution (issue #551 item 1) */
|
||
RUN_TEST(pkgmap_swift_targets_registers_module);
|
||
RUN_TEST(pkgmap_swift_products_do_not_register_alias);
|
||
RUN_TEST(pkgmap_swift_target_name_immediately_before_close_paren);
|
||
RUN_TEST(pkgmap_swift_target_honors_literal_path);
|
||
RUN_TEST(pkgmap_swift_target_computed_path_fails_closed);
|
||
RUN_TEST(pkgmap_swift_target_in_comment_or_string_not_registered);
|
||
RUN_TEST(pkgmap_swift_dependencies_do_not_leak_entries);
|
||
RUN_TEST(pkgmap_swift_target_name_dependency_does_not_leak_entry);
|
||
RUN_TEST(pkgmap_swift_ambiguous_target_name_fails_closed);
|
||
RUN_TEST(pkgmap_swift_scan_repo_finds_nested_manifest);
|
||
/* 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(full_reindex_recovers_when_previous_coverage_is_unreadable);
|
||
RUN_TEST(incremental_detects_deleted_file);
|
||
RUN_TEST(incremental_new_file_added);
|
||
RUN_TEST(cancelled_full_reindex_preserves_committed_db);
|
||
RUN_TEST(cancelled_incremental_reindex_preserves_committed_db);
|
||
RUN_TEST(backup_failed_publish_failure_preserves_final_sidecars);
|
||
RUN_TEST(backup_failed_rename_failure_preserves_corrupt_main);
|
||
#ifdef __linux__
|
||
RUN_TEST(full_reindex_preserves_exact_long_db_path);
|
||
#endif
|
||
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);
|
||
}
|
||
|
||
/* Focused semantic-manifest and publication contracts. Kept separate from the
|
||
* broad pipeline suite so RED/GREEN iterations exercise only this boundary;
|
||
* the default all-suite run still executes it. */
|
||
SUITE(pipeline_semantic_manifest_repro) {
|
||
RUN_TEST(incremental_downgrade_preserves_scope_and_artifact_across_change_noop_delete);
|
||
RUN_TEST(pipeline_incremental_repoints_call_reference_without_stale_edge);
|
||
RUN_TEST(pipeline_sql_lineage_and_relation_isolation);
|
||
RUN_TEST(pipeline_incremental_sql_table_rename_drops_stale_lineage);
|
||
RUN_TEST(pipeline_dbt_jinja_lineage);
|
||
RUN_TEST(pipeline_parallel_manifest_is_byte_stable_above_threshold);
|
||
RUN_TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full);
|
||
RUN_TEST(pipeline_closure_repair_removed_def_drops_dependent_edge);
|
||
RUN_TEST(pipeline_closure_repair_added_name_declines_to_full);
|
||
RUN_TEST(pipeline_closure_repair_new_file_declines_to_full);
|
||
RUN_TEST(pipeline_closure_repair_budget_declines_to_full);
|
||
RUN_TEST(pipeline_incremental_tsconfig_alias_change_matches_fresh_full);
|
||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||
RUN_TEST(pipeline_git_context_change_forces_full_and_refreshes_branch);
|
||
RUN_TEST(pipeline_global_extension_config_change_forces_full);
|
||
RUN_TEST(pipeline_publication_never_uses_a_predictable_staging_path);
|
||
RUN_TEST(pipeline_source_mutation_before_publication_preserves_previous_generation);
|
||
RUN_TEST(pipeline_source_addition_before_publication_preserves_previous_generation);
|
||
RUN_TEST(pipeline_tsconfig_mutation_before_publication_preserves_previous_generation);
|
||
RUN_TEST(pipeline_exact_inputs_migrate_coverage_metadata_and_index_mode);
|
||
RUN_TEST(pipeline_existing_artifact_refreshes_after_default_forced_full_reindex);
|
||
RUN_TEST(pipeline_full_cancel_after_predump_preserves_previous_generation);
|
||
RUN_TEST(pipeline_full_cancel_after_destination_prepare_preserves_previous_generation);
|
||
RUN_TEST(pipeline_full_persist_failure_after_stage_dump_preserves_previous_generation);
|
||
RUN_TEST(pipeline_incremental_persist_failure_preserves_previous_generation_and_retries);
|
||
RUN_TEST(pipeline_incremental_successful_publication_preserves_adr);
|
||
RUN_TEST(pipeline_full_adr_capture_failure_preserves_previous_generation);
|
||
RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root);
|
||
RUN_TEST(pipeline_full_reindex_quarantines_corrupt_destination_without_overwrite);
|
||
RUN_TEST(pipeline_full_reindex_replaces_legacy_schema_without_quarantine);
|
||
#endif
|
||
}
|