Skip to content

Commit 6392ffb

Browse files
authored
Merge branch 'main' into codex/fix-json-edge-properties
2 parents b11a230 + 7d6cdb2 commit 6392ffb

20 files changed

Lines changed: 3639 additions & 732 deletions

Makefile.cbm

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o
513513

514514
# ── Targets ──────────────────────────────────────────────────────
515515

516-
.PHONY: test test-repro test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security
516+
.PHONY: test test-par test-repro test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security
517517

518518
$(BUILD_DIR):
519519
mkdir -p $(BUILD_DIR)
@@ -639,6 +639,12 @@ $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_
639639
test: $(BUILD_DIR)/test-runner
640640
cd $(CURDIR) && $(BUILD_DIR)/test-runner
641641

642+
# Parallel variant: every suite as its own process; identical gate quality
643+
# (see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh — union guard
644+
# against --list-suites + aggregated pass/fail/skip totals).
645+
test-par: $(BUILD_DIR)/test-runner
646+
cd $(CURDIR) && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner
647+
642648
# Focused native development is intentionally a separate, explicit target so
643649
# an inherited TEST_SUITES value cannot silently narrow the gating test target.
644650
TEST_SUITES ?=

scripts/run-tests-parallel.sh

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env bash
2+
# run-tests-parallel.sh — run every registered test suite as parallel
3+
# processes of the already-built test-runner.
4+
#
5+
# ZERO-LOSS CONTRACT (gate quality must be identical to the sequential run):
6+
# 1. The suite list comes from `test-runner --list-suites`, which is printed
7+
# by the SAME macro table that executes suites — the list cannot drift
8+
# from reality by construction.
9+
# 2. UNION GUARD: after the run, the set of suites that actually produced a
10+
# result is compared against that list; any difference (a suite that
11+
# never ran, or ran twice) fails the gate loudly. A newly added suite is
12+
# picked up automatically on the next invocation.
13+
# 3. Per-suite pass/fail/skip counts are summed and reported in the same
14+
# "N passed[, M failed][, K skipped]" shape as the sequential runner, so
15+
# before/after totals are directly comparable.
16+
# 4. ANY suite failing, crashing (nonzero exit), or missing ⇒ exit 1.
17+
#
18+
# Usage: run-tests-parallel.sh <path-to-test-runner> [jobs]
19+
# jobs defaults to CBM_TEST_PAR_JOBS, then the CPU count.
20+
21+
set -uo pipefail
22+
23+
RUNNER="${1:?usage: run-tests-parallel.sh <path-to-test-runner> [jobs]}"
24+
JOBS="${2:-${CBM_TEST_PAR_JOBS:-}}"
25+
26+
if [ -z "$JOBS" ]; then
27+
if command -v nproc >/dev/null 2>&1; then
28+
JOBS=$(nproc)
29+
elif command -v sysctl >/dev/null 2>&1; then
30+
JOBS=$(sysctl -n hw.ncpu 2>/dev/null || echo 4)
31+
else
32+
JOBS=4
33+
fi
34+
fi
35+
36+
LOGDIR="$(dirname "$RUNNER")/test-logs"
37+
rm -rf "$LOGDIR"
38+
mkdir -p "$LOGDIR"
39+
40+
SUITES_FILE="$LOGDIR/suites.txt"
41+
RESULTS_FILE="$LOGDIR/results.txt"
42+
43+
# tr strips the CR that the Windows CRT appends to every stdout line — a
44+
# suites file with CRLF endings made the runner reject every name
45+
# ("arena\r" is an unknown suite) and fail all 104 suites on CI.
46+
if ! "$RUNNER" --list-suites | tr -d '\r' > "$SUITES_FILE"; then
47+
echo "FAIL: test-runner --list-suites exited nonzero" >&2
48+
exit 1
49+
fi
50+
NSUITES=$(wc -l < "$SUITES_FILE" | tr -d ' ')
51+
if [ "$NSUITES" -lt 1 ] || grep -qvE '^[a-z0-9_]+$' "$SUITES_FILE"; then
52+
echo "FAIL: suite list empty or malformed (runner too old for --list-suites?)" >&2
53+
exit 1
54+
fi
55+
# Timing-sensitive suites run SEQUENTIALLY after the parallel wave: they
56+
# spawn subprocesses / watch the filesystem / bind ports with fixed
57+
# deadlines, and a saturated 4-core CI runner starves those deadlines into
58+
# flakes (3 cli-suite failures on the ubuntu legs of the first CI run).
59+
# Same suites, same tests, same gates — only the schedule differs; the
60+
# union guard below still checks the COMBINED result set.
61+
# stack_overflow_a/b/c: their giant-recursion ASan allocations stall ~100x
62+
# when co-STARTED with a large wave on Apple Silicon (2s staggered vs ~230s
63+
# simultaneous — a local scheduler/zone quirk, not contention: job count
64+
# does not change it). Staggered in the tail they cost seconds.
65+
SERIAL_SUITES="cli subprocess watcher incremental httpd ui index_resilience mcp \
66+
stack_overflow_a stack_overflow_b stack_overflow_c"
67+
is_serial() {
68+
case " $SERIAL_SUITES " in *" $1 "*) return 0 ;; *) return 1 ;; esac
69+
}
70+
PAR_FILE="$LOGDIR/suites-parallel.txt"
71+
SER_FILE="$LOGDIR/suites-serial.txt"
72+
: > "$PAR_FILE"
73+
: > "$SER_FILE"
74+
while IFS= read -r sname; do
75+
if is_serial "$sname"; then
76+
echo "$sname" >> "$SER_FILE"
77+
else
78+
echo "$sname" >> "$PAR_FILE"
79+
fi
80+
done < "$SUITES_FILE"
81+
echo "=== parallel test run: $NSUITES suites ($(wc -l < "$SER_FILE" | tr -d ' ') serial-tail), $JOBS jobs ==="
82+
83+
export RUNNER LOGDIR RESULTS_FILE
84+
run_one() {
85+
s="$1"
86+
t0=$SECONDS
87+
"$RUNNER" "$s" > "$LOGDIR/$s.log" 2>&1
88+
rc=$?
89+
secs=$((SECONDS - t0))
90+
summary=$(grep -E '^ [0-9]+ passed' "$LOGDIR/$s.log" | tail -1)
91+
pass=$(printf '%s' "$summary" | sed -n 's/^ \([0-9]*\) passed.*/\1/p')
92+
failn=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) failed.*/\1/p')
93+
skip=$(printf '%s' "$summary" | sed -n 's/.* \([0-9]*\) skipped.*/\1/p')
94+
# A single short echo line is an atomic append (< PIPE_BUF).
95+
echo "$s rc=$rc pass=${pass:-0} fail=${failn:-0} skip=${skip:-0} secs=$secs" >> "$RESULTS_FILE"
96+
}
97+
export -f run_one
98+
99+
xargs -P "$JOBS" -I{} bash -c 'run_one "$@"' _ {} < "$PAR_FILE"
100+
# Serial tail: quiet machine for the deadline-sensitive suites.
101+
while IFS= read -r sname; do
102+
run_one "$sname"
103+
done < "$SER_FILE"
104+
105+
# ── Union guard: every listed suite produced exactly one result ──
106+
MISSING=$(comm -23 <(sort "$SUITES_FILE") <(awk '{print $1}' "$RESULTS_FILE" | sort -u))
107+
DUPES=$(awk '{print $1}' "$RESULTS_FILE" | sort | uniq -d)
108+
if [ -n "$MISSING" ] || [ -n "$DUPES" ]; then
109+
echo "FAIL: shard union does not match --list-suites (GATE-QUALITY LOSS)" >&2
110+
[ -n "$MISSING" ] && echo " never ran: $MISSING" >&2
111+
[ -n "$DUPES" ] && echo " ran twice: $DUPES" >&2
112+
exit 1
113+
fi
114+
115+
TOTAL_PASS=$(awk -F'pass=' '{split($2,a," "); s+=a[1]} END{print s+0}' "$RESULTS_FILE")
116+
TOTAL_FAIL=$(awk -F'fail=' '{split($2,a," "); s+=a[1]} END{print s+0}' "$RESULTS_FILE")
117+
TOTAL_SKIP=$(awk -F'skip=' '{split($2,a," "); s+=a[1]} END{print s+0}' "$RESULTS_FILE")
118+
BAD_RC=$(grep -cv ' rc=0 ' "$RESULTS_FILE" || true)
119+
120+
echo "── 8 slowest suites ──"
121+
sort -t= -k6 -rn "$RESULTS_FILE" | head -8
122+
grep -v ' rc=0 ' "$RESULTS_FILE" || true
123+
for f in $(grep -v ' rc=0 ' "$RESULTS_FILE" | awk '{print $1}'); do
124+
echo "──── $f: every failure site ────"
125+
grep -B2 -A8 "FAIL" "$LOGDIR/$f.log" | head -120
126+
echo "──── $f: last 15 lines ────"
127+
tail -15 "$LOGDIR/$f.log"
128+
done
129+
130+
echo "────────────────────────────────────────────"
131+
echo " $TOTAL_PASS passed, $TOTAL_FAIL failed, $TOTAL_SKIP skipped ($NSUITES suites, $JOBS jobs)"
132+
echo "────────────────────────────────────────────"
133+
134+
if [ "$TOTAL_FAIL" -gt 0 ] || [ "$BAD_RC" -gt 0 ]; then
135+
exit 1
136+
fi
137+
exit 0

