diff --git a/Makefile.cbm b/Makefile.cbm index 3b93146f1..443946c7f 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1386,6 +1386,7 @@ LLVM_BIN := $(shell brew --prefix llvm 2>/dev/null)/bin CLANG_TIDY ?= $(shell [ -x "$(LLVM_BIN)/clang-tidy" ] && echo "$(LLVM_BIN)/clang-tidy" || echo clang-tidy) CLANG_FORMAT ?= $(shell [ -x "$(LLVM_BIN)/clang-format" ] && echo "$(LLVM_BIN)/clang-format" || echo clang-format) CPPCHECK ?= cppcheck +CPPCHECK_JOBS ?= 2 # macOS SDK sysroot (needed for Homebrew LLVM to find system headers) SYSROOT = $(shell xcrun --show-sdk-path 2>/dev/null) @@ -1410,10 +1411,16 @@ lint-tidy: lint-tidy-diff: @scripts/lint-tidy-diff.sh "$(CLANG_TIDY)" $(LINT_SRCS) -- $(CFLAGS_COMMON) $(SYSROOT_FLAG) -# cppcheck: complementary analysis (config in .cppcheck) +# cppcheck: complementary analysis (config in .cppcheck). A build directory +# retains whole-program analysis with parallel workers; each run owns its state. lint-cppcheck: @echo "=== cppcheck ===" - @$(CPPCHECK) --enable=warning,style,performance,portability \ + @set -e; \ + cppcheck_dir=$$(mktemp -d "$${TMPDIR:-/tmp}/cbm-cppcheck.XXXXXX"); \ + trap 'status=$$?; rm -rf "$$cppcheck_dir" || :; exit "$$status"' EXIT; \ + trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM; \ + $(CPPCHECK) -j$(CPPCHECK_JOBS) --cppcheck-build-dir="$$cppcheck_dir" \ + --enable=warning,style,performance,portability \ --std=c11 --language=c \ --suppressions-list=.cppcheck \ --error-exitcode=1 \ diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 55596732d..a2c367677 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -8923,6 +8923,68 @@ static void extract_objc_impl_methods(CBMExtractCtx *ctx, TSNode impl_node, cons } } +/* True for a C# 14 extension block. The vendored tree-sitter-c-sharp has no + * `extension` production, so `extension(string text) { ... }` parses as a + * constructor_declaration named `extension` and nothing flags it: has_error + * stays 0. A constructor carries its own type's name, so this shape can only be + * a genuine constructor inside a type literally called `extension` (#2071). */ +static bool csharp_is_extension_block(TSNode node, TSNode class_node, const char *source) { + if (strcmp(ts_node_type(node), "constructor_declaration") != 0) { + return false; + } + TSNode name = resolve_method_name(node, CBM_LANG_CSHARP); + if (ts_node_is_null(name)) { + return false; + } + const size_t kw_len = sizeof("extension") - 1; + uint32_t len = ts_node_end_byte(name) - ts_node_start_byte(name); + if (len != kw_len || strncmp(source + ts_node_start_byte(name), "extension", kw_len) != 0) { + return false; + } + TSNode type_name = ts_node_child_by_field_name(class_node, TS_FIELD("name")); + if (!ts_node_is_null(type_name)) { + const char *type_text = source + ts_node_start_byte(type_name); + uint32_t tlen = ts_node_end_byte(type_name) - ts_node_start_byte(type_name); + /* A verbatim identifier names the same type: class @extension may + * declare a genuine constructor spelled extension(). */ + if (tlen > 0 && type_text[0] == '@') { + type_text++; + tlen--; + } + if (tlen == kw_len && strncmp(type_text, "extension", kw_len) == 0) { + return false; + } + } + return true; +} + +/* Lift the members of a misparsed C# 14 extension block onto the enclosing + * type. They sit in the block the grammar took for a constructor body, which + * nothing recurses into, so every one of them was dropped. Methods land there + * as local_function_statement; a property in the block parses as ERROR and is + * beyond recovery without a grammar that knows the construct (#2071). */ +static void extract_csharp_extension_members(CBMExtractCtx *ctx, TSNode ext_node, TSNode class_node, + const char *class_qn, const CBMLangSpec *spec) { + TSNode body = cbm_find_child_by_kind(ext_node, "block"); + if (ts_node_is_null(body)) { + return; + } + member_iter_t it; + member_iter_init(&it, body, ctx->language, false); + TSNode member; + while (member_iter_next(&it, &member)) { + if (strcmp(ts_node_type(member), "local_function_statement") != 0) { + continue; + } + TSNode name = resolve_method_name(member, ctx->language); + if (ts_node_is_null(name)) { + continue; + } + push_method_def(ctx, member, class_node, class_qn, spec, name); + } + member_iter_done(&it); +} + // Extract methods inside a class body static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const char *class_qn, const CBMLangSpec *spec) { @@ -8936,6 +8998,15 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c TSNode child; while (member_iter_next(&it, &child)) { + /* C# 14 extension block: emit the members it holds as methods of the + * enclosing type, rather than the block itself as a constructor named + * `extension` with its members buried in the body (#2071). */ + if (ctx->language == CBM_LANG_CSHARP && + csharp_is_extension_block(child, class_node, ctx->source)) { + extract_csharp_extension_members(ctx, child, class_node, class_qn, spec); + continue; + } + if (ctx->language == CBM_LANG_OBJC && strcmp(ts_node_type(child), "implementation_definition") == 0) { extract_objc_impl_methods(ctx, child, class_qn, spec); diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index 8ee5e6642..b7d966c63 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -20,7 +20,7 @@ * cbm_batch_ts_lsp_cross. * * Conventions (mirrors c_lsp / go_lsp): - * - All allocations go through ctx->arena; no malloc / free. + * - Persistent results use ctx->arena; temporary walk/index buffers use tracked allocations. * - ts_emit_resolved_call requires a non-NULL enclosing_func_qn — calls outside any * function (module top-level) are not emitted. * - eval_depth caps at TS_LSP_MAX_EVAL_DEPTH to defend against pathological recursion. @@ -28,6 +28,10 @@ #include "ts_lsp.h" #include "../../../src/foundation/platform.h" +#include "../helpers.h" +#include "../../../src/foundation/log.h" +#include "../../../src/foundation/mem_core.h" +#include #include #include #include @@ -60,6 +64,7 @@ void cbm_ts_full_registry_builds_reset(void) { static _Thread_local long g_ts_type_budget = -1; static _Thread_local bool g_ts_type_budget_warned; #ifdef CBM_ENABLE_TEST_SEAMS +static _Thread_local unsigned long g_ts_signature_lookup_work; static _Thread_local int g_ts_max_member_lookup_steps; static _Thread_local int g_ts_max_method_lookup_steps; #endif @@ -174,6 +179,9 @@ long cbm_ts_lsp_test_budget_remaining(void) { bool cbm_ts_lsp_test_budget_warned(void) { return g_ts_type_budget_warned; } +unsigned long cbm_ts_lsp_test_signature_lookup_work(void) { + return g_ts_signature_lookup_work; +} int cbm_ts_lsp_test_max_member_lookup_steps(void) { return g_ts_max_member_lookup_steps; } @@ -3956,6 +3964,7 @@ void ts_lsp_init(TSLSPContext *ctx, CBMArena *arena, const char *source, int sou if (!ctx) return; #ifdef CBM_ENABLE_TEST_SEAMS + g_ts_signature_lookup_work = 0; g_ts_max_member_lookup_steps = 0; g_ts_max_method_lookup_steps = 0; #endif @@ -4805,16 +4814,77 @@ static void register_file_defs(CBMArena *arena, CBMTypeRegistry *reg, CBMFileRes } } -// Find the first `return EXPR` inside a body and evaluate EXPR's type. Returns -// UNKNOWN if the body has no return or every return is unresolvable. +// AST walk stacks for the passes below. The first chunk lives on the C stack, the +// same 256 slots the old fixed arrays used; a wider pending set grows on the heap +// through cbm_walk_stack_reserve and is released when the walk ends, so nothing +// is cut from the per-file result arena. A fixed cap used to stop pushing without +// a word, which skipped every top-level declaration past the 256th and left the +// return walk short of returns it had never reached (#2388). +enum { TS_WALK_STACK_INLINE = 256 }; + +static int ts_walk_pending_count(int top, uint32_t count, const char *walker) { + if (top < 0 || count > (uint32_t)(INT_MAX - top)) { + char pending[24]; + snprintf(pending, sizeof(pending), "%llu", (unsigned long long)count + (unsigned)top); + cbm_log_error("extract.walk_stack_alloc_failed", "walker", walker, "pending", pending); + return -1; + } + return top + (int)count; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +int cbm_ts_lsp_test_walk_pending_count(int top, uint32_t count) { + return ts_walk_pending_count(top, count, "ts_lsp_test"); +} +#endif + +// Push every child of n in cursor order (one O(N) pass). The walk pops the stack, +// so children are visited last-child first: the order these walks have always +// used. False means the stack could not grow; cbm_walk_stack_reserve has logged it. +static bool ts_walk_push_children(TSNode **stack, int *cap, int *top, TSNode n, + const TSNode *inline_buf, const char *walker) { + uint32_t count = ts_node_child_count(n); + if (count == 0) + return true; + int need = ts_walk_pending_count(*top, count, walker); + if (need < 0 || + !cbm_walk_stack_reserve((void **)stack, cap, need, sizeof(TSNode), inline_buf, walker)) + return false; + TSTreeCursor cursor = ts_tree_cursor_new(n); + if (ts_tree_cursor_goto_first_child(&cursor)) { + uint32_t pushed = 0; + do { + (*stack)[(*top)++] = ts_tree_cursor_current_node(&cursor); + pushed++; + } while (pushed < count && ts_tree_cursor_goto_next_sibling(&cursor)); + } + ts_tree_cursor_delete(&cursor); + return true; +} + +static bool ts_walk_push(TSNode **stack, int *cap, int *top, TSNode n, const TSNode *inline_buf, + const char *walker) { + int need = ts_walk_pending_count(*top, 1, walker); + if (need < 0 || + !cbm_walk_stack_reserve((void **)stack, cap, need, sizeof(TSNode), inline_buf, walker)) + return false; + (*stack)[(*top)++] = n; + return true; +} + +// Find a `return EXPR` inside a body and evaluate EXPR's type. Children are visited +// last-child first, so of several returns the one latest in the body is tried +// first; the first one that resolves wins. Returns UNKNOWN if the body has no +// return, every return is unresolvable, or the walk stack could not grow. static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body) { if (ts_node_is_null(body)) return cbm_type_unknown(); - // Iterative DFS using a small fixed-size stack to avoid C-stack blowup on big bodies. - enum { STACK_CAP = 256 }; - TSNode stack[STACK_CAP]; + TSNode inline_stack[TS_WALK_STACK_INLINE]; + TSNode *stack = inline_stack; + int cap = TS_WALK_STACK_INLINE; int top = 0; + const CBMType *found = cbm_type_unknown(); stack[top++] = body; while (top > 0) { TSNode n = stack[--top]; @@ -4830,8 +4900,10 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body if (ts_node_is_null(expr)) continue; const CBMType *t = ts_eval_expr_type(ctx, expr); - if (t && !cbm_type_is_unknown(t)) - return t; + if (t && !cbm_type_is_unknown(t)) { + found = t; + break; + } continue; } // Don't recurse into nested function bodies — their returns belong to them. @@ -4839,14 +4911,96 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body strcmp(k, "arrow_function") == 0 || strcmp(k, "method_definition") == 0) continue; - uint32_t cnt = ts_node_child_count(n); - for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) { - stack[top++] = ts_node_child(n, i); - } + if (!ts_walk_push_children(&stack, &cap, &top, n, inline_stack, "ts_lsp_return_walk")) + break; // "unknown" is the safe answer for a walk that could not finish } - return cbm_type_unknown(); + cbm_walk_stack_release(stack, inline_stack); + return found; } +// These two AST passes patch the FIRST matching local registration, even when +// the general registry index prefers the last duplicate. Do not follow fallback +// or copy its entries into the overlay. The snapshot lives only across these +// passes, which mutate signatures but never append or rename registered funcs. +typedef struct { + CBMRegisteredFunc **slots; + size_t capacity; +} TSSignatureIndex; + +static void ts_signature_work(void) { +#ifdef CBM_ENABLE_TEST_SEAMS + g_ts_signature_lookup_work++; +#endif +} + +static size_t ts_signature_slot(const TSSignatureIndex *index, const char *qn) { + uint64_t hash = 0xcbf29ce484222325ULL; + for (const unsigned char *p = (const unsigned char *)qn; *p; p++) { + hash ^= *p; + hash *= 0x100000001b3ULL; + } + size_t slot = (size_t)hash & (index->capacity - 1); + for (;;) { + ts_signature_work(); + if (!index->slots[slot] || strcmp(index->slots[slot]->qualified_name, qn) == 0) + return slot; + slot = (slot + 1) & (index->capacity - 1); + } +} + +static TSSignatureIndex ts_signature_index(CBMTypeRegistry *reg) { + TSSignatureIndex index = {0}; + if (reg->func_count <= 0 || (size_t)reg->func_count > SIZE_MAX / 2) + return index; + size_t capacity = 16; + while (capacity < (size_t)reg->func_count * 2) { + if (capacity > SIZE_MAX / 2) + return index; + capacity *= 2; + } + if (capacity > SIZE_MAX / sizeof(*index.slots)) + return index; + index.slots = cbm_alloc(CBM_MEM_CLASS_EXTRACT, capacity * sizeof(*index.slots)); + if (!index.slots) + return index; // Preserve the original lookup on allocation failure. + index.capacity = capacity; + memset(index.slots, 0, capacity * sizeof(*index.slots)); + for (int i = 0; i < reg->func_count; i++) { + ts_signature_work(); + CBMRegisteredFunc *func = ®->funcs[i]; + if (!func->qualified_name) + continue; + size_t slot = ts_signature_slot(&index, func->qualified_name); + if (!index.slots[slot]) + index.slots[slot] = func; + } + return index; +} + +static CBMRegisteredFunc *ts_signature_func(CBMTypeRegistry *reg, const TSSignatureIndex *index, + const char *qn) { + if (!qn) + return NULL; + if (index->slots) + return index->slots[ts_signature_slot(index, qn)]; + for (int i = 0; i < reg->func_count; i++) { + ts_signature_work(); + if (reg->funcs[i].qualified_name && strcmp(reg->funcs[i].qualified_name, qn) == 0) + return ®->funcs[i]; + } + return NULL; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +int cbm_ts_lsp_test_signature_func_index(CBMTypeRegistry *reg, const char *qn) { + TSSignatureIndex index = ts_signature_index(reg); + CBMRegisteredFunc *func = ts_signature_func(reg, &index, qn); + int result = func ? (int)(func - reg->funcs) : -1; + cbm_free(CBM_MEM_CLASS_EXTRACT, index.slots); + return result; +} +#endif + // Rebuild a function's CBMRegisteredFunc signature from the AST directly. extract_defs // only handles type_identifier / generic_type / predefined_type for params, so function // types (`(x: Foo) => void`), object types, tuple types etc. are missing from @@ -4854,18 +5008,22 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body // // Called after `register_file_defs` so the func entries already exist; we only mutate // in place. -static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg) { +static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg, + const TSSignatureIndex *index) { if (ts_node_is_null(root) || !reg || !ctx->module_qn) return; - enum { STACK_CAP = 256 }; - TSNode stack[STACK_CAP]; + // Seeded with every top-level child, so a file with more than 256 of them + // needs the growing stack (see TS_WALK_STACK_INLINE). If it cannot grow, the + // walk stops and the declarations not yet reached keep extract_defs' signature. + TSNode inline_stack[TS_WALK_STACK_INLINE]; + TSNode *stack = inline_stack; + int cap = TS_WALK_STACK_INLINE; int top = 0; - uint32_t nc = ts_node_child_count(root); - for (uint32_t i = 0; i < nc && top < STACK_CAP; i++) - stack[top++] = ts_node_child(root, i); + bool walking = + ts_walk_push_children(&stack, &cap, &top, root, inline_stack, "ts_lsp_rebuild_signatures"); - while (top > 0) { + while (walking && top > 0) { TSNode n = stack[--top]; if (ts_node_is_null(n)) continue; @@ -4873,16 +5031,15 @@ static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeR // Recurse into export_statement and class bodies. if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) { - uint32_t cnt = ts_node_child_count(n); - for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) { - stack[top++] = ts_node_child(n, i); - } + walking = ts_walk_push_children(&stack, &cap, &top, n, inline_stack, + "ts_lsp_rebuild_signatures"); continue; } if (strcmp(k, "class_declaration") == 0) { TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body")); - if (!ts_node_is_null(body) && top < STACK_CAP) - stack[top++] = body; + if (!ts_node_is_null(body)) + walking = ts_walk_push(&stack, &cap, &top, body, inline_stack, + "ts_lsp_rebuild_signatures"); continue; } @@ -4993,43 +5150,42 @@ static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeR // Build new signature and patch in registry. const CBMType *rets[2] = {ret, NULL}; const CBMType *new_sig = cbm_type_func(ctx->arena, p_names, p_types, rets); - for (int fi = 0; fi < reg->func_count; fi++) { - if (!reg->funcs[fi].qualified_name) - continue; - if (strcmp(reg->funcs[fi].qualified_name, fqn) != 0) - continue; + CBMRegisteredFunc *func = ts_signature_func(reg, index, fqn); + if (func) { // Preserve the ret type if AST gave UNKNOWN but the existing reg sig had one. - if (cbm_type_is_unknown(ret) && reg->funcs[fi].signature && - reg->funcs[fi].signature->kind == CBM_TYPE_FUNC && - reg->funcs[fi].signature->data.func.return_types && - reg->funcs[fi].signature->data.func.return_types[0] && - !cbm_type_is_unknown(reg->funcs[fi].signature->data.func.return_types[0])) { - const CBMType *preserved[2] = {reg->funcs[fi].signature->data.func.return_types[0], - NULL}; + if (cbm_type_is_unknown(ret) && func->signature && + func->signature->kind == CBM_TYPE_FUNC && func->signature->data.func.return_types && + func->signature->data.func.return_types[0] && + !cbm_type_is_unknown(func->signature->data.func.return_types[0])) { + const CBMType *preserved[2] = {func->signature->data.func.return_types[0], NULL}; new_sig = cbm_type_func(ctx->arena, p_names, p_types, preserved); } - reg->funcs[fi].signature = new_sig; - break; + func->signature = new_sig; } } + cbm_walk_stack_release(stack, inline_stack); } // Convert NAMED references to TYPE_PARAM for any function whose declaration has a // `type_parameters` block. Without this, generic functions like `function id(x: T): T` // register T as NAMED("module.T") instead of TYPE_PARAM("T"), defeating substitution. -static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg) { +static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg, + const TSSignatureIndex *index) { if (ts_node_is_null(root) || !reg || !ctx->module_qn) return; // Walk: function_declaration, class_declaration { method_definition }, plus exported. - enum { STACK_CAP = 256 }; - TSNode stack[STACK_CAP]; + // Seeded with every top-level child, so a file with more than 256 of them needs + // the growing stack (see TS_WALK_STACK_INLINE). If it cannot grow, the walk stops + // and the generics not yet reached keep their NAMED parameters. + TSNode inline_stack[TS_WALK_STACK_INLINE]; + TSNode *stack = inline_stack; + int cap = TS_WALK_STACK_INLINE; int top = 0; - uint32_t nc = ts_node_child_count(root); - for (uint32_t i = 0; i < nc && top < STACK_CAP; i++) - stack[top++] = ts_node_child(root, i); + bool walking = + ts_walk_push_children(&stack, &cap, &top, root, inline_stack, "ts_lsp_type_params"); - while (top > 0) { + while (walking && top > 0) { TSNode n = stack[--top]; if (ts_node_is_null(n)) continue; @@ -5037,16 +5193,15 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp // Recurse into export_statement and class_body. if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) { - uint32_t cnt = ts_node_child_count(n); - for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) { - stack[top++] = ts_node_child(n, i); - } + walking = + ts_walk_push_children(&stack, &cap, &top, n, inline_stack, "ts_lsp_type_params"); continue; } if (strcmp(k, "class_declaration") == 0) { TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body")); - if (!ts_node_is_null(body) && top < STACK_CAP) - stack[top++] = body; + if (!ts_node_is_null(body)) + walking = + ts_walk_push(&stack, &cap, &top, body, inline_stack, "ts_lsp_type_params"); continue; } @@ -5122,17 +5277,14 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp } // Patch the registered func's signature: rewrite NAMED → TYPE_PARAM. - for (int fi = 0; fi < reg->func_count; fi++) { - if (!reg->funcs[fi].qualified_name) - continue; - if (strcmp(reg->funcs[fi].qualified_name, fqn) != 0) - continue; - const CBMType *old_sig = reg->funcs[fi].signature; + CBMRegisteredFunc *func = ts_signature_func(reg, index, fqn); + if (func) { + const CBMType *old_sig = func->signature; if (!old_sig) - break; + continue; const CBMType *new_sig = cbm_type_substitute(ctx->arena, old_sig, names, args); if (new_sig) - reg->funcs[fi].signature = new_sig; + func->signature = new_sig; // Also store the type_param_names for later overload-by-types use. const char **stored = (const char **)cbm_arena_alloc( ctx->arena, (size_t)(tpc + 1) * sizeof(const char *)); @@ -5140,11 +5292,20 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp for (int ti = 0; ti < tpc; ti++) stored[ti] = names[ti]; stored[tpc] = NULL; - reg->funcs[fi].type_param_names = stored; + func->type_param_names = stored; } - break; } } + cbm_walk_stack_release(stack, inline_stack); +} + +// Build once after shape registration, then release before later passes may +// grow the registry. This keeps per-file lookup storage out of the result arena. +static void refine_ast_signatures(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg) { + TSSignatureIndex index = ts_signature_index(reg); + rebuild_signatures_from_ast(ctx, root, reg, &index); + convert_signature_type_params(ctx, root, reg, &index); + cbm_free(CBM_MEM_CLASS_EXTRACT, index.slots); } // Parse a JSDoc comment block for `@param {T} name` and `@returns {T}` tags. For each @@ -5789,8 +5950,7 @@ void cbm_run_ts_lsp(CBMArena *arena, CBMFileResult *result, const char *source, } ast_sweep_shapes(&ctx, root, ®); - rebuild_signatures_from_ast(&ctx, root, ®); - convert_signature_type_params(&ctx, root, ®); + refine_ast_signatures(&ctx, root, ®); apply_jsdoc_signatures(&ctx, root, ®); infer_implicit_returns(&ctx, root, ®); @@ -6043,8 +6203,7 @@ void cbm_run_ts_lsp_cross_with_registry(CBMArena *arena, const char *source, int } ast_sweep_shapes(&ctx, root, &overlay); - rebuild_signatures_from_ast(&ctx, root, &overlay); - convert_signature_type_params(&ctx, root, &overlay); + refine_ast_signatures(&ctx, root, &overlay); apply_jsdoc_signatures(&ctx, root, &overlay); infer_implicit_returns(&ctx, root, &overlay); @@ -6259,8 +6418,7 @@ void cbm_run_ts_lsp_cross(CBMArena *arena, const char *source, int source_len, } ast_sweep_shapes(&ctx, root, ®); - rebuild_signatures_from_ast(&ctx, root, ®); - convert_signature_type_params(&ctx, root, ®); + refine_ast_signatures(&ctx, root, ®); apply_jsdoc_signatures(&ctx, root, ®); infer_implicit_returns(&ctx, root, ®); diff --git a/internal/cbm/lsp/ts_lsp.h b/internal/cbm/lsp/ts_lsp.h index 1096bea90..fc67895b3 100644 --- a/internal/cbm/lsp/ts_lsp.h +++ b/internal/cbm/lsp/ts_lsp.h @@ -72,6 +72,11 @@ typedef struct { // for the calling thread, valid after a cbm_run_ts_lsp on the same thread. long cbm_ts_lsp_test_budget_remaining(void); bool cbm_ts_lsp_test_budget_warned(void); +// Registry entries/probes visited by the two AST signature passes in the last run. +unsigned long cbm_ts_lsp_test_signature_lookup_work(void); +// Inspect signature-index selection and checked walker addition without huge ASTs. +int cbm_ts_lsp_test_signature_func_index(CBMTypeRegistry *reg, const char *qn); +int cbm_ts_lsp_test_walk_pending_count(int top, uint32_t count); // Maximum recursive calls in one member/method lookup during the last TS LSP run, // including attempts rejected by the depth or visited-type guard. int cbm_ts_lsp_test_max_member_lookup_steps(void); diff --git a/src/cli/cli.c b/src/cli/cli.c index 1ebe1687e..6b04a8522 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -23,6 +23,7 @@ #include "foundation/platform.h" #include "foundation/constants.h" #include "foundation/log.h" +#include "foundation/mem_core.h" #include "foundation/sha256.h" #include "foundation/str_util.h" #include "cli/client_adapter.h" @@ -1125,36 +1126,50 @@ static bool is_executable(const char *path) { #endif } +/* Windows ships CLIs as extension-bearing shims -- .cmd from npm, .ps1 from + * mise, .exe from scoop -- so a bare-name probe never matches there. One + * definition serves both the PATH scan and the fallback-directory probe (#221). */ +#ifdef _WIN32 +static const char *const WIN_EXEC_EXTS[] = {".exe", ".cmd", ".bat", ".ps1", NULL}; +#endif + /* Search for an executable named `name` in the PATH environment variable. - * Returns the full path in `out` (max out_sz) if found, else empty string. */ + * Returns the full path in `out` (max out_sz) if found, else empty string. + * + * The PATH copy is sized to the REAL value, not to a fixed 4 KB stack buffer. + * That buffer was the first root cause of #221: a Windows dev machine's PATH + * routinely runs past 4096 characters, and cbm_safe_getenv() REFUSES a value it + * cannot fit rather than truncating it -- so find_in_path() returned false + * before looking at a single directory, and opencode (installed through + * mise/npm/scoop and present on PATH) read as "not installed". The same 4 KB + * cap hid entries on macOS/Linux too; the fix is not Windows-specific. */ static bool find_in_path(const char *name, char *out, size_t out_sz) { - char path_copy[CLI_BUF_4K]; - if (!cbm_safe_getenv("PATH", path_copy, sizeof(path_copy), NULL)) { + char *path_copy = cbm_env_dup("PATH"); + if (!path_copy) { return false; } - char *saveptr; + + /* One exit. PATH is a full live copy of the environment, the scan has + * several ways to stop, and the previous shape returned straight out of the + * loop -- which is exactly what leaked a PATH-sized allocation per call. */ + bool found = false; + char *saveptr = NULL; char *dir = strtok_r(path_copy, PATH_DELIM, &saveptr); - while (dir) { + while (dir != NULL && !found) { snprintf(out, out_sz, "%s/%s", dir, name); - if (is_executable(out)) { - return true; - } + found = is_executable(out); #ifdef _WIN32 - /* On Windows executables carry an extension (PATHEXT). A CLI like - * opencode is often installed as a .cmd / .ps1 / .exe shim (e.g. via - * mise or npm), so the bare-name probe above misses it (#221). Try the - * common executable extensions before moving to the next PATH entry. */ - static const char *const win_exts[] = {".exe", ".cmd", ".bat", ".ps1", NULL}; - for (int i = 0; win_exts[i]; i++) { - snprintf(out, out_sz, "%s/%s%s", dir, name, win_exts[i]); - if (is_executable(out)) { - return true; - } + /* PATHEXT variants for THIS entry, before moving to the next one. */ + for (int i = 0; !found && WIN_EXEC_EXTS[i] != NULL; i++) { + snprintf(out, out_sz, "%s/%s%s", dir, name, WIN_EXEC_EXTS[i]); + found = is_executable(out); } #endif dir = strtok_r(NULL, PATH_DELIM, &saveptr); } - return false; + + cbm_free(CBM_MEM_CLASS_OTHER, path_copy); + return found; } const char *cbm_find_cli(const char *name, const char *home_dir) { @@ -1168,22 +1183,66 @@ const char *cbm_find_cli(const char *name, const char *home_dir) { if (!home_dir || !home_dir[0]) { return ""; } + /* PATH-independent fallbacks, tried in order. Every home-derived entry is + * built from the `home_dir` ARGUMENT and not from cbm_get_home_dir(): a + * dry-run scan or a test passes a synthetic home and must not be shown the + * host's agents. `/` separators are fine on Windows -- the wide-safe file + * layer accepts them, and the rest of this file already composes paths so. */ enum { NUM_PATHS = 5 }; char paths[NUM_PATHS][CLI_BUF_512]; - snprintf(paths[0], sizeof(paths[0]), "/usr/local/bin/%s", name); - snprintf(paths[1], sizeof(paths[1]), "%s/.npm/bin/%s", home_dir, name); - snprintf(paths[2], sizeof(paths[2]), "%s/.local/bin/%s", home_dir, name); - snprintf(paths[3], sizeof(paths[3]), "%s/.cargo/bin/%s", home_dir, name); -#ifdef __APPLE__ - snprintf(paths[4], sizeof(paths[4]), "/opt/homebrew/bin/%s", name); + memset(paths, 0, sizeof(paths)); + int n = 0; +#ifdef _WIN32 + /* Locations that were already probed on Windows before #221 and are real + * there: pipx installs into ~/.local/bin and cargo into ~/.cargo/bin, so + * replacing the list rather than extending it would make pipx- and + * cargo-installed agents disappear. */ + snprintf(paths[n++], sizeof(paths[0]), "%s/.local/bin/%s", home_dir, name); + snprintf(paths[n++], sizeof(paths[0]), "%s/.cargo/bin/%s", home_dir, name); + /* Windows-shaped locations (issue #221, second root cause): npm puts its + * global bin under %APPDATA%\npm, and mise and scoop expose shims under + * %LOCALAPPDATA%\mise\shims and %USERPROFILE%\scoop\shims. Each is read + * through its own environment seam, so the list stays testable. */ + const char *appdata = cbm_app_config_dir(); + if (appdata) { + snprintf(paths[n++], sizeof(paths[0]), "%s/npm/%s", appdata, name); + } + const char *localapp = cbm_app_local_dir(); + if (localapp) { + snprintf(paths[n++], sizeof(paths[0]), "%s/mise/shims/%s", localapp, name); + } + snprintf(paths[n++], sizeof(paths[0]), "%s/scoop/shims/%s", home_dir, name); #else - paths[4][0] = '\0'; + snprintf(paths[n++], sizeof(paths[0]), "/usr/local/bin/%s", name); + snprintf(paths[n++], sizeof(paths[0]), "%s/.npm/bin/%s", home_dir, name); + snprintf(paths[n++], sizeof(paths[0]), "%s/.local/bin/%s", home_dir, name); + snprintf(paths[n++], sizeof(paths[0]), "%s/.cargo/bin/%s", home_dir, name); +#ifdef __APPLE__ + snprintf(paths[n++], sizeof(paths[0]), "/opt/homebrew/bin/%s", name); #endif - for (int i = 0; i < NUM_RETRIES; i++) { - if (paths[i][0] && is_executable(paths[i])) { +#endif + /* NUM_PATHS, not NUM_RETRIES: both are 5 today, so the old bound only + * looked right by coincidence -- what the loop means is the list length. */ + for (int i = 0; i < NUM_PATHS; i++) { + if (!paths[i][0]) { + continue; + } + if (is_executable(paths[i])) { snprintf(buf, sizeof(buf), "%s", paths[i]); return buf; } +#ifdef _WIN32 + /* The npm/mise/scoop shims carry an extension, so every fallback + * directory needs the same PATHEXT probe the PATH scan got. */ + for (int e = 0; WIN_EXEC_EXTS[e] != NULL; e++) { + char extpath[CLI_BUF_512]; + snprintf(extpath, sizeof(extpath), "%s%s", paths[i], WIN_EXEC_EXTS[e]); + if (is_executable(extpath)) { + snprintf(buf, sizeof(buf), "%s", extpath); + return buf; + } + } +#endif } return ""; } @@ -4053,13 +4112,14 @@ static int cbm_reconcile_codex_hooks_command_detailed(const char *config_path, c const char *command_windows, cbm_toml_codex_hook_action_t action, bool check_only, - cbm_toml_codex_hook_failure_t *failure) { + cbm_toml_codex_hook_failure_t *failure, + int *pretool_written) { if (!config_path || !command || !command_windows) { return CLI_ERR; } - return cbm_toml_reconcile_codex_hooks_detailed(config_path, CODEX_HOOK_BEGIN, CODEX_HOOK_END, - command, command_windows, action, - check_only ? 1 : 0, failure) == 0 + return cbm_toml_reconcile_codex_hooks_detailed( + config_path, CODEX_HOOK_BEGIN, CODEX_HOOK_END, command, command_windows, action, + check_only ? 1 : 0, failure, pretool_written) == 0 ? CLI_OK : CLI_ERR; } @@ -4067,7 +4127,7 @@ static int cbm_reconcile_codex_hooks_command(const char *config_path, const char const char *command_windows, cbm_toml_codex_hook_action_t action, bool check_only) { return cbm_reconcile_codex_hooks_command_detailed(config_path, command, command_windows, action, - check_only, NULL); + check_only, NULL, NULL); } static int cbm_upsert_codex_hooks_command(const char *config_path, const char *command, const char *command_windows) { @@ -6509,6 +6569,35 @@ static int cbm_remove_paired_lifecycle_hooks_json(const char *settings_path, return session_result == CLI_OK && subagent_result == CLI_OK ? CLI_OK : CLI_ERR; } +/* Codex names its shell tool Bash and passes {"command": "..."} as tool_input, + * the payload hook-augment already augments for Claude Code. Only the entry whose + * command is exactly ours is touched; the user's own PreToolUse hooks stay. */ +static int cbm_upsert_codex_pretool_hooks_json(const char *settings_path, const char *command, + const char *command_windows) { + return upsert_hooks_json((hooks_upsert_args_t){ + .settings_path = settings_path, + .hook_event = "PreToolUse", + .matcher_str = "Bash", + .command_str = command, + .command_windows = command_windows, + .timeout_value = CMM_HOOK_TIMEOUT_SEC, + .match_command_exact = command, + }); +} + +static int cbm_remove_codex_pretool_hooks_json(const char *settings_path, + const char *canonical_command) { + if (!canonical_command) { + return CLI_ERR; + } + return remove_hooks_json((hooks_remove_args_t){ + .settings_path = settings_path, + .hook_event = "PreToolUse", + .matcher_str = "Bash", + .match_command_exact = canonical_command, + }); +} + static int cbm_remove_qwen_lifecycle_hooks(const char *settings_path, const char *binary_path, bool windows) { char command[CLI_BUF_8K]; @@ -9745,7 +9834,7 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const cbm_toml_codex_hook_failure_t preflight_failure = CBM_TOML_CODEX_HOOK_FAILURE_NONE; int preflight_result = commands_ok ? cbm_reconcile_codex_hooks_command_detailed( cp, command, command_windows, preflight_action, - true, &preflight_failure) + true, &preflight_failure, NULL) : CLI_ERR; if (preflight_result != CLI_OK) { if (!g_install_plan) { @@ -9788,19 +9877,29 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const plan_record("Codex CLI", "hook", hook_target); } else { bool hook_ok = true; + int pretool_written = 1; if (!dry_run && use_hooks_json) { hook_ok = cbm_upsert_paired_lifecycle_hooks_json(hooks_json, command, command_windows, NULL, CMM_HOOK_TIMEOUT_SEC) == CLI_OK && + cbm_upsert_codex_pretool_hooks_json(hooks_json, command, command_windows) == + CLI_OK && cbm_reconcile_codex_hooks_command(cp, command, command_windows, CBM_TOML_CODEX_HOOK_REMOVE, false) == CLI_OK; } else if (!dry_run) { - hook_ok = cbm_upsert_codex_hooks_command(cp, command, command_windows) == CLI_OK; + hook_ok = cbm_reconcile_codex_hooks_command_detailed( + cp, command, command_windows, CBM_TOML_CODEX_HOOK_UPSERT, false, NULL, + &pretool_written) == CLI_OK; } if (!hook_ok) { record_agent_config_error(false, "Codex CLI", "hook_install", hook_target); } else { - printf(" hooks: SessionStart + SubagentStart (dynamic graph context)\n"); + printf(pretool_written + ? " hooks: SessionStart + SubagentStart + PreToolUse Bash (dynamic " + "graph context)\n" + : " hooks: SessionStart + SubagentStart (dynamic graph context); " + "PreToolUse skipped: config.toml already has an inline " + "hooks.PreToolUse\n"); printf(" note: non-managed hooks require /hooks trust; definition changes " "require re-trust\n"); } @@ -12226,10 +12325,10 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char installed_binary, hook_command_windows, sizeof(hook_command_windows)) == CLI_OK; cbm_toml_codex_hook_failure_t preflight_failure = CBM_TOML_CODEX_HOOK_FAILURE_NONE; - bool hook_preflight_ok = - hook_commands_ok && cbm_reconcile_codex_hooks_command_detailed( - cp, hook_command, hook_command_windows, - CBM_TOML_CODEX_HOOK_REMOVE, true, &preflight_failure) == CLI_OK; + bool hook_preflight_ok = hook_commands_ok && cbm_reconcile_codex_hooks_command_detailed( + cp, hook_command, hook_command_windows, + CBM_TOML_CODEX_HOOK_REMOVE, true, + &preflight_failure, NULL) == CLI_OK; if (!hook_preflight_ok) { printf("Codex CLI:\n"); fflush(stdout); @@ -12264,7 +12363,8 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char snprintf(hooks_json, sizeof(hooks_json), "%s/hooks.json", config_dir); if (cbm_file_exists(hooks_json) && (!hook_command_ok || - cbm_remove_paired_lifecycle_hooks_json(hooks_json, hook_command) != CLI_OK)) { + cbm_remove_paired_lifecycle_hooks_json(hooks_json, hook_command) != CLI_OK || + cbm_remove_codex_pretool_hooks_json(hooks_json, hook_command) != CLI_OK)) { record_agent_config_error(true, "Codex CLI", "json_hook_uninstall", hooks_json); } } diff --git a/src/cli/config_toml_edit.c b/src/cli/config_toml_edit.c index 5ffcbafaa..8f0931279 100644 --- a/src/cli/config_toml_edit.c +++ b/src/cli/config_toml_edit.c @@ -3379,7 +3379,8 @@ static int toml_codex_inline_is_owned(const char *data, size_t start, size_t end toml_string_t type = {0}; toml_string_t command = {0}; toml_string_t windows = {0}; - const char *expected_matcher = event == 0 ? "startup|resume|clear|compact" : "*"; + const char *expected_matcher = + event == 0 ? "startup|resume|clear|compact" : (event == 1 ? "*" : "Bash"); int result = 0; if (toml_codex_take(data, end, &pos, "[") != TOML_EDIT_OK || toml_codex_take(data, end, &pos, "{") != TOML_EDIT_OK) @@ -3432,11 +3433,15 @@ done: toml_string_dispose(&windows); return result; } +/* include_pretool renders the current block: the lifecycle pair plus a PreToolUse + * Bash hook, so Codex shell searches get graph context in flow. Without it the + * function renders the previous block (lifecycle pair only), which installs up to + * v0.12 wrote and which upgrades and uninstalls must still recognize as owned. */ static int toml_codex_build_block(const char *command, const char *command_windows, - toml_buffer_t *block) { + int include_pretool, toml_buffer_t *block) { char escaped[8192]; char escaped_windows[8192]; - char rendered[24576]; + char rendered[32768]; if (cbm_toml_escape_basic_string(command, escaped, sizeof(escaped)) != TOML_EDIT_OK || cbm_toml_escape_basic_string(command_windows, escaped_windows, sizeof(escaped_windows)) != TOML_EDIT_OK || @@ -3455,6 +3460,16 @@ static int toml_codex_build_block(const char *command, const char *command_windo "type = \"command\"\ncommand = \"%s\"\n" "command_windows = \"%s\"\ntimeout = 5\n", escaped, escaped_windows, escaped, escaped_windows); + if (written > 0 && (size_t)written < sizeof(rendered) && include_pretool) { + int more = snprintf(rendered + written, sizeof(rendered) - (size_t)written, + "\n[[hooks.PreToolUse]]\nmatcher = \"Bash\"\n\n" + "[[hooks.PreToolUse.hooks]]\n" + "type = \"command\"\ncommand = \"%s\"\n" + "command_windows = \"%s\"\ntimeout = 5\n", + escaped, escaped_windows); + written = + more > 0 && (size_t)more < sizeof(rendered) - (size_t)written ? written + more : -1; + } return written > 0 && (size_t)written < sizeof(rendered) ? toml_buffer_append(block, rendered, (size_t)written) : TOML_EDIT_ERR; @@ -3485,19 +3500,24 @@ static size_t toml_codex_owned_span(const char *data, size_t len, const char *be } toml_assignment_dispose(&assignment); } - toml_buffer_t block = {0}; - toml_buffer_t expected = {0}; - if (toml_codex_current_command_is_owned(values[0].data, values[0].len) && - toml_codex_current_command_is_owned(values[1].data, values[1].len) && - toml_codex_build_block(values[0].data, values[1].data, &block) == TOML_EDIT_OK && - (marked ? toml_append_managed(&expected, begin_marker, end_marker, block.data, newline) - : toml_append_normalized_text(&expected, block.data, block.len, newline)) == - TOML_EDIT_OK && - expected.len <= len && memcmp(expected.data, data, expected.len) == 0) { - result = expected.len; + /* Either generation is ours: the current block with PreToolUse first, then the + * previous lifecycle-only block, so upgrade and uninstall never strand it. */ + for (int include_pretool = 1; include_pretool >= 0 && result == 0U; --include_pretool) { + toml_buffer_t block = {0}; + toml_buffer_t expected = {0}; + if (toml_codex_current_command_is_owned(values[0].data, values[0].len) && + toml_codex_current_command_is_owned(values[1].data, values[1].len) && + toml_codex_build_block(values[0].data, values[1].data, include_pretool, &block) == + TOML_EDIT_OK && + (marked ? toml_append_managed(&expected, begin_marker, end_marker, block.data, newline) + : toml_append_normalized_text(&expected, block.data, block.len, newline)) == + TOML_EDIT_OK && + expected.len <= len && memcmp(expected.data, data, expected.len) == 0) { + result = expected.len; + } + toml_buffer_dispose(&expected); + toml_buffer_dispose(&block); } - toml_buffer_dispose(&expected); - toml_buffer_dispose(&block); done: toml_string_dispose(&values[0]); toml_string_dispose(&values[1]); @@ -3509,14 +3529,21 @@ static int toml_codex_add_edit(toml_codex_edit_t *edits, size_t *count, size_t s edits[(*count)++] = (toml_codex_edit_t){.start = start, .end = end}; return TOML_EDIT_OK; } +/* Inline forms Codex may normalize our hooks to (#1432). SessionStart and + * SubagentStart must be ours or the edit is refused. A foreign inline PreToolUse + * is the user's own hook: it is kept, and *foreign_pretool tells the caller to + * leave PreToolUse out of the block, because TOML cannot hold an inline array and + * array-of-tables entries for the same key. */ static int toml_codex_scan_inline(const char *data, size_t len, toml_codex_edit_t *edits, - size_t *edit_count, int found[2], + size_t *edit_count, int found[3], int *foreign_pretool, cbm_toml_codex_hook_failure_t *failure) { - toml_key_path_t wanted[2] = {{0}}; + toml_key_path_t wanted[3] = {{0}}; toml_key_path_t scope = {0}; if (toml_parse_key_path("hooks.SessionStart", 0U, strlen("hooks.SessionStart"), &wanted[0]) != TOML_EDIT_OK || toml_parse_key_path("hooks.SubagentStart", 0U, strlen("hooks.SubagentStart"), &wanted[1]) != + TOML_EDIT_OK || + toml_parse_key_path("hooks.PreToolUse", 0U, strlen("hooks.PreToolUse"), &wanted[2]) != TOML_EDIT_OK) { toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_EDIT_BUILD); goto error; @@ -3554,9 +3581,12 @@ static int toml_codex_scan_inline(const char *data, size_t len, toml_codex_edit_ toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_EDIT_BUILD); goto error; } - int event = toml_key_path_equal(&full_key, &wanted[0]) - ? 0 - : (toml_key_path_equal(&full_key, &wanted[1]) ? 1 : -1); + int event = -1; + for (int candidate = 0; candidate < 3 && event < 0; ++candidate) { + if (toml_key_path_equal(&full_key, &wanted[candidate])) { + event = candidate; + } + } toml_key_path_dispose(&full_key); if (event >= 0) { size_t tail = assignment.value_end; @@ -3564,12 +3594,14 @@ static int toml_codex_scan_inline(const char *data, size_t len, toml_codex_edit_ size_t payload_start = toml_codex_payload_start(data, len); size_t edit_start = line.start == 0U ? payload_start : line.start; cbm_toml_codex_hook_failure_t event_failure = CBM_TOML_CODEX_HOOK_FAILURE_NONE; - ++found[event]; - if (found[event] > 1) { + int owned = !assignment.multiline_value && tail == line.content_end && + toml_codex_inline_is_owned(data, assignment.value_start, + assignment.value_end, event); + if (event == 2 && !owned) { + *foreign_pretool = 1; + } else if (++found[event] > 1) { event_failure = CBM_TOML_CODEX_HOOK_FAILURE_CONFLICTING_HOOKS; - } else if (assignment.multiline_value || tail != line.content_end || - !toml_codex_inline_is_owned(data, assignment.value_start, - assignment.value_end, event)) { + } else if (!owned) { event_failure = CBM_TOML_CODEX_HOOK_FAILURE_AMBIGUOUS_OWNERSHIP; } else if (toml_codex_add_edit(edits, edit_count, edit_start, line.full_end) != TOML_EDIT_OK) { @@ -3600,14 +3632,19 @@ done: toml_key_path_dispose(&scope); toml_key_path_dispose(&wanted[0]); toml_key_path_dispose(&wanted[1]); + toml_key_path_dispose(&wanted[2]); return result; } int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *begin_marker, const char *end_marker, const char *command, const char *command_windows, cbm_toml_codex_hook_action_t action, int check_only, - cbm_toml_codex_hook_failure_t *failure) { + cbm_toml_codex_hook_failure_t *failure, + int *pretool_written) { toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_NONE); + if (pretool_written) { + *pretool_written = 0; + } if (!toml_valid_path(file_path) || !toml_valid_marker(begin_marker) || !toml_valid_marker(end_marker) || strcmp(begin_marker, end_marker) == 0 || !command || !command_windows || @@ -3616,7 +3653,7 @@ int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *b return TOML_EDIT_ERR; } toml_buffer_t block = {0}; - if (toml_codex_build_block(command, command_windows, &block) != TOML_EDIT_OK) { + if (toml_codex_build_block(command, command_windows, 1, &block) != TOML_EDIT_OK) { toml_buffer_dispose(&block); toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_COMMAND_RENDER); return TOML_EDIT_ERR; @@ -3687,15 +3724,23 @@ int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *b pos += owned_len - 1U; } } - int inline_found[2] = {0}; - if (toml_codex_scan_inline(existing, existing_len, edits, &edit_count, inline_found, failure) != - TOML_EDIT_OK) { + int inline_found[3] = {0}; + int foreign_pretool = 0; + if (toml_codex_scan_inline(existing, existing_len, edits, &edit_count, inline_found, + &foreign_pretool, failure) != TOML_EDIT_OK) { goto error; } - if (markerless_count && (has_pair || inline_found[0] || inline_found[1])) { + if (markerless_count && (has_pair || inline_found[0] || inline_found[1] || inline_found[2])) { toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_CONFLICTING_HOOKS); goto error; } + if (foreign_pretool) { + toml_buffer_dispose(&block); + if (toml_codex_build_block(command, command_windows, 0, &block) != TOML_EDIT_OK) { + toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_COMMAND_RENDER); + goto error; + } + } for (size_t i = 0U; i < edit_count; ++i) { for (size_t j = i + 1U; j < edit_count; ++j) { if (edits[j].start < edits[i].start) { @@ -3737,6 +3782,8 @@ int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *b output.len, &snapshot); if (result != TOML_EDIT_OK) { toml_codex_set_failure(failure, CBM_TOML_CODEX_HOOK_FAILURE_CONFIG_WRITE); + } else if (pretool_written) { + *pretool_written = action == CBM_TOML_CODEX_HOOK_UPSERT && !foreign_pretool; } error: toml_buffer_dispose(&output); @@ -3749,5 +3796,5 @@ int cbm_toml_reconcile_codex_hooks(const char *file_path, const char *begin_mark const char *command_windows, cbm_toml_codex_hook_action_t action, int check_only) { return cbm_toml_reconcile_codex_hooks_detailed(file_path, begin_marker, end_marker, command, - command_windows, action, check_only, NULL); + command_windows, action, check_only, NULL, NULL); } diff --git a/src/cli/config_toml_edit.h b/src/cli/config_toml_edit.h index 6b944ee19..430401b3b 100644 --- a/src/cli/config_toml_edit.h +++ b/src/cli/config_toml_edit.h @@ -98,12 +98,16 @@ typedef enum { /* Returns a stable, content-free diagnostic name for a failure category. */ const char *cbm_toml_codex_hook_failure_name(cbm_toml_codex_hook_failure_t failure); /* `failure` may be NULL. When non-NULL, it is reset to NONE on entry and remains - * NONE on success; failure categories never contain configuration content. */ + * NONE on success; failure categories never contain configuration content. + * `pretool_written` may be NULL. When non-NULL it is set to 1 after a successful + * upsert that wrote the PreToolUse Bash hook, and to 0 when a foreign inline + * hooks.PreToolUse made the upsert keep only the lifecycle pair. */ int cbm_toml_reconcile_codex_hooks_detailed(const char *file_path, const char *begin_marker, const char *end_marker, const char *command, const char *command_windows, cbm_toml_codex_hook_action_t action, int check_only, - cbm_toml_codex_hook_failure_t *failure); + cbm_toml_codex_hook_failure_t *failure, + int *pretool_written); int cbm_toml_reconcile_codex_hooks(const char *file_path, const char *begin_marker, const char *end_marker, const char *command, const char *command_windows, cbm_toml_codex_hook_action_t action, diff --git a/src/foundation/platform.c b/src/foundation/platform.c index 0c4c6f9df..d11df686e 100644 --- a/src/foundation/platform.c +++ b/src/foundation/platform.c @@ -7,6 +7,7 @@ #include "foundation/compat.h" #include "foundation/constants.h" +#include "foundation/mem_core.h" #include "foundation/platform_internal.h" #include #include @@ -467,6 +468,82 @@ const char *cbm_native_getenv(const char *name, char *buf, size_t buf_sz, const return NULL; } +/* See platform.h. The long-value sibling of cbm_safe_getenv: there is no caller + * buffer to overrun, so a PATH past 4 KB is read in full instead of being + * refused (#221). Both the wide staging buffer and the UTF-8 result are + * allocated through the memory core, which is also the only allocator the + * caller may release them with -- keeping the memory-core ratchet honest means + * this file adds no raw allocation site to pay for the new read. */ +char *cbm_env_dup(const char *name) { + if (!name || !name[0]) { + return NULL; + } + /* The same view cbm_safe_getenv gives: the startup settings resolver + * answers first, and a handled NULL masks the variable. */ + const char *resolved = NULL; + if (environment_resolver && environment_resolver(name, &resolved)) { + return resolved ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, resolved) : NULL; + } +#ifdef _WIN32 + /* Same wide route as cbm_safe_getenv: _environ holds ANSI-code-page bytes, + * so a non-ASCII PATH would arrive mojibake'd or with '?' substituted -- + * invalid in a Windows path and fatal to every file API downstream. */ + wchar_t wname[CBM_SZ_256]; + int wn = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name, -1, wname, CBM_SZ_256); + if (wn <= 0) { + return NULL; + } + SetLastError(ERROR_SUCCESS); + DWORD needed = GetEnvironmentVariableW(wname, NULL, 0U); + DWORD lookup_error = GetLastError(); + if (needed == 0U) { + if (lookup_error != ERROR_ENVVAR_NOT_FOUND) { + /* Present but empty. Distinct from unset, and callers are entitled + * to tell the two apart, so return "" and not NULL. */ + return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, ""); + } + return NULL; + } + wchar_t *wvalue = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)needed * sizeof(*wvalue)); + if (!wvalue) { + return NULL; + } + SetLastError(ERROR_SUCCESS); + DWORD got = GetEnvironmentVariableW(wname, wvalue, needed); + DWORD read_error = GetLastError(); + if (got >= needed || (got == 0U && read_error != ERROR_SUCCESS)) { + cbm_free(CBM_MEM_CLASS_OTHER, wvalue); + return NULL; + } + int utf8_size = + WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wvalue, -1, NULL, 0, NULL, NULL); + char *utf8 = utf8_size > 0 ? cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)utf8_size) : NULL; + int converted = utf8 ? WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wvalue, -1, utf8, + utf8_size, NULL, NULL) + : 0; + if (!utf8 || converted != utf8_size) { + cbm_free(CBM_MEM_CLASS_OTHER, utf8); + cbm_free(CBM_MEM_CLASS_OTHER, wvalue); + return NULL; + } + cbm_free(CBM_MEM_CLASS_OTHER, wvalue); + return utf8; +#else + /* Same environ walk as cbm_safe_getenv: getenv() returns a pointer another + * thread can invalidate with setenv(), so the value is copied out here. */ + char **env = CBM_ENVIRON; + if (env) { + size_t name_len = strlen(name); + for (; *env; env++) { + if (strncmp(*env, name, name_len) == 0 && (*env)[name_len] == '=') { + return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, *env + name_len + SKIP_ONE); + } + } + } + return NULL; +#endif +} + /* See platform.h. The shape here is the one src/main.c:1104 already uses for * --port=: an end pointer says where the read stopped, errno catches a number * too large, and *end == '\0' catches anything left over. */ diff --git a/src/foundation/platform.h b/src/foundation/platform.h index 806e49a09..40b020222 100644 --- a/src/foundation/platform.h +++ b/src/foundation/platform.h @@ -147,6 +147,21 @@ const char *cbm_runtime_getenv(const char *name); /* Explicit inherited environment access for bootstrap/settings provenance. */ const char *cbm_native_getenv(const char *name, char *buf, size_t buf_sz, const char *fallback); +/* Allocating sibling of cbm_safe_getenv, for a value that must not be capped at + * a caller-chosen size. A Windows PATH routinely runs past the 4096-byte buffer + * cbm_safe_getenv's callers pass, and the copy helper REPORTS that as failure + * rather than truncating -- so the whole PATH was skipped and every directory + * past the cut became invisible (#221). + * + * Returns a UTF-8 copy owned by the memory core: release it with + * cbm_free(CBM_MEM_CLASS_OTHER, ptr), never free(). NULL when the variable is + * unset or cannot be read; a variable that exists but is empty yields "". + * On Windows the value is read with the wide API and converted, the same route + * cbm_safe_getenv takes, so a non-ASCII value survives intact. Like + * cbm_safe_getenv, an installed settings resolver answers before the process + * environment does. */ +char *cbm_env_dup(const char *name); + /* Read an environment variable as a whole number. * * Answers true only when the variable is set, is not empty, and reads cleanly diff --git a/tests/test_cli.c b/tests/test_cli.c index 539b0df18..2c4cf2a31 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -4245,6 +4245,212 @@ TEST(cli_find_cli_fallback_paths) { PASS(); } +/* ── #221 regression: PATH longer than the old 4 KB copy ───────────────── */ + +/* Snapshot/restore one environment variable. The suite swaps PATH and the + * Windows profile roots, and a leaked swap would poison every later suite. */ +static char *cli_issue221_save_env(const char *name) { + const char *value = getenv(name); + return value ? strdup(value) : NULL; +} + +static void cli_issue221_restore_env(const char *name, char *saved) { + if (saved) { + cbm_setenv(name, saved, 1); + free(saved); + } else { + cbm_unsetenv(name); + } +} + +/* + * cli_find_cli_long_path_beyond_four_kb_issue221 + * + * find_in_path() used to copy PATH into a fixed 4096-byte stack buffer through + * cbm_safe_getenv(). That helper does not truncate -- it REFUSES a value it + * cannot fit -- so on a machine whose PATH exceeds 4096 characters the whole + * PATH was skipped and every agent on it read as "not installed". That is the + * layout the #221 reporter had: mise, npm and scoop each append entries. + * + * The fixture directory is the LAST PATH entry, so any truncation of the copy + * hides exactly the directory under test and nothing else. This case is not + * Windows-specific -- the same 4 KB cap hid entries on macOS and Linux. + * + * RED before the fix (empty result), GREEN after it. + */ +TEST(cli_find_cli_long_path_beyond_four_kb_issue221) { + const char *created = th_mktempdir("repro221long"); + if (!created) { + FAIL("th_mktempdir failed"); + } + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s", created); + +#ifdef _WIN32 + const char *fixture_name = "cbm-repro221-long.cmd"; + const char *fixture_body = "@echo off\r\nrem fake long-PATH agent\r\n"; + const char path_delim = ';'; +#else + const char *fixture_name = "cbm-repro221-long"; + const char *fixture_body = "#!/bin/sh\n# fake long-PATH agent\n"; + const char path_delim = ':'; +#endif + + char fixture_path[1024]; + snprintf(fixture_path, sizeof(fixture_path), "%s/%s", tmpdir, fixture_name); + if (th_write_file(fixture_path, fixture_body) != 0) { + th_cleanup(tmpdir); + FAIL("could not write the long-PATH fixture"); + } + th_make_executable(fixture_path); + + /* + * Filler entries name directories that do not exist: the scan has to walk + * past every one of them to reach the fixture, which is appended after the + * filler so that it always sits beyond the 4096-byte mark. + */ + enum { FILLER_TARGET = 4600, PATH_CAP = FILLER_TARGET + 1024 }; + char long_path[PATH_CAP]; + size_t used = 0U; + while (used < (size_t)FILLER_TARGET) { + int written = snprintf(long_path + used, sizeof(long_path) - used, + "/nonexistent-repro221-filler%05zu%c", used, path_delim); + if (written <= 0 || (size_t)written >= sizeof(long_path) - used) { + th_cleanup(tmpdir); + FAIL("could not build the long PATH filler"); + } + used += (size_t)written; + } + ASSERT_GT(used, 4096); + int tail = snprintf(long_path + used, sizeof(long_path) - used, "%s", tmpdir); + if (tail <= 0 || (size_t)tail >= sizeof(long_path) - used) { + th_cleanup(tmpdir); + FAIL("could not append the fixture directory to the long PATH"); + } + + char *saved_path = cli_issue221_save_env("PATH"); + cbm_setenv("PATH", long_path, 1); + + /* A home that does not exist keeps the PATH-independent fallbacks out of + * the way: the fixture's directory is the only place that can satisfy it. */ + const char *result = cbm_find_cli("cbm-repro221-long", "/nonexistent-repro221-home"); + + cli_issue221_restore_env("PATH", saved_path); + + ASSERT_NOT_NULL(result); + ASSERT(result[0] != '\0'); + ASSERT(strncmp(result, tmpdir, strlen(tmpdir)) == 0); + ASSERT(strstr(result, "cbm-repro221-long") != NULL); + + th_cleanup(tmpdir); + PASS(); +} + +/* ── #221 regression: the Windows fallback shim locations ───────────────── */ + +/* + * cli_find_cli_windows_fallback_shims_issue221 + * + * An agent the PATH scan cannot see must still be found where Windows actually + * puts CLIs: %APPDATA%\npm (npm's global bin), %LOCALAPPDATA%\mise\shims, + * %USERPROFILE%\scoop\shims, plus the pipx and cargo homes (~\.local\bin, + * ~\.cargo\bin) that were already probed before #221 passed and must not be + * dropped by the fix. + * + * Each location is reached through its own seam -- APPDATA / LOCALAPPDATA, or + * the home_dir argument -- so this never reads the developer's real profile. + * Every shim carries a .cmd extension, which is why the fallback probe needs + * the same PATHEXT treatment the PATH scan got. + * + * Windows-only: the directory layout it asserts is the one Windows installers + * produce. */ +TEST(cli_find_cli_windows_fallback_shims_issue221) { +#ifndef _WIN32 + SKIP_PLATFORM("Windows-only: npm/mise/scoop shim fallback layout"); +#else + const char *created = th_mktempdir("repro221fb"); + if (!created) { + FAIL("th_mktempdir failed"); + } + char root[512]; + snprintf(root, sizeof(root), "%s", created); + + char *saved_appdata = cli_issue221_save_env("APPDATA"); + char *saved_localappdata = cli_issue221_save_env("LOCALAPPDATA"); + char *saved_userprofile = cli_issue221_save_env("USERPROFILE"); + char *saved_path = cli_issue221_save_env("PATH"); + + /* APPDATA/LOCALAPPDATA/USERPROFILE all point at the fixture root. The + * scoop, pipx and cargo entries are built from the home_dir ARGUMENT, so + * USERPROFILE here mirrors what a real profile would supply. */ + cbm_setenv("APPDATA", root, 1); + cbm_setenv("LOCALAPPDATA", root, 1); + cbm_setenv("USERPROFILE", root, 1); + cbm_setenv("PATH", "/nonexistent-repro221-path", 1); + + /* One shim at a time, so the returned path proves which entry matched + * rather than merely that something did. Each case removes its shim before + * the next one runs, so the earlier entries cannot shadow the later ones. */ + const struct { + const char *subdir; + const char *label; + } cases[] = { + {".local/bin", "pipx home"}, {".cargo/bin", "cargo home"}, {"npm", "APPDATA npm"}, + {"mise/shims", "LOCALAPPDATA mise"}, {"scoop/shims", "scoop home"}, + }; + const char *shim_name = "cbm-repro221-fb.cmd"; + const size_t case_count = sizeof(cases) / sizeof(cases[0]); + + for (size_t i = 0; i < case_count; i++) { + char dir[512]; + snprintf(dir, sizeof(dir), "%s/%s", root, cases[i].subdir); + if (th_mkdir_p(dir) != 0) { + cli_issue221_restore_env("PATH", saved_path); + cli_issue221_restore_env("USERPROFILE", saved_userprofile); + cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata); + cli_issue221_restore_env("APPDATA", saved_appdata); + th_cleanup(root); + FAIL("could not create the fallback fixture directory"); + } + char shim[1024]; + snprintf(shim, sizeof(shim), "%s/%s", dir, shim_name); + if (th_write_file(shim, "@echo off\r\nrem fake fallback shim\r\n") != 0) { + cli_issue221_restore_env("PATH", saved_path); + cli_issue221_restore_env("USERPROFILE", saved_userprofile); + cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata); + cli_issue221_restore_env("APPDATA", saved_appdata); + th_cleanup(root); + FAIL("could not write the fallback shim"); + } + + const char *result = cbm_find_cli("cbm-repro221-fb", root); + char expected_prefix[512]; + snprintf(expected_prefix, sizeof(expected_prefix), "%s/%s/", root, cases[i].subdir); + bool matched = result != NULL && result[0] != '\0' && + strncmp(result, expected_prefix, strlen(expected_prefix)) == 0; + (void)remove(shim); + + if (!matched) { + printf(" fallback case %zu (%s) did not resolve in %s\n", i, cases[i].label, dir); + cli_issue221_restore_env("PATH", saved_path); + cli_issue221_restore_env("USERPROFILE", saved_userprofile); + cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata); + cli_issue221_restore_env("APPDATA", saved_appdata); + th_cleanup(root); + FAIL("a Windows fallback shim location was not probed"); + } + } + + cli_issue221_restore_env("PATH", saved_path); + cli_issue221_restore_env("USERPROFILE", saved_userprofile); + cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata); + cli_issue221_restore_env("APPDATA", saved_appdata); + + th_cleanup(root); + PASS(); +#endif +} + /* ═══════════════════════════════════════════════════════════════════ * Dry-run flag parsing (port of TestDryRun) * ═══════════════════════════════════════════════════════════════════ */ @@ -12197,6 +12403,89 @@ TEST(cli_codex_session_hook_issue330) { PASS(); } +/* The block installs up to v0.12 wrote: the lifecycle pair only, inside the + * markers. Upgrades and uninstalls must still recognize it as ours. */ +static const char test_codex_previous_hook_block[] = + "# >>> codebase-memory-mcp SessionStart >>>\n" + "[[hooks.SessionStart]]\n" + "matcher = \"startup|resume|clear|compact\"\n\n" + "[[hooks.SessionStart.hooks]]\n" + "type = \"command\"\ncommand = \"codebase-memory-mcp hook-augment\"\n" + "command_windows = \"codebase-memory-mcp hook-augment\"\ntimeout = 5\n\n" + "[[hooks.SubagentStart]]\nmatcher = \"*\"\n\n" + "[[hooks.SubagentStart.hooks]]\n" + "type = \"command\"\ncommand = \"codebase-memory-mcp hook-augment\"\n" + "command_windows = \"codebase-memory-mcp hook-augment\"\ntimeout = 5\n" + "# <<< codebase-memory-mcp SessionStart <<<\n"; + +/* Codex shell searches get graph context in flow: the managed block adds a + * PreToolUse Bash hook, upgrades the previous block in place, removes either + * generation, keeps a user's own inline hooks.PreToolUse (TOML cannot mix an + * inline array with array-of-tables entries for one key), and replaces the + * inline form Codex may normalize ours to. */ +TEST(cli_codex_pretooluse_hook_lifecycle) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codexpre-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + char cfg[512]; + snprintf(cfg, sizeof(cfg), "%s/config.toml", tmpdir); + + /* fresh install: one PreToolUse Bash hook running hook-augment, idempotent */ + write_test_file(cfg, "[mcp_servers.other]\ncommand = \"x\"\n"); + ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0); + ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0); + const char *d = read_test_file(cfg); + ASSERT_NOT_NULL(d); + ASSERT_EQ(test_count_substring(d, "[[hooks.PreToolUse]]\nmatcher = \"Bash\""), 1U); + ASSERT_EQ(test_count_substring(d, "[[hooks.PreToolUse.hooks]]"), 1U); + ASSERT_EQ(test_count_substring(d, "[[hooks.SessionStart]]"), 1U); + ASSERT_EQ(cbm_remove_codex_hooks(cfg), 0); + d = read_test_file(cfg); + ASSERT_NULL(strstr(d, "hooks.PreToolUse")); + ASSERT_NOT_NULL(strstr(d, "[mcp_servers.other]")); + + /* upgrade: the previous block is replaced, never refused as ambiguous */ + char previous[2048]; + snprintf(previous, sizeof(previous), "[mcp_servers.other]\ncommand = \"x\"\n\n%s", + test_codex_previous_hook_block); + write_test_file(cfg, previous); + ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0); + d = read_test_file(cfg); + ASSERT_EQ(test_count_substring(d, "[[hooks.SessionStart]]"), 1U); + ASSERT_EQ(test_count_substring(d, "[[hooks.PreToolUse]]"), 1U); + + /* uninstall of the previous generation */ + write_test_file(cfg, previous); + ASSERT_EQ(cbm_remove_codex_hooks(cfg), 0); + d = read_test_file(cfg); + ASSERT_NULL(strstr(d, "hooks.SessionStart")); + ASSERT_NULL(strstr(d, "hook-augment")); + + /* a user's own inline PreToolUse stays, and our block leaves PreToolUse out */ + const char *foreign = "[hooks]\nPreToolUse = [{ matcher = \"Bash\", hooks = [{ type = " + "\"command\", command = \"/usr/local/bin/audit-shell\" }] }]\n"; + write_test_file(cfg, foreign); + ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0); + d = read_test_file(cfg); + ASSERT_NOT_NULL(strstr(d, "audit-shell")); + ASSERT_NULL(strstr(d, "[[hooks.PreToolUse]]")); + ASSERT_EQ(test_count_substring(d, "[[hooks.SessionStart]]"), 1U); + + /* Codex's inline form of our own PreToolUse is replaced, not duplicated */ + write_test_file(cfg, "[hooks]\nPreToolUse = [{ matcher = \"Bash\", hooks = [{ type = " + "\"command\", command = \"codebase-memory-mcp hook-augment\", " + "command_windows = \"codebase-memory-mcp hook-augment\", " + "timeout = 5 }] }]\n"); + ASSERT_EQ(cbm_upsert_codex_hooks(cfg), 0); + d = read_test_file(cfg); + ASSERT_NULL(strstr(d, "PreToolUse = [")); + ASSERT_EQ(test_count_substring(d, "[[hooks.PreToolUse]]"), 1U); + + test_rmdir_r(tmpdir); + PASS(); +} + /* Gemini/Antigravity SessionStart reminder parity (settings.json JSON path). */ TEST(cli_gemini_session_hook_parity) { char tmpdir[256]; @@ -13221,7 +13510,8 @@ TEST(cli_codex_migrates_to_single_hook_representation) { bool lifecycle_ok = first_rc == 0 && repeat_rc == 0 && dry_rc == 0 && first && repeated && after_dry && strcmp(first, repeated) == 0 && strcmp(first, after_dry) == 0; bool migrated = migration_rc == 0 && toml && !strstr(toml, "SessionStart") && hooks && - strstr(hooks, "SessionStart") && strstr(hooks, "SubagentStart"); + strstr(hooks, "SessionStart") && strstr(hooks, "SubagentStart") && + strstr(hooks, "PreToolUse"); free(first); free(repeated); free(after_dry); @@ -13580,6 +13870,47 @@ TEST(cli_hook_augment_bash_pretooluse_reaches_augmenter) { PASS(); } +/* Codex sends PreToolUse for its shell tool as tool_name "Bash" with a string + * tool_input.command, plus its own fields (turn_id, model, permission_mode, + * tool_use_id, a null transcript_path). The default dialect must augment it. */ +TEST(cli_hook_augment_codex_pretooluse_payload) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + char *project = cbm_project_name_from_path("/tmp/hookproj"); + ASSERT_NOT_NULL(project); + cbm_mcp_server_set_project(srv, project); + cbm_store_upsert_project(st, project, "/tmp/hookproj"); + char qualified_name[256]; + snprintf(qualified_name, sizeof(qualified_name), "%s.mod.someIndexedSymbol", project); + cbm_node_t node = {.project = project, + .label = "Function", + .name = "someIndexedSymbol", + .qualified_name = qualified_name, + .file_path = "mod.py", + .start_line = 1, + .end_line = 4}; + ASSERT_GT(cbm_store_upsert_node(st, &node), 0); + + const char *input = "{\"session_id\":\"0199a1b2-codex\",\"turn_id\":\"turn-1\"," + "\"transcript_path\":null,\"cwd\":\"/tmp/hookproj\"," + "\"hook_event_name\":\"PreToolUse\",\"model\":\"gpt-5.6\"," + "\"permission_mode\":\"default\",\"tool_name\":\"Bash\"," + "\"tool_input\":{\"command\":\"rg -n someIndexedSymbol .\"}," + "\"tool_use_id\":\"call_1\"}"; + char *output = cbm_hook_augment_process(srv, input); + bool reached = output && strstr(output, "\"hookEventName\":\"PreToolUse\"") && + strstr(output, "additionalContext") && strstr(output, "someIndexedSymbol"); + free(output); + cbm_mcp_server_free(srv); + free(project); + + if (!reached) + FAIL("a Codex-shaped PreToolUse Bash payload must reach graph search augmentation"); + PASS(); +} + TEST(cli_hook_augment_bash_pattern_extractor) { char out[256]; @@ -18307,6 +18638,8 @@ SUITE(cli) { RUN_TEST(cli_find_cli_result_is_thread_local); RUN_TEST(cli_find_cli_on_path); RUN_TEST(cli_find_cli_fallback_paths); + RUN_TEST(cli_find_cli_long_path_beyond_four_kb_issue221); + RUN_TEST(cli_find_cli_windows_fallback_shims_issue221); /* Dry-run flag parsing (1 test — install_test.go) */ RUN_TEST(cli_dry_run_flags); @@ -18500,6 +18833,7 @@ SUITE(cli) { RUN_TEST(cli_hook_metadata_rejects_truncated_utf8_without_oob); RUN_TEST(cli_aider_config_loads_installed_conventions); RUN_TEST(cli_codex_session_hook_issue330); + RUN_TEST(cli_codex_pretooluse_hook_lifecycle); RUN_TEST(cli_gemini_session_hook_parity); RUN_TEST(cli_claude_subagent_hook); RUN_TEST(cli_claude_hook_mutation_converges_mixed_owned_duplicates); @@ -18525,6 +18859,7 @@ SUITE(cli) { RUN_TEST(cli_hook_augment_context_tracks_search_json_shape); RUN_TEST(cli_hook_augment_context_names_graph_follow_ups); RUN_TEST(cli_hook_augment_bash_pretooluse_reaches_augmenter); + RUN_TEST(cli_hook_augment_codex_pretooluse_payload); RUN_TEST(cli_hook_augment_bash_pattern_extractor); RUN_TEST(cli_hook_augment_coverage_requests_machine_json); RUN_TEST(cli_hook_augment_lifecycle_output_contract); diff --git a/tests/test_config_toml_edit.c b/tests/test_config_toml_edit.c index 8f928aa6e..43c390a8b 100644 --- a/tests/test_config_toml_edit.c +++ b/tests/test_config_toml_edit.c @@ -57,7 +57,8 @@ static int cte_codex_edit_commands_detailed(const char *path, const char *comman cbm_toml_codex_hook_action_t action, int check_only, cbm_toml_codex_hook_failure_t *failure) { return cbm_toml_reconcile_codex_hooks_detailed(path, CTE_CODEX_BEGIN, CTE_CODEX_END, command, - command_windows, action, check_only, failure); + command_windows, action, check_only, failure, + NULL); } static int cte_codex_edit_detailed(const char *path, cbm_toml_codex_hook_action_t action, @@ -1456,14 +1457,18 @@ TEST(config_toml_codex_accepts_v0102_managed_windows_crlf) { ASSERT_EQ(cte_read(path, actual, sizeof(actual)), 0); ASSERT_STR_EQ(actual, before); + /* The v0.10.2 lifecycle-only block is ours: the upsert upgrades it in place to + * the current block (which adds PreToolUse Bash) and keeps the BOM, CRLF and + * the surrounding tables; a second upsert is then byte-identical. */ ASSERT_EQ(cte_codex_edit_commands_detailed(path, command, command_windows, CBM_TOML_CODEX_HOOK_UPSERT, 0, &failure), 0); ASSERT_EQ(cte_read(path, actual, sizeof(actual)), 0); - ASSERT_STR_EQ(actual, before); + ASSERT_EQ(strncmp(actual, "\xEF\xBB\xBF", 3U), 0); ASSERT_EQ(cte_occurrences(actual, CTE_CODEX_BEGIN), 1); ASSERT_EQ(cte_occurrences(actual, "[[hooks.SessionStart]]"), 1); ASSERT_EQ(cte_occurrences(actual, "[[hooks.SubagentStart]]"), 1); + ASSERT_EQ(cte_occurrences(actual, "[[hooks.PreToolUse]]"), 1); ASSERT_NOT_NULL(strstr(actual, "[mcp_servers.other]")); ASSERT_NOT_NULL(strstr(actual, "keep = true")); for (const char *cursor = actual; *cursor; ++cursor) { @@ -1471,12 +1476,14 @@ TEST(config_toml_codex_accepts_v0102_managed_windows_crlf) { ASSERT(cursor > actual && cursor[-1] == '\r'); } } + char upgraded[CTE_FILE_CAP]; + ASSERT_GT(snprintf(upgraded, sizeof(upgraded), "%s", actual), 0); ASSERT_EQ(cte_codex_edit_commands_detailed(path, command, command_windows, CBM_TOML_CODEX_HOOK_UPSERT, 0, &failure), 0); ASSERT_EQ(cte_read(path, actual, sizeof(actual)), 0); - ASSERT_STR_EQ(actual, before); + ASSERT_STR_EQ(actual, upgraded); ASSERT_EQ(cte_codex_edit_commands_detailed(path, command, command_windows, CBM_TOML_CODEX_HOOK_REMOVE, 0, &failure), diff --git a/tests/test_extraction.c b/tests/test_extraction.c index e1c1e4491..aadde696d 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -879,6 +879,89 @@ TEST(csharp_interface) { PASS(); } +/* Issue #2071: a C# 14 extension block parses as a constructor_declaration + * named `extension` whose body holds the members, and has_error stays 0, so the + * members were dropped without any signal. They belong to the enclosing type. */ +TEST(csharp_extension_block_members) { + CBMFileResult *r = extract("namespace App;\n" + "public static class NumberExtensions\n" + "{\n" + " extension(IEnumerable numbers)\n" + " {\n" + " public int SumPositive() => 0;\n" + " public static int Zero() => 0;\n" + " }\n" + "}\n", + CBM_LANG_CSHARP, "t", "NumberExtensions.cs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Class", "NumberExtensions")); + ASSERT(has_def(r, "Method", "SumPositive")); + ASSERT(has_def(r, "Method", "Zero")); + ASSERT_FALSE(has_def_any(r, "extension")); + cbm_free_result(r); + PASS(); +} + +/* The same shape is a genuine constructor inside a type actually called + * `extension`, and must still be emitted (#2071). */ +TEST(csharp_constructor_of_type_named_extension) { + CBMFileResult *r = + extract("namespace App { public class extension { public extension() { } } }", + CBM_LANG_CSHARP, "t", "extension.cs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Method", "extension")); + cbm_free_result(r); + PASS(); +} + +/* Escaping a type identifier does not change its constructor's identity. */ +TEST(csharp_constructor_of_escaped_type_named_extension) { + const char *src = "namespace App { public class @extension { " + "public extension() { int ConstructorLocal() => 1; } } }"; + CBMFileResult *r = extract(src, CBM_LANG_CSHARP, "t", "extension.cs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Method", "extension")); + ASSERT_FALSE(has_def(r, "Method", "ConstructorLocal")); + cbm_free_result(r); + PASS(); +} + +/* All conditional branches expose extension members, but method-local + * functions must never be promoted to members of the enclosing type. */ +TEST(csharp_extension_block_conditional_members) { + const char *src = "public static class TextExtensions\n" + "{\n" + " extension(string text)\n" + " {\n" + "#if FIRST\n" + " public int First() { int Nested() => 1; return Nested(); }\n" + "#elif SECOND\n" + " public int Second() => 2;\n" + "#else\n" + "#if NESTED\n" + " public int Third() => 3;\n" + "#else\n" + " public int Fourth() => 4;\n" + "#endif\n" + "#endif\n" + " }\n" + "}\n"; + CBMFileResult *r = extract(src, CBM_LANG_CSHARP, "t", "TextExtensions.cs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Method", "First")); + ASSERT(has_def(r, "Method", "Second")); + ASSERT(has_def(r, "Method", "Third")); + ASSERT(has_def(r, "Method", "Fourth")); + ASSERT_FALSE(has_def_any(r, "extension")); + ASSERT_FALSE(has_def(r, "Method", "Nested")); + cbm_free_result(r); + PASS(); +} + /* --- Swift --- */ TEST(swift_class) { CBMFileResult *r = extract("class Vehicle {\n var speed: Int = 0\n func accelerate() { " @@ -13156,6 +13239,10 @@ SUITE(extraction) { RUN_TEST(ruby_module); RUN_TEST(csharp_class); RUN_TEST(csharp_interface); + RUN_TEST(csharp_extension_block_members); + RUN_TEST(csharp_constructor_of_type_named_extension); + RUN_TEST(csharp_constructor_of_escaped_type_named_extension); + RUN_TEST(csharp_extension_block_conditional_members); RUN_TEST(swift_class); RUN_TEST(swift_protocol); RUN_TEST(kotlin_function); diff --git a/tests/test_grammar_labels.c b/tests/test_grammar_labels.c index f2e58074d..be28a0687 100644 --- a/tests/test_grammar_labels.c +++ b/tests/test_grammar_labels.c @@ -93,6 +93,7 @@ static const LabelGolden LABEL_GOLDENS[] = { {"ruby", "Class:1,Function:1,Module:1"}, {"php", "Class:1,Function:1,Module:1"}, {"c_sharp", "Class:1,Method:1,Module:1"}, + {"csharp_extension_declaration", "Class:1,Method:1,Module:1"}, {"bash", "Function:2,Module:1"}, {"zsh", "Function:2,Module:1"}, {"lua", "Function:2,Module:1"}, diff --git a/tests/test_grammar_regression.c b/tests/test_grammar_regression.c index c768c2bb9..7a02468a6 100644 --- a/tests/test_grammar_regression.c +++ b/tests/test_grammar_regression.c @@ -147,6 +147,21 @@ const GrammarCase CBM_GRAMMAR_CASES[] = { "class A {\n void Foo() {}\n}\n", 2, {"A", "Foo", NULL}}, + {"csharp_extension_declaration", + CBM_LANG_CSHARP, + "Extensions.cs", + "using System.Collections.Generic;\n" + "using System.Linq;\n" + "public static class NumberExtensions\n" + "{\n" + " extension(IEnumerable numbers)\n" + " {\n" + " public int SumPositive() =>\n" + " numbers.Where(n => n > 0).Aggregate(0, static (total, n) => total + n);\n" + " }\n" + "}\n", + 2, + {"NumberExtensions", "SumPositive", NULL}}, {"bash", CBM_LANG_BASH, "a.sh", diff --git a/tests/test_platform.c b/tests/test_platform.c index d1434c2ee..8b18a4884 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -7,6 +7,7 @@ #include "../src/foundation/compat_regex.h" #include "../src/foundation/constants.h" #include "../src/foundation/compat_thread.h" +#include "../src/foundation/mem_core.h" #include "../src/foundation/platform.h" #include "../src/foundation/platform_internal.h" #include "../src/foundation/system_info_internal.h" @@ -895,6 +896,52 @@ TEST(platform_env_long_refuses_what_it_cannot_read) { PASS(); } +/* cbm_env_dup (#221) gives the same environment view as cbm_safe_getenv: an + * installed settings resolver answers first, a handled NULL masks the + * variable, and an unhandled name falls through to the process environment. */ +static bool platform_env_dup_test_resolver(const char *name, const char **value) { + if (strcmp(name, "CBM_TEST_ENV_DUP_RESOLVED") == 0) { + *value = "from-settings"; + return true; + } + if (strcmp(name, "CBM_TEST_ENV_DUP_MASKED") == 0) { + *value = NULL; + return true; + } + return false; +} + +TEST(platform_env_dup_honours_the_settings_resolver) { + ASSERT_EQ(cbm_setenv("CBM_TEST_ENV_DUP_RESOLVED", "from-environment", 1), 0); + ASSERT_EQ(cbm_setenv("CBM_TEST_ENV_DUP_MASKED", "from-environment", 1), 0); + ASSERT_EQ(cbm_setenv("CBM_TEST_ENV_DUP_NATIVE", "from-environment", 1), 0); + + cbm_set_environment_resolver(platform_env_dup_test_resolver); + char *resolved = cbm_env_dup("CBM_TEST_ENV_DUP_RESOLVED"); + char *masked = cbm_env_dup("CBM_TEST_ENV_DUP_MASKED"); + char *native = cbm_env_dup("CBM_TEST_ENV_DUP_NATIVE"); + char safe[64]; + const char *safe_value = cbm_safe_getenv("CBM_TEST_ENV_DUP_RESOLVED", safe, sizeof(safe), NULL); + cbm_set_environment_resolver(NULL); + + bool safe_ok = safe_value && strcmp(safe_value, "from-settings") == 0; + bool resolved_ok = resolved && strcmp(resolved, "from-settings") == 0; + bool masked_ok = masked == NULL; + bool native_ok = native && strcmp(native, "from-environment") == 0; + cbm_free(CBM_MEM_CLASS_OTHER, resolved); + cbm_free(CBM_MEM_CLASS_OTHER, masked); + cbm_free(CBM_MEM_CLASS_OTHER, native); + (void)cbm_unsetenv("CBM_TEST_ENV_DUP_RESOLVED"); + (void)cbm_unsetenv("CBM_TEST_ENV_DUP_MASKED"); + (void)cbm_unsetenv("CBM_TEST_ENV_DUP_NATIVE"); + + ASSERT_TRUE(safe_ok); + ASSERT_TRUE(resolved_ok); + ASSERT_TRUE(masked_ok); + ASSERT_TRUE(native_ok); + PASS(); +} + #ifdef _WIN32 /* cbm_safe_getenv reads Windows' wide environment as UTF-8. Its matching * setter must update that same wide environment; _putenv_s alone interprets @@ -1420,6 +1467,7 @@ SUITE(platform) { RUN_TEST(platform_default_workers_env_unset); RUN_TEST(platform_env_long_reads_a_clean_number); 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(regex_compile_refuses_oversized_expansion); RUN_TEST(regex_compile_accepts_expansion_under_budget); diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index d4ed45c0c..b21e946d0 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -26,6 +26,7 @@ #include "../src/foundation/compat.h" #include "../src/pipeline/lsp_resolve.h" #include "lsp/ts_lsp.h" +#include /* ── Helpers ───────────────────────────────────────────────────────────────── */ @@ -310,6 +311,21 @@ TEST(tslsp_return_inferred_local) { PASS(); } +/* infer_return_type_from_body visits children last-child first, so of two returns + * the one later in the body is tried first. Pinned so that a change to the walk + * order (#2091) is a visible decision rather than a side effect. */ +TEST(tslsp_return_walk_order_prefers_later_return) { + CBMFileResult *r = + extract_ts("class A { a(): void {} }\n" + "class B { b(): void {} }\n" + "function pick(f) { if (f) { return new A(); } return new B(); }\n" + "function go() { const x = pick(true); x.b(); }\n"); + ASSERT_NOT_NULL(r); + ASSERT_GTE(require_resolved(r, ".go", "b"), 0); + cbm_free_result(r); + PASS(); +} + TEST(tslsp_return_async_promise) { CBMFileResult *r = extract_ts("class User { hello(): void {} }\n" "async function load(): Promise { return new User(); }\n" @@ -3433,6 +3449,122 @@ TEST(tslsp_stress_many_classes) { PASS(); } +TEST(tslsp_signature_walk_keeps_first_local_registration) { + CBMRegisteredFunc base_funcs[] = {{.qualified_name = "test.base.only"}}; + CBMTypeRegistry base = {.funcs = base_funcs, .func_count = 1, .read_only = true}; + CBMRegisteredFunc funcs[] = { + {.qualified_name = NULL}, + {.qualified_name = "test.main.identity"}, + {.qualified_name = "test.main.identity"}, + {.qualified_name = "test.main.Box.identity"}, + }; + CBMTypeRegistry overlay = {.funcs = funcs, .func_count = 4, .fallback = &base}; + ASSERT_EQ(cbm_ts_lsp_test_signature_func_index(&overlay, "test.main.identity"), 1); + ASSERT_EQ(cbm_ts_lsp_test_signature_func_index(&overlay, "test.main.Box.identity"), 3); + ASSERT_EQ(cbm_ts_lsp_test_signature_func_index(&overlay, "test.base.only"), -1); + ASSERT_EQ(cbm_ts_lsp_test_signature_func_index(&overlay, "test.missing"), -1); + ASSERT_EQ(overlay.func_count, 4); + ASSERT_EQ(base.func_count, 1); + PASS(); +} + +TEST(tslsp_walk_pending_count_rejects_overflow) { + ASSERT_EQ(cbm_ts_lsp_test_walk_pending_count(255, 300), 555); + ASSERT_EQ(cbm_ts_lsp_test_walk_pending_count(INT_MAX - 1, 1), INT_MAX); + ASSERT_EQ(cbm_ts_lsp_test_walk_pending_count(INT_MAX, 1), -1); + ASSERT_EQ(cbm_ts_lsp_test_walk_pending_count(1, (uint32_t)INT_MAX), -1); + ASSERT_EQ(cbm_ts_lsp_test_walk_pending_count(0, UINT32_MAX), -1); + PASS(); +} + +/* Count actual registry work, independent of machine speed. Both inputs exceed + * the old walk cap, and every declaration exercises rebuild and generic rewrite. */ +TEST(tslsp_signature_walk_lookup_scales_linearly) { + unsigned long work[2] = {0}; + for (int scale = 0; scale < 2; scale++) { + const int count = 300 << scale; + const size_t capacity = (size_t)count * 160 + 512; + char *source = (char *)malloc(capacity); + ASSERT_NOT_NULL(source); + size_t used = 0; + for (int i = 0; i < count; i++) { + used += (size_t)snprintf(source + used, capacity - used, + "export function identity%d(x: T): T { return x; }\n", i); + } + snprintf(source + used, capacity - used, + "class Box { use(): void {} }\n" + "function go() { const b = identity%d(new Box()); b.use(); }\n", + count - 1); + CBMFileResult *r = extract_ts(source); + free(source); + ASSERT_NOT_NULL(r); + work[scale] = cbm_ts_lsp_test_signature_lookup_work(); + ASSERT_GTE(require_resolved(r, ".go", "use"), 0); + cbm_free_result(r); + } + printf(" signature lookup work: 300=%lu 600=%lu\n", work[0], work[1]); + ASSERT_GT(work[0], 0); + /* Allow 25% above ideal doubling for hash-bucket distribution. */ + ASSERT_LT(work[1], work[0] * 5 / 2); + PASS(); +} + +TEST(tslsp_stress_declarations_past_stack_cap) { + /* 300 padding functions, then the generic pair that needs the AST walk. + * The walk used to hold 256 nodes at most, so every top-level declaration + * after the 256th was dropped without a word and kept whatever signature + * the first extraction pass had guessed. */ + enum { BUF_CAP = 64 * 1024 }; + char *buf = (char *)malloc(BUF_CAP); + ASSERT_NOT_NULL(buf); + char *p = buf; + char *end = buf + BUF_CAP; + for (int i = 0; i < 300; i++) { + p += snprintf(p, (size_t)(end - p), "function pad%d(): void {}\n", i); + } + p += snprintf(p, (size_t)(end - p), "function identity(x: T): T { return x; }\n"); + p += snprintf(p, (size_t)(end - p), "class Box { use(): void {} }\n"); + p += snprintf(p, (size_t)(end - p), + "function go() { const b = identity(new Box()); b.use(); }\n"); + *p = '\0'; + + CBMFileResult *r = extract_ts(buf); + free(buf); + ASSERT_NOT_NULL(r); + /* Same claim as tslsp_generic_identity_inference, 300 declarations deeper. */ + ASSERT_GTE(require_resolved(r, ".go", "use"), 0); + cbm_free_result(r); + PASS(); +} + +TEST(tslsp_stress_return_past_stack_cap) { + /* infer_return_type_from_body reads the return type out of a body that + * carries no written one. The return here sits after 300 statements, more + * than the old fixed cap held, so the walk used to stop before reaching it + * and answer "unknown return type". */ + enum { BUF_CAP = 64 * 1024 }; + char *buf = (char *)malloc(BUF_CAP); + ASSERT_NOT_NULL(buf); + char *p = buf; + char *end = buf + BUF_CAP; + p += snprintf(p, (size_t)(end - p), "class Conn { ping(): void {} }\n"); + p += snprintf(p, (size_t)(end - p), "function makeConn() {\n"); + for (int i = 0; i < 300; i++) { + p += snprintf(p, (size_t)(end - p), " let a%d = 0;\n", i); + } + p += snprintf(p, (size_t)(end - p), " return new Conn();\n}\n"); + p += snprintf(p, (size_t)(end - p), "function go() { const c = makeConn(); c.ping(); }\n"); + *p = '\0'; + + CBMFileResult *r = extract_ts(buf); + free(buf); + ASSERT_NOT_NULL(r); + /* Same claim as tslsp_return_inferred_local, 300 statements deeper. */ + ASSERT_GTE(require_resolved(r, ".go", "ping"), 0); + cbm_free_result(r); + PASS(); +} + TEST(tslsp_stress_deep_inheritance) { /* Chain of 30 classes via extends, leaf method on root. */ char buf[16 * 1024]; @@ -4860,6 +4992,7 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_return_simple); RUN_TEST(tslsp_return_chain); RUN_TEST(tslsp_return_inferred_local); + RUN_TEST(tslsp_return_walk_order_prefers_later_return); RUN_TEST(tslsp_return_async_promise); RUN_TEST(tslsp_return_method_no_rttype); @@ -5189,6 +5322,11 @@ SUITE(ts_lsp) { /* Stress tests */ RUN_TEST(tslsp_stress_many_classes); + RUN_TEST(tslsp_signature_walk_keeps_first_local_registration); + RUN_TEST(tslsp_walk_pending_count_rejects_overflow); + RUN_TEST(tslsp_signature_walk_lookup_scales_linearly); + RUN_TEST(tslsp_stress_declarations_past_stack_cap); + RUN_TEST(tslsp_stress_return_past_stack_cap); RUN_TEST(tslsp_stress_deep_inheritance); RUN_TEST(tslsp_stress_long_method_chain); RUN_TEST(tslsp_stress_megafile_mixed);