diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 667029d5c..64e10a328 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -760,6 +760,13 @@ static void expr_free(cbm_expr_t *e) { safe_str_free(&cur->cond.in_values[i]); } free(cur->cond.in_values); + safe_str_free(&cur->cond.func); + for (int i = 0; i < cur->cond.arg_count; i++) { + safe_str_free(&cur->cond.args[i].variable); + safe_str_free(&cur->cond.args[i].property); + safe_str_free(&cur->cond.args[i].literal); + } + free(cur->cond.args); } if (cur->right) { if (top < EXPR_FREE_STACK) { @@ -838,6 +845,37 @@ static const char *unsupported_clause_error(cbm_token_type_t type) { /* Forward declarations for recursive descent */ static cbm_expr_t *parse_or_expr(parser_t *p); +/* Multi-arg scalar function support, shared with the RETURN-item parser (#874) */ +static bool is_multiarg_func_call(parser_t *p); +static int parse_multiarg_func_item(parser_t *p, cbm_return_item_t *item); + +/* Free a multi-arg function argument array. */ +static void func_args_free(cbm_func_arg_t *args, int count) { + for (int i = 0; i < count; i++) { + safe_str_free(&args[i].variable); + safe_str_free(&args[i].property); + safe_str_free(&args[i].literal); + } + free(args); +} + +/* Free the fields of a partially-parsed multi-arg function item. */ +static void func_item_fields_free(cbm_return_item_t *item) { + safe_str_free(&item->variable); + safe_str_free(&item->property); + safe_str_free(&item->func); + func_args_free(item->args, item->arg_count); + item->args = NULL; + item->arg_count = 0; +} + +/* Free the function-call fields of a WHERE condition (#874). */ +static void cond_func_fields_free(cbm_condition_t *c) { + safe_str_free(&c->func); + func_args_free(c->args, c->arg_count); + c->args = NULL; + c->arg_count = 0; +} /* Parse IN [val, val, ...] list. Returns expr_leaf or NULL on error. */ static cbm_expr_t *parse_in_list(parser_t *p, cbm_condition_t *c) { @@ -1008,11 +1046,9 @@ static cbm_expr_t *parse_exists_predicate(parser_t *p, bool negated) { return expr_leaf(c); } -static bool cyp_ci_eq(const char *a, const char *b); - /* Parse the operator + value tail shared by every condition subject - * (var[.prop] and coalesce(var.prop, literal)): IS [NOT] NULL, IN [...], - * or a comparison operator with a literal value. Consumes/frees *c. */ + * (var[.prop] and multi-arg functions like coalesce(...)): IS [NOT] NULL, + * IN [...], or a comparison operator with a literal value. */ static cbm_expr_t *parse_condition_op(parser_t *p, cbm_condition_t *c) { /* IS NULL / IS NOT NULL */ if (check(p, TOK_IS)) { @@ -1029,13 +1065,18 @@ static cbm_expr_t *parse_condition_op(parser_t *p, cbm_condition_t *c) { /* IN [...] */ if (check(p, TOK_IN)) { - return parse_in_list(p, c); + cbm_expr_t *e = parse_in_list(p, c); + if (!e) { + cond_func_fields_free(c); + } + return e; } /* Standard operators */ c->op = parse_comparison_op(p); if (!c->op) { snprintf(p->error, sizeof(p->error), "unexpected operator at pos %d", peek(p)->pos); + cond_func_fields_free(c); safe_str_free(&c->variable); safe_str_free(&c->property); safe_str_free(&c->coalesce_default); @@ -1053,6 +1094,7 @@ static cbm_expr_t *parse_condition_op(parser_t *p, cbm_condition_t *c) { c->value = heap_strdup("false"); } else { snprintf(p->error, sizeof(p->error), "expected value at pos %d", peek(p)->pos); + cond_func_fields_free(c); safe_str_free(&c->variable); safe_str_free(&c->property); safe_str_free(&c->op); @@ -1063,6 +1105,75 @@ static cbm_expr_t *parse_condition_op(parser_t *p, cbm_condition_t *c) { return expr_leaf(*c); } +/* parse_condition_lhs result: the label-test form is a complete condition. */ +enum { COND_LHS_COMPLETE = 1 }; + +/* Parse the left-hand side of a WHERE condition into c. + * Returns CBM_NOT_FOUND on error, 0 when an operator/value should follow, and + * COND_LHS_COMPLETE when the condition is already complete (label test). */ +static int parse_condition_lhs(parser_t *p, cbm_condition_t *c) { + if (is_multiarg_func_call(p)) { + /* Multi-arg scalar function LHS: coalesce(f.depth, 0) >= 2 (#874). + * Reuse the RETURN-item parser, then move ownership into the condition. */ + cbm_return_item_t fitem; + memset(&fitem, 0, sizeof(fitem)); + if (parse_multiarg_func_item(p, &fitem) < 0) { + func_item_fields_free(&fitem); + return CBM_NOT_FOUND; + } + c->variable = fitem.variable; + c->property = fitem.property; + c->func = fitem.func; + c->args = fitem.args; + c->arg_count = fitem.arg_count; + return 0; + } + + if (check(p, TOK_IDENT) && p->pos + SKIP_ONE < p->count && + p->tokens[p->pos + SKIP_ONE].type == TOK_LPAREN) { + /* Unrecognised function call in WHERE — fail loudly with the supported + * set instead of the misleading "unexpected operator" (#874). */ + snprintf(p->error, sizeof(p->error), + "unsupported function '%s' in WHERE (supported: coalesce, substring, replace, " + "left, right)", + peek(p)->text); + return CBM_NOT_FOUND; + } + + const cbm_token_t *var = expect(p, TOK_IDENT); + if (!var) { + return CBM_NOT_FOUND; + } + + /* Label test: WHERE n:Label (openCypher, #241). Modelled as a leaf with + * op="HAS_LABEL" and value=Label, evaluated against the bound node's label. */ + if (check(p, TOK_COLON)) { + advance(p); + const cbm_token_t *lbl = expect(p, TOK_IDENT); + if (!lbl) { + return CBM_NOT_FOUND; + } + c->variable = heap_strdup(var->text); + c->op = heap_strdup("HAS_LABEL"); + c->value = heap_strdup(lbl->text); + return COND_LHS_COMPLETE; + } + + if (match(p, TOK_DOT)) { + const cbm_token_t *prop = expect(p, TOK_IDENT); + if (!prop) { + return CBM_NOT_FOUND; + } + c->variable = heap_strdup(var->text); + c->property = heap_strdup(prop->text); + } else { + /* No dot: bare alias (e.g. post-WITH variable like "cnt") */ + c->variable = heap_strdup(var->text); + c->property = NULL; + } + return 0; +} + static cbm_expr_t *parse_condition_expr(parser_t *p) { /* Check for NOT prefix at condition level (e.g. NOT n.name CONTAINS "x") */ bool negated = match(p, TOK_NOT); @@ -1072,73 +1183,16 @@ static cbm_expr_t *parse_condition_expr(parser_t *p) { return parse_exists_predicate(p, negated); } - /* coalesce(var.prop, ) as a null-safe condition subject (#874). - * The default literal substitutes a missing/empty property at eval time; - * any comparison operator may follow, exactly as with a bare var.prop. */ - if (check(p, TOK_IDENT) && cyp_ci_eq(peek(p)->text, "coalesce") && - p->pos + SKIP_ONE < p->count && p->tokens[p->pos + SKIP_ONE].type == TOK_LPAREN) { - advance(p); /* coalesce */ - advance(p); /* ( */ - const cbm_token_t *cvar = expect(p, TOK_IDENT); - if (!cvar || !expect(p, TOK_DOT)) { - return NULL; - } - const cbm_token_t *cprop = expect(p, TOK_IDENT); - if (!cprop || !expect(p, TOK_COMMA)) { - return NULL; - } - if (!check(p, TOK_STRING) && !check(p, TOK_NUMBER)) { - return NULL; /* supported form: coalesce(var.prop, literal) */ - } - const cbm_token_t *cdef = peek(p); - cbm_condition_t cc = {0}; - cc.negated = negated; - cc.variable = heap_strdup(cvar->text); - cc.property = heap_strdup(cprop->text); - cc.coalesce_default = heap_strdup(cdef->text); - advance(p); - if (!expect(p, TOK_RPAREN)) { - safe_str_free(&cc.variable); - safe_str_free(&cc.property); - safe_str_free(&cc.coalesce_default); - return NULL; - } - return parse_condition_op(p, &cc); - } - - const cbm_token_t *var = expect(p, TOK_IDENT); - if (!var) { - return NULL; - } - cbm_condition_t c = {0}; c.negated = negated; - /* Label test: WHERE n:Label (openCypher, #241). Modelled as a leaf with - * op="HAS_LABEL" and value=Label, evaluated against the bound node's label. */ - if (check(p, TOK_COLON)) { - advance(p); - const cbm_token_t *lbl = expect(p, TOK_IDENT); - if (!lbl) { - return NULL; - } - c.variable = heap_strdup(var->text); - c.op = heap_strdup("HAS_LABEL"); - c.value = heap_strdup(lbl->text); - return expr_leaf(c); + int lhs_rc = parse_condition_lhs(p, &c); + if (lhs_rc < 0) { + return NULL; } - - if (match(p, TOK_DOT)) { - const cbm_token_t *prop = expect(p, TOK_IDENT); - if (!prop) { - return NULL; - } - c.variable = heap_strdup(var->text); - c.property = heap_strdup(prop->text); - } else { - /* No dot: bare alias (e.g. post-WITH variable like "cnt") */ - c.variable = heap_strdup(var->text); - c.property = NULL; + if (lhs_rc > 0) { + /* HAS_LABEL leaf — complete condition, no operator follows */ + return expr_leaf(c); } return parse_condition_op(p, &c); @@ -1995,6 +2049,8 @@ static void free_where(cbm_where_clause_t *w) { safe_str_free(&w->conditions[i].in_values[j]); } free(w->conditions[i].in_values); + safe_str_free(&w->conditions[i].func); + func_args_free(w->conditions[i].args, w->conditions[i].arg_count); } free(w->conditions); safe_str_free(&w->op); @@ -2230,15 +2286,49 @@ static const char *json_extract_prop(const char *json, const char *key, char *bu p++; } if (*p == '"') { - /* String value */ + /* String value — honor backslash escapes: without this, an embedded \" + * cuts the value short at the first escaped quote. */ p++; size_t i = 0; while (*p && *p != '"' && i < buf_sz - SKIP_ONE) { + if (*p == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) { + buf[i++] = *p++; /* keep the escape pair intact */ + } buf[i++] = *p++; } buf[i] = '\0'; + } else if (*p == '[' || *p == '{') { + /* Array/object value — copy the whole balanced construct. A scan-to-comma + * truncates at the first comma INSIDE the value: e.g. a decorators array + * ["@Roles('OWNER', 'ADMIN')","@Get()"] came back as ["@Roles('OWNER'. */ + char open = *p; + char close = (open == '[') ? ']' : '}'; + int depth = 0; + int in_str = 0; + size_t i = 0; + while (*p && i < buf_sz - SKIP_ONE) { + char c = *p; + if (in_str) { + if (c == '\\' && p[SKIP_ONE] && i + SKIP_ONE < buf_sz - SKIP_ONE) { + buf[i++] = *p++; /* escape pair stays intact */ + } else if (c == '"') { + in_str = 0; + } + } else if (c == '"') { + in_str = 1; + } else if (c == open) { + depth++; + } else if (c == close) { + depth--; + } + buf[i++] = *p++; + if (!in_str && depth == 0) { + break; /* outer bracket closed */ + } + } + buf[i] = '\0'; } else { - /* Numeric or other value */ + /* Numeric or other scalar value */ size_t i = 0; while (*p && *p != ',' && *p != '}' && *p != ' ' && i < buf_sz - SKIP_ONE) { buf[i++] = *p++; @@ -2384,8 +2474,26 @@ static void binding_set(binding_t *b, const char *var, const cbm_node_t *node) { b->var_count++; } +static const char *eval_multiarg_func(binding_t *b, const cbm_return_item_t *item, char *buf, + size_t bufsz); + /* Resolve the actual property value for a condition from a binding */ static const char *resolve_condition_value(const cbm_condition_t *c, binding_t *b) { + /* Multi-arg scalar function LHS: coalesce(f.depth, 0) >= 2 (#874). + * Evaluated through the same code path as RETURN projections. The value is + * consumed by eval_condition before any other condition is resolved, so a + * single thread-local buffer per call is safe. */ + if (c->func) { + static _Thread_local char func_buf[CBM_SZ_512]; + cbm_return_item_t item = {0}; + item.variable = c->variable; + item.property = c->property; + item.func = c->func; + item.args = c->args; + item.arg_count = c->arg_count; + return eval_multiarg_func(b, &item, func_buf, sizeof(func_buf)); + } + cbm_edge_t *e = binding_get_edge(b, c->variable); if (e) { return edge_prop(e, c->property); @@ -2650,6 +2758,49 @@ static void rb_add_row(result_builder_t *rb, const char **values) { * Prevents accidental multi-GB JSON payloads from unbounded MATCH (n) RETURN n. */ #define CYPHER_RESULT_CEILING 100000 +/* Wall-clock execution deadline (#601). The row ceiling above only fires once + * rows exist, but an unbounded `OPTIONAL MATCH` over the full node set (or a + * GROUP BY with ~one group per node) does O(bindings x groups) work and can run + * for minutes — exhausting RAM/CPU — before a single row is produced, so the + * ceiling never trips and the caller just hangs. A monotonic deadline aborts + * such runaway queries with a clear, actionable error. Checked (throttled) in + * the scan, expansion and aggregation hot loops. */ +#define CYPHER_DEADLINE_BUDGET_MS 30000 /* 30s: generous for legit heavy queries */ +#define CYPHER_DEADLINE_CHECK_MASK 0x3FF /* sample the clock every 1024 iterations */ + +static _Thread_local uint64_t g_cypher_deadline_ms = 0; /* absolute; 0 = disarmed */ +static _Thread_local bool g_cypher_timed_out = false; +static _Thread_local int64_t g_cypher_deadline_override_ms = -1; /* test hook; <0 = default */ + +static void cypher_deadline_arm(void) { + g_cypher_timed_out = false; + int64_t budget = g_cypher_deadline_override_ms >= 0 ? g_cypher_deadline_override_ms + : CYPHER_DEADLINE_BUDGET_MS; + g_cypher_deadline_ms = cbm_now_ms() + (uint64_t)budget; +} + +/* True once the query has run past its wall-clock budget. Sticky: after the + * first trip every subsequent call returns true, so later loops short-circuit. */ +static bool cypher_deadline_exceeded(void) { + if (g_cypher_timed_out) { + return true; + } + if (g_cypher_deadline_ms == 0) { + return false; + } + if (cbm_now_ms() >= g_cypher_deadline_ms) { + g_cypher_timed_out = true; + return true; + } + return false; +} + +/* Test-only: force the execution budget (ms) for subsequent queries on this + * thread. 0 = trip on the first hot-loop check; <0 restores the default. */ +void cbm_cypher_test_set_deadline_ms(int64_t budget_ms) { + g_cypher_deadline_override_ms = budget_ms; +} + /* ── Binding virtual variables (for WITH clause) ──────────────── */ static const char *binding_get_virtual(binding_t *b, const char *var, const char *prop) { @@ -3054,6 +3205,11 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_ int *bind_count, const int *bind_cap, const char **var_name, bool is_optional) { for (int ri = 0; ri < pat->rel_count; ri++) { + /* #601: stop expanding further hops once the wall-clock budget is spent + * (an unbounded expansion is exactly what blows up here). */ + if (cypher_deadline_exceeded()) { + return; + } cbm_rel_pattern_t *rel = &pat->rels[ri]; cbm_node_pattern_t *target_node = &pat->nodes[ri + SKIP_ONE]; const char *to_var = target_node->variable ? target_node->variable : "_n_t"; @@ -3068,6 +3224,9 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_ int new_count = 0; for (int bi = 0; bi < *bind_count; bi++) { + if ((bi & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) { + break; + } binding_t *b = &(*bindings)[bi]; cbm_node_t *src = binding_get(b, *var_name); if (!src) { @@ -4111,6 +4270,11 @@ static void execute_return_agg(cbm_return_clause_t *ret, binding_t *bindings, in int agg_count = 0; for (int bi = 0; bi < bind_count; bi++) { + /* #601: grouping is O(bindings x groups) — the dominant cost on a + * whole-graph GROUP BY. Abort if we blow the wall-clock budget. */ + if ((bi & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) { + break; + } char key[CBM_SZ_1K] = ""; const char *vals[CBM_SZ_32]; char valbufs[CBM_SZ_32][CBM_SZ_512]; @@ -4479,6 +4643,9 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec const char *var_name = pat0->nodes[0].variable ? pat0->nodes[0].variable : "_n0"; for (int i = 0; i < scan_count && bind_count < bind_cap; i++) { + if ((i & CYPHER_DEADLINE_CHECK_MASK) == 0 && cypher_deadline_exceeded()) { + break; + } binding_t b = {0}; b.store = store; binding_set(&b, var_name, &scanned[i]); @@ -4527,6 +4694,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec cbm_cypher_result_t *out) { memset(out, 0, sizeof(*out)); g_cypher_depth_clamped = 0; + cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */ if (max_rows <= 0) { max_rows = CYPHER_RESULT_CEILING; } @@ -4570,6 +4738,18 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec rb_apply_distinct(&rb); } + /* #601: abort a runaway query that blew the wall-clock budget before it can + * return a misleading partial result. Checked before the row ceiling. */ + if (g_cypher_timed_out) { + rb_free(&rb); + cbm_query_free(q); + out->error = + heap_strdup("query exceeded the execution time limit — narrow the pattern with a WHERE " + "filter, use a directed MATCH instead of an unbounded OPTIONAL MATCH, or " + "add LIMIT"); + return CBM_NOT_FOUND; + } + /* Check ceiling */ if (rb.row_count >= CYPHER_RESULT_CEILING) { rb_free(&rb); diff --git a/src/cypher/cypher.h b/src/cypher/cypher.h index 67b976e56..55d187251 100644 --- a/src/cypher/cypher.h +++ b/src/cypher/cypher.h @@ -181,6 +181,13 @@ typedef struct { int rel_count; } cbm_pattern_t; +/* One argument to a multi-argument scalar function (coalesce, substring, ...). */ +typedef struct { + const char *variable; /* variable reference (NULL if a literal) */ + const char *property; /* property of the variable (NULL if whole var / literal) */ + const char *literal; /* literal string/number text (NULL if a variable ref) */ +} cbm_func_arg_t; + /* WHERE condition */ typedef struct { const char *variable; @@ -198,6 +205,11 @@ typedef struct { * anchor, `value` the edge type (NULL = any), `exists_dir` the direction * (0 = outbound, 1 = inbound, 2 = any). */ int exists_dir; + /* Multi-arg scalar function on the LHS, e.g. coalesce(f.depth, 0) >= 2 + * (#874). NULL func = plain variable/property LHS. */ + const char *func; + cbm_func_arg_t *args; + int arg_count; } cbm_condition_t; /* Expression tree for WHERE clause */ @@ -237,13 +249,6 @@ typedef struct { const char *else_val; /* NULL if no ELSE */ } cbm_case_expr_t; -/* One argument to a multi-argument scalar function (coalesce, substring, ...). */ -typedef struct { - const char *variable; /* variable reference (NULL if a literal) */ - const char *property; /* property of the variable (NULL if whole var / literal) */ - const char *literal; /* literal string/number text (NULL if a variable ref) */ -} cbm_func_arg_t; - /* RETURN item */ typedef struct { const char *variable; @@ -332,4 +337,9 @@ int cbm_cypher_parse(const char *query, cbm_query_t **out, char **error); /* Free a query AST. */ void cbm_query_free(cbm_query_t *q); +/* Test-only (#601): force the wall-clock execution budget in milliseconds for + * subsequent queries on the calling thread. 0 = trip on the first hot-loop + * check; a negative value restores the default budget. */ +void cbm_cypher_test_set_deadline_ms(int64_t budget_ms); + #endif /* CBM_CYPHER_H */ diff --git a/src/foundation/system_info.c b/src/foundation/system_info.c index 21e0a9cb3..8ba237f6c 100644 --- a/src/foundation/system_info.c +++ b/src/foundation/system_info.c @@ -95,7 +95,7 @@ static cbm_system_info_t detect_system_bsd(void) { info.total_cores = nprocs > 0 ? (int)nprocs : 1; info.perf_cores = info.total_cores; -#if defined(__OpenBSD__) +#if defined(__OpenBSD__) || defined(__FreeBSD__) int mib[2] = {CTL_HW, HW_PHYSMEM}; #else int mib[2] = {CTL_HW, HW_PHYSMEM64}; diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c index 869eeb35f..b413727d5 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -117,7 +117,19 @@ char *cbm_pipeline_fqn_compute(const char *project, const char *rel_path, const char *path = strdup(rel_path ? rel_path : ""); cbm_normalize_path_sep(path); - strip_file_extension(path); + /* #1077/#964: File-node QNs (name=="__file__") must preserve the full + * filename so sibling files sharing a stem get DISTINCT nodes — e.g. + * .env / .env.local / .env.production (which all strip to ".env" and + * collide, so only one survives per directory), and a C/C++ header vs + * its same-stem .cpp. Extension stripping stays for MODULE/symbol QNs + * (name==NULL or a symbol): that stem unification is load-bearing for + * C/C++ declaration↔definition cross-file resolution. All 26 __file__ + * QN sites route through here, so creation and every lookup stay + * consistent. */ + bool is_file_qn = name && strcmp(name, "__file__") == 0; + if (!is_file_qn) { + strip_file_extension(path); + } const char *segments[CBM_SZ_256]; int seg_count = 0; diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index e5e62634c..9f363922a 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -615,6 +615,47 @@ static bool is_infra_file(const char *fp) { strstr(fp, ".tf") != NULL || strstr(fp, ".hcl") != NULL || strstr(fp, ".toml") != NULL); } +/* CI/tooling configs describe the development TOOLCHAIN — their URLs are + * repository/action/registry references, never endpoints this service + * exposes. Minting infra Route nodes from them lets the route matcher's + * root-service heuristic attach every handler of an ambiguous "/" route to + * each tooling URL (junk HANDLES churn on plain pallets/flask, #999). + * Deny by file identity, not URL shape: deployment configs (Cloud + * Scheduler, compose) keep minting their genuine endpoints. */ +static bool is_ci_tooling_config(const char *fp) { + if (!fp) { + return false; + } + if (strstr(fp, ".github/") != NULL || strstr(fp, ".gitlab/") != NULL || + strstr(fp, ".circleci/") != NULL) { + return true; + } + const char *slash = strrchr(fp, '/'); + const char *base = slash ? slash + 1 : fp; + static const char *const tooling[] = {".pre-commit-config.yaml", + ".pre-commit-hooks.yaml", + ".gitlab-ci.yml", + ".travis.yml", + "azure-pipelines.yml", + "appveyor.yml", + "bitbucket-pipelines.yml", + ".readthedocs.yaml", + ".readthedocs.yml", + "codecov.yml", + ".codecov.yml", + ".goreleaser.yaml", + ".goreleaser.yml", + ".golangci.yml", + ".golangci.yaml", + NULL}; + for (int i = 0; tooling[i]; i++) { + if (strcmp(base, tooling[i]) == 0) { + return true; + } + } + return false; +} + /* True when a YAML key path denotes an UPSTREAM dependency, CONFIG value, or * HEALTHCHECK target rather than an endpoint this service exposes. Such URLs * (auth JWKS, downstream service base URLs, package-registry URLs, healthcheck @@ -683,7 +724,8 @@ static void cbm_pipeline_extract_infra_routes(cbm_gbuf_t *gbuf, const cbm_file_i CBMHashTable *denied = cbm_ht_create(16); for (int pass = 0; pass < 2; pass++) { for (int i = 0; i < file_count; i++) { - if (!result_cache[i] || !is_infra_file(files[i].rel_path)) { + if (!result_cache[i] || !is_infra_file(files[i].rel_path) || + is_ci_tooling_config(files[i].rel_path)) { continue; } for (int si = 0; si < result_cache[i]->string_refs.count; si++) { diff --git a/tests/repro/repro_issue964.c b/tests/repro/repro_issue964.c index f0b2692a7..f317e6091 100644 --- a/tests/repro/repro_issue964.c +++ b/tests/repro/repro_issue964.c @@ -60,26 +60,36 @@ static int r964_file_node_exists(cbm_store_t *store, const char *project, const return found; } -/* Count inbound IMPORTS edges whose TARGET node name is exactly `name`. */ -static int r964_inbound_imports(cbm_store_t *store, const char *project, const char *name) { - cbm_edge_t *edges = NULL; - int n = 0; - if (cbm_store_find_edges_by_type(store, project, "IMPORTS", &edges, &n) != CBM_STORE_OK) - return -1; - int hits = 0; - for (int i = 0; i < n; i++) { - cbm_node_t tgt; - if (cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) != CBM_STORE_OK) +/* Count INBOUND edges (any of the reporter's connection types) landing on the + * File node named `name` — the exact disconnection metric from the issue's + * query `NOT EXISTS { (f)<-[:IMPORTS|CALLS|CONTAINS_FILE|DEFINES|USAGE]-() }`. + * A header that used to have no node at all (extension-stripped QN collision) + * now has its own File node AND is connected (CONTAINS_FILE from its folder, + * plus DEFINES of the class it declares); `#include` itself resolves to the + * declared Class node via #983, not to the header File. */ +static int r964_file_inbound(cbm_store_t *store, const char *project, const char *name) { + static const char *const types[] = {"IMPORTS", "CALLS", "CONTAINS_FILE", + "DEFINES", "USAGE", NULL}; + int total = 0; + for (int t = 0; types[t]; t++) { + cbm_edge_t *edges = NULL; + int n = 0; + if (cbm_store_find_edges_by_type(store, project, types[t], &edges, &n) != CBM_STORE_OK) continue; - if (tgt.name && strcmp(tgt.name, name) == 0) - hits++; - cbm_node_free_fields(&tgt); + for (int i = 0; i < n; i++) { + cbm_node_t tgt; + if (cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) != CBM_STORE_OK) + continue; + if (tgt.name && strcmp(tgt.name, name) == 0) + total++; + cbm_node_free_fields(&tgt); + } + cbm_store_free_edges(edges, n); } - cbm_store_free_edges(edges, n); - return hits; + return total; } -TEST(repro_issue964_header_has_node_and_inbound_import) { +TEST(repro_issue964_header_has_node_and_is_connected) { static const RFile files[] = { {"NodeController.h", "#pragma once\n" "class NodeController {\n" @@ -102,20 +112,20 @@ TEST(repro_issue964_header_has_node_and_inbound_import) { ASSERT_NOT_NULL(store); int header_node = r964_file_node_exists(store, lp.project, "NodeController.h"); - int header_inbound = r964_inbound_imports(store, lp.project, "NodeController.h"); + int header_inbound = r964_file_inbound(store, lp.project, "NodeController.h"); if (!header_node || header_inbound < 1) { fprintf(stderr, - " [964] FAIL header_file_node=%d inbound_imports=%d (header merged into " - "same-stem .cpp by extension-stripped QN collision)\n", + " [964] FAIL header_file_node=%d inbound_edges=%d (header merged into " + "same-stem .cpp by extension-stripped QN collision, or left disconnected)\n", header_node, header_inbound); } - ASSERT_TRUE(header_node); /* header must keep its own File node */ - ASSERT_TRUE(header_inbound >= 1); /* #include must land an edge on it */ + ASSERT_TRUE(header_node); /* header keeps its own File node (not merged) */ + ASSERT_TRUE(header_inbound >= 1); /* and is connected (not the reporter's zero-inbound) */ rh_cleanup(&lp, store); PASS(); } SUITE(repro_issue964) { - RUN_TEST(repro_issue964_header_has_node_and_inbound_import); + RUN_TEST(repro_issue964_header_has_node_and_is_connected); } diff --git a/tests/test_cypher.c b/tests/test_cypher.c index bc627f5c5..fc3a06932 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -836,6 +836,123 @@ TEST(cypher_func_multiarg) { PASS(); } +/* issue #874: coalesce() in WHERE — null-safe numeric filter over an optional + * JSON property. Exact repro shape from the issue: nodes lacking the property + * fall back to the literal default instead of failing to parse. */ +TEST(cypher_issue874_where_coalesce_numeric) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + cbm_node_t n1 = {.project = "test", + .label = "Function", + .name = "DeepLoop", + .qualified_name = "test.DeepLoop", + .file_path = "deep.go", + .properties_json = "{\"transitive_loop_depth\":5}"}; + cbm_node_t n2 = {.project = "test", + .label = "Function", + .name = "NoMetrics", + .qualified_name = "test.NoMetrics", + .file_path = "flat.go"}; + cbm_store_upsert_node(s, &n1); + cbm_store_upsert_node(s, &n2); + + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (f:Function) " + "WHERE coalesce(f.transitive_loop_depth, 0) >= 2 " + "RETURN f.qualified_name LIMIT 10", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); /* only DeepLoop; NoMetrics coalesces to 0 */ + ASSERT_STR_EQ(r.rows[0][0], "test.DeepLoop"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +/* issue #874: coalesce() in WHERE with a string fallback and first-arg-wins. */ +TEST(cypher_issue874_where_coalesce_string) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + + /* Missing property on every node → fallback literal matches all 4 Functions */ + int rc = cbm_cypher_execute( + s, + "MATCH (f:Function) WHERE coalesce(f.missing, \"fallback\") = \"fallback\" " + "RETURN f.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 4); + cbm_cypher_result_free(&r); + + /* Present first arg wins over the fallback */ + cbm_cypher_result_t r2 = {0}; + rc = cbm_cypher_execute( + s, "MATCH (f:Function) WHERE coalesce(f.name, \"zz\") = \"HandleOrder\" RETURN f.name", + "test", 0, &r2); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r2.row_count, 1); + ASSERT_STR_EQ(r2.rows[0][0], "HandleOrder"); + cbm_cypher_result_free(&r2); + + cbm_store_close(s); + PASS(); +} + +/* issue #874: function LHS composes with NOT and AND like any other condition. */ +TEST(cypher_issue874_where_coalesce_not_and) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + + int rc = cbm_cypher_execute( + s, "MATCH (f:Function) WHERE NOT coalesce(f.missing, \"x\") = \"x\" RETURN f.name", "test", + 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 0); /* every node coalesces to "x" — NOT filters all */ + cbm_cypher_result_free(&r); + + cbm_cypher_result_t r2 = {0}; + rc = cbm_cypher_execute(s, + "MATCH (f:Function) WHERE coalesce(f.missing, \"1\") = \"1\" " + "AND f.name CONTAINS \"Order\" RETURN f.name", + "test", 0, &r2); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r2.row_count, 3); /* HandleOrder, ValidateOrder, SubmitOrder */ + cbm_cypher_result_free(&r2); + + cbm_store_close(s); + PASS(); +} + +/* issue #874: the other multi-arg scalar functions work in WHERE too. */ +TEST(cypher_issue874_where_substring) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute( + s, "MATCH (f:Function) WHERE substring(f.name, 0, 6) = \"Handle\" RETURN f.name", "test", 0, + &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "HandleOrder"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +/* issue #874: an unrecognised function in WHERE must fail loudly with the + * supported set, not the misleading "unexpected operator". */ +TEST(cypher_issue874_where_unsupported_func_error) { + cbm_query_t *q = NULL; + char *err = NULL; + int rc = + cbm_cypher_parse("MATCH (f:Function) WHERE foo(f.name) = \"x\" RETURN f.name", &q, &err); + ASSERT_EQ(rc, -1); + ASSERT_NOT_NULL(err); + ASSERT_TRUE(strstr(err, "unsupported function 'foo'") != NULL); + free(err); + PASS(); +} + TEST(cypher_exists_no_callers) { /* NOT EXISTS { (f)<-[:CALLS]-() } → functions with no CALLS caller. * HandleOrder has only an incoming DEFINES edge (not CALLS), so it is the @@ -2794,6 +2911,59 @@ TEST(cypher_multi_prop_projection_no_alias) { * forked child so a stack overwrite (ASan abort, or a raw segfault) shows up * as a killing signal instead of taking down the whole runner; the bounded * path returns an ordinary error and the child exits cleanly. */ +/* Property projection must return the WHOLE value of composite properties. + * json_extract_prop() scanned a non-string value up to the first ',' — so an + * array/object property was truncated at its first INTERNAL comma. Real-world + * hit: a NestJS handler's decorators + * ["@Roles('OWNER', 'ADMIN')","@Get()"] + * projected as ["@Roles('OWNER' — unusable for route/authz queries. */ +TEST(cypher_exec_prop_array_with_internal_commas) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + cbm_node_t n = {.project = "test", + .label = "Method", + .name = "findAll", + .qualified_name = "test.PacienteController.findAll", + .file_path = "paciente.controller.ts", + .properties_json = + "{\"decorators\":[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"],\"lines\":3}"}; + cbm_store_upsert_node(s, &n); + + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, "MATCH (m:Method) RETURN m.decorators, m.lines", "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + /* whole array, commas and all — was ["@Roles('OWNER' before the fix */ + ASSERT_STR_EQ(r.rows[0][0], "[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"]"); + ASSERT_STR_EQ(r.rows[0][1], "3"); /* scalar sibling still parses */ + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +/* A string property must not end at an ESCAPED quote: the scan stopped at the + * first '"' regardless of a preceding backslash, cutting the value short. */ +TEST(cypher_exec_prop_string_with_escaped_quote) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "parse", + .qualified_name = "test.parse", + .file_path = "p.ts", + .properties_json = "{\"signature\":\"(sep: \\\"a,b\\\") => void\"}"}; + cbm_store_upsert_node(s, &n); + + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.signature", "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "(sep: \\\"a,b\\\") => void"); /* was: (sep: \ */ + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + TEST(cypher_wide_return_projection_bounded) { #ifdef _WIN32 SKIP_PLATFORM("fork crash-isolation is POSIX-only; the parse-time bound is platform-agnostic"); @@ -2827,6 +2997,54 @@ TEST(cypher_wide_return_projection_bounded) { #endif } +/* #601: an unbounded whole-graph OPTIONAL MATCH / GROUP BY does + * O(bindings x groups) work and can run for minutes with no wall-clock guard — + * the 100k row ceiling never fires because no rows are produced, so query_graph + * just hangs. With the execution deadline armed to trip immediately (budget 0), + * the runaway query must abort with a clear error instead of returning a + * (misleading, possibly partial) result. + * + * RED on unfixed code: no deadline exists, so the query completes and returns + * rc==0 with rows and no error — the assertions below fail. */ +TEST(cypher_exec_deadline_aborts_runaway_query_issue601) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + + cbm_cypher_test_set_deadline_ms(0); /* trip on the first hot-loop check */ + int rc = cbm_cypher_execute( + s, "MATCH (a) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.qualified_name, count(b)", "test", + 0, &r); + cbm_cypher_test_set_deadline_ms(-1); /* restore default before asserting (thread-local) */ + + ASSERT_TRUE(rc != 0); /* CBM_NOT_FOUND (-1) — query aborted, not success */ + ASSERT_NOT_NULL(r.error); + ASSERT_TRUE(strstr(r.error, "time limit") != NULL); + ASSERT_EQ(r.row_count, 0); + + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +/* #601 companion: the default (ample) budget must NOT false-positive on a + * normal small query — it still returns its rows. */ +TEST(cypher_exec_deadline_allows_normal_query_issue601) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + + int rc = cbm_cypher_execute( + s, "MATCH (a) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.qualified_name, count(b)", "test", + 0, &r); + + ASSERT_EQ(rc, 0); + ASSERT_TRUE(r.error == NULL); + ASSERT_GT(r.row_count, 0); + + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ */ SUITE(cypher) { @@ -2859,6 +3077,8 @@ SUITE(cypher) { RUN_TEST(cypher_parse_inline_props); RUN_TEST(cypher_parse_error); /* Execution */ + RUN_TEST(cypher_exec_deadline_aborts_runaway_query_issue601); + RUN_TEST(cypher_exec_deadline_allows_normal_query_issue601); RUN_TEST(cypher_exec_match_all_functions); RUN_TEST(cypher_issue240_labels_function); RUN_TEST(cypher_issue237_distinct_order_limit); @@ -2882,6 +3102,11 @@ SUITE(cypher) { RUN_TEST(cypher_func_tointeger_tofloat); RUN_TEST(cypher_func_size_reverse); RUN_TEST(cypher_func_multiarg); + RUN_TEST(cypher_issue874_where_coalesce_numeric); + RUN_TEST(cypher_issue874_where_coalesce_string); + RUN_TEST(cypher_issue874_where_coalesce_not_and); + RUN_TEST(cypher_issue874_where_substring); + RUN_TEST(cypher_issue874_where_unsupported_func_error); RUN_TEST(cypher_multi_prop_projection_no_alias); RUN_TEST(cypher_exists_no_callers); RUN_TEST(cypher_exists_has_outgoing_calls); @@ -2994,4 +3219,7 @@ SUITE(cypher) { RUN_TEST(cypher_parse_unwind); RUN_TEST(cypher_parse_unwind_var); RUN_TEST(cypher_wide_return_projection_bounded); + /* Composite property projection (arrays/objects, escaped quotes) */ + RUN_TEST(cypher_exec_prop_array_with_internal_commas); + RUN_TEST(cypher_exec_prop_string_with_escaped_quote); } diff --git a/tests/test_fqn.c b/tests/test_fqn.c index e7583d012..d2163424f 100644 --- a/tests/test_fqn.c +++ b/tests/test_fqn.c @@ -52,6 +52,38 @@ TEST(fqn_compute_basic_c) { PASS(); } +/* #1077/#964: File-node QNs (name=="__file__") must preserve the FULL filename + * so sibling files sharing a stem get DISTINCT nodes. Extension stripping is + * kept for module/symbol QNs (name!=__file__) — load-bearing for C/C++ + * declaration↔definition resolution. */ +TEST(fqn_file_qn_preserves_dotfile_variants_issue1077) { + /* .env / .env.local / .env.production all stripped to ".env" before, + * colliding so only one File node survived per directory. */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", ".env", "__file__"), "proj..env.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", ".env.local", "__file__"), + "proj..env.local.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", ".env.production", "__file__"), + "proj..env.production.__file__"); + PASS(); +} + +TEST(fqn_file_qn_distinguishes_same_stem_header_source_issue964) { + /* NodeController.h and NodeController.cpp both stripped to + * "NodeController", so the header's File node was merged into the .cpp's. */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "NodeController.h", "__file__"), + "proj.NodeController.h.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "NodeController.cpp", "__file__"), + "proj.NodeController.cpp.__file__"); + PASS(); +} + +TEST(fqn_module_qn_still_strips_extension) { + /* The MODULE/symbol QN keeps stripping — unchanged by the File-QN fix. */ + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "core.c", "init"), "proj.core.init"); + ASSERT_FQN(cbm_pipeline_fqn_module("proj", "NodeController.cpp"), "proj.NodeController"); + PASS(); +} + TEST(fqn_compute_basic_rs) { ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "lib.rs", "new"), "proj.lib.new"); PASS(); @@ -559,6 +591,9 @@ SUITE(fqn) { RUN_TEST(fqn_compute_basic_ts); RUN_TEST(fqn_compute_basic_js); RUN_TEST(fqn_compute_basic_c); + RUN_TEST(fqn_file_qn_preserves_dotfile_variants_issue1077); + RUN_TEST(fqn_file_qn_distinguishes_same_stem_header_source_issue964); + RUN_TEST(fqn_module_qn_still_strips_extension); RUN_TEST(fqn_compute_basic_rs); /* fqn_compute: nested paths */ diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index 087ac0040..3ce639ded 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -1242,6 +1242,100 @@ static int calls_edge_between(cbm_store_t *store, const char *project, const cha return found; } +/* #999: URLs in CI/tooling configs (.pre-commit-config.yaml, .github/ + * workflows) are repository references for TOOLING, not endpoints this + * service exposes. They must not mint __route__infra__ Route nodes — the + * route matcher's root-service heuristic otherwise attaches every handler + * of an ambiguous "/" route to each junk URL (HANDLES churn on plain + * pallets/flask). */ +static int count_infra_routes_matching(cbm_store_t *store, const char *project, + const char *substr) { + cbm_node_t *nodes = NULL; + int count = 0; + if (cbm_store_find_nodes_by_label(store, project, "Route", &nodes, &count) != CBM_STORE_OK) + return -1; + int hits = 0; + for (int i = 0; i < count; i++) { + const char *qn = nodes[i].qualified_name; + if (qn && strncmp(qn, "__route__infra__", (sizeof("__route__infra__") - 1)) == 0 && + strstr(qn, substr)) + hits++; + } + cbm_store_free_nodes(nodes, count); + return hits; +} + +TEST(contract_edge_no_infra_routes_from_ci_configs_issue999) { + LangProj lp; + static const LangFile f[] = { + {"app.py", "from flask import Flask\n" + "app = Flask(__name__)\n" + "\n" + "@app.route(\"/\")\n" + "def index():\n" + " return \"hi\"\n" + "\n" + "@app.route(\"/auth/login\")\n" + "def login():\n" + " return \"login\"\n"}, + {".pre-commit-config.yaml", "repos:\n" + " - repo: https://github.com/astral-sh/ruff-pre-commit\n" + " rev: v0.1.0\n" + " hooks:\n" + " - id: ruff\n" + " - repo: https://github.com/pre-commit/pre-commit-hooks\n" + " rev: v4.5.0\n" + " hooks:\n" + " - id: trailing-whitespace\n"}, + {".github/workflows/ci.yml", "name: CI\n" + "on: [push]\n" + "jobs:\n" + " build:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + " - run: curl https://coverage.example.io/upload\n"}}; + cbm_store_t *store = lang_index_files(&lp, f, 3); + ASSERT_NOT_NULL(store); + /* No tooling URL may materialize as an infra Route node. */ + int gh_routes = count_infra_routes_matching(store, lp.project, "github.com"); + int wf_routes = count_infra_routes_matching(store, lp.project, "coverage.example.io"); + if (gh_routes != 0 || wf_routes != 0) { + fprintf(stderr, + " [999] FAIL infra routes from CI configs: github.com=%d workflows=%d " + "(expected 0/0)\n", + gh_routes, wf_routes); + } + ASSERT_EQ(gh_routes, 0); + ASSERT_EQ(wf_routes, 0); + lang_cleanup(&lp, store); + PASS(); +} + +/* #999 inverse guard: genuine infra endpoint URLs (Cloud Scheduler push + * targets and similar deployment configs) must STILL mint infra Route + * nodes — the CI/tooling deny must be file-scoped, not a blanket URL kill. */ +TEST(contract_edge_infra_routes_from_deploy_configs_still_minted) { + LangProj lp; + static const LangFile f[] = { + {"scheduler.yaml", + "jobs:\n" + " - name: nightly-sync\n" + " schedule: \"0 3 * * *\"\n" + " push_endpoint: https://sync.internal.example/api/v1/sync\n"}}; + cbm_store_t *store = lang_index_files(&lp, f, 1); + ASSERT_NOT_NULL(store); + int routes = count_infra_routes_matching(store, lp.project, "sync.internal.example"); + if (routes < 1) { + fprintf(stderr, " [999] FAIL deploy-config endpoint minted %d infra routes " + "(expected >=1)\n", + routes); + } + ASSERT_TRUE(routes >= 1); + lang_cleanup(&lp, store); + PASS(); +} + /* True if some CALLS edge's TARGET node carries `label` and a QN ending with * `qn_suffix` — i.e. the call resolved to that specific definition. */ static int calls_edge_targets(cbm_store_t *store, const char *project, const char *label, @@ -1563,6 +1657,8 @@ SUITE(lang_contract) { RUN_TEST(contract_edge_imports_alias_no_phantom_folder_edge_issue767); RUN_TEST(contract_edge_imports_alias_resolves_real_file_issue767); RUN_TEST(contract_edge_python_aliased_import_call_resolves_issue988); + RUN_TEST(contract_edge_no_infra_routes_from_ci_configs_issue999); + RUN_TEST(contract_edge_infra_routes_from_deploy_configs_still_minted); RUN_TEST(contract_edge_commonjs_require_call_resolves_issue871); RUN_TEST(contract_edge_depends_on); RUN_TEST(contract_edge_parallel_service_edges); diff --git a/tests/test_platform.c b/tests/test_platform.c index 7ae9237c3..127edd8ab 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -132,6 +132,13 @@ TEST(platform_default_workers_env_unset) { PASS(); } +TEST(platform_system_info) { + cbm_system_info_t info = cbm_system_info(); + ASSERT_GT(info.total_cores, 0); + ASSERT_GT(info.total_ram, 0); + PASS(); +} + /* ── cgroup-aware detection (Linux only) ─────────────────────────── */ #ifdef __linux__ @@ -316,6 +323,7 @@ SUITE(platform) { RUN_TEST(platform_default_workers_env_override); RUN_TEST(platform_default_workers_env_invalid); RUN_TEST(platform_default_workers_env_unset); + RUN_TEST(platform_system_info); #ifdef __linux__ RUN_TEST(cgroup_v2_cpu_quota); RUN_TEST(cgroup_v2_cpu_quota_rounds_up);