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);