From e9f87efe6abff44a7130fa3db9a9a409d22fd556 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Tue, 18 Aug 2026 16:13:14 +0800 Subject: [PATCH 01/65] 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 bf51a5a8c0..9e5564cc64 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 d7468af5a0..edf1b63d70 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 0000000000..83e853e659 --- /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 0000000000..2ca944df23 --- /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 0000000000..f6de32943e --- /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 4ab1f5155d..7cf05a0c41 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 a50af9de43..f02b9cc8b0 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 ba6f26d45b..883ba4ed66 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 0000000000..23c37314e8 --- /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 789c60c86bc75b342c52ddedcaf174d12ccdfa73 Mon Sep 17 00:00:00 2001 From: tmonestudio Date: Sat, 12 Sep 2026 21:52:00 -0300 Subject: [PATCH 02/65] feat(mcp): report truthful index freshness from checkout evidence Signed-off-by: tmonestudio --- src/git/git_context.c | 218 +++++++++++++ src/git/git_context.h | 36 +++ src/mcp/mcp.c | 166 +++++++++- src/pipeline/pipeline.c | 5 + src/pipeline/pipeline_incremental.c | 2 + src/pipeline/pipeline_internal.h | 2 + src/store/store.c | 77 ++++- src/store/store.h | 5 + tests/test_git_context.c | 275 +++++++++++++++- tests/test_mcp.c | 472 +++++++++++++++++++++++++++- tests/test_store_nodes.c | 70 +++++ 11 files changed, 1311 insertions(+), 17 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index f739c46e6a..df8070a23f 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -16,6 +16,17 @@ enum { GIT_OUTPUT_MAX = 4096, }; +/* Bounded worktree-status stream constants. + * STATUS_FIELD_MAX is the largest single NUL-delimited record field we + * buffer; a field at/over the bound is treated as a malformed stream and the + * whole snapshot fails closed. PATH components of real repos are far below + * this even with long paths. */ +enum { + STATUS_HEADER_LEN = 3, /* porcelain-v1 record prefix "XY " */ + STATUS_CHUNK_MAX = CBM_SZ_4K, + STATUS_FIELD_MAX = CBM_SZ_16K, +}; + static char *git_strdup(const char *s) { if (!s) { s = ""; @@ -409,3 +420,210 @@ int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_ } return off; } + +/* ── Bounded worktree status ────────────────────────────────────────────── + * git status --porcelain=v1 -z --untracked-files=all is parsed record by + * record, NUL-delimited, over a bounded chunk buffer. The full stream is + * counted but only max_samples paths per class (tracked / untracked) are + * ever retained, so a huge dirty tree cannot grow memory. Rename/copy + * records carry a second NUL-terminated path (the source); it is consumed + * without being counted so one rename counts once. */ + +typedef struct { + cbm_worktree_status_t *out; + int max_samples; + bool expecting_source; /* next field is the source path of an R/C record */ + bool parse_ok; + char partial[STATUS_FIELD_MAX]; + size_t partial_len; +} status_parser_t; + +/* porcelain-v1 status columns (X/Y): documented codes plus the lowercase + * submodule variants and type-change 'T'. Anything else is not a record + * header, so a malformed stream fails closed instead of being misread. */ +static bool status_code_ok(char c) { + switch (c) { + case ' ': + case 'M': + case 'A': + case 'D': + case 'R': + case 'C': + case 'U': + case '?': + case '!': + case 'T': + case 'm': + case 'a': + case 'd': + case 'r': + case 'c': + case 'u': + case 't': + return true; + default: + return false; + } +} + +/* Retain one bounded sample; the running total always advances so the caller + * sees the true stream count even when samples are capped or OOM. */ +static void status_add_sample(int *total, char ***samples, int *sample_count, + bool *truncated, int max_samples, const char *path, + size_t path_len) { + (*total)++; + if (*sample_count >= max_samples) { + *truncated = true; + return; + } + char *copy = (char *)malloc(path_len + 1); + if (!copy) { + *truncated = true; + return; + } + memcpy(copy, path, path_len); + copy[path_len] = '\0'; + char **grown = (char **)realloc(*samples, (size_t)(*sample_count + 1) * sizeof(char *)); + if (!grown) { + free(copy); + *truncated = true; + return; + } + *samples = grown; + (*samples)[*sample_count] = copy; + (*sample_count)++; +} + +/* Parse one complete NUL-delimited field. Returns false on malformed input, + * which fails the whole snapshot closed. */ +static bool status_parse_record(cbm_worktree_status_t *out, status_parser_t *parser, + const char *field, size_t field_len) { + if (parser->expecting_source) { + parser->expecting_source = false; + return true; /* second path of a rename/copy record — not counted */ + } + if (field_len < STATUS_HEADER_LEN || field[2] != ' ' || + !status_code_ok(field[0]) || !status_code_ok(field[1])) { + return false; + } + const char *path = field + STATUS_HEADER_LEN; + size_t path_len = field_len - STATUS_HEADER_LEN; + if (field[0] == '?' && field[1] == '?') { + status_add_sample(&out->untracked_count, &out->untracked_paths, + &out->untracked_sample_count, &out->untracked_truncated, + parser->max_samples, path, path_len); + return true; + } + if (field[0] == 'R' || field[0] == 'C' || field[1] == 'R' || field[1] == 'C') { + parser->expecting_source = true; + } + status_add_sample(&out->tracked_count, &out->tracked_paths, + &out->tracked_sample_count, &out->tracked_truncated, + parser->max_samples, path, path_len); + return true; +} + +/* Feed a chunk of the NUL-delimited stream through the bounded record parser. */ +static void status_feed(status_parser_t *parser, const char *data, size_t data_len) { + size_t i = 0; + while (i < data_len) { + if (parser->partial_len >= STATUS_FIELD_MAX) { + parser->parse_ok = false; /* field exceeded the bound: malformed */ + return; + } + size_t j = i; + while (j < data_len && data[j] != '\0' && + parser->partial_len + (j - i) < STATUS_FIELD_MAX) { + j++; + } + size_t take = j - i; + memcpy(parser->partial + parser->partial_len, data + i, take); + parser->partial_len += take; + i += take; + if (i < data_len && data[i] == '\0') { + if (!status_parse_record(parser->out, parser, parser->partial, + parser->partial_len)) { + parser->parse_ok = false; + return; + } + parser->partial_len = 0; + i++; /* consume the record terminator */ + } else if (i >= data_len) { + return; /* chunk exhausted mid-record */ + } + } +} + +int cbm_git_worktree_status(const char *validated_root, int max_samples, + cbm_worktree_status_t *out) { + if (!out) { + return CBM_NOT_FOUND; + } + memset(out, 0, sizeof(*out)); + if (!validated_root || !validated_root[0] || max_samples < 0) { + return CBM_NOT_FOUND; + } + if (!git_validate_repo_path(validated_root)) { + return CBM_NOT_FOUND; + } + + char cmd[GIT_CMD_MAX]; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + int n = snprintf(cmd, sizeof(cmd), + "git -C \"%s\" status --porcelain=v1 -z --untracked-files=all 2>%s", + validated_root, null_dev); + if (n < 0 || n >= (int)sizeof(cmd)) { + return CBM_NOT_FOUND; + } + + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return 0; /* out is zeroed: available=false */ + } + + status_parser_t parser; + memset(&parser, 0, sizeof(parser)); + parser.out = out; + parser.max_samples = max_samples; + parser.parse_ok = true; + + char chunk[STATUS_CHUNK_MAX]; + size_t got; + bool io_error = false; + while (parser.parse_ok && (got = fread(chunk, 1, sizeof(chunk), fp)) > 0) { + status_feed(&parser, chunk, got); + } + if (ferror(fp)) { + io_error = true; + } + int rc = cbm_pclose(fp); + + if (rc != 0 || !parser.parse_ok || io_error || parser.partial_len > 0) { + /* Fail closed: any deviation from a clean, complete status stream is a + * machine-readable "unavailable", never a clean worktree. */ + cbm_git_worktree_status_free(out); + return 0; + } + + out->available = true; + return 0; +} + +void cbm_git_worktree_status_free(cbm_worktree_status_t *st) { + if (!st) { + return; + } + for (int i = 0; i < st->tracked_sample_count; i++) { + free(st->tracked_paths[i]); + } + free(st->tracked_paths); + for (int i = 0; i < st->untracked_sample_count; i++) { + free(st->untracked_paths[i]); + } + free(st->untracked_paths); + memset(st, 0, sizeof(*st)); +} diff --git a/src/git/git_context.h b/src/git/git_context.h index 876309eb6a..2caa20bba3 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -24,4 +24,40 @@ 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); int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_size); +/* Bounded snapshot of the git worktree status for one validated repo root. + * + * available is true ONLY when `git status --porcelain=v1 -z --untracked-files=all` + * exited 0 AND every NUL-delimited record parsed cleanly. Any other outcome — + * shell-unsafe path, spawn failure, nonzero exit, malformed/oversized record, + * trailing partial record — leaves available=false with zero counts and no + * samples. Callers MUST treat !available as "status unavailable", never as a + * clean worktree. + * + * The entire stream is counted, but only max_samples paths per class (tracked + * / untracked) are ever retained, in stream order; exceeding the cap sets the + * matching *_truncated flag. Rename/copy records are counted once (their two + * NUL-separated paths are consumed without counting the second), and the + * sampled path is the record's NEW path. Ignored files never appear because + * --ignored is not requested. + * + * cbm_git_worktree_status returns 0 when *out has been filled (available tells + * whether the snapshot succeeded) and CBM_NOT_FOUND for invalid arguments + * (NULL root/out or max_samples < 0). The caller owns the returned samples via + * cbm_git_worktree_status_free. */ +typedef struct { + bool available; + int tracked_count; + int untracked_count; + char **tracked_paths; /* heap-owned samples, tracked_sample_count entries */ + int tracked_sample_count; + bool tracked_truncated; + char **untracked_paths; + int untracked_sample_count; + bool untracked_truncated; +} cbm_worktree_status_t; + +int cbm_git_worktree_status(const char *validated_root, int max_samples, + cbm_worktree_status_t *out); +void cbm_git_worktree_status_free(cbm_worktree_status_t *st); + #endif diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index f80d97c6e4..ec8aa9d3b3 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -46,6 +46,8 @@ enum { * promise. The actual cross-platform contract is this deterministic UTF-8 * byte ceiling, applied only at whole semantic-unit boundaries. */ MCP_OUTPUT_BYTES_PER_TOKEN_ESTIMATE = 4, + MCP_STATUS_SAMPLE_MAX = 16, /* per-class path samples in verbose freshness */ + MCP_REASONS_MAX = 8, /* freshness reasons array ceiling (see below) */ }; #define MCP_MS_TO_US 1000LL #define MCP_S_TO_US 1000000LL @@ -6721,13 +6723,169 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args return result; } +/* Emit the bounded worktree-status snapshot inside the freshness block. + * Fail-closed: status_available is false whenever git status could not run + * clean, in which case the API guarantees zero counts and no samples — a + * non-git root or missing git is NEVER presented as a clean worktree. Ignored + * paths are deliberately absent (no --ignored in the underlying query) and + * must not be confused with tracked/untracked sources. */ +static void add_worktree_status_json(yyjson_mut_doc *doc, yyjson_mut_val *freshness, + const cbm_worktree_status_t *st) { + yyjson_mut_obj_add_bool(doc, freshness, "status_available", st->available); + + yyjson_mut_val *tracked = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, tracked, "count", st->tracked_count); + yyjson_mut_val *tpaths = yyjson_mut_arr(doc); + for (int i = 0; i < st->tracked_sample_count; i++) { + yyjson_mut_arr_add_strcpy(doc, tpaths, st->tracked_paths[i]); + } + yyjson_mut_obj_add_val(doc, tracked, "paths", tpaths); + yyjson_mut_obj_add_bool(doc, tracked, "truncated", st->tracked_truncated); + yyjson_mut_obj_add_val(doc, freshness, "tracked_changes", tracked); + + yyjson_mut_val *untracked = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, untracked, "count", st->untracked_count); + yyjson_mut_val *upaths = yyjson_mut_arr(doc); + for (int i = 0; i < st->untracked_sample_count; i++) { + yyjson_mut_arr_add_strcpy(doc, upaths, st->untracked_paths[i]); + } + yyjson_mut_obj_add_val(doc, untracked, "paths", upaths); + yyjson_mut_obj_add_bool(doc, untracked, "truncated", st->untracked_truncated); + yyjson_mut_obj_add_val(doc, freshness, "untracked_source", untracked); +} + +/* BT-240: fail-closed freshness verdict against the indexed-checkout identity + * recorded with the DB at the successful staged-generation boundary. A live + * checkout SHA is not proof of the generation that produced graph content, so + * the verdict comes from that recorded identity, the live git HEAD and the + * bounded worktree status (tracked/untracked deltas): + * - no indexed SHA → unknown / unavailable + * - indexed SHA differs from the live git HEAD → stale / mismatch + * - tracked changes present → stale / tracked + * - untracked sources present → prevents current + * - equal SHA + status available + no changes → current + * Report-only: it never triggers indexing. Verbose-only and read-only; the + * live checkout is resolved fresh from the project root (never mutated). */ +static void add_index_freshness_json(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_store_t *store, + const char *project, const char *root_path, + const char *indexed_generation) { + yyjson_mut_val *freshness = yyjson_mut_obj(doc); + if (indexed_generation && indexed_generation[0]) { + yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_generation", indexed_generation); + } else { + yyjson_mut_obj_add_str(doc, freshness, "indexed_generation", ""); + } + + cbm_coverage_meta_t meta = {0}; + bool have_meta = store && cbm_store_coverage_meta_get(store, project, &meta) == CBM_STORE_OK; + const char *indexed_sha = NULL; + if (have_meta && meta.indexed_checkout_sha && meta.indexed_checkout_sha[0]) { + indexed_sha = meta.indexed_checkout_sha; + } + + cbm_git_context_t ctx = {0}; + (void)cbm_git_context_resolve(root_path, &ctx); + const char *checkout_sha = ctx.head_sha && ctx.head_sha[0] ? ctx.head_sha : NULL; + + cbm_worktree_status_t st = {0}; + int status_rc = cbm_git_worktree_status(root_path, MCP_STATUS_SAMPLE_MAX, &st); + bool status_available = status_rc == 0 && st.available; + add_worktree_status_json(doc, freshness, &st); + + if (indexed_sha) { + yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_checkout_sha", indexed_sha); + } else { + yyjson_mut_obj_add_null(doc, freshness, "indexed_checkout_sha"); + } + if (checkout_sha) { + yyjson_mut_obj_add_strcpy(doc, freshness, "checkout_sha", checkout_sha); + } else { + yyjson_mut_obj_add_null(doc, freshness, "checkout_sha"); + } + + /* Verdict/reasons composition. Stale reasons (identity mismatch, tracked + * changes) win over everything; a missing indexed identity is unknown + * regardless of live state; status unavailability and untracked sources + * both prevent "current" but only yield stale when a stale reason is + * already present. The reasons array keeps every applicable code in a + * stable order; the singular reason stays as the first (dominant) one for + * compatibility with consumers that read it directly. */ + bool sha_present = indexed_sha != NULL; + bool live_present = checkout_sha != NULL; + bool sha_mismatch = sha_present && live_present && strcmp(indexed_sha, checkout_sha) != 0; + bool has_tracked = st.tracked_count > 0; + bool has_untracked = st.untracked_count > 0; + bool has_stale = sha_mismatch || has_tracked; + + const char *verdict; + if (!sha_present) { + verdict = "unknown"; + } else if (has_stale) { + verdict = "stale"; + } else if (!live_present) { + verdict = "unknown"; + } else if (!status_available) { + verdict = "unknown"; + } else if (has_untracked) { + verdict = "unknown"; + } else { + verdict = "current"; + } + + const char *reasons[MCP_REASONS_MAX]; + int n_reasons = 0; + if (!sha_present) { + reasons[n_reasons++] = "indexed_checkout_unavailable"; + } + if (sha_mismatch) { + reasons[n_reasons++] = "indexed_checkout_mismatch"; + } + if (has_tracked) { + reasons[n_reasons++] = "tracked_changes_present"; + } + if (!status_available) { + reasons[n_reasons++] = "status_unavailable"; + } + if (has_untracked) { + reasons[n_reasons++] = "untracked_not_indexed"; + } + if (n_reasons == 0) { + reasons[n_reasons++] = "indexed_checkout_current"; + } + const char *reason = reasons[0]; + + const char *recommended_action; + if (strcmp(verdict, "current") == 0) { + recommended_action = "use_graph"; + } else if (strcmp(verdict, "stale") == 0) { + recommended_action = "reindex_to_match_checkout"; + } else { + recommended_action = "reindex_to_record_indexed_checkout"; + } + yyjson_mut_obj_add_str(doc, freshness, "verdict", verdict); + yyjson_mut_obj_add_str(doc, freshness, "reason", reason); + yyjson_mut_val *reasons_val = yyjson_mut_arr(doc); + for (int i = 0; i < n_reasons; i++) { + yyjson_mut_arr_add_str(doc, reasons_val, reasons[i]); + } + yyjson_mut_obj_add_val(doc, freshness, "reasons", reasons_val); + yyjson_mut_obj_add_str(doc, freshness, "recommended_action", recommended_action); + yyjson_mut_obj_add_val(doc, root, "freshness", freshness); + + if (have_meta) { + cbm_store_coverage_meta_clear(&meta); + } + cbm_git_worktree_status_free(&st); + cbm_git_context_free(&ctx); +} + static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); cbm_store_t *store = resolve_store(srv, project); REQUIRE_STORE(store, project); - /* The git context block (worktree/shadow path variants) only matters when - * debugging index-location issues — gate it so the common status call - * stays lean. */ + /* The git context block (worktree/shadow path variants) and the freshness + * verdict only matter when debugging index-location issues — gate them so + * the common status call stays lean. */ bool verbose = cbm_mcp_get_bool_arg(args, "verbose"); char *diagnostics = cbm_mcp_get_string_arg(args, "diagnostics"); int coverage_samples = 0; @@ -6758,6 +6916,8 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { proj_info.indexed_at ? proj_info.indexed_at : ""); if (verbose) { add_git_context_json(doc, root, proj_info.root_path); + add_index_freshness_json(doc, root, store, project, proj_info.root_path, + proj_info.indexed_at); } } add_coverage_report(doc, root, store, project, have_proj_info ? proj_info.indexed_at : NULL, diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 25b1c2eb74..44ddb05983 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -460,6 +460,10 @@ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p) { return p ? p->repo_path : NULL; } +const char *cbm_pipeline_indexed_checkout_sha(const cbm_pipeline_t *p) { + return p ? p->git_ctx.head_sha : NULL; +} + atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p) { return p ? p->cancelled : NULL; } @@ -2214,6 +2218,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas .ignored_files_total = p->ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, + .indexed_checkout_sha = p->git_ctx.head_sha, }, .surface_rows = p->surface_rows, .surface_row_count = p->surface_row_count, diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index bf71a1f847..a520c75aa6 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -2329,6 +2329,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .ignored_files_total = run_ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, + .indexed_checkout_sha = cbm_pipeline_indexed_checkout_sha(p), }, .surface_rows = NULL, .surface_row_count = 0, @@ -2885,6 +2886,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .ignored_files_total = run_ignored_total, .coverage_version = CBM_SEMANTIC_INDEX_VERSION, .hash_records_complete = true, + .indexed_checkout_sha = cbm_pipeline_indexed_checkout_sha(p), }; /* Publish surfaces: the surviving previous rows plus this run's fresh * ones (closure route). The legacy test route publishes none — its diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 8deb38abac..2983a24ca3 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -834,6 +834,8 @@ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *row /* Pipeline accessors for incremental use */ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p); +/* Borrowed checkout identity captured and validated for this pipeline run. */ +const char *cbm_pipeline_indexed_checkout_sha(const cbm_pipeline_t *p); atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p); /* Record committed graph size (#334 gate axis) from the incremental path, * which cannot see the opaque cbm_pipeline struct. Call before the dump. */ diff --git a/src/store/store.c b/src/store/store.c index fa074cb5db..7bb721bfd1 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -230,6 +230,33 @@ static void iso_now(char *buf, size_t sz) { /* ── Schema ─────────────────────────────────────────────────────── */ +/* Best-effort probe for the BT-240 indexed_checkout_sha column. Returns true + * only when the index_coverage_meta table exists AND exposes the column. + * Writable opens run init_schema (which adds it), so false is expected only on + * read-only legacy DBs opened via the query path — those must keep working and + * simply report no indexed-checkout identity. */ +static bool coverage_meta_has_checkout_column(cbm_store_t *s) { + if (!s || !s->db) { + return false; + } + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, "PRAGMA table_info(index_coverage_meta);", CBM_NOT_FOUND, &stmt, + NULL) != SQLITE_OK) { + return false; + } + bool has = false; + int rc; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const char *name = (const char *)sqlite3_column_text(stmt, SKIP_ONE); + if (name && strcmp(name, "indexed_checkout_sha") == 0) { + has = true; + break; + } + } + sqlite3_finalize(stmt); + return has; +} + static int init_schema(cbm_store_t *s) { const char *ddl = "CREATE TABLE IF NOT EXISTS projects (" @@ -341,7 +368,8 @@ static int init_schema(cbm_store_t *s) { " ignored_files_stored INTEGER NOT NULL DEFAULT 0," " ignored_files_total INTEGER NOT NULL DEFAULT 0," " coverage_version INTEGER NOT NULL DEFAULT 1," - " hash_records_complete INTEGER NOT NULL DEFAULT 0" + " hash_records_complete INTEGER NOT NULL DEFAULT 0," + " indexed_checkout_sha TEXT" ");"; int rc = exec_sql(s, ddl); @@ -349,6 +377,16 @@ static int init_schema(cbm_store_t *s) { return rc; } + /* BT-240 migration: CREATE TABLE IF NOT EXISTS cannot add a column to an + * existing legacy index_coverage_meta, so add it idempotently for any + * writable DB that predates it. Read-only query opens skip init_schema + * entirely and meta_get probes the column separately. */ + if (!coverage_meta_has_checkout_column(s) && + exec_sql(s, "ALTER TABLE index_coverage_meta ADD COLUMN indexed_checkout_sha TEXT;") != + CBM_STORE_OK) { + return CBM_STORE_ERR; + } + /* Schema-compat probe (#768): DBs created before the local_name_gen * discriminator still enforce UNIQUE(source_id,target_id,type) and lack * the column — the widened upsert in cbm_store_insert_edge can neither @@ -4045,11 +4083,12 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, "INSERT INTO index_coverage_meta " "(project, generation, index_mode, recorded_at, recording_status, " " ignored_files_stored, ignored_files_total, coverage_version, " - " hash_records_complete) " - "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " + " hash_records_complete, indexed_checkout_sha) " + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) " "ON CONFLICT(project) DO UPDATE SET generation=?2, index_mode=?3, " "recorded_at=?4, recording_status=?5, ignored_files_stored=?6, " - "ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9;", + "ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9, " + "indexed_checkout_sha=?10;", CBM_NOT_FOUND, &up_meta, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "coverage meta upsert prepare"); (void)exec_sql(s, "ROLLBACK;"); @@ -4064,6 +4103,11 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, sqlite3_bind_int(up_meta, 7, ignored_total); sqlite3_bind_int(up_meta, 8, coverage_version); sqlite3_bind_int(up_meta, 9, meta->hash_records_complete ? 1 : 0); + if (meta->indexed_checkout_sha && meta->indexed_checkout_sha[0]) { + bind_text(up_meta, 10, meta->indexed_checkout_sha); + } else { + sqlite3_bind_null(up_meta, 10); + } int meta_rc = sqlite3_step(up_meta); sqlite3_finalize(up_meta); if (meta_rc != SQLITE_DONE) { @@ -4217,6 +4261,7 @@ void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) { free((char *)meta->index_mode); free((char *)meta->recorded_at); free((char *)meta->recording_status); + free((char *)meta->indexed_checkout_sha); memset(meta, 0, sizeof(*meta)); } @@ -4229,11 +4274,21 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag return CBM_STORE_ERR; } sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, - "SELECT project, generation, index_mode, recorded_at, recording_status, " - "ignored_files_stored, ignored_files_total, coverage_version, " - "hash_records_complete FROM index_coverage_meta WHERE project = ?1;", - CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + /* Legacy read-only DBs (query opens skip init_schema) lack the + * indexed_checkout_sha column — probe and fall back so they still read and + * report no identity instead of erroring. */ + static const char SQL_FULL[] = + "SELECT project, generation, index_mode, recorded_at, recording_status, " + "ignored_files_stored, ignored_files_total, coverage_version, " + "hash_records_complete, indexed_checkout_sha FROM index_coverage_meta " + "WHERE project = ?1;"; + static const char SQL_LEGACY[] = + "SELECT project, generation, index_mode, recorded_at, recording_status, " + "ignored_files_stored, ignored_files_total, coverage_version, " + "hash_records_complete FROM index_coverage_meta WHERE project = ?1;"; + bool has_checkout_column = coverage_meta_has_checkout_column(s); + if (sqlite3_prepare_v2(s->db, has_checkout_column ? SQL_FULL : SQL_LEGACY, CBM_NOT_FOUND, &stmt, + NULL) != SQLITE_OK) { store_set_error_sqlite(s, "coverage meta get prepare"); return CBM_STORE_ERR; } @@ -4249,6 +4304,10 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag out->ignored_files_total = sqlite3_column_int(stmt, 6); out->coverage_version = sqlite3_column_int(stmt, 7); out->hash_records_complete = sqlite3_column_int(stmt, 8) != 0; + if (has_checkout_column) { + const char *sha = (const char *)sqlite3_column_text(stmt, 9); + out->indexed_checkout_sha = sha && sha[0] ? heap_strdup(sha) : NULL; + } sqlite3_finalize(stmt); if (!out->project || !out->generation || !out->index_mode || !out->recorded_at || !out->recording_status) { diff --git a/src/store/store.h b/src/store/store.h index b6ce844f75..a111058b6f 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -662,6 +662,11 @@ typedef struct { int ignored_files_total; int coverage_version; bool hash_records_complete; + /* Git checkout identity frozen at the successful staged-generation + * boundary (BT-240). NULL/"" when the run had no git context (non-git + * repo) or the DB predates the column. Owned by the row: meta_get + * allocates it, meta_clear frees it. */ + const char *indexed_checkout_sha; } cbm_coverage_meta_t; /* Replace the project's coverage rows in one transaction, then prune rows for diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a50..3e6ebc113f 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -8,8 +8,10 @@ * (input_path), not to worktree_root. Joining it with worktree_root and then * string-stripping "/.git" left unresolved ".." components in the result. * - * These tests shell out to `git`, so they SKIP_PLATFORM on Windows (the CI - * shell there cannot init a repo via system()). + * These tests shell out to `git`, so the canonical_root tests SKIP_PLATFORM on + * Windows (the CI shell there cannot init a repo via system()). The bounded + * worktree-status tests below shell out through cbm_popen instead, so they run + * on every platform that has git on PATH. * * Reproduce-first guard: canonical_root_subdir is the genuine RED-without-the-fix * guard — a repo indexed from a subdirectory yields a relative --git-common-dir @@ -232,10 +234,279 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +/* ── Bounded worktree status (BT-240 increment 3) ────────────────────── + * These run on EVERY platform, not just POSIX: they shell out through + * cbm_popen (the same isolated spawn production git_context uses) instead of + * system(), so they work whenever git is on PATH — Windows CI included. The + * core status parser is platform-neutral and must be exercised everywhere git + * is available; only a genuinely missing git skips. */ + +static int status_git_run(const char *dir, const char *args) { + char cmd[1024]; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", dir, args, null_dev); + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return -1; + } + char drain[256]; + while (fgets(drain, sizeof(drain), fp)) { + } + return cbm_pclose(fp); +} + +static int status_make_repo(const char *dir) { + if (th_mkdir_p(dir) != 0) return -1; + if (status_git_run(dir, "init -q") != 0) return -1; + if (status_git_run(dir, "config user.email test@example.com") != 0) return -1; + if (status_git_run(dir, "config user.name Test") != 0) return -1; + if (th_write_file(TH_PATH(dir, ".keep"), "") != 0) return -1; + if (status_git_run(dir, "add -A") != 0) return -1; + if (status_git_run(dir, "commit -qm init") != 0) return -1; + return 0; +} + +TEST(worktree_status_clean) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("clean repo status not available"); + } + ASSERT_EQ(st.tracked_count, 0); + ASSERT_EQ(st.untracked_count, 0); + ASSERT_EQ(st.tracked_sample_count, 0); + ASSERT_EQ(st.untracked_sample_count, 0); + ASSERT_FALSE(st.tracked_truncated); + ASSERT_FALSE(st.untracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_tracked_modified) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 || + status_git_run(tmp, "add -f -- tracked.txt") != 0 || + status_git_run(tmp, "commit -q -m add-tracked") != 0) { + th_rmtree(tmp); + FAIL("failed to commit tracked file"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v2") != 0) { + th_rmtree(tmp); + FAIL("failed to modify tracked file"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.tracked_count, 1); + ASSERT_EQ(st.untracked_count, 0); + ASSERT_EQ(st.tracked_sample_count, 1); + ASSERT_STR_EQ(st.tracked_paths[0], "tracked.txt"); + ASSERT_FALSE(st.tracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_untracked) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "newfile.txt"), "n") != 0) { + th_rmtree(tmp); + FAIL("failed to create untracked file"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.untracked_count, 1); + ASSERT_EQ(st.tracked_count, 0); + ASSERT_EQ(st.untracked_sample_count, 1); + ASSERT_STR_EQ(st.untracked_paths[0], "newfile.txt"); + ASSERT_FALSE(st.untracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_rename_counts_once) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "old.txt"), "o") != 0 || + th_write_file(TH_PATH(tmp, "other.txt"), "x") != 0 || + status_git_run(tmp, "add -A") != 0 || + status_git_run(tmp, "commit -qm base") != 0 || + status_git_run(tmp, "mv old.txt new.txt") != 0) { + th_rmtree(tmp); + FAIL("failed to stage a rename"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.tracked_count, 1); /* rename/copy counts ONCE, not twice */ + ASSERT_EQ(st.tracked_sample_count, 1); + ASSERT_STR_EQ(st.tracked_paths[0], "new.txt"); /* sampled NEW path */ + ASSERT_EQ(st.untracked_count, 0); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_sample_cap_truncates) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + enum { N_FILES = 5, SAMPLE_MAX = 2 }; + for (int i = 0; i < N_FILES; i++) { + char path[1024]; + snprintf(path, sizeof(path), "%s/u%d.txt", tmp, i); + if (th_write_file(path, "u") != 0) { + th_rmtree(tmp); + FAIL("failed to create untracked file"); + } + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, SAMPLE_MAX, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.untracked_count, N_FILES); /* full stream counted... */ + ASSERT_EQ(st.untracked_sample_count, SAMPLE_MAX); /* ...samples bounded */ + ASSERT_TRUE(st.untracked_truncated); + ASSERT_EQ(st.tracked_count, 0); + ASSERT_FALSE(st.tracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_mixed_tracked_and_untracked) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 || + status_git_run(tmp, "add -f -- tracked.txt") != 0 || + status_git_run(tmp, "commit -q -m add-tracked") != 0) { + th_rmtree(tmp); + FAIL("failed to commit tracked file"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v2") != 0 || + th_write_file(TH_PATH(tmp, "untracked.txt"), "n") != 0) { + th_rmtree(tmp); + FAIL("failed to prepare mixed worktree"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.tracked_count, 1); + ASSERT_EQ(st.untracked_count, 1); + ASSERT_STR_EQ(st.tracked_paths[0], "tracked.txt"); + ASSERT_STR_EQ(st.untracked_paths[0], "untracked.txt"); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +/* Fail-closed contract: a non-repo directory must report unavailable with + * zero counts — never a silent "clean" tree. Does not need git on PATH. */ +TEST(worktree_status_unavailable_non_repo) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0) { + th_rmtree(tmp); + FAIL("status call returned an error"); + } + ASSERT_FALSE(st.available); + ASSERT_EQ(st.tracked_count, 0); + ASSERT_EQ(st.untracked_count, 0); + ASSERT_EQ(st.tracked_sample_count, 0); + ASSERT_EQ(st.untracked_sample_count, 0); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(worktree_status_clean); + RUN_TEST(worktree_status_tracked_modified); + RUN_TEST(worktree_status_untracked); + RUN_TEST(worktree_status_rename_counts_once); + RUN_TEST(worktree_status_sample_cap_truncates); + RUN_TEST(worktree_status_mixed_tracked_and_untracked); + RUN_TEST(worktree_status_unavailable_non_repo); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 95f6698521..13667afeed 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -16,6 +16,7 @@ #include "test_framework.h" #include "test_helpers.h" #include +#include #include /* spawn-count hook — #845 in-process guard */ #include #include @@ -5897,9 +5898,465 @@ TEST(tool_index_status_includes_git_metadata) { PASS(); } -/* ══════════════════════════════════════════════════════════════════ - * TOOL HANDLERS WITH DATA - * ══════════════════════════════════════════════════════════════════ */ +/* BT-240 RED: a live checkout SHA is not proof of the generation that produced + * graph content. Older databases lack an indexed-checkout identity, so verbose + * status must fail closed as unknown while exposing graph generation + * separately. */ +TEST(tool_index_status_fails_closed_without_indexed_checkout_identity) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"test-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"freshness\"")); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\"")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_generation\"")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":null")); + ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_unavailable")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* BT-240 lean-default guard: the freshness verdict is diagnostics — a + * report-only signal that never auto-indexes — so it must stay out of the + * common (non-verbose) status call. */ +TEST(tool_index_status_omits_freshness_by_default) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"test-project\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"status\"")); + ASSERT_NULL(strstr(inner, "\"freshness\"")); + ASSERT_NULL(strstr(inner, "indexed_checkout_sha")); + /* BT-240 increment 3: the worktree-status snapshot is part of the + * verbose-only freshness block and must not leak into the default call. */ + ASSERT_NULL(strstr(inner, "status_available")); + ASSERT_NULL(strstr(inner, "tracked_changes")); + ASSERT_NULL(strstr(inner, "untracked_source")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── BT-240: freshness verdict against the recorded indexed checkout ── + * These shell out to git via cbm_popen (the same isolated spawn production + * git_context uses) rather than system(), so they run on every platform that + * has git on PATH — Windows CI included. A genuinely missing git is the only + * skip. */ + +static int mcp_git_run(const char *dir, const char *args) { + char cmd[1024]; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", dir, args, null_dev); + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return -1; + } + char drain[256]; + while (fgets(drain, sizeof(drain), fp)) { + } + return cbm_pclose(fp); +} + +static int mcp_make_git_repo(const char *dir) { + if (th_mkdir_p(dir) != 0) return -1; + if (mcp_git_run(dir, "init -q") != 0) return -1; + if (mcp_git_run(dir, "config user.email test@example.com") != 0) return -1; + if (mcp_git_run(dir, "config user.name Test") != 0) return -1; + if (th_write_file(TH_PATH(dir, ".keep"), "") != 0) return -1; + if (mcp_git_run(dir, "add -A") != 0) return -1; + if (mcp_git_run(dir, "commit -qm init") != 0) return -1; + return 0; +} + +/* Helper: build a server whose project root is the given git repo and whose + * coverage metadata records indexed_checkout_sha = sha (may be NULL). */ +static cbm_mcp_server_t *mcp_freshness_server(const char *repo, const char *sha) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) return NULL; + cbm_store_t *st = cbm_mcp_server_store(srv); + if (!st) { + cbm_mcp_server_free(srv); + return NULL; + } + cbm_mcp_server_set_project(srv, "fresh-project"); + cbm_store_upsert_project(st, "fresh-project", repo); + cbm_coverage_meta_t meta = { + .generation = "fresh-generation", + .index_mode = "fast", + .recorded_at = "2026-08-11T00:00:00Z", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true, + .indexed_checkout_sha = sha, + }; + if (cbm_store_coverage_replace_ex(st, "fresh-project", NULL, 0, &meta) != CBM_STORE_OK) { + cbm_mcp_server_free(srv); + return NULL; + } + return srv; +} + +TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"current\"")); + ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_current")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(inner, "\"checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"use_graph\"")); + ASSERT_NOT_NULL(strstr(inner, "\"status_available\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":")); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":0")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + + /* A stale recorded identity that does not equal the live HEAD. */ + cbm_mcp_server_t *srv = mcp_freshness_server(repo, "0000000000000000000000000000000000000000"); + if (!srv) { + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_mismatch")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":\"0000000000000000000000000000000000000000\"")); + ASSERT_NOT_NULL(strstr(inner, "\"checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"reindex_to_match_checkout\"")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: a modified tracked file makes the verdict stale even + * when the indexed SHA matches the live HEAD, with the sample surfaced. */ +TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 || + mcp_git_run(repo, "add -f -- tracked.txt") != 0 || + mcp_git_run(repo, "commit -q -m add-tracked") != 0) { + th_rmtree(repo); + FAIL("failed to commit tracked file"); + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v2") != 0) { + free(head); + th_rmtree(repo); + FAIL("failed to modify tracked file"); + } + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(inner, "tracked_changes_present")); + ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":1")); + ASSERT_NOT_NULL(strstr(inner, "\"paths\":[\"tracked.txt\"]")); + ASSERT_NOT_NULL(strstr(inner, "\"truncated\":false")); + ASSERT_NULL(strstr(inner, "indexed_checkout_current")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: untracked sources prevent "current" — verdict is + * unknown (no stale reason), with the untracked_not_indexed reason. */ +TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(repo, "newfile.txt"), "n") != 0) { + th_rmtree(repo); + FAIL("failed to create untracked file"); + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\"")); + ASSERT_NOT_NULL(strstr(inner, "untracked_not_indexed")); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":1")); + ASSERT_NULL(strstr(inner, "indexed_checkout_current")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: tracked changes win over untracked — both reasons are + * present but the verdict is stale, never unknown. */ +TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 || + mcp_git_run(repo, "add -f -- tracked.txt") != 0 || + mcp_git_run(repo, "commit -q -m add-tracked") != 0) { + th_rmtree(repo); + FAIL("failed to commit tracked file"); + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v2") != 0 || + th_write_file(TH_PATH(repo, "untracked.txt"), "n") != 0) { + free(head); + th_rmtree(repo); + FAIL("failed to prepare mixed worktree"); + } + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(inner, "tracked_changes_present")); + ASSERT_NOT_NULL(strstr(inner, "untracked_not_indexed")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: a non-git root with a recorded identity cannot prove + * current — status availability must be false and the verdict unknown. */ +TEST(tool_index_status_freshness_status_unavailable_non_git) { + char *repo = th_mktempdir("cbm_fresh_non_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + + /* Record an identity even though the root is not a git repository. */ + cbm_mcp_server_t *srv = mcp_freshness_server(repo, "0123456789012345678901234567890123456789"); + if (!srv) { + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"status_available\":false")); + ASSERT_NOT_NULL(strstr(inner, "status_unavailable")); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\"")); + ASSERT_NULL(strstr(inner, "indexed_checkout_current")); + ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":")); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":0")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: sample cap at the MCP layer — many untracked sources + * surface the true count, a bounded paths array and truncated=true. */ +TEST(tool_index_status_freshness_sample_cap_truncates) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + enum { N_FILES = 17, MCP_SAMPLE_MAX = 16 }; + for (int i = 0; i < N_FILES; i++) { + char path[1024]; + snprintf(path, sizeof(path), "%s/u%02d.txt", repo, i); + if (th_write_file(path, "u") != 0) { + th_rmtree(repo); + FAIL("failed to create untracked file"); + } + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":17")); + ASSERT_NOT_NULL(strstr(inner, "\"truncated\":true")); + static const char EXPECTED_PATHS[] = + "\"paths\":[\"u00.txt\",\"u01.txt\",\"u02.txt\",\"u03.txt\",\"u04.txt\",\"u05.txt\"," + "\"u06.txt\",\"u07.txt\",\"u08.txt\",\"u09.txt\",\"u10.txt\",\"u11.txt\",\"u12.txt\"," + "\"u13.txt\",\"u14.txt\",\"u15.txt\"]"; + ASSERT_NOT_NULL(strstr(inner, EXPECTED_PATHS)); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} TEST(tool_trace_call_path_not_found) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -20382,6 +20839,15 @@ SUITE(mcp) { RUN_TEST(tool_check_index_coverage_requires_source_when_file_metadata_changed); RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors); RUN_TEST(tool_index_status_includes_git_metadata); + RUN_TEST(tool_index_status_fails_closed_without_indexed_checkout_identity); + RUN_TEST(tool_index_status_omits_freshness_by_default); + RUN_TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes); + RUN_TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked); + RUN_TEST(tool_index_status_freshness_status_unavailable_non_git); + RUN_TEST(tool_index_status_freshness_sample_cap_truncates); /* Tool handlers with validation */ RUN_TEST(tool_trace_call_path_not_found); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index cf7b8bc8fd..4c555db30b 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -2361,12 +2361,82 @@ TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails) { PASS(); } +TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "coverage-sha", "/tmp/coverage-sha"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-sha", "keep.c", "", 1, 1), CBM_STORE_OK); + cbm_coverage_row_t row = {.rel_path = "keep.c", .kind = "parse_partial", .detail = "1-2"}; + + /* NULL indexed_checkout_sha (non-git run) persists as NULL and reads back + * as no identity. */ + cbm_coverage_meta_t no_sha_meta = { + .generation = "gen-0", + .index_mode = "full", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true, + }; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &no_sha_meta), + CBM_STORE_OK); + cbm_coverage_meta_t got = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_NULL(got.indexed_checkout_sha); + cbm_store_coverage_meta_clear(&got); + + /* Round-trip: git-run identity persists and can be overwritten by the next + * staged generation. */ + cbm_coverage_meta_t sha_meta = no_sha_meta; + sha_meta.generation = "gen-1"; + sha_meta.indexed_checkout_sha = "0123456789abcdef0123456789abcdef01234567"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &sha_meta), + CBM_STORE_OK); + got = (cbm_coverage_meta_t){0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.indexed_checkout_sha, "0123456789abcdef0123456789abcdef01234567"); + ASSERT_STR_EQ(got.generation, "gen-1"); + cbm_store_coverage_meta_clear(&got); + + cbm_coverage_meta_t new_meta = sha_meta; + new_meta.generation = "gen-2"; + new_meta.indexed_checkout_sha = "abcdef0123456789abcdef0123456789abcdef01"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &new_meta), + CBM_STORE_OK); + got = (cbm_coverage_meta_t){0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.indexed_checkout_sha, "abcdef0123456789abcdef0123456789abcdef01"); + ASSERT_STR_EQ(got.generation, "gen-2"); + cbm_store_coverage_meta_clear(&got); + + /* Rollback: a failed replace must retain the prior identity + generation. */ + ASSERT_EQ(cbm_store_exec(s, "CREATE TRIGGER fail_sha_missed_insert BEFORE INSERT ON nodes " + "WHEN NEW.project = 'coverage-sha::missed' " + "BEGIN SELECT RAISE(ABORT, 'forced sha shadow failure'); END;"), + CBM_STORE_OK); + cbm_coverage_meta_t failed_meta = new_meta; + failed_meta.generation = "gen-must-not-commit"; + failed_meta.indexed_checkout_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + cbm_coverage_row_t failed_row = row; + failed_row.detail = "changed-to-force-shadow-rebuild"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &failed_row, 1, &failed_meta), + CBM_STORE_ERR); + got = (cbm_coverage_meta_t){0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.generation, "gen-2"); + ASSERT_STR_EQ(got.indexed_checkout_sha, "abcdef0123456789abcdef0123456789abcdef01"); + cbm_store_coverage_meta_clear(&got); + + cbm_store_close(s); + PASS(); +} + SUITE(store_nodes) { RUN_TEST(store_coverage_roundtrip_prune_shadow); RUN_TEST(store_coverage_targeted_path_and_scope_lookup); RUN_TEST(store_coverage_meta_zero_row_truncation_and_delete); RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments); RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails); + RUN_TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback); RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like); RUN_TEST(sql_relation_labels_match_cbm_label_is_relation); RUN_TEST(store_open_memory); From ed78ff056e89da814ff5ae05da6fb32b7e10c0db Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sun, 20 Sep 2026 16:44:10 +0800 Subject: [PATCH 03/65] fix(install): reject Android portable binary Signed-off-by: lorenzozanee --- install.sh | 11 ++++++++ tests/test-install-android-guard.sh | 41 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100755 tests/test-install-android-guard.sh diff --git a/install.sh b/install.sh index 9f4b4ec999..01ab6c3781 100755 --- a/install.sh +++ b/install.sh @@ -148,8 +148,19 @@ detect_arch() { esac } +is_android() { + [ "$(uname -o 2>/dev/null || true)" = "Android" ] \ + || [ -n "${TERMUX_VERSION:-}" ] \ + || [[ "${PREFIX:-}" == /data/data/com.termux/* ]] +} + OS=$(detect_os) ARCH=$(detect_arch) +if [ "$OS" = "linux" ] && is_android; then + echo "error: Android/Termux is not supported by the Linux portable binary." >&2 + echo "Build from source with scripts/build.sh in Termux or use a supported Linux host." >&2 + exit 1 +fi echo "codebase-memory-mcp installer" echo " os: $OS" diff --git a/tests/test-install-android-guard.sh b/tests/test-install-android-guard.sh new file mode 100755 index 0000000000..6b3a383a29 --- /dev/null +++ b/tests/test-install-android-guard.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +mkdir -p "$WORK/bin" "$WORK/home" +cat >"$WORK/bin/uname" <<'EOF' +case "$1" in + -s) printf '%s\n' Linux ;; + -m) printf '%s\n' aarch64 ;; + -o) printf '%s\n' Android ;; + *) exit 1 ;; +esac +EOF +cat >"$WORK/bin/curl" <<'EOF' +printf 'curl must not run for Android/Termux\n' >&2 +exit 99 +EOF +chmod +x "$WORK/bin/uname" "$WORK/bin/curl" + +set +e +output=$(PATH="$WORK/bin:$PATH" HOME="$WORK/home" bash "$ROOT/install.sh" --skip-config 2>&1) +rc=$? +set -e + +if [ "$rc" -eq 0 ]; then + printf 'FAIL: installer accepted Android/Termux\n' >&2 + exit 1 +fi +if ! grep -Fq 'Android/Termux' <<<"$output"; then + printf 'FAIL: missing Android/Termux guidance\n%s\n' "$output" >&2 + exit 1 +fi +if grep -Fq 'curl must not run' <<<"$output"; then + printf 'FAIL: installer downloaded before rejecting Android/Termux\n' >&2 + exit 1 +fi + +printf 'PASS: Android/Termux is rejected before download\n' From 0d2f840c8ed2695db206acbcad1ad1c36a1f3958 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 07:22:01 -0400 Subject: [PATCH 04/65] 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 66b50fe082..3a9b2137dc 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 038256976e..4c27e7f574 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 b59a894a6a..13c6789436 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 cf7f6fa443..ada71ab01a 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 f8909c3654..1043b48cda 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 748b8bb757..8a79ecd5dd 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 f739c46e6a..2734dc703b 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 876309eb6a..633e280189 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 75ff8e396c..f27ddb9a8b 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 de170067a8..8e78f9f406 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 211ec63555..46f826d639 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 a384651a50..08df0dd7b1 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 ffa9ea3a3b..787b26e2e2 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 1c840df7495e3b71924b8efd7e3bbf53c9076abc Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Mon, 28 Sep 2026 15:05:48 +0000 Subject: [PATCH 05/65] ci: retrigger after macOS smoke runner cancellation pr-smoke (macos-14) was cancelled mid-run (operation was canceled) at an unrelated UI-server check, with all 30+ other legs green; ci-ok failed only as a consequence. Empty commit to get a fresh run. Signed-off-by: Amir Fathi From 16698e8193fa1dbb53f34f7205569495b3b5fbca Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 07:22:01 -0400 Subject: [PATCH 06/65] 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 9c82c18aed..fb4ce7326e 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 038256976e..4c27e7f574 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 6e33759e4a..08aded6219 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 5522edef06..a6ef66618c 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 f8909c3654..1043b48cda 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 748b8bb757..8a79ecd5dd 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 47956562f4..cb27c17cf4 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 876309eb6a..633e280189 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 78b8dccf96..41a63273eb 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 de170067a8..8e78f9f406 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 7cd44244e3..14553ef680 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 cc94f1d06e..894cb68aac 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 60649f5054..b7b1335a12 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 07/65] 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 cb27c17cf4..3a3da7acb4 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 08/65] 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 8a79ecd5dd..153bc0d81e 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 09/65] 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 1043b48cda..a389e487ff 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 41a63273eb..19083feee6 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 09befb7809..6fb110181e 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 14553ef680..5d7441ec41 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 10/65] 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 3a3da7acb4..3fbf5b65d6 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 633e280189..30f2f545a1 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 894cb68aac..5b3c4b7790 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 11/65] 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 a389e487ff..b08d6ebd10 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 5d7441ec41..6297a64d09 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 12/65] 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 b08d6ebd10..d3baace47c 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 3fbf5b65d6..8e2629cc48 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 30f2f545a1..1b9d174f17 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 6297a64d09..e234d77df8 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 5b3c4b7790..10cab00097 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 13/65] 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 d3baace47c..10e7741a19 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 8e2629cc48..a63d3385e9 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 14/65] 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 10e7741a19..3de1691aa1 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 e234d77df8..27883c5c87 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 15/65] 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 3de1691aa1..342a7bd61b 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 16/65] 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 342a7bd61b..384ba8467b 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 17/65] 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 2ca944df23..56f74e8cbc 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 f6de32943e..e52d148c5a 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 23c37314e8..a2b455f310 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 18/65] 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 56f74e8cbc..06b5b5e2fa 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 a2b455f310..c4f17e71fa 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 19/65] 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 06b5b5e2fa..6d6e7e6dba 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 c4f17e71fa..ba578d2042 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 20/65] 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 6d6e7e6dba..f7f41e10fa 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 e52d148c5a..dc9271d5b9 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 ba578d2042..a3d4aca6e1 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 21/65] 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 f7f41e10fa..1411c36491 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 a3d4aca6e1..1ad45e314c 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 22/65] 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 1411c36491..c0063212f8 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 dc9271d5b9..ad2d81de39 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 1ad45e314c..63f764f343 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 23/65] 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 c0063212f8..ed97c78ec0 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 63f764f343..d1999357fc 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 24/65] 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 38802e5aa6..640befd6cf 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 25/65] 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 cccff18386..c45ec6de89 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 a03c1731a9..8eab275a2c 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 26/65] 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 ab5c89db73..b2a655505b 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 27/65] Free partial Cypher patterns after parse errors Signed-off-by: Nohj --- src/cypher/cypher.c | 25 +++++++++++++++++-------- tests/test_cypher.c | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 7c7a2f9878..42ecb93c5b 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)); + /* 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) { - return CBM_NOT_FOUND; + goto fail; } - 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)); } - 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 7c251bd9b0..5d198a0ef6 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 28/65] 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 a63d3385e9..6986be4aaa 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 5c51d71aea43f1ca6b27f33085b8f3ae6e2fe51c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 02:33:19 +0200 Subject: [PATCH 29/65] fix(git): run the worktree status probe through cbm_popen_git The freshness verdict reads cbm_git_worktree_status(), which spawned git with plain cbm_popen. A GIT_DIR/GIT_WORK_TREE inherited from a git hook or an editor overrides `git -C `, so a clean checkout was reported stale with the other repository's changes. Every other git child already goes through cbm_popen_git since #2003; this probe now does too. worktree_status_ignores_inherited_repo_env pins it: a dirty decoy in the hook environment must not leak into a clean fixture's status. Signed-off-by: Martin Vogel --- src/git/git_context.c | 4 +++- tests/test_git_context.c | 49 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index 1542a9477f..bd4f9c4238 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -628,7 +628,9 @@ int cbm_git_worktree_status(const char *validated_root, int max_samples, return CBM_NOT_FOUND; } - FILE *fp = cbm_popen(cmd, "r"); + /* An inherited GIT_DIR/GIT_WORK_TREE (a git hook, an editor) overrides + * `git -C`, which would report another repository's worktree here. */ + FILE *fp = cbm_popen_git(cmd); if (!fp) { return 0; /* out is zeroed: available=false */ } diff --git a/tests/test_git_context.c b/tests/test_git_context.c index b73d76328a..a4fc61b7d6 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -497,10 +497,54 @@ TEST(git_branch_resolve_matches_full_context) { #endif /* _WIN32 */ } +/* The freshness verdict reads this snapshot, so an inherited GIT_DIR / + * GIT_WORK_TREE pointing at a dirty decoy must not make a clean fixture + * report the decoy's changes as its own (#2003). */ +TEST(worktree_status_ignores_inherited_repo_env) { +#ifdef _WIN32 + SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI"); +#else + char root[256]; + char *raw = th_mktempdir("cbm_gitenv_status"); + if (!raw) + FAIL("th_mktempdir returned NULL"); + snprintf(root, sizeof(root), "%s", raw); + if (make_env_isolation_tree(root) != 0) { + th_rmtree(root); + SKIP_PLATFORM("git not available to init a repo"); + } + char decoy[512], fixture[512], path[600]; + snprintf(decoy, sizeof(decoy), "%s/decoy", root); + snprintf(fixture, sizeof(fixture), "%s/fixture", root); + /* Dirty only the decoy: one tracked edit and one untracked file. */ + snprintf(path, sizeof(path), "%s/a.go", decoy); + th_write_file(path, "package a // dirty\n"); + snprintf(path, sizeof(path), "%s/untracked.go", decoy); + th_write_file(path, "package a\n"); + + git_env_snapshot_t snap; + git_env_hook_enter(&snap, decoy); + cbm_worktree_status_t st; + int rc = cbm_git_worktree_status(fixture, 4, &st); + git_env_hook_leave(&snap); + bool available = st.available; + int tracked = st.tracked_count; + int untracked = st.untracked_count; + cbm_git_worktree_status_free(&st); + th_rmtree(root); + + ASSERT_EQ(rc, 0); + ASSERT_TRUE(available); + ASSERT_EQ(tracked, 0); /* the fixture is clean; the decoy is not */ + ASSERT_EQ(untracked, 0); + PASS(); +#endif /* _WIN32 */ +} + /* ── Bounded worktree status (BT-240 increment 3) ────────────────────── * These run on EVERY platform, not just POSIX: they shell out through - * cbm_popen (the same isolated spawn production git_context uses) instead of - * system(), so they work whenever git is on PATH — Windows CI included. The + * cbm_popen instead of system(), so they work whenever git is on PATH — + * Windows CI included. The * core status parser is platform-neutral and must be exercised everywhere git * is available; only a genuinely missing git skips. */ @@ -769,6 +813,7 @@ SUITE(git_context) { RUN_TEST(canonical_root_linked_worktree); RUN_TEST(git_context_ignores_inherited_repo_env); RUN_TEST(githistory_ignores_inherited_repo_env); + RUN_TEST(worktree_status_ignores_inherited_repo_env); RUN_TEST(worktree_status_clean); RUN_TEST(worktree_status_tracked_modified); RUN_TEST(worktree_status_untracked); From 3a1b5a12851437f4624aff0cca529a8cc428eb82 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 02:33:26 +0200 Subject: [PATCH 30/65] refactor(git,store): allocate freshness data through the memory core The status samples and the stored indexed_checkout_sha were the only new raw malloc/realloc/free sites, which grew the memory-core ratchet (scripts/lint-memory-core.py) for git_context.c and store.c. They now use cbm_alloc/cbm_realloc/cbm_free and cbm_mem_strdup, with one class per pointer (OTHER for the samples, STORE for the column value). Signed-off-by: Martin Vogel --- src/git/git_context.c | 16 +++++++++------- src/store/store.c | 5 +++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index bd4f9c4238..05ec5f2c86 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -3,6 +3,7 @@ #include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/log.h" +#include "foundation/mem_core.h" #include "foundation/platform.h" #include "foundation/str_util.h" @@ -524,16 +525,17 @@ static void status_add_sample(int *total, char ***samples, int *sample_count, *truncated = true; return; } - char *copy = (char *)malloc(path_len + 1); + char *copy = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, path_len + 1); if (!copy) { *truncated = true; return; } memcpy(copy, path, path_len); copy[path_len] = '\0'; - char **grown = (char **)realloc(*samples, (size_t)(*sample_count + 1) * sizeof(char *)); + char **grown = (char **)cbm_realloc(CBM_MEM_CLASS_OTHER, *samples, + (size_t)(*sample_count + 1) * sizeof(char *)); if (!grown) { - free(copy); + cbm_free(CBM_MEM_CLASS_OTHER, copy); *truncated = true; return; } @@ -668,12 +670,12 @@ void cbm_git_worktree_status_free(cbm_worktree_status_t *st) { return; } for (int i = 0; i < st->tracked_sample_count; i++) { - free(st->tracked_paths[i]); + cbm_free(CBM_MEM_CLASS_OTHER, st->tracked_paths[i]); } - free(st->tracked_paths); + cbm_free(CBM_MEM_CLASS_OTHER, st->tracked_paths); for (int i = 0; i < st->untracked_sample_count; i++) { - free(st->untracked_paths[i]); + cbm_free(CBM_MEM_CLASS_OTHER, st->untracked_paths[i]); } - free(st->untracked_paths); + cbm_free(CBM_MEM_CLASS_OTHER, st->untracked_paths); memset(st, 0, sizeof(*st)); } diff --git a/src/store/store.c b/src/store/store.c index 2471d00d7c..41fcbc4fb6 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -5072,7 +5072,7 @@ void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) { free((char *)meta->index_mode); free((char *)meta->recorded_at); free((char *)meta->recording_status); - free((char *)meta->indexed_checkout_sha); + cbm_free(CBM_MEM_CLASS_STORE, (char *)meta->indexed_checkout_sha); memset(meta, 0, sizeof(*meta)); } @@ -5117,7 +5117,8 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag out->hash_records_complete = sqlite3_column_int(stmt, 8) != 0; if (has_checkout_column) { const char *sha = (const char *)sqlite3_column_text(stmt, 9); - out->indexed_checkout_sha = sha && sha[0] ? heap_strdup(sha) : NULL; + out->indexed_checkout_sha = + sha && sha[0] ? cbm_mem_strdup(CBM_MEM_CLASS_STORE, sha) : NULL; } sqlite3_finalize(stmt); if (!out->project || !out->generation || !out->index_mode || !out->recorded_at || From 9d0aca2b83bb6e2e3552a9d3ee84f8a60a2021e9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 02:33:26 +0200 Subject: [PATCH 31/65] style(git): clang-format the worktree status parser .clang-format sets IndentCaseLabels: false; the status_code_ok switch and a few wrapped calls in the new parser did not match it. Formatting only; no behaviour change. Signed-off-by: Martin Vogel --- src/git/git_context.c | 57 ++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index 05ec5f2c86..9e5a56e48a 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -492,34 +492,33 @@ typedef struct { * header, so a malformed stream fails closed instead of being misread. */ static bool status_code_ok(char c) { switch (c) { - case ' ': - case 'M': - case 'A': - case 'D': - case 'R': - case 'C': - case 'U': - case '?': - case '!': - case 'T': - case 'm': - case 'a': - case 'd': - case 'r': - case 'c': - case 'u': - case 't': - return true; - default: - return false; + case ' ': + case 'M': + case 'A': + case 'D': + case 'R': + case 'C': + case 'U': + case '?': + case '!': + case 'T': + case 'm': + case 'a': + case 'd': + case 'r': + case 'c': + case 'u': + case 't': + return true; + default: + return false; } } /* Retain one bounded sample; the running total always advances so the caller * sees the true stream count even when samples are capped or OOM. */ -static void status_add_sample(int *total, char ***samples, int *sample_count, - bool *truncated, int max_samples, const char *path, - size_t path_len) { +static void status_add_sample(int *total, char ***samples, int *sample_count, bool *truncated, + int max_samples, const char *path, size_t path_len) { (*total)++; if (*sample_count >= max_samples) { *truncated = true; @@ -552,8 +551,8 @@ static bool status_parse_record(cbm_worktree_status_t *out, status_parser_t *par parser->expecting_source = false; return true; /* second path of a rename/copy record — not counted */ } - if (field_len < STATUS_HEADER_LEN || field[2] != ' ' || - !status_code_ok(field[0]) || !status_code_ok(field[1])) { + if (field_len < STATUS_HEADER_LEN || field[2] != ' ' || !status_code_ok(field[0]) || + !status_code_ok(field[1])) { return false; } const char *path = field + STATUS_HEADER_LEN; @@ -567,9 +566,8 @@ static bool status_parse_record(cbm_worktree_status_t *out, status_parser_t *par if (field[0] == 'R' || field[0] == 'C' || field[1] == 'R' || field[1] == 'C') { parser->expecting_source = true; } - status_add_sample(&out->tracked_count, &out->tracked_paths, - &out->tracked_sample_count, &out->tracked_truncated, - parser->max_samples, path, path_len); + status_add_sample(&out->tracked_count, &out->tracked_paths, &out->tracked_sample_count, + &out->tracked_truncated, parser->max_samples, path, path_len); return true; } @@ -591,8 +589,7 @@ static void status_feed(status_parser_t *parser, const char *data, size_t data_l parser->partial_len += take; i += take; if (i < data_len && data[i] == '\0') { - if (!status_parse_record(parser->out, parser, parser->partial, - parser->partial_len)) { + if (!status_parse_record(parser->out, parser, parser->partial, parser->partial_len)) { parser->parse_ok = false; return; } From fa331ed5e6b4294a0ed9bbd6b6f2bb6c98302d85 Mon Sep 17 00:00:00 2001 From: Petteri Ponsimaa Date: Wed, 21 Oct 2026 10:00:23 +0300 Subject: [PATCH 32/65] feat(extraction): route Next.js App Router handlers app/**/route.{ts,js} files produced 0 Route nodes, so cross-repo intelligence found no edges into a Next.js backend (#759). cbm_extract_nextjs_routes fills route_path/route_method on the existing Function definition for each exported HTTP-verb handler. It adds no pass and no synthetic definitions; the route-node pass builds the Route and HANDLES edge as it does for other frameworks. CBM_SEMANTIC_INDEX_VERSION goes from 9 to 10 so existing indexes re-extract once. Fixes #759 Signed-off-by: Petteri Ponsimaa --- Makefile.cbm | 1 + internal/cbm/cbm.c | 3 + internal/cbm/cbm.h | 5 + internal/cbm/extract_nextjs.c | 272 ++++++++++++++++++++ src/pipeline/pipeline_internal.h | 9 +- tests/test_extraction.c | 173 +++++++++++++ tests/test_pipeline.c | 422 +++++++++++++++++++++++++++++++ 7 files changed, 883 insertions(+), 2 deletions(-) create mode 100644 internal/cbm/extract_nextjs.c diff --git a/Makefile.cbm b/Makefile.cbm index 3b93146f1f..631290d693 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -397,6 +397,7 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/diagram.c \ $(CBM_DIR)/route_path.c \ $(CBM_DIR)/openapi.c \ + $(CBM_DIR)/extract_nextjs.c \ $(CBM_DIR)/sql_values.c \ $(CBM_DIR)/helpers.c \ $(CBM_DIR)/cpp_branch_views.c \ diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 23b871dbc0..50dd975689 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -3902,6 +3902,9 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C // OpenAPI operations as Routes (self-gated: API description documents). cbm_extract_openapi(&ctx); + // Next.js App Router handlers' routes (self-gated: app/**/route.ts and route.js). + cbm_extract_nextjs_routes(&ctx); + // K8s / Kustomize semantic pass (additional structured extraction for YAML-based infra files). if (ctx.language == CBM_LANG_KUSTOMIZE || ctx.language == CBM_LANG_K8S) { cbm_extract_k8s(&ctx); diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index ade41fea57..12b8c1acd3 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -1389,6 +1389,11 @@ void cbm_extract_diagram_file(CBMExtractCtx *ctx); * or JSON files whose root says `openapi` or `swagger`. */ void cbm_extract_openapi(CBMExtractCtx *ctx); +/* Next.js App Router route handlers (extract_nextjs.c): the HTTP verb Functions + * a route file exports carry the route path its directories name. Self-gated: + * TS / JS route.ts and route.js under app/ or src/app/. */ +void cbm_extract_nextjs_routes(CBMExtractCtx *ctx); + /* Canonicalize route-path parameter placeholders (":id", "{id}", "", * "${...}") to a single "{}" token so that client call sites, server handlers * and OpenAPI operations rendezvous on the same Route QN regardless of diff --git a/internal/cbm/extract_nextjs.c b/internal/cbm/extract_nextjs.c new file mode 100644 index 0000000000..eea270fa7e --- /dev/null +++ b/internal/cbm/extract_nextjs.c @@ -0,0 +1,272 @@ +/* + * extract_nextjs.c -- Next.js App Router route handlers (#759): an HTTP verb a + * route file (`app/.../route.ts` or `route.js`, the app directory at the root + * or under src/) exports serves the path its directories name. + * + * The handler is the Function definition extraction already made for the + * export: this fills its route_path / route_method, so its Route node and + * HANDLES edge come from the route pass exactly as a decorator route's do, on + * a full and on an incremental index alike. The path is spelled as SvelteKit's + * (sveltekit_route_path, pass_route_nodes.c): `[id]` is `:id`, `[...slug]` is + * `*slug` and a `(group)` directory adds no segment. + * + * A directory this cannot spell makes the file no route at all: an optional + * catch-all (`[[...slug]]`), a private folder (`_lib`), a parallel slot + * (`@modal`), an intercepting route (`(.)photo`), a malformed bracket, or a + * path that does not fit route_path. A handler is a Function of this file: + * `export [async] function GET`, `export const GET = [async] (...) => ...`, + * or `export { GET }` of such a declaration. A renamed + * (`export { handler as GET }`), re-exported, destructured or wrapped + * (`export const GET = auth(handler)`) handler, or a `function` expression + * bound to a const, has no Function of its own here and is not routed. Each + * file is judged alone: a src/app/ route file is routed even where a root + * app/ directory makes Next.js ignore src/app/. + */ +#include "cbm.h" +#include "foundation/arena.h" +#include "helpers.h" +#include "tree_sitter/api.h" +#include "foundation/constants.h" +#include +#include +#include + +enum { + NX_PATH_MAX = CBM_SZ_256, /* the route pass rebuilds a Route from this much */ + NX_REST_LEN = 3, /* "..." */ +}; + +/* The path of a route file below its app directory, NULL for any other file. */ +static const char *nx_app_relative(const char *rel_path) { + const char *base = rel_path ? strrchr(rel_path, '/') : NULL; + if (!base || (strcmp(base, "/route.ts") != 0 && strcmp(base, "/route.js") != 0)) { + return NULL; + } + if (strncmp(rel_path, "app/", strlen("app/")) == 0) { + return rel_path + strlen("app/"); + } + if (strncmp(rel_path, "src/app/", strlen("src/app/")) == 0) { + return rel_path + strlen("src/app/"); + } + return NULL; +} + +static bool nx_has_any(const char *s, size_t n, const char *chars) { + for (size_t i = 0; i < n; i++) { + if (strchr(chars, s[i])) { + return true; + } + } + return false; +} + +/* Append directory segment seg (n bytes) to the route path out[*len]. False + * when the segment cannot be spelled or the path would not fit. */ +static bool nx_append_segment(char *out, size_t *len, const char *seg, size_t n) { + if (n == 0 || seg[0] == '_' || seg[0] == '@') { + return false; + } + if (seg[0] == '(') { + /* A route group adds nothing; `(.)x`, `(..)x` and `(...)x` intercept. */ + return n > PAIR_LEN && seg[n - SKIP_ONE] == ')' && seg[SKIP_ONE] != '.' && + !nx_has_any(seg + SKIP_ONE, n - PAIR_LEN, "()"); + } + char marker = '\0'; + if (seg[0] == '[') { + if (seg[n - SKIP_ONE] != ']') { + return false; + } + marker = ':'; + seg += SKIP_ONE; + n -= PAIR_LEN; + if (n >= NX_REST_LEN && strncmp(seg, "...", NX_REST_LEN) == 0) { + marker = '*'; + seg += NX_REST_LEN; + n -= NX_REST_LEN; + } + if (n == 0 || nx_has_any(seg, n, "[].")) { + return false; + } + } else if (nx_has_any(seg, n, "[]")) { + return false; + } + size_t need = SKIP_ONE + (marker ? SKIP_ONE : 0) + n; + if (*len + need >= NX_PATH_MAX) { + return false; + } + out[(*len)++] = '/'; + if (marker) { + out[(*len)++] = marker; + } + memcpy(out + *len, seg, n); + *len += n; + out[*len] = '\0'; + return true; +} + +/* The route path of the file being extracted, in the arena; NULL for none. */ +static const char *nx_route_path(CBMExtractCtx *ctx) { + const char *seg = nx_app_relative(ctx->rel_path); + if (!seg) { + return NULL; + } + const char *base = strrchr(ctx->rel_path, '/'); + char path[NX_PATH_MAX] = "/"; + size_t len = 0; + while (seg < base) { + const char *end = strchr(seg, '/'); + if (!nx_append_segment(path, &len, seg, (size_t)(end - seg))) { + return NULL; + } + seg = end + SKIP_ONE; + } + return cbm_arena_strdup(ctx->arena, path); +} + +static bool nx_is_method(const char *name) { + static const char *const methods[] = {"GET", "POST", "PUT", "PATCH", + "DELETE", "HEAD", "OPTIONS"}; + for (size_t i = 0; i < sizeof(methods) / sizeof(methods[0]); i++) { + if (strcmp(name, methods[i]) == 0) { + return true; + } + } + return false; +} + +static bool nx_is(TSNode n, const char *type) { + return !ts_node_is_null(n) && strcmp(ts_node_type(n), type) == 0; +} + +/* Does a node carry the anonymous keyword child kw (`default`, `type`)? */ +static bool nx_has_keyword(TSNode n, const char *kw) { + uint32_t count = ts_node_child_count(n); + for (uint32_t i = 0; i < count; i++) { + TSNode c = ts_node_child(n, i); + if (!ts_node_is_named(c) && strcmp(ts_node_type(c), kw) == 0) { + return true; + } + } + return false; +} + +/* The name of a declarator whose value is an arrow function, the Function + * extraction names after the binding (`GET = () => ...`); a null node for any + * other declarator. */ +static TSNode nx_arrow_binding(TSNode declarator) { + TSNode value = ts_node_child_by_field_name(declarator, TS_FIELD("value")); + TSNode name = ts_node_child_by_field_name(declarator, TS_FIELD("name")); + if (!nx_is(name, "identifier") || !nx_is(value, "arrow_function")) { + return (TSNode){0}; + } + return name; +} + +/* Route the Function definition named by name_node, declared inside decl. */ +static void nx_route_handler(CBMExtractCtx *ctx, TSNode decl, TSNode name_node, const char *path) { + const char *name = cbm_node_text(ctx->arena, name_node, ctx->source); + if (!name || !nx_is_method(name)) { + return; + } + const char *qn = + cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name, ctx->language); + uint32_t first = ts_node_start_point(decl).row + TS_LINE_OFFSET; + uint32_t last = ts_node_end_point(decl).row + TS_LINE_OFFSET; + for (int i = 0; qn && i < ctx->result->defs.count; i++) { + CBMDefinition *def = &ctx->result->defs.items[i]; + if (!def->route_path && def->label && def->qualified_name && + strcmp(def->label, "Function") == 0 && strcmp(def->qualified_name, qn) == 0 && + def->start_line >= first && def->end_line <= last) { + def->route_path = path; + def->route_method = name; + return; + } + } +} + +/* Does the name node, if any, spell want? Any name does when want is NULL. */ +static bool nx_wanted(CBMExtractCtx *ctx, TSNode name, const char *want) { + if (ts_node_is_null(name)) { + return false; + } + const char *text = want ? cbm_node_text(ctx->arena, name, ctx->source) : NULL; + return !want || (text && strcmp(text, want) == 0); +} + +/* Route the handlers a top-level declaration (function or const) defines. + * Only names equal to want are considered when want is set. */ +static void nx_route_declaration(CBMExtractCtx *ctx, TSNode decl, const char *path, + const char *want) { + if (nx_is(decl, "function_declaration")) { + TSNode name = ts_node_child_by_field_name(decl, TS_FIELD("name")); + if (nx_wanted(ctx, name, want)) { + nx_route_handler(ctx, decl, name, path); + } + return; + } + if (!nx_is(decl, "lexical_declaration") && !nx_is(decl, "variable_declaration")) { + return; + } + uint32_t count = ts_node_named_child_count(decl); + for (uint32_t i = 0; i < count; i++) { + TSNode declarator = ts_node_named_child(decl, i); + TSNode name = + nx_is(declarator, "variable_declarator") ? nx_arrow_binding(declarator) : (TSNode){0}; + if (nx_wanted(ctx, name, want)) { + nx_route_handler(ctx, declarator, name, path); + } + } +} + +/* `export { GET }`: route the file's own top-level declaration of each + * unrenamed, non-type specifier. */ +static void nx_route_export_clause(CBMExtractCtx *ctx, TSNode clause, const char *path) { + uint32_t count = ts_node_named_child_count(clause); + uint32_t top = ts_node_named_child_count(ctx->root); + for (uint32_t i = 0; i < count; i++) { + TSNode spec = ts_node_named_child(clause, i); + TSNode name = ts_node_child_by_field_name(spec, TS_FIELD("name")); + if (!nx_is(spec, "export_specifier") || !nx_is(name, "identifier") || + !ts_node_is_null(ts_node_child_by_field_name(spec, TS_FIELD("alias"))) || + nx_has_keyword(spec, "type") || nx_has_keyword(spec, "typeof")) { + continue; + } + const char *want = cbm_node_text(ctx->arena, name, ctx->source); + for (uint32_t j = 0; want && j < top; j++) { + nx_route_declaration(ctx, ts_node_named_child(ctx->root, j), path, want); + } + } +} + +void cbm_extract_nextjs_routes(CBMExtractCtx *ctx) { + if (ctx->language != CBM_LANG_TYPESCRIPT && ctx->language != CBM_LANG_JAVASCRIPT) { + return; + } + const char *path = nx_route_path(ctx); + if (!path) { + return; + } + uint32_t count = ts_node_named_child_count(ctx->root); + for (uint32_t i = 0; i < count; i++) { + TSNode node = ts_node_named_child(ctx->root, i); + /* A runtime export of this file: not `export default`, not + * `export type { ... }`, not a re-export `... from './x'`. */ + if (!nx_is(node, "export_statement") || nx_has_keyword(node, "default") || + nx_has_keyword(node, "type") || + !ts_node_is_null(ts_node_child_by_field_name(node, TS_FIELD("source")))) { + continue; + } + TSNode decl = ts_node_child_by_field_name(node, TS_FIELD("declaration")); + if (!ts_node_is_null(decl)) { + nx_route_declaration(ctx, decl, path, NULL); + continue; + } + uint32_t parts = ts_node_named_child_count(node); + for (uint32_t j = 0; j < parts; j++) { + TSNode part = ts_node_named_child(node, j); + if (nx_is(part, "export_clause")) { + nx_route_export_clause(ctx, part, path); + } + } + } +} diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index fb5b724d64..bf72989a06 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1645,8 +1645,13 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr * a kube / k8s task DEPLOYS {via:ansible} the template it applies * (pass_ansible.c). An external Chart is keyed by its publisher URL. * An index built before holds none of them, so it rebuilds once on - * upgrade. */ -enum { CBM_SEMANTIC_INDEX_VERSION = 9 }; + * upgrade. + * 10: a Next.js App Router route handler, the HTTP verb Function a route.ts + * / route.js under app/ or src/app/ exports, carries the route_path its + * directories name and HANDLES its Route (extract_nextjs.c). An index + * built before has none of these Routes for its unchanged route files, + * so it rebuilds once on upgrade. */ +enum { CBM_SEMANTIC_INDEX_VERSION = 10 }; typedef struct { cbm_gbuf_t *gbuf; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index e1c1e4491f..15f03463a9 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -5622,6 +5622,177 @@ TEST(extract_ts_nestjs_controller_routes_issue1428) { PASS(); } +/* #759: the one definition named `name`; NULL for none or more than one. */ +static const CBMDefinition *nextjs_only_def(CBMFileResult *r, const char *name) { + const CBMDefinition *found = NULL; + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0) { + if (found) { + return NULL; + } + found = &r->defs.items[i]; + } + } + return found; +} + +/* #759: a Next.js App Router route file's exported HTTP verb handlers are the + * Functions extraction already makes for them, now carrying the route path of + * the file's directories -- `[id]` as `:id`, `[...slug]` as `*slug`, a + * `(group)` adding nothing -- and the verb as their method. A local + * declaration exported by name (`export { GET }`) is that same Function; no + * second definition appears. The 255-byte path is the longest that fits. */ +TEST(extract_nextjs_route_metadata_contract_issue759) { + char long_file[CBM_SZ_512]; + char long_path[CBM_SZ_256]; + char segment[CBM_SZ_256]; + memset(segment, 'x', CBM_SZ_256 - PAIR_LEN); + segment[CBM_SZ_256 - PAIR_LEN] = '\0'; + snprintf(long_file, sizeof(long_file), "app/%s/route.ts", segment); + snprintf(long_path, sizeof(long_path), "/%s", segment); + static const char verbs_src[] = "export async function GET() {}\n" + "export async function POST() {}\n" + "export function PUT() {}\n" + "export function PATCH() {}\n" + "export function DELETE() {}\n" + "export function HEAD() {}\n" + "export function OPTIONS() {}\n"; + const struct { + const char *file; + const char *src; + const char *path; + const char *names[CBM_SZ_8]; + } cases[] = { + {"src/app/api/users/[id]/route.ts", + "export function GET() { return 1; }\nexport const POST = async () => 2;\n", + "/api/users/:id", + {"GET", "POST"}}, + {"app/api/verbs/route.ts", + verbs_src, + "/api/verbs", + {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}}, + {"src/app/(public)/api/[org]/users/[id]/route.ts", + "export async function GET() { return 1; }\n", + "/api/:org/users/:id", + {"GET"}}, + {"app/api/[...slug]/route.ts", "export function GET() {}\n", "/api/*slug", {"GET"}}, + {"app/api/typed/route.ts", + "type Handler = () => Promise;\nexport const GET: Handler = async () => 1;\n", + "/api/typed", + {"GET"}}, + {"app/route.js", "export function GET() { return 1; }\n", "/", {"GET"}}, + {"app/api/local/route.ts", + "function GET() { return 1; }\nconst POST = async () => 2;\nexport { GET, POST };\n", + "/api/local", + {"GET", "POST"}}, + {long_file, "export function GET() {}\n", long_path, {"GET"}}, + }; + for (size_t c = 0; c < sizeof(cases) / sizeof(cases[0]); c++) { + CBMLanguage lang = strstr(cases[c].file, ".js") ? CBM_LANG_JAVASCRIPT : CBM_LANG_TYPESCRIPT; + CBMFileResult *r = extract(cases[c].src, lang, "t", cases[c].file); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + int routed = 0; + for (int i = 0; i < r->defs.count; i++) { + routed += r->defs.items[i].route_path != NULL; + } + int want = 0; + for (; want < CBM_SZ_8 && cases[c].names[want]; want++) { + const CBMDefinition *d = nextjs_only_def(r, cases[c].names[want]); + ASSERT_NOT_NULL(d); + ASSERT_STR_EQ(d->label, "Function"); + ASSERT_STR_EQ(d->file_path, cases[c].file); + ASSERT_NOT_NULL(d->route_path); + ASSERT_STR_EQ(d->route_path, cases[c].path); + ASSERT_STR_EQ(d->route_method, cases[c].names[want]); + } + ASSERT_EQ(routed, want); + cbm_free_result(r); + } + PASS(); +} + +/* #759: nothing else routes. Not a route file, or a directory the route path + * cannot spell -- an optional catch-all, a private folder, a parallel slot, + * an intercepting route, a malformed bracket, a workspace package root, a path + * past 255 bytes. Not a runtime export of an HTTP verb Function of this file: + * an unexported, default, type-only or nested GET, a class method, a + * constant, a lower-case verb; and no handler is made up for a renamed, + * re-exported, wrapped or destructured export, or a `function` expression + * bound to a const: none of them has a Function of its own here. */ +TEST(extract_nextjs_rejects_nonroutes_issue759) { + char long_file[CBM_SZ_512]; + char segment[CBM_SZ_256]; + memset(segment, 'x', CBM_SZ_256 - SKIP_ONE); + segment[CBM_SZ_256 - SKIP_ONE] = '\0'; + snprintf(long_file, sizeof(long_file), "app/%s/route.ts", segment); + const char *files[] = { + "pages/api/route.ts", + "app/api/page.ts", + "app/api/route.test.ts", + "app/api/view/route.tsx", + "app/api/view/route.jsx", + "app/api/module/route.mjs", + "app/_private/route.ts", + "src/app/api/_lib/route.ts", + "app/@modal/route.ts", + "app/(.)photo/route.ts", + "app/(..)(..)photo/route.ts", + "app/api/[[...opt]]/route.ts", + "app/api/[broken/route.ts", + "app/api/broken]/route.ts", + "app/api/[]/route.ts", + "app/api/[...]/route.ts", + "app/api/a[id]/route.ts", + "app/()/route.ts", + "apps/web/app/api/route.ts", + long_file, + }; + const struct { + const char *file; + const char *src; + } sources[] = { + {"app/api/nonexports/route.ts", "function GET() {}\n" + "export default function POST() {}\n" + "function PUT() {}\n" + "export type { PUT };\n" + "function OPTIONS() {}\n" + "export { type OPTIONS };\n" + "export class Handlers { PATCH() {} }\n" + "export const DELETE = 42;\n" + "export function get() {}\n" + "export function helper() { function HEAD() {} }\n"}, + {"app/api/bindings/route.ts", "const handler = async () => 1;\n" + "export { handler as GET };\n" + "export { POST } from './impl';\n" + "export const PUT = wrap(handler);\n" + "export const DELETE = function () {};\n" + "export const { PATCH } = handlers;\n" + "export const HEAD = handler as Handler;\n" + "export const OPTIONS = (async () => 1) satisfies Handler;\n" + "export * from './all';\n"}, + }; + const int file_count = (int)(sizeof(files) / sizeof(files[0])); + const int source_count = (int)(sizeof(sources) / sizeof(sources[0])); + for (int c = 0; c < file_count + source_count; c++) { + bool by_file = c < file_count; + const char *file = by_file ? files[c] : sources[c - file_count].file; + CBMFileResult *r = + extract(by_file ? "export function GET() {}\n" : sources[c - file_count].src, + CBM_LANG_TYPESCRIPT, "t", file); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + if (by_file) { + ASSERT_NOT_NULL(nextjs_only_def(r, "GET")); + } + for (int i = 0; i < r->defs.count; i++) { + ASSERT_NULL(r->defs.items[i].route_path); + } + cbm_free_result(r); + } + PASS(); +} + /* Return the file's Module definition (extraction pushes it first), or NULL. */ static const CBMDefinition *find_module_def(CBMFileResult *r) { for (int i = 0; i < r->defs.count; i++) { @@ -13434,6 +13605,8 @@ SUITE(extraction) { RUN_TEST(extract_java_jaxrs_empty_path_means_class_path); RUN_TEST(extract_java_spring_relative_string_not_route_path); RUN_TEST(extract_ts_nestjs_controller_routes_issue1428); + RUN_TEST(extract_nextjs_route_metadata_contract_issue759); + RUN_TEST(extract_nextjs_rejects_nonroutes_issue759); RUN_TEST(extract_blazor_page_directive_routes_component); RUN_TEST(extract_blazor_component_without_page_has_no_route); RUN_TEST(extract_razor_page_directive_routes_cshtml_view); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index bd76775f30..060e870221 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -18874,6 +18874,423 @@ TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1) { PASS(); } +/* #759: index a Next.js App Router tree as project "nextjs" (a full index, or + * the incremental route a stored index of the same tree takes). */ +static int nextjs_index(const char *repo, const char *db_path) { + cbm_pipeline_t *p = cbm_pipeline_new(repo, db_path, CBM_MODE_FULL); + if (!p) { + return -1; + } + (void)cbm_pipeline_set_project_name(p, "nextjs"); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + return rc; +} + +/* Every Route of a stored index with each handler that HANDLES it, one sorted + * line each: "METHOD path route_qn file_path handler_qn". A Route no handler + * reaches ends in "-". The properties are parsed (json_extract): a Route not + * from route metadata is marked "!source", a handler declared in another file + * "!file", a HANDLES edge whose handler property is not its handler's QN + * "!handler"; malformed JSON fails the query. */ +static bool nextjs_route_tuples(const char *db_path, char *out, size_t sz) { + sqlite3 *db = NULL; + sqlite3_stmt *st = NULL; + bool ok = false; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK && + sqlite3_prepare_v2( + db, + "SELECT coalesce(group_concat(t, char(10)), '') FROM (SELECT " + "json_extract(r.properties,'$.method') || ' ' || r.name || ' ' || r.qualified_name || " + "' ' || r.file_path || ' ' || coalesce(h.qualified_name, '-') || CASE WHEN " + "json_extract(r.properties,'$.source') = 'decorator' THEN '' ELSE ' !source' END || " + "CASE WHEN h.id IS NULL OR h.file_path = r.file_path THEN '' ELSE ' !file' END || " + "CASE WHEN e.id IS NULL OR json_extract(e.properties,'$.handler') = h.qualified_name " + "THEN '' ELSE ' !handler' END AS t FROM nodes r LEFT JOIN edges e ON " + "e.target_id=r.id AND e.type='HANDLES' LEFT JOIN nodes h ON h.id=e.source_id WHERE " + "r.label='Route' ORDER BY t)", + -1, &st, NULL) == SQLITE_OK && + sqlite3_step(st) == SQLITE_ROW) { + snprintf(out, sz, "%s", (const char *)sqlite3_column_text(st, 0)); + ok = true; + } + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +static int nextjs_by_qn(const void *a, const void *b) { + return strcmp(((const cbm_search_result_t *)a)->node.qualified_name, + ((const cbm_search_result_t *)b)->node.qualified_name); +} + +/* The Routes a search by label Route and declaring-file glob finds, one + * "qualified_name file_path" line each in QN order. */ +static bool nextjs_found_routes(const char *db_path, const char *glob, char *out, size_t sz) { + cbm_store_t *s = cbm_store_open_path_existing(db_path); + if (!s) { + return false; + } + cbm_search_params_t params = {.project = "nextjs", + .label = "Route", + .file_pattern = glob, + .min_degree = -1, + .max_degree = -1, + .limit = 100}; + cbm_search_output_t res = {0}; + bool ok = cbm_store_search(s, ¶ms, &res) == CBM_STORE_OK; + if (ok && res.count > 1) { + qsort(res.results, (size_t)res.count, sizeof(res.results[0]), nextjs_by_qn); + } + size_t len = 0; + out[0] = '\0'; + for (int i = 0; ok && i < res.count; i++) { + int n = snprintf(out + len, sz - len, "%s%s %s", i ? "\n" : "", + res.results[i].node.qualified_name, res.results[i].node.file_path); + ok = n >= 0 && (size_t)n < sz - len; + len += ok ? (size_t)n : 0; + } + cbm_store_search_free(&res); + cbm_store_close(s); + return ok; +} + +/* One index of a Next.js tree: its result, the route it took and the stored + * Routes after it (nextjs_route_tuples). */ +typedef struct { + int rc; + cbm_incremental_route_t route; + bool read; + char tuples[1024]; +} nextjs_run_t; + +static void nextjs_run(const char *repo, const char *db_path, nextjs_run_t *run) { + run->rc = nextjs_index(repo, db_path); + run->route = cbm_pipeline_incremental_test_last_route(); + run->read = nextjs_route_tuples(db_path, run->tuples, sizeof(run->tuples)); +} + +/* #759: the HTTP verb Functions a Next.js route file exports HANDLE the Routes + * its directories name, in the stored graph: a dynamic segment, a group, a + * catch-all and the root, under src/app/ and app/. Each Route is there once, + * with its file, from its own handler, whose HANDLES property is its QN. A + * search by label Route and the declaring file's glob finds them. `fillers` + * more files put the index on the parallel extraction path. */ +static int nextjs_persisted_contract(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_nextjs_routes_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "lib/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + write_temp_file(tmp, "src/app/api/users/[id]/route.ts", + "export function GET() {\n return 1;\n}\n" + "export const POST = async () => 1;\n"); + write_temp_file(tmp, "src/app/(public)/api/[org]/users/[id]/route.ts", + "export async function GET() {\n return 2;\n}\n"); + write_temp_file(tmp, "app/api/[...slug]/route.ts", "export function GET() {\n return 3;\n}\n"); + write_temp_file(tmp, "app/route.js", "export function GET() {\n return 4;\n}\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/nextjs.db", tmp); + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + nextjs_run_t full; + nextjs_run(tmp, db_path, &full); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + char found[1024]; + bool searched = + nextjs_found_routes(db_path, "src/app/api/users/*/route.ts", found, sizeof(found)); + th_rmtree(tmp); + + ASSERT_EQ(full.rc, 0); + ASSERT_TRUE(full.read); + ASSERT_STR_EQ( + full.tuples, + "GET / __route__GET__/ app/route.js nextjs.app.route.GET\n" + "GET /api/*slug __route__GET__/api/*slug app/api/[...slug]/route.ts " + "nextjs.app.api.[...slug].route.GET\n" + "GET /api/:org/users/:id __route__GET__/api/{}/users/{} " + "src/app/(public)/api/[org]/users/[id]/route.ts " + "nextjs.src.app.(public).api.[org].users.[id].route.GET\n" + "GET /api/users/:id __route__GET__/api/users/{} src/app/api/users/[id]/route.ts " + "nextjs.src.app.api.users.[id].route.GET\n" + "POST /api/users/:id __route__POST__/api/users/{} src/app/api/users/[id]/route.ts " + "nextjs.src.app.api.users.[id].route.POST"); + ASSERT_TRUE(searched); + ASSERT_STR_EQ(found, "__route__GET__/api/users/{} src/app/api/users/[id]/route.ts\n" + "__route__POST__/api/users/{} src/app/api/users/[id]/route.ts"); + PASS(); +} + +TEST(pipeline_nextjs_persisted_route_contract_issue759) { + return nextjs_persisted_contract(0); +} + +TEST(pipeline_nextjs_persisted_route_contract_parallel_issue759) { + return nextjs_persisted_contract(52); +} + +/* #759 over the life of a route file. Each step re-indexes the stored graph; + * after each, the file's current handlers are its only Routes, each once, and + * an unrelated route file keeps its Route. An unchanged tree is a no-op, and a + * change to another file rebuilds the unchanged route files' Routes from their + * stored handlers (pass_route_nodes.c), which a search still finds. */ +TEST(pipeline_nextjs_incremental_lifecycle_issue759) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_nextjs_life_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "app/api/items/route.ts", + "export function GET() {\n return 1;\n}\n" + "export async function POST() {\n return 2;\n}\n"); + write_temp_file(tmp, "app/api/health/route.ts", "export const GET = () => 'ok';\n"); + write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 1;\n}\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/nextjs.db", tmp); + + nextjs_run_t full; + nextjs_run(tmp, db_path, &full); + nextjs_run_t same; + nextjs_run(tmp, db_path, &same); + char found_same[1024]; + bool searched_same = + nextjs_found_routes(db_path, "app/api/*/route.ts", found_same, sizeof(found_same)); + + write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 2;\n}\n"); + nextjs_run_t helper_only; + nextjs_run(tmp, db_path, &helper_only); + char found_repair[1024]; + bool searched_repair = + nextjs_found_routes(db_path, "app/api/*/route.ts", found_repair, sizeof(found_repair)); + + write_temp_file(tmp, "app/api/items/route.ts", "export function GET() {\n return 1;\n}\n"); + nextjs_run_t no_post; + nextjs_run(tmp, db_path, &no_post); + + write_temp_file(tmp, "app/api/items/route.ts", + "export function GET() {\n const n = 10;\n return n;\n}\n"); + nextjs_run_t new_body; + nextjs_run(tmp, db_path, &new_body); + + write_temp_file(tmp, "app/api/items/route.ts", "export function PATCH() {\n return 3;\n}\n"); + nextjs_run_t patch; + nextjs_run(tmp, db_path, &patch); + + write_temp_file(tmp, "app/api/products/route.ts", + "export function PATCH() {\n return 3;\n}\n"); + stem_unlink(tmp, "app/api/items/route.ts"); + nextjs_run_t moved; + nextjs_run(tmp, db_path, &moved); + + write_temp_file(tmp, "app/api/products/[id]/route.ts", + "export function PATCH() {\n return 3;\n}\n"); + stem_unlink(tmp, "app/api/products/route.ts"); + nextjs_run_t dynamic; + nextjs_run(tmp, db_path, &dynamic); + + stem_unlink(tmp, "app/api/products/[id]/route.ts"); + nextjs_run_t deleted; + nextjs_run(tmp, db_path, &deleted); + th_rmtree(tmp); + + static const char health[] = "GET /api/health __route__GET__/api/health " + "app/api/health/route.ts nextjs.app.api.health.route.GET"; + static const char routes[] = "GET /api/health __route__GET__/api/health " + "app/api/health/route.ts nextjs.app.api.health.route.GET\n" + "GET /api/items __route__GET__/api/items app/api/items/route.ts " + "nextjs.app.api.items.route.GET\n" + "POST /api/items __route__POST__/api/items app/api/items/route.ts " + "nextjs.app.api.items.route.POST"; + static const char get_only[] = "GET /api/health __route__GET__/api/health " + "app/api/health/route.ts nextjs.app.api.health.route.GET\n" + "GET /api/items __route__GET__/api/items app/api/items/route.ts " + "nextjs.app.api.items.route.GET"; + ASSERT_EQ(full.rc, 0); + ASSERT_TRUE(full.read); + ASSERT_STR_EQ(full.tuples, routes); + ASSERT_EQ(same.rc, 0); + ASSERT_EQ(same.route, CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_STR_EQ(same.tuples, routes); + ASSERT_TRUE(searched_same); + ASSERT_STR_EQ(found_same, "__route__GET__/api/health app/api/health/route.ts\n" + "__route__GET__/api/items app/api/items/route.ts\n" + "__route__POST__/api/items app/api/items/route.ts"); + ASSERT_EQ(helper_only.rc, 0); + ASSERT_EQ(helper_only.route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_STR_EQ(helper_only.tuples, routes); + ASSERT_TRUE(searched_repair); + ASSERT_STR_EQ(found_repair, found_same); + ASSERT_EQ(no_post.rc, 0); + ASSERT_EQ(no_post.route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_STR_EQ(no_post.tuples, get_only); + ASSERT_EQ(new_body.rc, 0); + ASSERT_EQ(new_body.route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_STR_EQ(new_body.tuples, get_only); + ASSERT_EQ(patch.rc, 0); + ASSERT_STR_EQ(patch.tuples, "GET /api/health __route__GET__/api/health " + "app/api/health/route.ts nextjs.app.api.health.route.GET\n" + "PATCH /api/items __route__PATCH__/api/items " + "app/api/items/route.ts nextjs.app.api.items.route.PATCH"); + ASSERT_EQ(moved.rc, 0); + ASSERT_STR_EQ(moved.tuples, "GET /api/health __route__GET__/api/health " + "app/api/health/route.ts nextjs.app.api.health.route.GET\n" + "PATCH /api/products __route__PATCH__/api/products " + "app/api/products/route.ts nextjs.app.api.products.route.PATCH"); + ASSERT_EQ(dynamic.rc, 0); + ASSERT_STR_EQ(dynamic.tuples, + "GET /api/health __route__GET__/api/health " + "app/api/health/route.ts nextjs.app.api.health.route.GET\n" + "PATCH /api/products/:id __route__PATCH__/api/products/{} " + "app/api/products/[id]/route.ts nextjs.app.api.products.[id].route.PATCH"); + ASSERT_EQ(deleted.rc, 0); + ASSERT_STR_EQ(deleted.tuples, health); + PASS(); +} + +/* CBM_SEMANTIC_INDEX_VERSION 10 (#759): an index version 9 wrote has no route + * metadata, Route or HANDLES for a Next.js route file, and an unchanged tree + * is otherwise a no-op, so the version is what makes the new binary rebuild + * it. The stored index is put back to the version-9 state by hand (metadata, + * the handlers' route properties, the Routes and their edges); the run after + * that must replace it in full, the next one is a no-op. */ +TEST(pipeline_nextjs_semantic_upgrade_issue759) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_nextjs_upgrade_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "app/api/items/route.ts", + "export function GET() {\n return 1;\n}\n" + "export const POST = async () => 2;\n"); + write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 1;\n}\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/nextjs.db", tmp); + static const char all_nodes[] = "SELECT COUNT(*) FROM nodes"; + static const char all_edges[] = "SELECT COUNT(*) FROM edges"; + static const char routed[] = + "SELECT COUNT(*) FROM nodes WHERE json_extract(properties,'$.route_path') IS NOT NULL"; + + nextjs_run_t full; + nextjs_run(tmp, db_path, &full); + int nodes_before = k8s_inc_count(db_path, all_nodes); + int edges_before = k8s_inc_count(db_path, all_edges); + + /* Put the published index back to what version 9 wrote. */ + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + cbm_coverage_row_t *coverage_rows = NULL; + int coverage_count = 0; + ASSERT_EQ(cbm_store_coverage_get(store, "nextjs", &coverage_rows, &coverage_count), + CBM_STORE_OK); + cbm_coverage_meta_t meta = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(store, "nextjs", &meta), CBM_STORE_OK); + cbm_coverage_meta_t old_meta = meta; + old_meta.coverage_version = 9; + ASSERT_EQ( + cbm_store_coverage_replace_ex(store, "nextjs", coverage_rows, coverage_count, &old_meta), + CBM_STORE_OK); + cbm_store_free_coverage(coverage_rows, coverage_count); + cbm_store_coverage_meta_clear(&meta); + ASSERT_EQ(cbm_store_exec(store, + "DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE " + "label = 'Route') OR target_id IN (SELECT id FROM nodes WHERE " + "label = 'Route');" + "DELETE FROM nodes WHERE label = 'Route';" + "UPDATE nodes SET properties = json_remove(properties, " + "'$.route_path', '$.route_method') " + "WHERE json_extract(properties, '$.route_path') IS NOT NULL;"), + CBM_STORE_OK); + cbm_store_close(store); + nextjs_run_t version_9 = {0}; + version_9.read = nextjs_route_tuples(db_path, version_9.tuples, sizeof(version_9.tuples)); + int routed_9 = k8s_inc_count(db_path, routed); + + /* Nothing in the tree changed: only the version says the index is stale. */ + nextjs_run_t upgrade; + nextjs_run(tmp, db_path, &upgrade); + int nodes_after = k8s_inc_count(db_path, all_nodes); + int edges_after = k8s_inc_count(db_path, all_edges); + int routed_after = k8s_inc_count(db_path, routed); + store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + cbm_coverage_meta_t new_meta = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(store, "nextjs", &new_meta), CBM_STORE_OK); + int stored_version = new_meta.coverage_version; + cbm_store_coverage_meta_clear(&new_meta); + cbm_store_close(store); + nextjs_run_t again; + nextjs_run(tmp, db_path, &again); + th_rmtree(tmp); + + ASSERT_EQ(full.rc, 0); + ASSERT_STR_EQ(full.tuples, "GET /api/items __route__GET__/api/items app/api/items/route.ts " + "nextjs.app.api.items.route.GET\n" + "POST /api/items __route__POST__/api/items app/api/items/route.ts " + "nextjs.app.api.items.route.POST"); + ASSERT_TRUE(version_9.read); + ASSERT_STR_EQ(version_9.tuples, ""); + ASSERT_EQ(routed_9, 0); + ASSERT_EQ(upgrade.rc, 0); + ASSERT_EQ(upgrade.route, CBM_INCREMENTAL_ROUTE_FORCED_FULL); + ASSERT_STR_EQ(upgrade.tuples, full.tuples); + ASSERT_EQ(routed_after, 2); + ASSERT_EQ(nodes_after, nodes_before); + ASSERT_EQ(edges_after, edges_before); + ASSERT_EQ(stored_version, CBM_SEMANTIC_INDEX_VERSION); + ASSERT_EQ(again.rc, 0); + ASSERT_EQ(again.route, CBM_INCREMENTAL_ROUTE_NOOP); + ASSERT_STR_EQ(again.tuples, full.tuples); + ASSERT_GTE(CBM_SEMANTIC_INDEX_VERSION, 10); + PASS(); +} + +/* #759: a quote is legal in a POSIX directory name, so a route path, its file + * and its handler's QN can hold one, escaped in the stored JSON. The route pass + * builds the Route from its handler's stored properties on every index + * (pass_route_nodes.c), so the full index and an incremental one after an + * unrelated edit must both store it intact. */ +TEST(pipeline_nextjs_quoted_route_round_trip_issue759) { +#ifdef _WIN32 + SKIP_PLATFORM("a quote is not a legal Windows path character"); +#else + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_nextjs_quote_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "app/api/a\"b/route.ts", "export function GET() {\n return 1;\n}\n"); + write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 1;\n}\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/nextjs.db", tmp); + + nextjs_run_t full; + nextjs_run(tmp, db_path, &full); + write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 2;\n}\n"); + nextjs_run_t helper_only; + nextjs_run(tmp, db_path, &helper_only); + th_rmtree(tmp); + + ASSERT_EQ(full.rc, 0); + ASSERT_TRUE(full.read); + ASSERT_STR_EQ(full.tuples, "GET /api/a\"b __route__GET__/api/a\"b app/api/a\"b/route.ts " + "nextjs.app.api.a\"b.route.GET"); + ASSERT_EQ(helper_only.rc, 0); + ASSERT_EQ(helper_only.route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_TRUE(helper_only.read); + ASSERT_STR_EQ(helper_only.tuples, full.tuples); + PASS(); +#endif +} + /* `#include ` from arch/x/bugs.c: two headers end with the * include path (include/linux/device.h, tools/virtio/linux/device.h) and each * declares `struct device`. The exact-file lookup returned the first matching @@ -27771,6 +28188,11 @@ SUITE(pipeline) { RUN_TEST(pipeline_c_variants_on_sequential_and_parallel_paths_c1); RUN_TEST(pipeline_cpp_overloads_are_listed_as_variants_c1); RUN_TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1); + RUN_TEST(pipeline_nextjs_persisted_route_contract_issue759); + RUN_TEST(pipeline_nextjs_persisted_route_contract_parallel_issue759); + RUN_TEST(pipeline_nextjs_incremental_lifecycle_issue759); + RUN_TEST(pipeline_nextjs_semantic_upgrade_issue759); + RUN_TEST(pipeline_nextjs_quoted_route_round_trip_issue759); RUN_TEST(pipeline_python_app_factory_routes_issue1548); RUN_TEST(pipeline_python_nested_def_is_function_local_issue1548); RUN_TEST(pipeline_python_local_classes_stay_in_their_def_issue1548); From 51b48f110437324866b550fd10b72a3c6333a78f Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Sun, 13 Sep 2026 13:52:16 +0300 Subject: [PATCH 33/65] fix(test): isolate benchmark harness daemon runtimes scripts/benchmark-index.sh and scripts/benchmark-search-graph.sh ran the product with no runtime or cache of their own: the benchmark repository was indexed into the operator's live store and every one-shot joined the operator's account daemon. Source scripts/test-runtime.sh in both, start the private daemon before timing, record setup-time.txt and total-time.txt beside index-time.txt, and index from a repository path instead of querying a project in the live store. Add tests/test_benchmark_runtime_isolation_contract.sh, which fails before this change, and wire it into scripts/test.sh. Part of #1696. Signed-off-by: Anton Standrik --- scripts/benchmark-index.sh | 20 ++++ scripts/benchmark-search-graph.sh | 38 +++++++- scripts/test.sh | 3 + ...st_benchmark_runtime_isolation_contract.sh | 96 +++++++++++++++++++ 4 files changed, 152 insertions(+), 5 deletions(-) create mode 100755 tests/test_benchmark_runtime_isolation_contract.sh diff --git a/scripts/benchmark-index.sh b/scripts/benchmark-index.sh index 756bda06ed..71e694c171 100755 --- a/scripts/benchmark-index.sh +++ b/scripts/benchmark-index.sh @@ -9,6 +9,14 @@ LANG="${2:?}" REPO="${3:?}" RESULTS_DIR="${4:?}" +# The index must run against a daemon rendezvous and cache this run owns: only +# CBM_RUNTIME_DIR moves the rendezvous, and without a private cache the +# benchmark repository was indexed into the operator's live store (#1696). +# shellcheck source=test-runtime.sh +source "$(dirname "${BASH_SOURCE[0]}")/test-runtime.sh" +cbm_test_runtime_init +trap 'cbm_test_runtime_cleanup "$BINARY"' EXIT + # Resolve symlinks REPO=$(cd "$REPO" && pwd -P) @@ -33,6 +41,16 @@ LOC=$(find "$REPO" -type f \ echo "$FILE_COUNT" > "$OUT/file-count.txt" echo "$LOC" > "$OUT/loc.txt" +# Start the private daemon before timing so index-time.txt measures the index +# alone. setup-time.txt keeps the activation cost attributable and +# total-time.txt is their sum — the figure comparable with earlier runs, which +# paid activation inside the index timing whenever no daemon was already warm. +SETUP_START_MS=$(python3 -c "import time; print(int(time.time()*1000))") +if ! "$BINARY" daemon start >/dev/null 2>&1; then + echo " $LANG: private daemon did not start" >&2 + exit 1 +fi + # Index via CLI and capture timing START_MS=$(python3 -c "import time; print(int(time.time()*1000))") @@ -43,6 +61,8 @@ ELAPSED=$((END_MS - START_MS)) echo "$INDEX_JSON" > "$OUT/00-index.json" echo "$ELAPSED" > "$OUT/index-time.txt" +echo "$((START_MS - SETUP_START_MS))" > "$OUT/setup-time.txt" +echo "$((END_MS - SETUP_START_MS))" > "$OUT/total-time.txt" # Extract node/edge counts (CLI wraps in MCP content envelope) NODES=$(echo "$INDEX_JSON" | python3 -c " diff --git a/scripts/benchmark-search-graph.sh b/scripts/benchmark-search-graph.sh index cc94147ec6..1d67b95818 100755 --- a/scripts/benchmark-search-graph.sh +++ b/scripts/benchmark-search-graph.sh @@ -3,18 +3,46 @@ # codebase-memory-mcp binary to measure the regex / LIKE pre-filter performance. # # Usage: -# scripts/benchmark-search-graph.sh +# scripts/benchmark-search-graph.sh # # Example: -# scripts/benchmark-search-graph.sh ./build/c/codebase-memory-mcp my-project +# scripts/benchmark-search-graph.sh ./build/c/codebase-memory-mcp ~/src/my-project +# +# The repository is indexed (untimed) into a private runtime and cache first; +# the queries then run against that index through a daemon this run keeps warm, +# so a timing never includes daemon activation and never touches the operator's +# live store (#1696). set -euo pipefail -BINARY="${1:?Usage: $0 }" -PROJECT="${2:?Usage: $0 }" +BINARY="${1:?Usage: $0 }" +REPO="${2:?Usage: $0 }" +REPO=$(cd "$REPO" && pwd -P) + +# shellcheck source=test-runtime.sh +source "$(dirname "${BASH_SOURCE[0]}")/test-runtime.sh" +cbm_test_runtime_init +trap 'cbm_test_runtime_cleanup "$BINARY"' EXIT + +if ! "$BINARY" daemon start >/dev/null 2>&1; then + echo "private daemon did not start" >&2 + exit 1 +fi +INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":\"$REPO\",\"mode\":\"full\"}" 2>/dev/null || echo '{}') +PROJECT=$(echo "$INDEX_JSON" | python3 -c " +import json, sys +d = json.load(sys.stdin) +if 'content' in d: + d = json.loads(d['content'][0]['text']) +print(d.get('project', '')) +" 2>/dev/null || echo "") +if [ -z "$PROJECT" ]; then + echo "index of $REPO did not report a project" >&2 + exit 1 +fi echo "Binary: $BINARY" -echo "Project: $PROJECT" +echo "Project: $PROJECT (indexed from $REPO)" echo "" run_case() { diff --git a/scripts/test.sh b/scripts/test.sh index 7f4fea6e83..f95404712c 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -316,6 +316,9 @@ bash "$ROOT/tests/test_parallel_harness_contract.sh" echo "=== Step 0i2: suite failure-report contract ===" bash "$ROOT/tests/test_failure_report_contract.sh" +echo "=== Step 0i3: benchmark harness runtime isolation contract (#1696) ===" +bash "$ROOT/tests/test_benchmark_runtime_isolation_contract.sh" + echo "=== Step 0j: venue parity contract (one harness, every venue) ===" bash "$ROOT/tests/test_venue_parity_contract.sh" diff --git a/tests/test_benchmark_runtime_isolation_contract.sh b/tests/test_benchmark_runtime_isolation_contract.sh new file mode 100755 index 0000000000..b944df7683 --- /dev/null +++ b/tests/test_benchmark_runtime_isolation_contract.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runtime-isolation contract for the benchmark harnesses (#1696, follow-up to +# #1691). +# +# scripts/benchmark-index.sh and scripts/benchmark-search-graph.sh ran the +# product with no runtime or cache of their own: the index landed in the +# operator's live store, every one-shot joined the operator's account daemon, +# and the timings depended on whatever that daemon was doing. Drive both with +# an environment-probe fixture and require that no product process ever +# receives the caller's runtime or cache, and that the index benchmark records +# the setup cost it now pays explicitly. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +normalize_path() { + local path=${1%$'\r'} + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$path" 2>/dev/null && return 0 + fi + printf '%s\n' "${path//\\//}" +} + +ENV_PROBE="$WORKDIR/environment-probe" +cat > "$ENV_PROBE" <<'EOF' +#!/usr/bin/env bash +printf '%s\t%s\n' "${CBM_CACHE_DIR-}" "${CBM_RUNTIME_DIR-}" >> "$CBM_BENCH_ENV_PROBE" +[[ "${1-} ${2-}" == "daemon status" ]] && exit 1 +exit 0 +EOF +chmod +x "$ENV_PROBE" + +CALLER_CACHE="$WORKDIR/caller-cache" +CALLER_RUNTIME="$WORKDIR/caller-runtime" +REPO="$WORKDIR/repo" +mkdir -p "$CALLER_CACHE" "$CALLER_RUNTIME" "$REPO" +echo 'def bench(): return 1' > "$REPO/bench.py" +CALLER_CACHE_NORMALIZED=$(normalize_path "$CALLER_CACHE") +CALLER_RUNTIME_NORMALIZED=$(normalize_path "$CALLER_RUNTIME") + +# The fixture answers nothing, so the search benchmark stops once the index +# reports no project; only the environment handed to the product is under test. +assert_isolated() { + local harness="$1" env_log="$2" private_root="" + [[ -s "$env_log" ]] || fail "$harness did not execute the environment-probe fixture" + while IFS=$'\t' read -r child_cache_raw child_runtime_raw; do + local child_cache child_runtime + child_cache=$(normalize_path "$child_cache_raw") + child_runtime=$(normalize_path "$child_runtime_raw") + if [[ -z "$child_runtime" || "$child_runtime" == "$CALLER_RUNTIME_NORMALIZED" ]]; then + fail "$harness exposed the caller CBM_RUNTIME_DIR to a product process" + fi + if [[ -z "$child_cache" || "$child_cache" == "$CALLER_CACHE_NORMALIZED" ]]; then + fail "$harness exposed the caller CBM_CACHE_DIR to a product process" + fi + if [[ "${child_runtime%/*}" != "${child_cache%/*}" || + "${child_runtime##*/}" != "runtime" || "${child_cache##*/}" != "cache" ]]; then + fail "$harness runtime/cache were not isolated beneath one private root" + fi + if [[ -n "$private_root" && "$private_root" != "${child_runtime%/*}" ]]; then + fail "$harness switched private roots mid-run" + fi + private_root="${child_runtime%/*}" + done < "$env_log" + [[ ! -e "$private_root" ]] || fail "$harness left its private root behind: $private_root" +} + +INDEX_LOG="$WORKDIR/index-environment.log" +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$INDEX_LOG" \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" probe "$REPO" "$WORKDIR/results" \ + > "$WORKDIR/index.out" 2>&1 || true +assert_isolated "benchmark-index" "$INDEX_LOG" +for metric in setup-time total-time index-time; do + [[ -s "$WORKDIR/results/probe/$metric.txt" ]] || + fail "benchmark-index did not record $metric.txt" +done + +SEARCH_LOG="$WORKDIR/search-environment.log" +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$SEARCH_LOG" \ + "$ROOT/scripts/benchmark-search-graph.sh" "$ENV_PROBE" "$REPO" \ + > "$WORKDIR/search.out" 2>&1 || true +assert_isolated "benchmark-search-graph" "$SEARCH_LOG" + +echo "PASS: benchmark harnesses isolate their daemon runtime and cache from the caller" From 27a335b18c23a0a88f5bf3c212d295faf206ecd5 Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Mon, 21 Sep 2026 12:00:10 +0300 Subject: [PATCH 34/65] fix(test): escape benchmark paths, keep stderr, use monotonic clock Review follow-up for the benchmark harness isolation. - Both benchmark scripts build the index request with a JSON-escaped repository path (python3 json.dumps, the spelling soak-test.sh already uses): a path containing a quote or a backslash produced a payload the server could not parse. - benchmark-index.sh reads its three timestamps from one helper backed by time.monotonic_ns(); an NTP step mid-run no longer skews or negates a figure whose purpose is comparison across runs. - benchmark-search-graph.sh keeps the stderr of the index call and of the response parse in a run-private temp file (removed by the EXIT trap) and prints it, plus the first 500 bytes of the response, under the existing "did not report a project" line. - The contract test now refuses the index from its fixture and requires the cause to reach the output, and (POSIX only) drives a repository path with a quote and a backslash and requires the recorded request to parse as JSON with the resolved path. Both assertions fail against the previous head of this branch. Signed-off-by: Anton Standrik --- scripts/benchmark-index.sh | 19 +++++-- scripts/benchmark-search-graph.sh | 25 ++++++++-- ...st_benchmark_runtime_isolation_contract.sh | 49 +++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/scripts/benchmark-index.sh b/scripts/benchmark-index.sh index 71e694c171..0bc24af2f8 100755 --- a/scripts/benchmark-index.sh +++ b/scripts/benchmark-index.sh @@ -19,6 +19,17 @@ trap 'cbm_test_runtime_cleanup "$BINARY"' EXIT # Resolve symlinks REPO=$(cd "$REPO" && pwd -P) +# One pre-escaped spelling of the path for every request below, the way the +# soak harness builds its own: a repository path may legitimately contain a +# quote or a backslash, and hand-built JSON turns that into a parse error. +REPO_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$REPO") + +# Elapsed time is read from a monotonic clock, never the wall clock: an NTP +# step mid-run would otherwise skew — or negate — a figure whose whole purpose +# is comparison across runs. Its reference point is fixed per boot on every +# platform CPython supports here, so the three readings below are comparable +# even though each comes from its own process. +bench_now_ms() { python3 -c "import time; print(time.monotonic_ns() // 1000000)"; } OUT="$RESULTS_DIR/$LANG" mkdir -p "$OUT" @@ -45,18 +56,18 @@ echo "$LOC" > "$OUT/loc.txt" # alone. setup-time.txt keeps the activation cost attributable and # total-time.txt is their sum — the figure comparable with earlier runs, which # paid activation inside the index timing whenever no daemon was already warm. -SETUP_START_MS=$(python3 -c "import time; print(int(time.time()*1000))") +SETUP_START_MS=$(bench_now_ms) if ! "$BINARY" daemon start >/dev/null 2>&1; then echo " $LANG: private daemon did not start" >&2 exit 1 fi # Index via CLI and capture timing -START_MS=$(python3 -c "import time; print(int(time.time()*1000))") +START_MS=$(bench_now_ms) -INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":\"$REPO\",\"mode\":\"full\"}" 2>/dev/null || echo '{"error":"index failed"}') +INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":$REPO_JSON,\"mode\":\"full\"}" 2>/dev/null || echo '{"error":"index failed"}') -END_MS=$(python3 -c "import time; print(int(time.time()*1000))") +END_MS=$(bench_now_ms) ELAPSED=$((END_MS - START_MS)) echo "$INDEX_JSON" > "$OUT/00-index.json" diff --git a/scripts/benchmark-search-graph.sh b/scripts/benchmark-search-graph.sh index 1d67b95818..6296d31fb2 100755 --- a/scripts/benchmark-search-graph.sh +++ b/scripts/benchmark-search-graph.sh @@ -22,22 +22,41 @@ REPO=$(cd "$REPO" && pwd -P) # shellcheck source=test-runtime.sh source "$(dirname "${BASH_SOURCE[0]}")/test-runtime.sh" cbm_test_runtime_init -trap 'cbm_test_runtime_cleanup "$BINARY"' EXIT +BENCH_TMP="" +trap 'cbm_test_runtime_cleanup "$BINARY"; [ -z "$BENCH_TMP" ] || rm -rf -- "$BENCH_TMP"' EXIT +BENCH_TMP=$(mktemp -d) +INDEX_ERR="$BENCH_TMP/index-stderr.log" if ! "$BINARY" daemon start >/dev/null 2>&1; then echo "private daemon did not start" >&2 exit 1 fi -INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":\"$REPO\",\"mode\":\"full\"}" 2>/dev/null || echo '{}') +# One pre-escaped spelling of the path, the way the soak harness builds its +# own: a repository path may legitimately contain a quote or a backslash, and +# hand-built JSON turns that into a parse error. +REPO_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$REPO") +# Index and parse keep their stderr instead of discarding it: without it the +# failure below names only its symptom, and the cause — an unreadable +# repository, a refused daemon, a malformed envelope — is unrecoverable. +INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":$REPO_JSON,\"mode\":\"full\"}" \ + 2>"$INDEX_ERR" || echo '{}') PROJECT=$(echo "$INDEX_JSON" | python3 -c " import json, sys d = json.load(sys.stdin) if 'content' in d: d = json.loads(d['content'][0]['text']) print(d.get('project', '')) -" 2>/dev/null || echo "") +" 2>>"$INDEX_ERR" || echo "") if [ -z "$PROJECT" ]; then echo "index of $REPO did not report a project" >&2 + if [ -s "$INDEX_ERR" ]; then + echo "--- index/parse stderr ---" >&2 + cat "$INDEX_ERR" >&2 + fi + if [ -n "$INDEX_JSON" ]; then + echo "--- index response (first 500 bytes) ---" >&2 + printf '%.500s\n' "$INDEX_JSON" >&2 + fi exit 1 fi diff --git a/tests/test_benchmark_runtime_isolation_contract.sh b/tests/test_benchmark_runtime_isolation_contract.sh index b944df7683..1dc618ae69 100755 --- a/tests/test_benchmark_runtime_isolation_contract.sh +++ b/tests/test_benchmark_runtime_isolation_contract.sh @@ -33,6 +33,11 @@ ENV_PROBE="$WORKDIR/environment-probe" cat > "$ENV_PROBE" <<'EOF' #!/usr/bin/env bash printf '%s\t%s\n' "${CBM_CACHE_DIR-}" "${CBM_RUNTIME_DIR-}" >> "$CBM_BENCH_ENV_PROBE" +if [[ "${1-} ${2-}" == "cli index_repository" ]]; then + [[ -z "${CBM_BENCH_REQUEST_LOG-}" ]] || printf '%s\n' "${3-}" >> "$CBM_BENCH_REQUEST_LOG" + echo "probe: index refused" >&2 + exit 1 +fi [[ "${1-} ${2-}" == "daemon status" ]] && exit 1 exit 0 EOF @@ -93,4 +98,48 @@ CBM_BENCH_ENV_PROBE="$SEARCH_LOG" \ > "$WORKDIR/search.out" 2>&1 || true assert_isolated "benchmark-search-graph" "$SEARCH_LOG" +# A refused index must name its cause. The fixture writes one line to stderr and +# exits non-zero; discarding it leaves "did not report a project" as the only +# thing an operator sees. +grep -q -- '--- index/parse stderr ---' "$WORKDIR/search.out" || + fail "benchmark-search-graph hid the index stderr behind its own message" +grep -q 'probe: index refused' "$WORKDIR/search.out" || + fail "benchmark-search-graph did not surface the cause of the index failure" + +# The request carrying the repository path has to be built as JSON. A path may +# legitimately contain a quote or a backslash — hand-built JSON turns that into +# a payload the server cannot parse, or one that means something else. NTFS +# rejects both characters in a path component, so this case is POSIX-only. +case "$(uname -s)" in +MINGW* | MSYS* | CYGWIN*) ;; +*) + QUIRKY_REPO="$WORKDIR/re\"po\\dir" + mkdir -p "$QUIRKY_REPO" + echo 'def bench(): return 1' > "$QUIRKY_REPO/bench.py" + QUIRKY_RESOLVED=$(cd "$QUIRKY_REPO" && pwd -P) + REQUEST_LOG="$WORKDIR/requests.log" + CBM_CACHE_DIR="$CALLER_CACHE" \ + CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ + CBM_BENCH_ENV_PROBE="$WORKDIR/quirky-environment.log" \ + CBM_BENCH_REQUEST_LOG="$REQUEST_LOG" \ + "$ROOT/scripts/benchmark-search-graph.sh" "$ENV_PROBE" "$QUIRKY_REPO" \ + > "$WORKDIR/quirky.out" 2>&1 || true + python3 - "$REQUEST_LOG" "$QUIRKY_RESOLVED" <<'PY' || fail "benchmark-search-graph built an index request that is not valid JSON for a quoted path" +import json +import sys + +request_log, expected = sys.argv[1], sys.argv[2] +try: + lines = [line for line in open(request_log).read().splitlines() if line.strip()] +except OSError: + sys.exit("the search benchmark sent no index request") +if not lines: + sys.exit("the search benchmark sent no index request") +payload = json.loads(lines[0]) +if payload.get("repo_path") != expected: + sys.exit(f"repo_path is {payload.get('repo_path')!r}, expected {expected!r}") +PY + ;; +esac + echo "PASS: benchmark harnesses isolate their daemon runtime and cache from the caller" From 567ed5d87e807f6463ed90f324137b6794fa23ae Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Mon, 21 Sep 2026 12:25:28 +0300 Subject: [PATCH 35/65] fix(test): hand the benchmark index to the caller on request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The evaluation plan (docs/EVALUATION_PLAN.md §7) indexes a language with benchmark-index.sh and then answers graph questions against that index from its own MCP session. With a run-private runtime (#1696) the index was gone before that session could start, so the handoff is now explicit and opt-in. With CBM_BENCH_KEEP_RUNTIME set, a SUCCESSFUL run stops its daemon, leaves the private root in place and records the paths that reach it in //runtime-root.txt (sourceable: CBM_BENCH_RUNTIME_ROOT, CBM_RUNTIME_DIR, CBM_CACHE_DIR). Ownership of that root, including its removal, passes to the caller. A failed run cleans up regardless: there is no index worth keeping and nothing may leak. Nothing changes in the default path or in the three timing files. §7's skeleton sets the flag, sources the file before the graph session and removes the root in step 8 instead of the live-store *.db files. The contract test drives a kept run and requires the root to survive, the handoff file to be sourceable, and its paths to be the ones the product processes actually used; the unflagged run still asserts the root is gone. The kept-run assertions fail against the previous head. Signed-off-by: Anton Standrik --- docs/EVALUATION_PLAN.md | 7 +++++- scripts/benchmark-index.sh | 23 ++++++++++++++++- ...st_benchmark_runtime_isolation_contract.sh | 25 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/docs/EVALUATION_PLAN.md b/docs/EVALUATION_PLAN.md index efd56207db..01398daca5 100644 --- a/docs/EVALUATION_PLAN.md +++ b/docs/EVALUATION_PLAN.md @@ -283,8 +283,13 @@ for lang in $ALL_LANGS; do # ALL_LANGS = full 159-name list # --- step 2: cold index in the main channel, TIMED (key metric) --- t0=$(now_ms) + CBM_BENCH_KEEP_RUNTIME=1 \ scripts/benchmark-index.sh ~/.local/bin/codebase-memory-mcp "$lang" /tmp/bench/"$lang" /tmp/eval-results index_ms=$(( $(now_ms) - t0 )) # clone+index wall-clock → manifest + report (§5) + # The harness indexes into a run-private root (#1696). CBM_BENCH_KEEP_RUNTIME leaves that + # root behind on success and records its paths; the graph session in steps 4-7 must be + # started with these CBM_RUNTIME_DIR / CBM_CACHE_DIR, and step 8 removes the root. + set -a; . /tmp/eval-results/"$lang"/runtime-root.txt; set +a # --- step 3: record per-type histograms (zeros back-filled) --- # node-types.json, edge-types.json (every label + all 32 edge types, zeros kept, §7 below) @@ -293,7 +298,7 @@ for lang in $ALL_LANGS; do # ALL_LANGS = full 159-name list # + per-language report + (deferred, blind) judge --- # --- step 8: delete THIS language's index so the next is cold, then mark done --- - rm -f ~/.cache/codebase-memory-mcp/*.db + rm -rf -- "$CBM_BENCH_RUNTIME_ROOT" manifest_mark_done "$lang" "$index_ms" done ``` diff --git a/scripts/benchmark-index.sh b/scripts/benchmark-index.sh index 0bc24af2f8..11d79ee1fa 100755 --- a/scripts/benchmark-index.sh +++ b/scripts/benchmark-index.sh @@ -15,7 +15,28 @@ RESULTS_DIR="${4:?}" # shellcheck source=test-runtime.sh source "$(dirname "${BASH_SOURCE[0]}")/test-runtime.sh" cbm_test_runtime_init -trap 'cbm_test_runtime_cleanup "$BINARY"' EXIT + +# The evaluation plan (docs/EVALUATION_PLAN.md §7) indexes a language here and +# then answers graph questions against that index from its own MCP session. +# A run-private root would be gone before that session starts, so the caller +# may ask for it to be kept: after a SUCCESSFUL run the harness stops its +# daemon, leaves the root in place, records the paths that reach it in +# //runtime-root.txt (sourceable), and ownership of the root — +# including its removal — passes to the caller. A failed run cleans up +# regardless: there is no index worth keeping, and nothing must leak. Never on +# by default, or an unattended run accumulates one root per language. +bench_finish() { + local rc=$? + if [ "$rc" -eq 0 ] && [ -n "${CBM_BENCH_KEEP_RUNTIME:-}" ] && [ -d "${OUT:-}" ]; then + "$BINARY" daemon stop >/dev/null 2>&1 || true + printf 'CBM_BENCH_RUNTIME_ROOT=%q\nCBM_RUNTIME_DIR=%q\nCBM_CACHE_DIR=%q\n' \ + "$CBM_TEST_RUNTIME_ROOT" "$CBM_RUNTIME_DIR" "$CBM_CACHE_DIR" > "$OUT/runtime-root.txt" + echo " $LANG: runtime kept at $CBM_TEST_RUNTIME_ROOT; paths in $OUT/runtime-root.txt" >&2 + return 0 + fi + cbm_test_runtime_cleanup "$BINARY" +} +trap bench_finish EXIT # Resolve symlinks REPO=$(cd "$REPO" && pwd -P) diff --git a/tests/test_benchmark_runtime_isolation_contract.sh b/tests/test_benchmark_runtime_isolation_contract.sh index 1dc618ae69..73c1758f2b 100755 --- a/tests/test_benchmark_runtime_isolation_contract.sh +++ b/tests/test_benchmark_runtime_isolation_contract.sh @@ -90,6 +90,31 @@ for metric in setup-time total-time index-time; do fail "benchmark-index did not record $metric.txt" done +# The evaluation plan indexes a language and then reads that index from its own +# MCP session (docs/EVALUATION_PLAN.md §7). Asked to keep the runtime, a +# successful run must leave its root behind and record, sourceably, the paths +# that reach it — and they must be the paths the product processes actually +# used. Unasked, the root is gone (asserted above). +KEEP_LOG="$WORKDIR/keep-environment.log" +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$KEEP_LOG" \ +CBM_BENCH_KEEP_RUNTIME=1 \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" keep "$REPO" "$WORKDIR/results" \ + > "$WORKDIR/keep.out" 2>&1 || true +HANDOFF="$WORKDIR/results/keep/runtime-root.txt" +[[ -s "$HANDOFF" ]] || fail "benchmark-index was asked to keep its runtime but recorded no handoff" +KEPT_ROOT=$(bash -c '. "$1" && printf "%s" "${CBM_BENCH_RUNTIME_ROOT-}"' _ "$HANDOFF") +KEPT_RUNTIME=$(bash -c '. "$1" && printf "%s" "${CBM_RUNTIME_DIR-}"' _ "$HANDOFF") +KEPT_CACHE=$(bash -c '. "$1" && printf "%s" "${CBM_CACHE_DIR-}"' _ "$HANDOFF") +[[ -n "$KEPT_ROOT" && -d "$KEPT_ROOT" && ! -L "$KEPT_ROOT" ]] || + fail "benchmark-index did not keep its runtime root: ${KEPT_ROOT:-}" +[[ -d "$KEPT_ROOT/cache" && -d "$KEPT_ROOT/runtime" ]] || + fail "the kept root $KEPT_ROOT lost its cache or runtime directory" +grep -qF -- "${KEPT_CACHE}"$'\t'"${KEPT_RUNTIME}" "$KEEP_LOG" || + fail "runtime-root.txt does not name the runtime and cache the product processes used" +rm -rf -- "$KEPT_ROOT" + SEARCH_LOG="$WORKDIR/search-environment.log" CBM_CACHE_DIR="$CALLER_CACHE" \ CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ From a2ac0540c1d10cde927707717f8cec2088e245bb Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Tue, 22 Sep 2026 13:57:30 +0300 Subject: [PATCH 36/65] fix(test): fail the index benchmark when its index is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bench_finish keeps the private root on a zero exit status, but the index call swallowed its failure (`|| echo '{"error":"index failed"}'`), so a refused index exited 0 and, with CBM_BENCH_KEEP_RUNTIME set, kept an empty root and wrote runtime-root.txt for an index that does not exist. benchmark-index.sh now records the CLI's exit status, keeps 00-index.json valid JSON on failure ({"error":"index failed","exit":N}), and exits 1 once every per-run file — the three timing files included — is written, when the index failed or reported no project (the search-graph twin's own test). bench_finish then cleans up as its comment always promised, and the caller's loop notices. The index call keeps its stderr in a run-private temp file, the twin's shape, and the failure prints it under `--- index stderr ---` with the first 500 bytes of the response: for a one-shot CLI, stderr is the only channel a refusal is reported on. A stale runtime-root.txt from an earlier run in the same results directory is removed at start, so a failed run leaves no handoff at all. The contract test's fixture answers `cli index_repository` with a minimal envelope when CBM_BENCH_PROBE_INDEX_OK is set, so the keep run covers the successful-index handoff and asserts project.txt; the unflagged run asserts a non-zero exit and the surfaced cause after a refused index; a new keep run with a refused index asserts a non-zero exit, the timing files, no surviving root and no handoff, a pre-seeded stale one included. Against the previous head the test fails on "benchmark-index exited 0 after a refused index". Signed-off-by: Anton Standrik --- scripts/benchmark-index.sh | 33 +++++++++++- ...st_benchmark_runtime_isolation_contract.sh | 54 +++++++++++++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/scripts/benchmark-index.sh b/scripts/benchmark-index.sh index 11d79ee1fa..207944a85f 100755 --- a/scripts/benchmark-index.sh +++ b/scripts/benchmark-index.sh @@ -27,6 +27,7 @@ cbm_test_runtime_init # by default, or an unattended run accumulates one root per language. bench_finish() { local rc=$? + [ -z "${BENCH_TMP:-}" ] || rm -rf -- "$BENCH_TMP" || true if [ "$rc" -eq 0 ] && [ -n "${CBM_BENCH_KEEP_RUNTIME:-}" ] && [ -d "${OUT:-}" ]; then "$BINARY" daemon stop >/dev/null 2>&1 || true printf 'CBM_BENCH_RUNTIME_ROOT=%q\nCBM_RUNTIME_DIR=%q\nCBM_CACHE_DIR=%q\n' \ @@ -36,7 +37,13 @@ bench_finish() { fi cbm_test_runtime_cleanup "$BINARY" } +BENCH_TMP="" trap bench_finish EXIT +# The index call keeps its stderr, the way the search-graph twin does: for a +# one-shot CLI that is the only channel a refusal is reported on, and without +# it the failure below could name only its symptom. +BENCH_TMP=$(mktemp -d) +INDEX_ERR="$BENCH_TMP/index-stderr.log" # Resolve symlinks REPO=$(cd "$REPO" && pwd -P) @@ -54,6 +61,10 @@ bench_now_ms() { python3 -c "import time; print(time.monotonic_ns() // 1000000)" OUT="$RESULTS_DIR/$LANG" mkdir -p "$OUT" +# A handoff names this run's index or none: the evaluation loop reuses the +# results directory, and a stale one would point the graph session at a root +# step 8 has already removed. +rm -f -- "$OUT/runtime-root.txt" echo "INDEX: $LANG ($REPO)" @@ -86,7 +97,12 @@ fi # Index via CLI and capture timing START_MS=$(bench_now_ms) -INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":$REPO_JSON,\"mode\":\"full\"}" 2>/dev/null || echo '{"error":"index failed"}') +# The CLI's exit status is the index's verdict. It is recorded here and acted +# on below, once every per-run file is written; an empty response stays valid +# JSON in 00-index.json so the failure is legible there as well. +INDEX_RC=0 +INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":$REPO_JSON,\"mode\":\"full\"}" 2>"$INDEX_ERR") || + { INDEX_RC=$?; INDEX_JSON="{\"error\":\"index failed\",\"exit\":$INDEX_RC}"; } END_MS=$(bench_now_ms) ELAPSED=$((END_MS - START_MS)) @@ -130,5 +146,20 @@ echo "$NODES" > "$OUT/nodes.txt" echo "$EDGES" > "$OUT/edges.txt" echo "$PROJECT" > "$OUT/project.txt" +# An index that failed, or that named no project, fails the run: after the +# timing files, so the caller keeps its figures, and through the exit status, +# so the caller's loop notices and bench_finish never keeps a root with no +# index in it. +if [ "$INDEX_RC" -ne 0 ] || [ -z "$PROJECT" ]; then + echo " $LANG: index failed (cli exit $INDEX_RC, project '$PROJECT'); response in $OUT/00-index.json" >&2 + if [ -s "$INDEX_ERR" ]; then + echo "--- index stderr ---" >&2 + cat "$INDEX_ERR" >&2 + fi + echo "--- index response (first 500 bytes) ---" >&2 + printf '%.500s\n' "$INDEX_JSON" >&2 + exit 1 +fi + printf " %s: %s files, %s LOC, %sms, %s nodes, %s edges\n" \ "$LANG" "$FILE_COUNT" "$LOC" "$ELAPSED" "$NODES" "$EDGES" diff --git a/tests/test_benchmark_runtime_isolation_contract.sh b/tests/test_benchmark_runtime_isolation_contract.sh index 73c1758f2b..f2e862d0dd 100755 --- a/tests/test_benchmark_runtime_isolation_contract.sh +++ b/tests/test_benchmark_runtime_isolation_contract.sh @@ -9,8 +9,9 @@ set -euo pipefail # operator's live store, every one-shot joined the operator's account daemon, # and the timings depended on whatever that daemon was doing. Drive both with # an environment-probe fixture and require that no product process ever -# receives the caller's runtime or cache, and that the index benchmark records -# the setup cost it now pays explicitly. +# receives the caller's runtime or cache, that the index benchmark records +# the setup cost it now pays explicitly, and that a refused index fails the +# run, names its cause, and leaves nothing behind. ROOT="$(cd "$(dirname "$0")/.." && pwd)" WORKDIR="$(mktemp -d)" @@ -35,6 +36,10 @@ cat > "$ENV_PROBE" <<'EOF' printf '%s\t%s\n' "${CBM_CACHE_DIR-}" "${CBM_RUNTIME_DIR-}" >> "$CBM_BENCH_ENV_PROBE" if [[ "${1-} ${2-}" == "cli index_repository" ]]; then [[ -z "${CBM_BENCH_REQUEST_LOG-}" ]] || printf '%s\n' "${3-}" >> "$CBM_BENCH_REQUEST_LOG" + if [[ -n "${CBM_BENCH_PROBE_INDEX_OK-}" ]]; then + printf '%s\n' '{"project":"probe"}' + exit 0 + fi echo "probe: index refused" >&2 exit 1 fi @@ -78,30 +83,46 @@ assert_isolated() { [[ ! -e "$private_root" ]] || fail "$harness left its private root behind: $private_root" } +# The fixture refuses the index. That must fail the run — after the timing +# files are written, so the caller keeps its figures — and name its cause: the +# fixture's one stderr line has to reach the output. INDEX_LOG="$WORKDIR/index-environment.log" +index_rc=0 CBM_CACHE_DIR="$CALLER_CACHE" \ CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ CBM_BENCH_ENV_PROBE="$INDEX_LOG" \ "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" probe "$REPO" "$WORKDIR/results" \ - > "$WORKDIR/index.out" 2>&1 || true + > "$WORKDIR/index.out" 2>&1 || index_rc=$? +[[ "$index_rc" -ne 0 ]] || fail "benchmark-index exited 0 after a refused index" assert_isolated "benchmark-index" "$INDEX_LOG" for metric in setup-time total-time index-time; do [[ -s "$WORKDIR/results/probe/$metric.txt" ]] || fail "benchmark-index did not record $metric.txt" done +grep -q -- '--- index stderr ---' "$WORKDIR/index.out" || + fail "benchmark-index hid the index stderr behind its own message" +grep -q 'probe: index refused' "$WORKDIR/index.out" || + fail "benchmark-index did not surface the cause of the index failure" # The evaluation plan indexes a language and then reads that index from its own # MCP session (docs/EVALUATION_PLAN.md §7). Asked to keep the runtime, a # successful run must leave its root behind and record, sourceably, the paths # that reach it — and they must be the paths the product processes actually -# used. Unasked, the root is gone (asserted above). +# used. Unasked, the root is gone (asserted above). The fixture answers this +# index with a minimal envelope, so what is kept is an index that succeeded. KEEP_LOG="$WORKDIR/keep-environment.log" +keep_rc=0 CBM_CACHE_DIR="$CALLER_CACHE" \ CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ CBM_BENCH_ENV_PROBE="$KEEP_LOG" \ +CBM_BENCH_PROBE_INDEX_OK=1 \ CBM_BENCH_KEEP_RUNTIME=1 \ "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" keep "$REPO" "$WORKDIR/results" \ - > "$WORKDIR/keep.out" 2>&1 || true + > "$WORKDIR/keep.out" 2>&1 || keep_rc=$? +[[ "$keep_rc" -eq 0 ]] || fail "benchmark-index failed a run whose index succeeded (exit $keep_rc)" +KEPT_PROJECT=$(cat "$WORKDIR/results/keep/project.txt" 2>/dev/null || true) +[[ "${KEPT_PROJECT%$'\r'}" == "probe" ]] || + fail "benchmark-index did not record the project the index reported: '${KEPT_PROJECT:-}'" HANDOFF="$WORKDIR/results/keep/runtime-root.txt" [[ -s "$HANDOFF" ]] || fail "benchmark-index was asked to keep its runtime but recorded no handoff" KEPT_ROOT=$(bash -c '. "$1" && printf "%s" "${CBM_BENCH_RUNTIME_ROOT-}"' _ "$HANDOFF") @@ -115,6 +136,29 @@ grep -qF -- "${KEPT_CACHE}"$'\t'"${KEPT_RUNTIME}" "$KEEP_LOG" || fail "runtime-root.txt does not name the runtime and cache the product processes used" rm -rf -- "$KEPT_ROOT" +# Asked to keep the runtime and refused the index, the harness has nothing worth +# keeping: the run fails, no root survives, and no handoff is written — nor left +# over from an earlier run, since the evaluation loop reuses the results +# directory. +REFUSED_LOG="$WORKDIR/keep-refused-environment.log" +REFUSED_HANDOFF="$WORKDIR/results/keep-refused/runtime-root.txt" +mkdir -p "${REFUSED_HANDOFF%/*}" +echo 'CBM_BENCH_RUNTIME_ROOT=/stale/root/from/an/earlier/run' > "$REFUSED_HANDOFF" +refused_rc=0 +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$REFUSED_LOG" \ +CBM_BENCH_KEEP_RUNTIME=1 \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" keep-refused "$REPO" "$WORKDIR/results" \ + > "$WORKDIR/keep-refused.out" 2>&1 || refused_rc=$? +[[ "$refused_rc" -ne 0 ]] || fail "benchmark-index exited 0 after a refused index it was asked to keep" +for metric in setup-time total-time index-time; do + [[ -s "$WORKDIR/results/keep-refused/$metric.txt" ]] || + fail "benchmark-index did not record $metric.txt for a refused index" +done +assert_isolated "benchmark-index (keep, refused index)" "$REFUSED_LOG" +[[ ! -e "$REFUSED_HANDOFF" ]] || fail "benchmark-index left a handoff for an index that failed" + SEARCH_LOG="$WORKDIR/search-environment.log" CBM_CACHE_DIR="$CALLER_CACHE" \ CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ From 54c18648c211ad152cd025af4ee5d76da1616601 Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Tue, 22 Sep 2026 13:57:30 +0300 Subject: [PATCH 37/65] docs(eval-plan): stop the kept daemon before its root is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §7 step 8 removed the kept runtime root while the graph session of steps 4-7 may still have a daemon running under it; stop that daemon under the sourced CBM_RUNTIME_DIR first. §13 Reproducibility still cleared ~/.cache/codebase-memory-mcp/*.db and called the harness unflagged, which no longer describes what the harness does: it indexes into a run-private root, and only CBM_BENCH_KEEP_RUNTIME=1 hands that root, via runtime-root.txt, to the graph phase. Signed-off-by: Anton Standrik --- docs/EVALUATION_PLAN.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/EVALUATION_PLAN.md b/docs/EVALUATION_PLAN.md index 01398daca5..39d72848fa 100644 --- a/docs/EVALUATION_PLAN.md +++ b/docs/EVALUATION_PLAN.md @@ -298,6 +298,9 @@ for lang in $ALL_LANGS; do # ALL_LANGS = full 159-name list # + per-language report + (deferred, blind) judge --- # --- step 8: delete THIS language's index so the next is cold, then mark done --- + # The graph session (steps 4-7) may still have a daemon running under the kept + # CBM_RUNTIME_DIR (exported above); stop it before its root goes away. + ~/.local/bin/codebase-memory-mcp daemon stop || true rm -rf -- "$CBM_BENCH_RUNTIME_ROOT" manifest_mark_done "$lang" "$index_ms" done @@ -865,10 +868,13 @@ Deep-Dive section. # 1. Clone all 159 repos (shallow; skip existing) scripts/clone-bench-repos.sh /tmp/bench -# 2. Cold index all 159 (LSP cohort in full mode) -rm -f ~/.cache/codebase-memory-mcp/*.db +# 2. Cold index all 159 (LSP cohort in full mode). Each language is indexed into a run-private +# runtime and cache (#1696); CBM_BENCH_KEEP_RUNTIME=1 keeps that root after a successful +# index and records its paths in /tmp/eval-results//runtime-root.txt. Source it +# before the graph phase, then stop the daemon and remove the root, as in §7 step 8. mkdir -p /tmp/eval-results for lang in $ALL_LANGS; do + CBM_BENCH_KEEP_RUNTIME=1 \ scripts/benchmark-index.sh ~/.local/bin/codebase-memory-mcp "$lang" /tmp/bench/"$lang" /tmp/eval-results done From 349d80c64361df8d0aea8c695cf1549445088ddb Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Thu, 29 Oct 2026 11:46:19 -0500 Subject: [PATCH 38/65] perf(index): run supervised index workers at below-normal CPU priority A full index saturates every core in the parallel extraction pass, and interactive CLI/MCP queries (CPU-bound at start-up) were measured taking twice their wall time behind it with flat CPU time: pure run-queue contention. The index-worker role now lowers its own priority once, before any indexing thread is created: nice 10 on POSIX, BELOW_NORMAL_PRIORITY_CLASS on Windows. It never raises priority: a process already nicer than 10 (or in the IDLE / BELOW_NORMAL class) is left alone. Plain nice only, not macOS background QoS, which would pin indexing to efficiency cores and throttle its I/O. Failure is logged and ignored. The test runs the helper in a forked child and checks both legs: a normal-priority child ends at nice >= 10, and a child started nicer than that is left where it was. Refs #2834 Signed-off-by: Shane McCarron --- src/foundation/platform.c | 25 +++++++++++++++++++++ src/foundation/platform.h | 13 +++++++++++ src/main.c | 6 +++++ tests/test_platform.c | 47 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/src/foundation/platform.c b/src/foundation/platform.c index d11df686e8..3a4ac16420 100644 --- a/src/foundation/platform.c +++ b/src/foundation/platform.c @@ -729,3 +729,28 @@ const char *cbm_resolve_cache_dir(void) { } return buf; } + +/* ── CPU scheduling priority ────────────────── */ + +#ifndef _WIN32 +#include +#endif + +bool cbm_lower_cpu_priority(void) { +#ifdef _WIN32 + HANDLE self = GetCurrentProcess(); + DWORD current = GetPriorityClass(self); + if (current == IDLE_PRIORITY_CLASS || current == BELOW_NORMAL_PRIORITY_CLASS) { + return true; /* already at least this low: never raise priority */ + } + return SetPriorityClass(self, BELOW_NORMAL_PRIORITY_CLASS) != 0; +#else + /* getpriority can legitimately return -1, so errno is the only error signal. */ + errno = 0; + int current = getpriority(PRIO_PROCESS, 0); + if (errno == 0 && current >= CBM_LOW_CPU_PRIORITY_NICE) { + return true; /* already at least this nice: never raise priority */ + } + return setpriority(PRIO_PROCESS, 0, CBM_LOW_CPU_PRIORITY_NICE) == 0; +#endif +} diff --git a/src/foundation/platform.h b/src/foundation/platform.h index 40b020222c..8eb4627786 100644 --- a/src/foundation/platform.h +++ b/src/foundation/platform.h @@ -215,4 +215,17 @@ int64_t cbm_file_size(const char *path); * On POSIX, this is a no-op. Returns the input pointer. */ char *cbm_normalize_path_sep(char *path); +/* ── CPU scheduling priority ───────────────────────────────────── */ + +enum { CBM_LOW_CPU_PRIORITY_NICE = 10 }; + +/* Lower this process's CPU scheduling priority so background work yields the + * cores to interactive processes. POSIX: nice CBM_LOW_CPU_PRIORITY_NICE (never + * raises an already-nicer process). Plain nice only, deliberately not macOS + * background QoS: that pins work to efficiency cores and throttles I/O. + * Windows: BELOW_NORMAL_PRIORITY_CLASS. On Linux nice is per thread, so call + * this before creating threads; later threads inherit it. + * Returns false when the priority could not be changed. */ +bool cbm_lower_cpu_priority(void); + #endif /* CBM_PLATFORM_H */ diff --git a/src/main.c b/src/main.c index c2f22ada9d..3f5e80e22c 100644 --- a/src/main.c +++ b/src/main.c @@ -902,6 +902,12 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo * own here, and only here (cbm_sqlite_dedicated_heap). */ cbm_sqlite_dedicated_heap(true); cbm_memev_process_role("worker"); + /* A full index saturates every core; run it below normal priority so + * interactive CLI/MCP queries are not starved. Before any indexing + * thread exists, so they all inherit it. Failure is harmless. */ + if (!cbm_lower_cpu_priority()) { + cbm_log_info("index.worker.priority", "lowered", "false"); + } } cbm_index_set_worker_role_options(index_worker, response_out, worker_single_thread, worker_marker, worker_quarantine, diff --git a/tests/test_platform.c b/tests/test_platform.c index 2c383c025d..d83f90dab6 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -16,6 +16,9 @@ #include #ifndef _WIN32 #include /* chmod for the symlink-policy mode legs */ +#include +#include /* getpriority for the worker-priority leg */ +#include #endif #ifdef __linux__ @@ -1442,6 +1445,49 @@ TEST(regex_estimate_counts_brackets_and_escapes_literally) { PASS(); } +/* An index worker lowers its own priority (#2834). A non-root process cannot + * raise it back, so the helper runs in a forked child that reports its nice. */ +TEST(platform_lower_cpu_priority_nices_the_process) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX nice contract"); +#else + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + pid_t child = fork(); + if (child == 0) { + (void)close(fds[0]); + /* report: lowered ok, nice after; then, from a nicer start, ok, target, nice after */ + int report[5] = {cbm_lower_cpu_priority() ? 1 : 0, 0, 0, 0, 0}; + errno = 0; + report[1] = getpriority(PRIO_PROCESS, 0); + if (errno != 0) { + report[0] = 0; + } + /* Never-raise leg: start nicer than the helper's level; it must leave it. */ + report[3] = report[1] >= 15 ? 19 : 15; + if (setpriority(PRIO_PROCESS, 0, report[3]) == 0) { + report[2] = cbm_lower_cpu_priority() ? 1 : 0; + errno = 0; + report[4] = getpriority(PRIO_PROCESS, 0); + } + _exit(write(fds[1], report, sizeof(report)) == (ssize_t)sizeof(report) ? 0 : 1); + } + ASSERT_TRUE(child > 0); + (void)close(fds[1]); + int report[5] = {0, 0, 0, 0, 0}; + ssize_t got = read(fds[0], report, sizeof(report)); + (void)close(fds[0]); + int status = 0; + (void)waitpid(child, &status, 0); + ASSERT_EQ(got, (ssize_t)sizeof(report)); + ASSERT_EQ(report[0], 1); + ASSERT_TRUE(report[1] >= CBM_LOW_CPU_PRIORITY_NICE); + ASSERT_EQ(report[2], 1); + ASSERT_EQ(report[4], report[3]); + PASS(); +#endif +} + SUITE(platform) { RUN_TEST(platform_file_apis_survive_max_path_overflow); RUN_TEST(platform_mkdir_p_follows_own_symlink_only_when_opted_in); @@ -1477,6 +1523,7 @@ SUITE(platform) { RUN_TEST(platform_env_long_refuses_what_it_cannot_read); RUN_TEST(platform_env_dup_honours_the_settings_resolver); RUN_TEST(platform_system_info); + RUN_TEST(platform_lower_cpu_priority_nices_the_process); RUN_TEST(regex_compile_refuses_oversized_expansion); RUN_TEST(regex_compile_accepts_expansion_under_budget); RUN_TEST(regex_compile_refuses_overlong_pattern); 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 39/65] 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 74c1980e2f..be7752a399 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 371cb5536f..7fd3905d33 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 fcb06903de..c0b2191723 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 4b91ae05f2..67326dcd50 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 91844f67e4..fd5d7c3d33 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 429fb66285..4f92318c03 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 11f20c578bf142a0a896f04f319491eec449c685 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:34:44 +0000 Subject: [PATCH 40/65] build(deps): bump actions/setup-python from 7.0.0 to 7.1.0 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 7.0.0 to 7.1.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/_test.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 501a82b205..ba967f2830 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -318,7 +318,7 @@ jobs: with: node-version: "22" - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4926260d5..020ea3f0f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -535,7 +535,7 @@ jobs: # ── PyPI ───────────────────────────────────────────────── - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.12' 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 41/65] 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 152cca7376..4579ee42a3 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 72701ca7ad..d08b1692b9 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 39f9e76b1d..5a6f0e9c70 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 501a82b205..ff378b1d07 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 c582716fea..7554f16fb8 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 d4357aad63..e45d3395f9 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 e20ac274af..2c59a4db28 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 e4926260d5..033c1a14be 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 49f70dd673..a0a53061c5 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 42/65] 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 ed97c78ec0..6c89ac01f2 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 ad2d81de39..317702cb6b 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 d1999357fc..1e2b2c85e1 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 43/65] 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 6c89ac01f2..2464a68910 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 1e2b2c85e1..4aa00b9f8d 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 44/65] 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 83e853e659..f94ccd764c 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 4aa00b9f8d..c5499f78e7 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 45/65] 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 9157c4bcdc..a4d5612e88 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 46/65] 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 be7752a399..b7309bc335 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 c0b2191723..2b019dce9e 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 fd5d7c3d33..6f18c215e7 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 4f92318c03..1aa5c806dc 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 17415f6a3806a8529c0fc217b04f0cd9a056ce9f Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 22:43:58 +0100 Subject: [PATCH 47/65] test(install): rename the Android guard test to the test_*.sh convention Every shell contract test under tests/ is named test_*.sh with underscores. A pure rename, so history and blame follow the file. Refs #1202 Signed-off-by: Martin Vogel --- ...est-install-android-guard.sh => test_install_android_guard.sh} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test-install-android-guard.sh => test_install_android_guard.sh} (100%) diff --git a/tests/test-install-android-guard.sh b/tests/test_install_android_guard.sh similarity index 100% rename from tests/test-install-android-guard.sh rename to tests/test_install_android_guard.sh From 1fc70c2024ed6ff7e4fb5c735a342143c8572e02 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 22:43:58 +0100 Subject: [PATCH 48/65] test(install): run the Android guard test as Step 0x2 of scripts/test.sh scripts/test.sh lists every shell contract test explicitly and does not glob tests/, so an unlisted test never ran on any leg. Register tests/test_install_android_guard.sh as Step 0x2, right after Step 0x, in the same two-line shape as its neighbours. Give the two PATH stubs (uname, curl) the same shebang every other stub under tests/ uses, so they are proper executables on every leg instead of relying on the shell fallback for files without one. Refs #1202 Signed-off-by: Martin Vogel --- scripts/test.sh | 3 +++ tests/test_install_android_guard.sh | 2 ++ 2 files changed, 5 insertions(+) diff --git a/scripts/test.sh b/scripts/test.sh index 992aea8370..baf41a9351 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -278,6 +278,9 @@ bash "$ROOT/tests/test_language_count_contract.sh" echo "=== Step 0x: packaging version-metadata contract ===" bash "$ROOT/tests/test_version_metadata_contract.sh" +echo "=== Step 0x2: installer Android/Termux guard contract (#1202) ===" +bash "$ROOT/tests/test_install_android_guard.sh" + # Step 0y: the Windows leg must not decide its verdict from an exit status that # the ssh/msys2_shell chain can mangle — a channel that turns 0 into 1 can turn # 1 into 0, and that direction reports a RED Windows leg as green. Runs diff --git a/tests/test_install_android_guard.sh b/tests/test_install_android_guard.sh index 6b3a383a29..59503fc7d1 100755 --- a/tests/test_install_android_guard.sh +++ b/tests/test_install_android_guard.sh @@ -7,6 +7,7 @@ trap 'rm -rf "$WORK"' EXIT mkdir -p "$WORK/bin" "$WORK/home" cat >"$WORK/bin/uname" <<'EOF' +#!/usr/bin/env bash case "$1" in -s) printf '%s\n' Linux ;; -m) printf '%s\n' aarch64 ;; @@ -15,6 +16,7 @@ case "$1" in esac EOF cat >"$WORK/bin/curl" <<'EOF' +#!/usr/bin/env bash printf 'curl must not run for Android/Termux\n' >&2 exit 99 EOF From 8b2b0d769f00dcfb994fd250ecbd9f58fd395973 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 22:43:59 +0100 Subject: [PATCH 49/65] fix(install): say that Termux is not a supported platform yet The guard told users to build from source with scripts/build.sh in Termux, which reads as a supported path. Source builds under Termux still have open problems tracked in #1202, so the message now says why the portable binary cannot run there and that a source build may work but is not supported yet, pointing at #1202. The test still matches Android/Termux in the new message. Refs #1202 Signed-off-by: Martin Vogel --- install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 01ab6c3781..f3666e5d44 100755 --- a/install.sh +++ b/install.sh @@ -157,8 +157,8 @@ is_android() { OS=$(detect_os) ARCH=$(detect_arch) if [ "$OS" = "linux" ] && is_android; then - echo "error: Android/Termux is not supported by the Linux portable binary." >&2 - echo "Build from source with scripts/build.sh in Termux or use a supported Linux host." >&2 + echo 'error: Android/Termux cannot run the Linux portable binary (static glibc; Android kills it with "Bad system call").' >&2 + echo "Building from source in Termux may work but is not a supported platform yet - see issue #1202." >&2 exit 1 fi From 7a7ecb7356c152e0f6d22ac4d208b5919a21c862 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 23:02:46 +0100 Subject: [PATCH 50/65] test(install): keep the Android guard test off the network Point CBM_DOWNLOAD_URL, the documented download-base override in install.sh, at a closed loopback port. If a curl stub were ever missed on some leg, the download would then fail locally instead of fetching a real release. Check that the uname stub is the one PATH resolves before running install.sh. Otherwise the test would quietly exercise the real host instead of an Android one and prove nothing. Refs #1202 Signed-off-by: Martin Vogel --- tests/test_install_android_guard.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_install_android_guard.sh b/tests/test_install_android_guard.sh index 59503fc7d1..b4bd6a7047 100755 --- a/tests/test_install_android_guard.sh +++ b/tests/test_install_android_guard.sh @@ -22,8 +22,16 @@ exit 99 EOF chmod +x "$WORK/bin/uname" "$WORK/bin/curl" +# The stubs must be what install.sh sees, or this test proves nothing. +if [ "$(PATH="$WORK/bin:$PATH" uname -o)" != "Android" ]; then + printf 'FAIL: the uname stub is not picked up from PATH\n' >&2 + exit 1 +fi + set +e -output=$(PATH="$WORK/bin:$PATH" HOME="$WORK/home" bash "$ROOT/install.sh" --skip-config 2>&1) +output=$(PATH="$WORK/bin:$PATH" HOME="$WORK/home" \ + CBM_DOWNLOAD_URL="http://127.0.0.1:9/cbm-android-guard-test" \ + bash "$ROOT/install.sh" --skip-config 2>&1) rc=$? set -e From 14b8da00c3a7ee559c5b07ec4e8e220e171c79e7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 00:06:52 +0100 Subject: [PATCH 51/65] fix(test): drop a stale benchmark handoff before setup can fail benchmark-index.sh removed the runtime-root.txt of an earlier run only after the repository path, the private runtime and the temp directory had been set up. A run that failed in one of those steps exited non-zero but left the previous handoff in the reused results directory, and section 7 of the evaluation plan sources that file unconditionally, so the graph session would be pointed at a root that step 8 has already removed. The results directory is now created and the stale handoff removed right after argument parsing, before any setup step that can fail. Signed-off-by: Martin Vogel --- scripts/benchmark-index.sh | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/benchmark-index.sh b/scripts/benchmark-index.sh index 207944a85f..5ab4353bb2 100755 --- a/scripts/benchmark-index.sh +++ b/scripts/benchmark-index.sh @@ -9,6 +9,14 @@ LANG="${2:?}" REPO="${3:?}" RESULTS_DIR="${4:?}" +OUT="$RESULTS_DIR/$LANG" +mkdir -p "$OUT" +# A handoff names this run's index or none: the evaluation loop reuses the +# results directory, and a stale one would point the graph session at a root +# step 8 has already removed. It goes before any setup step that can fail, so +# a run that never reaches the index cannot leave an earlier one behind. +rm -f -- "$OUT/runtime-root.txt" + # The index must run against a daemon rendezvous and cache this run owns: only # CBM_RUNTIME_DIR moves the rendezvous, and without a private cache the # benchmark repository was indexed into the operator's live store (#1696). @@ -59,13 +67,6 @@ REPO_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$REPO" # even though each comes from its own process. bench_now_ms() { python3 -c "import time; print(time.monotonic_ns() // 1000000)"; } -OUT="$RESULTS_DIR/$LANG" -mkdir -p "$OUT" -# A handoff names this run's index or none: the evaluation loop reuses the -# results directory, and a stale one would point the graph session at a root -# step 8 has already removed. -rm -f -- "$OUT/runtime-root.txt" - echo "INDEX: $LANG ($REPO)" # Count source files and LOC (exclude .git, vendor, node_modules, build dirs) From b9851700a431e5589f050970f275dde140ba7f3c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 00:06:52 +0100 Subject: [PATCH 52/65] fix(test): surface why a benchmark daemon did not start Both harnesses discarded the output of `daemon start`, so a refused start was reported only as "private daemon did not start", the symptom without its cause. The output now goes to a run-private file next to the index stderr, the shape both scripts already use for the index call, and is printed under "--- daemon start output ---" when the start fails. Signed-off-by: Martin Vogel --- scripts/benchmark-index.sh | 9 ++++++++- scripts/benchmark-search-graph.sh | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/benchmark-index.sh b/scripts/benchmark-index.sh index 5ab4353bb2..a8db36828d 100755 --- a/scripts/benchmark-index.sh +++ b/scripts/benchmark-index.sh @@ -52,6 +52,7 @@ trap bench_finish EXIT # it the failure below could name only its symptom. BENCH_TMP=$(mktemp -d) INDEX_ERR="$BENCH_TMP/index-stderr.log" +DAEMON_START_LOG="$BENCH_TMP/daemon-start.log" # Resolve symlinks REPO=$(cd "$REPO" && pwd -P) @@ -90,8 +91,14 @@ echo "$LOC" > "$OUT/loc.txt" # total-time.txt is their sum — the figure comparable with earlier runs, which # paid activation inside the index timing whenever no daemon was already warm. SETUP_START_MS=$(bench_now_ms) -if ! "$BINARY" daemon start >/dev/null 2>&1; then +# Its output is kept for the failure path: the product names why a daemon did +# not start, and that is the part an operator needs. +if ! "$BINARY" daemon start >"$DAEMON_START_LOG" 2>&1; then echo " $LANG: private daemon did not start" >&2 + if [ -s "$DAEMON_START_LOG" ]; then + echo "--- daemon start output ---" >&2 + cat "$DAEMON_START_LOG" >&2 + fi exit 1 fi diff --git a/scripts/benchmark-search-graph.sh b/scripts/benchmark-search-graph.sh index 6296d31fb2..dad4f6ec48 100755 --- a/scripts/benchmark-search-graph.sh +++ b/scripts/benchmark-search-graph.sh @@ -26,9 +26,16 @@ BENCH_TMP="" trap 'cbm_test_runtime_cleanup "$BINARY"; [ -z "$BENCH_TMP" ] || rm -rf -- "$BENCH_TMP"' EXIT BENCH_TMP=$(mktemp -d) INDEX_ERR="$BENCH_TMP/index-stderr.log" +DAEMON_START_LOG="$BENCH_TMP/daemon-start.log" -if ! "$BINARY" daemon start >/dev/null 2>&1; then +# Its output is kept for the failure path: the product names why a daemon did +# not start, and that is the part an operator needs. +if ! "$BINARY" daemon start >"$DAEMON_START_LOG" 2>&1; then echo "private daemon did not start" >&2 + if [ -s "$DAEMON_START_LOG" ]; then + echo "--- daemon start output ---" >&2 + cat "$DAEMON_START_LOG" >&2 + fi exit 1 fi # One pre-escaped spelling of the path, the way the soak harness builds its From 78bf6a274505aafe662b49955376f2d3f9e9d47e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 00:06:52 +0100 Subject: [PATCH 53/65] fix(test): hand over a kept benchmark runtime only after its daemon stopped With CBM_BENCH_KEEP_RUNTIME=1, bench_finish ran `daemon stop` with its output and exit status discarded and wrote runtime-root.txt regardless. `daemon stop` refuses while a committed client still uses the daemon and fails when the daemon does not answer; the handoff then promised a stopped daemon that was still running. A refused stop now prints the explanation of the product, writes no handoff, keeps the root without cleaning it up (the daemon may still be using it) and exits non-zero. A stop with nothing to stop still exits 0, so a clean run is unaffected. Section 7 step 8 of the evaluation plan follows the same rule: the kept root is removed only if its daemon stopped, and is kept with a message otherwise. Signed-off-by: Martin Vogel --- docs/EVALUATION_PLAN.md | 10 +++++++--- scripts/benchmark-index.sh | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/EVALUATION_PLAN.md b/docs/EVALUATION_PLAN.md index 39d72848fa..25a8f6aafd 100644 --- a/docs/EVALUATION_PLAN.md +++ b/docs/EVALUATION_PLAN.md @@ -299,9 +299,13 @@ for lang in $ALL_LANGS; do # ALL_LANGS = full 159-name list # --- step 8: delete THIS language's index so the next is cold, then mark done --- # The graph session (steps 4-7) may still have a daemon running under the kept - # CBM_RUNTIME_DIR (exported above); stop it before its root goes away. - ~/.local/bin/codebase-memory-mcp daemon stop || true - rm -rf -- "$CBM_BENCH_RUNTIME_ROOT" + # CBM_RUNTIME_DIR (exported above); stop it before its root goes away. A refused + # stop (a session still attached) means the root is still in use: keep it. + if ~/.local/bin/codebase-memory-mcp daemon stop; then + rm -rf -- "$CBM_BENCH_RUNTIME_ROOT" + else + echo "$lang: daemon under $CBM_RUNTIME_DIR did not stop; keeping $CBM_BENCH_RUNTIME_ROOT" >&2 + fi manifest_mark_done "$lang" "$index_ms" done ``` diff --git a/scripts/benchmark-index.sh b/scripts/benchmark-index.sh index a8db36828d..792ec9febe 100755 --- a/scripts/benchmark-index.sh +++ b/scripts/benchmark-index.sh @@ -33,11 +33,21 @@ cbm_test_runtime_init # including its removal — passes to the caller. A failed run cleans up # regardless: there is no index worth keeping, and nothing must leak. Never on # by default, or an unattended run accumulates one root per language. +# +# The handoff promises a stopped daemon, so it is written only once +# `daemon stop` succeeded. A refused stop (a client still committed to the +# daemon, or no answer) fails the run with the product's explanation, writes +# no handoff, and keeps the root without cleaning it up: the daemon may still +# be using it, and the operator decides what happens to it. bench_finish() { - local rc=$? + local rc=$? stop_out="" [ -z "${BENCH_TMP:-}" ] || rm -rf -- "$BENCH_TMP" || true if [ "$rc" -eq 0 ] && [ -n "${CBM_BENCH_KEEP_RUNTIME:-}" ] && [ -d "${OUT:-}" ]; then - "$BINARY" daemon stop >/dev/null 2>&1 || true + if ! stop_out=$("$BINARY" daemon stop 2>&1); then + echo " $LANG: private daemon did not stop; runtime kept at $CBM_TEST_RUNTIME_ROOT, no handoff written" >&2 + [ -z "$stop_out" ] || printf '%s\n' "$stop_out" >&2 + exit 1 + fi printf 'CBM_BENCH_RUNTIME_ROOT=%q\nCBM_RUNTIME_DIR=%q\nCBM_CACHE_DIR=%q\n' \ "$CBM_TEST_RUNTIME_ROOT" "$CBM_RUNTIME_DIR" "$CBM_CACHE_DIR" > "$OUT/runtime-root.txt" echo " $LANG: runtime kept at $CBM_TEST_RUNTIME_ROOT; paths in $OUT/runtime-root.txt" >&2 From 32f60a4e8d74eb6fe33de54b22f1a4b7bbf0a8f8 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 00:06:52 +0100 Subject: [PATCH 54/65] docs(eval-plan): stop clearing the live cache before an isolated run Section 7 still began with rm -f ~/.cache/codebase-memory-mcp/*.db. Every index in the loop now lands in a run-private cache (#1696), so that line no longer made any run colder; it only deleted the indexes in the live store of the operator. Dropped, and the comment above it now names only what the setup still does. Signed-off-by: Martin Vogel --- docs/EVALUATION_PLAN.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/EVALUATION_PLAN.md b/docs/EVALUATION_PLAN.md index 25a8f6aafd..17b3701f99 100644 --- a/docs/EVALUATION_PLAN.md +++ b/docs/EVALUATION_PLAN.md @@ -274,8 +274,7 @@ then its index is **deleted** before the next language — so **only one project every index is genuinely cold. The skeleton: ```bash -# Start clean: no leftover DBs, fresh results tree -rm -f ~/.cache/codebase-memory-mcp/*.db +# Start clean: fresh results tree mkdir -p /tmp/eval-results for lang in $ALL_LANGS; do # ALL_LANGS = full 159-name list (§2 / §8) From b9a186db479674ff50443546624fe956d4bcf175 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 00:06:52 +0100 Subject: [PATCH 55/65] test(bench): cover setup failure, refused daemon start and refused stop Three more cases in the benchmark runtime isolation contract, each RED against the harness without its fix: - a stale runtime-root.txt is gone after a run that fails during setup (a repository path that does not exist); - with the keep flag and a `daemon stop` that refuses, the run fails, writes no handoff, keeps its root and prints the refusal; the test removes that root itself, before any verdict, so a failing assertion leaks nothing; - a refused `daemon start` fails both harnesses, prints the cause and leaves no root behind. The fixture refuses the start on stderr and the stop on stdout, the channel the product uses for a busy refusal. Signed-off-by: Martin Vogel --- ...st_benchmark_runtime_isolation_contract.sh | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/tests/test_benchmark_runtime_isolation_contract.sh b/tests/test_benchmark_runtime_isolation_contract.sh index f2e862d0dd..3657910e39 100755 --- a/tests/test_benchmark_runtime_isolation_contract.sh +++ b/tests/test_benchmark_runtime_isolation_contract.sh @@ -11,7 +11,9 @@ set -euo pipefail # an environment-probe fixture and require that no product process ever # receives the caller's runtime or cache, that the index benchmark records # the setup cost it now pays explicitly, and that a refused index fails the -# run, names its cause, and leaves nothing behind. +# run, names its cause, and leaves nothing behind. A daemon that refuses to +# start or to stop fails the run the same way, and a kept runtime is handed +# over only once its daemon stopped. ROOT="$(cd "$(dirname "$0")/.." && pwd)" WORKDIR="$(mktemp -d)" @@ -43,6 +45,16 @@ if [[ "${1-} ${2-}" == "cli index_repository" ]]; then echo "probe: index refused" >&2 exit 1 fi +if [[ "${1-} ${2-}" == "daemon start" && -n "${CBM_BENCH_PROBE_START_REFUSED-}" ]]; then + echo "probe: daemon start refused" >&2 + exit 1 +fi +# The product reports a busy refusal (a committed client still uses the +# daemon) on stdout, so that is where the fixture reports its own. +if [[ "${1-} ${2-}" == "daemon stop" && -n "${CBM_BENCH_PROBE_STOP_REFUSED-}" ]]; then + echo "probe: daemon NOT stopped, a committed client still uses it" + exit 1 +fi [[ "${1-} ${2-}" == "daemon status" ]] && exit 1 exit 0 EOF @@ -136,6 +148,37 @@ grep -qF -- "${KEPT_CACHE}"$'\t'"${KEPT_RUNTIME}" "$KEEP_LOG" || fail "runtime-root.txt does not name the runtime and cache the product processes used" rm -rf -- "$KEPT_ROOT" +# The handoff promises a stopped daemon, so a kept runtime is handed over only +# once `daemon stop` succeeded. A refused stop fails the run, writes no handoff +# and keeps the root: the daemon may still be using it, so removing it is not +# the harness's call. The refusal itself has to reach the output. +STOP_LOG="$WORKDIR/keep-stop-refused-environment.log" +STOP_HANDOFF="$WORKDIR/results/keep-stop-refused/runtime-root.txt" +stop_rc=0 +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$STOP_LOG" \ +CBM_BENCH_PROBE_INDEX_OK=1 \ +CBM_BENCH_PROBE_STOP_REFUSED=1 \ +CBM_BENCH_KEEP_RUNTIME=1 \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" keep-stop-refused "$REPO" "$WORKDIR/results" \ + > "$WORKDIR/keep-stop-refused.out" 2>&1 || stop_rc=$? +[[ -s "$STOP_LOG" ]] || fail "benchmark-index (keep, refused stop) did not execute the environment-probe fixture" +STOP_RUNTIME=$(normalize_path "$(tail -n 1 "$STOP_LOG" | cut -f2)") +STOP_ROOT="${STOP_RUNTIME%/*}" +stop_root_kept=0 +[[ ! -d "$STOP_ROOT/runtime" ]] || stop_root_kept=1 +# Removed here, before any verdict, so a failing assertion leaks no root. +case "${STOP_ROOT##*/}" in +cbmrt.?* | cbmrt-?*) rm -rf -- "$STOP_ROOT" ;; +*) fail "benchmark-index (keep, refused stop) used an unexpected private root: $STOP_ROOT" ;; +esac +[[ "$stop_rc" -ne 0 ]] || fail "benchmark-index exited 0 although its daemon refused to stop" +[[ ! -e "$STOP_HANDOFF" ]] || fail "benchmark-index handed over a runtime whose daemon refused to stop" +(( stop_root_kept )) || fail "benchmark-index removed a runtime root whose daemon refused to stop" +grep -q 'probe: daemon NOT stopped' "$WORKDIR/keep-stop-refused.out" || + fail "benchmark-index did not surface why its daemon refused to stop" + # Asked to keep the runtime and refused the index, the harness has nothing worth # keeping: the run fails, no root survives, and no handoff is written — nor left # over from an earlier run, since the evaluation loop reuses the results @@ -159,6 +202,23 @@ done assert_isolated "benchmark-index (keep, refused index)" "$REFUSED_LOG" [[ ! -e "$REFUSED_HANDOFF" ]] || fail "benchmark-index left a handoff for an index that failed" +# The same holds for a run that never reaches the index: one whose repository +# does not exist fails during setup, and an earlier run's handoff in the reused +# results directory must already be gone by then. +SETUP_HANDOFF="$WORKDIR/results/setup-failed/runtime-root.txt" +mkdir -p "${SETUP_HANDOFF%/*}" +echo 'CBM_BENCH_RUNTIME_ROOT=/stale/root/from/an/earlier/run' > "$SETUP_HANDOFF" +setup_rc=0 +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$WORKDIR/setup-failed-environment.log" \ +CBM_BENCH_KEEP_RUNTIME=1 \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" setup-failed "$WORKDIR/no-such-repo" "$WORKDIR/results" \ + > "$WORKDIR/setup-failed.out" 2>&1 || setup_rc=$? +[[ "$setup_rc" -ne 0 ]] || fail "benchmark-index exited 0 for a repository that does not exist" +[[ ! -e "$SETUP_HANDOFF" ]] || + fail "benchmark-index left an earlier handoff behind after a run that failed during setup" + SEARCH_LOG="$WORKDIR/search-environment.log" CBM_CACHE_DIR="$CALLER_CACHE" \ CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ @@ -175,6 +235,28 @@ grep -q -- '--- index/parse stderr ---' "$WORKDIR/search.out" || grep -q 'probe: index refused' "$WORKDIR/search.out" || fail "benchmark-search-graph did not surface the cause of the index failure" +# A private daemon that does not start fails either harness, and the product's +# own explanation has to reach the output: "did not start" alone leaves the +# operator with the symptom only. +for harness in benchmark-index benchmark-search-graph; do + if [[ "$harness" == benchmark-index ]]; then + harness_args=("$ENV_PROBE" start-refused "$REPO" "$WORKDIR/results") + else + harness_args=("$ENV_PROBE" "$REPO") + fi + start_rc=0 + CBM_CACHE_DIR="$CALLER_CACHE" \ + CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ + CBM_BENCH_ENV_PROBE="$WORKDIR/$harness-start-refused-environment.log" \ + CBM_BENCH_PROBE_START_REFUSED=1 \ + "$ROOT/scripts/$harness.sh" "${harness_args[@]}" \ + > "$WORKDIR/$harness-start-refused.out" 2>&1 || start_rc=$? + [[ "$start_rc" -ne 0 ]] || fail "$harness exited 0 although its private daemon did not start" + assert_isolated "$harness (daemon start refused)" "$WORKDIR/$harness-start-refused-environment.log" + grep -q 'probe: daemon start refused' "$WORKDIR/$harness-start-refused.out" || + fail "$harness did not surface why its private daemon did not start" +done + # The request carrying the repository path has to be built as JSON. A path may # legitimately contain a quote or a backslash — hand-built JSON turns that into # a payload the server cannot parse, or one that means something else. NTFS From faf25ee4fa6a9ba7ff0f24a5091756f21f8611e3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:04:05 +0100 Subject: [PATCH 56/65] 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 b2a655505b..ab5c89db73 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 42ecb93c5b..7c7a2f9878 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 5d198a0ef6..7c251bd9b0 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 57/65] 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 4ac403387c..6e0f85ff37 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 384ba8467b..61bc0100bd 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 c45ec6de89..00479171bf 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 f2fe09b26e..31ffd69b04 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 8e78f9f406..fc4f37cb48 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 af140c53f1..586b788845 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 8eab275a2c..257e799ad8 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 58/65] 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 99a2a328a5..181a8aad4a 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 59/65] 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 4166b74e6c..c82f90f11d 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; From a0d0521fcba680ca9f21ad3dc55425b25ec29f48 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 18:12:22 +0100 Subject: [PATCH 60/65] fix(store): keep the indexed checkout outside the digested tables index_coverage_meta is one of the tables the graph digest pins column by column, so the new indexed_checkout_sha column made the digest reject every live store with a schema error: the live-schema compatibility test failed, and test-impact, which digests the graph it builds for HEAD, fell back to running every test for every change. Record the checkout in a table of its own instead: index_checkout (project primary key referencing projects ON DELETE CASCADE, indexed_checkout_sha NOT NULL). A checkout is source identity, which the graph digest deliberately does not bind, and the store already keeps other per-project data (doc_link_*, observed_*) outside the digested tables. index_coverage_meta keeps exactly the shape the digest expects, so the digest needs no change and no version bump, and a new table needs no migration of existing databases. The row is written and cleared in the same coverage transaction, so a failed replace keeps the previous identity, and it is removed with its project. The store API is unchanged: cbm_coverage_meta_t carries indexed_checkout_sha as before. Read-only opens of databases without the table report no identity, as before. store_graph_digest_indexed_checkout_identity_is_not_bound writes a store through the real coverage API with and without a checkout SHA and asserts that it still digests and that the SHA leaves the digest unchanged. Signed-off-by: Martin Vogel --- src/store/store.c | 159 ++++++++++++++++++++------------ src/store/store.h | 6 +- tests/test_store_graph_digest.c | 40 ++++++++ 3 files changed, 143 insertions(+), 62 deletions(-) diff --git a/src/store/store.c b/src/store/store.c index 41fcbc4fb6..ed8462d5aa 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -324,29 +324,22 @@ bool cbm_store_edges_schema_current(cbm_store_t *s) { return true; } -/* Best-effort probe for the BT-240 indexed_checkout_sha column. Returns true - * only when the index_coverage_meta table exists AND exposes the column. - * Writable opens run init_schema (which adds it), so false is expected only on - * read-only legacy DBs opened via the query path — those must keep working and - * simply report no indexed-checkout identity. */ -static bool coverage_meta_has_checkout_column(cbm_store_t *s) { +/* Best-effort probe for the BT-240 index_checkout table. Writable opens run + * init_schema (which creates it), so false is expected only on read-only + * legacy DBs opened via the query path — those must keep working and simply + * report no indexed-checkout identity. */ +static bool index_checkout_table_exists(cbm_store_t *s) { if (!s || !s->db) { return false; } sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, "PRAGMA table_info(index_coverage_meta);", CBM_NOT_FOUND, &stmt, - NULL) != SQLITE_OK) { + if (sqlite3_prepare_v2(s->db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name = 'index_checkout';", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { return false; } - bool has = false; - int rc; - while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { - const char *name = (const char *)sqlite3_column_text(stmt, SKIP_ONE); - if (name && strcmp(name, "indexed_checkout_sha") == 0) { - has = true; - break; - } - } + bool has = sqlite3_step(stmt) == SQLITE_ROW; sqlite3_finalize(stmt); return has; } @@ -490,8 +483,15 @@ static int init_schema(cbm_store_t *s) { " ignored_files_stored INTEGER NOT NULL DEFAULT 0," " ignored_files_total INTEGER NOT NULL DEFAULT 0," " coverage_version INTEGER NOT NULL DEFAULT 1," - " hash_records_complete INTEGER NOT NULL DEFAULT 0," - " indexed_checkout_sha TEXT" + " hash_records_complete INTEGER NOT NULL DEFAULT 0" + ");" + /* BT-240: the checkout an index was built from. A table of its own, + * not an index_coverage_meta column: it is source identity, which the + * pinned graph-digest tables deliberately do not bind, and a new + * table needs no migration of existing databases. */ + "CREATE TABLE IF NOT EXISTS index_checkout (" + " project TEXT PRIMARY KEY REFERENCES projects(name) ON DELETE CASCADE," + " indexed_checkout_sha TEXT NOT NULL" ");"; int rc = exec_sql(s, ddl); @@ -499,16 +499,6 @@ static int init_schema(cbm_store_t *s) { return rc; } - /* BT-240 migration: CREATE TABLE IF NOT EXISTS cannot add a column to an - * existing legacy index_coverage_meta, so add it idempotently for any - * writable DB that predates it. Read-only query opens skip init_schema - * entirely and meta_get probes the column separately. */ - if (!coverage_meta_has_checkout_column(s) && - exec_sql(s, "ALTER TABLE index_coverage_meta ADD COLUMN indexed_checkout_sha TEXT;") != - CBM_STORE_OK) { - return CBM_STORE_ERR; - } - /* Schema-compat probe (#768): DBs created before the local_name_gen * discriminator still enforce UNIQUE(source_id,target_id,type) and lack * the column — the widened upsert in cbm_store_insert_edge can neither @@ -3065,6 +3055,7 @@ int cbm_store_delete_project(cbm_store_t *s, const char *name) { static const char *cleanup_sql[] = { "DELETE FROM index_coverage WHERE project = ?1;", "DELETE FROM index_coverage_meta WHERE project = ?1;", + "DELETE FROM index_checkout WHERE project = ?1;", "DELETE FROM projects WHERE name = ?1 || '::missed';", }; /* doc_link_unresolved exists only in databases published by a build that @@ -4773,6 +4764,34 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) { return CBM_STORE_OK; } +/* Record (or, with no sha, clear) the checkout a project's index was built + * from. Runs inside the caller's coverage transaction, so a failed replace + * keeps the previous identity. */ +static int index_checkout_write(cbm_store_t *s, const char *project, const char *sha) { + bool record = sha && sha[0]; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + record ? "INSERT INTO index_checkout (project, indexed_checkout_sha) " + "VALUES (?1, ?2) ON CONFLICT(project) DO UPDATE SET " + "indexed_checkout_sha=?2;" + : "DELETE FROM index_checkout WHERE project = ?1;", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "index checkout write prepare"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + if (record) { + bind_text(stmt, ST_COL_2, sha); + } + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + store_set_error_sqlite(s, "index checkout write"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, const cbm_coverage_row_t *rows, int count, const cbm_coverage_meta_t *meta) { @@ -4894,12 +4913,11 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, "INSERT INTO index_coverage_meta " "(project, generation, index_mode, recorded_at, recording_status, " " ignored_files_stored, ignored_files_total, coverage_version, " - " hash_records_complete, indexed_checkout_sha) " - "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) " + " hash_records_complete) " + "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) " "ON CONFLICT(project) DO UPDATE SET generation=?2, index_mode=?3, " "recorded_at=?4, recording_status=?5, ignored_files_stored=?6, " - "ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9, " - "indexed_checkout_sha=?10;", + "ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9;", CBM_NOT_FOUND, &up_meta, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "coverage meta upsert prepare"); (void)exec_sql(s, "ROLLBACK;"); @@ -4914,11 +4932,6 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, sqlite3_bind_int(up_meta, 7, ignored_total); sqlite3_bind_int(up_meta, 8, coverage_version); sqlite3_bind_int(up_meta, 9, meta->hash_records_complete ? 1 : 0); - if (meta->indexed_checkout_sha && meta->indexed_checkout_sha[0]) { - bind_text(up_meta, 10, meta->indexed_checkout_sha); - } else { - sqlite3_bind_null(up_meta, 10); - } int meta_rc = sqlite3_step(up_meta); sqlite3_finalize(up_meta); if (meta_rc != SQLITE_DONE) { @@ -4926,6 +4939,10 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, (void)exec_sql(s, "ROLLBACK;"); return CBM_STORE_ERR; } + if (index_checkout_write(s, project, meta->indexed_checkout_sha) != CBM_STORE_OK) { + (void)exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; + } } else { sqlite3_stmt *del_meta = NULL; if (sqlite3_prepare_v2(s->db, "DELETE FROM index_coverage_meta WHERE project = ?1;", @@ -4942,6 +4959,10 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, (void)exec_sql(s, "ROLLBACK;"); return CBM_STORE_ERR; } + if (index_checkout_write(s, project, NULL) != CBM_STORE_OK) { + (void)exec_sql(s, "ROLLBACK;"); + return CBM_STORE_ERR; + } } /* Rebuild the derived miss-graph view from the now-authoritative table @@ -5076,6 +5097,41 @@ void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) { memset(meta, 0, sizeof(*meta)); } +/* Fill out->indexed_checkout_sha from index_checkout. A legacy read-only DB + * (query opens skip init_schema) may lack the table: no identity, not an + * error. On failure out is cleared. */ +static int index_checkout_read(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out) { + if (!index_checkout_table_exists(s)) { + return CBM_STORE_OK; + } + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT indexed_checkout_sha FROM index_checkout WHERE project = ?1;", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "index checkout read prepare"); + cbm_store_coverage_meta_clear(out); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + int rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + const char *sha = (const char *)sqlite3_column_text(stmt, 0); + if (sha && sha[0]) { + out->indexed_checkout_sha = cbm_mem_strdup(CBM_MEM_CLASS_STORE, sha); + rc = out->indexed_checkout_sha ? SQLITE_DONE : SQLITE_NOMEM; + } else { + rc = SQLITE_DONE; + } + } + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + store_set_error_sqlite(s, "index checkout read"); + cbm_store_coverage_meta_clear(out); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out) { if (!out) { return CBM_STORE_ERR; @@ -5085,21 +5141,11 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag return CBM_STORE_ERR; } sqlite3_stmt *stmt = NULL; - /* Legacy read-only DBs (query opens skip init_schema) lack the - * indexed_checkout_sha column — probe and fall back so they still read and - * report no identity instead of erroring. */ - static const char SQL_FULL[] = - "SELECT project, generation, index_mode, recorded_at, recording_status, " - "ignored_files_stored, ignored_files_total, coverage_version, " - "hash_records_complete, indexed_checkout_sha FROM index_coverage_meta " - "WHERE project = ?1;"; - static const char SQL_LEGACY[] = - "SELECT project, generation, index_mode, recorded_at, recording_status, " - "ignored_files_stored, ignored_files_total, coverage_version, " - "hash_records_complete FROM index_coverage_meta WHERE project = ?1;"; - bool has_checkout_column = coverage_meta_has_checkout_column(s); - if (sqlite3_prepare_v2(s->db, has_checkout_column ? SQL_FULL : SQL_LEGACY, CBM_NOT_FOUND, &stmt, - NULL) != SQLITE_OK) { + if (sqlite3_prepare_v2(s->db, + "SELECT project, generation, index_mode, recorded_at, recording_status, " + "ignored_files_stored, ignored_files_total, coverage_version, " + "hash_records_complete FROM index_coverage_meta WHERE project = ?1;", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "coverage meta get prepare"); return CBM_STORE_ERR; } @@ -5115,18 +5161,13 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag out->ignored_files_total = sqlite3_column_int(stmt, 6); out->coverage_version = sqlite3_column_int(stmt, 7); out->hash_records_complete = sqlite3_column_int(stmt, 8) != 0; - if (has_checkout_column) { - const char *sha = (const char *)sqlite3_column_text(stmt, 9); - out->indexed_checkout_sha = - sha && sha[0] ? cbm_mem_strdup(CBM_MEM_CLASS_STORE, sha) : NULL; - } sqlite3_finalize(stmt); if (!out->project || !out->generation || !out->index_mode || !out->recorded_at || !out->recording_status) { cbm_store_coverage_meta_clear(out); return CBM_STORE_ERR; } - return CBM_STORE_OK; + return index_checkout_read(s, project, out); } sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { diff --git a/src/store/store.h b/src/store/store.h index 30662b221d..43f1a21f0c 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -791,9 +791,9 @@ typedef struct { int coverage_version; bool hash_records_complete; /* Git checkout identity frozen at the successful staged-generation - * boundary (BT-240). NULL/"" when the run had no git context (non-git - * repo) or the DB predates the column. Owned by the row: meta_get - * allocates it, meta_clear frees it. */ + * boundary (BT-240), stored in the index_checkout table. NULL/"" when the + * run had no git context (non-git repo) or the DB predates that table. + * Owned by the row: meta_get allocates it, meta_clear frees it. */ const char *indexed_checkout_sha; } cbm_coverage_meta_t; diff --git a/tests/test_store_graph_digest.c b/tests/test_store_graph_digest.c index abb32ba7c3..d2add2adf2 100644 --- a/tests/test_store_graph_digest.c +++ b/tests/test_store_graph_digest.c @@ -594,6 +594,45 @@ TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates) { cbm_store_close(a);cbm_store_close(b);PASS(); } +/* BT-240 (#1561): the indexed checkout is source identity, which the digest + * never binds, so it lives outside the digested tables. A store written through + * the real coverage API, with and without a checkout SHA, keeps passing the + * live-schema check (test-impact falls back to running everything otherwise), + * and recording or changing the SHA leaves the digest unchanged. */ +TEST(store_graph_digest_indexed_checkout_identity_is_not_bound) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "p", "/live"), CBM_STORE_OK); + cbm_coverage_meta_t meta = {.generation = "g", + .index_mode = "full", + .recorded_at = "t", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true}; + cbm_store_graph_digest_t none = {0}, first = {0}, second = {0}; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK); + bool none_ok = gd_p(s, &none); + meta.indexed_checkout_sha = "0123456789abcdef0123456789abcdef01234567"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK); + bool first_ok = gd_p(s, &first); + meta.indexed_checkout_sha = "89abcdef0123456789abcdef0123456789abcdef"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK); + bool second_ok = gd_p(s, &second); + cbm_coverage_meta_t got = {0}; + int got_rc = cbm_store_coverage_meta_get(s, "p", &got); + bool sha_kept = got_rc == CBM_STORE_OK && got.indexed_checkout_sha && + strcmp(got.indexed_checkout_sha, "89abcdef0123456789abcdef0123456789abcdef") == 0; + cbm_store_coverage_meta_clear(&got); + cbm_store_close(s); + ASSERT_TRUE(none_ok); + ASSERT_TRUE(first_ok); + ASSERT_TRUE(second_ok); + ASSERT_TRUE(sha_kept); + ASSERT_TRUE(gd_equal(&none, &first)); + ASSERT_TRUE(gd_equal(&first, &second)); + PASS(); +} + SUITE(store_graph_digest) { RUN_TEST(store_graph_digest_independent_canonical_golden); RUN_TEST(store_graph_digest_logical_order_and_excluded_storage_are_stable); @@ -607,4 +646,5 @@ SUITE(store_graph_digest) { RUN_TEST(store_graph_digest_cancellation_large_blob_never_publishes_prefix); RUN_TEST(store_graph_digest_sql_errors_cleanup_no_mutation_and_close_discard); RUN_TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates); + RUN_TEST(store_graph_digest_indexed_checkout_identity_is_not_bound); } From 42a2e2dd7880a9cb5676f549e28754344394e724 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sun, 8 Nov 2026 19:16:51 -0800 Subject: [PATCH 61/65] Port upstream #2198: benchmark runtime isolation (upstream #1696) Finish the merge of the benchmark harness isolation work onto the CLI-first fork. The auto-merged upstream hunks left both scripts as a mix of fork and upstream invocations (undefined INDEX_RC, an unused INDEX_ERR, MCP `cli index_repository` calls). benchmark-index.sh: run `codebase-memory-cli index REPO --mode full --json` with stderr captured to INDEX_ERR and the exit status kept in INDEX_RC, so a refused index fails the run, prints "--- index stderr ---" and the cause. Dropped the now-unneeded hand-built JSON path (argv carries the path). benchmark-search-graph.sh: index through `index REPO --mode full --json` instead of the MCP `cli index_repository` request; stop unwrapping the MCP content envelope; fix usage comment (arg is a repo path). docs/EVALUATION_PLAN.md: example invocations name codebase-memory-cli. Contract test adaptations (assertions on isolation, stderr surfacing, exit status, handoff and stop behaviour unchanged): - Probe fixture detects `index` (argv 1) instead of `cli index_repository`. - The fixture logs the repo path (argv 2) rather than a JSON body; the quoted/backslash-path check now asserts the path reaches the index command as one intact argument, since the fork's CLI has no JSON request. - "MCP session" comment wording changed to "graph session". --- docs/EVALUATION_PLAN.md | 4 ++-- scripts/benchmark-index.sh | 13 ++++++----- scripts/benchmark-search-graph.sh | 14 ++++------- ...st_benchmark_runtime_isolation_contract.sh | 23 +++++++++---------- 4 files changed, 24 insertions(+), 30 deletions(-) diff --git a/docs/EVALUATION_PLAN.md b/docs/EVALUATION_PLAN.md index 17b3701f99..bf610a41a6 100644 --- a/docs/EVALUATION_PLAN.md +++ b/docs/EVALUATION_PLAN.md @@ -283,7 +283,7 @@ for lang in $ALL_LANGS; do # ALL_LANGS = full 159-name list # --- step 2: cold index in the main channel, TIMED (key metric) --- t0=$(now_ms) CBM_BENCH_KEEP_RUNTIME=1 \ - scripts/benchmark-index.sh ~/.local/bin/codebase-memory-mcp "$lang" /tmp/bench/"$lang" /tmp/eval-results + scripts/benchmark-index.sh ~/.local/bin/codebase-memory-cli "$lang" /tmp/bench/"$lang" /tmp/eval-results index_ms=$(( $(now_ms) - t0 )) # clone+index wall-clock → manifest + report (§5) # The harness indexes into a run-private root (#1696). CBM_BENCH_KEEP_RUNTIME leaves that # root behind on success and records its paths; the graph session in steps 4-7 must be @@ -878,7 +878,7 @@ scripts/clone-bench-repos.sh /tmp/bench mkdir -p /tmp/eval-results for lang in $ALL_LANGS; do CBM_BENCH_KEEP_RUNTIME=1 \ - scripts/benchmark-index.sh ~/.local/bin/codebase-memory-mcp "$lang" /tmp/bench/"$lang" /tmp/eval-results + scripts/benchmark-index.sh ~/.local/bin/codebase-memory-cli "$lang" /tmp/bench/"$lang" /tmp/eval-results done # 3. Cross-repo pass for the 9 LSP pairs (index each service dir, then cross-repo-intelligence) diff --git a/scripts/benchmark-index.sh b/scripts/benchmark-index.sh index 484cf1122c..51ac5f73b8 100755 --- a/scripts/benchmark-index.sh +++ b/scripts/benchmark-index.sh @@ -2,7 +2,7 @@ set -euo pipefail # Index a single benchmark repository and capture metrics. -# Usage: benchmark-index.sh +# Usage: benchmark-index.sh BINARY="${1:?Usage: benchmark-index.sh }" LANG="${2:?}" @@ -66,10 +66,6 @@ DAEMON_START_LOG="$BENCH_TMP/daemon-start.log" # Resolve symlinks REPO=$(cd "$REPO" && pwd -P) -# One pre-escaped spelling of the path for every request below, the way the -# soak harness builds its own: a repository path may legitimately contain a -# quote or a backslash, and hand-built JSON turns that into a parse error. -REPO_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$REPO") # Elapsed time is read from a monotonic clock, never the wall clock: an NTP # step mid-run would otherwise skew — or negate — a figure whose whole purpose @@ -115,7 +111,12 @@ fi # Index via CLI and capture timing START_MS=$(bench_now_ms) -INDEX_JSON=$("$BINARY" index "$REPO" --mode full --json 2>/dev/null || echo '{"error":"index failed"}') +# The path is one argv element, so a quote or backslash in it needs no escaping. +# The exit status is kept (a bare `|| echo` would hide it) and stderr goes to +# INDEX_ERR for the failure path below. +INDEX_RC=0 +INDEX_JSON=$("$BINARY" index "$REPO" --mode full --json 2>"$INDEX_ERR") || INDEX_RC=$? +[ -n "$INDEX_JSON" ] || INDEX_JSON='{"error":"index failed"}' END_MS=$(bench_now_ms) ELAPSED=$((END_MS - START_MS)) diff --git a/scripts/benchmark-search-graph.sh b/scripts/benchmark-search-graph.sh index 2a38d6fb44..02895680cb 100755 --- a/scripts/benchmark-search-graph.sh +++ b/scripts/benchmark-search-graph.sh @@ -3,7 +3,7 @@ # codebase-memory-cli binary to measure regex / BM25 search performance. # # Usage: -# scripts/benchmark-search-graph.sh +# scripts/benchmark-search-graph.sh set -euo pipefail @@ -30,20 +30,14 @@ if ! "$BINARY" daemon start >"$DAEMON_START_LOG" 2>&1; then fi exit 1 fi -# One pre-escaped spelling of the path, the way the soak harness builds its -# own: a repository path may legitimately contain a quote or a backslash, and -# hand-built JSON turns that into a parse error. -REPO_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$REPO") # Index and parse keep their stderr instead of discarding it: without it the # failure below names only its symptom, and the cause — an unreadable -# repository, a refused daemon, a malformed envelope — is unrecoverable. -INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":$REPO_JSON,\"mode\":\"full\"}" \ - 2>"$INDEX_ERR" || echo '{}') +# repository, a refused daemon, a malformed envelope — is unrecoverable. The +# repository path is one argv element, so a quote or backslash needs no escaping. +INDEX_JSON=$("$BINARY" index "$REPO" --mode full --json 2>"$INDEX_ERR" || echo '{}') PROJECT=$(echo "$INDEX_JSON" | python3 -c " import json, sys d = json.load(sys.stdin) -if 'content' in d: - d = json.loads(d['content'][0]['text']) print(d.get('project', '')) " 2>>"$INDEX_ERR" || echo "") if [ -z "$PROJECT" ]; then diff --git a/tests/test_benchmark_runtime_isolation_contract.sh b/tests/test_benchmark_runtime_isolation_contract.sh index 3657910e39..98193bab02 100755 --- a/tests/test_benchmark_runtime_isolation_contract.sh +++ b/tests/test_benchmark_runtime_isolation_contract.sh @@ -36,8 +36,8 @@ ENV_PROBE="$WORKDIR/environment-probe" cat > "$ENV_PROBE" <<'EOF' #!/usr/bin/env bash printf '%s\t%s\n' "${CBM_CACHE_DIR-}" "${CBM_RUNTIME_DIR-}" >> "$CBM_BENCH_ENV_PROBE" -if [[ "${1-} ${2-}" == "cli index_repository" ]]; then - [[ -z "${CBM_BENCH_REQUEST_LOG-}" ]] || printf '%s\n' "${3-}" >> "$CBM_BENCH_REQUEST_LOG" +if [[ "${1-}" == "index" ]]; then + [[ -z "${CBM_BENCH_REQUEST_LOG-}" ]] || printf '%s\n' "${2-}" >> "$CBM_BENCH_REQUEST_LOG" if [[ -n "${CBM_BENCH_PROBE_INDEX_OK-}" ]]; then printf '%s\n' '{"project":"probe"}' exit 0 @@ -117,7 +117,7 @@ grep -q 'probe: index refused' "$WORKDIR/index.out" || fail "benchmark-index did not surface the cause of the index failure" # The evaluation plan indexes a language and then reads that index from its own -# MCP session (docs/EVALUATION_PLAN.md §7). Asked to keep the runtime, a +# graph session (docs/EVALUATION_PLAN.md §7). Asked to keep the runtime, a # successful run must leave its root behind and record, sourceably, the paths # that reach it — and they must be the paths the product processes actually # used. Unasked, the root is gone (asserted above). The fixture answers this @@ -257,10 +257,11 @@ for harness in benchmark-index benchmark-search-graph; do fail "$harness did not surface why its private daemon did not start" done -# The request carrying the repository path has to be built as JSON. A path may -# legitimately contain a quote or a backslash — hand-built JSON turns that into -# a payload the server cannot parse, or one that means something else. NTFS -# rejects both characters in a path component, so this case is POSIX-only. +# The repository path reaches the CLI as a single argv element. A path may +# legitimately contain a quote or a backslash — word-splitting or re-quoting +# would mangle it. NTFS rejects both characters in a path component, so this +# case is POSIX-only. (Adapted: upstream asserted a JSON request body; the +# fork's CLI takes the path as `index `, so the fixture logs argv.) case "$(uname -s)" in MINGW* | MSYS* | CYGWIN*) ;; *) @@ -275,8 +276,7 @@ MINGW* | MSYS* | CYGWIN*) ;; CBM_BENCH_REQUEST_LOG="$REQUEST_LOG" \ "$ROOT/scripts/benchmark-search-graph.sh" "$ENV_PROBE" "$QUIRKY_REPO" \ > "$WORKDIR/quirky.out" 2>&1 || true - python3 - "$REQUEST_LOG" "$QUIRKY_RESOLVED" <<'PY' || fail "benchmark-search-graph built an index request that is not valid JSON for a quoted path" -import json + python3 - "$REQUEST_LOG" "$QUIRKY_RESOLVED" <<'PY' || fail "benchmark-search-graph did not pass a quoted path intact to the index command" import sys request_log, expected = sys.argv[1], sys.argv[2] @@ -286,9 +286,8 @@ except OSError: sys.exit("the search benchmark sent no index request") if not lines: sys.exit("the search benchmark sent no index request") -payload = json.loads(lines[0]) -if payload.get("repo_path") != expected: - sys.exit(f"repo_path is {payload.get('repo_path')!r}, expected {expected!r}") +if lines[0] != expected: + sys.exit(f"repo path is {lines[0]!r}, expected {expected!r}") PY ;; esac From 0aad606d075bb7959eaba88d5512b44288836ea8 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sun, 8 Nov 2026 19:28:56 -0800 Subject: [PATCH 62/65] Add truthful index freshness verdict to verbose status Port upstream DeusData PR #1561 from the removed MCP server into the operations layer. `status --verbose` now emits a `freshness` object next to the unchanged `git` object: - indexed_generation, indexed_checkout_sha (recorded in coverage meta at the staged-generation boundary), checkout_sha (live HEAD) - status_available plus bounded tracked_changes / untracked_source samples (cap 16, truncated flag) from cbm_git_worktree_status - verdict current/stale/unknown, dominant reason, reasons[] (max 8), recommended_action The verdict is fail-closed: a missing recorded identity is unknown, a SHA mismatch or tracked changes are stale, and untracked sources, an unavailable git status or an unresolvable HEAD prevent "current". It is report-only and never triggers indexing; the default (non-verbose) status output is unchanged. The status tool schema description and docs/MEASURING_SAVINGS.md describe the new block. Integration tests (real git repos, real project DB in a private cache dir) cover current, mismatch, tracked, untracked-only, mixed, non-git, missing identity, sample-cap truncation and the verbose-only gate. --- docs/MEASURING_SAVINGS.md | 10 +- src/operations/read.c | 123 ++++++++++++ src/operations/tool_catalog.c | 5 +- tests/test_incremental.c | 356 ++++++++++++++++++++++++++++++++++ 4 files changed, 488 insertions(+), 6 deletions(-) diff --git a/docs/MEASURING_SAVINGS.md b/docs/MEASURING_SAVINGS.md index 92d54f03aa..3d459f4c2d 100644 --- a/docs/MEASURING_SAVINGS.md +++ b/docs/MEASURING_SAVINGS.md @@ -104,10 +104,12 @@ codebase-memory-cli status --project PROJECT_NAME --verbose ``` Confirm that `root_path` is `GRAPH_REPO`, `git.head_sha` is `SHA`, and the Git -context is detached. `index_status` describes the project root and its current -filesystem Git context; it does **not** by itself prove that indexed records -were produced from that revision. The immediately preceding successful fresh -`index_repository` call is the freshness requirement. +context is detached. The verbose `freshness` object compares the checkout SHA +recorded when the index was written with the live HEAD and a bounded worktree +status; `verdict` is `current`, `stale`, or `unknown` (report-only, it never +indexes). Treat anything other than `current` as not proven. The immediately +preceding successful fresh `index_repository` call remains the freshness +requirement. Finally, run one or more representative queries whose expected symbols you have verified directly at the recorded SHA: diff --git a/src/operations/read.c b/src/operations/read.c index ebdafab303..b21d5fd597 100644 --- a/src/operations/read.c +++ b/src/operations/read.c @@ -36,6 +36,8 @@ #define OP_DB_EXT_LEN 3U #define OP_COVERAGE_FILE_CAP 500 #define OP_COVERAGE_SUMMARY_SAMPLES 5 +#define OP_STATUS_SAMPLE_MAX 16 /* per-class path samples in verbose freshness */ +#define OP_REASONS_MAX 8 /* freshness reasons array ceiling */ #define OP_PROJECTS_MAX_LIMIT 500 static char *copy_string(const char *text); @@ -542,6 +544,126 @@ static void add_status_coverage(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s } } +/* Bounded worktree-status snapshot inside the freshness block. Fail-closed: + * status_available is false whenever git status could not run clean, in which + * case counts are zero and no samples are emitted -- a non-git root or missing + * git is never presented as a clean worktree. Ignored paths are absent. */ +static void add_worktree_status_json(yyjson_mut_doc *doc, yyjson_mut_val *freshness, + const cbm_worktree_status_t *st) { + yyjson_mut_obj_add_bool(doc, freshness, "status_available", st->available); + + yyjson_mut_val *tracked = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, tracked, "count", st->tracked_count); + yyjson_mut_val *tpaths = yyjson_mut_arr(doc); + for (int i = 0; i < st->tracked_sample_count; i++) + yyjson_mut_arr_add_strcpy(doc, tpaths, st->tracked_paths[i]); + yyjson_mut_obj_add_val(doc, tracked, "paths", tpaths); + yyjson_mut_obj_add_bool(doc, tracked, "truncated", st->tracked_truncated); + yyjson_mut_obj_add_val(doc, freshness, "tracked_changes", tracked); + + yyjson_mut_val *untracked = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, untracked, "count", st->untracked_count); + yyjson_mut_val *upaths = yyjson_mut_arr(doc); + for (int i = 0; i < st->untracked_sample_count; i++) + yyjson_mut_arr_add_strcpy(doc, upaths, st->untracked_paths[i]); + yyjson_mut_obj_add_val(doc, untracked, "paths", upaths); + yyjson_mut_obj_add_bool(doc, untracked, "truncated", st->untracked_truncated); + yyjson_mut_obj_add_val(doc, freshness, "untracked_source", untracked); +} + +/* Fail-closed freshness verdict against the indexed-checkout identity recorded + * with the DB at the staged-generation boundary: + * - no indexed SHA -> unknown + * - indexed SHA differs from live git HEAD -> stale + * - tracked changes present -> stale + * - untracked sources / status unavailable -> prevents current (unknown) + * - equal SHA + status available + no changes -> current + * Report-only: never triggers indexing. Verbose-only. */ +static void add_index_freshness_json(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_store_t *store, + const char *project, const char *root_path, + const char *indexed_generation) { + yyjson_mut_val *freshness = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_generation", + indexed_generation ? indexed_generation : ""); + + cbm_coverage_meta_t meta = {0}; + bool have_meta = store && cbm_store_coverage_meta_get(store, project, &meta) == CBM_STORE_OK; + const char *indexed_sha = NULL; + if (have_meta && meta.indexed_checkout_sha && meta.indexed_checkout_sha[0]) + indexed_sha = meta.indexed_checkout_sha; + + cbm_git_context_t ctx = {0}; + (void)cbm_git_context_resolve(root_path, &ctx); + const char *checkout_sha = ctx.head_sha && ctx.head_sha[0] ? ctx.head_sha : NULL; + + cbm_worktree_status_t st = {0}; + int status_rc = cbm_git_worktree_status(root_path, OP_STATUS_SAMPLE_MAX, &st); + bool status_available = status_rc == 0 && st.available; + add_worktree_status_json(doc, freshness, &st); + + if (indexed_sha) + yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_checkout_sha", indexed_sha); + else + yyjson_mut_obj_add_null(doc, freshness, "indexed_checkout_sha"); + if (checkout_sha) + yyjson_mut_obj_add_strcpy(doc, freshness, "checkout_sha", checkout_sha); + else + yyjson_mut_obj_add_null(doc, freshness, "checkout_sha"); + + /* Stale reasons win; a missing indexed identity is unknown regardless of + * live state. "reason" is the first (dominant) entry of "reasons". */ + bool sha_present = indexed_sha != NULL; + bool live_present = checkout_sha != NULL; + bool sha_mismatch = sha_present && live_present && strcmp(indexed_sha, checkout_sha) != 0; + bool has_tracked = st.tracked_count > 0; + bool has_untracked = st.untracked_count > 0; + + const char *verdict; + if (!sha_present) + verdict = "unknown"; + else if (sha_mismatch || has_tracked) + verdict = "stale"; + else if (!live_present || !status_available || has_untracked) + verdict = "unknown"; + else + verdict = "current"; + + const char *reasons[OP_REASONS_MAX]; + int n_reasons = 0; + if (!sha_present) + reasons[n_reasons++] = "indexed_checkout_unavailable"; + if (sha_mismatch) + reasons[n_reasons++] = "indexed_checkout_mismatch"; + if (has_tracked) + reasons[n_reasons++] = "tracked_changes_present"; + if (!status_available) + reasons[n_reasons++] = "status_unavailable"; + if (has_untracked) + reasons[n_reasons++] = "untracked_not_indexed"; + if (n_reasons == 0) + reasons[n_reasons++] = "indexed_checkout_current"; + + const char *recommended_action = "reindex_to_record_indexed_checkout"; + if (strcmp(verdict, "current") == 0) + recommended_action = "use_graph"; + else if (strcmp(verdict, "stale") == 0) + recommended_action = "reindex_to_match_checkout"; + + yyjson_mut_obj_add_str(doc, freshness, "verdict", verdict); + yyjson_mut_obj_add_str(doc, freshness, "reason", reasons[0]); + yyjson_mut_val *reasons_val = yyjson_mut_arr(doc); + for (int i = 0; i < n_reasons; i++) + yyjson_mut_arr_add_str(doc, reasons_val, reasons[i]); + yyjson_mut_obj_add_val(doc, freshness, "reasons", reasons_val); + yyjson_mut_obj_add_str(doc, freshness, "recommended_action", recommended_action); + yyjson_mut_obj_add_val(doc, root, "freshness", freshness); + + if (have_meta) + cbm_store_coverage_meta_clear(&meta); + cbm_git_worktree_status_free(&st); + cbm_git_context_free(&ctx); +} + static cbm_operation_result_t execute_status(const char *args_json, const cbm_operation_runtime_t *runtime) { char *project = json_string_arg(args_json, "project"); @@ -638,6 +760,7 @@ static cbm_operation_result_t execute_status(const char *args_json, yyjson_mut_obj_add_val(doc, root, "git", git_obj); } cbm_git_context_free(&git); + add_index_freshness_json(doc, root, store, project, root_path, info.indexed_at); } } add_status_coverage(doc, root, store, project, have_info ? info.indexed_at : NULL, diff --git a/src/operations/tool_catalog.c b/src/operations/tool_catalog.c index 5daf470693..b81125badd 100644 --- a/src/operations/tool_catalog.c +++ b/src/operations/tool_catalog.c @@ -389,8 +389,9 @@ static const tool_def_t TOOLS[] = { "not, why.", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}," "\"verbose\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Include the git " - "context block (worktree/shadow path variants). Only needed when debugging where an index " - "lives — omitted by default to keep the status lean.\"}," + "context block (worktree/shadow path variants) and a report-only freshness verdict " + "(current/stale/unknown against the indexed checkout and worktree status). Only needed " + "when debugging where an index lives — omitted by default to keep the status lean.\"}," "\"diagnostics\":{\"type\":\"string\",\"enum\":[\"none\",\"summary\",\"full\"]," "\"default\":\"none\",\"description\":\"Coverage detail: none = counts only; summary = a few " "sample paths per class; full = up to 500 paths per class.\"}},\"required\":[" diff --git a/tests/test_incremental.c b/tests/test_incremental.c index 77447016ab..4b9b8482c3 100644 --- a/tests/test_incremental.c +++ b/tests/test_incremental.c @@ -15,6 +15,7 @@ #include "test_helpers.h" #include "test_operation_host.h" #include +#include #include #include #include @@ -3221,7 +3222,362 @@ TEST(tool_delete_and_verify) { * SUITE * ══════════════════════════════════════════════════════════════════ */ +/* ── index_status verbose freshness verdict ──────────────────────── + * Real git repos on disk and a real project DB in a private cache dir. The + * verdict is report-only: it compares the indexed checkout SHA recorded in the + * store with the live HEAD and the bounded worktree status. */ + +typedef struct { + char cache[512]; + char *saved_cache; + char repo[512]; +} fresh_fixture_t; + +static int fresh_git_run(const char *dir, const char *args) { + char cmd[1024]; + snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>/dev/null", dir, args); + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return -1; + } + char drain[256]; + while (fgets(drain, sizeof(drain), fp)) {} + return cbm_pclose(fp); +} + +static int fresh_make_git_repo(const char *dir) { + if (th_mkdir_p(dir) != 0 || fresh_git_run(dir, "init -q") != 0 || + fresh_git_run(dir, "config user.email test@example.com") != 0 || + fresh_git_run(dir, "config user.name Test") != 0 || + th_write_file(TH_PATH(dir, ".keep"), "") != 0 || fresh_git_run(dir, "add -A") != 0 || + fresh_git_run(dir, "commit -qm init") != 0) { + return -1; + } + return 0; +} + +static int fresh_commit_tracked(const char *dir) { + if (th_write_file(TH_PATH(dir, "tracked.txt"), "v1") != 0 || + fresh_git_run(dir, "add -f -- tracked.txt") != 0 || + fresh_git_run(dir, "commit -q -m add-tracked") != 0) { + return -1; + } + return 0; +} + +/* Copies the live HEAD into buf; false when git or HEAD is unavailable. */ +static bool fresh_head_sha(const char *dir, char *buf, size_t bufsz) { + cbm_git_context_t ctx = {0}; + bool ok = cbm_git_context_resolve(dir, &ctx) == 0 && ctx.head_sha && ctx.head_sha[0]; + if (ok) { + snprintf(buf, bufsz, "%s", ctx.head_sha); + } + cbm_git_context_free(&ctx); + return ok; +} + +static void fresh_fixture_close(fresh_fixture_t *fx) { + if (fx->saved_cache) { + cbm_setenv("CBM_CACHE_DIR", fx->saved_cache, 1); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + free(fx->saved_cache); + fx->saved_cache = NULL; + if (fx->cache[0]) { + th_rmtree(fx->cache); + } + if (fx->repo[0]) { + th_rmtree(fx->repo); + } +} + +/* Private cache dir + empty repo dir (git-initialised when want_git) and a + * "fresh-project" DB rooted there recording indexed_checkout_sha = sha. */ +static bool fresh_fixture_open(fresh_fixture_t *fx, bool want_git) { + memset(fx, 0, sizeof(*fx)); + snprintf(fx->cache, sizeof(fx->cache), "%s/cbm-fresh-cache-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(fx->cache)) { + fx->cache[0] = '\0'; + return false; + } + const char *saved = getenv("CBM_CACHE_DIR"); + fx->saved_cache = saved ? strdup(saved) : NULL; + if (cbm_setenv("CBM_CACHE_DIR", fx->cache, 1) != 0) { + return false; + } + const char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) { + return false; + } + snprintf(fx->repo, sizeof(fx->repo), "%s", repo); + if (want_git && fresh_make_git_repo(fx->repo) != 0) { + return false; + } + return true; +} + +static bool fresh_seed_project(const fresh_fixture_t *fx, const char *sha) { + char db_path[640]; + snprintf(db_path, sizeof(db_path), "%s/fresh-project.db", fx->cache); + cbm_store_t *st = cbm_store_open_path(db_path); + if (!st) { + return false; + } + cbm_coverage_meta_t meta = { + .generation = "fresh-generation", + .index_mode = "fast", + .recorded_at = "2026-08-11T00:00:00Z", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true, + .indexed_checkout_sha = sha, + }; + bool ok = cbm_store_upsert_project(st, "fresh-project", fx->repo) == CBM_STORE_OK && + cbm_store_coverage_replace_ex(st, "fresh-project", NULL, 0, &meta) == CBM_STORE_OK; + cbm_store_close(st); + return ok; +} + +static char *fresh_status(bool verbose) { + cbm_test_operation_host_t *host = cbm_test_operation_host_new(NULL); + if (!host) { + return NULL; + } + char *r = + cbm_test_operation_execute(host, "index_status", + verbose ? "{\"project\":\"fresh-project\",\"verbose\":true}" + : "{\"project\":\"fresh-project\"}"); + cbm_test_operation_host_free(host); + return r; +} + +TEST(tool_index_status_fails_closed_without_indexed_checkout_identity) { + fresh_fixture_t fx; + if (!fresh_fixture_open(&fx, true) || !fresh_seed_project(&fx, NULL)) { + fresh_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *r = fresh_status(true); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\"freshness\"")); + ASSERT_NOT_NULL(strstr(r, "\"verdict\":\"unknown\"")); + ASSERT_NOT_NULL(strstr(r, "\"indexed_generation\"")); + ASSERT_NOT_NULL(strstr(r, "\"indexed_checkout_sha\":null")); + ASSERT_NOT_NULL(strstr(r, "indexed_checkout_unavailable")); + ASSERT_NOT_NULL(strstr(r, "\"recommended_action\":\"reindex_to_record_indexed_checkout\"")); + free(r); + fresh_fixture_close(&fx); + PASS(); +} + +TEST(tool_index_status_omits_freshness_by_default) { + fresh_fixture_t fx; + if (!fresh_fixture_open(&fx, true) || !fresh_seed_project(&fx, NULL)) { + fresh_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *r = fresh_status(false); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\"status\"")); + ASSERT_NULL(strstr(r, "\"freshness\"")); + ASSERT_NULL(strstr(r, "indexed_checkout_sha")); + ASSERT_NULL(strstr(r, "status_available")); + ASSERT_NULL(strstr(r, "tracked_changes")); + ASSERT_NULL(strstr(r, "untracked_source")); + free(r); + fresh_fixture_close(&fx); + PASS(); +} + +TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches) { + fresh_fixture_t fx; + char head[128]; + if (!fresh_fixture_open(&fx, true)) { + fresh_fixture_close(&fx); + SKIP_PLATFORM("git not available to init a repo"); + } + if (!fresh_head_sha(fx.repo, head, sizeof(head))) { + fresh_fixture_close(&fx); + SKIP_PLATFORM("git head not resolvable"); + } + if (!fresh_seed_project(&fx, head)) { + fresh_fixture_close(&fx); + FAIL("seed failed"); + } + char *r = fresh_status(true); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\"verdict\":\"current\"")); + ASSERT_NOT_NULL(strstr(r, "indexed_checkout_current")); + ASSERT_NOT_NULL(strstr(r, "\"indexed_checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(r, "\"checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(r, "\"recommended_action\":\"use_graph\"")); + ASSERT_NOT_NULL(strstr(r, "\"status_available\":true")); + ASSERT_NOT_NULL(strstr(r, "\"tracked_changes\":{\"count\":0")); + ASSERT_NOT_NULL(strstr(r, "\"untracked_source\":{\"count\":0")); + free(r); + fresh_fixture_close(&fx); + PASS(); +} + +TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches) { + fresh_fixture_t fx; + if (!fresh_fixture_open(&fx, true)) { + fresh_fixture_close(&fx); + SKIP_PLATFORM("git not available to init a repo"); + } + if (!fresh_seed_project(&fx, "0000000000000000000000000000000000000000")) { + fresh_fixture_close(&fx); + FAIL("seed failed"); + } + char *r = fresh_status(true); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(r, "indexed_checkout_mismatch")); + ASSERT_NOT_NULL( + strstr(r, "\"indexed_checkout_sha\":\"0000000000000000000000000000000000000000\"")); + ASSERT_NOT_NULL(strstr(r, "\"recommended_action\":\"reindex_to_match_checkout\"")); + free(r); + fresh_fixture_close(&fx); + PASS(); +} + +TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes) { + fresh_fixture_t fx; + char head[128]; + if (!fresh_fixture_open(&fx, true)) { + fresh_fixture_close(&fx); + SKIP_PLATFORM("git not available to init a repo"); + } + if (fresh_commit_tracked(fx.repo) != 0 || !fresh_head_sha(fx.repo, head, sizeof(head)) || + th_write_file(TH_PATH(fx.repo, "tracked.txt"), "v2") != 0 || + !fresh_seed_project(&fx, head)) { + fresh_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *r = fresh_status(true); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(r, "tracked_changes_present")); + ASSERT_NOT_NULL(strstr(r, "\"tracked_changes\":{\"count\":1,\"paths\":[\"tracked.txt\"]," + "\"truncated\":false}")); + ASSERT_NULL(strstr(r, "indexed_checkout_current")); + free(r); + fresh_fixture_close(&fx); + PASS(); +} + +TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only) { + fresh_fixture_t fx; + char head[128]; + if (!fresh_fixture_open(&fx, true)) { + fresh_fixture_close(&fx); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(fx.repo, "newfile.txt"), "n") != 0 || + !fresh_head_sha(fx.repo, head, sizeof(head)) || !fresh_seed_project(&fx, head)) { + fresh_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *r = fresh_status(true); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\"verdict\":\"unknown\"")); + ASSERT_NOT_NULL(strstr(r, "untracked_not_indexed")); + ASSERT_NOT_NULL(strstr(r, "\"untracked_source\":{\"count\":1,\"paths\":[\"newfile.txt\"]")); + ASSERT_NULL(strstr(r, "indexed_checkout_current")); + free(r); + fresh_fixture_close(&fx); + PASS(); +} + +TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked) { + fresh_fixture_t fx; + char head[128]; + if (!fresh_fixture_open(&fx, true)) { + fresh_fixture_close(&fx); + SKIP_PLATFORM("git not available to init a repo"); + } + if (fresh_commit_tracked(fx.repo) != 0 || !fresh_head_sha(fx.repo, head, sizeof(head)) || + th_write_file(TH_PATH(fx.repo, "tracked.txt"), "v2") != 0 || + th_write_file(TH_PATH(fx.repo, "untracked.txt"), "n") != 0 || + !fresh_seed_project(&fx, head)) { + fresh_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *r = fresh_status(true); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(r, "tracked_changes_present")); + ASSERT_NOT_NULL(strstr(r, "untracked_not_indexed")); + free(r); + fresh_fixture_close(&fx); + PASS(); +} + +TEST(tool_index_status_freshness_status_unavailable_non_git) { + fresh_fixture_t fx; + if (!fresh_fixture_open(&fx, false) || + !fresh_seed_project(&fx, "0123456789012345678901234567890123456789")) { + fresh_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *r = fresh_status(true); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\"status_available\":false")); + ASSERT_NOT_NULL(strstr(r, "status_unavailable")); + ASSERT_NOT_NULL(strstr(r, "\"verdict\":\"unknown\"")); + ASSERT_NULL(strstr(r, "indexed_checkout_current")); + ASSERT_NOT_NULL(strstr(r, "\"tracked_changes\":{\"count\":0")); + ASSERT_NOT_NULL(strstr(r, "\"untracked_source\":{\"count\":0")); + free(r); + fresh_fixture_close(&fx); + PASS(); +} + +TEST(tool_index_status_freshness_sample_cap_truncates) { + fresh_fixture_t fx; + char head[128]; + if (!fresh_fixture_open(&fx, true)) { + fresh_fixture_close(&fx); + SKIP_PLATFORM("git not available to init a repo"); + } + for (int i = 0; i < 17; i++) { + char path[1024]; + snprintf(path, sizeof(path), "%s/u%02d.txt", fx.repo, i); + if (th_write_file(path, "u") != 0) { + fresh_fixture_close(&fx); + FAIL("failed to create untracked file"); + } + } + if (!fresh_head_sha(fx.repo, head, sizeof(head)) || !fresh_seed_project(&fx, head)) { + fresh_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *r = fresh_status(true); + ASSERT_NOT_NULL(r); + static const char EXPECTED[] = + "\"untracked_source\":{\"count\":17,\"paths\":[\"u00.txt\",\"u01.txt\",\"u02.txt\"," + "\"u03.txt\",\"u04.txt\",\"u05.txt\",\"u06.txt\",\"u07.txt\",\"u08.txt\",\"u09.txt\"," + "\"u10.txt\",\"u11.txt\",\"u12.txt\",\"u13.txt\",\"u14.txt\",\"u15.txt\"]," + "\"truncated\":true}"; + ASSERT_NOT_NULL(strstr(r, EXPECTED)); + free(r); + fresh_fixture_close(&fx); + PASS(); +} + SUITE(incremental) { + /* Self-contained freshness tests: private cache + repos, no fixture clone. */ + RUN_TEST(tool_index_status_fails_closed_without_indexed_checkout_identity); + RUN_TEST(tool_index_status_omits_freshness_by_default); + RUN_TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes); + RUN_TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked); + RUN_TEST(tool_index_status_freshness_status_unavailable_non_git); + RUN_TEST(tool_index_status_freshness_sample_cap_truncates); + if (incremental_setup() != 0) { printf(" SETUP FAILED — skipping incremental tests (network?)\n"); return; From 0dccc267b9c8af5f1d233ff7cd58390584378a09 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sun, 8 Nov 2026 19:28:56 -0800 Subject: [PATCH 63/65] Port upstream #1430 ignore_worktrees to the CLI: hook gate and explicit index refusal hook_augment: when ignore_worktrees is on in an existing _config.db (never created by the hook), canonicalize cwd and resolve the project through ha_resolve_project_ignoring_worktrees, dropping a parent graph that does not belong to the cwd's own linked worktree and switching to the 'not indexed on purpose' guidance. Key off (default) takes the previous path and the test gate counter does not move. index operation: an explicit index of a linked git worktree is refused when ignore_worktrees is on, with an error naming --index-worktree (per-call index_worktree arg, added to the tool schema) and 'config set ignore_worktrees false'. No silent skip. docs/CONFIGURATION.md: settings row, config example, CLI wording for the ignore_worktrees section, --index-worktree mention. tests: refusal/override/off/main-checkout integration test with a real git worktree, --index-worktree arg parsing test, and hook assertions updated for the CLI guidance text (codebase-memory-cli index). --- docs/CONFIGURATION.md | 17 +++--- src/cli/hook_augment.c | 57 +++++++++++++++++- src/operations/index.c | 25 +++++++- src/operations/tool_catalog.c | 3 + tests/test_cli.c | 107 ++++++++++++++++++++++++++++++---- 5 files changed, 189 insertions(+), 20 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a5abf7e220..f87557cc3b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -78,13 +78,15 @@ codebase-memory-cli config get auto_index codebase-memory-cli config set auto_index true codebase-memory-cli config set auto_index_limit 50000 codebase-memory-cli config set watcher_enabled false +codebase-memory-cli config set ignore_worktrees true codebase-memory-cli config set doc_candidates false codebase-memory-cli config reset doc_candidates ``` `doc_candidates` is `auto` by default: unset, the index mode decides. A single `codebase-memory-cli index --doc-candidates true|false` also overrides it for -that index only. +that index only. With `ignore_worktrees` on, `codebase-memory-cli index +--index-worktree` indexes a linked git worktree for that one call. Current keys: @@ -94,6 +96,7 @@ Current keys: | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | | `auto_watch` | `true` | Register an active project with the retained background watcher when a warm runtime/hook integration supplies project context. A daemon-started watcher can retain registered projects after the initiating session ends until daemon shutdown, project deletion/root disappearance, or this setting is disabled. The ordinary one-shot CLI does not require this. | | `doc_candidates` | `auto` | Score documentation sections against code to store doc<->code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections x functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `codebase-memory-cli index --doc-candidates true|false` sets it for a single index and wins over this setting. Applies from the next index. | +| `ignore_worktrees` | `false` | Skip linked git worktrees when indexing automatically, and refuse an explicit `codebase-memory-cli index` of one unless `--index-worktree` is passed. See [`ignore_worktrees`](#ignore_worktrees). | | `watch_non_git` | `false` | Poll non-Git roots with the indexer discovery rules and an adaptive file-tree scan. Skipped paths, including `.codebase-memory/`, do not trigger indexing. The first poll after daemon startup reindexes once; later changes trigger one reindex. This walks the whole tree each poll and is read when the daemon starts. | | `watcher_enabled` | `true` | Master switch for the retained background watcher subsystem. Set `false` to stop its poll thread/project registration. Reindex manually with `codebase-memory-cli index` when disabled. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | @@ -128,24 +131,24 @@ 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 +codebase-memory-cli 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. + of telling the agent to run `codebase-memory-cli index`; +- an explicit `codebase-memory-cli index` of a linked worktree is refused with an + error that names both ways forward: pass `--index-worktree` for that one call, + or run `codebase-memory-cli config set ignore_worktrees false`. 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 +given path to the nearest `.git` entry, so `codebase-memory-cli index` 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 diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index ec2b8bee97..123211a994 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -23,6 +23,8 @@ #include "foundation/constants.h" #include "foundation/mem.h" #include "foundation/platform.h" +#include "foundation/str_util.h" +#include "git/git_context.h" #include "pipeline/pipeline.h" #include "yyjson/yyjson.h" @@ -1715,6 +1717,53 @@ static bool ha_invocation_supported(ha_lifecycle_dialect_t dialect, const char * static CBM_TLS int *g_ha_worktree_gate_counter = NULL; #endif +/* Reads ignore_worktrees from the runtime config store only when the store + * already exists: the hook must not create _config.db as a side effect, and + * with the key off (the default) it does nothing else for worktrees. */ +static bool ha_ignore_worktrees_enabled(void) { + const char *cache_dir = cbm_resolve_cache_dir(); + if (!cache_dir || !cache_dir[0]) { + return false; + } + char db_path[4096]; + int written = snprintf(db_path, sizeof(db_path), "%s/" CBM_CONFIG_DB_FILENAME, cache_dir); + if (written <= 0 || (size_t)written >= sizeof(db_path) || !cbm_file_exists(db_path)) { + return false; + } + cbm_config_t *cfg = cbm_config_open(cache_dir); + bool enabled = cbm_config_get_bool(cfg, CBM_CONFIG_IGNORE_WORKTREES, false); + cbm_config_close(cfg); + return enabled; +} + +/* Project resolution once ignore_worktrees is on; the caller runs this only + * after reading the key. A symlink alias has no .git of its own, so cwd is + * canonicalized as the index operation does before the same gate. A linked + * worktree is refused even inside an indexed checkout or 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(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(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(const char *session_root, yyjson_val *root, const char *forced_event, ha_lifecycle_dialect_t dialect) { if (!root || !yyjson_is_obj(root)) { @@ -1728,10 +1777,14 @@ static char *ha_lifecycle_json_from_root(const char *session_root, yyjson_val *r char cwd_buffer[4096]; const char *cwd = ha_normalized_cwd_with_root(root, session_root, cwd_buffer, sizeof(cwd_buffer)); - char *project = cwd ? ha_resolve_indexed_project(cwd) : NULL; - /* Upstream #1430 worktree gate; set by the ignore_worktrees resolution. */ bool drop_ancestor = false; bool worktree_ignored = false; + char *project = NULL; + if (cwd && ha_ignore_worktrees_enabled()) { + project = ha_resolve_project_ignoring_worktrees(cwd, &drop_ancestor, &worktree_ignored); + } else if (cwd) { + project = ha_resolve_indexed_project(cwd); + } char context[2048]; const char *scope = "Session"; diff --git a/src/operations/index.c b/src/operations/index.c index 03ed58678a..cdc3ef304c 100644 --- a/src/operations/index.c +++ b/src/operations/index.c @@ -18,6 +18,7 @@ #include "foundation/platform.h" #include "foundation/str_util.h" #include "foundation/workspace.h" +#include "git/git_context.h" #include "pipeline/artifact.h" #include "pipeline/pipeline.h" #include "store/store.h" @@ -1217,6 +1218,27 @@ static bool index_call_mode_arg(const char *json, const char *key, bool *out) { return valid; } +/* ignore_worktrees: an explicit index of a linked worktree is refused, not + * silently skipped; index_worktree overrides it for this call. Fills error and + * returns true on refusal. Reads no config unless repo_path is a linked + * worktree. */ +static bool index_worktree_refused(const char *json, const char *repo_path, char *error, + size_t error_size) { + if (index_bool_arg(json, "index_worktree") || !cbm_git_is_linked_worktree(repo_path)) { + return false; + } + cbm_config_t *config = cbm_config_open(cbm_resolve_cache_dir()); + bool ignore_worktrees = cbm_config_get_bool(config, CBM_CONFIG_IGNORE_WORKTREES, false); + cbm_config_close(config); + if (ignore_worktrees) { + snprintf(error, error_size, + "repo_path is a linked git worktree and ignore_worktrees is enabled. Pass " + "--index-worktree to index it anyway, or run: codebase-memory-cli config set " + "ignore_worktrees false"); + } + return ignore_worktrees; +} + cbm_operation_result_t cbm_index_operation_execute(const char *args_json, const cbm_operation_runtime_t *runtime) { const char *json = args_json ? args_json : "{}"; @@ -1258,7 +1280,8 @@ cbm_operation_result_t cbm_index_operation_execute(const char *args_json, : getenv("CBM_ALLOWED_ROOT"); char boundary[CBM_SZ_1K]; if (!cbm_workspace_root_allowed(repo_path, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), - allowed_root, boundary, sizeof(boundary))) { + allowed_root, boundary, sizeof(boundary)) || + (!status_mode && index_worktree_refused(json, repo_path, boundary, sizeof(boundary)))) { free(repo_path); return index_text_error(boundary); } diff --git a/src/operations/tool_catalog.c b/src/operations/tool_catalog.c index b81125badd..89896199cf 100644 --- a/src/operations/tool_catalog.c +++ b/src/operations/tool_catalog.c @@ -44,6 +44,9 @@ static const tool_def_t TOOLS[] = { "\"doc_candidates\":{\"type\":\"boolean\",\"description\":" "\"Doc-code candidates; default: the doc_candidates setting, else on for full, off for " "moderate/fast.\"}," + "\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":" + "\"Index repo_path even when it is a linked git worktree and the ignore_worktrees setting " + "is on. No effect otherwise.\"}," "\"async\":{\"type\":\"boolean\",\"default\":false,\"description\":" "\"Start or join a background index. Requires a durable daemon (run daemon start). Poll with " "--status.\"}," diff --git a/tests/test_cli.c b/tests/test_cli.c index 3a967e8ea9..af41e2ecfb 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -12,6 +12,7 @@ #include "../src/foundation/compat_thread.h" #include "test_framework.h" #include "operations/tool_catalog.h" +#include "operations/index.h" #include "test_helpers.h" #include #include @@ -10119,7 +10120,7 @@ TEST(cli_hook_worktree_cwd_resolves_own_indexed_project) { /* Completes the payload-cwd matrix (with the indexed-worktree test above and the * deadline/logging contract in cli_hook_augment_deadline_breadcrumb_issue858): a * linked worktree that was never indexed must yield a deliberate, non-empty "no - * project matched — run index_repository" notice, never a silent 0-byte reply + * project matched — run codebase-memory-cli index" notice, never a silent 0-byte reply * that an agent cannot distinguish from "no matches". */ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { char tmpdir[256]; @@ -10163,7 +10164,7 @@ 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, "Run index_repository") && + strstr(wt_out, "Run `codebase-memory-cli index") && !strstr(wt_out, "ignore_worktrees is enabled") && !strstr(wt_out, "is indexed"); free(wt_out); @@ -10175,7 +10176,7 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { 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, "do not run `codebase-memory-cli index") && !strstr(ignored_out, "is indexed"); free(ignored_out); @@ -10190,8 +10191,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { 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"); + strstr(alias_out, "do not run `codebase-memory-cli index") && + !strstr(alias_out, "Run `codebase-memory-cli index"); free(alias_out); /* A linked worktree nested inside the indexed checkout must not inherit @@ -10208,7 +10209,7 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { 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, "do not run `codebase-memory-cli index") && !strstr(inside_out, "is indexed") && !strstr(inside_out, main_name); free(inside_out); @@ -10244,7 +10245,7 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { 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, "do not run `codebase-memory-cli index") && !strstr(child_out, "is indexed") && !strstr(child_out, inside_name); free(child_out); @@ -10267,7 +10268,7 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { 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"); + FAIL("a symlink into an ignored worktree must not recommend indexing"); if (!inside_ignored) FAIL("a nested linked worktree must not inherit the indexed parent graph"); if (!exact_kept) @@ -10322,7 +10323,7 @@ TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work) { 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") && + bool off_note = off_out && strstr(off_out, "Run `codebase-memory-cli index") && !strstr(off_out, "ignore_worktrees is enabled"); free(off_out); bool store_absent_after = stat(store_path, &st) != 0; @@ -10334,7 +10335,7 @@ TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work) { 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") && + bool unset_note = unset_out && strstr(unset_out, "Run `codebase-memory-cli index") && !strstr(unset_out, "ignore_worktrees is enabled"); free(unset_out); int unset_gate_calls = gate_calls; @@ -15984,6 +15985,17 @@ TEST(cli_build_args_json_key_equals_value_issue680) { PASS(); } +/* --index-worktree reaches the index operation as the index_worktree boolean. */ +TEST(cli_build_args_json_index_worktree_flag) { + char *err = NULL; + char *argv[] = {"--repo-path=/a", "--index-worktree"}; + char *json = cbm_cli_build_args_json("index_repository", 2, argv, &err); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"index_worktree\":true") != NULL); + free(json); + PASS(); +} + /* A non-`--` positional is an error: returns NULL and sets *err_out. */ TEST(cli_build_args_json_bad_positional_errors_issue680) { char *err = NULL; @@ -16415,6 +16427,79 @@ TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558) { PASS(); } +/* Explicit `index` of a linked worktree with ignore_worktrees on is refused + * with both ways forward; the per-call index_worktree override and the key + * being off both get past the gate. Real git worktree and config store. */ +TEST(cli_index_ignore_worktrees_refuses_explicit_linked_worktree) { + char tmpdir[256]; + char *raw = th_mktempdir("cbm_index_wt_gate"); + if (!raw) + FAIL("th_mktempdir returned NULL"); + snprintf(tmpdir, sizeof(tmpdir), "%s", raw); + char cache[512]; + char maindir[512]; + char wtdir[512]; + snprintf(cache, sizeof(cache), "%s/cache", tmpdir); + snprintf(maindir, sizeof(maindir), "%s/checkout-main", tmpdir); + snprintf(wtdir, sizeof(wtdir), "%s/checkout-side", tmpdir); + 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 wtidx", + 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 args[1200]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", wtdir); + char args_override[1300]; + snprintf(args_override, sizeof(args_override), "{\"repo_path\":\"%s\",\"index_worktree\":true}", + wtdir); + + /* Key off: no refusal. */ + cbm_operation_result_t off = cbm_index_operation_execute(args, NULL); + bool off_not_refused = !off.payload || !strstr(off.payload, "ignore_worktrees"); + + cbm_config_t *cfg = cbm_config_open(cache); + bool enabled = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(cfg); + + cbm_operation_result_t on = cbm_index_operation_execute(args, NULL); + bool refused = enabled && on.is_error && on.payload && strstr(on.payload, "--index-worktree") && + strstr(on.payload, "config set ignore_worktrees false"); + cbm_operation_result_t over = cbm_index_operation_execute(args_override, NULL); + bool override_not_refused = !over.payload || !strstr(over.payload, "ignore_worktrees"); + /* The main checkout is never refused. */ + char main_args[1200]; + snprintf(main_args, sizeof(main_args), "{\"repo_path\":\"%s\"}", maindir); + cbm_operation_result_t main_res = cbm_index_operation_execute(main_args, NULL); + bool main_not_refused = !main_res.payload || !strstr(main_res.payload, "ignore_worktrees"); + + cbm_operation_result_dispose(&off); + cbm_operation_result_dispose(&on); + cbm_operation_result_dispose(&over); + cbm_operation_result_dispose(&main_res); + 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(off_not_refused); + ASSERT_TRUE(refused); + ASSERT_TRUE(override_not_refused); + ASSERT_TRUE(main_not_refused); + 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++) { @@ -17391,6 +17476,7 @@ SUITE(cli) { 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_index_ignore_worktrees_refuses_explicit_linked_worktree); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); RUN_TEST(cli_install_hooks_uses_managed_binary_path); @@ -17749,6 +17835,7 @@ SUITE(cli) { RUN_TEST(cli_build_args_json_array_literal_repeated); RUN_TEST(cli_build_args_json_kebab_to_snake_issue680); RUN_TEST(cli_build_args_json_key_equals_value_issue680); + RUN_TEST(cli_build_args_json_index_worktree_flag); RUN_TEST(cli_build_args_json_bad_positional_errors_issue680); RUN_TEST(cli_print_tool_help_issue680); RUN_TEST(cli_usage_points_to_tool_format_json_issue2102); From 3cbefe21083ba48627553a205303e30f999591d8 Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sun, 8 Nov 2026 19:29:24 -0800 Subject: [PATCH 64/65] fix(status): emit verbose git object when git context resolves cbm_git_context_resolve returns 0 on success, but execute_status treated its result as a boolean, so status --verbose only entered the git branch when resolution failed (and then is_git was unset). The git object was therefore never emitted. Compare against 0 like the other callers (http_server.c, pipeline.c). Found while porting upstream #1561. --- src/operations/read.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/operations/read.c b/src/operations/read.c index b21d5fd597..ca279549c8 100644 --- a/src/operations/read.c +++ b/src/operations/read.c @@ -751,7 +751,7 @@ static cbm_operation_result_t execute_status(const char *args_json, yyjson_mut_obj_add_strcpy(doc, root, "indexed_at", info.indexed_at ? info.indexed_at : ""); if (verbose && root_path[0]) { cbm_git_context_t git = {0}; - if (cbm_git_context_resolve(root_path, &git) && git.is_git) { + if (cbm_git_context_resolve(root_path, &git) == 0 && git.is_git) { yyjson_mut_val *git_obj = yyjson_mut_obj(doc); if (git.branch) yyjson_mut_obj_add_strcpy(doc, git_obj, "branch", git.branch); From c3c5271ab8f454287452f0df5eca9e38ffd250cf Mon Sep 17 00:00:00 2001 From: ngallodev Date: Sun, 8 Nov 2026 20:38:22 -0800 Subject: [PATCH 65/65] test: give the index policy fixture distinct repo and cache roots index_policy_mcp_rejects_forged_override_and_preserves_serving_index called th_mktempdir twice and kept both returned pointers, but th_mktempdir returns a shared static buffer. After the second call `repo` and `cache` were the same string (the cache directory), so the first index_repository request targeted the cache root and was refused by the workspace guard: /tmp/cbm_index_policy_mcp_cache_LjrHdR: path is a volume root or holds the codebase-memory cache; it cannot be indexed. That guard is intentional (fork commit 19f5bdfa8, "fix workspace cache root authorization"), which landed on main after the last green full Jenkins run (#88 at 214b87b84). The failure therefore pre-exists on main cf63fe667 and surfaced in release-tooling build #89; it is not caused by the upstream 13M-1 merge. Copy each path into its own buffer so the fixture's repository and cache are separate sibling directories. Product behaviour is unchanged. Root cause found by a Codex gpt-6-luna agent; verified by rerunning the index_policy and index_supervisor suites. --- tests/test_index_policy.c | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_index_policy.c b/tests/test_index_policy.c index b7c7649a56..c323d4c6a3 100644 --- a/tests/test_index_policy.c +++ b/tests/test_index_policy.c @@ -287,10 +287,16 @@ TEST(index_policy_worker_rejects_missing_parent_policy) { } TEST(index_policy_mcp_rejects_forged_override_and_preserves_serving_index) { - char *repo = th_mktempdir("cbm_index_policy_mcp_repo"); - char *cache = th_mktempdir("cbm_index_policy_mcp_cache"); - ASSERT_NOT_NULL(repo); - ASSERT_NOT_NULL(cache); + char repo_path[1024]; + char *created = th_mktempdir("cbm_index_policy_mcp_repo"); + ASSERT_NOT_NULL(created); + (void)snprintf(repo_path, sizeof(repo_path), "%s", created); + created = th_mktempdir("cbm_index_policy_mcp_cache"); + ASSERT_NOT_NULL(created); + char cache_path[1024]; + (void)snprintf(cache_path, sizeof(cache_path), "%s", created); + const char *repo = repo_path; + const char *cache = cache_path; ASSERT_EQ(th_write_file(TH_PATH(repo, "first.c"), "int first(void) { return 1; }\n"), 0); const char *saved_cache = getenv("CBM_CACHE_DIR");