mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Go gives `init` no binding in the package block: init functions cannot
be referred to from anywhere in a program, and a method or field named
init needs a selector. A bare `init` is therefore always a local name -
a closure (`init := func(ctx) {...}; init(ctx)`), a parameter, or the
`package init` clause of a directory named init.
Before #1915 same_module bound such a name to the flat package init.
With per-file init QNs it now falls through to the short-name fallback
and binds unrelated methods named init. On kubernetes that minted 24
CALLS (22 e2e storage DefineTests closures among them), 28 WRITES,
7 USAGE and one derived TESTS edge, all false.
cbm_go_suppress_bare_init_bind refuses, for a bare Go `init`, any
registry binding onto a Function (every Go Function named init is a
package init) and a weak short-name binding onto a Method or Field.
lsp_* answers and selector calls (`w.init()`) are untouched. Wired at
the CALLS, USAGE and READS/WRITES seams of both resolvers.
Kubernetes, full index, main vs branch: only those 60 edges removed,
nodes identical, no resolver edge added; the 14 LSP-typed selector
CALLS into init methods are unchanged.
Tests: pipeline_go_bare_init_binds_nothing (+ parallel twin) are RED
with the predicate disabled (closure CALLS, parameter CALLS, closure
WRITES, package-clause USAGE onto Watcher.init) and GREEN with it; the
selector control keeps Watcher.Start/Boot -> Watcher.init.
go_bare_init_never_binds_init_namesake pins the predicate.
Refs #1915
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2835 lines
125 KiB
C
2835 lines
125 KiB
C
|
||
/*
|
||
* registry.c — Function/Method/Class registry for call resolution.
|
||
*
|
||
* Indexes all callable symbols by qualified name and simple name.
|
||
* Resolution uses a prioritized strategy chain:
|
||
* 1. Import map lookup
|
||
* 2. Same-module match
|
||
* 3. Unique name (single candidate project-wide)
|
||
* 4. Suffix match with import distance scoring
|
||
*/
|
||
#include "foundation/constants.h"
|
||
|
||
enum { REG_INIT_CAP = 16, REG_MIN_CANDIDATES = 3, REG_RESOLVED = 1, REG_SUFFIX_ALLOC = 2 };
|
||
/* Names with more registered definitions than this are unresolvable by name
|
||
* alone: candidate_count_penalty already floors their confidence to ~3/count
|
||
* (<= 0.006 at 256), so the emitted edge is noise — while the candidate walk
|
||
* (reachability + scoring per candidate, re-done per file) is the dominant
|
||
* resolution cost on identifier-dense repos. On the Linux kernel, 274 names
|
||
* exceed 256 candidates ("list_head" 7188, "flags" 5520, "dev" 4374, ...) and
|
||
* accounted for ~900 s of the 987 s usage-resolution CPU. Bail out early. */
|
||
enum { REG_MAX_CANDIDATES = 256 };
|
||
#define REG_FULL_CONF 1.0
|
||
#define REG_HALF_PENALTY 0.5
|
||
|
||
#define DEFAULT_CONFIDENCE 0.5
|
||
#include "pipeline/pipeline.h"
|
||
#include "pipeline/pipeline_internal.h"
|
||
#include "cbm.h" /* cbm_label_is_relation — the resolve-time relation veto */
|
||
#include "foundation/compat.h" /* CBM_TLS */
|
||
#include "foundation/hash_table.h"
|
||
#include "foundation/dyn_array.h"
|
||
#include "foundation/platform.h"
|
||
#include "callable_sig.h" /* cbm_qn_callable_base_len_named: overloads share the name key */
|
||
|
||
#include <ctype.h>
|
||
#include <math.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
|
||
/* Confidence score → human-readable band label. */
|
||
#define CONF_BAND_HIGH 0.7
|
||
#define CONF_BAND_MEDIUM 0.45
|
||
#define CONF_BAND_SPECULATIVE 0.25
|
||
|
||
const char *cbm_confidence_band(double score) {
|
||
if (score >= CONF_BAND_HIGH) {
|
||
return "high";
|
||
}
|
||
if (score >= CONF_BAND_MEDIUM) {
|
||
return "medium";
|
||
}
|
||
if (score >= CONF_BAND_SPECULATIVE) {
|
||
return "speculative";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/* ── Resolution confidence scores ────────────────────────────────── */
|
||
/* Strategy 1: import_map — direct import → high confidence */
|
||
#define CONF_IMPORT_MAP 0.95
|
||
#define CONF_IMPORT_MAP_SUFFIX 0.85
|
||
/* Strategy 2: same_module — same file/package → high confidence */
|
||
#define CONF_SAME_MODULE 0.90
|
||
/* Python lexical scope — a def bound in an enclosing function (LEGB) */
|
||
#define CONF_LEXICAL_SCOPE 0.95
|
||
/* Strategy 3: unique_name — only one candidate project-wide */
|
||
#define CONF_UNIQUE_NAME 0.75
|
||
/* Strategy 4: suffix_match — multiple candidates, filtered */
|
||
#define CONF_SUFFIX_MATCH 0.55
|
||
/* Fuzzy fallback: lower confidence */
|
||
#define CONF_FUZZY_SINGLE 0.40
|
||
#define CONF_FUZZY_MULTI 0.30
|
||
/* Candidate count penalty cap */
|
||
#define CANDIDATE_PENALTY_CAP 3.0
|
||
|
||
/* ── Internal types ──────────────────────────────────────────────── */
|
||
|
||
/* Array of QN strings for byName index, with the test/mock verdict of each
|
||
* name cached beside it: the scorer asked is_test_qn for every candidate of
|
||
* every unresolved call, and the answer depends only on the name, which never
|
||
* changes once registered (waste sanitizer scaling lane, 2026-09-17). Field
|
||
* order and names match CBM_DYN_ARRAY(char *) so cbm_da_push works on it. */
|
||
typedef struct {
|
||
char **items;
|
||
int count;
|
||
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
|
||
* strdup per registered definition (~8.5M strdups on the kernel). */
|
||
char *label_pool[64];
|
||
int label_pool_n;
|
||
/* exact: qualifiedName → label string (heap-owned copies) */
|
||
CBMHashTable *exact;
|
||
|
||
/* byName: simpleName → qn_array_t* (heap-owned) */
|
||
CBMHashTable *by_name;
|
||
};
|
||
|
||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||
|
||
/* Extract the last path segment from a QN. Returns pointer into s.
|
||
* Recognizes both '.' (most langs) and Rust/C++ '::' separators, so a
|
||
* scoped callee like "lib::square" yields "square" rather than the whole
|
||
* scoped path (which never matches the by-name index). */
|
||
static const char *simple_name(const char *qn) {
|
||
const char *dot = strrchr(qn, '.');
|
||
const char *seg = dot ? dot + SKIP_ONE : qn;
|
||
/* Find the last "::" and, if it sits after the last '.', use the
|
||
* segment following it. */
|
||
const char *colons = NULL;
|
||
for (const char *p = qn; (p = strstr(p, "::")) != NULL; p += 2) {
|
||
colons = p;
|
||
}
|
||
if (colons && colons + 2 > seg) {
|
||
seg = colons + 2;
|
||
}
|
||
return seg;
|
||
}
|
||
|
||
/* Extract everything before the last dot. Returns heap-allocated string. */
|
||
|
||
/* Count common dot-separated prefix segments. One pass over the shared prefix:
|
||
* the same answer as measuring each segment of both strings with strchr and
|
||
* strlen and comparing them, which walked every candidate's whole name per
|
||
* call (the scaling lane's x3.4, waste sanitizer 2026-09-17). */
|
||
static int common_prefix_len(const char *a, const char *b) {
|
||
if (!a || !b) {
|
||
return 0;
|
||
}
|
||
int count = 0;
|
||
size_t i = 0;
|
||
while (a[i] != '\0' && b[i] != '\0') { /* a segment starts here in both */
|
||
while (a[i] == b[i] && a[i] != '.' && a[i] != '\0') {
|
||
i++;
|
||
}
|
||
bool a_ends = a[i] == '.' || a[i] == '\0';
|
||
bool b_ends = b[i] == '.' || b[i] == '\0';
|
||
if (!a_ends || !b_ends) {
|
||
break; /* the segments differ in content or in length */
|
||
}
|
||
count++;
|
||
if (a[i] == '\0' || b[i] == '\0') {
|
||
break; /* one name stops here: no further segment to compare */
|
||
}
|
||
i++; /* both stand on '.': step into the next segment */
|
||
}
|
||
return count;
|
||
}
|
||
|
||
enum { REG_TEST_PENALTY = 1000 };
|
||
|
||
/* Check if a qualified name looks like a test/mock path: does it contain any of
|
||
* Test, test, Mock, mock, Stub, stub, Fake, fake, Fixture, spec? One pass that
|
||
* dispatches on the first character instead of ten strstr scans of the whole
|
||
* name (this ran for every candidate of every unresolved call: the scaling
|
||
* lane's x4.0, waste sanitizer 2026-09-17). */
|
||
static bool is_test_qn(const char *qn) {
|
||
if (!qn) {
|
||
return false;
|
||
}
|
||
for (const char *p = qn; *p; p++) {
|
||
switch (*p) {
|
||
case 'T':
|
||
if (strncmp(p, "Test", 4) == 0) {
|
||
return true;
|
||
}
|
||
break;
|
||
case 't':
|
||
if (strncmp(p, "test", 4) == 0) {
|
||
return true;
|
||
}
|
||
break;
|
||
case 'M':
|
||
if (strncmp(p, "Mock", 4) == 0) {
|
||
return true;
|
||
}
|
||
break;
|
||
case 'm':
|
||
if (strncmp(p, "mock", 4) == 0) {
|
||
return true;
|
||
}
|
||
break;
|
||
case 'S':
|
||
if (strncmp(p, "Stub", 4) == 0) {
|
||
return true;
|
||
}
|
||
break;
|
||
case 's':
|
||
if (strncmp(p, "stub", 4) == 0 || strncmp(p, "spec", 4) == 0) {
|
||
return true;
|
||
}
|
||
break;
|
||
case 'F':
|
||
if (strncmp(p, "Fake", 4) == 0 || strncmp(p, "Fixture", 7) == 0) {
|
||
return true;
|
||
}
|
||
break;
|
||
case 'f':
|
||
if (strncmp(p, "fake", 4) == 0) {
|
||
return true;
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* The cached test verdicts, only when they cover every entry: a failed growth
|
||
* leaves the cache short, and then the scorer asks per candidate as before. */
|
||
static const uint8_t *qn_test_flags(const qn_array_t *arr) {
|
||
return arr->is_test && arr->is_test_cap >= arr->count ? arr->is_test : NULL;
|
||
}
|
||
|
||
/* The cached candidate languages, under the same coverage rule: both side
|
||
* arrays grow together and share is_test_cap. */
|
||
static const uint8_t *qn_lang_flags(const qn_array_t *arr) {
|
||
return arr->lang && arr->is_test_cap >= arr->count ? arr->lang : NULL;
|
||
}
|
||
|
||
/* Score a candidate for tiebreaking. Higher = better.
|
||
* Layer 1: Non-test code preferred over test code (+1000)
|
||
* Layer 2: Namespace proximity via common prefix length (+plen)
|
||
* `is_test` is the cached verdict when the caller has one (-1: ask). */
|
||
static int candidate_score(const char *candidate_qn, const char *module_qn, int is_test) {
|
||
int score = 0;
|
||
bool test = is_test >= 0 ? is_test != 0 : is_test_qn(candidate_qn);
|
||
if (!test) {
|
||
score += REG_TEST_PENALTY;
|
||
}
|
||
score += common_prefix_len(candidate_qn, module_qn);
|
||
return score;
|
||
}
|
||
|
||
/* How plainly a candidate is something a call can target, lower = better: a
|
||
* function or method; then any other definition (a class for a constructor
|
||
* call, a macro, a type); then a candidate whose label is unknown; last a
|
||
* variable or field, which a call reaches only when it holds a function. */
|
||
enum {
|
||
REG_RANK_CALLABLE = 0,
|
||
REG_RANK_OTHER_DEF = 1,
|
||
REG_RANK_UNKNOWN = 2,
|
||
REG_RANK_DATA = 3,
|
||
};
|
||
|
||
static int callable_rank(const cbm_registry_t *r, const char *qn) {
|
||
const char *label = r ? cbm_registry_label_of(r, qn) : NULL;
|
||
if (!label) {
|
||
return REG_RANK_UNKNOWN;
|
||
}
|
||
if (strcmp(label, "Function") == 0 || strcmp(label, "Method") == 0) {
|
||
return REG_RANK_CALLABLE;
|
||
}
|
||
if (strcmp(label, "Variable") == 0 || strcmp(label, "Field") == 0) {
|
||
return REG_RANK_DATA;
|
||
}
|
||
return REG_RANK_OTHER_DEF;
|
||
}
|
||
|
||
/* A .NET reference assembly (`src/libraries/<Asm>/ref/<Asm>.cs`) declares an
|
||
* assembly's public surface with `throw null` bodies in one shallow file; a
|
||
* call that could equally mean the implementation means the implementation.
|
||
* C# only: elsewhere a `ref` directory is an ordinary name. */
|
||
static bool csharp_reference_stub(const char *qn, uint8_t lang) {
|
||
return lang == CBM_LANG_CSHARP && strstr(qn, ".ref.") != NULL;
|
||
}
|
||
|
||
/* Total order among candidates that tie on candidate_score. The registry's
|
||
* by-name bucket is in registration order, which follows the file list; a
|
||
* tie broken by bucket position made the resolved target a function of
|
||
* that order (kernel: 632 CALLS edges differed between two indexes of the
|
||
* same tree, `dev_name` flipping between twenty same-named struct fields
|
||
* and unresolved, `sg_set_buf` between include/linux and tools/virtio).
|
||
* The rule, a pure function of the candidate set (O9):
|
||
* 1. the more plainly callable candidate (`dev_name` the function over the
|
||
* struct fields; Kotlin `where(...)` the method over the property);
|
||
* 2. for C#, an implementation over a reference-assembly stub;
|
||
* 3. the lexicographically smaller QN (include/linux over tools/virtio).
|
||
* Nesting depth is deliberately not a criterion: "least nested wins" sent
|
||
* django's `Model.objects.filter` to the template `Library.filter`, one
|
||
* segment shallower than `QuerySet.filter` (2026-10-10 audit). */
|
||
static bool candidate_outranks_on_tie(const cbm_registry_t *r, const char *candidate,
|
||
uint8_t candidate_lang, const char *best, uint8_t best_lang) {
|
||
int ck = callable_rank(r, candidate);
|
||
int bk = callable_rank(r, best);
|
||
if (ck != bk) {
|
||
return ck < bk;
|
||
}
|
||
bool cs = csharp_reference_stub(candidate, candidate_lang);
|
||
bool bs = csharp_reference_stub(best, best_lang);
|
||
if (cs != bs) {
|
||
return !cs;
|
||
}
|
||
return strcmp(candidate, best) < 0;
|
||
}
|
||
|
||
/* Pick candidate with highest composite score (test-deprioritization + namespace
|
||
* proximity), ties settled by candidate_outranks_on_tie. `is_test_flags` and
|
||
* `lang_flags` are the candidates' cached test verdicts and languages, or NULL
|
||
* when the caller has none (a filtered subset carries its own copies). */
|
||
static const char *best_by_import_distance(const cbm_registry_t *r, const char **candidates,
|
||
const uint8_t *is_test_flags, const uint8_t *lang_flags,
|
||
int count, const char *module_qn) {
|
||
const char *best = NULL;
|
||
uint8_t best_lang = (uint8_t)CBM_LANG_COUNT;
|
||
int best_score = CBM_NOT_FOUND;
|
||
for (int i = 0; i < count; i++) {
|
||
int score =
|
||
candidate_score(candidates[i], module_qn, is_test_flags ? (int)is_test_flags[i] : -1);
|
||
uint8_t lang = lang_flags ? lang_flags[i] : (uint8_t)CBM_LANG_COUNT;
|
||
if (score > best_score ||
|
||
(score == best_score && best &&
|
||
candidate_outranks_on_tie(r, candidates[i], lang, best, best_lang))) {
|
||
best_score = score;
|
||
best = candidates[i];
|
||
best_lang = lang;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/* ── Per-file is_import_reachable memoization cache ───────────────
|
||
*
|
||
* The hot path on kubernetes spends ~50% of resolve_calls CPU in
|
||
* is_import_reachable's O(candidates × imports × strstr) scan. The
|
||
* SAME candidate_qn is re-evaluated dozens of times per file because
|
||
* the same callee_name appears in multiple call sites and each lookup
|
||
* re-checks all candidates for that name.
|
||
*
|
||
* Cache life-cycle is per-FILE (because import_vals changes between
|
||
* files). resolve_file_calls calls _begin at file entry and _end at
|
||
* file exit. Thread-local so each worker has its own cache without
|
||
* contention. */
|
||
static CBM_TLS CBMHashTable *_reach_cache = NULL;
|
||
/* The cache's key copies live in one per-file arena, released with the cache:
|
||
* a strdup per memoized candidate was 12.5 M malloc + free pairs on the Go
|
||
* corpus (waste sanitizer, 2026-09-17). Keys are still copies -- the public
|
||
* cbm_registry_is_import_reachable may be handed a caller's buffer. */
|
||
static CBM_TLS CBMArena _reach_keys;
|
||
static CBM_TLS bool _reach_keys_live = false;
|
||
/* The arena opens on the first memoized key, with a small first block that
|
||
* doubles: opened eagerly at the 64 KB default, the per-file arenas were 1.4 GB
|
||
* of cumulative allocation on the Go corpus, almost all of it never written
|
||
* (waste sanitizer, 2026-09-17). */
|
||
enum { REACH_KEYS_FIRST_BLOCK = 4096 };
|
||
|
||
/* Sentinels stored as values in the cache. NULL means "not cached".
|
||
* We need two distinct non-NULL pointers to encode true/false. */
|
||
#define REACH_CACHE_TRUE ((void *)(uintptr_t)1)
|
||
#define REACH_CACHE_FALSE ((void *)(uintptr_t)2)
|
||
|
||
void cbm_registry_reach_cache_begin(int estimated_capacity) {
|
||
if (_reach_cache) {
|
||
/* Defensive: caller forgot to call _end. Clear and reuse. */
|
||
cbm_ht_clear(_reach_cache);
|
||
if (_reach_keys_live) {
|
||
cbm_arena_reset(&_reach_keys);
|
||
}
|
||
return;
|
||
}
|
||
if (estimated_capacity < 16)
|
||
estimated_capacity = 16;
|
||
_reach_cache = cbm_ht_create((uint32_t)estimated_capacity);
|
||
}
|
||
|
||
void cbm_registry_reach_cache_end(void) {
|
||
if (!_reach_cache)
|
||
return;
|
||
cbm_ht_free(_reach_cache);
|
||
_reach_cache = NULL;
|
||
if (_reach_keys_live) {
|
||
cbm_arena_destroy(&_reach_keys);
|
||
_reach_keys_live = false;
|
||
}
|
||
}
|
||
|
||
/* ── Per-file import-map prefix → module-QN hash ──────────────────
|
||
*
|
||
* resolve_import_map does a linear strcmp scan over the per-file
|
||
* import list for every call (and every usage, throw, rw, def). On
|
||
* kubernetes typical files have 20-30 imports and ~200 calls. That's
|
||
* 4000-6000 strcmps per file × 20K files = ~80M strcmps.
|
||
*
|
||
* Build a hash from prefix → module_qn ONCE per file at file entry.
|
||
* Each lookup becomes O(1). Keys and values are BORROWED from the
|
||
* caller's import_map — they outlive the cache scope. */
|
||
static CBM_TLS CBMHashTable *_import_map_cache = NULL;
|
||
|
||
void cbm_registry_import_map_cache_begin(const char **keys, const char **vals, int count) {
|
||
if (_import_map_cache) {
|
||
cbm_ht_free(_import_map_cache);
|
||
_import_map_cache = NULL;
|
||
}
|
||
if (!keys || !vals || count <= 0)
|
||
return;
|
||
_import_map_cache = cbm_ht_create((uint32_t)count * 2u + 8u);
|
||
if (!_import_map_cache)
|
||
return;
|
||
for (int i = 0; i < count; i++) {
|
||
if (keys[i] && vals[i]) {
|
||
cbm_ht_set(_import_map_cache, keys[i], (void *)(uintptr_t)vals[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
void cbm_registry_import_map_cache_end(void) {
|
||
if (!_import_map_cache)
|
||
return;
|
||
/* Keys/values borrowed from caller — no free callback needed. */
|
||
cbm_ht_free(_import_map_cache);
|
||
_import_map_cache = NULL;
|
||
}
|
||
|
||
/* ── Per-file full-result cache for cbm_registry_resolve ──────────
|
||
*
|
||
* THE big one: 98.7% of resolve_calls CPU on kubernetes lives inside
|
||
* cbm_registry_resolve's strategy chain (suffix_match iterating
|
||
* hundreds of candidates per common name like "Get"/"Add"/"New"
|
||
* × 1M LSP-miss calls = ~800s of work).
|
||
*
|
||
* Since module_qn is CONSTANT per file, the resolution for a given
|
||
* callee_name within a file is also constant — same name resolves
|
||
* to the same QN every time. Cache the full cbm_resolution_t result
|
||
* keyed by callee_name; first lookup does the full chain, repeats
|
||
* are O(1). On K8s a typical file has ~200 calls but only ~50
|
||
* unique callee_names → ~75% hit rate → ~75% of the resolve cost
|
||
* eliminated. */
|
||
typedef struct {
|
||
cbm_resolution_t res;
|
||
size_t scope_len; /* raw and Python-scoped requests may share one file cache */
|
||
bool skip_members; /* a C call and a C reference to the same name share it too */
|
||
} resolve_cache_entry_t;
|
||
|
||
static CBM_TLS CBMHashTable *_resolve_cache = NULL;
|
||
/* Entries are malloc'd; keys are strdup'd. Both freed in _end. */
|
||
|
||
static void resolve_cache_free_entry(const char *key, void *val, void *ud) {
|
||
(void)ud;
|
||
free((char *)key);
|
||
free(val);
|
||
}
|
||
|
||
void cbm_registry_resolve_cache_begin(int estimated_capacity) {
|
||
if (_resolve_cache) {
|
||
cbm_ht_foreach(_resolve_cache, resolve_cache_free_entry, NULL);
|
||
cbm_ht_free(_resolve_cache);
|
||
_resolve_cache = NULL;
|
||
}
|
||
if (estimated_capacity < 32)
|
||
estimated_capacity = 32;
|
||
_resolve_cache = cbm_ht_create((uint32_t)estimated_capacity);
|
||
}
|
||
|
||
void cbm_registry_resolve_cache_end(void) {
|
||
if (!_resolve_cache)
|
||
return;
|
||
cbm_ht_foreach(_resolve_cache, resolve_cache_free_entry, NULL);
|
||
cbm_ht_free(_resolve_cache);
|
||
_resolve_cache = NULL;
|
||
}
|
||
|
||
/* Check if candidate's module prefix appears in import map values.
|
||
* Uses stack buffer to avoid malloc/free per call in hot resolution loop.
|
||
* Per-file memoization via TLS cache: repeated lookups of the same
|
||
* candidate_qn (same name appears in many call sites) become O(1)
|
||
* after the first computation. */
|
||
static bool is_import_reachable(const char *candidate_qn, const char **import_vals,
|
||
int import_count) {
|
||
if (_reach_cache) {
|
||
void *cached = cbm_ht_get(_reach_cache, candidate_qn);
|
||
if (cached == REACH_CACHE_TRUE)
|
||
return true;
|
||
if (cached == REACH_CACHE_FALSE)
|
||
return false;
|
||
}
|
||
|
||
char cand_mod[CBM_SZ_512];
|
||
const char *last = strrchr(candidate_qn, '.');
|
||
if (last) {
|
||
size_t len = (size_t)(last - candidate_qn);
|
||
if (len >= sizeof(cand_mod)) {
|
||
len = sizeof(cand_mod) - SKIP_ONE;
|
||
}
|
||
memcpy(cand_mod, candidate_qn, len);
|
||
cand_mod[len] = '\0';
|
||
} else {
|
||
snprintf(cand_mod, sizeof(cand_mod), "%s", candidate_qn);
|
||
}
|
||
bool reachable = false;
|
||
for (int i = 0; i < import_count; i++) {
|
||
if (strstr(cand_mod, import_vals[i]) || strstr(import_vals[i], cand_mod)) {
|
||
reachable = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (_reach_cache) {
|
||
if (!_reach_keys_live) {
|
||
cbm_arena_init_sized(&_reach_keys, REACH_KEYS_FIRST_BLOCK);
|
||
_reach_keys_live = true;
|
||
}
|
||
char *kdup = cbm_arena_strdup(&_reach_keys, candidate_qn);
|
||
if (kdup) {
|
||
cbm_ht_set(_reach_cache, kdup, reachable ? REACH_CACHE_TRUE : REACH_CACHE_FALSE);
|
||
}
|
||
}
|
||
return reachable;
|
||
}
|
||
|
||
/* Scale confidence inversely with candidate count. */
|
||
static double candidate_count_penalty(double base, int count) {
|
||
if (count <= REG_MIN_CANDIDATES) {
|
||
return base;
|
||
}
|
||
return base * fmin(REG_FULL_CONF, CANDIDATE_PENALTY_CAP / (double)count);
|
||
}
|
||
|
||
static cbm_resolution_t empty_result(void) {
|
||
cbm_resolution_t r = {0};
|
||
return r;
|
||
}
|
||
|
||
/* ── Perl builtin guard (#459 follow-up: call-graph noise) ──────────
|
||
* Curated subset of perlfunc core builtins. When a Perl CALL resolves
|
||
* only by the generic short-name matcher (no LSP, no import, after the
|
||
* same-module/name-lookup chain), a builtin name like `push`/`shift`/
|
||
* `keys` must NOT be wired to a project sub that merely shares the name
|
||
* — that is virtually always a false positive. A genuine intra-project
|
||
* call is resolved by earlier (LSP/textual) stages before this guard.
|
||
* MUST stay sorted ASCII-ascending for bsearch. */
|
||
static const char *const PERL_BUILTINS[] = {
|
||
"abs", "atan2", "binmode", "bless", "caller", "chdir", "chmod", "chomp",
|
||
"chop", "chown", "chr", "chroot", "close", "closedir", "cos", "defined",
|
||
"delete", "die", "do", "each", "eof", "eval", "exec", "exists",
|
||
"exit", "fork", "gmtime", "goto", "grep", "hex", "index", "int",
|
||
"join", "keys", "last", "lc", "lcfirst", "length", "local", "localtime",
|
||
"log", "lstat", "map", "mkdir", "my", "next", "oct", "open",
|
||
"opendir", "ord", "our", "pop", "pos", "print", "printf", "push",
|
||
"quotemeta", "rand", "read", "readdir", "readline", "redo", "ref", "rename",
|
||
"require", "return", "reverse", "rindex", "rmdir", "say", "scalar", "seek",
|
||
"shift", "sin", "sleep", "sort", "splice", "split", "sprintf", "sqrt",
|
||
"srand", "stat", "substr", "system", "time", "uc", "ucfirst", "undef",
|
||
"unlink", "unshift", "values", "wantarray", "warn", "write",
|
||
};
|
||
|
||
static int perl_builtin_cmp(const void *key, const void *elem) {
|
||
return strcmp((const char *)key, *(const char *const *)elem);
|
||
}
|
||
|
||
/* True if `name` is one of the curated Perl core builtins. Used to suppress
|
||
* generic-resolver CALLS edges from Perl builtin invocations to project subs
|
||
* that happen to share the builtin's name. Perl-scoped: callers gate on the
|
||
* file language so no other language's resolution is affected. */
|
||
bool cbm_perl_is_builtin(const char *name) {
|
||
if (!name || !name[0]) {
|
||
return false;
|
||
}
|
||
return bsearch(name, PERL_BUILTINS, sizeof(PERL_BUILTINS) / sizeof(PERL_BUILTINS[0]),
|
||
sizeof(PERL_BUILTINS[0]), perl_builtin_cmp) != NULL;
|
||
}
|
||
|
||
/* Decide whether a *resolved* Perl call edge is generic-resolver noise that
|
||
* should be suppressed (#476). Returns true only for Perl, only for a builtin
|
||
* invocation or a method call, and only when the registry landed the match via
|
||
* a WEAK short-name strategy. High-confidence import/same-module strategies
|
||
* (same_module, import_map, import_map_suffix) are KEPT so a genuine same-file
|
||
* or imported call to a builtin-named sub still resolves — only the weak
|
||
* short-name guesses (suffix_match, unique_name) are dropped. `strategy` is the
|
||
* cbm_resolution_t.strategy of a non-empty match;
|
||
* NULL/empty (no match) returns false. Pure + side-effect-free so the
|
||
* suppression contract is unit-testable without a full pipeline. */
|
||
bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *callee_name,
|
||
const char *strategy) {
|
||
if (!is_perl) {
|
||
return false;
|
||
}
|
||
if (!(is_method || cbm_perl_is_builtin(callee_name))) {
|
||
return false;
|
||
}
|
||
if (!strategy || !strategy[0]) {
|
||
return false;
|
||
}
|
||
if (strcmp(strategy, "same_module") == 0 || strcmp(strategy, "import_map") == 0 ||
|
||
strcmp(strategy, "import_map_suffix") == 0) {
|
||
return false; /* high-confidence import/same-module match — keep the genuine edge */
|
||
}
|
||
return true; /* weak short-name match (suffix_match / unique_name / …) → drop */
|
||
}
|
||
|
||
/* Dynamic-language analogue of the Perl guard above (#592/#606/#1276
|
||
* direction; precedent #477). A member call `x.foo()` reaches the weak textual
|
||
* cascade ONLY when the language's LSP could not resolve the receiver type —
|
||
* type-resolved calls win via lsp_* strategies before the registry runs.
|
||
* Binding such a call to a project symbol by a weak short-name strategy
|
||
* fabricates a CALLS edge (`re.test()` -> SalesforceRestClient.test,
|
||
* `accelerator.print()` -> MockAccelerator.print). Drop ONLY the weak
|
||
* strategies; keep import/same-module/qualified-tail matches and every lsp_*
|
||
* strategy. Uses an EXPLICIT drop-list (not keep-list + default-drop) because
|
||
* the parallel resolver runs lsp_* strategies through the same guard variable —
|
||
* a default-drop would silently kill lsp_ts_method. Pure + side-effect-free so
|
||
* the contract is unit-testable without a full pipeline.
|
||
*
|
||
* `enabled` is the CALLER's per-language gate, deliberately kept OUT of this
|
||
* helper: the guard applies only to the language set each call site enumerates
|
||
* (today Python plus the JS/TS family including ArkTS). Widening it is a
|
||
* per-language decision made at the call sites in pass_calls.c and
|
||
* pass_parallel.c, which MUST stay in lockstep — a gate added to only one of
|
||
* them diverges the sequential and parallel resolvers. */
|
||
/* The weak short-name strategies that actually reach the call-resolution
|
||
* guards: the registry's suffix_match / unique_name and the parallel
|
||
* field_type_hint. "fuzzy" is listed as defensive insurance only —
|
||
* cbm_registry_fuzzy_resolve is not wired into the sequential/parallel resolvers
|
||
* today, so it never reaches these helpers, but naming it keeps a future wiring
|
||
* from silently reintroducing the noise. Everything else — same_module /
|
||
* import_map / import_map_suffix / qualified_suffix / callee_suffix /
|
||
* service_pattern / lsp_* — is a receiver- or import-aware match and is KEPT.
|
||
*
|
||
* Shared by BOTH weak-call guards below so the drop-list exists exactly once: a
|
||
* list that drifted between the member guard and the local-binding guard would
|
||
* make the two disagree about what "weak" means. */
|
||
static bool weak_short_name_strategy(const char *strategy) {
|
||
if (!strategy || !strategy[0]) {
|
||
return false;
|
||
}
|
||
return strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "unique_name") == 0 ||
|
||
strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0;
|
||
}
|
||
|
||
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy) {
|
||
if (!enabled || !is_method) {
|
||
return false;
|
||
}
|
||
return weak_short_name_strategy(strategy);
|
||
}
|
||
|
||
/* ── Python builtin-type members ─────────────────────────────────────────
|
||
* Methods of Python's builtin types (str, bytes, list, dict, set, file objects)
|
||
* plus the builtin functions that appear as attribute calls. Like PERL_BUILTINS
|
||
* this is a fact about the LANGUAGE, not about corpus fashion: a member call
|
||
* whose name is a builtin's own method — `parts.extend(...)`, `line.strip()`,
|
||
* `d.items()` — is that builtin's method whenever the receiver is untyped, so a
|
||
* project symbol that merely shares the spelling must not bind. MUST stay
|
||
* sorted ASCII-ascending for bsearch. */
|
||
static const char *const PYTHON_BUILTIN_MEMBERS[] = {
|
||
"add", "append", "capitalize",
|
||
"casefold", "center", "clear",
|
||
"close", "copy", "count",
|
||
"decode", "difference", "discard",
|
||
"encode", "endswith", "expandtabs",
|
||
"extend", "fileno", "find",
|
||
"flush", "format", "get",
|
||
"index", "insert", "intersection",
|
||
"isalnum", "isalpha", "isdecimal",
|
||
"isdigit", "isidentifier", "islower",
|
||
"isnumeric", "isprintable", "isspace",
|
||
"istitle", "isupper", "issubset",
|
||
"issuperset", "items", "join",
|
||
"keys", "ljust", "lower",
|
||
"lstrip", "partition", "pop",
|
||
"popitem", "print", "read",
|
||
"readable", "readline", "readlines",
|
||
"remove", "replace", "reverse",
|
||
"rfind", "rindex", "rjust",
|
||
"rpartition", "rsplit", "rstrip",
|
||
"seek", "setdefault", "sort",
|
||
"split", "splitlines", "startswith",
|
||
"strip", "swapcase", "symmetric_difference",
|
||
"tell", "title", "truncate",
|
||
"union", "update", "upper",
|
||
"values", "writable", "write",
|
||
"writelines", "zfill",
|
||
};
|
||
|
||
static int python_builtin_member_cmp(const void *key, const void *elem) {
|
||
return strcmp((const char *)key, *(const char *const *)elem);
|
||
}
|
||
|
||
bool cbm_python_is_builtin_member(const char *name) {
|
||
if (!name || !name[0]) {
|
||
return false;
|
||
}
|
||
return bsearch(name, PYTHON_BUILTIN_MEMBERS,
|
||
sizeof(PYTHON_BUILTIN_MEMBERS) / sizeof(PYTHON_BUILTIN_MEMBERS[0]),
|
||
sizeof(PYTHON_BUILTIN_MEMBERS[0]), python_builtin_member_cmp) != NULL;
|
||
}
|
||
|
||
/* The member guard's one exemption. A Python member call whose receiver could
|
||
* not be typed still carries real evidence when three facts line up: the callee
|
||
* name has exactly ONE definition in the whole project (strategy unique_name),
|
||
* the receiver is an attribute chain rooted at self/cls — an object the class
|
||
* owns, not a parameter handed in — and the name is not a builtin type's own
|
||
* method. `self.compiler.apply_converters()` names the only apply_converters in
|
||
* django and nothing else it could mean exists. Measured on django/django
|
||
* (v0.10.8 → v0.11.0 A/B, 2026-09-16): the unconditional guard dropped 800 such
|
||
* self-rooted edges, every sampled one a real call in the source. A bare
|
||
* parameter receiver (`accelerator.backward()`, #1276's own case) carries no
|
||
* ownership evidence and stays suppressed, as does every builtin-member name
|
||
* (`self.parts.extend()`). Python only: the JS/TS family keeps the TS-LSP trade
|
||
* recorded in the resolution probe. Pure; unit-tested in test_registry.c. */
|
||
bool cbm_weak_member_unique_name_exempt(bool is_python, bool receiver_is_self_attribute,
|
||
const char *callee_name, const char *strategy) {
|
||
if (!is_python || !receiver_is_self_attribute) {
|
||
return false;
|
||
}
|
||
if (!strategy || strcmp(strategy, "unique_name") != 0) {
|
||
return false;
|
||
}
|
||
if (!callee_name || !callee_name[0]) {
|
||
return false;
|
||
}
|
||
return !cbm_python_is_builtin_member(simple_name(callee_name));
|
||
}
|
||
|
||
/* Bare-call counterpart of the member guard above. A Python call `foo()` whose
|
||
* callee identifier is bound as a parameter of an enclosing scope cannot be the
|
||
* module-level `foo`: the parameter shadows it for the whole body. Binding such
|
||
* a call to a project Function/Method by a weak short-name strategy fabricates
|
||
* the edge by construction (`def _run_with_heavy_slot(run): run()` ->
|
||
* SatoriLive.run).
|
||
*
|
||
* This is deliberately NOT keyed on the callee's spelling. A list of
|
||
* "generic-looking" names (get / run / execute) asserts that certain spellings
|
||
* are usually noise, which is a claim about corpus fashion rather than about
|
||
* what the resolver knew — and it ages invisibly, because nothing fails when the
|
||
* distribution shifts, the graph just quietly loses different edges. A parameter
|
||
* binding is a fact about THIS file's scope, decidable outright.
|
||
*
|
||
* `enabled` is the caller's per-language gate, kept out of the helper for the
|
||
* same reason as the member guard: the two call sites in pass_calls.c and
|
||
* pass_parallel.c MUST enumerate the identical language set, or the sequential
|
||
* and parallel resolvers diverge. Pure; unit-tested in test_registry.c. */
|
||
bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound,
|
||
const char *strategy) {
|
||
if (!enabled || !callee_is_locally_bound) {
|
||
return false;
|
||
}
|
||
return weak_short_name_strategy(strategy);
|
||
}
|
||
|
||
/* A module specifier that names a path inside the indexed tree ("./x", "../x",
|
||
* "/abs/x", and on Windows "C:\x", "C:/x", "\\server\share\x") rather than an
|
||
* external package. Package specifiers are everything else — "drizzle-orm",
|
||
* "rxjs/operators", "@scope/pkg". Windows paths must count as in-tree here:
|
||
* pr-smoke runs this pipeline on Windows, and misclassifying a drive-letter or
|
||
* UNC specifier as "external" is exactly the false-external edge this guard
|
||
* exists to prevent (#1355). */
|
||
static bool specifier_is_relative(const char *module_path) {
|
||
if (!module_path || !module_path[0]) {
|
||
return false;
|
||
}
|
||
if (module_path[0] == '.' || module_path[0] == '/') {
|
||
return true;
|
||
}
|
||
/* UNC share: \\server\share\... */
|
||
if (module_path[0] == '\\' && module_path[1] == '\\') {
|
||
return true;
|
||
}
|
||
/* Drive-letter absolute path: C:\repo\... or C:/repo/... */
|
||
if (isalpha((unsigned char)module_path[0]) && module_path[1] == ':' &&
|
||
(module_path[2] == '\\' || module_path[2] == '/')) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* True when the specifier names a package that the indexed tree DECLARES —
|
||
* a workspace sibling, not a third-party dependency. `indexed_packages` is the
|
||
* pipeline's package map, keyed by the `name` of every manifest found in the
|
||
* tree (package.json, go.mod, Cargo.toml, ...), so a pnpm/npm/cargo workspace
|
||
* registers each of its own packages here.
|
||
*
|
||
* The key is the bare package name, so a subpath specifier is walked back one
|
||
* slash at a time: "drizzle-orm/pg-core" -> "drizzle-orm". Scoped names keep
|
||
* their leading segment ("@scope/pkg/sub" -> "@scope/pkg" -> "@scope").
|
||
*
|
||
* Deliberately a NAME test, not a resolution test. A workspace package usually
|
||
* points `main`/`module` at a build artifact ("./index.cjs") that is not
|
||
* checked in, so asking whether the entry file exists in the graph answers
|
||
* "no" for exactly the monorepos this has to protect. Whether the tree claims
|
||
* the name is decidable from the manifest alone. */
|
||
static bool specifier_names_indexed_package(const CBMHashTable *indexed_packages,
|
||
const char *module_path) {
|
||
if (!indexed_packages || !module_path || !module_path[0]) {
|
||
return false;
|
||
}
|
||
if (cbm_ht_has(indexed_packages, module_path)) {
|
||
return true;
|
||
}
|
||
char buf[CBM_SZ_512];
|
||
if (strlen(module_path) >= sizeof(buf)) {
|
||
return false;
|
||
}
|
||
snprintf(buf, sizeof(buf), "%s", module_path);
|
||
for (char *slash = strrchr(buf, '/'); slash != NULL; slash = strrchr(buf, '/')) {
|
||
*slash = '\0';
|
||
if (cbm_ht_has(indexed_packages, buf)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* True when the specifier is a package PATH the indexed tree itself DECLARES.
|
||
* `declared_packages` is the pipeline's namespace map, keyed by the
|
||
* dot-normalized `package` / `namespace` / `use` declaration of every indexed
|
||
* file (cbm_pipeline_namespace_map_build), so this is the JVM/CLR/PHP
|
||
* counterpart of the manifest-name test above: those languages have no
|
||
* specifier shape that separates in-tree from third-party, and their manifests
|
||
* (pom.xml, build.gradle) name artifacts, not packages.
|
||
*
|
||
* A package-path specifier names a member inside a package, so the trailing
|
||
* segment is walked off one at a time, exactly like the namespace-map lookup in
|
||
* cbm_pipeline_resolve_import_node: `org.jetbrains.exposed.v1.tests.shared
|
||
* .assertEquals` -> `org.jetbrains.exposed.v1.tests.shared`, which Exposed's
|
||
* own Assert.kt declares, so the call keeps its edge. `java.net.URL` walks to
|
||
* `java.net` then `java`, which no project file declares, so it stays external
|
||
* and the guard still cuts its bind to an in-tree `URL` class.
|
||
*
|
||
* Separator normalization mirrors the map builder's: PHP writes `\`, some
|
||
* grammars `::` or `/`. NULL disables the check. */
|
||
static bool specifier_names_declared_package(const CBMHashTable *declared_packages,
|
||
const char *module_path) {
|
||
if (!declared_packages || !module_path || !module_path[0]) {
|
||
return false;
|
||
}
|
||
char buf[CBM_SZ_512];
|
||
if (strlen(module_path) >= sizeof(buf)) {
|
||
return false;
|
||
}
|
||
snprintf(buf, sizeof(buf), "%s", module_path);
|
||
char *brace = strchr(buf, '{');
|
||
if (brace) {
|
||
*brace = '\0'; /* grouped import — the prefix is the package */
|
||
}
|
||
char *alias = strstr(buf, " as ");
|
||
if (alias) {
|
||
*alias = '\0';
|
||
}
|
||
for (char *p = buf; *p; p++) {
|
||
if (*p == '\\' || *p == ':' || *p == '/') {
|
||
*p = '.';
|
||
}
|
||
}
|
||
/* Collapse the empty segments "::" just produced, then strip glob/
|
||
* separator noise off the tail. */
|
||
for (;;) {
|
||
char *dd = strstr(buf, "..");
|
||
if (!dd) {
|
||
break;
|
||
}
|
||
memmove(dd, dd + 1, strlen(dd + 1) + 1);
|
||
}
|
||
size_t len = strlen(buf);
|
||
while (len > 0 && (buf[len - 1] == '*' || buf[len - 1] == '.' || buf[len - 1] == ' ')) {
|
||
buf[--len] = '\0';
|
||
}
|
||
if (buf[0] == '\0') {
|
||
return false;
|
||
}
|
||
if (cbm_ht_has(declared_packages, buf)) {
|
||
return true;
|
||
}
|
||
/* The walk has no floor, and that is a deliberate false-NEGATIVE: once the
|
||
* tree declares `com.example`, EVERY specifier under `com.example.**` reads
|
||
* as in-tree, including a third-party library that happens to publish under
|
||
* a declared prefix. Such a call keeps the same-name fallback it has on
|
||
* main, so the cost is a fabricated edge this guard could have removed —
|
||
* never a real edge lost. Stopping earlier would need a rule for where a
|
||
* package path ends and a type begins, and getting that wrong fails the
|
||
* other way. Pinned by external_import_shadow_declared_prefix_is_permissive
|
||
* in test_registry.c. */
|
||
for (char *dot = strrchr(buf, '.'); dot != NULL; dot = strrchr(buf, '.')) {
|
||
*dot = '\0';
|
||
if (cbm_ht_has(declared_packages, buf)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static bool import_map_binds(const char **import_map_keys, int import_map_count,
|
||
const char *local_name) {
|
||
if (!import_map_keys || import_map_count <= 0) {
|
||
return false;
|
||
}
|
||
for (int i = 0; i < import_map_count; i++) {
|
||
if (import_map_keys[i] && strcmp(import_map_keys[i], local_name) == 0) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* #1355: a bare call whose name the calling file binds to an EXTERNAL package
|
||
* import must not fall back to a project-wide same-name guess.
|
||
*
|
||
* `import { eq } from "drizzle-orm"` binds `eq` in this file's scope. When the
|
||
* package is not part of the indexed tree it materializes no IMPORTS edge, so
|
||
* the import map has no entry, strategies 1-2 miss, and strategy 3/4 attach
|
||
* `eq(users.id, id)` to whatever project symbol happens to share the simple
|
||
* name — a CALLS edge into an unrelated local helper. The explicit import
|
||
* statement is positive evidence, taken from the caller's own source, that the
|
||
* identifier does NOT denote that symbol.
|
||
*
|
||
* Fires only when ALL of:
|
||
* - the callee is a BARE identifier: member (`x.foo()`) and package/namespace
|
||
* qualified callees are the receiver-aware guards' business, not this one;
|
||
* - the match came from a project-wide guess (suffix_match / unique_name;
|
||
* field_type_hint / fuzzy listed for the same defensive reason as the TS/JS
|
||
* guard) — every import-, receiver- or module-aware strategy is KEPT;
|
||
* - the file imports that exact local name from a NON-RELATIVE specifier;
|
||
* - no import-map key binds the name, i.e. that import resolved to nothing in
|
||
* the graph. A name the map does bind already had its chance at strategy 1
|
||
* and is import-aware by construction.
|
||
*
|
||
* Relative specifiers are deliberately excluded: they name a path inside the
|
||
* indexed tree, so a missing IMPORTS edge is an in-project resolution gap and
|
||
* the same-name fallback can still be right. A name imported from both a
|
||
* relative and a package specifier keeps the edge — the relative binding wins
|
||
* the tie explicitly, so the outcome does not depend on import order.
|
||
*
|
||
* `indexed_packages` extends that same reasoning to workspace monorepos, where
|
||
* a BARE specifier also names in-tree code: `import { eq } from "drizzle-orm"`
|
||
* inside the drizzle-orm repository is a sibling package, not a dependency. A
|
||
* specifier the tree's own manifests claim is treated exactly like a relative
|
||
* one. NULL disables the check and restores the specifier-shape-only contract.
|
||
*
|
||
* `declared_packages` closes the same hole for the package-path languages,
|
||
* where NO specifier shape says in-tree: `import
|
||
* org.jetbrains.exposed.v1.tests.shared.assertEquals` is spelled exactly like
|
||
* `import kotlin.test.assertEquals`, and only the tree's own `package`
|
||
* declarations tell them apart. It is permissive by construction — any
|
||
* specifier UNDER a declared package prefix counts as in-tree, see the note on
|
||
* the walk — so it can leave a fabricated edge in place, never remove a real
|
||
* one. NULL disables the check.
|
||
*
|
||
* Pure + side-effect-free so the contract is unit-testable without a pipeline. */
|
||
bool cbm_suppress_external_import_shadow(const char *callee_name, const char *strategy,
|
||
const CBMImportArray *file_imports,
|
||
const char **import_map_keys, int import_map_count,
|
||
const CBMHashTable *indexed_packages,
|
||
const CBMHashTable *declared_packages) {
|
||
if (!callee_name || !callee_name[0] || !strategy || !strategy[0]) {
|
||
return false;
|
||
}
|
||
if (strcmp(strategy, "suffix_match") != 0 && strcmp(strategy, "unique_name") != 0 &&
|
||
strcmp(strategy, "field_type_hint") != 0 && strcmp(strategy, "fuzzy") != 0) {
|
||
return false;
|
||
}
|
||
if (strchr(callee_name, '.') != NULL || strstr(callee_name, "::") != NULL) {
|
||
return false;
|
||
}
|
||
if (!file_imports || file_imports->count <= 0) {
|
||
return false;
|
||
}
|
||
if (import_map_binds(import_map_keys, import_map_count, callee_name)) {
|
||
return false;
|
||
}
|
||
bool external_binding = false;
|
||
for (int i = 0; i < file_imports->count; i++) {
|
||
const CBMImport *imp = &file_imports->items[i];
|
||
if (!imp->local_name || !imp->module_path || strcmp(imp->local_name, callee_name) != 0) {
|
||
continue;
|
||
}
|
||
if (specifier_is_relative(imp->module_path) ||
|
||
specifier_names_indexed_package(indexed_packages, imp->module_path) ||
|
||
specifier_names_declared_package(declared_packages, imp->module_path)) {
|
||
return false; /* an in-tree binding for this name — never suppress */
|
||
}
|
||
external_binding = true;
|
||
}
|
||
return external_binding;
|
||
}
|
||
|
||
bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy,
|
||
double confidence) {
|
||
/* Go analog of the TS/JS guard above, same failure class: a selector call
|
||
* whose receiver the Go LSP could not type reaches the registry and a bare
|
||
* short-name strategy binds it to an arbitrary same-named project symbol
|
||
* (`f.Close()` on an os.File -> a project `Close`, suffix_match over 15
|
||
* candidates). Built on the shared drop-list so "weak" means one thing
|
||
* for every guard; Go narrows it in one place only (unique_name below).
|
||
* field_type_hint stays weak: it is a receiver-NAME substring match, not
|
||
* a declared-type lookup (the parallel resolver no longer produces it for
|
||
* Go; a typed field call such as lrp_go_s8's `s.repo.Find()` resolves
|
||
* through the Go LSP). */
|
||
if (!is_go || !is_method || !weak_short_name_strategy(strategy)) {
|
||
return false;
|
||
}
|
||
if (strcmp(strategy, "unique_name") != 0) {
|
||
return true; /* suffix_match, field_type_hint, fuzzy */
|
||
}
|
||
/* unique_name is dropped only when PENALIZED: resolve_name_lookup scales
|
||
* CONF_UNIQUE_NAME by DEFAULT_CONFIDENCE exactly when the lone candidate
|
||
* is not reachable through the caller's imports — the stdlib/vendor
|
||
* hijack shape (`io.Copy` -> a project `Copy`). An unpenalized
|
||
* unique_name target sits inside the caller's import closure (or the
|
||
* file has no imports, e.g. a same-package call) and must be kept —
|
||
* dropping it kills genuinely-typed lone-candidate calls that never
|
||
* enter the field-type-hint upgrade (candidate_count == 1). */
|
||
return confidence < CONF_UNIQUE_NAME;
|
||
}
|
||
|
||
bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name,
|
||
const char *strategy, const char *target_label) {
|
||
/* The Go spec gives `init` no binding in the package block: the
|
||
* identifier declares init functions and nothing else, and "init
|
||
* functions cannot be referred to from anywhere in a program". A method
|
||
* or a field named init is reached only through a selector (`x.init()`,
|
||
* `e.init`), which arrives here as is_member and is left alone. So a
|
||
* BARE `init` is always a local name: a closure (`init :=
|
||
* func(ctx) {...}; init(ctx)`, 22 kubernetes e2e storage suites), a
|
||
* parameter (`func sum(init func() ref.Val)`), or the `package init`
|
||
* clause of a directory named init. None of them is a project node.
|
||
*
|
||
* Every Go Function named init is a package init (no other package-level
|
||
* `init` can be declared), so that target is refused for every registry
|
||
* strategy. Before #1915 the flat QN made same_module bind the bare name
|
||
* to its own package's init; after it, the per-file QN misses there and
|
||
* the name fallback binds unrelated init methods instead. A Method or
|
||
* Field namesake is refused when only the name found it (the shared weak
|
||
* drop-list), which is how every such edge was minted. lsp_* answers are
|
||
* untouched: the sequential resolver emits them before its registry
|
||
* guards run, so refusing one here would split the two resolvers.
|
||
*
|
||
* Go-gated: elsewhere `init` is an ordinary name. A bare `init()` in a
|
||
* Java, C#, C++, Kotlin or Ruby method calls the class's own method, and
|
||
* a C, Python or JS `init` function is called by its bare name. */
|
||
if (!is_go || is_member || !name || strcmp(name, "init") != 0 || !target_label) {
|
||
return false;
|
||
}
|
||
if (strategy && strncmp(strategy, "lsp_", strlen("lsp_")) == 0) {
|
||
return false;
|
||
}
|
||
if (strcmp(target_label, "Function") == 0) {
|
||
return true;
|
||
}
|
||
if (strcmp(target_label, "Method") == 0 || strcmp(target_label, "Field") == 0) {
|
||
return weak_short_name_strategy(strategy);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* Import-binding counterpart of the parameter guard above (#2127). An import
|
||
* binds the identifier for the whole module; when that import is external it
|
||
* never materializes, and the chain falls through to project-wide short-name
|
||
* strategies (`from unittest.mock import patch; patch()` -> a REST view's
|
||
* `PkgConfigView.patch`). The caller decides whether the binding contradicts
|
||
* the target (cbm_python_import_binding_contradicts: a SCOPE FACT about this
|
||
* file, not a spelling list); only weak strategies are dropped, so import_map /
|
||
* same_module / lsp_* edges are untouched. Pure; unit-tested in test_registry.c. */
|
||
bool cbm_suppress_weak_import_bound_call(bool enabled, bool import_binding_contradicts,
|
||
const char *strategy) {
|
||
if (!enabled || !import_binding_contradicts) {
|
||
return false;
|
||
}
|
||
return weak_short_name_strategy(strategy);
|
||
}
|
||
|
||
static bool js_ts_family(CBMLanguage lang) {
|
||
return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
|
||
lang == CBM_LANG_ARKTS;
|
||
}
|
||
|
||
/* C and C++ are one family for cross-language checks: .h maps to CBM_LANG_CPP
|
||
* in the extension table, so a .c file referencing a symbol declared in its
|
||
* own header would otherwise read as a language boundary. */
|
||
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, "<python-builtins>") == 0) {
|
||
return CBM_LANG_PYTHON;
|
||
}
|
||
if (strcmp(file_path, "<kotlin-builtins>") == 0) {
|
||
return CBM_LANG_KOTLIN;
|
||
}
|
||
return CBM_LANG_COUNT;
|
||
}
|
||
|
||
static const char *path_basename(const char *path) {
|
||
if (!path || !path[0]) {
|
||
return path;
|
||
}
|
||
const char *slash = strrchr(path, '/');
|
||
#ifdef _WIN32
|
||
const char *bslash = strrchr(path, '\\');
|
||
if (bslash && (!slash || bslash > slash)) {
|
||
slash = bslash;
|
||
}
|
||
#endif
|
||
return slash ? slash + 1 : path;
|
||
}
|
||
|
||
/* Build and configuration languages have no cross-language call semantics: a
|
||
* Makefile's `$(eval ...)` or a CMake `function(...)` names nothing in a C
|
||
* file, so a bare-name bind into another language is always a collision
|
||
* (2026-09-16 probe: kernel Makefile targets bound to `sk_psock.eval`). */
|
||
static bool build_config_language(CBMLanguage lang) {
|
||
return lang == CBM_LANG_MAKEFILE || lang == CBM_LANG_CMAKE || lang == CBM_LANG_YAML ||
|
||
lang == CBM_LANG_TOML || lang == CBM_LANG_JSON || lang == CBM_LANG_INI ||
|
||
lang == CBM_LANG_DOCKERFILE;
|
||
}
|
||
|
||
bool cbm_suppress_cross_language_suffix_match(CBMLanguage caller_lang, const char *target_file_path,
|
||
const char *strategy) {
|
||
/* Two same-named symbols in different languages: suffix_match picks one
|
||
* winner by import-distance and attaches every bare-name call to it
|
||
* (#725, Bash/Python main, JS/Python commit). unique_name is the
|
||
* candidates==1 case (#1572) and is not this guard — except for a build
|
||
* or configuration caller, where even a unique match into another
|
||
* language is a collision by construction. */
|
||
if (!strategy) {
|
||
return false;
|
||
}
|
||
bool config_caller = build_config_language(caller_lang);
|
||
if (strcmp(strategy, "suffix_match") != 0 &&
|
||
!(config_caller && strcmp(strategy, "unique_name") == 0)) {
|
||
return false;
|
||
}
|
||
if (caller_lang == CBM_LANG_COUNT || !target_file_path || !target_file_path[0]) {
|
||
return false;
|
||
}
|
||
CBMLanguage target_lang = cbm_language_for_filename(path_basename(target_file_path));
|
||
if (target_lang == CBM_LANG_COUNT) {
|
||
return false;
|
||
}
|
||
if (caller_lang == target_lang) {
|
||
return false;
|
||
}
|
||
if (js_ts_family(caller_lang) && js_ts_family(target_lang)) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target_file_path) {
|
||
/* #1928: USAGE / WRITES / READS analog of the CALLS guard above. A
|
||
* variable or field reference resolved by the short-name registry must
|
||
* not cross a language boundary: unlike CALLS, a reference edge carries
|
||
* no import-closure evidence at all — a Go test's local `event` and an
|
||
* eBPF C probe's automatic `event` share nothing but the spelling, so
|
||
* EVERY registry strategy is a bare-name guess here and none is exempt.
|
||
* LSP-backed semantic references resolve before the registry fallback
|
||
* and never reach this predicate, which is where a genuine cross-language
|
||
* binding (a future cgo resolver) would live. The JS/TS family keeps its
|
||
* exemption (.js/.ts/.d.ts pairs legitimately share symbols), and C/C++
|
||
* count as one family (.h maps to CBM_LANG_CPP). */
|
||
if (caller_lang == CBM_LANG_COUNT || !target_file_path || !target_file_path[0]) {
|
||
return false;
|
||
}
|
||
CBMLanguage target_lang = cbm_language_for_filename(path_basename(target_file_path));
|
||
if (target_lang == CBM_LANG_COUNT) {
|
||
return false;
|
||
}
|
||
if (caller_lang == target_lang) {
|
||
return false;
|
||
}
|
||
if (js_ts_family(caller_lang) && js_ts_family(target_lang)) {
|
||
return false;
|
||
}
|
||
if (c_cpp_family(caller_lang) && c_cpp_family(target_lang)) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool cbm_suppress_bare_field_ref(CBMLanguage lang, bool is_member_access,
|
||
const char *target_label) {
|
||
/* #1942/#1962: a bare Go identifier can never denote a struct field —
|
||
* field access is always a selector expression (x.f). The extractor
|
||
* strips the receiver before the resolver runs (resolve_lhs_write_name
|
||
* records the trailing field name; is_reference_node records the inner
|
||
* field_identifier), so the reference TEXT is always dot-less and cannot
|
||
* carry the distinction — the recorded is_member_access shape can. Only a
|
||
* reference that was never the member half of a selector is refused a
|
||
* Field bind.
|
||
*
|
||
* #2666: C has the same rule. A struct member is named only as the member
|
||
* half of `a.b`, `a->b` or a designator `.b`, all recorded as
|
||
* field_identifier; a bare C identifier (a macro argument such as
|
||
* RUN_SELECTED_SUITE(mcp), a stray name in an unexpanded macro body) is
|
||
* never one. The call path already drops a bare C call onto a Field
|
||
* (cbm_call_onto_field_policy).
|
||
*
|
||
* Go- and C-gated: a C#/Java/C++/Python method body legitimately
|
||
* references its own members bare (cp_reads_writes_cs_static_field pins
|
||
* that shape as required), so a global veto would break those languages.
|
||
* A `.h` is parsed as C++ and keeps that behavior. */
|
||
if ((lang != CBM_LANG_GO && lang != CBM_LANG_C) || is_member_access || !target_label) {
|
||
return false;
|
||
}
|
||
return strcmp(target_label, "Field") == 0;
|
||
}
|
||
|
||
cbm_field_call_policy_t cbm_call_onto_field_policy(CBMLanguage caller_lang, const char *callee_text,
|
||
const char *target_label,
|
||
const char *target_file_path) {
|
||
/* The short-name registry holds Field nodes, so a call spelled like some
|
||
* struct member can resolve onto that Field. Three shapes are decided here:
|
||
* - a caller in another language: nothing but the spelling connects a
|
||
* Bash `command` or a Python `sorted()` to a C struct member. The
|
||
* unique_name exemption of the CALLS guard (#1572) is about functions;
|
||
* for a Field the #1928 reference rule applies, with its JS/TS and
|
||
* C/C++ family exemptions;
|
||
* - a bare C call (`socket(...)`, a function-pointer parameter
|
||
* `want(x)`): a bare C name is never a struct member;
|
||
* - a C or C++ member call (`cb.close(ctx)`, `text.size()`): the type of
|
||
* the object decides which struct's member is called, never the
|
||
* member name alone. The C LSP publishes owners in both modes.
|
||
* A bare C++ call keeps its resolution (a method body reaches its own
|
||
* members without a selector), and so does every other language. */
|
||
if (!target_label || strcmp(target_label, "Field") != 0) {
|
||
return CBM_FIELD_CALL_KEEP;
|
||
}
|
||
if (cbm_suppress_cross_language_ref(caller_lang, target_file_path)) {
|
||
return CBM_FIELD_CALL_DROP;
|
||
}
|
||
if (caller_lang != CBM_LANG_C && caller_lang != CBM_LANG_CPP) {
|
||
return CBM_FIELD_CALL_KEEP;
|
||
}
|
||
if (callee_text && (strchr(callee_text, '.') || strstr(callee_text, "->"))) {
|
||
return CBM_FIELD_CALL_BY_OWNER;
|
||
}
|
||
return caller_lang == CBM_LANG_C ? CBM_FIELD_CALL_DROP : CBM_FIELD_CALL_KEEP;
|
||
}
|
||
|
||
bool cbm_c_arrow_member_call(CBMLanguage caller_lang, const char *callee_text) {
|
||
if (caller_lang != CBM_LANG_C && caller_lang != CBM_LANG_CPP) {
|
||
return false;
|
||
}
|
||
return callee_text != NULL && strstr(callee_text, "->") != NULL;
|
||
}
|
||
|
||
bool cbm_c_member_rule_file(CBMLanguage lang, const char *rel_path) {
|
||
if (lang == CBM_LANG_C) {
|
||
return true;
|
||
}
|
||
if (lang != CBM_LANG_CPP || !rel_path) {
|
||
return false;
|
||
}
|
||
size_t len = strlen(rel_path);
|
||
return len > PAIR_LEN && strcmp(rel_path + len - PAIR_LEN, ".h") == 0;
|
||
}
|
||
|
||
bool cbm_c_member_binds_by_owner(bool is_c, bool is_member_access) {
|
||
/* The member half of `a.b` / `a->b` is not a name in any C scope: only the
|
||
* type of `a` gives `b` a meaning. Handing the bare `b` to the short-name
|
||
* registry bound whichever Field (or function, or variable) was called
|
||
* `b` -- on this repository 9,150 USAGE and 2,810 WRITES edges onto
|
||
* Fields, 3,976 of the USAGE edges on a name several structs share. Such a
|
||
* reference binds through the C LSP's field-owner rows; an object the LSP
|
||
* could not type binds only a member name that is unique in the project
|
||
* (cbm_registry_unique_field_qn), never one of several.
|
||
*
|
||
* C-gated like the Go rule above: a C++ or Objective-C method body
|
||
* reaches its own members without a selector, and their member accesses
|
||
* keep the existing resolution until their LSPs publish owners too. */
|
||
return is_c && is_member_access;
|
||
}
|
||
|
||
bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label) {
|
||
/* A Java call expression never names data. `p.x()` is a method invocation
|
||
* whatever `x` spells elsewhere in the tree, so a CALLS edge into a
|
||
* Variable or a Field is a spelling collision and not a call.
|
||
*
|
||
* The bind is reached when the type-aware resolver finds no method to
|
||
* return: a record's accessors are implicit, so `record Point(double x,
|
||
* double y, double z)` declares no `x()` for java_lookup_method to find,
|
||
* and every `p.x()` falls through to the short-name registry — which
|
||
* matches the first `x` anywhere in the project, typically an unrelated
|
||
* class's private field. Measured on a 600-file Java tree (2026-09-22):
|
||
* 278 of 2444 CALLS edges landed on fields that way, and the callers of
|
||
* one four-field class absorbed every record read in the project.
|
||
*
|
||
* Java-gated, like the Go guard above, because the veto is only sound
|
||
* where no callable name can be anything but a method: Kotlin properties
|
||
* hold function values (`val f: () -> Unit; f()`) and C has function
|
||
* pointers, so both legitimately call through data. */
|
||
if (!is_java || !target_label) {
|
||
return false;
|
||
}
|
||
return strcmp(target_label, "Variable") == 0 || strcmp(target_label, "Field") == 0;
|
||
}
|
||
|
||
/* ── Lifecycle ──────────────────────────────────────────────────── */
|
||
|
||
cbm_registry_t *cbm_registry_new(void) {
|
||
cbm_registry_t *r = calloc(CBM_ALLOC_ONE, sizeof(cbm_registry_t));
|
||
if (!r) {
|
||
return NULL;
|
||
}
|
||
r->exact = cbm_ht_create(CBM_SZ_1K);
|
||
r->by_name = cbm_ht_create(CBM_SZ_512);
|
||
return r;
|
||
}
|
||
|
||
static void free_label(const char *key, void *value, void *ud) {
|
||
(void)ud;
|
||
(void)value; /* interned in the registry's label_pool */
|
||
free((void *)key);
|
||
}
|
||
|
||
static void free_qn_array(const char *key, void *value, void *ud) {
|
||
(void)ud;
|
||
qn_array_t *arr = value;
|
||
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);
|
||
}
|
||
free((void *)key);
|
||
}
|
||
|
||
void cbm_registry_free(cbm_registry_t *r) {
|
||
if (!r) {
|
||
return;
|
||
}
|
||
/* by_name first: its items borrow exact's keys. */
|
||
cbm_ht_foreach(r->by_name, free_qn_array, NULL);
|
||
cbm_ht_free(r->by_name);
|
||
cbm_ht_foreach(r->exact, free_label, NULL);
|
||
cbm_ht_free(r->exact);
|
||
for (int i = 0; i < r->label_pool_n; i++) {
|
||
free(r->label_pool[i]);
|
||
}
|
||
free(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,
|
||
CBMLanguage lang) {
|
||
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;
|
||
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);
|
||
}
|
||
}
|
||
|
||
static bool registry_label_is_callable(const char *label) {
|
||
return label && (strcmp(label, "Function") == 0 || strcmp(label, "Method") == 0);
|
||
}
|
||
|
||
/* Length of the QN prefix the defs of the caller's file carry: its module QN
|
||
* (cbm_pipeline_fqn_module), minus the trailing `__init__` / `index` segment
|
||
* that symbol QNs drop (pkg/__init__.py defines proj.pkg.f). 0 when there is
|
||
* no module QN or `qualified_name` does not extend that prefix. */
|
||
static size_t python_def_prefix_len(const char *module_qn, const char *qualified_name) {
|
||
static const char *const dropped[] = {".__init__", ".index"};
|
||
size_t n = module_qn ? strlen(module_qn) : 0;
|
||
for (size_t i = 0; i < sizeof(dropped) / sizeof(dropped[0]); i++) {
|
||
size_t k = strlen(dropped[i]);
|
||
if (n > k && strcmp(module_qn + n - k, dropped[i]) == 0) {
|
||
n -= k;
|
||
break;
|
||
}
|
||
}
|
||
if (n == 0 || !qualified_name || strncmp(qualified_name, module_qn, n) != 0 ||
|
||
qualified_name[n] != '.') {
|
||
return 0;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/* Whether `qualified_name` sits inside a def of its OWN file: only prefixes
|
||
* strictly longer than `module_prefix_len` (cbm_pipeline_fqn_symbol_prefix_len
|
||
* of the defining file) count. A package's __init__.py can define a function
|
||
* whose QN equals a submodule's (poetry: layouts/__init__.py `def layout` vs
|
||
* layouts/layout.py), so an unbounded walk read the submodule's classes as
|
||
* that function's locals -- and the answer depended on which file registered
|
||
* first. */
|
||
bool cbm_registry_qn_is_function_local(const cbm_registry_t *r, const char *qualified_name,
|
||
size_t module_prefix_len) {
|
||
char scope[CBM_SZ_512];
|
||
size_t len = qualified_name ? strlen(qualified_name) : 0;
|
||
size_t floor = module_prefix_len;
|
||
if (!r || floor == 0 || len <= floor || len >= sizeof(scope) || qualified_name[floor] != '.') {
|
||
return false;
|
||
}
|
||
memcpy(scope, qualified_name, len + SKIP_ONE);
|
||
for (char *dot = strrchr(scope, '.'); dot && (size_t)(dot - scope) > floor;
|
||
dot = strrchr(scope, '.')) {
|
||
*dot = '\0';
|
||
if (registry_label_is_callable(cbm_ht_get(r->exact, scope))) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* Record `qualified_name` in the exact map. Returns the registry's owned copy
|
||
* of the QN, or NULL when it is already registered or cannot be recorded. */
|
||
static const char *registry_add_exact(cbm_registry_t *r, const char *qualified_name,
|
||
const char *label) {
|
||
/* Check for duplicate */
|
||
if (cbm_ht_get(r->exact, qualified_name)) {
|
||
return NULL;
|
||
}
|
||
|
||
/* Intern the label (bounded set; linear scan is fine at this size). */
|
||
const char *interned = NULL;
|
||
for (int i = 0; i < r->label_pool_n; i++) {
|
||
if (strcmp(r->label_pool[i], label) == 0) {
|
||
interned = r->label_pool[i];
|
||
break;
|
||
}
|
||
}
|
||
if (!interned && r->label_pool_n < (int)(sizeof(r->label_pool) / sizeof(r->label_pool[0]))) {
|
||
r->label_pool[r->label_pool_n] = strdup(label);
|
||
interned = r->label_pool[r->label_pool_n];
|
||
r->label_pool_n++;
|
||
}
|
||
if (!interned) {
|
||
return NULL; /* pool exhausted (cannot happen with sane label sets) */
|
||
}
|
||
|
||
/* Store in exact map: QN → interned label. The key is the registry's ONE
|
||
* owned copy of the QN; by_name borrows it (same lifetime) instead
|
||
* of a second strdup — this pair of copies was ~280 MB on the kernel. */
|
||
cbm_ht_set(r->exact, strdup(qualified_name), (void *)interned);
|
||
return cbm_ht_get_key(r->exact, qualified_name);
|
||
}
|
||
|
||
void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name,
|
||
const char *label) {
|
||
cbm_registry_add_lang_in_module(r, name, qualified_name, label, CBM_LANG_COUNT, 0);
|
||
}
|
||
|
||
void cbm_registry_add_lang(cbm_registry_t *r, const char *name, const char *qualified_name,
|
||
const char *label, CBMLanguage lang) {
|
||
cbm_registry_add_lang_in_module(r, name, qualified_name, label, lang, 0);
|
||
}
|
||
|
||
/* Python nests defs: a def or class declared in a function body -- a closure,
|
||
* an app factory's route handler, a local class and its methods -- is a local
|
||
* of that function, and its QN extends the function's
|
||
* (proj.mod.create_app.index). No code outside the function can name it, so it
|
||
* stays out of the short-name index: the weak name strategies (unique_name,
|
||
* suffix_match) must not offer django's per-test `class MyModelAdmin` or a
|
||
* decorator's `def wrapper` to a caller in another function or file. The exact
|
||
* map still holds it, for LSP targets and cbm_registry_resolve_lexical. One
|
||
* file's defs register parent first, so the enclosing function is already in
|
||
* the exact map when its locals arrive; the walk never leaves the symbol's own
|
||
* file (`module_prefix_len`, cbm_pipeline_fqn_symbol_prefix_len of the defining
|
||
* file; 0 = no local detection).
|
||
* Gated to Python: probing every prefix of every symbol would cost a kernel
|
||
* index seconds for nothing. */
|
||
void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name,
|
||
const char *qualified_name, const char *label,
|
||
CBMLanguage lang, size_t module_prefix_len) {
|
||
if (!r || !qualified_name || !label) {
|
||
return;
|
||
}
|
||
if (lang == CBM_LANG_PYTHON &&
|
||
cbm_registry_qn_is_function_local(r, qualified_name, module_prefix_len)) {
|
||
(void)registry_add_exact(r, qualified_name, label);
|
||
return;
|
||
}
|
||
const char *owned_qn = registry_add_exact(r, qualified_name, label);
|
||
if (!owned_qn) {
|
||
return;
|
||
}
|
||
|
||
/* 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. 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
|
||
* 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.
|
||
*
|
||
* A signature-qualified callable (#2061) is indexed by its bare `name` in
|
||
* place of the derived key, so every overload shares one bucket; any other
|
||
* QN keeps its historical last-segment key. */
|
||
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, lang);
|
||
/* '#' is a QN fence; no extractor mints one for a REGISTERED definition
|
||
* today (Rust cfg twins are variants of one plain QN; a C macro's QN ends in
|
||
* "#macro", but Macro is no registry label, so it never gets here). 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.
|
||
* The passed name is compared with the key already written: a
|
||
* 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, lang);
|
||
}
|
||
}
|
||
|
||
/* A generated member (a Java record's implicit accessor) is a definition the
|
||
* source never spells: no call names it except through its owner's type. The
|
||
* exact map holds it for the LSP targets and the import/same-module lookups,
|
||
* as it holds a Python function's locals; the by-name index does not, so no
|
||
* unique_name / suffix_match / field_type_hint / fuzzy guess can land on it. */
|
||
void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label) {
|
||
if (!r || !qualified_name || !label) {
|
||
return;
|
||
}
|
||
(void)registry_add_exact(r, qualified_name, label);
|
||
}
|
||
|
||
bool cbm_def_props_generated(const char *const *extra_props) {
|
||
for (const char *const *kv = extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) {
|
||
if (strcmp(kv[0], CBM_PROP_GENERATED) == 0) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool cbm_node_props_generated(const char *properties_json) {
|
||
/* The key as the definition passes write it: "generated":"<value>". */
|
||
return properties_json && strstr(properties_json, "\"" CBM_PROP_GENERATED "\":\"") != NULL;
|
||
}
|
||
|
||
/* ── Lookup ──────────────────────────────────────────────────────── */
|
||
|
||
bool cbm_registry_exists(const cbm_registry_t *r, const char *qn) {
|
||
if (!r || !qn) {
|
||
return false;
|
||
}
|
||
return cbm_ht_get(r->exact, qn) != NULL;
|
||
}
|
||
|
||
const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn) {
|
||
if (!r || !qn) {
|
||
return NULL;
|
||
}
|
||
return cbm_ht_get(r->exact, qn);
|
||
}
|
||
|
||
const char *cbm_registry_value_target(const cbm_registry_t *r, const char *qn) {
|
||
const char *label = cbm_registry_label_of(r, qn);
|
||
if (!label || strcmp(label, "Method") != 0) {
|
||
return qn;
|
||
}
|
||
char twin[CBM_SZ_1K];
|
||
int n = snprintf(twin, sizeof(twin), "%s#field", qn);
|
||
if (n < 0 || (size_t)n >= sizeof(twin)) {
|
||
return qn;
|
||
}
|
||
const char *owned = cbm_ht_get_key(r->exact, twin);
|
||
return owned ? owned : qn;
|
||
}
|
||
|
||
/* Python evaluates a class statement's bases in the namespace of the body
|
||
* that encloses it. For a class nested in a class, a bare base naming a class
|
||
* defined EARLIER in that same class body is that sibling
|
||
* (`class IntegrityError(DatabaseError)` in sqlalchemy's asyncpg adapter),
|
||
* whatever same-named classes the project has elsewhere. A sibling defined
|
||
* later is not bound yet when the base is evaluated. Exact lookups only. */
|
||
const char *cbm_registry_resolve_python_class_body_base(const cbm_registry_t *r,
|
||
const cbm_gbuf_t *gb, const char *class_qn,
|
||
const char *base, CBMLanguage lang) {
|
||
if (lang != CBM_LANG_PYTHON || !r || !gb || !class_qn || !base || !base[0] ||
|
||
strchr(base, '.')) {
|
||
return NULL;
|
||
}
|
||
const char *dot = strrchr(class_qn, '.');
|
||
if (!dot || dot == class_qn) {
|
||
return NULL;
|
||
}
|
||
char sibling[CBM_SZ_1K];
|
||
int enclosing_len = (int)(dot - class_qn);
|
||
int n = snprintf(sibling, sizeof(sibling), "%.*s", enclosing_len, class_qn);
|
||
if (n < 0 || (size_t)n >= sizeof(sibling) ||
|
||
!cbm_label_is_type_like(cbm_registry_label_of(r, sibling))) {
|
||
return NULL; /* not nested in a class body (module level, or a def) */
|
||
}
|
||
n = snprintf(sibling, sizeof(sibling), "%.*s.%s", enclosing_len, class_qn, base);
|
||
if (n < 0 || (size_t)n >= sizeof(sibling) || strcmp(sibling, class_qn) == 0) {
|
||
return NULL;
|
||
}
|
||
const char *owned = cbm_ht_get_key(r->exact, sibling);
|
||
if (!owned || !cbm_label_is_type_like(cbm_registry_label_of(r, owned))) {
|
||
return NULL;
|
||
}
|
||
const cbm_gbuf_node_t *cls = cbm_gbuf_find_by_qn(gb, class_qn);
|
||
const cbm_gbuf_node_t *sib = cbm_gbuf_find_by_qn(gb, owned);
|
||
if (!cls || !sib || !cls->file_path || !sib->file_path ||
|
||
strcmp(cls->file_path, sib->file_path) != 0 || sib->start_line >= cls->start_line) {
|
||
return NULL;
|
||
}
|
||
return owned;
|
||
}
|
||
|
||
int cbm_registry_find_by_name(const cbm_registry_t *r, const char *name, const char ***out,
|
||
int *count) {
|
||
if (!r || !out || !count) {
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
qn_array_t *arr = cbm_ht_get(r->by_name, name);
|
||
if (arr && arr->count > 0) {
|
||
*out = (const char **)arr->items;
|
||
*count = arr->count;
|
||
} else {
|
||
*out = NULL;
|
||
*count = 0;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
const char *cbm_registry_unique_field_qn(const cbm_registry_t *r, const char *member_name) {
|
||
/* A member name that exactly one Field in the whole project carries names
|
||
* that Field without any choice being made. This is what an untyped C
|
||
* member access may still bind: the defect in bare-name member resolution
|
||
* was the arbitrary pick among SEVERAL same-named members (kernel: 359,236
|
||
* of the edges a typed-only rule dropped were onto such a unique name).
|
||
* Only Fields are candidates -- a function or variable of the same name
|
||
* can never be what `a.b` means -- and two of them bind nothing. */
|
||
const char **qns = NULL;
|
||
int count = 0;
|
||
if (!member_name || cbm_registry_find_by_name(r, member_name, &qns, &count) != 0 || !qns) {
|
||
return NULL;
|
||
}
|
||
const char *only = NULL;
|
||
for (int i = 0; i < count; i++) {
|
||
const char *label = qns[i] ? cbm_registry_label_of(r, qns[i]) : NULL;
|
||
if (!label || strcmp(label, "Field") != 0) {
|
||
continue;
|
||
}
|
||
if (only) {
|
||
return NULL;
|
||
}
|
||
only = qns[i];
|
||
}
|
||
return only;
|
||
}
|
||
|
||
bool cbm_registry_has_type_named(const cbm_registry_t *r, const char *name) {
|
||
if (!r || !name || !name[0]) {
|
||
return false;
|
||
}
|
||
const char **qns = NULL;
|
||
int count = 0;
|
||
cbm_registry_find_by_name(r, name, &qns, &count);
|
||
for (int i = 0; i < count; i++) {
|
||
if (cbm_label_is_type_like(cbm_registry_label_of(r, qns[i]))) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
int cbm_registry_size(const cbm_registry_t *r) {
|
||
return r ? (int)cbm_ht_count(r->exact) : 0;
|
||
}
|
||
|
||
/* ── Resolution ──────────────────────────────────────────────────── */
|
||
|
||
/* Callback context for import_map_suffix scan */
|
||
/* Strategy 1: Import map lookup (exact → suffix fallback) */
|
||
static cbm_resolution_t resolve_import_map(const cbm_registry_t *r, const char *prefix,
|
||
const char *suffix, const char **keys, const char **vals,
|
||
int map_count) {
|
||
if (!keys || !vals || map_count <= 0) {
|
||
return empty_result();
|
||
}
|
||
|
||
/* Find prefix in import map keys. Prefer the per-file TLS hash
|
||
* cache (O(1)) over the linear scan when available. */
|
||
const char *resolved = NULL;
|
||
if (_import_map_cache) {
|
||
resolved = (const char *)cbm_ht_get(_import_map_cache, prefix);
|
||
} else {
|
||
for (int i = 0; i < map_count; i++) {
|
||
if (strcmp(keys[i], prefix) == 0) {
|
||
resolved = vals[i];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!resolved) {
|
||
return empty_result();
|
||
}
|
||
|
||
/* Build candidate: resolved.suffix or resolved.prefix.
|
||
* When the callee has a dot ("pkg.Func"), prefix="pkg" is the import key
|
||
* and suffix="Func" is the function name, so the target QN is
|
||
* resolved.Func. When the callee is bare ("requireAdmin"), prefix IS the
|
||
* function name and suffix is NULL, so the target QN must be
|
||
* resolved.requireAdmin — not just resolved, which would point at the
|
||
* module node and miss the function entirely. */
|
||
/* Direct hit ONLY for suffix-less callees (an aliased direct-symbol
|
||
* import called bare: `from m import f as g; g()` — #875/#979; Yui
|
||
* `import execute as bridge_execute`). With a suffix present
|
||
* (`imported.method()`), returning the bare base here would swallow
|
||
* the suffix and bind the call to the imported symbol's own node
|
||
* (a Variable/Class/module) instead of base.method — exactly the
|
||
* mis-resolution the comment above warns about. That regressed
|
||
* django-scale graphs by ~11K CALLS/TESTS edges (Signal.send calls
|
||
* degraded to edges onto the signal variables themselves). #1000 */
|
||
if (!suffix || !suffix[0]) {
|
||
const char *direct = cbm_ht_get_key(r->exact, resolved);
|
||
if (direct) {
|
||
return (cbm_resolution_t){direct, "import_map", CONF_IMPORT_MAP, REG_RESOLVED};
|
||
}
|
||
}
|
||
char candidate[CBM_SZ_512];
|
||
if (suffix && suffix[0]) {
|
||
snprintf(candidate, sizeof(candidate), "%s.%s", resolved, suffix);
|
||
} else {
|
||
snprintf(candidate, sizeof(candidate), "%s.%s", resolved, prefix);
|
||
}
|
||
/* Use cbm_ht_get_key to get the persistent heap-owned key string */
|
||
const char *stored_key = cbm_ht_get_key(r->exact, candidate);
|
||
if (stored_key) {
|
||
return (cbm_resolution_t){stored_key, "import_map", CONF_IMPORT_MAP, REG_RESOLVED};
|
||
}
|
||
|
||
/* import_map_suffix fallback: find a QN starting with resolved+"." and
|
||
* ending with "."+suffix. Any such QN's last segment equals the last
|
||
* segment of suffix, so probe the by_name index and tail-check the (few)
|
||
* candidates instead of cbm_ht_foreach over the WHOLE exact table — that
|
||
* scan ran per unresolved call and dominated elasticsearch's resolve
|
||
* phase (94% of samples: 700k-entry foreach + strlen per entry). */
|
||
if (suffix && suffix[0]) {
|
||
char resolved_dot[CBM_SZ_512];
|
||
char dot_suffix[CBM_SZ_256];
|
||
snprintf(resolved_dot, sizeof(resolved_dot), "%s.", resolved);
|
||
snprintf(dot_suffix, sizeof(dot_suffix), ".%s", suffix);
|
||
qn_array_t *arr = cbm_ht_get(r->by_name, simple_name(suffix));
|
||
if (arr) {
|
||
size_t rd_len = strlen(resolved_dot);
|
||
size_t ds_len = strlen(dot_suffix);
|
||
for (int i = 0; i < arr->count; i++) {
|
||
const char *qn = arr->items[i];
|
||
size_t klen = strlen(qn);
|
||
if (klen >= rd_len + ds_len && strncmp(qn, resolved_dot, rd_len) == 0 &&
|
||
strcmp(qn + klen - ds_len, dot_suffix) == 0) {
|
||
return (cbm_resolution_t){qn, "import_map_suffix", CONF_IMPORT_MAP_SUFFIX,
|
||
REG_RESOLVED};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return empty_result();
|
||
}
|
||
|
||
/* Exact same-module lookup without ever hashing a truncated candidate. The
|
||
* long path uses the canonical short-name bucket, not heuristic name limits. */
|
||
static const char *same_module_candidate(const cbm_registry_t *r, const char *module_qn,
|
||
size_t scope_len, const char *name) {
|
||
if (!module_qn || !name)
|
||
return NULL;
|
||
size_t name_len = strlen(name);
|
||
char candidate[CBM_SZ_512];
|
||
if (scope_len < sizeof(candidate) && name_len < sizeof(candidate) - scope_len - 1) {
|
||
memcpy(candidate, module_qn, scope_len);
|
||
candidate[scope_len] = '.';
|
||
memcpy(candidate + scope_len + 1, name, name_len + 1);
|
||
return cbm_ht_get_key(r->exact, candidate);
|
||
}
|
||
qn_array_t *arr = (qn_array_t *)cbm_ht_get(r->by_name, simple_name(name));
|
||
if (!arr)
|
||
return NULL;
|
||
for (int i = 0; i < arr->count; i++) {
|
||
const char *qn = arr->items[i];
|
||
if (!qn)
|
||
continue;
|
||
size_t qn_len = strlen(qn);
|
||
if (qn_len > scope_len && qn_len - scope_len - 1 == name_len &&
|
||
memcmp(qn, module_qn, scope_len) == 0 && qn[scope_len] == '.' &&
|
||
memcmp(qn + scope_len + 1, name, name_len) == 0)
|
||
return qn;
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* Strategy 2: Same-module match. Scoring elsewhere retains the raw file QN. */
|
||
static cbm_resolution_t resolve_same_module(const cbm_registry_t *r, const char *callee_name,
|
||
const char *suffix, const char *module_qn,
|
||
size_t scope_len) {
|
||
const char *stored_key = same_module_candidate(r, module_qn, scope_len, callee_name);
|
||
if (!stored_key && suffix && suffix[0])
|
||
stored_key = same_module_candidate(r, module_qn, scope_len, suffix);
|
||
if (stored_key)
|
||
return (cbm_resolution_t){stored_key, "same_module", CONF_SAME_MODULE, REG_RESOLVED};
|
||
return empty_result();
|
||
}
|
||
|
||
/* Strategy 4: multiple candidates with import filtering. */
|
||
static cbm_resolution_t resolve_multi_with_imports(const cbm_registry_t *r, const qn_array_t *arr,
|
||
const char *module_qn, const char **import_vals,
|
||
int import_count) {
|
||
const char *filtered[CBM_SZ_256];
|
||
uint8_t filtered_test[CBM_SZ_256];
|
||
uint8_t filtered_lang[CBM_SZ_256];
|
||
const uint8_t *flags = qn_test_flags(arr);
|
||
const uint8_t *langs = qn_lang_flags(arr);
|
||
int fcount = 0;
|
||
for (int i = 0; i < arr->count && fcount < CBM_SZ_256; i++) {
|
||
if (is_import_reachable(arr->items[i], import_vals, import_count)) {
|
||
if (flags) {
|
||
filtered_test[fcount] = flags[i];
|
||
}
|
||
if (langs) {
|
||
filtered_lang[fcount] = langs[i];
|
||
}
|
||
filtered[fcount] = arr->items[i];
|
||
fcount++;
|
||
}
|
||
}
|
||
if (fcount == SKIP_ONE) {
|
||
double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, arr->count);
|
||
return (cbm_resolution_t){filtered[0], "suffix_match", conf, arr->count};
|
||
}
|
||
if (fcount > SKIP_ONE) {
|
||
const char *best = best_by_import_distance(r, filtered, flags ? filtered_test : NULL,
|
||
langs ? filtered_lang : NULL, fcount, module_qn);
|
||
if (best) {
|
||
double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, fcount);
|
||
return (cbm_resolution_t){best, "suffix_match", conf, fcount};
|
||
}
|
||
}
|
||
/* No import-reachable — use all candidates with penalty */
|
||
const char *best = best_by_import_distance(r, (const char **)arr->items, qn_test_flags(arr),
|
||
qn_lang_flags(arr), arr->count, module_qn);
|
||
if (best) {
|
||
double conf = candidate_count_penalty(CONF_SUFFIX_MATCH * REG_HALF_PENALTY, arr->count);
|
||
return (cbm_resolution_t){best, "suffix_match", conf, arr->count};
|
||
}
|
||
return empty_result();
|
||
}
|
||
|
||
/* Confidence for a full qualified-tail match (Strategy 3.5). A package- or
|
||
* namespace-qualified callee that uniquely matches one candidate's full tail is
|
||
* as trustworthy as a same-module hit. */
|
||
#define CONF_QUALIFIED_SUFFIX 0.90
|
||
|
||
/* When a callee is package/namespace-qualified (Foo::Bar::sub or Foo.Bar.sub),
|
||
* disambiguate among same-simple-name candidates by matching the FULL qualified
|
||
* tail against each candidate QN at a segment boundary. Returns the sole
|
||
* candidate whose QN equals or ends with ".<dotted-callee>", or NULL when zero
|
||
* or several candidates match (the caller then falls back to bare-name scoring).
|
||
*
|
||
* Fixes qualified cross-file calls collapsing onto one namespace when the bare
|
||
* symbol name is defined in several — e.g. Perl's Foo::Bar::run and Foo::Baz::run
|
||
* both reduce to "run", so the bare-name scorer would route every caller to a
|
||
* single winner. Language agnostic: callees with no separator return NULL and
|
||
* leave behavior unchanged. */
|
||
static const char *qualified_suffix_match(const qn_array_t *arr, const char *callee_name) {
|
||
/* Normalize "::" → "." so the tail composes with dotted candidate QNs. */
|
||
char dotted[CBM_SZ_512];
|
||
size_t w = 0;
|
||
for (const char *s = callee_name; *s && w + SKIP_ONE < sizeof(dotted);) {
|
||
if (s[0] == ':' && s[1] == ':') {
|
||
dotted[w++] = '.';
|
||
s += 2;
|
||
} else {
|
||
dotted[w++] = *s++;
|
||
}
|
||
}
|
||
dotted[w] = '\0';
|
||
/* Must be qualified (contain a '.') — a bare name matches every candidate
|
||
* and carries no disambiguating signal. */
|
||
if (!strchr(dotted, '.')) {
|
||
return NULL;
|
||
}
|
||
const char *match = NULL;
|
||
for (int i = 0; i < arr->count; i++) {
|
||
const char *qn = arr->items[i];
|
||
size_t qlen = strlen(qn);
|
||
if (qlen < w) {
|
||
continue;
|
||
}
|
||
const char *tail = qn + (qlen - w);
|
||
if (strcmp(tail, dotted) != 0) {
|
||
continue;
|
||
}
|
||
/* Segment boundary: tail is the whole QN or is preceded by '.'. */
|
||
if (tail != qn && tail[-1] != '.') {
|
||
continue;
|
||
}
|
||
if (match) {
|
||
return NULL; /* ambiguous — more than one qualified tail matches */
|
||
}
|
||
match = qn;
|
||
}
|
||
return match;
|
||
}
|
||
|
||
/* A dotted callee whose FIRST segment starts upper-case names a type — URLSession,
|
||
* Calendar, JSONEncoder. That receiver chain is evidence the bare-name scorers
|
||
* throw away, and throwing it away binds Foundation's URLSession.shared.data to
|
||
* a project's own PickedFile.data: high confidence, and nothing in the graph
|
||
* shows it is wrong. Require instead that the candidate's own parent segment
|
||
* appears somewhere in the chain. Calendar.utcGregorian.startOfDayUTC resolving
|
||
* to AuthDTOs.Calendar.startOfDayUTC passes, because Calendar is in the chain.
|
||
*
|
||
* Only an upper-case first segment is guarded. A lower-case root names a value
|
||
* (vm.load, http.Get, os.path.join) whose declared type the chain does not
|
||
* show, so the chain proves nothing there and the call passes through
|
||
* unchanged. A callee with no separator passes through as well.
|
||
*
|
||
* Language agnostic by design: the registry holds no language, and every
|
||
* language that writes receiver chains gains the same protection. */
|
||
static bool receiver_chain_admits(const char *callee_name, const char *candidate_qn) {
|
||
/* Normalize "::" -> "." so the chain composes with dotted candidate QNs,
|
||
* the same way qualified_suffix_match does. */
|
||
char dotted[CBM_SZ_512];
|
||
size_t w = 0;
|
||
for (const char *s = callee_name; *s && w + SKIP_ONE < sizeof(dotted);) {
|
||
if (s[0] == ':' && s[1] == ':') {
|
||
dotted[w++] = '.';
|
||
s += 2;
|
||
} else {
|
||
dotted[w++] = *s++;
|
||
}
|
||
}
|
||
dotted[w] = '\0';
|
||
|
||
const char *last_dot = strrchr(dotted, '.');
|
||
if (!last_dot) {
|
||
return true; /* bare name — no receiver chain to judge */
|
||
}
|
||
if (dotted[0] < 'A' || dotted[0] > 'Z') {
|
||
return true; /* lower-case root names a value, not a type */
|
||
}
|
||
/* A name written in capitals with underscores is a constant holding a
|
||
* value, not a type: ISO_4217_URL.lower is a string's own method. JSON and
|
||
* URL carry no underscore and stay guarded. */
|
||
int has_underscore = 0;
|
||
int all_caps = 1;
|
||
for (const char *c = dotted; c < last_dot && *c != '.'; c++) {
|
||
if (*c == '_') {
|
||
has_underscore = 1;
|
||
} else if (*c >= 'a' && *c <= 'z') {
|
||
all_caps = 0;
|
||
break;
|
||
}
|
||
}
|
||
if (all_caps && has_underscore) {
|
||
return true;
|
||
}
|
||
|
||
/* The candidate's ancestry: every segment before its final name. */
|
||
const char *cand_last = strrchr(candidate_qn, '.');
|
||
if (!cand_last || cand_last == candidate_qn) {
|
||
return true; /* top-level candidate — no ancestry to look for */
|
||
}
|
||
|
||
/* Walk the chain — every segment before the final callee name. A trailing
|
||
* "()" is dropped so JSONEncoder().encode reads as JSONEncoder. A chain
|
||
* segment admits the candidate when it names ANY segment of the candidate's
|
||
* ancestry, not only the immediate parent: Settings.builder().putList binds
|
||
* settings.Settings.Builder.putList because Settings owns Builder, and
|
||
* RequestOptions.DEFAULT.toBuilder().setWarningsHandler binds
|
||
* RequestOptions.Builder.setWarningsHandler the same way. Measured on
|
||
* elastic/elasticsearch: the parent-only rule refused 9,473 unique-name
|
||
* calls, most of them exactly these factory chains into a nested Builder.
|
||
* A foreign root still finds nothing in the ancestry — Base64.getEncoder
|
||
* does not admit DocOffsetsCodec.getEncoder, URLSession.shared.data does
|
||
* not admit PickedFile.data — so the #1893 refusals hold. */
|
||
for (const char *seg = dotted; seg < last_dot;) {
|
||
const char *end = strchr(seg, '.');
|
||
size_t len = (size_t)(end - seg);
|
||
if (len >= 2 && seg[len - 2] == '(' && seg[len - 1] == ')') {
|
||
len -= 2; /* an empty "()" — JSONEncoder().encode names JSONEncoder */
|
||
}
|
||
for (const char *anc = candidate_qn; anc < cand_last;) {
|
||
const char *anc_end = strchr(anc, '.');
|
||
size_t anc_len = (size_t)(anc_end - anc);
|
||
if (anc_len == len && len > 0 && strncmp(seg, anc, len) == 0) {
|
||
return true;
|
||
}
|
||
anc = anc_end + SKIP_ONE;
|
||
}
|
||
seg = end + SKIP_ONE;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* The same-name candidates without the members -- Field and Method nodes --
|
||
* in `view` (backed by the caller's buffers, at most REG_MAX_CANDIDATES
|
||
* entries). The side arrays are carried over only when the bucket has them
|
||
* for every entry. */
|
||
static void candidates_without_members(const cbm_registry_t *r, const qn_array_t *arr,
|
||
qn_array_t *view, char **items, uint8_t *is_test,
|
||
uint8_t *lang) {
|
||
const uint8_t *flags = qn_test_flags(arr);
|
||
const uint8_t *langs = qn_lang_flags(arr);
|
||
bool side = flags && langs;
|
||
int n = 0;
|
||
for (int i = 0; i < arr->count; i++) {
|
||
const char *label = cbm_registry_label_of(r, arr->items[i]);
|
||
if (label && (strcmp(label, "Field") == 0 || strcmp(label, "Method") == 0)) {
|
||
continue;
|
||
}
|
||
items[n] = arr->items[i];
|
||
if (side) {
|
||
is_test[n] = flags[i];
|
||
lang[n] = langs[i];
|
||
}
|
||
n++;
|
||
}
|
||
*view = (qn_array_t){.items = items,
|
||
.count = n,
|
||
.cap = n,
|
||
.is_test = side ? is_test : NULL,
|
||
.is_test_cap = side ? n : 0,
|
||
.lang = side ? lang : NULL};
|
||
}
|
||
|
||
/* Strategies 3.5, 3 and 4 over one same-name candidate set. */
|
||
static cbm_resolution_t resolve_name_candidates(const cbm_registry_t *r, const qn_array_t *arr,
|
||
const char *callee_name, const char *module_qn,
|
||
const char **import_vals, int import_count) {
|
||
/* Strategy 3.5: a qualified callee disambiguates among multiple same-name
|
||
* candidates by full qualified tail, before bare-name scoring collapses
|
||
* them onto a single winner. */
|
||
if (arr->count > 1) {
|
||
const char *q = qualified_suffix_match(arr, callee_name);
|
||
if (q) {
|
||
return (cbm_resolution_t){q, "qualified_suffix", CONF_QUALIFIED_SUFFIX, REG_RESOLVED};
|
||
}
|
||
}
|
||
|
||
/* Strategy 3: unique name */
|
||
if (arr->count == SKIP_ONE) {
|
||
if (!receiver_chain_admits(callee_name, arr->items[0])) {
|
||
return empty_result();
|
||
}
|
||
double conf = CONF_UNIQUE_NAME;
|
||
if (import_vals && import_count > 0 &&
|
||
!is_import_reachable(arr->items[0], import_vals, import_count)) {
|
||
conf *= DEFAULT_CONFIDENCE;
|
||
}
|
||
return (cbm_resolution_t){arr->items[0], "unique_name", conf, REG_RESOLVED};
|
||
}
|
||
|
||
/* Strategy 4: multiple candidates */
|
||
if (import_vals && import_count > 0) {
|
||
return resolve_multi_with_imports(r, arr, module_qn, import_vals, import_count);
|
||
}
|
||
const char *best = best_by_import_distance(r, (const char **)arr->items, qn_test_flags(arr),
|
||
qn_lang_flags(arr), arr->count, module_qn);
|
||
if (best) {
|
||
if (!receiver_chain_admits(callee_name, best)) {
|
||
return empty_result();
|
||
}
|
||
double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, arr->count);
|
||
return (cbm_resolution_t){best, "suffix_match", conf, arr->count};
|
||
}
|
||
return empty_result();
|
||
}
|
||
|
||
/* Strategy 3+4: Name lookup + suffix match. skip_members: the callee cannot
|
||
* name a member (a bare C call: C has no methods, and a struct member is only
|
||
* ever reached through its object), so Field and Method nodes do not compete.
|
||
* With a Field in the bucket, a unique header function became a suffix_match
|
||
* that the #725 guard then refused (.h is C++): kernel `put_page(page)`
|
||
* 345 -> 9 callers (2026-10-13 graph audit). Dropping only the Fields made
|
||
* libc `read()` the unique name of a C++ adapter's Method instead. */
|
||
static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char *callee_name,
|
||
const char *module_qn, const char **import_vals,
|
||
int import_count, bool skip_members) {
|
||
const char *lookup = simple_name(callee_name);
|
||
const qn_array_t *arr = cbm_ht_get(r->by_name, lookup);
|
||
if (!arr || arr->count == 0) {
|
||
return empty_result();
|
||
}
|
||
if (arr->count > REG_MAX_CANDIDATES) {
|
||
return empty_result(); /* unresolvably ambiguous — see REG_MAX_CANDIDATES */
|
||
}
|
||
if (skip_members) {
|
||
qn_array_t view;
|
||
char *view_items[REG_MAX_CANDIDATES];
|
||
uint8_t view_test[REG_MAX_CANDIDATES];
|
||
uint8_t view_lang[REG_MAX_CANDIDATES];
|
||
candidates_without_members(r, arr, &view, view_items, view_test, view_lang);
|
||
if (view.count == 0) {
|
||
return empty_result();
|
||
}
|
||
return resolve_name_candidates(r, &view, callee_name, module_qn, import_vals, import_count);
|
||
}
|
||
return resolve_name_candidates(r, arr, callee_name, module_qn, import_vals, import_count);
|
||
}
|
||
|
||
/* The strategy chain shared by both public resolve variants (no caching here —
|
||
* cbm_registry_resolve owns the per-file cache). */
|
||
static cbm_resolution_t registry_resolve_chain(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,
|
||
size_t scope_len, bool skip_members) {
|
||
/* Split callee at the first path separator: "pkg.Func" → prefix="pkg",
|
||
* suffix="Func". Rust/C++ use "::" rather than ".", so honor whichever
|
||
* separator appears first ("lib::square" → prefix="lib", suffix="square").
|
||
* Both separators are unambiguous, so handling "::" never affects "."-only
|
||
* callees. */
|
||
char prefix[CBM_SZ_256] = {0};
|
||
const char *suffix = NULL;
|
||
const char *dot = strchr(callee_name, '.');
|
||
const char *colons = strstr(callee_name, "::");
|
||
const char *sep = dot;
|
||
size_t sep_len = SKIP_ONE; /* length of '.' */
|
||
if (colons && (!sep || colons < sep)) {
|
||
sep = colons;
|
||
sep_len = 2; /* length of "::" */
|
||
}
|
||
if (sep) {
|
||
size_t plen = sep - callee_name;
|
||
if (plen >= sizeof(prefix)) {
|
||
plen = sizeof(prefix) - SKIP_ONE;
|
||
}
|
||
memcpy(prefix, callee_name, plen);
|
||
prefix[plen] = '\0';
|
||
suffix = sep + sep_len;
|
||
} else {
|
||
snprintf(prefix, sizeof(prefix), "%s", callee_name);
|
||
}
|
||
|
||
/* Strategy 1: import map */
|
||
cbm_resolution_t res =
|
||
resolve_import_map(r, prefix, suffix, import_map_keys, import_map_vals, import_map_count);
|
||
if (!(res.qualified_name && res.qualified_name[0])) {
|
||
/* Strategy 2: same module */
|
||
res = resolve_same_module(r, callee_name, suffix, module_qn, scope_len);
|
||
}
|
||
if (!(res.qualified_name && res.qualified_name[0])) {
|
||
/* Strategy 3+4: name lookup */
|
||
res = resolve_name_lookup(r, callee_name, module_qn, import_map_vals, import_map_count,
|
||
skip_members);
|
||
}
|
||
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));
|
||
}
|
||
|
||
static cbm_resolution_t registry_resolve_scoped(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,
|
||
size_t scope_len, bool skip_members) {
|
||
if (!r || !callee_name) {
|
||
return empty_result();
|
||
}
|
||
|
||
/* Per-file cache: same callee_name in N call sites → 1 chain walk
|
||
* + N-1 O(1) hash hits. module_qn is constant per file so the
|
||
* cache key only needs callee_name. */
|
||
resolve_cache_entry_t *cached =
|
||
_resolve_cache ? (resolve_cache_entry_t *)cbm_ht_get(_resolve_cache, callee_name) : NULL;
|
||
if (cached && cached->scope_len == scope_len && cached->skip_members == skip_members)
|
||
return cached->res;
|
||
|
||
cbm_resolution_t res =
|
||
registry_resolve_chain(r, callee_name, module_qn, import_map_keys, import_map_vals,
|
||
import_map_count, scope_len, skip_members);
|
||
|
||
/* Data relations (Table/View) are lineage-only registry members: common
|
||
* table names (users, orders, config) collide with code identifiers across
|
||
* every language, so the DEFAULT resolve never returns them — a veto, not a
|
||
* re-route, so a name-collision does not fall through to a weaker strategy.
|
||
* Every consumer (CALLS/USAGE/READS/WRITES/THROWS/handlers/decorators,
|
||
* present and future) is thereby relation-safe by construction. The SQL
|
||
* lineage path opts in via cbm_registry_resolve_lineage. */
|
||
if (res.qualified_name && res.qualified_name[0] &&
|
||
cbm_label_is_relation(cbm_registry_label_of(r, res.qualified_name))) {
|
||
res = empty_result();
|
||
}
|
||
|
||
/* Cache the result (including empty — caching the negative answer
|
||
* is just as valuable; same name asks the same question). */
|
||
if (cached) {
|
||
/* Same key, different effective scope: reuse its owned entry/key. */
|
||
cached->res = res;
|
||
cached->scope_len = scope_len;
|
||
cached->skip_members = skip_members;
|
||
} else if (_resolve_cache) {
|
||
resolve_cache_entry_t *e = (resolve_cache_entry_t *)malloc(sizeof(*e));
|
||
if (e) {
|
||
e->res = res;
|
||
e->scope_len = scope_len;
|
||
e->skip_members = skip_members;
|
||
char *kdup = strdup(callee_name);
|
||
if (kdup) {
|
||
cbm_ht_set(_resolve_cache, kdup, e);
|
||
} else {
|
||
free(e);
|
||
}
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
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) {
|
||
return registry_resolve_scoped(r, callee_name, module_qn, import_map_keys, import_map_vals,
|
||
import_map_count, module_qn ? strlen(module_qn) : 0, false);
|
||
}
|
||
|
||
static size_t lang_scope_len(const char *module_qn, CBMLanguage lang) {
|
||
return lang == CBM_LANG_PYTHON ? cbm_fqn_symbol_scope_len(module_qn)
|
||
: (module_qn ? strlen(module_qn) : 0);
|
||
}
|
||
|
||
cbm_resolution_t cbm_registry_resolve_lang(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,
|
||
CBMLanguage lang) {
|
||
return registry_resolve_scoped(r, callee_name, module_qn, import_map_keys, import_map_vals,
|
||
import_map_count, lang_scope_len(module_qn, lang), false);
|
||
}
|
||
|
||
cbm_resolution_t cbm_registry_resolve_call_lang(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,
|
||
CBMLanguage lang) {
|
||
/* A bare C call never names a member: a struct member is reached through
|
||
* its object (cbm_call_onto_field_policy refuses every Field it lands on)
|
||
* and C has no methods. So its Field and Method namesakes do not compete
|
||
* for the name; a C member call and every other language keep them. */
|
||
bool skip_members = lang == CBM_LANG_C && callee_name && !strchr(callee_name, '.') &&
|
||
!strstr(callee_name, "->");
|
||
return registry_resolve_scoped(r, callee_name, module_qn, import_map_keys, import_map_vals,
|
||
import_map_count, lang_scope_len(module_qn, lang), skip_members);
|
||
}
|
||
|
||
/* ── Class-qualified route handlers (PHP, #1146) ─────────────────── */
|
||
|
||
/* Method QNs are path-based: <project>.<dirs>.<file stem>.<Class>.<method>.
|
||
* A PHP class is named by its namespace instead, so a "Ns\\Class::method"
|
||
* handler is placed on a QN by what places the class file itself. */
|
||
#define CONF_PSR4_MEMBER 0.95
|
||
#define CONF_NAMESPACE_PATH_MEMBER 0.90
|
||
|
||
static bool seg_eq_nocase(const char *a, size_t alen, const char *b, size_t blen) {
|
||
if (alen != blen) {
|
||
return false;
|
||
}
|
||
for (size_t i = 0; i < alen; i++) {
|
||
char ca = (char)((a[i] >= 'A' && a[i] <= 'Z') ? a[i] - 'A' + 'a' : a[i]);
|
||
char cb = (char)((b[i] >= 'A' && b[i] <= 'Z') ? b[i] - 'A' + 'a' : b[i]);
|
||
if (ca != cb) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* Does qn end with ".<cls>.<member>"? Returns the offset of that tail or -1. */
|
||
static int qn_class_member_tail(const char *qn, const char *cls, size_t cls_len,
|
||
const char *member) {
|
||
size_t qlen = strlen(qn);
|
||
size_t mlen = strlen(member);
|
||
if (qlen < cls_len + mlen + PAIR_LEN) {
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
const char *m = qn + qlen - mlen;
|
||
const char *c = m - SKIP_ONE - cls_len;
|
||
if (strcmp(m, member) != 0 || m[-SKIP_ONE] != '.' || strncmp(c, cls, cls_len) != 0 ||
|
||
c[-SKIP_ONE] != '.') {
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
return (int)(c - SKIP_ONE - qn);
|
||
}
|
||
|
||
/* PSR-4: the class file Composer's autoloader loads for the FQN, the class it
|
||
* defines, and that class's member — through the PSR-4 import resolver
|
||
* (cbm_pipeline_psr4_member_qn), so a root whose folder does not mirror its
|
||
* namespace (Acme\\Blog\\ -> packages/blog/src) places the class as well.
|
||
* Returns its tri-state outcome; *out_qn is the member QN when RESOLVED. */
|
||
static cbm_psr4_member_t psr4_member_qn(const cbm_gbuf_t *gbuf, const char *fqn, size_t fqn_len,
|
||
const char *member, const char **out_qn) {
|
||
char cls_fqn[CBM_SZ_512];
|
||
*out_qn = NULL;
|
||
if (!gbuf || fqn_len >= sizeof(cls_fqn)) {
|
||
return CBM_PSR4_MEMBER_NOT_COVERED;
|
||
}
|
||
memcpy(cls_fqn, fqn, fqn_len);
|
||
cls_fqn[fqn_len] = '\0';
|
||
return cbm_pipeline_psr4_member_qn(gbuf, cls_fqn, member, out_qn);
|
||
}
|
||
|
||
/* Do the directory segments of qn (between the project root segment and the
|
||
* file stem, i.e. before offset tail = ".<Class>.<member>") end with every
|
||
* segment of the namespace fqn[0, ns_len)? Case-insensitive: App\\Http is
|
||
* conventionally app/Http. An empty namespace is trivially aligned. */
|
||
static bool namespace_matches_dirs(const char *qn, int tail, const char *fqn, size_t ns_len) {
|
||
const char *root_dot = strchr(qn, '.');
|
||
const char *stem_end = qn + tail;
|
||
if (!root_dot || stem_end <= root_dot) {
|
||
return false;
|
||
}
|
||
const char *dir_end = stem_end - SKIP_ONE; /* back over the file stem */
|
||
while (*dir_end != '.') {
|
||
dir_end--;
|
||
}
|
||
size_t ns_end = ns_len;
|
||
while (ns_end > 0) {
|
||
size_t ns_start = ns_end;
|
||
while (ns_start > 0 && fqn[ns_start - SKIP_ONE] != '\\') {
|
||
ns_start--;
|
||
}
|
||
if (dir_end <= root_dot) {
|
||
return false; /* more namespace segments than directories */
|
||
}
|
||
const char *dir_start = dir_end;
|
||
while (dir_start[-SKIP_ONE] != '.') {
|
||
dir_start--;
|
||
}
|
||
if (!seg_eq_nocase(fqn + ns_start, ns_end - ns_start, dir_start,
|
||
(size_t)(dir_end - dir_start))) {
|
||
return false;
|
||
}
|
||
dir_end = dir_start - SKIP_ONE;
|
||
ns_end = ns_start > 0 ? ns_start - SKIP_ONE : 0;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* Namespace/path alignment: the one "<Class>.<member>" candidate that sits
|
||
* where its namespace says. NULL when none or several do. */
|
||
static const char *aligned_member_qn(const cbm_registry_t *r, const char *fqn, size_t ns_len,
|
||
const char *cls, size_t cls_len, const char *member) {
|
||
const qn_array_t *arr = cbm_ht_get(r->by_name, member);
|
||
const char *match = NULL;
|
||
for (int i = 0; arr && i < arr->count; i++) {
|
||
const char *qn = arr->items[i];
|
||
int tail = qn_class_member_tail(qn, cls, cls_len, member);
|
||
if (tail == CBM_NOT_FOUND || !namespace_matches_dirs(qn, tail, fqn, ns_len)) {
|
||
continue;
|
||
}
|
||
if (match) {
|
||
return NULL; /* ambiguous */
|
||
}
|
||
match = qn;
|
||
}
|
||
return match;
|
||
}
|
||
|
||
cbm_resolution_t cbm_registry_resolve_handler(const cbm_registry_t *r, const char *handler_ref,
|
||
const char *module_qn, const char **import_map_keys,
|
||
const char **import_map_vals, int import_map_count,
|
||
const cbm_gbuf_t *gbuf, CBMLanguage lang) {
|
||
const char *sep = handler_ref ? strstr(handler_ref, "::") : NULL;
|
||
if (!r || !sep) {
|
||
return cbm_registry_resolve_lang(r, handler_ref, module_qn, import_map_keys,
|
||
import_map_vals, import_map_count, lang);
|
||
}
|
||
const char *member = sep + PAIR_LEN;
|
||
size_t fqn_len = (size_t)(sep - handler_ref);
|
||
size_t ns_len = fqn_len;
|
||
while (ns_len > 0 && handler_ref[ns_len - SKIP_ONE] != '\\') {
|
||
ns_len--;
|
||
}
|
||
const char *cls = handler_ref + ns_len;
|
||
size_t cls_len = fqn_len - ns_len;
|
||
ns_len = ns_len > 0 ? ns_len - SKIP_ONE : 0; /* drop the separator before the class */
|
||
if (cls_len == 0 || !member[0]) {
|
||
return empty_result();
|
||
}
|
||
const char *qn = NULL;
|
||
switch (psr4_member_qn(gbuf, handler_ref, fqn_len, member, &qn)) {
|
||
case CBM_PSR4_MEMBER_RESOLVED:
|
||
return (cbm_resolution_t){qn, "php_psr4", CONF_PSR4_MEMBER, REG_RESOLVED};
|
||
case CBM_PSR4_MEMBER_UNRESOLVED:
|
||
/* A covered class lives where PSR-4 says or nowhere; a namespace-
|
||
* mirroring folder elsewhere would be a guessed edge (#1186). */
|
||
return empty_result();
|
||
case CBM_PSR4_MEMBER_NOT_COVERED:
|
||
break;
|
||
}
|
||
qn = aligned_member_qn(r, handler_ref, ns_len, cls, cls_len, member);
|
||
if (qn) {
|
||
return (cbm_resolution_t){qn, "php_namespace_path", CONF_NAMESPACE_PATH_MEMBER,
|
||
REG_RESOLVED};
|
||
}
|
||
return empty_result();
|
||
}
|
||
|
||
cbm_resolution_t cbm_registry_resolve_lineage(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) {
|
||
if (!r || !callee_name) {
|
||
return empty_result();
|
||
}
|
||
/* Relation-permitting variant for SQL FROM/JOIN lineage usages ONLY.
|
||
* Deliberately uncached: the per-file cache is keyed by bare callee_name
|
||
* and stores the relation-vetoed answer of the default variant — sharing
|
||
* it would poison one variant with the other's semantics. SQL files hold
|
||
* few distinct relation refs, so the chain walk stays cheap. */
|
||
return registry_resolve_chain(r, callee_name, module_qn, import_map_keys, import_map_vals,
|
||
import_map_count, module_qn ? strlen(module_qn) : 0, false);
|
||
}
|
||
|
||
/* Python looks a bare name up in the calling def's own scope, then in each
|
||
* enclosing def's scope, and only then in the module and the builtins; a class
|
||
* body is never among them. So a bare `index()` in create_app, or `visit(...)`
|
||
* in a def nested in method Tree.visit, names the def bound in the nearest
|
||
* enclosing def -- create_app.index, Tree.visit.visit -- before anything a
|
||
* module-wide strategy would find. Walks scope_qn outward while its prefixes
|
||
* are registered defs or classes (skipping the classes) and stops at the
|
||
* caller's module (`module_qn`), which the other strategies own -- a
|
||
* same-QN function of a package __init__.py is never an enclosing scope.
|
||
* Empty unless such a local exists. Uncached: the answer depends on the
|
||
* caller, not only on the name. */
|
||
cbm_resolution_t cbm_registry_resolve_lexical(const cbm_registry_t *r, const char *callee_name,
|
||
const char *scope_qn, const char *module_qn) {
|
||
char scope[CBM_SZ_512];
|
||
char candidate[CBM_SZ_512];
|
||
size_t len = scope_qn ? strlen(scope_qn) : 0;
|
||
size_t floor = python_def_prefix_len(module_qn, scope_qn);
|
||
if (!r || !callee_name || !callee_name[0] || strchr(callee_name, '.') ||
|
||
strstr(callee_name, "::") || len == 0 || len >= sizeof(scope) || floor == 0) {
|
||
return empty_result();
|
||
}
|
||
memcpy(scope, scope_qn, len + SKIP_ONE);
|
||
while (strlen(scope) > floor) {
|
||
const char *label = cbm_ht_get(r->exact, scope);
|
||
if (!label) {
|
||
break;
|
||
}
|
||
if (registry_label_is_callable(label)) {
|
||
int n = snprintf(candidate, sizeof(candidate), "%s.%s", scope, callee_name);
|
||
const char *stored_key =
|
||
n > 0 && (size_t)n < sizeof(candidate) ? cbm_ht_get_key(r->exact, candidate) : NULL;
|
||
if (stored_key) {
|
||
return (cbm_resolution_t){stored_key, "lexical_scope", CONF_LEXICAL_SCOPE,
|
||
REG_RESOLVED};
|
||
}
|
||
}
|
||
char *dot = strrchr(scope, '.');
|
||
if (!dot) {
|
||
break;
|
||
}
|
||
*dot = '\0';
|
||
}
|
||
return empty_result();
|
||
}
|
||
|
||
/* The registry step both call resolvers (pass_calls.c, pass_parallel.c) run
|
||
* once the LSP has not answered: a Python bare name bound in an enclosing def
|
||
* first (the LSP binds those locals itself; this covers the sites it did not
|
||
* resolve), then the language-aware chain. One entry point, so the two
|
||
* resolvers cannot diverge on the rule. */
|
||
cbm_resolution_t cbm_registry_resolve_call_scoped(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, CBMLanguage lang,
|
||
const char *scope_qn) {
|
||
if (lang == CBM_LANG_PYTHON) {
|
||
cbm_resolution_t lexical =
|
||
cbm_registry_resolve_lexical(r, callee_name, scope_qn, module_qn);
|
||
if (lexical.qualified_name && lexical.qualified_name[0]) {
|
||
return lexical;
|
||
}
|
||
}
|
||
return cbm_registry_resolve_call_lang(r, callee_name, module_qn, import_map_keys,
|
||
import_map_vals, import_map_count, lang);
|
||
}
|
||
|
||
/* ── Fuzzy Resolve ──────────────────────────────────────────────── */
|
||
|
||
/* Filter candidates by import reachability. Returns count of reachable. */
|
||
static int filter_import_reachable(const char **candidates, int count, const char **import_vals,
|
||
int import_count, const char **out, int max_out) {
|
||
int n = 0;
|
||
for (int i = 0; i < count && n < max_out; i++) {
|
||
if (is_import_reachable(candidates[i], import_vals, import_count)) {
|
||
out[n++] = candidates[i];
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
cbm_fuzzy_result_t cbm_registry_fuzzy_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) {
|
||
(void)import_map_keys;
|
||
cbm_fuzzy_result_t no_match = {{0}, false};
|
||
if (!r || !callee_name) {
|
||
return no_match;
|
||
}
|
||
|
||
/* Extract simple name (last segment after dots) */
|
||
const char *lookup = simple_name(callee_name);
|
||
qn_array_t *arr = cbm_ht_get(r->by_name, lookup);
|
||
if (!arr || arr->count == 0) {
|
||
return no_match;
|
||
}
|
||
if (arr->count > REG_MAX_CANDIDATES) {
|
||
return no_match; /* unresolvably ambiguous — see REG_MAX_CANDIDATES */
|
||
}
|
||
|
||
bool have_imports = (import_map_vals && import_map_count > 0);
|
||
|
||
/* Single candidate */
|
||
if (arr->count == SKIP_ONE) {
|
||
double conf = CONF_FUZZY_SINGLE;
|
||
if (have_imports &&
|
||
!is_import_reachable(arr->items[0], import_map_vals, import_map_count)) {
|
||
conf *= DEFAULT_CONFIDENCE;
|
||
}
|
||
return (cbm_fuzzy_result_t){{arr->items[0], "fuzzy", conf, REG_RESOLVED}, true};
|
||
}
|
||
|
||
/* Multiple candidates: filter by import reachability */
|
||
const char *filtered[CBM_SZ_256];
|
||
int fcount = arr->count;
|
||
const char **fptr = (const char **)arr->items;
|
||
|
||
if (have_imports) {
|
||
fcount = filter_import_reachable((const char **)arr->items, arr->count, import_map_vals,
|
||
import_map_count, filtered, CBM_SZ_256);
|
||
fptr = filtered;
|
||
}
|
||
|
||
if (fcount == 0) {
|
||
/* No import-reachable — use originals with penalty */
|
||
const char *best = best_by_import_distance(r, (const char **)arr->items, qn_test_flags(arr),
|
||
qn_lang_flags(arr), arr->count, module_qn);
|
||
if (!best) {
|
||
return no_match;
|
||
}
|
||
return (cbm_fuzzy_result_t){
|
||
{best, "fuzzy",
|
||
candidate_count_penalty(CONF_FUZZY_MULTI * REG_HALF_PENALTY, arr->count), arr->count},
|
||
true};
|
||
}
|
||
if (fcount == SKIP_ONE) {
|
||
return (cbm_fuzzy_result_t){
|
||
{fptr[0], "fuzzy", candidate_count_penalty(CONF_FUZZY_SINGLE, arr->count), arr->count},
|
||
true};
|
||
}
|
||
const bool all = fptr == (const char **)arr->items;
|
||
const char *best = best_by_import_distance(r, fptr, all ? qn_test_flags(arr) : NULL,
|
||
all ? qn_lang_flags(arr) : NULL, fcount, module_qn);
|
||
if (!best) {
|
||
return no_match;
|
||
}
|
||
return (cbm_fuzzy_result_t){
|
||
{best, "fuzzy", candidate_count_penalty(CONF_FUZZY_MULTI, fcount), fcount}, true};
|
||
}
|
||
|
||
/* ── FindEndingWith ─────────────────────────────────────────────── */
|
||
|
||
struct few_ctx {
|
||
const char *target; /* ".suffix" */
|
||
size_t target_len;
|
||
const char **results;
|
||
int count;
|
||
int cap;
|
||
};
|
||
|
||
static void few_scan(const char *key, void *value, void *ud) {
|
||
(void)value;
|
||
struct few_ctx *ctx = ud;
|
||
size_t klen = strlen(key);
|
||
if (klen >= ctx->target_len && strcmp(key + klen - ctx->target_len, ctx->target) == 0) {
|
||
if (ctx->count >= ctx->cap) {
|
||
ctx->cap = ctx->cap ? ctx->cap * PAIR_LEN : REG_INIT_CAP;
|
||
ctx->results = safe_realloc(ctx->results, (size_t)ctx->cap * sizeof(char *));
|
||
}
|
||
ctx->results[ctx->count++] = key;
|
||
}
|
||
}
|
||
|
||
int cbm_registry_find_ending_with(const cbm_registry_t *r, const char *suffix, const char ***out) {
|
||
if (!r || !suffix || !out) {
|
||
if (out) {
|
||
*out = NULL;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Build ".suffix" target */
|
||
size_t slen = strlen(suffix);
|
||
char *target = malloc(slen + REG_SUFFIX_ALLOC);
|
||
target[0] = '.';
|
||
memcpy(target + SKIP_ONE, suffix, slen + SKIP_ONE);
|
||
|
||
struct few_ctx ctx = {target, slen + SKIP_ONE, NULL, 0, 0};
|
||
cbm_ht_foreach(r->exact, few_scan, &ctx);
|
||
|
||
free(target);
|
||
*out = ctx.results;
|
||
return ctx.count;
|
||
}
|
||
|
||
/* Public wrapper for is_import_reachable (testing). */
|
||
bool cbm_registry_is_import_reachable(const char *candidate_qn, const char **import_vals,
|
||
int import_count) {
|
||
return is_import_reachable(candidate_qn, import_vals, import_count);
|
||
}
|