mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge origin/main into feat/codex-skill-location-compat
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
commit
12920dae15
51 files changed
+5459
-335
No files matched your search
@@ -696,6 +696,26 @@ MIMALLOC_CFLAGS_TEST = -std=c11 -g -O1 -w \
|
||||
-Ivendored/mimalloc/include \
|
||||
-Ivendored/mimalloc/src \
|
||||
-DMI_OVERRIDE=0 $(MIMALLOC_WIN_DEFINE)
|
||||
# MI_WIN_INIT_USE_FLS (MinGW TEST build only) is an EXPERIMENT (R32) for the
|
||||
# Windows test leg's nondeterministic crash: 1-3 suites die in their first
|
||||
# test with 0xC0000005 and no sanitizer report, seen only on trees with
|
||||
# mimalloc 3.5.3. 3.5.3 is the first version whose loader hooks are live on
|
||||
# MinGW: TLS callbacks in .CRT$XLB/.CRT$XLY run _mi_auto_process_init under the
|
||||
# loader lock and _mi_thread_done on every thread detach. With FLS,
|
||||
# src/prim/windows/prim.c compiles none of those hooks; process init runs once,
|
||||
# from the constructor in src/prim/prim.c (no double init), and thread-done
|
||||
# goes through a fiber-local destructor. MI_DEBUG=1 is what FLS needs to run at
|
||||
# all: the first thread init (src/init.c) associates the main theap before the
|
||||
# FLS key exists, which trips an internal assertion in prim.c and aborts every
|
||||
# MI_DEBUG=2 process at startup. Level 1 keeps the fill patterns, padding and
|
||||
# double-free checks and drops only the internal assertions. The production
|
||||
# allocator (MIMALLOC_CFLAGS) is unchanged. Drop or promote this when the
|
||||
# experiment ends.
|
||||
MIMALLOC_WIN_TEST_DEFINE :=
|
||||
ifeq ($(IS_MINGW),yes)
|
||||
MIMALLOC_WIN_TEST_DEFINE := -DMI_WIN_INIT_USE_FLS=1 -DMI_DEBUG=1
|
||||
MIMALLOC_CFLAGS_TEST += $(MIMALLOC_WIN_TEST_DEFINE)
|
||||
endif
|
||||
|
||||
# sqlite3 (vendored amalgamation — compiled ourselves for ASan instrumentation)
|
||||
# SQLITE_ENABLE_FTS5: enables the FTS5 full-text search extension used by the
|
||||
|
||||
@@ -911,7 +911,7 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a
|
||||
|------|-------------|
|
||||
| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected (under `daemon start`, also after it disconnects), subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
|
||||
| `list_projects` | List indexed projects with paging; use `detail: "stats"` for node/edge counts and database sizes. |
|
||||
| `delete_project` | Remove a project and all its graph data. |
|
||||
| `delete_project` | Remove a project and all its graph data, including its ADR sidecar (`<db>.adr.db`). |
|
||||
| `index_status` | Check indexing status of a project. |
|
||||
| `check_index_coverage` | Check whether exact paths or a scope are indexed and fresh. A clean result means no recorded gap, not proof of completeness. |
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -32,7 +32,7 @@
|
||||
"playwright": "1.62.1",
|
||||
"three-stdlib": "2.36.1",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "6.4.4",
|
||||
"vite": "6.4.5",
|
||||
"vitest": "4.1.12"
|
||||
}
|
||||
},
|
||||
@@ -5277,9 +5277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
|
||||
"integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
|
||||
"version": "6.4.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz",
|
||||
"integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"playwright": "1.62.1",
|
||||
"three-stdlib": "2.36.1",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "6.4.4",
|
||||
"vite": "6.4.5",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
+3
-2
@@ -289,8 +289,9 @@ typedef struct {
|
||||
* qualified_name (base QN = the first qn_sig_off bytes); 0 = no suffix.
|
||||
* Always 0 until a language enables its callable_identity mode. */
|
||||
uint32_t qn_sig_off;
|
||||
/* C# only: declared namespace of a TOP-LEVEL type (NULL for nested types,
|
||||
* the global namespace and every other language). Per type, because the
|
||||
/* C# only: declared namespace of a TOP-LEVEL type, "" for the global
|
||||
* namespace (proven only by a clean parse), NULL for nested types, an
|
||||
* unknown namespace and every other language. Per type, because the
|
||||
* file-level namespace_name records only a file's first namespace. */
|
||||
const char *decl_namespace;
|
||||
CBMTestDefinitionRole test_role; /* NONE preserves legacy helper/test-file semantics */
|
||||
|
||||
@@ -718,6 +718,66 @@ static bool elixir_call_head_in(TSNode call, const char *source, const char *con
|
||||
call_node_text_in(ts_node_child(call, 0), source, heads);
|
||||
}
|
||||
|
||||
/* A definition's head declares a name; it is never a call to it. The head under
|
||||
* a guard is not the def's first argument -- `def f(x) when g` parses its whole
|
||||
* head as a `when` binary_operator -- so the plain node comparison below stopped
|
||||
* recognising it, and the inner `f(x)` was recorded as a call to `f`. Under the
|
||||
* widened span that phantom lands inside the function's own node, and cbm.c
|
||||
* decides self-recursion by line containment, so an ordinary two-clause guarded
|
||||
* function reported itself recursive. `recursive` is a queryable node property
|
||||
* and seeds the cycle detection in pass_complexity, so that is a load-bearing
|
||||
* signal, not a cosmetic one. The suppression therefore travels with the fold
|
||||
* rather than following it.
|
||||
*
|
||||
* The head comes from cbm_elixir_def_head_is (helpers.c) rather than a private
|
||||
* peel here, because three files read this same node and all three have to
|
||||
* agree on which node is the head, or a node one of them is treating as a
|
||||
* definition name is recorded by another as a reference to that very name:
|
||||
* - extract_defs.c, extract_elixir_func_def, unwraps it to NAME the
|
||||
* definition. It and this file disagreeing mints the phantom above.
|
||||
* - extract_usages.c, is_elixir_def_binding, treats an identifier inside that
|
||||
* head as a binding occurrence rather than a reference, which is what keeps
|
||||
* a head suppressed here from re-emerging as a USAGE edge: a suppression in
|
||||
* one extractor of the unified walk only REMOVES a phantom if the others
|
||||
* also decline the node. The two files ask different questions of the same
|
||||
* helper -- this one whether the node IS the head or a `when` wrapper above
|
||||
* it, that one whether the node sits INSIDE the head -- so there is one
|
||||
* definition of the head and they cannot drift apart. That containment
|
||||
* stops at the head rather than covering the whole `when` operator, which
|
||||
* is deliberate in the other direction: only the parameters being BOUND are
|
||||
* excluded, and a parameter READ in the guard is a reference, exactly as
|
||||
* the same read in the body is.
|
||||
* - extract_unified.c, compute_elixir_func_qn, resolves a def's QN to open
|
||||
* the function's call scope and reads the head through the same helper.
|
||||
* Handed the `when` operator instead it returns NULL, no scope is pushed,
|
||||
* and every call in a guarded body is sourced from the File rather than
|
||||
* from the enclosing Function. That failure is independent of the two above
|
||||
* because it decides edge SOURCE, not edge existence.
|
||||
*
|
||||
* Suppressing the head does not only delete edges; it moves metadata on edges
|
||||
* that SURVIVE, and that is worth stating because a caller reading an edge's
|
||||
* `line` will see a different number than before. `edges` is UNIQUE on
|
||||
* (source, target, type), so where the def-head phantom happened to be the
|
||||
* dedup survivor for a pair, deleting it promotes some other row for that same
|
||||
* pair and the surviving edge takes that row's `line`.
|
||||
*
|
||||
* Measured by indexing one 970-file Elixir lib tree with the binary built from
|
||||
* this commit's parent and with this one, and diffing the `edges` rows out of
|
||||
* the two SQLite stores keyed on (source QN, target QN, type): 2,436 edges go
|
||||
* and NONE is added -- 2,104 CALLS and 332 USAGE. Of the 119,696 edges that
|
||||
* survive, 1,054 record a different `line`.
|
||||
*
|
||||
* The typespec attributes -- `@spec f(t) :: u` and the @callback / @type family
|
||||
* -- put the declared name in a `call` node too, and are deliberately NOT
|
||||
* suppressed here. A def head can be, because extract_usages.c already treats a
|
||||
* def's first argument as a binding. Nothing there treats a typespec subject as
|
||||
* one, so declining it in this walk would not remove its phantom, it would
|
||||
* RELABEL it: handle_usages reaches the bare identifier handle_calls just
|
||||
* declined and mints a USAGE onto the same function, which under
|
||||
* UNIQUE(source, target, type) is a separate row that no longer collides with
|
||||
* the real call it used to hide behind, and which pass_importance then counts.
|
||||
* A typespec phantom has to be removed before any extractor sees it, by
|
||||
* skipping the whole subtree in the unified walk. */
|
||||
static bool elixir_call_is_definition_role(TSNode node, const char *source) {
|
||||
static const char *const structural_heads[] = {"def", "defp", "defmacro", "defmodule", NULL};
|
||||
static const char *const function_heads[] = {"def", "defp", "defmacro", NULL};
|
||||
@@ -735,7 +795,7 @@ static bool elixir_call_is_definition_role(TSNode node, const char *source) {
|
||||
TSNode signature = ts_node_named_child_count(arguments) > 0
|
||||
? ts_node_named_child(arguments, 0)
|
||||
: arguments;
|
||||
return ts_node_eq(signature, node);
|
||||
return cbm_elixir_def_head_is(signature, node);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -757,7 +817,23 @@ static bool call_node_is_definition_container(CBMLanguage lang, TSNode node, con
|
||||
if (lang == CBM_LANG_AGDA && strcmp(kind, "expr") == 0) {
|
||||
return agda_expr_is_definition_role(node);
|
||||
}
|
||||
return lang == CBM_LANG_ELIXIR && strcmp(kind, "call") == 0 &&
|
||||
/* A guarded head is a `when` binary_operator, not a `call`, and Elixir's
|
||||
* call node types include binary_operator -- so the head reaches this walk
|
||||
* and must be able to answer that it is a definition. A paren-less clause
|
||||
* (`def f when g`) has no inner call at all, so the operator node itself
|
||||
* reaches extract_callee_name and, with no callee of its own, takes that
|
||||
* function's last resort -- the first identifier child -- minting a phantom
|
||||
* CALLS edge onto the very function being defined.
|
||||
*
|
||||
* That last resort overrides a deliberate NULL for every Elixir
|
||||
* binary_operator extract_scripting_callee declines (`=`, `<-`, `->`, `\\`,
|
||||
* `::`, `when`), so `def g(c \\ 2)` still emits a phantom `c` and
|
||||
* `e = target(d)` a phantom `e`. That is the pre-existing behaviour for
|
||||
* those operators and is untouched here: only a `when` operator is
|
||||
* admitted, so an operator definition's own head (`def a + b`) stays an
|
||||
* ordinary node, as it was before guards were handled at all. */
|
||||
return lang == CBM_LANG_ELIXIR &&
|
||||
(strcmp(kind, "call") == 0 || cbm_elixir_is_when_guard(node)) &&
|
||||
elixir_call_is_definition_role(node, source);
|
||||
}
|
||||
|
||||
|
||||
+161
-12
@@ -8340,9 +8340,15 @@ static const char *cs_namespace_decl_name(CBMArena *a, TSNode ns, const char *so
|
||||
/* Declared namespace of a C# type declaration: every enclosing namespace
|
||||
* block (outer to inner) under an optional file-scoped namespace, which the
|
||||
* grammar may attach either as an ancestor or as a preceding sibling of the
|
||||
* compilation unit's members. NULL = global namespace. The file-level
|
||||
* namespace_name keeps only the FIRST namespace of a file, which mislabels
|
||||
* every type of a multi-namespace file (#2120). */
|
||||
* compilation unit's members. "" = the global namespace, NULL = unknown. The
|
||||
* file-level namespace_name keeps only the FIRST namespace of a file, which
|
||||
* mislabels every type of a multi-namespace file (#2120).
|
||||
*
|
||||
* "No namespace found" proves the global namespace only for a clean parse:
|
||||
* error recovery can dissolve a namespace declaration around a construct the
|
||||
* grammar does not know (a `ref partial struct` with members does it), leaving
|
||||
* the types after it parented straight under the compilation unit. Calling
|
||||
* those global would let them hide every imported type of the same name. */
|
||||
static const char *cs_type_decl_namespace(CBMArena *a, TSNode node, const char *source) {
|
||||
const char *acc = NULL;
|
||||
bool file_scoped = false;
|
||||
@@ -8376,7 +8382,10 @@ static const char *cs_type_decl_namespace(CBMArena *a, TSNode node, const char *
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
if (acc) {
|
||||
return acc;
|
||||
}
|
||||
return ts_node_has_error(top) ? NULL : "";
|
||||
}
|
||||
|
||||
static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) {
|
||||
@@ -9714,8 +9723,119 @@ static TSNode elixir_call_args(TSNode node) {
|
||||
return args;
|
||||
}
|
||||
|
||||
// Handle Elixir def/defp/defmacro — extract function definition.
|
||||
static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro) {
|
||||
// Fold one more clause of an Elixir function into the def already pushed for
|
||||
// the previous clause. Every clause is its own `def` call, and an Elixir QN
|
||||
// carries neither module nor arity, so all of them compute one qualified name
|
||||
// and collide on a single graph node. cbm_gbuf_upsert_node breaks a same-QN
|
||||
// collision by keeping the LARGEST start_line, so the survivor was the last
|
||||
// clause in the file and get_code_snippet returned that clause as the whole
|
||||
// function: `def admin?(%__MODULE__{role: :admin}), do: true` /
|
||||
// `def admin?(%__MODULE__{}), do: false` read back as a predicate that always
|
||||
// returns false. One node per function is right, so widen its span instead.
|
||||
//
|
||||
// Two bounds keep the widened span honest, and a clause folds only if it clears
|
||||
// both:
|
||||
// - Same module body. `scope` is the do_block the previous clause was written
|
||||
// in, so a nested `defmodule` cannot merge with its parent: the QN carries
|
||||
// no module, so `Outer.run` and `Outer.Inner.run` compute the same name and
|
||||
// are array-adjacent when Inner is declared just above Outer's own clause.
|
||||
// - Same group. Only the immediately preceding EXTRACTED definition is a
|
||||
// candidate, so a def of another name between two same-named defs ends the
|
||||
// group. That is a bound this code imposes, not a guarantee Elixir gives:
|
||||
// the compiler only WARNS ("clauses with the same name and arity should be
|
||||
// grouped together"), it warns per name AND arity while this QN is
|
||||
// arity-free, and non-contiguous clauses do compile. Split clauses
|
||||
// therefore keep separate nodes, which is the pre-existing behaviour.
|
||||
// What the group bound does NOT exclude is a non-definition construct
|
||||
// between two clauses: `@doc` / `@spec` are unary_operators, `use` /
|
||||
// `alias` / `describe` are ordinary calls, and a `defimpl` block's inner
|
||||
// defs are not extracted -- none of them push a def, so anything of that
|
||||
// kind written between two clauses of one function ends up inside the
|
||||
// widened span, and that is not rare. Measured by indexing one 970-file
|
||||
// Elixir lib tree with the binary built from this commit's parent and with
|
||||
// this one and diffing the `nodes` rows out of the two SQLite stores: 6,996
|
||||
// Function nodes widen their span, none is added, none is lost and none
|
||||
// narrows. Reading each widened span for a module-body construct written at
|
||||
// the module's own two-space indent, which no clause body reaches, 169 of
|
||||
// the 6,996 cover such a line. Counting each node once per kind, 123 cover
|
||||
// a typespec attribute (@spec/@type/@typep/@opaque/@callback/
|
||||
// @macrocallback), 100 an @doc or @typedoc, 46 a bare comment line, 14 an
|
||||
// @impl, and none a `use`, `alias`, `import`, `require` or a module-level
|
||||
// `quote`. Swallowed typespecs are the largest attribute kind, not an
|
||||
// absent one.
|
||||
//
|
||||
// The widened span also admits a phantom call that a narrower span kept out,
|
||||
// which is why the head suppression in extract_calls.c travels with this fold
|
||||
// rather than after it. cbm.c flags self-recursion by finding the innermost
|
||||
// Function whose [start_line, end_line] contains a recorded call whose short
|
||||
// name matches the function's own, so any pre-existing phantom between the
|
||||
// first and the last clause becomes a self-edge as soon as the span covers it.
|
||||
// That cost is real and is measured rather than asserted away. Same corpus,
|
||||
// reading each flagged node's own source span for any line that names it as a
|
||||
// call, a capture or a pipe target, counting a one-line clause's body after
|
||||
// `, do:` as such a line: self_recursive Function nodes go 129 -> 430, and the
|
||||
// ones carrying no self-call form anywhere in their span go 6 -> 48. The lines
|
||||
// responsible are the typespec heads the widened span now covers -- `@spec
|
||||
// f(t) :: u` puts the declared name in a `call` node, and the unified walk
|
||||
// reads it as code -- a separate defect with a separate fix, not something this
|
||||
// fold can close.
|
||||
//
|
||||
// Skipping the typespec subtrees, later in this stack, closes that half: the
|
||||
// same method then reports 389 self_recursive Function nodes and 8 with no
|
||||
// self-call form, against 129 and 6 on the base. All 8 are the pre-existing
|
||||
// phantom a local variable sharing the function's name mints (`defp slug(value)
|
||||
// do slug = ...`), 6 of them already present on the base; the other 2 are
|
||||
// guarded clauses that only have a node of their own to be flagged on because
|
||||
// of this stack.
|
||||
//
|
||||
// What the fold and the head suppression together remove is 2,436 edges, none
|
||||
// of them added back: see elixir_call_is_definition_role in extract_calls.c.
|
||||
//
|
||||
// A clause whose macro differs (`def` foo/1 beside `defp` foo/2) still folds,
|
||||
// because the arity-free QN already puts both on one node; is_exported is then
|
||||
// the OR over the folded clauses, so a name any clause exports stays exported.
|
||||
// That OR changes a recorded flag, which the node diff confirms and which is
|
||||
// worth stating rather than leaving to be discovered: on the same corpus
|
||||
// exactly 6 Function nodes change is_exported, all false -> true and none the
|
||||
// other way. Each is a `def` clause written above a `defp` clause of the same
|
||||
// name, where the pre-patch last-clause-wins record reported a publicly
|
||||
// callable name as unexported. Verified against the source in every one: the
|
||||
// new value is the correct one.
|
||||
// Returns true when the clause folded.
|
||||
static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, TSNode node,
|
||||
uint32_t end_line, bool is_exported) {
|
||||
if (!qn || ctx->result->defs.count <= 0 || ts_node_is_null(scope)) {
|
||||
return false;
|
||||
}
|
||||
TSNode here = ts_node_parent(node);
|
||||
if (ts_node_is_null(here) || !ts_node_eq(here, scope)) {
|
||||
return false;
|
||||
}
|
||||
CBMDefinition *prev = &ctx->result->defs.items[ctx->result->defs.count - 1];
|
||||
if (!prev->label || strcmp(prev->label, "Function") != 0 || !prev->qualified_name ||
|
||||
strcmp(prev->qualified_name, qn) != 0) {
|
||||
return false;
|
||||
}
|
||||
/* Only end_line moves. prev->start_line is already the minimum, so this
|
||||
* takes no start_line argument: a clause folds only into the def pushed
|
||||
* for the immediately preceding extracted clause of the same do_block, and
|
||||
* extract_elixir_call pushes a do_block's children onto its stack in
|
||||
* reverse index order so they pop in source order. A `start_line <
|
||||
* prev->start_line` guard here would be unreachable; if that traversal
|
||||
* ever stops being source-ordered, this is the line that has to change
|
||||
* with it. */
|
||||
if (end_line > prev->end_line) {
|
||||
prev->end_line = end_line;
|
||||
}
|
||||
prev->is_exported = prev->is_exported || is_exported;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle Elixir def/defp/defmacro — extract function definition. `scope` is the
|
||||
// module body the previous clause was extracted from, and is updated to this
|
||||
// def's own module body; see fold_elixir_clause for what it bounds.
|
||||
static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro,
|
||||
TSNode *scope) {
|
||||
CBMArena *a = ctx->arena;
|
||||
TSNode args = elixir_call_args(node);
|
||||
if (ts_node_is_null(args)) {
|
||||
@@ -9727,6 +9847,16 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char
|
||||
return;
|
||||
}
|
||||
|
||||
// `def name(args) when guard` parses the whole head as a `when`
|
||||
// binary_operator, so the name lives on its left operand rather than
|
||||
// directly under the call. Without unwrapping it, every guarded clause is
|
||||
// dropped, and a function whose clauses ALL carry guards never appears in
|
||||
// the graph at all -- silently, since a missing definition is not an error.
|
||||
// The unwrap lives in helpers.c because it peels only `when`: an operator
|
||||
// definition (`def a + b`) is a binary_operator head too, and unwrapping
|
||||
// that one would name the function after its own left parameter.
|
||||
first_arg = cbm_elixir_def_head_unwrap_guard(first_arg);
|
||||
|
||||
const char *fk = ts_node_type(first_arg);
|
||||
char *name = NULL;
|
||||
if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) {
|
||||
@@ -9738,15 +9868,25 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char
|
||||
return;
|
||||
}
|
||||
|
||||
const char *qn = cbm_fqn_compute(a, ctx->project, ctx->rel_path, name);
|
||||
uint32_t start_line = ts_node_start_point(node).row + TS_LINE_OFFSET;
|
||||
uint32_t end_line = ts_node_end_point(node).row + TS_LINE_OFFSET;
|
||||
bool is_exported = (strcmp(macro, "def") == 0 || strcmp(macro, "defmacro") == 0);
|
||||
bool folded = fold_elixir_clause(ctx, qn, *scope, node, end_line, is_exported);
|
||||
*scope = ts_node_parent(node);
|
||||
if (folded) {
|
||||
return;
|
||||
}
|
||||
|
||||
CBMDefinition def;
|
||||
memset(&def, 0, sizeof(def));
|
||||
def.name = name;
|
||||
def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, name);
|
||||
def.qualified_name = qn;
|
||||
def.label = "Function";
|
||||
def.file_path = ctx->rel_path;
|
||||
def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET;
|
||||
def.end_line = ts_node_end_point(node).row + TS_LINE_OFFSET;
|
||||
def.is_exported = (strcmp(macro, "def") == 0 || strcmp(macro, "defmacro") == 0);
|
||||
def.start_line = start_line;
|
||||
def.end_line = end_line;
|
||||
def.is_exported = is_exported;
|
||||
cbm_defs_push(&ctx->result->defs, a, def);
|
||||
}
|
||||
|
||||
@@ -9783,6 +9923,10 @@ static TSNode emit_elixir_module_class(CBMExtractCtx *ctx, TSNode cur) {
|
||||
// without recursion between extract_elixir_call ↔ extract_elixir_module_def.
|
||||
static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) {
|
||||
(void)spec;
|
||||
/* Module body the last extracted clause was written in; see
|
||||
* fold_elixir_clause. Null until the first def, so nothing folds into a
|
||||
* def left over from a previous top-level call node. */
|
||||
TSNode def_scope = {0};
|
||||
TSNodeStack stack;
|
||||
ts_nstack_init(&stack, ctx, CBM_SZ_64);
|
||||
ts_nstack_push(&stack, node);
|
||||
@@ -9805,12 +9949,17 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp
|
||||
|
||||
if (strcmp(macro, "def") == 0 || strcmp(macro, "defp") == 0 ||
|
||||
strcmp(macro, "defmacro") == 0) {
|
||||
extract_elixir_func_def(ctx, cur, macro);
|
||||
extract_elixir_func_def(ctx, cur, macro, &def_scope);
|
||||
} else if (strcmp(macro, "if") == 0 || strcmp(macro, "unless") == 0) {
|
||||
/* A compile-time if/unless in a module body defines functions
|
||||
* under a condition: its do and else branches hold variants of the
|
||||
* same definitions (graph_buffer.c, "Definition variants"). The
|
||||
* grammar nests the else_block inside the do_block. */
|
||||
* grammar nests the else_block inside the do_block.
|
||||
* The clause fold composes with this: fold_elixir_clause only
|
||||
* folds a clause into one written in the same block, and each
|
||||
* branch is its own block, so the clauses of one branch fold into
|
||||
* one definition while the branches stay separate definitions that
|
||||
* the graph buffer merges as variants of one identity. */
|
||||
TSNode branch = cbm_find_child_by_kind(cur, "do_block");
|
||||
for (int pass = 0; pass < 2 && !ts_node_is_null(branch); pass++) {
|
||||
uint32_t bc = ts_node_child_count(branch);
|
||||
|
||||
@@ -603,6 +603,11 @@ static const char *compute_elixir_func_qn(CBMExtractCtx *ctx, TSNode node) {
|
||||
if (ts_node_is_null(first_arg)) {
|
||||
return NULL;
|
||||
}
|
||||
/* A guard wraps the whole head in a `when` operator, so the head naming the
|
||||
* function is its left operand. Left wrapped, this returns NULL and the def
|
||||
* opens NO function scope: every call in a guarded body then sources to the
|
||||
* FILE node and the function itself reports no outgoing edges. */
|
||||
first_arg = cbm_elixir_def_head_unwrap_guard(first_arg);
|
||||
const char *fk = ts_node_type(first_arg);
|
||||
char *name = NULL;
|
||||
if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) {
|
||||
@@ -2154,6 +2159,111 @@ static bool is_unified_trivia_node(TSNode node) {
|
||||
return ts_node_is_extra(node);
|
||||
}
|
||||
|
||||
static bool unified_node_text_equals(const CBMExtractCtx *ctx, TSNode node, const char *expected) {
|
||||
if (ts_node_is_null(node) || !ctx->source || !expected) {
|
||||
return false;
|
||||
}
|
||||
uint32_t start = ts_node_start_byte(node);
|
||||
uint32_t end = ts_node_end_byte(node);
|
||||
size_t len = strlen(expected);
|
||||
return end >= start && (size_t)(end - start) == len &&
|
||||
memcmp(ctx->source + start, expected, len) == 0;
|
||||
}
|
||||
|
||||
/* An Elixir module attribute parses as `unary_operator(@, call(<attr>, args))`,
|
||||
* so the typespec family reaches the unified walk in the shape real code has:
|
||||
* `@spec foo(t) :: t` puts `foo(t)` in the same `call` node an invocation would
|
||||
* occupy, and `@type entry :: String.t()` does the same to a type name. The
|
||||
* walk then reads a declaration as code and mints reference edges from it.
|
||||
*
|
||||
* Measured on a fixture of three typespec lines (`@type state :: map()`, `@spec
|
||||
* fetch(opts) :: state`, `@spec load(User.t()) :: :ok`) beside `def state`,
|
||||
* `def opts` and a remote `MyApp.User.t/0`: seven edges, every one phantom and
|
||||
* every one landing on a Function. CALLS onto each specified function;
|
||||
* CALLS + USAGE + WRITES onto `state`, the WRITES asserting a mutation that
|
||||
* does not exist; and USAGE onto `opts` and onto `MyApp.User.t/0`, because a
|
||||
* bare or remote TYPE name resolves to the same-named FUNCTION. On a codebase
|
||||
* whose convention is @spec on every public function, fan_in therefore never
|
||||
* reaches 0 and "which exported functions nothing calls" is unanswerable.
|
||||
*
|
||||
* The whole subtree is skipped, not just the specified name. What that costs is
|
||||
* not uniform across the six heads, and both halves were measured by indexing
|
||||
* one fixture repo -- a declaring file per head plus the file each one reaches
|
||||
* -- with the pristine binary and with this one:
|
||||
*
|
||||
* spec, type, typep, opaque. A type reference resolves onto a FUNCTION or
|
||||
* onto nothing, never onto the type it names, because a type declaration
|
||||
* mints no node. `@spec build(MyApp.User.t())` where that module's `t` is a
|
||||
* `@type` produced no edge in either build, and `@type ext :: Ecto.Schema.t()`
|
||||
* naming a module outside the repo produced none either. The references that
|
||||
* do resolve land on a same-named Function, cross-file included -- `USAGE
|
||||
* lib/decl_type.ex -> Function type_target @lib/remote_type.ex`, and the
|
||||
* @typep and @opaque equivalents. A Function standing in for a Type is the
|
||||
* pollution rather than a record of it, so nothing correct is lost here.
|
||||
*
|
||||
* callback, macrocallback. The declared name IS a function name, so it
|
||||
* resolves onto an implementation elsewhere in the corpus: pristine yields
|
||||
* `CALLS lib/decl_callback.ex -> Function callback_fun @lib/impl_callback.ex`
|
||||
* and the @macrocallback equivalent, both cross-file, and this build yields
|
||||
* neither. That edge does point at a real Behaviour-to-Impl relationship, and
|
||||
* nothing replaces it -- `@behaviour MyBehaviour` mints no edge of its own,
|
||||
* so after this change nothing in the graph links a behaviour to its
|
||||
* implementations. It is deleted anyway because it is name resolution and not
|
||||
* a behaviour model: on a fixture with one @callback and three same-named
|
||||
* `handle_it/1` defs, the single edge landed on the one module that
|
||||
* implements nothing, leaving both real implementors at fan_in 0. Recording
|
||||
* that relationship correctly means minting it from `@behaviour`, which is a
|
||||
* separate change and not a reason to keep an arbitrary edge.
|
||||
*
|
||||
* The other accepted cost: `unquote(...)` inside a typespec does execute at
|
||||
* compile time, so `@type t :: unquote(build_type())` loses its genuine
|
||||
* `build_type` call edge along with the phantoms.
|
||||
*
|
||||
* Only this family is skipped. Every other attribute VALUE is ordinary
|
||||
* compile-time code -- `@timeout Application.compile_env(:app, :timeout)`
|
||||
* really does call compile_env -- so those subtrees are walked exactly as
|
||||
* before, and their attribute NAME is still emitted as a callee: `@behaviour
|
||||
* GenServer`, `@timeout 5_000` and `@doc "x"` each still mint one phantom CALLS
|
||||
* onto a same-named Function. That half of the defect class is untouched here
|
||||
* and needs its own fix.
|
||||
*
|
||||
* This subtree skip is one of two mechanisms, and neither subsumes the other.
|
||||
* It removes the phantom a typespec LINE mints; the def-head phantom is removed
|
||||
* separately, by the calls walk recognising a `when`-wrapped head as a
|
||||
* declaration (elixir_call_is_definition_role, extract_calls.c). A function
|
||||
* carrying both an @spec and a `when` clause needs both: with only this one it
|
||||
* keeps the def-head reference, with only the other it keeps the @spec one.
|
||||
* `extract_elixir_typespec_attribute_is_not_code` pins that by asserting 0
|
||||
* references onto such a function, so removing either mechanism fails it.
|
||||
*
|
||||
* Measured over forge-symphony-graph/lib (970 .ex files) by classifying the
|
||||
* source line every CALLS/USAGE/WRITES edge records: a build without this skip
|
||||
* anchors 4,565 CALLS edges on a typespec-attribute line, and this build
|
||||
* anchors 0 -- with no USAGE or WRITES appearing in their place, which is what
|
||||
* a relabel rather than a removal would look like. Total USAGE edges fall from
|
||||
* 25,393 to 23,044 over the same corpus; they do not rise. */
|
||||
static bool is_elixir_typespec_attribute(const CBMExtractCtx *ctx, TSNode node) {
|
||||
static const char *const typespec_heads[] = {
|
||||
"spec", "callback", "macrocallback", "type", "typep", "opaque", NULL};
|
||||
if (ctx->language != CBM_LANG_ELIXIR || strcmp(ts_node_type(node), "unary_operator") != 0 ||
|
||||
!unified_node_text_equals(ctx, ts_node_child_by_field_name(node, TS_FIELD("operator")),
|
||||
"@")) {
|
||||
return false;
|
||||
}
|
||||
TSNode operand = ts_node_child_by_field_name(node, TS_FIELD("operand"));
|
||||
if (ts_node_is_null(operand) || strcmp(ts_node_type(operand), "call") != 0 ||
|
||||
ts_node_child_count(operand) == 0) {
|
||||
return false;
|
||||
}
|
||||
TSNode head = ts_node_child(operand, 0);
|
||||
for (const char *const *name = typespec_heads; *name; name++) {
|
||||
if (unified_node_text_equals(ctx, head, *name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// JS/TS `export_statement` appears in import_node_types so re-exports
|
||||
// (`export { X } from './m'`) are treated as an import boundary. But it also
|
||||
// wraps exported *declarations* (`export function f(cfg: Config) {}`), and
|
||||
@@ -2755,7 +2865,8 @@ void cbm_extract_unified(CBMExtractCtx *ctx) {
|
||||
break;
|
||||
}
|
||||
bool trivia = is_unified_trivia_node(node);
|
||||
if (!trivia) {
|
||||
bool typespec = is_elixir_typespec_attribute(ctx, node);
|
||||
if (!trivia && !typespec) {
|
||||
/* Trivia consumes no semantic state. Scope expiry may be deferred
|
||||
* until the next code-bearing node; pop restores the displaced
|
||||
* tuple and push applies the new frame's effect, both O(1) --
|
||||
@@ -2788,7 +2899,7 @@ void cbm_extract_unified(CBMExtractCtx *ctx) {
|
||||
* asking the cursor to construct a child iterator for every one of
|
||||
* hundreds of thousands of flat comment siblings. Structured extras
|
||||
* still descend normally. */
|
||||
if ((!trivia || ts_node_child_count(node) > 0) &&
|
||||
if (!typespec && (!trivia || ts_node_child_count(node) > 0) &&
|
||||
ts_tree_cursor_goto_first_child(&cursor)) {
|
||||
depth++;
|
||||
continue;
|
||||
|
||||
@@ -617,6 +617,11 @@ static bool is_elixir_def_binding(CBMExtractCtx *ctx, TSNode node) {
|
||||
TSNode signature = ts_node_named_child_count(arguments) > 0
|
||||
? ts_node_named_child(arguments, 0)
|
||||
: arguments;
|
||||
/* A guard makes that signature the whole `when` operator, which spans the
|
||||
* guard expression as well as the head. Unwrapped to the head, only the
|
||||
* parameters being BOUND are excluded here; a parameter READ inside the
|
||||
* guard stays a usage, as the same read in the body already is. */
|
||||
signature = cbm_elixir_def_head_unwrap_guard(signature);
|
||||
return node_contains(head, node) || node_contains(signature, node);
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1382,6 +1382,93 @@ const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, c
|
||||
return scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name;
|
||||
}
|
||||
|
||||
/* ── Elixir def-head guards ─────────────────────────────────── */
|
||||
|
||||
/* The guard sits on the head, not beside it: `def name(args) when guard` parses
|
||||
* as a single `when` binary_operator whose left operand is `name(args)`. The
|
||||
* operator field is an exact anonymous token, so the check is the token type
|
||||
* rather than the node kind — `def a + b` is a binary_operator head too, and
|
||||
* unwrapping it would name the function after its left parameter. */
|
||||
bool cbm_elixir_is_when_guard(TSNode node) {
|
||||
if (ts_node_is_null(node) || strcmp(ts_node_type(node), "binary_operator") != 0) {
|
||||
return false;
|
||||
}
|
||||
TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator"));
|
||||
return !ts_node_is_null(op) && strcmp(ts_node_type(op), "when") == 0;
|
||||
}
|
||||
|
||||
/* One `when` level off a def's first argument, or the node unchanged.
|
||||
*
|
||||
* There is deliberately no named-child fallback for a missing `left`. An infix
|
||||
* operator cannot reduce without a left operand, so tree-sitter-elixir never
|
||||
* builds a `when` binary_operator that lacks one: measured over every guarded
|
||||
* shape in the suite plus a corpus of truncated heads (`def when true`,
|
||||
* `def f(x) when`, `def f(x) when when is_x(x)`, `def when() when when`),
|
||||
* `left` was present on all 66 unwraps, ERROR-recovered parses included, and
|
||||
* every one had the left operand as named child 0. Where the left operand was
|
||||
* genuinely absent the grammar produced no `when` operator at all -- it read
|
||||
* `when` as the head identifier -- so the unwrap is never entered. A
|
||||
* named-child fallback would therefore be dead on a well-formed tree and, on
|
||||
* the hypothetical malformed one, would hand back named child 0 without
|
||||
* knowing it is the left operand rather than the guard expression: naming the
|
||||
* function after its own guard. Returning the node unchanged degrades to the
|
||||
* pre-guard behaviour instead, which is the safe direction. */
|
||||
static TSNode elixir_unwrap_one_guard(TSNode first_arg) {
|
||||
if (!cbm_elixir_is_when_guard(first_arg)) {
|
||||
return first_arg;
|
||||
}
|
||||
TSNode lhs = ts_node_child_by_field_name(first_arg, TS_FIELD("left"));
|
||||
return ts_node_is_null(lhs) ? first_arg : lhs;
|
||||
}
|
||||
|
||||
/* The head under every guard a def's first argument carries, or first_arg
|
||||
* unchanged when it carries none. Elixir admits more than one guard on a
|
||||
* clause -- `def f(x) when is_atom(x) when is_binary(x)` -- and
|
||||
* tree-sitter-elixir nests those RIGHT-associatively, measured on that exact
|
||||
* source: `when(f(x), when(is_atom(x), is_binary(x)))`. The declared head is
|
||||
* therefore the outer operator's left operand and one peel reaches it. The
|
||||
* peel still runs to a fixed point: the extra iteration is one token compare,
|
||||
* and it is what makes the helper's contract "the head under EVERY guard"
|
||||
* rather than "the head under the first one", which is the property the three
|
||||
* walks rely on.
|
||||
*
|
||||
* Left wrapped, the defs walk drops the clause, the unified walk opens no
|
||||
* function scope (so every call in a guarded body sources to the FILE node),
|
||||
* the calls walk stops recognising the head and emits it as an invocation of
|
||||
* the very function being defined, and the usages walk swallows every
|
||||
* identifier in the guard as part of the binding. Four walks read this same
|
||||
* argument, which is why the unwrap lives here rather than in any one of
|
||||
* them. */
|
||||
TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg) {
|
||||
for (;;) {
|
||||
TSNode next = elixir_unwrap_one_guard(first_arg);
|
||||
if (ts_node_eq(next, first_arg)) {
|
||||
return first_arg;
|
||||
}
|
||||
first_arg = next;
|
||||
}
|
||||
}
|
||||
|
||||
/* True when `node` is the head a def declares, or any of the `when` guard
|
||||
* operators wrapping it. The calls walk needs the whole chain, not just its
|
||||
* ends: each member reaches that walk by its own route -- `def f(x) when g`
|
||||
* arrives as the inner `f(x)` call, `def f when g` has no inner call at all and
|
||||
* the operator itself falls through to extract_callee_name's first-identifier
|
||||
* last resort -- and a member it fails to recognise as a declaration is emitted
|
||||
* as an invocation of the function being defined. */
|
||||
bool cbm_elixir_def_head_is(TSNode signature, TSNode node) {
|
||||
for (;;) {
|
||||
if (ts_node_eq(signature, node)) {
|
||||
return true;
|
||||
}
|
||||
TSNode next = elixir_unwrap_one_guard(signature);
|
||||
if (ts_node_eq(next, signature)) {
|
||||
return false;
|
||||
}
|
||||
signature = next;
|
||||
}
|
||||
}
|
||||
|
||||
static const char *func_node_name(CBMArena *a, TSNode func_node, const char *source,
|
||||
CBMLanguage lang) {
|
||||
// Wolfram: set_delayed_top/set_top/set_delayed/set — LHS is apply(user_symbol("f"), ...)
|
||||
|
||||
@@ -157,6 +157,26 @@ const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char
|
||||
// attrpath and an enclosing attrset compose into one qualified name.
|
||||
const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name);
|
||||
|
||||
// ── Elixir def-head guards ──
|
||||
// `def f(x) when g` parses its WHOLE head as a `when` binary_operator, so the
|
||||
// `f(x)` call naming the function is the operator's left operand, and more than
|
||||
// one guard (`when a when b`) nests those operators right-associatively, so the
|
||||
// head stays the outermost operator's left operand in either shape. Four
|
||||
// walks read that same first argument — the defs walk names the function from
|
||||
// it, the unified walk opens the function's call scope from it, the calls walk
|
||||
// tells a head apart from an invocation by it, and the usages walk excludes the
|
||||
// head's identifiers from the usage set by it — so a private copy in one of
|
||||
// them makes the rest disagree about what a guarded clause is.
|
||||
// cbm_elixir_is_when_guard answers "is this node a guarded head"; the unwrap
|
||||
// returns the head under every guard, or the node unchanged when it carries
|
||||
// none; cbm_elixir_def_head_is answers whether a node is that head or one of
|
||||
// the guard operators above it, which is the question the calls walk asks.
|
||||
// All three test the operator token, so an operator definition (`def a + b`) is
|
||||
// left alone rather than mis-read as a guard.
|
||||
bool cbm_elixir_is_when_guard(TSNode node);
|
||||
TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg);
|
||||
bool cbm_elixir_def_head_is(TSNode signature, TSNode node);
|
||||
|
||||
// Resolve a function/method definition node's NAME node across all ~130 grammars
|
||||
// (generic `name` field, arrow→declarator, C/C++ declarator chain, plus the many
|
||||
// per-language quirks: Fortran subroutine, SCSS mixin, SQL create_function, R,
|
||||
|
||||
+95
-22
@@ -51,6 +51,10 @@
|
||||
#define CS_USING_INITIAL_CAP 16
|
||||
#define CS_NAMESPACE_INITIAL_CAP 8
|
||||
#define CS_LSP_PARENT_WALK_MAX 32
|
||||
/* Nested-type qualifier walk depth: one level per type nesting step of `A.B.C`
|
||||
* (a namespace-qualified top-level type binds without recursing), so 8 covers
|
||||
* real nesting while a cyclic alias such as `using A = A.B;` stops after 8. */
|
||||
#define CS_QUALIFIER_MAX_DEPTH 8
|
||||
|
||||
extern const TSLanguage *tree_sitter_c_sharp(void);
|
||||
|
||||
@@ -257,11 +261,15 @@ void cs_lsp_init(CSLSPContext *ctx, CBMArena *arena, const char *source, int sou
|
||||
|
||||
/* ── using management ───────────────────────────────────────────── */
|
||||
|
||||
void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_name,
|
||||
const char *target_qn, bool is_global) {
|
||||
/* Append a using directive; `in_namespace` marks one declared inside a
|
||||
* namespace body (CBMCSUsing.in_namespace). */
|
||||
static void cs_add_using_scoped(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_name,
|
||||
const char *target_qn, bool is_global, bool in_namespace) {
|
||||
if (!target_qn)
|
||||
return;
|
||||
/* Dedupe by (kind, local_name, target). */
|
||||
/* Dedupe by (kind, local_name, target). The cross-file entry pre-loads
|
||||
* the pipeline's import list without placement; the AST walk then finds
|
||||
* the same directive and is the authority on where it was declared. */
|
||||
for (int i = 0; i < ctx->using_count; i++) {
|
||||
CBMCSUsing *u = &ctx->usings[i];
|
||||
if (u->kind != kind)
|
||||
@@ -272,6 +280,7 @@ void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_
|
||||
const char *b = u->local_name ? u->local_name : "";
|
||||
if (strcmp(a, b) != 0)
|
||||
continue;
|
||||
u->in_namespace = u->in_namespace || in_namespace;
|
||||
return;
|
||||
}
|
||||
if (ctx->using_count >= ctx->using_cap) {
|
||||
@@ -289,6 +298,12 @@ void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_
|
||||
slot->local_name = local_name ? cbm_arena_strdup(ctx->arena, local_name) : "";
|
||||
slot->target_qn = cbm_arena_strdup(ctx->arena, target_qn);
|
||||
slot->is_global = is_global;
|
||||
slot->in_namespace = in_namespace;
|
||||
}
|
||||
|
||||
void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_name,
|
||||
const char *target_qn, bool is_global) {
|
||||
cs_add_using_scoped(ctx, kind, local_name, target_qn, is_global, false);
|
||||
}
|
||||
|
||||
/* ── namespace stack ────────────────────────────────────────────── */
|
||||
@@ -358,12 +373,29 @@ static const char *cs_try_type_qn(CSLSPContext *ctx, const char *qn) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Append the file's using-namespace targets declared inside (`in_namespace`)
|
||||
* or outside a namespace body. */
|
||||
static int cs_append_using_namespaces(const CSLSPContext *ctx, bool in_namespace, const char **out,
|
||||
int n, int cap) {
|
||||
for (int i = 0; i < ctx->using_count && n < cap; i++) {
|
||||
const CBMCSUsing *u = &ctx->usings[i];
|
||||
if (u->kind == CBM_CS_USING_NAMESPACE && u->in_namespace == in_namespace && u->target_qn &&
|
||||
u->target_qn[0])
|
||||
out[n++] = u->target_qn;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Namespaces through which `qualifier` (the part of a type reference before
|
||||
* its simple name, NULL for a simple name) can reach a top-level type, in C#
|
||||
* lookup order: each enclosing namespace innermost outward, then the global
|
||||
* namespace (qualified references only) or the file's `using` namespaces
|
||||
* (simple names only -- a using-namespace directive does not import nested
|
||||
* namespaces). Returns the count written to `out` (at most `cap`). */
|
||||
* namespace (qualified references only) or, for a simple name, the usings
|
||||
* of the enclosing namespace bodies, the global namespace "" and last the
|
||||
* compilation-unit usings (a using-namespace directive does not import nested
|
||||
* namespaces). C# consults the members of the global namespace BEFORE the
|
||||
* compilation unit's usings: a top-level type declared without a namespace
|
||||
* hides an imported type of the same name. Returns the count written to
|
||||
* `out` (at most `cap`). */
|
||||
static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const char **out,
|
||||
int cap) {
|
||||
int n = 0;
|
||||
@@ -378,12 +410,13 @@ static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const
|
||||
out[n++] = qualifier;
|
||||
return n;
|
||||
}
|
||||
for (int i = 0; i < ctx->using_count && n < cap; i++) {
|
||||
const CBMCSUsing *u = &ctx->usings[i];
|
||||
if (u->kind == CBM_CS_USING_NAMESPACE && u->target_qn && u->target_qn[0])
|
||||
out[n++] = u->target_qn;
|
||||
}
|
||||
return n;
|
||||
n = cs_append_using_namespaces(ctx, true, out, n, cap);
|
||||
/* The global namespace (#2120 residual: such types used to be invisible
|
||||
* to step 8b) ranks after every enclosing namespace and namespace-body
|
||||
* using, and before the compilation-unit usings. */
|
||||
if (n < cap)
|
||||
out[n++] = "";
|
||||
return cs_append_using_namespaces(ctx, false, out, n, cap);
|
||||
}
|
||||
|
||||
/* Bind a (possibly namespace-qualified) type reference to the project type
|
||||
@@ -421,6 +454,24 @@ static const char *cs_resolve_by_declared_namespace(CSLSPContext *ctx, const cha
|
||||
}
|
||||
}
|
||||
}
|
||||
/* No namespace-qualified hit: a qualified `A.B` may name a NESTED type —
|
||||
* A a type in scope (resolved recursively, one segment shorter), B a
|
||||
* member type of it. Nested QNs are the parent QN plus the simple name,
|
||||
* which step 8b's namespace scan can never produce because nested types
|
||||
* carry no namespace_qn (#2120 residual: `new Command.Result(...)`,
|
||||
* `new Grain.Handler(dep).Handle(...)`). The walk is depth-bounded: an
|
||||
* alias whose target names the alias itself (`using A = A.B;`, or a
|
||||
* mutual pair) re-enters this path through step 8 with no end. */
|
||||
if (!best && qualifier && ctx->qualifier_depth < CS_QUALIFIER_MAX_DEPTH) {
|
||||
ctx->qualifier_depth++;
|
||||
const char *qtype = cs_resolve_type_name(ctx, qualifier);
|
||||
ctx->qualifier_depth--;
|
||||
if (qtype) {
|
||||
const char *nested = cbm_arena_sprintf(ctx->arena, "%s.%s", qtype, simple);
|
||||
if (cs_lookup_type_qn(ctx, nested))
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return best ? best->qualified_name : NULL;
|
||||
}
|
||||
|
||||
@@ -610,10 +661,14 @@ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) {
|
||||
}
|
||||
|
||||
/* Other top-level declarations of the type `t` names: same simple name, same
|
||||
* declared namespace, different entry. Registration order; at most `cap`. */
|
||||
* declared namespace, different entry. Registration order; at most `cap`.
|
||||
* A type in the global namespace ("") has no twins: without a namespace the
|
||||
* same name across a repository is a different type per assembly (every test
|
||||
* project's own `Program`), so pooling them would bind a member of one to an
|
||||
* unrelated other, ahead of the type's own base classes. */
|
||||
static int cs_namespace_twins(CSLSPContext *ctx, const CBMRegisteredType *t,
|
||||
const CBMRegisteredType **out, int cap) {
|
||||
if (!ctx->registry || !t || !t->namespace_qn || !t->short_name)
|
||||
if (!ctx->registry || !t || !t->namespace_qn || !t->namespace_qn[0] || !t->short_name)
|
||||
return 0;
|
||||
int n = 0;
|
||||
CBMTypeShortIter it;
|
||||
@@ -2797,12 +2852,19 @@ static void cs_process_type_decl(CSLSPContext *ctx, TSNode node) {
|
||||
|
||||
static void cs_collect_imports(CSLSPContext *ctx, TSNode root) {
|
||||
/* Walk the entire tree once at top — using directives can appear at file
|
||||
* scope or inside namespace blocks. */
|
||||
TSNode stack[256];
|
||||
* scope or inside namespace blocks. `in_ns` records per stacked node
|
||||
* whether it sits inside a namespace declaration: a block namespace's
|
||||
* body, or the compilation-unit siblings after a file-scoped namespace
|
||||
* (the grammar may attach those either way). */
|
||||
enum { CS_IMPORT_WALK_MAX = 256 };
|
||||
TSNode stack[CS_IMPORT_WALK_MAX];
|
||||
bool in_ns[CS_IMPORT_WALK_MAX];
|
||||
int top = 0;
|
||||
in_ns[top] = false;
|
||||
stack[top++] = root;
|
||||
while (top > 0) {
|
||||
TSNode n = stack[--top];
|
||||
bool n_in_ns = in_ns[top];
|
||||
if (ts_node_is_null(n))
|
||||
continue;
|
||||
const char *k = ts_node_type(n);
|
||||
@@ -2886,20 +2948,23 @@ static void cs_collect_imports(CSLSPContext *ctx, TSNode root) {
|
||||
}
|
||||
if (target) {
|
||||
if (is_alias) {
|
||||
cs_lsp_add_using(ctx, CBM_CS_USING_ALIAS, alias_name ? alias_name : "", target,
|
||||
is_global);
|
||||
cs_add_using_scoped(ctx, CBM_CS_USING_ALIAS, alias_name ? alias_name : "",
|
||||
target, is_global, n_in_ns);
|
||||
} else if (is_static) {
|
||||
cs_lsp_add_using(ctx, CBM_CS_USING_STATIC, "", target, is_global);
|
||||
cs_add_using_scoped(ctx, CBM_CS_USING_STATIC, "", target, is_global, n_in_ns);
|
||||
} else {
|
||||
cs_lsp_add_using(ctx, CBM_CS_USING_NAMESPACE, "", target, is_global);
|
||||
cs_add_using_scoped(ctx, CBM_CS_USING_NAMESPACE, "", target, is_global,
|
||||
n_in_ns);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Push children for traversal. We don't recurse into method bodies
|
||||
* (using directives can't appear there), but namespace bodies may
|
||||
* contain more usings. */
|
||||
bool child_in_ns = n_in_ns || strcmp(k, "namespace_declaration") == 0 ||
|
||||
strcmp(k, "file_scoped_namespace_declaration") == 0;
|
||||
uint32_t cnc = ts_node_child_count(n);
|
||||
for (uint32_t i = 0; i < cnc && top < 256; i++) {
|
||||
for (uint32_t i = 0; i < cnc && top < CS_IMPORT_WALK_MAX; i++) {
|
||||
TSNode c = ts_node_child(n, i);
|
||||
if (ts_node_is_null(c))
|
||||
continue;
|
||||
@@ -2908,7 +2973,11 @@ static void cs_collect_imports(CSLSPContext *ctx, TSNode root) {
|
||||
strcmp(ck, "constructor_declaration") == 0 || strcmp(ck, "block") == 0) {
|
||||
continue;
|
||||
}
|
||||
in_ns[top] = child_in_ns;
|
||||
stack[top++] = c;
|
||||
/* A file-scoped namespace covers the siblings that follow it. */
|
||||
if (strcmp(ck, "file_scoped_namespace_declaration") == 0)
|
||||
child_in_ns = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3749,12 +3818,16 @@ void cbm_run_cs_lsp(CBMArena *arena, CBMFileResult *result, const char *source,
|
||||
* type is not reachable by its simple name through a namespace). */
|
||||
static const char *cs_top_level_namespace(const char *type_qn, const char *short_name,
|
||||
const char *module_qn, const char *namespace_name) {
|
||||
if (!type_qn || !short_name || !module_qn || !namespace_name || !namespace_name[0])
|
||||
if (!type_qn || !short_name || !module_qn)
|
||||
return NULL;
|
||||
size_t mlen = strlen(module_qn);
|
||||
if (strncmp(type_qn, module_qn, mlen) != 0 || type_qn[mlen] != '.' ||
|
||||
strcmp(type_qn + mlen + 1, short_name) != 0)
|
||||
return NULL;
|
||||
/* Top-level C# type: its declared namespace, "" for the global namespace
|
||||
* so step 8b can bind it, NULL when extraction could not tell (a parse
|
||||
* error dissolved the namespace; see cs_type_decl_namespace). NULL stays
|
||||
* invisible to step 8b, as do nested types. */
|
||||
return namespace_name;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ typedef struct {
|
||||
const char *local_name; /* alias name; "" for non-alias */
|
||||
const char *target_qn; /* dotted target QN */
|
||||
bool is_global; /* `global using` */
|
||||
/* Declared inside a namespace body rather than at compilation-unit level.
|
||||
* C# consults a namespace declaration's usings right after that
|
||||
* namespace's own members, but compilation-unit usings only after the
|
||||
* global namespace's members, so the two rank on opposite sides of it. */
|
||||
bool in_namespace;
|
||||
} CBMCSUsing;
|
||||
|
||||
/* CSLSPContext — per-file type-evaluation state. */
|
||||
@@ -111,6 +116,11 @@ typedef struct {
|
||||
/* Recursion guard for cs_eval_expr_type. */
|
||||
int eval_depth;
|
||||
|
||||
/* Recursion guard for the nested-type qualifier walk of
|
||||
* cs_resolve_type_name: resolving `A.B` resolves the qualifier `A` as a
|
||||
* type first, and a `using A = A.B;` alias cycles back into it. */
|
||||
int qualifier_depth;
|
||||
|
||||
/* Debug mode (CBM_LSP_DEBUG env). */
|
||||
bool debug;
|
||||
} CSLSPContext;
|
||||
|
||||
@@ -46,6 +46,25 @@ static const lib_pattern_t http_libraries[] = {
|
||||
* case-sensitive, so without this entry `Axios.get(url)` fell through to
|
||||
* the `.get` route-suffix fallback and became a route registration. */
|
||||
{"Axios", CBM_SVC_HTTP, NULL},
|
||||
/* Angular HttpClient (#2768). The receiver rows end in a dot, like "gin.":
|
||||
* a bare "this.http" / "$http" would also match this.httpAdapter.get
|
||||
* (NestJS route registration) and $httpBackend.expectGET (an AngularJS test
|
||||
* double), because an uppercase letter after an id counts as CamelCase
|
||||
* glue. A chain wrapped before the verb keeps its line break in the callee
|
||||
* text ("this.http\n .post", prettier's style), so each receiver also
|
||||
* has a line-break twin: LF, and CR for CRLF files. Java/C# HttpClient is
|
||||
* already listed below. */
|
||||
{"@angular/common/http", CBM_SVC_HTTP, NULL},
|
||||
{"angular.common.http", CBM_SVC_HTTP, NULL},
|
||||
{"this.http.", CBM_SVC_HTTP, NULL},
|
||||
{"this.http\n", CBM_SVC_HTTP, NULL},
|
||||
{"this.http\r", CBM_SVC_HTTP, NULL},
|
||||
{"httpClient.", CBM_SVC_HTTP, NULL},
|
||||
{"httpClient\n", CBM_SVC_HTTP, NULL},
|
||||
{"httpClient\r", CBM_SVC_HTTP, NULL},
|
||||
{"$http.", CBM_SVC_HTTP, NULL},
|
||||
{"$http\n", CBM_SVC_HTTP, NULL},
|
||||
{"$http\r", CBM_SVC_HTTP, NULL},
|
||||
{"superagent", CBM_SVC_HTTP, NULL},
|
||||
{"needle", CBM_SVC_HTTP, NULL},
|
||||
{"node-fetch", CBM_SVC_HTTP, NULL},
|
||||
|
||||
@@ -74,9 +74,9 @@ src/pipeline/pass_semantic.c 3
|
||||
src/pipeline/pass_tests.c 3
|
||||
src/pipeline/pass_usages.c 4
|
||||
src/pipeline/path_alias.c 40
|
||||
src/pipeline/pipeline.c 132
|
||||
src/pipeline/pipeline.c 129
|
||||
src/pipeline/pipeline_delta.c 19
|
||||
src/pipeline/pipeline_incremental.c 140
|
||||
src/pipeline/pipeline_incremental.c 130
|
||||
src/pipeline/registry.c 17
|
||||
src/pipeline/worker_pool.c 2
|
||||
src/store/store.c 354
|
||||
|
||||
+76
-5
@@ -2969,7 +2969,26 @@ int cbm_remove_zed_mcp_owned(const char *binary_path, const char *config_path) {
|
||||
|
||||
static bool dir_exists(const char *path) {
|
||||
cbm_path_info_t info = {0};
|
||||
return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_directory;
|
||||
if (cbm_path_info_utf8(path, &info) != CBM_PATH_INFO_OK) {
|
||||
return false;
|
||||
}
|
||||
if (info.is_directory) {
|
||||
return true;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
if (info.is_symlink) {
|
||||
uid_t self = geteuid();
|
||||
struct stat link_state;
|
||||
if (lstat(path, &link_state) != 0 || !S_ISLNK(link_state.st_mode) ||
|
||||
link_state.st_uid != self) {
|
||||
return false;
|
||||
}
|
||||
struct stat target_state;
|
||||
return stat(path, &target_state) == 0 && S_ISDIR(target_state.st_mode) &&
|
||||
target_state.st_uid == self;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Resolve the Claude Code config dir.
|
||||
@@ -7713,6 +7732,13 @@ static const char *get_cache_dir(const char *home_dir) {
|
||||
return cbm_resolve_cache_dir();
|
||||
}
|
||||
|
||||
/* A per-project ADR sidecar ("<db>.adr.db") sits in the cache beside its index
|
||||
* but is not itself an index — exclude it from listing/counting. */
|
||||
static bool cli_is_adr_sidecar(const char *name, size_t len) {
|
||||
const size_t ext = sizeof(".adr.db") - 1;
|
||||
return len >= ext && strcmp(name + len - ext, ".adr.db") == 0;
|
||||
}
|
||||
|
||||
int cbm_list_indexes(const char *home_dir) {
|
||||
const char *cache_dir = get_cache_dir(home_dir);
|
||||
if (!cache_dir) {
|
||||
@@ -7727,6 +7753,7 @@ int cbm_list_indexes(const char *home_dir) {
|
||||
int count = 0;
|
||||
cbm_dirent_t *ent;
|
||||
while ((ent = cbm_readdir(d)) != NULL) {
|
||||
/* cbm_is_project_index_db already excludes the ".adr.db" sidecar. */
|
||||
if (cbm_is_project_index_db(ent->name)) {
|
||||
printf(" %s/%s\n", cache_dir, ent->name);
|
||||
count++;
|
||||
@@ -7750,9 +7777,18 @@ int cbm_remove_indexes(const char *home_dir) {
|
||||
int count = 0;
|
||||
cbm_dirent_t *ent;
|
||||
while ((ent = cbm_readdir(d)) != NULL) {
|
||||
if (cbm_is_project_index_db(ent->name)) {
|
||||
char path[CLI_BUF_1K];
|
||||
snprintf(path, sizeof(path), "%s/%s", cache_dir, ent->name);
|
||||
bool is_index = cbm_is_project_index_db(ent->name);
|
||||
/* A "<db>.adr.db" ADR sidecar is excluded from cbm_is_project_index_db,
|
||||
* but a full index wipe must still remove it (and any orphan whose index
|
||||
* is already gone) — otherwise the next re-index of that name inherits a
|
||||
* stale ADR. It appears as its own dirent, so remove it directly. */
|
||||
bool is_adr = cli_is_adr_sidecar(ent->name, strlen(ent->name));
|
||||
if (!is_index && !is_adr) {
|
||||
continue;
|
||||
}
|
||||
char path[CLI_BUF_1K];
|
||||
snprintf(path, sizeof(path), "%s/%s", cache_dir, ent->name);
|
||||
if (is_index) {
|
||||
/* Also remove .db.tmp if present */
|
||||
char tmp_path[CLI_FIELD_1040];
|
||||
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path);
|
||||
@@ -7766,6 +7802,11 @@ int cbm_remove_indexes(const char *home_dir) {
|
||||
* its .db. Sidecars are not indexes, so count is unchanged. */
|
||||
cbm_remove_db_sidecars(path);
|
||||
cbm_remove_db_sidecars(tmp_path);
|
||||
} else {
|
||||
/* ADR sidecar: remove it and its own -wal/-shm/-journal; not an
|
||||
* index, so count is unchanged. */
|
||||
cbm_unlink(path);
|
||||
cbm_remove_db_sidecars(path);
|
||||
}
|
||||
}
|
||||
cbm_closedir(d);
|
||||
@@ -10058,6 +10099,35 @@ static void install_devin_durable_context(const cbm_agent_registry_context_t *re
|
||||
static void install_generated_client_extension(const char *label, const char *path,
|
||||
const char *binary_path,
|
||||
char *(*generate)(const char *), bool dry_run) {
|
||||
/* #2204: a module that already carries content but none of our markers is
|
||||
* the user's. The generated header promises "remove the markers to take
|
||||
* ownership", and appending our prelude to a foreign module produces a file
|
||||
* the client cannot even parse — the prelude redeclares the user's own
|
||||
* import and const names, and both sides declare a default export. Probe
|
||||
* ownership before generating so the preview names the refusal instead of
|
||||
* promising a write the run will not make. A probe failure (-1) falls
|
||||
* through to the write, which reports it through the usual error path. */
|
||||
bool foreign =
|
||||
cbm_text_managed_block_status(path, CBM_ADAPTER_MARKER_START, CBM_ADAPTER_MARKER_END) == 1;
|
||||
if (foreign) {
|
||||
/* The --plan receipt (#388) lists only writes the run will make, and
|
||||
* its stdout carries the receipt JSON, so plan mode records nothing
|
||||
* and prints nothing here. The dry run names the skip instead. */
|
||||
if (g_install_plan) {
|
||||
return;
|
||||
}
|
||||
if (dry_run) {
|
||||
printf(" extension: %s would be skipped — the file there is not ours (no "
|
||||
"codebase-memory-mcp markers), so it is left untouched; delete it or add the "
|
||||
"markers to let the installer manage it\n",
|
||||
path);
|
||||
} else {
|
||||
printf(" extension: %s left untouched — the file there is not ours (no "
|
||||
"codebase-memory-mcp markers)\n",
|
||||
path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (g_install_plan) {
|
||||
plan_record(label, "extension", path);
|
||||
return;
|
||||
@@ -11265,7 +11335,8 @@ static int count_db_indexes(const char *home) {
|
||||
int count = 0;
|
||||
cbm_dirent_t *ent;
|
||||
while ((ent = cbm_readdir(d)) != NULL) {
|
||||
if (cbm_is_project_index_db(ent->name)) {
|
||||
if (cbm_is_project_index_db(ent->name) &&
|
||||
!cli_is_adr_sidecar(ent->name, strlen(ent->name))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
+73
-13
@@ -1020,6 +1020,42 @@ static int text_validate_markers(const char *begin_marker, const char *end_marke
|
||||
return TEXT_OK;
|
||||
}
|
||||
|
||||
/* A managed document as read from disk, with our block located in it. */
|
||||
typedef struct {
|
||||
char *data;
|
||||
size_t len;
|
||||
size_t bom_len;
|
||||
text_file_snapshot_t snapshot;
|
||||
text_managed_region_t region;
|
||||
} text_managed_document_t;
|
||||
|
||||
/* The read-validate-scan step shared by the upsert and the read-only ownership
|
||||
* probe, so both judge a file by the same rules. An absent file reads as an
|
||||
* empty document. On success the caller owns doc->data and releases it; on
|
||||
* failure nothing is left allocated. */
|
||||
static int text_read_managed_document(const char *file_path, const char *begin_marker,
|
||||
size_t begin_len, const char *end_marker, size_t end_len,
|
||||
text_managed_document_t *doc) {
|
||||
doc->data = NULL;
|
||||
doc->len = 0U;
|
||||
doc->bom_len = 0U;
|
||||
int status = text_read_file(file_path, &doc->data, &doc->len, &doc->snapshot);
|
||||
if (status == TEXT_OK) {
|
||||
status = text_validate_bytes(doc->data, doc->len, 1);
|
||||
}
|
||||
if (status == TEXT_OK) {
|
||||
doc->bom_len = text_bom_length(doc->data, doc->len);
|
||||
status = text_scan_managed_region(doc->data, doc->len, doc->bom_len, begin_marker,
|
||||
begin_len, end_marker, end_len, &doc->region);
|
||||
}
|
||||
if (status != TEXT_OK) {
|
||||
free(doc->data);
|
||||
doc->data = NULL;
|
||||
return TEXT_ERROR;
|
||||
}
|
||||
return TEXT_OK;
|
||||
}
|
||||
|
||||
static int text_upsert_managed_block(const char *file_path, const char *begin_marker,
|
||||
const char *end_marker, const char *owned_content,
|
||||
size_t max_document_bytes) {
|
||||
@@ -1036,21 +1072,16 @@ static int text_upsert_managed_block(const char *file_path, const char *begin_ma
|
||||
return TEXT_ERROR;
|
||||
}
|
||||
|
||||
char *old_data = NULL;
|
||||
size_t old_len = 0U;
|
||||
text_file_snapshot_t snapshot;
|
||||
if (text_read_file(file_path, &old_data, &old_len, &snapshot) != TEXT_OK ||
|
||||
text_validate_bytes(old_data, old_len, 1) != TEXT_OK) {
|
||||
free(old_data);
|
||||
return TEXT_ERROR;
|
||||
}
|
||||
size_t bom_len = text_bom_length(old_data, old_len);
|
||||
text_managed_region_t region;
|
||||
if (text_scan_managed_region(old_data, old_len, bom_len, begin_marker, begin_len, end_marker,
|
||||
end_len, ®ion) != TEXT_OK) {
|
||||
free(old_data);
|
||||
text_managed_document_t doc;
|
||||
int read_status =
|
||||
text_read_managed_document(file_path, begin_marker, begin_len, end_marker, end_len, &doc);
|
||||
if (read_status != TEXT_OK) {
|
||||
return TEXT_ERROR;
|
||||
}
|
||||
char *old_data = doc.data;
|
||||
size_t old_len = doc.len;
|
||||
text_file_snapshot_t snapshot = doc.snapshot;
|
||||
text_managed_region_t region = doc.region;
|
||||
|
||||
text_buffer_t updated = {0};
|
||||
int result = TEXT_ERROR;
|
||||
@@ -1070,6 +1101,7 @@ static int text_upsert_managed_block(const char *file_path, const char *begin_ma
|
||||
&snapshot);
|
||||
}
|
||||
} else {
|
||||
size_t bom_len = doc.bom_len;
|
||||
const char *eol = NULL;
|
||||
size_t eol_len = 0U;
|
||||
text_detect_eol(old_data + bom_len, old_len - bom_len, &eol, &eol_len);
|
||||
@@ -1105,6 +1137,34 @@ int cbm_text_upsert_managed_block_limited(const char *file_path, const char *beg
|
||||
max_document_bytes);
|
||||
}
|
||||
|
||||
int cbm_text_managed_block_status(const char *file_path, const char *begin_marker,
|
||||
const char *end_marker) {
|
||||
size_t begin_len = 0U;
|
||||
size_t end_len = 0U;
|
||||
if (!text_valid_path(file_path) ||
|
||||
text_validate_markers(begin_marker, end_marker, &begin_len, &end_len) != TEXT_OK) {
|
||||
return TEXT_ERROR;
|
||||
}
|
||||
text_managed_document_t doc;
|
||||
int read_status =
|
||||
text_read_managed_document(file_path, begin_marker, begin_len, end_marker, end_len, &doc);
|
||||
if (read_status != TEXT_OK) {
|
||||
return TEXT_ERROR;
|
||||
}
|
||||
int result = TEXT_OK;
|
||||
if (!doc.region.present) {
|
||||
for (size_t pos = doc.bom_len; pos < doc.len; pos++) {
|
||||
char byte = doc.data[pos];
|
||||
if (byte != ' ' && byte != '\t' && byte != '\r' && byte != '\n') {
|
||||
result = TEXT_UNOWNED;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
free(doc.data);
|
||||
return result;
|
||||
}
|
||||
|
||||
int cbm_text_remove_managed_block(const char *file_path, const char *begin_marker,
|
||||
const char *end_marker) {
|
||||
size_t begin_len = 0U;
|
||||
|
||||
@@ -11,6 +11,16 @@ int cbm_text_upsert_managed_block(const char *file_path, const char *begin_marke
|
||||
int cbm_text_upsert_managed_block_limited(const char *file_path, const char *begin_marker,
|
||||
const char *end_marker, const char *owned_content,
|
||||
size_t max_document_bytes);
|
||||
/* Read-only ownership probe for a managed block, used before writing a file a
|
||||
* client auto-loads as a module. Returns 0 when the block may be written (no
|
||||
* file, a file whose content is only whitespace, or a file already carrying our
|
||||
* markers), 1 when the file carries content but none of our markers — the
|
||||
* caller must leave the user's file alone and say so rather than append to it,
|
||||
* because the generated block redeclares module-level names a hand-ported
|
||||
* module also declares (#2204) — and -1 for unsafe state or I/O failure. Never
|
||||
* writes, so a dry run may call it. */
|
||||
int cbm_text_managed_block_status(const char *file_path, const char *begin_marker,
|
||||
const char *end_marker);
|
||||
int cbm_text_remove_managed_block(const char *file_path, const char *begin_marker,
|
||||
const char *end_marker);
|
||||
|
||||
|
||||
@@ -2146,6 +2146,9 @@ static bool application_index_args_normalize_defaults(yyjson_mut_val *root) {
|
||||
if (name && (!yyjson_mut_is_str(name) || yyjson_mut_get_len(name) == 0)) {
|
||||
(void)yyjson_mut_obj_remove_key(root, "name");
|
||||
}
|
||||
/* Automatic jobs carry _cbm_background, which picks the worker count and
|
||||
* never what is indexed, so an explicit request joins them. */
|
||||
(void)yyjson_mut_obj_remove_key(root, "_cbm_background");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2559,10 +2562,10 @@ static char *application_auto_index_args(cbm_daemon_application_t *application,
|
||||
return NULL;
|
||||
}
|
||||
yyjson_mut_doc_set_root(document, root);
|
||||
char *args = yyjson_mut_obj_add_strcpy(document, root, "repo_path", root_path) &&
|
||||
application_index_args_add_policy(application, document, root)
|
||||
? yyjson_mut_write(document, 0, NULL)
|
||||
: NULL;
|
||||
bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", root_path) &&
|
||||
application_index_args_add_policy(application, document, root) &&
|
||||
yyjson_mut_obj_add_bool(document, root, "_cbm_background", true);
|
||||
char *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL;
|
||||
yyjson_mut_doc_free(document);
|
||||
return args;
|
||||
}
|
||||
@@ -4423,7 +4426,8 @@ static int application_background_index(cbm_daemon_application_t *application,
|
||||
}
|
||||
yyjson_mut_doc_set_root(document, root);
|
||||
bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", canonical_root) &&
|
||||
application_index_args_add_policy(application, document, root);
|
||||
application_index_args_add_policy(application, document, root) &&
|
||||
yyjson_mut_obj_add_bool(document, root, "_cbm_background", true);
|
||||
char *default_project = cbm_project_name_from_path(canonical_root);
|
||||
bool custom_project =
|
||||
project_name[0] && (!default_project || strcmp(default_project, project_name) != 0);
|
||||
|
||||
@@ -317,7 +317,9 @@ bool cbm_is_internal_cache_db(const char *filename) {
|
||||
|
||||
bool cbm_is_project_index_db(const char *filename) {
|
||||
static const char db_ext[] = ".db";
|
||||
static const char adr_ext[] = ".adr.db";
|
||||
const size_t ext_len = sizeof(db_ext) - 1;
|
||||
const size_t adr_len = sizeof(adr_ext) - 1;
|
||||
if (!filename) {
|
||||
return false;
|
||||
}
|
||||
@@ -325,6 +327,12 @@ bool cbm_is_project_index_db(const char *filename) {
|
||||
if (len <= ext_len || strcmp(filename + len - ext_len, db_ext) != 0) {
|
||||
return false;
|
||||
}
|
||||
/* A per-project ADR sidecar ("<db>.adr.db") ends in ".db" but is not itself
|
||||
* an index — exclude it from every index scan/count (MCP loops + the CLI's
|
||||
* cbm_list_indexes / count_db_indexes all gate on this helper). */
|
||||
if (len >= adr_len && strcmp(filename + len - adr_len, adr_ext) == 0) {
|
||||
return false;
|
||||
}
|
||||
return !cbm_is_internal_cache_db(filename);
|
||||
}
|
||||
|
||||
|
||||
+51
-6
@@ -769,7 +769,7 @@ static const tool_def_t TOOLS[] = {
|
||||
"\"offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0},"
|
||||
"\"metadata_only\":{\"type\":\"boolean\",\"default\":false,"
|
||||
"\"description\":\"Compatibility: omit counts, size, and branch.\"}}}"},
|
||||
{"delete_project", "Delete a project from the index",
|
||||
{"delete_project", "Delete a project from the index, including its ADR store",
|
||||
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
|
||||
"\"project\"]}"},
|
||||
|
||||
@@ -7885,7 +7885,9 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/* delete_project: just erase the .db file (and WAL/SHM). */
|
||||
/* delete_project: erase the .db file (and WAL/SHM) plus the ADR sidecar
|
||||
* "<db>.adr.db" (and its WAL/SHM/journal), so deleting a project removes its
|
||||
* ADR store with it. */
|
||||
static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) {
|
||||
char *name = get_project_arg(args);
|
||||
if (!name) {
|
||||
@@ -7924,18 +7926,37 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) {
|
||||
const char *error_detail = NULL;
|
||||
bool is_error = false;
|
||||
|
||||
/* ADR sidecar lives beside the graph DB as "<db>.adr.db" — remove it (and
|
||||
* its own WAL/SHM/journal) whenever the project is deleted. Sized as the
|
||||
* graph path plus the ".adr.db" suffix. */
|
||||
char adr_path[CBM_SZ_1K + sizeof(".adr.db")];
|
||||
int adr_n = snprintf(adr_path, sizeof(adr_path), "%s.adr.db", path);
|
||||
bool have_adr_path = adr_n > 0 && (size_t)adr_n < sizeof(adr_path);
|
||||
|
||||
if (exists) {
|
||||
int rc = cbm_unlink(path);
|
||||
(void)cbm_unlink(wal);
|
||||
(void)cbm_unlink(shm);
|
||||
if (rc == 0) {
|
||||
status = "deleted";
|
||||
/* Only drop the ADR store once the graph DB is actually gone, so a
|
||||
* failed graph delete leaves the project (and its ADR) intact. */
|
||||
if (have_adr_path) {
|
||||
(void)cbm_unlink(adr_path);
|
||||
(void)cbm_remove_db_sidecars(adr_path);
|
||||
}
|
||||
} else {
|
||||
status = "delete_failed";
|
||||
error_detail = strerror(errno);
|
||||
is_error = true;
|
||||
}
|
||||
} else {
|
||||
/* No graph DB, but a sidecar could still linger (e.g. ADR written then
|
||||
* the index deleted out of band) — best-effort clean it up. */
|
||||
if (have_adr_path) {
|
||||
(void)cbm_unlink(adr_path);
|
||||
(void)cbm_remove_db_sidecars(adr_path);
|
||||
}
|
||||
is_error = true;
|
||||
}
|
||||
|
||||
@@ -12449,7 +12470,8 @@ static char *index_run_supervised_path(cbm_mcp_server_t *srv, const char *root_p
|
||||
* worker reads no config); unset, the worker's index mode decides. */
|
||||
if (!yyjson_mut_obj_add_strcpy(doc, root, "repo_path", root_path) ||
|
||||
!cbm_mcp_index_policy_add_to_args(doc, root, &policy) ||
|
||||
!cbm_mcp_index_doc_candidates_add_to_args(doc, root, srv ? srv->config : NULL)) {
|
||||
!cbm_mcp_index_doc_candidates_add_to_args(doc, root, srv ? srv->config : NULL) ||
|
||||
!yyjson_mut_obj_add_bool(doc, root, "_cbm_background", true)) {
|
||||
yyjson_mut_doc_free(doc);
|
||||
return NULL;
|
||||
}
|
||||
@@ -12752,6 +12774,22 @@ static bool index_root_owner_resolve(const char *repo_path, char **owner_out, ch
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Background CPU headroom is for a refresh of a graph that already serves.
|
||||
* A project's first index keeps every core, even when it was started
|
||||
* automatically: the caller is waiting on a graph that does not exist yet.
|
||||
* An empty or unreadable file is not a committed index. Both sites that
|
||||
* honour automatic indexing — handle_index_repository's _cbm_background
|
||||
* flag and the in-process autoindex_thread — go through this so they
|
||||
* cannot drift. */
|
||||
static bool project_has_committed_index(const char *project) {
|
||||
if (!project || !project[0]) {
|
||||
return false;
|
||||
}
|
||||
char db_path[CBM_SZ_1K];
|
||||
project_db_path(project, db_path, sizeof(db_path));
|
||||
return db_path[0] && project_db_is_servable(project, db_path);
|
||||
}
|
||||
|
||||
/* The three heap strings handle_index_repository owns from
|
||||
* cbm_mcp_get_string_arg / resolved_repo_path_from_project_arg. One release
|
||||
* point keeps the dozen early-return paths in step; free(NULL) is a no-op, so
|
||||
@@ -12881,6 +12919,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
|
||||
char *repo_path = cbm_mcp_get_string_arg(args, "repo_path");
|
||||
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
|
||||
char *name_override = cbm_mcp_get_string_arg(args, "name");
|
||||
bool background = cbm_mcp_get_bool_arg(args, "_cbm_background");
|
||||
cbm_normalize_path_sep(repo_path);
|
||||
|
||||
if (!repo_path) {
|
||||
@@ -13053,6 +13092,10 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
|
||||
return cbm_mcp_text_result("invalid project name", true);
|
||||
}
|
||||
free(name_override);
|
||||
/* Headroom follows the final project name, decided before artifact
|
||||
* bootstrap can create the first local copy of this index. */
|
||||
cbm_pipeline_set_background(p, background &&
|
||||
project_has_committed_index(cbm_pipeline_project_name(p)));
|
||||
cbm_pipeline_set_persistence(p, persistence);
|
||||
cbm_pipeline_set_resource_policy(p, &resource_policy);
|
||||
index_doc_candidates_apply(p, doc_candidates);
|
||||
@@ -20792,9 +20835,10 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ADRs are stored in the SQLite store (project_summaries), the SAME
|
||||
* backend the UI /api/adr endpoints use — so writes via the MCP tool and
|
||||
* the UI are visible to each other (#256). */
|
||||
/* ADRs are stored in the per-project ADR sidecar DB ("<db>.adr.db"), the
|
||||
* SAME backend the UI /api/adr endpoints use (via cbm_store_adr_*) — so
|
||||
* writes via the MCP tool and the UI are visible to each other (#256), and
|
||||
* a reindex of the graph DB never disturbs them. */
|
||||
store_recovery_status_t recovery_status = STORE_RECOVERY_NONE;
|
||||
cbm_store_t *resolved =
|
||||
resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status, true);
|
||||
@@ -21462,6 +21506,7 @@ static void *autoindex_thread(void *arg) {
|
||||
return NULL;
|
||||
}
|
||||
index_doc_candidates_apply(p, index_doc_candidates_choice(srv, NULL));
|
||||
cbm_pipeline_set_background(p, project_has_committed_index(cbm_pipeline_project_name(p)));
|
||||
|
||||
/* Block until any concurrent pipeline finishes */
|
||||
cbm_pipeline_lock();
|
||||
|
||||
+91
-8
@@ -884,13 +884,54 @@ static void artifact_snapshot_tmp_close(artifact_snapshot_tmp_t *tmp) {
|
||||
tmp->dir[0] = '\0';
|
||||
}
|
||||
|
||||
/* Make the snapshot's project_summaries carry exactly the current ADR — and
|
||||
* nothing stale. ADRs now live in the "<db>.adr.db" sidecar, but a legacy
|
||||
* pre-sidecar row can still linger in the graph DB and gets copied into the
|
||||
* snapshot by VACUUM INTO; incremental/closure-repair clones keep carrying it.
|
||||
* So ALWAYS clear project_summaries first, then insert only when an ADR is
|
||||
* present (`adr` is NULL when the sidecar has none). Otherwise a no-ADR export
|
||||
* would ship the stale row and a teammate's import would restore a decision
|
||||
* record that was deleted. Best-effort: on any failure the artifact simply
|
||||
* omits the ADR, as before the sidecar existed. */
|
||||
static void snapshot_inject_adr(const char *snapshot_path, const cbm_adr_t *adr) {
|
||||
sqlite3 *db = NULL;
|
||||
if (sqlite3_open_v2(snapshot_path, &db, SQLITE_OPEN_READWRITE, NULL) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return;
|
||||
}
|
||||
sqlite3_exec(db,
|
||||
"CREATE TABLE IF NOT EXISTS project_summaries ("
|
||||
" project TEXT PRIMARY KEY, summary TEXT NOT NULL, source_hash TEXT NOT NULL,"
|
||||
" created_at TEXT NOT NULL, updated_at TEXT NOT NULL);",
|
||||
NULL, NULL, NULL);
|
||||
/* Drop any stale row (a deleted ADR must not resurrect through the export). */
|
||||
sqlite3_exec(db, "DELETE FROM project_summaries;", NULL, NULL, NULL);
|
||||
if (adr && adr->project && adr->content) {
|
||||
sqlite3_stmt *st = NULL;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT INTO project_summaries "
|
||||
"(project, summary, source_hash, created_at, updated_at) "
|
||||
"VALUES (?1, ?2, '', ?3, ?4);",
|
||||
-1, &st, NULL) == SQLITE_OK) {
|
||||
sqlite3_bind_text(st, 1, adr->project, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(st, 2, adr->content, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(st, 3, adr->created_at ? adr->created_at : "", -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(st, 4, adr->updated_at ? adr->updated_at : "", -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(st);
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
}
|
||||
sqlite3_close(db);
|
||||
}
|
||||
|
||||
/* Prepare a stripped DB copy for best-quality export.
|
||||
* VACUUM INTO → (optionally) drop indexes → VACUUM. Returns malloc'd buffer
|
||||
* or NULL. VACUUM INTO runs on BOTH quality levels: it is the consistent
|
||||
* snapshot — the store runs in WAL mode, so raw main-file bytes miss
|
||||
* committed transactions still in the -wal and can be mid-checkpoint torn
|
||||
* (#895). Only the index-stripping is BEST-only. */
|
||||
static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes) {
|
||||
* VACUUM INTO → (optionally) drop indexes → VACUUM → (optionally) inject the
|
||||
* ADR. Returns malloc'd buffer or NULL. VACUUM INTO runs on BOTH quality
|
||||
* levels: it is the consistent snapshot — the store runs in WAL mode, so raw
|
||||
* main-file bytes miss committed transactions still in the -wal and can be
|
||||
* mid-checkpoint torn (#895). Only the index-stripping is BEST-only. */
|
||||
static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes,
|
||||
const cbm_adr_t *adr) {
|
||||
artifact_snapshot_tmp_t tmp;
|
||||
if (!artifact_snapshot_tmp_open(&tmp)) {
|
||||
artifact_export_fail("prepare_snapshot_dir", cbm_tmpdir(), "private_tmpdir_failed", errno);
|
||||
@@ -935,6 +976,11 @@ static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool str
|
||||
}
|
||||
}
|
||||
|
||||
/* Carry the ADR (from the sidecar) into the snapshot so the exported
|
||||
* artifact ships the team's decision record. After the strip/VACUUM, so the
|
||||
* row is not dropped. */
|
||||
snapshot_inject_adr(tmp_path, adr);
|
||||
|
||||
/* Reopened by path rather than held on a descriptor across VACUUM INTO,
|
||||
* because sqlite owns the create. The private directory is what makes that
|
||||
* safe: an attacker who cannot enter it cannot swap the file underneath. */
|
||||
@@ -980,15 +1026,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char *
|
||||
char *db_data = NULL;
|
||||
int compression_level = ART_ZSTD_FAST;
|
||||
|
||||
/* Fetch the ADR from the sidecar (read-only; does not create it) so the
|
||||
* snapshot can carry it — the team-shared artifact used to include the ADR
|
||||
* via project_summaries, and the sidecar must not silently break that. */
|
||||
cbm_adr_t adr = {0};
|
||||
bool have_adr = false;
|
||||
cbm_store_t *adr_src = cbm_store_open_path_query(db_path);
|
||||
if (adr_src) {
|
||||
have_adr = cbm_store_adr_get(adr_src, project_name, &adr) == CBM_STORE_OK;
|
||||
cbm_store_close(adr_src);
|
||||
}
|
||||
const cbm_adr_t *adr_arg = have_adr ? &adr : NULL;
|
||||
|
||||
if (quality == CBM_ARTIFACT_BEST) {
|
||||
compression_level = ART_ZSTD_BEST;
|
||||
db_data = prepare_snapshot_db(db_path, &db_size, true);
|
||||
db_data = prepare_snapshot_db(db_path, &db_size, true, adr_arg);
|
||||
} else {
|
||||
/* FAST keeps zstd-3 and its indexes, but still snapshots via
|
||||
* VACUUM INTO: the raw main-file bytes of a live WAL store are a
|
||||
* torn copy (#895). */
|
||||
db_data = prepare_snapshot_db(db_path, &db_size, false);
|
||||
db_data = prepare_snapshot_db(db_path, &db_size, false, adr_arg);
|
||||
}
|
||||
cbm_store_adr_free(&adr);
|
||||
|
||||
if (!db_data || db_size == 0) {
|
||||
free(db_data);
|
||||
@@ -1071,6 +1130,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char *
|
||||
|
||||
/* ── Import ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* Restore the imported ADR (carried in the graph DB's project_summaries) into
|
||||
* the per-project sidecar so it arrives with the shared artifact — even for a
|
||||
* teammate who already indexed the project (a marked sidecar with no ADR),
|
||||
* which the marker-gated migrate-once would skip. INSERT OR IGNORE means a
|
||||
* local ADR is never overwritten. Best-effort: the graph DB is already
|
||||
* installed when this runs, so a failed restore never fails the import, but it
|
||||
* is logged with the store's reason so a team ADR that did not arrive is
|
||||
* diagnosable instead of silently missing. */
|
||||
static void artifact_import_restore_adr(const char *cache_db_path) {
|
||||
cbm_store_t *restore = cbm_store_open_path_existing(cache_db_path);
|
||||
if (!restore) {
|
||||
cbm_log_warn("artifact.import", "err", "adr_restore", "detail", "reopen_imported_db",
|
||||
"path", cache_db_path);
|
||||
return;
|
||||
}
|
||||
if (cbm_store_adr_restore_from_graph_if_absent(restore) != CBM_STORE_OK) {
|
||||
cbm_log_warn("artifact.import", "err", "adr_restore", "detail", cbm_store_error(restore),
|
||||
"path", cache_db_path);
|
||||
}
|
||||
cbm_store_close(restore);
|
||||
}
|
||||
|
||||
int cbm_artifact_import(const char *repo_path, const char *cache_db_path) {
|
||||
if (!repo_path || !cache_db_path) {
|
||||
return CBM_NOT_FOUND;
|
||||
@@ -1198,6 +1279,8 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) {
|
||||
cbm_unlink(wal);
|
||||
cbm_unlink(shm);
|
||||
|
||||
artifact_import_restore_adr(cache_db_path);
|
||||
|
||||
cbm_log_info("artifact.import", "db", cache_db_path, "size_mb",
|
||||
itoa_buf((int)((size_t)dlen / ART_BYTES_PER_MB)));
|
||||
|
||||
|
||||
@@ -486,10 +486,12 @@ typedef struct {
|
||||
const CBMLSPDef *all_defs;
|
||||
const int *def_starts;
|
||||
cbm_lsp_surface_row_t *rows;
|
||||
int file_count;
|
||||
_Atomic int next_file;
|
||||
_Atomic bool failed;
|
||||
} surface_job_t;
|
||||
|
||||
static void surface_row_one(int i, void *arg) {
|
||||
static void surface_row_build(int i, void *arg) {
|
||||
surface_job_t *job = (surface_job_t *)arg;
|
||||
if (atomic_load_explicit(&job->failed, memory_order_relaxed)) {
|
||||
return;
|
||||
@@ -529,6 +531,45 @@ static void surface_row_one(int i, void *arg) {
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
|
||||
static cbm_lsp_surface_row_observer_fn g_row_observer;
|
||||
static void *g_row_observer_ctx;
|
||||
|
||||
void cbm_lsp_surface_test_set_row_observer(cbm_lsp_surface_row_observer_fn observer, void *ctx) {
|
||||
g_row_observer = observer;
|
||||
g_row_observer_ctx = ctx;
|
||||
}
|
||||
#endif
|
||||
|
||||
static void surface_row_one(int i, void *arg) {
|
||||
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
|
||||
if (g_row_observer) {
|
||||
g_row_observer(i, true, g_row_observer_ctx);
|
||||
}
|
||||
#endif
|
||||
surface_row_build(i, arg);
|
||||
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
|
||||
if (g_row_observer) {
|
||||
g_row_observer(i, false, g_row_observer_ctx);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/* The pool also runs work on its caller. Dispatch at most one coarse job per
|
||||
* allowed participant, each stealing files, so the total active row builders
|
||||
* cannot exceed the pipeline budget even when the caller takes a job. */
|
||||
static void surface_rows_worker(int worker, void *arg) {
|
||||
(void)worker;
|
||||
surface_job_t *job = arg;
|
||||
for (;;) {
|
||||
int i = atomic_fetch_add_explicit(&job->next_file, 1, memory_order_relaxed);
|
||||
if (i >= job->file_count) {
|
||||
break;
|
||||
}
|
||||
surface_row_one(i, job);
|
||||
}
|
||||
}
|
||||
|
||||
int cbm_lsp_surface_build_rows(const cbm_pipeline_ctx_t *ctx, const char *project,
|
||||
CBMFileResult **cache, const cbm_file_info_t *files, int file_count,
|
||||
const CBMLSPDef *all_defs, const int *def_starts,
|
||||
@@ -550,10 +591,16 @@ int cbm_lsp_surface_build_rows(const cbm_pipeline_ctx_t *ctx, const char *projec
|
||||
.all_defs = all_defs,
|
||||
.def_starts = def_starts,
|
||||
.rows = rows,
|
||||
.file_count = file_count,
|
||||
};
|
||||
atomic_init(&job.next_file, 0);
|
||||
atomic_init(&job.failed, false);
|
||||
cbm_parallel_for(file_count, surface_row_one, &job,
|
||||
(cbm_parallel_for_opts_t){.max_workers = 0, .force_pthreads = false});
|
||||
int workers = cbm_pipeline_worker_count(ctx ? ctx->pipeline : NULL);
|
||||
if (workers > file_count) {
|
||||
workers = file_count;
|
||||
}
|
||||
cbm_parallel_for(workers, surface_rows_worker, &job,
|
||||
(cbm_parallel_for_opts_t){.max_workers = workers, .force_pthreads = false});
|
||||
if (atomic_load_explicit(&job.failed, memory_order_relaxed)) {
|
||||
cbm_store_free_lsp_surfaces(rows, file_count); /* untouched rows are all NULL */
|
||||
return -1;
|
||||
|
||||
@@ -68,4 +68,11 @@ int cbm_lsp_surface_python_namespace_from_json(CBMArena *arena, const char *json
|
||||
*/
|
||||
int cbm_lsp_surface_python_change(const char *old_json, const char *new_json);
|
||||
|
||||
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
|
||||
/* Bracket each file callback, including skipped/error rows. Install only with
|
||||
* no surface build running; clear after the observed build returns. */
|
||||
typedef void (*cbm_lsp_surface_row_observer_fn)(int index, bool entering, void *ctx);
|
||||
void cbm_lsp_surface_test_set_row_observer(cbm_lsp_surface_row_observer_fn observer, void *ctx);
|
||||
#endif
|
||||
|
||||
#endif /* CBM_PIPELINE_LSP_SURFACE_H */
|
||||
@@ -1361,6 +1361,14 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
|
||||
if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) {
|
||||
return 0;
|
||||
}
|
||||
/* A bare Go `init(...)` calls a local closure or parameter: never a
|
||||
* package init (unreferable in Go) and never a same-named method found
|
||||
* by name alone — see cbm_go_suppress_bare_init_bind. MUST match
|
||||
* pass_parallel.c exactly. */
|
||||
if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name,
|
||||
res.strategy, target_node->label)) {
|
||||
return 0;
|
||||
}
|
||||
evidence->candidate_qn = target_node->qualified_name;
|
||||
evidence->calls_emitted |=
|
||||
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys,
|
||||
|
||||
@@ -720,6 +720,16 @@ static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *s
|
||||
char method[CBM_SZ_32] = {0};
|
||||
json_str_prop(props, "url_path", url_path, sizeof(url_path));
|
||||
json_str_prop(props, "method", method, sizeof(method));
|
||||
if (!method[0]) {
|
||||
char callee[CBM_SZ_256] = {0};
|
||||
json_str_prop(props, "callee", callee, sizeof(callee));
|
||||
if (callee[0]) {
|
||||
const char *m = cbm_service_pattern_http_method(callee);
|
||||
if (m) {
|
||||
snprintf(method, sizeof(method), "%s", m);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!url_path[0]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2621,19 +2621,37 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
|
||||
if (!cbm_service_pattern_is_http_route_literal(norm, call->callee_name)) {
|
||||
continue;
|
||||
}
|
||||
const char *method = cbm_service_pattern_http_method(call->callee_name);
|
||||
const char *prefix = method ? method : "ANY";
|
||||
char route_qn[CBM_ROUTE_QN_SIZE];
|
||||
char cpath[CBM_SZ_256];
|
||||
snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s",
|
||||
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix,
|
||||
cbm_route_canon_path(norm, cpath, sizeof(cpath)));
|
||||
int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0,
|
||||
"{\"source\":\"arg_url\"}");
|
||||
char route_props[CBM_SZ_256];
|
||||
if (method) {
|
||||
snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\",\"method\":\"%s\"}",
|
||||
method);
|
||||
} else {
|
||||
snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\"}");
|
||||
}
|
||||
int64_t route_id =
|
||||
cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, route_props);
|
||||
char esc_c[CBM_SZ_256];
|
||||
char esc_n[CBM_SZ_256];
|
||||
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
|
||||
cbm_json_escape(esc_n, sizeof(esc_n), norm);
|
||||
char eprops[CBM_SZ_512];
|
||||
snprintf(eprops, sizeof(eprops),
|
||||
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n);
|
||||
/* Two escaped 255-byte strings plus the fixed keys and the verb: 1K
|
||||
* never truncates the JSON (512 could, once the method was added). */
|
||||
char eprops[CBM_SZ_1K];
|
||||
if (method) {
|
||||
snprintf(
|
||||
eprops, sizeof(eprops),
|
||||
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\",\"method\":\"%s\"}",
|
||||
esc_c, esc_n, method);
|
||||
} else {
|
||||
snprintf(eprops, sizeof(eprops),
|
||||
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n);
|
||||
}
|
||||
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "HTTP_CALLS", eprops);
|
||||
break;
|
||||
}
|
||||
@@ -2985,6 +3003,29 @@ static const cbm_gbuf_node_t *find_source_node(const cbm_gbuf_t *gbuf, const cha
|
||||
/* Field type hint resolution for obj.Method() with multiple candidates.
|
||||
* Strips C# field prefixes (_ / m_), capitalizes to get type name, and
|
||||
* checks if TypeName.Method or ITypeName.Method exists among candidates. */
|
||||
/* #1927: the hint may only bind a candidate whose OWNING dot-segment — the
|
||||
* segment immediately before the method name — EQUALS the hinted type. The
|
||||
* previous raw strstr() accepted the hinted name anywhere in the QN, so a
|
||||
* single-letter receiver (`f.Close()` → hint "F") matched nearly every
|
||||
* candidate ("F" ⊂ "…FileStore.Close", the method name included) and rebound
|
||||
* a call whose resolution had already failed to an arbitrary project symbol
|
||||
* at PP_FIELD_HINT_CONF. Segment equality keeps the intended
|
||||
* variable-named-after-its-type case (`repo.Find()` → `Repo.Find`,
|
||||
* lrp_go_s8_field_type_hint) and picks the candidate whose owner actually
|
||||
* carries the name instead of whichever QN contains the letters. */
|
||||
static bool fth_owner_segment_is(const char *candidate_qn, const char *type_name) {
|
||||
const char *last_dot = strrchr(candidate_qn, '.');
|
||||
if (!last_dot || last_dot == candidate_qn) {
|
||||
return false;
|
||||
}
|
||||
const char *seg_start = last_dot;
|
||||
while (seg_start > candidate_qn && seg_start[-1] != '.') {
|
||||
seg_start--;
|
||||
}
|
||||
size_t seg_len = (size_t)(last_dot - seg_start);
|
||||
return seg_len > 0 && strncmp(seg_start, type_name, seg_len) == 0 && type_name[seg_len] == '\0';
|
||||
}
|
||||
|
||||
static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const char *callee_name,
|
||||
int64_t source_id) {
|
||||
if (!res->qualified_name || res->candidate_count <= SKIP_ONE) {
|
||||
@@ -3024,7 +3065,8 @@ static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const
|
||||
int cand_count = 0;
|
||||
cbm_registry_find_by_name(rc->registry, method, &cands, &cand_count);
|
||||
for (int ci = 0; ci < cand_count; ci++) {
|
||||
if (strstr(cands[ci], type_name) || strstr(cands[ci], iface_name)) {
|
||||
if (fth_owner_segment_is(cands[ci], type_name) ||
|
||||
fth_owner_segment_is(cands[ci], iface_name)) {
|
||||
const cbm_gbuf_node_t *better = cbm_gbuf_find_by_qn(rc->main_gbuf, cands[ci]);
|
||||
if (better && better->id != source_id) {
|
||||
res->qualified_name = cands[ci];
|
||||
@@ -3659,6 +3701,13 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
|
||||
* Variable or a Field. */
|
||||
continue;
|
||||
}
|
||||
if (target_node && source_node->id != target_node->id &&
|
||||
cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name,
|
||||
res.strategy, target_node->label)) {
|
||||
/* Same guard as pass_calls.c — a bare Go `init` is a local name,
|
||||
* never a package init or a method found by name alone. */
|
||||
continue;
|
||||
}
|
||||
if (!target_node || source_node->id == target_node->id) {
|
||||
/* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`)
|
||||
* resolve to an unindexed QN (target_node == NULL), but their edge
|
||||
@@ -3835,6 +3884,12 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws,
|
||||
if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) {
|
||||
continue;
|
||||
}
|
||||
/* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind).
|
||||
* Mirrors the sequential twin (pass_usages.c). */
|
||||
if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access,
|
||||
usage->ref_name, res.strategy, tgt->label)) {
|
||||
continue;
|
||||
}
|
||||
if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow ||
|
||||
cbm_pipeline_node_is_callable_target(tgt))) {
|
||||
continue;
|
||||
@@ -3963,6 +4018,12 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi
|
||||
if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) {
|
||||
continue;
|
||||
}
|
||||
/* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind).
|
||||
* Mirrors the sequential twin (pass_usages.c). */
|
||||
if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name,
|
||||
res.strategy, tgt->label)) {
|
||||
continue;
|
||||
}
|
||||
const char *etype = rw->is_write ? "WRITES" : "READS";
|
||||
cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}");
|
||||
}
|
||||
|
||||
@@ -288,6 +288,12 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res
|
||||
if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) {
|
||||
continue;
|
||||
}
|
||||
/* A bare Go `init` names a local, never a package init or an init
|
||||
* method (cbm_go_suppress_bare_init_bind). Mirrors pass_parallel.c. */
|
||||
if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access,
|
||||
usage->ref_name, res.strategy, tgt->label)) {
|
||||
continue;
|
||||
}
|
||||
if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow ||
|
||||
cbm_pipeline_node_is_callable_target(tgt))) {
|
||||
continue;
|
||||
@@ -420,6 +426,13 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result
|
||||
if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) {
|
||||
continue;
|
||||
}
|
||||
/* `init := func(...)`: a bare Go `init` names a local, never a package
|
||||
* init or an init method (cbm_go_suppress_bare_init_bind). Mirrors
|
||||
* pass_parallel.c. */
|
||||
if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name,
|
||||
res.strategy, tgt->label)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const char *edge_type = rw->is_write ? "WRITES" : "READS";
|
||||
cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}");
|
||||
|
||||
+100
-61
@@ -81,11 +81,22 @@ static inline void *intptr_to_ptr(intptr_t v) {
|
||||
* Atomic spinlock: 0 = free, 1 = locked. */
|
||||
static atomic_int g_pipeline_busy = 0;
|
||||
|
||||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||||
static atomic_int g_last_worker_count = 0;
|
||||
|
||||
void cbm_pipeline_worker_count_test_reset(void) {
|
||||
atomic_store(&g_last_worker_count, 0);
|
||||
}
|
||||
|
||||
int cbm_pipeline_worker_count_test_last(void) {
|
||||
return atomic_load(&g_last_worker_count);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||||
static atomic_bool g_persist_test_fail_after_stage_dump = false;
|
||||
static atomic_bool g_persist_test_cancel_after_predump = false;
|
||||
static atomic_bool g_persist_test_cancel_after_destination_prepare = false;
|
||||
static atomic_bool g_persist_test_fail_adr_capture = false;
|
||||
static cbm_pipeline_test_hook_fn g_persist_test_before_final_manifest = NULL;
|
||||
static void *g_persist_test_before_final_manifest_userdata = NULL;
|
||||
static cbm_pipeline_test_hook_fn g_persist_test_after_stage_created = NULL;
|
||||
@@ -103,10 +114,6 @@ void cbm_pipeline_incremental_test_cancel_after_destination_prepare_once(void) {
|
||||
atomic_store(&g_persist_test_cancel_after_destination_prepare, true);
|
||||
}
|
||||
|
||||
void cbm_pipeline_incremental_test_fail_adr_capture_once(void) {
|
||||
atomic_store(&g_persist_test_fail_adr_capture, true);
|
||||
}
|
||||
|
||||
void cbm_pipeline_incremental_test_before_final_manifest_once(cbm_pipeline_test_hook_fn hook,
|
||||
void *userdata) {
|
||||
g_persist_test_before_final_manifest = hook;
|
||||
@@ -162,7 +169,6 @@ void cbm_pipeline_persist_test_reset_faults(void) {
|
||||
atomic_store(&g_persist_test_fail_after_stage_dump, false);
|
||||
atomic_store(&g_persist_test_cancel_after_predump, false);
|
||||
atomic_store(&g_persist_test_cancel_after_destination_prepare, false);
|
||||
atomic_store(&g_persist_test_fail_adr_capture, false);
|
||||
g_persist_test_before_final_manifest = NULL;
|
||||
g_persist_test_before_final_manifest_userdata = NULL;
|
||||
g_persist_test_after_stage_created = NULL;
|
||||
@@ -210,6 +216,7 @@ struct cbm_pipeline {
|
||||
bool persistence; /* write .codebase-memory/graph.db.zst after indexing */
|
||||
cbm_index_resource_policy_t resource_policy;
|
||||
cbm_index_resource_violation_t resource_violation;
|
||||
bool background; /* reserve CPU headroom when no user is waiting */
|
||||
|
||||
/* Snapshot of the artifact export failure of THIS run (set only by
|
||||
* export_after_publish failure, zeroed at run start, cleared on success).
|
||||
@@ -277,10 +284,6 @@ struct cbm_pipeline {
|
||||
bool live_noop;
|
||||
const char *live_db_path; /* borrowed from cbm_pipeline_run */
|
||||
|
||||
/* ADR (project_summaries) captured before a full-reindex DB delete, so it
|
||||
* can be restored after the rebuild. NULL when no ADR existed. Issue #516. */
|
||||
char *saved_adr;
|
||||
|
||||
/* Per-file LSP surfaces serialized at the collect_all_defs seam (the only
|
||||
* moment the result cache is alive), persisted by dump_and_persist_hashes
|
||||
* so the closure-repair incremental route can early-cutoff on surface
|
||||
@@ -965,6 +968,12 @@ const char *cbm_pipeline_export_error(const cbm_pipeline_t *p) {
|
||||
return p ? p->export_error : "";
|
||||
}
|
||||
|
||||
void cbm_pipeline_set_background(cbm_pipeline_t *p, bool background) {
|
||||
if (p) {
|
||||
p->background = background;
|
||||
}
|
||||
}
|
||||
|
||||
bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name) {
|
||||
if (!p || !name || !name[0]) {
|
||||
return false;
|
||||
@@ -1472,9 +1481,6 @@ void cbm_pipeline_free(cbm_pipeline_t *p) {
|
||||
p->file_errors_count = 0;
|
||||
p->file_errors_cap = 0;
|
||||
free(p->branch_qn);
|
||||
free(p->saved_adr); /* freed here too: error paths can exit before the
|
||||
* restore in dump_and_persist_hashes runs. Issue #516. */
|
||||
p->saved_adr = NULL;
|
||||
cbm_store_free_lsp_surfaces(p->surface_rows, p->surface_row_count);
|
||||
p->surface_rows = NULL;
|
||||
p->surface_row_count = 0;
|
||||
@@ -1829,17 +1835,42 @@ void cbm_pipeline_set_committed_counts(cbm_pipeline_t *p, int nodes, int edges)
|
||||
}
|
||||
}
|
||||
|
||||
/* Two workers is the smallest width that takes the parallel route. */
|
||||
enum { PL_PARALLEL_MIN_WORKERS = 2 };
|
||||
|
||||
/* Background refreshes leave CPU headroom, but never drop below two workers
|
||||
* while the foreground width is two or more. WHY: one worker sends the indexer
|
||||
* down the sequential route, which still builds a different graph than the
|
||||
* parallel route (fewer Route and HANDLES edges, churned CALLS). Until the two
|
||||
* routes agree, an automatic refresh must not change the graph only because
|
||||
* the host has two or fewer performance cores. */
|
||||
int cbm_pipeline_background_worker_width(int foreground, int headroom) {
|
||||
if (foreground >= PL_PARALLEL_MIN_WORKERS && headroom < PL_PARALLEL_MIN_WORKERS) {
|
||||
return PL_PARALLEL_MIN_WORKERS;
|
||||
}
|
||||
return headroom;
|
||||
}
|
||||
|
||||
/* Effective worker count. The crash supervisor re-runs its worker single-
|
||||
* threaded (CBM_INDEX_SINGLE_THREAD=1) so a per-file marker can pin the EXACT
|
||||
* crasher; a parallel re-run would race the marker. Honour that override
|
||||
* everywhere the worker count drives the parallel/sequential decision, so the
|
||||
* whole extraction phase collapses to the deterministic sequential path. */
|
||||
static int effective_worker_count(bool initial) {
|
||||
int cbm_pipeline_worker_count(const cbm_pipeline_t *p) {
|
||||
const char *st = getenv("CBM_INDEX_SINGLE_THREAD");
|
||||
int workers;
|
||||
if (st && st[0] == '1') {
|
||||
return 1;
|
||||
workers = 1;
|
||||
} else if (p && p->background) {
|
||||
workers = cbm_pipeline_background_worker_width(cbm_default_worker_count(true),
|
||||
cbm_default_worker_count(false));
|
||||
} else {
|
||||
workers = cbm_default_worker_count(true);
|
||||
}
|
||||
return cbm_default_worker_count(initial);
|
||||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||||
atomic_store(&g_last_worker_count, workers);
|
||||
#endif
|
||||
return workers;
|
||||
}
|
||||
|
||||
/* Resolve the DB path for this pipeline. Caller must free(). */
|
||||
@@ -3139,38 +3170,39 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx,
|
||||
return check_cancel(p) ? CBM_NOT_FOUND : 0;
|
||||
}
|
||||
|
||||
static int capture_existing_adr(cbm_pipeline_t *p, const char *db_path) {
|
||||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||||
if (atomic_exchange(&g_persist_test_fail_adr_capture, false)) {
|
||||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||||
static size_t stage_root_length(const char *path);
|
||||
|
||||
/* Move a legacy ADR row (pre-sidecar, still in the graph DB's project_summaries)
|
||||
* into the "<db>.adr.db" sidecar BEFORE a reindex unlinks the old generation —
|
||||
* otherwise the row is lost on an upgrade whose first action is a full/format
|
||||
* reindex. Read-only on the graph DB (migration writes only the sidecar, on its
|
||||
* own connection). One-time via the sidecar's marker; a no-op when there is no
|
||||
* legacy row. Returns true on success (safe to delete), false to preserve. */
|
||||
static bool adr_migrate_before_delete(const char *db_path) {
|
||||
/* `db_path` in routing is a staging copy; the live generation (and the
|
||||
* sidecar the reader looks for) is keyed to the FINAL path — the staging
|
||||
* suffix stripped. Migrate against that, so the ADR lands in
|
||||
* "<final>.adr.db", not a discarded stage's sidecar. */
|
||||
size_t root_len = stage_root_length(db_path);
|
||||
char final_path[4096];
|
||||
if (root_len >= sizeof(final_path)) {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
cbm_store_t *adr_store = cbm_store_open_path_query(db_path);
|
||||
if (!adr_store) {
|
||||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||||
memcpy(final_path, db_path, root_len);
|
||||
final_path[root_len] = '\0';
|
||||
/* The live final DB must exist to carry a legacy ADR forward; if it does
|
||||
* not (a first index), there is nothing to migrate — succeed. But
|
||||
* cbm_store_open_path_query returns NULL on an open *failure* too, not only
|
||||
* a missing file, so only proceed when the file genuinely does not exist;
|
||||
* a real open failure must preserve-and-abort rather than publish over a
|
||||
* legacy ADR it never moved. */
|
||||
cbm_store_t *mig = cbm_store_open_path_query(final_path);
|
||||
if (!mig) {
|
||||
return !cbm_file_exists(final_path);
|
||||
}
|
||||
cbm_adr_t existing = {0};
|
||||
int adr_rc = cbm_store_adr_get(adr_store, p->project_name, &existing);
|
||||
if (adr_rc == CBM_STORE_NOT_FOUND) {
|
||||
cbm_store_close(adr_store);
|
||||
free(p->saved_adr);
|
||||
p->saved_adr = NULL;
|
||||
return 0;
|
||||
}
|
||||
if (adr_rc != CBM_STORE_OK || !existing.content) {
|
||||
cbm_store_adr_free(&existing);
|
||||
cbm_store_close(adr_store);
|
||||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||||
}
|
||||
char *saved = strdup(existing.content);
|
||||
cbm_store_adr_free(&existing);
|
||||
cbm_store_close(adr_store);
|
||||
if (!saved) {
|
||||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||||
}
|
||||
free(p->saved_adr);
|
||||
p->saved_adr = saved;
|
||||
return 0;
|
||||
int rc = cbm_store_adr_migrate_once(mig);
|
||||
cbm_store_close(mig);
|
||||
return rc == CBM_STORE_OK;
|
||||
}
|
||||
|
||||
static int remove_stage_files(const char *stage_path);
|
||||
@@ -3276,11 +3308,19 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file
|
||||
cbm_log_info("pipeline.route", "path", "format_change_reindex", "stored_format",
|
||||
itoa_buf(fmt));
|
||||
p->format_migration = true;
|
||||
int adr_rc = capture_existing_adr(p, db_path);
|
||||
/* Carry a legacy ADR into the sidecar before deleting the old graph DB;
|
||||
* preserve and abort if that fails, so the decision record is never lost
|
||||
* to a rebuild. The sidecar itself is untouched by cbm_remove_db_sidecars
|
||||
* (it strips only -wal/-shm/-journal). */
|
||||
if (!adr_migrate_before_delete(db_path)) {
|
||||
cbm_log_warn("pipeline.route", "reason", "adr_migrate_failed");
|
||||
free(db_path);
|
||||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||||
}
|
||||
(void)cbm_unlink(db_path);
|
||||
(void)cbm_remove_db_sidecars(db_path);
|
||||
free(db_path);
|
||||
return adr_rc != 0 ? adr_rc : CBM_PIPELINE_FORCE_FULL_REINDEX;
|
||||
return CBM_PIPELINE_FORCE_FULL_REINDEX;
|
||||
}
|
||||
|
||||
cbm_log_info("pipeline.route", "path", "incremental_manifest");
|
||||
@@ -3293,9 +3333,13 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file
|
||||
* no-op and successful-incremental routes -- the pipeline reports success
|
||||
* while every later reader finds no store. */
|
||||
if (rc == CBM_PIPELINE_FORCE_FULL_REINDEX) {
|
||||
int adr_rc = capture_existing_adr(p, db_path);
|
||||
if (adr_rc != 0) {
|
||||
rc = adr_rc;
|
||||
/* Carry a legacy ADR into the sidecar before deleting the old graph DB;
|
||||
* preserve and abort if that fails. The sidecar survives the delete
|
||||
* (cbm_remove_db_sidecars strips only -wal/-shm/-journal). */
|
||||
if (!adr_migrate_before_delete(db_path)) {
|
||||
cbm_log_warn("pipeline.route", "reason", "adr_migrate_failed");
|
||||
free(db_path);
|
||||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||||
}
|
||||
(void)cbm_unlink(db_path);
|
||||
(void)cbm_remove_db_sidecars(db_path);
|
||||
@@ -3817,10 +3861,8 @@ static int publish_staged_impl(char *stage_path, const cbm_pipeline_generation_t
|
||||
cbm_store_upsert_lsp_surface_batch(store, generation->surface_rows,
|
||||
generation->surface_row_count) == CBM_STORE_OK;
|
||||
}
|
||||
if (ok && generation->adr_content) {
|
||||
ok = cbm_store_adr_store(store, generation->project, generation->adr_content) ==
|
||||
CBM_STORE_OK;
|
||||
}
|
||||
/* ADRs are NOT written here: they live in the "<db>.adr.db" sidecar, which
|
||||
* this rebuild never touches, so there is nothing to re-apply. */
|
||||
/* Doc -> code candidates belong to the generation too: replaced inside
|
||||
* the staging store (the table is created here), so a reader never sees
|
||||
* candidates of another graph. */
|
||||
@@ -4108,7 +4150,6 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas
|
||||
.cancelled = p->cancelled,
|
||||
.manifest = manifest,
|
||||
.manifest_count = manifest_count,
|
||||
.adr_content = p->saved_adr,
|
||||
.coverage = cov,
|
||||
.coverage_count = cov_count,
|
||||
.coverage_meta =
|
||||
@@ -4162,8 +4203,6 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas
|
||||
cbm_log_warn("index.ignored_capped", "stored", itoa_buf(p->ignored_count), "total",
|
||||
itoa_buf(p->ignored_total));
|
||||
}
|
||||
free(p->saved_adr);
|
||||
p->saved_adr = NULL;
|
||||
|
||||
free(db_path);
|
||||
return 0;
|
||||
@@ -4186,7 +4225,7 @@ static int run_githistory(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) {
|
||||
gh_compute_arg_t gh_arg = {.repo_path = ctx->repo_path, .result = &gh_result};
|
||||
|
||||
if (p->mode != CBM_MODE_FAST) {
|
||||
if (effective_worker_count(true) > SKIP_ONE) {
|
||||
if (cbm_pipeline_worker_count(p) > SKIP_ONE) {
|
||||
if (cbm_thread_create(&gh_thread, 0, gh_compute_thread_fn, &gh_arg) == 0) {
|
||||
gh_threaded = true;
|
||||
}
|
||||
@@ -4286,7 +4325,7 @@ static int run_structure_and_extraction(cbm_pipeline_t *p, cbm_pipeline_ctx_t *c
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
|
||||
int worker_count = p->frozen ? 1 : effective_worker_count(true);
|
||||
int worker_count = p->frozen ? 1 : cbm_pipeline_worker_count(p);
|
||||
if (p->frozen)
|
||||
frozen_observe(p, 0, 1);
|
||||
CBM_PROF_START(t_extract_total);
|
||||
@@ -4417,7 +4456,7 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) {
|
||||
: mode_promoted
|
||||
? cbm_pipeline_build_fresh_semantic_manifest(p, p->project_name, &baseline_manifest,
|
||||
&baseline_count)
|
||||
: cbm_pipeline_build_semantic_manifest(p->project_name, p->repo_path, files,
|
||||
: cbm_pipeline_build_semantic_manifest(p, p->project_name, p->repo_path, files,
|
||||
file_count, p->excluded_dirs, p->excluded_count,
|
||||
&p->git_ctx, p->userconfig, &baseline_manifest,
|
||||
&baseline_count);
|
||||
|
||||
@@ -71,6 +71,23 @@ bool cbm_pipeline_doc_candidates_enabled(const cbm_pipeline_t *p);
|
||||
/* Copy the exact discovery violation from the most recent run. */
|
||||
void cbm_pipeline_get_resource_violation(const cbm_pipeline_t *p,
|
||||
cbm_index_resource_violation_t *violation);
|
||||
/* Mark work that runs without a waiting user. Background pipelines reserve
|
||||
* CPU headroom; foreground pipelines retain the initial-index all-core policy. */
|
||||
void cbm_pipeline_set_background(cbm_pipeline_t *p, bool background);
|
||||
|
||||
/* Resolve the worker policy for this pipeline, including environment and
|
||||
* crash-recovery overrides. */
|
||||
int cbm_pipeline_worker_count(const cbm_pipeline_t *p);
|
||||
|
||||
/* Background width from the foreground width and the headroom width: the
|
||||
* headroom, floored at two whenever the foreground width is two or more so an
|
||||
* automatic refresh keeps the parallel route. */
|
||||
int cbm_pipeline_background_worker_width(int foreground, int headroom);
|
||||
|
||||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
||||
void cbm_pipeline_worker_count_test_reset(void);
|
||||
int cbm_pipeline_worker_count_test_last(void);
|
||||
#endif
|
||||
|
||||
/* Snapshot of the artifact export failure of the last cbm_pipeline_run, or ""
|
||||
* when the run succeeded / did not reach post-publish export. Used to
|
||||
@@ -459,6 +476,16 @@ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *st
|
||||
bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy,
|
||||
double confidence);
|
||||
|
||||
/* A bare Go `init` (a call or reference that is not the member half of a
|
||||
* selector) is always a local name: Go forbids referring to a package init
|
||||
* function, and a method or field needs a selector. True when such a name
|
||||
* resolved onto a Function (every Go Function named init is a package init;
|
||||
* any registry strategy) or onto a Method or Field found by a weak short-name
|
||||
* strategy. lsp_* answers are kept. Go only. Pure; unit-tested in
|
||||
* test_registry.c; wired in pass_calls.c, pass_usages.c and pass_parallel.c. */
|
||||
bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name,
|
||||
const char *strategy, const char *target_label);
|
||||
|
||||
/* Import-binding counterpart (#2127). True when the callee's root identifier
|
||||
* is bound by an import of this file whose module chain contradicts the
|
||||
* resolved target (`from unittest.mock import patch; patch()` must not bind a
|
||||
|
||||
@@ -479,9 +479,9 @@ static void *manifest_hash_worker(void *arg) {
|
||||
|
||||
enum { MANIFEST_PARALLEL_MIN_FILES = 64 };
|
||||
|
||||
int cbm_pipeline_build_semantic_manifest(const char *project, const char *repo_path,
|
||||
const cbm_file_info_t *files, int file_count,
|
||||
char **excluded_dirs, int excluded_count,
|
||||
int cbm_pipeline_build_semantic_manifest(const cbm_pipeline_t *p, const char *project,
|
||||
const char *repo_path, const cbm_file_info_t *files,
|
||||
int file_count, char **excluded_dirs, int excluded_count,
|
||||
const cbm_git_context_t *git_ctx,
|
||||
const cbm_userconfig_t *userconfig, cbm_file_hash_t **out,
|
||||
int *out_count) {
|
||||
@@ -522,7 +522,7 @@ int cbm_pipeline_build_semantic_manifest(const char *project, const char *repo_p
|
||||
}
|
||||
struct timespec t_hash;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t_hash);
|
||||
int hash_workers = cbm_default_worker_count(true);
|
||||
int hash_workers = cbm_pipeline_worker_count(p);
|
||||
if (rc == 0 && file_count >= MANIFEST_PARALLEL_MIN_FILES && hash_workers > SKIP_ONE) {
|
||||
char (*shas)[CBM_SHA256_HEX_LEN + 1] = malloc((size_t)file_count * sizeof(*shas));
|
||||
int64_t *mtimes = calloc((size_t)file_count, sizeof(int64_t));
|
||||
@@ -869,9 +869,9 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr
|
||||
&fresh_ignored_total);
|
||||
}
|
||||
if (rc == 0) {
|
||||
rc = cbm_pipeline_build_semantic_manifest(project, repo_path, fresh_files, fresh_file_count,
|
||||
fresh_excluded, fresh_excluded_count,
|
||||
&fresh_git_ctx, fresh_userconfig, out, out_count);
|
||||
rc = cbm_pipeline_build_semantic_manifest(
|
||||
p, project, repo_path, fresh_files, fresh_file_count, fresh_excluded,
|
||||
fresh_excluded_count, &fresh_git_ctx, fresh_userconfig, out, out_count);
|
||||
}
|
||||
cbm_set_user_lang_config(previous_userconfig);
|
||||
cbm_git_context_free(&fresh_git_ctx);
|
||||
@@ -1855,7 +1855,8 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed
|
||||
* a full build takes, which is what makes its output converge. */
|
||||
|
||||
#define MIN_FILES_FOR_PARALLEL_INCR 50
|
||||
int worker_count = cbm_pipeline_is_frozen(ctx->pipeline) ? 1 : cbm_default_worker_count(true);
|
||||
int worker_count =
|
||||
cbm_pipeline_is_frozen(ctx->pipeline) ? 1 : cbm_pipeline_worker_count(ctx->pipeline);
|
||||
bool use_parallel =
|
||||
closure != NULL || (worker_count > SKIP_ONE && ci > MIN_FILES_FOR_PARALLEL_INCR);
|
||||
|
||||
@@ -2238,8 +2239,7 @@ typedef struct {
|
||||
|
||||
static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project,
|
||||
atomic_int *cancelled, const cbm_file_hash_t *manifest,
|
||||
int manifest_count, const char *adr_content,
|
||||
const cbm_coverage_row_t *cov, int cov_count,
|
||||
int manifest_count, const cbm_coverage_row_t *cov, int cov_count,
|
||||
const cbm_coverage_meta_t *meta_template,
|
||||
const cbm_lsp_surface_row_t *surface_rows, int surface_row_count,
|
||||
legacy_doc_rows_t doc, const cbm_doc_candidate_t *doc_candidates,
|
||||
@@ -2253,7 +2253,6 @@ static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *p
|
||||
.cancelled = cancelled,
|
||||
.manifest = manifest,
|
||||
.manifest_count = manifest_count,
|
||||
.adr_content = adr_content,
|
||||
.coverage = cov,
|
||||
.coverage_count = cov_count,
|
||||
.coverage_meta = meta_template ? *meta_template : (cbm_coverage_meta_t){0},
|
||||
@@ -2341,7 +2340,7 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project,
|
||||
_Atomic int64_t probe_ids;
|
||||
atomic_init(&probe_ids, cbm_gbuf_next_id(probe_gbuf));
|
||||
rc = cbm_parallel_extract(&probe_ctx, probe_files, probe_count, cache, &probe_ids,
|
||||
cbm_default_worker_count(true));
|
||||
cbm_pipeline_worker_count(p));
|
||||
}
|
||||
if (rc == 0) {
|
||||
char **def_modules =
|
||||
@@ -2355,10 +2354,14 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project,
|
||||
if (def_modules && def_starts) {
|
||||
defs = cbm_pxc_collect_all_defs(NULL, &probe_arena, cache, probe_files, probe_count,
|
||||
project, def_modules, &def_count, def_starts);
|
||||
rc = def_count < 0
|
||||
? CBM_PIPELINE_ABORT_PRESERVE_DB
|
||||
: cbm_lsp_surface_build_rows(NULL, project, cache, probe_files, probe_count,
|
||||
defs, def_starts, out_rows, out_count);
|
||||
/* Extract left pipeline NULL so file errors are not recorded twice.
|
||||
* Surface rows only borrow it for the background worker policy. */
|
||||
probe_ctx.pipeline = p;
|
||||
rc = def_count < 0 ? CBM_PIPELINE_ABORT_PRESERVE_DB
|
||||
: cbm_lsp_surface_build_rows(&probe_ctx, project, cache, probe_files,
|
||||
probe_count, defs, def_starts, out_rows,
|
||||
out_count);
|
||||
probe_ctx.pipeline = NULL;
|
||||
} else {
|
||||
rc = -1;
|
||||
}
|
||||
@@ -3484,7 +3487,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
|
||||
elig[elig_count++] = row;
|
||||
}
|
||||
}
|
||||
int workers = cbm_default_worker_count(true);
|
||||
int workers = cbm_pipeline_worker_count(p);
|
||||
if (workers < 1) {
|
||||
workers = 1;
|
||||
}
|
||||
@@ -3840,7 +3843,6 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
|
||||
.cancelled = cbm_pipeline_cancelled_ptr(p),
|
||||
.manifest = manifest,
|
||||
.manifest_count = manifest_count,
|
||||
.adr_content = NULL, /* the clone already carries the ADR rows */
|
||||
.coverage = cov,
|
||||
.coverage_count = cov_n,
|
||||
.coverage_meta =
|
||||
@@ -4233,43 +4235,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
|
||||
char *saved_adr = NULL;
|
||||
cbm_adr_t existing_adr = {0};
|
||||
int adr_rc = cbm_store_adr_get(store, project, &existing_adr);
|
||||
if (adr_rc == CBM_STORE_OK) {
|
||||
bool had_adr_content = existing_adr.content != NULL;
|
||||
if (had_adr_content) {
|
||||
saved_adr = strdup(existing_adr.content);
|
||||
}
|
||||
cbm_store_adr_free(&existing_adr);
|
||||
if (had_adr_content && !saved_adr) {
|
||||
cbm_gbuf_free(existing);
|
||||
free(changed_files);
|
||||
for (int i = 0; i < deleted_count; i++) {
|
||||
free(deleted[i]);
|
||||
}
|
||||
free(deleted);
|
||||
free_mode_skipped(mode_skipped, mode_skipped_count);
|
||||
cbm_store_free_coverage(old_cov, old_cov_count);
|
||||
cbm_store_close(store);
|
||||
closure_plan_free(&closure_plan);
|
||||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||||
}
|
||||
} else if (adr_rc != CBM_STORE_NOT_FOUND) {
|
||||
cbm_store_adr_free(&existing_adr);
|
||||
cbm_gbuf_free(existing);
|
||||
free(changed_files);
|
||||
for (int i = 0; i < deleted_count; i++) {
|
||||
free(deleted[i]);
|
||||
}
|
||||
free(deleted);
|
||||
free_mode_skipped(mode_skipped, mode_skipped_count);
|
||||
cbm_store_free_coverage(old_cov, old_cov_count);
|
||||
cbm_store_close(store);
|
||||
closure_plan_free(&closure_plan);
|
||||
return CBM_PIPELINE_ABORT_PRESERVE_DB;
|
||||
}
|
||||
|
||||
/* ADRs live in the "<db>.adr.db" sidecar, which the delta clone/patch/rename
|
||||
* never touches, so there is nothing to capture or re-apply here. */
|
||||
legacy_doc_t legacy_doc;
|
||||
legacy_doc_load(&legacy_doc, store, project, changed_files, ci, deleted, deleted_count);
|
||||
cbm_store_close(store);
|
||||
@@ -4429,7 +4396,6 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
incr_free_edge_capture(&edge_cap);
|
||||
cbm_store_free_coverage(old_cov, old_cov_count);
|
||||
free_mode_skipped(mode_skipped, mode_skipped_count);
|
||||
free(saved_adr);
|
||||
cbm_gbuf_free(existing);
|
||||
cbm_arena_destroy(&decl_arena);
|
||||
legacy_doc_free(&legacy_doc);
|
||||
@@ -4551,7 +4517,6 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
free(cov);
|
||||
cbm_store_free_coverage(old_cov, old_cov_count);
|
||||
free_mode_skipped(mode_skipped, mode_skipped_count);
|
||||
free(saved_adr);
|
||||
cbm_gbuf_free(existing);
|
||||
cbm_arena_destroy(&decl_arena);
|
||||
cbm_doclinks_free_rows(doc_rows, doc_row_count);
|
||||
@@ -4589,12 +4554,12 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
* rehydrated project, so this run's rows are complete. */
|
||||
int doc_cand_n = 0;
|
||||
const cbm_doc_candidate_t *doc_cands = cbm_pipeline_doc_candidates(p, &doc_cand_n);
|
||||
/* ADRs are not captured/re-applied here (sidecar store), so no adr argument. */
|
||||
int persist_rc = dump_and_persist(existing, db_path, project, cbm_pipeline_cancelled_ptr(p),
|
||||
manifest, manifest_count, saved_adr, cov, cov_n,
|
||||
&coverage_meta, NULL, 0, doc_out, doc_cands, doc_cand_n);
|
||||
manifest, manifest_count, cov, cov_n, &coverage_meta, NULL, 0,
|
||||
doc_out, doc_cands, doc_cand_n);
|
||||
cbm_doclinks_free_rows(doc_rows, doc_row_count);
|
||||
cbm_pipeline_free_semantic_manifest(manifest, manifest_count);
|
||||
free(saved_adr);
|
||||
free(cov);
|
||||
cbm_store_free_coverage(old_cov, old_cov_count);
|
||||
free_mode_skipped(mode_skipped, mode_skipped_count);
|
||||
|
||||
@@ -1587,9 +1587,9 @@ bool cbm_pipeline_incremental_generation_exact(cbm_pipeline_t *p, cbm_store_t *s
|
||||
#define CBM_SEMANTIC_INPUT_GLOBAL_CONFIG CBM_SEMANTIC_INPUT_PREFIX "global-extension-config-v1"
|
||||
#define CBM_SEMANTIC_INPUT_PROJECT_CONFIG CBM_SEMANTIC_INPUT_PREFIX "project-extension-config-v1"
|
||||
|
||||
int cbm_pipeline_build_semantic_manifest(const char *project, const char *repo_path,
|
||||
const cbm_file_info_t *files, int file_count,
|
||||
char **excluded_dirs, int excluded_count,
|
||||
int cbm_pipeline_build_semantic_manifest(const cbm_pipeline_t *p, const char *project,
|
||||
const char *repo_path, const cbm_file_info_t *files,
|
||||
int file_count, char **excluded_dirs, int excluded_count,
|
||||
const cbm_git_context_t *git_ctx,
|
||||
const cbm_userconfig_t *userconfig, cbm_file_hash_t **out,
|
||||
int *out_count);
|
||||
@@ -1694,7 +1694,6 @@ typedef struct {
|
||||
atomic_int *cancelled;
|
||||
const cbm_file_hash_t *manifest;
|
||||
int manifest_count;
|
||||
const char *adr_content;
|
||||
const cbm_coverage_row_t *coverage;
|
||||
int coverage_count;
|
||||
cbm_coverage_meta_t coverage_meta;
|
||||
@@ -1960,7 +1959,6 @@ void cbm_pipeline_incremental_test_force_legacy_partial_once(void);
|
||||
void cbm_pipeline_incremental_test_fail_after_stage_dump_once(void);
|
||||
void cbm_pipeline_incremental_test_cancel_after_predump_once(void);
|
||||
void cbm_pipeline_incremental_test_cancel_after_destination_prepare_once(void);
|
||||
void cbm_pipeline_incremental_test_fail_adr_capture_once(void);
|
||||
typedef void (*cbm_pipeline_test_hook_fn)(void *userdata);
|
||||
void cbm_pipeline_incremental_test_before_final_manifest_once(cbm_pipeline_test_hook_fn hook,
|
||||
void *userdata);
|
||||
|
||||
@@ -1015,6 +1015,46 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s
|
||||
return confidence < CONF_UNIQUE_NAME;
|
||||
}
|
||||
|
||||
bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name,
|
||||
const char *strategy, const char *target_label) {
|
||||
/* The Go spec gives `init` no binding in the package block: the
|
||||
* identifier declares init functions and nothing else, and "init
|
||||
* functions cannot be referred to from anywhere in a program". A method
|
||||
* or a field named init is reached only through a selector (`x.init()`,
|
||||
* `e.init`), which arrives here as is_member and is left alone. So a
|
||||
* BARE `init` is always a local name: a closure (`init :=
|
||||
* func(ctx) {...}; init(ctx)`, 22 kubernetes e2e storage suites), a
|
||||
* parameter (`func sum(init func() ref.Val)`), or the `package init`
|
||||
* clause of a directory named init. None of them is a project node.
|
||||
*
|
||||
* Every Go Function named init is a package init (no other package-level
|
||||
* `init` can be declared), so that target is refused for every registry
|
||||
* strategy. Before #1915 the flat QN made same_module bind the bare name
|
||||
* to its own package's init; after it, the per-file QN misses there and
|
||||
* the name fallback binds unrelated init methods instead. A Method or
|
||||
* Field namesake is refused when only the name found it (the shared weak
|
||||
* drop-list), which is how every such edge was minted. lsp_* answers are
|
||||
* untouched: the sequential resolver emits them before its registry
|
||||
* guards run, so refusing one here would split the two resolvers.
|
||||
*
|
||||
* Go-gated: elsewhere `init` is an ordinary name. A bare `init()` in a
|
||||
* Java, C#, C++, Kotlin or Ruby method calls the class's own method, and
|
||||
* a C, Python or JS `init` function is called by its bare name. */
|
||||
if (!is_go || is_member || !name || strcmp(name, "init") != 0 || !target_label) {
|
||||
return false;
|
||||
}
|
||||
if (strategy && strncmp(strategy, "lsp_", strlen("lsp_")) == 0) {
|
||||
return false;
|
||||
}
|
||||
if (strcmp(target_label, "Function") == 0) {
|
||||
return true;
|
||||
}
|
||||
if (strcmp(target_label, "Method") == 0 || strcmp(target_label, "Field") == 0) {
|
||||
return weak_short_name_strategy(strategy);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Import-binding counterpart of the parameter guard above (#2127). An import
|
||||
* binds the identifier for the whole module; when that import is external it
|
||||
* never materializes, and the chain falls through to project-wide short-name
|
||||
|
||||
@@ -22,9 +22,28 @@ enum { WP_TRUE = 1, WP_MIN = 1, WP_STEP = 1 };
|
||||
|
||||
/* CBM_WORKER_STACK_SIZE (8 MB per worker) lives in worker_pool.h. */
|
||||
|
||||
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
|
||||
static cbm_parallel_spawn_observer_fn g_spawn_observer;
|
||||
static void *g_spawn_observer_ctx;
|
||||
|
||||
void cbm_parallel_test_set_spawn_observer(cbm_parallel_spawn_observer_fn observer, void *ctx) {
|
||||
g_spawn_observer = observer;
|
||||
g_spawn_observer_ctx = ctx;
|
||||
}
|
||||
|
||||
static void observe_spawn(int count, int helpers) {
|
||||
if (g_spawn_observer) {
|
||||
g_spawn_observer(count, helpers, g_spawn_observer_ctx);
|
||||
}
|
||||
}
|
||||
#else
|
||||
#define observe_spawn(count, helpers) ((void)0)
|
||||
#endif
|
||||
|
||||
/* ── Serial fallback ─────────────────────────────────────────────── */
|
||||
|
||||
static void run_serial(int count, cbm_parallel_fn fn, void *ctx) {
|
||||
observe_spawn(count, 0);
|
||||
for (int i = 0; i < count; i++) {
|
||||
fn(i, ctx);
|
||||
}
|
||||
@@ -103,6 +122,7 @@ static void run_pthreads_counted(int count, cbm_parallel_fn fn, void *ctx, int n
|
||||
}
|
||||
started++;
|
||||
}
|
||||
observe_spawn(count, started);
|
||||
int main_done = 0;
|
||||
uint64_t main_ops_before = cbm_memev_thread_ops();
|
||||
while (WP_TRUE) {
|
||||
@@ -164,6 +184,7 @@ static void run_pthreads(int count, cbm_parallel_fn fn, void *ctx, int nworkers)
|
||||
}
|
||||
}
|
||||
|
||||
observe_spawn(count, nworkers);
|
||||
/* Main thread also participates */
|
||||
while (WP_TRUE) {
|
||||
int idx = atomic_fetch_add_explicit(&next_idx, WP_STEP, memory_order_relaxed);
|
||||
|
||||
@@ -36,4 +36,11 @@ typedef struct {
|
||||
* If count <= 1 or workers <= 1, runs single-threaded. */
|
||||
void cbm_parallel_for(int count, cbm_parallel_fn fn, void *ctx, cbm_parallel_for_opts_t opts);
|
||||
|
||||
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
|
||||
/* Observe the completed spawn attempt before the caller takes work. Install
|
||||
* only while no dispatch is running; clear after the observed dispatch joins. */
|
||||
typedef void (*cbm_parallel_spawn_observer_fn)(int count, int helpers, void *ctx);
|
||||
void cbm_parallel_test_set_spawn_observer(cbm_parallel_spawn_observer_fn observer, void *ctx);
|
||||
#endif
|
||||
|
||||
#endif /* CBM_WORKER_POOL_H */
|
||||
+363
-42
@@ -183,6 +183,15 @@ struct cbm_store {
|
||||
sqlite3_stmt *stmt_get_file_hashes;
|
||||
sqlite3_stmt *stmt_delete_file_hash;
|
||||
sqlite3_stmt *stmt_delete_file_hashes;
|
||||
|
||||
/* ADRs live in a per-project sidecar DB ("<db_path>.adr.db"), NOT in the
|
||||
* graph DB, so a reindex that rebuilds/replaces the graph DB never touches
|
||||
* them (removing the capture/re-apply race entirely). The read-write handle
|
||||
* is lazily opened on the first ADR write/migration and cached for the
|
||||
* store's lifetime; NULL for a :memory: store, where ADRs stay in the graph
|
||||
* DB (never file-swapped, no race). Reads open a short-lived read-only
|
||||
* handle so a query-only cache is never mutated. */
|
||||
sqlite3 *adr_db;
|
||||
};
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────── */
|
||||
@@ -195,6 +204,13 @@ static void store_set_error_sqlite(cbm_store_t *s, const char *prefix) {
|
||||
snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(s->db));
|
||||
}
|
||||
|
||||
/* Like store_set_error_sqlite but reads the message from an explicit
|
||||
* connection — used for the ADR sidecar DB, whose errors do not live on
|
||||
* s->db. */
|
||||
static void store_set_error_sqlite_on(cbm_store_t *s, sqlite3 *db, const char *prefix) {
|
||||
snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(db));
|
||||
}
|
||||
|
||||
static int exec_sql(cbm_store_t *s, const char *sql) {
|
||||
if (!s || !s->db) {
|
||||
return CBM_STORE_ERR;
|
||||
@@ -1530,6 +1546,11 @@ void cbm_store_close(cbm_store_t *s) {
|
||||
* Prevents ASan false-positive leaks from sqlite3 internal state. */
|
||||
sqlite3_close_v2(s->db);
|
||||
cbm_graph_mem_release(s->graph_mem);
|
||||
/* ADR sidecar: checkpoint + close its own connection (file stores only). */
|
||||
if (s->adr_db) {
|
||||
(void)sqlite3_wal_checkpoint_v2(s->adr_db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL);
|
||||
sqlite3_close_v2(s->adr_db);
|
||||
}
|
||||
safe_str_free(&s->db_path);
|
||||
free(s);
|
||||
}
|
||||
@@ -11923,7 +11944,260 @@ void cbm_adr_sections_free(cbm_adr_sections_t *s) {
|
||||
memset(s, 0, sizeof(*s));
|
||||
}
|
||||
|
||||
/* ── ADR sidecar store ──────────────────────────────────────────────
|
||||
* ADRs are kept in a per-project sidecar SQLite DB ("<graph_db>.adr.db"), so a
|
||||
* reindex that deletes/replaces the graph DB never disturbs them (no capture,
|
||||
* re-read or re-apply, no lock). adr_conn() returns the connection ADR ops run
|
||||
* on: the sidecar for a file-backed store (opened, schema-created and migrated
|
||||
* once, then cached), or the graph DB for a :memory: store (never file-swapped,
|
||||
* so there is no race to avoid there). */
|
||||
|
||||
/* The sidecar's on-disk path ("<db_path>.adr.db"), routed through
|
||||
* cbm_path_for_file_api so a long path gets the Windows \\?\ prefix (like
|
||||
* store_open_internal). Returns false for a :memory: store or an over-long
|
||||
* path. `raw` receives the plain path for existence checks/unlinks; `api`
|
||||
* receives the file-API form used to open. */
|
||||
static bool adr_sidecar_path(const cbm_store_t *s, char *raw, size_t raw_sz, char *api,
|
||||
size_t api_sz) {
|
||||
if (!s->db_path) {
|
||||
return false;
|
||||
}
|
||||
int n = snprintf(raw, raw_sz, "%s.adr.db", s->db_path);
|
||||
if (n <= 0 || (size_t)n >= raw_sz) {
|
||||
return false;
|
||||
}
|
||||
return cbm_path_for_file_api(raw, api, api_sz);
|
||||
}
|
||||
|
||||
/* Match the graph store's locking so cross-process ADR access waits rather than
|
||||
* failing: WAL + a 10 s busy timeout (SQLite's own lock wait, not a hand-rolled
|
||||
* poll). Best-effort: a read-only sidecar rejects journal_mode, which is fine. */
|
||||
static void adr_configure(sqlite3 *db) {
|
||||
(void)sqlite3_exec(db, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL);
|
||||
(void)sqlite3_exec(db, "PRAGMA journal_mode = WAL;", NULL, NULL, NULL);
|
||||
}
|
||||
|
||||
static int adr_ensure_schema(sqlite3 *db) {
|
||||
/* project_summaries has the same shape as the graph DB's, so migration is a
|
||||
* plain row copy and the cbm_adr_t mapping is unchanged. adr_meta carries a
|
||||
* one-time "migrated" marker so the legacy copy runs exactly once and can
|
||||
* never resurrect a row deleted after migration. */
|
||||
int rc = sqlite3_exec(db,
|
||||
"CREATE TABLE IF NOT EXISTS project_summaries ("
|
||||
" project TEXT PRIMARY KEY,"
|
||||
" summary TEXT NOT NULL,"
|
||||
" source_hash TEXT NOT NULL,"
|
||||
" created_at TEXT NOT NULL,"
|
||||
" updated_at TEXT NOT NULL"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS adr_meta ("
|
||||
" key TEXT PRIMARY KEY,"
|
||||
" value TEXT NOT NULL"
|
||||
");",
|
||||
NULL, NULL, NULL);
|
||||
return (rc == SQLITE_OK) ? CBM_STORE_OK : CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
/* True once the one-time legacy migration has been recorded in the sidecar. */
|
||||
static bool adr_migrated(sqlite3 *adb) {
|
||||
sqlite3_stmt *st = NULL;
|
||||
if (sqlite3_prepare_v2(adb, "SELECT 1 FROM adr_meta WHERE key='migrated' LIMIT 1;",
|
||||
CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
bool done = sqlite3_step(st) == SQLITE_ROW;
|
||||
sqlite3_finalize(st);
|
||||
return done;
|
||||
}
|
||||
|
||||
/* Copy every ADR row from the graph DB's project_summaries into the sidecar with
|
||||
* INSERT OR IGNORE (so a row already present locally is never overwritten). Must
|
||||
* run inside a transaction on the sidecar connection. Returns OK/ERR; does not
|
||||
* touch the `migrated` marker. */
|
||||
static int adr_copy_rows(cbm_store_t *s, sqlite3 *adb) {
|
||||
sqlite3_stmt *probe = NULL;
|
||||
if (sqlite3_prepare_v2(
|
||||
s->db,
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;",
|
||||
CBM_NOT_FOUND, &probe, NULL) != SQLITE_OK) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
bool has_legacy = sqlite3_step(probe) == SQLITE_ROW;
|
||||
sqlite3_finalize(probe);
|
||||
if (!has_legacy) {
|
||||
return CBM_STORE_OK; /* no source table -> nothing to copy */
|
||||
}
|
||||
|
||||
sqlite3_stmt *sel = NULL;
|
||||
if (sqlite3_prepare_v2(s->db,
|
||||
"SELECT project, summary, source_hash, created_at, updated_at "
|
||||
"FROM project_summaries;",
|
||||
CBM_NOT_FOUND, &sel, NULL) != SQLITE_OK) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
sqlite3_stmt *ins = NULL;
|
||||
if (sqlite3_prepare_v2(adb,
|
||||
"INSERT OR IGNORE INTO project_summaries "
|
||||
"(project, summary, source_hash, created_at, updated_at) "
|
||||
"VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
CBM_NOT_FOUND, &ins, NULL) != SQLITE_OK) {
|
||||
sqlite3_finalize(sel);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
int rc = SQLITE_DONE;
|
||||
while ((rc = sqlite3_step(sel)) == SQLITE_ROW) {
|
||||
for (int c = 0; c < ST_COL_5; c++) {
|
||||
bind_text(ins, c + 1, (const char *)sqlite3_column_text(sel, c));
|
||||
}
|
||||
int irc = sqlite3_step(ins);
|
||||
sqlite3_reset(ins);
|
||||
sqlite3_clear_bindings(ins);
|
||||
if (irc != SQLITE_DONE) {
|
||||
sqlite3_finalize(sel);
|
||||
sqlite3_finalize(ins);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(sel);
|
||||
sqlite3_finalize(ins);
|
||||
return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
/* Copy any legacy ADR rows from the graph DB's project_summaries into the
|
||||
* sidecar (INSERT OR IGNORE, so a local row is never clobbered), then set the
|
||||
* one-time marker. The marker check, copy and marker insert run in ONE
|
||||
* BEGIN IMMEDIATE transaction on the sidecar, so two processes migrating at
|
||||
* once cannot re-copy a row the other just deleted. Returns CBM_STORE_ERR on
|
||||
* any failure (transaction rolled back) so callers can fail closed and preserve
|
||||
* the old generation. No-op once the marker is set. */
|
||||
static int adr_copy_legacy_once(cbm_store_t *s, sqlite3 *adb) {
|
||||
if (adr_migrated(adb)) {
|
||||
return CBM_STORE_OK; /* fast path: already migrated, no lock needed */
|
||||
}
|
||||
if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
/* Re-check under the write lock: another process may have migrated between
|
||||
* the fast-path check and acquiring the lock. */
|
||||
if (adr_migrated(adb)) {
|
||||
(void)sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL);
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
if (adr_copy_rows(s, adb) != CBM_STORE_OK) {
|
||||
goto fail;
|
||||
}
|
||||
if (sqlite3_exec(adb, "INSERT OR IGNORE INTO adr_meta (key, value) VALUES ('migrated','1');",
|
||||
NULL, NULL, NULL) != SQLITE_OK) {
|
||||
goto fail;
|
||||
}
|
||||
if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
goto fail;
|
||||
}
|
||||
return CBM_STORE_OK;
|
||||
|
||||
fail:
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
/* Restore ADRs from the graph DB into the sidecar for any project the sidecar has
|
||||
* no row for, WITHOUT overwriting a local one, regardless of the `migrated`
|
||||
* marker. This is what import needs: an artifact carries its ADR in the graph
|
||||
* DB's project_summaries, and a teammate who already indexed the project (so the
|
||||
* sidecar is marked, but has no ADR) must still receive it. INSERT OR IGNORE
|
||||
* guarantees a local ADR is never clobbered; running ungated by the marker is
|
||||
* what distinguishes this from the one-time legacy migration. It never writes
|
||||
* the marker: `adb` comes from adr_conn_write, which hands out a sidecar handle
|
||||
* only after adr_copy_legacy_once committed it, and nothing deletes it. On
|
||||
* failure the store error names the step (the import logs it). */
|
||||
static int adr_restore_if_absent(cbm_store_t *s, sqlite3 *adb) {
|
||||
if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite_on(s, adb, "adr restore begin");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
if (adr_copy_rows(s, adb) != CBM_STORE_OK) {
|
||||
store_set_error(s, "adr restore copy failed");
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite_on(s, adb, "adr restore commit");
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
/* Open (create) the read-write sidecar, configure locking, ensure the schema,
|
||||
* run the one-time legacy migration, and cache the handle. NULL + error set on
|
||||
* failure (fail closed). :memory: stores return the graph DB (no race there). */
|
||||
static sqlite3 *adr_conn_write(cbm_store_t *s) {
|
||||
if (!s || !s->db) {
|
||||
return NULL;
|
||||
}
|
||||
if (!s->db_path) {
|
||||
return s->db; /* :memory: store — ADRs stay in the graph DB */
|
||||
}
|
||||
if (s->adr_db) {
|
||||
return s->adr_db;
|
||||
}
|
||||
char raw[4096];
|
||||
char api[4096];
|
||||
if (!adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api))) {
|
||||
store_set_error(s, "adr sidecar path too long");
|
||||
return NULL;
|
||||
}
|
||||
sqlite3 *adb = NULL;
|
||||
int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
|
||||
if (sqlite3_open_v2(api, &adb, flags, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite_on(s, adb, "adr sidecar open");
|
||||
sqlite3_close_v2(adb);
|
||||
return NULL;
|
||||
}
|
||||
adr_configure(adb);
|
||||
if (adr_ensure_schema(adb) != CBM_STORE_OK) {
|
||||
store_set_error_sqlite_on(s, adb, "adr sidecar schema");
|
||||
sqlite3_close_v2(adb);
|
||||
return NULL;
|
||||
}
|
||||
if (adr_copy_legacy_once(s, adb) != CBM_STORE_OK) {
|
||||
store_set_error(s, "adr sidecar legacy migration failed");
|
||||
sqlite3_close_v2(adb);
|
||||
return NULL;
|
||||
}
|
||||
s->adr_db = adb;
|
||||
return s->adr_db;
|
||||
}
|
||||
|
||||
int cbm_store_adr_migrate_once(cbm_store_t *s) {
|
||||
if (!s || !s->db) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
if (!s->db_path) {
|
||||
return CBM_STORE_OK; /* :memory: — ADRs already in the graph DB, nothing to move */
|
||||
}
|
||||
return adr_conn_write(s) ? CBM_STORE_OK : CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s) {
|
||||
if (!s || !s->db) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
if (!s->db_path) {
|
||||
return CBM_STORE_OK; /* :memory: — ADR already lives in the graph DB */
|
||||
}
|
||||
sqlite3 *adb = adr_conn_write(s);
|
||||
if (!adb) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return adr_restore_if_absent(s, adb);
|
||||
}
|
||||
|
||||
int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content) {
|
||||
sqlite3 *adb = adr_conn_write(s);
|
||||
if (!adb) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
char now[CBM_SZ_32];
|
||||
iso_now(now, sizeof(now));
|
||||
|
||||
@@ -11933,8 +12207,8 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content
|
||||
"ON CONFLICT(project) DO UPDATE SET summary=excluded.summary, "
|
||||
"updated_at=excluded.updated_at";
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "adr_store");
|
||||
if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite_on(s, adb, "adr_store");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
bind_text(stmt, SKIP_ONE, project);
|
||||
@@ -11946,22 +12220,17 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content
|
||||
return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
|
||||
if (!s || !s->db || !project || !out) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* ADR storage was added after the original graph schema. A readable
|
||||
* legacy generation without project_summaries has no ADR to preserve; it
|
||||
* is not a read failure and must remain replaceable by a full reindex. */
|
||||
/* Read one ADR row from an already-open connection into `out`. Returns
|
||||
* CBM_STORE_OK / CBM_STORE_NOT_FOUND / CBM_STORE_ERR. Never writes. */
|
||||
static int adr_read_row(cbm_store_t *s, sqlite3 *adb, const char *project, cbm_adr_t *out) {
|
||||
/* A readable legacy generation without project_summaries has no ADR; that is
|
||||
* NOT_FOUND, not a read failure. */
|
||||
sqlite3_stmt *table_stmt = NULL;
|
||||
int rc = sqlite3_prepare_v2(
|
||||
s->db,
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;",
|
||||
adb, "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;",
|
||||
CBM_NOT_FOUND, &table_stmt, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "adr_get table probe");
|
||||
store_set_error_sqlite_on(s, adb, "adr_get table probe");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
rc = sqlite3_step(table_stmt);
|
||||
@@ -11970,26 +12239,26 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
|
||||
return CBM_STORE_NOT_FOUND;
|
||||
}
|
||||
if (rc != SQLITE_ROW || sqlite3_finalize(table_stmt) != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "adr_get table probe step");
|
||||
store_set_error_sqlite_on(s, adb, "adr_get table probe step");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
const char *sql = "SELECT project, summary, created_at, updated_at FROM project_summaries "
|
||||
"WHERE project=?1";
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "adr_get");
|
||||
if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite_on(s, adb, "adr_get");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
if (bind_text(stmt, SKIP_ONE, project) != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "adr_get bind");
|
||||
store_set_error_sqlite_on(s, adb, "adr_get bind");
|
||||
sqlite3_finalize(stmt);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
rc = sqlite3_step(stmt);
|
||||
if (rc != SQLITE_ROW) {
|
||||
if (rc != SQLITE_DONE) {
|
||||
store_set_error_sqlite(s, "adr_get step");
|
||||
store_set_error_sqlite_on(s, adb, "adr_get step");
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc == SQLITE_DONE) {
|
||||
@@ -12005,13 +12274,11 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
|
||||
rc = sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_OK) {
|
||||
cbm_store_adr_free(out);
|
||||
store_set_error_sqlite(s, "adr_get finalize");
|
||||
store_set_error_sqlite_on(s, adb, "adr_get finalize");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
/* Every selected column is NOT NULL in the schema. A NULL here therefore
|
||||
* means either allocation failure or corrupt data; never return a partial
|
||||
* ADR that a full rebuild could silently drop during publication. */
|
||||
/* Every selected column is NOT NULL in the schema. A NULL here means an
|
||||
* allocation failure or corruption; never return a partial ADR. */
|
||||
if (!out->project || !out->content || !out->created_at || !out->updated_at) {
|
||||
cbm_store_adr_free(out);
|
||||
store_set_error(s, "adr_get: failed to copy complete ADR");
|
||||
@@ -12020,16 +12287,62 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
|
||||
if (!s || !s->db || !project || !out) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* :memory: store, or a sidecar already open read-write: read it directly. */
|
||||
if (!s->db_path) {
|
||||
return adr_read_row(s, s->db, project, out);
|
||||
}
|
||||
if (s->adr_db) {
|
||||
return adr_read_row(s, s->adr_db, project, out);
|
||||
}
|
||||
|
||||
/* A read must never create the sidecar (a query-only cache may be
|
||||
* read-only): open it READONLY if the file exists, otherwise read the
|
||||
* legacy row from the graph DB without migrating. */
|
||||
char raw[4096];
|
||||
char api[4096];
|
||||
if (adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api)) && cbm_file_exists(raw)) {
|
||||
sqlite3 *ro = NULL;
|
||||
if (sqlite3_open_v2(api, &ro, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite_on(s, ro, "adr sidecar open (ro)");
|
||||
sqlite3_close_v2(ro);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
(void)sqlite3_exec(ro, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL);
|
||||
/* A sidecar that exists but was never marked migrated is half-created:
|
||||
* the legacy copy did not commit, so the ADR (if any) still lives only
|
||||
* in the graph DB. Fall back to the graph row so a partial sidecar never
|
||||
* hides an existing ADR. */
|
||||
if (!adr_migrated(ro)) {
|
||||
sqlite3_close_v2(ro);
|
||||
return adr_read_row(s, s->db, project, out);
|
||||
}
|
||||
int rc = adr_read_row(s, ro, project, out);
|
||||
sqlite3_close_v2(ro);
|
||||
return rc;
|
||||
}
|
||||
return adr_read_row(s, s->db, project, out);
|
||||
}
|
||||
|
||||
int cbm_store_adr_delete(cbm_store_t *s, const char *project) {
|
||||
sqlite3 *adb = adr_conn_write(s);
|
||||
if (!adb) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
const char *sql = "DELETE FROM project_summaries WHERE project=?1";
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "adr_delete");
|
||||
if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite_on(s, adb, "adr_delete");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
bind_text(stmt, SKIP_ONE, project);
|
||||
int rc = sqlite3_step(stmt);
|
||||
int changes = sqlite3_changes(s->db);
|
||||
int changes = sqlite3_changes(adb);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) {
|
||||
return CBM_STORE_ERR;
|
||||
@@ -12047,13 +12360,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
/* The read-modify-write below must be ONE transaction. Three writers
|
||||
* replace this row wholesale — the indexing pipeline, the UI POST
|
||||
* /api/adr handler, and manage_adr mode='update' — so an unguarded
|
||||
* get/merge/store silently loses whichever of them commits between the
|
||||
* read and the UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a
|
||||
* competing writer waits rather than being overwritten. */
|
||||
if (cbm_store_begin(s) != CBM_STORE_OK) {
|
||||
/* The read-modify-write below must be ONE transaction, and it runs on the
|
||||
* ADR connection (the sidecar for a file store, the graph DB for :memory:)
|
||||
* — the same connection cbm_store_adr_get/store use, so the write lock
|
||||
* actually covers them. Two writers replace this row wholesale — the UI
|
||||
* POST /api/adr handler and manage_adr mode='update' — so an unguarded
|
||||
* get/merge/store silently loses whichever commits between the read and the
|
||||
* UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a competing
|
||||
* writer waits rather than being overwritten. */
|
||||
sqlite3 *adb = adr_conn_write(s);
|
||||
if (!adb) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite_on(s, adb, "adr_update begin");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
@@ -12061,7 +12381,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
|
||||
cbm_adr_t existing;
|
||||
int rc = cbm_store_adr_get(s, project, &existing);
|
||||
if (rc != CBM_STORE_OK) {
|
||||
(void)cbm_store_rollback(s);
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
store_set_error(s, "no existing ADR to update");
|
||||
return rc;
|
||||
}
|
||||
@@ -12076,7 +12396,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
|
||||
if (cbm_adr_check_structure(existing.content, structure_err, (int)sizeof(structure_err)) !=
|
||||
CBM_STORE_OK) {
|
||||
cbm_store_adr_free(&existing);
|
||||
(void)cbm_store_rollback(s);
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
store_set_error(s, structure_err);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
@@ -12090,7 +12410,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
|
||||
}
|
||||
|
||||
if (!merged) {
|
||||
(void)cbm_store_rollback(s);
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
store_set_error(s, "failed to splice ADR section");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
@@ -12101,7 +12421,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
|
||||
snprintf(msg, sizeof(msg), "merged ADR exceeds %d chars (%d chars)", CBM_ADR_MAX_LENGTH,
|
||||
(int)strlen(merged));
|
||||
free(merged);
|
||||
(void)cbm_store_rollback(s);
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
store_set_error(s, msg);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
@@ -12110,19 +12430,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha
|
||||
rc = cbm_store_adr_store(s, project, merged);
|
||||
free(merged);
|
||||
if (rc != CBM_STORE_OK) {
|
||||
(void)cbm_store_rollback(s);
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* Read back INSIDE the transaction so `out` is exactly what commits. */
|
||||
rc = cbm_store_adr_get(s, project, out);
|
||||
if (rc != CBM_STORE_OK) {
|
||||
(void)cbm_store_rollback(s);
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
return rc;
|
||||
}
|
||||
if (cbm_store_commit(s) != CBM_STORE_OK) {
|
||||
if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite_on(s, adb, "adr_update commit");
|
||||
cbm_store_adr_free(out);
|
||||
(void)cbm_store_rollback(s);
|
||||
(void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL);
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
return CBM_STORE_OK;
|
||||
|
||||
@@ -1163,6 +1163,17 @@ typedef struct {
|
||||
|
||||
int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content);
|
||||
int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out);
|
||||
/* Move any legacy ADR row from the graph DB's project_summaries into the
|
||||
* per-project sidecar, once (guarded by a marker in the sidecar). Call before a
|
||||
* rebuild deletes the old generation, and on artifact import. Returns
|
||||
* CBM_STORE_ERR on failure so callers can preserve the old generation. */
|
||||
int cbm_store_adr_migrate_once(cbm_store_t *s);
|
||||
/* Restore ADRs from the graph DB's project_summaries into the per-project sidecar
|
||||
* for any project the sidecar has no row for, without overwriting a local one and
|
||||
* regardless of the migrated marker. Used on artifact import so a teammate who
|
||||
* already indexed the project (marked sidecar, no ADR) still receives the shared
|
||||
* ADR the artifact carries. Returns CBM_STORE_ERR on failure. */
|
||||
int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s);
|
||||
int cbm_store_adr_delete(cbm_store_t *s, const char *project);
|
||||
int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const char **keys,
|
||||
const char **values, int count, cbm_adr_t *out);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* test_artifact.c — Tests for persistent artifact export/import.
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include "sqlite3.h" /* vendored/sqlite3 — seed a legacy project_summaries row in ADR tests */
|
||||
#include "store/store.h"
|
||||
#include "pipeline/artifact.h"
|
||||
#include "pipeline/pipeline.h"
|
||||
@@ -64,6 +65,21 @@ static void cleanup_dir(const char *path) {
|
||||
(void)system(cmd);
|
||||
}
|
||||
|
||||
/* Remove a destination graph DB (+ its -wal/-shm/-journal) while leaving the ADR
|
||||
* sidecar "<db>.adr.db" in place. Import only runs when the graph DB is missing
|
||||
* (try_artifact_bootstrap); this mirrors that shape so the import rename does not
|
||||
* hit an existing file (which Windows refuses, unlike POSIX). */
|
||||
static void remove_graph_db_keep_sidecar(const char *db) {
|
||||
char p[1200];
|
||||
(void)cbm_unlink(db);
|
||||
snprintf(p, sizeof(p), "%s-wal", db);
|
||||
(void)cbm_unlink(p);
|
||||
snprintf(p, sizeof(p), "%s-shm", db);
|
||||
(void)cbm_unlink(p);
|
||||
snprintf(p, sizeof(p), "%s-journal", db);
|
||||
(void)cbm_unlink(p);
|
||||
}
|
||||
|
||||
static void write_text_file(const char *path, const char *text) {
|
||||
FILE *fp = fopen(path, "w");
|
||||
if (!fp) {
|
||||
@@ -206,6 +222,226 @@ TEST(artifact_export_fast_roundtrip) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The ADR travels with the exported artifact: it lives in the "<db>.adr.db"
|
||||
* sidecar, and export must carry it into graph.db.zst so an import restores it
|
||||
* into the destination's sidecar (round-2 review item 7d). */
|
||||
TEST(artifact_export_roundtrip_keeps_adr) {
|
||||
setup_artifact_test();
|
||||
create_test_db(g_db);
|
||||
|
||||
const char *adr_text = "# Decision\nThe team chose the sidecar design.";
|
||||
cbm_store_t *src = cbm_store_open_path(g_db);
|
||||
ASSERT_NOT_NULL(src);
|
||||
ASSERT_EQ(cbm_store_adr_store(src, "test-proj", adr_text), CBM_STORE_OK);
|
||||
cbm_store_close(src);
|
||||
|
||||
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
|
||||
|
||||
char import_db[1024];
|
||||
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
|
||||
ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0);
|
||||
|
||||
/* The imported project starts with the team's ADR (restored into the
|
||||
* destination sidecar by import). */
|
||||
cbm_store_t *dst = cbm_store_open_path(import_db);
|
||||
ASSERT_NOT_NULL(dst);
|
||||
cbm_adr_t adr = {0};
|
||||
ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK);
|
||||
ASSERT_NOT_NULL(adr.content);
|
||||
ASSERT_STR_EQ(adr.content, adr_text);
|
||||
cbm_store_adr_free(&adr);
|
||||
cbm_store_close(dst);
|
||||
|
||||
/* The fresh destination sidecar is marked migrated after the import, so a
|
||||
* later reindex's migrate-before-delete is a no-op. Opening the sidecar for
|
||||
* the restore already commits the marker; the restore itself adds none. */
|
||||
char side[1100];
|
||||
snprintf(side, sizeof(side), "%s.adr.db", import_db);
|
||||
sqlite3 *raw = NULL;
|
||||
ASSERT_EQ(sqlite3_open_v2(side, &raw, SQLITE_OPEN_READONLY, NULL), SQLITE_OK);
|
||||
sqlite3_stmt *st = NULL;
|
||||
ASSERT_EQ(
|
||||
sqlite3_prepare_v2(raw, "SELECT value FROM adr_meta WHERE key='migrated';", -1, &st, NULL),
|
||||
SQLITE_OK);
|
||||
ASSERT_EQ(sqlite3_step(st), SQLITE_ROW);
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_close(raw);
|
||||
|
||||
cleanup_dir(g_tmpdir);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Regression: a stale legacy `project_summaries` row that lingers in the graph DB
|
||||
* after its ADR was migrated then DELETED must NOT resurrect through an export.
|
||||
* With the sidecar empty, the snapshot must ship an empty table, so an import
|
||||
* finds no ADR. (Before the DELETE-first fix, VACUUM INTO copied the stale row
|
||||
* and import restored a deleted decision record.) */
|
||||
TEST(artifact_export_without_adr_does_not_resurrect_legacy_row) {
|
||||
setup_artifact_test();
|
||||
create_test_db(g_db);
|
||||
|
||||
/* Pre-sidecar layout: an ADR lingering only in the graph DB. */
|
||||
sqlite3 *raw = NULL;
|
||||
ASSERT_EQ(sqlite3_open(g_db, &raw), SQLITE_OK);
|
||||
char *sql = sqlite3_mprintf(
|
||||
"INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) "
|
||||
"VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');",
|
||||
"test-proj", "# Decision\nstale, later deleted");
|
||||
ASSERT_NOT_NULL(sql);
|
||||
ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK);
|
||||
sqlite3_free(sql);
|
||||
sqlite3_close(raw);
|
||||
|
||||
/* Migrate it into the sidecar (marker set), then DELETE it. The graph row
|
||||
* still lingers; the sidecar is now migrated-but-empty. */
|
||||
cbm_store_t *s = cbm_store_open_path(g_db);
|
||||
ASSERT_NOT_NULL(s);
|
||||
ASSERT_EQ(cbm_store_adr_migrate_once(s), CBM_STORE_OK);
|
||||
ASSERT_EQ(cbm_store_adr_delete(s, "test-proj"), CBM_STORE_OK);
|
||||
cbm_store_close(s);
|
||||
|
||||
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
|
||||
|
||||
char import_db[1024];
|
||||
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
|
||||
ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0);
|
||||
|
||||
cbm_store_t *dst = cbm_store_open_path(import_db);
|
||||
ASSERT_NOT_NULL(dst);
|
||||
cbm_adr_t adr = {0};
|
||||
ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_NOT_FOUND);
|
||||
cbm_store_adr_free(&adr);
|
||||
cbm_store_close(dst);
|
||||
|
||||
cleanup_dir(g_tmpdir);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* An import must not clobber an ADR already present locally: the restore uses
|
||||
* INSERT OR IGNORE, so the teammate's own decision record survives importing a
|
||||
* shared artifact. */
|
||||
TEST(artifact_import_does_not_clobber_local_adr) {
|
||||
setup_artifact_test();
|
||||
create_test_db(g_db);
|
||||
|
||||
/* The shared artifact carries ADR "A". */
|
||||
cbm_store_t *src = cbm_store_open_path(g_db);
|
||||
ASSERT_NOT_NULL(src);
|
||||
ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK);
|
||||
cbm_store_close(src);
|
||||
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
|
||||
|
||||
/* The importing machine already has its own local ADR "B" for this project. */
|
||||
char import_db[1024];
|
||||
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
|
||||
cbm_store_t *local = cbm_store_open_path(import_db);
|
||||
ASSERT_NOT_NULL(local);
|
||||
ASSERT_EQ(cbm_store_adr_store(local, "test-proj", "# Decision\nlocal B"), CBM_STORE_OK);
|
||||
cbm_store_close(local);
|
||||
|
||||
/* Import only runs when the graph DB is missing (try_artifact_bootstrap), and
|
||||
* Windows cannot rename over an existing file. Remove the destination graph DB
|
||||
* (keeping the sidecar + its marker) to match production. */
|
||||
remove_graph_db_keep_sidecar(import_db);
|
||||
ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0);
|
||||
|
||||
/* Local B is preserved — the import did not overwrite it with shared A. */
|
||||
cbm_store_t *dst = cbm_store_open_path(import_db);
|
||||
ASSERT_NOT_NULL(dst);
|
||||
cbm_adr_t adr = {0};
|
||||
ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK);
|
||||
ASSERT_NOT_NULL(adr.content);
|
||||
ASSERT_STR_EQ(adr.content, "# Decision\nlocal B");
|
||||
cbm_store_adr_free(&adr);
|
||||
cbm_store_close(dst);
|
||||
|
||||
cleanup_dir(g_tmpdir);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The shared ADR must reach a teammate who has ALREADY indexed the project: their
|
||||
* sidecar is marked migrated but has no ADR row, so the marker-gated migrate-once
|
||||
* would skip it. Import restores-if-absent (ungated), so the artifact's ADR "A"
|
||||
* arrives. (On main, import replaced the whole DB, so the ADR always came through;
|
||||
* the sidecar must preserve that.) */
|
||||
TEST(artifact_import_restores_adr_when_local_absent) {
|
||||
setup_artifact_test();
|
||||
create_test_db(g_db);
|
||||
|
||||
/* The shared artifact carries ADR "A". */
|
||||
cbm_store_t *src = cbm_store_open_path(g_db);
|
||||
ASSERT_NOT_NULL(src);
|
||||
ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK);
|
||||
cbm_store_close(src);
|
||||
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
|
||||
|
||||
/* The destination is already indexed: a marked sidecar with NO ADR row. */
|
||||
char import_db[1024];
|
||||
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
|
||||
create_test_db(import_db);
|
||||
cbm_store_t *pre = cbm_store_open_path(import_db);
|
||||
ASSERT_NOT_NULL(pre);
|
||||
ASSERT_EQ(cbm_store_adr_migrate_once(pre), CBM_STORE_OK); /* marks the sidecar, no ADR */
|
||||
cbm_adr_t none = {0};
|
||||
ASSERT_EQ(cbm_store_adr_get(pre, "test-proj", &none), CBM_STORE_NOT_FOUND);
|
||||
cbm_store_adr_free(&none);
|
||||
cbm_store_close(pre);
|
||||
|
||||
/* Import only runs when the graph DB is missing; remove it (keep the marked
|
||||
* sidecar) so the rename succeeds on Windows too. */
|
||||
remove_graph_db_keep_sidecar(import_db);
|
||||
ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0);
|
||||
|
||||
/* The shared ADR "A" is restored despite the pre-existing marker. */
|
||||
cbm_store_t *dst = cbm_store_open_path(import_db);
|
||||
ASSERT_NOT_NULL(dst);
|
||||
cbm_adr_t adr = {0};
|
||||
ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK);
|
||||
ASSERT_NOT_NULL(adr.content);
|
||||
ASSERT_STR_EQ(adr.content, "# Decision\nshared A");
|
||||
cbm_store_adr_free(&adr);
|
||||
cbm_store_close(dst);
|
||||
|
||||
cleanup_dir(g_tmpdir);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A failed ADR restore must not be silent (#2315 follow-up). A DIRECTORY at
|
||||
* "<db>.adr.db" makes the sidecar open fail deterministically (no hook). The
|
||||
* graph DB is already installed when the restore runs, so the import still
|
||||
* succeeds (best-effort, unchanged), but the team ADR that did not arrive is
|
||||
* logged with the store's reason instead of being dropped without a trace. */
|
||||
TEST(artifact_import_logs_failed_adr_restore) {
|
||||
setup_artifact_test();
|
||||
create_test_db(g_db);
|
||||
|
||||
cbm_store_t *src = cbm_store_open_path(g_db);
|
||||
ASSERT_NOT_NULL(src);
|
||||
ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK);
|
||||
cbm_store_close(src);
|
||||
ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0);
|
||||
|
||||
char import_db[1024];
|
||||
snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir);
|
||||
char adr_dir[1100];
|
||||
snprintf(adr_dir, sizeof(adr_dir), "%s.adr.db", import_db);
|
||||
ASSERT_EQ(cbm_mkdir(adr_dir), 0);
|
||||
|
||||
capture_logs_start();
|
||||
int rc = cbm_artifact_import(g_repo, import_db);
|
||||
const char *logs = capture_logs_end();
|
||||
|
||||
ASSERT_EQ(rc, 0); /* the graph DB is installed; the restore stays best-effort */
|
||||
ASSERT_TRUE(cbm_file_exists(import_db));
|
||||
const char *line = strstr(logs, "level=warn msg=artifact.import err=adr_restore");
|
||||
ASSERT_NOT_NULL(line);
|
||||
/* The store's reason is carried (the text log format maps spaces to '_'). */
|
||||
ASSERT(strstr(line, "detail=adr_sidecar_open") != NULL);
|
||||
|
||||
cleanup_dir(g_tmpdir);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(artifact_export_best_roundtrip) {
|
||||
setup_artifact_test();
|
||||
create_test_db(g_db);
|
||||
@@ -1172,6 +1408,11 @@ SUITE(artifact) {
|
||||
RUN_TEST(artifact_repo_path_shell_safe_rejects_injection);
|
||||
RUN_TEST(artifact_repo_path_shell_safe_rejects_cmd_metachars_on_windows);
|
||||
RUN_TEST(artifact_export_fast_roundtrip);
|
||||
RUN_TEST(artifact_export_roundtrip_keeps_adr);
|
||||
RUN_TEST(artifact_export_without_adr_does_not_resurrect_legacy_row);
|
||||
RUN_TEST(artifact_import_does_not_clobber_local_adr);
|
||||
RUN_TEST(artifact_import_restores_adr_when_local_absent);
|
||||
RUN_TEST(artifact_import_logs_failed_adr_restore);
|
||||
RUN_TEST(artifact_export_best_roundtrip);
|
||||
RUN_TEST(artifact_exists_check);
|
||||
RUN_TEST(artifact_commit_hash);
|
||||
|
||||
@@ -6180,6 +6180,149 @@ TEST(cli_zed_mcp_jsonc_comments) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
TEST(cli_install_agent_config_symlinks_issue2671) {
|
||||
/* Refs #2671: dotfiles checkout pattern where agent config directory is a
|
||||
* user-owned symlink into dotfiles under the user's home directory. */
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-symlink-test-XXXXXX");
|
||||
if (!cbm_mkdtemp(tmpdir))
|
||||
FAIL("cbm_mkdtemp failed");
|
||||
|
||||
char dotfiles_zed[512];
|
||||
snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir);
|
||||
test_mkdirp(dotfiles_zed);
|
||||
char dotfiles_settings[512];
|
||||
snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed);
|
||||
write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n");
|
||||
|
||||
#ifdef __APPLE__
|
||||
char parent_dir[512];
|
||||
snprintf(parent_dir, sizeof(parent_dir), "%s/Library/Application Support", tmpdir);
|
||||
test_mkdirp(parent_dir);
|
||||
char zed_link[512];
|
||||
snprintf(zed_link, sizeof(zed_link), "%s/Zed", parent_dir);
|
||||
#else
|
||||
char parent_dir[512];
|
||||
snprintf(parent_dir, sizeof(parent_dir), "%s/.config", tmpdir);
|
||||
test_mkdirp(parent_dir);
|
||||
char zed_link[512];
|
||||
snprintf(zed_link, sizeof(zed_link), "%s/zed", parent_dir);
|
||||
#endif
|
||||
|
||||
char *saved_xdg = save_test_env("XDG_CONFIG_HOME");
|
||||
char xdg_dir[640];
|
||||
snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir);
|
||||
cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */
|
||||
|
||||
/* Dangling symlink and symlink to a regular file must remain undetected. */
|
||||
char dangling_target[512];
|
||||
snprintf(dangling_target, sizeof(dangling_target), "%s/dotfiles/missing-zed", tmpdir);
|
||||
ASSERT_EQ(symlink(dangling_target, zed_link), 0);
|
||||
bool dangling_detected = cbm_detect_agents(tmpdir).zed;
|
||||
ASSERT_EQ(cbm_unlink(zed_link), 0);
|
||||
|
||||
ASSERT_EQ(symlink(dotfiles_settings, zed_link), 0);
|
||||
bool file_link_detected = cbm_detect_agents(tmpdir).zed;
|
||||
ASSERT_EQ(cbm_unlink(zed_link), 0);
|
||||
|
||||
/* Symlink resolving to a directory not owned by the invoking user must be refused. */
|
||||
char foreign_dir[512];
|
||||
const char *unowned_target = "/";
|
||||
if (geteuid() == 0) {
|
||||
snprintf(foreign_dir, sizeof(foreign_dir), "%s/dotfiles/foreign-zed", tmpdir);
|
||||
test_mkdirp(foreign_dir);
|
||||
ASSERT_EQ(chown(foreign_dir, 65534, 65534), 0);
|
||||
unowned_target = foreign_dir;
|
||||
}
|
||||
ASSERT_EQ(symlink(unowned_target, zed_link), 0);
|
||||
bool unowned_target_detected = cbm_detect_agents(tmpdir).zed;
|
||||
ASSERT_EQ(cbm_unlink(zed_link), 0);
|
||||
|
||||
ASSERT_EQ(symlink(dotfiles_zed, zed_link), 0);
|
||||
|
||||
cbm_cli_set_client_selection_for_testing("zed");
|
||||
int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false);
|
||||
cbm_cli_set_client_selection_for_testing(NULL);
|
||||
restore_test_env("XDG_CONFIG_HOME", saved_xdg);
|
||||
|
||||
char *after = read_test_file_alloc(dotfiles_settings);
|
||||
bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL;
|
||||
bool has_original = after && strstr(after, "Ayu Dark") != NULL;
|
||||
free(after);
|
||||
|
||||
struct stat st;
|
||||
ASSERT_EQ(lstat(zed_link, &st), 0);
|
||||
bool still_symlink = S_ISLNK(st.st_mode);
|
||||
|
||||
test_rmdir_r(tmpdir);
|
||||
ASSERT(!dangling_detected);
|
||||
ASSERT(!file_link_detected);
|
||||
ASSERT(!unowned_target_detected);
|
||||
ASSERT_EQ(rc, 0);
|
||||
ASSERT(has_cbm);
|
||||
ASSERT(has_original);
|
||||
ASSERT(still_symlink);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cli_install_agent_config_file_symlink_issue2671) {
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-file-symlink-test-XXXXXX");
|
||||
if (!cbm_mkdtemp(tmpdir))
|
||||
FAIL("cbm_mkdtemp failed");
|
||||
|
||||
char dotfiles_zed[512];
|
||||
snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir);
|
||||
test_mkdirp(dotfiles_zed);
|
||||
char dotfiles_settings[512];
|
||||
snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed);
|
||||
write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n");
|
||||
|
||||
#ifdef __APPLE__
|
||||
char zed_dir[512];
|
||||
snprintf(zed_dir, sizeof(zed_dir), "%s/Library/Application Support/Zed", tmpdir);
|
||||
test_mkdirp(zed_dir);
|
||||
char zed_settings_link[512];
|
||||
snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir);
|
||||
ASSERT_EQ(symlink("../../../dotfiles/zed/settings.json", zed_settings_link), 0);
|
||||
#else
|
||||
char zed_dir[512];
|
||||
snprintf(zed_dir, sizeof(zed_dir), "%s/.config/zed", tmpdir);
|
||||
test_mkdirp(zed_dir);
|
||||
char zed_settings_link[512];
|
||||
snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir);
|
||||
ASSERT_EQ(symlink("../../dotfiles/zed/settings.json", zed_settings_link), 0);
|
||||
#endif
|
||||
|
||||
char *saved_xdg = save_test_env("XDG_CONFIG_HOME");
|
||||
char xdg_dir[640];
|
||||
snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir);
|
||||
cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */
|
||||
|
||||
cbm_cli_set_client_selection_for_testing("zed");
|
||||
int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false);
|
||||
cbm_cli_set_client_selection_for_testing(NULL);
|
||||
restore_test_env("XDG_CONFIG_HOME", saved_xdg);
|
||||
|
||||
char *after = read_test_file_alloc(dotfiles_settings);
|
||||
bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL;
|
||||
bool has_original = after && strstr(after, "Ayu Dark") != NULL;
|
||||
free(after);
|
||||
|
||||
struct stat st;
|
||||
ASSERT_EQ(lstat(zed_settings_link, &st), 0);
|
||||
bool still_symlink = S_ISLNK(st.st_mode);
|
||||
|
||||
test_rmdir_r(tmpdir);
|
||||
ASSERT_EQ(rc, 0);
|
||||
ASSERT(has_cbm);
|
||||
ASSERT(has_original);
|
||||
ASSERT(still_symlink);
|
||||
PASS();
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
* PATH management tests (port of TestCLI_InstallPATHAppend)
|
||||
* ═══════════════════════════════════════════════════════════════════ */
|
||||
@@ -9429,6 +9572,127 @@ TEST(cli_dry_run_predicts_refused_hook_script_issue1387) {
|
||||
FAIL("dry-run must predict a refused hook-script rewrite (#1387)");
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #2204: the OpenCode adapter is an auto-loaded module, and the generated block
|
||||
* tells readers to "remove the markers to take ownership". A marker-less file
|
||||
* with content is the user's: appending the block to a hand-ported module
|
||||
* produces a file the client cannot even parse (the prelude redeclares the
|
||||
* user's own import and const names, and both sides declare a default export).
|
||||
* The installer must leave that file alone, and the dry run must say so instead
|
||||
* of promising a write it will not make. A dry run never writes, so the byte
|
||||
* check below follows a REAL install: that is the run that appended. */
|
||||
TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) {
|
||||
char home[256];
|
||||
char bin[320];
|
||||
if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin)))
|
||||
FAIL("cbm_mkdtemp failed");
|
||||
|
||||
/* An `opencode` CLI on the isolated PATH marks OpenCode as installed. */
|
||||
char exe[512];
|
||||
snprintf(exe, sizeof(exe), "%s/opencode", bin);
|
||||
if (write_test_file(exe, "#!/bin/sh\nexit 0\n") != 0 || chmod(exe, 0755) != 0)
|
||||
FAIL("opencode fixture failed");
|
||||
|
||||
char plugins_dir[512];
|
||||
snprintf(plugins_dir, sizeof(plugins_dir), "%s/.config/opencode/plugins", home);
|
||||
test_mkdirp(plugins_dir);
|
||||
char plugin_path[768];
|
||||
snprintf(plugin_path, sizeof(plugin_path), "%s/cbm-augment.ts", plugins_dir);
|
||||
const char foreign[] = "import { spawn } from 'node:child_process';\n"
|
||||
"const BIN = '/opt/other/codebase-memory-mcp';\n"
|
||||
"export default { id: 'codebase-memory-augment', setup() {} };\n";
|
||||
if (write_test_file(plugin_path, foreign) != 0)
|
||||
FAIL("foreign module fixture failed");
|
||||
|
||||
client_detect_env_t saved = client_detect_env_isolate(bin);
|
||||
FILE *capture = tmpfile();
|
||||
int saved_stdout = capture ? dup(STDOUT_FILENO) : -1;
|
||||
bool redirected = false;
|
||||
if (capture && saved_stdout >= 0) {
|
||||
fflush(stdout);
|
||||
redirected = dup2(fileno(capture), STDOUT_FILENO) >= 0;
|
||||
}
|
||||
long plan_output_start = -1;
|
||||
long plan_output_end = -1;
|
||||
char *plan = NULL;
|
||||
if (redirected) {
|
||||
(void)cbm_install_agent_configs(home, "/opt/other/codebase-memory-mcp", false, true);
|
||||
fflush(stdout);
|
||||
/* The --plan receipt (#388) replays the same path with writes off. It
|
||||
* must not list a module the run leaves alone, and it must print
|
||||
* nothing: stdout carries the receipt JSON. */
|
||||
plan_output_start = (long)lseek(STDOUT_FILENO, 0, SEEK_CUR);
|
||||
plan = cbm_build_install_plan_json(home, "/opt/other/codebase-memory-mcp");
|
||||
fflush(stdout);
|
||||
/* The real install, the run #2204 is about. */
|
||||
plan_output_end = (long)lseek(STDOUT_FILENO, 0, SEEK_CUR);
|
||||
(void)cbm_install_agent_configs(home, "/opt/other/codebase-memory-mcp", false, false);
|
||||
fflush(stdout);
|
||||
(void)dup2(saved_stdout, STDOUT_FILENO);
|
||||
}
|
||||
if (saved_stdout >= 0)
|
||||
close(saved_stdout);
|
||||
|
||||
char bytes[512] = {0};
|
||||
FILE *file = cbm_fopen(plugin_path, "rb");
|
||||
size_t got = file ? fread(bytes, 1U, sizeof(bytes) - 1U, file) : 0U;
|
||||
if (file)
|
||||
fclose(file);
|
||||
|
||||
/* Control: with the user's module gone the receipt does list the path, so
|
||||
* the omission above cannot pass just because OpenCode went undetected. */
|
||||
char *control_plan = NULL;
|
||||
if (unlink(plugin_path) == 0)
|
||||
control_plan = cbm_build_install_plan_json(home, "/opt/other/codebase-memory-mcp");
|
||||
client_detect_env_restore(&saved);
|
||||
|
||||
char output[16384] = {0};
|
||||
if (capture) {
|
||||
rewind(capture);
|
||||
size_t count = fread(output, 1, sizeof(output) - 1U, capture);
|
||||
output[count] = '\0';
|
||||
fclose(capture);
|
||||
}
|
||||
|
||||
/* Each phase owns its slice of stdout: [0, start) dry run, [start, end)
|
||||
* plan mode, [end, len) real install, so no phase passes on another's
|
||||
* line. Checked back to front, cutting the buffer at each boundary. */
|
||||
bool sliced = plan_output_start >= 0 && plan_output_end >= plan_output_start &&
|
||||
(size_t)plan_output_end <= strlen(output);
|
||||
bool reported =
|
||||
sliced && strstr(output + plan_output_end, "cbm-augment.ts left untouched") != NULL;
|
||||
if (sliced)
|
||||
output[plan_output_end] = '\0';
|
||||
bool plan_silent = sliced && strstr(output + plan_output_start, "cbm-augment.ts") == NULL;
|
||||
if (sliced)
|
||||
output[plan_output_start] = '\0';
|
||||
bool warned =
|
||||
sliced && strstr(output, "cbm-augment.ts") != NULL && strstr(output, "not ours") != NULL;
|
||||
bool untouched = got == sizeof(foreign) - 1U && memcmp(bytes, foreign, got) == 0;
|
||||
bool plan_omits = plan && strstr(plan, "cbm-augment.ts") == NULL;
|
||||
bool control_lists = control_plan && strstr(control_plan, "cbm-augment.ts") != NULL;
|
||||
free(plan);
|
||||
free(control_plan);
|
||||
|
||||
test_rmdir_r(home);
|
||||
if (!redirected)
|
||||
FAIL("stdout capture failed");
|
||||
if (!sliced)
|
||||
FAIL("stdout capture lost a phase boundary");
|
||||
if (!warned)
|
||||
FAIL("dry-run must predict an untouched user-owned OpenCode module (#2204)");
|
||||
if (!untouched)
|
||||
FAIL("a real install must leave the user-owned OpenCode module byte-identical (#2204)");
|
||||
if (!reported)
|
||||
FAIL("a real install must report the user-owned OpenCode module as left untouched (#2204)");
|
||||
if (!plan_omits)
|
||||
FAIL("the install plan must not list a module the run leaves untouched (#2204)");
|
||||
if (!plan_silent)
|
||||
FAIL("plan mode must keep stdout for the receipt JSON (#2204)");
|
||||
if (!control_lists)
|
||||
FAIL("control: the install plan must list the module once it is not user-owned");
|
||||
PASS();
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST(cli_agent_client_registry_routes_plan_install_and_uninstall) {
|
||||
@@ -19675,6 +19939,10 @@ SUITE(cli) {
|
||||
RUN_TEST(cli_zed_mcp_preserves_settings);
|
||||
RUN_TEST(cli_zed_mcp_uninstall);
|
||||
RUN_TEST(cli_zed_mcp_jsonc_comments);
|
||||
#ifndef _WIN32
|
||||
RUN_TEST(cli_install_agent_config_symlinks_issue2671);
|
||||
RUN_TEST(cli_install_agent_config_file_symlink_issue2671);
|
||||
#endif
|
||||
|
||||
/* PATH management (3 tests) */
|
||||
RUN_TEST(cli_ensure_path_append);
|
||||
@@ -19759,6 +20027,7 @@ SUITE(cli) {
|
||||
#endif
|
||||
#ifndef _WIN32
|
||||
RUN_TEST(cli_dry_run_predicts_refused_hook_script_issue1387);
|
||||
RUN_TEST(cli_extension_leaves_user_owned_module_untouched_issue2204);
|
||||
#endif
|
||||
RUN_TEST(cli_agent_client_registry_routes_plan_install_and_uninstall);
|
||||
RUN_TEST(cli_registry_installs_kimi_rovo_amp_durable_context);
|
||||
|
||||
@@ -402,6 +402,45 @@ TEST(cv_elixir_compile_time_if) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A multi-clause function under a compile-time `if`. Each branch's clauses
|
||||
* fold into one definition spanning them (the clause fold), and the two
|
||||
* branches stay two variants of one identity: exactly the spans 5-6 and 8-9,
|
||||
* neither one span per clause (no fold) nor one span over both branches (a
|
||||
* fold across the branch boundary). */
|
||||
TEST(cv_elixir_multi_clause_under_if) {
|
||||
static const cv_file_t files[] = {{"pick.ex", "defmodule Pick do\n" /* 1 */
|
||||
" def alpha(_), do: :ok\n" /* 2 */
|
||||
" def beta(_), do: :ok\n" /* 3 */
|
||||
" if Code.ensure_loaded?(:win32) do\n" /* 4 */
|
||||
" def pick(0), do: alpha(0)\n" /* 5 */
|
||||
" def pick(n), do: alpha(n)\n" /* 6 */
|
||||
" else\n" /* 7 */
|
||||
" def pick(0), do: beta(0)\n" /* 8 */
|
||||
" def pick(n), do: beta(n)\n" /* 9 */
|
||||
" end\n" /* 10 */
|
||||
" def caller, do: pick(1)\n" /* 11 */
|
||||
"end\n"}}; /* 12 */
|
||||
cv_proj_t p;
|
||||
ASSERT_TRUE(cv_index(&p, files, 1));
|
||||
cv_result_t r = cv_measure(&p, "pick", "alpha", "beta", "caller");
|
||||
const char *span_do =
|
||||
"SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v "
|
||||
"WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS
|
||||
" AND json_extract(v.value,'$.start_line')=5 AND json_extract(v.value,'$.end_line')=6";
|
||||
const char *span_else =
|
||||
"SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v "
|
||||
"WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS
|
||||
" AND json_extract(v.value,'$.start_line')=8 AND json_extract(v.value,'$.end_line')=9";
|
||||
int do_spans = cv_count(&p, span_do, "pick", NULL);
|
||||
int else_spans = cv_count(&p, span_else, "pick", NULL);
|
||||
cv_cleanup(&p);
|
||||
ASSERT_TRUE(cv_expect("elixir_multi_clause", r));
|
||||
ASSERT_EQ(r.variant_spans, 2);
|
||||
ASSERT_EQ(do_spans, 1);
|
||||
ASSERT_EQ(else_spans, 1);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Verilog: the identity only. Module instantiation produces no CALLS edge in
|
||||
* this graph even without any `ifdef (a general Verilog gap, not a variant
|
||||
* one), so the body and caller edges are not this suite's to assert. */
|
||||
@@ -798,6 +837,7 @@ SUITE(conditional_variants) {
|
||||
RUN_TEST(cv_pascal_ifdef);
|
||||
RUN_TEST(cv_julia_static_if);
|
||||
RUN_TEST(cv_elixir_compile_time_if);
|
||||
RUN_TEST(cv_elixir_multi_clause_under_if);
|
||||
RUN_TEST(cv_verilog_ifdef);
|
||||
RUN_TEST(cv_c_platform_files);
|
||||
RUN_TEST(cv_c_platform_directories);
|
||||
|
||||
@@ -725,6 +725,44 @@ TEST(config_text_write_owned_document_if_unchanged_rejects_stale_snapshot) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #2204: the generated OpenCode module must not be appended to a file the user
|
||||
* owns. The probe separates "may be written" (absent, whitespace-only, or
|
||||
* already carrying our markers) from "the user's file" (content, no markers) so
|
||||
* the installer can leave it alone instead of producing an unparseable hybrid. */
|
||||
TEST(config_text_managed_block_status_reports_foreign_module) {
|
||||
char dir[CTE_PATH_CAP];
|
||||
char path[CTE_PATH_CAP];
|
||||
const char whitespace[] = " \n\t\n";
|
||||
const char foreign[] = "import { spawn } from 'node:child_process';\n"
|
||||
"export default { id: 'codebase-memory-augment', setup() {} };\n";
|
||||
const char unbalanced[] = CTE_BEGIN "\nowned\n";
|
||||
ASSERT_EQ(cte_fixture(dir, sizeof(dir), path, sizeof(path)), 0);
|
||||
|
||||
/* No file yet, an empty file, and a whitespace-only file are all writable. */
|
||||
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
|
||||
ASSERT_EQ(cte_write_bytes(path, "", 0U), 0);
|
||||
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
|
||||
ASSERT_EQ(cte_write_bytes(path, whitespace, sizeof(whitespace) - 1U), 0);
|
||||
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
|
||||
|
||||
/* Our own block refreshes in place, so it is writable too. */
|
||||
ASSERT_EQ(cbm_text_upsert_managed_block(path, CTE_BEGIN, CTE_END, "owned"), 0);
|
||||
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
|
||||
|
||||
/* A hand-ported module with no markers is the user's: refuse, never append. */
|
||||
ASSERT_EQ(cte_write_bytes(path, foreign, sizeof(foreign) - 1U), 0);
|
||||
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 1);
|
||||
|
||||
/* Unbalanced markers and malformed marker arguments are unsafe states. */
|
||||
ASSERT_EQ(cte_write_bytes(path, unbalanced, sizeof(unbalanced) - 1U), 0);
|
||||
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), -1);
|
||||
ASSERT_EQ(cbm_text_managed_block_status(path, "same", "same"), -1);
|
||||
ASSERT_EQ(cbm_text_managed_block_status(NULL, CTE_BEGIN, CTE_END), -1);
|
||||
|
||||
th_cleanup(dir);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(config_text_edit) {
|
||||
RUN_TEST(config_text_managed_insert_preserves_bom_comments_and_is_idempotent);
|
||||
RUN_TEST(config_text_managed_replace_preserves_crlf_surroundings);
|
||||
@@ -732,6 +770,7 @@ SUITE(config_text_edit) {
|
||||
RUN_TEST(config_text_managed_remove_preserves_user_bytes);
|
||||
RUN_TEST(config_text_managed_malformed_markers_fail_closed);
|
||||
RUN_TEST(config_text_managed_rejects_unsafe_markers_and_owned_content);
|
||||
RUN_TEST(config_text_managed_block_status_reports_foreign_module);
|
||||
RUN_TEST(config_text_owned_document_write_remove_exact_only);
|
||||
RUN_TEST(config_text_owned_document_migrates_exact_releases_only);
|
||||
RUN_TEST(config_text_rejects_invalid_utf8_and_controls);
|
||||
|
||||
@@ -944,10 +944,201 @@ TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
static bool cross_repo_seed_server_route(const cross_repo_fixture_t *fixture,
|
||||
const char *target_project, const char *method,
|
||||
const char *route_path, const char *handler_name) {
|
||||
char target_path[512];
|
||||
if (!cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) {
|
||||
return false;
|
||||
}
|
||||
cbm_store_t *target = cbm_store_open_path(target_path);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
bool ok = cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK;
|
||||
char target_route_qn[256];
|
||||
char handler_qn[256];
|
||||
char route_name[128];
|
||||
snprintf(target_route_qn, sizeof(target_route_qn), "__route__%s__%s", method, route_path);
|
||||
snprintf(handler_qn, sizeof(handler_qn), "%s.%s", target_project, handler_name);
|
||||
snprintf(route_name, sizeof(route_name), "%s %s", method, route_path);
|
||||
|
||||
cbm_node_t target_route = {.project = target_project,
|
||||
.label = "Route",
|
||||
.name = route_name,
|
||||
.qualified_name = target_route_qn,
|
||||
.file_path = "server.java"};
|
||||
cbm_node_t handler = {.project = target_project,
|
||||
.label = "Function",
|
||||
.name = handler_name,
|
||||
.qualified_name = handler_qn,
|
||||
.file_path = "server.java"};
|
||||
int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0;
|
||||
int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0;
|
||||
cbm_edge_t handles = {.project = target_project,
|
||||
.source_id = handler_id,
|
||||
.target_id = target_route_id,
|
||||
.type = "HANDLES"};
|
||||
ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0;
|
||||
cbm_store_close(target);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool cross_repo_seed_client_call(const cross_repo_fixture_t *fixture,
|
||||
const char *source_project, const char *caller_name,
|
||||
const char *callee_name, const char *url_path,
|
||||
const char *method) {
|
||||
char source_path[512];
|
||||
if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) {
|
||||
return false;
|
||||
}
|
||||
cbm_store_t *source = cbm_store_open_path(source_path);
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK;
|
||||
char caller_qn[256];
|
||||
char local_route_qn[256];
|
||||
char edge_props[512];
|
||||
snprintf(caller_qn, sizeof(caller_qn), "%s.%s", source_project, caller_name);
|
||||
snprintf(local_route_qn, sizeof(local_route_qn), "__route__%s__%s", method ? method : "ANY",
|
||||
url_path);
|
||||
if (method) {
|
||||
snprintf(edge_props, sizeof(edge_props),
|
||||
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"method\":\"%s\"}", callee_name,
|
||||
url_path, method);
|
||||
} else {
|
||||
snprintf(edge_props, sizeof(edge_props),
|
||||
"{\"callee\":\"%s\",\"url_path\":\"%s\"}", callee_name, url_path);
|
||||
}
|
||||
cbm_node_t caller = {.project = source_project,
|
||||
.label = "Function",
|
||||
.name = caller_name,
|
||||
.qualified_name = caller_qn,
|
||||
.file_path = "item.service.ts"};
|
||||
cbm_node_t local_route = {.project = source_project,
|
||||
.label = "Route",
|
||||
.name = url_path,
|
||||
.qualified_name = local_route_qn,
|
||||
.file_path = "item.service.ts"};
|
||||
int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0;
|
||||
int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0;
|
||||
cbm_edge_t http_call = {.project = source_project,
|
||||
.source_id = caller_id,
|
||||
.target_id = local_route_id,
|
||||
.type = "HTTP_CALLS",
|
||||
.properties_json = edge_props};
|
||||
ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0;
|
||||
cbm_store_close(source);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static int cross_repo_count_edges_like(const cross_repo_fixture_t *fixture, const char *project,
|
||||
const char *edge_type, const char *pattern) {
|
||||
char path[512];
|
||||
if (!cross_repo_project_path(fixture, project, path, sizeof(path))) {
|
||||
return -1;
|
||||
}
|
||||
cbm_store_t *store = cbm_store_open_path_query(path);
|
||||
if (!store) {
|
||||
return -1;
|
||||
}
|
||||
sqlite3_stmt *s = NULL;
|
||||
int count = -1;
|
||||
if (sqlite3_prepare_v2(
|
||||
cbm_store_get_db(store),
|
||||
"SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2 AND properties LIKE ?3",
|
||||
-1, &s, NULL) == SQLITE_OK) {
|
||||
sqlite3_bind_text(s, 1, project, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(s, 2, edge_type, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(s, 3, pattern, -1, SQLITE_STATIC);
|
||||
if (sqlite3_step(s) == SQLITE_ROW) {
|
||||
count = sqlite3_column_int(s, 0);
|
||||
}
|
||||
sqlite3_finalize(s);
|
||||
}
|
||||
cbm_store_close(store);
|
||||
return count;
|
||||
}
|
||||
|
||||
/* #2768: Angular HttpClient calls must match cross-repo routes with exact HTTP verbs.
|
||||
* When client makes GET requests to /items/list, /items/42, and /items/job/10,
|
||||
* and the server defines overlapping routes:
|
||||
* DELETE /items/{id} (seeded first in DB)
|
||||
* GET /items/{id} (seeded second in DB)
|
||||
* POST /items/{parentId}/{id} (seeded first in DB)
|
||||
* GET /items/job/{jobId} (seeded second in DB)
|
||||
* the fuzzy route matcher must enforce method compatibility, linking the GET calls
|
||||
* to their respective GET endpoints and never to DELETE or POST endpoints.
|
||||
* getItem and getJob are seeded WITHOUT a method property (an edge written by an
|
||||
* index that did not record the verb): the matcher must infer GET from the
|
||||
* callee's verb suffix, or the method-less fuzzy match lands on the DELETE / POST
|
||||
* route seeded first. getItems keeps its explicit "GET". */
|
||||
TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) {
|
||||
cross_repo_fixture_t fixture;
|
||||
bool setup = cross_repo_fixture_begin(&fixture) &&
|
||||
/* Target server with overlapping endpoints */
|
||||
cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/list",
|
||||
"handle_list_items") &&
|
||||
cross_repo_seed_server_route(&fixture, "api-2768", "DELETE", "/items/{}",
|
||||
"handle_delete_item") &&
|
||||
cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/{}",
|
||||
"handle_get_item") &&
|
||||
cross_repo_seed_server_route(&fixture, "api-2768", "POST", "/items/{}/{}",
|
||||
"handle_post_nested_item") &&
|
||||
cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/job/{}",
|
||||
"handle_get_job") &&
|
||||
/* Angular client making GET requests */
|
||||
cross_repo_seed_client_call(&fixture, "angular-2768", "getItems",
|
||||
"this.http.get", "/items/list", "GET") &&
|
||||
cross_repo_seed_client_call(&fixture, "angular-2768", "getItem",
|
||||
"this.http.get", "/items/42", NULL) &&
|
||||
cross_repo_seed_client_call(&fixture, "angular-2768", "getJob",
|
||||
"this.http.get", "/items/job/10", NULL);
|
||||
if (!setup) {
|
||||
cross_repo_fixture_end(&fixture);
|
||||
FAIL("failed to seed issue 2768 fixture");
|
||||
}
|
||||
|
||||
const char *target = "api-2768";
|
||||
cbm_cross_repo_result_t fwd = cbm_cross_repo_match("angular-2768", &target, 1);
|
||||
ASSERT_FALSE(fwd.failed);
|
||||
ASSERT_EQ(fwd.http_edges, 3);
|
||||
|
||||
/* Assert that client CROSS_HTTP_CALLS edges are linked to GET handlers, not DELETE or POST */
|
||||
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
|
||||
"%handle_delete_item%"),
|
||||
0);
|
||||
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
|
||||
"%handle_post_nested_item%"),
|
||||
0);
|
||||
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
|
||||
"%handle_list_items%"),
|
||||
1);
|
||||
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
|
||||
"%handle_get_item%"),
|
||||
1);
|
||||
ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS",
|
||||
"%handle_get_job%"),
|
||||
1);
|
||||
|
||||
/* Reverse pass */
|
||||
const char *consumer = "angular-2768";
|
||||
cbm_cross_repo_result_t rev = cbm_cross_repo_match("api-2768", &consumer, 1);
|
||||
ASSERT_FALSE(rev.failed);
|
||||
ASSERT_EQ(rev.http_edges, 3);
|
||||
|
||||
cross_repo_fixture_end(&fixture);
|
||||
cbm_cross_repo_result_free(&fwd);
|
||||
cbm_cross_repo_result_free(&rev);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(cross_repo) {
|
||||
RUN_TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459);
|
||||
RUN_TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459);
|
||||
RUN_TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459);
|
||||
RUN_TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768);
|
||||
RUN_TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609);
|
||||
RUN_TEST(cross_repo_null_target_fails_without_dereference);
|
||||
RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens);
|
||||
|
||||
@@ -1226,6 +1226,174 @@ TEST(cslsp_ordinary_same_leaf_calls_join_by_exact_site) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A qualified type name resolves its qualifier as a TYPE when no namespace
|
||||
* binds it (nested types, #2120). A using alias whose target is qualified by
|
||||
* the alias itself (`using Loop = Loop.Inner;`) or by a mutual alias re-enters
|
||||
* that walk through the alias step forever: each `Loop` resolves the target
|
||||
* `Loop.Inner`, whose qualifier is `Loop` again. The walk is depth-bounded;
|
||||
* the file must still finish and resolve its ordinary calls. */
|
||||
TEST(cslsp_cyclic_alias_qualifier_terminates) {
|
||||
const char *src = "using Loop = Loop.Inner;\n"
|
||||
"using Ping = Pong.Inner;\n"
|
||||
"using Pong = Ping.Inner;\n"
|
||||
"namespace App {\n"
|
||||
" public class Svc { public void Go() { } }\n"
|
||||
" public class Runner {\n"
|
||||
" public void Run(Loop a, Ping b) {\n"
|
||||
" var x = new Loop();\n"
|
||||
" var y = new Ping.Leaf();\n"
|
||||
" var s = new Svc();\n"
|
||||
" s.Go();\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
CBMFileResult *r = extract_cs(src);
|
||||
ASSERT(r);
|
||||
ASSERT(require_resolved(r, "Runner.Run", "Svc.Go") >= 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
static const CBMDefinition *find_def_named(const CBMFileResult *r, const char *name) {
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0)
|
||||
return &r->defs.items[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A top-level C# type records its declared namespace; "" means the global
|
||||
* namespace and is claimed only on a clean parse. The vendored grammar does
|
||||
* not parse a `ref partial struct` with members, and error recovery dissolves
|
||||
* the enclosing namespace, so the types after it surface with no namespace
|
||||
* ancestor. Those must not read as global (they would hide every imported
|
||||
* type of the same name); unknown (NULL) keeps them out of namespace binding
|
||||
* exactly as before global-namespace binding existed. */
|
||||
TEST(cslsp_decl_namespace_global_needs_clean_parse) {
|
||||
CBMFileResult *clean = extract_cs("public class Loose { }\n"
|
||||
"namespace Acme.Lib { public class Kept { } }\n");
|
||||
ASSERT(clean);
|
||||
const CBMDefinition *loose = find_def_named(clean, "Loose");
|
||||
const CBMDefinition *kept = find_def_named(clean, "Kept");
|
||||
ASSERT(loose && kept);
|
||||
ASSERT(loose->decl_namespace && strcmp(loose->decl_namespace, "") == 0);
|
||||
ASSERT(kept->decl_namespace && strcmp(kept->decl_namespace, "Acme.Lib") == 0);
|
||||
cbm_free_result(clean);
|
||||
|
||||
CBMFileResult *damaged = extract_cs("namespace Acme.Lib\n"
|
||||
"{\n"
|
||||
" public ref partial struct Handler\n"
|
||||
" {\n"
|
||||
" public void Append(string value) { }\n"
|
||||
" }\n"
|
||||
" public static partial class Gadget { }\n"
|
||||
"}\n");
|
||||
ASSERT(damaged);
|
||||
const CBMDefinition *gadget = find_def_named(damaged, "Gadget");
|
||||
ASSERT(gadget);
|
||||
if (gadget->decl_namespace && !gadget->decl_namespace[0])
|
||||
printf(" Gadget after a parse error claims the global namespace\n");
|
||||
ASSERT(!gadget->decl_namespace || gadget->decl_namespace[0]);
|
||||
cbm_free_result(damaged);
|
||||
PASS();
|
||||
}
|
||||
|
||||
static bool has_resolved_row(const CBMResolvedCallArray *out, const char *caller_qn,
|
||||
const char *callee_qn) {
|
||||
for (int i = 0; i < out->count; i++) {
|
||||
const CBMResolvedCall *rc = &out->items[i];
|
||||
if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 &&
|
||||
strcmp(rc->callee_qn, callee_qn) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* `partial` pieces of one namespaced type are separate registry entries, so
|
||||
* a member declared on another piece still binds (#2120). Types declared in
|
||||
* the GLOBAL namespace carry the empty namespace, and the same simple name
|
||||
* there is a different type per assembly across a repository (each test
|
||||
* project's own `Helper`). They must never pool as twins: the twin probe runs
|
||||
* before the base-class walk, so pooling bound `h.Stop()` on ToolA's Helper
|
||||
* to the unrelated ToolB Helper instead of ToolA's own base ToolABase. Runs
|
||||
* the production Tier-2 C# registry builder over cross-file defs. */
|
||||
TEST(cslsp_global_namespace_types_are_not_partial_twins) {
|
||||
CBMArena arena;
|
||||
cbm_arena_init(&arena);
|
||||
CBMLSPDef defs[] = {
|
||||
{.qualified_name = "p.ToolA.Helper.Helper",
|
||||
.short_name = "Helper",
|
||||
.label = "Class",
|
||||
.def_module_qn = "p.ToolA.Helper",
|
||||
.embedded_types = "p.ToolA.Base.ToolABase",
|
||||
.lang = CBM_LANG_CSHARP},
|
||||
{.qualified_name = "p.ToolA.Helper.Helper.Start",
|
||||
.short_name = "Start",
|
||||
.label = "Method",
|
||||
.receiver_type = "p.ToolA.Helper.Helper",
|
||||
.def_module_qn = "p.ToolA.Helper",
|
||||
.lang = CBM_LANG_CSHARP},
|
||||
{.qualified_name = "p.ToolA.Base.ToolABase",
|
||||
.short_name = "ToolABase",
|
||||
.label = "Class",
|
||||
.def_module_qn = "p.ToolA.Base",
|
||||
.lang = CBM_LANG_CSHARP},
|
||||
{.qualified_name = "p.ToolA.Base.ToolABase.Stop",
|
||||
.short_name = "Stop",
|
||||
.label = "Method",
|
||||
.receiver_type = "p.ToolA.Base.ToolABase",
|
||||
.def_module_qn = "p.ToolA.Base",
|
||||
.lang = CBM_LANG_CSHARP},
|
||||
{.qualified_name = "p.ToolB.Helper.Helper",
|
||||
.short_name = "Helper",
|
||||
.label = "Class",
|
||||
.def_module_qn = "p.ToolB.Helper",
|
||||
.lang = CBM_LANG_CSHARP},
|
||||
{.qualified_name = "p.ToolB.Helper.Helper.Stop",
|
||||
.short_name = "Stop",
|
||||
.label = "Method",
|
||||
.receiver_type = "p.ToolB.Helper.Helper",
|
||||
.def_module_qn = "p.ToolB.Helper",
|
||||
.lang = CBM_LANG_CSHARP},
|
||||
};
|
||||
CBMTypeRegistry *reg =
|
||||
cbm_cs_build_cross_registry(&arena, defs, (int)(sizeof(defs) / sizeof(defs[0])));
|
||||
ASSERT_NOT_NULL(reg);
|
||||
/* No namespace anywhere: both Helpers and the base live in the global
|
||||
* namespace. The caller sits in ToolA's Helper file, so `new Helper()`
|
||||
* binds ToolA's Helper through the same-module step, independent of
|
||||
* registration order. */
|
||||
const char *src = "public class Helper : ToolABase { public void Start() { } }\n"
|
||||
"public class Caller {\n"
|
||||
" public void Go() {\n"
|
||||
" var h = new Helper();\n"
|
||||
" h.Start();\n"
|
||||
" h.Stop();\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
CBMResolvedCallArray out = {0};
|
||||
cbm_run_cs_lsp_cross_with_registry(&arena, src, (int)strlen(src), "p.ToolA.Helper", reg, NULL,
|
||||
0, NULL, &out);
|
||||
const char *caller = "p.ToolA.Helper.Caller.Go";
|
||||
bool start_own = has_resolved_row(&out, caller, "p.ToolA.Helper.Helper.Start");
|
||||
bool stop_base = has_resolved_row(&out, caller, "p.ToolA.Base.ToolABase.Stop");
|
||||
bool stop_twin = has_resolved_row(&out, caller, "p.ToolB.Helper.Helper.Stop");
|
||||
if (!start_own || !stop_base || stop_twin) {
|
||||
printf(" start_own=%d stop_base=%d stop_unrelated_global_twin=%d\n", start_own, stop_base,
|
||||
stop_twin);
|
||||
for (int i = 0; i < out.count; i++) {
|
||||
printf(" %s -> %s [%s]\n", out.items[i].caller_qn ? out.items[i].caller_qn : "?",
|
||||
out.items[i].callee_qn ? out.items[i].callee_qn : "?",
|
||||
out.items[i].strategy ? out.items[i].strategy : "?");
|
||||
}
|
||||
}
|
||||
cbm_arena_destroy(&arena);
|
||||
ASSERT_TRUE(start_own);
|
||||
ASSERT_TRUE(stop_base);
|
||||
ASSERT_FALSE(stop_twin);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ─────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(cs_lsp) {
|
||||
@@ -1290,4 +1458,7 @@ SUITE(cs_lsp) {
|
||||
RUN_TEST(cslsp_stringbuilder_chain);
|
||||
RUN_TEST(cslsp_httpclient_chain);
|
||||
RUN_TEST(cslsp_ordinary_same_leaf_calls_join_by_exact_site);
|
||||
RUN_TEST(cslsp_cyclic_alias_qualifier_terminates);
|
||||
RUN_TEST(cslsp_global_namespace_types_are_not_partial_twins);
|
||||
RUN_TEST(cslsp_decl_namespace_global_needs_clean_parse);
|
||||
}
|
||||
@@ -1662,6 +1662,7 @@ typedef struct {
|
||||
* itself instead, which is the edge a reader on another thread needs. */
|
||||
atomic_bool args_captured[APP_FAKE_MAX_ATTEMPTS];
|
||||
size_t memory_budgets[APP_FAKE_MAX_ATTEMPTS];
|
||||
bool background_requests[APP_FAKE_MAX_ATTEMPTS];
|
||||
} app_fake_worker_context_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -1729,6 +1730,8 @@ static int app_fake_worker_start(void *opaque, const char *args_json, size_t mem
|
||||
args_json ? args_json : "");
|
||||
atomic_store(&context->args_captured[worker->attempt], true);
|
||||
context->memory_budgets[worker->attempt] = memory_budget_bytes;
|
||||
context->background_requests[worker->attempt] =
|
||||
cbm_mcp_get_bool_arg(args_json, "_cbm_background");
|
||||
if (marker_file) {
|
||||
(void)snprintf(context->marker_paths[worker->attempt], APP_TEST_PATH_CAP, "%s",
|
||||
marker_file);
|
||||
@@ -2546,6 +2549,7 @@ TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions) {
|
||||
ASSERT_TRUE(first_initialized);
|
||||
ASSERT_TRUE(first_owned);
|
||||
ASSERT_TRUE(auto_policy_propagated);
|
||||
ASSERT_TRUE(fake.background_requests[0]);
|
||||
ASSERT_TRUE(second_initialized);
|
||||
ASSERT_TRUE(coalesced);
|
||||
ASSERT_TRUE(restricted_disconnect_kept_job);
|
||||
@@ -4287,6 +4291,7 @@ TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session) {
|
||||
ASSERT_TRUE(thread_started);
|
||||
ASSERT_TRUE(subscribed);
|
||||
ASSERT_TRUE(worker_started);
|
||||
ASSERT_FALSE(fixture.fake.background_requests[0]);
|
||||
ASSERT_TRUE(request_returned);
|
||||
ASSERT_TRUE(thread_joined);
|
||||
ASSERT_EQ(request.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED);
|
||||
@@ -4584,6 +4589,7 @@ TEST(daemon_application_final_cancel_drains_admitted_watcher_job) {
|
||||
ASSERT_TRUE(fixture_ready);
|
||||
ASSERT_TRUE(thread_started);
|
||||
ASSERT_TRUE(worker_started);
|
||||
ASSERT_TRUE(fixture.fake.background_requests[0]);
|
||||
ASSERT_TRUE(job_active);
|
||||
ASSERT_EQ(watches_after_cancel, 0);
|
||||
ASSERT_TRUE(thread_joined);
|
||||
@@ -7292,8 +7298,9 @@ typedef struct {
|
||||
uint32_t context_length;
|
||||
} app_async_fixture_t;
|
||||
|
||||
static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *label,
|
||||
bool permanent) {
|
||||
/* stored_config is optional; a test that needs auto_index passes its own. */
|
||||
static bool app_async_fixture_init_with_config(app_async_fixture_t *fixture, const char *label,
|
||||
bool permanent, cbm_config_t *stored_config) {
|
||||
memset(fixture, 0, sizeof(*fixture));
|
||||
app_fake_worker_context_init(&fixture->fake);
|
||||
fixture->ops = (cbm_daemon_application_worker_ops_t){
|
||||
@@ -7304,7 +7311,8 @@ static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *lab
|
||||
.log_path = app_fake_worker_log_path,
|
||||
.destroy = app_fake_worker_destroy,
|
||||
};
|
||||
cbm_daemon_application_config_t config = {.worker_ops = &fixture->ops};
|
||||
cbm_daemon_application_config_t config = {.config = stored_config,
|
||||
.worker_ops = &fixture->ops};
|
||||
fixture->application = cbm_daemon_application_new(&config);
|
||||
if (!fixture->application) {
|
||||
return false;
|
||||
@@ -7325,6 +7333,11 @@ static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *lab
|
||||
&fixture->context_length);
|
||||
}
|
||||
|
||||
static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *label,
|
||||
bool permanent) {
|
||||
return app_async_fixture_init_with_config(fixture, label, permanent, NULL);
|
||||
}
|
||||
|
||||
static cbm_daemon_runtime_application_session_t *app_async_session(app_async_fixture_t *fixture,
|
||||
cbm_daemon_client_id_t id) {
|
||||
cbm_daemon_runtime_application_session_t *session = app_test_open(&fixture->callbacks, id);
|
||||
@@ -7702,6 +7715,78 @@ TEST(daemon_application_cut_short_sync_index_advice_reaches_next_call) {
|
||||
* the daemon's only live session is refused async (the daemon would stop and
|
||||
* cancel the job as soon as the command exits), while status stays allowed and
|
||||
* a long-lived MCP session on the same temporary daemon remains a valid host. */
|
||||
/* Automatic indexing (session auto-index, watcher re-index) marks its job
|
||||
* _cbm_background so the worker leaves CPU headroom. The marker picks the
|
||||
* worker count, never what is indexed, so an explicit index_repository for the
|
||||
* same root still JOINS the automatic job already running for it, the contract
|
||||
* the repo_path fold above restored, instead of being refused as an options
|
||||
* conflict. The worker is held, so the join is observed while the job runs. */
|
||||
TEST(daemon_application_explicit_index_joins_running_auto_index_job) {
|
||||
app_env_backup_t cache_environment;
|
||||
bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR");
|
||||
char cache[APP_TEST_PATH_CAP];
|
||||
(void)snprintf(cache, sizeof(cache), "%s/cbm-app-auto-join-cache-XXXXXX", cbm_tmpdir());
|
||||
bool cache_ready =
|
||||
cache_saved && cbm_mkdtemp(cache) != NULL && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0;
|
||||
cbm_config_t *stored_config = cache_ready ? cbm_config_open(cache) : NULL;
|
||||
bool config_ready = stored_config &&
|
||||
cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "true") == 0 &&
|
||||
cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "false") == 0;
|
||||
app_async_fixture_t fixture;
|
||||
bool setup = config_ready &&
|
||||
app_async_fixture_init_with_config(&fixture, "auto-join", true, stored_config);
|
||||
|
||||
cbm_daemon_runtime_application_session_t *automatic =
|
||||
setup ? app_test_open(&fixture.callbacks, 7801) : NULL;
|
||||
bool auto_started = automatic &&
|
||||
app_test_initialize_profile(&fixture.callbacks, automatic, fixture.root,
|
||||
CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL) &&
|
||||
app_wait_for_atomic_int(&fixture.fake.starts, 1);
|
||||
/* args_json is published by the args_captured release store; the
|
||||
* background_requests slot is written after it, so read the args. */
|
||||
bool auto_marked = auto_started &&
|
||||
app_wait_for_atomic_bool(&fixture.fake.args_captured[0], true) &&
|
||||
cbm_mcp_get_bool_arg(fixture.fake.args_json[0], "_cbm_background");
|
||||
|
||||
cbm_daemon_runtime_application_session_t *explicit_session =
|
||||
auto_marked ? app_async_session(&fixture, 7802) : NULL;
|
||||
char *joined =
|
||||
explicit_session ? app_async_call_now(&fixture, explicit_session, ",\"async\":true") : NULL;
|
||||
int starts_while_running = atomic_load(&fixture.fake.starts);
|
||||
size_t active_while_running =
|
||||
setup ? cbm_daemon_application_active_jobs(fixture.application) : 0;
|
||||
|
||||
if (explicit_session) {
|
||||
fixture.callbacks.session_close(fixture.callbacks.context, explicit_session);
|
||||
}
|
||||
if (automatic) {
|
||||
fixture.callbacks.session_cancel(fixture.callbacks.context, automatic);
|
||||
fixture.callbacks.session_close(fixture.callbacks.context, automatic);
|
||||
}
|
||||
bool stopped = !setup || app_async_fixture_finish(&fixture);
|
||||
cbm_config_close(stored_config);
|
||||
bool cleaned = !cache_ready || th_rmtree(cache) == 0;
|
||||
bool cache_restored = app_env_backup_restore(&cache_environment);
|
||||
|
||||
ASSERT_TRUE(cache_ready);
|
||||
ASSERT_TRUE(config_ready);
|
||||
ASSERT_TRUE(setup);
|
||||
ASSERT_TRUE(auto_started);
|
||||
ASSERT_TRUE(auto_marked);
|
||||
ASSERT_NOT_NULL(explicit_session);
|
||||
ASSERT_NOT_NULL(joined);
|
||||
ASSERT_FALSE(app_async_contains(joined, "different options"));
|
||||
ASSERT_FALSE(app_async_contains(joined, "\"isError\":true"));
|
||||
ASSERT_TRUE(app_async_contains(joined, "\"joined\":true"));
|
||||
ASSERT_EQ(starts_while_running, 1);
|
||||
ASSERT_EQ(active_while_running, 1);
|
||||
ASSERT_TRUE(stopped);
|
||||
ASSERT_TRUE(cleaned);
|
||||
ASSERT_TRUE(cache_restored);
|
||||
free(joined);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_application_async_refused_for_lone_one_shot_client_of_temporary_daemon) {
|
||||
app_async_fixture_t fixture;
|
||||
bool setup = app_async_fixture_init(&fixture, "async-refuse", false);
|
||||
@@ -8031,6 +8116,7 @@ SUITE(daemon_application) {
|
||||
RUN_TEST(daemon_application_async_and_status_validation_errors);
|
||||
RUN_TEST(daemon_application_cancelled_sync_index_reply_offers_async);
|
||||
RUN_TEST(daemon_application_cut_short_sync_index_advice_reaches_next_call);
|
||||
RUN_TEST(daemon_application_explicit_index_joins_running_auto_index_job);
|
||||
RUN_TEST(daemon_application_async_refused_for_lone_one_shot_client_of_temporary_daemon);
|
||||
RUN_TEST(daemon_application_unnamed_status_finds_async_job_of_root_owner_issue2134);
|
||||
RUN_TEST(daemon_application_unnamed_status_keeps_path_derived_key_issue2134);
|
||||
|
||||
@@ -100,6 +100,47 @@ static int count_defs_named(CBMFileResult *r, const char *label, const char *nam
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Count calls to `callee` whose enclosing scope is exactly `scope_qn`. A call the
|
||||
* scope walk failed to place carries the FILE or module QN here, so a bare count
|
||||
* of the callee passes just as happily with every call homed on the file. */
|
||||
static int count_calls_from(CBMFileResult *r, const char *callee, const char *scope_qn) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < r->calls.count; i++) {
|
||||
if (r->calls.items[i].callee_name && r->calls.items[i].enclosing_func_qn &&
|
||||
strcmp(r->calls.items[i].callee_name, callee) == 0 &&
|
||||
strcmp(r->calls.items[i].enclosing_func_qn, scope_qn) == 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Count usages of `ref` attributed to exactly `scope_qn`, and to any scope. A
|
||||
* def head is a BINDING, not a usage, so the pair separates two different
|
||||
* mistakes: too few rows means a read was swallowed by the binding, too many
|
||||
* means a binding site was counted as a read. */
|
||||
static int count_usages_from(CBMFileResult *r, const char *ref, const char *scope_qn) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < r->usages.count; i++) {
|
||||
if (r->usages.items[i].ref_name && r->usages.items[i].enclosing_func_qn &&
|
||||
strcmp(r->usages.items[i].ref_name, ref) == 0 &&
|
||||
strcmp(r->usages.items[i].enclosing_func_qn, scope_qn) == 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
static int count_usages_named(CBMFileResult *r, const char *ref) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < r->usages.count; i++) {
|
||||
if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, ref) == 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
static int count_calls_named(CBMFileResult *r, const char *callee) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < r->calls.count; i++) {
|
||||
@@ -1896,6 +1937,730 @@ TEST(elixir_function) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Elixir def-head guard helpers (internal/cbm/helpers.c) ─────────
|
||||
*
|
||||
* `def name(args) when guard` parses its WHOLE head as one `when`
|
||||
* binary_operator whose left operand is the `name(args)` call, and Elixir
|
||||
* admits more than one guard on a clause, which tree-sitter-elixir nests
|
||||
* right-associatively. Four walks read that same first argument and each asks a
|
||||
* different question of it: the defs and call-scope walks want the head UNDER
|
||||
* every guard, the usages walk wants it so that a parameter READ by the guard
|
||||
* stays outside the binding, and the calls walk wants to know whether the node
|
||||
* it is visiting IS that head or one of the guard operators above it.
|
||||
*
|
||||
* That last question is why cbm_elixir_def_head_is exists beside the unwrap,
|
||||
* and it is exercised here on the AST rather than through cbm_extract_file
|
||||
* because the two routes it has to answer for are indistinguishable from
|
||||
* outside: `def f(x) when g` reaches the calls walk as the inner `f(x)` call,
|
||||
* which the unwrap reaches, while `def f when g` has no inner call at all and
|
||||
* reaches it as the `when` operator itself, which the unwrap does not. A test
|
||||
* that only drives one walk sees whichever of the two that walk happens to hit.
|
||||
*
|
||||
* This file's helpers change no extraction behaviour on their own; the walks
|
||||
* that consume them are separate changes. */
|
||||
|
||||
enum { ELIXIR_HEAD_PROBE_MAX = 16, ELIXIR_HEAD_STACK_MAX = 256 };
|
||||
|
||||
static bool head_probe_text_is(const char *source, TSNode node, const char *want) {
|
||||
if (ts_node_is_null(node)) {
|
||||
return false;
|
||||
}
|
||||
uint32_t start = ts_node_start_byte(node);
|
||||
uint32_t end = ts_node_end_byte(node);
|
||||
size_t len = strlen(want);
|
||||
return end >= start && (size_t)(end - start) == len && memcmp(source + start, want, len) == 0;
|
||||
}
|
||||
|
||||
/* The first argument of every `def`/`defp` call under `root`, in source order.
|
||||
* That argument is what all four walks read, so it is what the helpers take. */
|
||||
typedef struct {
|
||||
TSNode signature[ELIXIR_HEAD_PROBE_MAX];
|
||||
int count;
|
||||
} elixir_head_probe_t;
|
||||
|
||||
static void collect_elixir_def_signatures(TSNode root, const char *source,
|
||||
elixir_head_probe_t *out) {
|
||||
TSNode stack[ELIXIR_HEAD_STACK_MAX];
|
||||
int top = 0;
|
||||
stack[top++] = root;
|
||||
while (top > 0) {
|
||||
TSNode cur = stack[--top];
|
||||
uint32_t cc = ts_node_child_count(cur);
|
||||
if (cc > 1 && strcmp(ts_node_type(cur), "call") == 0 &&
|
||||
(head_probe_text_is(source, ts_node_child(cur, 0), "def") ||
|
||||
head_probe_text_is(source, ts_node_child(cur, 0), "defp")) &&
|
||||
out->count < ELIXIR_HEAD_PROBE_MAX) {
|
||||
TSNode args = ts_node_child(cur, 1);
|
||||
if (!ts_node_is_null(args) && ts_node_child_count(args) > 0) {
|
||||
out->signature[out->count++] = ts_node_child(args, 0);
|
||||
}
|
||||
}
|
||||
for (int i = (int)cc - 1; i >= 0 && top < ELIXIR_HEAD_STACK_MAX; i--) {
|
||||
stack[top++] = ts_node_child(cur, (uint32_t)i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it) {
|
||||
const char *source = "defmodule Heads do\n"
|
||||
" def plain(x), do: x\n"
|
||||
" def guarded(x) when is_binary(x), do: x\n"
|
||||
" def bare when true, do: :ok\n"
|
||||
" def twice(x) when is_atom(x) when is_binary(x), do: x\n"
|
||||
" def a + b, do: {a, b}\n"
|
||||
"end\n";
|
||||
const TSLanguage *language = cbm_ts_language(CBM_LANG_ELIXIR);
|
||||
ASSERT_NOT_NULL(language);
|
||||
TSParser *parser = ts_parser_new();
|
||||
ASSERT_NOT_NULL(parser);
|
||||
ASSERT_TRUE(ts_parser_set_language(parser, language));
|
||||
TSTree *tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)strlen(source));
|
||||
ASSERT_NOT_NULL(tree);
|
||||
|
||||
elixir_head_probe_t probe;
|
||||
probe.count = 0;
|
||||
collect_elixir_def_signatures(ts_tree_root_node(tree), source, &probe);
|
||||
ASSERT_EQ(5, probe.count);
|
||||
|
||||
/* Unguarded: nothing to peel, and the head is the whole first argument.
|
||||
* The name node inside it is NOT the head -- the calls walk compares the
|
||||
* node it is visiting, and the `plain` identifier is one of those. */
|
||||
TSNode plain = probe.signature[0];
|
||||
ASSERT_FALSE(cbm_elixir_is_when_guard(plain));
|
||||
ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(plain), plain));
|
||||
ASSERT_TRUE(cbm_elixir_def_head_is(plain, plain));
|
||||
ASSERT_FALSE(cbm_elixir_def_head_is(plain, ts_node_child(plain, 0)));
|
||||
|
||||
/* One guard: the first argument is the operator, the head is its left
|
||||
* operand, and BOTH answer true -- the operator is the node the paren-less
|
||||
* route hands the calls walk, the inner call is the node the peel route
|
||||
* hands it. The guard expression answers false, or the walk would suppress
|
||||
* a real call written inside a guard. */
|
||||
TSNode guarded = probe.signature[1];
|
||||
ASSERT_TRUE(cbm_elixir_is_when_guard(guarded));
|
||||
TSNode guarded_head = cbm_elixir_def_head_unwrap_guard(guarded);
|
||||
ASSERT_FALSE(ts_node_eq(guarded_head, guarded));
|
||||
ASSERT_STR_EQ("call", ts_node_type(guarded_head));
|
||||
ASSERT_TRUE(head_probe_text_is(source, guarded_head, "guarded(x)"));
|
||||
ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded));
|
||||
ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded_head));
|
||||
TSNode guard_expr = ts_node_child_by_field_name(guarded, TS_FIELD("right"));
|
||||
ASSERT_FALSE(ts_node_is_null(guard_expr));
|
||||
ASSERT_TRUE(head_probe_text_is(source, guard_expr, "is_binary(x)"));
|
||||
ASSERT_FALSE(cbm_elixir_def_head_is(guarded, guard_expr));
|
||||
|
||||
/* Paren-less: the head under the guard is a bare identifier, so there is no
|
||||
* inner call for the peel route to produce. This is the shape that makes
|
||||
* cbm_elixir_def_head_is necessary: the only node the calls walk ever sees
|
||||
* for this clause is the operator. */
|
||||
TSNode bare = probe.signature[2];
|
||||
ASSERT_TRUE(cbm_elixir_is_when_guard(bare));
|
||||
TSNode bare_head = cbm_elixir_def_head_unwrap_guard(bare);
|
||||
ASSERT_STR_EQ("identifier", ts_node_type(bare_head));
|
||||
ASSERT_TRUE(head_probe_text_is(source, bare_head, "bare"));
|
||||
ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare));
|
||||
ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare_head));
|
||||
|
||||
/* Two guards on one clause. tree-sitter-elixir nests them
|
||||
* RIGHT-associatively -- `when(twice(x), when(is_atom(x), is_binary(x)))`,
|
||||
* which is what this asserts -- so the head is still the outer operator's
|
||||
* left operand and the peel reaches it in one step. The repeated peel is
|
||||
* therefore a fixed point rather than a chain walk on this grammar; the
|
||||
* nodes that must answer true are the operator and the head. */
|
||||
TSNode twice = probe.signature[3];
|
||||
ASSERT_TRUE(cbm_elixir_is_when_guard(twice));
|
||||
TSNode second_guard = ts_node_child_by_field_name(twice, TS_FIELD("right"));
|
||||
ASSERT_TRUE(cbm_elixir_is_when_guard(second_guard));
|
||||
TSNode twice_head = cbm_elixir_def_head_unwrap_guard(twice);
|
||||
ASSERT_TRUE(head_probe_text_is(source, twice_head, "twice(x)"));
|
||||
ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice));
|
||||
ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice_head));
|
||||
/* The second guard is a `when` operator too, and it is NOT on the head
|
||||
* chain: a walk that treated any `when` above the head as a declaration
|
||||
* would suppress the calls written inside it. */
|
||||
ASSERT_FALSE(cbm_elixir_def_head_is(twice, second_guard));
|
||||
|
||||
/* An operator definition's head is a binary_operator too, and unwrapping it
|
||||
* would name the function after its own left parameter. Only the `when`
|
||||
* token admits the peel, so `def a + b` is returned unchanged and its left
|
||||
* operand is not a head. */
|
||||
TSNode op_def = probe.signature[4];
|
||||
ASSERT_STR_EQ("binary_operator", ts_node_type(op_def));
|
||||
ASSERT_FALSE(cbm_elixir_is_when_guard(op_def));
|
||||
ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(op_def), op_def));
|
||||
ASSERT_TRUE(cbm_elixir_def_head_is(op_def, op_def));
|
||||
TSNode op_lhs = ts_node_child_by_field_name(op_def, TS_FIELD("left"));
|
||||
ASSERT_TRUE(head_probe_text_is(source, op_lhs, "a"));
|
||||
ASSERT_FALSE(cbm_elixir_def_head_is(op_def, op_lhs));
|
||||
|
||||
ts_tree_delete(tree);
|
||||
ts_parser_delete(parser);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* `def name(args) when guard` parses its whole head as a `when`
|
||||
* binary_operator, so the name sits on the operator's left operand rather than
|
||||
* directly under the call. The def extractor accepted only `call` and
|
||||
* `identifier` there, so every guarded clause was dropped -- and a function
|
||||
* whose clauses ALL carry guards never reached the graph at all, silently.
|
||||
* Guards are ordinary Elixir: on one real 2.9k-function codebase this hid 216
|
||||
* of 270 all-guarded functions, and every call edge touching them. */
|
||||
TEST(extract_elixir_guarded_def_head) {
|
||||
CBMFileResult *r = extract("defmodule Guarded do\n"
|
||||
" def plain(x), do: x\n"
|
||||
" def guarded(x) when is_binary(x), do: x\n"
|
||||
" def guarded(x) when is_list(x), do: x\n"
|
||||
" def multi(x, y) when is_binary(x) and is_integer(y), do: {x, y}\n"
|
||||
" defp priv_guarded(x) when is_atom(x), do: x\n"
|
||||
" def bare when true, do: :ok\n"
|
||||
" def a + b, do: {a, b}\n"
|
||||
" def c - d when is_integer(c), do: {c, d}\n"
|
||||
" def multi_guard(x) when is_atom(x) when is_binary(x), do: x\n"
|
||||
"end\n",
|
||||
CBM_LANG_ELIXIR, "t", "guarded.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT(has_def(r, "Function", "plain"));
|
||||
ASSERT(has_def(r, "Function", "guarded"));
|
||||
ASSERT(has_def(r, "Function", "multi"));
|
||||
ASSERT(has_def(r, "Function", "priv_guarded"));
|
||||
ASSERT(has_def(r, "Function", "bare"));
|
||||
|
||||
/* Elixir admits more than one guard on a clause. tree-sitter-elixir nests
|
||||
* those `when` operators right-associatively, so the declared head stays
|
||||
* the outermost operator's left operand -- one node either way, and the
|
||||
* peel must not stop short of it or return the guard. */
|
||||
ASSERT(has_def(r, "Function", "multi_guard"));
|
||||
ASSERT_EQ(count_defs_named(r, "Function", "multi_guard"), 1);
|
||||
|
||||
/* An operator definition is a binary_operator head too, but it is not a
|
||||
* guard: unwrapping it names the function after its own LEFT PARAMETER.
|
||||
* `def a + b` must not mint a function called `a`, nor `def c - d when ...`
|
||||
* one called `c` -- the guard's left operand is itself the `c - d`
|
||||
* operator, which names no function either. Neither operator form is
|
||||
* extracted at all, which is what this grammar did before guards were
|
||||
* handled; only the `when` token admits the unwrap. */
|
||||
ASSERT_EQ(count_defs_named(r, "Function", "a"), 0);
|
||||
ASSERT_EQ(count_defs_named(r, "Function", "c"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A guard hides the head from the scope walk and from the calls walk.
|
||||
* compute_elixir_func_qn accepts only a `call` or an `identifier` as a def's
|
||||
* first argument, so a guarded clause resolved no QN and opened NO function
|
||||
* scope: every call in its body was attributed to the FILE node, the function
|
||||
* reported no outgoing edges, and its callees gained an in-edge from the file
|
||||
* instead of from their caller. A paren-less guarded clause (`def f when g`) is
|
||||
* worse still: it has no inner call node at all, so the guard operator itself
|
||||
* falls through to extract_callee_name's first-identifier last resort and mints
|
||||
* a phantom CALLS edge naming the function being defined. */
|
||||
TEST(extract_elixir_guarded_def_head_scope) {
|
||||
CBMFileResult *r = extract("defmodule Guards do\n"
|
||||
" def target(x), do: x\n"
|
||||
" def other(x), do: x\n"
|
||||
" def unguarded_caller(a), do: target(a)\n"
|
||||
" def guarded_caller(a) when is_binary(a) do\n"
|
||||
" target(a)\n"
|
||||
" other(a)\n"
|
||||
" end\n"
|
||||
" def bare_guarded when true, do: target(:ok)\n"
|
||||
"end\n",
|
||||
CBM_LANG_ELIXIR, "t", "guard_scope.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
/* A paren-less guarded head is a definition, never a call to itself. */
|
||||
ASSERT_EQ(count_calls_named(r, "bare_guarded"), 0);
|
||||
|
||||
/* Both calls in the guarded body source to the guarded function, and the
|
||||
* paren-less clause's body call to that clause. */
|
||||
ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.guarded_caller"), 1);
|
||||
ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope.guarded_caller"), 1);
|
||||
ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.bare_guarded"), 1);
|
||||
|
||||
/* Control on the path the fix does not touch: an unguarded clause still
|
||||
* sources its body call to itself. This is NOT a leak detector -- the walk
|
||||
* expires scopes by tree depth (pop_expired_scopes), so a sibling clause
|
||||
* always unwinds the previous one before pushing its own and "pushed but
|
||||
* never popped" is not a state this walk can reach. */
|
||||
ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.unguarded_caller"), 1);
|
||||
|
||||
/* Nothing falls back to the file node. */
|
||||
ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope"), 0);
|
||||
ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope"), 0);
|
||||
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The half the calls and scope assertions above cannot see: the usages walk
|
||||
* asks whether an identifier sits inside the def's SIGNATURE, and treats
|
||||
* everything that does as part of the binding rather than as a reference. A
|
||||
* guard makes that signature the whole `when` operator, which spans the guard
|
||||
* expression too -- so `a` read by `is_binary(a)` was classified as its own
|
||||
* binding and emitted no USAGE row at all. A guard is an expression, not a
|
||||
* pattern: the parameters are bound by `guarded(a, b)` and every mention of one
|
||||
* to its right is a read, exactly as the same mention in the body already is.
|
||||
* Unwrapping the signature to the head restores those rows without admitting
|
||||
* the binding sites themselves, which is why each name is asserted both within
|
||||
* its scope and in total. */
|
||||
TEST(extract_elixir_guarded_def_head_guard_usages) {
|
||||
CBMFileResult *r = extract("defmodule GuardUsage do\n"
|
||||
" def plain(x), do: x\n"
|
||||
" def guarded(a, b) when is_binary(a) and byte_size(a) > 3 do\n"
|
||||
" b\n"
|
||||
" end\n"
|
||||
"end\n",
|
||||
CBM_LANG_ELIXIR, "t", "guard_usage.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
/* Both guard reads of the parameter are usages of the guarded clause. */
|
||||
ASSERT_EQ(count_usages_from(r, "a", "t.guard_usage.guarded"), 2);
|
||||
/* ...and nothing else claims `a`: the `a` in `guarded(a, b)` binds it. */
|
||||
ASSERT_EQ(count_usages_named(r, "a"), 2);
|
||||
|
||||
/* The body read is unchanged, and the head's `b` is still a binding --
|
||||
* an unwrap that took the guard instead of the head would add both head
|
||||
* parameters here. */
|
||||
ASSERT_EQ(count_usages_from(r, "b", "t.guard_usage.guarded"), 1);
|
||||
ASSERT_EQ(count_usages_named(r, "b"), 1);
|
||||
|
||||
/* Control: an unguarded clause was never affected either way. */
|
||||
ASSERT_EQ(count_usages_from(r, "x", "t.guard_usage.plain"), 1);
|
||||
ASSERT_EQ(count_usages_named(r, "x"), 1);
|
||||
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Every clause of an Elixir function is its own `def` call, and an Elixir QN
|
||||
* carries neither module nor arity, so all clauses compute one qualified name
|
||||
* and collide on a single graph node. cbm_gbuf_upsert_node ranks a same-QN
|
||||
* collision by LARGEST start_line, so the survivor was the last clause in the
|
||||
* file and get_code_snippet returned that clause as the whole function: a
|
||||
* two-clause `admin?` read back as `def admin?(%__MODULE__{}), do: false` --
|
||||
* the graph asserting a predicate that always returns false. The node has to
|
||||
* span its own clauses, and ONLY its own: the fold is bounded to the
|
||||
* immediately preceding definition and to the enclosing module body, so a def
|
||||
* of another name ends the group and two same-named functions in two modules
|
||||
* stay two nodes. */
|
||||
TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module) {
|
||||
CBMFileResult *r = extract("defmodule Roles do\n" /* 1 */
|
||||
" def admin?(%{role: :admin}), do: true\n" /* 2 */
|
||||
" def admin?(%{}), do: false\n" /* 3 */
|
||||
"\n" /* 4 */
|
||||
" def promote(user), do: user\n" /* 5 */
|
||||
"\n" /* 6 */
|
||||
" def admin?(_other), do: false\n" /* 7 */
|
||||
"end\n" /* 8 */
|
||||
"\n" /* 9 */
|
||||
"defmodule Guests do\n" /* 10 */
|
||||
" def admin?(:root), do: true\n" /* 11 */
|
||||
" def admin?(_any), do: false\n" /* 12 */
|
||||
"end\n", /* 13 */
|
||||
CBM_LANG_ELIXIR, "t", "roles.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
/* The two adjacent Roles clauses fold; the clause past `promote` is its own
|
||||
* group; the Guests pair is a DIFFERENT function and stays its own node. */
|
||||
ASSERT_EQ(3, count_defs_named(r, "Function", "admin?"));
|
||||
ASSERT_EQ(1, count_defs_named(r, "Function", "promote"));
|
||||
|
||||
const CBMDefinition *admin[3] = {NULL, NULL, NULL};
|
||||
const CBMDefinition *promote = NULL;
|
||||
int seen = 0;
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
if (strcmp(r->defs.items[i].label, "Function") != 0) {
|
||||
continue;
|
||||
}
|
||||
if (strcmp(r->defs.items[i].name, "admin?") == 0 && seen < 3) {
|
||||
admin[seen++] = &r->defs.items[i];
|
||||
} else if (strcmp(r->defs.items[i].name, "promote") == 0) {
|
||||
promote = &r->defs.items[i];
|
||||
}
|
||||
}
|
||||
ASSERT_NOT_NULL(admin[0]);
|
||||
ASSERT_NOT_NULL(admin[1]);
|
||||
ASSERT_NOT_NULL(admin[2]);
|
||||
ASSERT_NOT_NULL(promote);
|
||||
|
||||
ASSERT_EQ(2, (int)admin[0]->start_line);
|
||||
ASSERT_EQ(3, (int)admin[0]->end_line);
|
||||
/* separated from lines 2-3 by `promote`, so it keeps its own span */
|
||||
ASSERT_EQ(7, (int)admin[1]->start_line);
|
||||
ASSERT_EQ(7, (int)admin[1]->end_line);
|
||||
/* Guests.admin? never reaches back over the module boundary */
|
||||
ASSERT_EQ(11, (int)admin[2]->start_line);
|
||||
ASSERT_EQ(12, (int)admin[2]->end_line);
|
||||
ASSERT_EQ(5, (int)promote->start_line);
|
||||
ASSERT_EQ(5, (int)promote->end_line);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A nested module is the one place where the "def of another name ends the
|
||||
* group" rule is not enough: an Elixir QN carries no module, so `Outer.run` and
|
||||
* `Outer.Inner.run` compute the SAME qualified name, and when Inner sits
|
||||
* directly above Outer's own clause the two are adjacent in the extracted defs.
|
||||
* The fold is bounded to the module body as well, so they stay two nodes. */
|
||||
TEST(extract_elixir_nested_module_clauses_do_not_fold) {
|
||||
CBMFileResult *r = extract("defmodule Outer do\n" /* 1 */
|
||||
" defmodule Inner do\n" /* 2 */
|
||||
" def run(a), do: a\n" /* 3 */
|
||||
" end\n" /* 4 */
|
||||
"\n" /* 5 */
|
||||
" def run(b), do: b + 1\n" /* 6 */
|
||||
"end\n", /* 7 */
|
||||
CBM_LANG_ELIXIR, "t", "nested.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT_EQ(2, count_defs_named(r, "Function", "run"));
|
||||
|
||||
const CBMDefinition *runs[2] = {NULL, NULL};
|
||||
int seen = 0;
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
if (strcmp(r->defs.items[i].label, "Function") == 0 &&
|
||||
strcmp(r->defs.items[i].name, "run") == 0 && seen < 2) {
|
||||
runs[seen++] = &r->defs.items[i];
|
||||
}
|
||||
}
|
||||
ASSERT_NOT_NULL(runs[0]);
|
||||
ASSERT_NOT_NULL(runs[1]);
|
||||
ASSERT_EQ(3, (int)runs[0]->start_line);
|
||||
ASSERT_EQ(3, (int)runs[0]->end_line);
|
||||
ASSERT_EQ(6, (int)runs[1]->start_line);
|
||||
ASSERT_EQ(6, (int)runs[1]->end_line);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Folding clauses has to decide is_exported for the merged node. A `def` clause
|
||||
* and a `defp` clause of the same name AND arity are clauses of one function --
|
||||
* a private guard clause or a nil short-circuit written above the public head
|
||||
* is ordinary Elixir -- so is_exported is the OR over the folded clauses: the
|
||||
* name is callable from outside if any clause exports it, and a wholly private
|
||||
* function stays unexported. */
|
||||
TEST(extract_elixir_folded_clause_is_exported_is_the_or) {
|
||||
CBMFileResult *r = extract("defmodule Acc do\n" /* 1 */
|
||||
" def build(l), do: build(l)\n" /* 2 */
|
||||
" defp build([]), do: []\n" /* 3 */
|
||||
"\n" /* 4 */
|
||||
" defp trim(nil), do: nil\n" /* 5 */
|
||||
" defp trim(s), do: s\n" /* 6 */
|
||||
"end\n", /* 7 */
|
||||
CBM_LANG_ELIXIR, "t", "acc.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT_EQ(1, count_defs_named(r, "Function", "build"));
|
||||
ASSERT_EQ(1, count_defs_named(r, "Function", "trim"));
|
||||
|
||||
const CBMDefinition *build = NULL;
|
||||
const CBMDefinition *trim = NULL;
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
if (strcmp(r->defs.items[i].label, "Function") != 0) {
|
||||
continue;
|
||||
}
|
||||
if (strcmp(r->defs.items[i].name, "build") == 0) {
|
||||
build = &r->defs.items[i];
|
||||
} else if (strcmp(r->defs.items[i].name, "trim") == 0) {
|
||||
trim = &r->defs.items[i];
|
||||
}
|
||||
}
|
||||
ASSERT_NOT_NULL(build);
|
||||
ASSERT_NOT_NULL(trim);
|
||||
ASSERT_EQ(2, (int)build->start_line);
|
||||
ASSERT_EQ(3, (int)build->end_line);
|
||||
ASSERT_TRUE(build->is_exported);
|
||||
ASSERT_EQ(5, (int)trim->start_line);
|
||||
ASSERT_EQ(6, (int)trim->end_line);
|
||||
ASSERT_FALSE(trim->is_exported);
|
||||
cbm_free_result(r);
|
||||
|
||||
/* The other direction, and the only one the `prev->is_exported ||` disjunct
|
||||
* decides. Above, the exporting clause is the FIRST one, so a plain
|
||||
* `prev->is_exported = is_exported` assignment would also have to lose it
|
||||
* on the later defp -- but a last-clause-wins assignment happens to be
|
||||
* right whenever the def comes last. Here the defp comes first and the def
|
||||
* second, so the surviving flag can only be true if the fold ORs rather
|
||||
* than assigns. */
|
||||
CBMFileResult *o = extract("defmodule Ord do\n" /* 1 */
|
||||
" defp z(1), do: 1\n" /* 2 */
|
||||
" def z(2), do: 2\n" /* 3 */
|
||||
"end\n", /* 4 */
|
||||
CBM_LANG_ELIXIR, "t", "ord.ex");
|
||||
ASSERT_NOT_NULL(o);
|
||||
ASSERT_FALSE(o->has_error);
|
||||
ASSERT_EQ(1, count_defs_named(o, "Function", "z"));
|
||||
const CBMDefinition *z = NULL;
|
||||
for (int i = 0; i < o->defs.count; i++) {
|
||||
if (strcmp(o->defs.items[i].label, "Function") == 0 &&
|
||||
strcmp(o->defs.items[i].name, "z") == 0) {
|
||||
z = &o->defs.items[i];
|
||||
}
|
||||
}
|
||||
ASSERT_NOT_NULL(z);
|
||||
ASSERT_EQ(2, (int)z->start_line);
|
||||
ASSERT_EQ(3, (int)z->end_line);
|
||||
ASSERT_TRUE(z->is_exported);
|
||||
cbm_free_result(o);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* First definition with this name, for the Elixir clause-fold tests below.
|
||||
* find_def() is declared much further down this file. */
|
||||
static const CBMDefinition *elixir_first_def(CBMFileResult *r, const char *name) {
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0) {
|
||||
return &r->defs.items[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A guarded clause head is a DECLARATION of the name, never a call to it, and
|
||||
* the call extractor has to agree with the def extractor about which node the
|
||||
* head is. It did not: it suppressed a head only when the head node IS the
|
||||
* def's first named argument, but `def f(x) when g` parses its whole head as a
|
||||
* `when` binary_operator, so the inner `f(x)` was not that argument and was
|
||||
* recorded as a call to `f`. Under the folded span that phantom lands inside
|
||||
* the function's own node, and self-recursion is decided by line containment,
|
||||
* so an ordinary two-clause guarded function reported itself recursive.
|
||||
* Recursion is a load-bearing signal: `recursive` is a queryable node property
|
||||
* and seeds the cycle detection in pass_complexity. */
|
||||
TEST(extract_elixir_guarded_clause_head_is_not_a_self_call) {
|
||||
CBMFileResult *r = extract("defmodule Guarded do\n" /* 1 */
|
||||
" def f(x) when is_integer(x), do: x\n" /* 2 */
|
||||
" def f(_x), do: 0\n" /* 3 */
|
||||
"\n" /* 4 */
|
||||
" def solo(x) when is_binary(x), do: x\n" /* 5 */
|
||||
"end\n", /* 6 */
|
||||
CBM_LANG_ELIXIR, "t", "guard_call.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
/* No call to either name exists anywhere in this file. */
|
||||
ASSERT_EQ(0, count_calls_named(r, "f"));
|
||||
ASSERT_EQ(0, count_calls_named(r, "solo"));
|
||||
/* The guard predicate IS a real call and stays one. */
|
||||
ASSERT_EQ(1, count_calls_named(r, "is_integer"));
|
||||
ASSERT_EQ(1, count_calls_named(r, "is_binary"));
|
||||
|
||||
ASSERT_EQ(1, count_defs_named(r, "Function", "f"));
|
||||
const CBMDefinition *f = elixir_first_def(r, "f");
|
||||
ASSERT_NOT_NULL(f);
|
||||
ASSERT_EQ(2, (int)f->start_line);
|
||||
ASSERT_EQ(3, (int)f->end_line);
|
||||
ASSERT_FALSE(f->is_recursive);
|
||||
|
||||
/* A single guarded clause was wrong even before the span widened: the
|
||||
* phantom landed on its own one-line node. */
|
||||
const CBMDefinition *solo = elixir_first_def(r, "solo");
|
||||
ASSERT_NOT_NULL(solo);
|
||||
ASSERT_FALSE(solo->is_recursive);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Every reference of any kind this file records for `name`, across all four
|
||||
* extractors the unified walk runs over one subtree: a call site, a value
|
||||
* usage, a read/write occurrence, a type reference. Suppressing a node in ONE
|
||||
* of them does not remove a phantom reference, it moves it -- handle_calls
|
||||
* declining a node leaves state->callee_expr unset, and handle_usages then
|
||||
* reaches the bare identifier and mints a USAGE instead. USAGE and CALLS are
|
||||
* separate rows in the edge table, so the relabelled phantom also escapes the
|
||||
* UNIQUE(source,target,type) dedup that had been collapsing it onto a real
|
||||
* call, and pass_importance counts an inbound reference that did not exist
|
||||
* before. An assertion naming only r->calls cannot see any of that. */
|
||||
static int elixir_any_ref_count(CBMFileResult *r, const char *name) {
|
||||
int count = count_calls_named(r, name);
|
||||
for (int i = 0; i < r->usages.count; i++) {
|
||||
if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, name) == 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < r->rw.count; i++) {
|
||||
if (r->rw.items[i].var_name && strcmp(r->rw.items[i].var_name, name) == 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < r->type_refs.count; i++) {
|
||||
if (r->type_refs.items[i].type_name && strcmp(r->type_refs.items[i].type_name, name) == 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* The head suppression has to REMOVE the phantom, not relabel it. What makes
|
||||
* that hold for a def head is not the calls walk alone: is_elixir_def_binding in
|
||||
* extract_usages.c already treats everything inside a def's first argument as
|
||||
* a binding occurrence rather than a reference, so the identifier handle_calls
|
||||
* declines is declined by handle_usages too. That agreement is the invariant
|
||||
* under test, and it is why the counts below are over every extractor rather
|
||||
* than over r->calls.
|
||||
*
|
||||
* `spec_subject` pins the boundary between the two mechanisms. A typespec
|
||||
* subject is the same shape as a def head, and suppressing it in the calls walk
|
||||
* would only relabel its phantom as a USAGE, because nothing treats it as a
|
||||
* binding. It is removed instead by skipping the whole `@spec` subtree before
|
||||
* any extractor sees it (is_elixir_typespec_attribute, extract_unified.c), so
|
||||
* the assertion is both that no reference of any kind survives AND that what
|
||||
* does survive is never a relabelling: the equality holds at 0 == 0 here, and
|
||||
* would hold at 1 == 1 on a build with the subtree skip removed but the calls
|
||||
* suppression left in place. */
|
||||
TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind) {
|
||||
CBMFileResult *r = extract("defmodule Heads do\n" /* 1 */
|
||||
" def guarded(x) when is_integer(x), do: x\n" /* 2 */
|
||||
" def guarded(_x), do: 0\n" /* 3 */
|
||||
"\n" /* 4 */
|
||||
" defp bare(x), do: x\n" /* 5 */
|
||||
"\n" /* 6 */
|
||||
" @spec spec_subject(integer) :: integer\n" /* 7 */
|
||||
" def spec_subject(n), do: n\n" /* 8 */
|
||||
"end\n", /* 9 */
|
||||
CBM_LANG_ELIXIR, "t", "heads.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
|
||||
/* Nothing in this file references `guarded` or `bare`. Not as a call, not
|
||||
* as a value, not as a read/write, not as a type. */
|
||||
ASSERT_EQ(0, elixir_any_ref_count(r, "guarded"));
|
||||
ASSERT_EQ(0, elixir_any_ref_count(r, "bare"));
|
||||
/* The guard predicate is a real call and survives as one. */
|
||||
ASSERT_EQ(1, count_calls_named(r, "is_integer"));
|
||||
ASSERT_EQ(1, elixir_any_ref_count(r, "is_integer"));
|
||||
/* Neither the @spec line nor the def head it precedes leaves a reference,
|
||||
* and nothing has been relabelled into a usage, a read/write or a type
|
||||
* reference. */
|
||||
ASSERT_EQ(0, count_calls_named(r, "spec_subject"));
|
||||
ASSERT_EQ(count_calls_named(r, "spec_subject"),
|
||||
elixir_any_ref_count(r, "spec_subject"));
|
||||
|
||||
ASSERT_EQ(1, count_defs_named(r, "Function", "guarded"));
|
||||
const CBMDefinition *guarded = elixir_first_def(r, "guarded");
|
||||
ASSERT_NOT_NULL(guarded);
|
||||
ASSERT_EQ(2, (int)guarded->start_line);
|
||||
ASSERT_EQ(3, (int)guarded->end_line);
|
||||
ASSERT_FALSE(guarded->is_recursive);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The other direction: suppressing declaration heads must not blind the
|
||||
* detector to real recursion, and folding must not lose a self-call written in
|
||||
* a clause that is no longer the surviving one. `walk` recurses from its
|
||||
* guarded second clause; exactly one call to `walk` is recorded (the body one,
|
||||
* not the two heads) and the folded node is recursive. */
|
||||
TEST(extract_elixir_real_recursion_survives_head_suppression) {
|
||||
CBMFileResult *r = extract("defmodule Rec do\n" /* 1 */
|
||||
" def walk([]), do: []\n" /* 2 */
|
||||
" def walk([h | t]) when is_integer(h), do: [h | walk(t)]\n" /* 3 */
|
||||
"end\n", /* 4 */
|
||||
CBM_LANG_ELIXIR, "t", "rec.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
ASSERT_EQ(1, count_calls_named(r, "walk"));
|
||||
ASSERT_EQ(1, count_defs_named(r, "Function", "walk"));
|
||||
const CBMDefinition *walk = elixir_first_def(r, "walk");
|
||||
ASSERT_NOT_NULL(walk);
|
||||
ASSERT_EQ(2, (int)walk->start_line);
|
||||
ASSERT_EQ(3, (int)walk->end_line);
|
||||
ASSERT_TRUE(walk->is_recursive);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* An Elixir module attribute parses as `unary_operator(@, call(<attr>, args))`,
|
||||
* so a typespec line reaches the unified walk as ordinary code and every symbol
|
||||
* it names collects a phantom inbound edge from the enclosing Module -- a CALLS
|
||||
* onto the specified function, and for `@type` a USAGE plus a WRITES asserting
|
||||
* a mutation that does not exist. On a codebase whose convention is @spec on
|
||||
* every public function, fan_in then never reaches 0 and "which exported
|
||||
* functions nothing calls" is unanswerable.
|
||||
*
|
||||
* All six heads in the skip list are exercised, so deleting any one entry from
|
||||
* typespec_heads[] breaks this test.
|
||||
*
|
||||
* Two of the groups below pin a decision rather than a repair, and both are
|
||||
* argued in is_elixir_typespec_attribute's comment:
|
||||
*
|
||||
* type_refs[] -- a typespec's type references are not indexed at all. A type
|
||||
* declaration mints no node, so such a reference resolves onto nothing or
|
||||
* onto a same-named FUNCTION, which is the pollution rather than a record of
|
||||
* it. Restoring any of them must break this test.
|
||||
*
|
||||
* behaviour_contract_names[] -- accepted cost. A @callback / @macrocallback
|
||||
* name is a function name, so before this change it resolved cross-file onto
|
||||
* the functions implementing the behaviour. Nothing replaces that edge.
|
||||
*
|
||||
* The attribute NAME phantom outside the typespec family is deliberately still
|
||||
* there: `@timeout` keeps its one reference, and its VALUE is ordinary
|
||||
* compile-time code that still runs. `guarded` is pinned at 0 rather than 1
|
||||
* because the guarded def head no longer mints a phantom either -- the calls
|
||||
* walk now recognises a `when`-wrapped head as a declaration. */
|
||||
TEST(extract_elixir_typespec_attribute_is_not_code) {
|
||||
/* Names a typespec declares, plus the six attribute heads themselves.
|
||||
* Every one carries a reference on the pristine build. */
|
||||
static const char *const declared[] = {"with_spec", "entry", "secret", "handle",
|
||||
"spec", "type", "typep", "opaque",
|
||||
"callback", "macrocallback", NULL};
|
||||
/* Accepted cost: these used to resolve onto the behaviour's implementors. */
|
||||
static const char *const behaviour_contract_names[] = {"handle_it", "expand_it", NULL};
|
||||
/* Pinned decision: type references are not indexed -- remote, bare-local
|
||||
* and builtin alike. */
|
||||
static const char *const type_refs[] = {"MyApp.User.t", "String.t", "MyApp.Vault.key",
|
||||
"Macro.t", "t", "key",
|
||||
"term", "integer", "reference",
|
||||
"atom", NULL};
|
||||
CBMFileResult *r = extract("defmodule Specced do\n"
|
||||
" @timeout Application.compile_env(:app, :timeout)\n"
|
||||
" @type entry :: String.t()\n"
|
||||
" @typep secret :: MyApp.Vault.key()\n"
|
||||
" @opaque handle :: reference()\n"
|
||||
" @callback handle_it(term) :: :ok\n"
|
||||
" @macrocallback expand_it(term) :: Macro.t()\n"
|
||||
" @spec with_spec(MyApp.User.t()) :: integer\n"
|
||||
" def with_spec(n), do: n\n"
|
||||
" def without_spec(n), do: n\n"
|
||||
" @spec guarded(atom) :: :ok\n"
|
||||
" def guarded(x) when is_atom(x), do: :ok\n"
|
||||
"end\n",
|
||||
CBM_LANG_ELIXIR, "t", "specced.ex");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
/* The definitions themselves are a separate pass and are unaffected. */
|
||||
ASSERT(has_def(r, "Function", "with_spec"));
|
||||
ASSERT(has_def(r, "Function", "without_spec"));
|
||||
ASSERT(has_def(r, "Function", "guarded"));
|
||||
/* Control on the instrument, not a pin: `without_spec` carries no typespec
|
||||
* and is never called, so it reads 0 on the pristine build too. It is here
|
||||
* to show elixir_any_ref_count does not score a definition as a reference,
|
||||
* which is what would make every 0 below vacuous. It cannot fail first and
|
||||
* cannot catch a regression. */
|
||||
ASSERT_EQ(0, elixir_any_ref_count(r, "without_spec"));
|
||||
for (const char *const *name = declared; *name; name++) {
|
||||
ASSERT_EQ(0, elixir_any_ref_count(r, *name));
|
||||
}
|
||||
for (const char *const *name = behaviour_contract_names; *name; name++) {
|
||||
ASSERT_EQ(0, elixir_any_ref_count(r, *name));
|
||||
}
|
||||
for (const char *const *name = type_refs; *name; name++) {
|
||||
ASSERT_EQ(0, elixir_any_ref_count(r, *name));
|
||||
}
|
||||
/* A non-typespec attribute is untouched: its value really is compile-time
|
||||
* code, and its name still mints the one phantom this fix does not close. */
|
||||
ASSERT_EQ(1, count_calls_named(r, "Application.compile_env"));
|
||||
ASSERT_EQ(1, elixir_any_ref_count(r, "timeout"));
|
||||
/* Both phantoms onto `guarded` are gone: the @spec one to the subtree skip,
|
||||
* the def-head one to the calls walk recognising a guarded head. */
|
||||
ASSERT_EQ(0, elixir_any_ref_count(r, "guarded"));
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* tree-sitter-elixir gives a call's arguments node no field name, so the
|
||||
* generic `arguments` field lookup returns null and first_string_arg was never
|
||||
* populated for any Elixir call — Phoenix route paths, service URLs and config
|
||||
@@ -13762,7 +14527,18 @@ SUITE(extraction) {
|
||||
|
||||
/* Functional */
|
||||
RUN_TEST(elixir_function);
|
||||
RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it);
|
||||
RUN_TEST(extract_elixir_guarded_def_head);
|
||||
RUN_TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module);
|
||||
RUN_TEST(extract_elixir_nested_module_clauses_do_not_fold);
|
||||
RUN_TEST(extract_elixir_folded_clause_is_exported_is_the_or);
|
||||
RUN_TEST(extract_elixir_guarded_clause_head_is_not_a_self_call);
|
||||
RUN_TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind);
|
||||
RUN_TEST(extract_elixir_real_recursion_survives_head_suppression);
|
||||
RUN_TEST(extract_elixir_typespec_attribute_is_not_code);
|
||||
RUN_TEST(elixir_call_string_argument);
|
||||
RUN_TEST(extract_elixir_guarded_def_head_scope);
|
||||
RUN_TEST(extract_elixir_guarded_def_head_guard_usages);
|
||||
RUN_TEST(elixir_channels_pubsub_and_handle_in);
|
||||
RUN_TEST(haskell_function);
|
||||
RUN_TEST(haskell_infix_definition_name);
|
||||
|
||||
@@ -173,6 +173,13 @@ TEST(infrascan_service_pattern_match_rejects_ids_inside_words) {
|
||||
{"kafkaesque.story.tell", CBM_SVC_NONE},
|
||||
/* "Axios" glued to a lowercase continuation */
|
||||
{"geo.Axiosphere.map", CBM_SVC_NONE},
|
||||
/* Angular receiver rows end in a dot (#2768): a longer receiver that
|
||||
* only starts with the same letters is not the HttpClient (NestJS
|
||||
* route registration, AngularJS test double) */
|
||||
{"this.httpAdapter.get", CBM_SVC_NONE},
|
||||
{"this.httpServer.post", CBM_SVC_NONE},
|
||||
{"$httpBackend.expectGET", CBM_SVC_NONE},
|
||||
{"this.httpAdapter\n .get", CBM_SVC_NONE},
|
||||
{NULL, CBM_SVC_NONE},
|
||||
};
|
||||
ASSERT_EQ(svc_case_mismatches(cases), 0);
|
||||
@@ -202,6 +209,18 @@ TEST(infrascan_service_pattern_match_keeps_real_library_qns) {
|
||||
{"Axios.post", CBM_SVC_HTTP},
|
||||
{"AxiosInstance.get", CBM_SVC_HTTP},
|
||||
{"curl_exec", CBM_SVC_HTTP},
|
||||
/* Angular HttpClient receivers (#2768); `_httpClient` is the common
|
||||
* C# field spelling of the same client */
|
||||
{"this.http.get", CBM_SVC_HTTP},
|
||||
{"this.httpClient.post", CBM_SVC_HTTP},
|
||||
{"httpClient.put", CBM_SVC_HTTP},
|
||||
{"_httpClient.GetAsync", CBM_SVC_HTTP},
|
||||
{"$http.get", CBM_SVC_HTTP},
|
||||
/* a chain wrapped before the verb keeps the line break (LF / CRLF) */
|
||||
{"this.http\n .post", CBM_SVC_HTTP},
|
||||
{"this.http\r\n .delete", CBM_SVC_HTTP},
|
||||
{"this.httpClient\n .put", CBM_SVC_HTTP},
|
||||
{"$http\n .get", CBM_SVC_HTTP},
|
||||
{"surf.get", CBM_SVC_HTTP},
|
||||
{"hyper.Client.request", CBM_SVC_HTTP},
|
||||
{"phin.promisified", CBM_SVC_HTTP},
|
||||
|
||||
@@ -10227,6 +10227,57 @@ TEST(tool_delete_project_mutation_guard_blocks_then_releases) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* delete_project removes the ADR sidecar with the project, and a re-index of a
|
||||
* same-named project afterwards starts with no ADR (round-2 review item 7c). */
|
||||
TEST(tool_delete_project_removes_adr_sidecar) {
|
||||
char cache[256];
|
||||
snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-adr-XXXXXX");
|
||||
if (!cbm_mkdtemp(cache)) {
|
||||
PASS();
|
||||
}
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
const char *project = "adr-delete-project";
|
||||
char db_path[CBM_SZ_1K];
|
||||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||||
cbm_store_t *setup = cbm_store_open_path(db_path);
|
||||
ASSERT_NOT_NULL(setup);
|
||||
ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/adr-delete-project"), CBM_STORE_OK);
|
||||
ASSERT_EQ(cbm_store_adr_store(setup, project, "# Decision\nkeep it"), CBM_STORE_OK);
|
||||
cbm_store_close(setup);
|
||||
|
||||
char adr_path[CBM_SZ_1K];
|
||||
snprintf(adr_path, sizeof(adr_path), "%s.adr.db", db_path);
|
||||
ASSERT_TRUE(cbm_file_exists(adr_path));
|
||||
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
char *resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"adr-delete-project\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "deleted"));
|
||||
free(resp);
|
||||
|
||||
ASSERT_FALSE(cbm_file_exists(db_path));
|
||||
ASSERT_FALSE(cbm_file_exists(adr_path)); /* sidecar removed with the project */
|
||||
|
||||
/* A fresh store of the same name starts with no ADR. */
|
||||
cbm_store_t *fresh = cbm_store_open_path(db_path);
|
||||
ASSERT_NOT_NULL(fresh);
|
||||
cbm_adr_t adr = {0};
|
||||
ASSERT_EQ(cbm_store_adr_get(fresh, project, &adr), CBM_STORE_NOT_FOUND);
|
||||
cbm_store_adr_free(&adr);
|
||||
cbm_store_close(fresh);
|
||||
|
||||
cbm_mcp_server_free(srv);
|
||||
cleanup_project_db(cache, project);
|
||||
cbm_rmdir(cache);
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) {
|
||||
char root[CBM_SZ_1K];
|
||||
(void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir());
|
||||
@@ -24385,6 +24436,161 @@ TEST(autoindex_limit_guards_git_root_issue713) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A servable project row is a committed index. user_version stays 0, so a
|
||||
* later run cannot take the incremental no-op and skip recording workers. */
|
||||
static bool mcp_publish_committed_index(const char *root) {
|
||||
char *project = root ? cbm_project_name_from_path(root) : NULL;
|
||||
cbm_store_t *store = project ? cbm_store_open(project) : NULL;
|
||||
bool published = store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK;
|
||||
cbm_store_close(store);
|
||||
free(project);
|
||||
return published;
|
||||
}
|
||||
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
typedef struct {
|
||||
const char *root;
|
||||
bool published;
|
||||
} mcp_committed_seed_t;
|
||||
|
||||
/* maybe_auto_index returns before the thread when <project>.db already
|
||||
* exists. Publish the committed index from the count hook, which runs after
|
||||
* that check and before autoindex_thread chooses a width. */
|
||||
static void mcp_autoindex_publish_committed(void *context) {
|
||||
mcp_committed_seed_t *seed = context;
|
||||
if (seed) {
|
||||
seed->published = mcp_publish_committed_index(seed->root);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/* First index, including one started automatically, uses every core. Headroom
|
||||
* applies only once a servable project database already exists.
|
||||
* automatic: in-process auto-index (initialize → autoindex_thread).
|
||||
* Otherwise index_repository with _cbm_background, the daemon/watcher path. */
|
||||
static int mcp_assert_inprocess_worker_policy(bool committed, bool automatic) {
|
||||
char cache[256];
|
||||
char repo[512];
|
||||
(void)snprintf(cache, sizeof(cache), "%s/cbm-worker-policy-%d%d-XXXXXX", cbm_tmpdir(),
|
||||
committed ? 1 : 0, automatic ? 1 : 0);
|
||||
bool cache_ready = cbm_mkdtemp(cache) != NULL;
|
||||
(void)snprintf(repo, sizeof(repo), "%s/repo", cache);
|
||||
char source[640];
|
||||
(void)snprintf(source, sizeof(source), "%s/main.py", repo);
|
||||
bool repo_ready = cache_ready && th_mkdir_p(repo) == 0 &&
|
||||
th_write_file(source, "def background_index():\n return True\n") == 0;
|
||||
|
||||
mcp_test_env_backup_t environment[] = {
|
||||
{.name = "CBM_CACHE_DIR"},
|
||||
{.name = "CBM_WORKERS"},
|
||||
{.name = "CBM_INDEX_SINGLE_THREAD"},
|
||||
};
|
||||
bool environment_saved = true;
|
||||
for (size_t i = 0; i < sizeof(environment) / sizeof(environment[0]); i++) {
|
||||
const char *value = getenv(environment[i].name);
|
||||
environment[i].present = value != NULL;
|
||||
environment[i].value = value ? strdup(value) : NULL;
|
||||
environment_saved = environment_saved && (!value || environment[i].value);
|
||||
}
|
||||
bool environment_ready = environment_saved && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 &&
|
||||
cbm_unsetenv("CBM_WORKERS") == 0 &&
|
||||
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD") == 0;
|
||||
|
||||
bool seeded = !committed;
|
||||
char old_cwd[CBM_SZ_4K] = {0};
|
||||
bool cwd_ready = true;
|
||||
cbm_config_t *config = NULL;
|
||||
bool config_ready = true;
|
||||
cbm_mcp_server_t *server = NULL;
|
||||
char *response = NULL;
|
||||
if (automatic) {
|
||||
cwd_ready = repo_ready && environment_ready && cbm_getcwd(old_cwd, sizeof(old_cwd)) &&
|
||||
cbm_chdir(repo) == 0;
|
||||
config = cwd_ready ? cbm_config_open(cache) : NULL;
|
||||
config_ready = config && cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "true") == 0 &&
|
||||
cbm_config_set(config, CBM_CONFIG_AUTO_WATCH, "false") == 0;
|
||||
cbm_pipeline_worker_count_test_reset();
|
||||
server = config_ready ? cbm_mcp_server_new(NULL) : NULL;
|
||||
if (server) {
|
||||
cbm_mcp_server_set_config(server, config);
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
mcp_committed_seed_t seed = {.root = repo, .published = !committed};
|
||||
if (committed) {
|
||||
cbm_mcp_server_set_auto_index_count_test_hook(
|
||||
server, mcp_autoindex_publish_committed, &seed);
|
||||
}
|
||||
#endif
|
||||
response = cbm_mcp_server_handle(
|
||||
server, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}");
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
seeded = seed.published;
|
||||
#endif
|
||||
cbm_mcp_server_free(server); /* joins the automatic index thread */
|
||||
}
|
||||
} else {
|
||||
if (committed && repo_ready && environment_ready) {
|
||||
seeded = mcp_publish_committed_index(repo);
|
||||
}
|
||||
cbm_pipeline_worker_count_test_reset();
|
||||
server = repo_ready && environment_ready && seeded ? cbm_mcp_server_new(NULL) : NULL;
|
||||
if (server) {
|
||||
char repo_json[512];
|
||||
char args[CBM_SZ_1K];
|
||||
(void)snprintf(repo_json, sizeof(repo_json), "%s", repo);
|
||||
cbm_normalize_path_sep(repo_json);
|
||||
(void)snprintf(args, sizeof(args), "{\"_cbm_background\":true,\"repo_path\":\"%s\"}",
|
||||
repo_json);
|
||||
response = cbm_mcp_handle_tool(server, "index_repository", args);
|
||||
cbm_mcp_server_free(server);
|
||||
}
|
||||
}
|
||||
int selected_workers = cbm_pipeline_worker_count_test_last();
|
||||
bool server_ready = server != NULL;
|
||||
bool response_ready = response != NULL;
|
||||
|
||||
free(response);
|
||||
cbm_config_close(config);
|
||||
if (automatic && cwd_ready) {
|
||||
(void)cbm_chdir(old_cwd);
|
||||
}
|
||||
/* Capture while CBM_WORKERS is still unset. Restoring first makes the
|
||||
* expected count follow the lane override instead of this policy. */
|
||||
int expected = committed ? cbm_pipeline_background_worker_width(cbm_default_worker_count(true),
|
||||
cbm_default_worker_count(false))
|
||||
: cbm_default_worker_count(true);
|
||||
mcp_test_restore_env(environment, sizeof(environment) / sizeof(environment[0]));
|
||||
bool cleaned = !cache_ready || th_rmtree(cache) == 0;
|
||||
|
||||
ASSERT_TRUE(cache_ready);
|
||||
ASSERT_TRUE(repo_ready);
|
||||
ASSERT_TRUE(environment_saved);
|
||||
ASSERT_TRUE(environment_ready);
|
||||
ASSERT_TRUE(cwd_ready);
|
||||
ASSERT_TRUE(config_ready);
|
||||
ASSERT_TRUE(seeded);
|
||||
ASSERT_TRUE(server_ready);
|
||||
ASSERT_TRUE(response_ready);
|
||||
ASSERT_EQ(selected_workers, expected);
|
||||
ASSERT_TRUE(cleaned);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(mcp_auto_index_in_process_fresh_project_uses_full_width) {
|
||||
return mcp_assert_inprocess_worker_policy(false, true);
|
||||
}
|
||||
|
||||
TEST(mcp_auto_index_in_process_existing_index_keeps_headroom) {
|
||||
return mcp_assert_inprocess_worker_policy(true, true);
|
||||
}
|
||||
|
||||
TEST(mcp_background_flag_fresh_project_uses_full_width) {
|
||||
return mcp_assert_inprocess_worker_policy(false, false);
|
||||
}
|
||||
|
||||
TEST(mcp_background_flag_existing_index_keeps_headroom) {
|
||||
return mcp_assert_inprocess_worker_policy(true, false);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* #853 — auto_watch=false must ALSO gate the SUPERVISED fresh-index
|
||||
* watcher registration (keystone × #849 merge interaction)
|
||||
@@ -26736,6 +26942,10 @@ 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);
|
||||
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);
|
||||
RUN_TEST(mcp_background_flag_existing_index_keeps_headroom);
|
||||
RUN_TEST(search_code_context_window_is_bounded);
|
||||
RUN_TEST(search_code_context_extreme_values_answer_normally);
|
||||
RUN_TEST(tool_query_graph_max_rows_above_ceiling_is_capped);
|
||||
@@ -26757,6 +26967,7 @@ SUITE(mcp_mutation_guard) {
|
||||
RUN_TEST(tool_ingest_traces_mutation_guard_missing_store_releases);
|
||||
RUN_TEST(tool_ingest_traces_mutation_guard_query_failure_releases);
|
||||
RUN_TEST(tool_delete_project_mutation_guard_blocks_then_releases);
|
||||
RUN_TEST(tool_delete_project_removes_adr_sidecar);
|
||||
RUN_TEST(tool_index_repository_mutation_guard_blocks_before_local_worker);
|
||||
RUN_TEST(tool_manage_adr_mutation_guard_balances_success);
|
||||
RUN_TEST(tool_manage_adr_read_paths_skip_blocking_mutation_guard);
|
||||
|
||||
@@ -3026,6 +3026,311 @@ TEST(parallel_csharp_same_name_class_binds_by_namespace) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #2120 residual, reported on a real repo: step 8b binds only namespace-
|
||||
* qualified TOP-LEVEL types. A qualified `A.B` whose qualifier A is a TYPE
|
||||
* (nested-type constructor `new Command.Result(...)`, chained-constructor
|
||||
* receiver `new Grain.Handler(dep).Handle(...)`) and a top-level type with NO
|
||||
* namespace declaration (global namespace, e.g. a `[Inject]`-consumed service)
|
||||
* both fell through to the short-name fallback, where the prefix score against
|
||||
* a path-derived QN is 0 for every variant — registration order picked the
|
||||
* target. Decoy files are registered FIRST so the old registry-order behavior
|
||||
* binds every check to the decoy and the test fails. */
|
||||
TEST(parallel_csharp_nested_type_and_global_namespace_binding) {
|
||||
static const char decoy_src[] =
|
||||
"namespace Acme.Layouts.Cqrs;\n"
|
||||
"public class GrainCommand {\n"
|
||||
" public class Handler {\n"
|
||||
" public int Handle() => 9;\n"
|
||||
" }\n"
|
||||
" public class Result {\n"
|
||||
" public Result(bool ok) { }\n"
|
||||
" public int Code() => 9;\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
static const char partner_src[] =
|
||||
"namespace Acme.Partner.Cqrs;\n"
|
||||
"public class GrainCommand {\n"
|
||||
" public class Handler {\n"
|
||||
" private readonly int _v;\n"
|
||||
" public Handler(int v) { _v = v; }\n"
|
||||
" public int Handle() => _v;\n"
|
||||
" }\n"
|
||||
" public class Result {\n"
|
||||
" public Result(bool ok) { }\n"
|
||||
" public int Code() => 1;\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
static const char backoffice_src[] =
|
||||
"namespace Acme.BackOffice.Cqrs;\n"
|
||||
"public class GrainCommand {\n"
|
||||
" public class Result {\n"
|
||||
" public Result(bool ok) { }\n"
|
||||
" public int Code() => 2;\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
static const char nested_user_src[] =
|
||||
"using Acme.Partner.Cqrs;\n"
|
||||
"namespace Acme.App;\n"
|
||||
"public class NestedUser {\n"
|
||||
" public int Run() {\n"
|
||||
" var r = new GrainCommand.Result(true);\n"
|
||||
" return r.Code();\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
static const char chained_user_src[] =
|
||||
"using Acme.Partner.Cqrs;\n"
|
||||
"namespace Acme.App;\n"
|
||||
"public class ChainedUser {\n"
|
||||
" public int Run() {\n"
|
||||
" return new GrainCommand.Handler(3).Handle();\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
static const char other_update_src[] =
|
||||
"namespace Acme.Other.Web.Services;\n"
|
||||
"public class UpdateService {\n"
|
||||
" public string Check() => \"other\";\n"
|
||||
"}\n";
|
||||
/* No namespace declaration: the global namespace. */
|
||||
static const char global_update_src[] =
|
||||
"public class UpdateService {\n"
|
||||
" public string Check() => \"global\";\n"
|
||||
"}\n";
|
||||
static const char boot_src[] =
|
||||
"namespace Acme.App;\n"
|
||||
"public class Boot {\n"
|
||||
" public string Boot1() {\n"
|
||||
" var u = new UpdateService();\n"
|
||||
" return u.Check();\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
/* The lookup-order probes: the SAME simple name is importable from
|
||||
* Acme.Other.Web.Services AND declared in the global namespace. C# looks
|
||||
* a simple name up namespace by namespace, innermost outward; at each one
|
||||
* it checks that namespace's members, then the usings of the namespace
|
||||
* declaration enclosing the reference. The global namespace comes last,
|
||||
* and its "declaration" is the compilation unit, so:
|
||||
* - a compilation-unit `using` (ImportUser) is consulted only AFTER the
|
||||
* global namespace's members: the global UpdateService wins;
|
||||
* - a `using` inside the namespace body (NsUsingUser) is consulted
|
||||
* before any outer namespace: the imported UpdateService wins. */
|
||||
static const char import_user_src[] =
|
||||
"using Acme.Other.Web.Services;\n"
|
||||
"namespace Acme.App;\n"
|
||||
"public class ImportUser {\n"
|
||||
" public string Run2() {\n"
|
||||
" var u = new UpdateService();\n"
|
||||
" return u.Check();\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
static const char ns_using_user_src[] =
|
||||
"namespace Acme.App {\n"
|
||||
" using Acme.Other.Web.Services;\n"
|
||||
" public class NsUsingUser {\n"
|
||||
" public string Run3() {\n"
|
||||
" var u = new UpdateService();\n"
|
||||
" return u.Check();\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
const char *project = "cbm_cs_nested";
|
||||
static const char *rels[] = {
|
||||
"src/Decoy/Layouts.cs",
|
||||
"src/Decoy/OtherUpdate.cs",
|
||||
"src/Partner/Cqrs/GrainCommand.cs",
|
||||
"src/BackOffice/Cqrs/GrainCommand.cs",
|
||||
"src/Globals/UpdateService.cs",
|
||||
"src/App/NestedUser.cs",
|
||||
"src/App/ChainedUser.cs",
|
||||
"src/App/Boot.cs",
|
||||
"src/App/ImportUser.cs",
|
||||
"src/App/NsUsingUser.cs",
|
||||
};
|
||||
const char *srcs[] = {decoy_src, other_update_src, partner_src,
|
||||
backoffice_src, global_update_src, nested_user_src,
|
||||
chained_user_src, boot_src, import_user_src,
|
||||
ns_using_user_src};
|
||||
enum { N_FILES = 10, N_CHECKS = 12 };
|
||||
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_nested_XXXXXX");
|
||||
if (!cbm_mkdtemp(tmpdir)) {
|
||||
FAIL("mkdtemp failed");
|
||||
}
|
||||
char paths[N_FILES][512];
|
||||
cbm_file_info_t files[N_FILES] = {0};
|
||||
for (int i = 0; i < N_FILES; i++) {
|
||||
snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]);
|
||||
if (th_write_file(paths[i], srcs[i]) != 0) {
|
||||
th_rmtree(tmpdir);
|
||||
FAIL("failed to write C# nested-type fixture");
|
||||
}
|
||||
files[i].path = paths[i];
|
||||
files[i].rel_path = (char *)rels[i];
|
||||
files[i].language = CBM_LANG_CSHARP;
|
||||
}
|
||||
|
||||
cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL};
|
||||
g_harness_cs_registry = true;
|
||||
graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2);
|
||||
g_harness_cs_registry = false;
|
||||
ASSERT_NOT_NULL(graphs[0]);
|
||||
ASSERT_NOT_NULL(graphs[1]);
|
||||
|
||||
const char *pf_code = "cbm_cs_nested.src.Partner.Cqrs.GrainCommand.GrainCommand."
|
||||
"Result.Code";
|
||||
const char *pf_handle = "cbm_cs_nested.src.Partner.Cqrs.GrainCommand.GrainCommand."
|
||||
"Handler.Handle";
|
||||
const char *bo_code = "cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand."
|
||||
"Result.Code";
|
||||
const char *decoy_code = "cbm_cs_nested.src.Decoy.Layouts.GrainCommand.GrainCommand."
|
||||
"Result.Code";
|
||||
const char *decoy_handle = "cbm_cs_nested.src.Decoy.Layouts.GrainCommand.GrainCommand."
|
||||
"Handler.Handle";
|
||||
const char *global_check = "cbm_cs_nested.src.Globals.UpdateService.UpdateService.Check";
|
||||
const char *other_check = "cbm_cs_nested.src.Decoy.OtherUpdate.UpdateService.Check";
|
||||
bool ok[2][N_CHECKS];
|
||||
bool all = true;
|
||||
for (int g = 0; g < 2; g++) {
|
||||
cbm_gbuf_t *gb = graphs[g];
|
||||
ok[g][0] = has_edge_from_callable_to_qn(gb, "NestedUser.Run", pf_code, "CALLS");
|
||||
ok[g][1] = !has_edge_from_callable_to_qn(gb, "NestedUser.Run", bo_code, "CALLS") &&
|
||||
!has_edge_from_callable_to_qn(gb, "NestedUser.Run", decoy_code, "CALLS");
|
||||
/* The ctor EDGE itself joins through the pipeline's textual carrier and
|
||||
* is out of scope here (see PR notes); what must hold is that the
|
||||
* nested type RESOLUTION is right, proven by the Code()/Handle() edges
|
||||
* above, and that no ctor edge binds to a variant the caller cannot
|
||||
* see: neither the BackOffice nor the registry-first decoy. */
|
||||
ok[g][2] = !has_edge_from_callable_to_qn(
|
||||
gb, "NestedUser.Run",
|
||||
"cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand.Result",
|
||||
"CALLS") &&
|
||||
!has_edge_from_callable_to_qn(
|
||||
gb, "NestedUser.Run",
|
||||
"cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand."
|
||||
"Result.Result",
|
||||
"CALLS");
|
||||
ok[g][3] = !has_edge_from_callable_to_qn(gb, "NestedUser.Run",
|
||||
"cbm_cs_nested.src.Decoy.Layouts.GrainCommand."
|
||||
"GrainCommand.Result.Result",
|
||||
"CALLS");
|
||||
ok[g][4] = has_edge_from_callable_to_qn(gb, "ChainedUser.Run", pf_handle, "CALLS");
|
||||
ok[g][5] = !has_edge_from_callable_to_qn(gb, "ChainedUser.Run", decoy_handle, "CALLS");
|
||||
ok[g][6] = has_edge_from_callable_to_qn(gb, "Boot.Boot1", global_check, "CALLS");
|
||||
ok[g][7] = !has_edge_from_callable_to_qn(gb, "Boot.Boot1", other_check, "CALLS");
|
||||
ok[g][8] = has_edge_from_callable_to_qn(gb, "ImportUser.Run2", global_check, "CALLS");
|
||||
ok[g][9] = !has_edge_from_callable_to_qn(gb, "ImportUser.Run2", other_check, "CALLS");
|
||||
ok[g][10] = has_edge_from_callable_to_qn(gb, "NsUsingUser.Run3", other_check, "CALLS");
|
||||
ok[g][11] = !has_edge_from_callable_to_qn(gb, "NsUsingUser.Run3", global_check, "CALLS");
|
||||
for (int k = 0; k < N_CHECKS; k++) {
|
||||
/* The ctor-edge checks 2/3 gate the parallel path only. In the
|
||||
* sequential path the `new GrainCommand.Result(true)` edge is
|
||||
* still drawn by the textual ctor carrier, not by the resolved
|
||||
* type (the out-of-scope carrier join above); on current main it
|
||||
* lands on the BackOffice Result already with the nested-type
|
||||
* resolution alone. The diagnostic below still prints both. */
|
||||
if (g == 0 && (k == 2 || k == 3))
|
||||
continue;
|
||||
all = all && ok[g][k];
|
||||
}
|
||||
}
|
||||
if (!all) {
|
||||
for (int g = 0; g < 2; g++) {
|
||||
printf(" C# nested/global diagnostic %s: nested_code=%d/%d nested_ctor=%d/%d "
|
||||
"chained=%d/%d global_ns=%d/%d cu_using=%d/%d ns_using=%d/%d\n",
|
||||
g == 0 ? "sequential" : "parallel", ok[g][0], ok[g][1], ok[g][2], ok[g][3],
|
||||
ok[g][4], ok[g][5], ok[g][6], ok[g][7], ok[g][8], ok[g][9], ok[g][10],
|
||||
ok[g][11]);
|
||||
}
|
||||
}
|
||||
cbm_gbuf_free(graphs[0]);
|
||||
cbm_gbuf_free(graphs[1]);
|
||||
th_rmtree(tmpdir);
|
||||
ASSERT_TRUE(all);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A type extracted with no namespace is global only if its file parsed
|
||||
* cleanly. The vendored grammar does not parse a `ref partial struct` with
|
||||
* members (reference assemblies are full of them), and error recovery
|
||||
* dissolves the namespace around it, so the static class after it surfaces
|
||||
* with no namespace. Read as global, it outranked the compilation-unit
|
||||
* `using` and took every `Gadget.Spin()` call from the real, visible type;
|
||||
* on dotnet/runtime that moved 5,313 CALLS from implementation files into
|
||||
* reference assemblies. The damaged file is registered FIRST so neither the
|
||||
* old registry order nor the global rank can explain a pass. */
|
||||
TEST(parallel_csharp_parse_damaged_namespace_is_not_global) {
|
||||
static const char damaged_src[] = "namespace Acme.Lib\n"
|
||||
"{\n"
|
||||
" public ref partial struct Handler\n"
|
||||
" {\n"
|
||||
" public void Append(string value) { }\n"
|
||||
" }\n"
|
||||
" public static partial class Gadget {\n"
|
||||
" public static int Spin() => 9;\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
static const char real_src[] = "namespace Acme.Lib;\n"
|
||||
"public static class Gadget {\n"
|
||||
" public static int Spin() => 1;\n"
|
||||
"}\n";
|
||||
static const char user_src[] = "using Acme.Lib;\n"
|
||||
"namespace Acme.App;\n"
|
||||
"public class GadgetUser {\n"
|
||||
" public int Run() {\n"
|
||||
" return Gadget.Spin();\n"
|
||||
" }\n"
|
||||
"}\n";
|
||||
const char *project = "cbm_cs_damaged";
|
||||
static const char *rels[] = {"src/Ref/Lib.cs", "src/Lib/Gadget.cs", "src/App/GadgetUser.cs"};
|
||||
const char *srcs[] = {damaged_src, real_src, user_src};
|
||||
enum { N_FILES = 3 };
|
||||
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_damaged_XXXXXX");
|
||||
if (!cbm_mkdtemp(tmpdir)) {
|
||||
FAIL("mkdtemp failed");
|
||||
}
|
||||
char paths[N_FILES][512];
|
||||
cbm_file_info_t files[N_FILES] = {0};
|
||||
for (int i = 0; i < N_FILES; i++) {
|
||||
snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]);
|
||||
if (th_write_file(paths[i], srcs[i]) != 0) {
|
||||
th_rmtree(tmpdir);
|
||||
FAIL("failed to write C# damaged-namespace fixture");
|
||||
}
|
||||
files[i].path = paths[i];
|
||||
files[i].rel_path = (char *)rels[i];
|
||||
files[i].language = CBM_LANG_CSHARP;
|
||||
}
|
||||
|
||||
cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL};
|
||||
g_harness_cs_registry = true;
|
||||
graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2);
|
||||
g_harness_cs_registry = false;
|
||||
ASSERT_NOT_NULL(graphs[0]);
|
||||
ASSERT_NOT_NULL(graphs[1]);
|
||||
|
||||
const char *real_spin = "cbm_cs_damaged.src.Lib.Gadget.Gadget.Spin";
|
||||
const char *damaged_spin = "cbm_cs_damaged.src.Ref.Lib.Gadget.Spin";
|
||||
bool ok[2][2];
|
||||
for (int g = 0; g < 2; g++) {
|
||||
ok[g][0] = has_edge_from_callable_to_qn(graphs[g], "GadgetUser.Run", real_spin, "CALLS");
|
||||
ok[g][1] = !has_edge_from_callable_to_qn(graphs[g], "GadgetUser.Run", damaged_spin,
|
||||
"CALLS");
|
||||
}
|
||||
bool all = ok[0][0] && ok[0][1] && ok[1][0] && ok[1][1];
|
||||
if (!all) {
|
||||
printf(" C# damaged-namespace diagnostic: sequential real=%d no_damaged=%d "
|
||||
"parallel real=%d no_damaged=%d\n",
|
||||
ok[0][0], ok[0][1], ok[1][0], ok[1][1]);
|
||||
}
|
||||
cbm_gbuf_free(graphs[0]);
|
||||
cbm_gbuf_free(graphs[1]);
|
||||
th_rmtree(tmpdir);
|
||||
ASSERT_TRUE(all);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* External Kotlin protocol targets must not borrow a project method merely
|
||||
* because the final Class.method segments agree. Raw assertions prove that the
|
||||
* iterator/destructuring carriers and their stdlib semantic rows really exist;
|
||||
@@ -5694,6 +5999,8 @@ SUITE(parallel) {
|
||||
RUN_TEST(parallel_typescript_import_namespace_exact_parity);
|
||||
RUN_TEST(parallel_tsx_import_namespace_exact_parity);
|
||||
RUN_TEST(parallel_csharp_same_name_class_binds_by_namespace);
|
||||
RUN_TEST(parallel_csharp_nested_type_and_global_namespace_binding);
|
||||
RUN_TEST(parallel_csharp_parse_damaged_namespace_is_not_global);
|
||||
RUN_TEST(parallel_kotlin_external_protocol_does_not_use_project_class_method_tail);
|
||||
RUN_TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph);
|
||||
RUN_TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest);
|
||||
|
||||
+1359
-69
File diff suppressed because it is too large.
Load diff
@@ -1260,6 +1260,45 @@ TEST(go_bare_ref_never_binds_field) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(go_bare_init_never_binds_init_namesake) {
|
||||
/* Go cannot refer to a package init function, and a method or field
|
||||
* named init needs a selector, so a bare `init` (a closure, a parameter,
|
||||
* a `package init` clause) is always a local name. */
|
||||
/* Every Go Function named init is a package init: refused for every
|
||||
* registry strategy, same_module included (the pre-#1915 binding). */
|
||||
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Function"));
|
||||
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Function"));
|
||||
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Function"));
|
||||
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", NULL, "Function"));
|
||||
/* A Method or Field namesake found by the name alone is refused. */
|
||||
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Method"));
|
||||
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "unique_name", "Method"));
|
||||
ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Field"));
|
||||
/* ... but not one that an import- or module-aware strategy produced. */
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Method"));
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Method"));
|
||||
/* lsp_* answers are kept: the sequential resolver emits them before its
|
||||
* registry guards run, so refusing one here would split the resolvers. */
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Function"));
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Method"));
|
||||
/* The member half of a selector (`w.init()`, `e.init`) is not bare. */
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Method"));
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Field"));
|
||||
/* Other names and other target kinds are untouched. */
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "initAll", "suffix_match", "Method"));
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "Init", "suffix_match", "Function"));
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Variable"));
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Module"));
|
||||
/* Go only: elsewhere a bare `init()` is an ordinary call (a Java or C++
|
||||
* method body calls its own init, a C or Python init is a function). */
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "suffix_match", "Method"));
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "same_module", "Function"));
|
||||
/* Degenerate input → nothing to judge. */
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, NULL, "suffix_match", "Function"));
|
||||
ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", NULL));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(c_bare_ref_never_binds_field) {
|
||||
/* #2666: a C struct member is named only as the member half of `a.b`,
|
||||
* `a->b` or a designator `.b` (field_identifier → is_member_access). A
|
||||
@@ -2656,6 +2695,7 @@ SUITE(registry) {
|
||||
RUN_TEST(registry_tie_prefers_csharp_implementation_over_ref_stub);
|
||||
RUN_TEST(cross_language_ref_drops_go_vs_c);
|
||||
RUN_TEST(go_bare_ref_never_binds_field);
|
||||
RUN_TEST(go_bare_init_never_binds_init_namesake);
|
||||
RUN_TEST(c_bare_ref_never_binds_field);
|
||||
RUN_TEST(call_onto_field_follows_language_and_shape);
|
||||
RUN_TEST(c_bare_call_does_not_weigh_field_namesakes);
|
||||
|
||||
@@ -620,6 +620,22 @@ TEST(adr_store_and_retrieve) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(adr_restore_from_graph_if_absent_memory_is_noop) {
|
||||
/* A :memory: store keeps ADRs in the graph DB, so restore-if-absent is a
|
||||
* well-formed no-op (returns OK) and leaves an existing ADR untouched. */
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
|
||||
ASSERT_EQ(cbm_store_adr_store(s, "test", "v1"), CBM_STORE_OK);
|
||||
ASSERT_EQ(cbm_store_adr_restore_from_graph_if_absent(s), CBM_STORE_OK);
|
||||
cbm_adr_t adr;
|
||||
ASSERT_EQ(cbm_store_adr_get(s, "test", &adr), CBM_STORE_OK);
|
||||
ASSERT_STR_EQ(adr.content, "v1");
|
||||
cbm_store_adr_free(&adr);
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(adr_upsert) {
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
@@ -2033,6 +2049,7 @@ SUITE(store_arch) {
|
||||
/* ADR */
|
||||
RUN_TEST(adr_store_and_retrieve);
|
||||
RUN_TEST(adr_upsert);
|
||||
RUN_TEST(adr_restore_from_graph_if_absent_memory_is_noop);
|
||||
RUN_TEST(adr_delete);
|
||||
RUN_TEST(adr_delete_not_found);
|
||||
RUN_TEST(doc_candidates_replace_and_get);
|
||||
|
||||
Reference in new issue
Block a user