From 42a932020092cb014f5fa3338f0ccfdf1e9a329a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 02:44:56 +0200 Subject: [PATCH] graph: one identity per conditional definition, SPAWNS edges, no cross-language guesses The test-impact engine selects tests by walking the graph, so it can only be as right as the graph. Three gaps made it wrong: One identity for a definition's mutually exclusive variants - A definition written once per #if/#else branch, per #[cfg], per platform file (foo_linux.c / foo_win.c, platform directories) or per Go build tag is ONE node. The graph buffer keeps every variant's span in a `variants` node property (definition labels only), so a change in any variant maps to the definition. - Extraction now sees definitions inside conditional blocks where it skipped them: C++/C#/ObjC class members under #if (one cursor walk, conditional block types per language in lang_specs), Elixir defs under if/unless, Fortran .F90/.F95/.F03/.F08, Rust #[cfg] twins (no more `name#cfg(...)` QNs that lost every call), C platform files and directories (non-static file-scope functions). - Haskell: the vendored scanner reads only an #if group's first branch. The grammar stays upstream; every further branch is parsed as its own same-length view (cpp_branch_views.c) and adds what the raw parse missed. haskell/bytestring: no new partial parses, +27 functions, IMPORTS 322 -> 329, 131 definitions with variant spans. - Incremental closure repair re-parses every file of such a definition when one of them changes (variant partners through DEFINES), so a repair equals a full index. No cross-language name guesses - A name-only resolution (unique_name, suffix_match, qualified_suffix, field_type_hint, and same_module across files that only share a path stem) never binds a symbol another language defines. - Bash imports are the files a script sources: every top-level command was read as an import of its name (`set -e` imported "set", bound to a TSX method in 137 scripts here). - This repository: cross-language CALLS 5,916 -> 5,091; the remainder are C/C++ pairs, builtins and same-stem module collisions (recorded). SPAWNS edges (per language, like the HTTP client patterns) - A call that starts a program (subprocess.run, exec.Command, child_process.spawn, posix_spawn, system, Process.Start, Command::new, ... about 30 languages, through the file's own imports) gets a SPAWNS edge to a Process node named after the program (literal first word, or ) instead of a CALLS guess. No target resolution. Both resolvers ask at the same point (parity test). The engine follows the graph - Seeds map changed lines through every variant span; a test written once per #if branch is one test; when a change reaches a non-test `main`, the tests that spawn processes are reached too (the process runs main, and main reaches everything). Signed-off-by: Martin Vogel --- Makefile.cbm | 7 +- README.md | 3 +- internal/cbm/cbm.c | 100 ++++ internal/cbm/cpp_branch_views.c | 183 +++++++ internal/cbm/cpp_branch_views.h | 34 ++ internal/cbm/extract_defs.c | 191 ++++--- internal/cbm/extract_imports.c | 16 +- internal/cbm/extract_unified.c | 10 +- internal/cbm/helpers.c | 140 ++++++ internal/cbm/helpers.h | 11 + internal/cbm/lang_specs.c | 22 + internal/cbm/lang_specs.h | 7 + internal/cbm/spawn_patterns.c | 744 ++++++++++++++++++++++++++++ internal/cbm/spawn_patterns.h | 71 +++ src/cli/cli.c | 2 +- src/discover/language.c | 7 +- src/graph_buffer/graph_buffer.c | 191 ++++++- src/mcp/test_impact_engine_reach.c | 93 +++- src/mcp/test_impact_seed.c | 65 ++- src/mcp/test_impact_seed.h | 4 + src/pipeline/pass_calls.c | 25 + src/pipeline/pass_definitions.c | 6 +- src/pipeline/pass_parallel.c | 37 +- src/pipeline/pass_spawns.c | 115 +++++ src/pipeline/pipeline.h | 35 +- src/pipeline/pipeline_incremental.c | 140 +++++- src/pipeline/pipeline_internal.h | 24 + src/pipeline/registry.c | 233 ++++++++- src/store/store.c | 151 ++++-- src/store/store.h | 18 + tests/test_conditional_variants.c | 635 ++++++++++++++++++++++++ tests/test_extraction.c | 36 +- tests/test_lang_contract.c | 25 +- tests/test_main.c | 44 +- tests/test_pipeline.c | 228 +++++++++ tests/test_registry.c | 110 ++++ tests/test_spawns.c | 376 ++++++++++++++ tests/test_test_impact_engine.c | 120 ++++- tests/test_test_impact_seed.c | 85 ++++ 39 files changed, 4130 insertions(+), 214 deletions(-) create mode 100644 internal/cbm/cpp_branch_views.c create mode 100644 internal/cbm/cpp_branch_views.h create mode 100644 internal/cbm/spawn_patterns.c create mode 100644 internal/cbm/spawn_patterns.h create mode 100644 src/pipeline/pass_spawns.c create mode 100644 tests/test_conditional_variants.c create mode 100644 tests/test_spawns.c diff --git a/Makefile.cbm b/Makefile.cbm index e249e1686..59febbbfc 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -375,12 +375,14 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/extract_dbt.c \ $(CBM_DIR)/sql_values.c \ $(CBM_DIR)/helpers.c \ + $(CBM_DIR)/cpp_branch_views.c \ $(CBM_DIR)/result_compact.c \ $(CBM_DIR)/result_spill.c \ $(CBM_DIR)/lang_specs.c \ $(CBM_DIR)/macro_table.c \ $(CBM_DIR)/iris_export_xml.c \ - $(CBM_DIR)/service_patterns.c + $(CBM_DIR)/service_patterns.c \ + $(CBM_DIR)/spawn_patterns.c # LSP resolvers (compiled as one unit via lsp_all.c) LSP_SRCS = $(CBM_DIR)/lsp_all.c @@ -481,6 +483,7 @@ PIPELINE_SRCS = \ src/pipeline/pass_parallel.c \ src/pipeline/pass_definitions.c \ src/pipeline/pass_calls.c \ + src/pipeline/pass_spawns.c \ src/pipeline/pass_lsp_cross.c \ src/pipeline/pass_usages.c \ src/pipeline/pass_semantic.c \ @@ -790,7 +793,7 @@ TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c TEST_RUST_LSP_SRCS = tests/test_rust_lsp.c -TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/test_lang_contract.c tests/test_edge_imports.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_node_creation_probe.c tests/test_edge_types_probe.c tests/test_convergence_probe.c tests/test_matrix_known_classes.c tests/test_matrix_new_constructs.c tests/test_grammar_probe_a.c tests/test_grammar_probe_b.c tests/test_grammar_probe_c.c tests/test_grammar_probe_d.c tests/test_grammar_probe_e.c tests/test_grammar_probe_f.c tests/test_grammar_probe_g.c +TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/test_lang_contract.c tests/test_edge_imports.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_node_creation_probe.c tests/test_edge_types_probe.c tests/test_convergence_probe.c tests/test_matrix_known_classes.c tests/test_matrix_new_constructs.c tests/test_grammar_probe_a.c tests/test_grammar_probe_b.c tests/test_grammar_probe_c.c tests/test_grammar_probe_d.c tests/test_grammar_probe_e.c tests/test_grammar_probe_f.c tests/test_grammar_probe_g.c tests/test_conditional_variants.c tests/test_spawns.c TEST_TRACES_SRCS = tests/test_traces.c diff --git a/README.md b/README.md index f644775a7..da4d824e0 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ The install script placed beside the binary is **reported, not deleted** — uni - `USAGE` — an identifier is used, but a unique callable target is not proven (including ambiguous or complex expressions) - `IMPORTS`, `DEFINES`, `IMPLEMENTS`, `INHERITS` - `HTTP_CALLS`, `ASYNC_CALLS` (cross-service) +- `SPAWNS` — the caller starts another program (`subprocess.run`, `exec.Command`, `posix_spawn`, `Process.Start`, ...); the target is a `Process` node named after the program, or `` when the program is not a literal - `EMITS`, `LISTENS_ON` (channels) - `DATA_FLOWS` with arg-to-param mapping + field access chains - `SIMILAR_TO` (MinHash + LSH near-clone detection, Jaccard scored) @@ -740,7 +741,7 @@ mode. `index_status` keeps describing the published graph and its freshness. ### Edge Types -`CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `HTTP_CALLS`, `ASYNC_CALLS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `REFERENCES_FILE`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH` +`CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `HTTP_CALLS`, `ASYNC_CALLS`, `SPAWNS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `REFERENCES_FILE`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH` ### Qualified Names diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 458bf6955..52393419b 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -22,6 +22,7 @@ #include "lsp/rust_lsp.h" #include "preprocessor.h" #include "sql_values.h" // #1735: literal INSERT rows kept out of the SQL parse +#include "cpp_branch_views.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" // cbm_fopen — crash-supervisor per-file marker write #include "foundation/hash_table.h" // CBMHashTable — crash-supervisor quarantine set @@ -2146,6 +2147,102 @@ static bool cbm_sql_values_exclusion_on(const char *rel_path) { return true; } +/* Drop the entries a branch view re-extracted from code outside the + * conditionals: a definition with a QN the file already has at the same line, + * an import with the same name and path. */ +static void cpp_view_drop_seen(CBMFileResult *r, int defs_before, int imports_before) { + int w = defs_before; + for (int i = defs_before; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + bool seen = false; + for (int j = 0; j < w && !seen; j++) { + const CBMDefinition *e = &r->defs.items[j]; + seen = e->start_line == d->start_line && e->qualified_name && d->qualified_name && + strcmp(e->qualified_name, d->qualified_name) == 0; + } + if (!seen) { + r->defs.items[w++] = *d; + } + } + r->defs.count = w; + w = imports_before; + for (int i = imports_before; i < r->imports.count; i++) { + const CBMImport *m = &r->imports.items[i]; + bool seen = false; + for (int j = 0; j < w && !seen; j++) { + const CBMImport *e = &r->imports.items[j]; + seen = e->local_name && m->local_name && e->module_path && m->module_path && + strcmp(e->local_name, m->local_name) == 0 && + strcmp(e->module_path, m->module_path) == 0; + } + if (!seen) { + r->imports.items[w++] = *m; + } + } + r->imports.count = w; +} + +/* Haskell: the scanner parses the first branch of an #if group and swallows + * the rest up to #endif as one token, so what is written once per + * configuration (a definition, an import, a call in the other branch) was + * seen in its first variant only. Every further branch is parsed as its own + * view (cpp_branch_views.h: same bytes and lines, the other branches and the + * conditional lines blanked) and extracted into the same result, under the + * raw parse's budget. A definition at another line under a QN the file + * already has is that definition's variant; the graph keeps one identity with + * both spans. Calls and usages outside the conditionals come back as + * duplicates, which the pipeline dedups by caller and callee. The raw parse + * alone decides the file's parse status. */ +static void extract_cpp_branch_views(const CBMExtractCtx *raw, const TSLanguage *ts_lang, + int64_t timeout_micros) { + int views = cbm_cpp_branch_view_count(raw->arena, raw->source, raw->source_len); + for (int v = 1; v <= views; v++) { + char *view = cbm_cpp_branch_view(raw->arena, raw->source, raw->source_len, v); + TSParser *parser = view ? get_thread_parser(ts_lang, raw->language) : NULL; + if (!parser) { + return; + } + ts_parser_reset(parser); + CBMStringInput input = {view, (uint32_t)raw->source_len}; + TSInput ts_input = {&input, cbm_string_read, TSInputEncodingUTF8, NULL}; + TSParseOptions opts = {0}; + CBMParseBudget budget = {0}; // cppcheck-suppress unreadVariable + if (timeout_micros > 0) { + uint64_t budget_ns = (uint64_t)timeout_micros * USEC_TO_NSEC; + budget.cpu_deadline_ns = cbm_thread_cpu_time_ns() + budget_ns; + budget.wall_ceiling_ns = now_ns() + budget_ns * CBM_PARSE_WALL_CEILING_FACTOR; + opts.payload = &budget; + opts.progress_callback = cbm_timeout_cb; + } + TSTree *tree = ts_parser_parse_with_options(parser, NULL, ts_input, opts); + if (!tree) { + return; + } + CBMExtractCtx vctx = { + .arena = raw->arena, + .scratch = raw->scratch, + .result = raw->result, + .source = view, + .source_len = raw->source_len, + .language = raw->language, + .project = raw->project, + .rel_path = raw->rel_path, + .module_qn = raw->module_qn, + .root = ts_tree_root_node(tree), + .macro_table = raw->macro_table, + .return_type_table = raw->return_type_table, + .walk_budget_nodes = cbm_walk_max_nodes(), + }; + int defs_before = raw->result->defs.count; + int imports_before = raw->result->imports.count; + cbm_extract_definitions(&vctx); + cbm_extract_imports(&vctx); + cbm_extract_unified(&vctx); + cpp_view_drop_seen(raw->result, defs_before, imports_before); + ts_tree_delete(tree); + } +} + static CBMFileResult *extract_file_ex_body(const char *source, int source_len, CBMLanguage language, const char *project, const char *rel_path, int64_t timeout_micros, const char **extra_defines, @@ -2353,6 +2450,9 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C cbm_test_declarations_finish(&ctx); cbm_extract_imports(&ctx); cbm_extract_unified(&ctx); + if (cbm_lang_needs_cpp_branch_views(language)) { + extract_cpp_branch_views(&ctx, ts_lang, timeout_micros); + } result->tree_nodes = ts_node_descendant_count(root); result->walk_nodes_visited = ctx.walk_nodes_visited; if (ctx.walk_budget_exhausted) { diff --git a/internal/cbm/cpp_branch_views.c b/internal/cbm/cpp_branch_views.c new file mode 100644 index 000000000..fd776bf23 --- /dev/null +++ b/internal/cbm/cpp_branch_views.c @@ -0,0 +1,183 @@ +/* + * cpp_branch_views.c — one parse view per #if/#else branch (see the header). + */ +#include "cpp_branch_views.h" + +#include + +enum { CPP_LINE_CODE, CPP_LINE_START, CPP_LINE_ELSE, CPP_LINE_END }; + +enum { CPP_NEST_MAX = 64 }; + +bool cbm_lang_needs_cpp_branch_views(CBMLanguage lang) { + return lang == CBM_LANG_HASKELL; +} + +static bool cpp_word_char(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; +} + +// Classify the line at src[pos..end): a conditional directive is a '#' in +// column 0, optional blanks, then the directive word — the same lines the +// Haskell scanner treats as conditionals. +static int cpp_line_kind(const char *src, int pos, int end) { + if (pos >= end || src[pos] != '#') { + return CPP_LINE_CODE; + } + int p = pos + 1; + while (p < end && (src[p] == ' ' || src[p] == '\t')) { + p++; + } + int w = p; + while (w < end && cpp_word_char(src[w])) { + w++; + } + static const struct { + const char *word; + int kind; + } words[] = { + {"if", CPP_LINE_START}, {"ifdef", CPP_LINE_START}, {"ifndef", CPP_LINE_START}, + {"else", CPP_LINE_ELSE}, {"elif", CPP_LINE_ELSE}, {"elifdef", CPP_LINE_ELSE}, + {"elifndef", CPP_LINE_ELSE}, {"endif", CPP_LINE_END}, + }; + size_t n = (size_t)(w - p); + for (size_t i = 0; i < sizeof(words) / sizeof(words[0]); i++) { + if (strlen(words[i].word) == n && memcmp(src + p, words[i].word, n) == 0) { + return words[i].kind; + } + } + return CPP_LINE_CODE; +} + +static int cpp_line_end(const char *src, int pos, int len) { + const char *nl = memchr(src + pos, '\n', (size_t)(len - pos)); + return nl ? (int)(nl - src) : len; +} + +typedef struct { + int group; // groups are numbered in order of their #if line + int branch; // branch the current line is in + bool outer; // the enclosing context is kept +} cpp_frame_t; + +typedef struct { + cpp_frame_t frames[CPP_NEST_MAX]; + int depth; + int overflow; // groups nested past CPP_NEST_MAX are not tracked + int next; +} cpp_nest_t; + +// Track one line. Returns the innermost tracked group when the line is one of +// its #else/#elif lines, else NULL. +static cpp_frame_t *cpp_nest_step(cpp_nest_t *n, int kind, bool active) { + switch (kind) { + case CPP_LINE_START: + if (n->depth < CPP_NEST_MAX && n->overflow == 0) { + n->frames[n->depth++] = (cpp_frame_t){n->next, 0, active}; + } else { + n->overflow++; + } + n->next++; + return NULL; + case CPP_LINE_ELSE: + if (n->overflow > 0 || n->depth == 0) { + return NULL; + } + n->frames[n->depth - 1].branch++; + return &n->frames[n->depth - 1]; + case CPP_LINE_END: + if (n->overflow > 0) { + n->overflow--; + } else if (n->depth > 0) { + n->depth--; + } + return NULL; + default: + return NULL; + } +} + +// Branch count per group. Returns the group count, or -1 on allocation failure. +static int cpp_group_branches(CBMArena *a, const char *src, int len, int **out) { + int groups = 0; + for (int pos = 0; pos < len;) { + int end = cpp_line_end(src, pos, len); + groups += cpp_line_kind(src, pos, end) == CPP_LINE_START; + pos = end + 1; + } + *out = NULL; + if (groups == 0) { + return 0; + } + int *branches = cbm_arena_alloc(a, sizeof(int) * (size_t)groups); + if (!branches) { + return -1; + } + for (int g = 0; g < groups; g++) { + branches[g] = 1; + } + cpp_nest_t nest = {.depth = 0}; + for (int pos = 0; pos < len;) { + int end = cpp_line_end(src, pos, len); + cpp_frame_t *frame = cpp_nest_step(&nest, cpp_line_kind(src, pos, end), true); + if (frame) { + branches[frame->group] = frame->branch + 1; + } + pos = end + 1; + } + *out = branches; + return groups; +} + +int cbm_cpp_branch_view_count(CBMArena *a, const char *src, int len) { + int *branches = NULL; + int groups = cpp_group_branches(a, src, len, &branches); + int most = 1; + for (int g = 0; g < groups; g++) { + if (branches[g] > most) { + most = branches[g]; + } + } + int views = most - 1; + return views > CBM_CPP_BRANCH_VIEWS_MAX ? CBM_CPP_BRANCH_VIEWS_MAX : views; +} + +// The current line belongs to the view: every enclosing group sits in the +// branch the view keeps. +static bool cpp_view_keeps(const cpp_nest_t *n, const int *branches, int view) { + if (n->depth == 0) { + return true; + } + const cpp_frame_t *top = &n->frames[n->depth - 1]; + int nb = branches[top->group]; + int kept = 0; + if (nb >= 2) { + kept = view < nb - 1 ? view : nb - 1; + } + return top->outer && top->branch == kept; +} + +char *cbm_cpp_branch_view(CBMArena *a, const char *src, int len, int view) { + int *branches = NULL; + int groups = cpp_group_branches(a, src, len, &branches); + char *buf = groups >= 0 ? cbm_arena_alloc(a, (size_t)len + 1) : NULL; + if (!buf) { + return NULL; + } + memcpy(buf, src, (size_t)len); + buf[len] = '\0'; + cpp_nest_t nest = {.depth = 0}; + for (int pos = 0; pos < len;) { + int end = cpp_line_end(src, pos, len); + bool kept = cpp_view_keeps(&nest, branches, view); + int kind = cpp_line_kind(src, pos, end); + cpp_nest_step(&nest, kind, kept); + if (kind != CPP_LINE_CODE || !kept) { + for (int i = pos; i < end; i++) { + buf[i] = buf[i] == '\r' ? '\r' : ' '; + } + } + pos = end + 1; + } + return buf; +} diff --git a/internal/cbm/cpp_branch_views.h b/internal/cbm/cpp_branch_views.h new file mode 100644 index 000000000..3ef114c1e --- /dev/null +++ b/internal/cbm/cpp_branch_views.h @@ -0,0 +1,34 @@ +#ifndef CBM_CPP_BRANCH_VIEWS_H +#define CBM_CPP_BRANCH_VIEWS_H + +#include "cbm.h" + +/* + * Branch views of a source whose grammar parses only the first branch of an + * #if/#else group (the Haskell scanner swallows everything from #else to + * #endif as one token, so a definition written once per configuration is seen + * in its first variant only). + * + * A view keeps the source byte for byte and line for line: the branch it + * selects in every group stays, the other branches and all conditional lines + * (#if/#ifdef/#ifndef/#else/#elif.../#endif) become spaces, newlines stay. So + * every span extracted from a view is a raw-source span. + * + * View v (1-based) selects branch min(v, branches - 1) of a group with at + * least two branches, and the only branch of a group without one. + */ + +#define CBM_CPP_BRANCH_VIEWS_MAX 3 + +// True when `lang`'s grammar parses only the first branch of a conditional. +bool cbm_lang_needs_cpp_branch_views(CBMLanguage lang); + +// Number of views the source needs (0 when no group has a second branch), +// at most CBM_CPP_BRANCH_VIEWS_MAX. +int cbm_cpp_branch_view_count(CBMArena *a, const char *src, int len); + +// View `view` (1..count) as an arena buffer of exactly `len` bytes plus a NUL, +// or NULL on allocation failure. +char *cbm_cpp_branch_view(CBMArena *a, const char *src, int len, int view); + +#endif diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index d1e45b13a..55cd6f7af 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3561,12 +3561,6 @@ static const char **extract_decorators(CBMArena *a, TSNode node, const char *sou return result; } -/* Rust: two same-named functions guarded by mutually-exclusive #[cfg(...)] - * attributes both parse as distinct function_item nodes and otherwise receive - * the SAME qualified_name, so the second graph upsert silently overwrites the - * first and one branch is lost (#495). Fold the cfg predicate into the QN so - * each cfg-gated twin gets a DISTINCT, predicate-encoding QN. Returns the - * (possibly suffixed) QN; the original QN when no cfg attribute is present. */ /* Rust: mark a function as a test when it carries a test attribute (#855). * cbm's test detection is otherwise file-path-based (cbm_is_test_file: * *_test.rs / test_*), so inline #[test]/#[tokio::test] functions inside a @@ -3596,32 +3590,6 @@ static bool rust_def_is_test(const char *const *decorators) { return false; } -static const char *rust_cfg_qualified_name(CBMArena *a, const char *base_qn, - const char *const *decorators) { - if (!decorators) { - return base_qn; - } - for (int i = 0; decorators[i]; i++) { - const char *cfg = strstr(decorators[i], "cfg("); - if (!cfg) { - continue; - } - /* Build a compact predicate suffix from the cfg(...) text, dropping - * whitespace and quotes so the QN stays readable and stable. */ - char buf[CBM_SZ_256]; - size_t bi = 0; - for (const char *p = cfg; *p && bi + 1 < sizeof(buf); p++) { - if (*p == ' ' || *p == '\t' || *p == '"' || *p == '\'') { - continue; - } - buf[bi++] = *p; - } - buf[bi] = '\0'; - return cbm_arena_sprintf(a, "%s#%s", base_qn, buf); - } - return base_qn; -} - // Extract base class name text from a single base_class child node. static char *extract_cpp_base_text(CBMArena *a, TSNode bc, const char *source) { const char *bk = ts_node_type(bc); @@ -6261,6 +6229,14 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * proj.myapp.db.Func, not proj.myapp.db.conn.Func). Other langs unchanged. */ def.qualified_name = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, qn_name, ctx->language); + /* C-family platform variants (foo_win.c / foo_posix.c, src/unix/ vs + * src/win/): an external-linkage file-scope function takes its + * platform-neutral QN, so its variants are one definition. The call-scope + * twin in extract_unified.c applies the same rule. */ + if (!ctx->enclosing_class_qn) { + def.qualified_name = cbm_platform_variant_qn(a, ctx->language, ctx->project, ctx->rel_path, + qn_name, def.qualified_name, node); + } /* A free function declared inside a namespace (C++/C#/PHP) is qualified by * the namespace scope the def walk carries (enclosing_class_qn was extended * by is_namespace_scope_kind), so `ns::serialize` is `proj.file.ns.serialize` @@ -6385,10 +6361,13 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec def.decorators = extract_decorators(a, node, ctx->source, ctx->language, spec); extract_route_from_decorators(a, node, ctx->source, spec, &def.route_path, &def.route_method); - // Rust: disambiguate cfg-gated twin functions by folding the #[cfg(...)] - // predicate into the QN so both branches survive the graph upsert (#495). + // Rust: cfg-gated twins (#[cfg(windows)] fn f / #[cfg(not(windows))] fn f) + // keep the plain QN: they are variants of ONE definition, and the graph + // keeps every twin's span and the union of their bodies' calls on one node + // (graph_buffer.c, "Definition variants"). #495's lost branch is a variant + // now; the old per-twin `f#cfg(...)` QNs left the bodies' calls without a + // source node (calls are scoped to the plain QN). if (ctx->language == CBM_LANG_RUST) { - def.qualified_name = rust_cfg_qualified_name(a, def.qualified_name, def.decorators); def.is_test = rust_def_is_test(def.decorators); } @@ -7820,20 +7799,94 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, } // Extract methods from an ObjC implementation_definition node. -static void extract_objc_impl_methods(CBMExtractCtx *ctx, TSNode impl_node, const char *class_qn, - const CBMLangSpec *spec) { - uint32_t nc = ts_node_child_count(impl_node); - for (uint32_t j = 0; j < nc; j++) { - TSNode inner = ts_node_child(impl_node, j); - if (ts_node_is_null(inner)) { +/* Members of a class body, flattened through conditional blocks: a method, + * field or variable inside #if/#ifdef/#else/#elif (cbm_conditional_block_types: + * C, C++, CUDA, Objective-C, C#, ...) is still a member of its class, and its + * #else twin is a variant of the same member (graph_buffer.c, "Definition + * variants"). One tree cursor: linear in the body's width, no recursion. */ +enum { MEMBER_CONDITIONAL_DEPTH_MAX = 16 }; + +typedef struct { + TSTreeCursor cursor; + const char **conditional; + int depth; /* conditional blocks entered below the body */ + bool started; + bool named; +} member_iter_t; + +static void member_iter_init(member_iter_t *it, TSNode body, CBMLanguage lang, bool named) { + it->cursor = ts_tree_cursor_new(body); + it->conditional = cbm_conditional_block_types(lang); + it->depth = 0; + it->started = false; + it->named = named; +} + +static void member_iter_done(member_iter_t *it) { + ts_tree_cursor_delete(&it->cursor); +} + +static bool member_iter_advance(member_iter_t *it) { + if (!it->started) { + it->started = true; + return ts_tree_cursor_goto_first_child(&it->cursor); + } + while (!ts_tree_cursor_goto_next_sibling(&it->cursor)) { + if (it->depth == 0) { + return false; + } + ts_tree_cursor_goto_parent(&it->cursor); + it->depth--; + } + return true; +} + +static bool member_iter_next(member_iter_t *it, TSNode *out) { + for (bool have = member_iter_advance(it); have; have = member_iter_advance(it)) { + TSNode node = ts_tree_cursor_current_node(&it->cursor); + while (it->conditional && it->depth < MEMBER_CONDITIONAL_DEPTH_MAX && + cbm_kind_in_set(node, it->conditional) && + ts_tree_cursor_goto_first_child(&it->cursor)) { + it->depth++; + node = ts_tree_cursor_current_node(&it->cursor); + } + if (it->conditional && cbm_kind_in_set(node, it->conditional)) { + continue; /* an empty (or too deeply nested) block holds no member */ + } + if (it->named && !ts_node_is_named(node)) { continue; } - if (cbm_kind_in_set(inner, spec->function_node_types)) { - TSNode nm = resolve_method_name(inner, ctx->language); - if (!ts_node_is_null(nm)) { - push_method_def(ctx, inner, impl_node, class_qn, spec, nm); + *out = node; + return true; + } + return false; +} + +/* Objective-C wraps every @implementation member in an implementation_definition, + * and a #if/#else block inside @implementation wraps each branch's members in + * another one: walk those nested wrappers too (worklist, no recursion). */ +static void extract_objc_impl_methods(CBMExtractCtx *ctx, TSNode impl_node, const char *class_qn, + const CBMLangSpec *spec) { + TSNode pending[MEMBER_CONDITIONAL_DEPTH_MAX]; + int count = 0; + pending[count++] = impl_node; + while (count > 0) { + TSNode cur = pending[--count]; + member_iter_t it; + member_iter_init(&it, cur, ctx->language, false); + TSNode inner; + while (member_iter_next(&it, &inner)) { + if (cbm_kind_in_set(inner, spec->function_node_types)) { + TSNode nm = resolve_method_name(inner, ctx->language); + if (!ts_node_is_null(nm)) { + push_method_def(ctx, inner, impl_node, class_qn, spec, nm); + } + } else if (count < MEMBER_CONDITIONAL_DEPTH_MAX && + strcmp(ts_node_type(inner), "implementation_definition") == 0) { + pending[count++] = inner; } } + member_iter_done(&it); } } @@ -7845,12 +7898,10 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c return; } - uint32_t count = ts_node_child_count(body); - for (uint32_t i = 0; i < count; i++) { - TSNode child = ts_node_child(body, i); - if (ts_node_is_null(child)) { - continue; - } + member_iter_t it; + member_iter_init(&it, body, ctx->language, false); + TSNode child; + while (member_iter_next(&it, &child)) { if (ctx->language == CBM_LANG_OBJC && strcmp(ts_node_type(child), "implementation_definition") == 0) { @@ -7939,6 +7990,7 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c push_method_def(ctx, method_node, class_node, class_qn, spec, name_node); } + member_iter_done(&it); } // --- Rust impl block extraction --- @@ -8153,6 +8205,22 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp if (strcmp(macro, "def") == 0 || strcmp(macro, "defp") == 0 || strcmp(macro, "defmacro") == 0) { extract_elixir_func_def(ctx, cur, macro); + } else if (strcmp(macro, "if") == 0 || strcmp(macro, "unless") == 0) { + /* A compile-time if/unless in a module body defines functions + * under a condition: its do and else branches hold variants of the + * same definitions (graph_buffer.c, "Definition variants"). The + * grammar nests the else_block inside the do_block. */ + TSNode branch = cbm_find_child_by_kind(cur, "do_block"); + for (int pass = 0; pass < 2 && !ts_node_is_null(branch); pass++) { + uint32_t bc = ts_node_child_count(branch); + for (int bi = (int)bc - SKIP_CHAR; bi >= 0; bi--) { + TSNode bchild = ts_node_child(branch, (uint32_t)bi); + if (!ts_node_is_null(bchild) && strcmp(ts_node_type(bchild), "call") == 0) { + ts_nstack_push(&stack, bchild); + } + } + branch = cbm_find_child_by_kind(branch, "else_block"); + } } else if (strcmp(macro, "defmodule") == 0) { TSNode do_block = emit_elixir_module_class(ctx, cur); if (!ts_node_is_null(do_block)) { @@ -9811,10 +9879,10 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch } CBMArena *a = ctx->arena; - uint32_t count = ts_node_named_child_count(body); - for (uint32_t i = 0; i < count; i++) { - TSNode child = ts_node_named_child(body, i); - + member_iter_t it; + member_iter_init(&it, body, ctx->language, true); + TSNode child; + while (member_iter_next(&it, &child)) { // ObjectScript UDL wraps each member in a class_statement node. if (ctx->language == CBM_LANG_OBJECTSCRIPT_UDL && strcmp(ts_node_type(child), "class_statement") == 0 && @@ -10128,6 +10196,7 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch cbm_defs_push(&ctx->result->defs, a, def); } + member_iter_done(&it); } // Extract class-level variables (field declarations inside class bodies) @@ -10146,13 +10215,15 @@ static void extract_class_variables(CBMExtractCtx *ctx, TSNode class_node, const * push_var_def_qn); saved/restored so module-level minting stays bare. */ const char *saved_parent = ctx->var_parent_class; ctx->var_parent_class = class_qn; - uint32_t count = ts_node_named_child_count(body); - for (uint32_t i = 0; i < count; i++) { - TSNode child = ts_node_named_child(body, i); + member_iter_t it; + member_iter_init(&it, body, ctx->language, true); + TSNode child; + while (member_iter_next(&it, &child)) { if (cbm_kind_in_set(child, spec->variable_node_types)) { extract_var_names(ctx, child, spec); } } + member_iter_done(&it); ctx->var_parent_class = saved_parent; } @@ -10312,8 +10383,10 @@ static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, wd_sta wd_push(s, child, enclosing_qn); } else { const char *ck = ts_node_type(child); + /* A nested class inside #if/#else is still nested here. */ if (strcmp(ck, "field_declaration") == 0 || - strcmp(ck, "template_declaration") == 0 || strcmp(ck, "declaration") == 0) { + strcmp(ck, "template_declaration") == 0 || strcmp(ck, "declaration") == 0 || + cbm_kind_in_set(child, cbm_conditional_block_types(spec->language))) { ts_nstack_push(&nc_stack, child); } } diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index dde0495b8..0875c8912 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -47,7 +47,7 @@ static void parse_lisp_imports(CBMExtractCtx *ctx); static void parse_starlark_imports(CBMExtractCtx *ctx); static void parse_tcl_imports(CBMExtractCtx *ctx); static void parse_teal_imports(CBMExtractCtx *ctx); -static void parse_zsh_imports(CBMExtractCtx *ctx); +static void parse_shell_source_imports(CBMExtractCtx *ctx); static void parse_css_imports(CBMExtractCtx *ctx); static void parse_html_imports(CBMExtractCtx *ctx); static void parse_cmake_imports(CBMExtractCtx *ctx); @@ -2187,10 +2187,13 @@ static void parse_teal_imports(CBMExtractCtx *ctx) { } } -// --- Zsh imports --- +// --- Bash / Zsh imports --- // source file / . file — `command` nodes whose command_name is "source" or ".". -// The argument field carries the sourced path. -static void parse_zsh_imports(CBMExtractCtx *ctx) { +// The argument field carries the sourced path. Every other command is a call, +// not an import: read as an import, `set -e` imported "set", which the import +// resolver then bound to whatever project symbol is named set (a TSX method), +// and every `set` call in the script followed it. +static void parse_shell_source_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; ts_nstack_init(&stack, ctx, CBM_SZ_512); @@ -3049,8 +3052,7 @@ void cbm_extract_imports(CBMExtractCtx *ctx) { parse_generic_imports(ctx, "call"); break; case CBM_LANG_BASH: - // source/. commands - parse_generic_imports(ctx, "command"); + parse_shell_source_imports(ctx); break; case CBM_LANG_ZIG: parse_zig_imports(ctx); @@ -3120,7 +3122,7 @@ void cbm_extract_imports(CBMExtractCtx *ctx) { parse_teal_imports(ctx); break; case CBM_LANG_ZSH: - parse_zsh_imports(ctx); + parse_shell_source_imports(ctx); break; case CBM_LANG_CMAKE: parse_cmake_imports(ctx); diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index f7f5e2d4f..4e83ca900 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1110,9 +1110,13 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, qn_name); } /* Java/Go: directory-based module so this enclosing-func QN matches the def - * QN and the LSP caller_qn (the lsp_resolve join keys on exact equality). */ - return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, qn_name, - ctx->language); + * QN and the LSP caller_qn (the lsp_resolve join keys on exact equality). + * C-family platform variants share their platform-neutral QN — the same + * rule extract_func_def applies, so in-body calls source to the def. */ + const char *qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, qn_name, + ctx->language); + return cbm_platform_variant_qn(ctx->arena, ctx->language, ctx->project, ctx->rel_path, qn_name, + qn, node); } // Compute class QN for scope tracking. diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 055a4cca9..5b2ae1320 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1678,6 +1678,146 @@ char *cbm_fqn_compute_source_lang(CBMArena *a, const char *project, const char * return cbm_arena_sprintf(a, "%s.%s", module, name); } +/* Operating systems and CPU architectures only: a token names a platform, so a + * path segment or stem suffix made of one marks a platform alternative. Vague + * words (generic, default, common, stub) are deliberately absent. */ +static const char *const platform_tokens[] = { + "win", "win32", "win64", "windows", "wince", "nt", "posix", "unix", + "linux", "darwin", "mac", "macos", "macosx", "osx", "apple", "ios", + "android", "bsd", "freebsd", "openbsd", "netbsd", "dragonfly", "solaris", "sunos", + "aix", "hpux", "haiku", "fuchsia", "cygwin", "mingw", "msvc", "wasm", + "wasi", "emscripten", "x86", "x86_64", "x64", "amd64", "i386", "i686", + "ia32", "arm", "arm64", "aarch64", "armv7", "riscv", "riscv64", "mips", + "mips64", "ppc", "ppc64", "powerpc", "s390", "s390x", "sparc", "sparc64", + "loongarch", "loongarch64", NULL}; + +static bool platform_token_eq(const char *s, size_t len) { + for (int i = 0; platform_tokens[i]; i++) { + const char *t = platform_tokens[i]; + size_t tl = strlen(t); + if (tl != len) { + continue; + } + size_t k = 0; + while (k < len && tolower((unsigned char)s[k]) == t[k]) { + k++; + } + if (k == len) { + return true; + } + } + return false; +} + +/* Length of `stem` without a trailing `_` / `-` / `.` + * platform suffix (the longest one), or `len` when it has none. */ +static size_t platform_stem_len(const char *stem, size_t len) { + size_t best = len; + for (size_t sep = 1; sep < len; sep++) { + char c = stem[sep - 1]; + if ((c == '_' || c == '-' || c == '.') && platform_token_eq(stem + sep, len - sep)) { + if (sep - 1 < best) { + best = sep - 1; + } + } + } + return best; +} + +static bool c_family_variant_lang(CBMLanguage lang) { + return lang == CBM_LANG_C || lang == CBM_LANG_CPP || lang == CBM_LANG_CUDA || + lang == CBM_LANG_OBJC; +} + +static bool func_is_static(TSNode func_node) { + uint32_t n = ts_node_child_count(func_node); + for (uint32_t i = 0; i < n; i++) { + TSNode child = ts_node_child(func_node, i); + if (strcmp(ts_node_type(child), "storage_class_specifier") == 0 && + ts_node_child_count(child) > 0 && + strcmp(ts_node_type(ts_node_child(child, 0)), "static") == 0) { + return true; + } + } + return false; +} + +const char *cbm_platform_variant_qn(CBMArena *a, CBMLanguage lang, const char *project, + const char *rel_path, const char *name, const char *qn, + TSNode func_node) { + if (!c_family_variant_lang(lang) || !rel_path || !name || !name[0] || !qn || + ts_node_is_null(func_node) || func_is_static(func_node)) { + return qn; + } + char *plain = cbm_fqn_compute(a, project, rel_path, name); + if (!plain || strcmp(plain, qn) != 0) { + return qn; /* namespaced or otherwise scoped: not a file-scope function */ + } + /* Rebuild the path without platform tokens: a directory segment that is + * one is dropped; a filename stem loses its platform suffix, and a stem + * that IS a token leaves the function at its directory. */ + size_t len = strlen(rel_path); + char *neutral = (char *)cbm_arena_alloc(a, len + SKIP_ONE); + if (!neutral) { + return qn; + } + size_t out = 0; + bool changed = false; + bool dir_level = false; + const char *seg = rel_path; + while (*seg) { + const char *slash = strchr(seg, '/'); + size_t seg_len = slash ? (size_t)(slash - seg) : strlen(seg); + if (slash) { + if (platform_token_eq(seg, seg_len)) { + changed = true; + } else { + memcpy(neutral + out, seg, seg_len); + out += seg_len; + neutral[out++] = '/'; + } + seg = slash + SKIP_ONE; + continue; + } + const char *dot = NULL; + for (const char *p = seg + seg_len; p > seg; p--) { + if (p[-1] == '.') { + dot = p - 1; + break; + } + } + size_t stem_len = dot && dot > seg ? (size_t)(dot - seg) : seg_len; + size_t keep = platform_token_eq(seg, stem_len) ? 0 : platform_stem_len(seg, stem_len); + if (keep != stem_len) { + changed = true; + } + if (keep == 0) { + dir_level = true; /* the function lives at the directory */ + if (out > 0 && neutral[out - SKIP_ONE] == '/') { + out--; + } + } else { + memcpy(neutral + out, seg, keep); + out += keep; + memcpy(neutral + out, seg + stem_len, seg_len - stem_len); + out += seg_len - stem_len; + } + break; + } + neutral[out] = '\0'; + if (!changed) { + return qn; + } + if (dir_level) { + /* No file stem left: the folder form keeps every directory segment + * (cbm_fqn_compute would strip a dotted segment as an extension). */ + char *folder = cbm_fqn_folder(a, project ? project : "", neutral); + return folder ? cbm_arena_sprintf(a, "%s.%s", folder, name) : qn; + } + char *variant = cbm_fqn_compute(a, project, neutral, name); + return variant ? variant : qn; +} + char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir) { // project.dir1.dir2 size_t proj_len = strlen(project); diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 00e3d30dc..a6fd923f6 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -250,6 +250,17 @@ char *cbm_fqn_module_source_lang(CBMArena *a, const char *project, const char *r char *cbm_fqn_compute_source_lang(CBMArena *a, const char *project, const char *rel_path, const char *name, CBMLanguage lang); +// C-family platform variants: the same external-linkage function defined in +// files that differ only by a platform token (foo_win.c / foo_posix.c, +// src/unix/fs.c / src/win/fs.c) is ONE function with platform variants — two +// external definitions of one name cannot link into one program, so they are +// alternatives by construction. Returns the platform-neutral QN (the path with +// its platform tokens removed) for a non-static file-scope function of +// C/C++/CUDA/ObjC whose `qn` is the plain file-stem form; otherwise `qn`. +const char *cbm_platform_variant_qn(CBMArena *a, CBMLanguage lang, const char *project, + const char *rel_path, const char *name, const char *qn, + TSNode func_node); + // Folder QN: project.dir_parts char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir); diff --git a/internal/cbm/lang_specs.c b/internal/cbm/lang_specs.c index cfd363c71..2c385cf33 100644 --- a/internal/cbm/lang_specs.c +++ b/internal/cbm/lang_specs.c @@ -2737,6 +2737,28 @@ static const CBMLangSpec lang_specs[CBM_LANG_COUNT] = { _Static_assert(sizeof(lang_specs) / sizeof(lang_specs[0]) == CBM_LANG_COUNT, "lang_specs array size must match CBM_LANG_COUNT"); +/* Conditional-compilation blocks per grammar (the `*_in_field_declaration_list` + * / `*_in_top_level` forms are aliased to these names by the grammars). */ +static const char *c_family_conditional_types[] = { + "preproc_if", "preproc_ifdef", "preproc_else", "preproc_elif", "preproc_elifdef", NULL}; + +const char **cbm_conditional_block_types(CBMLanguage lang) { + switch (lang) { + case CBM_LANG_C: + case CBM_LANG_CPP: + case CBM_LANG_CUDA: + case CBM_LANG_OBJC: + case CBM_LANG_CSHARP: + case CBM_LANG_GLSL: + case CBM_LANG_HLSL: + case CBM_LANG_ISPC: + case CBM_LANG_SLANG: + return c_family_conditional_types; + default: + return NULL; + } +} + const CBMLangSpec *cbm_lang_spec(CBMLanguage lang) { if (lang < 0 || lang >= CBM_LANG_COUNT) { return NULL; diff --git a/internal/cbm/lang_specs.h b/internal/cbm/lang_specs.h index ab823557b..722ff84e1 100644 --- a/internal/cbm/lang_specs.h +++ b/internal/cbm/lang_specs.h @@ -48,6 +48,13 @@ typedef struct { // language rows; the table lives in extract_calls.c next to the dispatch code. const char **cbm_string_dispatch_suffixes(CBMLanguage lang); +// Returns a NULL-terminated list of node types that wrap conditionally +// compiled alternatives (#if/#ifdef/#else/#elif blocks) for a language, or +// NULL when the grammar has none. A definition inside one is still a member +// of its enclosing scope; its #else twin is a variant of the same definition. +// Side table for the same -Wmissing-field-initializers reason as above. +const char **cbm_conditional_block_types(CBMLanguage lang); + // Get the language spec for a given language. Returns NULL for unsupported. const CBMLangSpec *cbm_lang_spec(CBMLanguage lang); diff --git a/internal/cbm/spawn_patterns.c b/internal/cbm/spawn_patterns.c new file mode 100644 index 000000000..dc9b53950 --- /dev/null +++ b/internal/cbm/spawn_patterns.c @@ -0,0 +1,744 @@ +/* + * spawn_patterns.c — process-spawning call sites, per language (see the header). + * + * Spellings come from what the extractor records as the callee (checked per + * language against real calls): Rust `Command::new`, Go `exec.Command`, Java + * `Runtime.getRuntime().exec`, a constructor as its class name. + */ +#include "spawn_patterns.h" +#include "foundation/constants.h" + +#include +#include + +#define Q CBM_SPAWN_QUALIFIED +#define U CBM_SPAWN_BARE +#define I CBM_SPAWN_BARE_IMPORTED +#define S CBM_SPAWN_STRING_RECEIVER + +/* libc / POSIX / Win32. posix_spawn and _spawn* take the path second; + * CreateProcess names the program first or only in its command line. */ +static const cbm_spawn_api_t c_family[] = { + {"system", NULL, U, 0, -1}, {"std::system", NULL, U, 0, -1}, + {"_wsystem", NULL, U, 0, -1}, {"popen", NULL, U, 0, -1}, + {"_popen", NULL, U, 0, -1}, {"_wpopen", NULL, U, 0, -1}, + {"execl", NULL, U, 0, -1}, {"execle", NULL, U, 0, -1}, + {"execlp", NULL, U, 0, -1}, {"execv", NULL, U, 0, -1}, + {"execve", NULL, U, 0, -1}, {"execvp", NULL, U, 0, -1}, + {"execvpe", NULL, U, 0, -1}, {"fexecve", NULL, U, 0, -1}, + {"posix_spawn", NULL, U, 1, -1}, {"posix_spawnp", NULL, U, 1, -1}, + {"_execl", NULL, U, 0, -1}, {"_execv", NULL, U, 0, -1}, + {"_execlp", NULL, U, 0, -1}, {"_execvp", NULL, U, 0, -1}, + {"_spawnl", NULL, U, 1, -1}, {"_spawnle", NULL, U, 1, -1}, + {"_spawnlp", NULL, U, 1, -1}, {"_spawnv", NULL, U, 1, -1}, + {"_spawnve", NULL, U, 1, -1}, {"_spawnvp", NULL, U, 1, -1}, + {"_wspawnv", NULL, U, 1, -1}, {"_wspawnvp", NULL, U, 1, -1}, + {"CreateProcess", NULL, U, 0, 1}, {"CreateProcessA", NULL, U, 0, 1}, + {"CreateProcessW", NULL, U, 0, 1}, {"ShellExecute", NULL, U, 2, -1}, + {"ShellExecuteA", NULL, U, 2, -1}, {"ShellExecuteW", NULL, U, 2, -1}, + {"ShellExecuteExA", NULL, U, 0, -1}, {"ShellExecuteExW", NULL, U, 0, -1}, + {"WinExec", NULL, U, 0, -1}, {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t python[] = { + {"subprocess.run", NULL, Q, 0, -1}, + {"subprocess.call", NULL, Q, 0, -1}, + {"subprocess.check_call", NULL, Q, 0, -1}, + {"subprocess.check_output", NULL, Q, 0, -1}, + {"subprocess.Popen", NULL, Q, 0, -1}, + {"subprocess.getoutput", NULL, Q, 0, -1}, + {"subprocess.getstatusoutput", NULL, Q, 0, -1}, + {"os.system", NULL, Q, 0, -1}, + {"os.popen", NULL, Q, 0, -1}, + {"os.startfile", NULL, Q, 0, -1}, + {"os.execl", NULL, Q, 0, -1}, + {"os.execle", NULL, Q, 0, -1}, + {"os.execlp", NULL, Q, 0, -1}, + {"os.execlpe", NULL, Q, 0, -1}, + {"os.execv", NULL, Q, 0, -1}, + {"os.execve", NULL, Q, 0, -1}, + {"os.execvp", NULL, Q, 0, -1}, + {"os.execvpe", NULL, Q, 0, -1}, + {"os.posix_spawn", NULL, Q, 0, -1}, + {"os.posix_spawnp", NULL, Q, 0, -1}, + {"os.spawnl", NULL, Q, 1, -1}, + {"os.spawnle", NULL, Q, 1, -1}, + {"os.spawnlp", NULL, Q, 1, -1}, + {"os.spawnlpe", NULL, Q, 1, -1}, + {"os.spawnv", NULL, Q, 1, -1}, + {"os.spawnve", NULL, Q, 1, -1}, + {"os.spawnvp", NULL, Q, 1, -1}, + {"os.spawnvpe", NULL, Q, 1, -1}, + {"asyncio.create_subprocess_exec", NULL, Q, 0, -1}, + {"asyncio.create_subprocess_shell", NULL, Q, 0, -1}, + {"pexpect.spawn", NULL, Q, 0, -1}, + {"pexpect.run", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +/* Node's child_process (also behind `node:`), the destructured-require form, + * execa / cross-spawn, and the Bun and Deno globals. */ +static const cbm_spawn_api_t js_family[] = { + {"child_process.spawn", NULL, Q, 0, -1}, + {"child_process.spawnSync", NULL, Q, 0, -1}, + {"child_process.exec", NULL, Q, 0, -1}, + {"child_process.execSync", NULL, Q, 0, -1}, + {"child_process.execFile", NULL, Q, 0, -1}, + {"child_process.execFileSync", NULL, Q, 0, -1}, + {"child_process.fork", NULL, Q, 0, -1}, + {"spawn", "child_process", I, 0, -1}, + {"spawnSync", "child_process", I, 0, -1}, + {"exec", "child_process", I, 0, -1}, + {"execSync", "child_process", I, 0, -1}, + {"execFile", "child_process", I, 0, -1}, + {"execFileSync", "child_process", I, 0, -1}, + {"fork", "child_process", I, 0, -1}, + {"execa.execa", NULL, Q, 0, -1}, + {"execa.execaSync", NULL, Q, 0, -1}, + {"execa.execaCommand", NULL, Q, 0, -1}, + {"execa.execaCommandSync", NULL, Q, 0, -1}, + {"cross-spawn.spawn", NULL, Q, 0, -1}, + {"cross-spawn.sync", NULL, Q, 0, -1}, + {"Bun.spawn", NULL, Q, 0, -1}, + {"Bun.spawnSync", NULL, Q, 0, -1}, + {"Deno.Command", NULL, Q, 0, -1}, + {"Deno.run", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t go[] = { + {"os/exec.Command", NULL, Q, 0, -1}, + {"os/exec.CommandContext", NULL, Q, 1, -1}, + {"golang.org/x/sys/execabs.Command", NULL, Q, 0, -1}, + {"golang.org/x/sys/execabs.CommandContext", NULL, Q, 1, -1}, + {"os.StartProcess", NULL, Q, 0, -1}, + {"syscall.Exec", NULL, Q, 0, -1}, + {"syscall.ForkExec", NULL, Q, 0, -1}, + {"syscall.StartProcess", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t rust[] = { + {"std::process::Command::new", NULL, Q, 0, -1}, + {"tokio::process::Command::new", NULL, Q, 0, -1}, + {"async_process::Command::new", NULL, Q, 0, -1}, + {"async_std::process::Command::new", NULL, Q, 0, -1}, + {"smol::process::Command::new", NULL, Q, 0, -1}, + {"duct::cmd", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +/* java.lang is implicit, so the class name alone is the spelling. */ +static const cbm_spawn_api_t jvm[] = { + {"ProcessBuilder", NULL, U, 0, -1}, + {"java.lang.ProcessBuilder", NULL, Q, 0, -1}, + {"Runtime.getRuntime().exec", NULL, U, 0, -1}, + {".execute", NULL, S, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t dotnet[] = { + {"Process.Start", NULL, U, 0, -1}, {"System.Diagnostics.Process.Start", NULL, Q, 0, -1}, + {"ProcessStartInfo", NULL, U, 0, -1}, {"System.Diagnostics.ProcessStartInfo", NULL, Q, 0, -1}, + {"Cli.Wrap", NULL, U, 0, -1}, {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t ruby[] = { + {"system", NULL, U, 0, -1}, {"exec", NULL, U, 0, -1}, + {"spawn", NULL, U, 0, -1}, {"Kernel.system", NULL, U, 0, -1}, + {"Kernel.exec", NULL, U, 0, -1}, {"Kernel.spawn", NULL, U, 0, -1}, + {"Process.spawn", NULL, U, 0, -1}, {"Process.exec", NULL, U, 0, -1}, + {"IO.popen", NULL, U, 0, -1}, {"PTY.spawn", NULL, U, 0, -1}, + {"Open3.popen2", NULL, U, 0, -1}, {"Open3.popen2e", NULL, U, 0, -1}, + {"Open3.popen3", NULL, U, 0, -1}, {"Open3.capture2", NULL, U, 0, -1}, + {"Open3.capture2e", NULL, U, 0, -1}, {"Open3.capture3", NULL, U, 0, -1}, + {"Open3.pipeline", NULL, U, 0, -1}, {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t php[] = { + {"exec", NULL, U, 0, -1}, + {"shell_exec", NULL, U, 0, -1}, + {"system", NULL, U, 0, -1}, + {"passthru", NULL, U, 0, -1}, + {"proc_open", NULL, U, 0, -1}, + {"popen", NULL, U, 0, -1}, + {"pcntl_exec", NULL, U, 0, -1}, + {"Symfony\\Component\\Process\\Process", NULL, Q, 0, -1}, + {"Symfony\\Component\\Process\\Process::fromShellCommandline", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t perl[] = { + {"system", NULL, U, 0, -1}, + {"exec", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t lua[] = { + {"os.execute", NULL, U, 0, -1}, + {"io.popen", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t r_lang[] = { + {"system", NULL, U, 0, -1}, + {"system2", NULL, U, 0, -1}, + {"shell", NULL, U, 0, -1}, + {"processx::run", NULL, Q, 0, -1}, + {"sys::exec_wait", NULL, Q, 0, -1}, + {"sys::exec_internal", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t elixir[] = { + {"System.cmd", NULL, U, 0, -1}, + {"System.shell", NULL, U, 0, -1}, + {"Port.open", NULL, U, 0, CBM_SPAWN_ANY_LITERAL}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t erlang[] = { + {"os:cmd", NULL, Q, 0, -1}, + {"open_port", NULL, U, 0, -1}, + {"erlang:open_port", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t haskell[] = { + {"callProcess", "System.Process", I, 0, -1}, + {"callCommand", "System.Process", I, 0, -1}, + {"readProcess", "System.Process", I, 0, -1}, + {"readProcessWithExitCode", "System.Process", I, 0, -1}, + {"readCreateProcess", "System.Process", I, 0, -1}, + {"readCreateProcessWithExitCode", "System.Process", I, 0, -1}, + {"createProcess", "System.Process", I, 0, -1}, + {"spawnProcess", "System.Process", I, 0, -1}, + {"spawnCommand", "System.Process", I, 0, -1}, + {"system", "System.Process", I, 0, -1}, + {"rawSystem", "System.Process", I, 0, -1}, + {"runProcess", "System.Process", I, 0, -1}, + {"runCommand", "System.Process", I, 0, -1}, + {"runInteractiveProcess", "System.Process", I, 0, -1}, + {"runInteractiveCommand", "System.Process", I, 0, -1}, + {"runProcess", "System.Process.Typed", I, 0, -1}, + {"readProcess", "System.Process.Typed", I, 0, -1}, + {"startProcess", "System.Process.Typed", I, 0, -1}, + {"system", "System.Cmd", I, 0, -1}, + {"rawSystem", "System.Cmd", I, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t dlang[] = { + {"execute", "std.process", I, 0, -1}, + {"executeShell", "std.process", I, 0, -1}, + {"spawnProcess", "std.process", I, 0, -1}, + {"spawnShell", "std.process", I, 0, -1}, + {"pipeProcess", "std.process", I, 0, -1}, + {"pipeShell", "std.process", I, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +/* Foundation's Process: the program is a property set after construction, + * so a bare construction is a spawn site with a dynamic program. */ +static const cbm_spawn_api_t swift[] = { + {"Process.launchedProcess", NULL, U, 0, -1}, + {"Process.run", NULL, U, 0, -1}, + {"Process", "Foundation", I, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t crystal[] = { + {"Process.run", NULL, U, 0, -1}, {"Process.new", NULL, U, 0, -1}, + {"Process.exec", NULL, U, 0, -1}, {"system", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t zig[] = { + {"std.process.Child.init", NULL, Q, 0, -1}, + {"std.process.Child.run", NULL, Q, 0, CBM_SPAWN_ANY_LITERAL}, + {"std.ChildProcess.init", NULL, Q, 0, -1}, + {"std.ChildProcess.run", NULL, Q, 0, CBM_SPAWN_ANY_LITERAL}, + {"std.ChildProcess.exec", NULL, Q, 0, CBM_SPAWN_ANY_LITERAL}, + {"std.process.execv", NULL, Q, 1, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t ocaml[] = { + {"Sys.command", NULL, U, 0, -1}, + {"Unix.system", NULL, U, 0, -1}, + {"Unix.create_process", NULL, U, 0, -1}, + {"Unix.create_process_env", NULL, U, 0, -1}, + {"Unix.open_process", NULL, U, 0, -1}, + {"Unix.open_process_in", NULL, U, 0, -1}, + {"Unix.open_process_out", NULL, U, 0, -1}, + {"Unix.open_process_full", NULL, U, 0, -1}, + {"Unix.open_process_args", NULL, U, 0, -1}, + {"Unix.execv", NULL, U, 0, -1}, + {"Unix.execvp", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t clojure[] = { + {"clojure.java.shell/sh", NULL, Q, 0, -1}, + {"sh", "clojure.java.shell", I, 0, -1}, + {"babashka.process/process", NULL, Q, 0, -1}, + {"babashka.process/shell", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t tcl[] = { + {"exec", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t fortran[] = { + {"execute_command_line", NULL, U, 0, -1}, + {"system", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t julia[] = { + {"run", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t powershell[] = { + {"Start-Process", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +#undef Q +#undef U +#undef I +#undef S + +static const cbm_spawn_api_t *spawn_table(CBMLanguage lang) { + switch (lang) { + case CBM_LANG_C: + case CBM_LANG_CPP: + case CBM_LANG_CUDA: + case CBM_LANG_OBJC: + return c_family; + case CBM_LANG_PYTHON: + return python; + case CBM_LANG_JAVASCRIPT: + case CBM_LANG_TYPESCRIPT: + case CBM_LANG_TSX: + case CBM_LANG_ARKTS: + return js_family; + case CBM_LANG_GO: + return go; + case CBM_LANG_RUST: + return rust; + case CBM_LANG_JAVA: + case CBM_LANG_KOTLIN: + case CBM_LANG_SCALA: + case CBM_LANG_GROOVY: + return jvm; + case CBM_LANG_CSHARP: + case CBM_LANG_FSHARP: + return dotnet; + case CBM_LANG_RUBY: + return ruby; + case CBM_LANG_PHP: + return php; + case CBM_LANG_PERL: + return perl; + case CBM_LANG_LUA: + case CBM_LANG_LUAU: + case CBM_LANG_TEAL: + return lua; + case CBM_LANG_R: + return r_lang; + case CBM_LANG_ELIXIR: + return elixir; + case CBM_LANG_ERLANG: + return erlang; + case CBM_LANG_HASKELL: + return haskell; + case CBM_LANG_DLANG: + return dlang; + case CBM_LANG_SWIFT: + return swift; + case CBM_LANG_CRYSTAL: + return crystal; + case CBM_LANG_ZIG: + return zig; + case CBM_LANG_OCAML: + return ocaml; + case CBM_LANG_CLOJURE: + return clojure; + case CBM_LANG_TCL: + return tcl; + case CBM_LANG_FORTRAN: + return fortran; + case CBM_LANG_JULIA: + return julia; + case CBM_LANG_POWERSHELL: + return powershell; + default: + return NULL; + } +} + +/* An import value without Node's `node:` scheme. */ +static const char spawn_node_scheme[] = "node:"; +static const char *spawn_module(const char *val) { + size_t n = sizeof(spawn_node_scheme) - 1; + return strncmp(val, spawn_node_scheme, n) == 0 ? val + n : val; +} + +static const char *const spawn_separators[] = {"::", ".", ":", "/", "\\", NULL}; + +/* Length of the callee's head (before its first separator) and the + * separator's offset, or 0 when the callee has none. */ +static size_t spawn_head_len(const char *callee) { + size_t best = 0; + for (int i = 0; spawn_separators[i]; i++) { + const char *at = strstr(callee, spawn_separators[i]); + if (at && at > callee && (best == 0 || (size_t)(at - callee) < best)) { + best = (size_t)(at - callee); + } + } + return best; +} + +/* True when `val` names `name` as its last segment ("subprocess.run" for + * run, "java.lang.ProcessBuilder" for ProcessBuilder). */ +static bool spawn_val_names(const char *val, const char *name) { + size_t vl = strlen(val); + size_t nl = strlen(name); + if (vl <= nl || strcmp(val + vl - nl, name) != 0) { + return false; + } + char sep = val[vl - nl - 1]; + return sep == '.' || sep == ':' || sep == '/' || sep == '\\'; +} + +/* The callee as its import makes it: `sp.run` -> subprocess.run (an alias), + * `run` -> subprocess.run (a member import), `spawn` -> child_process.spawn + * (a named import mapped to its module). Empty when no import applies. */ +static void spawn_expand(const char *callee, const CBMImport *imports, int count, char *out, + size_t n) { + out[0] = '\0'; + size_t head = spawn_head_len(callee); + size_t key_len = head ? head : strlen(callee); + for (int i = 0; i < count; i++) { + const char *key = imports[i].local_name; + if (!key || !imports[i].module_path || strlen(key) != key_len || + strncmp(key, callee, key_len) != 0) { + continue; + } + const char *val = spawn_module(imports[i].module_path); + if (head) { + snprintf(out, n, "%s%s", val, callee + head); + } else if (spawn_val_names(val, callee)) { + snprintf(out, n, "%s", val); + } else { + snprintf(out, n, "%s.%s", val, callee); + } + return; + } +} + +static bool spawn_imports_module(const CBMImport *imports, int count, const char *module) { + size_t ml = strlen(module); + for (int i = 0; i < count; i++) { + if (!imports[i].module_path) { + continue; + } + const char *val = spawn_module(imports[i].module_path); + if (strncmp(val, module, ml) == 0 && + (val[ml] == '\0' || val[ml] == '.' || val[ml] == ':' || val[ml] == '/')) { + return true; + } + } + return false; +} + +/* Groovy `"git status".execute`: a quoted receiver, then the api suffix. */ +static bool spawn_string_receiver(const char *callee, const char *suffix) { + size_t cl = strlen(callee); + size_t sl = strlen(suffix); + return (callee[0] == '"' || callee[0] == '\'') && cl > sl + 1 && + strcmp(callee + cl - sl, suffix) == 0 && callee[cl - sl - 1] == callee[0]; +} + +static bool spawn_entry_matches(const cbm_spawn_api_t *e, const char *callee, const char *expanded, + const CBMImport *imports, int count) { + switch (e->rule) { + case CBM_SPAWN_QUALIFIED: + return strcmp(callee, e->api) == 0 || (expanded[0] && strcmp(expanded, e->api) == 0); + case CBM_SPAWN_BARE: + return strcmp(callee, e->api) == 0; + case CBM_SPAWN_BARE_IMPORTED: + return strcmp(callee, e->api) == 0 && spawn_imports_module(imports, count, e->module); + case CBM_SPAWN_STRING_RECEIVER: + return spawn_string_receiver(callee, e->api); + default: + return false; + } +} + +const cbm_spawn_api_t *cbm_spawn_match(CBMLanguage lang, const char *callee, + const CBMImport *imports, int count) { + const cbm_spawn_api_t *table = spawn_table(lang); + if (!table || !callee || !callee[0]) { + return NULL; + } + if (!imports) { + count = 0; + } + char expanded[CBM_SZ_512]; + spawn_expand(callee, imports, count, expanded, sizeof(expanded)); + for (const cbm_spawn_api_t *e = table; e->api; e++) { + if (spawn_entry_matches(e, callee, expanded, imports, count)) { + return e; + } + } + return NULL; +} + +/* The first quoted literal in `text` ("..", '..' or `..`), copied without + * its quotes. False when there is none. */ +static bool spawn_first_literal(const char *text, char *out, size_t n) { + for (const char *p = text; *p; p++) { + if (*p != '"' && *p != '\'' && *p != '`') { + continue; + } + const char *end = p + 1; + while (*end && *end != *p) { + end += (*end == '\\' && end[1]) ? 2 : 1; + } + if (!*end) { + return false; + } + size_t len = (size_t)(end - p - 1); + if (len >= n) { + len = n - 1; + } + memcpy(out, p + 1, len); + out[len] = '\0'; + return true; + } + return false; +} + +static const char *spawn_arg_expr(const CBMCall *call, int index) { + for (int i = 0; i < call->arg_count; i++) { + if (call->args[i].index == index && call->args[i].expr) { + return call->args[i].expr; + } + } + return NULL; +} + +static bool spawn_word_char(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; +} + +static const char *spawn_skip_space(const char *p) { + while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') { + p++; + } + return p; +} + +/* Past a keyword label in front of an argument (`launchPath: `, `args=`). */ +static const char *spawn_skip_label(const char *p) { + const char *q = p; + while (spawn_word_char(*q)) { + q++; + } + if (q == p) { + return p; + } + q = spawn_skip_space(q); + if ((*q == ':' && q[1] != ':') || (*q == '=' && q[1] != '=')) { + return spawn_skip_space(q + 1); + } + return p; +} + +/* A string literal at the start of `p`, after a prefix such as r, b, f, L + * or @ (r"..", L"..", @".."). */ +static bool spawn_leading_literal(const char *p, char *out, size_t n) { + for (int i = 0; i < 2 && *p && strchr("rRbBuUfFL@$", *p); i++) { + p++; + } + return (*p == '"' || *p == '\'' || *p == '`') && spawn_first_literal(p, out, n); +} + +/* What may stand before a collection's bracket: nothing, Zig's `&.` / `.`, + * Java's `new String[]`, or a collection constructor. A name before `[` is a + * subscript (`record["argv"]`) and any other call's arguments are not the + * command (`os.path.join("bin", tool)`). */ +static bool spawn_collection_prefix(const char *p, size_t len) { + static const char *const ctors[] = { + "listOf", "arrayOf", "mutableListOf", "List.of", "Arrays.asList", "vec!", "c", "Cmd", NULL}; + if (len == 0 || (len <= 2 && strspn(p, "&.") == len) || + (len > 2 && strncmp(p + len - 2, "[]", 2) == 0 && strncmp(p, "new ", 4) == 0)) { + return true; + } + for (int i = 0; ctors[i]; i++) { + if (strlen(ctors[i]) == len && strncmp(p, ctors[i], len) == 0) { + return true; + } + } + return false; +} + +/* The first element of a collection written at `p` ([..], (..), {..}, + * listOf(..), &.{..}), copied into `out`. False when `p` is no collection. */ +static bool spawn_first_element(const char *p, char *out, size_t n) { + const char *open = p; + while (*open && (spawn_word_char(*open) || strchr("!.&:<> ", *open) || + (open[0] == '[' && open[1] == ']'))) { + open += (open[0] == '[') ? 2 : 1; + } + if ((*open != '[' && *open != '(' && *open != '{') || + !spawn_collection_prefix(p, (size_t)(open - p))) { + return false; + } + p = open; + const char *start = spawn_skip_space(p + 1); + int depth = 0; + const char *end = start; + for (; *end; end++) { + if (*end == '"' || *end == '\'' || *end == '`') { + const char *close = strchr(end + 1, *end); + if (!close) { + break; + } + end = close; + } else if (strchr("[({", *end)) { + depth++; + } else if (strchr("])}", *end)) { + if (depth-- == 0) { + break; + } + } else if (*end == ',' && depth == 0) { + break; + } + } + size_t len = (size_t)(end - start); + if (len == 0 || len >= n) { + return false; + } + memcpy(out, start, len); + out[len] = '\0'; + return true; +} + +/* The program literal an argument spells: a leading literal, or the first + * element of a collection when that element is one. A program held in a + * variable (`[binary, "--version"]`) is no literal: the arguments after it + * are not the program. */ +static bool spawn_expr_literal(const char *expr, char *out, size_t n) { + char element[CBM_SZ_256]; + for (int depth = 0; depth < 3; depth++) { + expr = spawn_skip_label(spawn_skip_space(expr)); + if (spawn_leading_literal(expr, out, n)) { + return true; + } + if (!spawn_first_element(expr, element, sizeof(element))) { + return false; + } + expr = element; + } + return false; +} + +/* A literal from the argument at `index`. Tcl words are literals unquoted; + * elsewhere an unquoted argument counts only when the extractor itself read + * it as the call's string (Perl's `system("make")`). */ +static bool spawn_arg_literal(const CBMCall *call, CBMLanguage lang, int index, bool anywhere, + char *out, size_t n) { + const char *expr = spawn_arg_expr(call, index); + if (!expr) { + if (call->arg_count == 0 && index == 0 && call->first_string_arg) { + snprintf(out, n, "%s", call->first_string_arg); + return true; + } + return false; + } + if (anywhere ? spawn_first_literal(expr, out, n) : spawn_expr_literal(expr, out, n)) { + return true; + } + bool bareword = lang == CBM_LANG_TCL && strpbrk(expr, "$[") == NULL; + if (bareword || (call->first_string_arg && strcmp(expr, call->first_string_arg) == 0)) { + snprintf(out, n, "%s", expr); + return true; + } + return false; +} + +/* A shell word that is not the program: an environment assignment + * (`LC_ALL=C make`) or the `env` that runs the program after them + * (`/usr/bin/env make`). */ +static bool spawn_prefix_word(const char *word, size_t len) { + static const char env[] = "env"; + size_t env_len = sizeof(env) - 1; + if (len >= env_len && strncmp(word + len - env_len, env, env_len) == 0 && + (len == env_len || word[len - env_len - 1] == '/')) { + return true; + } + size_t i = 0; + while (i < len && spawn_word_char(word[i])) { + i++; + } + return i > 0 && i < len && word[i] == '='; +} + +/* The program word of a command line: its first word that is not an + * environment prefix, without directories. Anything built at run time is not + * a program name. */ +static void spawn_program_word(const char *literal, char *buf, size_t n) { + size_t len = 0; + for (;;) { + while (*literal == ' ' || *literal == '\t') { + literal++; + } + len = strcspn(literal, " \t\r\n"); + if (!spawn_prefix_word(literal, len) || !literal[len]) { + break; + } + literal += len; + } + const char *start = literal; + for (size_t i = 0; i < len; i++) { + if (literal[i] == '/' || literal[i] == '\\') { + start = literal + i + 1; + } + } + len -= (size_t)(start - literal); + bool dynamic = len == 0 || len >= CBM_SPAWN_PROGRAM_MAX || len >= n; + for (size_t i = 0; i < len && !dynamic; i++) { + dynamic = strchr("$%{}`()<>|;&*?[]\"'", start[i]) != NULL; + } + if (dynamic) { + snprintf(buf, n, "%s", CBM_SPAWN_DYNAMIC); + return; + } + memcpy(buf, start, len); + buf[len] = '\0'; +} + +void cbm_spawn_program(const cbm_spawn_api_t *api, CBMLanguage lang, const CBMCall *call, char *buf, + size_t n) { + char literal[CBM_SZ_256]; + bool found = false; + if (api->rule == CBM_SPAWN_STRING_RECEIVER) { + found = spawn_first_literal(call->callee_name, literal, sizeof(literal)); + } else { + bool anywhere = api->alt == CBM_SPAWN_ANY_LITERAL; + found = spawn_arg_literal(call, lang, api->arg, anywhere, literal, sizeof(literal)) || + (api->alt >= 0 && + spawn_arg_literal(call, lang, api->alt, false, literal, sizeof(literal))); + } + if (!found) { + snprintf(buf, n, "%s", CBM_SPAWN_DYNAMIC); + return; + } + spawn_program_word(literal, buf, n); +} diff --git a/internal/cbm/spawn_patterns.h b/internal/cbm/spawn_patterns.h new file mode 100644 index 000000000..bd7119bdc --- /dev/null +++ b/internal/cbm/spawn_patterns.h @@ -0,0 +1,71 @@ +/* + * spawn_patterns.h — process-spawning call sites, per language. + * + * Like the HTTP client tables in service_patterns.h: a call that starts + * another program (subprocess.run, exec.Command, posix_spawn, Process.Start, + * Command::new, ...) becomes a SPAWNS edge to a Process node named after the + * program it starts. Nothing resolves the program to code: the edge records + * that the caller leaves the graph through a process boundary, and which + * program it starts when the call spells it as a literal. + */ +#ifndef CBM_SPAWN_PATTERNS_H +#define CBM_SPAWN_PATTERNS_H + +#include "cbm.h" + +#include +#include + +/* How a table spelling matches a call. */ +typedef enum { + /* The full spelling, written out or reached through the file's imports + * (`sp.run` with `import subprocess as sp`, `run` from + * `from subprocess import run`, Go `exec.Command` from "os/exec"). */ + CBM_SPAWN_QUALIFIED = 0, + /* A bare name (`system`, `popen`, `exec`): the spelling alone. A + * definition of that name in the calling module (or one the call reaches + * through an import) still makes it an ordinary call. */ + CBM_SPAWN_BARE, + /* A bare name that counts when the file imports `module` + * (Haskell `callProcess` with System.Process, a destructured + * `const { spawn } = require("child_process")`). */ + CBM_SPAWN_BARE_IMPORTED, + /* Groovy `"git status".execute()`: the receiver is the command line. */ + CBM_SPAWN_STRING_RECEIVER, +} cbm_spawn_rule_t; + +typedef struct { + const char *api; /* canonical spelling: "subprocess.run", "os/exec.Command", "system" */ + const char *module; /* the import a CBM_SPAWN_BARE_IMPORTED name needs, else NULL */ + unsigned char rule; /* cbm_spawn_rule_t */ + signed char arg; /* argument holding the program or command line */ + signed char alt; /* second place to look, -1 for none (CreateProcess: name or line), + * or CBM_SPAWN_ANY_LITERAL */ +} cbm_spawn_api_t; + +/* `alt` value: the program is the first literal anywhere in the argument, + * which is an options tuple or struct (Elixir {:spawn, "cat"}, Zig + * .{ .argv = &.{"ls"} }). Elsewhere only a leading literal or a collection's + * first element is the program. */ +#define CBM_SPAWN_ANY_LITERAL (-2) + +/* The spawn API a call names, or NULL. `imports` are the calling file's own + * import statements (local name -> module path) as extracted, external + * modules included: subprocess or os/exec never resolve to project nodes, so + * the resolver's import map does not hold them. Whether the call is a spawn + * after all is the resolver's to decide: an evidence-backed resolution onto + * project code (an import, the same module, the LSP; never a name guess) + * makes it an ordinary call (src/pipeline/pass_spawns.c). */ +const cbm_spawn_api_t *cbm_spawn_match(CBMLanguage lang, const char *callee, + const CBMImport *imports, int count); + +/* The program a matched spawn starts: the first literal in its program + * argument, that literal's first word, without directories + * ("/usr/bin/git status" -> "git"). CBM_SPAWN_DYNAMIC when the program is + * not a literal. Writes into buf (at least CBM_SPAWN_PROGRAM_MAX bytes). */ +#define CBM_SPAWN_DYNAMIC "" +#define CBM_SPAWN_PROGRAM_MAX 64 +void cbm_spawn_program(const cbm_spawn_api_t *api, CBMLanguage lang, const CBMCall *call, char *buf, + size_t n); + +#endif diff --git a/src/cli/cli.c b/src/cli/cli.c index df51e723b..93df2dabe 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1564,7 +1564,7 @@ static const char skill_content[] = "`check_index_coverage`, `manage_adr`, `ingest_traces`\n" "\n" "## Edge Types\n" - "CALLS, HTTP_CALLS, ASYNC_CALLS, DATA_FLOWS, IMPORTS, DEFINES, DEFINES_METHOD,\n" + "CALLS, HTTP_CALLS, ASYNC_CALLS, SPAWNS, DATA_FLOWS, IMPORTS, DEFINES, DEFINES_METHOD,\n" "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, CALL_REFERENCE, CONFIGURES, REFERENCES_FILE,\n" "FILE_CHANGES_WITH, SIMILAR_TO, SEMANTICALLY_RELATED, CONTAINS_FILE, CONTAINS_FOLDER,\n" "CONTAINS_PACKAGE\n" diff --git a/src/discover/language.c b/src/discover/language.c index 342655e23..116cec024 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -122,11 +122,16 @@ static const ext_entry_t EXT_TABLE[] = { {".frm", CBM_LANG_FORM}, {".prc", CBM_LANG_FORM}, - /* Fortran */ + /* Fortran. Upper-case suffixes are the C-preprocessed spellings of the + * same free-form sources (#ifdef blocks); lookup is case-sensitive. */ {".f03", CBM_LANG_FORTRAN}, {".f08", CBM_LANG_FORTRAN}, {".f90", CBM_LANG_FORTRAN}, {".f95", CBM_LANG_FORTRAN}, + {".F03", CBM_LANG_FORTRAN}, + {".F08", CBM_LANG_FORTRAN}, + {".F90", CBM_LANG_FORTRAN}, + {".F95", CBM_LANG_FORTRAN}, /* GLSL */ {".frag", CBM_LANG_GLSL}, diff --git a/src/graph_buffer/graph_buffer.c b/src/graph_buffer/graph_buffer.c index e55eb28f0..51af15e8a 100644 --- a/src/graph_buffer/graph_buffer.c +++ b/src/graph_buffer/graph_buffer.c @@ -835,6 +835,162 @@ void cbm_gbuf_set_next_id(cbm_gbuf_t *gb, int64_t next_id) { gb->next_id = next_id; } +/* ── Definition variants ───────────────────────────────────────────── + * One name defined more than once under mutually exclusive conditions — + * C-family #if/#else, Rust #[cfg], build-tagged files, a definition under an + * `if` — is ONE identity: one QN, one node, whose edges are the union of + * every variant's body. The collision rule below keeps one canonical winner + * for name, file, lines and properties; every variant's span survives in the + * node's `variants` property ([{"file_path","start_line","end_line"}], + * sorted, unique), so a change in any variant maps to the node. Recorded only + * between definitions of the same label with real spans: a different kind + * sharing the QN (C: struct vs function) is no variant, a span on line 0 is + * no location (delta-route proxies), and a full tie is the same entity. */ +static void *gb_yy_malloc(void *ctx, size_t size) { + (void)ctx; + return cbm_alloc(CBM_MEM_CLASS_GBUF_STRING, size); +} + +static void *gb_yy_realloc(void *ctx, void *block, size_t old_size, size_t size) { + (void)ctx; + (void)old_size; + return cbm_realloc(CBM_MEM_CLASS_GBUF_STRING, block, size); +} + +static void gb_yy_free(void *ctx, void *block) { + (void)ctx; + cbm_free(CBM_MEM_CLASS_GBUF_STRING, block); +} + +static const yyjson_alc gb_yy_alc = {gb_yy_malloc, gb_yy_realloc, gb_yy_free, NULL}; + +enum { GB_VARIANTS_MAX = 64 }; + +typedef struct { + const char *file; + int start; + int end; +} gb_span_t; + +static bool gb_variant_label(const char *label) { + static const char *const labels[] = {"Function", "Method", "Class", "Interface", "Enum", + "Struct", "Trait", "Type", "Macro"}; + for (size_t i = 0; label && i < sizeof(labels) / sizeof(labels[0]); i++) { + if (strcmp(label, labels[i]) == 0) { + return true; + } + } + return false; +} + +static int gb_span_cmp(const void *a, const void *b) { + const gb_span_t *x = a; + const gb_span_t *y = b; + int c = strcmp(x->file, y->file); + if (c == 0) { + c = (x->start > y->start) - (x->start < y->start); + } + if (c == 0) { + c = (x->end > y->end) - (x->end < y->end); + } + return c; +} + +/* A definition's spans: its `variants` property, else its own span. */ +static int gb_spans_of(yyjson_doc *doc, const char *file, int start, int end, gb_span_t *out, + int n) { + yyjson_val *list = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), "variants") : NULL; + if (yyjson_is_arr(list) && yyjson_arr_size(list) > 0) { + size_t i; + size_t max; + yyjson_val *v; + yyjson_arr_foreach(list, i, max, v) { + const char *f = yyjson_get_str(yyjson_obj_get(v, "file_path")); + int s = (int)yyjson_get_int(yyjson_obj_get(v, "start_line")); + int e = (int)yyjson_get_int(yyjson_obj_get(v, "end_line")); + if (f && s > 0 && n < GB_VARIANTS_MAX) { + out[n++] = (gb_span_t){f, s, e}; + } + } + return n; + } + if (start > 0 && n < GB_VARIANTS_MAX) { + out[n++] = (gb_span_t){file ? file : "", start, end}; + } + return n; +} + +/* The survivor's properties with the union of both definitions' variant + * spans. Returns a GBUF_STRING-owned JSON string, or NULL when there is + * nothing to record (fewer than two distinct spans) or on any failure — the + * survivor then keeps its own properties: a lost variants list costs + * precision, never data. */ +static char *gb_variants_props(const char *survivor_props, const char *a_props, const char *a_file, + int a_start, int a_end, const char *b_props, const char *b_file, + int b_start, int b_end) { + if (a_start <= 0 || b_start <= 0) { + return NULL; + } + yyjson_doc *a_doc = + a_props ? yyjson_read_opts((char *)a_props, strlen(a_props), 0, &gb_yy_alc, NULL) : NULL; + yyjson_doc *b_doc = + b_props ? yyjson_read_opts((char *)b_props, strlen(b_props), 0, &gb_yy_alc, NULL) : NULL; + gb_span_t spans[GB_VARIANTS_MAX * 2]; + int n = gb_spans_of(a_doc, a_file, a_start, a_end, spans, 0); + n = gb_spans_of(b_doc, b_file, b_start, b_end, spans, n); + qsort(spans, (size_t)n, sizeof(spans[0]), gb_span_cmp); + int unique = 0; + for (int i = 0; i < n; i++) { + if (unique == 0 || gb_span_cmp(&spans[unique - 1], &spans[i]) != 0) { + spans[unique++] = spans[i]; + } + } + char *out = NULL; + if (unique >= 2) { + yyjson_mut_doc *mut = yyjson_mut_doc_new(&gb_yy_alc); + yyjson_doc *s_doc = survivor_props + ? yyjson_read_opts((char *)survivor_props, strlen(survivor_props), + 0, &gb_yy_alc, NULL) + : NULL; + yyjson_mut_val *root = s_doc && yyjson_is_obj(yyjson_doc_get_root(s_doc)) + ? yyjson_val_mut_copy(mut, yyjson_doc_get_root(s_doc)) + : yyjson_mut_obj(mut); + yyjson_mut_val *list = yyjson_mut_arr(mut); + for (int i = 0; root && list && i < unique; i++) { + yyjson_mut_val *v = yyjson_mut_arr_add_obj(mut, list); + yyjson_mut_obj_add_strcpy(mut, v, "file_path", spans[i].file); + yyjson_mut_obj_add_int(mut, v, "start_line", spans[i].start); + yyjson_mut_obj_add_int(mut, v, "end_line", spans[i].end); + } + if (mut && root && list) { + yyjson_mut_obj_remove_key(root, "variants"); + yyjson_mut_obj_add_val(mut, root, "variants", list); + yyjson_mut_doc_set_root(mut, root); + out = yyjson_mut_write_opts(mut, 0, &gb_yy_alc, NULL, NULL); + } + yyjson_doc_free(s_doc); + yyjson_mut_doc_free(mut); + } + yyjson_doc_free(a_doc); + yyjson_doc_free(b_doc); + return out; +} + +/* Record the variant spans of a same-QN collision on the surviving node. */ +static void gb_record_variants(cbm_gbuf_node_t *survivor, const char *a_label, const char *a_props, + const char *a_file, int a_start, int a_end, const char *b_label, + const char *b_props, const char *b_file, int b_start, int b_end) { + if (!gb_variant_label(a_label) || !b_label || strcmp(a_label, b_label) != 0) { + return; + } + char *merged = gb_variants_props(survivor->properties_json, a_props, a_file, a_start, a_end, + b_props, b_file, b_start, b_end); + if (merged) { + cbm_free(CBM_MEM_CLASS_GBUF_STRING, survivor->properties_json); + survivor->properties_json = merged; + } +} + /* ── Node operations ─────────────────────────────────────────────── */ int64_t cbm_gbuf_upsert_node(cbm_gbuf_t *gb, const char *label, const char *name, @@ -892,8 +1048,19 @@ int64_t cbm_gbuf_upsert_node(cbm_gbuf_t *gb, const char *label, const char *name c = strcmp(existing->label ? existing->label : "", label ? label : ""); } if (c > 0) { + gb_record_variants(existing, existing->label, existing->properties_json, + existing->file_path, existing->start_line, existing->end_line, label, + properties_json, file_path, start_line, end_line); return existing->id; /* existing entity is the canonical winner */ } + /* The arrival wins: its properties, plus every variant span both carry. */ + char *variant_props = NULL; + if (gb_variant_label(label) && existing->label && strcmp(existing->label, label) == 0) { + variant_props = + gb_variants_props(properties_json, existing->properties_json, existing->file_path, + existing->start_line, existing->end_line, properties_json, + file_path, start_line, end_line); + } /* Update in-place. name/properties are strdup'd BEFORE freeing old ones * (callers may pass existing->name as an argument). label/file_path are * interned: gb_intern returns a stable pool pointer (idempotent even when @@ -903,7 +1070,9 @@ int64_t cbm_gbuf_upsert_node(cbm_gbuf_t *gb, const char *label, const char *name * label/name — cbm_gbuf_find_by_label/name then missed or mis-listed it, * which is how the flickering Function set reached the semantic pass). */ char *new_name = heap_strdup(name); - char *new_props = properties_json ? heap_strdup(properties_json) : NULL; + char *new_props = variant_props ? variant_props + : properties_json ? heap_strdup(properties_json) + : NULL; const char *new_label_interned = gb_intern(gb, label); bool label_changed = !existing->label || !new_label_interned || strcmp(existing->label, new_label_interned) != 0; @@ -1493,7 +1662,22 @@ static void merge_update_existing(cbm_gbuf_t *dst, cbm_gbuf_node_t *existing, c = strcmp(existing->label ? existing->label : "", sn->label ? sn->label : ""); } bool sn_wins = c <= 0; + if (!sn_wins) { + gb_record_variants(existing, existing->label, existing->properties_json, + existing->file_path, existing->start_line, existing->end_line, + sn->label, sn->properties_json, sn->file_path, sn->start_line, + sn->end_line); + } if (sn_wins) { + /* src's properties, plus every variant span both carry. */ + char *variant_props = NULL; + if (gb_variant_label(sn->label) && existing->label && + strcmp(existing->label, sn->label) == 0) { + variant_props = gb_variants_props(sn->properties_json, existing->properties_json, + existing->file_path, existing->start_line, + existing->end_line, sn->properties_json, + sn->file_path, sn->start_line, sn->end_line); + } /* Keep the secondary indexes consistent when the surviving * label/name changes (the old code left the node listed under its * original label/name, so find_by_label/name mis-listed it). */ @@ -1518,9 +1702,10 @@ static void merge_update_existing(cbm_gbuf_t *dst, cbm_gbuf_node_t *existing, existing->file_path = (char *)gb_intern(dst, sn->file_path); existing->start_line = sn->start_line; existing->end_line = sn->end_line; - if (sn->properties_json) { + if (variant_props || sn->properties_json) { cbm_free(CBM_MEM_CLASS_GBUF_STRING, existing->properties_json); - existing->properties_json = heap_strdup(sn->properties_json); + existing->properties_json = + variant_props ? variant_props : heap_strdup(sn->properties_json); } if (label_changed) { node_ptr_array_t *by_label = get_or_create_node_array( diff --git a/src/mcp/test_impact_engine_reach.c b/src/mcp/test_impact_engine_reach.c index a3f96d0ba..dd581a4f5 100644 --- a/src/mcp/test_impact_engine_reach.c +++ b/src/mcp/test_impact_engine_reach.c @@ -22,8 +22,10 @@ * reached, never expanded (their caller is the runner's main). */ #define TE_RUNNER_ROLE_PREFIX "tf_maybe_run_" -static const char *const te_walk_types[] = {"CALLS", "ASYNC_CALLS", "USAGE", "CALL_REFERENCE", - "READS"}; +/* SPAWNS only carries reach from a Process node, which enters the walk + * through te_spawn_lift alone. */ +static const char *const te_walk_types[] = {"CALLS", "ASYNC_CALLS", "USAGE", + "CALL_REFERENCE", "READS", "SPAWNS"}; typedef struct { int64_t *items; @@ -47,6 +49,10 @@ static bool te_is_function(const cbm_ti_node_t *n) { * definition starts on the line of its TEST(...) / SUITE(...) — graphs name * them differently (`x` without configured declarations, `TEST_x` with) — * else every function named `name`. *count of them, ids appended. */ +static bool te_named_match(const cbm_ti_node_t *n, int pass, int line, const char *name) { + return te_is_function(n) && (pass == 0 ? n->start_line == line : strcmp(n->name, name) == 0); +} + static bool te_named(te_ctx_t *c, const char *file, const char *name, int line, te_ids_t *out, int *count) { bool ok = true; @@ -59,10 +65,17 @@ static bool te_named(te_ctx_t *c, const char *file, const char *name, int line, *count = 0; for (int pass = 0; pass < 2 && *count == 0; pass++) { for (int i = 0; i < n; i++) { - bool match = - te_is_function(&nodes[i]) && - (pass == 0 ? nodes[i].start_line == line : strcmp(nodes[i].name, name) == 0); - if (!match) { + /* Any span of a definition names it by its start line, the + * primary one or a variant (graph_buffer.c "Definition + * variants"); each definition counts once. */ + if (!te_named_match(&nodes[i], pass, line, name)) { + continue; + } + bool counted = false; + for (int k = 0; k < i && !counted; k++) { + counted = nodes[k].id == nodes[i].id && te_named_match(&nodes[k], pass, line, name); + } + if (counted) { continue; } (*count)++; @@ -239,7 +252,7 @@ static bool te_runner_sinks(te_ctx_t *c, te_sinks_t *s) { false; } for (int k = 0; k < count; k++) { - if (!te_is_function(&nodes[k])) { + if (!te_is_function(&nodes[k]) || nodes[k].variant_span) { continue; } bool main_fn = strcmp(nodes[k].name, "main") == 0; @@ -440,7 +453,7 @@ static bool te_gap_seeds(te_ctx_t *c, const te_gap_t *g, te_ids_t *out) { int n = 0; const cbm_ti_node_t *nodes = ok ? cbm_ti_graph_file(c->graph, g->file, &n, &ok) : NULL; for (int i = 0; ok && i < n; i++) { - ok = te_ids_push(c, out, nodes[i].id); + ok = nodes[i].variant_span || te_ids_push(c, out, nodes[i].id); } return ok || te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, "graph read failed"); } @@ -519,7 +532,9 @@ static bool te_reach_rows(te_ctx_t *c, cbm_impact_walk_t *walk, const bool *fixt int changed_n = 0; const int *changed = cbm_ti_seeds_changed_cases(c->seeds, &changed_n); c->reach = cbm_arena_calloc(&c->arena, (size_t)(n ? n : 1) * sizeof(*c->reach)); - if (!c->reach) { + /* The graph node each row's name mapped to (0 = none or several). */ + int64_t *row_id = cbm_arena_calloc(&c->arena, (size_t)(n ? n : 1) * sizeof(*row_id)); + if (!c->reach || !row_id) { return te_oom(c); } for (int i = 0; i < n; i++) { @@ -540,6 +555,7 @@ static bool te_reach_rows(te_ctx_t *c, cbm_impact_walk_t *walk, const bool *fixt for (int k = 0; k < ids.count; k++) { reached = reached || cbm_impact_walk_reached(walk, ids.items[k]); } + int64_t id = count == 1 && ids.count == 1 ? ids.items[0] : 0; cbm_free(CBM_MEM_CLASS_EXTRACT, ids.items); bool was_changed = fixture_cases[i]; for (int k = 0; k < changed_n; k++) { @@ -553,12 +569,18 @@ static bool te_reach_rows(te_ctx_t *c, cbm_impact_walk_t *walk, const bool *fixt continue; } if (at >= 0) { - /* the same name twice in one file: ambiguous, never narrowed */ - c->reach[at].mapped = false; + /* The same name twice in one file is ambiguous and never narrowed, + * unless both are the same graph node: one test written once per + * #if/#elif branch is one definition with variants (graph_buffer.c + * "Definition variants"), as the graph already counts it. */ + if (id == 0 || row_id[at] != id) { + c->reach[at].mapped = false; + } c->reach[at].reached = c->reach[at].reached || reached; c->reach[at].changed = c->reach[at].changed || was_changed; continue; } + row_id[c->reach_count] = id; c->reach[c->reach_count++] = (cbm_test_reach_t){.file = cases[i].file, .test = cases[i].name, .mapped = count == 1, @@ -666,6 +688,52 @@ static bool te_header_triggers(te_ctx_t *c) { return true; } +/* ── Spawned processes (src/pipeline/pass_spawns.c) ───────────────── + * A test that starts a program runs code no CALLS edge leads to: the program + * runs its main, and main reaches everything. The graph does not resolve + * which program a Process node is, so when the walk reaches a `main` outside + * the tests (the product's entry), every Process node joins the walk as a + * further seed, and SPAWNS, walked from the process to whoever starts it, + * carries the reach to the tests that spawn. More, never fewer. */ +static bool te_spawn_lift(te_ctx_t *c, cbm_impact_walk_t *walk) { + cbm_node_t *mains = NULL; + int main_count = 0; + if (cbm_store_find_nodes_by_name(c->store, "test-impact", "main", &mains, &main_count) != + CBM_STORE_OK) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, "graph read failed") && + false; + } + bool product_main = false; + for (int i = 0; i < main_count && !product_main; i++) { + product_main = mains[i].label && strcmp(mains[i].label, "Function") == 0 && + mains[i].file_path && !te_test_file(c, mains[i].file_path) && + cbm_impact_walk_reached(walk, mains[i].id); + } + cbm_store_free_nodes(mains, main_count); + if (!product_main) { + return true; + } + cbm_node_t *processes = NULL; + int process_count = 0; + if (cbm_store_find_nodes_by_label(c->store, "test-impact", "Process", &processes, + &process_count) != CBM_STORE_OK) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, "graph read failed") && + false; + } + te_ids_t seeds = {0}; + bool ok = true; + for (int i = 0; ok && i < process_count; i++) { + ok = te_ids_push(c, &seeds, processes[i].id); + } + cbm_store_free_nodes(processes, process_count); + if (ok && seeds.count && cbm_impact_walk_run(walk, seeds.items, seeds.count) != CBM_STORE_OK) { + ok = te_fallback(c, CBM_TEST_RESULT_FALLBACK_ENGINE_SATURATED, "the impact walk failed") && + false; + } + cbm_free(CBM_MEM_CLASS_EXTRACT, seeds.items); + return ok; +} + bool te_reach(te_ctx_t *c) { int64_t *extra = NULL; int extra_count = 0; @@ -683,6 +751,9 @@ bool te_reach(te_ctx_t *c) { } } } + /* After the runner check: the runner re-executing itself is a spawn too, + * and reaching its main through that is no change to the runner. */ + ok = ok && te_spawn_lift(c, walk); ok = ok && te_reach_rows(c, walk, fixture_cases) && te_suite_triggers(c, walk) && te_rule_triggers(c) && te_header_triggers(c); c->static_complete = ok; diff --git a/src/mcp/test_impact_seed.c b/src/mcp/test_impact_seed.c index ccade1138..113c1ab7e 100644 --- a/src/mcp/test_impact_seed.c +++ b/src/mcp/test_impact_seed.c @@ -89,8 +89,8 @@ struct cbm_ti_graph { }; static bool ts_seedless(const char *label) { - static const char *const seedless[] = {"File", "Folder", "Project", "Module", - "Package", "Section", "Route", "Resource"}; + static const char *const seedless[] = {"File", "Folder", "Project", "Module", "Package", + "Section", "Route", "Process", "Resource"}; for (size_t i = 0; i < sizeof(seedless) / sizeof(seedless[0]); i++) { if (label && strcmp(label, seedless[i]) == 0) { return true; @@ -146,32 +146,69 @@ static int ts_node_compare(const void *left, const void *right) { return a->id < b->id ? -1 : (a->id > b->id ? 1 : 0); } +static bool ts_node_from_row(cbm_ti_graph_t *g, cbm_ti_node_t *n, const cbm_node_t *row) { + n->id = row->id; + n->label = cbm_arena_strdup(&g->arena, row->label ? row->label : ""); + n->name = cbm_arena_strdup(&g->arena, row->name ? row->name : ""); + n->qualified_name = cbm_arena_strdup(&g->arena, row->qualified_name ? row->qualified_name : ""); + n->start_line = row->start_line; + n->end_line = row->end_line; + n->variant_span = false; + return n->label && n->name && n->qualified_name; +} + +static bool ts_has_span(const cbm_ti_node_t *nodes, int count, const cbm_node_t *row) { + for (int i = 0; i < count; i++) { + if (nodes[i].id == row->id && nodes[i].start_line == row->start_line && + nodes[i].end_line == row->end_line) { + return true; + } + } + return false; +} + +/* The file's definitions, each with every span it has in the file: a + * definition written once per #if branch or platform file is ONE node + * (graph_buffer.c "Definition variants") whose own span is one variant, so + * its other variants here (other branches, or a node whose file_path names + * another platform file) join as further spans of the same node. A change + * in any variant then seeds that definition. */ static ts_file_t *ts_load_file(cbm_ti_graph_t *g, const char *file) { cbm_node_t *rows = NULL; int count = 0; if (cbm_store_find_nodes_by_file(g->store, g->project, file, &rows, &count) != CBM_STORE_OK) { return NULL; } + cbm_node_t *spans = NULL; + int span_count = 0; + if (cbm_store_find_variant_spans_by_file(g->store, g->project, file, &spans, &span_count) != + CBM_STORE_OK) { + cbm_store_free_nodes(rows, count); + return NULL; + } + int total = count + span_count; ts_file_t *f = cbm_arena_calloc(&g->arena, sizeof(*f)); - cbm_ti_node_t *nodes = cbm_arena_alloc(&g->arena, (size_t)(count ? count : 1) * sizeof(*nodes)); + cbm_ti_node_t *nodes = cbm_arena_alloc(&g->arena, (size_t)(total ? total : 1) * sizeof(*nodes)); const char *key = cbm_arena_strdup(&g->arena, file); bool ok = f && nodes && key; int kept = 0; for (int i = 0; ok && i < count; i++) { - if (ts_seedless(rows[i].label)) { - continue; + if (!ts_seedless(rows[i].label)) { + ok = ts_node_from_row(g, &nodes[kept++], &rows[i]); + } + } + for (int i = 0; ok && i < span_count; i++) { + if (!ts_seedless(spans[i].label) && !ts_has_span(nodes, kept, &spans[i])) { + bool listed = false; + for (int j = 0; j < kept && !listed; j++) { + listed = nodes[j].id == spans[i].id; + } + ok = ts_node_from_row(g, &nodes[kept], &spans[i]); + nodes[kept++].variant_span = listed; } - cbm_ti_node_t *n = &nodes[kept++]; - n->id = rows[i].id; - n->label = cbm_arena_strdup(&g->arena, rows[i].label ? rows[i].label : ""); - n->name = cbm_arena_strdup(&g->arena, rows[i].name ? rows[i].name : ""); - n->qualified_name = - cbm_arena_strdup(&g->arena, rows[i].qualified_name ? rows[i].qualified_name : ""); - n->start_line = rows[i].start_line; - n->end_line = rows[i].end_line; - ok = n->label && n->name && n->qualified_name; } cbm_store_free_nodes(rows, count); + cbm_store_free_nodes(spans, span_count); if (!ok) { return NULL; } diff --git a/src/mcp/test_impact_seed.h b/src/mcp/test_impact_seed.h index 23e97114b..ce0a5c6ab 100644 --- a/src/mcp/test_impact_seed.h +++ b/src/mcp/test_impact_seed.h @@ -41,6 +41,10 @@ typedef struct { const char *qualified_name; int start_line; int end_line; + /* A further span of a definition already listed (another #if branch or + * platform-file variant, graph_buffer.c "Definition variants"): it maps + * lines to the node, but enumerating definitions counts the node once. */ + bool variant_span; } cbm_ti_node_t; /* Borrows the store until free. Nodes of a file are read once, on first use. */ diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 855e67363..a85ad7dca 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -686,6 +686,15 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM res.confidence = lsp->confidence; res.strategy = lsp->strategy; res.candidate_count = 1; + /* An LSP answer on a synthetic builtin (``) is + * not project code: a spawn spelling still spawns. The parallel + * pass keeps the LSP answer in `res` and asks the same question. + * MUST match pass_parallel.c. */ + cbm_pipeline_spawn_t spawn; + if (cbm_pipeline_spawn_site(ctx->gbuf, lang, call, imports, &res, &spawn)) { + cbm_pipeline_emit_spawn(ctx->gbuf, source_node, call, &spawn); + return SKIP_ONE; + } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, false, route_mount); return SKIP_ONE; @@ -755,6 +764,14 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM if (!rust_external) { res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count); + /* Cross-language veto: a name-only guess never binds another + * language's symbol (a JS app.get must not become Python's + * builtins.dict.get). The vetoed answer becomes EMPTY here, before + * the fallbacks below, so the route/HTTP classification still runs. + * MUST match pass_parallel.c. */ + if (cbm_registry_name_guess_vetoed(ctx->registry, lang, &res)) { + res = (cbm_resolution_t){0}; + } } /* `p->open(fd)`: the registry does not split a callee on the arrow, and a * member is not a name it could resolve anyway. The object's type decides, @@ -768,6 +785,14 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM call->enclosing_func_qn, call->callee_name, cbm_c_member_rule_file(lang, rel), &res) != NULL; } + /* A call that starts another program (subprocess.run, exec.Command, + * posix_spawn): a SPAWNS edge to its Process node instead of a CALLS + * edge. MUST match pass_parallel.c. */ + cbm_pipeline_spawn_t spawn; + if (cbm_pipeline_spawn_site(ctx->gbuf, lang, call, imports, &res, &spawn)) { + cbm_pipeline_emit_spawn(ctx->gbuf, source_node, call, &spawn); + return SKIP_ONE; + } if (!res.qualified_name || res.qualified_name[0] == '\0') { /* Resolution is empty when the callee belongs to an EXTERNAL client * library whose source is not in the indexed tree (e.g. `requests.get`, diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 4fc6dde05..113c25228 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -381,7 +381,7 @@ static void pd_add_file_doc(const cbm_gbuf_node_t *file_node, const char *doc) { * `file_doc`, the file's own doc (passed with its first def only), goes on the * File node. */ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const char *rel, - const char *file_doc) { + const char *file_doc, CBMLanguage lang) { if (!def->qualified_name || !def->name) { return; } @@ -401,7 +401,7 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed * through the same predicate, so the three registries cannot diverge. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label); + cbm_registry_add_lang(ctx->registry, def->name, def->qualified_name, def->label, lang); } char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); @@ -956,7 +956,7 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t /* Create nodes for each definition */ const char *file_doc = result->module_doc; /* goes with the first def */ for (int d = 0; d < result->defs.count; d++) { - process_def(ctx, &result->defs.items[d], rel, file_doc); + process_def(ctx, &result->defs.items[d], rel, file_doc, lang); file_doc = NULL; total_defs++; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 6a9b1b7de..d7847305a 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1730,7 +1730,7 @@ int cbm_parallel_extract(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, * once per file by the caller: computing the file QN and finding its node for * every definition was 700 k allocations and lookups on the Go corpus. */ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, - int64_t file_node_id, int *reg_entries) { + int64_t file_node_id, CBMLanguage lang, int *reg_entries) { int edges = 0; if (!def->name || !def->qualified_name || !def->label) { return 0; @@ -1738,7 +1738,7 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d /* Registry membership is defined ONCE by cbm_label_is_registry_symbol * (helpers.c) — see pass_definitions.c for the per-label rationale. */ if (cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label); + cbm_registry_add_lang(ctx->registry, def->name, def->qualified_name, def->label, lang); (*reg_entries)++; } const cbm_gbuf_node_t *def_node = cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name); @@ -1933,8 +1933,8 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t free(file_qn); pp_add_file_doc(file_node, result->module_doc); for (int d = 0; d < result->defs.count; d++) { - defines_edges += - register_and_link_def(ctx, &result->defs.items[d], file_node_id, ®_entries); + defines_edges += register_and_link_def(ctx, &result->defs.items[d], file_node_id, + files[i].language, ®_entries); } } @@ -3186,6 +3186,13 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB _rc_t0 = extract_now_ns(); try_field_type_hint(rc, &res, call->callee_name, source_node->id); + /* Cross-language veto — the same predicate as pass_calls.c, applied + * AFTER the field-type hint so the hint may still re-pick a compatible + * candidate first, and BEFORE the empty-resolution fallbacks below so a + * vetoed call still reaches route/HTTP classification. */ + if (cbm_registry_name_guess_vetoed(rc->registry, lang, &res)) { + res = (cbm_resolution_t){0}; + } atomic_fetch_add_explicit(&rc->time_ns_rc_hint, extract_now_ns() - _rc_t0, memory_order_relaxed); @@ -3281,19 +3288,29 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } } + /* A call that starts another program (subprocess.run, exec.Command, + * posix_spawn): a SPAWNS edge to its Process node instead of a CALLS + * edge. `res` holds the LSP answer when there is one, as the + * sequential pass asks inside its LSP branch. MUST match pass_calls.c. */ + cbm_pipeline_spawn_t spawn; + if (cbm_pipeline_spawn_site(rc->main_gbuf, lang, call, &result->imports, &res, &spawn)) { + cbm_pipeline_emit_spawn(ws->local_edge_buf, source_node, call, &spawn); + continue; + } + if (!res.qualified_name || res.qualified_name[0] == '\0') { if (cbm_service_pattern_route_method(call->callee_name) != NULL) { cbm_resolution_t fake_res = {.qualified_name = call->callee_name, .confidence = PP_HALF_CONF, .strategy = "callee_suffix"}; - /* #2053: an LSP-external Rust call (`map.get(k)` on a std - * HashMap) reaches this branch only because its registry - * fallback was skipped. Without a route path the plain-CALLS - * fall-through would bind source -> source, a fabricated - * self-call, so it keeps only the route/service edges. */ + /* The target here is the SOURCE itself (nothing resolved), so + * the plain-CALLS fall-through for a route-verb call without a + * route path (`cache.get(key)`) would be a self-loop; that holds + * for #2053's LSP-external Rust calls too. Suppress it always; + * route/HTTP/service classification is unchanged. */ emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, - imp_count, rust_external, route_mount); + imp_count, true, route_mount); } else if (cbm_service_pattern_is_global_fetch(call->callee_name)) { /* Native `fetch()` (#856): only the global API once resolution * has failed to find a local/imported `fetch`. Call the low-level diff --git a/src/pipeline/pass_spawns.c b/src/pipeline/pass_spawns.c new file mode 100644 index 000000000..062ea4039 --- /dev/null +++ b/src/pipeline/pass_spawns.c @@ -0,0 +1,115 @@ +/* + * pass_spawns.c — SPAWNS edges: a call that starts another program, to the + * Process node of that program (internal/cbm/spawn_patterns.h). + * + * Shared by both call resolvers (pass_calls.c, pass_parallel.c), which ask + * at the same point — after registry resolution and the cross-language veto, + * before the empty-resolution fallbacks — so they emit the same edges. + */ +#include "pipeline/pipeline_internal.h" +#include "graph_buffer/graph_buffer.h" +#include "foundation/constants.h" +#include "foundation/str_util.h" +#include "spawn_patterns.h" + +#include +#include + +/* Edge confidence: a site read from the source, or only from the + * macro-expanded buffer of a C-family file. */ +static const double SPAWN_CONF_SOURCE = 1.0; +static const double SPAWN_CONF_EXPANDED = 0.5; + +/* Strategies that only guess by name: they never make a spawn spelling an + * ordinary call (`subprocess.run` must not become the project's `run`, nor + * a C `system(...)` the static `system` of another file). */ +static bool spawn_name_guess(const char *strategy) { + static const char *const guesses[] = {"unique_name", "suffix_match", "qualified_suffix", + "field_type_hint", "fuzzy", NULL}; + for (int i = 0; strategy && guesses[i]; i++) { + if (strcmp(strategy, guesses[i]) == 0) { + return true; + } + } + return false; +} + +/* Labels of code a call can run: a function, or a type it constructs. */ +static bool spawn_code_label(const char *label) { + static const char *const labels[] = {"Function", "Method", "Class", "Struct", "Interface", + "Trait", "Enum", "Type", "Macro", NULL}; + for (int i = 0; label && labels[i]; i++) { + if (strcmp(label, labels[i]) == 0) { + return true; + } + } + return false; +} + +/* The call reached code in the project's own sources. A synthetic builtin + * (a `` node), a QN without a node, and a Variable are not: + * `const { spawn } = require("child_process")` mints a Variable `spawn` that + * the call resolves to by same module, and it is the import binding. For a + * qualified spelling, same_module is a guess too: it matched the suffix + * alone, so `subprocess.run` inside a module's own `def run` found that + * `run`. */ +static bool spawn_resolved_to_project(const cbm_gbuf_t *gbuf, const cbm_resolution_t *res, + bool qualified) { + if (!res || !res->qualified_name || !res->qualified_name[0]) { + return false; + } + const cbm_gbuf_node_t *node = cbm_gbuf_find_by_qn(gbuf, res->qualified_name); + if (!node || !node->file_path || !node->file_path[0] || node->file_path[0] == '<' || + !spawn_code_label(node->label)) { + return false; + } + bool guess = spawn_name_guess(res->strategy) || + (qualified && res->strategy && strcmp(res->strategy, "same_module") == 0); + return !guess; +} + +bool cbm_pipeline_spawn_site(const cbm_gbuf_t *gbuf, CBMLanguage lang, const CBMCall *call, + const CBMImportArray *imports, const cbm_resolution_t *res, + cbm_pipeline_spawn_t *out) { + const cbm_spawn_api_t *api = cbm_spawn_match( + lang, call->callee_name, imports ? imports->items : NULL, imports ? imports->count : 0); + bool qualified = strpbrk(call->callee_name, ".:/\\") != NULL; + if (!api || spawn_resolved_to_project(gbuf, res, qualified)) { + return false; + } + out->api = api->api; + cbm_spawn_program(api, lang, call, out->program, sizeof(out->program)); + return true; +} + +void cbm_pipeline_emit_spawn(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, + const cbm_pipeline_spawn_t *spawn) { + char program[CBM_SZ_128]; + char api[CBM_SZ_256]; + cbm_json_escape(program, (int)sizeof(program), spawn->program); + cbm_json_escape(api, (int)sizeof(api), spawn->api); + char qn[CBM_SZ_128]; + snprintf(qn, sizeof(qn), "__process__%s", spawn->program); + char node_props[CBM_SZ_256]; + snprintf(node_props, sizeof(node_props), "{\"program\":\"%s\"}", program); + int64_t process_id = + cbm_gbuf_upsert_node(gbuf, "Process", spawn->program, qn, "", 0, 0, node_props); + if (process_id <= 0) { + return; + } + /* C-family files are extracted twice; a call from the macro-expanded + * buffer carries an expanded-buffer line, not a source line. It records + * no line and less confidence, so the raw site's edge (which wins on + * confidence when both exist) keeps the source line. */ + char edge_props[CBM_SZ_512]; + if (call->source_origin == CBM_SOURCE_ORIGIN_RAW) { + snprintf(edge_props, sizeof(edge_props), + "{\"api\":\"%s\",\"program\":\"%s\",\"line\":%d,\"confidence\":%.2f}", api, + program, call->start_line, SPAWN_CONF_SOURCE); + } else { + snprintf(edge_props, sizeof(edge_props), + "{\"api\":\"%s\",\"program\":\"%s\",\"confidence\":%.2f}", api, program, + SPAWN_CONF_EXPANDED); + } + cbm_gbuf_insert_edge(gbuf, source->id, process_id, "SPAWNS", edge_props); +} diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 0d013a8c3..fd2233fc9 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -235,10 +235,28 @@ typedef struct { cbm_registry_t *cbm_registry_new(void); void cbm_registry_free(cbm_registry_t *r); -/* Register a function/method/class. All strings are copied. */ +/* Register a function/method/class. All strings are copied. The entry's + * language is unknown, so name-based resolution never filters it out. */ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label); +/* As cbm_registry_add, recording `lang`: the DETECTED language of the file + * that defines the symbol (CBM_LANG_COUNT = unknown), which the cross-language + * veto (cbm_registry_name_guess_vetoed) checks against the caller. */ +void cbm_registry_add_lang(cbm_registry_t *r, const char *name, const char *qualified_name, + const char *label, CBMLanguage lang); + +/* True when code in `caller` can call a symbol defined in `target` by bare + * name: same language, same interop family (JS/TS + script hosts, C/C++/CUDA/ + * ObjC, JVM, .NET, ...; the table lives in registry.c), or Swift with the C + * family. An unknown language (CBM_LANG_COUNT) on either side is compatible. */ +bool cbm_lang_resolution_compatible(CBMLanguage caller, CBMLanguage target); + +/* Language of a synthetic definition source minted by a language's own LSP + * layer ("" -> Python, "" -> Kotlin); + * CBM_LANG_COUNT for anything else. */ +CBMLanguage cbm_registry_synthetic_path_language(const char *file_path); + /* Resolve a callee name using prioritized strategies. * import_map: NULL-terminated array of {local_name, resolved_qn} pairs, or NULL. * Returns result with qualified_name="" if unresolved. @@ -250,6 +268,21 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count); +/* Cross-language veto for a CALLS resolution made by a caller written in + * `caller_lang` (its file's detected language). True when `res` is a + * name-only answer (qualified_suffix, unique_name, suffix_match, the + * parallel field_type_hint re-pick, or same_module: two files of different + * languages with the same path stem share a module QN) whose target's recorded language the + * caller cannot call (cbm_lang_resolution_compatible): a JS call must never + * bind a Python symbol by spelling alone. Callers treat a vetoed answer as + * EMPTY before their empty-resolution fallbacks (route registration, HTTP + * clients), so those still classify the call. A veto, not a re-pick: the + * strategy chain is unchanged. Import-map and lsp_* answers are never + * vetoed; CBM_LANG_COUNT never vetoes. pass_calls.c and pass_parallel.c + * MUST apply it identically. Pure; unit-tested in test_registry.c. */ +bool cbm_registry_name_guess_vetoed(const cbm_registry_t *r, CBMLanguage caller_lang, + const cbm_resolution_t *res); + /* Relation-permitting resolve for SQL FROM/JOIN lineage usages ONLY — the one * consumer allowed to bind Table/View targets. Uncached (the per-file resolve * cache stores the default variant's relation-vetoed answers). */ diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 4549e8d13..b60f8bdec 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1254,12 +1254,52 @@ static bool incr_label_is_registry_symbol(const char *label) { * project's definition symbols so the resolver can match cross-file symbols * during incremental. Mirrors the full-index registry contents exactly so an * incremental re-resolve picks the same nodes a full reindex would. */ +/* Each symbol is seeded with its file's DETECTED language, exactly what the + * full index recorded (pass_definitions.c / pass_parallel.c), so the cross- + * language veto judges an incremental re-resolve the same way. Stored nodes carry no + * language, so it comes from the current discovery (rel_path -> language); + * synthetic LSP sources () map by their fixed path, which is + * also why a builtin stub that outlives the last .py file stays Python. */ +typedef struct { + cbm_registry_t *registry; + CBMHashTable *lang_by_path; /* rel_path -> (CBMLanguage + 1); keys borrowed */ +} registry_seed_t; + static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { - cbm_registry_t *r = (cbm_registry_t *)userdata; + registry_seed_t *seed = (registry_seed_t *)userdata; if (!incr_label_is_registry_symbol(node->label)) { return; } - cbm_registry_add(r, node->name, node->qualified_name, node->label); + CBMLanguage lang = cbm_registry_synthetic_path_language(node->file_path); + if (lang == CBM_LANG_COUNT && seed->lang_by_path && node->file_path) { + void *v = cbm_ht_get(seed->lang_by_path, node->file_path); + if (v) { + lang = (CBMLanguage)((uintptr_t)v - SKIP_ONE); + } + } + cbm_registry_add_lang(seed->registry, node->name, node->qualified_name, node->label, lang); +} + +/* Seed the registry from every registry symbol in the graph buffer, with + * languages taken from the discovered files (see registry_visitor). */ +static void registry_seed_from_gbuf(cbm_registry_t *registry, const cbm_gbuf_t *gbuf, + const cbm_file_info_t *files, int file_count) { + registry_seed_t seed = {.registry = registry, .lang_by_path = NULL}; + if (files && file_count > 0) { + seed.lang_by_path = cbm_ht_create((size_t)file_count * PAIR_LEN); + } + if (seed.lang_by_path) { + for (int i = 0; i < file_count; i++) { + if (files[i].rel_path) { + cbm_ht_set(seed.lang_by_path, files[i].rel_path, + (void *)((uintptr_t)files[i].language + SKIP_ONE)); + } + } + } + cbm_gbuf_foreach_node(gbuf, registry_visitor, &seed); + if (seed.lang_by_path) { + cbm_ht_free(seed.lang_by_path); + } } static void free_incremental_result_cache(CBMFileResult **cache, int count) { @@ -1294,6 +1334,9 @@ enum { * always worth attempting; the percentage governs at scale, where it is * the honest signal that a rebuild costs less than a repair. */ CLOSURE_BUDGET_FLOOR_FILES = 8, + /* Variant partners of partners: real chains are one step (a header and + * its source, a set of platform files); a longer one declines. */ + CLOSURE_VARIANT_ROUNDS_MAX = 8, }; typedef struct { @@ -1975,6 +2018,84 @@ static void closure_seed_governed(CBMHashTable *closure_set, const cbm_file_info } } +static bool closure_path_listed(char **paths, int count, const char *path) { + for (int i = 0; i < count; i++) { + if (strcmp(paths[i], path) == 0) { + return true; + } + } + return false; +} + +/* Variant partners (graph_buffer.c "Definition variants"): a definition + * written in several files (platform files, a struct's header declaration + * and its source definition, Go build-tagged files) is ONE node whose + * file_path names one of them, with a DEFINES edge from every file that + * writes a variant. Re-parsing one of those files alone rebuilds the node + * without the others' spans and outbound edges, or leaves a changed + * partner's stale span behind; so every file of such a node joins the + * closure, to a fixpoint. `paths` is the delta (changed, then deleted from + * index n_changed on); `dependents` already joined. A deleted partner is in + * the delta already; any other partner outside the discovery declines, as a + * dependent would. Returns the decline reason, or NULL. */ +static const char *closure_add_variant_partners(cbm_store_t *store, const char *project, + CBMHashTable *closure_set, + CBMHashTable *files_by_path, char **paths, + int path_count, int n_changed, char **dependents, + int dependent_count) { + int cap = path_count + dependent_count + CBM_SZ_64; + const char **pending = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)cap * sizeof(char *)); + if (!pending) { + return "alloc"; + } + int n = 0; + for (int i = 0; i < path_count; i++) { + pending[n++] = paths[i]; + } + for (int i = 0; i < dependent_count; i++) { + pending[n++] = dependents[i]; + } + const char *decline = NULL; + for (int round = 0; n > 0 && !decline; round++) { + char **partners = NULL; + int pc = 0; + if (round == CLOSURE_VARIANT_ROUNDS_MAX) { + decline = "variant_partner_rounds"; + } else if (cbm_store_get_variant_partner_files(store, project, pending, n, &partners, + &pc) != CBM_STORE_OK) { + decline = "variant_partner_query_failed"; + } + n = 0; + for (int i = 0; i < pc && !decline; i++) { + const cbm_file_info_t *fi = cbm_ht_get(files_by_path, partners[i]); + if (!fi) { + if (!closure_path_listed(paths + n_changed, path_count - n_changed, partners[i])) { + decline = "variant_partner_not_discovered"; + } + continue; + } + if (cbm_ht_get(closure_set, fi->rel_path)) { + continue; + } + cbm_ht_set(closure_set, fi->rel_path, (void *)fi->rel_path); + if (n == cap) { + cap *= PAIR_LEN; + const char **grown = + cbm_realloc(CBM_MEM_CLASS_OTHER, pending, (size_t)cap * sizeof(char *)); + if (!grown) { + decline = "alloc"; + break; + } + pending = grown; + } + pending[n++] = fi->rel_path; + } + cbm_store_free_dependent_files(partners, pc); + } + cbm_free(CBM_MEM_CLASS_OTHER, pending); + return decline; +} + /* Decide whether this manifest delta is closure-repairable, and if so build * the plan. Returns 1 with *plan filled, or 0 (decline → FULL) with the * reason logged. Every early exit declines; nothing here mutates state. */ @@ -2194,6 +2315,12 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p cbm_ht_set(closure_set, dependents[i], dependents[i]); } } + decline = + closure_add_variant_partners(store, project, closure_set, files_by_path, changed_paths, + n_changed + n_deleted, n_changed, dependents, dependent_count); + if (decline) { + goto done; + } /* closure_count == 0 is legitimate: a deleted-only delta with no * dependents has nothing to re-parse, but the purge itself still needs * the executor. */ @@ -2265,7 +2392,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char closure_plan_t *plan, cbm_file_info_t *changed_files, int ci, char **deleted, int deleted_count, cbm_file_hash_t *mode_skipped, int mode_skipped_count, cbm_coverage_row_t *old_cov, int old_cov_count, - cbm_store_t *route_store, struct timespec t0) { + cbm_store_t *route_store, const cbm_file_info_t *files, int file_count, + struct timespec t0) { struct timespec t; cbm_store_close(route_store); @@ -2335,7 +2463,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char if (!registry) { goto out; } - cbm_gbuf_foreach_node(gbuf, registry_visitor, registry); + registry_seed_from_gbuf(registry, gbuf, files, file_count); cbm_log_info("delta.preseed_done", "registry", itoa_buf(cbm_registry_size(registry)), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); @@ -2928,7 +3056,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil return run_closure_delta(p, db_path, project, baseline_manifest, baseline_count, &closure_plan, changed_files, ci, deleted, deleted_count, mode_skipped, mode_skipped_count, old_cov, old_cov_count, store, - t0); + files, file_count, t0); } struct timespec t; @@ -3035,7 +3163,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil /* Step 3-5: Registry + extract + resolve */ cbm_registry_t *registry = cbm_registry_new(); cbm_clock_gettime(CLOCK_MONOTONIC, &t); - cbm_gbuf_foreach_node(existing, registry_visitor, registry); + registry_seed_from_gbuf(registry, existing, files, file_count); cbm_log_info("incremental.registry_seed", "symbols", itoa_buf(cbm_registry_size(registry)), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index c7c992b68..440fe865d 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -17,6 +17,7 @@ #include "discover/test_conventions.h" #include "git/git_context.h" #include "foundation/hash_table.h" +#include "spawn_patterns.h" #include "cbm.h" #include "lsp/go_lsp.h" /* CBMLSPDef for cbm_parallel_resolve cross-LSP inputs */ #include @@ -572,6 +573,29 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje const char **imp_vals, int imp_count, const CBMCall *call, char *out, size_t out_sz); +/* A spawn site (pass_spawns.c): the API the call names and the program it + * starts (CBM_SPAWN_DYNAMIC when not a literal). */ +typedef struct { + const char *api; + char program[CBM_SPAWN_PROGRAM_MAX]; +} cbm_pipeline_spawn_t; + +/* True when `call` starts another program (spawn_patterns.h) and `res`, the + * registry answer after the cross-language veto, does not make it a call into + * the project's own code. `imports` are the file's extracted imports, not the + * resolver's import map: that one holds only imports that resolved to project + * nodes, and subprocess or os/exec never do. Both call resolvers ask at the + * same point and, on true, emit cbm_pipeline_emit_spawn INSTEAD of a CALLS + * edge. */ +bool cbm_pipeline_spawn_site(const cbm_gbuf_t *gbuf, CBMLanguage lang, const CBMCall *call, + const CBMImportArray *imports, const cbm_resolution_t *res, + cbm_pipeline_spawn_t *out); + +/* The Process node of the spawned program (QN __process__, one per + * program) and the SPAWNS edge from `source` to it. */ +void cbm_pipeline_emit_spawn(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, + const cbm_pipeline_spawn_t *spawn); + /* Resolve an import to its in-graph target node, or NULL if unresolvable. * * Resolution order (first hit wins): diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 9b5dc2af2..a970039e3 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -84,8 +84,14 @@ typedef struct { int cap; uint8_t *is_test; /* is_test_qn(items[i]), one byte per entry */ int is_test_cap; + /* Language of the file that defined items[i] (a CBMLanguage; CBM_LANG_COUNT + * = unknown, compatible with every caller), one byte per entry. Grown in + * lockstep with is_test, so is_test_cap bounds it too. */ + uint8_t *lang; } qn_array_t; +_Static_assert(CBM_LANG_COUNT <= UINT8_MAX, "CBMLanguage must fit the per-entry lang byte"); + struct cbm_registry { /* Interned label strings (<=~30 distinct labels; owned here, freed in * _free). The exact map's VALUES point into this pool instead of one @@ -724,6 +730,152 @@ static bool c_cpp_family(CBMLanguage lang) { return lang == CBM_LANG_C || lang == CBM_LANG_CPP; } +/* ── Cross-language resolution compatibility ───────────────────────── + * A name-based registry guess (qualified_suffix / unique_name / suffix_match) + * carries no import or type evidence, so its answer only stands when the + * caller's language can actually call the target by that name + * (cbm_registry_name_guess_vetoed). same_module joins them: module QNs drop + * the extension, so tests/x.c and tests/x.py share one module, and a call in + * the C file found the Python function by name inside it. Families are the interop + * groups the resolvers already honour, plus the well-known interop pairs: + * - JS family: JS/TS/TSX/ArkTS (the #725 and #1928 guards) and the script + * hosts whose calls ARE JS/TS (HTML/Vue/Svelte/Astro, the weak-member + * guard's embedded-script set; QML embeds JS and imports .js files). + * - C family: C/C++ (.h maps to C++, the #1928 guard), plus CUDA and ObjC, + * which call C/C++ declarations from shared headers directly. + * - JVM: Java/Kotlin/Scala/Groovy call each other's classes directly (the + * JVM cross-file LSP pairs Java with Kotlin). + * - .NET: C#/F#. PHP: PHP/Blade. Go: Go/templ (templ compiles to Go). + * Lua: Lua/Luau/Teal. BEAM: Erlang/Elixir/Gleam. Shell: Bash/Zsh. + * SQL: SQL/PL-SQL. Salesforce: Apex/SOQL/SOSL. HDL: Verilog/SystemVerilog. + * CFML: tag and script dialects. ObjectScript: UDL/routine/export. + * - Swift additionally calls the C family (C and ObjC headers import into + * Swift as native declarations), and Python calls ObjectScript (IRIS + * Embedded Python, iris.cls(...), #1260) and back. + * Every other language is its own family. An unknown language on either side + * (CBM_LANG_COUNT) is compatible with everything: without evidence, no filter. + * The languages are the DETECTED languages of the calling file and of the + * defining file (not a filename guess: .m is MATLAB by extension but ObjC by + * content), so full, parallel and incremental registries agree. */ +enum { + XL_FAM_NONE = 0, + XL_FAM_JS, + XL_FAM_C, + XL_FAM_JVM, + XL_FAM_DOTNET, + XL_FAM_PHP, + XL_FAM_GO, + XL_FAM_LUA, + XL_FAM_BEAM, + XL_FAM_SHELL, + XL_FAM_SQL, + XL_FAM_SALESFORCE, + XL_FAM_HDL, + XL_FAM_CFML, + XL_FAM_OBJECTSCRIPT, +}; + +static int xl_family(CBMLanguage lang) { + switch (lang) { + case CBM_LANG_JAVASCRIPT: + case CBM_LANG_TYPESCRIPT: + case CBM_LANG_TSX: + case CBM_LANG_ARKTS: + case CBM_LANG_HTML: + case CBM_LANG_VUE: + case CBM_LANG_SVELTE: + case CBM_LANG_ASTRO: + case CBM_LANG_QML: + return XL_FAM_JS; + case CBM_LANG_C: + case CBM_LANG_CPP: + case CBM_LANG_CUDA: + case CBM_LANG_OBJC: + return XL_FAM_C; + case CBM_LANG_JAVA: + case CBM_LANG_KOTLIN: + case CBM_LANG_SCALA: + case CBM_LANG_GROOVY: + return XL_FAM_JVM; + case CBM_LANG_CSHARP: + case CBM_LANG_FSHARP: + return XL_FAM_DOTNET; + case CBM_LANG_PHP: + case CBM_LANG_BLADE: + return XL_FAM_PHP; + case CBM_LANG_GO: + case CBM_LANG_TEMPL: + return XL_FAM_GO; + case CBM_LANG_LUA: + case CBM_LANG_LUAU: + case CBM_LANG_TEAL: + return XL_FAM_LUA; + case CBM_LANG_ERLANG: + case CBM_LANG_ELIXIR: + case CBM_LANG_GLEAM: + return XL_FAM_BEAM; + case CBM_LANG_BASH: + case CBM_LANG_ZSH: + return XL_FAM_SHELL; + case CBM_LANG_SQL: + case CBM_LANG_PLSQL: + return XL_FAM_SQL; + case CBM_LANG_APEX: + case CBM_LANG_SOQL: + case CBM_LANG_SOSL: + return XL_FAM_SALESFORCE; + case CBM_LANG_VERILOG: + case CBM_LANG_SYSTEMVERILOG: + return XL_FAM_HDL; + case CBM_LANG_CFML: + case CBM_LANG_CFSCRIPT: + return XL_FAM_CFML; + case CBM_LANG_OBJECTSCRIPT_UDL: + case CBM_LANG_OBJECTSCRIPT_ROUTINE: + case CBM_LANG_OBJECTSCRIPT_EXPORT: + return XL_FAM_OBJECTSCRIPT; + default: + return XL_FAM_NONE; + } +} + +bool cbm_lang_resolution_compatible(CBMLanguage caller, CBMLanguage target) { + if (caller < 0 || caller >= CBM_LANG_COUNT || target < 0 || target >= CBM_LANG_COUNT) { + return true; /* unknown on either side: no evidence to filter on */ + } + if (caller == target) { + return true; + } + int cf = xl_family(caller); + int tf = xl_family(target); + if (cf != XL_FAM_NONE && cf == tf) { + return true; + } + /* Swift imports C and ObjC headers as native declarations. IRIS Embedded + * Python and ObjectScript call each other's classes by name + * (iris.cls("Pkg.A").Run(), #1260). */ + return (caller == CBM_LANG_SWIFT && tf == XL_FAM_C) || + (target == CBM_LANG_SWIFT && cf == XL_FAM_C) || + (caller == CBM_LANG_PYTHON && tf == XL_FAM_OBJECTSCRIPT) || + (target == CBM_LANG_PYTHON && cf == XL_FAM_OBJECTSCRIPT); +} + +/* Synthetic definition sources minted by a language's own LSP layer + * (py_builtins.c, kotlin_builtins.c). They have no real file to detect, but + * their language is a fact of the injecting LSP. */ +CBMLanguage cbm_registry_synthetic_path_language(const char *file_path) { + if (!file_path) { + return CBM_LANG_COUNT; + } + if (strcmp(file_path, "") == 0) { + return CBM_LANG_PYTHON; + } + if (strcmp(file_path, "") == 0) { + return CBM_LANG_KOTLIN; + } + return CBM_LANG_COUNT; +} + static const char *path_basename(const char *path) { if (!path || !path[0]) { return path; @@ -918,6 +1070,7 @@ static void free_qn_array(const char *key, void *value, void *ud) { if (arr) { /* items borrow the exact map's keys — freed there, not here */ cbm_free(CBM_MEM_CLASS_DYN_ARRAY, arr->is_test); + cbm_free(CBM_MEM_CLASS_DYN_ARRAY, arr->lang); cbm_da_free(arr); free(arr); } @@ -946,7 +1099,8 @@ void cbm_registry_free(cbm_registry_t *r) { * * No array dedup needed: cbm_registry_add's exact-map check guarantees the QN * is new, and it calls this at most once per distinct key. */ -static void index_under_name(cbm_registry_t *r, const char *key, const char *owned_qn) { +static void index_under_name(cbm_registry_t *r, const char *key, const char *owned_qn, + CBMLanguage lang) { qn_array_t *arr = cbm_ht_get(r->by_name, key); if (!arr) { arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t)); @@ -963,16 +1117,31 @@ static void index_under_name(cbm_registry_t *r, const char *key, const char *own cbm_realloc(CBM_MEM_CLASS_DYN_ARRAY, arr->is_test, (size_t)want * sizeof(uint8_t)); if (grown) { arr->is_test = grown; - arr->is_test_cap = want; + uint8_t *grown_lang = + cbm_realloc(CBM_MEM_CLASS_DYN_ARRAY, arr->lang, (size_t)want * sizeof(uint8_t)); + /* Both side arrays share is_test_cap: on failure it stays below + * count, so this name serves neither cached test verdicts nor + * language filtering, exactly the unfiltered answer. */ + if (grown_lang) { + arr->lang = grown_lang; + arr->is_test_cap = want; + } } } if (arr->count <= arr->is_test_cap) { arr->is_test[arr->count - SKIP_ONE] = is_test_qn(owned_qn) ? 1 : 0; + arr->lang[arr->count - SKIP_ONE] = + (uint8_t)(lang >= 0 && lang < CBM_LANG_COUNT ? lang : CBM_LANG_COUNT); } } void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label) { + cbm_registry_add_lang(r, name, qualified_name, label, CBM_LANG_COUNT); +} + +void cbm_registry_add_lang(cbm_registry_t *r, const char *name, const char *qualified_name, + const char *label, CBMLanguage lang) { if (!r || !qualified_name || !label) { return; } @@ -1011,10 +1180,11 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified * The derived key is the one every language has always used and it stays * unconditional, so a grammar whose QNs carry no '#' is byte-identical to * before this commit. The second key exists for one shape: a fenced tail is - * a literal string no bare callee is ever written as. rust_cfg_qualified_name - * mints a `#[cfg(test)]` twin as "proj.lib.add#cfg(test)", simple_name() has - * no '#' handling, so the derived key filed that function under the whole - * string "add#cfg(test)" where no bare `add` callee could reach it. + * a literal string no bare callee is ever written as. A QN such as + * "proj.lib.add#cfg(test)" (Rust cfg twins were minted that way until they + * became variants of one definition) would otherwise be filed under the + * whole string "add#cfg(test)" — simple_name() has no '#' handling — where + * no bare `add` callee could reach it. * * Gating on the fence rather than on the file's language is what makes * "unchanged for every other grammar" checkable instead of asserted. Every @@ -1034,9 +1204,9 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified const char *derived = simple_name(qualified_name); const char *primary = name && cbm_qn_callable_base_len_named(owned_qn, name) < strlen(owned_qn) ? name : derived; - index_under_name(r, primary, owned_qn); - /* '#' is a QN fence, and extract_defs.c's rust_cfg_qualified_name is the - * only thing in the tree that mints one today. A grammar that starts + index_under_name(r, primary, owned_qn, lang); + /* '#' is a QN fence; no extractor mints one for a definition today (Rust + * cfg twins are variants of one plain QN). A grammar that starts * minting a '#' opts into this second key by doing so, whatever it means by * the fence: its symbols become reachable under the passed name as well, * and they share that name's bucket with everything else filed under it. @@ -1044,7 +1214,7 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified * signature-qualified callable is filed under `name` already, and its * capped suffix may carry a '#' of its own. */ if (name && name[0] && strchr(derived, '#') && strcmp(name, primary) != 0) { - index_under_name(r, name, owned_qn); + index_under_name(r, name, owned_qn, lang); } } @@ -1505,6 +1675,49 @@ static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const ch return res; } +/* The recorded language of the registry entry `qn` (the owned key pointer a + * resolution returns), or CBM_LANG_COUNT when unknown. The by-name bucket is + * at most REG_MAX_CANDIDATES long whenever a name strategy produced `qn`. */ +static CBMLanguage registry_entry_language(const cbm_registry_t *r, const char *qn) { + const qn_array_t *arr = cbm_ht_get(r->by_name, simple_name(qn)); + if (!arr || !arr->lang || arr->is_test_cap < arr->count) { + return CBM_LANG_COUNT; + } + for (int i = 0; i < arr->count; i++) { + if (arr->items[i] == qn || strcmp(arr->items[i], qn) == 0) { + return (CBMLanguage)arr->lang[i]; + } + } + return CBM_LANG_COUNT; +} + +/* The cross-language veto (see cbm_lang_resolution_compatible). A name-only + * answer carries no import or module evidence, so it only stands when the + * caller's language can call the target: a JS `app.get` must not bind + * Python's builtins.dict.get because it is the one "get" in the project. + * Callers turn a vetoed answer into an EMPTY resolution BEFORE their empty- + * resolution fallbacks, so route registration / HTTP classification still + * run. A veto, not a re-pick among the remaining candidates: re-picking + * promotes the next same-language short-name guess (measured on n8n: +3.1k + * CALLS, 88% of them jest `describe()` bound to a project method). + * field_type_hint is included: it re-picks by type-name substring across ALL + * candidates, language-blind. */ +bool cbm_registry_name_guess_vetoed(const cbm_registry_t *r, CBMLanguage caller_lang, + const cbm_resolution_t *res) { + if (!r || !res || caller_lang < 0 || caller_lang >= CBM_LANG_COUNT || !res->qualified_name || + !res->qualified_name[0] || !res->strategy) { + return false; + } + if (strcmp(res->strategy, "unique_name") != 0 && strcmp(res->strategy, "suffix_match") != 0 && + strcmp(res->strategy, "qualified_suffix") != 0 && + strcmp(res->strategy, "field_type_hint") != 0 && + strcmp(res->strategy, "same_module") != 0) { + return false; + } + return !cbm_lang_resolution_compatible(caller_lang, + registry_entry_language(r, res->qualified_name)); +} + cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count) { diff --git a/src/store/store.c b/src/store/store.c index e82c3cd4d..375834a25 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -4721,6 +4721,42 @@ static char *store_like_dot_suffix(const char *text, const char *tail) { return pat; } +int cbm_store_find_variant_spans_by_file(cbm_store_t *s, const char *project, const char *file_path, + cbm_node_t **out, int *count) { + *out = NULL; + *count = 0; + if (!s || !s->db || !project || !file_path) { + return CBM_STORE_ERR; + } + /* The file's own nodes with variants (idx_nodes_file), and the nodes of + * other files it writes a variant of (its File/Module DEFINES edges); + * then each one's variant spans that lie in this file. */ + const char *sql = + "SELECT n.id, n.project, n.label, n.name, n.qualified_name, n.file_path," + " CAST(json_extract(v.value, '$.start_line') AS INTEGER)," + " CAST(json_extract(v.value, '$.end_line') AS INTEGER), n.properties" + " FROM nodes n, json_each(n.properties, '$.variants') v" + " WHERE n.id IN (" + " SELECT id FROM nodes WHERE project = ?1 AND file_path = ?2" + " AND json_array_length(properties, '$.variants') > 1" + " UNION SELECT tgt.id FROM nodes src" + " CROSS JOIN edges e ON e.source_id = src.id" + " CROSS JOIN nodes tgt ON tgt.id = e.target_id" + " WHERE src.project = ?1 AND src.file_path = ?2 AND src.label IN ('File','Module')" + " AND e.type = 'DEFINES' AND tgt.file_path <> ?2" + " AND json_array_length(tgt.properties, '$.variants') > 1)" + " AND json_extract(v.value, '$.file_path') = ?2" + " ORDER BY n.id, 7, 8"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "variant spans prepare"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + bind_text(stmt, ST_COL_2, file_path); + return store_collect_nodes(s, stmt, NULL, NULL, out, count); +} + int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const char *suffix, cbm_node_t **out, int *count) { *out = NULL; @@ -5349,43 +5385,40 @@ void cbm_store_free_lsp_surfaces(cbm_lsp_surface_row_t *rows, int count) { free(rows); } -/* One chunk of the dependent-file query. Binding an IN list needs one - * placeholder per element, so the SQL is assembled per chunk; the chunk cap +/* One chunk of a file-path query over a list of files. Binding an IN list + * needs one placeholder per element, so the SQL is assembled per chunk from + * `parts`, with the placeholder list ?2..?n+1 between consecutive parts (a + * query may name the list more than once); ?1 is the project. The chunk cap * keeps it bounded. Dedup across chunks happens in the caller's hash set. */ enum { DEPFILE_CHUNK = 200 }; -static int dependent_files_chunk(cbm_store_t *s, const char *project, - const char *const *target_files, int chunk_count, - CBMHashTable *seen, char ***out, int *out_count, int *out_cap) { - char sql[CBM_SZ_4K]; - int pos = snprintf(sql, sizeof(sql), - /* CROSS JOIN pins nodes-first (see the delta snapshot query: - * the free planner walks every project edge instead). */ - "SELECT DISTINCT src.file_path FROM nodes tgt" - " CROSS JOIN edges e ON e.target_id = tgt.id" - " CROSS JOIN nodes src ON e.source_id = src.id" - " WHERE tgt.project = ?1 AND tgt.file_path IN ("); - for (int i = 0; i < chunk_count; i++) { - pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, "%s?%d", i ? "," : "", i + ST_COL_2); +static int file_query_chunk(cbm_store_t *s, const char *const *parts, int nparts, const char *what, + const char *project, const char *const *target_files, int chunk_count, + CBMHashTable *seen, char ***out, int *out_count, int *out_cap) { + char sql[CBM_SZ_8K]; + sql[0] = '\0'; + if (nparts < 1) { + return CBM_STORE_ERR; + } + int pos = 0; + for (int part = 0; part < nparts; part++) { + if (part > 0) { + for (int i = 0; i < chunk_count; i++) { + pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, "%s?%d", i ? "," : "", + i + ST_COL_2); + if ((size_t)pos >= sizeof(sql)) { + return CBM_STORE_ERR; + } + } + } + pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, "%s", parts[part]); if ((size_t)pos >= sizeof(sql)) { return CBM_STORE_ERR; } } - /* Structural containment is not resolution: a Folder/Project container - * (whose file_path is a properties placeholder, not a real file) or a - * CONTAINS_* edge says nothing about who consumed the target's - * definitions, and the closure planner would otherwise decline on a - * "dependent" no discovery can ever produce. */ - pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, - ") AND src.file_path <> '' AND src.file_path IS NOT NULL" - " AND src.label NOT IN ('Folder','Project')" - " AND e.type NOT IN ('CONTAINS_FILE','CONTAINS_FOLDER')"); - if ((size_t)pos >= sizeof(sql)) { - return CBM_STORE_ERR; - } sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "dependent_files prepare"); + store_set_error_sqlite(s, what); return CBM_STORE_ERR; } bind_text(stmt, ST_COL_1, project); @@ -5420,16 +5453,18 @@ static int dependent_files_chunk(cbm_store_t *s, const char *project, return step_rc == SQLITE_DONE ? CBM_STORE_OK : CBM_STORE_ERR; } -int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, - const char *const *target_files, int target_count, char ***out, - int *out_count) { +/* Run `parts` over every chunk of target_files; the targets themselves are + * excluded from the result. */ +static int file_query(cbm_store_t *s, const char *const *parts, int nparts, const char *what, + const char *project, const char *const *target_files, int target_count, + char ***out, int *out_count) { *out = NULL; *out_count = 0; if (!s || !project || target_count <= 0) { return target_count == 0 ? CBM_STORE_OK : CBM_STORE_ERR; } /* Exclude the targets themselves up front: an edge between two files of - * the closure adds nothing, and the caller wants "who ELSE consumed". */ + * the closure adds nothing, and the caller wants "who ELSE". */ CBMHashTable *seen = cbm_ht_create((size_t)target_count * PAIR_LEN); if (!seen) { return CBM_STORE_ERR; @@ -5446,8 +5481,8 @@ int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, if (chunk > DEPFILE_CHUNK) { chunk = DEPFILE_CHUNK; } - rc = dependent_files_chunk(s, project, target_files + off, chunk, seen, &files, &count, - &cap); + rc = file_query_chunk(s, parts, nparts, what, project, target_files + off, chunk, seen, + &files, &count, &cap); } cbm_ht_free(seen); if (rc != CBM_STORE_OK) { @@ -5459,6 +5494,54 @@ int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, return CBM_STORE_OK; } +int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, + const char *const *target_files, int target_count, char ***out, + int *out_count) { + static const char *const parts[] = { + /* CROSS JOIN pins nodes-first (see the delta snapshot query: the + * free planner walks every project edge instead). */ + "SELECT DISTINCT src.file_path FROM nodes tgt" + " CROSS JOIN edges e ON e.target_id = tgt.id" + " CROSS JOIN nodes src ON e.source_id = src.id" + " WHERE tgt.project = ?1 AND tgt.file_path IN (", + /* Structural containment is not resolution: a Folder/Project + * container (whose file_path is a properties placeholder, not a real + * file) or a CONTAINS_* edge says nothing about who consumed the + * target's definitions, and the closure planner would otherwise + * decline on a "dependent" no discovery can ever produce. */ + ") AND src.file_path <> '' AND src.file_path IS NOT NULL" + " AND src.label NOT IN ('Folder','Project')" + " AND e.type NOT IN ('CONTAINS_FILE','CONTAINS_FOLDER')"}; + return file_query(s, parts, 2, "dependent_files prepare", project, target_files, target_count, + out, out_count); +} + +int cbm_store_get_variant_partner_files(cbm_store_t *s, const char *project, + const char *const *files, int count, char ***out, + int *out_count) { + static const char *const parts[] = { + /* A file that writes a variant of a node whose file_path is + * another file. */ + "SELECT tgt.file_path FROM nodes src" + " CROSS JOIN edges e ON e.source_id = src.id" + " CROSS JOIN nodes tgt ON e.target_id = tgt.id" + " WHERE src.project = ?1 AND src.label IN ('File','Module') AND e.type = 'DEFINES'" + " AND tgt.file_path <> src.file_path AND tgt.file_path <> ''" + " AND json_array_length(tgt.properties, '$.variants') > 1" + " AND src.file_path IN (", + /* The other files that write a variant of a node in the file. */ + ") UNION SELECT src.file_path FROM nodes tgt" + " CROSS JOIN edges e ON e.target_id = tgt.id" + " CROSS JOIN nodes src ON e.source_id = src.id" + " WHERE tgt.project = ?1 AND e.type = 'DEFINES' AND src.label IN ('File','Module')" + " AND src.file_path <> tgt.file_path AND src.file_path <> ''" + " AND json_array_length(tgt.properties, '$.variants') > 1" + " AND tgt.file_path IN (", + ")"}; + return file_query(s, parts, 3, "variant_partner_files prepare", project, files, count, out, + out_count); +} + void cbm_store_free_dependent_files(char **files, int count) { if (!files) { return; diff --git a/src/store/store.h b/src/store/store.h index 7f98baeef..fd690b5da 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -99,6 +99,14 @@ typedef struct { const char *config_ctx; /* governing-config context hash ("" = none) */ } cbm_lsp_surface_row_t; +/* The definition variants that lie in a file (graph_buffer.c "Definition + * variants"): one row per variant span in `file_path`, as the node it + * belongs to with start_line/end_line set to that span. Covers the file's + * own nodes (their other #if branches) and nodes whose file_path names + * another file that the file writes a variant of. */ +int cbm_store_find_variant_spans_by_file(cbm_store_t *s, const char *project, const char *file_path, + cbm_node_t **out, int *count); + /* Find nodes overlapping a line range in a file (excludes Module/Package). */ int cbm_store_find_nodes_by_file_overlap(cbm_store_t *s, const char *project, const char *file_path, int start_line, int end_line, cbm_node_t **out, @@ -170,6 +178,16 @@ int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, int *out_count); void cbm_store_free_dependent_files(char **files, int count); +/* Variant partners for closure-repair routing: the files that write another + * variant of a definition written in any of `files` (one node for a + * definition's platform files, header declaration and source definition, Go + * build-tagged files; a DEFINES edge from each file's File node). Excludes + * `files` themselves; served by idx_nodes_file + the edge source/target + * indexes. Free with cbm_store_free_dependent_files. */ +int cbm_store_get_variant_partner_files(cbm_store_t *s, const char *project, + const char *const *files, int count, char ***out, + int *out_count); + /* Find edges whose properties contain a url_path matching the keyword. */ int cbm_store_find_edges_by_url_path(cbm_store_t *s, const char *project, const char *keyword, cbm_edge_t **out, int *count); diff --git a/tests/test_conditional_variants.c b/tests/test_conditional_variants.c new file mode 100644 index 000000000..6c05c373c --- /dev/null +++ b/tests/test_conditional_variants.c @@ -0,0 +1,635 @@ +/* + * test_conditional_variants.c — one definition with mutually exclusive + * variants is ONE identity in the graph, in every language. + * + * A variant is a second definition of the same name that only one build or + * run ever sees: C-family #if/#else, C#/F# #if, Rust #[cfg], Swift #if, + * Go build-tagged files, D version blocks, Erlang -ifdef, Haskell CPP, + * Fortran .F90 preprocessing, Verilog `ifdef, Elixir/Python/Ruby/PHP + * definitions under an `if`, and C platform files (foo_win.c / foo_posix.c). + * + * Every fixture defines `pick` twice — variant A calls `alpha`, variant B + * calls `beta` — plus a `caller` that calls `pick`. The graph must hold: + * - exactly one node for `pick` (no per-variant twins, no condition text + * in its qualified name); + * - CALLS from that node to alpha AND beta (every variant's body counts); + * - a CALLS edge from caller to it; + * - its `variants` property listing every variant's span, so a change in + * any variant maps to the definition. + * Fixtures avoid each language's unrelated extraction gaps (Ruby and Pascal + * calls carry parentheses; Haskell calls are applications). + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +typedef struct { + const char *name; + const char *content; +} cv_file_t; + +typedef struct { + char tmpdir[256]; + char dbpath[1024]; + char *project; + cbm_mcp_server_t *srv; + cbm_store_t *store; +} cv_proj_t; + +static bool cv_write(const char *root, const cv_file_t *file) { + char path[700]; + snprintf(path, sizeof(path), "%s/%s", root, file->name); + char *slash = strrchr(path, '/'); + if (slash && slash > path + strlen(root)) { + *slash = '\0'; + cbm_mkdir_p(path, 0755); + *slash = '/'; + } + FILE *f = cbm_fopen(path, "wb"); + if (!f) { + return false; + } + bool ok = fputs(file->content, f) >= 0; + return fclose(f) == 0 && ok; +} + +static bool cv_index(cv_proj_t *p, const cv_file_t *files, int count) { + memset(p, 0, sizeof(*p)); + snprintf(p->tmpdir, sizeof(p->tmpdir), "%s", th_mktempdir("cbm-cv")); + if (!p->tmpdir[0]) { + return false; + } + for (int i = 0; i < count; i++) { + if (!cv_write(p->tmpdir, &files[i])) { + return false; + } + } + p->project = cbm_project_name_from_path(p->tmpdir); + if (!p->project) { + return false; + } + /* Where the MCP server writes the project DB: CBM_CACHE_DIR, else the + * per-user cache (same resolution as test_lang_contract.c). */ + char cache[700]; + const char *configured = getenv("CBM_CACHE_DIR"); + const char *home = getenv("HOME"); + if (configured && configured[0]) { + snprintf(cache, sizeof(cache), "%s", configured); + } else { + snprintf(cache, sizeof(cache), "%s/.cache/codebase-memory-mcp", home ? home : "/tmp"); + } + cbm_mkdir_p(cache, 0755); + snprintf(p->dbpath, sizeof(p->dbpath), "%s/%s.db", cache, p->project); + unlink(p->dbpath); + p->srv = cbm_mcp_server_new(NULL); + if (!p->srv) { + return false; + } + char args[800]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", p->tmpdir); + free(cbm_mcp_handle_tool(p->srv, "index_repository", args)); + p->store = cbm_store_open_path(p->dbpath); + return p->store != NULL; +} + +static void cv_cleanup(cv_proj_t *p) { + if (p->store) { + cbm_store_close(p->store); + } + if (p->srv) { + cbm_mcp_server_free(p->srv); + } + if (p->dbpath[0]) { + char side[1100]; + unlink(p->dbpath); + snprintf(side, sizeof(side), "%s-wal", p->dbpath); + unlink(side); + snprintf(side, sizeof(side), "%s-shm", p->dbpath); + unlink(side); + } + free(p->project); + if (p->tmpdir[0]) { + th_rmtree(p->tmpdir); + } + memset(p, 0, sizeof(*p)); +} + +/* One integer from SQL with text parameters; -1 on any query failure. */ +static int cv_count(cv_proj_t *p, const char *sql, const char *a, const char *b) { + sqlite3 *db = cbm_store_get_db(p->store); + sqlite3_stmt *stmt = NULL; + if (!db || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + sqlite3_finalize(stmt); + return -1; + } + int n = sqlite3_bind_parameter_count(stmt); + const char *values[] = {p->project, a, b}; + for (int i = 0; i < n && i < 3; i++) { + sqlite3_bind_text(stmt, i + 1, values[i], -1, SQLITE_TRANSIENT); + } + int count = sqlite3_step(stmt) == SQLITE_ROW ? sqlite3_column_int(stmt, 0) : -1; + sqlite3_finalize(stmt); + return count; +} + +#define CV_DEF_LABELS "('Function','Method','Module','Class')" + +typedef struct { + int nodes; /* distinct `pick` definitions */ + int clean_names; /* of those, qualified names without condition text */ + int to_alpha; /* CALLS pick -> alpha */ + int to_beta; /* CALLS pick -> beta */ + int from_caller; /* CALLS caller -> pick */ + int variant_spans; /* entries of pick's `variants` property */ +} cv_result_t; + +static cv_result_t cv_measure(cv_proj_t *p, const char *pick, const char *alpha, const char *beta, + const char *caller) { + cv_result_t r; + r.nodes = cv_count(p, + "SELECT COUNT(DISTINCT qualified_name) FROM nodes WHERE project=?1 " + "AND name=?2 AND label IN " CV_DEF_LABELS, + pick, NULL); + r.clean_names = cv_count( + p, + "SELECT COUNT(*) FROM nodes WHERE project=?1 AND name=?2 AND label IN " CV_DEF_LABELS + " AND qualified_name NOT GLOB '*[#[]()]*'", + pick, NULL); + const char *calls = "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id " + "JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='CALLS' " + "AND s.name=?2 AND t.name=?3"; + r.to_alpha = cv_count(p, calls, pick, alpha); + r.to_beta = cv_count(p, calls, pick, beta); + r.from_caller = cv_count(p, calls, caller, pick); + r.variant_spans = + cv_count(p, + "SELECT COALESCE(MAX(json_array_length(properties,'$.variants')),0) " + "FROM nodes WHERE project=?1 AND name=?2 AND label IN " CV_DEF_LABELS, + pick, NULL); + return r; +} + +static int cv_expect(const char *lang, cv_result_t r) { + bool ok = r.nodes == 1 && r.clean_names == 1 && r.to_alpha >= 1 && r.to_beta >= 1 && + r.from_caller >= 1 && r.variant_spans >= 2; + if (!ok) { + fprintf( + stderr, " [%s] nodes=%d clean=%d ->alpha=%d ->beta=%d caller->=%d variant_spans=%d\n", + lang, r.nodes, r.clean_names, r.to_alpha, r.to_beta, r.from_caller, r.variant_spans); + } + return ok; +} + +static int cv_run(const char *lang, const cv_file_t *files, int count, const char *pick, + const char *alpha, const char *beta, const char *caller) { + cv_proj_t p; + bool indexed = cv_index(&p, files, count); + int ok = indexed && cv_expect(lang, cv_measure(&p, pick, alpha, beta, caller)); + if (!indexed) { + fprintf(stderr, " [%s] fixture could not be indexed\n", lang); + } + cv_cleanup(&p); + return ok; +} + +#define CV_ONE(lang, path, src) \ + do { \ + static const cv_file_t files_[] = {{path, src}}; \ + ASSERT_TRUE(cv_run(lang, files_, 1, "pick", "alpha", "beta", "caller")); \ + } while (0) + +TEST(cv_c_preprocessor) { + CV_ONE("c", "pick.c", + "void alpha(void) {}\nvoid beta(void) {}\n#ifdef _WIN32\nvoid pick(void) { alpha(); }\n" + "#else\nvoid pick(void) { beta(); }\n#endif\nvoid caller(void) { pick(); }\n"); + PASS(); +} + +TEST(cv_cpp_elif) { + CV_ONE( + "cpp", "pick.cpp", + "void alpha() {}\nvoid beta() {}\n#if defined(_WIN32)\nvoid pick() { alpha(); }\n" + "#elif defined(__linux__)\nvoid pick() { beta(); }\n#endif\nvoid caller() { pick(); }\n"); + PASS(); +} + +TEST(cv_cpp_class_members) { + CV_ONE("cpp_member", "pick.cpp", + "struct P {\n void alpha() {}\n void beta() {}\n#ifdef _WIN32\n void pick() { " + "alpha(); }\n" + "#else\n void pick() { beta(); }\n#endif\n void caller() { pick(); }\n};\n"); + PASS(); +} + +TEST(cv_objc_methods) { + CV_ONE("objc", "P.m", + "@implementation P\n- (void)alpha {}\n- (void)beta {}\n#if TARGET_OS_IPHONE\n" + "- (void)pick { alpha(); }\n#else\n- (void)pick { beta(); }\n#endif\n" + "- (void)caller { pick(); }\n@end\nvoid alpha(void) {}\nvoid beta(void) {}\n" + "void pick(void);\n"); + PASS(); +} + +TEST(cv_cuda) { + CV_ONE( + "cuda", "pick.cu", + "__device__ void alpha() {}\n__device__ void beta() {}\n#ifdef USE_FAST\n" + "__device__ void pick() { alpha(); }\n#else\n__device__ void pick() { beta(); }\n#endif\n" + "__device__ void caller() { pick(); }\n"); + PASS(); +} + +TEST(cv_csharp_class_members) { + static const cv_file_t files[] = { + {"Picker.cs", "class Picker {\n static void Alpha() {}\n static void Beta() {}\n" + "#if WINDOWS\n static void Pick() { Alpha(); }\n#else\n" + " static void Pick() { Beta(); }\n#endif\n" + " static void Caller() { Pick(); }\n}\n"}}; + ASSERT_TRUE(cv_run("csharp", files, 1, "Pick", "Alpha", "Beta", "Caller")); + PASS(); +} + +TEST(cv_fsharp) { + CV_ONE("fsharp", "Pick.fs", + "module Pick\nlet alpha () = ()\nlet beta () = ()\n#if WINDOWS\nlet pick () = alpha ()\n" + "#else\nlet pick () = beta ()\n#endif\nlet caller () = pick ()\n"); + PASS(); +} + +TEST(cv_rust_cfg_free_functions) { + static const cv_file_t files[] = { + {"Cargo.toml", "[package]\nname = \"cv\"\nversion = \"0.1.0\"\n"}, + {"src/lib.rs", "fn alpha() {}\nfn beta() {}\n#[cfg(windows)]\nfn pick() { alpha(); }\n" + "#[cfg(not(windows))]\nfn pick() { beta(); }\nfn caller() { pick(); }\n"}}; + ASSERT_TRUE(cv_run("rust", files, 2, "pick", "alpha", "beta", "caller")); + PASS(); +} + +TEST(cv_rust_cfg_methods) { + static const cv_file_t files[] = { + {"Cargo.toml", "[package]\nname = \"cv\"\nversion = \"0.1.0\"\n"}, + {"src/lib.rs", + "struct P;\nfn alpha() {}\nfn beta() {}\nimpl P {\n #[cfg(target_os = \"linux\")]\n" + " fn pick(&self) { alpha(); }\n #[cfg(not(target_os = \"linux\"))]\n" + " fn pick(&self) { beta(); }\n fn caller(&self) { self.pick(); }\n}\n"}}; + ASSERT_TRUE(cv_run("rust_impl", files, 2, "pick", "alpha", "beta", "caller")); + PASS(); +} + +TEST(cv_go_build_tag_files) { + static const cv_file_t files[] = { + {"go.mod", "module example.com/cv\n\ngo 1.22\n"}, + {"pick/pick_windows.go", "//go:build windows\npackage pick\n\nfunc Pick() { alpha() }\n"}, + {"pick/pick_posix.go", "//go:build !windows\npackage pick\n\nfunc Pick() { beta() }\n"}, + {"pick/common.go", + "package pick\n\nfunc alpha() {}\nfunc beta() {}\nfunc Caller() { Pick() }\n"}}; + ASSERT_TRUE(cv_run("go", files, 4, "Pick", "alpha", "beta", "Caller")); + PASS(); +} + +TEST(cv_swift) { + CV_ONE("swift", "pick.swift", + "func alpha() {}\nfunc beta() {}\n#if os(Windows)\nfunc pick() { alpha() }\n#else\n" + "func pick() { beta() }\n#endif\nfunc caller() { pick() }\n"); + PASS(); +} + +TEST(cv_python_if) { + CV_ONE("python", "pick.py", + "import sys\ndef alpha(): pass\ndef beta(): pass\nif sys.platform == \"win32\":\n" + " def pick():\n alpha()\nelse:\n def pick():\n beta()\n" + "def caller():\n pick()\n"); + PASS(); +} + +TEST(cv_ruby_if) { + CV_ONE( + "ruby", "pick.rb", + "def alpha(); end\ndef beta(); end\nif RUBY_PLATFORM =~ /mswin/\n def pick\n alpha()\n" + " end\nelse\n def pick\n beta()\n end\nend\ndef caller\n pick()\nend\n"); + PASS(); +} + +TEST(cv_php_if) { + CV_ONE("php", "pick.php", + " ok.\nbeta() -> ok.\n-ifdef(WINDOWS).\n" + "pick() -> alpha().\n-else.\npick() -> beta().\n-endif.\ncaller() -> pick().\n"); + PASS(); +} + +TEST(cv_haskell_cpp) { + CV_ONE("haskell", "Pick.hs", + "{-# LANGUAGE CPP #-}\nmodule Pick where\nalpha :: Int -> Int\nalpha x = x\n" + "beta :: Int -> Int\nbeta x = x\n#if defined(mingw32_HOST_OS)\npick :: Int -> Int\n" + "pick x = alpha x\n#else\npick :: Int -> Int\npick x = beta x\n#endif\n" + "caller :: Int -> Int\ncaller x = pick x\n"); + PASS(); +} + +/* The first branch calls neither target: alpha is reached only through the + * #elif branch, beta only through the nested group's # else inside #else. */ +TEST(cv_haskell_cpp_elif_nested) { + CV_ONE("haskell", "Pick.hs", + "{-# LANGUAGE CPP #-}\nmodule Pick where\nalpha :: Int -> Int\nalpha x = x\n" + "beta :: Int -> Int\nbeta x = x\n#if defined(mingw32_HOST_OS)\npick :: Int -> Int\n" + "pick x = x\n#elif defined(darwin_HOST_OS)\npick :: Int -> Int\npick x = alpha x\n" + "#else\n# if WORD_SIZE_IN_BITS == 64\npick :: Int -> Int\npick x = x\n# else\n" + "pick :: Int -> Int\npick x = beta x\n# endif\n#endif\n" + "caller :: Int -> Int\ncaller x = pick x\n"); + PASS(); +} + +TEST(cv_fortran_preprocessed) { + CV_ONE("fortran", "pick.F90", + "module pickmod\ncontains\n subroutine alpha()\n end subroutine alpha\n" + " subroutine beta()\n end subroutine beta\n#ifdef WINDOWS\n subroutine pick()\n" + " call alpha()\n end subroutine pick\n#else\n subroutine pick()\n call beta()\n" + " end subroutine pick\n#endif\n subroutine caller()\n call pick()\n" + " end subroutine caller\nend module pickmod\n"); + PASS(); +} + +TEST(cv_pascal_ifdef) { + CV_ONE("pascal", "pick.pas", + "unit pick;\ninterface\nprocedure caller;\nimplementation\nprocedure alpha; begin end;\n" + "procedure beta; begin end;\n{$IFDEF WINDOWS}\nprocedure pick; begin alpha(); end;\n" + "{$ELSE}\nprocedure pick; begin beta(); end;\n{$ENDIF}\n" + "procedure caller; begin pick(); end;\nend.\n"); + PASS(); +} + +TEST(cv_julia_static_if) { + CV_ONE("julia", "pick.jl", + "alpha() = nothing\nbeta() = nothing\n@static if Sys.iswindows()\n pick() = alpha()\n" + "else\n pick() = beta()\nend\ncaller() = pick()\n"); + PASS(); +} + +TEST(cv_elixir_compile_time_if) { + CV_ONE("elixir", "pick.ex", + "defmodule Pick do\n def alpha, do: :ok\n def beta, do: :ok\n" + " if Code.ensure_loaded?(:win32) do\n def pick, do: alpha()\n else\n" + " def pick, do: beta()\n end\n def caller, do: pick()\nend\n"); + PASS(); +} + +/* Verilog: the identity only. Module instantiation produces no CALLS edge in + * this graph even without any `ifdef (a general Verilog gap, not a variant + * one), so the body and caller edges are not this suite's to assert. */ +TEST(cv_verilog_ifdef) { + static const cv_file_t files[] = { + {"pick.sv", "`ifdef FAST\nmodule pick(input a); alpha u(); endmodule\n`else\n" + "module pick(input a); beta u(); endmodule\n`endif\nmodule alpha(); endmodule\n" + "module beta(); endmodule\nmodule caller(); pick p(.a(1'b0)); endmodule\n"}}; + cv_proj_t p; + ASSERT_TRUE(cv_index(&p, files, 1)); + cv_result_t r = cv_measure(&p, "pick", "alpha", "beta", "caller"); + cv_cleanup(&p); + ASSERT_EQ(r.nodes, 1); + ASSERT_EQ(r.clean_names, 1); + ASSERT_GTE(r.variant_spans, 2); + PASS(); +} + +/* C platform files: the same function in foo_win.c and foo_posix.c is one + * definition with two variants, and a caller in a shared file binds to it. */ +TEST(cv_c_platform_files) { + static const cv_file_t files[] = { + {"src/plat_win.c", "void alpha(void);\nvoid pick(void) { alpha(); }\n"}, + {"src/plat_posix.c", "void beta(void);\nvoid pick(void) { beta(); }\n"}, + {"src/plat.c", "void alpha(void) {}\nvoid beta(void) {}\nvoid pick(void);\n" + "void caller(void) { pick(); }\n"}}; + ASSERT_TRUE(cv_run("c_platform_files", files, 3, "pick", "alpha", "beta", "caller")); + PASS(); +} + +/* Platform directories (libuv's src/unix/ and src/win/) are platform variants + * the same way platform suffixes are. */ +TEST(cv_c_platform_directories) { + static const cv_file_t files[] = { + {"src/unix/fs.c", "void alpha(void);\nvoid pick(void) { alpha(); }\n"}, + {"src/win/fs.c", "void beta(void);\nvoid pick(void) { beta(); }\n"}, + {"src/core.c", "void alpha(void) {}\nvoid beta(void) {}\nvoid pick(void);\n" + "void caller(void) { pick(); }\n"}}; + ASSERT_TRUE(cv_run("c_platform_dirs", files, 3, "pick", "alpha", "beta", "caller")); + PASS(); +} + +/* Not variants: static functions are file-local even in platform files, and + * same-named external functions in files without a platform token may belong + * to different programs. Each keeps its own node. */ +TEST(cv_c_non_variants_keep_their_nodes) { + static const cv_file_t files[] = {{"src/plat_win.c", "static void pick(void) {}\n"}, + {"src/plat_posix.c", "static void pick(void) {}\n"}, + {"tools/one.c", "void helper(void) {}\n"}, + {"tools/two.c", "void helper(void) {}\n"}}; + cv_proj_t p; + ASSERT_TRUE(cv_index(&p, files, 4)); + int picks = cv_count(&p, + "SELECT COUNT(DISTINCT qualified_name) FROM nodes WHERE project=?1 " + "AND name='pick' AND label='Function'", + NULL, NULL); + int helpers = cv_count(&p, + "SELECT COUNT(DISTINCT qualified_name) FROM nodes WHERE project=?1 " + "AND name='helper' AND label='Function'", + NULL, NULL); + cv_cleanup(&p); + ASSERT_EQ(picks, 2); + ASSERT_EQ(helpers, 2); + PASS(); +} + +/* A call that names a function no definition of its own language provides + * must not bind to a same-named definition in another language. */ +TEST(cv_no_cross_language_name_binding) { + static const cv_file_t files[] = { + {"go.mod", "module example.com/cv\n\ngo 1.22\n"}, + {"pick/pick.go", "package pick\n\nfunc Pick() {}\n"}, + {"cs/Caller.cs", "class C {\n static void Caller() { Pick(); }\n}\n"}}; + cv_proj_t p; + ASSERT_TRUE(cv_index(&p, files, 3)); + /* Positive controls: both definitions are in the graph, so a missing + * edge below is the rule at work, not an empty index. */ + int go_pick = cv_count(&p, + "SELECT COUNT(*) FROM nodes WHERE project=?1 AND name='Pick' " + "AND file_path LIKE '%.go'", + NULL, NULL); + int cs_caller = cv_count(&p, + "SELECT COUNT(*) FROM nodes WHERE project=?1 AND name='Caller' " + "AND file_path LIKE '%.cs'", + NULL, NULL); + int crossing = + cv_count(&p, + "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id " + "JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='CALLS' " + "AND s.name='Caller' AND t.file_path LIKE '%.go'", + NULL, NULL); + cv_cleanup(&p); + ASSERT_EQ(go_pick, 1); + ASSERT_EQ(cs_caller, 1); + ASSERT_EQ(crossing, 0); + PASS(); +} + +/* ── Incremental: a definition written in several files ─────────────── + * Closure repair re-parses only the changed files and their recorded + * dependents. `pick` written in two platform files is ONE node; editing + * either file must re-parse the other too, or the repair drops the other + * variant's span and calls (when the edited file holds the node) or keeps + * the edited file's stale ones. After each edit the repaired graph must be + * the graph a full index of the same tree builds. */ + +static bool cv_pipeline(const char *repo, const char *db, cbm_incremental_route_t *route) { + cbm_pipeline_t *p = cbm_pipeline_new(repo, db, CBM_MODE_FULL); + if (!p) { + return false; + } + bool ok = cbm_pipeline_set_project_name(p, "cvinc"); + cbm_pipeline_set_persistence(p, false); + ok = ok && cbm_pipeline_run(p) == 0; + if (route) { + *route = cbm_pipeline_incremental_test_last_route(); + } + cbm_pipeline_free(p); + return ok; +} + +/* CALLS edges by name plus pick's variant spans, one line each. */ +static bool cv_signature(const char *db_path, char *out, size_t n) { + sqlite3 *db = NULL; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + const char *sql = + "SELECT line FROM (SELECT s.name || '>' || t.name AS line FROM edges e " + "JOIN nodes s ON s.id=e.source_id JOIN nodes t ON t.id=e.target_id WHERE e.type='CALLS' " + "UNION ALL SELECT 'variants ' || json_extract(properties,'$.variants') FROM nodes " + "WHERE name='pick' AND label='Function') ORDER BY line"; + sqlite3_stmt *stmt = NULL; + bool ok = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK; + out[0] = '\0'; + while (ok && sqlite3_step(stmt) == SQLITE_ROW) { + const char *line = (const char *)sqlite3_column_text(stmt, 0); + size_t used = strlen(out); + snprintf(out + used, n - used, "%s\n", line ? line : "?"); + } + sqlite3_finalize(stmt); + sqlite3_close(db); + return ok; +} + +/* Rewrite `name` in `repo`, repair the incremental DB, index the same tree + * from scratch, and compare. */ +static bool cv_edit_matches_full(const char *repo, const char *dbdir, const char *name, + const char *content, char *sig, size_t n) { + char inc_db[600]; + char full_db[600]; + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", dbdir); + snprintf(full_db, sizeof(full_db), "%s/full.db", dbdir); + static char full_sig[8192]; + const cv_file_t file = {name, content}; + cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE; + bool ok = cv_write(repo, &file) && cv_pipeline(repo, inc_db, &route); + if (route != CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR) { + fprintf(stderr, " [incremental %s] route %d, not closure repair\n", name, (int)route); + ok = false; + } + unlink(full_db); + ok = ok && cv_pipeline(repo, full_db, NULL) && cv_signature(inc_db, sig, n) && + cv_signature(full_db, full_sig, sizeof(full_sig)); + if (ok && strcmp(sig, full_sig) != 0) { + fprintf(stderr, " [incremental %s] repaired:\n%s full:\n%s", name, sig, full_sig); + ok = false; + } + return ok; +} + +TEST(cv_incremental_variant_partners) { + char repo[300]; + char dbdir[300]; + snprintf(repo, sizeof(repo), "%s", th_mktempdir("cbm-cvinc")); + snprintf(dbdir, sizeof(dbdir), "%s", th_mktempdir("cbm-cvdb")); + ASSERT_TRUE(repo[0] && dbdir[0]); + static const cv_file_t files[] = { + {"util.c", "void alpha(void) {}\nvoid beta(void) {}\nvoid gamma_fn(void) {}\n"}, + {"pick_linux.c", "void pick(void) { alpha(); }\n"}, + {"pick_win.c", "void pick(void) { beta(); }\n"}, + {"caller.c", "void caller(void) { pick(); }\n"}, + }; + for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); i++) { + ASSERT_TRUE(cv_write(repo, &files[i])); + } + char inc_db[600]; + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", dbdir); + ASSERT_TRUE(cv_pipeline(repo, inc_db, NULL)); + static char sig[8192]; + /* Edit each variant file in turn (one of them holds the node). */ + ASSERT_TRUE(cv_edit_matches_full(repo, dbdir, "pick_linux.c", + "void pick(void) { alpha(); gamma_fn(); }\n", sig, + sizeof(sig))); + ASSERT_TRUE(strstr(sig, "pick>beta\n") && strstr(sig, "pick>gamma_fn\n")); + ASSERT_TRUE(cv_edit_matches_full(repo, dbdir, "pick_win.c", "void pick(void) {\n beta();\n}\n", + sig, sizeof(sig))); + ASSERT_TRUE(strstr(sig, "pick>alpha\n") && strstr(sig, "pick>beta\n")); + th_rmtree(repo); + th_rmtree(dbdir); + PASS(); +} + +SUITE(conditional_variants) { + RUN_TEST(cv_c_preprocessor); + RUN_TEST(cv_cpp_elif); + RUN_TEST(cv_cpp_class_members); + RUN_TEST(cv_objc_methods); + RUN_TEST(cv_cuda); + RUN_TEST(cv_csharp_class_members); + RUN_TEST(cv_fsharp); + RUN_TEST(cv_rust_cfg_free_functions); + RUN_TEST(cv_rust_cfg_methods); + RUN_TEST(cv_go_build_tag_files); + RUN_TEST(cv_swift); + RUN_TEST(cv_python_if); + RUN_TEST(cv_ruby_if); + RUN_TEST(cv_php_if); + RUN_TEST(cv_d_version); + RUN_TEST(cv_erlang_ifdef); + RUN_TEST(cv_haskell_cpp); + RUN_TEST(cv_haskell_cpp_elif_nested); + RUN_TEST(cv_fortran_preprocessed); + RUN_TEST(cv_pascal_ifdef); + RUN_TEST(cv_julia_static_if); + RUN_TEST(cv_elixir_compile_time_if); + RUN_TEST(cv_verilog_ifdef); + RUN_TEST(cv_c_platform_files); + RUN_TEST(cv_c_platform_directories); + RUN_TEST(cv_c_non_variants_keep_their_nodes); + RUN_TEST(cv_no_cross_language_name_binding); + RUN_TEST(cv_incremental_variant_partners); +} diff --git a/tests/test_extraction.c b/tests/test_extraction.c index de1136b1a..5a4c7e9eb 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3758,6 +3758,32 @@ TEST(go_cgo_pseudo_import_dropped) { PASS(); } +/* A Bash script imports the files it sources and nothing else. Every top-level + * command used to be read as an import of its command name (`set -e` imported + * "set", `source x.sh` imported "source"), which the import resolver bound to + * any project symbol of that name; the sourced paths themselves were lost. */ +TEST(bash_imports_are_sourced_files_only) { + CBMFileResult *r = extract("#!/usr/bin/env bash\nset -euo pipefail\nsource ./lib/util.sh\n" + ". \"$DIR/other.sh\"\ncd build\nmake all\n" + "if true; then\n source nested.sh\nfi\n", + CBM_LANG_BASH, "t", "run.sh"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(r->imports.count, 3); + ASSERT(has_import(r, "lib/util.sh")); + ASSERT(has_import(r, "other.sh")); + ASSERT(has_import(r, "nested.sh")); + static const char *const commands[] = {"set", "source", ".", "cd", "make"}; + for (int i = 0; i < r->imports.count; i++) { + ASSERT_NOT_NULL(r->imports.items[i].module_path); + for (size_t c = 0; c < sizeof(commands) / sizeof(commands[0]); c++) { + ASSERT_TRUE(strcmp(r->imports.items[i].module_path, commands[c]) != 0); + } + } + cbm_free_result(r); + PASS(); +} + /* #1935: Go struct fields were never extracted — find_class_body() returns the * struct_type node, whose only named child is a field_declaration_list, so the * member loop matched nothing and every field was silently skipped (0 Field @@ -5248,10 +5274,11 @@ TEST(swift_non_url_constructor_untouched_issue1892) { * the per-file constant map and resolved at the call site, for both return * statements and arrow expression bodies. */ TEST(extract_ts_await_generic_call_issue2210) { - CBMFileResult *r = extract("function parseJsonBody() { return {} as T; }\n" - "async function plain() { return await parseJsonBody(); }\n" - "async function generic() { return await parseJsonBody(); }\n", - CBM_LANG_TYPESCRIPT, "t", "await.ts"); + CBMFileResult *r = + extract("function parseJsonBody() { return {} as T; }\n" + "async function plain() { return await parseJsonBody(); }\n" + "async function generic() { return await parseJsonBody(); }\n", + CBM_LANG_TYPESCRIPT, "t", "await.ts"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); ASSERT_EQ(count_calls_named(r, "parseJsonBody"), 2); @@ -9748,6 +9775,7 @@ SUITE(extraction) { RUN_TEST(js_imports); RUN_TEST(go_imports); RUN_TEST(go_cgo_pseudo_import_dropped); + RUN_TEST(bash_imports_are_sourced_files_only); RUN_TEST(extract_go_struct_fields_have_nodes); RUN_TEST(java_imports); RUN_TEST(rust_imports); diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index 6eb993564..0ca52c349 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -463,11 +463,10 @@ TEST(contract_python_relative_import) { /* Python: relative aliased from-import must CALLS the real def (execute). */ TEST(contract_python_aliased_from_import_calls) { - static const LangFile f[] = { - {"gate.py", "def execute(x):\n return x\n"}, - {"router.py", - "from .gate import execute as bridge_execute\n\n\n" - "def submit_task(y):\n return bridge_execute(y)\n"}}; + static const LangFile f[] = {{"gate.py", "def execute(x):\n return x\n"}, + {"router.py", + "from .gate import execute as bridge_execute\n\n\n" + "def submit_task(y):\n return bridge_execute(y)\n"}}; LangProj lp; cbm_store_t *store = lang_index_files(&lp, f, 2); ASSERT_TRUE(store != NULL); @@ -560,10 +559,8 @@ TEST(contract_python_absolute_aliased_from_import_calls) { /* Python: unresolvable module + alias must emit no CALLS (no invented edge). */ TEST(contract_python_ghost_module_aliased_from_import_no_calls) { - static const LangFile f[] = { - {"router.py", - "from ghost_module import nope as alias\n\n\n" - "def submit_task(y):\n return alias(y)\n"}}; + static const LangFile f[] = {{"router.py", "from ghost_module import nope as alias\n\n\n" + "def submit_task(y):\n return alias(y)\n"}}; LangProj lp; cbm_store_t *store = lang_index_files(&lp, f, 1); ASSERT_TRUE(store != NULL); @@ -1073,6 +1070,7 @@ static const char *ALL_EDGE_TYPES[] = {"CALLS", "REFERENCES_FILE", "SEMANTICALLY_RELATED", "SIMILAR_TO", + "SPAWNS", "TESTS_FILE", "TESTS", "TRPC_CALLS", @@ -1760,10 +1758,11 @@ TEST(contract_edge_parallel_service_edges) { if (store) { sqlite3_stmt *stmt = NULL; sqlite3 *db = cbm_store_get_db(store); - if (db && sqlite3_prepare_v2(db, - "SELECT count(*) FROM edges WHERE properties IS NOT NULL " - "AND properties != '' AND json_valid(properties)=0;", - -1, &stmt, NULL) == SQLITE_OK && + if (db && + sqlite3_prepare_v2(db, + "SELECT count(*) FROM edges WHERE properties IS NOT NULL " + "AND properties != '' AND json_valid(properties)=0;", + -1, &stmt, NULL) == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) { invalid_props = sqlite3_column_int(stmt, 0); } diff --git a/tests/test_main.c b/tests/test_main.c index 7428194f2..6ede15bb7 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -12,22 +12,22 @@ int tf_deselected_count = 0; #include "test_framework.h" #include "test_helpers.h" #include "test_daemon_runtime_contract.h" -#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ -#include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ -#include "foundation/constants.h" /* CBM_SZ_4K — forced stderr buffer */ -#include "foundation/log.h" /* crash-durable worker log probe */ -#include "foundation/mem.h" /* cbm_mem_init — worker budget */ -#include "foundation/log.h" /* worker liveness heartbeat probe */ -#include "foundation/platform.h" /* cbm_file_exists — blocking-git marker */ +#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ +#include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ +#include "foundation/constants.h" /* CBM_SZ_4K — forced stderr buffer */ +#include "foundation/log.h" /* crash-durable worker log probe */ +#include "foundation/mem.h" /* cbm_mem_init — worker budget */ +#include "foundation/log.h" /* worker liveness heartbeat probe */ +#include "foundation/platform.h" /* cbm_file_exists — blocking-git marker */ #include "foundation/test_selection.h" /* shared executable selection token bound */ -#include "daemon/bootstrap.h" /* runner rendezvous isolation */ -#include "daemon/runtime.h" /* bounded worker response probe */ -#include "daemon/ipc.h" /* Windows private-lock re-exec probe */ -#include "daemon/version_cohort.h" /* Windows crash-turnover re-exec probe */ -#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ -#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ -#include "ui/http_server.h" /* deleted-self executable probe */ -#include "result_spill.h" /* pinned free disk: spill verdicts ignore the host disk */ +#include "daemon/bootstrap.h" /* runner rendezvous isolation */ +#include "daemon/runtime.h" /* bounded worker response probe */ +#include "daemon/ipc.h" /* Windows private-lock re-exec probe */ +#include "daemon/version_cohort.h" /* Windows crash-turnover re-exec probe */ +#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ +#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ +#include "ui/http_server.h" /* deleted-self executable probe */ +#include "result_spill.h" /* pinned free disk: spill verdicts ignore the host disk */ #include #include #include @@ -49,11 +49,11 @@ int tf_deselected_count = 0; #include #endif #ifdef CBM_TEST_COVERAGE -#include /* the libc functions behind the profile-keeping ones */ -#include /* the marker of a forked child */ -#include /* non-consuming child-failure observation */ -#include /* pthread_atfork: a forked child names its own profile */ -#include /* execl */ +#include /* the libc functions behind the profile-keeping ones */ +#include /* the marker of a forked child */ +#include /* non-consuming child-failure observation */ +#include /* pthread_atfork: a forked child names its own profile */ +#include /* execl */ #include /* reject detectable failure-channel descriptor replacement */ #endif #endif @@ -1853,6 +1853,8 @@ extern void suite_security(void); extern void suite_yaml(void); extern void suite_integration(void); extern void suite_lang_contract(void); +extern void suite_conditional_variants(void); +extern void suite_spawns(void); extern void suite_edge_imports(void); extern void suite_edge_structural(void); extern void suite_lsp_resolution_probe(void); @@ -2312,6 +2314,8 @@ int main(int argc, char **argv) { /* Per-language graph contracts (node/edge types, attribution, no-crash) */ RUN_SELECTED_SUITE(lang_contract); + RUN_SELECTED_SUITE(conditional_variants); + RUN_SELECTED_SUITE(spawns); RUN_SELECTED_SUITE(edge_imports); RUN_SELECTED_SUITE(edge_structural); RUN_SELECTED_SUITE(lsp_resolution_probe); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 15ff1bdbf..929fbd760 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1999,6 +1999,231 @@ TEST(pipeline_objectscript_export_incremental_matches_full_relationships) { PASS(); } +/* Cross-language unique_name leak. Every Python file injects a few builtin + * stub definitions (builtins.dict.get, builtins.len, ...; see + * internal/cbm/lsp/py_builtins.c) into the project registry. A JS Express + * registration `app.get('/users', listUsers)` has no LSP target, falls + * through to the registry, and its short name "get" had exactly one + * candidate project-wide: Python's builtins.dict.get (strategy unique_name). + * The non-empty resolution skipped the route-registration fallback (it runs + * only on an EMPTY resolution), and the weak-member guard then dropped the + * plain CALLS edge, so the Route node and its HANDLES edge vanished -- on the + * sequential and the parallel path alike, and on an incremental reindex whose + * registry is re-seeded from stored nodes (the builtin stubs survive there + * even after the last .py file is gone). + * + * The same leak fabricates CALLS edges on both paths: a bare JS `print(msg)` + * bound to Python's builtins.print by unique_name. `cache.get(key)` guards the + * other side of the veto: an unresolved route verb without a route path must + * not become a CALLS self-loop on the parallel path. + * + * The Python control proves the builtins stay reachable from Python itself: + * lookup() -> builtins.dict.get (lsp_generic_method) and -> builtins.len. */ +typedef struct { + int run_rc; + bool store_opened; + int routes; /* Route node "/users" */ + int handles; /* listUsers -[HANDLES]-> "/users" */ + int py_dict_get; /* lookup -[CALLS]-> get */ + int py_len; /* lookup -[CALLS]-> len */ + int js_print; /* report -[CALLS]-> print (JS global, never Python's) */ + int self_loop; /* readCache -[CALLS]-> readCache (unresolved route verb) */ +} XlangRouteObservation; + +static XlangRouteObservation observe_xlang_route(const char *repo_path, const char *db_name) { + XlangRouteObservation o = {.run_rc = -1, + .routes = -1, + .handles = -1, + .py_dict_get = -1, + .py_len = -1, + .js_print = -1, + .self_loop = -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 o; + } + o.run_rc = cbm_pipeline_run(pipeline); + const char *project = cbm_pipeline_project_name(pipeline); + cbm_store_t *store = cbm_store_open_path(db_path); + o.store_opened = store != NULL; + if (store && project) { + o.routes = named_node_count(store, project, "/users"); + o.handles = named_edge_count(store, project, "HANDLES", "listUsers", "/users"); + o.py_dict_get = named_edge_count(store, project, "CALLS", "lookup", "get"); + o.py_len = named_edge_count(store, project, "CALLS", "lookup", "len"); + o.js_print = named_edge_count(store, project, "CALLS", "report", "print"); + o.self_loop = named_edge_count(store, project, "CALLS", "readCache", "readCache"); + } + if (store) { + cbm_store_close(store); + } + cbm_pipeline_free(pipeline); + return o; +} + +static const char XLANG_APP_JS[] = "const express = require('express');\n" + "const app = express();\n" + "\n" + "function listUsers(req, res) {\n" + " res.json([]);\n" + "}\n" + "\n" + "app.get('/users', listUsers);\n" + "\n" + "function report(msg) {\n" + " print(msg);\n" + "}\n" + "\n" + "function readCache(cache, key) {\n" + " return cache.get(key);\n" + "}\n" + "module.exports = { app, report, readCache };\n"; + +static const char XLANG_TOOL_PY[] = "def lookup(xs):\n" + " d = {\"k\": 1}\n" + " n = len(xs)\n" + " return d.get(\"k\"), n\n"; + +/* Writes app.js + tool.py + `pad` JS filler files (the fillers only select the + * parallel path: more than MIN_FILES_FOR_PARALLEL files). 0 on success. */ +static int write_xlang_route_fixture(const char *dir, int pad) { + char path[512]; + snprintf(path, sizeof(path), "%s/app.js", dir); + if (th_write_file(path, XLANG_APP_JS) != 0) { + return -1; + } + snprintf(path, sizeof(path), "%s/tool.py", dir); + if (th_write_file(path, XLANG_TOOL_PY) != 0) { + return -1; + } + for (int i = 0; i < pad; i++) { + char source[128]; + snprintf(path, sizeof(path), "%s/xlang_pad_%02d.js", dir, i); + snprintf(source, sizeof(source), "function xlangPad%02d() { return %d; }\n", i, i); + if (th_write_file(path, source) != 0) { + return -1; + } + } + return 0; +} + +/* Saves and overrides the two worker-selection variables. */ +typedef struct { + char *workers; + char *single; +} XlangEnvSave; + +static XlangEnvSave xlang_env_select(bool parallel) { + XlangEnvSave save = {0}; + const char *w = getenv("CBM_WORKERS"); + const char *s = getenv("CBM_INDEX_SINGLE_THREAD"); + save.workers = w ? strdup(w) : NULL; + save.single = s ? strdup(s) : NULL; + if (parallel) { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + } else { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + } + return save; +} + +static void xlang_env_restore(XlangEnvSave *save) { + if (save->workers) { + cbm_setenv("CBM_WORKERS", save->workers, 1); + free(save->workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + if (save->single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", save->single, 1); + free(save->single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + save->workers = NULL; + save->single = NULL; +} + +static XlangRouteObservation run_xlang_route_case(bool parallel, int pad) { + XlangRouteObservation o = {.run_rc = -1}; + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_xlang_route_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + return o; + } + if (write_xlang_route_fixture(tmp, pad) == 0) { + XlangEnvSave save = xlang_env_select(parallel); + o = observe_xlang_route(tmp, "xlang.db"); + xlang_env_restore(&save); + } + th_rmtree(tmp); + return o; +} + +TEST(pipeline_js_route_not_bound_to_python_builtin_sequential) { + XlangRouteObservation o = run_xlang_route_case(false, 0); + ASSERT_EQ(o.run_rc, 0); + ASSERT_TRUE(o.store_opened); + ASSERT_EQ(o.routes, 1); + ASSERT_EQ(o.handles, 1); + ASSERT_EQ(o.py_dict_get, 1); + ASSERT_EQ(o.py_len, 1); + ASSERT_EQ(o.js_print, 0); + ASSERT_EQ(o.self_loop, 0); + PASS(); +} + +TEST(pipeline_js_route_not_bound_to_python_builtin_parallel) { + XlangRouteObservation o = run_xlang_route_case(true, 55); + ASSERT_EQ(o.run_rc, 0); + ASSERT_TRUE(o.store_opened); + ASSERT_EQ(o.routes, 1); + ASSERT_EQ(o.handles, 1); + ASSERT_EQ(o.py_dict_get, 1); + ASSERT_EQ(o.py_len, 1); + ASSERT_EQ(o.js_print, 0); + ASSERT_EQ(o.self_loop, 0); + PASS(); +} + +/* Incremental: the registry is re-seeded from stored nodes, where the Python + * builtin stubs outlive the last .py file (their file_path is synthetic). */ +TEST(pipeline_js_route_not_bound_to_python_builtin_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_xlang_incr_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + if (write_xlang_route_fixture(tmp, 0) != 0) { + th_rmtree(tmp); + FAIL("fixture"); + } + XlangEnvSave save = xlang_env_select(false); + XlangRouteObservation initial = observe_xlang_route(tmp, "xlang-incr.db"); + char path[512]; + snprintf(path, sizeof(path), "%s/tool.py", tmp); + int rm_rc = remove(path); + snprintf(path, sizeof(path), "%s/app.js", tmp); + int append_rc = th_append_file(path, "// edited: re-resolve this file\n"); + XlangRouteObservation incremental = observe_xlang_route(tmp, "xlang-incr.db"); + xlang_env_restore(&save); + th_rmtree(tmp); + + ASSERT_EQ(rm_rc, 0); + ASSERT_EQ(append_rc, 0); + ASSERT_EQ(initial.run_rc, 0); + ASSERT_EQ(incremental.run_rc, 0); + ASSERT_TRUE(incremental.store_opened); + ASSERT_EQ(incremental.routes, 1); + ASSERT_EQ(incremental.handles, 1); + ASSERT_EQ(incremental.js_print, 0); + ASSERT_EQ(incremental.self_loop, 0); + PASS(); +} + static bool test_buffer_appendf(char *buffer, size_t capacity, size_t *used, const char *format, ...) { if (!buffer || !used || *used >= capacity) { @@ -18151,6 +18376,9 @@ SUITE(pipeline) { 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_js_route_not_bound_to_python_builtin_sequential); + RUN_TEST(pipeline_js_route_not_bound_to_python_builtin_parallel); + RUN_TEST(pipeline_js_route_not_bound_to_python_builtin_incremental); RUN_TEST(pipeline_objectscript_export_aggregate_exceeds_arena_block_table); #if defined(CBM_COVERAGE_MARKER_TEST_API) && CBM_COVERAGE_MARKER_TEST_API RUN_TEST(pipeline_objectscript_export_range_join_keeps_one_trailing_marker); diff --git a/tests/test_registry.c b/tests/test_registry.c index 1671f628e..bcd589a7d 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -961,6 +961,113 @@ TEST(registry_tie_break_is_independent_of_registration_order) { PASS(); } +TEST(lang_resolution_compatible_families) { + /* Same language and the interop families the resolvers already honour. */ + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_PYTHON, CBM_LANG_PYTHON)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_JAVASCRIPT, CBM_LANG_TYPESCRIPT)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_VUE, CBM_LANG_TSX)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_C, CBM_LANG_CPP)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_OBJC, CBM_LANG_C)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_KOTLIN, CBM_LANG_JAVA)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_SWIFT, CBM_LANG_OBJC)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_OBJC, CBM_LANG_SWIFT)); + /* Unknown on either side: no evidence, no filter. */ + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_COUNT, CBM_LANG_PYTHON)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_JAVASCRIPT, CBM_LANG_COUNT)); + /* Across families: never by name alone. */ + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_JAVASCRIPT, CBM_LANG_PYTHON)); + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_PYTHON, CBM_LANG_TSX)); + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_GO, CBM_LANG_C)); + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_SWIFT, CBM_LANG_KOTLIN)); + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_MAKEFILE, CBM_LANG_C)); + /* Synthetic LSP sources carry their language. */ + ASSERT_EQ(cbm_registry_synthetic_path_language(""), CBM_LANG_PYTHON); + ASSERT_EQ(cbm_registry_synthetic_path_language(""), CBM_LANG_KOTLIN); + ASSERT_EQ(cbm_registry_synthetic_path_language("app.py"), CBM_LANG_COUNT); + ASSERT_EQ(cbm_registry_synthetic_path_language(NULL), CBM_LANG_COUNT); + PASS(); +} + +/* Resolve with the unfiltered chain, then apply the cross-language veto the + * way pass_calls.c / pass_parallel.c do. */ +static cbm_resolution_t xl_resolve(const cbm_registry_t *r, CBMLanguage lang, const char *callee, + const char *module_qn) { + cbm_resolution_t res = cbm_registry_resolve(r, callee, module_qn, NULL, NULL, 0); + if (cbm_registry_name_guess_vetoed(r, lang, &res)) { + res = (cbm_resolution_t){0}; + } + return res; +} + +TEST(name_guess_vetoed_across_languages) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_lang(r, "get", "builtins.dict.get", "Method", CBM_LANG_PYTHON); + cbm_registry_add_lang(r, "patch", "proj.web.Panel.patch", "Function", CBM_LANG_TSX); + cbm_registry_add_lang(r, "patch", "proj.api.client.patch", "Function", CBM_LANG_PYTHON); + cbm_registry_add(r, "legacy", "proj.x.legacy", "Function"); /* unknown language */ + + /* JS `app.get`: the only "get" is Python's -> EMPTY, not unique_name. */ + cbm_resolution_t res = xl_resolve(r, CBM_LANG_JAVASCRIPT, "app.get", "proj.app"); + ASSERT_TRUE(res.qualified_name == NULL || res.qualified_name[0] == '\0'); + /* Python keeps its own builtin. */ + res = xl_resolve(r, CBM_LANG_PYTHON, "d.get", "proj.tool"); + ASSERT_STR_EQ(res.qualified_name, "builtins.dict.get"); + ASSERT_STR_EQ(res.strategy, "unique_name"); + /* Two same-named candidates: the chain still picks by import distance; + * the answer stands only for a caller that can call the winner. */ + res = xl_resolve(r, CBM_LANG_PYTHON, "patch", "proj.api.x"); + ASSERT_STR_EQ(res.qualified_name, "proj.api.client.patch"); + ASSERT_STR_EQ(res.strategy, "suffix_match"); + res = xl_resolve(r, CBM_LANG_TYPESCRIPT, "patch", "proj.api.x"); + ASSERT_TRUE(res.qualified_name == NULL || res.qualified_name[0] == '\0'); + res = xl_resolve(r, CBM_LANG_TYPESCRIPT, "patch", "proj.web.ui"); + ASSERT_STR_EQ(res.qualified_name, "proj.web.Panel.patch"); + /* Unknown-language entries and an unknown caller are never vetoed. */ + res = xl_resolve(r, CBM_LANG_GO, "legacy", "proj.go"); + ASSERT_STR_EQ(res.qualified_name, "proj.x.legacy"); + res = xl_resolve(r, CBM_LANG_COUNT, "app.get", "proj.app"); + ASSERT_STR_EQ(res.qualified_name, "builtins.dict.get"); + + /* Evidence-backed strategies are never vetoed, whatever the language. */ + cbm_resolution_t imp = {.qualified_name = "builtins.dict.get", + .strategy = "import_map", + .confidence = 0.95, + .candidate_count = 1}; + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_JAVASCRIPT, &imp)); + imp.strategy = "lsp_generic_method"; + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_JAVASCRIPT, &imp)); + /* The parallel field-type hint re-picks language-blind: vetoed too. */ + imp.strategy = "field_type_hint"; + ASSERT_TRUE(cbm_registry_name_guess_vetoed(r, CBM_LANG_JAVASCRIPT, &imp)); + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_PYTHON, &imp)); + cbm_registry_free(r); + PASS(); +} + +/* tests/cov.c and tests/cov.py share the module QN proj.tests.cov (module QNs + * drop the extension): a same_module answer across languages is that + * collision, not evidence. Within the caller's language it stands. */ +TEST(same_module_vetoed_across_languages) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_lang(r, "reducer_worker", "proj.tests.cov.reducer_worker", "Function", + CBM_LANG_PYTHON); + cbm_registry_add_lang(r, "reducer_worker", "proj.other.reducer_worker", "Function", + CBM_LANG_PYTHON); + cbm_registry_add_lang(r, "stat_child", "proj.tests.cov.stat_child", "Function", CBM_LANG_C); + + cbm_resolution_t res = + cbm_registry_resolve(r, "reducer_worker", "proj.tests.cov", NULL, NULL, 0); + ASSERT_STR_EQ(res.strategy, "same_module"); + ASSERT_TRUE(cbm_registry_name_guess_vetoed(r, CBM_LANG_C, &res)); + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_PYTHON, &res)); + + res = cbm_registry_resolve(r, "stat_child", "proj.tests.cov", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.tests.cov.stat_child"); + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_C, &res)); + cbm_registry_free(r); + PASS(); +} + TEST(cross_language_suffix_match_drops_py_vs_js) { /* #725: two same-named symbols in different languages. suffix_match is the * strategy that collapses them; unique_name is #1572 and must stay. */ @@ -1473,6 +1580,9 @@ SUITE(registry) { RUN_TEST(perl_suppress_drops_weak_builtin_and_method_matches); RUN_TEST(perl_suppress_keeps_high_confidence_and_genuine_calls); RUN_TEST(cross_language_suffix_match_drops_py_vs_js); + RUN_TEST(lang_resolution_compatible_families); + RUN_TEST(name_guess_vetoed_across_languages); + RUN_TEST(same_module_vetoed_across_languages); RUN_TEST(cross_language_config_caller_drops_unique_name_too); RUN_TEST(registry_tie_break_is_independent_of_registration_order); RUN_TEST(cross_language_ref_drops_go_vs_c); diff --git a/tests/test_spawns.c b/tests/test_spawns.c new file mode 100644 index 000000000..c42b31a3e --- /dev/null +++ b/tests/test_spawns.c @@ -0,0 +1,376 @@ +/* + * test_spawns.c — SPAWNS edges: a call that starts another program, to the + * Process node of that program (internal/cbm/spawn_patterns.h, + * src/pipeline/pass_spawns.c). + * + * Unit tests pin how a call is recognised (spellings, the file's imports, + * bare names only when nothing in the project answers) and which program it + * starts (a literal, a collection's first element, never an argument after a + * variable program). Pipeline tests pin the edges in an indexed repo and that + * the sequential and parallel resolvers emit the same ones. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include "spawn_patterns.h" + +#include +#include +#include +#include + +/* ── Recognition ─────────────────────────────────────────────────── */ + +static const char *sp_api(CBMLanguage lang, const char *callee, const CBMImport *imports, + int count) { + const cbm_spawn_api_t *api = cbm_spawn_match(lang, callee, imports, count); + return api ? api->api : NULL; +} + +TEST(spawn_match_through_python_imports) { + const CBMImport imports[] = {{.local_name = "sp", .module_path = "subprocess"}, + {.local_name = "run", .module_path = "subprocess.run"}, + {.local_name = "os", .module_path = "os"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "sp.check_output", imports, 3), + "subprocess.check_output"); + ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "run", imports, 3), "subprocess.run"); + ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "subprocess.Popen", NULL, 0), "subprocess.Popen"); + ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "os.spawnv", imports, 3), "os.spawnv"); + /* A bare `run` is not subprocess.run without the import that says so. */ + ASSERT_NULL(sp_api(CBM_LANG_PYTHON, "run", NULL, 0)); + ASSERT_NULL(sp_api(CBM_LANG_PYTHON, "system", NULL, 0)); + PASS(); +} + +TEST(spawn_match_through_go_rust_js_imports) { + const CBMImport go[] = {{.local_name = "exec", .module_path = "os/exec"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_GO, "exec.Command", go, 1), "os/exec.Command"); + ASSERT_STR_EQ(sp_api(CBM_LANG_GO, "exec.CommandContext", go, 1), "os/exec.CommandContext"); + ASSERT_NULL(sp_api(CBM_LANG_GO, "exec.Command", NULL, 0)); + + const CBMImport rust[] = {{.local_name = "Command", .module_path = "std::process::Command"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_RUST, "Command::new", rust, 1), "std::process::Command::new"); + ASSERT_NULL(sp_api(CBM_LANG_RUST, "Command::new", NULL, 0)); + + /* `import { spawn } from "node:child_process"`, `const cp = require(..)`, + * and a destructured require that records only the module. */ + const CBMImport named[] = {{.local_name = "spawn", .module_path = "node:child_process"}, + {.local_name = "cp", .module_path = "child_process"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_TYPESCRIPT, "spawn", named, 2), "child_process.spawn"); + ASSERT_STR_EQ(sp_api(CBM_LANG_JAVASCRIPT, "cp.execSync", named, 2), "child_process.execSync"); + const CBMImport destructured[] = { + {.local_name = "child_process", .module_path = "child_process"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_JAVASCRIPT, "execFile", destructured, 1), "execFile"); + ASSERT_NULL(sp_api(CBM_LANG_JAVASCRIPT, "execFile", NULL, 0)); + PASS(); +} + +/* Bare names: libc-style ones by spelling (a definition of the name in the + * calling module still wins, see spawns_in_the_graph), import-gated ones only + * with their import. */ +TEST(spawn_match_bare_names) { + ASSERT_STR_EQ(sp_api(CBM_LANG_C, "system", NULL, 0), "system"); + ASSERT_STR_EQ(sp_api(CBM_LANG_CPP, "std::system", NULL, 0), "std::system"); + ASSERT_STR_EQ(sp_api(CBM_LANG_RUBY, "spawn", NULL, 0), "spawn"); + const CBMImport hs[] = {{.local_name = "Process", .module_path = "System.Process"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_HASKELL, "callProcess", hs, 1), "callProcess"); + ASSERT_NULL(sp_api(CBM_LANG_HASKELL, "callProcess", NULL, 0)); + ASSERT_NULL(sp_api(CBM_LANG_GO, "system", NULL, 0)); + ASSERT_NULL(sp_api(CBM_LANG_C, "systemd_notify", NULL, 0)); + PASS(); +} + +/* ── The program ─────────────────────────────────────────────────── */ + +/* Program of `callee(arg0, arg1)` as the extractor records it. */ +static const char *sp_program(CBMLanguage lang, const char *callee, const char *arg0, + const char *arg1, const CBMImport *imports, int count, char *buf) { + CBMCallArg args[2] = {{.expr = arg0, .index = 0}, {.expr = arg1, .index = 1}}; + CBMCall call = {.callee_name = callee, .args = args, .arg_count = arg1 ? 2 : (arg0 ? 1 : 0)}; + const cbm_spawn_api_t *api = cbm_spawn_match(lang, callee, imports, count); + if (!api) { + return "(no match)"; + } + cbm_spawn_program(api, lang, &call, buf, CBM_SPAWN_PROGRAM_MAX); + return buf; +} + +TEST(spawn_program_rules) { + char b[CBM_SPAWN_PROGRAM_MAX]; + const CBMLanguage py = CBM_LANG_PYTHON; + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "[\"git\", \"status\"]", NULL, NULL, 0, b), + "git"); + /* The program is the variable; the arguments after it are not. */ + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "[binary, \"--version\"]", NULL, NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ(sp_program(py, "subprocess.Popen", "record[\"argv\"]", NULL, NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "os.path.join(\"bin\", tool)", NULL, NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "f\"{tool} --x\"", NULL, NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "args=[\"make\", \"all\"]", NULL, NULL, 0, b), + "make"); + ASSERT_STR_EQ(sp_program(py, "os.system", "\"rm -rf build\"", NULL, NULL, 0, b), "rm"); + /* Environment prefixes and directories are not the program. */ + ASSERT_STR_EQ(sp_program(CBM_LANG_C, "popen", "\"LC_ALL=C ps -eo pid\"", "\"r\"", NULL, 0, b), + "ps"); + ASSERT_STR_EQ( + sp_program(CBM_LANG_C, "system", "\"/usr/bin/env FOO=1 make all\"", NULL, NULL, 0, b), + "make"); + /* posix_spawnp names the program second; CreateProcess in its command + * line when the application name is NULL. */ + ASSERT_STR_EQ(sp_program(CBM_LANG_C, "posix_spawnp", "&pid", "\"git\"", NULL, 0, b), "git"); + ASSERT_STR_EQ(sp_program(CBM_LANG_C, "CreateProcessW", "NULL", "L\"cmd /c dir\"", NULL, 0, b), + "cmd"); + ASSERT_STR_EQ(sp_program(CBM_LANG_C, "execvp", "argv[0]", "argv", NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ( + sp_program(CBM_LANG_KOTLIN, "ProcessBuilder", "listOf(\"git\", \"log\")", NULL, NULL, 0, b), + "git"); + ASSERT_STR_EQ(sp_program(CBM_LANG_SWIFT, "Process.launchedProcess", "launchPath: \"/bin/ls\"", + "arguments: []", NULL, 0, b), + "ls"); + /* Options tuples / structs: the program is the literal inside. */ + ASSERT_STR_EQ(sp_program(CBM_LANG_ELIXIR, "Port.open", "{:spawn, \"cat\"}", "[]", NULL, 0, b), + "cat"); + /* Groovy: the receiver is the command line. */ + ASSERT_STR_EQ(sp_program(CBM_LANG_GROOVY, "\"git status\".execute", NULL, NULL, NULL, 0, b), + "git"); + PASS(); +} + +/* ── In the graph ────────────────────────────────────────────────── */ + +typedef struct { + const char *name; + const char *content; +} sp_file_t; + +typedef struct { + char tmpdir[256]; + char dbpath[1024]; + char *project; + cbm_mcp_server_t *srv; + cbm_store_t *store; +} sp_proj_t; + +static bool sp_write(const char *root, const char *name, const char *content) { + char path[700]; + snprintf(path, sizeof(path), "%s/%s", root, name); + char *slash = strrchr(path, '/'); + if (slash && slash > path + strlen(root)) { + *slash = '\0'; + cbm_mkdir_p(path, 0755); + *slash = '/'; + } + FILE *f = cbm_fopen(path, "wb"); + if (!f) { + return false; + } + bool ok = fputs(content, f) >= 0; + return fclose(f) == 0 && ok; +} + +/* Index `files`, plus `pad` trivial Python files (enough of them moves the + * index onto the parallel resolver, MIN_FILES_FOR_PARALLEL). */ +static bool sp_index(sp_proj_t *p, const sp_file_t *files, int count, int pad) { + memset(p, 0, sizeof(*p)); + snprintf(p->tmpdir, sizeof(p->tmpdir), "%s", th_mktempdir("cbm-sp")); + if (!p->tmpdir[0]) { + return false; + } + for (int i = 0; i < count; i++) { + if (!sp_write(p->tmpdir, files[i].name, files[i].content)) { + return false; + } + } + for (int i = 0; i < pad; i++) { + char name[64]; + char body[96]; + snprintf(name, sizeof(name), "pad/p%d.py", i); + snprintf(body, sizeof(body), "def pad_%d():\n return %d\n", i, i); + if (!sp_write(p->tmpdir, name, body)) { + return false; + } + } + p->project = cbm_project_name_from_path(p->tmpdir); + if (!p->project) { + return false; + } + char cache[700]; + const char *configured = getenv("CBM_CACHE_DIR"); + const char *home = getenv("HOME"); + if (configured && configured[0]) { + snprintf(cache, sizeof(cache), "%s", configured); + } else { + snprintf(cache, sizeof(cache), "%s/.cache/codebase-memory-mcp", home ? home : "/tmp"); + } + cbm_mkdir_p(cache, 0755); + snprintf(p->dbpath, sizeof(p->dbpath), "%s/%s.db", cache, p->project); + unlink(p->dbpath); + p->srv = cbm_mcp_server_new(NULL); + if (!p->srv) { + return false; + } + char args[800]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", p->tmpdir); + free(cbm_mcp_handle_tool(p->srv, "index_repository", args)); + p->store = cbm_store_open_path(p->dbpath); + return p->store != NULL; +} + +static void sp_cleanup(sp_proj_t *p) { + if (p->store) { + cbm_store_close(p->store); + } + if (p->srv) { + cbm_mcp_server_free(p->srv); + } + if (p->dbpath[0]) { + char side[1100]; + unlink(p->dbpath); + snprintf(side, sizeof(side), "%s-wal", p->dbpath); + unlink(side); + snprintf(side, sizeof(side), "%s-shm", p->dbpath); + unlink(side); + } + free(p->project); + if (p->tmpdir[0]) { + th_rmtree(p->tmpdir); + } + memset(p, 0, sizeof(*p)); +} + +static int sp_count(sp_proj_t *p, const char *sql, const char *a, const char *b) { + sqlite3 *db = cbm_store_get_db(p->store); + sqlite3_stmt *stmt = NULL; + if (!db || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + sqlite3_finalize(stmt); + return -1; + } + int n = sqlite3_bind_parameter_count(stmt); + const char *values[] = {p->project, a, b}; + for (int i = 0; i < n && i < 3; i++) { + sqlite3_bind_text(stmt, i + 1, values[i], -1, SQLITE_TRANSIENT); + } + int count = sqlite3_step(stmt) == SQLITE_ROW ? sqlite3_column_int(stmt, 0) : -1; + sqlite3_finalize(stmt); + return count; +} + +/* SPAWNS edges from the definition named `caller` to the Process `program`. */ +static int sp_spawns(sp_proj_t *p, const char *caller, const char *program) { + return sp_count(p, + "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id " + "JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='SPAWNS' " + "AND s.name=?2 AND t.name=?3 AND t.label='Process'", + caller, program); +} + +static int sp_calls(sp_proj_t *p, const char *caller, const char *callee) { + return sp_count(p, + "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id " + "JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='CALLS' " + "AND s.name=?2 AND t.name=?3", + caller, callee); +} + +/* Every SPAWNS edge as "caller>program|api" lines, sorted, into `out`. */ +static bool sp_edge_set(sp_proj_t *p, char *out, size_t n) { + sqlite3 *db = cbm_store_get_db(p->store); + sqlite3_stmt *stmt = NULL; + const char *sql = "SELECT s.name || '>' || t.name || '|' || json_extract(e.properties,'$.api') " + "FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON " + "t.id=e.target_id WHERE e.project=?1 AND e.type='SPAWNS' ORDER BY 1"; + if (!db || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + sqlite3_finalize(stmt); + return false; + } + sqlite3_bind_text(stmt, 1, p->project, -1, SQLITE_TRANSIENT); + out[0] = '\0'; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *row = (const char *)sqlite3_column_text(stmt, 0); + size_t used = strlen(out); + snprintf(out + used, n - used, "%s\n", row ? row : "?"); + } + sqlite3_finalize(stmt); + return true; +} + +static const sp_file_t SP_FILES[] = { + /* subprocess.run inside the module's own `def run`: the resolver's + * suffix guess (same_module) must not make it a call to `run`. */ + {"tools.py", "import subprocess\n\n\ndef run(cmd):\n return subprocess.run(cmd)\n\n\n" + "def build():\n subprocess.check_output([\"make\", \"all\"])\n"}, + {"native.c", "#include \nint clean(void) { return system(\"rm -rf build\"); }\n"}, + /* A project-defined `system` answers the bare name: a call, no spawn. */ + {"own.c", "static int system(const char *c) { return c != 0; }\n" + "int use_own(void) { return system(\"x\"); }\n"}, + {"deploy.go", "package main\n\nimport \"os/exec\"\n\n" + "func deploy() { exec.Command(\"kubectl\", \"apply\").Run() }\n"}, + {"launch.js", "const { spawn } = require(\"child_process\");\n" + "function start() { spawn(\"node\", [\"server.js\"]); }\n"}, +}; +#define SP_FILE_COUNT ((int)(sizeof(SP_FILES) / sizeof(SP_FILES[0]))) + +TEST(spawns_in_the_graph) { + sp_proj_t p; + ASSERT_TRUE(sp_index(&p, SP_FILES, SP_FILE_COUNT, 0)); + ASSERT_EQ(sp_spawns(&p, "build", "make"), 1); + ASSERT_EQ(sp_spawns(&p, "run", CBM_SPAWN_DYNAMIC), 1); + ASSERT_EQ(sp_spawns(&p, "clean", "rm"), 1); + ASSERT_EQ(sp_spawns(&p, "deploy", "kubectl"), 1); + ASSERT_EQ(sp_spawns(&p, "start", "node"), 1); + /* The spawn replaces the CALLS edge a guess would have made. */ + ASSERT_EQ(sp_calls(&p, "build", "run"), 0); + /* A project-defined system() is code, not a process. */ + ASSERT_EQ(sp_calls(&p, "use_own", "system"), 1); + ASSERT_EQ(sp_count(&p, + "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id WHERE " + "e.project=?1 AND e.type='SPAWNS' AND s.name=?2", + "use_own", NULL), + 0); + /* One Process node per program, named and qualified by it. */ + ASSERT_EQ(sp_count(&p, + "SELECT COUNT(*) FROM nodes WHERE label='Process' AND name=?2 AND " + "qualified_name='__process__make'", + "make", NULL), + 1); + sp_cleanup(&p); + PASS(); +} + +/* The sequential (few files) and parallel (past MIN_FILES_FOR_PARALLEL) + * resolvers ask at the same point and emit the same SPAWNS edges. */ +TEST(spawns_parallel_matches_sequential) { + static char seq[4096]; + static char par[4096]; + sp_proj_t p; + ASSERT_TRUE(sp_index(&p, SP_FILES, SP_FILE_COUNT, 0)); + ASSERT_TRUE(sp_edge_set(&p, seq, sizeof(seq))); + sp_cleanup(&p); + ASSERT_TRUE(sp_index(&p, SP_FILES, SP_FILE_COUNT, 60)); + ASSERT_TRUE(sp_edge_set(&p, par, sizeof(par))); + sp_cleanup(&p); + ASSERT_TRUE(seq[0] != '\0'); + if (strcmp(seq, par) != 0) { + fprintf(stderr, " sequential:\n%s parallel:\n%s", seq, par); + } + ASSERT_STR_EQ(seq, par); + PASS(); +} + +SUITE(spawns) { + RUN_TEST(spawn_match_through_python_imports); + RUN_TEST(spawn_match_through_go_rust_js_imports); + RUN_TEST(spawn_match_bare_names); + RUN_TEST(spawn_program_rules); + RUN_TEST(spawns_in_the_graph); + RUN_TEST(spawns_parallel_matches_sequential); +} diff --git a/tests/test_test_impact_engine.c b/tests/test_test_impact_engine.c index 8d735340b..02ce23b79 100644 --- a/tests/test_test_impact_engine.c +++ b/tests/test_test_impact_engine.c @@ -122,14 +122,20 @@ static const char tie_lib[] = "int lib_value(void) {\n" " return 2;\n" "}\n"; -static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char *extra); +static bool tie_open_files(tie_fixture_t *fx, const char *const *extra, int extra_count); static bool tie_open(tie_fixture_t *fx) { - return tie_open_with(fx, NULL, NULL); + return tie_open_files(fx, NULL, 0); } /* The fixture, plus one more file in the base commit. */ static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char *extra) { + const char *const files[] = {extra_path, extra}; + return tie_open_files(fx, files, 1); +} + +/* The fixture, plus (path, content) pairs written over it in the base commit. */ +static bool tie_open_files(tie_fixture_t *fx, const char *const *extra, int extra_count) { memset(fx, 0, sizeof(*fx)); const char *git = cbm_find_cli("git", cbm_get_home_dir()); const char *home = th_mktempdir("cbm-ti-engine"); @@ -145,15 +151,18 @@ static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char const char *init[] = {"-c", "init.templateDir=", "init", "--quiet", "--initial-branch=main", NULL}; const char *topic[] = {"checkout", "--quiet", "-b", "topic", NULL}; - return th_mkdir_p(fx->repo) == 0 && th_mkdir_p(fx->work) == 0 && chmod(fx->work, 0700) == 0 && - tie_git(fx, init) && tie_write(fx, ".codebase-memory.json", tie_config) && - tie_write(fx, "tests/test_main.c", tie_main) && - tie_write(fx, "tests/test_alpha.c", tie_alpha) && - tie_write(fx, "tests/test_beta.c", tie_beta) && - tie_write(fx, "tests/test_matrix.c", tie_matrix) && - tie_write(fx, "src/lib.c", tie_lib) && tie_write(fx, "Makefile", "all:\n") && - (!extra_path || tie_write(fx, extra_path, extra)) && tie_commit(fx, "base") && - tie_git(fx, topic); + bool ok = th_mkdir_p(fx->repo) == 0 && th_mkdir_p(fx->work) == 0 && + chmod(fx->work, 0700) == 0 && tie_git(fx, init) && + tie_write(fx, ".codebase-memory.json", tie_config) && + tie_write(fx, "tests/test_main.c", tie_main) && + tie_write(fx, "tests/test_alpha.c", tie_alpha) && + tie_write(fx, "tests/test_beta.c", tie_beta) && + tie_write(fx, "tests/test_matrix.c", tie_matrix) && + tie_write(fx, "src/lib.c", tie_lib) && tie_write(fx, "Makefile", "all:\n"); + for (int i = 0; ok && i < extra_count; i++) { + ok = tie_write(fx, extra[2 * i], extra[2 * i + 1]); + } + return ok && tie_commit(fx, "base") && tie_git(fx, topic); } static void tie_close(tie_fixture_t *fx) { @@ -463,7 +472,96 @@ TEST(test_impact_engine_coverage_reaches_unmapped_code_through_mapped_callers) { PASS(); } +/* One test written once per #if branch is one graph node (graph_buffer.c + * "Definition variants"): the engine maps it as the graph counts it, never as + * an ambiguous name. */ +static const char tie_alpha_variants[] = "int lib_value(void);\n" + "#ifdef ALPHA_WIDE\n" + "TEST(alpha_uses_lib) {\n" + " return lib_value() == 1 ? 0 : 1;\n" + "}\n" + "#else\n" + "TEST(alpha_uses_lib) {\n" + " return lib_value() > 0 ? 0 : 1;\n" + "}\n" + "#endif\n" + "TEST(alpha_plain) {\n" + " return 0;\n" + "}\n" + "SUITE(alpha) {\n" + " RUN_TEST(alpha_uses_lib);\n" + " RUN_TEST(alpha_plain);\n" + "}\n"; + +TEST(test_impact_engine_variant_tests_are_one_test) { + tie_fixture_t fx; + ASSERT_TRUE(tie_open_with(&fx, "tests/test_alpha.c", tie_alpha_variants)); + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1 + 0;\n}\n" + "int lib_other(void) {\n return 2;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_value")); + char *json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + if (!tie_suite_has(json, "alpha", "STATIC") || tie_suite_has(json, "alpha", "UNMAPPED")) { + printf(" %.900s\n", json); + } + ASSERT_TRUE(tie_suite_has(json, "alpha", "STATIC")); + ASSERT_FALSE(tie_suite_has(json, "alpha", "UNMAPPED")); + free(json); + tie_close(&fx); + PASS(); +} + +/* A test that starts a program runs code no CALLS edge leads to; the program + * runs main, and main reaches everything. A change that reaches the product's + * main reaches the tests that spawn; one that does not, does not. */ +static const char tie_spawn_main[] = "int lib_value(void);\n" + "int main(void) {\n" + " return lib_value();\n" + "}\n"; +static const char tie_spawn_beta[] = "#include \n" + "TEST(beta_alone) {\n" + " return system(\"./app --version\");\n" + "}\n" + "SUITE(beta) {\n" + " RUN_TEST(beta_alone);\n" + "}\n"; + +TEST(test_impact_engine_spawning_tests_follow_main) { + const char *const extra[] = {"src/main.c", tie_spawn_main, "tests/test_beta.c", tie_spawn_beta}; + tie_fixture_t fx; + ASSERT_TRUE(tie_open_files(&fx, extra, 2)); + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1 + 0;\n}\n" + "int lib_other(void) {\n return 2;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_value")); + char *json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + if (!tie_suite_has(json, "beta", "STATIC")) { + printf(" %.900s\n", json); + } + ASSERT_TRUE(tie_suite_has(json, "alpha", "STATIC")); + ASSERT_TRUE(tie_suite_has(json, "beta", "STATIC")); + ASSERT_FALSE(tie_suite_has(json, "matrix", "STATIC")); + free(json); + + /* lib_other: main never calls it, so the spawning test is not reached. */ + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1;\n}\n" + "int lib_other(void) {\n return 2 + 0;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_other instead")); + json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + ASSERT_TRUE(tie_suite_has(json, "matrix", "STATIC")); + ASSERT_FALSE(tie_suite_has(json, "beta", "STATIC")); + free(json); + tie_close(&fx); + PASS(); +} + SUITE(test_impact_engine) { + RUN_TEST(test_impact_engine_variant_tests_are_one_test); + RUN_TEST(test_impact_engine_spawning_tests_follow_main); RUN_TEST(test_impact_engine_reached_suites_carry_static); RUN_TEST(test_impact_engine_macro_registered_tests_select_their_suite); RUN_TEST(test_impact_engine_rules_unmapped_and_empty); diff --git a/tests/test_test_impact_seed.c b/tests/test_test_impact_seed.c index 97dd62b73..47f3c9c01 100644 --- a/tests/test_test_impact_seed.c +++ b/tests/test_test_impact_seed.c @@ -286,8 +286,93 @@ TEST(test_impact_seed_unclassified_header_lines_escalate_the_file) { PASS(); } +/* One definition with variants (graph_buffer.c "Definition variants") is one + * node holding one variant's span; the others are in its `variants` + * property, in the same file (#if branches) or in another file that DEFINES + * it too (platform files). A hunk in ANY variant seeds that node. */ +TEST(test_impact_seed_variant_spans_seed_their_definition) { + static const char pick_src[] = "#ifdef _WIN32\nvoid pick(void) {\n beta();\n}\n#else\n" + "void pick(void) {\n alpha();\n}\n#endif\n"; + const char *files[] = {"src/pick.c", pick_src, + "src/plat_linux.c", "void plat(void) {\n alpha();\n}\n", + "src/plat_win.c", "void plat(void) {\n beta();\n}\n"}; + tsd_fixture_t f; + ASSERT_TRUE(tsd_open(&f, files, 6)); + cbm_node_t pick = {.project = "p", + .label = "Function", + .name = "pick", + .qualified_name = "p.src.pick.pick", + .file_path = "src/pick.c", + .start_line = 2, + .end_line = 4, + .properties_json = "{\"variants\":[" + "{\"file_path\":\"src/pick.c\",\"start_line\":2," + "\"end_line\":4}," + "{\"file_path\":\"src/pick.c\",\"start_line\":6," + "\"end_line\":8}]}"}; + int64_t pick_id = cbm_store_upsert_node(f.store, &pick); + cbm_node_t plat = {.project = "p", + .label = "Function", + .name = "plat", + .qualified_name = "p.src.plat.plat", + .file_path = "src/plat_linux.c", + .start_line = 1, + .end_line = 3, + .properties_json = "{\"variants\":[" + "{\"file_path\":\"src/plat_linux.c\",\"start_line\":1," + "\"end_line\":3}," + "{\"file_path\":\"src/plat_win.c\",\"start_line\":1," + "\"end_line\":3}]}"}; + int64_t plat_id = cbm_store_upsert_node(f.store, &plat); + cbm_node_t win_file = {.project = "p", + .label = "File", + .name = "plat_win.c", + .qualified_name = "p.src.plat_win.c.__file__", + .file_path = "src/plat_win.c"}; + int64_t win_file_id = cbm_store_upsert_node(f.store, &win_file); + cbm_edge_t defines = { + .project = "p", .source_id = win_file_id, .target_id = plat_id, .type = "DEFINES"}; + ASSERT_TRUE(pick_id > 0 && plat_id > 0 && win_file_id > 0); + ASSERT_TRUE(cbm_store_insert_edge(f.store, &defines) > 0); + + /* The #else branch (lines 6-8). */ + static const char *const added[] = {" alpha2();"}; + static const char *const removed[] = {" alpha();"}; + cbm_diff_hunk_t branch = {.start = 7, + .count = 1, + .added = added, + .added_count = 1, + .removed = removed, + .removed_count = 1}; + cbm_ti_change_t change = {.path = "src/pick.c", .hunks = &branch, .hunk_count = 1}; + cbm_ti_seeds_t *s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 1); + ASSERT_TRUE(tsd_seeded(s, pick_id)); + cbm_ti_seeds_free(s); + + /* The Windows platform file, whose variant lives on plat_linux.c's node. */ + static const char *const win_added[] = {" beta2();"}; + static const char *const win_removed[] = {" beta();"}; + cbm_diff_hunk_t win = {.start = 2, + .count = 1, + .added = win_added, + .added_count = 1, + .removed = win_removed, + .removed_count = 1}; + cbm_ti_change_t win_change = {.path = "src/plat_win.c", .hunks = &win, .hunk_count = 1}; + s = tsd_seed(&f, &win_change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 1); + ASSERT_TRUE(tsd_seeded(s, plat_id)); + cbm_ti_seeds_free(s); + tsd_close(&f); + PASS(); +} + SUITE(test_impact_seed) { RUN_TEST(test_impact_seed_hunks_follow_the_reference); + RUN_TEST(test_impact_seed_variant_spans_seed_their_definition); RUN_TEST(test_impact_seed_changed_cases_and_suites_are_reported); RUN_TEST(test_impact_seed_deleted_names_are_found_by_text); RUN_TEST(test_impact_seed_header_names_propagate_through_includes_and_macros);