From 446dd8dcae155da4c0d6c69dfc198abed1507341 Mon Sep 17 00:00:00 2001 From: Tony Thayer-Osborne <962968+tthayer@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:18:48 -0700 Subject: [PATCH] feat(go): emit BINDS edges for google/wire wire.Bind wire.Bind(new(I), new(T)) records that a provider set or injector binds interface I to concrete T. That fact lives only in the injector source, so the graph could not say which provider set selects which implementation. For Go files that import github.com/google/wire (aliases honored, dot and blank imports skipped), each .Bind call whose two args are new([*]Ident) or new([*]pkg.Ident) emits a BINDS edge: - source: the innermost package-level Variable containing the call (var Set = wire.NewSet(...)), else the enclosing Function (injector) - target: T, resolved exactly to a type-like node via the file's package or import map; no edge when T is not in the graph - props: {"strategy":"wire_bind","interfaces":[QN...], "unresolved_interfaces":[raw...]}, sorted and deduplicated All interfaces bound to the same (source, target) share one edge, because edges dedupe on (source, target, type). Emitted next to the env CONFIGURES helper at the sequential, parallel and incremental sites. Refs #2444 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Tony Thayer-Osborne <962968+tthayer@users.noreply.github.com> --- README.md | 3 +- src/pipeline/pass_definitions.c | 329 +++++++++++++++++++++++++++++++ src/pipeline/pass_parallel.c | 1 + src/pipeline/pipeline_internal.h | 5 + tests/test_convergence_probe.c | 3 +- tests/test_edge_structural.c | 3 +- tests/test_lang_contract.c | 3 +- tests/test_pipeline.c | 236 ++++++++++++++++++++++ 8 files changed, 579 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9c82c18aed..0b02a5329d 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ The install script placed beside the binary is **reported, not deleted** — uni - `IMPORTS`, `DEFINES`, `IMPLEMENTS`, `INHERITS` - `HTTP_CALLS`, `ASYNC_CALLS` (cross-service) - `EMITS`, `LISTENS_ON` (channels) +- `BINDS` (Go google/wire `wire.Bind(new(I), new(T))`: provider set or injector → concrete type, exact matches only) - `DATA_FLOWS` with arg-to-param mapping + field access chains - `SIMILAR_TO` (MinHash + LSH near-clone detection, Jaccard scored) - `SEMANTICALLY_RELATED` (vocabulary-mismatch, same-language, score ≥ 0.80) @@ -704,7 +705,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool ### 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`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `BINDS`, `REFERENCES_FILE`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH` ### Qualified Names diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 7c7ed68847..7523f0079a 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -26,10 +26,14 @@ enum { PD_JSON_FIELD_OVERHEAD = 6 }; #include "foundation/compat_fs.h" #include "foundation/limits.h" #include "foundation/str_util.h" +#include "foundation/dyn_array.h" +#include "foundation/mem_core.h" #include "cbm.h" +#include "discover/discover.h" #include "arena.h" #include "iris_export_xml.h" #include "simhash/minhash.h" +#include "yyjson/yyjson.h" #include "semantic/ast_profile.h" #include @@ -447,6 +451,329 @@ int cbm_pipeline_create_env_configures_for_file(cbm_pipeline_ctx_t *ctx, return count; } +/* ── google/wire BINDS edges ───────────────────────────────────────── + * `wire.Bind(new(I), new(*T))` declares that provider-set/injector code binds + * interface I to concrete T. We emit (Variable-or-Function) -BINDS-> T only + * when T resolves EXACTLY to a type-like node; nothing is ever invented. */ +#define WIRE_IMPORT_PATH "github.com/google/wire" +enum { + WIRE_QUOTE_PAIR = 2, /* surrounding quote characters of an import path */ + WIRE_SKIP_ONE = 1, /* one character or element */ + WIRE_BIND_ARGS = 2, /* wire.Bind(iface, impl) */ + WIRE_IMPL_ARG = 1, /* index of the concrete-type argument */ +}; + +/* Local name of the google/wire import, or NULL (absent, dot or blank). */ +static const char *wire_import_local_name(const CBMFileResult *result) { + for (int i = 0; i < result->imports.count; i++) { + const CBMImport *imp = &result->imports.items[i]; + if (!imp->module_path || !imp->local_name || !imp->local_name[0]) { + continue; + } + const char *mp = imp->module_path; + size_t len = strlen(mp); + if (len >= WIRE_QUOTE_PAIR && (mp[0] == '"' || mp[0] == '`')) { + mp++; + len -= WIRE_QUOTE_PAIR; + } + if (len != strlen(WIRE_IMPORT_PATH) || strncmp(mp, WIRE_IMPORT_PATH, len) != 0) { + continue; + } + if (strcmp(imp->local_name, ".") == 0 || strcmp(imp->local_name, "_") == 0) { + return NULL; + } + return imp->local_name; + } + return NULL; +} + +static bool wire_is_ident(const char *s, size_t len) { + if (len == 0 || isdigit((unsigned char)s[0])) { + return false; + } + for (size_t i = 0; i < len; i++) { + if (!isalnum((unsigned char)s[i]) && s[i] != '_') { + return false; + } + } + return true; +} + +/* Parse `new(T)`, `new(*T)`, `new(pkg.T)`, `new(*pkg.T)` into out ("T" or + * "pkg.T"). Anything else (generics, parens, extra tokens) is rejected. */ +static bool wire_parse_new_arg(const char *expr, char *out, size_t out_sz) { + static const char open[] = "new("; + if (!expr) { + return false; + } + while (isspace((unsigned char)*expr)) { + expr++; + } + if (strncmp(expr, open, strlen(open)) != 0) { + return false; + } + expr += strlen(open); + const char *end = expr + strlen(expr); + while (end > expr && isspace((unsigned char)end[-WIRE_SKIP_ONE])) { + end--; + } + if (end == expr || end[-WIRE_SKIP_ONE] != ')') { + return false; + } + end--; + while (expr < end && isspace((unsigned char)*expr)) { + expr++; + } + if (expr < end && *expr == '*') { + expr++; + } + while (end > expr && isspace((unsigned char)end[-WIRE_SKIP_ONE])) { + end--; + } + size_t len = (size_t)(end - expr); + const char *dot = memchr(expr, '.', len); + size_t head = dot ? (size_t)(dot - expr) : len; + if (!wire_is_ident(expr, head) || + (dot && !wire_is_ident(dot + WIRE_SKIP_ONE, len - head - WIRE_SKIP_ONE)) || len >= out_sz) { + return false; + } + memcpy(out, expr, len); + out[len] = '\0'; + return true; +} + +/* Resolve "T" (same package) or "pkg.T" (via the file's imports) to an exact + * type-like node. Go packages are directory modules: QN = .. */ +static const cbm_gbuf_node_t *wire_resolve_type(cbm_pipeline_ctx_t *ctx, + const CBMFileResult *result, const char *rel, + const char *name) { + const char *type_name = name; + const char *dot = strchr(name, '.'); + const char *mod = NULL; + if (!dot) { + mod = result->module_qn; /* Go: the package directory module */ + } else { + type_name = dot + WIRE_SKIP_ONE; + char mod_buf[CBM_SZ_512]; + snprintf(mod_buf, sizeof(mod_buf), "%.*s", (int)(dot - name), name); + for (int i = 0; i < result->imports.count; i++) { + const CBMImport *imp = &result->imports.items[i]; + if (imp->local_name && strcmp(imp->local_name, mod_buf) == 0) { + const cbm_gbuf_node_t *pkg = + cbm_pipeline_resolve_import_node(ctx, rel, NULL, imp, NULL); + mod = pkg ? pkg->qualified_name : NULL; + break; + } + } + } + const cbm_gbuf_node_t *node = NULL; + if (mod && mod[0]) { + char qn[CBM_SZ_1K]; + snprintf(qn, sizeof(qn), "%s.%s", mod, type_name); + node = cbm_gbuf_find_by_qn(ctx->gbuf, qn); + if (node && !cbm_label_is_type_like(node->label)) { + node = NULL; + } + } + return node; +} + +/* Source node: the innermost package-level Variable whose span contains the + * call (provider-set shape), else the enclosing Function/Method (injector). */ +static const cbm_gbuf_node_t *wire_bind_source(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, + const CBMCall *call) { + const CBMDefinition *best = NULL; + for (int i = 0; i < result->defs.count; i++) { + const CBMDefinition *d = &result->defs.items[i]; + if (!d->label || strcmp(d->label, "Variable") != 0 || !d->qualified_name || + (int)d->start_line > call->start_line || (int)d->end_line < call->start_line) { + continue; + } + if (!best || (d->end_line - d->start_line) < (best->end_line - best->start_line)) { + best = d; + } + } + const cbm_gbuf_node_t *var = best ? cbm_gbuf_find_by_qn(ctx->gbuf, best->qualified_name) : NULL; + if (var) { + return var; + } + const cbm_gbuf_node_t *fn = (call->enclosing_func_qn && call->enclosing_func_qn[0]) + ? cbm_gbuf_find_by_qn(ctx->gbuf, call->enclosing_func_qn) + : NULL; + if (fn && fn->label && + (strcmp(fn->label, "Function") == 0 || strcmp(fn->label, "Method") == 0)) { + return fn; + } + return NULL; +} + +/* One accepted wire.Bind: source -> concrete type, plus the interface as a + * resolved graph QN or the raw as-written name. Collected per file so all + * interfaces bound to the same (src, dst) land in ONE edge (the graph buffer + * dedups on src/dst/type and would otherwise keep only one interface). */ +typedef struct { + int64_t src; + int64_t dst; + char *name; /* owned: resolved QN, or raw name when !resolved */ + bool resolved; +} wire_bind_rec_t; +typedef CBM_DYN_ARRAY(wire_bind_rec_t) wire_bind_list_t; + +static void wire_bind_list_free(wire_bind_list_t *list) { + for (int i = 0; i < list->count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, list->items[i].name); + } + cbm_da_free(list); +} + +/* Collect one wire.Bind call (skipped silently unless both args are `new(T)`, + * the concrete type resolves exactly, and a source node exists). */ +static void wire_collect_bind(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, const char *rel, + const CBMCall *call, wire_bind_list_t *out) { + char iface[CBM_SZ_256]; + char impl[CBM_SZ_256]; + if (!wire_parse_new_arg(call->args[0].expr, iface, sizeof(iface)) || + !wire_parse_new_arg(call->args[WIRE_IMPL_ARG].expr, impl, sizeof(impl))) { + return; + } + const cbm_gbuf_node_t *dst = wire_resolve_type(ctx, result, rel, impl); + const cbm_gbuf_node_t *src = dst ? wire_bind_source(ctx, result, call) : NULL; + if (!src) { + return; + } + const cbm_gbuf_node_t *inode = wire_resolve_type(ctx, result, rel, iface); + char *name = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, inode ? inode->qualified_name : iface); + if (!name) { + return; + } + wire_bind_rec_t rec = {.src = src->id, .dst = dst->id, .name = name, .resolved = inode != NULL}; + int before = out->count; + cbm_da_push(out, rec); + if (out->count == before) { + cbm_free(CBM_MEM_CLASS_OTHER, name); + } +} + +static int wire_cmp_id(int64_t a, int64_t b) { + return (a > b) - (a < b); +} + +/* Order: src, dst, resolved before unresolved, then name. */ +static int wire_bind_rec_cmp(const void *a, const void *b) { + const wire_bind_rec_t *x = a; + const wire_bind_rec_t *y = b; + int c = wire_cmp_id(x->src, y->src); + if (c == 0) { + c = wire_cmp_id(x->dst, y->dst); + } + if (c == 0) { + c = (int)y->resolved - (int)x->resolved; + } + return c != 0 ? c : strcmp(x->name, y->name); +} + +/* Add the names of group [from,to) whose resolved flag equals `resolved` (the + * list is pre-sorted) to a JSON array, skipping duplicates. */ +static void wire_add_names(yyjson_mut_doc *doc, yyjson_mut_val *arr, const wire_bind_rec_t *recs, + int from, int to, bool resolved) { + const char *prev = NULL; + for (int i = from; i < to; i++) { + if (recs[i].resolved == resolved && !(prev && strcmp(prev, recs[i].name) == 0)) { + yyjson_mut_arr_add_strcpy(doc, arr, recs[i].name); + prev = recs[i].name; + } + } +} + +/* yyjson allocator on the tracked heap, so the props buffer is cbm_free'd. */ +static void *wire_alc_malloc(void *ctx, size_t size) { + (void)ctx; + return cbm_alloc(CBM_MEM_CLASS_OTHER, size); +} +static void *wire_alc_realloc(void *ctx, void *ptr, size_t old_size, size_t size) { + (void)ctx; + (void)old_size; + return cbm_realloc(CBM_MEM_CLASS_OTHER, ptr, size); +} +static void wire_alc_free(void *ctx, void *ptr) { + (void)ctx; + cbm_free(CBM_MEM_CLASS_OTHER, ptr); +} +static const yyjson_alc WIRE_ALC = {wire_alc_malloc, wire_alc_realloc, wire_alc_free, NULL}; + +/* Emit one BINDS edge for group [from,to) of the sorted list. */ +static bool wire_emit_group(cbm_pipeline_ctx_t *ctx, const wire_bind_rec_t *recs, int from, + int to) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(&WIRE_ALC); + if (!doc) { + return false; + } + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_str(doc, root, "strategy", "wire_bind"); + yyjson_mut_val *ifaces = yyjson_mut_arr(doc); + yyjson_mut_val *unres = yyjson_mut_arr(doc); + yyjson_mut_obj_add_val(doc, root, "interfaces", ifaces); + yyjson_mut_obj_add_val(doc, root, "unresolved_interfaces", unres); + wire_add_names(doc, ifaces, recs, from, to, true); + wire_add_names(doc, unres, recs, from, to, false); + char *json = yyjson_mut_write_opts(doc, 0, &WIRE_ALC, NULL, NULL); + yyjson_mut_doc_free(doc); + if (!json) { + return false; + } + cbm_gbuf_insert_edge(ctx->gbuf, recs[from].src, recs[from].dst, "BINDS", json); + cbm_free(CBM_MEM_CLASS_OTHER, json); + return true; +} + +/* Sort the collected binds and emit one edge per (src,dst); returns edges. */ +static int wire_emit_groups(cbm_pipeline_ctx_t *ctx, wire_bind_list_t *list) { + qsort(list->items, (size_t)list->count, sizeof(*list->items), wire_bind_rec_cmp); + int edges = 0; + int from = 0; + while (from < list->count) { + int to = from + WIRE_SKIP_ONE; + while (to < list->count && list->items[to].src == list->items[from].src && + list->items[to].dst == list->items[from].dst) { + to++; + } + edges += (int)wire_emit_group(ctx, list->items, from, to); + from = to; + } + return edges; +} + +/* Create BINDS edges for one Go file's google/wire `wire.Bind(new(I), new(T))` + * calls. Called at every site that calls the env-CONFIGURES helper so the + * sequential, parallel and incremental paths produce identical edges. All + * interfaces bound to the same (source, concrete) pair share one edge, since + * the graph buffer dedups on (src, dst, type). */ +int cbm_pipeline_create_wire_binds_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, + const char *rel) { + if (!ctx || !result || !rel || result->calls.count == 0 || + cbm_language_for_filename(rel) != CBM_LANG_GO) { + return 0; + } + const char *wire = wire_import_local_name(result); + if (!wire) { + return 0; + } + char callee[CBM_SZ_128]; + snprintf(callee, sizeof(callee), "%s.Bind", wire); + wire_bind_list_t list = {0}; + for (int i = 0; i < result->calls.count; i++) { + const CBMCall *call = &result->calls.items[i]; + if (call->callee_name && strcmp(call->callee_name, callee) == 0 && + call->arg_count == WIRE_BIND_ARGS && call->args) { + wire_collect_bind(ctx, result, rel, call, &list); + } + } + int edges = wire_emit_groups(ctx, &list); + wire_bind_list_free(&list); + return edges; +} + /* Create IMPORTS edges for one file's imports. Mirrors the resolution * logic in pass_parallel.c register_and_link_def — keep the two in sync. */ static int create_import_edges_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, @@ -877,6 +1204,7 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t total_imports += create_import_edges_for_file(ctx, result, rel, NULL); create_channel_edges_for_file(ctx, result, rel); cbm_pipeline_create_env_configures_for_file(ctx, result, rel); + cbm_pipeline_create_wire_binds_for_file(ctx, result, rel); cbm_free_result(result); } } @@ -910,6 +1238,7 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t create_import_edges_for_file(ctx, result, files[i].rel_path, namespace_map); create_channel_edges_for_file(ctx, result, files[i].rel_path); cbm_pipeline_create_env_configures_for_file(ctx, result, files[i].rel_path); + cbm_pipeline_create_wire_binds_for_file(ctx, result, files[i].rel_path); } cbm_pipeline_namespace_map_free(namespace_map); if (owns_local_cache) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f1..f15280ca2e 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1748,6 +1748,7 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t imports_edges += create_imports_edges(ctx, result, rel, namespace_map); create_channel_edges(ctx, result, rel); cbm_pipeline_create_env_configures_for_file(ctx, result, rel); + cbm_pipeline_create_wire_binds_for_file(ctx, result, rel); if (loaded) { cbm_free_result(result); } diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index e7782adeff..d54b39add2 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -183,6 +183,11 @@ CBMFileResult *cbm_pipeline_extract_objectscript_export( int cbm_pipeline_create_env_configures_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, const char *rel); +/* Materialize BINDS edges for google/wire `wire.Bind(new(I), new(T))` calls in one Go + * file. Called alongside the env-CONFIGURES helper at every site. */ +int cbm_pipeline_create_wire_binds_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, + const char *rel); + static inline int cbm_pipeline_relpath_is_excluded(const char *rel_path, char *const *excluded_dirs, int excluded_count) { if (!rel_path || rel_path[0] == '\0' || !excluded_dirs || excluded_count <= 0) { diff --git a/tests/test_convergence_probe.c b/tests/test_convergence_probe.c index 1d63be1b2b..c7bcc34a63 100644 --- a/tests/test_convergence_probe.c +++ b/tests/test_convergence_probe.c @@ -275,7 +275,8 @@ static int cp_callables(cbm_store_t *store, const char *project) { } /* Diagnostic edge histogram (stderr only, so test output stays clean). */ -static const char *CP_ALL_EDGE_TYPES[] = {"CALLS", +static const char *CP_ALL_EDGE_TYPES[] = {"BINDS", + "CALLS", "CALL_REFERENCE", "CONFIGURES", "CONTAINS_FILE", diff --git a/tests/test_edge_structural.c b/tests/test_edge_structural.c index 798fef3d08..b06e0d7c34 100644 --- a/tests/test_edge_structural.c +++ b/tests/test_edge_structural.c @@ -238,7 +238,8 @@ static void es_lang_cleanup(ES_LangProj *lp, cbm_store_t *store) { } /* Every graph edge type the pipeline can emit — used in diagnostic dumps. */ -static const char *ES_ALL_EDGE_TYPES[] = {"CALLS", +static const char *ES_ALL_EDGE_TYPES[] = {"BINDS", + "CALLS", "CALL_REFERENCE", "CONFIGURES", "CONTAINS_FILE", diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index 6eb993564e..72b82251f4 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -1050,7 +1050,8 @@ TEST(contract_calls_breadth) { /* Every graph edge type the pipeline can emit — used to print a histogram * when an edge contract fails, so a regression shows exactly what WAS * produced instead of the missing type. */ -static const char *ALL_EDGE_TYPES[] = {"CALLS", +static const char *ALL_EDGE_TYPES[] = {"BINDS", + "CALLS", "CALL_REFERENCE", "CONFIGURES", "CONTAINS_FILE", diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 568740f170..3c0910e8bb 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -2177,6 +2177,239 @@ TEST(pipeline_env_access_configures_sequential_parallel_parity) { PASS(); } +/* ── google/wire BINDS edges ─────────────────────────────────────── */ + +typedef struct { + const char *rel; + const char *content; +} WireFile; + +typedef struct { + int run_rc; + int total; /* all BINDS edges in the project */ + int props_ok; /* strategy + resolved ObjectWriter in interfaces, none unresolved */ + int named[3]; /* BINDS edge count for each requested (src, dst) pair */ + char props0[512]; /* properties_json of the first BINDS edge */ +} WireObservation; + +typedef struct { + const char *src; + const char *dst; +} WirePair; + +static void wire_write_files(const char *root, const WireFile *files, int n) { + for (int i = 0; i < n; i++) { + write_temp_file(root, files[i].rel, files[i].content); + } +} + +/* Padding files keep the incremental run below the full-reindex threshold. */ +static void wire_write_pad(const char *root, int sign) { + for (int i = 0; i < 50; i++) { + char name[64]; + char src[128]; + snprintf(name, sizeof(name), "pad/wire_pad_%02d.ts", i); + snprintf(src, sizeof(src), "export function wirePad%02d(): number { return %d; }\n", i, + sign * i); + write_temp_file(root, name, src); + } +} + +static void wire_restore_env(const char *name, char *saved) { + if (saved) { + cbm_setenv(name, saved, 1); + free(saved); + } else { + cbm_unsetenv(name); + } +} + +/* Run a pipeline over `repo` (single_thread: sequential, else 4 workers) and + * observe BINDS edges; env vars are restored afterwards. */ +static WireObservation wire_observe(const char *repo, const char *db_name, bool single_thread, + const WirePair *pairs, int npairs) { + WireObservation o = {.run_rc = -1, .total = -1, .named = {-1, -1, -1}}; + char *w = getenv("CBM_WORKERS"); + char *saved_w = w ? strdup(w) : NULL; + char *st = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_st = st ? strdup(st) : NULL; + if (single_thread) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s", repo, db_name); + cbm_pipeline_t *pl = cbm_pipeline_new(repo, db_path, CBM_MODE_FULL); + if (pl) { + o.run_rc = cbm_pipeline_run(pl); + const char *project = cbm_pipeline_project_name(pl); + cbm_store_t *store = cbm_store_open_path(db_path); + cbm_edge_t *edges = NULL; + int n = 0; + if (store && project && + cbm_store_find_edges_by_type(store, project, "BINDS", &edges, &n) == CBM_STORE_OK) { + o.total = n; + o.props_ok = 0; + for (int i = 0; i < n; i++) { + const char *pj = edges[i].properties_json; + o.props_ok += pj && strstr(pj, "\"strategy\":\"wire_bind\"") && + strstr(pj, "ObjectWriter\"],\"unresolved_interfaces\":[]}"); + } + if (n > 0 && edges[0].properties_json) { + snprintf(o.props0, sizeof(o.props0), "%s", edges[0].properties_json); + } + cbm_store_free_edges(edges, n); + for (int i = 0; i < npairs; i++) { + o.named[i] = named_edge_count(store, project, "BINDS", pairs[i].src, pairs[i].dst); + } + } + if (store) { + cbm_store_close(store); + } + cbm_pipeline_free(pl); + } + wire_restore_env("CBM_WORKERS", saved_w); + wire_restore_env("CBM_INDEX_SINGLE_THREAD", saved_st); + return o; +} + +static const char WIRE_TYPES_GO[] = "package store\n\n" + "type ObjectWriter interface{ Write() }\n" + "type LocalWriter struct{}\n" + "type S3Writer struct{}\n" + "func (l *LocalWriter) Write() {}\n" + "func (s *S3Writer) Write() {}\n"; + +static const WireFile WIRE_FIXTURE[] = { + {"store/types.go", WIRE_TYPES_GO}, + {"store/set.go", "package store\n\nimport \"github.com/google/wire\"\n\n" + "func NewLocalWriter() *LocalWriter { return &LocalWriter{} }\n\n" + "var LocalSet = wire.NewSet(\n\tNewLocalWriter,\n" + "\twire.Bind(new(ObjectWriter), new(*LocalWriter)),\n)\n"}, + /* Primary real-world shape: multi-line NewSet, cross-package qualified Bind. */ + {"providers/set.go", "package providers\n\nimport (\n\t\"github.com/google/wire\"\n" + "\t\"example.com/app/store\"\n)\n\n" + "func NewS3() *store.S3Writer { return &store.S3Writer{} }\n\n" + "var RemoteSet = wire.NewSet(\n\tNewS3,\n\twire.Bind(\n" + "\t\tnew(store.ObjectWriter),\n\t\tnew(*store.S3Writer),\n\t),\n)\n"}, + {"cmd/api/wire.go", "//go:build wireinject\n\npackage api\n\n" + "import (\n\t\"github.com/google/wire\"\n\t\"example.com/app/store\"\n)\n\n" + "type Handler struct{}\n\n" + "func InitializeAPI() *Handler {\n\twire.Build(store.LocalSet)\n" + "\treturn nil\n}\n"}, + {"cmd/worker/wire.go", + "//go:build wireinject\n\npackage worker\n\n" + "import (\n\t\"github.com/google/wire\"\n\t\"example.com/app/store\"\n)\n\n" + "type Worker struct{}\n\n" + "func InitializeWorker() *Worker {\n" + "\twire.Build(wire.Bind(new(store.ObjectWriter), new(*store.S3Writer)))\n\treturn nil\n}\n"}, + {"go.mod", "module example.com/app\n\ngo 1.21\n"}, +}; + +/* BINDS must be identical for sequential, parallel and incremental runs. */ +TEST(pipeline_wire_bind_edges_sequential_parallel_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_wire_bind_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + wire_write_files(tmp, WIRE_FIXTURE, (int)(sizeof(WIRE_FIXTURE) / sizeof(*WIRE_FIXTURE))); + wire_write_pad(tmp, 1); + static const WirePair pairs[] = { + {"LocalSet", "LocalWriter"}, {"InitializeWorker", "S3Writer"}, {"RemoteSet", "S3Writer"}}; + WireObservation seq = wire_observe(tmp, "wire-seq.db", true, pairs, 3); + WireObservation par = wire_observe(tmp, "wire-par.db", false, pairs, 3); + /* Incremental: touch the file defining the targets so its nodes are + * re-created while the injector files stay unchanged, plus padding. */ + write_temp_file(tmp, "store/types.go", WIRE_TYPES_GO); + write_temp_file(tmp, "store/extra.go", "package store\n\nfunc Extra() {}\n"); + wire_write_pad(tmp, -1); + WireObservation inc = wire_observe(tmp, "wire-par.db", false, pairs, 3); + th_rmtree(tmp); + + const WireObservation *all[] = {&seq, &par, &inc}; + for (int i = 0; i < 3; i++) { + ASSERT_EQ(all[i]->run_rc, 0); + ASSERT_EQ(all[i]->total, 3); + for (int k = 0; k < 3; k++) { + ASSERT_EQ(all[i]->named[k], 1); + } + ASSERT_EQ(all[i]->props_ok, 3); + } + PASS(); +} + +/* One Variable binding the same concrete type to two interfaces (one in-repo, + * one external, one repeated) must yield exactly ONE BINDS edge that keeps + * every interface: resolved QNs in `interfaces`, raw names in + * `unresolved_interfaces`. */ +TEST(pipeline_wire_bind_multi_interface_single_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_wire_multi_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + const WireFile files[] = { + {"multi/types.go", "package multi\n\ntype Reader interface{ Read() }\ntype T struct{}\n" + "func (t *T) Read() {}\nfunc (t *T) Write(p []byte) {}\n"}, + {"multi/set.go", "package multi\n\nimport (\n\t\"io\"\n\t\"github.com/google/wire\"\n)\n\n" + "var RW = wire.NewSet(\n" + "\twire.Bind(new(io.Writer), new(*T)),\n" + "\twire.Bind(new(Reader), new(*T)),\n" + "\twire.Bind(new(Reader), new(*T)),\n)\n"}, + }; + wire_write_files(tmp, files, 2); + WireObservation o = wire_observe(tmp, "wire-multi.db", true, NULL, 0); + th_rmtree(tmp); + ASSERT_EQ(o.run_rc, 0); + ASSERT_EQ(o.total, 1); + ASSERT_NOT_NULL(strstr(o.props0, "\"strategy\":\"wire_bind\"")); + ASSERT_NOT_NULL(strstr(o.props0, "\"interfaces\":[\"")); + ASSERT_NOT_NULL(strstr(o.props0, ".multi.Reader\"]")); + ASSERT_NOT_NULL(strstr(o.props0, "\"unresolved_interfaces\":[\"io.Writer\"]}")); + PASS(); +} + +/* Negative controls: each must yield no BINDS edge; only `di.Bind` (aliased + * import, correct local name) may emit. */ +TEST(pipeline_wire_bind_negative_controls) { + static const WireFile files[] = { + /* (a) local `wire` value, no google/wire import */ + {"nega/a.go", "package nega\n\ntype A interface{}\ntype B struct{}\n" + "type binder struct{}\nfunc (binder) Bind(x, y any) {}\nvar wire binder\n" + "func NegA() { wire.Bind(new(A), new(*B)) }\n"}, + /* (b) aliased import: `wire.Bind` is the wrong name, `di.Bind` is right */ + {"negb/b.go", "package negb\n\nimport di \"github.com/google/wire\"\n\n" + "type I interface{}\ntype C struct{}\n" + "func NegBWrong() { wire.Bind(new(I), new(*C)) }\n" + "var GoodSet = di.NewSet(di.Bind(new(I), new(*C)))\n"}, + /* (c) non-new args */ + {"negc/c.go", "package negc\n\nimport \"github.com/google/wire\"\n\n" + "type I interface{}\ntype D struct{}\nvar x, y int\n" + "func NegC() { wire.Bind(x, y) }\n" + "func NegC2() { wire.Bind(new(I), new(D), new(D)) }\n" + "func NegC3() { wire.Bind(new(I), new(Gen[D])) }\n"}, + /* (d) concrete type not defined in the repo */ + {"negd/d.go", "package negd\n\nimport \"github.com/google/wire\"\n\ntype I interface{}\n" + "func NegD() { wire.Bind(new(I), new(*Missing)) }\n" + "func NegD2() { wire.Bind(new(I), new(*other.Missing)) }\n"}, + /* (e) non-Go file with the same text; a real Go type E sits in the same + * directory so only the language gate can stop an edge */ + {"nege/types.go", "package nege\n\ntype I interface{}\ntype E struct{}\n"}, + {"nege/e.ts", "import wire from \"github.com/google/wire\";\n" + "export function negE() { return wire.Bind(new(I), new(E)); }\n"}, + }; + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_wire_neg_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + wire_write_files(tmp, files, (int)(sizeof(files) / sizeof(*files))); + WireObservation o = wire_observe(tmp, "wire-neg.db", true, &(WirePair){"GoodSet", "C"}, 1); + th_rmtree(tmp); + + ASSERT_EQ(o.run_rc, 0); + ASSERT_EQ(o.named[0], 1); /* di.Bind emits */ + ASSERT_EQ(o.total, 1); /* every negative control emits nothing */ + PASS(); +} + /* Sequential and fused-parallel resolution must materialize the same precise * CALL_REFERENCE edge set. The table covers each independently implemented * resolver family involved in callable-value/reference semantics; sibling @@ -15870,6 +16103,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_objectscript_export_range_join_keeps_one_trailing_marker); #endif RUN_TEST(pipeline_env_access_configures_sequential_parallel_parity); + RUN_TEST(pipeline_wire_bind_edges_sequential_parallel_incremental); + RUN_TEST(pipeline_wire_bind_negative_controls); + RUN_TEST(pipeline_wire_bind_multi_interface_single_edge); RUN_TEST(pipeline_call_reference_sequential_parallel_edge_set_parity); RUN_TEST(pipeline_complexity_props_independent_of_worker_order); RUN_TEST(pipeline_incremental_cross_file_call_reference_matches_fresh_full);