From e9f87efe6abff44a7130fa3db9a9a409d22fd556 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Tue, 18 Aug 2026 16:13:14 +0800 Subject: [PATCH 01/36] feat(lsp): add the Ruby per-file resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-process, type-aware call resolution for single Ruby files, mirroring the perl_lsp.c / php_lsp.c shape and dispatched from cbm_extract_file: - PASS 1 collects classes and modules with lexical nesting, superclasses, and include / prepend / extend mixins, plus method tables with instance and singleton methods classified from the AST. Singleton methods key on ".self", which cannot collide with a constant path. - PASS 1.5 infers instance-variable types from `@x = Const.new`; conflicting assignments latch to no type rather than guessing. - PASS 2 resolves constructors (`Const.new` targets the class node, the way Python's lsp_constructor does), implicit and explicit self dispatch, singleton methods, superclass chains, mixin lookup in Ruby's method-resolution order, super, and ivar / local / chained receivers. The stdlib seed covers Ruby core plus a curated Rails surface, including ActiveRecord query typing: `User.find(1)` is a User, and relation chains keep the model type. Dynamic dispatch (send, method_missing, define_method) and unresolved receivers emit no edge, and only project-defined targets are emitted. Cross-file resolution is a separate tier and is not part of this change; the 18 rows here drive the resolver through cbm_extract_file exactly as the extraction pipeline calls it. PROVENANCE: this resolver was authored clean-room from the tree-sitter-ruby grammar and Ruby language semantics. No Ruby language server source was consulted. scripts/check-lsp-originality.sh gains Shopify's ruby-lsp (MIT) as the Ruby reference so the boundary is auditable alongside the generated data it guards; `check-lsp-originality.sh --lang ruby` reports CLEAN — no verbatim string or comment overlap and no structural clones. Refs #1701 Assisted-by: OpenCode:anthropic/claude-opus-5 Signed-off-by: Ben Fairless --- Makefile.cbm | 3 +- internal/cbm/cbm.c | 4 + internal/cbm/lsp/generated/ruby_stdlib_data.c | 461 ++++++ internal/cbm/lsp/ruby_lsp.c | 1261 +++++++++++++++++ internal/cbm/lsp/ruby_lsp.h | 148 ++ internal/cbm/lsp_all.c | 2 + scripts/check-lsp-originality.sh | 6 +- tests/test_main.c | 2 + tests/test_ruby_lsp.c | 636 +++++++++ 9 files changed, 2521 insertions(+), 2 deletions(-) create mode 100644 internal/cbm/lsp/generated/ruby_stdlib_data.c create mode 100644 internal/cbm/lsp/ruby_lsp.c create mode 100644 internal/cbm/lsp/ruby_lsp.h create mode 100644 tests/test_ruby_lsp.c diff --git a/Makefile.cbm b/Makefile.cbm index bf51a5a8c..9e5564cc6 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -579,6 +579,7 @@ TEST_CS_LSP_SRCS = tests/test_cs_lsp.c TEST_CS_LSP_BENCH_SRCS = tests/test_cs_lsp_bench.c TEST_PERL_LSP_SRCS = tests/test_perl_lsp.c +TEST_RUBY_LSP_SRCS = tests/test_ruby_lsp.c TEST_SCOPE_SRCS = tests/test_scope.c @@ -701,7 +702,7 @@ TEST_REPRO_SRCS = \ tests/repro/repro_lsp_java_cs.c \ tests/repro/repro_lsp_kt_php_rust.c -ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) +ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_RUBY_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) # ── Build directories ──────────────────────────────────────────── diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index d7468af5a..edf1b63d7 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -8,6 +8,7 @@ #include "lsp/php_lsp.h" #include "lsp/perl_lsp.h" #include "lsp/py_lsp.h" +#include "lsp/ruby_lsp.h" #include "lsp/ts_lsp.h" #include "lsp/cs_lsp.h" #include "lsp/java_lsp.h" @@ -1320,6 +1321,9 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua if (language == CBM_LANG_PERL) { cbm_run_perl_lsp(a, result, source, source_len, root); } + if (language == CBM_LANG_RUBY) { + cbm_run_ruby_lsp(a, result, source, source_len, root); + } if (language == CBM_LANG_PYTHON) { cbm_run_py_lsp(a, result, source, source_len, root); } diff --git a/internal/cbm/lsp/generated/ruby_stdlib_data.c b/internal/cbm/lsp/generated/ruby_stdlib_data.c new file mode 100644 index 000000000..83e853e65 --- /dev/null +++ b/internal/cbm/lsp/generated/ruby_stdlib_data.c @@ -0,0 +1,461 @@ +/* + * ruby_stdlib_data.c — hand-written Ruby core + curated Rails type data. + * + * Strategy mirrors perl_stdlib_data.c / php_stdlib_data.c: + * 1. Ruby core classes (String, Array, Hash, Integer, ...) registered as + * types with receiver-keyed instance methods whose return types enable + * call-chain typing ("a,b".split(",").first). Method QNs are bare + * ("String.upcase") — no project prefix — so they can never collide + * with an indexed def; they exist for TYPING, not for edge emission + * (the resolver only emits edges to project-defined methods). + * 2. Kernel built-ins (puts, require, raise, ...) as global functions so + * bare calls to them type-check without producing spurious edges. + * 3. A curated Rails surface: ActiveRecord::Base class-side query methods + * and instance persistence methods, ActionController::Base helpers, + * and ActiveSupport-style predicates on Object. Registered under + * dotted QNs ("ActiveRecord.Base") to match ruby path dotting + * (Foo::Bar -> Foo.Bar). The resolver special-cases ActiveRecord + * query methods to return the *receiving model's* type (User.find -> + * User), which these generic entries cannot express. + * + * Return types are left UNKNOWN where the real type is polymorphic; the + * seed only needs to be right where chaining matters. + */ + +#include "../type_rep.h" +#include "../type_registry.h" +#include "../../arena.h" +#include "../ruby_lsp.h" +#include + +#define MIXED cbm_type_unknown() + +/* Register a type by bare dotted QN (e.g. "String", "ActiveRecord.Base"). */ +#define REG_TYPE(qn_) \ + do { \ + CBMRegisteredType rt; \ + memset(&rt, 0, sizeof(rt)); \ + rt.qualified_name = (qn_); \ + rt.short_name = (qn_); \ + cbm_registry_add_type(reg, rt); \ + } while (0) + +/* Register an instance method on receiver type `recv_` returning `ret_`. */ +#define REG_METHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + rf.short_name = (name_); \ + rf.receiver_type = (recv_); \ + { \ + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ + rets[0] = (ret_); \ + rets[1] = NULL; \ + rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ + } \ + cbm_registry_add_func(reg, rf); \ + } while (0) + +/* Register a singleton (class-side) method: receiver key ".self" + * matches ruby_lookup_singleton_method's keying convention. */ +#define REG_SMETHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + rf.short_name = (name_); \ + rf.receiver_type = cbm_arena_sprintf(arena, "%s.self", (recv_)); \ + { \ + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ + rets[0] = (ret_); \ + rets[1] = NULL; \ + rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ + } \ + cbm_registry_add_func(reg, rf); \ + } while (0) + +/* Register a global (Kernel) built-in function returning `ret_`. */ +#define REG_BUILTIN(name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = (name_); \ + rf.short_name = (name_); \ + { \ + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ + rets[0] = (ret_); \ + rets[1] = NULL; \ + rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ + } \ + cbm_registry_add_func(reg, rf); \ + } while (0) + +void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena) { + CBMRegisteredFunc rf; + + const CBMType *T_STRING = cbm_type_named(arena, "String"); + const CBMType *T_ARRAY = cbm_type_named(arena, "Array"); + const CBMType *T_HASH = cbm_type_named(arena, "Hash"); + const CBMType *T_INT = cbm_type_named(arena, "Integer"); + const CBMType *T_FLOAT = cbm_type_named(arena, "Float"); + const CBMType *T_SYM = cbm_type_named(arena, "Symbol"); + const CBMType *T_BOOL = cbm_type_builtin(arena, "bool"); + + /* ── Core class constants (resolvable as superclasses / receivers) ── */ + static const char *core_types[] = {"Object", + "BasicObject", + "String", + "Array", + "Hash", + "Integer", + "Float", + "Numeric", + "Symbol", + "Range", + "Regexp", + "Time", + "Date", + "DateTime", + "File", + "IO", + "Dir", + "Struct", + "OpenStruct", + "StringIO", + "Set", + "Proc", + "Method", + "Thread", + "Mutex", + "Queue", + "Rational", + "Complex", + "NilClass", + "TrueClass", + "FalseClass", + "Exception", + "StandardError", + "RuntimeError", + "ArgumentError", + "TypeError", + "NameError", + "NoMethodError", + "IOError", + "KeyError", + "IndexError", + "RangeError", + "NotImplementedError", + "StopIteration", + "Comparable", + "Enumerable", + "Kernel", + "Math", + "JSON", + "ERB", + "Logger", + "Enumerator", + NULL}; + for (int i = 0; core_types[i]; i++) + REG_TYPE(core_types[i]); + + /* ── String ── */ + REG_METHOD("String", "upcase", T_STRING); + REG_METHOD("String", "downcase", T_STRING); + REG_METHOD("String", "capitalize", T_STRING); + REG_METHOD("String", "strip", T_STRING); + REG_METHOD("String", "chomp", T_STRING); + REG_METHOD("String", "gsub", T_STRING); + REG_METHOD("String", "sub", T_STRING); + REG_METHOD("String", "split", T_ARRAY); + REG_METHOD("String", "chars", T_ARRAY); + REG_METHOD("String", "lines", T_ARRAY); + REG_METHOD("String", "bytes", T_ARRAY); + REG_METHOD("String", "to_s", T_STRING); + REG_METHOD("String", "to_str", T_STRING); + REG_METHOD("String", "to_i", T_INT); + REG_METHOD("String", "to_f", T_FLOAT); + REG_METHOD("String", "to_sym", T_SYM); + REG_METHOD("String", "length", T_INT); + REG_METHOD("String", "size", T_INT); + REG_METHOD("String", "reverse", T_STRING); + REG_METHOD("String", "include?", T_BOOL); + REG_METHOD("String", "start_with?", T_BOOL); + REG_METHOD("String", "end_with?", T_BOOL); + REG_METHOD("String", "empty?", T_BOOL); + REG_METHOD("String", "match?", T_BOOL); + REG_METHOD("String", "freeze", T_STRING); + REG_METHOD("String", "dup", T_STRING); + + /* ── Array ── */ + REG_METHOD("Array", "map", T_ARRAY); + REG_METHOD("Array", "flat_map", T_ARRAY); + REG_METHOD("Array", "select", T_ARRAY); + REG_METHOD("Array", "filter", T_ARRAY); + REG_METHOD("Array", "reject", T_ARRAY); + REG_METHOD("Array", "sort", T_ARRAY); + REG_METHOD("Array", "sort_by", T_ARRAY); + REG_METHOD("Array", "uniq", T_ARRAY); + REG_METHOD("Array", "compact", T_ARRAY); + REG_METHOD("Array", "flatten", T_ARRAY); + REG_METHOD("Array", "reverse", T_ARRAY); + REG_METHOD("Array", "concat", T_ARRAY); + REG_METHOD("Array", "push", T_ARRAY); + REG_METHOD("Array", "join", T_STRING); + REG_METHOD("Array", "first", MIXED); + REG_METHOD("Array", "last", MIXED); + REG_METHOD("Array", "sample", MIXED); + REG_METHOD("Array", "length", T_INT); + REG_METHOD("Array", "size", T_INT); + REG_METHOD("Array", "count", T_INT); + REG_METHOD("Array", "sum", MIXED); + REG_METHOD("Array", "min", MIXED); + REG_METHOD("Array", "max", MIXED); + REG_METHOD("Array", "include?", T_BOOL); + REG_METHOD("Array", "empty?", T_BOOL); + REG_METHOD("Array", "any?", T_BOOL); + REG_METHOD("Array", "all?", T_BOOL); + REG_METHOD("Array", "none?", T_BOOL); + REG_METHOD("Array", "each", T_ARRAY); + REG_METHOD("Array", "each_with_index", T_ARRAY); + REG_METHOD("Array", "to_a", T_ARRAY); + REG_METHOD("Array", "group_by", T_HASH); + REG_METHOD("Array", "each_slice", MIXED); + REG_METHOD("Array", "zip", T_ARRAY); + + /* ── Hash ── */ + REG_METHOD("Hash", "keys", T_ARRAY); + REG_METHOD("Hash", "values", T_ARRAY); + REG_METHOD("Hash", "merge", T_HASH); + REG_METHOD("Hash", "merge!", T_HASH); + REG_METHOD("Hash", "fetch", MIXED); + REG_METHOD("Hash", "dig", MIXED); + REG_METHOD("Hash", "key?", T_BOOL); + REG_METHOD("Hash", "has_key?", T_BOOL); + REG_METHOD("Hash", "include?", T_BOOL); + REG_METHOD("Hash", "empty?", T_BOOL); + REG_METHOD("Hash", "size", T_INT); + REG_METHOD("Hash", "length", T_INT); + REG_METHOD("Hash", "count", T_INT); + REG_METHOD("Hash", "each", T_HASH); + REG_METHOD("Hash", "map", T_ARRAY); + REG_METHOD("Hash", "select", T_HASH); + REG_METHOD("Hash", "reject", T_HASH); + REG_METHOD("Hash", "to_a", T_ARRAY); + REG_METHOD("Hash", "to_h", T_HASH); + REG_METHOD("Hash", "transform_values", T_HASH); + REG_METHOD("Hash", "transform_keys", T_HASH); + REG_METHOD("Hash", "symbolize_keys", T_HASH); /* ActiveSupport */ + REG_METHOD("Hash", "stringify_keys", T_HASH); /* ActiveSupport */ + REG_METHOD("Hash", "deep_symbolize_keys", T_HASH); + REG_METHOD("Hash", "with_indifferent_access", T_HASH); + + /* ── Integer / Float ── */ + REG_METHOD("Integer", "to_s", T_STRING); + REG_METHOD("Integer", "to_i", T_INT); + REG_METHOD("Integer", "to_f", T_FLOAT); + REG_METHOD("Integer", "times", MIXED); + REG_METHOD("Integer", "upto", MIXED); + REG_METHOD("Integer", "abs", T_INT); + REG_METHOD("Integer", "zero?", T_BOOL); + REG_METHOD("Integer", "positive?", T_BOOL); + REG_METHOD("Integer", "negative?", T_BOOL); + REG_METHOD("Integer", "even?", T_BOOL); + REG_METHOD("Integer", "odd?", T_BOOL); + REG_METHOD("Float", "to_s", T_STRING); + REG_METHOD("Float", "to_i", T_INT); + REG_METHOD("Float", "round", T_INT); + REG_METHOD("Float", "ceil", T_INT); + REG_METHOD("Float", "floor", T_INT); + REG_METHOD("Float", "abs", T_FLOAT); + + /* ── Symbol / misc ── */ + REG_METHOD("Symbol", "to_s", T_STRING); + REG_METHOD("Symbol", "to_sym", T_SYM); + REG_METHOD("Symbol", "to_proc", cbm_type_named(arena, "Proc")); + REG_METHOD("Range", "to_a", T_ARRAY); + REG_METHOD("Range", "each", MIXED); + REG_METHOD("Range", "map", T_ARRAY); + REG_METHOD("Range", "include?", T_BOOL); + REG_METHOD("Time", "to_s", T_STRING); + REG_METHOD("Time", "to_i", T_INT); + REG_METHOD("Time", "strftime", T_STRING); + REG_METHOD("Time", "year", T_INT); + REG_METHOD("Time", "month", T_INT); + REG_METHOD("Time", "day", T_INT); + REG_SMETHOD("Time", "now", cbm_type_named(arena, "Time")); + REG_SMETHOD("Time", "at", cbm_type_named(arena, "Time")); + REG_SMETHOD("Time", "parse", cbm_type_named(arena, "Time")); + REG_SMETHOD("Time", "current", cbm_type_named(arena, "Time")); /* ActiveSupport */ + REG_SMETHOD("Time", "zone", MIXED); + REG_SMETHOD("File", "read", T_STRING); + REG_SMETHOD("File", "readlines", T_ARRAY); + REG_SMETHOD("File", "open", cbm_type_named(arena, "File")); + REG_SMETHOD("File", "exist?", T_BOOL); + REG_SMETHOD("File", "basename", T_STRING); + REG_SMETHOD("File", "dirname", T_STRING); + REG_SMETHOD("File", "join", T_STRING); + REG_SMETHOD("File", "expand_path", T_STRING); + REG_SMETHOD("JSON", "parse", MIXED); + REG_SMETHOD("JSON", "generate", T_STRING); + REG_SMETHOD("JSON", "pretty_generate", T_STRING); + REG_SMETHOD("Math", "sqrt", T_FLOAT); + REG_SMETHOD("Math", "pow", T_FLOAT); + REG_SMETHOD("Math", "sin", T_FLOAT); + REG_SMETHOD("Math", "cos", T_FLOAT); + REG_SMETHOD("Math", "log", T_FLOAT); + + /* ── Object (universal receivers; ActiveSupport predicates) ── */ + REG_METHOD("Object", "to_s", T_STRING); + REG_METHOD("Object", "inspect", T_STRING); + REG_METHOD("Object", "freeze", MIXED); + REG_METHOD("Object", "frozen?", T_BOOL); + REG_METHOD("Object", "dup", MIXED); + REG_METHOD("Object", "clone", MIXED); + REG_METHOD("Object", "tap", MIXED); + REG_METHOD("Object", "then", MIXED); + REG_METHOD("Object", "nil?", T_BOOL); + REG_METHOD("Object", "is_a?", T_BOOL); + REG_METHOD("Object", "kind_of?", T_BOOL); + REG_METHOD("Object", "instance_of?", T_BOOL); + REG_METHOD("Object", "respond_to?", T_BOOL); + REG_METHOD("Object", "blank?", T_BOOL); /* ActiveSupport */ + REG_METHOD("Object", "present?", T_BOOL); /* ActiveSupport */ + REG_METHOD("Object", "presence", MIXED); /* ActiveSupport */ + REG_METHOD("Object", "to_json", T_STRING); /* ActiveSupport / json */ + + /* ── Kernel built-ins (global functions) ── */ + REG_BUILTIN("puts", MIXED); + REG_BUILTIN("print", MIXED); + REG_BUILTIN("p", MIXED); + REG_BUILTIN("pp", MIXED); + REG_BUILTIN("require", T_BOOL); + REG_BUILTIN("require_relative", T_BOOL); + REG_BUILTIN("raise", MIXED); + REG_BUILTIN("fail", MIXED); + REG_BUILTIN("loop", MIXED); + REG_BUILTIN("sleep", T_INT); + REG_BUILTIN("rand", MIXED); + REG_BUILTIN("format", T_STRING); + REG_BUILTIN("sprintf", T_STRING); + REG_BUILTIN("gets", T_STRING); + REG_BUILTIN("exit", MIXED); + REG_BUILTIN("abort", MIXED); + REG_BUILTIN("lambda", cbm_type_named(arena, "Proc")); + REG_BUILTIN("proc", cbm_type_named(arena, "Proc")); + REG_BUILTIN("binding", MIXED); + REG_BUILTIN("catch", MIXED); + REG_BUILTIN("throw", MIXED); + REG_BUILTIN("attr_accessor", MIXED); + REG_BUILTIN("attr_reader", MIXED); + REG_BUILTIN("attr_writer", MIXED); + + /* ── Rails: ActiveRecord::Base ────────────────────────────────── + * Class-side query interface. Return types here are generic; the + * resolver's AR special case substitutes the receiving model's type + * (User.find -> User) when the receiver's ancestry reaches + * ActiveRecord.Base / ApplicationRecord. */ + REG_TYPE("ActiveRecord.Base"); + REG_TYPE("ApplicationRecord"); + static const char *ar_class_methods[] = {"find", + "find_by", + "find_by!", + "find_or_create_by", + "find_or_initialize_by", + "where", + "all", + "first", + "last", + "take", + "create", + "create!", + "new", + "count", + "order", + "limit", + "offset", + "joins", + "left_joins", + "includes", + "preload", + "eager_load", + "group", + "having", + "distinct", + "pluck", + "ids", + "exists?", + "none", + "unscoped", + "select", + "sum", + "maximum", + "minimum", + "average", + "destroy_all", + "delete_all", + "update_all", + "transaction", + "find_each", + NULL}; + for (int i = 0; ar_class_methods[i]; i++) + REG_SMETHOD("ActiveRecord.Base", ar_class_methods[i], MIXED); + REG_METHOD("ActiveRecord.Base", "save", T_BOOL); + REG_METHOD("ActiveRecord.Base", "save!", T_BOOL); + REG_METHOD("ActiveRecord.Base", "update", T_BOOL); + REG_METHOD("ActiveRecord.Base", "update!", T_BOOL); + REG_METHOD("ActiveRecord.Base", "destroy", MIXED); + REG_METHOD("ActiveRecord.Base", "destroy!", MIXED); + REG_METHOD("ActiveRecord.Base", "delete", MIXED); + REG_METHOD("ActiveRecord.Base", "reload", MIXED); + REG_METHOD("ActiveRecord.Base", "valid?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "invalid?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "persisted?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "new_record?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "errors", MIXED); + REG_METHOD("ActiveRecord.Base", "attributes", T_HASH); + REG_METHOD("ActiveRecord.Base", "assign_attributes", MIXED); + REG_METHOD("ActiveRecord.Base", "update_attribute", T_BOOL); + REG_METHOD("ActiveRecord.Base", "touch", T_BOOL); + REG_METHOD("ActiveRecord.Base", "id", T_INT); + + /* ── Rails: ActionController::Base / ::API ── */ + REG_TYPE("ActionController.Base"); + REG_TYPE("ActionController.API"); + static const char *ac_receivers[] = {"ActionController.Base", "ActionController.API", NULL}; + for (int i = 0; ac_receivers[i]; i++) { + const char *acr = ac_receivers[i]; + REG_METHOD(acr, "render", MIXED); + REG_METHOD(acr, "redirect_to", MIXED); + REG_METHOD(acr, "head", MIXED); + REG_METHOD(acr, "params", MIXED); + REG_METHOD(acr, "session", MIXED); + REG_METHOD(acr, "cookies", MIXED); + REG_METHOD(acr, "request", MIXED); + REG_METHOD(acr, "response", MIXED); + REG_METHOD(acr, "respond_to", MIXED); + REG_METHOD(acr, "flash", MIXED); + } + REG_SMETHOD("ActionController.Base", "before_action", MIXED); + REG_SMETHOD("ActionController.Base", "after_action", MIXED); + REG_SMETHOD("ActionController.Base", "skip_before_action", MIXED); + REG_SMETHOD("ActionController.API", "before_action", MIXED); + REG_SMETHOD("ActionController.API", "after_action", MIXED); + + /* ── Rails: jobs / mailers (constants only, for superclass chains) ── */ + REG_TYPE("ActiveJob.Base"); + REG_TYPE("ApplicationJob"); + REG_TYPE("ActionMailer.Base"); + REG_TYPE("ApplicationMailer"); + REG_TYPE("ApplicationController"); + REG_SMETHOD("ActiveJob.Base", "perform_later", MIXED); + REG_SMETHOD("ActiveJob.Base", "perform_now", MIXED); + REG_SMETHOD("ActiveJob.Base", "set", MIXED); + REG_METHOD("ActionMailer.Base", "mail", MIXED); + REG_SMETHOD("ActionMailer.Base", "with", MIXED); + + /* ── Minitest/RSpec anchors (superclass chains in test files) ── */ + REG_TYPE("Minitest.Test"); + REG_TYPE("ActiveSupport.TestCase"); + REG_TYPE("ActionDispatch.IntegrationTest"); +} diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c new file mode 100644 index 000000000..2ca944df2 --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.c @@ -0,0 +1,1261 @@ +/* + * ruby_lsp.c — Ruby Light Semantic Pass. + * + * In-process type-aware call resolver for Ruby. Mirrors the perl_lsp.c / + * php_lsp.c shape: + * 1. Build a CBMTypeRegistry from file-local definitions + stdlib + * (Ruby core classes + curated Rails surface), with per-class method + * tables keyed by receiver QN. Singleton (class-side) methods are + * keyed ".self" — Ruby constants cannot be lowercase, so + * the suffix never collides with a real nested-constant path. + * 2. ruby_lsp_process_file does a multi-pass walk: + * PASS 1 — collect class/module declarations (with lexical nesting), + * superclasses, include/prepend/extend mixins, and method tables + * (instance vs singleton classified from the AST — `def m` vs + * `def self.m` / `class << self`). + * PASS 1.5 — infer instance-variable types from `@x = Const.new` + * assignments inside method bodies (conflicting types latch to + * "conflicted" and resolve to nothing). + * PASS 2 — walk method bodies, track local variable types through + * assignments, and resolve call expressions into CBMResolvedCall + * edges via Ruby method lookup (prepends → own → includes → + * superclass chain; extends feed the singleton side). + * + * Verified tree-sitter-ruby node/field names (vendored compiled grammar at + * internal/cbm/vendored/grammars/ruby/parser.c — ts_symbol_names and + * ts_field_names tables): + * - call : fields `receiver`, `method`, `arguments`, `block`. Both + * `foo(x)` and command calls (`foo x`) surface as "call" nodes. + * - class : fields `name` (constant | scope_resolution), `superclass` + * (a `superclass` wrapper node), `body` (body_statement). + * - module : fields `name`, `body`. + * - method : fields `name`, `parameters`, `body`. + * - singleton_method : fields `object` (self | constant), `name`, `body`. + * - singleton_class : `class << self` — fields `value`, body children. + * - assignment : fields `left`, `right`. + * - scope_resolution : fields `scope` (may be absent for ::Foo), `name`. + * - leaves: constant, identifier, self, instance_variable, super, + * string, array, hash, integer, float, simple_symbol, regex. + * + * QN scheme (matches the structural extractor): Ruby HAS class node types + * (class/module), so defs are named `module_qn..` with + * parent_class = `module_qn.`. Constructor calls: the textual + * extractor rewrites `Widget.new` to callee "Widget" (extract_calls.c), so + * constructor rows emit the CLASS QN — the edge lands on the Class node, + * exactly like Python's lsp_constructor. + * + * Zero-edge guarantee: if a receiver's type is unknown/unindexed, NO edge + * is emitted (false edges are worse than missing edges). Dynamic dispatch + * (`send`, `public_send`, `method_missing`, `define_method`) is + * intentionally ignored. + */ + +#include "ruby_lsp.h" +#include "../helpers.h" +#include "../arena.h" +#include +#include +#include +#include + +/* Recursion cap for ruby_eval_expr_type — mirrors perl/php (unknown past 8). */ +#define RUBY_EVAL_MAX_DEPTH 8 + +/* Confidence levels. */ +#define RUBY_CONF_HIGH 0.95f /* constructor / static constant dispatch */ +#define RUBY_CONF_TYPED 0.90f /* inferred receiver type (locals, ivars, chains) */ + +/* Maximum AST-walk recursion depth (see scope.h rationale — graceful + * degradation on pathologically nested sources, never a crash). */ +#define CBM_LSP_RUBY_MAX_WALK_DEPTH 512 + +/* ── forward declarations ───────────────────────────────────────── */ + +static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node); +static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node); +static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node); +static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node); +static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node); +static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node); +static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit); + +/* ── helpers ────────────────────────────────────────────────────── */ + +static char *ruby_node_text(RubyLSPContext *ctx, TSNode node) { + return cbm_node_text(ctx->arena, node, ctx->source); +} + +/* Convert a constant expression node (constant | scope_resolution) into a + * dotted path ("Foo", "A.B.C"). Returns NULL for anything else. A leading + * "::Foo" (scope_resolution without a scope) yields "Foo" — the resolver's + * table probe covers the top-level meaning. */ +static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return NULL; + const char *k = ts_node_type(node); + if (strcmp(k, "constant") == 0) + return ruby_node_text(ctx, node); + if (strcmp(k, "scope_resolution") != 0) + return NULL; + TSNode scope = ts_node_child_by_field_name(node, "scope", 5); + TSNode name = ts_node_child_by_field_name(node, "name", 4); + if (ts_node_is_null(name)) + return NULL; + char *nm = ruby_node_text(ctx, name); + if (!nm || !nm[0]) + return NULL; + if (ts_node_is_null(scope)) + return nm; /* ::Foo */ + const char *sp = ruby_const_path(ctx, scope); + if (!sp || !sp[0]) + return nm; + return cbm_arena_sprintf(ctx->arena, "%s.%s", sp, nm); +} + +/* Join two dotted path fragments; either side may be empty/NULL. */ +static const char *ruby_path_join(CBMArena *a, const char *left, const char *right) { + if (!left || !left[0]) + return right; + if (!right || !right[0]) + return left; + return cbm_arena_sprintf(a, "%s.%s", left, right); +} + +/* True when a QN belongs to the indexed project (module_qn's first segment + * is its prefix). Stdlib seed QNs ("String.upcase", "ActiveRecord.Base") + * never carry the project prefix, so edges are only ever emitted at + * project-defined targets. */ +static bool ruby_is_project_qn(RubyLSPContext *ctx, const char *qn) { + if (!qn || !ctx->module_qn || !ctx->module_qn[0]) + return false; + const char *dot = strchr(ctx->module_qn, '.'); + size_t plen = dot ? (size_t)(dot - ctx->module_qn) : strlen(ctx->module_qn); + return strncmp(qn, ctx->module_qn, plen) == 0 && qn[plen] == '.'; +} + +/* ── table growth helpers ───────────────────────────────────────── */ + +static RubyClassInfo *ruby_add_class(RubyLSPContext *ctx, const char *path, const char *qn, + bool is_module) { + if (!ctx || !path || !qn) + return NULL; + /* Reopened class/module: reuse the existing record. */ + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 0) + return &ctx->classes[i]; + } + if (ctx->class_count >= ctx->class_cap) { + int newcap = ctx->class_cap ? ctx->class_cap * 2 : 8; + RubyClassInfo *nc = + (RubyClassInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyClassInfo)); + if (!nc) + return NULL; + for (int i = 0; i < ctx->class_count; i++) + nc[i] = ctx->classes[i]; + ctx->classes = nc; + ctx->class_cap = newcap; + } + RubyClassInfo *ci = &ctx->classes[ctx->class_count++]; + memset(ci, 0, sizeof(*ci)); + ci->path = cbm_arena_strdup(ctx->arena, path); + ci->qn = cbm_arena_strdup(ctx->arena, qn); + ci->is_module = is_module; + return ci; +} + +static void ruby_add_mixin(RubyLSPContext *ctx, const char *owner_qn, const char *module_ref, + RubyMixinKind kind) { + if (!ctx || !owner_qn || !module_ref) + return; + if (ctx->mixin_count >= ctx->mixin_cap) { + int newcap = ctx->mixin_cap ? ctx->mixin_cap * 2 : 8; + RubyMixinInfo *nm = + (RubyMixinInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyMixinInfo)); + if (!nm) + return; + for (int i = 0; i < ctx->mixin_count; i++) + nm[i] = ctx->mixins[i]; + ctx->mixins = nm; + ctx->mixin_cap = newcap; + } + RubyMixinInfo *mi = &ctx->mixins[ctx->mixin_count++]; + memset(mi, 0, sizeof(*mi)); + mi->owner_qn = cbm_arena_strdup(ctx->arena, owner_qn); + mi->module_ref = cbm_arena_strdup(ctx->arena, module_ref); + mi->kind = kind; +} + +static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char *ivar_name, + const char *type_qn) { + if (!ctx || !class_qn || !ivar_name || !type_qn) + return; + for (int i = 0; i < ctx->ivar_count; i++) { + RubyIvarInfo *iv = &ctx->ivars[i]; + if (strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) { + if (strcmp(iv->type_qn, type_qn) != 0) + iv->conflicted = true; /* disagreeing assignments — no type */ + return; + } + } + if (ctx->ivar_count >= ctx->ivar_cap) { + int newcap = ctx->ivar_cap ? ctx->ivar_cap * 2 : 8; + RubyIvarInfo *ni = + (RubyIvarInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyIvarInfo)); + if (!ni) + return; + for (int i = 0; i < ctx->ivar_count; i++) + ni[i] = ctx->ivars[i]; + ctx->ivars = ni; + ctx->ivar_cap = newcap; + } + RubyIvarInfo *iv = &ctx->ivars[ctx->ivar_count++]; + memset(iv, 0, sizeof(*iv)); + iv->class_qn = cbm_arena_strdup(ctx->arena, class_qn); + iv->ivar_name = cbm_arena_strdup(ctx->arena, ivar_name); + iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); +} + +static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + if (!ctx || !class_qn || !ivar_name) + return NULL; + for (int i = 0; i < ctx->ivar_count; i++) { + RubyIvarInfo *iv = &ctx->ivars[i]; + if (!iv->conflicted && strcmp(iv->class_qn, class_qn) == 0 && + strcmp(iv->ivar_name, ivar_name) == 0) + return iv->type_qn; + } + return NULL; +} + +static RubyClassInfo *ruby_class_by_qn(RubyLSPContext *ctx, const char *qn) { + if (!qn) + return NULL; + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].qn, qn) == 0) + return &ctx->classes[i]; + } + return NULL; +} + +/* Conventional Rails superclasses for stdlib-seeded types the class table + * does not carry (per-file extraction cannot see app/models/ + * application_record.rb from another file; the convention is fixed). */ +static const char *ruby_stdlib_superclass(const char *qn) { + if (!qn) + return NULL; + if (strcmp(qn, "ApplicationRecord") == 0) + return "ActiveRecord.Base"; + if (strcmp(qn, "ApplicationController") == 0) + return "ActionController.Base"; + if (strcmp(qn, "ApplicationJob") == 0) + return "ActiveJob.Base"; + if (strcmp(qn, "ApplicationMailer") == 0) + return "ActionMailer.Base"; + return NULL; +} + +/* Superclass QN of a class: table entry first, then the conventional + * stdlib chain above. */ +static const char *ruby_superclass_qn(RubyLSPContext *ctx, const char *class_qn) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (ci) + return ci->superclass_qn; + return ruby_stdlib_superclass(class_qn); +} + +/* ── constant resolution ────────────────────────────────────────── */ + +const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path) { + if (!ctx || !path || !path[0]) + return NULL; + /* Lexical nesting probe: for nesting "A.B" try "A.B.path", "A.path", + * then "path" (innermost first — Ruby's constant lookup order). */ + const char *nest = ctx->nesting ? ctx->nesting : ""; + char prefix[512]; + size_t nlen = strlen(nest); + if (nlen < sizeof(prefix)) { + memcpy(prefix, nest, nlen + 1); + while (1) { + if (prefix[0]) { + const char *cand = cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, path); + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, cand) == 0) + return ctx->classes[i].qn; + } + char *last_dot = strrchr(prefix, '.'); + if (last_dot) + *last_dot = '\0'; + else + prefix[0] = '\0'; + continue; + } + break; + } + } + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 0) + return ctx->classes[i].qn; + } + /* Stdlib types are keyed by their dotted bare path. */ + const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, path); + if (t) + return t->qualified_name; + return NULL; +} + +/* ── method lookup (Ruby MRO approximation) ─────────────────────── */ + +/* Depth cap shared by the instance/singleton lookup walks. */ +#define RUBY_LOOKUP_MAX_DEPTH CBM_LSP_MAX_LOOKUP_DEPTH + +/* Own instance method of one exact class (no ancestry walk). */ +static const CBMRegisteredFunc *ruby_own_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + return cbm_registry_lookup_method(ctx->registry, class_qn, method_name); +} + +/* Own singleton method of one exact class (".self" receiver key). */ +static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + const char *key = cbm_arena_sprintf(ctx->arena, "%s.self", class_qn); + return cbm_registry_lookup_method(ctx->registry, key, method_name); +} + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth); + +/* Search a mixin kind on `owner_qn` (most recent first — later mixins win), + * recursing into each module's own include chain. */ +static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const char *owner_qn, + RubyMixinKind kind, const char *method_name, + int depth) { + for (int i = ctx->mixin_count - 1; i >= 0; i--) { + RubyMixinInfo *mi = &ctx->mixins[i]; + if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0 || !mi->module_qn) + continue; + const CBMRegisteredFunc *f = + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1); + if (f) + return f; + } + return NULL; +} + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth) { + if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) + return NULL; + /* Prepended modules shadow the class's own methods. */ + const CBMRegisteredFunc *f = + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth); + if (f) + return f; + f = ruby_own_instance_method(ctx, class_qn, method_name); + if (f) + return f; + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth); + if (f) + return f; + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + const CBMRegisteredFunc *f = ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0); + if (f) + return f; + /* Universal receiver fallback (Object / ActiveSupport predicates) — + * typing only; Object methods are stdlib entries, never project defs. */ + if (class_qn && strcmp(class_qn, "Object") != 0) + return cbm_registry_lookup_method(ctx->registry, "Object", method_name); + return NULL; +} + +static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth) { + if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) + return NULL; + const CBMRegisteredFunc *f = ruby_own_singleton_method(ctx, class_qn, method_name); + if (f) + return f; + /* `extend Mod` adds Mod's instance methods class-side. */ + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth); + if (f) + return f; + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0); +} + +/* ── ActiveRecord model detection (typing special case) ─────────── */ + +/* True when class_qn's superclass chain reaches ActiveRecord::Base (or the + * conventional ApplicationRecord intermediary, which per-file extraction + * usually cannot see through). */ +static bool ruby_is_ar_model(RubyLSPContext *ctx, const char *class_qn, int depth) { + if (!class_qn || depth > RUBY_LOOKUP_MAX_DEPTH) + return false; + if (strcmp(class_qn, "ActiveRecord.Base") == 0 || strcmp(class_qn, "ApplicationRecord") == 0) + return true; + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (ci && ci->superclass_ref && + (strcmp(ci->superclass_ref, "ApplicationRecord") == 0 || + strcmp(ci->superclass_ref, "ActiveRecord.Base") == 0)) + return true; + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_is_ar_model(ctx, sup, depth + 1); + return false; +} + +/* ActiveRecord class-side query methods that return the model (or a + * relation we approximate as the model, so chains like + * `User.where(...).first` keep their type). */ +static bool ruby_ar_returns_model(const char *m) { + static const char *names[] = {"find", + "find_by", + "find_by!", + "find_or_create_by", + "find_or_initialize_by", + "where", + "all", + "first", + "last", + "take", + "create", + "create!", + "new", + "order", + "limit", + "offset", + "joins", + "left_joins", + "includes", + "preload", + "eager_load", + "group", + "having", + "distinct", + "none", + "unscoped", + "find_each", + NULL}; + for (int i = 0; names[i]; i++) { + if (strcmp(m, names[i]) == 0) + return true; + } + return false; +} + +/* ── emit ───────────────────────────────────────────────────────── */ + +static void ruby_emit_resolved(RubyLSPContext *ctx, const char *callee_qn, const char *strategy, + float confidence, TSNode site) { + if (!ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn) + return; + /* Only project-defined targets become edges (stdlib rows would never + * land on a node and merely add noise). */ + if (!ruby_is_project_qn(ctx, callee_qn)) + return; + CBMResolvedCall rc = {0}; + rc.caller_qn = ctx->enclosing_func_qn; + rc.callee_qn = callee_qn; + rc.strategy = strategy; + rc.confidence = confidence; + rc.reason = NULL; + rc.kind = CBM_RESOLVED_INVOCATION; + if (!ts_node_is_null(site)) { + rc.site_start_byte = ts_node_start_byte(site); + rc.site_end_byte = ts_node_end_byte(site); + } + cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc); +} + +/* ── expression typing ──────────────────────────────────────────── */ + +/* Return type of a registered func's signature, or unknown. */ +static const CBMType *ruby_func_return_type(const CBMRegisteredFunc *f) { + if (f && f->signature && f->signature->kind == CBM_TYPE_FUNC && + f->signature->data.func.return_types && f->signature->data.func.return_types[0]) { + return f->signature->data.func.return_types[0]; + } + return cbm_type_unknown(); +} + +const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return cbm_type_unknown(); + if (ctx->eval_depth >= RUBY_EVAL_MAX_DEPTH) + return cbm_type_unknown(); + ctx->eval_depth++; + const CBMType *result = cbm_type_unknown(); + const char *k = ts_node_type(node); + + if (strcmp(k, "identifier") == 0) { + char *txt = ruby_node_text(ctx, node); + if (txt) { + const CBMType *t = cbm_scope_lookup(ctx->current_scope, txt); + if (t) + result = t; + } + } else if (strcmp(k, "instance_variable") == 0) { + char *txt = ruby_node_text(ctx, node); + const char *tqn = ruby_ivar_type(ctx, ctx->enclosing_class_qn, txt); + if (tqn) + result = cbm_type_named(ctx->arena, tqn); + } else if (strcmp(k, "self") == 0) { + if (ctx->enclosing_class_qn && !ctx->in_singleton_method) + result = cbm_type_named(ctx->arena, ctx->enclosing_class_qn); + } else if (strcmp(k, "call") == 0) { + result = ruby_eval_call_type(ctx, node, false); + } else if (strcmp(k, "assignment") == 0) { + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(right)) + result = ruby_eval_expr_type(ctx, right); + } else if (strcmp(k, "string") == 0 || strcmp(k, "string_content") == 0 || + strcmp(k, "heredoc_body") == 0 || strcmp(k, "chained_string") == 0) { + result = cbm_type_named(ctx->arena, "String"); + } else if (strcmp(k, "array") == 0 || strcmp(k, "string_array") == 0 || + strcmp(k, "symbol_array") == 0) { + result = cbm_type_named(ctx->arena, "Array"); + } else if (strcmp(k, "hash") == 0) { + result = cbm_type_named(ctx->arena, "Hash"); + } else if (strcmp(k, "integer") == 0) { + result = cbm_type_named(ctx->arena, "Integer"); + } else if (strcmp(k, "float") == 0) { + result = cbm_type_named(ctx->arena, "Float"); + } else if (strcmp(k, "simple_symbol") == 0 || strcmp(k, "delimited_symbol") == 0) { + result = cbm_type_named(ctx->arena, "Symbol"); + } else if (strcmp(k, "regex") == 0) { + result = cbm_type_named(ctx->arena, "Regexp"); + } else if (strcmp(k, "parenthesized_statements") == 0 || strcmp(k, "begin") == 0) { + /* Type of the last meaningful child. */ + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = nc; i > 0; i--) { + TSNode c = ts_node_named_child(node, i - 1); + if (ts_node_is_null(c)) + continue; + const char *ck = ts_node_type(c); + if (strcmp(ck, "comment") == 0) + continue; + result = ruby_eval_expr_type(ctx, c); + break; + } + } + + ctx->eval_depth--; + return result; +} + +/* ── call resolution core ───────────────────────────────────────── */ + +/* Dynamic-dispatch method names the resolver must never guess through. */ +static bool ruby_is_dynamic_dispatch(const char *m) { + return strcmp(m, "send") == 0 || strcmp(m, "public_send") == 0 || strcmp(m, "__send__") == 0 || + strcmp(m, "method_missing") == 0 || strcmp(m, "define_method") == 0 || + strcmp(m, "instance_eval") == 0 || strcmp(m, "class_eval") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "instance_variable_get") == 0 || + strcmp(m, "instance_variable_set") == 0; +} + +/* Class-body macro-ish names PASS 1 already interpreted (or that never + * denote resolvable user calls). */ +static bool ruby_is_body_macro(const char *m) { + return strcmp(m, "include") == 0 || strcmp(m, "extend") == 0 || strcmp(m, "prepend") == 0 || + strcmp(m, "require") == 0 || strcmp(m, "require_relative") == 0 || + strcmp(m, "attr_accessor") == 0 || strcmp(m, "attr_reader") == 0 || + strcmp(m, "attr_writer") == 0 || strcmp(m, "private") == 0 || strcmp(m, "public") == 0 || + strcmp(m, "protected") == 0 || strcmp(m, "module_function") == 0 || + strcmp(m, "alias_method") == 0 || strcmp(m, "loop") == 0 || strcmp(m, "lambda") == 0 || + strcmp(m, "proc") == 0; +} + +/* Resolve a singleton-side dispatch on a resolved class QN. Emits (when + * `emit`) and returns the call's type. Also owns the ActiveRecord typing + * special case (User.find -> User). */ +static const CBMType *ruby_dispatch_singleton(RubyLSPContext *ctx, const char *class_qn, + const char *mname, bool emit, TSNode site) { + const CBMRegisteredFunc *f = ruby_lookup_singleton_method(ctx, class_qn, mname); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_class_method", RUBY_CONF_HIGH, site); + const CBMType *rt = ruby_func_return_type(f); + if (!cbm_type_is_unknown(rt)) + return rt; + /* Stdlib AR query entries return unknown; substitute the model. */ + if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname)) + return cbm_type_named(ctx->arena, class_qn); + return rt; + } + if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname)) + return cbm_type_named(ctx->arena, class_qn); + return cbm_type_unknown(); +} + +/* Resolve an instance-side dispatch on a receiver type QN. */ +static const CBMType *ruby_dispatch_instance(RubyLSPContext *ctx, const char *class_qn, + const char *mname, const char *strategy, bool emit, + TSNode site) { + const CBMRegisteredFunc *f = ruby_lookup_instance_method(ctx, class_qn, mname); + if (!f) + return cbm_type_unknown(); + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, strategy, RUBY_CONF_TYPED, site); + return ruby_func_return_type(f); +} + +/* Resolve one `call` node. When `emit` is set, resolved project targets are + * appended as CBMResolvedCall edges; the call's result type is returned + * either way (so chains and assignments can type through it). */ +static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit) { + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (ts_node_is_null(meth)) + return cbm_type_unknown(); + + /* `super(...)` is dispatched by the walker (ruby_resolve_super); as an + * expression its type is not modeled. */ + if (strcmp(ts_node_type(meth), "super") == 0) + return cbm_type_unknown(); + + char *mname = ruby_node_text(ctx, meth); + if (!mname || !mname[0]) + return cbm_type_unknown(); + if (ruby_is_dynamic_dispatch(mname)) + return cbm_type_unknown(); /* zero-edge guarantee */ + + /* Constructor: Const.new / A::B.new — the textual extractor rewrites + * the callee to the constant, so the row targets the CLASS QN. */ + if (strcmp(mname, "new") == 0 && !ts_node_is_null(recv)) { + const char *cpath = ruby_const_path(ctx, recv); + if (cpath) { + const char *cqn = ruby_resolve_constant(ctx, cpath); + if (cqn) { + if (emit) + ruby_emit_resolved(ctx, cqn, "ruby_constructor", RUBY_CONF_HIGH, call); + return cbm_type_named(ctx->arena, cqn); + } + return cbm_type_unknown(); + } + /* Fall through: `expr.new` on a non-constant receiver. */ + } + + if (ts_node_is_null(recv)) { + /* Bare call: self-dispatch inside methods, then file-level funcs. */ + if (ruby_is_body_macro(mname)) + return cbm_type_unknown(); + if (ctx->enclosing_class_qn) { + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + } + const CBMRegisteredFunc *f = + cbm_registry_lookup_symbol(ctx->registry, ctx->module_qn, mname); + if (!f) + f = cbm_registry_lookup_func(ctx->registry, mname); /* Kernel builtins */ + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_function_local", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + return cbm_type_unknown(); + } + + const char *rk = ts_node_type(recv); + + /* self.m — explicit self dispatch. */ + if (strcmp(rk, "self") == 0) { + if (!ctx->enclosing_class_qn) + return cbm_type_unknown(); + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + return cbm_type_unknown(); + } + + /* Const.m / A::B.m — singleton dispatch on the resolved class. */ + if (strcmp(rk, "constant") == 0 || strcmp(rk, "scope_resolution") == 0) { + const char *cpath = ruby_const_path(ctx, recv); + const char *cqn = cpath ? ruby_resolve_constant(ctx, cpath) : NULL; + if (!cqn) + return cbm_type_unknown(); /* unknown constant — zero-edge */ + return ruby_dispatch_singleton(ctx, cqn, mname, emit, call); + } + + /* Typed receiver: locals, ivars, chained calls, literals. */ + const CBMType *rt = ruby_eval_expr_type(ctx, recv); + if (!rt || rt->kind != CBM_TYPE_NAMED) + return cbm_type_unknown(); /* unknown receiver — zero-edge */ + const char *strategy = "ruby_method_typed"; + if (strcmp(rk, "instance_variable") == 0) + strategy = "ruby_ivar_method"; + else if (strcmp(rk, "call") == 0) + strategy = "ruby_method_chained"; + const CBMType *out = + ruby_dispatch_instance(ctx, rt->data.named.qualified_name, mname, strategy, emit, call); + if (!cbm_type_is_unknown(out)) + return out; + /* AR relation approximation: chains on a model type keep the model. */ + if (ruby_is_ar_model(ctx, rt->data.named.qualified_name, 0) && ruby_ar_returns_model(mname)) + return rt; + return out; +} + +/* `super` / `super(...)`: resolve the enclosing method's name starting at + * the enclosing class's ancestry (own def skipped — that IS the caller). */ +static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { + if (!ctx->enclosing_class_qn || !ctx->enclosing_func_qn) + return; + const char *fq = ctx->enclosing_func_qn; + const char *mname = strrchr(fq, '.'); + mname = mname ? mname + 1 : fq; + const CBMRegisteredFunc *f = NULL; + /* Search order mirrors Ruby: includes of the class, then superclass + * chain — the class's OWN def is the caller and must be skipped. */ + f = ruby_lookup_in_mixins(ctx, ctx->enclosing_class_qn, + ctx->in_singleton_method ? RUBY_MIXIN_EXTEND : RUBY_MIXIN_INCLUDE, + mname, 0); + if (!f) { + const char *sup = ruby_superclass_qn(ctx, ctx->enclosing_class_qn); + if (sup) { + f = ctx->in_singleton_method ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0); + } + } + if (f) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); + /* No known parent or unresolved — zero-edge guarantee. */ +} + +/* ── assignment observer (scope binding) ────────────────────────── */ + +static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) { + TSNode left = ts_node_child_by_field_name(assign, "left", 4); + TSNode right = ts_node_child_by_field_name(assign, "right", 5); + if (ts_node_is_null(left) || ts_node_is_null(right)) + return; + if (strcmp(ts_node_type(left), "identifier") != 0) + return; /* only simple local targets are tracked */ + char *vname = ruby_node_text(ctx, left); + if (!vname || !vname[0]) + return; + const CBMType *rt = ruby_eval_expr_type(ctx, right); + if (rt && rt->kind == CBM_TYPE_NAMED) + cbm_scope_bind(ctx->current_scope, vname, rt); +} + +/* ── PASS 2: resolution walk ────────────────────────────────────── */ + +/* Process one method/singleton_method definition node. */ +static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton) { + CBMScope *saved_scope = ctx->current_scope; + const char *saved_func = ctx->enclosing_func_qn; + bool saved_singleton = ctx->in_singleton_method; + + ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope); + ctx->in_singleton_method = singleton; + + TSNode name = ts_node_child_by_field_name(node, "name", 4); + char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); + if (mname && mname[0]) { + const char *owner = ctx->enclosing_class_qn ? ctx->enclosing_class_qn : ctx->module_qn; + if (owner) + ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname); + else + ctx->enclosing_func_qn = cbm_arena_strdup(ctx->arena, mname); + } + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_resolve_calls_in_node(ctx, body); + + ctx->current_scope = saved_scope; + ctx->enclosing_func_qn = saved_func; + ctx->in_singleton_method = saved_singleton; +} + +/* Process a class/module node during PASS 2: update nesting + enclosing + * class, then walk the body. */ +static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return; + + const char *saved_nesting = ctx->nesting; + const char *saved_class = ctx->enclosing_class_qn; + bool saved_singleton = ctx->in_singleton_method; + + ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); + const char *qn = ruby_resolve_constant(ctx, cpath); + ctx->enclosing_class_qn = qn; + ctx->in_singleton_method = false; + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_resolve_calls_in_node(ctx, body); + + ctx->nesting = saved_nesting; + ctx->enclosing_class_qn = saved_class; + ctx->in_singleton_method = saved_singleton; +} + +static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_resolve_calls_in_node_inner(ctx, node); + ctx->walk_depth--; +} + +static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { + ruby_process_class_body(ctx, node); + return; + } + if (strcmp(k, "singleton_class") == 0) { + /* `class << self` — methods inside are singleton methods. */ + bool saved = ctx->in_singleton_method; + ctx->in_singleton_method = true; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) + ruby_resolve_calls_in_node(ctx, ts_node_named_child(node, i)); + ctx->in_singleton_method = saved; + return; + } + if (strcmp(k, "method") == 0) { + ruby_process_method(ctx, node, ctx->in_singleton_method); + return; + } + if (strcmp(k, "singleton_method") == 0) { + ruby_process_method(ctx, node, true); + return; + } + + if (strcmp(k, "assignment") == 0) + ruby_process_assignment(ctx, node); + + if (strcmp(k, "call") == 0) { + TSNode meth = ts_node_child_by_field_name(node, "method", 6); + if (!ts_node_is_null(meth) && strcmp(ts_node_type(meth), "super") == 0) + ruby_resolve_super(ctx, node); + else + ruby_eval_call_type(ctx, node, true); + /* Recurse into receiver/arguments/blocks for nested calls. */ + } else if (strcmp(k, "super") == 0) { + /* Bare `super` (no argument list) appears as a standalone node. */ + ruby_resolve_super(ctx, node); + return; + } + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_resolve_calls_in_node(ctx, c); + } +} + +/* ── PASS 1: class/mixin/method collection ──────────────────────── */ + +/* Register one method into the registry with the proper receiver key. */ +static void ruby_register_method(RubyLSPContext *ctx, CBMTypeRegistry *reg, const char *owner_qn, + const char *mname, bool singleton) { + if (!mname || !mname[0]) + return; + CBMRegisteredFunc rf; + memset(&rf, 0, sizeof(rf)); + rf.min_params = -1; + const char *owner = owner_qn ? owner_qn : ctx->module_qn; + rf.qualified_name = owner ? cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname) + : cbm_arena_strdup(ctx->arena, mname); + rf.short_name = cbm_arena_strdup(ctx->arena, mname); + if (owner_qn) { + rf.receiver_type = singleton ? cbm_arena_sprintf(ctx->arena, "%s.self", owner_qn) + : cbm_arena_strdup(ctx->arena, owner_qn); + } + const CBMType **rets = (const CBMType **)cbm_arena_alloc(ctx->arena, 2 * sizeof(*rets)); + if (rets) { + rets[0] = cbm_type_unknown(); + rets[1] = NULL; + } + rf.signature = cbm_type_func(ctx->arena, NULL, NULL, rets); + cbm_registry_add_func(reg, rf); +} + +/* PASS-1 refs are nesting-scoped: the finalize step resolves superclass and + * mixin references once the class table is complete, so PASS 1 records raw + * dotted refs plus the nesting string active at the declaration site + * (packed as "|" when nesting is set). */ +static const char *ruby_pack_ref(RubyLSPContext *ctx, const char *ref) { + const char *nest = ctx->nesting ? ctx->nesting : ""; + if (!nest[0]) + return ref; + return cbm_arena_sprintf(ctx->arena, "%s|%s", nest, ref); +} + +/* Resolve a packed "|" (or bare "") against the class + * table + stdlib, honoring the recorded lexical nesting. */ +static const char *ruby_resolve_packed_ref(RubyLSPContext *ctx, const char *packed) { + if (!packed) + return NULL; + const char *bar = strchr(packed, '|'); + const char *saved_nesting = ctx->nesting; + const char *ref = packed; + if (bar) { + ctx->nesting = cbm_arena_strndup(ctx->arena, packed, (size_t)(bar - packed)); + ref = bar + 1; + } else { + ctx->nesting = ""; + } + const char *qn = ruby_resolve_constant(ctx, ref); + ctx->nesting = saved_nesting; + return qn; +} + +/* Bare ref portion of a packed "|" string. */ +static const char *ruby_packed_bare_ref(const char *packed) { + if (!packed) + return NULL; + const char *bar = strchr(packed, '|'); + return bar ? bar + 1 : packed; +} + +/* Interpret a class-body `include X` / `prepend X` / `extend X` call, + * recording the mixin with the nesting packed for finalize-time resolve. */ +static void ruby_collect_mixin_call(RubyLSPContext *ctx, TSNode call) { + if (!ctx->enclosing_class_qn) + return; + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (ts_node_is_null(meth)) + return; + if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "self") != 0) + return; /* not a bare/self class-body macro */ + char *mname = ruby_node_text(ctx, meth); + if (!mname) + return; + RubyMixinKind kind; + if (strcmp(mname, "include") == 0) + kind = RUBY_MIXIN_INCLUDE; + else if (strcmp(mname, "prepend") == 0) + kind = RUBY_MIXIN_PREPEND; + else if (strcmp(mname, "extend") == 0) + kind = RUBY_MIXIN_EXTEND; + else + return; + TSNode args = ts_node_child_by_field_name(call, "arguments", 9); + if (ts_node_is_null(args)) + return; + uint32_t nc = ts_node_named_child_count(args); + for (uint32_t i = 0; i < nc; i++) { + const char *mpath = ruby_const_path(ctx, ts_node_named_child(args, i)); + if (!mpath) + continue; /* `extend self` etc. — not a constant */ + ruby_add_mixin(ctx, ctx->enclosing_class_qn, ruby_pack_ref(ctx, mpath), kind); + } +} + +static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return; + + const char *saved_nesting = ctx->nesting; + const char *full_path = ruby_path_join(ctx->arena, saved_nesting, cpath); + const char *qn = ctx->module_qn + ? cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, full_path) + : full_path; + RubyClassInfo *ci = ruby_add_class(ctx, full_path, qn, is_module); + + /* Superclass: `class C < Base` — the `superclass` field wraps the + * expression after `<`. */ + if (ci && !is_module && !ci->superclass_ref) { + TSNode sup = ts_node_child_by_field_name(node, "superclass", 10); + if (!ts_node_is_null(sup)) { + uint32_t sc = ts_node_named_child_count(sup); + for (uint32_t i = 0; i < sc; i++) { + const char *spath = ruby_const_path(ctx, ts_node_named_child(sup, i)); + if (spath) { + ci->superclass_ref = ruby_pack_ref(ctx, spath); + break; + } + } + } + } + + ctx->nesting = full_path; + const char *saved_class = ctx->enclosing_class_qn; + bool saved_singleton = ctx->in_singleton_method; + ctx->enclosing_class_qn = qn; + ctx->in_singleton_method = false; + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_pass1_scan(ctx, body); + ctx->nesting = saved_nesting; + ctx->enclosing_class_qn = saved_class; + ctx->in_singleton_method = saved_singleton; +} + +static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_pass1_scan_inner(ctx, node); + ctx->walk_depth--; +} + +/* Registry under construction during PASS 1 (methods are registered as + * they are discovered). Held in a file-scope slot only for the duration of + * cbm_run_ruby_lsp's pass-1 invocation — single-threaded per file. */ +static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0) { + ruby_pass1_class(ctx, node, false); + return; + } + if (strcmp(k, "module") == 0) { + ruby_pass1_class(ctx, node, true); + return; + } + if (strcmp(k, "singleton_class") == 0) { + bool saved = ctx->in_singleton_method; + ctx->in_singleton_method = true; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) + ruby_pass1_scan(ctx, ts_node_named_child(node, i)); + ctx->in_singleton_method = saved; + return; + } + if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; + TSNode name = ts_node_child_by_field_name(node, "name", 4); + char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); + if (ctx->build_reg) + ruby_register_method(ctx, ctx->build_reg, ctx->enclosing_class_qn, mname, singleton); + return; /* method bodies are PASS-2 territory */ + } + if (strcmp(k, "call") == 0) { + TSNode meth = ts_node_child_by_field_name(node, "method", 6); + if (!ts_node_is_null(meth)) { + char *mn = ruby_node_text(ctx, meth); + if (mn && (strcmp(mn, "include") == 0 || strcmp(mn, "prepend") == 0 || + strcmp(mn, "extend") == 0)) { + ruby_collect_mixin_call(ctx, node); + return; + } + } + } + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_pass1_scan(ctx, c); + } +} + +/* Finalize PASS 1: resolve superclass + mixin references now that the class + * table is complete. */ +static void ruby_finalize_refs(RubyLSPContext *ctx) { + for (int i = 0; i < ctx->class_count; i++) { + RubyClassInfo *ci = &ctx->classes[i]; + if (ci->superclass_ref) { + ci->superclass_qn = ruby_resolve_packed_ref(ctx, ci->superclass_ref); + /* Keep the bare ref spelling for AR-model heuristics. */ + ci->superclass_ref = ruby_packed_bare_ref(ci->superclass_ref); + } + } + for (int i = 0; i < ctx->mixin_count; i++) { + RubyMixinInfo *mi = &ctx->mixins[i]; + mi->module_qn = ruby_resolve_packed_ref(ctx, mi->module_ref); + mi->module_ref = ruby_packed_bare_ref(mi->module_ref); + } +} + +/* ── PASS 1.5: instance-variable type inference ─────────────────── */ + +/* Statically evaluate `Const.new` / `A::B.new` without scope context. */ +static const char *ruby_static_ctor_type(RubyLSPContext *ctx, TSNode expr) { + if (ts_node_is_null(expr) || strcmp(ts_node_type(expr), "call") != 0) + return NULL; + TSNode meth = ts_node_child_by_field_name(expr, "method", 6); + TSNode recv = ts_node_child_by_field_name(expr, "receiver", 8); + if (ts_node_is_null(meth) || ts_node_is_null(recv)) + return NULL; + char *mname = ruby_node_text(ctx, meth); + if (!mname || strcmp(mname, "new") != 0) + return NULL; + const char *cpath = ruby_const_path(ctx, recv); + if (!cpath) + return NULL; + return ruby_resolve_constant(ctx, cpath); +} + +static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_ivar_scan_inner(ctx, node); + ctx->walk_depth--; +} + +static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return; + const char *saved_nesting = ctx->nesting; + const char *saved_class = ctx->enclosing_class_qn; + ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); + ctx->enclosing_class_qn = ruby_resolve_constant(ctx, cpath); + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_ivar_scan(ctx, body); + ctx->nesting = saved_nesting; + ctx->enclosing_class_qn = saved_class; + return; + } + + if (strcmp(k, "assignment") == 0 && ctx->enclosing_class_qn) { + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left) && !ts_node_is_null(right) && + strcmp(ts_node_type(left), "instance_variable") == 0) { + const char *tqn = ruby_static_ctor_type(ctx, right); + if (tqn) { + char *ivar = ruby_node_text(ctx, left); + ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + } + } + } + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_ivar_scan(ctx, c); + } +} + +/* ── public API ─────────────────────────────────────────────────── */ + +void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len, + const CBMTypeRegistry *registry, const char *module_qn, + CBMResolvedCallArray *out) { + memset(ctx, 0, sizeof(*ctx)); + ctx->arena = arena; + ctx->source = source; + ctx->source_len = source_len; + ctx->registry = registry; + ctx->module_qn = module_qn; + ctx->nesting = ""; + ctx->resolved_calls = out; + ctx->current_scope = cbm_scope_push(arena, NULL); + + const char *dbg = getenv("CBM_LSP_DEBUG"); + ctx->debug = (dbg && dbg[0]); +} + +/* ── entry: cbm_run_ruby_lsp ────────────────────────────────────── */ + +void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root) { + if (!result || !arena || ts_node_is_null(root)) + return; + + CBMTypeRegistry reg; + cbm_registry_init(®, arena); + + /* Phase A: stdlib (Ruby core + curated Rails surface). */ + cbm_ruby_stdlib_register(®, arena); + + const char *module_qn = result->module_qn; + + RubyLSPContext ctx; + ruby_lsp_init(&ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls); + + /* Phase B: PASS 1 — collect classes/modules, mixins, and methods from + * the AST (methods register directly into `reg`; the AST is ground + * truth for the instance-vs-singleton split the defs don't carry). */ + ctx.build_reg = ® + ruby_pass1_scan(&ctx, root); + ctx.build_reg = NULL; + ruby_finalize_refs(&ctx); + + /* Finalize the registry for O(1) lookups — index allocations go to a + * per-call scratch arena that dies with this call (see perl_lsp.c). */ + CBMArena idx_arena; + cbm_arena_init(&idx_arena); + cbm_registry_finalize_into(®, &idx_arena); + + /* Phase B.5: PASS 1.5 — instance-variable types (needs the finished + * class table + registry for constant resolution). */ + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ruby_ivar_scan(&ctx, root); + + /* Phase C: PASS 2 — resolution walk. */ + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ctx.enclosing_func_qn = NULL; + ctx.in_singleton_method = false; + ruby_resolve_calls_in_node(&ctx, root); + + if (ctx.debug) { + fprintf(stderr, "[ruby_lsp] module_qn=%s defs=%d resolved=%d classes=%d mixins=%d\n", + module_qn ? module_qn : "(null)", result->defs.count, result->resolved_calls.count, + ctx.class_count, ctx.mixin_count); + for (int i = 0; i < result->resolved_calls.count; i++) { + CBMResolvedCall *r = &result->resolved_calls.items[i]; + fprintf(stderr, "[ruby_lsp] %s -> %s [%s %.2f]\n", r->caller_qn, r->callee_qn, + r->strategy, r->confidence); + } + } + + cbm_arena_destroy(&idx_arena); +} diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h new file mode 100644 index 000000000..f6de32943 --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.h @@ -0,0 +1,148 @@ +#ifndef CBM_LSP_RUBY_LSP_H +#define CBM_LSP_RUBY_LSP_H + +#include "type_rep.h" +#include "scope.h" +#include "type_registry.h" +#include "../cbm.h" + +/* Ruby mixin kinds. Lookup order for an instance method on class C: + * prepended modules (last prepended first) → C's own methods → + * included modules (last included first) → superclass chain. + * `extend` adds a module's instance methods to C's singleton (class-side). */ +typedef enum { + RUBY_MIXIN_INCLUDE = 0, + RUBY_MIXIN_PREPEND, + RUBY_MIXIN_EXTEND, +} RubyMixinKind; + +/* One class/module discovered in the file (or, cross-file, in the project). + * `path` is the dotted source-level constant path (e.g. "Admin.User" for + * `Admin::User`); `qn` is the full graph QN (module_qn.path for file-local + * classes; the extractor QN for cross-file defs). `superclass_qn` is resolved + * against the class table + stdlib after collection; NULL when unknown. */ +typedef struct { + const char *path; + const char *qn; + const char *superclass_ref; /* raw dotted superclass spelling, or NULL */ + const char *superclass_qn; /* resolved QN (table/stdlib), or NULL */ + bool is_module; /* `module` (mixin source) vs `class` */ +} RubyClassInfo; + +/* One `include`/`prepend`/`extend` record: owner class QN → module ref. */ +typedef struct { + const char *owner_qn; /* class/module whose body held the call */ + const char *module_ref; /* raw dotted module spelling */ + const char *module_qn; /* resolved QN, or NULL */ + RubyMixinKind kind; +} RubyMixinInfo; + +/* One inferred instance-variable type: (class QN, @name) → type QN. + * `conflicted` latches when two assignments disagree — lookup then returns + * nothing (zero-edge guarantee beats a coin-flip type). */ +typedef struct { + const char *class_qn; + const char *ivar_name; /* including the leading '@' */ + const char *type_qn; + bool conflicted; +} RubyIvarInfo; + +/* RubyLSPContext — per-file state for Ruby type-aware call resolution. + * Mirrors PerlLSPContext / PHPLSPContext structure. + * + * Ruby specifics that shape this context: + * - Classes/modules nest lexically (`module A; class B`), and constants + * resolve through the lexical nesting outward (A::B sees A's constants + * before top-level ones). `nesting` tracks the current dotted path. + * - Inheritance is `class C < Base`; mixins arrive via include/prepend/ + * extend and participate in method lookup (see RubyMixinKind). + * - `Foo.new` constructs a Foo and dispatches to Foo#initialize. + * - `def m` defines an instance method; `def self.m` a singleton (class) + * method. Singleton methods are registered under the receiver key + * ".self" — constants cannot be lowercase, so the suffix can + * never collide with a real nested-constant path. */ +typedef struct { + CBMArena *arena; + const char *source; + int source_len; + const CBMTypeRegistry *registry; + /* Mutable alias of `registry`, set only while PASS 1 populates method + * tables (mirrors perl_lsp's mutable-reg build phase). NULL afterwards. */ + CBMTypeRegistry *build_reg; + CBMScope *current_scope; + + /* Lexical nesting: dotted constant path of the enclosing class/module + * chain ("" at top level, "A.B" inside `module A; class B`). */ + const char *nesting; + + /* Class/module table (pass 1). */ + RubyClassInfo *classes; + int class_count; + int class_cap; + + /* Mixin table (pass 1). */ + RubyMixinInfo *mixins; + int mixin_count; + int mixin_cap; + + /* Instance-variable type table (pass 1). */ + RubyIvarInfo *ivars; + int ivar_count; + int ivar_cap; + + /* Cross-file import map (require/require_relative resolved by the + * pipeline). Unused per-file: Ruby constants are file-global. */ + const char **import_names; + const char **import_qns; + int import_count; + + /* Current enclosing context during the resolution walk. */ + const char *enclosing_class_qn; /* class QN, or NULL at top level */ + const char *enclosing_func_qn; /* enclosing method QN, or NULL */ + bool in_singleton_method; /* `def self.m` / class << self scope */ + const char *module_qn; + + /* Output: resolved calls accumulate here. */ + CBMResolvedCallArray *resolved_calls; + + /* Recursion guards. */ + int eval_depth; + int walk_depth; + + /* Debug mode (CBM_LSP_DEBUG env). */ + bool debug; +} RubyLSPContext; + +/* Initialize a RubyLSPContext for processing one file. */ +void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len, + const CBMTypeRegistry *registry, const char *module_qn, + CBMResolvedCallArray *out); + +/* Resolve a dotted constant path (e.g. "Foo", "A.B") against the class + * table using the current lexical nesting, then the stdlib. Returns the + * resolved QN or NULL. */ +const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path); + +/* Look up an instance method on a class, walking prepends → own → includes → + * superclass chain. Returns the resolved CBMRegisteredFunc or NULL. */ +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name); + +/* Look up a singleton (class-side) method: own singleton methods → + * `extend`ed modules' instance methods → superclass singleton chain. */ +const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name); + +/* Evaluate a Ruby expression's type. May return CBM_TYPE_UNKNOWN. */ +const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node); + +/* Entry point: build registry from file defs + stdlib, then run resolution. + * Called from cbm_extract_file() via the language dispatch in cbm.c. */ +void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root); + +/* Register Ruby core stdlib types/methods + a curated Rails surface + * (ActiveRecord/ActiveSupport/ActionController) into a registry. */ +void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena); + +#endif /* CBM_LSP_RUBY_LSP_H */ diff --git a/internal/cbm/lsp_all.c b/internal/cbm/lsp_all.c index 4ab1f5155..7cf05a0c4 100644 --- a/internal/cbm/lsp_all.c +++ b/internal/cbm/lsp_all.c @@ -14,6 +14,8 @@ #include "lsp/generated/php_stdlib_data.c" #include "lsp/perl_lsp.c" #include "lsp/generated/perl_stdlib_data.c" +#include "lsp/ruby_lsp.c" +#include "lsp/generated/ruby_stdlib_data.c" #include "lsp/generated/python_stdlib_data.c" #include "lsp/py_lsp.c" #include "lsp/ts_lsp.c" diff --git a/scripts/check-lsp-originality.sh b/scripts/check-lsp-originality.sh index a50af9de4..f02b9cc8b 100755 --- a/scripts/check-lsp-originality.sh +++ b/scripts/check-lsp-originality.sh @@ -27,7 +27,7 @@ # USAGE # bash scripts/check-lsp-originality.sh [--lang NAME] [--refresh] # [--list-candidates] [--help] -# --lang NAME scan against ONE reference only (py|ts|go|cs|c|java|kotlin|rust|php|perl) +# --lang NAME scan against ONE reference only (py|ts|go|cs|c|java|kotlin|rust|php|perl|ruby) # --refresh re-fetch reference sources even if cached # --list-candidates print the extracted local tokens and exit (no fetch; self-test) # Exit 0 = no verbatim overlap found. Exit 1 = overlap(s) to review by a human. @@ -64,6 +64,10 @@ REFS=( # Perl (PR #461) was authored clean-room. We scan against PerlNavigator (MIT) # — the leading OSS Perl language server — for defensive copy-detection. "perl|https://github.com/bscan/PerlNavigator|server/src" + # Ruby was authored clean-room from the tree-sitter-ruby grammar + Ruby + # language semantics. We scan against Shopify's ruby-lsp (MIT) — the + # leading OSS Ruby language server — for defensive copy-detection. + "ruby|https://github.com/Shopify/ruby-lsp|lib" ) ONLY_LANG=""; REFRESH=0; LIST_ONLY=0 diff --git a/tests/test_main.c b/tests/test_main.c index ba6f26d45..883ba4ed6 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -733,6 +733,7 @@ extern void suite_php_lsp(void); extern void suite_cs_lsp(void); extern void suite_cs_lsp_bench(void); extern void suite_perl_lsp(void); +extern void suite_ruby_lsp(void); extern void suite_scope(void); extern void suite_type_rep(void); extern void suite_py_lsp(void); @@ -1026,6 +1027,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(cs_lsp); RUN_SELECTED_SUITE_PERF(cs_lsp_bench); RUN_SELECTED_SUITE(perl_lsp); + RUN_SELECTED_SUITE(ruby_lsp); RUN_SELECTED_SUITE(py_lsp); RUN_SELECTED_SUITE(kotlin_lsp); RUN_SELECTED_SUITE(rust_lsp); diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c new file mode 100644 index 000000000..23c37314e --- /dev/null +++ b/tests/test_ruby_lsp.c @@ -0,0 +1,636 @@ +/* + * test_ruby_lsp.c — Tests for the Ruby Light Semantic Pass. + * + * Coverage mirrors tests/test_perl_lsp.c / tests/test_php_lsp.c, exercising + * the foundational Ruby resolution scenarios: + * 1. QN contract (defs carry module_qn.ClassPath.method — the join key + * the resolver's emitted rows depend on) + * 2. Constructor typing + edge (a = Animal.new; a.speak) + * 3. Constructor edge targets CLASS (textual `Widget.new` -> "Widget") + * 4. Implicit/explicit self dispatch + * 5. Singleton (class) methods (def self.m; Animal.m) + * 6. Superclass chain lookup (class B < A) + * 7. include + prepend mixin lookup (prepend shadows the class's own) + * 8. extend mixin (class-side) lookup + * 9. super dispatch + * 10. Instance-variable typing (@thing = Foo.new; @thing.bar) + * 11. Chained call typing (Foo.new.bar) + * 12. Nested modules (lexical nesting + `A::B` references) + * 13. ActiveRecord model typing (User.find(1).full_name) + * 14. Top-level function calls + * 15. Unresolvable receiver emits NO spurious edge (negative) + * 16. send() / the whole dynamic-dispatch family emits NO edge (negative) + * + * Scope: every row here is SINGLE-FILE, driven through cbm_extract_file so it + * exercises the per-file resolver exactly as the extraction pipeline calls it. + * Cross-file resolution (cbm_run_ruby_lsp_cross, wired through + * pass_lsp_cross.c) is a separate tier and is covered by its own rows. + * + * The resolver populates result->resolved_calls with CBMResolvedCall rows. + * Ruby defs weave the class path into the QN (module_qn.Animal.speak), so + * for these single-file fixtures ("test"/"main.rb" -> module QN test.main) + * callee fragments like "main.Animal.speak" are unique join keys. + */ +#include "test_framework.h" +#include "cbm.h" +#include "../src/pipeline/lsp_resolve.h" +#include "lsp/ruby_lsp.h" +#include + +/* ── Helpers (mirror test_perl_lsp.c) ──────────────────────────── */ + +static CBMFileResult *extract_ruby(const char *source) { + return cbm_extract_file(source, (int)strlen(source), CBM_LANG_RUBY, "test", "main.rb", 0, NULL, + NULL); +} + +static int find_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && strstr(rc->caller_qn, callerSub) && rc->callee_qn && + strstr(rc->callee_qn, calleeSub)) + return i; + } + return -1; +} + +static int require_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { + int idx = find_resolved(r, callerSub, calleeSub); + if (idx < 0) { + printf(" MISSING resolved call: caller~%s -> callee~%s (have %d)\n", callerSub, calleeSub, + r->resolved_calls.count); + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)", + rc->callee_qn ? rc->callee_qn : "(null)", rc->strategy ? rc->strategy : "(null)", + rc->confidence); + } + } + return idx; +} + +static const CBMResolvedCall *find_resolved_with_strategy(const CBMFileResult *r, + const char *callerSub, + const char *calleeSub, + const char *strategy) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (!rc->caller_qn || !rc->callee_qn) + continue; + if (!strstr(rc->caller_qn, callerSub)) + continue; + if (!strstr(rc->callee_qn, calleeSub)) + continue; + if (strategy && (!rc->strategy || strcmp(rc->strategy, strategy) != 0)) + continue; + return rc; + } + return NULL; +} + +static const CBMDefinition *find_def(const CBMFileResult *r, const char *label, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->label && d->name && strcmp(d->label, label) == 0 && strcmp(d->name, name) == 0) + return d; + } + return NULL; +} + +/* ── 1. QN contract: defs weave the class path into method QNs ─── */ + +TEST(rubylsp_qn_contract) { + const char *src = "class Animal\n" + " def speak\n" + " 'woof'\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMDefinition *m = find_def(r, "Method", "speak"); + if (!m) { + printf(" no Method speak def; defs:\n"); + for (int i = 0; i < r->defs.count; i++) + printf(" [%s] %s qn=%s parent=%s\n", r->defs.items[i].label, r->defs.items[i].name, + r->defs.items[i].qualified_name, + r->defs.items[i].parent_class ? r->defs.items[i].parent_class : "(null)"); + } + ASSERT(m); + if (strcmp(m->qualified_name, "test.main.Animal.speak") != 0) { + printf(" QN scheme mismatch: got %s (resolver assumes module_qn.ClassPath.method)\n", + m->qualified_name); + } + ASSERT(strcmp(m->qualified_name, "test.main.Animal.speak") == 0); + cbm_free_result(r); + PASS(); +} + +/* ── 2. Constructor typing: a = Animal.new; a.speak ────────────── */ + +TEST(rubylsp_method_via_constructor_assignment) { + const char *src = "class Animal\n" + " def initialize(name)\n" + " @name = name\n" + " end\n" + " def speak\n" + " @name\n" + " end\n" + "end\n" + "class Runner\n" + " def run\n" + " a = Animal.new('rex')\n" + " a.speak\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Runner.run", "Animal.speak") >= 0); + ASSERT(find_resolved_with_strategy(r, "Runner.run", "Animal.speak", "ruby_method_typed")); + cbm_free_result(r); + PASS(); +} + +/* ── 3. Constructor edge targets the CLASS node ─────────────────── */ + +TEST(rubylsp_constructor_edge_targets_class) { + const char *src = "class Widget\n" + " def initialize\n" + " end\n" + "end\n" + "class Maker\n" + " def build\n" + " Widget.new\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "Maker.build", "Widget", "ruby_constructor"); + ASSERT(rc); + /* Callee is the class QN (leaf "Widget"), matching the textual + * extractor's Widget.new -> "Widget" rewrite so the row joins. */ + ASSERT(strcmp(rc->callee_qn, "test.main.Widget") == 0); + ASSERT(rc->confidence >= CBM_LSP_CONFIDENCE_FLOOR); + cbm_free_result(r); + PASS(); +} + +/* ── 4. Implicit + explicit self dispatch ───────────────────────── */ + +TEST(rubylsp_self_dispatch) { + const char *src = "class Greeter\n" + " def greet\n" + " build_greeting('hi')\n" + " self.build_greeting('yo')\n" + " end\n" + " def build_greeting(word)\n" + " word\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Greeter.greet", "Greeter.build_greeting") >= 0); + ASSERT(find_resolved_with_strategy(r, "Greeter.greet", "Greeter.build_greeting", + "ruby_self_dispatch")); + cbm_free_result(r); + PASS(); +} + +/* ── 5. Singleton (class) methods ───────────────────────────────── */ + +TEST(rubylsp_singleton_method_dispatch) { + const char *src = "class Registry\n" + " def self.register(key)\n" + " key\n" + " end\n" + "end\n" + "class App\n" + " def boot\n" + " Registry.register(:db)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.boot", "Registry.register") >= 0); + ASSERT(find_resolved_with_strategy(r, "App.boot", "Registry.register", "ruby_class_method")); + cbm_free_result(r); + PASS(); +} + +/* Singleton methods must NOT satisfy instance dispatch (and vice versa). */ +TEST(rubylsp_singleton_instance_split) { + const char *src = "class Config\n" + " def self.load\n" + " 1\n" + " end\n" + "end\n" + "class App\n" + " def boot\n" + " c = Config.new\n" + " c.load\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* c.load is an INSTANCE call; Config only defines a singleton `load`. + * Zero-edge guarantee: no resolved row may bind them. */ + ASSERT(find_resolved(r, "App.boot", "Config.load") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 6. Superclass chain ────────────────────────────────────────── */ + +TEST(rubylsp_inheritance) { + const char *src = "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " def work\n" + " helper\n" + " self.helper\n" + " end\n" + "end\n" + "class Driver\n" + " def drive\n" + " c = Child.new\n" + " c.helper\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Inherited method resolves to Base.helper from both dispatch shapes. */ + ASSERT(require_resolved(r, "Child.work", "Base.helper") >= 0); + ASSERT(require_resolved(r, "Driver.drive", "Base.helper") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7. include mixin ───────────────────────────────────────────── */ + +TEST(rubylsp_include_mixin) { + const char *src = "module Greetable\n" + " def greet\n" + " 'hello'\n" + " end\n" + "end\n" + "class Person\n" + " include Greetable\n" + " def hail\n" + " greet()\n" + " end\n" + "end\n" + "class Caller\n" + " def run\n" + " p = Person.new\n" + " p.greet\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Person.hail", "Greetable.greet") >= 0); + ASSERT(require_resolved(r, "Caller.run", "Greetable.greet") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7b. prepend mixin shadows the class's own method ───────────── */ + +/* Ruby's lookup order puts prepended modules AHEAD of the class itself, which + * is the whole point of `prepend` over `include`. Both an own method and a + * prepended one exist here, so a resolver that walked the class first would + * still emit an edge — just the wrong one. Pin the winner, not merely that + * something resolved. */ +TEST(rubylsp_prepend_mixin) { + const char *src = "module Audited\n" + " def save\n" + " 'audited'\n" + " end\n" + "end\n" + "class Record\n" + " prepend Audited\n" + " def save\n" + " 'plain'\n" + " end\n" + "end\n" + "class App\n" + " def run\n" + " r = Record.new\n" + " r.save\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* The prepended module wins. */ + ASSERT(require_resolved(r, "App.run", "Audited.save") >= 0); + /* And the shadowed own method must NOT also be emitted. */ + ASSERT(find_resolved(r, "App.run", "Record.save") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 8. extend mixin (class-side) ───────────────────────────────── */ + +TEST(rubylsp_extend_mixin) { + const char *src = "module Findable\n" + " def locate(id)\n" + " id\n" + " end\n" + "end\n" + "class Record\n" + " extend Findable\n" + "end\n" + "class App\n" + " def run\n" + " Record.locate(7)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.run", "Findable.locate") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 9. super dispatch ──────────────────────────────────────────── */ + +TEST(rubylsp_super_dispatch) { + const char *src = "class Base\n" + " def setup\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " def setup\n" + " super\n" + " 2\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "Child.setup", "Base.setup", "ruby_method_super"); + if (!rc) + (void)require_resolved(r, "Child.setup", "Base.setup"); + ASSERT(rc); + cbm_free_result(r); + PASS(); +} + +/* ── 10. Instance-variable typing ───────────────────────────────── */ + +TEST(rubylsp_ivar_typing) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class Car\n" + " def initialize\n" + " @engine = Engine.new\n" + " end\n" + " def drive\n" + " @engine.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Car.drive", "Engine.start") >= 0); + ASSERT(find_resolved_with_strategy(r, "Car.drive", "Engine.start", "ruby_ivar_method")); + cbm_free_result(r); + PASS(); +} + +/* Conflicting ivar assignments must suppress the type (zero-edge). */ +TEST(rubylsp_ivar_conflict_no_edge) { + const char *src = "class A\n" + " def go\n" + " 1\n" + " end\n" + "end\n" + "class B\n" + " def go\n" + " 2\n" + " end\n" + "end\n" + "class Holder\n" + " def initialize(flag)\n" + " @x = A.new\n" + " @x = B.new\n" + " end\n" + " def run\n" + " @x.go\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Holder.run", ".go") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 11. Chained calls ──────────────────────────────────────────── */ + +TEST(rubylsp_chained_constructor_call) { + const char *src = "class Builder\n" + " def finish\n" + " 1\n" + " end\n" + "end\n" + "class App\n" + " def run\n" + " Builder.new.finish\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.run", "Builder.finish") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 12. Nested modules + lexical nesting ───────────────────────── */ + +TEST(rubylsp_nested_modules) { + const char *src = "module Admin\n" + " class User\n" + " def self.lookup(id)\n" + " id\n" + " end\n" + " def name\n" + " 'n'\n" + " end\n" + " end\n" + " class Panel\n" + " def show\n" + " User.lookup(1)\n" + " u = User.new\n" + " u.name\n" + " end\n" + " end\n" + "end\n" + "class Outside\n" + " def probe\n" + " Admin::User.lookup(2)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Lexical reference from sibling class inside the module. */ + ASSERT(require_resolved(r, "Panel.show", "User.lookup") >= 0); + ASSERT(require_resolved(r, "Panel.show", "User.name") >= 0); + /* Fully-qualified reference from outside. */ + ASSERT(require_resolved(r, "Outside.probe", "User.lookup") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 13. ActiveRecord model typing ──────────────────────────────── */ + +TEST(rubylsp_activerecord_model_typing) { + const char *src = "class User < ApplicationRecord\n" + " def full_name\n" + " 'x'\n" + " end\n" + "end\n" + "class UsersController < ApplicationController\n" + " def show\n" + " u = User.find(1)\n" + " u.full_name\n" + " end\n" + " def index\n" + " User.where(active: true).first.full_name\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* User.find returns User (AR query typing); u.full_name resolves. */ + ASSERT(require_resolved(r, "UsersController.show", "User.full_name") >= 0); + /* Relation approximation keeps the model type through where/first. */ + ASSERT(require_resolved(r, "UsersController.index", "User.full_name") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 14. Top-level functions ────────────────────────────────────── */ + +TEST(rubylsp_top_level_function) { + const char *src = "def helper(x)\n" + " x\n" + "end\n" + "def run\n" + " helper(1)\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "main.run", "main.helper", "ruby_function_local"); + if (!rc) + (void)require_resolved(r, "main.run", "main.helper"); + ASSERT(rc); + cbm_free_result(r); + PASS(); +} + +/* ── 15. Negative: unresolvable receiver emits no edge ──────────── */ + +TEST(rubylsp_unknown_receiver_no_edge) { + const char *src = "class Safe\n" + " def process(payload)\n" + " payload.transform\n" + " mystery = fetch_thing\n" + " mystery.explode\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Neither the untyped parameter nor the unresolved local may produce + * a resolved row. */ + ASSERT(find_resolved(r, "Safe.process", "transform") < 0); + ASSERT(find_resolved(r, "Safe.process", "explode") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 16. Negative: dynamic dispatch emits no edge ───────────────── */ + +TEST(rubylsp_send_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def invoke\n" + " t = Target.new\n" + " t.send(:hidden)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.invoke", "hidden") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 16b. Negative: the whole dynamic-dispatch family emits no edge ── */ + +/* `send` has its own row above. The zero-edge guarantee is advertised for the + * whole reflective family, so pin the rest of it too: a resolver that treated + * any of these as an ordinary call would invent an edge to `hidden`. */ +TEST(rubylsp_dynamic_dispatch_family_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def via_public_send\n" + " Target.new.public_send(:hidden)\n" + " end\n" + " def via_underscore_send\n" + " Target.new.__send__(:hidden)\n" + " end\n" + " def via_define_method\n" + " Target.define_method(:hidden) { 2 }\n" + " end\n" + " def via_instance_eval\n" + " Target.new.instance_eval { hidden }\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.via_public_send", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_underscore_send", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_define_method", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_instance_eval", "hidden") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── suite ──────────────────────────────────────────────────────── */ + +void suite_ruby_lsp(void) { + RUN_TEST(rubylsp_qn_contract); + RUN_TEST(rubylsp_method_via_constructor_assignment); + RUN_TEST(rubylsp_constructor_edge_targets_class); + RUN_TEST(rubylsp_self_dispatch); + RUN_TEST(rubylsp_singleton_method_dispatch); + RUN_TEST(rubylsp_singleton_instance_split); + RUN_TEST(rubylsp_inheritance); + RUN_TEST(rubylsp_include_mixin); + RUN_TEST(rubylsp_prepend_mixin); + RUN_TEST(rubylsp_extend_mixin); + RUN_TEST(rubylsp_super_dispatch); + RUN_TEST(rubylsp_ivar_typing); + RUN_TEST(rubylsp_ivar_conflict_no_edge); + RUN_TEST(rubylsp_chained_constructor_call); + RUN_TEST(rubylsp_nested_modules); + RUN_TEST(rubylsp_activerecord_model_typing); + RUN_TEST(rubylsp_top_level_function); + RUN_TEST(rubylsp_unknown_receiver_no_edge); + RUN_TEST(rubylsp_send_no_edge); + RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); +} From 0d2f840c8ed2695db206acbcad1ad1c36a1f3958 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 07:22:01 -0400 Subject: [PATCH 02/36] feat(config): add ignore_worktrees to skip linked git worktrees Agents that use `git worktree add` to run parallel branches end up with one indexed project per worktree. Each is a near-duplicate of the main checkout, so the cache fills with redundant graphs and project pickers get noisy. Add an opt-in `ignore_worktrees` config key. When enabled, a linked worktree is left alone: - auto-index on connect skips it (logged as autoindex.skip) - the daemon's background auto-index skips it - explicit index_repository refuses with an actionable message naming both ways forward (index_worktree=true, or turn the key off) - the hook-augment "not indexed" guidance stops telling the agent to run index_repository in a directory where it would only be refused Detection is git plumbing, no subprocess: /.git must be a regular file holding a "gitdir:" pointer AND that gitdir must contain a `commondir` file. The commondir check is what separates a linked worktree from a submodule, whose .git is also a gitlink file but whose gitdir has no commondir. The main checkout of a repo that has worktrees is never affected. Defaults to false, so behaviour is unchanged unless the key is set. Signed-off-by: Nohj --- README.md | 3 + docs/CONFIGURATION.md | 30 ++++ src/cli/cli.c | 1 + src/cli/cli.h | 4 +- src/cli/hook_augment.c | 20 ++- src/daemon/application.c | 6 + src/git/git_context.c | 60 ++++++++ src/git/git_context.h | 12 ++ src/mcp/mcp.c | 36 ++++- src/mcp/mcp_internal.h | 5 + tests/test_cli.c | 22 ++- tests/test_git_context.c | 114 +++++++++++++++ tests/test_mcp.c | 292 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 597 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 66b50fe08..3a9b2137d 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ Watcher registration is controlled separately by `auto_watch` (default `true`). To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch` — which is consulted per session — `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings). +Linked git worktrees are indexed as their own projects by default. Set `config set ignore_worktrees true` to skip them on the automatic paths — useful when short-lived `git worktree add` checkouts would otherwise each leave behind a permanent index of the same repository. An explicit `index_repository` call on a worktree is then refused unless you pass `index_worktree=true`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#ignore_worktrees). + ### Keeping Up to Date **Updates run from the install script on every platform, not from inside the running binary.** `codebase-memory-mcp update` validates your flags and then prints the exact command to run: @@ -735,6 +737,7 @@ codebase-memory-mcp config list # show all settings codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true) +codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false) codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 038256976..4c27e7f57 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,6 +87,7 @@ Current keys: |---|---|---| | `auto_index` | `false` | Automatically index new projects when an MCP session starts. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | +| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. | | `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). | | `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | @@ -126,6 +127,35 @@ and preserves any previously serving database. See [Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and error-response details. +### `ignore_worktrees` + +Every indexed project is registered under its own absolute root path, so each +linked worktree becomes a separate permanent index. On machines that create many +short-lived worktrees, the automatic paths (`auto_index`, and the session hook's +"index this project first" guidance) turn every throwaway checkout into another +stored index of what is largely the same repository. + +Enable the key to keep those checkouts out of the index: + +```bash +codebase-memory-mcp config set ignore_worktrees true +``` + +With it enabled: + +- automatic indexing skips a session whose root is a linked worktree; +- the `hook-augment` context says the worktree is unindexed on purpose instead + of telling the agent to run `index_repository`; +- an explicit `index_repository` call on a linked worktree is refused, and names + both ways forward — pass `index_worktree=true` for that one call, or turn the + key back off. + +The main checkout of the same repository is unaffected, as are ordinary clones +and submodules. Detection is git plumbing only: a linked worktree's `.git` is a +file pointing at a gitdir that contains a `commondir` entry. + +The default is `false`, so indexing behavior is unchanged unless you opt in. + ## 3. UI Settings The optional built-in graph UI stores its settings in: diff --git a/src/cli/cli.c b/src/cli/cli.c index b59a894a6..13c678943 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7442,6 +7442,7 @@ static const config_key_def_t CONFIG_KEYS[] = { {CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"}, {CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"}, {CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"}, + {CBM_CONFIG_IGNORE_WORKTREES, "false", "Skip linked git worktrees when indexing automatically"}, {CBM_CONFIG_WATCHER_ENABLED, "true", "Run the background watcher thread (auto-reindex); false to disable"}, {CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"}, diff --git a/src/cli/cli.h b/src/cli/cli.h index cf7f6fa44..ada71ab01 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -250,7 +250,8 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect, const char *forced_event); bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows); void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter); @@ -431,6 +432,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t #define CBM_CONFIG_AUTO_INDEX "auto_index" #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" #define CBM_CONFIG_AUTO_WATCH "auto_watch" +#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees" #define CBM_CONFIG_UI_LANG "ui-lang" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" /* #1558: the graph UI's loopback listener. Stored in the UI config file rather diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index f8909c365..1043b48cd 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -21,7 +21,9 @@ #include "foundation/constants.h" #include "foundation/mem.h" #include "foundation/platform.h" +#include "git/git_context.h" #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" #include "yyjson/yyjson.h" @@ -1440,7 +1442,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) { return "Tier 2 verification"; } -static const char *ha_no_project_index_guidance(const char *event) { +static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) { + /* ignore_worktrees is on and this cwd is a linked worktree: indexing it is + * deliberately disabled, so telling the agent to run index_repository would + * only produce a refusal. Point at the setting instead. */ + if (worktree_ignored) { + return "This is a linked git worktree and ignore_worktrees is enabled, so it is not " + "indexed on purpose; do not run index_repository here."; + } return event && strcmp(event, "SubagentStart") == 0 ? "Ask the parent agent to run index_repository before structural exploration; " "do not attempt graph mutation." @@ -1472,6 +1481,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; + bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && + cbm_git_is_linked_worktree(cwd); cbm_mcp_server_free(owned_server); char context[2048]; @@ -1505,7 +1516,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "and file reads for literals, configs, non-code files, and verification.", scope, safe_project, tier); } else { - const char *index_guidance = ha_no_project_index_guidance(event); + const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored); snprintf(context, sizeof(context), "[codebase-memory] %s context: no indexed graph project matched this working " "directory. %s Once indexed, " @@ -1624,8 +1635,9 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, return ha_path_contains_mode(root, candidate, case_insensitive); } -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { - return ha_no_project_index_guidance(event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored) { + return ha_no_project_index_guidance(event, worktree_ignored); } bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) { diff --git a/src/daemon/application.c b/src/daemon/application.c index 748b8bb75..8a79ecd5d 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -15,6 +15,7 @@ #include "foundation/sha256.h" #include "foundation/subprocess.h" #include "foundation/workspace.h" +#include "git/git_context.h" #include "mcp/index_supervisor.h" #include "mcp/mcp.h" #include "mcp/mcp_internal.h" @@ -2071,6 +2072,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio !application_session_workspace_allowed(session, "auto_index_discovery")) { auto_index_candidate = false; } + if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree"); + auto_index_candidate = false; + } bool within_auto_index_limit = !auto_index_candidate || cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files); diff --git a/src/git/git_context.c b/src/git/git_context.c index f739c46e6..2734dc703 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -100,6 +100,66 @@ static bool path_is_absolute(const char *path) { #endif } +/* Read the "gitdir: " pointer out of a gitlink FILE at /.git. + * Returns false when .git is missing, a directory (ordinary repo), or holds no + * pointer. A relative pointer is resolved against path. */ +static bool read_gitlink_target(const char *path, char *out, size_t out_size) { + char dot_git[GIT_OUTPUT_MAX]; + int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path); + if (n < 0 || n >= (int)sizeof(dot_git)) { + return false; + } + struct stat st; + if (stat(dot_git, &st) != 0 || !S_ISREG(st.st_mode)) { + return false; + } + + FILE *f = cbm_fopen(dot_git, "r"); + if (!f) { + return false; + } + char line[GIT_OUTPUT_MAX]; + bool got = false; + while (fgets(line, sizeof(line), f)) { + trim_newlines(line); + if (strncmp(line, "gitdir:", 7) != 0) { + continue; + } + const char *value = line + 7; + while (*value == ' ' || *value == '\t') { + value++; + } + if (!value[0]) { + break; + } + int written = path_is_absolute(value) ? snprintf(out, out_size, "%s", value) + : snprintf(out, out_size, "%s/%s", path, value); + got = written > 0 && written < (int)out_size; + break; + } + fclose(f); + return got; +} + +bool cbm_git_is_linked_worktree(const char *path) { + if (!path || !path[0]) { + return false; + } + char git_dir[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) { + return false; + } + /* Only linked worktrees carry /commondir; a submodule gitlink + * points at /.git/modules/, which does not. */ + char commondir[GIT_OUTPUT_MAX]; + int n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); + if (n < 0 || n >= (int)sizeof(commondir)) { + return false; + } + struct stat st; + return stat(commondir, &st) == 0 && S_ISREG(st.st_mode); +} + static char *join_root_relative(const char *root, const char *rel) { if (!root || !root[0]) { return git_strdup(rel); diff --git a/src/git/git_context.h b/src/git/git_context.h index 876309eb6..633e28018 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -19,6 +19,18 @@ typedef struct { char *base_sha; } cbm_git_context_t; +/* True when path is the root of a LINKED git worktree (`git worktree add`). + * + * Plumbing-only, no subprocess: /.git must be a regular file holding a + * "gitdir: " pointer AND that gitdir must contain a `commondir` file. + * The commondir check is what separates a linked worktree from a submodule — + * a submodule's .git is also a gitlink file, but its gitdir + * (/.git/modules/) has no commondir entry. + * + * Callers run this on every session start, so it stays fork-free; the richer + * cbm_git_context_resolve() shells out to git and is not usable on that path. */ +bool cbm_git_is_linked_worktree(const char *path); + int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 75ff8e396..f27ddb9a8 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -478,7 +478,10 @@ static const tool_def_t TOOLS[] = { "\"name\":{\"type\":\"string\",\"description\":" "\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" - "\"Write .codebase-memory/graph.db.zst.\"}" + "\"Write .codebase-memory/graph.db.zst.\"}," + "\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":" + "\"Index repo_path even when it is a linked git worktree and the " + "ignore_worktrees config key is enabled. No effect otherwise.\"}" "},\"required\":[\"repo_path\"]}"}, {"search_graph", @@ -11145,6 +11148,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(boundary_err, true); } + /* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree + * is refused with the two ways forward (per-call override, or turn the + * setting off) rather than silently skipped — a silent success would be + * indistinguishable from a real index to the caller. */ + if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") && + cbm_git_is_linked_worktree(repo_path)) { + index_args_free(repo_path, mode_str, name_override); + return cbm_mcp_text_result( + "repo_path is a linked git worktree and ignore_worktrees is enabled. Pass " + "index_worktree=true to index it anyway, or run: codebase-memory-mcp config set " + "ignore_worktrees false", + true); + } + if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { char *result = handle_cross_repo_mode(srv, repo_path, name_override, args); index_args_free(repo_path, mode_str, name_override); @@ -17569,6 +17586,17 @@ static bool auto_watch_enabled(cbm_mcp_server_t *srv) { return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true); } +/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees + * (default off, so existing setups keep indexing them). Users who create many + * short-lived worktrees can stop each throwaway checkout from registering a + * new permanent project with `config set ignore_worktrees true`. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) { + if (!srv || !srv->config) { + return false; /* default off */ + } + return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false); +} + /* Register the session project with the background watcher for ongoing * change detection — unless auto_watch is disabled. */ static void register_watcher_if_enabled(cbm_mcp_server_t *srv) { @@ -17721,6 +17749,12 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { return; } + if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) { + cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project", + srv->session_project); + return; + } + /* Quick tracked-file count check to avoid OOM on massive repos. */ int file_count = -1; #ifdef CBM_ENABLE_TEST_SEAMS diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index de170067a..8e78f9f40 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -63,6 +63,11 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy); bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, int *file_count_out); +/* True when the `ignore_worktrees` config key is on for this server (default + * off). Callers pair it with cbm_git_is_linked_worktree() to decide whether an + * automatic index of a linked worktree should be skipped. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv); + /* detect_changes seed scoping (#1363): does `node`'s line range overlap any * recorded hunk for `file`? Exposed for direct unit testing of the overlap * logic, independent of the git/subprocess/index plumbing around it. */ diff --git a/tests/test_cli.c b/tests/test_cli.c index 211ec6355..46f826d63 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -12263,14 +12263,18 @@ TEST(cli_hook_augment_subagent_tier_router_contract) { } TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only) { - const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart"); - const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart"); + const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart", false); + const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart", false); + const char *worktree = cbm_hook_no_project_index_guidance_for_testing("SessionStart", true); ASSERT_NOT_NULL(session); ASSERT_NOT_NULL(subagent); + ASSERT_NOT_NULL(worktree); ASSERT(strstr(session, "Run index_repository") != NULL); ASSERT(strstr(subagent, "Ask the parent agent to run index_repository") != NULL); ASSERT(strstr(subagent, "do not attempt graph mutation") != NULL); ASSERT(strstr(subagent, "Run index_repository") == NULL); + ASSERT(strstr(worktree, "ignore_worktrees is enabled") != NULL); + ASSERT(strstr(worktree, "do not run index_repository") != NULL); PASS(); } @@ -15578,6 +15582,19 @@ TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558) { PASS(); } +TEST(cli_ignore_worktrees_config_key_is_discoverable) { + bool listed = false; + for (size_t i = 0; i < cbm_cli_config_key_count_for_testing(); i++) { + const char *key = cbm_cli_config_key_at_for_testing(i); + if (key && strcmp(key, CBM_CONFIG_IGNORE_WORKTREES) == 0) { + listed = true; + break; + } + } + ASSERT_TRUE(listed); + PASS(); +} + TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554) { const cbm_skill_t *sk = cbm_get_skills(); ASSERT_NOT_NULL(sk); @@ -16011,6 +16028,7 @@ SUITE(cli) { RUN_TEST(cli_update_download_failure_does_not_quiesce_sessions); RUN_TEST(cli_update_already_current_does_not_quiesce_sessions); RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558); + RUN_TEST(cli_ignore_worktrees_config_key_is_discoverable); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a5..08df0dd7b 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -232,10 +232,124 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +/* ── cbm_git_is_linked_worktree ─────────────────────────────────── */ +/* Detection backing the `ignore_worktrees` config gate. The predicate must be + * TRUE only for a linked worktree (`git worktree add`) — never for the main + * checkout, a plain directory, or a submodule. The submodule case is the one a + * naive "is .git a regular file?" check gets wrong: a submodule's .git is also a + * gitlink file, and it is separated here by the absence of a `commondir` entry + * in the pointed-at gitdir. */ + +TEST(is_linked_worktree_true_for_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + /* th_mktempdir() returns a static buffer — copy before the second call. */ + char main_tmp[256]; + char *raw = th_mktempdir("cbm_wt_main"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(main_tmp, raw, sizeof(main_tmp) - 1); + main_tmp[sizeof(main_tmp) - 1] = '\0'; + + char wt_tmp[256]; + raw = th_mktempdir("cbm_wt_linked"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(wt_tmp, raw, sizeof(wt_tmp) - 1); + wt_tmp[sizeof(wt_tmp) - 1] = '\0'; + th_rmtree(wt_tmp); /* git worktree add creates it */ + + if (make_git_repo(main_tmp) != 0) { + th_rmtree(main_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (git_run(main_tmp, "branch wt-branch") != 0) { + th_rmtree(main_tmp); + FAIL("failed to create branch for worktree"); + } + char wt_cmd[1024]; + snprintf(wt_cmd, sizeof(wt_cmd), "worktree add \"%s\" wt-branch", wt_tmp); + if (git_run(main_tmp, wt_cmd) != 0) { + th_rmtree(wt_tmp); + th_rmtree(main_tmp); + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + + bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp); + /* The MAIN checkout of the very same repo must NOT be flagged — otherwise + * enabling ignore_worktrees would stop indexing ordinary repositories. */ + bool main_detected = cbm_git_is_linked_worktree(main_tmp); + + git_run(main_tmp, "worktree prune"); + th_rmtree(main_tmp); + th_rmtree(wt_tmp); + + ASSERT(worktree_detected); + ASSERT(!main_detected); + PASS(); +#endif /* _WIN32 */ +} + +TEST(is_linked_worktree_false_for_submodule_and_nongit) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + char super_tmp[256]; + char *raw = th_mktempdir("cbm_wt_super"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(super_tmp, raw, sizeof(super_tmp) - 1); + super_tmp[sizeof(super_tmp) - 1] = '\0'; + + char child_tmp[256]; + raw = th_mktempdir("cbm_wt_child"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(child_tmp, raw, sizeof(child_tmp) - 1); + child_tmp[sizeof(child_tmp) - 1] = '\0'; + + if (make_git_repo(super_tmp) != 0 || make_git_repo(child_tmp) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + /* A plain directory that is not a git repo at all. */ + char plain[1024]; + snprintf(plain, sizeof(plain), "%s/plain", super_tmp); + if (th_mkdir_p(plain) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + FAIL("failed to create plain dir"); + } + bool plain_detected = cbm_git_is_linked_worktree(plain); + + /* file:// submodules are refused by default since the CVE-2022-39253 fix. */ + char sub_cmd[1024]; + snprintf(sub_cmd, sizeof(sub_cmd), + "-c protocol.file.allow=always submodule add -q \"%s\" subm", child_tmp); + int sub_rc = git_run(super_tmp, sub_cmd); + + char subm[1024]; + snprintf(subm, sizeof(subm), "%s/subm", super_tmp); + bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm); + + th_rmtree(super_tmp); + th_rmtree(child_tmp); + + ASSERT(!plain_detected); + if (sub_rc != 0) { + SKIP_PLATFORM("git submodule add unavailable in this environment"); + } + /* A submodule gitlink has no commondir → must not be treated as a worktree. */ + ASSERT(!submodule_detected); + PASS(); +#endif /* _WIN32 */ +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(is_linked_worktree_true_for_linked_worktree); + RUN_TEST(is_linked_worktree_false_for_submodule_and_nongit); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ffa9ea3a3..787b26e2e 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -19278,6 +19278,291 @@ TEST(mcp_auto_watch_false_skips_watcher_on_connect) { PASS(); } +/* ═══════════════════════════════════════════════════════════════ + * ignore_worktrees — explicit index_repository on a linked worktree + * ═══════════════════════════════════════════════════════════════ */ + +/* An EXPLICIT index_repository call is refused only when ignore_worktrees is on + * AND repo_path is a linked worktree AND no per-call override was passed. The + * refusal must never fire for the MAIN checkout — that would break ordinary + * indexing for anyone enabling the key — and index_worktree=true must escape it. + * + * Default-off is the regression class the project guards hardest: with the key + * unset, a linked worktree still indexes exactly as today. + * + * Probe returns a bit set, or a negative fixture-setup code. */ +enum { + IGNORE_WT_REFUSED_WORKTREE = 1, /* expected when the key is on */ + IGNORE_WT_REFUSED_MAIN = 2, /* BUG if set */ + IGNORE_WT_REFUSED_OVERRIDE = 4, /* BUG if set */ +}; + +#ifndef _WIN32 +typedef struct { + char cache[256]; + char main_repo[512]; + char wt_repo[512]; +} ignore_wt_fixture_t; + +static void ignore_wt_fixture_cleanup(ignore_wt_fixture_t *fx) { + if (!fx) { + return; + } + if (fx->main_repo[0]) { + char prune[1024]; + snprintf(prune, sizeof(prune), "git -C \"%s\" worktree prune >/dev/null 2>&1", fx->main_repo); + (void)system(prune); + } + if (fx->cache[0]) { + th_rmtree(fx->cache); + } +} + +/* 0 = ready. -1 tmpdir, -2 mkdir, -3 git worktree fixture unavailable. */ +static int ignore_wt_fixture_setup(ignore_wt_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + char *raw = th_mktempdir("cbm_ignorewt"); + if (!raw) { + return -1; + } + snprintf(fx->cache, sizeof(fx->cache), "%s", raw); + snprintf(fx->main_repo, sizeof(fx->main_repo), "%s/main", fx->cache); + snprintf(fx->wt_repo, sizeof(fx->wt_repo), "%s/wt", fx->cache); + if (th_mkdir_p(fx->main_repo) != 0) { + ignore_wt_fixture_cleanup(fx); + return -2; + } + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" init -q >/dev/null 2>&1 && " + "git -C \"%s\" config user.email t@example.com && " + "git -C \"%s\" config user.name T && touch \"%s/.keep\" && " + "git -C \"%s\" add .keep && git -C \"%s\" commit -q -m init && " + "git -C \"%s\" worktree add -q \"%s\" -b wtb", + fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, + fx->main_repo, fx->main_repo, fx->wt_repo); + if (system(cmd) != 0) { + ignore_wt_fixture_cleanup(fx); + return -3; + } + return 0; +} + +static bool ignore_wt_refused(cbm_mcp_server_t *srv, const char *repo_path, bool override) { + char args[2048]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"%s}", repo_path, + override ? ",\"index_worktree\":true" : ""); + char *result = cbm_mcp_handle_tool(srv, "index_repository", args); + bool refused = result && strstr(result, "ignore_worktrees is enabled") != NULL; + free(result); + return refused; +} + +static int ignore_worktrees_index_probe(const char *ignore_value) { + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup < 0) { + return setup; + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", fx.cache, 1); + + int bits = -4; + cbm_config_t *cfg = cbm_config_open(fx.cache); + if (cfg) { + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (srv) { + cbm_mcp_server_set_config(srv, cfg); + bits = 0; + if (ignore_wt_refused(srv, fx.wt_repo, false)) { + bits |= IGNORE_WT_REFUSED_WORKTREE; + } + if (ignore_wt_refused(srv, fx.main_repo, false)) { + bits |= IGNORE_WT_REFUSED_MAIN; + } + if (ignore_wt_refused(srv, fx.wt_repo, true)) { + bits |= IGNORE_WT_REFUSED_OVERRIDE; + } + cbm_mcp_server_free(srv); + } + cbm_config_close(cfg); + } + + restore_cache_dir(saved_copy); + free(saved_copy); + ignore_wt_fixture_cleanup(&fx); + return bits; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +static void ignore_wt_count_started(void *context) { + int *calls = context; + (*calls)++; +} + +/* Drive initialize → maybe_auto_index on a linked worktree. ignore_value NULL + * leaves the key unset (default-off). The count hook fires only if auto-index + * reaches discovery, so a skip for linked_worktree must leave it at 0. */ +static int ignore_wt_autoindex_count_calls(const char *session_root, const char *ignore_value) { + char *raw = th_mktempdir("cbm_ignorewt_auto"); + if (!raw) { + return -1; + } + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char old_cwd[1024]; + if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(session_root) != 0) { + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return -2; + } + + int calls = -3; + cbm_config_t *cfg = cbm_config_open(cache); + cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; + if (srv) { + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); + /* Fail-closed on the actual count so this never launches an index thread. */ + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "-1"); + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_set_config(srv, cfg); + calls = 0; + cbm_mcp_server_set_auto_index_count_test_hook(srv, ignore_wt_count_started, &calls); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1430,\"method\":\"initialize\",\"params\":{}}"); + free(response); + cbm_mcp_server_free(srv); + } + if (cfg) { + cbm_config_close(cfg); + } + + (void)cbm_chdir(old_cwd); + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return calls; +} +#endif /* CBM_ENABLE_TEST_SEAMS */ +#endif /* !_WIN32 */ + +TEST(mcp_ignore_worktrees_gates_explicit_index_repository) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe("true"); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + /* RED before the gate existed: nothing is ever refused. */ + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) != 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +/* Default-off pin: key unset, linked worktree still indexes. The gate is + * opt-in; an unset key must be bit-for-bit today's behaviour. */ +TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe(NULL); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +#ifdef CBM_ENABLE_TEST_SEAMS +TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, "true"); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 0); + PASS(); +#endif /* _WIN32 */ +} + +TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, NULL); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 1); + PASS(); +#endif /* _WIN32 */ +} +#endif /* CBM_ENABLE_TEST_SEAMS */ + /* ══════════════════════════════════════════════════════════════════ * #1466 / #713 — the auto_index_limit guard * @@ -20766,6 +21051,13 @@ SUITE(mcp) { RUN_TEST(autoindex_limit_guards_non_git_root_issue713); RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713); RUN_TEST(autoindex_limit_guards_git_root_issue713); + /* ignore_worktrees gate */ + RUN_TEST(mcp_ignore_worktrees_gates_explicit_index_repository); + RUN_TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree); +#ifdef CBM_ENABLE_TEST_SEAMS + RUN_TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree); + RUN_TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count); +#endif } /* Kept separate so daemon-coordination regressions can be iterated without From 16698e8193fa1dbb53f34f7205569495b3b5fbca Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 07:22:01 -0400 Subject: [PATCH 03/36] feat(config): add ignore_worktrees to skip linked git worktrees Agents that use `git worktree add` to run parallel branches end up with one indexed project per worktree. Each is a near-duplicate of the main checkout, so the cache fills with redundant graphs and project pickers get noisy. Add an opt-in `ignore_worktrees` config key. When enabled, a linked worktree is left alone: - auto-index on connect skips it (logged as autoindex.skip) - the daemon's background auto-index skips it - explicit index_repository refuses with an actionable message naming both ways forward (index_worktree=true, or turn the key off) - the hook-augment "not indexed" guidance stops telling the agent to run index_repository in a directory where it would only be refused Detection is git plumbing, no subprocess: /.git must be a regular file holding a "gitdir:" pointer AND that gitdir must contain a `commondir` file. The commondir check is what separates a linked worktree from a submodule, whose .git is also a gitlink file but whose gitdir has no commondir. The main checkout of a repo that has worktrees is never affected. Defaults to false, so behaviour is unchanged unless the key is set. Signed-off-by: Nohj --- README.md | 3 + docs/CONFIGURATION.md | 30 ++++ src/cli/cli.c | 1 + src/cli/cli.h | 4 +- src/cli/hook_augment.c | 20 ++- src/daemon/application.c | 6 + src/git/git_context.c | 60 ++++++++ src/git/git_context.h | 12 ++ src/mcp/mcp.c | 36 ++++- src/mcp/mcp_internal.h | 5 + tests/test_cli.c | 22 ++- tests/test_git_context.c | 114 +++++++++++++++ tests/test_mcp.c | 292 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 597 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9c82c18ae..fb4ce7326 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ Watcher registration is controlled separately by `auto_watch` (default `true`). To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch` — which is consulted per session — `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings). +Linked git worktrees are indexed as their own projects by default. Set `config set ignore_worktrees true` to skip them on the automatic paths — useful when short-lived `git worktree add` checkouts would otherwise each leave behind a permanent index of the same repository. An explicit `index_repository` call on a worktree is then refused unless you pass `index_worktree=true`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#ignore_worktrees). + ### Keeping Up to Date **Updates run from the install script on every platform, not from inside the running binary.** `codebase-memory-mcp update` validates your flags and then prints the exact command to run: @@ -735,6 +737,7 @@ codebase-memory-mcp config list # show all settings codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true) +codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false) codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 038256976..4c27e7f57 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,6 +87,7 @@ Current keys: |---|---|---| | `auto_index` | `false` | Automatically index new projects when an MCP session starts. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | +| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. | | `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). | | `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | @@ -126,6 +127,35 @@ and preserves any previously serving database. See [Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and error-response details. +### `ignore_worktrees` + +Every indexed project is registered under its own absolute root path, so each +linked worktree becomes a separate permanent index. On machines that create many +short-lived worktrees, the automatic paths (`auto_index`, and the session hook's +"index this project first" guidance) turn every throwaway checkout into another +stored index of what is largely the same repository. + +Enable the key to keep those checkouts out of the index: + +```bash +codebase-memory-mcp config set ignore_worktrees true +``` + +With it enabled: + +- automatic indexing skips a session whose root is a linked worktree; +- the `hook-augment` context says the worktree is unindexed on purpose instead + of telling the agent to run `index_repository`; +- an explicit `index_repository` call on a linked worktree is refused, and names + both ways forward — pass `index_worktree=true` for that one call, or turn the + key back off. + +The main checkout of the same repository is unaffected, as are ordinary clones +and submodules. Detection is git plumbing only: a linked worktree's `.git` is a +file pointing at a gitdir that contains a `commondir` entry. + +The default is `false`, so indexing behavior is unchanged unless you opt in. + ## 3. UI Settings The optional built-in graph UI stores its settings in: diff --git a/src/cli/cli.c b/src/cli/cli.c index 6e33759e4..08aded621 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7442,6 +7442,7 @@ static const config_key_def_t CONFIG_KEYS[] = { {CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"}, {CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"}, {CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"}, + {CBM_CONFIG_IGNORE_WORKTREES, "false", "Skip linked git worktrees when indexing automatically"}, {CBM_CONFIG_WATCHER_ENABLED, "true", "Run the background watcher thread (auto-reindex); false to disable"}, {CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"}, diff --git a/src/cli/cli.h b/src/cli/cli.h index 5522edef0..a6ef66618 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -262,7 +262,8 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect, const char *forced_event); bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows); void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter); @@ -443,6 +444,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t #define CBM_CONFIG_AUTO_INDEX "auto_index" #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" #define CBM_CONFIG_AUTO_WATCH "auto_watch" +#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees" #define CBM_CONFIG_UI_LANG "ui-lang" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" /* #1558: the graph UI's loopback listener. Stored in the UI config file rather diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index f8909c365..1043b48cd 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -21,7 +21,9 @@ #include "foundation/constants.h" #include "foundation/mem.h" #include "foundation/platform.h" +#include "git/git_context.h" #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" #include "yyjson/yyjson.h" @@ -1440,7 +1442,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) { return "Tier 2 verification"; } -static const char *ha_no_project_index_guidance(const char *event) { +static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) { + /* ignore_worktrees is on and this cwd is a linked worktree: indexing it is + * deliberately disabled, so telling the agent to run index_repository would + * only produce a refusal. Point at the setting instead. */ + if (worktree_ignored) { + return "This is a linked git worktree and ignore_worktrees is enabled, so it is not " + "indexed on purpose; do not run index_repository here."; + } return event && strcmp(event, "SubagentStart") == 0 ? "Ask the parent agent to run index_repository before structural exploration; " "do not attempt graph mutation." @@ -1472,6 +1481,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; + bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && + cbm_git_is_linked_worktree(cwd); cbm_mcp_server_free(owned_server); char context[2048]; @@ -1505,7 +1516,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "and file reads for literals, configs, non-code files, and verification.", scope, safe_project, tier); } else { - const char *index_guidance = ha_no_project_index_guidance(event); + const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored); snprintf(context, sizeof(context), "[codebase-memory] %s context: no indexed graph project matched this working " "directory. %s Once indexed, " @@ -1624,8 +1635,9 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, return ha_path_contains_mode(root, candidate, case_insensitive); } -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { - return ha_no_project_index_guidance(event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored) { + return ha_no_project_index_guidance(event, worktree_ignored); } bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) { diff --git a/src/daemon/application.c b/src/daemon/application.c index 748b8bb75..8a79ecd5d 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -15,6 +15,7 @@ #include "foundation/sha256.h" #include "foundation/subprocess.h" #include "foundation/workspace.h" +#include "git/git_context.h" #include "mcp/index_supervisor.h" #include "mcp/mcp.h" #include "mcp/mcp_internal.h" @@ -2071,6 +2072,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio !application_session_workspace_allowed(session, "auto_index_discovery")) { auto_index_candidate = false; } + if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree"); + auto_index_candidate = false; + } bool within_auto_index_limit = !auto_index_candidate || cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files); diff --git a/src/git/git_context.c b/src/git/git_context.c index 47956562f..cb27c17cf 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -100,6 +100,66 @@ static bool path_is_absolute(const char *path) { #endif } +/* Read the "gitdir: " pointer out of a gitlink FILE at /.git. + * Returns false when .git is missing, a directory (ordinary repo), or holds no + * pointer. A relative pointer is resolved against path. */ +static bool read_gitlink_target(const char *path, char *out, size_t out_size) { + char dot_git[GIT_OUTPUT_MAX]; + int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path); + if (n < 0 || n >= (int)sizeof(dot_git)) { + return false; + } + struct stat st; + if (stat(dot_git, &st) != 0 || !S_ISREG(st.st_mode)) { + return false; + } + + FILE *f = cbm_fopen(dot_git, "r"); + if (!f) { + return false; + } + char line[GIT_OUTPUT_MAX]; + bool got = false; + while (fgets(line, sizeof(line), f)) { + trim_newlines(line); + if (strncmp(line, "gitdir:", 7) != 0) { + continue; + } + const char *value = line + 7; + while (*value == ' ' || *value == '\t') { + value++; + } + if (!value[0]) { + break; + } + int written = path_is_absolute(value) ? snprintf(out, out_size, "%s", value) + : snprintf(out, out_size, "%s/%s", path, value); + got = written > 0 && written < (int)out_size; + break; + } + fclose(f); + return got; +} + +bool cbm_git_is_linked_worktree(const char *path) { + if (!path || !path[0]) { + return false; + } + char git_dir[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) { + return false; + } + /* Only linked worktrees carry /commondir; a submodule gitlink + * points at /.git/modules/, which does not. */ + char commondir[GIT_OUTPUT_MAX]; + int n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); + if (n < 0 || n >= (int)sizeof(commondir)) { + return false; + } + struct stat st; + return stat(commondir, &st) == 0 && S_ISREG(st.st_mode); +} + static char *join_root_relative(const char *root, const char *rel) { if (!root || !root[0]) { return git_strdup(rel); diff --git a/src/git/git_context.h b/src/git/git_context.h index 876309eb6..633e28018 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -19,6 +19,18 @@ typedef struct { char *base_sha; } cbm_git_context_t; +/* True when path is the root of a LINKED git worktree (`git worktree add`). + * + * Plumbing-only, no subprocess: /.git must be a regular file holding a + * "gitdir: " pointer AND that gitdir must contain a `commondir` file. + * The commondir check is what separates a linked worktree from a submodule — + * a submodule's .git is also a gitlink file, but its gitdir + * (/.git/modules/) has no commondir entry. + * + * Callers run this on every session start, so it stays fork-free; the richer + * cbm_git_context_resolve() shells out to git and is not usable on that path. */ +bool cbm_git_is_linked_worktree(const char *path); + int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 78b8dccf9..41a63273e 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -478,7 +478,10 @@ static const tool_def_t TOOLS[] = { "\"name\":{\"type\":\"string\",\"description\":" "\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" - "\"Write .codebase-memory/graph.db.zst.\"}" + "\"Write .codebase-memory/graph.db.zst.\"}," + "\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":" + "\"Index repo_path even when it is a linked git worktree and the " + "ignore_worktrees config key is enabled. No effect otherwise.\"}" "},\"required\":[\"repo_path\"]}"}, {"search_graph", @@ -11174,6 +11177,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(boundary_err, true); } + /* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree + * is refused with the two ways forward (per-call override, or turn the + * setting off) rather than silently skipped — a silent success would be + * indistinguishable from a real index to the caller. */ + if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") && + cbm_git_is_linked_worktree(repo_path)) { + index_args_free(repo_path, mode_str, name_override); + return cbm_mcp_text_result( + "repo_path is a linked git worktree and ignore_worktrees is enabled. Pass " + "index_worktree=true to index it anyway, or run: codebase-memory-mcp config set " + "ignore_worktrees false", + true); + } + if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { char *result = handle_cross_repo_mode(srv, repo_path, name_override, args); index_args_free(repo_path, mode_str, name_override); @@ -17613,6 +17630,17 @@ static bool auto_watch_enabled(cbm_mcp_server_t *srv) { return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true); } +/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees + * (default off, so existing setups keep indexing them). Users who create many + * short-lived worktrees can stop each throwaway checkout from registering a + * new permanent project with `config set ignore_worktrees true`. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) { + if (!srv || !srv->config) { + return false; /* default off */ + } + return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false); +} + /* Register the session project with the background watcher for ongoing * change detection — unless auto_watch is disabled. */ static void register_watcher_if_enabled(cbm_mcp_server_t *srv) { @@ -17765,6 +17793,12 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { return; } + if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) { + cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project", + srv->session_project); + return; + } + /* Quick tracked-file count check to avoid OOM on massive repos. */ int file_count = -1; #ifdef CBM_ENABLE_TEST_SEAMS diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index de170067a..8e78f9f40 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -63,6 +63,11 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy); bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, int *file_count_out); +/* True when the `ignore_worktrees` config key is on for this server (default + * off). Callers pair it with cbm_git_is_linked_worktree() to decide whether an + * automatic index of a linked worktree should be skipped. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv); + /* detect_changes seed scoping (#1363): does `node`'s line range overlap any * recorded hunk for `file`? Exposed for direct unit testing of the overlap * logic, independent of the git/subprocess/index plumbing around it. */ diff --git a/tests/test_cli.c b/tests/test_cli.c index 7cd44244e..14553ef68 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -12536,14 +12536,18 @@ TEST(cli_hook_augment_subagent_tier_router_contract) { } TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only) { - const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart"); - const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart"); + const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart", false); + const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart", false); + const char *worktree = cbm_hook_no_project_index_guidance_for_testing("SessionStart", true); ASSERT_NOT_NULL(session); ASSERT_NOT_NULL(subagent); + ASSERT_NOT_NULL(worktree); ASSERT(strstr(session, "Run index_repository") != NULL); ASSERT(strstr(subagent, "Ask the parent agent to run index_repository") != NULL); ASSERT(strstr(subagent, "do not attempt graph mutation") != NULL); ASSERT(strstr(subagent, "Run index_repository") == NULL); + ASSERT(strstr(worktree, "ignore_worktrees is enabled") != NULL); + ASSERT(strstr(worktree, "do not run index_repository") != NULL); PASS(); } @@ -15941,6 +15945,19 @@ TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558) { PASS(); } +TEST(cli_ignore_worktrees_config_key_is_discoverable) { + bool listed = false; + for (size_t i = 0; i < cbm_cli_config_key_count_for_testing(); i++) { + const char *key = cbm_cli_config_key_at_for_testing(i); + if (key && strcmp(key, CBM_CONFIG_IGNORE_WORKTREES) == 0) { + listed = true; + break; + } + } + ASSERT_TRUE(listed); + PASS(); +} + TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554) { const cbm_skill_t *sk = cbm_get_skills(); ASSERT_NOT_NULL(sk); @@ -16374,6 +16391,7 @@ SUITE(cli) { RUN_TEST(cli_update_download_failure_does_not_quiesce_sessions); RUN_TEST(cli_update_already_current_does_not_quiesce_sessions); RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558); + RUN_TEST(cli_ignore_worktrees_config_key_is_discoverable); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index cc94f1d06..894cb68aa 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -253,6 +253,118 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +/* ── cbm_git_is_linked_worktree ─────────────────────────────────── */ +/* Detection backing the `ignore_worktrees` config gate. The predicate must be + * TRUE only for a linked worktree (`git worktree add`) — never for the main + * checkout, a plain directory, or a submodule. The submodule case is the one a + * naive "is .git a regular file?" check gets wrong: a submodule's .git is also a + * gitlink file, and it is separated here by the absence of a `commondir` entry + * in the pointed-at gitdir. */ + +TEST(is_linked_worktree_true_for_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + /* th_mktempdir() returns a static buffer — copy before the second call. */ + char main_tmp[256]; + char *raw = th_mktempdir("cbm_wt_main"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(main_tmp, raw, sizeof(main_tmp) - 1); + main_tmp[sizeof(main_tmp) - 1] = '\0'; + + char wt_tmp[256]; + raw = th_mktempdir("cbm_wt_linked"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(wt_tmp, raw, sizeof(wt_tmp) - 1); + wt_tmp[sizeof(wt_tmp) - 1] = '\0'; + th_rmtree(wt_tmp); /* git worktree add creates it */ + + if (make_git_repo(main_tmp) != 0) { + th_rmtree(main_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (git_run(main_tmp, "branch wt-branch") != 0) { + th_rmtree(main_tmp); + FAIL("failed to create branch for worktree"); + } + char wt_cmd[1024]; + snprintf(wt_cmd, sizeof(wt_cmd), "worktree add \"%s\" wt-branch", wt_tmp); + if (git_run(main_tmp, wt_cmd) != 0) { + th_rmtree(wt_tmp); + th_rmtree(main_tmp); + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + + bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp); + /* The MAIN checkout of the very same repo must NOT be flagged — otherwise + * enabling ignore_worktrees would stop indexing ordinary repositories. */ + bool main_detected = cbm_git_is_linked_worktree(main_tmp); + + git_run(main_tmp, "worktree prune"); + th_rmtree(main_tmp); + th_rmtree(wt_tmp); + + ASSERT(worktree_detected); + ASSERT(!main_detected); + PASS(); +#endif /* _WIN32 */ +} + +TEST(is_linked_worktree_false_for_submodule_and_nongit) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + char super_tmp[256]; + char *raw = th_mktempdir("cbm_wt_super"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(super_tmp, raw, sizeof(super_tmp) - 1); + super_tmp[sizeof(super_tmp) - 1] = '\0'; + + char child_tmp[256]; + raw = th_mktempdir("cbm_wt_child"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(child_tmp, raw, sizeof(child_tmp) - 1); + child_tmp[sizeof(child_tmp) - 1] = '\0'; + + if (make_git_repo(super_tmp) != 0 || make_git_repo(child_tmp) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + /* A plain directory that is not a git repo at all. */ + char plain[1024]; + snprintf(plain, sizeof(plain), "%s/plain", super_tmp); + if (th_mkdir_p(plain) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + FAIL("failed to create plain dir"); + } + bool plain_detected = cbm_git_is_linked_worktree(plain); + + /* file:// submodules are refused by default since the CVE-2022-39253 fix. */ + char sub_cmd[1024]; + snprintf(sub_cmd, sizeof(sub_cmd), + "-c protocol.file.allow=always submodule add -q \"%s\" subm", child_tmp); + int sub_rc = git_run(super_tmp, sub_cmd); + + char subm[1024]; + snprintf(subm, sizeof(subm), "%s/subm", super_tmp); + bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm); + + th_rmtree(super_tmp); + th_rmtree(child_tmp); + + ASSERT(!plain_detected); + if (sub_rc != 0) { + SKIP_PLATFORM("git submodule add unavailable in this environment"); + } + /* A submodule gitlink has no commondir → must not be treated as a worktree. */ + ASSERT(!submodule_detected); + PASS(); +#endif /* _WIN32 */ +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { @@ -260,4 +372,6 @@ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(is_linked_worktree_true_for_linked_worktree); + RUN_TEST(is_linked_worktree_false_for_submodule_and_nongit); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 60649f505..b7b1335a1 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -19335,6 +19335,291 @@ TEST(mcp_auto_watch_false_skips_watcher_on_connect) { PASS(); } +/* ═══════════════════════════════════════════════════════════════ + * ignore_worktrees — explicit index_repository on a linked worktree + * ═══════════════════════════════════════════════════════════════ */ + +/* An EXPLICIT index_repository call is refused only when ignore_worktrees is on + * AND repo_path is a linked worktree AND no per-call override was passed. The + * refusal must never fire for the MAIN checkout — that would break ordinary + * indexing for anyone enabling the key — and index_worktree=true must escape it. + * + * Default-off is the regression class the project guards hardest: with the key + * unset, a linked worktree still indexes exactly as today. + * + * Probe returns a bit set, or a negative fixture-setup code. */ +enum { + IGNORE_WT_REFUSED_WORKTREE = 1, /* expected when the key is on */ + IGNORE_WT_REFUSED_MAIN = 2, /* BUG if set */ + IGNORE_WT_REFUSED_OVERRIDE = 4, /* BUG if set */ +}; + +#ifndef _WIN32 +typedef struct { + char cache[256]; + char main_repo[512]; + char wt_repo[512]; +} ignore_wt_fixture_t; + +static void ignore_wt_fixture_cleanup(ignore_wt_fixture_t *fx) { + if (!fx) { + return; + } + if (fx->main_repo[0]) { + char prune[1024]; + snprintf(prune, sizeof(prune), "git -C \"%s\" worktree prune >/dev/null 2>&1", fx->main_repo); + (void)system(prune); + } + if (fx->cache[0]) { + th_rmtree(fx->cache); + } +} + +/* 0 = ready. -1 tmpdir, -2 mkdir, -3 git worktree fixture unavailable. */ +static int ignore_wt_fixture_setup(ignore_wt_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + char *raw = th_mktempdir("cbm_ignorewt"); + if (!raw) { + return -1; + } + snprintf(fx->cache, sizeof(fx->cache), "%s", raw); + snprintf(fx->main_repo, sizeof(fx->main_repo), "%s/main", fx->cache); + snprintf(fx->wt_repo, sizeof(fx->wt_repo), "%s/wt", fx->cache); + if (th_mkdir_p(fx->main_repo) != 0) { + ignore_wt_fixture_cleanup(fx); + return -2; + } + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" init -q >/dev/null 2>&1 && " + "git -C \"%s\" config user.email t@example.com && " + "git -C \"%s\" config user.name T && touch \"%s/.keep\" && " + "git -C \"%s\" add .keep && git -C \"%s\" commit -q -m init && " + "git -C \"%s\" worktree add -q \"%s\" -b wtb", + fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, + fx->main_repo, fx->main_repo, fx->wt_repo); + if (system(cmd) != 0) { + ignore_wt_fixture_cleanup(fx); + return -3; + } + return 0; +} + +static bool ignore_wt_refused(cbm_mcp_server_t *srv, const char *repo_path, bool override) { + char args[2048]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"%s}", repo_path, + override ? ",\"index_worktree\":true" : ""); + char *result = cbm_mcp_handle_tool(srv, "index_repository", args); + bool refused = result && strstr(result, "ignore_worktrees is enabled") != NULL; + free(result); + return refused; +} + +static int ignore_worktrees_index_probe(const char *ignore_value) { + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup < 0) { + return setup; + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", fx.cache, 1); + + int bits = -4; + cbm_config_t *cfg = cbm_config_open(fx.cache); + if (cfg) { + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (srv) { + cbm_mcp_server_set_config(srv, cfg); + bits = 0; + if (ignore_wt_refused(srv, fx.wt_repo, false)) { + bits |= IGNORE_WT_REFUSED_WORKTREE; + } + if (ignore_wt_refused(srv, fx.main_repo, false)) { + bits |= IGNORE_WT_REFUSED_MAIN; + } + if (ignore_wt_refused(srv, fx.wt_repo, true)) { + bits |= IGNORE_WT_REFUSED_OVERRIDE; + } + cbm_mcp_server_free(srv); + } + cbm_config_close(cfg); + } + + restore_cache_dir(saved_copy); + free(saved_copy); + ignore_wt_fixture_cleanup(&fx); + return bits; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +static void ignore_wt_count_started(void *context) { + int *calls = context; + (*calls)++; +} + +/* Drive initialize → maybe_auto_index on a linked worktree. ignore_value NULL + * leaves the key unset (default-off). The count hook fires only if auto-index + * reaches discovery, so a skip for linked_worktree must leave it at 0. */ +static int ignore_wt_autoindex_count_calls(const char *session_root, const char *ignore_value) { + char *raw = th_mktempdir("cbm_ignorewt_auto"); + if (!raw) { + return -1; + } + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char old_cwd[1024]; + if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(session_root) != 0) { + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return -2; + } + + int calls = -3; + cbm_config_t *cfg = cbm_config_open(cache); + cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; + if (srv) { + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); + /* Fail-closed on the actual count so this never launches an index thread. */ + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "-1"); + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_set_config(srv, cfg); + calls = 0; + cbm_mcp_server_set_auto_index_count_test_hook(srv, ignore_wt_count_started, &calls); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1430,\"method\":\"initialize\",\"params\":{}}"); + free(response); + cbm_mcp_server_free(srv); + } + if (cfg) { + cbm_config_close(cfg); + } + + (void)cbm_chdir(old_cwd); + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return calls; +} +#endif /* CBM_ENABLE_TEST_SEAMS */ +#endif /* !_WIN32 */ + +TEST(mcp_ignore_worktrees_gates_explicit_index_repository) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe("true"); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + /* RED before the gate existed: nothing is ever refused. */ + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) != 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +/* Default-off pin: key unset, linked worktree still indexes. The gate is + * opt-in; an unset key must be bit-for-bit today's behaviour. */ +TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe(NULL); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +#ifdef CBM_ENABLE_TEST_SEAMS +TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, "true"); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 0); + PASS(); +#endif /* _WIN32 */ +} + +TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, NULL); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 1); + PASS(); +#endif /* _WIN32 */ +} +#endif /* CBM_ENABLE_TEST_SEAMS */ + /* ══════════════════════════════════════════════════════════════════ * #1466 / #713 — the auto_index_limit guard * @@ -20824,6 +21109,13 @@ SUITE(mcp) { RUN_TEST(autoindex_limit_guards_non_git_root_issue713); RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713); RUN_TEST(autoindex_limit_guards_git_root_issue713); + /* ignore_worktrees gate */ + RUN_TEST(mcp_ignore_worktrees_gates_explicit_index_repository); + RUN_TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree); +#ifdef CBM_ENABLE_TEST_SEAMS + RUN_TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree); + RUN_TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count); +#endif } /* Kept separate so daemon-coordination regressions can be iterated without From d63d0e16d18cf46836f0d019a03726bb6a577d16 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 03:23:24 +0000 Subject: [PATCH 04/36] fix(git): probe worktree gitlinks without stat() Upstream's Unicode path fix dropped stat() from this file because it follows symlinks and rejects non-ASCII paths on Windows. Keep ignore_worktrees detection on cbm_path_info_utf8 so the rebase does not restore that probe. Signed-off-by: Nohj --- src/git/git_context.c | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index cb27c17cf..3a3da7acb 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -86,6 +86,14 @@ static int git_capture(const char *repo_path, const char *git_args, char **out) return *out ? 0 : CBM_NOT_FOUND; } +/* Regular file, not a directory and not a symlink. Uses the Unicode-safe + * path probe: stat() follows links and rejects non-ASCII paths on Windows, + * which upstream removed from this file in the Unicode repository-path fix. */ +static bool path_is_regular_file(const char *path) { + cbm_path_info_t info; + return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_regular; +} + static bool path_is_absolute(const char *path) { if (!path || !path[0]) { return false; @@ -109,8 +117,7 @@ static bool read_gitlink_target(const char *path, char *out, size_t out_size) { if (n < 0 || n >= (int)sizeof(dot_git)) { return false; } - struct stat st; - if (stat(dot_git, &st) != 0 || !S_ISREG(st.st_mode)) { + if (!path_is_regular_file(dot_git)) { return false; } @@ -156,8 +163,7 @@ bool cbm_git_is_linked_worktree(const char *path) { if (n < 0 || n >= (int)sizeof(commondir)) { return false; } - struct stat st; - return stat(commondir, &st) == 0 && S_ISREG(st.st_mode); + return path_is_regular_file(commondir); } static char *join_root_relative(const char *root, const char *rel) { From 3bb85c8c7c3c005fc59bed0a8ee5c467d903a761 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 03:37:57 +0000 Subject: [PATCH 05/36] fix(daemon): recheck ignore_worktrees on auto-index retry A session queued while capacity was full could still be indexed after the setting was turned on. Retry admission now applies the same linked-worktree gate as the initial path. Signed-off-by: Nohj --- src/daemon/application.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/daemon/application.c b/src/daemon/application.c index 8a79ecd5d..153bc0d81 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -1457,6 +1457,18 @@ static void application_auto_index_retry_pending_locked(cbm_daemon_application_t application_refresh_watch_locked(session); continue; } + /* ignore_worktrees can be turned on after this session was queued for + * a capacity slot. Initial admission already skips linked worktrees; + * retry must apply the same gate or the pending checkout is indexed + * once a slot opens. */ + if (cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + session->auto_index_retry_pending = false; + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", + "linked_worktree"); + application_refresh_watch_locked(session); + continue; + } char *args = application_auto_index_args(application, root_path); if (!args) { continue; From 5d5c9621eb3e94ee26ab39c26b15237fb38223df Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 10:44:02 +0000 Subject: [PATCH 06/36] fix(cli): load runtime config for the hook-augment worktree note The production hook builds its server with cbm_mcp_server_new(NULL), so ignore_worktrees stayed off and an unindexed linked worktree still told the agent to run index_repository. Attach the runtime store for that check, then drop it before return. Signed-off-by: Nohj --- src/cli/hook_augment.c | 14 ++++++++++++++ src/mcp/mcp.c | 12 ++++++++++++ src/mcp/mcp.h | 6 ++++++ tests/test_cli.c | 16 +++++++++++++++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 1043b48cd..a389e487f 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1475,14 +1475,28 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root char cwd_buffer[4096]; cbm_mcp_server_t *owned_server = NULL; + cbm_config_t *owned_config = NULL; if (!srv) { owned_server = cbm_mcp_server_new(NULL); srv = owned_server; } + /* cbm_mcp_server_new(NULL) does not attach the runtime store. The production + * hook uses that constructor, so ignore_worktrees would stay at its default + * (off) and the "not indexed" note would still tell the agent to run + * index_repository in a linked worktree the setting refuses. Load the store + * only when the caller has not already set one, then drop it before return + * so a caller-owned server is not left pointing at a closed config. */ + if (srv) { + owned_config = cbm_mcp_server_attach_runtime_config(srv); + } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); + if (owned_config) { + cbm_mcp_server_set_config(srv, NULL); + cbm_config_close(owned_config); + } cbm_mcp_server_free(owned_server); char context[2048]; diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 41a63273e..19083feee 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1751,6 +1751,18 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } +struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv) { + if (!srv || srv->config) { + return NULL; + } + cbm_config_t *cfg = cbm_config_open(cbm_resolve_cache_dir()); + if (!cfg) { + return NULL; + } + srv->config = cfg; + return cfg; +} + #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv, cbm_mcp_auto_index_count_test_hook_fn hook, diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 09befb780..6fb110181 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -158,6 +158,12 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w); /* Set external config store reference (for auto_index setting). Not owned. */ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg); +/* Open the runtime config and attach it when srv has none. The returned store + * is owned by the caller, who must clear it with cbm_mcp_server_set_config + * before closing. Returns NULL when a config is already set or the store + * cannot be opened. */ +struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv); + /* Set an explicit session context for an embedded/daemon-backed server. * session_root is copied and its project name is derived using the same naming * rule as indexing. allowed_root is copied when non-NULL; an explicit NULL diff --git a/tests/test_cli.c b/tests/test_cli.c index 14553ef68..5d7441ec4 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10629,9 +10629,21 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir); char *wt_out = cbm_hook_augment_lifecycle_json(input); bool deliberate = wt_out && wt_out[0] && strstr(wt_out, "no indexed graph project matched") && - !strstr(wt_out, "is indexed"); + strstr(wt_out, "Run index_repository") && + !strstr(wt_out, "ignore_worktrees is enabled") && !strstr(wt_out, "is indexed"); free(wt_out); + /* The hook must read the runtime store. With the key off the note still + * says to index; with it on, the same unindexed worktree must not. */ + cbm_config_t *cfg = cbm_config_open(cache); + bool configured = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(cfg); + char *ignored_out = configured ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool honored = ignored_out && strstr(ignored_out, "ignore_worktrees is enabled") && + strstr(ignored_out, "do not run index_repository") && + !strstr(ignored_out, "is indexed"); + free(ignored_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -10647,6 +10659,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { if (!deliberate) FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); + if (!honored) + FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); From 0071007396568b44fb331bb876084dd88a91a415 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 10:59:57 +0000 Subject: [PATCH 07/36] fix(git): detect linked worktrees from nested directories ignore_worktrees only inspected /.git, so a session or index path inside a linked worktree still passed the gate. Walk ancestors until a git anchor, and stop at a main checkout or a submodule. Signed-off-by: Nohj --- src/git/git_context.c | 87 +++++++++++++++++++++++++++++++++++++--- src/git/git_context.h | 13 +++--- tests/test_git_context.c | 23 ++++++++++- 3 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index 3a3da7acb..3fbf5b65d 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -148,22 +148,97 @@ static bool read_gitlink_target(const char *path, char *out, size_t out_size) { return got; } -bool cbm_git_is_linked_worktree(const char *path) { - if (!path || !path[0]) { - return false; +/* 1 = linked-worktree gitlink, 0 = a different git anchor (stop walking), + * -1 = no .git at this directory (keep walking). */ +static int linked_worktree_anchor(const char *path) { + char dot_git[GIT_OUTPUT_MAX]; + int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path); + if (n < 0 || n >= (int)sizeof(dot_git)) { + return 0; + } + cbm_path_info_t info; + if (cbm_path_info_utf8(dot_git, &info) != CBM_PATH_INFO_OK) { + return -1; + } + /* A directory .git is a main checkout. Anything else that is not a + * regular gitlink file (a symlink, for example) is also an anchor: + * do not follow it, and do not blame a parent worktree for this tree. */ + if (!info.is_regular) { + return 0; } char git_dir[GIT_OUTPUT_MAX]; if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) { - return false; + return 0; } /* Only linked worktrees carry /commondir; a submodule gitlink * points at /.git/modules/, which does not. */ char commondir[GIT_OUTPUT_MAX]; - int n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); + n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); if (n < 0 || n >= (int)sizeof(commondir)) { + return 0; + } + return path_is_regular_file(commondir) ? 1 : 0; +} + +static bool parent_directory(char *path) { + size_t n = strlen(path); + while (n > 1 && (path[n - 1] == '/' || path[n - 1] == '\\')) { + path[--n] = '\0'; + } +#ifdef _WIN32 + if ((n == 2 && path[1] == ':') || + (n == 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\'))) { return false; } - return path_is_regular_file(commondir); +#endif + if (n <= 1) { + return false; + } + char *slash = NULL; + for (size_t i = 0; i < n; i++) { + if (path[i] == '/' || path[i] == '\\') { + slash = path + i; + } + } + if (!slash) { + return false; + } + if (slash == path) { + path[1] = '\0'; + return true; + } + *slash = '\0'; + return true; +} + +bool cbm_git_is_linked_worktree(const char *path) { + if (!path || !path[0]) { + return false; + } + char current[GIT_OUTPUT_MAX]; + int n = snprintf(current, sizeof(current), "%s", path); + if (n < 0 || n >= (int)sizeof(current)) { + return false; + } + size_t len = strlen(current); + while (len > 1 && (current[len - 1] == '/' || current[len - 1] == '\\')) { + current[--len] = '\0'; + } + /* A session cwd or index_repository path is often a subdirectory. Walk + * ancestors until a git anchor so ignore_worktrees applies to the whole + * linked checkout, not only its root. */ + for (int depth = 0; depth < 64; depth++) { + int kind = linked_worktree_anchor(current); + if (kind >= 0) { + return kind == 1; + } + char previous[GIT_OUTPUT_MAX]; + snprintf(previous, sizeof(previous), "%s", current); + if (!parent_directory(current) || strcmp(previous, current) == 0) { + return false; + } + } + return false; } static char *join_root_relative(const char *root, const char *rel) { diff --git a/src/git/git_context.h b/src/git/git_context.h index 633e28018..30f2f545a 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -19,13 +19,14 @@ typedef struct { char *base_sha; } cbm_git_context_t; -/* True when path is the root of a LINKED git worktree (`git worktree add`). +/* True when path is a LINKED git worktree (`git worktree add`) or a + * subdirectory of one. * - * Plumbing-only, no subprocess: /.git must be a regular file holding a - * "gitdir: " pointer AND that gitdir must contain a `commondir` file. - * The commondir check is what separates a linked worktree from a submodule — - * a submodule's .git is also a gitlink file, but its gitdir - * (/.git/modules/) has no commondir entry. + * Plumbing-only, no subprocess. Walk ancestors until a git anchor. A linked + * worktree's /.git is a regular file holding a "gitdir: " pointer + * AND that gitdir contains a `commondir` file. The walk stops at any other + * anchor: a main checkout (`.git` is a directory) or a submodule (gitlink + * file whose gitdir, /.git/modules/, has no commondir). * * Callers run this on every session start, so it stays fork-free; the richer * cbm_git_context_resolve() shells out to git and is not usable on that path. */ diff --git a/tests/test_git_context.c b/tests/test_git_context.c index 894cb68aa..5b3c4b779 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -296,16 +296,30 @@ TEST(is_linked_worktree_true_for_linked_worktree) { } bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp); + /* A subdirectory has no .git of its own. The predicate must still see the + * linked worktree above it, or ignore_worktrees misses nested sessions. */ + char nested[1024]; + snprintf(nested, sizeof(nested), "%s/nested/deeper", wt_tmp); + bool nested_ok = th_mkdir_p(nested) == 0; + bool nested_detected = nested_ok && cbm_git_is_linked_worktree(nested); /* The MAIN checkout of the very same repo must NOT be flagged — otherwise * enabling ignore_worktrees would stop indexing ordinary repositories. */ bool main_detected = cbm_git_is_linked_worktree(main_tmp); + char main_nested[1024]; + snprintf(main_nested, sizeof(main_nested), "%s/src", main_tmp); + bool main_nested_ok = th_mkdir_p(main_nested) == 0; + bool main_nested_detected = main_nested_ok && cbm_git_is_linked_worktree(main_nested); git_run(main_tmp, "worktree prune"); th_rmtree(main_tmp); th_rmtree(wt_tmp); ASSERT(worktree_detected); + ASSERT(nested_ok); + ASSERT(nested_detected); ASSERT(!main_detected); + ASSERT(main_nested_ok); + ASSERT(!main_nested_detected); PASS(); #endif /* _WIN32 */ } @@ -351,6 +365,10 @@ TEST(is_linked_worktree_false_for_submodule_and_nongit) { char subm[1024]; snprintf(subm, sizeof(subm), "%s/subm", super_tmp); bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm); + char sub_nested[1024]; + snprintf(sub_nested, sizeof(sub_nested), "%s/nested", subm); + bool sub_nested_made = sub_rc == 0 && th_mkdir_p(sub_nested) == 0; + bool sub_nested_detected = sub_nested_made && cbm_git_is_linked_worktree(sub_nested); th_rmtree(super_tmp); th_rmtree(child_tmp); @@ -359,8 +377,11 @@ TEST(is_linked_worktree_false_for_submodule_and_nongit) { if (sub_rc != 0) { SKIP_PLATFORM("git submodule add unavailable in this environment"); } - /* A submodule gitlink has no commondir → must not be treated as a worktree. */ + /* A submodule gitlink has no commondir → must not be treated as a worktree, + * including a subdirectory, which must not walk into the superproject. */ ASSERT(!submodule_detected); + ASSERT(sub_nested_made); + ASSERT(!sub_nested_detected); PASS(); #endif /* _WIN32 */ } From 47c7a07a745c143a499977f920f0f8273cdadc8f Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:12:54 +0000 Subject: [PATCH 08/36] fix(cli): do not treat a parent graph as a linked worktree ignore_worktrees checked the setting only after project resolution, so a linked worktree nested in an indexed checkout was reported as that parent project. Drop an ancestor match and keep an index whose root is the worktree itself. Signed-off-by: Nohj --- src/cli/hook_augment.c | 19 ++++++++++++++++--- tests/test_cli.c | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index a389e487f..b08d6ebd1 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1490,9 +1490,22 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root owned_config = cbm_mcp_server_attach_runtime_config(srv); } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); - char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; - bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && - cbm_git_is_linked_worktree(cwd); + char project_root[4096]; + project_root[0] = '\0'; + char *project = srv && cwd ? ha_resolve_indexed_project_with_root(srv, cwd, project_root, + sizeof(project_root)) + : NULL; + /* ignore_worktrees refuses a linked worktree, including one nested inside an + * indexed checkout. The ancestor walk would otherwise report that parent + * graph as this cwd's project. An index whose own root is the linked + * worktree (or inside it) still counts. */ + bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && + cbm_git_is_linked_worktree(cwd); + if (ignore_linked && project && !cbm_git_is_linked_worktree(project_root)) { + free(project); + project = NULL; + } + bool worktree_ignored = ignore_linked && !project; if (owned_config) { cbm_mcp_server_set_config(srv, NULL); cbm_config_close(owned_config); diff --git a/tests/test_cli.c b/tests/test_cli.c index 5d7441ec4..6297a64d0 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10644,6 +10644,41 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { !strstr(ignored_out, "is indexed"); free(ignored_out); + /* A linked worktree nested inside the indexed checkout must not inherit + * that parent graph once ignore_worktrees is on. */ + char insider[512]; + snprintf(insider, sizeof(insider), "%s/inside-wt", maindir); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7inside", maindir, + insider); + bool inside_made = configured && system(cmd) == 0; + char inside_sub[600]; + snprintf(inside_sub, sizeof(inside_sub), "%s/nested", insider); + bool inside_sub_made = inside_made && test_mkdirp(inside_sub) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + inside_sub); + char *inside_out = inside_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool inside_ignored = inside_out && strstr(inside_out, "ignore_worktrees is enabled") && + strstr(inside_out, "do not run index_repository") && + !strstr(inside_out, "is indexed") && !strstr(inside_out, main_name); + free(inside_out); + + /* Indexing that nested worktree itself must still win over the parent. */ + char *inside_name = inside_made ? cbm_project_name_from_path(insider) : NULL; + bool inside_stored = false; + if (inside_name) { + char db_inside[900]; + snprintf(db_inside, sizeof(db_inside), "%s/%s.db", cache, inside_name); + cbm_store_t *si = cbm_store_open_path(db_inside); + inside_stored = si && cbm_store_upsert_project(si, inside_name, insider) == CBM_STORE_OK; + if (si) + cbm_store_close(si); + } + char *exact_out = inside_stored ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool exact_kept = exact_out && inside_name && strstr(exact_out, inside_name) && + strstr(exact_out, "is indexed") && + !strstr(exact_out, "ignore_worktrees is enabled"); + free(exact_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -10652,6 +10687,7 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { restore_test_env("CBM_CACHE_DIR", saved_cache); free(main_name); + free(inside_name); snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1", maindir, wtdir); (void)system(cmd); @@ -10661,6 +10697,10 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); if (!honored) FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); + if (!inside_ignored) + FAIL("a nested linked worktree must not inherit the indexed parent graph"); + if (!exact_kept) + FAIL("an exact index of the nested worktree must still be reported"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); From 51a18c7b27728534ffefd7b2c4963277a91aa176 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:18:45 +0000 Subject: [PATCH 09/36] fix(cli): keep a worktree graph only for that same worktree The parent-graph drop treated any linked worktree root as a match, so an unindexed worktree nested inside an indexed one still inherited that parent. Compare the gitdir of each path's own anchor and keep the project only when both paths are that same linked worktree. Signed-off-by: Nohj --- src/cli/hook_augment.c | 7 +++---- src/git/git_context.c | 37 +++++++++++++++++++++++++++++++++++-- src/git/git_context.h | 6 ++++++ tests/test_cli.c | 21 +++++++++++++++++++++ tests/test_git_context.c | 18 ++++++++++++++++++ 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index b08d6ebd1..d3baace47 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1496,12 +1496,11 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root sizeof(project_root)) : NULL; /* ignore_worktrees refuses a linked worktree, including one nested inside an - * indexed checkout. The ancestor walk would otherwise report that parent - * graph as this cwd's project. An index whose own root is the linked - * worktree (or inside it) still counts. */ + * indexed checkout or inside another linked worktree. Keep a match only when + * its root belongs to this cwd's own linked worktree, not a parent graph. */ bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); - if (ignore_linked && project && !cbm_git_is_linked_worktree(project_root)) { + if (ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root)) { free(project); project = NULL; } diff --git a/src/git/git_context.c b/src/git/git_context.c index 3fbf5b65d..8e2629cc4 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -211,7 +211,8 @@ static bool parent_directory(char *path) { return true; } -bool cbm_git_is_linked_worktree(const char *path) { +/* On a linked-worktree hit, optionally copy that anchor directory into root_out. */ +static bool walk_linked_worktree_root(const char *path, char *root_out, size_t root_out_size) { if (!path || !path[0]) { return false; } @@ -230,7 +231,16 @@ bool cbm_git_is_linked_worktree(const char *path) { for (int depth = 0; depth < 64; depth++) { int kind = linked_worktree_anchor(current); if (kind >= 0) { - return kind == 1; + if (kind != 1) { + return false; + } + if (root_out && root_out_size > 0U) { + int written = snprintf(root_out, root_out_size, "%s", current); + if (written < 0 || (size_t)written >= root_out_size) { + return false; + } + } + return true; } char previous[GIT_OUTPUT_MAX]; snprintf(previous, sizeof(previous), "%s", current); @@ -241,6 +251,29 @@ bool cbm_git_is_linked_worktree(const char *path) { return false; } +bool cbm_git_is_linked_worktree(const char *path) { + return walk_linked_worktree_root(path, NULL, 0U); +} + +bool cbm_git_same_linked_worktree(const char *a, const char *b) { + char root_a[GIT_OUTPUT_MAX]; + char root_b[GIT_OUTPUT_MAX]; + if (!walk_linked_worktree_root(a, root_a, sizeof(root_a)) || + !walk_linked_worktree_root(b, root_b, sizeof(root_b))) { + return false; + } + /* The gitdir file is unique per linked worktree. Comparing those pointers + * keeps a nested worktree from matching its parent even when both are + * linked checkouts. */ + char git_a[GIT_OUTPUT_MAX]; + char git_b[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(root_a, git_a, sizeof(git_a)) || + !read_gitlink_target(root_b, git_b, sizeof(git_b))) { + return false; + } + return strcmp(git_a, git_b) == 0; +} + static char *join_root_relative(const char *root, const char *rel) { if (!root || !root[0]) { return git_strdup(rel); diff --git a/src/git/git_context.h b/src/git/git_context.h index 30f2f545a..1b9d174f1 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -32,6 +32,12 @@ typedef struct { * cbm_git_context_resolve() shells out to git and is not usable on that path. */ bool cbm_git_is_linked_worktree(const char *path); +/* True when both paths sit in the same linked worktree. Identity is the + * gitdir pointer of each path's linked-worktree anchor, so a worktree nested + * inside another does not count as that parent. False when either path is not + * inside a linked worktree. */ +bool cbm_git_same_linked_worktree(const char *a, const char *b); + int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); diff --git a/tests/test_cli.c b/tests/test_cli.c index 6297a64d0..e234d77df 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10679,6 +10679,25 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { !strstr(exact_out, "ignore_worktrees is enabled"); free(exact_out); + /* An unindexed worktree nested inside that indexed worktree must not + * inherit the outer worktree's graph. */ + char child[600]; + snprintf(child, sizeof(child), "%s/child-wt", insider); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7child", maindir, + child); + bool child_made = inside_stored && system(cmd) == 0; + char child_sub[700]; + snprintf(child_sub, sizeof(child_sub), "%s/sub", child); + bool child_sub_made = child_made && test_mkdirp(child_sub) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + child_sub); + char *child_out = child_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool child_ignored = child_out && inside_name && + strstr(child_out, "ignore_worktrees is enabled") && + strstr(child_out, "do not run index_repository") && + !strstr(child_out, "is indexed") && !strstr(child_out, inside_name); + free(child_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -10701,6 +10720,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("a nested linked worktree must not inherit the indexed parent graph"); if (!exact_kept) FAIL("an exact index of the nested worktree must still be reported"); + if (!child_ignored) + FAIL("a worktree nested inside an indexed worktree must not inherit that graph"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index 5b3c4b779..10cab0009 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -309,6 +309,19 @@ TEST(is_linked_worktree_true_for_linked_worktree) { snprintf(main_nested, sizeof(main_nested), "%s/src", main_tmp); bool main_nested_ok = th_mkdir_p(main_nested) == 0; bool main_nested_detected = main_nested_ok && cbm_git_is_linked_worktree(main_nested); + /* A worktree nested inside another must not share the parent's anchor. */ + char inner[1024]; + snprintf(inner, sizeof(inner), "%s/inner-wt", wt_tmp); + char inner_cmd[1200]; + snprintf(inner_cmd, sizeof(inner_cmd), "worktree add -b inner-branch \"%s\"", inner); + bool inner_added = git_run(main_tmp, inner_cmd) == 0; + char inner_sub[1200]; + snprintf(inner_sub, sizeof(inner_sub), "%s/sub", inner); + bool inner_sub_ok = inner_added && th_mkdir_p(inner_sub) == 0; + bool same_outer = cbm_git_same_linked_worktree(nested, wt_tmp); + bool same_inner = inner_sub_ok && cbm_git_same_linked_worktree(inner_sub, inner); + bool not_parent = inner_sub_ok && !cbm_git_same_linked_worktree(inner_sub, wt_tmp); + bool not_main = !cbm_git_same_linked_worktree(nested, main_tmp); git_run(main_tmp, "worktree prune"); th_rmtree(main_tmp); @@ -320,6 +333,11 @@ TEST(is_linked_worktree_true_for_linked_worktree) { ASSERT(!main_detected); ASSERT(main_nested_ok); ASSERT(!main_nested_detected); + ASSERT(inner_added); + ASSERT(same_outer); + ASSERT(same_inner); + ASSERT(not_parent); + ASSERT(not_main); PASS(); #endif /* _WIN32 */ } From 72f1a52dc6bb898166a2998b335c0682110a5c31 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:25:14 +0000 Subject: [PATCH 10/36] fix(git): identify a worktree by its canonical gitdir A 64-level walk missed a deep session path, and a relative gitdir joined onto a symlink spelling no longer matched the real project root. Walk to the filesystem root and compare canonical gitdirs. Drop the unused project resolver that failed the macOS -Werror build. Signed-off-by: Nohj --- src/cli/hook_augment.c | 4 ---- src/git/git_context.c | 14 +++++++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index d3baace47..10e7741a1 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1002,10 +1002,6 @@ static char *ha_resolve_indexed_project_with_root(cbm_mcp_server_t *srv, const c return ha_registry_project_for_path(srv, cwd, root_out, root_out_size); } -static char *ha_resolve_indexed_project(cbm_mcp_server_t *srv, const char *cwd) { - return ha_resolve_indexed_project_with_root(srv, cwd, NULL, 0U); -} - static const char *ha_hook_event_name(yyjson_val *root) { const char *event = ha_obj_str(root, "hook_event_name"); return event ? event : ha_obj_str(root, "hookEventName"); diff --git a/src/git/git_context.c b/src/git/git_context.c index 8e2629cc4..a63d3385e 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -228,7 +228,9 @@ static bool walk_linked_worktree_root(const char *path, char *root_out, size_t r /* A session cwd or index_repository path is often a subdirectory. Walk * ancestors until a git anchor so ignore_worktrees applies to the whole * linked checkout, not only its root. */ - for (int depth = 0; depth < 64; depth++) { + /* Bound is the path itself: each step drops one component and stops at + * the filesystem root. A fixed depth would miss a deep session cwd. */ + for (;;) { int kind = linked_worktree_anchor(current); if (kind >= 0) { if (kind != 1) { @@ -248,7 +250,6 @@ static bool walk_linked_worktree_root(const char *path, char *root_out, size_t r return false; } } - return false; } bool cbm_git_is_linked_worktree(const char *path) { @@ -271,7 +272,14 @@ bool cbm_git_same_linked_worktree(const char *a, const char *b) { !read_gitlink_target(root_b, git_b, sizeof(git_b))) { return false; } - return strcmp(git_a, git_b) == 0; + /* A relative gitdir is joined onto the walked path. Canonicalize both so + * a symlink spelling of the cwd still matches the real project root. + * Fall back to the raw string when the gitdir does not exist. */ + char norm_a[GIT_OUTPUT_MAX]; + char norm_b[GIT_OUTPUT_MAX]; + const char *left = cbm_canonical_path(git_a, norm_a, sizeof(norm_a)) ? norm_a : git_a; + const char *right = cbm_canonical_path(git_b, norm_b, sizeof(norm_b)) ? norm_b : git_b; + return strcmp(left, right) == 0; } static char *join_root_relative(const char *root, const char *rel) { From 394faaacfbec823e3d138407bb21ad46869f9022 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:35:29 +0000 Subject: [PATCH 11/36] fix(cli): canonicalize cwd before the worktree gate A symlink into a linked worktree has no .git on its lexical path, so the hook told the agent to run index_repository. That call canonicalizes first and then refuses. Resolve the cwd before project lookup and the ignore_worktrees checks. Signed-off-by: Nohj --- src/cli/hook_augment.c | 8 ++++++++ tests/test_cli.c | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 10e7741a1..3de1691aa 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1486,6 +1486,14 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root owned_config = cbm_mcp_server_attach_runtime_config(srv); } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); + /* A symlink alias has no .git of its own, and walking its lexical parents + * leaves the linked worktree. index_repository canonicalizes before the + * same gate, so the hook must too or it tells the agent to index a path + * the setting will refuse. */ + char canonical_cwd[4096]; + if (cwd && ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) { + cwd = canonical_cwd; + } char project_root[4096]; project_root[0] = '\0'; char *project = srv && cwd ? ha_resolve_indexed_project_with_root(srv, cwd, project_root, diff --git a/tests/test_cli.c b/tests/test_cli.c index e234d77df..27883c5c8 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10644,6 +10644,22 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { !strstr(ignored_out, "is indexed"); free(ignored_out); + /* A symlink to a directory inside that worktree has no lexical .git. + * The hook must still follow the real path and honor ignore_worktrees. */ + char alias_target[600]; + snprintf(alias_target, sizeof(alias_target), "%s/nested", wtdir); + bool alias_target_ok = test_mkdirp(alias_target) == 0; + char alias[512]; + snprintf(alias, sizeof(alias), "%s/wt-alias", tmpdir); + bool alias_made = alias_target_ok && symlink(alias_target, alias) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + alias); + char *alias_out = alias_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool alias_ignored = alias_out && strstr(alias_out, "ignore_worktrees is enabled") && + strstr(alias_out, "do not run index_repository") && + !strstr(alias_out, "Run index_repository"); + free(alias_out); + /* A linked worktree nested inside the indexed checkout must not inherit * that parent graph once ignore_worktrees is on. */ char insider[512]; @@ -10716,6 +10732,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); if (!honored) FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); + if (!alias_ignored) + FAIL("a symlink into an ignored worktree must not recommend index_repository"); if (!inside_ignored) FAIL("a nested linked worktree must not inherit the indexed parent graph"); if (!exact_kept) From 3bab06134bdf1401e568884e1e624c464522635e Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 12:00:58 +0000 Subject: [PATCH 12/36] style(cli): break the worktree gate assignment for clang-format The ignore_linked initializer exceeded the formatter's assignment break. Lint treats that as an error, so the line now matches .clang-format. Signed-off-by: Nohj --- src/cli/hook_augment.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 3de1691aa..342a7bd61 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1502,8 +1502,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root /* ignore_worktrees refuses a linked worktree, including one nested inside an * indexed checkout or inside another linked worktree. Keep a match only when * its root belongs to this cwd's own linked worktree, not a parent graph. */ - bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && - cbm_git_is_linked_worktree(cwd); + bool ignore_linked = + srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); if (ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root)) { free(project); project = NULL; From b21c8ccb6ec37b53d026984278bd795db5f3abc1 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 12:19:57 +0000 Subject: [PATCH 13/36] fix(cli): drop a parent worktree graph without a new free The memory-core ratchet rejects another raw free() in the hook. Keep the parent name until the existing free(project) and skip it when the match is a different worktree. Signed-off-by: Nohj --- src/cli/hook_augment.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 342a7bd61..384ba8467 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1504,11 +1504,11 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root * its root belongs to this cwd's own linked worktree, not a parent graph. */ bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); - if (ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root)) { - free(project); - project = NULL; - } - bool worktree_ignored = ignore_linked && !project; + /* Drop a parent graph without a second free(): this file's raw free count + * is ratcheted, and the single free(project) below still releases it. */ + bool drop_ancestor = + ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root); + bool worktree_ignored = ignore_linked && (!project || drop_ancestor); if (owned_config) { cbm_mcp_server_set_config(srv, NULL); cbm_config_close(owned_config); @@ -1531,7 +1531,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root scope = "Compaction"; } const char *tier = ha_active_tier(root, event); - if (project) { + if (project && !drop_ancestor) { char safe_project[HA_METADATA_CAP]; ha_sanitize_metadata(project, safe_project, sizeof(safe_project)); snprintf(context, sizeof(context), From 9f204925dd93aecf8ca8010d65c3b808864b1d50 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:30:30 +0800 Subject: [PATCH 14/36] fix(ruby-lsp): build compact class QNs the way the extractor does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural extractor names a class by appending the declaration's raw name text to the enclosing class QN, so `class Admin::User` yields `.Admin::User` while `module Admin; class User` yields `.Admin.User`. The resolver normalised both to the dotted form, and because the pipeline joins resolved rows to defs by exact caller QN, every row from or to a compact-declared class was silently dropped — the most common Rails style. All three passes now enter a class through one shared frame helper that computes the extractor-shaped QN from the raw name and keeps the dotted path only for constant lookup. The class table dedupes on QN. Nesting is now one raw frame per declaration joined by '.', and the constant probe strips one frame at a time: inside `class A::B` only `A.B.x` and `x` are tried, never `A.x`, matching Ruby's lexical scoping (`class A::B` does not open A). Tests pin exact QN equality on both ends of the row for compact, nested-compact, and the outer-scope-not-opened case. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 201 +++++++++++++++++++++++------------- internal/cbm/lsp/ruby_lsp.h | 21 ++-- tests/test_ruby_lsp.c | 149 ++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 77 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 2ca944df2..56f74e8cb 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -38,11 +38,16 @@ * string, array, hash, integer, float, simple_symbol, regex. * * QN scheme (matches the structural extractor): Ruby HAS class node types - * (class/module), so defs are named `module_qn..` with - * parent_class = `module_qn.`. Constructor calls: the textual - * extractor rewrites `Widget.new` to callee "Widget" (extract_calls.c), so - * constructor rows emit the CLASS QN — the edge lands on the Class node, - * exactly like Python's lsp_constructor. + * (class/module), so defs are named `.` with + * parent_class = ``, where a class QN is the enclosing class QN + * (or the module QN at top level) joined with the declaration's RAW name + * text. A compact `class Admin::User` therefore yields `module_qn.Admin::User` + * while `module Admin; class User` yields `module_qn.Admin.User` — both are + * reproduced here byte-for-byte because the pipeline joins resolved rows to + * defs by exact caller name. The dotted form is used only for constant + * lookup. Constructor calls: the textual extractor rewrites `Widget.new` to + * callee "Widget" (extract_calls.c), so constructor rows emit the CLASS QN — + * the edge lands on the Class node, exactly like Python's lsp_constructor. * * Zero-edge guarantee: if a receiver's type is unknown/unindexed, NO edge * is emitted (false edges are worse than missing edges). Dynamic dispatch @@ -121,6 +126,76 @@ static const char *ruby_path_join(CBMArena *a, const char *left, const char *rig return cbm_arena_sprintf(a, "%s.%s", left, right); } +/* Dotted form of a raw constant spelling: every "::" becomes ".". Used to + * turn nesting frames (raw) into class-table paths (dotted). */ +static const char *ruby_dotted(CBMArena *a, const char *raw) { + if (!raw) + return NULL; + size_t n = strlen(raw); + char *out = (char *)cbm_arena_alloc(a, n + 1); + if (!out) + return raw; + size_t j = 0; + for (size_t i = 0; i < n; i++) { + if (raw[i] == ':' && i + 1 < n && raw[i + 1] == ':') { + out[j++] = '.'; + i++; + continue; + } + out[j++] = raw[i]; + } + out[j] = '\0'; + return out; +} + +/* Class-declaration frame shared by every pass, so PASS 1, PASS 1.5 and + * PASS 2 compute identical nesting + class QNs. `raw_name` is the name + * field's source text (the extractor's spelling, "Admin::User"); `qn` is + * the extractor-shaped class QN; `path` is the dotted constant-table path. */ +typedef struct { + const char *raw_name; + const char *path; + const char *qn; + const char *saved_nesting; + const char *saved_class; + bool saved_singleton; +} RubyClassFrame; + +/* Enter a class/module node: compute names, push nesting + enclosing class. + * Returns false (nothing pushed) when the name is not a constant path. */ +static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *fr) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return false; + memset(fr, 0, sizeof(*fr)); + fr->raw_name = ruby_node_text(ctx, name); + if (!fr->raw_name || !fr->raw_name[0]) + return false; + fr->saved_nesting = ctx->nesting; + fr->saved_class = ctx->enclosing_class_qn; + fr->saved_singleton = ctx->in_singleton_method; + + fr->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath); + if (fr->saved_class) + fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", fr->saved_class, fr->raw_name); + else if (ctx->module_qn) + fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, fr->raw_name); + else + fr->qn = fr->raw_name; + + ctx->nesting = ruby_path_join(ctx->arena, fr->saved_nesting, fr->raw_name); + ctx->enclosing_class_qn = fr->qn; + ctx->in_singleton_method = false; + return true; +} + +static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) { + ctx->nesting = fr->saved_nesting; + ctx->enclosing_class_qn = fr->saved_class; + ctx->in_singleton_method = fr->saved_singleton; +} + /* True when a QN belongs to the indexed project (module_qn's first segment * is its prefix). Stdlib seed QNs ("String.upcase", "ActiveRecord.Base") * never carry the project prefix, so edges are only ever emitted at @@ -139,9 +214,12 @@ static RubyClassInfo *ruby_add_class(RubyLSPContext *ctx, const char *path, cons bool is_module) { if (!ctx || !path || !qn) return NULL; - /* Reopened class/module: reuse the existing record. */ + /* Reopened class/module: reuse the existing record. Keyed on the + * extractor QN, so `module A; class B` and `class A::B` in one file each + * keep their own spelling (constant lookup returns whichever was + * declared first; a miss on the other fails closed at the join). */ for (int i = 0; i < ctx->class_count; i++) { - if (strcmp(ctx->classes[i].path, path) == 0) + if (strcmp(ctx->classes[i].qn, qn) == 0) return &ctx->classes[i]; } if (ctx->class_count >= ctx->class_cap) { @@ -266,37 +344,45 @@ static const char *ruby_superclass_qn(RubyLSPContext *ctx, const char *class_qn) /* ── constant resolution ────────────────────────────────────────── */ +/* Class-table probe by dotted path. */ +static const char *ruby_class_qn_by_path(RubyLSPContext *ctx, const char *path) { + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 0) + return ctx->classes[i].qn; + } + return NULL; +} + const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path) { if (!ctx || !path || !path[0]) return NULL; - /* Lexical nesting probe: for nesting "A.B" try "A.B.path", "A.path", - * then "path" (innermost first — Ruby's constant lookup order). */ + /* Lexical nesting probe, innermost frame first — Ruby's constant lookup + * order. The nesting string holds one RAW frame per declaration joined + * by '.', so stripping at the last '.' drops exactly one frame: inside + * `module M; class A::B` (nesting "M.A::B") the probes are "M.A.B.path", + * "M.path", "path" — never "M.A.path", because `class A::B` does not + * open A's scope. */ const char *nest = ctx->nesting ? ctx->nesting : ""; char prefix[512]; size_t nlen = strlen(nest); if (nlen < sizeof(prefix)) { memcpy(prefix, nest, nlen + 1); - while (1) { - if (prefix[0]) { - const char *cand = cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, path); - for (int i = 0; i < ctx->class_count; i++) { - if (strcmp(ctx->classes[i].path, cand) == 0) - return ctx->classes[i].qn; - } - char *last_dot = strrchr(prefix, '.'); - if (last_dot) - *last_dot = '\0'; - else - prefix[0] = '\0'; - continue; - } - break; + while (prefix[0]) { + const char *cand = + cbm_arena_sprintf(ctx->arena, "%s.%s", ruby_dotted(ctx->arena, prefix), path); + const char *qn = ruby_class_qn_by_path(ctx, cand); + if (qn) + return qn; + char *last_dot = strrchr(prefix, '.'); + if (last_dot) + *last_dot = '\0'; + else + prefix[0] = '\0'; } } - for (int i = 0; i < ctx->class_count; i++) { - if (strcmp(ctx->classes[i].path, path) == 0) - return ctx->classes[i].qn; - } + const char *qn = ruby_class_qn_by_path(ctx, path); + if (qn) + return qn; /* Stdlib types are keyed by their dotted bare path. */ const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, path); if (t) @@ -808,27 +894,15 @@ static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton /* Process a class/module node during PASS 2: update nesting + enclosing * class, then walk the body. */ static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { - TSNode name = ts_node_child_by_field_name(node, "name", 4); - const char *cpath = ruby_const_path(ctx, name); - if (!cpath) + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) return; - const char *saved_nesting = ctx->nesting; - const char *saved_class = ctx->enclosing_class_qn; - bool saved_singleton = ctx->in_singleton_method; - - ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); - const char *qn = ruby_resolve_constant(ctx, cpath); - ctx->enclosing_class_qn = qn; - ctx->in_singleton_method = false; - TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_resolve_calls_in_node(ctx, body); - ctx->nesting = saved_nesting; - ctx->enclosing_class_qn = saved_class; - ctx->in_singleton_method = saved_singleton; + ruby_class_leave(ctx, &fr); } static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node) { @@ -992,20 +1066,15 @@ static void ruby_collect_mixin_call(RubyLSPContext *ctx, TSNode call) { } static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { - TSNode name = ts_node_child_by_field_name(node, "name", 4); - const char *cpath = ruby_const_path(ctx, name); - if (!cpath) + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) return; - - const char *saved_nesting = ctx->nesting; - const char *full_path = ruby_path_join(ctx->arena, saved_nesting, cpath); - const char *qn = ctx->module_qn - ? cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, full_path) - : full_path; - RubyClassInfo *ci = ruby_add_class(ctx, full_path, qn, is_module); + RubyClassInfo *ci = ruby_add_class(ctx, fr.path, fr.qn, is_module); /* Superclass: `class C < Base` — the `superclass` field wraps the - * expression after `<`. */ + * expression after `<`. The ref is resolved from the DECLARING scope + * (the frame just pushed does not apply to its own superclass), so pack + * it with the outer nesting. */ if (ci && !is_module && !ci->superclass_ref) { TSNode sup = ts_node_child_by_field_name(node, "superclass", 10); if (!ts_node_is_null(sup)) { @@ -1013,24 +1082,20 @@ static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { for (uint32_t i = 0; i < sc; i++) { const char *spath = ruby_const_path(ctx, ts_node_named_child(sup, i)); if (spath) { + const char *inner = ctx->nesting; + ctx->nesting = fr.saved_nesting; ci->superclass_ref = ruby_pack_ref(ctx, spath); + ctx->nesting = inner; break; } } } } - ctx->nesting = full_path; - const char *saved_class = ctx->enclosing_class_qn; - bool saved_singleton = ctx->in_singleton_method; - ctx->enclosing_class_qn = qn; - ctx->in_singleton_method = false; TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_pass1_scan(ctx, body); - ctx->nesting = saved_nesting; - ctx->enclosing_class_qn = saved_class; - ctx->in_singleton_method = saved_singleton; + ruby_class_leave(ctx, &fr); } static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node) { @@ -1145,19 +1210,13 @@ static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { const char *k = ts_node_type(node); if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { - TSNode name = ts_node_child_by_field_name(node, "name", 4); - const char *cpath = ruby_const_path(ctx, name); - if (!cpath) + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) return; - const char *saved_nesting = ctx->nesting; - const char *saved_class = ctx->enclosing_class_qn; - ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); - ctx->enclosing_class_qn = ruby_resolve_constant(ctx, cpath); TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_ivar_scan(ctx, body); - ctx->nesting = saved_nesting; - ctx->enclosing_class_qn = saved_class; + ruby_class_leave(ctx, &fr); return; } diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index f6de32943..e52d148c5 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -17,10 +17,16 @@ typedef enum { } RubyMixinKind; /* One class/module discovered in the file (or, cross-file, in the project). - * `path` is the dotted source-level constant path (e.g. "Admin.User" for - * `Admin::User`); `qn` is the full graph QN (module_qn.path for file-local - * classes; the extractor QN for cross-file defs). `superclass_qn` is resolved - * against the class table + stdlib after collection; NULL when unknown. */ + * `path` is the dotted constant path used ONLY for constant lookup ("Admin.User" + * for both `module Admin; class User` and `class Admin::User`). `qn` is the + * graph QN built exactly the way the structural extractor builds it: each + * declaration's raw name text appended to the enclosing class QN (or the + * module QN at top level), so a compact `class Admin::User` yields + * ".Admin::User" while the lexically nested form yields + * ".Admin.User". Emitted rows must carry the extractor spelling + * or the pipeline's exact-name join silently drops them. `superclass_qn` is + * resolved against the class table + stdlib after collection; NULL when + * unknown. */ typedef struct { const char *path; const char *qn; @@ -71,8 +77,11 @@ typedef struct { CBMTypeRegistry *build_reg; CBMScope *current_scope; - /* Lexical nesting: dotted constant path of the enclosing class/module - * chain ("" at top level, "A.B" inside `module A; class B`). */ + /* Lexical nesting: one frame per enclosing class/module declaration, + * frames joined by '.', each frame keeping its raw source spelling ("" at + * top level, "M.A::B" inside `module M; class A::B`). Ruby's constant + * lookup walks the frames outward — inside `class A::B` the nesting is + * [A::B] only, so A's constants are NOT probed. */ const char *nesting; /* Class/module table (pass 1). */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 23c37314e..a2b455f31 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -88,6 +88,41 @@ static const CBMResolvedCall *find_resolved_with_strategy(const CBMFileResult *r return NULL; } +/* Exact-name match on BOTH ends. The pipeline joins a resolved row to its + * caller def by exact QN equality, so a row whose caller is spelled even + * slightly differently from the extractor's def is silently dropped — + * substring matching would hide exactly that failure. */ +static int find_resolved_exact(const CBMFileResult *r, const char *caller_qn, + const char *callee_qn) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 && + strcmp(rc->callee_qn, callee_qn) == 0) + return i; + } + return -1; +} + +static void dump_resolved(const CBMFileResult *r) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)", + rc->callee_qn ? rc->callee_qn : "(null)", rc->strategy ? rc->strategy : "(null)", + rc->confidence); + } +} + +static int require_resolved_exact(const CBMFileResult *r, const char *caller_qn, + const char *callee_qn) { + int idx = find_resolved_exact(r, caller_qn, callee_qn); + if (idx < 0) { + printf(" MISSING exact resolved call: %s -> %s (have %d)\n", caller_qn, callee_qn, + r->resolved_calls.count); + dump_resolved(r); + } + return idx; +} + static const CBMDefinition *find_def(const CBMFileResult *r, const char *label, const char *name) { for (int i = 0; i < r->defs.count; i++) { const CBMDefinition *d = &r->defs.items[i]; @@ -487,6 +522,117 @@ TEST(rubylsp_nested_modules) { PASS(); } +/* ── 12b. Compact `class A::B` names match the extractor byte-for-byte ── */ + +/* The structural extractor names a compact declaration with its RAW name text + * (`Admin::User`), and the pipeline joins a resolved row to its caller def by + * exact QN equality. A resolver that normalised the name to `Admin.User` would + * have every row from or to such a class dropped at the join — silently, since + * substring assertions would still pass. Pin the exact spelling on both ends, + * and pin that the extractor's def really is spelled that way. */ +TEST(rubylsp_compact_class_name_exact) { + const char *src = "class Admin::User\n" + " def initialize\n" + " end\n" + " def name\n" + " 'n'\n" + " end\n" + " def shout\n" + " self.name\n" + " end\n" + "end\n" + "class Outside\n" + " def probe\n" + " u = Admin::User.new\n" + " u.name\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* The extractor's contract this resolver must reproduce. */ + const CBMDefinition *m = find_def(r, "Method", "name"); + ASSERT(m); + ASSERT(strcmp(m->qualified_name, "test.main.Admin::User.name") == 0); + ASSERT(m->parent_class && strcmp(m->parent_class, "test.main.Admin::User") == 0); + /* Caller inside the compact class, callee inside it. */ + ASSERT(require_resolved_exact(r, "test.main.Admin::User.shout", "test.main.Admin::User.name") >= + 0); + /* Caller outside: constructor row lands on the class QN, typed call on + * the method QN — both in the extractor's spelling. */ + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin::User") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin::User.name") >= 0); + /* And the dotted spelling must NOT appear anywhere. */ + ASSERT(find_resolved(r, "Outside.probe", "Admin.User") < 0); + cbm_free_result(r); + PASS(); +} + +/* Compact declaration nested inside a lexical module: the extractor joins the + * enclosing class QN with the raw name, giving `Admin.Foo::Bar`. */ +TEST(rubylsp_compact_class_inside_module_exact) { + const char *src = "module Admin\n" + " class Foo::Bar\n" + " def m\n" + " 1\n" + " end\n" + " end\n" + " class Panel\n" + " def show\n" + " Foo::Bar.new.m\n" + " end\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMDefinition *m = find_def(r, "Method", "m"); + ASSERT(m); + ASSERT(strcmp(m->qualified_name, "test.main.Admin.Foo::Bar.m") == 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.Foo::Bar") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.Foo::Bar.m") >= + 0); + cbm_free_result(r); + PASS(); +} + +/* Inside `class Admin::User`, Ruby's lexical nesting is [Admin::User] only — + * `Admin` is not opened, so a bare `Helper` does NOT see Admin::Helper. A + * resolver that probed the dotted prefixes (Admin.User, Admin, "") would + * invent that constant and emit a wrong constructor edge. The explicit + * `Admin::Helper` still resolves. */ +TEST(rubylsp_compact_class_does_not_open_outer_scope) { + const char *src = "module Admin\n" + " class Helper\n" + " def initialize\n" + " end\n" + " def assist\n" + " 1\n" + " end\n" + " end\n" + "end\n" + "class Admin::User\n" + " def bare\n" + " h = Helper.new\n" + " h.assist\n" + " end\n" + " def explicit\n" + " h = Admin::Helper.new\n" + " h.assist\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Bare `Helper` inside the compact class: no edge at all. */ + ASSERT(find_resolved(r, "Admin::User.bare", "Helper") < 0); + /* Fully qualified reference resolves, in exact extractor spelling. */ + ASSERT(require_resolved_exact(r, "test.main.Admin::User.explicit", "test.main.Admin.Helper") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Admin::User.explicit", + "test.main.Admin.Helper.assist") >= 0); + cbm_free_result(r); + PASS(); +} + /* ── 13. ActiveRecord model typing ──────────────────────────────── */ TEST(rubylsp_activerecord_model_typing) { @@ -628,6 +774,9 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_ivar_conflict_no_edge); RUN_TEST(rubylsp_chained_constructor_call); RUN_TEST(rubylsp_nested_modules); + RUN_TEST(rubylsp_compact_class_name_exact); + RUN_TEST(rubylsp_compact_class_inside_module_exact); + RUN_TEST(rubylsp_compact_class_does_not_open_outer_scope); RUN_TEST(rubylsp_activerecord_model_typing); RUN_TEST(rubylsp_top_level_function); RUN_TEST(rubylsp_unknown_receiver_no_edge); From c2d8644777066a0670c58cc0d579e5a3b334b42b Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:38:59 +0800 Subject: [PATCH 15/36] fix(ruby-lsp): make def a scope gate and fail closed on untyped reassignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `def` body inherited the enclosing scope, so a typed local from the surrounding body (or a preceding sibling method) typed a same-named parameter or local inside it. Parameters and block parameters were never bound, so they could not shadow an outer local either. And an assignment whose right-hand side had no proven type was ignored, so `a = Foo.new; a = x; a.bar` still resolved to Foo#bar. Ruby scoping is now modeled explicitly: `def`, `class` and `module` bodies start from a fresh, unparented scope; blocks and lambdas get a chained child scope; method, block and lambda parameters (including destructured groups and block-local names) bind to UNKNOWN; and any write the resolver cannot type — plain reassignment, multiple assignment, `||=` on an already-typed name, other compound operators, or an assignment inside a block — rebinds the nearest existing binding to UNKNOWN. `a ||= Foo.new` on a fresh name still types as Foo. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 161 ++++++++++++++++++++++++++++++++++-- tests/test_ruby_lsp.c | 159 +++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 6 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 56f74e8cb..06b5b5e2f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -844,6 +844,87 @@ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { /* No known parent or unresolved — zero-edge guarantee. */ } +/* ── local scope (Ruby scope gates) ─────────────────────────────── */ + +/* Ruby scoping, as modeled here: + * - `def`, `class` and `module` bodies are SCOPE GATES: no outer local is + * visible inside, so each gets a fresh, unparented scope. + * - Blocks (`{ }`, `do end`, `-> { }`) close over the enclosing locals, so + * they get a CHAINED child scope. Block parameters shadow outer names + * for the block's extent only. + * - Assigning to a name that is already bound in an enclosing frame + * rebinds THAT frame (closure assignment), otherwise binds locally. + * - Parameters and any reassignment whose right-hand side has no proven + * type bind the name to UNKNOWN. A stale type must never survive a + * write the resolver could not read (zero-edge guarantee). */ + +/* Assign `type` to `name`: rebind the nearest existing binding in place, or + * bind in the current frame when the name is new. */ +static void ruby_scope_assign(RubyLSPContext *ctx, const char *name, const CBMType *type) { + if (!ctx->current_scope || !name || !name[0]) + return; + for (CBMScope *s = ctx->current_scope; s != NULL; s = s->parent) { + for (CBMScopeChunk *c = s->chunks; c != NULL; c = c->next) { + for (int i = 0; i < c->used; i++) { + if (c->bindings[i].name && strcmp(c->bindings[i].name, name) == 0) { + c->bindings[i].type = type; + c->bindings[i].callable_qn = NULL; + return; + } + } + } + } + cbm_scope_bind(ctx->current_scope, name, type); +} + +/* Bind every name introduced by a parameter list (method, block or lambda + * parameters, including destructured groups and block-local `; x` names) to + * UNKNOWN in the current frame. Parameters arrive untyped, and a parameter + * that shares its name with an outer local must shadow it. */ +static void ruby_bind_params_unknown(RubyLSPContext *ctx, TSNode params) { + if (ts_node_is_null(params)) + return; + const char *k = ts_node_type(params); + if (strcmp(k, "identifier") == 0) { + char *nm = ruby_node_text(ctx, params); + if (nm && nm[0]) + cbm_scope_bind(ctx->current_scope, nm, cbm_type_unknown()); + return; + } + if (strcmp(k, "optional_parameter") == 0 || strcmp(k, "keyword_parameter") == 0 || + strcmp(k, "splat_parameter") == 0 || strcmp(k, "hash_splat_parameter") == 0 || + strcmp(k, "block_parameter") == 0) { + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(params, "name", 4)); + return; + } + if (strcmp(k, "method_parameters") == 0 || strcmp(k, "block_parameters") == 0 || + strcmp(k, "lambda_parameters") == 0 || strcmp(k, "destructured_parameter") == 0) { + uint32_t nc = ts_node_named_child_count(params); + for (uint32_t i = 0; i < nc; i++) + ruby_bind_params_unknown(ctx, ts_node_named_child(params, i)); + } +} + +/* Rebind every local named on the left of a multiple assignment + * (`a, b = ...`, `a, (b, c) = ...`, `a, *rest = ...`) to UNKNOWN. */ +static void ruby_unbind_assignment_list(RubyLSPContext *ctx, TSNode lhs) { + if (ts_node_is_null(lhs)) + return; + const char *k = ts_node_type(lhs); + if (strcmp(k, "identifier") == 0) { + char *nm = ruby_node_text(ctx, lhs); + if (nm && nm[0]) + ruby_scope_assign(ctx, nm, cbm_type_unknown()); + return; + } + if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 || + strcmp(k, "rest_assignment") == 0) { + uint32_t nc = ts_node_named_child_count(lhs); + for (uint32_t i = 0; i < nc; i++) + ruby_unbind_assignment_list(ctx, ts_node_named_child(lhs, i)); + } +} + /* ── assignment observer (scope binding) ────────────────────────── */ static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) { @@ -851,26 +932,66 @@ static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) { TSNode right = ts_node_child_by_field_name(assign, "right", 5); if (ts_node_is_null(left) || ts_node_is_null(right)) return; - if (strcmp(ts_node_type(left), "identifier") != 0) + const char *lk = ts_node_type(left); + if (strcmp(lk, "left_assignment_list") == 0) { + ruby_unbind_assignment_list(ctx, left); /* no per-element typing */ + return; + } + if (strcmp(lk, "identifier") != 0) return; /* only simple local targets are tracked */ char *vname = ruby_node_text(ctx, left); if (!vname || !vname[0]) return; const CBMType *rt = ruby_eval_expr_type(ctx, right); - if (rt && rt->kind == CBM_TYPE_NAMED) - cbm_scope_bind(ctx->current_scope, vname, rt); + /* An untyped right-hand side REBINDS to unknown — `a = Foo.new; a = x` + * must not leave `a` typed Foo. */ + ruby_scope_assign(ctx, vname, (rt && rt->kind == CBM_TYPE_NAMED) ? rt : cbm_type_unknown()); +} + +/* `a ||= expr` and the other compound assignments. `||=` on a name with no + * binding yet takes the right-hand type; on a name already bound to the same + * type it keeps it; every other case (untyped right-hand side, different + * type, or any non-`||=` operator) fails closed to UNKNOWN. */ +static void ruby_process_operator_assignment(RubyLSPContext *ctx, TSNode assign) { + TSNode left = ts_node_child_by_field_name(assign, "left", 4); + TSNode op = ts_node_child_by_field_name(assign, "operator", 8); + TSNode right = ts_node_child_by_field_name(assign, "right", 5); + if (ts_node_is_null(left) || ts_node_is_null(right)) + return; + if (strcmp(ts_node_type(left), "identifier") != 0) + return; + char *vname = ruby_node_text(ctx, left); + if (!vname || !vname[0]) + return; + char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op); + const CBMType *result = cbm_type_unknown(); + if (optxt && strcmp(optxt, "||=") == 0) { + const CBMType *rt = ruby_eval_expr_type(ctx, right); + if (rt && rt->kind == CBM_TYPE_NAMED) { + const CBMVarBinding *cur = cbm_scope_lookup_binding(ctx->current_scope, vname); + if (!cur) + result = rt; /* nil || Foo.new → Foo */ + else if (cur->type && cur->type->kind == CBM_TYPE_NAMED && + strcmp(cur->type->data.named.qualified_name, rt->data.named.qualified_name) == + 0) + result = rt; + } + } + ruby_scope_assign(ctx, vname, result); } /* ── PASS 2: resolution walk ────────────────────────────────────── */ -/* Process one method/singleton_method definition node. */ +/* Process one method/singleton_method definition node. `def` is a scope + * gate: the body starts from a FRESH scope holding only its parameters. */ static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton) { CBMScope *saved_scope = ctx->current_scope; const char *saved_func = ctx->enclosing_func_qn; bool saved_singleton = ctx->in_singleton_method; - ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope); + ctx->current_scope = cbm_scope_push(ctx->arena, NULL); ctx->in_singleton_method = singleton; + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10)); TSNode name = ts_node_child_by_field_name(node, "name", 4); char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); @@ -891,17 +1012,39 @@ static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton ctx->in_singleton_method = saved_singleton; } +/* Process a block / do-block / lambda: a CHAINED scope (closures see the + * enclosing locals) in which the block parameters shadow as UNKNOWN. */ +static void ruby_process_block(RubyLSPContext *ctx, TSNode node) { + CBMScope *saved_scope = ctx->current_scope; + ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope); + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10)); + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (ts_node_is_null(c)) + continue; + /* Parameters were consumed above; walking them would be harmless + * (identifiers are not calls) but default values may hold calls. */ + ruby_resolve_calls_in_node(ctx, c); + } + ctx->current_scope = saved_scope; +} + /* Process a class/module node during PASS 2: update nesting + enclosing - * class, then walk the body. */ + * class, then walk the body from a fresh scope (class bodies are scope + * gates too). */ static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { RubyClassFrame fr; if (!ruby_class_enter(ctx, node, &fr)) return; + CBMScope *saved_scope = ctx->current_scope; + ctx->current_scope = cbm_scope_push(ctx->arena, NULL); TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_resolve_calls_in_node(ctx, body); + ctx->current_scope = saved_scope; ruby_class_leave(ctx, &fr); } @@ -940,9 +1083,15 @@ static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { ruby_process_method(ctx, node, true); return; } + if (strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0) { + ruby_process_block(ctx, node); + return; + } if (strcmp(k, "assignment") == 0) ruby_process_assignment(ctx, node); + else if (strcmp(k, "operator_assignment") == 0) + ruby_process_operator_assignment(ctx, node); if (strcmp(k, "call") == 0) { TSNode meth = ts_node_child_by_field_name(node, "method", 6); diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index a2b455f31..c4f17e71f 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -123,6 +123,19 @@ static int require_resolved_exact(const CBMFileResult *r, const char *caller_qn, return idx; } +/* Number of rows with exactly this caller and callee. */ +static int count_resolved_exact(const CBMFileResult *r, const char *caller_qn, + const char *callee_qn) { + int n = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 && + strcmp(rc->callee_qn, callee_qn) == 0) + n++; + } + return n; +} + static const CBMDefinition *find_def(const CBMFileResult *r, const char *label, const char *name) { for (int i = 0; i < r->defs.count; i++) { const CBMDefinition *d = &r->defs.items[i]; @@ -700,6 +713,149 @@ TEST(rubylsp_unknown_receiver_no_edge) { PASS(); } +/* ── 15b. `def` is a scope gate ─────────────────────────────────── */ + +/* Ruby locals do not cross a `def`: a typed local in the enclosing body (or + * a sibling method) is invisible inside, and a parameter of the same name is + * a brand-new, untyped binding. A resolver that chained the method scope to + * its parent would type `a` from the outside and emit a wrong edge. */ +TEST(rubylsp_def_is_scope_gate) { + const char *src = "class Foo\n" + " def bar\n" + " 1\n" + " end\n" + "end\n" + "a = Foo.new\n" + "def top\n" + " a.bar\n" + "end\n" + "class Worker\n" + " def run\n" + " a = Foo.new\n" + " a.bar\n" + " helper(a)\n" + " end\n" + " def helper(a)\n" + " a.bar\n" + " end\n" + " def later\n" + " a.bar\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Positive control: the typed local inside its own method resolves. */ + ASSERT(require_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") >= 0); + /* Top-level `a` is not visible inside `def top`. */ + ASSERT(find_resolved(r, "main.top", "Foo.bar") < 0); + /* Parameter `a` is untyped, whatever the caller passed. */ + ASSERT(find_resolved(r, "Worker.helper", "Foo.bar") < 0); + /* Sibling method's local is not visible either. */ + ASSERT(find_resolved(r, "Worker.later", "Foo.bar") < 0); + cbm_free_result(r); + PASS(); +} + +/* Block parameters shadow the enclosing local for the block's extent only: + * `|a|` inside the block is untyped, and `a` after the block is still Foo. */ +TEST(rubylsp_block_param_shadows_local) { + const char *src = "class Foo\n" + " def bar\n" + " 1\n" + " end\n" + "end\n" + "class Worker\n" + " def run\n" + " a = Foo.new\n" + " [1].each { |a| a.bar }\n" + " [1].each do |x, (y, z), *rest; loc|\n" + " x.bar\n" + " y.bar\n" + " rest.bar\n" + " loc.bar\n" + " end\n" + " f = ->(a) { a.bar }\n" + " a.bar\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Exactly ONE row: the `a.bar` after the blocks. Every `.bar` inside a + * block is on an untyped block parameter. */ + if (count_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") != 1) + dump_resolved(r); + ASSERT(count_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") == 1); + cbm_free_result(r); + PASS(); +} + +/* Reassigning a typed local to anything the resolver cannot type must drop + * the old type. `a = Foo.new; a = x; a.bar` is NOT a call on Foo. Covers + * plain reassignment, multiple assignment, `||=` and other compound forms. */ +TEST(rubylsp_untyped_reassignment_drops_type) { + const char *src = "class Foo\n" + " def bar\n" + " 1\n" + " end\n" + "end\n" + "class Worker\n" + " def plain(x)\n" + " a = Foo.new\n" + " a = x\n" + " a.bar\n" + " end\n" + " def multi(x)\n" + " a = Foo.new\n" + " a, b = x, 1\n" + " a.bar\n" + " end\n" + " def nested_multi(x)\n" + " a = Foo.new\n" + " (a, b), c = x, 1\n" + " a.bar\n" + " end\n" + " def splat_multi(x)\n" + " a = Foo.new\n" + " b, *a = x\n" + " a.bar\n" + " end\n" + " def or_assign(x)\n" + " a = Foo.new\n" + " a ||= x\n" + " a.bar\n" + " end\n" + " def compound(x)\n" + " a = Foo.new\n" + " a += x\n" + " a.bar\n" + " end\n" + " def in_block(x)\n" + " a = Foo.new\n" + " [1].each { a = x }\n" + " a.bar\n" + " end\n" + " def fresh_or_assign\n" + " a ||= Foo.new\n" + " a.bar\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Worker.plain", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.multi", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.nested_multi", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.splat_multi", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.or_assign", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.compound", "Foo.bar") < 0); + /* Assignment inside a block rebinds the enclosing local (closure). */ + ASSERT(find_resolved(r, "Worker.in_block", "Foo.bar") < 0); + /* Positive control: `||=` on a fresh name with a typed RHS is a + * constructor assignment. */ + ASSERT(require_resolved_exact(r, "test.main.Worker.fresh_or_assign", "test.main.Foo.bar") >= 0); + cbm_free_result(r); + PASS(); +} + /* ── 16. Negative: dynamic dispatch emits no edge ───────────────── */ TEST(rubylsp_send_no_edge) { @@ -780,6 +936,9 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_activerecord_model_typing); RUN_TEST(rubylsp_top_level_function); RUN_TEST(rubylsp_unknown_receiver_no_edge); + RUN_TEST(rubylsp_def_is_scope_gate); + RUN_TEST(rubylsp_block_param_shadows_local); + RUN_TEST(rubylsp_untyped_reassignment_drops_type); RUN_TEST(rubylsp_send_no_edge); RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); } From ffeb06f595cf5fe00dc41cd7e0ff6f2cbceba1e2 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:43:38 +0800 Subject: [PATCH 16/36] fix(ruby-lsp): latch ivar type on any non-constructor write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PASS 1.5 only recorded `@x = Const.new` and ignored every other write, so `@x = compute` (or a parameter, a compound operator, a multiple assignment, or an unresolved constant's .new) left the ivar typed by whichever constructor it saw elsewhere. Any such write now latches the conflict, exactly as two different constructors already did; `nil` is neutral. `@x ||= Const.new` — the memoised-constructor idiom — counts as a constructor assignment. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 122 +++++++++++++++++++++++++++++------- tests/test_ruby_lsp.c | 110 ++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 21 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 06b5b5e2f..6d6e7e6db 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -263,24 +263,21 @@ static void ruby_add_mixin(RubyLSPContext *ctx, const char *owner_qn, const char mi->kind = kind; } -static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char *ivar_name, - const char *type_qn) { - if (!ctx || !class_qn || !ivar_name || !type_qn) - return; +/* Find-or-create the (class, @ivar) record. `type_qn` may be NULL when the + * caller only wants to latch a conflict on a name never typed. */ +static RubyIvarInfo *ruby_ivar_slot(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { for (int i = 0; i < ctx->ivar_count; i++) { RubyIvarInfo *iv = &ctx->ivars[i]; - if (strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) { - if (strcmp(iv->type_qn, type_qn) != 0) - iv->conflicted = true; /* disagreeing assignments — no type */ - return; - } + if (strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) + return iv; } if (ctx->ivar_count >= ctx->ivar_cap) { int newcap = ctx->ivar_cap ? ctx->ivar_cap * 2 : 8; RubyIvarInfo *ni = (RubyIvarInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyIvarInfo)); if (!ni) - return; + return NULL; for (int i = 0; i < ctx->ivar_count; i++) ni[i] = ctx->ivars[i]; ctx->ivars = ni; @@ -290,7 +287,34 @@ static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char memset(iv, 0, sizeof(*iv)); iv->class_qn = cbm_arena_strdup(ctx->arena, class_qn); iv->ivar_name = cbm_arena_strdup(ctx->arena, ivar_name); - iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); + return iv; +} + +/* Record `@x = ` (a resolved constructor). A second, different + * type latches the conflict. */ +static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char *ivar_name, + const char *type_qn) { + if (!ctx || !class_qn || !ivar_name || !type_qn) + return; + RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name); + if (!iv) + return; + if (!iv->type_qn) + iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); + else if (strcmp(iv->type_qn, type_qn) != 0) + iv->conflicted = true; /* disagreeing assignments — no type */ +} + +/* Latch `@x` as untypable: some write to it was not a resolved constructor + * (or `nil`). Lookup then returns nothing regardless of other assignments — + * the zero-edge guarantee beats a coin-flip type. */ +static void ruby_ivar_mark_conflict(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + if (!ctx || !class_qn || !ivar_name) + return; + RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name); + if (iv) + iv->conflicted = true; } static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn, @@ -299,7 +323,7 @@ static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn, return NULL; for (int i = 0; i < ctx->ivar_count; i++) { RubyIvarInfo *iv = &ctx->ivars[i]; - if (!iv->conflicted && strcmp(iv->class_qn, class_qn) == 0 && + if (!iv->conflicted && iv->type_qn && strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) return iv->type_qn; } @@ -1345,6 +1369,44 @@ static const char *ruby_static_ctor_type(RubyLSPContext *ctx, TSNode expr) { return ruby_resolve_constant(ctx, cpath); } +/* Observe one write to `@x` (the `left` node) with right-hand side `right`. + * Only a RESOLVED `Const.new` types the ivar; `nil` is neutral (a common + * reset that says nothing about the type); every other right-hand side — + * an unresolved constant's `.new`, a method result, a parameter, a literal + * — latches the conflict, exactly like two different constructors do. */ +static void ruby_ivar_observe_write(RubyLSPContext *ctx, TSNode left, TSNode right) { + char *ivar = ruby_node_text(ctx, left); + if (!ivar || !ivar[0]) + return; + if (!ts_node_is_null(right) && strcmp(ts_node_type(right), "nil") == 0) + return; + const char *tqn = ruby_static_ctor_type(ctx, right); + if (tqn) + ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + else + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); +} + +/* Latch every instance variable named on the left of a multiple assignment + * (`@a, @b = ...`): per-element typing is not attempted. */ +static void ruby_ivar_conflict_assignment_list(RubyLSPContext *ctx, TSNode lhs) { + if (ts_node_is_null(lhs)) + return; + const char *k = ts_node_type(lhs); + if (strcmp(k, "instance_variable") == 0) { + char *ivar = ruby_node_text(ctx, lhs); + if (ivar && ivar[0]) + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); + return; + } + if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 || + strcmp(k, "rest_assignment") == 0) { + uint32_t nc = ts_node_named_child_count(lhs); + for (uint32_t i = 0; i < nc; i++) + ruby_ivar_conflict_assignment_list(ctx, ts_node_named_child(lhs, i)); + } +} + static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node) { if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) return; @@ -1369,15 +1431,33 @@ static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { return; } - if (strcmp(k, "assignment") == 0 && ctx->enclosing_class_qn) { - TSNode left = ts_node_child_by_field_name(node, "left", 4); - TSNode right = ts_node_child_by_field_name(node, "right", 5); - if (!ts_node_is_null(left) && !ts_node_is_null(right) && - strcmp(ts_node_type(left), "instance_variable") == 0) { - const char *tqn = ruby_static_ctor_type(ctx, right); - if (tqn) { - char *ivar = ruby_node_text(ctx, left); - ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + if (ctx->enclosing_class_qn) { + if (strcmp(k, "assignment") == 0) { + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left)) { + const char *lk = ts_node_type(left); + if (strcmp(lk, "instance_variable") == 0) + ruby_ivar_observe_write(ctx, left, right); + else if (strcmp(lk, "left_assignment_list") == 0) + ruby_ivar_conflict_assignment_list(ctx, left); + } + } else if (strcmp(k, "operator_assignment") == 0) { + /* `@x ||= Const.new` is the memoised-constructor idiom and counts + * as a constructor assignment; any other operator is an untyped + * write. */ + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode op = ts_node_child_by_field_name(node, "operator", 8); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left) && strcmp(ts_node_type(left), "instance_variable") == 0) { + char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op); + if (optxt && strcmp(optxt, "||=") == 0) { + ruby_ivar_observe_write(ctx, left, right); + } else { + char *ivar = ruby_node_text(ctx, left); + if (ivar && ivar[0]) + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); + } } } } diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index c4f17e71f..ba578d204 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -479,6 +479,114 @@ TEST(rubylsp_ivar_conflict_no_edge) { PASS(); } +/* Any write to @x that is not a resolved constructor (or nil) must latch the + * conflict, the same way two different constructors do — a method result, a + * parameter, an unresolved constant's `.new`, a compound operator, or a + * multiple assignment. Otherwise `@x = Foo.new` in one method silently types + * every other method's `@x` even after it was reassigned to who-knows-what. */ +TEST(rubylsp_ivar_non_constructor_write_latches) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class ViaMethod\n" + " def initialize\n" + " @e = Engine.new\n" + " end\n" + " def reset\n" + " @e = compute\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaParam\n" + " def initialize(e)\n" + " @e = Engine.new\n" + " @e = e\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaUnknownCtor\n" + " def initialize\n" + " @e = Engine.new\n" + " @e = Mystery.new\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaCompound\n" + " def initialize\n" + " @e = Engine.new\n" + " @e += 1\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaMulti\n" + " def initialize(a)\n" + " @e = Engine.new\n" + " @e, @f = a\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaOrUntyped\n" + " def initialize\n" + " @e = Engine.new\n" + " end\n" + " def lazy\n" + " @e ||= build\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "ViaMethod.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaParam.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaUnknownCtor.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaCompound.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaMulti.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaOrUntyped.go", "Engine.start") < 0); + cbm_free_result(r); + PASS(); +} + +/* `@x ||= Const.new` is the memoised-constructor idiom and counts as a + * constructor assignment; a `nil` reset says nothing about the type. */ +TEST(rubylsp_ivar_or_assign_constructor_types) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class Car\n" + " def engine\n" + " @engine ||= Engine.new\n" + " end\n" + " def clear\n" + " @engine = nil\n" + " end\n" + " def drive\n" + " @engine.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Car.drive", "test.main.Engine.start") >= 0); + ASSERT(find_resolved_with_strategy(r, "Car.drive", "Engine.start", "ruby_ivar_method")); + cbm_free_result(r); + PASS(); +} + /* ── 11. Chained calls ──────────────────────────────────────────── */ TEST(rubylsp_chained_constructor_call) { @@ -928,6 +1036,8 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_super_dispatch); RUN_TEST(rubylsp_ivar_typing); RUN_TEST(rubylsp_ivar_conflict_no_edge); + RUN_TEST(rubylsp_ivar_non_constructor_write_latches); + RUN_TEST(rubylsp_ivar_or_assign_constructor_types); RUN_TEST(rubylsp_chained_constructor_call); RUN_TEST(rubylsp_nested_modules); RUN_TEST(rubylsp_compact_class_name_exact); From 50f28fbae520cfead28ff6c331954ed414564c21 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:55:33 +0800 Subject: [PATCH 17/36] fix(ruby-lsp): suppress self dispatch inside blocks that rebind self instance_eval/instance_exec, class_eval/class_exec, module_eval/ module_exec, define_method and Class.new/Module.new run their block with a different `self`, but bare and `self.` calls inside them were dispatched on the enclosing class. The eval call itself was already typed unknown; the walk into its block was not. The block of such a call is now walked with self_unknown set: bare calls, `self.` calls and `super` emit nothing there, `self` has no type, and PASS 1.5 does not attribute ivar writes inside it to the enclosing class. Receiver and argument expressions of the same call still resolve, and the suppression ends with the block. The *_exec forms join the dynamic-dispatch list. The existing negative test passed only because Meta had no `hidden`; it now has one, and the calls are written with `()` because tree-sitter-ruby parses a lone identifier as a variable reference, which the extractor never emits as a call. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 78 ++++++++++++++++++++++++++++--- internal/cbm/lsp/ruby_lsp.h | 5 ++ tests/test_ruby_lsp.c | 92 ++++++++++++++++++++++++++++++++++++- 3 files changed, 166 insertions(+), 9 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 6d6e7e6db..f7f41e10f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -83,6 +83,7 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node); static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node); static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node); static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit); +static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call); /* ── helpers ────────────────────────────────────────────────────── */ @@ -159,6 +160,7 @@ typedef struct { const char *saved_nesting; const char *saved_class; bool saved_singleton; + bool saved_self_unknown; } RubyClassFrame; /* Enter a class/module node: compute names, push nesting + enclosing class. @@ -175,6 +177,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f fr->saved_nesting = ctx->nesting; fr->saved_class = ctx->enclosing_class_qn; fr->saved_singleton = ctx->in_singleton_method; + fr->saved_self_unknown = ctx->self_unknown; fr->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath); if (fr->saved_class) @@ -187,6 +190,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f ctx->nesting = ruby_path_join(ctx->arena, fr->saved_nesting, fr->raw_name); ctx->enclosing_class_qn = fr->qn; ctx->in_singleton_method = false; + ctx->self_unknown = false; /* a class body names its self again */ return true; } @@ -194,6 +198,7 @@ static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) { ctx->nesting = fr->saved_nesting; ctx->enclosing_class_qn = fr->saved_class; ctx->in_singleton_method = fr->saved_singleton; + ctx->self_unknown = fr->saved_self_unknown; } /* True when a QN belongs to the indexed project (module_qn's first segment @@ -630,7 +635,7 @@ const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { if (tqn) result = cbm_type_named(ctx->arena, tqn); } else if (strcmp(k, "self") == 0) { - if (ctx->enclosing_class_qn && !ctx->in_singleton_method) + if (ctx->enclosing_class_qn && !ctx->in_singleton_method && !ctx->self_unknown) result = cbm_type_named(ctx->arena, ctx->enclosing_class_qn); } else if (strcmp(k, "call") == 0) { result = ruby_eval_call_type(ctx, node, false); @@ -679,9 +684,40 @@ const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { static bool ruby_is_dynamic_dispatch(const char *m) { return strcmp(m, "send") == 0 || strcmp(m, "public_send") == 0 || strcmp(m, "__send__") == 0 || strcmp(m, "method_missing") == 0 || strcmp(m, "define_method") == 0 || - strcmp(m, "instance_eval") == 0 || strcmp(m, "class_eval") == 0 || - strcmp(m, "module_eval") == 0 || strcmp(m, "instance_variable_get") == 0 || - strcmp(m, "instance_variable_set") == 0; + strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 || + strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 || + strcmp(m, "instance_variable_get") == 0 || strcmp(m, "instance_variable_set") == 0; +} + +/* True when `call` carries a block whose body runs with a DIFFERENT `self` + * than the enclosing lexical scope: the eval/exec family, define_method + * (self is the future receiver), and `Class.new { }` / `Module.new { }` + * (self is the anonymous class). Bare and `self.` calls in such a block + * must not dispatch on the enclosing class. */ +static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call) { + if (ts_node_is_null(ts_node_child_by_field_name(call, "block", 5))) + return false; + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + if (ts_node_is_null(meth)) + return false; + char *m = ruby_node_text(ctx, meth); + if (!m) + return false; + if (strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 || + strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 || + strcmp(m, "define_method") == 0) + return true; + if (strcmp(m, "new") == 0) { + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "constant") == 0) { + char *r = ruby_node_text(ctx, recv); + if (r && (strcmp(r, "Class") == 0 || strcmp(r, "Module") == 0)) + return true; + } + } + return false; } /* Class-body macro-ish names PASS 1 already interpreted (or that never @@ -770,6 +806,8 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool /* Bare call: self-dispatch inside methods, then file-level funcs. */ if (ruby_is_body_macro(mname)) return cbm_type_unknown(); + if (ctx->self_unknown) + return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */ if (ctx->enclosing_class_qn) { const CBMRegisteredFunc *f = ctx->in_singleton_method @@ -799,7 +837,7 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool /* self.m — explicit self dispatch. */ if (strcmp(rk, "self") == 0) { - if (!ctx->enclosing_class_qn) + if (!ctx->enclosing_class_qn || ctx->self_unknown) return cbm_type_unknown(); const CBMRegisteredFunc *f = ctx->in_singleton_method @@ -845,7 +883,7 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool /* `super` / `super(...)`: resolve the enclosing method's name starting at * the enclosing class's ancestry (own def skipped — that IS the caller). */ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { - if (!ctx->enclosing_class_qn || !ctx->enclosing_func_qn) + if (!ctx->enclosing_class_qn || !ctx->enclosing_func_qn || ctx->self_unknown) return; const char *fq = ctx->enclosing_func_qn; const char *mname = strrchr(fq, '.'); @@ -1123,6 +1161,26 @@ static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { ruby_resolve_super(ctx, node); else ruby_eval_call_type(ctx, node, true); + if (ruby_call_rebinds_self(ctx, node)) { + /* Receiver and arguments are ordinary expressions; the block + * body runs with a `self` this resolver cannot name. */ + TSNode blk = ts_node_child_by_field_name(node, "block", 5); + uint32_t cc = ts_node_child_count(node); + for (uint32_t i = 0; i < cc; i++) { + TSNode c = ts_node_child(node, i); + if (ts_node_is_null(c)) + continue; + if (ts_node_eq(c, blk)) { + bool saved = ctx->self_unknown; + ctx->self_unknown = true; + ruby_resolve_calls_in_node(ctx, c); + ctx->self_unknown = saved; + } else { + ruby_resolve_calls_in_node(ctx, c); + } + } + return; + } /* Recurse into receiver/arguments/blocks for nested calls. */ } else if (strcmp(k, "super") == 0) { /* Bare `super` (no argument list) appears as a standalone node. */ @@ -1462,10 +1520,16 @@ static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { } } + /* A block that rebinds `self` writes ivars on some OTHER object; do not + * attribute them to the enclosing class. */ + TSNode skip_blk = {0}; + if (strcmp(k, "call") == 0 && ruby_call_rebinds_self(ctx, node)) + skip_blk = ts_node_child_by_field_name(node, "block", 5); + uint32_t nc = ts_node_child_count(node); for (uint32_t i = 0; i < nc; i++) { TSNode c = ts_node_child(node, i); - if (!ts_node_is_null(c)) + if (!ts_node_is_null(c) && !(!ts_node_is_null(skip_blk) && ts_node_eq(c, skip_blk))) ruby_ivar_scan(ctx, c); } } diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index e52d148c5..dc9271d5b 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -109,6 +109,11 @@ typedef struct { const char *enclosing_class_qn; /* class QN, or NULL at top level */ const char *enclosing_func_qn; /* enclosing method QN, or NULL */ bool in_singleton_method; /* `def self.m` / class << self scope */ + /* Inside a block that rebinds `self` (instance_eval/exec, class_eval/ + * exec, module_eval/exec, define_method, Class.new/Module.new): the + * receiver of bare and `self.` calls is NOT the enclosing class, and + * the resolver cannot know what it is — so they emit nothing. */ + bool self_unknown; const char *module_qn; /* Output: resolved calls accumulate here. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index ba578d204..a3d4aca6e 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -989,7 +989,14 @@ TEST(rubylsp_send_no_edge) { /* `send` has its own row above. The zero-edge guarantee is advertised for the * whole reflective family, so pin the rest of it too: a resolver that treated - * any of these as an ordinary call would invent an edge to `hidden`. */ + * any of these as an ordinary call would invent an edge to `hidden`. + * + * `Meta` deliberately defines its OWN `hidden`: inside `instance_eval { }` + * self is the Target, not the Meta, so a resolver that dispatched the bare + * `hidden()` on the enclosing class would emit Meta.via_instance_eval -> + * Meta.hidden — a wrong edge that an empty Meta would never reveal. The + * bare calls are written with `()` because tree-sitter-ruby parses a lone + * `hidden` as an identifier, which the extractor never emits as a call. */ TEST(rubylsp_dynamic_dispatch_family_no_edge) { const char *src = "class Target\n" " def hidden\n" @@ -997,6 +1004,9 @@ TEST(rubylsp_dynamic_dispatch_family_no_edge) { " end\n" "end\n" "class Meta\n" + " def hidden\n" + " 0\n" + " end\n" " def via_public_send\n" " Target.new.public_send(:hidden)\n" " end\n" @@ -1007,7 +1017,7 @@ TEST(rubylsp_dynamic_dispatch_family_no_edge) { " Target.define_method(:hidden) { 2 }\n" " end\n" " def via_instance_eval\n" - " Target.new.instance_eval { hidden }\n" + " Target.new.instance_eval { hidden() }\n" " end\n" "end\n"; CBMFileResult *r = extract_ruby(src); @@ -1020,6 +1030,83 @@ TEST(rubylsp_dynamic_dispatch_family_no_edge) { PASS(); } +/* ── 16c. Negative: blocks that rebind `self` ───────────────────── */ + +/* instance_eval/exec, class_eval/exec, module_eval/exec, define_method and + * Class.new/Module.new run their block with a different `self`. Bare calls, + * `self.` calls and `super` inside them must not dispatch on the enclosing + * class. Every enclosing class here HAS the method being called, so the old + * behaviour would emit a wrong edge in each row. Receiver and argument + * expressions of the same call are ordinary and still resolve. */ +TEST(rubylsp_self_rebinding_blocks_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def hidden\n" + " 0\n" + " end\n" + " def target\n" + " Target.new\n" + " end\n" + " def via_instance_exec\n" + " target().instance_exec(1) { |x| hidden(); self.hidden }\n" + " end\n" + " def via_class_eval\n" + " Target.class_eval do\n" + " hidden()\n" + " end\n" + " end\n" + " def via_class_exec\n" + " Target.class_exec { hidden() }\n" + " end\n" + " def via_module_eval\n" + " Target.module_eval { self.hidden }\n" + " end\n" + " def via_module_exec\n" + " Target.module_exec { hidden() }\n" + " end\n" + " def via_define_method_body\n" + " Target.define_method(:other) { hidden() }\n" + " end\n" + " def via_class_new\n" + " Class.new do\n" + " hidden()\n" + " def hidden\n" + " super\n" + " end\n" + " end\n" + " end\n" + " def via_module_new\n" + " Module.new { self.hidden }\n" + " end\n" + " def after_block\n" + " Target.new.instance_eval { hidden() }\n" + " self.hidden\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.via_instance_exec", "hidden") < 0); + /* ...but the receiver expression of that same call still resolves. */ + ASSERT(require_resolved_exact(r, "test.main.Meta.via_instance_exec", "test.main.Meta.target") >= + 0); + ASSERT(find_resolved(r, "Meta.via_class_eval", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_class_exec", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_module_eval", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_module_exec", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_define_method_body", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_class_new", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_module_new", "hidden") < 0); + /* The suppression ends with the block: the following statement is an + * ordinary self dispatch on Meta. */ + ASSERT(count_resolved_exact(r, "test.main.Meta.after_block", "test.main.Meta.hidden") == 1); + cbm_free_result(r); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1051,4 +1138,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_untyped_reassignment_drops_type); RUN_TEST(rubylsp_send_no_edge); RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); + RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); } From 60801bc53fd19959c7b14083f18ad66167c86566 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 10:05:01 +0800 Subject: [PATCH 18/36] fix(ruby-lsp): fail closed on unresolved ancestry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup walks skipped a mixin whose module did not resolve and carried on to the class's own methods and superclass chain, and a bare call whose class ancestry did not resolve fell through to a same-named top-level def. An unseen ancestor may define or shadow the method, so none of those answers is trustworthy. Every lookup now carries a `blocked` flag set the moment the walk meets an unresolved prepend, include, extend or superclass reference, in Ruby's lookup order: an unresolved prepend blocks even an own-method hit; an unresolved include (or superclass) stops the walk before the superclass chain; an unresolved extend blocks the singleton walk. When blocked, nothing is emitted — no Object typing fallback, no top-level fallthrough, no `super` edge. Hits found before the unseen ancestor are still returned (an own method ahead of an unresolved include; a resolved include listed after an unresolved one). Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 123 ++++++++++++++++++++++++++---------- tests/test_ruby_lsp.c | 121 +++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 34 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index f7f41e10f..1411c3649 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -437,23 +437,44 @@ static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, c return cbm_registry_lookup_method(ctx->registry, key, method_name); } +/* Fail-closed ancestry. Every lookup below carries a `blocked` flag that is + * set the moment the walk meets an ancestor it cannot see: a mixin whose + * module did not resolve, or a superclass reference that did not resolve. + * An unseen ancestor may define (or, for a prepend, shadow) the method, so + * from that point on no hit is trustworthy and callers must emit nothing — + * including NOT falling through to a same-named top-level def or to the + * Object typing fallback. Hits found BEFORE the unseen ancestor in Ruby's + * lookup order are still returned. */ + static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth); + int depth, bool *blocked); + +/* True when the class declares a superclass the finalize step could not + * resolve (stdlib-seeded conventions like ApplicationRecord DO resolve). */ +static bool ruby_superclass_unresolved(RubyLSPContext *ctx, const char *class_qn) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + return ci && ci->superclass_ref && !ci->superclass_qn; +} /* Search a mixin kind on `owner_qn` (most recent first — later mixins win), - * recursing into each module's own include chain. */ + * recursing into each module's own include chain. An UNRESOLVED mixin met + * before any hit blocks the walk. */ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const char *owner_qn, RubyMixinKind kind, const char *method_name, - int depth) { + int depth, bool *blocked) { for (int i = ctx->mixin_count - 1; i >= 0; i--) { RubyMixinInfo *mi = &ctx->mixins[i]; - if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0 || !mi->module_qn) + if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0) continue; + if (!mi->module_qn) { + *blocked = true; /* unseen module — it may define/shadow the method */ + return NULL; + } const CBMRegisteredFunc *f = - ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1); - if (f) + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, blocked); + if (f || *blocked) return f; } return NULL; @@ -462,30 +483,41 @@ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth) { + int depth, bool *blocked) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; - /* Prepended modules shadow the class's own methods. */ + /* Prepended modules shadow the class's own methods — an unresolved + * prepend therefore blocks even an own-method hit. */ const CBMRegisteredFunc *f = - ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth); - if (f) + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth, blocked); + if (f || *blocked) return f; f = ruby_own_instance_method(ctx, class_qn, method_name); if (f) return f; - f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth); - if (f) + /* An unresolved include stops the walk before the superclass chain. */ + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth, blocked); + if (f || *blocked) return f; + if (ruby_superclass_unresolved(ctx, class_qn)) { + *blocked = true; + return NULL; + } const char *sup = ruby_superclass_qn(ctx, class_qn); if (sup && strcmp(sup, class_qn) != 0) - return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1); + return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, blocked); return NULL; } -const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, - const char *method_name) { - const CBMRegisteredFunc *f = ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0); - if (f) +/* Instance lookup with the universal-receiver typing fallback. Skipped when + * the ancestry is blocked: nothing below the unseen ancestor is trustworthy. */ +static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + bool *blocked) { + const CBMRegisteredFunc *f = + ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, blocked); + if (f || *blocked) return f; /* Universal receiver fallback (Object / ActiveSupport predicates) — * typing only; Object methods are stdlib entries, never project defs. */ @@ -494,28 +526,39 @@ const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const return NULL; } +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + bool blocked = false; + return ruby_lookup_instance_method_b(ctx, class_qn, method_name, &blocked); +} + static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth) { + int depth, bool *blocked) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; const CBMRegisteredFunc *f = ruby_own_singleton_method(ctx, class_qn, method_name); if (f) return f; /* `extend Mod` adds Mod's instance methods class-side. */ - f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth); - if (f) + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth, blocked); + if (f || *blocked) return f; + if (ruby_superclass_unresolved(ctx, class_qn)) { + *blocked = true; + return NULL; + } const char *sup = ruby_superclass_qn(ctx, class_qn); if (sup && strcmp(sup, class_qn) != 0) - return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1); + return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1, blocked); return NULL; } const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, const char *method_name) { - return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0); + bool blocked = false; + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &blocked); } /* ── ActiveRecord model detection (typing special case) ─────────── */ @@ -809,16 +852,23 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (ctx->self_unknown) return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */ if (ctx->enclosing_class_qn) { + bool blocked = false; const CBMRegisteredFunc *f = ctx->in_singleton_method - ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) - : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, + &blocked) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, + &blocked); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, call); return ruby_func_return_type(f); } + /* An unseen ancestor may define this method: do NOT fall through + * to a same-named top-level def. */ + if (blocked) + return cbm_type_unknown(); } const CBMRegisteredFunc *f = cbm_registry_lookup_symbol(ctx->registry, ctx->module_qn, mname); @@ -839,10 +889,12 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (strcmp(rk, "self") == 0) { if (!ctx->enclosing_class_qn || ctx->self_unknown) return cbm_type_unknown(); - const CBMRegisteredFunc *f = - ctx->in_singleton_method - ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) - : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + bool blocked = false; + const CBMRegisteredFunc *f = ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth( + ctx, ctx->enclosing_class_qn, mname, 0, &blocked) + : ruby_lookup_instance_method_depth( + ctx, ctx->enclosing_class_qn, mname, 0, &blocked); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, @@ -889,19 +941,22 @@ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { const char *mname = strrchr(fq, '.'); mname = mname ? mname + 1 : fq; const CBMRegisteredFunc *f = NULL; + bool blocked = false; /* Search order mirrors Ruby: includes of the class, then superclass - * chain — the class's OWN def is the caller and must be skipped. */ + * chain — the class's OWN def is the caller and must be skipped. An + * unresolved mixin or superclass on the way blocks the walk. */ f = ruby_lookup_in_mixins(ctx, ctx->enclosing_class_qn, ctx->in_singleton_method ? RUBY_MIXIN_EXTEND : RUBY_MIXIN_INCLUDE, - mname, 0); - if (!f) { + mname, 0, &blocked); + if (!f && !blocked && !ruby_superclass_unresolved(ctx, ctx->enclosing_class_qn)) { const char *sup = ruby_superclass_qn(ctx, ctx->enclosing_class_qn); if (sup) { - f = ctx->in_singleton_method ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0) - : ruby_lookup_instance_method_depth(ctx, sup, mname, 0); + f = ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0, &blocked) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &blocked); } } - if (f) + if (f && !blocked) ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); /* No known parent or unresolved — zero-edge guarantee. */ } diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index a3d4aca6e..1ad45e314 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -379,6 +379,125 @@ TEST(rubylsp_prepend_mixin) { PASS(); } +/* ── 7c. Unresolved ancestry fails closed ───────────────────────── */ + +/* Three cases where an ancestor the resolver cannot see could define (or + * shadow) the method being called. Each one has a tempting local answer — + * the class's own method, a superclass method, a same-named top-level def — + * and each answer may be wrong, so none may be emitted. */ +TEST(rubylsp_unresolved_ancestry_fails_closed) { + const char *src = /* (a) unresolved PREPEND blocks an own-method hit: the + * prepended module may shadow `save`. */ + "class Record\n" + " prepend UnseenAudit\n" + " def save\n" + " 1\n" + " end\n" + " def persist\n" + " self.save\n" + " end\n" + "end\n" + "class RecordUser\n" + " def run\n" + " r = Record.new\n" + " r.save\n" + " end\n" + "end\n" + /* (b) unresolved INCLUDE stops the walk before the superclass chain: + * the included module may define `helper` ahead of Base. */ + "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " include UnseenConcern\n" + " def work\n" + " self.helper\n" + " end\n" + "end\n" + "class ChildUser\n" + " def run\n" + " c = Child.new\n" + " c.helper\n" + " end\n" + "end\n" + /* (c) a bare call in a class with an unresolved SUPERCLASS must not + * fall through to a same-named top-level def. */ + "def util\n" + " 1\n" + "end\n" + "class Orphan < UnseenBase\n" + " def go\n" + " util()\n" + " end\n" + " def again\n" + " super\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* (a) */ + ASSERT(find_resolved(r, "Record.persist", "save") < 0); + ASSERT(find_resolved(r, "RecordUser.run", "save") < 0); + /* (b) */ + ASSERT(find_resolved(r, "Child.work", "helper") < 0); + ASSERT(find_resolved(r, "ChildUser.run", "helper") < 0); + /* (c) */ + ASSERT(find_resolved(r, "Orphan.go", "util") < 0); + ASSERT(find_resolved(r, "Orphan.again", "again") < 0); + cbm_free_result(r); + PASS(); +} + +/* Positive controls for the rule above: resolved ancestry still reaches the + * superclass and still falls through to a top-level def, and a hit found + * BEFORE the unseen ancestor in Ruby's lookup order is still emitted. */ +TEST(rubylsp_resolved_ancestry_still_resolves) { + const char *src = "module Seen\n" + " def greet\n" + " 1\n" + " end\n" + "end\n" + "def util\n" + " 1\n" + "end\n" + "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " include Seen\n" + " def work\n" + " self.helper\n" + " util()\n" + " end\n" + "end\n" + "class Mixed < UnseenBase\n" + " include UnseenConcern\n" + " include Seen\n" + " def own\n" + " 2\n" + " end\n" + " def work\n" + " self.own\n" + " self.greet\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Child.work", "test.main.Base.helper") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Child.work", "test.main.util") >= 0); + /* Own method precedes includes and the superclass in lookup order. */ + ASSERT(require_resolved_exact(r, "test.main.Mixed.work", "test.main.Mixed.own") >= 0); + /* `include Seen` comes AFTER `include UnseenConcern`, so Seen is searched + * first and its hit is trustworthy. */ + ASSERT(require_resolved_exact(r, "test.main.Mixed.work", "test.main.Seen.greet") >= 0); + cbm_free_result(r); + PASS(); +} + /* ── 8. extend mixin (class-side) ───────────────────────────────── */ TEST(rubylsp_extend_mixin) { @@ -1119,6 +1238,8 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_inheritance); RUN_TEST(rubylsp_include_mixin); RUN_TEST(rubylsp_prepend_mixin); + RUN_TEST(rubylsp_unresolved_ancestry_fails_closed); + RUN_TEST(rubylsp_resolved_ancestry_still_resolves); RUN_TEST(rubylsp_extend_mixin); RUN_TEST(rubylsp_super_dispatch); RUN_TEST(rubylsp_ivar_typing); From 8bc99a40587b1d12604738417af4ba3b392788e7 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 10:12:59 +0800 Subject: [PATCH 19/36] fix(ruby-lsp): do not register block-nested defs as class members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PASS 1 recursed through blocks and registered any `def` it met on the enclosing class or module, so ActiveSupport's `class_methods do … end` and `included do … end` bodies became instance methods of the concern and were then found through `include Concern` — edges to defs the structural extractor does not even emit. `include X` written inside such a block was likewise recorded on the concern instead of the includer. PASS 1 now tracks the block depth since the nearest class/module body and, inside a block, registers no methods and collects no mixins. In PASS 2 a class-body block def is skipped outright (no def exists for it to join), and a top-level block def (an RSpec group) is walked with self unknown: typed-receiver calls in its body may still resolve, bare and self. calls emit nothing, and other top-level code cannot reach it. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 44 ++++++++++++++++---- internal/cbm/lsp/ruby_lsp.h | 6 +++ tests/test_ruby_lsp.c | 81 +++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 1411c3649..c0063212f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -161,6 +161,7 @@ typedef struct { const char *saved_class; bool saved_singleton; bool saved_self_unknown; + int saved_block_depth; } RubyClassFrame; /* Enter a class/module node: compute names, push nesting + enclosing class. @@ -178,6 +179,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f fr->saved_class = ctx->enclosing_class_qn; fr->saved_singleton = ctx->in_singleton_method; fr->saved_self_unknown = ctx->self_unknown; + fr->saved_block_depth = ctx->block_depth; fr->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath); if (fr->saved_class) @@ -191,6 +193,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f ctx->enclosing_class_qn = fr->qn; ctx->in_singleton_method = false; ctx->self_unknown = false; /* a class body names its self again */ + ctx->block_depth = 0; return true; } @@ -199,6 +202,7 @@ static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) { ctx->enclosing_class_qn = fr->saved_class; ctx->in_singleton_method = fr->saved_singleton; ctx->self_unknown = fr->saved_self_unknown; + ctx->block_depth = fr->saved_block_depth; } /* True when a QN belongs to the indexed project (module_qn's first segment @@ -1192,16 +1196,29 @@ static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { ctx->in_singleton_method = saved; return; } - if (strcmp(k, "method") == 0) { - ruby_process_method(ctx, node, ctx->in_singleton_method); - return; - } - if (strcmp(k, "singleton_method") == 0) { - ruby_process_method(ctx, node, true); + if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; + if (ctx->block_depth > 0) { + /* A def inside a block. Inside a class body the extractor emits + * no def for it, so no row could ever join: skip it. At top + * level the extractor emits a plain Function, so typed-receiver + * calls in its body may resolve — but its `self` is whatever + * the block's DSL made it, so bare and self. calls emit nothing. */ + if (ctx->enclosing_class_qn) + return; + bool saved = ctx->self_unknown; + ctx->self_unknown = true; + ruby_process_method(ctx, node, singleton); + ctx->self_unknown = saved; + return; + } + ruby_process_method(ctx, node, singleton); return; } if (strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0) { + ctx->block_depth++; ruby_process_block(ctx, node); + ctx->block_depth--; return; } @@ -1418,6 +1435,11 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { return; } if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + /* A def inside a block (`class_methods do … end`, `included do … + * end`, a describe group) is not a method of the enclosing + * class/module — and not a file-level function either. */ + if (ctx->block_depth > 0) + return; bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; TSNode name = ts_node_child_by_field_name(node, "name", 4); char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); @@ -1425,7 +1447,9 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { ruby_register_method(ctx, ctx->build_reg, ctx->enclosing_class_qn, mname, singleton); return; /* method bodies are PASS-2 territory */ } - if (strcmp(k, "call") == 0) { + if (strcmp(k, "call") == 0 && ctx->block_depth == 0) { + /* `include X` inside a block (`included do include X end`, + * `Foo.class_eval { include X }`) applies to some other module. */ TSNode meth = ts_node_child_by_field_name(node, "method", 6); if (!ts_node_is_null(meth)) { char *mn = ruby_node_text(ctx, meth); @@ -1437,12 +1461,18 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { } } + bool is_block = + strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0; + if (is_block) + ctx->block_depth++; uint32_t nc = ts_node_child_count(node); for (uint32_t i = 0; i < nc; i++) { TSNode c = ts_node_child(node, i); if (!ts_node_is_null(c)) ruby_pass1_scan(ctx, c); } + if (is_block) + ctx->block_depth--; } /* Finalize PASS 1: resolve superclass + mixin references now that the class diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index dc9271d5b..ad2d81de3 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -114,6 +114,12 @@ typedef struct { * receiver of bare and `self.` calls is NOT the enclosing class, and * the resolver cannot know what it is — so they emit nothing. */ bool self_unknown; + /* Number of enclosing blocks (`{ }`, `do end`, `-> { }`) since the + * nearest class/module body. A `def` met here is NOT a method of the + * enclosing class — ActiveSupport's `class_methods do … end`, + * `included do … end`, RSpec groups — so PASS 1 registers nothing for + * it and collects no mixins from it. */ + int block_depth; const char *module_qn; /* Output: resolved calls accumulate here. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 1ad45e314..63f764f34 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -498,6 +498,85 @@ TEST(rubylsp_resolved_ancestry_still_resolves) { PASS(); } +/* ── 7d. Methods defined inside blocks are not members of the module ── */ + +/* ActiveSupport's `class_methods do … end` / `included do … end` define + * methods on whatever class includes the concern (class-side, for + * class_methods), never instance methods of the concern module itself. A + * resolver that registered them on the module would then find them through + * `include Concern` and emit `Foo.new.build` -> Concern.build — an edge to a + * def the extractor does not even emit. Same for `include X` written INSIDE + * such a block: it applies to the includer, not the concern. */ +TEST(rubylsp_block_defs_not_module_members) { + const char *src = "module Other\n" + " def other_m\n" + " 1\n" + " end\n" + "end\n" + "module Concern\n" + " class_methods do\n" + " def build\n" + " 1\n" + " end\n" + " end\n" + " included do\n" + " include Other\n" + " def injected\n" + " 2\n" + " end\n" + " end\n" + " def real\n" + " 3\n" + " end\n" + "end\n" + "class Foo\n" + " include Concern\n" + " def initialize\n" + " end\n" + "end\n" + "class User\n" + " def run\n" + " f = Foo.new\n" + " f.build\n" + " f.injected\n" + " f.other_m\n" + " f.real\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "User.run", "build") < 0); + ASSERT(find_resolved(r, "User.run", "injected") < 0); + ASSERT(find_resolved(r, "User.run", "other_m") < 0); + /* Positive control: a def directly in the module body IS a member. */ + ASSERT(require_resolved_exact(r, "test.main.User.run", "test.main.Concern.real") >= 0); + cbm_free_result(r); + PASS(); +} + +/* A def inside a top-level block (an RSpec group) has whatever `self` the + * DSL gave it: bare calls in its body emit nothing, and it is not a + * file-level function other top-level code can reach. */ +TEST(rubylsp_top_level_block_defs_fail_closed) { + const char *src = "def util\n" + " 1\n" + "end\n" + "describe 'x' do\n" + " def helper\n" + " util()\n" + " end\n" + "end\n" + "def caller_fn\n" + " helper()\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "helper", "util") < 0); + ASSERT(find_resolved(r, "caller_fn", "helper") < 0); + cbm_free_result(r); + PASS(); +} + /* ── 8. extend mixin (class-side) ───────────────────────────────── */ TEST(rubylsp_extend_mixin) { @@ -1240,6 +1319,8 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_prepend_mixin); RUN_TEST(rubylsp_unresolved_ancestry_fails_closed); RUN_TEST(rubylsp_resolved_ancestry_still_resolves); + RUN_TEST(rubylsp_block_defs_not_module_members); + RUN_TEST(rubylsp_top_level_block_defs_fail_closed); RUN_TEST(rubylsp_extend_mixin); RUN_TEST(rubylsp_super_dispatch); RUN_TEST(rubylsp_ivar_typing); From 0f8b7194d81f6e3c4ba5c2da87141cbdebd9f87a Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 10:17:46 +0800 Subject: [PATCH 20/36] test(ruby-lsp): exact-name assertions for nested-class rows The nested-modules row matched caller and callee by substring, which would have let a wrongly-spelled class path pass; it now pins the full extractor QNs on both ends with strcmp, as the compact-class rows already do. Refresh the file headers to describe the scope gates, fail-closed ancestry and self-rebinding rules the resolver now follows. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 44 +++++++++++++++++++++++++++---------- tests/test_ruby_lsp.c | 24 +++++++++++++++----- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index c0063212f..ed97c78ec 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -12,27 +12,41 @@ * PASS 1 — collect class/module declarations (with lexical nesting), * superclasses, include/prepend/extend mixins, and method tables * (instance vs singleton classified from the AST — `def m` vs - * `def self.m` / `class << self`). + * `def self.m` / `class << self`). Defs and mixin calls inside a + * BLOCK (`class_methods do … end`, `included do … end`) belong to + * some other module and are not recorded. * PASS 1.5 — infer instance-variable types from `@x = Const.new` - * assignments inside method bodies (conflicting types latch to - * "conflicted" and resolve to nothing). + * (and `@x ||= Const.new`) inside method bodies. Any other write + * to the ivar latches it as untypable. * PASS 2 — walk method bodies, track local variable types through - * assignments, and resolve call expressions into CBMResolvedCall - * edges via Ruby method lookup (prepends → own → includes → - * superclass chain; extends feed the singleton side). + * assignments with Ruby's scope gates (fresh scope per def/class, + * chained scope per block, parameters unknown, untyped writes + * rebind to unknown), and resolve call expressions into + * CBMResolvedCall edges via Ruby method lookup (prepends → own → + * includes → superclass chain; extends feed the singleton side). + * The walk fails closed the moment an ancestor is unresolved. * * Verified tree-sitter-ruby node/field names (vendored compiled grammar at * internal/cbm/vendored/grammars/ruby/parser.c — ts_symbol_names and * ts_field_names tables): * - call : fields `receiver`, `method`, `arguments`, `block`. Both - * `foo(x)` and command calls (`foo x`) surface as "call" nodes. + * `foo(x)` and command calls (`foo x`) surface as "call" nodes. A lone + * `foo` is an `identifier`, never a call — and the extractor emits no + * call for it either, so the resolver does not treat it as one. * - class : fields `name` (constant | scope_resolution), `superclass` * (a `superclass` wrapper node), `body` (body_statement). * - module : fields `name`, `body`. * - method : fields `name`, `parameters`, `body`. * - singleton_method : fields `object` (self | constant), `name`, `body`. * - singleton_class : `class << self` — fields `value`, body children. - * - assignment : fields `left`, `right`. + * - assignment : fields `left`, `right` (`left` may be a + * left_assignment_list for `a, b = …`). + * - operator_assignment : fields `left`, `operator`, `right` (`||=` etc.). + * - block / do_block / lambda : field `parameters` (block_parameters / + * lambda_parameters), body. + * - method_parameters children: identifier | optional_parameter | + * keyword_parameter | splat_parameter | hash_splat_parameter | + * block_parameter (each with a `name` field) | destructured_parameter. * - scope_resolution : fields `scope` (may be absent for ::Foo), `name`. * - leaves: constant, identifier, self, instance_variable, super, * string, array, hash, integer, float, simple_symbol, regex. @@ -50,9 +64,17 @@ * the edge lands on the Class node, exactly like Python's lsp_constructor. * * Zero-edge guarantee: if a receiver's type is unknown/unindexed, NO edge - * is emitted (false edges are worse than missing edges). Dynamic dispatch - * (`send`, `public_send`, `method_missing`, `define_method`) is - * intentionally ignored. + * is emitted (false edges are worse than missing edges). Concretely: + * - dynamic dispatch (`send`, `public_send`, `__send__`, `method_missing`, + * `define_method`, the eval/exec family) is never guessed through; + * - a block that rebinds `self` (instance_eval/exec, class_eval/exec, + * module_eval/exec, define_method, Class.new/Module.new) suppresses bare, + * `self.` and `super` dispatch inside it; + * - an unresolved prepend blocks even an own-method hit, an unresolved + * include/extend/superclass stops the walk, and a blocked walk never + * falls through to a same-named top-level def or the Object fallback; + * - a local or ivar written with anything the resolver cannot type loses + * its type rather than keeping a stale one. */ #include "ruby_lsp.h" diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 63f764f34..d1999357f 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -16,10 +16,14 @@ * 10. Instance-variable typing (@thing = Foo.new; @thing.bar) * 11. Chained call typing (Foo.new.bar) * 12. Nested modules (lexical nesting + `A::B` references) + * 12b. Compact `class A::B` names (exact extractor spelling, scope) * 13. ActiveRecord model typing (User.find(1).full_name) * 14. Top-level function calls * 15. Unresolvable receiver emits NO spurious edge (negative) + * 15b. Scope gates (def, params, blocks, reassignment) * 16. send() / the whole dynamic-dispatch family emits NO edge (negative) + * 16c. Blocks that rebind self emit NO self edge (negative) + * Fail-closed ancestry (7c) and block-nested defs (7d) sit with mixins. * * Scope: every row here is SINGLE-FILE, driven through cbm_extract_file so it * exercises the per-file resolver exactly as the extraction pipeline calls it. @@ -27,9 +31,10 @@ * pass_lsp_cross.c) is a separate tier and is covered by its own rows. * * The resolver populates result->resolved_calls with CBMResolvedCall rows. - * Ruby defs weave the class path into the QN (module_qn.Animal.speak), so - * for these single-file fixtures ("test"/"main.rb" -> module QN test.main) - * callee fragments like "main.Animal.speak" are unique join keys. + * The pipeline joins a row to its caller def by EXACT QN equality, so every + * row that pins a class-path spelling (nested or compact) asserts the full + * caller and callee QN with strcmp ("test"/"main.rb" -> module QN test.main). + * Substring helpers remain for rows where only presence/absence matters. */ #include "test_framework.h" #include "cbm.h" @@ -832,11 +837,18 @@ TEST(rubylsp_nested_modules) { "end\n"; CBMFileResult *r = extract_ruby(src); ASSERT(r); + /* Exact spellings: lexically nested declarations join with '.', matching + * the extractor's `.` — a substring match would let + * a wrongly-spelled row pass. */ /* Lexical reference from sibling class inside the module. */ - ASSERT(require_resolved(r, "Panel.show", "User.lookup") >= 0); - ASSERT(require_resolved(r, "Panel.show", "User.name") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User.lookup") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User.name") >= + 0); /* Fully-qualified reference from outside. */ - ASSERT(require_resolved(r, "Outside.probe", "User.lookup") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin.User.lookup") >= + 0); cbm_free_result(r); PASS(); } From b2e143371115d43d3518ef962e296cda4dd4ddef Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 19:30:37 -0400 Subject: [PATCH 21/36] Fix portable stat declarations for worktree detection Signed-off-by: Nohj --- src/git/git_context.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/git/git_context.c b/src/git/git_context.c index 38802e5aa..640befd6c 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -10,6 +10,7 @@ #include #include #include +#include enum { GIT_CMD_MAX = 1024, From 470500eaae0b25c69772ba5dca1b01a7554c9637 Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 19:55:35 -0400 Subject: [PATCH 22/36] Restore runtime config attachment for hook augmentation Signed-off-by: Nohj --- src/mcp/mcp.c | 11 +++++++++++ tests/test_mcp.c | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index cccff1838..c45ec6de8 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1849,6 +1849,17 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } +struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv) { + if (!srv || srv->config) { + return NULL; + } + cbm_config_t *config = cbm_config_open(cbm_resolve_cache_dir()); + if (config) { + cbm_mcp_server_set_config(srv, config); + } + return config; +} + #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv, cbm_mcp_auto_index_count_test_hook_fn hook, diff --git a/tests/test_mcp.c b/tests/test_mcp.c index a03c1731a..8eab275a2 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1202,6 +1202,41 @@ TEST(mcp_initialize_response) { PASS(); } +TEST(mcp_attach_runtime_config_respects_caller_ownership) { + char *cache = th_mktempdir("cbm_mcp_runtime_config"); + ASSERT_NOT_NULL(cache); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_config_t *config = cbm_config_open(cache); + cbm_mcp_server_t *server = cbm_mcp_server_new(NULL); + bool stored = config && cbm_config_set(config, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + bool default_off = server && !cbm_mcp_ignore_worktrees_enabled(server); + cbm_config_t *attached = server ? cbm_mcp_server_attach_runtime_config(server) : NULL; + bool enabled = attached && cbm_mcp_ignore_worktrees_enabled(server); + bool not_replaced = server && cbm_mcp_server_attach_runtime_config(server) == NULL; + if (server) { + cbm_mcp_server_set_config(server, config); + } + cbm_config_close(attached); + bool caller_config_kept = server && cbm_mcp_server_attach_runtime_config(server) == NULL && + cbm_mcp_ignore_worktrees_enabled(server); + cbm_mcp_server_free(server); + cbm_config_close(config); + restore_cache_dir(saved_copy); + free(saved_copy); + int removed = th_rmtree(cache); + + ASSERT(stored); + ASSERT(default_off); + ASSERT(enabled); + ASSERT(not_replaced); + ASSERT(caller_config_kept); + ASSERT_EQ(removed, 0); + PASS(); +} + TEST(mcp_tools_list) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); @@ -21147,6 +21182,7 @@ SUITE(mcp) { /* MCP protocol helpers */ RUN_TEST(mcp_initialize_response); + RUN_TEST(mcp_attach_runtime_config_respects_caller_ownership); RUN_TEST(mcp_tools_list); RUN_TEST(mcp_tools_help_list_matches_registry); RUN_TEST(mcp_tools_list_latest_metadata); From adc2764a3eff3791cc5692ec7e997887a1f6df3f Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 20:32:09 -0400 Subject: [PATCH 23/36] Fix C++ sanitizer runtime linkage in fuzz build Signed-off-by: Nohj --- Makefile.cbm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile.cbm b/Makefile.cbm index ab5c89db7..b2a655505 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1381,10 +1381,10 @@ fuzz: CC=$(FUZZ_CC) CXX=$(FUZZ_CXX) SANITIZE="$(FUZZ_SANITIZE)" $(BUILD_DIR)/cbm-fuzz: $(FUZZ_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) - $(CC) $(CFLAGS_TEST) -fsanitize=fuzzer -o $@ \ + $(CXX) $(CFLAGS_TEST) -x c -fsanitize=fuzzer -o $@ \ $(FUZZ_SRC) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ - $(OBJS_VENDORED_TEST) \ + -x none $(OBJS_VENDORED_TEST) \ $(LDFLAGS_TEST) # Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads. From 9588007b16a07c3a85980ceb980e21cd8102c17a Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 21:45:52 -0400 Subject: [PATCH 24/36] Free partial Cypher patterns after parse errors Signed-off-by: Nohj --- src/cypher/cypher.c | 27 ++++++++++++++++++--------- tests/test_cypher.c | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 7c7a2f987..42ecb93c5 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -1862,6 +1862,8 @@ static int parse_return(parser_t *p, cbm_return_clause_t **out) { return parse_return_or_with(p, out, false); } +static void free_pattern(cbm_pattern_t *pat); + /* Parse a single MATCH pattern into pat */ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { memset(pat, 0, sizeof(*pat)); @@ -1870,31 +1872,38 @@ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { pat->nodes = malloc(node_cap * sizeof(cbm_node_pattern_t)); pat->rels = calloc(rel_cap, sizeof(cbm_rel_pattern_t)); - if (parse_node(p, &pat->nodes[0]) < 0) { - return CBM_NOT_FOUND; - } + /* Count the slot before parsing so even partially filled nodes and + * relationships are released when a malformed pattern is rejected. */ pat->node_count = SKIP_ONE; + if (parse_node(p, &pat->nodes[0]) < 0) { + goto fail; + } while (check(p, TOK_DASH) || check(p, TOK_LT)) { if (pat->rel_count >= rel_cap) { rel_cap *= PAIR_LEN; pat->rels = safe_realloc(pat->rels, rel_cap * sizeof(cbm_rel_pattern_t)); } - if (parse_rel(p, &pat->rels[pat->rel_count]) < 0) { - return CBM_NOT_FOUND; - } pat->rel_count++; + if (parse_rel(p, &pat->rels[pat->rel_count - SKIP_ONE]) < 0) { + goto fail; + } if (pat->node_count >= node_cap) { node_cap *= PAIR_LEN; pat->nodes = safe_realloc(pat->nodes, node_cap * sizeof(cbm_node_pattern_t)); } - if (parse_node(p, &pat->nodes[pat->node_count]) < 0) { - return CBM_NOT_FOUND; - } pat->node_count++; + if (parse_node(p, &pat->nodes[pat->node_count - SKIP_ONE]) < 0) { + goto fail; + } } return 0; + +fail: + free_pattern(pat); + memset(pat, 0, sizeof(*pat)); + return CBM_NOT_FOUND; } /* Parse UNWIND [...] AS var clause into query */ diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 7c251bd9b..5d198a0ef 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -274,6 +274,24 @@ TEST(cypher_parse_accepts_single_with_clause) { PASS(); } +TEST(cypher_parse_rejects_partial_patterns) { + const char *queries[] = { + "MATCH p = (a:Class)-[:INHERITS*1..3]->(b:Class) RETURN a.name, length(p)", + "MATCH (a)-[:CALLS]->(b) MATCH (c)-[:CALLS*]-> RETURN a.name", + "MATCH (a)-[:CALLS|]->(b)", + }; + for (size_t i = 0; i < sizeof(queries) / sizeof(queries[0]); i++) { + cbm_query_t *q = NULL; + char *err = NULL; + int rc = cbm_cypher_parse(queries[i], &q, &err); + ASSERT_NEQ(rc, 0); + ASSERT_NULL(q); + ASSERT_NOT_NULL(err); + free(err); + } + PASS(); +} + TEST(cypher_parse_relationship_outbound) { cbm_query_t *q = NULL; char *err = NULL; @@ -4842,6 +4860,7 @@ SUITE(cypher) { RUN_TEST(cypher_parse_rejects_trailing_tokens); RUN_TEST(cypher_parse_rejects_second_with_clause); RUN_TEST(cypher_parse_accepts_single_with_clause); + RUN_TEST(cypher_parse_rejects_partial_patterns); RUN_TEST(cypher_parse_relationship_outbound); RUN_TEST(cypher_parse_relationship_inbound); RUN_TEST(cypher_parse_relationship_any); From 62479ff9fce0f8a052b0eab29a5499ca48b93bec Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 22:38:59 -0400 Subject: [PATCH 25/36] Isolate Git context probes from inherited repository environment Signed-off-by: Nohj --- src/git/git_context.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index a63d3385e..6986be4aa 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -65,7 +65,9 @@ static int git_capture(const char *repo_path, const char *git_args, char **out) return CBM_NOT_FOUND; } - FILE *fp = cbm_popen(cmd, "r"); + /* A hook or editor may export GIT_DIR/GIT_WORK_TREE for another repo. + * Isolate this child so git -C selects repo_path rather than that repo. */ + FILE *fp = cbm_popen_git(cmd); if (!fp) { return CBM_NOT_FOUND; } From 1b64c50c54a804fa688c311de625049d704eed70 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:17:03 -0700 Subject: [PATCH 26/36] fix(python): resolve ClassName() calls to __init__ A Python construction call resolved to the Class node, so a user __init__ never received a caller from ClassName(...) and the dead-code view reported live constructors as unreferenced. When the called name is the class itself, resolve it to the __init__ found on the class or, depth-first, its declared bases (the lookup super().__init__() already uses), with a new lsp_constructor_init strategy that joins the textual call site through its reason. Classes with no user __init__ keep the Class target (lsp_constructor), as does a builtins.* __init__. Calls through an instance (f = Foo(); f()) are unchanged, since they dispatch to __call__. This changes the documented Class-target contract for classes that define __init__; the three tests that pinned it are updated. Refs #1642 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- internal/cbm/lsp/py_lsp.c | 21 ++- src/pipeline/lsp_resolve.h | 1 + tests/repro/repro_lsp_go_py.c | 30 +++- tests/test_edge_structural.c | 22 ++- tests/test_lsp_resolution_probe.c | 7 +- tests/test_py_lsp.c | 262 ++++++++++++++++++++++++++++++ 6 files changed, 332 insertions(+), 11 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 74c1980e2..be7752a39 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -3249,7 +3249,26 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { py_emit_resolved_call_reason(ctx, qn, "lsp_import_alias", 0.85f, fname, call_node); } else if (qn && cbm_registry_lookup_type(ctx->registry, qn)) { - py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f, call_node); + /* ClassName() runs a user __init__ found on the class or, + * depth-first, a declared base (the same walk + * super().__init__ uses, not C3 MRO). Retarget only when + * this binding IS the class being called. An instance + * (`f = Foo(); f()`) keeps the class row: that call + * dispatches to __call__, and a reason-join would invent + * a Foo.__init__ edge. A builtins.* __init__ is not a + * project constructor, so the class target stays. */ + const CBMRegisteredFunc *fi = NULL; + if (qn_short && strcmp(qn_short, fname) == 0) { + fi = py_lookup_attribute(ctx, qn, "__init__"); + } + if (fi && fi->qualified_name && + strncmp(fi->qualified_name, "builtins.", sizeof("builtins.") - 1) != 0) { + py_emit_resolved_call_reason(ctx, fi->qualified_name, + "lsp_constructor_init", 0.85f, fname, + call_node); + } else { + py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f, call_node); + } } } return; diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 371cb5536..7fd3905d3 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -388,6 +388,7 @@ static inline bool cbm_pipeline_invocation_reason_join_strategy(const char *stra strcmp(strategy, "lsp_method_ref_ctor_synth") == 0 || strcmp(strategy, "lsp_dict_dispatch") == 0 || strcmp(strategy, "lsp_import_alias") == 0 || + strcmp(strategy, "lsp_constructor_init") == 0 || strcmp(strategy, "php_method_dynamic") == 0); } diff --git a/tests/repro/repro_lsp_go_py.c b/tests/repro/repro_lsp_go_py.c index fcb06903d..c0b219172 100644 --- a/tests/repro/repro_lsp_go_py.c +++ b/tests/repro/repro_lsp_go_py.c @@ -64,8 +64,10 @@ * PYTHON STRATEGY INVENTORY — every literal "lsp_..." emitted by py_lsp.c * (grep '"lsp_' internal/cbm/lsp/py_lsp.c), with its keying site: * lsp_direct (py_lsp.c:1631) module-local f() - * lsp_constructor (py_lsp.c:1624) ClassName() where the name is a - * NAMED type in scope + * lsp_constructor ClassName() with no user __init__ → the Class + * lsp_constructor_init ClassName() with a user __init__ (on the class + * or a declared base) → that __init__. The textual + * callee leaf stays the class name; reason joins it. * lsp_method (py_lsp.c:1731) obj.method() on a NAMED-typed * receiver (covers self.other()) * lsp_super (py_lsp.c:1693) super().method() resolved on a @@ -340,13 +342,24 @@ static const char kPyDirect[] = "def caller(v):\n" " return helper(v)\n"; -/* lsp_constructor — ClassName() where the name is a NAMED type in scope - * (py_lsp.c:1620-1624: cbm_scope_lookup yields a NAMED type → emit constructor - * edge to the class QN). */ +/* lsp_constructor — ClassName() where the class has no user __init__. + * The edge stays on the Class node. A class that defines __init__ is + * lsp_constructor_init below; strstr would otherwise treat that strategy + * as a hit for "lsp_constructor". */ static const char kPyConstructor[] = + "class Widget:\n" + " def label(self):\n" + " return 1\n" + "def caller():\n" + " return Widget()\n"; + +/* lsp_constructor_init — ClassName() resolves to the user __init__ + * (issue #1642). reason carries the class-name leaf so the pipeline join + * still matches the textual call. */ +static const char kPyConstructorInit[] = "class Widget:\n" " def __init__(self):\n" - " pass\n" + " self.ready = True\n" "def caller():\n" " return Widget()\n"; @@ -538,6 +551,10 @@ TEST(repro_lsp_py_constructor) { return assert_lsp_strategy("main.py", kPyConstructor, "lsp_constructor"); } +TEST(repro_lsp_py_constructor_init) { + return assert_lsp_strategy("main.py", kPyConstructorInit, "lsp_constructor_init"); +} + TEST(repro_lsp_py_method) { return assert_lsp_strategy("main.py", kPyMethod, "lsp_method"); } @@ -623,6 +640,7 @@ SUITE(repro_lsp_go_py) { RUN_TEST(repro_lsp_py_direct); RUN_TEST(repro_lsp_py_constructor); + RUN_TEST(repro_lsp_py_constructor_init); RUN_TEST(repro_lsp_py_method); RUN_TEST(repro_lsp_py_super); RUN_TEST(repro_lsp_py_super_init); diff --git a/tests/test_edge_structural.c b/tests/test_edge_structural.c index 4b91ae05f..67326dcd5 100644 --- a/tests/test_edge_structural.c +++ b/tests/test_edge_structural.c @@ -826,14 +826,31 @@ TEST(es_decorates_attribute_csharp) { * resolves in the registry. pass_usages.c handles this. * ══════════════════════════════════════════════════════════════════ */ -/* Python constructor syntax is a CALLS edge to the materialized Class node. */ +/* Python ClassName() with a user __init__ is a CALLS edge to that method. + * The Class node no longer receives the instantiation edge. A class with no + * user __init__ still targets the Class (lsp_constructor). */ TEST(es_usage_crossfile_python) { static const ES_LangFile f[] = { {"models.py", "class User:\n def __init__(self, name):\n self.name = name\n"}, {"main.py", "from .models import User\n\n\ndef create_user(name):\n return User(name)\n"}}; - ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "User"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "__init__"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "User"), 0); + PASS(); +} + +/* Issue #1642 repro: build() -> Embedder.__init__, not the Embedder class. */ +TEST(es_calls_python_constructor_init) { + static const ES_LangFile f[] = { + {"main.py", "class Embedder:\n" + " def __init__(self, model=\"x\"):\n" + " self.model = model\n" + "\n" + "def build():\n" + " return Embedder(model=\"y\")\n"}}; + ASSERT_EQ(es_exact_edge_by_name(f, 1, "CALLS", "build", "__init__"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 1, "CALLS", "build", "Embedder"), 0); PASS(); } @@ -982,6 +999,7 @@ SUITE(edge_structural) { /* ── FAMILY 7: USAGE cross-file ─────────────────────────── */ /* Uncertain: depends on USAGE extraction for each language. */ RUN_TEST(es_usage_crossfile_python); + RUN_TEST(es_calls_python_constructor_init); RUN_TEST(es_usage_crossfile_typescript); RUN_TEST(es_usage_crossfile_go); diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 91844f67e..fd5d7c3d3 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1837,16 +1837,19 @@ TEST(lrp_tsx_usage_import_type_with_decoy) { PASS(); } -/* Python: constructor syntax is an exact call to the materialized Class node. */ +/* Python: ClassName() with a user __init__ is an exact CALLS edge to that + * method. The Class node no longer receives the instantiation edge. */ TEST(lrp_python_usage_instantiation) { static const LRP_File f[] = { {"model.py", "class User:\n def __init__(self, name):\n self.name = name\n"}, {"main.py", "from .model import User\n\n\ndef create(name):\n return User(name)\n"}}; LRP_Proj lp; cbm_store_t *store = lrp_index(&lp, f, 2); - int exact = store ? lrp_exact_calls_by_name(store, lp.project, "create", "User") : -1; + int exact = store ? lrp_exact_calls_by_name(store, lp.project, "create", "__init__") : -1; + int class_edge = store ? lrp_exact_calls_by_name(store, lp.project, "create", "User") : -1; lrp_cleanup(&lp, store); ASSERT_EQ(exact, 1); + ASSERT_EQ(class_edge, 0); PASS(); } diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 429fb6628..4f92318c0 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -356,6 +356,164 @@ TEST(pylsp_constructor_call_returns_instance) { PASS(); } +/* Issue #1642: Embedder(...) must resolve to Embedder.__init__, and the + * textual callee leaf "Embedder" must still join that row. */ +TEST(pylsp_constructor_call_targets_init) { + const char *source = "class Embedder:\n" + " def __init__(self, model=\"x\"):\n" + " self.model = model\n" + "def build():\n" + " return Embedder(model=\"y\")\n"; + CBMFileResult *r = extract_py(source); + ASSERT_NOT_NULL(r); + int idx = require_resolved(r, "build", "Embedder.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(rc->reason, "Embedder"); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !strstr(rc->caller_qn, "build")) + continue; + /* One site, one target: the class node no longer receives the call. */ + ASSERT(strstr(rc->callee_qn, ".__init__") != NULL); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } + + const CBMCall *carrier = NULL; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *call = &r->calls.items[i]; + if (call->callee_name && strcmp(call->callee_name, "Embedder") == 0 && + call->enclosing_func_qn && strstr(call->enclosing_func_qn, "build")) { + carrier = call; + break; + } + } + ASSERT_NOT_NULL(carrier); + const CBMResolvedCall *joined = + cbm_pipeline_find_lsp_resolution(&r->resolved_calls, carrier, false); + ASSERT_NOT_NULL(joined); + ASSERT(strstr(joined->callee_qn, "Embedder.__init__") != NULL); + ASSERT_STR_EQ(joined->strategy, "lsp_constructor_init"); + cbm_free_result(r); + PASS(); +} + +/* class B(A) with only A.__init__ resolves construction of B to A.__init__. */ +TEST(pylsp_constructor_inherited_init) { + CBMFileResult *r = extract_py("class A:\n" + " def __init__(self):\n" + " self.ready = True\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "def build():\n" + " return B()\n"); + ASSERT_NOT_NULL(r); + int idx = require_resolved(r, "build", "A.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT(strstr(rc->callee_qn, "B.__init__") == NULL); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } + ASSERT_EQ(find_resolved(r, "build", "B.__init__"), -1); + cbm_free_result(r); + PASS(); +} + +/* No user __init__ (and no inherited one): the call stays on the class. */ +TEST(pylsp_constructor_without_init_stays_on_class) { + CBMFileResult *r = extract_py("class Bare:\n" + " def method(self):\n" + " return 1\n" + "def build():\n" + " return Bare()\n"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(find_resolved(r, "build", "__init__"), -1); + int idx = -1; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind == CBM_RESOLVED_INVOCATION && rc->caller_qn && + strstr(rc->caller_qn, "build") && rc->callee_qn && strstr(rc->callee_qn, "Bare") && + !strstr(rc->callee_qn, "__init__")) { + idx = i; + break; + } + } + ASSERT_GTE(idx, 0); + if (idx >= 0) + ASSERT_STR_EQ(r->resolved_calls.items[idx].strategy, "lsp_constructor"); + cbm_free_result(r); + PASS(); +} + +/* f = Foo(); f() is a callable instance, not a constructor. It must not + * gain an edge to Foo.__init__. Foo() itself still does. */ +TEST(pylsp_callable_instance_call_skips_init) { + CBMFileResult *r = extract_py("class Foo:\n" + " def __init__(self):\n" + " pass\n" + " def __call__(self):\n" + " return 1\n" + "def use():\n" + " f = Foo()\n" + " return f()\n"); + ASSERT_NOT_NULL(r); + int init_rows = 0; + int class_rows = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !strstr(rc->caller_qn, "use")) + continue; + if (strstr(rc->callee_qn, "__init__")) { + init_rows++; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(rc->reason, "Foo"); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } else if (strstr(rc->callee_qn, "Foo")) { + class_rows++; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor"); + ASSERT_NULL(rc->reason); + } + } + ASSERT_EQ(init_rows, 1); + ASSERT_EQ(class_rows, 1); + cbm_free_result(r); + PASS(); +} + +/* builtins.object.__init__ is in the stdlib registry. Construction must not + * record a resolution to it. */ +TEST(pylsp_constructor_skips_builtin_init) { + CBMFileResult *r = extract_py("from builtins import object\n" + "def build():\n" + " return object()\n"); + ASSERT_NOT_NULL(r); + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (!rc->callee_qn) + continue; + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0 || + strstr(rc->callee_qn, ".__init__") == NULL); + } + int idx = find_resolved(r, "build", "builtins.object"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor"); + ASSERT(strstr(rc->callee_qn, "__init__") == NULL); + } + cbm_free_result(r); + PASS(); +} + TEST(pylsp_method_via_inheritance) { CBMFileResult *r = extract_py( "class Base:\n" @@ -924,6 +1082,103 @@ TEST(pylsp_crossfile_inheritance) { PASS(); } +/* Imported ClassName() resolves to that class's __init__. Bare names are + * module-qualified during per-file registration, so the imported class has + * to come from the cross-file registry. */ +TEST(pylsp_crossfile_constructor_targets_init) { + const char *source = "from models import Embedder\n" + "def build():\n" + " return Embedder(model=\"y\")\n"; + + CBMLSPDef defs[2]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "models.Embedder"; + defs[0].short_name = "Embedder"; + defs[0].label = "Class"; + defs[0].def_module_qn = "models"; + defs[0].lang = CBM_LANG_PYTHON; + defs[1].qualified_name = "models.Embedder.__init__"; + defs[1].short_name = "__init__"; + defs[1].label = "Method"; + defs[1].receiver_type = "models.Embedder"; + defs[1].def_module_qn = "models"; + defs[1].lang = CBM_LANG_PYTHON; + + const char *imp_names[] = {"Embedder"}; + const char *imp_qns[] = {"models.Embedder"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 2); + ASSERT_NOT_NULL(reg); + CBMResolvedCallArray out = {0}; + cbm_run_py_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, + imp_names, imp_qns, 1, NULL, &out, NULL); + + int idx = find_resolved_arr(&out, "build", "models.Embedder.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(out.items[idx].reason, "Embedder"); + ASSERT(strncmp(out.items[idx].callee_qn, "builtins.", 9) != 0); + } + cbm_arena_destroy(&arena); + PASS(); +} + +/* class B(A) where A's __init__ lives in another module. The cross registry + * records the real base QN; per-file registration would have stored + * test.main.A and missed the base. */ +TEST(pylsp_crossfile_inherited_init) { + const char *source = "from base_mod import A\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "def build():\n" + " return B()\n"; + + CBMLSPDef defs[3]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "base_mod.A"; + defs[0].short_name = "A"; + defs[0].label = "Class"; + defs[0].def_module_qn = "base_mod"; + defs[0].lang = CBM_LANG_PYTHON; + defs[1].qualified_name = "base_mod.A.__init__"; + defs[1].short_name = "__init__"; + defs[1].label = "Method"; + defs[1].receiver_type = "base_mod.A"; + defs[1].def_module_qn = "base_mod"; + defs[1].lang = CBM_LANG_PYTHON; + defs[2].qualified_name = "test.main.B"; + defs[2].short_name = "B"; + defs[2].label = "Class"; + defs[2].def_module_qn = "test.main"; + defs[2].embedded_types = "base_mod.A"; + defs[2].lang = CBM_LANG_PYTHON; + + const char *imp_names[] = {"A"}; + const char *imp_qns[] = {"base_mod.A"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 3); + ASSERT_NOT_NULL(reg); + CBMResolvedCallArray out = {0}; + cbm_run_py_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, + imp_names, imp_qns, 1, NULL, &out, NULL); + + int idx = find_resolved_arr(&out, "build", "base_mod.A.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(out.items[idx].reason, "B"); + ASSERT(strstr(out.items[idx].callee_qn, "B.__init__") == NULL); + } + cbm_arena_destroy(&arena); + PASS(); +} + TEST(pylsp_batch_two_files) { const char *src_a = "def helper():\n" @@ -5010,6 +5265,11 @@ SUITE(py_lsp) { RUN_TEST(pylsp_method_call_simple); RUN_TEST(pylsp_method_via_self); RUN_TEST(pylsp_constructor_call_returns_instance); + RUN_TEST(pylsp_constructor_call_targets_init); + RUN_TEST(pylsp_constructor_inherited_init); + RUN_TEST(pylsp_constructor_without_init_stays_on_class); + RUN_TEST(pylsp_callable_instance_call_skips_init); + RUN_TEST(pylsp_constructor_skips_builtin_init); RUN_TEST(pylsp_method_via_inheritance); RUN_TEST(pylsp_no_false_positive_on_unknown_method); /* Phases 7-8 — decorators, super(), multi-inheritance */ @@ -5028,6 +5288,8 @@ SUITE(py_lsp) { RUN_TEST(pylsp_cross_registry_interleaved_order); RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); + RUN_TEST(pylsp_crossfile_constructor_targets_init); + RUN_TEST(pylsp_crossfile_inherited_init); RUN_TEST(pylsp_batch_two_files); RUN_TEST(pylsp_from_import_alias_equal_module_leaf_targets_imported_member); RUN_TEST(pylsp_project_prefixed_direct_alias_same_tail_is_not_from_import_reference); From 1efa00eee31f0ba8e4cbd8de7b99c90f067ac663 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:21:49 +0000 Subject: [PATCH 27/36] build(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.1 to 7.0.2. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/_build.yml | 26 +++++++++++----------- .github/workflows/_memwaste.yml | 10 ++++----- .github/workflows/_soak.yml | 20 ++++++++--------- .github/workflows/_test.yml | 12 +++++----- .github/workflows/dry-run.yml | 2 +- .github/workflows/nightly-soak.yml | 2 +- .github/workflows/pr.yml | 6 ++--- .github/workflows/release.yml | 4 ++-- .github/workflows/test-impact-artifact.yml | 2 +- 9 files changed, 42 insertions(+), 42 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 152cca737..4579ee42a 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -108,7 +108,7 @@ jobs: release-candidates/${{ matrix.goos }}-${{ matrix.goarch }}/unstripped/* release-candidates/${{ matrix.goos }}-${{ matrix.goarch }}/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-${{ matrix.goos }}-${{ matrix.goarch }} path: release-candidates/${{ matrix.goos }}-${{ matrix.goarch }} @@ -166,7 +166,7 @@ jobs: release-candidates/windows-amd64/unstripped/* release-candidates/windows-amd64/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-windows-amd64 path: release-candidates/windows-amd64 @@ -228,7 +228,7 @@ jobs: release-candidates/windows-arm64/unstripped/* release-candidates/windows-arm64/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-windows-arm64 path: release-candidates/windows-arm64 @@ -292,7 +292,7 @@ jobs: release-candidates/linux-${{ matrix.arch }}-portable/unstripped/* release-candidates/linux-${{ matrix.arch }}-portable/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-linux-${{ matrix.arch }}-portable path: release-candidates/linux-${{ matrix.arch }}-portable @@ -446,7 +446,7 @@ jobs: - name: Preserve candidate selection evidence if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-selection-evidence path: selection-evidence @@ -455,56 +455,56 @@ jobs: # Keep the established artifact names so the reusable smoke workflow and # all callers consume only the selected, byte-verified containers. - name: Upload linux-amd64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-amd64 path: release-containers/linux-amd64/* if-no-files-found: error - name: Upload linux-arm64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-arm64 path: release-containers/linux-arm64/* if-no-files-found: error - name: Upload linux-amd64-portable containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-amd64-portable path: release-containers/linux-amd64-portable/* if-no-files-found: error - name: Upload linux-arm64-portable containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-arm64-portable path: release-containers/linux-arm64-portable/* if-no-files-found: error - name: Upload darwin-amd64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-darwin-amd64 path: release-containers/darwin-amd64/* if-no-files-found: error - name: Upload darwin-arm64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-darwin-arm64 path: release-containers/darwin-arm64/* if-no-files-found: error - name: Upload windows-amd64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-windows-amd64 path: release-containers/windows-amd64/* if-no-files-found: error - name: Upload windows-arm64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-windows-arm64 path: release-containers/windows-arm64/* diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index 72701ca7a..d08b1692b 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -52,7 +52,7 @@ jobs: run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue ${{ inputs.mode }}-linux - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-event-linux-${{ inputs.mode }} path: | @@ -82,7 +82,7 @@ jobs: --out "$RUNNER_TEMP/memwaste-scaling" | tee "$RUNNER_TEMP/memwaste-scaling/report.txt" - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-scaling-linux-${{ inputs.mode }} path: ${{ runner.temp }}/memwaste-scaling/report.txt @@ -108,7 +108,7 @@ jobs: --out "$RUNNER_TEMP/memwaste-access" | tee "$RUNNER_TEMP/memwaste-access/report.txt" - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-access-linux-${{ inputs.mode }} path: | @@ -135,7 +135,7 @@ jobs: run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue full-macos - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-event-macos-${{ inputs.mode }} path: | @@ -177,7 +177,7 @@ jobs: run: python3 scripts/memwaste-ratchet.py --out "$(cygpath -u "$RUNNER_TEMP")/memwaste-event" --venue full-windows - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-event-windows-${{ inputs.mode }} path: | diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index 39f9e76b1..5a6f0e9c7 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -98,14 +98,14 @@ jobs: scripts/soak-legs.sh --legs "${{ inputs.legs }}" "$binary" ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-${{ matrix.goos }}-${{ matrix.goarch }} path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-${{ matrix.goos }}-${{ matrix.goarch }} path: soak-results-query-leak/ @@ -175,14 +175,14 @@ jobs: scripts/soak-legs.sh --legs "${{ inputs.legs }}" "$binary" ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-windows-amd64 path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-windows-amd64 path: soak-results-query-leak/ @@ -253,14 +253,14 @@ jobs: scripts/soak-legs.sh --legs "${{ inputs.legs }}" "$binary" ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-windows-arm64 path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-windows-arm64 path: soak-results-query-leak/ @@ -305,14 +305,14 @@ jobs: ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-linux-${{ matrix.arch }}-portable path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-linux-${{ matrix.arch }}-portable path: soak-results-query-leak/ @@ -373,7 +373,7 @@ jobs: run: scripts/soak-legs.sh --legs quick build/c/codebase-memory-mcp 15 - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-asan-${{ matrix.goos }}-${{ matrix.goarch }} path: soak-results/ @@ -413,7 +413,7 @@ jobs: run: scripts/soak-legs.sh --legs quick build/c/codebase-memory-mcp 15 - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-asan-windows-amd64 path: soak-results/ diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 501a82b20..ff378b1d0 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -253,7 +253,7 @@ jobs: # too keeps red iterations warm. - name: Shard manifest (completeness proof) if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: shard-manifest-${{ matrix.os }}-${{ matrix.cc }}-${{ strategy.job-index }} path: build/c/test-logs/shard-manifest.txt @@ -274,7 +274,7 @@ jobs: # directory, and a second error would only bury the first. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-${{ matrix.os }}-${{ matrix.cc }}-${{ strategy.job-index }} path: build/c/test-logs/failed/ @@ -406,7 +406,7 @@ jobs: # not end green, only when the job failed. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-diag path: build/c/test-logs/failed/ @@ -504,7 +504,7 @@ jobs: # not end green, only when the job failed. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-lsan-macos path: build/c/test-logs/failed/ @@ -669,7 +669,7 @@ jobs: - name: Shard manifest (completeness proof) if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: shard-manifest-${{ matrix.os }}-${{ matrix.msystem }}-${{ strategy.job-index }} path: build/c/test-logs/shard-manifest.txt @@ -684,7 +684,7 @@ jobs: # not end green, only when the job failed. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-${{ matrix.os }}-${{ matrix.msystem }}-${{ strategy.job-index }} path: build/c/test-logs/failed/ diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index c582716fe..7554f16fb 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -148,7 +148,7 @@ jobs: run: scripts/fuzz.sh all --runs 20000 --seed 1 - name: Upload reproducers if: ${{ failure() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-artifacts path: build/fuzz/artifacts-*/ diff --git a/.github/workflows/nightly-soak.yml b/.github/workflows/nightly-soak.yml index d4357aad6..e45d3395f 100644 --- a/.github/workflows/nightly-soak.yml +++ b/.github/workflows/nightly-soak.yml @@ -51,7 +51,7 @@ jobs: run: scripts/fuzz.sh all --max-total-time 1800 --seed 0 - name: Upload reproducers if: ${{ failure() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-artifacts-nightly path: build/fuzz/artifacts-*/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e20ac274a..2c59a4db2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -78,7 +78,7 @@ jobs: run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" --gate "$RUNNER_TEMP/test-selection" - name: Upload the selection if: ${{ steps.select.outputs.selection == 'narrowed' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-selection path: ${{ runner.temp }}/test-selection @@ -88,7 +88,7 @@ jobs: # for the vetoes, never read by a test leg. - name: Upload the vetoed selection if: ${{ !cancelled() && steps.select.outcome != 'skipped' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-selection-vetoed path: ${{ runner.temp }}/test-impact/selection-vetoed.json @@ -342,7 +342,7 @@ jobs: run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" - name: Upload the prediction if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-impact-prediction path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4926260d..033c1a14b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,7 +236,7 @@ jobs: # same-run workflow artifact, not a draft-release download, so that # job keeps contents: read and no gh dependency. - name: Preserve checksums for the registry job - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-checksums path: checksums.txt @@ -429,7 +429,7 @@ jobs: - name: Preserve extracted-object VirusTotal evidence if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: virustotal-evidence-release-${{ github.run_id }} path: | diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml index 49f70dd67..a0a53061c 100644 --- a/.github/workflows/test-impact-artifact.yml +++ b/.github/workflows/test-impact-artifact.yml @@ -79,7 +79,7 @@ jobs: --out "$RUNNER_TEMP/bundle" --mode "$mode" --before "$before" - name: Upload the bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-impact-${{ github.sha }} path: ${{ runner.temp }}/bundle From 0210ea97940ed9c3af670fb11758b23a768cd69e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:17:21 +0100 Subject: [PATCH 28/36] fix(ruby-lsp): search each ancestor once per method lookup The mixin walk recursed into prepend and include at every level with only the depth cap of 16 as a bound, so a module that both prepends and includes the next one doubled the paths per level: a miss on a chain of n such modules cost 2^n visits (measured 4 -> 20,744 for n = 2..14). Each lookup is now one walk that carries the fail-closed blocked flag and a visit stamp. Ruby linearizes ancestors, so a module reached a second time in one walk was already searched and missed (a hit or a block would have ended the walk); skipping the repeat keeps every lookup linear in the ancestors the file declares. Lookup order, hits and the fail-closed rules are unchanged. The stamps live on the per-file class table, so the walk allocates nothing. The walk entries feed CBM_LSP_WORK (seam builds only; product builds compile it to nothing). rubylsp_mixin_walk_visits_each_module_once pins linear work for the doubled chain on the instance and the class side (before: 66 -> 4,098 visits for n = 6 -> 12; after: 14 -> 26) and keeps a diamond and a prepend-plus-include pair resolving exactly once. Signed-off-by: Martin Vogel --- internal/cbm/lsp/ruby_lsp.c | 135 ++++++++++++++++++++++++------------ internal/cbm/lsp/ruby_lsp.h | 8 +++ tests/test_ruby_lsp.c | 114 ++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 43 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index ed97c78ec..6c89ac01f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -78,6 +78,7 @@ */ #include "ruby_lsp.h" +#include "lsp_work.h" #include "../helpers.h" #include "../arena.h" #include @@ -470,12 +471,53 @@ static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, c * from that point on no hit is trustworthy and callers must emit nothing — * including NOT falling through to a same-named top-level def or to the * Object typing fallback. Hits found BEFORE the unseen ancestor in Ruby's - * lookup order are still returned. */ + * lookup order are still returned. + * + * Each lookup is one walk: the `blocked` flag plus a visit stamp. Ruby + * linearizes ancestors, so a module reached a second time in one walk + * (`prepend M; include M`, two includes sharing a module) was already + * searched and missed: a hit or a block would have ended the walk. Skipping + * the repeat keeps a lookup linear in the ancestors it can see; without it + * every module that both prepends and includes the next one doubles the + * paths per level. */ +typedef struct { + bool blocked; + unsigned int stamp; +} RubyLookupWalk; + +static RubyLookupWalk ruby_walk_begin(RubyLSPContext *ctx) { + if (++ctx->lookup_stamp == 0) { + /* Wrapped: clear every mark so no class looks visited. */ + for (int i = 0; i < ctx->class_count; i++) { + ctx->classes[i].seen_instance = 0; + ctx->classes[i].seen_singleton = 0; + } + ctx->lookup_stamp = 1; + } + RubyLookupWalk w = {false, ctx->lookup_stamp}; + return w; +} + +/* Marks `class_qn` searched on one side (instance or singleton) in this walk; + * true when it already was. Only the file's class table is marked: mixins + * and superclass refs are recorded for those classes alone, so a type outside + * the table (a stdlib type) has nothing to expand and is never a repeat. */ +static bool ruby_walk_seen(RubyLSPContext *ctx, RubyLookupWalk *w, const char *class_qn, + bool singleton) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (!ci) + return false; + unsigned int *mark = singleton ? &ci->seen_singleton : &ci->seen_instance; + if (*mark == w->stamp) + return true; + *mark = w->stamp; + return false; +} static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth, bool *blocked); + int depth, RubyLookupWalk *w); /* True when the class declares a superclass the finalize step could not * resolve (stdlib-seeded conventions like ApplicationRecord DO resolve). */ @@ -489,18 +531,18 @@ static bool ruby_superclass_unresolved(RubyLSPContext *ctx, const char *class_qn * before any hit blocks the walk. */ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const char *owner_qn, RubyMixinKind kind, const char *method_name, - int depth, bool *blocked) { + int depth, RubyLookupWalk *w) { for (int i = ctx->mixin_count - 1; i >= 0; i--) { RubyMixinInfo *mi = &ctx->mixins[i]; if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0) continue; if (!mi->module_qn) { - *blocked = true; /* unseen module — it may define/shadow the method */ + w->blocked = true; /* unseen module — it may define/shadow the method */ return NULL; } const CBMRegisteredFunc *f = - ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, blocked); - if (f || *blocked) + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, w); + if (f || w->blocked) return f; } return NULL; @@ -509,29 +551,32 @@ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth, bool *blocked) { + int depth, RubyLookupWalk *w) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; + CBM_LSP_WORK(1); + if (ruby_walk_seen(ctx, w, class_qn, false)) + return NULL; /* searched earlier in this walk: a repeat can only miss */ /* Prepended modules shadow the class's own methods — an unresolved * prepend therefore blocks even an own-method hit. */ const CBMRegisteredFunc *f = - ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth, blocked); - if (f || *blocked) + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth, w); + if (f || w->blocked) return f; f = ruby_own_instance_method(ctx, class_qn, method_name); if (f) return f; /* An unresolved include stops the walk before the superclass chain. */ - f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth, blocked); - if (f || *blocked) + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth, w); + if (f || w->blocked) return f; if (ruby_superclass_unresolved(ctx, class_qn)) { - *blocked = true; + w->blocked = true; return NULL; } const char *sup = ruby_superclass_qn(ctx, class_qn); if (sup && strcmp(sup, class_qn) != 0) - return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, blocked); + return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, w); return NULL; } @@ -540,10 +585,10 @@ static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - bool *blocked) { + RubyLookupWalk *w) { const CBMRegisteredFunc *f = - ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, blocked); - if (f || *blocked) + ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, w); + if (f || w->blocked) return f; /* Universal receiver fallback (Object / ActiveSupport predicates) — * typing only; Object methods are stdlib entries, never project defs. */ @@ -554,37 +599,44 @@ static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ct const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, const char *method_name) { - bool blocked = false; - return ruby_lookup_instance_method_b(ctx, class_qn, method_name, &blocked); + if (!ctx) + return NULL; + RubyLookupWalk w = ruby_walk_begin(ctx); + return ruby_lookup_instance_method_b(ctx, class_qn, method_name, &w); } static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth, bool *blocked) { + int depth, RubyLookupWalk *w) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; + CBM_LSP_WORK(1); + if (ruby_walk_seen(ctx, w, class_qn, true)) + return NULL; /* searched earlier in this walk: a repeat can only miss */ const CBMRegisteredFunc *f = ruby_own_singleton_method(ctx, class_qn, method_name); if (f) return f; /* `extend Mod` adds Mod's instance methods class-side. */ - f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth, blocked); - if (f || *blocked) + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth, w); + if (f || w->blocked) return f; if (ruby_superclass_unresolved(ctx, class_qn)) { - *blocked = true; + w->blocked = true; return NULL; } const char *sup = ruby_superclass_qn(ctx, class_qn); if (sup && strcmp(sup, class_qn) != 0) - return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1, blocked); + return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1, w); return NULL; } const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, const char *method_name) { - bool blocked = false; - return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &blocked); + if (!ctx) + return NULL; + RubyLookupWalk w = ruby_walk_begin(ctx); + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &w); } /* ── ActiveRecord model detection (typing special case) ─────────── */ @@ -878,13 +930,11 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (ctx->self_unknown) return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */ if (ctx->enclosing_class_qn) { - bool blocked = false; + RubyLookupWalk w = ruby_walk_begin(ctx); const CBMRegisteredFunc *f = ctx->in_singleton_method - ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, - &blocked) - : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, - &blocked); + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, @@ -893,7 +943,7 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool } /* An unseen ancestor may define this method: do NOT fall through * to a same-named top-level def. */ - if (blocked) + if (w.blocked) return cbm_type_unknown(); } const CBMRegisteredFunc *f = @@ -915,12 +965,11 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (strcmp(rk, "self") == 0) { if (!ctx->enclosing_class_qn || ctx->self_unknown) return cbm_type_unknown(); - bool blocked = false; - const CBMRegisteredFunc *f = ctx->in_singleton_method - ? ruby_lookup_singleton_method_depth( - ctx, ctx->enclosing_class_qn, mname, 0, &blocked) - : ruby_lookup_instance_method_depth( - ctx, ctx->enclosing_class_qn, mname, 0, &blocked); + RubyLookupWalk w = ruby_walk_begin(ctx); + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, @@ -967,22 +1016,22 @@ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { const char *mname = strrchr(fq, '.'); mname = mname ? mname + 1 : fq; const CBMRegisteredFunc *f = NULL; - bool blocked = false; + RubyLookupWalk w = ruby_walk_begin(ctx); /* Search order mirrors Ruby: includes of the class, then superclass * chain — the class's OWN def is the caller and must be skipped. An * unresolved mixin or superclass on the way blocks the walk. */ f = ruby_lookup_in_mixins(ctx, ctx->enclosing_class_qn, ctx->in_singleton_method ? RUBY_MIXIN_EXTEND : RUBY_MIXIN_INCLUDE, - mname, 0, &blocked); - if (!f && !blocked && !ruby_superclass_unresolved(ctx, ctx->enclosing_class_qn)) { + mname, 0, &w); + if (!f && !w.blocked && !ruby_superclass_unresolved(ctx, ctx->enclosing_class_qn)) { const char *sup = ruby_superclass_qn(ctx, ctx->enclosing_class_qn); if (sup) { f = ctx->in_singleton_method - ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0, &blocked) - : ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &blocked); + ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &w); } } - if (f && !blocked) + if (f && !w.blocked) ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); /* No known parent or unresolved — zero-edge guarantee. */ } diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index ad2d81de3..317702cb6 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -33,6 +33,10 @@ typedef struct { const char *superclass_ref; /* raw dotted superclass spelling, or NULL */ const char *superclass_qn; /* resolved QN (table/stdlib), or NULL */ bool is_module; /* `module` (mixin source) vs `class` */ + /* Method-lookup visit marks: the stamp of the last walk that searched + * this class on the instance / singleton side (ruby_walk_seen). */ + unsigned int seen_instance; + unsigned int seen_singleton; } RubyClassInfo; /* One `include`/`prepend`/`extend` record: owner class QN → module ref. */ @@ -129,6 +133,10 @@ typedef struct { int eval_depth; int walk_depth; + /* Stamp of the current method-lookup walk; each walk takes a fresh one + * so marks left by earlier walks never match. */ + unsigned int lookup_stamp; + /* Debug mode (CBM_LSP_DEBUG env). */ bool debug; } RubyLSPContext; diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index d1999357f..1e2b2c85e 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -40,6 +40,9 @@ #include "cbm.h" #include "../src/pipeline/lsp_resolve.h" #include "lsp/ruby_lsp.h" +#include "lsp/lsp_work.h" +#include +#include #include /* ── Helpers (mirror test_perl_lsp.c) ──────────────────────────── */ @@ -1317,6 +1320,116 @@ TEST(rubylsp_self_rebinding_blocks_no_edge) { PASS(); } +/* ── 17. Bounded resolver work ──────────────────────────────────── + * + * Measured with counters, never with a clock (O9): CBM_LSP_WORK + * (lsp_work.h, seam builds) for lookup work, and the result arena's + * cumulative allocation for bytes built. Each fixture is measured at n and + * 2n: linear work doubles (ratio about 2); anything worse lands above the + * gate of 3. */ + +static uint64_t rubylsp_counted_work(const char *src, size_t *arena_bytes) { + (void)cbm_lsp_work_take(); + CBMFileResult *r = extract_ruby(src); + uint64_t work = cbm_lsp_work_take(); + if (arena_bytes) + *arena_bytes = r ? cbm_arena_total(&r->arena) : 0; + if (r) + cbm_free_result(r); + return work; +} + +/* n modules where every module both prepends and includes the next one; + * the last defines one method. `Probe` reaches M0 through `include` + * (instance side) or `extend` (class side) and calls a method nobody + * defines, so the lookup must miss after searching every ancestor. */ +static char *rubylsp_mixin_chain_src(int n, bool class_side) { + size_t cap = (size_t)n * 96 + 256; + char *src = (char *)malloc(cap); + if (!src) + return NULL; + size_t off = 0; + for (int i = 0; i < n; i++) { + if (i + 1 < n) + off += (size_t)snprintf(src + off, cap - off, + "module M%d\n prepend M%d\n include M%d\nend\n", i, i + 1, + i + 1); + else + off += (size_t)snprintf(src + off, cap - off, + "module M%d\n def leaf\n 1\n end\nend\n", i); + } + if (class_side) + (void)snprintf(src + off, cap - off, + "class Probe\n extend M0\n def self.run\n nobody_defines_this()\n" + " end\nend\n"); + else + (void)snprintf(src + off, cap - off, + "class Probe\n include M0\n def run\n nobody_defines_this()\n" + " end\nend\n"); + return src; +} + +static uint64_t rubylsp_mixin_chain_work(int n, bool class_side) { + char *src = rubylsp_mixin_chain_src(n, class_side); + if (!src) + return 0; + uint64_t work = rubylsp_counted_work(src, NULL); + free(src); + return work; +} + +/* Review ask (a): Ruby linearizes ancestors, so a module reached through + * two paths (`prepend M; include M`, a diamond) is searched once. Without a + * visited set every level of the chain doubles the paths: 2^n visits + * (measured 4 -> 20,744 for n = 2..14). With it a miss costs one visit per + * module. Positive controls: the doubled pair still resolves through the + * prepend, and a diamond resolves to the shared module exactly once. */ +TEST(rubylsp_mixin_walk_visits_each_module_once) { + uint64_t inst_n = rubylsp_mixin_chain_work(6, false); + uint64_t inst_2n = rubylsp_mixin_chain_work(12, false); + uint64_t cls_n = rubylsp_mixin_chain_work(6, true); + uint64_t cls_2n = rubylsp_mixin_chain_work(12, true); + printf(" mixin chain work: instance n=6 %llu, n=12 %llu; class side n=6 %llu, n=12 %llu\n", + (unsigned long long)inst_n, (unsigned long long)inst_2n, (unsigned long long)cls_n, + (unsigned long long)cls_2n); + ASSERT(inst_n > 0 && cls_n > 0); + ASSERT(inst_2n <= 3 * inst_n); + ASSERT(cls_2n <= 3 * cls_n); + + const char *src = "module Shared\n" + " def hello\n" + " 1\n" + " end\n" + "end\n" + "module Left\n" + " include Shared\n" + "end\n" + "module Right\n" + " include Shared\n" + "end\n" + "module Twice\n" + " def twice\n" + " 2\n" + " end\n" + "end\n" + "class Diamond\n" + " include Left\n" + " include Right\n" + " prepend Twice\n" + " include Twice\n" + " def run\n" + " hello()\n" + " twice()\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Shared.hello") == 1); + ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Twice.twice") == 1); + cbm_free_result(r); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1353,4 +1466,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_send_no_edge); RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); + RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); } From 214a9ac8436ba8e2277bce533726a8ffdc1ed187 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:17:28 +0100 Subject: [PATCH 29/36] fix(ruby-lsp): build constant paths in one pass ruby_const_path recursed once per :: segment and re-formatted the growing prefix at every level, so an A::B::C path cost one stack frame per segment and quadratic bytes in the per-file arena. It now follows the scope fields in a loop: one pass measures the segments, a second copies them right to left into a single buffer. The result is the same string as before, including a leading ::Foo and a scope that is not a constant path. rubylsp_const_path_is_linear pins the arena bytes for an 800 vs a 1,600 segment path (before: 494,520 -> 1,660,520 bytes already at 400 -> 800 segments; after: 136,248 -> 150,848 at 800 -> 1,600) and the exact class QN for a five-level path and for a rooted ::Top::Inner. Signed-off-by: Martin Vogel --- internal/cbm/lsp/ruby_lsp.c | 77 +++++++++++++++++++++++++++++++------ tests/test_ruby_lsp.c | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 11 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 6c89ac01f..2464a6891 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -114,10 +114,52 @@ static char *ruby_node_text(RubyLSPContext *ctx, TSNode node) { return cbm_node_text(ctx->arena, node, ctx->source); } +/* Byte length of a node's source text (0 for an empty node). */ +static size_t ruby_node_len(TSNode node) { + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + return end > start ? (size_t)(end - start) : 0; +} + +/* One step left along a constant path. `*cur` is a scope_resolution whose + * own segment has been taken; on success `*seg` is the next segment to its + * left and `*cur` the node to continue from. The path ends at a missing + * scope (`::Foo`), at a scope that is not a constant path (`expr::Foo`), + * and at an empty segment. A plain `constant` has no scope field, so it is + * always the last segment. */ +static bool ruby_const_step(TSNode *cur, TSNode *seg) { + TSNode scope = ts_node_child_by_field_name(*cur, "scope", 5); + if (ts_node_is_null(scope)) + return false; + const char *k = ts_node_type(scope); + if (strcmp(k, "constant") == 0) { + if (ruby_node_len(scope) == 0) + return false; + *seg = scope; + *cur = scope; + return true; + } + if (strcmp(k, "scope_resolution") != 0) + return false; + TSNode name = ts_node_child_by_field_name(scope, "name", 4); + if (ts_node_is_null(name) || ruby_node_len(name) == 0) + return false; + *seg = name; + *cur = scope; + return true; +} + /* Convert a constant expression node (constant | scope_resolution) into a * dotted path ("Foo", "A.B.C"). Returns NULL for anything else. A leading * "::Foo" (scope_resolution without a scope) yields "Foo" — the resolver's - * table probe covers the top-level meaning. */ + * table probe covers the top-level meaning. A scope that is not itself a + * constant path (`expr::Foo`) contributes nothing. + * + * `A::B::C` nests to the LEFT (scope_resolution(scope_resolution(A, B), C)), + * so the path is read from the outermost node along the `scope` fields: one + * pass measures the segments, a second copies them right to left into one + * buffer. No recursion and no re-formatting of a growing prefix, so neither + * stack depth nor bytes grow faster than the path itself. */ static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { if (ts_node_is_null(node)) return NULL; @@ -126,19 +168,32 @@ static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { return ruby_node_text(ctx, node); if (strcmp(k, "scope_resolution") != 0) return NULL; - TSNode scope = ts_node_child_by_field_name(node, "scope", 5); TSNode name = ts_node_child_by_field_name(node, "name", 4); - if (ts_node_is_null(name)) + if (ts_node_is_null(name) || ruby_node_len(name) == 0) return NULL; - char *nm = ruby_node_text(ctx, name); - if (!nm || !nm[0]) + + size_t total = 0; + TSNode cur = node; + TSNode seg = name; + do { + total += ruby_node_len(seg) + 1; /* segment + its '.' (or the NUL) */ + } while (ruby_const_step(&cur, &seg)); + + char *out = (char *)cbm_arena_alloc(ctx->arena, total); + if (!out) return NULL; - if (ts_node_is_null(scope)) - return nm; /* ::Foo */ - const char *sp = ruby_const_path(ctx, scope); - if (!sp || !sp[0]) - return nm; - return cbm_arena_sprintf(ctx->arena, "%s.%s", sp, nm); + size_t pos = total - 1; + out[pos] = '\0'; + cur = node; + seg = name; + do { + size_t len = ruby_node_len(seg); + pos -= len; + memcpy(out + pos, ctx->source + ts_node_start_byte(seg), len); + if (pos > 0) + out[--pos] = '.'; + } while (ruby_const_step(&cur, &seg)); + return out; } /* Join two dotted path fragments; either side may be empty/NULL. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 1e2b2c85e..4aa00b9f8 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -1430,6 +1430,74 @@ TEST(rubylsp_mixin_walk_visits_each_module_once) { PASS(); } +/* `class Probe; def run; S0::S1::...::S.new; end; end` */ +static char *rubylsp_deep_const_src(int n) { + size_t cap = (size_t)n * 12 + 128; + char *src = (char *)malloc(cap); + if (!src) + return NULL; + size_t off = (size_t)snprintf(src, cap, "class Probe\n def run\n "); + for (int i = 0; i < n; i++) + off += (size_t)snprintf(src + off, cap - off, i ? "::S%d" : "S%d", i); + (void)snprintf(src + off, cap - off, ".new\n end\nend\n"); + return src; +} + +static size_t rubylsp_deep_const_bytes(int n) { + char *src = rubylsp_deep_const_src(n); + if (!src) + return 0; + size_t bytes = 0; + (void)rubylsp_counted_work(src, &bytes); + free(src); + return bytes; +} + +/* Review ask (b): a constant path is built in one pass. Recursing once per + * `::` and re-formatting the growing prefix at every level costs one stack + * frame per segment and quadratic bytes; the loop has no recursion and + * writes the path once. The positive control pins the exact class QN for a + * deep path and for a leading `::`. */ +TEST(rubylsp_const_path_is_linear) { + size_t b_n = rubylsp_deep_const_bytes(800); + size_t b_2n = rubylsp_deep_const_bytes(1600); + printf(" const path arena bytes: n=800 %zu, n=1600 %zu\n", b_n, b_2n); + ASSERT(b_n > 0); + ASSERT(b_2n <= 3 * b_n); + + const char *src = "module L0\n" + " module L1\n" + " module L2\n" + " module L3\n" + " module L4\n" + " class Leaf\n" + " end\n" + " end\n" + " end\n" + " end\n" + " end\n" + "end\n" + "class Top\n" + " class Inner\n" + " end\n" + "end\n" + "class Caller\n" + " def deep\n" + " L0::L1::L2::L3::L4::Leaf.new\n" + " end\n" + " def rooted\n" + " ::Top::Inner.new\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Caller.deep", "test.main.L0.L1.L2.L3.L4.Leaf") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Caller.rooted", "test.main.Top.Inner") >= 0); + cbm_free_result(r); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1467,4 +1535,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); + RUN_TEST(rubylsp_const_path_is_linear); } From 435baf6bd87bbe250ba45b2c4424d11a1e2e057e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:17:28 +0100 Subject: [PATCH 30/36] fix(ruby-lsp): survive allocation failure in the stdlib seed The REG_* macros wrote through the return-type vector without checking the allocation, and registered entries whose formatted QN came back NULL. A shared ruby_seed_signature helper now leaves a failed vector out, so the signature types as unknown (the fallback ruby_register_method already uses), and REG_METHOD / REG_SMETHOD skip an entry whose formatted name is NULL, since a NULL QN would reach strcmp in finalize and in every lookup. rubylsp_stdlib_seed_survives_alloc_failure seeds a registry from an arena that cannot allocate. Before: UBSan reported a store to a null pointer and the run crashed with SEGV at ruby_stdlib_data.c:163. After: every registered entry carries a name, the registry finalizes, and the Kernel built-ins with literal names still resolve. Signed-off-by: Martin Vogel --- internal/cbm/lsp/generated/ruby_stdlib_data.c | 90 ++++++++++--------- tests/test_ruby_lsp.c | 27 ++++++ 2 files changed, 74 insertions(+), 43 deletions(-) diff --git a/internal/cbm/lsp/generated/ruby_stdlib_data.c b/internal/cbm/lsp/generated/ruby_stdlib_data.c index 83e853e65..f94ccd764 100644 --- a/internal/cbm/lsp/generated/ruby_stdlib_data.c +++ b/internal/cbm/lsp/generated/ruby_stdlib_data.c @@ -40,55 +40,59 @@ cbm_registry_add_type(reg, rt); \ } while (0) -/* Register an instance method on receiver type `recv_` returning `ret_`. */ -#define REG_METHOD(recv_, name_, ret_) \ - do { \ - memset(&rf, 0, sizeof(rf)); \ - rf.min_params = -1; \ - rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ - rf.short_name = (name_); \ - rf.receiver_type = (recv_); \ - { \ - const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ - rets[0] = (ret_); \ - rets[1] = NULL; \ - rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ - } \ - cbm_registry_add_func(reg, rf); \ +/* Signature `() -> ret`. Every seed allocation can fail (arena exhausted): + * a NULL return vector is left out, so the signature types as unknown (the + * same fallback ruby_register_method uses) instead of being written through. */ +static const CBMType *ruby_seed_signature(CBMArena *arena, const CBMType *ret) { + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); + if (rets) { + rets[0] = ret; + rets[1] = NULL; + } + return cbm_type_func(arena, NULL, NULL, rets); +} + +/* Register an instance method on receiver type `recv_` returning `ret_`. + * An entry whose formatted QN came back NULL is not registered: a NULL QN + * would reach strcmp in finalize and in every later lookup. */ +#define REG_METHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + if (!rf.qualified_name) \ + break; \ + rf.short_name = (name_); \ + rf.receiver_type = (recv_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ } while (0) /* Register a singleton (class-side) method: receiver key ".self" - * matches ruby_lookup_singleton_method's keying convention. */ -#define REG_SMETHOD(recv_, name_, ret_) \ - do { \ - memset(&rf, 0, sizeof(rf)); \ - rf.min_params = -1; \ - rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ - rf.short_name = (name_); \ - rf.receiver_type = cbm_arena_sprintf(arena, "%s.self", (recv_)); \ - { \ - const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ - rets[0] = (ret_); \ - rets[1] = NULL; \ - rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ - } \ - cbm_registry_add_func(reg, rf); \ + * matches ruby_lookup_singleton_method's keying convention. Skipped when + * either formatted name came back NULL (see REG_METHOD). */ +#define REG_SMETHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + rf.receiver_type = cbm_arena_sprintf(arena, "%s.self", (recv_)); \ + if (!rf.qualified_name || !rf.receiver_type) \ + break; \ + rf.short_name = (name_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ } while (0) /* Register a global (Kernel) built-in function returning `ret_`. */ -#define REG_BUILTIN(name_, ret_) \ - do { \ - memset(&rf, 0, sizeof(rf)); \ - rf.min_params = -1; \ - rf.qualified_name = (name_); \ - rf.short_name = (name_); \ - { \ - const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ - rets[0] = (ret_); \ - rets[1] = NULL; \ - rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ - } \ - cbm_registry_add_func(reg, rf); \ +#define REG_BUILTIN(name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = (name_); \ + rf.short_name = (name_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ } while (0) void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena) { diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 4aa00b9f8..c5499f78e 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -1498,6 +1498,32 @@ TEST(rubylsp_const_path_is_linear) { PASS(); } +/* Review ask (c): the stdlib seed must survive allocation failure. A + * zero-initialised arena has no blocks and no grow size, so every + * allocation from it returns NULL. The seed must not write through a NULL + * return-type vector, and must not register an entry whose formatted name + * came back NULL (a NULL QN would reach strcmp in finalize and in every later + * lookup). */ +TEST(rubylsp_stdlib_seed_survives_alloc_failure) { + CBMArena reg_arena; + cbm_arena_init(®_arena); + CBMTypeRegistry reg; + cbm_registry_init(®, ®_arena); + CBMArena dead; + memset(&dead, 0, sizeof(dead)); + cbm_ruby_stdlib_register(®, &dead); + ASSERT(reg.type_count > 0); + ASSERT(reg.func_count > 0); /* Kernel built-ins carry literal names */ + for (int i = 0; i < reg.func_count; i++) { + ASSERT(reg.funcs[i].qualified_name != NULL); + ASSERT(reg.funcs[i].short_name != NULL); + } + cbm_registry_finalize(®); + ASSERT(cbm_registry_lookup_func(®, "puts") != NULL); + cbm_arena_destroy(®_arena); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1536,4 +1562,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); RUN_TEST(rubylsp_const_path_is_linear); + RUN_TEST(rubylsp_stdlib_seed_survives_alloc_failure); } From aa02511cc7bdd00e17b4ebd9d38add3846765485 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:25:07 -0800 Subject: [PATCH 31/36] test(pipeline): expect Layout.__init__ for the #1548 constructor call pipeline_python_init_function_shares_submodule_qn pinned the old Class target for Layout("demo"). Layout defines __init__, so the constructor call now lands on that method. The other tests that pin the contract were moved in the previous commit; this one reached main afterwards. Refs #1642 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- tests/test_pipeline.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9157c4bcd..a4d5612e8 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -21140,8 +21140,9 @@ static int pyn_run_init_collision(int fillers) { const char *project = cbm_pipeline_project_name(p); imports[run] = pyn_edge(s, project, "tests.test_app.py.__file__", "src.rowpkg.layout.Layout", "IMPORTS"); + /* Layout defines __init__, so Layout("demo") targets it (#1642). */ calls[run] = pyn_edge(s, project, "tests.test_app.test_build", - "src.rowpkg.layout.Layout", "CALLS"); + "src.rowpkg.layout.Layout.__init__", "CALLS"); method[run] = pyn_edge(s, project, "tests.test_app.test_build", "src.rowpkg.layout.Layout.create", "CALLS"); cbm_store_close(s); @@ -21160,7 +21161,7 @@ static int pyn_run_init_collision(int fillers) { for (int run = 0; run < 2; run++) { ASSERT_EQ(rc[run], 0); ASSERT_TRUE(imports[run]); /* the class stays importable */ - ASSERT_TRUE(calls[run]); /* Layout("demo") */ + ASSERT_TRUE(calls[run]); /* Layout("demo") -> Layout.__init__ */ ASSERT_TRUE(method[run]); /* .create() through the constructed type */ } PASS(); From 054719044a6e3f349f1c17f3d06d242b6b35bd14 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:27:32 +0100 Subject: [PATCH 32/36] fix(python): bind ClassName() only to the class own __init__ A construction call now resolves to __init__ only when the called class declares that __init__ itself. An inherited __init__ is no longer followed: class B(A) without its own __init__ keeps the B() call on the Class B, as it was before this branch. Following the bases bound every subclass construction to the base constructor. On a django index (fb113765) that made Model.__init__ a 583-caller hub (every model instantiation), bound IntegerField() to Field.__init__ and HttpResponseForbidden() to HttpResponse.__init__, and degraded 625 constructor call sites versus main (457 fell back to textual resolution, 168 lost their row; CALLS -161). With own __init__ only, 6016 sites move to the class own __init__, 5468 stay on the Class, none fall back to a textual strategy, and total CALLS is +4 versus main. Tests: the same-file and cross-file inherited cases now pin the Class target, and a new same-file subclass test at the LSP and the graph level covers B(A) without __init__ (stays on B, never A.__init__) and C(A) with its own __init__ (binds to C.__init__, never the class C or A.__init__). The strategy inventory comment in the repro suite is updated to match. Refs #1642 Signed-off-by: Martin Vogel --- internal/cbm/lsp/py_lsp.c | 12 +++--- tests/repro/repro_lsp_go_py.c | 10 +++-- tests/test_lsp_resolution_probe.c | 44 +++++++++++++++++++++ tests/test_py_lsp.c | 66 ++++++++++++++++++++++--------- 4 files changed, 105 insertions(+), 27 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index be7752a39..b7309bc33 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -3249,17 +3249,19 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { py_emit_resolved_call_reason(ctx, qn, "lsp_import_alias", 0.85f, fname, call_node); } else if (qn && cbm_registry_lookup_type(ctx->registry, qn)) { - /* ClassName() runs a user __init__ found on the class or, - * depth-first, a declared base (the same walk - * super().__init__ uses, not C3 MRO). Retarget only when - * this binding IS the class being called. An instance + /* ClassName() binds to the __init__ the class declares + * itself. An inherited __init__ is not followed: B(A) + * without its own __init__ keeps the call on B, so a base + * constructor does not collect every subclass + * construction as its caller. Retarget only when this + * binding IS the class being called. An instance * (`f = Foo(); f()`) keeps the class row: that call * dispatches to __call__, and a reason-join would invent * a Foo.__init__ edge. A builtins.* __init__ is not a * project constructor, so the class target stays. */ const CBMRegisteredFunc *fi = NULL; if (qn_short && strcmp(qn_short, fname) == 0) { - fi = py_lookup_attribute(ctx, qn, "__init__"); + fi = cbm_registry_lookup_method(ctx->registry, qn, "__init__"); } if (fi && fi->qualified_name && strncmp(fi->qualified_name, "builtins.", sizeof("builtins.") - 1) != 0) { diff --git a/tests/repro/repro_lsp_go_py.c b/tests/repro/repro_lsp_go_py.c index c0b219172..2b019dce9 100644 --- a/tests/repro/repro_lsp_go_py.c +++ b/tests/repro/repro_lsp_go_py.c @@ -64,10 +64,12 @@ * PYTHON STRATEGY INVENTORY — every literal "lsp_..." emitted by py_lsp.c * (grep '"lsp_' internal/cbm/lsp/py_lsp.c), with its keying site: * lsp_direct (py_lsp.c:1631) module-local f() - * lsp_constructor ClassName() with no user __init__ → the Class - * lsp_constructor_init ClassName() with a user __init__ (on the class - * or a declared base) → that __init__. The textual - * callee leaf stays the class name; reason joins it. + * lsp_constructor ClassName() where the class declares no __init__ + * of its own (an inherited one is not followed) + * → the Class + * lsp_constructor_init ClassName() where the class declares its own + * __init__ → that __init__. The textual callee + * leaf stays the class name; reason joins it. * lsp_method (py_lsp.c:1731) obj.method() on a NAMED-typed * receiver (covers self.other()) * lsp_super (py_lsp.c:1693) super().method() resolved on a diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index fd5d7c3d3..6f18c215e 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1853,6 +1853,49 @@ TEST(lrp_python_usage_instantiation) { PASS(); } +/* Python same-file subclasses in the graph: ClassName() binds only to an + * __init__ the class declares itself. B(A) declares none, so make_b keeps + * its CALLS edge on the class B and the inherited A.__init__ gets none. + * C(A) declares its own __init__, so make_c calls C.__init__, not C and not + * the inherited A.__init__. */ +TEST(lrp_python_subclass_constructor_own_init_only) { + static const LRP_File f[] = {{"main.py", "class A:\n" + " def __init__(self):\n" + " self.ready = True\n" + "\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "\n" + "class C(A):\n" + " def __init__(self):\n" + " self.ready = False\n" + "\n" + "def make_b():\n" + " return B()\n" + "\n" + "def make_c():\n" + " return C()\n"}}; + LRP_Proj lp; + cbm_store_t *store = lrp_index(&lp, f, 1); + const char *p = store ? lp.project : NULL; + int b_class = lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_b", "main.B"); + int b_base_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_b", "main.A.__init__"); + int c_own_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.C.__init__"); + int c_class = lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.C"); + int c_base_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.A.__init__"); + lrp_cleanup(&lp, store); + ASSERT_EQ(b_class, 1); + ASSERT_EQ(b_base_init, 0); + ASSERT_EQ(c_own_init, 1); + ASSERT_EQ(c_class, 0); + ASSERT_EQ(c_base_init, 0); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════════ * SUITE registration * ══════════════════════════════════════════════════════════════════════ */ @@ -1970,4 +2013,5 @@ SUITE(lsp_resolution_probe) { RUN_TEST(lrp_ts_usage_import_type_with_decoy); RUN_TEST(lrp_tsx_usage_import_type_with_decoy); RUN_TEST(lrp_python_usage_instantiation); + RUN_TEST(lrp_python_subclass_constructor_own_init_only); } diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 4f92318c0..1aa5c806d 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -403,26 +403,55 @@ TEST(pylsp_constructor_call_targets_init) { PASS(); } -/* class B(A) with only A.__init__ resolves construction of B to A.__init__. */ -TEST(pylsp_constructor_inherited_init) { +/* Same-file subclasses: only an __init__ the class declares itself is a + * construction target. B(A) inherits A.__init__ but declares none, so B() + * stays on the class B (never A.__init__). C(A) declares its own __init__, + * so C() resolves to C.__init__ (never the inherited A.__init__). */ +static int count_build_rows(const CBMFileResult *r, const char *caller_leaf, + const char *callee_qn, const char *strategy) { + int n = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !rc->strategy) + continue; + const char *dot = strrchr(rc->caller_qn, '.'); + const char *leaf = dot ? dot + 1 : rc->caller_qn; + if (strcmp(leaf, caller_leaf) == 0 && strcmp(rc->callee_qn, callee_qn) == 0 && + strcmp(rc->strategy, strategy) == 0) + n++; + } + return n; +} + +TEST(pylsp_constructor_same_file_subclass_own_init_only) { CBMFileResult *r = extract_py("class A:\n" " def __init__(self):\n" " self.ready = True\n" "class B(A):\n" " def go(self):\n" " return 1\n" - "def build():\n" - " return B()\n"); + "class C(A):\n" + " def __init__(self):\n" + " self.ready = False\n" + "def build_b():\n" + " return B()\n" + "def build_c():\n" + " return C()\n"); ASSERT_NOT_NULL(r); - int idx = require_resolved(r, "build", "A.__init__"); + /* B() keeps the class target; the inherited A.__init__ gets no row. */ + ASSERT_EQ(count_build_rows(r, "build_b", "test.main.B", "lsp_constructor"), 1); + ASSERT_EQ(find_resolved(r, "build_b", "__init__"), -1); + /* C() binds to the __init__ C declares itself. */ + int idx = require_resolved(r, "build_c", "test.main.C.__init__"); ASSERT_GTE(idx, 0); if (idx >= 0) { const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); - ASSERT(strstr(rc->callee_qn, "B.__init__") == NULL); - ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + ASSERT_STR_EQ(rc->reason, "C"); } - ASSERT_EQ(find_resolved(r, "build", "B.__init__"), -1); + ASSERT_EQ(find_resolved(r, "build_c", "test.main.A.__init__"), -1); + ASSERT_EQ(count_build_rows(r, "build_c", "test.main.C", "lsp_constructor"), 0); cbm_free_result(r); PASS(); } @@ -1126,10 +1155,11 @@ TEST(pylsp_crossfile_constructor_targets_init) { PASS(); } -/* class B(A) where A's __init__ lives in another module. The cross registry - * records the real base QN; per-file registration would have stored - * test.main.A and missed the base. */ -TEST(pylsp_crossfile_inherited_init) { +/* class B(A) where A's __init__ lives in another module and B declares no + * __init__ of its own. Construction binds only to an __init__ the class + * declares itself, so B() stays on the class B and the inherited + * base_mod.A.__init__ gets no row. */ +TEST(pylsp_crossfile_inherited_init_stays_on_class) { const char *source = "from base_mod import A\n" "class B(A):\n" " def go(self):\n" @@ -1168,12 +1198,12 @@ TEST(pylsp_crossfile_inherited_init) { cbm_run_py_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, imp_names, imp_qns, 1, NULL, &out, NULL); - int idx = find_resolved_arr(&out, "build", "base_mod.A.__init__"); + ASSERT_EQ(find_resolved_arr(&out, "build", "__init__"), -1); + int idx = find_resolved_arr(&out, "build", "test.main.B"); ASSERT_GTE(idx, 0); if (idx >= 0) { - ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor_init"); - ASSERT_STR_EQ(out.items[idx].reason, "B"); - ASSERT(strstr(out.items[idx].callee_qn, "B.__init__") == NULL); + ASSERT_STR_EQ(out.items[idx].callee_qn, "test.main.B"); + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor"); } cbm_arena_destroy(&arena); PASS(); @@ -5266,7 +5296,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_method_via_self); RUN_TEST(pylsp_constructor_call_returns_instance); RUN_TEST(pylsp_constructor_call_targets_init); - RUN_TEST(pylsp_constructor_inherited_init); + RUN_TEST(pylsp_constructor_same_file_subclass_own_init_only); RUN_TEST(pylsp_constructor_without_init_stays_on_class); RUN_TEST(pylsp_callable_instance_call_skips_init); RUN_TEST(pylsp_constructor_skips_builtin_init); @@ -5289,7 +5319,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); RUN_TEST(pylsp_crossfile_constructor_targets_init); - RUN_TEST(pylsp_crossfile_inherited_init); + RUN_TEST(pylsp_crossfile_inherited_init_stays_on_class); RUN_TEST(pylsp_batch_two_files); RUN_TEST(pylsp_from_import_alias_equal_module_leaf_targets_imported_member); RUN_TEST(pylsp_project_prefixed_direct_alias_same_tail_is_not_from_import_reference); From faf25ee4fa6a9ba7ff0f24a5091756f21f8611e3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:04:05 +0100 Subject: [PATCH 33/36] fix(config): drop the fuzz-link and Cypher hunks from the worktree change The review of 2026-10-01 asked to split these out of the ignore_worktrees change. Neither belongs to it, and both are already on main in another form, so this restores the three files to the branch merge-base: - Makefile.cbm: main links cbm-fuzz with -fsanitize-link-c++-runtime. - src/cypher/cypher.c and tests/test_cypher.c: main frees a partially parsed MATCH pattern since 70543ba82, with its own regression test cypher_parse_error_frees_partial_pattern. Signed-off-by: Martin Vogel --- Makefile.cbm | 4 ++-- src/cypher/cypher.c | 25 ++++++++----------------- tests/test_cypher.c | 19 ------------------- 3 files changed, 10 insertions(+), 38 deletions(-) diff --git a/Makefile.cbm b/Makefile.cbm index b2a655505..ab5c89db7 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1381,10 +1381,10 @@ fuzz: CC=$(FUZZ_CC) CXX=$(FUZZ_CXX) SANITIZE="$(FUZZ_SANITIZE)" $(BUILD_DIR)/cbm-fuzz: $(FUZZ_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) - $(CXX) $(CFLAGS_TEST) -x c -fsanitize=fuzzer -o $@ \ + $(CC) $(CFLAGS_TEST) -fsanitize=fuzzer -o $@ \ $(FUZZ_SRC) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ - -x none $(OBJS_VENDORED_TEST) \ + $(OBJS_VENDORED_TEST) \ $(LDFLAGS_TEST) # Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads. diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 42ecb93c5..7c7a2f987 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -1862,8 +1862,6 @@ static int parse_return(parser_t *p, cbm_return_clause_t **out) { return parse_return_or_with(p, out, false); } -static void free_pattern(cbm_pattern_t *pat); - /* Parse a single MATCH pattern into pat */ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { memset(pat, 0, sizeof(*pat)); @@ -1872,38 +1870,31 @@ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { pat->nodes = malloc(node_cap * sizeof(cbm_node_pattern_t)); pat->rels = calloc(rel_cap, sizeof(cbm_rel_pattern_t)); - /* Count the slot before parsing so even partially filled nodes and - * relationships are released when a malformed pattern is rejected. */ - pat->node_count = SKIP_ONE; if (parse_node(p, &pat->nodes[0]) < 0) { - goto fail; + return CBM_NOT_FOUND; } + pat->node_count = SKIP_ONE; while (check(p, TOK_DASH) || check(p, TOK_LT)) { if (pat->rel_count >= rel_cap) { rel_cap *= PAIR_LEN; pat->rels = safe_realloc(pat->rels, rel_cap * sizeof(cbm_rel_pattern_t)); } - pat->rel_count++; - if (parse_rel(p, &pat->rels[pat->rel_count - SKIP_ONE]) < 0) { - goto fail; + if (parse_rel(p, &pat->rels[pat->rel_count]) < 0) { + return CBM_NOT_FOUND; } + pat->rel_count++; if (pat->node_count >= node_cap) { node_cap *= PAIR_LEN; pat->nodes = safe_realloc(pat->nodes, node_cap * sizeof(cbm_node_pattern_t)); } - pat->node_count++; - if (parse_node(p, &pat->nodes[pat->node_count - SKIP_ONE]) < 0) { - goto fail; + if (parse_node(p, &pat->nodes[pat->node_count]) < 0) { + return CBM_NOT_FOUND; } + pat->node_count++; } return 0; - -fail: - free_pattern(pat); - memset(pat, 0, sizeof(*pat)); - return CBM_NOT_FOUND; } /* Parse UNWIND [...] AS var clause into query */ diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 5d198a0ef..7c251bd9b 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -274,24 +274,6 @@ TEST(cypher_parse_accepts_single_with_clause) { PASS(); } -TEST(cypher_parse_rejects_partial_patterns) { - const char *queries[] = { - "MATCH p = (a:Class)-[:INHERITS*1..3]->(b:Class) RETURN a.name, length(p)", - "MATCH (a)-[:CALLS]->(b) MATCH (c)-[:CALLS*]-> RETURN a.name", - "MATCH (a)-[:CALLS|]->(b)", - }; - for (size_t i = 0; i < sizeof(queries) / sizeof(queries[0]); i++) { - cbm_query_t *q = NULL; - char *err = NULL; - int rc = cbm_cypher_parse(queries[i], &q, &err); - ASSERT_NEQ(rc, 0); - ASSERT_NULL(q); - ASSERT_NOT_NULL(err); - free(err); - } - PASS(); -} - TEST(cypher_parse_relationship_outbound) { cbm_query_t *q = NULL; char *err = NULL; @@ -4860,7 +4842,6 @@ SUITE(cypher) { RUN_TEST(cypher_parse_rejects_trailing_tokens); RUN_TEST(cypher_parse_rejects_second_with_clause); RUN_TEST(cypher_parse_accepts_single_with_clause); - RUN_TEST(cypher_parse_rejects_partial_patterns); RUN_TEST(cypher_parse_relationship_outbound); RUN_TEST(cypher_parse_relationship_inbound); RUN_TEST(cypher_parse_relationship_any); From 77310d5f6f57a63a4bd0d65f6fc4035e41bc7cf3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:04:15 +0100 Subject: [PATCH 34/36] fix(cli): keep the hook default path untouched while ignore_worktrees is off The lifecycle hook now reads ignore_worktrees first and does nothing else for it while the key is off; the project lookup is then the one the hook did before the key existed. - cbm_mcp_ignore_worktrees_runtime_enabled() replaces cbm_mcp_server_attach_runtime_config(). It never attaches a store to the server, which may be the daemon session server. A server with a store answers from it. Otherwise the runtime store is opened only to read this key, and only when it already exists, so a hook event never creates _config.db. - cwd canonicalization, the linked-worktree probe and the parent-graph check moved into ha_resolve_project_ignoring_worktrees(), which runs only when the key is on. Test cli_hook_ignore_worktrees_default_off_does_no_worktree_work was red on the previous head (a default-off event created _config.db). It also pins, through a test-API counter, that neither an absent store nor a store without the key reaches the worktree gate, and that the key on reaches it once. mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone replaces the test of the removed helper. Signed-off-by: Martin Vogel --- src/cli/cli.h | 3 ++ src/cli/hook_augment.c | 93 ++++++++++++++++++++++++++---------------- src/mcp/mcp.c | 38 ++++++++++++----- src/mcp/mcp.h | 6 --- src/mcp/mcp_internal.h | 5 +++ tests/test_cli.c | 92 +++++++++++++++++++++++++++++++++++++++++ tests/test_mcp.c | 55 ++++++++++++++++--------- 7 files changed, 220 insertions(+), 72 deletions(-) diff --git a/src/cli/cli.h b/src/cli/cli.h index 4ac403387..6e0f85ff3 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -264,6 +264,9 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, bool worktree_ignored); +/* Counts lifecycle calls that reached the ignore_worktrees resolution (cwd + * canonicalization and the linked-worktree probe). NULL stops counting. */ +void cbm_hook_set_worktree_gate_counter_for_testing(int *counter); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows); void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter); diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 384ba8467..61bc0100b 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -17,6 +17,7 @@ */ #include "cli/cli.h" +#include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/mem.h" @@ -1002,6 +1003,10 @@ static char *ha_resolve_indexed_project_with_root(cbm_mcp_server_t *srv, const c return ha_registry_project_for_path(srv, cwd, root_out, root_out_size); } +static char *ha_resolve_indexed_project(cbm_mcp_server_t *srv, const char *cwd) { + return ha_resolve_indexed_project_with_root(srv, cwd, NULL, 0U); +} + static const char *ha_hook_event_name(yyjson_val *root) { const char *event = ha_obj_str(root, "hook_event_name"); return event ? event : ha_obj_str(root, "hookEventName"); @@ -1459,6 +1464,41 @@ static bool ha_invocation_supported(ha_lifecycle_dialect_t dialect, const char * return !forced_event || ha_dialect_event_supported(dialect, forced_event); } +#ifdef CBM_CLI_ENABLE_TEST_API +static CBM_TLS int *g_ha_worktree_gate_counter = NULL; +#endif + +/* Project resolution once ignore_worktrees is on; the caller runs this only + * after reading the key, so the default path does none of it. A symlink alias + * has no .git of its own, and walking its lexical parents leaves the linked + * worktree. index_repository canonicalizes before the same gate, so the hook + * must too or it tells the agent to index a path the setting will refuse. The + * setting refuses a linked worktree even when it sits inside an indexed + * checkout or inside another linked worktree, so a match is kept only when its + * root belongs to this cwd's own linked worktree, not a parent graph. */ +static char *ha_resolve_project_ignoring_worktrees(cbm_mcp_server_t *srv, const char *cwd, + bool *drop_ancestor, bool *worktree_ignored) { +#ifdef CBM_CLI_ENABLE_TEST_API + if (g_ha_worktree_gate_counter) { + (*g_ha_worktree_gate_counter)++; + } +#endif + char canonical_cwd[4096]; + if (ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) { + cwd = canonical_cwd; + } + char project_root[4096]; + project_root[0] = '\0'; + char *project = + ha_resolve_indexed_project_with_root(srv, cwd, project_root, sizeof(project_root)); + bool linked = cbm_git_is_linked_worktree(cwd); + /* Drop a parent graph without a second free(): this file's raw free count + * is ratcheted, and the caller's single free(project) still releases it. */ + *drop_ancestor = linked && project && !cbm_git_same_linked_worktree(cwd, project_root); + *worktree_ignored = linked && (!project || *drop_ancestor); + return project; +} + static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root, const char *forced_event, ha_lifecycle_dialect_t dialect) { if (!root || !yyjson_is_obj(root)) { @@ -1471,47 +1511,24 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root char cwd_buffer[4096]; cbm_mcp_server_t *owned_server = NULL; - cbm_config_t *owned_config = NULL; if (!srv) { owned_server = cbm_mcp_server_new(NULL); srv = owned_server; } - /* cbm_mcp_server_new(NULL) does not attach the runtime store. The production - * hook uses that constructor, so ignore_worktrees would stay at its default - * (off) and the "not indexed" note would still tell the agent to run - * index_repository in a linked worktree the setting refuses. Load the store - * only when the caller has not already set one, then drop it before return - * so a caller-owned server is not left pointing at a closed config. */ - if (srv) { - owned_config = cbm_mcp_server_attach_runtime_config(srv); - } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); - /* A symlink alias has no .git of its own, and walking its lexical parents - * leaves the linked worktree. index_repository canonicalizes before the - * same gate, so the hook must too or it tells the agent to index a path - * the setting will refuse. */ - char canonical_cwd[4096]; - if (cwd && ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) { - cwd = canonical_cwd; - } - char project_root[4096]; - project_root[0] = '\0'; - char *project = srv && cwd ? ha_resolve_indexed_project_with_root(srv, cwd, project_root, - sizeof(project_root)) - : NULL; - /* ignore_worktrees refuses a linked worktree, including one nested inside an - * indexed checkout or inside another linked worktree. Keep a match only when - * its root belongs to this cwd's own linked worktree, not a parent graph. */ - bool ignore_linked = - srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); - /* Drop a parent graph without a second free(): this file's raw free count - * is ratcheted, and the single free(project) below still releases it. */ - bool drop_ancestor = - ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root); - bool worktree_ignored = ignore_linked && (!project || drop_ancestor); - if (owned_config) { - cbm_mcp_server_set_config(srv, NULL); - cbm_config_close(owned_config); + /* Read ignore_worktrees first and do nothing else for it while it is off. + * cbm_mcp_server_new(NULL) has no store, and srv may be the daemon's + * session server, so the read leaves srv untouched (see + * cbm_mcp_ignore_worktrees_runtime_enabled). Off, this is the resolution + * the hook did before the key existed. */ + bool drop_ancestor = false; + bool worktree_ignored = false; + char *project = NULL; + if (srv && cwd && cbm_mcp_ignore_worktrees_runtime_enabled(srv)) { + project = + ha_resolve_project_ignoring_worktrees(srv, cwd, &drop_ancestor, &worktree_ignored); + } else { + project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; } cbm_mcp_server_free(owned_server); @@ -1670,6 +1687,10 @@ const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, return ha_no_project_index_guidance(event, worktree_ignored); } +void cbm_hook_set_worktree_gate_counter_for_testing(int *counter) { + g_ha_worktree_gate_counter = counter; +} + bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) { return ha_parse_bash_search_pattern(cmd, out, out_sz); } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index c45ec6de8..00479171b 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1849,17 +1849,6 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } -struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv) { - if (!srv || srv->config) { - return NULL; - } - cbm_config_t *config = cbm_config_open(cbm_resolve_cache_dir()); - if (config) { - cbm_mcp_server_set_config(srv, config); - } - return config; -} - #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv, cbm_mcp_auto_index_count_test_hook_fn hook, @@ -17928,6 +17917,33 @@ bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) { return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false); } +/* The same key for a caller whose server may have no store: the stand-alone + * lifecycle hook builds one with cbm_mcp_server_new(NULL), and the daemon lends + * its session server, which this must not modify. A server with a store answers + * from it. Otherwise the runtime store is opened only to read this key, and only + * when it exists: a missing store leaves every key at its default, so this read + * never creates one. */ +bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv) { + if (srv && srv->config) { + return cbm_mcp_ignore_worktrees_enabled(srv); + } + const char *cache_dir = cbm_resolve_cache_dir(); + if (!cache_dir) { + return false; + } + char store_path[CBM_SZ_4K]; + int written = snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache_dir); + cbm_path_info_t info; + if (written < 0 || (size_t)written >= sizeof(store_path) || + cbm_path_info_utf8(store_path, &info) != CBM_PATH_INFO_OK || !info.is_regular) { + return false; + } + cbm_config_t *config = cbm_config_open(cache_dir); + bool enabled = config && cbm_config_get_bool(config, CBM_CONFIG_IGNORE_WORKTREES, false); + cbm_config_close(config); + return enabled; +} + /* Register the session project with the background watcher for ongoing * change detection — unless auto_watch is disabled. */ static void register_watcher_if_enabled(cbm_mcp_server_t *srv) { diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index f2fe09b26..31ffd69b0 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -182,12 +182,6 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w); /* Set external config store reference (for auto_index setting). Not owned. */ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg); -/* Open the runtime config and attach it when srv has none. The returned store - * is owned by the caller, who must clear it with cbm_mcp_server_set_config - * before closing. Returns NULL when a config is already set or the store - * cannot be opened. */ -struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv); - /* Set an explicit session context for an embedded/daemon-backed server. * session_root is copied and its project name is derived using the same naming * rule as indexing. allowed_root is copied when non-NULL; an explicit NULL diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index 8e78f9f40..fc4f37cb4 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -68,6 +68,11 @@ bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, * automatic index of a linked worktree should be skipped. */ bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv); +/* The same key when srv may have no store (the lifecycle hook). Never attaches + * a store to srv and never creates the runtime store; reads it only when it + * already exists. */ +bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv); + /* detect_changes seed scoping (#1363): does `node`'s line range overlap any * recorded hunk for `file`? Exposed for direct unit testing of the overlap * logic, independent of the git/subprocess/index plumbing around it. */ diff --git a/tests/test_cli.c b/tests/test_cli.c index af140c53f..586b78884 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -10944,6 +10945,96 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); } + +/* With ignore_worktrees unset, the lifecycle hook must do what it did before + * the key existed. It may read the key, but it must not create a runtime + * config store just to find the key unset, and it must not canonicalize the + * cwd or probe for a linked worktree. Turning the key on is the positive + * control: the same call then reaches the worktree gate exactly once. */ +TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work) { + char tmpdir[256]; + char *raw = th_mktempdir("cbm_hook_wt_off"); + if (!raw) + FAIL("th_mktempdir returned NULL"); + snprintf(tmpdir, sizeof(tmpdir), "%s", raw); + char cache[512]; + char maindir[512]; + char wtdir[512]; + char store_path[600]; + snprintf(cache, sizeof(cache), "%s/cache", tmpdir); + snprintf(maindir, sizeof(maindir), "%s/checkout-main", tmpdir); + snprintf(wtdir, sizeof(wtdir), "%s/checkout-side", tmpdir); + snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache); + test_mkdirp(cache); + test_mkdirp(maindir); + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "cd \"%s\" && git init -q && git config user.email t@t && git config user.name t && " + ": > f.txt && git add f.txt && git commit -qm init && " + "git worktree add -q \"%s\" -b wtoff", + maindir, wtdir); + if (system(cmd) != 0) { + th_rmtree(tmpdir); + SKIP_PLATFORM("git worktree add unavailable"); + } + + char *saved_cache = save_test_env("CBM_CACHE_DIR"); + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char input[1024]; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir); + struct stat st; + int gate_calls = 0; + cbm_hook_set_worktree_gate_counter_for_testing(&gate_calls); + bool store_absent_before = stat(store_path, &st) != 0; + char *off_out = cbm_hook_augment_lifecycle_json(input); + bool off_note = off_out && strstr(off_out, "Run index_repository") && + !strstr(off_out, "ignore_worktrees is enabled"); + free(off_out); + bool store_absent_after = stat(store_path, &st) != 0; + int off_gate_calls = gate_calls; + + /* A store that leaves the key unset may be read for the key, nothing more. */ + cbm_config_t *cfg = cbm_config_open(cache); + bool other_key_set = cfg && cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "true") == 0; + cbm_config_close(cfg); + gate_calls = 0; + char *unset_out = other_key_set ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool unset_note = unset_out && strstr(unset_out, "Run index_repository") && + !strstr(unset_out, "ignore_worktrees is enabled"); + free(unset_out); + int unset_gate_calls = gate_calls; + + cfg = cbm_config_open(cache); + bool enabled = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(cfg); + gate_calls = 0; + char *on_out = enabled ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool on_note = on_out && strstr(on_out, "ignore_worktrees is enabled"); + free(on_out); + int on_gate_calls = gate_calls; + cbm_hook_set_worktree_gate_counter_for_testing(NULL); + + restore_test_env("CBM_CACHE_DIR", saved_cache); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1", + maindir, wtdir); + (void)system(cmd); + th_rmtree(tmpdir); + + ASSERT_TRUE(store_absent_before); + ASSERT_TRUE(off_note); + if (!store_absent_after) + FAIL("a default-off lifecycle event must not create the runtime config store"); + ASSERT_EQ(off_gate_calls, 0); + ASSERT_TRUE(other_key_set); + ASSERT_TRUE(unset_note); + ASSERT_EQ(unset_gate_calls, 0); + ASSERT_TRUE(enabled); + ASSERT_TRUE(on_note); + ASSERT_EQ(on_gate_calls, 1); + PASS(); +} #endif TEST(cli_hook_session_sanitizes_untrusted_project_metadata) { @@ -16975,6 +17066,7 @@ SUITE(cli) { #ifndef _WIN32 RUN_TEST(cli_hook_worktree_cwd_resolves_own_indexed_project); RUN_TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent); + RUN_TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work); #endif RUN_TEST(cli_hook_session_sanitizes_untrusted_project_metadata); RUN_TEST(cli_hook_ownership_requires_exact_command_identity); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 8eab275a2..257e799ad 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -10,6 +10,7 @@ #include "../src/foundation/log.h" #include "../src/foundation/platform.h" /* cbm_file_size */ #include "../src/foundation/mem.h" /* cbm_mem_set_budget_for_tests — over-budget seam */ +#include "../src/foundation/str_util.h" #include "../src/foundation/subprocess.h" #include "../src/foundation/workspace.h" #include "../src/mcp/compact_out.h" @@ -1202,37 +1203,53 @@ TEST(mcp_initialize_response) { PASS(); } -TEST(mcp_attach_runtime_config_respects_caller_ownership) { - char *cache = th_mktempdir("cbm_mcp_runtime_config"); - ASSERT_NOT_NULL(cache); +/* The lifecycle hook reads ignore_worktrees through this reader. It must never + * attach a store to the server (the daemon lends its session server) and never + * create the runtime store; a server with its own store answers from it. */ +TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone) { + char *raw = th_mktempdir("cbm_mcp_runtime_config"); + ASSERT_NOT_NULL(raw); + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + char own_dir[320]; + snprintf(own_dir, sizeof(own_dir), "%s/own", cache); + char store_path[400]; + snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache); const char *saved = getenv("CBM_CACHE_DIR"); char *saved_copy = saved ? strdup(saved) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - cbm_config_t *config = cbm_config_open(cache); cbm_mcp_server_t *server = cbm_mcp_server_new(NULL); - bool stored = config && cbm_config_set(config, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; - bool default_off = server && !cbm_mcp_ignore_worktrees_enabled(server); - cbm_config_t *attached = server ? cbm_mcp_server_attach_runtime_config(server) : NULL; - bool enabled = attached && cbm_mcp_ignore_worktrees_enabled(server); - bool not_replaced = server && cbm_mcp_server_attach_runtime_config(server) == NULL; + struct stat st; + bool absent_off = server && !cbm_mcp_ignore_worktrees_runtime_enabled(server); + bool still_absent = stat(store_path, &st) != 0; + + cbm_config_t *runtime = cbm_config_open(cache); + bool stored = runtime && cbm_config_set(runtime, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(runtime); + bool runtime_on = server && cbm_mcp_ignore_worktrees_runtime_enabled(server); + bool server_untouched = server && !cbm_mcp_ignore_worktrees_enabled(server); + + cbm_config_t *own = cbm_config_open(own_dir); + if (server && own) { + cbm_mcp_server_set_config(server, own); + } + bool own_store_wins = server && own && !cbm_mcp_ignore_worktrees_runtime_enabled(server); if (server) { - cbm_mcp_server_set_config(server, config); + cbm_mcp_server_set_config(server, NULL); } - cbm_config_close(attached); - bool caller_config_kept = server && cbm_mcp_server_attach_runtime_config(server) == NULL && - cbm_mcp_ignore_worktrees_enabled(server); cbm_mcp_server_free(server); - cbm_config_close(config); + cbm_config_close(own); restore_cache_dir(saved_copy); free(saved_copy); int removed = th_rmtree(cache); + ASSERT(absent_off); + ASSERT(still_absent); ASSERT(stored); - ASSERT(default_off); - ASSERT(enabled); - ASSERT(not_replaced); - ASSERT(caller_config_kept); + ASSERT(runtime_on); + ASSERT(server_untouched); + ASSERT(own_store_wins); ASSERT_EQ(removed, 0); PASS(); } @@ -21182,7 +21199,7 @@ SUITE(mcp) { /* MCP protocol helpers */ RUN_TEST(mcp_initialize_response); - RUN_TEST(mcp_attach_runtime_config_respects_caller_ownership); + RUN_TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone); RUN_TEST(mcp_tools_list); RUN_TEST(mcp_tools_help_list_matches_registry); RUN_TEST(mcp_tools_list_latest_metadata); From 04ed0240e9f62778b578444f67494421c41a064e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:04:21 +0100 Subject: [PATCH 35/36] docs(config): state how ignore_worktrees treats subdirectories The review of 2026-10-01 asked for this to be a stated decision rather than a side effect. Detection walks up from the given path to the nearest .git entry, so a subdirectory of a linked worktree is treated as that worktree: index_repository on it is refused, and a session root or hook working directory inside it is handled the same way. The walk stops at the first .git; outside any checkout it runs to the filesystem root one directory at a time. None of it runs while the key is off. Signed-off-by: Martin Vogel --- docs/CONFIGURATION.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 99a2a328a..181a8aad4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -154,6 +154,15 @@ The main checkout of the same repository is unaffected, as are ordinary clones and submodules. Detection is git plumbing only: a linked worktree's `.git` is a file pointing at a gitdir that contains a `commondir` entry. +A subdirectory counts as the worktree it sits in. Detection walks up from the +given path to the nearest `.git` entry, so `index_repository` on any +subdirectory of a linked worktree is refused the same way, and a session root or +hook working directory inside one is treated as that worktree. The walk stops at +the first `.git` it finds; a main checkout or a submodule ends it as "not a +worktree". A path outside any git checkout is checked one directory at a time up +to the filesystem root and is never treated as a worktree. None of this runs +while the key is off. + The default is `false`, so indexing behavior is unchanged unless you opt in. ## 3. UI Settings From 141b25af7b46ffff462615b8003d3f27ee263e45 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:16:53 +0100 Subject: [PATCH 36/36] fix(mcp): do not shadow cache_dir() in the ignore_worktrees reader cppcheck 2.20 (the CI version) reports shadowFunction: the local cache_dir in cbm_mcp_ignore_worktrees_runtime_enabled() shadows the static cache_dir() helper in mcp.c. Rename the local to store_dir; no behavior change. Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 4166b74e6..c82f90f11 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -21589,18 +21589,18 @@ bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv) { if (srv && srv->config) { return cbm_mcp_ignore_worktrees_enabled(srv); } - const char *cache_dir = cbm_resolve_cache_dir(); - if (!cache_dir) { + const char *store_dir = cbm_resolve_cache_dir(); + if (!store_dir) { return false; } char store_path[CBM_SZ_4K]; - int written = snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache_dir); + int written = snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, store_dir); cbm_path_info_t info; if (written < 0 || (size_t)written >= sizeof(store_path) || cbm_path_info_utf8(store_path, &info) != CBM_PATH_INFO_OK || !info.is_regular) { return false; } - cbm_config_t *config = cbm_config_open(cache_dir); + cbm_config_t *config = cbm_config_open(store_dir); bool enabled = config && cbm_config_get_bool(config, CBM_CONFIG_IGNORE_WORKTREES, false); cbm_config_close(config); return enabled;