scripts/smoke-test.sh

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,9 @@ echo "OK: search_graph found $TOTAL result(s) for 'compute'"
195195
if ! TRACE=$(cli trace_path --project "$PROJECT" --function-name compute --direction inbound --depth 1); then
196196
echo "FAIL: trace_path (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
197197
fi
198-
# trace_path default output is TOON: the callers[N]{...} header carries the count
199-
CALLERS=$(echo "$TRACE" | sed -n 's/^callers\[\([0-9]*\)\].*/\1/p' | head -1)
198+
# trace_path default output is the tree format: callers_total carries the
199+
# exact reachable count (test files excluded by default)
200+
CALLERS=$(echo "$TRACE" | sed -n 's/^callers_total: \([0-9]*\).*/\1/p' | head -1)
200201
CALLERS=${CALLERS:-0}
201202
if [ "$CALLERS" -lt 1 ]; then
202203
echo "FAIL: trace_path found 0 callers for 'compute'"
@@ -278,7 +279,7 @@ cyp_first_cell() {
278279
# argv token, so string-literal args (e.g. replace(f.name,"a","A")) and Cypher
279280
# metacharacters {}|=~<>" need no JSON escaping.
280281
cli query_graph --project "$PROJECT" --query "$1" |
281-
sed -n '/^rows\[/{n;p;}' | sed 's/^ //' | sed 's/^"//;s/"$//;s/\\"/"/g'
282+
sed -n '/^rows: /{n;p;}' | sed 's/^ //' | sed 's/^"//;s/"$//;s/\\"/"/g'
282283
}
283284

