Merge origin/main into fix/objectscript-empty-deserialize

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-25 20:26:27 +01:00
commit eaed3cdf6e
16 files changed
+1265 -150

No files matched your search

+9 -2
View File
@@ -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 \
+71
View File
@@ -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);
+227 -69
View File
@@ -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 <limits.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
@@ -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 = &reg->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 &reg->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<T>(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, &reg);
rebuild_signatures_from_ast(&ctx, root, &reg);
convert_signature_type_params(&ctx, root, &reg);
refine_ast_signatures(&ctx, root, &reg);
apply_jsdoc_signatures(&ctx, root, &reg);
infer_implicit_returns(&ctx, root, &reg);
@@ -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, &reg);
rebuild_signatures_from_ast(&ctx, root, &reg);
convert_signature_type_params(&ctx, root, &reg);
refine_ast_signatures(&ctx, root, &reg);
apply_jsdoc_signatures(&ctx, root, &reg);
infer_implicit_returns(&ctx, root, &reg);
+5
View File
@@ -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);
+141 -41
View File
@@ -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);
}
}
+79 -32
View File
@@ -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);
}
+6 -2
View File
@@ -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,
+77
View File
@@ -7,6 +7,7 @@
#include "foundation/compat.h"
#include "foundation/constants.h"
#include "foundation/mem_core.h"
#include "foundation/platform_internal.h"
#include <ctype.h>
#include <errno.h>
@@ -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. */
+15
View File
@@ -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
+336 -1
View File
@@ -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);
+10 -3
View File
@@ -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),
+87
View File
@@ -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<int> 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);
+1
View File
@@ -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"},
+15
View File
@@ -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<int> 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",
+48
View File
@@ -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);
+138
View File
@@ -26,6 +26,7 @@
#include "../src/foundation/compat.h"
#include "../src/pipeline/lsp_resolve.h"
#include "lsp/ts_lsp.h"
#include <limits.h>
/* ── 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<User> { 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<T>(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<T>(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);