From e9f87efe6abff44a7130fa3db9a9a409d22fd556 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Tue, 18 Aug 2026 16:13:14 +0800 Subject: [PATCH 01/61] feat(lsp): add the Ruby per-file resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-process, type-aware call resolution for single Ruby files, mirroring the perl_lsp.c / php_lsp.c shape and dispatched from cbm_extract_file: - PASS 1 collects classes and modules with lexical nesting, superclasses, and include / prepend / extend mixins, plus method tables with instance and singleton methods classified from the AST. Singleton methods key on ".self", which cannot collide with a constant path. - PASS 1.5 infers instance-variable types from `@x = Const.new`; conflicting assignments latch to no type rather than guessing. - PASS 2 resolves constructors (`Const.new` targets the class node, the way Python's lsp_constructor does), implicit and explicit self dispatch, singleton methods, superclass chains, mixin lookup in Ruby's method-resolution order, super, and ivar / local / chained receivers. The stdlib seed covers Ruby core plus a curated Rails surface, including ActiveRecord query typing: `User.find(1)` is a User, and relation chains keep the model type. Dynamic dispatch (send, method_missing, define_method) and unresolved receivers emit no edge, and only project-defined targets are emitted. Cross-file resolution is a separate tier and is not part of this change; the 18 rows here drive the resolver through cbm_extract_file exactly as the extraction pipeline calls it. PROVENANCE: this resolver was authored clean-room from the tree-sitter-ruby grammar and Ruby language semantics. No Ruby language server source was consulted. scripts/check-lsp-originality.sh gains Shopify's ruby-lsp (MIT) as the Ruby reference so the boundary is auditable alongside the generated data it guards; `check-lsp-originality.sh --lang ruby` reports CLEAN — no verbatim string or comment overlap and no structural clones. Refs #1701 Assisted-by: OpenCode:anthropic/claude-opus-5 Signed-off-by: Ben Fairless --- Makefile.cbm | 3 +- internal/cbm/cbm.c | 4 + internal/cbm/lsp/generated/ruby_stdlib_data.c | 461 ++++++ internal/cbm/lsp/ruby_lsp.c | 1261 +++++++++++++++++ internal/cbm/lsp/ruby_lsp.h | 148 ++ internal/cbm/lsp_all.c | 2 + scripts/check-lsp-originality.sh | 6 +- tests/test_main.c | 2 + tests/test_ruby_lsp.c | 636 +++++++++ 9 files changed, 2521 insertions(+), 2 deletions(-) create mode 100644 internal/cbm/lsp/generated/ruby_stdlib_data.c create mode 100644 internal/cbm/lsp/ruby_lsp.c create mode 100644 internal/cbm/lsp/ruby_lsp.h create mode 100644 tests/test_ruby_lsp.c diff --git a/Makefile.cbm b/Makefile.cbm index bf51a5a8c..9e5564cc6 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -579,6 +579,7 @@ TEST_CS_LSP_SRCS = tests/test_cs_lsp.c TEST_CS_LSP_BENCH_SRCS = tests/test_cs_lsp_bench.c TEST_PERL_LSP_SRCS = tests/test_perl_lsp.c +TEST_RUBY_LSP_SRCS = tests/test_ruby_lsp.c TEST_SCOPE_SRCS = tests/test_scope.c @@ -701,7 +702,7 @@ TEST_REPRO_SRCS = \ tests/repro/repro_lsp_java_cs.c \ tests/repro/repro_lsp_kt_php_rust.c -ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) +ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_RUBY_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) # ── Build directories ──────────────────────────────────────────── diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index d7468af5a..edf1b63d7 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -8,6 +8,7 @@ #include "lsp/php_lsp.h" #include "lsp/perl_lsp.h" #include "lsp/py_lsp.h" +#include "lsp/ruby_lsp.h" #include "lsp/ts_lsp.h" #include "lsp/cs_lsp.h" #include "lsp/java_lsp.h" @@ -1320,6 +1321,9 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua if (language == CBM_LANG_PERL) { cbm_run_perl_lsp(a, result, source, source_len, root); } + if (language == CBM_LANG_RUBY) { + cbm_run_ruby_lsp(a, result, source, source_len, root); + } if (language == CBM_LANG_PYTHON) { cbm_run_py_lsp(a, result, source, source_len, root); } diff --git a/internal/cbm/lsp/generated/ruby_stdlib_data.c b/internal/cbm/lsp/generated/ruby_stdlib_data.c new file mode 100644 index 000000000..83e853e65 --- /dev/null +++ b/internal/cbm/lsp/generated/ruby_stdlib_data.c @@ -0,0 +1,461 @@ +/* + * ruby_stdlib_data.c — hand-written Ruby core + curated Rails type data. + * + * Strategy mirrors perl_stdlib_data.c / php_stdlib_data.c: + * 1. Ruby core classes (String, Array, Hash, Integer, ...) registered as + * types with receiver-keyed instance methods whose return types enable + * call-chain typing ("a,b".split(",").first). Method QNs are bare + * ("String.upcase") — no project prefix — so they can never collide + * with an indexed def; they exist for TYPING, not for edge emission + * (the resolver only emits edges to project-defined methods). + * 2. Kernel built-ins (puts, require, raise, ...) as global functions so + * bare calls to them type-check without producing spurious edges. + * 3. A curated Rails surface: ActiveRecord::Base class-side query methods + * and instance persistence methods, ActionController::Base helpers, + * and ActiveSupport-style predicates on Object. Registered under + * dotted QNs ("ActiveRecord.Base") to match ruby path dotting + * (Foo::Bar -> Foo.Bar). The resolver special-cases ActiveRecord + * query methods to return the *receiving model's* type (User.find -> + * User), which these generic entries cannot express. + * + * Return types are left UNKNOWN where the real type is polymorphic; the + * seed only needs to be right where chaining matters. + */ + +#include "../type_rep.h" +#include "../type_registry.h" +#include "../../arena.h" +#include "../ruby_lsp.h" +#include + +#define MIXED cbm_type_unknown() + +/* Register a type by bare dotted QN (e.g. "String", "ActiveRecord.Base"). */ +#define REG_TYPE(qn_) \ + do { \ + CBMRegisteredType rt; \ + memset(&rt, 0, sizeof(rt)); \ + rt.qualified_name = (qn_); \ + rt.short_name = (qn_); \ + cbm_registry_add_type(reg, rt); \ + } while (0) + +/* Register an instance method on receiver type `recv_` returning `ret_`. */ +#define REG_METHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + rf.short_name = (name_); \ + rf.receiver_type = (recv_); \ + { \ + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ + rets[0] = (ret_); \ + rets[1] = NULL; \ + rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ + } \ + cbm_registry_add_func(reg, rf); \ + } while (0) + +/* Register a singleton (class-side) method: receiver key ".self" + * matches ruby_lookup_singleton_method's keying convention. */ +#define REG_SMETHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + rf.short_name = (name_); \ + rf.receiver_type = cbm_arena_sprintf(arena, "%s.self", (recv_)); \ + { \ + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ + rets[0] = (ret_); \ + rets[1] = NULL; \ + rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ + } \ + cbm_registry_add_func(reg, rf); \ + } while (0) + +/* Register a global (Kernel) built-in function returning `ret_`. */ +#define REG_BUILTIN(name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = (name_); \ + rf.short_name = (name_); \ + { \ + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ + rets[0] = (ret_); \ + rets[1] = NULL; \ + rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ + } \ + cbm_registry_add_func(reg, rf); \ + } while (0) + +void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena) { + CBMRegisteredFunc rf; + + const CBMType *T_STRING = cbm_type_named(arena, "String"); + const CBMType *T_ARRAY = cbm_type_named(arena, "Array"); + const CBMType *T_HASH = cbm_type_named(arena, "Hash"); + const CBMType *T_INT = cbm_type_named(arena, "Integer"); + const CBMType *T_FLOAT = cbm_type_named(arena, "Float"); + const CBMType *T_SYM = cbm_type_named(arena, "Symbol"); + const CBMType *T_BOOL = cbm_type_builtin(arena, "bool"); + + /* ── Core class constants (resolvable as superclasses / receivers) ── */ + static const char *core_types[] = {"Object", + "BasicObject", + "String", + "Array", + "Hash", + "Integer", + "Float", + "Numeric", + "Symbol", + "Range", + "Regexp", + "Time", + "Date", + "DateTime", + "File", + "IO", + "Dir", + "Struct", + "OpenStruct", + "StringIO", + "Set", + "Proc", + "Method", + "Thread", + "Mutex", + "Queue", + "Rational", + "Complex", + "NilClass", + "TrueClass", + "FalseClass", + "Exception", + "StandardError", + "RuntimeError", + "ArgumentError", + "TypeError", + "NameError", + "NoMethodError", + "IOError", + "KeyError", + "IndexError", + "RangeError", + "NotImplementedError", + "StopIteration", + "Comparable", + "Enumerable", + "Kernel", + "Math", + "JSON", + "ERB", + "Logger", + "Enumerator", + NULL}; + for (int i = 0; core_types[i]; i++) + REG_TYPE(core_types[i]); + + /* ── String ── */ + REG_METHOD("String", "upcase", T_STRING); + REG_METHOD("String", "downcase", T_STRING); + REG_METHOD("String", "capitalize", T_STRING); + REG_METHOD("String", "strip", T_STRING); + REG_METHOD("String", "chomp", T_STRING); + REG_METHOD("String", "gsub", T_STRING); + REG_METHOD("String", "sub", T_STRING); + REG_METHOD("String", "split", T_ARRAY); + REG_METHOD("String", "chars", T_ARRAY); + REG_METHOD("String", "lines", T_ARRAY); + REG_METHOD("String", "bytes", T_ARRAY); + REG_METHOD("String", "to_s", T_STRING); + REG_METHOD("String", "to_str", T_STRING); + REG_METHOD("String", "to_i", T_INT); + REG_METHOD("String", "to_f", T_FLOAT); + REG_METHOD("String", "to_sym", T_SYM); + REG_METHOD("String", "length", T_INT); + REG_METHOD("String", "size", T_INT); + REG_METHOD("String", "reverse", T_STRING); + REG_METHOD("String", "include?", T_BOOL); + REG_METHOD("String", "start_with?", T_BOOL); + REG_METHOD("String", "end_with?", T_BOOL); + REG_METHOD("String", "empty?", T_BOOL); + REG_METHOD("String", "match?", T_BOOL); + REG_METHOD("String", "freeze", T_STRING); + REG_METHOD("String", "dup", T_STRING); + + /* ── Array ── */ + REG_METHOD("Array", "map", T_ARRAY); + REG_METHOD("Array", "flat_map", T_ARRAY); + REG_METHOD("Array", "select", T_ARRAY); + REG_METHOD("Array", "filter", T_ARRAY); + REG_METHOD("Array", "reject", T_ARRAY); + REG_METHOD("Array", "sort", T_ARRAY); + REG_METHOD("Array", "sort_by", T_ARRAY); + REG_METHOD("Array", "uniq", T_ARRAY); + REG_METHOD("Array", "compact", T_ARRAY); + REG_METHOD("Array", "flatten", T_ARRAY); + REG_METHOD("Array", "reverse", T_ARRAY); + REG_METHOD("Array", "concat", T_ARRAY); + REG_METHOD("Array", "push", T_ARRAY); + REG_METHOD("Array", "join", T_STRING); + REG_METHOD("Array", "first", MIXED); + REG_METHOD("Array", "last", MIXED); + REG_METHOD("Array", "sample", MIXED); + REG_METHOD("Array", "length", T_INT); + REG_METHOD("Array", "size", T_INT); + REG_METHOD("Array", "count", T_INT); + REG_METHOD("Array", "sum", MIXED); + REG_METHOD("Array", "min", MIXED); + REG_METHOD("Array", "max", MIXED); + REG_METHOD("Array", "include?", T_BOOL); + REG_METHOD("Array", "empty?", T_BOOL); + REG_METHOD("Array", "any?", T_BOOL); + REG_METHOD("Array", "all?", T_BOOL); + REG_METHOD("Array", "none?", T_BOOL); + REG_METHOD("Array", "each", T_ARRAY); + REG_METHOD("Array", "each_with_index", T_ARRAY); + REG_METHOD("Array", "to_a", T_ARRAY); + REG_METHOD("Array", "group_by", T_HASH); + REG_METHOD("Array", "each_slice", MIXED); + REG_METHOD("Array", "zip", T_ARRAY); + + /* ── Hash ── */ + REG_METHOD("Hash", "keys", T_ARRAY); + REG_METHOD("Hash", "values", T_ARRAY); + REG_METHOD("Hash", "merge", T_HASH); + REG_METHOD("Hash", "merge!", T_HASH); + REG_METHOD("Hash", "fetch", MIXED); + REG_METHOD("Hash", "dig", MIXED); + REG_METHOD("Hash", "key?", T_BOOL); + REG_METHOD("Hash", "has_key?", T_BOOL); + REG_METHOD("Hash", "include?", T_BOOL); + REG_METHOD("Hash", "empty?", T_BOOL); + REG_METHOD("Hash", "size", T_INT); + REG_METHOD("Hash", "length", T_INT); + REG_METHOD("Hash", "count", T_INT); + REG_METHOD("Hash", "each", T_HASH); + REG_METHOD("Hash", "map", T_ARRAY); + REG_METHOD("Hash", "select", T_HASH); + REG_METHOD("Hash", "reject", T_HASH); + REG_METHOD("Hash", "to_a", T_ARRAY); + REG_METHOD("Hash", "to_h", T_HASH); + REG_METHOD("Hash", "transform_values", T_HASH); + REG_METHOD("Hash", "transform_keys", T_HASH); + REG_METHOD("Hash", "symbolize_keys", T_HASH); /* ActiveSupport */ + REG_METHOD("Hash", "stringify_keys", T_HASH); /* ActiveSupport */ + REG_METHOD("Hash", "deep_symbolize_keys", T_HASH); + REG_METHOD("Hash", "with_indifferent_access", T_HASH); + + /* ── Integer / Float ── */ + REG_METHOD("Integer", "to_s", T_STRING); + REG_METHOD("Integer", "to_i", T_INT); + REG_METHOD("Integer", "to_f", T_FLOAT); + REG_METHOD("Integer", "times", MIXED); + REG_METHOD("Integer", "upto", MIXED); + REG_METHOD("Integer", "abs", T_INT); + REG_METHOD("Integer", "zero?", T_BOOL); + REG_METHOD("Integer", "positive?", T_BOOL); + REG_METHOD("Integer", "negative?", T_BOOL); + REG_METHOD("Integer", "even?", T_BOOL); + REG_METHOD("Integer", "odd?", T_BOOL); + REG_METHOD("Float", "to_s", T_STRING); + REG_METHOD("Float", "to_i", T_INT); + REG_METHOD("Float", "round", T_INT); + REG_METHOD("Float", "ceil", T_INT); + REG_METHOD("Float", "floor", T_INT); + REG_METHOD("Float", "abs", T_FLOAT); + + /* ── Symbol / misc ── */ + REG_METHOD("Symbol", "to_s", T_STRING); + REG_METHOD("Symbol", "to_sym", T_SYM); + REG_METHOD("Symbol", "to_proc", cbm_type_named(arena, "Proc")); + REG_METHOD("Range", "to_a", T_ARRAY); + REG_METHOD("Range", "each", MIXED); + REG_METHOD("Range", "map", T_ARRAY); + REG_METHOD("Range", "include?", T_BOOL); + REG_METHOD("Time", "to_s", T_STRING); + REG_METHOD("Time", "to_i", T_INT); + REG_METHOD("Time", "strftime", T_STRING); + REG_METHOD("Time", "year", T_INT); + REG_METHOD("Time", "month", T_INT); + REG_METHOD("Time", "day", T_INT); + REG_SMETHOD("Time", "now", cbm_type_named(arena, "Time")); + REG_SMETHOD("Time", "at", cbm_type_named(arena, "Time")); + REG_SMETHOD("Time", "parse", cbm_type_named(arena, "Time")); + REG_SMETHOD("Time", "current", cbm_type_named(arena, "Time")); /* ActiveSupport */ + REG_SMETHOD("Time", "zone", MIXED); + REG_SMETHOD("File", "read", T_STRING); + REG_SMETHOD("File", "readlines", T_ARRAY); + REG_SMETHOD("File", "open", cbm_type_named(arena, "File")); + REG_SMETHOD("File", "exist?", T_BOOL); + REG_SMETHOD("File", "basename", T_STRING); + REG_SMETHOD("File", "dirname", T_STRING); + REG_SMETHOD("File", "join", T_STRING); + REG_SMETHOD("File", "expand_path", T_STRING); + REG_SMETHOD("JSON", "parse", MIXED); + REG_SMETHOD("JSON", "generate", T_STRING); + REG_SMETHOD("JSON", "pretty_generate", T_STRING); + REG_SMETHOD("Math", "sqrt", T_FLOAT); + REG_SMETHOD("Math", "pow", T_FLOAT); + REG_SMETHOD("Math", "sin", T_FLOAT); + REG_SMETHOD("Math", "cos", T_FLOAT); + REG_SMETHOD("Math", "log", T_FLOAT); + + /* ── Object (universal receivers; ActiveSupport predicates) ── */ + REG_METHOD("Object", "to_s", T_STRING); + REG_METHOD("Object", "inspect", T_STRING); + REG_METHOD("Object", "freeze", MIXED); + REG_METHOD("Object", "frozen?", T_BOOL); + REG_METHOD("Object", "dup", MIXED); + REG_METHOD("Object", "clone", MIXED); + REG_METHOD("Object", "tap", MIXED); + REG_METHOD("Object", "then", MIXED); + REG_METHOD("Object", "nil?", T_BOOL); + REG_METHOD("Object", "is_a?", T_BOOL); + REG_METHOD("Object", "kind_of?", T_BOOL); + REG_METHOD("Object", "instance_of?", T_BOOL); + REG_METHOD("Object", "respond_to?", T_BOOL); + REG_METHOD("Object", "blank?", T_BOOL); /* ActiveSupport */ + REG_METHOD("Object", "present?", T_BOOL); /* ActiveSupport */ + REG_METHOD("Object", "presence", MIXED); /* ActiveSupport */ + REG_METHOD("Object", "to_json", T_STRING); /* ActiveSupport / json */ + + /* ── Kernel built-ins (global functions) ── */ + REG_BUILTIN("puts", MIXED); + REG_BUILTIN("print", MIXED); + REG_BUILTIN("p", MIXED); + REG_BUILTIN("pp", MIXED); + REG_BUILTIN("require", T_BOOL); + REG_BUILTIN("require_relative", T_BOOL); + REG_BUILTIN("raise", MIXED); + REG_BUILTIN("fail", MIXED); + REG_BUILTIN("loop", MIXED); + REG_BUILTIN("sleep", T_INT); + REG_BUILTIN("rand", MIXED); + REG_BUILTIN("format", T_STRING); + REG_BUILTIN("sprintf", T_STRING); + REG_BUILTIN("gets", T_STRING); + REG_BUILTIN("exit", MIXED); + REG_BUILTIN("abort", MIXED); + REG_BUILTIN("lambda", cbm_type_named(arena, "Proc")); + REG_BUILTIN("proc", cbm_type_named(arena, "Proc")); + REG_BUILTIN("binding", MIXED); + REG_BUILTIN("catch", MIXED); + REG_BUILTIN("throw", MIXED); + REG_BUILTIN("attr_accessor", MIXED); + REG_BUILTIN("attr_reader", MIXED); + REG_BUILTIN("attr_writer", MIXED); + + /* ── Rails: ActiveRecord::Base ────────────────────────────────── + * Class-side query interface. Return types here are generic; the + * resolver's AR special case substitutes the receiving model's type + * (User.find -> User) when the receiver's ancestry reaches + * ActiveRecord.Base / ApplicationRecord. */ + REG_TYPE("ActiveRecord.Base"); + REG_TYPE("ApplicationRecord"); + static const char *ar_class_methods[] = {"find", + "find_by", + "find_by!", + "find_or_create_by", + "find_or_initialize_by", + "where", + "all", + "first", + "last", + "take", + "create", + "create!", + "new", + "count", + "order", + "limit", + "offset", + "joins", + "left_joins", + "includes", + "preload", + "eager_load", + "group", + "having", + "distinct", + "pluck", + "ids", + "exists?", + "none", + "unscoped", + "select", + "sum", + "maximum", + "minimum", + "average", + "destroy_all", + "delete_all", + "update_all", + "transaction", + "find_each", + NULL}; + for (int i = 0; ar_class_methods[i]; i++) + REG_SMETHOD("ActiveRecord.Base", ar_class_methods[i], MIXED); + REG_METHOD("ActiveRecord.Base", "save", T_BOOL); + REG_METHOD("ActiveRecord.Base", "save!", T_BOOL); + REG_METHOD("ActiveRecord.Base", "update", T_BOOL); + REG_METHOD("ActiveRecord.Base", "update!", T_BOOL); + REG_METHOD("ActiveRecord.Base", "destroy", MIXED); + REG_METHOD("ActiveRecord.Base", "destroy!", MIXED); + REG_METHOD("ActiveRecord.Base", "delete", MIXED); + REG_METHOD("ActiveRecord.Base", "reload", MIXED); + REG_METHOD("ActiveRecord.Base", "valid?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "invalid?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "persisted?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "new_record?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "errors", MIXED); + REG_METHOD("ActiveRecord.Base", "attributes", T_HASH); + REG_METHOD("ActiveRecord.Base", "assign_attributes", MIXED); + REG_METHOD("ActiveRecord.Base", "update_attribute", T_BOOL); + REG_METHOD("ActiveRecord.Base", "touch", T_BOOL); + REG_METHOD("ActiveRecord.Base", "id", T_INT); + + /* ── Rails: ActionController::Base / ::API ── */ + REG_TYPE("ActionController.Base"); + REG_TYPE("ActionController.API"); + static const char *ac_receivers[] = {"ActionController.Base", "ActionController.API", NULL}; + for (int i = 0; ac_receivers[i]; i++) { + const char *acr = ac_receivers[i]; + REG_METHOD(acr, "render", MIXED); + REG_METHOD(acr, "redirect_to", MIXED); + REG_METHOD(acr, "head", MIXED); + REG_METHOD(acr, "params", MIXED); + REG_METHOD(acr, "session", MIXED); + REG_METHOD(acr, "cookies", MIXED); + REG_METHOD(acr, "request", MIXED); + REG_METHOD(acr, "response", MIXED); + REG_METHOD(acr, "respond_to", MIXED); + REG_METHOD(acr, "flash", MIXED); + } + REG_SMETHOD("ActionController.Base", "before_action", MIXED); + REG_SMETHOD("ActionController.Base", "after_action", MIXED); + REG_SMETHOD("ActionController.Base", "skip_before_action", MIXED); + REG_SMETHOD("ActionController.API", "before_action", MIXED); + REG_SMETHOD("ActionController.API", "after_action", MIXED); + + /* ── Rails: jobs / mailers (constants only, for superclass chains) ── */ + REG_TYPE("ActiveJob.Base"); + REG_TYPE("ApplicationJob"); + REG_TYPE("ActionMailer.Base"); + REG_TYPE("ApplicationMailer"); + REG_TYPE("ApplicationController"); + REG_SMETHOD("ActiveJob.Base", "perform_later", MIXED); + REG_SMETHOD("ActiveJob.Base", "perform_now", MIXED); + REG_SMETHOD("ActiveJob.Base", "set", MIXED); + REG_METHOD("ActionMailer.Base", "mail", MIXED); + REG_SMETHOD("ActionMailer.Base", "with", MIXED); + + /* ── Minitest/RSpec anchors (superclass chains in test files) ── */ + REG_TYPE("Minitest.Test"); + REG_TYPE("ActiveSupport.TestCase"); + REG_TYPE("ActionDispatch.IntegrationTest"); +} diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c new file mode 100644 index 000000000..2ca944df2 --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.c @@ -0,0 +1,1261 @@ +/* + * ruby_lsp.c — Ruby Light Semantic Pass. + * + * In-process type-aware call resolver for Ruby. Mirrors the perl_lsp.c / + * php_lsp.c shape: + * 1. Build a CBMTypeRegistry from file-local definitions + stdlib + * (Ruby core classes + curated Rails surface), with per-class method + * tables keyed by receiver QN. Singleton (class-side) methods are + * keyed ".self" — Ruby constants cannot be lowercase, so + * the suffix never collides with a real nested-constant path. + * 2. ruby_lsp_process_file does a multi-pass walk: + * PASS 1 — collect class/module declarations (with lexical nesting), + * superclasses, include/prepend/extend mixins, and method tables + * (instance vs singleton classified from the AST — `def m` vs + * `def self.m` / `class << self`). + * PASS 1.5 — infer instance-variable types from `@x = Const.new` + * assignments inside method bodies (conflicting types latch to + * "conflicted" and resolve to nothing). + * PASS 2 — walk method bodies, track local variable types through + * assignments, and resolve call expressions into CBMResolvedCall + * edges via Ruby method lookup (prepends → own → includes → + * superclass chain; extends feed the singleton side). + * + * Verified tree-sitter-ruby node/field names (vendored compiled grammar at + * internal/cbm/vendored/grammars/ruby/parser.c — ts_symbol_names and + * ts_field_names tables): + * - call : fields `receiver`, `method`, `arguments`, `block`. Both + * `foo(x)` and command calls (`foo x`) surface as "call" nodes. + * - class : fields `name` (constant | scope_resolution), `superclass` + * (a `superclass` wrapper node), `body` (body_statement). + * - module : fields `name`, `body`. + * - method : fields `name`, `parameters`, `body`. + * - singleton_method : fields `object` (self | constant), `name`, `body`. + * - singleton_class : `class << self` — fields `value`, body children. + * - assignment : fields `left`, `right`. + * - scope_resolution : fields `scope` (may be absent for ::Foo), `name`. + * - leaves: constant, identifier, self, instance_variable, super, + * string, array, hash, integer, float, simple_symbol, regex. + * + * QN scheme (matches the structural extractor): Ruby HAS class node types + * (class/module), so defs are named `module_qn..` with + * parent_class = `module_qn.`. Constructor calls: the textual + * extractor rewrites `Widget.new` to callee "Widget" (extract_calls.c), so + * constructor rows emit the CLASS QN — the edge lands on the Class node, + * exactly like Python's lsp_constructor. + * + * Zero-edge guarantee: if a receiver's type is unknown/unindexed, NO edge + * is emitted (false edges are worse than missing edges). Dynamic dispatch + * (`send`, `public_send`, `method_missing`, `define_method`) is + * intentionally ignored. + */ + +#include "ruby_lsp.h" +#include "../helpers.h" +#include "../arena.h" +#include +#include +#include +#include + +/* Recursion cap for ruby_eval_expr_type — mirrors perl/php (unknown past 8). */ +#define RUBY_EVAL_MAX_DEPTH 8 + +/* Confidence levels. */ +#define RUBY_CONF_HIGH 0.95f /* constructor / static constant dispatch */ +#define RUBY_CONF_TYPED 0.90f /* inferred receiver type (locals, ivars, chains) */ + +/* Maximum AST-walk recursion depth (see scope.h rationale — graceful + * degradation on pathologically nested sources, never a crash). */ +#define CBM_LSP_RUBY_MAX_WALK_DEPTH 512 + +/* ── forward declarations ───────────────────────────────────────── */ + +static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node); +static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node); +static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node); +static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node); +static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node); +static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node); +static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit); + +/* ── helpers ────────────────────────────────────────────────────── */ + +static char *ruby_node_text(RubyLSPContext *ctx, TSNode node) { + return cbm_node_text(ctx->arena, node, ctx->source); +} + +/* Convert a constant expression node (constant | scope_resolution) into a + * dotted path ("Foo", "A.B.C"). Returns NULL for anything else. A leading + * "::Foo" (scope_resolution without a scope) yields "Foo" — the resolver's + * table probe covers the top-level meaning. */ +static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return NULL; + const char *k = ts_node_type(node); + if (strcmp(k, "constant") == 0) + return ruby_node_text(ctx, node); + if (strcmp(k, "scope_resolution") != 0) + return NULL; + TSNode scope = ts_node_child_by_field_name(node, "scope", 5); + TSNode name = ts_node_child_by_field_name(node, "name", 4); + if (ts_node_is_null(name)) + return NULL; + char *nm = ruby_node_text(ctx, name); + if (!nm || !nm[0]) + return NULL; + if (ts_node_is_null(scope)) + return nm; /* ::Foo */ + const char *sp = ruby_const_path(ctx, scope); + if (!sp || !sp[0]) + return nm; + return cbm_arena_sprintf(ctx->arena, "%s.%s", sp, nm); +} + +/* Join two dotted path fragments; either side may be empty/NULL. */ +static const char *ruby_path_join(CBMArena *a, const char *left, const char *right) { + if (!left || !left[0]) + return right; + if (!right || !right[0]) + return left; + return cbm_arena_sprintf(a, "%s.%s", left, right); +} + +/* True when a QN belongs to the indexed project (module_qn's first segment + * is its prefix). Stdlib seed QNs ("String.upcase", "ActiveRecord.Base") + * never carry the project prefix, so edges are only ever emitted at + * project-defined targets. */ +static bool ruby_is_project_qn(RubyLSPContext *ctx, const char *qn) { + if (!qn || !ctx->module_qn || !ctx->module_qn[0]) + return false; + const char *dot = strchr(ctx->module_qn, '.'); + size_t plen = dot ? (size_t)(dot - ctx->module_qn) : strlen(ctx->module_qn); + return strncmp(qn, ctx->module_qn, plen) == 0 && qn[plen] == '.'; +} + +/* ── table growth helpers ───────────────────────────────────────── */ + +static RubyClassInfo *ruby_add_class(RubyLSPContext *ctx, const char *path, const char *qn, + bool is_module) { + if (!ctx || !path || !qn) + return NULL; + /* Reopened class/module: reuse the existing record. */ + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 0) + return &ctx->classes[i]; + } + if (ctx->class_count >= ctx->class_cap) { + int newcap = ctx->class_cap ? ctx->class_cap * 2 : 8; + RubyClassInfo *nc = + (RubyClassInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyClassInfo)); + if (!nc) + return NULL; + for (int i = 0; i < ctx->class_count; i++) + nc[i] = ctx->classes[i]; + ctx->classes = nc; + ctx->class_cap = newcap; + } + RubyClassInfo *ci = &ctx->classes[ctx->class_count++]; + memset(ci, 0, sizeof(*ci)); + ci->path = cbm_arena_strdup(ctx->arena, path); + ci->qn = cbm_arena_strdup(ctx->arena, qn); + ci->is_module = is_module; + return ci; +} + +static void ruby_add_mixin(RubyLSPContext *ctx, const char *owner_qn, const char *module_ref, + RubyMixinKind kind) { + if (!ctx || !owner_qn || !module_ref) + return; + if (ctx->mixin_count >= ctx->mixin_cap) { + int newcap = ctx->mixin_cap ? ctx->mixin_cap * 2 : 8; + RubyMixinInfo *nm = + (RubyMixinInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyMixinInfo)); + if (!nm) + return; + for (int i = 0; i < ctx->mixin_count; i++) + nm[i] = ctx->mixins[i]; + ctx->mixins = nm; + ctx->mixin_cap = newcap; + } + RubyMixinInfo *mi = &ctx->mixins[ctx->mixin_count++]; + memset(mi, 0, sizeof(*mi)); + mi->owner_qn = cbm_arena_strdup(ctx->arena, owner_qn); + mi->module_ref = cbm_arena_strdup(ctx->arena, module_ref); + mi->kind = kind; +} + +static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char *ivar_name, + const char *type_qn) { + if (!ctx || !class_qn || !ivar_name || !type_qn) + return; + for (int i = 0; i < ctx->ivar_count; i++) { + RubyIvarInfo *iv = &ctx->ivars[i]; + if (strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) { + if (strcmp(iv->type_qn, type_qn) != 0) + iv->conflicted = true; /* disagreeing assignments — no type */ + return; + } + } + if (ctx->ivar_count >= ctx->ivar_cap) { + int newcap = ctx->ivar_cap ? ctx->ivar_cap * 2 : 8; + RubyIvarInfo *ni = + (RubyIvarInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyIvarInfo)); + if (!ni) + return; + for (int i = 0; i < ctx->ivar_count; i++) + ni[i] = ctx->ivars[i]; + ctx->ivars = ni; + ctx->ivar_cap = newcap; + } + RubyIvarInfo *iv = &ctx->ivars[ctx->ivar_count++]; + memset(iv, 0, sizeof(*iv)); + iv->class_qn = cbm_arena_strdup(ctx->arena, class_qn); + iv->ivar_name = cbm_arena_strdup(ctx->arena, ivar_name); + iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); +} + +static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + if (!ctx || !class_qn || !ivar_name) + return NULL; + for (int i = 0; i < ctx->ivar_count; i++) { + RubyIvarInfo *iv = &ctx->ivars[i]; + if (!iv->conflicted && strcmp(iv->class_qn, class_qn) == 0 && + strcmp(iv->ivar_name, ivar_name) == 0) + return iv->type_qn; + } + return NULL; +} + +static RubyClassInfo *ruby_class_by_qn(RubyLSPContext *ctx, const char *qn) { + if (!qn) + return NULL; + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].qn, qn) == 0) + return &ctx->classes[i]; + } + return NULL; +} + +/* Conventional Rails superclasses for stdlib-seeded types the class table + * does not carry (per-file extraction cannot see app/models/ + * application_record.rb from another file; the convention is fixed). */ +static const char *ruby_stdlib_superclass(const char *qn) { + if (!qn) + return NULL; + if (strcmp(qn, "ApplicationRecord") == 0) + return "ActiveRecord.Base"; + if (strcmp(qn, "ApplicationController") == 0) + return "ActionController.Base"; + if (strcmp(qn, "ApplicationJob") == 0) + return "ActiveJob.Base"; + if (strcmp(qn, "ApplicationMailer") == 0) + return "ActionMailer.Base"; + return NULL; +} + +/* Superclass QN of a class: table entry first, then the conventional + * stdlib chain above. */ +static const char *ruby_superclass_qn(RubyLSPContext *ctx, const char *class_qn) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (ci) + return ci->superclass_qn; + return ruby_stdlib_superclass(class_qn); +} + +/* ── constant resolution ────────────────────────────────────────── */ + +const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path) { + if (!ctx || !path || !path[0]) + return NULL; + /* Lexical nesting probe: for nesting "A.B" try "A.B.path", "A.path", + * then "path" (innermost first — Ruby's constant lookup order). */ + const char *nest = ctx->nesting ? ctx->nesting : ""; + char prefix[512]; + size_t nlen = strlen(nest); + if (nlen < sizeof(prefix)) { + memcpy(prefix, nest, nlen + 1); + while (1) { + if (prefix[0]) { + const char *cand = cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, path); + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, cand) == 0) + return ctx->classes[i].qn; + } + char *last_dot = strrchr(prefix, '.'); + if (last_dot) + *last_dot = '\0'; + else + prefix[0] = '\0'; + continue; + } + break; + } + } + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 0) + return ctx->classes[i].qn; + } + /* Stdlib types are keyed by their dotted bare path. */ + const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, path); + if (t) + return t->qualified_name; + return NULL; +} + +/* ── method lookup (Ruby MRO approximation) ─────────────────────── */ + +/* Depth cap shared by the instance/singleton lookup walks. */ +#define RUBY_LOOKUP_MAX_DEPTH CBM_LSP_MAX_LOOKUP_DEPTH + +/* Own instance method of one exact class (no ancestry walk). */ +static const CBMRegisteredFunc *ruby_own_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + return cbm_registry_lookup_method(ctx->registry, class_qn, method_name); +} + +/* Own singleton method of one exact class (".self" receiver key). */ +static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + const char *key = cbm_arena_sprintf(ctx->arena, "%s.self", class_qn); + return cbm_registry_lookup_method(ctx->registry, key, method_name); +} + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth); + +/* Search a mixin kind on `owner_qn` (most recent first — later mixins win), + * recursing into each module's own include chain. */ +static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const char *owner_qn, + RubyMixinKind kind, const char *method_name, + int depth) { + for (int i = ctx->mixin_count - 1; i >= 0; i--) { + RubyMixinInfo *mi = &ctx->mixins[i]; + if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0 || !mi->module_qn) + continue; + const CBMRegisteredFunc *f = + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1); + if (f) + return f; + } + return NULL; +} + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth) { + if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) + return NULL; + /* Prepended modules shadow the class's own methods. */ + const CBMRegisteredFunc *f = + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth); + if (f) + return f; + f = ruby_own_instance_method(ctx, class_qn, method_name); + if (f) + return f; + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth); + if (f) + return f; + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + const CBMRegisteredFunc *f = ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0); + if (f) + return f; + /* Universal receiver fallback (Object / ActiveSupport predicates) — + * typing only; Object methods are stdlib entries, never project defs. */ + if (class_qn && strcmp(class_qn, "Object") != 0) + return cbm_registry_lookup_method(ctx->registry, "Object", method_name); + return NULL; +} + +static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth) { + if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) + return NULL; + const CBMRegisteredFunc *f = ruby_own_singleton_method(ctx, class_qn, method_name); + if (f) + return f; + /* `extend Mod` adds Mod's instance methods class-side. */ + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth); + if (f) + return f; + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0); +} + +/* ── ActiveRecord model detection (typing special case) ─────────── */ + +/* True when class_qn's superclass chain reaches ActiveRecord::Base (or the + * conventional ApplicationRecord intermediary, which per-file extraction + * usually cannot see through). */ +static bool ruby_is_ar_model(RubyLSPContext *ctx, const char *class_qn, int depth) { + if (!class_qn || depth > RUBY_LOOKUP_MAX_DEPTH) + return false; + if (strcmp(class_qn, "ActiveRecord.Base") == 0 || strcmp(class_qn, "ApplicationRecord") == 0) + return true; + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (ci && ci->superclass_ref && + (strcmp(ci->superclass_ref, "ApplicationRecord") == 0 || + strcmp(ci->superclass_ref, "ActiveRecord.Base") == 0)) + return true; + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_is_ar_model(ctx, sup, depth + 1); + return false; +} + +/* ActiveRecord class-side query methods that return the model (or a + * relation we approximate as the model, so chains like + * `User.where(...).first` keep their type). */ +static bool ruby_ar_returns_model(const char *m) { + static const char *names[] = {"find", + "find_by", + "find_by!", + "find_or_create_by", + "find_or_initialize_by", + "where", + "all", + "first", + "last", + "take", + "create", + "create!", + "new", + "order", + "limit", + "offset", + "joins", + "left_joins", + "includes", + "preload", + "eager_load", + "group", + "having", + "distinct", + "none", + "unscoped", + "find_each", + NULL}; + for (int i = 0; names[i]; i++) { + if (strcmp(m, names[i]) == 0) + return true; + } + return false; +} + +/* ── emit ───────────────────────────────────────────────────────── */ + +static void ruby_emit_resolved(RubyLSPContext *ctx, const char *callee_qn, const char *strategy, + float confidence, TSNode site) { + if (!ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn) + return; + /* Only project-defined targets become edges (stdlib rows would never + * land on a node and merely add noise). */ + if (!ruby_is_project_qn(ctx, callee_qn)) + return; + CBMResolvedCall rc = {0}; + rc.caller_qn = ctx->enclosing_func_qn; + rc.callee_qn = callee_qn; + rc.strategy = strategy; + rc.confidence = confidence; + rc.reason = NULL; + rc.kind = CBM_RESOLVED_INVOCATION; + if (!ts_node_is_null(site)) { + rc.site_start_byte = ts_node_start_byte(site); + rc.site_end_byte = ts_node_end_byte(site); + } + cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc); +} + +/* ── expression typing ──────────────────────────────────────────── */ + +/* Return type of a registered func's signature, or unknown. */ +static const CBMType *ruby_func_return_type(const CBMRegisteredFunc *f) { + if (f && f->signature && f->signature->kind == CBM_TYPE_FUNC && + f->signature->data.func.return_types && f->signature->data.func.return_types[0]) { + return f->signature->data.func.return_types[0]; + } + return cbm_type_unknown(); +} + +const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return cbm_type_unknown(); + if (ctx->eval_depth >= RUBY_EVAL_MAX_DEPTH) + return cbm_type_unknown(); + ctx->eval_depth++; + const CBMType *result = cbm_type_unknown(); + const char *k = ts_node_type(node); + + if (strcmp(k, "identifier") == 0) { + char *txt = ruby_node_text(ctx, node); + if (txt) { + const CBMType *t = cbm_scope_lookup(ctx->current_scope, txt); + if (t) + result = t; + } + } else if (strcmp(k, "instance_variable") == 0) { + char *txt = ruby_node_text(ctx, node); + const char *tqn = ruby_ivar_type(ctx, ctx->enclosing_class_qn, txt); + if (tqn) + result = cbm_type_named(ctx->arena, tqn); + } else if (strcmp(k, "self") == 0) { + if (ctx->enclosing_class_qn && !ctx->in_singleton_method) + result = cbm_type_named(ctx->arena, ctx->enclosing_class_qn); + } else if (strcmp(k, "call") == 0) { + result = ruby_eval_call_type(ctx, node, false); + } else if (strcmp(k, "assignment") == 0) { + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(right)) + result = ruby_eval_expr_type(ctx, right); + } else if (strcmp(k, "string") == 0 || strcmp(k, "string_content") == 0 || + strcmp(k, "heredoc_body") == 0 || strcmp(k, "chained_string") == 0) { + result = cbm_type_named(ctx->arena, "String"); + } else if (strcmp(k, "array") == 0 || strcmp(k, "string_array") == 0 || + strcmp(k, "symbol_array") == 0) { + result = cbm_type_named(ctx->arena, "Array"); + } else if (strcmp(k, "hash") == 0) { + result = cbm_type_named(ctx->arena, "Hash"); + } else if (strcmp(k, "integer") == 0) { + result = cbm_type_named(ctx->arena, "Integer"); + } else if (strcmp(k, "float") == 0) { + result = cbm_type_named(ctx->arena, "Float"); + } else if (strcmp(k, "simple_symbol") == 0 || strcmp(k, "delimited_symbol") == 0) { + result = cbm_type_named(ctx->arena, "Symbol"); + } else if (strcmp(k, "regex") == 0) { + result = cbm_type_named(ctx->arena, "Regexp"); + } else if (strcmp(k, "parenthesized_statements") == 0 || strcmp(k, "begin") == 0) { + /* Type of the last meaningful child. */ + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = nc; i > 0; i--) { + TSNode c = ts_node_named_child(node, i - 1); + if (ts_node_is_null(c)) + continue; + const char *ck = ts_node_type(c); + if (strcmp(ck, "comment") == 0) + continue; + result = ruby_eval_expr_type(ctx, c); + break; + } + } + + ctx->eval_depth--; + return result; +} + +/* ── call resolution core ───────────────────────────────────────── */ + +/* Dynamic-dispatch method names the resolver must never guess through. */ +static bool ruby_is_dynamic_dispatch(const char *m) { + return strcmp(m, "send") == 0 || strcmp(m, "public_send") == 0 || strcmp(m, "__send__") == 0 || + strcmp(m, "method_missing") == 0 || strcmp(m, "define_method") == 0 || + strcmp(m, "instance_eval") == 0 || strcmp(m, "class_eval") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "instance_variable_get") == 0 || + strcmp(m, "instance_variable_set") == 0; +} + +/* Class-body macro-ish names PASS 1 already interpreted (or that never + * denote resolvable user calls). */ +static bool ruby_is_body_macro(const char *m) { + return strcmp(m, "include") == 0 || strcmp(m, "extend") == 0 || strcmp(m, "prepend") == 0 || + strcmp(m, "require") == 0 || strcmp(m, "require_relative") == 0 || + strcmp(m, "attr_accessor") == 0 || strcmp(m, "attr_reader") == 0 || + strcmp(m, "attr_writer") == 0 || strcmp(m, "private") == 0 || strcmp(m, "public") == 0 || + strcmp(m, "protected") == 0 || strcmp(m, "module_function") == 0 || + strcmp(m, "alias_method") == 0 || strcmp(m, "loop") == 0 || strcmp(m, "lambda") == 0 || + strcmp(m, "proc") == 0; +} + +/* Resolve a singleton-side dispatch on a resolved class QN. Emits (when + * `emit`) and returns the call's type. Also owns the ActiveRecord typing + * special case (User.find -> User). */ +static const CBMType *ruby_dispatch_singleton(RubyLSPContext *ctx, const char *class_qn, + const char *mname, bool emit, TSNode site) { + const CBMRegisteredFunc *f = ruby_lookup_singleton_method(ctx, class_qn, mname); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_class_method", RUBY_CONF_HIGH, site); + const CBMType *rt = ruby_func_return_type(f); + if (!cbm_type_is_unknown(rt)) + return rt; + /* Stdlib AR query entries return unknown; substitute the model. */ + if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname)) + return cbm_type_named(ctx->arena, class_qn); + return rt; + } + if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname)) + return cbm_type_named(ctx->arena, class_qn); + return cbm_type_unknown(); +} + +/* Resolve an instance-side dispatch on a receiver type QN. */ +static const CBMType *ruby_dispatch_instance(RubyLSPContext *ctx, const char *class_qn, + const char *mname, const char *strategy, bool emit, + TSNode site) { + const CBMRegisteredFunc *f = ruby_lookup_instance_method(ctx, class_qn, mname); + if (!f) + return cbm_type_unknown(); + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, strategy, RUBY_CONF_TYPED, site); + return ruby_func_return_type(f); +} + +/* Resolve one `call` node. When `emit` is set, resolved project targets are + * appended as CBMResolvedCall edges; the call's result type is returned + * either way (so chains and assignments can type through it). */ +static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit) { + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (ts_node_is_null(meth)) + return cbm_type_unknown(); + + /* `super(...)` is dispatched by the walker (ruby_resolve_super); as an + * expression its type is not modeled. */ + if (strcmp(ts_node_type(meth), "super") == 0) + return cbm_type_unknown(); + + char *mname = ruby_node_text(ctx, meth); + if (!mname || !mname[0]) + return cbm_type_unknown(); + if (ruby_is_dynamic_dispatch(mname)) + return cbm_type_unknown(); /* zero-edge guarantee */ + + /* Constructor: Const.new / A::B.new — the textual extractor rewrites + * the callee to the constant, so the row targets the CLASS QN. */ + if (strcmp(mname, "new") == 0 && !ts_node_is_null(recv)) { + const char *cpath = ruby_const_path(ctx, recv); + if (cpath) { + const char *cqn = ruby_resolve_constant(ctx, cpath); + if (cqn) { + if (emit) + ruby_emit_resolved(ctx, cqn, "ruby_constructor", RUBY_CONF_HIGH, call); + return cbm_type_named(ctx->arena, cqn); + } + return cbm_type_unknown(); + } + /* Fall through: `expr.new` on a non-constant receiver. */ + } + + if (ts_node_is_null(recv)) { + /* Bare call: self-dispatch inside methods, then file-level funcs. */ + if (ruby_is_body_macro(mname)) + return cbm_type_unknown(); + if (ctx->enclosing_class_qn) { + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + } + const CBMRegisteredFunc *f = + cbm_registry_lookup_symbol(ctx->registry, ctx->module_qn, mname); + if (!f) + f = cbm_registry_lookup_func(ctx->registry, mname); /* Kernel builtins */ + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_function_local", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + return cbm_type_unknown(); + } + + const char *rk = ts_node_type(recv); + + /* self.m — explicit self dispatch. */ + if (strcmp(rk, "self") == 0) { + if (!ctx->enclosing_class_qn) + return cbm_type_unknown(); + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + return cbm_type_unknown(); + } + + /* Const.m / A::B.m — singleton dispatch on the resolved class. */ + if (strcmp(rk, "constant") == 0 || strcmp(rk, "scope_resolution") == 0) { + const char *cpath = ruby_const_path(ctx, recv); + const char *cqn = cpath ? ruby_resolve_constant(ctx, cpath) : NULL; + if (!cqn) + return cbm_type_unknown(); /* unknown constant — zero-edge */ + return ruby_dispatch_singleton(ctx, cqn, mname, emit, call); + } + + /* Typed receiver: locals, ivars, chained calls, literals. */ + const CBMType *rt = ruby_eval_expr_type(ctx, recv); + if (!rt || rt->kind != CBM_TYPE_NAMED) + return cbm_type_unknown(); /* unknown receiver — zero-edge */ + const char *strategy = "ruby_method_typed"; + if (strcmp(rk, "instance_variable") == 0) + strategy = "ruby_ivar_method"; + else if (strcmp(rk, "call") == 0) + strategy = "ruby_method_chained"; + const CBMType *out = + ruby_dispatch_instance(ctx, rt->data.named.qualified_name, mname, strategy, emit, call); + if (!cbm_type_is_unknown(out)) + return out; + /* AR relation approximation: chains on a model type keep the model. */ + if (ruby_is_ar_model(ctx, rt->data.named.qualified_name, 0) && ruby_ar_returns_model(mname)) + return rt; + return out; +} + +/* `super` / `super(...)`: resolve the enclosing method's name starting at + * the enclosing class's ancestry (own def skipped — that IS the caller). */ +static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { + if (!ctx->enclosing_class_qn || !ctx->enclosing_func_qn) + return; + const char *fq = ctx->enclosing_func_qn; + const char *mname = strrchr(fq, '.'); + mname = mname ? mname + 1 : fq; + const CBMRegisteredFunc *f = NULL; + /* Search order mirrors Ruby: includes of the class, then superclass + * chain — the class's OWN def is the caller and must be skipped. */ + f = ruby_lookup_in_mixins(ctx, ctx->enclosing_class_qn, + ctx->in_singleton_method ? RUBY_MIXIN_EXTEND : RUBY_MIXIN_INCLUDE, + mname, 0); + if (!f) { + const char *sup = ruby_superclass_qn(ctx, ctx->enclosing_class_qn); + if (sup) { + f = ctx->in_singleton_method ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0); + } + } + if (f) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); + /* No known parent or unresolved — zero-edge guarantee. */ +} + +/* ── assignment observer (scope binding) ────────────────────────── */ + +static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) { + TSNode left = ts_node_child_by_field_name(assign, "left", 4); + TSNode right = ts_node_child_by_field_name(assign, "right", 5); + if (ts_node_is_null(left) || ts_node_is_null(right)) + return; + if (strcmp(ts_node_type(left), "identifier") != 0) + return; /* only simple local targets are tracked */ + char *vname = ruby_node_text(ctx, left); + if (!vname || !vname[0]) + return; + const CBMType *rt = ruby_eval_expr_type(ctx, right); + if (rt && rt->kind == CBM_TYPE_NAMED) + cbm_scope_bind(ctx->current_scope, vname, rt); +} + +/* ── PASS 2: resolution walk ────────────────────────────────────── */ + +/* Process one method/singleton_method definition node. */ +static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton) { + CBMScope *saved_scope = ctx->current_scope; + const char *saved_func = ctx->enclosing_func_qn; + bool saved_singleton = ctx->in_singleton_method; + + ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope); + ctx->in_singleton_method = singleton; + + TSNode name = ts_node_child_by_field_name(node, "name", 4); + char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); + if (mname && mname[0]) { + const char *owner = ctx->enclosing_class_qn ? ctx->enclosing_class_qn : ctx->module_qn; + if (owner) + ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname); + else + ctx->enclosing_func_qn = cbm_arena_strdup(ctx->arena, mname); + } + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_resolve_calls_in_node(ctx, body); + + ctx->current_scope = saved_scope; + ctx->enclosing_func_qn = saved_func; + ctx->in_singleton_method = saved_singleton; +} + +/* Process a class/module node during PASS 2: update nesting + enclosing + * class, then walk the body. */ +static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return; + + const char *saved_nesting = ctx->nesting; + const char *saved_class = ctx->enclosing_class_qn; + bool saved_singleton = ctx->in_singleton_method; + + ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); + const char *qn = ruby_resolve_constant(ctx, cpath); + ctx->enclosing_class_qn = qn; + ctx->in_singleton_method = false; + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_resolve_calls_in_node(ctx, body); + + ctx->nesting = saved_nesting; + ctx->enclosing_class_qn = saved_class; + ctx->in_singleton_method = saved_singleton; +} + +static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_resolve_calls_in_node_inner(ctx, node); + ctx->walk_depth--; +} + +static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { + ruby_process_class_body(ctx, node); + return; + } + if (strcmp(k, "singleton_class") == 0) { + /* `class << self` — methods inside are singleton methods. */ + bool saved = ctx->in_singleton_method; + ctx->in_singleton_method = true; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) + ruby_resolve_calls_in_node(ctx, ts_node_named_child(node, i)); + ctx->in_singleton_method = saved; + return; + } + if (strcmp(k, "method") == 0) { + ruby_process_method(ctx, node, ctx->in_singleton_method); + return; + } + if (strcmp(k, "singleton_method") == 0) { + ruby_process_method(ctx, node, true); + return; + } + + if (strcmp(k, "assignment") == 0) + ruby_process_assignment(ctx, node); + + if (strcmp(k, "call") == 0) { + TSNode meth = ts_node_child_by_field_name(node, "method", 6); + if (!ts_node_is_null(meth) && strcmp(ts_node_type(meth), "super") == 0) + ruby_resolve_super(ctx, node); + else + ruby_eval_call_type(ctx, node, true); + /* Recurse into receiver/arguments/blocks for nested calls. */ + } else if (strcmp(k, "super") == 0) { + /* Bare `super` (no argument list) appears as a standalone node. */ + ruby_resolve_super(ctx, node); + return; + } + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_resolve_calls_in_node(ctx, c); + } +} + +/* ── PASS 1: class/mixin/method collection ──────────────────────── */ + +/* Register one method into the registry with the proper receiver key. */ +static void ruby_register_method(RubyLSPContext *ctx, CBMTypeRegistry *reg, const char *owner_qn, + const char *mname, bool singleton) { + if (!mname || !mname[0]) + return; + CBMRegisteredFunc rf; + memset(&rf, 0, sizeof(rf)); + rf.min_params = -1; + const char *owner = owner_qn ? owner_qn : ctx->module_qn; + rf.qualified_name = owner ? cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname) + : cbm_arena_strdup(ctx->arena, mname); + rf.short_name = cbm_arena_strdup(ctx->arena, mname); + if (owner_qn) { + rf.receiver_type = singleton ? cbm_arena_sprintf(ctx->arena, "%s.self", owner_qn) + : cbm_arena_strdup(ctx->arena, owner_qn); + } + const CBMType **rets = (const CBMType **)cbm_arena_alloc(ctx->arena, 2 * sizeof(*rets)); + if (rets) { + rets[0] = cbm_type_unknown(); + rets[1] = NULL; + } + rf.signature = cbm_type_func(ctx->arena, NULL, NULL, rets); + cbm_registry_add_func(reg, rf); +} + +/* PASS-1 refs are nesting-scoped: the finalize step resolves superclass and + * mixin references once the class table is complete, so PASS 1 records raw + * dotted refs plus the nesting string active at the declaration site + * (packed as "|" when nesting is set). */ +static const char *ruby_pack_ref(RubyLSPContext *ctx, const char *ref) { + const char *nest = ctx->nesting ? ctx->nesting : ""; + if (!nest[0]) + return ref; + return cbm_arena_sprintf(ctx->arena, "%s|%s", nest, ref); +} + +/* Resolve a packed "|" (or bare "") against the class + * table + stdlib, honoring the recorded lexical nesting. */ +static const char *ruby_resolve_packed_ref(RubyLSPContext *ctx, const char *packed) { + if (!packed) + return NULL; + const char *bar = strchr(packed, '|'); + const char *saved_nesting = ctx->nesting; + const char *ref = packed; + if (bar) { + ctx->nesting = cbm_arena_strndup(ctx->arena, packed, (size_t)(bar - packed)); + ref = bar + 1; + } else { + ctx->nesting = ""; + } + const char *qn = ruby_resolve_constant(ctx, ref); + ctx->nesting = saved_nesting; + return qn; +} + +/* Bare ref portion of a packed "|" string. */ +static const char *ruby_packed_bare_ref(const char *packed) { + if (!packed) + return NULL; + const char *bar = strchr(packed, '|'); + return bar ? bar + 1 : packed; +} + +/* Interpret a class-body `include X` / `prepend X` / `extend X` call, + * recording the mixin with the nesting packed for finalize-time resolve. */ +static void ruby_collect_mixin_call(RubyLSPContext *ctx, TSNode call) { + if (!ctx->enclosing_class_qn) + return; + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (ts_node_is_null(meth)) + return; + if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "self") != 0) + return; /* not a bare/self class-body macro */ + char *mname = ruby_node_text(ctx, meth); + if (!mname) + return; + RubyMixinKind kind; + if (strcmp(mname, "include") == 0) + kind = RUBY_MIXIN_INCLUDE; + else if (strcmp(mname, "prepend") == 0) + kind = RUBY_MIXIN_PREPEND; + else if (strcmp(mname, "extend") == 0) + kind = RUBY_MIXIN_EXTEND; + else + return; + TSNode args = ts_node_child_by_field_name(call, "arguments", 9); + if (ts_node_is_null(args)) + return; + uint32_t nc = ts_node_named_child_count(args); + for (uint32_t i = 0; i < nc; i++) { + const char *mpath = ruby_const_path(ctx, ts_node_named_child(args, i)); + if (!mpath) + continue; /* `extend self` etc. — not a constant */ + ruby_add_mixin(ctx, ctx->enclosing_class_qn, ruby_pack_ref(ctx, mpath), kind); + } +} + +static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return; + + const char *saved_nesting = ctx->nesting; + const char *full_path = ruby_path_join(ctx->arena, saved_nesting, cpath); + const char *qn = ctx->module_qn + ? cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, full_path) + : full_path; + RubyClassInfo *ci = ruby_add_class(ctx, full_path, qn, is_module); + + /* Superclass: `class C < Base` — the `superclass` field wraps the + * expression after `<`. */ + if (ci && !is_module && !ci->superclass_ref) { + TSNode sup = ts_node_child_by_field_name(node, "superclass", 10); + if (!ts_node_is_null(sup)) { + uint32_t sc = ts_node_named_child_count(sup); + for (uint32_t i = 0; i < sc; i++) { + const char *spath = ruby_const_path(ctx, ts_node_named_child(sup, i)); + if (spath) { + ci->superclass_ref = ruby_pack_ref(ctx, spath); + break; + } + } + } + } + + ctx->nesting = full_path; + const char *saved_class = ctx->enclosing_class_qn; + bool saved_singleton = ctx->in_singleton_method; + ctx->enclosing_class_qn = qn; + ctx->in_singleton_method = false; + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_pass1_scan(ctx, body); + ctx->nesting = saved_nesting; + ctx->enclosing_class_qn = saved_class; + ctx->in_singleton_method = saved_singleton; +} + +static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_pass1_scan_inner(ctx, node); + ctx->walk_depth--; +} + +/* Registry under construction during PASS 1 (methods are registered as + * they are discovered). Held in a file-scope slot only for the duration of + * cbm_run_ruby_lsp's pass-1 invocation — single-threaded per file. */ +static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0) { + ruby_pass1_class(ctx, node, false); + return; + } + if (strcmp(k, "module") == 0) { + ruby_pass1_class(ctx, node, true); + return; + } + if (strcmp(k, "singleton_class") == 0) { + bool saved = ctx->in_singleton_method; + ctx->in_singleton_method = true; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) + ruby_pass1_scan(ctx, ts_node_named_child(node, i)); + ctx->in_singleton_method = saved; + return; + } + if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; + TSNode name = ts_node_child_by_field_name(node, "name", 4); + char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); + if (ctx->build_reg) + ruby_register_method(ctx, ctx->build_reg, ctx->enclosing_class_qn, mname, singleton); + return; /* method bodies are PASS-2 territory */ + } + if (strcmp(k, "call") == 0) { + TSNode meth = ts_node_child_by_field_name(node, "method", 6); + if (!ts_node_is_null(meth)) { + char *mn = ruby_node_text(ctx, meth); + if (mn && (strcmp(mn, "include") == 0 || strcmp(mn, "prepend") == 0 || + strcmp(mn, "extend") == 0)) { + ruby_collect_mixin_call(ctx, node); + return; + } + } + } + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_pass1_scan(ctx, c); + } +} + +/* Finalize PASS 1: resolve superclass + mixin references now that the class + * table is complete. */ +static void ruby_finalize_refs(RubyLSPContext *ctx) { + for (int i = 0; i < ctx->class_count; i++) { + RubyClassInfo *ci = &ctx->classes[i]; + if (ci->superclass_ref) { + ci->superclass_qn = ruby_resolve_packed_ref(ctx, ci->superclass_ref); + /* Keep the bare ref spelling for AR-model heuristics. */ + ci->superclass_ref = ruby_packed_bare_ref(ci->superclass_ref); + } + } + for (int i = 0; i < ctx->mixin_count; i++) { + RubyMixinInfo *mi = &ctx->mixins[i]; + mi->module_qn = ruby_resolve_packed_ref(ctx, mi->module_ref); + mi->module_ref = ruby_packed_bare_ref(mi->module_ref); + } +} + +/* ── PASS 1.5: instance-variable type inference ─────────────────── */ + +/* Statically evaluate `Const.new` / `A::B.new` without scope context. */ +static const char *ruby_static_ctor_type(RubyLSPContext *ctx, TSNode expr) { + if (ts_node_is_null(expr) || strcmp(ts_node_type(expr), "call") != 0) + return NULL; + TSNode meth = ts_node_child_by_field_name(expr, "method", 6); + TSNode recv = ts_node_child_by_field_name(expr, "receiver", 8); + if (ts_node_is_null(meth) || ts_node_is_null(recv)) + return NULL; + char *mname = ruby_node_text(ctx, meth); + if (!mname || strcmp(mname, "new") != 0) + return NULL; + const char *cpath = ruby_const_path(ctx, recv); + if (!cpath) + return NULL; + return ruby_resolve_constant(ctx, cpath); +} + +static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_ivar_scan_inner(ctx, node); + ctx->walk_depth--; +} + +static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return; + const char *saved_nesting = ctx->nesting; + const char *saved_class = ctx->enclosing_class_qn; + ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); + ctx->enclosing_class_qn = ruby_resolve_constant(ctx, cpath); + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_ivar_scan(ctx, body); + ctx->nesting = saved_nesting; + ctx->enclosing_class_qn = saved_class; + return; + } + + if (strcmp(k, "assignment") == 0 && ctx->enclosing_class_qn) { + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left) && !ts_node_is_null(right) && + strcmp(ts_node_type(left), "instance_variable") == 0) { + const char *tqn = ruby_static_ctor_type(ctx, right); + if (tqn) { + char *ivar = ruby_node_text(ctx, left); + ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + } + } + } + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_ivar_scan(ctx, c); + } +} + +/* ── public API ─────────────────────────────────────────────────── */ + +void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len, + const CBMTypeRegistry *registry, const char *module_qn, + CBMResolvedCallArray *out) { + memset(ctx, 0, sizeof(*ctx)); + ctx->arena = arena; + ctx->source = source; + ctx->source_len = source_len; + ctx->registry = registry; + ctx->module_qn = module_qn; + ctx->nesting = ""; + ctx->resolved_calls = out; + ctx->current_scope = cbm_scope_push(arena, NULL); + + const char *dbg = getenv("CBM_LSP_DEBUG"); + ctx->debug = (dbg && dbg[0]); +} + +/* ── entry: cbm_run_ruby_lsp ────────────────────────────────────── */ + +void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root) { + if (!result || !arena || ts_node_is_null(root)) + return; + + CBMTypeRegistry reg; + cbm_registry_init(®, arena); + + /* Phase A: stdlib (Ruby core + curated Rails surface). */ + cbm_ruby_stdlib_register(®, arena); + + const char *module_qn = result->module_qn; + + RubyLSPContext ctx; + ruby_lsp_init(&ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls); + + /* Phase B: PASS 1 — collect classes/modules, mixins, and methods from + * the AST (methods register directly into `reg`; the AST is ground + * truth for the instance-vs-singleton split the defs don't carry). */ + ctx.build_reg = ® + ruby_pass1_scan(&ctx, root); + ctx.build_reg = NULL; + ruby_finalize_refs(&ctx); + + /* Finalize the registry for O(1) lookups — index allocations go to a + * per-call scratch arena that dies with this call (see perl_lsp.c). */ + CBMArena idx_arena; + cbm_arena_init(&idx_arena); + cbm_registry_finalize_into(®, &idx_arena); + + /* Phase B.5: PASS 1.5 — instance-variable types (needs the finished + * class table + registry for constant resolution). */ + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ruby_ivar_scan(&ctx, root); + + /* Phase C: PASS 2 — resolution walk. */ + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ctx.enclosing_func_qn = NULL; + ctx.in_singleton_method = false; + ruby_resolve_calls_in_node(&ctx, root); + + if (ctx.debug) { + fprintf(stderr, "[ruby_lsp] module_qn=%s defs=%d resolved=%d classes=%d mixins=%d\n", + module_qn ? module_qn : "(null)", result->defs.count, result->resolved_calls.count, + ctx.class_count, ctx.mixin_count); + for (int i = 0; i < result->resolved_calls.count; i++) { + CBMResolvedCall *r = &result->resolved_calls.items[i]; + fprintf(stderr, "[ruby_lsp] %s -> %s [%s %.2f]\n", r->caller_qn, r->callee_qn, + r->strategy, r->confidence); + } + } + + cbm_arena_destroy(&idx_arena); +} diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h new file mode 100644 index 000000000..f6de32943 --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.h @@ -0,0 +1,148 @@ +#ifndef CBM_LSP_RUBY_LSP_H +#define CBM_LSP_RUBY_LSP_H + +#include "type_rep.h" +#include "scope.h" +#include "type_registry.h" +#include "../cbm.h" + +/* Ruby mixin kinds. Lookup order for an instance method on class C: + * prepended modules (last prepended first) → C's own methods → + * included modules (last included first) → superclass chain. + * `extend` adds a module's instance methods to C's singleton (class-side). */ +typedef enum { + RUBY_MIXIN_INCLUDE = 0, + RUBY_MIXIN_PREPEND, + RUBY_MIXIN_EXTEND, +} RubyMixinKind; + +/* One class/module discovered in the file (or, cross-file, in the project). + * `path` is the dotted source-level constant path (e.g. "Admin.User" for + * `Admin::User`); `qn` is the full graph QN (module_qn.path for file-local + * classes; the extractor QN for cross-file defs). `superclass_qn` is resolved + * against the class table + stdlib after collection; NULL when unknown. */ +typedef struct { + const char *path; + const char *qn; + const char *superclass_ref; /* raw dotted superclass spelling, or NULL */ + const char *superclass_qn; /* resolved QN (table/stdlib), or NULL */ + bool is_module; /* `module` (mixin source) vs `class` */ +} RubyClassInfo; + +/* One `include`/`prepend`/`extend` record: owner class QN → module ref. */ +typedef struct { + const char *owner_qn; /* class/module whose body held the call */ + const char *module_ref; /* raw dotted module spelling */ + const char *module_qn; /* resolved QN, or NULL */ + RubyMixinKind kind; +} RubyMixinInfo; + +/* One inferred instance-variable type: (class QN, @name) → type QN. + * `conflicted` latches when two assignments disagree — lookup then returns + * nothing (zero-edge guarantee beats a coin-flip type). */ +typedef struct { + const char *class_qn; + const char *ivar_name; /* including the leading '@' */ + const char *type_qn; + bool conflicted; +} RubyIvarInfo; + +/* RubyLSPContext — per-file state for Ruby type-aware call resolution. + * Mirrors PerlLSPContext / PHPLSPContext structure. + * + * Ruby specifics that shape this context: + * - Classes/modules nest lexically (`module A; class B`), and constants + * resolve through the lexical nesting outward (A::B sees A's constants + * before top-level ones). `nesting` tracks the current dotted path. + * - Inheritance is `class C < Base`; mixins arrive via include/prepend/ + * extend and participate in method lookup (see RubyMixinKind). + * - `Foo.new` constructs a Foo and dispatches to Foo#initialize. + * - `def m` defines an instance method; `def self.m` a singleton (class) + * method. Singleton methods are registered under the receiver key + * ".self" — constants cannot be lowercase, so the suffix can + * never collide with a real nested-constant path. */ +typedef struct { + CBMArena *arena; + const char *source; + int source_len; + const CBMTypeRegistry *registry; + /* Mutable alias of `registry`, set only while PASS 1 populates method + * tables (mirrors perl_lsp's mutable-reg build phase). NULL afterwards. */ + CBMTypeRegistry *build_reg; + CBMScope *current_scope; + + /* Lexical nesting: dotted constant path of the enclosing class/module + * chain ("" at top level, "A.B" inside `module A; class B`). */ + const char *nesting; + + /* Class/module table (pass 1). */ + RubyClassInfo *classes; + int class_count; + int class_cap; + + /* Mixin table (pass 1). */ + RubyMixinInfo *mixins; + int mixin_count; + int mixin_cap; + + /* Instance-variable type table (pass 1). */ + RubyIvarInfo *ivars; + int ivar_count; + int ivar_cap; + + /* Cross-file import map (require/require_relative resolved by the + * pipeline). Unused per-file: Ruby constants are file-global. */ + const char **import_names; + const char **import_qns; + int import_count; + + /* Current enclosing context during the resolution walk. */ + const char *enclosing_class_qn; /* class QN, or NULL at top level */ + const char *enclosing_func_qn; /* enclosing method QN, or NULL */ + bool in_singleton_method; /* `def self.m` / class << self scope */ + const char *module_qn; + + /* Output: resolved calls accumulate here. */ + CBMResolvedCallArray *resolved_calls; + + /* Recursion guards. */ + int eval_depth; + int walk_depth; + + /* Debug mode (CBM_LSP_DEBUG env). */ + bool debug; +} RubyLSPContext; + +/* Initialize a RubyLSPContext for processing one file. */ +void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len, + const CBMTypeRegistry *registry, const char *module_qn, + CBMResolvedCallArray *out); + +/* Resolve a dotted constant path (e.g. "Foo", "A.B") against the class + * table using the current lexical nesting, then the stdlib. Returns the + * resolved QN or NULL. */ +const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path); + +/* Look up an instance method on a class, walking prepends → own → includes → + * superclass chain. Returns the resolved CBMRegisteredFunc or NULL. */ +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name); + +/* Look up a singleton (class-side) method: own singleton methods → + * `extend`ed modules' instance methods → superclass singleton chain. */ +const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name); + +/* Evaluate a Ruby expression's type. May return CBM_TYPE_UNKNOWN. */ +const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node); + +/* Entry point: build registry from file defs + stdlib, then run resolution. + * Called from cbm_extract_file() via the language dispatch in cbm.c. */ +void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root); + +/* Register Ruby core stdlib types/methods + a curated Rails surface + * (ActiveRecord/ActiveSupport/ActionController) into a registry. */ +void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena); + +#endif /* CBM_LSP_RUBY_LSP_H */ diff --git a/internal/cbm/lsp_all.c b/internal/cbm/lsp_all.c index 4ab1f5155..7cf05a0c4 100644 --- a/internal/cbm/lsp_all.c +++ b/internal/cbm/lsp_all.c @@ -14,6 +14,8 @@ #include "lsp/generated/php_stdlib_data.c" #include "lsp/perl_lsp.c" #include "lsp/generated/perl_stdlib_data.c" +#include "lsp/ruby_lsp.c" +#include "lsp/generated/ruby_stdlib_data.c" #include "lsp/generated/python_stdlib_data.c" #include "lsp/py_lsp.c" #include "lsp/ts_lsp.c" diff --git a/scripts/check-lsp-originality.sh b/scripts/check-lsp-originality.sh index a50af9de4..f02b9cc8b 100755 --- a/scripts/check-lsp-originality.sh +++ b/scripts/check-lsp-originality.sh @@ -27,7 +27,7 @@ # USAGE # bash scripts/check-lsp-originality.sh [--lang NAME] [--refresh] # [--list-candidates] [--help] -# --lang NAME scan against ONE reference only (py|ts|go|cs|c|java|kotlin|rust|php|perl) +# --lang NAME scan against ONE reference only (py|ts|go|cs|c|java|kotlin|rust|php|perl|ruby) # --refresh re-fetch reference sources even if cached # --list-candidates print the extracted local tokens and exit (no fetch; self-test) # Exit 0 = no verbatim overlap found. Exit 1 = overlap(s) to review by a human. @@ -64,6 +64,10 @@ REFS=( # Perl (PR #461) was authored clean-room. We scan against PerlNavigator (MIT) # — the leading OSS Perl language server — for defensive copy-detection. "perl|https://github.com/bscan/PerlNavigator|server/src" + # Ruby was authored clean-room from the tree-sitter-ruby grammar + Ruby + # language semantics. We scan against Shopify's ruby-lsp (MIT) — the + # leading OSS Ruby language server — for defensive copy-detection. + "ruby|https://github.com/Shopify/ruby-lsp|lib" ) ONLY_LANG=""; REFRESH=0; LIST_ONLY=0 diff --git a/tests/test_main.c b/tests/test_main.c index ba6f26d45..883ba4ed6 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -733,6 +733,7 @@ extern void suite_php_lsp(void); extern void suite_cs_lsp(void); extern void suite_cs_lsp_bench(void); extern void suite_perl_lsp(void); +extern void suite_ruby_lsp(void); extern void suite_scope(void); extern void suite_type_rep(void); extern void suite_py_lsp(void); @@ -1026,6 +1027,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(cs_lsp); RUN_SELECTED_SUITE_PERF(cs_lsp_bench); RUN_SELECTED_SUITE(perl_lsp); + RUN_SELECTED_SUITE(ruby_lsp); RUN_SELECTED_SUITE(py_lsp); RUN_SELECTED_SUITE(kotlin_lsp); RUN_SELECTED_SUITE(rust_lsp); diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c new file mode 100644 index 000000000..23c37314e --- /dev/null +++ b/tests/test_ruby_lsp.c @@ -0,0 +1,636 @@ +/* + * test_ruby_lsp.c — Tests for the Ruby Light Semantic Pass. + * + * Coverage mirrors tests/test_perl_lsp.c / tests/test_php_lsp.c, exercising + * the foundational Ruby resolution scenarios: + * 1. QN contract (defs carry module_qn.ClassPath.method — the join key + * the resolver's emitted rows depend on) + * 2. Constructor typing + edge (a = Animal.new; a.speak) + * 3. Constructor edge targets CLASS (textual `Widget.new` -> "Widget") + * 4. Implicit/explicit self dispatch + * 5. Singleton (class) methods (def self.m; Animal.m) + * 6. Superclass chain lookup (class B < A) + * 7. include + prepend mixin lookup (prepend shadows the class's own) + * 8. extend mixin (class-side) lookup + * 9. super dispatch + * 10. Instance-variable typing (@thing = Foo.new; @thing.bar) + * 11. Chained call typing (Foo.new.bar) + * 12. Nested modules (lexical nesting + `A::B` references) + * 13. ActiveRecord model typing (User.find(1).full_name) + * 14. Top-level function calls + * 15. Unresolvable receiver emits NO spurious edge (negative) + * 16. send() / the whole dynamic-dispatch family emits NO edge (negative) + * + * Scope: every row here is SINGLE-FILE, driven through cbm_extract_file so it + * exercises the per-file resolver exactly as the extraction pipeline calls it. + * Cross-file resolution (cbm_run_ruby_lsp_cross, wired through + * pass_lsp_cross.c) is a separate tier and is covered by its own rows. + * + * The resolver populates result->resolved_calls with CBMResolvedCall rows. + * Ruby defs weave the class path into the QN (module_qn.Animal.speak), so + * for these single-file fixtures ("test"/"main.rb" -> module QN test.main) + * callee fragments like "main.Animal.speak" are unique join keys. + */ +#include "test_framework.h" +#include "cbm.h" +#include "../src/pipeline/lsp_resolve.h" +#include "lsp/ruby_lsp.h" +#include + +/* ── Helpers (mirror test_perl_lsp.c) ──────────────────────────── */ + +static CBMFileResult *extract_ruby(const char *source) { + return cbm_extract_file(source, (int)strlen(source), CBM_LANG_RUBY, "test", "main.rb", 0, NULL, + NULL); +} + +static int find_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && strstr(rc->caller_qn, callerSub) && rc->callee_qn && + strstr(rc->callee_qn, calleeSub)) + return i; + } + return -1; +} + +static int require_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { + int idx = find_resolved(r, callerSub, calleeSub); + if (idx < 0) { + printf(" MISSING resolved call: caller~%s -> callee~%s (have %d)\n", callerSub, calleeSub, + r->resolved_calls.count); + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)", + rc->callee_qn ? rc->callee_qn : "(null)", rc->strategy ? rc->strategy : "(null)", + rc->confidence); + } + } + return idx; +} + +static const CBMResolvedCall *find_resolved_with_strategy(const CBMFileResult *r, + const char *callerSub, + const char *calleeSub, + const char *strategy) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (!rc->caller_qn || !rc->callee_qn) + continue; + if (!strstr(rc->caller_qn, callerSub)) + continue; + if (!strstr(rc->callee_qn, calleeSub)) + continue; + if (strategy && (!rc->strategy || strcmp(rc->strategy, strategy) != 0)) + continue; + return rc; + } + return NULL; +} + +static const CBMDefinition *find_def(const CBMFileResult *r, const char *label, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->label && d->name && strcmp(d->label, label) == 0 && strcmp(d->name, name) == 0) + return d; + } + return NULL; +} + +/* ── 1. QN contract: defs weave the class path into method QNs ─── */ + +TEST(rubylsp_qn_contract) { + const char *src = "class Animal\n" + " def speak\n" + " 'woof'\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMDefinition *m = find_def(r, "Method", "speak"); + if (!m) { + printf(" no Method speak def; defs:\n"); + for (int i = 0; i < r->defs.count; i++) + printf(" [%s] %s qn=%s parent=%s\n", r->defs.items[i].label, r->defs.items[i].name, + r->defs.items[i].qualified_name, + r->defs.items[i].parent_class ? r->defs.items[i].parent_class : "(null)"); + } + ASSERT(m); + if (strcmp(m->qualified_name, "test.main.Animal.speak") != 0) { + printf(" QN scheme mismatch: got %s (resolver assumes module_qn.ClassPath.method)\n", + m->qualified_name); + } + ASSERT(strcmp(m->qualified_name, "test.main.Animal.speak") == 0); + cbm_free_result(r); + PASS(); +} + +/* ── 2. Constructor typing: a = Animal.new; a.speak ────────────── */ + +TEST(rubylsp_method_via_constructor_assignment) { + const char *src = "class Animal\n" + " def initialize(name)\n" + " @name = name\n" + " end\n" + " def speak\n" + " @name\n" + " end\n" + "end\n" + "class Runner\n" + " def run\n" + " a = Animal.new('rex')\n" + " a.speak\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Runner.run", "Animal.speak") >= 0); + ASSERT(find_resolved_with_strategy(r, "Runner.run", "Animal.speak", "ruby_method_typed")); + cbm_free_result(r); + PASS(); +} + +/* ── 3. Constructor edge targets the CLASS node ─────────────────── */ + +TEST(rubylsp_constructor_edge_targets_class) { + const char *src = "class Widget\n" + " def initialize\n" + " end\n" + "end\n" + "class Maker\n" + " def build\n" + " Widget.new\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "Maker.build", "Widget", "ruby_constructor"); + ASSERT(rc); + /* Callee is the class QN (leaf "Widget"), matching the textual + * extractor's Widget.new -> "Widget" rewrite so the row joins. */ + ASSERT(strcmp(rc->callee_qn, "test.main.Widget") == 0); + ASSERT(rc->confidence >= CBM_LSP_CONFIDENCE_FLOOR); + cbm_free_result(r); + PASS(); +} + +/* ── 4. Implicit + explicit self dispatch ───────────────────────── */ + +TEST(rubylsp_self_dispatch) { + const char *src = "class Greeter\n" + " def greet\n" + " build_greeting('hi')\n" + " self.build_greeting('yo')\n" + " end\n" + " def build_greeting(word)\n" + " word\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Greeter.greet", "Greeter.build_greeting") >= 0); + ASSERT(find_resolved_with_strategy(r, "Greeter.greet", "Greeter.build_greeting", + "ruby_self_dispatch")); + cbm_free_result(r); + PASS(); +} + +/* ── 5. Singleton (class) methods ───────────────────────────────── */ + +TEST(rubylsp_singleton_method_dispatch) { + const char *src = "class Registry\n" + " def self.register(key)\n" + " key\n" + " end\n" + "end\n" + "class App\n" + " def boot\n" + " Registry.register(:db)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.boot", "Registry.register") >= 0); + ASSERT(find_resolved_with_strategy(r, "App.boot", "Registry.register", "ruby_class_method")); + cbm_free_result(r); + PASS(); +} + +/* Singleton methods must NOT satisfy instance dispatch (and vice versa). */ +TEST(rubylsp_singleton_instance_split) { + const char *src = "class Config\n" + " def self.load\n" + " 1\n" + " end\n" + "end\n" + "class App\n" + " def boot\n" + " c = Config.new\n" + " c.load\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* c.load is an INSTANCE call; Config only defines a singleton `load`. + * Zero-edge guarantee: no resolved row may bind them. */ + ASSERT(find_resolved(r, "App.boot", "Config.load") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 6. Superclass chain ────────────────────────────────────────── */ + +TEST(rubylsp_inheritance) { + const char *src = "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " def work\n" + " helper\n" + " self.helper\n" + " end\n" + "end\n" + "class Driver\n" + " def drive\n" + " c = Child.new\n" + " c.helper\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Inherited method resolves to Base.helper from both dispatch shapes. */ + ASSERT(require_resolved(r, "Child.work", "Base.helper") >= 0); + ASSERT(require_resolved(r, "Driver.drive", "Base.helper") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7. include mixin ───────────────────────────────────────────── */ + +TEST(rubylsp_include_mixin) { + const char *src = "module Greetable\n" + " def greet\n" + " 'hello'\n" + " end\n" + "end\n" + "class Person\n" + " include Greetable\n" + " def hail\n" + " greet()\n" + " end\n" + "end\n" + "class Caller\n" + " def run\n" + " p = Person.new\n" + " p.greet\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Person.hail", "Greetable.greet") >= 0); + ASSERT(require_resolved(r, "Caller.run", "Greetable.greet") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7b. prepend mixin shadows the class's own method ───────────── */ + +/* Ruby's lookup order puts prepended modules AHEAD of the class itself, which + * is the whole point of `prepend` over `include`. Both an own method and a + * prepended one exist here, so a resolver that walked the class first would + * still emit an edge — just the wrong one. Pin the winner, not merely that + * something resolved. */ +TEST(rubylsp_prepend_mixin) { + const char *src = "module Audited\n" + " def save\n" + " 'audited'\n" + " end\n" + "end\n" + "class Record\n" + " prepend Audited\n" + " def save\n" + " 'plain'\n" + " end\n" + "end\n" + "class App\n" + " def run\n" + " r = Record.new\n" + " r.save\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* The prepended module wins. */ + ASSERT(require_resolved(r, "App.run", "Audited.save") >= 0); + /* And the shadowed own method must NOT also be emitted. */ + ASSERT(find_resolved(r, "App.run", "Record.save") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 8. extend mixin (class-side) ───────────────────────────────── */ + +TEST(rubylsp_extend_mixin) { + const char *src = "module Findable\n" + " def locate(id)\n" + " id\n" + " end\n" + "end\n" + "class Record\n" + " extend Findable\n" + "end\n" + "class App\n" + " def run\n" + " Record.locate(7)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.run", "Findable.locate") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 9. super dispatch ──────────────────────────────────────────── */ + +TEST(rubylsp_super_dispatch) { + const char *src = "class Base\n" + " def setup\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " def setup\n" + " super\n" + " 2\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "Child.setup", "Base.setup", "ruby_method_super"); + if (!rc) + (void)require_resolved(r, "Child.setup", "Base.setup"); + ASSERT(rc); + cbm_free_result(r); + PASS(); +} + +/* ── 10. Instance-variable typing ───────────────────────────────── */ + +TEST(rubylsp_ivar_typing) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class Car\n" + " def initialize\n" + " @engine = Engine.new\n" + " end\n" + " def drive\n" + " @engine.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Car.drive", "Engine.start") >= 0); + ASSERT(find_resolved_with_strategy(r, "Car.drive", "Engine.start", "ruby_ivar_method")); + cbm_free_result(r); + PASS(); +} + +/* Conflicting ivar assignments must suppress the type (zero-edge). */ +TEST(rubylsp_ivar_conflict_no_edge) { + const char *src = "class A\n" + " def go\n" + " 1\n" + " end\n" + "end\n" + "class B\n" + " def go\n" + " 2\n" + " end\n" + "end\n" + "class Holder\n" + " def initialize(flag)\n" + " @x = A.new\n" + " @x = B.new\n" + " end\n" + " def run\n" + " @x.go\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Holder.run", ".go") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 11. Chained calls ──────────────────────────────────────────── */ + +TEST(rubylsp_chained_constructor_call) { + const char *src = "class Builder\n" + " def finish\n" + " 1\n" + " end\n" + "end\n" + "class App\n" + " def run\n" + " Builder.new.finish\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.run", "Builder.finish") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 12. Nested modules + lexical nesting ───────────────────────── */ + +TEST(rubylsp_nested_modules) { + const char *src = "module Admin\n" + " class User\n" + " def self.lookup(id)\n" + " id\n" + " end\n" + " def name\n" + " 'n'\n" + " end\n" + " end\n" + " class Panel\n" + " def show\n" + " User.lookup(1)\n" + " u = User.new\n" + " u.name\n" + " end\n" + " end\n" + "end\n" + "class Outside\n" + " def probe\n" + " Admin::User.lookup(2)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Lexical reference from sibling class inside the module. */ + ASSERT(require_resolved(r, "Panel.show", "User.lookup") >= 0); + ASSERT(require_resolved(r, "Panel.show", "User.name") >= 0); + /* Fully-qualified reference from outside. */ + ASSERT(require_resolved(r, "Outside.probe", "User.lookup") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 13. ActiveRecord model typing ──────────────────────────────── */ + +TEST(rubylsp_activerecord_model_typing) { + const char *src = "class User < ApplicationRecord\n" + " def full_name\n" + " 'x'\n" + " end\n" + "end\n" + "class UsersController < ApplicationController\n" + " def show\n" + " u = User.find(1)\n" + " u.full_name\n" + " end\n" + " def index\n" + " User.where(active: true).first.full_name\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* User.find returns User (AR query typing); u.full_name resolves. */ + ASSERT(require_resolved(r, "UsersController.show", "User.full_name") >= 0); + /* Relation approximation keeps the model type through where/first. */ + ASSERT(require_resolved(r, "UsersController.index", "User.full_name") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 14. Top-level functions ────────────────────────────────────── */ + +TEST(rubylsp_top_level_function) { + const char *src = "def helper(x)\n" + " x\n" + "end\n" + "def run\n" + " helper(1)\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "main.run", "main.helper", "ruby_function_local"); + if (!rc) + (void)require_resolved(r, "main.run", "main.helper"); + ASSERT(rc); + cbm_free_result(r); + PASS(); +} + +/* ── 15. Negative: unresolvable receiver emits no edge ──────────── */ + +TEST(rubylsp_unknown_receiver_no_edge) { + const char *src = "class Safe\n" + " def process(payload)\n" + " payload.transform\n" + " mystery = fetch_thing\n" + " mystery.explode\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Neither the untyped parameter nor the unresolved local may produce + * a resolved row. */ + ASSERT(find_resolved(r, "Safe.process", "transform") < 0); + ASSERT(find_resolved(r, "Safe.process", "explode") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 16. Negative: dynamic dispatch emits no edge ───────────────── */ + +TEST(rubylsp_send_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def invoke\n" + " t = Target.new\n" + " t.send(:hidden)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.invoke", "hidden") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 16b. Negative: the whole dynamic-dispatch family emits no edge ── */ + +/* `send` has its own row above. The zero-edge guarantee is advertised for the + * whole reflective family, so pin the rest of it too: a resolver that treated + * any of these as an ordinary call would invent an edge to `hidden`. */ +TEST(rubylsp_dynamic_dispatch_family_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def via_public_send\n" + " Target.new.public_send(:hidden)\n" + " end\n" + " def via_underscore_send\n" + " Target.new.__send__(:hidden)\n" + " end\n" + " def via_define_method\n" + " Target.define_method(:hidden) { 2 }\n" + " end\n" + " def via_instance_eval\n" + " Target.new.instance_eval { hidden }\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.via_public_send", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_underscore_send", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_define_method", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_instance_eval", "hidden") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── suite ──────────────────────────────────────────────────────── */ + +void suite_ruby_lsp(void) { + RUN_TEST(rubylsp_qn_contract); + RUN_TEST(rubylsp_method_via_constructor_assignment); + RUN_TEST(rubylsp_constructor_edge_targets_class); + RUN_TEST(rubylsp_self_dispatch); + RUN_TEST(rubylsp_singleton_method_dispatch); + RUN_TEST(rubylsp_singleton_instance_split); + RUN_TEST(rubylsp_inheritance); + RUN_TEST(rubylsp_include_mixin); + RUN_TEST(rubylsp_prepend_mixin); + RUN_TEST(rubylsp_extend_mixin); + RUN_TEST(rubylsp_super_dispatch); + RUN_TEST(rubylsp_ivar_typing); + RUN_TEST(rubylsp_ivar_conflict_no_edge); + RUN_TEST(rubylsp_chained_constructor_call); + RUN_TEST(rubylsp_nested_modules); + RUN_TEST(rubylsp_activerecord_model_typing); + RUN_TEST(rubylsp_top_level_function); + RUN_TEST(rubylsp_unknown_receiver_no_edge); + RUN_TEST(rubylsp_send_no_edge); + RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); +} From 358d6e76ab749a7808ce02089b1b5b8d6431fc82 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Thu, 20 Aug 2026 16:29:19 +0200 Subject: [PATCH 02/61] fix(mcp): recover unsent requests after daemon loss Signed-off-by: Pxx500 --- src/daemon/application.c | 27 +-- src/daemon/application.h | 5 +- src/daemon/frontend.c | 183 +++++++++++++++----- src/daemon/frontend.h | 22 ++- src/daemon/runtime.c | 14 +- src/daemon/runtime.h | 5 +- src/main.c | 60 +++++-- tests/test_daemon_frontend.c | 322 +++++++++++++++++++++++++++++++---- tests/test_daemon_runtime.c | 27 ++- 9 files changed, 547 insertions(+), 118 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 69ff52ec7..58fb6bb31 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -3116,7 +3116,7 @@ cbm_daemon_runtime_application_callbacks_t cbm_daemon_application_runtime_callba static cbm_daemon_runtime_application_status_t application_client_exchange_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, uint8_t *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms) { + uint32_t *response_length_out, bool *request_sent_out, uint32_t timeout_ms) { uint8_t *response = NULL; uint32_t response_length = 0; if (response_out) { @@ -3125,13 +3125,16 @@ static cbm_daemon_runtime_application_status_t application_client_exchange_tagge if (response_length_out) { *response_length_out = 0; } + if (request_sent_out) { + *request_sent_out = false; + } cbm_daemon_runtime_application_status_t status = request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID ? cbm_daemon_runtime_client_application_request(client, request, request_length, &response, &response_length, timeout_ms) - : cbm_daemon_runtime_client_application_request_tagged(client, request_token, request, - request_length, &response, - &response_length, timeout_ms); + : cbm_daemon_runtime_client_application_request_tagged( + client, request_token, request, request_length, &response, &response_length, + request_sent_out, timeout_ms); free(request); if (status != CBM_DAEMON_RUNTIME_APPLICATION_OK) { free(response); @@ -3162,7 +3165,7 @@ static cbm_daemon_runtime_application_status_t application_client_exchange( uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { return application_client_exchange_tagged(client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, request, request_length, response_out, - response_length_out, timeout_ms); + response_length_out, NULL, timeout_ms); } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_context( @@ -3290,7 +3293,7 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_ui_readine static cbm_daemon_runtime_application_status_t application_client_text_request_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, cbm_daemon_application_request_kind_t kind, const char *text, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms) { + uint32_t *response_length_out, bool *request_sent_out, uint32_t timeout_ms) { if (!client || !text || !text[0]) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } @@ -3306,7 +3309,7 @@ static cbm_daemon_runtime_application_status_t application_client_text_request_t memcpy(request + 1, text, text_length); return application_client_exchange_tagged(client, request_token, request, (uint32_t)text_length + 1U, response_out, - response_length_out, timeout_ms); + response_length_out, request_sent_out, timeout_ms); } static cbm_daemon_runtime_application_status_t application_client_text_request( @@ -3314,7 +3317,7 @@ static cbm_daemon_runtime_application_status_t application_client_text_request( const char *text, uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { return application_client_text_request_tagged( client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, kind, text, response_out, - response_length_out, timeout_ms); + response_length_out, NULL, timeout_ms); } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( @@ -3327,10 +3330,10 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const char *message, uint8_t **response_out, uint32_t *response_length_out, - uint32_t timeout_ms) { - return application_client_text_request_tagged(client, request_token, - CBM_DAEMON_APPLICATION_REQUEST_MCP, message, - response_out, response_length_out, timeout_ms); + bool *request_sent_out, uint32_t timeout_ms) { + return application_client_text_request_tagged( + client, request_token, CBM_DAEMON_APPLICATION_REQUEST_MCP, message, response_out, + response_length_out, request_sent_out, timeout_ms); } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_tool( diff --git a/src/daemon/application.h b/src/daemon/application.h index 933186aa7..b071ea03c 100644 --- a/src/daemon/application.h +++ b/src/daemon/application.h @@ -149,11 +149,12 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( cbm_daemon_runtime_client_t *client, const char *message, uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms); -/* Cancellable frontend variant using a token reserved on the runtime client. */ +/* Cancellable frontend variant using a token reserved on the runtime client. + * request_sent_out preserves the runtime's application-frame delivery fact. */ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const char *message, uint8_t **response_out, uint32_t *response_length_out, - uint32_t timeout_ms); + bool *request_sent_out, uint32_t timeout_ms); cbm_daemon_runtime_application_status_t cbm_daemon_application_client_tool( cbm_daemon_runtime_client_t *client, const char *tool_name, const char *args_json, diff --git a/src/daemon/frontend.c b/src/daemon/frontend.c index 2dd986901..92b032686 100644 --- a/src/daemon/frontend.c +++ b/src/daemon/frontend.c @@ -76,6 +76,7 @@ typedef struct { cbm_mutex_t mutex; cbm_daemon_runtime_client_t *client; cbm_version_cohort_manager_t *cohort_manager; + const cbm_daemon_frontend_session_config_t *session; FILE *out; frontend_item_t queue[FRONTEND_QUEUE_CAPACITY]; size_t head; @@ -89,6 +90,7 @@ typedef struct { int64_t active_id; const char *active_id_str; cbm_daemon_runtime_application_token_t active_request_token; + bool active_cancelled; bool failed; /* Monotonic count of fully processed queue items (responses written or * cancellations acknowledged). The EOF drain below watches it to tell a @@ -352,6 +354,7 @@ static bool frontend_pop_begin(frontend_state_t *state, frontend_item_t *item) { state->active_id_str = item->id_str; item->request_token = request_token; state->active_request_token = request_token; + state->active_cancelled = false; popped = true; } cbm_mutex_unlock(&state->mutex); @@ -365,6 +368,7 @@ static void frontend_end_request(frontend_state_t *state, bool failed) { state->active_id = 0; state->active_id_str = NULL; state->active_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + state->active_cancelled = false; state->failed = state->failed || failed; cbm_mutex_unlock(&state->mutex); } @@ -446,12 +450,8 @@ typedef enum { * request routes its exact runtime token without closing the authenticated * session. A queued request is marked and receives a cancellation error * without ever reaching the daemon. Stale/invalid targets are ignored. */ -static frontend_cancellation_route_t frontend_route_cancellation( - frontend_state_t *state, const char *message, - cbm_daemon_runtime_application_token_t *request_token_out) { - if (request_token_out) { - *request_token_out = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; - } +static frontend_cancellation_route_t frontend_route_cancellation(frontend_state_t *state, + const char *message) { cbm_jsonrpc_request_t request = {0}; if (!frontend_parse_cancellation(message, &request)) { return FRONTEND_CANCELLATION_NONE; @@ -460,12 +460,9 @@ static frontend_cancellation_route_t frontend_route_cancellation( frontend_cancellation_route_t route = FRONTEND_CANCELLATION_STALE; cbm_mutex_lock(&state->mutex); if (state->in_request && state->active_has_id && - state->active_request_token != CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && cbm_mcp_cancel_request_matches(request.params_raw, state->active_id, state->active_id_str)) { - if (request_token_out) { - *request_token_out = state->active_request_token; - } + state->active_cancelled = true; route = FRONTEND_CANCELLATION_ACTIVE; } else { for (size_t offset = 0; offset < state->count; offset++) { @@ -484,6 +481,89 @@ static frontend_cancellation_route_t frontend_route_cancellation( return route; } +static cbm_daemon_runtime_cancel_result_t frontend_cancel_active(frontend_state_t *state) { + cbm_mutex_lock(&state->mutex); + cbm_daemon_runtime_cancel_result_t result = CBM_DAEMON_RUNTIME_CANCEL_STALE; + if (state->in_request && state->active_cancelled && state->client && + state->active_request_token != CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + result = cbm_daemon_runtime_client_application_cancel(state->client, + state->active_request_token); + } else if (state->in_request && state->active_cancelled) { + /* A reconnect has no daemon request to cancel yet. The worker observes + * active_cancelled before retrying the frame. */ + result = CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; + } + cbm_mutex_unlock(&state->mutex); + return result; +} + +static bool frontend_configure_client(cbm_daemon_runtime_client_t *client, + const cbm_daemon_frontend_session_config_t *session) { + uint32_t timeout_ms = session->bootstrap.connect_timeout_ms; + if (cbm_daemon_application_client_set_context( + client, session->session_root, session->allowed_root, session->tool_profile, NULL, NULL, + timeout_ms) != CBM_DAEMON_RUNTIME_APPLICATION_OK) { + return false; + } + return session->ui_update_mask == 0 || + cbm_daemon_application_client_set_ui_config( + client, session->ui_update_mask, session->ui_enabled, session->ui_port, + timeout_ms) == CBM_DAEMON_RUNTIME_APPLICATION_OK; +} + +static bool frontend_recover_client(frontend_state_t *state, frontend_item_t *item, + bool *cancelled_out) { + if (cancelled_out) { + *cancelled_out = false; + } + + cbm_mutex_lock(&state->mutex); + if (state->stopping || !state->client) { + cbm_mutex_unlock(&state->mutex); + return false; + } + cbm_daemon_runtime_client_t *previous = state->client; + state->client = NULL; + state->active_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + cbm_mutex_unlock(&state->mutex); + if (previous) { + (void)cbm_daemon_runtime_client_close(previous, FRONTEND_CLOSE_TIMEOUT_MS); + } + + cbm_daemon_bootstrap_result_t bootstrap = {0}; + cbm_daemon_bootstrap_status_t bootstrap_status = + cbm_daemon_bootstrap_execute(&state->session->bootstrap, &bootstrap); + cbm_daemon_runtime_client_t *replacement = + bootstrap_status == CBM_DAEMON_BOOTSTRAP_CONNECTED ? bootstrap.client : NULL; + bool configured = replacement && frontend_configure_client(replacement, state->session); + cbm_daemon_runtime_application_token_t replacement_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + + cbm_mutex_lock(&state->mutex); + bool cancelled = state->active_cancelled; + bool usable = configured && !state->stopping && !state->failed; + if (usable && !cancelled) { + usable = + cbm_daemon_runtime_client_application_token_reserve(replacement, &replacement_token); + } + if (usable) { + state->client = replacement; + if (!cancelled) { + state->active_request_token = replacement_token; + item->request_token = replacement_token; + } + if (cancelled_out) { + *cancelled_out = cancelled; + } + } + cbm_mutex_unlock(&state->mutex); + + if (!usable && replacement) { + (void)cbm_daemon_runtime_client_close(replacement, FRONTEND_CLOSE_TIMEOUT_MS); + } + return usable; +} + static void *frontend_worker(void *opaque) { frontend_state_t *state = opaque; for (;;) { @@ -504,10 +584,27 @@ static void *frontend_worker(void *opaque) { if (item.cancelled) { failed = !frontend_write_cancelled_response(state->out, &item); } else { + bool request_sent = false; cbm_daemon_runtime_application_status_t status = - cbm_daemon_application_client_mcp_tagged(state->client, item.request_token, - item.message, &response, &response_length, - FRONTEND_REQUEST_TIMEOUT_MS); + cbm_daemon_application_client_mcp_tagged( + state->client, item.request_token, item.message, &response, &response_length, + &request_sent, FRONTEND_REQUEST_TIMEOUT_MS); + if (status == CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && !request_sent) { + free(response); + response = NULL; + response_length = 0; + bool cancelled = false; + if (frontend_recover_client(state, &item, &cancelled)) { + if (cancelled) { + status = CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED; + } else { + bool retry_sent = false; + status = cbm_daemon_application_client_mcp_tagged( + state->client, item.request_token, item.message, &response, + &response_length, &retry_sent, FRONTEND_REQUEST_TIMEOUT_MS); + } + } + } if (status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) { failed = !frontend_write_cancelled_response(state->out, &item); } else { @@ -526,12 +623,11 @@ static void *frontend_worker(void *opaque) { atomic_fetch_add_explicit(&state->completed_items, 1, memory_order_release); if (failed) { if (!expected_stop) { - /* A failed daemon transport cannot wake a thread blocked in - * stdio portably. This frontend owns no state: immediate - * process exit closes the kernel IPC handle, which cancels - * daemon session ownership without a detached reader or an - * unsafe cross-thread fclose. Logging or flushing here could - * itself block on agent-owned stdout/stderr. */ + /* Recovery was unsafe or the one safe retry failed. Immediate + * process exit wakes an agent blocked on stdio and closes the + * kernel IPC handle without a detached reader or cross-thread + * fclose. Logging or flushing here could itself block on + * agent-owned stdout/stderr. */ _Exit(EXIT_FAILURE); } break; @@ -614,11 +710,11 @@ static bool frontend_enqueue(frontend_state_t *state, char *message, bool conten static bool frontend_stop_begin(frontend_state_t *state) { cbm_mutex_lock(&state->mutex); state->stopping = true; + cbm_daemon_runtime_client_t *client = state->client; + bool stop_begun = !client || cbm_daemon_runtime_client_close_begin(client); cbm_mutex_unlock(&state->mutex); - /* Retain the client allocation until the worker is joined. This covers the - * boundary where the worker has claimed an item but has not yet entered the - * runtime exchange: a late call observes closing instead of freed memory. */ - return cbm_daemon_runtime_client_close_begin(state->client); + /* NULL means recovery owns cleanup. Otherwise retain the client until join. */ + return stop_begun; } static bool frontend_cancel_for_maintenance(void *opaque) { @@ -628,24 +724,33 @@ static bool frontend_cancel_for_maintenance(void *opaque) { cbm_mutex_lock(&state->mutex); state->stopping = true; if (state->in_request) { + state->active_cancelled = true; request_token = state->active_request_token; } - cbm_mutex_unlock(&state->mutex); if (request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + cbm_mutex_unlock(&state->mutex); return false; } - return cbm_daemon_runtime_client_application_cancel(state->client, request_token) == - CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; + bool cancelled = state->client && + cbm_daemon_runtime_client_application_cancel(state->client, request_token) == + CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; + cbm_mutex_unlock(&state->mutex); + return cancelled; } int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, - cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out) { - if (!client || !cohort_manager || !in || !out) { + cbm_version_cohort_manager_t *cohort_manager, + const cbm_daemon_frontend_session_config_t *session, FILE *in, + FILE *out) { + if (!client || !cohort_manager || !session || !session->bootstrap.endpoint || + !session->bootstrap.identity || !session->session_root || !session->session_root[0] || + !in || !out) { return -1; } frontend_state_t state = { .client = client, .cohort_manager = cohort_manager, + .session = session, .out = out, }; cbm_mutex_init(&state.mutex); @@ -669,7 +774,7 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, } int result = 0; - bool close_begun = false; + bool stop_begun = false; bool clean_eof = false; for (;;) { char *message = NULL; @@ -681,18 +786,14 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, free(message); break; } - cbm_daemon_runtime_application_token_t cancel_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; - frontend_cancellation_route_t cancellation = - frontend_route_cancellation(&state, message, &cancel_token); + frontend_cancellation_route_t cancellation = frontend_route_cancellation(&state, message); if (cancellation != FRONTEND_CANCELLATION_NONE) { free(message); if (cancellation == FRONTEND_CANCELLATION_ACTIVE) { - cbm_daemon_runtime_cancel_result_t cancelled = - cbm_daemon_runtime_client_application_cancel(state.client, cancel_token); + cbm_daemon_runtime_cancel_result_t cancelled = frontend_cancel_active(&state); if (cancelled == CBM_DAEMON_RUNTIME_CANCEL_ERROR) { result = -1; - close_begun = frontend_stop_begin(&state); + stop_begun = frontend_stop_begin(&state); break; } } @@ -707,7 +808,7 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, } } - if (clean_eof && !close_begun) { + if (clean_eof && !stop_begun) { frontend_input_closed(&state); /* EOF ends INPUT, not accepted work: as long as queued items keep * completing, every already-enqueued request still receives its @@ -733,8 +834,8 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, } } } - if (!close_begun) { - close_begun = frontend_stop_begin(&state); + if (!stop_begun) { + stop_begun = frontend_stop_begin(&state); } frontend_join_watchdog_t watchdog; cbm_thread_t watchdog_thread; @@ -759,9 +860,9 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, if (!cbm_daemon_maintenance_monitor_stop(&maintenance_monitor)) { _Exit(EXIT_FAILURE); } - if (close_begun) { + if (stop_begun && state.client) { (void)cbm_daemon_runtime_client_close_finish(state.client, FRONTEND_CLOSE_TIMEOUT_MS); - } else { + } else if (!stop_begun) { result = -1; } for (size_t i = 0; i < FRONTEND_QUEUE_CAPACITY; i++) { diff --git a/src/daemon/frontend.h b/src/daemon/frontend.h index f23395774..10255687f 100644 --- a/src/daemon/frontend.h +++ b/src/daemon/frontend.h @@ -4,14 +4,29 @@ #ifndef CBM_DAEMON_FRONTEND_H #define CBM_DAEMON_FRONTEND_H +#include "daemon/bootstrap.h" #include "daemon/runtime.h" #include "daemon/version_cohort.h" +#include "mcp/mcp.h" #include #include typedef struct cbm_daemon_maintenance_monitor cbm_daemon_maintenance_monitor_t; +/* Everything the stdio frontend must replay when its authenticated daemon + * connection is replaced. Pointer fields are borrowed for the complete + * frontend call. */ +typedef struct { + cbm_daemon_bootstrap_config_t bootstrap; + const char *session_root; + const char *allowed_root; + cbm_mcp_tool_profile_t tool_profile; + uint8_t ui_update_mask; + bool ui_enabled; + int ui_port; +} cbm_daemon_frontend_session_config_t; + /* Called once when install/update/uninstall requests an active local command * to stop cooperatively. Returning false does not authorize the command to * outlive the bounded grace period. */ @@ -60,9 +75,12 @@ uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void); * either thread is blocked in stdio, requests cooperative cancellation for the * exact active request, and then bounds process exit. Kernel IPC close cancels * only this session's daemon work. EOF/parse failure closes the authenticated - * session. An unexpected daemon transport failure likewise terminates the + * session. A transport failure before the application frame is sent replaces + * the daemon client and retries once. Sent or repeated failures terminate the * process so an agent waiting with stdin still open observes server EOF. */ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, - cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out); + cbm_version_cohort_manager_t *cohort_manager, + const cbm_daemon_frontend_session_config_t *session, FILE *in, + FILE *out); #endif /* CBM_DAEMON_FRONTEND_H */ diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index 7db0f0225..c9eaeda49 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -3019,13 +3019,16 @@ cbm_daemon_runtime_cancel_result_t cbm_daemon_runtime_client_application_cancel( cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_request_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const void *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms) { + uint32_t *response_length_out, bool *request_sent_out, uint32_t timeout_ms) { if (response_out) { *response_out = NULL; } if (response_length_out) { *response_length_out = 0; } + if (request_sent_out) { + *request_sent_out = false; + } if (!client || !response_out || !response_length_out || request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || @@ -3081,6 +3084,9 @@ cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_re request_can_send && cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, (uint32_t)wire_length); + if (request_sent_out) { + *request_sent_out = sent; + } bool send_cancel = false; cbm_mutex_lock(&client->state_mutex); client->application_request_sent = sent; @@ -3179,9 +3185,9 @@ cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_re if (!cbm_daemon_runtime_client_application_token_reserve(client, &request_token)) { return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; } - return cbm_daemon_runtime_client_application_request_tagged(client, request_token, request, - request_length, response_out, - response_length_out, timeout_ms); + return cbm_daemon_runtime_client_application_request_tagged( + client, request_token, request, request_length, response_out, response_length_out, NULL, + timeout_ms); } bool cbm_daemon_runtime_client_close_begin(cbm_daemon_runtime_client_t *client) { diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index 4d53da9a4..15521ac83 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -399,11 +399,12 @@ cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_re /* Execute using the sole outstanding token returned by token_reserve(). This * is the cancellable frontend path; the legacy helper above reserves - * internally. */ + * internally. request_sent_out reports whether the application frame crossed + * the local transport, including failures while awaiting its response. */ cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_request_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const void *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms); + uint32_t *response_length_out, bool *request_sent_out, uint32_t timeout_ms); /* Begin a two-phase close without freeing the client. This atomically rejects * future exchanges and interrupts an exchange already in flight. Exactly one diff --git a/src/main.c b/src/main.c index 8a575c44f..98db00561 100644 --- a/src/main.c +++ b/src/main.c @@ -3073,38 +3073,54 @@ int main(int argc, char **argv) { cbm_daemon_ipc_endpoint_free(endpoint); if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap_result.client) { main_report_client_bootstrap_failure(role, &bootstrap_result); + cbm_daemon_ipc_endpoint_free(endpoint); (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); return EXIT_FAILURE; } g_daemon_client = bootstrap_result.client; - if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && - !main_set_client_context(g_daemon_client, NULL, tool_profile, NULL, NULL, - MAIN_CONNECT_TIMEOUT_MS)) { - (void)fprintf(stderr, "codebase-memory-mcp: daemon session context was rejected\n"); - (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); - g_daemon_client = NULL; - (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); - return EXIT_FAILURE; + char mcp_session_root[MAIN_PATH_CAP] = {0}; + char mcp_allowed_root[MAIN_PATH_CAP] = {0}; + const char *mcp_allowed_root_ptr = NULL; + if (role == CBM_DAEMON_PROCESS_MCP_CLIENT) { + bool context_resolved = + main_session_context(NULL, mcp_session_root, mcp_allowed_root, &mcp_allowed_root_ptr); + cbm_daemon_runtime_application_status_t context_status = + context_resolved + ? cbm_daemon_application_client_set_context(g_daemon_client, mcp_session_root, + mcp_allowed_root_ptr, tool_profile, + NULL, NULL, MAIN_CONNECT_TIMEOUT_MS) + : CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + if (context_status != CBM_DAEMON_RUNTIME_APPLICATION_OK) { + (void)fprintf(stderr, "codebase-memory-mcp: daemon session context was rejected\n"); + (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); + g_daemon_client = NULL; + cbm_daemon_ipc_endpoint_free(endpoint); + (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); + return EXIT_FAILURE; + } } /* Persist UI mutations only after the exact-build HELLO succeeds. A * conflicting binary must be observationally read-only: applying its * flags before bootstrap could reconfigure the already-running daemon * even though that client was then rejected. */ + bool mcp_ui_enabled = false; + int mcp_ui_port = 0; + uint8_t mcp_ui_update_mask = 0; if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && cbm_mcp_tool_profile_allows_http(tool_profile)) { - bool ui_enabled = false; - int ui_port = 0; bool explicitly_enabled = false; - uint8_t update_mask = - parse_ui_flags(argc, argv, &ui_enabled, &ui_port, &explicitly_enabled); - if (update_mask != 0 && cbm_daemon_application_client_set_ui_config( - g_daemon_client, update_mask, ui_enabled, ui_port, - MAIN_CONNECT_TIMEOUT_MS) != CBM_DAEMON_RUNTIME_APPLICATION_OK) { + mcp_ui_update_mask = + parse_ui_flags(argc, argv, &mcp_ui_enabled, &mcp_ui_port, &explicitly_enabled); + if (mcp_ui_update_mask != 0 && + cbm_daemon_application_client_set_ui_config( + g_daemon_client, mcp_ui_update_mask, mcp_ui_enabled, mcp_ui_port, + MAIN_CONNECT_TIMEOUT_MS) != CBM_DAEMON_RUNTIME_APPLICATION_OK) { (void)fprintf(stderr, "codebase-memory-mcp: daemon UI configuration update failed\n"); (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); g_daemon_client = NULL; + cbm_daemon_ipc_endpoint_free(endpoint); (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); return EXIT_FAILURE; } @@ -3119,14 +3135,26 @@ int main(int argc, char **argv) { (void)fprintf(stderr, "codebase-memory-mcp: parent-death watchdog could not start\n"); (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); g_daemon_client = NULL; + cbm_daemon_ipc_endpoint_free(endpoint); (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); return EXIT_FAILURE; } #endif setup_signal_handlers(); - int result = cbm_daemon_frontend_mcp_run(g_daemon_client, client_cohort_manager, stdin, stdout); + cbm_daemon_frontend_session_config_t frontend_session = { + .bootstrap = bootstrap_config, + .session_root = mcp_session_root, + .allowed_root = mcp_allowed_root_ptr, + .tool_profile = tool_profile, + .ui_update_mask = mcp_ui_update_mask, + .ui_enabled = mcp_ui_enabled, + .ui_port = mcp_ui_port, + }; + int result = cbm_daemon_frontend_mcp_run(g_daemon_client, client_cohort_manager, + &frontend_session, stdin, stdout); g_daemon_client = NULL; /* frontend consumed the handle */ + cbm_daemon_ipc_endpoint_free(endpoint); bool client_cohort_cleanup = main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); atomic_store(&g_shutdown, 1); diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c index ed0e868e9..7df0e3e9f 100644 --- a/tests/test_daemon_frontend.c +++ b/tests/test_daemon_frontend.c @@ -4,6 +4,7 @@ #include "test_framework.h" #include "test_helpers.h" +#include "daemon/application.h" #include "daemon/frontend.h" #include "daemon/ipc.h" #include "daemon/service.h" @@ -239,8 +240,18 @@ enum { * regression black-box: it must remain valid if the exact capacity changes * while still proving that overload cannot hide an already-pending EOF. */ FRONTEND_EOF_TEST_OVERFLOW_MESSAGES = 32, + /* Hold replacement context past the production frontend's 15 second + * no-progress EOF + drain so shutdown deterministically overlaps recovery. */ + FRONTEND_RECOVERY_EOF_RELEASE_MS = 16000, }; +typedef enum { + FRONTEND_RECOVERY_RETRY = 0, + FRONTEND_RECOVERY_CANCEL = 1, + FRONTEND_RECOVERY_CLEAN_EOF = 2, +} frontend_recovery_mode_t; + typedef struct { atomic_int requests; atomic_int session_cancels; @@ -268,6 +279,7 @@ typedef struct { typedef struct { int fd; bool overflow; + frontend_recovery_mode_t recovery_mode; frontend_eof_application_context_t *application; atomic_bool finished; atomic_bool succeeded; @@ -275,7 +287,9 @@ typedef struct { typedef struct { char conflict_log[FRONTEND_TEST_PATH_CAP]; + char build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; cbm_daemon_ipc_endpoint_t *endpoint; + cbm_daemon_build_identity_t identity; cbm_version_cohort_manager_t *manager; cbm_daemon_runtime_service_t *service; cbm_daemon_runtime_client_t *client; @@ -337,16 +351,22 @@ static cbm_daemon_runtime_application_status_t frontend_eof_application_request( } /* Released: fall through and answer normally like every later item. */ } - if (request_length == 0) { + if (request_length == 0 || request[0] == CBM_DAEMON_APPLICATION_REQUEST_SET_CONTEXT) { return CBM_DAEMON_RUNTIME_APPLICATION_OK; } - uint8_t *response = malloc(request_length); + const uint8_t *response_source = request; + uint32_t response_length = request_length; + if (request[0] == CBM_DAEMON_APPLICATION_REQUEST_MCP) { + response_source++; + response_length--; + } + uint8_t *response = malloc(response_length); if (!response) { return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; } - memcpy(response, request, request_length); + memcpy(response, response_source, response_length); *response_out = response; - *response_length_out = request_length; + *response_length_out = response_length; if (context->request_observed_fd >= 0) { const char marker = 'Q'; (void)write(context->request_observed_fd, &marker, 1); @@ -454,6 +474,86 @@ static void *frontend_eof_writer(void *opaque) { return NULL; } +static const char frontend_recovery_request[] = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n"; +static const char frontend_recovery_cancel[] = + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," + "\"params\":{\"requestId\":1}}\n"; +static const char frontend_recovery_followup[] = + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + +static void *frontend_recovery_writer(void *opaque) { + frontend_eof_writer_t *writer = opaque; + bool ok = frontend_eof_write_all(writer->fd, frontend_recovery_request, + sizeof(frontend_recovery_request) - 1U); + uint64_t deadline = cbm_now_ms() + FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS; + if (writer->recovery_mode != FRONTEND_RECOVERY_RETRY) { + while (ok && + !atomic_load_explicit(&writer->application->first_request_started, + memory_order_acquire) && + cbm_now_ms() < deadline) { + cbm_usleep(1000); + } + ok = ok && atomic_load_explicit(&writer->application->first_request_started, + memory_order_acquire); + } + if (writer->recovery_mode == FRONTEND_RECOVERY_CANCEL) { + ok = ok && frontend_eof_write_all(writer->fd, frontend_recovery_cancel, + sizeof(frontend_recovery_cancel) - 1U); + /* Keep context blocked until the reader routes the cancellation. */ + cbm_usleep(100000); + atomic_store_explicit(&writer->application->release_first_request, true, + memory_order_release); + ok = ok && frontend_eof_write_all(writer->fd, frontend_recovery_followup, + sizeof(frontend_recovery_followup) - 1U); + } else if (writer->recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF) { + ok = close(writer->fd) == 0 && ok; + writer->fd = -1; + cbm_usleep(FRONTEND_RECOVERY_EOF_RELEASE_MS * 1000U); + atomic_store_explicit(&writer->application->release_first_request, true, + memory_order_release); + } + int expected_requests = writer->recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF ? 1 : 2; + while (ok && + atomic_load_explicit(&writer->application->requests, memory_order_acquire) < + expected_requests && + cbm_now_ms() < deadline) { + cbm_usleep(1000); + } + ok = ok && atomic_load_explicit(&writer->application->requests, memory_order_acquire) == + expected_requests; + if (writer->fd >= 0) { + ok = close(writer->fd) == 0 && ok; + writer->fd = -1; + } + atomic_store_explicit(&writer->succeeded, ok, memory_order_release); + atomic_store_explicit(&writer->finished, true, memory_order_release); + return NULL; +} + +static cbm_daemon_runtime_service_t *frontend_eof_service_start(frontend_eof_fixture_t *fixture) { + cbm_daemon_runtime_application_callbacks_t callbacks = { + .context = &fixture->application, + .session_open = frontend_eof_application_session_open, + .request = frontend_eof_application_request, + .request_cancel = frontend_eof_application_request_cancel, + .session_cancel = frontend_eof_application_session_cancel, + .session_close = frontend_eof_application_session_close, + }; + cbm_daemon_runtime_service_config_t config = { + .endpoint = fixture->endpoint, + .identity = fixture->identity, + .conflict_log_path = fixture->conflict_log, + .conflict_log_cap_bytes = 64U * 1024U, + .max_clients = 4, + .lease_timeout_ms = 5000, + .request_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .shutdown_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .application = callbacks, + }; + return cbm_daemon_runtime_service_start(&config); +} + static bool frontend_eof_fixture_start(frontend_eof_fixture_t *fixture, const char *parent) { memset(fixture, 0, sizeof(*fixture)); atomic_init(&fixture->application.requests, 0); @@ -467,53 +567,42 @@ static bool frontend_eof_fixture_start(frontend_eof_fixture_t *fixture, const ch fixture->application.request_observed_fd = -1; fixture->application.session_cancel_fd = -1; char key[CBM_DAEMON_KEY_SIZE]; - char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; int log_written = snprintf(fixture->conflict_log, sizeof(fixture->conflict_log), "%s/conflicts.ndjson", parent); if (log_written <= 0 || log_written >= (int)sizeof(fixture->conflict_log) || !cbm_daemon_rendezvous_key(key) || - !cbm_daemon_runtime_process_build_fingerprint((uint64_t)getpid(), build)) { + !cbm_daemon_runtime_process_build_fingerprint((uint64_t)getpid(), + fixture->build_fingerprint)) { return false; } fixture->endpoint = cbm_daemon_ipc_endpoint_new(key, parent); fixture->manager = fixture->endpoint ? cbm_version_cohort_manager_new(fixture->endpoint) : NULL; - cbm_daemon_build_identity_t identity = { + fixture->identity = (cbm_daemon_build_identity_t){ .semantic_version = "2.4.0", - .build_fingerprint = build, + .build_fingerprint = fixture->build_fingerprint, .cache_fingerprint = FRONTEND_TEST_CACHE, .protocol_abi = 3, .store_abi = 11, .feature_abi = 7, }; - cbm_daemon_runtime_application_callbacks_t callbacks = { - .context = &fixture->application, - .session_open = frontend_eof_application_session_open, - .request = frontend_eof_application_request, - .request_cancel = frontend_eof_application_request_cancel, - .session_cancel = frontend_eof_application_session_cancel, - .session_close = frontend_eof_application_session_close, - }; - cbm_daemon_runtime_service_config_t config = { - .endpoint = fixture->endpoint, - .identity = identity, - .conflict_log_path = fixture->conflict_log, - .conflict_log_cap_bytes = 64U * 1024U, - .max_clients = 4, - .lease_timeout_ms = 5000, - .request_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, - .shutdown_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, - .application = callbacks, - }; - fixture->service = fixture->manager ? cbm_daemon_runtime_service_start(&config) : NULL; + fixture->service = fixture->manager ? frontend_eof_service_start(fixture) : NULL; cbm_daemon_runtime_connect_result_t connect_result = {0}; fixture->client = fixture->service - ? cbm_daemon_runtime_client_connect(fixture->endpoint, &identity, + ? cbm_daemon_runtime_client_connect(fixture->endpoint, &fixture->identity, FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, &connect_result) : NULL; return fixture->client && connect_result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; } +static bool frontend_eof_fixture_restart_service(frontend_eof_fixture_t *fixture) { + bool stopped = fixture->service && cbm_daemon_runtime_service_stop( + fixture->service, FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS); + bool freed = stopped && cbm_daemon_runtime_service_free(fixture->service); + fixture->service = freed ? frontend_eof_service_start(fixture) : NULL; + return stopped && freed && fixture->service; +} + static bool frontend_eof_fixture_finish(frontend_eof_fixture_t *fixture) { bool ok = true; if (fixture->client) { @@ -579,7 +668,21 @@ static int frontend_eof_child_run(const char *parent, bool overflow) { cbm_daemon_runtime_client_t *frontend_client = fixture.client; fixture.client = NULL; /* cbm_daemon_frontend_mcp_run consumes it. */ - int result = cbm_daemon_frontend_mcp_run(frontend_client, fixture.manager, input, output); + cbm_daemon_frontend_session_config_t session = { + .bootstrap = + { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &fixture.identity, + .executable_path = "unused", + .connect_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .startup_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + }, + .session_root = parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; + int result = + cbm_daemon_frontend_mcp_run(frontend_client, fixture.manager, &session, input, output); bool joined = cbm_thread_join(&writer_thread) == 0; bool writer_ok = atomic_load_explicit(&writer.finished, memory_order_acquire) && atomic_load_explicit(&writer.succeeded, memory_order_acquire); @@ -660,6 +763,111 @@ static bool frontend_eof_run_isolated(const char *tag, bool overflow) { return child_ok && cleaned; } +static int frontend_recovery_child_run(const char *parent, frontend_recovery_mode_t recovery_mode) { + (void)alarm(FRONTEND_EOF_TEST_CATASTROPHIC_TIMEOUT_S); + frontend_eof_fixture_t fixture; + if (!frontend_eof_fixture_start(&fixture, parent)) { + (void)frontend_eof_fixture_finish(&fixture); + return 73; + } + atomic_store_explicit(&fixture.application.block_first_request, + recovery_mode != FRONTEND_RECOVERY_RETRY, memory_order_release); + cbm_daemon_runtime_client_t *stale_client = fixture.client; + fixture.client = NULL; + bool replacement_ready = frontend_eof_fixture_restart_service(&fixture); + int input_pipe[2] = {-1, -1}; + bool pipe_ready = replacement_ready && pipe(input_pipe) == 0; + FILE *input = pipe_ready ? fdopen(input_pipe[0], "rb") : NULL; + FILE *output = input ? tmpfile() : NULL; + frontend_eof_writer_t writer = { + .fd = input_pipe[1], + .recovery_mode = recovery_mode, + .application = &fixture.application, + }; + atomic_init(&writer.finished, false); + atomic_init(&writer.succeeded, false); + cbm_thread_t writer_thread; + bool writer_started = + output && cbm_thread_create(&writer_thread, 0, frontend_recovery_writer, &writer) == 0; + if (!writer_started && input_pipe[1] >= 0) { + (void)close(input_pipe[1]); + input_pipe[1] = -1; + } + bool request_ready = writer_started; + cbm_daemon_frontend_session_config_t session = { + .bootstrap = + { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &fixture.identity, + .executable_path = "unused", + .connect_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .startup_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + }, + .session_root = parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; + int result = request_ready ? cbm_daemon_frontend_mcp_run(stale_client, fixture.manager, + &session, input, output) + : -1; + stale_client = NULL; /* the frontend consumes the stale or replacement handle */ + bool writer_joined = writer_started && cbm_thread_join(&writer_thread) == 0; + bool writer_ok = writer_joined && + atomic_load_explicit(&writer.finished, memory_order_acquire) && + atomic_load_explicit(&writer.succeeded, memory_order_acquire); + char response[512]; + memset(response, 0, sizeof(response)); + bool response_ready = result == 0 && fflush(output) == 0 && fseek(output, 0, SEEK_SET) == 0; + size_t response_length = response_ready ? fread(response, 1, sizeof(response) - 1U, output) : 0; + bool response_exact = false; + if (recovery_mode == FRONTEND_RECOVERY_CANCEL) { + response_exact = strstr(response, "\"id\":1") && strstr(response, "\"code\":-32800") && + strstr(response, frontend_recovery_followup); + } else if (recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF) { + response_exact = response_length == 0; + } else { + response_exact = response_length == sizeof(frontend_recovery_request) - 1U && + memcmp(response, frontend_recovery_request, + sizeof(frontend_recovery_request) - 1U) == 0; + } + int requests = atomic_load_explicit(&fixture.application.requests, memory_order_acquire); + int expected_requests = recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF ? 1 : 2; + bool input_closed = input && fclose(input) == 0; + bool output_closed = output && fclose(output) == 0; + bool fixture_closed = frontend_eof_fixture_finish(&fixture); + return replacement_ready && request_ready && result == 0 && response_exact && + requests == expected_requests && writer_ok && input_closed && output_closed && + fixture_closed + ? 0 + : 74; +} + +static bool frontend_recovery_run_isolated(frontend_recovery_mode_t recovery_mode) { + char parent[FRONTEND_TEST_PATH_CAP]; + int written = snprintf(parent, sizeof(parent), "%s/cbm-frontend-recovery-XXXXXX", cbm_tmpdir()); + if (written <= 0 || written >= (int)sizeof(parent) || !cbm_mkdtemp(parent)) { + return false; + } + pid_t child = fork(); + if (child == 0) { + (void)signal(SIGALRM, SIG_DFL); + _exit(frontend_recovery_child_run(parent, recovery_mode)); + } + int status = 0; + pid_t waited; + do { + waited = child > 0 ? waitpid(child, &status, 0) : -1; + } while (waited < 0 && errno == EINTR); + bool cleaned = th_rmtree(parent) == 0; + bool child_ok = child > 0 && waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0; + if (!child_ok && child > 0 && waited == child) { + (void)fprintf(stderr, "frontend recovery fixture failed: status=0x%x exit=%d signal=%d\n", + status, WIFEXITED(status) ? WEXITSTATUS(status) : -1, + WIFSIGNALED(status) ? WTERMSIG(status) : 0); + } + return child_ok && cleaned; +} + static void frontend_test_release_lease(cbm_version_cohort_lease_t **lease) { while (lease && *lease && cbm_version_cohort_lease_release(lease) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); @@ -920,7 +1128,20 @@ static int frontend_backpressure_frontend_run(const char *parent, int input_fd, return 81; } - int result = cbm_daemon_frontend_mcp_run(client, manager, input, output); + cbm_daemon_frontend_session_config_t session = { + .bootstrap = + { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = endpoint, + .identity = &identity, + .executable_path = "unused", + .connect_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .startup_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + }, + .session_root = parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; + int result = cbm_daemon_frontend_mcp_run(client, manager, &session, input, output); bool joined = cbm_thread_join(&writer_thread) == 0; bool writer_ok = atomic_load_explicit(&writer.finished, memory_order_acquire) && atomic_load_explicit(&writer.succeeded, memory_order_acquire); @@ -1397,8 +1618,21 @@ TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked) { if (!announced) { _exit(70); } + cbm_daemon_frontend_session_config_t session = { + .bootstrap = + { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = endpoint, + .identity = &identity, + .executable_path = "unused", + .connect_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .startup_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + }, + .session_root = fixture.parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; int result = cbm_daemon_frontend_mcp_run((cbm_daemon_runtime_client_t *)(uintptr_t)1, - manager, input, output); + manager, &session, input, output); (void)result; _exit(71); } @@ -1612,6 +1846,27 @@ TEST(daemon_frontend_over_capacity_input_backpressures_without_loss) { PASS(); } +/* A request rejected before its application frame reaches the daemon is safe + * to replay. The frontend must replace its stale authenticated client, restore + * the session, reserve a token from the new connection, and return the one + * response without closing Codex's stdio transport. */ +TEST(daemon_frontend_recovers_one_unsent_request_after_daemon_replacement) { + ASSERT_TRUE(frontend_recovery_run_isolated(FRONTEND_RECOVERY_RETRY)); + PASS(); +} + +/* Context-replay cancellation must not strand a token or break the next request. */ +TEST(daemon_frontend_recovery_cancellation_preserves_next_request) { + ASSERT_TRUE(frontend_recovery_run_isolated(FRONTEND_RECOVERY_CANCEL)); + PASS(); +} + +/* Clean EOF during context replay must preserve worker-owned client cleanup. */ +TEST(daemon_frontend_clean_eof_during_recovery_preserves_client_ownership) { + ASSERT_TRUE(frontend_recovery_run_isolated(FRONTEND_RECOVERY_CLEAN_EOF)); + PASS(); +} + /* Clean EOF is the normal MCP-session ownership boundary. Accepted work gets a * bounded drain opportunity; work still active at the deadline is cancelled * with that exact session, while the frontend itself reports a clean close. */ @@ -1669,6 +1924,9 @@ SUITE(daemon_frontend) { RUN_TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation); RUN_TEST(daemon_local_participant_monitor_allows_supervisor_containment_window); RUN_TEST(daemon_frontend_over_capacity_input_backpressures_without_loss); + RUN_TEST(daemon_frontend_recovers_one_unsent_request_after_daemon_replacement); + RUN_TEST(daemon_frontend_recovery_cancellation_preserves_next_request); + RUN_TEST(daemon_frontend_clean_eof_during_recovery_preserves_client_ownership); RUN_TEST(daemon_frontend_eof_drain_timeout_cancels_and_returns_success); RUN_TEST(daemon_frontend_stdout_backpressure_eof_fail_stops_and_cancels_session); RUN_TEST(daemon_frontend_stdout_backpressure_maintenance_stops_and_cancels_session); diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 991506a74..b79d6c463 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -907,7 +907,8 @@ static void *runtime_application_client_request_thread(void *opaque) { call->status = call->tagged ? cbm_daemon_runtime_client_application_request_tagged( call->client, call->request_token, call->request, call->request_length, - &call->response, &call->response_length, RUNTIME_TEST_TIMEOUT_MS) + &call->response, &call->response_length, NULL, + RUNTIME_TEST_TIMEOUT_MS) : cbm_daemon_runtime_client_application_request( call->client, call->request, call->request_length, &call->response, &call->response_length, RUNTIME_TEST_TIMEOUT_MS); @@ -3161,7 +3162,7 @@ TEST(daemon_runtime_presend_request_cancel_is_sticky_and_nonterminal) { cancel = cbm_daemon_runtime_client_application_cancel(client, request_token); cancelled_status = cbm_daemon_runtime_client_application_request_tagged( client, request_token, blocking_request, (uint32_t)sizeof(blocking_request), - &cancelled_response, &cancelled_response_length, RUNTIME_TEST_TIMEOUT_MS); + &cancelled_response, &cancelled_response_length, NULL, RUNTIME_TEST_TIMEOUT_MS); } if (cancelled_status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) { next_status = cbm_daemon_runtime_client_application_request( @@ -3218,8 +3219,10 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { bool first_reserved = false; bool duplicate_reservation_rejected = false; bool first_exact = false; + bool first_sent = false; bool second_reserved = false; bool second_exact = false; + bool second_sent = false; bool closed = false; bool exited = false; @@ -3239,7 +3242,7 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { cbm_daemon_runtime_application_status_t status = cbm_daemon_runtime_client_application_request_tagged( client, first_token, first_request, (uint32_t)sizeof(first_request), &response, - &response_length, RUNTIME_TEST_TIMEOUT_MS); + &response_length, &first_sent, RUNTIME_TEST_TIMEOUT_MS); first_exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response_length == sizeof(first_request) && response && memcmp(response, first_request, sizeof(first_request)) == 0; @@ -3255,7 +3258,7 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { cbm_daemon_runtime_application_status_t status = cbm_daemon_runtime_client_application_request_tagged( client, second_token, second_request, (uint32_t)sizeof(second_request), &response, - &response_length, RUNTIME_TEST_TIMEOUT_MS); + &response_length, &second_sent, RUNTIME_TEST_TIMEOUT_MS); second_exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response_length == sizeof(second_request) && response && memcmp(response, second_request, sizeof(second_request)) == 0; @@ -3274,9 +3277,11 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { ASSERT_TRUE(first_reserved); ASSERT_TRUE(duplicate_reservation_rejected); ASSERT_TRUE(first_exact); + ASSERT_TRUE(first_sent); ASSERT_TRUE(second_reserved); ASSERT_EQ(second_token, first_token + 1U); ASSERT_TRUE(second_exact); + ASSERT_TRUE(second_sent); ASSERT_TRUE(closed); ASSERT_TRUE(exited); ASSERT_EQ(atomic_load(&context.requests), 2); @@ -3376,6 +3381,10 @@ TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { uint8_t *response = NULL; uint32_t response_length = 0; cbm_daemon_runtime_application_status_t status = CBM_DAEMON_RUNTIME_APPLICATION_OK; + cbm_daemon_runtime_application_token_t request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + bool token_reserved = false; + bool request_sent = true; bool close_begun = false; bool duplicate_begin_rejected = false; bool close_acknowledged = false; @@ -3386,6 +3395,8 @@ TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { + token_reserved = + cbm_daemon_runtime_client_application_token_reserve(client, &request_token); close_begun = cbm_daemon_runtime_client_close_begin(client); duplicate_begin_rejected = close_begun && !cbm_daemon_runtime_client_close_begin(client); } @@ -3393,9 +3404,9 @@ TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { /* Deterministically models the frontend boundary where close begins * after a worker claims an item but before it enters the runtime API. * The retained allocation must reject the call without touching IPC. */ - status = cbm_daemon_runtime_client_application_request( - client, request, (uint32_t)sizeof(request), &response, &response_length, - RUNTIME_TEST_TIMEOUT_MS); + status = cbm_daemon_runtime_client_application_request_tagged( + client, request_token, request, (uint32_t)sizeof(request), &response, &response_length, + &request_sent, RUNTIME_TEST_TIMEOUT_MS); close_acknowledged = cbm_daemon_runtime_client_close_finish(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; @@ -3413,11 +3424,13 @@ TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { runtime_test_fixture_finish(&fixture); ASSERT_TRUE(started); + ASSERT_TRUE(token_reserved); ASSERT_TRUE(close_begun); ASSERT_TRUE(duplicate_begin_rejected); ASSERT_EQ(status, CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR); ASSERT_NULL(response); ASSERT_EQ(response_length, 0); + ASSERT_FALSE(request_sent); ASSERT_TRUE(close_acknowledged); ASSERT_TRUE(exited); ASSERT_EQ(atomic_load(&context.opened), 1); From b9da6dca03126ef0c8cacccb3321dd5d69ccf34d Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Fri, 21 Aug 2026 18:44:15 +0200 Subject: [PATCH 03/61] test(frontend): widen recovery hang guard for MSan Signed-off-by: Pxx500 --- src/main.c | 1 - tests/test_daemon_frontend.c | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main.c b/src/main.c index 98db00561..5547d79b1 100644 --- a/src/main.c +++ b/src/main.c @@ -3070,7 +3070,6 @@ int main(int argc, char **argv) { cbm_daemon_bootstrap_result_t bootstrap_result; cbm_daemon_bootstrap_status_t bootstrap_status = main_client_bootstrap_with_upgrade(&bootstrap_config, &bootstrap_result, true); - cbm_daemon_ipc_endpoint_free(endpoint); if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap_result.client) { main_report_client_bootstrap_failure(role, &bootstrap_result); cbm_daemon_ipc_endpoint_free(endpoint); diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c index 7df0e3e9f..96889ad33 100644 --- a/tests/test_daemon_frontend.c +++ b/tests/test_daemon_frontend.c @@ -231,6 +231,9 @@ enum { * dev machine (see the index-supervisor calibration). */ FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS = 30000, FRONTEND_EOF_TEST_CATASTROPHIC_TIMEOUT_S = 90, + /* Recovery can spend most of the generic guard restarting an instrumented + * daemon before the clean-EOF case enters its deliberate 15s drain. */ + FRONTEND_RECOVERY_TEST_CATASTROPHIC_TIMEOUT_S = 120, FRONTEND_BACKPRESSURE_MESSAGE_BYTES = 2 * 1024 * 1024, FRONTEND_BACKPRESSURE_FRONTEND_TIMEOUT_S = 90, FRONTEND_BACKPRESSURE_DAEMON_TIMEOUT_S = 90, @@ -764,7 +767,7 @@ static bool frontend_eof_run_isolated(const char *tag, bool overflow) { } static int frontend_recovery_child_run(const char *parent, frontend_recovery_mode_t recovery_mode) { - (void)alarm(FRONTEND_EOF_TEST_CATASTROPHIC_TIMEOUT_S); + (void)alarm(FRONTEND_RECOVERY_TEST_CATASTROPHIC_TIMEOUT_S); frontend_eof_fixture_t fixture; if (!frontend_eof_fixture_start(&fixture, parent)) { (void)frontend_eof_fixture_finish(&fixture); From 85482e3c599d454d76ccef5e862a5dc6c38a898e Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Mon, 7 Sep 2026 17:30:13 +0200 Subject: [PATCH 04/61] test(frontend): address recovery review feedback Signed-off-by: Pxx500 --- src/daemon/frontend.c | 16 ++++++++ src/daemon/frontend.h | 12 ++++-- tests/test_daemon_frontend.c | 72 +++++++++++++++++++++++++----------- 3 files changed, 75 insertions(+), 25 deletions(-) diff --git a/src/daemon/frontend.c b/src/daemon/frontend.c index 92b032686..d65284bd7 100644 --- a/src/daemon/frontend.c +++ b/src/daemon/frontend.c @@ -119,6 +119,7 @@ static atomic_bool frontend_test_monitor_is_waiting = ATOMIC_VAR_INIT(false); static atomic_uint_fast64_t frontend_test_monitor_observation_count = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t frontend_test_worker_observation_count = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t frontend_test_worker_idle_count = ATOMIC_VAR_INIT(0); +static atomic_uint_fast64_t frontend_test_routed_cancellation_count = ATOMIC_VAR_INIT(0); void cbm_daemon_frontend_test_observer_reset(bool hold_monitor) { atomic_store_explicit(&frontend_test_monitor_observation_count, 0, memory_order_release); @@ -147,6 +148,10 @@ uint64_t cbm_daemon_frontend_test_worker_observations(void) { uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void) { return atomic_load_explicit(&frontend_test_worker_idle_count, memory_order_acquire); } + +uint64_t cbm_daemon_frontend_test_routed_cancellations(void) { + return atomic_load_explicit(&frontend_test_routed_cancellation_count, memory_order_acquire); +} #endif static cbm_version_cohort_maintenance_presence_t frontend_observe_maintenance( @@ -478,6 +483,12 @@ static frontend_cancellation_route_t frontend_route_cancellation(frontend_state_ } cbm_mutex_unlock(&state->mutex); cbm_jsonrpc_request_free(&request); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + if (route == FRONTEND_CANCELLATION_ACTIVE || route == FRONTEND_CANCELLATION_QUEUED) { + atomic_fetch_add_explicit(&frontend_test_routed_cancellation_count, 1, + memory_order_release); + } +#endif return route; } @@ -531,6 +542,11 @@ static bool frontend_recover_client(frontend_state_t *state, frontend_item_t *it } cbm_daemon_bootstrap_result_t bootstrap = {0}; + /* Bootstrap can report launch failures on stderr from this worker. + * Keep that diagnostic + * to explain failed recovery. It never writes MCP + * stdout, but blocked stderr can delay + * exit. Do not add more logging here. */ cbm_daemon_bootstrap_status_t bootstrap_status = cbm_daemon_bootstrap_execute(&state->session->bootstrap, &bootstrap); cbm_daemon_runtime_client_t *replacement = diff --git a/src/daemon/frontend.h b/src/daemon/frontend.h index 10255687f..9ee193be8 100644 --- a/src/daemon/frontend.h +++ b/src/daemon/frontend.h @@ -14,9 +14,11 @@ typedef struct cbm_daemon_maintenance_monitor cbm_daemon_maintenance_monitor_t; -/* Everything the stdio frontend must replay when its authenticated daemon - * connection is replaced. Pointer fields are borrowed for the complete - * frontend call. */ +/* Connection settings replayed when the authenticated daemon is replaced. + * Background activation + * from initialize is not restored here. + * Pointer fields are borrowed for the complete frontend + * call. */ typedef struct { cbm_daemon_bootstrap_config_t bootstrap; const char *session_root; @@ -67,6 +69,10 @@ bool cbm_daemon_frontend_test_monitor_waiting(void); uint64_t cbm_daemon_frontend_test_monitor_observations(void); uint64_t cbm_daemon_frontend_test_worker_observations(void); uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void); +/* Counts matching cancellations marked under the frontend mutex. + * Tests snapshot this monotonic + * count before sending a notification. */ +uint64_t cbm_daemon_frontend_test_routed_cancellations(void); #endif /* Takes ownership of client and borrows cohort_manager for the complete call. diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c index 96889ad33..acdaf0512 100644 --- a/tests/test_daemon_frontend.c +++ b/tests/test_daemon_frontend.c @@ -39,6 +39,8 @@ typedef struct { typedef struct { frontend_maintenance_fixture_t maintenance; char conflict_log[FRONTEND_TEST_PATH_CAP]; + char build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + cbm_daemon_build_identity_t identity; cbm_daemon_runtime_service_t *service; cbm_daemon_runtime_client_t *client; } frontend_idle_fixture_t; @@ -46,6 +48,7 @@ typedef struct { typedef struct { cbm_daemon_runtime_client_t *client; cbm_version_cohort_manager_t *manager; + const cbm_daemon_frontend_session_config_t *session; FILE *input; FILE *output; int result; @@ -99,22 +102,22 @@ static cbm_daemon_runtime_application_status_t frontend_idle_request( return CBM_DAEMON_RUNTIME_APPLICATION_OK; } -static void frontend_idle_request_cancel( - void *context, cbm_daemon_runtime_application_session_t *session, - cbm_daemon_runtime_application_token_t request_token) { +static void frontend_idle_request_cancel(void *context, + cbm_daemon_runtime_application_session_t *session, + cbm_daemon_runtime_application_token_t request_token) { (void)context; (void)session; (void)request_token; } -static void frontend_idle_session_cancel( - void *context, cbm_daemon_runtime_application_session_t *session) { +static void frontend_idle_session_cancel(void *context, + cbm_daemon_runtime_application_session_t *session) { (void)context; (void)session; } -static void frontend_idle_session_close( - void *context, cbm_daemon_runtime_application_session_t *session) { +static void frontend_idle_session_close(void *context, + cbm_daemon_runtime_application_session_t *session) { (void)context; (void)session; } @@ -128,22 +131,21 @@ static uint64_t frontend_test_process_id(void) { } static bool frontend_idle_fixture_start(frontend_idle_fixture_t *fixture) { - static const char cache[] = - "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + static const char cache[] = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; memset(fixture, 0, sizeof(*fixture)); if (!frontend_maintenance_fixture_start(&fixture->maintenance, "idle-observer")) { return false; } - char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; int log_written = snprintf(fixture->conflict_log, sizeof(fixture->conflict_log), "%s/conflicts.ndjson", fixture->maintenance.parent); if (log_written <= 0 || log_written >= (int)sizeof(fixture->conflict_log) || - !cbm_daemon_runtime_process_build_fingerprint(frontend_test_process_id(), build)) { + !cbm_daemon_runtime_process_build_fingerprint(frontend_test_process_id(), + fixture->build_fingerprint)) { return false; } - cbm_daemon_build_identity_t identity = { + fixture->identity = (cbm_daemon_build_identity_t){ .semantic_version = "2.4.0", - .build_fingerprint = build, + .build_fingerprint = fixture->build_fingerprint, .cache_fingerprint = cache, .protocol_abi = 3, .store_abi = 11, @@ -159,7 +161,7 @@ static bool frontend_idle_fixture_start(frontend_idle_fixture_t *fixture) { }; cbm_daemon_runtime_service_config_t config = { .endpoint = fixture->maintenance.endpoint, - .identity = identity, + .identity = fixture->identity, .conflict_log_path = fixture->conflict_log, .conflict_log_cap_bytes = 64U * 1024U, .max_clients = 2, @@ -170,10 +172,11 @@ static bool frontend_idle_fixture_start(frontend_idle_fixture_t *fixture) { }; fixture->service = cbm_daemon_runtime_service_start(&config); cbm_daemon_runtime_connect_result_t connect_result = {0}; - fixture->client = fixture->service - ? cbm_daemon_runtime_client_connect(fixture->maintenance.endpoint, &identity, - 30000, &connect_result) - : NULL; + fixture->client = + fixture->service + ? cbm_daemon_runtime_client_connect(fixture->maintenance.endpoint, &fixture->identity, + 30000, &connect_result) + : NULL; return fixture->client && connect_result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; } @@ -213,8 +216,8 @@ static int frontend_test_close_fd(int fd) { static void *frontend_idle_run(void *opaque) { frontend_idle_run_t *run = opaque; - run->result = - cbm_daemon_frontend_mcp_run(run->client, run->manager, run->input, run->output); + run->result = cbm_daemon_frontend_mcp_run(run->client, run->manager, run->session, run->input, + run->output); return NULL; } #endif @@ -479,11 +482,13 @@ static void *frontend_eof_writer(void *opaque) { static const char frontend_recovery_request[] = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n"; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS static const char frontend_recovery_cancel[] = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," "\"params\":{\"requestId\":1}}\n"; static const char frontend_recovery_followup[] = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; +#endif static void *frontend_recovery_writer(void *opaque) { frontend_eof_writer_t *writer = opaque; @@ -500,16 +505,24 @@ static void *frontend_recovery_writer(void *opaque) { ok = ok && atomic_load_explicit(&writer->application->first_request_started, memory_order_acquire); } +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS if (writer->recovery_mode == FRONTEND_RECOVERY_CANCEL) { + uint64_t cancellations = cbm_daemon_frontend_test_routed_cancellations(); ok = ok && frontend_eof_write_all(writer->fd, frontend_recovery_cancel, sizeof(frontend_recovery_cancel) - 1U); /* Keep context blocked until the reader routes the cancellation. */ - cbm_usleep(100000); + while (ok && cbm_daemon_frontend_test_routed_cancellations() == cancellations && + cbm_now_ms() < deadline) { + cbm_usleep(1000); + } + ok = ok && cbm_daemon_frontend_test_routed_cancellations() > cancellations; atomic_store_explicit(&writer->application->release_first_request, true, memory_order_release); ok = ok && frontend_eof_write_all(writer->fd, frontend_recovery_followup, sizeof(frontend_recovery_followup) - 1U); - } else if (writer->recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF) { + } +#endif + if (writer->recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF) { ok = close(writer->fd) == 0 && ok; writer->fd = -1; cbm_usleep(FRONTEND_RECOVERY_EOF_RELEASE_MS * 1000U); @@ -1537,9 +1550,20 @@ TEST(daemon_frontend_idle_uses_one_maintenance_observer) { bool pipe_ready = fixture_ready && cbm_pipe(input_pipe) == 0; FILE *input = pipe_ready ? frontend_test_fdopen_read(input_pipe[0]) : NULL; FILE *output = input ? tmpfile() : NULL; + cbm_daemon_frontend_session_config_t session = { + .bootstrap = {.role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.maintenance.endpoint, + .identity = &fixture.identity, + .executable_path = "unused", + .connect_timeout_ms = 30000, + .startup_timeout_ms = 30000}, + .session_root = fixture.maintenance.parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; frontend_idle_run_t run = { .client = fixture.client, .manager = fixture.maintenance.manager, + .session = &session, .input = input, .output = output, .result = -1, @@ -1859,10 +1883,12 @@ TEST(daemon_frontend_recovers_one_unsent_request_after_daemon_replacement) { } /* Context-replay cancellation must not strand a token or break the next request. */ +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS TEST(daemon_frontend_recovery_cancellation_preserves_next_request) { ASSERT_TRUE(frontend_recovery_run_isolated(FRONTEND_RECOVERY_CANCEL)); PASS(); } +#endif /* Clean EOF during context replay must preserve worker-owned client cleanup. */ TEST(daemon_frontend_clean_eof_during_recovery_preserves_client_ownership) { @@ -1928,7 +1954,9 @@ SUITE(daemon_frontend) { RUN_TEST(daemon_local_participant_monitor_allows_supervisor_containment_window); RUN_TEST(daemon_frontend_over_capacity_input_backpressures_without_loss); RUN_TEST(daemon_frontend_recovers_one_unsent_request_after_daemon_replacement); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS RUN_TEST(daemon_frontend_recovery_cancellation_preserves_next_request); +#endif RUN_TEST(daemon_frontend_clean_eof_during_recovery_preserves_client_ownership); RUN_TEST(daemon_frontend_eof_drain_timeout_cancels_and_returns_success); RUN_TEST(daemon_frontend_stdout_backpressure_eof_fail_stops_and_cancels_session); From 789c60c86bc75b342c52ddedcaf174d12ccdfa73 Mon Sep 17 00:00:00 2001 From: tmonestudio Date: Sat, 12 Sep 2026 21:52:00 -0300 Subject: [PATCH 05/61] 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 f739c46e6..df8070a23 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 876309eb6..2caa20bba 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 f80d97c6e..ec8aa9d3b 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 25b1c2eb7..44ddb0598 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 bf71a1f84..a520c75aa 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 8deb38aba..2983a24ca 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 fa074cb5d..7bb721bfd 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 b6ce844f7..a111058b6 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 a384651a5..3e6ebc113 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 95f669852..13667afee 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 cf7b8bc8f..4c555db30 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 0d2f840c8ed2695db206acbcad1ad1c36a1f3958 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 07:22:01 -0400 Subject: [PATCH 06/61] feat(config): add ignore_worktrees to skip linked git worktrees Agents that use `git worktree add` to run parallel branches end up with one indexed project per worktree. Each is a near-duplicate of the main checkout, so the cache fills with redundant graphs and project pickers get noisy. Add an opt-in `ignore_worktrees` config key. When enabled, a linked worktree is left alone: - auto-index on connect skips it (logged as autoindex.skip) - the daemon's background auto-index skips it - explicit index_repository refuses with an actionable message naming both ways forward (index_worktree=true, or turn the key off) - the hook-augment "not indexed" guidance stops telling the agent to run index_repository in a directory where it would only be refused Detection is git plumbing, no subprocess: /.git must be a regular file holding a "gitdir:" pointer AND that gitdir must contain a `commondir` file. The commondir check is what separates a linked worktree from a submodule, whose .git is also a gitlink file but whose gitdir has no commondir. The main checkout of a repo that has worktrees is never affected. Defaults to false, so behaviour is unchanged unless the key is set. Signed-off-by: Nohj --- README.md | 3 + docs/CONFIGURATION.md | 30 ++++ src/cli/cli.c | 1 + src/cli/cli.h | 4 +- src/cli/hook_augment.c | 20 ++- src/daemon/application.c | 6 + src/git/git_context.c | 60 ++++++++ src/git/git_context.h | 12 ++ src/mcp/mcp.c | 36 ++++- src/mcp/mcp_internal.h | 5 + tests/test_cli.c | 22 ++- tests/test_git_context.c | 114 +++++++++++++++ tests/test_mcp.c | 292 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 597 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 66b50fe08..3a9b2137d 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ Watcher registration is controlled separately by `auto_watch` (default `true`). To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch` — which is consulted per session — `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings). +Linked git worktrees are indexed as their own projects by default. Set `config set ignore_worktrees true` to skip them on the automatic paths — useful when short-lived `git worktree add` checkouts would otherwise each leave behind a permanent index of the same repository. An explicit `index_repository` call on a worktree is then refused unless you pass `index_worktree=true`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#ignore_worktrees). + ### Keeping Up to Date **Updates run from the install script on every platform, not from inside the running binary.** `codebase-memory-mcp update` validates your flags and then prints the exact command to run: @@ -735,6 +737,7 @@ codebase-memory-mcp config list # show all settings codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true) +codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false) codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 038256976..4c27e7f57 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,6 +87,7 @@ Current keys: |---|---|---| | `auto_index` | `false` | Automatically index new projects when an MCP session starts. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | +| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. | | `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). | | `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | @@ -126,6 +127,35 @@ and preserves any previously serving database. See [Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and error-response details. +### `ignore_worktrees` + +Every indexed project is registered under its own absolute root path, so each +linked worktree becomes a separate permanent index. On machines that create many +short-lived worktrees, the automatic paths (`auto_index`, and the session hook's +"index this project first" guidance) turn every throwaway checkout into another +stored index of what is largely the same repository. + +Enable the key to keep those checkouts out of the index: + +```bash +codebase-memory-mcp config set ignore_worktrees true +``` + +With it enabled: + +- automatic indexing skips a session whose root is a linked worktree; +- the `hook-augment` context says the worktree is unindexed on purpose instead + of telling the agent to run `index_repository`; +- an explicit `index_repository` call on a linked worktree is refused, and names + both ways forward — pass `index_worktree=true` for that one call, or turn the + key back off. + +The main checkout of the same repository is unaffected, as are ordinary clones +and submodules. Detection is git plumbing only: a linked worktree's `.git` is a +file pointing at a gitdir that contains a `commondir` entry. + +The default is `false`, so indexing behavior is unchanged unless you opt in. + ## 3. UI Settings The optional built-in graph UI stores its settings in: diff --git a/src/cli/cli.c b/src/cli/cli.c index b59a894a6..13c678943 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7442,6 +7442,7 @@ static const config_key_def_t CONFIG_KEYS[] = { {CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"}, {CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"}, {CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"}, + {CBM_CONFIG_IGNORE_WORKTREES, "false", "Skip linked git worktrees when indexing automatically"}, {CBM_CONFIG_WATCHER_ENABLED, "true", "Run the background watcher thread (auto-reindex); false to disable"}, {CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"}, diff --git a/src/cli/cli.h b/src/cli/cli.h index cf7f6fa44..ada71ab01 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -250,7 +250,8 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect, const char *forced_event); bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows); void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter); @@ -431,6 +432,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t #define CBM_CONFIG_AUTO_INDEX "auto_index" #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" #define CBM_CONFIG_AUTO_WATCH "auto_watch" +#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees" #define CBM_CONFIG_UI_LANG "ui-lang" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" /* #1558: the graph UI's loopback listener. Stored in the UI config file rather diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index f8909c365..1043b48cd 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -21,7 +21,9 @@ #include "foundation/constants.h" #include "foundation/mem.h" #include "foundation/platform.h" +#include "git/git_context.h" #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" #include "yyjson/yyjson.h" @@ -1440,7 +1442,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) { return "Tier 2 verification"; } -static const char *ha_no_project_index_guidance(const char *event) { +static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) { + /* ignore_worktrees is on and this cwd is a linked worktree: indexing it is + * deliberately disabled, so telling the agent to run index_repository would + * only produce a refusal. Point at the setting instead. */ + if (worktree_ignored) { + return "This is a linked git worktree and ignore_worktrees is enabled, so it is not " + "indexed on purpose; do not run index_repository here."; + } return event && strcmp(event, "SubagentStart") == 0 ? "Ask the parent agent to run index_repository before structural exploration; " "do not attempt graph mutation." @@ -1472,6 +1481,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; + bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && + cbm_git_is_linked_worktree(cwd); cbm_mcp_server_free(owned_server); char context[2048]; @@ -1505,7 +1516,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "and file reads for literals, configs, non-code files, and verification.", scope, safe_project, tier); } else { - const char *index_guidance = ha_no_project_index_guidance(event); + const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored); snprintf(context, sizeof(context), "[codebase-memory] %s context: no indexed graph project matched this working " "directory. %s Once indexed, " @@ -1624,8 +1635,9 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, return ha_path_contains_mode(root, candidate, case_insensitive); } -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { - return ha_no_project_index_guidance(event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored) { + return ha_no_project_index_guidance(event, worktree_ignored); } bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) { diff --git a/src/daemon/application.c b/src/daemon/application.c index 748b8bb75..8a79ecd5d 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -15,6 +15,7 @@ #include "foundation/sha256.h" #include "foundation/subprocess.h" #include "foundation/workspace.h" +#include "git/git_context.h" #include "mcp/index_supervisor.h" #include "mcp/mcp.h" #include "mcp/mcp_internal.h" @@ -2071,6 +2072,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio !application_session_workspace_allowed(session, "auto_index_discovery")) { auto_index_candidate = false; } + if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree"); + auto_index_candidate = false; + } bool within_auto_index_limit = !auto_index_candidate || cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files); diff --git a/src/git/git_context.c b/src/git/git_context.c index f739c46e6..2734dc703 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -100,6 +100,66 @@ static bool path_is_absolute(const char *path) { #endif } +/* Read the "gitdir: " pointer out of a gitlink FILE at /.git. + * Returns false when .git is missing, a directory (ordinary repo), or holds no + * pointer. A relative pointer is resolved against path. */ +static bool read_gitlink_target(const char *path, char *out, size_t out_size) { + char dot_git[GIT_OUTPUT_MAX]; + int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path); + if (n < 0 || n >= (int)sizeof(dot_git)) { + return false; + } + struct stat st; + if (stat(dot_git, &st) != 0 || !S_ISREG(st.st_mode)) { + return false; + } + + FILE *f = cbm_fopen(dot_git, "r"); + if (!f) { + return false; + } + char line[GIT_OUTPUT_MAX]; + bool got = false; + while (fgets(line, sizeof(line), f)) { + trim_newlines(line); + if (strncmp(line, "gitdir:", 7) != 0) { + continue; + } + const char *value = line + 7; + while (*value == ' ' || *value == '\t') { + value++; + } + if (!value[0]) { + break; + } + int written = path_is_absolute(value) ? snprintf(out, out_size, "%s", value) + : snprintf(out, out_size, "%s/%s", path, value); + got = written > 0 && written < (int)out_size; + break; + } + fclose(f); + return got; +} + +bool cbm_git_is_linked_worktree(const char *path) { + if (!path || !path[0]) { + return false; + } + char git_dir[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) { + return false; + } + /* Only linked worktrees carry /commondir; a submodule gitlink + * points at /.git/modules/, which does not. */ + char commondir[GIT_OUTPUT_MAX]; + int n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); + if (n < 0 || n >= (int)sizeof(commondir)) { + return false; + } + struct stat st; + return stat(commondir, &st) == 0 && S_ISREG(st.st_mode); +} + static char *join_root_relative(const char *root, const char *rel) { if (!root || !root[0]) { return git_strdup(rel); diff --git a/src/git/git_context.h b/src/git/git_context.h index 876309eb6..633e28018 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -19,6 +19,18 @@ typedef struct { char *base_sha; } cbm_git_context_t; +/* True when path is the root of a LINKED git worktree (`git worktree add`). + * + * Plumbing-only, no subprocess: /.git must be a regular file holding a + * "gitdir: " pointer AND that gitdir must contain a `commondir` file. + * The commondir check is what separates a linked worktree from a submodule — + * a submodule's .git is also a gitlink file, but its gitdir + * (/.git/modules/) has no commondir entry. + * + * Callers run this on every session start, so it stays fork-free; the richer + * cbm_git_context_resolve() shells out to git and is not usable on that path. */ +bool cbm_git_is_linked_worktree(const char *path); + int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 75ff8e396..f27ddb9a8 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -478,7 +478,10 @@ static const tool_def_t TOOLS[] = { "\"name\":{\"type\":\"string\",\"description\":" "\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" - "\"Write .codebase-memory/graph.db.zst.\"}" + "\"Write .codebase-memory/graph.db.zst.\"}," + "\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":" + "\"Index repo_path even when it is a linked git worktree and the " + "ignore_worktrees config key is enabled. No effect otherwise.\"}" "},\"required\":[\"repo_path\"]}"}, {"search_graph", @@ -11145,6 +11148,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(boundary_err, true); } + /* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree + * is refused with the two ways forward (per-call override, or turn the + * setting off) rather than silently skipped — a silent success would be + * indistinguishable from a real index to the caller. */ + if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") && + cbm_git_is_linked_worktree(repo_path)) { + index_args_free(repo_path, mode_str, name_override); + return cbm_mcp_text_result( + "repo_path is a linked git worktree and ignore_worktrees is enabled. Pass " + "index_worktree=true to index it anyway, or run: codebase-memory-mcp config set " + "ignore_worktrees false", + true); + } + if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { char *result = handle_cross_repo_mode(srv, repo_path, name_override, args); index_args_free(repo_path, mode_str, name_override); @@ -17569,6 +17586,17 @@ static bool auto_watch_enabled(cbm_mcp_server_t *srv) { return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true); } +/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees + * (default off, so existing setups keep indexing them). Users who create many + * short-lived worktrees can stop each throwaway checkout from registering a + * new permanent project with `config set ignore_worktrees true`. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) { + if (!srv || !srv->config) { + return false; /* default off */ + } + return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false); +} + /* Register the session project with the background watcher for ongoing * change detection — unless auto_watch is disabled. */ static void register_watcher_if_enabled(cbm_mcp_server_t *srv) { @@ -17721,6 +17749,12 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { return; } + if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) { + cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project", + srv->session_project); + return; + } + /* Quick tracked-file count check to avoid OOM on massive repos. */ int file_count = -1; #ifdef CBM_ENABLE_TEST_SEAMS diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index de170067a..8e78f9f40 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -63,6 +63,11 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy); bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, int *file_count_out); +/* True when the `ignore_worktrees` config key is on for this server (default + * off). Callers pair it with cbm_git_is_linked_worktree() to decide whether an + * automatic index of a linked worktree should be skipped. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv); + /* detect_changes seed scoping (#1363): does `node`'s line range overlap any * recorded hunk for `file`? Exposed for direct unit testing of the overlap * logic, independent of the git/subprocess/index plumbing around it. */ diff --git a/tests/test_cli.c b/tests/test_cli.c index 211ec6355..46f826d63 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -12263,14 +12263,18 @@ TEST(cli_hook_augment_subagent_tier_router_contract) { } TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only) { - const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart"); - const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart"); + const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart", false); + const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart", false); + const char *worktree = cbm_hook_no_project_index_guidance_for_testing("SessionStart", true); ASSERT_NOT_NULL(session); ASSERT_NOT_NULL(subagent); + ASSERT_NOT_NULL(worktree); ASSERT(strstr(session, "Run index_repository") != NULL); ASSERT(strstr(subagent, "Ask the parent agent to run index_repository") != NULL); ASSERT(strstr(subagent, "do not attempt graph mutation") != NULL); ASSERT(strstr(subagent, "Run index_repository") == NULL); + ASSERT(strstr(worktree, "ignore_worktrees is enabled") != NULL); + ASSERT(strstr(worktree, "do not run index_repository") != NULL); PASS(); } @@ -15578,6 +15582,19 @@ TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558) { PASS(); } +TEST(cli_ignore_worktrees_config_key_is_discoverable) { + bool listed = false; + for (size_t i = 0; i < cbm_cli_config_key_count_for_testing(); i++) { + const char *key = cbm_cli_config_key_at_for_testing(i); + if (key && strcmp(key, CBM_CONFIG_IGNORE_WORKTREES) == 0) { + listed = true; + break; + } + } + ASSERT_TRUE(listed); + PASS(); +} + TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554) { const cbm_skill_t *sk = cbm_get_skills(); ASSERT_NOT_NULL(sk); @@ -16011,6 +16028,7 @@ SUITE(cli) { RUN_TEST(cli_update_download_failure_does_not_quiesce_sessions); RUN_TEST(cli_update_already_current_does_not_quiesce_sessions); RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558); + RUN_TEST(cli_ignore_worktrees_config_key_is_discoverable); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a5..08df0dd7b 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -232,10 +232,124 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +/* ── cbm_git_is_linked_worktree ─────────────────────────────────── */ +/* Detection backing the `ignore_worktrees` config gate. The predicate must be + * TRUE only for a linked worktree (`git worktree add`) — never for the main + * checkout, a plain directory, or a submodule. The submodule case is the one a + * naive "is .git a regular file?" check gets wrong: a submodule's .git is also a + * gitlink file, and it is separated here by the absence of a `commondir` entry + * in the pointed-at gitdir. */ + +TEST(is_linked_worktree_true_for_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + /* th_mktempdir() returns a static buffer — copy before the second call. */ + char main_tmp[256]; + char *raw = th_mktempdir("cbm_wt_main"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(main_tmp, raw, sizeof(main_tmp) - 1); + main_tmp[sizeof(main_tmp) - 1] = '\0'; + + char wt_tmp[256]; + raw = th_mktempdir("cbm_wt_linked"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(wt_tmp, raw, sizeof(wt_tmp) - 1); + wt_tmp[sizeof(wt_tmp) - 1] = '\0'; + th_rmtree(wt_tmp); /* git worktree add creates it */ + + if (make_git_repo(main_tmp) != 0) { + th_rmtree(main_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (git_run(main_tmp, "branch wt-branch") != 0) { + th_rmtree(main_tmp); + FAIL("failed to create branch for worktree"); + } + char wt_cmd[1024]; + snprintf(wt_cmd, sizeof(wt_cmd), "worktree add \"%s\" wt-branch", wt_tmp); + if (git_run(main_tmp, wt_cmd) != 0) { + th_rmtree(wt_tmp); + th_rmtree(main_tmp); + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + + bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp); + /* The MAIN checkout of the very same repo must NOT be flagged — otherwise + * enabling ignore_worktrees would stop indexing ordinary repositories. */ + bool main_detected = cbm_git_is_linked_worktree(main_tmp); + + git_run(main_tmp, "worktree prune"); + th_rmtree(main_tmp); + th_rmtree(wt_tmp); + + ASSERT(worktree_detected); + ASSERT(!main_detected); + PASS(); +#endif /* _WIN32 */ +} + +TEST(is_linked_worktree_false_for_submodule_and_nongit) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + char super_tmp[256]; + char *raw = th_mktempdir("cbm_wt_super"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(super_tmp, raw, sizeof(super_tmp) - 1); + super_tmp[sizeof(super_tmp) - 1] = '\0'; + + char child_tmp[256]; + raw = th_mktempdir("cbm_wt_child"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(child_tmp, raw, sizeof(child_tmp) - 1); + child_tmp[sizeof(child_tmp) - 1] = '\0'; + + if (make_git_repo(super_tmp) != 0 || make_git_repo(child_tmp) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + /* A plain directory that is not a git repo at all. */ + char plain[1024]; + snprintf(plain, sizeof(plain), "%s/plain", super_tmp); + if (th_mkdir_p(plain) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + FAIL("failed to create plain dir"); + } + bool plain_detected = cbm_git_is_linked_worktree(plain); + + /* file:// submodules are refused by default since the CVE-2022-39253 fix. */ + char sub_cmd[1024]; + snprintf(sub_cmd, sizeof(sub_cmd), + "-c protocol.file.allow=always submodule add -q \"%s\" subm", child_tmp); + int sub_rc = git_run(super_tmp, sub_cmd); + + char subm[1024]; + snprintf(subm, sizeof(subm), "%s/subm", super_tmp); + bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm); + + th_rmtree(super_tmp); + th_rmtree(child_tmp); + + ASSERT(!plain_detected); + if (sub_rc != 0) { + SKIP_PLATFORM("git submodule add unavailable in this environment"); + } + /* A submodule gitlink has no commondir → must not be treated as a worktree. */ + ASSERT(!submodule_detected); + PASS(); +#endif /* _WIN32 */ +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(is_linked_worktree_true_for_linked_worktree); + RUN_TEST(is_linked_worktree_false_for_submodule_and_nongit); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ffa9ea3a3..787b26e2e 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -19278,6 +19278,291 @@ TEST(mcp_auto_watch_false_skips_watcher_on_connect) { PASS(); } +/* ═══════════════════════════════════════════════════════════════ + * ignore_worktrees — explicit index_repository on a linked worktree + * ═══════════════════════════════════════════════════════════════ */ + +/* An EXPLICIT index_repository call is refused only when ignore_worktrees is on + * AND repo_path is a linked worktree AND no per-call override was passed. The + * refusal must never fire for the MAIN checkout — that would break ordinary + * indexing for anyone enabling the key — and index_worktree=true must escape it. + * + * Default-off is the regression class the project guards hardest: with the key + * unset, a linked worktree still indexes exactly as today. + * + * Probe returns a bit set, or a negative fixture-setup code. */ +enum { + IGNORE_WT_REFUSED_WORKTREE = 1, /* expected when the key is on */ + IGNORE_WT_REFUSED_MAIN = 2, /* BUG if set */ + IGNORE_WT_REFUSED_OVERRIDE = 4, /* BUG if set */ +}; + +#ifndef _WIN32 +typedef struct { + char cache[256]; + char main_repo[512]; + char wt_repo[512]; +} ignore_wt_fixture_t; + +static void ignore_wt_fixture_cleanup(ignore_wt_fixture_t *fx) { + if (!fx) { + return; + } + if (fx->main_repo[0]) { + char prune[1024]; + snprintf(prune, sizeof(prune), "git -C \"%s\" worktree prune >/dev/null 2>&1", fx->main_repo); + (void)system(prune); + } + if (fx->cache[0]) { + th_rmtree(fx->cache); + } +} + +/* 0 = ready. -1 tmpdir, -2 mkdir, -3 git worktree fixture unavailable. */ +static int ignore_wt_fixture_setup(ignore_wt_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + char *raw = th_mktempdir("cbm_ignorewt"); + if (!raw) { + return -1; + } + snprintf(fx->cache, sizeof(fx->cache), "%s", raw); + snprintf(fx->main_repo, sizeof(fx->main_repo), "%s/main", fx->cache); + snprintf(fx->wt_repo, sizeof(fx->wt_repo), "%s/wt", fx->cache); + if (th_mkdir_p(fx->main_repo) != 0) { + ignore_wt_fixture_cleanup(fx); + return -2; + } + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" init -q >/dev/null 2>&1 && " + "git -C \"%s\" config user.email t@example.com && " + "git -C \"%s\" config user.name T && touch \"%s/.keep\" && " + "git -C \"%s\" add .keep && git -C \"%s\" commit -q -m init && " + "git -C \"%s\" worktree add -q \"%s\" -b wtb", + fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, + fx->main_repo, fx->main_repo, fx->wt_repo); + if (system(cmd) != 0) { + ignore_wt_fixture_cleanup(fx); + return -3; + } + return 0; +} + +static bool ignore_wt_refused(cbm_mcp_server_t *srv, const char *repo_path, bool override) { + char args[2048]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"%s}", repo_path, + override ? ",\"index_worktree\":true" : ""); + char *result = cbm_mcp_handle_tool(srv, "index_repository", args); + bool refused = result && strstr(result, "ignore_worktrees is enabled") != NULL; + free(result); + return refused; +} + +static int ignore_worktrees_index_probe(const char *ignore_value) { + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup < 0) { + return setup; + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", fx.cache, 1); + + int bits = -4; + cbm_config_t *cfg = cbm_config_open(fx.cache); + if (cfg) { + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (srv) { + cbm_mcp_server_set_config(srv, cfg); + bits = 0; + if (ignore_wt_refused(srv, fx.wt_repo, false)) { + bits |= IGNORE_WT_REFUSED_WORKTREE; + } + if (ignore_wt_refused(srv, fx.main_repo, false)) { + bits |= IGNORE_WT_REFUSED_MAIN; + } + if (ignore_wt_refused(srv, fx.wt_repo, true)) { + bits |= IGNORE_WT_REFUSED_OVERRIDE; + } + cbm_mcp_server_free(srv); + } + cbm_config_close(cfg); + } + + restore_cache_dir(saved_copy); + free(saved_copy); + ignore_wt_fixture_cleanup(&fx); + return bits; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +static void ignore_wt_count_started(void *context) { + int *calls = context; + (*calls)++; +} + +/* Drive initialize → maybe_auto_index on a linked worktree. ignore_value NULL + * leaves the key unset (default-off). The count hook fires only if auto-index + * reaches discovery, so a skip for linked_worktree must leave it at 0. */ +static int ignore_wt_autoindex_count_calls(const char *session_root, const char *ignore_value) { + char *raw = th_mktempdir("cbm_ignorewt_auto"); + if (!raw) { + return -1; + } + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char old_cwd[1024]; + if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(session_root) != 0) { + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return -2; + } + + int calls = -3; + cbm_config_t *cfg = cbm_config_open(cache); + cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; + if (srv) { + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); + /* Fail-closed on the actual count so this never launches an index thread. */ + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "-1"); + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_set_config(srv, cfg); + calls = 0; + cbm_mcp_server_set_auto_index_count_test_hook(srv, ignore_wt_count_started, &calls); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1430,\"method\":\"initialize\",\"params\":{}}"); + free(response); + cbm_mcp_server_free(srv); + } + if (cfg) { + cbm_config_close(cfg); + } + + (void)cbm_chdir(old_cwd); + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return calls; +} +#endif /* CBM_ENABLE_TEST_SEAMS */ +#endif /* !_WIN32 */ + +TEST(mcp_ignore_worktrees_gates_explicit_index_repository) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe("true"); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + /* RED before the gate existed: nothing is ever refused. */ + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) != 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +/* Default-off pin: key unset, linked worktree still indexes. The gate is + * opt-in; an unset key must be bit-for-bit today's behaviour. */ +TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe(NULL); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +#ifdef CBM_ENABLE_TEST_SEAMS +TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, "true"); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 0); + PASS(); +#endif /* _WIN32 */ +} + +TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, NULL); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 1); + PASS(); +#endif /* _WIN32 */ +} +#endif /* CBM_ENABLE_TEST_SEAMS */ + /* ══════════════════════════════════════════════════════════════════ * #1466 / #713 — the auto_index_limit guard * @@ -20766,6 +21051,13 @@ SUITE(mcp) { RUN_TEST(autoindex_limit_guards_non_git_root_issue713); RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713); RUN_TEST(autoindex_limit_guards_git_root_issue713); + /* ignore_worktrees gate */ + RUN_TEST(mcp_ignore_worktrees_gates_explicit_index_repository); + RUN_TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree); +#ifdef CBM_ENABLE_TEST_SEAMS + RUN_TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree); + RUN_TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count); +#endif } /* Kept separate so daemon-coordination regressions can be iterated without From 71ac6ecc15d3ab3ebcd31f0e467071a506006ca2 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Sat, 26 Sep 2026 23:01:11 +0200 Subject: [PATCH 07/61] fix(mcp): reuse response cleanup in frontend worker Signed-off-by: Pxx500 --- src/daemon/frontend.c | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/daemon/frontend.c b/src/daemon/frontend.c index d65284bd7..a4d4f8846 100644 --- a/src/daemon/frontend.c +++ b/src/daemon/frontend.c @@ -580,6 +580,12 @@ static bool frontend_recover_client(frontend_state_t *state, frontend_item_t *it return usable; } +static void frontend_response_reset(uint8_t **response, uint32_t *response_length) { + free(*response); + *response = NULL; + *response_length = 0; +} + static void *frontend_worker(void *opaque) { frontend_state_t *state = opaque; for (;;) { @@ -606,9 +612,7 @@ static void *frontend_worker(void *opaque) { state->client, item.request_token, item.message, &response, &response_length, &request_sent, FRONTEND_REQUEST_TIMEOUT_MS); if (status == CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && !request_sent) { - free(response); - response = NULL; - response_length = 0; + frontend_response_reset(&response, &response_length); bool cancelled = false; if (frontend_recover_client(state, &item, &cancelled)) { if (cancelled) { @@ -632,7 +636,7 @@ static void *frontend_worker(void *opaque) { item.content_length_framed); } } - free(response); + frontend_response_reset(&response, &response_length); bool expected_stop = failed && frontend_should_stop(state); frontend_end_request(state, failed && !expected_stop); frontend_item_free(&item); From 16698e8193fa1dbb53f34f7205569495b3b5fbca Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 4 Aug 2026 07:22:01 -0400 Subject: [PATCH 08/61] feat(config): add ignore_worktrees to skip linked git worktrees Agents that use `git worktree add` to run parallel branches end up with one indexed project per worktree. Each is a near-duplicate of the main checkout, so the cache fills with redundant graphs and project pickers get noisy. Add an opt-in `ignore_worktrees` config key. When enabled, a linked worktree is left alone: - auto-index on connect skips it (logged as autoindex.skip) - the daemon's background auto-index skips it - explicit index_repository refuses with an actionable message naming both ways forward (index_worktree=true, or turn the key off) - the hook-augment "not indexed" guidance stops telling the agent to run index_repository in a directory where it would only be refused Detection is git plumbing, no subprocess: /.git must be a regular file holding a "gitdir:" pointer AND that gitdir must contain a `commondir` file. The commondir check is what separates a linked worktree from a submodule, whose .git is also a gitlink file but whose gitdir has no commondir. The main checkout of a repo that has worktrees is never affected. Defaults to false, so behaviour is unchanged unless the key is set. Signed-off-by: Nohj --- README.md | 3 + docs/CONFIGURATION.md | 30 ++++ src/cli/cli.c | 1 + src/cli/cli.h | 4 +- src/cli/hook_augment.c | 20 ++- src/daemon/application.c | 6 + src/git/git_context.c | 60 ++++++++ src/git/git_context.h | 12 ++ src/mcp/mcp.c | 36 ++++- src/mcp/mcp_internal.h | 5 + tests/test_cli.c | 22 ++- tests/test_git_context.c | 114 +++++++++++++++ tests/test_mcp.c | 292 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 597 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9c82c18ae..fb4ce7326 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ Watcher registration is controlled separately by `auto_watch` (default `true`). To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch` — which is consulted per session — `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings). +Linked git worktrees are indexed as their own projects by default. Set `config set ignore_worktrees true` to skip them on the automatic paths — useful when short-lived `git worktree add` checkouts would otherwise each leave behind a permanent index of the same repository. An explicit `index_repository` call on a worktree is then refused unless you pass `index_worktree=true`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#ignore_worktrees). + ### Keeping Up to Date **Updates run from the install script on every platform, not from inside the running binary.** `codebase-memory-mcp update` validates your flags and then prints the exact command to run: @@ -735,6 +737,7 @@ codebase-memory-mcp config list # show all settings codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true) +codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false) codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 038256976..4c27e7f57 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,6 +87,7 @@ Current keys: |---|---|---| | `auto_index` | `false` | Automatically index new projects when an MCP session starts. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | +| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. | | `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). | | `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | @@ -126,6 +127,35 @@ and preserves any previously serving database. See [Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and error-response details. +### `ignore_worktrees` + +Every indexed project is registered under its own absolute root path, so each +linked worktree becomes a separate permanent index. On machines that create many +short-lived worktrees, the automatic paths (`auto_index`, and the session hook's +"index this project first" guidance) turn every throwaway checkout into another +stored index of what is largely the same repository. + +Enable the key to keep those checkouts out of the index: + +```bash +codebase-memory-mcp config set ignore_worktrees true +``` + +With it enabled: + +- automatic indexing skips a session whose root is a linked worktree; +- the `hook-augment` context says the worktree is unindexed on purpose instead + of telling the agent to run `index_repository`; +- an explicit `index_repository` call on a linked worktree is refused, and names + both ways forward — pass `index_worktree=true` for that one call, or turn the + key back off. + +The main checkout of the same repository is unaffected, as are ordinary clones +and submodules. Detection is git plumbing only: a linked worktree's `.git` is a +file pointing at a gitdir that contains a `commondir` entry. + +The default is `false`, so indexing behavior is unchanged unless you opt in. + ## 3. UI Settings The optional built-in graph UI stores its settings in: diff --git a/src/cli/cli.c b/src/cli/cli.c index 6e33759e4..08aded621 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7442,6 +7442,7 @@ static const config_key_def_t CONFIG_KEYS[] = { {CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"}, {CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"}, {CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"}, + {CBM_CONFIG_IGNORE_WORKTREES, "false", "Skip linked git worktrees when indexing automatically"}, {CBM_CONFIG_WATCHER_ENABLED, "true", "Run the background watcher thread (auto-reindex); false to disable"}, {CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"}, diff --git a/src/cli/cli.h b/src/cli/cli.h index 5522edef0..a6ef66618 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -262,7 +262,8 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect, const char *forced_event); bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows); void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter); @@ -443,6 +444,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t #define CBM_CONFIG_AUTO_INDEX "auto_index" #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" #define CBM_CONFIG_AUTO_WATCH "auto_watch" +#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees" #define CBM_CONFIG_UI_LANG "ui-lang" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" /* #1558: the graph UI's loopback listener. Stored in the UI config file rather diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index f8909c365..1043b48cd 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -21,7 +21,9 @@ #include "foundation/constants.h" #include "foundation/mem.h" #include "foundation/platform.h" +#include "git/git_context.h" #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" #include "yyjson/yyjson.h" @@ -1440,7 +1442,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) { return "Tier 2 verification"; } -static const char *ha_no_project_index_guidance(const char *event) { +static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) { + /* ignore_worktrees is on and this cwd is a linked worktree: indexing it is + * deliberately disabled, so telling the agent to run index_repository would + * only produce a refusal. Point at the setting instead. */ + if (worktree_ignored) { + return "This is a linked git worktree and ignore_worktrees is enabled, so it is not " + "indexed on purpose; do not run index_repository here."; + } return event && strcmp(event, "SubagentStart") == 0 ? "Ask the parent agent to run index_repository before structural exploration; " "do not attempt graph mutation." @@ -1472,6 +1481,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; + bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && + cbm_git_is_linked_worktree(cwd); cbm_mcp_server_free(owned_server); char context[2048]; @@ -1505,7 +1516,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "and file reads for literals, configs, non-code files, and verification.", scope, safe_project, tier); } else { - const char *index_guidance = ha_no_project_index_guidance(event); + const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored); snprintf(context, sizeof(context), "[codebase-memory] %s context: no indexed graph project matched this working " "directory. %s Once indexed, " @@ -1624,8 +1635,9 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, return ha_path_contains_mode(root, candidate, case_insensitive); } -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { - return ha_no_project_index_guidance(event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored) { + return ha_no_project_index_guidance(event, worktree_ignored); } bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) { diff --git a/src/daemon/application.c b/src/daemon/application.c index 748b8bb75..8a79ecd5d 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -15,6 +15,7 @@ #include "foundation/sha256.h" #include "foundation/subprocess.h" #include "foundation/workspace.h" +#include "git/git_context.h" #include "mcp/index_supervisor.h" #include "mcp/mcp.h" #include "mcp/mcp_internal.h" @@ -2071,6 +2072,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio !application_session_workspace_allowed(session, "auto_index_discovery")) { auto_index_candidate = false; } + if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree"); + auto_index_candidate = false; + } bool within_auto_index_limit = !auto_index_candidate || cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files); diff --git a/src/git/git_context.c b/src/git/git_context.c index 47956562f..cb27c17cf 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -100,6 +100,66 @@ static bool path_is_absolute(const char *path) { #endif } +/* Read the "gitdir: " pointer out of a gitlink FILE at /.git. + * Returns false when .git is missing, a directory (ordinary repo), or holds no + * pointer. A relative pointer is resolved against path. */ +static bool read_gitlink_target(const char *path, char *out, size_t out_size) { + char dot_git[GIT_OUTPUT_MAX]; + int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path); + if (n < 0 || n >= (int)sizeof(dot_git)) { + return false; + } + struct stat st; + if (stat(dot_git, &st) != 0 || !S_ISREG(st.st_mode)) { + return false; + } + + FILE *f = cbm_fopen(dot_git, "r"); + if (!f) { + return false; + } + char line[GIT_OUTPUT_MAX]; + bool got = false; + while (fgets(line, sizeof(line), f)) { + trim_newlines(line); + if (strncmp(line, "gitdir:", 7) != 0) { + continue; + } + const char *value = line + 7; + while (*value == ' ' || *value == '\t') { + value++; + } + if (!value[0]) { + break; + } + int written = path_is_absolute(value) ? snprintf(out, out_size, "%s", value) + : snprintf(out, out_size, "%s/%s", path, value); + got = written > 0 && written < (int)out_size; + break; + } + fclose(f); + return got; +} + +bool cbm_git_is_linked_worktree(const char *path) { + if (!path || !path[0]) { + return false; + } + char git_dir[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) { + return false; + } + /* Only linked worktrees carry /commondir; a submodule gitlink + * points at /.git/modules/, which does not. */ + char commondir[GIT_OUTPUT_MAX]; + int n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); + if (n < 0 || n >= (int)sizeof(commondir)) { + return false; + } + struct stat st; + return stat(commondir, &st) == 0 && S_ISREG(st.st_mode); +} + static char *join_root_relative(const char *root, const char *rel) { if (!root || !root[0]) { return git_strdup(rel); diff --git a/src/git/git_context.h b/src/git/git_context.h index 876309eb6..633e28018 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -19,6 +19,18 @@ typedef struct { char *base_sha; } cbm_git_context_t; +/* True when path is the root of a LINKED git worktree (`git worktree add`). + * + * Plumbing-only, no subprocess: /.git must be a regular file holding a + * "gitdir: " pointer AND that gitdir must contain a `commondir` file. + * The commondir check is what separates a linked worktree from a submodule — + * a submodule's .git is also a gitlink file, but its gitdir + * (/.git/modules/) has no commondir entry. + * + * Callers run this on every session start, so it stays fork-free; the richer + * cbm_git_context_resolve() shells out to git and is not usable on that path. */ +bool cbm_git_is_linked_worktree(const char *path); + int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 78b8dccf9..41a63273e 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -478,7 +478,10 @@ static const tool_def_t TOOLS[] = { "\"name\":{\"type\":\"string\",\"description\":" "\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" - "\"Write .codebase-memory/graph.db.zst.\"}" + "\"Write .codebase-memory/graph.db.zst.\"}," + "\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":" + "\"Index repo_path even when it is a linked git worktree and the " + "ignore_worktrees config key is enabled. No effect otherwise.\"}" "},\"required\":[\"repo_path\"]}"}, {"search_graph", @@ -11174,6 +11177,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(boundary_err, true); } + /* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree + * is refused with the two ways forward (per-call override, or turn the + * setting off) rather than silently skipped — a silent success would be + * indistinguishable from a real index to the caller. */ + if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") && + cbm_git_is_linked_worktree(repo_path)) { + index_args_free(repo_path, mode_str, name_override); + return cbm_mcp_text_result( + "repo_path is a linked git worktree and ignore_worktrees is enabled. Pass " + "index_worktree=true to index it anyway, or run: codebase-memory-mcp config set " + "ignore_worktrees false", + true); + } + if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { char *result = handle_cross_repo_mode(srv, repo_path, name_override, args); index_args_free(repo_path, mode_str, name_override); @@ -17613,6 +17630,17 @@ static bool auto_watch_enabled(cbm_mcp_server_t *srv) { return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true); } +/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees + * (default off, so existing setups keep indexing them). Users who create many + * short-lived worktrees can stop each throwaway checkout from registering a + * new permanent project with `config set ignore_worktrees true`. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) { + if (!srv || !srv->config) { + return false; /* default off */ + } + return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false); +} + /* Register the session project with the background watcher for ongoing * change detection — unless auto_watch is disabled. */ static void register_watcher_if_enabled(cbm_mcp_server_t *srv) { @@ -17765,6 +17793,12 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { return; } + if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) { + cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project", + srv->session_project); + return; + } + /* Quick tracked-file count check to avoid OOM on massive repos. */ int file_count = -1; #ifdef CBM_ENABLE_TEST_SEAMS diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index de170067a..8e78f9f40 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -63,6 +63,11 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy); bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, int *file_count_out); +/* True when the `ignore_worktrees` config key is on for this server (default + * off). Callers pair it with cbm_git_is_linked_worktree() to decide whether an + * automatic index of a linked worktree should be skipped. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv); + /* detect_changes seed scoping (#1363): does `node`'s line range overlap any * recorded hunk for `file`? Exposed for direct unit testing of the overlap * logic, independent of the git/subprocess/index plumbing around it. */ diff --git a/tests/test_cli.c b/tests/test_cli.c index 7cd44244e..14553ef68 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -12536,14 +12536,18 @@ TEST(cli_hook_augment_subagent_tier_router_contract) { } TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only) { - const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart"); - const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart"); + const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart", false); + const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart", false); + const char *worktree = cbm_hook_no_project_index_guidance_for_testing("SessionStart", true); ASSERT_NOT_NULL(session); ASSERT_NOT_NULL(subagent); + ASSERT_NOT_NULL(worktree); ASSERT(strstr(session, "Run index_repository") != NULL); ASSERT(strstr(subagent, "Ask the parent agent to run index_repository") != NULL); ASSERT(strstr(subagent, "do not attempt graph mutation") != NULL); ASSERT(strstr(subagent, "Run index_repository") == NULL); + ASSERT(strstr(worktree, "ignore_worktrees is enabled") != NULL); + ASSERT(strstr(worktree, "do not run index_repository") != NULL); PASS(); } @@ -15941,6 +15945,19 @@ TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558) { PASS(); } +TEST(cli_ignore_worktrees_config_key_is_discoverable) { + bool listed = false; + for (size_t i = 0; i < cbm_cli_config_key_count_for_testing(); i++) { + const char *key = cbm_cli_config_key_at_for_testing(i); + if (key && strcmp(key, CBM_CONFIG_IGNORE_WORKTREES) == 0) { + listed = true; + break; + } + } + ASSERT_TRUE(listed); + PASS(); +} + TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554) { const cbm_skill_t *sk = cbm_get_skills(); ASSERT_NOT_NULL(sk); @@ -16374,6 +16391,7 @@ SUITE(cli) { RUN_TEST(cli_update_download_failure_does_not_quiesce_sessions); RUN_TEST(cli_update_already_current_does_not_quiesce_sessions); RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558); + RUN_TEST(cli_ignore_worktrees_config_key_is_discoverable); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index cc94f1d06..894cb68aa 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -253,6 +253,118 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +/* ── cbm_git_is_linked_worktree ─────────────────────────────────── */ +/* Detection backing the `ignore_worktrees` config gate. The predicate must be + * TRUE only for a linked worktree (`git worktree add`) — never for the main + * checkout, a plain directory, or a submodule. The submodule case is the one a + * naive "is .git a regular file?" check gets wrong: a submodule's .git is also a + * gitlink file, and it is separated here by the absence of a `commondir` entry + * in the pointed-at gitdir. */ + +TEST(is_linked_worktree_true_for_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + /* th_mktempdir() returns a static buffer — copy before the second call. */ + char main_tmp[256]; + char *raw = th_mktempdir("cbm_wt_main"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(main_tmp, raw, sizeof(main_tmp) - 1); + main_tmp[sizeof(main_tmp) - 1] = '\0'; + + char wt_tmp[256]; + raw = th_mktempdir("cbm_wt_linked"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(wt_tmp, raw, sizeof(wt_tmp) - 1); + wt_tmp[sizeof(wt_tmp) - 1] = '\0'; + th_rmtree(wt_tmp); /* git worktree add creates it */ + + if (make_git_repo(main_tmp) != 0) { + th_rmtree(main_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (git_run(main_tmp, "branch wt-branch") != 0) { + th_rmtree(main_tmp); + FAIL("failed to create branch for worktree"); + } + char wt_cmd[1024]; + snprintf(wt_cmd, sizeof(wt_cmd), "worktree add \"%s\" wt-branch", wt_tmp); + if (git_run(main_tmp, wt_cmd) != 0) { + th_rmtree(wt_tmp); + th_rmtree(main_tmp); + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + + bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp); + /* The MAIN checkout of the very same repo must NOT be flagged — otherwise + * enabling ignore_worktrees would stop indexing ordinary repositories. */ + bool main_detected = cbm_git_is_linked_worktree(main_tmp); + + git_run(main_tmp, "worktree prune"); + th_rmtree(main_tmp); + th_rmtree(wt_tmp); + + ASSERT(worktree_detected); + ASSERT(!main_detected); + PASS(); +#endif /* _WIN32 */ +} + +TEST(is_linked_worktree_false_for_submodule_and_nongit) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + char super_tmp[256]; + char *raw = th_mktempdir("cbm_wt_super"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(super_tmp, raw, sizeof(super_tmp) - 1); + super_tmp[sizeof(super_tmp) - 1] = '\0'; + + char child_tmp[256]; + raw = th_mktempdir("cbm_wt_child"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(child_tmp, raw, sizeof(child_tmp) - 1); + child_tmp[sizeof(child_tmp) - 1] = '\0'; + + if (make_git_repo(super_tmp) != 0 || make_git_repo(child_tmp) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + /* A plain directory that is not a git repo at all. */ + char plain[1024]; + snprintf(plain, sizeof(plain), "%s/plain", super_tmp); + if (th_mkdir_p(plain) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + FAIL("failed to create plain dir"); + } + bool plain_detected = cbm_git_is_linked_worktree(plain); + + /* file:// submodules are refused by default since the CVE-2022-39253 fix. */ + char sub_cmd[1024]; + snprintf(sub_cmd, sizeof(sub_cmd), + "-c protocol.file.allow=always submodule add -q \"%s\" subm", child_tmp); + int sub_rc = git_run(super_tmp, sub_cmd); + + char subm[1024]; + snprintf(subm, sizeof(subm), "%s/subm", super_tmp); + bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm); + + th_rmtree(super_tmp); + th_rmtree(child_tmp); + + ASSERT(!plain_detected); + if (sub_rc != 0) { + SKIP_PLATFORM("git submodule add unavailable in this environment"); + } + /* A submodule gitlink has no commondir → must not be treated as a worktree. */ + ASSERT(!submodule_detected); + PASS(); +#endif /* _WIN32 */ +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { @@ -260,4 +372,6 @@ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(is_linked_worktree_true_for_linked_worktree); + RUN_TEST(is_linked_worktree_false_for_submodule_and_nongit); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 60649f505..b7b1335a1 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -19335,6 +19335,291 @@ TEST(mcp_auto_watch_false_skips_watcher_on_connect) { PASS(); } +/* ═══════════════════════════════════════════════════════════════ + * ignore_worktrees — explicit index_repository on a linked worktree + * ═══════════════════════════════════════════════════════════════ */ + +/* An EXPLICIT index_repository call is refused only when ignore_worktrees is on + * AND repo_path is a linked worktree AND no per-call override was passed. The + * refusal must never fire for the MAIN checkout — that would break ordinary + * indexing for anyone enabling the key — and index_worktree=true must escape it. + * + * Default-off is the regression class the project guards hardest: with the key + * unset, a linked worktree still indexes exactly as today. + * + * Probe returns a bit set, or a negative fixture-setup code. */ +enum { + IGNORE_WT_REFUSED_WORKTREE = 1, /* expected when the key is on */ + IGNORE_WT_REFUSED_MAIN = 2, /* BUG if set */ + IGNORE_WT_REFUSED_OVERRIDE = 4, /* BUG if set */ +}; + +#ifndef _WIN32 +typedef struct { + char cache[256]; + char main_repo[512]; + char wt_repo[512]; +} ignore_wt_fixture_t; + +static void ignore_wt_fixture_cleanup(ignore_wt_fixture_t *fx) { + if (!fx) { + return; + } + if (fx->main_repo[0]) { + char prune[1024]; + snprintf(prune, sizeof(prune), "git -C \"%s\" worktree prune >/dev/null 2>&1", fx->main_repo); + (void)system(prune); + } + if (fx->cache[0]) { + th_rmtree(fx->cache); + } +} + +/* 0 = ready. -1 tmpdir, -2 mkdir, -3 git worktree fixture unavailable. */ +static int ignore_wt_fixture_setup(ignore_wt_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + char *raw = th_mktempdir("cbm_ignorewt"); + if (!raw) { + return -1; + } + snprintf(fx->cache, sizeof(fx->cache), "%s", raw); + snprintf(fx->main_repo, sizeof(fx->main_repo), "%s/main", fx->cache); + snprintf(fx->wt_repo, sizeof(fx->wt_repo), "%s/wt", fx->cache); + if (th_mkdir_p(fx->main_repo) != 0) { + ignore_wt_fixture_cleanup(fx); + return -2; + } + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" init -q >/dev/null 2>&1 && " + "git -C \"%s\" config user.email t@example.com && " + "git -C \"%s\" config user.name T && touch \"%s/.keep\" && " + "git -C \"%s\" add .keep && git -C \"%s\" commit -q -m init && " + "git -C \"%s\" worktree add -q \"%s\" -b wtb", + fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, + fx->main_repo, fx->main_repo, fx->wt_repo); + if (system(cmd) != 0) { + ignore_wt_fixture_cleanup(fx); + return -3; + } + return 0; +} + +static bool ignore_wt_refused(cbm_mcp_server_t *srv, const char *repo_path, bool override) { + char args[2048]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"%s}", repo_path, + override ? ",\"index_worktree\":true" : ""); + char *result = cbm_mcp_handle_tool(srv, "index_repository", args); + bool refused = result && strstr(result, "ignore_worktrees is enabled") != NULL; + free(result); + return refused; +} + +static int ignore_worktrees_index_probe(const char *ignore_value) { + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup < 0) { + return setup; + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", fx.cache, 1); + + int bits = -4; + cbm_config_t *cfg = cbm_config_open(fx.cache); + if (cfg) { + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (srv) { + cbm_mcp_server_set_config(srv, cfg); + bits = 0; + if (ignore_wt_refused(srv, fx.wt_repo, false)) { + bits |= IGNORE_WT_REFUSED_WORKTREE; + } + if (ignore_wt_refused(srv, fx.main_repo, false)) { + bits |= IGNORE_WT_REFUSED_MAIN; + } + if (ignore_wt_refused(srv, fx.wt_repo, true)) { + bits |= IGNORE_WT_REFUSED_OVERRIDE; + } + cbm_mcp_server_free(srv); + } + cbm_config_close(cfg); + } + + restore_cache_dir(saved_copy); + free(saved_copy); + ignore_wt_fixture_cleanup(&fx); + return bits; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +static void ignore_wt_count_started(void *context) { + int *calls = context; + (*calls)++; +} + +/* Drive initialize → maybe_auto_index on a linked worktree. ignore_value NULL + * leaves the key unset (default-off). The count hook fires only if auto-index + * reaches discovery, so a skip for linked_worktree must leave it at 0. */ +static int ignore_wt_autoindex_count_calls(const char *session_root, const char *ignore_value) { + char *raw = th_mktempdir("cbm_ignorewt_auto"); + if (!raw) { + return -1; + } + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char old_cwd[1024]; + if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(session_root) != 0) { + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return -2; + } + + int calls = -3; + cbm_config_t *cfg = cbm_config_open(cache); + cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; + if (srv) { + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); + /* Fail-closed on the actual count so this never launches an index thread. */ + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "-1"); + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_set_config(srv, cfg); + calls = 0; + cbm_mcp_server_set_auto_index_count_test_hook(srv, ignore_wt_count_started, &calls); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1430,\"method\":\"initialize\",\"params\":{}}"); + free(response); + cbm_mcp_server_free(srv); + } + if (cfg) { + cbm_config_close(cfg); + } + + (void)cbm_chdir(old_cwd); + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return calls; +} +#endif /* CBM_ENABLE_TEST_SEAMS */ +#endif /* !_WIN32 */ + +TEST(mcp_ignore_worktrees_gates_explicit_index_repository) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe("true"); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + /* RED before the gate existed: nothing is ever refused. */ + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) != 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +/* Default-off pin: key unset, linked worktree still indexes. The gate is + * opt-in; an unset key must be bit-for-bit today's behaviour. */ +TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe(NULL); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +#ifdef CBM_ENABLE_TEST_SEAMS +TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, "true"); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 0); + PASS(); +#endif /* _WIN32 */ +} + +TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, NULL); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 1); + PASS(); +#endif /* _WIN32 */ +} +#endif /* CBM_ENABLE_TEST_SEAMS */ + /* ══════════════════════════════════════════════════════════════════ * #1466 / #713 — the auto_index_limit guard * @@ -20824,6 +21109,13 @@ SUITE(mcp) { RUN_TEST(autoindex_limit_guards_non_git_root_issue713); RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713); RUN_TEST(autoindex_limit_guards_git_root_issue713); + /* ignore_worktrees gate */ + RUN_TEST(mcp_ignore_worktrees_gates_explicit_index_repository); + RUN_TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree); +#ifdef CBM_ENABLE_TEST_SEAMS + RUN_TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree); + RUN_TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count); +#endif } /* Kept separate so daemon-coordination regressions can be iterated without From d63d0e16d18cf46836f0d019a03726bb6a577d16 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 03:23:24 +0000 Subject: [PATCH 09/61] fix(git): probe worktree gitlinks without stat() Upstream's Unicode path fix dropped stat() from this file because it follows symlinks and rejects non-ASCII paths on Windows. Keep ignore_worktrees detection on cbm_path_info_utf8 so the rebase does not restore that probe. Signed-off-by: Nohj --- src/git/git_context.c | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index cb27c17cf..3a3da7acb 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -86,6 +86,14 @@ static int git_capture(const char *repo_path, const char *git_args, char **out) return *out ? 0 : CBM_NOT_FOUND; } +/* Regular file, not a directory and not a symlink. Uses the Unicode-safe + * path probe: stat() follows links and rejects non-ASCII paths on Windows, + * which upstream removed from this file in the Unicode repository-path fix. */ +static bool path_is_regular_file(const char *path) { + cbm_path_info_t info; + return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_regular; +} + static bool path_is_absolute(const char *path) { if (!path || !path[0]) { return false; @@ -109,8 +117,7 @@ static bool read_gitlink_target(const char *path, char *out, size_t out_size) { if (n < 0 || n >= (int)sizeof(dot_git)) { return false; } - struct stat st; - if (stat(dot_git, &st) != 0 || !S_ISREG(st.st_mode)) { + if (!path_is_regular_file(dot_git)) { return false; } @@ -156,8 +163,7 @@ bool cbm_git_is_linked_worktree(const char *path) { if (n < 0 || n >= (int)sizeof(commondir)) { return false; } - struct stat st; - return stat(commondir, &st) == 0 && S_ISREG(st.st_mode); + return path_is_regular_file(commondir); } static char *join_root_relative(const char *root, const char *rel) { From 3bb85c8c7c3c005fc59bed0a8ee5c467d903a761 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 03:37:57 +0000 Subject: [PATCH 10/61] fix(daemon): recheck ignore_worktrees on auto-index retry A session queued while capacity was full could still be indexed after the setting was turned on. Retry admission now applies the same linked-worktree gate as the initial path. Signed-off-by: Nohj --- src/daemon/application.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/daemon/application.c b/src/daemon/application.c index 8a79ecd5d..153bc0d81 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -1457,6 +1457,18 @@ static void application_auto_index_retry_pending_locked(cbm_daemon_application_t application_refresh_watch_locked(session); continue; } + /* ignore_worktrees can be turned on after this session was queued for + * a capacity slot. Initial admission already skips linked worktrees; + * retry must apply the same gate or the pending checkout is indexed + * once a slot opens. */ + if (cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + session->auto_index_retry_pending = false; + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", + "linked_worktree"); + application_refresh_watch_locked(session); + continue; + } char *args = application_auto_index_args(application, root_path); if (!args) { continue; From 5d5c9621eb3e94ee26ab39c26b15237fb38223df Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 10:44:02 +0000 Subject: [PATCH 11/61] fix(cli): load runtime config for the hook-augment worktree note The production hook builds its server with cbm_mcp_server_new(NULL), so ignore_worktrees stayed off and an unindexed linked worktree still told the agent to run index_repository. Attach the runtime store for that check, then drop it before return. Signed-off-by: Nohj --- src/cli/hook_augment.c | 14 ++++++++++++++ src/mcp/mcp.c | 12 ++++++++++++ src/mcp/mcp.h | 6 ++++++ tests/test_cli.c | 16 +++++++++++++++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 1043b48cd..a389e487f 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1475,14 +1475,28 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root char cwd_buffer[4096]; cbm_mcp_server_t *owned_server = NULL; + cbm_config_t *owned_config = NULL; if (!srv) { owned_server = cbm_mcp_server_new(NULL); srv = owned_server; } + /* cbm_mcp_server_new(NULL) does not attach the runtime store. The production + * hook uses that constructor, so ignore_worktrees would stay at its default + * (off) and the "not indexed" note would still tell the agent to run + * index_repository in a linked worktree the setting refuses. Load the store + * only when the caller has not already set one, then drop it before return + * so a caller-owned server is not left pointing at a closed config. */ + if (srv) { + owned_config = cbm_mcp_server_attach_runtime_config(srv); + } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); + if (owned_config) { + cbm_mcp_server_set_config(srv, NULL); + cbm_config_close(owned_config); + } cbm_mcp_server_free(owned_server); char context[2048]; diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 41a63273e..19083feee 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1751,6 +1751,18 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } +struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv) { + if (!srv || srv->config) { + return NULL; + } + cbm_config_t *cfg = cbm_config_open(cbm_resolve_cache_dir()); + if (!cfg) { + return NULL; + } + srv->config = cfg; + return cfg; +} + #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv, cbm_mcp_auto_index_count_test_hook_fn hook, diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 09befb780..6fb110181 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -158,6 +158,12 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w); /* Set external config store reference (for auto_index setting). Not owned. */ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg); +/* Open the runtime config and attach it when srv has none. The returned store + * is owned by the caller, who must clear it with cbm_mcp_server_set_config + * before closing. Returns NULL when a config is already set or the store + * cannot be opened. */ +struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv); + /* Set an explicit session context for an embedded/daemon-backed server. * session_root is copied and its project name is derived using the same naming * rule as indexing. allowed_root is copied when non-NULL; an explicit NULL diff --git a/tests/test_cli.c b/tests/test_cli.c index 14553ef68..5d7441ec4 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10629,9 +10629,21 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir); char *wt_out = cbm_hook_augment_lifecycle_json(input); bool deliberate = wt_out && wt_out[0] && strstr(wt_out, "no indexed graph project matched") && - !strstr(wt_out, "is indexed"); + strstr(wt_out, "Run index_repository") && + !strstr(wt_out, "ignore_worktrees is enabled") && !strstr(wt_out, "is indexed"); free(wt_out); + /* The hook must read the runtime store. With the key off the note still + * says to index; with it on, the same unindexed worktree must not. */ + cbm_config_t *cfg = cbm_config_open(cache); + bool configured = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(cfg); + char *ignored_out = configured ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool honored = ignored_out && strstr(ignored_out, "ignore_worktrees is enabled") && + strstr(ignored_out, "do not run index_repository") && + !strstr(ignored_out, "is indexed"); + free(ignored_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -10647,6 +10659,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { if (!deliberate) FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); + if (!honored) + FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); From 0071007396568b44fb331bb876084dd88a91a415 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 10:59:57 +0000 Subject: [PATCH 12/61] fix(git): detect linked worktrees from nested directories ignore_worktrees only inspected /.git, so a session or index path inside a linked worktree still passed the gate. Walk ancestors until a git anchor, and stop at a main checkout or a submodule. Signed-off-by: Nohj --- src/git/git_context.c | 87 +++++++++++++++++++++++++++++++++++++--- src/git/git_context.h | 13 +++--- tests/test_git_context.c | 23 ++++++++++- 3 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index 3a3da7acb..3fbf5b65d 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -148,22 +148,97 @@ static bool read_gitlink_target(const char *path, char *out, size_t out_size) { return got; } -bool cbm_git_is_linked_worktree(const char *path) { - if (!path || !path[0]) { - return false; +/* 1 = linked-worktree gitlink, 0 = a different git anchor (stop walking), + * -1 = no .git at this directory (keep walking). */ +static int linked_worktree_anchor(const char *path) { + char dot_git[GIT_OUTPUT_MAX]; + int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path); + if (n < 0 || n >= (int)sizeof(dot_git)) { + return 0; + } + cbm_path_info_t info; + if (cbm_path_info_utf8(dot_git, &info) != CBM_PATH_INFO_OK) { + return -1; + } + /* A directory .git is a main checkout. Anything else that is not a + * regular gitlink file (a symlink, for example) is also an anchor: + * do not follow it, and do not blame a parent worktree for this tree. */ + if (!info.is_regular) { + return 0; } char git_dir[GIT_OUTPUT_MAX]; if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) { - return false; + return 0; } /* Only linked worktrees carry /commondir; a submodule gitlink * points at /.git/modules/, which does not. */ char commondir[GIT_OUTPUT_MAX]; - int n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); + n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); if (n < 0 || n >= (int)sizeof(commondir)) { + return 0; + } + return path_is_regular_file(commondir) ? 1 : 0; +} + +static bool parent_directory(char *path) { + size_t n = strlen(path); + while (n > 1 && (path[n - 1] == '/' || path[n - 1] == '\\')) { + path[--n] = '\0'; + } +#ifdef _WIN32 + if ((n == 2 && path[1] == ':') || + (n == 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\'))) { return false; } - return path_is_regular_file(commondir); +#endif + if (n <= 1) { + return false; + } + char *slash = NULL; + for (size_t i = 0; i < n; i++) { + if (path[i] == '/' || path[i] == '\\') { + slash = path + i; + } + } + if (!slash) { + return false; + } + if (slash == path) { + path[1] = '\0'; + return true; + } + *slash = '\0'; + return true; +} + +bool cbm_git_is_linked_worktree(const char *path) { + if (!path || !path[0]) { + return false; + } + char current[GIT_OUTPUT_MAX]; + int n = snprintf(current, sizeof(current), "%s", path); + if (n < 0 || n >= (int)sizeof(current)) { + return false; + } + size_t len = strlen(current); + while (len > 1 && (current[len - 1] == '/' || current[len - 1] == '\\')) { + current[--len] = '\0'; + } + /* A session cwd or index_repository path is often a subdirectory. Walk + * ancestors until a git anchor so ignore_worktrees applies to the whole + * linked checkout, not only its root. */ + for (int depth = 0; depth < 64; depth++) { + int kind = linked_worktree_anchor(current); + if (kind >= 0) { + return kind == 1; + } + char previous[GIT_OUTPUT_MAX]; + snprintf(previous, sizeof(previous), "%s", current); + if (!parent_directory(current) || strcmp(previous, current) == 0) { + return false; + } + } + return false; } static char *join_root_relative(const char *root, const char *rel) { diff --git a/src/git/git_context.h b/src/git/git_context.h index 633e28018..30f2f545a 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -19,13 +19,14 @@ typedef struct { char *base_sha; } cbm_git_context_t; -/* True when path is the root of a LINKED git worktree (`git worktree add`). +/* True when path is a LINKED git worktree (`git worktree add`) or a + * subdirectory of one. * - * Plumbing-only, no subprocess: /.git must be a regular file holding a - * "gitdir: " pointer AND that gitdir must contain a `commondir` file. - * The commondir check is what separates a linked worktree from a submodule — - * a submodule's .git is also a gitlink file, but its gitdir - * (/.git/modules/) has no commondir entry. + * Plumbing-only, no subprocess. Walk ancestors until a git anchor. A linked + * worktree's /.git is a regular file holding a "gitdir: " pointer + * AND that gitdir contains a `commondir` file. The walk stops at any other + * anchor: a main checkout (`.git` is a directory) or a submodule (gitlink + * file whose gitdir, /.git/modules/, has no commondir). * * Callers run this on every session start, so it stays fork-free; the richer * cbm_git_context_resolve() shells out to git and is not usable on that path. */ diff --git a/tests/test_git_context.c b/tests/test_git_context.c index 894cb68aa..5b3c4b779 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -296,16 +296,30 @@ TEST(is_linked_worktree_true_for_linked_worktree) { } bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp); + /* A subdirectory has no .git of its own. The predicate must still see the + * linked worktree above it, or ignore_worktrees misses nested sessions. */ + char nested[1024]; + snprintf(nested, sizeof(nested), "%s/nested/deeper", wt_tmp); + bool nested_ok = th_mkdir_p(nested) == 0; + bool nested_detected = nested_ok && cbm_git_is_linked_worktree(nested); /* The MAIN checkout of the very same repo must NOT be flagged — otherwise * enabling ignore_worktrees would stop indexing ordinary repositories. */ bool main_detected = cbm_git_is_linked_worktree(main_tmp); + char main_nested[1024]; + snprintf(main_nested, sizeof(main_nested), "%s/src", main_tmp); + bool main_nested_ok = th_mkdir_p(main_nested) == 0; + bool main_nested_detected = main_nested_ok && cbm_git_is_linked_worktree(main_nested); git_run(main_tmp, "worktree prune"); th_rmtree(main_tmp); th_rmtree(wt_tmp); ASSERT(worktree_detected); + ASSERT(nested_ok); + ASSERT(nested_detected); ASSERT(!main_detected); + ASSERT(main_nested_ok); + ASSERT(!main_nested_detected); PASS(); #endif /* _WIN32 */ } @@ -351,6 +365,10 @@ TEST(is_linked_worktree_false_for_submodule_and_nongit) { char subm[1024]; snprintf(subm, sizeof(subm), "%s/subm", super_tmp); bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm); + char sub_nested[1024]; + snprintf(sub_nested, sizeof(sub_nested), "%s/nested", subm); + bool sub_nested_made = sub_rc == 0 && th_mkdir_p(sub_nested) == 0; + bool sub_nested_detected = sub_nested_made && cbm_git_is_linked_worktree(sub_nested); th_rmtree(super_tmp); th_rmtree(child_tmp); @@ -359,8 +377,11 @@ TEST(is_linked_worktree_false_for_submodule_and_nongit) { if (sub_rc != 0) { SKIP_PLATFORM("git submodule add unavailable in this environment"); } - /* A submodule gitlink has no commondir → must not be treated as a worktree. */ + /* A submodule gitlink has no commondir → must not be treated as a worktree, + * including a subdirectory, which must not walk into the superproject. */ ASSERT(!submodule_detected); + ASSERT(sub_nested_made); + ASSERT(!sub_nested_detected); PASS(); #endif /* _WIN32 */ } From 47c7a07a745c143a499977f920f0f8273cdadc8f Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:12:54 +0000 Subject: [PATCH 13/61] fix(cli): do not treat a parent graph as a linked worktree ignore_worktrees checked the setting only after project resolution, so a linked worktree nested in an indexed checkout was reported as that parent project. Drop an ancestor match and keep an index whose root is the worktree itself. Signed-off-by: Nohj --- src/cli/hook_augment.c | 19 ++++++++++++++++--- tests/test_cli.c | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index a389e487f..b08d6ebd1 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1490,9 +1490,22 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root owned_config = cbm_mcp_server_attach_runtime_config(srv); } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); - char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; - bool worktree_ignored = !project && srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && - cbm_git_is_linked_worktree(cwd); + char project_root[4096]; + project_root[0] = '\0'; + char *project = srv && cwd ? ha_resolve_indexed_project_with_root(srv, cwd, project_root, + sizeof(project_root)) + : NULL; + /* ignore_worktrees refuses a linked worktree, including one nested inside an + * indexed checkout. The ancestor walk would otherwise report that parent + * graph as this cwd's project. An index whose own root is the linked + * worktree (or inside it) still counts. */ + bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && + cbm_git_is_linked_worktree(cwd); + if (ignore_linked && project && !cbm_git_is_linked_worktree(project_root)) { + free(project); + project = NULL; + } + bool worktree_ignored = ignore_linked && !project; if (owned_config) { cbm_mcp_server_set_config(srv, NULL); cbm_config_close(owned_config); diff --git a/tests/test_cli.c b/tests/test_cli.c index 5d7441ec4..6297a64d0 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10644,6 +10644,41 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { !strstr(ignored_out, "is indexed"); free(ignored_out); + /* A linked worktree nested inside the indexed checkout must not inherit + * that parent graph once ignore_worktrees is on. */ + char insider[512]; + snprintf(insider, sizeof(insider), "%s/inside-wt", maindir); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7inside", maindir, + insider); + bool inside_made = configured && system(cmd) == 0; + char inside_sub[600]; + snprintf(inside_sub, sizeof(inside_sub), "%s/nested", insider); + bool inside_sub_made = inside_made && test_mkdirp(inside_sub) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + inside_sub); + char *inside_out = inside_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool inside_ignored = inside_out && strstr(inside_out, "ignore_worktrees is enabled") && + strstr(inside_out, "do not run index_repository") && + !strstr(inside_out, "is indexed") && !strstr(inside_out, main_name); + free(inside_out); + + /* Indexing that nested worktree itself must still win over the parent. */ + char *inside_name = inside_made ? cbm_project_name_from_path(insider) : NULL; + bool inside_stored = false; + if (inside_name) { + char db_inside[900]; + snprintf(db_inside, sizeof(db_inside), "%s/%s.db", cache, inside_name); + cbm_store_t *si = cbm_store_open_path(db_inside); + inside_stored = si && cbm_store_upsert_project(si, inside_name, insider) == CBM_STORE_OK; + if (si) + cbm_store_close(si); + } + char *exact_out = inside_stored ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool exact_kept = exact_out && inside_name && strstr(exact_out, inside_name) && + strstr(exact_out, "is indexed") && + !strstr(exact_out, "ignore_worktrees is enabled"); + free(exact_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -10652,6 +10687,7 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { restore_test_env("CBM_CACHE_DIR", saved_cache); free(main_name); + free(inside_name); snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1", maindir, wtdir); (void)system(cmd); @@ -10661,6 +10697,10 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); if (!honored) FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); + if (!inside_ignored) + FAIL("a nested linked worktree must not inherit the indexed parent graph"); + if (!exact_kept) + FAIL("an exact index of the nested worktree must still be reported"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); From 51a18c7b27728534ffefd7b2c4963277a91aa176 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:18:45 +0000 Subject: [PATCH 14/61] fix(cli): keep a worktree graph only for that same worktree The parent-graph drop treated any linked worktree root as a match, so an unindexed worktree nested inside an indexed one still inherited that parent. Compare the gitdir of each path's own anchor and keep the project only when both paths are that same linked worktree. Signed-off-by: Nohj --- src/cli/hook_augment.c | 7 +++---- src/git/git_context.c | 37 +++++++++++++++++++++++++++++++++++-- src/git/git_context.h | 6 ++++++ tests/test_cli.c | 21 +++++++++++++++++++++ tests/test_git_context.c | 18 ++++++++++++++++++ 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index b08d6ebd1..d3baace47 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1496,12 +1496,11 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root sizeof(project_root)) : NULL; /* ignore_worktrees refuses a linked worktree, including one nested inside an - * indexed checkout. The ancestor walk would otherwise report that parent - * graph as this cwd's project. An index whose own root is the linked - * worktree (or inside it) still counts. */ + * indexed checkout or inside another linked worktree. Keep a match only when + * its root belongs to this cwd's own linked worktree, not a parent graph. */ bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); - if (ignore_linked && project && !cbm_git_is_linked_worktree(project_root)) { + if (ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root)) { free(project); project = NULL; } diff --git a/src/git/git_context.c b/src/git/git_context.c index 3fbf5b65d..8e2629cc4 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -211,7 +211,8 @@ static bool parent_directory(char *path) { return true; } -bool cbm_git_is_linked_worktree(const char *path) { +/* On a linked-worktree hit, optionally copy that anchor directory into root_out. */ +static bool walk_linked_worktree_root(const char *path, char *root_out, size_t root_out_size) { if (!path || !path[0]) { return false; } @@ -230,7 +231,16 @@ bool cbm_git_is_linked_worktree(const char *path) { for (int depth = 0; depth < 64; depth++) { int kind = linked_worktree_anchor(current); if (kind >= 0) { - return kind == 1; + if (kind != 1) { + return false; + } + if (root_out && root_out_size > 0U) { + int written = snprintf(root_out, root_out_size, "%s", current); + if (written < 0 || (size_t)written >= root_out_size) { + return false; + } + } + return true; } char previous[GIT_OUTPUT_MAX]; snprintf(previous, sizeof(previous), "%s", current); @@ -241,6 +251,29 @@ bool cbm_git_is_linked_worktree(const char *path) { return false; } +bool cbm_git_is_linked_worktree(const char *path) { + return walk_linked_worktree_root(path, NULL, 0U); +} + +bool cbm_git_same_linked_worktree(const char *a, const char *b) { + char root_a[GIT_OUTPUT_MAX]; + char root_b[GIT_OUTPUT_MAX]; + if (!walk_linked_worktree_root(a, root_a, sizeof(root_a)) || + !walk_linked_worktree_root(b, root_b, sizeof(root_b))) { + return false; + } + /* The gitdir file is unique per linked worktree. Comparing those pointers + * keeps a nested worktree from matching its parent even when both are + * linked checkouts. */ + char git_a[GIT_OUTPUT_MAX]; + char git_b[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(root_a, git_a, sizeof(git_a)) || + !read_gitlink_target(root_b, git_b, sizeof(git_b))) { + return false; + } + return strcmp(git_a, git_b) == 0; +} + static char *join_root_relative(const char *root, const char *rel) { if (!root || !root[0]) { return git_strdup(rel); diff --git a/src/git/git_context.h b/src/git/git_context.h index 30f2f545a..1b9d174f1 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -32,6 +32,12 @@ typedef struct { * cbm_git_context_resolve() shells out to git and is not usable on that path. */ bool cbm_git_is_linked_worktree(const char *path); +/* True when both paths sit in the same linked worktree. Identity is the + * gitdir pointer of each path's linked-worktree anchor, so a worktree nested + * inside another does not count as that parent. False when either path is not + * inside a linked worktree. */ +bool cbm_git_same_linked_worktree(const char *a, const char *b); + int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); diff --git a/tests/test_cli.c b/tests/test_cli.c index 6297a64d0..e234d77df 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10679,6 +10679,25 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { !strstr(exact_out, "ignore_worktrees is enabled"); free(exact_out); + /* An unindexed worktree nested inside that indexed worktree must not + * inherit the outer worktree's graph. */ + char child[600]; + snprintf(child, sizeof(child), "%s/child-wt", insider); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7child", maindir, + child); + bool child_made = inside_stored && system(cmd) == 0; + char child_sub[700]; + snprintf(child_sub, sizeof(child_sub), "%s/sub", child); + bool child_sub_made = child_made && test_mkdirp(child_sub) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + child_sub); + char *child_out = child_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool child_ignored = child_out && inside_name && + strstr(child_out, "ignore_worktrees is enabled") && + strstr(child_out, "do not run index_repository") && + !strstr(child_out, "is indexed") && !strstr(child_out, inside_name); + free(child_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -10701,6 +10720,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("a nested linked worktree must not inherit the indexed parent graph"); if (!exact_kept) FAIL("an exact index of the nested worktree must still be reported"); + if (!child_ignored) + FAIL("a worktree nested inside an indexed worktree must not inherit that graph"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index 5b3c4b779..10cab0009 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -309,6 +309,19 @@ TEST(is_linked_worktree_true_for_linked_worktree) { snprintf(main_nested, sizeof(main_nested), "%s/src", main_tmp); bool main_nested_ok = th_mkdir_p(main_nested) == 0; bool main_nested_detected = main_nested_ok && cbm_git_is_linked_worktree(main_nested); + /* A worktree nested inside another must not share the parent's anchor. */ + char inner[1024]; + snprintf(inner, sizeof(inner), "%s/inner-wt", wt_tmp); + char inner_cmd[1200]; + snprintf(inner_cmd, sizeof(inner_cmd), "worktree add -b inner-branch \"%s\"", inner); + bool inner_added = git_run(main_tmp, inner_cmd) == 0; + char inner_sub[1200]; + snprintf(inner_sub, sizeof(inner_sub), "%s/sub", inner); + bool inner_sub_ok = inner_added && th_mkdir_p(inner_sub) == 0; + bool same_outer = cbm_git_same_linked_worktree(nested, wt_tmp); + bool same_inner = inner_sub_ok && cbm_git_same_linked_worktree(inner_sub, inner); + bool not_parent = inner_sub_ok && !cbm_git_same_linked_worktree(inner_sub, wt_tmp); + bool not_main = !cbm_git_same_linked_worktree(nested, main_tmp); git_run(main_tmp, "worktree prune"); th_rmtree(main_tmp); @@ -320,6 +333,11 @@ TEST(is_linked_worktree_true_for_linked_worktree) { ASSERT(!main_detected); ASSERT(main_nested_ok); ASSERT(!main_nested_detected); + ASSERT(inner_added); + ASSERT(same_outer); + ASSERT(same_inner); + ASSERT(not_parent); + ASSERT(not_main); PASS(); #endif /* _WIN32 */ } From 72f1a52dc6bb898166a2998b335c0682110a5c31 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:25:14 +0000 Subject: [PATCH 15/61] fix(git): identify a worktree by its canonical gitdir A 64-level walk missed a deep session path, and a relative gitdir joined onto a symlink spelling no longer matched the real project root. Walk to the filesystem root and compare canonical gitdirs. Drop the unused project resolver that failed the macOS -Werror build. Signed-off-by: Nohj --- src/cli/hook_augment.c | 4 ---- src/git/git_context.c | 14 +++++++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index d3baace47..10e7741a1 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1002,10 +1002,6 @@ static char *ha_resolve_indexed_project_with_root(cbm_mcp_server_t *srv, const c return ha_registry_project_for_path(srv, cwd, root_out, root_out_size); } -static char *ha_resolve_indexed_project(cbm_mcp_server_t *srv, const char *cwd) { - return ha_resolve_indexed_project_with_root(srv, cwd, NULL, 0U); -} - static const char *ha_hook_event_name(yyjson_val *root) { const char *event = ha_obj_str(root, "hook_event_name"); return event ? event : ha_obj_str(root, "hookEventName"); diff --git a/src/git/git_context.c b/src/git/git_context.c index 8e2629cc4..a63d3385e 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -228,7 +228,9 @@ static bool walk_linked_worktree_root(const char *path, char *root_out, size_t r /* A session cwd or index_repository path is often a subdirectory. Walk * ancestors until a git anchor so ignore_worktrees applies to the whole * linked checkout, not only its root. */ - for (int depth = 0; depth < 64; depth++) { + /* Bound is the path itself: each step drops one component and stops at + * the filesystem root. A fixed depth would miss a deep session cwd. */ + for (;;) { int kind = linked_worktree_anchor(current); if (kind >= 0) { if (kind != 1) { @@ -248,7 +250,6 @@ static bool walk_linked_worktree_root(const char *path, char *root_out, size_t r return false; } } - return false; } bool cbm_git_is_linked_worktree(const char *path) { @@ -271,7 +272,14 @@ bool cbm_git_same_linked_worktree(const char *a, const char *b) { !read_gitlink_target(root_b, git_b, sizeof(git_b))) { return false; } - return strcmp(git_a, git_b) == 0; + /* A relative gitdir is joined onto the walked path. Canonicalize both so + * a symlink spelling of the cwd still matches the real project root. + * Fall back to the raw string when the gitdir does not exist. */ + char norm_a[GIT_OUTPUT_MAX]; + char norm_b[GIT_OUTPUT_MAX]; + const char *left = cbm_canonical_path(git_a, norm_a, sizeof(norm_a)) ? norm_a : git_a; + const char *right = cbm_canonical_path(git_b, norm_b, sizeof(norm_b)) ? norm_b : git_b; + return strcmp(left, right) == 0; } static char *join_root_relative(const char *root, const char *rel) { From 394faaacfbec823e3d138407bb21ad46869f9022 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 11:35:29 +0000 Subject: [PATCH 16/61] fix(cli): canonicalize cwd before the worktree gate A symlink into a linked worktree has no .git on its lexical path, so the hook told the agent to run index_repository. That call canonicalizes first and then refuses. Resolve the cwd before project lookup and the ignore_worktrees checks. Signed-off-by: Nohj --- src/cli/hook_augment.c | 8 ++++++++ tests/test_cli.c | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 10e7741a1..3de1691aa 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1486,6 +1486,14 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root owned_config = cbm_mcp_server_attach_runtime_config(srv); } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); + /* A symlink alias has no .git of its own, and walking its lexical parents + * leaves the linked worktree. index_repository canonicalizes before the + * same gate, so the hook must too or it tells the agent to index a path + * the setting will refuse. */ + char canonical_cwd[4096]; + if (cwd && ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) { + cwd = canonical_cwd; + } char project_root[4096]; project_root[0] = '\0'; char *project = srv && cwd ? ha_resolve_indexed_project_with_root(srv, cwd, project_root, diff --git a/tests/test_cli.c b/tests/test_cli.c index e234d77df..27883c5c8 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10644,6 +10644,22 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { !strstr(ignored_out, "is indexed"); free(ignored_out); + /* A symlink to a directory inside that worktree has no lexical .git. + * The hook must still follow the real path and honor ignore_worktrees. */ + char alias_target[600]; + snprintf(alias_target, sizeof(alias_target), "%s/nested", wtdir); + bool alias_target_ok = test_mkdirp(alias_target) == 0; + char alias[512]; + snprintf(alias, sizeof(alias), "%s/wt-alias", tmpdir); + bool alias_made = alias_target_ok && symlink(alias_target, alias) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + alias); + char *alias_out = alias_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool alias_ignored = alias_out && strstr(alias_out, "ignore_worktrees is enabled") && + strstr(alias_out, "do not run index_repository") && + !strstr(alias_out, "Run index_repository"); + free(alias_out); + /* A linked worktree nested inside the indexed checkout must not inherit * that parent graph once ignore_worktrees is on. */ char insider[512]; @@ -10716,6 +10732,8 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); if (!honored) FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); + if (!alias_ignored) + FAIL("a symlink into an ignored worktree must not recommend index_repository"); if (!inside_ignored) FAIL("a nested linked worktree must not inherit the indexed parent graph"); if (!exact_kept) From 3bab06134bdf1401e568884e1e624c464522635e Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 12:00:58 +0000 Subject: [PATCH 17/61] style(cli): break the worktree gate assignment for clang-format The ignore_linked initializer exceeded the formatter's assignment break. Lint treats that as an error, so the line now matches .clang-format. Signed-off-by: Nohj --- src/cli/hook_augment.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 3de1691aa..342a7bd61 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1502,8 +1502,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root /* ignore_worktrees refuses a linked worktree, including one nested inside an * indexed checkout or inside another linked worktree. Keep a match only when * its root belongs to this cwd's own linked worktree, not a parent graph. */ - bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && - cbm_git_is_linked_worktree(cwd); + bool ignore_linked = + srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); if (ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root)) { free(project); project = NULL; From b21c8ccb6ec37b53d026984278bd795db5f3abc1 Mon Sep 17 00:00:00 2001 From: Nohj Date: Tue, 29 Sep 2026 12:19:57 +0000 Subject: [PATCH 18/61] fix(cli): drop a parent worktree graph without a new free The memory-core ratchet rejects another raw free() in the hook. Keep the parent name until the existing free(project) and skip it when the match is a different worktree. Signed-off-by: Nohj --- src/cli/hook_augment.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 342a7bd61..384ba8467 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1504,11 +1504,11 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root * its root belongs to this cwd's own linked worktree, not a parent graph. */ bool ignore_linked = srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); - if (ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root)) { - free(project); - project = NULL; - } - bool worktree_ignored = ignore_linked && !project; + /* Drop a parent graph without a second free(): this file's raw free count + * is ratcheted, and the single free(project) below still releases it. */ + bool drop_ancestor = + ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root); + bool worktree_ignored = ignore_linked && (!project || drop_ancestor); if (owned_config) { cbm_mcp_server_set_config(srv, NULL); cbm_config_close(owned_config); @@ -1531,7 +1531,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root scope = "Compaction"; } const char *tier = ha_active_tier(root, event); - if (project) { + if (project && !drop_ancestor) { char safe_project[HA_METADATA_CAP]; ha_sanitize_metadata(project, safe_project, sizeof(safe_project)); snprintf(context, sizeof(context), From 9f204925dd93aecf8ca8010d65c3b808864b1d50 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:30:30 +0800 Subject: [PATCH 19/61] fix(ruby-lsp): build compact class QNs the way the extractor does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural extractor names a class by appending the declaration's raw name text to the enclosing class QN, so `class Admin::User` yields `.Admin::User` while `module Admin; class User` yields `.Admin.User`. The resolver normalised both to the dotted form, and because the pipeline joins resolved rows to defs by exact caller QN, every row from or to a compact-declared class was silently dropped — the most common Rails style. All three passes now enter a class through one shared frame helper that computes the extractor-shaped QN from the raw name and keeps the dotted path only for constant lookup. The class table dedupes on QN. Nesting is now one raw frame per declaration joined by '.', and the constant probe strips one frame at a time: inside `class A::B` only `A.B.x` and `x` are tried, never `A.x`, matching Ruby's lexical scoping (`class A::B` does not open A). Tests pin exact QN equality on both ends of the row for compact, nested-compact, and the outer-scope-not-opened case. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 201 +++++++++++++++++++++++------------- internal/cbm/lsp/ruby_lsp.h | 21 ++-- tests/test_ruby_lsp.c | 149 ++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 77 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 2ca944df2..56f74e8cb 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -38,11 +38,16 @@ * string, array, hash, integer, float, simple_symbol, regex. * * QN scheme (matches the structural extractor): Ruby HAS class node types - * (class/module), so defs are named `module_qn..` with - * parent_class = `module_qn.`. Constructor calls: the textual - * extractor rewrites `Widget.new` to callee "Widget" (extract_calls.c), so - * constructor rows emit the CLASS QN — the edge lands on the Class node, - * exactly like Python's lsp_constructor. + * (class/module), so defs are named `.` with + * parent_class = ``, where a class QN is the enclosing class QN + * (or the module QN at top level) joined with the declaration's RAW name + * text. A compact `class Admin::User` therefore yields `module_qn.Admin::User` + * while `module Admin; class User` yields `module_qn.Admin.User` — both are + * reproduced here byte-for-byte because the pipeline joins resolved rows to + * defs by exact caller name. The dotted form is used only for constant + * lookup. Constructor calls: the textual extractor rewrites `Widget.new` to + * callee "Widget" (extract_calls.c), so constructor rows emit the CLASS QN — + * the edge lands on the Class node, exactly like Python's lsp_constructor. * * Zero-edge guarantee: if a receiver's type is unknown/unindexed, NO edge * is emitted (false edges are worse than missing edges). Dynamic dispatch @@ -121,6 +126,76 @@ static const char *ruby_path_join(CBMArena *a, const char *left, const char *rig return cbm_arena_sprintf(a, "%s.%s", left, right); } +/* Dotted form of a raw constant spelling: every "::" becomes ".". Used to + * turn nesting frames (raw) into class-table paths (dotted). */ +static const char *ruby_dotted(CBMArena *a, const char *raw) { + if (!raw) + return NULL; + size_t n = strlen(raw); + char *out = (char *)cbm_arena_alloc(a, n + 1); + if (!out) + return raw; + size_t j = 0; + for (size_t i = 0; i < n; i++) { + if (raw[i] == ':' && i + 1 < n && raw[i + 1] == ':') { + out[j++] = '.'; + i++; + continue; + } + out[j++] = raw[i]; + } + out[j] = '\0'; + return out; +} + +/* Class-declaration frame shared by every pass, so PASS 1, PASS 1.5 and + * PASS 2 compute identical nesting + class QNs. `raw_name` is the name + * field's source text (the extractor's spelling, "Admin::User"); `qn` is + * the extractor-shaped class QN; `path` is the dotted constant-table path. */ +typedef struct { + const char *raw_name; + const char *path; + const char *qn; + const char *saved_nesting; + const char *saved_class; + bool saved_singleton; +} RubyClassFrame; + +/* Enter a class/module node: compute names, push nesting + enclosing class. + * Returns false (nothing pushed) when the name is not a constant path. */ +static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *fr) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return false; + memset(fr, 0, sizeof(*fr)); + fr->raw_name = ruby_node_text(ctx, name); + if (!fr->raw_name || !fr->raw_name[0]) + return false; + fr->saved_nesting = ctx->nesting; + fr->saved_class = ctx->enclosing_class_qn; + fr->saved_singleton = ctx->in_singleton_method; + + fr->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath); + if (fr->saved_class) + fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", fr->saved_class, fr->raw_name); + else if (ctx->module_qn) + fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, fr->raw_name); + else + fr->qn = fr->raw_name; + + ctx->nesting = ruby_path_join(ctx->arena, fr->saved_nesting, fr->raw_name); + ctx->enclosing_class_qn = fr->qn; + ctx->in_singleton_method = false; + return true; +} + +static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) { + ctx->nesting = fr->saved_nesting; + ctx->enclosing_class_qn = fr->saved_class; + ctx->in_singleton_method = fr->saved_singleton; +} + /* True when a QN belongs to the indexed project (module_qn's first segment * is its prefix). Stdlib seed QNs ("String.upcase", "ActiveRecord.Base") * never carry the project prefix, so edges are only ever emitted at @@ -139,9 +214,12 @@ static RubyClassInfo *ruby_add_class(RubyLSPContext *ctx, const char *path, cons bool is_module) { if (!ctx || !path || !qn) return NULL; - /* Reopened class/module: reuse the existing record. */ + /* Reopened class/module: reuse the existing record. Keyed on the + * extractor QN, so `module A; class B` and `class A::B` in one file each + * keep their own spelling (constant lookup returns whichever was + * declared first; a miss on the other fails closed at the join). */ for (int i = 0; i < ctx->class_count; i++) { - if (strcmp(ctx->classes[i].path, path) == 0) + if (strcmp(ctx->classes[i].qn, qn) == 0) return &ctx->classes[i]; } if (ctx->class_count >= ctx->class_cap) { @@ -266,37 +344,45 @@ static const char *ruby_superclass_qn(RubyLSPContext *ctx, const char *class_qn) /* ── constant resolution ────────────────────────────────────────── */ +/* Class-table probe by dotted path. */ +static const char *ruby_class_qn_by_path(RubyLSPContext *ctx, const char *path) { + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 0) + return ctx->classes[i].qn; + } + return NULL; +} + const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path) { if (!ctx || !path || !path[0]) return NULL; - /* Lexical nesting probe: for nesting "A.B" try "A.B.path", "A.path", - * then "path" (innermost first — Ruby's constant lookup order). */ + /* Lexical nesting probe, innermost frame first — Ruby's constant lookup + * order. The nesting string holds one RAW frame per declaration joined + * by '.', so stripping at the last '.' drops exactly one frame: inside + * `module M; class A::B` (nesting "M.A::B") the probes are "M.A.B.path", + * "M.path", "path" — never "M.A.path", because `class A::B` does not + * open A's scope. */ const char *nest = ctx->nesting ? ctx->nesting : ""; char prefix[512]; size_t nlen = strlen(nest); if (nlen < sizeof(prefix)) { memcpy(prefix, nest, nlen + 1); - while (1) { - if (prefix[0]) { - const char *cand = cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, path); - for (int i = 0; i < ctx->class_count; i++) { - if (strcmp(ctx->classes[i].path, cand) == 0) - return ctx->classes[i].qn; - } - char *last_dot = strrchr(prefix, '.'); - if (last_dot) - *last_dot = '\0'; - else - prefix[0] = '\0'; - continue; - } - break; + while (prefix[0]) { + const char *cand = + cbm_arena_sprintf(ctx->arena, "%s.%s", ruby_dotted(ctx->arena, prefix), path); + const char *qn = ruby_class_qn_by_path(ctx, cand); + if (qn) + return qn; + char *last_dot = strrchr(prefix, '.'); + if (last_dot) + *last_dot = '\0'; + else + prefix[0] = '\0'; } } - for (int i = 0; i < ctx->class_count; i++) { - if (strcmp(ctx->classes[i].path, path) == 0) - return ctx->classes[i].qn; - } + const char *qn = ruby_class_qn_by_path(ctx, path); + if (qn) + return qn; /* Stdlib types are keyed by their dotted bare path. */ const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, path); if (t) @@ -808,27 +894,15 @@ static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton /* Process a class/module node during PASS 2: update nesting + enclosing * class, then walk the body. */ static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { - TSNode name = ts_node_child_by_field_name(node, "name", 4); - const char *cpath = ruby_const_path(ctx, name); - if (!cpath) + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) return; - const char *saved_nesting = ctx->nesting; - const char *saved_class = ctx->enclosing_class_qn; - bool saved_singleton = ctx->in_singleton_method; - - ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); - const char *qn = ruby_resolve_constant(ctx, cpath); - ctx->enclosing_class_qn = qn; - ctx->in_singleton_method = false; - TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_resolve_calls_in_node(ctx, body); - ctx->nesting = saved_nesting; - ctx->enclosing_class_qn = saved_class; - ctx->in_singleton_method = saved_singleton; + ruby_class_leave(ctx, &fr); } static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node) { @@ -992,20 +1066,15 @@ static void ruby_collect_mixin_call(RubyLSPContext *ctx, TSNode call) { } static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { - TSNode name = ts_node_child_by_field_name(node, "name", 4); - const char *cpath = ruby_const_path(ctx, name); - if (!cpath) + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) return; - - const char *saved_nesting = ctx->nesting; - const char *full_path = ruby_path_join(ctx->arena, saved_nesting, cpath); - const char *qn = ctx->module_qn - ? cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, full_path) - : full_path; - RubyClassInfo *ci = ruby_add_class(ctx, full_path, qn, is_module); + RubyClassInfo *ci = ruby_add_class(ctx, fr.path, fr.qn, is_module); /* Superclass: `class C < Base` — the `superclass` field wraps the - * expression after `<`. */ + * expression after `<`. The ref is resolved from the DECLARING scope + * (the frame just pushed does not apply to its own superclass), so pack + * it with the outer nesting. */ if (ci && !is_module && !ci->superclass_ref) { TSNode sup = ts_node_child_by_field_name(node, "superclass", 10); if (!ts_node_is_null(sup)) { @@ -1013,24 +1082,20 @@ static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { for (uint32_t i = 0; i < sc; i++) { const char *spath = ruby_const_path(ctx, ts_node_named_child(sup, i)); if (spath) { + const char *inner = ctx->nesting; + ctx->nesting = fr.saved_nesting; ci->superclass_ref = ruby_pack_ref(ctx, spath); + ctx->nesting = inner; break; } } } } - ctx->nesting = full_path; - const char *saved_class = ctx->enclosing_class_qn; - bool saved_singleton = ctx->in_singleton_method; - ctx->enclosing_class_qn = qn; - ctx->in_singleton_method = false; TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_pass1_scan(ctx, body); - ctx->nesting = saved_nesting; - ctx->enclosing_class_qn = saved_class; - ctx->in_singleton_method = saved_singleton; + ruby_class_leave(ctx, &fr); } static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node) { @@ -1145,19 +1210,13 @@ static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { const char *k = ts_node_type(node); if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { - TSNode name = ts_node_child_by_field_name(node, "name", 4); - const char *cpath = ruby_const_path(ctx, name); - if (!cpath) + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) return; - const char *saved_nesting = ctx->nesting; - const char *saved_class = ctx->enclosing_class_qn; - ctx->nesting = ruby_path_join(ctx->arena, saved_nesting, cpath); - ctx->enclosing_class_qn = ruby_resolve_constant(ctx, cpath); TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_ivar_scan(ctx, body); - ctx->nesting = saved_nesting; - ctx->enclosing_class_qn = saved_class; + ruby_class_leave(ctx, &fr); return; } diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index f6de32943..e52d148c5 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -17,10 +17,16 @@ typedef enum { } RubyMixinKind; /* One class/module discovered in the file (or, cross-file, in the project). - * `path` is the dotted source-level constant path (e.g. "Admin.User" for - * `Admin::User`); `qn` is the full graph QN (module_qn.path for file-local - * classes; the extractor QN for cross-file defs). `superclass_qn` is resolved - * against the class table + stdlib after collection; NULL when unknown. */ + * `path` is the dotted constant path used ONLY for constant lookup ("Admin.User" + * for both `module Admin; class User` and `class Admin::User`). `qn` is the + * graph QN built exactly the way the structural extractor builds it: each + * declaration's raw name text appended to the enclosing class QN (or the + * module QN at top level), so a compact `class Admin::User` yields + * ".Admin::User" while the lexically nested form yields + * ".Admin.User". Emitted rows must carry the extractor spelling + * or the pipeline's exact-name join silently drops them. `superclass_qn` is + * resolved against the class table + stdlib after collection; NULL when + * unknown. */ typedef struct { const char *path; const char *qn; @@ -71,8 +77,11 @@ typedef struct { CBMTypeRegistry *build_reg; CBMScope *current_scope; - /* Lexical nesting: dotted constant path of the enclosing class/module - * chain ("" at top level, "A.B" inside `module A; class B`). */ + /* Lexical nesting: one frame per enclosing class/module declaration, + * frames joined by '.', each frame keeping its raw source spelling ("" at + * top level, "M.A::B" inside `module M; class A::B`). Ruby's constant + * lookup walks the frames outward — inside `class A::B` the nesting is + * [A::B] only, so A's constants are NOT probed. */ const char *nesting; /* Class/module table (pass 1). */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 23c37314e..a2b455f31 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -88,6 +88,41 @@ static const CBMResolvedCall *find_resolved_with_strategy(const CBMFileResult *r return NULL; } +/* Exact-name match on BOTH ends. The pipeline joins a resolved row to its + * caller def by exact QN equality, so a row whose caller is spelled even + * slightly differently from the extractor's def is silently dropped — + * substring matching would hide exactly that failure. */ +static int find_resolved_exact(const CBMFileResult *r, const char *caller_qn, + const char *callee_qn) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 && + strcmp(rc->callee_qn, callee_qn) == 0) + return i; + } + return -1; +} + +static void dump_resolved(const CBMFileResult *r) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)", + rc->callee_qn ? rc->callee_qn : "(null)", rc->strategy ? rc->strategy : "(null)", + rc->confidence); + } +} + +static int require_resolved_exact(const CBMFileResult *r, const char *caller_qn, + const char *callee_qn) { + int idx = find_resolved_exact(r, caller_qn, callee_qn); + if (idx < 0) { + printf(" MISSING exact resolved call: %s -> %s (have %d)\n", caller_qn, callee_qn, + r->resolved_calls.count); + dump_resolved(r); + } + return idx; +} + static const CBMDefinition *find_def(const CBMFileResult *r, const char *label, const char *name) { for (int i = 0; i < r->defs.count; i++) { const CBMDefinition *d = &r->defs.items[i]; @@ -487,6 +522,117 @@ TEST(rubylsp_nested_modules) { PASS(); } +/* ── 12b. Compact `class A::B` names match the extractor byte-for-byte ── */ + +/* The structural extractor names a compact declaration with its RAW name text + * (`Admin::User`), and the pipeline joins a resolved row to its caller def by + * exact QN equality. A resolver that normalised the name to `Admin.User` would + * have every row from or to such a class dropped at the join — silently, since + * substring assertions would still pass. Pin the exact spelling on both ends, + * and pin that the extractor's def really is spelled that way. */ +TEST(rubylsp_compact_class_name_exact) { + const char *src = "class Admin::User\n" + " def initialize\n" + " end\n" + " def name\n" + " 'n'\n" + " end\n" + " def shout\n" + " self.name\n" + " end\n" + "end\n" + "class Outside\n" + " def probe\n" + " u = Admin::User.new\n" + " u.name\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* The extractor's contract this resolver must reproduce. */ + const CBMDefinition *m = find_def(r, "Method", "name"); + ASSERT(m); + ASSERT(strcmp(m->qualified_name, "test.main.Admin::User.name") == 0); + ASSERT(m->parent_class && strcmp(m->parent_class, "test.main.Admin::User") == 0); + /* Caller inside the compact class, callee inside it. */ + ASSERT(require_resolved_exact(r, "test.main.Admin::User.shout", "test.main.Admin::User.name") >= + 0); + /* Caller outside: constructor row lands on the class QN, typed call on + * the method QN — both in the extractor's spelling. */ + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin::User") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin::User.name") >= 0); + /* And the dotted spelling must NOT appear anywhere. */ + ASSERT(find_resolved(r, "Outside.probe", "Admin.User") < 0); + cbm_free_result(r); + PASS(); +} + +/* Compact declaration nested inside a lexical module: the extractor joins the + * enclosing class QN with the raw name, giving `Admin.Foo::Bar`. */ +TEST(rubylsp_compact_class_inside_module_exact) { + const char *src = "module Admin\n" + " class Foo::Bar\n" + " def m\n" + " 1\n" + " end\n" + " end\n" + " class Panel\n" + " def show\n" + " Foo::Bar.new.m\n" + " end\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMDefinition *m = find_def(r, "Method", "m"); + ASSERT(m); + ASSERT(strcmp(m->qualified_name, "test.main.Admin.Foo::Bar.m") == 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.Foo::Bar") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.Foo::Bar.m") >= + 0); + cbm_free_result(r); + PASS(); +} + +/* Inside `class Admin::User`, Ruby's lexical nesting is [Admin::User] only — + * `Admin` is not opened, so a bare `Helper` does NOT see Admin::Helper. A + * resolver that probed the dotted prefixes (Admin.User, Admin, "") would + * invent that constant and emit a wrong constructor edge. The explicit + * `Admin::Helper` still resolves. */ +TEST(rubylsp_compact_class_does_not_open_outer_scope) { + const char *src = "module Admin\n" + " class Helper\n" + " def initialize\n" + " end\n" + " def assist\n" + " 1\n" + " end\n" + " end\n" + "end\n" + "class Admin::User\n" + " def bare\n" + " h = Helper.new\n" + " h.assist\n" + " end\n" + " def explicit\n" + " h = Admin::Helper.new\n" + " h.assist\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Bare `Helper` inside the compact class: no edge at all. */ + ASSERT(find_resolved(r, "Admin::User.bare", "Helper") < 0); + /* Fully qualified reference resolves, in exact extractor spelling. */ + ASSERT(require_resolved_exact(r, "test.main.Admin::User.explicit", "test.main.Admin.Helper") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Admin::User.explicit", + "test.main.Admin.Helper.assist") >= 0); + cbm_free_result(r); + PASS(); +} + /* ── 13. ActiveRecord model typing ──────────────────────────────── */ TEST(rubylsp_activerecord_model_typing) { @@ -628,6 +774,9 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_ivar_conflict_no_edge); RUN_TEST(rubylsp_chained_constructor_call); RUN_TEST(rubylsp_nested_modules); + RUN_TEST(rubylsp_compact_class_name_exact); + RUN_TEST(rubylsp_compact_class_inside_module_exact); + RUN_TEST(rubylsp_compact_class_does_not_open_outer_scope); RUN_TEST(rubylsp_activerecord_model_typing); RUN_TEST(rubylsp_top_level_function); RUN_TEST(rubylsp_unknown_receiver_no_edge); From c2d8644777066a0670c58cc0d579e5a3b334b42b Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:38:59 +0800 Subject: [PATCH 20/61] fix(ruby-lsp): make def a scope gate and fail closed on untyped reassignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `def` body inherited the enclosing scope, so a typed local from the surrounding body (or a preceding sibling method) typed a same-named parameter or local inside it. Parameters and block parameters were never bound, so they could not shadow an outer local either. And an assignment whose right-hand side had no proven type was ignored, so `a = Foo.new; a = x; a.bar` still resolved to Foo#bar. Ruby scoping is now modeled explicitly: `def`, `class` and `module` bodies start from a fresh, unparented scope; blocks and lambdas get a chained child scope; method, block and lambda parameters (including destructured groups and block-local names) bind to UNKNOWN; and any write the resolver cannot type — plain reassignment, multiple assignment, `||=` on an already-typed name, other compound operators, or an assignment inside a block — rebinds the nearest existing binding to UNKNOWN. `a ||= Foo.new` on a fresh name still types as Foo. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 161 ++++++++++++++++++++++++++++++++++-- tests/test_ruby_lsp.c | 159 +++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 6 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 56f74e8cb..06b5b5e2f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -844,6 +844,87 @@ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { /* No known parent or unresolved — zero-edge guarantee. */ } +/* ── local scope (Ruby scope gates) ─────────────────────────────── */ + +/* Ruby scoping, as modeled here: + * - `def`, `class` and `module` bodies are SCOPE GATES: no outer local is + * visible inside, so each gets a fresh, unparented scope. + * - Blocks (`{ }`, `do end`, `-> { }`) close over the enclosing locals, so + * they get a CHAINED child scope. Block parameters shadow outer names + * for the block's extent only. + * - Assigning to a name that is already bound in an enclosing frame + * rebinds THAT frame (closure assignment), otherwise binds locally. + * - Parameters and any reassignment whose right-hand side has no proven + * type bind the name to UNKNOWN. A stale type must never survive a + * write the resolver could not read (zero-edge guarantee). */ + +/* Assign `type` to `name`: rebind the nearest existing binding in place, or + * bind in the current frame when the name is new. */ +static void ruby_scope_assign(RubyLSPContext *ctx, const char *name, const CBMType *type) { + if (!ctx->current_scope || !name || !name[0]) + return; + for (CBMScope *s = ctx->current_scope; s != NULL; s = s->parent) { + for (CBMScopeChunk *c = s->chunks; c != NULL; c = c->next) { + for (int i = 0; i < c->used; i++) { + if (c->bindings[i].name && strcmp(c->bindings[i].name, name) == 0) { + c->bindings[i].type = type; + c->bindings[i].callable_qn = NULL; + return; + } + } + } + } + cbm_scope_bind(ctx->current_scope, name, type); +} + +/* Bind every name introduced by a parameter list (method, block or lambda + * parameters, including destructured groups and block-local `; x` names) to + * UNKNOWN in the current frame. Parameters arrive untyped, and a parameter + * that shares its name with an outer local must shadow it. */ +static void ruby_bind_params_unknown(RubyLSPContext *ctx, TSNode params) { + if (ts_node_is_null(params)) + return; + const char *k = ts_node_type(params); + if (strcmp(k, "identifier") == 0) { + char *nm = ruby_node_text(ctx, params); + if (nm && nm[0]) + cbm_scope_bind(ctx->current_scope, nm, cbm_type_unknown()); + return; + } + if (strcmp(k, "optional_parameter") == 0 || strcmp(k, "keyword_parameter") == 0 || + strcmp(k, "splat_parameter") == 0 || strcmp(k, "hash_splat_parameter") == 0 || + strcmp(k, "block_parameter") == 0) { + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(params, "name", 4)); + return; + } + if (strcmp(k, "method_parameters") == 0 || strcmp(k, "block_parameters") == 0 || + strcmp(k, "lambda_parameters") == 0 || strcmp(k, "destructured_parameter") == 0) { + uint32_t nc = ts_node_named_child_count(params); + for (uint32_t i = 0; i < nc; i++) + ruby_bind_params_unknown(ctx, ts_node_named_child(params, i)); + } +} + +/* Rebind every local named on the left of a multiple assignment + * (`a, b = ...`, `a, (b, c) = ...`, `a, *rest = ...`) to UNKNOWN. */ +static void ruby_unbind_assignment_list(RubyLSPContext *ctx, TSNode lhs) { + if (ts_node_is_null(lhs)) + return; + const char *k = ts_node_type(lhs); + if (strcmp(k, "identifier") == 0) { + char *nm = ruby_node_text(ctx, lhs); + if (nm && nm[0]) + ruby_scope_assign(ctx, nm, cbm_type_unknown()); + return; + } + if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 || + strcmp(k, "rest_assignment") == 0) { + uint32_t nc = ts_node_named_child_count(lhs); + for (uint32_t i = 0; i < nc; i++) + ruby_unbind_assignment_list(ctx, ts_node_named_child(lhs, i)); + } +} + /* ── assignment observer (scope binding) ────────────────────────── */ static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) { @@ -851,26 +932,66 @@ static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) { TSNode right = ts_node_child_by_field_name(assign, "right", 5); if (ts_node_is_null(left) || ts_node_is_null(right)) return; - if (strcmp(ts_node_type(left), "identifier") != 0) + const char *lk = ts_node_type(left); + if (strcmp(lk, "left_assignment_list") == 0) { + ruby_unbind_assignment_list(ctx, left); /* no per-element typing */ + return; + } + if (strcmp(lk, "identifier") != 0) return; /* only simple local targets are tracked */ char *vname = ruby_node_text(ctx, left); if (!vname || !vname[0]) return; const CBMType *rt = ruby_eval_expr_type(ctx, right); - if (rt && rt->kind == CBM_TYPE_NAMED) - cbm_scope_bind(ctx->current_scope, vname, rt); + /* An untyped right-hand side REBINDS to unknown — `a = Foo.new; a = x` + * must not leave `a` typed Foo. */ + ruby_scope_assign(ctx, vname, (rt && rt->kind == CBM_TYPE_NAMED) ? rt : cbm_type_unknown()); +} + +/* `a ||= expr` and the other compound assignments. `||=` on a name with no + * binding yet takes the right-hand type; on a name already bound to the same + * type it keeps it; every other case (untyped right-hand side, different + * type, or any non-`||=` operator) fails closed to UNKNOWN. */ +static void ruby_process_operator_assignment(RubyLSPContext *ctx, TSNode assign) { + TSNode left = ts_node_child_by_field_name(assign, "left", 4); + TSNode op = ts_node_child_by_field_name(assign, "operator", 8); + TSNode right = ts_node_child_by_field_name(assign, "right", 5); + if (ts_node_is_null(left) || ts_node_is_null(right)) + return; + if (strcmp(ts_node_type(left), "identifier") != 0) + return; + char *vname = ruby_node_text(ctx, left); + if (!vname || !vname[0]) + return; + char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op); + const CBMType *result = cbm_type_unknown(); + if (optxt && strcmp(optxt, "||=") == 0) { + const CBMType *rt = ruby_eval_expr_type(ctx, right); + if (rt && rt->kind == CBM_TYPE_NAMED) { + const CBMVarBinding *cur = cbm_scope_lookup_binding(ctx->current_scope, vname); + if (!cur) + result = rt; /* nil || Foo.new → Foo */ + else if (cur->type && cur->type->kind == CBM_TYPE_NAMED && + strcmp(cur->type->data.named.qualified_name, rt->data.named.qualified_name) == + 0) + result = rt; + } + } + ruby_scope_assign(ctx, vname, result); } /* ── PASS 2: resolution walk ────────────────────────────────────── */ -/* Process one method/singleton_method definition node. */ +/* Process one method/singleton_method definition node. `def` is a scope + * gate: the body starts from a FRESH scope holding only its parameters. */ static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton) { CBMScope *saved_scope = ctx->current_scope; const char *saved_func = ctx->enclosing_func_qn; bool saved_singleton = ctx->in_singleton_method; - ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope); + ctx->current_scope = cbm_scope_push(ctx->arena, NULL); ctx->in_singleton_method = singleton; + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10)); TSNode name = ts_node_child_by_field_name(node, "name", 4); char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); @@ -891,17 +1012,39 @@ static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton ctx->in_singleton_method = saved_singleton; } +/* Process a block / do-block / lambda: a CHAINED scope (closures see the + * enclosing locals) in which the block parameters shadow as UNKNOWN. */ +static void ruby_process_block(RubyLSPContext *ctx, TSNode node) { + CBMScope *saved_scope = ctx->current_scope; + ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope); + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10)); + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (ts_node_is_null(c)) + continue; + /* Parameters were consumed above; walking them would be harmless + * (identifiers are not calls) but default values may hold calls. */ + ruby_resolve_calls_in_node(ctx, c); + } + ctx->current_scope = saved_scope; +} + /* Process a class/module node during PASS 2: update nesting + enclosing - * class, then walk the body. */ + * class, then walk the body from a fresh scope (class bodies are scope + * gates too). */ static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { RubyClassFrame fr; if (!ruby_class_enter(ctx, node, &fr)) return; + CBMScope *saved_scope = ctx->current_scope; + ctx->current_scope = cbm_scope_push(ctx->arena, NULL); TSNode body = ts_node_child_by_field_name(node, "body", 4); if (!ts_node_is_null(body)) ruby_resolve_calls_in_node(ctx, body); + ctx->current_scope = saved_scope; ruby_class_leave(ctx, &fr); } @@ -940,9 +1083,15 @@ static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { ruby_process_method(ctx, node, true); return; } + if (strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0) { + ruby_process_block(ctx, node); + return; + } if (strcmp(k, "assignment") == 0) ruby_process_assignment(ctx, node); + else if (strcmp(k, "operator_assignment") == 0) + ruby_process_operator_assignment(ctx, node); if (strcmp(k, "call") == 0) { TSNode meth = ts_node_child_by_field_name(node, "method", 6); diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index a2b455f31..c4f17e71f 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -123,6 +123,19 @@ static int require_resolved_exact(const CBMFileResult *r, const char *caller_qn, return idx; } +/* Number of rows with exactly this caller and callee. */ +static int count_resolved_exact(const CBMFileResult *r, const char *caller_qn, + const char *callee_qn) { + int n = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 && + strcmp(rc->callee_qn, callee_qn) == 0) + n++; + } + return n; +} + static const CBMDefinition *find_def(const CBMFileResult *r, const char *label, const char *name) { for (int i = 0; i < r->defs.count; i++) { const CBMDefinition *d = &r->defs.items[i]; @@ -700,6 +713,149 @@ TEST(rubylsp_unknown_receiver_no_edge) { PASS(); } +/* ── 15b. `def` is a scope gate ─────────────────────────────────── */ + +/* Ruby locals do not cross a `def`: a typed local in the enclosing body (or + * a sibling method) is invisible inside, and a parameter of the same name is + * a brand-new, untyped binding. A resolver that chained the method scope to + * its parent would type `a` from the outside and emit a wrong edge. */ +TEST(rubylsp_def_is_scope_gate) { + const char *src = "class Foo\n" + " def bar\n" + " 1\n" + " end\n" + "end\n" + "a = Foo.new\n" + "def top\n" + " a.bar\n" + "end\n" + "class Worker\n" + " def run\n" + " a = Foo.new\n" + " a.bar\n" + " helper(a)\n" + " end\n" + " def helper(a)\n" + " a.bar\n" + " end\n" + " def later\n" + " a.bar\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Positive control: the typed local inside its own method resolves. */ + ASSERT(require_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") >= 0); + /* Top-level `a` is not visible inside `def top`. */ + ASSERT(find_resolved(r, "main.top", "Foo.bar") < 0); + /* Parameter `a` is untyped, whatever the caller passed. */ + ASSERT(find_resolved(r, "Worker.helper", "Foo.bar") < 0); + /* Sibling method's local is not visible either. */ + ASSERT(find_resolved(r, "Worker.later", "Foo.bar") < 0); + cbm_free_result(r); + PASS(); +} + +/* Block parameters shadow the enclosing local for the block's extent only: + * `|a|` inside the block is untyped, and `a` after the block is still Foo. */ +TEST(rubylsp_block_param_shadows_local) { + const char *src = "class Foo\n" + " def bar\n" + " 1\n" + " end\n" + "end\n" + "class Worker\n" + " def run\n" + " a = Foo.new\n" + " [1].each { |a| a.bar }\n" + " [1].each do |x, (y, z), *rest; loc|\n" + " x.bar\n" + " y.bar\n" + " rest.bar\n" + " loc.bar\n" + " end\n" + " f = ->(a) { a.bar }\n" + " a.bar\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Exactly ONE row: the `a.bar` after the blocks. Every `.bar` inside a + * block is on an untyped block parameter. */ + if (count_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") != 1) + dump_resolved(r); + ASSERT(count_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") == 1); + cbm_free_result(r); + PASS(); +} + +/* Reassigning a typed local to anything the resolver cannot type must drop + * the old type. `a = Foo.new; a = x; a.bar` is NOT a call on Foo. Covers + * plain reassignment, multiple assignment, `||=` and other compound forms. */ +TEST(rubylsp_untyped_reassignment_drops_type) { + const char *src = "class Foo\n" + " def bar\n" + " 1\n" + " end\n" + "end\n" + "class Worker\n" + " def plain(x)\n" + " a = Foo.new\n" + " a = x\n" + " a.bar\n" + " end\n" + " def multi(x)\n" + " a = Foo.new\n" + " a, b = x, 1\n" + " a.bar\n" + " end\n" + " def nested_multi(x)\n" + " a = Foo.new\n" + " (a, b), c = x, 1\n" + " a.bar\n" + " end\n" + " def splat_multi(x)\n" + " a = Foo.new\n" + " b, *a = x\n" + " a.bar\n" + " end\n" + " def or_assign(x)\n" + " a = Foo.new\n" + " a ||= x\n" + " a.bar\n" + " end\n" + " def compound(x)\n" + " a = Foo.new\n" + " a += x\n" + " a.bar\n" + " end\n" + " def in_block(x)\n" + " a = Foo.new\n" + " [1].each { a = x }\n" + " a.bar\n" + " end\n" + " def fresh_or_assign\n" + " a ||= Foo.new\n" + " a.bar\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Worker.plain", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.multi", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.nested_multi", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.splat_multi", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.or_assign", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.compound", "Foo.bar") < 0); + /* Assignment inside a block rebinds the enclosing local (closure). */ + ASSERT(find_resolved(r, "Worker.in_block", "Foo.bar") < 0); + /* Positive control: `||=` on a fresh name with a typed RHS is a + * constructor assignment. */ + ASSERT(require_resolved_exact(r, "test.main.Worker.fresh_or_assign", "test.main.Foo.bar") >= 0); + cbm_free_result(r); + PASS(); +} + /* ── 16. Negative: dynamic dispatch emits no edge ───────────────── */ TEST(rubylsp_send_no_edge) { @@ -780,6 +936,9 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_activerecord_model_typing); RUN_TEST(rubylsp_top_level_function); RUN_TEST(rubylsp_unknown_receiver_no_edge); + RUN_TEST(rubylsp_def_is_scope_gate); + RUN_TEST(rubylsp_block_param_shadows_local); + RUN_TEST(rubylsp_untyped_reassignment_drops_type); RUN_TEST(rubylsp_send_no_edge); RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); } From ffeb06f595cf5fe00dc41cd7e0ff6f2cbceba1e2 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:43:38 +0800 Subject: [PATCH 21/61] fix(ruby-lsp): latch ivar type on any non-constructor write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PASS 1.5 only recorded `@x = Const.new` and ignored every other write, so `@x = compute` (or a parameter, a compound operator, a multiple assignment, or an unresolved constant's .new) left the ivar typed by whichever constructor it saw elsewhere. Any such write now latches the conflict, exactly as two different constructors already did; `nil` is neutral. `@x ||= Const.new` — the memoised-constructor idiom — counts as a constructor assignment. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 122 +++++++++++++++++++++++++++++------- tests/test_ruby_lsp.c | 110 ++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 21 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 06b5b5e2f..6d6e7e6db 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -263,24 +263,21 @@ static void ruby_add_mixin(RubyLSPContext *ctx, const char *owner_qn, const char mi->kind = kind; } -static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char *ivar_name, - const char *type_qn) { - if (!ctx || !class_qn || !ivar_name || !type_qn) - return; +/* Find-or-create the (class, @ivar) record. `type_qn` may be NULL when the + * caller only wants to latch a conflict on a name never typed. */ +static RubyIvarInfo *ruby_ivar_slot(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { for (int i = 0; i < ctx->ivar_count; i++) { RubyIvarInfo *iv = &ctx->ivars[i]; - if (strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) { - if (strcmp(iv->type_qn, type_qn) != 0) - iv->conflicted = true; /* disagreeing assignments — no type */ - return; - } + if (strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) + return iv; } if (ctx->ivar_count >= ctx->ivar_cap) { int newcap = ctx->ivar_cap ? ctx->ivar_cap * 2 : 8; RubyIvarInfo *ni = (RubyIvarInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyIvarInfo)); if (!ni) - return; + return NULL; for (int i = 0; i < ctx->ivar_count; i++) ni[i] = ctx->ivars[i]; ctx->ivars = ni; @@ -290,7 +287,34 @@ static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char memset(iv, 0, sizeof(*iv)); iv->class_qn = cbm_arena_strdup(ctx->arena, class_qn); iv->ivar_name = cbm_arena_strdup(ctx->arena, ivar_name); - iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); + return iv; +} + +/* Record `@x = ` (a resolved constructor). A second, different + * type latches the conflict. */ +static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char *ivar_name, + const char *type_qn) { + if (!ctx || !class_qn || !ivar_name || !type_qn) + return; + RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name); + if (!iv) + return; + if (!iv->type_qn) + iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); + else if (strcmp(iv->type_qn, type_qn) != 0) + iv->conflicted = true; /* disagreeing assignments — no type */ +} + +/* Latch `@x` as untypable: some write to it was not a resolved constructor + * (or `nil`). Lookup then returns nothing regardless of other assignments — + * the zero-edge guarantee beats a coin-flip type. */ +static void ruby_ivar_mark_conflict(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + if (!ctx || !class_qn || !ivar_name) + return; + RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name); + if (iv) + iv->conflicted = true; } static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn, @@ -299,7 +323,7 @@ static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn, return NULL; for (int i = 0; i < ctx->ivar_count; i++) { RubyIvarInfo *iv = &ctx->ivars[i]; - if (!iv->conflicted && strcmp(iv->class_qn, class_qn) == 0 && + if (!iv->conflicted && iv->type_qn && strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) return iv->type_qn; } @@ -1345,6 +1369,44 @@ static const char *ruby_static_ctor_type(RubyLSPContext *ctx, TSNode expr) { return ruby_resolve_constant(ctx, cpath); } +/* Observe one write to `@x` (the `left` node) with right-hand side `right`. + * Only a RESOLVED `Const.new` types the ivar; `nil` is neutral (a common + * reset that says nothing about the type); every other right-hand side — + * an unresolved constant's `.new`, a method result, a parameter, a literal + * — latches the conflict, exactly like two different constructors do. */ +static void ruby_ivar_observe_write(RubyLSPContext *ctx, TSNode left, TSNode right) { + char *ivar = ruby_node_text(ctx, left); + if (!ivar || !ivar[0]) + return; + if (!ts_node_is_null(right) && strcmp(ts_node_type(right), "nil") == 0) + return; + const char *tqn = ruby_static_ctor_type(ctx, right); + if (tqn) + ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + else + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); +} + +/* Latch every instance variable named on the left of a multiple assignment + * (`@a, @b = ...`): per-element typing is not attempted. */ +static void ruby_ivar_conflict_assignment_list(RubyLSPContext *ctx, TSNode lhs) { + if (ts_node_is_null(lhs)) + return; + const char *k = ts_node_type(lhs); + if (strcmp(k, "instance_variable") == 0) { + char *ivar = ruby_node_text(ctx, lhs); + if (ivar && ivar[0]) + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); + return; + } + if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 || + strcmp(k, "rest_assignment") == 0) { + uint32_t nc = ts_node_named_child_count(lhs); + for (uint32_t i = 0; i < nc; i++) + ruby_ivar_conflict_assignment_list(ctx, ts_node_named_child(lhs, i)); + } +} + static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node) { if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) return; @@ -1369,15 +1431,33 @@ static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { return; } - if (strcmp(k, "assignment") == 0 && ctx->enclosing_class_qn) { - TSNode left = ts_node_child_by_field_name(node, "left", 4); - TSNode right = ts_node_child_by_field_name(node, "right", 5); - if (!ts_node_is_null(left) && !ts_node_is_null(right) && - strcmp(ts_node_type(left), "instance_variable") == 0) { - const char *tqn = ruby_static_ctor_type(ctx, right); - if (tqn) { - char *ivar = ruby_node_text(ctx, left); - ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + if (ctx->enclosing_class_qn) { + if (strcmp(k, "assignment") == 0) { + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left)) { + const char *lk = ts_node_type(left); + if (strcmp(lk, "instance_variable") == 0) + ruby_ivar_observe_write(ctx, left, right); + else if (strcmp(lk, "left_assignment_list") == 0) + ruby_ivar_conflict_assignment_list(ctx, left); + } + } else if (strcmp(k, "operator_assignment") == 0) { + /* `@x ||= Const.new` is the memoised-constructor idiom and counts + * as a constructor assignment; any other operator is an untyped + * write. */ + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode op = ts_node_child_by_field_name(node, "operator", 8); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left) && strcmp(ts_node_type(left), "instance_variable") == 0) { + char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op); + if (optxt && strcmp(optxt, "||=") == 0) { + ruby_ivar_observe_write(ctx, left, right); + } else { + char *ivar = ruby_node_text(ctx, left); + if (ivar && ivar[0]) + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); + } } } } diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index c4f17e71f..ba578d204 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -479,6 +479,114 @@ TEST(rubylsp_ivar_conflict_no_edge) { PASS(); } +/* Any write to @x that is not a resolved constructor (or nil) must latch the + * conflict, the same way two different constructors do — a method result, a + * parameter, an unresolved constant's `.new`, a compound operator, or a + * multiple assignment. Otherwise `@x = Foo.new` in one method silently types + * every other method's `@x` even after it was reassigned to who-knows-what. */ +TEST(rubylsp_ivar_non_constructor_write_latches) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class ViaMethod\n" + " def initialize\n" + " @e = Engine.new\n" + " end\n" + " def reset\n" + " @e = compute\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaParam\n" + " def initialize(e)\n" + " @e = Engine.new\n" + " @e = e\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaUnknownCtor\n" + " def initialize\n" + " @e = Engine.new\n" + " @e = Mystery.new\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaCompound\n" + " def initialize\n" + " @e = Engine.new\n" + " @e += 1\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaMulti\n" + " def initialize(a)\n" + " @e = Engine.new\n" + " @e, @f = a\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaOrUntyped\n" + " def initialize\n" + " @e = Engine.new\n" + " end\n" + " def lazy\n" + " @e ||= build\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "ViaMethod.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaParam.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaUnknownCtor.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaCompound.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaMulti.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaOrUntyped.go", "Engine.start") < 0); + cbm_free_result(r); + PASS(); +} + +/* `@x ||= Const.new` is the memoised-constructor idiom and counts as a + * constructor assignment; a `nil` reset says nothing about the type. */ +TEST(rubylsp_ivar_or_assign_constructor_types) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class Car\n" + " def engine\n" + " @engine ||= Engine.new\n" + " end\n" + " def clear\n" + " @engine = nil\n" + " end\n" + " def drive\n" + " @engine.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Car.drive", "test.main.Engine.start") >= 0); + ASSERT(find_resolved_with_strategy(r, "Car.drive", "Engine.start", "ruby_ivar_method")); + cbm_free_result(r); + PASS(); +} + /* ── 11. Chained calls ──────────────────────────────────────────── */ TEST(rubylsp_chained_constructor_call) { @@ -928,6 +1036,8 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_super_dispatch); RUN_TEST(rubylsp_ivar_typing); RUN_TEST(rubylsp_ivar_conflict_no_edge); + RUN_TEST(rubylsp_ivar_non_constructor_write_latches); + RUN_TEST(rubylsp_ivar_or_assign_constructor_types); RUN_TEST(rubylsp_chained_constructor_call); RUN_TEST(rubylsp_nested_modules); RUN_TEST(rubylsp_compact_class_name_exact); From 50f28fbae520cfead28ff6c331954ed414564c21 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 09:55:33 +0800 Subject: [PATCH 22/61] fix(ruby-lsp): suppress self dispatch inside blocks that rebind self instance_eval/instance_exec, class_eval/class_exec, module_eval/ module_exec, define_method and Class.new/Module.new run their block with a different `self`, but bare and `self.` calls inside them were dispatched on the enclosing class. The eval call itself was already typed unknown; the walk into its block was not. The block of such a call is now walked with self_unknown set: bare calls, `self.` calls and `super` emit nothing there, `self` has no type, and PASS 1.5 does not attribute ivar writes inside it to the enclosing class. Receiver and argument expressions of the same call still resolve, and the suppression ends with the block. The *_exec forms join the dynamic-dispatch list. The existing negative test passed only because Meta had no `hidden`; it now has one, and the calls are written with `()` because tree-sitter-ruby parses a lone identifier as a variable reference, which the extractor never emits as a call. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 78 ++++++++++++++++++++++++++++--- internal/cbm/lsp/ruby_lsp.h | 5 ++ tests/test_ruby_lsp.c | 92 ++++++++++++++++++++++++++++++++++++- 3 files changed, 166 insertions(+), 9 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 6d6e7e6db..f7f41e10f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -83,6 +83,7 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node); static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node); static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node); static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit); +static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call); /* ── helpers ────────────────────────────────────────────────────── */ @@ -159,6 +160,7 @@ typedef struct { const char *saved_nesting; const char *saved_class; bool saved_singleton; + bool saved_self_unknown; } RubyClassFrame; /* Enter a class/module node: compute names, push nesting + enclosing class. @@ -175,6 +177,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f fr->saved_nesting = ctx->nesting; fr->saved_class = ctx->enclosing_class_qn; fr->saved_singleton = ctx->in_singleton_method; + fr->saved_self_unknown = ctx->self_unknown; fr->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath); if (fr->saved_class) @@ -187,6 +190,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f ctx->nesting = ruby_path_join(ctx->arena, fr->saved_nesting, fr->raw_name); ctx->enclosing_class_qn = fr->qn; ctx->in_singleton_method = false; + ctx->self_unknown = false; /* a class body names its self again */ return true; } @@ -194,6 +198,7 @@ static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) { ctx->nesting = fr->saved_nesting; ctx->enclosing_class_qn = fr->saved_class; ctx->in_singleton_method = fr->saved_singleton; + ctx->self_unknown = fr->saved_self_unknown; } /* True when a QN belongs to the indexed project (module_qn's first segment @@ -630,7 +635,7 @@ const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { if (tqn) result = cbm_type_named(ctx->arena, tqn); } else if (strcmp(k, "self") == 0) { - if (ctx->enclosing_class_qn && !ctx->in_singleton_method) + if (ctx->enclosing_class_qn && !ctx->in_singleton_method && !ctx->self_unknown) result = cbm_type_named(ctx->arena, ctx->enclosing_class_qn); } else if (strcmp(k, "call") == 0) { result = ruby_eval_call_type(ctx, node, false); @@ -679,9 +684,40 @@ const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { static bool ruby_is_dynamic_dispatch(const char *m) { return strcmp(m, "send") == 0 || strcmp(m, "public_send") == 0 || strcmp(m, "__send__") == 0 || strcmp(m, "method_missing") == 0 || strcmp(m, "define_method") == 0 || - strcmp(m, "instance_eval") == 0 || strcmp(m, "class_eval") == 0 || - strcmp(m, "module_eval") == 0 || strcmp(m, "instance_variable_get") == 0 || - strcmp(m, "instance_variable_set") == 0; + strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 || + strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 || + strcmp(m, "instance_variable_get") == 0 || strcmp(m, "instance_variable_set") == 0; +} + +/* True when `call` carries a block whose body runs with a DIFFERENT `self` + * than the enclosing lexical scope: the eval/exec family, define_method + * (self is the future receiver), and `Class.new { }` / `Module.new { }` + * (self is the anonymous class). Bare and `self.` calls in such a block + * must not dispatch on the enclosing class. */ +static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call) { + if (ts_node_is_null(ts_node_child_by_field_name(call, "block", 5))) + return false; + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + if (ts_node_is_null(meth)) + return false; + char *m = ruby_node_text(ctx, meth); + if (!m) + return false; + if (strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 || + strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 || + strcmp(m, "define_method") == 0) + return true; + if (strcmp(m, "new") == 0) { + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "constant") == 0) { + char *r = ruby_node_text(ctx, recv); + if (r && (strcmp(r, "Class") == 0 || strcmp(r, "Module") == 0)) + return true; + } + } + return false; } /* Class-body macro-ish names PASS 1 already interpreted (or that never @@ -770,6 +806,8 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool /* Bare call: self-dispatch inside methods, then file-level funcs. */ if (ruby_is_body_macro(mname)) return cbm_type_unknown(); + if (ctx->self_unknown) + return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */ if (ctx->enclosing_class_qn) { const CBMRegisteredFunc *f = ctx->in_singleton_method @@ -799,7 +837,7 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool /* self.m — explicit self dispatch. */ if (strcmp(rk, "self") == 0) { - if (!ctx->enclosing_class_qn) + if (!ctx->enclosing_class_qn || ctx->self_unknown) return cbm_type_unknown(); const CBMRegisteredFunc *f = ctx->in_singleton_method @@ -845,7 +883,7 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool /* `super` / `super(...)`: resolve the enclosing method's name starting at * the enclosing class's ancestry (own def skipped — that IS the caller). */ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { - if (!ctx->enclosing_class_qn || !ctx->enclosing_func_qn) + if (!ctx->enclosing_class_qn || !ctx->enclosing_func_qn || ctx->self_unknown) return; const char *fq = ctx->enclosing_func_qn; const char *mname = strrchr(fq, '.'); @@ -1123,6 +1161,26 @@ static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { ruby_resolve_super(ctx, node); else ruby_eval_call_type(ctx, node, true); + if (ruby_call_rebinds_self(ctx, node)) { + /* Receiver and arguments are ordinary expressions; the block + * body runs with a `self` this resolver cannot name. */ + TSNode blk = ts_node_child_by_field_name(node, "block", 5); + uint32_t cc = ts_node_child_count(node); + for (uint32_t i = 0; i < cc; i++) { + TSNode c = ts_node_child(node, i); + if (ts_node_is_null(c)) + continue; + if (ts_node_eq(c, blk)) { + bool saved = ctx->self_unknown; + ctx->self_unknown = true; + ruby_resolve_calls_in_node(ctx, c); + ctx->self_unknown = saved; + } else { + ruby_resolve_calls_in_node(ctx, c); + } + } + return; + } /* Recurse into receiver/arguments/blocks for nested calls. */ } else if (strcmp(k, "super") == 0) { /* Bare `super` (no argument list) appears as a standalone node. */ @@ -1462,10 +1520,16 @@ static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { } } + /* A block that rebinds `self` writes ivars on some OTHER object; do not + * attribute them to the enclosing class. */ + TSNode skip_blk = {0}; + if (strcmp(k, "call") == 0 && ruby_call_rebinds_self(ctx, node)) + skip_blk = ts_node_child_by_field_name(node, "block", 5); + uint32_t nc = ts_node_child_count(node); for (uint32_t i = 0; i < nc; i++) { TSNode c = ts_node_child(node, i); - if (!ts_node_is_null(c)) + if (!ts_node_is_null(c) && !(!ts_node_is_null(skip_blk) && ts_node_eq(c, skip_blk))) ruby_ivar_scan(ctx, c); } } diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index e52d148c5..dc9271d5b 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -109,6 +109,11 @@ typedef struct { const char *enclosing_class_qn; /* class QN, or NULL at top level */ const char *enclosing_func_qn; /* enclosing method QN, or NULL */ bool in_singleton_method; /* `def self.m` / class << self scope */ + /* Inside a block that rebinds `self` (instance_eval/exec, class_eval/ + * exec, module_eval/exec, define_method, Class.new/Module.new): the + * receiver of bare and `self.` calls is NOT the enclosing class, and + * the resolver cannot know what it is — so they emit nothing. */ + bool self_unknown; const char *module_qn; /* Output: resolved calls accumulate here. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index ba578d204..a3d4aca6e 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -989,7 +989,14 @@ TEST(rubylsp_send_no_edge) { /* `send` has its own row above. The zero-edge guarantee is advertised for the * whole reflective family, so pin the rest of it too: a resolver that treated - * any of these as an ordinary call would invent an edge to `hidden`. */ + * any of these as an ordinary call would invent an edge to `hidden`. + * + * `Meta` deliberately defines its OWN `hidden`: inside `instance_eval { }` + * self is the Target, not the Meta, so a resolver that dispatched the bare + * `hidden()` on the enclosing class would emit Meta.via_instance_eval -> + * Meta.hidden — a wrong edge that an empty Meta would never reveal. The + * bare calls are written with `()` because tree-sitter-ruby parses a lone + * `hidden` as an identifier, which the extractor never emits as a call. */ TEST(rubylsp_dynamic_dispatch_family_no_edge) { const char *src = "class Target\n" " def hidden\n" @@ -997,6 +1004,9 @@ TEST(rubylsp_dynamic_dispatch_family_no_edge) { " end\n" "end\n" "class Meta\n" + " def hidden\n" + " 0\n" + " end\n" " def via_public_send\n" " Target.new.public_send(:hidden)\n" " end\n" @@ -1007,7 +1017,7 @@ TEST(rubylsp_dynamic_dispatch_family_no_edge) { " Target.define_method(:hidden) { 2 }\n" " end\n" " def via_instance_eval\n" - " Target.new.instance_eval { hidden }\n" + " Target.new.instance_eval { hidden() }\n" " end\n" "end\n"; CBMFileResult *r = extract_ruby(src); @@ -1020,6 +1030,83 @@ TEST(rubylsp_dynamic_dispatch_family_no_edge) { PASS(); } +/* ── 16c. Negative: blocks that rebind `self` ───────────────────── */ + +/* instance_eval/exec, class_eval/exec, module_eval/exec, define_method and + * Class.new/Module.new run their block with a different `self`. Bare calls, + * `self.` calls and `super` inside them must not dispatch on the enclosing + * class. Every enclosing class here HAS the method being called, so the old + * behaviour would emit a wrong edge in each row. Receiver and argument + * expressions of the same call are ordinary and still resolve. */ +TEST(rubylsp_self_rebinding_blocks_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def hidden\n" + " 0\n" + " end\n" + " def target\n" + " Target.new\n" + " end\n" + " def via_instance_exec\n" + " target().instance_exec(1) { |x| hidden(); self.hidden }\n" + " end\n" + " def via_class_eval\n" + " Target.class_eval do\n" + " hidden()\n" + " end\n" + " end\n" + " def via_class_exec\n" + " Target.class_exec { hidden() }\n" + " end\n" + " def via_module_eval\n" + " Target.module_eval { self.hidden }\n" + " end\n" + " def via_module_exec\n" + " Target.module_exec { hidden() }\n" + " end\n" + " def via_define_method_body\n" + " Target.define_method(:other) { hidden() }\n" + " end\n" + " def via_class_new\n" + " Class.new do\n" + " hidden()\n" + " def hidden\n" + " super\n" + " end\n" + " end\n" + " end\n" + " def via_module_new\n" + " Module.new { self.hidden }\n" + " end\n" + " def after_block\n" + " Target.new.instance_eval { hidden() }\n" + " self.hidden\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.via_instance_exec", "hidden") < 0); + /* ...but the receiver expression of that same call still resolves. */ + ASSERT(require_resolved_exact(r, "test.main.Meta.via_instance_exec", "test.main.Meta.target") >= + 0); + ASSERT(find_resolved(r, "Meta.via_class_eval", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_class_exec", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_module_eval", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_module_exec", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_define_method_body", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_class_new", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_module_new", "hidden") < 0); + /* The suppression ends with the block: the following statement is an + * ordinary self dispatch on Meta. */ + ASSERT(count_resolved_exact(r, "test.main.Meta.after_block", "test.main.Meta.hidden") == 1); + cbm_free_result(r); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1051,4 +1138,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_untyped_reassignment_drops_type); RUN_TEST(rubylsp_send_no_edge); RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); + RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); } From 60801bc53fd19959c7b14083f18ad66167c86566 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 10:05:01 +0800 Subject: [PATCH 23/61] fix(ruby-lsp): fail closed on unresolved ancestry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup walks skipped a mixin whose module did not resolve and carried on to the class's own methods and superclass chain, and a bare call whose class ancestry did not resolve fell through to a same-named top-level def. An unseen ancestor may define or shadow the method, so none of those answers is trustworthy. Every lookup now carries a `blocked` flag set the moment the walk meets an unresolved prepend, include, extend or superclass reference, in Ruby's lookup order: an unresolved prepend blocks even an own-method hit; an unresolved include (or superclass) stops the walk before the superclass chain; an unresolved extend blocks the singleton walk. When blocked, nothing is emitted — no Object typing fallback, no top-level fallthrough, no `super` edge. Hits found before the unseen ancestor are still returned (an own method ahead of an unresolved include; a resolved include listed after an unresolved one). Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 123 ++++++++++++++++++++++++++---------- tests/test_ruby_lsp.c | 121 +++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 34 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index f7f41e10f..1411c3649 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -437,23 +437,44 @@ static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, c return cbm_registry_lookup_method(ctx->registry, key, method_name); } +/* Fail-closed ancestry. Every lookup below carries a `blocked` flag that is + * set the moment the walk meets an ancestor it cannot see: a mixin whose + * module did not resolve, or a superclass reference that did not resolve. + * An unseen ancestor may define (or, for a prepend, shadow) the method, so + * from that point on no hit is trustworthy and callers must emit nothing — + * including NOT falling through to a same-named top-level def or to the + * Object typing fallback. Hits found BEFORE the unseen ancestor in Ruby's + * lookup order are still returned. */ + static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth); + int depth, bool *blocked); + +/* True when the class declares a superclass the finalize step could not + * resolve (stdlib-seeded conventions like ApplicationRecord DO resolve). */ +static bool ruby_superclass_unresolved(RubyLSPContext *ctx, const char *class_qn) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + return ci && ci->superclass_ref && !ci->superclass_qn; +} /* Search a mixin kind on `owner_qn` (most recent first — later mixins win), - * recursing into each module's own include chain. */ + * recursing into each module's own include chain. An UNRESOLVED mixin met + * before any hit blocks the walk. */ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const char *owner_qn, RubyMixinKind kind, const char *method_name, - int depth) { + int depth, bool *blocked) { for (int i = ctx->mixin_count - 1; i >= 0; i--) { RubyMixinInfo *mi = &ctx->mixins[i]; - if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0 || !mi->module_qn) + if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0) continue; + if (!mi->module_qn) { + *blocked = true; /* unseen module — it may define/shadow the method */ + return NULL; + } const CBMRegisteredFunc *f = - ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1); - if (f) + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, blocked); + if (f || *blocked) return f; } return NULL; @@ -462,30 +483,41 @@ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth) { + int depth, bool *blocked) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; - /* Prepended modules shadow the class's own methods. */ + /* Prepended modules shadow the class's own methods — an unresolved + * prepend therefore blocks even an own-method hit. */ const CBMRegisteredFunc *f = - ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth); - if (f) + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth, blocked); + if (f || *blocked) return f; f = ruby_own_instance_method(ctx, class_qn, method_name); if (f) return f; - f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth); - if (f) + /* An unresolved include stops the walk before the superclass chain. */ + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth, blocked); + if (f || *blocked) return f; + if (ruby_superclass_unresolved(ctx, class_qn)) { + *blocked = true; + return NULL; + } const char *sup = ruby_superclass_qn(ctx, class_qn); if (sup && strcmp(sup, class_qn) != 0) - return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1); + return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, blocked); return NULL; } -const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, - const char *method_name) { - const CBMRegisteredFunc *f = ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0); - if (f) +/* Instance lookup with the universal-receiver typing fallback. Skipped when + * the ancestry is blocked: nothing below the unseen ancestor is trustworthy. */ +static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + bool *blocked) { + const CBMRegisteredFunc *f = + ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, blocked); + if (f || *blocked) return f; /* Universal receiver fallback (Object / ActiveSupport predicates) — * typing only; Object methods are stdlib entries, never project defs. */ @@ -494,28 +526,39 @@ const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const return NULL; } +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + bool blocked = false; + return ruby_lookup_instance_method_b(ctx, class_qn, method_name, &blocked); +} + static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth) { + int depth, bool *blocked) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; const CBMRegisteredFunc *f = ruby_own_singleton_method(ctx, class_qn, method_name); if (f) return f; /* `extend Mod` adds Mod's instance methods class-side. */ - f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth); - if (f) + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth, blocked); + if (f || *blocked) return f; + if (ruby_superclass_unresolved(ctx, class_qn)) { + *blocked = true; + return NULL; + } const char *sup = ruby_superclass_qn(ctx, class_qn); if (sup && strcmp(sup, class_qn) != 0) - return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1); + return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1, blocked); return NULL; } const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, const char *method_name) { - return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0); + bool blocked = false; + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &blocked); } /* ── ActiveRecord model detection (typing special case) ─────────── */ @@ -809,16 +852,23 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (ctx->self_unknown) return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */ if (ctx->enclosing_class_qn) { + bool blocked = false; const CBMRegisteredFunc *f = ctx->in_singleton_method - ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) - : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, + &blocked) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, + &blocked); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, call); return ruby_func_return_type(f); } + /* An unseen ancestor may define this method: do NOT fall through + * to a same-named top-level def. */ + if (blocked) + return cbm_type_unknown(); } const CBMRegisteredFunc *f = cbm_registry_lookup_symbol(ctx->registry, ctx->module_qn, mname); @@ -839,10 +889,12 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (strcmp(rk, "self") == 0) { if (!ctx->enclosing_class_qn || ctx->self_unknown) return cbm_type_unknown(); - const CBMRegisteredFunc *f = - ctx->in_singleton_method - ? ruby_lookup_singleton_method(ctx, ctx->enclosing_class_qn, mname) - : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0); + bool blocked = false; + const CBMRegisteredFunc *f = ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth( + ctx, ctx->enclosing_class_qn, mname, 0, &blocked) + : ruby_lookup_instance_method_depth( + ctx, ctx->enclosing_class_qn, mname, 0, &blocked); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, @@ -889,19 +941,22 @@ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { const char *mname = strrchr(fq, '.'); mname = mname ? mname + 1 : fq; const CBMRegisteredFunc *f = NULL; + bool blocked = false; /* Search order mirrors Ruby: includes of the class, then superclass - * chain — the class's OWN def is the caller and must be skipped. */ + * chain — the class's OWN def is the caller and must be skipped. An + * unresolved mixin or superclass on the way blocks the walk. */ f = ruby_lookup_in_mixins(ctx, ctx->enclosing_class_qn, ctx->in_singleton_method ? RUBY_MIXIN_EXTEND : RUBY_MIXIN_INCLUDE, - mname, 0); - if (!f) { + mname, 0, &blocked); + if (!f && !blocked && !ruby_superclass_unresolved(ctx, ctx->enclosing_class_qn)) { const char *sup = ruby_superclass_qn(ctx, ctx->enclosing_class_qn); if (sup) { - f = ctx->in_singleton_method ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0) - : ruby_lookup_instance_method_depth(ctx, sup, mname, 0); + f = ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0, &blocked) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &blocked); } } - if (f) + if (f && !blocked) ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); /* No known parent or unresolved — zero-edge guarantee. */ } diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index a3d4aca6e..1ad45e314 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -379,6 +379,125 @@ TEST(rubylsp_prepend_mixin) { PASS(); } +/* ── 7c. Unresolved ancestry fails closed ───────────────────────── */ + +/* Three cases where an ancestor the resolver cannot see could define (or + * shadow) the method being called. Each one has a tempting local answer — + * the class's own method, a superclass method, a same-named top-level def — + * and each answer may be wrong, so none may be emitted. */ +TEST(rubylsp_unresolved_ancestry_fails_closed) { + const char *src = /* (a) unresolved PREPEND blocks an own-method hit: the + * prepended module may shadow `save`. */ + "class Record\n" + " prepend UnseenAudit\n" + " def save\n" + " 1\n" + " end\n" + " def persist\n" + " self.save\n" + " end\n" + "end\n" + "class RecordUser\n" + " def run\n" + " r = Record.new\n" + " r.save\n" + " end\n" + "end\n" + /* (b) unresolved INCLUDE stops the walk before the superclass chain: + * the included module may define `helper` ahead of Base. */ + "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " include UnseenConcern\n" + " def work\n" + " self.helper\n" + " end\n" + "end\n" + "class ChildUser\n" + " def run\n" + " c = Child.new\n" + " c.helper\n" + " end\n" + "end\n" + /* (c) a bare call in a class with an unresolved SUPERCLASS must not + * fall through to a same-named top-level def. */ + "def util\n" + " 1\n" + "end\n" + "class Orphan < UnseenBase\n" + " def go\n" + " util()\n" + " end\n" + " def again\n" + " super\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* (a) */ + ASSERT(find_resolved(r, "Record.persist", "save") < 0); + ASSERT(find_resolved(r, "RecordUser.run", "save") < 0); + /* (b) */ + ASSERT(find_resolved(r, "Child.work", "helper") < 0); + ASSERT(find_resolved(r, "ChildUser.run", "helper") < 0); + /* (c) */ + ASSERT(find_resolved(r, "Orphan.go", "util") < 0); + ASSERT(find_resolved(r, "Orphan.again", "again") < 0); + cbm_free_result(r); + PASS(); +} + +/* Positive controls for the rule above: resolved ancestry still reaches the + * superclass and still falls through to a top-level def, and a hit found + * BEFORE the unseen ancestor in Ruby's lookup order is still emitted. */ +TEST(rubylsp_resolved_ancestry_still_resolves) { + const char *src = "module Seen\n" + " def greet\n" + " 1\n" + " end\n" + "end\n" + "def util\n" + " 1\n" + "end\n" + "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " include Seen\n" + " def work\n" + " self.helper\n" + " util()\n" + " end\n" + "end\n" + "class Mixed < UnseenBase\n" + " include UnseenConcern\n" + " include Seen\n" + " def own\n" + " 2\n" + " end\n" + " def work\n" + " self.own\n" + " self.greet\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Child.work", "test.main.Base.helper") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Child.work", "test.main.util") >= 0); + /* Own method precedes includes and the superclass in lookup order. */ + ASSERT(require_resolved_exact(r, "test.main.Mixed.work", "test.main.Mixed.own") >= 0); + /* `include Seen` comes AFTER `include UnseenConcern`, so Seen is searched + * first and its hit is trustworthy. */ + ASSERT(require_resolved_exact(r, "test.main.Mixed.work", "test.main.Seen.greet") >= 0); + cbm_free_result(r); + PASS(); +} + /* ── 8. extend mixin (class-side) ───────────────────────────────── */ TEST(rubylsp_extend_mixin) { @@ -1119,6 +1238,8 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_inheritance); RUN_TEST(rubylsp_include_mixin); RUN_TEST(rubylsp_prepend_mixin); + RUN_TEST(rubylsp_unresolved_ancestry_fails_closed); + RUN_TEST(rubylsp_resolved_ancestry_still_resolves); RUN_TEST(rubylsp_extend_mixin); RUN_TEST(rubylsp_super_dispatch); RUN_TEST(rubylsp_ivar_typing); From 8bc99a40587b1d12604738417af4ba3b392788e7 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 10:12:59 +0800 Subject: [PATCH 24/61] fix(ruby-lsp): do not register block-nested defs as class members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PASS 1 recursed through blocks and registered any `def` it met on the enclosing class or module, so ActiveSupport's `class_methods do … end` and `included do … end` bodies became instance methods of the concern and were then found through `include Concern` — edges to defs the structural extractor does not even emit. `include X` written inside such a block was likewise recorded on the concern instead of the includer. PASS 1 now tracks the block depth since the nearest class/module body and, inside a block, registers no methods and collects no mixins. In PASS 2 a class-body block def is skipped outright (no def exists for it to join), and a top-level block def (an RSpec group) is walked with self unknown: typed-receiver calls in its body may still resolve, bare and self. calls emit nothing, and other top-level code cannot reach it. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 44 ++++++++++++++++---- internal/cbm/lsp/ruby_lsp.h | 6 +++ tests/test_ruby_lsp.c | 81 +++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 1411c3649..c0063212f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -161,6 +161,7 @@ typedef struct { const char *saved_class; bool saved_singleton; bool saved_self_unknown; + int saved_block_depth; } RubyClassFrame; /* Enter a class/module node: compute names, push nesting + enclosing class. @@ -178,6 +179,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f fr->saved_class = ctx->enclosing_class_qn; fr->saved_singleton = ctx->in_singleton_method; fr->saved_self_unknown = ctx->self_unknown; + fr->saved_block_depth = ctx->block_depth; fr->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath); if (fr->saved_class) @@ -191,6 +193,7 @@ static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *f ctx->enclosing_class_qn = fr->qn; ctx->in_singleton_method = false; ctx->self_unknown = false; /* a class body names its self again */ + ctx->block_depth = 0; return true; } @@ -199,6 +202,7 @@ static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) { ctx->enclosing_class_qn = fr->saved_class; ctx->in_singleton_method = fr->saved_singleton; ctx->self_unknown = fr->saved_self_unknown; + ctx->block_depth = fr->saved_block_depth; } /* True when a QN belongs to the indexed project (module_qn's first segment @@ -1192,16 +1196,29 @@ static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { ctx->in_singleton_method = saved; return; } - if (strcmp(k, "method") == 0) { - ruby_process_method(ctx, node, ctx->in_singleton_method); - return; - } - if (strcmp(k, "singleton_method") == 0) { - ruby_process_method(ctx, node, true); + if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; + if (ctx->block_depth > 0) { + /* A def inside a block. Inside a class body the extractor emits + * no def for it, so no row could ever join: skip it. At top + * level the extractor emits a plain Function, so typed-receiver + * calls in its body may resolve — but its `self` is whatever + * the block's DSL made it, so bare and self. calls emit nothing. */ + if (ctx->enclosing_class_qn) + return; + bool saved = ctx->self_unknown; + ctx->self_unknown = true; + ruby_process_method(ctx, node, singleton); + ctx->self_unknown = saved; + return; + } + ruby_process_method(ctx, node, singleton); return; } if (strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0) { + ctx->block_depth++; ruby_process_block(ctx, node); + ctx->block_depth--; return; } @@ -1418,6 +1435,11 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { return; } if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + /* A def inside a block (`class_methods do … end`, `included do … + * end`, a describe group) is not a method of the enclosing + * class/module — and not a file-level function either. */ + if (ctx->block_depth > 0) + return; bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; TSNode name = ts_node_child_by_field_name(node, "name", 4); char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); @@ -1425,7 +1447,9 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { ruby_register_method(ctx, ctx->build_reg, ctx->enclosing_class_qn, mname, singleton); return; /* method bodies are PASS-2 territory */ } - if (strcmp(k, "call") == 0) { + if (strcmp(k, "call") == 0 && ctx->block_depth == 0) { + /* `include X` inside a block (`included do include X end`, + * `Foo.class_eval { include X }`) applies to some other module. */ TSNode meth = ts_node_child_by_field_name(node, "method", 6); if (!ts_node_is_null(meth)) { char *mn = ruby_node_text(ctx, meth); @@ -1437,12 +1461,18 @@ static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { } } + bool is_block = + strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0; + if (is_block) + ctx->block_depth++; uint32_t nc = ts_node_child_count(node); for (uint32_t i = 0; i < nc; i++) { TSNode c = ts_node_child(node, i); if (!ts_node_is_null(c)) ruby_pass1_scan(ctx, c); } + if (is_block) + ctx->block_depth--; } /* Finalize PASS 1: resolve superclass + mixin references now that the class diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index dc9271d5b..ad2d81de3 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -114,6 +114,12 @@ typedef struct { * receiver of bare and `self.` calls is NOT the enclosing class, and * the resolver cannot know what it is — so they emit nothing. */ bool self_unknown; + /* Number of enclosing blocks (`{ }`, `do end`, `-> { }`) since the + * nearest class/module body. A `def` met here is NOT a method of the + * enclosing class — ActiveSupport's `class_methods do … end`, + * `included do … end`, RSpec groups — so PASS 1 registers nothing for + * it and collects no mixins from it. */ + int block_depth; const char *module_qn; /* Output: resolved calls accumulate here. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 1ad45e314..63f764f34 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -498,6 +498,85 @@ TEST(rubylsp_resolved_ancestry_still_resolves) { PASS(); } +/* ── 7d. Methods defined inside blocks are not members of the module ── */ + +/* ActiveSupport's `class_methods do … end` / `included do … end` define + * methods on whatever class includes the concern (class-side, for + * class_methods), never instance methods of the concern module itself. A + * resolver that registered them on the module would then find them through + * `include Concern` and emit `Foo.new.build` -> Concern.build — an edge to a + * def the extractor does not even emit. Same for `include X` written INSIDE + * such a block: it applies to the includer, not the concern. */ +TEST(rubylsp_block_defs_not_module_members) { + const char *src = "module Other\n" + " def other_m\n" + " 1\n" + " end\n" + "end\n" + "module Concern\n" + " class_methods do\n" + " def build\n" + " 1\n" + " end\n" + " end\n" + " included do\n" + " include Other\n" + " def injected\n" + " 2\n" + " end\n" + " end\n" + " def real\n" + " 3\n" + " end\n" + "end\n" + "class Foo\n" + " include Concern\n" + " def initialize\n" + " end\n" + "end\n" + "class User\n" + " def run\n" + " f = Foo.new\n" + " f.build\n" + " f.injected\n" + " f.other_m\n" + " f.real\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "User.run", "build") < 0); + ASSERT(find_resolved(r, "User.run", "injected") < 0); + ASSERT(find_resolved(r, "User.run", "other_m") < 0); + /* Positive control: a def directly in the module body IS a member. */ + ASSERT(require_resolved_exact(r, "test.main.User.run", "test.main.Concern.real") >= 0); + cbm_free_result(r); + PASS(); +} + +/* A def inside a top-level block (an RSpec group) has whatever `self` the + * DSL gave it: bare calls in its body emit nothing, and it is not a + * file-level function other top-level code can reach. */ +TEST(rubylsp_top_level_block_defs_fail_closed) { + const char *src = "def util\n" + " 1\n" + "end\n" + "describe 'x' do\n" + " def helper\n" + " util()\n" + " end\n" + "end\n" + "def caller_fn\n" + " helper()\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "helper", "util") < 0); + ASSERT(find_resolved(r, "caller_fn", "helper") < 0); + cbm_free_result(r); + PASS(); +} + /* ── 8. extend mixin (class-side) ───────────────────────────────── */ TEST(rubylsp_extend_mixin) { @@ -1240,6 +1319,8 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_prepend_mixin); RUN_TEST(rubylsp_unresolved_ancestry_fails_closed); RUN_TEST(rubylsp_resolved_ancestry_still_resolves); + RUN_TEST(rubylsp_block_defs_not_module_members); + RUN_TEST(rubylsp_top_level_block_defs_fail_closed); RUN_TEST(rubylsp_extend_mixin); RUN_TEST(rubylsp_super_dispatch); RUN_TEST(rubylsp_ivar_typing); From 0f8b7194d81f6e3c4ba5c2da87141cbdebd9f87a Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Wed, 30 Sep 2026 10:17:46 +0800 Subject: [PATCH 25/61] test(ruby-lsp): exact-name assertions for nested-class rows The nested-modules row matched caller and callee by substring, which would have let a wrongly-spelled class path pass; it now pins the full extractor QNs on both ends with strcmp, as the compact-class rows already do. Refresh the file headers to describe the scope gates, fail-closed ancestry and self-rebinding rules the resolver now follows. Assisted by OpenCode using anthropic/claude-fable-5-1. Signed-off-by: Ben Fairless --- internal/cbm/lsp/ruby_lsp.c | 44 +++++++++++++++++++++++++++---------- tests/test_ruby_lsp.c | 24 +++++++++++++++----- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index c0063212f..ed97c78ec 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -12,27 +12,41 @@ * PASS 1 — collect class/module declarations (with lexical nesting), * superclasses, include/prepend/extend mixins, and method tables * (instance vs singleton classified from the AST — `def m` vs - * `def self.m` / `class << self`). + * `def self.m` / `class << self`). Defs and mixin calls inside a + * BLOCK (`class_methods do … end`, `included do … end`) belong to + * some other module and are not recorded. * PASS 1.5 — infer instance-variable types from `@x = Const.new` - * assignments inside method bodies (conflicting types latch to - * "conflicted" and resolve to nothing). + * (and `@x ||= Const.new`) inside method bodies. Any other write + * to the ivar latches it as untypable. * PASS 2 — walk method bodies, track local variable types through - * assignments, and resolve call expressions into CBMResolvedCall - * edges via Ruby method lookup (prepends → own → includes → - * superclass chain; extends feed the singleton side). + * assignments with Ruby's scope gates (fresh scope per def/class, + * chained scope per block, parameters unknown, untyped writes + * rebind to unknown), and resolve call expressions into + * CBMResolvedCall edges via Ruby method lookup (prepends → own → + * includes → superclass chain; extends feed the singleton side). + * The walk fails closed the moment an ancestor is unresolved. * * Verified tree-sitter-ruby node/field names (vendored compiled grammar at * internal/cbm/vendored/grammars/ruby/parser.c — ts_symbol_names and * ts_field_names tables): * - call : fields `receiver`, `method`, `arguments`, `block`. Both - * `foo(x)` and command calls (`foo x`) surface as "call" nodes. + * `foo(x)` and command calls (`foo x`) surface as "call" nodes. A lone + * `foo` is an `identifier`, never a call — and the extractor emits no + * call for it either, so the resolver does not treat it as one. * - class : fields `name` (constant | scope_resolution), `superclass` * (a `superclass` wrapper node), `body` (body_statement). * - module : fields `name`, `body`. * - method : fields `name`, `parameters`, `body`. * - singleton_method : fields `object` (self | constant), `name`, `body`. * - singleton_class : `class << self` — fields `value`, body children. - * - assignment : fields `left`, `right`. + * - assignment : fields `left`, `right` (`left` may be a + * left_assignment_list for `a, b = …`). + * - operator_assignment : fields `left`, `operator`, `right` (`||=` etc.). + * - block / do_block / lambda : field `parameters` (block_parameters / + * lambda_parameters), body. + * - method_parameters children: identifier | optional_parameter | + * keyword_parameter | splat_parameter | hash_splat_parameter | + * block_parameter (each with a `name` field) | destructured_parameter. * - scope_resolution : fields `scope` (may be absent for ::Foo), `name`. * - leaves: constant, identifier, self, instance_variable, super, * string, array, hash, integer, float, simple_symbol, regex. @@ -50,9 +64,17 @@ * the edge lands on the Class node, exactly like Python's lsp_constructor. * * Zero-edge guarantee: if a receiver's type is unknown/unindexed, NO edge - * is emitted (false edges are worse than missing edges). Dynamic dispatch - * (`send`, `public_send`, `method_missing`, `define_method`) is - * intentionally ignored. + * is emitted (false edges are worse than missing edges). Concretely: + * - dynamic dispatch (`send`, `public_send`, `__send__`, `method_missing`, + * `define_method`, the eval/exec family) is never guessed through; + * - a block that rebinds `self` (instance_eval/exec, class_eval/exec, + * module_eval/exec, define_method, Class.new/Module.new) suppresses bare, + * `self.` and `super` dispatch inside it; + * - an unresolved prepend blocks even an own-method hit, an unresolved + * include/extend/superclass stops the walk, and a blocked walk never + * falls through to a same-named top-level def or the Object fallback; + * - a local or ivar written with anything the resolver cannot type loses + * its type rather than keeping a stale one. */ #include "ruby_lsp.h" diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 63f764f34..d1999357f 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -16,10 +16,14 @@ * 10. Instance-variable typing (@thing = Foo.new; @thing.bar) * 11. Chained call typing (Foo.new.bar) * 12. Nested modules (lexical nesting + `A::B` references) + * 12b. Compact `class A::B` names (exact extractor spelling, scope) * 13. ActiveRecord model typing (User.find(1).full_name) * 14. Top-level function calls * 15. Unresolvable receiver emits NO spurious edge (negative) + * 15b. Scope gates (def, params, blocks, reassignment) * 16. send() / the whole dynamic-dispatch family emits NO edge (negative) + * 16c. Blocks that rebind self emit NO self edge (negative) + * Fail-closed ancestry (7c) and block-nested defs (7d) sit with mixins. * * Scope: every row here is SINGLE-FILE, driven through cbm_extract_file so it * exercises the per-file resolver exactly as the extraction pipeline calls it. @@ -27,9 +31,10 @@ * pass_lsp_cross.c) is a separate tier and is covered by its own rows. * * The resolver populates result->resolved_calls with CBMResolvedCall rows. - * Ruby defs weave the class path into the QN (module_qn.Animal.speak), so - * for these single-file fixtures ("test"/"main.rb" -> module QN test.main) - * callee fragments like "main.Animal.speak" are unique join keys. + * The pipeline joins a row to its caller def by EXACT QN equality, so every + * row that pins a class-path spelling (nested or compact) asserts the full + * caller and callee QN with strcmp ("test"/"main.rb" -> module QN test.main). + * Substring helpers remain for rows where only presence/absence matters. */ #include "test_framework.h" #include "cbm.h" @@ -832,11 +837,18 @@ TEST(rubylsp_nested_modules) { "end\n"; CBMFileResult *r = extract_ruby(src); ASSERT(r); + /* Exact spellings: lexically nested declarations join with '.', matching + * the extractor's `.` — a substring match would let + * a wrongly-spelled row pass. */ /* Lexical reference from sibling class inside the module. */ - ASSERT(require_resolved(r, "Panel.show", "User.lookup") >= 0); - ASSERT(require_resolved(r, "Panel.show", "User.name") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User.lookup") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User.name") >= + 0); /* Fully-qualified reference from outside. */ - ASSERT(require_resolved(r, "Outside.probe", "User.lookup") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin.User.lookup") >= + 0); cbm_free_result(r); PASS(); } From b2e143371115d43d3518ef962e296cda4dd4ddef Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 19:30:37 -0400 Subject: [PATCH 26/61] Fix portable stat declarations for worktree detection Signed-off-by: Nohj --- src/git/git_context.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/git/git_context.c b/src/git/git_context.c index 38802e5aa..640befd6c 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -10,6 +10,7 @@ #include #include #include +#include enum { GIT_CMD_MAX = 1024, From 470500eaae0b25c69772ba5dca1b01a7554c9637 Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 19:55:35 -0400 Subject: [PATCH 27/61] Restore runtime config attachment for hook augmentation Signed-off-by: Nohj --- src/mcp/mcp.c | 11 +++++++++++ tests/test_mcp.c | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index cccff1838..c45ec6de8 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1849,6 +1849,17 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } +struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv) { + if (!srv || srv->config) { + return NULL; + } + cbm_config_t *config = cbm_config_open(cbm_resolve_cache_dir()); + if (config) { + cbm_mcp_server_set_config(srv, config); + } + return config; +} + #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv, cbm_mcp_auto_index_count_test_hook_fn hook, diff --git a/tests/test_mcp.c b/tests/test_mcp.c index a03c1731a..8eab275a2 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1202,6 +1202,41 @@ TEST(mcp_initialize_response) { PASS(); } +TEST(mcp_attach_runtime_config_respects_caller_ownership) { + char *cache = th_mktempdir("cbm_mcp_runtime_config"); + ASSERT_NOT_NULL(cache); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_config_t *config = cbm_config_open(cache); + cbm_mcp_server_t *server = cbm_mcp_server_new(NULL); + bool stored = config && cbm_config_set(config, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + bool default_off = server && !cbm_mcp_ignore_worktrees_enabled(server); + cbm_config_t *attached = server ? cbm_mcp_server_attach_runtime_config(server) : NULL; + bool enabled = attached && cbm_mcp_ignore_worktrees_enabled(server); + bool not_replaced = server && cbm_mcp_server_attach_runtime_config(server) == NULL; + if (server) { + cbm_mcp_server_set_config(server, config); + } + cbm_config_close(attached); + bool caller_config_kept = server && cbm_mcp_server_attach_runtime_config(server) == NULL && + cbm_mcp_ignore_worktrees_enabled(server); + cbm_mcp_server_free(server); + cbm_config_close(config); + restore_cache_dir(saved_copy); + free(saved_copy); + int removed = th_rmtree(cache); + + ASSERT(stored); + ASSERT(default_off); + ASSERT(enabled); + ASSERT(not_replaced); + ASSERT(caller_config_kept); + ASSERT_EQ(removed, 0); + PASS(); +} + TEST(mcp_tools_list) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); @@ -21147,6 +21182,7 @@ SUITE(mcp) { /* MCP protocol helpers */ RUN_TEST(mcp_initialize_response); + RUN_TEST(mcp_attach_runtime_config_respects_caller_ownership); RUN_TEST(mcp_tools_list); RUN_TEST(mcp_tools_help_list_matches_registry); RUN_TEST(mcp_tools_list_latest_metadata); From adc2764a3eff3791cc5692ec7e997887a1f6df3f Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 20:32:09 -0400 Subject: [PATCH 28/61] Fix C++ sanitizer runtime linkage in fuzz build Signed-off-by: Nohj --- Makefile.cbm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile.cbm b/Makefile.cbm index ab5c89db7..b2a655505 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1381,10 +1381,10 @@ fuzz: CC=$(FUZZ_CC) CXX=$(FUZZ_CXX) SANITIZE="$(FUZZ_SANITIZE)" $(BUILD_DIR)/cbm-fuzz: $(FUZZ_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) - $(CC) $(CFLAGS_TEST) -fsanitize=fuzzer -o $@ \ + $(CXX) $(CFLAGS_TEST) -x c -fsanitize=fuzzer -o $@ \ $(FUZZ_SRC) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ - $(OBJS_VENDORED_TEST) \ + -x none $(OBJS_VENDORED_TEST) \ $(LDFLAGS_TEST) # Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads. From 9588007b16a07c3a85980ceb980e21cd8102c17a Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 21:45:52 -0400 Subject: [PATCH 29/61] Free partial Cypher patterns after parse errors Signed-off-by: Nohj --- src/cypher/cypher.c | 27 ++++++++++++++++++--------- tests/test_cypher.c | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 7c7a2f987..42ecb93c5 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -1862,6 +1862,8 @@ static int parse_return(parser_t *p, cbm_return_clause_t **out) { return parse_return_or_with(p, out, false); } +static void free_pattern(cbm_pattern_t *pat); + /* Parse a single MATCH pattern into pat */ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { memset(pat, 0, sizeof(*pat)); @@ -1870,31 +1872,38 @@ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { pat->nodes = malloc(node_cap * sizeof(cbm_node_pattern_t)); pat->rels = calloc(rel_cap, sizeof(cbm_rel_pattern_t)); - if (parse_node(p, &pat->nodes[0]) < 0) { - return CBM_NOT_FOUND; - } + /* Count the slot before parsing so even partially filled nodes and + * relationships are released when a malformed pattern is rejected. */ pat->node_count = SKIP_ONE; + if (parse_node(p, &pat->nodes[0]) < 0) { + goto fail; + } while (check(p, TOK_DASH) || check(p, TOK_LT)) { if (pat->rel_count >= rel_cap) { rel_cap *= PAIR_LEN; pat->rels = safe_realloc(pat->rels, rel_cap * sizeof(cbm_rel_pattern_t)); } - if (parse_rel(p, &pat->rels[pat->rel_count]) < 0) { - return CBM_NOT_FOUND; - } pat->rel_count++; + if (parse_rel(p, &pat->rels[pat->rel_count - SKIP_ONE]) < 0) { + goto fail; + } if (pat->node_count >= node_cap) { node_cap *= PAIR_LEN; pat->nodes = safe_realloc(pat->nodes, node_cap * sizeof(cbm_node_pattern_t)); } - if (parse_node(p, &pat->nodes[pat->node_count]) < 0) { - return CBM_NOT_FOUND; - } pat->node_count++; + if (parse_node(p, &pat->nodes[pat->node_count - SKIP_ONE]) < 0) { + goto fail; + } } return 0; + +fail: + free_pattern(pat); + memset(pat, 0, sizeof(*pat)); + return CBM_NOT_FOUND; } /* Parse UNWIND [...] AS var clause into query */ diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 7c251bd9b..5d198a0ef 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -274,6 +274,24 @@ TEST(cypher_parse_accepts_single_with_clause) { PASS(); } +TEST(cypher_parse_rejects_partial_patterns) { + const char *queries[] = { + "MATCH p = (a:Class)-[:INHERITS*1..3]->(b:Class) RETURN a.name, length(p)", + "MATCH (a)-[:CALLS]->(b) MATCH (c)-[:CALLS*]-> RETURN a.name", + "MATCH (a)-[:CALLS|]->(b)", + }; + for (size_t i = 0; i < sizeof(queries) / sizeof(queries[0]); i++) { + cbm_query_t *q = NULL; + char *err = NULL; + int rc = cbm_cypher_parse(queries[i], &q, &err); + ASSERT_NEQ(rc, 0); + ASSERT_NULL(q); + ASSERT_NOT_NULL(err); + free(err); + } + PASS(); +} + TEST(cypher_parse_relationship_outbound) { cbm_query_t *q = NULL; char *err = NULL; @@ -4842,6 +4860,7 @@ SUITE(cypher) { RUN_TEST(cypher_parse_rejects_trailing_tokens); RUN_TEST(cypher_parse_rejects_second_with_clause); RUN_TEST(cypher_parse_accepts_single_with_clause); + RUN_TEST(cypher_parse_rejects_partial_patterns); RUN_TEST(cypher_parse_relationship_outbound); RUN_TEST(cypher_parse_relationship_inbound); RUN_TEST(cypher_parse_relationship_any); From 62479ff9fce0f8a052b0eab29a5499ca48b93bec Mon Sep 17 00:00:00 2001 From: Nohj Date: Wed, 30 Sep 2026 22:38:59 -0400 Subject: [PATCH 30/61] Isolate Git context probes from inherited repository environment Signed-off-by: Nohj --- src/git/git_context.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index a63d3385e..6986be4aa 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -65,7 +65,9 @@ static int git_capture(const char *repo_path, const char *git_args, char **out) return CBM_NOT_FOUND; } - FILE *fp = cbm_popen(cmd, "r"); + /* A hook or editor may export GIT_DIR/GIT_WORK_TREE for another repo. + * Isolate this child so git -C selects repo_path rather than that repo. */ + FILE *fp = cbm_popen_git(cmd); if (!fp) { return CBM_NOT_FOUND; } From 5c51d71aea43f1ca6b27f33085b8f3ae6e2fe51c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 02:33:19 +0200 Subject: [PATCH 31/61] 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 1542a9477..bd4f9c423 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 b73d76328..a4fc61b7d 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 32/61] 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 bd4f9c423..05ec5f2c8 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 2471d00d7..41fcbc4fb 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 33/61] 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 05ec5f2c8..9e5a56e48 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 09dc36d26100236bc58969fda149f2c8b55bb1cb Mon Sep 17 00:00:00 2001 From: harshitaajoshi Date: Sun, 27 Sep 2026 18:16:16 -0500 Subject: [PATCH 34/61] fix(extract): parse PHP past inline HTML so symbols after ?> reach the graph (#2000) Signed-off-by: harshitaajoshi --- Makefile.cbm | 1 + internal/cbm/cbm.c | 14 ++ internal/cbm/cbm.h | 4 + internal/cbm/lsp/php_lsp.c | 4 + internal/cbm/php_inline_html.c | 317 +++++++++++++++++++++++++++++++++ tests/test_extraction.c | 128 +++++++++++++ tests/test_pipeline.c | 60 +++++++ 7 files changed, 528 insertions(+) create mode 100644 internal/cbm/php_inline_html.c diff --git a/Makefile.cbm b/Makefile.cbm index 6d19f430a..fec175f67 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -398,6 +398,7 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/route_path.c \ $(CBM_DIR)/openapi.c \ $(CBM_DIR)/sql_values.c \ + $(CBM_DIR)/php_inline_html.c \ $(CBM_DIR)/helpers.c \ $(CBM_DIR)/cpp_branch_views.c \ $(CBM_DIR)/result_compact.c \ diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 23b871dbc..4eb030ae0 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -3313,6 +3313,18 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage return r; } +/* The bytes the grammar parses. PHP that leaves PHP mode (`?> markup ` (#2000). Same length and line breaks as the input; returns +// `source` itself when nothing needs rewriting. Defined in php_inline_html.c. +const char *cbm_php_mask_inline_html(CBMArena *arena, const char *source, int source_len); void cbm_extract_imports(CBMExtractCtx *ctx); void cbm_extract_usages(CBMExtractCtx *ctx); void cbm_extract_semantic(CBMExtractCtx *ctx); diff --git a/internal/cbm/lsp/php_lsp.c b/internal/cbm/lsp/php_lsp.c index 7aae329e8..85118f671 100644 --- a/internal/cbm/lsp/php_lsp.c +++ b/internal/cbm/lsp/php_lsp.c @@ -20,6 +20,7 @@ #include "php_lsp.h" #include "lsp_node_iter.h" #include "lsp_work.h" +#include "../cbm.h" #include "../helpers.h" #include "../../../src/foundation/hash_table.h" #include @@ -4795,6 +4796,9 @@ void cbm_run_php_lsp_cross_with_bindings(CBMArena *arena, const char *source, in const CBMPHPImportBindings *bindings) { if (!arena || !source || source_len <= 0 || !out) return; + /* The retained source is the file on disk; read it the way the per-file + * extract did, so the cached tree and node text agree (#2000). */ + source = cbm_php_mask_inline_html(arena, source, source_len); TSParser *parser = NULL; TSTree *tree = cached_tree; diff --git a/internal/cbm/php_inline_html.c b/internal/cbm/php_inline_html.c new file mode 100644 index 000000000..df5652d54 --- /dev/null +++ b/internal/cbm/php_inline_html.c @@ -0,0 +1,317 @@ +// php_inline_html.c — let the php_only grammar read a file that leaves PHP mode. +// +// A PHP file starts in inline-HTML mode and may switch in and out of PHP any +// number of times: `?>
...
` or for the +// text that follows it, so every byte after the first close tag used to land in +// an ERROR region and the declarations there never reached the graph (#2000). +// +// Instead of parsing the markup, blank it. The rewritten buffer has the same +// length as the original and keeps every line break, so byte offsets, rows and +// columns are unchanged and node text inside PHP code is byte-identical: +// +// - markup before the first open tag and between tags becomes spaces +// - the first open tag stays, so the tree keeps its php_tag +// - each later `` becomes `; `, the statement terminator PHP itself treats it as +// +// Only code and `//` / `#` comments can close PHP mode. Strings, heredoc, +// nowdoc and block comments are skipped, `#[` is an attribute, and everything +// after __halt_compiler is left alone, as PHP stops reading there. + +#include "cbm.h" +#include "foundation/arena.h" +#include "foundation/constants.h" +#include +#include +#include + +enum { + PHP_TAG_OPEN_LEN = 5, /* = 0 && i <= len && (size_t)(len - i) >= n && memcmp(s + i, lit, n) == 0; +} + +static bool php_is_ident_char(unsigned char c) { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c >= PHP_FIRST_NON_ASCII; +} + +static bool php_is_ident_start(unsigned char c) { + return php_is_ident_char(c) && !(c >= '0' && c <= '9'); +} + +static bool php_is_space(unsigned char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; +} + +static bool php_is_blank(char c) { + return c == ' ' || c == '\t'; +} + +/* Case-insensitive match of the lowercase `word` at s[i]. */ +static bool php_ieq(const char *s, int len, int i, const char *word) { + int n = (int)strlen(word); + if (i + n > len) { + return false; + } + for (int k = 0; k < n; k++) { + char c = s[i + k]; + if (c >= 'A' && c <= 'Z') { + c = (char)(c - 'A' + 'a'); + } + if (c != word[k]) { + return false; + } + } + return true; +} + +/* Length of the open tag at i, or 0. `` prolog in markup stays markup. */ +static int php_open_tag_len(const char *s, int len, int i) { + if (!php_at(s, len, i, "")) { + return true; + } + } + return false; +} + +/* Blank [from, to) in place, keeping line breaks. */ +static void php_blank(char *out, int from, int to) { + for (int k = from; k < to; k++) { + if (out[k] != '\n' && out[k] != '\r') { + out[k] = ' '; + } + } +} + +/* Index just past the quoted string opened at i. */ +static int php_skip_quoted(const char *s, int len, int i) { + char quote = s[i]; + i += SKIP_ONE; + while (i < len && s[i] != quote) { + i += (s[i] == '\\') ? PAIR_LEN : SKIP_ONE; + } + return i < len ? i + SKIP_ONE : len; +} + +/* Index just past the block comment opened at i. */ +static int php_skip_block_comment(const char *s, int len, int i) { + for (int k = i + PAIR_LEN; k < len; k++) { + if (php_at(s, len, k, "*/")) { + return k + PAIR_LEN; + } + } + return len; +} + +/* Index of the line break or `?>` that ends the one-line comment at i. */ +static int php_skip_line_comment(const char *s, int len, int i) { + while (i < len && s[i] != '\n' && !php_at(s, len, i, "?>")) { + i++; + } + return i; +} + +/* Parse the heredoc/nowdoc opener `<<= len || s[p] != quote) { + return PHP_NOT_FOUND; + } + p++; + } + if (p < len && s[p] == '\r') { + p++; + } + return (p < len && s[p] == '\n') ? p : PHP_NOT_FOUND; +} + +/* True when the line starting at p closes the heredoc. PHP 7.3+ allows the + * closing label to be indented and followed by any non-identifier byte. */ +static bool php_heredoc_closes(const char *s, int len, int p, int label, int label_len, int *end) { + while (p < len && php_is_blank(s[p])) { + p++; + } + if (len - p < label_len || memcmp(s + p, s + label, (size_t)label_len) != 0) { + return false; + } + int after = p + label_len; + if (after < len && php_is_ident_char((unsigned char)s[after])) { + return false; + } + *end = after; + return true; +} + +/* Index just past the heredoc/nowdoc opened by the `<<<` at i. */ +static int php_skip_heredoc(const char *s, int len, int i) { + int label = 0; + int label_len = 0; + int nl = php_heredoc_opener(s, len, i, &label, &label_len); + if (nl < 0) { + return i + PHP_HEREDOC_LEN; + } + while (nl < len) { + int line = nl + SKIP_ONE; + int end = 0; + if (php_heredoc_closes(s, len, line, label, label_len, &end)) { + return end; + } + const char *next = memchr(s + line, '\n', (size_t)(len - line)); + if (!next) { + return len; + } + nl = (int)(next - s); + } + return len; +} + +/* Index just past the identifier at i; sets *halt when it is __halt_compiler + * (a keyword, so not a `$variable` of that name). */ +static int php_skip_ident(const char *s, int len, int i, bool *halt) { + int start = i; + while (i < len && php_is_ident_char((unsigned char)s[i])) { + i++; + } + bool is_var = start > 0 && s[start - SKIP_ONE] == '$'; + *halt = + !is_var && i - start == PHP_HALT_COMPILER_LEN && php_ieq(s, len, start, PHP_HALT_COMPILER); + return i; +} + +/* Scan PHP code from i to the next close tag. Returns the index of that `?>`, + * len when the file ends in PHP mode, or PHP_NOT_FOUND at __halt_compiler. */ +static int php_scan_code(const char *s, int len, int i) { + while (i < len) { + unsigned char c = (unsigned char)s[i]; + if (php_at(s, len, i, "?>")) { + return i; + } + if (c == '\'' || c == '"' || c == '`') { + i = php_skip_quoted(s, len, i); + } else if (php_at(s, len, i, "/*")) { + i = php_skip_block_comment(s, len, i); + } else if (php_at(s, len, i, "//") || (c == '#' && !php_at(s, len, i, "#["))) { + i = php_skip_line_comment(s, len, i); + } else if (php_at(s, len, i, "<<<")) { + i = php_skip_heredoc(s, len, i); + } else if (php_is_ident_start(c)) { + bool halt = false; + i = php_skip_ident(s, len, i, &halt); + if (halt) { + return PHP_NOT_FOUND; + } + } else { + i++; + } + } + return len; +} + +const char *cbm_php_mask_inline_html(CBMArena *arena, const char *source, int source_len) { + if (!arena || !source || source_len <= 0) { + return source; + } + /* Fast path: a file that opens PHP at its first byte and never writes a + * close tag is exactly what the grammar already accepts. */ + if (php_open_tag_len(source, source_len, 0) > 0 && !php_has_close_tag(source, source_len)) { + return source; + } + + char *out = (char *)cbm_arena_alloc(arena, (size_t)source_len + SKIP_ONE); + if (!out) { + return source; + } + memcpy(out, source, (size_t)source_len); + out[source_len] = '\0'; + + bool changed = false; + bool seen_open = false; + int i = 0; + while (i < source_len) { + /* Inline-HTML mode: blank up to the next open tag. */ + int html_start = i; + int tag = 0; + while (i < source_len && (tag = php_open_tag_len(source, source_len, i)) == 0) { + i++; + } + if (i > html_start) { + php_blank(out, html_start, i); + changed = true; + } + if (i >= source_len) { + break; + } + if (seen_open) { + php_blank(out, i, i + tag); + changed = true; + } + seen_open = true; + + /* PHP mode, up to the close tag. */ + int close = php_scan_code(source, source_len, i + tag); + if (close < 0 || close >= source_len) { + break; + } + out[close] = ';'; + out[close + SKIP_ONE] = ' '; + changed = true; + i = close + PAIR_LEN; + } + return changed ? out : source; +} diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 7bfda63fc..c8d47ca52 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -833,6 +833,130 @@ TEST(php_function) { PASS(); } +/* #2000: inline HTML between `?>` and `` fell into an + * ERROR region and never reached the graph. */ +static const CBMCall *find_call_named(CBMFileResult *r, const char *callee) { + for (int i = 0; i < r->calls.count; i++) { + if (r->calls.items[i].callee_name && strcmp(r->calls.items[i].callee_name, callee) == 0) + return &r->calls.items[i]; + } + return NULL; +} + +static int php_def_line(CBMFileResult *r, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].name, name) == 0) + return (int)r->defs.items[i].start_line; + } + return -1; +} + +TEST(php_inline_html_tail_reaches_graph_issue2000) { + CBMFileResult *r = extract("\n" + "
\n" + "

plain markup after the closing tag

\n" + "
\n" + "has_error); + ASSERT(has_def(r, "Function", "gamma")); + ASSERT(has_def(r, "Function", "delta")); + /* Lines are in original-file coordinates. */ + ASSERT_EQ(php_def_line(r, "gamma"), 2); + ASSERT_EQ(php_def_line(r, "delta"), 8); + const CBMCall *c = find_call_named(r, "gamma"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(strstr(c->enclosing_func_qn, "delta")); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + +/* View-file shapes: echo tags keep their calls, and control structures that + * are opened in one PHP block and closed in a later one still parse. */ +TEST(php_inline_html_template_shapes_issue2000) { + CBMFileResult *r = extract("\n" + "<?= page_title($page) ?>\n" + "\n" + "\n" + "

Hello name?>

\n" + "\n" + " log in\n" + "\n" + "\n" + "
  • \n" + "\n" + "\n" + "\n", + CBM_LANG_PHP, "t", "view.php"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_call(r, "page_title")); + ASSERT(has_call(r, "render_item")); + ASSERT(has_def(r, "Function", "footer_links")); + ASSERT_EQ(php_def_line(r, "footer_links"), 12); + ASSERT(has_call(r, "nav_links")); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + +/* `?>` only leaves PHP mode from code or a one-line comment. Inside strings, + * heredoc, nowdoc and block comments it is text; `#[` is an attribute, not a + * comment; ` quoted';\n" + "$b = \"double ?> quoted \\\" still\";\n" + "$c = << body\n" + " EOT;\n" + "$d = <<<'NOW'\n" + "nowdoc ?> body\n" + "NOW;\n" + "/* block ?> comment */\n" + "#[Pure]\n" + "function one() { return 1; }\n" + "// line comment ends here ?>\n" + "\n" + "has_error); + ASSERT(has_def(r, "Function", "one")); + ASSERT(has_def(r, "Function", "two")); + ASSERT_EQ(php_def_line(r, "two"), 16); + ASSERT(has_call(r, "one")); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + +/* A trailing `?>` (common at the end of older class files) is not a partial + * parse, and CRLF line endings keep their line numbers. */ +TEST(php_inline_html_trailing_close_tag_crlf_issue2000) { + CBMFileResult *r = extract("\r\n", + CBM_LANG_PHP, "t", "Repo.php"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Class", "Repo")); + ASSERT(has_def(r, "Method", "find")); + ASSERT_EQ(php_def_line(r, "find"), 3); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + /* --- Ruby --- */ TEST(ruby_class) { CBMFileResult *r = extract("class Animal\n def initialize(name)\n @name = name\n end\n " @@ -13125,6 +13249,10 @@ SUITE(extraction) { RUN_TEST(python_class_base_extracted_bare); RUN_TEST(php_class); RUN_TEST(php_function); + RUN_TEST(php_inline_html_tail_reaches_graph_issue2000); + RUN_TEST(php_inline_html_template_shapes_issue2000); + RUN_TEST(php_inline_html_close_tag_in_literals_issue2000); + RUN_TEST(php_inline_html_trailing_close_tag_crlf_issue2000); RUN_TEST(ruby_class); RUN_TEST(ruby_module); RUN_TEST(csharp_class); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d9b327226..94a281a07 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1376,6 +1376,65 @@ TEST(pipeline_nix_scoped_binding_calls_resolve) { PASS(); } +/* #2000, end to end: functions declared after an inline-HTML block reach the + * store with their CALLS edges, in the same file and across files. */ +TEST(pipeline_php_inline_html_calls_resolve_issue2000) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + static const struct { + const char *name; + const char *body; + } files[] = { + {"inline_html.php", "\n" + "
    \n" + "

    plain markup after the closing tag

    \n" + "
    \n" + "\n" + "\n" + "

    \n" + " Date: Sun, 27 Sep 2026 18:55:55 -0500 Subject: [PATCH 35/61] fix(extract): address review on PHP inline-HTML masking (#2000) Signed-off-by: harshitaajoshi --- internal/cbm/php_inline_html.c | 65 +++++++++++++++++++++++++--------- tests/test_extraction.c | 52 +++++++++++++++++++++++++++ tests/test_php_lsp.c | 60 +++++++++++++++++++++++++++++++ tests/test_pipeline.c | 10 +----- 4 files changed, 161 insertions(+), 26 deletions(-) diff --git a/internal/cbm/php_inline_html.c b/internal/cbm/php_inline_html.c index df5652d54..e766a1fbd 100644 --- a/internal/cbm/php_inline_html.c +++ b/internal/cbm/php_inline_html.c @@ -263,6 +263,47 @@ static int php_scan_code(const char *s, int len, int i) { return len; } +/* The rewritten buffer. It is allocated and filled from the source on the + * first write, so a file whose only `?>` bytes sit in strings or comments + * costs no copy. */ +typedef struct { + CBMArena *arena; + const char *source; + int len; + char *out; + bool failed; +} PHPMask; + +static char *php_mask_writable(PHPMask *m) { + if (!m->out && !m->failed) { + m->out = (char *)cbm_arena_alloc(m->arena, (size_t)m->len + SKIP_ONE); + if (m->out) { + memcpy(m->out, m->source, (size_t)m->len); + m->out[m->len] = '\0'; + } else { + m->failed = true; + } + } + return m->out; +} + +/* Blank [from, to) of the rewritten buffer, keeping line breaks. */ +static void php_mask_blank(PHPMask *m, int from, int to) { + char *out = php_mask_writable(m); + if (out) { + php_blank(out, from, to); + } +} + +/* Turn the `?>` at i into `; `. */ +static void php_mask_close_tag(PHPMask *m, int i) { + char *out = php_mask_writable(m); + if (out) { + out[i] = ';'; + out[i + SKIP_ONE] = ' '; + } +} + const char *cbm_php_mask_inline_html(CBMArena *arena, const char *source, int source_len) { if (!arena || !source || source_len <= 0) { return source; @@ -273,17 +314,10 @@ const char *cbm_php_mask_inline_html(CBMArena *arena, const char *source, int so return source; } - char *out = (char *)cbm_arena_alloc(arena, (size_t)source_len + SKIP_ONE); - if (!out) { - return source; - } - memcpy(out, source, (size_t)source_len); - out[source_len] = '\0'; - - bool changed = false; + PHPMask m = {.arena = arena, .source = source, .len = source_len}; bool seen_open = false; int i = 0; - while (i < source_len) { + while (i < source_len && !m.failed) { /* Inline-HTML mode: blank up to the next open tag. */ int html_start = i; int tag = 0; @@ -291,15 +325,13 @@ const char *cbm_php_mask_inline_html(CBMArena *arena, const char *source, int so i++; } if (i > html_start) { - php_blank(out, html_start, i); - changed = true; + php_mask_blank(&m, html_start, i); } if (i >= source_len) { break; } if (seen_open) { - php_blank(out, i, i + tag); - changed = true; + php_mask_blank(&m, i, i + tag); } seen_open = true; @@ -308,10 +340,9 @@ const char *cbm_php_mask_inline_html(CBMArena *arena, const char *source, int so if (close < 0 || close >= source_len) { break; } - out[close] = ';'; - out[close + SKIP_ONE] = ' '; - changed = true; + php_mask_close_tag(&m, close); i = close + PAIR_LEN; } - return changed ? out : source; + /* On allocation failure parse the file as it is: the old behaviour. */ + return (m.out && !m.failed) ? m.out : source; } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index c8d47ca52..4d03423c1 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -938,6 +938,56 @@ TEST(php_inline_html_close_tag_in_literals_issue2000) { PASS(); } +/* Markup is blanked, never parsed: code-shaped text outside the PHP tags must + * not produce definitions or calls, wherever it sits. */ +TEST(php_inline_html_markup_never_reaches_graph_issue2000) { + CBMFileResult *r = extract("

    function before() { return leakBefore(); }

    \n" + "\n" + "

    function fake() { return leak(); }

    \n" + "\n" + "function xmlFake() { leakXml(); }\n" + "\n" + "

    class Tail { function tailFake() { leakTail(); } }

    \n", + CBM_LANG_PHP, "t", "markup.php"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "real")); + ASSERT(has_def(r, "Function", "after")); + ASSERT(has_call(r, "real")); + ASSERT_FALSE(has_def_any(r, "before")); + ASSERT_FALSE(has_def_any(r, "fake")); + ASSERT_FALSE(has_def_any(r, "jsFake")); + ASSERT_FALSE(has_def_any(r, "xmlFake")); + ASSERT_FALSE(has_def_any(r, "Tail")); + ASSERT_FALSE(has_def_any(r, "tailFake")); + ASSERT_FALSE(has_call(r, "leak")); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + +/* The mask hands back the caller's buffer, uncopied, when nothing needs + * rewriting, and a same-length rewrite otherwise. */ +TEST(php_inline_html_mask_copies_only_on_write_issue2000) { + CBMArena arena; + cbm_arena_init(&arena); + const char *plain = "';\n/* ?> */\n$h = <<\nEOT;\n"; + const char *mixed = "\nx\n"; + ASSERT(cbm_php_mask_inline_html(&arena, plain, (int)strlen(plain)) == plain); + ASSERT(cbm_php_mask_inline_html(&arena, quoted, (int)strlen(quoted)) == quoted); + const char *masked = cbm_php_mask_inline_html(&arena, mixed, (int)strlen(mixed)); + ASSERT(masked != mixed); + ASSERT_EQ((int)strlen(masked), (int)strlen(mixed)); + ASSERT_STR_EQ(masked, "` (common at the end of older class files) is not a partial * parse, and CRLF line endings keep their line numbers. */ TEST(php_inline_html_trailing_close_tag_crlf_issue2000) { @@ -13252,6 +13302,8 @@ SUITE(extraction) { RUN_TEST(php_inline_html_tail_reaches_graph_issue2000); RUN_TEST(php_inline_html_template_shapes_issue2000); RUN_TEST(php_inline_html_close_tag_in_literals_issue2000); + RUN_TEST(php_inline_html_markup_never_reaches_graph_issue2000); + RUN_TEST(php_inline_html_mask_copies_only_on_write_issue2000); RUN_TEST(php_inline_html_trailing_close_tag_crlf_issue2000); RUN_TEST(ruby_class); RUN_TEST(ruby_module); diff --git a/tests/test_php_lsp.c b/tests/test_php_lsp.c index 04ff1001d..7c0314a27 100644 --- a/tests/test_php_lsp.c +++ b/tests/test_php_lsp.c @@ -110,6 +110,65 @@ TEST(phplsp_local_method_via_typed_param) { PASS(); } +/* #2000: with no cached tree (a result spilled under the memory budget comes + * back without one) the cross-file pass re-parses the on-disk source itself. + * It must blank inline HTML exactly as the per-file extract does, or a file's + * tail would lose cross-file resolution only when that file spilled. */ +static int find_resolved_arr(const CBMResolvedCallArray *arr, const char *callerSub, + const char *calleeSub) { + for (int i = 0; i < arr->count; i++) { + const CBMResolvedCall *rc = &arr->items[i]; + if (rc->caller_qn && strstr(rc->caller_qn, callerSub) && rc->callee_qn && + strstr(rc->callee_qn, calleeSub)) + return i; + } + return -1; +} + +TEST(phplsp_cross_reparse_reads_past_inline_html_issue2000) { + const char *src = "\n" + "
    \n" + "

    plain markup after the closing tag

    \n" + "
    \n" + "send(); }\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.main.gamma", + .short_name = "gamma", + .label = "Function", + .def_module_qn = "test.main"}, + {.qualified_name = "test.main.delta", + .short_name = "delta", + .label = "Function", + .def_module_qn = "test.main"}, + {.qualified_name = "test.mailer.Mailer", + .short_name = "Mailer", + .label = "Class", + .def_module_qn = "test.mailer"}, + {.qualified_name = "test.mailer.Mailer.send", + .short_name = "send", + .label = "Method", + .def_module_qn = "test.mailer", + .receiver_type = "test.mailer.Mailer"}, + }; + const char *imp_names[] = {"Mailer"}; + const char *imp_qns[] = {"test.mailer.Mailer"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + + cbm_run_php_lsp_cross(&arena, src, (int)strlen(src), "test.main", defs, 4, imp_names, imp_qns, + 1, NULL, &out); + + ASSERT_GTE(find_resolved_arr(&out, "main.delta", "Mailer.send"), 0); + + cbm_arena_destroy(&arena); + PASS(); +} + /* ── 3. Arrow function with typed parameter ───────────────────── */ TEST(phplsp_arrow_function_typed_param) { @@ -5841,6 +5900,7 @@ SUITE(php_lsp) { /* Phase 1 baseline regressions */ RUN_TEST(phplsp_local_method_via_new_assignment); RUN_TEST(phplsp_local_method_via_typed_param); + RUN_TEST(phplsp_cross_reparse_reads_past_inline_html_issue2000); RUN_TEST(phplsp_arrow_function_typed_param); RUN_TEST(phplsp_static_call_resolved); RUN_TEST(phplsp_self_and_parent); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 94a281a07..c8094efac 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1405,15 +1405,7 @@ TEST(pipeline_php_inline_html_calls_resolve_issue2000) { "function renderCard($item) { return formatPrice($item['cents']); }\n"}, }; for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); i++) { - char path[512]; - snprintf(path, sizeof(path), "%s/%s", g_tmpdir, files[i].name); - FILE *f = fopen(path, "w"); - if (!f) { - teardown_test_repo(); - FAIL("failed to write php fixture"); - } - fputs(files[i].body, f); - fclose(f); + write_temp_file(g_tmpdir, files[i].name, files[i].body); } char db[512]; From bbdeeb61a49c91fb13665a3512b7f36a4ed7e0dd Mon Sep 17 00:00:00 2001 From: harshitaajoshi Date: Mon, 28 Sep 2026 13:10:32 -0500 Subject: [PATCH 36/61] test(extract): pin PHP mask copy-on-write via arena allocation total (#2000) Signed-off-by: harshitaajoshi --- tests/test_extraction.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 4d03423c1..ad6ecd863 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -971,16 +971,28 @@ TEST(php_inline_html_markup_never_reaches_graph_issue2000) { } /* The mask hands back the caller's buffer, uncopied, when nothing needs - * rewriting, and a same-length rewrite otherwise. */ + * rewriting, and a same-length rewrite otherwise. The pointer alone cannot + * tell copy-on-write from an eager copy, so the arena's allocation total is + * sampled around each call: no bytes for `plain` and `quoted`, a copy for + * `mixed`. */ TEST(php_inline_html_mask_copies_only_on_write_issue2000) { CBMArena arena; cbm_arena_init(&arena); const char *plain = "';\n/* ?> */\n$h = <<\nEOT;\n"; const char *mixed = "\nx\n"; + + size_t before = cbm_arena_total(&arena); ASSERT(cbm_php_mask_inline_html(&arena, plain, (int)strlen(plain)) == plain); + ASSERT_EQ(cbm_arena_total(&arena), before); + + before = cbm_arena_total(&arena); ASSERT(cbm_php_mask_inline_html(&arena, quoted, (int)strlen(quoted)) == quoted); + ASSERT_EQ(cbm_arena_total(&arena), before); + + before = cbm_arena_total(&arena); const char *masked = cbm_php_mask_inline_html(&arena, mixed, (int)strlen(mixed)); + ASSERT(cbm_arena_total(&arena) > before); ASSERT(masked != mixed); ASSERT_EQ((int)strlen(masked), (int)strlen(mixed)); ASSERT_STR_EQ(masked, " Date: Sun, 13 Sep 2026 13:52:16 +0300 Subject: [PATCH 37/61] 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 756bda06e..71e694c17 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 cc94147ec..1d67b9581 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 7f4fea6e8..f95404712 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 000000000..b944df768 --- /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 38/61] 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 71e694c17..0bc24af2f 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 1d67b9581..6296d31fb 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 b944df768..1dc618ae6 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 39/61] 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 efd56207d..01398daca 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 0bc24af2f..11d79ee1f 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 1dc618ae6..73c1758f2 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 40/61] 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 11d79ee1f..207944a85 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 73c1758f2..f2e862d0d 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 41/61] 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 01398daca..39d72848f 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 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 42/61] fix(python): resolve ClassName() calls to __init__ A Python construction call resolved to the Class node, so a user __init__ never received a caller from ClassName(...) and the dead-code view reported live constructors as unreferenced. When the called name is the class itself, resolve it to the __init__ found on the class or, depth-first, its declared bases (the lookup super().__init__() already uses), with a new lsp_constructor_init strategy that joins the textual call site through its reason. Classes with no user __init__ keep the Class target (lsp_constructor), as does a builtins.* __init__. Calls through an instance (f = Foo(); f()) are unchanged, since they dispatch to __call__. This changes the documented Class-target contract for classes that define __init__; the three tests that pinned it are updated. Refs #1642 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- internal/cbm/lsp/py_lsp.c | 21 ++- src/pipeline/lsp_resolve.h | 1 + tests/repro/repro_lsp_go_py.c | 30 +++- tests/test_edge_structural.c | 22 ++- tests/test_lsp_resolution_probe.c | 7 +- tests/test_py_lsp.c | 262 ++++++++++++++++++++++++++++++ 6 files changed, 332 insertions(+), 11 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 74c1980e2..be7752a39 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -3249,7 +3249,26 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { py_emit_resolved_call_reason(ctx, qn, "lsp_import_alias", 0.85f, fname, call_node); } else if (qn && cbm_registry_lookup_type(ctx->registry, qn)) { - py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f, call_node); + /* ClassName() runs a user __init__ found on the class or, + * depth-first, a declared base (the same walk + * super().__init__ uses, not C3 MRO). Retarget only when + * this binding IS the class being called. An instance + * (`f = Foo(); f()`) keeps the class row: that call + * dispatches to __call__, and a reason-join would invent + * a Foo.__init__ edge. A builtins.* __init__ is not a + * project constructor, so the class target stays. */ + const CBMRegisteredFunc *fi = NULL; + if (qn_short && strcmp(qn_short, fname) == 0) { + fi = py_lookup_attribute(ctx, qn, "__init__"); + } + if (fi && fi->qualified_name && + strncmp(fi->qualified_name, "builtins.", sizeof("builtins.") - 1) != 0) { + py_emit_resolved_call_reason(ctx, fi->qualified_name, + "lsp_constructor_init", 0.85f, fname, + call_node); + } else { + py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f, call_node); + } } } return; diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 371cb5536..7fd3905d3 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -388,6 +388,7 @@ static inline bool cbm_pipeline_invocation_reason_join_strategy(const char *stra strcmp(strategy, "lsp_method_ref_ctor_synth") == 0 || strcmp(strategy, "lsp_dict_dispatch") == 0 || strcmp(strategy, "lsp_import_alias") == 0 || + strcmp(strategy, "lsp_constructor_init") == 0 || strcmp(strategy, "php_method_dynamic") == 0); } diff --git a/tests/repro/repro_lsp_go_py.c b/tests/repro/repro_lsp_go_py.c index fcb06903d..c0b219172 100644 --- a/tests/repro/repro_lsp_go_py.c +++ b/tests/repro/repro_lsp_go_py.c @@ -64,8 +64,10 @@ * PYTHON STRATEGY INVENTORY — every literal "lsp_..." emitted by py_lsp.c * (grep '"lsp_' internal/cbm/lsp/py_lsp.c), with its keying site: * lsp_direct (py_lsp.c:1631) module-local f() - * lsp_constructor (py_lsp.c:1624) ClassName() where the name is a - * NAMED type in scope + * lsp_constructor ClassName() with no user __init__ → the Class + * lsp_constructor_init ClassName() with a user __init__ (on the class + * or a declared base) → that __init__. The textual + * callee leaf stays the class name; reason joins it. * lsp_method (py_lsp.c:1731) obj.method() on a NAMED-typed * receiver (covers self.other()) * lsp_super (py_lsp.c:1693) super().method() resolved on a @@ -340,13 +342,24 @@ static const char kPyDirect[] = "def caller(v):\n" " return helper(v)\n"; -/* lsp_constructor — ClassName() where the name is a NAMED type in scope - * (py_lsp.c:1620-1624: cbm_scope_lookup yields a NAMED type → emit constructor - * edge to the class QN). */ +/* lsp_constructor — ClassName() where the class has no user __init__. + * The edge stays on the Class node. A class that defines __init__ is + * lsp_constructor_init below; strstr would otherwise treat that strategy + * as a hit for "lsp_constructor". */ static const char kPyConstructor[] = + "class Widget:\n" + " def label(self):\n" + " return 1\n" + "def caller():\n" + " return Widget()\n"; + +/* lsp_constructor_init — ClassName() resolves to the user __init__ + * (issue #1642). reason carries the class-name leaf so the pipeline join + * still matches the textual call. */ +static const char kPyConstructorInit[] = "class Widget:\n" " def __init__(self):\n" - " pass\n" + " self.ready = True\n" "def caller():\n" " return Widget()\n"; @@ -538,6 +551,10 @@ TEST(repro_lsp_py_constructor) { return assert_lsp_strategy("main.py", kPyConstructor, "lsp_constructor"); } +TEST(repro_lsp_py_constructor_init) { + return assert_lsp_strategy("main.py", kPyConstructorInit, "lsp_constructor_init"); +} + TEST(repro_lsp_py_method) { return assert_lsp_strategy("main.py", kPyMethod, "lsp_method"); } @@ -623,6 +640,7 @@ SUITE(repro_lsp_go_py) { RUN_TEST(repro_lsp_py_direct); RUN_TEST(repro_lsp_py_constructor); + RUN_TEST(repro_lsp_py_constructor_init); RUN_TEST(repro_lsp_py_method); RUN_TEST(repro_lsp_py_super); RUN_TEST(repro_lsp_py_super_init); diff --git a/tests/test_edge_structural.c b/tests/test_edge_structural.c index 4b91ae05f..67326dcd5 100644 --- a/tests/test_edge_structural.c +++ b/tests/test_edge_structural.c @@ -826,14 +826,31 @@ TEST(es_decorates_attribute_csharp) { * resolves in the registry. pass_usages.c handles this. * ══════════════════════════════════════════════════════════════════ */ -/* Python constructor syntax is a CALLS edge to the materialized Class node. */ +/* Python ClassName() with a user __init__ is a CALLS edge to that method. + * The Class node no longer receives the instantiation edge. A class with no + * user __init__ still targets the Class (lsp_constructor). */ TEST(es_usage_crossfile_python) { static const ES_LangFile f[] = { {"models.py", "class User:\n def __init__(self, name):\n self.name = name\n"}, {"main.py", "from .models import User\n\n\ndef create_user(name):\n return User(name)\n"}}; - ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "User"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "__init__"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "User"), 0); + PASS(); +} + +/* Issue #1642 repro: build() -> Embedder.__init__, not the Embedder class. */ +TEST(es_calls_python_constructor_init) { + static const ES_LangFile f[] = { + {"main.py", "class Embedder:\n" + " def __init__(self, model=\"x\"):\n" + " self.model = model\n" + "\n" + "def build():\n" + " return Embedder(model=\"y\")\n"}}; + ASSERT_EQ(es_exact_edge_by_name(f, 1, "CALLS", "build", "__init__"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 1, "CALLS", "build", "Embedder"), 0); PASS(); } @@ -982,6 +999,7 @@ SUITE(edge_structural) { /* ── FAMILY 7: USAGE cross-file ─────────────────────────── */ /* Uncertain: depends on USAGE extraction for each language. */ RUN_TEST(es_usage_crossfile_python); + RUN_TEST(es_calls_python_constructor_init); RUN_TEST(es_usage_crossfile_typescript); RUN_TEST(es_usage_crossfile_go); diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 91844f67e..fd5d7c3d3 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1837,16 +1837,19 @@ TEST(lrp_tsx_usage_import_type_with_decoy) { PASS(); } -/* Python: constructor syntax is an exact call to the materialized Class node. */ +/* Python: ClassName() with a user __init__ is an exact CALLS edge to that + * method. The Class node no longer receives the instantiation edge. */ TEST(lrp_python_usage_instantiation) { static const LRP_File f[] = { {"model.py", "class User:\n def __init__(self, name):\n self.name = name\n"}, {"main.py", "from .model import User\n\n\ndef create(name):\n return User(name)\n"}}; LRP_Proj lp; cbm_store_t *store = lrp_index(&lp, f, 2); - int exact = store ? lrp_exact_calls_by_name(store, lp.project, "create", "User") : -1; + int exact = store ? lrp_exact_calls_by_name(store, lp.project, "create", "__init__") : -1; + int class_edge = store ? lrp_exact_calls_by_name(store, lp.project, "create", "User") : -1; lrp_cleanup(&lp, store); ASSERT_EQ(exact, 1); + ASSERT_EQ(class_edge, 0); PASS(); } diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 429fb6628..4f92318c0 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -356,6 +356,164 @@ TEST(pylsp_constructor_call_returns_instance) { PASS(); } +/* Issue #1642: Embedder(...) must resolve to Embedder.__init__, and the + * textual callee leaf "Embedder" must still join that row. */ +TEST(pylsp_constructor_call_targets_init) { + const char *source = "class Embedder:\n" + " def __init__(self, model=\"x\"):\n" + " self.model = model\n" + "def build():\n" + " return Embedder(model=\"y\")\n"; + CBMFileResult *r = extract_py(source); + ASSERT_NOT_NULL(r); + int idx = require_resolved(r, "build", "Embedder.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(rc->reason, "Embedder"); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !strstr(rc->caller_qn, "build")) + continue; + /* One site, one target: the class node no longer receives the call. */ + ASSERT(strstr(rc->callee_qn, ".__init__") != NULL); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } + + const CBMCall *carrier = NULL; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *call = &r->calls.items[i]; + if (call->callee_name && strcmp(call->callee_name, "Embedder") == 0 && + call->enclosing_func_qn && strstr(call->enclosing_func_qn, "build")) { + carrier = call; + break; + } + } + ASSERT_NOT_NULL(carrier); + const CBMResolvedCall *joined = + cbm_pipeline_find_lsp_resolution(&r->resolved_calls, carrier, false); + ASSERT_NOT_NULL(joined); + ASSERT(strstr(joined->callee_qn, "Embedder.__init__") != NULL); + ASSERT_STR_EQ(joined->strategy, "lsp_constructor_init"); + cbm_free_result(r); + PASS(); +} + +/* class B(A) with only A.__init__ resolves construction of B to A.__init__. */ +TEST(pylsp_constructor_inherited_init) { + CBMFileResult *r = extract_py("class A:\n" + " def __init__(self):\n" + " self.ready = True\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "def build():\n" + " return B()\n"); + ASSERT_NOT_NULL(r); + int idx = require_resolved(r, "build", "A.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT(strstr(rc->callee_qn, "B.__init__") == NULL); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } + ASSERT_EQ(find_resolved(r, "build", "B.__init__"), -1); + cbm_free_result(r); + PASS(); +} + +/* No user __init__ (and no inherited one): the call stays on the class. */ +TEST(pylsp_constructor_without_init_stays_on_class) { + CBMFileResult *r = extract_py("class Bare:\n" + " def method(self):\n" + " return 1\n" + "def build():\n" + " return Bare()\n"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(find_resolved(r, "build", "__init__"), -1); + int idx = -1; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind == CBM_RESOLVED_INVOCATION && rc->caller_qn && + strstr(rc->caller_qn, "build") && rc->callee_qn && strstr(rc->callee_qn, "Bare") && + !strstr(rc->callee_qn, "__init__")) { + idx = i; + break; + } + } + ASSERT_GTE(idx, 0); + if (idx >= 0) + ASSERT_STR_EQ(r->resolved_calls.items[idx].strategy, "lsp_constructor"); + cbm_free_result(r); + PASS(); +} + +/* f = Foo(); f() is a callable instance, not a constructor. It must not + * gain an edge to Foo.__init__. Foo() itself still does. */ +TEST(pylsp_callable_instance_call_skips_init) { + CBMFileResult *r = extract_py("class Foo:\n" + " def __init__(self):\n" + " pass\n" + " def __call__(self):\n" + " return 1\n" + "def use():\n" + " f = Foo()\n" + " return f()\n"); + ASSERT_NOT_NULL(r); + int init_rows = 0; + int class_rows = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !strstr(rc->caller_qn, "use")) + continue; + if (strstr(rc->callee_qn, "__init__")) { + init_rows++; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(rc->reason, "Foo"); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } else if (strstr(rc->callee_qn, "Foo")) { + class_rows++; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor"); + ASSERT_NULL(rc->reason); + } + } + ASSERT_EQ(init_rows, 1); + ASSERT_EQ(class_rows, 1); + cbm_free_result(r); + PASS(); +} + +/* builtins.object.__init__ is in the stdlib registry. Construction must not + * record a resolution to it. */ +TEST(pylsp_constructor_skips_builtin_init) { + CBMFileResult *r = extract_py("from builtins import object\n" + "def build():\n" + " return object()\n"); + ASSERT_NOT_NULL(r); + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (!rc->callee_qn) + continue; + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0 || + strstr(rc->callee_qn, ".__init__") == NULL); + } + int idx = find_resolved(r, "build", "builtins.object"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor"); + ASSERT(strstr(rc->callee_qn, "__init__") == NULL); + } + cbm_free_result(r); + PASS(); +} + TEST(pylsp_method_via_inheritance) { CBMFileResult *r = extract_py( "class Base:\n" @@ -924,6 +1082,103 @@ TEST(pylsp_crossfile_inheritance) { PASS(); } +/* Imported ClassName() resolves to that class's __init__. Bare names are + * module-qualified during per-file registration, so the imported class has + * to come from the cross-file registry. */ +TEST(pylsp_crossfile_constructor_targets_init) { + const char *source = "from models import Embedder\n" + "def build():\n" + " return Embedder(model=\"y\")\n"; + + CBMLSPDef defs[2]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "models.Embedder"; + defs[0].short_name = "Embedder"; + defs[0].label = "Class"; + defs[0].def_module_qn = "models"; + defs[0].lang = CBM_LANG_PYTHON; + defs[1].qualified_name = "models.Embedder.__init__"; + defs[1].short_name = "__init__"; + defs[1].label = "Method"; + defs[1].receiver_type = "models.Embedder"; + defs[1].def_module_qn = "models"; + defs[1].lang = CBM_LANG_PYTHON; + + const char *imp_names[] = {"Embedder"}; + const char *imp_qns[] = {"models.Embedder"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 2); + ASSERT_NOT_NULL(reg); + CBMResolvedCallArray out = {0}; + cbm_run_py_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, + imp_names, imp_qns, 1, NULL, &out, NULL); + + int idx = find_resolved_arr(&out, "build", "models.Embedder.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(out.items[idx].reason, "Embedder"); + ASSERT(strncmp(out.items[idx].callee_qn, "builtins.", 9) != 0); + } + cbm_arena_destroy(&arena); + PASS(); +} + +/* class B(A) where A's __init__ lives in another module. The cross registry + * records the real base QN; per-file registration would have stored + * test.main.A and missed the base. */ +TEST(pylsp_crossfile_inherited_init) { + const char *source = "from base_mod import A\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "def build():\n" + " return B()\n"; + + CBMLSPDef defs[3]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "base_mod.A"; + defs[0].short_name = "A"; + defs[0].label = "Class"; + defs[0].def_module_qn = "base_mod"; + defs[0].lang = CBM_LANG_PYTHON; + defs[1].qualified_name = "base_mod.A.__init__"; + defs[1].short_name = "__init__"; + defs[1].label = "Method"; + defs[1].receiver_type = "base_mod.A"; + defs[1].def_module_qn = "base_mod"; + defs[1].lang = CBM_LANG_PYTHON; + defs[2].qualified_name = "test.main.B"; + defs[2].short_name = "B"; + defs[2].label = "Class"; + defs[2].def_module_qn = "test.main"; + defs[2].embedded_types = "base_mod.A"; + defs[2].lang = CBM_LANG_PYTHON; + + const char *imp_names[] = {"A"}; + const char *imp_qns[] = {"base_mod.A"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 3); + ASSERT_NOT_NULL(reg); + CBMResolvedCallArray out = {0}; + cbm_run_py_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, + imp_names, imp_qns, 1, NULL, &out, NULL); + + int idx = find_resolved_arr(&out, "build", "base_mod.A.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(out.items[idx].reason, "B"); + ASSERT(strstr(out.items[idx].callee_qn, "B.__init__") == NULL); + } + cbm_arena_destroy(&arena); + PASS(); +} + TEST(pylsp_batch_two_files) { const char *src_a = "def helper():\n" @@ -5010,6 +5265,11 @@ SUITE(py_lsp) { RUN_TEST(pylsp_method_call_simple); RUN_TEST(pylsp_method_via_self); RUN_TEST(pylsp_constructor_call_returns_instance); + RUN_TEST(pylsp_constructor_call_targets_init); + RUN_TEST(pylsp_constructor_inherited_init); + RUN_TEST(pylsp_constructor_without_init_stays_on_class); + RUN_TEST(pylsp_callable_instance_call_skips_init); + RUN_TEST(pylsp_constructor_skips_builtin_init); RUN_TEST(pylsp_method_via_inheritance); RUN_TEST(pylsp_no_false_positive_on_unknown_method); /* Phases 7-8 — decorators, super(), multi-inheritance */ @@ -5028,6 +5288,8 @@ SUITE(py_lsp) { RUN_TEST(pylsp_cross_registry_interleaved_order); RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); + RUN_TEST(pylsp_crossfile_constructor_targets_init); + RUN_TEST(pylsp_crossfile_inherited_init); RUN_TEST(pylsp_batch_two_files); RUN_TEST(pylsp_from_import_alias_equal_module_leaf_targets_imported_member); RUN_TEST(pylsp_project_prefixed_direct_alias_same_tail_is_not_from_import_reference); From f683b856672c158bee68f12d532a51a382812ebd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:34:34 +0000 Subject: [PATCH 43/61] build(deps): bump actions/download-artifact from 8.0.1 to 8.0.2 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 8.0.1 to 8.0.2. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/_build.yml | 2 +- .github/workflows/_smoke.yml | 6 +++--- .github/workflows/_soak.yml | 8 ++++---- .github/workflows/_test.yml | 4 ++-- .github/workflows/release.yml | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 152cca737..dac1dde67 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -317,7 +317,7 @@ jobs: persist-credentials: false - name: Download all native candidate pairs - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: release-candidates-* path: ${{ runner.temp }}/release-candidate-artifacts diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index ed1022b71..932ee858d 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -103,7 +103,7 @@ jobs: go-version-file: pkg/go/go.mod cache: false - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} @@ -203,7 +203,7 @@ jobs: zip coreutils - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-windows-${{ matrix.arch }} @@ -303,7 +303,7 @@ jobs: go-version-file: pkg/go/go.mod cache: false - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-linux-${{ matrix.arch }}-portable diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index 39f9e76b1..4eb0ac44a 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -73,7 +73,7 @@ jobs: run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - name: Download selected release container if: ${{ inputs.use_release_artifacts }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} path: ${{ runner.temp }}/cbm-selected-container @@ -147,7 +147,7 @@ jobs: run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++ - name: Download selected release container if: ${{ inputs.use_release_artifacts }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-windows-amd64 path: ${{ runner.temp }}/cbm-selected-container @@ -225,7 +225,7 @@ jobs: run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++ - name: Download selected release container if: ${{ inputs.use_release_artifacts }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-windows-arm64 path: ${{ runner.temp }}/cbm-selected-container @@ -287,7 +287,7 @@ jobs: timeout-minutes: 10 run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y python3 git - name: Download selected portable release container - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-linux-${{ matrix.arch }}-portable path: ${{ runner.temp }}/cbm-selected-container diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 501a82b20..2f89e79e6 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -231,7 +231,7 @@ jobs: # producer, so arm64, macOS and Windows code under #ifdef has static # evidence only and those legs run every test. if: ${{ inputs.test_selection != '' && runner.os == 'Linux' && runner.arch == 'X64' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: ${{ inputs.test_selection }} path: ${{ runner.temp }}/test-selection @@ -720,7 +720,7 @@ jobs: with: path: repo - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: shard-manifest-* path: manifests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4926260d..c70330c7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,7 +185,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download only selected release containers - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: binaries-* merge-multiple: true @@ -194,7 +194,7 @@ jobs: # download above. Preserve only the three reviewable manifests that bind # candidate bytes, VT results, and the tuple-local decision. - name: Download candidate selection evidence separately - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-selection-evidence path: selection-evidence @@ -617,7 +617,7 @@ jobs: # the release-draft job as a same-run artifact (not a draft-release # download — that would need contents beyond read plus gh). - name: Fetch release checksums - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-checksums 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 44/61] build(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.1 to 7.0.2. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/_build.yml | 26 +++++++++++----------- .github/workflows/_memwaste.yml | 10 ++++----- .github/workflows/_soak.yml | 20 ++++++++--------- .github/workflows/_test.yml | 12 +++++----- .github/workflows/dry-run.yml | 2 +- .github/workflows/nightly-soak.yml | 2 +- .github/workflows/pr.yml | 6 ++--- .github/workflows/release.yml | 4 ++-- .github/workflows/test-impact-artifact.yml | 2 +- 9 files changed, 42 insertions(+), 42 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 152cca737..4579ee42a 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -108,7 +108,7 @@ jobs: release-candidates/${{ matrix.goos }}-${{ matrix.goarch }}/unstripped/* release-candidates/${{ matrix.goos }}-${{ matrix.goarch }}/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-${{ matrix.goos }}-${{ matrix.goarch }} path: release-candidates/${{ matrix.goos }}-${{ matrix.goarch }} @@ -166,7 +166,7 @@ jobs: release-candidates/windows-amd64/unstripped/* release-candidates/windows-amd64/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-windows-amd64 path: release-candidates/windows-amd64 @@ -228,7 +228,7 @@ jobs: release-candidates/windows-arm64/unstripped/* release-candidates/windows-arm64/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-windows-arm64 path: release-candidates/windows-arm64 @@ -292,7 +292,7 @@ jobs: release-candidates/linux-${{ matrix.arch }}-portable/unstripped/* release-candidates/linux-${{ matrix.arch }}-portable/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-linux-${{ matrix.arch }}-portable path: release-candidates/linux-${{ matrix.arch }}-portable @@ -446,7 +446,7 @@ jobs: - name: Preserve candidate selection evidence if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-selection-evidence path: selection-evidence @@ -455,56 +455,56 @@ jobs: # Keep the established artifact names so the reusable smoke workflow and # all callers consume only the selected, byte-verified containers. - name: Upload linux-amd64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-amd64 path: release-containers/linux-amd64/* if-no-files-found: error - name: Upload linux-arm64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-arm64 path: release-containers/linux-arm64/* if-no-files-found: error - name: Upload linux-amd64-portable containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-amd64-portable path: release-containers/linux-amd64-portable/* if-no-files-found: error - name: Upload linux-arm64-portable containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-arm64-portable path: release-containers/linux-arm64-portable/* if-no-files-found: error - name: Upload darwin-amd64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-darwin-amd64 path: release-containers/darwin-amd64/* if-no-files-found: error - name: Upload darwin-arm64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-darwin-arm64 path: release-containers/darwin-arm64/* if-no-files-found: error - name: Upload windows-amd64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-windows-amd64 path: release-containers/windows-amd64/* if-no-files-found: error - name: Upload windows-arm64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-windows-arm64 path: release-containers/windows-arm64/* diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index 72701ca7a..d08b1692b 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -52,7 +52,7 @@ jobs: run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue ${{ inputs.mode }}-linux - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-event-linux-${{ inputs.mode }} path: | @@ -82,7 +82,7 @@ jobs: --out "$RUNNER_TEMP/memwaste-scaling" | tee "$RUNNER_TEMP/memwaste-scaling/report.txt" - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-scaling-linux-${{ inputs.mode }} path: ${{ runner.temp }}/memwaste-scaling/report.txt @@ -108,7 +108,7 @@ jobs: --out "$RUNNER_TEMP/memwaste-access" | tee "$RUNNER_TEMP/memwaste-access/report.txt" - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-access-linux-${{ inputs.mode }} path: | @@ -135,7 +135,7 @@ jobs: run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue full-macos - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-event-macos-${{ inputs.mode }} path: | @@ -177,7 +177,7 @@ jobs: run: python3 scripts/memwaste-ratchet.py --out "$(cygpath -u "$RUNNER_TEMP")/memwaste-event" --venue full-windows - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-event-windows-${{ inputs.mode }} path: | diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index 39f9e76b1..5a6f0e9c7 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -98,14 +98,14 @@ jobs: scripts/soak-legs.sh --legs "${{ inputs.legs }}" "$binary" ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-${{ matrix.goos }}-${{ matrix.goarch }} path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-${{ matrix.goos }}-${{ matrix.goarch }} path: soak-results-query-leak/ @@ -175,14 +175,14 @@ jobs: scripts/soak-legs.sh --legs "${{ inputs.legs }}" "$binary" ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-windows-amd64 path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-windows-amd64 path: soak-results-query-leak/ @@ -253,14 +253,14 @@ jobs: scripts/soak-legs.sh --legs "${{ inputs.legs }}" "$binary" ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-windows-arm64 path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-windows-arm64 path: soak-results-query-leak/ @@ -305,14 +305,14 @@ jobs: ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-linux-${{ matrix.arch }}-portable path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-linux-${{ matrix.arch }}-portable path: soak-results-query-leak/ @@ -373,7 +373,7 @@ jobs: run: scripts/soak-legs.sh --legs quick build/c/codebase-memory-mcp 15 - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-asan-${{ matrix.goos }}-${{ matrix.goarch }} path: soak-results/ @@ -413,7 +413,7 @@ jobs: run: scripts/soak-legs.sh --legs quick build/c/codebase-memory-mcp 15 - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-asan-windows-amd64 path: soak-results/ diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 501a82b20..ff378b1d0 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -253,7 +253,7 @@ jobs: # too keeps red iterations warm. - name: Shard manifest (completeness proof) if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: shard-manifest-${{ matrix.os }}-${{ matrix.cc }}-${{ strategy.job-index }} path: build/c/test-logs/shard-manifest.txt @@ -274,7 +274,7 @@ jobs: # directory, and a second error would only bury the first. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-${{ matrix.os }}-${{ matrix.cc }}-${{ strategy.job-index }} path: build/c/test-logs/failed/ @@ -406,7 +406,7 @@ jobs: # not end green, only when the job failed. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-diag path: build/c/test-logs/failed/ @@ -504,7 +504,7 @@ jobs: # not end green, only when the job failed. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-lsan-macos path: build/c/test-logs/failed/ @@ -669,7 +669,7 @@ jobs: - name: Shard manifest (completeness proof) if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: shard-manifest-${{ matrix.os }}-${{ matrix.msystem }}-${{ strategy.job-index }} path: build/c/test-logs/shard-manifest.txt @@ -684,7 +684,7 @@ jobs: # not end green, only when the job failed. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-${{ matrix.os }}-${{ matrix.msystem }}-${{ strategy.job-index }} path: build/c/test-logs/failed/ diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index c582716fe..7554f16fb 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -148,7 +148,7 @@ jobs: run: scripts/fuzz.sh all --runs 20000 --seed 1 - name: Upload reproducers if: ${{ failure() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-artifacts path: build/fuzz/artifacts-*/ diff --git a/.github/workflows/nightly-soak.yml b/.github/workflows/nightly-soak.yml index d4357aad6..e45d3395f 100644 --- a/.github/workflows/nightly-soak.yml +++ b/.github/workflows/nightly-soak.yml @@ -51,7 +51,7 @@ jobs: run: scripts/fuzz.sh all --max-total-time 1800 --seed 0 - name: Upload reproducers if: ${{ failure() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-artifacts-nightly path: build/fuzz/artifacts-*/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e20ac274a..2c59a4db2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -78,7 +78,7 @@ jobs: run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" --gate "$RUNNER_TEMP/test-selection" - name: Upload the selection if: ${{ steps.select.outputs.selection == 'narrowed' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-selection path: ${{ runner.temp }}/test-selection @@ -88,7 +88,7 @@ jobs: # for the vetoes, never read by a test leg. - name: Upload the vetoed selection if: ${{ !cancelled() && steps.select.outcome != 'skipped' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-selection-vetoed path: ${{ runner.temp }}/test-impact/selection-vetoed.json @@ -342,7 +342,7 @@ jobs: run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" - name: Upload the prediction if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-impact-prediction path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4926260d..033c1a14b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,7 +236,7 @@ jobs: # same-run workflow artifact, not a draft-release download, so that # job keeps contents: read and no gh dependency. - name: Preserve checksums for the registry job - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-checksums path: checksums.txt @@ -429,7 +429,7 @@ jobs: - name: Preserve extracted-object VirusTotal evidence if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: virustotal-evidence-release-${{ github.run_id }} path: | diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml index 49f70dd67..a0a53061c 100644 --- a/.github/workflows/test-impact-artifact.yml +++ b/.github/workflows/test-impact-artifact.yml @@ -79,7 +79,7 @@ jobs: --out "$RUNNER_TEMP/bundle" --mode "$mode" --before "$before" - name: Upload the bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-impact-${{ github.sha }} path: ${{ runner.temp }}/bundle From 0210ea97940ed9c3af670fb11758b23a768cd69e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:17:21 +0100 Subject: [PATCH 45/61] fix(ruby-lsp): search each ancestor once per method lookup The mixin walk recursed into prepend and include at every level with only the depth cap of 16 as a bound, so a module that both prepends and includes the next one doubled the paths per level: a miss on a chain of n such modules cost 2^n visits (measured 4 -> 20,744 for n = 2..14). Each lookup is now one walk that carries the fail-closed blocked flag and a visit stamp. Ruby linearizes ancestors, so a module reached a second time in one walk was already searched and missed (a hit or a block would have ended the walk); skipping the repeat keeps every lookup linear in the ancestors the file declares. Lookup order, hits and the fail-closed rules are unchanged. The stamps live on the per-file class table, so the walk allocates nothing. The walk entries feed CBM_LSP_WORK (seam builds only; product builds compile it to nothing). rubylsp_mixin_walk_visits_each_module_once pins linear work for the doubled chain on the instance and the class side (before: 66 -> 4,098 visits for n = 6 -> 12; after: 14 -> 26) and keeps a diamond and a prepend-plus-include pair resolving exactly once. Signed-off-by: Martin Vogel --- internal/cbm/lsp/ruby_lsp.c | 135 ++++++++++++++++++++++++------------ internal/cbm/lsp/ruby_lsp.h | 8 +++ tests/test_ruby_lsp.c | 114 ++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 43 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index ed97c78ec..6c89ac01f 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -78,6 +78,7 @@ */ #include "ruby_lsp.h" +#include "lsp_work.h" #include "../helpers.h" #include "../arena.h" #include @@ -470,12 +471,53 @@ static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, c * from that point on no hit is trustworthy and callers must emit nothing — * including NOT falling through to a same-named top-level def or to the * Object typing fallback. Hits found BEFORE the unseen ancestor in Ruby's - * lookup order are still returned. */ + * lookup order are still returned. + * + * Each lookup is one walk: the `blocked` flag plus a visit stamp. Ruby + * linearizes ancestors, so a module reached a second time in one walk + * (`prepend M; include M`, two includes sharing a module) was already + * searched and missed: a hit or a block would have ended the walk. Skipping + * the repeat keeps a lookup linear in the ancestors it can see; without it + * every module that both prepends and includes the next one doubles the + * paths per level. */ +typedef struct { + bool blocked; + unsigned int stamp; +} RubyLookupWalk; + +static RubyLookupWalk ruby_walk_begin(RubyLSPContext *ctx) { + if (++ctx->lookup_stamp == 0) { + /* Wrapped: clear every mark so no class looks visited. */ + for (int i = 0; i < ctx->class_count; i++) { + ctx->classes[i].seen_instance = 0; + ctx->classes[i].seen_singleton = 0; + } + ctx->lookup_stamp = 1; + } + RubyLookupWalk w = {false, ctx->lookup_stamp}; + return w; +} + +/* Marks `class_qn` searched on one side (instance or singleton) in this walk; + * true when it already was. Only the file's class table is marked: mixins + * and superclass refs are recorded for those classes alone, so a type outside + * the table (a stdlib type) has nothing to expand and is never a repeat. */ +static bool ruby_walk_seen(RubyLSPContext *ctx, RubyLookupWalk *w, const char *class_qn, + bool singleton) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (!ci) + return false; + unsigned int *mark = singleton ? &ci->seen_singleton : &ci->seen_instance; + if (*mark == w->stamp) + return true; + *mark = w->stamp; + return false; +} static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth, bool *blocked); + int depth, RubyLookupWalk *w); /* True when the class declares a superclass the finalize step could not * resolve (stdlib-seeded conventions like ApplicationRecord DO resolve). */ @@ -489,18 +531,18 @@ static bool ruby_superclass_unresolved(RubyLSPContext *ctx, const char *class_qn * before any hit blocks the walk. */ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const char *owner_qn, RubyMixinKind kind, const char *method_name, - int depth, bool *blocked) { + int depth, RubyLookupWalk *w) { for (int i = ctx->mixin_count - 1; i >= 0; i--) { RubyMixinInfo *mi = &ctx->mixins[i]; if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0) continue; if (!mi->module_qn) { - *blocked = true; /* unseen module — it may define/shadow the method */ + w->blocked = true; /* unseen module — it may define/shadow the method */ return NULL; } const CBMRegisteredFunc *f = - ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, blocked); - if (f || *blocked) + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, w); + if (f || w->blocked) return f; } return NULL; @@ -509,29 +551,32 @@ static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth, bool *blocked) { + int depth, RubyLookupWalk *w) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; + CBM_LSP_WORK(1); + if (ruby_walk_seen(ctx, w, class_qn, false)) + return NULL; /* searched earlier in this walk: a repeat can only miss */ /* Prepended modules shadow the class's own methods — an unresolved * prepend therefore blocks even an own-method hit. */ const CBMRegisteredFunc *f = - ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth, blocked); - if (f || *blocked) + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth, w); + if (f || w->blocked) return f; f = ruby_own_instance_method(ctx, class_qn, method_name); if (f) return f; /* An unresolved include stops the walk before the superclass chain. */ - f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth, blocked); - if (f || *blocked) + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth, w); + if (f || w->blocked) return f; if (ruby_superclass_unresolved(ctx, class_qn)) { - *blocked = true; + w->blocked = true; return NULL; } const char *sup = ruby_superclass_qn(ctx, class_qn); if (sup && strcmp(sup, class_qn) != 0) - return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, blocked); + return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, w); return NULL; } @@ -540,10 +585,10 @@ static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - bool *blocked) { + RubyLookupWalk *w) { const CBMRegisteredFunc *f = - ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, blocked); - if (f || *blocked) + ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, w); + if (f || w->blocked) return f; /* Universal receiver fallback (Object / ActiveSupport predicates) — * typing only; Object methods are stdlib entries, never project defs. */ @@ -554,37 +599,44 @@ static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ct const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, const char *method_name) { - bool blocked = false; - return ruby_lookup_instance_method_b(ctx, class_qn, method_name, &blocked); + if (!ctx) + return NULL; + RubyLookupWalk w = ruby_walk_begin(ctx); + return ruby_lookup_instance_method_b(ctx, class_qn, method_name, &w); } static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, const char *class_qn, const char *method_name, - int depth, bool *blocked) { + int depth, RubyLookupWalk *w) { if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) return NULL; + CBM_LSP_WORK(1); + if (ruby_walk_seen(ctx, w, class_qn, true)) + return NULL; /* searched earlier in this walk: a repeat can only miss */ const CBMRegisteredFunc *f = ruby_own_singleton_method(ctx, class_qn, method_name); if (f) return f; /* `extend Mod` adds Mod's instance methods class-side. */ - f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth, blocked); - if (f || *blocked) + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth, w); + if (f || w->blocked) return f; if (ruby_superclass_unresolved(ctx, class_qn)) { - *blocked = true; + w->blocked = true; return NULL; } const char *sup = ruby_superclass_qn(ctx, class_qn); if (sup && strcmp(sup, class_qn) != 0) - return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1, blocked); + return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1, w); return NULL; } const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, const char *method_name) { - bool blocked = false; - return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &blocked); + if (!ctx) + return NULL; + RubyLookupWalk w = ruby_walk_begin(ctx); + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &w); } /* ── ActiveRecord model detection (typing special case) ─────────── */ @@ -878,13 +930,11 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (ctx->self_unknown) return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */ if (ctx->enclosing_class_qn) { - bool blocked = false; + RubyLookupWalk w = ruby_walk_begin(ctx); const CBMRegisteredFunc *f = ctx->in_singleton_method - ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, - &blocked) - : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, - &blocked); + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, @@ -893,7 +943,7 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool } /* An unseen ancestor may define this method: do NOT fall through * to a same-named top-level def. */ - if (blocked) + if (w.blocked) return cbm_type_unknown(); } const CBMRegisteredFunc *f = @@ -915,12 +965,11 @@ static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool if (strcmp(rk, "self") == 0) { if (!ctx->enclosing_class_qn || ctx->self_unknown) return cbm_type_unknown(); - bool blocked = false; - const CBMRegisteredFunc *f = ctx->in_singleton_method - ? ruby_lookup_singleton_method_depth( - ctx, ctx->enclosing_class_qn, mname, 0, &blocked) - : ruby_lookup_instance_method_depth( - ctx, ctx->enclosing_class_qn, mname, 0, &blocked); + RubyLookupWalk w = ruby_walk_begin(ctx); + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w); if (f) { if (emit) ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, @@ -967,22 +1016,22 @@ static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { const char *mname = strrchr(fq, '.'); mname = mname ? mname + 1 : fq; const CBMRegisteredFunc *f = NULL; - bool blocked = false; + RubyLookupWalk w = ruby_walk_begin(ctx); /* Search order mirrors Ruby: includes of the class, then superclass * chain — the class's OWN def is the caller and must be skipped. An * unresolved mixin or superclass on the way blocks the walk. */ f = ruby_lookup_in_mixins(ctx, ctx->enclosing_class_qn, ctx->in_singleton_method ? RUBY_MIXIN_EXTEND : RUBY_MIXIN_INCLUDE, - mname, 0, &blocked); - if (!f && !blocked && !ruby_superclass_unresolved(ctx, ctx->enclosing_class_qn)) { + mname, 0, &w); + if (!f && !w.blocked && !ruby_superclass_unresolved(ctx, ctx->enclosing_class_qn)) { const char *sup = ruby_superclass_qn(ctx, ctx->enclosing_class_qn); if (sup) { f = ctx->in_singleton_method - ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0, &blocked) - : ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &blocked); + ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &w); } } - if (f && !blocked) + if (f && !w.blocked) ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); /* No known parent or unresolved — zero-edge guarantee. */ } diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h index ad2d81de3..317702cb6 100644 --- a/internal/cbm/lsp/ruby_lsp.h +++ b/internal/cbm/lsp/ruby_lsp.h @@ -33,6 +33,10 @@ typedef struct { const char *superclass_ref; /* raw dotted superclass spelling, or NULL */ const char *superclass_qn; /* resolved QN (table/stdlib), or NULL */ bool is_module; /* `module` (mixin source) vs `class` */ + /* Method-lookup visit marks: the stamp of the last walk that searched + * this class on the instance / singleton side (ruby_walk_seen). */ + unsigned int seen_instance; + unsigned int seen_singleton; } RubyClassInfo; /* One `include`/`prepend`/`extend` record: owner class QN → module ref. */ @@ -129,6 +133,10 @@ typedef struct { int eval_depth; int walk_depth; + /* Stamp of the current method-lookup walk; each walk takes a fresh one + * so marks left by earlier walks never match. */ + unsigned int lookup_stamp; + /* Debug mode (CBM_LSP_DEBUG env). */ bool debug; } RubyLSPContext; diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index d1999357f..1e2b2c85e 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -40,6 +40,9 @@ #include "cbm.h" #include "../src/pipeline/lsp_resolve.h" #include "lsp/ruby_lsp.h" +#include "lsp/lsp_work.h" +#include +#include #include /* ── Helpers (mirror test_perl_lsp.c) ──────────────────────────── */ @@ -1317,6 +1320,116 @@ TEST(rubylsp_self_rebinding_blocks_no_edge) { PASS(); } +/* ── 17. Bounded resolver work ──────────────────────────────────── + * + * Measured with counters, never with a clock (O9): CBM_LSP_WORK + * (lsp_work.h, seam builds) for lookup work, and the result arena's + * cumulative allocation for bytes built. Each fixture is measured at n and + * 2n: linear work doubles (ratio about 2); anything worse lands above the + * gate of 3. */ + +static uint64_t rubylsp_counted_work(const char *src, size_t *arena_bytes) { + (void)cbm_lsp_work_take(); + CBMFileResult *r = extract_ruby(src); + uint64_t work = cbm_lsp_work_take(); + if (arena_bytes) + *arena_bytes = r ? cbm_arena_total(&r->arena) : 0; + if (r) + cbm_free_result(r); + return work; +} + +/* n modules where every module both prepends and includes the next one; + * the last defines one method. `Probe` reaches M0 through `include` + * (instance side) or `extend` (class side) and calls a method nobody + * defines, so the lookup must miss after searching every ancestor. */ +static char *rubylsp_mixin_chain_src(int n, bool class_side) { + size_t cap = (size_t)n * 96 + 256; + char *src = (char *)malloc(cap); + if (!src) + return NULL; + size_t off = 0; + for (int i = 0; i < n; i++) { + if (i + 1 < n) + off += (size_t)snprintf(src + off, cap - off, + "module M%d\n prepend M%d\n include M%d\nend\n", i, i + 1, + i + 1); + else + off += (size_t)snprintf(src + off, cap - off, + "module M%d\n def leaf\n 1\n end\nend\n", i); + } + if (class_side) + (void)snprintf(src + off, cap - off, + "class Probe\n extend M0\n def self.run\n nobody_defines_this()\n" + " end\nend\n"); + else + (void)snprintf(src + off, cap - off, + "class Probe\n include M0\n def run\n nobody_defines_this()\n" + " end\nend\n"); + return src; +} + +static uint64_t rubylsp_mixin_chain_work(int n, bool class_side) { + char *src = rubylsp_mixin_chain_src(n, class_side); + if (!src) + return 0; + uint64_t work = rubylsp_counted_work(src, NULL); + free(src); + return work; +} + +/* Review ask (a): Ruby linearizes ancestors, so a module reached through + * two paths (`prepend M; include M`, a diamond) is searched once. Without a + * visited set every level of the chain doubles the paths: 2^n visits + * (measured 4 -> 20,744 for n = 2..14). With it a miss costs one visit per + * module. Positive controls: the doubled pair still resolves through the + * prepend, and a diamond resolves to the shared module exactly once. */ +TEST(rubylsp_mixin_walk_visits_each_module_once) { + uint64_t inst_n = rubylsp_mixin_chain_work(6, false); + uint64_t inst_2n = rubylsp_mixin_chain_work(12, false); + uint64_t cls_n = rubylsp_mixin_chain_work(6, true); + uint64_t cls_2n = rubylsp_mixin_chain_work(12, true); + printf(" mixin chain work: instance n=6 %llu, n=12 %llu; class side n=6 %llu, n=12 %llu\n", + (unsigned long long)inst_n, (unsigned long long)inst_2n, (unsigned long long)cls_n, + (unsigned long long)cls_2n); + ASSERT(inst_n > 0 && cls_n > 0); + ASSERT(inst_2n <= 3 * inst_n); + ASSERT(cls_2n <= 3 * cls_n); + + const char *src = "module Shared\n" + " def hello\n" + " 1\n" + " end\n" + "end\n" + "module Left\n" + " include Shared\n" + "end\n" + "module Right\n" + " include Shared\n" + "end\n" + "module Twice\n" + " def twice\n" + " 2\n" + " end\n" + "end\n" + "class Diamond\n" + " include Left\n" + " include Right\n" + " prepend Twice\n" + " include Twice\n" + " def run\n" + " hello()\n" + " twice()\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Shared.hello") == 1); + ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Twice.twice") == 1); + cbm_free_result(r); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1353,4 +1466,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_send_no_edge); RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); + RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); } From 214a9ac8436ba8e2277bce533726a8ffdc1ed187 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:17:28 +0100 Subject: [PATCH 46/61] fix(ruby-lsp): build constant paths in one pass ruby_const_path recursed once per :: segment and re-formatted the growing prefix at every level, so an A::B::C path cost one stack frame per segment and quadratic bytes in the per-file arena. It now follows the scope fields in a loop: one pass measures the segments, a second copies them right to left into a single buffer. The result is the same string as before, including a leading ::Foo and a scope that is not a constant path. rubylsp_const_path_is_linear pins the arena bytes for an 800 vs a 1,600 segment path (before: 494,520 -> 1,660,520 bytes already at 400 -> 800 segments; after: 136,248 -> 150,848 at 800 -> 1,600) and the exact class QN for a five-level path and for a rooted ::Top::Inner. Signed-off-by: Martin Vogel --- internal/cbm/lsp/ruby_lsp.c | 77 +++++++++++++++++++++++++++++++------ tests/test_ruby_lsp.c | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 11 deletions(-) diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c index 6c89ac01f..2464a6891 100644 --- a/internal/cbm/lsp/ruby_lsp.c +++ b/internal/cbm/lsp/ruby_lsp.c @@ -114,10 +114,52 @@ static char *ruby_node_text(RubyLSPContext *ctx, TSNode node) { return cbm_node_text(ctx->arena, node, ctx->source); } +/* Byte length of a node's source text (0 for an empty node). */ +static size_t ruby_node_len(TSNode node) { + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + return end > start ? (size_t)(end - start) : 0; +} + +/* One step left along a constant path. `*cur` is a scope_resolution whose + * own segment has been taken; on success `*seg` is the next segment to its + * left and `*cur` the node to continue from. The path ends at a missing + * scope (`::Foo`), at a scope that is not a constant path (`expr::Foo`), + * and at an empty segment. A plain `constant` has no scope field, so it is + * always the last segment. */ +static bool ruby_const_step(TSNode *cur, TSNode *seg) { + TSNode scope = ts_node_child_by_field_name(*cur, "scope", 5); + if (ts_node_is_null(scope)) + return false; + const char *k = ts_node_type(scope); + if (strcmp(k, "constant") == 0) { + if (ruby_node_len(scope) == 0) + return false; + *seg = scope; + *cur = scope; + return true; + } + if (strcmp(k, "scope_resolution") != 0) + return false; + TSNode name = ts_node_child_by_field_name(scope, "name", 4); + if (ts_node_is_null(name) || ruby_node_len(name) == 0) + return false; + *seg = name; + *cur = scope; + return true; +} + /* Convert a constant expression node (constant | scope_resolution) into a * dotted path ("Foo", "A.B.C"). Returns NULL for anything else. A leading * "::Foo" (scope_resolution without a scope) yields "Foo" — the resolver's - * table probe covers the top-level meaning. */ + * table probe covers the top-level meaning. A scope that is not itself a + * constant path (`expr::Foo`) contributes nothing. + * + * `A::B::C` nests to the LEFT (scope_resolution(scope_resolution(A, B), C)), + * so the path is read from the outermost node along the `scope` fields: one + * pass measures the segments, a second copies them right to left into one + * buffer. No recursion and no re-formatting of a growing prefix, so neither + * stack depth nor bytes grow faster than the path itself. */ static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { if (ts_node_is_null(node)) return NULL; @@ -126,19 +168,32 @@ static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { return ruby_node_text(ctx, node); if (strcmp(k, "scope_resolution") != 0) return NULL; - TSNode scope = ts_node_child_by_field_name(node, "scope", 5); TSNode name = ts_node_child_by_field_name(node, "name", 4); - if (ts_node_is_null(name)) + if (ts_node_is_null(name) || ruby_node_len(name) == 0) return NULL; - char *nm = ruby_node_text(ctx, name); - if (!nm || !nm[0]) + + size_t total = 0; + TSNode cur = node; + TSNode seg = name; + do { + total += ruby_node_len(seg) + 1; /* segment + its '.' (or the NUL) */ + } while (ruby_const_step(&cur, &seg)); + + char *out = (char *)cbm_arena_alloc(ctx->arena, total); + if (!out) return NULL; - if (ts_node_is_null(scope)) - return nm; /* ::Foo */ - const char *sp = ruby_const_path(ctx, scope); - if (!sp || !sp[0]) - return nm; - return cbm_arena_sprintf(ctx->arena, "%s.%s", sp, nm); + size_t pos = total - 1; + out[pos] = '\0'; + cur = node; + seg = name; + do { + size_t len = ruby_node_len(seg); + pos -= len; + memcpy(out + pos, ctx->source + ts_node_start_byte(seg), len); + if (pos > 0) + out[--pos] = '.'; + } while (ruby_const_step(&cur, &seg)); + return out; } /* Join two dotted path fragments; either side may be empty/NULL. */ diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 1e2b2c85e..4aa00b9f8 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -1430,6 +1430,74 @@ TEST(rubylsp_mixin_walk_visits_each_module_once) { PASS(); } +/* `class Probe; def run; S0::S1::...::S.new; end; end` */ +static char *rubylsp_deep_const_src(int n) { + size_t cap = (size_t)n * 12 + 128; + char *src = (char *)malloc(cap); + if (!src) + return NULL; + size_t off = (size_t)snprintf(src, cap, "class Probe\n def run\n "); + for (int i = 0; i < n; i++) + off += (size_t)snprintf(src + off, cap - off, i ? "::S%d" : "S%d", i); + (void)snprintf(src + off, cap - off, ".new\n end\nend\n"); + return src; +} + +static size_t rubylsp_deep_const_bytes(int n) { + char *src = rubylsp_deep_const_src(n); + if (!src) + return 0; + size_t bytes = 0; + (void)rubylsp_counted_work(src, &bytes); + free(src); + return bytes; +} + +/* Review ask (b): a constant path is built in one pass. Recursing once per + * `::` and re-formatting the growing prefix at every level costs one stack + * frame per segment and quadratic bytes; the loop has no recursion and + * writes the path once. The positive control pins the exact class QN for a + * deep path and for a leading `::`. */ +TEST(rubylsp_const_path_is_linear) { + size_t b_n = rubylsp_deep_const_bytes(800); + size_t b_2n = rubylsp_deep_const_bytes(1600); + printf(" const path arena bytes: n=800 %zu, n=1600 %zu\n", b_n, b_2n); + ASSERT(b_n > 0); + ASSERT(b_2n <= 3 * b_n); + + const char *src = "module L0\n" + " module L1\n" + " module L2\n" + " module L3\n" + " module L4\n" + " class Leaf\n" + " end\n" + " end\n" + " end\n" + " end\n" + " end\n" + "end\n" + "class Top\n" + " class Inner\n" + " end\n" + "end\n" + "class Caller\n" + " def deep\n" + " L0::L1::L2::L3::L4::Leaf.new\n" + " end\n" + " def rooted\n" + " ::Top::Inner.new\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Caller.deep", "test.main.L0.L1.L2.L3.L4.Leaf") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Caller.rooted", "test.main.Top.Inner") >= 0); + cbm_free_result(r); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1467,4 +1535,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); + RUN_TEST(rubylsp_const_path_is_linear); } From 435baf6bd87bbe250ba45b2c4424d11a1e2e057e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:17:28 +0100 Subject: [PATCH 47/61] fix(ruby-lsp): survive allocation failure in the stdlib seed The REG_* macros wrote through the return-type vector without checking the allocation, and registered entries whose formatted QN came back NULL. A shared ruby_seed_signature helper now leaves a failed vector out, so the signature types as unknown (the fallback ruby_register_method already uses), and REG_METHOD / REG_SMETHOD skip an entry whose formatted name is NULL, since a NULL QN would reach strcmp in finalize and in every lookup. rubylsp_stdlib_seed_survives_alloc_failure seeds a registry from an arena that cannot allocate. Before: UBSan reported a store to a null pointer and the run crashed with SEGV at ruby_stdlib_data.c:163. After: every registered entry carries a name, the registry finalizes, and the Kernel built-ins with literal names still resolve. Signed-off-by: Martin Vogel --- internal/cbm/lsp/generated/ruby_stdlib_data.c | 90 ++++++++++--------- tests/test_ruby_lsp.c | 27 ++++++ 2 files changed, 74 insertions(+), 43 deletions(-) diff --git a/internal/cbm/lsp/generated/ruby_stdlib_data.c b/internal/cbm/lsp/generated/ruby_stdlib_data.c index 83e853e65..f94ccd764 100644 --- a/internal/cbm/lsp/generated/ruby_stdlib_data.c +++ b/internal/cbm/lsp/generated/ruby_stdlib_data.c @@ -40,55 +40,59 @@ cbm_registry_add_type(reg, rt); \ } while (0) -/* Register an instance method on receiver type `recv_` returning `ret_`. */ -#define REG_METHOD(recv_, name_, ret_) \ - do { \ - memset(&rf, 0, sizeof(rf)); \ - rf.min_params = -1; \ - rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ - rf.short_name = (name_); \ - rf.receiver_type = (recv_); \ - { \ - const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ - rets[0] = (ret_); \ - rets[1] = NULL; \ - rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ - } \ - cbm_registry_add_func(reg, rf); \ +/* Signature `() -> ret`. Every seed allocation can fail (arena exhausted): + * a NULL return vector is left out, so the signature types as unknown (the + * same fallback ruby_register_method uses) instead of being written through. */ +static const CBMType *ruby_seed_signature(CBMArena *arena, const CBMType *ret) { + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); + if (rets) { + rets[0] = ret; + rets[1] = NULL; + } + return cbm_type_func(arena, NULL, NULL, rets); +} + +/* Register an instance method on receiver type `recv_` returning `ret_`. + * An entry whose formatted QN came back NULL is not registered: a NULL QN + * would reach strcmp in finalize and in every later lookup. */ +#define REG_METHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + if (!rf.qualified_name) \ + break; \ + rf.short_name = (name_); \ + rf.receiver_type = (recv_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ } while (0) /* Register a singleton (class-side) method: receiver key ".self" - * matches ruby_lookup_singleton_method's keying convention. */ -#define REG_SMETHOD(recv_, name_, ret_) \ - do { \ - memset(&rf, 0, sizeof(rf)); \ - rf.min_params = -1; \ - rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ - rf.short_name = (name_); \ - rf.receiver_type = cbm_arena_sprintf(arena, "%s.self", (recv_)); \ - { \ - const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ - rets[0] = (ret_); \ - rets[1] = NULL; \ - rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ - } \ - cbm_registry_add_func(reg, rf); \ + * matches ruby_lookup_singleton_method's keying convention. Skipped when + * either formatted name came back NULL (see REG_METHOD). */ +#define REG_SMETHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + rf.receiver_type = cbm_arena_sprintf(arena, "%s.self", (recv_)); \ + if (!rf.qualified_name || !rf.receiver_type) \ + break; \ + rf.short_name = (name_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ } while (0) /* Register a global (Kernel) built-in function returning `ret_`. */ -#define REG_BUILTIN(name_, ret_) \ - do { \ - memset(&rf, 0, sizeof(rf)); \ - rf.min_params = -1; \ - rf.qualified_name = (name_); \ - rf.short_name = (name_); \ - { \ - const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); \ - rets[0] = (ret_); \ - rets[1] = NULL; \ - rf.signature = cbm_type_func(arena, NULL, NULL, rets); \ - } \ - cbm_registry_add_func(reg, rf); \ +#define REG_BUILTIN(name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = (name_); \ + rf.short_name = (name_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ } while (0) void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena) { diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c index 4aa00b9f8..c5499f78e 100644 --- a/tests/test_ruby_lsp.c +++ b/tests/test_ruby_lsp.c @@ -1498,6 +1498,32 @@ TEST(rubylsp_const_path_is_linear) { PASS(); } +/* Review ask (c): the stdlib seed must survive allocation failure. A + * zero-initialised arena has no blocks and no grow size, so every + * allocation from it returns NULL. The seed must not write through a NULL + * return-type vector, and must not register an entry whose formatted name + * came back NULL (a NULL QN would reach strcmp in finalize and in every later + * lookup). */ +TEST(rubylsp_stdlib_seed_survives_alloc_failure) { + CBMArena reg_arena; + cbm_arena_init(®_arena); + CBMTypeRegistry reg; + cbm_registry_init(®, ®_arena); + CBMArena dead; + memset(&dead, 0, sizeof(dead)); + cbm_ruby_stdlib_register(®, &dead); + ASSERT(reg.type_count > 0); + ASSERT(reg.func_count > 0); /* Kernel built-ins carry literal names */ + for (int i = 0; i < reg.func_count; i++) { + ASSERT(reg.funcs[i].qualified_name != NULL); + ASSERT(reg.funcs[i].short_name != NULL); + } + cbm_registry_finalize(®); + ASSERT(cbm_registry_lookup_func(®, "puts") != NULL); + cbm_arena_destroy(®_arena); + PASS(); +} + /* ── suite ──────────────────────────────────────────────────────── */ void suite_ruby_lsp(void) { @@ -1536,4 +1562,5 @@ void suite_ruby_lsp(void) { RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); RUN_TEST(rubylsp_const_path_is_linear); + RUN_TEST(rubylsp_stdlib_seed_survives_alloc_failure); } From aa02511cc7bdd00e17b4ebd9d38add3846765485 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:25:07 -0800 Subject: [PATCH 48/61] test(pipeline): expect Layout.__init__ for the #1548 constructor call pipeline_python_init_function_shares_submodule_qn pinned the old Class target for Layout("demo"). Layout defines __init__, so the constructor call now lands on that method. The other tests that pin the contract were moved in the previous commit; this one reached main afterwards. Refs #1642 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- tests/test_pipeline.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9157c4bcd..a4d5612e8 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -21140,8 +21140,9 @@ static int pyn_run_init_collision(int fillers) { const char *project = cbm_pipeline_project_name(p); imports[run] = pyn_edge(s, project, "tests.test_app.py.__file__", "src.rowpkg.layout.Layout", "IMPORTS"); + /* Layout defines __init__, so Layout("demo") targets it (#1642). */ calls[run] = pyn_edge(s, project, "tests.test_app.test_build", - "src.rowpkg.layout.Layout", "CALLS"); + "src.rowpkg.layout.Layout.__init__", "CALLS"); method[run] = pyn_edge(s, project, "tests.test_app.test_build", "src.rowpkg.layout.Layout.create", "CALLS"); cbm_store_close(s); @@ -21160,7 +21161,7 @@ static int pyn_run_init_collision(int fillers) { for (int run = 0; run < 2; run++) { ASSERT_EQ(rc[run], 0); ASSERT_TRUE(imports[run]); /* the class stays importable */ - ASSERT_TRUE(calls[run]); /* Layout("demo") */ + ASSERT_TRUE(calls[run]); /* Layout("demo") -> Layout.__init__ */ ASSERT_TRUE(method[run]); /* .create() through the constructed type */ } PASS(); From 054719044a6e3f349f1c17f3d06d242b6b35bd14 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 21:27:32 +0100 Subject: [PATCH 49/61] fix(python): bind ClassName() only to the class own __init__ A construction call now resolves to __init__ only when the called class declares that __init__ itself. An inherited __init__ is no longer followed: class B(A) without its own __init__ keeps the B() call on the Class B, as it was before this branch. Following the bases bound every subclass construction to the base constructor. On a django index (fb113765) that made Model.__init__ a 583-caller hub (every model instantiation), bound IntegerField() to Field.__init__ and HttpResponseForbidden() to HttpResponse.__init__, and degraded 625 constructor call sites versus main (457 fell back to textual resolution, 168 lost their row; CALLS -161). With own __init__ only, 6016 sites move to the class own __init__, 5468 stay on the Class, none fall back to a textual strategy, and total CALLS is +4 versus main. Tests: the same-file and cross-file inherited cases now pin the Class target, and a new same-file subclass test at the LSP and the graph level covers B(A) without __init__ (stays on B, never A.__init__) and C(A) with its own __init__ (binds to C.__init__, never the class C or A.__init__). The strategy inventory comment in the repro suite is updated to match. Refs #1642 Signed-off-by: Martin Vogel --- internal/cbm/lsp/py_lsp.c | 12 +++--- tests/repro/repro_lsp_go_py.c | 10 +++-- tests/test_lsp_resolution_probe.c | 44 +++++++++++++++++++++ tests/test_py_lsp.c | 66 ++++++++++++++++++++++--------- 4 files changed, 105 insertions(+), 27 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index be7752a39..b7309bc33 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -3249,17 +3249,19 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { py_emit_resolved_call_reason(ctx, qn, "lsp_import_alias", 0.85f, fname, call_node); } else if (qn && cbm_registry_lookup_type(ctx->registry, qn)) { - /* ClassName() runs a user __init__ found on the class or, - * depth-first, a declared base (the same walk - * super().__init__ uses, not C3 MRO). Retarget only when - * this binding IS the class being called. An instance + /* ClassName() binds to the __init__ the class declares + * itself. An inherited __init__ is not followed: B(A) + * without its own __init__ keeps the call on B, so a base + * constructor does not collect every subclass + * construction as its caller. Retarget only when this + * binding IS the class being called. An instance * (`f = Foo(); f()`) keeps the class row: that call * dispatches to __call__, and a reason-join would invent * a Foo.__init__ edge. A builtins.* __init__ is not a * project constructor, so the class target stays. */ const CBMRegisteredFunc *fi = NULL; if (qn_short && strcmp(qn_short, fname) == 0) { - fi = py_lookup_attribute(ctx, qn, "__init__"); + fi = cbm_registry_lookup_method(ctx->registry, qn, "__init__"); } if (fi && fi->qualified_name && strncmp(fi->qualified_name, "builtins.", sizeof("builtins.") - 1) != 0) { diff --git a/tests/repro/repro_lsp_go_py.c b/tests/repro/repro_lsp_go_py.c index c0b219172..2b019dce9 100644 --- a/tests/repro/repro_lsp_go_py.c +++ b/tests/repro/repro_lsp_go_py.c @@ -64,10 +64,12 @@ * PYTHON STRATEGY INVENTORY — every literal "lsp_..." emitted by py_lsp.c * (grep '"lsp_' internal/cbm/lsp/py_lsp.c), with its keying site: * lsp_direct (py_lsp.c:1631) module-local f() - * lsp_constructor ClassName() with no user __init__ → the Class - * lsp_constructor_init ClassName() with a user __init__ (on the class - * or a declared base) → that __init__. The textual - * callee leaf stays the class name; reason joins it. + * lsp_constructor ClassName() where the class declares no __init__ + * of its own (an inherited one is not followed) + * → the Class + * lsp_constructor_init ClassName() where the class declares its own + * __init__ → that __init__. The textual callee + * leaf stays the class name; reason joins it. * lsp_method (py_lsp.c:1731) obj.method() on a NAMED-typed * receiver (covers self.other()) * lsp_super (py_lsp.c:1693) super().method() resolved on a diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index fd5d7c3d3..6f18c215e 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1853,6 +1853,49 @@ TEST(lrp_python_usage_instantiation) { PASS(); } +/* Python same-file subclasses in the graph: ClassName() binds only to an + * __init__ the class declares itself. B(A) declares none, so make_b keeps + * its CALLS edge on the class B and the inherited A.__init__ gets none. + * C(A) declares its own __init__, so make_c calls C.__init__, not C and not + * the inherited A.__init__. */ +TEST(lrp_python_subclass_constructor_own_init_only) { + static const LRP_File f[] = {{"main.py", "class A:\n" + " def __init__(self):\n" + " self.ready = True\n" + "\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "\n" + "class C(A):\n" + " def __init__(self):\n" + " self.ready = False\n" + "\n" + "def make_b():\n" + " return B()\n" + "\n" + "def make_c():\n" + " return C()\n"}}; + LRP_Proj lp; + cbm_store_t *store = lrp_index(&lp, f, 1); + const char *p = store ? lp.project : NULL; + int b_class = lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_b", "main.B"); + int b_base_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_b", "main.A.__init__"); + int c_own_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.C.__init__"); + int c_class = lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.C"); + int c_base_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.A.__init__"); + lrp_cleanup(&lp, store); + ASSERT_EQ(b_class, 1); + ASSERT_EQ(b_base_init, 0); + ASSERT_EQ(c_own_init, 1); + ASSERT_EQ(c_class, 0); + ASSERT_EQ(c_base_init, 0); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════════ * SUITE registration * ══════════════════════════════════════════════════════════════════════ */ @@ -1970,4 +2013,5 @@ SUITE(lsp_resolution_probe) { RUN_TEST(lrp_ts_usage_import_type_with_decoy); RUN_TEST(lrp_tsx_usage_import_type_with_decoy); RUN_TEST(lrp_python_usage_instantiation); + RUN_TEST(lrp_python_subclass_constructor_own_init_only); } diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 4f92318c0..1aa5c806d 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -403,26 +403,55 @@ TEST(pylsp_constructor_call_targets_init) { PASS(); } -/* class B(A) with only A.__init__ resolves construction of B to A.__init__. */ -TEST(pylsp_constructor_inherited_init) { +/* Same-file subclasses: only an __init__ the class declares itself is a + * construction target. B(A) inherits A.__init__ but declares none, so B() + * stays on the class B (never A.__init__). C(A) declares its own __init__, + * so C() resolves to C.__init__ (never the inherited A.__init__). */ +static int count_build_rows(const CBMFileResult *r, const char *caller_leaf, + const char *callee_qn, const char *strategy) { + int n = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !rc->strategy) + continue; + const char *dot = strrchr(rc->caller_qn, '.'); + const char *leaf = dot ? dot + 1 : rc->caller_qn; + if (strcmp(leaf, caller_leaf) == 0 && strcmp(rc->callee_qn, callee_qn) == 0 && + strcmp(rc->strategy, strategy) == 0) + n++; + } + return n; +} + +TEST(pylsp_constructor_same_file_subclass_own_init_only) { CBMFileResult *r = extract_py("class A:\n" " def __init__(self):\n" " self.ready = True\n" "class B(A):\n" " def go(self):\n" " return 1\n" - "def build():\n" - " return B()\n"); + "class C(A):\n" + " def __init__(self):\n" + " self.ready = False\n" + "def build_b():\n" + " return B()\n" + "def build_c():\n" + " return C()\n"); ASSERT_NOT_NULL(r); - int idx = require_resolved(r, "build", "A.__init__"); + /* B() keeps the class target; the inherited A.__init__ gets no row. */ + ASSERT_EQ(count_build_rows(r, "build_b", "test.main.B", "lsp_constructor"), 1); + ASSERT_EQ(find_resolved(r, "build_b", "__init__"), -1); + /* C() binds to the __init__ C declares itself. */ + int idx = require_resolved(r, "build_c", "test.main.C.__init__"); ASSERT_GTE(idx, 0); if (idx >= 0) { const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); - ASSERT(strstr(rc->callee_qn, "B.__init__") == NULL); - ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + ASSERT_STR_EQ(rc->reason, "C"); } - ASSERT_EQ(find_resolved(r, "build", "B.__init__"), -1); + ASSERT_EQ(find_resolved(r, "build_c", "test.main.A.__init__"), -1); + ASSERT_EQ(count_build_rows(r, "build_c", "test.main.C", "lsp_constructor"), 0); cbm_free_result(r); PASS(); } @@ -1126,10 +1155,11 @@ TEST(pylsp_crossfile_constructor_targets_init) { PASS(); } -/* class B(A) where A's __init__ lives in another module. The cross registry - * records the real base QN; per-file registration would have stored - * test.main.A and missed the base. */ -TEST(pylsp_crossfile_inherited_init) { +/* class B(A) where A's __init__ lives in another module and B declares no + * __init__ of its own. Construction binds only to an __init__ the class + * declares itself, so B() stays on the class B and the inherited + * base_mod.A.__init__ gets no row. */ +TEST(pylsp_crossfile_inherited_init_stays_on_class) { const char *source = "from base_mod import A\n" "class B(A):\n" " def go(self):\n" @@ -1168,12 +1198,12 @@ TEST(pylsp_crossfile_inherited_init) { cbm_run_py_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, imp_names, imp_qns, 1, NULL, &out, NULL); - int idx = find_resolved_arr(&out, "build", "base_mod.A.__init__"); + ASSERT_EQ(find_resolved_arr(&out, "build", "__init__"), -1); + int idx = find_resolved_arr(&out, "build", "test.main.B"); ASSERT_GTE(idx, 0); if (idx >= 0) { - ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor_init"); - ASSERT_STR_EQ(out.items[idx].reason, "B"); - ASSERT(strstr(out.items[idx].callee_qn, "B.__init__") == NULL); + ASSERT_STR_EQ(out.items[idx].callee_qn, "test.main.B"); + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor"); } cbm_arena_destroy(&arena); PASS(); @@ -5266,7 +5296,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_method_via_self); RUN_TEST(pylsp_constructor_call_returns_instance); RUN_TEST(pylsp_constructor_call_targets_init); - RUN_TEST(pylsp_constructor_inherited_init); + RUN_TEST(pylsp_constructor_same_file_subclass_own_init_only); RUN_TEST(pylsp_constructor_without_init_stays_on_class); RUN_TEST(pylsp_callable_instance_call_skips_init); RUN_TEST(pylsp_constructor_skips_builtin_init); @@ -5289,7 +5319,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); RUN_TEST(pylsp_crossfile_constructor_targets_init); - RUN_TEST(pylsp_crossfile_inherited_init); + RUN_TEST(pylsp_crossfile_inherited_init_stays_on_class); RUN_TEST(pylsp_batch_two_files); RUN_TEST(pylsp_from_import_alias_equal_module_leaf_targets_imported_member); RUN_TEST(pylsp_project_prefixed_direct_alias_same_tail_is_not_from_import_reference); From bf30a1aaf01d01f750fdfa695baec34ed977dd53 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 1 Nov 2026 23:48:31 +0100 Subject: [PATCH 50/61] fix(daemon): replay the session within the startup budget The recovery path replays the session context and UI settings on the replacement daemon. It waited on the bootstrap connect budget, which only bounds a probe (1 s in production), while main.c gives the first set_context and set_ui_config the startup budget since eb294298e. A replacement daemon whose UI save runs longer than a probe would end an otherwise good recovery. frontend_configure_client now waits on startup_timeout_ms. A test seam records the budget the replay used, and the recovery fixture gives the bootstrap distinct connect and startup budgets and exits 75 when the replay waited on the wrong one. With the old budget exactly the three recovery tests fail with exit 75; with the fix daemon_frontend is 17/17. Signed-off-by: Martin Vogel --- src/daemon/frontend.c | 14 +++++++++++++- src/daemon/frontend.h | 4 ++++ tests/test_daemon_frontend.c | 15 ++++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/daemon/frontend.c b/src/daemon/frontend.c index 123ec00e4..e76c920b6 100644 --- a/src/daemon/frontend.c +++ b/src/daemon/frontend.c @@ -120,6 +120,7 @@ static atomic_uint_fast64_t frontend_test_monitor_observation_count = ATOMIC_VAR static atomic_uint_fast64_t frontend_test_worker_observation_count = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t frontend_test_worker_idle_count = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t frontend_test_routed_cancellation_count = ATOMIC_VAR_INIT(0); +static atomic_uint_fast32_t frontend_test_replay_budget_ms = ATOMIC_VAR_INIT(0); void cbm_daemon_frontend_test_observer_reset(bool hold_monitor) { atomic_store_explicit(&frontend_test_monitor_observation_count, 0, memory_order_release); @@ -152,6 +153,10 @@ uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void) { uint64_t cbm_daemon_frontend_test_routed_cancellations(void) { return atomic_load_explicit(&frontend_test_routed_cancellation_count, memory_order_acquire); } + +uint32_t cbm_daemon_frontend_test_replay_timeout_ms(void) { + return (uint32_t)atomic_load_explicit(&frontend_test_replay_budget_ms, memory_order_acquire); +} #endif static cbm_version_cohort_maintenance_presence_t frontend_observe_maintenance( @@ -528,7 +533,14 @@ static cbm_daemon_runtime_cancel_result_t frontend_cancel_active(frontend_state_ static bool frontend_configure_client(cbm_daemon_runtime_client_t *client, const cbm_daemon_frontend_session_config_t *session) { - uint32_t timeout_ms = session->bootstrap.connect_timeout_ms; + /* The replay restores what startup set, so it waits within the startup + * budget like the first set_context and set_ui_config in main.c. The + * connect budget only bounds a probe (1 s in production): a replacement + * daemon whose UI save runs longer would end an otherwise good recovery. */ + uint32_t timeout_ms = session->bootstrap.startup_timeout_ms; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + atomic_store_explicit(&frontend_test_replay_budget_ms, timeout_ms, memory_order_release); +#endif if (cbm_daemon_application_client_set_context( client, session->session_root, session->allowed_root, session->tool_profile, NULL, NULL, timeout_ms) != CBM_DAEMON_RUNTIME_APPLICATION_OK) { diff --git a/src/daemon/frontend.h b/src/daemon/frontend.h index 8f59ec28b..8ee33d276 100644 --- a/src/daemon/frontend.h +++ b/src/daemon/frontend.h @@ -79,6 +79,10 @@ uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void); * Tests snapshot this monotonic * count before sending a notification. */ uint64_t cbm_daemon_frontend_test_routed_cancellations(void); +/* Budget in ms that the most recent session replay gave its context and UI + * settings requests. Recovery tests use distinct connect and startup budgets + * and compare. */ +uint32_t cbm_daemon_frontend_test_replay_timeout_ms(void); #endif /* Takes ownership of client and borrows cohort_manager for the complete call. diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c index e4ed5e135..aa49bf4c7 100644 --- a/tests/test_daemon_frontend.c +++ b/tests/test_daemon_frontend.c @@ -250,6 +250,11 @@ enum { * no-progress EOF drain so shutdown deterministically overlaps recovery. */ FRONTEND_RECOVERY_EOF_RELEASE_MS = 16000, + /* Startup budget of the recovery bootstrap. It differs from the connect + * budget (FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS) so the recovery tests can + * tell which one the session replay waited on, and it is the larger one, + * as in production (1 s connect probe, 30 s startup). */ + FRONTEND_RECOVERY_TEST_STARTUP_TIMEOUT_MS = 45000, }; typedef enum { @@ -818,7 +823,7 @@ static int frontend_recovery_child_run(const char *parent, frontend_recovery_mod .identity = &fixture.identity, .executable_path = "unused", .connect_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, - .startup_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .startup_timeout_ms = FRONTEND_RECOVERY_TEST_STARTUP_TIMEOUT_MS, }, .session_root = parent, .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, @@ -851,6 +856,14 @@ static int frontend_recovery_child_run(const char *parent, frontend_recovery_mod bool input_closed = input && fclose(input) == 0; bool output_closed = output && fclose(output) == 0; bool fixture_closed = frontend_eof_fixture_finish(&fixture); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + /* The replay restores what startup set within the startup budget. On the + * connect budget (a probe bound) a replacement daemon that answers slower + * than a probe would end an otherwise good recovery. Every mode replays. */ + if (cbm_daemon_frontend_test_replay_timeout_ms() != FRONTEND_RECOVERY_TEST_STARTUP_TIMEOUT_MS) { + return 75; + } +#endif return replacement_ready && request_ready && result == 0 && response_exact && requests == expected_requests && writer_ok && input_closed && output_closed && fixture_closed From 14b8da00c3a7ee559c5b07ec4e8e220e171c79e7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 00:06:52 +0100 Subject: [PATCH 51/61] 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 207944a85..5ab4353bb 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/61] 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 5ab4353bb..a8db36828 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 6296d31fb..dad4f6ec4 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/61] 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 39d72848f..25a8f6aaf 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 a8db36828..792ec9feb 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/61] 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 25a8f6aaf..17b3701f9 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/61] 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 f2e862d0d..3657910e3 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/61] fix(config): drop the fuzz-link and Cypher hunks from the worktree change The review of 2026-10-01 asked to split these out of the ignore_worktrees change. Neither belongs to it, and both are already on main in another form, so this restores the three files to the branch merge-base: - Makefile.cbm: main links cbm-fuzz with -fsanitize-link-c++-runtime. - src/cypher/cypher.c and tests/test_cypher.c: main frees a partially parsed MATCH pattern since 70543ba82, with its own regression test cypher_parse_error_frees_partial_pattern. Signed-off-by: Martin Vogel --- Makefile.cbm | 4 ++-- src/cypher/cypher.c | 25 ++++++++----------------- tests/test_cypher.c | 19 ------------------- 3 files changed, 10 insertions(+), 38 deletions(-) diff --git a/Makefile.cbm b/Makefile.cbm index b2a655505..ab5c89db7 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1381,10 +1381,10 @@ fuzz: CC=$(FUZZ_CC) CXX=$(FUZZ_CXX) SANITIZE="$(FUZZ_SANITIZE)" $(BUILD_DIR)/cbm-fuzz: $(FUZZ_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) - $(CXX) $(CFLAGS_TEST) -x c -fsanitize=fuzzer -o $@ \ + $(CC) $(CFLAGS_TEST) -fsanitize=fuzzer -o $@ \ $(FUZZ_SRC) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ - -x none $(OBJS_VENDORED_TEST) \ + $(OBJS_VENDORED_TEST) \ $(LDFLAGS_TEST) # Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads. diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 42ecb93c5..7c7a2f987 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -1862,8 +1862,6 @@ static int parse_return(parser_t *p, cbm_return_clause_t **out) { return parse_return_or_with(p, out, false); } -static void free_pattern(cbm_pattern_t *pat); - /* Parse a single MATCH pattern into pat */ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { memset(pat, 0, sizeof(*pat)); @@ -1872,38 +1870,31 @@ static int parse_match_pattern(parser_t *p, cbm_pattern_t *pat) { pat->nodes = malloc(node_cap * sizeof(cbm_node_pattern_t)); pat->rels = calloc(rel_cap, sizeof(cbm_rel_pattern_t)); - /* Count the slot before parsing so even partially filled nodes and - * relationships are released when a malformed pattern is rejected. */ - pat->node_count = SKIP_ONE; if (parse_node(p, &pat->nodes[0]) < 0) { - goto fail; + return CBM_NOT_FOUND; } + pat->node_count = SKIP_ONE; while (check(p, TOK_DASH) || check(p, TOK_LT)) { if (pat->rel_count >= rel_cap) { rel_cap *= PAIR_LEN; pat->rels = safe_realloc(pat->rels, rel_cap * sizeof(cbm_rel_pattern_t)); } - pat->rel_count++; - if (parse_rel(p, &pat->rels[pat->rel_count - SKIP_ONE]) < 0) { - goto fail; + if (parse_rel(p, &pat->rels[pat->rel_count]) < 0) { + return CBM_NOT_FOUND; } + pat->rel_count++; if (pat->node_count >= node_cap) { node_cap *= PAIR_LEN; pat->nodes = safe_realloc(pat->nodes, node_cap * sizeof(cbm_node_pattern_t)); } - pat->node_count++; - if (parse_node(p, &pat->nodes[pat->node_count - SKIP_ONE]) < 0) { - goto fail; + if (parse_node(p, &pat->nodes[pat->node_count]) < 0) { + return CBM_NOT_FOUND; } + pat->node_count++; } return 0; - -fail: - free_pattern(pat); - memset(pat, 0, sizeof(*pat)); - return CBM_NOT_FOUND; } /* Parse UNWIND [...] AS var clause into query */ diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 5d198a0ef..7c251bd9b 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -274,24 +274,6 @@ TEST(cypher_parse_accepts_single_with_clause) { PASS(); } -TEST(cypher_parse_rejects_partial_patterns) { - const char *queries[] = { - "MATCH p = (a:Class)-[:INHERITS*1..3]->(b:Class) RETURN a.name, length(p)", - "MATCH (a)-[:CALLS]->(b) MATCH (c)-[:CALLS*]-> RETURN a.name", - "MATCH (a)-[:CALLS|]->(b)", - }; - for (size_t i = 0; i < sizeof(queries) / sizeof(queries[0]); i++) { - cbm_query_t *q = NULL; - char *err = NULL; - int rc = cbm_cypher_parse(queries[i], &q, &err); - ASSERT_NEQ(rc, 0); - ASSERT_NULL(q); - ASSERT_NOT_NULL(err); - free(err); - } - PASS(); -} - TEST(cypher_parse_relationship_outbound) { cbm_query_t *q = NULL; char *err = NULL; @@ -4860,7 +4842,6 @@ SUITE(cypher) { RUN_TEST(cypher_parse_rejects_trailing_tokens); RUN_TEST(cypher_parse_rejects_second_with_clause); RUN_TEST(cypher_parse_accepts_single_with_clause); - RUN_TEST(cypher_parse_rejects_partial_patterns); RUN_TEST(cypher_parse_relationship_outbound); RUN_TEST(cypher_parse_relationship_inbound); RUN_TEST(cypher_parse_relationship_any); From 77310d5f6f57a63a4bd0d65f6fc4035e41bc7cf3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:04:15 +0100 Subject: [PATCH 57/61] fix(cli): keep the hook default path untouched while ignore_worktrees is off The lifecycle hook now reads ignore_worktrees first and does nothing else for it while the key is off; the project lookup is then the one the hook did before the key existed. - cbm_mcp_ignore_worktrees_runtime_enabled() replaces cbm_mcp_server_attach_runtime_config(). It never attaches a store to the server, which may be the daemon session server. A server with a store answers from it. Otherwise the runtime store is opened only to read this key, and only when it already exists, so a hook event never creates _config.db. - cwd canonicalization, the linked-worktree probe and the parent-graph check moved into ha_resolve_project_ignoring_worktrees(), which runs only when the key is on. Test cli_hook_ignore_worktrees_default_off_does_no_worktree_work was red on the previous head (a default-off event created _config.db). It also pins, through a test-API counter, that neither an absent store nor a store without the key reaches the worktree gate, and that the key on reaches it once. mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone replaces the test of the removed helper. Signed-off-by: Martin Vogel --- src/cli/cli.h | 3 ++ src/cli/hook_augment.c | 93 ++++++++++++++++++++++++++---------------- src/mcp/mcp.c | 38 ++++++++++++----- src/mcp/mcp.h | 6 --- src/mcp/mcp_internal.h | 5 +++ tests/test_cli.c | 92 +++++++++++++++++++++++++++++++++++++++++ tests/test_mcp.c | 55 ++++++++++++++++--------- 7 files changed, 220 insertions(+), 72 deletions(-) diff --git a/src/cli/cli.h b/src/cli/cli.h index 4ac403387..6e0f85ff3 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -264,6 +264,9 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, bool worktree_ignored); +/* Counts lifecycle calls that reached the ignore_worktrees resolution (cwd + * canonicalization and the linked-worktree probe). NULL stops counting. */ +void cbm_hook_set_worktree_gate_counter_for_testing(int *counter); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows); void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter); diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 384ba8467..61bc0100b 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -17,6 +17,7 @@ */ #include "cli/cli.h" +#include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/mem.h" @@ -1002,6 +1003,10 @@ static char *ha_resolve_indexed_project_with_root(cbm_mcp_server_t *srv, const c return ha_registry_project_for_path(srv, cwd, root_out, root_out_size); } +static char *ha_resolve_indexed_project(cbm_mcp_server_t *srv, const char *cwd) { + return ha_resolve_indexed_project_with_root(srv, cwd, NULL, 0U); +} + static const char *ha_hook_event_name(yyjson_val *root) { const char *event = ha_obj_str(root, "hook_event_name"); return event ? event : ha_obj_str(root, "hookEventName"); @@ -1459,6 +1464,41 @@ static bool ha_invocation_supported(ha_lifecycle_dialect_t dialect, const char * return !forced_event || ha_dialect_event_supported(dialect, forced_event); } +#ifdef CBM_CLI_ENABLE_TEST_API +static CBM_TLS int *g_ha_worktree_gate_counter = NULL; +#endif + +/* Project resolution once ignore_worktrees is on; the caller runs this only + * after reading the key, so the default path does none of it. A symlink alias + * has no .git of its own, and walking its lexical parents leaves the linked + * worktree. index_repository canonicalizes before the same gate, so the hook + * must too or it tells the agent to index a path the setting will refuse. The + * setting refuses a linked worktree even when it sits inside an indexed + * checkout or inside another linked worktree, so a match is kept only when its + * root belongs to this cwd's own linked worktree, not a parent graph. */ +static char *ha_resolve_project_ignoring_worktrees(cbm_mcp_server_t *srv, const char *cwd, + bool *drop_ancestor, bool *worktree_ignored) { +#ifdef CBM_CLI_ENABLE_TEST_API + if (g_ha_worktree_gate_counter) { + (*g_ha_worktree_gate_counter)++; + } +#endif + char canonical_cwd[4096]; + if (ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) { + cwd = canonical_cwd; + } + char project_root[4096]; + project_root[0] = '\0'; + char *project = + ha_resolve_indexed_project_with_root(srv, cwd, project_root, sizeof(project_root)); + bool linked = cbm_git_is_linked_worktree(cwd); + /* Drop a parent graph without a second free(): this file's raw free count + * is ratcheted, and the caller's single free(project) still releases it. */ + *drop_ancestor = linked && project && !cbm_git_same_linked_worktree(cwd, project_root); + *worktree_ignored = linked && (!project || *drop_ancestor); + return project; +} + static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root, const char *forced_event, ha_lifecycle_dialect_t dialect) { if (!root || !yyjson_is_obj(root)) { @@ -1471,47 +1511,24 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root char cwd_buffer[4096]; cbm_mcp_server_t *owned_server = NULL; - cbm_config_t *owned_config = NULL; if (!srv) { owned_server = cbm_mcp_server_new(NULL); srv = owned_server; } - /* cbm_mcp_server_new(NULL) does not attach the runtime store. The production - * hook uses that constructor, so ignore_worktrees would stay at its default - * (off) and the "not indexed" note would still tell the agent to run - * index_repository in a linked worktree the setting refuses. Load the store - * only when the caller has not already set one, then drop it before return - * so a caller-owned server is not left pointing at a closed config. */ - if (srv) { - owned_config = cbm_mcp_server_attach_runtime_config(srv); - } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); - /* A symlink alias has no .git of its own, and walking its lexical parents - * leaves the linked worktree. index_repository canonicalizes before the - * same gate, so the hook must too or it tells the agent to index a path - * the setting will refuse. */ - char canonical_cwd[4096]; - if (cwd && ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) { - cwd = canonical_cwd; - } - char project_root[4096]; - project_root[0] = '\0'; - char *project = srv && cwd ? ha_resolve_indexed_project_with_root(srv, cwd, project_root, - sizeof(project_root)) - : NULL; - /* ignore_worktrees refuses a linked worktree, including one nested inside an - * indexed checkout or inside another linked worktree. Keep a match only when - * its root belongs to this cwd's own linked worktree, not a parent graph. */ - bool ignore_linked = - srv && cwd && cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(cwd); - /* Drop a parent graph without a second free(): this file's raw free count - * is ratcheted, and the single free(project) below still releases it. */ - bool drop_ancestor = - ignore_linked && project && !cbm_git_same_linked_worktree(cwd, project_root); - bool worktree_ignored = ignore_linked && (!project || drop_ancestor); - if (owned_config) { - cbm_mcp_server_set_config(srv, NULL); - cbm_config_close(owned_config); + /* Read ignore_worktrees first and do nothing else for it while it is off. + * cbm_mcp_server_new(NULL) has no store, and srv may be the daemon's + * session server, so the read leaves srv untouched (see + * cbm_mcp_ignore_worktrees_runtime_enabled). Off, this is the resolution + * the hook did before the key existed. */ + bool drop_ancestor = false; + bool worktree_ignored = false; + char *project = NULL; + if (srv && cwd && cbm_mcp_ignore_worktrees_runtime_enabled(srv)) { + project = + ha_resolve_project_ignoring_worktrees(srv, cwd, &drop_ancestor, &worktree_ignored); + } else { + project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; } cbm_mcp_server_free(owned_server); @@ -1670,6 +1687,10 @@ const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, return ha_no_project_index_guidance(event, worktree_ignored); } +void cbm_hook_set_worktree_gate_counter_for_testing(int *counter) { + g_ha_worktree_gate_counter = counter; +} + bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) { return ha_parse_bash_search_pattern(cmd, out, out_sz); } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index c45ec6de8..00479171b 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1849,17 +1849,6 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } -struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv) { - if (!srv || srv->config) { - return NULL; - } - cbm_config_t *config = cbm_config_open(cbm_resolve_cache_dir()); - if (config) { - cbm_mcp_server_set_config(srv, config); - } - return config; -} - #ifdef CBM_ENABLE_TEST_SEAMS void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv, cbm_mcp_auto_index_count_test_hook_fn hook, @@ -17928,6 +17917,33 @@ bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) { return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false); } +/* The same key for a caller whose server may have no store: the stand-alone + * lifecycle hook builds one with cbm_mcp_server_new(NULL), and the daemon lends + * its session server, which this must not modify. A server with a store answers + * from it. Otherwise the runtime store is opened only to read this key, and only + * when it exists: a missing store leaves every key at its default, so this read + * never creates one. */ +bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv) { + if (srv && srv->config) { + return cbm_mcp_ignore_worktrees_enabled(srv); + } + const char *cache_dir = cbm_resolve_cache_dir(); + if (!cache_dir) { + return false; + } + char store_path[CBM_SZ_4K]; + int written = snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache_dir); + cbm_path_info_t info; + if (written < 0 || (size_t)written >= sizeof(store_path) || + cbm_path_info_utf8(store_path, &info) != CBM_PATH_INFO_OK || !info.is_regular) { + return false; + } + cbm_config_t *config = cbm_config_open(cache_dir); + bool enabled = config && cbm_config_get_bool(config, CBM_CONFIG_IGNORE_WORKTREES, false); + cbm_config_close(config); + return enabled; +} + /* Register the session project with the background watcher for ongoing * change detection — unless auto_watch is disabled. */ static void register_watcher_if_enabled(cbm_mcp_server_t *srv) { diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index f2fe09b26..31ffd69b0 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -182,12 +182,6 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w); /* Set external config store reference (for auto_index setting). Not owned. */ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg); -/* Open the runtime config and attach it when srv has none. The returned store - * is owned by the caller, who must clear it with cbm_mcp_server_set_config - * before closing. Returns NULL when a config is already set or the store - * cannot be opened. */ -struct cbm_config *cbm_mcp_server_attach_runtime_config(cbm_mcp_server_t *srv); - /* Set an explicit session context for an embedded/daemon-backed server. * session_root is copied and its project name is derived using the same naming * rule as indexing. allowed_root is copied when non-NULL; an explicit NULL diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index 8e78f9f40..fc4f37cb4 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -68,6 +68,11 @@ bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, * automatic index of a linked worktree should be skipped. */ bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv); +/* The same key when srv may have no store (the lifecycle hook). Never attaches + * a store to srv and never creates the runtime store; reads it only when it + * already exists. */ +bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv); + /* detect_changes seed scoping (#1363): does `node`'s line range overlap any * recorded hunk for `file`? Exposed for direct unit testing of the overlap * logic, independent of the git/subprocess/index plumbing around it. */ diff --git a/tests/test_cli.c b/tests/test_cli.c index af140c53f..586b78884 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -10944,6 +10945,96 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); } + +/* With ignore_worktrees unset, the lifecycle hook must do what it did before + * the key existed. It may read the key, but it must not create a runtime + * config store just to find the key unset, and it must not canonicalize the + * cwd or probe for a linked worktree. Turning the key on is the positive + * control: the same call then reaches the worktree gate exactly once. */ +TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work) { + char tmpdir[256]; + char *raw = th_mktempdir("cbm_hook_wt_off"); + if (!raw) + FAIL("th_mktempdir returned NULL"); + snprintf(tmpdir, sizeof(tmpdir), "%s", raw); + char cache[512]; + char maindir[512]; + char wtdir[512]; + char store_path[600]; + snprintf(cache, sizeof(cache), "%s/cache", tmpdir); + snprintf(maindir, sizeof(maindir), "%s/checkout-main", tmpdir); + snprintf(wtdir, sizeof(wtdir), "%s/checkout-side", tmpdir); + snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache); + test_mkdirp(cache); + test_mkdirp(maindir); + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "cd \"%s\" && git init -q && git config user.email t@t && git config user.name t && " + ": > f.txt && git add f.txt && git commit -qm init && " + "git worktree add -q \"%s\" -b wtoff", + maindir, wtdir); + if (system(cmd) != 0) { + th_rmtree(tmpdir); + SKIP_PLATFORM("git worktree add unavailable"); + } + + char *saved_cache = save_test_env("CBM_CACHE_DIR"); + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char input[1024]; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir); + struct stat st; + int gate_calls = 0; + cbm_hook_set_worktree_gate_counter_for_testing(&gate_calls); + bool store_absent_before = stat(store_path, &st) != 0; + char *off_out = cbm_hook_augment_lifecycle_json(input); + bool off_note = off_out && strstr(off_out, "Run index_repository") && + !strstr(off_out, "ignore_worktrees is enabled"); + free(off_out); + bool store_absent_after = stat(store_path, &st) != 0; + int off_gate_calls = gate_calls; + + /* A store that leaves the key unset may be read for the key, nothing more. */ + cbm_config_t *cfg = cbm_config_open(cache); + bool other_key_set = cfg && cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "true") == 0; + cbm_config_close(cfg); + gate_calls = 0; + char *unset_out = other_key_set ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool unset_note = unset_out && strstr(unset_out, "Run index_repository") && + !strstr(unset_out, "ignore_worktrees is enabled"); + free(unset_out); + int unset_gate_calls = gate_calls; + + cfg = cbm_config_open(cache); + bool enabled = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(cfg); + gate_calls = 0; + char *on_out = enabled ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool on_note = on_out && strstr(on_out, "ignore_worktrees is enabled"); + free(on_out); + int on_gate_calls = gate_calls; + cbm_hook_set_worktree_gate_counter_for_testing(NULL); + + restore_test_env("CBM_CACHE_DIR", saved_cache); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1", + maindir, wtdir); + (void)system(cmd); + th_rmtree(tmpdir); + + ASSERT_TRUE(store_absent_before); + ASSERT_TRUE(off_note); + if (!store_absent_after) + FAIL("a default-off lifecycle event must not create the runtime config store"); + ASSERT_EQ(off_gate_calls, 0); + ASSERT_TRUE(other_key_set); + ASSERT_TRUE(unset_note); + ASSERT_EQ(unset_gate_calls, 0); + ASSERT_TRUE(enabled); + ASSERT_TRUE(on_note); + ASSERT_EQ(on_gate_calls, 1); + PASS(); +} #endif TEST(cli_hook_session_sanitizes_untrusted_project_metadata) { @@ -16975,6 +17066,7 @@ SUITE(cli) { #ifndef _WIN32 RUN_TEST(cli_hook_worktree_cwd_resolves_own_indexed_project); RUN_TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent); + RUN_TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work); #endif RUN_TEST(cli_hook_session_sanitizes_untrusted_project_metadata); RUN_TEST(cli_hook_ownership_requires_exact_command_identity); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 8eab275a2..257e799ad 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -10,6 +10,7 @@ #include "../src/foundation/log.h" #include "../src/foundation/platform.h" /* cbm_file_size */ #include "../src/foundation/mem.h" /* cbm_mem_set_budget_for_tests — over-budget seam */ +#include "../src/foundation/str_util.h" #include "../src/foundation/subprocess.h" #include "../src/foundation/workspace.h" #include "../src/mcp/compact_out.h" @@ -1202,37 +1203,53 @@ TEST(mcp_initialize_response) { PASS(); } -TEST(mcp_attach_runtime_config_respects_caller_ownership) { - char *cache = th_mktempdir("cbm_mcp_runtime_config"); - ASSERT_NOT_NULL(cache); +/* The lifecycle hook reads ignore_worktrees through this reader. It must never + * attach a store to the server (the daemon lends its session server) and never + * create the runtime store; a server with its own store answers from it. */ +TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone) { + char *raw = th_mktempdir("cbm_mcp_runtime_config"); + ASSERT_NOT_NULL(raw); + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + char own_dir[320]; + snprintf(own_dir, sizeof(own_dir), "%s/own", cache); + char store_path[400]; + snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache); const char *saved = getenv("CBM_CACHE_DIR"); char *saved_copy = saved ? strdup(saved) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - cbm_config_t *config = cbm_config_open(cache); cbm_mcp_server_t *server = cbm_mcp_server_new(NULL); - bool stored = config && cbm_config_set(config, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; - bool default_off = server && !cbm_mcp_ignore_worktrees_enabled(server); - cbm_config_t *attached = server ? cbm_mcp_server_attach_runtime_config(server) : NULL; - bool enabled = attached && cbm_mcp_ignore_worktrees_enabled(server); - bool not_replaced = server && cbm_mcp_server_attach_runtime_config(server) == NULL; + struct stat st; + bool absent_off = server && !cbm_mcp_ignore_worktrees_runtime_enabled(server); + bool still_absent = stat(store_path, &st) != 0; + + cbm_config_t *runtime = cbm_config_open(cache); + bool stored = runtime && cbm_config_set(runtime, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(runtime); + bool runtime_on = server && cbm_mcp_ignore_worktrees_runtime_enabled(server); + bool server_untouched = server && !cbm_mcp_ignore_worktrees_enabled(server); + + cbm_config_t *own = cbm_config_open(own_dir); + if (server && own) { + cbm_mcp_server_set_config(server, own); + } + bool own_store_wins = server && own && !cbm_mcp_ignore_worktrees_runtime_enabled(server); if (server) { - cbm_mcp_server_set_config(server, config); + cbm_mcp_server_set_config(server, NULL); } - cbm_config_close(attached); - bool caller_config_kept = server && cbm_mcp_server_attach_runtime_config(server) == NULL && - cbm_mcp_ignore_worktrees_enabled(server); cbm_mcp_server_free(server); - cbm_config_close(config); + cbm_config_close(own); restore_cache_dir(saved_copy); free(saved_copy); int removed = th_rmtree(cache); + ASSERT(absent_off); + ASSERT(still_absent); ASSERT(stored); - ASSERT(default_off); - ASSERT(enabled); - ASSERT(not_replaced); - ASSERT(caller_config_kept); + ASSERT(runtime_on); + ASSERT(server_untouched); + ASSERT(own_store_wins); ASSERT_EQ(removed, 0); PASS(); } @@ -21182,7 +21199,7 @@ SUITE(mcp) { /* MCP protocol helpers */ RUN_TEST(mcp_initialize_response); - RUN_TEST(mcp_attach_runtime_config_respects_caller_ownership); + RUN_TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone); RUN_TEST(mcp_tools_list); RUN_TEST(mcp_tools_help_list_matches_registry); RUN_TEST(mcp_tools_list_latest_metadata); From 04ed0240e9f62778b578444f67494421c41a064e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:04:21 +0100 Subject: [PATCH 58/61] docs(config): state how ignore_worktrees treats subdirectories The review of 2026-10-01 asked for this to be a stated decision rather than a side effect. Detection walks up from the given path to the nearest .git entry, so a subdirectory of a linked worktree is treated as that worktree: index_repository on it is refused, and a session root or hook working directory inside it is handled the same way. The walk stops at the first .git; outside any checkout it runs to the filesystem root one directory at a time. None of it runs while the key is off. Signed-off-by: Martin Vogel --- docs/CONFIGURATION.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 99a2a328a..181a8aad4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -154,6 +154,15 @@ The main checkout of the same repository is unaffected, as are ordinary clones and submodules. Detection is git plumbing only: a linked worktree's `.git` is a file pointing at a gitdir that contains a `commondir` entry. +A subdirectory counts as the worktree it sits in. Detection walks up from the +given path to the nearest `.git` entry, so `index_repository` on any +subdirectory of a linked worktree is refused the same way, and a session root or +hook working directory inside one is treated as that worktree. The walk stops at +the first `.git` it finds; a main checkout or a submodule ends it as "not a +worktree". A path outside any git checkout is checked one directory at a time up +to the filesystem root and is never treated as a worktree. None of this runs +while the key is off. + The default is `false`, so indexing behavior is unchanged unless you opt in. ## 3. UI Settings From 141b25af7b46ffff462615b8003d3f27ee263e45 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 02:16:53 +0100 Subject: [PATCH 59/61] fix(mcp): do not shadow cache_dir() in the ignore_worktrees reader cppcheck 2.20 (the CI version) reports shadowFunction: the local cache_dir in cbm_mcp_ignore_worktrees_runtime_enabled() shadows the static cache_dir() helper in mcp.c. Rename the local to store_dir; no behavior change. Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 4166b74e6..c82f90f11 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -21589,18 +21589,18 @@ bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv) { if (srv && srv->config) { return cbm_mcp_ignore_worktrees_enabled(srv); } - const char *cache_dir = cbm_resolve_cache_dir(); - if (!cache_dir) { + const char *store_dir = cbm_resolve_cache_dir(); + if (!store_dir) { return false; } char store_path[CBM_SZ_4K]; - int written = snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache_dir); + int written = snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, store_dir); cbm_path_info_t info; if (written < 0 || (size_t)written >= sizeof(store_path) || cbm_path_info_utf8(store_path, &info) != CBM_PATH_INFO_OK || !info.is_regular) { return false; } - cbm_config_t *config = cbm_config_open(cache_dir); + cbm_config_t *config = cbm_config_open(store_dir); bool enabled = config && cbm_config_get_bool(config, CBM_CONFIG_IGNORE_WORKTREES, false); cbm_config_close(config); return enabled; From a0d0521fcba680ca9f21ad3dc55425b25ec29f48 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 18:12:22 +0100 Subject: [PATCH 60/61] 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 41fcbc4fb..ed8462d5a 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 30662b221..43f1a21f0 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 abb32ba7c..d2add2adf 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 e8f0f4a2898b6d2b4468dd6cb984a39372e86ded Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 2 Nov 2026 18:23:18 +0100 Subject: [PATCH 61/61] fix(cli): accept the emptied hooks: {} mapping in the Hermes hook editor Removing the only Hermes pre_llm_call hook empties the hooks mapping, and the mapping editor writes an emptied section as hooks: {} - the same convention it uses for mcp_servers: {}. The hook sequence editor then refused that file for both upsert and removal, because it required every parent on the sequence path to be a block mapping header. On a config where our hook was the only entry under hooks, install -> uninstall -> install exited 1, and a repeated uninstall did too. An exact empty flow mapping on a parent of the sequence path is now read as that empty mapping, mirroring how the mapping-entry editor already treats section: {}. Removal is a byte-identical no-op, and upsert expands the header in place, keeping its comment, the line endings and every other byte, so a reinstall lands back on the first install byte for byte. The sequence key itself holding a mapping, any other flow value, and content nested under the inline mapping stay refused. Hooks written by the user and every foreign key are left untouched. Found while finishing #1862: its persisted hook opt-out runs this remover on every later install, so every Hermes user whose only hook was ours would have hit the refusal on each install after the first. Signed-off-by: Martin Vogel --- src/cli/config_yaml_edit.c | 87 +++++++++++++++++++-- tests/test_cli.c | 139 ++++++++++++++++++++++++++++++++++ tests/test_config_yaml_edit.c | 126 ++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 6 deletions(-) diff --git a/src/cli/config_yaml_edit.c b/src/cli/config_yaml_edit.c index a6f8ced10..09a079212 100644 --- a/src/cli/config_yaml_edit.c +++ b/src/cli/config_yaml_edit.c @@ -2589,6 +2589,10 @@ typedef struct { bool identity_found; size_t identity_start; size_t identity_end; + /* The missing rest of the path begins under a `parent: {}` header. */ + bool parent_inline_empty; + size_t parent_line; + size_t parent_colon; } yaml_mapping_sequence_target_t; static void yaml_sequence_key_vec_free(yaml_sequence_key_vec_t *keys) { @@ -2962,6 +2966,37 @@ static int yaml_sequence_parse_block(const yaml_doc_t *doc, size_t begin_line, s return 0; } +/* `section: {}` is how the mapping-entry editor writes a section it emptied + * (yaml_build_last_mapping_entry_removal), and its upsert accepts it back. On + * a PARENT of the sequence path it is that same empty mapping, so the rest of + * the path is missing beneath it: removal is a no-op and upsert expands the + * header in place. The Hermes remover leaves `hooks: {}` whenever our hook was + * the only entry. The sequence key itself holding `{}`, any other flow value, + * and content nested under the inline mapping stay refused. */ +static int yaml_sequence_target_empty_parent(const yaml_doc_t *doc, size_t key_line, size_t colon, + size_t child_end, size_t next_depth, + size_t sequence_path_len, + yaml_mapping_sequence_target_t *target) { + bool inline_empty = false; + if (next_depth >= sequence_path_len || + yaml_tail_is_explicit_empty_mapping(doc->data, colon, doc->lines[key_line].text_end, + &inline_empty) != 0 || + !inline_empty) { + return YAML_ERROR; + } + for (size_t i = key_line + YAML_UNIT; i < child_end; i++) { + if (!doc->lines[i].blank && !doc->lines[i].comment) { + return YAML_ERROR; + } + } + target->missing_index = next_depth; + target->insert_offset = yaml_sequence_line_offset(doc, child_end); + target->parent_inline_empty = true; + target->parent_line = key_line; + target->parent_colon = colon; + return 0; +} + static int yaml_sequence_analyze(const yaml_doc_t *doc, const char *const *sequence_path, const size_t *path_lengths, size_t sequence_path_len, const char *identity_key, const char *identity_value, @@ -2993,10 +3028,11 @@ static int yaml_sequence_analyze(const yaml_doc_t *doc, const char *const *seque return 0; } const yaml_line_t *header = &doc->lines[key_line]; - if (!yaml_tail_is_empty(doc->data, colon, header->text_end)) { - return YAML_ERROR; - } size_t child_end = yaml_sequence_nested_end(doc, key_line, parent_end); + if (!yaml_tail_is_empty(doc->data, colon, header->text_end)) { + return yaml_sequence_target_empty_parent(doc, key_line, colon, child_end, + depth + YAML_UNIT, sequence_path_len, target); + } if (depth + YAML_UNIT == sequence_path_len) { target->sequence_found = true; target->missing_index = sequence_path_len; @@ -3095,13 +3131,52 @@ static int yaml_sequence_validate_canonical(const char *canonical_item, size_t c return result; } +/* Copies the document up to insert_offset with the `parent: {}` header turned + * into a block header `parent:`, exactly as + * yaml_build_inline_empty_mapping_section_add does for a mapping section: a + * trailing comment and the line ending are kept. */ +static int yaml_sequence_append_expanded_parent(yaml_buf_t *out, const yaml_doc_t *doc, + const yaml_mapping_sequence_target_t *target) { + if (target->parent_line >= doc->line_count) { + return YAML_ERROR; + } + const yaml_line_t *header = &doc->lines[target->parent_line]; + size_t comment = 0U; + if (target->insert_offset < header->end || + yaml_find_comment(doc->data, target->parent_colon + YAML_UNIT, header->text_end, + &comment) != 0 || + yaml_buf_append(out, doc->data, target->parent_colon + YAML_UNIT) != 0) { + return YAML_ERROR; + } + size_t kept_tail = header->text_end; + if (comment < header->text_end) { + if (yaml_buf_append_char(out, ' ') != 0) { + return YAML_ERROR; + } + kept_tail = comment; + } + if (yaml_buf_append(out, doc->data + kept_tail, header->end - kept_tail) != 0 || + yaml_buf_append(out, doc->data + header->end, target->insert_offset - header->end) != 0 || + (out->len > 0U && out->data[out->len - YAML_UNIT] != '\n' && + yaml_buf_append(out, doc->eol, doc->eol_len) != 0)) { + return YAML_ERROR; + } + return 0; +} + static int yaml_sequence_build_insert(yaml_buf_t *out, const yaml_doc_t *doc, const yaml_mapping_sequence_target_t *target, const char *const *sequence_path, const size_t *path_lengths, size_t sequence_path_len, const yaml_buf_t *rendered_item) { - if (target->insert_offset > doc->len || - yaml_buf_append(out, doc->data, target->insert_offset) != 0 || - yaml_append_separator_if_needed(out, doc, target->insert_offset) != 0) { + if (target->insert_offset > doc->len) { + return YAML_ERROR; + } + if (target->parent_inline_empty) { + if (yaml_sequence_append_expanded_parent(out, doc, target) != 0) { + return YAML_ERROR; + } + } else if (yaml_buf_append(out, doc->data, target->insert_offset) != 0 || + yaml_append_separator_if_needed(out, doc, target->insert_offset) != 0) { return YAML_ERROR; } if (!target->sequence_found) { diff --git a/tests/test_cli.c b/tests/test_cli.c index f716418ad..c5417b4e2 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -9449,6 +9449,143 @@ TEST(cli_hermes_stable_shell_context_contract) { PASS(); } +/* A fake HOME holding only ~/.hermes/config.yaml, so install and uninstall + * reach exactly the Hermes editors. */ +typedef struct { + char tmpdir[256]; + char config_path[640]; + char binary_path[640]; + char *saved_home; + char *saved_path; + char *saved_hermes; +} cli_hermes_cycle_t; + +static bool cli_hermes_cycle_open(cli_hermes_cycle_t *cycle, const char *initial_config) { + memset(cycle, 0, sizeof(*cycle)); + snprintf(cycle->tmpdir, sizeof(cycle->tmpdir), "/tmp/cli-hermes-cycle-XXXXXX"); + if (!cbm_mkdtemp(cycle->tmpdir)) { + cycle->tmpdir[0] = '\0'; + return false; + } + cycle->saved_home = save_test_env("HOME"); + cycle->saved_path = save_test_env("PATH"); + cycle->saved_hermes = save_test_env("HERMES_HOME"); + cbm_setenv("HOME", cycle->tmpdir, 1); + cbm_setenv("PATH", cycle->tmpdir, 1); + cbm_unsetenv("HERMES_HOME"); + + char hermes_dir[512]; + snprintf(hermes_dir, sizeof(hermes_dir), "%s/.hermes", cycle->tmpdir); + snprintf(cycle->config_path, sizeof(cycle->config_path), "%s/config.yaml", hermes_dir); +#ifdef _WIN32 + snprintf(cycle->binary_path, sizeof(cycle->binary_path), + "%s/.local/bin/codebase-memory-mcp.exe", cycle->tmpdir); +#else + snprintf(cycle->binary_path, sizeof(cycle->binary_path), "%s/.local/bin/codebase-memory-mcp", + cycle->tmpdir); +#endif + test_mkdirp(hermes_dir); + return write_test_file(cycle->config_path, initial_config) == 0; +} + +static void cli_hermes_cycle_close(cli_hermes_cycle_t *cycle) { + if (!cycle->tmpdir[0]) { + return; + } + restore_test_env("HOME", cycle->saved_home); + restore_test_env("PATH", cycle->saved_path); + restore_test_env("HERMES_HOME", cycle->saved_hermes); + test_rmdir_r(cycle->tmpdir); + cycle->tmpdir[0] = '\0'; +} + +/* Found while finishing #1862: when our hook was the only entry under + * `hooks:`, uninstall empties that mapping and the mapping editor writes it as + * `hooks: {}` (the same convention as `mcp_servers: {}`). The hook sequence + * editor then refused the file, so install -> uninstall -> install exited 1, + * and so did a repeated uninstall. The explicit empty mapping must be accepted: + * a repeated uninstall changes nothing and the reinstall restores the first + * install byte for byte, with exactly one owned hook. */ +TEST(cli_hermes_hook_reinstalls_after_uninstall_empties_hooks) { + cli_hermes_cycle_t cycle; + if (!cli_hermes_cycle_open(&cycle, "theme: solarized\n")) { + cli_hermes_cycle_close(&cycle); + FAIL("Hermes fake-HOME setup failed"); + } + char *argv[] = {"uninstall", "--yes"}; + int install_rc = cbm_install_agent_configs(cycle.tmpdir, cycle.binary_path, false, false); + char *installed = read_test_file_alloc(cycle.config_path); + int uninstall_rc = cli_test_cmd_uninstall(2, argv); + char *emptied = read_test_file_alloc(cycle.config_path); + int repeat_rc = cli_test_cmd_uninstall(2, argv); + char *repeated = read_test_file_alloc(cycle.config_path); + int reinstall_rc = cbm_install_agent_configs(cycle.tmpdir, cycle.binary_path, false, false); + char *reinstalled = read_test_file_alloc(cycle.config_path); + cli_hermes_cycle_close(&cycle); + + bool installed_once = install_rc == 0 && installed && + test_count_substring(installed, "pre_llm_call:") == 1U && + test_count_substring(installed, "hook-augment") == 1U; + bool emptied_clean = uninstall_rc == 0 && emptied && strstr(emptied, "theme: solarized\n") && + test_count_substring(emptied, "hooks: {}\n") == 1U && + !strstr(emptied, "pre_llm_call") && !strstr(emptied, "hook-augment"); + bool repeat_unchanged = repeat_rc == 0 && emptied && repeated && strcmp(repeated, emptied) == 0; + bool reinstalled_once = + reinstall_rc == 0 && installed && reinstalled && strcmp(reinstalled, installed) == 0; + free(installed); + free(emptied); + free(repeated); + free(reinstalled); + if (!installed_once) + FAIL("Hermes install must write exactly one owned pre_llm_call hook"); + if (!emptied_clean) + FAIL("uninstall must remove the owned hook and leave only the empty hooks: {} mapping"); + if (!repeat_unchanged) + FAIL("a repeated uninstall must accept hooks: {} and leave the file unchanged"); + if (!reinstalled_once) + FAIL("reinstall must accept hooks: {} and restore exactly one owned hook"); + PASS(); +} + +/* The same cycle with a user's own hook in the shared pre_llm_call sequence: + * the foreign hook survives untouched and uninstall restores the original + * file byte for byte. */ +TEST(cli_hermes_hook_cycle_keeps_foreign_pre_llm_hook) { + static const char original[] = "theme: solarized\n" + "mcp_servers: {}\n" + "hooks:\n" + " pre_llm_call:\n" + " - command: \"/usr/bin/user-hermes-hook\"\n"; + cli_hermes_cycle_t cycle; + if (!cli_hermes_cycle_open(&cycle, original)) { + cli_hermes_cycle_close(&cycle); + FAIL("Hermes fake-HOME setup failed"); + } + char *argv[] = {"uninstall", "--yes"}; + int install_rc = cbm_install_agent_configs(cycle.tmpdir, cycle.binary_path, false, false); + char *installed = read_test_file_alloc(cycle.config_path); + int uninstall_rc = cli_test_cmd_uninstall(2, argv); + char *restored = read_test_file_alloc(cycle.config_path); + int reinstall_rc = cbm_install_agent_configs(cycle.tmpdir, cycle.binary_path, false, false); + char *reinstalled = read_test_file_alloc(cycle.config_path); + cli_hermes_cycle_close(&cycle); + + bool merged = install_rc == 0 && installed && + test_count_substring(installed, "/usr/bin/user-hermes-hook") == 1U && + test_count_substring(installed, "pre_llm_call:") == 1U && + test_count_substring(installed, "hook-augment") == 1U; + bool restored_exactly = uninstall_rc == 0 && restored && strcmp(restored, original) == 0; + bool reinstalled_once = + reinstall_rc == 0 && installed && reinstalled && strcmp(reinstalled, installed) == 0; + free(installed); + free(restored); + free(reinstalled); + if (!merged || !restored_exactly || !reinstalled_once) + FAIL("a user's own pre_llm_call hook must survive install, uninstall and reinstall, and " + "uninstall must restore the original file byte for byte"); + PASS(); +} + #ifndef _WIN32 TEST(cli_detected_agent_summary_includes_registry_clients) { char tmpdir[256]; @@ -20351,6 +20488,8 @@ SUITE(cli) { RUN_TEST(cli_junie_current_durable_context_contract); RUN_TEST(cli_rovo_installs_documented_global_memory); RUN_TEST(cli_hermes_stable_shell_context_contract); + RUN_TEST(cli_hermes_hook_reinstalls_after_uninstall_empties_hooks); + RUN_TEST(cli_hermes_hook_cycle_keeps_foreign_pre_llm_hook); #ifndef _WIN32 RUN_TEST(cli_detected_agent_summary_includes_registry_clients); #endif diff --git a/tests/test_config_yaml_edit.c b/tests/test_config_yaml_edit.c index 9efaa2e38..95bc89404 100644 --- a/tests/test_config_yaml_edit.c +++ b/tests/test_config_yaml_edit.c @@ -1998,6 +1998,129 @@ TEST(config_yaml_edit_nested_sequence_removes_only_exact_canonical_item) { PASS(); } +/* Found while finishing #1862: cbm_yaml_remove_mapping_entry writes a section + * it empties as `section: {}`, and the Hermes remover empties `hooks:` whenever + * our hook was its only entry. The sequence editor refused that explicit empty + * parent mapping, so the next install (and a repeated removal) failed. The + * primitives' own round trip must land back on the first insert. */ +TEST(config_yaml_edit_nested_sequence_reinserts_under_emptied_parent) { + const char *initial = "theme: x\n"; + yaml_fixture_t fixture; + ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0); + ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id", + yaml_hook_identity, yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + char *first = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(first); + ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id", + yaml_hook_identity, yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + ASSERT_EQ(cbm_yaml_remove_mapping_entry(fixture.path, "hooks", "pre_llm_call"), 0); + char *emptied = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(emptied); + ASSERT_STR_EQ(emptied, "theme: x\nhooks: {}\n"); + free(emptied); + ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id", + yaml_hook_identity, yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id", + yaml_hook_identity, yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + char *again = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(again); + ASSERT_STR_EQ(again, first); + free(again); + free(first); + th_cleanup(fixture.dir); + PASS(); +} + +/* An exact empty flow mapping on a PARENT of the sequence path is an empty + * mapping, the same `section: {}` the mapping-entry editor writes and accepts. + * Removal is a byte-identical no-op; upsert expands the header in place, + * keeping its comment, the line endings and every other byte. */ +TEST(config_yaml_edit_nested_sequence_expands_explicit_empty_parent_mapping) { + static const char item_lf[] = " pre_llm_call:\n" + " - id: \"codebase-memory-mcp\"\n" + " type: \"command\"\n" + " command: \"/opt/Codebase Memory/bin/cbm\"\n"; + static const char item_crlf[] = " pre_llm_call:\r\n" + " - id: \"codebase-memory-mcp\"\r\n" + " type: \"command\"\r\n" + " command: \"/opt/Codebase Memory/bin/cbm\"\r\n"; + struct { + const char *initial; + const char *header; + const char *item; + const char *tail; + } cases[] = { + {"theme: x\nhooks: {} # keep me\nafter: 1\n", "theme: x\nhooks: # keep me\n", item_lf, + "after: 1\n"}, + {"theme: x\r\nhooks: {}\r\nafter: 1\r\n", "theme: x\r\nhooks:\r\n", item_crlf, + "after: 1\r\n"}, + {"theme: x\nhooks: {}", "theme: x\nhooks:\n", item_lf, ""}, + {"hooks: {}\n# trailing note\n", "hooks:\n# trailing note\n", item_lf, ""}, + }; + for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) { + char expected[1024]; + ASSERT(snprintf(expected, sizeof(expected), "%s%s%s", cases[i].header, cases[i].item, + cases[i].tail) > 0); + yaml_fixture_t fixture; + ASSERT_EQ(yaml_fixture_init(&fixture, cases[i].initial), 0); + ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, + "id", yaml_hook_identity, + yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + char *unchanged = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(unchanged); + ASSERT_STR_EQ(unchanged, cases[i].initial); + free(unchanged); + for (int pass = 0; pass < 2; pass++) { + ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, + 2U, "id", yaml_hook_identity, + yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + char *expanded = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(expanded); + ASSERT_STR_EQ(expanded, expected); + free(expanded); + } + th_cleanup(fixture.dir); + } + PASS(); +} + +/* Only an exact `{}` on a parent with nothing nested beneath it is accepted. + * The sequence key itself holding a mapping, a non-empty flow mapping, a flow + * sequence where a mapping belongs, and content indented under an inline `{}` + * stay refused byte-identically by both upsert and removal. */ +TEST(config_yaml_edit_nested_sequence_still_refuses_non_empty_parent_flow) { + const char *cases[] = { + "hooks:\n pre_llm_call: {}\n", + "hooks: {pre_llm_call: []}\n", + "hooks: []\n", + "hooks: {}\n pre_llm_call:\n - id: \"codebase-memory-mcp\"\n", + }; + for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) { + yaml_fixture_t fixture; + ASSERT_EQ(yaml_fixture_init(&fixture, cases[i]), 0); + ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, + "id", yaml_hook_identity, + yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_ERROR); + ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, + "id", yaml_hook_identity, + yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_ERROR); + char *after = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(after); + ASSERT_STR_EQ(after, cases[i]); + free(after); + th_cleanup(fixture.dir); + } + PASS(); +} + /* Uninstall must be idempotent against a config that was never written. Hermes * is detected by its binary on PATH, so a HOME without `.hermes/` reached the * YAML removers with an absent target — and they failed acquiring the lock @@ -2245,6 +2368,9 @@ SUITE(config_yaml_edit) { RUN_TEST(config_yaml_edit_nested_sequence_preserves_crlf); RUN_TEST(config_yaml_edit_nested_sequence_foreign_identity_is_preserved); RUN_TEST(config_yaml_edit_nested_sequence_removes_only_exact_canonical_item); + RUN_TEST(config_yaml_edit_nested_sequence_reinserts_under_emptied_parent); + RUN_TEST(config_yaml_edit_nested_sequence_expands_explicit_empty_parent_mapping); + RUN_TEST(config_yaml_edit_nested_sequence_still_refuses_non_empty_parent_flow); RUN_TEST(config_yaml_edit_nested_sequence_ignores_foreign_flow_and_block_scalars_issue2209); RUN_TEST(config_yaml_edit_nested_sequence_ambiguity_fails_byte_identically); RUN_TEST(config_yaml_edit_remove_on_absent_config_is_a_no_op);