284285
# labels(n) → JSON list like ["Function"]
@@ -366,7 +367,7 @@ LEFTV=$(cyp_first_cell 'MATCH (f:Function) RETURN left(f.name, 3) AS l LIMIT 1')
366367

367368
# NOT EXISTS dead-code query (functions with no caller)
368369
CYPHER_NX=$(cli query_graph --project "$PROJECT" --query "MATCH (f:Function) WHERE NOT EXISTS { (f)<-[:CALLS]-() } RETURN f.name")
369-
NX_OK=$(echo "$CYPHER_NX" | grep -qE '^rows\[[0-9]+\]\{' && echo "True" || echo "False")
370+
NX_OK=$(echo "$CYPHER_NX" | grep -qE '^rows: [0-9]+' && echo "True" || echo "False")
370371
[ "$NX_OK" = "True" ] && echo "OK: query_graph NOT EXISTS dead-code query executed" || { echo "FAIL: NOT EXISTS query"; echo "$CYPHER_NX" | head -c 300; exit 1; }
371372

372373
# CASE expression in RETURN
@@ -390,7 +391,7 @@ if ! ARCH=$(cli get_architecture --project "$PROJECT" --aspects clusters); then
390391
echo "FAIL: get_architecture (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
391392
fi
392393
# get_architecture default output is TOON: clusters[N]{...} header carries the count
393-
NCLUST=$(echo "$ARCH" | sed -n 's/^clusters\[\([0-9]*\)\].*/\1/p' | head -1)
394+
NCLUST=$(echo "$ARCH" | sed -n 's/^clusters: \([0-9]*\).*/\1/p' | head -1)
394395
NCLUST=${NCLUST:-0}
395396
if [ "$NCLUST" -lt 1 ]; then
396397
echo "FAIL: get_architecture returned 0 community clusters"; echo "$ARCH" | head -c 400; exit 1
@@ -431,7 +432,7 @@ echo "=== Phase 3h: CLI input-mode guards (flags / stdin / --args-file / --help
431432
assert_json_obj() { python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if isinstance(d,dict) else 1)" 2>/dev/null; }
432433
# search_graph emits TOON by default: a results/semantic table header proves
433434
# the tool parsed its typed flags and produced a well-formed response.
434-
assert_toon_table() { grep -qE '^(results|semantic)\[[0-9]+\]\{'; }
435+
assert_toon_table() { grep -qE '^(results|semantic): [0-9]+'; }
435436

