diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 7af2efa5e..33cd4a5af 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -65,8 +65,9 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- # The PR merge commit and its base parent: the change to select for.
- fetch-depth: 2
+ # Full history: the engine certifies the history it diffs and refuses
+ # a shallow clone, so a depth-2 checkout always answered `all`.
+ fetch-depth: 0
persist-credentials: false
- name: Build this checkout's engine
run: |
@@ -281,8 +282,11 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- # The PR merge commit and its base parent: the diff to predict for.
- fetch-depth: 2
+ # Full history: the engine (test-impact select) certifies the history
+ # it diffs and refuses a shallow clone ("Graft or shallow state
+ # prevents history certification"), which made every shadow answer
+ # run_all (GIT_UNAVAILABLE). About 400 MiB, this job only.
+ fetch-depth: 0
persist-credentials: false
- name: Fetch the latest released binary
id: release
diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml
index 9a13d9ad0..4db216ecd 100644
--- a/.github/workflows/test-impact-artifact.yml
+++ b/.github/workflows/test-impact-artifact.yml
@@ -38,7 +38,9 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- fetch-depth: 2
+ # Full history: `test-impact select/publish` certify the history they
+ # read and refuse a shallow clone.
+ fetch-depth: 0
persist-credentials: false
- name: Install build deps and LLVM 21 (coverage toolchain)
diff --git a/README.md b/README.md
index e1d1815b2..acf836f66 100644
--- a/README.md
+++ b/README.md
@@ -151,6 +151,8 @@ codebase-memory-mcp config set auto_index true
When enabled, new projects are indexed automatically on first connection. Previously-indexed projects are registered with the background watcher for ongoing git-based change detection. Configurable file limit: `config set auto_index_limit 50000`.
+The watcher follows MCP sessions: it watches the project each open MCP session is rooted in (the client's working directory) and stops when the last session for that project closes. A repository indexed with `cli index_repository`, or indexed from a session rooted somewhere else, is not watched — re-run `index_repository` after changing it. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.
+
Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher — useful when working across many projects and you want each session contained to explicit indexing.
To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch` — which is consulted per session — `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings).
@@ -246,7 +248,7 @@ The install script placed beside the binary is **reported, not deleted** — uni
### Distribution & operation
- **Native runtime set, zero infrastructure services**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/`
-- **Auto-sync**: Background watcher detects file changes and re-indexes automatically
+- **Auto-sync**: Background watcher detects file changes in the project an MCP session is working in and re-indexes it automatically (other indexed repos: re-run `index_repository`)
- **Route nodes**: REST endpoints are first-class graph entities
- **CLI mode**: `codebase-memory-mcp cli search_graph '{"project": "my-project", "name_pattern": ".*Handler.*"}'`
- **Available on**: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, `go install`
@@ -681,7 +683,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
| Tool | Description |
|------|-------------|
-| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh after that. 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). |
+| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh only while it is the project of an open MCP session; repos indexed from the CLI or from another project's session need a re-run (`index_status` shows `watch`). 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 all indexed projects with node/edge counts. |
| `delete_project` | Remove a project and all its graph data. |
| `index_status` | Check indexing status of a project. |
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 0099efb52..0ee1f41eb 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -88,11 +88,17 @@ Current keys:
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
| `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). |
-| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. |
+| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Only an open MCP session's own project is ever watched; `index_status` reports the current state in its `watch` object. |
| `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. |
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |
+For a watched project, `index_status` names the polling method in `watch.strategy`:
+`git` (HEAD and dirty-state polling), `tree` (a non-git root polled by the
+`watch_non_git` file-tree scan), `none` (a non-git root that is registered but never
+polled, the default for a plain directory) or `pending` (registered; the first poll
+has not run yet).
+
> **`watcher_enabled` vs `auto_watch`.** `watcher_enabled` controls whether the
> watcher *subsystem* starts at all (the background poll thread). `auto_watch` is
> narrower: it only controls whether a connecting session registers *its own*
diff --git a/docs/index.html b/docs/index.html
index fdfde941e..2add87b46 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -728,7 +728,7 @@
Auto-sync
-
A background watcher detects changes and re-indexes incrementally. No manual reindex after editing files.
+
A background watcher detects changes in the project your MCP session is working in and re-indexes it incrementally. No manual reindex after editing files there.
Team-shared graph artifact
diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h
index 63265cebc..99a519540 100644
--- a/internal/cbm/cbm.h
+++ b/internal/cbm/cbm.h
@@ -283,6 +283,10 @@ 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
+ * 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 */
/* Configured raw definitions only; zero for all legacy rows. Exact spans
* are bound to the owning result's source identity before cross-file use. */
diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c
index 1001563f5..d492b3ccf 100644
--- a/internal/cbm/extract_defs.c
+++ b/internal/cbm/extract_defs.c
@@ -7155,6 +7155,58 @@ static void extract_py_field_types(CBMExtractCtx *ctx, TSNode class_node, const
}
}
+/* Name of a C# namespace declaration node (block or file-scoped), or NULL. */
+static const char *cs_namespace_decl_name(CBMArena *a, TSNode ns, const char *source) {
+ TSNode nm = ts_node_child_by_field_name(ns, TS_FIELD("name"));
+ if (ts_node_is_null(nm)) {
+ return NULL;
+ }
+ const char *text = cbm_node_text(a, nm, source);
+ return text && text[0] ? text : NULL;
+}
+
+/* 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). */
+static const char *cs_type_decl_namespace(CBMArena *a, TSNode node, const char *source) {
+ const char *acc = NULL;
+ bool file_scoped = false;
+ TSNode top = node;
+ for (TSNode cur = ts_node_parent(node); !ts_node_is_null(cur); cur = ts_node_parent(cur)) {
+ const char *k = ts_node_type(cur);
+ bool is_file_scoped = strcmp(k, "file_scoped_namespace_declaration") == 0;
+ if (is_file_scoped || strcmp(k, "namespace_declaration") == 0) {
+ const char *name = cs_namespace_decl_name(a, cur, source);
+ if (name) {
+ acc = acc ? cbm_arena_sprintf(a, "%s.%s", name, acc) : name;
+ }
+ file_scoped = file_scoped || is_file_scoped;
+ }
+ top = cur;
+ }
+ if (file_scoped) {
+ return acc;
+ }
+ uint32_t start = ts_node_start_byte(node);
+ uint32_t n = ts_node_named_child_count(top);
+ for (uint32_t i = 0; i < n; i++) {
+ TSNode c = ts_node_named_child(top, i);
+ if (ts_node_start_byte(c) >= start) {
+ break;
+ }
+ if (strcmp(ts_node_type(c), "file_scoped_namespace_declaration") == 0) {
+ const char *name = cs_namespace_decl_name(a, c, source);
+ if (name) {
+ return acc ? cbm_arena_sprintf(a, "%s.%s", name, acc) : name;
+ }
+ }
+ }
+ return acc;
+}
+
static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) {
CBMArena *a = ctx->arena;
const char *kind = ts_node_type(node);
@@ -7555,6 +7607,9 @@ static void emit_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *s
def.end_line = ts_node_end_point(node).row + TS_LINE_OFFSET;
def.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET);
def.is_exported = cbm_is_exported(name, ctx->language);
+ if (ctx->language == CBM_LANG_CSHARP && !ctx->enclosing_class_qn) {
+ def.decl_namespace = cs_type_decl_namespace(a, node, ctx->source);
+ }
def.base_classes = extract_base_classes(a, node, ctx->source, ctx->language);
def.decorators = extract_decorators(a, node, ctx->source, ctx->language, spec);
def.docstring = extract_docstring(ctx, node, name);
diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c
index 749b126bf..c5ee1a87b 100644
--- a/internal/cbm/lsp/cs_lsp.c
+++ b/internal/cbm/lsp/cs_lsp.c
@@ -74,6 +74,8 @@ static char *cs_node_text_cached(CSLSPContext *ctx, TSNode node);
static const CBMType *cs_unwrap_task(CSLSPContext *ctx, const CBMType *t);
static const CBMType *cs_unwrap_nullable(const CBMType *t);
static const char *cs_resolve_callable_value(CSLSPContext *ctx, TSNode node, TSNode *callable_leaf);
+static const char *cs_top_level_namespace(const char *type_qn, const char *short_name,
+ const char *module_qn, const char *namespace_name);
/* ── small helpers ──────────────────────────────────────────────── */
@@ -356,6 +358,72 @@ static const char *cs_try_type_qn(CSLSPContext *ctx, const char *qn) {
return NULL;
}
+/* 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`). */
+static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const char **out,
+ int cap) {
+ int n = 0;
+ const char *ns = ctx->namespace_count > 0 ? cs_namespace_qn(ctx) : NULL;
+ while (ns && *ns && n < cap) {
+ out[n++] = qualifier ? cbm_arena_sprintf(ctx->arena, "%s.%s", ns, qualifier) : ns;
+ const char *dot = strrchr(ns, '.');
+ ns = dot ? cbm_arena_strndup(ctx->arena, ns, (size_t)(dot - ns)) : NULL;
+ }
+ if (qualifier) {
+ if (n < cap)
+ 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;
+}
+
+/* Bind a (possibly namespace-qualified) type reference to the project type
+ * whose DECLARED namespace the current file can see, preferring the earliest
+ * namespace in C# lookup order; equal ranks keep registration order. NULL when
+ * no top-level type with that simple name is visible (#2120). */
+static const char *cs_resolve_by_declared_namespace(CSLSPContext *ctx, const char *name) {
+ if (!ctx->registry || !name || !*name)
+ return NULL;
+ const char *simple = cs_short_name(name);
+ if (!simple || !*simple)
+ return NULL;
+ const char *qualifier =
+ simple > name ? cbm_arena_strndup(ctx->arena, name, (size_t)(simple - name - 1)) : NULL;
+ enum { CS_VISIBLE_NS_MAX = 128 };
+ const char *visible[CS_VISIBLE_NS_MAX];
+ int visible_count = cs_visible_namespaces(ctx, qualifier, visible, CS_VISIBLE_NS_MAX);
+ if (visible_count == 0)
+ return NULL;
+
+ const CBMRegisteredType *best = NULL;
+ int best_rank = visible_count;
+ CBMTypeShortIter it;
+ cbm_registry_types_by_short_name_chain(ctx->registry, simple, &it);
+ int i;
+ while ((i = cbm_type_short_iter_next(&it)) >= 0) {
+ const CBMRegisteredType *cand = &it.reg->types[i];
+ if (!cand->namespace_qn || !cand->short_name || strcmp(cand->short_name, simple) != 0)
+ continue;
+ for (int r = 0; r < best_rank; r++) {
+ if (strcmp(cand->namespace_qn, visible[r]) == 0) {
+ best = cand;
+ best_rank = r;
+ break;
+ }
+ }
+ }
+ return best ? best->qualified_name : NULL;
+}
+
/* Returns a fully-qualified type name resolved against:
* 1. predefined alias (int -> System.Int32)
* 2. type-parameter substitution map
@@ -365,7 +433,9 @@ static const char *cs_try_type_qn(CSLSPContext *ctx, const char *qn) {
* 6. module-prefixed (file-local QN that the unified extractor produced)
* 7. each `using namespace X` prefix
* 8. each `using A = X` alias substitution
- * 9. fall back to bare name (registry will fail; pipeline drops the call)
+ * 8b. declared namespace of a top-level project type (enclosing namespaces,
+ * then `using` namespaces)
+ * 9. short-name fallback, then the bare name (registry miss drops the call)
*/
const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) {
if (!raw || !*raw)
@@ -457,6 +527,11 @@ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) {
char *target_bare = cs_strip_generic_args(ctx->arena, u->target_qn);
if (target_bare && cs_lookup_type_qn(ctx, target_bare))
return target_bare;
+ /* A project type is registered under its path-derived QN, so
+ * `using A = Ns.Type;` binds through the declared namespace. */
+ const char *declared = cs_resolve_by_declared_namespace(ctx, target_bare);
+ if (declared)
+ return declared;
return cbm_arena_strdup(ctx->arena, u->target_qn);
}
if (bare[alias_len] == '.') {
@@ -470,6 +545,15 @@ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) {
}
}
+ /* 8b. Declared-namespace binding for project types (#2120): their QNs are
+ * path-derived, so steps 5 and 7 cannot hit them; bind by the namespace
+ * each top-level type declares, in C# lookup order. */
+ {
+ const char *declared = cs_resolve_by_declared_namespace(ctx, bare);
+ if (declared)
+ return declared;
+ }
+
/* 9. Short-name fallback: scan the registry for any type whose
* short_name equals `bare` and whose QN shares the longest namespace
* prefix with the file's namespace stack. This recovers cross-file
@@ -525,6 +609,28 @@ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) {
return bare;
}
+/* Other top-level declarations of the type `t` names: same simple name, same
+ * declared namespace, different entry. Registration order; at most `cap`. */
+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)
+ return 0;
+ int n = 0;
+ CBMTypeShortIter it;
+ cbm_registry_types_by_short_name_chain(ctx->registry, t->short_name, &it);
+ int i;
+ while (n < cap && (i = cbm_type_short_iter_next(&it)) >= 0) {
+ const CBMRegisteredType *c = &it.reg->types[i];
+ if (!c->namespace_qn || !c->short_name || !c->qualified_name ||
+ strcmp(c->short_name, t->short_name) != 0 ||
+ strcmp(c->namespace_qn, t->namespace_qn) != 0 ||
+ strcmp(c->qualified_name, t->qualified_name) == 0)
+ continue;
+ out[n++] = c;
+ }
+ return n;
+}
+
/* Look up a method on a type, walking inheritance chain. */
const CBMRegisteredFunc *cs_lookup_method(CSLSPContext *ctx, const char *type_qn,
const char *method_name) {
@@ -535,19 +641,34 @@ const CBMRegisteredFunc *cs_lookup_method(CSLSPContext *ctx, const char *type_qn
if (f)
return f;
- /* Walk inheritance chain (base + interfaces + transitive bases). */
const CBMRegisteredType *t = cs_lookup_type_qn(ctx, type_qn);
if (!t)
return NULL;
+ /* The other declarations of this same type -- `partial` pieces in other
+ * files, reference-assembly copies -- are separate registry entries
+ * (path-derived QNs) but one C# type: a member declared on any of them is
+ * a member of this one, and so is a base declared on any of them. */
+ const CBMRegisteredType *twins[CS_LSP_PARENT_WALK_MAX] = {0};
+ int twin_count = cs_namespace_twins(ctx, t, twins, CS_LSP_PARENT_WALK_MAX);
+ for (int k = 0; k < twin_count; k++) {
+ f = cbm_registry_lookup_method(ctx->registry, twins[k]->qualified_name, method_name);
+ if (f)
+ return f;
+ }
+
+ /* Walk inheritance chain (base + interfaces + transitive bases). */
const char *visited[CS_LSP_PARENT_WALK_MAX];
int visited_count = 0;
const char *frontier[CS_LSP_PARENT_WALK_MAX];
int frontier_count = 0;
- if (t->embedded_types) {
- for (int i = 0; t->embedded_types[i] && frontier_count < CS_LSP_PARENT_WALK_MAX; i++) {
- frontier[frontier_count++] = t->embedded_types[i];
+ for (int k = -1; k < twin_count; k++) {
+ const CBMRegisteredType *decl = k < 0 ? t : twins[k];
+ for (int i = 0; decl->embedded_types && decl->embedded_types[i] &&
+ frontier_count < CS_LSP_PARENT_WALK_MAX;
+ i++) {
+ frontier[frontier_count++] = decl->embedded_types[i];
}
}
@@ -3469,6 +3590,8 @@ void cbm_run_cs_lsp(CBMArena *arena, CBMFileResult *result, const char *source,
memset(&rt, 0, sizeof(rt));
rt.qualified_name = d->qualified_name;
rt.short_name = d->name;
+ rt.namespace_qn =
+ cs_top_level_namespace(d->qualified_name, d->name, module_qn, d->decl_namespace);
rt.is_interface = (strcmp(d->label, "Interface") == 0);
if (d->base_classes) {
int bc = 0;
@@ -3620,6 +3743,21 @@ void cbm_run_cs_lsp(CBMArena *arena, CBMFileResult *result, const char *source,
/* ── cross-file entry ───────────────────────────────────────────── */
+/* The type's declared `namespace_name` when `type_qn` is a TOP-LEVEL type of
+ * the file whose module QN is `module_qn` (top-level C# type QNs are exactly
+ * module.Name; nested ones are module.Outer.Name and stay NULL, since a nested
+ * 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])
+ 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;
+ return namespace_name;
+}
+
/* Register one batch of CBMLSPDef[] into a registry. Shared by the
* per-file cross-LSP path and the Tier 2 pre-built registry builder.
* Def-driven (no per-file AST mutation) so deterministic per def set. */
@@ -3636,6 +3774,8 @@ static void cs_register_lsp_defs(CBMArena *arena, CBMTypeRegistry *reg, CBMLSPDe
memset(&rt, 0, sizeof(rt));
rt.qualified_name = d->qualified_name;
rt.short_name = d->short_name;
+ rt.namespace_qn = cs_top_level_namespace(d->qualified_name, d->short_name,
+ d->def_module_qn, d->namespace_name);
rt.is_interface = d->is_interface || (strcmp(d->label, "Interface") == 0);
if (d->embedded_types && *d->embedded_types) {
/* Parse "|"-separated list. */
diff --git a/internal/cbm/lsp/type_registry.h b/internal/cbm/lsp/type_registry.h
index 065ae2585..389fc457b 100644
--- a/internal/cbm/lsp/type_registry.h
+++ b/internal/cbm/lsp/type_registry.h
@@ -67,6 +67,12 @@ typedef struct {
const CBMType *index_value_type; // V or NULL
// Generic constraints, parallel to type_param_names. NULL or shorter array means "any".
const CBMType **type_param_constraints; // NULL-terminated, parallel to type_param_names
+
+ // --- C#-specific (NULL for every other language) ---
+ // Declared namespace of a TOP-LEVEL project type. Graph QNs are path-derived,
+ // so this is the only way to bind a short name through `using` directives and
+ // enclosing namespaces (#2120). NULL for nested types and unknown namespaces.
+ const char *namespace_qn;
} CBMRegisteredType;
// Hash-table bucket entry. Chains collisions via next-index list for overload sets.
diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c
index 9691b6c96..fe4e7bece 100644
--- a/internal/cbm/result_compact.c
+++ b/internal/cbm/result_compact.c
@@ -276,6 +276,7 @@ static void cr_walk_def(cr_ctx_t *c, CBMDefinition *d) {
cr_str(c, &d->http_client);
cr_str(c, &d->http_base_url);
cr_str(c, &d->variants);
+ cr_str(c, &d->decl_namespace);
}
static void cr_walk_call(cr_ctx_t *c, CBMCall *call) {
diff --git a/scripts/ci/test-impact-publish.sh b/scripts/ci/test-impact-publish.sh
index 8e9dbeeb6..fe8f1b053 100755
--- a/scripts/ci/test-impact-publish.sh
+++ b/scripts/ci/test-impact-publish.sh
@@ -56,9 +56,19 @@ fi
map() { # out [suite...]
local out="$1"; shift
local args=()
+ local rc=0
for suite in "$@"; do args+=(--suite "$suite"); done
python3 "$ROOT/scripts/test-impact/coverage-map.py" --runner "$RUNNER" --out "$out" \
- ${LLVM_BIN:+--llvm-bin "$LLVM_BIN"} ${args[@]+"${args[@]}"}
+ ${LLVM_BIN:+--llvm-bin "$LLVM_BIN"} ${args[@]+"${args[@]}"} || rc=$?
+ # 1 = a suite failed or timed out under coverage: the map is still written
+ # and that suite's tests are `incomplete`, which only ever selects MORE
+ # tests (an incomplete test runs whole). Publish it, but say which suite.
+ # 2 = usage or toolchain error: no trustworthy map, fail.
+ if [ "$rc" -eq 1 ]; then
+ echo "::warning::coverage map: a suite failed under coverage; its tests stay incomplete ($(python3 -c 'import json,sys; print(", ".join(s["suite"] for s in json.load(open(sys.argv[1]))["suites"] if s["exit"] != 0))' "$out/meta.json"))"
+ return 0
+ fi
+ return "$rc"
}
OBSERVED=""
diff --git a/src/daemon/application.c b/src/daemon/application.c
index bdc6437d1..0dd236024 100644
--- a/src/daemon/application.c
+++ b/src/daemon/application.c
@@ -565,6 +565,57 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s
session->watch = watch;
}
+static const char *application_watch_strategy_name(cbm_watcher_strategy_t strategy) {
+ switch (strategy) {
+ case CBM_WATCHER_STRATEGY_GIT:
+ return "git";
+ case CBM_WATCHER_STRATEGY_TREE:
+ return "tree";
+ case CBM_WATCHER_STRATEGY_NONE:
+ return "none";
+ default:
+ return "pending";
+ }
+}
+
+/* index_status watch visibility (#2167). The physical watcher is the truth for
+ * "watched"; the reasons explain the documented scope: only an MCP session's
+ * own project is watched, and only while that session is open. Takes the
+ * application mutex and the watcher lock one after the other, never nested. */
+static void application_watch_status(void *context, const char *project,
+ cbm_mcp_watch_status_t *out) {
+ cbm_daemon_application_session_t *session = context;
+ cbm_daemon_application_t *application = session ? session->application : NULL;
+ if (!application || !application->watcher) {
+ out->reason = "watcher_disabled";
+ return;
+ }
+ cbm_watcher_project_info_t info;
+ if (cbm_watcher_project_info(application->watcher, project, &info)) {
+ out->watched = true;
+ out->strategy = application_watch_strategy_name(info.strategy);
+ out->poll_interval_ms = info.poll_interval_ms;
+ out->last_scan_unix_s = info.last_scan_unix_s;
+ return;
+ }
+ const char *session_project = cbm_mcp_server_session_project(session->mcp);
+ if (!session_project || strcmp(session_project, project) != 0) {
+ out->reason = "not_session_project";
+ return;
+ }
+ if (application->config &&
+ !cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) {
+ out->reason = "auto_watch_off";
+ return;
+ }
+ /* Only MCP sessions run background initialization; a one-shot CLI command
+ * never holds a watch across calls. */
+ cbm_mutex_lock(&application->mutex);
+ bool mcp_session = session->background_eligible;
+ cbm_mutex_unlock(&application->mutex);
+ out->reason = mcp_session ? "not_registered" : "cli_session";
+}
+
static void application_refresh_watch(cbm_daemon_application_session_t *session) {
if (!session || !session->application) {
return;
@@ -2798,6 +2849,7 @@ static cbm_daemon_runtime_application_session_t *application_session_open(
}
cbm_mcp_server_set_background_tasks(session->mcp, false);
cbm_mcp_server_set_config(session->mcp, application->config);
+ cbm_mcp_server_set_watch_status_provider(session->mcp, application_watch_status, session);
cbm_mcp_server_set_index_executor(session->mcp, application_index_execute, session);
cbm_mcp_server_set_index_status_provider(session->mcp, application_index_status, session);
cbm_mcp_server_set_project_mutation_guard(session->mcp, application_session_mutation_begin,
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index 12c4455f0..8be458b4c 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -750,9 +750,9 @@ static const tool_def_t TOOLS[] = {
"\"project\"]}"},
{"index_status",
- "Project readiness, counts, root, coverage gaps, and doc_links (doc-comment references: "
- "MENTIONS edges, unresolved by reason). diagnostics adds coverage rows; verbose adds Git "
- "paths. Best-effort only; verify cited paths with check_index_coverage.",
+ "Project readiness, counts, root, watch state, coverage gaps, and doc_links (doc-comment "
+ "references: MENTIONS edges, unresolved by reason). diagnostics adds coverage rows; verbose "
+ "adds Git paths. Best-effort only; verify cited paths with check_index_coverage.",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},"
"\"verbose\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Add worktree/"
"shadow Git paths for index-location debugging.\"},"
@@ -1411,7 +1411,8 @@ static const int SUPPORTED_VERSION_COUNT =
static const char MCP_SERVER_INSTRUCTIONS[] =
"Graph first: search_graph for symbols, trace_path for relationships, get_code_snippet for "
"source, query_graph for multi-hop, and get_architecture for overview. Use search_code/grep "
- "for literals or coverage gaps. Indexes auto-refresh. Check cited-path coverage; paginate.";
+ "for literals or coverage gaps. The session project's index auto-refreshes; re-index others. "
+ "Check cited-path coverage; paginate.";
static const char MCP_ANALYSIS_SERVER_INSTRUCTIONS[] =
"analysis tool profile: read-only graph work via search_graph, trace_path, "
@@ -1793,6 +1794,9 @@ struct cbm_mcp_server {
bool background_tasks; /* per-server update/auto-index work enabled */
struct cbm_watcher *watcher; /* external watcher ref (not owned) */
struct cbm_config *config; /* external config ref (not owned) */
+ /* index_status watch visibility (#2167) */
+ cbm_mcp_watch_status_fn watch_status_fn;
+ void *watch_status_context;
cbm_mcp_index_executor_fn index_executor;
void *index_executor_context;
cbm_mcp_index_status_fn index_status_provider; /* #2144; NULL outside the daemon */
@@ -1880,6 +1884,14 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w) {
}
}
+void cbm_mcp_server_set_watch_status_provider(cbm_mcp_server_t *srv, cbm_mcp_watch_status_fn fn,
+ void *context) {
+ if (srv) {
+ srv->watch_status_fn = fn;
+ srv->watch_status_context = context;
+ }
+}
+
void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) {
if (srv) {
srv->config = cfg;
@@ -7227,6 +7239,40 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args
return result;
}
+/* index_status "watch" object (#2167): is this project kept fresh by the
+ * background watcher right now, and if not, why. Auto-sync covers the active
+ * MCP session's project only; anything else needs index_repository again. */
+static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yyjson_mut_val *root,
+ const char *project) {
+ cbm_mcp_watch_status_t status = {0};
+ if (srv->watch_status_fn) {
+ srv->watch_status_fn(srv->watch_status_context, project, &status);
+ } else {
+ status.reason = "no_watcher";
+ }
+ yyjson_mut_val *watch = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_bool(doc, watch, "watched", status.watched);
+ if (status.watched) {
+ yyjson_mut_obj_add_str(doc, watch, "strategy", status.strategy ? status.strategy : "");
+ yyjson_mut_obj_add_int(doc, watch, "poll_interval_ms", status.poll_interval_ms);
+ if (status.last_scan_unix_s > 0) {
+ char when[CBM_SZ_32];
+ time_t t = (time_t)status.last_scan_unix_s;
+ struct tm tm;
+ cbm_gmtime_r(&t, &tm);
+ if (strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%SZ", &tm) > 0) {
+ yyjson_mut_obj_add_strcpy(doc, watch, "last_scan_at", when);
+ }
+ }
+ } else {
+ yyjson_mut_obj_add_str(doc, watch, "reason", status.reason ? status.reason : "unknown");
+ yyjson_mut_obj_add_str(doc, watch, "hint",
+ "Auto-sync watches only the active MCP session's project; "
+ "re-run index_repository to refresh this index.");
+ }
+ yyjson_mut_obj_add_val(doc, root, "watch", watch);
+}
+
static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
char *project = get_project_arg(args);
cbm_store_t *store = resolve_store(srv, project);
@@ -7309,6 +7355,7 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
doc, root, "hint",
"Project is empty. Re-run index_repository(repo_path=...) to populate.");
}
+ add_watch_status_json(srv, doc, root, project);
} else {
yyjson_mut_obj_add_str(doc, root, "status", "no_project");
}
diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h
index 5701067ee..3a5fce5ff 100644
--- a/src/mcp/mcp.h
+++ b/src/mcp/mcp.h
@@ -179,6 +179,22 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv);
/* Set external watcher reference (for auto-index registration). Not owned. */
void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w);
+/* Watch visibility for index_status (#2167). The daemon, which owns the one
+ * background watcher, answers whether `project` is currently watched. All
+ * strings are static. A server without a provider runs no watcher at all and
+ * reports watched=false with reason "no_watcher". */
+typedef struct {
+ bool watched;
+ const char *reason; /* why not watched (set when !watched) */
+ const char *strategy; /* "pending" | "git" | "tree" | "none" (set when watched) */
+ int poll_interval_ms; /* current adaptive cadence (when watched) */
+ int64_t last_scan_unix_s; /* last completed scan, wall clock; 0 = none yet */
+} cbm_mcp_watch_status_t;
+typedef void (*cbm_mcp_watch_status_fn)(void *context, const char *project,
+ cbm_mcp_watch_status_t *out);
+void cbm_mcp_server_set_watch_status_provider(cbm_mcp_server_t *srv, cbm_mcp_watch_status_fn fn,
+ void *context);
+
/* Set external config store reference (for auto_index setting). Not owned. */
void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg);
diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c
index 49e6e68f7..c56631ba8 100644
--- a/src/pipeline/pass_lsp_cross.c
+++ b/src/pipeline/pass_lsp_cross.c
@@ -441,6 +441,12 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch
dst->label = label;
dst->def_module_qn = module_qn;
dst->namespace_name = namespace_name ? cbm_arena_strdup(arena, namespace_name) : NULL;
+ /* C# types carry their OWN declared namespace: the file-level one is only
+ * the file's first namespace, wrong for every later namespace (#2120). */
+ if (lang == CBM_LANG_CSHARP && cbm_label_is_type_like(src->label)) {
+ dst->namespace_name =
+ src->decl_namespace ? cbm_arena_strdup(arena, src->decl_namespace) : NULL;
+ }
dst->is_interface = (strcmp(label, "Interface") == 0 || strcmp(label, "Protocol") == 0);
/* Single return-type string. The per-language registrars split on '|'
* for multi-return languages (Go); single-return languages just see one
diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c
index dea2d7a9d..feccd1920 100644
--- a/src/pipeline/pipeline.c
+++ b/src/pipeline/pipeline.c
@@ -1895,7 +1895,8 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c
}
str_def_fp += def->fingerprint ? (size_t)def->fingerprint_k * sizeof(uint32_t) : 0;
str_def_misc += census_len(def->route_path) + census_len(def->route_method) +
- census_len(def->impl_trait) + census_len(def->http_base_url);
+ census_len(def->impl_trait) + census_len(def->http_base_url) +
+ census_len(def->decl_namespace);
}
for (int c = 0; c < r->calls.count; c++) {
const CBMCall *call = &r->calls.items[c];
diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c
index a83cab31a..efb46ac72 100644
--- a/src/watcher/watcher.c
+++ b/src/watcher/watcher.c
@@ -99,6 +99,12 @@ typedef struct {
* from `rev-parse --show-cdup`. Porcelain paths are repository-relative, so
* the signature needs this to stat them. Resolved once at baseline. */
char repo_cdup[CBM_SZ_4K];
+ /* Published status for cbm_watcher_project_info (#2167). The poll path
+ * writes these while index_status reads them from a session thread, so
+ * they are atomics: the diagnostic read is never a data race. */
+ atomic_int status_strategy; /* cbm_watcher_strategy_t */
+ atomic_int status_interval_ms; /* mirrors interval_ms */
+ _Atomic int64_t status_last_scan_s; /* wall-clock seconds; 0 = none yet */
} project_state_t;
/* ── Watcher struct ─────────────────────────────────────────────── */
@@ -922,6 +928,9 @@ static project_state_t *state_new(const char *name, const char *root_path) {
}
atomic_init(&s->registered, true);
s->interval_ms = POLL_BASE_MS;
+ atomic_init(&s->status_strategy, CBM_WATCHER_STRATEGY_PENDING);
+ atomic_init(&s->status_interval_ms, POLL_BASE_MS);
+ atomic_init(&s->status_last_scan_s, 0);
return s;
}
@@ -1262,6 +1271,25 @@ int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name)
return failures;
}
+bool cbm_watcher_project_info(cbm_watcher_t *w, const char *project_name,
+ cbm_watcher_project_info_t *out) {
+ if (out) {
+ memset(out, 0, sizeof(*out));
+ }
+ if (!w || !project_name || !out) {
+ return false;
+ }
+ cbm_mutex_lock(&w->projects_lock);
+ project_state_t *s = cbm_ht_get(w->projects, project_name);
+ if (s) {
+ out->strategy = (cbm_watcher_strategy_t)atomic_load(&s->status_strategy);
+ out->poll_interval_ms = atomic_load(&s->status_interval_ms);
+ out->last_scan_unix_s = atomic_load(&s->status_last_scan_s);
+ }
+ cbm_mutex_unlock(&w->projects_lock);
+ return s != NULL;
+}
+
int cbm_watcher_watch_count(cbm_watcher_t *w) {
if (!w) {
return 0;
@@ -1274,6 +1302,23 @@ int cbm_watcher_watch_count(cbm_watcher_t *w) {
/* ── Single poll cycle ──────────────────────────────────────────── */
+/* Publish what index_status reports: the strategy, the current cadence and,
+ * when a check just completed, the wall-clock time of that scan. A non-git
+ * root is "tree" when watch_non_git polls it (#1948), else "none". */
+static void publish_status(project_state_t *s, bool scanned) {
+ cbm_watcher_strategy_t strategy = CBM_WATCHER_STRATEGY_NONE;
+ if (s->is_git) {
+ strategy = CBM_WATCHER_STRATEGY_GIT;
+ } else if (s->tree_poll) {
+ strategy = CBM_WATCHER_STRATEGY_TREE;
+ }
+ atomic_store(&s->status_strategy, strategy);
+ atomic_store(&s->status_interval_ms, s->interval_ms);
+ if (scanned) {
+ atomic_store(&s->status_last_scan_s, (int64_t)time(NULL));
+ }
+}
+
/* Init baseline for a project: check if git, get HEAD, count files */
static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
struct stat st;
@@ -1281,6 +1326,7 @@ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path);
s->baseline_done = true;
s->is_git = false;
+ publish_status(s, false);
return true;
}
@@ -1358,6 +1404,7 @@ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
}
s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS);
+ publish_status(s, true);
return true;
}
@@ -1524,6 +1571,7 @@ static void commit_baselines(cbm_watcher_t *w, project_state_t *s) {
if (git_file_count(w, s, &file_count) == WATCHER_GIT_OK) {
s->file_count = file_count;
s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count);
+ publish_status(s, false);
}
}
@@ -1595,6 +1643,7 @@ static void poll_project(const char *key, void *val, void *ud) {
if (!check_changes(ctx->w, s, &changed)) {
return;
}
+ publish_status(s, true);
if (!changed) {
s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * US_PER_MS);
return;
diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h
index 6e8410138..0959853ae 100644
--- a/src/watcher/watcher.h
+++ b/src/watcher/watcher.h
@@ -130,6 +130,26 @@ int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name);
int cbm_watcher_test_pending_free_count(cbm_watcher_t *w);
#endif
+/* Per-project watch status, for index_status (#2167). */
+typedef enum {
+ CBM_WATCHER_STRATEGY_PENDING = 0, /* registered; first poll not done yet */
+ CBM_WATCHER_STRATEGY_GIT = 1, /* git HEAD + dirty-state polling */
+ CBM_WATCHER_STRATEGY_NONE = 2, /* not a git project: never polled */
+ CBM_WATCHER_STRATEGY_TREE = 3, /* not a git project: file-tree polling (watch_non_git) */
+} cbm_watcher_strategy_t;
+
+typedef struct {
+ cbm_watcher_strategy_t strategy;
+ int poll_interval_ms; /* current adaptive cadence */
+ int64_t last_scan_unix_s; /* wall clock of the last completed scan; 0 = none */
+} cbm_watcher_project_info_t;
+
+/* Return true when project_name is physically watched and fill *out with its
+ * published status; false (and *out zeroed) otherwise. Safe to call while the
+ * poll loop runs: the fields are published atomically by the poll path. */
+bool cbm_watcher_project_info(cbm_watcher_t *w, const char *project_name,
+ cbm_watcher_project_info_t *out);
+
/* Return the adaptive poll interval (ms) for a given file count. */
int cbm_watcher_poll_interval_ms(int file_count);
diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c
index 3b97b3f65..1f77e1e58 100644
--- a/tests/test_daemon_application.c
+++ b/tests/test_daemon_application.c
@@ -1102,6 +1102,289 @@ TEST(daemon_application_reference_counts_one_shared_watch) {
PASS();
}
+/* #2167: index_status must say whether a project is kept fresh by the
+ * watcher. Only the MCP session's own project is watched; any other indexed
+ * project reports watched=false with the reason, instead of silently going
+ * stale (the reporter indexed via the CLI and waited for auto-sync). */
+static bool app_test_seed_project_db(const char *cache, const char *project, const char *root,
+ char *db_path, size_t db_path_size) {
+ snprintf(db_path, db_path_size, "%s/%s.db", cache, project);
+ cbm_store_t *seed = cbm_store_open_path(db_path);
+ bool seeded = seed && cbm_store_upsert_project(seed, project, root) == CBM_STORE_OK;
+ if (seed) {
+ cbm_store_close(seed);
+ }
+ return seeded;
+}
+
+static void app_test_unlink_project_db(const char *db_path) {
+ char sidecar[APP_TEST_PATH_CAP];
+ if (!db_path[0]) {
+ return;
+ }
+ (void)cbm_unlink(db_path);
+ snprintf(sidecar, sizeof(sidecar), "%s-wal", db_path);
+ (void)cbm_unlink(sidecar);
+ snprintf(sidecar, sizeof(sidecar), "%s-shm", db_path);
+ (void)cbm_unlink(sidecar);
+}
+
+/* The tool result embeds the JSON document as escaped text: match
+ * \"key\":value inside it. */
+static bool app_test_response_has(const char *response, const char *key, const char *value) {
+ char needle[APP_TEST_PATH_CAP];
+ snprintf(needle, sizeof(needle), "\\\"%s\\\":%s", key, value);
+ return response && strstr(response, needle) != NULL;
+}
+
+static char *app_test_index_status(const cbm_daemon_runtime_application_callbacks_t *callbacks,
+ cbm_daemon_runtime_application_session_t *session,
+ const char *project) {
+ char args[APP_TEST_PATH_CAP];
+ snprintf(args, sizeof(args), "{\"project\":\"%s\",\"format\":\"json\"}", project);
+ uint8_t *tool = NULL;
+ uint32_t tool_length = 0;
+ uint8_t *response = NULL;
+ uint32_t response_length = 0;
+ if (!app_test_tool_request("index_status", args, &tool, &tool_length) ||
+ app_test_request(callbacks, session, tool, tool_length, &response, &response_length) !=
+ CBM_DAEMON_RUNTIME_APPLICATION_OK) {
+ free(response);
+ response = NULL;
+ }
+ free(tool);
+ return (char *)response;
+}
+
+TEST(daemon_application_index_status_reports_watch_issue2167) {
+ const char *old_cache = getenv("CBM_CACHE_DIR");
+ bool had_cache = old_cache != NULL;
+ char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
+ char root[APP_TEST_PATH_CAP];
+ char other_root[APP_TEST_PATH_CAP];
+ char cache[APP_TEST_PATH_CAP];
+ snprintf(root, sizeof(root), "%s/cbm-app-status-root-XXXXXX", cbm_tmpdir());
+ snprintf(other_root, sizeof(other_root), "%s/cbm-app-status-other-XXXXXX", cbm_tmpdir());
+ snprintf(cache, sizeof(cache), "%s/cbm-app-status-cache-XXXXXX", cbm_tmpdir());
+ bool dirs_ok =
+ cbm_mkdtemp(root) != NULL && cbm_mkdtemp(other_root) != NULL && cbm_mkdtemp(cache) != NULL;
+ bool env_ok = (!had_cache || saved_cache) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0;
+ char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL;
+ char *other_project = dirs_ok ? cbm_project_name_from_path(other_root) : NULL;
+ char db_path[APP_TEST_PATH_CAP] = {0};
+ char other_db_path[APP_TEST_PATH_CAP] = {0};
+ bool seeded = project && other_project &&
+ app_test_seed_project_db(cache, project, root, db_path, sizeof(db_path)) &&
+ app_test_seed_project_db(cache, other_project, other_root, other_db_path,
+ sizeof(other_db_path));
+ cbm_store_t *store = cbm_store_open_memory();
+ cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL);
+ cbm_daemon_application_config_t config = {.watcher = watcher, .config = NULL};
+ cbm_daemon_application_t *application = cbm_daemon_application_new(&config);
+ cbm_daemon_runtime_application_callbacks_t callbacks =
+ cbm_daemon_application_runtime_callbacks(application);
+ cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 21);
+ uint8_t *context = NULL;
+ uint32_t context_length = 0;
+ uint8_t *ping = NULL;
+ uint32_t ping_length = 0;
+ uint8_t *response = NULL;
+ uint32_t response_length = 0;
+ /* Context + one MCP request registers the session root's watch. */
+ bool registered = seeded && app_test_context_request(root, root, &context, &context_length) &&
+ app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP,
+ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}",
+ &ping, &ping_length) &&
+ app_test_request(&callbacks, session, context, context_length, &response,
+ &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK;
+ free(response);
+ response = NULL;
+ registered =
+ registered && app_test_request(&callbacks, session, ping, ping_length, &response,
+ &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK;
+ free(response);
+ response = NULL;
+ registered = registered && cbm_watcher_watch_count(watcher) == 1;
+
+ char *own = registered ? app_test_index_status(&callbacks, session, project) : NULL;
+ char *other = registered ? app_test_index_status(&callbacks, session, other_project) : NULL;
+ bool own_watched = app_test_response_has(own, "watched", "true") &&
+ app_test_response_has(own, "strategy", "\\\"pending\\\"") &&
+ app_test_response_has(own, "poll_interval_ms", "5000");
+ bool other_unwatched = app_test_response_has(other, "watched", "false") &&
+ app_test_response_has(other, "reason", "\\\"not_session_project\\\"");
+
+ callbacks.session_cancel(callbacks.context, session);
+ callbacks.session_close(callbacks.context, session);
+ cbm_daemon_application_free(application);
+ cbm_watcher_stop(watcher);
+ cbm_watcher_free(watcher);
+ cbm_store_close(store);
+ free(own);
+ free(other);
+ free(context);
+ free(ping);
+ free(project);
+ free(other_project);
+ app_test_unlink_project_db(db_path);
+ app_test_unlink_project_db(other_db_path);
+ (void)cbm_rmdir(root);
+ (void)cbm_rmdir(other_root);
+ (void)cbm_rmdir(cache);
+ if (saved_cache) {
+ (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1);
+ } else if (!had_cache) {
+ (void)cbm_unsetenv("CBM_CACHE_DIR");
+ }
+ free(saved_cache);
+
+ ASSERT_TRUE(env_ok);
+ ASSERT_TRUE(seeded);
+ ASSERT_TRUE(registered);
+ ASSERT_TRUE(own_watched);
+ ASSERT_TRUE(other_unwatched);
+ PASS();
+}
+
+/* #2167: watcher_enabled=false leaves the daemon without a watcher; the
+ * session's own project must then say so rather than look watched. */
+TEST(daemon_application_index_status_watcher_disabled_issue2167) {
+ const char *old_cache = getenv("CBM_CACHE_DIR");
+ bool had_cache = old_cache != NULL;
+ char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
+ char root[APP_TEST_PATH_CAP];
+ char cache[APP_TEST_PATH_CAP];
+ snprintf(root, sizeof(root), "%s/cbm-app-nowatch-root-XXXXXX", cbm_tmpdir());
+ snprintf(cache, sizeof(cache), "%s/cbm-app-nowatch-cache-XXXXXX", cbm_tmpdir());
+ bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL;
+ bool env_ok = (!had_cache || saved_cache) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0;
+ char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL;
+ char db_path[APP_TEST_PATH_CAP] = {0};
+ bool seeded =
+ project && app_test_seed_project_db(cache, project, root, db_path, sizeof(db_path));
+ cbm_daemon_application_config_t config = {.watcher = NULL, .config = NULL};
+ cbm_daemon_application_t *application = cbm_daemon_application_new(&config);
+ cbm_daemon_runtime_application_callbacks_t callbacks =
+ cbm_daemon_application_runtime_callbacks(application);
+ cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 22);
+ uint8_t *context = NULL;
+ uint32_t context_length = 0;
+ uint8_t *response = NULL;
+ uint32_t response_length = 0;
+ bool context_ok = seeded && app_test_context_request(root, root, &context, &context_length) &&
+ app_test_request(&callbacks, session, context, context_length, &response,
+ &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK;
+ free(response);
+ char *status = context_ok ? app_test_index_status(&callbacks, session, project) : NULL;
+ bool disabled = app_test_response_has(status, "watched", "false") &&
+ app_test_response_has(status, "reason", "\\\"watcher_disabled\\\"");
+
+ callbacks.session_cancel(callbacks.context, session);
+ callbacks.session_close(callbacks.context, session);
+ cbm_daemon_application_free(application);
+ free(status);
+ free(context);
+ free(project);
+ app_test_unlink_project_db(db_path);
+ (void)cbm_rmdir(root);
+ (void)cbm_rmdir(cache);
+ if (saved_cache) {
+ (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1);
+ } else if (!had_cache) {
+ (void)cbm_unsetenv("CBM_CACHE_DIR");
+ }
+ free(saved_cache);
+
+ ASSERT_TRUE(env_ok);
+ ASSERT_TRUE(context_ok);
+ ASSERT_TRUE(disabled);
+ PASS();
+}
+
+/* #2167 with #1948: a non-git root that watch_non_git polls by file-tree scan
+ * reports strategy "tree". "none" stays reserved for a registered root that is
+ * never polled (a plain directory without the opt-in). */
+TEST(daemon_application_index_status_reports_tree_strategy_issue2167) {
+ const char *old_cache = getenv("CBM_CACHE_DIR");
+ bool had_cache = old_cache != NULL;
+ char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
+ char root[APP_TEST_PATH_CAP];
+ char cache[APP_TEST_PATH_CAP];
+ snprintf(root, sizeof(root), "%s/cbm-app-tree-root-XXXXXX", cbm_tmpdir());
+ snprintf(cache, sizeof(cache), "%s/cbm-app-tree-cache-XXXXXX", cbm_tmpdir());
+ bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL;
+ bool env_ok = (!had_cache || saved_cache) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0;
+ char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL;
+ char db_path[APP_TEST_PATH_CAP] = {0};
+ bool seeded =
+ project && app_test_seed_project_db(cache, project, root, db_path, sizeof(db_path));
+ cbm_store_t *store = cbm_store_open_memory();
+ cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL);
+ /* What the daemon host does at startup when watch_non_git is true. */
+ cbm_watcher_set_poll_non_git(watcher, true);
+ cbm_daemon_application_config_t config = {.watcher = watcher, .config = NULL};
+ cbm_daemon_application_t *application = cbm_daemon_application_new(&config);
+ cbm_daemon_runtime_application_callbacks_t callbacks =
+ cbm_daemon_application_runtime_callbacks(application);
+ cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 23);
+ uint8_t *context = NULL;
+ uint32_t context_length = 0;
+ uint8_t *ping = NULL;
+ uint32_t ping_length = 0;
+ uint8_t *response = NULL;
+ uint32_t response_length = 0;
+ /* Context + one MCP request registers the session root's watch. */
+ bool registered = seeded && app_test_context_request(root, root, &context, &context_length) &&
+ app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP,
+ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}",
+ &ping, &ping_length) &&
+ app_test_request(&callbacks, session, context, context_length, &response,
+ &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK;
+ free(response);
+ response = NULL;
+ registered =
+ registered && app_test_request(&callbacks, session, ping, ping_length, &response,
+ &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK;
+ free(response);
+ response = NULL;
+ registered = registered && cbm_watcher_watch_count(watcher) == 1;
+ /* The first poll completes the baseline: the plain directory gets tree
+ * polling and its strategy is published. */
+ if (registered) {
+ (void)cbm_watcher_poll_once(watcher);
+ }
+
+ char *status = registered ? app_test_index_status(&callbacks, session, project) : NULL;
+ bool tree = app_test_response_has(status, "watched", "true") &&
+ app_test_response_has(status, "strategy", "\\\"tree\\\"");
+
+ callbacks.session_cancel(callbacks.context, session);
+ callbacks.session_close(callbacks.context, session);
+ cbm_daemon_application_free(application);
+ cbm_watcher_stop(watcher);
+ cbm_watcher_free(watcher);
+ cbm_store_close(store);
+ free(status);
+ free(context);
+ free(ping);
+ free(project);
+ app_test_unlink_project_db(db_path);
+ (void)cbm_rmdir(root);
+ (void)cbm_rmdir(cache);
+ if (saved_cache) {
+ (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1);
+ } else if (!had_cache) {
+ (void)cbm_unsetenv("CBM_CACHE_DIR");
+ }
+ free(saved_cache);
+
+ ASSERT_TRUE(env_ok);
+ ASSERT_TRUE(seeded);
+ ASSERT_TRUE(registered);
+ ASSERT_TRUE(tree);
+ PASS();
+}
+
TEST(daemon_application_free_releases_live_watch_once) {
const char *old_cache = getenv("CBM_CACHE_DIR");
bool had_cache = old_cache != NULL;
@@ -6504,6 +6787,9 @@ SUITE(daemon_application) {
RUN_TEST(daemon_application_hook_context_preserves_event_and_dialect);
RUN_TEST(daemon_application_mcp_notification_has_no_response);
RUN_TEST(daemon_application_reference_counts_one_shared_watch);
+ RUN_TEST(daemon_application_index_status_reports_watch_issue2167);
+ RUN_TEST(daemon_application_index_status_watcher_disabled_issue2167);
+ RUN_TEST(daemon_application_index_status_reports_tree_strategy_issue2167);
RUN_TEST(daemon_application_free_releases_live_watch_once);
RUN_TEST(daemon_application_prune_clears_logical_watch_for_reregistration);
RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions);
diff --git a/tests/test_parallel.c b/tests/test_parallel.c
index 5a332878e..2cf6038f0 100644
--- a/tests/test_parallel.c
+++ b/tests/test_parallel.c
@@ -304,6 +304,8 @@ static cbm_gbuf_t *run_sequential_with_lsp_cross(const char *project, const char
* run_parallel_pipeline does) and records how many results the store parked. */
static bool g_harness_spill = false;
static int64_t g_harness_parked = -1;
+/* Opt-in: hand cbm_parallel_resolve the production Tier-2 C# registry. */
+static bool g_harness_cs_registry = false;
static cbm_gbuf_t *run_parallel_with_extract_opts_and_mutator(
const char *project, const char *repo_path, cbm_file_info_t *files, int file_count,
@@ -375,9 +377,17 @@ static cbm_gbuf_t *run_parallel_with_extract_opts_and_mutator(
CBMModuleDefIndex *module_def_index =
all_defs ? cbm_pxc_build_module_def_index(all_defs, def_count) : NULL;
+ /* C# has no per-file cross path (cbm_pxc_run_one has no C# case): production
+ * resolves it only through the Tier-2 registry, so a harness run that opts
+ * in builds that one registry exactly as run_parallel_pipeline does. */
+ CBMCrossLspRegistries harness_registries = {0};
+ if (g_harness_cs_registry && all_defs) {
+ harness_registries.cs = cbm_cs_build_cross_registry(&cross_arena, all_defs, def_count);
+ }
cbm_parallel_resolve(&ctx, files, file_count, result_cache, &shared_ids, worker_count, all_defs,
def_count, def_modules, module_def_index,
- NULL /* cross_registries — tests use per-file path */);
+ g_harness_cs_registry ? &harness_registries
+ : NULL /* tests use per-file path */);
cbm_gbuf_set_next_id(gbuf, atomic_load(&shared_ids));
cbm_pxc_free_module_def_index(module_def_index);
@@ -2473,6 +2483,191 @@ static kotlin_implicit_probe_t probe_kotlin_implicit_site(const CBMFileResult *r
return probe;
}
+/* #2120: two C# projects declare a class with the same simple name in
+ * namespaces that share a root (Contoso.Platform.Identity.UserIdentityInfo vs
+ * Contoso.Platform.BackOffice.Identity.UserIdentityInfo). Graph QNs are
+ * path-derived, so the namespace-prefix lookups never hit a project type and
+ * the short-name fallback scored both variants 0 against the file namespace:
+ * registration order picked the target, ignoring the caller's `using`
+ * directive. Each caller below can see exactly ONE variant (C# binding rules),
+ * so exactly one edge per caller is correct whatever the file order: plain
+ * `using` (both directions), enclosing-namespace proximity, and a
+ * fully-qualified name. The last case is a reference-assembly shape: one file
+ * declaring several namespaces, whose file-level namespace is only the FIRST
+ * one; the decoy is registered first, so a mislabeled namespace falls back to
+ * it. `partial` pieces of one type are separate registry entries, so a member
+ * declared on another piece must still bind to that piece. */
+TEST(parallel_csharp_same_name_class_binds_by_namespace) {
+ static const char platform_src[] =
+ "namespace Contoso.Platform.Identity;\n"
+ "public class UserIdentityInfo {\n"
+ " public static UserIdentityInfo ParseFromClaim(string c)\n"
+ " => new UserIdentityInfo();\n"
+ "}\n";
+ static const char backoffice_src[] =
+ "namespace Contoso.Platform.BackOffice.Identity;\n"
+ "public class UserIdentityInfo {\n"
+ " public static UserIdentityInfo ParseFromClaim(string c)\n"
+ " => new UserIdentityInfo();\n"
+ "}\n";
+ static const char bo_handler_src[] =
+ "using Contoso.Platform.BackOffice.Identity;\n"
+ "namespace Contoso.Platform.BackOffice.CqrsHandlers;\n"
+ "public class Handler {\n"
+ " public string Handle(string claim) {\n"
+ " var info = UserIdentityInfo.ParseFromClaim(claim);\n"
+ " return info.ToString();\n"
+ " }\n"
+ "}\n";
+ static const char pf_handler_src[] =
+ "using Contoso.Platform.Identity;\n"
+ "namespace Contoso.Platform.Api;\n"
+ "public class PlatformHandler {\n"
+ " public string Handle(string claim) {\n"
+ " var info = UserIdentityInfo.ParseFromClaim(claim);\n"
+ " return info.ToString();\n"
+ " }\n"
+ "}\n";
+ static const char nested_ns_src[] =
+ "namespace Contoso.Platform.BackOffice.Identity.Claims\n"
+ "{\n"
+ " public class ClaimReader {\n"
+ " public string Read(string claim) {\n"
+ " var info = UserIdentityInfo.ParseFromClaim(claim);\n"
+ " return info.ToString();\n"
+ " }\n"
+ " }\n"
+ "}\n";
+ static const char fq_src[] = "namespace Contoso.Platform.Reports;\n"
+ "public class Report {\n"
+ " public string Build(string claim) {\n"
+ " var info = Contoso.Platform.BackOffice.Identity"
+ ".UserIdentityInfo.ParseFromClaim(claim);\n"
+ " return info.ToString();\n"
+ " }\n"
+ "}\n";
+ static const char decoy_src[] = "namespace Contoso.Decoy;\n"
+ "public class Worker {\n"
+ " public void Start() { }\n"
+ " public void Beta() { }\n"
+ "}\n";
+ static const char multi_ns_src[] = "namespace Contoso.Win32 { public class SafeHandle { } }\n"
+ "namespace Contoso.Threading {\n"
+ " public class Worker { public void Start() { } }\n"
+ "}\n";
+ static const char runner_src[] = "using Contoso.Threading;\n"
+ "namespace Contoso.App;\n"
+ "public class Runner {\n"
+ " public void Run() {\n"
+ " var w = new Worker();\n"
+ " w.Start();\n"
+ " }\n"
+ "}\n";
+ static const char part_a_src[] = "namespace Contoso.Parts;\n"
+ "public partial class Thing { public void Alpha() { } }\n";
+ static const char part_b_src[] = "namespace Contoso.Parts;\n"
+ "public partial class Thing { public void Beta() { } }\n";
+ static const char part_user_src[] = "using Contoso.Parts;\n"
+ "namespace Contoso.App;\n"
+ "public class PartUser {\n"
+ " public void Use() {\n"
+ " var t = new Thing();\n"
+ " t.Alpha();\n"
+ " t.Beta();\n"
+ " }\n"
+ "}\n";
+ const char *project = "cbm_cs_same_name";
+ static const char *rels[] = {
+ "src/Decoy/Worker.cs",
+ "src/Contoso.Platform/Identity/UserIdentityInfo.cs",
+ "src/Contoso.Platform.BackOffice/Identity/UserIdentityInfo.cs",
+ "src/Contoso.Platform.BackOffice/CqrsHandlers/Handler.cs",
+ "src/Contoso.Platform/Api/PlatformHandler.cs",
+ "src/Contoso.Platform.BackOffice/Identity/Claims/ClaimReader.cs",
+ "src/Contoso.Platform/Reports/Report.cs",
+ "src/Ref/Threading.cs",
+ "src/App/Runner.cs",
+ "src/Parts/Thing.A.cs",
+ "src/Parts/Thing.B.cs",
+ "src/App/PartUser.cs",
+ };
+ const char *srcs[] = {decoy_src, platform_src, backoffice_src, bo_handler_src,
+ pf_handler_src, nested_ns_src, fq_src, multi_ns_src,
+ runner_src, part_a_src, part_b_src, part_user_src};
+ enum { N_FILES = 12, N_CHECKS = 13 };
+
+ char tmpdir[256];
+ snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_same_name_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# same-name 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 = "cbm_cs_same_name.src.Contoso.Platform.Identity.UserIdentityInfo."
+ "UserIdentityInfo.ParseFromClaim";
+ const char *bo = "cbm_cs_same_name.src.Contoso.Platform.BackOffice.Identity."
+ "UserIdentityInfo.UserIdentityInfo.ParseFromClaim";
+ const char *ref_start = "cbm_cs_same_name.src.Ref.Threading.Worker.Start";
+ const char *decoy_start = "cbm_cs_same_name.src.Decoy.Worker.Worker.Start";
+ const char *part_alpha = "cbm_cs_same_name.src.Parts.Thing.A.Thing.Alpha";
+ const char *part_beta = "cbm_cs_same_name.src.Parts.Thing.B.Thing.Beta";
+ const char *decoy_beta = "cbm_cs_same_name.src.Decoy.Worker.Worker.Beta";
+ 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, "Handler.Handle", bo, "CALLS");
+ ok[g][1] = !has_edge_from_callable_to_qn(gb, "Handler.Handle", pf, "CALLS");
+ ok[g][2] = has_edge_from_callable_to_qn(gb, "PlatformHandler.Handle", pf, "CALLS");
+ ok[g][3] = !has_edge_from_callable_to_qn(gb, "PlatformHandler.Handle", bo, "CALLS");
+ ok[g][4] = has_edge_from_callable_to_qn(gb, "ClaimReader.Read", bo, "CALLS");
+ ok[g][5] = !has_edge_from_callable_to_qn(gb, "ClaimReader.Read", pf, "CALLS");
+ ok[g][6] = has_edge_from_callable_to_qn(gb, "Report.Build", bo, "CALLS");
+ ok[g][7] = !has_edge_from_callable_to_qn(gb, "Report.Build", pf, "CALLS");
+ ok[g][8] = has_edge_from_callable_to_qn(gb, "Runner.Run", ref_start, "CALLS");
+ ok[g][9] = !has_edge_from_callable_to_qn(gb, "Runner.Run", decoy_start, "CALLS");
+ ok[g][10] = has_edge_from_callable_to_qn(gb, "PartUser.Use", part_alpha, "CALLS");
+ ok[g][11] = has_edge_from_callable_to_qn(gb, "PartUser.Use", part_beta, "CALLS");
+ ok[g][12] = !has_edge_from_callable_to_qn(gb, "PartUser.Use", decoy_beta, "CALLS");
+ for (int k = 0; k < N_CHECKS; k++) {
+ all = all && ok[g][k];
+ }
+ }
+ if (!all) {
+ for (int g = 0; g < 2; g++) {
+ printf(" C# same-name diagnostic %s: using_bo=%d/%d using_pf=%d/%d "
+ "enclosing_ns=%d/%d fully_qualified=%d/%d multi_ns_file=%d/%d "
+ "partial=%d/%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],
+ ok[g][12]);
+ }
+ }
+ 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;
@@ -4590,6 +4785,7 @@ SUITE(parallel) {
RUN_TEST(parallel_typescript_module_value_usage_respects_lexical_shadows);
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_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);
diff --git a/tests/test_test_impact_git.c b/tests/test_test_impact_git.c
index 85b26951d..0964429b6 100644
--- a/tests/test_test_impact_git.c
+++ b/tests/test_test_impact_git.c
@@ -64,9 +64,14 @@ int tf_maybe_run_git_facts_diff_probe(int argc, char **argv) {
* Re-enter the existing exact-diff test; the parent process remains untouched.
* An inherited CBM_TEST_ONLY_FILE (a narrowed CI run) is cleared: the runner
* unions it with CBM_TEST_ONLY, and one naming this suite would re-run the
- * spawning test, which spawns again. */
+ * spawning test, which spawns again. An inherited CBM_TEST_COVERAGE_DIR (a
+ * coverage-map run) is cleared too: with it the child would start a coverage
+ * run of its own, which refuses the per-test LLVM_PROFILE_FILE it inherits.
+ * Without it the child's profile goes to that file, credited to the spawning
+ * test like any other child process. */
if (cbm_setenv("GIT_DIFF_OPTS", "--unified=999", 1) != 0 ||
cbm_setenv("CBM_TEST_ONLY_FILE", "", 1) != 0 ||
+ cbm_unsetenv("CBM_TEST_COVERAGE_DIR") != 0 ||
cbm_setenv(
"CBM_TEST_ONLY",
"test_impact_git:test_git_facts_diff_uses_merge_base_zero_context_and_nul_metadata",
diff --git a/tests/test_watcher.c b/tests/test_watcher.c
index 91b4568c9..20a6cad34 100644
--- a/tests/test_watcher.c
+++ b/tests/test_watcher.c
@@ -2181,6 +2181,44 @@ TEST(watcher_index_failure_count_unknown_project) {
PASS();
}
+/* #2167: index_status reads the watch state through cbm_watcher_project_info.
+ * A registered project starts "pending" at the base cadence with no scan;
+ * the first poll completes its baseline and publishes the strategy plus a
+ * wall-clock scan time. Unwatched projects report false with *out zeroed. */
+TEST(watcher_project_info_publishes_scan_issue2167) {
+ char root[256];
+ snprintf(root, sizeof(root), "%s/cbm_watcher_info_XXXXXX", cbm_tmpdir());
+ ASSERT_NOT_NULL(cbm_mkdtemp(root));
+ cbm_store_t *store = cbm_store_open_memory();
+ cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL);
+
+ cbm_watcher_project_info_t info;
+ ASSERT_FALSE(cbm_watcher_project_info(w, "never-watched", &info));
+ ASSERT_EQ(info.poll_interval_ms, 0);
+ ASSERT_FALSE(cbm_watcher_project_info(NULL, "x", &info));
+
+ ASSERT_TRUE(cbm_watcher_watch(w, "info-proj", root));
+ ASSERT_TRUE(cbm_watcher_project_info(w, "info-proj", &info));
+ ASSERT_EQ(info.strategy, CBM_WATCHER_STRATEGY_PENDING);
+ ASSERT_EQ(info.poll_interval_ms, cbm_watcher_poll_interval_ms(0));
+ ASSERT_EQ(info.last_scan_unix_s, 0);
+
+ /* A plain directory (no git): the baseline decides it is never polled. */
+ int64_t before = (int64_t)time(NULL);
+ (void)cbm_watcher_poll_once(w);
+ ASSERT_TRUE(cbm_watcher_project_info(w, "info-proj", &info));
+ ASSERT_EQ(info.strategy, CBM_WATCHER_STRATEGY_NONE);
+ ASSERT_TRUE(info.last_scan_unix_s >= before);
+
+ cbm_watcher_unwatch(w, "info-proj");
+ ASSERT_FALSE(cbm_watcher_project_info(w, "info-proj", &info));
+
+ cbm_watcher_free(w);
+ cbm_store_close(store);
+ (void)cbm_rmdir(root);
+ PASS();
+}
+
TEST(watcher_multiple_projects) {
/* Create two temporary git repos */
char tmpdirA[256];
@@ -3872,6 +3910,7 @@ SUITE(watcher) {
RUN_TEST(watcher_index_failure_backoff_gates_repolling_issue2015);
RUN_TEST(watcher_sustained_failure_logs_once_issue2015);
RUN_TEST(watcher_index_failure_count_unknown_project);
+ RUN_TEST(watcher_project_info_publishes_scan_issue2167);
/* Lifecycle */
RUN_TEST(watcher_create_free);