mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
4898 lines
232 KiB
C
4898 lines
232 KiB
C
/*
|
||
* pass_parallel.c — Three-phase parallel pipeline.
|
||
*
|
||
* Phase 3A: Parallel extract + create definition nodes (per-worker gbufs)
|
||
* Phase 3B: Serial registry build + edge creation from cached results
|
||
* Phase 4: Parallel call/usage/semantic resolution (per-worker edge bufs)
|
||
*
|
||
* Each file is read and parsed ONCE (Phase 3A). The CBMFileResult is cached
|
||
* and reused for resolution (Phase 4), eliminating 3x redundant I/O + parsing.
|
||
*
|
||
* Depends on: worker_pool, graph_buffer (shared IDs + merge), extraction (cbm.h)
|
||
*/
|
||
#include "foundation/constants.h"
|
||
|
||
enum {
|
||
PP_RING = 4,
|
||
PP_RING_MASK = 3,
|
||
PP_JSON_MARGIN = 10,
|
||
PP_ESC_MARGIN = 3,
|
||
PP_ESC_SPACE = 2,
|
||
/* Fixed bytes around a serialized JSON field: ,"key":"value" / ,"key":[...]
|
||
* -> comma + 2 key quotes + colon + 2 value quotes (resp. brackets). */
|
||
PP_JSON_FIELD_OVERHEAD = 6,
|
||
PP_ARGS_MARGIN = 20,
|
||
/* ,"line":<int> -> comma + key (7) + colon + up to 10 digits + NUL. */
|
||
PP_LINE_MARGIN = 24,
|
||
PP_LOG_THRESH = 24,
|
||
PP_LOG_INTERVAL = 10,
|
||
PP_TIMER_THRESH = 1000,
|
||
/* Extraction memory back-pressure: when the process is over its RSS budget,
|
||
* a worker reclaims + naps before pulling another file so peers can finish
|
||
* and return pages. Bounded spins avoid deadlock when the resident graph
|
||
* itself is near budget: then ONE confirmation cycle decides — drained
|
||
* means continue, still over means the attempt fails whole (decision A,
|
||
* #1997 #832) instead of the former open-ended soft overshoot. */
|
||
PP_BACKPRESSURE_MAX_SPINS = 40,
|
||
PP_BACKPRESSURE_NAP_NS = 3000000, /* 3 ms */
|
||
};
|
||
#define PP_NSEC_PER_SEC 1000000000ULL
|
||
#define PP_USEC_PER_MS 1000000ULL
|
||
#define PP_HALF_CONF 0.5
|
||
#define PP_FIELD_HINT_CONF 0.85
|
||
enum { PP_CSHARP_M_PREFIX_LEN = 2 };
|
||
|
||
/* Absolute source-retention ceilings for the parallel extract pipeline.
|
||
*
|
||
* The extract worker copies each file's source bytes into result->arena so
|
||
* the fused cross-file LSP step in resolve_worker can re-parse without
|
||
* re-opening the file. That retention is TRANSIENT (freed at run end) but it
|
||
* is a PEAK-RSS driver: every retained byte is resident at once across the
|
||
* extract→resolve handoff.
|
||
*
|
||
* A cap here is a FLOOR as much as a ceiling: whatever total we allow, we
|
||
* WILL hold that much resident at peak on a large repo. rust-analyzer bounds
|
||
* retained file *text* to a small fixed budget and re-reads source on a miss
|
||
* rather than scaling the retained set with host RAM — because the re-read is
|
||
* cheap relative to holding tens of GB resident. We follow the same model:
|
||
* - derive the total budget from the process memory budget (budget/8),
|
||
* - BUT clamp the RAM-derived DEFAULT to a small absolute ceiling (1 GiB)
|
||
* so a 512 GiB host does not retain 64 GiB of source it would re-read
|
||
* cheaply anyway, and keep the per-file cap modest (32 MiB) so one
|
||
* pathological generated blob cannot monopolise the budget.
|
||
* A file dropped from retention is NOT lost to cross-file resolution:
|
||
* resolve_worker re-reads it on demand, bounded and freed immediately
|
||
* (source_reread fallback). Both caps are env-overridable — CBM_RETAIN_TOTAL_MB
|
||
* / CBM_RETAIN_PER_FILE_MB raise or lower the auto-derived defaults directly
|
||
* (the hard ceilings bound only the RAM-derived default, never a deliberate
|
||
* operator/caller choice). */
|
||
#define PP_RETAIN_TOTAL_HARD_MAX_BYTES (1024ULL * 1024 * 1024) /* 1 GiB default ceiling */
|
||
#define PP_RETAIN_PER_FILE_HARD_MAX_BYTES (32ULL * 1024 * 1024) /* 32 MiB per file */
|
||
#include "pipeline/pipeline.h"
|
||
#include "pipeline/pipeline_internal.h"
|
||
#include "pipeline/doc_links.h" /* cbm_doclinks_resolve_file: MENTIONS beside CALLS */
|
||
#include "result_spill.h"
|
||
#include "foundation/platform.h" /* cbm_resolve_cache_dir */
|
||
#include "pipeline/pass_lsp_cross.h" /* cbm_pxc_* helpers for fused cross-file LSP */
|
||
#include "pipeline/lsp_resolve.h"
|
||
#include "pipeline/laravel_routing.h"
|
||
#include "lsp/rust_cargo.h"
|
||
#include "lsp/c_lsp.h"
|
||
#include "lsp/py_lsp.h"
|
||
#include "helpers.h" /* cbm_kind_in_set_free_cache — per-worker-thread cache teardown */
|
||
#include "pipeline/worker_pool.h"
|
||
#include "foundation/compat.h"
|
||
#include "foundation/compat_fs.h"
|
||
#include "foundation/compat_thread.h"
|
||
#include "foundation/mem_core.h"
|
||
#include "graph_buffer/graph_buffer.h"
|
||
#include "service_patterns.h"
|
||
#include "foundation/platform.h"
|
||
#include "foundation/hash_table.h"
|
||
#include "foundation/log.h"
|
||
#include "foundation/slab_alloc.h"
|
||
#include "foundation/mem.h"
|
||
#include "foundation/str_util.h"
|
||
#include "foundation/profile.h"
|
||
#include "foundation/compat_regex.h"
|
||
#include "foundation/limits.h"
|
||
#include "cbm.h"
|
||
#include "arena.h"
|
||
#include "macro_table.h"
|
||
#include "simhash/minhash.h"
|
||
#include "semantic/ast_profile.h"
|
||
|
||
#include <errno.h>
|
||
#include <stdatomic.h>
|
||
#include <stdint.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <time.h>
|
||
|
||
/* Back-pressure nap-cycle counter (test observability): each execution of the
|
||
* over-budget collect+nap gate counts one cycle. Lets tests assert the gate does
|
||
* not re-pay the full nap tax on every file pull when napping cannot reclaim
|
||
* memory (the resident floor, not in-flight transients, holds the budget). */
|
||
static _Atomic long g_bp_nap_cycles = 0;
|
||
static _Atomic uint64_t g_lsp_linear_fallback_rows = 0;
|
||
/* Defined here, declared in lsp_resolve.h — the uncapped tail-match scan's
|
||
* cost, surfaced at end of resolve (#1669). */
|
||
_Atomic uint64_t g_lsp_tail_lookups = 0;
|
||
_Atomic uint64_t g_lsp_tail_candidates = 0;
|
||
|
||
long cbm_pp_bp_nap_cycles(void) {
|
||
return atomic_load_explicit(&g_bp_nap_cycles, memory_order_relaxed);
|
||
}
|
||
|
||
void cbm_pp_bp_nap_cycles_reset(void) {
|
||
atomic_store_explicit(&g_bp_nap_cycles, 0, memory_order_relaxed);
|
||
}
|
||
|
||
uint64_t cbm_pp_lsp_linear_fallback_rows(void) {
|
||
return atomic_load_explicit(&g_lsp_linear_fallback_rows, memory_order_relaxed);
|
||
}
|
||
|
||
void cbm_pp_lsp_linear_fallback_rows_reset(void) {
|
||
atomic_store_explicit(&g_lsp_linear_fallback_rows, 0, memory_order_relaxed);
|
||
atomic_store_explicit(&g_lsp_tail_lookups, 0, memory_order_relaxed);
|
||
atomic_store_explicit(&g_lsp_tail_candidates, 0, memory_order_relaxed);
|
||
}
|
||
|
||
/* Parse a positive MB-valued retention env knob (CBM_RETAIN_*_MB) into bytes.
|
||
* Follows the limits.c strtol convention: unset / unparseable / non-positive
|
||
* → return 0 so the caller keeps its derived default. */
|
||
static size_t cbm_retain_env_bytes(const char *name) {
|
||
const char *raw = cbm_runtime_getenv(name);
|
||
if (!raw || !raw[0]) {
|
||
return 0;
|
||
}
|
||
errno = 0;
|
||
char *end = NULL;
|
||
long v = strtol(raw, &end, 10);
|
||
if (errno != 0 || end == raw || *end != '\0' || v <= 0) {
|
||
return 0;
|
||
}
|
||
return (size_t)v * 1024 * 1024;
|
||
}
|
||
|
||
/* Auto-derived TOTAL retention budget: a fraction of the process memory
|
||
* budget, clamped to the absolute ceiling. The clamp bounds ONLY this
|
||
* RAM-derived default — a huge-RAM host must not retain tens of GB it would
|
||
* re-read cheaply. When the budget is unset (tests / no cgroup) fall back to
|
||
* the ceiling. */
|
||
static size_t cbm_parallel_extract_default_total_cap(void) {
|
||
size_t cap = cbm_mem_budget();
|
||
if (cap > 0) {
|
||
cap /= 8;
|
||
} else {
|
||
cap = PP_RETAIN_TOTAL_HARD_MAX_BYTES;
|
||
}
|
||
if (cap > PP_RETAIN_TOTAL_HARD_MAX_BYTES) {
|
||
cap = PP_RETAIN_TOTAL_HARD_MAX_BYTES;
|
||
}
|
||
return cap;
|
||
}
|
||
|
||
/* Auto-derived PER-FILE cap: modest absolute ceiling, never above the total. */
|
||
static size_t cbm_parallel_extract_default_per_file_cap(size_t total_cap) {
|
||
size_t cap = PP_RETAIN_PER_FILE_HARD_MAX_BYTES;
|
||
if (cap > total_cap) {
|
||
cap = total_cap;
|
||
}
|
||
return cap;
|
||
}
|
||
|
||
/* Resolve the effective retention options from (in precedence order):
|
||
* explicit caller opts > CBM_RETAIN_*_MB env knobs > RAM-derived defaults.
|
||
* The absolute hard ceilings apply only to the RAM-derived defaults; an
|
||
* operator/caller that sets a value explicitly is trusted. The only invariant
|
||
* enforced unconditionally is per-file ≤ total. */
|
||
static cbm_parallel_extract_opts_t cbm_parallel_extract_resolve_opts(
|
||
const cbm_parallel_extract_opts_t *opts) {
|
||
cbm_parallel_extract_opts_t resolved = {
|
||
.retain_sources = true,
|
||
.retain_sources_set = true,
|
||
.retain_total_budget_bytes = cbm_parallel_extract_default_total_cap(),
|
||
.retain_per_file_max_bytes = 0,
|
||
};
|
||
|
||
size_t env_total = cbm_retain_env_bytes("CBM_RETAIN_TOTAL_MB");
|
||
if (env_total > 0) {
|
||
resolved.retain_total_budget_bytes = env_total;
|
||
}
|
||
|
||
resolved.retain_per_file_max_bytes =
|
||
cbm_parallel_extract_default_per_file_cap(resolved.retain_total_budget_bytes);
|
||
size_t env_per_file = cbm_retain_env_bytes("CBM_RETAIN_PER_FILE_MB");
|
||
if (env_per_file > 0) {
|
||
resolved.retain_per_file_max_bytes = env_per_file;
|
||
}
|
||
|
||
if (opts) {
|
||
if (opts->retain_sources_set) {
|
||
resolved.retain_sources = opts->retain_sources;
|
||
}
|
||
if (opts->retain_total_budget_bytes > 0) {
|
||
resolved.retain_total_budget_bytes = opts->retain_total_budget_bytes;
|
||
}
|
||
if (opts->retain_per_file_max_bytes > 0) {
|
||
resolved.retain_per_file_max_bytes = opts->retain_per_file_max_bytes;
|
||
}
|
||
}
|
||
|
||
/* Correctness invariant: a single file can never exceed the total budget. */
|
||
if (resolved.retain_per_file_max_bytes > resolved.retain_total_budget_bytes) {
|
||
resolved.retain_per_file_max_bytes = resolved.retain_total_budget_bytes;
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
static uint64_t extract_now_ns(void) {
|
||
struct timespec ts;
|
||
cbm_clock_gettime(CLOCK_MONOTONIC, &ts);
|
||
return ((uint64_t)ts.tv_sec * PP_NSEC_PER_SEC) + (uint64_t)ts.tv_nsec;
|
||
}
|
||
|
||
/* ── Helpers (duplicated from pass files — kept static for isolation) ── */
|
||
|
||
/* Read file into a malloc'd buffer (= mimalloc in production).
|
||
* *out_size receives the on-disk size and *out_status the failure reason so the
|
||
* caller can attribute a skip to the right phase (read vs oversized) instead of
|
||
* a silent drop. Both out params may be NULL. */
|
||
static char *read_file(const char *path, int *out_len, long *out_size,
|
||
cbm_read_status_t *out_status) {
|
||
if (out_size) {
|
||
*out_size = 0;
|
||
}
|
||
if (out_status) {
|
||
*out_status = CBM_READ_OK;
|
||
}
|
||
FILE *f = cbm_fopen(path, "rb");
|
||
if (!f) {
|
||
if (out_status) {
|
||
*out_status = CBM_READ_OPEN_FAIL;
|
||
}
|
||
return NULL;
|
||
}
|
||
(void)fseek(f, 0, SEEK_END);
|
||
long size = ftell(f);
|
||
(void)fseek(f, 0, SEEK_SET);
|
||
if (out_size) {
|
||
*out_size = size;
|
||
}
|
||
if (size <= 0) {
|
||
(void)fclose(f);
|
||
if (out_status) {
|
||
*out_status = CBM_READ_EMPTY;
|
||
}
|
||
return NULL;
|
||
}
|
||
if (size > cbm_max_file_bytes()) { /* generous, env-configurable cap (B4) */
|
||
(void)fclose(f);
|
||
if (out_status) {
|
||
*out_status = CBM_READ_OVERSIZED;
|
||
}
|
||
return NULL;
|
||
}
|
||
char *buf = (char *)malloc((size_t)size + SKIP_ONE);
|
||
if (!buf) {
|
||
(void)fclose(f);
|
||
if (out_status) {
|
||
*out_status = CBM_READ_OOM;
|
||
}
|
||
return NULL;
|
||
}
|
||
size_t nread = fread(buf, SKIP_ONE, (size_t)size, f);
|
||
(void)fclose(f);
|
||
buf[nread] = '\0';
|
||
*out_len = (int)nread;
|
||
return buf;
|
||
}
|
||
|
||
/* ── Per-worker skip list (Stage 2 / Track B) ───────────────────────
|
||
* Each extract worker appends read/extract/oversized skips into its OWN list
|
||
* (no lock on the hot path); the lists are merged into the pipeline's
|
||
* cbm_file_error_t array in the existing sequential merge loop. */
|
||
typedef struct {
|
||
cbm_file_error_t *items;
|
||
int count;
|
||
int cap;
|
||
} pp_err_list_t;
|
||
|
||
/* NULL-safe heap strdup. */
|
||
static char *pp_err_dup(const char *s) {
|
||
if (!s) {
|
||
return NULL;
|
||
}
|
||
size_t n = strlen(s) + 1;
|
||
char *d = (char *)malloc(n);
|
||
if (d) {
|
||
memcpy(d, s, n);
|
||
}
|
||
return d;
|
||
}
|
||
|
||
static void pp_err_add(pp_err_list_t *list, const char *path, const char *reason,
|
||
const char *phase) {
|
||
if (!list) {
|
||
return;
|
||
}
|
||
if (list->count >= list->cap) {
|
||
int ncap = list->cap ? list->cap * 2 : 8;
|
||
cbm_file_error_t *grown =
|
||
(cbm_file_error_t *)realloc(list->items, (size_t)ncap * sizeof(*grown));
|
||
if (!grown) {
|
||
return; /* drop on OOM — never fail extraction to record a skip */
|
||
}
|
||
list->items = grown;
|
||
list->cap = ncap;
|
||
}
|
||
list->items[list->count].path = pp_err_dup(path);
|
||
list->items[list->count].reason = pp_err_dup(reason);
|
||
list->items[list->count].phase = pp_err_dup(phase);
|
||
list->count++;
|
||
}
|
||
|
||
/* Free source buffer. */
|
||
static void free_source(char *buf) {
|
||
free(buf);
|
||
}
|
||
|
||
static const char *itoa_log(int val) {
|
||
static CBM_TLS char bufs[PP_RING][CBM_SZ_32];
|
||
static CBM_TLS int idx = 0;
|
||
int i = idx;
|
||
idx = (idx + SKIP_ONE) & PP_RING_MASK;
|
||
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
|
||
return bufs[i];
|
||
}
|
||
|
||
/* Append a JSON-escaped string value to buf at position *pos. */
|
||
/* Escape one character for JSON. Returns bytes written (1 or 2). */
|
||
static int json_escape_char(char *buf, size_t avail, char ch) {
|
||
char esc = 0;
|
||
switch (ch) {
|
||
case '"':
|
||
esc = '"';
|
||
break;
|
||
case '\\':
|
||
esc = '\\';
|
||
break;
|
||
case '\n':
|
||
esc = 'n';
|
||
break;
|
||
case '\r':
|
||
esc = 'r';
|
||
break;
|
||
case '\t':
|
||
esc = 't';
|
||
break;
|
||
default:
|
||
if (avail >= SKIP_ONE) {
|
||
/* Any other raw control byte (e.g. form feed) is invalid inside a
|
||
* JSON string — degrade to a space. */
|
||
buf[0] = ((unsigned char)ch < 0x20) ? ' ' : ch;
|
||
}
|
||
return SKIP_ONE;
|
||
}
|
||
if (avail >= PP_ESC_SPACE) {
|
||
buf[0] = '\\';
|
||
buf[SKIP_ONE] = esc;
|
||
}
|
||
return PP_ESC_SPACE;
|
||
}
|
||
|
||
/* Escaped length of a string under json_escape_char's rules: escaped
|
||
* characters expand to 2 bytes, everything else stays 1. */
|
||
static size_t pp_json_escaped_len(const char *s) {
|
||
size_t n = 0;
|
||
for (; *s; s++) {
|
||
switch (*s) {
|
||
case '"':
|
||
case '\\':
|
||
case '\n':
|
||
case '\r':
|
||
case '\t':
|
||
n += PP_ESC_SPACE;
|
||
break;
|
||
default:
|
||
n += SKIP_ONE;
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/* Appends are ATOMIC: a field is emitted only if the WHOLE serialized form
|
||
* fits (with PP_ESC_SPACE bytes reserved for the closing '}' + NUL). Cutting a
|
||
* field mid-value produced unterminated strings/arrays — malformed properties
|
||
* JSON that aborts every json_extract()-based consumer downstream (seen on the
|
||
* Linux kernel: 50-param functions truncated at the 2 KB cap). Dropping an
|
||
* oversized optional field whole keeps the JSON valid. Twin of
|
||
* pass_definitions.c — keep both in sync. */
|
||
static void append_json_string(char *buf, size_t bufsize, size_t *pos, const char *key,
|
||
const char *val) {
|
||
if (!val || val[0] == '\0') {
|
||
return;
|
||
}
|
||
size_t required = strlen(key) + pp_json_escaped_len(val) + PP_JSON_FIELD_OVERHEAD;
|
||
if (*pos + required + PP_ESC_SPACE > bufsize) {
|
||
return; /* whole field would not fit — skip it atomically */
|
||
}
|
||
size_t p = *pos;
|
||
int w = snprintf(buf + p, bufsize - p, ",\"%s\":\"", key);
|
||
if (w <= 0 || (size_t)w >= bufsize - p) {
|
||
return;
|
||
}
|
||
p += (size_t)w;
|
||
for (const char *s = val; *s && p < bufsize - PP_ESC_MARGIN; s++) {
|
||
int n = json_escape_char(buf + p, bufsize - p - PP_ESC_SPACE, *s);
|
||
p += (size_t)n;
|
||
}
|
||
if (p < bufsize - SKIP_ONE) {
|
||
buf[p++] = '"';
|
||
}
|
||
buf[p] = '\0';
|
||
*pos = p;
|
||
}
|
||
|
||
/* Append a JSON array of strings: ,"key":["a","b","c"]. Atomic like
|
||
* append_json_string: emitted only if the whole array fits. */
|
||
static void append_json_str_array(char *buf, size_t bufsize, size_t *pos, const char *key,
|
||
const char **arr) {
|
||
if (!arr || !arr[0] || *pos >= bufsize - PP_JSON_MARGIN) {
|
||
return;
|
||
}
|
||
/* ,"key":[ + per item "<escaped>" + separating commas + ] */
|
||
size_t required = strlen(key) + PP_JSON_FIELD_OVERHEAD;
|
||
for (int i = 0; arr[i]; i++) {
|
||
required += pp_json_escaped_len(arr[i]) + PP_ESC_SPACE + (i > 0 ? SKIP_ONE : 0);
|
||
}
|
||
if (*pos + required + PP_ESC_SPACE > bufsize) {
|
||
return; /* whole array would not fit — skip it atomically */
|
||
}
|
||
size_t p = *pos;
|
||
int n = snprintf(buf + p, bufsize - p, ",\"%s\":[", key);
|
||
if (n <= 0 || p + (size_t)n >= bufsize - PP_ESC_SPACE) {
|
||
return;
|
||
}
|
||
p += (size_t)n;
|
||
for (int i = 0; arr[i]; i++) {
|
||
if (i > 0 && p < bufsize - SKIP_ONE) {
|
||
buf[p++] = ',';
|
||
}
|
||
if (p < bufsize - SKIP_ONE) {
|
||
buf[p++] = '"';
|
||
}
|
||
/* Full escaping (not just quote/backslash): items like C param types
|
||
* sliced from multi-line declarations carry raw \n/\t bytes, which are
|
||
* invalid inside JSON strings. */
|
||
for (const char *s = arr[i]; *s && p < bufsize - PP_ESC_SPACE; s++) {
|
||
p += (size_t)json_escape_char(buf + p, bufsize - p - PP_ESC_SPACE, *s);
|
||
}
|
||
if (p < bufsize - SKIP_ONE) {
|
||
buf[p++] = '"';
|
||
}
|
||
}
|
||
if (p < bufsize - SKIP_ONE) {
|
||
buf[p++] = ']';
|
||
}
|
||
buf[p] = '\0';
|
||
*pos = p;
|
||
}
|
||
|
||
/* Append an already-serialized JSON value verbatim: ,"key":<json>. Atomic like
|
||
* append_json_string. For `variants`, which extraction builds as a JSON array
|
||
* of objects (cbm.h). Twin of pass_definitions.c -- keep both in sync. */
|
||
static void append_json_raw(char *buf, size_t bufsize, size_t *pos, const char *key,
|
||
const char *json) {
|
||
if (!json || json[0] == '\0') {
|
||
return;
|
||
}
|
||
size_t required = strlen(key) + strlen(json) + PP_JSON_FIELD_OVERHEAD;
|
||
if (*pos + required + PP_ESC_SPACE > bufsize) {
|
||
return; /* whole field would not fit — skip it atomically */
|
||
}
|
||
int w = snprintf(buf + *pos, bufsize - *pos, ",\"%s\":%s", key, json);
|
||
if (w > 0 && (size_t)w < bufsize - *pos) {
|
||
*pos += (size_t)w;
|
||
}
|
||
}
|
||
|
||
static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) {
|
||
/* Complexity/loop/recursion metrics are meaningful only for Function/Method.
|
||
* Gate the block so the millions of Macro/Field/Variable/Class/Enum nodes
|
||
* keep a lean properties blob (lossless — those fields are always zero for
|
||
* non-functions). Cuts RAM, gbuf-merge copy and dump volume. Mirrors
|
||
* pass_definitions.c::build_def_props — keep both in sync. */
|
||
const bool is_fn =
|
||
def->label && (strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0);
|
||
const char *test_role = def->test_role == CBM_TEST_ROLE_CASE ? ",\"test_role\":\"case\""
|
||
: def->test_role == CBM_TEST_ROLE_SUITE ? ",\"test_role\":\"suite\""
|
||
: "";
|
||
int n;
|
||
if (is_fn) {
|
||
n = snprintf(buf, bufsize,
|
||
"{\"complexity\":%d,\"cognitive\":%d,\"loop_count\":%d,\"loop_depth\":%d,"
|
||
"\"self_recursive\":%s,\"param_count\":%d,\"max_access_depth\":%d,"
|
||
"\"linear_scan_in_loop\":%d,\"alloc_in_loop\":%d,\"recursion_in_loop\":%s,"
|
||
"\"unguarded_recursion\":%s,"
|
||
"\"lines\":%d,\"is_exported\":%s,\"is_test\":%s,\"is_entry_point\":%s%s",
|
||
def->complexity, def->cognitive, def->loop_count, def->loop_depth,
|
||
def->is_recursive ? "true" : "false", def->param_count, def->max_access_depth,
|
||
def->linear_scan_in_loop, def->alloc_in_loop,
|
||
def->recursion_in_loop ? "true" : "false",
|
||
def->unguarded_recursion ? "true" : "false", def->lines,
|
||
def->is_exported ? "true" : "false", def->is_test ? "true" : "false",
|
||
def->is_entry_point ? "true" : "false", test_role);
|
||
} else {
|
||
n = snprintf(buf, bufsize,
|
||
"{\"complexity\":%d,\"lines\":%d,\"is_exported\":%s,\"is_test\":%s,"
|
||
"\"is_entry_point\":%s%s",
|
||
def->complexity, def->lines, def->is_exported ? "true" : "false",
|
||
def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false",
|
||
test_role);
|
||
}
|
||
if (n <= 0 || (size_t)n >= bufsize) {
|
||
buf[0] = '\0';
|
||
return;
|
||
}
|
||
size_t pos = (size_t)n;
|
||
append_json_string(buf, bufsize, &pos, "docstring", def->docstring);
|
||
/* The buffer is sized for every field below (pp_props_buf), so none can be
|
||
* squeezed out. */
|
||
append_json_raw(buf, bufsize, &pos, "variants", def->variants);
|
||
append_json_string(buf, bufsize, &pos, "variant_key", def->variant_key);
|
||
append_json_string(buf, bufsize, &pos, "signature", def->signature);
|
||
append_json_string(buf, bufsize, &pos, "return_type", def->return_type);
|
||
append_json_string(buf, bufsize, &pos, "parent_class", def->parent_class);
|
||
append_json_str_array(buf, bufsize, &pos, "decorators", def->decorators);
|
||
append_json_str_array(buf, bufsize, &pos, "base_classes", def->base_classes);
|
||
append_json_str_array(buf, bufsize, &pos, "param_names", def->param_names);
|
||
append_json_str_array(buf, bufsize, &pos, "param_types", def->param_types);
|
||
append_json_string(buf, bufsize, &pos, "route_path", def->route_path);
|
||
append_json_string(buf, bufsize, &pos, "route_method", def->route_method);
|
||
append_json_string(buf, bufsize, &pos, "http_client", def->http_client);
|
||
append_json_string(buf, bufsize, &pos, "http_base_url", def->http_base_url);
|
||
/* A document definition's facts (an ADR's id, status, date, ...): key, value pairs. */
|
||
for (const char **kv = def->extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) {
|
||
append_json_string(buf, bufsize, &pos, kv[0], kv[SKIP_ONE]);
|
||
}
|
||
|
||
/* MinHash fingerprint — append if present and buffer has room.
|
||
* Hex-encoded K=64 uint32 = 512 chars + key/quotes ≈ 520 chars. */
|
||
if (def->fingerprint && def->fingerprint_k > 0 &&
|
||
pos + CBM_MINHASH_HEX_LEN + CBM_MINHASH_JSON_OVERHEAD < bufsize) {
|
||
char fp_hex[CBM_MINHASH_HEX_BUF];
|
||
cbm_minhash_to_hex((const cbm_minhash_t *)def->fingerprint, fp_hex, sizeof(fp_hex));
|
||
append_json_string(buf, bufsize, &pos, "fp", fp_hex);
|
||
}
|
||
|
||
/* AST structural profile — append if present and buffer has room. */
|
||
if (def->structural_profile && pos + CBM_AST_PROFILE_BUF < bufsize) {
|
||
append_json_string(buf, bufsize, &pos, "sp", def->structural_profile);
|
||
}
|
||
|
||
/* Body tokens — raw identifiers from function body AST for semantic search. */
|
||
if (def->body_tokens && pos + CBM_SZ_512 < bufsize) {
|
||
append_json_string(buf, bufsize, &pos, "bt", def->body_tokens);
|
||
}
|
||
|
||
if (pos < bufsize - SKIP_ONE) {
|
||
buf[pos] = '}';
|
||
buf[pos + SKIP_ONE] = '\0';
|
||
}
|
||
}
|
||
|
||
/* True for languages whose module QN derives from the CONTAINING DIRECTORY
|
||
* (Java/Go package). MUST match cbm_lang_module_is_dir() (internal/cbm/helpers.c)
|
||
* and pxc_module_is_dir() (pass_lsp_cross.c) so same-module callee resolution
|
||
* keys against the directory-based def-node QNs in the registry. */
|
||
static bool pp_module_is_dir(CBMLanguage lang) {
|
||
return lang == CBM_LANG_JAVA || lang == CBM_LANG_GO;
|
||
}
|
||
|
||
static bool is_checked_exception(const char *name) {
|
||
if (!name) {
|
||
return false;
|
||
}
|
||
if (strstr(name, "Error") || strstr(name, "Panic") || strstr(name, "error") ||
|
||
strstr(name, "panic")) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static const char *resolve_as_class(const cbm_registry_t *reg, const char *name,
|
||
const char *module_qn, const char **imp_keys,
|
||
const char **imp_vals, int imp_count, CBMLanguage lang) {
|
||
cbm_resolution_t res =
|
||
cbm_registry_resolve_lang(reg, name, module_qn, imp_keys, imp_vals, imp_count, lang);
|
||
if (!res.qualified_name || res.qualified_name[0] == '\0') {
|
||
return NULL;
|
||
}
|
||
/* Accept any type-like container (Class/Struct/Interface/Enum/Type/Trait):
|
||
* base classes, Rust `impl Trait for S` struct receivers, and Go struct
|
||
* embedding all resolve through here. Struct included so the struct receiver
|
||
* of an IMPLEMENTS edge is not dropped. */
|
||
const char *label = cbm_registry_label_of(reg, res.qualified_name);
|
||
if (!cbm_label_is_type_like(label)) {
|
||
return NULL;
|
||
}
|
||
return res.qualified_name;
|
||
}
|
||
|
||
static void extract_decorator_func(const char *dec, char *out, size_t outsz) {
|
||
out[0] = '\0';
|
||
if (!dec) {
|
||
return;
|
||
}
|
||
const char *start = dec;
|
||
while (*start == '@' || *start == '#' || *start == '[' || *start == ' ') {
|
||
start++;
|
||
}
|
||
size_t len = 0;
|
||
while (start[len] && start[len] != '(' && start[len] != '[' && start[len] != ']' &&
|
||
start[len] != ' ' && start[len] != ',') {
|
||
len++;
|
||
}
|
||
if (len == 0 || len >= outsz) {
|
||
return;
|
||
}
|
||
memcpy(out, start, len);
|
||
out[len] = '\0';
|
||
}
|
||
|
||
/* ── File sort for tail-latency reduction ────────────────────────── */
|
||
|
||
typedef struct {
|
||
int idx;
|
||
int64_t size;
|
||
} file_sort_entry_t;
|
||
|
||
static int compare_by_size_desc(const void *a, const void *b) {
|
||
const file_sort_entry_t *fa = a;
|
||
const file_sort_entry_t *fb = b;
|
||
if (fb->size > fa->size) {
|
||
return SKIP_ONE;
|
||
}
|
||
if (fb->size < fa->size) {
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* ── Phase 3A: Parallel Extract ──────────────────────────────────── */
|
||
|
||
#define CBM_CACHE_LINE CBM_SZ_128
|
||
|
||
typedef struct __attribute__((aligned(CBM_CACHE_LINE))) {
|
||
cbm_gbuf_t *local_gbuf;
|
||
int nodes_created;
|
||
int errors;
|
||
char _pad[CBM_CACHE_LINE - sizeof(cbm_gbuf_t *) - (PP_ESC_SPACE * sizeof(int))];
|
||
} extract_worker_state_t;
|
||
|
||
typedef struct {
|
||
const cbm_file_info_t *files;
|
||
file_sort_entry_t *sorted;
|
||
int file_count;
|
||
const char *project_name;
|
||
const char *repo_path;
|
||
|
||
extract_worker_state_t *workers;
|
||
int max_workers;
|
||
_Atomic int next_worker_id;
|
||
|
||
CBMFileResult **result_cache;
|
||
_Atomic int64_t *shared_ids;
|
||
_Atomic int *cancelled;
|
||
_Atomic int next_file_idx;
|
||
cbm_pipeline_ctx_t *pctx; /* spill store + mode latch live here */
|
||
_Atomic uint8_t *slot_state; /* per file: 0 in progress, 1 cached, 2 parked */
|
||
_Atomic int spill_sweeps_running; /* workers inside pp_spill_sweep right now */
|
||
int spill_env; /* CBM_MEM_SPILL for this run: 1 force, 0 off, 2 budget decides */
|
||
cbm_mutex_t spill_mu; /* serializes the one store open */
|
||
_Atomic int spill_unavailable; /* store could not open: never retry */
|
||
|
||
cbm_pkg_entries_t *pkg_entries; /* per-worker manifest arrays (separate allocation) */
|
||
|
||
bool retain_sources; /* copy source into result->arena for cross-file LSP */
|
||
size_t retain_total_budget_bytes; /* project-wide retention cap (peak-RSS bound) */
|
||
size_t retain_per_file_max_bytes; /* per-file retention cap */
|
||
_Atomic int64_t retained_bytes; /* total source bytes copied into result arenas */
|
||
_Atomic int retain_cap_warned; /* WARN index.retain_capped emitted once per run */
|
||
|
||
/* Per-worker skip lists (separate allocation, indexed by worker_id — no hot-
|
||
* path lock). Merged into the pipeline in the sequential merge loop. */
|
||
pp_err_list_t *err_lists;
|
||
_Atomic int oversized_warned; /* throttle for the index.file_oversized WARN */
|
||
|
||
/* Back-pressure futility latch: set when a full collect+nap cycle ended
|
||
* still over budget — the resident floor (graph + retained sources), not
|
||
* in-flight transients, holds the memory, so napping cannot reclaim it.
|
||
* While set, pulls skip the nap (the designed soft overshoot); the cheap
|
||
* over-budget probe re-arms the gate once RSS drains under budget. */
|
||
_Atomic int bp_futile;
|
||
/* Decision A (#1997 #832): set once futility was CONFIRMED by a second
|
||
* full cycle that still ended over budget. Every pull loop breaks on it
|
||
* and extract returns CBM_PIPELINE_ABORT_OVER_BUDGET. Deliberately not
|
||
* the shared cancel token — that one belongs to client cancellation. */
|
||
_Atomic int over_budget_abort;
|
||
|
||
const CBMMacroTable *macro_table; /* ObjectScript $$$macros (NULL if none) */
|
||
const CBMReturnTypeTable *return_type_table; /* ObjectScript return types (NULL if none) */
|
||
|
||
/* Superlinearity probe — see profile.h. Ticked on each file claim below. */
|
||
cbm_scale_probe_t scale;
|
||
} extract_ctx_t;
|
||
|
||
/* Cap on the number of index.file_oversized WARN lines (the full list still goes
|
||
* to the response/logfile — this only throttles the stderr noise). */
|
||
enum { PP_OVERSIZED_WARN_MAX = 32 };
|
||
|
||
/* Upper bound of build_def_props' fixed prefix: the Function/Method metrics
|
||
* literal (about 330 B) with nine ints and seven true/false/test_role strings at
|
||
* their widest stays under 600 B. */
|
||
enum { PP_PROPS_PREFIX_MAX = CBM_SZ_1K };
|
||
|
||
/* Serialized size of one optional field as append_json_string emits it
|
||
* (0 when the field is absent). */
|
||
static size_t pp_str_field_len(const char *key, const char *val) {
|
||
return val && val[0] ? strlen(key) + pp_json_escaped_len(val) + PP_JSON_FIELD_OVERHEAD : 0;
|
||
}
|
||
|
||
/* Serialized size of a string array as append_json_str_array emits it. */
|
||
static size_t pp_arr_field_len(const char *key, const char **arr) {
|
||
if (!arr || !arr[0]) {
|
||
return 0;
|
||
}
|
||
size_t n = strlen(key) + PP_JSON_FIELD_OVERHEAD;
|
||
for (int i = 0; arr[i]; i++) {
|
||
n += pp_json_escaped_len(arr[i]) + PP_ESC_SPACE + (i > 0 ? SKIP_ONE : 0);
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/* A def's properties buffer, sized so EVERY field build_def_props appends fits:
|
||
* the fixed metrics prefix (PP_PROPS_PREFIX_MAX) plus each optional field's
|
||
* whole serialized form. A field that does not fit is dropped whole, so a buffer
|
||
* sized for only some of them let a long signature, decorator list or parameter
|
||
* list squeeze out the MinHash fingerprint ("fp"), structural profile ("sp") and
|
||
* body tokens ("bt") behind them: no SIMILAR_TO edge and fewer
|
||
* SEMANTICALLY_RELATED edges for exactly the widest definitions (jellyfin's
|
||
* ImageController actions, 2026-10-18). fp/sp/bt are appended behind their own
|
||
* room checks, so both the check's reserve and the field are counted. Returns
|
||
* `stack` when everything fits it, or when the larger buffer cannot be
|
||
* allocated. Twin of pass_definitions.c -- keep both in sync. */
|
||
static char *pp_props_buf(const CBMDefinition *def, char *stack, size_t *size) {
|
||
size_t need = PP_PROPS_PREFIX_MAX + pp_str_field_len("docstring", def->docstring);
|
||
if (def->variants && def->variants[0]) {
|
||
need += strlen("variants") + strlen(def->variants) + PP_JSON_FIELD_OVERHEAD;
|
||
}
|
||
need += pp_str_field_len("variant_key", def->variant_key) +
|
||
pp_str_field_len("signature", def->signature) +
|
||
pp_str_field_len("return_type", def->return_type) +
|
||
pp_str_field_len("parent_class", def->parent_class) +
|
||
pp_arr_field_len("decorators", def->decorators) +
|
||
pp_arr_field_len("base_classes", def->base_classes) +
|
||
pp_arr_field_len("param_names", def->param_names) +
|
||
pp_arr_field_len("param_types", def->param_types) +
|
||
pp_str_field_len("route_path", def->route_path) +
|
||
pp_str_field_len("route_method", def->route_method) +
|
||
pp_str_field_len("http_client", def->http_client) +
|
||
pp_str_field_len("http_base_url", def->http_base_url);
|
||
for (const char **kv = def->extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) {
|
||
need += pp_str_field_len(kv[0], kv[SKIP_ONE]);
|
||
}
|
||
if (def->fingerprint && def->fingerprint_k > 0) {
|
||
need += CBM_MINHASH_HEX_LEN + CBM_MINHASH_JSON_OVERHEAD + SKIP_ONE;
|
||
}
|
||
if (def->structural_profile) {
|
||
need += CBM_AST_PROFILE_BUF + pp_str_field_len("sp", def->structural_profile) + SKIP_ONE;
|
||
}
|
||
if (def->body_tokens) {
|
||
need += CBM_SZ_512 + pp_str_field_len("bt", def->body_tokens) + SKIP_ONE;
|
||
}
|
||
need += PP_ESC_SPACE; /* the closing '}' + NUL */
|
||
if (need <= *size) {
|
||
return stack;
|
||
}
|
||
char *buf = cbm_alloc(CBM_MEM_CLASS_GBUF_STRING, need);
|
||
if (!buf) {
|
||
return stack;
|
||
}
|
||
*size = need;
|
||
return buf;
|
||
}
|
||
|
||
/* Insert one definition node (and its route if present) into the local gbuf. */
|
||
static void insert_def_into_gbuf(extract_worker_state_t *ws, const cbm_stem_marks_t *marks,
|
||
const cbm_file_info_t *fi, CBMDefinition *def) {
|
||
char stack[CBM_SZ_2K];
|
||
size_t props_size = sizeof(stack);
|
||
char *props = pp_props_buf(def, stack, &props_size);
|
||
build_def_props(props, props_size, def);
|
||
const char *rel = def->file_path ? def->file_path : fi->rel_path;
|
||
char marked[CBM_SZ_4K];
|
||
const char *qn = def->label && strcmp(def->label, "Module") == 0
|
||
? cbm_stem_marks_module_qn(marks, def->qualified_name, rel, fi->language,
|
||
marked, sizeof(marked))
|
||
: def->qualified_name;
|
||
int64_t func_id =
|
||
cbm_gbuf_upsert_node(ws->local_gbuf, def->label ? def->label : "Function", def->name, qn,
|
||
rel, (int)def->start_line, (int)def->end_line, props);
|
||
if (props != stack) {
|
||
cbm_free(CBM_MEM_CLASS_GBUF_STRING, props);
|
||
}
|
||
ws->nodes_created++;
|
||
cbm_pipeline_emit_def_route(ws->local_gbuf, func_id, def, fi->rel_path);
|
||
}
|
||
|
||
static void log_extract_fail(int pos, uint64_t ms, const char *path) {
|
||
if (pos < PP_LOG_THRESH) {
|
||
cbm_log_warn("parallel.extract.file.fail", "pos", itoa_log(pos), "elapsed_ms",
|
||
itoa_log((int)ms), "path", path);
|
||
}
|
||
}
|
||
|
||
static void log_extract_done(int pos, uint64_t ms, int defs, const char *path) {
|
||
if (pos < PP_LOG_THRESH || ms > PP_TIMER_THRESH) {
|
||
cbm_log_info("parallel.extract.file.done", "pos", itoa_log(pos), "elapsed_ms",
|
||
itoa_log((int)ms), "defs", itoa_log(defs), "path", path);
|
||
}
|
||
}
|
||
|
||
/* One back-pressure cycle: reclaim this thread's freed pages, then nap in
|
||
* bounded 3 ms steps while the process stays over budget. Counted for test
|
||
* observability. Returns true when the FULL cycle elapsed still over budget —
|
||
* the resident floor, not in-flight transients, holds the memory. */
|
||
static bool pp_backpressure_cycle_still_over(extract_ctx_t *ec) {
|
||
cbm_mem_collect();
|
||
atomic_fetch_add_explicit(&g_bp_nap_cycles, SKIP_ONE, memory_order_relaxed);
|
||
int bp = 0;
|
||
for (; bp < PP_BACKPRESSURE_MAX_SPINS && cbm_mem_over_budget() &&
|
||
!atomic_load_explicit(ec->cancelled, memory_order_relaxed) &&
|
||
!atomic_load_explicit(&ec->over_budget_abort, memory_order_relaxed);
|
||
bp++) {
|
||
struct timespec nap = {0, PP_BACKPRESSURE_NAP_NS};
|
||
cbm_nanosleep(&nap, NULL);
|
||
}
|
||
return bp == PP_BACKPRESSURE_MAX_SPINS && cbm_mem_over_budget();
|
||
}
|
||
|
||
/* Decision A (#1997 #832): back-pressure was futile and the confirmation
|
||
* cycle still ended over budget. The budget stops being advisory here: flag
|
||
* the attempt so every worker stops pulling and extract returns
|
||
* CBM_PIPELINE_ABORT_OVER_BUDGET. Cooperative on purpose — the failure must
|
||
* travel as a clean-exit worker response, never a kill, or the supervisor
|
||
* would quarantine innocent files. */
|
||
static void pp_fail_whole_over_budget(extract_ctx_t *ec) {
|
||
if (atomic_exchange_explicit(&ec->over_budget_abort, 1, memory_order_relaxed) == 0) {
|
||
cbm_log_error("mem.budget.exceeded", "rss_mb",
|
||
itoa_log((int)(cbm_mem_rss() / (1024 * 1024))), "budget_mb",
|
||
itoa_log((int)(cbm_mem_budget() / (1024 * 1024))), "phase",
|
||
"parallel_extract", "action", "fail_whole");
|
||
}
|
||
}
|
||
|
||
/* ── Spill / admission control ──────────────────────────────────────── */
|
||
|
||
/* Spill mode is entered this fraction short of the budget: 1/16 = 6.25%,
|
||
* ~940 MB at 15 GB, above the 4% the kernel's in-flight files were measured
|
||
* to carry past the line. */
|
||
enum { PP_SPILL_EARLY_DIV = 16 };
|
||
|
||
/* CBM_MEM_SPILL=1 forces spill mode from the first file (tests, small
|
||
* machines); CBM_MEM_SPILL=0 keeps results in memory even over budget. Read
|
||
* once per extraction run, never cached per process: a test toggles it. */
|
||
static int pp_spill_env_read(void) {
|
||
char buf[CBM_SZ_16];
|
||
if (cbm_safe_getenv("CBM_MEM_SPILL", buf, sizeof(buf), NULL)) {
|
||
return buf[0] == '1' ? 1 : 0;
|
||
}
|
||
return 2; /* unset: the budget decides */
|
||
}
|
||
static bool pp_spill_forced_by_env(const extract_ctx_t *ec) {
|
||
return ec->spill_env == 1;
|
||
}
|
||
static bool pp_spill_allowed(const extract_ctx_t *ec) {
|
||
return ec->spill_env != 0;
|
||
}
|
||
|
||
/* Latch spill mode (once) and open the store. The store is open BEFORE the
|
||
* latch is visible: a peer that reads the latch must also find the store, or
|
||
* it sees "nothing to park" while this worker is still opening files and runs
|
||
* the futility cycle instead of joining the sweep (kernel, 16 GB budget,
|
||
* 2026-09-13: the abort fired before the first sweep had logged). Never fails
|
||
* the run: without a store the results simply stay in memory and the nap /
|
||
* futility path applies as before. */
|
||
static void pp_spill_enter(extract_ctx_t *ec, const char *reason) {
|
||
if (!ec->pctx || !ec->pctx->spill_allowed || !pp_spill_allowed(ec)) {
|
||
return;
|
||
}
|
||
if (atomic_load_explicit(&ec->pctx->spill_mode, memory_order_acquire) != 0 ||
|
||
atomic_load_explicit(&ec->spill_unavailable, memory_order_relaxed) != 0) {
|
||
return;
|
||
}
|
||
cbm_mutex_lock(&ec->spill_mu);
|
||
if (atomic_load_explicit(&ec->pctx->spill_mode, memory_order_acquire) == 0 &&
|
||
atomic_load_explicit(&ec->spill_unavailable, memory_order_relaxed) == 0) {
|
||
if (!ec->pctx->spill) {
|
||
ec->pctx->spill =
|
||
cbm_result_spill_open(cbm_resolve_cache_dir(), ec->max_workers, ec->file_count);
|
||
}
|
||
size_t mb = (size_t)1024 * 1024;
|
||
cbm_log_warn("mem.spill.on", "reason", reason, "charged_mb",
|
||
itoa_log((int)(cbm_mem_charged() / mb)), "budget_mb",
|
||
itoa_log((int)(cbm_mem_budget() / mb)), "store",
|
||
ec->pctx->spill ? "open" : "unavailable");
|
||
if (ec->pctx->spill) {
|
||
atomic_store_explicit(&ec->pctx->spill_mode, 1, memory_order_release);
|
||
} else {
|
||
atomic_store_explicit(&ec->spill_unavailable, 1, memory_order_relaxed);
|
||
}
|
||
}
|
||
cbm_mutex_unlock(&ec->spill_mu);
|
||
}
|
||
|
||
static bool pp_spill_active(const extract_ctx_t *ec) {
|
||
/* The atomic gates the pointer, not the other way round. pp_spill_enter
|
||
* assigns ec->pctx->spill under spill_mu and only THEN release-stores
|
||
* spill_mode, so a reader that has acquired a non-zero spill_mode is
|
||
* guaranteed to see the finished pointer. Testing the pointer first read it
|
||
* with no synchronisation at all while another worker was publishing it —
|
||
* a genuine data race on an 8-byte write, which TSan caught at
|
||
* pass_parallel.c:817 against this line. Short-circuit order is load
|
||
* bearing here; do not reorder these terms. */
|
||
return ec->pctx && atomic_load_explicit(&ec->pctx->spill_mode, memory_order_acquire) != 0 &&
|
||
ec->pctx->spill;
|
||
}
|
||
|
||
CBMFileResult *cbm_pipeline_result_acquire(const cbm_pipeline_ctx_t *ctx, CBMFileResult **cache,
|
||
int i, cbm_result_want_fn want, bool *loaded) {
|
||
*loaded = false;
|
||
if (cache && cache[i]) {
|
||
return (!want || want(cache[i])) ? cache[i] : NULL;
|
||
}
|
||
if (!ctx || !ctx->spill || !cbm_result_spill_has(ctx->spill, i)) {
|
||
return NULL;
|
||
}
|
||
if (want) {
|
||
CBMFileResult hdr;
|
||
if (!cbm_result_spill_peek_header(ctx->spill, i, &hdr) || !want(&hdr)) {
|
||
return NULL;
|
||
}
|
||
}
|
||
CBMFileResult *r = cbm_result_spill_load(ctx->spill, i);
|
||
*loaded = r != NULL;
|
||
return r;
|
||
}
|
||
|
||
void cbm_pipeline_result_release(CBMFileResult *r, bool loaded) {
|
||
if (r && loaded) {
|
||
cbm_free_result(r);
|
||
}
|
||
}
|
||
|
||
void cbm_pipeline_spill_close(cbm_pipeline_ctx_t *ctx) {
|
||
if (!ctx || !ctx->spill) {
|
||
return;
|
||
}
|
||
int64_t parked = 0;
|
||
int64_t bytes = 0;
|
||
int64_t loads = 0;
|
||
cbm_result_spill_stats(ctx->spill, &parked, &bytes, &loads);
|
||
cbm_log_info("mem.spill.done", "parked", itoa_log((int)parked), "mb",
|
||
itoa_log((int)(bytes / (1024 * 1024))), "loads", itoa_log((int)loads));
|
||
cbm_result_spill_close(ctx->spill);
|
||
ctx->spill = NULL;
|
||
atomic_store_explicit(&ctx->spill_mode, 0, memory_order_release);
|
||
cbm_work_arena_release();
|
||
}
|
||
|
||
/* Park every cached result that is complete. Slots are claimed 1 -> 2 so two
|
||
* workers never park the same result; a failed park hands the slot back. */
|
||
/* True while a cached (not yet parked) result exists or a sweep is running:
|
||
* memory can still come down without anyone napping. */
|
||
static bool pp_spill_work_remains(const extract_ctx_t *ec) {
|
||
if (!ec->slot_state) {
|
||
return false;
|
||
}
|
||
if (atomic_load_explicit(&ec->spill_sweeps_running, memory_order_acquire) > 0) {
|
||
return true;
|
||
}
|
||
for (int i = 0; i < ec->file_count; i++) {
|
||
if (atomic_load_explicit(&ec->slot_state[i], memory_order_relaxed) == 1) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static int pp_spill_sweep(extract_ctx_t *ec, int worker_id) {
|
||
if (!pp_spill_active(ec) || !ec->slot_state) {
|
||
return 0;
|
||
}
|
||
atomic_fetch_add_explicit(&ec->spill_sweeps_running, 1, memory_order_acq_rel);
|
||
int parked = 0;
|
||
for (int i = 0; i < ec->file_count; i++) {
|
||
if (atomic_load_explicit(&ec->over_budget_abort, memory_order_relaxed)) {
|
||
break;
|
||
}
|
||
uint8_t expected = 1;
|
||
if (!atomic_compare_exchange_strong_explicit(&ec->slot_state[i], &expected, (uint8_t)2,
|
||
memory_order_acq_rel, memory_order_relaxed)) {
|
||
continue;
|
||
}
|
||
CBMFileResult *r = ec->result_cache[i];
|
||
if (r && cbm_result_spill_park(ec->pctx->spill, worker_id, i, r)) {
|
||
ec->result_cache[i] = NULL;
|
||
parked++;
|
||
if ((parked & 255) == 0) {
|
||
cbm_mem_release_to_os();
|
||
}
|
||
} else {
|
||
atomic_store_explicit(&ec->slot_state[i], (uint8_t)1, memory_order_release);
|
||
}
|
||
}
|
||
if (parked > 0) {
|
||
cbm_mem_release_to_os();
|
||
cbm_log_info("mem.spill.sweep", "parked", itoa_log(parked), "charged_mb",
|
||
itoa_log((int)(cbm_mem_charged() / ((size_t)1024 * 1024))));
|
||
}
|
||
atomic_fetch_sub_explicit(&ec->spill_sweeps_running, 1, memory_order_acq_rel);
|
||
return parked;
|
||
}
|
||
|
||
/* ── Post-extraction projection (#2184) ───────────────────────────────
|
||
* Spill is entered DURING extraction, when the charge crosses the latch below.
|
||
* The phases after it -- registry build, cross-LSP prepare (all_defs, the
|
||
* per-language registries, surface rows, the module index) and resolve (edges
|
||
* into the graph buffer, cross-LSP appends into the cached results) -- cannot
|
||
* spill, and they grow the charge by a sizeable fraction of what extraction
|
||
* left. A run that ended extraction just under the latch therefore kept every
|
||
* result in memory and went over the budget later: openclaw, 8 workers,
|
||
* 4079 MB budget: 3678 MB charged at extraction end, 5829 MB in resolve.
|
||
*
|
||
* So extraction end projects that growth from the result counts and spills
|
||
* before handing over when charged + growth would cross the latch. A pure
|
||
* function of counts (O9): the same repo decides the same way on every run.
|
||
* Per-unit costs fitted 2026-09-25 (M5 Pro, release build, 8 workers) on the
|
||
* charge growth from the parallel_extract mark to the parallel_resolve mark:
|
||
*
|
||
* corpus files defs calls+usages growth model
|
||
* go 21882 735895 5,504,282 1307 MB 1909 MB
|
||
* django 4169 75419 591,350 231 MB 215 MB
|
||
* kotlin 5247 49669 421,788 129 MB 166 MB
|
||
* rust 818 23717 265,957 79 MB 76 MB
|
||
* php 2435 14691 137,969 47 MB 58 MB
|
||
* openclaw 48203 748829 >= 7,402,832 2151 MB >= 2419 MB
|
||
*
|
||
* It never under-reads by more than 7% (inside the budget/16 margin) and
|
||
* over-reads Go by 46% -- the safe side: an unneeded spill costs disk reads,
|
||
* never graph content (spilled and in-memory runs build the same graph). */
|
||
enum {
|
||
PP_POST_BYTES_PER_DEF = 1280, /* registry entry + LSP def + cross registries */
|
||
PP_POST_BYTES_PER_REF = 160, /* per call / usage: resolved edge + appends */
|
||
PP_POST_BYTES_PER_FILE = 8192, /* per-file tables: modules, surfaces, imports */
|
||
};
|
||
|
||
static size_t pp_post_extract_growth(const extract_ctx_t *ec, int64_t *defs_out,
|
||
int64_t *refs_out) {
|
||
int64_t defs = 0;
|
||
int64_t refs = 0;
|
||
for (int i = 0; i < ec->file_count; i++) {
|
||
const CBMFileResult *r = ec->result_cache[i];
|
||
if (r) {
|
||
defs += r->defs.count;
|
||
refs += (int64_t)r->calls.count + r->usages.count;
|
||
}
|
||
}
|
||
*defs_out = defs;
|
||
*refs_out = refs;
|
||
return (size_t)defs * PP_POST_BYTES_PER_DEF + (size_t)refs * PP_POST_BYTES_PER_REF +
|
||
(size_t)ec->file_count * PP_POST_BYTES_PER_FILE;
|
||
}
|
||
|
||
/* Enter spill mode at extraction end when the projected post-extraction growth
|
||
* would carry the charge over the latch; the final sweep then parks every
|
||
* cached result before registry build. No-op when spill is already on, not
|
||
* allowed for this owner, or no budget is set. */
|
||
static void pp_spill_if_projected_over(extract_ctx_t *ec) {
|
||
size_t budget = cbm_mem_budget();
|
||
if (budget == 0 || !ec->pctx || !ec->pctx->spill_allowed || !pp_spill_allowed(ec) ||
|
||
pp_spill_active(ec) || atomic_load_explicit(&ec->over_budget_abort, memory_order_relaxed)) {
|
||
return;
|
||
}
|
||
int64_t defs = 0;
|
||
int64_t refs = 0;
|
||
size_t growth = pp_post_extract_growth(ec, &defs, &refs);
|
||
size_t charged = cbm_mem_charged();
|
||
size_t line = budget - budget / PP_SPILL_EARLY_DIV;
|
||
bool spill = growth > line || charged > line - growth;
|
||
const size_t mb = (size_t)1024 * 1024;
|
||
char v[6][CBM_SZ_32];
|
||
snprintf(v[0], sizeof(v[0]), "%zu", charged / mb);
|
||
snprintf(v[1], sizeof(v[1]), "%zu", growth / mb);
|
||
snprintf(v[2], sizeof(v[2]), "%zu", line / mb);
|
||
snprintf(v[3], sizeof(v[3]), "%lld", (long long)defs);
|
||
snprintf(v[4], sizeof(v[4]), "%lld", (long long)refs);
|
||
snprintf(v[5], sizeof(v[5]), "%d", ec->file_count);
|
||
cbm_log_info("mem.post_extract.projection", "charged_mb", v[0], "growth_mb", v[1], "line_mb",
|
||
v[2], "defs", v[3], "refs", v[4], "files", v[5], "decision",
|
||
spill ? "spill" : "keep");
|
||
if (spill) {
|
||
pp_spill_enter(ec, "post_extract_projection");
|
||
}
|
||
}
|
||
|
||
/* Diagnostic (CBM_MEM_PHASES=1): where does the charge go between the
|
||
* near-budget latch and the first over-budget observation? One line per
|
||
* 256 MB step of the charge above its last logged value while spill mode is
|
||
* on, and one at the first over-budget observation, each with the
|
||
* footprint / commit / tracked breakdown and the class table. */
|
||
static _Atomic size_t g_probe_last_mb = 0;
|
||
static _Atomic int g_probe_over_logged = 0;
|
||
static void pp_charge_probe(extract_ctx_t *ec, bool over) {
|
||
if (!cbm_mem_phases_enabled() || !pp_spill_active(ec)) {
|
||
return;
|
||
}
|
||
enum { PROBE_STEP_MB = 256, PROBE_MB = 1024 * 1024 };
|
||
size_t charged_mb = cbm_mem_charged() / PROBE_MB;
|
||
size_t last = atomic_load_explicit(&g_probe_last_mb, memory_order_relaxed);
|
||
bool step = charged_mb >= last + PROBE_STEP_MB &&
|
||
atomic_compare_exchange_strong_explicit(&g_probe_last_mb, &last, charged_mb,
|
||
memory_order_relaxed, memory_order_relaxed);
|
||
bool first_over =
|
||
over && atomic_exchange_explicit(&g_probe_over_logged, 1, memory_order_relaxed) == 0;
|
||
if (!step && !first_over) {
|
||
return;
|
||
}
|
||
cbm_log_info("mem.charge.probe", "event", first_over ? "first_over" : "step", "charged_mb",
|
||
itoa_log((int)charged_mb), "footprint_mb",
|
||
itoa_log((int)(cbm_mem_footprint() / PROBE_MB)), "commit_mb",
|
||
itoa_log((int)(cbm_mem_allocator_committed() / PROBE_MB)), "tracked_mb",
|
||
itoa_log((int)(cbm_mem_tracked_live_bytes() / PROBE_MB)));
|
||
cbm_mem_class_log(first_over ? "charge.first_over" : "charge.step");
|
||
}
|
||
|
||
static void extract_worker(int worker_id, void *ctx_ptr) {
|
||
extract_ctx_t *ec = ctx_ptr;
|
||
extract_worker_state_t *ws = &ec->workers[worker_id];
|
||
|
||
/* Lazy gbuf creation */
|
||
if (!ws->local_gbuf) {
|
||
ws->local_gbuf = cbm_gbuf_new_worker(ec->project_name, ec->repo_path, ec->shared_ids);
|
||
}
|
||
|
||
/* Pull files from shared atomic counter */
|
||
while (SKIP_ONE) {
|
||
int sort_pos =
|
||
atomic_fetch_add_explicit(&ec->next_file_idx, SKIP_ONE, memory_order_relaxed);
|
||
if (sort_pos >= ec->file_count) {
|
||
break;
|
||
}
|
||
cbm_scale_tick(&ec->scale, sort_pos);
|
||
if (atomic_load_explicit(ec->cancelled, memory_order_relaxed) ||
|
||
atomic_load_explicit(&ec->over_budget_abort, memory_order_relaxed)) {
|
||
break;
|
||
}
|
||
|
||
/* Memory back-pressure (large repos): if the process is over its RSS
|
||
* budget, reclaim this thread's freed pages and nap so peer workers can
|
||
* finish their current file and return memory before this worker adds
|
||
* another parse working set. Caps the concurrent extraction transient
|
||
* near the budget instead of letting all workers parse their biggest
|
||
* files at once. Self-disabling when the budget is unset (tests) or RSS
|
||
* is under budget; bounded spins avoid deadlock when the resident graph
|
||
* is itself near budget.
|
||
*
|
||
* Futility latch: when a FULL nap cycle ends still over budget, the
|
||
* resident floor — not transients — holds the memory; napping again on
|
||
* the next pull cannot reclaim it and only idles workers (linux kernel:
|
||
* one full cycle per pull ≈ 390 s at 79% avg CPU). The worker that
|
||
* latches bp_futile (0→1) runs ONE confirmation cycle while its peers
|
||
* skip the nap. A confirmation that ends under budget re-arms the gate
|
||
* (RSS drained after all); one that still ends over budget fails the
|
||
* attempt whole (decision A, #1997 #832): every worker stops pulling,
|
||
* extract returns CBM_PIPELINE_ABORT_OVER_BUDGET, nothing is published
|
||
* and the previously serving index keeps answering. */
|
||
if (cbm_mem_budget() > 0) {
|
||
bool over = cbm_mem_over_budget();
|
||
pp_charge_probe(ec, over);
|
||
/* Anticipation: the gate sees the crossing per file pull, and the
|
||
* workers' in-flight files carry the charge past the line before
|
||
* the first sweep lands (kernel, 15 GB budget: high-water 15.65
|
||
* GB, 4% over, all of it set in that window). Spill mode is
|
||
* entered PP_SPILL_EARLY_DIV-th short of the budget, so results
|
||
* stop accumulating in memory while the headroom still covers the
|
||
* in-flight work. The nap / futility path still keys on the
|
||
* budget itself. */
|
||
if (!over && !pp_spill_active(ec)) {
|
||
size_t budget = cbm_mem_budget();
|
||
if (cbm_mem_charged() > budget - budget / PP_SPILL_EARLY_DIV) {
|
||
pp_spill_enter(ec, "near_budget");
|
||
}
|
||
}
|
||
/* The MACHINE can be out of memory while our own charge sits well
|
||
* under the budget: measured 2026-09-18 on a 48 GB host, 22 GB
|
||
* charged against a 24 GB budget, and the OS killed the worker
|
||
* anyway because a VM held the rest. Spill on real scarcity too.
|
||
* RELIEF ONLY — `over` is deliberately NOT set from this, so the
|
||
* futility/abort path below stays keyed to OUR budget. Another
|
||
* process's allocation spike must never fail this run.
|
||
* The cheap charge comparison guards the syscall, so the pressure
|
||
* query costs nothing until we are already in the danger zone. */
|
||
if (!pp_spill_active(ec) && cbm_mem_charged() > cbm_mem_budget() / 2 &&
|
||
cbm_mem_system_under_pressure()) {
|
||
pp_spill_enter(ec, "system_pressure");
|
||
(void)pp_spill_sweep(ec, worker_id);
|
||
}
|
||
bool settling = false;
|
||
if (over) {
|
||
/* Admission control, first response: park what can be parked.
|
||
* Only what is still over budget after that -- the floor --
|
||
* reaches the nap / futility / abort path below, and only
|
||
* once nothing is left to park anywhere. */
|
||
pp_spill_enter(ec, "over_budget");
|
||
int parked = pp_spill_sweep(ec, worker_id);
|
||
over = cbm_mem_over_budget();
|
||
if (over && pp_spill_active(ec) && (parked > 0 || pp_spill_work_remains(ec))) {
|
||
over = false; /* memory is still on its way down */
|
||
settling = true;
|
||
}
|
||
}
|
||
bool futile = atomic_load_explicit(&ec->bp_futile, memory_order_relaxed) != 0;
|
||
if (over && !futile) {
|
||
/* Act only on the 0→1 transition: all workers race into the
|
||
* gate before anyone latches, so a plain store would confirm
|
||
* (and WARN) once per worker. */
|
||
if (pp_backpressure_cycle_still_over(ec) &&
|
||
atomic_exchange_explicit(&ec->bp_futile, 1, memory_order_relaxed) == 0) {
|
||
cbm_log_warn("mem.backpressure.futile", "action", "confirm");
|
||
if (pp_backpressure_cycle_still_over(ec)) {
|
||
pp_fail_whole_over_budget(ec);
|
||
} else {
|
||
atomic_store_explicit(&ec->bp_futile, 0, memory_order_relaxed);
|
||
}
|
||
}
|
||
} else if (!over && !settling && futile) {
|
||
/* Re-arm only on a genuine under-budget reading. A reading the
|
||
* spill shortcut produced ("still on its way down") is not
|
||
* one: re-arming on it made the next over-budget pull pay a
|
||
* full nap cycle again -- the gate re-paid per pull that
|
||
* pipeline_backpressure_futile_nap_disengages guards against
|
||
* (TSan lane on PR #2202: 8 cycles against a bound of 7). */
|
||
atomic_store_explicit(&ec->bp_futile, 0, memory_order_relaxed);
|
||
}
|
||
}
|
||
if (atomic_load_explicit(&ec->over_budget_abort, memory_order_relaxed)) {
|
||
break;
|
||
}
|
||
|
||
int file_idx = ec->sorted[sort_pos].idx;
|
||
const cbm_file_info_t *fi = &ec->files[file_idx];
|
||
pp_err_list_t *errs = ec->err_lists ? &ec->err_lists[worker_id] : NULL;
|
||
|
||
/* Crash-quarantine skip (Stage 3c): a file the supervisor pinned as a
|
||
* crasher must never be extracted again. Record it as a phase="crash"
|
||
* skip in this worker's list (merged into the pipeline's file-error list
|
||
* later, surfacing in skipped[]) and move on — the good files still
|
||
* index and status stays "indexed". No-op unless the supervisor set
|
||
* CBM_INDEX_QUARANTINE_FILE. Covers the parallel path; the supervisor's
|
||
* single-threaded recovery run instead takes the sequential path
|
||
* (pass_definitions.c), and cbm_extract_file's hard guard backstops both. */
|
||
if (cbm_index_is_quarantined(fi->rel_path)) {
|
||
const char *phase = cbm_index_quarantine_phase(fi->rel_path);
|
||
if (!phase) {
|
||
phase = "crash";
|
||
}
|
||
const char *reason = (strcmp(phase, "hang") == 0) ? "quarantined after hang"
|
||
: (strcmp(phase, "error") == 0) ? "quarantined after error"
|
||
: "quarantined after crash";
|
||
pp_err_add(errs, fi->rel_path, reason, phase);
|
||
ws->errors++;
|
||
(void)cbm_pipeline_test_extraction_ok(ec->pctx, fi->language, NULL);
|
||
continue;
|
||
}
|
||
|
||
/* Read + extract */
|
||
int source_len = 0;
|
||
long file_size = 0;
|
||
cbm_read_status_t rst = CBM_READ_OK;
|
||
char *source = read_file(fi->path, &source_len, &file_size, &rst);
|
||
if (!source) {
|
||
ws->errors++;
|
||
if (rst == CBM_READ_OVERSIZED) {
|
||
/* Never a silent drop: record the oversized skip + a throttled
|
||
* WARN so the file surfaces in the response/logfile. */
|
||
long cap = cbm_max_file_bytes();
|
||
char reason[96];
|
||
snprintf(reason, sizeof(reason), "oversized (%lld MB > %lld MB)",
|
||
(long long)(file_size / (CBM_SZ_1K * CBM_SZ_1K)),
|
||
(long long)(cap / (CBM_SZ_1K * CBM_SZ_1K)));
|
||
pp_err_add(errs, fi->rel_path, reason, "oversized");
|
||
if (atomic_fetch_add_explicit(&ec->oversized_warned, SKIP_ONE,
|
||
memory_order_relaxed) < PP_OVERSIZED_WARN_MAX) {
|
||
cbm_log_warn("index.file_oversized", "path", fi->rel_path, "size_mb",
|
||
itoa_log((int)(file_size / (CBM_SZ_1K * CBM_SZ_1K))), "cap_mb",
|
||
itoa_log((int)(cap / (CBM_SZ_1K * CBM_SZ_1K))));
|
||
}
|
||
} else if (rst == CBM_READ_OPEN_FAIL || rst == CBM_READ_OOM) {
|
||
pp_err_add(errs, fi->rel_path, "read failed", "read");
|
||
}
|
||
if (rst != CBM_READ_EMPTY) {
|
||
(void)cbm_pipeline_test_extraction_ok(ec->pctx, fi->language, NULL);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
/* Per-file start log: shows which file each worker is processing.
|
||
* Critical for diagnosing stuck workers on large vendored files.
|
||
*
|
||
* Under a crash-durable log (i.e. a supervised worker) EVERY file gets
|
||
* its line, not just the first rounds. That log is the only evidence a
|
||
* contained crash or a kill leaves behind, and #1145/#1130 are
|
||
* unattributable precisely because it never named the file that was in
|
||
* flight — the run ends with the culprit still on the last lines. One
|
||
* line per file, never per node. */
|
||
if (sort_pos < PP_LOG_THRESH || /* first 2 rounds of workers = most interesting */
|
||
cbm_log_crash_durable()) {
|
||
cbm_log_info("parallel.extract.file.start", "pos", itoa_log(sort_pos), "size_kb",
|
||
itoa_log(source_len / CBM_SZ_1K), "path", fi->rel_path);
|
||
}
|
||
|
||
uint64_t file_t0 = extract_now_ns();
|
||
|
||
/* Export XML uses the same cache slot as every physical file, so its
|
||
* generated classes are composed before entering the common registry
|
||
* and resolution lifecycle. */
|
||
CBMFileResult *result =
|
||
cbm_pipeline_test_force_extract_null(ec->pctx, fi->language) ? NULL
|
||
: fi->language == CBM_LANG_OBJECTSCRIPT_EXPORT
|
||
? cbm_pipeline_extract_objectscript_export(source, source_len, ec->project_name,
|
||
fi->rel_path, ec->macro_table,
|
||
ec->return_type_table)
|
||
: cbm_extract_file_ex_with_tests(source, source_len, fi->language, ec->project_name,
|
||
fi->rel_path, CBM_EXTRACT_BUDGET, NULL, NULL,
|
||
ec->macro_table, ec->return_type_table,
|
||
ec->pctx->test_declarations);
|
||
|
||
uint64_t file_elapsed_ms = (extract_now_ns() - file_t0) / PP_USEC_PER_MS;
|
||
|
||
if (!cbm_pipeline_test_extraction_ok(ec->pctx, fi->language, result)) {
|
||
cbm_free_result(result);
|
||
free_source(source);
|
||
cbm_destroy_thread_parser();
|
||
cbm_slab_reclaim();
|
||
cbm_mem_collect();
|
||
continue;
|
||
}
|
||
if (!result) {
|
||
log_extract_fail(sort_pos, file_elapsed_ms, fi->rel_path);
|
||
free_source(source);
|
||
ws->errors++;
|
||
pp_err_add(errs, fi->rel_path, "extract failed", "extract");
|
||
continue;
|
||
}
|
||
if (result->has_test_definition_owners && result->lsp_skipped) {
|
||
cbm_pipeline_test_owner_error(result);
|
||
}
|
||
if (!cbm_pipeline_test_result_ok(ec->pctx, result)) {
|
||
cbm_free_result(result);
|
||
free_source(source);
|
||
cbm_destroy_thread_parser();
|
||
cbm_slab_reclaim();
|
||
cbm_mem_collect();
|
||
continue;
|
||
}
|
||
log_extract_done(sort_pos, file_elapsed_ms, result->defs.count, fi->rel_path);
|
||
|
||
/* Consume the previously-ignored has_error flag: a parse timeout / parse
|
||
* failure / unsupported-grammar result carries no defs but must still be
|
||
* reported (phase "extract", reason = the extractor's message). The empty
|
||
* result flows through unchanged below (the defs loop is a no-op). */
|
||
if (result->has_error) {
|
||
pp_err_add(errs, fi->rel_path, result->error_msg ? result->error_msg : "extract failed",
|
||
"extract");
|
||
ws->errors++;
|
||
} else if (result->parse_incomplete) {
|
||
/* Best-effort parse-coverage signal (#963): the file WAS indexed,
|
||
* but its tree contains ERROR/MISSING regions whose constructs are
|
||
* silently absent from the graph. Neither phase is a skip — both
|
||
* are recorded separately from skipped[] by the MCP layer.
|
||
* "parse_unusable" means one range covers so much of the file that
|
||
* naming the lines helps nobody; see parse_unusable in cbm.h. */
|
||
pp_err_add(errs, fi->rel_path, result->error_ranges ? result->error_ranges : "unknown",
|
||
result->parse_unusable ? "parse_unusable" : "parse_partial");
|
||
}
|
||
if (result->test_declarations_degraded) {
|
||
/* Degraded per file, not the index; see pass_definitions.c. */
|
||
pp_err_add(errs, fi->rel_path,
|
||
cbm_test_extract_status_message(result->test_declarations_degraded_status),
|
||
"test_declarations");
|
||
}
|
||
/* A truncated walk is a coverage gap like a partial parse, and until now
|
||
* it was the only one we kept to ourselves: result->walk_truncated was
|
||
* set and never read by anything, so a file the walk abandoned halfway
|
||
* was reported as fully indexed. Say how far it got — "walked 812k of
|
||
* 3.4M nodes" is the difference between a graph with a known hole and a
|
||
* graph that quietly lies about its coverage. Independent of the
|
||
* branches above: a truncated walk is not a parse error. */
|
||
if (result->walk_truncated) {
|
||
char how_far[CBM_SZ_64];
|
||
snprintf(how_far, sizeof(how_far), "%u/%u nodes walked", result->walk_nodes_visited,
|
||
result->tree_nodes);
|
||
pp_err_add(errs, fi->rel_path, how_far, "walk_truncated");
|
||
} else if (result->lsp_skipped && !result->has_error) {
|
||
/* has_error already recorded its precise cause above. Indexed, but without the per-file
|
||
* and cross-file LSP refinement: the same kind of hole from the other direction.
|
||
* Nothing in production sets this any more except a truncated walk (handled above) and
|
||
* the test seam — it is reported anyway, so that if something sets it again the gap
|
||
* arrives named, not silent. */
|
||
char size_text[CBM_SZ_64];
|
||
snprintf(size_text, sizeof(size_text), "%u nodes", result->tree_nodes);
|
||
pp_err_add(errs, fi->rel_path, size_text, "lsp_skipped");
|
||
}
|
||
|
||
/* Create definition nodes in local gbuf */
|
||
for (int d = 0; d < result->defs.count; d++) {
|
||
CBMDefinition *def = &result->defs.items[d];
|
||
if (def->qualified_name && def->name) {
|
||
insert_def_into_gbuf(ws, ec->pctx ? ec->pctx->stem_marks : NULL, fi, def);
|
||
}
|
||
}
|
||
|
||
/* Free TSTree immediately — arena strings survive for registry+resolve.
|
||
* This makes slab reset safe: tree-sitter's internal nodes (in slab)
|
||
* are released before the slab is bulk-reclaimed. */
|
||
cbm_free_tree(result);
|
||
|
||
/* Detect and parse manifest files for package map */
|
||
{
|
||
const char *bn = strrchr(fi->rel_path, '/');
|
||
cbm_pkgmap_try_parse(bn ? bn + SKIP_ONE : fi->rel_path, fi->rel_path, source,
|
||
source_len, &ec->pkg_entries[worker_id]);
|
||
}
|
||
|
||
/* Retain source bytes in result->arena so the fused cross-file LSP
|
||
* step in resolve_worker can re-parse without re-reading from disk.
|
||
* Bounded per-file (retain_per_file_max_bytes) and by a project-wide
|
||
* budget (retain_total_budget_bytes) to bound peak RSS — see the
|
||
* retention-cap comment at the top of this file. A file DROPPED here
|
||
* is NOT lost to cross-file resolution: resolve_worker re-reads it on
|
||
* demand (bounded, freed immediately). WARN once per run so the
|
||
* operator knows retention was capped (index.retain_capped). */
|
||
if (ec->retain_sources && source_len > 0) {
|
||
bool dropped = false;
|
||
if ((size_t)source_len > ec->retain_per_file_max_bytes) {
|
||
dropped = true; /* over the per-file cap */
|
||
} else {
|
||
int64_t prior = atomic_fetch_add_explicit(&ec->retained_bytes, (int64_t)source_len,
|
||
memory_order_relaxed);
|
||
if ((size_t)(prior + (int64_t)source_len) <= ec->retain_total_budget_bytes) {
|
||
char *copy = (char *)cbm_arena_alloc(&result->arena, (size_t)source_len + 1);
|
||
if (copy) {
|
||
memcpy(copy, source, (size_t)source_len);
|
||
copy[source_len] = '\0';
|
||
result->source = copy;
|
||
result->source_len = source_len;
|
||
} else {
|
||
/* Arena OOM — not a cap drop; re-read still covers
|
||
* cross-file resolution, so don't emit the WARN. */
|
||
atomic_fetch_sub_explicit(&ec->retained_bytes, (int64_t)source_len,
|
||
memory_order_relaxed);
|
||
}
|
||
} else {
|
||
atomic_fetch_sub_explicit(&ec->retained_bytes, (int64_t)source_len,
|
||
memory_order_relaxed);
|
||
dropped = true; /* project-wide budget exhausted */
|
||
}
|
||
}
|
||
if (dropped &&
|
||
atomic_exchange_explicit(&ec->retain_cap_warned, 1, memory_order_relaxed) == 0) {
|
||
cbm_log_warn("index.retain_capped", "path", fi->rel_path ? fi->rel_path : "?",
|
||
"bytes", itoa_log((int)source_len));
|
||
}
|
||
}
|
||
|
||
/* Free source buffer — extraction captured everything needed,
|
||
* and the retention copy (if any) lives in result->arena. */
|
||
free_source(source);
|
||
|
||
/* Everything this file will ever contribute has been written; drop
|
||
* the working arena (node-text copies, abandoned array generations)
|
||
* and keep only the reachable result. See cbm_result_compact. */
|
||
cbm_result_compact(result);
|
||
|
||
/* Cache result (arena + extracted data, no tree) for Phase 3B and
|
||
* Phase 4 -- or park it straight to disk in spill mode. */
|
||
if (pp_spill_active(ec) &&
|
||
cbm_result_spill_park(ec->pctx->spill, worker_id, file_idx, result)) {
|
||
if (ec->slot_state) {
|
||
atomic_store_explicit(&ec->slot_state[file_idx], (uint8_t)2, memory_order_release);
|
||
}
|
||
} else {
|
||
ec->result_cache[file_idx] = result;
|
||
if (ec->slot_state) {
|
||
atomic_store_explicit(&ec->slot_state[file_idx], (uint8_t)1, memory_order_release);
|
||
}
|
||
}
|
||
|
||
/* Progress logging: log every 10 files (atomic read, no contention) */
|
||
if ((sort_pos + SKIP_ONE) % PP_LOG_INTERVAL == 0 || sort_pos + SKIP_ONE == ec->file_count) {
|
||
cbm_log_info("parallel.extract.progress", "done", itoa_log(sort_pos + SKIP_ONE),
|
||
"total", itoa_log(ec->file_count));
|
||
}
|
||
|
||
/* Reclaim all slab + tier2 memory between files.
|
||
*
|
||
* After cbm_free_tree(result), all tree nodes are on free lists.
|
||
* We then destroy the parser (frees its internal allocations too),
|
||
* leaving ZERO live slab/tier2 pointers. At that point, we can
|
||
* safely munmap/free every page, bounding peak memory per-file
|
||
* instead of accumulating across all 644 files.
|
||
*
|
||
* get_thread_parser() in cbm_extract_file will create a fresh
|
||
* parser for the next file — cost is microseconds vs seconds
|
||
* for parsing. This prevents unbounded memory accumulation and works
|
||
* identically on macOS, Linux, and Windows. */
|
||
cbm_destroy_thread_parser();
|
||
cbm_slab_reclaim();
|
||
cbm_mem_collect();
|
||
}
|
||
|
||
/* Final cleanup (parser already destroyed in loop, just slab state) */
|
||
cbm_work_arena_release(); /* the working arena kept between files */
|
||
cbm_slab_destroy_thread();
|
||
cbm_kind_in_set_free_cache(); /* free this worker thread's node-type bitset cache */
|
||
}
|
||
|
||
static void merge_pkg_entries(cbm_pipeline_ctx_t *ctx, cbm_pkg_entries_t *pkg_entries,
|
||
int worker_count) {
|
||
if (!pkg_entries) {
|
||
return;
|
||
}
|
||
/* Supplement with a repo-wide filesystem walk so manifests filtered
|
||
* by the main discoverer (package.json, composer.json — in
|
||
* IGNORED_JSON_FILES) still feed pkgmap. Append into worker 0's
|
||
* array so the existing merge below sees them. */
|
||
cbm_pkgmap_scan_repo(ctx->repo_path, &pkg_entries[0], ctx->excluded_dirs, ctx->excluded_count);
|
||
cbm_pipeline_set_pkgmap(cbm_pkgmap_build(pkg_entries, worker_count, ctx->project_name));
|
||
for (int i = 0; i < worker_count; i++) {
|
||
cbm_pkg_entries_free(&pkg_entries[i]);
|
||
}
|
||
free(pkg_entries);
|
||
}
|
||
|
||
static void log_extract_mem_stats(int worker_count) {
|
||
if (cbm_mem_budget() > 0) {
|
||
size_t mb = (size_t)CBM_SZ_1K * CBM_SZ_1K;
|
||
cbm_log_info("parallel.extract.mem", "rss_mb", itoa_log((int)(cbm_mem_rss() / mb)),
|
||
"peak_mb", itoa_log((int)(cbm_mem_peak_rss() / mb)), "budget_mb",
|
||
itoa_log((int)(cbm_mem_budget() / mb)), "per_worker_mb",
|
||
itoa_log((int)(cbm_mem_worker_budget(worker_count) / mb)));
|
||
}
|
||
}
|
||
|
||
/* Forward declaration: macro table builder lives in pipeline.c (shared path). */
|
||
CBMMacroTable *cbm_build_macro_table_from_files(const cbm_file_info_t *files, int count,
|
||
const char *repo_path);
|
||
|
||
int cbm_parallel_extract_ex(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count,
|
||
CBMFileResult **result_cache, _Atomic int64_t *shared_ids,
|
||
int worker_count, const cbm_parallel_extract_opts_t *opts) {
|
||
cbm_parallel_extract_opts_t resolved_opts = cbm_parallel_extract_resolve_opts(opts);
|
||
|
||
if (file_count == 0) {
|
||
return 0;
|
||
}
|
||
|
||
cbm_log_info("parallel.extract.start", "files", itoa_log(file_count), "workers",
|
||
itoa_log(worker_count));
|
||
{
|
||
size_t mb = (size_t)CBM_SZ_1K * CBM_SZ_1K;
|
||
cbm_log_info("parallel.extract.retention", "retain_sources",
|
||
resolved_opts.retain_sources ? "true" : "false", "total_mb",
|
||
itoa_log((int)(resolved_opts.retain_total_budget_bytes / mb)), "per_file_mb",
|
||
itoa_log((int)(resolved_opts.retain_per_file_max_bytes / mb)));
|
||
}
|
||
|
||
/* Log per-worker memory budget */
|
||
if (cbm_mem_budget() > 0) {
|
||
size_t worker_budget = cbm_mem_worker_budget(worker_count);
|
||
cbm_log_info("parallel.mem.budget", "total_mb",
|
||
itoa_log((int)(cbm_mem_budget() / ((size_t)CBM_SZ_1K * CBM_SZ_1K))),
|
||
"per_worker_mb",
|
||
itoa_log((int)(worker_budget / ((size_t)CBM_SZ_1K * CBM_SZ_1K))));
|
||
}
|
||
|
||
/* Sub-phase: Ensure extraction library is initialized */
|
||
CBM_PROF_START(t_init);
|
||
cbm_init();
|
||
|
||
/* Slab allocator for tree-sitter (thread-safe via TLS). Destroy any
|
||
* parser this thread still holds BEFORE switching the global ts
|
||
* allocator: a parser created in the mimalloc epoch (sequential run,
|
||
* watcher tick) must be freed by the allocator that created it, or its
|
||
* teardown after the switch routes mi pointers into plain free()
|
||
* (#773). */
|
||
cbm_destroy_thread_parser();
|
||
cbm_slab_install();
|
||
CBM_PROF_END("parallel_extract", "1_init_libs", t_init);
|
||
|
||
/* Sub-phase: Sort files by descending size for tail-latency reduction */
|
||
CBM_PROF_START(t_sort);
|
||
file_sort_entry_t *sorted = malloc((size_t)file_count * sizeof(file_sort_entry_t));
|
||
if (!sorted) {
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
for (int i = 0; i < file_count; i++) {
|
||
sorted[i].idx = i;
|
||
sorted[i].size = files[i].size;
|
||
}
|
||
qsort(sorted, file_count, sizeof(file_sort_entry_t), compare_by_size_desc);
|
||
CBM_PROF_END_N("parallel_extract", "2_sort_files", t_sort, file_count);
|
||
|
||
/* Allocate per-worker state (cache-line aligned via posix_memalign) */
|
||
extract_worker_state_t *workers = NULL;
|
||
if (cbm_aligned_alloc((void **)&workers, CBM_CACHE_LINE,
|
||
(size_t)worker_count * sizeof(extract_worker_state_t)) != 0) {
|
||
free(sorted);
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
memset(workers, 0, (size_t)worker_count * sizeof(extract_worker_state_t));
|
||
|
||
/* Per-worker manifest entry arrays (separate from cache-line-aligned worker state) */
|
||
cbm_pkg_entries_t *pkg_entries = calloc((size_t)worker_count, sizeof(cbm_pkg_entries_t));
|
||
if (!pkg_entries) {
|
||
cbm_aligned_free(workers);
|
||
free(sorted);
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
|
||
/* Per-worker skip lists (separate allocation; merged into the pipeline in the
|
||
* sequential merge loop below). */
|
||
pp_err_list_t *err_lists = calloc((size_t)worker_count, sizeof(pp_err_list_t));
|
||
|
||
/* ObjectScript macro table (NULL when no .inc include files present). */
|
||
CBMMacroTable *pp_macro_table =
|
||
cbm_build_macro_table_from_files(files, file_count, ctx->repo_path);
|
||
|
||
extract_ctx_t ec = {
|
||
.files = files,
|
||
.sorted = sorted,
|
||
.file_count = file_count,
|
||
.project_name = ctx->project_name,
|
||
.repo_path = ctx->repo_path,
|
||
.workers = workers,
|
||
.max_workers = worker_count,
|
||
.result_cache = result_cache,
|
||
.shared_ids = shared_ids,
|
||
.cancelled = ctx->cancelled,
|
||
.pctx = ctx,
|
||
.slot_state = NULL,
|
||
.pkg_entries = pkg_entries,
|
||
.err_lists = err_lists,
|
||
.retain_sources = resolved_opts.retain_sources,
|
||
.retain_total_budget_bytes = resolved_opts.retain_total_budget_bytes,
|
||
.retain_per_file_max_bytes = resolved_opts.retain_per_file_max_bytes,
|
||
.macro_table = pp_macro_table,
|
||
.return_type_table = ctx->return_type_table,
|
||
};
|
||
atomic_init(&ec.next_worker_id, 0);
|
||
atomic_init(&ec.next_file_idx, 0);
|
||
atomic_init(&ec.retained_bytes, 0);
|
||
atomic_init(&ec.retain_cap_warned, 0);
|
||
atomic_init(&ec.oversized_warned, 0);
|
||
atomic_init(&ec.bp_futile, 0);
|
||
atomic_init(&ec.over_budget_abort, 0);
|
||
atomic_init(&ec.spill_sweeps_running, 0);
|
||
atomic_init(&ec.spill_unavailable, 0);
|
||
cbm_mutex_init(&ec.spill_mu);
|
||
ec.slot_state = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)file_count * sizeof(_Atomic uint8_t));
|
||
ec.spill_env = pp_spill_env_read();
|
||
if (pp_spill_forced_by_env(&ec)) {
|
||
pp_spill_enter(&ec, "env");
|
||
}
|
||
|
||
/* Sub-phase: Dispatch workers (parse + extract per file, PARALLEL) */
|
||
CBM_PROF_START(t_dispatch);
|
||
cbm_parallel_for_opts_t parallel_opts = {.max_workers = worker_count, .force_pthreads = false};
|
||
cbm_scale_begin(&ec.scale, "parallel_extract", (long)file_count);
|
||
cbm_parallel_for(worker_count, extract_worker, &ec, parallel_opts);
|
||
pp_spill_if_projected_over(&ec);
|
||
if (pp_spill_active(&ec) &&
|
||
!atomic_load_explicit(&ec.over_budget_abort, memory_order_relaxed)) {
|
||
/* Spill mode was entered, so results belong on disk: park every
|
||
* result still cached before the phases that cannot park (registry
|
||
* build, resolve, the semantic pass) inherit them. The sweeps above
|
||
* run only on an over-budget observation; a run that latched early
|
||
* and then stayed under budget through extraction (kernel, 15 GB,
|
||
* 2026-09-14: 14,949 MB at this point, 44,797 results = 8 GB still
|
||
* cached) reached resolve with no headroom and aborted there.
|
||
* The projection above enters spill mode here too when the phases
|
||
* after extraction would carry the charge over the latch (#2184). */
|
||
int parked = pp_spill_sweep(&ec, 0);
|
||
cbm_log_info("mem.spill.final_sweep", "parked", itoa_log(parked), "charged_mb",
|
||
itoa_log((int)(cbm_mem_charged() / ((size_t)1024 * 1024))));
|
||
}
|
||
if (ctx->spill) {
|
||
int64_t parked = 0;
|
||
int64_t bytes = 0;
|
||
cbm_result_spill_stats(ctx->spill, &parked, &bytes, NULL);
|
||
cbm_log_info("mem.spill.extract_done", "parked", itoa_log((int)parked), "mb",
|
||
itoa_log((int)(bytes / (1024 * 1024))));
|
||
}
|
||
cbm_mutex_destroy(&ec.spill_mu);
|
||
cbm_free(CBM_MEM_CLASS_OTHER, ec.slot_state);
|
||
ec.slot_state = NULL;
|
||
cbm_scale_end(&ec.scale);
|
||
CBM_PROF_END_N("parallel_extract", "3_dispatch_workers_parallel", t_dispatch, file_count);
|
||
|
||
/* Sub-phase: Merge all local gbufs into main gbuf (SEQUENTIAL, gbuf not thread-safe) */
|
||
CBM_PROF_START(t_merge);
|
||
int total_nodes = 0;
|
||
int total_errors = 0;
|
||
for (int i = 0; i < worker_count; i++) {
|
||
if (workers[i].local_gbuf) {
|
||
cbm_gbuf_merge(ctx->gbuf, workers[i].local_gbuf);
|
||
total_nodes += workers[i].nodes_created;
|
||
total_errors += workers[i].errors;
|
||
cbm_gbuf_free(workers[i].local_gbuf);
|
||
}
|
||
}
|
||
CBM_PROF_END_N("parallel_extract", "4_merge_gbufs_seq", t_merge, total_nodes);
|
||
|
||
/* Merge per-worker skip lists into the pipeline (SEQUENTIAL — no lock).
|
||
* Runs unconditionally (not gated on local_gbuf) so a worker whose files all
|
||
* failed still surfaces its skips. */
|
||
if (err_lists) {
|
||
for (int i = 0; i < worker_count; i++) {
|
||
for (int j = 0; j < err_lists[i].count; j++) {
|
||
cbm_pipeline_add_file_error(ctx->pipeline, err_lists[i].items[j].path,
|
||
err_lists[i].items[j].reason,
|
||
err_lists[i].items[j].phase);
|
||
free(err_lists[i].items[j].path);
|
||
free(err_lists[i].items[j].reason);
|
||
free(err_lists[i].items[j].phase);
|
||
}
|
||
free(err_lists[i].items);
|
||
}
|
||
free(err_lists);
|
||
}
|
||
|
||
merge_pkg_entries(ctx, pkg_entries, worker_count);
|
||
|
||
cbm_aligned_free(workers);
|
||
free(sorted);
|
||
cbm_macro_table_free(pp_macro_table); /* ObjectScript macro table (NULL-safe) */
|
||
|
||
/* The over-budget verdict outranks the cancel sentinel: a caller must be
|
||
* able to name the cause, and the orchestrator discards the staging DB on
|
||
* every non-zero code alike (the live generation is never touched). */
|
||
if (atomic_load(&ctx->test_declarations_failed)) {
|
||
cbm_log_error("pipeline.err", "phase", "test_declarations_extract");
|
||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||
}
|
||
if (atomic_load(&ec.over_budget_abort)) {
|
||
return CBM_PIPELINE_ABORT_OVER_BUDGET;
|
||
}
|
||
if (atomic_load(ctx->cancelled)) {
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
|
||
log_extract_mem_stats(worker_count);
|
||
|
||
cbm_log_info("parallel.extract.done", "nodes", itoa_log(total_nodes), "errors",
|
||
itoa_log(total_errors));
|
||
return 0;
|
||
}
|
||
|
||
int cbm_parallel_extract(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count,
|
||
CBMFileResult **result_cache, _Atomic int64_t *shared_ids,
|
||
int worker_count) {
|
||
return cbm_parallel_extract_ex(ctx, files, file_count, result_cache, shared_ids, worker_count,
|
||
NULL);
|
||
}
|
||
|
||
/* ── Phase 3B: Serial Registry Build ─────────────────────────────── */
|
||
|
||
/* Register one definition and create DEFINES + DEFINES_METHOD edges. Returns edge count. */
|
||
/* `file_node_id` is the defining file's node id (0 when it has none), looked up
|
||
* once per file by the caller: computing the file QN and finding its node for
|
||
* every definition was 700 k allocations and lookups on the Go corpus. */
|
||
static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def,
|
||
int64_t file_node_id, CBMLanguage lang, size_t module_prefix_len,
|
||
int *reg_entries) {
|
||
int edges = 0;
|
||
if (!def->name || !def->qualified_name || !def->label) {
|
||
return 0;
|
||
}
|
||
/* Registry membership is defined ONCE by cbm_label_is_registry_symbol
|
||
* (helpers.c) — see pass_definitions.c for the per-label rationale. */
|
||
if (cbm_label_is_registry_symbol(def->label)) {
|
||
cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label,
|
||
lang, module_prefix_len);
|
||
(*reg_entries)++;
|
||
}
|
||
char marked[CBM_SZ_4K];
|
||
const char *qn = strcmp(def->label, "Module") == 0
|
||
? cbm_stem_marks_module_qn(ctx->stem_marks, def->qualified_name,
|
||
def->file_path, lang, marked, sizeof(marked))
|
||
: def->qualified_name;
|
||
const cbm_gbuf_node_t *def_node = cbm_gbuf_find_by_qn(ctx->gbuf, qn);
|
||
if (file_node_id > 0 && def_node) {
|
||
cbm_gbuf_insert_edge(ctx->gbuf, file_node_id, def_node->id, "DEFINES", "{}");
|
||
edges++;
|
||
}
|
||
if (def->parent_class && strcmp(def->label, "Method") == 0) {
|
||
const cbm_gbuf_node_t *parent = cbm_gbuf_find_by_qn(ctx->gbuf, def->parent_class);
|
||
if (parent && def_node) {
|
||
cbm_gbuf_insert_edge(ctx->gbuf, parent->id, def_node->id, "DEFINES_METHOD", "{}");
|
||
}
|
||
}
|
||
if (def_node) {
|
||
cbm_pipeline_diagram_parent_edge(ctx, def, def_node->id);
|
||
}
|
||
return edges;
|
||
}
|
||
|
||
/* Add a file's own doc (Go package comment, Rust inner docs) to its File
|
||
* node as "docstring". Twin of pass_definitions.c -- keep both in sync. */
|
||
static void pp_add_file_doc(const cbm_gbuf_node_t *file_node, const char *doc) {
|
||
if (!file_node || !doc || !doc[0]) {
|
||
return;
|
||
}
|
||
const char *old = file_node->properties_json ? file_node->properties_json : "{}";
|
||
size_t olen = strlen(old);
|
||
if (olen < PAIR_LEN || old[olen - SKIP_ONE] != '}') {
|
||
return; /* not a JSON object -- leave it untouched */
|
||
}
|
||
size_t cap = olen + strlen("docstring") + pp_json_escaped_len(doc) + PP_JSON_FIELD_OVERHEAD +
|
||
PP_ESC_SPACE + SKIP_ONE;
|
||
char *neu = cbm_alloc(CBM_MEM_CLASS_GBUF_STRING, cap);
|
||
if (!neu) {
|
||
return;
|
||
}
|
||
size_t pos = olen - SKIP_ONE; /* without the closing brace */
|
||
memcpy(neu, old, pos);
|
||
neu[pos] = '\0';
|
||
append_json_string(neu, cap, &pos, "docstring", doc);
|
||
if (olen == PAIR_LEN && pos > PAIR_LEN) { /* "{}": drop the leading comma */
|
||
memmove(neu + SKIP_ONE, neu + PAIR_LEN, pos - SKIP_ONE);
|
||
pos--;
|
||
}
|
||
neu[pos++] = '}';
|
||
neu[pos] = '\0';
|
||
(void)cbm_gbuf_node_set_properties_json((cbm_gbuf_node_t *)file_node, neu);
|
||
cbm_free(CBM_MEM_CLASS_GBUF_STRING, neu);
|
||
}
|
||
|
||
/* Create IMPORTS edges for one file's imports (parallel path). */
|
||
static int create_imports_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result,
|
||
const char *rel, CBMHashTable *namespace_map) {
|
||
int count = 0;
|
||
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
|
||
const cbm_gbuf_node_t *source_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
|
||
if (!source_node) {
|
||
free(file_qn);
|
||
return 0;
|
||
}
|
||
if (cbm_language_for_filename(rel) == CBM_LANG_PHP) {
|
||
count = cbm_pipeline_php_create_import_edges(ctx, result, rel, file_qn, source_node,
|
||
namespace_map);
|
||
} else {
|
||
for (int j = 0; j < result->imports.count; j++) {
|
||
CBMImport *imp = &result->imports.items[j];
|
||
if (!imp->module_path) {
|
||
continue;
|
||
}
|
||
const cbm_gbuf_node_t *target =
|
||
cbm_pipeline_resolve_import_node(ctx, rel, file_qn, imp, namespace_map);
|
||
if (target && target->id != source_node->id) {
|
||
char esc_ln[CBM_SZ_128];
|
||
cbm_json_escape(esc_ln, sizeof(esc_ln), imp->local_name ? imp->local_name : "");
|
||
char imp_props[CBM_SZ_256];
|
||
snprintf(imp_props, sizeof(imp_props), "{\"local_name\":\"%s\"}", esc_ln);
|
||
cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, target->id, "IMPORTS", imp_props);
|
||
count++;
|
||
}
|
||
}
|
||
}
|
||
free(file_qn);
|
||
return count;
|
||
}
|
||
|
||
/* Find channel source node (enclosing function or file). */
|
||
static const cbm_gbuf_node_t *find_channel_src(cbm_pipeline_ctx_t *ctx, const CBMChannel *ch,
|
||
const char *rel) {
|
||
const cbm_gbuf_node_t *node = NULL;
|
||
if (ch->enclosing_func_qn && ch->enclosing_func_qn[0]) {
|
||
node = cbm_gbuf_find_by_qn(ctx->gbuf, ch->enclosing_func_qn);
|
||
}
|
||
if (!node) {
|
||
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
|
||
node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
|
||
free(file_qn);
|
||
}
|
||
return node;
|
||
}
|
||
|
||
/* Create Channel nodes + EMITS/LISTENS_ON edges for one file. */
|
||
static void create_channel_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result,
|
||
const char *rel) {
|
||
for (int j = 0; j < result->channels.count; j++) {
|
||
CBMChannel *ch = &result->channels.items[j];
|
||
if (!ch->channel_name || !ch->channel_name[0]) {
|
||
continue;
|
||
}
|
||
char channel_qn[CBM_SZ_512];
|
||
snprintf(channel_qn, sizeof(channel_qn), "__channel__%s__%s",
|
||
ch->transport ? ch->transport : "unknown", ch->channel_name);
|
||
char esc_cn[CBM_SZ_256];
|
||
cbm_json_escape(esc_cn, sizeof(esc_cn), ch->channel_name);
|
||
char channel_props[CBM_SZ_512];
|
||
snprintf(channel_props, sizeof(channel_props), "{\"transport\":\"%s\",\"name\":\"%s\"}",
|
||
ch->transport ? ch->transport : "unknown", esc_cn);
|
||
int64_t channel_id = cbm_gbuf_upsert_node(ctx->gbuf, "Channel", ch->channel_name,
|
||
channel_qn, "", 0, 0, channel_props);
|
||
const cbm_gbuf_node_t *src_node = find_channel_src(ctx, ch, rel);
|
||
if (src_node && channel_id > 0) {
|
||
const char *edge_type = ch->direction == CBM_CHANNEL_EMIT ? "EMITS" : "LISTENS_ON";
|
||
char edge_props[CBM_SZ_128];
|
||
snprintf(edge_props, sizeof(edge_props), "{\"transport\":\"%s\"}",
|
||
ch->transport ? ch->transport : "unknown");
|
||
cbm_gbuf_insert_edge(ctx->gbuf, src_node->id, channel_id, edge_type, edge_props);
|
||
}
|
||
}
|
||
}
|
||
|
||
int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
|
||
int file_count, CBMFileResult **result_cache) {
|
||
cbm_log_info("parallel.registry.start", "files", itoa_log(file_count));
|
||
|
||
int reg_entries = 0;
|
||
int defines_edges = 0;
|
||
int imports_edges = 0;
|
||
|
||
/* Namespace/package → File-QN map for namespace imports (C# `using`,
|
||
* Java/Kotlin `import`, PHP `use`). Built from the full result cache so
|
||
* every declaring file is visible regardless of loop order. */
|
||
const char **rels = (const char **)calloc((size_t)file_count, sizeof(char *));
|
||
if (rels) {
|
||
for (int i = 0; i < file_count; i++) {
|
||
rels[i] = files[i].rel_path;
|
||
}
|
||
}
|
||
/* Built from every file, including the ones already parked on disk: their
|
||
* results are NULL in result_cache, and a file absent from this map does
|
||
* not fail to resolve, it resolves through the looser fallback. Spilling
|
||
* therefore used to CHANGE the graph rather than merely delay it -- php
|
||
* measured 57,182 edges in memory against 59,379 while spilling, the same
|
||
* binary and corpus (2026-09-18), differing in both directions. */
|
||
const char **namespaces = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)file_count * sizeof(char *));
|
||
CBMHashTable *namespace_map = NULL;
|
||
if (namespaces) {
|
||
for (int i = 0; i < file_count; i++) {
|
||
namespaces[i] = result_cache[i] ? result_cache[i]->namespace_name
|
||
: cbm_result_spill_namespace(ctx->spill, i);
|
||
}
|
||
namespace_map =
|
||
cbm_pipeline_namespace_map_build_names(ctx->project_name, namespaces, rels, file_count);
|
||
cbm_free(CBM_MEM_CLASS_OTHER, namespaces);
|
||
}
|
||
free(rels);
|
||
|
||
for (int i = 0; i < file_count; i++) {
|
||
if (cbm_pipeline_check_cancel(ctx)) {
|
||
cbm_pipeline_namespace_map_free(namespace_map);
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
|
||
CBMFileResult *result = result_cache[i];
|
||
bool loaded = false;
|
||
if (!result && ctx->spill && cbm_result_spill_has(ctx->spill, i)) {
|
||
result = cbm_result_spill_load(ctx->spill, i);
|
||
loaded = result != NULL;
|
||
if (!cbm_pipeline_test_extraction_ok(ctx, files[i].language, result)) {
|
||
cbm_pipeline_result_release(result, loaded);
|
||
cbm_pipeline_namespace_map_free(namespace_map);
|
||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||
}
|
||
}
|
||
if (!result) {
|
||
continue;
|
||
}
|
||
|
||
if (!cbm_pipeline_test_result_ok(ctx, result)) {
|
||
cbm_pipeline_result_release(result, loaded);
|
||
cbm_pipeline_namespace_map_free(namespace_map);
|
||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||
}
|
||
|
||
const char *rel = files[i].rel_path;
|
||
|
||
/* Register callable symbols + DEFINES/DEFINES_METHOD edges */
|
||
if (result->defs.count > 0) {
|
||
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
|
||
const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
|
||
int64_t file_node_id = file_node ? file_node->id : 0;
|
||
free(file_qn);
|
||
pp_add_file_doc(file_node, result->module_doc);
|
||
/* Bounds the function-local detection to this file (pass_definitions.c). */
|
||
size_t py_prefix_len = files[i].language == CBM_LANG_PYTHON
|
||
? cbm_pipeline_fqn_symbol_prefix_len(ctx->project_name, rel)
|
||
: 0;
|
||
for (int d = 0; d < result->defs.count; d++) {
|
||
defines_edges +=
|
||
register_and_link_def(ctx, &result->defs.items[d], file_node_id,
|
||
files[i].language, py_prefix_len, ®_entries);
|
||
}
|
||
}
|
||
|
||
imports_edges += create_imports_edges(ctx, result, rel, namespace_map);
|
||
create_channel_edges(ctx, result, rel);
|
||
cbm_pipeline_diagram_arrows(ctx, result);
|
||
cbm_pipeline_create_env_configures_for_file(ctx, result, rel);
|
||
if (loaded) {
|
||
cbm_free_result(result);
|
||
}
|
||
}
|
||
|
||
/* Publish instead of free: the call pass needs the same declared-package
|
||
* set to tell an in-tree package path from a third-party one (#1355).
|
||
* The pipeline owns it from here and frees it in its cleanup. */
|
||
cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap());
|
||
cbm_pipeline_set_nsmap(namespace_map);
|
||
|
||
cbm_log_info("parallel.registry.done", "entries", itoa_log(reg_entries), "defines",
|
||
itoa_log(defines_edges), "imports", itoa_log(imports_edges));
|
||
return 0;
|
||
}
|
||
|
||
/* ── Phase 4: Parallel Resolution ────────────────────────────────── */
|
||
|
||
typedef struct __attribute__((aligned(CBM_CACHE_LINE))) {
|
||
cbm_gbuf_t *local_edge_buf;
|
||
int calls_resolved;
|
||
int usages_resolved;
|
||
int semantic_resolved;
|
||
int errors;
|
||
/* Subset of calls_resolved that were attributed via the LSP-override
|
||
* path (cbm_pipeline_find_lsp_resolution hit) rather than the
|
||
* registry's textual matcher. Surfaced in the parallel.resolve.done
|
||
* log line so divergence between pipelines becomes observable. */
|
||
int lsp_overrides;
|
||
CBMFileResult *loaded; /* spill-loaded result this worker is using */
|
||
char _pad[CBM_CACHE_LINE - 2 * sizeof(void *) - ((PP_RING + 1) * sizeof(int))];
|
||
} resolve_worker_state_t;
|
||
|
||
typedef struct {
|
||
const cbm_file_info_t *files;
|
||
int file_count;
|
||
const char *project_name;
|
||
const char *repo_path;
|
||
|
||
resolve_worker_state_t *workers;
|
||
int max_workers;
|
||
|
||
CBMFileResult **result_cache;
|
||
const cbm_gbuf_t *main_gbuf; /* READ-ONLY during Phase 4 */
|
||
const cbm_registry_t *registry; /* READ-ONLY during Phase 4 */
|
||
_Atomic int64_t *shared_ids;
|
||
_Atomic int *cancelled;
|
||
_Atomic int next_file_idx;
|
||
|
||
/* Cross-file LSP inputs — pre-built once by the caller in pipeline.c
|
||
* and shared read-only by usage across workers (typed non-const to
|
||
* match the existing cbm_run_X_lsp_cross callee signatures the
|
||
* worker forwards them to). NULL/0 → cross-LSP no-ops. */
|
||
CBMLSPDef *all_defs;
|
||
int def_count;
|
||
char *const *def_modules; /* per-file module QN; def_modules[i] for files[i] */
|
||
/* Optional inverted index for per-file def filtering (gopls pattern).
|
||
* When non-NULL, the fused worker calls cbm_pxc_filter_defs_for_file
|
||
* to shrink the def array passed to the LSP from O(all_defs) to
|
||
* O(relevant_defs). NULL → each file sees the full all_defs[]. */
|
||
struct CBMModuleDefIndex *module_def_index;
|
||
/* Tier 2 full: pre-built per-language registries (project-wide,
|
||
* finalized, READ-ONLY). When non-NULL for a lang, the worker uses
|
||
* cbm_run_X_lsp_cross_with_registry — skip per-file build entirely.
|
||
* Stored as CBMCrossLspRegistries* (typedef from pass_lsp_cross.h). */
|
||
CBMCrossLspRegistries *cross_registries;
|
||
|
||
/* Parsed once on the coordinator thread and borrowed read-only by resolve
|
||
* workers. The pointer is installed into each worker's TLS slot so Rust's
|
||
* manifest-aware cross-file resolver sees the same Cargo context. */
|
||
const CBMCargoManifest *rust_manifest;
|
||
|
||
/* F4: LAZILY-built shared Rust registry (built ONCE, on the first NULL-filter
|
||
* rust file — the ~all_defs amplifier files). Not eager: repos whose rust files
|
||
* all filter to subsets never pay the O(all_defs) build + multi-GB RSS. Built
|
||
* under rust_shared_mu into rust_shared_arena, published via rust_shared_reg;
|
||
* torn down after the worker dispatch. */
|
||
_Atomic(CBMTypeRegistry *) rust_shared_reg;
|
||
cbm_mutex_t rust_shared_mu;
|
||
CBMArena rust_shared_arena;
|
||
bool rust_shared_arena_live;
|
||
|
||
/* Counters for parallel.resolve.lsp_cross_done summary. */
|
||
_Atomic int lsp_cross_processed;
|
||
_Atomic int lsp_cross_skipped_no_source;
|
||
/* Each file is owned by one worker. The coordinator reports failures
|
||
* after join, without mutating the pipeline's error list concurrently. */
|
||
const char **lsp_failures;
|
||
|
||
/* Per-sub-phase timing (ns aggregated across workers) — surfaces
|
||
* exactly where parallel_resolve's wall time is spent so we stop
|
||
* guessing about hot paths. Logged once at the end of
|
||
* cbm_parallel_resolve. */
|
||
_Atomic uint64_t time_ns_import_map;
|
||
_Atomic uint64_t time_ns_cross_lsp;
|
||
_Atomic uint64_t time_ns_calls;
|
||
_Atomic uint64_t time_ns_usages;
|
||
_Atomic uint64_t time_ns_throws;
|
||
_Atomic uint64_t time_ns_rw;
|
||
_Atomic uint64_t time_ns_semantic;
|
||
/* Whole-iteration timer — captures everything from atomic file_idx
|
||
* pickup through cleanup. If this >> sum of sub-phases, the
|
||
* unmeasured cost is either in skip-eligibility checks, gbuf
|
||
* setup, or — most likely — workers waiting on the
|
||
* cbm_parallel_for synchronization barrier at the end. */
|
||
_Atomic uint64_t time_ns_total_loop;
|
||
_Atomic int total_files_visited;
|
||
/* Sub-breakdowns inside resolve_file_calls — finds the 553µs-per-
|
||
* iteration hot path that the high-level resolve_calls counter
|
||
* doesn't pinpoint. */
|
||
_Atomic uint64_t time_ns_rc_lsp_lookup; /* lsp_idx + fallback scan */
|
||
_Atomic uint64_t time_ns_rc_resolve; /* lsp_target_node OR registry_resolve */
|
||
_Atomic uint64_t time_ns_rc_hint; /* try_field_type_hint */
|
||
_Atomic uint64_t time_ns_rc_target; /* gbuf_find_by_qn for target */
|
||
_Atomic uint64_t time_ns_rc_emit; /* emit_service_edge */
|
||
_Atomic uint64_t time_ns_rc_source; /* find_source_node */
|
||
|
||
/* Superlinearity probe — see profile.h. This is the pass that made it
|
||
* necessary (#1669: 87% of a Java index), so it is the one that must never
|
||
* again grow superlinear without saying so. */
|
||
cbm_scale_probe_t scale;
|
||
cbm_pipeline_ctx_t *pctx; /* spill store access */
|
||
} resolve_ctx_t;
|
||
|
||
/* Minimum buffer space needed per arg JSON object */
|
||
#define CBM_ARG_JSON_GUARD CBM_SZ_32
|
||
|
||
/* Append arg data as JSON to edge properties: ,"args":[{"i":0,"e":"x","v":"val"},...]
|
||
* Returns new position in buffer. */
|
||
/* Sanitize expression string for JSON (in-place). */
|
||
static void sanitize_expr(char *expr_buf, const char *expr) {
|
||
if (expr) {
|
||
snprintf(expr_buf, 128, "%.*s", 120, expr);
|
||
/* The 120-byte cut can land inside a multibyte character; a torn
|
||
* sequence persisted as invalid UTF-8 in edge properties (2026-09-16
|
||
* probe: rust, java, typescript stores). */
|
||
cbm_utf8_trim_partial(expr_buf);
|
||
for (char *p = expr_buf; *p; p++) {
|
||
if (*p == '"') {
|
||
*p = '\'';
|
||
}
|
||
if (*p == '\n' || *p == '\r') {
|
||
*p = ' ';
|
||
}
|
||
}
|
||
} else {
|
||
expr_buf[0] = '\0';
|
||
}
|
||
}
|
||
|
||
/* Format one call arg as JSON. Returns snprintf result. */
|
||
static int format_call_arg(char *buf, size_t bufsize, const CBMCallArg *a, const char *expr) {
|
||
char esc_k[CBM_SZ_128];
|
||
char esc_e[CBM_SZ_128];
|
||
char esc_v[CBM_SZ_128];
|
||
cbm_json_escape(esc_e, sizeof(esc_e), expr);
|
||
if (a->keyword && a->value) {
|
||
cbm_json_escape(esc_k, sizeof(esc_k), a->keyword);
|
||
cbm_json_escape(esc_v, sizeof(esc_v), a->value);
|
||
return snprintf(buf, bufsize, "{\"i\":%d,\"k\":\"%s\",\"e\":\"%s\",\"v\":\"%s\"}", a->index,
|
||
esc_k, esc_e, esc_v);
|
||
}
|
||
if (a->keyword) {
|
||
cbm_json_escape(esc_k, sizeof(esc_k), a->keyword);
|
||
return snprintf(buf, bufsize, "{\"i\":%d,\"k\":\"%s\",\"e\":\"%s\"}", a->index, esc_k,
|
||
esc_e);
|
||
}
|
||
if (a->value) {
|
||
cbm_json_escape(esc_v, sizeof(esc_v), a->value);
|
||
return snprintf(buf, bufsize, "{\"i\":%d,\"e\":\"%s\",\"v\":\"%s\"}", a->index, esc_e,
|
||
esc_v);
|
||
}
|
||
return snprintf(buf, bufsize, "{\"i\":%d,\"e\":\"%s\"}", a->index, esc_e);
|
||
}
|
||
|
||
static size_t append_args_json(char *buf, size_t bufsize, size_t pos, const CBMCall *call) {
|
||
if (call->arg_count == 0 || pos >= bufsize - PP_ARGS_MARGIN) {
|
||
return pos;
|
||
}
|
||
int n = snprintf(buf + pos, bufsize - pos, ",\"args\":[");
|
||
if (n <= 0) {
|
||
return pos;
|
||
}
|
||
pos += (size_t)n;
|
||
for (int i = 0; i < call->arg_count && pos < bufsize - CBM_ARG_JSON_GUARD; i++) {
|
||
const CBMCallArg *a = &call->args[i];
|
||
size_t mark = pos; /* rollback point (before the separator) */
|
||
if (i > 0 && pos < bufsize - SKIP_ONE) {
|
||
buf[pos++] = ',';
|
||
}
|
||
char expr_buf[CBM_SZ_128];
|
||
sanitize_expr(expr_buf, a->expr);
|
||
n = format_call_arg(buf + pos, bufsize - pos, a, expr_buf);
|
||
/* snprintf returns the UNtruncated length: if the arg did not fully
|
||
* fit, advancing pos by n would push it past buf and the buf[pos]
|
||
* writes below would overflow. Drop the arg whole (atomic field —
|
||
* keeps the array valid) and stop appending. */
|
||
if (n <= 0 || (size_t)n >= bufsize - pos) {
|
||
pos = mark;
|
||
break;
|
||
}
|
||
pos += (size_t)n;
|
||
}
|
||
if (pos < bufsize - SKIP_ONE) {
|
||
buf[pos++] = ']';
|
||
}
|
||
buf[pos] = '\0';
|
||
return pos;
|
||
}
|
||
|
||
/* Scan call args for a URL-like route path and handler reference. */
|
||
static bool is_path_keyword(const char *keyword) {
|
||
static const char *path_keywords[] = {"prefix", "path", "route", "pattern",
|
||
"url", "endpoint", "rule", "mount_path",
|
||
"route_path", "url_path", NULL};
|
||
for (const char **kw = path_keywords; *kw; kw++) {
|
||
if (strcmp(keyword, *kw) == 0) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* A route path is one line that opens with a slash. A block or line comment
|
||
* opens with a slash too, and an argument list that starts with one used to
|
||
* hand the comment text to the Route pass (three Java block comments became
|
||
* Route nodes on elasticsearch, 2026-09-16). */
|
||
static bool is_route_path_shaped(const char *val) {
|
||
return val && val[0] == '/' && !cbm_service_pattern_is_comment_text(val);
|
||
}
|
||
|
||
static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler) {
|
||
*out_handler = NULL;
|
||
/* 1. First string arg starting with / */
|
||
if (is_route_path_shaped(call->first_string_arg)) {
|
||
*out_handler = call->second_arg_name;
|
||
return call->first_string_arg;
|
||
}
|
||
/* 2. Keyword args (prefix=, path=, route=, etc.) */
|
||
const char *found = NULL;
|
||
for (int ai = 0; ai < call->arg_count && !found; ai++) {
|
||
const CBMCallArg *ca = &call->args[ai];
|
||
const char *val = ca->value ? ca->value : ca->expr;
|
||
if (!is_route_path_shaped(val)) {
|
||
continue;
|
||
}
|
||
if ((ca->keyword && is_path_keyword(ca->keyword)) || (!ca->keyword && ca->index == 0)) {
|
||
found = val;
|
||
}
|
||
}
|
||
if (!found) {
|
||
return NULL;
|
||
}
|
||
/* 3. Handler: first identifier arg that's not a path/keyword */
|
||
for (int ai = 0; ai < call->arg_count; ai++) {
|
||
const CBMCallArg *ca = &call->args[ai];
|
||
if (!ca->expr || ca->expr[0] == '/' || ca->expr[0] == '"' || ca->expr[0] == '\'') {
|
||
continue;
|
||
}
|
||
if (ca->keyword && (strcmp(ca->keyword, "prefix") == 0 ||
|
||
strcmp(ca->keyword, "name") == 0 || strcmp(ca->keyword, "tags") == 0)) {
|
||
continue;
|
||
}
|
||
*out_handler = ca->expr;
|
||
break;
|
||
}
|
||
return found;
|
||
}
|
||
|
||
/* Append the call's args (and, for CALLS, its line) to props and close the
|
||
* brace. Shared with the sequential pass, whose service edges must carry the
|
||
* same bytes (pass_calls.c emit_http_async_edge). */
|
||
void cbm_pipeline_close_call_props(char *props, int n, const CBMCall *call, bool with_line) {
|
||
if (n > 0 && (size_t)n < CBM_SZ_2K - PP_ESC_SPACE) {
|
||
size_t pos = append_args_json(props, CBM_SZ_2K, (size_t)n, call);
|
||
if (with_line && call->start_line > 0 && pos < CBM_SZ_2K - PP_LINE_MARGIN) {
|
||
int ln = snprintf(props + pos, CBM_SZ_2K - pos, ",\"line\":%d", call->start_line);
|
||
if (ln > 0) {
|
||
pos += (size_t)ln;
|
||
}
|
||
}
|
||
if (pos < CBM_SZ_2K - SKIP_ONE) {
|
||
props[pos] = '}';
|
||
props[pos + SKIP_ONE] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Build props JSON, append args, close brace, emit edge. */
|
||
static bool finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id,
|
||
const char *edge_type, char *props, int n, const CBMCall *call) {
|
||
cbm_pipeline_close_call_props(props, n, call, strcmp(edge_type, "CALLS") == 0);
|
||
return cbm_gbuf_insert_edge(gbuf, src_id, tgt_id, edge_type, props) > 0 &&
|
||
strcmp(edge_type, "CALLS") == 0;
|
||
}
|
||
|
||
/* Build Route node QN and properties for HTTP/async service edges. */
|
||
static int64_t build_service_route(cbm_gbuf_t *gbuf, const char *arg, const char *method,
|
||
const char *broker, cbm_svc_kind_t svc) {
|
||
char route_qn[CBM_ROUTE_QN_SIZE];
|
||
const char *prefix;
|
||
char cpath[CBM_SZ_256];
|
||
const char *qpath = arg;
|
||
if (svc == CBM_SVC_HTTP) {
|
||
prefix = method ? method : "ANY";
|
||
qpath = cbm_route_canon_path(arg, cpath, sizeof(cpath));
|
||
} else {
|
||
prefix = broker ? broker : "async";
|
||
}
|
||
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, qpath);
|
||
char route_props[CBM_SZ_256];
|
||
if (method) {
|
||
snprintf(route_props, sizeof(route_props), "{\"method\":\"%s\"}", method);
|
||
} else if (broker) {
|
||
snprintf(route_props, sizeof(route_props), "{\"broker\":\"%s\"}", broker);
|
||
} else {
|
||
snprintf(route_props, sizeof(route_props), "{}");
|
||
}
|
||
return cbm_gbuf_upsert_node(gbuf, "Route", arg, route_qn, "", 0, 0, route_props);
|
||
}
|
||
|
||
/* Emit HTTP_CALLS or ASYNC_CALLS edge via Route node. */
|
||
static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
|
||
const CBMCall *call, const cbm_resolution_t *res,
|
||
cbm_svc_kind_t svc, const char *arg) {
|
||
const char *edge_type = (svc == CBM_SVC_HTTP) ? "HTTP_CALLS" : "ASYNC_CALLS";
|
||
const char *method =
|
||
(svc == CBM_SVC_HTTP) ? cbm_service_pattern_http_method(call->callee_name) : NULL;
|
||
const char *broker =
|
||
(svc == CBM_SVC_ASYNC) ? cbm_service_pattern_broker(res->qualified_name) : NULL;
|
||
|
||
int64_t route_id = build_service_route(gbuf, arg, method, broker, svc);
|
||
|
||
char esc_c[CBM_SZ_256];
|
||
char esc_a[CBM_SZ_256];
|
||
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
|
||
cbm_json_escape(esc_a, sizeof(esc_a), arg);
|
||
char props[CBM_SZ_2K];
|
||
int n = snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\"", esc_c, esc_a);
|
||
if (method) {
|
||
n += snprintf(props + n, sizeof(props) - (size_t)n, ",\"method\":\"%s\"", method);
|
||
}
|
||
if (broker) {
|
||
n += snprintf(props + n, sizeof(props) - (size_t)n, ",\"broker\":\"%s\"", broker);
|
||
}
|
||
finalize_and_emit(gbuf, source->id, route_id, edge_type, props, n, call);
|
||
}
|
||
|
||
/* Emit CONFIGURES edge. */
|
||
static void emit_config_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
|
||
const cbm_gbuf_node_t *target, const CBMCall *call,
|
||
const cbm_resolution_t *res, const char *arg) {
|
||
/* emit_service_edge may be reached with target==NULL on the HTTP/ASYNC
|
||
* external-client bypass (#523); a CONFIGURES edge needs a real target, so
|
||
* never deref a NULL target here. */
|
||
if (!target) {
|
||
return;
|
||
}
|
||
char esc_c[CBM_SZ_256];
|
||
char esc_k[CBM_SZ_256];
|
||
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
|
||
cbm_json_escape(esc_k, sizeof(esc_k), arg ? arg : "");
|
||
char props[CBM_SZ_2K];
|
||
int n = snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"key\":\"%s\",\"confidence\":%.2f",
|
||
esc_c, esc_k, res->confidence);
|
||
finalize_and_emit(gbuf, source->id, target->id, "CONFIGURES", props, n, call);
|
||
}
|
||
|
||
/* Emit normal CALLS edge. */
|
||
static bool emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
|
||
const cbm_gbuf_node_t *target, const CBMCall *call,
|
||
const cbm_resolution_t *res) {
|
||
/* A CALLS edge needs a real target; the HTTP/ASYNC external-client bypass
|
||
* (#523) can reach emit_service_edge with target==NULL, so guard the deref. */
|
||
if (!target) {
|
||
return false;
|
||
}
|
||
char esc_c[CBM_SZ_256];
|
||
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
|
||
char props[CBM_SZ_2K];
|
||
int n = snprintf(props, sizeof(props),
|
||
"{\"callee\":\"%s\",\"confidence\":%.2f,\"strategy\":\"%s\",\"candidates\":%d",
|
||
esc_c, res->confidence, res->strategy ? res->strategy : "unknown",
|
||
res->candidate_count);
|
||
return finalize_and_emit(gbuf, source->id, target->id, "CALLS", props, n, call);
|
||
}
|
||
|
||
/* Classify a resolved call by library identity and emit the appropriate edge. */
|
||
/* Create Route node + CALLS + HANDLES edges for a route registration call.
|
||
* route_mount is the framework mount of the registering file ("/api" for a
|
||
* Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */
|
||
static bool emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
|
||
const CBMCall *call, const char *route_path,
|
||
const char *handler_ref, const char *module_qn,
|
||
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
|
||
const char **ik, const char **iv, int ic,
|
||
const char *route_mount, CBMLanguage lang) {
|
||
const char *method = cbm_service_pattern_route_method(call->callee_name);
|
||
char mounted[CBM_SZ_256];
|
||
route_path = cbm_laravel_mount_route(route_mount, route_path, mounted, sizeof(mounted));
|
||
char rqn[CBM_ROUTE_QN_SIZE];
|
||
char cpath[CBM_SZ_256];
|
||
snprintf(rqn, sizeof(rqn), "__route__%s__%s", method ? method : "ANY",
|
||
cbm_route_canon_path(route_path, cpath, sizeof(cpath)));
|
||
char rp[CBM_SZ_256];
|
||
snprintf(rp, sizeof(rp), "{\"method\":\"%s\"}", method ? method : "ANY");
|
||
int64_t rid = cbm_gbuf_upsert_node(gbuf, "Route", route_path, rqn, "", 0, 0, rp);
|
||
char esc_cn[CBM_SZ_256]; /* sliced source text: escape quotes/newlines */
|
||
char esc_rp[CBM_SZ_512];
|
||
cbm_json_escape(esc_cn, sizeof(esc_cn), call->callee_name);
|
||
cbm_json_escape(esc_rp, sizeof(esc_rp), route_path);
|
||
char props[CBM_SZ_1K];
|
||
snprintf(props, sizeof(props),
|
||
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn,
|
||
esc_rp);
|
||
bool emitted = cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props) > 0;
|
||
if (handler_ref && handler_ref[0] != '\0') {
|
||
/* Python: a handler an app factory registers is usually its local def
|
||
* (`app.add_url_rule("/", "index", index)` inside create_app); only the
|
||
* lexical lookup sees a function's locals. MUST match pass_calls.c. */
|
||
cbm_resolution_t hres = {0};
|
||
if (lang == CBM_LANG_PYTHON) {
|
||
hres = cbm_registry_resolve_lexical(registry, handler_ref, call->enclosing_func_qn,
|
||
module_qn);
|
||
}
|
||
if (!hres.qualified_name) {
|
||
hres = cbm_registry_resolve_handler(registry, handler_ref, module_qn, ik, iv, ic,
|
||
main_gbuf, lang);
|
||
}
|
||
if (hres.qualified_name && hres.qualified_name[0] != '\0') {
|
||
const cbm_gbuf_node_t *h = cbm_gbuf_find_by_qn(main_gbuf, hres.qualified_name);
|
||
if (h) {
|
||
char hp[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the
|
||
closing brace */
|
||
char esc_h2[CBM_SZ_512];
|
||
cbm_json_escape(esc_h2, sizeof(esc_h2), hres.qualified_name);
|
||
snprintf(hp, sizeof(hp), "{\"handler\":\"%s\"}", esc_h2);
|
||
cbm_gbuf_insert_edge(gbuf, h->id, rid, "HANDLES", hp);
|
||
}
|
||
}
|
||
}
|
||
return emitted;
|
||
}
|
||
|
||
/* Reject regex metacharacters, spaces, double-slashes in URL candidates. */
|
||
static bool is_junk_url(const char *s) {
|
||
for (int i = 0; s[i]; i++) {
|
||
char ch = s[i];
|
||
if (ch == '\\' || ch == '^' || ch == '$' || ch == '*' || ch == '+' || ch == '(' ||
|
||
ch == ')' || ch == '[' || ch == ']' || ch == '|' || ch == ' ') {
|
||
return true;
|
||
}
|
||
if (ch == '/' && i > 0 && s[i - SKIP_ONE] == '/') {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* Normalize a template literal URL and reject junk patterns.
|
||
* Returns true if norm contains a valid API path. */
|
||
static bool normalize_url_arg(const char *url, char *norm, int norm_sz) {
|
||
int ni = 0;
|
||
const char *p = url;
|
||
if (*p == '`' || *p == '"' || *p == '\'') {
|
||
p++;
|
||
}
|
||
if (*p != '/') {
|
||
return false;
|
||
}
|
||
while (*p && ni < norm_sz - PAIR_LEN) {
|
||
if (*p == '$' && *(p + SKIP_ONE) == '{') {
|
||
norm[ni++] = ':';
|
||
p += PAIR_LEN;
|
||
while (*p && *p != '}' && ni < norm_sz - PAIR_LEN) {
|
||
norm[ni++] = *p++;
|
||
}
|
||
if (*p == '}') {
|
||
p++;
|
||
}
|
||
} else if (*p == '`' || *p == '"' || *p == '\'' || *p == '?') {
|
||
break;
|
||
} else {
|
||
norm[ni++] = *p++;
|
||
}
|
||
}
|
||
norm[ni] = '\0';
|
||
enum { MIN_URL_LEN = 4 };
|
||
if (ni < MIN_URL_LEN || !strchr(norm + SKIP_ONE, '/')) {
|
||
return false;
|
||
}
|
||
return !is_junk_url(norm);
|
||
}
|
||
|
||
static bool arg_is_topic(const CBMCallArg *arg) {
|
||
static const char *keywords[] = {"topic", "topic_id", "topic_name", "queue", "queue_name",
|
||
"queue_id", "subject", "channel", NULL};
|
||
for (int i = 0; arg->keyword && keywords[i]; i++) {
|
||
if (strcmp(arg->keyword, keywords[i]) == 0) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* File-language scope of the client-navigation heuristic (#1250): the JS/TS
|
||
* family and the hosts that embed it. Kept explicit so changes to the
|
||
* weak-member guard cannot broaden it. */
|
||
static bool pp_client_navigation_lang(CBMLanguage lang) {
|
||
return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
|
||
lang == CBM_LANG_ARKTS || lang == CBM_LANG_HTML || lang == CBM_LANG_VUE ||
|
||
lang == CBM_LANG_SVELTE || lang == CBM_LANG_ASTRO;
|
||
}
|
||
|
||
/* Detect API paths in call arguments and create HTTP_CALLS edges. */
|
||
static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call,
|
||
cbm_svc_kind_t svc, bool js_family) {
|
||
/* Only the generic argument heuristic is suppressed: explicit HTTP service
|
||
* classification above this call still emits its edge. */
|
||
if (cbm_service_pattern_is_client_navigation(js_family, call->callee_name)) {
|
||
return;
|
||
}
|
||
/* A resolved alias may be ASYNC even when its source spelling is unknown.
|
||
* Neither raw slash topics nor URL projections are HTTP evidence there. */
|
||
if (svc == CBM_SVC_ASYNC || cbm_service_pattern_match(call->callee_name) == CBM_SVC_ASYNC) {
|
||
return;
|
||
}
|
||
for (int ai = 0; ai < call->arg_count; ai++) {
|
||
const CBMCallArg *ca = &call->args[ai];
|
||
if (arg_is_topic(ca)) {
|
||
continue;
|
||
}
|
||
/* A slash-prefixed raw expression is not a URL string. In JS/TS this
|
||
* is notably a regex literal (`/<table/i`); genuine string literals
|
||
* and propagated constants are carried in `value`, while template
|
||
* literals keep their leading backtick in `expr`. */
|
||
if (!ca->value && !ca->url_value && ca->expr && ca->expr[0] == '/') {
|
||
continue;
|
||
}
|
||
const char *url = ca->url_value ? ca->url_value : (ca->value ? ca->value : ca->expr);
|
||
if (!url || (url[0] != '/' && url[0] != '`')) {
|
||
continue;
|
||
}
|
||
char norm[CBM_SZ_256];
|
||
if (!normalize_url_arg(url, norm, (int)sizeof(norm))) {
|
||
continue;
|
||
}
|
||
if (!cbm_service_pattern_is_http_route_literal(norm, call->callee_name)) {
|
||
continue;
|
||
}
|
||
char route_qn[CBM_ROUTE_QN_SIZE];
|
||
char cpath[CBM_SZ_256];
|
||
snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s",
|
||
cbm_route_canon_path(norm, cpath, sizeof(cpath)));
|
||
int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0,
|
||
"{\"source\":\"arg_url\"}");
|
||
char esc_c[CBM_SZ_256];
|
||
char esc_n[CBM_SZ_256];
|
||
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
|
||
cbm_json_escape(esc_n, sizeof(esc_n), norm);
|
||
char eprops[CBM_SZ_512];
|
||
snprintf(eprops, sizeof(eprops),
|
||
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n);
|
||
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "HTTP_CALLS", eprops);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* Extract gRPC service and method from a callee name.
|
||
* Handles patterns like: pb.NewFooServiceClient(conn).GetBar → Foo/GetBar
|
||
* Also: FooServiceGrpc.newBlockingStub(ch).getBar → FooService/getBar */
|
||
bool extract_grpc_service_method(const char *callee, char *service, size_t srv_sz, char *method,
|
||
size_t meth_sz) {
|
||
service[0] = '\0';
|
||
method[0] = '\0';
|
||
if (!callee) {
|
||
return false;
|
||
}
|
||
/* Find last dot to split service.Method */
|
||
const char *last_dot = strrchr(callee, '.');
|
||
if (!last_dot || !last_dot[SKIP_ONE]) {
|
||
return false;
|
||
}
|
||
snprintf(method, meth_sz, "%s", last_dot + SKIP_ONE);
|
||
|
||
/* Extract service name: everything before the last dot, stripped of prefixes/suffixes */
|
||
size_t prefix_len = (size_t)(last_dot - callee);
|
||
char raw[CBM_SZ_256];
|
||
if (prefix_len >= sizeof(raw)) {
|
||
prefix_len = sizeof(raw) - SKIP_ONE;
|
||
}
|
||
memcpy(raw, callee, prefix_len);
|
||
raw[prefix_len] = '\0';
|
||
|
||
/* Strip common prefixes: pb.New, New, pb. */
|
||
const char *s = raw;
|
||
if (strncmp(s, "pb.New", CBM_SZ_6) == 0) {
|
||
s += CBM_SZ_6;
|
||
} else if (strncmp(s, "pb.", CBM_SZ_3) == 0 || strncmp(s, "New", CBM_SZ_3) == 0) {
|
||
s += CBM_SZ_3;
|
||
}
|
||
|
||
/* Strip the generated-stub/client suffix, preserving the canonical
|
||
* proto-declared service name. The proto service is `<X>Service`; the
|
||
* generated client is `<X>ServiceClient` / `<X>ServiceGrpc`, so we strip
|
||
* only the trailing stub/client token (Client/Stub/Grpc/…), NOT "Service"
|
||
* itself — stripping "ServiceClient" yielded `<X>` and broke cross-repo
|
||
* matching against the `<X>Service` declared name (#294). Longest tokens
|
||
* first so e.g. BlockingStub wins over Stub.
|
||
*
|
||
* A match also serves as the gRPC stub-type signal: we ONLY emit a Route
|
||
* when a recognized suffix is actually present. Without this gate the
|
||
* fallback turned ordinary receiver vars (`_provider.GetGroup`,
|
||
* `_builder.AddSomeService`) into phantom `__grpc__provider/...` Routes
|
||
* that correspond to no .proto anywhere (#294). */
|
||
snprintf(service, srv_sz, "%s", s);
|
||
size_t slen = strlen(service);
|
||
static const char *const suffixes[] = {"BlockingStub", "FutureStub", "AsyncStub",
|
||
"AsyncClient", "Servicer", "Client",
|
||
"Stub", "Grpc", NULL};
|
||
bool stripped = false;
|
||
for (const char *const *sfx = suffixes; *sfx; sfx++) {
|
||
size_t flen = strlen(*sfx);
|
||
if (slen > flen && strcmp(service + slen - flen, *sfx) == 0) {
|
||
service[slen - flen] = '\0';
|
||
stripped = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return stripped && service[0] && method[0];
|
||
}
|
||
|
||
/* Emit GRPC_CALLS edge via gRPC Route node. */
|
||
static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call,
|
||
const cbm_resolution_t *res) {
|
||
char service[CBM_SZ_256];
|
||
char method[CBM_SZ_256];
|
||
/* Try callee_name first (e.g., "pb.NewCartServiceClient.GetCart") */
|
||
if (!extract_grpc_service_method(call->callee_name, service, sizeof(service), method,
|
||
sizeof(method))) {
|
||
/* Fallback: try the resolved QN for Go chained calls.
|
||
* Go pattern: pb.NewCartServiceClient(conn).GetCart(ctx, req)
|
||
* callee_name = "GetCart", QN = "...CartServiceClient.GetCart"
|
||
* The QN contains the full ServiceClient.Method pattern. */
|
||
if (!res->qualified_name ||
|
||
!extract_grpc_service_method(res->qualified_name, service, sizeof(service), method,
|
||
sizeof(method))) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
char route_qn[CBM_SZ_512];
|
||
snprintf(route_qn, sizeof(route_qn), "__grpc__%s/%s", service, method);
|
||
|
||
char route_name[CBM_SZ_256];
|
||
snprintf(route_name, sizeof(route_name), "%s/%s", service, method);
|
||
|
||
int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", route_name, route_qn, "", 0, 0,
|
||
"{\"source\":\"grpc\"}");
|
||
|
||
char esc_c[CBM_SZ_256];
|
||
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
|
||
char props[CBM_SZ_1K];
|
||
snprintf(props, sizeof(props),
|
||
"{\"callee\":\"%s\",\"service\":\"%s\",\"method\":\"%s\",\"confidence\":%.2f}", esc_c,
|
||
service, method, res->confidence);
|
||
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "GRPC_CALLS", props);
|
||
}
|
||
|
||
/* Emit GRAPHQL_CALLS edge. The Route is keyed by the operation NAME only
|
||
* (#598), never by the operation text:
|
||
* named operation -> __graphql__<Name> name "<Name>"
|
||
* anonymous query/mutation/ -> __graphql__<type>__anonymous name "(anonymous <type>)"
|
||
* subscription (incl. the `{ ... }` query shorthand)
|
||
* no GraphQL document -> __graphql__operation__anonymous name "(anonymous operation)"
|
||
* (URL, variable, missing string argument)
|
||
* Keys are bounded by the GraphQL Name grammar and deterministic. */
|
||
static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call,
|
||
const cbm_resolution_t *res) {
|
||
const char *op_type = "operation";
|
||
char op_name[CBM_SZ_128];
|
||
bool named = cbm_service_pattern_graphql_operation(call->first_string_arg, &op_type, op_name,
|
||
sizeof(op_name));
|
||
|
||
char route_name[CBM_SZ_256];
|
||
char route_qn[CBM_SZ_256];
|
||
if (named) {
|
||
snprintf(route_name, sizeof(route_name), "%s", op_name);
|
||
snprintf(route_qn, sizeof(route_qn), "__graphql__%s", op_name);
|
||
} else {
|
||
snprintf(route_name, sizeof(route_name), "(anonymous %s)", op_type);
|
||
snprintf(route_qn, sizeof(route_qn), "__graphql__%s__anonymous", op_type);
|
||
}
|
||
|
||
int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", route_name, route_qn, "", 0, 0,
|
||
"{\"source\":\"graphql\"}");
|
||
|
||
char esc_c[CBM_SZ_256];
|
||
char esc_op[CBM_SZ_512];
|
||
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
|
||
cbm_json_escape(esc_op, sizeof(esc_op), route_name);
|
||
char props[CBM_SZ_1K];
|
||
snprintf(props, sizeof(props),
|
||
"{\"callee\":\"%s\",\"operation\":\"%s\",\"operation_type\":\"%s\","
|
||
"\"confidence\":%.2f}",
|
||
esc_c, esc_op, op_type, res->confidence);
|
||
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "GRAPHQL_CALLS", props);
|
||
}
|
||
|
||
/* Emit TRPC_CALLS edge. Extract procedure path from callee chain. */
|
||
static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call,
|
||
const cbm_resolution_t *res) {
|
||
/* tRPC calls: trpc.user.getById.query() → extract "user.getById" */
|
||
const char *callee = call->callee_name;
|
||
if (!callee) {
|
||
return;
|
||
}
|
||
/* Strip trailing .query/.mutate/.subscribe */
|
||
char proc[CBM_SZ_256];
|
||
snprintf(proc, sizeof(proc), "%s", callee);
|
||
char *last_dot = strrchr(proc, '.');
|
||
if (last_dot && (strcmp(last_dot, ".query") == 0 || strcmp(last_dot, ".mutate") == 0 ||
|
||
strcmp(last_dot, ".subscribe") == 0 || strcmp(last_dot, ".useQuery") == 0 ||
|
||
strcmp(last_dot, ".useMutation") == 0)) {
|
||
*last_dot = '\0';
|
||
}
|
||
/* Strip leading trpc. */
|
||
const char *p = proc;
|
||
if (strncmp(p, "trpc.", CBM_SZ_5) == 0) {
|
||
p += CBM_SZ_5;
|
||
}
|
||
|
||
char route_qn[CBM_SZ_512];
|
||
snprintf(route_qn, sizeof(route_qn), "__trpc__%s", p);
|
||
|
||
int64_t route_id =
|
||
cbm_gbuf_upsert_node(gbuf, "Route", p, route_qn, "", 0, 0, "{\"source\":\"trpc\"}");
|
||
|
||
char esc_c[CBM_SZ_256];
|
||
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
|
||
char props[CBM_SZ_1K];
|
||
snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"procedure\":\"%s\",\"confidence\":%.2f}",
|
||
esc_c, p, res->confidence);
|
||
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "TRPC_CALLS", props);
|
||
}
|
||
|
||
/* When suppress_plain_calls is true (a TS/JS/TSX weak short-name member-call
|
||
* match, #592/#606), every service classification below still runs — only the
|
||
* plain CALLS fall-through (emit_normal_calls_edge) is skipped. detect_url_in_args
|
||
* and the HTTP/ASYNC/gRPC/GraphQL/tRPC/CONFIG/route branches are unaffected, so
|
||
* a verb-suffix HTTP client (api.patch('/x')), broker, or route registration
|
||
* keeps its edge; only the fabricated project CALLS edge is dropped. */
|
||
static bool emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
|
||
const cbm_gbuf_node_t *target, const CBMCall *call,
|
||
const cbm_resolution_t *res, const char *module_qn,
|
||
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
|
||
const char **imp_keys, const char **imp_vals, int imp_count,
|
||
bool suppress_plain_calls, const char *route_mount,
|
||
CBMLanguage lang) {
|
||
cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name);
|
||
const char *arg = call->first_string_arg;
|
||
|
||
/* Also detect route registration by callee name suffix alone (handles unresolved
|
||
* local variables like app.include_router where QN resolution fails). */
|
||
if (svc == CBM_SVC_NONE && cbm_service_pattern_route_method(call->callee_name) != NULL) {
|
||
svc = CBM_SVC_ROUTE_REG;
|
||
}
|
||
|
||
/* Detect gRPC stub method calls by resolved QN.
|
||
* Go pattern: pb.NewCartServiceClient(conn).GetCart(ctx, req)
|
||
* Tree-sitter extracts GetCart as the callee, which resolves to the
|
||
* generated pb interface method (QN contains "ServiceClient"). */
|
||
if (svc == CBM_SVC_NONE && res->qualified_name) {
|
||
if (strstr(res->qualified_name, "ServiceClient") != NULL ||
|
||
strstr(res->qualified_name, "ServiceGrpc") != NULL ||
|
||
strstr(res->qualified_name, "Servicer") != NULL) {
|
||
svc = CBM_SVC_GRPC;
|
||
}
|
||
}
|
||
|
||
if (svc == CBM_SVC_ROUTE_REG) {
|
||
const char *handler_ref = NULL;
|
||
const char *route_path = find_route_path_in_args(call, &handler_ref);
|
||
if (route_path) {
|
||
return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn,
|
||
registry, main_gbuf, imp_keys, imp_vals, imp_count,
|
||
route_mount, lang);
|
||
}
|
||
/* No path found — fall through to normal CALLS edge */
|
||
}
|
||
|
||
bool has_url = (arg && arg[0] != '\0' && (arg[0] == '/' || strstr(arg, "://") != NULL));
|
||
bool has_topic = (arg && arg[0] != '\0' && svc == CBM_SVC_ASYNC && strlen(arg) > PP_ESC_SPACE);
|
||
/* Set when this call already has its HTTP_CALLS edge from the service
|
||
* patterns (typed with the verb its callee names). */
|
||
bool http_edge_emitted = false;
|
||
bool calls_emitted = false;
|
||
|
||
if ((svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) && (has_url || has_topic)) {
|
||
emit_http_async_service_edge(gbuf, source, call, res, svc, arg);
|
||
http_edge_emitted = svc == CBM_SVC_HTTP;
|
||
} else if (svc == CBM_SVC_GRPC) {
|
||
emit_grpc_edge(gbuf, source, call, res);
|
||
} else if (svc == CBM_SVC_GRAPHQL) {
|
||
emit_graphql_edge(gbuf, source, call, res);
|
||
} else if (svc == CBM_SVC_TRPC) {
|
||
emit_trpc_edge(gbuf, source, call, res);
|
||
} else if (svc == CBM_SVC_CONFIG) {
|
||
emit_config_edge(gbuf, source, target, call, res, arg);
|
||
} else if (!suppress_plain_calls) {
|
||
calls_emitted = emit_normal_calls_edge(gbuf, source, target, call, res);
|
||
}
|
||
|
||
/* The arg-URL heuristic is for calls no service pattern knows (a local
|
||
* fetch wrapper). On a classified HTTP call it only minted a method-less
|
||
* __route__ANY__ twin of the typed edge, which cross-repo matching then
|
||
* bound to server handlers of any method. */
|
||
if (!http_edge_emitted) {
|
||
detect_url_in_args(gbuf, source, call, svc, pp_client_navigation_lang(lang));
|
||
}
|
||
return calls_emitted;
|
||
}
|
||
|
||
/* The #725 guard refuses a suffix_match binding across a language boundary,
|
||
* which leaves the callee unresolved. An unresolved callee the route
|
||
* classifier recognises is still a registration: `Route::get('/x', ...)`
|
||
* beside a JS `get`, `app.get('/x', h)` beside a Python `get`. Emit exactly
|
||
* the Route + CALLS + HANDLES that the unresolved-callee (callee_suffix)
|
||
* fallback in resolve_file_calls emits for it, classified by callee name the
|
||
* same way emit_service_edge classifies that fallback, and nothing else: no
|
||
* CALLS edge to the refused target and no URL-argument scan. Dropping the
|
||
* whole call lost every GET registration in a mixed-language repo while POST
|
||
* (no `post` to collide with) survived. Mirrors pass_calls.c. */
|
||
static bool emit_xlang_refused_route(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
|
||
const CBMCall *call, const char *module_qn,
|
||
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
|
||
const char **imp_keys, const char **imp_vals, int imp_count,
|
||
const char *route_mount, CBMLanguage lang) {
|
||
if (cbm_service_pattern_route_method(call->callee_name) == NULL) {
|
||
return false;
|
||
}
|
||
cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name);
|
||
if (svc != CBM_SVC_NONE && svc != CBM_SVC_ROUTE_REG) {
|
||
return false;
|
||
}
|
||
const char *handler_ref = NULL;
|
||
const char *route_path = find_route_path_in_args(call, &handler_ref);
|
||
if (route_path) {
|
||
return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn,
|
||
registry, main_gbuf, imp_keys, imp_vals, imp_count,
|
||
route_mount, lang);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* Find the source node for an edge: enclosing function or file node. */
|
||
/* This worker's last file-node answer. The graph buffer is read-only for the
|
||
* whole of phase 4 and a file's rel_path pointer is stable within it, so the
|
||
* pair identifies the answer; resolve_worker clears it at both ends so a later
|
||
* phase can never match a recycled address. Computing the file QN and looking
|
||
* it up ran for every call, usage, throw and read/write whose enclosing
|
||
* function is not a graph node of its own. */
|
||
static CBM_TLS const cbm_gbuf_t *tl_file_node_gbuf;
|
||
static CBM_TLS const char *tl_file_node_rel;
|
||
static CBM_TLS const cbm_gbuf_node_t *tl_file_node;
|
||
|
||
static void file_node_cache_clear(void) {
|
||
tl_file_node_gbuf = NULL;
|
||
tl_file_node_rel = NULL;
|
||
tl_file_node = NULL;
|
||
}
|
||
|
||
const cbm_gbuf_node_t *cbm_pipeline_file_node(const cbm_gbuf_t *gbuf, const char *project,
|
||
const char *rel) {
|
||
char *file_qn = cbm_pipeline_fqn_compute(project, rel, "__file__");
|
||
const cbm_gbuf_node_t *node = cbm_gbuf_find_by_qn(gbuf, file_qn);
|
||
free(file_qn);
|
||
return node;
|
||
}
|
||
|
||
static const cbm_gbuf_node_t *file_node_for(const cbm_gbuf_t *gbuf, const char *project,
|
||
const char *rel) {
|
||
if (tl_file_node_gbuf == gbuf && tl_file_node_rel == rel) {
|
||
return tl_file_node;
|
||
}
|
||
const cbm_gbuf_node_t *node = cbm_pipeline_file_node(gbuf, project, rel);
|
||
tl_file_node_gbuf = gbuf;
|
||
tl_file_node_rel = rel;
|
||
tl_file_node = node;
|
||
return node;
|
||
}
|
||
|
||
static const cbm_gbuf_node_t *find_source_node(const cbm_gbuf_t *gbuf, const char *project,
|
||
const char *rel, const char *enclosing_qn) {
|
||
const cbm_gbuf_node_t *src = NULL;
|
||
if (enclosing_qn) {
|
||
src = cbm_gbuf_find_by_qn(gbuf, enclosing_qn);
|
||
/* A class-level reference in a directory-module language carries the
|
||
* DIRECTORY module QN, which hits the shared Folder/Project node —
|
||
* attribute to this file's File node instead (#787). */
|
||
if (cbm_pipeline_node_is_dir_container(src)) {
|
||
src = NULL;
|
||
}
|
||
}
|
||
if (!src) {
|
||
src = file_node_for(gbuf, project, rel);
|
||
}
|
||
return src;
|
||
}
|
||
|
||
/* Field type hint resolution for obj.Method() with multiple candidates.
|
||
* Strips C# field prefixes (_ / m_), capitalizes to get type name, and
|
||
* checks if TypeName.Method or ITypeName.Method exists among candidates. */
|
||
static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const char *callee_name,
|
||
int64_t source_id) {
|
||
if (!res->qualified_name || res->candidate_count <= SKIP_ONE) {
|
||
return;
|
||
}
|
||
const char *dot = strchr(callee_name, '.');
|
||
if (!dot) {
|
||
return;
|
||
}
|
||
size_t plen = (size_t)(dot - callee_name);
|
||
char obj_name[CBM_SZ_256];
|
||
if (plen >= sizeof(obj_name)) {
|
||
return;
|
||
}
|
||
memcpy(obj_name, callee_name, plen);
|
||
obj_name[plen] = '\0';
|
||
|
||
const char *type_hint = obj_name;
|
||
if (type_hint[0] == '_') {
|
||
type_hint++;
|
||
}
|
||
if (type_hint[0] == 'm' && type_hint[SKIP_ONE] == '_') {
|
||
type_hint += PP_CSHARP_M_PREFIX_LEN;
|
||
}
|
||
|
||
char type_name[CBM_SZ_256];
|
||
snprintf(type_name, sizeof(type_name), "%s", type_hint);
|
||
if (type_name[0] >= 'a' && type_name[0] <= 'z') {
|
||
type_name[0] -= ('a' - 'A');
|
||
}
|
||
|
||
char iface_name[CBM_SZ_256];
|
||
snprintf(iface_name, sizeof(iface_name), "I%s", type_name);
|
||
|
||
const char *method = dot + SKIP_ONE;
|
||
const char **cands = NULL;
|
||
int cand_count = 0;
|
||
cbm_registry_find_by_name(rc->registry, method, &cands, &cand_count);
|
||
for (int ci = 0; ci < cand_count; ci++) {
|
||
if (strstr(cands[ci], type_name) || strstr(cands[ci], iface_name)) {
|
||
const cbm_gbuf_node_t *better = cbm_gbuf_find_by_qn(rc->main_gbuf, cands[ci]);
|
||
if (better && better->id != source_id) {
|
||
res->qualified_name = cands[ci];
|
||
res->confidence = PP_FIELD_HINT_CONF;
|
||
res->strategy = "field_type_hint";
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Free a strdup'd key stored in the per-file lsp_idx hash table. */
|
||
static void lsp_idx_free_key(const char *key, void *value, void *ud) {
|
||
(void)value;
|
||
(void)ud;
|
||
free((char *)key);
|
||
}
|
||
|
||
/* A hash key may have multiple semantic rows. Keep a permanent tombstone for
|
||
* distinct targets so O(1) lookup cannot turn ambiguity into a confidence
|
||
* contest; duplicate rows for the same target may still keep the best score. */
|
||
static CBMResolvedCall *const LSP_IDX_AMBIGUOUS = (CBMResolvedCall *)(uintptr_t)1;
|
||
|
||
static bool lsp_idx_keep_best(CBMHashTable *index, const char *key, CBMResolvedCall *candidate,
|
||
const cbm_gbuf_t *gbuf, const char *project_name,
|
||
bool allow_tail_match) {
|
||
if (!index || !key || !candidate) {
|
||
return false;
|
||
}
|
||
CBMResolvedCall *existing = (CBMResolvedCall *)cbm_ht_get(index, key);
|
||
if (!existing) {
|
||
char *owned_key = strdup(key);
|
||
if (owned_key) {
|
||
cbm_ht_set(index, owned_key, candidate);
|
||
if (cbm_ht_get(index, key) == candidate) {
|
||
return true;
|
||
}
|
||
free(owned_key);
|
||
}
|
||
return false;
|
||
}
|
||
if (existing == LSP_IDX_AMBIGUOUS) {
|
||
return true;
|
||
}
|
||
if (!cbm_pipeline_invocation_targets_equal(existing->callee_qn, candidate->callee_qn, gbuf,
|
||
project_name, allow_tail_match)) {
|
||
const char *stored_key = cbm_ht_get_key(index, key);
|
||
if (stored_key) {
|
||
cbm_ht_set(index, stored_key, LSP_IDX_AMBIGUOUS);
|
||
}
|
||
return cbm_ht_get(index, key) != NULL;
|
||
}
|
||
if (candidate->confidence > existing->confidence) {
|
||
const char *stored_key = cbm_ht_get_key(index, key);
|
||
if (stored_key) {
|
||
cbm_ht_set(index, stored_key, candidate);
|
||
}
|
||
}
|
||
return cbm_ht_get(index, key) != NULL;
|
||
}
|
||
|
||
/* Caller QNs are user-controlled graph identifiers and can legitimately exceed
|
||
* the old 1 KiB stack buffer. Build occurrence keys at their exact length so an
|
||
* exact semantic row can never disappear merely because its caller is long. */
|
||
static char *lsp_idx_key(const char *caller_qn, const char *leaf, bool exact_site,
|
||
uint32_t site_start_byte, uint32_t site_end_byte,
|
||
CBMSourceOrigin source_origin) {
|
||
if (!caller_qn || !leaf) {
|
||
return NULL;
|
||
}
|
||
size_t caller_len = strlen(caller_qn);
|
||
size_t leaf_len = strlen(leaf);
|
||
size_t suffix_len = exact_site ? 48U : 16U;
|
||
if (caller_len > SIZE_MAX - leaf_len - suffix_len - 2U) {
|
||
return NULL;
|
||
}
|
||
size_t cap = caller_len + 1U + leaf_len + suffix_len + 1U;
|
||
char *key = (char *)malloc(cap);
|
||
if (!key) {
|
||
return NULL;
|
||
}
|
||
int written = exact_site
|
||
? snprintf(key, cap, "%s|%s|%u:%u|%u", caller_qn, leaf, site_start_byte,
|
||
site_end_byte, (unsigned)source_origin)
|
||
: snprintf(key, cap, "%s|%s|%u", caller_qn, leaf, (unsigned)source_origin);
|
||
if (written <= 0 || (size_t)written >= cap) {
|
||
free(key);
|
||
return NULL;
|
||
}
|
||
return key;
|
||
}
|
||
|
||
static bool lsp_idx_insert_leaf(CBMHashTable *index, CBMResolvedCall *candidate, const char *leaf,
|
||
bool exact_site, const cbm_gbuf_t *gbuf, const char *project_name,
|
||
bool allow_tail_match) {
|
||
if (!index || !candidate || !candidate->caller_qn || !leaf || !leaf[0]) {
|
||
return false;
|
||
}
|
||
char *key = lsp_idx_key(candidate->caller_qn, leaf, exact_site, candidate->site_start_byte,
|
||
candidate->site_end_byte, candidate->source_origin);
|
||
if (!key) {
|
||
return false;
|
||
}
|
||
bool inserted = lsp_idx_keep_best(index, key, candidate, gbuf, project_name, allow_tail_match);
|
||
free(key);
|
||
return inserted;
|
||
}
|
||
|
||
static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, const CBMCall *call,
|
||
bool exact_site, bool *key_built, bool *ambiguous) {
|
||
if (key_built) {
|
||
*key_built = false;
|
||
}
|
||
if (ambiguous) {
|
||
*ambiguous = false;
|
||
}
|
||
if (!index || !call || !call->enclosing_func_qn || !call->callee_name) {
|
||
return NULL;
|
||
}
|
||
const char *leaf = cbm_pipeline_call_callee_leaf(call->callee_name);
|
||
if (!leaf || !leaf[0]) {
|
||
return NULL;
|
||
}
|
||
char *key = lsp_idx_key(call->enclosing_func_qn, leaf, exact_site, call->site_start_byte,
|
||
call->site_end_byte, call->source_origin);
|
||
if (!key) {
|
||
return NULL;
|
||
}
|
||
if (key_built) {
|
||
*key_built = true;
|
||
}
|
||
const CBMResolvedCall *candidate = (const CBMResolvedCall *)cbm_ht_get(index, key);
|
||
free(key);
|
||
if (candidate == LSP_IDX_AMBIGUOUS) {
|
||
if (ambiguous) {
|
||
*ambiguous = true;
|
||
}
|
||
return NULL;
|
||
}
|
||
if (!candidate || candidate->kind != CBM_RESOLVED_INVOCATION || !candidate->caller_qn ||
|
||
strcmp(candidate->caller_qn, call->enclosing_func_qn) != 0 ||
|
||
candidate->confidence < CBM_LSP_CONFIDENCE_FLOOR) {
|
||
return NULL;
|
||
}
|
||
int site_rank = cbm_pipeline_invocation_site_rank(candidate, call);
|
||
int expected_rank = exact_site ? 2 : 1;
|
||
return site_rank == expected_rank &&
|
||
cbm_pipeline_invocation_leaf_matches(candidate, call, site_rank)
|
||
? candidate
|
||
: NULL;
|
||
}
|
||
|
||
static CBMCallEvidence *resolve_evidence_at(CBMCallEvidence *evidence, int index,
|
||
CBMCallEvidence *fallback) {
|
||
if (evidence) {
|
||
return &evidence[index];
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
static CBMCallEvidence *resolve_evidence_new(const resolve_ctx_t *rc, const CBMFileResult *result) {
|
||
if (result->calls.count == 0) {
|
||
return NULL;
|
||
}
|
||
CBMCallEvidence *evidence =
|
||
cbm_calloc(CBM_MEM_CLASS_RESOLVE, (size_t)result->calls.count * sizeof(*evidence));
|
||
if (!evidence) {
|
||
cbm_pipeline_mark_unresolved_capture_failed(rc->pctx ? rc->pctx->pipeline : NULL);
|
||
}
|
||
return evidence;
|
||
}
|
||
|
||
/* Resolve calls for one file and emit CALLS/HTTP_CALLS/ASYNC_CALLS edges. */
|
||
static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result,
|
||
const char *rel, const char *module_qn, const char **imp_keys,
|
||
const char **imp_vals, int imp_count, CBMLanguage lang,
|
||
const cbm_php_vendor_names_t *php_vendor,
|
||
CBMCallEvidence *evidence) {
|
||
/* Framework mount of this file's routes (Laravel 11+ withRouting, #1146). */
|
||
char route_mount[CBM_SZ_128];
|
||
cbm_laravel_file_route_mount(rc->repo_path, rel, lang, result, route_mount,
|
||
sizeof(route_mount));
|
||
/* Two occurrence-aware indexes preserve the authoritative matcher's
|
||
* primary ordering without restoring its O(calls × resolutions) scan:
|
||
* exact caller+leaf+span first, then the legacy caller+leaf fallback.
|
||
* Repeated same-leaf calls therefore remain O(1), and a high-confidence
|
||
* 0:0 record can never hide an exact source occurrence. */
|
||
CBMHashTable *lsp_exact_idx = NULL;
|
||
CBMHashTable *lsp_legacy_idx = NULL;
|
||
bool lsp_exact_idx_complete = true;
|
||
bool lsp_legacy_idx_complete = true;
|
||
bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang);
|
||
/* Names this file's imports bind onto external libraries, built on the
|
||
* first unresolved call that asks (see pass_calls.c). */
|
||
cbm_external_import_index_t ext_imports = {0};
|
||
if (result->calls.count > 0 && result->resolved_calls.count > 0) {
|
||
uint32_t capacity = (uint32_t)result->resolved_calls.count * 2u + 16u;
|
||
lsp_exact_idx = cbm_ht_create(capacity);
|
||
lsp_legacy_idx = cbm_ht_create(capacity);
|
||
if (!lsp_exact_idx) {
|
||
lsp_exact_idx_complete = false;
|
||
}
|
||
if (!lsp_legacy_idx) {
|
||
lsp_legacy_idx_complete = false;
|
||
}
|
||
if (lsp_exact_idx || lsp_legacy_idx) {
|
||
for (int i = 0; i < result->resolved_calls.count; i++) {
|
||
CBMResolvedCall *rc_e = &result->resolved_calls.items[i];
|
||
if (rc_e->kind != CBM_RESOLVED_INVOCATION || !rc_e->caller_qn || !rc_e->callee_qn ||
|
||
rc_e->confidence < CBM_LSP_CONFIDENCE_FLOOR) {
|
||
continue;
|
||
}
|
||
bool exact_site =
|
||
cbm_pipeline_source_site_present(rc_e->site_start_byte, rc_e->site_end_byte);
|
||
bool legacy_site =
|
||
cbm_pipeline_source_site_legacy(rc_e->site_start_byte, rc_e->site_end_byte);
|
||
if (!exact_site && !legacy_site) {
|
||
continue;
|
||
}
|
||
CBMHashTable *index = exact_site ? lsp_exact_idx : lsp_legacy_idx;
|
||
bool inserted =
|
||
lsp_idx_insert_leaf(index, rc_e, cbm_lsp_bare_segment(rc_e->callee_qn),
|
||
exact_site, rc->main_gbuf, rc->project_name, allow_tail);
|
||
if (!inserted) {
|
||
if (exact_site) {
|
||
lsp_exact_idx_complete = false;
|
||
} else {
|
||
lsp_legacy_idx_complete = false;
|
||
}
|
||
}
|
||
if (rc_e->reason && cbm_pipeline_invocation_reason_join_strategy(rc_e->strategy)) {
|
||
inserted = lsp_idx_insert_leaf(index, rc_e, cbm_lsp_bare_segment(rc_e->reason),
|
||
exact_site, rc->main_gbuf, rc->project_name,
|
||
allow_tail);
|
||
if (!inserted) {
|
||
if (exact_site) {
|
||
lsp_exact_idx_complete = false;
|
||
} else {
|
||
lsp_legacy_idx_complete = false;
|
||
}
|
||
}
|
||
}
|
||
if (exact_site && rc_e->strategy && strcmp(rc_e->strategy, "lsp_destructor") == 0 &&
|
||
!lsp_idx_insert_leaf(index, rc_e, "~", true, rc->main_gbuf, rc->project_name,
|
||
allow_tail)) {
|
||
lsp_exact_idx_complete = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Field-owner rows, indexed on the first C member call that needs them. */
|
||
cbm_pipeline_lsp_field_index_t field_index = {0};
|
||
bool field_index_built = false;
|
||
bool field_index_ready = false;
|
||
for (int c = 0; c < result->calls.count; c++) {
|
||
const CBMCall *call = &result->calls.items[c];
|
||
CBMCallEvidence fallback = {0};
|
||
CBMCallEvidence *site_evidence = resolve_evidence_at(evidence, c, &fallback);
|
||
if (!call->callee_name) {
|
||
continue;
|
||
}
|
||
uint64_t _rc_t0 = extract_now_ns();
|
||
const cbm_gbuf_node_t *source_node =
|
||
find_source_node(rc->main_gbuf, rc->project_name, rel, call->enclosing_func_qn);
|
||
atomic_fetch_add_explicit(&rc->time_ns_rc_source, extract_now_ns() - _rc_t0,
|
||
memory_order_relaxed);
|
||
if (!source_node) {
|
||
continue;
|
||
}
|
||
|
||
/* LSP-resolved calls take precedence over registry textual matching.
|
||
* Same helper + same CBM_LSP_CONFIDENCE_FLOOR as the sequential
|
||
* pipeline (pass_calls.c) — both paths must admit the same set of
|
||
* LSP overrides so a project doesn't get different attributions
|
||
* depending on whether parallel mode kicked in. Unique-tail
|
||
* fallbacks are JVM-only (see cbm_pipeline_lsp_allow_tail_match). */
|
||
cbm_resolution_t res = {0};
|
||
const CBMResolvedCall *lsp = NULL;
|
||
bool exact_key_built = true;
|
||
bool exact_key_ambiguous = false;
|
||
bool legacy_key_built = true;
|
||
_rc_t0 = extract_now_ns();
|
||
if (cbm_pipeline_source_site_present(call->site_start_byte, call->site_end_byte)) {
|
||
lsp = lsp_idx_lookup(lsp_exact_idx, call, true, &exact_key_built, &exact_key_ambiguous);
|
||
}
|
||
bool exact_index_authoritative = lsp_exact_idx_complete && exact_key_built;
|
||
if (!lsp && !exact_index_authoritative) {
|
||
/* An incomplete exact index must fail closed: consult the
|
||
* authoritative matcher before a legacy row can win. */
|
||
atomic_fetch_add_explicit(&g_lsp_linear_fallback_rows,
|
||
(uint64_t)result->resolved_calls.count, memory_order_relaxed);
|
||
lsp = cbm_pipeline_find_lsp_resolution_in_graph(
|
||
&result->resolved_calls, call, allow_tail, rc->main_gbuf, rc->project_name);
|
||
}
|
||
if (!lsp && !call->requires_lsp_resolution && exact_index_authoritative &&
|
||
!exact_key_ambiguous) {
|
||
lsp = lsp_idx_lookup(lsp_legacy_idx, call, false, &legacy_key_built, NULL);
|
||
}
|
||
if (!lsp && (!lsp_legacy_idx_complete || !legacy_key_built || allow_tail ||
|
||
call->requires_lsp_resolution)) {
|
||
/* Fallback to the linear scan for edge cases the index may
|
||
* miss (e.g. callee_name that wasn't the registered short
|
||
* name). Keeps semantics identical. */
|
||
atomic_fetch_add_explicit(&g_lsp_linear_fallback_rows,
|
||
(uint64_t)result->resolved_calls.count, memory_order_relaxed);
|
||
lsp = cbm_pipeline_find_lsp_resolution_in_graph(
|
||
&result->resolved_calls, call, allow_tail, rc->main_gbuf, rc->project_name);
|
||
}
|
||
atomic_fetch_add_explicit(&rc->time_ns_rc_lsp_lookup, extract_now_ns() - _rc_t0,
|
||
memory_order_relaxed);
|
||
_rc_t0 = extract_now_ns();
|
||
const cbm_gbuf_node_t *lsp_target = NULL;
|
||
if (lsp) {
|
||
/* Canonicalise to the gbuf node's QN so res.qualified_name matches
|
||
* the gbuf even when the cross-file fallback had to prefix the
|
||
* project name. */
|
||
bool exact_external_target = call->requires_lsp_resolution &&
|
||
cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn);
|
||
lsp_target = exact_external_target
|
||
? cbm_pipeline_lsp_target_node_strict(rc->main_gbuf, rc->project_name,
|
||
lsp->callee_qn, allow_tail)
|
||
: cbm_pipeline_lsp_target_node(rc->main_gbuf, rc->project_name,
|
||
lsp->callee_qn, allow_tail);
|
||
if (lsp_target) {
|
||
res.qualified_name = lsp_target->qualified_name;
|
||
res.strategy = lsp->strategy ? lsp->strategy : "lsp_override";
|
||
res.confidence = (double)lsp->confidence;
|
||
res.candidate_count = 1;
|
||
ws->lsp_overrides++;
|
||
}
|
||
}
|
||
/* #1085: fall back to the registry resolver whenever the LSP did not
|
||
* yield a gbuf-resolvable target — whether no LSP resolution existed,
|
||
* OR the LSP was confident but its callee_qn isn't a node in the gbuf
|
||
* (the JSX-via-tsconfig-alias case: the TS LSP resolves the element
|
||
* ref to an alias-path QN that never matches a def node, so lsp_target
|
||
* is NULL). The old `else` ran the registry ONLY when lsp was null, so
|
||
* an LSP-with-unresolvable-target dropped the edge outright — silently
|
||
* losing every alias-imported JSX component edge on the parallel path
|
||
* (~21% of a Next.js call graph) while the sequential pass, which falls
|
||
* THROUGH to the registry here, kept them. This restores seq/parallel
|
||
* parity via the import_map / unique_name resolution. Synthetic
|
||
* semantic candidates are deliberately excluded: they require an
|
||
* exact LSP target and must fail closed rather than accepting a textual
|
||
* registry match. */
|
||
/* #2053: a Rust call the LSP placed on an EXTERNAL symbol (std's
|
||
* Path::join, a seeded crate API) is resolved — it just has no graph
|
||
* node. The textual registry would bind it to a same-named project
|
||
* method instead, so it must not run for such a row. Mirrors
|
||
* pass_calls.c; the service fallbacks below still see the call. */
|
||
bool php_vendor_call = cbm_pipeline_php_vendor_call(php_vendor, result, call) ||
|
||
(lsp && !lsp_target && cbm_pipeline_lsp_external_receiver(lsp));
|
||
bool rust_external = lsp && cbm_pipeline_rust_external_target(
|
||
lang, lsp->strategy, lsp->callee_qn, rc->project_name);
|
||
if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution &&
|
||
!rust_external && !php_vendor_call) {
|
||
/* Python LEGB fallback first (MUST match pass_calls.c). */
|
||
res = cbm_registry_resolve_call_scoped(rc->registry, call->callee_name, module_qn,
|
||
imp_keys, imp_vals, imp_count, lang,
|
||
call->enclosing_func_qn);
|
||
}
|
||
/* `p->open(fd)`: the registry does not split a callee on the arrow.
|
||
* The object's type decides, as for the dot call handled further
|
||
* down. Mirrors pass_calls.c. */
|
||
bool arrow_bound = false;
|
||
if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution &&
|
||
cbm_c_arrow_member_call(lang, call->callee_name)) {
|
||
if (!field_index_built) {
|
||
field_index_ready =
|
||
cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index);
|
||
field_index_built = true;
|
||
}
|
||
arrow_bound =
|
||
cbm_pipeline_c_member_call_resolve(
|
||
&result->resolved_calls, field_index_ready ? &field_index : NULL, rc->main_gbuf,
|
||
rc->registry, rc->project_name, call->enclosing_func_qn, call->callee_name,
|
||
cbm_c_member_rule_file(lang, rel), &res) != NULL;
|
||
}
|
||
atomic_fetch_add_explicit(&rc->time_ns_rc_resolve, extract_now_ns() - _rc_t0,
|
||
memory_order_relaxed);
|
||
|
||
/* A synthetic semantic candidate is an invocation only when the LSP
|
||
* resolved it to a concrete graph node. Never let registry, field-name,
|
||
* route, or service heuristics manufacture a target for it. */
|
||
if (call->requires_lsp_resolution && !lsp_target) {
|
||
continue;
|
||
}
|
||
|
||
_rc_t0 = extract_now_ns();
|
||
/* Not for Go (F-G9): the hint matches the capitalized receiver
|
||
* VARIABLE name as a substring of a candidate QN (`t.Run` -> any
|
||
* `...T...Run`), with no type evidence. The sequential resolver has no
|
||
* such step, so for Go it produced parallel-only edges (par/seq CALLS
|
||
* diverged), and the Go selector guard could not see them as the weak
|
||
* pick they are. SCIP precision of Go field_type_hint: 0.016. */
|
||
if (lang != CBM_LANG_GO) {
|
||
try_field_type_hint(rc, &res, call->callee_name, source_node->id);
|
||
}
|
||
/* Cross-language veto — the same predicate as pass_calls.c, applied
|
||
* AFTER the field-type hint so the hint may still re-pick a compatible
|
||
* candidate first, and BEFORE the empty-resolution fallbacks below so a
|
||
* vetoed call still reaches route/HTTP classification. */
|
||
if (cbm_registry_name_guess_vetoed(rc->registry, lang, &res)) {
|
||
res = (cbm_resolution_t){0};
|
||
}
|
||
atomic_fetch_add_explicit(&rc->time_ns_rc_hint, extract_now_ns() - _rc_t0,
|
||
memory_order_relaxed);
|
||
|
||
/* Perl call-graph noise guard (#476), mirroring the sequential pass
|
||
* (pass_calls.c). Perl has no LSP resolver; for builtins (push/shift/
|
||
* keys/...) and method calls ($obj->m, unresolved receiver), suppress
|
||
* only WEAK cross-file short-name matches and keep the high-confidence
|
||
* same_module / import_map strategies so a genuine same-file or
|
||
* imported call to a builtin-named sub still resolves. Placed after the
|
||
* field-type hint so a hint cannot re-introduce a suppressed edge.
|
||
* Gated to Perl — other languages are unaffected. */
|
||
if (cbm_perl_suppress_generic_match(lang == CBM_LANG_PERL, call->is_method,
|
||
call->callee_name, res.strategy)) {
|
||
continue;
|
||
}
|
||
|
||
/* Dynamic-language weak-member suppression (#592/#606/#1276). The
|
||
* receiver-aware guard must NOT drop this call here: doing so would also
|
||
* skip the #523 callee-name service bypass below, emit_service_edge's
|
||
* route/gRPC/config branches, and its detect_url_in_args
|
||
* (which classifies verb-suffix HTTP clients like api.patch('/x')).
|
||
* Instead, defer to the emit path and suppress ONLY the plain-CALLS
|
||
* fall-through (emit_normal_calls_edge), so every service edge stays
|
||
* main-identical by construction. res.strategy may carry an lsp_* value
|
||
* here (LSP-resolved calls keep res through this point); the helper's
|
||
* EXPLICIT drop-list leaves lsp_ts_method / lsp_cross untouched. See
|
||
* #606 direction.
|
||
*
|
||
* This language set MUST match the one in pass_calls.c exactly — see the
|
||
* note there. ArkTS belongs to the JS/TS family (#1842). Go (#1906)
|
||
* composes via its own predicate (different drop-list — see
|
||
* cbm_go_suppress_weak_method_match), mirrored in pass_calls.c. */
|
||
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
|
||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
|
||
lang == CBM_LANG_ARKTS ||
|
||
/* embedded-script hosts — see pass_calls.c */
|
||
lang == CBM_LANG_HTML || lang == CBM_LANG_VUE ||
|
||
lang == CBM_LANG_SVELTE || lang == CBM_LANG_ASTRO;
|
||
/* Bare-call local-binding suppression — see the note in pass_calls.c.
|
||
* This gate MUST stay identical to the one there. */
|
||
bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON;
|
||
/* The member guard's one exemption — MUST match pass_calls.c exactly. */
|
||
/* #1355: same guard as pass_calls.c, and joined to drop_plain_call for
|
||
* the same reason — a `continue` here would also skip route/HTTP/CONFIG
|
||
* classification, and a static-import route registration is a bare call
|
||
* bound by a package specifier. Suppress only the plain-CALLS
|
||
* fall-through so every Route node stays main-identical. */
|
||
bool drop_plain_call =
|
||
(cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) &&
|
||
!cbm_weak_member_unique_name_exempt(lang == CBM_LANG_PYTHON,
|
||
call->receiver_is_self_attribute,
|
||
call->callee_name, res.strategy)) ||
|
||
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
|
||
call->callee_is_locally_bound, res.strategy) ||
|
||
cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy,
|
||
res.confidence) ||
|
||
/* Import-binding suppression (#2127) — see pass_calls.c; this gate
|
||
* MUST stay identical to the one there. */
|
||
(lang == CBM_LANG_PYTHON &&
|
||
cbm_suppress_weak_import_bound_call(true, true, res.strategy) &&
|
||
cbm_python_import_binding_contradicts(&result->imports, call->callee_name,
|
||
res.qualified_name, rc->main_gbuf,
|
||
rc->project_name, rel)) ||
|
||
cbm_suppress_external_import_shadow(call->callee_name, res.strategy, &result->imports,
|
||
imp_keys, imp_count, cbm_pipeline_get_pkgmap(),
|
||
cbm_pipeline_get_nsmap()) ||
|
||
/* MUST match pass_calls.c exactly. */
|
||
(lang == CBM_LANG_PHP && cbm_suppress_weak_member_match(true, true, res.strategy) &&
|
||
cbm_pipeline_php_receiver_type_unindexed(&result->resolved_calls, call, rc->registry));
|
||
|
||
/* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the
|
||
* service signal lives in the callee_name. The registry can mis-resolve
|
||
* it to a spurious builtin short-name match (`requests.get` ->
|
||
* `builtins.dict.get` via "get"), which is non-empty and not an HTTP
|
||
* pattern, so the resolved-QN service checks below miss it and the call
|
||
* is dropped. Detect it on the callee_name FIRST so the HTTP_CALLS/
|
||
* ASYNC_CALLS edge is emitted regardless (target is a synthesized route
|
||
* node, not the unindexed library). Mirrors pass_calls.c. (#523)
|
||
*
|
||
* First: a call on an axios.create() instance (#1916) — decided by the
|
||
* receiver's binding, before the #523 spelling check and the
|
||
* route-registration suffix fallback. MUST match pass_calls.c. */
|
||
char client_url[CBM_SZ_512];
|
||
if (cbm_pipeline_http_client_call_url(rc->main_gbuf, rc->project_name, rel, result,
|
||
imp_keys, imp_vals, imp_count, call, client_url,
|
||
sizeof(client_url))) {
|
||
cbm_resolution_t svc_res = {.qualified_name = call->callee_name,
|
||
.confidence = PP_HALF_CONF,
|
||
.strategy = "http_client_instance"};
|
||
emit_http_async_service_edge(ws->local_edge_buf, source_node, call, &svc_res,
|
||
CBM_SVC_HTTP, client_url);
|
||
continue;
|
||
}
|
||
/* A PHP call on a proven vendor class is classified by that class
|
||
* (#1186): it skipped the registry above, so the route-verb fallback
|
||
* would otherwise mint a route registration for
|
||
* `GuzzleClient::get('/x')`. MUST match pass_calls.c. */
|
||
cbm_svc_kind_t csvc;
|
||
const char *svc_name = cbm_pipeline_service_name(call, lsp_target ? NULL : lsp, &csvc);
|
||
if (csvc == CBM_SVC_HTTP || csvc == CBM_SVC_ASYNC) {
|
||
const char *cu = call->first_string_arg;
|
||
bool chas_url = cu && cu[0] != '\0' &&
|
||
(cu[0] == '/' || strstr(cu, "://") != NULL ||
|
||
(csvc == CBM_SVC_ASYNC && strlen(cu) > PP_ESC_SPACE));
|
||
if (chas_url) {
|
||
cbm_resolution_t svc_res = {.qualified_name = svc_name,
|
||
.confidence = PP_HALF_CONF,
|
||
.strategy = "service_pattern"};
|
||
site_evidence->calls_emitted |=
|
||
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &svc_res,
|
||
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
|
||
imp_count, false, route_mount, lang);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
/* A call that starts another program (subprocess.run, exec.Command,
|
||
* posix_spawn): a SPAWNS edge to its Process node instead of a CALLS
|
||
* edge. `res` holds the LSP answer when there is one, as the
|
||
* sequential pass asks inside its LSP branch. MUST match pass_calls.c. */
|
||
cbm_pipeline_spawn_t spawn;
|
||
if (cbm_pipeline_spawn_site(rc->main_gbuf, lang, call, &result->imports, &res, &spawn)) {
|
||
cbm_pipeline_emit_spawn(ws->local_edge_buf, source_node, call, &spawn);
|
||
continue;
|
||
}
|
||
|
||
if (!res.qualified_name || res.qualified_name[0] == '\0') {
|
||
if (cbm_service_pattern_route_method(call->callee_name) != NULL) {
|
||
cbm_resolution_t fake_res = {.qualified_name = call->callee_name,
|
||
.confidence = PP_HALF_CONF,
|
||
.strategy = "callee_suffix"};
|
||
/* The target here is the SOURCE itself (nothing resolved), so
|
||
* the plain-CALLS fall-through for a route-verb call without a
|
||
* route path (`cache.get(key)`) would be a self-loop; that holds
|
||
* for #2053's LSP-external Rust calls too. Suppress it always;
|
||
* route/HTTP/service classification is unchanged. */
|
||
site_evidence->calls_emitted |=
|
||
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res,
|
||
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
|
||
imp_count, true, route_mount, lang);
|
||
} else if (cbm_service_pattern_is_global_fetch(call->callee_name)) {
|
||
/* Native `fetch()` (#856): only the global API once resolution
|
||
* has failed to find a local/imported `fetch`. Call the low-level
|
||
* emitter directly — emit_service_edge re-derives its own kind
|
||
* from res->qualified_name via cbm_service_pattern_match, which
|
||
* "fetch" deliberately never matches (mirrors pass_calls.c). */
|
||
const char *u = call->first_string_arg;
|
||
if (u && u[0] != '\0' && (u[0] == '/' || strstr(u, "://") != NULL)) {
|
||
cbm_resolution_t fake_res = {.qualified_name = call->callee_name,
|
||
.confidence = PP_HALF_CONF,
|
||
.strategy = "service_pattern"};
|
||
emit_http_async_service_edge(ws->local_edge_buf, source_node, call, &fake_res,
|
||
CBM_SVC_HTTP, u);
|
||
}
|
||
} else {
|
||
/* A callee an import binds to an external client library
|
||
* (`send` under `from paho.mqtt import publish as send`):
|
||
* spelled through its import, then classified. MUST match
|
||
* pass_calls.c. */
|
||
char spelled[CBM_SZ_512];
|
||
cbm_svc_kind_t isvc = cbm_pipeline_external_import_service(
|
||
&ext_imports, rc->main_gbuf, rc->project_name, rel, lang, &result->imports,
|
||
call, spelled, sizeof(spelled));
|
||
if (isvc != CBM_SVC_NONE) {
|
||
CBMCall through = *call;
|
||
through.callee_name = spelled;
|
||
cbm_resolution_t imp_res = {.qualified_name = spelled,
|
||
.confidence = PP_HALF_CONF,
|
||
.strategy = "import_binding"};
|
||
emit_http_async_service_edge(ws->local_edge_buf, source_node, &through,
|
||
&imp_res, isvc, through.first_string_arg);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
/* Reuse lsp_target as target_node when LSP resolved — avoids a
|
||
* second cbm_gbuf_find_by_qn lookup. try_field_type_hint may have
|
||
* upgraded res.qualified_name to a different candidate, in which
|
||
* case we must re-resolve. */
|
||
_rc_t0 = extract_now_ns();
|
||
const cbm_gbuf_node_t *target_node;
|
||
if (lsp_target && res.qualified_name == lsp_target->qualified_name) {
|
||
target_node = lsp_target;
|
||
} else {
|
||
target_node = cbm_gbuf_find_by_qn(rc->main_gbuf, res.qualified_name);
|
||
}
|
||
atomic_fetch_add_explicit(&rc->time_ns_rc_target, extract_now_ns() - _rc_t0,
|
||
memory_order_relaxed);
|
||
/* A call that resolved onto a struct Field: refused across languages
|
||
* and for a bare C call; a C/C++ member call takes the Field its
|
||
* object's type names. Mirrors the sequential twin (pass_calls.c). */
|
||
if (target_node && source_node->id != target_node->id) {
|
||
cbm_field_call_policy_t field_policy = cbm_call_onto_field_policy(
|
||
lang, call->callee_name, target_node->label, target_node->file_path);
|
||
if (field_policy == CBM_FIELD_CALL_DROP) {
|
||
continue;
|
||
}
|
||
if (field_policy == CBM_FIELD_CALL_BY_OWNER && !arrow_bound) {
|
||
if (!field_index_built) {
|
||
field_index_ready =
|
||
cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index);
|
||
field_index_built = true;
|
||
}
|
||
target_node = cbm_pipeline_c_member_call_resolve(
|
||
&result->resolved_calls, field_index_ready ? &field_index : NULL, rc->main_gbuf,
|
||
rc->registry, rc->project_name, call->enclosing_func_qn, call->callee_name,
|
||
cbm_c_member_rule_file(lang, rel), &res);
|
||
if (!target_node || source_node->id == target_node->id) {
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
if (target_node && source_node->id != target_node->id &&
|
||
cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) {
|
||
/* #725: same guard as pass_calls.c — do not emit a suffix_match
|
||
* CALLS edge across a language boundary. A route registration
|
||
* behind the refused binding still gets its Route. */
|
||
site_evidence->calls_emitted |= emit_xlang_refused_route(
|
||
ws->local_edge_buf, source_node, call, module_qn, rc->registry, rc->main_gbuf,
|
||
imp_keys, imp_vals, imp_count, route_mount, lang);
|
||
continue;
|
||
}
|
||
if (!target_node || source_node->id == target_node->id) {
|
||
/* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`)
|
||
* resolve to an unindexed QN (target_node == NULL), but their edge
|
||
* target is a synthesized route node, not the library — emit them
|
||
* anyway so cross-repo matching has an HTTP_CALLS edge to work with
|
||
* (#523). Mirrors the sequential resolve_single_call bypass. */
|
||
cbm_svc_kind_t psvc = cbm_service_pattern_match(res.qualified_name);
|
||
if ((psvc == CBM_SVC_HTTP || psvc == CBM_SVC_ASYNC) && !target_node) {
|
||
const char *u = call->first_string_arg;
|
||
bool url_or_topic = u && u[0] != '\0' &&
|
||
(u[0] == '/' || strstr(u, "://") != NULL ||
|
||
(psvc == CBM_SVC_ASYNC && strlen(u) > PP_ESC_SPACE));
|
||
if (url_or_topic) {
|
||
site_evidence->calls_emitted |= emit_service_edge(
|
||
ws->local_edge_buf, source_node, NULL, call, &res, module_qn, rc->registry,
|
||
rc->main_gbuf, imp_keys, imp_vals, imp_count, false, route_mount, lang);
|
||
ws->calls_resolved++;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
site_evidence->candidate_qn = target_node->qualified_name;
|
||
_rc_t0 = extract_now_ns();
|
||
site_evidence->calls_emitted |= emit_service_edge(
|
||
ws->local_edge_buf, source_node, target_node, call, &res, module_qn, rc->registry,
|
||
rc->main_gbuf, imp_keys, imp_vals, imp_count, drop_plain_call, route_mount, lang);
|
||
atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0,
|
||
memory_order_relaxed);
|
||
ws->calls_resolved++;
|
||
}
|
||
if (lsp_exact_idx) {
|
||
cbm_ht_foreach(lsp_exact_idx, lsp_idx_free_key, NULL);
|
||
cbm_ht_free(lsp_exact_idx);
|
||
}
|
||
if (lsp_legacy_idx) {
|
||
cbm_ht_foreach(lsp_legacy_idx, lsp_idx_free_key, NULL);
|
||
cbm_ht_free(lsp_legacy_idx);
|
||
}
|
||
cbm_pipeline_external_import_index_free(&ext_imports);
|
||
cbm_pipeline_lsp_field_index_free(&field_index);
|
||
}
|
||
|
||
/* Resolve usages for one file. */
|
||
static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws,
|
||
CBMFileResult *result, const char *rel, const char *module_qn,
|
||
const char **imp_keys, const char **imp_vals, int imp_count,
|
||
CBMLanguage lang, const cbm_php_vendor_names_t *php_vendor) {
|
||
cbm_pipeline_lsp_reference_index_t reference_index = {0};
|
||
bool reference_index_ready =
|
||
cbm_pipeline_lsp_reference_index_build(&result->resolved_calls, &reference_index);
|
||
cbm_pipeline_lsp_field_index_t field_index = {0};
|
||
bool field_index_ready =
|
||
cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index);
|
||
for (int u = 0; u < result->usages.count; u++) {
|
||
CBMUsage *usage = &result->usages.items[u];
|
||
if (!usage->ref_name) {
|
||
continue;
|
||
}
|
||
const cbm_gbuf_node_t *src =
|
||
find_source_node(rc->main_gbuf, rc->project_name, rel, usage->enclosing_func_qn);
|
||
if (!src) {
|
||
continue;
|
||
}
|
||
/* A C member name binds through the type of its object, published by
|
||
* the C LSP as field-owner rows, and through nothing else. Must mirror
|
||
* the sequential twin (pass_usages.c) exactly. */
|
||
if (usage->kind == CBM_USAGE_VALUE &&
|
||
cbm_c_member_binds_by_owner(cbm_c_member_rule_file(lang, rel),
|
||
usage->is_member_access)) {
|
||
cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor(
|
||
&result->resolved_calls, field_index_ready ? &field_index : NULL,
|
||
usage->enclosing_func_qn, usage->ref_name);
|
||
char member_esc[CBM_SZ_256];
|
||
cbm_json_escape(member_esc, sizeof(member_esc), usage->ref_name);
|
||
char member_props[CBM_SZ_512];
|
||
snprintf(member_props, sizeof(member_props), "{\"callee\":\"%s\"}", member_esc);
|
||
const CBMResolvedCall *owner;
|
||
bool typed = false;
|
||
while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) {
|
||
typed = true;
|
||
const cbm_gbuf_node_t *field =
|
||
cbm_pipeline_lsp_field_node(rc->main_gbuf, rc->project_name, owner);
|
||
if (!field || field->id == src->id) {
|
||
continue;
|
||
}
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, "USAGE", member_props);
|
||
ws->usages_resolved++;
|
||
}
|
||
if (!typed) {
|
||
/* The LSP could not type the object: only a member name the
|
||
* project holds exactly once may still bind. */
|
||
const char *only = cbm_registry_unique_field_qn(rc->registry, usage->ref_name);
|
||
const cbm_gbuf_node_t *field =
|
||
only ? cbm_gbuf_find_by_qn(rc->main_gbuf, only) : NULL;
|
||
if (field && field->id != src->id &&
|
||
!cbm_suppress_cross_language_ref(lang, field->file_path)) {
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, "USAGE",
|
||
member_props);
|
||
ws->usages_resolved++;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
const cbm_gbuf_node_t *tgt = NULL;
|
||
bool precise_call_reference = false;
|
||
const CBMResolvedCall *semantic_reference = NULL;
|
||
bool property_read = false;
|
||
if (cbm_pipeline_usage_semantic_reference_candidate(usage)) {
|
||
bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang);
|
||
semantic_reference = cbm_pipeline_find_lsp_reference_indexed_in_graph(
|
||
&result->resolved_calls, reference_index_ready ? &reference_index : NULL, usage,
|
||
allow_tail, rc->main_gbuf, rc->project_name);
|
||
if (semantic_reference &&
|
||
!cbm_pipeline_usage_allows_semantic_reference(usage, semantic_reference)) {
|
||
semantic_reference = NULL;
|
||
}
|
||
if (semantic_reference) {
|
||
tgt = cbm_pipeline_lsp_target_node(rc->main_gbuf, rc->project_name,
|
||
semantic_reference->callee_qn, allow_tail);
|
||
if (!cbm_pipeline_reference_target_admissible(semantic_reference, tgt)) {
|
||
/* a property read: the name fallback below may still bind the
|
||
* property's own node, never a callable */
|
||
tgt = NULL;
|
||
semantic_reference = NULL;
|
||
property_read = true;
|
||
}
|
||
precise_call_reference = cbm_pipeline_node_is_callable_target(tgt);
|
||
}
|
||
}
|
||
if (!tgt) {
|
||
/* Exact semantic ownership beats textual name fallback even when
|
||
* the semantic target is not materialized in this graph. */
|
||
if (semantic_reference) {
|
||
continue;
|
||
}
|
||
/* #1186: a PHP name imported from a vendor namespace is not the
|
||
* same-named project symbol. Mirrors pass_usages.c. */
|
||
if (cbm_pipeline_php_vendor_usage(php_vendor, result, usage)) {
|
||
continue;
|
||
}
|
||
/* SQL usages are FROM/JOIN lineage refs and may bind Table/View
|
||
* targets (cbm_registry_resolve_lineage); every other language
|
||
* resolves through the default variant, whose central relation
|
||
* veto keeps same-named code identifiers out of the lineage layer.
|
||
* Must mirror the sequential twin (pass_usages.c) exactly. */
|
||
cbm_resolution_t res =
|
||
(lang == CBM_LANG_SQL)
|
||
? cbm_registry_resolve_lineage(rc->registry, usage->ref_name, module_qn,
|
||
imp_keys, imp_vals, imp_count)
|
||
: cbm_registry_resolve_lang(rc->registry, usage->ref_name, module_qn, imp_keys,
|
||
imp_vals, imp_count, lang);
|
||
if (!res.qualified_name || res.qualified_name[0] == '\0') {
|
||
continue;
|
||
}
|
||
if (!cbm_pipeline_reference_candidate_fallback_allowed(lang, usage, res.strategy,
|
||
imp_keys, imp_count)) {
|
||
continue;
|
||
}
|
||
/* a plain value reference names the field twin of a same-named
|
||
* method; a callable-reference candidate keeps the method */
|
||
bool value_ref = usage->kind == CBM_USAGE_VALUE && !usage->may_be_call_reference;
|
||
tgt = cbm_gbuf_find_by_qn(
|
||
rc->main_gbuf, value_ref
|
||
? cbm_registry_value_target(rc->registry, res.qualified_name)
|
||
: res.qualified_name);
|
||
/* #1928: the registry fallback is a bare-name guess — never let it
|
||
* bind a reference across a language boundary. Mirrors the
|
||
* sequential twin (pass_usages.c). */
|
||
if (tgt && cbm_suppress_cross_language_ref(lang, tgt->file_path)) {
|
||
continue;
|
||
}
|
||
/* #1942/#1962: a bare Go reference can never denote a struct
|
||
* field; the member half of a selector may. */
|
||
if (tgt && cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, usage->is_member_access,
|
||
tgt->label)) {
|
||
continue;
|
||
}
|
||
if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow ||
|
||
cbm_pipeline_node_is_callable_target(tgt))) {
|
||
continue;
|
||
}
|
||
if (property_read && cbm_pipeline_node_is_callable_target(tgt)) {
|
||
continue;
|
||
}
|
||
}
|
||
if (!tgt || src->id == tgt->id) {
|
||
continue;
|
||
}
|
||
/* 512, matching the sequential twin in pass_usages.c. esc_ref holds up
|
||
* to 255 bytes and the {"callee":"..."} wrapper adds 13, so a 256-byte
|
||
* uprops truncates a long callable identifier mid-string -- cutting the
|
||
* closing quote-brace and persisting MALFORMED JSON. The two paths must
|
||
* also agree: the same repo indexed in parallel and sequentially has to
|
||
* produce byte-identical edge properties. */
|
||
char uprops[CBM_SZ_512];
|
||
char esc_ref[CBM_SZ_256]; /* sliced source text: escape quotes/newlines */
|
||
cbm_json_escape(esc_ref, sizeof(esc_ref), usage->ref_name);
|
||
snprintf(uprops, sizeof(uprops), "{\"callee\":\"%s\"}", esc_ref);
|
||
const char *edge_type = precise_call_reference ? "CALL_REFERENCE" : "USAGE";
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, edge_type, uprops);
|
||
ws->usages_resolved++;
|
||
}
|
||
cbm_pipeline_lsp_reference_index_free(&reference_index);
|
||
cbm_pipeline_lsp_field_index_free(&field_index);
|
||
}
|
||
|
||
/* Resolve throws/raises for one file. */
|
||
static void resolve_file_throws(resolve_ctx_t *rc, resolve_worker_state_t *ws,
|
||
CBMFileResult *result, const char *rel, const char *module_qn,
|
||
const char **imp_keys, const char **imp_vals, int imp_count,
|
||
CBMLanguage lang) {
|
||
for (int t = 0; t < result->throws.count; t++) {
|
||
CBMThrow *thr = &result->throws.items[t];
|
||
if (!thr->exception_name || !thr->enclosing_func_qn) {
|
||
continue;
|
||
}
|
||
/* find_source_node falls back to the per-file File node when the
|
||
* lookup lands on a shared Folder/Project node (#787, #842) — same
|
||
* guard resolve_file_calls/usages/rw already use. */
|
||
const cbm_gbuf_node_t *src =
|
||
find_source_node(rc->main_gbuf, rc->project_name, rel, thr->enclosing_func_qn);
|
||
if (!src) {
|
||
continue;
|
||
}
|
||
const char *edge_type = is_checked_exception(thr->exception_name) ? "THROWS" : "RAISES";
|
||
cbm_resolution_t res = cbm_registry_resolve_lang(
|
||
rc->registry, thr->exception_name, module_qn, imp_keys, imp_vals, imp_count, lang);
|
||
if (!res.qualified_name || res.qualified_name[0] == '\0') {
|
||
continue;
|
||
}
|
||
const cbm_gbuf_node_t *tgt = cbm_gbuf_find_by_qn(rc->main_gbuf, res.qualified_name);
|
||
if (!tgt || src->id == tgt->id) {
|
||
continue;
|
||
}
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, edge_type, "{}");
|
||
}
|
||
}
|
||
|
||
/* Resolve reads/writes for one file. */
|
||
static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result,
|
||
const char *rel, const char *module_qn, const char **imp_keys,
|
||
const char **imp_vals, int imp_count, CBMLanguage lang) {
|
||
cbm_pipeline_lsp_field_index_t field_index = {0};
|
||
bool field_index_ready =
|
||
cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index);
|
||
for (int r = 0; r < result->rw.count; r++) {
|
||
CBMReadWrite *rw = &result->rw.items[r];
|
||
if (!rw->var_name) {
|
||
continue;
|
||
}
|
||
const cbm_gbuf_node_t *src =
|
||
find_source_node(rc->main_gbuf, rc->project_name, rel, rw->enclosing_func_qn);
|
||
if (!src) {
|
||
continue;
|
||
}
|
||
/* C member read/write: same owner join as resolve_file_usages. Mirrors
|
||
* the sequential twin (pass_usages.c). */
|
||
if (cbm_c_member_binds_by_owner(cbm_c_member_rule_file(lang, rel), rw->is_member_access)) {
|
||
cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor(
|
||
&result->resolved_calls, field_index_ready ? &field_index : NULL,
|
||
rw->enclosing_func_qn, rw->var_name);
|
||
const char *member_edge = rw->is_write ? "WRITES" : "READS";
|
||
const CBMResolvedCall *owner;
|
||
bool typed = false;
|
||
while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) {
|
||
typed = true;
|
||
const cbm_gbuf_node_t *field =
|
||
cbm_pipeline_lsp_field_node(rc->main_gbuf, rc->project_name, owner);
|
||
if (!field || field->id == src->id) {
|
||
continue;
|
||
}
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, member_edge, "{}");
|
||
}
|
||
if (!typed) {
|
||
const char *only = cbm_registry_unique_field_qn(rc->registry, rw->var_name);
|
||
const cbm_gbuf_node_t *field =
|
||
only ? cbm_gbuf_find_by_qn(rc->main_gbuf, only) : NULL;
|
||
if (field && field->id != src->id &&
|
||
!cbm_suppress_cross_language_ref(lang, field->file_path)) {
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, member_edge, "{}");
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
cbm_resolution_t res = cbm_registry_resolve_lang(rc->registry, rw->var_name, module_qn,
|
||
imp_keys, imp_vals, imp_count, lang);
|
||
if (!res.qualified_name || res.qualified_name[0] == '\0') {
|
||
continue;
|
||
}
|
||
const cbm_gbuf_node_t *tgt = cbm_gbuf_find_by_qn(
|
||
rc->main_gbuf, cbm_registry_value_target(rc->registry, res.qualified_name));
|
||
if (!tgt || src->id == tgt->id) {
|
||
continue;
|
||
}
|
||
/* #1928: every resolution here is a bare-name registry guess — never
|
||
* let it bind a read/write across a language boundary. Mirrors the
|
||
* sequential twin (pass_usages.c). */
|
||
if (cbm_suppress_cross_language_ref(lang, tgt->file_path)) {
|
||
continue;
|
||
}
|
||
/* #1942/#1962: a bare Go reference can never denote a struct field;
|
||
* a selector-LHS write (`t.err = x`) may bind it. */
|
||
if (cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, rw->is_member_access, tgt->label)) {
|
||
continue;
|
||
}
|
||
const char *etype = rw->is_write ? "WRITES" : "READS";
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}");
|
||
}
|
||
cbm_pipeline_lsp_field_index_free(&field_index);
|
||
}
|
||
|
||
/* The per-file facts the base-class gate needs beyond the import map. */
|
||
typedef struct {
|
||
const CBMImportArray *imports;
|
||
const char *rel;
|
||
CBMLanguage lang;
|
||
} pp_file_scope_t;
|
||
|
||
/* Resolve base_classes → INHERITS edges for one definition. */
|
||
static void resolve_def_inherits(resolve_ctx_t *rc, resolve_worker_state_t *ws,
|
||
const CBMDefinition *def, const cbm_gbuf_node_t *node,
|
||
const pp_file_scope_t *fs, const char *mq, const char **ik,
|
||
const char **iv, int ic,
|
||
const cbm_php_vendor_names_t *php_vendor) {
|
||
if (!def->base_classes) {
|
||
return;
|
||
}
|
||
for (int b = 0; def->base_classes[b]; b++) {
|
||
/* #1186: `extends Request` with `use Saloon\Http\Request` names the
|
||
* vendor class, never a same-named project class. Mirrors
|
||
* pass_semantic.c. */
|
||
if (cbm_pipeline_php_vendor_bound(php_vendor, def->base_classes[b])) {
|
||
continue;
|
||
}
|
||
/* Same order as the sequential semantic pass: an earlier sibling of
|
||
* the class body first (no external-base gate), then the registry. */
|
||
const char *bqn = cbm_registry_resolve_python_class_body_base(
|
||
rc->registry, rc->main_gbuf, def->qualified_name, def->base_classes[b], fs->lang);
|
||
if (!bqn) {
|
||
bqn = resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic, fs->lang);
|
||
if (!bqn || cbm_python_external_base_contradicts(
|
||
fs->lang, fs->imports, def->base_classes[b], bqn, rc->main_gbuf,
|
||
rc->project_name, fs->rel)) {
|
||
continue;
|
||
}
|
||
}
|
||
const cbm_gbuf_node_t *bn = cbm_gbuf_find_by_qn(rc->main_gbuf, bqn);
|
||
if (bn && node->id != bn->id) {
|
||
/* Same Interface-label split as the sequential semantic pass —
|
||
* hardcoding INHERITS here demoted every explicit `implements`
|
||
* on large (parallel-path) corpora. */
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, node->id, bn->id,
|
||
cbm_semantic_base_edge_type(bn), "{}");
|
||
ws->semantic_resolved++;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Resolve decorators → DECORATES edges for one definition. */
|
||
static void resolve_def_decorators(resolve_ctx_t *rc, resolve_worker_state_t *ws,
|
||
const CBMDefinition *def, const cbm_gbuf_node_t *node,
|
||
const char *mq, const char **ik, const char **iv, int ic,
|
||
CBMLanguage lang) {
|
||
if (!def->decorators) {
|
||
return;
|
||
}
|
||
for (int dc = 0; def->decorators[dc]; dc++) {
|
||
char fn[CBM_SZ_256];
|
||
extract_decorator_func(def->decorators[dc], fn, sizeof(fn));
|
||
if (fn[0] == '\0') {
|
||
continue;
|
||
}
|
||
cbm_resolution_t res = cbm_registry_resolve_lang(rc->registry, fn, mq, ik, iv, ic, lang);
|
||
if ((!res.qualified_name || res.qualified_name[0] == '\0') && !strchr(fn, '.')) {
|
||
/* C# attributes are referenced by their short name (`[Log]`) but
|
||
* declared with an `Attribute` suffix (`class LogAttribute`). */
|
||
char with_suffix[CBM_SZ_256];
|
||
int wn = snprintf(with_suffix, sizeof(with_suffix), "%sAttribute", fn);
|
||
if (wn > 0 && (size_t)wn < sizeof(with_suffix)) {
|
||
res = cbm_registry_resolve_lang(rc->registry, with_suffix, mq, ik, iv, ic, lang);
|
||
}
|
||
}
|
||
const cbm_gbuf_node_t *dn = NULL;
|
||
if (res.qualified_name && res.qualified_name[0] != '\0') {
|
||
dn = cbm_gbuf_find_by_qn(rc->main_gbuf, res.qualified_name);
|
||
}
|
||
/* Qualified Rust proc-macro paths must not degrade to the decorated
|
||
* function itself through the registry's same-module suffix fallback
|
||
* (`#[tokio::main]` on local `main`). Preserve the full external path
|
||
* through the synthetic Decorator node instead. */
|
||
if (dn && dn->id == node->id && def->decorators[dc][0] == '#' &&
|
||
def->decorators[dc][1] == '[' && strstr(fn, "::")) {
|
||
dn = NULL;
|
||
}
|
||
int64_t dn_id = 0;
|
||
if (dn) {
|
||
dn_id = dn->id;
|
||
} else {
|
||
/* External/stdlib decorator (Rust `#[derive(Debug)]`, Swift
|
||
* `@discardableResult`, Scala `@deprecated`, Python `@cache`,
|
||
* Java `@Override`, ...): no local symbol resolves. Materialise a
|
||
* synthetic "Decorator" node so the DECORATES relation is recorded.
|
||
* The node is created in the per-worker local_edge_buf (shared-ID
|
||
* gbuf); the sequential merge dedupes by QN across workers, so all
|
||
* uses of the same decorator name collapse to one node project-wide
|
||
* and the edge target IDs are remapped consistently. */
|
||
char syn_qn[CBM_SZ_512];
|
||
snprintf(syn_qn, sizeof(syn_qn), "<decorator:%s>", fn);
|
||
dn_id =
|
||
cbm_gbuf_upsert_node(ws->local_edge_buf, "Decorator", fn, syn_qn, "", 0, 0, "{}");
|
||
}
|
||
if (dn_id != 0 && node->id != dn_id) {
|
||
/* Decorator SOURCE TEXT can contain quotes and raw newlines
|
||
* (e.g. @register.tag("block"), multi-line @override_settings) —
|
||
* interpolating it raw produced malformed properties JSON that
|
||
* aborts every json_extract consumer (django: 3826 such edges). */
|
||
char esc_dec[CBM_SZ_256];
|
||
cbm_json_escape(esc_dec, sizeof(esc_dec), def->decorators[dc]);
|
||
char dp[CBM_SZ_512];
|
||
snprintf(dp, sizeof(dp), "{\"decorator\":\"%s\"}", esc_dec);
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, node->id, dn_id, "DECORATES", dp);
|
||
/* Ensure a reference-style edge exists so the decorator appears in queries
|
||
* without being misclassified as a real call by downstream passes. */
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, node->id, dn_id, "USAGE", "{}");
|
||
ws->semantic_resolved++;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Resolve INHERITS + DECORATES + IMPLEMENTS for one file. */
|
||
static void resolve_file_semantic(resolve_ctx_t *rc, resolve_worker_state_t *ws,
|
||
CBMFileResult *result, const char *rel, CBMLanguage lang,
|
||
const char *module_qn, const char **imp_keys,
|
||
const char **imp_vals, int imp_count,
|
||
const cbm_php_vendor_names_t *php_vendor) {
|
||
const pp_file_scope_t fs = {&result->imports, rel, lang};
|
||
for (int d = 0; d < result->defs.count; d++) {
|
||
CBMDefinition *def = &result->defs.items[d];
|
||
if (!def->qualified_name) {
|
||
continue;
|
||
}
|
||
const cbm_gbuf_node_t *node = cbm_gbuf_find_by_qn(rc->main_gbuf, def->qualified_name);
|
||
if (!node) {
|
||
continue;
|
||
}
|
||
resolve_def_inherits(rc, ws, def, node, &fs, module_qn, imp_keys, imp_vals, imp_count,
|
||
php_vendor);
|
||
resolve_def_decorators(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count, lang);
|
||
}
|
||
for (int t = 0; t < result->impl_traits.count; t++) {
|
||
CBMImplTrait *it = &result->impl_traits.items[t];
|
||
if (!it->trait_name || !it->struct_name) {
|
||
continue;
|
||
}
|
||
const char *tqn = resolve_as_class(rc->registry, it->trait_name, module_qn, imp_keys,
|
||
imp_vals, imp_count, lang);
|
||
const char *sqn = resolve_as_class(rc->registry, it->struct_name, module_qn, imp_keys,
|
||
imp_vals, imp_count, lang);
|
||
if (!tqn || !sqn) {
|
||
continue;
|
||
}
|
||
const cbm_gbuf_node_t *tn = cbm_gbuf_find_by_qn(rc->main_gbuf, tqn);
|
||
const cbm_gbuf_node_t *sn = cbm_gbuf_find_by_qn(rc->main_gbuf, sqn);
|
||
if (tn && sn && tn->id != sn->id) {
|
||
cbm_gbuf_insert_edge(ws->local_edge_buf, sn->id, tn->id, "IMPLEMENTS", "{}");
|
||
ws->semantic_resolved++;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* F4: get (or lazily build ONCE) the shared all_defs Rust registry. Called by the
|
||
* first worker that hits a NULL-filter rust file; later null-files reuse it. Fast
|
||
* path is a lock-free atomic load; the build happens under rust_shared_mu into the
|
||
* dedicated rust_shared_arena (never a shared pipeline arena from a worker thread).
|
||
* Returns NULL if there are no defs (caller falls back to the per-file build). */
|
||
static CBMTypeRegistry *pp_rust_shared_registry(resolve_ctx_t *rc) {
|
||
CBMTypeRegistry *p = atomic_load_explicit(&rc->rust_shared_reg, memory_order_acquire);
|
||
if (p)
|
||
return p;
|
||
if (!rc->all_defs || rc->def_count <= 0)
|
||
return NULL;
|
||
cbm_mutex_lock(&rc->rust_shared_mu);
|
||
p = atomic_load_explicit(&rc->rust_shared_reg, memory_order_relaxed);
|
||
if (!p) {
|
||
cbm_arena_init(&rc->rust_shared_arena);
|
||
rc->rust_shared_arena_live = true;
|
||
p = cbm_rust_build_cross_registry(&rc->rust_shared_arena, rc->all_defs, rc->def_count);
|
||
if (p) {
|
||
char sb[96];
|
||
snprintf(sb, sizeof(sb), "types=%d funcs=%d", p->type_count, p->func_count);
|
||
cbm_log_info("cross_lsp.rust_registry", "scale", sb);
|
||
}
|
||
atomic_store_explicit(&rc->rust_shared_reg, p, memory_order_release);
|
||
}
|
||
cbm_mutex_unlock(&rc->rust_shared_mu);
|
||
return p;
|
||
}
|
||
|
||
/* void*-typed adapter so the shared dispatch helper (pass_lsp_cross.c) can
|
||
* borrow the lazily-built shared Rust registry without knowing resolve_ctx_t. */
|
||
static CBMTypeRegistry *pp_rust_shared_registry_get(void *ctx) {
|
||
return pp_rust_shared_registry((resolve_ctx_t *)ctx);
|
||
}
|
||
|
||
static int pp_call_reference_site_count(const CBMFileResult *result, CBMLanguage lang) {
|
||
int count = 0;
|
||
for (int i = 0; i < result->usages.count; i++) {
|
||
if (cbm_pipeline_usage_semantic_reference_candidate(&result->usages.items[i])) {
|
||
count++;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
static int pp_qualified_lsp_site_count(const CBMFileResult *result) {
|
||
int count = 0;
|
||
for (int i = 0; i < result->resolved_calls.count; i++) {
|
||
const CBMResolvedCall *resolved = &result->resolved_calls.items[i];
|
||
if ((resolved->kind == CBM_RESOLVED_INVOCATION ||
|
||
resolved->kind == CBM_RESOLVED_CALL_REFERENCE) &&
|
||
resolved->caller_qn && resolved->callee_qn &&
|
||
resolved->confidence >= CBM_LSP_CONFIDENCE_FLOOR) {
|
||
count++;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
/* A per-file semantic walk can discover a faithful source occurrence that the
|
||
* syntax extractor cannot represent as a CBMCall yet: Python operators and
|
||
* calls hidden in Rust built-in macro token trees are the canonical examples.
|
||
* An exact unresolved/low-confidence record is therefore an explicit request
|
||
* for the project-registry pass, even when the parser-backed site count is
|
||
* zero or already fully qualified. Zero-span generated semantics are excluded:
|
||
* without source provenance they cannot safely join a graph carrier. */
|
||
static bool pp_has_pending_lsp_site(const CBMFileResult *result) {
|
||
for (int i = 0; i < result->resolved_calls.count; i++) {
|
||
const CBMResolvedCall *resolved = &result->resolved_calls.items[i];
|
||
if (resolved->kind != CBM_RESOLVED_INVOCATION &&
|
||
resolved->kind != CBM_RESOLVED_CALL_REFERENCE) {
|
||
continue;
|
||
}
|
||
if (!resolved->caller_qn || !resolved->callee_qn ||
|
||
resolved->site_end_byte <= resolved->site_start_byte) {
|
||
continue;
|
||
}
|
||
if (resolved->reason || resolved->confidence < CBM_LSP_CONFIDENCE_FLOOR) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
static CBMFileResult *resolve_load_result(resolve_ctx_t *rc, resolve_worker_state_t *ws,
|
||
int file_idx) {
|
||
if (ws->loaded) {
|
||
cbm_free_result(ws->loaded);
|
||
ws->loaded = NULL;
|
||
}
|
||
CBMFileResult *result = rc->result_cache[file_idx];
|
||
if (!result && rc->pctx && rc->pctx->spill && cbm_result_spill_has(rc->pctx->spill, file_idx)) {
|
||
result = cbm_result_spill_load(rc->pctx->spill, file_idx);
|
||
ws->loaded = result;
|
||
(void)cbm_pipeline_test_extraction_ok(rc->pctx, rc->files[file_idx].language, result);
|
||
if (!result) {
|
||
cbm_pipeline_mark_unresolved_capture_failed(rc->pctx->pipeline);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
static void resolve_capture_coverage(const resolve_ctx_t *rc, const char *rel,
|
||
const CBMFileResult *result, const CBMCallEvidence *evidence) {
|
||
cbm_pipeline_record_unresolved_calls(rc->pctx ? rc->pctx->pipeline : NULL, rel, result,
|
||
evidence);
|
||
}
|
||
|
||
/* A C member access binds only through a field-owner row. The per-file walk
|
||
* publishes owners for the structs its own file declares; a struct from a
|
||
* header is known only to the cross-file pass. A member access that still has
|
||
* no owner is therefore a request for that pass, exactly like an unqualified
|
||
* call site -- without it a file with no pending call would keep none of its
|
||
* edges onto header-declared fields. */
|
||
static bool pp_has_unowned_c_member(const CBMFileResult *result, CBMLanguage lang,
|
||
const char *rel) {
|
||
if (!cbm_c_member_rule_file(lang, rel)) {
|
||
return false;
|
||
}
|
||
cbm_pipeline_lsp_field_index_t field_index = {0};
|
||
bool ready = cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index);
|
||
bool unowned = false;
|
||
for (int i = 0; i < result->usages.count && !unowned; i++) {
|
||
const CBMUsage *usage = &result->usages.items[i];
|
||
if (usage->kind != CBM_USAGE_VALUE || !usage->is_member_access || !usage->ref_name) {
|
||
continue;
|
||
}
|
||
cbm_pipeline_lsp_field_cursor_t owners =
|
||
cbm_pipeline_lsp_field_cursor(&result->resolved_calls, ready ? &field_index : NULL,
|
||
usage->enclosing_func_qn, usage->ref_name);
|
||
unowned = cbm_pipeline_lsp_field_next(&owners) == NULL;
|
||
}
|
||
for (int i = 0; i < result->rw.count && !unowned; i++) {
|
||
const CBMReadWrite *rw = &result->rw.items[i];
|
||
if (!rw->is_member_access || !rw->var_name) {
|
||
continue;
|
||
}
|
||
cbm_pipeline_lsp_field_cursor_t owners =
|
||
cbm_pipeline_lsp_field_cursor(&result->resolved_calls, ready ? &field_index : NULL,
|
||
rw->enclosing_func_qn, rw->var_name);
|
||
unowned = cbm_pipeline_lsp_field_next(&owners) == NULL;
|
||
}
|
||
for (int i = 0; i < result->calls.count && !unowned; i++) {
|
||
const CBMCall *call = &result->calls.items[i];
|
||
if (!call->callee_name ||
|
||
(!strchr(call->callee_name, '.') && !strstr(call->callee_name, "->"))) {
|
||
continue;
|
||
}
|
||
cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor(
|
||
&result->resolved_calls, ready ? &field_index : NULL, call->enclosing_func_qn,
|
||
cbm_lsp_bare_segment(call->callee_name));
|
||
unowned = cbm_pipeline_lsp_field_next(&owners) == NULL;
|
||
}
|
||
cbm_pipeline_lsp_field_index_free(&field_index);
|
||
return unowned;
|
||
}
|
||
|
||
static void resolve_worker(int worker_id, void *ctx_ptr) {
|
||
resolve_ctx_t *rc = ctx_ptr;
|
||
resolve_worker_state_t *ws = &rc->workers[worker_id];
|
||
|
||
cbm_pxc_set_rust_manifest(rc->rust_manifest);
|
||
|
||
if (!ws->local_edge_buf) {
|
||
ws->local_edge_buf = cbm_gbuf_new_worker(rc->project_name, rc->repo_path, rc->shared_ids);
|
||
}
|
||
|
||
/* Per-worker service-pattern result cache. The same resolved QN
|
||
* (e.g. "fmt.Errorf", "context.Context.Done") appears in many
|
||
* call edges across many files within a project — caching turns
|
||
* cbm_service_pattern_match's 6 × 30 × strstr scan into one hash
|
||
* lookup after the first miss for each QN. Scoped to the worker's
|
||
* lifetime in the parallel_resolve phase. */
|
||
cbm_service_pattern_cache_begin();
|
||
cbm_pxc_thread_scratch_begin();
|
||
file_node_cache_clear();
|
||
|
||
while (SKIP_ONE) {
|
||
int file_idx =
|
||
atomic_fetch_add_explicit(&rc->next_file_idx, SKIP_ONE, memory_order_relaxed);
|
||
if (file_idx >= rc->file_count) {
|
||
break;
|
||
}
|
||
cbm_scale_tick(&rc->scale, file_idx);
|
||
if (atomic_load_explicit(rc->cancelled, memory_order_relaxed)) {
|
||
break;
|
||
}
|
||
|
||
uint64_t _loop_t0 = extract_now_ns();
|
||
|
||
CBMFileResult *result = resolve_load_result(rc, ws, file_idx);
|
||
if (!result) {
|
||
atomic_fetch_add_explicit(&rc->time_ns_total_loop, extract_now_ns() - _loop_t0,
|
||
memory_order_relaxed);
|
||
continue;
|
||
}
|
||
if (!cbm_pipeline_test_result_ok(rc->pctx, result)) {
|
||
continue;
|
||
}
|
||
atomic_fetch_add_explicit(&rc->total_files_visited, 1, memory_order_relaxed);
|
||
|
||
CBMLanguage lang = rc->files[file_idx].language;
|
||
const char *rel = rc->files[file_idx].rel_path;
|
||
CBMCallEvidence *evidence = NULL;
|
||
|
||
/* Skip cross-LSP for machine-generated files — they're huge (10k-
|
||
* 70k lines for k8s protobuf/openapi), have low semantic value for
|
||
* graph navigation (boilerplate getters/setters/marshal), and
|
||
* dominate the cross-LSP wall time when they have even one
|
||
* unresolved call (tree-sitter parse on a 70k-line file is ~1-2s).
|
||
* The per-file LSP during extract still indexes their defs/calls
|
||
* normally — only the cross-file resolution refinement is skipped. */
|
||
bool is_generated = false;
|
||
if (rel) {
|
||
is_generated =
|
||
(strstr(rel, ".pb.go") != NULL) || (strstr(rel, "zz_generated") != NULL) ||
|
||
(strstr(rel, "_generated.go") != NULL) || (strstr(rel, ".gen.go") != NULL) ||
|
||
(strstr(rel, "/applyconfigurations/") != NULL) ||
|
||
(strstr(rel, "_pb2.py") != NULL) || (strstr(rel, "_pb2_grpc.py") != NULL) ||
|
||
(strstr(rel, ".pb.cc") != NULL) || (strstr(rel, ".pb.h") != NULL) ||
|
||
(strstr(rel, ".pb-c.c") != NULL) || (strstr(rel, ".pb-c.h") != NULL);
|
||
}
|
||
|
||
if (result->has_test_definition_owners &&
|
||
(!rc->all_defs || rc->def_count <= 0 || !cbm_pxc_has_cross_lsp(lang) || is_generated ||
|
||
result->lsp_skipped)) {
|
||
cbm_pipeline_test_owner_error(result);
|
||
(void)cbm_pipeline_test_result_ok(rc->pctx, result);
|
||
continue;
|
||
}
|
||
|
||
/* Cross-file LSP is a per-file tree-sitter re-parse + AST walk +
|
||
* registry lookups — ~50-150ms per file. It can only resolve semantic
|
||
* sites present in that AST: invocations plus explicit callable
|
||
* references. A file containing only a callable reference still needs
|
||
* this pass. For non-JVM languages, skip when per-file LSP already
|
||
* produced at least as many qualifying typed resolutions as sites.
|
||
* Java/Kotlin per-file LSP can fill the count with same-file sites while
|
||
* a mixed-source-root Java↔Kotlin site remains unresolved, so JVM
|
||
* callers run whenever either kind of site exists. */
|
||
bool jvm_cross_lsp = (lang == CBM_LANG_JAVA || lang == CBM_LANG_KOTLIN);
|
||
bool rust_workspace_cross_lsp =
|
||
(lang == CBM_LANG_RUST && rc->rust_manifest && rc->rust_manifest->member_count > 0);
|
||
int call_reference_sites = pp_call_reference_site_count(result, lang);
|
||
int semantic_sites = result->calls.count + call_reference_sites;
|
||
int qualified_lsp_sites = pp_qualified_lsp_site_count(result);
|
||
bool pending_lsp_site = pp_has_pending_lsp_site(result);
|
||
bool unowned_c_member = pp_has_unowned_c_member(result, lang, rel);
|
||
bool cross_lsp_eligible =
|
||
(rc->all_defs && rc->def_count > 0 && cbm_pxc_has_cross_lsp(lang) &&
|
||
(((semantic_sites > 0 || pending_lsp_site) &&
|
||
(jvm_cross_lsp || rust_workspace_cross_lsp || pending_lsp_site ||
|
||
qualified_lsp_sites < semantic_sites)) ||
|
||
unowned_c_member) &&
|
||
!is_generated) ||
|
||
result->has_test_definition_owners;
|
||
|
||
/* Skip files with nothing else to resolve and no cross-LSP work. */
|
||
if (result->calls.count == 0 && result->usages.count == 0 && result->throws.count == 0 &&
|
||
result->rw.count == 0 && result->defs.count == 0 && result->impl_traits.count == 0 &&
|
||
!cross_lsp_eligible) {
|
||
resolve_capture_coverage(rc, rel, result, NULL);
|
||
continue;
|
||
}
|
||
|
||
/* Build import map ONCE (read-only access to main_gbuf). The
|
||
* same imp_keys/imp_vals feed both the fused cross-file LSP
|
||
* step below AND the resolve_file_* chain — no duplicate build. */
|
||
const char **imp_keys = NULL;
|
||
const char **imp_vals = NULL;
|
||
int imp_count = 0;
|
||
uint64_t _imp_t0 = extract_now_ns();
|
||
cbm_pxc_build_import_map(rc->main_gbuf, rc->project_name, rel, lang, result, &imp_keys,
|
||
&imp_vals, &imp_count);
|
||
atomic_fetch_add_explicit(&rc->time_ns_import_map, extract_now_ns() - _imp_t0,
|
||
memory_order_relaxed);
|
||
cbm_php_vendor_names_t php_vendor;
|
||
cbm_pipeline_php_vendor_names_build(rc->main_gbuf, rc->project_name, rel, result,
|
||
&php_vendor);
|
||
|
||
/* Per-file is_import_reachable memoization. Spans all 5 resolve
|
||
* sub-passes (calls/usages/throws/rw/semantic) which all flow
|
||
* through cbm_registry_resolve. Same callee_name appears in
|
||
* many call sites — first eval pays the strstr cost, repeats
|
||
* are O(1) hash. Imports are constant within a file so the
|
||
* cache is sound; invalidated at file exit. */
|
||
cbm_registry_reach_cache_begin(result->calls.count + result->usages.count + 64);
|
||
|
||
/* Per-file import-map prefix → module-qn hash. resolve_import_map
|
||
* was doing O(imports) linear strcmp per call; with this it
|
||
* becomes O(1). Keys/values borrowed from imp_keys/imp_vals
|
||
* which outlive this scope. */
|
||
cbm_registry_import_map_cache_begin(imp_keys, imp_vals, imp_count);
|
||
|
||
/* THE BIG ONE: per-file cache of cbm_registry_resolve results.
|
||
* Same callee_name in multiple call sites resolves identically
|
||
* within a file (module_qn is fixed) — first call walks the
|
||
* strategy chain, repeats are O(1). On K8s this targets the
|
||
* 98.7% hot spot in resolve_file_calls (881 of 893s CPU). */
|
||
cbm_registry_resolve_cache_begin(result->calls.count + result->usages.count + 64);
|
||
|
||
char *module_qn =
|
||
cbm_pipeline_fqn_module_dir(rc->project_name, rel, pp_module_is_dir(lang));
|
||
|
||
/* ── Cross-file LSP (FUSED) ─────────────────────────────
|
||
* Runs BEFORE resolve_file_calls so its additions to
|
||
* result->resolved_calls are picked up by
|
||
* cbm_pipeline_find_lsp_resolution when calls become CALLS
|
||
* edges. Prefers source bytes retained in result->arena during
|
||
* extract; when the low-RAM retention cap dropped this file
|
||
* (result->source==NULL) it FALLS BACK to a bounded per-file
|
||
* read from disk, freed immediately after the LSP call. This is
|
||
* the correctness guarantee: lowering the retention cap trades
|
||
* retained RAM for a bounded re-read, it NEVER drops a cross-file
|
||
* edge. Only a genuine read failure (deleted/unreadable/oversized)
|
||
* leaves source NULL and is counted as skipped_no_source; defs/calls
|
||
* already in the extract are unaffected either way.
|
||
*
|
||
* Slab reclaim afterward: the LSP re-parses via tree-sitter,
|
||
* which allocates through this worker's TLS slab. Reclaiming
|
||
* here keeps the slab high-water bounded as the resolve phase
|
||
* walks across thousands of files in a single worker thread. */
|
||
if (cross_lsp_eligible) {
|
||
char *lsp_source_owned = NULL;
|
||
const char *lsp_source = result->source;
|
||
int lsp_source_len = result->source_len;
|
||
if ((!lsp_source || lsp_source_len <= 0) && rc->files[file_idx].path) {
|
||
/* Retention cap skipped this file — re-read on demand (bounded
|
||
* by read_file's cbm_max_file_bytes cap), freed below. */
|
||
lsp_source_owned = read_file(rc->files[file_idx].path, &lsp_source_len, NULL, NULL);
|
||
lsp_source = lsp_source_owned;
|
||
}
|
||
if (lsp_source && lsp_source_len > 0) {
|
||
const char *def_module = rc->def_modules ? rc->def_modules[file_idx] : module_qn;
|
||
|
||
uint64_t lsp_t0 = extract_now_ns();
|
||
|
||
/* Shared per-file dispatch (pass_lsp_cross.c): module-def
|
||
* filter → shared prebuilt registry (overlay pattern) →
|
||
* filtered per-file fallback. The SAME helper drives the
|
||
* sequential pass — one path, one semantics. Journal the
|
||
* file around the resolve so a hang HERE is attributed to
|
||
* this file, not to a stale extraction marker. */
|
||
cbm_index_mark_start(rel);
|
||
bool had_error = result->has_error;
|
||
/* The LSP map also binds annotation-only imports (#1277);
|
||
* only a file that records one needs its own map, every
|
||
* other file passes the CALLS map unchanged. MUST match the
|
||
* sequential dispatch in pass_lsp_cross.c. */
|
||
const char **lsp_keys = NULL;
|
||
const char **lsp_vals = NULL;
|
||
int lsp_count = 0;
|
||
bool own_lsp_map = cbm_pxc_result_has_type_only_import(result);
|
||
if (own_lsp_map) {
|
||
cbm_pxc_build_import_map_ex(rc->main_gbuf, rc->project_name, rel, lang, result,
|
||
true, &lsp_keys, &lsp_vals, &lsp_count);
|
||
}
|
||
cbm_pxc_dispatch_file_with_php_bindings(
|
||
lang, result, lsp_source, lsp_source_len, rel, def_module, rc->cross_registries,
|
||
rc->module_def_index, rc->all_defs, rc->def_count,
|
||
own_lsp_map ? lsp_keys : imp_keys, own_lsp_map ? lsp_vals : imp_vals,
|
||
own_lsp_map ? lsp_count : imp_count, pp_rust_shared_registry_get, rc,
|
||
&php_vendor);
|
||
if (own_lsp_map) {
|
||
cbm_pxc_free_import_map(lsp_keys, lsp_vals, lsp_count);
|
||
}
|
||
cbm_index_mark_done(rel);
|
||
if (!had_error && result->has_error && result->error_msg &&
|
||
(strcmp(result->error_msg, CBM_C_LSP_MEMO_ERROR) == 0 ||
|
||
strcmp(result->error_msg, CBM_PY_LSP_MEMO_ERROR) == 0 ||
|
||
strcmp(result->error_msg, CBM_C_LSP_DEPTH_ERROR) == 0 ||
|
||
strcmp(result->error_msg, CBM_PY_LSP_DEPTH_ERROR) == 0 ||
|
||
strcmp(result->error_msg, CBM_PY_LSP_SCOPE_ERROR) == 0)) {
|
||
rc->lsp_failures[file_idx] = result->error_msg;
|
||
ws->errors++;
|
||
}
|
||
/* Free the on-demand re-read (no-op when source was retained). */
|
||
free_source(lsp_source_owned);
|
||
/* Contract: cbm_slab_reclaim() requires the thread parser to be
|
||
* destroyed first; otherwise its lexer holds slab pointers
|
||
* (lexer.included_ranges) that get freed underneath it, causing
|
||
* a heap-use-after-free on the next ts_lexer_goto. The next
|
||
* cbm_extract_file on this thread will recreate the parser. */
|
||
cbm_destroy_thread_parser();
|
||
cbm_slab_reclaim();
|
||
uint64_t lsp_elapsed_ns = extract_now_ns() - lsp_t0;
|
||
atomic_fetch_add_explicit(&rc->time_ns_cross_lsp, lsp_elapsed_ns,
|
||
memory_order_relaxed);
|
||
uint64_t lsp_elapsed_ms = lsp_elapsed_ns / PP_USEC_PER_MS;
|
||
if (lsp_elapsed_ms > PP_TIMER_THRESH) {
|
||
cbm_log_info("parallel.resolve.lsp_cross.slow", "elapsed_ms",
|
||
itoa_log((int)lsp_elapsed_ms), "path", rel);
|
||
}
|
||
atomic_fetch_add_explicit(&rc->lsp_cross_processed, SKIP_ONE, memory_order_relaxed);
|
||
if (!cbm_pipeline_test_result_ok(rc->pctx, result)) {
|
||
goto resolve_file_cleanup;
|
||
}
|
||
} else {
|
||
/* Source unavailable even after the re-read fallback (file
|
||
* deleted / unreadable / oversized) → the cross-file LSP
|
||
* refinement no-ops for this file. This is a bounded skip, NOT
|
||
* a file failure: defs/calls were already extracted and are
|
||
* unaffected. Deliberately NOT recorded as a cbm_file_error —
|
||
* doing so would flood skipped[] with false positives (itself a
|
||
* false-guard bug). The "cross_lsp" phase string is reserved for
|
||
* Track C's real crash-attribution signal; leave it unwired. */
|
||
free_source(lsp_source_owned);
|
||
if (result->has_test_definition_owners) {
|
||
cbm_pipeline_test_owner_error(result);
|
||
(void)cbm_pipeline_test_result_ok(rc->pctx, result);
|
||
goto resolve_file_cleanup;
|
||
}
|
||
atomic_fetch_add_explicit(&rc->lsp_cross_skipped_no_source, SKIP_ONE,
|
||
memory_order_relaxed);
|
||
}
|
||
}
|
||
|
||
/* Per-sub-phase wall-clock so we can attribute the dominant cost. */
|
||
uint64_t _ph_t0;
|
||
|
||
/* Cross-LSP may append synthetic calls; their positions are now final. */
|
||
evidence = resolve_evidence_new(rc, result);
|
||
/* ── CALLS resolution ──────────────────────────────────── */
|
||
_ph_t0 = extract_now_ns();
|
||
resolve_file_calls(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang,
|
||
&php_vendor, evidence);
|
||
atomic_fetch_add_explicit(&rc->time_ns_calls, extract_now_ns() - _ph_t0,
|
||
memory_order_relaxed);
|
||
|
||
/* ── USAGE resolution ──────────────────────────────────── */
|
||
_ph_t0 = extract_now_ns();
|
||
resolve_file_usages(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang,
|
||
&php_vendor);
|
||
atomic_fetch_add_explicit(&rc->time_ns_usages, extract_now_ns() - _ph_t0,
|
||
memory_order_relaxed);
|
||
|
||
/* ── THROWS / RAISES ───────────────────────────────────── */
|
||
_ph_t0 = extract_now_ns();
|
||
resolve_file_throws(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang);
|
||
atomic_fetch_add_explicit(&rc->time_ns_throws, extract_now_ns() - _ph_t0,
|
||
memory_order_relaxed);
|
||
|
||
/* ── READS / WRITES ────────────────────────────────────── */
|
||
_ph_t0 = extract_now_ns();
|
||
resolve_file_rw(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang);
|
||
atomic_fetch_add_explicit(&rc->time_ns_rw, extract_now_ns() - _ph_t0, memory_order_relaxed);
|
||
|
||
/* ── INHERITS + DECORATES + IMPLEMENTS ──────────────────── */
|
||
_ph_t0 = extract_now_ns();
|
||
resolve_file_semantic(rc, ws, result, rel, lang, module_qn, imp_keys, imp_vals, imp_count,
|
||
&php_vendor);
|
||
atomic_fetch_add_explicit(&rc->time_ns_semantic, extract_now_ns() - _ph_t0,
|
||
memory_order_relaxed);
|
||
|
||
/* ── MENTIONS (doc-comment references) ─────────────────── */
|
||
if (rc->pctx && rc->pctx->doc_links) {
|
||
cbm_doclinks_resolve_file(rc->pctx->doc_links, file_idx, result, rc->main_gbuf,
|
||
ws->local_edge_buf);
|
||
}
|
||
|
||
resolve_file_cleanup:
|
||
/* Capture the resolved file and side evidence before releasing either,
|
||
* including files loaded from the spill store. */
|
||
resolve_capture_coverage(rc, rel, result, evidence);
|
||
cbm_free(CBM_MEM_CLASS_RESOLVE, evidence);
|
||
|
||
cbm_registry_reach_cache_end();
|
||
cbm_registry_import_map_cache_end();
|
||
cbm_registry_resolve_cache_end();
|
||
|
||
free(module_qn);
|
||
cbm_pipeline_php_vendor_names_free(&php_vendor);
|
||
cbm_pxc_free_import_map(imp_keys, imp_vals, imp_count);
|
||
|
||
atomic_fetch_add_explicit(&rc->time_ns_total_loop, extract_now_ns() - _loop_t0,
|
||
memory_order_relaxed);
|
||
}
|
||
|
||
/* Tear down this worker's thread-local parser + slab state, mirroring
|
||
* extract_worker. Without this, resolve-worker pages keep an owner pointer
|
||
* into dead TLS and are never retired, so a later cross-thread free can
|
||
* never bring their refcount to zero (leak). Retiring them here releases
|
||
* each page as its final chunk returns. */
|
||
if (ws->loaded) {
|
||
cbm_free_result(ws->loaded);
|
||
ws->loaded = NULL;
|
||
}
|
||
cbm_pxc_set_rust_manifest(NULL);
|
||
cbm_destroy_thread_parser();
|
||
cbm_slab_destroy_thread();
|
||
cbm_service_pattern_cache_end();
|
||
cbm_pxc_thread_scratch_end();
|
||
file_node_cache_clear();
|
||
}
|
||
|
||
int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count,
|
||
CBMFileResult **result_cache, _Atomic int64_t *shared_ids,
|
||
int worker_count, CBMLSPDef *all_defs, int def_count,
|
||
char *const *def_modules, struct CBMModuleDefIndex *module_def_index,
|
||
void *cross_registries_v) {
|
||
/* See header: typed as void* across the TU boundary; cast back here. */
|
||
CBMCrossLspRegistries *cross_registries = (CBMCrossLspRegistries *)cross_registries_v;
|
||
if (file_count == 0) {
|
||
return 0;
|
||
}
|
||
|
||
/* Preparation or an opt-out cannot erase owners already found by
|
||
* extraction, including results currently parked in the spill store. */
|
||
if (atomic_load(&ctx->test_declarations_failed) ||
|
||
(atomic_load(&ctx->test_definition_owners_seen) && (!all_defs || def_count <= 0))) {
|
||
atomic_store(&ctx->test_declarations_failed, 1);
|
||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||
}
|
||
|
||
if (file_count < 0 || (size_t)file_count > SIZE_MAX / sizeof(const char *)) {
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
|
||
cbm_log_info("parallel.resolve.start", "files", itoa_log(file_count), "workers",
|
||
itoa_log(worker_count));
|
||
|
||
resolve_worker_state_t *workers = NULL;
|
||
if (cbm_aligned_alloc((void **)&workers, CBM_CACHE_LINE,
|
||
(size_t)worker_count * sizeof(resolve_worker_state_t)) != 0) {
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
memset(workers, 0, (size_t)worker_count * sizeof(resolve_worker_state_t));
|
||
const char **lsp_failures =
|
||
cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)file_count * sizeof(*lsp_failures));
|
||
if (!lsp_failures) {
|
||
cbm_aligned_free(workers);
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
|
||
bool have_rust = false;
|
||
for (int i = 0; i < file_count; i++) {
|
||
if (files[i].language == CBM_LANG_RUST &&
|
||
(result_cache[i] || (ctx->spill && cbm_result_spill_has(ctx->spill, i)))) {
|
||
have_rust = true;
|
||
break;
|
||
}
|
||
}
|
||
CBMArena rust_manifest_arena;
|
||
CBMCargoManifest rust_manifest;
|
||
bool rust_manifest_arena_live = false;
|
||
const CBMCargoManifest *rust_manifest_ptr = NULL;
|
||
if (have_rust) {
|
||
cbm_arena_init(&rust_manifest_arena);
|
||
rust_manifest_arena_live = true;
|
||
if (cbm_pxc_build_rust_manifest(ctx, &rust_manifest_arena, &rust_manifest)) {
|
||
rust_manifest_ptr = &rust_manifest;
|
||
}
|
||
}
|
||
|
||
resolve_ctx_t rc = {
|
||
.pctx = ctx,
|
||
.files = files,
|
||
.file_count = file_count,
|
||
.project_name = ctx->project_name,
|
||
.repo_path = ctx->repo_path,
|
||
.workers = workers,
|
||
.max_workers = worker_count,
|
||
.result_cache = result_cache,
|
||
.main_gbuf = ctx->gbuf,
|
||
.registry = ctx->registry,
|
||
.shared_ids = shared_ids,
|
||
.cancelled = ctx->cancelled,
|
||
.all_defs = all_defs,
|
||
.def_count = def_count,
|
||
.def_modules = def_modules,
|
||
.module_def_index = module_def_index,
|
||
.cross_registries = cross_registries,
|
||
.rust_manifest = rust_manifest_ptr,
|
||
.lsp_failures = lsp_failures,
|
||
};
|
||
atomic_init(&rc.next_file_idx, 0);
|
||
atomic_init(&rc.lsp_cross_processed, 0);
|
||
atomic_init(&rc.lsp_cross_skipped_no_source, 0);
|
||
/* F4 lazy shared Rust registry: mutex up before workers spawn. */
|
||
atomic_init(&rc.rust_shared_reg, NULL);
|
||
cbm_mutex_init(&rc.rust_shared_mu);
|
||
rc.rust_shared_arena_live = false;
|
||
|
||
/* Sub-phase: Dispatch resolve workers (per-file call/usage resolution, PARALLEL) */
|
||
CBM_PROF_START(t_resolve_dispatch);
|
||
cbm_parallel_for_opts_t opts = {.max_workers = worker_count, .force_pthreads = false};
|
||
cbm_scale_begin(&rc.scale, "parallel_resolve", (long)file_count);
|
||
cbm_parallel_for(worker_count, resolve_worker, &rc, opts);
|
||
cbm_scale_end(&rc.scale);
|
||
CBM_PROF_END_N("parallel_resolve", "1_dispatch_workers_parallel", t_resolve_dispatch,
|
||
file_count);
|
||
for (int i = 0; i < file_count; i++) {
|
||
if (lsp_failures[i] && ctx->pipeline)
|
||
cbm_pipeline_add_file_error(ctx->pipeline, files[i].rel_path, lsp_failures[i],
|
||
"lsp_skipped");
|
||
}
|
||
cbm_free(CBM_MEM_CLASS_OTHER, lsp_failures);
|
||
/* Workers joined: the shared Rust registry (if built) is no longer read.
|
||
* Free its dedicated arena + the lock (registry was self-contained: it strdup'd
|
||
* all QNs, so freeing all_defs afterward is safe). */
|
||
if (rc.rust_shared_arena_live) {
|
||
cbm_arena_destroy(&rc.rust_shared_arena);
|
||
rc.rust_shared_arena_live = false;
|
||
}
|
||
cbm_mutex_destroy(&rc.rust_shared_mu);
|
||
if (rust_manifest_arena_live) {
|
||
cbm_arena_destroy(&rust_manifest_arena);
|
||
}
|
||
|
||
/* Sub-phase: Merge all local edge bufs into main gbuf (SEQUENTIAL) */
|
||
CBM_PROF_START(t_resolve_merge);
|
||
int total_calls = 0;
|
||
int total_usages = 0;
|
||
int total_semantic = 0;
|
||
int total_lsp_overrides = 0;
|
||
for (int i = 0; i < worker_count; i++) {
|
||
if (workers[i].local_edge_buf) {
|
||
cbm_gbuf_merge(ctx->gbuf, workers[i].local_edge_buf);
|
||
total_calls += workers[i].calls_resolved;
|
||
total_usages += workers[i].usages_resolved;
|
||
total_semantic += workers[i].semantic_resolved;
|
||
total_lsp_overrides += workers[i].lsp_overrides;
|
||
cbm_gbuf_free(workers[i].local_edge_buf);
|
||
}
|
||
}
|
||
CBM_PROF_END_N("parallel_resolve", "2_merge_edge_bufs_seq", t_resolve_merge,
|
||
total_calls + total_usages);
|
||
|
||
cbm_aligned_free(workers);
|
||
|
||
if (atomic_load(&ctx->test_declarations_failed)) {
|
||
cbm_log_error("pipeline.err", "phase", "test_declarations_resolve");
|
||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||
}
|
||
|
||
/* Go-style implicit interface satisfaction (needs full graph, serial) */
|
||
int go_impl = cbm_pipeline_implements_go(ctx);
|
||
|
||
/* Explicit-language override detection (same serial full-graph tail the
|
||
* sequential pipeline runs — the two venues must emit identical graphs). */
|
||
total_lsp_overrides += cbm_pipeline_override_explicit(ctx);
|
||
|
||
if (atomic_load(ctx->cancelled)) {
|
||
return CBM_NOT_FOUND;
|
||
}
|
||
|
||
/* Summary metric that replaces the removed `pass.timing pass=lsp_cross`
|
||
* log line — surfaces how many files the fused cross-file LSP step
|
||
* actually processed vs skipped (e.g. because their source bytes
|
||
* were not retained at extract time due to the per-file/total cap). */
|
||
cbm_log_info(
|
||
"parallel.resolve.lsp_cross_done", "files_processed",
|
||
itoa_log(atomic_load_explicit(&rc.lsp_cross_processed, memory_order_relaxed)),
|
||
"files_skipped_no_source",
|
||
itoa_log(atomic_load_explicit(&rc.lsp_cross_skipped_no_source, memory_order_relaxed)),
|
||
"defs_total", itoa_log(def_count));
|
||
|
||
/* Cross-LSP cost, NORMALISED (#1669). Wall time alone cannot distinguish
|
||
* "big repo" from "superlinear pass"; us_per_file can. For a pass that is
|
||
* linear in file count this number is roughly CONSTANT across repo sizes.
|
||
* When cross-file LSP rebuilds its registry from a corpus-scaled def set,
|
||
* per-file cost tracks defs_total instead — measured 35ms/file at 5.8k
|
||
* files and 209ms/file at 46k on the same tree, which is the O(n^2) that
|
||
* cost a 6x java regression and took an 11-corpus two-binary A/B to find.
|
||
* Emitted next to defs_total so one grep on two differently sized repos
|
||
* answers it. */
|
||
int cross_files = atomic_load_explicit(&rc.lsp_cross_processed, memory_order_relaxed);
|
||
uint64_t cross_us = atomic_load_explicit(&rc.time_ns_cross_lsp, memory_order_relaxed) / 1000ULL;
|
||
if (cross_files > 0) {
|
||
char cf_buf[CBM_SZ_32];
|
||
char nf_buf[CBM_SZ_32];
|
||
char cu_buf[CBM_SZ_32];
|
||
char pk_buf[CBM_SZ_32];
|
||
snprintf(cf_buf, sizeof(cf_buf), "%llu",
|
||
(unsigned long long)(cross_us / (uint64_t)cross_files));
|
||
snprintf(nf_buf, sizeof(nf_buf), "%d", cross_files);
|
||
snprintf(cu_buf, sizeof(cu_buf), "%llu", (unsigned long long)(cross_us / 1000ULL));
|
||
snprintf(pk_buf, sizeof(pk_buf), "%llu",
|
||
(unsigned long long)(def_count > 0
|
||
? (cross_us * 1000ULL) /
|
||
((uint64_t)cross_files * (uint64_t)def_count)
|
||
: 0ULL));
|
||
cbm_log_info("parallel.resolve.cross_lsp_cost", "cross_lsp_ms", cu_buf, "files", nf_buf,
|
||
"us_per_file", cf_buf, "defs_total", itoa_log(def_count),
|
||
"us_per_file_per_kdef", pk_buf);
|
||
|
||
/* What the per-file registry build actually cost. defs_per_file is the
|
||
* lever: if it tracks defs_total rather than the file's own module plus
|
||
* imports, the module filter is not containing the work and cross-file
|
||
* LSP is O(files x corpus_defs). */
|
||
uint64_t reg_defs = 0;
|
||
uint64_t reg_files = 0;
|
||
uint64_t flt_files = 0;
|
||
uint64_t flt_failed = 0;
|
||
cbm_pxc_filter_stats(®_defs, ®_files, &flt_files, &flt_failed);
|
||
if (reg_files > 0) {
|
||
char rd_buf[CBM_SZ_32];
|
||
char ff_buf[CBM_SZ_32];
|
||
char fp_buf[CBM_SZ_32];
|
||
snprintf(rd_buf, sizeof(rd_buf), "%llu", (unsigned long long)(reg_defs / reg_files));
|
||
snprintf(ff_buf, sizeof(ff_buf), "%llu", (unsigned long long)flt_failed);
|
||
snprintf(fp_buf, sizeof(fp_buf), "%llu", (unsigned long long)flt_files);
|
||
cbm_log_info("parallel.resolve.perfile_registry", "defs_per_file", rd_buf, "defs_total",
|
||
itoa_log(def_count), "filtered_files", fp_buf, "filter_failed", ff_buf);
|
||
}
|
||
}
|
||
|
||
cbm_log_info("parallel.resolve.done", "calls", itoa_log(total_calls), "usages",
|
||
itoa_log(total_usages), "semantic", itoa_log(total_semantic + go_impl),
|
||
"lsp_overrides", itoa_log(total_lsp_overrides));
|
||
|
||
/* Per-sub-phase breakdown so we stop guessing about hot paths.
|
||
* Numbers are summed across workers (total CPU-ms, not wall-time).
|
||
* Split into multiple log lines because itoa_log uses a 4-slot TLS
|
||
* ring buffer — more than 4 values per log_info call would alias
|
||
* each other (we hit that bug in the first profiling run). */
|
||
char loop_buf[32], visits_buf[32];
|
||
snprintf(
|
||
loop_buf, sizeof(loop_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_total_loop, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(visits_buf, sizeof(visits_buf), "%d",
|
||
atomic_load_explicit(&rc.total_files_visited, memory_order_relaxed));
|
||
cbm_log_info("parallel.resolve.phase_summary", "total_loop_cpu_ms", loop_buf, "files_visited",
|
||
visits_buf);
|
||
|
||
char imp_buf[32], xls_buf[32], cal_buf[32];
|
||
snprintf(
|
||
imp_buf, sizeof(imp_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_import_map, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(
|
||
xls_buf, sizeof(xls_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_cross_lsp, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(cal_buf, sizeof(cal_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_calls, memory_order_relaxed) /
|
||
1000000ULL));
|
||
cbm_log_info("parallel.resolve.phase_ms_a", "import_map", imp_buf, "cross_lsp", xls_buf,
|
||
"resolve_calls", cal_buf);
|
||
|
||
char use_buf[32], thr_buf[32], rw_buf[32], sem_buf[32];
|
||
snprintf(use_buf, sizeof(use_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_usages, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(thr_buf, sizeof(thr_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_throws, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(rw_buf, sizeof(rw_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_rw, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(sem_buf, sizeof(sem_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_semantic, memory_order_relaxed) /
|
||
1000000ULL));
|
||
cbm_log_info("parallel.resolve.phase_ms_b", "resolve_usages", use_buf, "resolve_throws",
|
||
thr_buf, "resolve_rw", rw_buf, "resolve_semantic", sem_buf);
|
||
|
||
char src_buf[32], lsp_buf[32], rsv_buf[32], hnt_buf[32], tgt_buf[32], emt_buf[32];
|
||
snprintf(
|
||
src_buf, sizeof(src_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_rc_source, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(
|
||
lsp_buf, sizeof(lsp_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_rc_lsp_lookup, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(
|
||
rsv_buf, sizeof(rsv_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_rc_resolve, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(hnt_buf, sizeof(hnt_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_rc_hint, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(
|
||
tgt_buf, sizeof(tgt_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_rc_target, memory_order_relaxed) /
|
||
1000000ULL));
|
||
snprintf(emt_buf, sizeof(emt_buf), "%llu",
|
||
(unsigned long long)(atomic_load_explicit(&rc.time_ns_rc_emit, memory_order_relaxed) /
|
||
1000000ULL));
|
||
cbm_log_info("parallel.resolve.calls_breakdown", "find_source", src_buf, "lsp_lookup", lsp_buf,
|
||
"resolve", rsv_buf);
|
||
cbm_log_info("parallel.resolve.calls_breakdown2", "field_hint", hnt_buf, "find_target", tgt_buf,
|
||
"emit_edge", emt_buf);
|
||
|
||
/* Candidate-scan cost (#1669). `per_lookup` is the diagnostic that matters:
|
||
* it is a property of the CORPUS, not of the machine, so it is comparable
|
||
* across runs and versions. If it grows with repo size, the tail-match scan
|
||
* is turning resolve superlinear — which is precisely what took an
|
||
* 11-corpus two-binary A/B to establish the first time. `fallback_rows`
|
||
* was already counted but, until now, readable only from a test. */
|
||
uint64_t tail_lookups = atomic_load_explicit(&g_lsp_tail_lookups, memory_order_relaxed);
|
||
uint64_t tail_cands = atomic_load_explicit(&g_lsp_tail_candidates, memory_order_relaxed);
|
||
char tl_buf[CBM_SZ_32];
|
||
char tc_buf[CBM_SZ_32];
|
||
char tp_buf[CBM_SZ_32];
|
||
char fb_buf[CBM_SZ_32];
|
||
snprintf(tl_buf, sizeof(tl_buf), "%llu", (unsigned long long)tail_lookups);
|
||
snprintf(tc_buf, sizeof(tc_buf), "%llu", (unsigned long long)tail_cands);
|
||
snprintf(tp_buf, sizeof(tp_buf), "%llu",
|
||
(unsigned long long)(tail_lookups ? tail_cands / tail_lookups : 0ULL));
|
||
snprintf(fb_buf, sizeof(fb_buf), "%llu",
|
||
(unsigned long long)atomic_load_explicit(&g_lsp_linear_fallback_rows,
|
||
memory_order_relaxed));
|
||
cbm_log_info("parallel.resolve.scan_cost", "tail_lookups", tl_buf, "tail_candidates", tc_buf,
|
||
"per_lookup", tp_buf, "fallback_rows", fb_buf);
|
||
return 0;
|
||
}
|