436437
# B1: INTEGER flag — --limit is schema-typed integer; must parse and answer.
437438
if ! IM_INT=$(cli search_graph --project "$PROJECT" --name-pattern compute --limit 5); then
@@ -458,7 +459,7 @@ fi
458459
if ! IM_ARR=$(cli search_graph --project "$PROJECT" --semantic-query send --semantic-query publish); then
459460
echo "FAIL B3: search_graph repeated --semantic-query exited non-zero"; cat "$CLI_STDERR"; exit 1
460461
fi
461-
if echo "$IM_ARR" | grep -qE '^semantic\[[0-9]+\]\{'; then
462+
if echo "$IM_ARR" | grep -qE '^semantic: [0-9]+'; then
462463
echo "OK B3: ARRAY flag (repeated --semantic-query) → semantic TOON table"
463464
else
464465
echo "FAIL B3: repeated --semantic-query did not produce a semantic table"; echo "$IM_ARR" | head -c 300; exit 1

scripts/test.sh

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,17 @@ verify_compiler "$CC"
5757
# Step 1: Clean
5858
scripts/clean.sh
5959

60-
# Step 2 + 3: Build and run tests (Makefile applies $ARCHFLAGS on macOS)
61-
make -j"$NPROC" -f Makefile.cbm test $MAKE_ARGS
60+
# Step 2 + 3: Build, then run every suite as parallel processes (identical
61+
# gate quality — see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh:
62+
# the suite set is enumerated from the runner itself and union-guarded, and
63+
# pass/fail/skip totals aggregate to the same numbers as the sequential run).
64+
# CBM_TEST_SEQUENTIAL=1 restores the single-process runner.
65+
make -j"$NPROC" -f Makefile.cbm build/c/test-runner $MAKE_ARGS
66+
if [ "${CBM_TEST_SEQUENTIAL:-0}" = "1" ]; then
67+
make -f Makefile.cbm test $MAKE_ARGS
68+
else
69+
make -f Makefile.cbm test-par $MAKE_ARGS
70+
fi
6271

6372
# Step 4: C++ large-TU index-hang regression guard (#410). Runs the PROD binary
6473
# in a subprocess with a wall-clock timeout — a hang must fail, not block the run.

src/cli/cli.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ int cbm_resolve_claude_hook_command_for_testing(const char *script_name, bool wi
190190
char *command, size_t command_size);
191191
bool cbm_optional_hook_supported_for_testing(const char *agent_name, bool windows);
192192
void cbm_hook_sanitize_metadata_for_testing(const char *input, char *output, size_t output_size);
193+
char *cbm_hook_augment_format_context_for_testing(const char *envelope, const char *token,
194+
bool *is_error);
193195
int cbm_upsert_qwen_lifecycle_hooks_for_testing(const char *settings_path, const char *binary_path,
194196
bool windows);
195197
int cbm_upsert_qoder_context_hooks_for_testing(const char *settings_path, const char *binary_path);

