diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..f44b5f3c2 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "codebase-memory", + "description": "The codebase-memory-mcp code knowledge graph server, as a Claude Code plugin.", + "owner": { + "name": "DeusData", + "url": "https://github.com/DeusData" + }, + "plugins": [ + { + "name": "codebase-memory", + "source": "./plugin", + "description": "Codebase knowledge graph for AI agents: sub-ms queries, 99% fewer tokens." + } + ] +} diff --git a/.gitignore b/.gitignore index 1533c93e0..c388fa360 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ Thumbs.db # MCP config (user-local, generated by install command) .mcp.json +# ...except the Claude Code plugin's server definition, which is published +!plugin/.mcp.json # MCP Registry auth tokens .mcpregistry_* diff --git a/README.md b/README.md index 79d03faef..5a432dabe 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,30 @@ Working in a clone? Use `.` in place of the flake URL, e.g. `nix run .#codebase- You: "Install this MCP server: https://github.com/DeusData/codebase-memory-mcp" ``` +### Install via Claude Code plugin + +This repository is also a Claude Code plugin marketplace, so you can find and +add the server from inside Claude Code: + +```bash +claude plugin marketplace add DeusData/codebase-memory-mcp +claude plugin install codebase-memory@codebase-memory +``` + +The plugin registers the MCP server only. It starts the server with +`npx -y codebase-memory-mcp@`, pinned to the current release; the first +start downloads that release's binary through the npm package, which verifies +it against the release checksums. The skill, the three graph agents and the +context hooks are not part of the plugin: they come from +`codebase-memory-mcp install`. + +Use one or the other. `install` registers the same server for Claude Code, so +with both you run it twice, under two tool namespaces +(`mcp__codebase-memory-mcp__*` and +`mcp__plugin_codebase-memory_codebase-memory-mcp__*`). Choose `install` for the +full Claude Code setup; choose the plugin to add just the server without +running an installer. + ### Build from Source
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0981f6fc2..f2ebf78eb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -245,6 +245,15 @@ root because they are too broad or too sensitive to index as a unit: - a credential directory at any depth — `.ssh`, `.aws`, `.gnupg`, `.kube`, `.docker`, `.netrc`, `.git-credentials`, `.password-store`, macOS `Keychains`. +A single top-level directory can still be indexed as one root on purpose, for +example a container or build machine that keeps its project in `/work`, `/app` or +`/src`, by approving that exact path in a terminal: +`codebase-memory-mcp allow-root --approve-broad /work`. The approval is recorded +like any other allowed root, listed as `(approved broad)` by `allow-root --list`, +and covers no other top-level directory; as with any recorded root, indexing is +then confined to the recorded roots. Filesystem, drive and share roots cannot be +approved. + Two limits are worth stating plainly. This constrains *scope*, not *sensitivity*: inside a root that is allowed, every file the process can read may be indexed and later returned. And the credential list is a denylist, so it diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index dd1559c1b..98d403975 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -298,9 +298,10 @@ typedef struct { * are bound to the owning result's source identity before cross-file use. */ uint32_t test_name_start_byte, test_name_end_byte; uint32_t test_body_start_byte, test_body_end_byte; - /* Documents only: facts a document definition adds to its node's - * properties, as a NULL-terminated key, value, key, value, ... list (an - * ADR's id, status, date, deciders, title). Keys are fixed literals; the + /* Facts a definition adds to its node's properties, as a NULL-terminated + * key, value, key, value, ... list: a document's (an ADR's id, status, + * date, deciders, title), or CBM_PROP_GENERATED on a member the compiler + * writes and the source never spells. Keys are fixed literals; the * values are escaped where the properties are written. Tail field: * zero-init stays valid. */ const char **extra_props; @@ -321,6 +322,13 @@ typedef struct { const char *alias_target; } CBMDefinition; +/* extra_props key of a generated member, one the compiler writes and the + * source never spells: a Java record's implicit accessor carries + * `generated: record`. The node keeps the property, and the pipeline registers + * such a definition by qualified name only, so no short-name guess can land on + * it (pipeline.h cbm_def_props_generated). */ +#define CBM_PROP_GENERATED "generated" + /* Argument captured from a call expression */ typedef struct { const char *expr; // raw expression text ("payload.info", "MY_URL", "'hello'") diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 7797152a5..93f606a50 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -230,6 +230,7 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch const CBMLangSpec *spec); static TSNode find_class_body(TSNode class_node, CBMLanguage lang); static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *class_qn); +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn); static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); // --- Helpers --- @@ -8317,6 +8318,10 @@ static void emit_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *s extract_enum_members(ctx, node, class_qn); } + if (ctx->language == CBM_LANG_JAVA && strcmp(kind, "record_declaration") == 0) { + extract_record_accessors(ctx, node, class_qn); + } + // Extract methods inside the class int methods_from = ctx->result->defs.count; extract_class_methods(ctx, node, class_qn, spec); @@ -9642,6 +9647,121 @@ static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *cl } } +/* Whether the record body declares this accessor itself (JLS §8.10.3 lets a + * record override any of them), so the implicit one is not emitted twice. */ +static bool record_declares_accessor(CBMExtractCtx *ctx, TSNode node, const char *comp_name) { + TSNode body = find_class_body(node, ctx->language); + if (ts_node_is_null(body)) { + return false; + } + uint32_t mc = ts_node_named_child_count(body); + for (uint32_t mi = 0; mi < mc; mi++) { + TSNode member = ts_node_named_child(body, mi); + if (strcmp(ts_node_type(member), "method_declaration") != 0) { + continue; + } + TSNode mname = ts_node_child_by_field_name(member, TS_FIELD("name")); + if (ts_node_is_null(mname)) { + continue; + } + char *name = cbm_node_text(ctx->arena, mname, ctx->source); + if (!name || strcmp(name, comp_name) != 0) { + continue; + } + /* Same name, no parameters — that is the accessor. */ + TSNode params = ts_node_child_by_field_name(member, TS_FIELD("parameters")); + if (ts_node_is_null(params)) { + return true; + } + /* Comments are named extras, not parameters. */ + bool has_parameters = false; + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + if (!ts_node_is_extra(ts_node_named_child(params, pi))) { + has_parameters = true; + break; + } + } + if (!has_parameters) { + return true; + } + } + return false; +} + +/* Java records: the compiler writes one accessor per component, so `p.x()` + * calls a method that appears nowhere in the source. Without a definition for + * it the call has nothing to resolve to and falls through to the project-wide + * name registry, which binds it to the first `x` in the tree — typically an + * unrelated class's private field. Emitting the accessors makes the record's + * own API visible, exactly as the language defines it (JLS §8.10.3). */ +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn) { + CBMArena *a = ctx->arena; + TSNode params = ts_node_child_by_field_name(node, TS_FIELD("parameters")); + if (ts_node_is_null(params)) { + return; + } + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + TSNode comp = ts_node_named_child(params, pi); + bool spread = strcmp(ts_node_type(comp), "spread_parameter") == 0; + if (!spread && strcmp(ts_node_type(comp), "formal_parameter") != 0) { + continue; + } + TSNode cname = ts_node_child_by_field_name(comp, TS_FIELD("name")); + TSNode ctype = ts_node_child_by_field_name(comp, TS_FIELD("type")); + if (spread) { + /* Java varargs have an unnamed type child and a nested declarator. */ + TSNode decl = cbm_find_child_by_kind(comp, "variable_declarator"); + if (ts_node_is_null(decl)) { + continue; + } + cname = ts_node_child_by_field_name(decl, TS_FIELD("name")); + uint32_t nc = ts_node_named_child_count(comp); + for (uint32_t ci = 0; ci < nc; ci++) { + TSNode child = ts_node_named_child(comp, ci); + if (!ts_node_is_extra(child) && strcmp(ts_node_type(child), "modifiers") != 0) { + ctype = child; + break; + } + } + } + if (ts_node_is_null(cname)) { + continue; + } + char *comp_name = cbm_node_text(a, cname, ctx->source); + if (!comp_name || !comp_name[0] || record_declares_accessor(ctx, node, comp_name)) { + continue; + } + CBMDefinition adef; + memset(&adef, 0, sizeof(adef)); + adef.name = comp_name; + adef.qualified_name = cbm_arena_sprintf(a, "%s.%s", class_qn, comp_name); + adef.label = "Method"; + adef.file_path = ctx->rel_path; + adef.start_line = ts_node_start_point(comp).row + TS_LINE_OFFSET; + adef.end_line = ts_node_end_point(comp).row + TS_LINE_OFFSET; + adef.lines = 1; + adef.parent_class = class_qn; + adef.is_exported = true; + if (!ts_node_is_null(ctype)) { + const char *type = cbm_node_text(a, ctype, ctx->source); + adef.return_type = spread && type ? cbm_arena_sprintf(a, "%s[]", type) : type; + } + /* The compiler writes this member and the source never spells it: its + * node says so, and the pipeline registers it by qualified name only, + * so no short-name guess can land on it. */ + const char **kv = (const char **)cbm_arena_alloc(a, CBM_SZ_3 * sizeof(char *)); + if (kv) { + kv[0] = CBM_PROP_GENERATED; + kv[SKIP_ONE] = "record"; + kv[PAIR_LEN] = NULL; + } + adef.extra_props = kv; + cbm_defs_push(&ctx->result->defs, a, adef); + } +} + /* Resolve the identifier node from a destructure pattern child. * pair_pattern → value field; shorthand/identifier → itself; others → first named child. */ static TSNode destructure_ident(TSNode pat_child) { diff --git a/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index db08c43d2..a009b7964 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -1789,9 +1789,25 @@ static uint32_t active_lexical_scope_id(const WalkState *state) { static uint32_t lexical_ancestor_of_kind(const WalkState *state, uint32_t start_id, bool want_function, bool want_block); -static bool python_default_value_reference(TSNode node) { - for (TSNode parent = ts_node_parent(node); !ts_node_is_null(parent); - parent = ts_node_parent(parent)) { +/* One step up: on the occurrence cursor when there is one, else with + * ts_node_parent, which descends from the ROOT, so a climb costs O(depth^2). */ +static TSNode usage_parent_step(TSTreeCursor *cursor, TSNode current) { + if (cursor) { + return ts_tree_cursor_goto_parent(cursor) ? ts_tree_cursor_current_node(cursor) + : (TSNode){0}; + } + usage_slow_parent_fallback_test_note(); + return ts_node_parent(current); +} + +/* Asked for every Python usage, and a deep expression puts O(depth) parents + * above each one. A tree without errors climbs them on the walk cursor, like + * python_scope_directive_kind; a tree with errors keeps ts_node_parent. */ +static bool python_default_value_reference(CBMExtractCtx *ctx, WalkState *state, TSNode node) { + TSTreeCursor *cursor = + ts_node_has_error(ctx->root) ? NULL : reset_occurrence_cursor(state, node); + for (TSNode parent = usage_parent_step(cursor, node); !ts_node_is_null(parent); + parent = usage_parent_step(cursor, parent)) { const char *kind = ts_node_type(parent); /* A nested executable scope inside the default owns its own lookups; * only a direct default expression is evaluated in the declaring @@ -1812,10 +1828,10 @@ static bool python_default_value_reference(TSNode node) { return false; } -static uint32_t usage_lexical_scope_id_for_node(CBMExtractCtx *ctx, const WalkState *state, - TSNode node) { +static uint32_t usage_lexical_scope_id_for_node(CBMExtractCtx *ctx, WalkState *state, TSNode node) { uint32_t scope_id = active_lexical_scope_id(state); - if (!ctx || ctx->language != CBM_LANG_PYTHON || !python_default_value_reference(node)) { + if (!ctx || ctx->language != CBM_LANG_PYTHON || + !python_default_value_reference(ctx, state, node)) { return scope_id; } uint32_t function_id = lexical_ancestor_of_kind(state, scope_id, true, false); @@ -1891,8 +1907,8 @@ static bool ensure_lexical_binding_capacity(WalkState *state) { } static bool lexical_ancestor_kind(TSNode node, const char *kind) { - for (TSNode parent = ts_node_parent(node); !ts_node_is_null(parent); - parent = ts_node_parent(parent)) { + for (TSNode parent = usage_parent_step(NULL, node); !ts_node_is_null(parent); + parent = usage_parent_step(NULL, parent)) { if (strcmp(ts_node_type(parent), kind) == 0) { return true; } @@ -1900,6 +1916,34 @@ static bool lexical_ancestor_kind(TSNode node, const char *kind) { return false; } +/* Is `node` a name listed by a Python global/nonlocal statement? The grammar + * makes those names direct children of the statement, so in a tree without + * errors one parent step on the walk cursor answers it. The full climb above + * steps with ts_node_parent, which descends from the ROOT each time: O(depth^2) + * per identifier, asked for every Python identifier, so one deep arithmetic + * expression went cubic. Error recovery can put a name under an ERROR child of + * the statement, so a tree with errors keeps the full climb. */ +static CBMPythonDirectiveKind python_scope_directive_kind(CBMExtractCtx *ctx, WalkState *state, + TSNode node) { + if (ts_node_has_error(ctx->root)) { + if (lexical_ancestor_kind(node, "global_statement")) { + return CBM_PYTHON_DIRECTIVE_GLOBAL; + } + return lexical_ancestor_kind(node, "nonlocal_statement") ? CBM_PYTHON_DIRECTIVE_NONLOCAL + : 0; + } + TSNode parent; + const char *field; + if (!occurrence_parent(reset_occurrence_cursor(state, node), node, &parent, &field)) { + return 0; + } + const char *kind = ts_node_type(parent); + if (strcmp(kind, "global_statement") == 0) { + return CBM_PYTHON_DIRECTIVE_GLOBAL; + } + return strcmp(kind, "nonlocal_statement") == 0 ? CBM_PYTHON_DIRECTIVE_NONLOCAL : 0; +} + static TSNode declared_function_name_owner(TSNode node, const CBMLangSpec *spec) { for (TSNode parent = ts_node_parent(node); !ts_node_is_null(parent); parent = ts_node_parent(parent)) { @@ -2246,13 +2290,11 @@ static void record_lexical_binding(CBMExtractCtx *ctx, WalkState *state, TSNode TSNode function_declaration = declared_function_name_owner(node, spec); TSNode class_declaration = declared_class_name_owner(node, spec); - if (ctx->language == CBM_LANG_PYTHON && (lexical_ancestor_kind(node, "global_statement") || - lexical_ancestor_kind(node, "nonlocal_statement"))) { + CBMPythonDirectiveKind scope_directive = + ctx->language == CBM_LANG_PYTHON ? python_scope_directive_kind(ctx, state, node) : 0; + if (scope_directive) { uint32_t function_id = lexical_ancestor_of_kind(state, current_id, true, false); - CBMPythonDirectiveKind directive = lexical_ancestor_kind(node, "global_statement") - ? CBM_PYTHON_DIRECTIVE_GLOBAL - : CBM_PYTHON_DIRECTIVE_NONLOCAL; - record_python_directive(state, function_id, name, directive); + record_python_directive(state, function_id, name, scope_directive); return; } @@ -2719,8 +2761,7 @@ void handle_usages(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Wal } bool python_scope_directive = - ctx->language == CBM_LANG_PYTHON && (lexical_ancestor_kind(node, "global_statement") || - lexical_ancestor_kind(node, "nonlocal_statement")); + ctx->language == CBM_LANG_PYTHON && python_scope_directive_kind(ctx, state, node) != 0; if (python_scope_directive || is_binding_occurrence(ctx, node, spec, state)) { char *binding_name = reference_name(ctx, node); if (binding_name && binding_name[0] && !cbm_is_keyword(binding_name, ctx->language)) { diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index f71529a9d..74c1980e2 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -6179,10 +6179,36 @@ static void py_split_field_defs(CBMArena *arena, const char *field_defs, const c *types_out = types; } +/* Qualified names of reg->types[0..noted), for a caller that registers into a + * registry it does not finalize until the end (cbm_py_build_cross_registry): + * that registry's own lookup scans every type. qns == NULL probes the + * registry instead. */ +typedef struct { + CBMHashTable *qns; + int noted; +} PyKnownTypes; + +/* Note the types added since the last call. Run after every add_type, so an + * add that failed silently cannot desync set and registry. If the set cannot + * grow it is dropped and probes fall back to the registry. */ +static void py_note_known_types(PyKnownTypes *known, const CBMTypeRegistry *reg) { + for (; known && known->qns && known->noted < reg->type_count; known->noted++) { + const char *qn = reg->types[known->noted].qualified_name; + if (!qn || cbm_ht_has(known->qns, qn)) + continue; + cbm_ht_set(known->qns, qn, (void *)(uintptr_t)(known->noted + 1)); + if (!cbm_ht_has(known->qns, qn)) { + cbm_ht_free(known->qns); + known->qns = NULL; + } + } +} + /* Build a registry from CBMLSPDef[] supplied by the caller — covers both - * the source file's own defs and cross-file referenced defs. */ + * the source file's own defs and cross-file referenced defs. known is NULL + * except for the shared Tier-2 build. */ static bool py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRegistry *reg, - CBMLSPDef *defs, int def_count) { + CBMLSPDef *defs, int def_count, PyKnownTypes *known) { /* Pass 1: types only — the method pass probes the registry per Method def * (receiver auto-registration), which is a LINEAR scan pre-finalize: * O(methods x types) per file (same quadratic as php_register_lsp_defs; @@ -6206,6 +6232,7 @@ static bool py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe } py_split_field_defs(arena, d->field_defs, &rt.field_names, &rt.field_types); cbm_registry_add_type(reg, rt); + py_note_known_types(known, reg); } } /* idx_arena == NULL skips the mid-build finalize: the tier-2 builder @@ -6259,13 +6286,17 @@ static bool py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe if (strcmp(d->label, "Method") == 0 && d->receiver_type && d->receiver_type[0]) { rf.receiver_type = d->receiver_type; /* borrowed */ - if (!cbm_registry_lookup_type(reg, rf.receiver_type)) { + bool recv_known = known && known->qns + ? cbm_ht_has(known->qns, rf.receiver_type) + : cbm_registry_lookup_type(reg, rf.receiver_type) != NULL; + if (!recv_known) { CBMRegisteredType auto_t; memset(&auto_t, 0, sizeof(auto_t)); auto_t.qualified_name = rf.receiver_type; const char *dot = strrchr(d->receiver_type, '.'); auto_t.short_name = dot ? dot + 1 : rf.receiver_type; /* borrowed substring */ cbm_registry_add_type(reg, auto_t); + py_note_known_types(known, reg); } } cbm_registry_add_func(reg, rf); @@ -6277,7 +6308,7 @@ static bool py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe #ifdef CBM_ENABLE_TEST_SEAMS bool cbm_py_lsp_test_register_defs(CBMArena *arena, CBMTypeRegistry *reg, CBMLSPDef *defs, int def_count) { - return py_register_lsp_defs(arena, NULL, reg, defs, def_count); + return py_register_lsp_defs(arena, NULL, reg, defs, def_count, NULL); } #endif @@ -6774,7 +6805,7 @@ CBMLSPStatus cbm_run_py_lsp_cross_status(CBMArena *arena, const char *source, in py_lsp_init_for_stage(&ctx, arena, source, source_len, ®, module_qn, out, "cross"); if (ctx.eval_failure) goto cross_cleanup; - if (!py_register_lsp_defs(arena, &idx_arena, ®, defs, def_count)) { + if (!py_register_lsp_defs(arena, &idx_arena, ®, defs, def_count, NULL)) { ctx.eval_failure = CBM_LSP_SCOPE_FAILED; goto cross_cleanup; } @@ -6813,15 +6844,27 @@ CBMTypeRegistry *cbm_py_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, i cbm_registry_init(reg, arena); cbm_python_stdlib_register(reg, arena); + /* The registry is not finalized until the end, so probing it for each + * Method's receiver scanned every type: O(methods x types), single-threaded + * (~54 s of lsp_cross_prepare on a large Python tree). Probe a set of + * the registered type QNs instead; registration order is unchanged. Sized + * for the stdlib seed, not def_count (the mixed all-language count). NULL + * on allocation failure: the registry probe runs as before. */ + PyKnownTypes known = {cbm_ht_create((uint32_t)reg->type_count), 0}; + py_note_known_types(&known, reg); + /* Filter to Python defs only — defs[] is mixed-language all_defs. */ for (int i = 0; i < def_count; i++) { CBMLSPDef *d = &defs[i]; if (d->lang != CBM_LANG_PYTHON) continue; /* Reuse the existing register fn on a single-def slice (n=1 inline). */ - if (!py_register_lsp_defs(arena, NULL, reg, d, 1)) + if (!py_register_lsp_defs(arena, NULL, reg, d, 1, &known)) { + cbm_ht_free(known.qns); return NULL; /* caller owns the shared arena and other registries */ + } } + cbm_ht_free(known.qns); py_mark_ambiguous_callable_bindings(reg, arena); cbm_registry_build_type_qn_order(reg, arena); diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 000000000..0960a2b71 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "codebase-memory", + "version": "0.12.1", + "description": "Codebase knowledge graph for AI agents: sub-ms queries, 99% fewer tokens.", + "author": { + "name": "DeusData", + "url": "https://github.com/DeusData" + } +} diff --git a/plugin/.mcp.json b/plugin/.mcp.json new file mode 100644 index 000000000..07fbe13c1 --- /dev/null +++ b/plugin/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "codebase-memory-mcp": { + "type": "stdio", + "command": "npx", + "args": ["-y", "codebase-memory-mcp@0.12.1"] + } + } +} diff --git a/src/cli/cli.c b/src/cli/cli.c index d53a53d41..771225e55 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -20,6 +20,7 @@ #include "daemon/runtime.h" #include "daemon/version_cohort.h" #include "foundation/compat.h" +#include "foundation/compat_fs.h" #include "foundation/platform.h" #include "foundation/constants.h" #include "foundation/json_writer.h" @@ -2718,12 +2719,8 @@ int cbm_remove_zed_mcp_owned(const char *binary_path, const char *config_path) { /* ── Agent detection ──────────────────────────────────────────── */ static bool dir_exists(const char *path) { - struct stat st; -#ifndef _WIN32 - return lstat(path, &st) == 0 && S_ISDIR(st.st_mode); -#else - return stat(path, &st) == 0 && S_ISDIR(st.st_mode); -#endif + cbm_path_info_t info = {0}; + return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_directory; } /* Resolve the Claude Code config dir. diff --git a/src/foundation/workspace.c b/src/foundation/workspace.c index 952e9aba0..c658d111c 100644 --- a/src/foundation/workspace.c +++ b/src/foundation/workspace.c @@ -392,8 +392,18 @@ enum { WS_LINE_MAX = 4096 }; /* A line may carry a leading '!' meaning "a person explicitly approved this * sensitive root". The marker is stored rather than inferred so re-classifying * later (a new credential name is added to the list, say) cannot silently - * upgrade an ordinary grant into a sensitive one. */ + * upgrade an ordinary grant into a sensitive one. A leading '^' is the same + * kind of record for a root refused as too broad (allow-root --approve-broad). + * Neither character can begin a canonical absolute path, so a marked line can + * never be misread as an ordinary grant. */ static const char WS_SENSITIVE_MARK = '!'; +static const char WS_BROAD_MARK = '^'; + +typedef enum { + WS_GRANT_ORDINARY = 0, + WS_GRANT_SENSITIVE, /* explicit approval of a CBM_WS_DENY_SENSITIVE root */ + WS_GRANT_BROAD, /* explicit approval of a CBM_WS_DENY_TOO_SHALLOW root */ +} ws_grant_kind_t; bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz) { if (!cache_dir || !cache_dir[0] || !out || out_sz == 0) { @@ -406,7 +416,8 @@ bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz) { /* Walk the grant file, invoking visit for each entry. visit returns true to stop. * Returns the number of entries seen. */ static int ws_grant_walk(const char *cache_dir, - bool (*visit)(const char *root, bool sensitive, void *ctx), void *ctx) { + bool (*visit)(const char *root, ws_grant_kind_t kind, void *ctx), + void *ctx) { char store[WS_LINE_MAX]; if (!cbm_workspace_grant_path(cache_dir, store, sizeof(store))) { return 0; @@ -425,13 +436,18 @@ static int ws_grant_walk(const char *cache_dir, if (len == 0 || line[0] == '#') { continue; } - bool sensitive = line[0] == WS_SENSITIVE_MARK; - const char *root = sensitive ? line + 1 : line; + ws_grant_kind_t kind = WS_GRANT_ORDINARY; + if (line[0] == WS_SENSITIVE_MARK) { + kind = WS_GRANT_SENSITIVE; + } else if (line[0] == WS_BROAD_MARK) { + kind = WS_GRANT_BROAD; + } + const char *root = kind == WS_GRANT_ORDINARY ? line : line + 1; if (!root[0]) { continue; } seen++; - if (visit && visit(root, sensitive, ctx)) { + if (visit && visit(root, kind, ctx)) { break; } } @@ -443,14 +459,21 @@ typedef struct { const char *candidate; bool contained; /* candidate is at or below some granted root */ bool exact_sensitive; /* candidate is itself a grant marked sensitive */ + bool exact_broad; /* candidate is itself a grant marked broad */ } ws_match_t; -static bool ws_match_visit(const char *root, bool sensitive, void *ctx) { +static bool ws_match_visit(const char *root, ws_grant_kind_t kind, void *ctx) { ws_match_t *m = ctx; if (cbm_path_within_root(root, m->candidate)) { m->contained = true; - if (sensitive && ws_paths_equal(root, m->candidate)) { - m->exact_sensitive = true; + /* Exact equality, never a prefix: an approval names one root, and must + * not quietly cover every root below it. */ + if (kind != WS_GRANT_ORDINARY && ws_paths_equal(root, m->candidate)) { + if (kind == WS_GRANT_SENSITIVE) { + m->exact_sensitive = true; + } else { + m->exact_broad = true; + } } } return false; /* visit every entry: a later one may be the exact match */ @@ -462,10 +485,15 @@ typedef struct { size_t used; } ws_list_t; -static bool ws_list_visit(const char *root, bool sensitive, void *ctx) { +static bool ws_list_visit(const char *root, ws_grant_kind_t kind, void *ctx) { ws_list_t *l = ctx; - int n = snprintf(l->out + l->used, l->out_sz - l->used, "%s%s\n", - sensitive ? "(approved) " : "", root); + const char *label = ""; + if (kind == WS_GRANT_SENSITIVE) { + label = "(approved) "; + } else if (kind == WS_GRANT_BROAD) { + label = "(approved broad) "; + } + int n = snprintf(l->out + l->used, l->out_sz - l->used, "%s%s\n", label, root); if (n > 0 && (size_t)n < l->out_sz - l->used) { l->used += (size_t)n; } @@ -481,9 +509,49 @@ bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz) { return ws_grant_walk(cache_dir, ws_list_visit, &l) > 0; } +/* The allow-root flag that records an explicit approval for a refused verdict, + * or NULL when nothing can lift it (absolute denials). */ +static const char *ws_verdict_approval_flag(cbm_ws_verdict_t verdict) { + if (verdict == CBM_WS_DENY_TOO_SHALLOW) { + return "--approve-broad"; + } + if (cbm_workspace_verdict_is_overridable(verdict)) { + return "--approve-sensitive"; + } + return NULL; +} + +/* True when a refused verdict carries the explicit approval that lifts it. Each + * flag lifts only its own verdict: --approve-sensitive never widens breadth and + * --approve-broad never widens sensitivity; absolute denials lift for neither. + * Refuses by default and names the flag, rather than asking a question whose + * answer we cannot authenticate. */ +static bool ws_grant_verdict_approved(cbm_ws_verdict_t verdict, bool approve_sensitive, + bool approve_broad, char *err, size_t err_sz) { + bool approved = false; + if (verdict == CBM_WS_DENY_TOO_SHALLOW) { + approved = approve_broad; + } else if (cbm_workspace_verdict_is_overridable(verdict)) { + approved = approve_sensitive; + } + if (approved) { + return true; + } + if (err) { + const char *flag = ws_verdict_approval_flag(verdict); + if (flag) { + snprintf(err, err_sz, "%s; re-run with %s if that is intended", + cbm_workspace_verdict_reason(verdict), flag); + } else { + snprintf(err, err_sz, "%s", cbm_workspace_verdict_reason(verdict)); + } + } + return false; +} + bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, - const char *canonical_path, bool approve_sensitive, char *err, - size_t err_sz) { + const char *canonical_path, bool approve_sensitive, bool approve_broad, + char *err, size_t err_sz) { if (err && err_sz) { err[0] = '\0'; } @@ -495,33 +563,22 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, } cbm_ws_verdict_t verdict = cbm_workspace_classify_root(canonical_path, home_dir, cache_dir); - if (verdict != CBM_WS_ALLOW) { - if (!cbm_workspace_verdict_is_overridable(verdict)) { - if (err) { - snprintf(err, err_sz, "%s", cbm_workspace_verdict_reason(verdict)); - } - return false; - } - if (!approve_sensitive) { - /* Refuse by default and name the flag, rather than asking a question - * whose answer we cannot authenticate. */ - if (err) { - snprintf(err, err_sz, "%s; re-run with --approve-sensitive if that is intended", - cbm_workspace_verdict_reason(verdict)); - } - return false; - } + if (verdict != CBM_WS_ALLOW && + !ws_grant_verdict_approved(verdict, approve_sensitive, approve_broad, err, err_sz)) { + return false; } bool sensitive = verdict == CBM_WS_DENY_SENSITIVE; + bool broad = verdict == CBM_WS_DENY_TOO_SHALLOW; - /* Already present is success, not a duplicate error. A sensitive approval + /* Already present is success, not a duplicate error. An explicit approval * is narrower: an old ordinary grant (or an ordinary ancestor) establishes * containment, but does not record the exact human-approved exception that * cbm_workspace_root_allowed requires. Append that exact marked entry once. */ - ws_match_t existing = {canonical_path, false, false}; + ws_match_t existing = {canonical_path, false, false, false}; (void)ws_grant_walk(cache_dir, ws_match_visit, &existing); - if (existing.contained && (!sensitive || existing.exact_sensitive)) { + if (existing.contained && (!sensitive || existing.exact_sensitive) && + (!broad || existing.exact_broad)) { return true; } @@ -539,7 +596,13 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, } return false; } - (void)fprintf(f, "%s%s\n", sensitive ? "!" : "", canonical_path); + char mark[2] = {'\0', '\0'}; + if (sensitive) { + mark[0] = WS_SENSITIVE_MARK; + } else if (broad) { + mark[0] = WS_BROAD_MARK; + } + (void)fprintf(f, "%s%s\n", mark, canonical_path); bool ok = fclose(f) == 0; if (!ok && err) { snprintf(err, err_sz, "cannot write %s", store); @@ -560,7 +623,7 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir return false; } - ws_match_t match = {canonical_path, false, false}; + ws_match_t match = {canonical_path, false, false, false}; int grants = ws_grant_walk(cache_dir, ws_match_visit, &match); /* A configured root behaves as an additional grant so existing @@ -605,17 +668,23 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir return true; } /* An explicit human approval recorded for exactly this path is the only thing - * that lifts a sensitive refusal. Absolute and shallow refusals cannot be - * lifted at all. */ + * that lifts a sensitive or a too-broad refusal, each by its own marker: a + * broad approval says "index this whole tree on purpose", which is not a + * statement about credentials, and the reverse. Exact match, never a prefix, + * so one broad approval cannot cover every root below it — the "/Users"-style + * breadth the depth rule exists to catch. Absolute refusals cannot be lifted. */ if (verdict == CBM_WS_DENY_SENSITIVE && match.exact_sensitive) { return true; } + if (verdict == CBM_WS_DENY_TOO_SHALLOW && match.exact_broad) { + return true; + } if (err) { - if (cbm_workspace_verdict_is_overridable(verdict)) { + const char *flag = ws_verdict_approval_flag(verdict); + if (flag) { snprintf(err, err_sz, - "%s: %s. To index it anyway, run: codebase-memory-mcp allow-root " - "--approve-sensitive %s", - canonical_path, cbm_workspace_verdict_reason(verdict), canonical_path); + "%s: %s. To index it anyway, run: codebase-memory-mcp allow-root %s %s", + canonical_path, cbm_workspace_verdict_reason(verdict), flag, canonical_path); } else { snprintf(err, err_sz, "%s: %s", canonical_path, cbm_workspace_verdict_reason(verdict)); } diff --git a/src/foundation/workspace.h b/src/foundation/workspace.h index c4674696d..a956529ab 100644 --- a/src/foundation/workspace.h +++ b/src/foundation/workspace.h @@ -25,7 +25,9 @@ typedef enum { CBM_WS_ALLOW = 0, - /* Fewer path components than the platform minimum — e.g. "/etc", "/home". */ + /* Fewer path components than the platform minimum — e.g. "/etc", "/home". + * Lifted only for one exact path by a recorded broad approval + * (allow-root --approve-broad), never by a manifest or a tool call. */ CBM_WS_DENY_TOO_SHALLOW, /* A filesystem root, a drive root, a UNC share root, or a path that is or * contains the cbm cache directory. Never overridable. */ @@ -42,8 +44,10 @@ cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const c /* Stable, human-facing reason for a verdict. Never NULL. */ const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict); -/* True when an explicit human action may override this verdict. Absolute denials - * and shallow paths are never overridable; sensitive ones are. */ +/* True when a sensitive approval (allow-root --approve-sensitive) may override + * this verdict. Absolute denials never are. A shallow path is not overridable + * in this sense either: only an exact broad approval lifts it, see + * cbm_workspace_grant_add. */ bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict); /* Number of path components below the volume root, ignoring repeated and @@ -81,11 +85,13 @@ bool cbm_path_within_root(const char *root_path, const char *abs_path); bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz); /* Record canonical_path as an allowed root. approve_sensitive records an explicit - * human override for a CBM_WS_DENY_SENSITIVE path; absolute denials and shallow - * paths are never grantable. Returns false and fills err on refusal. */ + * human override for a CBM_WS_DENY_SENSITIVE path; approve_broad records one for a + * CBM_WS_DENY_TOO_SHALLOW path. Each flag lifts only its own verdict, and only + * for exactly this path. Absolute denials are never grantable. Returns false and + * fills err on refusal. */ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, - const char *canonical_path, bool approve_sensitive, char *err, - size_t err_sz); + const char *canonical_path, bool approve_sensitive, bool approve_broad, + char *err, size_t err_sz); /* Newline-separated granted roots into out. True when at least one exists. */ bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz); diff --git a/src/main.c b/src/main.c index 4b29e3a63..c2f22ada9 100644 --- a/src/main.c +++ b/src/main.c @@ -1179,21 +1179,27 @@ static void print_help(void) { /* Try to handle a subcommand (cli/install/uninstall/update/config/--version/--help). * Returns -1 if no subcommand matched, otherwise the exit code. */ -/* `allow-root [--approve-sensitive] ` — record an indexing root. +/* `allow-root [--approve-sensitive | --approve-broad] ` — record an + * indexing root. * * Enrollment lives here, in a command a person types, and deliberately nowhere * else: the whole point of the grant store is that neither an indexed repository * nor a tool caller can widen its own boundary. A confirmation delivered through * the MCP surface would be answered by the same agent that may have been - * influenced, so it would not be a human decision at all. */ + * influenced, so it would not be a human decision at all. --approve-broad is the + * same kind of record for one exact root refused as too broad ("/work", "/app"), + * so it is listed, revocable and seen identically by stdio and daemon sessions. */ static int main_run_allow_root(int argc, char **argv) { const char *path = NULL; bool approve_sensitive = false; + bool approve_broad = false; bool list_only = false; bool approve_manifest = false; for (int i = 0; i < argc; i++) { if (strcmp(argv[i], "--approve-sensitive") == 0) { approve_sensitive = true; + } else if (strcmp(argv[i], "--approve-broad") == 0) { + approve_broad = true; } else if (strcmp(argv[i], "--list") == 0) { list_only = true; } else if (strcmp(argv[i], "--approve-manifest") == 0) { @@ -1225,7 +1231,8 @@ static int main_run_allow_root(int argc, char **argv) { } if (!path && !list_only) { (void)fprintf(stderr, - "usage: codebase-memory-mcp allow-root [--approve-sensitive] \n" + "usage: codebase-memory-mcp allow-root [--approve-sensitive | " + "--approve-broad] \n" " codebase-memory-mcp allow-root --approve-manifest \n" " codebase-memory-mcp allow-root --list\n"); return EXIT_FAILURE; @@ -1268,7 +1275,7 @@ static int main_run_allow_root(int argc, char **argv) { char err[CBM_SZ_1K]; if (!cbm_workspace_grant_add(cache_dir, cbm_workspace_home_dir(), canonical, approve_sensitive, - err, sizeof(err))) { + approve_broad, err, sizeof(err))) { (void)fprintf(stderr, "refused: %s\n", err[0] ? err : "not an allowable root"); return EXIT_FAILURE; } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 8c35827ab..9bfc2ab2d 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -8301,12 +8301,10 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t *scanned_edges = ecount; scc_graph_t g; if (!scc_build(src, tgt, ecount, &g)) { - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); return CBM_STORE_OK; /* no edges = no cycles, not an error */ } - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); int *comp = malloc((size_t)g.nverts * sizeof(int)); if (!comp) { diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 419f29e26..371cb5536 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -1281,10 +1281,18 @@ static inline const cbm_gbuf_node_t *cbm_pipeline_lsp_target_node_policy( if (strcmp(cand->label, "Function") != 0 && strcmp(cand->label, "Method") != 0) { continue; } - if (!unique_callable) { - unique_callable = cand; - } else if (unique_callable->id != cand->id) { - callable_ambiguous = true; + /* A generated member (a Java record's implicit accessor) is registered + * by qualified name only: it joins through its owner's Class.method + * tail below, never on its leaf name alone -- `Objects.nonNull(x)` is + * no call of some record's nonNull(). Nor does it make a same-named + * callable ambiguous: the leaf fallback sees the graph as if it were + * not there. */ + if (!cbm_node_props_generated(cand->properties_json)) { + if (!unique_callable) { + unique_callable = cand; + } else if (unique_callable->id != cand->id) { + callable_ambiguous = true; + } } if (!cbm_pipeline_qn_class_method_tail_eq(cand->qualified_name, callee_tail)) { continue; diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index b9cc89c5b..1df1fe155 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -997,6 +997,11 @@ static bool calls_emit_lsp_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call, if (!target_node || source_node->id == target_node->id) { return false; } + /* LSP textual fallbacks can also name data, as with static imports. The + * call is handled: no edge, and no registry fallback after it. */ + if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + return true; + } cbm_resolution_t res = {0}; /* Keep the canonical graph QN, including project prefixes added by fallback. */ res.qualified_name = target_node->qualified_name; @@ -1333,6 +1338,12 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM return calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, route_mount, lang, evidence); } + /* A Java call never targets data — see cbm_java_suppress_call_to_data_member. + * The language gate lives here, as with the guards above, and MUST match + * pass_parallel.c exactly or the two resolvers diverge. */ + if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + return 0; + } evidence->candidate_qn = target_node->qualified_name; evidence->calls_emitted |= emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 852627535..7c7aa438f 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -509,10 +509,15 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (helpers.c): callables + type-like containers (INHERITS/IMPLEMENTS/method/ * field resolution), Variable/Field (READS/WRITES resolution), and Table/View * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed - * through the same predicate, so the three registries cannot diverge. */ + * through the same predicate, so the three registries cannot diverge. + * A generated member is registered by qualified name only, at all three. */ if (node_id > 0 && 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); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, + def->label, lang, module_prefix_len); + } } 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); diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 15733d5b4..4dbfae9e9 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1811,10 +1811,15 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d return 0; } /* Registry membership is defined ONCE by cbm_label_is_registry_symbol - * (helpers.c) — see pass_definitions.c for the per-label rationale. */ + * (helpers.c) — see pass_definitions.c for the per-label rationale, and + * for the generated member registered by qualified name only. */ 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); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + 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]; @@ -3618,6 +3623,12 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB imp_keys, imp_vals, imp_count, route_mount, lang); continue; } + if (target_node && source_node->id != target_node->id && + cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + /* Same guard as pass_calls.c — a Java call never targets a + * Variable or a Field. */ + 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 diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index daf917379..7af626b2d 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2604,6 +2604,15 @@ static const cbm_gbuf_node_t *resolve_import_strategies( if (import_candidate_is_python_local(ctx->gbuf, cand)) { continue; } + /* Nor is a generated member (a Java record's implicit + * accessor, an instance method no import can name). It is + * registered by qualified name only, and a package segment + * that merely spells it (`javax.crypto.spec` -> spec()) + * must not bind the import -- or every use of the + * imported name -- to it. */ + if (cbm_node_props_generated(cand->properties_json)) { + continue; + } if (source_file_qn && cand->qualified_name && strcmp(cand->qualified_name, source_file_qn) == 0) { continue; /* self */ diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 9fd6946f1..34261302d 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -278,6 +278,24 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label, CBMLanguage lang, size_t module_prefix_len); +/* Register a symbol for exact (qualified-name) lookups only: it is never + * offered to the short-name strategies (unique_name, suffix_match, + * field_type_hint, fuzzy). For a generated member (cbm_def_props_generated), + * which the source never spells, so only a type-aware resolver that knows its + * owner may bind it. The three graph registration sites route such a symbol + * here instead of cbm_registry_add_lang_in_module. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label); + +/* True when a definition's extra_props (key, value, ..., NULL) carry the + * generated-member key (cbm.h CBM_PROP_GENERATED), e.g. a Java record's + * implicit accessor. Pure; unit-tested in test_registry.c. */ +bool cbm_def_props_generated(const char *const *extra_props); + +/* The stored-node twin of cbm_def_props_generated, for the incremental + * registry seed: true when a node's properties JSON carries that key as a + * string property. Pure; unit-tested in test_registry.c. */ +bool cbm_node_props_generated(const char *properties_json); + /* True when code in `caller` can call a symbol defined in `target` by bare * name: same language, same interop family (JS/TS + script hosts, C/C++/CUDA/ * ObjC, JVM, .NET, ...; the table lives in registry.c), or Swift with the C @@ -512,6 +530,13 @@ bool cbm_c_member_rule_file(CBMLanguage lang, const char *rel_path); * a member); NULL when none or several do. */ const char *cbm_registry_unique_field_qn(const cbm_registry_t *r, const char *member_name); +/* A Java call expression never names data: `p.x()` is a method invocation + * whatever `x` spells elsewhere in the project. Drops a CALLS bind whose + * target is a Variable or Field. Java only — Kotlin properties and C function + * pointers are callable names that are not methods. Pure; unit-tested in + * test_registry.c. */ +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label); + /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index 895cded93..ca1dbab46 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -590,6 +590,39 @@ static char *delta_owner_props(const char *project, const char *name, const char return out; } +/* `props` (a JSON object) with a generated member's mark (cbm.h + * CBM_PROP_GENERATED) added, or NULL when `generated` is NULL/empty + * (CBM_MEM_CLASS_OTHER; the caller frees). */ +static char *delta_generated_props(const char *props, const char *generated) { + if (!generated || !generated[0]) { + return NULL; + } + size_t pl = props ? strlen(props) : 0; + bool has_members = pl > PAIR_LEN && props[0] == '{' && props[pl - SKIP_ONE] == '}'; + size_t cap = pl + PAIR_LEN * strlen(generated) + CBM_SZ_32; + char *out = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, cap); + if (!out) { + return NULL; + } + size_t k = 0; + if (has_members) { + memcpy(out, props, pl - SKIP_ONE); + k = pl - SKIP_ONE; + out[k++] = ','; + } else { + out[k++] = '{'; + } + k += (size_t)snprintf(out + k, cap - k, "\"%s\":\"", CBM_PROP_GENERATED); + for (const char *p = generated; *p && k + CBM_SZ_3 < cap; p++) { + if (*p == '"' || *p == '\\') { + out[k++] = '\\'; + } + out[k++] = *p; + } + snprintf(out + k, cap - k, "\"}"); + return out; +} + int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *gbuf) { sqlite3 *db = cbm_store_get_db(store); if (!db) { @@ -634,7 +667,12 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g * goes by -- a Go method's QN has no receiver, a class-body variable's QN * no class. A document naming `Order.Reset` binds such a node through * its owner (doc_links_pdf.c), so a re-resolved document needs it too. - * Only where owner + name differs from the QN (delta_owner_props). */ + * Only where owner + name differs from the QN (delta_owner_props). + * + * And a generated member's mark (a Java record's implicit accessor, a + * Method node): the registry seed reads it to register the member by + * qualified name only, as the full build does (cbm_node_props_generated); + * without it the proxy came back into the short-name index. */ sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2( db, @@ -646,7 +684,10 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g " start_line, end_line," " CASE WHEN label IN ('Method','Variable')" " AND instr(properties, '\"parent_class\"') > 0" - " THEN json_extract(properties, '$.parent_class') END" + " THEN json_extract(properties, '$.parent_class') END," + " CASE WHEN label = 'Method'" + " AND instr(properties, '\"" CBM_PROP_GENERATED "\"') > 0" + " THEN json_extract(properties, '$." CBM_PROP_GENERATED "') END" " FROM nodes" " WHERE project = ?1 AND label NOT IN" " ('Macro','Comment','Section','Branch','Commit','Tag')" @@ -670,13 +711,16 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g int start_line = sqlite3_column_int(stmt, 6); int end_line = sqlite3_column_int(stmt, 7); const char *parent = (const char *)sqlite3_column_text(stmt, 8); + const char *generated = (const char *)sqlite3_column_text(stmt, 9); char *owner_props = parent ? delta_owner_props(project, name, qn, parent, client_props) : NULL; + const char *props = owner_props ? owner_props : (client_props ? client_props : "{}"); + char *generated_props = delta_generated_props(props, generated); /* Pin the gbuf id to the database id: proxies ARE their rows. */ cbm_gbuf_set_next_id(gbuf, id); - int64_t got = - cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, end_line, - owner_props ? owner_props : (client_props ? client_props : "{}")); + int64_t got = cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, + end_line, generated_props ? generated_props : props); + cbm_free(CBM_MEM_CLASS_OTHER, generated_props); cbm_free(CBM_MEM_CLASS_OTHER, owner_props); if (got != id) { /* A QN collision inside the preseed set would silently split diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index a2bdd5f3c..c246dbe4f 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1353,6 +1353,12 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { if (!incr_label_is_registry_symbol(node->label)) { return; } + /* A generated member stays out of the short-name index here as in the + * full index (pass_definitions.c); its node carries the mark. */ + if (cbm_node_props_generated(node->properties_json)) { + cbm_registry_add_qn_only(seed->registry, node->qualified_name, node->label); + return; + } CBMLanguage lang = cbm_registry_synthetic_path_language(node->file_path); if (lang == CBM_LANG_COUNT && seed->lang_by_path && node->file_path) { void *v = cbm_ht_get(seed->lang_by_path, node->file_path); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index a88091dc2..ee1eeb943 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1370,6 +1370,30 @@ bool cbm_c_member_binds_by_owner(bool is_c, bool is_member_access) { return is_c && is_member_access; } +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label) { + /* A Java call expression never names data. `p.x()` is a method invocation + * whatever `x` spells elsewhere in the tree, so a CALLS edge into a + * Variable or a Field is a spelling collision and not a call. + * + * The bind is reached when the type-aware resolver finds no method to + * return: a record's accessors are implicit, so `record Point(double x, + * double y, double z)` declares no `x()` for java_lookup_method to find, + * and every `p.x()` falls through to the short-name registry — which + * matches the first `x` anywhere in the project, typically an unrelated + * class's private field. Measured on a 600-file Java tree (2026-09-22): + * 278 of 2444 CALLS edges landed on fields that way, and the callers of + * one four-field class absorbed every record read in the project. + * + * Java-gated, like the Go guard above, because the veto is only sound + * where no callable name can be anything but a method: Kotlin properties + * hold function values (`val f: () -> Unit; f()`) and C has function + * pointers, so both legitimately call through data. */ + if (!is_java || !target_label) { + return false; + } + return strcmp(target_label, "Variable") == 0 || strcmp(target_label, "Field") == 0; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { @@ -1629,6 +1653,32 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, } } +/* A generated member (a Java record's implicit accessor) is a definition the + * source never spells: no call names it except through its owner's type. The + * exact map holds it for the LSP targets and the import/same-module lookups, + * as it holds a Python function's locals; the by-name index does not, so no + * unique_name / suffix_match / field_type_hint / fuzzy guess can land on it. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label) { + if (!r || !qualified_name || !label) { + return; + } + (void)registry_add_exact(r, qualified_name, label); +} + +bool cbm_def_props_generated(const char *const *extra_props) { + for (const char *const *kv = extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) { + if (strcmp(kv[0], CBM_PROP_GENERATED) == 0) { + return true; + } + } + return false; +} + +bool cbm_node_props_generated(const char *properties_json) { + /* The key as the definition passes write it: "generated":"". */ + return properties_json && strstr(properties_json, "\"" CBM_PROP_GENERATED "\":\"") != NULL; +} + /* ── Lookup ──────────────────────────────────────────────────────── */ bool cbm_registry_exists(const cbm_registry_t *r, const char *qn) { diff --git a/src/store/store.c b/src/store/store.c index 85e072791..9e6b6fc32 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -4186,10 +4186,18 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge bind_text(stmt, SKIP_ONE, project); sqlite3_bind_int(stmt, 2, max_edges > 0 ? max_edges + SKIP_ONE : CBM_NOT_FOUND); + /* Both arrays are STORE-class memory-core blocks: the caller releases them + * with cbm_store_free_call_edges, the only free that matches. */ int cap = ST_INIT_CAP_16; int n = 0; - int64_t *src = malloc((size_t)cap * sizeof(int64_t)); - int64_t *tgt = malloc((size_t)cap * sizeof(int64_t)); + int64_t *src = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); + int64_t *tgt = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); + if (!src || !tgt) { + sqlite3_finalize(stmt); + cbm_store_free_call_edges(src, tgt); + store_set_error(s, "fetch_call_edges: out of memory"); + return CBM_STORE_ERR; + } int scan_rc; while ((scan_rc = sqlite3_step(stmt)) == SQLITE_ROW) { if (max_edges > 0 && n >= max_edges) { @@ -4199,9 +4207,23 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge break; } if (n >= cap) { + size_t grown_bytes = (size_t)cap * ST_GROWTH * sizeof(int64_t); + int64_t *grown_src = cbm_realloc(CBM_MEM_CLASS_STORE, src, grown_bytes); + if (grown_src) { + src = grown_src; + } + int64_t *grown_tgt = + grown_src ? cbm_realloc(CBM_MEM_CLASS_STORE, tgt, grown_bytes) : NULL; + if (grown_tgt) { + tgt = grown_tgt; + } + if (!grown_src || !grown_tgt) { + sqlite3_finalize(stmt); + cbm_store_free_call_edges(src, tgt); + store_set_error(s, "fetch_call_edges: out of memory"); + return CBM_STORE_ERR; + } cap *= ST_GROWTH; - src = safe_realloc(src, (size_t)cap * sizeof(int64_t)); - tgt = safe_realloc(tgt, (size_t)cap * sizeof(int64_t)); } src[n] = sqlite3_column_int64(stmt, 0); tgt[n] = sqlite3_column_int64(stmt, 1); @@ -4209,8 +4231,7 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge } sqlite3_finalize(stmt); if (scan_rc != SQLITE_DONE && scan_rc != SQLITE_ROW) { - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); store_set_error_sqlite(s, "fetch_call_edges scan"); return CBM_STORE_ERR; } @@ -4220,6 +4241,11 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge return CBM_STORE_OK; } +void cbm_store_free_call_edges(int64_t *src, int64_t *tgt) { + cbm_free(CBM_MEM_CLASS_STORE, src); + cbm_free(CBM_MEM_CLASS_STORE, tgt); +} + int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count) { bind_id_t b = {source_id}; @@ -10668,6 +10694,10 @@ int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t * return cbm_leiden(nodes, node_count, edges, edge_count, 1.0, out, out_count); } +void cbm_leiden_free(cbm_louvain_result_t *result) { + cbm_free(CBM_MEM_CLASS_OTHER, result); /* cbm_leiden allocates OTHER */ +} + /* ── Architecture: community clusters via Leiden ───────────────── */ enum { diff --git a/src/store/store.h b/src/store/store.h index 9257079f7..eed6f746d 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -708,12 +708,16 @@ int64_t cbm_store_insert_edge(cbm_store_t *s, const cbm_edge_t *e); int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int count); /* Fetch all CALLS edges among Function/Method nodes for a project as parallel - * (source_id, target_id) arrays (caller frees both). For SCC / cycle analysis. + * (source_id, target_id) arrays, released together with + * cbm_store_free_call_edges. For SCC / cycle analysis. * Stops at max_edges and sets *truncated — never a silent cap. Returns * CBM_STORE_OK (or _ERR); *count is the number returned. */ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges, int64_t **out_src, int64_t **out_tgt, int *count, bool *truncated); +/* Free the (source_id, target_id) arrays returned by cbm_store_fetch_call_edges. */ +void cbm_store_free_call_edges(int64_t *src, int64_t *tgt); + /* Find edges by source node. */ int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count); @@ -1317,7 +1321,7 @@ typedef struct { * reported community is internally connected. The resolution parameter * controls granularity (higher -> more, smaller communities); 1.0 is standard. * Allocates *out (length *out_count == node_count) in CBM_MEM_CLASS_OTHER; - * the caller releases it with cbm_free(CBM_MEM_CLASS_OTHER, *out). */ + * the caller releases it with cbm_leiden_free(*out). */ int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, int edge_count, double resolution, cbm_louvain_result_t **out, int *out_count); @@ -1325,6 +1329,9 @@ int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *e int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, int edge_count, cbm_louvain_result_t **out, int *out_count); +/* Free the result array returned by cbm_leiden / cbm_louvain. */ +void cbm_leiden_free(cbm_louvain_result_t *result); + /* ── Memory management helpers ──────────────────────────────────── */ /* Free heap-allocated strings in a stack-allocated node (does NOT free the node itself). */ diff --git a/src/ui/http_server.c b/src/ui/http_server.c index ffc834f6d..6e153686e 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -2042,7 +2042,7 @@ static bool attach_missed_graph(yyjson_mut_doc *mdoc, yyjson_mut_val *mroot, cbm return false; } yyjson_doc *mldoc = yyjson_read(mjson, strlen(mjson), 0); - free(mjson); + cbm_layout_free_json(mjson); if (!mldoc) { return false; } @@ -2164,7 +2164,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } cbm_http_replyf(c, 200, g_cors_json, "%s", scoped_json); - free(scoped_json); + cbm_layout_free_json(scoped_json); return; } @@ -2200,13 +2200,13 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { if (linked_count == 0 && missed_graph) { cbm_store_close(store); cbm_http_replyf(c, 200, g_cors_json, "%s", primary_json); - free(primary_json); + cbm_layout_free_json(primary_json); return; } /* Parse primary JSON and append missed_graph + linked_projects */ yyjson_doc *pdoc = yyjson_read(primary_json, strlen(primary_json), 0); - free(primary_json); + cbm_layout_free_json(primary_json); if (!pdoc) { cbm_store_close(store); cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON parse failed\"}"); @@ -2258,7 +2258,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Parse linked project layout */ yyjson_doc *lpdoc = yyjson_read(lp_json, strlen(lp_json), 0); - free(lp_json); + cbm_layout_free_json(lp_json); if (!lpdoc) { cbm_store_close(lp_store); free(linked[li]); diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 144dff0f5..aba7e4474 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -14,6 +14,7 @@ #include "ui/layout3d.h" #include "ui/layout_internal.h" #include "foundation/log.h" +#include "foundation/json_writer.h" #include "foundation/mem_core.h" #include "foundation/platform.h" @@ -899,10 +900,12 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { yyjson_mut_obj_add_val(doc, root, "edges", ea); yyjson_mut_obj_add_int(doc, root, "total_nodes", r->total_nodes); + /* The output string is an OTHER-class memory-core block, released by + * cbm_layout_free_json; the document keeps its own allocator. */ size_t len = 0; yyjson_write_err write_err = {0}; - char *json = - yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL, &len, &write_err); + char *json = yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, + cbm_json_writer_allocator(), &len, &write_err); yyjson_mut_doc_free(doc); if (!json) { char code[CBM_SZ_32]; @@ -912,3 +915,7 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { } return json; } + +void cbm_layout_free_json(char *json) { + cbm_free(CBM_MEM_CLASS_OTHER, json); +} diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h index 0a3d65770..6d4cd22ed 100644 --- a/src/ui/layout3d.h +++ b/src/ui/layout3d.h @@ -69,9 +69,12 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, /* Release a computed layout result and its fields through their matching allocators. */ void cbm_layout_free(cbm_layout_result_t *result); -/* Serialize layout result to JSON string. Caller must free(). */ +/* Serialize layout result to JSON string. Release with cbm_layout_free_json(). */ char *cbm_layout_to_json(const cbm_layout_result_t *result); +/* Free the JSON string returned by cbm_layout_to_json. */ +void cbm_layout_free_json(char *json); + /* ── CBM Atlas region level ───────────────────────────────────── */ /* level=regions payload: one body per region (Leiden communities voted per diff --git a/tests/repro/repro_lexical_binding_precision.c b/tests/repro/repro_lexical_binding_precision.c index 6c014c2af..7550cb547 100644 --- a/tests/repro/repro_lexical_binding_precision.c +++ b/tests/repro/repro_lexical_binding_precision.c @@ -963,6 +963,59 @@ TEST(repro_python_global_assignment_is_module_binding) { PASS(); } +/* A global/nonlocal name is a directive, not a read (each name of `global a, + * b`), and the rebinding lands where it says: the module from that point on, + * or the enclosing function. Source 1 is an error tree, whose directive and + * default-value checks take the ts_node_parent fallback. */ +TEST(repro_python_scope_directives_bind_the_named_scope) { + static const char *const sources[] = { + "def handler():\n pass\n" + "def outer():\n handler = None\n" + " def inner():\n nonlocal handler\n handler = outer\n" + "def early():\n accept_early(handler)\n" + "def caller():\n def nested():\n global handler, other\n" + " handler = outer\n accept_outer(handler)\n" + "def sibling():\n accept_sibling(handler)\n", + "def handler():\n pass\n" + "def make(handler=handler):\n return handler\n" + "def caller():\n global handler other\n handler = make\n make(handler)\n", + }; + static const struct { + int source; + const char *marker, *name; + int count, blocked, shadow; + } sites[] = { + {0, "global handler", "handler", 0, 0, 0}, + {0, ", other", "other", 0, 0, 0}, + {0, "nonlocal handler", "handler", 0, 0, 0}, + {0, "accept_early(handler)", "handler", 1, 0, 0}, + {0, "accept_outer(handler)", "handler", 1, 1, 0}, + {0, "accept_sibling(handler)", "handler", 1, 1, 0}, + {1, "global handler", "handler", 0, 0, 0}, + {1, "=handler", "handler", 1, 0, 0}, + {1, "make(handler)", "handler", 1, 1, 0}, + }; + int failures = 0; + for (int i = 0; i < (int)(sizeof(sites) / sizeof(sites[0])); i++) { + const char *source = sources[sites[i].source]; + uint32_t start = lb_identifier_offset(source, sites[i].marker, sites[i].name); + ASSERT_NEQ(start, UINT32_MAX); + CBMFileResult *result = lb_extract(source, CBM_LANG_PYTHON, "directives.py"); + ASSERT_NOT_NULL(result); + ASSERT_EQ(result->parse_incomplete, sites[i].source); + LBBindingSite got = lb_binding_site(result, NULL, sites[i].name, start); + cbm_free_result(result); + if (got.count != sites[i].count || got.blocked_count != sites[i].blocked || + got.local_shadow_count != sites[i].shadow) { + printf(" %s: count %d blocked %d shadow %d\n", sites[i].marker, got.count, + got.blocked_count, got.local_shadow_count); + failures++; + } + } + ASSERT_EQ(failures, 0); + PASS(); +} + /* Module assignments participate in name lookup too. A later function must * not raw-name-fallback through a module variable to an older same-named * callable declaration. */ @@ -1055,6 +1108,7 @@ SUITE(repro_lexical_binding_precision) { RUN_TEST(repro_objectscript_udl_set_target_binds_method_local); RUN_TEST(repro_python_default_expression_uses_enclosing_namespace); RUN_TEST(repro_python_global_assignment_is_module_binding); + RUN_TEST(repro_python_scope_directives_bind_the_named_scope); RUN_TEST(repro_python_module_assignment_blocks_callable_fallback); RUN_TEST(repro_go_function_literal_parameter_does_not_leak); } diff --git a/tests/test_call_reference_contract.c b/tests/test_call_reference_contract.c index 3a7b3acd1..ec5ce4abe 100644 --- a/tests/test_call_reference_contract.c +++ b/tests/test_call_reference_contract.c @@ -35,6 +35,40 @@ static int crc_edge_count(cbm_store_t *store, const char *project, const char *t return matches; } +/* Edges of `type` out of `source_name` whose target's qualified name ends with + * `target_qn_suffix`. Two methods in one project can share a short name, and + * then only the qualified name tells them apart. */ +static int crc_edge_count_to_qn(cbm_store_t *store, const char *project, const char *type, + const char *source_name, const char *target_qn_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_type(store, project, type, &edges, &edge_count) != CBM_STORE_OK) { + return -1; + } + int matches = 0; + size_t suffix_len = strlen(target_qn_suffix); + for (int i = 0; i < edge_count; i++) { + cbm_node_t source = {0}; + cbm_node_t target = {0}; + bool source_ok = + cbm_store_find_node_by_id(store, edges[i].source_id, &source) == CBM_STORE_OK; + bool target_ok = + cbm_store_find_node_by_id(store, edges[i].target_id, &target) == CBM_STORE_OK; + if (source_ok && target_ok && source.name && target.qualified_name && + strcmp(source.name, source_name) == 0) { + size_t qn_len = strlen(target.qualified_name); + if (qn_len >= suffix_len && + strcmp(target.qualified_name + qn_len - suffix_len, target_qn_suffix) == 0) { + matches++; + } + } + cbm_node_free_fields(&source); + cbm_node_free_fields(&target); + } + cbm_store_free_edges(edges, edge_count); + return matches; +} + static int crc_global_handler_edge_count(cbm_store_t *store, const char *project, const char *caller) { int references = @@ -423,6 +457,195 @@ TEST(call_reference_go_ambiguous_promoted_method_stays_usage) { PASS(); } +TEST(call_java_record_accessor_binds_the_record) { + /* A record's accessors are implicit, so `record Point(double x, ...)` + * declares no `x()` in its source. Extraction emits them, and `other.x()` + * binds to the record's own accessor. + * + * Two things must not happen instead. The call must not reach the + * project-wide short-name registry and land on the first `x` in the tree — + * here an unrelated class's private field. And the guard that refuses such + * data binds must not swallow a real method that merely shares the + * spelling: the builder's `x(int)`, declared beside its own `x` field, + * keeps its edge. */ + static const RFile files[] = { + {"Point.java", "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double first(Point other) {\n" + " return Maths.abs(other.x()) + new Builder().x(5).made();\n" + " }\n" + "}\n"}, + {"Spring.java", "package com.example;\n" + "public final class Spring {\n" + " private double x;\n" + " public void put(double v) { this.x = v; }\n" + "}\n"}, + {"Builder.java", "package com.example;\n" + "public final class Builder {\n" + " private int x;\n" + " public Builder x(int v) { this.x = v; return this; }\n" + " public int made() { return x; }\n" + "}\n"}, + {"Maths.java", "package com.example;\n" + "public final class Maths {\n" + " public static double abs(double v) { return v < 0 ? -v : v; }\n" + "}\n"}}; + RProj project; + cbm_store_t *store = rh_index_files(&project, files, 4); + ASSERT_NOT_NULL(store); + int fabricated_field = crc_edge_count(store, project.project, "CALLS", "first", "Field", "x"); + int fabricated_var = crc_edge_count(store, project.project, "CALLS", "first", "Variable", "x"); + int genuine = crc_edge_count(store, project.project, "CALLS", "first", "Method", "abs"); + int accessor = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Point.x"); + int builder = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Builder.x"); + rh_cleanup(&project, store); + ASSERT_EQ(fabricated_field, 0); + ASSERT_EQ(fabricated_var, 0); + ASSERT_EQ(genuine, 1); + ASSERT_EQ(accessor, 1); + ASSERT_EQ(builder, 1); + PASS(); +} + +/* Static imports can name both an inherited method and a field. Thread's + * activeCount() is external to this project, whereas Owner.activeCount is + * local data; an unresolved import must not turn the field into a callee. */ +static int crc_java_static_import_data_call_count(bool parallel) { + RFile files[52] = {{"example/Owner.java", "package example;\n" + "public class Owner extends Thread {\n" + " public static int activeCount;\n" + "}\n"}, + {"example/Caller.java", "package example;\n" + "import static example.Owner.activeCount;\n" + "class Caller {\n" + " int caller() { return activeCount(); }\n" + "}\n"}}; + char names[50][64]; + char sources[50][64]; + int count = parallel ? 52 : 2; + for (int i = 2; i < count; i++) { + snprintf(names[i - 2], sizeof(names[i - 2]), "example/Pad%d.java", i); + snprintf(sources[i - 2], sizeof(sources[i - 2]), "package example; class Pad%d {}\n", i); + files[i].name = names[i - 2]; + files[i].content = sources[i - 2]; + } + RProj project; + cbm_store_t *store = rh_index_files(&project, files, count); + if (!store) { + return -1; + } + int fields = crc_edge_count(store, project.project, "CALLS", "caller", "Field", "activeCount"); + int vars = crc_edge_count(store, project.project, "CALLS", "caller", "Variable", "activeCount"); + rh_cleanup(&project, store); + return fields < 0 || vars < 0 ? -1 : fields + vars; +} + +TEST(call_java_static_import_does_not_call_field_sequential) { + ASSERT_EQ(crc_java_static_import_data_call_count(false), 0); + PASS(); +} + +TEST(call_java_static_import_does_not_call_field_parallel) { + ASSERT_EQ(crc_java_static_import_data_call_count(true), 0); + PASS(); +} + +TEST(call_java_record_varargs_accessor_has_array_return) { + static const char *sources[] = { + "record Values(String... xs) {}\n", "record Values(String[]... xs) {}\n", + "record Values(int... xs) {}\n", "record Values(@Deprecated String... xs) {}\n", + "record Values(String... xs) { public String[] xs() { return xs; } }\n"}; + static const char *return_types[] = {"String[]", "String[][]", "int[]", "String[]", "String[]"}; + for (int fixture = 0; fixture < 5; fixture++) { + CBMFileResult *result = + cbm_extract_file(sources[fixture], (int)strlen(sources[fixture]), CBM_LANG_JAVA, + "repro", "Values.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int accessors = 0; + bool array_return = false; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "xs") == 0 && strcmp(def->label, "Method") == 0) { + accessors++; + array_return = + def->return_type && strcmp(def->return_type, return_types[fixture]) == 0; + } + } + cbm_free_result(result); + ASSERT_EQ(accessors, 1); + ASSERT_TRUE(array_return); + } + PASS(); +} + +/* Comment extras in an otherwise empty parameter list must not create a + * second, implicit accessor at the record component's source location. */ +TEST(call_java_record_commented_accessor_has_only_explicit_span) { + static const char *sources[] = {"record Value(int x) {\n" + " public int x(/* explanation */) {\n" + " return x;\n" + " }\n" + "}\n", + "record Value(int x) {\n" + " public int x(// explanation\n" + " ) {\n" + " return x;\n" + " }\n" + "}\n"}; + static const int end_lines[] = {4, 5}; + for (int fixture = 0; fixture < 2; fixture++) { + CBMFileResult *result = + cbm_extract_file(sources[fixture], (int)strlen(sources[fixture]), CBM_LANG_JAVA, + "repro", "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int accessors = 0; + int explicit_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + accessors++; + if (def->start_line == 2 && def->end_line == end_lines[fixture]) { + explicit_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(accessors, 1); + ASSERT_EQ(explicit_spans, 1); + } + PASS(); +} + +TEST(call_java_record_commented_overload_keeps_implicit_accessor) { + static const char source[] = + "record Value(int x) {\n" + " public int x(/* explanation */ int scale) { return x * scale; }\n" + "}\n"; + CBMFileResult *result = cbm_extract_file(source, (int)strlen(source), CBM_LANG_JAVA, "repro", + "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int methods = 0; + int implicit_spans = 0; + int overload_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + methods++; + if (def->start_line == 1 && def->end_line == 1) { + implicit_spans++; + } + if (def->start_line == 2 && def->end_line == 2) { + overload_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(methods, 2); + ASSERT_EQ(implicit_spans, 1); + ASSERT_EQ(overload_spans, 1); + PASS(); +} + SUITE(call_reference_contract) { RUN_TEST(call_reference_typescript_direct_argument_is_exact); RUN_TEST(call_reference_kotlin_alias_argument_is_exact); @@ -439,4 +662,10 @@ SUITE(call_reference_contract) { RUN_TEST(call_reference_python_later_decorated_method_rebinding_stays_usage); RUN_TEST(call_reference_go_bound_method_argument_is_exact); RUN_TEST(call_reference_go_ambiguous_promoted_method_stays_usage); + RUN_TEST(call_java_record_accessor_binds_the_record); + RUN_TEST(call_java_record_varargs_accessor_has_array_return); + RUN_TEST(call_java_record_commented_accessor_has_only_explicit_span); + RUN_TEST(call_java_record_commented_overload_keeps_implicit_accessor); + RUN_TEST(call_java_static_import_does_not_call_field_sequential); + RUN_TEST(call_java_static_import_does_not_call_field_parallel); } diff --git a/tests/test_cli.c b/tests/test_cli.c index e3383be78..c3a84c5fb 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -15281,6 +15281,29 @@ TEST(cli_detect_agents_finds_zed_in_roaming_appdata) { } #endif +#ifdef _WIN32 +/* #2145: a profile path outside the ANSI code page must stay visible to agent + * detection. The directory is created with th_mkdir_p, which goes through the + * wide-char API: test_mkdirp uses cbm_mkdir, the narrow _mkdir on Windows, + * which would create a code-page-mangled name instead of this one. Cursor is + * detected by its directory alone, so the assertion goes through dir_exists. */ +TEST(cli_detect_agents_handles_non_ascii_windows_profile) { + char home[1024]; + snprintf(home, sizeof(home), "%s/cbm-用户-profile", cbm_tmpdir()); + test_rmdir_r(home); + char cursor_dir[1024]; + snprintf(cursor_dir, sizeof(cursor_dir), "%s/.cursor", home); + if (th_mkdir_p(cursor_dir) != 0) + FAIL("could not create non-ASCII Windows profile path"); + + cbm_detected_agents_t agents = cbm_detect_agents(home); + test_rmdir_r(home); + if (!agents.cursor) + FAIL("agent detection must support non-ASCII Windows profile paths"); + PASS(); +} +#endif + TEST(cli_detect_agents_finds_antigravity) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX"); @@ -19351,6 +19374,7 @@ SUITE(cli) { #endif #ifdef _WIN32 RUN_TEST(cli_detect_agents_finds_zed_in_roaming_appdata); + RUN_TEST(cli_detect_agents_handles_non_ascii_windows_profile); #endif RUN_TEST(cli_detect_agents_finds_antigravity); RUN_TEST(cli_detect_agents_finds_kilocode); diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 5bb032a5c..c12c6f022 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -2643,8 +2643,9 @@ TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls) app_wait_for_active_jobs(application, 0); char grant_error[APP_TEST_PATH_CAP]; - bool approved = ordinary_reaped && cbm_workspace_grant_add(cache, sensitive, sensitive, true, - grant_error, sizeof(grant_error)); + bool approved = + ordinary_reaped && cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, + grant_error, sizeof(grant_error)); bool approved_initialized = approved && app_test_initialize_profile(&callbacks, approved_session, sensitive, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); @@ -2769,8 +2770,9 @@ TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls) { bool ordinary_released = ordinary_watched && cbm_watcher_watch_count(watcher) == 0; char grant_error[APP_TEST_PATH_CAP]; - bool approved = ordinary_released && cbm_workspace_grant_add(cache, sensitive, sensitive, true, - grant_error, sizeof(grant_error)); + bool approved = + ordinary_released && cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, + grant_error, sizeof(grant_error)); bool approved_initialized = approved && app_test_initialize_profile(&callbacks, approved_session, sensitive, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index b63c69eb7..9b6612517 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -10933,6 +10933,49 @@ TEST(extract_rescript_let_bindings_use_the_walk_cursor) { ASSERT_EQ(big_fallbacks, 0); PASS(); } + +/* Every Python usage asks whether it is a global/nonlocal name and whether it + * sits in a parameter default. Both climbed with ts_node_parent, which + * descends from the ROOT: O(d^2) for a usage d deep, so one deep expression + * went cubic (2.2 s / 17 s / >90 s at 500/1000/2000 terms). Count every such + * root-descending step at N and 2N terms. */ +TEST(extract_python_deep_default_parent_climbs_are_linear) { + enum { SMALL = 200 }; + uint64_t slow_parents[2]; + for (int round = 0; round < 2; round++) { + /* def f(x=x + x + ... + x): return x -- a binary_operator chain. */ + int terms = SMALL << round; + size_t capacity = 32U + (size_t)terms * 4U; + char *source = malloc(capacity); + ASSERT_NOT_NULL(source); + int length = snprintf(source, capacity, "def f(x=x"); + for (int i = 1; i < terms; i++) { + length += snprintf(source + length, capacity - (size_t)length, " + x"); + } + length += snprintf(source + length, capacity - (size_t)length, "):\n return x\n"); + cbm_usage_field_lookup_test_reset(); + CBMFileResult *result = + cbm_extract_file(source, length, CBM_LANG_PYTHON, "proj", "deep.py", 0, NULL, NULL); + free(source); + ASSERT_NOT_NULL(result); + slow_parents[round] = cbm_usage_slow_parent_fallback_test_count(); + /* Anti-vacuous: every default x climbed its chain to the parameter and + * reads the enclosing namespace; only `return x` is shadowed by it. */ + int defaults = 0; + for (int i = 0; i < result->usages.count; i++) { + const CBMUsage *usage = &result->usages.items[i]; + defaults += usage->ref_name && strcmp(usage->ref_name, "x") == 0 && + !usage->semantic_reference_local_shadow; + } + cbm_free_result(result); + ASSERT_EQ(defaults, terms); + } + fprintf(stderr, " [python-deep-default] slow_parents(%d)=%llu slow_parents(%d)=%llu\n", SMALL, + (unsigned long long)slow_parents[0], 2 * SMALL, (unsigned long long)slow_parents[1]); + /* Twice the terms may cost about twice the steps; the cubic climb took 4x. */ + ASSERT_LTE(slow_parents[1], slow_parents[0] * 3U + 64U); + PASS(); +} #endif /* =================================================================== @@ -13455,6 +13498,7 @@ SUITE(extraction) { RUN_TEST(extract_wide_flat_reference_fields_are_linear); RUN_TEST(extract_csharp_argument_values_use_the_walk_cursor); RUN_TEST(extract_rescript_let_bindings_use_the_walk_cursor); + RUN_TEST(extract_python_deep_default_parent_climbs_are_linear); #endif /* Perl call-graph noise (#459 follow-up) */ diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ebf05d67b..cdad1a18f 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2046,7 +2046,7 @@ static int issue403_initialize_count_calls(const char *session_root, bool approv char err[1024]; bool approved = !approve_sensitive || cbm_workspace_grant_add(cache, cbm_workspace_home_dir(), session_root, - true, err, sizeof(err)); + true, false, err, sizeof(err)); cbm_config_t *cfg = approved ? cbm_config_open(cache) : NULL; cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; int calls = -2; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4177343cd..b3b46058e 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -19088,6 +19088,285 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { PASS(); } +/* A Java record's implicit accessors are GENERATED members (#2295): Method + * nodes marked `generated: record`, registered by qualified name only, as a + * Python function's locals are. The type-aware resolver binds `p.y()` on a + * Point to Point.y; no short-name guess may land on a member the source never + * spells. `far` calls y() on a type outside the project, so the project-wide + * name registry is its only route to Point.y. `data` calls x(), whose other + * spelling is Spring's private field: neither the accessor (a name guess) nor + * the field (a Java call never names data) is its target. The record's + * hand-written z() and norm() are source members: no `generated` mark. + * `org.external.y.Vector` is not in the project, and its package segment `y` + * spells the accessor: the import's symbol-name fallback must not bind it + * there (no IMPORTS edge), or `new Vector()` in `make` would follow the import + * onto the accessor (elasticsearch: `import javax.crypto.spec.PBEKeySpec` + * bound a record's `spec()`). `stat` calls, and `refs` passes, the JDK's + * Objects.nonNull: no project callable is its target, and Seen's accessor -- + * the only project callable called nonNull -- must not be taken for it on its + * leaf name alone (elasticsearch: `Objects.nonNull(x)` and + * `filter(Objects::nonNull)` bound a test record's nonNull()). */ +static const char jgen_point_java[] = + "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double z() { return z; }\n" + " public double norm() { return Math.sqrt(x * x + y * y + z * z); }\n" + "}\n"; +static const char jgen_seen_java[] = "package com.example;\n" + "record Seen(boolean nonNull) {}\n"; +static const char jgen_spring_java[] = "package com.example;\n" + "public final class Spring {\n" + " private double x;\n" + " public void put(double v) { this.x = v; }\n" + "}\n"; +static const char jgen_caller_java[] = + "package com.example;\n" + "import java.util.List;\n" + "import java.util.Objects;\n" + "import org.external.y.Vector;\n" + "public final class Caller {\n" + " public double near(Point p) { return p.y(); }\n" + " public double far(Vector v) { return v.y(); }\n" + " public double data(Vector v) { return v.x(); }\n" + " public Object make() { return new Vector(); }\n" + " public boolean stat(Object o) { return Objects.nonNull(o); }\n" + " public long refs(List xs) {\n" + " return xs.stream().filter(Objects::nonNull).count();\n" + " }\n" + "}\n"; + +/* The id of the one `label` node called `name` in `file_path`; 0 when there is + * none or more than one. `generated` (optional) receives whether its + * properties carry the `generated: record` mark. */ +static int64_t jgen_node(cbm_store_t *s, const char *project, const char *name, const char *label, + const char *file_path, bool *generated) { + cbm_node_t *nodes = NULL; + int count = 0; + int64_t id = 0; + int hits = 0; + bool marked = false; + if (cbm_store_find_nodes_by_name(s, project, name, &nodes, &count) != CBM_STORE_OK) { + return 0; + } + for (int i = 0; i < count; i++) { + if (nodes[i].label && strcmp(nodes[i].label, label) == 0 && nodes[i].file_path && + strcmp(nodes[i].file_path, file_path) == 0) { + hits++; + id = nodes[i].id; + marked = nodes[i].properties_json && + strstr(nodes[i].properties_json, "\"generated\":\"record\"") != NULL; + } + } + cbm_store_free_nodes(nodes, count); + if (generated) { + *generated = marked; + } + return hits == 1 ? id : 0; +} + +/* CALLS edges out of `source_id` into `target_id`, or (target_id 0) into any + * node called `target_name`. */ +static int jgen_calls(cbm_store_t *s, int64_t source_id, int64_t target_id, + const char *target_name) { + cbm_edge_t *edges = NULL; + int ec = 0; + int n = 0; + if (cbm_store_find_edges_by_source_type(s, source_id, "CALLS", &edges, &ec) != CBM_STORE_OK) { + return -1; + } + for (int i = 0; i < ec; i++) { + if (target_id) { + n += edges[i].target_id == target_id; + continue; + } + cbm_node_t t = {0}; + if (cbm_store_find_node_by_id(s, edges[i].target_id, &t) == CBM_STORE_OK && t.name && + strcmp(t.name, target_name) == 0) { + n++; + } + cbm_node_free_fields(&t); + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return n; +} + +/* Edges of `type` into `target_id`. */ +static int jgen_edges_into(cbm_store_t *s, int64_t target_id, const char *type) { + cbm_edge_t *edges = NULL; + int ec = 0; + if (cbm_store_find_edges_by_target_type(s, target_id, type, &edges, &ec) != CBM_STORE_OK) { + return -1; + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return ec; +} + +/* One fact of jgen_check: reported, counted, and the check goes on, so a run + * shows every fact that does not hold. */ +#define JGEN_EXPECT(cond) \ + do { \ + if (!(cond)) { \ + printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failed++; \ + } \ + } while (0) + +/* Every fact the fixture pins, on one indexed store; the number that fail. */ +static int jgen_check(cbm_store_t *s, const char *project) { + int failed = 0; + bool gen_x = false; + bool gen_y = false; + bool gen_z = true; + bool gen_norm = true; + bool gen_nn = false; + int64_t acc_nn = jgen_node(s, project, "nonNull", "Method", "Seen.java", &gen_nn); + int64_t acc_x = jgen_node(s, project, "x", "Method", "Point.java", &gen_x); + int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); + int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); + int64_t norm = jgen_node(s, project, "norm", "Method", "Point.java", &gen_norm); + int64_t near_m = jgen_node(s, project, "near", "Method", "Caller.java", NULL); + int64_t far_m = jgen_node(s, project, "far", "Method", "Caller.java", NULL); + int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); + int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); + int64_t stat = jgen_node(s, project, "stat", "Method", "Caller.java", NULL); + int64_t refs = jgen_node(s, project, "refs", "Method", "Caller.java", NULL); + JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0 && acc_nn > 0); + JGEN_EXPECT(near_m > 0 && far_m > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); + /* The compiler's members are marked; the hand-written ones are not. */ + JGEN_EXPECT(gen_x); + JGEN_EXPECT(gen_y); + JGEN_EXPECT(gen_nn); + JGEN_EXPECT(!gen_z); + JGEN_EXPECT(!gen_norm); + /* Bound by qualified name through the receiver's type ... */ + JGEN_EXPECT(jgen_calls(s, near_m, acc_y, NULL) == 1); + /* ... and never by a short-name guess. */ + JGEN_EXPECT(jgen_calls(s, far_m, acc_y, NULL) == 0); + JGEN_EXPECT(jgen_calls(s, far_m, 0, "y") == 0); + /* Neither the accessor nor Spring's field `x`. */ + JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); + /* No import names a generated member, so nothing follows one onto it. */ + JGEN_EXPECT(jgen_edges_into(s, acc_y, "IMPORTS") == 0); + JGEN_EXPECT(jgen_calls(s, make, 0, "y") == 0); + /* The JDK's Objects.nonNull is not Seen's accessor, called or referenced. */ + JGEN_EXPECT(jgen_calls(s, stat, acc_nn, NULL) == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "CALL_REFERENCE") == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "USAGE") == 0); + return failed; +} +#undef JGEN_EXPECT + +static int jgen_write_fixture(const char *tmp) { + write_temp_file(tmp, "Point.java", jgen_point_java); + write_temp_file(tmp, "Seen.java", jgen_seen_java); + write_temp_file(tmp, "Spring.java", jgen_spring_java); + write_temp_file(tmp, "Caller.java", jgen_caller_java); + return 0; +} + +TEST(pipeline_java_record_accessors_generated_qn_only) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* The same fixture through pass_parallel.c: >= 50 files and CBM_WORKERS=4. */ +TEST(pipeline_java_record_accessors_generated_qn_only_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "Filler%d.java", i); + snprintf(body, sizeof(body), + "package com.example;\nclass Filler%d { int f() { return %d; } }\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int run = p ? cbm_pipeline_run(p) : -1; + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + ASSERT_NOT_NULL(p); + ASSERT_EQ(run, 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* An incremental re-index seeds its registry from the stored graph: the + * untouched Point.java's accessors come back from their nodes, and must stay + * out of the short-name index exactly as in the full index while the touched + * Caller.java is re-resolved. */ +TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_incr_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen_incr.db", tmp); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + cbm_pipeline_free(p1); + + char caller_path[512]; + snprintf(caller_path, sizeof(caller_path), "%s/Caller.java", tmp); + ASSERT_EQ(th_append_file(caller_path, "\n// touched: re-resolve Caller.java incrementally\n"), + 0); + + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p2)); + cbm_store_close(s); + cbm_pipeline_free(p2); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + /* As pyn_edge, and the edge's properties also contain `fragment`. */ static bool pyn_edge_with(cbm_store_t *s, const char *project, const char *src_rel, const char *tgt_rel, const char *type, const char *fragment) { @@ -29520,6 +29799,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_import_never_binds_a_local_def_issue1548); RUN_TEST(pipeline_python_nested_defs_parallel_issue1548); RUN_TEST(pipeline_python_nested_def_local_after_incremental_issue1548); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_parallel); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental); RUN_TEST(pipeline_python_local_def_shadows_module_def_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); RUN_TEST(pipeline_python_super_follows_c3_mro); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index c8033992c..429fb6628 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -751,6 +751,91 @@ TEST(pylsp_fused_self_attr_chain_via_overlay) { PASS(); } +/* Characterization of the shared Tier-2 build order. Defs register one at a + * time in defs[] order, so a Method seen before its Class auto-registers a + * placeholder receiver type that stays ahead of the Class entry, and a Method + * whose receiver is already known adds no type. Any change to how the build + * probes for known receivers must keep this exact sequence. */ +TEST(pylsp_cross_registry_interleaved_order) { + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *base = cbm_py_build_cross_registry(&arena, NULL, 0); + ASSERT_NOT_NULL(base); + int tb = base->type_count, fb = base->func_count; + + CBMLSPDef defs[] = { + {.qualified_name = "t.m.A.m", + .short_name = "m", + .label = "Method", + .receiver_type = "t.m.A", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.A", + .short_name = "A", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.B", + .short_name = "B", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.B.f", + .short_name = "f", + .label = "Method", + .receiver_type = "t.m.B", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.C.g", + .short_name = "g", + .label = "Method", + .receiver_type = "t.m.C", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + /* Receiver auto-registered by C.g just above: no second placeholder. */ + {.qualified_name = "t.m.C.h", + .short_name = "h", + .label = "Method", + .receiver_type = "t.m.C", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + /* Non-Python defs are skipped by the builder. */ + {.qualified_name = "t.m.D", + .short_name = "D", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_GO}, + }; + CBMTypeRegistry *reg = + cbm_py_build_cross_registry(&arena, defs, (int)(sizeof(defs) / sizeof(defs[0]))); + ASSERT_NOT_NULL(reg); + + /* Types: placeholder A (from A.m), Class A, Class B, placeholder C. */ + ASSERT_EQ(reg->type_count, tb + 4); + ASSERT_TRUE(reg->types[tb].qualified_name == defs[0].receiver_type); + ASSERT_STR_EQ(reg->types[tb].short_name, "A"); + ASSERT_TRUE(reg->types[tb + 1].qualified_name == defs[1].qualified_name); + ASSERT_TRUE(reg->types[tb + 2].qualified_name == defs[2].qualified_name); + ASSERT_TRUE(reg->types[tb + 3].qualified_name == defs[4].receiver_type); + ASSERT_STR_EQ(reg->types[tb + 3].short_name, "C"); + + /* Funcs keep defs[] order. */ + ASSERT_EQ(reg->func_count, fb + 4); + ASSERT_STR_EQ(reg->funcs[fb].qualified_name, "t.m.A.m"); + ASSERT_STR_EQ(reg->funcs[fb + 1].qualified_name, "t.m.B.f"); + ASSERT_STR_EQ(reg->funcs[fb + 2].qualified_name, "t.m.C.g"); + ASSERT_STR_EQ(reg->funcs[fb + 2].receiver_type, "t.m.C"); + ASSERT_STR_EQ(reg->funcs[fb + 3].qualified_name, "t.m.C.h"); + + const CBMRegisteredType *a = cbm_registry_lookup_type(reg, "t.m.A"); + ASSERT_NOT_NULL(a); + ASSERT_STR_EQ(a->qualified_name, "t.m.A"); + ASSERT_NULL(cbm_registry_lookup_type(reg, "t.m.D")); + + cbm_arena_destroy(&arena); + PASS(); +} + /* Issue #228: a class/static method invoked directly on a CROSS-FILE imported * class name — ActionRecordX.build_from_text(...) — produced no CALLS edge, so * the method showed in/out degree 0 and was flagged as dead code. Distinct from @@ -2448,6 +2533,59 @@ TEST(pylsp_scale_work_is_linear) { PASS(); } +/* #2413 (O9: no clock): the shared Tier-2 build registers one def at a time + * into a registry it finalizes only at the end, so a receiver probe that scans + * the types registered so far makes it O(methods x types). The registry + * primitives count every candidate they examine (CBM_LSP_WORK): 4x the classes + * must cost at most ~4x the work; the scanning probe lands at ~16x. */ +static uint64_t cross_registry_work_for(int n_classes, int *out_types) { + CBMArena arena; + cbm_arena_init(&arena); + int n_defs = n_classes * 3; + CBMLSPDef *defs = (CBMLSPDef *)cbm_arena_calloc(&arena, (size_t)n_defs * sizeof(*defs)); + if (!defs) { + cbm_arena_destroy(&arena); + *out_types = -1; + return 0; + } + /* Extraction order: each class followed by its two methods. */ + for (int i = 0; i < n_classes; i++) { + CBMLSPDef *cls = &defs[i * 3]; + cls->qualified_name = cbm_arena_sprintf(&arena, "scale.mod.Cls%d", i); + cls->short_name = cls->qualified_name + strlen("scale.mod."); + cls->label = "Class"; + cls->def_module_qn = "scale.mod"; + cls->lang = CBM_LANG_PYTHON; + for (int j = 1; j <= 2; j++) { + CBMLSPDef *m = &defs[i * 3 + j]; + m->qualified_name = cbm_arena_sprintf(&arena, "%s.m%d", cls->qualified_name, j); + m->short_name = j == 1 ? "m1" : "m2"; + m->label = "Method"; + m->receiver_type = cls->qualified_name; + m->def_module_qn = "scale.mod"; + m->lang = CBM_LANG_PYTHON; + } + } + (void)cbm_lsp_work_take(); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, n_defs); + uint64_t work = cbm_lsp_work_take(); + *out_types = reg ? reg->type_count : -1; + cbm_arena_destroy(&arena); + return work; +} + +TEST(pylsp_cross_registry_build_work_is_linear) { + int t1 = 0, t4 = 0; + uint64_t w1 = cross_registry_work_for(1000, &t1); + uint64_t w4 = cross_registry_work_for(4000, &t4); + printf(" cross registry work: 1000=%llu (types=%d) 4000=%llu (types=%d)\n", + (unsigned long long)w1, t1, (unsigned long long)w4, t4); + /* Every class registered exactly once (no duplicate placeholder). */ + ASSERT_EQ(t4 - t1, 3000); + ASSERT(w4 <= 5 * w1 + 1000); + PASS(); +} + extern const TSLanguage *tree_sitter_python(void); TEST(pylsp_issue1527_memo_allocation_failure_is_reported) { @@ -4850,6 +4988,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_issue1527_long_binary_chain_is_not_nesting); RUN_TEST(pylsp_issue1527_long_receiver_chain_is_not_nesting); RUN_TEST(pylsp_scale_work_is_linear); + RUN_TEST(pylsp_cross_registry_build_work_is_linear); /* Phase 2 — smoke */ RUN_TEST(pylsp_smoke_empty); RUN_TEST(pylsp_smoke_one_function); @@ -4886,6 +5025,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_crossfile_receiver_through_typed_field); RUN_TEST(pylsp_crossfile_typed_field_shared_registry); RUN_TEST(pylsp_fused_self_attr_chain_via_overlay); + RUN_TEST(pylsp_cross_registry_interleaved_order); RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); RUN_TEST(pylsp_batch_two_files); diff --git a/tests/test_registry.c b/tests/test_registry.c index 80a7b3966..9e99376ba 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1434,6 +1434,72 @@ TEST(c_member_rule_covers_c_files_and_every_dot_h) { PASS(); } +TEST(java_call_never_binds_data_member) { + /* A Java call expression never names data, so a CALLS bind that landed on + * a Variable or a Field is a spelling collision. The shape that produces + * it: a record's accessors are implicit, `p.x()` finds no declared `x()`, + * and the short-name registry hands back the first `x` in the tree — an + * unrelated class's private field. */ + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Field")); + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Variable")); + /* Everything callable is kept, including the constructor's Class target. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Method")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Function")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Class")); + /* Other languages have callable data: a Kotlin property or a C function + * pointer is a name that is not a method and is called all the same. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Field")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Variable")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, NULL)); + PASS(); +} + +TEST(generated_member_registers_by_qn_only) { + /* A generated member (a Java record's implicit accessor) is found by its + * qualified name, never by a short-name guess. The same member registered + * the ordinary way is exactly what the guess binds. */ + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.y", "Method"); + ASSERT_TRUE(cbm_registry_exists(r, "proj.geo.Point.Point.y")); + ASSERT_STR_EQ(cbm_registry_label_of(r, "proj.geo.Point.Point.y"), "Method"); + const char **qns = NULL; + int n = -1; + ASSERT_EQ(cbm_registry_find_by_name(r, "y", &qns, &n), 0); + ASSERT_EQ(n, 0); + cbm_resolution_t res = cbm_registry_resolve(r, "v.y", "proj.app.Caller", NULL, NULL, 0); + ASSERT_NULL(res.qualified_name); + cbm_registry_add(r, "x", "proj.geo.Point.Point.x", "Method"); + res = cbm_registry_resolve(r, "v.x", "proj.app.Caller", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.geo.Point.Point.x"); + /* Degenerate input registers nothing. */ + cbm_registry_add_qn_only(r, NULL, "Method"); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.z", NULL); + cbm_registry_add_qn_only(NULL, "proj.geo.Point.Point.z", "Method"); + ASSERT_EQ(cbm_registry_size(r), 2); + cbm_registry_free(r); + PASS(); +} + +TEST(generated_member_mark_on_defs_and_nodes) { + static const char *const record[] = {"generated", "record", NULL}; + static const char *const later[] = {"source", "openapi", "generated", "lombok", NULL}; + static const char *const doc[] = {"id", "ADR-7", "status", "accepted", NULL}; + static const char *const keyless[] = {"generated", NULL}; + ASSERT_TRUE(cbm_def_props_generated(record)); + ASSERT_TRUE(cbm_def_props_generated(later)); + ASSERT_FALSE(cbm_def_props_generated(doc)); + ASSERT_FALSE(cbm_def_props_generated(keyless)); /* a key without a value is no fact */ + ASSERT_FALSE(cbm_def_props_generated(NULL)); + ASSERT_TRUE( + cbm_node_props_generated("{\"parent_class\":\"p.Point\",\"generated\":\"record\"}")); + ASSERT_FALSE(cbm_node_props_generated("{\"parent_class\":\"p.Point\"}")); + /* The word inside a value is not the key. */ + ASSERT_FALSE(cbm_node_props_generated("{\"docstring\":\"code generated by protoc\"}")); + ASSERT_FALSE(cbm_node_props_generated(NULL)); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -2597,6 +2663,9 @@ SUITE(registry) { RUN_TEST(unique_field_qn_needs_exactly_one_field_of_that_name); RUN_TEST(c_member_access_binds_by_owner_only); RUN_TEST(c_member_rule_covers_c_files_and_every_dot_h); + RUN_TEST(java_call_never_binds_data_member); + RUN_TEST(generated_member_registers_by_qn_only); + RUN_TEST(generated_member_mark_on_defs_and_nodes); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); RUN_TEST(python_builtin_member_table_matches_builtin_type_methods); diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 36dad3ef0..64a118288 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -1385,6 +1385,126 @@ TEST(adr_validate_keys_empty) { PASS(); } +/* ── cbm_store_fetch_call_edges / cbm_store_free_call_edges tests ─ */ + +TEST(fetch_call_edges_basic) { + cbm_store_t *s = setup_arch_test_store(); + ASSERT_NOT_NULL(s); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + /* setup_arch_test_store() wires 5 CALLS edges among Function nodes. */ + ASSERT_EQ(count, 5); + ASSERT_FALSE(truncated); + ASSERT_NOT_NULL(src); + ASSERT_NOT_NULL(tgt); + + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +TEST(fetch_call_edges_truncated) { + cbm_store_t *s = setup_arch_test_store(); + ASSERT_NOT_NULL(s); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = false; + /* Only 5 CALLS edges exist; capping at 2 must set truncated. */ + ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 2, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + ASSERT_TRUE(truncated); + + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +TEST(fetch_call_edges_empty_project) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "empty", "/tmp/empty"); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "empty", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + ASSERT_FALSE(truncated); + + /* cbm_store_free_call_edges(NULL, NULL) must not crash. */ + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +/* The two arrays cbm_store_fetch_call_edges hands out are memory-core blocks of + * the STORE class, and cbm_store_free_call_edges is what takes them back. Both + * halves are measured: the fetch adds exactly the two arrays and the paired free + * removes exactly those, so a raw allocation on either side fails. Thirty-six + * edges outgrow the initial capacity of 16, so the growth path is covered too. */ +TEST(fetch_call_edges_memory_core_ownership) { + enum { FN = 37 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "grow", "/tmp/grow"); + int64_t ids[FN]; + for (int i = 0; i < FN; i++) { + char name[32]; + char qn[64]; + snprintf(name, sizeof(name), "f%d", i); + snprintf(qn, sizeof(qn), "grow.pkg.f%d", i); + cbm_node_t n = {.project = "grow", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "pkg/f.go"}; + ids[i] = cbm_store_upsert_node(s, &n); + ASSERT_TRUE(ids[i] > 0); + } + for (int i = 0; i + 1 < FN; i++) { + cbm_edge_t e = { + .project = "grow", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"}; + ASSERT_TRUE(cbm_store_insert_edge(s, &e) > 0); + } + + size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE); + size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE); + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "grow", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, FN - 1); + ASSERT_FALSE(truncated); + ASSERT_NOT_NULL(src); + ASSERT_NOT_NULL(tgt); + /* Ordered by (source_id, target_id): growth kept every pair in place. */ + ASSERT_EQ(src[0], ids[0]); + ASSERT_EQ(tgt[0], ids[1]); + ASSERT_EQ(src[FN - 2], ids[FN - 2]); + ASSERT_EQ(tgt[FN - 2], ids[FN - 1]); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks + 2); + ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE) >= + bytes + 2 * (size_t)count * sizeof(int64_t)); + + cbm_store_free_call_edges(src, tgt); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE), bytes); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks); + cbm_store_close(s); + PASS(); +} + /* ── Louvain tests ──────────────────────────────────────────────── */ TEST(louvain_basic) { @@ -1410,7 +1530,7 @@ TEST(louvain_basic) { /* Triangle and pair different */ ASSERT_TRUE(comm[1] != comm[4]); - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1419,7 +1539,7 @@ TEST(louvain_empty) { int count = 0; ASSERT_EQ(cbm_louvain(NULL, 0, NULL, 0, &result, &count), CBM_STORE_OK); ASSERT_EQ(count, 0); - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1430,7 +1550,7 @@ TEST(louvain_single_node) { ASSERT_EQ(cbm_louvain(nodes, 1, NULL, 0, &result, &count), CBM_STORE_OK); ASSERT_EQ(count, 1); ASSERT_EQ(result[0].node_id, 42); - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1493,7 +1613,7 @@ TEST(louvain_converges) { } ASSERT_TRUE(same_count >= 8); - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1518,7 +1638,8 @@ TEST(leiden_result_memory_core_ownership) { ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1); ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER) >= bytes + 3 * sizeof(*result)); - cbm_free(CBM_MEM_CLASS_OTHER, result); + /* Released through the exported pair, the way an FFI caller must. */ + cbm_leiden_free(result); ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes); ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks); } @@ -1665,7 +1786,7 @@ TEST(leiden_multilevel_collapses_noise) { } ASSERT_TRUE(same >= SZ - 1); } - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1697,8 +1818,8 @@ TEST(leiden_resolution_controls_granularity) { ASSERT_TRUE(n_hi > n_lo); ASSERT_TRUE(leiden_all_communities_connected(lo, N, edges, ne)); ASSERT_TRUE(leiden_all_communities_connected(hi, N, edges, ne)); - cbm_free(CBM_MEM_CLASS_OTHER, lo); - cbm_free(CBM_MEM_CLASS_OTHER, hi); + cbm_leiden_free(lo); + cbm_leiden_free(hi); PASS(); } @@ -1965,6 +2086,12 @@ SUITE(store_arch) { RUN_TEST(adr_splice_refuses_unterminated_fence); RUN_TEST(adr_validate_keys_empty); + /* Call-edge fetch / free */ + RUN_TEST(fetch_call_edges_basic); + RUN_TEST(fetch_call_edges_truncated); + RUN_TEST(fetch_call_edges_empty_project); + RUN_TEST(fetch_call_edges_memory_core_ownership); + /* Louvain */ RUN_TEST(louvain_basic); RUN_TEST(louvain_empty); diff --git a/tests/test_ui.c b/tests/test_ui.c index 8c866ff52..6cd0d8146 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -556,7 +556,41 @@ TEST(layout_to_json) { ASSERT(strstr(json, "\"hello\"") != NULL); ASSERT(strstr(json, "\"Function\"") != NULL); - free(json); + cbm_layout_free_json(json); + cbm_layout_free(r); + cbm_store_close(store); + PASS(); +} + +/* cbm_layout_to_json hands out one memory-core block of the OTHER class, and + * cbm_layout_free_json takes exactly that block back. A raw allocation on either + * side of the pair fails the count. */ +TEST(layout_json_memory_core_ownership) { + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "test", "/tmp/test"); + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "hello", + .qualified_name = "test::hello", + .file_path = "a.c", + .start_line = 1, + .end_line = 5}; + cbm_store_upsert_node(store, &n); + cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + ASSERT_NOT_NULL(r); + + size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER); + size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER); + char *json = cbm_layout_to_json(r); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"hello\"") != NULL); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1); + ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER) >= bytes + strlen(json) + 1); + + cbm_layout_free_json(json); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks); cbm_layout_free(r); cbm_store_close(store); PASS(); @@ -777,7 +811,7 @@ TEST(layout_dead_code_classification) { ASSERT_NOT_NULL(json); ASSERT(strstr(json, "\"status\":\"dead\"") != NULL); ASSERT(strstr(json, "\"in_calls\":2") != NULL); - free(json); + cbm_layout_free_json(json); cbm_layout_free(r); cbm_store_close(store); @@ -2748,6 +2782,7 @@ SUITE(ui) { RUN_TEST(layout_honors_budget_above_default); RUN_TEST(layout_deterministic); RUN_TEST(layout_to_json); + RUN_TEST(layout_json_memory_core_ownership); RUN_TEST(layout_null_inputs); RUN_TEST(layout_dead_code_classification); RUN_TEST(layout_coincident_nodes_bounded); diff --git a/tests/test_version_metadata_contract.sh b/tests/test_version_metadata_contract.sh index b74203bf6..54d787944 100644 --- a/tests/test_version_metadata_contract.sh +++ b/tests/test_version_metadata_contract.sh @@ -109,6 +109,8 @@ SURFACES=( "pkg/chocolatey/tools/chocolateyInstall.ps1|release" "pkg/scoop/codebase-memory-mcp.json|release" "pkg/homebrew/Formula/codebase-memory-mcp.rb|release" + "plugin/.claude-plugin/plugin.json|release" + "plugin/.mcp.json|release" "pkg/aur/PKGBUILD|pin:0.8.1" "pkg/aur/.SRCINFO|pin:0.8.1" ) @@ -163,6 +165,12 @@ versions_in() { # cannot see it, because the version keyword is in the function NAME. grep -oE 'return "[0-9]+\.[0-9]+\.[0-9]+"' "$path" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' + # npm package-spec form: the Claude Code plugin launches the server as + # `npx -y codebase-memory-mcp@X.Y.Z`, and that pin is the version every + # plugin user actually runs. No declaration keyword sits next to it, so + # without this form plugin/.mcp.json reads as a surface with no claim. + grep -oE 'codebase-memory-mcp@[0-9]+\.[0-9]+\.[0-9]+' "$path" | + grep -oE '[0-9]+\.[0-9]+\.[0-9]+$' } | sort -u } @@ -280,7 +288,9 @@ fi # this contract exists to prevent, and every check above would still pass. The # sweep pattern is deliberately BROADER than the extractors in section 3: if the # two shared a regex, a claim form the extractors cannot see would be invisible -# to the sweep as well. +# to the sweep as well. The Claude Code plugin tree (plugin/, .claude-plugin/) is +# swept too: a later plugin file that pins `codebase-memory-mcp@X.Y.Z` (a hook +# command, say) must be registered here, or it would go stale unchecked. while IFS= read -r path; do [[ -z "$path" ]] && continue for entry in "${SURFACES[@]}"; do @@ -293,8 +303,8 @@ while IFS= read -r path; do "SURFACES so it stays in step with the release, or to EXEMPT with a" \ "note saying what its number actually is." done < <( - git grep -IlE '(version|pkgver|releases/(download|tag)/v)[^0-9]{0,16}[0-9]+\.[0-9]+\.[0-9]+|return "[0-9]+\.[0-9]+\.[0-9]+"' \ - -- server.json pkg tests/test_version_metadata_contract.sh 2>/dev/null || true + git grep -IlE '(version|pkgver|releases/(download|tag)/v)[^0-9]{0,16}[0-9]+\.[0-9]+\.[0-9]+|return "[0-9]+\.[0-9]+\.[0-9]+"|codebase-memory-mcp@[0-9]+\.[0-9]+\.[0-9]+' \ + -- server.json pkg plugin .claude-plugin tests/test_version_metadata_contract.sh 2>/dev/null || true ) if ((failures > 0)); then diff --git a/tests/test_workspace.c b/tests/test_workspace.c index cba81e76a..be93b8e29 100644 --- a/tests/test_workspace.c +++ b/tests/test_workspace.c @@ -75,6 +75,74 @@ TEST(ws_posix_top_level_trees_are_too_shallow) { PASS(); } +/* A recorded broad approval (allow-root --approve-broad) is the only thing that + * lifts CBM_WS_DENY_TOO_SHALLOW, and only for that exact path. Classification + * and cbm_workspace_verdict_is_overridable stay exactly as strict as the test + * above checks; the approval is a grant, so it is listed like any other human + * decision, and it lifts no other verdict. */ +TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only) { + char cache[256]; + char *created = th_mktempdir("cbm_ws_allow_broad"); + ASSERT_NOT_NULL(created); + snprintf(cache, sizeof(cache), "%s", created); + + char err[1024]; + char listed[4096]; + /* Refused out of the box, and the refusal names the command a person runs. */ + ASSERT_FALSE(cbm_workspace_root_allowed("/usr", HOME, cache, NULL, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "allow-root --approve-broad /usr")); + + /* Neither an ordinary grant nor a sensitive approval lifts breadth; a broad + * approval lifts neither an absolute nor a sensitive verdict. A refused grant + * records nothing. */ + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, "/usr", false, false, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "--approve-broad")); + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, "/usr", true, false, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, "/", false, true, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, HOME, false, true, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); + ASSERT_FALSE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + + /* The explicit approval is recorded and marked. */ + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, "/usr", false, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + ASSERT_STR_EQ(listed, "(approved broad) /usr\n"); + +#ifndef _WIN32 + /* Containment resolves real paths, so the rest needs a top-level directory + * that exists on every POSIX host: "/usr". A repeated approval is recognized + * rather than appended again. */ + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, "/usr", false, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + ASSERT_STR_EQ(listed, "(approved broad) /usr\n"); + + /* It lifts exactly that path. A configured root of "/" puts "/dev" inside the + * declared boundary too, so the only thing still refusing it is that the + * approval names another path. */ + ASSERT_TRUE(cbm_workspace_root_allowed("/usr", HOME, cache, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed("/usr", HOME, cache, "/", err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed("/dev", HOME, cache, "/", err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "allow-root --approve-broad /dev")); +#endif + + /* Each marker lifts only its own verdict: a broad mark on a credential + * directory, which grant_add would never write, still does not lift it. */ + char secret[1024]; + snprintf(secret, sizeof(secret), "%s/.ssh", cache); + ASSERT_EQ(cbm_mkdir(secret), 0); + char store[1024]; + ASSERT_TRUE(cbm_workspace_grant_path(cache, store, sizeof(store))); + FILE *f = cbm_fopen(store, "a"); + ASSERT_NOT_NULL(f); + (void)fprintf(f, "^%s\n", secret); + ASSERT_EQ(fclose(f), 0); + ASSERT_FALSE(cbm_workspace_root_allowed(secret, HOME, cache, NULL, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); + + th_cleanup(cache); + PASS(); +} + /* Legitimately shallow project roots must survive: these are the false positives * a blanket depth rule would cause, which is why Windows counts drive-relative. */ TEST(ws_legitimate_shallow_roots_are_allowed) { @@ -177,7 +245,7 @@ TEST(ws_sensitive_root_explicit_approval_is_preserved) { char err[1024]; ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); - ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, false, err, sizeof(err))); ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); th_cleanup(root); @@ -197,14 +265,14 @@ TEST(ws_sensitive_approval_upgrades_existing_ordinary_exact_grant) { snprintf(cache, sizeof(cache), "%s", created); char err[1024]; - ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, root, false, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, root, false, false, err, sizeof(err))); ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, false, err, sizeof(err))); ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); /* A repeated explicit approval must recognize the marked exact grant rather * than append another exception. */ - ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, false, err, sizeof(err))); char listed[4096]; char expected[4096]; @@ -232,13 +300,15 @@ TEST(ws_sensitive_approval_adds_exact_exception_under_ordinary_ancestor) { snprintf(sensitive, sizeof(sensitive), "%s/private-project", ancestor); ASSERT_EQ(cbm_mkdir(sensitive), 0); char err[1024]; - ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, ancestor, false, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, ancestor, false, false, err, sizeof(err))); ASSERT_FALSE(cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err))); + ASSERT_TRUE( + cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, err, sizeof(err))); ASSERT_TRUE( cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err))); + ASSERT_TRUE( + cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, err, sizeof(err))); char listed[4096]; char expected[4096]; @@ -774,7 +844,7 @@ TEST(ws_manifest_ordinary_entries_unchanged) { char err[1024]; /* The grant store names a root the sdk is not under. */ - ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, granted, false, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, granted, false, false, err, sizeof(err))); ws_manifest_write_line(base, sdk); ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); @@ -918,6 +988,7 @@ SUITE(workspace) { RUN_TEST(ws_volume_roots_are_absolutely_denied); RUN_TEST(ws_non_absolute_paths_are_denied); RUN_TEST(ws_posix_top_level_trees_are_too_shallow); + RUN_TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only); RUN_TEST(ws_legitimate_shallow_roots_are_allowed); RUN_TEST(ws_home_itself_is_sensitive_but_subdirs_are_fine); RUN_TEST(ws_credential_directories_are_sensitive_at_any_depth);