From 9129898e6e38e414f9370bcfc89de91c62571f60 Mon Sep 17 00:00:00 2001 From: Scott Beck <296871+bline@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:46:04 -0600 Subject: [PATCH 1/8] fix(markdown): qualify a repeated heading's Section by its parent headings (#2586) A Section's qualified name is the file's module plus the heading text, so two `## Implementation` headings under `# Service A` and `# Service B` had one qualified name. The graph kept one node (the last), and doclink_md sent the references under both headings to the File node, since neither heading owned the shared node. A heading whose name repeats in the file now takes the names of the headings above it into its qualified name (`docs.repeated.Service-A.Implementation`). tree-sitter-markdown nests each heading's `section` in the section of the heading above it, so the chain is read from the tree. A heading whose name is unique in the file keeps its name alone, so no other Section is renamed. Headings that repeat under the same parents still share a name and keep the existing File fallback (doc_links_md_repeated_headings is unchanged). A heading text may hold dots, so a qualified name can equal another heading's name: `# Service-A.Implementation` against the qualified `## Implementation` under `# Service A`. All of a file's final names are therefore settled together, once per file on its first heading, in the scratch arena. A unique heading keeps its name, or else the first qualified one does, and every other qualified name in the collision takes the first free `~2`, `~3`, .... Names collide only when they come from different headings: the comparison is on the list of heading names, not on the dotted text. Diagrams find their Section through the definition list, so a diagram under a repeated heading now hangs off that heading's own Section. Tests (tests/test_doc_links_md.c): the document from the issue, with a Mermaid diagram under the second heading. Without the fix it fails on the defect: one `Implementation` Section where there are two. The same document plus `# Service-A.Implementation` requires five Sections, the unique heading keeping its name. Without the collision handling it fails with four. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Scott Beck <296871+bline@users.noreply.github.com> --- internal/cbm/cbm.h | 3 + internal/cbm/extract_defs.c | 243 +++++++++++++++++++++++++++++++++++- tests/test_doc_links_md.c | 152 ++++++++++++++++++++++ 3 files changed, 394 insertions(+), 4 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 9fac8cf55..9589e730f 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -1021,6 +1021,9 @@ typedef struct { * POD section index. */ void *doc_memo; void *doc_pod_index; + /* Markdown: the sorted names of the file's headings (extract_defs.c), + * NULL until the first heading; allocated in `scratch`. */ + void *md_headings; const cbm_test_declarations_t *test_declarations; /* borrowed for this call only */ bool test_declarations_raw_source; struct CBMTestDefinitionMatch *test_definition_matches; /* traversal scratch */ diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index a017a5518..67af6fc11 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -7030,13 +7030,15 @@ static const char *extract_markdown_section_body(CBMExtractCtx *ctx, TSNode head /* Push a config-language definition that carries prose. push_simple_class_def * delegates here with no docstring. */ -static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *name, - const char *label, const char *docstring) { +/* Push a definition whose qualified name ends with `qn_name` (already QN-safe) + * rather than with its name. */ +static void push_class_def_qn(CBMExtractCtx *ctx, TSNode node, char *name, const char *qn_name, + const char *label, const char *docstring) { CBMArena *a = ctx->arena; CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; - def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, qn_safe_segment(a, name)); + def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, qn_name); def.label = label; def.file_path = ctx->rel_path; def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; @@ -7046,6 +7048,11 @@ static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *nam cbm_defs_push(&ctx->result->defs, a, def); } +static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *name, + const char *label, const char *docstring) { + push_class_def_qn(ctx, node, name, qn_safe_segment(ctx->arena, name), label, docstring); +} + static void push_simple_class_def(CBMExtractCtx *ctx, TSNode node, char *name, const char *label) { push_simple_class_def_doc(ctx, node, name, label, NULL); } @@ -7145,6 +7152,231 @@ static char *extract_markdown_heading_name(CBMArena *a, TSNode node, const char return trim_heading_name(name); } +/* #2586: a heading name that repeats in a file (`## Implementation` under two + * parents) gave its Sections one qualified name, so the graph kept one node + * and the references under them fell back to the File. A repeated heading's + * qualified name therefore takes the names of the headings above it as well + * (`Service-A.Implementation`). A heading whose name is unique in the file + * keeps its name alone, always. A heading text may itself hold dots, so a + * qualified name can equal another heading's final name (`# Service-A. + * Implementation`): the qualified one then takes `~2`, `~3`, ... until no + * other heading of the file has that name. Headings that repeat under the + * same parents too still share a name, as before. All final names are + * settled once per file, on the first heading. */ +typedef struct { + uint32_t start; /* the heading node's first byte */ + TSNode node; /* valid while the file's tree is */ + const char *seg; /* the heading's own QN-safe name */ + const char *key; /* repeated only: the chain's segments, '\n'-joined */ + const char *qn_name; /* the final QN segment */ + bool repeated; +} md_heading_t; + +typedef struct { + md_heading_t *v; /* in document order */ + int count; +} md_headings_t; + +/* The QN-safe name of a heading node, or NULL. */ +static const char *md_heading_segment(CBMExtractCtx *ctx, CBMArena *a, TSNode node) { + const char *kind = ts_node_type(node); + if (!is_markdown_heading_kind(kind)) { + return NULL; + } + char *name = extract_markdown_heading_name(a, node, kind, ctx->source); + return name ? qn_safe_segment(a, name) : NULL; +} + +/* Visit every heading of the file: count them (v NULL), or fill v. */ +static int md_collect_headings(CBMExtractCtx *ctx, CBMArena *a, md_heading_t *v, int cap) { + int n = 0; + if (ts_node_is_null(ctx->root)) { + return 0; + } + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (;;) { + TSNode node = ts_tree_cursor_current_node(&cur); + if (is_markdown_heading_kind(ts_node_type(node))) { + if (!v) { + n++; + } else if (n < cap) { + const char *seg = md_heading_segment(ctx, a, node); + if (seg) { + v[n++] = (md_heading_t){.start = ts_node_start_byte(node), + .node = node, + .seg = seg, + .qn_name = seg}; + } + } + } else if (ts_tree_cursor_goto_first_child(&cur)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cur)) { + if (!ts_tree_cursor_goto_parent(&cur)) { + ts_tree_cursor_delete(&cur); + return n; + } + } + } +} + +static int md_by_seg(const void *x, const void *y) { + return strcmp((*(const md_heading_t *const *)x)->seg, (*(const md_heading_t *const *)y)->seg); +} + +static int md_by_name(const void *x, const void *y) { + const md_heading_t *p = *(const md_heading_t *const *)x; + const md_heading_t *q = *(const md_heading_t *const *)y; + int c = strcmp(p->qn_name, q->qn_name); + return c ? c : (p->start > q->start) - (p->start < q->start); +} + +/* A repeated heading's chain: the names of the headings above it and its own, + * dot-joined (its QN segment) and '\n'-joined (its identity: a QN-safe name + * holds no whitespace). tree-sitter-markdown nests each heading's `section` in + * the section of the heading above it. False on allocation failure. */ +static bool md_heading_chain(CBMExtractCtx *ctx, CBMArena *a, md_heading_t *h) { + const char *dotted = h->seg; + const char *key = h->seg; + TSNode sec = ts_node_parent(h->node); + while (!ts_node_is_null(sec) && strcmp(ts_node_type(sec), "section") == 0) { + TSNode up = ts_node_parent(sec); + if (ts_node_is_null(up) || strcmp(ts_node_type(up), "section") != 0) { + break; + } + const char *above = ts_node_named_child_count(up) > 0 + ? md_heading_segment(ctx, a, ts_node_named_child(up, 0)) + : NULL; + if (above) { + dotted = cbm_arena_sprintf(a, "%s.%s", above, dotted); + key = cbm_arena_sprintf(a, "%s\n%s", above, key); + if (!dotted || !key) { + return false; + } + } + sec = up; + } + h->qn_name = dotted; + h->key = key; + return true; +} + +static bool md_name_taken(const md_headings_t *hs, const char *name) { + for (int i = 0; i < hs->count; i++) { + if (strcmp(hs->v[i].qn_name, name) == 0) { + return true; + } + } + return false; +} + +/* Within a run of headings that share a final name: a unique heading keeps + * it, or else the first chain does; every other chain takes the first free + * `~N`. Headings with one chain (the same headings) move together. */ +static bool md_split_run(CBMArena *a, md_headings_t *hs, md_heading_t **run, int n) { + const char *keep = run[0]->key; /* NULL: a unique heading keeps the name */ + for (int i = 0; i < n; i++) { + if (!run[i]->repeated) { + keep = NULL; + } + } + const char *base = run[0]->qn_name; + for (int i = 0; i < n; i++) { + md_heading_t *h = run[i]; + if (!h->repeated || (keep && strcmp(h->key, keep) == 0) || strcmp(h->qn_name, base) != 0) { + continue; /* the name's keeper, or a chain already moved */ + } + const char *name = NULL; + for (int k = PAIR_LEN; !name || md_name_taken(hs, name); k++) { + name = cbm_arena_sprintf(a, "%s~%d", base, k); + if (!name) { + return false; + } + } + for (int j = i; j < n; j++) { + if (run[j]->repeated && strcmp(run[j]->key, h->key) == 0) { + run[j]->qn_name = name; + } + } + } + return true; +} + +static bool md_settle_names(CBMExtractCtx *ctx, CBMArena *a, md_headings_t *hs) { + int n = hs->count; + md_heading_t **by = (md_heading_t **)cbm_arena_alloc(a, (size_t)n * sizeof(*by)); + if (!by) { + return false; + } + for (int i = 0; i < n; i++) { + by[i] = &hs->v[i]; + hs->v[i].qn_name = hs->v[i].seg; + } + qsort(by, (size_t)n, sizeof(*by), md_by_seg); + for (int i = 1; i < n; i++) { + if (strcmp(by[i]->seg, by[i - SKIP_ONE]->seg) == 0) { + by[i]->repeated = by[i - SKIP_ONE]->repeated = true; + } + } + for (int i = 0; i < n; i++) { + if (hs->v[i].repeated && !md_heading_chain(ctx, a, &hs->v[i])) { + return false; + } + } + qsort(by, (size_t)n, sizeof(*by), md_by_name); + for (int i = 0; i < n;) { + int j = i + SKIP_ONE; + while (j < n && strcmp(by[j]->qn_name, by[i]->qn_name) == 0) { + j++; + } + if (j - i > SKIP_ONE && !md_split_run(a, hs, by + i, j - i)) { + return false; + } + i = j; + } + return true; +} + +static const md_headings_t *md_headings(CBMExtractCtx *ctx) { + if (ctx->md_headings) { + return (const md_headings_t *)ctx->md_headings; + } + CBMArena *a = doc_scratch(ctx); + md_headings_t *hs = (md_headings_t *)cbm_arena_alloc(a, sizeof(*hs)); + if (!hs) { + return NULL; + } + int cap = md_collect_headings(ctx, a, NULL, 0); + hs->v = cap > 0 ? (md_heading_t *)cbm_arena_alloc(a, (size_t)cap * sizeof(md_heading_t)) : NULL; + hs->count = hs->v ? md_collect_headings(ctx, a, hs->v, cap) : 0; + if (hs->count > SKIP_ONE && !md_settle_names(ctx, a, hs)) { + hs->count = 0; /* every heading keeps its own name, as before */ + } + ctx->md_headings = hs; + return hs; +} + +/* The QN segment of a heading: its own name unless the file's names say + * otherwise. */ +static const char *md_heading_qn_name(CBMExtractCtx *ctx, TSNode heading, const char *seg) { + const md_headings_t *hs = md_headings(ctx); + uint32_t start = ts_node_start_byte(heading); + int lo = 0; + int hi = hs ? hs->count : 0; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (hs->v[mid].start < start) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + if (hs && lo < hs->count && hs->v[lo].start == start && strcmp(hs->v[lo].seg, seg) == 0) { + return hs->v[lo].qn_name; + } + return seg; +} + // INI: extract section name from section node. static char *find_ini_section_name(CBMArena *a, TSNode node, const char *source) { uint32_t nc = ts_node_child_count(node); @@ -7245,7 +7477,10 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char return false; } - if (name && name[0]) { + if (name && name[0] && ctx->language == CBM_LANG_MARKDOWN) { + const char *seg = qn_safe_segment(a, name); + push_class_def_qn(ctx, node, name, md_heading_qn_name(ctx, node, seg), label, docstring); + } else if (name && name[0]) { push_simple_class_def_doc(ctx, node, name, label, docstring); } return true; diff --git a/tests/test_doc_links_md.c b/tests/test_doc_links_md.c index 21e74f37e..843b48681 100644 --- a/tests/test_doc_links_md.c +++ b/tests/test_doc_links_md.c @@ -193,6 +193,156 @@ TEST(doc_links_md_repeated_headings) { PASS(); } +/* MENTIONS edges from the Section whose qualified name ends with `src` to the + * node whose qualified name ends with `tgt`. */ +static int rep_edge(const char *db, const char *src, const char *tgt) { + char props[512]; + int n = 0; + dm_edge(db, src, tgt, props, sizeof(props), &n); + return n; +} + +/* #2586: two `## Implementation` headings under different parents had one + * qualified name, so the graph kept one Section (the last) and the references + * under both went to the File. A repeated heading is now qualified by the + * headings above it; a unique heading keeps its name alone. */ +TEST(doc_links_md_repeated_headings_by_parent_issue2586) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_rep_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::balance`.\n" + "\n" + "# Service B\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::post_entry`.\n" + "\n" + "```mermaid\n" + "graph LR\n" + " Ledger --> Balance\n" + "```\n" + "\n" + "## Notes\n" + "\n" + "See `src/ledger.py`.\n"); + th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n" + " ledger.append(amount)\n" + " return balance(ledger)\n" + "\n" + "\n" + "def balance(ledger):\n" + " return sum(ledger)\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/md.db", tmp); + ASSERT_EQ(dm_index(tmp, db, NULL), 0); + + /* two Sections, each qualified by its parent heading */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND name = 'Implementation' AND file_path = 'docs/repeated.md'"), + 2); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation' " + "AND start_line = 3"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-B.Implementation' " + "AND start_line = 9"), + 1); + /* unique headings keep their names */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' AND " + "(qualified_name LIKE '%.repeated.Service-A' OR " + "qualified_name LIKE '%.repeated.Notes')"), + 2); + + /* each reference from its own Section, none from the File */ + ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.balance"), 1); + ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1); + ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.post_entry"), 0); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.label = 'File' " + "AND s.file_path = 'docs/repeated.md'"), + 0); + + /* the diagram under the second heading is that Section's */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = 'DEFINES' " + "AND t.label = 'Diagram' " + "AND s.qualified_name LIKE '%.Service-B.Implementation'"), + 1); + + th_cleanup(tmp); + PASS(); +} + +/* #2586 review: a heading text may hold dots, so `# Service-A.Implementation` + * named the same as the qualified `## Implementation` under `# Service A`. + * The unique heading keeps its name; the qualified one takes `~2`. Five + * headings, five Sections, every reference from its own. */ +TEST(doc_links_md_repeated_heading_name_collision_issue2586) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_col_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::balance`.\n" + "\n" + "# Service B\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::post_entry`.\n" + "\n" + "# Service-A.Implementation\n" + "\n" + "See `src/ledger.py`.\n"); + th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n" + " ledger.append(amount)\n" + " return balance(ledger)\n" + "\n" + "\n" + "def balance(ledger):\n" + " return sum(ledger)\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/md.db", tmp); + ASSERT_EQ(dm_index(tmp, db, NULL), 0); + + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND file_path = 'docs/repeated.md'"), + 5); + /* the unique heading keeps its name; the qualified one moves aside */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation' " + "AND start_line = 13"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation~2' " + "AND start_line = 3"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-B.Implementation' " + "AND start_line = 9"), + 1); + + /* each reference from its own Section, none from the File */ + ASSERT_EQ(rep_edge(db, "Service-A.Implementation~2", "ledger.balance"), 1); + ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1); + ASSERT_EQ(rep_edge(db, "repeated.Service-A.Implementation", "ledger.py.__file__"), 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.label = 'File' " + "AND s.file_path = 'docs/repeated.md'"), + 0); + + th_cleanup(tmp); + PASS(); +} + typedef struct { const char *text; CBMDocLinkMdShape shape; @@ -863,6 +1013,8 @@ SUITE(doc_links_md) { dm_ship_held_families(); RUN_TEST(doc_links_md_extract_tokens); RUN_TEST(doc_links_md_repeated_headings); + RUN_TEST(doc_links_md_repeated_headings_by_parent_issue2586); + RUN_TEST(doc_links_md_repeated_heading_name_collision_issue2586); RUN_TEST(doc_links_md_classify_span); RUN_TEST(doc_links_md_resolve); RUN_TEST(doc_links_md_incremental); From 51fd445f663fefabada1cb84b500039ece157479 Mon Sep 17 00:00:00 2001 From: knewstimek Date: Thu, 29 Oct 2026 07:05:52 +0900 Subject: [PATCH 2/8] fix(discover): select C++ for C-family include fragments Recognize explicit C-family code in .inc files before extraction while preserving BitBake recipe bodies and ObjectScript markers. Exercise disk and byte classifiers, callback/linkage extraction and fast/full indexing. Signed-off-by: knewstimek --- src/discover/discover.h | 5 +- src/discover/language.c | 162 +++++++++++++++++++++++++++++++++------- tests/test_extraction.c | 24 ++++++ tests/test_language.c | 50 +++++++++++++ tests/test_pipeline.c | 33 ++++++++ 5 files changed, 247 insertions(+), 27 deletions(-) diff --git a/src/discover/discover.h b/src/discover/discover.h index 85bc05294..a850df4e3 100644 --- a/src/discover/discover.h +++ b/src/discover/discover.h @@ -59,8 +59,9 @@ CBMLanguage cbm_disambiguate_frm(const char *path); CBMLanguage cbm_disambiguate_res(const char *path); /* Disambiguate .inc files by reading first 4KB of content. - * Returns CBM_LANG_OBJECTSCRIPT_ROUTINE if it looks like an ObjectScript - * include (a "ROUTINE " header), otherwise CBM_LANG_BITBAKE. + * Returns CBM_LANG_CPP for explicit C-family code, or + * CBM_LANG_OBJECTSCRIPT_ROUTINE for ObjectScript markers / a lone #define. + * BitBake recipe syntax and unknown content retain CBM_LANG_BITBAKE. * On read failure, defaults to CBM_LANG_BITBAKE. */ CBMLanguage cbm_disambiguate_inc(const char *path); diff --git a/src/discover/language.c b/src/discover/language.c index 7d967d7f3..9c6dd041d 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -1472,38 +1472,150 @@ CBMLanguage cbm_disambiguate_cls(const char *path) { return lang_cls_text(buf); } -/* Disambiguate .inc files: shared by BitBake include fragments and - * InterSystems ObjectScript include (macro) files. ObjectScript .inc files are - * predominantly macro definitions ("#define NAME ..." / "#def1arg NAME ..."); - * some also carry a "ROUTINE " header. The macro-preprocessor directives - * are the strongest signal because that is the primary content of an .inc file, - * whereas BitBake uses '#' only for "# comment" lines (always '#' + space). - * We therefore match ObjectScript preprocessor directives ('#' immediately - * followed by 'def'/';'), which BitBake never produces. Defaults to BitBake on - * any doubt (preserves existing behaviour). */ +/* A complete leading token, followed by whitespace or punctuation, within a + * single line. In particular #include_extra is not #include. */ +static const char *lang_inc_word(const char *line, const char *end, const char *word) { + size_t n = strlen(word); + if ((size_t)(end - line) < n || strncmp(line, word, n) != 0 || + (line + n < end && (isalnum((unsigned char)line[n]) || line[n] == '_'))) { + return NULL; + } + line += n; + while (line < end && isspace((unsigned char)*line)) { + line++; + } + return line; +} + +/* Recipes can embed C in quoted strings or shell/Python tasks. Recognize the + * recipe's own syntax before inspecting those bodies as standalone C code. */ +static bool lang_inc_recipe_line(const char *line, const char *end) { + static const char *const words[] = {"require", "include", "inherit", "addtask", "python", NULL}; + for (int i = 0; words[i]; i++) { + const char *tail = lang_inc_word(line, end, words[i]); + if (tail && tail > line + strlen(words[i])) { + return true; + } + } + if ((size_t)(end - line) >= SLEN("do_") && strncmp(line, "do_", SLEN("do_")) == 0) { + return true; + } + const char *p = line; + while (p < end && (isalnum((unsigned char)*p) || *p == '_' || *p == ':' || *p == '[' || + *p == ']' || *p == '-' || *p == '.')) { + p++; + } + if (p == line) { + return false; + } + while (p < end && isspace((unsigned char)*p)) { + p++; + } + return p < end && (*p == '=' || ((*p == '?' || *p == '+' || *p == ':' || *p == '.') && + p + SKIP_ONE < end && p[SKIP_ONE] == '=')); +} + +/* Skip only leading whitespace and C comments, keeping block-comment state + * across lines. Recipe strings and bodies are handled before looking for C. */ +static const char *lang_inc_code(const char *line, const char *end, bool *block_comment) { + while (line < end) { + if (*block_comment) { + const char *close = strstr(line, "*/"); + if (!close || close >= end) { + return end; + } + line = close + SLEN("*/"); + *block_comment = false; + continue; + } + if (isspace((unsigned char)*line)) { + line++; + continue; + } + if ((size_t)(end - line) >= SLEN("/*") && strncmp(line, "/*", SLEN("/*")) == 0) { + *block_comment = true; + line += SLEN("/*"); + continue; + } + break; + } + return line; +} + +static bool lang_inc_c_code(const char *line, const char *end) { + static const char *const words[] = { + "extern", "static", "typedef", "namespace", "template", + "struct", "class", "int", "void", NULL, + }; + for (int i = 0; words[i]; i++) { + const char *tail = lang_inc_word(line, end, words[i]); + if (tail && tail < end && *tail != '=') { + return true; + } + } + const char *tail = lang_inc_word(line, end, "if"); + return tail && tail < end && *tail == '('; +} + +static bool lang_inc_directive(const char *line, const char *end, bool *c_family, + bool *objectscript_define) { + line += SKIP_ONE; /* '#' */ + while (line < end && isspace((unsigned char)*line)) { + line++; + } + if (lang_inc_word(line, end, "def1arg") || (line < end && *line == ';')) { + return true; /* Explicit ObjectScript directive. */ + } + if (lang_inc_word(line, end, "define")) { + *objectscript_define = true; + } + const char *tail = lang_inc_word(line, end, "include"); + if (tail && tail < end && (*tail == '"' || *tail == '<')) { + *c_family = true; + } + return false; +} + +/* .inc is shared by C/C++, BitBake and ObjectScript. Select C++ for explicit + * C-family code (including C fragments); do not infer an enclosing include + * context. A lone #define and unknown content retain their existing defaults. */ static CBMLanguage lang_inc_text(const char *buf) { + bool c_family = false; + bool objectscript_define = false; + bool block_comment = false; const char *line = buf; while (*line) { - /* ObjectScript include header: a line beginning "ROUTINE ". */ - if (strncmp(line, "ROUTINE ", SLEN("ROUTINE ")) == 0 && - isupper((unsigned char)line[SLEN("ROUTINE ")])) { + const char *end = strchr(line, '\n'); + if (!end) { + end = line + strlen(line); + } + const char *code = lang_inc_code(line, end, &block_comment); + line = *end ? end + SKIP_ONE : end; + if (code == end) { + continue; + } + const char *tail = lang_inc_word(code, end, "ROUTINE"); + if (tail && tail < end && isupper((unsigned char)*tail)) { return CBM_LANG_OBJECTSCRIPT_ROUTINE; } - /* ObjectScript macro directives — the primary content of .inc files. - * "#define"/"#def1arg" (macro defs) and "#;" (line comment). BitBake's - * only '#' use is "# comment" (hash + space), so these never collide. */ - if (strncmp(line, "#define", SLEN("#define")) == 0 || - strncmp(line, "#def1arg", SLEN("#def1arg")) == 0 || - strncmp(line, "#;", SLEN("#;")) == 0) { - return CBM_LANG_OBJECTSCRIPT_ROUTINE; + if (*code == '#') { + if (lang_inc_directive(code, end, &c_family, &objectscript_define)) { + return CBM_LANG_OBJECTSCRIPT_ROUTINE; + } + continue; } - const char *nl = strchr(line, '\n'); - if (!nl) { - break; + if ((size_t)(end - code) >= SLEN("//") && strncmp(code, "//", SLEN("//")) == 0) { + continue; } - line = nl + SKIP_ONE; + if (!c_family && lang_inc_recipe_line(code, end)) { + return CBM_LANG_BITBAKE; + } + c_family = c_family || lang_inc_c_code(code, end); } - return CBM_LANG_BITBAKE; + if (c_family) { + return CBM_LANG_CPP; + } + return objectscript_define ? CBM_LANG_OBJECTSCRIPT_ROUTINE : CBM_LANG_BITBAKE; } CBMLanguage cbm_disambiguate_inc(const char *path) { @@ -1664,7 +1776,7 @@ CBMLanguage cbm_language_classify_with(const cbm_userconfig_t *config, const cha /* ObjectScript UDL, Apex or a Visual Basic 6 class module */ lang = readable ? lang_cls_text(buf) : CBM_LANG_APEX; } else if (strcmp(ext, ".inc") == 0) { - /* BitBake or an ObjectScript include */ + /* C/C++, BitBake or an ObjectScript include */ lang = readable ? lang_inc_text(buf) : CBM_LANG_BITBAKE; } else if (cfc) { /* script or tag dialect */ diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 234ad1d9e..35a8a9dec 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -250,6 +250,29 @@ TEST(extract_c_macros_issue375) { PASS(); } +TEST(extract_cpp_inc_linkage_and_callback) { + CBMFileResult *r = + extract("#define SAMPLE_CALLBACK(type) type\n" + "extern \"C\" __declspec(dllexport) int sample_export() { return 0; }\n" + "static SAMPLE_CALLBACK(int) sample_callback() { return sample_export(); }\n", + CBM_LANG_CPP, "sample", "runtime/sample.inc"); + ASSERT_NOT_NULL(r); + ASSERT_TRUE(has_def(r, "Function", "sample_export")); + ASSERT_TRUE(has_def(r, "Function", "sample_callback")); + ASSERT_FALSE(has_def_any(r, "extern")); + cbm_free_result(r); + + /* A statement include has no enclosing function in the file. Keep any + * syntax coverage diagnostic rather than inventing a callable owner. */ + r = extract("#ifdef ENABLE_SAMPLE\nif (sample_check()) return 1;\n#endif\n", CBM_LANG_CPP, + "sample", "runtime/guard.inc"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(has_def_any(r, "extern")); + ASSERT_FALSE(has_def(r, "Function", "if")); + cbm_free_result(r); + PASS(); +} + TEST(extract_cpp_macros_issue375) { CBMFileResult *r = extract("#define MAX(a, b) ((a) > (b) ? (a) : (b))\n" "#define PI 3.14159\n" @@ -13410,6 +13433,7 @@ SUITE(extraction) { RUN_TEST(traversal_stack_not_in_result_arena_issue2010); RUN_TEST(extract_c_macro_hidden_call_survives_preprocessed_pass_issue2010); RUN_TEST(extract_c_macros_issue375); + RUN_TEST(extract_cpp_inc_linkage_and_callback); RUN_TEST(extract_cpp_macros_issue375); RUN_TEST(extract_cpp_functionlike_macro_type_arg_no_false_parse_partial_issue1071); RUN_TEST(extract_cpp_real_in_body_error_still_flagged_issue1071); diff --git a/tests/test_language.c b/tests/test_language.c index 1ed72a0a2..8d7829a59 100644 --- a/tests/test_language.c +++ b/tests/test_language.c @@ -1390,6 +1390,55 @@ static CBMLanguage classify_content(const char *name, const char *content, size_ typedef CBMLanguage (*path_probe_fn)(const char *path); +/* Exercise the shared classifier and both disk entry points. All sources are + * synthetic; the recipe bodies deliberately contain misleading C-like code. */ +TEST(lang_inc_c_family_and_existing_dialects) { + static const struct { + const char *content; + CBMLanguage expected; + } cases[] = { + {"#include \"sample.h\"\nextern \"C\" int sample_export() { return 0; }\n", CBM_LANG_CPP}, + {"extern \"C\" __declspec(dllexport) int sample_export() { return 0; }\n", CBM_LANG_CPP}, + {"#define SAMPLE_CALLBACK(T) T\nstatic SAMPLE_CALLBACK(int) sample() { return 0; }\n", + CBM_LANG_CPP}, + {"/* in-function fragment */\n#ifdef SAMPLE_ENABLED\nif (check()) return 1;\n#endif\n", + CBM_LANG_CPP}, + {"int sample(void) { return 0; }\n", CBM_LANG_CPP}, + {" \t# include \r\n", CBM_LANG_CPP}, + {"/* example:\nextern \"C\" int example();\n*/\nSUMMARY = \"sample\"\n", CBM_LANG_BITBAKE}, + {"// extern \"C\" example only\n", CBM_LANG_BITBAKE}, + {"#include_extra \"sample.h\"\n", CBM_LANG_BITBAKE}, + {"#define Value 1\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#ifndef SAMPLE\n#define SAMPLE 1\n#endif\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"ROUTINE Sample [Type=INC]\n#define Value 1\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#def1arg Value(%x) %x\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#; ObjectScript comment\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#include \n#def1arg Value(%x) %x\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"# recipe\nSUMMARY = \"sample\"\nrequire base.inc\n", CBM_LANG_BITBAKE}, + {"SRC_URI += \"file://sample.c\"\ndo_compile() {\nif (check) { return; }\n}\n", + CBM_LANG_BITBAKE}, + {"python do_generate() {\nif (ready):\n pass\n}\n", CBM_LANG_BITBAKE}, + {"do_compile() {\ncat <<'EOF'\n#include \nstatic int sample;\nEOF\n}\n", + CBM_LANG_BITBAKE}, + {"require base.inc\nextern \"C\" example in recipe text\n", CBM_LANG_BITBAKE}, + {"", CBM_LANG_BITBAKE}, + }; + char path[512]; + snprintf(path, sizeof(path), "%s/cbm_lang_inc_routing.inc", cbm_tmpdir()); + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + size_t len = strlen(cases[i].content); + ASSERT_EQ(classify_content("sample.inc", cases[i].content, len), cases[i].expected); + ASSERT_TRUE(write_exact(path, cases[i].content, len)); + CBMLanguage by_probe = cbm_disambiguate_inc(path); + CBMLanguage by_file = cbm_language_for_file("sample.inc", path); + remove(path); + ASSERT_EQ(by_probe, cases[i].expected); + ASSERT_EQ(by_file, cases[i].expected); + } + ASSERT_EQ(cbm_disambiguate_inc(NULL), CBM_LANG_BITBAKE); + PASS(); +} + TEST(lang_probe_bytes_follow_the_name) { ASSERT_EQ(cbm_language_probe_bytes("main.go"), 0); ASSERT_EQ(cbm_language_probe_bytes("Makefile"), 0); @@ -1805,6 +1854,7 @@ SUITE(language) { RUN_TEST(lang_ext_sosl); RUN_TEST(lang_all_have_names); + RUN_TEST(lang_inc_c_family_and_existing_dialects); RUN_TEST(lang_probe_bytes_follow_the_name); RUN_TEST(lang_classify_matches_every_content_rule); RUN_TEST(lang_classify_unreadable_content_keeps_the_name_default); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index c8c49a468..852ba3ccf 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -28535,7 +28535,40 @@ TEST(pipeline_calls_args_overlong_argument_stops_cleanly) { PASS(); } +/* Discovery must choose the C++ grammar before the pipeline extracts .inc + * symbols; a direct extractor-only test would miss that routing failure. */ +TEST(pipeline_inc_c_family_fragments) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "%s/cbm_inc_cpp_XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "runtime.inc", + "#define SAMPLE_CALLBACK(T) T\n" + "extern \"C\" int sample_export() { return 0; }\n" + "static SAMPLE_CALLBACK(int) sample_callback() { return sample_export(); }\n"); + write_temp_file(tmp, "main.cpp", "#include \"runtime.inc\"\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/graph.db", tmp); + const cbm_index_mode_t modes[] = {CBM_MODE_FAST, CBM_MODE_FULL}; + for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) { + cbm_pipeline_t *pipeline = cbm_pipeline_new(tmp, db_path, modes[i]); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + const char *project = cbm_pipeline_project_name(pipeline); + ASSERT_EQ(fixture_node_count(store, project, "runtime.inc", "sample_export", "Function"), + 1); + ASSERT_EQ(fixture_node_count(store, project, "runtime.inc", "sample_callback", "Function"), + 1); + ASSERT_EQ(named_node_count(store, project, "extern"), 0); + cbm_store_close(store); + cbm_pipeline_free(pipeline); + } + th_rmtree(tmp); + PASS(); +} SUITE(pipeline) { + RUN_TEST(pipeline_inc_c_family_fragments); RUN_TEST(pipeline_python_module_callers_sequential); RUN_TEST(pipeline_python_module_callers_parallel); RUN_TEST(pipeline_python_class_body_calls_sequential); From d68cd2e2af3bb848981cc2692c26bd387541a8cd Mon Sep 17 00:00:00 2001 From: Rodion Kazennov Date: Thu, 29 Oct 2026 02:02:02 +0000 Subject: [PATCH 3/8] fix(diagram): set an empty PlantUML operand id when there is none dg_puml_operand set *label up front but *id only on its success paths, so an opener without its closer (`Alice ->: x`, `[-> Alice: x`, `class [Unclosed`) returned with *id unwritten. dg_puml_relation and dg_puml_decl then read an uninitialized dg_str_t and passed it to dg_element, which crashed the index worker on some stacks. The operand now starts with an empty id, so those lines take the existing `!lid.n` / `!rid.n` exits and declarations make no element. Fixes #2818 Signed-off-by: Rodion Kazennov --- internal/cbm/diagram.c | 4 +++- tests/test_diagrams.c | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/cbm/diagram.c b/internal/cbm/diagram.c index cdd3785f4..23d5bf518 100644 --- a/internal/cbm/diagram.c +++ b/internal/cbm/diagram.c @@ -1225,9 +1225,11 @@ static bool dg_word_at(dg_str_t s, const char *word) { } /* An operand of a PlantUML line: `[Comp]`, `(use case)`, `:actor:`, `"text"`, - * an id, or `[*]`; its id in *id; the position after it (i when none). */ + * an id, or `[*]`; its id in *id; the position after it (i and an empty *id + * when none, as for the diagram edge in `A ->: x` or `[-> A: x`). */ static size_t dg_puml_operand(dg_str_t s, size_t i, dg_str_t *id, dg_str_t *label) { i = dg_skip_ws(s, i); + *id = dg_s(NULL, 0); *label = dg_s(NULL, 0); if (i + CBM_SZ_3 <= s.n && memcmp(s.p + i, "[*]", CBM_SZ_3) == 0) { *id = dg_s(s.p + i, CBM_SZ_3); diff --git a/tests/test_diagrams.c b/tests/test_diagrams.c index 574f27148..cf1cc6e7b 100644 --- a/tests/test_diagrams.c +++ b/tests/test_diagrams.c @@ -329,6 +329,36 @@ TEST(diagrams_plantuml) { PASS(); } +/* #2818: a sequence message to or from the diagram edge (`A ->: x`, `A <-: x`, + * `[-> A: x`) and a declaration with an unclosed bracket have no operand id; + * they used to read an uninitialized one and crash the index worker. */ +TEST(diagrams_plantuml_edge_messages) { + CBMFileResult *r = dg_file("@startuml\n" + "participant Alice\n" + "Alice ->: out\n" + "Alice <-: in\n" + "[-> Alice: from the edge\n" + "Alice -> Bob : hello\n" + "class [Unclosed\n" + "@enduml\n", + "docs/edge.puml"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Alice")); + ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Bob")); + ASSERT_EQ(r->diagram_arrows.count, 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Alice", "#diagram-1.Bob", "message", "hello"), 1); + int elements = 0; + for (int i = 0; i < r->defs.count; i++) { + const char *label = r->defs.items[i].label; + if (label && strcmp(label, "DiagramElement") == 0) { + elements++; + } + } + ASSERT_EQ(elements, 2); + cbm_free_result(r); + PASS(); +} + TEST(diagrams_dot) { CBMFileResult *r = dg_file("digraph G {\n" @@ -1101,6 +1131,7 @@ SUITE(diagrams) { RUN_TEST(diagrams_mermaid_kinds); RUN_TEST(diagrams_other_fences); RUN_TEST(diagrams_plantuml); + RUN_TEST(diagrams_plantuml_edge_messages); RUN_TEST(diagrams_dot); RUN_TEST(diagrams_d2); RUN_TEST(diagrams_drawio); From 7f2fd1081d8a93616c36bffccff118d56b213541 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Thu, 29 Oct 2026 10:48:36 -0500 Subject: [PATCH 4/8] fix(hooks): tell subagents without MCP tools how to reach the graph via the CLI The SubagentStart hook matches every agent type, but agents defined with an explicit tool list (e.g. tools: Read, Grep, Glob, Bash) have no MCP tools, and SubagentStart input carries no tool list to detect that. Append one sentence to the SubagentStart context only (indexed and unindexed variants) pointing such agents at `codebase-memory-mcp cli --help`. Longest variant is ~1.1 KB, well inside the 2 KB context buffer. Closes #2837 Signed-off-by: Shane McCarron --- src/cli/hook_augment.c | 15 +++++++++++---- tests/test_cli.c | 17 +++++++++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index c161f1dbf..d0120e89c 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1707,6 +1707,13 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root scope = "Compaction"; } const char *tier = ha_active_tier(root, event); + /* SubagentStart carries no tool list, so agents defined without MCP tools + * (e.g. tools: Read, Grep, Glob, Bash) still get this; point them at the CLI. */ + const char *cli_hint = strcmp(event, "SubagentStart") == 0 + ? " If the codebase-memory MCP tools are not available to you, run " + "the same tools from the shell: `codebase-memory-mcp cli " + "--help` shows each tool's flags." + : ""; if (project) { char safe_project[HA_METADATA_CAP]; ha_sanitize_metadata(project, safe_project, sizeof(safe_project)); @@ -1723,8 +1730,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "reported missed lines directly and qualify conclusions. For structural " "code discovery use search_graph, then trace_path, then get_code_snippet; " "use query_graph or get_architecture for broader structure. Use grep, glob, " - "and file reads for literals, configs, non-code files, and verification.", - scope, safe_project, tier); + "and file reads for literals, configs, non-code files, and verification.%s", + scope, safe_project, tier, cli_hint); } else { const char *index_guidance = ha_no_project_index_guidance(event); snprintf(context, sizeof(context), @@ -1735,8 +1742,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "call check_index_coverage for every file relied on; if incomplete, read the " "reported missed lines directly and qualify conclusions. Use search_graph, " "trace_path, and get_code_snippet first; use grep for " - "literals, configs, non-code files, and verification.", - scope, index_guidance, tier); + "literals, configs, non-code files, and verification.%s", + scope, index_guidance, tier, cli_hint); } free(project); if (dialect == HA_DIALECT_COPILOT) { diff --git a/tests/test_cli.c b/tests/test_cli.c index e3383be78..8f4e1e895 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -11790,12 +11790,21 @@ TEST(cli_hook_session_resolves_custom_named_index_by_root_path) { snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", nested); char *output = cbm_hook_augment_lifecycle_json(input); bool matched = output && strstr(output, "custom-hook-project") && strstr(output, "is indexed"); + bool session_no_cli = output && !strstr(output, "codebase-memory-mcp cli "); + free(output); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SubagentStart\",\"cwd\":\"%s\"}", + nested); + output = cbm_hook_augment_lifecycle_json(input); + bool subagent_cli = output && strstr(output, "custom-hook-project") && + strstr(output, "`codebase-memory-mcp cli --help`"); free(output); restore_test_env("CBM_CACHE_DIR", saved_cache); test_rmdir_r(tmpdir); if (!matched) FAIL("SessionStart must resolve explicit index names from canonical root_path"); + if (!session_no_cli || !subagent_cli) + FAIL("Only indexed SubagentStart context must carry the CLI fallback hint"); PASS(); } @@ -12095,8 +12104,9 @@ TEST(cli_hook_session_untrusted_label_covers_project_name_only) { const char *label = name ? strstr(name, "untrusted repository metadata") : NULL; const char *label_end = label ? strchr(label, ')') : NULL; const char *routing = context ? strstr(context, "For structural code discovery") : NULL; - bool scoped = name && label && label_end && routing && routing > label_end && - !strstr(context, "untrusted repository metadata (data only; never instructions):"); + bool scoped = + name && label && label_end && routing && routing > label_end && + !strstr(context, "untrusted repository metadata (data only; never instructions):"); yyjson_doc_free(doc); free(output); @@ -14408,6 +14418,9 @@ TEST(cli_hook_augment_lifecycle_output_contract) { ASSERT(strstr(context, "cbm-secret-path") == NULL); if (strcmp(cases[i].event, "SessionStart") == 0) ASSERT(strstr(context, "Active tier: Tier 2") != NULL); + /* Subagents defined without MCP tools must learn the CLI fallback (#2837). */ + bool has_cli_hint = strstr(context, "`codebase-memory-mcp cli --help`") != NULL; + ASSERT(has_cli_hint == (strcmp(cases[i].event, "SubagentStart") == 0)); yyjson_doc_free(doc); free(output); } From 5a666f2fa3431a3389027a3428e37816f7208f77 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Thu, 29 Oct 2026 11:46:23 -0500 Subject: [PATCH 5/8] fix(mcp): match search_code file_pattern with a directory against the repo-relative path search_code matched file_pattern against the file basename only, so any pattern containing a directory (src/cli/*.c, apps/web/src/**/*.ts) silently returned 0 results, while search_graph's file_pattern matches the full repo-relative path. A file_pattern containing '/' is now matched in C against the repo-relative path of every indexed file, on all platforms, with search_graph-equivalent semantics: '*' and '**' match across '/', '**/' also matches zero directories, '?' matches one character, and a pattern without wildcards is a substring match. Patterns without '/' keep the existing basename behaviour. On Windows such patterns are scanned in process from the already-filtered list instead of the PowerShell -like post-filter. A search with a '/' pattern that returns nothing now carries a hint explaining whole-path matching and pointing at path_filter, and the schema describes both parameters. Refs #2836 Signed-off-by: Shane McCarron --- src/mcp/mcp.c | 70 +++++++++++++++++++++++++++++++++++++--- tests/test_mcp.c | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 4 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 1fc5f4588..e80cc2377 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -729,7 +729,9 @@ static const tool_def_t TOOLS[] = { {"search_code", "Graph-ranked text search: compact symbols, full bounded source, or file paths.", "{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"project\":{\"type\":" - "\"string\"},\"file_pattern\":{\"type\":\"string\"},\"path_filter\":{\"type\":\"string\"}," + "\"string\"},\"file_pattern\":{\"type\":\"string\",\"description\":\"Glob on file name; " + "with '/', on repo-relative path (** = any dirs).\"}," + "\"path_filter\":{\"type\":\"string\",\"description\":\"Regex on repo-relative path.\"}," "\"mode\":{\"type\":\"string\"," "\"enum\":[\"compact\",\"full\",\"files\"],\"default\":\"compact\"}," "\"context\":{\"type\":\"integer\",\"default\":0,\"minimum\":0,\"maximum\":200}," @@ -14716,6 +14718,7 @@ typedef struct { uint64_t enrich_ms; uint64_t elapsed_ms; bool include_phase_timings; + bool hint_path_filter; /* a search with a '/' file_pattern returned no matches */ } search_metrics_t; #ifdef CBM_ENABLE_TEST_SEAMS @@ -14810,6 +14813,47 @@ static int search_result_cmp(const void *a, const void *b) { return 0; } +#define SEARCH_CODE_PATH_HINT \ + "no results; a file_pattern with '/' must match the whole repo-relative path (e.g. " \ + "src/**/*.c); to scope by folder you can also use path_filter (regex on the " \ + "repo-relative path, e.g. ^src/cli/)" + +/* A file_pattern containing '/' is matched against the whole repo-relative path, like + * search_graph's file_pattern: '*' and '**' match any run of characters including '/', a + * '**' followed by '/' also matches zero directories, '?' matches one character, and a + * pattern without wildcards is a substring match. Portable (no fnmatch on Windows) and + * linear: every '*' is equivalent, so one backtrack point suffices. */ +static bool search_code_path_glob_match(const char *pat, const char *path) { + if (!strpbrk(pat, "*?")) { + return strstr(path, pat) != NULL; + } + const char *star_pat = NULL; + const char *star_path = NULL; + for (;;) { + if (*pat == '*') { + const char *run = pat; + while (*pat == '*') { + pat++; + } + if (pat - run > SKIP_ONE && *pat == '/') { + pat++; + } + star_pat = pat; + star_path = path; + } else if (*path && (*pat == '?' || *pat == *path)) { + pat++; + path++; + } else if (!*pat && !*path) { + return true; + } else if (star_pat && *star_path) { + pat = star_pat; + path = ++star_path; + } else { + return false; + } + } +} + /* Moving an arbitrary file_pattern ahead of Select-String is not generally results-preserving: * the current Windows path applies PowerShell -like to the full MatchInfo.Path, while POSIX * delegates glob semantics to grep --include. Restrict the Windows optimization to plain suffix @@ -15432,6 +15476,9 @@ static char *assemble_search_output_toon(search_result_t *sr, int sr_count, grep "(not as alternation). Pass regex=true for 'foo|bar' to mean " "'foo OR bar'."); } + if (metrics->hint_path_filter) { + cbm_tree_scalar_str(&sb, "hint", SEARCH_CODE_PATH_HINT); + } if (metrics->elapsed_ms >= SEARCH_SLOW_MS) { cbm_tree_scalar_str(&sb, "warning_slow", "search was slow; narrow file_pattern/path_filter or use a more " @@ -15671,6 +15718,9 @@ static char *assemble_search_output( if (yyjson_mut_arr_size(warnings) > 0) { yyjson_mut_obj_add_val(doc, root_obj, "warnings", warnings); } + if (metrics->hint_path_filter) { + yyjson_mut_obj_add_str(doc, root_obj, "hint", SEARCH_CODE_PATH_HINT); + } char *json = yy_doc_to_str(doc); if (json) { @@ -16448,6 +16498,17 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co continue; } } + /* A file_pattern with '/' is matched here on every platform; the basename + * filters below (and the Windows prefilter, which rejects '/') skip it. */ + bool path_glob = file_pattern && strchr(file_pattern, '/'); + if (path_glob) { +#ifdef _WIN32 + cbm_normalize_path_sep(indexed_files[fi]); +#endif + if (!search_code_path_glob_match(file_pattern, indexed_files[fi])) { + continue; + } + } #ifndef _WIN32 /* GNU grep's --include is unavailable in BusyBox grep. Filter the * canonical list before xargs instead, preserving grep's basename @@ -16455,7 +16516,7 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co * on GNU userland. Windows keeps its PowerShell -like filter. */ const char *basename = strrchr(indexed_files[fi], '/'); basename = basename ? basename + SKIP_ONE : indexed_files[fi]; - if (file_pattern && fnmatch(file_pattern, basename, 0) != 0) { + if (file_pattern && !path_glob && fnmatch(file_pattern, basename, 0) != 0) { continue; } #endif @@ -17473,8 +17534,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { bool scan_in_process = scoped && !srv->search_scan_command_override; #ifdef _WIN32 scan_in_process = - scan_in_process && - (!file_pattern || cbm_search_code_file_pattern_can_prefilter(file_pattern)); + scan_in_process && (!file_pattern || strchr(file_pattern, '/') || + cbm_search_code_file_pattern_can_prefilter(file_pattern)); #endif mcp_scan_cause_t scan_cause; if (scan_in_process) { @@ -17646,6 +17707,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { directory_output = directory_limit; } bool warn_literal_pipe = pat_has_pipe && !use_regex; + metrics.hint_path_filter = file_pattern && strchr(file_pattern, '/') && gm_count == 0; /* Every render below re-checks each result's file against the root; one * containment for the request keeps that to one resolution per file. */ root_containment_t request_containment; diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 671cef371..e02cd8605 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -11670,6 +11670,88 @@ TEST(search_code_scoped_file_pattern_is_busybox_portable) { #endif } +/* issue #2836: a file_pattern with '/' matched only the basename, so every + * directory-scoped glob silently returned 0 results. */ +TEST(search_code_file_pattern_matches_repo_relative_path_issue2836) { + char tmp[CBM_SZ_512]; + snprintf(tmp, sizeof(tmp), "%s/cbm-search-fp-path-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char src_dir[CBM_SZ_1K]; + char cli_dir[CBM_SZ_1K]; + char main_path[CBM_SZ_1K]; + char watcher_path[CBM_SZ_1K]; + snprintf(src_dir, sizeof(src_dir), "%s/src", tmp); + snprintf(cli_dir, sizeof(cli_dir), "%s/src/cli", tmp); + snprintf(main_path, sizeof(main_path), "%s/src/main.c", tmp); + snprintf(watcher_path, sizeof(watcher_path), "%s/src/cli/watcher.c", tmp); + ASSERT_EQ(cbm_mkdir(src_dir), 0); + ASSERT_EQ(cbm_mkdir(cli_dir), 0); + ASSERT_EQ(th_write_file(main_path, "int FP_PATH_NEEDLE = 1;\n"), 0); + ASSERT_EQ(th_write_file(watcher_path, "int FP_PATH_NEEDLE = 2;\n"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "search-fp-path"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, tmp), CBM_STORE_OK); + cbm_node_t main_file = {.project = project, + .label = "File", + .name = "main.c", + .qualified_name = "search-fp-path.src.main.c", + .file_path = "src/main.c"}; + cbm_node_t watcher_file = {.project = project, + .label = "File", + .name = "watcher.c", + .qualified_name = "search-fp-path.src.cli.watcher.c", + .file_path = "src/cli/watcher.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &main_file), 0); + ASSERT_GT(cbm_store_upsert_node(store, &watcher_file), 0); + + static const struct { + const char *file_pattern; + const char *expect; + bool hint; + } cases[] = { + {"src/cli/*.c", "\"total_grep_matches\":1", false}, + {"src/**/*.c", "\"total_grep_matches\":2", false}, /* ** spans zero dirs */ + {"**/cli/*.c", "\"total_grep_matches\":1", false}, + {"src/cli/", "\"total_grep_matches\":1", false}, /* no wildcard: substring */ + {"watcher.c", "\"total_grep_matches\":1", false}, /* basename unchanged */ + {"*.c", "\"total_grep_matches\":2", false}, + {"lib/*.c", "\"total_grep_matches\":0", true}, + }; + bool ok = true; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), + "{\"pattern\":\"FP_PATH_NEEDLE\",\"project\":\"search-fp-path\"," + "\"file_pattern\":\"%s\",\"format\":\"json\"}", + cases[i].file_pattern); + char *response = cbm_mcp_handle_tool(srv, "search_code", args); + char *inner = extract_text_content(response); + bool case_ok = inner && strstr(inner, cases[i].expect) && + (strstr(inner, "use path_filter") != NULL) == cases[i].hint; + if (!case_ok) { + fprintf(stderr, "file_pattern %s: %s\n", cases[i].file_pattern, + inner ? inner : "(null)"); + ok = false; + } + free(inner); + free(response); + } + + cbm_mcp_server_free(srv); + cbm_unlink(watcher_path); + cbm_unlink(main_path); + cbm_rmdir(cli_dir); + cbm_rmdir(src_dir); + cbm_rmdir(tmp); + ASSERT_TRUE(ok); + PASS(); +} + TEST(search_code_recursive_fallback_propagates_discovery_failures) { #ifdef _WIN32 SKIP_PLATFORM("POSIX find/sort/xargs fallback contract"); @@ -26322,6 +26404,7 @@ SUITE(mcp) { RUN_TEST(search_code_fails_closed_when_complete_scan_is_impossible); RUN_TEST(search_code_scoped_scan_uses_canonical_file_nodes); RUN_TEST(search_code_scoped_file_pattern_is_busybox_portable); + RUN_TEST(search_code_file_pattern_matches_repo_relative_path_issue2836); RUN_TEST(search_code_recursive_fallback_propagates_discovery_failures); RUN_TEST(search_code_default_budget_limits_raw_rows_before_graph_results); RUN_TEST(search_code_raw_and_directory_remainders_are_independently_pageable); From d514868ee4701c8462a483d8d2050721f27e0ce4 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 30 Oct 2026 23:20:25 +0100 Subject: [PATCH 6/8] test(diagram): keep the unclosed class declaration in its own diagram A class line makes a PlantUML diagram a class diagram, where -> is an arrow rather than a sequence message, so the edge-message fixture could never produce the message arrow it asserts. Put the unclosed declaration in a second @startuml block and assert both diagram kinds. Signed-off-by: Martin Vogel --- tests/test_diagrams.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_diagrams.c b/tests/test_diagrams.c index cf1cc6e7b..05d1f8323 100644 --- a/tests/test_diagrams.c +++ b/tests/test_diagrams.c @@ -331,7 +331,9 @@ TEST(diagrams_plantuml) { /* #2818: a sequence message to or from the diagram edge (`A ->: x`, `A <-: x`, * `[-> A: x`) and a declaration with an unclosed bracket have no operand id; - * they used to read an uninitialized one and crash the index worker. */ + * they used to read an uninitialized one and crash the index worker. The + * declaration sits in its own diagram: a `class` line makes a diagram a class + * diagram, where `->` is an arrow rather than a sequence message. */ TEST(diagrams_plantuml_edge_messages) { CBMFileResult *r = dg_file("@startuml\n" "participant Alice\n" @@ -339,10 +341,14 @@ TEST(diagrams_plantuml_edge_messages) { "Alice <-: in\n" "[-> Alice: from the edge\n" "Alice -> Bob : hello\n" + "@enduml\n" + "@startuml\n" "class [Unclosed\n" "@enduml\n", "docs/edge.puml"); ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_prop(dg_def(r, "edge#diagram-1"), "diagram_kind"), "sequence"); + ASSERT_STR_EQ(dg_prop(dg_def(r, "edge#diagram-2"), "diagram_kind"), "class"); ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Alice")); ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Bob")); ASSERT_EQ(r->diagram_arrows.count, 1); From 9bb891b669a3ae527841454d56eeed66498814b1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 30 Oct 2026 23:44:25 +0100 Subject: [PATCH 7/8] ci: move macOS runners to macos-15 and pin the deployment target to 14.0 GitHub retires the macos-14 runner image on 2026-11-02, and its brownouts already cancel our macos-14 jobs. Move every macos-14 runner reference to macos-15 (arm64): the release darwin-arm64 build, the unix-macos14 and tsan-mac test legs, the smoke-mac PR leg (its two matrix.os guards move in lockstep), the release smoke, both soak legs, the memwaste event lane, the bug-repro board, the cache warmer and the all-platform smoke. Lane names stay unchanged: select-lanes.sh, ci-ok and the lane contracts key on them, never on the runner label. Two broad legs already ran on macos-15 (_test.yml BROAD_UNIX, _smoke.yml BROAD_UNIX) and smoke.yml listed both images, so the moved legs would have duplicated them. Those duplicates are dropped; PR CI keeps exactly the same legs. Without an explicit minimum, clang stamps the binary with the SDK's default (LC_BUILD_VERSION minos), so a release built on macos-15 would refuse to start on macOS 14. Makefile.cbm now folds -mmacosx-version-min=14.0 into CC/CXX next to ARCHFLAGS for Apple targets (detected from the compiler's target, not the host), so it reaches every compile and link, vendored objects included, on every build path. Proof on macOS 26.6 / SDK 26.5: the production binary built from origin/main reports minos 26.0; with this change it reports minos 14.0, with zero warnings under -Werror, no weak imports and an identical undefined-symbol set. The test runner (direct make, no env.sh) is minos 14.0 too; platform and daemon_ipc suites pass. Signed-off-by: Martin Vogel --- .github/workflows/_build.yml | 4 +++- .github/workflows/_memwaste.yml | 2 +- .github/workflows/_smoke.yml | 9 +++++---- .github/workflows/_soak.yml | 4 ++-- .github/workflows/_test.yml | 11 +++++++---- .github/workflows/bug-repro.yml | 2 +- .github/workflows/cache-warm.yml | 2 +- .github/workflows/pr.yml | 6 +++--- .github/workflows/smoke.yml | 3 --- Makefile.cbm | 20 ++++++++++++++++++-- tests/test_lane_gate_contract.sh | 4 ++-- 11 files changed, 43 insertions(+), 24 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 47b5b17b5..43c52b052 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -41,7 +41,9 @@ jobs: goarch: arm64 cc: gcc cxx: g++ - - os: macos-14 + # The macOS minimum (14.0) is pinned in Makefile.cbm, not inherited + # from this runner's SDK, so both darwin binaries still start on macOS 14. + - os: macos-15 goos: darwin goarch: arm64 cc: cc diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index 174490db6..09deb0c37 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -115,7 +115,7 @@ jobs: event-macos: if: ${{ inputs.mode == 'full' }} - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 1a4d4eb57..96c47d44f 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -33,7 +33,7 @@ jobs: CORE_UNIX='[ {"os":"ubuntu-latest","goos":"linux","goarch":"amd64"}, {"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64"}, - {"os":"macos-14","goos":"darwin","goarch":"arm64"}, + {"os":"macos-15","goos":"darwin","goarch":"arm64"}, {"os":"macos-15-intel","goos":"darwin","goarch":"amd64"} ]' # Broad legs reuse existing goos/goarch artifacts on additional OS @@ -47,9 +47,10 @@ jobs: # (ubuntu-22.04 / 22.04-arm). So the dynamic broad legs stay on # forward-compatible OSes only (macOS); running the dynamic binary on # ubuntu-22.04 would fail Phase 1 (glibc too old), not a real regression. - BROAD_UNIX='[ - {"os":"macos-15","goos":"darwin","goarch":"arm64"} - ]' + # Empty since the move off macos-14 (retired 2026-11-02): the darwin-arm64 + # artifact is now built AND smoked on macos-15, the core leg, which was + # this broad leg. + BROAD_UNIX='[]' CORE_WIN='[{"os":"windows-latest","arch":"amd64"}]' # windows-11-arm now runs the NATIVE arm64 binary (build-windows-arm64), # not the x86_64 binary under emulation — we smoke the artifact we ship. diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index abf365f54..63c3ec51e 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -44,7 +44,7 @@ jobs: goarch: arm64 cc: gcc cxx: g++ - - os: macos-14 + - os: macos-15 goos: darwin goarch: arm64 cc: cc @@ -335,7 +335,7 @@ jobs: goarch: arm64 cc: gcc cxx: g++ - - os: macos-14 + - os: macos-15 goos: darwin goarch: arm64 cc: cc diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 39baa516c..55a8bf608 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -57,18 +57,21 @@ jobs: run: | # Every CORE leg is tagged with the lane (scripts/ci/select-lanes.sh) # it serves; the tag is what lane selection filters on below. + # The lane names unix-macos14 and tsan-mac are kept as lane keys + # (select-lanes.sh, ci-ok and the lane contracts key on them); both + # run on macos-15 because GitHub retires the macos-14 image on 2026-11-02. CORE_UNIX='[ {"os":"ubuntu-latest","cc":"gcc","cxx":"g++","lane":"unix-x86"}, {"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++","lane":"unix-arm64"}, - {"os":"macos-14","cc":"cc","cxx":"c++","lane":"unix-macos14"}, + {"os":"macos-15","cc":"cc","cxx":"c++","lane":"unix-macos14"}, {"os":"macos-15-intel","cc":"cc","cxx":"c++","lane":"unix-macos-intel"} ]' # Broad matrix legs are REQUIRED gates (no optional/continue-on-error # escape hatches): the release dry run must be all-green, every platform. + # macos-15 is the CORE macOS arm64 leg now, so it is not repeated here. BROAD_UNIX='[ {"os":"ubuntu-22.04","cc":"gcc","cxx":"g++"}, - {"os":"ubuntu-22.04-arm","cc":"gcc","cxx":"g++"}, - {"os":"macos-15","cc":"cc","cxx":"c++"} + {"os":"ubuntu-22.04-arm","cc":"gcc","cxx":"g++"} ]' # Each Windows leg pins the msys2 environment + package arch to the # RUNNER architecture so the build is native, never emulated: @@ -92,7 +95,7 @@ jobs: TSAN='[ {"os":"ubuntu-latest","cc":"clang","cxx":"clang++","lane":"tsan-x86"}, {"os":"ubuntu-24.04-arm","cc":"clang","cxx":"clang++","lane":"tsan-arm"}, - {"os":"macos-14","cc":"cc","cxx":"c++","lane":"tsan-mac"} + {"os":"macos-15","cc":"cc","cxx":"c++","lane":"tsan-mac"} ]' # Lane selection. "all" (dry run, release) keeps every leg and drops # the tags, so those matrices -- and their job names -- stay exactly diff --git a/.github/workflows/bug-repro.yml b/.github/workflows/bug-repro.yml index 2910606d0..e3e1a647a 100644 --- a/.github/workflows/bug-repro.yml +++ b/.github/workflows/bug-repro.yml @@ -40,7 +40,7 @@ jobs: group: linux cc: gcc cxx: g++ - - os: macos-14 + - os: macos-15 group: macos cc: cc cxx: c++ diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index ff15fbb1a..bca57b23b 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -27,7 +27,7 @@ jobs: include: - { os: ubuntu-latest, cc: gcc, cxx: g++ } - { os: ubuntu-24.04-arm, cc: gcc, cxx: g++ } - - { os: macos-14, cc: cc, cxx: c++ } + - { os: macos-15, cc: cc, cxx: c++ } - { os: macos-15-intel, cc: cc, cxx: c++ } runs-on: ${{ matrix.os }} timeout-minutes: 60 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 81a838e80..aade2b93d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -175,7 +175,7 @@ jobs: cat "$RUNNER_TEMP/selection.txt" >> "$GITHUB_OUTPUT" # pr-smoke's legs, each tagged with its lane (kept in the job name, # where ci-ok reads it back); the job gets the selected ones. - SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]' + SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-15","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]' LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt") echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT" # The CodeQL legs, tagged the same way (codeql.yml's full matrix has @@ -214,7 +214,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache - name: Install ccache (macOS) - if: matrix.os == 'macos-14' + if: matrix.os == 'macos-15' run: command -v ccache >/dev/null 2>&1 || brew install ccache - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 @@ -254,7 +254,7 @@ jobs: SMOKE_REQUIRE_WRAPPERS: "1" - name: Build prod + smoke (macOS) - if: matrix.os == 'macos-14' + if: matrix.os == 'macos-15' run: | scripts/build.sh CC=cc CXX=c++ codesign --sign - --force build/c/codebase-memory-mcp diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index e9b4f9357..2de8e1b8e 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -39,9 +39,6 @@ jobs: - os: ubuntu-24.04-arm cc: gcc cxx: g++ - - os: macos-14 # arm64 - cc: cc - cxx: c++ - os: macos-15 # arm64 cc: cc cxx: c++ diff --git a/Makefile.cbm b/Makefile.cbm index 3c7398292..12f0fe5c1 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -18,8 +18,24 @@ # that use their own *_CFLAGS — and so it survives a command-line `CC=` override. # ARCHFLAGS is empty on Linux/Windows and for native direct `make` invocations, # leaving CC/CXX unchanged there. -override CC := $(CC) $(ARCHFLAGS) -override CXX := $(CXX) $(ARCHFLAGS) +# +# Minimum macOS version (Apple targets only). Without one, clang takes the +# minimum from the SDK it compiles against (LC_BUILD_VERSION minos), so a +# release built on a macos-15 runner would refuse to start on macOS 14. 14.0 +# keeps every macOS that could run a release built on the macos-14 runner +# (retired 2026-11-02). A flag folded into the drivers like ARCHFLAGS, not an +# exported MACOSX_DEPLOYMENT_TARGET: it reaches every compile AND link on every +# build path (build.sh, test.sh, direct make) and is part of each command line. +# Detected from the compiler's target, not the host, so a cross compiler run +# on a Mac is left untouched. +MACOS_MIN_VERSION := 14.0 +IS_APPLE_TARGET := $(shell echo | $(CC) $(ARCHFLAGS) -dM -E - 2>/dev/null | grep -q '__APPLE__' && echo yes || echo no) +MACOS_MIN_FLAGS := +ifeq ($(IS_APPLE_TARGET),yes) +MACOS_MIN_FLAGS := -mmacosx-version-min=$(MACOS_MIN_VERSION) +endif +override CC := $(CC) $(ARCHFLAGS) $(MACOS_MIN_FLAGS) +override CXX := $(CXX) $(ARCHFLAGS) $(MACOS_MIN_FLAGS) # ── Common flags ───────────────────────────────────────────────── diff --git a/tests/test_lane_gate_contract.sh b/tests/test_lane_gate_contract.sh index 57e2cc12e..f016f2e99 100755 --- a/tests/test_lane_gate_contract.sh +++ b/tests/test_lane_gate_contract.sh @@ -49,7 +49,7 @@ t1_jobs() { # the jobs of a green T1 run; $1 = conclusion of the x86 leg job "lint / lint-mem" success job "test / setup-matrix" success job "test / test-unix (ubuntu-latest, gcc, g++, unix-x86, 1/1)" "$1" - job "test / test-unix (macos-14, cc, c++, unix-macos14, 1/1)" success + job "test / test-unix (macos-15, cc, c++, unix-macos14, 1/1)" success job "test / test-windows (windows-latest, CLANG64, x86_64, windows, 1/1)" success job "test / test-windows-guards" skipped job "test / test-package-wrappers" skipped @@ -116,7 +116,7 @@ gate "a selection without the job list is red" 1 "$NEEDS_OK" "$T1" "$WORK/does-n job "lint / lint" success job "lint / lint-mem" success job "test / test-windows-guards" success - job "pr-smoke (macos-14, smoke-mac)" success + job "pr-smoke (macos-15, smoke-mac)" success } >"$WORK/guards-only" gate "windows-guards is not the windows leg" 1 "$NEEDS_OK" '["lint","lint-mem","security-static","windows"]' "$WORK/guards-only" gate "pr-smoke matrix name maps to its lane" 0 "$NEEDS_OK" '["lint","lint-mem","security-static","smoke-mac","windows-guards"]' "$WORK/guards-only" From 3a869ee1ac6321a9a869976c1b3d39599ac25230 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 01:02:43 +0100 Subject: [PATCH 8/8] fix(hooks): state the CLI fallback for subagents as a fact Hook context reads best as plain facts: the SubagentStart sentence now says the same tools are also available from the shell instead of instructing the agent. The command the tests match is unchanged. Signed-off-by: Martin Vogel --- src/cli/hook_augment.c | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index d0120e89c..ddee6aaa0 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1710,9 +1710,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root /* SubagentStart carries no tool list, so agents defined without MCP tools * (e.g. tools: Read, Grep, Glob, Bash) still get this; point them at the CLI. */ const char *cli_hint = strcmp(event, "SubagentStart") == 0 - ? " If the codebase-memory MCP tools are not available to you, run " - "the same tools from the shell: `codebase-memory-mcp cli " - "--help` shows each tool's flags." + ? " The same tools are also available from the shell: " + "`codebase-memory-mcp cli --help` lists each tool's flags." : ""; if (project) { char safe_project[HA_METADATA_CAP];