mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge origin/main into fix/swift-init-extraction
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
commit
6d5cb97b6f
31 files changed
+2394
-22
No files matched your search
@@ -318,7 +318,7 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -535,7 +535,7 @@ jobs:
|
||||
|
||||
# ── PyPI ─────────────────────────────────────────────────
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -415,6 +415,7 @@ EXTRACTION_SRCS = \
|
||||
$(CBM_DIR)/diagram.c \
|
||||
$(CBM_DIR)/route_path.c \
|
||||
$(CBM_DIR)/openapi.c \
|
||||
$(CBM_DIR)/extract_nextjs.c \
|
||||
$(CBM_DIR)/sql_values.c \
|
||||
$(CBM_DIR)/helpers.c \
|
||||
$(CBM_DIR)/cpp_branch_views.c \
|
||||
|
||||
@@ -263,6 +263,8 @@ To turn the watcher off entirely, set `config set watcher_enabled false` (defaul
|
||||
|
||||
The watcher follows **git** projects only by default: a project indexed from a directory that is not a git repository is not watched and stays at its last index until you run `index_repository` again. To have such roots polled too, set `config set watch_non_git true` (default `false`, read when the daemon starts): they are then checked on the same cadence with a file-tree scan that uses the indexer's own ignore rules.
|
||||
|
||||
Linked git worktrees are indexed as their own projects by default. Set `config set ignore_worktrees true` to skip them on the automatic paths — useful when short-lived `git worktree add` checkouts would otherwise each leave behind a permanent index of the same repository. An explicit `index_repository` call on a worktree is then refused unless you pass `index_worktree=true`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#ignore_worktrees).
|
||||
|
||||
### Keeping Up to Date
|
||||
|
||||
**Updates run from the install script on every platform, not from inside the running binary.** `codebase-memory-mcp update` validates your flags and then prints the exact command to run:
|
||||
@@ -1016,6 +1018,7 @@ codebase-memory-mcp config list # show all settings
|
||||
codebase-memory-mcp config set auto_index true # auto-index on session start
|
||||
codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index
|
||||
codebase-memory-mcp config set auto_watch false # remove/disable all background watches (default: true)
|
||||
codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false)
|
||||
codebase-memory-mcp config set doc_candidates false # skip doc<->code candidate scoring on huge repos (default: auto = on for full, off for moderate/fast)
|
||||
codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true)
|
||||
codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false)
|
||||
|
||||
@@ -87,6 +87,7 @@ Current keys:
|
||||
|---|---|---|
|
||||
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
|
||||
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
|
||||
| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. |
|
||||
| `auto_watch` | `true` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. |
|
||||
| `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. |
|
||||
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; under `daemon start` those watches outlive the session until the daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. `index_status` reports the current state in its `watch` object. |
|
||||
@@ -142,6 +143,44 @@ and preserves any previously serving database. See
|
||||
[Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and
|
||||
error-response details.
|
||||
|
||||
### `ignore_worktrees`
|
||||
|
||||
Every indexed project is registered under its own absolute root path, so each
|
||||
linked worktree becomes a separate permanent index. On machines that create many
|
||||
short-lived worktrees, the automatic paths (`auto_index`, and the session hook's
|
||||
"index this project first" guidance) turn every throwaway checkout into another
|
||||
stored index of what is largely the same repository.
|
||||
|
||||
Enable the key to keep those checkouts out of the index:
|
||||
|
||||
```bash
|
||||
codebase-memory-mcp config set ignore_worktrees true
|
||||
```
|
||||
|
||||
With it enabled:
|
||||
|
||||
- automatic indexing skips a session whose root is a linked worktree;
|
||||
- the `hook-augment` context says the worktree is unindexed on purpose instead
|
||||
of telling the agent to run `index_repository`;
|
||||
- an explicit `index_repository` call on a linked worktree is refused, and names
|
||||
both ways forward — pass `index_worktree=true` for that one call, or turn the
|
||||
key back off.
|
||||
|
||||
The main checkout of the same repository is unaffected, as are ordinary clones
|
||||
and submodules. Detection is git plumbing only: a linked worktree's `.git` is a
|
||||
file pointing at a gitdir that contains a `commondir` entry.
|
||||
|
||||
A subdirectory counts as the worktree it sits in. Detection walks up from the
|
||||
given path to the nearest `.git` entry, so `index_repository` on any
|
||||
subdirectory of a linked worktree is refused the same way, and a session root or
|
||||
hook working directory inside one is treated as that worktree. The walk stops at
|
||||
the first `.git` it finds; a main checkout or a submodule ends it as "not a
|
||||
worktree". A path outside any git checkout is checked one directory at a time up
|
||||
to the filesystem root and is never treated as a worktree. None of this runs
|
||||
while the key is off.
|
||||
|
||||
The default is `false`, so indexing behavior is unchanged unless you opt in.
|
||||
|
||||
## 3. UI Settings
|
||||
|
||||
The optional built-in graph UI stores its settings in:
|
||||
|
||||
+11
@@ -148,8 +148,19 @@ detect_arch() {
|
||||
esac
|
||||
}
|
||||
|
||||
is_android() {
|
||||
[ "$(uname -o 2>/dev/null || true)" = "Android" ] \
|
||||
|| [ -n "${TERMUX_VERSION:-}" ] \
|
||||
|| [[ "${PREFIX:-}" == /data/data/com.termux/* ]]
|
||||
}
|
||||
|
||||
OS=$(detect_os)
|
||||
ARCH=$(detect_arch)
|
||||
if [ "$OS" = "linux" ] && is_android; then
|
||||
echo 'error: Android/Termux cannot run the Linux portable binary (static glibc; Android kills it with "Bad system call").' >&2
|
||||
echo "Building from source in Termux may work but is not a supported platform yet - see issue #1202." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "codebase-memory-mcp installer"
|
||||
echo " os: $OS"
|
||||
|
||||
@@ -3907,6 +3907,9 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
|
||||
// OpenAPI operations as Routes (self-gated: API description documents).
|
||||
cbm_extract_openapi(&ctx);
|
||||
|
||||
// Next.js App Router handlers' routes (self-gated: app/**/route.ts and route.js).
|
||||
cbm_extract_nextjs_routes(&ctx);
|
||||
|
||||
// K8s / Kustomize semantic pass (additional structured extraction for YAML-based infra files).
|
||||
if (ctx.language == CBM_LANG_KUSTOMIZE || ctx.language == CBM_LANG_K8S) {
|
||||
cbm_extract_k8s(&ctx);
|
||||
|
||||
@@ -1409,6 +1409,11 @@ void cbm_extract_diagram_file(CBMExtractCtx *ctx);
|
||||
* or JSON files whose root says `openapi` or `swagger`. */
|
||||
void cbm_extract_openapi(CBMExtractCtx *ctx);
|
||||
|
||||
/* Next.js App Router route handlers (extract_nextjs.c): the HTTP verb Functions
|
||||
* a route file exports carry the route path its directories name. Self-gated:
|
||||
* TS / JS route.ts and route.js under app/ or src/app/. */
|
||||
void cbm_extract_nextjs_routes(CBMExtractCtx *ctx);
|
||||
|
||||
/* Canonicalize route-path parameter placeholders (":id", "{id}", "<id>",
|
||||
* "${...}") to a single "{}" token so that client call sites, server handlers
|
||||
* and OpenAPI operations rendezvous on the same Route QN regardless of
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* extract_nextjs.c -- Next.js App Router route handlers (#759): an HTTP verb a
|
||||
* route file (`app/.../route.ts` or `route.js`, the app directory at the root
|
||||
* or under src/) exports serves the path its directories name.
|
||||
*
|
||||
* The handler is the Function definition extraction already made for the
|
||||
* export: this fills its route_path / route_method, so its Route node and
|
||||
* HANDLES edge come from the route pass exactly as a decorator route's do, on
|
||||
* a full and on an incremental index alike. The path is spelled as SvelteKit's
|
||||
* (sveltekit_route_path, pass_route_nodes.c): `[id]` is `:id`, `[...slug]` is
|
||||
* `*slug` and a `(group)` directory adds no segment.
|
||||
*
|
||||
* A directory this cannot spell makes the file no route at all: an optional
|
||||
* catch-all (`[[...slug]]`), a private folder (`_lib`), a parallel slot
|
||||
* (`@modal`), an intercepting route (`(.)photo`), a malformed bracket, or a
|
||||
* path that does not fit route_path. A handler is a Function of this file:
|
||||
* `export [async] function GET`, `export const GET = [async] (...) => ...`,
|
||||
* or `export { GET }` of such a declaration. A renamed
|
||||
* (`export { handler as GET }`), re-exported, destructured or wrapped
|
||||
* (`export const GET = auth(handler)`) handler, or a `function` expression
|
||||
* bound to a const, has no Function of its own here and is not routed. Each
|
||||
* file is judged alone: a src/app/ route file is routed even where a root
|
||||
* app/ directory makes Next.js ignore src/app/.
|
||||
*/
|
||||
#include "cbm.h"
|
||||
#include "foundation/arena.h"
|
||||
#include "helpers.h"
|
||||
#include "tree_sitter/api.h"
|
||||
#include "foundation/constants.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
enum {
|
||||
NX_PATH_MAX = CBM_SZ_256, /* the route pass rebuilds a Route from this much */
|
||||
NX_REST_LEN = 3, /* "..." */
|
||||
};
|
||||
|
||||
/* The path of a route file below its app directory, NULL for any other file. */
|
||||
static const char *nx_app_relative(const char *rel_path) {
|
||||
const char *base = rel_path ? strrchr(rel_path, '/') : NULL;
|
||||
if (!base || (strcmp(base, "/route.ts") != 0 && strcmp(base, "/route.js") != 0)) {
|
||||
return NULL;
|
||||
}
|
||||
if (strncmp(rel_path, "app/", strlen("app/")) == 0) {
|
||||
return rel_path + strlen("app/");
|
||||
}
|
||||
if (strncmp(rel_path, "src/app/", strlen("src/app/")) == 0) {
|
||||
return rel_path + strlen("src/app/");
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool nx_has_any(const char *s, size_t n, const char *chars) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (strchr(chars, s[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Append directory segment seg (n bytes) to the route path out[*len]. False
|
||||
* when the segment cannot be spelled or the path would not fit. */
|
||||
static bool nx_append_segment(char *out, size_t *len, const char *seg, size_t n) {
|
||||
if (n == 0 || seg[0] == '_' || seg[0] == '@') {
|
||||
return false;
|
||||
}
|
||||
if (seg[0] == '(') {
|
||||
/* A route group adds nothing; `(.)x`, `(..)x` and `(...)x` intercept. */
|
||||
return n > PAIR_LEN && seg[n - SKIP_ONE] == ')' && seg[SKIP_ONE] != '.' &&
|
||||
!nx_has_any(seg + SKIP_ONE, n - PAIR_LEN, "()");
|
||||
}
|
||||
char marker = '\0';
|
||||
if (seg[0] == '[') {
|
||||
if (seg[n - SKIP_ONE] != ']') {
|
||||
return false;
|
||||
}
|
||||
marker = ':';
|
||||
seg += SKIP_ONE;
|
||||
n -= PAIR_LEN;
|
||||
if (n >= NX_REST_LEN && strncmp(seg, "...", NX_REST_LEN) == 0) {
|
||||
marker = '*';
|
||||
seg += NX_REST_LEN;
|
||||
n -= NX_REST_LEN;
|
||||
}
|
||||
if (n == 0 || nx_has_any(seg, n, "[].")) {
|
||||
return false;
|
||||
}
|
||||
} else if (nx_has_any(seg, n, "[]")) {
|
||||
return false;
|
||||
}
|
||||
size_t need = SKIP_ONE + (marker ? SKIP_ONE : 0) + n;
|
||||
if (*len + need >= NX_PATH_MAX) {
|
||||
return false;
|
||||
}
|
||||
out[(*len)++] = '/';
|
||||
if (marker) {
|
||||
out[(*len)++] = marker;
|
||||
}
|
||||
memcpy(out + *len, seg, n);
|
||||
*len += n;
|
||||
out[*len] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The route path of the file being extracted, in the arena; NULL for none. */
|
||||
static const char *nx_route_path(CBMExtractCtx *ctx) {
|
||||
const char *seg = nx_app_relative(ctx->rel_path);
|
||||
if (!seg) {
|
||||
return NULL;
|
||||
}
|
||||
const char *base = strrchr(ctx->rel_path, '/');
|
||||
char path[NX_PATH_MAX] = "/";
|
||||
size_t len = 0;
|
||||
while (seg < base) {
|
||||
const char *end = strchr(seg, '/');
|
||||
if (!nx_append_segment(path, &len, seg, (size_t)(end - seg))) {
|
||||
return NULL;
|
||||
}
|
||||
seg = end + SKIP_ONE;
|
||||
}
|
||||
return cbm_arena_strdup(ctx->arena, path);
|
||||
}
|
||||
|
||||
static bool nx_is_method(const char *name) {
|
||||
static const char *const methods[] = {"GET", "POST", "PUT", "PATCH",
|
||||
"DELETE", "HEAD", "OPTIONS"};
|
||||
for (size_t i = 0; i < sizeof(methods) / sizeof(methods[0]); i++) {
|
||||
if (strcmp(name, methods[i]) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool nx_is(TSNode n, const char *type) {
|
||||
return !ts_node_is_null(n) && strcmp(ts_node_type(n), type) == 0;
|
||||
}
|
||||
|
||||
/* Does a node carry the anonymous keyword child kw (`default`, `type`)? */
|
||||
static bool nx_has_keyword(TSNode n, const char *kw) {
|
||||
uint32_t count = ts_node_child_count(n);
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
TSNode c = ts_node_child(n, i);
|
||||
if (!ts_node_is_named(c) && strcmp(ts_node_type(c), kw) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* The name of a declarator whose value is an arrow function, the Function
|
||||
* extraction names after the binding (`GET = () => ...`); a null node for any
|
||||
* other declarator. */
|
||||
static TSNode nx_arrow_binding(TSNode declarator) {
|
||||
TSNode value = ts_node_child_by_field_name(declarator, TS_FIELD("value"));
|
||||
TSNode name = ts_node_child_by_field_name(declarator, TS_FIELD("name"));
|
||||
if (!nx_is(name, "identifier") || !nx_is(value, "arrow_function")) {
|
||||
return (TSNode){0};
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/* Route the Function definition named by name_node, declared inside decl. */
|
||||
static void nx_route_handler(CBMExtractCtx *ctx, TSNode decl, TSNode name_node, const char *path) {
|
||||
const char *name = cbm_node_text(ctx->arena, name_node, ctx->source);
|
||||
if (!name || !nx_is_method(name)) {
|
||||
return;
|
||||
}
|
||||
const char *qn =
|
||||
cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name, ctx->language);
|
||||
uint32_t first = ts_node_start_point(decl).row + TS_LINE_OFFSET;
|
||||
uint32_t last = ts_node_end_point(decl).row + TS_LINE_OFFSET;
|
||||
for (int i = 0; qn && i < ctx->result->defs.count; i++) {
|
||||
CBMDefinition *def = &ctx->result->defs.items[i];
|
||||
if (!def->route_path && def->label && def->qualified_name &&
|
||||
strcmp(def->label, "Function") == 0 && strcmp(def->qualified_name, qn) == 0 &&
|
||||
def->start_line >= first && def->end_line <= last) {
|
||||
def->route_path = path;
|
||||
def->route_method = name;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Does the name node, if any, spell want? Any name does when want is NULL. */
|
||||
static bool nx_wanted(CBMExtractCtx *ctx, TSNode name, const char *want) {
|
||||
if (ts_node_is_null(name)) {
|
||||
return false;
|
||||
}
|
||||
const char *text = want ? cbm_node_text(ctx->arena, name, ctx->source) : NULL;
|
||||
return !want || (text && strcmp(text, want) == 0);
|
||||
}
|
||||
|
||||
/* Route the handlers a top-level declaration (function or const) defines.
|
||||
* Only names equal to want are considered when want is set. */
|
||||
static void nx_route_declaration(CBMExtractCtx *ctx, TSNode decl, const char *path,
|
||||
const char *want) {
|
||||
if (nx_is(decl, "function_declaration")) {
|
||||
TSNode name = ts_node_child_by_field_name(decl, TS_FIELD("name"));
|
||||
if (nx_wanted(ctx, name, want)) {
|
||||
nx_route_handler(ctx, decl, name, path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!nx_is(decl, "lexical_declaration") && !nx_is(decl, "variable_declaration")) {
|
||||
return;
|
||||
}
|
||||
uint32_t count = ts_node_named_child_count(decl);
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
TSNode declarator = ts_node_named_child(decl, i);
|
||||
TSNode name =
|
||||
nx_is(declarator, "variable_declarator") ? nx_arrow_binding(declarator) : (TSNode){0};
|
||||
if (nx_wanted(ctx, name, want)) {
|
||||
nx_route_handler(ctx, declarator, name, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* `export { GET }`: route the file's own top-level declaration of each
|
||||
* unrenamed, non-type specifier. */
|
||||
static void nx_route_export_clause(CBMExtractCtx *ctx, TSNode clause, const char *path) {
|
||||
uint32_t count = ts_node_named_child_count(clause);
|
||||
uint32_t top = ts_node_named_child_count(ctx->root);
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
TSNode spec = ts_node_named_child(clause, i);
|
||||
TSNode name = ts_node_child_by_field_name(spec, TS_FIELD("name"));
|
||||
if (!nx_is(spec, "export_specifier") || !nx_is(name, "identifier") ||
|
||||
!ts_node_is_null(ts_node_child_by_field_name(spec, TS_FIELD("alias"))) ||
|
||||
nx_has_keyword(spec, "type") || nx_has_keyword(spec, "typeof")) {
|
||||
continue;
|
||||
}
|
||||
const char *want = cbm_node_text(ctx->arena, name, ctx->source);
|
||||
for (uint32_t j = 0; want && j < top; j++) {
|
||||
nx_route_declaration(ctx, ts_node_named_child(ctx->root, j), path, want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cbm_extract_nextjs_routes(CBMExtractCtx *ctx) {
|
||||
if (ctx->language != CBM_LANG_TYPESCRIPT && ctx->language != CBM_LANG_JAVASCRIPT) {
|
||||
return;
|
||||
}
|
||||
const char *path = nx_route_path(ctx);
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
uint32_t count = ts_node_named_child_count(ctx->root);
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
TSNode node = ts_node_named_child(ctx->root, i);
|
||||
/* A runtime export of this file: not `export default`, not
|
||||
* `export type { ... }`, not a re-export `... from './x'`. */
|
||||
if (!nx_is(node, "export_statement") || nx_has_keyword(node, "default") ||
|
||||
nx_has_keyword(node, "type") ||
|
||||
!ts_node_is_null(ts_node_child_by_field_name(node, TS_FIELD("source")))) {
|
||||
continue;
|
||||
}
|
||||
TSNode decl = ts_node_child_by_field_name(node, TS_FIELD("declaration"));
|
||||
if (!ts_node_is_null(decl)) {
|
||||
nx_route_declaration(ctx, decl, path, NULL);
|
||||
continue;
|
||||
}
|
||||
uint32_t parts = ts_node_named_child_count(node);
|
||||
for (uint32_t j = 0; j < parts; j++) {
|
||||
TSNode part = ts_node_named_child(node, j);
|
||||
if (nx_is(part, "export_clause")) {
|
||||
nx_route_export_clause(ctx, part, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,9 @@ bash "$ROOT/tests/test_language_count_contract.sh"
|
||||
echo "=== Step 0x: packaging version-metadata contract ==="
|
||||
bash "$ROOT/tests/test_version_metadata_contract.sh"
|
||||
|
||||
echo "=== Step 0x2: installer Android/Termux guard contract (#1202) ==="
|
||||
bash "$ROOT/tests/test_install_android_guard.sh"
|
||||
|
||||
# Step 0y: the Windows leg must not decide its verdict from an exit status that
|
||||
# the ssh/msys2_shell chain can mangle — a channel that turns 0 into 1 can turn
|
||||
# 1 into 0, and that direction reports a RED Windows leg as green. Runs
|
||||
|
||||
@@ -8088,6 +8088,7 @@ static const config_key_def_t CONFIG_KEYS[] = {
|
||||
{CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"},
|
||||
{CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"},
|
||||
{CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"},
|
||||
{CBM_CONFIG_IGNORE_WORKTREES, "false", "Skip linked git worktrees when indexing automatically"},
|
||||
/* Unset: the index mode decides (cbm_pipeline_doc_candidates_enabled). */
|
||||
{CBM_CONFIG_DOC_CANDIDATES, "auto",
|
||||
"Doc-code candidates: auto = on for full, off for moderate/fast; true/false forces"},
|
||||
|
||||
+6
-1
@@ -336,7 +336,11 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect,
|
||||
const char *forced_event);
|
||||
bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate,
|
||||
bool case_insensitive);
|
||||
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event);
|
||||
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event,
|
||||
bool worktree_ignored);
|
||||
/* Counts lifecycle calls that reached the ignore_worktrees resolution (cwd
|
||||
* canonicalization and the linked-worktree probe). NULL stops counting. */
|
||||
void cbm_hook_set_worktree_gate_counter_for_testing(int *counter);
|
||||
bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz);
|
||||
/* include_posttool false renders the previous lifecycle-only manifest. Release
|
||||
* the result with cbm_free(CBM_MEM_CLASS_OTHER, ...). */
|
||||
@@ -526,6 +530,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t
|
||||
#define CBM_CONFIG_AUTO_INDEX "auto_index"
|
||||
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
|
||||
#define CBM_CONFIG_AUTO_WATCH "auto_watch"
|
||||
#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees"
|
||||
#define CBM_CONFIG_DOC_CANDIDATES "doc_candidates"
|
||||
#define CBM_CONFIG_UI_LANG "ui-lang"
|
||||
#define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled"
|
||||
|
||||
+69
-6
@@ -17,11 +17,14 @@
|
||||
*/
|
||||
|
||||
#include "cli/cli.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/constants.h"
|
||||
#include "foundation/mem.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "git/git_context.h"
|
||||
#include "mcp/mcp.h"
|
||||
#include "mcp/mcp_internal.h"
|
||||
#include "pipeline/pipeline.h"
|
||||
#include "yyjson/yyjson.h"
|
||||
|
||||
@@ -1654,7 +1657,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) {
|
||||
return "Tier 2 verification";
|
||||
}
|
||||
|
||||
static const char *ha_no_project_index_guidance(const char *event) {
|
||||
static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) {
|
||||
/* ignore_worktrees is on and this cwd is a linked worktree: indexing it is
|
||||
* deliberately disabled, so telling the agent to run index_repository would
|
||||
* only produce a refusal. Point at the setting instead. */
|
||||
if (worktree_ignored) {
|
||||
return "This is a linked git worktree and ignore_worktrees is enabled, so it is not "
|
||||
"indexed on purpose; do not run index_repository here.";
|
||||
}
|
||||
return event && strcmp(event, "SubagentStart") == 0
|
||||
? "Ask the parent agent to run index_repository before structural exploration; "
|
||||
"do not attempt graph mutation."
|
||||
@@ -1671,6 +1681,41 @@ static bool ha_invocation_supported(ha_lifecycle_dialect_t dialect, const char *
|
||||
return !forced_event || ha_dialect_event_supported(dialect, forced_event);
|
||||
}
|
||||
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
static CBM_TLS int *g_ha_worktree_gate_counter = NULL;
|
||||
#endif
|
||||
|
||||
/* Project resolution once ignore_worktrees is on; the caller runs this only
|
||||
* after reading the key, so the default path does none of it. A symlink alias
|
||||
* has no .git of its own, and walking its lexical parents leaves the linked
|
||||
* worktree. index_repository canonicalizes before the same gate, so the hook
|
||||
* must too or it tells the agent to index a path the setting will refuse. The
|
||||
* setting refuses a linked worktree even when it sits inside an indexed
|
||||
* checkout or inside another linked worktree, so a match is kept only when its
|
||||
* root belongs to this cwd's own linked worktree, not a parent graph. */
|
||||
static char *ha_resolve_project_ignoring_worktrees(cbm_mcp_server_t *srv, const char *cwd,
|
||||
bool *drop_ancestor, bool *worktree_ignored) {
|
||||
#ifdef CBM_CLI_ENABLE_TEST_API
|
||||
if (g_ha_worktree_gate_counter) {
|
||||
(*g_ha_worktree_gate_counter)++;
|
||||
}
|
||||
#endif
|
||||
char canonical_cwd[4096];
|
||||
if (ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) {
|
||||
cwd = canonical_cwd;
|
||||
}
|
||||
char project_root[4096];
|
||||
project_root[0] = '\0';
|
||||
char *project =
|
||||
ha_resolve_indexed_project_with_root(srv, cwd, project_root, sizeof(project_root));
|
||||
bool linked = cbm_git_is_linked_worktree(cwd);
|
||||
/* Drop a parent graph without a second free(): this file's raw free count
|
||||
* is ratcheted, and the caller's single free(project) still releases it. */
|
||||
*drop_ancestor = linked && project && !cbm_git_same_linked_worktree(cwd, project_root);
|
||||
*worktree_ignored = linked && (!project || *drop_ancestor);
|
||||
return project;
|
||||
}
|
||||
|
||||
static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root,
|
||||
const char *forced_event, ha_lifecycle_dialect_t dialect) {
|
||||
if (!root || !yyjson_is_obj(root)) {
|
||||
@@ -1688,7 +1733,20 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
|
||||
srv = owned_server;
|
||||
}
|
||||
const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer));
|
||||
char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL;
|
||||
/* Read ignore_worktrees first and do nothing else for it while it is off.
|
||||
* cbm_mcp_server_new(NULL) has no store, and srv may be the daemon's
|
||||
* session server, so the read leaves srv untouched (see
|
||||
* cbm_mcp_ignore_worktrees_runtime_enabled). Off, this is the resolution
|
||||
* the hook did before the key existed. */
|
||||
bool drop_ancestor = false;
|
||||
bool worktree_ignored = false;
|
||||
char *project = NULL;
|
||||
if (srv && cwd && cbm_mcp_ignore_worktrees_runtime_enabled(srv)) {
|
||||
project =
|
||||
ha_resolve_project_ignoring_worktrees(srv, cwd, &drop_ancestor, &worktree_ignored);
|
||||
} else {
|
||||
project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL;
|
||||
}
|
||||
cbm_mcp_server_free(owned_server);
|
||||
|
||||
char context[2048];
|
||||
@@ -1713,7 +1771,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
|
||||
? " The same tools are also available from the shell: "
|
||||
"`codebase-memory-mcp cli <tool> --help` lists each tool's flags."
|
||||
: "";
|
||||
if (project) {
|
||||
if (project && !drop_ancestor) {
|
||||
char safe_project[HA_METADATA_CAP];
|
||||
ha_sanitize_metadata(project, safe_project, sizeof(safe_project));
|
||||
/* The untrusted label covers the graph-derived project name only. Wrapping
|
||||
@@ -1732,7 +1790,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root
|
||||
"and file reads for literals, configs, non-code files, and verification.%s",
|
||||
scope, safe_project, tier, cli_hint);
|
||||
} else {
|
||||
const char *index_guidance = ha_no_project_index_guidance(event);
|
||||
const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored);
|
||||
snprintf(context, sizeof(context),
|
||||
"[codebase-memory] %s context: no indexed graph project matched this working "
|
||||
"directory. %s Once indexed, "
|
||||
@@ -1851,8 +1909,13 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate,
|
||||
return ha_path_contains_mode(root, candidate, case_insensitive);
|
||||
}
|
||||
|
||||
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) {
|
||||
return ha_no_project_index_guidance(event);
|
||||
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event,
|
||||
bool worktree_ignored) {
|
||||
return ha_no_project_index_guidance(event, worktree_ignored);
|
||||
}
|
||||
|
||||
void cbm_hook_set_worktree_gate_counter_for_testing(int *counter) {
|
||||
g_ha_worktree_gate_counter = counter;
|
||||
}
|
||||
|
||||
bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "foundation/sha256.h"
|
||||
#include "foundation/subprocess.h"
|
||||
#include "foundation/workspace.h"
|
||||
#include "git/git_context.h"
|
||||
#include "mcp/index_supervisor.h"
|
||||
#include "mcp/mcp.h"
|
||||
#include "mcp/mcp_internal.h"
|
||||
@@ -1941,6 +1942,18 @@ static void application_auto_index_retry_pending_locked(cbm_daemon_application_t
|
||||
application_refresh_watch_locked(session);
|
||||
continue;
|
||||
}
|
||||
/* ignore_worktrees can be turned on after this session was queued for
|
||||
* a capacity slot. Initial admission already skips linked worktrees;
|
||||
* retry must apply the same gate or the pending checkout is indexed
|
||||
* once a slot opens. */
|
||||
if (cbm_mcp_ignore_worktrees_enabled(session->mcp) &&
|
||||
cbm_git_is_linked_worktree(root_path)) {
|
||||
session->auto_index_retry_pending = false;
|
||||
cbm_log_info("daemon.autoindex.skipped", "project", project, "reason",
|
||||
"linked_worktree");
|
||||
application_refresh_watch_locked(session);
|
||||
continue;
|
||||
}
|
||||
char *args = application_auto_index_args(application, root_path);
|
||||
if (!args) {
|
||||
continue;
|
||||
@@ -2599,6 +2612,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio
|
||||
!application_session_workspace_allowed(session, "auto_index_discovery")) {
|
||||
auto_index_candidate = false;
|
||||
}
|
||||
if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) &&
|
||||
cbm_git_is_linked_worktree(root_path)) {
|
||||
cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree");
|
||||
auto_index_candidate = false;
|
||||
}
|
||||
bool within_auto_index_limit =
|
||||
!auto_index_candidate ||
|
||||
cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files);
|
||||
|
||||
+23
-9
@@ -1263,6 +1263,13 @@ static int posix_named_shared_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *
|
||||
return posix_named_lock_try_acquire_mode(endpoint, lock_name, true, fd_out, process_entry_out);
|
||||
}
|
||||
|
||||
/* Never open and close the lifetime lock file in a process that may already
|
||||
* hold it: fcntl(2) record locks are scoped to (process, inode), so that
|
||||
* close would silently release every lock this process holds on the file,
|
||||
* regardless of which fd took it. This lock is fcntl rather than flock for
|
||||
* exactly this reason in reverse: flock locks survive a fork, fcntl locks
|
||||
* do not, and a forked child must never appear to hold its parent's
|
||||
* reservation. */
|
||||
static int posix_record_lock_set(int fd, short lock_type) {
|
||||
struct flock record_lock = {
|
||||
.l_type = lock_type,
|
||||
@@ -1286,6 +1293,15 @@ static int posix_lifetime_lock_probe(const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
if (process_claimed < 0) {
|
||||
return -1;
|
||||
}
|
||||
if (process_claimed == 1) {
|
||||
/* Already held by this process per the in-process registry: trust
|
||||
* that alone. Opening a throwaway fd on the lock file here and then
|
||||
* closing it would release the real fcntl(2) record lock, which is
|
||||
* scoped to (process, inode) rather than (fd, inode): closing any
|
||||
* fd on this inode drops every lock this process holds on it, even
|
||||
* one held via a different, still-open fd. */
|
||||
return endpoint_runtime_still_valid(endpoint) ? 1 : -1;
|
||||
}
|
||||
|
||||
int fd = openat(endpoint->dir_fd, lock_name, O_RDWR | O_CLOEXEC | O_NOFOLLOW);
|
||||
if (fd < 0) {
|
||||
@@ -1297,10 +1313,6 @@ static int posix_lifetime_lock_probe(const cbm_daemon_ipc_endpoint_t *endpoint,
|
||||
(void)close(fd);
|
||||
return -1;
|
||||
}
|
||||
if (process_claimed == 1) {
|
||||
bool still_private = endpoint_runtime_still_valid(endpoint);
|
||||
return close(fd) == 0 && still_private ? 1 : -1;
|
||||
}
|
||||
struct flock record_lock = {
|
||||
.l_type = F_WRLCK,
|
||||
.l_whence = SEEK_SET,
|
||||
@@ -1338,11 +1350,13 @@ static int posix_lifetime_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *endp
|
||||
process_lock_entry_t *process_entry = NULL;
|
||||
int process_result = process_lock_claim(endpoint, lock_name, &process_entry);
|
||||
if (process_result != 1) {
|
||||
return process_result == 0 &&
|
||||
private_regular_file_at_is_safe(endpoint->dir_fd, lock_name, 1) &&
|
||||
endpoint_runtime_still_valid(endpoint)
|
||||
? 0
|
||||
: -1;
|
||||
/* Already held by this process per the in-process registry: trust
|
||||
* that alone, the same as the probe's early return above. Calling
|
||||
* private_regular_file_at_is_safe() here would open a throwaway fd
|
||||
* on the lock file and close it, releasing the real fcntl(2) record
|
||||
* lock this process holds, because that lock is scoped to (process,
|
||||
* inode) rather than (fd, inode). */
|
||||
return process_result == 0 && endpoint_runtime_still_valid(endpoint) ? 0 : -1;
|
||||
}
|
||||
int fd = openat(endpoint->dir_fd, lock_name, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600);
|
||||
if (fd < 0 || !fd_set_cloexec(fd)) {
|
||||
|
||||
@@ -729,3 +729,28 @@ const char *cbm_resolve_cache_dir(void) {
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* ── CPU scheduling priority ────────────────── */
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <sys/resource.h>
|
||||
#endif
|
||||
|
||||
bool cbm_lower_cpu_priority(void) {
|
||||
#ifdef _WIN32
|
||||
HANDLE self = GetCurrentProcess();
|
||||
DWORD current = GetPriorityClass(self);
|
||||
if (current == IDLE_PRIORITY_CLASS || current == BELOW_NORMAL_PRIORITY_CLASS) {
|
||||
return true; /* already at least this low: never raise priority */
|
||||
}
|
||||
return SetPriorityClass(self, BELOW_NORMAL_PRIORITY_CLASS) != 0;
|
||||
#else
|
||||
/* getpriority can legitimately return -1, so errno is the only error signal. */
|
||||
errno = 0;
|
||||
int current = getpriority(PRIO_PROCESS, 0);
|
||||
if (errno == 0 && current >= CBM_LOW_CPU_PRIORITY_NICE) {
|
||||
return true; /* already at least this nice: never raise priority */
|
||||
}
|
||||
return setpriority(PRIO_PROCESS, 0, CBM_LOW_CPU_PRIORITY_NICE) == 0;
|
||||
#endif
|
||||
}
|
||||
@@ -215,4 +215,17 @@ int64_t cbm_file_size(const char *path);
|
||||
* On POSIX, this is a no-op. Returns the input pointer. */
|
||||
char *cbm_normalize_path_sep(char *path);
|
||||
|
||||
/* ── CPU scheduling priority ───────────────────────────────────── */
|
||||
|
||||
enum { CBM_LOW_CPU_PRIORITY_NICE = 10 };
|
||||
|
||||
/* Lower this process's CPU scheduling priority so background work yields the
|
||||
* cores to interactive processes. POSIX: nice CBM_LOW_CPU_PRIORITY_NICE (never
|
||||
* raises an already-nicer process). Plain nice only, deliberately not macOS
|
||||
* background QoS: that pins work to efficiency cores and throttles I/O.
|
||||
* Windows: BELOW_NORMAL_PRIORITY_CLASS. On Linux nice is per thread, so call
|
||||
* this before creating threads; later threads inherit it.
|
||||
* Returns false when the priority could not be changed. */
|
||||
bool cbm_lower_cpu_priority(void);
|
||||
|
||||
#endif /* CBM_PLATFORM_H */
|
||||
@@ -73,6 +73,8 @@ static int git_capture_into(const char *repo_path, const char *git_args, char *b
|
||||
* stuck git child is named by the last marker printed (spawn, read or
|
||||
* close). Silent at the default level. */
|
||||
cbm_log_debug("git.capture", "stage", "spawn", "args", git_args);
|
||||
/* A hook or editor may export GIT_DIR/GIT_WORK_TREE for another repo.
|
||||
* Isolate this child so git -C selects repo_path rather than that repo. */
|
||||
FILE *fp = cbm_popen_git(cmd);
|
||||
if (!fp) {
|
||||
return CBM_NOT_FOUND;
|
||||
@@ -111,6 +113,14 @@ static int git_capture(const char *repo_path, const char *git_args, char **out)
|
||||
return *out ? 0 : CBM_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* Regular file, not a directory and not a symlink. Uses the Unicode-safe
|
||||
* path probe: stat() follows links and rejects non-ASCII paths on Windows,
|
||||
* which upstream removed from this file in the Unicode repository-path fix. */
|
||||
static bool path_is_regular_file(const char *path) {
|
||||
cbm_path_info_t info;
|
||||
return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_regular;
|
||||
}
|
||||
|
||||
static bool path_is_absolute(const char *path) {
|
||||
if (!path || !path[0]) {
|
||||
return false;
|
||||
@@ -125,6 +135,180 @@ static bool path_is_absolute(const char *path) {
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Read the "gitdir: <path>" pointer out of a gitlink FILE at <path>/.git.
|
||||
* Returns false when .git is missing, a directory (ordinary repo), or holds no
|
||||
* pointer. A relative pointer is resolved against path. */
|
||||
static bool read_gitlink_target(const char *path, char *out, size_t out_size) {
|
||||
char dot_git[GIT_OUTPUT_MAX];
|
||||
int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path);
|
||||
if (n < 0 || n >= (int)sizeof(dot_git)) {
|
||||
return false;
|
||||
}
|
||||
if (!path_is_regular_file(dot_git)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FILE *f = cbm_fopen(dot_git, "r");
|
||||
if (!f) {
|
||||
return false;
|
||||
}
|
||||
char line[GIT_OUTPUT_MAX];
|
||||
bool got = false;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
trim_newlines(line);
|
||||
if (strncmp(line, "gitdir:", 7) != 0) {
|
||||
continue;
|
||||
}
|
||||
const char *value = line + 7;
|
||||
while (*value == ' ' || *value == '\t') {
|
||||
value++;
|
||||
}
|
||||
if (!value[0]) {
|
||||
break;
|
||||
}
|
||||
int written = path_is_absolute(value) ? snprintf(out, out_size, "%s", value)
|
||||
: snprintf(out, out_size, "%s/%s", path, value);
|
||||
got = written > 0 && written < (int)out_size;
|
||||
break;
|
||||
}
|
||||
fclose(f);
|
||||
return got;
|
||||
}
|
||||
|
||||
/* 1 = linked-worktree gitlink, 0 = a different git anchor (stop walking),
|
||||
* -1 = no .git at this directory (keep walking). */
|
||||
static int linked_worktree_anchor(const char *path) {
|
||||
char dot_git[GIT_OUTPUT_MAX];
|
||||
int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path);
|
||||
if (n < 0 || n >= (int)sizeof(dot_git)) {
|
||||
return 0;
|
||||
}
|
||||
cbm_path_info_t info;
|
||||
if (cbm_path_info_utf8(dot_git, &info) != CBM_PATH_INFO_OK) {
|
||||
return -1;
|
||||
}
|
||||
/* A directory .git is a main checkout. Anything else that is not a
|
||||
* regular gitlink file (a symlink, for example) is also an anchor:
|
||||
* do not follow it, and do not blame a parent worktree for this tree. */
|
||||
if (!info.is_regular) {
|
||||
return 0;
|
||||
}
|
||||
char git_dir[GIT_OUTPUT_MAX];
|
||||
if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) {
|
||||
return 0;
|
||||
}
|
||||
/* Only linked worktrees carry <gitdir>/commondir; a submodule gitlink
|
||||
* points at <super>/.git/modules/<name>, which does not. */
|
||||
char commondir[GIT_OUTPUT_MAX];
|
||||
n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir);
|
||||
if (n < 0 || n >= (int)sizeof(commondir)) {
|
||||
return 0;
|
||||
}
|
||||
return path_is_regular_file(commondir) ? 1 : 0;
|
||||
}
|
||||
|
||||
static bool parent_directory(char *path) {
|
||||
size_t n = strlen(path);
|
||||
while (n > 1 && (path[n - 1] == '/' || path[n - 1] == '\\')) {
|
||||
path[--n] = '\0';
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if ((n == 2 && path[1] == ':') ||
|
||||
(n == 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\'))) {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
if (n <= 1) {
|
||||
return false;
|
||||
}
|
||||
char *slash = NULL;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (path[i] == '/' || path[i] == '\\') {
|
||||
slash = path + i;
|
||||
}
|
||||
}
|
||||
if (!slash) {
|
||||
return false;
|
||||
}
|
||||
if (slash == path) {
|
||||
path[1] = '\0';
|
||||
return true;
|
||||
}
|
||||
*slash = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
/* On a linked-worktree hit, optionally copy that anchor directory into root_out. */
|
||||
static bool walk_linked_worktree_root(const char *path, char *root_out, size_t root_out_size) {
|
||||
if (!path || !path[0]) {
|
||||
return false;
|
||||
}
|
||||
char current[GIT_OUTPUT_MAX];
|
||||
int n = snprintf(current, sizeof(current), "%s", path);
|
||||
if (n < 0 || n >= (int)sizeof(current)) {
|
||||
return false;
|
||||
}
|
||||
size_t len = strlen(current);
|
||||
while (len > 1 && (current[len - 1] == '/' || current[len - 1] == '\\')) {
|
||||
current[--len] = '\0';
|
||||
}
|
||||
/* A session cwd or index_repository path is often a subdirectory. Walk
|
||||
* ancestors until a git anchor so ignore_worktrees applies to the whole
|
||||
* linked checkout, not only its root. */
|
||||
/* Bound is the path itself: each step drops one component and stops at
|
||||
* the filesystem root. A fixed depth would miss a deep session cwd. */
|
||||
for (;;) {
|
||||
int kind = linked_worktree_anchor(current);
|
||||
if (kind >= 0) {
|
||||
if (kind != 1) {
|
||||
return false;
|
||||
}
|
||||
if (root_out && root_out_size > 0U) {
|
||||
int written = snprintf(root_out, root_out_size, "%s", current);
|
||||
if (written < 0 || (size_t)written >= root_out_size) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
char previous[GIT_OUTPUT_MAX];
|
||||
snprintf(previous, sizeof(previous), "%s", current);
|
||||
if (!parent_directory(current) || strcmp(previous, current) == 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool cbm_git_is_linked_worktree(const char *path) {
|
||||
return walk_linked_worktree_root(path, NULL, 0U);
|
||||
}
|
||||
|
||||
bool cbm_git_same_linked_worktree(const char *a, const char *b) {
|
||||
char root_a[GIT_OUTPUT_MAX];
|
||||
char root_b[GIT_OUTPUT_MAX];
|
||||
if (!walk_linked_worktree_root(a, root_a, sizeof(root_a)) ||
|
||||
!walk_linked_worktree_root(b, root_b, sizeof(root_b))) {
|
||||
return false;
|
||||
}
|
||||
/* The gitdir file is unique per linked worktree. Comparing those pointers
|
||||
* keeps a nested worktree from matching its parent even when both are
|
||||
* linked checkouts. */
|
||||
char git_a[GIT_OUTPUT_MAX];
|
||||
char git_b[GIT_OUTPUT_MAX];
|
||||
if (!read_gitlink_target(root_a, git_a, sizeof(git_a)) ||
|
||||
!read_gitlink_target(root_b, git_b, sizeof(git_b))) {
|
||||
return false;
|
||||
}
|
||||
/* A relative gitdir is joined onto the walked path. Canonicalize both so
|
||||
* a symlink spelling of the cwd still matches the real project root.
|
||||
* Fall back to the raw string when the gitdir does not exist. */
|
||||
char norm_a[GIT_OUTPUT_MAX];
|
||||
char norm_b[GIT_OUTPUT_MAX];
|
||||
const char *left = cbm_canonical_path(git_a, norm_a, sizeof(norm_a)) ? norm_a : git_a;
|
||||
const char *right = cbm_canonical_path(git_b, norm_b, sizeof(norm_b)) ? norm_b : git_b;
|
||||
return strcmp(left, right) == 0;
|
||||
}
|
||||
|
||||
static char *join_root_relative(const char *root, const char *rel) {
|
||||
if (!root || !root[0]) {
|
||||
return git_strdup(rel);
|
||||
|
||||
@@ -20,6 +20,25 @@ typedef struct {
|
||||
char *base_sha;
|
||||
} cbm_git_context_t;
|
||||
|
||||
/* True when path is a LINKED git worktree (`git worktree add`) or a
|
||||
* subdirectory of one.
|
||||
*
|
||||
* Plumbing-only, no subprocess. Walk ancestors until a git anchor. A linked
|
||||
* worktree's <root>/.git is a regular file holding a "gitdir: <path>" pointer
|
||||
* AND that gitdir contains a `commondir` file. The walk stops at any other
|
||||
* anchor: a main checkout (`.git` is a directory) or a submodule (gitlink
|
||||
* file whose gitdir, <super>/.git/modules/<name>, has no commondir).
|
||||
*
|
||||
* Callers run this on every session start, so it stays fork-free; the richer
|
||||
* cbm_git_context_resolve() shells out to git and is not usable on that path. */
|
||||
bool cbm_git_is_linked_worktree(const char *path);
|
||||
|
||||
/* True when both paths sit in the same linked worktree. Identity is the
|
||||
* gitdir pointer of each path's linked-worktree anchor, so a worktree nested
|
||||
* inside another does not count as that parent. False when either path is not
|
||||
* inside a linked worktree. */
|
||||
bool cbm_git_same_linked_worktree(const char *a, const char *b);
|
||||
|
||||
int cbm_git_context_resolve(const char *path, cbm_git_context_t *out);
|
||||
/* Only the is_git and branch fields of cbm_git_context_resolve, from the same
|
||||
* two git commands it uses for them (2 git processes instead of 7). branch
|
||||
|
||||
@@ -902,6 +902,12 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
|
||||
* own here, and only here (cbm_sqlite_dedicated_heap). */
|
||||
cbm_sqlite_dedicated_heap(true);
|
||||
cbm_memev_process_role("worker");
|
||||
/* A full index saturates every core; run it below normal priority so
|
||||
* interactive CLI/MCP queries are not starved. Before any indexing
|
||||
* thread exists, so they all inherit it. Failure is harmless. */
|
||||
if (!cbm_lower_cpu_priority()) {
|
||||
cbm_log_info("index.worker.priority", "lowered", "false");
|
||||
}
|
||||
}
|
||||
cbm_index_set_worker_role_options(index_worker, response_out, worker_single_thread,
|
||||
worker_marker, worker_quarantine,
|
||||
|
||||
@@ -551,6 +551,9 @@ static const tool_def_t TOOLS[] = {
|
||||
"\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"},"
|
||||
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
|
||||
"\"Write .codebase-memory/graph.db.zst.\"},"
|
||||
"\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":"
|
||||
"\"Index repo_path even when it is a linked git worktree and the "
|
||||
"ignore_worktrees config key is enabled. No effect otherwise.\"},"
|
||||
"\"doc_candidates\":{\"type\":\"boolean\",\"description\":"
|
||||
"\"Doc-code candidates; default: the doc_candidates setting, else on for full, off for "
|
||||
"moderate/fast.\"},"
|
||||
@@ -13050,6 +13053,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
|
||||
return cbm_mcp_text_result(boundary_err, true);
|
||||
}
|
||||
|
||||
/* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree
|
||||
* is refused with the two ways forward (per-call override, or turn the
|
||||
* setting off) rather than silently skipped — a silent success would be
|
||||
* indistinguishable from a real index to the caller. */
|
||||
if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") &&
|
||||
cbm_git_is_linked_worktree(repo_path)) {
|
||||
index_args_free(repo_path, mode_str, name_override);
|
||||
return cbm_mcp_text_result(
|
||||
"repo_path is a linked git worktree and ignore_worktrees is enabled. Pass "
|
||||
"index_worktree=true to index it anyway, or run: codebase-memory-mcp config set "
|
||||
"ignore_worktrees false",
|
||||
true);
|
||||
}
|
||||
|
||||
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
|
||||
if (async_mode) {
|
||||
index_args_free(repo_path, mode_str, name_override);
|
||||
@@ -21551,6 +21568,44 @@ static bool auto_watch_enabled(cbm_mcp_server_t *srv) {
|
||||
return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true);
|
||||
}
|
||||
|
||||
/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees
|
||||
* (default off, so existing setups keep indexing them). Users who create many
|
||||
* short-lived worktrees can stop each throwaway checkout from registering a
|
||||
* new permanent project with `config set ignore_worktrees true`. */
|
||||
bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) {
|
||||
if (!srv || !srv->config) {
|
||||
return false; /* default off */
|
||||
}
|
||||
return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false);
|
||||
}
|
||||
|
||||
/* The same key for a caller whose server may have no store: the stand-alone
|
||||
* lifecycle hook builds one with cbm_mcp_server_new(NULL), and the daemon lends
|
||||
* its session server, which this must not modify. A server with a store answers
|
||||
* from it. Otherwise the runtime store is opened only to read this key, and only
|
||||
* when it exists: a missing store leaves every key at its default, so this read
|
||||
* never creates one. */
|
||||
bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv) {
|
||||
if (srv && srv->config) {
|
||||
return cbm_mcp_ignore_worktrees_enabled(srv);
|
||||
}
|
||||
const char *store_dir = cbm_resolve_cache_dir();
|
||||
if (!store_dir) {
|
||||
return false;
|
||||
}
|
||||
char store_path[CBM_SZ_4K];
|
||||
int written = snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, store_dir);
|
||||
cbm_path_info_t info;
|
||||
if (written < 0 || (size_t)written >= sizeof(store_path) ||
|
||||
cbm_path_info_utf8(store_path, &info) != CBM_PATH_INFO_OK || !info.is_regular) {
|
||||
return false;
|
||||
}
|
||||
cbm_config_t *config = cbm_config_open(store_dir);
|
||||
bool enabled = config && cbm_config_get_bool(config, CBM_CONFIG_IGNORE_WORKTREES, false);
|
||||
cbm_config_close(config);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/* Register the session project with the background watcher for ongoing
|
||||
* change detection — unless auto_watch is disabled. */
|
||||
static void register_watcher_if_enabled(cbm_mcp_server_t *srv) {
|
||||
@@ -21705,6 +21760,12 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) {
|
||||
cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project",
|
||||
srv->session_project);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Quick tracked-file count check to avoid OOM on massive repos. */
|
||||
int file_count = -1;
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
|
||||
@@ -86,6 +86,16 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy);
|
||||
bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit,
|
||||
int *file_count_out);
|
||||
|
||||
/* True when the `ignore_worktrees` config key is on for this server (default
|
||||
* off). Callers pair it with cbm_git_is_linked_worktree() to decide whether an
|
||||
* automatic index of a linked worktree should be skipped. */
|
||||
bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv);
|
||||
|
||||
/* The same key when srv may have no store (the lifecycle hook). Never attaches
|
||||
* a store to srv and never creates the runtime store; reads it only when it
|
||||
* already exists. */
|
||||
bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv);
|
||||
|
||||
/* detect_changes seed scoping (#1363): does `node`'s line range overlap any
|
||||
* recorded hunk for `file`? Exposed for direct unit testing of the overlap
|
||||
* logic, independent of the git/subprocess/index plumbing around it. */
|
||||
|
||||
@@ -1684,8 +1684,13 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr
|
||||
* are preserved and dotted-name collisions are disambiguated before section
|
||||
* references resolve. Old indices rebuild once so Section nodes, MENTIONS
|
||||
* and diagram owners agree.
|
||||
* 13: a Next.js App Router route handler, the HTTP verb Function a route.ts
|
||||
* / route.js under app/ or src/app/ exports, carries the route_path its
|
||||
* directories name and HANDLES its Route (extract_nextjs.c). An index
|
||||
* built before has none of these Routes for its unchanged route files,
|
||||
* so it rebuilds once on upgrade.
|
||||
* The unresolved-call coverage threshold remains independent. */
|
||||
enum { CBM_SEMANTIC_INDEX_VERSION = 12 };
|
||||
enum { CBM_SEMANTIC_INDEX_VERSION = 13 };
|
||||
|
||||
typedef struct {
|
||||
cbm_gbuf_t *gbuf;
|
||||
|
||||
+206
-3
@@ -27,6 +27,7 @@
|
||||
#include <foundation/platform.h>
|
||||
#include <foundation/private_file_lock.h>
|
||||
#include <foundation/sha256.h>
|
||||
#include <foundation/str_util.h>
|
||||
#include <mcp/mcp.h>
|
||||
#include <mcp/index_supervisor.h>
|
||||
#include <pipeline/pipeline.h>
|
||||
@@ -12677,9 +12678,91 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) {
|
||||
snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir);
|
||||
char *wt_out = cbm_hook_augment_lifecycle_json(input);
|
||||
bool deliberate = wt_out && wt_out[0] && strstr(wt_out, "no indexed graph project matched") &&
|
||||
!strstr(wt_out, "is indexed");
|
||||
strstr(wt_out, "Run index_repository") &&
|
||||
!strstr(wt_out, "ignore_worktrees is enabled") && !strstr(wt_out, "is indexed");
|
||||
free(wt_out);
|
||||
|
||||
/* The hook must read the runtime store. With the key off the note still
|
||||
* says to index; with it on, the same unindexed worktree must not. */
|
||||
cbm_config_t *cfg = cbm_config_open(cache);
|
||||
bool configured = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0;
|
||||
cbm_config_close(cfg);
|
||||
char *ignored_out = configured ? cbm_hook_augment_lifecycle_json(input) : NULL;
|
||||
bool honored = ignored_out && strstr(ignored_out, "ignore_worktrees is enabled") &&
|
||||
strstr(ignored_out, "do not run index_repository") &&
|
||||
!strstr(ignored_out, "is indexed");
|
||||
free(ignored_out);
|
||||
|
||||
/* A symlink to a directory inside that worktree has no lexical .git.
|
||||
* The hook must still follow the real path and honor ignore_worktrees. */
|
||||
char alias_target[600];
|
||||
snprintf(alias_target, sizeof(alias_target), "%s/nested", wtdir);
|
||||
bool alias_target_ok = test_mkdirp(alias_target) == 0;
|
||||
char alias[512];
|
||||
snprintf(alias, sizeof(alias), "%s/wt-alias", tmpdir);
|
||||
bool alias_made = alias_target_ok && symlink(alias_target, alias) == 0;
|
||||
snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}",
|
||||
alias);
|
||||
char *alias_out = alias_made ? cbm_hook_augment_lifecycle_json(input) : NULL;
|
||||
bool alias_ignored = alias_out && strstr(alias_out, "ignore_worktrees is enabled") &&
|
||||
strstr(alias_out, "do not run index_repository") &&
|
||||
!strstr(alias_out, "Run index_repository");
|
||||
free(alias_out);
|
||||
|
||||
/* A linked worktree nested inside the indexed checkout must not inherit
|
||||
* that parent graph once ignore_worktrees is on. */
|
||||
char insider[512];
|
||||
snprintf(insider, sizeof(insider), "%s/inside-wt", maindir);
|
||||
snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7inside", maindir,
|
||||
insider);
|
||||
bool inside_made = configured && system(cmd) == 0;
|
||||
char inside_sub[600];
|
||||
snprintf(inside_sub, sizeof(inside_sub), "%s/nested", insider);
|
||||
bool inside_sub_made = inside_made && test_mkdirp(inside_sub) == 0;
|
||||
snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}",
|
||||
inside_sub);
|
||||
char *inside_out = inside_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL;
|
||||
bool inside_ignored = inside_out && strstr(inside_out, "ignore_worktrees is enabled") &&
|
||||
strstr(inside_out, "do not run index_repository") &&
|
||||
!strstr(inside_out, "is indexed") && !strstr(inside_out, main_name);
|
||||
free(inside_out);
|
||||
|
||||
/* Indexing that nested worktree itself must still win over the parent. */
|
||||
char *inside_name = inside_made ? cbm_project_name_from_path(insider) : NULL;
|
||||
bool inside_stored = false;
|
||||
if (inside_name) {
|
||||
char db_inside[900];
|
||||
snprintf(db_inside, sizeof(db_inside), "%s/%s.db", cache, inside_name);
|
||||
cbm_store_t *si = cbm_store_open_path(db_inside);
|
||||
inside_stored = si && cbm_store_upsert_project(si, inside_name, insider) == CBM_STORE_OK;
|
||||
if (si)
|
||||
cbm_store_close(si);
|
||||
}
|
||||
char *exact_out = inside_stored ? cbm_hook_augment_lifecycle_json(input) : NULL;
|
||||
bool exact_kept = exact_out && inside_name && strstr(exact_out, inside_name) &&
|
||||
strstr(exact_out, "is indexed") &&
|
||||
!strstr(exact_out, "ignore_worktrees is enabled");
|
||||
free(exact_out);
|
||||
|
||||
/* An unindexed worktree nested inside that indexed worktree must not
|
||||
* inherit the outer worktree's graph. */
|
||||
char child[600];
|
||||
snprintf(child, sizeof(child), "%s/child-wt", insider);
|
||||
snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7child", maindir,
|
||||
child);
|
||||
bool child_made = inside_stored && system(cmd) == 0;
|
||||
char child_sub[700];
|
||||
snprintf(child_sub, sizeof(child_sub), "%s/sub", child);
|
||||
bool child_sub_made = child_made && test_mkdirp(child_sub) == 0;
|
||||
snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}",
|
||||
child_sub);
|
||||
char *child_out = child_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL;
|
||||
bool child_ignored = child_out && inside_name &&
|
||||
strstr(child_out, "ignore_worktrees is enabled") &&
|
||||
strstr(child_out, "do not run index_repository") &&
|
||||
!strstr(child_out, "is indexed") && !strstr(child_out, inside_name);
|
||||
free(child_out);
|
||||
|
||||
snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}",
|
||||
maindir);
|
||||
char *main_out = cbm_hook_augment_lifecycle_json(input);
|
||||
@@ -12688,6 +12771,7 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) {
|
||||
|
||||
restore_test_env("CBM_CACHE_DIR", saved_cache);
|
||||
free(main_name);
|
||||
free(inside_name);
|
||||
snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1",
|
||||
maindir, wtdir);
|
||||
(void)system(cmd);
|
||||
@@ -12695,10 +12779,110 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) {
|
||||
|
||||
if (!deliberate)
|
||||
FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes");
|
||||
if (!honored)
|
||||
FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note");
|
||||
if (!alias_ignored)
|
||||
FAIL("a symlink into an ignored worktree must not recommend index_repository");
|
||||
if (!inside_ignored)
|
||||
FAIL("a nested linked worktree must not inherit the indexed parent graph");
|
||||
if (!exact_kept)
|
||||
FAIL("an exact index of the nested worktree must still be reported");
|
||||
if (!child_ignored)
|
||||
FAIL("a worktree nested inside an indexed worktree must not inherit that graph");
|
||||
if (!main_ok)
|
||||
FAIL("the indexed main checkout must still resolve in the same matrix");
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* With ignore_worktrees unset, the lifecycle hook must do what it did before
|
||||
* the key existed. It may read the key, but it must not create a runtime
|
||||
* config store just to find the key unset, and it must not canonicalize the
|
||||
* cwd or probe for a linked worktree. Turning the key on is the positive
|
||||
* control: the same call then reaches the worktree gate exactly once. */
|
||||
TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work) {
|
||||
char tmpdir[256];
|
||||
char *raw = th_mktempdir("cbm_hook_wt_off");
|
||||
if (!raw)
|
||||
FAIL("th_mktempdir returned NULL");
|
||||
snprintf(tmpdir, sizeof(tmpdir), "%s", raw);
|
||||
char cache[512];
|
||||
char maindir[512];
|
||||
char wtdir[512];
|
||||
char store_path[600];
|
||||
snprintf(cache, sizeof(cache), "%s/cache", tmpdir);
|
||||
snprintf(maindir, sizeof(maindir), "%s/checkout-main", tmpdir);
|
||||
snprintf(wtdir, sizeof(wtdir), "%s/checkout-side", tmpdir);
|
||||
snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache);
|
||||
test_mkdirp(cache);
|
||||
test_mkdirp(maindir);
|
||||
|
||||
char cmd[2048];
|
||||
snprintf(cmd, sizeof(cmd),
|
||||
"cd \"%s\" && git init -q && git config user.email t@t && git config user.name t && "
|
||||
": > f.txt && git add f.txt && git commit -qm init && "
|
||||
"git worktree add -q \"%s\" -b wtoff",
|
||||
maindir, wtdir);
|
||||
if (system(cmd) != 0) {
|
||||
th_rmtree(tmpdir);
|
||||
SKIP_PLATFORM("git worktree add unavailable");
|
||||
}
|
||||
|
||||
char *saved_cache = save_test_env("CBM_CACHE_DIR");
|
||||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
char input[1024];
|
||||
snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir);
|
||||
struct stat st;
|
||||
int gate_calls = 0;
|
||||
cbm_hook_set_worktree_gate_counter_for_testing(&gate_calls);
|
||||
bool store_absent_before = stat(store_path, &st) != 0;
|
||||
char *off_out = cbm_hook_augment_lifecycle_json(input);
|
||||
bool off_note = off_out && strstr(off_out, "Run index_repository") &&
|
||||
!strstr(off_out, "ignore_worktrees is enabled");
|
||||
free(off_out);
|
||||
bool store_absent_after = stat(store_path, &st) != 0;
|
||||
int off_gate_calls = gate_calls;
|
||||
|
||||
/* A store that leaves the key unset may be read for the key, nothing more. */
|
||||
cbm_config_t *cfg = cbm_config_open(cache);
|
||||
bool other_key_set = cfg && cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "true") == 0;
|
||||
cbm_config_close(cfg);
|
||||
gate_calls = 0;
|
||||
char *unset_out = other_key_set ? cbm_hook_augment_lifecycle_json(input) : NULL;
|
||||
bool unset_note = unset_out && strstr(unset_out, "Run index_repository") &&
|
||||
!strstr(unset_out, "ignore_worktrees is enabled");
|
||||
free(unset_out);
|
||||
int unset_gate_calls = gate_calls;
|
||||
|
||||
cfg = cbm_config_open(cache);
|
||||
bool enabled = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0;
|
||||
cbm_config_close(cfg);
|
||||
gate_calls = 0;
|
||||
char *on_out = enabled ? cbm_hook_augment_lifecycle_json(input) : NULL;
|
||||
bool on_note = on_out && strstr(on_out, "ignore_worktrees is enabled");
|
||||
free(on_out);
|
||||
int on_gate_calls = gate_calls;
|
||||
cbm_hook_set_worktree_gate_counter_for_testing(NULL);
|
||||
|
||||
restore_test_env("CBM_CACHE_DIR", saved_cache);
|
||||
snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1",
|
||||
maindir, wtdir);
|
||||
(void)system(cmd);
|
||||
th_rmtree(tmpdir);
|
||||
|
||||
ASSERT_TRUE(store_absent_before);
|
||||
ASSERT_TRUE(off_note);
|
||||
if (!store_absent_after)
|
||||
FAIL("a default-off lifecycle event must not create the runtime config store");
|
||||
ASSERT_EQ(off_gate_calls, 0);
|
||||
ASSERT_TRUE(other_key_set);
|
||||
ASSERT_TRUE(unset_note);
|
||||
ASSERT_EQ(unset_gate_calls, 0);
|
||||
ASSERT_TRUE(enabled);
|
||||
ASSERT_TRUE(on_note);
|
||||
ASSERT_EQ(on_gate_calls, 1);
|
||||
PASS();
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST(cli_hook_session_sanitizes_untrusted_project_metadata) {
|
||||
@@ -15172,14 +15356,18 @@ TEST(cli_hook_augment_subagent_tier_router_contract) {
|
||||
}
|
||||
|
||||
TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only) {
|
||||
const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart");
|
||||
const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart");
|
||||
const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart", false);
|
||||
const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart", false);
|
||||
const char *worktree = cbm_hook_no_project_index_guidance_for_testing("SessionStart", true);
|
||||
ASSERT_NOT_NULL(session);
|
||||
ASSERT_NOT_NULL(subagent);
|
||||
ASSERT_NOT_NULL(worktree);
|
||||
ASSERT(strstr(session, "Run index_repository") != NULL);
|
||||
ASSERT(strstr(subagent, "Ask the parent agent to run index_repository") != NULL);
|
||||
ASSERT(strstr(subagent, "do not attempt graph mutation") != NULL);
|
||||
ASSERT(strstr(subagent, "Run index_repository") == NULL);
|
||||
ASSERT(strstr(worktree, "ignore_worktrees is enabled") != NULL);
|
||||
ASSERT(strstr(worktree, "do not run index_repository") != NULL);
|
||||
PASS();
|
||||
}
|
||||
|
||||
@@ -19282,6 +19470,19 @@ TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cli_ignore_worktrees_config_key_is_discoverable) {
|
||||
bool listed = false;
|
||||
for (size_t i = 0; i < cbm_cli_config_key_count_for_testing(); i++) {
|
||||
const char *key = cbm_cli_config_key_at_for_testing(i);
|
||||
if (key && strcmp(key, CBM_CONFIG_IGNORE_WORKTREES) == 0) {
|
||||
listed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ASSERT_TRUE(listed);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554) {
|
||||
const cbm_skill_t *sk = cbm_get_skills();
|
||||
ASSERT_NOT_NULL(sk);
|
||||
@@ -20165,6 +20366,7 @@ SUITE(cli) {
|
||||
RUN_TEST(cli_update_download_failure_does_not_quiesce_sessions);
|
||||
RUN_TEST(cli_update_already_current_does_not_quiesce_sessions);
|
||||
RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558);
|
||||
RUN_TEST(cli_ignore_worktrees_config_key_is_discoverable);
|
||||
RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554);
|
||||
RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566);
|
||||
RUN_TEST(cli_install_managed_binary_config_path_issue2722);
|
||||
@@ -20405,6 +20607,7 @@ SUITE(cli) {
|
||||
#ifndef _WIN32
|
||||
RUN_TEST(cli_hook_worktree_cwd_resolves_own_indexed_project);
|
||||
RUN_TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent);
|
||||
RUN_TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work);
|
||||
#endif
|
||||
RUN_TEST(cli_hook_session_sanitizes_untrusted_project_metadata);
|
||||
RUN_TEST(cli_hook_session_untrusted_label_covers_project_name_only);
|
||||
|
||||
@@ -3581,6 +3581,222 @@ TEST(daemon_ipc_posix_lifetime_reservation_rejects_fork_inheritance) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
static bool ipc_test_lifetime_lock_path(char out[TEST_PATH_CAP], const char *runtime_dir,
|
||||
const char *key) {
|
||||
int written = runtime_dir && key
|
||||
? snprintf(out, TEST_PATH_CAP, "%s/cbm-%s.lifetime.lock", runtime_dir, key)
|
||||
: -1;
|
||||
return written > 0 && written < TEST_PATH_CAP;
|
||||
}
|
||||
|
||||
/* Child-process verdict on whether the lifetime lock at lock_path is held by
|
||||
* someone else, going straight through open()/fcntl() rather than any of
|
||||
* this codebase's APIs, so a genuinely separate process cannot inherit the
|
||||
* parent's in-process claim registry. Distinguishes three outcomes instead
|
||||
* of collapsing "held" and "could not even check" into the same value:
|
||||
* IPC_TEST_LOCK_HELD only when open() succeeded and F_SETLK failed with
|
||||
* EACCES or EAGAIN (the lock is held by another process, as expected);
|
||||
* IPC_TEST_LOCK_TAKEN when F_SETLK succeeded (the lock was free, a bug);
|
||||
* IPC_TEST_LOCK_ERROR for anything else (open() failed, e.g. a wrong path,
|
||||
* or F_SETLK failed with an errno that says nothing about the lock's
|
||||
* state), which must never be mistaken for IPC_TEST_LOCK_HELD. */
|
||||
enum {
|
||||
IPC_TEST_LOCK_HELD = 0,
|
||||
IPC_TEST_LOCK_TAKEN = 1,
|
||||
IPC_TEST_LOCK_ERROR = 3,
|
||||
};
|
||||
|
||||
static uint8_t ipc_test_lock_child_verdict(const char *lock_path) {
|
||||
int fd = open(lock_path, O_RDWR);
|
||||
if (fd < 0) {
|
||||
return IPC_TEST_LOCK_ERROR;
|
||||
}
|
||||
struct flock record_lock = {
|
||||
.l_type = F_WRLCK,
|
||||
.l_whence = SEEK_SET,
|
||||
.l_start = 0,
|
||||
.l_len = 0,
|
||||
};
|
||||
int lock_result;
|
||||
do {
|
||||
lock_result = fcntl(fd, F_SETLK, &record_lock);
|
||||
} while (lock_result != 0 && errno == EINTR);
|
||||
int lock_errno = errno;
|
||||
(void)close(fd);
|
||||
if (lock_result == 0) {
|
||||
return IPC_TEST_LOCK_TAKEN;
|
||||
}
|
||||
return lock_errno == EACCES || lock_errno == EAGAIN ? IPC_TEST_LOCK_HELD : IPC_TEST_LOCK_ERROR;
|
||||
}
|
||||
|
||||
TEST(daemon_ipc_posix_lifetime_reservation_probe_does_not_drop_held_lock) {
|
||||
static const char key[] = "c1c1d2d2e3e3f4f4";
|
||||
char parent[TEST_PATH_CAP] = {0};
|
||||
char runtime_dir[TEST_PATH_CAP] = {0};
|
||||
char lock_path[TEST_PATH_CAP] = {0};
|
||||
cbm_daemon_ipc_endpoint_t *endpoint = NULL;
|
||||
cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL;
|
||||
int result_pipe[2] = {-1, -1};
|
||||
pid_t child = -1;
|
||||
uint8_t child_result = 0;
|
||||
int child_status = -1;
|
||||
int acquired = -1;
|
||||
int held_after_first_probe = -1;
|
||||
int held_after_second_probe = -1;
|
||||
int free_after_release = -1;
|
||||
bool lock_path_ok = false;
|
||||
|
||||
if (ipc_test_parent_new(parent, "probe-holds-lock")) {
|
||||
endpoint = cbm_daemon_ipc_endpoint_new(key, parent);
|
||||
}
|
||||
if (endpoint) {
|
||||
ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint));
|
||||
lock_path_ok = ipc_test_lifetime_lock_path(lock_path, runtime_dir, key);
|
||||
acquired = cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &reservation);
|
||||
}
|
||||
if (reservation) {
|
||||
/* Two probes, matching how a real daemon polls its own reservation
|
||||
* repeatedly while running. Each probe of an already-held lock used
|
||||
* to open a throwaway fd on the lock file and close it. Since
|
||||
* fcntl(2) record locks are scoped to (process, inode), that close
|
||||
* silently released the real lock every time, regardless of the
|
||||
* still-open fd the reservation itself is holding. */
|
||||
held_after_first_probe = cbm_daemon_ipc_lifetime_reservation_probe(endpoint);
|
||||
held_after_second_probe = cbm_daemon_ipc_lifetime_reservation_probe(endpoint);
|
||||
}
|
||||
if (reservation && lock_path_ok && pipe(result_pipe) == 0) {
|
||||
child = fork();
|
||||
}
|
||||
if (child == 0) {
|
||||
/* A genuinely independent process, not going through any of this
|
||||
* codebase's APIs so it cannot inherit the parent's in-process
|
||||
* claim registry: try to take the real OS-level lock directly. If
|
||||
* the parent's fcntl lock survived the probes above, this fails. */
|
||||
(void)close(result_pipe[0]);
|
||||
child_result = ipc_test_lock_child_verdict(lock_path);
|
||||
bool reported = ipc_test_fd_write_all(result_pipe[1], &child_result, sizeof(child_result));
|
||||
(void)close(result_pipe[1]);
|
||||
_exit(reported ? 0 : 1);
|
||||
}
|
||||
if (child > 0) {
|
||||
(void)close(result_pipe[1]);
|
||||
result_pipe[1] = -1;
|
||||
bool received = ipc_test_fd_read_all(result_pipe[0], &child_result, sizeof(child_result));
|
||||
(void)close(result_pipe[0]);
|
||||
result_pipe[0] = -1;
|
||||
if (!received) {
|
||||
child_result = 2;
|
||||
}
|
||||
while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {}
|
||||
}
|
||||
cbm_daemon_ipc_lifetime_reservation_release(reservation);
|
||||
if (endpoint) {
|
||||
free_after_release = cbm_daemon_ipc_lifetime_reservation_probe(endpoint);
|
||||
}
|
||||
for (size_t index = 0; index < 2; index++) {
|
||||
if (result_pipe[index] >= 0) {
|
||||
(void)close(result_pipe[index]);
|
||||
}
|
||||
}
|
||||
cbm_daemon_ipc_endpoint_free(endpoint);
|
||||
ipc_test_remove_tree(runtime_dir, parent);
|
||||
|
||||
ASSERT_EQ(acquired, 1);
|
||||
ASSERT_TRUE(lock_path_ok);
|
||||
ASSERT_EQ(held_after_first_probe, 1);
|
||||
ASSERT_EQ(held_after_second_probe, 1);
|
||||
ASSERT_GT(child, 0);
|
||||
ASSERT_TRUE(WIFEXITED(child_status));
|
||||
ASSERT_EQ(WEXITSTATUS(child_status), 0);
|
||||
ASSERT_EQ(child_result, IPC_TEST_LOCK_HELD);
|
||||
ASSERT_EQ(free_after_release, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_ipc_posix_lifetime_reservation_try_acquire_again_does_not_drop_held_lock) {
|
||||
static const char key[] = "a1a1b2b2c3c3d4d4";
|
||||
char parent[TEST_PATH_CAP] = {0};
|
||||
char runtime_dir[TEST_PATH_CAP] = {0};
|
||||
char lock_path[TEST_PATH_CAP] = {0};
|
||||
cbm_daemon_ipc_endpoint_t *endpoint = NULL;
|
||||
cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL;
|
||||
cbm_daemon_ipc_lifetime_reservation_t *second_reservation =
|
||||
(cbm_daemon_ipc_lifetime_reservation_t *)(uintptr_t)1;
|
||||
int result_pipe[2] = {-1, -1};
|
||||
pid_t child = -1;
|
||||
uint8_t child_result = 0;
|
||||
int child_status = -1;
|
||||
int acquired = -1;
|
||||
int reacquired = -1;
|
||||
int free_after_release = -1;
|
||||
bool lock_path_ok = false;
|
||||
|
||||
if (ipc_test_parent_new(parent, "try-acquire-again-holds-lock")) {
|
||||
endpoint = cbm_daemon_ipc_endpoint_new(key, parent);
|
||||
}
|
||||
if (endpoint) {
|
||||
ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint));
|
||||
lock_path_ok = ipc_test_lifetime_lock_path(lock_path, runtime_dir, key);
|
||||
acquired = cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &reservation);
|
||||
}
|
||||
if (reservation) {
|
||||
/* This process already holds the lifetime lock. Calling try_acquire
|
||||
* again, still in this process, must trust the in-process registry
|
||||
* alone and return 0 (already held, nothing new to hand back)
|
||||
* without touching the lock file: opening and closing a second fd
|
||||
* on it would drop the fcntl(2) record lock this process already
|
||||
* holds, exactly the way the probe's own fast path used to. */
|
||||
reacquired = cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &second_reservation);
|
||||
}
|
||||
if (reservation && lock_path_ok && pipe(result_pipe) == 0) {
|
||||
child = fork();
|
||||
}
|
||||
if (child == 0) {
|
||||
/* A genuinely independent process, not going through any of this
|
||||
* codebase's APIs so it cannot inherit the parent's in-process
|
||||
* claim registry: try to take the real OS-level lock directly. If
|
||||
* the repeated try_acquire above survived, this fails. */
|
||||
(void)close(result_pipe[0]);
|
||||
child_result = ipc_test_lock_child_verdict(lock_path);
|
||||
bool reported = ipc_test_fd_write_all(result_pipe[1], &child_result, sizeof(child_result));
|
||||
(void)close(result_pipe[1]);
|
||||
_exit(reported ? 0 : 1);
|
||||
}
|
||||
if (child > 0) {
|
||||
(void)close(result_pipe[1]);
|
||||
result_pipe[1] = -1;
|
||||
bool received = ipc_test_fd_read_all(result_pipe[0], &child_result, sizeof(child_result));
|
||||
(void)close(result_pipe[0]);
|
||||
result_pipe[0] = -1;
|
||||
if (!received) {
|
||||
child_result = 2;
|
||||
}
|
||||
while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {}
|
||||
}
|
||||
cbm_daemon_ipc_lifetime_reservation_release(reservation);
|
||||
if (endpoint) {
|
||||
free_after_release = cbm_daemon_ipc_lifetime_reservation_probe(endpoint);
|
||||
}
|
||||
for (size_t index = 0; index < 2; index++) {
|
||||
if (result_pipe[index] >= 0) {
|
||||
(void)close(result_pipe[index]);
|
||||
}
|
||||
}
|
||||
cbm_daemon_ipc_endpoint_free(endpoint);
|
||||
ipc_test_remove_tree(runtime_dir, parent);
|
||||
|
||||
ASSERT_EQ(acquired, 1);
|
||||
ASSERT_TRUE(lock_path_ok);
|
||||
ASSERT_EQ(reacquired, 0);
|
||||
ASSERT_TRUE(second_reservation == NULL);
|
||||
ASSERT_GT(child, 0);
|
||||
ASSERT_TRUE(WIFEXITED(child_status));
|
||||
ASSERT_EQ(WEXITSTATUS(child_status), 0);
|
||||
ASSERT_EQ(child_result, IPC_TEST_LOCK_HELD);
|
||||
ASSERT_EQ(free_after_release, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_ipc_posix_child_participant_handoff_retains_legacy_bridge) {
|
||||
#ifdef _WIN32
|
||||
PASS();
|
||||
@@ -5999,6 +6215,8 @@ SUITE(daemon_ipc) {
|
||||
#endif
|
||||
RUN_TEST(daemon_ipc_posix_startup_lock_is_cross_process);
|
||||
RUN_TEST(daemon_ipc_posix_lifetime_reservation_rejects_fork_inheritance);
|
||||
RUN_TEST(daemon_ipc_posix_lifetime_reservation_probe_does_not_drop_held_lock);
|
||||
RUN_TEST(daemon_ipc_posix_lifetime_reservation_try_acquire_again_does_not_drop_held_lock);
|
||||
RUN_TEST(daemon_ipc_posix_child_participant_handoff_retains_legacy_bridge);
|
||||
RUN_TEST(daemon_ipc_posix_publication_boundaries_recover_from_crash);
|
||||
RUN_TEST(daemon_ipc_posix_record_publication_windows_recover_from_crash);
|
||||
|
||||
@@ -7149,6 +7149,177 @@ TEST(extract_ts_nestjs_controller_routes_issue1428) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #759: the one definition named `name`; NULL for none or more than one. */
|
||||
static const CBMDefinition *nextjs_only_def(CBMFileResult *r, const char *name) {
|
||||
const CBMDefinition *found = NULL;
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0) {
|
||||
if (found) {
|
||||
return NULL;
|
||||
}
|
||||
found = &r->defs.items[i];
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/* #759: a Next.js App Router route file's exported HTTP verb handlers are the
|
||||
* Functions extraction already makes for them, now carrying the route path of
|
||||
* the file's directories -- `[id]` as `:id`, `[...slug]` as `*slug`, a
|
||||
* `(group)` adding nothing -- and the verb as their method. A local
|
||||
* declaration exported by name (`export { GET }`) is that same Function; no
|
||||
* second definition appears. The 255-byte path is the longest that fits. */
|
||||
TEST(extract_nextjs_route_metadata_contract_issue759) {
|
||||
char long_file[CBM_SZ_512];
|
||||
char long_path[CBM_SZ_256];
|
||||
char segment[CBM_SZ_256];
|
||||
memset(segment, 'x', CBM_SZ_256 - PAIR_LEN);
|
||||
segment[CBM_SZ_256 - PAIR_LEN] = '\0';
|
||||
snprintf(long_file, sizeof(long_file), "app/%s/route.ts", segment);
|
||||
snprintf(long_path, sizeof(long_path), "/%s", segment);
|
||||
static const char verbs_src[] = "export async function GET() {}\n"
|
||||
"export async function POST() {}\n"
|
||||
"export function PUT() {}\n"
|
||||
"export function PATCH() {}\n"
|
||||
"export function DELETE() {}\n"
|
||||
"export function HEAD() {}\n"
|
||||
"export function OPTIONS() {}\n";
|
||||
const struct {
|
||||
const char *file;
|
||||
const char *src;
|
||||
const char *path;
|
||||
const char *names[CBM_SZ_8];
|
||||
} cases[] = {
|
||||
{"src/app/api/users/[id]/route.ts",
|
||||
"export function GET() { return 1; }\nexport const POST = async () => 2;\n",
|
||||
"/api/users/:id",
|
||||
{"GET", "POST"}},
|
||||
{"app/api/verbs/route.ts",
|
||||
verbs_src,
|
||||
"/api/verbs",
|
||||
{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}},
|
||||
{"src/app/(public)/api/[org]/users/[id]/route.ts",
|
||||
"export async function GET() { return 1; }\n",
|
||||
"/api/:org/users/:id",
|
||||
{"GET"}},
|
||||
{"app/api/[...slug]/route.ts", "export function GET() {}\n", "/api/*slug", {"GET"}},
|
||||
{"app/api/typed/route.ts",
|
||||
"type Handler = () => Promise<number>;\nexport const GET: Handler = async () => 1;\n",
|
||||
"/api/typed",
|
||||
{"GET"}},
|
||||
{"app/route.js", "export function GET() { return 1; }\n", "/", {"GET"}},
|
||||
{"app/api/local/route.ts",
|
||||
"function GET() { return 1; }\nconst POST = async () => 2;\nexport { GET, POST };\n",
|
||||
"/api/local",
|
||||
{"GET", "POST"}},
|
||||
{long_file, "export function GET() {}\n", long_path, {"GET"}},
|
||||
};
|
||||
for (size_t c = 0; c < sizeof(cases) / sizeof(cases[0]); c++) {
|
||||
CBMLanguage lang = strstr(cases[c].file, ".js") ? CBM_LANG_JAVASCRIPT : CBM_LANG_TYPESCRIPT;
|
||||
CBMFileResult *r = extract(cases[c].src, lang, "t", cases[c].file);
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
int routed = 0;
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
routed += r->defs.items[i].route_path != NULL;
|
||||
}
|
||||
int want = 0;
|
||||
for (; want < CBM_SZ_8 && cases[c].names[want]; want++) {
|
||||
const CBMDefinition *d = nextjs_only_def(r, cases[c].names[want]);
|
||||
ASSERT_NOT_NULL(d);
|
||||
ASSERT_STR_EQ(d->label, "Function");
|
||||
ASSERT_STR_EQ(d->file_path, cases[c].file);
|
||||
ASSERT_NOT_NULL(d->route_path);
|
||||
ASSERT_STR_EQ(d->route_path, cases[c].path);
|
||||
ASSERT_STR_EQ(d->route_method, cases[c].names[want]);
|
||||
}
|
||||
ASSERT_EQ(routed, want);
|
||||
cbm_free_result(r);
|
||||
}
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #759: nothing else routes. Not a route file, or a directory the route path
|
||||
* cannot spell -- an optional catch-all, a private folder, a parallel slot,
|
||||
* an intercepting route, a malformed bracket, a workspace package root, a path
|
||||
* past 255 bytes. Not a runtime export of an HTTP verb Function of this file:
|
||||
* an unexported, default, type-only or nested GET, a class method, a
|
||||
* constant, a lower-case verb; and no handler is made up for a renamed,
|
||||
* re-exported, wrapped or destructured export, or a `function` expression
|
||||
* bound to a const: none of them has a Function of its own here. */
|
||||
TEST(extract_nextjs_rejects_nonroutes_issue759) {
|
||||
char long_file[CBM_SZ_512];
|
||||
char segment[CBM_SZ_256];
|
||||
memset(segment, 'x', CBM_SZ_256 - SKIP_ONE);
|
||||
segment[CBM_SZ_256 - SKIP_ONE] = '\0';
|
||||
snprintf(long_file, sizeof(long_file), "app/%s/route.ts", segment);
|
||||
const char *files[] = {
|
||||
"pages/api/route.ts",
|
||||
"app/api/page.ts",
|
||||
"app/api/route.test.ts",
|
||||
"app/api/view/route.tsx",
|
||||
"app/api/view/route.jsx",
|
||||
"app/api/module/route.mjs",
|
||||
"app/_private/route.ts",
|
||||
"src/app/api/_lib/route.ts",
|
||||
"app/@modal/route.ts",
|
||||
"app/(.)photo/route.ts",
|
||||
"app/(..)(..)photo/route.ts",
|
||||
"app/api/[[...opt]]/route.ts",
|
||||
"app/api/[broken/route.ts",
|
||||
"app/api/broken]/route.ts",
|
||||
"app/api/[]/route.ts",
|
||||
"app/api/[...]/route.ts",
|
||||
"app/api/a[id]/route.ts",
|
||||
"app/()/route.ts",
|
||||
"apps/web/app/api/route.ts",
|
||||
long_file,
|
||||
};
|
||||
const struct {
|
||||
const char *file;
|
||||
const char *src;
|
||||
} sources[] = {
|
||||
{"app/api/nonexports/route.ts", "function GET() {}\n"
|
||||
"export default function POST() {}\n"
|
||||
"function PUT() {}\n"
|
||||
"export type { PUT };\n"
|
||||
"function OPTIONS() {}\n"
|
||||
"export { type OPTIONS };\n"
|
||||
"export class Handlers { PATCH() {} }\n"
|
||||
"export const DELETE = 42;\n"
|
||||
"export function get() {}\n"
|
||||
"export function helper() { function HEAD() {} }\n"},
|
||||
{"app/api/bindings/route.ts", "const handler = async () => 1;\n"
|
||||
"export { handler as GET };\n"
|
||||
"export { POST } from './impl';\n"
|
||||
"export const PUT = wrap(handler);\n"
|
||||
"export const DELETE = function () {};\n"
|
||||
"export const { PATCH } = handlers;\n"
|
||||
"export const HEAD = handler as Handler;\n"
|
||||
"export const OPTIONS = (async () => 1) satisfies Handler;\n"
|
||||
"export * from './all';\n"},
|
||||
};
|
||||
const int file_count = (int)(sizeof(files) / sizeof(files[0]));
|
||||
const int source_count = (int)(sizeof(sources) / sizeof(sources[0]));
|
||||
for (int c = 0; c < file_count + source_count; c++) {
|
||||
bool by_file = c < file_count;
|
||||
const char *file = by_file ? files[c] : sources[c - file_count].file;
|
||||
CBMFileResult *r =
|
||||
extract(by_file ? "export function GET() {}\n" : sources[c - file_count].src,
|
||||
CBM_LANG_TYPESCRIPT, "t", file);
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_FALSE(r->has_error);
|
||||
if (by_file) {
|
||||
ASSERT_NOT_NULL(nextjs_only_def(r, "GET"));
|
||||
}
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
ASSERT_NULL(r->defs.items[i].route_path);
|
||||
}
|
||||
cbm_free_result(r);
|
||||
}
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Return the file's Module definition (extraction pushes it first), or NULL. */
|
||||
static const CBMDefinition *find_module_def(CBMFileResult *r) {
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
@@ -15315,6 +15486,8 @@ SUITE(extraction) {
|
||||
RUN_TEST(extract_java_jaxrs_empty_path_means_class_path);
|
||||
RUN_TEST(extract_java_spring_relative_string_not_route_path);
|
||||
RUN_TEST(extract_ts_nestjs_controller_routes_issue1428);
|
||||
RUN_TEST(extract_nextjs_route_metadata_contract_issue759);
|
||||
RUN_TEST(extract_nextjs_rejects_nonroutes_issue759);
|
||||
RUN_TEST(extract_blazor_page_directive_routes_component);
|
||||
RUN_TEST(extract_blazor_component_without_page_has_no_route);
|
||||
RUN_TEST(extract_razor_page_directive_routes_cshtml_view);
|
||||
|
||||
@@ -346,6 +346,95 @@ TEST(canonical_root_linked_worktree) {
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
/* ── cbm_git_is_linked_worktree ─────────────────────────────────── */
|
||||
/* Detection backing the `ignore_worktrees` config gate. The predicate must be
|
||||
* TRUE only for a linked worktree (`git worktree add`) — never for the main
|
||||
* checkout, a plain directory, or a submodule. The submodule case is the one a
|
||||
* naive "is .git a regular file?" check gets wrong: a submodule's .git is also a
|
||||
* gitlink file, and it is separated here by the absence of a `commondir` entry
|
||||
* in the pointed-at gitdir. */
|
||||
|
||||
TEST(is_linked_worktree_true_for_linked_worktree) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git worktree test not implemented for Windows");
|
||||
#else
|
||||
/* th_mktempdir() returns a static buffer — copy before the second call. */
|
||||
char main_tmp[256];
|
||||
char *raw = th_mktempdir("cbm_wt_main");
|
||||
if (!raw) FAIL("th_mktempdir returned NULL");
|
||||
strncpy(main_tmp, raw, sizeof(main_tmp) - 1);
|
||||
main_tmp[sizeof(main_tmp) - 1] = '\0';
|
||||
|
||||
char wt_tmp[256];
|
||||
raw = th_mktempdir("cbm_wt_linked");
|
||||
if (!raw) FAIL("th_mktempdir returned NULL");
|
||||
strncpy(wt_tmp, raw, sizeof(wt_tmp) - 1);
|
||||
wt_tmp[sizeof(wt_tmp) - 1] = '\0';
|
||||
th_rmtree(wt_tmp); /* git worktree add creates it */
|
||||
|
||||
if (make_git_repo(main_tmp) != 0) {
|
||||
th_rmtree(main_tmp);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
if (git_run(main_tmp, "branch wt-branch") != 0) {
|
||||
th_rmtree(main_tmp);
|
||||
FAIL("failed to create branch for worktree");
|
||||
}
|
||||
char wt_cmd[1024];
|
||||
snprintf(wt_cmd, sizeof(wt_cmd), "worktree add \"%s\" wt-branch", wt_tmp);
|
||||
if (git_run(main_tmp, wt_cmd) != 0) {
|
||||
th_rmtree(wt_tmp);
|
||||
th_rmtree(main_tmp);
|
||||
SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)");
|
||||
}
|
||||
|
||||
bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp);
|
||||
/* A subdirectory has no .git of its own. The predicate must still see the
|
||||
* linked worktree above it, or ignore_worktrees misses nested sessions. */
|
||||
char nested[1024];
|
||||
snprintf(nested, sizeof(nested), "%s/nested/deeper", wt_tmp);
|
||||
bool nested_ok = th_mkdir_p(nested) == 0;
|
||||
bool nested_detected = nested_ok && cbm_git_is_linked_worktree(nested);
|
||||
/* The MAIN checkout of the very same repo must NOT be flagged — otherwise
|
||||
* enabling ignore_worktrees would stop indexing ordinary repositories. */
|
||||
bool main_detected = cbm_git_is_linked_worktree(main_tmp);
|
||||
char main_nested[1024];
|
||||
snprintf(main_nested, sizeof(main_nested), "%s/src", main_tmp);
|
||||
bool main_nested_ok = th_mkdir_p(main_nested) == 0;
|
||||
bool main_nested_detected = main_nested_ok && cbm_git_is_linked_worktree(main_nested);
|
||||
/* A worktree nested inside another must not share the parent's anchor. */
|
||||
char inner[1024];
|
||||
snprintf(inner, sizeof(inner), "%s/inner-wt", wt_tmp);
|
||||
char inner_cmd[1200];
|
||||
snprintf(inner_cmd, sizeof(inner_cmd), "worktree add -b inner-branch \"%s\"", inner);
|
||||
bool inner_added = git_run(main_tmp, inner_cmd) == 0;
|
||||
char inner_sub[1200];
|
||||
snprintf(inner_sub, sizeof(inner_sub), "%s/sub", inner);
|
||||
bool inner_sub_ok = inner_added && th_mkdir_p(inner_sub) == 0;
|
||||
bool same_outer = cbm_git_same_linked_worktree(nested, wt_tmp);
|
||||
bool same_inner = inner_sub_ok && cbm_git_same_linked_worktree(inner_sub, inner);
|
||||
bool not_parent = inner_sub_ok && !cbm_git_same_linked_worktree(inner_sub, wt_tmp);
|
||||
bool not_main = !cbm_git_same_linked_worktree(nested, main_tmp);
|
||||
|
||||
git_run(main_tmp, "worktree prune");
|
||||
th_rmtree(main_tmp);
|
||||
th_rmtree(wt_tmp);
|
||||
|
||||
ASSERT(worktree_detected);
|
||||
ASSERT(nested_ok);
|
||||
ASSERT(nested_detected);
|
||||
ASSERT(!main_detected);
|
||||
ASSERT(main_nested_ok);
|
||||
ASSERT(!main_nested_detected);
|
||||
ASSERT(inner_added);
|
||||
ASSERT(same_outer);
|
||||
ASSERT(same_inner);
|
||||
ASSERT(not_parent);
|
||||
ASSERT(not_main);
|
||||
PASS();
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
TEST(git_context_ignores_inherited_repo_env) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI");
|
||||
@@ -403,6 +492,68 @@ TEST(git_context_ignores_inherited_repo_env) {
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
TEST(is_linked_worktree_false_for_submodule_and_nongit) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git worktree test not implemented for Windows");
|
||||
#else
|
||||
char super_tmp[256];
|
||||
char *raw = th_mktempdir("cbm_wt_super");
|
||||
if (!raw) FAIL("th_mktempdir returned NULL");
|
||||
strncpy(super_tmp, raw, sizeof(super_tmp) - 1);
|
||||
super_tmp[sizeof(super_tmp) - 1] = '\0';
|
||||
|
||||
char child_tmp[256];
|
||||
raw = th_mktempdir("cbm_wt_child");
|
||||
if (!raw) FAIL("th_mktempdir returned NULL");
|
||||
strncpy(child_tmp, raw, sizeof(child_tmp) - 1);
|
||||
child_tmp[sizeof(child_tmp) - 1] = '\0';
|
||||
|
||||
if (make_git_repo(super_tmp) != 0 || make_git_repo(child_tmp) != 0) {
|
||||
th_rmtree(super_tmp);
|
||||
th_rmtree(child_tmp);
|
||||
SKIP_PLATFORM("git not available to init a repo");
|
||||
}
|
||||
|
||||
/* A plain directory that is not a git repo at all. */
|
||||
char plain[1024];
|
||||
snprintf(plain, sizeof(plain), "%s/plain", super_tmp);
|
||||
if (th_mkdir_p(plain) != 0) {
|
||||
th_rmtree(super_tmp);
|
||||
th_rmtree(child_tmp);
|
||||
FAIL("failed to create plain dir");
|
||||
}
|
||||
bool plain_detected = cbm_git_is_linked_worktree(plain);
|
||||
|
||||
/* file:// submodules are refused by default since the CVE-2022-39253 fix. */
|
||||
char sub_cmd[1024];
|
||||
snprintf(sub_cmd, sizeof(sub_cmd),
|
||||
"-c protocol.file.allow=always submodule add -q \"%s\" subm", child_tmp);
|
||||
int sub_rc = git_run(super_tmp, sub_cmd);
|
||||
|
||||
char subm[1024];
|
||||
snprintf(subm, sizeof(subm), "%s/subm", super_tmp);
|
||||
bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm);
|
||||
char sub_nested[1024];
|
||||
snprintf(sub_nested, sizeof(sub_nested), "%s/nested", subm);
|
||||
bool sub_nested_made = sub_rc == 0 && th_mkdir_p(sub_nested) == 0;
|
||||
bool sub_nested_detected = sub_nested_made && cbm_git_is_linked_worktree(sub_nested);
|
||||
|
||||
th_rmtree(super_tmp);
|
||||
th_rmtree(child_tmp);
|
||||
|
||||
ASSERT(!plain_detected);
|
||||
if (sub_rc != 0) {
|
||||
SKIP_PLATFORM("git submodule add unavailable in this environment");
|
||||
}
|
||||
/* A submodule gitlink has no commondir, including a subdirectory which
|
||||
* must not walk into the superproject. */
|
||||
ASSERT(!submodule_detected);
|
||||
ASSERT(sub_nested_made);
|
||||
ASSERT(!sub_nested_detected);
|
||||
PASS();
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
TEST(githistory_ignores_inherited_repo_env) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI");
|
||||
@@ -503,6 +654,8 @@ SUITE(git_context) {
|
||||
RUN_TEST(canonical_root_repo_root);
|
||||
RUN_TEST(canonical_root_subdir);
|
||||
RUN_TEST(canonical_root_linked_worktree);
|
||||
RUN_TEST(is_linked_worktree_true_for_linked_worktree);
|
||||
RUN_TEST(is_linked_worktree_false_for_submodule_and_nongit);
|
||||
RUN_TEST(git_context_ignores_inherited_repo_env);
|
||||
RUN_TEST(githistory_ignores_inherited_repo_env);
|
||||
}
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
mkdir -p "$WORK/bin" "$WORK/home"
|
||||
cat >"$WORK/bin/uname" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
case "$1" in
|
||||
-s) printf '%s\n' Linux ;;
|
||||
-m) printf '%s\n' aarch64 ;;
|
||||
-o) printf '%s\n' Android ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
EOF
|
||||
cat >"$WORK/bin/curl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf 'curl must not run for Android/Termux\n' >&2
|
||||
exit 99
|
||||
EOF
|
||||
chmod +x "$WORK/bin/uname" "$WORK/bin/curl"
|
||||
|
||||
# The stubs must be what install.sh sees, or this test proves nothing.
|
||||
if [ "$(PATH="$WORK/bin:$PATH" uname -o)" != "Android" ]; then
|
||||
printf 'FAIL: the uname stub is not picked up from PATH\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
output=$(PATH="$WORK/bin:$PATH" HOME="$WORK/home" \
|
||||
CBM_DOWNLOAD_URL="http://127.0.0.1:9/cbm-android-guard-test" \
|
||||
bash "$ROOT/install.sh" --skip-config 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
printf 'FAIL: installer accepted Android/Termux\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq 'Android/Termux' <<<"$output"; then
|
||||
printf 'FAIL: missing Android/Termux guidance\n%s\n' "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Fq 'curl must not run' <<<"$output"; then
|
||||
printf 'FAIL: installer downloaded before rejecting Android/Termux\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'PASS: Android/Termux is rejected before download\n'
|
||||
@@ -1214,6 +1214,57 @@ TEST(mcp_initialize_response) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The lifecycle hook reads ignore_worktrees through this reader. It must never
|
||||
* attach a store to the server (the daemon lends its session server) and never
|
||||
* create the runtime store; a server with its own store answers from it. */
|
||||
TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone) {
|
||||
char *raw = th_mktempdir("cbm_mcp_runtime_config");
|
||||
ASSERT_NOT_NULL(raw);
|
||||
char cache[256];
|
||||
snprintf(cache, sizeof(cache), "%s", raw);
|
||||
char own_dir[320];
|
||||
snprintf(own_dir, sizeof(own_dir), "%s/own", cache);
|
||||
char store_path[400];
|
||||
snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache);
|
||||
const char *saved = getenv("CBM_CACHE_DIR");
|
||||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
|
||||
struct stat st;
|
||||
bool absent_off = server && !cbm_mcp_ignore_worktrees_runtime_enabled(server);
|
||||
bool still_absent = stat(store_path, &st) != 0;
|
||||
|
||||
cbm_config_t *runtime = cbm_config_open(cache);
|
||||
bool stored = runtime && cbm_config_set(runtime, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0;
|
||||
cbm_config_close(runtime);
|
||||
bool runtime_on = server && cbm_mcp_ignore_worktrees_runtime_enabled(server);
|
||||
bool server_untouched = server && !cbm_mcp_ignore_worktrees_enabled(server);
|
||||
|
||||
cbm_config_t *own = cbm_config_open(own_dir);
|
||||
if (server && own) {
|
||||
cbm_mcp_server_set_config(server, own);
|
||||
}
|
||||
bool own_store_wins = server && own && !cbm_mcp_ignore_worktrees_runtime_enabled(server);
|
||||
if (server) {
|
||||
cbm_mcp_server_set_config(server, NULL);
|
||||
}
|
||||
cbm_mcp_server_free(server);
|
||||
cbm_config_close(own);
|
||||
restore_cache_dir(saved_copy);
|
||||
free(saved_copy);
|
||||
int removed = th_rmtree(cache);
|
||||
|
||||
ASSERT(absent_off);
|
||||
ASSERT(still_absent);
|
||||
ASSERT(stored);
|
||||
ASSERT(runtime_on);
|
||||
ASSERT(server_untouched);
|
||||
ASSERT(own_store_wins);
|
||||
ASSERT_EQ(removed, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(mcp_tools_list) {
|
||||
char *json = cbm_mcp_tools_list();
|
||||
ASSERT_NOT_NULL(json);
|
||||
@@ -24438,6 +24489,291 @@ TEST(mcp_auto_watch_false_skips_watcher_on_connect) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
* ignore_worktrees — explicit index_repository on a linked worktree
|
||||
* ═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* An EXPLICIT index_repository call is refused only when ignore_worktrees is on
|
||||
* AND repo_path is a linked worktree AND no per-call override was passed. The
|
||||
* refusal must never fire for the MAIN checkout — that would break ordinary
|
||||
* indexing for anyone enabling the key — and index_worktree=true must escape it.
|
||||
*
|
||||
* Default-off is the regression class the project guards hardest: with the key
|
||||
* unset, a linked worktree still indexes exactly as today.
|
||||
*
|
||||
* Probe returns a bit set, or a negative fixture-setup code. */
|
||||
enum {
|
||||
IGNORE_WT_REFUSED_WORKTREE = 1, /* expected when the key is on */
|
||||
IGNORE_WT_REFUSED_MAIN = 2, /* BUG if set */
|
||||
IGNORE_WT_REFUSED_OVERRIDE = 4, /* BUG if set */
|
||||
};
|
||||
|
||||
#ifndef _WIN32
|
||||
typedef struct {
|
||||
char cache[256];
|
||||
char main_repo[512];
|
||||
char wt_repo[512];
|
||||
} ignore_wt_fixture_t;
|
||||
|
||||
static void ignore_wt_fixture_cleanup(ignore_wt_fixture_t *fx) {
|
||||
if (!fx) {
|
||||
return;
|
||||
}
|
||||
if (fx->main_repo[0]) {
|
||||
char prune[1024];
|
||||
snprintf(prune, sizeof(prune), "git -C \"%s\" worktree prune >/dev/null 2>&1", fx->main_repo);
|
||||
(void)system(prune);
|
||||
}
|
||||
if (fx->cache[0]) {
|
||||
th_rmtree(fx->cache);
|
||||
}
|
||||
}
|
||||
|
||||
/* 0 = ready. -1 tmpdir, -2 mkdir, -3 git worktree fixture unavailable. */
|
||||
static int ignore_wt_fixture_setup(ignore_wt_fixture_t *fx) {
|
||||
memset(fx, 0, sizeof(*fx));
|
||||
char *raw = th_mktempdir("cbm_ignorewt");
|
||||
if (!raw) {
|
||||
return -1;
|
||||
}
|
||||
snprintf(fx->cache, sizeof(fx->cache), "%s", raw);
|
||||
snprintf(fx->main_repo, sizeof(fx->main_repo), "%s/main", fx->cache);
|
||||
snprintf(fx->wt_repo, sizeof(fx->wt_repo), "%s/wt", fx->cache);
|
||||
if (th_mkdir_p(fx->main_repo) != 0) {
|
||||
ignore_wt_fixture_cleanup(fx);
|
||||
return -2;
|
||||
}
|
||||
|
||||
char cmd[2048];
|
||||
snprintf(cmd, sizeof(cmd),
|
||||
"git -C \"%s\" init -q >/dev/null 2>&1 && "
|
||||
"git -C \"%s\" config user.email t@example.com && "
|
||||
"git -C \"%s\" config user.name T && touch \"%s/.keep\" && "
|
||||
"git -C \"%s\" add .keep && git -C \"%s\" commit -q -m init && "
|
||||
"git -C \"%s\" worktree add -q \"%s\" -b wtb",
|
||||
fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo,
|
||||
fx->main_repo, fx->main_repo, fx->wt_repo);
|
||||
if (system(cmd) != 0) {
|
||||
ignore_wt_fixture_cleanup(fx);
|
||||
return -3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool ignore_wt_refused(cbm_mcp_server_t *srv, const char *repo_path, bool override) {
|
||||
char args[2048];
|
||||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"%s}", repo_path,
|
||||
override ? ",\"index_worktree\":true" : "");
|
||||
char *result = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||||
bool refused = result && strstr(result, "ignore_worktrees is enabled") != NULL;
|
||||
free(result);
|
||||
return refused;
|
||||
}
|
||||
|
||||
static int ignore_worktrees_index_probe(const char *ignore_value) {
|
||||
ignore_wt_fixture_t fx;
|
||||
int setup = ignore_wt_fixture_setup(&fx);
|
||||
if (setup < 0) {
|
||||
return setup;
|
||||
}
|
||||
|
||||
const char *saved = getenv("CBM_CACHE_DIR");
|
||||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||||
cbm_setenv("CBM_CACHE_DIR", fx.cache, 1);
|
||||
|
||||
int bits = -4;
|
||||
cbm_config_t *cfg = cbm_config_open(fx.cache);
|
||||
if (cfg) {
|
||||
if (ignore_value) {
|
||||
cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value);
|
||||
}
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
if (srv) {
|
||||
cbm_mcp_server_set_config(srv, cfg);
|
||||
bits = 0;
|
||||
if (ignore_wt_refused(srv, fx.wt_repo, false)) {
|
||||
bits |= IGNORE_WT_REFUSED_WORKTREE;
|
||||
}
|
||||
if (ignore_wt_refused(srv, fx.main_repo, false)) {
|
||||
bits |= IGNORE_WT_REFUSED_MAIN;
|
||||
}
|
||||
if (ignore_wt_refused(srv, fx.wt_repo, true)) {
|
||||
bits |= IGNORE_WT_REFUSED_OVERRIDE;
|
||||
}
|
||||
cbm_mcp_server_free(srv);
|
||||
}
|
||||
cbm_config_close(cfg);
|
||||
}
|
||||
|
||||
restore_cache_dir(saved_copy);
|
||||
free(saved_copy);
|
||||
ignore_wt_fixture_cleanup(&fx);
|
||||
return bits;
|
||||
}
|
||||
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
static void ignore_wt_count_started(void *context) {
|
||||
int *calls = context;
|
||||
(*calls)++;
|
||||
}
|
||||
|
||||
/* Drive initialize → maybe_auto_index on a linked worktree. ignore_value NULL
|
||||
* leaves the key unset (default-off). The count hook fires only if auto-index
|
||||
* reaches discovery, so a skip for linked_worktree must leave it at 0. */
|
||||
static int ignore_wt_autoindex_count_calls(const char *session_root, const char *ignore_value) {
|
||||
char *raw = th_mktempdir("cbm_ignorewt_auto");
|
||||
if (!raw) {
|
||||
return -1;
|
||||
}
|
||||
char cache[256];
|
||||
snprintf(cache, sizeof(cache), "%s", raw);
|
||||
|
||||
const char *saved = getenv("CBM_CACHE_DIR");
|
||||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
char old_cwd[1024];
|
||||
if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(session_root) != 0) {
|
||||
restore_cache_dir(saved_copy);
|
||||
free(saved_copy);
|
||||
th_cleanup(cache);
|
||||
return -2;
|
||||
}
|
||||
|
||||
int calls = -3;
|
||||
cbm_config_t *cfg = cbm_config_open(cache);
|
||||
cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL;
|
||||
if (srv) {
|
||||
cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true");
|
||||
/* Fail-closed on the actual count so this never launches an index thread. */
|
||||
cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "-1");
|
||||
if (ignore_value) {
|
||||
cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value);
|
||||
}
|
||||
cbm_mcp_server_set_config(srv, cfg);
|
||||
calls = 0;
|
||||
cbm_mcp_server_set_auto_index_count_test_hook(srv, ignore_wt_count_started, &calls);
|
||||
char *response = cbm_mcp_server_handle(
|
||||
srv, "{\"jsonrpc\":\"2.0\",\"id\":1430,\"method\":\"initialize\",\"params\":{}}");
|
||||
free(response);
|
||||
cbm_mcp_server_free(srv);
|
||||
}
|
||||
if (cfg) {
|
||||
cbm_config_close(cfg);
|
||||
}
|
||||
|
||||
(void)cbm_chdir(old_cwd);
|
||||
restore_cache_dir(saved_copy);
|
||||
free(saved_copy);
|
||||
th_cleanup(cache);
|
||||
return calls;
|
||||
}
|
||||
#endif /* CBM_ENABLE_TEST_SEAMS */
|
||||
#endif /* !_WIN32 */
|
||||
|
||||
TEST(mcp_ignore_worktrees_gates_explicit_index_repository) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git worktree fixture not implemented for Windows");
|
||||
#else
|
||||
int bits = ignore_worktrees_index_probe("true");
|
||||
if (bits == -1) {
|
||||
FAIL("th_mktempdir returned NULL");
|
||||
}
|
||||
if (bits == -2) {
|
||||
FAIL("failed to create ignore_worktrees fixture directory");
|
||||
}
|
||||
if (bits < 0) {
|
||||
SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)");
|
||||
}
|
||||
/* RED before the gate existed: nothing is ever refused. */
|
||||
ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) != 0);
|
||||
ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0);
|
||||
ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0);
|
||||
PASS();
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
/* Default-off pin: key unset, linked worktree still indexes. The gate is
|
||||
* opt-in; an unset key must be bit-for-bit today's behaviour. */
|
||||
TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git worktree fixture not implemented for Windows");
|
||||
#else
|
||||
int bits = ignore_worktrees_index_probe(NULL);
|
||||
if (bits == -1) {
|
||||
FAIL("th_mktempdir returned NULL");
|
||||
}
|
||||
if (bits == -2) {
|
||||
FAIL("failed to create ignore_worktrees fixture directory");
|
||||
}
|
||||
if (bits < 0) {
|
||||
SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)");
|
||||
}
|
||||
ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) == 0);
|
||||
ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0);
|
||||
ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0);
|
||||
PASS();
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git worktree fixture not implemented for Windows");
|
||||
#else
|
||||
ignore_wt_fixture_t fx;
|
||||
int setup = ignore_wt_fixture_setup(&fx);
|
||||
if (setup == -1) {
|
||||
FAIL("th_mktempdir returned NULL");
|
||||
}
|
||||
if (setup == -2) {
|
||||
FAIL("failed to create ignore_worktrees fixture directory");
|
||||
}
|
||||
if (setup < 0) {
|
||||
SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)");
|
||||
}
|
||||
int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, "true");
|
||||
ignore_wt_fixture_cleanup(&fx);
|
||||
if (calls == -1) {
|
||||
FAIL("th_mktempdir returned NULL");
|
||||
}
|
||||
if (calls < 0) {
|
||||
FAIL("ignore_worktrees auto-index fixture failed");
|
||||
}
|
||||
ASSERT_EQ(calls, 0);
|
||||
PASS();
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
|
||||
TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("git worktree fixture not implemented for Windows");
|
||||
#else
|
||||
ignore_wt_fixture_t fx;
|
||||
int setup = ignore_wt_fixture_setup(&fx);
|
||||
if (setup == -1) {
|
||||
FAIL("th_mktempdir returned NULL");
|
||||
}
|
||||
if (setup == -2) {
|
||||
FAIL("failed to create ignore_worktrees fixture directory");
|
||||
}
|
||||
if (setup < 0) {
|
||||
SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)");
|
||||
}
|
||||
int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, NULL);
|
||||
ignore_wt_fixture_cleanup(&fx);
|
||||
if (calls == -1) {
|
||||
FAIL("th_mktempdir returned NULL");
|
||||
}
|
||||
if (calls < 0) {
|
||||
FAIL("ignore_worktrees auto-index fixture failed");
|
||||
}
|
||||
ASSERT_EQ(calls, 1);
|
||||
PASS();
|
||||
#endif /* _WIN32 */
|
||||
}
|
||||
#endif /* CBM_ENABLE_TEST_SEAMS */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* #1466 / #713 — the auto_index_limit guard
|
||||
*
|
||||
@@ -26751,6 +27087,7 @@ SUITE(mcp) {
|
||||
|
||||
/* MCP protocol helpers */
|
||||
RUN_TEST(mcp_initialize_response);
|
||||
RUN_TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone);
|
||||
RUN_TEST(mcp_tools_list);
|
||||
RUN_TEST(mcp_tools_help_list_matches_registry);
|
||||
RUN_TEST(mcp_tools_list_latest_metadata);
|
||||
@@ -27149,6 +27486,13 @@ SUITE(mcp) {
|
||||
RUN_TEST(autoindex_limit_guards_non_git_root_issue713);
|
||||
RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713);
|
||||
RUN_TEST(autoindex_limit_guards_git_root_issue713);
|
||||
/* ignore_worktrees gate */
|
||||
RUN_TEST(mcp_ignore_worktrees_gates_explicit_index_repository);
|
||||
RUN_TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree);
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
RUN_TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree);
|
||||
RUN_TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count);
|
||||
#endif
|
||||
RUN_TEST(mcp_auto_index_in_process_fresh_project_uses_full_width);
|
||||
RUN_TEST(mcp_auto_index_in_process_existing_index_keeps_headroom);
|
||||
RUN_TEST(mcp_background_flag_fresh_project_uses_full_width);
|
||||
|
||||
@@ -22228,6 +22228,423 @@ static int pipeline_legacy_c_identity_index_is_rebuilt(int legacy_version) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #759: index a Next.js App Router tree as project "nextjs" (a full index, or
|
||||
* the incremental route a stored index of the same tree takes). */
|
||||
static int nextjs_index(const char *repo, const char *db_path) {
|
||||
cbm_pipeline_t *p = cbm_pipeline_new(repo, db_path, CBM_MODE_FULL);
|
||||
if (!p) {
|
||||
return -1;
|
||||
}
|
||||
(void)cbm_pipeline_set_project_name(p, "nextjs");
|
||||
int rc = cbm_pipeline_run(p);
|
||||
cbm_pipeline_free(p);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* Every Route of a stored index with each handler that HANDLES it, one sorted
|
||||
* line each: "METHOD path route_qn file_path handler_qn". A Route no handler
|
||||
* reaches ends in "-". The properties are parsed (json_extract): a Route not
|
||||
* from route metadata is marked "!source", a handler declared in another file
|
||||
* "!file", a HANDLES edge whose handler property is not its handler's QN
|
||||
* "!handler"; malformed JSON fails the query. */
|
||||
static bool nextjs_route_tuples(const char *db_path, char *out, size_t sz) {
|
||||
sqlite3 *db = NULL;
|
||||
sqlite3_stmt *st = NULL;
|
||||
bool ok = false;
|
||||
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK &&
|
||||
sqlite3_prepare_v2(
|
||||
db,
|
||||
"SELECT coalesce(group_concat(t, char(10)), '') FROM (SELECT "
|
||||
"json_extract(r.properties,'$.method') || ' ' || r.name || ' ' || r.qualified_name || "
|
||||
"' ' || r.file_path || ' ' || coalesce(h.qualified_name, '-') || CASE WHEN "
|
||||
"json_extract(r.properties,'$.source') = 'decorator' THEN '' ELSE ' !source' END || "
|
||||
"CASE WHEN h.id IS NULL OR h.file_path = r.file_path THEN '' ELSE ' !file' END || "
|
||||
"CASE WHEN e.id IS NULL OR json_extract(e.properties,'$.handler') = h.qualified_name "
|
||||
"THEN '' ELSE ' !handler' END AS t FROM nodes r LEFT JOIN edges e ON "
|
||||
"e.target_id=r.id AND e.type='HANDLES' LEFT JOIN nodes h ON h.id=e.source_id WHERE "
|
||||
"r.label='Route' ORDER BY t)",
|
||||
-1, &st, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(st) == SQLITE_ROW) {
|
||||
snprintf(out, sz, "%s", (const char *)sqlite3_column_text(st, 0));
|
||||
ok = true;
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_close(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static int nextjs_by_qn(const void *a, const void *b) {
|
||||
return strcmp(((const cbm_search_result_t *)a)->node.qualified_name,
|
||||
((const cbm_search_result_t *)b)->node.qualified_name);
|
||||
}
|
||||
|
||||
/* The Routes a search by label Route and declaring-file glob finds, one
|
||||
* "qualified_name file_path" line each in QN order. */
|
||||
static bool nextjs_found_routes(const char *db_path, const char *glob, char *out, size_t sz) {
|
||||
cbm_store_t *s = cbm_store_open_path_existing(db_path);
|
||||
if (!s) {
|
||||
return false;
|
||||
}
|
||||
cbm_search_params_t params = {.project = "nextjs",
|
||||
.label = "Route",
|
||||
.file_pattern = glob,
|
||||
.min_degree = -1,
|
||||
.max_degree = -1,
|
||||
.limit = 100};
|
||||
cbm_search_output_t res = {0};
|
||||
bool ok = cbm_store_search(s, ¶ms, &res) == CBM_STORE_OK;
|
||||
if (ok && res.count > 1) {
|
||||
qsort(res.results, (size_t)res.count, sizeof(res.results[0]), nextjs_by_qn);
|
||||
}
|
||||
size_t len = 0;
|
||||
out[0] = '\0';
|
||||
for (int i = 0; ok && i < res.count; i++) {
|
||||
int n = snprintf(out + len, sz - len, "%s%s %s", i ? "\n" : "",
|
||||
res.results[i].node.qualified_name, res.results[i].node.file_path);
|
||||
ok = n >= 0 && (size_t)n < sz - len;
|
||||
len += ok ? (size_t)n : 0;
|
||||
}
|
||||
cbm_store_search_free(&res);
|
||||
cbm_store_close(s);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* One index of a Next.js tree: its result, the route it took and the stored
|
||||
* Routes after it (nextjs_route_tuples). */
|
||||
typedef struct {
|
||||
int rc;
|
||||
cbm_incremental_route_t route;
|
||||
bool read;
|
||||
char tuples[1024];
|
||||
} nextjs_run_t;
|
||||
|
||||
static void nextjs_run(const char *repo, const char *db_path, nextjs_run_t *run) {
|
||||
run->rc = nextjs_index(repo, db_path);
|
||||
run->route = cbm_pipeline_incremental_test_last_route();
|
||||
run->read = nextjs_route_tuples(db_path, run->tuples, sizeof(run->tuples));
|
||||
}
|
||||
|
||||
/* #759: the HTTP verb Functions a Next.js route file exports HANDLE the Routes
|
||||
* its directories name, in the stored graph: a dynamic segment, a group, a
|
||||
* catch-all and the root, under src/app/ and app/. Each Route is there once,
|
||||
* with its file, from its own handler, whose HANDLES property is its QN. A
|
||||
* search by label Route and the declaring file's glob finds them. `fillers`
|
||||
* more files put the index on the parallel extraction path. */
|
||||
static int nextjs_persisted_contract(int fillers) {
|
||||
char tmp[256];
|
||||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_nextjs_routes_XXXXXX");
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||||
for (int i = 0; i < fillers; i++) {
|
||||
char name[64];
|
||||
char body[128];
|
||||
snprintf(name, sizeof(name), "lib/filler%d.ts", i);
|
||||
snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i,
|
||||
i);
|
||||
write_temp_file(tmp, name, body);
|
||||
}
|
||||
write_temp_file(tmp, "src/app/api/users/[id]/route.ts",
|
||||
"export function GET() {\n return 1;\n}\n"
|
||||
"export const POST = async () => 1;\n");
|
||||
write_temp_file(tmp, "src/app/(public)/api/[org]/users/[id]/route.ts",
|
||||
"export async function GET() {\n return 2;\n}\n");
|
||||
write_temp_file(tmp, "app/api/[...slug]/route.ts", "export function GET() {\n return 3;\n}\n");
|
||||
write_temp_file(tmp, "app/route.js", "export function GET() {\n return 4;\n}\n");
|
||||
char db_path[512];
|
||||
snprintf(db_path, sizeof(db_path), "%s/nextjs.db", tmp);
|
||||
|
||||
char *old_workers = getenv("CBM_WORKERS");
|
||||
char *saved = old_workers ? strdup(old_workers) : NULL;
|
||||
if (fillers > 0) {
|
||||
cbm_setenv("CBM_WORKERS", "4", 1);
|
||||
}
|
||||
nextjs_run_t full;
|
||||
nextjs_run(tmp, db_path, &full);
|
||||
if (saved) {
|
||||
cbm_setenv("CBM_WORKERS", saved, 1);
|
||||
free(saved);
|
||||
} else {
|
||||
cbm_unsetenv("CBM_WORKERS");
|
||||
}
|
||||
char found[1024];
|
||||
bool searched =
|
||||
nextjs_found_routes(db_path, "src/app/api/users/*/route.ts", found, sizeof(found));
|
||||
th_rmtree(tmp);
|
||||
|
||||
ASSERT_EQ(full.rc, 0);
|
||||
ASSERT_TRUE(full.read);
|
||||
ASSERT_STR_EQ(
|
||||
full.tuples,
|
||||
"GET / __route__GET__/ app/route.js nextjs.app.route.GET\n"
|
||||
"GET /api/*slug __route__GET__/api/*slug app/api/[...slug]/route.ts "
|
||||
"nextjs.app.api.[...slug].route.GET\n"
|
||||
"GET /api/:org/users/:id __route__GET__/api/{}/users/{} "
|
||||
"src/app/(public)/api/[org]/users/[id]/route.ts "
|
||||
"nextjs.src.app.(public).api.[org].users.[id].route.GET\n"
|
||||
"GET /api/users/:id __route__GET__/api/users/{} src/app/api/users/[id]/route.ts "
|
||||
"nextjs.src.app.api.users.[id].route.GET\n"
|
||||
"POST /api/users/:id __route__POST__/api/users/{} src/app/api/users/[id]/route.ts "
|
||||
"nextjs.src.app.api.users.[id].route.POST");
|
||||
ASSERT_TRUE(searched);
|
||||
ASSERT_STR_EQ(found, "__route__GET__/api/users/{} src/app/api/users/[id]/route.ts\n"
|
||||
"__route__POST__/api/users/{} src/app/api/users/[id]/route.ts");
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pipeline_nextjs_persisted_route_contract_issue759) {
|
||||
return nextjs_persisted_contract(0);
|
||||
}
|
||||
|
||||
TEST(pipeline_nextjs_persisted_route_contract_parallel_issue759) {
|
||||
return nextjs_persisted_contract(52);
|
||||
}
|
||||
|
||||
/* #759 over the life of a route file. Each step re-indexes the stored graph;
|
||||
* after each, the file's current handlers are its only Routes, each once, and
|
||||
* an unrelated route file keeps its Route. An unchanged tree is a no-op, and a
|
||||
* change to another file rebuilds the unchanged route files' Routes from their
|
||||
* stored handlers (pass_route_nodes.c), which a search still finds. */
|
||||
TEST(pipeline_nextjs_incremental_lifecycle_issue759) {
|
||||
char tmp[256];
|
||||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_nextjs_life_XXXXXX");
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||||
write_temp_file(tmp, "app/api/items/route.ts",
|
||||
"export function GET() {\n return 1;\n}\n"
|
||||
"export async function POST() {\n return 2;\n}\n");
|
||||
write_temp_file(tmp, "app/api/health/route.ts", "export const GET = () => 'ok';\n");
|
||||
write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 1;\n}\n");
|
||||
char db_path[512];
|
||||
snprintf(db_path, sizeof(db_path), "%s/nextjs.db", tmp);
|
||||
|
||||
nextjs_run_t full;
|
||||
nextjs_run(tmp, db_path, &full);
|
||||
nextjs_run_t same;
|
||||
nextjs_run(tmp, db_path, &same);
|
||||
char found_same[1024];
|
||||
bool searched_same =
|
||||
nextjs_found_routes(db_path, "app/api/*/route.ts", found_same, sizeof(found_same));
|
||||
|
||||
write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 2;\n}\n");
|
||||
nextjs_run_t helper_only;
|
||||
nextjs_run(tmp, db_path, &helper_only);
|
||||
char found_repair[1024];
|
||||
bool searched_repair =
|
||||
nextjs_found_routes(db_path, "app/api/*/route.ts", found_repair, sizeof(found_repair));
|
||||
|
||||
write_temp_file(tmp, "app/api/items/route.ts", "export function GET() {\n return 1;\n}\n");
|
||||
nextjs_run_t no_post;
|
||||
nextjs_run(tmp, db_path, &no_post);
|
||||
|
||||
write_temp_file(tmp, "app/api/items/route.ts",
|
||||
"export function GET() {\n const n = 10;\n return n;\n}\n");
|
||||
nextjs_run_t new_body;
|
||||
nextjs_run(tmp, db_path, &new_body);
|
||||
|
||||
write_temp_file(tmp, "app/api/items/route.ts", "export function PATCH() {\n return 3;\n}\n");
|
||||
nextjs_run_t patch;
|
||||
nextjs_run(tmp, db_path, &patch);
|
||||
|
||||
write_temp_file(tmp, "app/api/products/route.ts",
|
||||
"export function PATCH() {\n return 3;\n}\n");
|
||||
stem_unlink(tmp, "app/api/items/route.ts");
|
||||
nextjs_run_t moved;
|
||||
nextjs_run(tmp, db_path, &moved);
|
||||
|
||||
write_temp_file(tmp, "app/api/products/[id]/route.ts",
|
||||
"export function PATCH() {\n return 3;\n}\n");
|
||||
stem_unlink(tmp, "app/api/products/route.ts");
|
||||
nextjs_run_t dynamic;
|
||||
nextjs_run(tmp, db_path, &dynamic);
|
||||
|
||||
stem_unlink(tmp, "app/api/products/[id]/route.ts");
|
||||
nextjs_run_t deleted;
|
||||
nextjs_run(tmp, db_path, &deleted);
|
||||
th_rmtree(tmp);
|
||||
|
||||
static const char health[] = "GET /api/health __route__GET__/api/health "
|
||||
"app/api/health/route.ts nextjs.app.api.health.route.GET";
|
||||
static const char routes[] = "GET /api/health __route__GET__/api/health "
|
||||
"app/api/health/route.ts nextjs.app.api.health.route.GET\n"
|
||||
"GET /api/items __route__GET__/api/items app/api/items/route.ts "
|
||||
"nextjs.app.api.items.route.GET\n"
|
||||
"POST /api/items __route__POST__/api/items app/api/items/route.ts "
|
||||
"nextjs.app.api.items.route.POST";
|
||||
static const char get_only[] = "GET /api/health __route__GET__/api/health "
|
||||
"app/api/health/route.ts nextjs.app.api.health.route.GET\n"
|
||||
"GET /api/items __route__GET__/api/items app/api/items/route.ts "
|
||||
"nextjs.app.api.items.route.GET";
|
||||
ASSERT_EQ(full.rc, 0);
|
||||
ASSERT_TRUE(full.read);
|
||||
ASSERT_STR_EQ(full.tuples, routes);
|
||||
ASSERT_EQ(same.rc, 0);
|
||||
ASSERT_EQ(same.route, CBM_INCREMENTAL_ROUTE_NOOP);
|
||||
ASSERT_STR_EQ(same.tuples, routes);
|
||||
ASSERT_TRUE(searched_same);
|
||||
ASSERT_STR_EQ(found_same, "__route__GET__/api/health app/api/health/route.ts\n"
|
||||
"__route__GET__/api/items app/api/items/route.ts\n"
|
||||
"__route__POST__/api/items app/api/items/route.ts");
|
||||
ASSERT_EQ(helper_only.rc, 0);
|
||||
ASSERT_EQ(helper_only.route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||||
ASSERT_STR_EQ(helper_only.tuples, routes);
|
||||
ASSERT_TRUE(searched_repair);
|
||||
ASSERT_STR_EQ(found_repair, found_same);
|
||||
ASSERT_EQ(no_post.rc, 0);
|
||||
ASSERT_EQ(no_post.route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||||
ASSERT_STR_EQ(no_post.tuples, get_only);
|
||||
ASSERT_EQ(new_body.rc, 0);
|
||||
ASSERT_EQ(new_body.route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||||
ASSERT_STR_EQ(new_body.tuples, get_only);
|
||||
ASSERT_EQ(patch.rc, 0);
|
||||
ASSERT_STR_EQ(patch.tuples, "GET /api/health __route__GET__/api/health "
|
||||
"app/api/health/route.ts nextjs.app.api.health.route.GET\n"
|
||||
"PATCH /api/items __route__PATCH__/api/items "
|
||||
"app/api/items/route.ts nextjs.app.api.items.route.PATCH");
|
||||
ASSERT_EQ(moved.rc, 0);
|
||||
ASSERT_STR_EQ(moved.tuples, "GET /api/health __route__GET__/api/health "
|
||||
"app/api/health/route.ts nextjs.app.api.health.route.GET\n"
|
||||
"PATCH /api/products __route__PATCH__/api/products "
|
||||
"app/api/products/route.ts nextjs.app.api.products.route.PATCH");
|
||||
ASSERT_EQ(dynamic.rc, 0);
|
||||
ASSERT_STR_EQ(dynamic.tuples,
|
||||
"GET /api/health __route__GET__/api/health "
|
||||
"app/api/health/route.ts nextjs.app.api.health.route.GET\n"
|
||||
"PATCH /api/products/:id __route__PATCH__/api/products/{} "
|
||||
"app/api/products/[id]/route.ts nextjs.app.api.products.[id].route.PATCH");
|
||||
ASSERT_EQ(deleted.rc, 0);
|
||||
ASSERT_STR_EQ(deleted.tuples, health);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* CBM_SEMANTIC_INDEX_VERSION 13 (#759): an index version 12 wrote has no route
|
||||
* metadata, Route or HANDLES for a Next.js route file, and an unchanged tree
|
||||
* is otherwise a no-op, so the version is what makes the new binary rebuild
|
||||
* it. The stored index is put back to the version-12 state by hand (metadata,
|
||||
* the handlers' route properties, the Routes and their edges); the run after
|
||||
* that must replace it in full, the next one is a no-op. */
|
||||
TEST(pipeline_nextjs_semantic_upgrade_issue759) {
|
||||
char tmp[256];
|
||||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_nextjs_upgrade_XXXXXX");
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||||
write_temp_file(tmp, "app/api/items/route.ts",
|
||||
"export function GET() {\n return 1;\n}\n"
|
||||
"export const POST = async () => 2;\n");
|
||||
write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 1;\n}\n");
|
||||
char db_path[512];
|
||||
snprintf(db_path, sizeof(db_path), "%s/nextjs.db", tmp);
|
||||
static const char all_nodes[] = "SELECT COUNT(*) FROM nodes";
|
||||
static const char all_edges[] = "SELECT COUNT(*) FROM edges";
|
||||
static const char routed[] =
|
||||
"SELECT COUNT(*) FROM nodes WHERE json_extract(properties,'$.route_path') IS NOT NULL";
|
||||
|
||||
nextjs_run_t full;
|
||||
nextjs_run(tmp, db_path, &full);
|
||||
int nodes_before = k8s_inc_count(db_path, all_nodes);
|
||||
int edges_before = k8s_inc_count(db_path, all_edges);
|
||||
|
||||
/* Put the published index back to what version 12 wrote. */
|
||||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||||
ASSERT_NOT_NULL(store);
|
||||
cbm_coverage_row_t *coverage_rows = NULL;
|
||||
int coverage_count = 0;
|
||||
ASSERT_EQ(cbm_store_coverage_get(store, "nextjs", &coverage_rows, &coverage_count),
|
||||
CBM_STORE_OK);
|
||||
cbm_coverage_meta_t meta = {0};
|
||||
ASSERT_EQ(cbm_store_coverage_meta_get(store, "nextjs", &meta), CBM_STORE_OK);
|
||||
cbm_coverage_meta_t old_meta = meta;
|
||||
old_meta.coverage_version = 12;
|
||||
ASSERT_EQ(
|
||||
cbm_store_coverage_replace_ex(store, "nextjs", coverage_rows, coverage_count, &old_meta),
|
||||
CBM_STORE_OK);
|
||||
cbm_store_free_coverage(coverage_rows, coverage_count);
|
||||
cbm_store_coverage_meta_clear(&meta);
|
||||
ASSERT_EQ(cbm_store_exec(store,
|
||||
"DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE "
|
||||
"label = 'Route') OR target_id IN (SELECT id FROM nodes WHERE "
|
||||
"label = 'Route');"
|
||||
"DELETE FROM nodes WHERE label = 'Route';"
|
||||
"UPDATE nodes SET properties = json_remove(properties, "
|
||||
"'$.route_path', '$.route_method') "
|
||||
"WHERE json_extract(properties, '$.route_path') IS NOT NULL;"),
|
||||
CBM_STORE_OK);
|
||||
cbm_store_close(store);
|
||||
nextjs_run_t version_12 = {0};
|
||||
version_12.read = nextjs_route_tuples(db_path, version_12.tuples, sizeof(version_12.tuples));
|
||||
int routed_12 = k8s_inc_count(db_path, routed);
|
||||
|
||||
/* Nothing in the tree changed: only the version says the index is stale. */
|
||||
nextjs_run_t upgrade;
|
||||
nextjs_run(tmp, db_path, &upgrade);
|
||||
int nodes_after = k8s_inc_count(db_path, all_nodes);
|
||||
int edges_after = k8s_inc_count(db_path, all_edges);
|
||||
int routed_after = k8s_inc_count(db_path, routed);
|
||||
store = cbm_store_open_path(db_path);
|
||||
ASSERT_NOT_NULL(store);
|
||||
cbm_coverage_meta_t new_meta = {0};
|
||||
ASSERT_EQ(cbm_store_coverage_meta_get(store, "nextjs", &new_meta), CBM_STORE_OK);
|
||||
int stored_version = new_meta.coverage_version;
|
||||
cbm_store_coverage_meta_clear(&new_meta);
|
||||
cbm_store_close(store);
|
||||
nextjs_run_t again;
|
||||
nextjs_run(tmp, db_path, &again);
|
||||
th_rmtree(tmp);
|
||||
|
||||
ASSERT_EQ(full.rc, 0);
|
||||
ASSERT_STR_EQ(full.tuples, "GET /api/items __route__GET__/api/items app/api/items/route.ts "
|
||||
"nextjs.app.api.items.route.GET\n"
|
||||
"POST /api/items __route__POST__/api/items app/api/items/route.ts "
|
||||
"nextjs.app.api.items.route.POST");
|
||||
ASSERT_TRUE(version_12.read);
|
||||
ASSERT_STR_EQ(version_12.tuples, "");
|
||||
ASSERT_EQ(routed_12, 0);
|
||||
ASSERT_EQ(upgrade.rc, 0);
|
||||
ASSERT_EQ(upgrade.route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
|
||||
ASSERT_STR_EQ(upgrade.tuples, full.tuples);
|
||||
ASSERT_EQ(routed_after, 2);
|
||||
ASSERT_EQ(nodes_after, nodes_before);
|
||||
ASSERT_EQ(edges_after, edges_before);
|
||||
ASSERT_EQ(stored_version, CBM_SEMANTIC_INDEX_VERSION);
|
||||
ASSERT_EQ(again.rc, 0);
|
||||
ASSERT_EQ(again.route, CBM_INCREMENTAL_ROUTE_NOOP);
|
||||
ASSERT_STR_EQ(again.tuples, full.tuples);
|
||||
ASSERT_GTE(CBM_SEMANTIC_INDEX_VERSION, 13);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #759: a quote is legal in a POSIX directory name, so a route path, its file
|
||||
* and its handler's QN can hold one, escaped in the stored JSON. The route pass
|
||||
* builds the Route from its handler's stored properties on every index
|
||||
* (pass_route_nodes.c), so the full index and an incremental one after an
|
||||
* unrelated edit must both store it intact. */
|
||||
TEST(pipeline_nextjs_quoted_route_round_trip_issue759) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("a quote is not a legal Windows path character");
|
||||
#else
|
||||
char tmp[256];
|
||||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_nextjs_quote_XXXXXX");
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||||
write_temp_file(tmp, "app/api/a\"b/route.ts", "export function GET() {\n return 1;\n}\n");
|
||||
write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 1;\n}\n");
|
||||
char db_path[512];
|
||||
snprintf(db_path, sizeof(db_path), "%s/nextjs.db", tmp);
|
||||
|
||||
nextjs_run_t full;
|
||||
nextjs_run(tmp, db_path, &full);
|
||||
write_temp_file(tmp, "lib/helper.ts", "export function helper(): number {\n return 2;\n}\n");
|
||||
nextjs_run_t helper_only;
|
||||
nextjs_run(tmp, db_path, &helper_only);
|
||||
th_rmtree(tmp);
|
||||
|
||||
ASSERT_EQ(full.rc, 0);
|
||||
ASSERT_TRUE(full.read);
|
||||
ASSERT_STR_EQ(full.tuples, "GET /api/a\"b __route__GET__/api/a\"b app/api/a\"b/route.ts "
|
||||
"nextjs.app.api.a\"b.route.GET");
|
||||
ASSERT_EQ(helper_only.rc, 0);
|
||||
ASSERT_EQ(helper_only.route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||||
ASSERT_TRUE(helper_only.read);
|
||||
ASSERT_STR_EQ(helper_only.tuples, full.tuples);
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1) {
|
||||
return pipeline_legacy_c_identity_index_is_rebuilt(3);
|
||||
}
|
||||
@@ -31448,6 +31865,11 @@ SUITE(pipeline) {
|
||||
RUN_TEST(pipeline_cpp_overloads_are_listed_as_variants_c1);
|
||||
RUN_TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1);
|
||||
RUN_TEST(pipeline_semantic_version_5_index_is_rebuilt_in_full_c1);
|
||||
RUN_TEST(pipeline_nextjs_persisted_route_contract_issue759);
|
||||
RUN_TEST(pipeline_nextjs_persisted_route_contract_parallel_issue759);
|
||||
RUN_TEST(pipeline_nextjs_incremental_lifecycle_issue759);
|
||||
RUN_TEST(pipeline_nextjs_semantic_upgrade_issue759);
|
||||
RUN_TEST(pipeline_nextjs_quoted_route_round_trip_issue759);
|
||||
RUN_TEST(pipeline_python_app_factory_routes_issue1548);
|
||||
RUN_TEST(pipeline_python_nested_def_is_function_local_issue1548);
|
||||
RUN_TEST(pipeline_python_local_classes_stay_in_their_def_issue1548);
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
#include <unistd.h>
|
||||
#ifndef _WIN32
|
||||
#include <sys/stat.h> /* chmod for the symlink-policy mode legs */
|
||||
#include <errno.h>
|
||||
#include <sys/resource.h> /* getpriority for the worker-priority leg */
|
||||
#include <sys/wait.h>
|
||||
#endif
|
||||
|
||||
#ifdef __linux__
|
||||
@@ -1442,6 +1445,49 @@ TEST(regex_estimate_counts_brackets_and_escapes_literally) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* An index worker lowers its own priority (#2834). A non-root process cannot
|
||||
* raise it back, so the helper runs in a forked child that reports its nice. */
|
||||
TEST(platform_lower_cpu_priority_nices_the_process) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("POSIX nice contract");
|
||||
#else
|
||||
int fds[2];
|
||||
ASSERT_EQ(pipe(fds), 0);
|
||||
pid_t child = fork();
|
||||
if (child == 0) {
|
||||
(void)close(fds[0]);
|
||||
/* report: lowered ok, nice after; then, from a nicer start, ok, target, nice after */
|
||||
int report[5] = {cbm_lower_cpu_priority() ? 1 : 0, 0, 0, 0, 0};
|
||||
errno = 0;
|
||||
report[1] = getpriority(PRIO_PROCESS, 0);
|
||||
if (errno != 0) {
|
||||
report[0] = 0;
|
||||
}
|
||||
/* Never-raise leg: start nicer than the helper's level; it must leave it. */
|
||||
report[3] = report[1] >= 15 ? 19 : 15;
|
||||
if (setpriority(PRIO_PROCESS, 0, report[3]) == 0) {
|
||||
report[2] = cbm_lower_cpu_priority() ? 1 : 0;
|
||||
errno = 0;
|
||||
report[4] = getpriority(PRIO_PROCESS, 0);
|
||||
}
|
||||
_exit(write(fds[1], report, sizeof(report)) == (ssize_t)sizeof(report) ? 0 : 1);
|
||||
}
|
||||
ASSERT_TRUE(child > 0);
|
||||
(void)close(fds[1]);
|
||||
int report[5] = {0, 0, 0, 0, 0};
|
||||
ssize_t got = read(fds[0], report, sizeof(report));
|
||||
(void)close(fds[0]);
|
||||
int status = 0;
|
||||
(void)waitpid(child, &status, 0);
|
||||
ASSERT_EQ(got, (ssize_t)sizeof(report));
|
||||
ASSERT_EQ(report[0], 1);
|
||||
ASSERT_TRUE(report[1] >= CBM_LOW_CPU_PRIORITY_NICE);
|
||||
ASSERT_EQ(report[2], 1);
|
||||
ASSERT_EQ(report[4], report[3]);
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
SUITE(platform) {
|
||||
RUN_TEST(platform_file_apis_survive_max_path_overflow);
|
||||
RUN_TEST(platform_mkdir_p_follows_own_symlink_only_when_opted_in);
|
||||
@@ -1477,6 +1523,7 @@ SUITE(platform) {
|
||||
RUN_TEST(platform_env_long_refuses_what_it_cannot_read);
|
||||
RUN_TEST(platform_env_dup_honours_the_settings_resolver);
|
||||
RUN_TEST(platform_system_info);
|
||||
RUN_TEST(platform_lower_cpu_priority_nices_the_process);
|
||||
RUN_TEST(regex_compile_refuses_oversized_expansion);
|
||||
RUN_TEST(regex_compile_accepts_expansion_under_budget);
|
||||
RUN_TEST(regex_compile_refuses_overlong_pattern);
|
||||
|
||||
Reference in new issue
Block a user