src/cli/hook_augment.c

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,23 @@ static char *ha_format_context(const char *envelope, const char *token, bool *is
368368
yyjson_doc_free(edoc);
369369
return NULL;
370370
}
371+
/* json-tree shape: {total, cols, groups:[{qn_prefix, file,
372+
* rows:[[name,label,lines,in,out],...]}]}. The full qualified name is
373+
* qn_prefix + "." + row[0]; label is row[1]; file lives on the group. */
371374
yyjson_val *iroot = yyjson_doc_get_root(idoc);
372-
yyjson_val *results = yyjson_obj_get(iroot, "results");
373-
size_t nres = (results && yyjson_is_arr(results)) ? yyjson_arr_size(results) : 0;
375+
yyjson_val *groups = yyjson_obj_get(iroot, "groups");
376+
size_t nres = 0;
377+
size_t gidx;
378+
size_t gmax;
379+
yyjson_val *g;
380+
if (groups && yyjson_is_arr(groups)) {
381+
yyjson_arr_foreach(groups, gidx, gmax, g) {
382+
yyjson_val *rows = yyjson_obj_get(g, "rows");
383+
if (rows && yyjson_is_arr(rows)) {
384+
nres += yyjson_arr_size(rows);
385+
}
386+
}
387+
}
374388
if (nres == 0) {
375389
yyjson_doc_free(idoc);
376390
yyjson_doc_free(edoc);
@@ -389,26 +403,37 @@ static char *ha_format_context(const char *envelope, const char *token, bool *is
389403
"(structured context; your search results below are "
390404
"unaffected):",
391405
nres, token);
392-
size_t idx;
393-
size_t maxn;
394-
yyjson_val *r;
395-
yyjson_arr_foreach(results, idx, maxn, r) {
396-
if (off < 0 || off >= 3900) {
397-
break;
406+
yyjson_arr_foreach(groups, gidx, gmax, g) {
407+
const char *prefix = ha_obj_str(g, "qn_prefix");
408+
const char *fp = ha_obj_str(g, "file");
409+
yyjson_val *rows = yyjson_obj_get(g, "rows");
410+
if (!rows || !yyjson_is_arr(rows)) {
411+
continue;
412+
}
413+
size_t ridx;
414+
size_t rmax;
415+
yyjson_val *row;
416+
yyjson_arr_foreach(rows, ridx, rmax, row) {
417+
if (off < 0 || off >= 3900) {
418+
break;
419+
}
420+
const char *nm = yyjson_get_str(yyjson_arr_get(row, 0));
421+
const char *lb = yyjson_get_str(yyjson_arr_get(row, 1));
422+
char disp[HA_METADATA_CAP];
423+
if (prefix && prefix[0] && nm && nm[0]) {
424+
snprintf(disp, sizeof(disp), "%s.%s", prefix, nm);
425+
} else {
426+
snprintf(disp, sizeof(disp), "%s", nm ? nm : "");
427+
}
428+
char safe_disp[HA_METADATA_CAP];
429+
char safe_path[HA_METADATA_CAP];
430+
char safe_label[HA_METADATA_CAP];
431+
ha_sanitize_metadata(disp, safe_disp, sizeof(safe_disp));
432+
ha_sanitize_metadata(fp, safe_path, sizeof(safe_path));
433+
ha_sanitize_metadata(lb, safe_label, sizeof(safe_label));
434+
off += snprintf(text + off, (size_t)(4096 - off), "\n- %s %s%s%s", safe_disp,
435+
safe_path, safe_label[0] ? " " : "", safe_label);
398436
}
399-
const char *qn = ha_obj_str(r, "qualified_name");
400-
const char *nm = ha_obj_str(r, "name");
401-
const char *fp = ha_obj_str(r, "file_path");
402-
const char *lb = ha_obj_str(r, "label");
403-
const char *disp = (qn && qn[0]) ? qn : (nm ? nm : "");
404-
char safe_disp[HA_METADATA_CAP];
405-
char safe_path[HA_METADATA_CAP];
406-
char safe_label[HA_METADATA_CAP];
407-
ha_sanitize_metadata(disp, safe_disp, sizeof(safe_disp));
408-
ha_sanitize_metadata(fp, safe_path, sizeof(safe_path));
409-
ha_sanitize_metadata(lb, safe_label, sizeof(safe_label));
410-
off += snprintf(text + off, (size_t)(4096 - off), "\n- %s %s%s%s", safe_disp, safe_path,
411-
safe_label[0] ? " " : "", safe_label);
412437
}
413438

414439
yyjson_doc_free(idoc);
@@ -1222,6 +1247,15 @@ char *cbm_hook_augment_lifecycle_json_for_dialect(const char *input, const char
12221247
return json;
12231248
}
12241249

1250+
/* Test seam: run the real envelope->additionalContext formatter. Couples the
1251+
* test suite to the ACTUAL search_graph response shape — a format change that
1252+
* breaks this parser breaks a test locally, not just the Windows CI guard. */
1253+
char *cbm_hook_augment_format_context_for_testing(const char *envelope, const char *token,
1254+
bool *is_error) {
1255+
bool dummy = false;
1256+
return ha_format_context(envelope, token, is_error ? is_error : &dummy);
1257+
}
1258+
12251259
char *cbm_hook_augment_tool_json_for_testing(const char *input, const char *dialect_name,
12261260
const char *context, char *path, size_t path_size) {
12271261
if (!input || !context || !path || path_size == 0U || strlen(input) > HA_STDIN_CAP) {

src/cypher/cypher.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2163,8 +2163,13 @@ static const char *node_string_field(const cbm_node_t *n, const char *prop) {
21632163
} fields[] = {
21642164
{"name", offsetof(cbm_node_t, name)},
21652165
{"qualified_name", offsetof(cbm_node_t, qualified_name)},
2166+
/* Aliases: field-eval agents reach for the short names, and a miss
2167+
* used to return a silent empty column costing a round-trip. */
2168+
{"qn", offsetof(cbm_node_t, qualified_name)},
21662169
{"label", offsetof(cbm_node_t, label)},
21672170
{"file_path", offsetof(cbm_node_t, file_path)},
2171+
{"file", offsetof(cbm_node_t, file_path)},
2172+
{"path", offsetof(cbm_node_t, file_path)},
21682173
};
21692174
for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) {
21702175
if (strcmp(prop, fields[i].key) == 0) {

0 commit comments

Comments
 (0)