mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge origin/main into fix/mcp-frontend-recovery
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
commit
b42159e227
29 files changed
+5572
-25
No files matched your search
+2
-1
@@ -887,6 +887,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
|
||||
|
||||
@@ -1010,7 +1011,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 ────────────────────────────────────────────
|
||||
|
||||
@@ -263,6 +263,8 @@ To turn the watcher off entirely, set `config set watcher_enabled false` (defaul
|
||||
|
||||
The watcher follows **git** projects only by default: a project indexed from a directory that is not a git repository is not watched and stays at its last index until you run `index_repository` again. To have such roots polled too, set `config set watch_non_git true` (default `false`, read when the daemon starts): they are then checked on the same cadence with a file-tree scan that uses the indexer's own ignore rules.
|
||||
|
||||
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:
|
||||
@@ -1016,6 +1018,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 # remove/disable all background watches (default: true)
|
||||
codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false)
|
||||
codebase-memory-mcp config set doc_candidates false # skip doc<->code candidate scoring on huge repos (default: auto = on for full, off for moderate/fast)
|
||||
codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true)
|
||||
codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false)
|
||||
|
||||
@@ -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` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. |
|
||||
| `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. |
|
||||
| `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. Active MCP sessions can register their root project and successful explicit index targets; under `daemon start` those watches outlive the session until the daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. `index_status` reports the current state in its `watch` object. |
|
||||
@@ -142,6 +143,44 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
The optional built-in graph UI stores its settings in:
|
||||
|
||||
@@ -16,6 +16,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"
|
||||
@@ -3951,6 +3952,9 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
/*
|
||||
* 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 <string.h>
|
||||
|
||||
#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)
|
||||
|
||||
/* 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 "<qn>.self"
|
||||
* 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_); \
|
||||
rf.signature = ruby_seed_signature(arena, (ret_)); \
|
||||
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");
|
||||
}
|
||||
@@ -3249,7 +3249,28 @@ 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() 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 = cbm_registry_lookup_method(ctx->registry, 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;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,176 @@
|
||||
#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 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
|
||||
* "<module_qn>.Admin::User" while the lexically nested form yields
|
||||
* "<module_qn>.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;
|
||||
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. */
|
||||
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
|
||||
* "<class_qn>.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: 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). */
|
||||
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 */
|
||||
/* 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;
|
||||
/* 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. */
|
||||
CBMResolvedCallArray *resolved_calls;
|
||||
|
||||
/* Recursion guards. */
|
||||
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;
|
||||
|
||||
/* 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 */
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
# Infrastructure hybrid LSPs (K8s YAML, Kustomize bundles, Flux/Argo deploy
|
||||
# scopes, the Go operator extension) were written from the Kubernetes, Flux,
|
||||
# Argo, Cluster API, cert-manager and Kubermatic API types. Scanned against
|
||||
|
||||
@@ -8088,6 +8088,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"},
|
||||
/* Unset: the index mode decides (cbm_pipeline_doc_candidates_enabled). */
|
||||
{CBM_CONFIG_DOC_CANDIDATES, "auto",
|
||||
"Doc-code candidates: auto = on for full, off for moderate/fast; true/false forces"},
|
||||
|
||||
+6
-1
@@ -336,7 +336,11 @@ 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);
|
||||
/* 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);
|
||||
/* include_posttool false renders the previous lifecycle-only manifest. Release
|
||||
* the result with cbm_free(CBM_MEM_CLASS_OTHER, ...). */
|
||||
@@ -526,6 +530,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_DOC_CANDIDATES "doc_candidates"
|
||||
#define CBM_CONFIG_UI_LANG "ui-lang"
|
||||
#define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled"
|
||||
|
||||
+69
-6
@@ -17,11 +17,14 @@
|
||||
*/
|
||||
|
||||
#include "cli/cli.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#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"
|
||||
|
||||
@@ -1654,7 +1657,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."
|
||||
@@ -1671,6 +1681,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)) {
|
||||
@@ -1688,7 +1733,20 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
|
||||
srv = owned_server;
|
||||
}
|
||||
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;
|
||||
/* 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);
|
||||
|
||||
char context[2048];
|
||||
@@ -1713,7 +1771,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
|
||||
? " The same tools are also available from the shell: "
|
||||
"`codebase-memory-mcp cli <tool> --help` lists each tool's flags."
|
||||
: "";
|
||||
if (project) {
|
||||
if (project && !drop_ancestor) {
|
||||
char safe_project[HA_METADATA_CAP];
|
||||
ha_sanitize_metadata(project, safe_project, sizeof(safe_project));
|
||||
/* The untrusted label covers the graph-derived project name only. Wrapping
|
||||
@@ -1732,7 +1790,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.%s",
|
||||
scope, safe_project, tier, cli_hint);
|
||||
} 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, "
|
||||
@@ -1851,8 +1909,13 @@ 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -17,6 +17,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"
|
||||
@@ -1941,6 +1942,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;
|
||||
@@ -2599,6 +2612,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);
|
||||
|
||||
@@ -73,6 +73,8 @@ static int git_capture_into(const char *repo_path, const char *git_args, char *b
|
||||
* stuck git child is named by the last marker printed (spawn, read or
|
||||
* close). Silent at the default level. */
|
||||
cbm_log_debug("git.capture", "stage", "spawn", "args", git_args);
|
||||
/* 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;
|
||||
@@ -111,6 +113,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;
|
||||
@@ -125,6 +135,180 @@ static bool path_is_absolute(const char *path) {
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Read the "gitdir: <path>" pointer out of a gitlink FILE at <path>/.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;
|
||||
}
|
||||
if (!path_is_regular_file(dot_git)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 0;
|
||||
}
|
||||
/* Only linked worktrees carry <gitdir>/commondir; a submodule gitlink
|
||||
* points at <super>/.git/modules/<name>, which does not. */
|
||||
char commondir[GIT_OUTPUT_MAX];
|
||||
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;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
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. */
|
||||
/* 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) {
|
||||
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);
|
||||
if (!parent_directory(current) || strcmp(previous, current) == 0) {
|
||||
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;
|
||||
}
|
||||
/* 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) {
|
||||
if (!root || !root[0]) {
|
||||
return git_strdup(rel);
|
||||
|
||||
@@ -20,6 +20,25 @@ typedef struct {
|
||||
char *base_sha;
|
||||
} cbm_git_context_t;
|
||||
|
||||
/* True when path is a LINKED git worktree (`git worktree add`) or a
|
||||
* subdirectory of one.
|
||||
*
|
||||
* Plumbing-only, no subprocess. Walk ancestors until a git anchor. A linked
|
||||
* worktree's <root>/.git is a regular file holding a "gitdir: <path>" 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, <super>/.git/modules/<name>, 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. */
|
||||
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);
|
||||
/* Only the is_git and branch fields of cbm_git_context_resolve, from the same
|
||||
* two git commands it uses for them (2 git processes instead of 7). branch
|
||||
|
||||
@@ -551,6 +551,9 @@ static const tool_def_t TOOLS[] = {
|
||||
"\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"},"
|
||||
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
|
||||
"\"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.\"},"
|
||||
"\"doc_candidates\":{\"type\":\"boolean\",\"description\":"
|
||||
"\"Doc-code candidates; default: the doc_candidates setting, else on for full, off for "
|
||||
"moderate/fast.\"},"
|
||||
@@ -13050,6 +13053,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) {
|
||||
if (async_mode) {
|
||||
index_args_free(repo_path, mode_str, name_override);
|
||||
@@ -21551,6 +21568,44 @@ 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);
|
||||
}
|
||||
|
||||
/* 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 *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, 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(store_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) {
|
||||
@@ -21705,6 +21760,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
|
||||
|
||||
@@ -86,6 +86,16 @@ 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);
|
||||
|
||||
/* 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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +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 (py_lsp.c:1624) ClassName() where the name is a
|
||||
* NAMED type in scope
|
||||
* 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
|
||||
@@ -340,13 +344,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 +553,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 +642,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);
|
||||
|
||||
+206
-3
@@ -27,6 +27,7 @@
|
||||
#include <foundation/platform.h>
|
||||
#include <foundation/private_file_lock.h>
|
||||
#include <foundation/sha256.h>
|
||||
#include <foundation/str_util.h>
|
||||
#include <mcp/mcp.h>
|
||||
#include <mcp/index_supervisor.h>
|
||||
#include <pipeline/pipeline.h>
|
||||
@@ -12677,9 +12678,91 @@ 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);
|
||||
|
||||
/* 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];
|
||||
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);
|
||||
|
||||
/* 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);
|
||||
@@ -12688,6 +12771,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);
|
||||
@@ -12695,10 +12779,110 @@ 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 (!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)
|
||||
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();
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
@@ -15172,14 +15356,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();
|
||||
}
|
||||
|
||||
@@ -19282,6 +19470,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);
|
||||
@@ -20165,6 +20366,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_install_managed_binary_config_path_issue2722);
|
||||
@@ -20405,6 +20607,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_session_untrusted_label_covers_project_name_only);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -346,6 +346,95 @@ 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);
|
||||
/* 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);
|
||||
/* 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);
|
||||
th_rmtree(wt_tmp);
|
||||
|
||||
ASSERT(worktree_detected);
|
||||
ASSERT(nested_ok);
|
||||
ASSERT(nested_detected);
|
||||
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 */
|
||||
}
|
||||
|
||||
TEST(git_context_ignores_inherited_repo_env) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI");
|
||||
@@ -403,6 +492,68 @@ TEST(git_context_ignores_inherited_repo_env) {
|
||||
#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);
|
||||
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);
|
||||
|
||||
ASSERT(!plain_detected);
|
||||
if (sub_rc != 0) {
|
||||
SKIP_PLATFORM("git submodule add unavailable in this environment");
|
||||
}
|
||||
/* A submodule gitlink has no commondir, including a subdirectory which
|
||||
* must not walk into the superproject. */
|
||||
ASSERT(!submodule_detected);
|
||||
ASSERT(sub_nested_made);
|
||||
ASSERT(!sub_nested_detected);
|
||||
PASS();
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
TEST(githistory_ignores_inherited_repo_env) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI");
|
||||
@@ -503,6 +654,8 @@ 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);
|
||||
RUN_TEST(git_context_ignores_inherited_repo_env);
|
||||
RUN_TEST(githistory_ignores_inherited_repo_env);
|
||||
}
|
||||
@@ -1837,16 +1837,62 @@ 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();
|
||||
}
|
||||
|
||||
/* 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();
|
||||
}
|
||||
|
||||
@@ -1967,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);
|
||||
}
|
||||
@@ -1802,6 +1802,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);
|
||||
@@ -2435,6 +2436,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(kotlin_typed_calls);
|
||||
|
||||
@@ -1214,6 +1214,57 @@ TEST(mcp_initialize_response) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* 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_mcp_server_t *server = cbm_mcp_server_new(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, NULL);
|
||||
}
|
||||
cbm_mcp_server_free(server);
|
||||
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(runtime_on);
|
||||
ASSERT(server_untouched);
|
||||
ASSERT(own_store_wins);
|
||||
ASSERT_EQ(removed, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(mcp_tools_list) {
|
||||
char *json = cbm_mcp_tools_list();
|
||||
ASSERT_NOT_NULL(json);
|
||||
@@ -24438,6 +24489,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
|
||||
*
|
||||
@@ -26751,6 +27087,7 @@ SUITE(mcp) {
|
||||
|
||||
/* MCP protocol helpers */
|
||||
RUN_TEST(mcp_initialize_response);
|
||||
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);
|
||||
@@ -27149,6 +27486,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
|
||||
RUN_TEST(mcp_auto_index_in_process_fresh_project_uses_full_width);
|
||||
RUN_TEST(mcp_auto_index_in_process_existing_index_keeps_headroom);
|
||||
RUN_TEST(mcp_background_flag_fresh_project_uses_full_width);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -356,6 +356,193 @@ 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();
|
||||
}
|
||||
|
||||
/* 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"
|
||||
"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);
|
||||
/* 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_STR_EQ(rc->reason, "C");
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/* 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 +1111,104 @@ 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 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"
|
||||
" 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);
|
||||
|
||||
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].callee_qn, "test.main.B");
|
||||
ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor");
|
||||
}
|
||||
cbm_arena_destroy(&arena);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pylsp_batch_two_files) {
|
||||
const char *src_a =
|
||||
"def helper():\n"
|
||||
@@ -5010,6 +5295,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_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);
|
||||
RUN_TEST(pylsp_method_via_inheritance);
|
||||
RUN_TEST(pylsp_no_false_positive_on_unknown_method);
|
||||
/* Phases 7-8 — decorators, super(), multi-inheritance */
|
||||
@@ -5028,6 +5318,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_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);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user