diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index c99c72d8d..bdf6f5944 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -858,9 +858,38 @@ void cbm_registry_free(cbm_registry_t *r) { /* ── Registration ────────────────────────────────────────────────── */ +/* Record `owned_qn` in the by-name bucket for `key`, keeping the bucket's + * is_test flags in step with its entries. + * + * 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) { + qn_array_t *arr = cbm_ht_get(r->by_name, key); + if (!arr) { + arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t)); + cbm_ht_set(r->by_name, strdup(key), arr); + } + int before = arr->count; + cbm_da_push(arr, (char *)owned_qn); + if (arr->count == before) { + return; /* the name could not be recorded: no verdict to cache */ + } + if (arr->count > arr->is_test_cap) { + int want = arr->cap > 0 ? arr->cap : arr->count; + uint8_t *grown = + 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; + } + } + if (arr->count <= arr->is_test_cap) { + arr->is_test[arr->count - SKIP_ONE] = is_test_qn(owned_qn) ? 1 : 0; + } +} + void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label) { - (void)name; if (!r || !qualified_name || !label) { return; } @@ -893,30 +922,37 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified cbm_ht_set(r->exact, strdup(qualified_name), (void *)interned); const char *owned_qn = cbm_ht_get_key(r->exact, qualified_name); - /* Index by simple name. - * No array dedup needed: exact-map check above guarantees uniqueness. */ - const char *simple = simple_name(qualified_name); - qn_array_t *arr = cbm_ht_get(r->by_name, simple); - if (!arr) { - arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t)); - cbm_ht_set(r->by_name, strdup(simple), arr); - } - int before = arr->count; - cbm_da_push(arr, (char *)owned_qn); - if (arr->count == before) { - return; /* the name could not be recorded: no verdict to cache */ - } - if (arr->count > arr->is_test_cap) { - int want = arr->cap > 0 ? arr->cap : arr->count; - uint8_t *grown = - 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; - } - } - if (arr->count <= arr->is_test_cap) { - arr->is_test[arr->count - SKIP_ONE] = is_test_qn(owned_qn) ? 1 : 0; + /* Index the symbol under the QN's last dot segment, and additionally under + * the name its caller passed when that segment carries a '#' fence. + * + * 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. + * + * Gating on the fence rather than on the file's language is what makes + * "unchanged for every other grammar" checkable instead of asserted. Every + * by-name lookup keys through simple_name(), which splits on '.' and "::" + * only, so a passed name that differs from the derived key by anything + * OTHER than a fence is a key no lookup can reach: an HCL block's + * "resource.aws_instance.web" and a TOML table's "tool.poetry.dependencies" + * are both already indexed under the tail a reference spells, and adding + * their dotted form would only widen the bucket the scorer walks. + * + * `name` is NULL or empty only for callers that have no symbol name to + * give; those have the derived key and nothing else. */ + const char *derived = simple_name(qualified_name); + index_under_name(r, derived, 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 + * 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. */ + if (name && name[0] && strchr(derived, '#') && strcmp(name, derived) != 0) { + index_under_name(r, name, owned_qn); } } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 6ef16a89b..500c71693 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1147,6 +1147,60 @@ TEST(pipeline_nix_scoped_binding_calls_resolve) { PASS(); } +/* Terraform reference resolution, end to end. + * + * An HCL block names itself with its labels appended -- find_hcl_block_name + * mints "resource.aws_instance.web" -- while its QN's last dot segment is + * bare "web", and a reference written `aws_instance.web.id` reaches the + * by-name index through that tail. HCL is therefore a language where the + * definition name and the QN tail are different strings, and an index keyed on + * either one alone drops every cross-resource reference in the file. + * + * This has to be a pipeline test: the registry is shared by every language + * and nothing in the HCL extractor mentions the index key, so the two halves + * can disagree with every extraction-level assertion still green. + */ +TEST(pipeline_hcl_block_reference_resolves_to_its_block) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + char tf_path[512]; + snprintf(tf_path, sizeof(tf_path), "%s/main.tf", g_tmpdir); + FILE *tf = fopen(tf_path, "w"); + if (!tf) { + teardown_test_repo(); + FAIL("failed to write terraform fixture"); + } + fprintf(tf, "resource \"aws_instance\" \"web\" {\n" + " ami = \"ami-0c55b159cbfafe1f0\"\n" + " instance_type = \"t2.micro\"\n" + "}\n" + "\n" + "resource \"aws_eip\" \"ip\" {\n" + " instance = aws_instance.web.id\n" + "}\n"); + fclose(tf); + + char tf_db[512]; + snprintf(tf_db, sizeof(tf_db), "%s/test_hcl_refs.db", g_tmpdir); + + cbm_pipeline_t *tp = cbm_pipeline_new(g_tmpdir, tf_db, CBM_MODE_FULL); + ASSERT_NOT_NULL(tp); + ASSERT_EQ(cbm_pipeline_run(tp), 0); + + cbm_store_t *ts = cbm_store_open_path(tf_db); + ASSERT_NOT_NULL(ts); + const char *tf_project = cbm_pipeline_project_name(tp); + + ASSERT(cross_file_edge_exists(ts, tf_project, "main", "resource.aws_instance.web", "USAGE")); + + cbm_store_close(ts); + cbm_pipeline_free(tp); + teardown_test_repo(); + PASS(); +} + /* Regression: incremental re-index of an edited file must NOT drop inbound * cross-file CALLS edges whose source lives in an UNCHANGED file. * @@ -15129,6 +15183,7 @@ SUITE(pipeline) { /* Calls pass */ RUN_TEST(pipeline_calls_resolution); RUN_TEST(pipeline_nix_scoped_binding_calls_resolve); + RUN_TEST(pipeline_hcl_block_reference_resolves_to_its_block); 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); diff --git a/tests/test_registry.c b/tests/test_registry.c index 5f751a899..f77b1460b 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -325,6 +325,56 @@ TEST(resolve_qualified_ambiguous_tail_falls_through) { PASS(); } +/* cbm_registry_add used to discard its `name` argument and re-derive the + * lookup key from the QN's last dot segment. That made the QN's tail load + * bearing for the bare-name index every language shares: a Rust cfg twin, + * minted as "add#cfg(test)", was indexed under that literal string and no bare + * `add` callee could ever reach it. */ +TEST(registry_indexes_by_passed_name_not_qn_tail) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "add", "proj.lib.add#cfg(test)", "Function"); + + cbm_resolution_t res = cbm_registry_resolve(r, "add", "proj.lib.caller", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.lib.add#cfg(test)"); + + cbm_registry_free(r); + PASS(); +} + +/* The derived key stays unconditional, and this pins that. + * + * A name may carry segments the QN's tail drops, and then the tail is the key + * callers actually spell. An HCL block is named "resource.aws_instance.web" by + * find_hcl_block_name while its QN tail is bare "web", which is how an + * `aws_instance.web.id` reference reaches it. Re-keying the index on the passed + * name would lose that lookup outright, so the fence gate above leaves these + * shapes exactly as they were. + * + * The Module row registers a shape production cannot produce: every caller of + * cbm_registry_add gates on cbm_label_is_registry_symbol, which does not admit + * "Module". It is here because the registry's own contract is per-label-string, + * not per-caller, and a future label change should not silently drop the stem + * lookup a bare module reference needs. */ +TEST(registry_indexes_a_dotted_name_under_its_tail_too) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "resource.aws_instance.web", "proj.main.resource.aws_instance.web", + "Class"); + cbm_registry_add(r, "helper.py", "proj.pkg.helper", "Module"); + + cbm_resolution_t tail = cbm_registry_resolve(r, "web", "proj.main", NULL, NULL, 0); + ASSERT_STR_EQ(tail.qualified_name, "proj.main.resource.aws_instance.web"); + + cbm_resolution_t whole = + cbm_registry_resolve(r, "resource.aws_instance.web", "proj.main", NULL, NULL, 0); + ASSERT_STR_EQ(whole.qualified_name, "proj.main.resource.aws_instance.web"); + + cbm_resolution_t stem = cbm_registry_resolve(r, "helper", "proj.pkg.caller", NULL, NULL, 0); + ASSERT_STR_EQ(stem.qualified_name, "proj.pkg.helper"); + + cbm_registry_free(r); + PASS(); +} + TEST(resolve_import_map) { cbm_registry_t *r = cbm_registry_new(); cbm_registry_add(r, "Process", "proj.pkg.worker.Process", "Function"); @@ -1201,6 +1251,8 @@ SUITE(registry) { RUN_TEST(resolve_same_module); RUN_TEST(resolve_qualified_disambiguates_same_name); RUN_TEST(resolve_qualified_ambiguous_tail_falls_through); + RUN_TEST(registry_indexes_by_passed_name_not_qn_tail); + RUN_TEST(registry_indexes_a_dotted_name_under_its_tail_too); RUN_TEST(resolve_import_map); RUN_TEST(resolve_import_map_bare_function); RUN_TEST(resolve_import_map_bare_alias);