From a030a831df786eb77655b65c3ac74b6bce38dffa Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 26 Sep 2026 21:38:23 +0200 Subject: [PATCH 01/50] build(deps): bump github/codeql-action to 4.38.1 in one step Dependabot opened one PR per sub-action (#2363 init, #2365 analyze, #2364 upload-sarif). CodeQL refuses a split pin: with init at 4.38.1 and analyze at 4.38.0 the analyze post-step fails with "Loaded a configuration file for version '4.38.1', but running version '4.38.0'", so neither PR can pass the codeql gate alone and merging either one would break the gate on main. This bumps all three to 1c5b675653bb5c22dbe9b12b556ec555138e09fd, which is the commit the upstream v4.38.1 annotated tag points to. Signed-off-by: Martin Vogel --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e83a82d06..2430c6712 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,7 +29,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zlib1g-dev - name: Initialize CodeQL - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: c-cpp build-mode: manual @@ -38,6 +38,6 @@ jobs: run: scripts/build.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: "/language:c-cpp" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f2d8bcb92..c2acbd8ac 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -27,6 +27,6 @@ jobs: publish_results: true - name: Upload SARIF results - uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4 + uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 with: sarif_file: results.sarif From dc5293d71e705604d315f122a60b92f0176ff360 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 20 Sep 2026 17:32:12 +0200 Subject: [PATCH 02/50] fix(cypher): stop the early WHERE pruning rows it cannot judge yet Distilled from #1245 by Andrew Hundt (upstream b835b5e8), re-derived on main's cypher.c rather than cherry-picked -- the file has moved ~9k lines since the branch point. The WHERE clause runs twice: once early, on seed rows where only the first alias is bound, to prune; and once late, when every alias is bound, to decide. The early pass used the same two-valued evaluator as the late one. That evaluator answers "true" for a comparison on an alias it cannot see -- the permissive default that makes the late pass work -- but NOT and XOR invert whatever they are given, so MATCH (a)-[:CALLS]->(b) WHERE NOT b.name CONTAINS "x" evaluated NOT(true) on every seed and pruned every row. Zero results, no error, for a query that is correct. The early pass now uses a three-valued evaluator. A condition on an alias that is not yet bound is UNKNOWN; NOT of UNKNOWN stays UNKNOWN; AND and OR short-circuit only on a definite operand; XOR with any UNKNOWN operand is UNKNOWN. Only a definite FALSE prunes. The late pass is unchanged and still gives the final two-valued answer, so nothing that was correct before changes. Three deliberate differences from the upstream diff: - the unbound-alias rule lives in one leaf, eval_condition_partial, so the expression-tree path and main's legacy flat AND/OR path cannot drift apart; - that leaf guards c->variable == NULL. On main a condition whose left side is a function of literals, coalesce(0, 1) >= 2, carries no variable at all; the upstream version would have passed NULL to strcmp; - the flat path keeps main's rule that an empty condition list is true. RED before the fix, all four with row_count == 0, expected 1: cypher_exec_where_not_on_relationship_target, cypher_exec_where_mixed_alias_and, ..._or, ..._xor. GREEN after: cypher 208 passed; cypher + mcp 526 passed, 4 skipped (the Windows-only skips). eval_expr_partial recurses and is added to CBM_RECURSION_WHITELIST. Co-authored-by: Andrew Hundt Signed-off-by: Martin Vogel --- src/cypher/cypher.c | 92 +++++++++++++++++++++++++++- src/foundation/recursion_whitelist.h | 14 ++--- tests/test_cypher.c | 72 ++++++++++++++++++++++ 3 files changed, 170 insertions(+), 8 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 234f49087..8bf69af24 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -2803,6 +2803,96 @@ static bool eval_where(const cbm_where_clause_t *w, binding_t *b) { return is_and; } +/* Three-valued WHERE evaluation for the early (seed-row) pass in + * execute_single: only the first MATCH alias is bound there, so a leaf on + * any other alias is UNKNOWN rather than true. The two-valued evaluator + * returns true for an unbound alias, which NOT / XOR then invert -- pruning + * every seed before relationship expansion (distilled from PR #1245). The + * full evaluation reruns after expansion (Step 3), so the early pass only has + * to prune on a definite false. Allocation-free, O(expression nodes). */ +typedef enum { CYP_PARTIAL_FALSE = 0, CYP_PARTIAL_TRUE, CYP_PARTIAL_UNKNOWN } cyp_partial_t; + +static cyp_partial_t partial_and(cyp_partial_t l, cyp_partial_t r) { + if (l == CYP_PARTIAL_FALSE || r == CYP_PARTIAL_FALSE) { + return CYP_PARTIAL_FALSE; + } + return (l == CYP_PARTIAL_TRUE && r == CYP_PARTIAL_TRUE) ? CYP_PARTIAL_TRUE + : CYP_PARTIAL_UNKNOWN; +} + +static cyp_partial_t partial_or(cyp_partial_t l, cyp_partial_t r) { + if (l == CYP_PARTIAL_TRUE || r == CYP_PARTIAL_TRUE) { + return CYP_PARTIAL_TRUE; + } + return (l == CYP_PARTIAL_FALSE && r == CYP_PARTIAL_FALSE) ? CYP_PARTIAL_FALSE + : CYP_PARTIAL_UNKNOWN; +} + +/* A condition whose alias is not bound yet is UNKNOWN; a literal-only LHS + * (func condition with no variable arg) has nothing to wait for. */ +static cyp_partial_t eval_condition_partial(const cbm_condition_t *c, binding_t *b) { + if (c->variable && !binding_get(b, c->variable) && !binding_get_edge(b, c->variable)) { + return CYP_PARTIAL_UNKNOWN; + } + return eval_condition(c, b) ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE; +} + +static cyp_partial_t eval_expr_partial(const cbm_expr_t *e, // NOLINT(misc-no-recursion) + binding_t *b) { + if (!e) { + return CYP_PARTIAL_TRUE; + } + if (e->type == EXPR_CONDITION) { + return eval_condition_partial(&e->cond, b); + } + cyp_partial_t left = eval_expr_partial(e->left, b); + if (e->type == EXPR_NOT) { + if (left == CYP_PARTIAL_UNKNOWN) { + return CYP_PARTIAL_UNKNOWN; + } + return left == CYP_PARTIAL_TRUE ? CYP_PARTIAL_FALSE : CYP_PARTIAL_TRUE; + } + /* Same short-circuits as eval_expr: a definite left decides AND / OR. */ + if (e->type == EXPR_AND && left == CYP_PARTIAL_FALSE) { + return CYP_PARTIAL_FALSE; + } + if (e->type == EXPR_OR && left == CYP_PARTIAL_TRUE) { + return CYP_PARTIAL_TRUE; + } + cyp_partial_t right = eval_expr_partial(e->right, b); + if (e->type == EXPR_AND) { + return partial_and(left, right); + } + if (e->type == EXPR_OR) { + return partial_or(left, right); + } + /* EXPR_XOR */ + if (left == CYP_PARTIAL_UNKNOWN || right == CYP_PARTIAL_UNKNOWN) { + return CYP_PARTIAL_UNKNOWN; + } + return left != right ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE; +} + +static cyp_partial_t eval_where_partial(const cbm_where_clause_t *w, binding_t *b) { + if (!w) { + return CYP_PARTIAL_TRUE; + } + if (w->root) { + return eval_expr_partial(w->root, b); + } + /* Legacy flat evaluation */ + if (w->count == 0) { + return CYP_PARTIAL_TRUE; + } + bool is_and = (w->op && strcmp(w->op, "AND") == 0) != 0; + cyp_partial_t result = is_and ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE; + for (int i = 0; i < w->count; i++) { + cyp_partial_t r = eval_condition_partial(&w->conditions[i], b); + result = is_and ? partial_and(result, r) : partial_or(result, r); + } + return result; +} + /* Check if a string value looks like a regex pattern. */ static bool looks_like_regex(const char *s) { if (!s) { @@ -5135,7 +5225,7 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec binding_t b = {0}; b.store = store; binding_set(&b, var_name, &scanned[i]); - bool pass = !q->where || eval_where(q->where, &b); + bool pass = eval_where_partial(q->where, &b) != CYP_PARTIAL_FALSE; if (pass) { bindings[bind_count++] = b; } else { diff --git a/src/foundation/recursion_whitelist.h b/src/foundation/recursion_whitelist.h index c3e2b5c8c..73a38cc50 100644 --- a/src/foundation/recursion_whitelist.h +++ b/src/foundation/recursion_whitelist.h @@ -8,8 +8,8 @@ * - parse_or_expr, parse_xor_expr, parse_and_expr, parse_not_expr * - parse_atom_expr, parse_post_where, cbm_parse * - * Cypher expression evaluator (bounded by WHERE clause depth ~5): - * - eval_expr + * Cypher expression evaluators (bounded by WHERE clause depth ~5): + * - eval_expr, eval_expr_partial * * Glob pattern matcher (bounded by pattern nesting ~3): * - glob_match, glob_match_star, glob_match_doublestar @@ -25,9 +25,9 @@ * To add a function: add it below AND add NOLINT(misc-no-recursion) on * the function definition line. The lint gate verifies both match. */ -#define CBM_RECURSION_WHITELIST \ - "parse_or_expr", "parse_xor_expr", "parse_and_expr", "parse_not_expr", "parse_atom_expr", \ - "parse_post_where", "cbm_parse", "eval_expr", "glob_match", "glob_match_star", \ - "glob_match_doublestar", "glob_match_doublestar_slash", "glob_match_doublestar_any", \ - "parse_bool_expr", "parse_bool_atom", "r_collect_imports", \ +#define CBM_RECURSION_WHITELIST \ + "parse_or_expr", "parse_xor_expr", "parse_and_expr", "parse_not_expr", "parse_atom_expr", \ + "parse_post_where", "cbm_parse", "eval_expr", "eval_expr_partial", "glob_match", \ + "glob_match_star", "glob_match_doublestar", "glob_match_doublestar_slash", \ + "glob_match_doublestar_any", "parse_bool_expr", "parse_bool_atom", "r_collect_imports", \ "find_first_descendant_by_kind", "find_first_descendant_of" diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 8bdee76c9..853abc266 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -3111,6 +3111,74 @@ TEST(cypher_exec_where_not) { PASS(); } +/* Regression (distilled from PR #1245, Andrew Hundt): the early WHERE pass in + * execute_single runs on seed rows with only the first alias bound. A + * comparison on an unbound alias used to evaluate to true, which NOT / XOR + * then inverted -- so every seed was pruned before CALLS expansion and the + * query returned zero rows. Unbound leaves must stay UNKNOWN there. */ +TEST(cypher_exec_where_not_on_relationship_target) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a:Function)-[:CALLS]->(b:Function) " + "WHERE NOT b.name CONTAINS \"Order\" RETURN b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "LogError"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_mixed_alias_and) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute( + s, + "MATCH (a:Function)-[:CALLS]->(b:Function) " + "WHERE a.name = \"HandleOrder\" AND NOT b.name CONTAINS \"Order\" RETURN b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "LogError"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_mixed_alias_or) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute( + s, + "MATCH (a:Function)-[:CALLS]->(b:Function) " + "WHERE a.name = \"NoSuchFunction\" OR NOT b.name CONTAINS \"Order\" RETURN b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "LogError"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_mixed_alias_xor) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = + cbm_cypher_execute(s, + "MATCH (a:Function)-[:CALLS]->(b:Function) " + "WHERE a.name = \"HandleOrder\" XOR b.name = \"LogError\" RETURN b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + TEST(cypher_exec_where_in) { cbm_store_t *s = setup_cypher_store(); cbm_cypher_result_t r = {0}; @@ -4769,6 +4837,10 @@ SUITE(cypher) { RUN_TEST(cypher_exec_where_neq_bang); RUN_TEST(cypher_exec_where_ends_with); RUN_TEST(cypher_exec_where_not); + RUN_TEST(cypher_exec_where_not_on_relationship_target); + RUN_TEST(cypher_exec_where_mixed_alias_and); + RUN_TEST(cypher_exec_where_mixed_alias_or); + RUN_TEST(cypher_exec_where_mixed_alias_xor); RUN_TEST(cypher_exec_where_in); RUN_TEST(cypher_exec_where_not_in); RUN_TEST(cypher_exec_where_is_null); From 65aad0ff7cda7089e28c7c0ff802c6d74095ffd2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 20 Sep 2026 17:50:53 +0200 Subject: [PATCH 03/50] fix(mcp): an integer argument outside int range takes the default Distilled from #1245 by Andrew Hundt (d030c8f4). cbm_mcp_get_int_arg read integer arguments with yyjson_is_int and yyjson_get_int, and the int cast inside get_int truncates. A client sending {"limit": 4294967297} -- 2^32 + 1 -- got back 1: the low word of the value, silently, as a bound. The same read serves limit and offset on the query tools, so an oversized page request became a page of one. The argument is now read as a 64-bit signed or unsigned integer and only accepted if it fits in int; anything outside the range takes the caller's default, exactly as a non-integer does. In-range values and non-integer JSON behave as before. One difference from the upstream hunk: the unsigned comparison casts INT_MAX explicitly rather than relying on the implicit conversion. RED before the fix: mcp_get_int_arg FAIL tests/test_mcp.c:1777: val == 1, expected 17 == 17 The test now covers 2^32 + 1, its negative, INT_MAX + 1 and INT64_MIN each returning a distinct default, and INT_MAX, INT_MIN and -7 passing through unchanged. GREEN after: mcp 318 passed, 4 skipped (the Windows-only skips); mcp cli daemon_application 692 passed, 0 failed. Co-authored-by: Andrew Hundt Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 15 +++++++++++++-- tests/test_mcp.c | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d6c7bd468..ee4897e5b 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1584,8 +1584,19 @@ int cbm_mcp_get_int_arg(const char *args_json, const char *key, int default_val) yyjson_val *root = yyjson_doc_get_root(doc); yyjson_val *val = yyjson_obj_get(root, key); int result = default_val; - if (val && yyjson_is_int(val)) { - result = yyjson_get_int(val); + /* yyjson_get_int truncates 64-bit integers to int (2^32 + 1 became 1); + * read the full width and treat anything outside int range like a + * non-integer, i.e. fall back to the caller's default. */ + if (val && yyjson_is_sint(val)) { + int64_t parsed = yyjson_get_sint(val); + if (parsed >= INT_MIN && parsed <= INT_MAX) { + result = (int)parsed; + } + } else if (val && yyjson_is_uint(val)) { + uint64_t parsed = yyjson_get_uint(val); + if (parsed <= (uint64_t)INT_MAX) { + result = (int)parsed; + } } yyjson_doc_free(doc); return result; diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ffa9ea3a3..027ab0c50 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1771,6 +1771,23 @@ TEST(mcp_get_int_arg) { ASSERT_EQ(val, 5); val = cbm_mcp_get_int_arg(args, "missing", 42); ASSERT_EQ(val, 42); + /* Out-of-int-range integers return the default instead of truncating: + * 2^32 + 1 used to read back as 1 through yyjson_get_int's int cast. */ + val = cbm_mcp_get_int_arg("{\"limit\":4294967297}", "limit", 17); + ASSERT_EQ(val, 17); + val = cbm_mcp_get_int_arg("{\"limit\":-4294967297}", "limit", 19); + ASSERT_EQ(val, 19); + val = cbm_mcp_get_int_arg("{\"limit\":2147483648}", "limit", 23); + ASSERT_EQ(val, 23); + val = cbm_mcp_get_int_arg("{\"limit\":-9223372036854775808}", "limit", 29); + ASSERT_EQ(val, 29); + /* Boundary values and a negative in-range value still pass through. */ + val = cbm_mcp_get_int_arg("{\"limit\":2147483647}", "limit", 0); + ASSERT_EQ(val, 2147483647); + val = cbm_mcp_get_int_arg("{\"limit\":-2147483648}", "limit", 0); + ASSERT_EQ(val, -2147483648); + val = cbm_mcp_get_int_arg("{\"limit\":-7}", "limit", 0); + ASSERT_EQ(val, -7); PASS(); } From e4da6643ff9556ad84444e938b7babcbf41500fa Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 30 Sep 2026 23:09:02 +0200 Subject: [PATCH 04/50] ci: lane selector for PR CI (R1 rule set) with contract and history replay scripts/ci/select-lanes.sh maps a PR's changed-file list to a tier, a lane set, a full flag and the reasons: each path gets a category, the categories give a tier (T0a docs .. T5 core), the tier gives the R1 lanes, and a few categories add lanes of their own (pkg -> wrappers + 3-OS smoke, graph-ui -> Windows guards, memory core -> memwaste, ...). It fails safe: tier T5, an unmatched path, an empty list, the selector itself, any workflow, the test harness, Makefile.cbm and vendored code select every lane. The output is a pure function of the file set. tests/test_select_lanes.sh pins the decision table (43 cases), determinism (order, duplicates, CRLF), the input channels and the lane universe. scripts/test-impact/replay-selector.sh replays all 767 September 2026 PR pushes and the 22 real failures from a compact fixture: every push gets its expected tier (729 equal to the analysis, 38 documented deviations where the analysis split Makefile.cbm diffs by content and the selector, seeing names only, is conservatively full), and every real failure's lane is selected. Both run as Step 0z/0z2 of scripts/test.sh. Signed-off-by: Martin Vogel --- scripts/ci/README.md | 1 + scripts/ci/select-lanes.sh | 336 ++++++++ .../test-impact/fixtures/2026-09-filesets.tsv | 326 ++++++++ .../test-impact/fixtures/2026-09-pushes.tsv | 776 ++++++++++++++++++ .../fixtures/2026-09-real-failures.tsv | 26 + scripts/test-impact/replay-selector.sh | 135 +++ scripts/test.sh | 8 + tests/fixtures/select-lanes/cases.tsv | 48 ++ tests/test_select_lanes.sh | 165 ++++ 9 files changed, 1821 insertions(+) create mode 100755 scripts/ci/select-lanes.sh create mode 100644 scripts/test-impact/fixtures/2026-09-filesets.tsv create mode 100644 scripts/test-impact/fixtures/2026-09-pushes.tsv create mode 100644 scripts/test-impact/fixtures/2026-09-real-failures.tsv create mode 100755 scripts/test-impact/replay-selector.sh create mode 100644 tests/fixtures/select-lanes/cases.tsv create mode 100755 tests/test_select_lanes.sh diff --git a/scripts/ci/README.md b/scripts/ci/README.md index f296e6fc5..0504f01d1 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -14,6 +14,7 @@ CI and the local infrastructure — both of which the venue-parity contract | `check-glibc-compat.sh` | Run a linux binary in debian:bullseye (glibc 2.31) — the portable binary must start on old glibc. | `_smoke.yml` portable legs | | `generate-sbom.py` | The release SPDX SBOM (vendored versions reviewable here, diffable by vendoring PRs — was inline YAML). | `release.yml` | | `require-all-green.sh` | The aggregate gate: fail unless every needed job succeeded or legitimately skipped (was inline YAML). | `pr.yml ci-ok` | +| `select-lanes.sh` | Decide which PR lanes a change set runs (tier, lanes, full, reasons) from its file list — the R1 rule set, fail-safe to every lane. Pinned by `tests/test_select_lanes.sh` and the September 2026 history replay (`scripts/test-impact/replay-selector.sh`). | `pr.yml changes` | | `verify-shard-union.sh` | Prove sharded test legs lost nothing: shard count agreement, indices 1..n, identical suite lists, union of slices == full list (was inline YAML). | `_test.yml` shard-completeness | | `prepare-release-candidates.sh` | Copy one linker output into stripped/unstripped candidates, finalize signatures, composition-check them without execution, and record their hashes. | `_build.yml`, local artifact smoke | | `stage-release-candidates.py` | Admit exactly eight candidate artifacts / sixteen byte-distinct binaries into the content-addressed VirusTotal scan set. | `_build.yml` | diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh new file mode 100755 index 000000000..8b8c936a7 --- /dev/null +++ b/scripts/ci/select-lanes.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +# select-lanes.sh — decide which PR CI lanes a change set must run. +# +# Canonical CI step (called by pr.yml's `changes` job on the PR's file list). +# This is the R1 rule set, measured against every PR push of September 2026 +# (767 pushes; scripts/test-impact/replay-selector.sh replays them, plus the 22 +# real failures, as a contract): each changed path gets a category, the +# categories give a tier, the tier gives a lane set, and a few categories add +# lanes of their own. `tier` is the classification the analysis measured; +# `full` overrides it with every lane. +# +# It FAILS SAFE: full=true for tier T5, a path no rule knows, an empty file +# list, this script, any workflow, the test harness, Makefile.cbm (source +# registration and flag changes look identical in a file list) and vendored +# code. It is a pure function of the file SET: order, duplicates, blank lines +# and CRLF never change a byte of the output. +# +# Usage: scripts/ci/select-lanes.sh [--format json|github-output] [FILE] +# FILE (default stdin): changed paths, one per line. +# json (default): {"full","lanes","reasons","tier"} on one line. +# github-output: tier=, full=, lanes=, reasons= lines ($GITHUB_OUTPUT). +# scripts/ci/select-lanes.sh --batch FILE +# FILE: JSON lines {"id":..., "files":[...]}; prints one json result +# per line with its "id" (the history replay's single process). +# scripts/ci/select-lanes.sh --list-lanes +# Every lane name, one per line (the universe ci-ok checks against). +# Exit: 0 = decided · 2 = usage error. +set -euo pipefail + +usage() { sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'; } + +FORMAT=json +MODE="select" +INPUT="" +while [ $# -gt 0 ]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --format) + FORMAT="${2:-}" + [ $# -gt 1 ] && shift + ;; + --format=*) FORMAT="${1#--format=}" ;; + --batch) + MODE="batch" + INPUT="${2:-}" + [ $# -gt 1 ] && shift + ;; + --list-lanes) MODE="list" ;; + -*) + echo "select-lanes.sh: unknown option '$1'. Please consult --help." >&2 + exit 2 + ;; + *) + if [ -n "$INPUT" ]; then + echo "select-lanes.sh: more than one input file. Please consult --help." >&2 + exit 2 + fi + INPUT="$1" + ;; + esac + shift +done +case "$FORMAT" in +json | github-output) ;; +*) + echo "select-lanes.sh: --format must be json or github-output. Please consult --help." >&2 + exit 2 + ;; +esac +if [ "$MODE" = batch ] && [ -z "$INPUT" ]; then + echo "select-lanes.sh: --batch needs a file. Please consult --help." >&2 + exit 2 +fi +if [ -n "$INPUT" ] && [ ! -f "$INPUT" ]; then + echo "select-lanes.sh: no such file '$INPUT'. Please consult --help." >&2 + exit 2 +fi + +# The heredoc below is the program, so stdin input is staged in a file first. +STAGED="" +if [ "$MODE" = select ] && [ -z "$INPUT" ]; then + STAGED=$(mktemp "${TMPDIR:-/tmp}/cbm-select-lanes.XXXXXX") + trap 'rm -f "$STAGED"' EXIT + cat >"$STAGED" + INPUT="$STAGED" +fi + +python3 - "$MODE" "$FORMAT" "$INPUT" <<'PY' +import json +import re +import sys + +mode, fmt, source = sys.argv[1:4] + +# ── Path -> category (first matching rule wins; order is part of the rule) ── +GATE_WORKFLOWS = {"pr.yml", "_test.yml", "_lint.yml", "_security.yml", + "_smoke.yml", "_memwaste.yml", "codeql.yml", "dco.yml"} +BUILD = {"Makefile.cbm", "scripts/test.sh", "scripts/run-tests-parallel.sh", + "scripts/env.sh", "scripts/build.sh", "scripts/clean.sh", + "scripts/path-safety.sh", "scripts/test-runtime.sh", "scripts/msan.sh", + "scripts/lint.sh", ".clang-format", ".clang-tidy", + "scripts/vendored-checksums.txt"} +SMOKE = {"scripts/smoke-test.sh", "scripts/smoke-local.sh", + "scripts/smoke-fixture-server.py", "scripts/smoke-invariants.sh", + "test-infrastructure/vm/vm-smoke.sh", + "test-infrastructure/vm/windows-user-path-guard.ps1", + "scripts/test-windows.ps1"} +LINT_CONFIG = {"scripts/memory-core-baseline.txt", "scripts/lint-memory-core.py", + "scripts/hooks/pre-commit"} +NON_GATING = {"scripts/package-release.sh", "scripts/benchmark-index.sh", + "scripts/benchmark-search-graph.sh", "scripts/benchmark-streaming.py", + "scripts/soak-test.sh", ".cbmignore", ".node-version", "flake.nix"} +PKG_FILES = {"install.sh", "install.ps1", "server.json", "package.json", "manifest.json"} +DOC_SUFFIXES = (".md", ".png", ".svg", ".jpg", ".gif", ".txt") +DOC_FILES = {"LICENSE", "CODEOWNERS", ".gitignore", ".gitattributes"} + + +def category(path): + base = path.rsplit("/", 1)[-1] + if path == "Makefile.cbm": + return "build" + if path in LINT_CONFIG: + return "lint-cfg" + if path.startswith(("scripts/memwaste", "scripts/memlab")): + return "memwaste-tools" + if path in NON_GATING or path.startswith("scripts/test-impact/"): + return "ci-other" + if path == "scripts/test_mcp_in_process.py": + return "test-contract" + if path == "scripts/fuzz.sh": + return "fuzz" + if path == "scripts/audit-license-provenance.py": + return "ci-gate" + if path.startswith(("internal/cbm/vendored/grammars/", "tools/tree-sitter-")): + return "grammar" + if path.startswith(".github/workflows/"): + return "ci-gate" if base in GATE_WORKFLOWS else "ci-other" + if path.startswith(".github/"): + if path.endswith(".md") or ("ISSUE_TEMPLATE" in path and path.endswith((".yml", ".yaml"))): + return "docs" + return "ci-other" + if path.startswith("scripts/ci/"): + return "ci-gate" + if path in BUILD: + return "build" + if path in SMOKE: + return "smoke" + if path.startswith("tests/fixtures/"): + return "test" + if path.startswith("tests/windows/"): + return "test-win" + if path.startswith("tests/fuzz/"): + return "fuzz" + if path.startswith("tests/"): + return "test-contract" if path.endswith((".sh", ".py", ".ps1")) else "test" + if path.startswith(("docs/", "eval-results/")) or base in DOC_FILES or ( + path.endswith(DOC_SUFFIXES) and not path.startswith(("src/", "internal/", "scripts/"))): + return "docs" + if path.startswith("graph-ui/"): + return "ui-frontend" + if path.startswith(("pkg/", "scripts/setup")) or base in PKG_FILES: + return "pkg" + if path.startswith(("internal/cbm/vendored/", "vendored/")): + return "vendored" + m = re.match(r"internal/cbm/lsp/([a-z]+)_", path) + if m and m.group(1) not in ("lsp", "type", "scope"): + return "lsp:" + m.group(1) + if path.startswith("internal/cbm/lsp/"): + return "lsp-core" + if path.startswith("internal/cbm/"): + return "extract" + if path == "src/main.c": + return "cli" + if path.startswith("src/foundation/"): + return "mem-core" if re.search(r"/(mem|slab|arena|alloc|mimalloc)", path) else "foundation" + if path.startswith("src/"): + return "src:" + path.split("/")[1] + if path.startswith("test-infrastructure/"): + return "local-infra" + if path.startswith("scripts/"): + return "scripts-other" + return "other" + + +# ── Categories -> tier (highest wins) ── +EXTRACT = {"grammar", "lsp-core", "extract"} +MID = {"pipeline", "mcp", "store", "cypher", "discover", "git", "graph_buffer", + "semantic", "simhash", "traces", "personal_memory"} +PLATFORM = {"daemon", "cli", "watcher", "ui", "foundation", "smoke", "pkg", "test-win"} +CORE = {"mem-core", "build", "vendored", "ci-gate", "other", "scripts-other"} +LOWER = {"test", "test-contract", "fuzz", "ui-frontend", "ci-other", + "local-infra", "docs", "lint-cfg", "memwaste-tools"} + + +def normalized(cat): + # src//... categories are the directory name; a directory no tier + # names stays unknown (and therefore T5 + full). + if cat.startswith("src:"): + name = cat[4:] + return name if name in MID | PLATFORM else cat + return cat + + +def known(cat): + return cat in EXTRACT | MID | PLATFORM | CORE | LOWER or cat.startswith("lsp:") + + +def tier_of(cats): + if cats & CORE or not all(known(c) for c in cats): + return "T5-core" + if cats & PLATFORM: + return "T4-platform" + if cats & MID: + return "T3-mid" + if cats & EXTRACT or any(c.startswith("lsp:") for c in cats): + return "T2-extract" + if cats & {"test", "test-contract", "fuzz"}: + return "T1-tests" + if cats & {"ui-frontend", "ci-other", "local-infra", "lint-cfg", "memwaste-tools"}: + return "T0b-nonproduct" + return "T0a-docs" + + +# ── Tier -> lanes (R1) ── +FULL = {"codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos", + "memwaste", "msan", "pkg-wrappers", "security-static", "shard-completeness", + "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", + "unix-arm64", "unix-macos-intel", "unix-macos14", "unix-x86", "windows", + "windows-guards"} +UNIVERSE = FULL | {"contracts"} # contracts: the docs-only job; every test leg runs them too +LINT = {"lint", "lint-mem"} +CORE_LEGS = {"unix-x86", "unix-macos14", "windows", "shard-completeness"} +TEST_LEGS = {"unix-x86", "unix-arm64", "unix-macos14", "unix-macos-intel", "windows"} +ALWAYS = {"security-static"} +R1 = { + "T0a-docs": {"contracts"}, + "T0b-nonproduct": LINT, + "T1-tests": LINT | CORE_LEGS, + "T2-extract": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"}, + "T3-mid": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"}, + "T4-platform": FULL - {"msan", "diag", "memwaste"}, + "T5-core": FULL, +} +# Category add-ons: (categories, lanes). pkg adds nothing to a docs-only set +# because a pkg path is never docs-only (it lifts the tier to T4). +ADD_ONS = [ + ({"pkg"}, {"pkg-wrappers", "smoke-ubuntu", "smoke-mac", "smoke-win"}), + ({"vendored", "pkg", "ci-gate", "build"}, {"license-gate"}), + ({"ui-frontend"}, {"windows-guards", "smoke-ubuntu"}), + ({"memwaste-tools", "mem-core"}, {"memwaste"}), + ({"test-win"}, {"windows", "windows-guards"}), + # the contract steps run inside test.sh's default leg + ({"ci-other", "test-contract", "fuzz"}, {"unix-x86"}), +] +HARNESS = {"scripts/test.sh", "scripts/run-tests-parallel.sh", "scripts/run-test-wave.py", + "tests/test_main.c", "tests/test_framework.h"} + + +def triggers(path, cat): + found = [] + if path == "scripts/ci/select-lanes.sh": + found.append("the lane selector itself") + if path.startswith(".github/workflows/"): + found.append("workflow") + if path in HARNESS: + found.append("test harness") + if path == "Makefile.cbm": + found.append("Makefile.cbm (registration and flag changes are indistinguishable by name)") + if cat == "vendored": + found.append("vendored code") + if cat in ("other", "scripts-other") or not known(cat): + found.append("path matched by no rule") + return found + + +def select(paths): + paths = sorted({p for p in paths if p}) + by_cat, by_trigger = {}, {} + for path in paths: + cat = normalized(category(path)) + by_cat.setdefault(cat, []).append(path) + for trigger in triggers(path, cat): + by_trigger.setdefault(trigger, []).append(path) + cats = set(by_cat) + tier = tier_of(cats) + lanes = R1[tier] | ALWAYS + reasons = [f"category {c}: {len(p)} file(s), e.g. {p[0]}" for c, p in by_cat.items()] + for when, extra in ADD_ONS: + hit = sorted(cats & when) + if hit and tier != "T0a-docs" and not extra <= lanes: + reasons.append(f"add-on {'/'.join(hit)}: +{' +'.join(sorted(extra - lanes))}") + lanes = lanes | extra + if lanes & TEST_LEGS: + lanes = lanes | {"shard-completeness"} + for trigger, hits in by_trigger.items(): + more = f" (+{len(hits) - 1} more)" if len(hits) > 1 else "" + reasons.append(f"full: {trigger}: {hits[0]}{more}") + if not paths: + reasons.append("full: empty file list (fail safe)") + if tier == "T5-core": + reasons.append("full: tier T5-core") + full = tier == "T5-core" or bool(by_trigger) or not paths + if full: + lanes = set(FULL) + return {"full": full, "lanes": sorted(lanes), "reasons": sorted(reasons), "tier": tier} + + +def dump(obj): + return json.dumps(obj, sort_keys=True, separators=(",", ":")) + + +def read_paths(path): + with open(path, encoding="utf-8") as fh: # universal newlines: CRLF == LF + return [line.strip() for line in fh] + + +if mode == "list": + print("\n".join(sorted(UNIVERSE))) +elif mode == "batch": + with open(source, encoding="utf-8") as fh: + for line in fh: + if line.strip(): + row = json.loads(line) + print(dump({"id": row["id"], **select(row["files"])})) +elif fmt == "github-output": + decision = select(read_paths(source)) + print(f"tier={decision['tier']}") + print(f"full={'true' if decision['full'] else 'false'}") + print(f"lanes={dump(decision['lanes'])}") + print(f"reasons={dump(decision['reasons'])}") +else: + print(dump(select(read_paths(source)))) +PY diff --git a/scripts/test-impact/fixtures/2026-09-filesets.tsv b/scripts/test-impact/fixtures/2026-09-filesets.tsv new file mode 100644 index 000000000..41bf7799d --- /dev/null +++ b/scripts/test-impact/fixtures/2026-09-filesets.tsv @@ -0,0 +1,326 @@ +# Distinct changed-file sets of the September 2026 PR pushes (git diff +# --name-only merge-base..head per push), referenced by 2026-09-pushes.tsv. +# Columns (TAB): fileset, paths (space-separated; no path contains a space). +f001 README.md +f002 internal/cbm/vendored/grammars/swift/scanner.c scripts/vendored-checksums.txt tests/test_extraction.c +f003 internal/cbm/lsp/go_lsp.c src/pipeline/pass_lsp_cross.c tests/test_go_lsp.c tests/test_parallel.c +f004 src/foundation/mem.c src/foundation/mem.h tests/test_mem.c +f005 Makefile.cbm internal/cbm/cbm.c internal/cbm/cbm.h src/mcp/mcp.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pipeline_internal.h src/store/store.c tests/test_index_resilience.c tests/test_mcp.c tests/test_parse_coverage.c tests/test_pipeline.c +f006 .github/workflows/pr.yml Makefile.cbm internal/cbm/cbm.c internal/cbm/cbm.h scripts/ci/README.md scripts/ci/coverage-gate-allowlist.txt scripts/ci/parse-partial-baseline.txt scripts/ci/self-index-coverage-gate.sh src/mcp/mcp.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pipeline_internal.h src/store/store.c tests/test_index_resilience.c tests/test_mcp.c tests/test_parse_coverage.c tests/test_pipeline.c tests/test_venue_parity_contract.sh +f007 README.md scripts/smoke-test.sh src/cli/cli.c tests/test_cli.c +f008 src/ui/http_server.c tests/test_httpd.c +f009 src/daemon/application.c src/daemon/application.h src/daemon/frontend.c src/daemon/frontend.h src/daemon/runtime.c src/daemon/runtime.h src/main.c tests/test_daemon_frontend.c tests/test_daemon_runtime.c +f010 internal/cbm/lsp/c_lsp.c internal/cbm/lsp/type_registry.c internal/cbm/lsp/type_registry.h tests/test_c_lsp.c +f011 src/pipeline/fqn.c src/pipeline/pass_pkgmap.c tests/test_edge_imports.c tests/test_fqn.c +f012 internal/cbm/extract_calls.c tests/test_extraction.c tests/test_pipeline.c +f013 src/mcp/mcp.c tests/test_mcp.c +f014 src/cypher/cypher.c tests/test_cypher.c +f015 src/mcp/mcp.c src/store/store.c src/store/store.h src/ui/layout3d.c src/ui/layout3d.h tests/test_store_arch.c tests/test_ui.c +f016 Makefile.cbm README.md docs/index.html docs/llms.txt scripts/smoke-invariants.sh src/cli/cli.c src/cli/cli.h src/foundation/platform.c src/foundation/platform.h src/mcp/mcp.c src/personal_memory/memory.c src/personal_memory/memory.h tests/test_cli.c tests/test_mcp.c +f017 internal/cbm/extract_defs.c tests/test_extraction.c +f018 internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/swift/scanner.c scripts/vendored-checksums.txt tests/test_extraction.c +f019 internal/cbm/extract_calls.c internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/swift/scanner.c scripts/vendored-checksums.txt tests/test_extraction.c tests/test_pipeline.c +f020 src/daemon/application.c tests/test_daemon_application.c tests/windows/test_non_ascii_path.py +f021 internal/cbm/lsp/ts_lsp.c tests/repro/repro_lsp_ts.c +f022 src/graph_buffer/graph_buffer.c src/mcp/mcp.c tests/test_graph_buffer.c tests/test_mcp.c +f023 internal/cbm/cbm.h internal/cbm/extract_calls.c src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f024 .github/workflows/pr.yml Makefile.cbm internal/cbm/cbm.c internal/cbm/cbm.h scripts/ci/README.md scripts/ci/coverage-gate-allowlist.txt scripts/ci/parse-partial-baseline.txt scripts/ci/self-index-coverage-gate.sh scripts/test.sh src/mcp/mcp.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pipeline_internal.h src/store/store.c tests/test_coverage_gate_contract.sh tests/test_index_resilience.c tests/test_mcp.c tests/test_parse_coverage.c tests/test_pipeline.c tests/test_venue_parity_contract.sh +f025 Makefile.cbm README.md docs/CONFIGURATION.md docs/INDEX_RESOURCE_LIMITS.md scripts/test-runtime.sh src/cli/cli.c src/cli/cli.h src/daemon/application.c src/discover/discover.c src/discover/discover.h src/foundation/index_policy.c src/foundation/index_policy.h src/mcp/mcp.c src/mcp/mcp_internal.h src/pipeline/pipeline.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_daemon_application.c tests/test_discover.c tests/test_index_policy.c tests/test_main.c tests/test_pipeline.c tests/test_worker_error_response.sh tests/test_worker_watchdog.sh +f026 Makefile.cbm src/cli/cli.c src/pipeline/pass_doclinks.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_doclinks.c tests/test_edge_structural.c tests/test_lang_contract.c tests/test_main.c +f027 src/mcp/mcp.c src/mcp/mcp_internal.h tests/test_mcp.c +f028 internal/cbm/extract_defs.c src/discover/language.c src/pipeline/pass_route_nodes.c tests/test_extraction.c tests/test_language.c +f029 README.md docs/CONFIGURATION.md scripts/smoke-test.sh src/cli/cli.c src/cli/cli.h src/main.c tests/test_cli.c +f030 src/main.c tests/test_parent_watchdog.sh +f031 internal/cbm/extract_imports.c tests/test_extraction.c +f032 internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_unified.c internal/cbm/helpers.h src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f033 internal/cbm/extract_calls.c src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f034 internal/cbm/cbm.c internal/cbm/preprocessor.cpp internal/cbm/preprocessor.h tests/test_extraction.c +f035 internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_unified.c internal/cbm/helpers.c internal/cbm/lsp/type_registry.c src/pipeline/lsp_resolve.h src/pipeline/registry.c tests/test_extraction.c tests/test_pipeline.c +f036 internal/cbm/cbm.h internal/cbm/extract_semantic.c internal/cbm/extract_usages.c src/pipeline/pass_parallel.c src/pipeline/pass_usages.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_pipeline.c tests/test_registry.c +f037 internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_defs.c src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f038 internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_unified.c internal/cbm/extract_unified.h src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f039 src/cli/cli.c src/ui/http_server.c tests/test_cli.c +f040 internal/cbm/extract_channels.c tests/test_extraction.c tests/test_pipeline.c +f041 internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_unified.c internal/cbm/helpers.h internal/cbm/lsp/go_lsp.c src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_extraction.c tests/test_parallel.c tests/test_pipeline.c tests/test_registry.c +f042 internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_unified.c internal/cbm/helpers.c internal/cbm/helpers.h internal/cbm/lsp/go_lsp.c src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_extraction.c tests/test_parallel.c tests/test_pipeline.c tests/test_registry.c +f043 internal/cbm/extract_defs.c tests/test_edge_types_probe.c +f044 internal/cbm/extract_calls.c tests/test_extraction.c +f045 internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_channels.c internal/cbm/extract_defs.c internal/cbm/extract_env_accesses.c internal/cbm/extract_imports.c internal/cbm/extract_node_stack.h internal/cbm/extract_semantic.c internal/cbm/extract_type_assigns.c internal/cbm/extract_type_refs.c tests/test_extraction.c tests/test_mem.c +f046 src/cli/client_adapter.c tests/test_agent_clients.c +f047 graph-ui/src/hooks/useProjects.test.tsx graph-ui/src/hooks/useProjects.ts +f048 internal/cbm/cbm.c tests/test_parse_coverage.c +f049 graph-ui/src/App.test.tsx graph-ui/src/App.tsx src/ui/http_server.c tests/test_httpd.c +f050 src/daemon/application.c src/foundation/workspace.c src/foundation/workspace.h src/mcp/mcp.c src/ui/http_server.c tests/test_workspace.c +f051 README.md docs/CONFIGURATION.md src/cli/cli.c src/cli/cli.h src/daemon/ipc.c src/foundation/compat_fs.c src/foundation/compat_fs.h src/main.c tests/test_daemon_ipc.c +f052 src/traces/traces.c src/traces/traces.h tests/test_traces.c +f053 src/pipeline/registry.c tests/test_pipeline.c +f054 src/cli/cli.h src/cli/hook_augment.c src/foundation/platform.c src/foundation/platform.h src/mcp/index_supervisor.c src/mcp/mcp.c src/mcp/mcp.h tests/test_cli.c tests/test_platform.c +f055 tests/test_cli.c +f056 src/daemon/application.c src/mcp/mcp.c src/pipeline/pipeline.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c tests/test_daemon_application.c tests/test_mcp.c tests/test_pipeline.c +f057 internal/cbm/extract_defs.c src/discover/language.c tests/test_extraction.c tests/test_language.c +f058 src/cli/hook_augment.c src/daemon/runtime.c src/daemon/runtime.h src/foundation/sha256.c src/foundation/sha256.h src/main.c tests/test_cli.c tests/test_daemon_runtime.c +f059 tests/windows/test_cli_non_ascii_arg.py tests/windows/test_daemon_stability.py tests/windows/test_hook_augment.py +f060 src/cli/cli.c +f061 .github/workflows/_test.yml +f062 scripts/smoke-test.sh +f063 src/foundation/subprocess.c src/foundation/subprocess.h src/mcp/index_supervisor.c tests/test_main.c tests/test_subprocess.c +f064 graph-ui/src/components/EdgeLines.test.ts graph-ui/src/components/EdgeLines.tsx graph-ui/src/components/GraphTab.edgeSampling.test.tsx graph-ui/src/components/GraphTab.test.ts graph-ui/src/components/GraphTab.tsx graph-ui/src/components/NodeCloud.test.ts graph-ui/src/components/NodeCloud.tsx graph-ui/src/components/NodeDetailPanel.test.tsx graph-ui/src/components/NodeDetailPanel.tsx graph-ui/src/components/NodeLabels.tsx graph-ui/src/hooks/useGraphData.test.ts graph-ui/src/hooks/useGraphData.ts graph-ui/src/lib/edgeBudget.test.ts graph-ui/src/lib/edgeBudget.ts graph-ui/src/lib/graphIndex.test.ts graph-ui/src/lib/graphIndex.ts graph-ui/src/lib/topK.test.ts graph-ui/src/lib/topK.ts src/store/store.c src/store/store.h src/ui/http_server.c src/ui/layout3d.c src/ui/layout3d.h src/ui/layout3d_internal.h tests/test_httpd.c tests/test_store_edges.c tests/test_store_search.c tests/test_ui.c +f065 graph-ui/package-lock.json +f066 src/foundation/lock_registry.c src/foundation/lock_registry_internal.h tests/test_lock_registry.c +f067 src/daemon/version_cohort.c src/daemon/version_cohort.h tests/test_version_cohort.c +f068 internal/cbm/extract_defs.c tests/test_extraction_inheritance.c +f069 internal/cbm/extract_channels.c tests/test_extraction.c +f070 test-infrastructure/Dockerfile.msan +f071 src/git/git_context.c src/ui/http_server.c tests/test_daemon_application.c tests/test_git_context.c tests/test_httpd.c +f072 scripts/hooks/pre-commit +f073 tests/test_daemon_runtime.c +f074 src/cli/agent_profiles.c src/cli/client_adapter.c tests/test_agent_clients.c tests/test_agent_profiles.c +f075 internal/cbm/sqlite_writer.c src/graph_buffer/graph_buffer.c tests/test_graph_buffer.c +f076 scripts/run-test-wave.py tests/test_parallel_harness_contract.sh +f077 internal/cbm/extract_imports.c internal/cbm/lang_specs.c tests/test_edge_structural.c tests/test_extraction.c tests/test_grammar_labels.c +f078 internal/cbm/sqlite_writer.c src/graph_buffer/graph_buffer.c tests/test_graph_buffer.c tests/test_sqlite_writer.c +f079 src/foundation/dump_verify.c src/foundation/dump_verify.h tests/test_dump_verify.c +f080 Makefile.cbm scripts/ci/check-binary-composition.sh tests/test_mcpb_bundle_contract.sh tests/test_release_candidate_derivation_contract.sh +f081 scripts/smoke-test.sh src/cli/cli.c tests/test_cli.c tests/windows/test_hook_augment.py +f082 src/main.c tests/windows/test_daemon_lifecycle.py +f083 README.md docs/CONFIGURATION.md graph-ui/src/components/NodeDetailPanel.test.tsx graph-ui/src/components/NodeDetailPanel.tsx graph-ui/src/components/StatsTab.test.tsx graph-ui/src/hooks/useProjects.test.tsx graph-ui/src/hooks/useProjects.ts scripts/smoke-test.sh src/cli/cli.c src/cli/cli.h src/cli/client_adapter.c src/cli/hook_augment.c src/cli/progress_sink.c src/cli/progress_sink.h src/cypher/cypher.c src/cypher/cypher.h src/foundation/compat_fs.c src/foundation/compat_fs.h src/foundation/log.c src/foundation/log.h src/main.c src/mcp/compact_out.c src/mcp/compact_out.h src/mcp/mcp.c src/mcp/mcp.h src/mcp/mcp_internal.h src/pipeline/pipeline.c src/store/store.c src/store/store.h src/ui/http_server.c tests/scale_contract.sh tests/test_cli.c tests/test_cypher.c tests/test_daemon_runtime.c tests/test_diagnostics.c tests/test_httpd.c tests/test_incremental.c tests/test_index_resilience.c tests/test_index_supervisor.c tests/test_log.c tests/test_main.c tests/test_mcp.c tests/test_pipeline.c tests/test_store_pragmas.c tests/windows/test_daemon_lifecycle.py tests/windows/test_daemon_stability.py +f084 README.md docs/CONFIGURATION.md graph-ui/src/components/NodeDetailPanel.test.tsx graph-ui/src/components/NodeDetailPanel.tsx graph-ui/src/components/StatsTab.test.tsx graph-ui/src/hooks/useProjects.test.tsx graph-ui/src/hooks/useProjects.ts scripts/smoke-test.sh src/cli/cli.c src/cli/cli.h src/cli/client_adapter.c src/cli/hook_augment.c src/cli/progress_sink.c src/cli/progress_sink.h src/cypher/cypher.c src/cypher/cypher.h src/foundation/compat_fs.c src/foundation/compat_fs.h src/foundation/log.c src/foundation/log.h src/main.c src/mcp/compact_out.c src/mcp/compact_out.h src/mcp/mcp.c src/mcp/mcp.h src/mcp/mcp_internal.h src/pipeline/pipeline.c src/store/store.c src/store/store.h src/ui/http_server.c tests/scale_contract.sh tests/test_cli.c tests/test_cypher.c tests/test_daemon_runtime.c tests/test_diagnostics.c tests/test_httpd.c tests/test_incremental.c tests/test_index_resilience.c tests/test_index_supervisor.c tests/test_log.c tests/test_main.c tests/test_mcp.c tests/test_pipeline.c tests/test_store_pragmas.c tests/windows/test_daemon_lifecycle.py tests/windows/test_daemon_stability.py tests/windows/test_hook_augment.py +f085 src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_pipeline.c tests/test_registry.c +f086 src/cli/config_yaml_edit.c tests/test_config_yaml_edit.c +f087 src/foundation/compat_fs.c tests/test_cli.c tests/test_platform.c +f088 .github/workflows/release.yml +f089 .github/workflows/pages.yml +f090 src/store/store.c tests/test_store_nodes.c +f091 src/discover/discover.c tests/test_discover.c +f092 Makefile.cbm internal/cbm/extract_defs.c internal/cbm/helpers.c internal/cbm/helpers.h tests/repro/repro_issue1692.c tests/repro/repro_main.c +f093 src/mcp/mcp.c src/store/store.c tests/test_mcp.c tests/test_store_nodes.c +f094 tests/test_mcp.c +f095 src/mcp/mcp.c src/store/store.c src/store/store.h tests/test_mcp.c +f096 scripts/msan.sh scripts/test.sh tests/test_main.c +f097 src/daemon/ipc.c src/daemon/ipc.h tests/test_cli.c tests/test_daemon_ipc.c +f098 src/daemon/application.c src/mcp/mcp.c tests/test_daemon.c +f099 Makefile.cbm README.md graph-ui/index.html graph-ui/src/App.tsx graph-ui/src/components/ChangesTab.tsx graph-ui/src/components/CommandK.tsx graph-ui/src/components/ControlTab.tsx graph-ui/src/components/DashboardTab.tsx graph-ui/src/components/DisplaySettingsMenu.tsx graph-ui/src/components/ErrorBoundary.tsx graph-ui/src/components/FilterPanel.tsx graph-ui/src/components/FlowsTab.observed.test.tsx graph-ui/src/components/FlowsTab.tsx graph-ui/src/components/FlowsTab.zh.test.tsx graph-ui/src/components/GraphLoader.tsx graph-ui/src/components/GraphScene.tsx graph-ui/src/components/GraphTab.deadcode.test.tsx graph-ui/src/components/GraphTab.filters.test.tsx graph-ui/src/components/GraphTab.regions.test.tsx graph-ui/src/components/GraphTab.selection.test.tsx graph-ui/src/components/GraphTab.tsx graph-ui/src/components/HaloLayer.tsx graph-ui/src/components/MetricChip.tsx graph-ui/src/components/Minimap.tsx graph-ui/src/components/MissedCallout.tsx graph-ui/src/components/ModulesTab.tsx graph-ui/src/components/NodeDetailPanel.showmore.test.tsx graph-ui/src/components/NodeDetailPanel.tsx graph-ui/src/components/NodeTooltip.tsx graph-ui/src/components/OverviewTab.tsx graph-ui/src/components/ProjectCard.tsx graph-ui/src/components/ProjectSwitcher.tsx graph-ui/src/components/PromptBasket.tsx graph-ui/src/components/RegionPanel.tsx graph-ui/src/components/RegionTooltip.tsx graph-ui/src/components/Sidebar.tsx graph-ui/src/components/StatsTab.tsx graph-ui/src/components/SymbolTab.tsx graph-ui/src/components/TriggerTree.tsx graph-ui/src/components/WikiHoverCard.test.tsx graph-ui/src/components/WikiHoverCard.tsx graph-ui/src/components/WikiPanel.test.tsx graph-ui/src/components/WikiPanel.tsx graph-ui/src/components/WikiPanel.zh.test.tsx graph-ui/src/components/wikiTier.ts graph-ui/src/hooks/useGraphData.ts graph-ui/src/hooks/useProjects.test.tsx graph-ui/src/hooks/useProjects.ts graph-ui/src/lib/atlas.test.ts graph-ui/src/lib/atlas.ts graph-ui/src/lib/complexity.test.ts graph-ui/src/lib/complexity.ts graph-ui/src/lib/composer.ts graph-ui/src/lib/density.test.ts graph-ui/src/lib/density.ts graph-ui/src/lib/findings.test.ts graph-ui/src/lib/findings.ts graph-ui/src/lib/firstread.test.ts graph-ui/src/lib/firstread.ts graph-ui/src/lib/flowgroup.test.ts graph-ui/src/lib/flowgroup.ts graph-ui/src/lib/i18n.ts graph-ui/src/lib/impact.test.ts graph-ui/src/lib/impact.ts graph-ui/src/lib/observed.test.ts graph-ui/src/lib/observed.ts graph-ui/src/lib/questions.test.ts graph-ui/src/lib/questions.ts graph-ui/src/lib/rationale.test.ts graph-ui/src/lib/rationale.ts graph-ui/src/lib/regions.test.ts graph-ui/src/lib/regions.ts graph-ui/src/lib/treemap.ts graph-ui/src/lib/types.ts graph-ui/src/lib/who.test.ts graph-ui/src/lib/who.ts graph-ui/src/lib/why.test.ts graph-ui/src/lib/why.ts graph-ui/src/lib/whyfetch.ts graph-ui/src/styles/globals.css graph-ui/src/test/setup.ts graph-ui/src/wiki/entries.test.ts graph-ui/src/wiki/entries.ts graph-ui/tsconfig.tsbuildinfo graph-ui/vite.config.ts scripts/run-test-wave.py scripts/security-ui.sh src/main.c src/mcp/mcp.c src/pipeline/pipeline.h src/pipeline/pipeline_internal.h src/store/store.c src/store/store.h src/ui/atlas.h src/ui/atlas_api.c src/ui/atlas_flows.c src/ui/atlas_handout.c src/ui/atlas_metrics.c src/ui/atlas_why.c src/ui/http_server.c src/ui/layout3d.c src/ui/layout3d.h src/ui/layout_internal.h src/ui/layout_regions.c src/watcher/watcher.c src/watcher/watcher.h test-infrastructure/docker-compose.yml test-infrastructure/ladder.sh test-infrastructure/run.sh tests/test_cypher.c tests/test_daemon_runtime.c tests/test_httpd.c tests/test_incremental.c tests/test_mcp.c tests/test_py_lsp_scale.c tests/test_ui.c tests/test_watcher.c +f100 README.md docs/CONFIGURATION.md scripts/test_mcp_in_process.py src/main.c +f101 tests/test_pipeline.c +f102 src/daemon/application.c src/daemon/application_internal.h tests/test_daemon.c tests/test_daemon_application.c +f103 src/discover/discover.c src/discover/discover.h src/discover/language.c tests/test_language.c +f104 src/watcher/watcher.c src/watcher/watcher.h tests/test_watcher.c +f105 scripts/test.sh src/main.c tests/test_worker_session_scope.sh +f106 src/pipeline/pass_complexity.c tests/fixtures/complexity_pass_cycle_order/p00_a.py tests/fixtures/complexity_pass_cycle_order/p00_b.py tests/fixtures/complexity_pass_cycle_order/p01_a.py tests/fixtures/complexity_pass_cycle_order/p01_b.py tests/fixtures/complexity_pass_cycle_order/p02_a.py tests/fixtures/complexity_pass_cycle_order/p02_b.py tests/fixtures/complexity_pass_cycle_order/p03_a.py tests/fixtures/complexity_pass_cycle_order/p03_b.py tests/fixtures/complexity_pass_cycle_order/p04_a.py tests/fixtures/complexity_pass_cycle_order/p04_b.py tests/fixtures/complexity_pass_cycle_order/p05_a.py tests/fixtures/complexity_pass_cycle_order/p05_b.py tests/fixtures/complexity_pass_cycle_order/p06_a.py tests/fixtures/complexity_pass_cycle_order/p06_b.py tests/fixtures/complexity_pass_cycle_order/p07_a.py tests/fixtures/complexity_pass_cycle_order/p07_b.py tests/fixtures/complexity_pass_cycle_order/p08_a.py tests/fixtures/complexity_pass_cycle_order/p08_b.py tests/fixtures/complexity_pass_cycle_order/p09_a.py tests/fixtures/complexity_pass_cycle_order/p09_b.py tests/fixtures/complexity_pass_cycle_order/p10_a.py tests/fixtures/complexity_pass_cycle_order/p10_b.py tests/fixtures/complexity_pass_cycle_order/p11_a.py tests/fixtures/complexity_pass_cycle_order/p11_b.py tests/fixtures/complexity_pass_cycle_order/p12_a.py tests/fixtures/complexity_pass_cycle_order/p12_b.py tests/fixtures/complexity_pass_cycle_order/p13_a.py tests/fixtures/complexity_pass_cycle_order/p13_b.py tests/fixtures/complexity_pass_cycle_order/p14_a.py tests/fixtures/complexity_pass_cycle_order/p14_b.py tests/fixtures/complexity_pass_cycle_order/p15_a.py tests/fixtures/complexity_pass_cycle_order/p15_b.py tests/fixtures/complexity_pass_cycle_order/p16_a.py tests/fixtures/complexity_pass_cycle_order/p16_b.py tests/fixtures/complexity_pass_cycle_order/p17_a.py tests/fixtures/complexity_pass_cycle_order/p17_b.py tests/fixtures/complexity_pass_cycle_order/p18_a.py tests/fixtures/complexity_pass_cycle_order/p18_b.py tests/fixtures/complexity_pass_cycle_order/p19_a.py tests/fixtures/complexity_pass_cycle_order/p19_b.py tests/fixtures/complexity_pass_cycle_order/p20_a.py tests/fixtures/complexity_pass_cycle_order/p20_b.py tests/fixtures/complexity_pass_cycle_order/p21_a.py tests/fixtures/complexity_pass_cycle_order/p21_b.py tests/fixtures/complexity_pass_cycle_order/p22_a.py tests/fixtures/complexity_pass_cycle_order/p22_b.py tests/fixtures/complexity_pass_cycle_order/p23_a.py tests/fixtures/complexity_pass_cycle_order/p23_b.py tests/fixtures/complexity_pass_cycle_order/t00_x.py tests/fixtures/complexity_pass_cycle_order/t00_y.py tests/fixtures/complexity_pass_cycle_order/t00_z.py tests/fixtures/complexity_pass_cycle_order/t01_x.py tests/fixtures/complexity_pass_cycle_order/t01_y.py tests/fixtures/complexity_pass_cycle_order/t01_z.py tests/fixtures/complexity_pass_cycle_order/t02_x.py tests/fixtures/complexity_pass_cycle_order/t02_y.py tests/fixtures/complexity_pass_cycle_order/t02_z.py tests/fixtures/complexity_pass_cycle_order/t03_x.py tests/fixtures/complexity_pass_cycle_order/t03_y.py tests/fixtures/complexity_pass_cycle_order/t03_z.py tests/fixtures/complexity_pass_cycle_order/t04_x.py tests/fixtures/complexity_pass_cycle_order/t04_y.py tests/fixtures/complexity_pass_cycle_order/t04_z.py tests/fixtures/complexity_pass_cycle_order/t05_x.py tests/fixtures/complexity_pass_cycle_order/t05_y.py tests/fixtures/complexity_pass_cycle_order/t05_z.py tests/test_pipeline.c +f107 docs/benchmarks/bounded-two-pass-results.json docs/benchmarks/bounded-two-pass.md scripts/benchmark-streaming.py src/pipeline/lsp_surface.c src/pipeline/lsp_surface.h src/pipeline/pass_parallel.c src/pipeline/pipeline.c src/pipeline/pipeline_internal.h tests/test_pipeline.c +f108 src/pipeline/pass_semantic_edges.c tests/test_pipeline.c +f109 .github/workflows/pr.yml internal/cbm/cbm.c scripts/ci/README.md scripts/ci/coverage-gate-allowlist.txt scripts/ci/parse-partial-baseline.txt scripts/ci/self-index-coverage-gate.sh scripts/test.sh tests/test_coverage_gate_contract.sh tests/test_parse_coverage.c tests/test_venue_parity_contract.sh +f110 Makefile.cbm README.md docs/CONFIGURATION.md docs/INDEX_RESOURCE_LIMITS.md scripts/test-runtime.sh src/cli/cli.c src/cli/cli.h src/daemon/application.c src/discover/discover.c src/discover/discover.h src/foundation/index_policy.c src/foundation/index_policy.h src/foundation/subprocess.c src/foundation/subprocess.h src/mcp/index_supervisor.c src/mcp/index_supervisor.h src/mcp/mcp.c src/mcp/mcp.h src/mcp/mcp_internal.h src/pipeline/pipeline.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_daemon_application.c tests/test_discover.c tests/test_index_policy.c tests/test_index_supervisor.c tests/test_main.c tests/test_mcp.c tests/test_pipeline.c tests/test_subprocess.c tests/test_worker_error_response.sh tests/test_worker_watchdog.sh +f111 Makefile.cbm README.md docs/CONFIGURATION.md docs/INDEX_RESOURCE_LIMITS.md scripts/test-runtime.sh src/cli/cli.c src/cli/cli.h src/daemon/application.c src/daemon/bootstrap.c src/discover/discover.c src/discover/discover.h src/foundation/compat_fs.c src/foundation/compat_fs.h src/foundation/index_policy.c src/foundation/index_policy.h src/foundation/subprocess.c src/foundation/subprocess.h src/main.c src/mcp/index_supervisor.c src/mcp/index_supervisor.h src/mcp/mcp.c src/mcp/mcp.h src/mcp/mcp_internal.h src/pipeline/pipeline.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_daemon_application.c tests/test_discover.c tests/test_index_policy.c tests/test_index_supervisor.c tests/test_main.c tests/test_mcp.c tests/test_pipeline.c tests/test_platform.c tests/test_subprocess.c tests/test_worker_error_response.sh tests/test_worker_watchdog.sh +f112 Makefile.cbm README.md docs/CONFIGURATION.md docs/INDEX_RESOURCE_LIMITS.md scripts/test-runtime.sh src/cli/cli.c src/cli/cli.h src/daemon/application.c src/daemon/bootstrap.c src/discover/discover.c src/discover/discover.h src/foundation/compat_fs.c src/foundation/compat_fs.h src/foundation/index_policy.c src/foundation/index_policy.h src/foundation/subprocess.c src/foundation/subprocess.h src/git/git_context.c src/git/git_context.h src/main.c src/mcp/index_supervisor.c src/mcp/index_supervisor.h src/mcp/mcp.c src/mcp/mcp.h src/mcp/mcp_internal.h src/pipeline/pipeline.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_daemon_application.c tests/test_discover.c tests/test_git_context.c tests/test_index_policy.c tests/test_index_supervisor.c tests/test_main.c tests/test_mcp.c tests/test_pipeline.c tests/test_platform.c tests/test_subprocess.c tests/test_worker_error_response.sh tests/test_worker_watchdog.sh +f113 src/pipeline/pipeline_incremental.c tests/test_pipeline.c +f114 README.md THIRD_PARTY.md docs/index.html docs/llms.txt internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/grammar_vba.c internal/cbm/helpers.c internal/cbm/lang_specs.c internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/vba/LICENSE internal/cbm/vendored/grammars/vba/parser.c internal/cbm/vendored/grammars/vba/tree_sitter/alloc.h internal/cbm/vendored/grammars/vba/tree_sitter/array.h internal/cbm/vendored/grammars/vba/tree_sitter/parser.h pkg/chocolatey/codebase-memory-mcp.nuspec pkg/npm/README.md scripts/new-languages.json scripts/package-release.sh scripts/vendored-checksums.txt server.json src/discover/discover.h src/discover/language.c src/discover/userconfig.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_manifest.c tests/repro/repro_invariant_breadth.c tests/repro/repro_language_registry.c tests/test_extraction.c tests/test_grammar_labels.c tests/test_grammar_regression.c tests/test_lang_contract.c tests/test_language.c +f115 README.md THIRD_PARTY.md docs/index.html docs/llms.txt internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/extract_unified.c internal/cbm/extract_unified.h internal/cbm/grammar_vba.c internal/cbm/helpers.c internal/cbm/helpers.h internal/cbm/lang_specs.c internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/vba/LICENSE internal/cbm/vendored/grammars/vba/parser.c internal/cbm/vendored/grammars/vba/tree_sitter/alloc.h internal/cbm/vendored/grammars/vba/tree_sitter/array.h internal/cbm/vendored/grammars/vba/tree_sitter/parser.h pkg/chocolatey/codebase-memory-mcp.nuspec pkg/npm/README.md scripts/new-languages.json scripts/package-release.sh scripts/vendored-checksums.txt server.json src/discover/discover.h src/discover/language.c src/discover/userconfig.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_manifest.c tests/repro/repro_invariant_breadth.c tests/repro/repro_language_registry.c tests/test_extraction.c tests/test_grammar_labels.c tests/test_grammar_regression.c tests/test_lang_contract.c tests/test_language.c +f116 internal/cbm/lang_specs.c +f117 internal/cbm/extract_node_stack.h internal/cbm/lsp/ts_lsp.c tests/test_ts_lsp.c +f118 .cbmignore src/cli/cli.c src/cli/cli.h src/main.c src/mcp/mcp.c +f119 .cbmignore src/cli/cli.c src/cli/cli.h src/main.c src/mcp/mcp.c tests/test_cli.c +f120 src/git/git_context.c src/git/git_context.h src/mcp/mcp.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/store/store.c src/store/store.h tests/test_git_context.c tests/test_mcp.c tests/test_store_nodes.c +f121 src/watcher/watcher.c tests/test_watcher.c +f122 scripts/run-tests-parallel.sh src/git/git_context.c src/git/git_context.h src/mcp/mcp.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/store/store.c src/store/store.h tests/test_git_context.c tests/test_mcp.c tests/test_store_nodes.c +f123 scripts/test-windows.ps1 tests/windows/test_mcp_stdio.py +f124 internal/cbm/extract_defs.c tests/test_grammar_labels.c tests/test_grammar_regression.c +f125 src/cli/cli.c tests/test_cli.c +f126 src/cli/cli.c src/cli/config_json_like.c src/cli/hook_augment.c src/daemon/application.c src/foundation/compat_fs.c src/foundation/compat_fs.h src/main.c src/mcp/index_supervisor.c src/mcp/mcp.c src/pipeline/artifact.c src/pipeline/pipeline.c src/ui/config.c tests/test_artifact.c tests/test_cli.c tests/test_platform.c +f127 tests/test_daemon_frontend.c +f128 src/daemon/ipc.c src/daemon/ipc_internal.h tests/test_daemon_ipc.c +f129 .github/dependabot.yml .github/workflows/_build.yml .github/workflows/_lint.yml .github/workflows/_test.yml .github/workflows/release.yml .node-version CONTRIBUTING.md scripts/ci/lint-workflows.sh scripts/ci/test-frontend.sh tests/test_venue_parity_contract.sh +f130 src/foundation/compat_fs.c src/foundation/compat_fs.h src/pipeline/pipeline.c src/pipeline/pipeline_internal.h tests/test_pipeline.c +f131 src/daemon/application.c src/mcp/mcp.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/pipeline_internal.h tests/test_daemon_application.c tests/test_incremental.c tests/test_mcp.c tests/test_pipeline.c +f132 src/daemon/bootstrap.c src/daemon/bootstrap.h src/daemon/host.c src/daemon/ipc.c src/daemon/ipc.h src/daemon/ipc_internal.h src/foundation/platform.c src/foundation/platform.h tests/test_daemon_bootstrap.c tests/test_daemon_ipc.c +f133 Makefile.cbm src/cli/cli.c src/cli/config_edit_path.c src/cli/config_edit_path.h src/cli/config_json_like.c src/cli/config_text_edit.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c tests/test_cli.c tests/test_config_json_like.c tests/test_config_text_edit.c tests/test_config_toml_edit.c tests/test_config_yaml_edit.c +f134 src/cli/cli.c src/mcp/index_supervisor.c src/mcp/index_supervisor.h tests/test_cli.c +f135 src/cli/cli.c src/cli/config_json_like.c src/cli/config_json_like.h src/mcp/mcp.c tests/test_cli.c tests/test_config_json_like.c tests/test_mcp.c +f136 src/cli/cli.c tests/test_cli.c tests/test_smoke_fixture_contract.sh +f137 .github/workflows/_lint.yml .github/workflows/_test.yml +f138 src/daemon/ipc.c src/daemon/ipc.h tests/test_daemon_ipc.c +f139 src/cli/activation_transaction.c src/daemon/ipc.c src/daemon/ipc.h tests/test_daemon_ipc.c +f140 src/cli/cli.h src/main.c tests/test_cli.c +f141 internal/cbm/extract_defs.c tests/test_edge_types_probe.c tests/test_extraction.c +f142 internal/cbm/lsp/php_lsp.c +f143 internal/cbm/lsp/php_lsp.c src/pipeline/registry.c +f144 src/cli/cli.c src/cli/cli.h src/main.c src/mcp/mcp.c tests/test_cli.c +f145 README.md docs/UPSTREAM-SYNC.md docs/lsp-uplift/PERL-CROSS-FILE-INHERITANCE.md docs/lsp-uplift/PERL-DUCK-TYPING-AND-CROSS-LANG-AUDIT.md docs/lsp-uplift/PLAN.md docs/lsp-uplift/RESOLVER-PLAYBOOK.md docs/lsp-uplift/SOP.md internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/extract_unified.c internal/cbm/helpers.c internal/cbm/lang_specs.c internal/cbm/lsp/generated/perl_stdlib_data.c internal/cbm/lsp/generated/rust_crates_seed.c internal/cbm/lsp/go_lsp.c internal/cbm/lsp/go_lsp.h internal/cbm/lsp/go_stdlib_modern.c internal/cbm/lsp/java_lsp.c internal/cbm/lsp/perl_lsp.c internal/cbm/lsp/perl_lsp.h internal/cbm/lsp/py_builtins.c internal/cbm/lsp/py_lsp.c internal/cbm/lsp/py_stdlib_compat.c internal/cbm/lsp/rust_cargo.c internal/cbm/lsp/rust_cargo.h internal/cbm/lsp/rust_lsp.c internal/cbm/lsp/rust_lsp.h internal/cbm/lsp/type_registry.h internal/cbm/lsp_all.c internal/cbm/service_patterns.c internal/cbm/service_patterns.h src/discover/language.c src/pipeline/lsp_surface.c src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_lsp_cross.c src/pipeline/pass_lsp_cross.h src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pass_route_nodes.c src/pipeline/pass_semantic.c src/pipeline/pass_tests.c src/pipeline/pipeline.c src/pipeline/registry.c tests/test_extraction.c tests/test_go_lsp.c tests/test_java_lsp.c tests/test_language.c tests/test_perl_lsp.c tests/test_pipeline.c tests/test_py_lsp.c tests/test_registry.c tests/test_route_canon.c tests/test_rust_lsp.c +f146 src/foundation/subprocess.c src/foundation/subprocess.h src/mcp/index_supervisor.c src/mcp/index_supervisor.h tests/test_index_supervisor.c tests/test_main.c tests/test_subprocess.c +f147 src/daemon/daemon.c src/daemon/daemon.h src/daemon/host.c src/daemon/runtime.c src/daemon/runtime.h tests/test_daemon_runtime.c +f148 internal/cbm/cbm.c src/foundation/compat.c src/foundation/compat.h tests/test_edge_types_probe.c +f149 scripts/test-windows.ps1 tests/windows/mcp_stdio.py tests/windows/test_mcp_stdio.py +f150 .github/workflows/codeql.yml +f151 .github/workflows/scorecard.yml +f152 src/cli/cli.c src/foundation/sha256.c src/foundation/sha256.h src/mcp/index_supervisor.c src/mcp/index_supervisor.h tests/test_cli.c +f153 install.ps1 scripts/test.sh src/cli/activation_transaction.c src/cli/cli.c tests/test_activation_diagnostic_contract.sh tests/test_windows_bundle_contract.sh +f154 scripts/run-test-wave.py src/cli/cli.c src/foundation/sha256.c src/foundation/sha256.h src/mcp/index_supervisor.c src/mcp/index_supervisor.h tests/test_cli.c +f155 .github/workflows/_security.yml graph-ui/package-lock.json graph-ui/package.json scripts/test.sh tests/test_security_gate_fail_closed.sh +f156 graph-ui/package-lock.json graph-ui/package.json +f157 .github/workflows/_security.yml .github/workflows/pr.yml .github/workflows/release.yml graph-ui/package-lock.json graph-ui/package.json scripts/test.sh tests/test_security_gate_fail_closed.sh +f158 tests/test_fqn.c +f159 .github/workflows/dry-run.yml tests/test_security_gate_fail_closed.sh +f160 tests/test_daemon_ipc.c tests/test_main.c +f161 src/daemon/ipc.c tests/test_daemon_ipc.c tests/test_main.c +f162 .github/workflows/_test.yml src/daemon/ipc.c src/daemon/ipc.h tests/test_daemon_ipc.c tests/test_main.c +f163 scripts/soak-test.sh scripts/test.sh tests/test_soak_runtime_isolation_contract.sh +f164 scripts/memlab.sh scripts/test.sh tests/test_memlab_runtime_isolation_contract.sh +f165 scripts/benchmark-index.sh scripts/benchmark-search-graph.sh scripts/test.sh tests/test_benchmark_runtime_isolation_contract.sh +f166 scripts/smoke-test.sh scripts/test.sh tests/test_smoke_runtime_isolation_contract.sh +f167 Makefile.cbm scripts/lint-memory-core.py scripts/memory-core-baseline.txt src/foundation/mem.c src/foundation/mem.h src/foundation/mem_core.c src/foundation/mem_core.h src/foundation/platform.h src/foundation/system_info.c src/pipeline/pipeline.c tests/test_mem.c +f168 src/cli/activation_transaction.c +f169 Makefile.cbm internal/cbm/arena.h internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_unified.c internal/cbm/lsp/c_lsp.c internal/cbm/lsp/c_lsp.h internal/cbm/lsp/cs_lsp.c internal/cbm/lsp/go_lsp.c internal/cbm/lsp/py_lsp.c internal/cbm/lsp/py_lsp.h internal/cbm/lsp/type_registry.c internal/cbm/lsp/type_registry.h internal/cbm/result_compact.c internal/cbm/result_spill.c internal/cbm/result_spill.h scripts/lint-memory-core.py scripts/memory-core-baseline.txt src/foundation/arena.c src/foundation/arena.h src/foundation/dyn_array.h src/foundation/hash_table.c src/foundation/hash_table.h src/foundation/mem.c src/foundation/mem.h src/foundation/mem_core.c src/foundation/mem_core.h src/foundation/platform.h src/foundation/slab_alloc.c src/foundation/system_info.c src/graph_buffer/graph_buffer.c src/graph_buffer/graph_buffer.h src/mcp/mcp.c src/pipeline/lsp_surface.c src/pipeline/lsp_surface.h src/pipeline/pass_complexity.c src/pipeline/pass_importance.c src/pipeline/pass_lsp_cross.c src/pipeline/pass_lsp_cross.h src/pipeline/pass_parallel.c src/pipeline/pass_semantic.c src/pipeline/pass_semantic_edges.c src/pipeline/pass_similarity.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/pipeline/worker_pool.c src/semantic/semantic.c src/semantic/semantic.h src/simhash/minhash.c tests/test_arena.c tests/test_c_lsp.c tests/test_extraction.c tests/test_mcp.c tests/test_mem.c tests/test_parallel.c tests/test_pipeline.c +f170 docs/CONFIGURATION.md src/main.c tests/test_daemon_open_readiness.py +f171 src/daemon/ipc.c tests/test_daemon_ipc.c +f172 src/cli/hook_augment.c src/daemon/runtime.c src/daemon/runtime.h src/main.c tests/test_cli.c tests/test_daemon_runtime.c +f173 Makefile.cbm internal/cbm/arena.h internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_unified.c internal/cbm/lsp/c_lsp.c internal/cbm/lsp/c_lsp.h internal/cbm/lsp/cs_lsp.c internal/cbm/lsp/go_lsp.c internal/cbm/lsp/py_lsp.c internal/cbm/lsp/py_lsp.h internal/cbm/lsp/type_registry.c internal/cbm/lsp/type_registry.h internal/cbm/result_compact.c internal/cbm/result_spill.c internal/cbm/result_spill.h scripts/lint-memory-core.py scripts/memory-core-baseline.txt src/foundation/arena.c src/foundation/arena.h src/foundation/diagnostics.c src/foundation/dyn_array.h src/foundation/hash_table.c src/foundation/hash_table.h src/foundation/mem.c src/foundation/mem.h src/foundation/mem_core.c src/foundation/mem_core.h src/foundation/platform.h src/foundation/slab_alloc.c src/foundation/system_info.c src/graph_buffer/graph_buffer.c src/graph_buffer/graph_buffer.h src/main.c src/mcp/mcp.c src/pipeline/lsp_surface.c src/pipeline/lsp_surface.h src/pipeline/pass_complexity.c src/pipeline/pass_importance.c src/pipeline/pass_lsp_cross.c src/pipeline/pass_lsp_cross.h src/pipeline/pass_parallel.c src/pipeline/pass_semantic.c src/pipeline/pass_semantic_edges.c src/pipeline/pass_similarity.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/pipeline/worker_pool.c src/semantic/semantic.c src/semantic/semantic.h src/simhash/minhash.c tests/test_arena.c tests/test_c_lsp.c tests/test_extraction.c tests/test_graph_buffer.c tests/test_mcp.c tests/test_mem.c tests/test_parallel.c tests/test_pipeline.c +f174 README.md scripts/smoke-test.sh src/cli/cli.c src/cli/cli.h tests/test_cli.c +f175 src/foundation/mem.c src/foundation/mem.h +f176 flake.nix pkg/chocolatey/codebase-memory-mcp.nuspec pkg/chocolatey/tools/chocolateyInstall.ps1 pkg/go/cmd/codebase-memory-mcp/main.go pkg/npm/package.json pkg/pypi/pyproject.toml pkg/pypi/src/codebase_memory_mcp/_cli.py server.json +f177 graph-ui/src/components/FilterPanel.tsx graph-ui/src/components/GraphTab.deadcode.test.tsx graph-ui/src/lib/colors.ts +f178 CONTRIBUTING.md README.md docs/index.html pkg/npm/README.md +f179 internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/service_patterns.c internal/cbm/service_patterns.h src/cli/cli.c src/cypher/cypher.c src/foundation/str_util.c src/foundation/str_util.h src/mcp/mcp.c src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_cli.c tests/test_cypher.c tests/test_extraction.c tests/test_infrascan.c tests/test_mcp.c tests/test_parse_coverage.c tests/test_pipeline.c tests/test_registry.c tests/test_str_util.c +f180 internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/service_patterns.c internal/cbm/service_patterns.h scripts/memory-core-baseline.txt src/cli/cli.c src/cypher/cypher.c src/foundation/str_util.c src/foundation/str_util.h src/mcp/mcp.c src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_cli.c tests/test_cypher.c tests/test_extraction.c tests/test_infrascan.c tests/test_mcp.c tests/test_parse_coverage.c tests/test_pipeline.c tests/test_registry.c tests/test_str_util.c +f181 src/discover/language.c tests/test_language.c +f182 .github/workflows/_memwaste.yml .github/workflows/dry-run.yml .github/workflows/nightly-soak.yml .github/workflows/pr.yml .github/workflows/release.yml Makefile.cbm internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_node_stack.h internal/cbm/lsp/go_lsp.c internal/cbm/result_compact.c internal/cbm/sqlite_writer.c scripts/fuzz.sh scripts/memlab-drive.py scripts/memlab-report.py scripts/memlab.sh scripts/memwaste-padding.py scripts/memwaste-ratchet.py scripts/memwaste-report.py scripts/memwaste-scaling.py scripts/memwaste.sh scripts/sanitizer-coverage-map.py scripts/sanitizer-map.json src/foundation/arena.c src/foundation/arena.h src/foundation/compat_thread.c src/foundation/dyn_array.h src/foundation/hash_table.c src/foundation/mem.c src/foundation/mem.h src/foundation/mem_core.c src/foundation/mem_events.c src/foundation/mem_events.h src/foundation/mem_override_darwin.c src/foundation/mem_override_libc.c src/foundation/mem_override_posix.c src/foundation/mem_override_win.c src/foundation/mem_profile.c src/foundation/mem_profile.h src/main.c src/pipeline/lsp_surface.c src/pipeline/pass_configlink.c src/pipeline/pass_k8s.c src/pipeline/pass_lsp_cross.c src/pipeline/pass_lsp_cross.h src/pipeline/pass_parallel.c src/pipeline/pass_semantic.c src/pipeline/pass_semantic_edges.c src/pipeline/pass_similarity.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/registry.c src/pipeline/worker_pool.c src/semantic/semantic.c src/semantic/semantic.h src/simhash/minhash.c src/simhash/minhash.h tests/fuzz/cbm_fuzz.c tests/fuzz/corpus/config/json-empty.seed tests/fuzz/corpus/config/json.seed tests/fuzz/corpus/config/toml.seed tests/fuzz/corpus/config/yaml.seed tests/fuzz/corpus/cypher/agg.seed tests/fuzz/corpus/cypher/hops.seed tests/fuzz/corpus/cypher/match.seed tests/fuzz/corpus/cypher/optional.seed tests/fuzz/corpus/cypher/union.seed tests/fuzz/corpus/cypher/where.seed tests/fuzz/corpus/extract/c.seed tests/fuzz/corpus/extract/cpp.seed tests/fuzz/corpus/extract/csharp.seed tests/fuzz/corpus/extract/go.seed tests/fuzz/corpus/extract/java.seed tests/fuzz/corpus/extract/javascript.seed tests/fuzz/corpus/extract/makefile.seed tests/fuzz/corpus/extract/php.seed tests/fuzz/corpus/extract/python.seed tests/fuzz/corpus/extract/ruby.seed tests/fuzz/corpus/extract/rust.seed tests/fuzz/corpus/extract/sql.seed tests/fuzz/corpus/extract/typescript.seed tests/fuzz/corpus/extract/yaml.seed tests/test_arena.c tests/test_extraction.c tests/test_hash_table.c tests/test_main.c tests/test_mem.c tests/test_mem_events.c tests/test_registry.c tests/test_semantic.c tests/test_shell_line_endings.sh tests/test_simhash.c tests/test_venue_parity_contract.sh +f183 src/cli/cli.h src/main.c tests/test_cli.c tests/test_daemon_bootstrap.c +f184 src/cli/cli.h src/main.c tests/test_cli.c tests/test_daemon_bootstrap.c tests/test_mcp.c +f185 src/ui/layout3d.c tests/test_ui.c +f186 Makefile.cbm README.md src/daemon/application.c src/daemon/bootstrap.c src/foundation/mem_core.c src/foundation/mem_core.h src/main.c src/mcp/cache.c src/mcp/cache.h src/mcp/mcp.c tests/test_cache.c tests/test_cache_cli.py tests/test_daemon_application.c tests/test_daemon_bootstrap.c tests/test_main.c tests/test_mcp.c +f187 .github/workflows/_memwaste.yml .github/workflows/dry-run.yml .github/workflows/nightly-soak.yml .github/workflows/pr.yml .github/workflows/release.yml Makefile.cbm internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_node_stack.h internal/cbm/lsp/go_lsp.c internal/cbm/result_compact.c internal/cbm/result_spill.c internal/cbm/result_spill.h internal/cbm/sqlite_writer.c scripts/fuzz.sh scripts/memlab-drive.py scripts/memlab-report.py scripts/memlab.sh scripts/memory-core-baseline.txt scripts/memwaste-padding.py scripts/memwaste-ratchet.py scripts/memwaste-report.py scripts/memwaste-scaling.py scripts/memwaste.sh scripts/sanitizer-coverage-map.py scripts/sanitizer-map.json scripts/smoke-test.sh scripts/test.sh src/foundation/arena.c src/foundation/arena.h src/foundation/compat_thread.c src/foundation/dyn_array.h src/foundation/hash_table.c src/foundation/mem.c src/foundation/mem.h src/foundation/mem_core.c src/foundation/mem_events.c src/foundation/mem_events.h src/foundation/mem_override_darwin.c src/foundation/mem_override_libc.c src/foundation/mem_override_posix.c src/foundation/mem_override_win.c src/foundation/mem_profile.c src/foundation/mem_profile.h src/foundation/platform.h src/foundation/system_info.c src/graph_buffer/graph_buffer.c src/main.c src/pipeline/lsp_surface.c src/pipeline/pass_configlink.c src/pipeline/pass_k8s.c src/pipeline/pass_lsp_cross.c src/pipeline/pass_lsp_cross.h src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pass_route_nodes.c src/pipeline/pass_semantic.c src/pipeline/pass_semantic_edges.c src/pipeline/pass_similarity.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/pipeline/registry.c src/pipeline/worker_pool.c src/semantic/semantic.c src/semantic/semantic.h src/simhash/minhash.c src/simhash/minhash.h tests/fuzz/cbm_fuzz.c tests/fuzz/corpus/config/json-empty.seed tests/fuzz/corpus/config/json.seed tests/fuzz/corpus/config/toml.seed tests/fuzz/corpus/config/yaml.seed tests/fuzz/corpus/cypher/agg.seed tests/fuzz/corpus/cypher/hops.seed tests/fuzz/corpus/cypher/match.seed tests/fuzz/corpus/cypher/optional.seed tests/fuzz/corpus/cypher/union.seed tests/fuzz/corpus/cypher/where.seed tests/fuzz/corpus/extract/c.seed tests/fuzz/corpus/extract/cpp.seed tests/fuzz/corpus/extract/csharp.seed tests/fuzz/corpus/extract/go.seed tests/fuzz/corpus/extract/java.seed tests/fuzz/corpus/extract/javascript.seed tests/fuzz/corpus/extract/makefile.seed tests/fuzz/corpus/extract/php.seed tests/fuzz/corpus/extract/python.seed tests/fuzz/corpus/extract/ruby.seed tests/fuzz/corpus/extract/rust.seed tests/fuzz/corpus/extract/sql.seed tests/fuzz/corpus/extract/typescript.seed tests/fuzz/corpus/extract/yaml.seed tests/test_arena.c tests/test_extraction.c tests/test_hash_table.c tests/test_main.c tests/test_mem.c tests/test_mem_events.c tests/test_parallel.c tests/test_pipeline.c tests/test_registry.c tests/test_semantic.c tests/test_shell_line_endings.sh tests/test_simhash.c tests/test_thread_stack_tls_contract.sh tests/test_venue_parity_contract.sh +f188 .github/workflows/_memwaste.yml .github/workflows/dry-run.yml .github/workflows/nightly-soak.yml .github/workflows/pr.yml .github/workflows/release.yml Makefile.cbm internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_node_stack.h internal/cbm/extract_unified.c internal/cbm/extract_usages.c internal/cbm/lsp/go_lsp.c internal/cbm/result_compact.c internal/cbm/result_spill.c internal/cbm/result_spill.h internal/cbm/sqlite_writer.c internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/properties/scanner.c scripts/fuzz.sh scripts/memlab-drive.py scripts/memlab-report.py scripts/memlab.sh scripts/memory-core-baseline.txt scripts/memwaste-padding.py scripts/memwaste-ratchet.py scripts/memwaste-report.py scripts/memwaste-scaling.py scripts/memwaste.sh scripts/sanitizer-coverage-map.py scripts/sanitizer-map.json scripts/smoke-test.sh scripts/test.sh scripts/vendored-checksums.txt src/foundation/arena.c src/foundation/arena.h src/foundation/compat_thread.c src/foundation/dyn_array.h src/foundation/hash_table.c src/foundation/mem.c src/foundation/mem.h src/foundation/mem_core.c src/foundation/mem_events.c src/foundation/mem_events.h src/foundation/mem_override_darwin.c src/foundation/mem_override_libc.c src/foundation/mem_override_posix.c src/foundation/mem_override_win.c src/foundation/mem_profile.c src/foundation/mem_profile.h src/foundation/platform.h src/foundation/system_info.c src/graph_buffer/graph_buffer.c src/main.c src/pipeline/lsp_surface.c src/pipeline/pass_configlink.c src/pipeline/pass_k8s.c src/pipeline/pass_lsp_cross.c src/pipeline/pass_lsp_cross.h src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pass_route_nodes.c src/pipeline/pass_semantic.c src/pipeline/pass_semantic_edges.c src/pipeline/pass_similarity.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/pipeline/registry.c src/pipeline/worker_pool.c src/semantic/semantic.c src/semantic/semantic.h src/simhash/minhash.c src/simhash/minhash.h test-infrastructure/vm/vm-run-tests.sh tests/fuzz/cbm_fuzz.c tests/fuzz/corpus/config/json-empty.seed tests/fuzz/corpus/config/json.seed tests/fuzz/corpus/config/toml.seed tests/fuzz/corpus/config/yaml.seed tests/fuzz/corpus/cypher/agg.seed tests/fuzz/corpus/cypher/hops.seed tests/fuzz/corpus/cypher/match.seed tests/fuzz/corpus/cypher/optional.seed tests/fuzz/corpus/cypher/union.seed tests/fuzz/corpus/cypher/where.seed tests/fuzz/corpus/extract/c.seed tests/fuzz/corpus/extract/cpp.seed tests/fuzz/corpus/extract/csharp.seed tests/fuzz/corpus/extract/go.seed tests/fuzz/corpus/extract/java.seed tests/fuzz/corpus/extract/javascript.seed tests/fuzz/corpus/extract/makefile.seed tests/fuzz/corpus/extract/php.seed tests/fuzz/corpus/extract/python.seed tests/fuzz/corpus/extract/ruby.seed tests/fuzz/corpus/extract/rust.seed tests/fuzz/corpus/extract/sql.seed tests/fuzz/corpus/extract/typescript.seed tests/fuzz/corpus/extract/yaml.seed tests/test_arena.c tests/test_extraction.c tests/test_hash_table.c tests/test_main.c tests/test_mem.c tests/test_mem_events.c tests/test_parallel.c tests/test_pipeline.c tests/test_registry.c tests/test_semantic.c tests/test_shell_line_endings.sh tests/test_simhash.c tests/test_thread_stack_tls_contract.sh tests/test_venue_parity_contract.sh tests/test_vm_verdict_contract.sh +f189 internal/cbm/extract_calls.c internal/cbm/lsp/ts_lsp.c tests/test_extraction.c tests/test_ts_lsp.c +f190 .github/workflows/pr.yml Makefile.cbm README.md src/cli/cache.c src/cli/cache.h src/daemon/bootstrap.c src/daemon/version_cohort.c src/daemon/version_cohort.h src/foundation/mem_core.c src/foundation/mem_core.h src/main.c tests/test_cache.c tests/test_cache_cli.py tests/test_daemon_bootstrap.c tests/test_main.c tests/test_mcp.c tests/test_version_cohort.c +f191 .github/workflows/pr.yml Makefile.cbm README.md pkg/pypi/tests/test_cli.py src/cli/cache.c src/cli/cache.h src/daemon/bootstrap.c src/daemon/version_cohort.c src/daemon/version_cohort.h src/foundation/mem_core.c src/foundation/mem_core.h src/main.c tests/test_cache.c tests/test_cache_cli.py tests/test_daemon_bootstrap.c tests/test_main.c tests/test_mcp.c tests/test_version_cohort.c +f192 install.sh tests/test-install-android-guard.sh +f193 src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pipeline.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/pipeline/registry.c tests/test_pipeline.c tests/test_registry.c +f194 internal/cbm/vendored/grammars/sql/parser.c internal/cbm/vendored/grammars/tsx/parser.c scripts/vendored-checksums.txt tests/test_parse_coverage.c +f195 internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/sql/parser.c internal/cbm/vendored/grammars/tsx/parser.c scripts/vendored-checksums.txt tests/test_parse_coverage.c +f196 src/mcp/mcp.c src/pipeline/pipeline.c src/pipeline/pipeline.h tests/test_pipeline.c +f197 .github/workflows/_lint.yml .github/workflows/_test.yml .github/workflows/release.yml +f198 internal/cbm/lang_specs.c tests/test_extraction.c +f199 install.ps1 +f200 internal/cbm/lsp/py_lsp.c internal/cbm/lsp/scope.c internal/cbm/lsp/scope.h tests/test_scope.c +f201 src/cypher/cypher.c src/foundation/recursion_whitelist.h tests/test_cypher.c +f202 internal/cbm/service_patterns.c tests/test_infrascan.c tests/test_pipeline.c +f203 scripts/memory-core-baseline.txt src/mcp/mcp.c +f204 internal/cbm/service_patterns.c tests/test_pipeline.c +f205 src/foundation/compat_thread.c +f206 CONTRIBUTING.md README.md +f207 README.md docs/WINDOWS_TROUBLESHOOTING.md +f208 scripts/memory-core-baseline.txt src/mcp/mcp.c tests/test_worker_session_scope.sh +f209 .github/workflows/smoke.yml scripts/smoke-invariants.sh +f210 internal/cbm/cbm.c internal/cbm/preprocessor.cpp internal/cbm/preprocessor.h src/mcp/mcp.c tests/test_extraction.c +f211 README.md docs/CONFIGURATION.md docs/INDEX_RESOURCE_LIMITS.md scripts/test-runtime.sh src/cli/cli.c src/daemon/application.c src/foundation/index_policy.c src/foundation/index_policy.h src/foundation/subprocess.c src/foundation/subprocess.h src/mcp/index_supervisor.c src/mcp/index_supervisor.h src/mcp/mcp.c src/mcp/mcp.h src/mcp/mcp_internal.h src/pipeline/pipeline.c tests/test_daemon_application.c tests/test_index_policy.c tests/test_index_supervisor.c tests/test_mcp.c tests/test_subprocess.c +f212 docs/EVALUATION_PLAN.md scripts/benchmark-index.sh scripts/benchmark-search-graph.sh scripts/test.sh tests/test_benchmark_runtime_isolation_contract.sh +f213 Makefile.cbm src/foundation/compat.c src/foundation/compat.h src/foundation/limits.h src/foundation/mem_override_win.c src/foundation/source_encoding.c src/foundation/source_encoding.h src/mcp/index_supervisor.c src/mcp/mcp.c src/pipeline/pass_calls.c src/pipeline/pass_compile_commands.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_semantic.c src/pipeline/pass_usages.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_pipeline.c tests/test_platform.c +f214 README.md docs/CONFIGURATION.md docs/INDEX_RESOURCE_LIMITS.md scripts/test-runtime.sh src/cli/cli.c src/daemon/application.c src/foundation/index_policy.c src/foundation/index_policy.h src/foundation/subprocess.c src/foundation/subprocess.h src/mcp/index_supervisor.c src/mcp/index_supervisor.h src/mcp/mcp.c src/mcp/mcp.h src/mcp/mcp_internal.h src/pipeline/pipeline.c tests/test_daemon_application.c tests/test_index_policy.c tests/test_index_supervisor.c tests/test_mcp.c tests/test_subprocess.c tests/test_worker_session_scope.sh +f215 .github/workflows/pr.yml +f216 CONTRIBUTING.md +f217 README.md docs/CONFIGURATION.md docs/INDEX_RESOURCE_LIMITS.md scripts/test-runtime.sh src/cli/cli.c src/daemon/application.c src/daemon/application.h src/foundation/index_policy.c src/foundation/index_policy.h src/foundation/subprocess.c src/foundation/subprocess.h src/mcp/index_supervisor.c src/mcp/index_supervisor.h src/mcp/mcp.c src/mcp/mcp.h src/mcp/mcp_internal.h src/pipeline/pipeline.c tests/test_daemon_application.c tests/test_index_policy.c tests/test_index_supervisor.c tests/test_mcp.c tests/test_subprocess.c tests/test_worker_session_scope.sh +f218 tests/test_daemon_bootstrap.c +f219 internal/cbm/service_patterns.c tests/test_infrascan.c +f220 Makefile.cbm src/cli/cli.c src/pipeline/pass_doclinks.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_doclinks.c tests/test_edge_structural.c tests/test_lang_contract.c tests/test_main.c tests/test_pipeline.c +f221 Makefile.cbm README.md src/cli/cli.c src/pipeline/pass_doclinks.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_doclinks.c tests/test_edge_structural.c tests/test_lang_contract.c tests/test_main.c tests/test_pipeline.c +f222 src/daemon/application.c src/mcp/mcp.c src/pipeline/lsp_surface.c src/pipeline/pipeline.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_daemon_application.c tests/test_mcp.c tests/test_pipeline.c +f223 README.md docs/CONFIGURATION.md docs/INDEX_RESOURCE_LIMITS.md scripts/test-runtime.sh src/cli/cli.c src/daemon/application.c src/daemon/application.h src/foundation/index_policy.c src/foundation/index_policy.h src/foundation/subprocess.c src/foundation/subprocess.h src/mcp/index_supervisor.c src/mcp/index_supervisor.h src/mcp/mcp.c src/mcp/mcp.h src/mcp/mcp_internal.h src/pipeline/pipeline.c tests/test_daemon_application.c tests/test_daemon_bootstrap.c tests/test_index_policy.c tests/test_index_supervisor.c tests/test_mcp.c tests/test_subprocess.c tests/test_worker_session_scope.sh +f224 internal/cbm/lsp/ts_lsp.c internal/cbm/lsp/ts_lsp.h tests/test_stack_overflow.c +f225 src/foundation/mem_override_win.c +f226 src/foundation/compat.c src/mcp/index_supervisor.c src/mcp/mcp.c tests/test_platform.c +f227 src/pipeline/lsp_surface.c tests/test_pipeline.c +f228 src/daemon/host.c src/daemon/ipc.c src/daemon/ipc.h src/daemon/runtime.c src/daemon/runtime.h src/daemon/version_cohort.c src/daemon/version_cohort.h src/foundation/private_file_lock.c src/foundation/private_file_lock_internal.h tests/test_daemon_ipc.c tests/test_private_file_lock.c tests/test_version_cohort.c +f229 src/foundation/compat.h tests/test_platform.c +f230 README.md docs/CONFIGURATION.md src/cli/cli.c src/cli/cli.h src/cli/hook_augment.c src/daemon/application.c src/git/git_context.c src/git/git_context.h src/mcp/mcp.c src/mcp/mcp_internal.h tests/test_cli.c tests/test_git_context.c tests/test_mcp.c +f231 internal/cbm/extract_defs.c src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_call_reference_contract.c tests/test_registry.c +f232 internal/cbm/extract_calls.c internal/cbm/extract_unified.c internal/cbm/extract_unified.h tests/repro/repro_call_scope_usages.c tests/test_convergence_probe.c +f233 pkg/scoop/codebase-memory-mcp.json +f234 src/mcp/mcp.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/store/store.c src/store/store.h tests/test_mcp.c tests/test_pipeline.c +f235 internal/cbm/extract_defs.c internal/cbm/helpers.c internal/cbm/helpers.h tests/test_extraction.c +f236 src/pipeline/registry.c tests/test_pipeline.c tests/test_registry.c +f237 internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_unified.c internal/cbm/extract_usages.c internal/cbm/helpers.c internal/cbm/helpers.h internal/cbm/result_compact.c src/foundation/constants.h src/foundation/recursion_whitelist.h src/mcp/mcp.c src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_usages.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/registry.c src/store/store.c src/store/store.h tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_node_behaviors.c tests/test_extraction.c tests/test_mcp.c tests/test_pipeline.c tests/test_registry.c tests/test_store_nodes.c +f238 src/foundation/compat_fs.c src/foundation/compat_fs.h src/mcp/mcp.c src/pipeline/pipeline.c tests/test_pipeline.c +f239 src/mcp/mcp.c src/pipeline/pass_cross_repo.c src/pipeline/pass_cross_repo.h tests/test_cli.c tests/test_cross_repo.c +f240 src/pipeline/pass_cross_repo.c tests/test_cross_repo.c +f241 internal/cbm/extract_defs.c internal/cbm/lang_specs.c tests/test_extraction.c +f242 docs/CONFIGURATION.md src/daemon/ipc.c src/daemon/ipc.h tests/test_daemon_ipc.c +f243 docs/CONFIGURATION.md src/daemon/ipc.c src/daemon/ipc.h src/main.c tests/test_daemon_ipc.c +f244 src/cli/config_toml_edit.c src/cli/config_toml_edit.h tests/test_cli.c tests/test_config_toml_edit.c +f245 docs/cbmignore.md src/discover/discover.c tests/test_discover.c +f246 Makefile.cbm internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/sql_values.c internal/cbm/sql_values.h tests/test_extraction.c tests/test_parse_coverage.c tests/test_pipeline.c +f247 THIRD_PARTY.md internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/javascript/parser.c internal/cbm/vendored/grammars/tsx/parser.c internal/cbm/vendored/grammars/tsx/tree_sitter/array.h internal/cbm/vendored/grammars/tsx/tree_sitter/parser.h scripts/audit-license-provenance.py scripts/vendored-checksums.txt tests/test_parse_coverage.c tools/tree-sitter-javascript/grammar.js tools/tree-sitter-javascript/package.json tools/tree-sitter-javascript/patches/jsx-lone-ampersand.patch tools/tree-sitter-javascript/src/grammar.json tools/tree-sitter-javascript/src/node-types.json tools/tree-sitter-javascript/src/parser.c tools/tree-sitter-javascript/src/scanner.c tools/tree-sitter-javascript/src/tree_sitter/alloc.h tools/tree-sitter-javascript/src/tree_sitter/array.h tools/tree-sitter-javascript/src/tree_sitter/parser.h tools/tree-sitter-javascript/test/corpus/jsx_ampersand.txt tools/tree-sitter-javascript/tree-sitter.json tools/tree-sitter-tsx/define-grammar.js tools/tree-sitter-tsx/grammar.js tools/tree-sitter-tsx/javascript-grammar.js tools/tree-sitter-tsx/package.json tools/tree-sitter-tsx/src/_common_scanner.h tools/tree-sitter-tsx/src/grammar.json tools/tree-sitter-tsx/src/node-types.json tools/tree-sitter-tsx/src/parser.c tools/tree-sitter-tsx/src/scanner.c tools/tree-sitter-tsx/src/tree_sitter/alloc.h tools/tree-sitter-tsx/src/tree_sitter/array.h tools/tree-sitter-tsx/src/tree_sitter/parser.h tools/tree-sitter-tsx/test/corpus/jsx_ampersand.txt tools/tree-sitter-tsx/tree-sitter.json +f248 internal/cbm/lsp/c_lsp.c internal/cbm/lsp/ts_lsp.c internal/cbm/lsp/ts_lsp.h tests/test_c_lsp.c tests/test_ts_lsp.c +f249 internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/c_sharp/parser.c internal/cbm/vendored/grammars/c_sharp/scanner.c internal/cbm/vendored/grammars/c_sharp/tree_sitter/alloc.h internal/cbm/vendored/grammars/c_sharp/tree_sitter/array.h internal/cbm/vendored/grammars/c_sharp/tree_sitter/parser.h scripts/vendored-checksums.txt tests/test_parse_coverage.c +f250 internal/cbm/sqlite_writer.c tests/test_sqlite_writer.c +f251 src/mcp/mcp.c src/pipeline/fqn.c src/pipeline/pipeline.h tests/test_mcp.c +f252 internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/result_compact.c src/pipeline/lsp_surface.c src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pipeline.c src/pipeline/pipeline_delta.c src/pipeline/pipeline_internal.h tests/test_extraction.c tests/test_pipeline.c +f253 README.md docs/CONFIGURATION.md src/cli/cli.c src/cli/cli.h src/daemon/host.c src/discover/discover.c src/discover/discover.h src/watcher/watcher.c src/watcher/watcher.h tests/test_cli.c tests/test_watcher.c +f254 src/daemon/runtime.c src/daemon/runtime.h tests/test_daemon_runtime.c +f255 internal/cbm/cbm.c internal/cbm/cbm.h tests/test_parse_coverage.c +f256 scripts/test.sh tests/repro/repro_main.c tests/test_git_env_isolation_contract.sh tests/test_helpers.h tests/test_main.c +f257 src/cli/config_toml_edit.c tests/test_config_toml_edit.c +f258 internal/cbm/lsp/generated/rust_stdlib_data.c src/pipeline/lsp_resolve.h src/pipeline/pass_calls.c src/pipeline/pass_parallel.c tests/test_parallel.c tests/test_pipeline.c +f259 internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/lsp/c_lsp.c internal/cbm/lsp/cs_lsp.c internal/cbm/lsp/go_lsp.c internal/cbm/lsp/java_lsp.c internal/cbm/lsp/kotlin_lsp.c internal/cbm/lsp/php_lsp.c internal/cbm/lsp/py_lsp.c internal/cbm/lsp/rust_lsp.c internal/cbm/lsp/ts_lsp.c tests/test_parse_coverage.c +f260 Makefile.cbm src/foundation/compat_fs.c src/foundation/compat_fs.h src/foundation/git_env.c src/foundation/git_env.h src/foundation/subprocess.c src/foundation/subprocess.h src/git/git_context.c src/mcp/mcp.c src/pipeline/artifact.c src/pipeline/pass_githistory.c src/watcher/watcher.c tests/test_git_context.c tests/test_subprocess.c +f261 Makefile.cbm internal/cbm/callable_sig.c internal/cbm/callable_sig.h internal/cbm/cbm.h internal/cbm/lsp/go_lsp.h internal/cbm/lsp/type_registry.h internal/cbm/result_compact.c scripts/memory-core-baseline.txt src/mcp/mcp.c src/pipeline/lsp_resolve.h src/pipeline/pass_ensemble_routing.c src/pipeline/pass_lsp_cross.c src/pipeline/pass_semantic.c src/pipeline/registry.c src/store/store.c src/store/store.h tests/test_callable_sig.c tests/test_main.c tests/test_store_nodes.c +f262 src/pipeline/pass_cross_repo.c src/store/store.c src/store/store.h tests/test_cross_repo.c +f263 src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline.h src/pipeline/pipeline_internal.h src/pipeline/registry.c tests/test_edge_imports.c tests/test_registry.c +f264 internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/lsp/cs_lsp.c internal/cbm/lsp/type_registry.h internal/cbm/result_compact.c src/pipeline/pass_lsp_cross.c src/pipeline/pipeline.c tests/test_parallel.c +f265 README.md docs/CONFIGURATION.md docs/index.html src/daemon/application.c src/mcp/mcp.c src/mcp/mcp.h src/watcher/watcher.c src/watcher/watcher.h tests/test_daemon_application.c tests/test_watcher.c +f266 src/daemon/host.c src/daemon/host_internal.h src/daemon/ipc.c src/daemon/ipc.h src/daemon/runtime.c src/daemon/runtime.h src/daemon/version_cohort.c src/daemon/version_cohort.h src/foundation/private_file_lock.c src/foundation/private_file_lock_internal.h tests/test_daemon_ipc.c tests/test_daemon_runtime.c tests/test_private_file_lock.c tests/test_version_cohort.c +f267 src/mcp/mcp.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/store/store.c tests/test_pipeline.c +f268 README.md docs/CONFIGURATION.md +f269 internal/cbm/extract_usages.c internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/rescript/scanner.c src/discover/discover.c src/discover/discover.h src/discover/language.c tests/test_complexity.c tests/test_extraction.c tests/test_language.c +f270 internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/result_compact.c scripts/memory-core-baseline.txt src/pipeline/lsp_surface.c src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pipeline.c src/pipeline/pipeline_delta.c src/pipeline/pipeline_internal.h tests/test_extraction.c tests/test_pipeline.c +f271 THIRD_PARTY.md internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/pascal/parser.c internal/cbm/vendored/grammars/pascal/tree_sitter/parser.h scripts/audit-license-provenance.py scripts/vendored-checksums.txt tests/test_parse_coverage.c tools/tree-sitter-pascal/grammar.js tools/tree-sitter-pascal/package.json tools/tree-sitter-pascal/patches/unicode-identifiers.patch tools/tree-sitter-pascal/src/grammar.json tools/tree-sitter-pascal/src/node-types.json tools/tree-sitter-pascal/src/parser.c tools/tree-sitter-pascal/src/tree_sitter/alloc.h tools/tree-sitter-pascal/src/tree_sitter/array.h tools/tree-sitter-pascal/src/tree_sitter/parser.h tools/tree-sitter-pascal/test/corpus/unicode_identifiers.txt tools/tree-sitter-pascal/tree-sitter.json +f272 README.md src/daemon/application.c src/daemon/frontend.c src/daemon/frontend.h src/mcp/mcp.c src/mcp/mcp.h src/ui/http_server.c tests/test_daemon_application.c tests/test_daemon_frontend.c tests/test_mcp.c +f273 internal/cbm/cbm.h src/pipeline/pipeline_internal.h +f274 pkg/scoop/codebase-memory-mcp.json tests/test_version_metadata_contract.sh +f275 README.md scripts/memory-core-baseline.txt src/cli/cli.c src/mcp/mcp.c src/pipeline/artifact.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/store/store.c src/store/store.h tests/test_artifact.c tests/test_mcp.c tests/test_pipeline.c +f276 internal/cbm/extract_imports.c src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline_internal.h tests/test_edge_imports.c tests/test_extraction.c tests/test_pipeline.c +f277 internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/extract_unified.c internal/cbm/helpers.c src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline_internal.h src/pipeline/registry.c tests/test_edge_imports.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f278 src/foundation/compat.c +f279 internal/cbm/cbm.c src/cli/cli.c src/foundation/compat.h tests/test_extraction.c tests/test_platform.c +f280 src/mcp/mcp.c src/pipeline/pipeline.h src/pipeline/pipeline_internal.h tests/test_mcp.c +f281 internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/extract_unified.c internal/cbm/helpers.c src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline.h src/pipeline/pipeline_internal.h src/pipeline/registry.c tests/test_edge_imports.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f282 THIRD_PARTY.md internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_usages.c internal/cbm/helpers.c internal/cbm/helpers.h internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/objectscript_routine/objectscript_common.h internal/cbm/vendored/grammars/objectscript_routine/parser.c internal/cbm/vendored/grammars/objectscript_routine/scanner.c internal/cbm/vendored/grammars/objectscript_routine/tree_sitter/array.h internal/cbm/vendored/grammars/objectscript_udl/objectscript_common.h internal/cbm/vendored/grammars/objectscript_udl/parser.c internal/cbm/vendored/grammars/objectscript_udl/scanner.c internal/cbm/vendored/grammars/objectscript_udl/tree_sitter/array.h scripts/vendored-checksums.txt tests/test_extraction.c +f283 internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_unified.c internal/cbm/extract_usages.c internal/cbm/helpers.c internal/cbm/helpers.h tests/test_extraction.c +f284 src/daemon/application.c src/foundation/log.c src/foundation/log.h src/mcp/mcp.c src/ui/http_server.c tests/test_log.c tests/test_mcp.c +f285 internal/cbm/extract_defs.c scripts/memory-core-baseline.txt tests/test_extraction.c +f286 README.md docs/CONFIGURATION.md scripts/test.sh src/main.c tests/test_in_process_mcp.py tests/test_in_process_mcp.sh tests/test_runtime_isolation_contract.sh +f287 internal/cbm/extract_imports.c scripts/memory-core-baseline.txt src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline_internal.h tests/test_edge_imports.c tests/test_extraction.c tests/test_pipeline.c +f288 internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/extract_unified.c internal/cbm/helpers.c scripts/memory-core-baseline.txt src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline_internal.h src/pipeline/registry.c tests/test_edge_imports.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f289 internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/extract_unified.c internal/cbm/helpers.c scripts/memory-core-baseline.txt src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline.h src/pipeline/pipeline_internal.h src/pipeline/registry.c tests/test_edge_imports.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f290 src/cli/client_adapter.c src/cli/client_adapter.h tests/test_agent_clients.c +f291 internal/cbm/extract_usages.c internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/rescript/scanner.c scripts/vendored-checksums.txt src/discover/discover.c src/discover/discover.h src/discover/language.c tests/test_complexity.c tests/test_extraction.c tests/test_language.c +f292 Makefile.cbm internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/lsp/php_lsp.c internal/cbm/php_inline_html.c tests/test_extraction.c tests/test_pipeline.c +f293 Makefile.cbm internal/cbm/cbm.c internal/cbm/cbm.h internal/cbm/lsp/php_lsp.c internal/cbm/php_inline_html.c tests/test_extraction.c tests/test_php_lsp.c tests/test_pipeline.c +f294 src/daemon/bootstrap.h src/daemon/runtime.c src/daemon/runtime.h src/main.c tests/test_daemon_version.c +f295 scripts/memory-core-baseline.txt src/cli/agent_profiles.c src/cli/agent_profiles.h src/cli/cli.c tests/test_cli.c +f296 src/foundation/mem.c src/foundation/mem.h src/pipeline/pass_parallel.c tests/test_parallel.c +f297 internal/cbm/extract_defs.c internal/cbm/helpers.c internal/cbm/lang_specs.c tests/test_extraction.c +f298 src/mcp/mcp.c +f299 README.md THIRD_PARTY.md docs/index.html docs/llms.txt internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_usages.c internal/cbm/grammar_st.c internal/cbm/lang_specs.c internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/st/LICENSE internal/cbm/vendored/grammars/st/parser.c internal/cbm/vendored/grammars/st/scanner.c internal/cbm/vendored/grammars/st/tree_sitter/alloc.h internal/cbm/vendored/grammars/st/tree_sitter/array.h internal/cbm/vendored/grammars/st/tree_sitter/parser.h pkg/chocolatey/codebase-memory-mcp.nuspec pkg/npm/README.md scripts/new-languages.json scripts/package-release.sh scripts/vendored-checksums.txt server.json src/discover/language.c src/discover/userconfig.c src/mcp/mcp.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_manifest.c tests/repro/repro_language_registry.c tests/test_extraction.c tests/test_grammar_labels.c tests/test_grammar_regression.c tests/test_language.c +f300 README.md scripts/memory-core-baseline.txt src/cli/cli.c src/mcp/mcp.c src/pipeline/artifact.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/store/store.c src/store/store.h tests/test_artifact.c tests/test_mcp.c tests/test_pipeline.c tests/test_store_arch.c +f301 internal/cbm/cbm.h src/mcp/mcp.c src/pipeline/pass_calls.c src/pipeline/pass_parallel.c src/pipeline/pipeline.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/store/store.c src/store/store.h tests/test_mcp.c tests/test_pipeline.c +f302 THIRD_PARTY.md internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_usages.c internal/cbm/helpers.c internal/cbm/helpers.h internal/cbm/lang_specs.c internal/cbm/vendored/grammars/MANIFEST.md internal/cbm/vendored/grammars/objectscript_routine/objectscript_common.h internal/cbm/vendored/grammars/objectscript_routine/parser.c internal/cbm/vendored/grammars/objectscript_routine/scanner.c internal/cbm/vendored/grammars/objectscript_routine/tree_sitter/array.h internal/cbm/vendored/grammars/objectscript_udl/objectscript_common.h internal/cbm/vendored/grammars/objectscript_udl/parser.c internal/cbm/vendored/grammars/objectscript_udl/scanner.c internal/cbm/vendored/grammars/objectscript_udl/tree_sitter/array.h scripts/vendored-checksums.txt tests/repro/repro_call_node_behaviors.c tests/repro/repro_call_node_manifest.c tests/test_extraction.c +f303 internal/cbm/lsp/ts_lsp.c tests/repro/repro_issue514.c tests/test_ts_lsp.c +f304 src/cli/cli.c src/cli/cli.h src/foundation/str_util.c src/foundation/str_util.h src/pipeline/pass_cross_repo.c tests/test_cli.c +f305 internal/cbm/result_spill.c internal/cbm/result_spill.h tests/test_extraction.c tests/test_main.c +f306 internal/cbm/lsp/ts_lsp.c tests/test_pipeline.c tests/test_ts_lsp.c +f307 tests/test_watcher.c +f308 tests/test_daemon_ipc.c +f309 src/daemon/bootstrap.c src/daemon/bootstrap.h tests/repro/repro_main.c tests/test_daemon_bootstrap.c tests/test_helpers.h tests/test_main.c +f310 src/cli/cli.c src/cli/cli.h tests/test_cli.c +f311 internal/cbm/extract_imports.c scripts/memory-core-baseline.txt src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h tests/test_edge_imports.c tests/test_extraction.c tests/test_pipeline.c +f312 src/pipeline/pass_definitions.c tests/test_parallel.c +f313 internal/cbm/extract_usages.c tests/repro/repro_lexical_binding_precision.c tests/test_extraction.c +f314 internal/cbm/lsp/py_lsp.c tests/test_py_lsp.c tests/test_py_lsp_scale.c +f315 src/discover/discover.c src/discover/discover.h src/discover/language.c tests/test_discover.c tests/test_language.c +f316 internal/cbm/lsp/py_lsp.c internal/cbm/lsp/py_lsp.h tests/test_py_lsp.c +f317 internal/cbm/extract_defs.c src/store/store.h tests/test_extraction.c +f318 scripts/memory-core-baseline.txt src/cli/activation_transaction.c src/cli/activation_transaction.h tests/test_activation_transaction.c +f319 internal/cbm/lsp/py_lsp.c src/pipeline/lsp_resolve.h tests/repro/repro_lsp_go_py.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_py_lsp.c +f320 internal/cbm/cbm.h internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/extract_unified.c internal/cbm/helpers.c scripts/memory-core-baseline.txt src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/pipeline/registry.c tests/test_edge_imports.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f321 internal/cbm/cbm.h internal/cbm/extract_calls.c internal/cbm/extract_defs.c internal/cbm/extract_imports.c internal/cbm/extract_unified.c internal/cbm/helpers.c scripts/memory-core-baseline.txt src/pipeline/pass_calls.c src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_pkgmap.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/pipeline/registry.c tests/test_edge_imports.c tests/test_extraction.c tests/test_pipeline.c tests/test_registry.c +f322 src/pipeline/pass_definitions.c src/pipeline/pass_parallel.c src/pipeline/pass_tests.c src/pipeline/pipeline.h src/pipeline/pipeline_incremental.c src/pipeline/pipeline_internal.h src/pipeline/registry.c tests/test_pipeline.c tests/test_registry.c +f323 src/pipeline/pass_calls.c src/pipeline/pass_lsp_cross.c src/pipeline/pass_parallel.c src/pipeline/pass_semantic.c src/pipeline/pass_usages.c src/pipeline/pipeline.h src/pipeline/registry.c tests/test_pipeline.c diff --git a/scripts/test-impact/fixtures/2026-09-pushes.tsv b/scripts/test-impact/fixtures/2026-09-pushes.tsv new file mode 100644 index 000000000..a6277fed3 --- /dev/null +++ b/scripts/test-impact/fixtures/2026-09-pushes.tsv @@ -0,0 +1,776 @@ +# Every PR push of September 2026 (one pr.yml run each) with the tier the +# lane analysis assigned it (sel_classify.py) and the tier the selector must +# produce. They differ only where a deviation is documented in `note`: +# makefile-by-name the analysis classified Makefile.cbm by its DIFF +# (source registration / test flags -> no tier of its +# own); a file list cannot show that, so the selector +# treats every Makefile.cbm change as a build-flag +# change: T5-core, full. Deliberately conservative. +# Columns (TAB): run_id, pr, head_sha, fileset, analysis_tier, expected_tier, note. +33464275799 1697 22f90fe883f641c2c460a210a1de3a8df74c4285 f001 T0a-docs T0a-docs +33464725171 1986 9d91891cb5dc6b9d67a0d4919d44d6346799af23 f002 T5-core T5-core +33484752078 1939 e11d5900cd8735fd2ff5ca5308bc028fbe5ed2a5 f003 T3-mid T3-mid +33492041913 1657 7f158e950f874bea6d9530eba16f44b0032f5cac f004 T5-core T5-core +33512870122 1941 35efca813cb2e280fd98168e037cc2a098e365c3 f005 T3-mid T5-core makefile-by-name +33512876012 1971 a5af586d9275249ff9ab8fd7504c9a1d12f7ef85 f005 T3-mid T5-core makefile-by-name +33512878163 1968 9e954104ff5c23bbbb05eccce024b74e6e0a1a6a f006 T5-core T5-core +33525282952 1739 2a2e817bbc11c64755e9d9e3c2e3bb951cf29083 f007 T4-platform T4-platform +33525334366 1817 e7bf16ac616d3d04733caa075a59902fa204d664 f008 T4-platform T4-platform +33529411666 1784 fad564e1e1ad683657ae604bec2c5f0852f19151 f009 T4-platform T4-platform +33530652927 1992 abd82a108b459d70a923d7b4a43651fbeb835912 f001 T0a-docs T0a-docs +33530742647 1697 22f90fe883f641c2c460a210a1de3a8df74c4285 f001 T0a-docs T0a-docs +33533600441 1993 da61c81af243e3277e3089fb474ee5c08fe5c148 f010 T2-extract T2-extract +33533605337 1697 4b844b7d9e782ff26284248c072578215cda3d2f f001 T0a-docs T0a-docs +33541770974 1697 957b27fca50d2c3b1ac86d771733f39612bae841 f001 T0a-docs T0a-docs +33542424474 1811 fb2e38f5bbd45a2504d9daee09fc4c57a8d7db7a f011 T3-mid T3-mid +33542805417 1697 4f3412b78a5fa64ad01cb9ffedea603d101262ec f001 T0a-docs T0a-docs +33542807266 1896 50eaeebeaa2edb2ce14cabbf0d128e47f47b9c5b f012 T2-extract T2-extract +33542815580 1703 359f7360ddd39dc155637076e76a4e855c27611f f013 T3-mid T3-mid +33543233252 1811 b149247a0fd78d912ec906f44c2cf30bae7e5209 f011 T3-mid T3-mid +33549536985 1918 738174f74a2fbc2ad53ee9f4b2aed1eea2214d8e f014 T3-mid T3-mid +33550775043 1996 5e328814fc99ffe588daae4f0f1e8cf5066e084b f015 T4-platform T4-platform +33556880174 808 c19373cd4eba3dc836ccd9b12569cceb9aeab970 f016 T4-platform T5-core makefile-by-name +33557912833 808 909d01dd80de2c2d2b3518b60e33901dd4f284d0 f016 T4-platform T5-core makefile-by-name +33558106767 808 4b33971df0796fe841df867ca202f28a59a5824f f016 T4-platform T5-core makefile-by-name +33558842421 1697 40e5e373ab27bba3129405f346aa5937f9c93850 f001 T0a-docs T0a-docs +33558848235 1895 230f42454d76e103209e3baa54f6b7bf563cd2bd f017 T2-extract T2-extract +33560684540 1998 45b97287c75a0d2f49a28ac900c2dc00d518e594 f014 T3-mid T3-mid +33564199875 1986 b2ac3c58a99c534d4882674b5651deb61296be42 f018 T5-core T5-core +33564348882 1697 40e5e373ab27bba3129405f346aa5937f9c93850 f001 T0a-docs T0a-docs +33564426767 1976 82e1de8cef4121db80c53f4e144530dcf5dcb747 f019 T5-core T5-core +33565344188 1876 9d4ac2b98b9c19633ce2cbf8d9c6bf9edc57bba6 f020 T4-platform T4-platform +33569142503 808 89f1edcb50ecdec1e0db64002f1d8eed07164d46 f016 T4-platform T5-core makefile-by-name +33569301401 1999 2641ec858dff11307d42e60e8e3dccc627d02f0e f021 T2-extract T2-extract +33571644044 1875 25a7aacc87250bff752ff61cd33e9236be5d1310 f014 T3-mid T3-mid +33571903536 1877 62b44519c68b403ea44d9a85ba8ee74cddee66e6 f022 T3-mid T3-mid +33572811960 1912 63f79365e3217d2ef7a3615567bb96627f3fd6b3 f023 T3-mid T3-mid +33574864607 1968 a3e8fa3d08691f050f16d75879fd8a84b73ad76a f024 T5-core T5-core +33576560737 1986 e8204092333ee1c38cd681a81abb36f125288894 f018 T5-core T5-core +33577534823 1999 a739a2d81b43eb6938d014e005b306ab27e02396 f021 T2-extract T2-extract +33578326689 2002 976f312ea24acb8b1840ee5f766f3e2588af333e f014 T3-mid T3-mid +33578871318 1723 b769ad6324f866a580d731f5579a9d076cc45cf0 f025 T5-core T5-core +33580573384 1832 844a36b78aa10f16338d24a1a819ca9a31525129 f026 T4-platform T5-core makefile-by-name +33586571817 1836 2f37f61e8848f31f308bce8623f66dc2fdb50619 f027 T3-mid T3-mid +33588509339 1824 fd73c347fbcfd7f66c210577b4d713fc6ce7780b f028 T3-mid T3-mid +33590570368 1862 0e8fc16abce48346d07af78acf3293a805085e23 f029 T4-platform T4-platform +33593858493 1874 c0173a3362878e3cb9e7bf9b65c7ab79b46e73ef f030 T4-platform T4-platform +33593864739 1871 4772f4715172173559ba3f9130ddd90df4416ed7 f013 T3-mid T3-mid +33596260489 1779 98e386e58b753bcf63f67aeeacbfcb94961d035c f013 T3-mid T3-mid +33599715238 1739 4c1b23477b8e0de27feee74eac11c21a1fd64151 f007 T4-platform T4-platform +33600443796 808 53a97f0f74e34a55076ea3d1f1dc0be562b1c7b9 f016 T4-platform T5-core makefile-by-name +33600512620 808 003f8f4c2efbc22fe72e2dcab7fec5cfd1b42cd0 f016 T4-platform T5-core makefile-by-name +33605426923 1931 5cb90f64adaf9c5a38b591971aa7c09e3d9e958c f031 T2-extract T2-extract +33605453120 1936 27d4d34f7973823530922ec5485ce19a64589e22 f032 T3-mid T3-mid +33605453184 1907 651ae3c2af1a22ec61a498b39350785aee480973 f033 T3-mid T3-mid +33605453306 1913 a6b1a69a7c9a9ba67ac16c88e9c11de3524f87cc f032 T3-mid T3-mid +33605454090 1915 fcbd12ee1ba8580c1c83ce7ba797613ff5af00e7 f032 T3-mid T3-mid +33608282620 2005 256b3d3722247f451a2213023dc36a1ebd5827af f034 T2-extract T2-extract +33610039868 1811 cd8a5ed0d7c5674072f5d422b12302aa6424fda3 f011 T3-mid T3-mid +33610440955 1946 4bfb074b2892ec181e6b16dee3ee984af6dda201 f035 T3-mid T3-mid +33612193345 2006 349aecba9528427fa635975e246f5b8959b14f30 f036 T3-mid T3-mid +33612686565 2002 3d9a6cf5cb7fd39c6b5fb4dfa43011aa70f736ea f014 T3-mid T3-mid +33614840408 1947 ec0ca565a6571cad6e1c90914400b8dd4c2098e9 f037 T3-mid T3-mid +33615574338 1912 97517a4652f376cb6bf096255e235edf456f86b1 f038 T3-mid T3-mid +33615892072 1777 7fedaba331d8fbd241582f3ceeccd70af0ade149 f039 T4-platform T4-platform +33616176807 1949 91a3c6a5173de50fb47c4ebc5a8ba448598f7d5a f040 T2-extract T2-extract +33617264651 1913 dbba7573016e17f20fe5afaafe5cc2cddd98744e f041 T3-mid T3-mid +33617265496 1936 ac46c17fc37f78ae518145e73d51b46ca3013fac f042 T3-mid T3-mid +33617265877 1915 adff62e7f20dd439129d17c5dc028c7069af92f2 f042 T3-mid T3-mid +33620018950 2008 c36b4fbc446f9085704a6073b81ec743fd4180fc f043 T2-extract T2-extract +33620040078 2009 592894a4387a50e1d128ff6a2695ff48f8cc32a5 f044 T2-extract T2-extract +33641828637 2013 40f2722d6166c1f97fa1667c1e63e8c97e6f05f0 f045 T2-extract T2-extract +33653455785 1809 88269ac2ee70ab88f3ee8d84bd509399e3a8f3d6 f046 T4-platform T4-platform +33654490848 1809 b026a6907807f7a411e7da4de45d0cedc2e93dc9 f046 T4-platform T4-platform +33656319321 1819 64ea496a7b3623f5f8b28343aba99535b282d86a f047 T0b-nonproduct T0b-nonproduct +33658603433 1773 572725e61a00b9deda7407971dc9290477170515 f048 T2-extract T2-extract +33658798739 1811 3f08fac5139a4acd3f393e9ca78538c1ba8b94d7 f011 T3-mid T3-mid +33663175876 2016 0e3c9a3d52fbdd234db180894e94f434efce3505 f049 T4-platform T4-platform +33663200103 2017 1e92269d7562d89a816271c4d99904ee53a94606 f050 T4-platform T4-platform +33669707175 2016 e0785b353af1af3812d5d14cad55c839efc5749d f049 T4-platform T4-platform +33683309620 1649 ff8714662728f55a5ff368462fd8daedc80b89a6 f051 T4-platform T4-platform +33687771558 1832 69a9c9005a6d81889cc7fe5e0355e610d2a08e6a f026 T4-platform T5-core makefile-by-name +33688645901 1723 83ca40ced11f51e479d4d868ed167b47cfe993d9 f025 T5-core T5-core +33700059940 1896 c4e902849f8062e37a0f0ddaed1c6cc042780cb3 f012 T2-extract T2-extract +33700061299 1976 7b528b3247257fdc8d503a4af617007bbe3f8454 f012 T2-extract T2-extract +33702992083 1874 4529db7eb56c5793227bdce34723827030796a8b f030 T4-platform T4-platform +33702997094 1871 2283bcb9eb85b17863786608a0c41b0937521b29 f013 T3-mid T3-mid +33703360466 1998 41e649281bb9b27ead5d6ec223a0f0c78924c8f8 f014 T3-mid T3-mid +33705255783 1918 426e415f5427f2fb2e9c6f918cc7cc9abd9c109b f014 T3-mid T3-mid +33705617151 1881 57ef0a6d8662e9854dc5711922f801c3da381071 f052 T3-mid T3-mid +33706085211 1897 2c76563a137876b110cec15b9b6fa08d35069f73 f053 T3-mid T3-mid +33706763915 1880 6b801eec68d6465aad2020de438e629cead99f5c f054 T4-platform T4-platform +33708048488 2019 ad04339030bbfea4e77139bcfa9a87356c1e0e5b f055 T1-tests T1-tests +33727721488 2022 c846ed7b37331696543e71a70a8e5c8dd301467d f056 T4-platform T4-platform +33731062466 1874 c38d19139ad9657ab563951ccbf83dcbd1bb2435 f030 T4-platform T4-platform +33731071275 1871 adb49b3a28826e6eef871b1777b63668199c0463 f013 T3-mid T3-mid +33732861010 1811 da09b73bc0c03f3909a0153d75b8479220c31e2c f011 T3-mid T3-mid +33739760521 1811 e79a7d73b45644d86ab3ca3c7af84aa04aebe75f f011 T3-mid T3-mid +33740406928 2024 1770d68a2c934501b889e625dc94ef933e3a1815 f057 T3-mid T3-mid +33745916176 1811 bbecc9b9e92af203a03433e8cb6968e1e8d21cb2 f011 T3-mid T3-mid +33751987784 1996 7c4b5fc8a7c9ed2f1cf3b675bb821dbbccf2c1bb f015 T4-platform T4-platform +33752032210 1811 b64e2170009159a5111b1339312512f4e66b1b57 f011 T3-mid T3-mid +33754008677 1874 956489dcf88d0fd571efc493e6a93bb1d96760c7 f030 T4-platform T4-platform +33757191193 1767 06576511ce84b54b2177a75ae82c3af7dc897160 f058 T4-platform T4-platform +33758229230 2026 73f4a432e4ac1d575635a490d96e0f5ea588344d f059 T4-platform T4-platform +33763283492 2027 0ac290e2e243148b9e84bb682cf1101fe38bc6e3 f060 T4-platform T4-platform +33765101593 2028 1d38be3331576f50ce555d99717974060a6a16a8 f061 T5-core T5-core +33765957247 2029 be66a2d2c6f1538a4a831eb2c91622997be5b5ec f062 T4-platform T4-platform +33771464451 1809 e65378e6a5faf49abd7a0987994d7c98a9f834ad f046 T4-platform T4-platform +33775413774 1809 d402623c20e1dbff62e560a865fd4955798dfbfe f046 T4-platform T4-platform +33779258079 2030 3507d9555d73aa746cde23139588081c6c02d2e2 f063 T4-platform T4-platform +33799475629 1819 b25b1f65b402ba5395da846f05a306746055594f f047 T0b-nonproduct T0b-nonproduct +33799483900 1918 74700067c48b967edc4ef723b658a0fbb04f291c f014 T3-mid T3-mid +33799487158 1880 e63292a8d4036fc2618ecbee973bd6cccaaaffc4 f054 T4-platform T4-platform +33799492841 1881 4d4af28672729a5c1326974684728c9ee150926a f052 T3-mid T3-mid +33799495512 1897 429141261daefc04fc94f35300f7dbd899ed28f2 f053 T3-mid T3-mid +33799501343 1767 8a74d814e5149be17f2fc70f9a59d0c166604273 f058 T4-platform T4-platform +33799505794 2024 380b1c5af1d5f3e94248752266031b3035080793 f057 T3-mid T3-mid +33799508762 1811 f47cd47afe9e7e144f3c6d49df37c5b53d3d1cdb f011 T3-mid T3-mid +33809990745 2040 12f5e2c3636b694cbcc76b197815a983349fff5a f064 T4-platform T4-platform +33816056229 2041 588cd5543a617c73646be61de6926b9ac7004064 f055 T1-tests T1-tests +33816348076 2042 9933548586760fd81f2cf76673ec42c338ff62eb f065 T0b-nonproduct T0b-nonproduct +33818108315 2043 7109e5e7b013f43230f735f93043ae608be5d98f f066 T4-platform T4-platform +33831172851 2005 ec28e66434bc03ec33dcd05563b3bfd7f58efe86 f034 T2-extract T2-extract +33849949280 2047 9104feb67f78959e64ddff78d236e0dea475a434 f067 T4-platform T4-platform +33850387435 1976 aa3627d3bfb38627d700043a599622bb0e80ef2f f012 T2-extract T2-extract +33850532415 1709 2efb7a6323f992d5c407cc85e37b6d167c184ca1 f068 T2-extract T2-extract +33850538524 1721 dc703dad4a706a0f2c8130a869922677dba09ae3 f044 T2-extract T2-extract +33850542851 1730 90ba8990764674b2eeaca6fa372c654a63a04f51 f069 T2-extract T2-extract +33864581134 2048 f404dfbc7ae47f9912b18a0554a5e5d91f65be1a f070 T0b-nonproduct T0b-nonproduct +33868849245 1816 2ea73059f894df564e21be409908c2b5d3d53b37 f071 T4-platform T4-platform +33879400189 1996 8a1f2503c33bdd9a4d87782493af5640a505594f f015 T4-platform T4-platform +33879404796 2041 e74f041b3387e89fc3d3bc727cae772516099e54 f055 T1-tests T1-tests +33879411345 1809 b435bd87b6d0e6a8da43c2964829927fff331f5c f046 T4-platform T4-platform +33879415252 2002 c6ff57df0c216dc0fc86442f111607d8b3bd7ef8 f014 T3-mid T3-mid +33879421262 1741 fbda6bacf78b47e4c498dfb44ae653f9d10a4add f072 T0b-nonproduct T0b-nonproduct +33879427336 1742 e63bc6f06a03e93fad9e962c9b9aaf4b0309306e f073 T1-tests T1-tests +33879430001 1976 6f7a4af4a3a1716a5aeb5cae60a112e2231195a5 f012 T2-extract T2-extract +33879554682 2043 7e47043acce9bdfd87e7becc7fe7a348b02753ec f066 T4-platform T4-platform +33883643915 2042 6b0c44afcfeae65eed82e2dbbd58f00b043f21f6 f065 T0b-nonproduct T0b-nonproduct +33883715988 1649 f195c6aa81f425882626cca1186116ec64a0143e f051 T4-platform T4-platform +33884431041 1649 89186a26bcc37ae51f943b785270aa66716c832c f051 T4-platform T4-platform +33885636468 1933 8d46d2586255fc85e35f00ff8b814424b140e623 f074 T4-platform T4-platform +33887517545 2051 e2195dcb8f6fba33b72da60c1f3fffc83cdcd714 f075 T3-mid T3-mid +33891234168 1874 8069c7cd4c73ff7ea4513d38d7dfd08c350472a4 f030 T4-platform T4-platform +33892259683 2055 36bee9039848829c7d2271c44a46515cdaa8d870 f076 T5-core T5-core +33892321505 2056 24f54c0eb64c516941abee2ad0d7de758b34ac86 f077 T2-extract T2-extract +33892540012 2055 132e8fc32309e843ccd97138c8f032e317333673 f076 T5-core T5-core +33910645341 2051 1067e519c04f6273dd6e2b95b16bd0a9db4c3056 f078 T3-mid T3-mid +33913569180 1851 20c7dd3dbbba55770b82a99f6fcec6120f362873 f079 T4-platform T4-platform +33914255147 1914 7b92d2a6218936767b112081b9aa4e79d5b81dd9 f080 T5-core T5-core +33917205956 1849 39c74bdd5c8b5dc3bcd43f962195add0ba794150 f081 T4-platform T4-platform +33918309198 1796 ad479b61e9f23c01606f42c7d44ca174f104a2c8 f082 T4-platform T4-platform +33921848127 1597 335279ee403922c0b84c3972c3fc4782ffe878bc f083 T4-platform T4-platform +33923008470 1597 0094133cca7d92e587c4d876ccdb37140515fa7c f084 T4-platform T4-platform +33923354326 1766 797a11a15eff6154428aeb268c0b55edb5647a7a f085 T3-mid T3-mid +33924300711 2059 e0f37d7cb305cd2ff1d709c3ba1198cf950e83df f086 T4-platform T4-platform +33924381537 1597 3cd0043d0a3dc282c6a36ae0a262ebe02a4cec0f f084 T4-platform T4-platform +33932138466 2060 3a8b8e82120872a56ca4d0157260569d90abe85c f087 T4-platform T4-platform +33934626037 1784 4cf2acf2b7483ffa0fe3918f1a38e6d72d08b743 f009 T4-platform T4-platform +33935475331 1597 b900338a71c7395ade83c4e371ca277f2cb5498b f084 T4-platform T4-platform +33940288019 2062 9b4a3bc91e2f4d50e00deccf236c6b0db177f4eb f065 T0b-nonproduct T0b-nonproduct +33941972045 1597 10d05b38a63ff1efa62bfe2d419b01c72bcb2bc9 f084 T4-platform T4-platform +33944160492 1597 a15d99e39fd9ea04722f064ed02dc726b13d2943 f084 T4-platform T4-platform +33947308282 1874 9ae4ba7648101603eada6e7cd54c6cdc284617f6 f030 T4-platform T4-platform +33947974349 2063 f8132b42ac9ee1fde464866b5f3b08939528f267 f088 T0b-nonproduct T0b-nonproduct +33948072527 2064 3979f12a749232e1f934949f58f68c83a8b66014 f089 T0b-nonproduct T0b-nonproduct +33949446878 1597 9427f435158662074d6576053a6675a403696543 f084 T4-platform T4-platform +33951152502 2065 7dfd2c064911a7f2b246b7edd610fab4f993240c f090 T3-mid T3-mid +33951945765 2066 4b73a518d720bbc8773970402ca96f5661726b78 f013 T3-mid T3-mid +33963617997 1874 41f52a0f3b948468c468ddcca36489eb5f42fff3 f030 T4-platform T4-platform +33970672289 2063 3b173288feafebac3f0845f53e481f69adcd70b1 f088 T0b-nonproduct T0b-nonproduct +33971609329 1784 b9da6dca03126ef0c8cacccb3321dd5d69ccf34d f009 T4-platform T4-platform +33979110285 1822 7fbaf02904363d38c88f9b749b4523a614feecd5 f091 T3-mid T3-mid +33979848957 1759 683d4bc312625a3d26819635673529ab02a0d0e1 f092 T2-extract T5-core makefile-by-name +33980369725 2056 4f82a1e56f0eef435fed0eac5c8dd06b5b30c2fc f077 T2-extract T2-extract +33980647419 2065 3e1a22897ccea3404b95126112009e8b1a719a39 f093 T3-mid T3-mid +33980897876 2065 73c509f4246b0f97ca772b6d9a3159181a7fa6b6 f093 T3-mid T3-mid +33980899537 2066 c4bec4a6b38ed66ccd97df06490e051d3d9d81cd f094 T1-tests T1-tests +33982832791 2067 b7e18c8940d2bb58d85339d27da0754e5aba92e5 f095 T3-mid T3-mid +33984123524 1716 439659b333895a49174dca1fe4343f356616210c f096 T5-core T5-core +33985769258 1894 b49c7a736c0789069e5c2d07a301143e382af53c f097 T4-platform T4-platform +33986188532 1739 b51411753e9423f1c67f810ecf54f9fce903cefc f007 T4-platform T4-platform +33986691207 2069 3b9052c955a9aafbb3baff874a93bba19afeaace f076 T5-core T5-core +33990039141 1941 b04f54506ab42eddcbd89232a0d47d8355edd596 f005 T3-mid T5-core makefile-by-name +33990592318 1971 f89f01e9456c8241a28fb909d930ec8d88b23239 f005 T3-mid T5-core makefile-by-name +33993950950 2059 3007dbf8426764a92ace4da111836b86bec6b508 f086 T4-platform T4-platform +34001348423 2056 e384f88f90d1f13ac95220f15de72e888a654c98 f077 T2-extract T2-extract +34001350759 1822 eb3bd818b597b9c1f317906b350cbf323f2a4995 f091 T3-mid T3-mid +34001352917 1716 26682d58c7595c4166d7b7d274885115f2dac324 f096 T5-core T5-core +34001355464 1759 b35f73123302d8d101323c3621e6fe755d4970c6 f092 T2-extract T5-core makefile-by-name +34001357884 2069 f8951524252b71be8d8d3ea1f86d03eb97d6e9d5 f076 T5-core T5-core +34001359042 2066 7da8bf15838c42f8e3f366fc2188ca63f2b61942 f094 T1-tests T1-tests +34001473433 1739 9a5e3f36a9580f76211300faa8b7ed5942169b47 f007 T4-platform T4-platform +34002279390 2070 c1b9c451c7f124936e635835ca4dd0cf8bf7d5b3 f098 T4-platform T4-platform +34003689551 1860 a44732b6f8ee6409cd12b135f14867f1b5ecab33 f099 T5-core T5-core +34010341126 1998 04ba2fa368db12a80091315bfa649626a36145e1 f014 T3-mid T3-mid +34011344933 1968 17b6dfe68b65ae7fae797487c69efdf110b0cbae f024 T5-core T5-core +34012479012 2065 c72fe3222a5f3caac2e87636252931f3cfce2619 f093 T3-mid T3-mid +34012788868 2041 874a972fb7c4eb06e739dce0a390fffb85d31f83 f055 T1-tests T1-tests +34012788874 1976 eb80a4ecff04d8bfacef20e1aa2ee29445a782ff f012 T2-extract T2-extract +34014013202 1874 a3500040e69af8284e3cac1398e00ae3d00a0524 f030 T4-platform T4-platform +34014527172 2065 60449f895dbaedb4e74c4f155a8e214d78486e12 f093 T3-mid T3-mid +34018899805 2072 b0f9398d59c0c8a5bb03b80eba84d30e630160ec f100 T4-platform T4-platform +34019712929 2072 06644428b6d0a6295414d23d2c0613f29a73e1d7 f100 T4-platform T4-platform +34019861168 2072 bae6977e91daf703b812136c70556cefe4e3c283 f100 T4-platform T4-platform +34035459918 1968 f3e1253859c299d194c608d91d5c89065ca0c9ff f024 T5-core T5-core +34037162475 2073 5abd77f5d9580d75f8600914b46495be21e9a4a5 f101 T1-tests T1-tests +34037415003 1971 9540d64c52aecf89ce8b0524b8fea69c2aafb0e9 f005 T3-mid T5-core makefile-by-name +34037489952 2056 9cb2e9dd1c9fd4e6b3cb9f939ec46264b05c75ab f077 T2-extract T2-extract +34037491375 1716 8ab6bb6edd69a4ec64df07c3910a5c035b42c937 f096 T5-core T5-core +34037493874 1739 53f55ebe72870695c15ace98101df5af6eeef38e f007 T4-platform T4-platform +34037496632 1759 bb21edbbbba442364b88598af97478a97e62243a f092 T2-extract T5-core makefile-by-name +34037543448 2041 99fa463023fe63520d5a23c29f777b9d91d553b7 f055 T1-tests T1-tests +34038315061 1941 ddb6533cd9c16e8e2be12aa791521420fb427881 f005 T3-mid T5-core makefile-by-name +34038319912 2059 306c02d44e9a464faf68640f183fa6e82fea0907 f086 T4-platform T4-platform +34038901925 1971 ede0e3b111054c165b0237124084894b45e0e052 f005 T3-mid T5-core makefile-by-name +34039040074 2070 9846c3f1711d557c78426913efaff340e37feb4e f102 T4-platform T4-platform +34044235267 2074 6db22aadb6e5a92b9b193608e2ac3f420e461ec3 f103 T3-mid T3-mid +34044659567 1998 c8ea1cad0dd34e6f6f8c542014a1a22885f67ee3 f014 T3-mid T3-mid +34044858364 2075 086422020f5fd9396b46384b10413cdca6994c96 f104 T4-platform T4-platform +34044911261 2065 d550dcbe73558bb773211a0ff9463daff91721fd f093 T3-mid T3-mid +34045088264 2076 a08b9ecbbda54793275cbd4e93fecf0ad4ff1c0a f105 T5-core T5-core +34063281067 1739 1d35def402f586b4c18bba41de33c881eda8cfa2 f007 T4-platform T4-platform +34064340654 2079 e410c86f270a013d79639577d82009698b207e8b f106 T3-mid T3-mid +34065487385 1998 98899ba5d62c9a66d038b24f3857b137d03d44b0 f014 T3-mid T3-mid +34066407580 1716 e3c0010267e783b4270e0ac7c8ee6332b61aeabb f096 T5-core T5-core +34066408930 2076 1b89228d90b32ddd46f494df8755f496f6aba030 f105 T5-core T5-core +34066410797 1759 fbe9eae11c237459da50a06ebd83f709d655d491 f092 T2-extract T5-core makefile-by-name +34066413612 2065 8eb63ecfe79ea1f33fd911b8913a8b704ecde9d8 f093 T3-mid T3-mid +34066415650 1739 76d0382f8f958238119c8e36bbdd204838d15db5 f007 T4-platform T4-platform +34066415871 2079 dab58c0701d8806ddac2783949f1a478225f80bb f106 T3-mid T3-mid +34066418484 1998 a143b0882d0457f2f56b2d734128f914325d555f f014 T3-mid T3-mid +34068996256 2082 16a739d32314faeee5539386a92afd2363dc1879 f107 T3-mid T3-mid +34069613782 2083 77a0c7d94156a43f19047330c7356be4ee12cba5 f108 T3-mid T3-mid +34071276309 1874 4ef5672c53777f9dd316510dacfa3b88e2166fe2 f030 T4-platform T4-platform +34074515665 1894 088fedb2d25b869621fd93db05b2f3aa69e30e65 f097 T4-platform T4-platform +34074587051 1894 4d3d06ac2aba832ba774cad396a3f445b290f0b4 f097 T4-platform T4-platform +34081576339 1971 e5c17de6217ec5938d18d8c51e2c0d7ead116a19 f048 T2-extract T2-extract +34081831353 1968 2b1dafd83d6df8b0ad8a064e1b23bc27ecf4de7d f109 T5-core T5-core +34085524473 2084 4e0b7e1d168744438f0602735c4fee3970f3caf1 f008 T4-platform T4-platform +34086907163 2082 d8dbf01c3f90257d98c7e23a21c5c6f7f64d3a4d f107 T3-mid T3-mid +34090735558 1723 c8e7f7e3cc26f1faf8eaff5889d6ff0abd75ae9e f025 T5-core T5-core +34090831729 1724 70b411d5cf392e1ad6528e9f78d391304368ee30 f110 T5-core T5-core +34090999743 1725 8152277aebdd974badfbe0ffce13dfd231a23732 f111 T5-core T5-core +34091031555 1725 980934b2a2cf4c1dd32163894b500e0c96587bc6 f111 T5-core T5-core +34091168476 1726 fa11ee4193bb7537e18865fb649d1e73e8be1db9 f111 T5-core T5-core +34091245271 1727 f9a7e1fece6264d1677ed5dbbc2894553f7ecebf f112 T5-core T5-core +34091261353 1728 4602cb100fb0dd404d49531158204fa3f00d9ac2 f112 T5-core T5-core +34091378540 2072 c72cb8bb950019b5451325e52940da956250f0d0 f100 T4-platform T4-platform +34091504731 2072 bc945a4d6c6716ee22d314b6b53a7d50ed007081 f100 T4-platform T4-platform +34091593211 1725 ca6a5d5bc408d59af6ae9ca1edab0d169664092c f111 T5-core T5-core +34091642820 1726 f870ce4cd80c0882568ba19438a3cb6ebb2e43a4 f111 T5-core T5-core +34091649819 1727 8d4c416579db0148869f3f5c7ffed3e0e90c4417 f112 T5-core T5-core +34091656935 1728 37e213860e8f8390f54880bfff717345d2da5689 f112 T5-core T5-core +34091819685 2022 84fffefa48cbb60ca1533d2433921787c8ea1031 f056 T4-platform T4-platform +34092213233 2072 838fb7090f3a08920363e9ee793824b4d6202bef f100 T4-platform T4-platform +34098390879 2085 9f57edfa576072398728eda2c6fc2585d2ecb063 f113 T3-mid T3-mid +34109049475 2086 b9d550b6f3e1c08ce6cf59140601205d609dcd52 f114 T5-core T5-core +34109055503 2087 45b28f0b11d3e74e28e492c6c234a0f7ed0b30e4 f115 T5-core T5-core +34113927999 2086 2082b2d2890ceb007e04b9ba77129d14a8f4df52 f114 T5-core T5-core +34113933101 2087 9a7445e93de55eaca53855057ce6ce1a6f234564 f115 T5-core T5-core +34115081932 2073 8e29e1568f442ed9422e0fcd24d2594ae0ba7893 f101 T1-tests T1-tests +34118689934 2089 dc34e63a0a0aa29cb70456b692733124aba65350 f046 T4-platform T4-platform +34126118251 2090 6b56d78e1bbb5715c9d0c715453244187bd299bc f116 T2-extract T2-extract +34132427862 2073 aec9bfe3751d8c1f2eb2cb48d8fe584b9d3fd973 f101 T1-tests T1-tests +34134175243 1725 8d57fa45ae548e23b11083072447a62ff053da86 f111 T5-core T5-core +34134183283 1726 c263f48d0307540b0837726ba5a3c2cd9d2ea012 f111 T5-core T5-core +34134191503 1727 94119b3803e276a4cc45f4b92ddaf3d1377e3889 f112 T5-core T5-core +34134201351 1728 f588bc374ab91697ccb4467f5df9686ada5cb62c f112 T5-core T5-core +34138662959 1784 85482e3c599d454d76ccef5e862a5dc6c38a898e f009 T4-platform T4-platform +34139187715 2091 b974adf6ed9b82d8669d1a65b1757816ce0d12df f117 T2-extract T2-extract +34139262637 1784 a21562666317f752f9921109a0c70427f3febe04 f009 T4-platform T4-platform +34141564461 2092 d1abca20c091b9623187c5cfc61f251e7bd7796e f118 T4-platform T4-platform +34143404498 2091 ec56ea8f60f0058bd1cd0fffb38cc0153b99f895 f117 T2-extract T2-extract +34143899077 2092 1f08f82adcd979ba2f0efb38a1ccfed13901f76c f118 T4-platform T4-platform +34146676636 2092 4f8290aae42ad3f2ca4767089057e74756311743 f119 T4-platform T4-platform +34159409001 1561 b939a2d6ee76e482288a0dbbf712cd94d0d57164 f120 T3-mid T3-mid +34168455959 2094 7219a24cc692500cdeb5cc192adbd47ac8bb6ee1 f086 T4-platform T4-platform +34168458726 2095 d3eb7e9823b43a8e2e5917d2d234367c1f88436d f091 T3-mid T3-mid +34168461878 2096 9e1faa7327f8192101f16d8d6d81020937475166 f121 T4-platform T4-platform +34180076668 1561 6875483ffe40017f79884872a325e2ff6b37befb f122 T5-core T5-core +34180195791 1561 a7941a4d2094aa6bbb0683e3a17371a0463de209 f122 T5-core T5-core +34186497464 2097 d653052508998144e39357a37f72a12448c769f1 f123 T4-platform T4-platform +34187802611 2098 5f9ac15818e3bbda9e031db54867dfab2b6c531e f124 T2-extract T2-extract +34187888044 2099 95f252447de4ae08707b199f02f296f28f8dcb21 f125 T4-platform T4-platform +34188664789 2100 2a47a6556cc14dd5ac036b3c2f3225c9925cdeec f013 T3-mid T3-mid +34190172584 2060 8622d6a0ed4935aedaa336d216d8b6b269732de5 f126 T4-platform T4-platform +34192737413 2098 f6c310c08e6dad38b92e953c85c7c5876f6df839 f124 T2-extract T2-extract +34194384003 2097 d9dec5dd3b334a514113670b42732ca9ea4a15de f123 T4-platform T4-platform +34194639858 2100 669f6a3741ef7b875aa5eaeda317acf1900bcd33 f013 T3-mid T3-mid +34195390569 2101 0c1d6f68dbf3f2c8215ae9c52003d785ac874a22 f127 T1-tests T1-tests +34195424010 2060 3c854ae217c4c5d2f48d8aaeb136541bb0d8b52a f126 T4-platform T4-platform +34196235240 2103 9f876f70ed13636a8f9a25ba043e8f682c6688f6 f128 T4-platform T4-platform +34236364592 2105 22e0335d94552dc1a9d9ef0ee8221e7665608209 f013 T3-mid T3-mid +34240237866 1784 18b2f8670c64fd83221617c5e7970fb95af17454 f009 T4-platform T4-platform +34244310689 2105 dcb10fe902905926eaf9325bb03254dde9cea6ab f013 T3-mid T3-mid +34249699686 1823 b09ef3df3289e74bae129df4df29886fefc75484 f076 T5-core T5-core +34249700997 1823 b09ef3df3289e74bae129df4df29886fefc75484 f076 T5-core T5-core +34253442941 2109 e65fffc5b1ac269c16d4371fc8c1ccbd102e267a f129 T5-core T5-core +34255410324 2109 840de91e384d2ea36dc2360ee12ace26d25b680f f129 T5-core T5-core +34257609138 2111 7b4eb4a00ac600060be4836ee4e1bbfa7bbedf24 f130 T4-platform T4-platform +34257629348 2112 21591d51168d26f931f64d62edc36013cd1f6249 f131 T4-platform T4-platform +34257632907 2113 00060cec854dbd38d7864aeb95a2d8ba1baa8ab2 f132 T4-platform T4-platform +34260896139 2110 d40ed1a6bbc968bbddf90abd3b38e74a131a1b5a f133 T4-platform T5-core makefile-by-name +34261770203 2113 0567dc70a097f36a953368978d6728b0a412b229 f132 T4-platform T4-platform +34261855843 1768 e8dcb28ba719e4272f605f3b961422bb1f3c3fad f055 T1-tests T1-tests +34263433901 2110 7bf366f63b3fd5211cec19e483ee2ed27145ca14 f133 T4-platform T5-core makefile-by-name +34263688699 2115 013e1f7ec7d4b981a1cd28bf35d5767c195f8447 f134 T4-platform T4-platform +34264299704 2116 3a4160b4610f83ea26daf69f1ffe7cb898bf48ad f135 T4-platform T4-platform +34273160491 2119 8135be28a764fad4ee55abe466762bbda7ebab74 f136 T4-platform T4-platform +34274955966 2116 7e73b48ef155a0e14a6e058be56287232e3976ea f135 T4-platform T4-platform +34275249072 2111 91e31211df9dd61459617d857d46b71699e34df8 f130 T4-platform T4-platform +34278140735 2110 a3c24a71b4c28e805b912f9f928b6b0b151f9495 f133 T4-platform T5-core makefile-by-name +34282381014 2113 ecfb9642e153ffc656d0bc2344b17e9d454ff916 f132 T4-platform T4-platform +34288397854 2115 92cb3b9a4bebbc79bd283e447104669ca068ea64 f134 T4-platform T4-platform +34315414268 2116 e45d705105a63f2fd9b35ab9e37ffbc640be8231 f135 T4-platform T4-platform +34315782814 2124 8134712dc43b159fd863244ce53ba23641f20f8c f070 T0b-nonproduct T0b-nonproduct +34327374288 1723 21ae02154a198265d7dbed13af4280aa2b1e5690 f025 T5-core T5-core +34327382260 1724 87da4422c34feb4df8ff369dab8d770c33d142eb f110 T5-core T5-core +34327387577 1725 d82f760040412d51d424f3c287d277e89d4fb199 f111 T5-core T5-core +34327494146 1726 9380b22a24b3ec85482fd209475f5b1641b5f699 f111 T5-core T5-core +34327502315 1727 579336cbdaa6493f47ed4de10f64387b6f7f16b8 f112 T5-core T5-core +34327526954 1728 9d5cfa53098003cb68bf1812a94ca51f5e046056 f112 T5-core T5-core +34379806132 1823 5e5e433d94880c283400215ba79094225a8b33df f076 T5-core T5-core +34392601650 2116 9ef7da8cbfba8de5526ccd05e7196f5fa73af8f1 f135 T4-platform T4-platform +34392735468 2110 91c63be5d4e41d44e0746ef533af4a0b1967d6b3 f133 T4-platform T5-core makefile-by-name +34392998828 2136 557cb0102863e66cbd5cc622509a29a4964d1831 f137 T5-core T5-core +34393093741 2137 4fa11d1ab0b5807a7eaed645a6e3675f0663def8 f125 T4-platform T4-platform +34408696449 2138 8116672a7e3a02aecb5cb0e6fd9e685cd5066fe1 f138 T4-platform T4-platform +34413434571 2139 df579830f3941e5ebaef73dc87d183aee358fbcb f139 T4-platform T4-platform +34414749652 2139 618849512d01a3d14fcb704532911f9d02c23436 f139 T4-platform T4-platform +34415791973 2139 62bafb76b380d3af1cd1d6228b4afea0082c2a7b f139 T4-platform T4-platform +34433093443 2140 a1d11e79087bb1284d6126b02b22c3ce7e72ab07 f140 T4-platform T4-platform +34446903088 2089 8f3c15044b8200a15ee1895c5127f4bf117da493 f046 T4-platform T4-platform +34456518090 2160 f7b56b900d4726221de2882d5a5d076b29f6758f f125 T4-platform T4-platform +34456521294 2161 c8e244d8ff1d51e57c9d757a8e6bad80dc67f107 f091 T3-mid T3-mid +34460157213 2159 8d353a573dcc4be7ae69a74685eeb819a5626b5f f013 T3-mid T3-mid +34460157936 2158 b5f119c3f27bc3601bc048b16bbfaf57af3908f2 f141 T2-extract T2-extract +34464851279 2161 6790e1ac0fc134360cda6544befc1af2e29b9605 f091 T3-mid T3-mid +34464853698 2160 b3af44832790fbc6dd6a7e09d18362f55149a4da f125 T4-platform T4-platform +34465154636 2164 70926a2956ce986bfacbf6e7ebfd398fd91ff266 f142 T2-extract T2-extract +34465653349 2164 9115f5154383bd091e125c956aa4f241ebac1c7b f143 T3-mid T3-mid +34470429949 2159 7e111f877b85b79efdd371475d7f71e61174f95e f013 T3-mid T3-mid +34484066063 2092 b2bb18cfc79e129f50b5da940413e5735bce09b1 f144 T4-platform T4-platform +34491583983 2092 b317506b5a62a3383e763011e5b148a5afd92a89 f144 T4-platform T4-platform +34491670375 2092 b037b488be4620a5e92424e7e390ee625cd7e5af f144 T4-platform T4-platform +34524166740 2170 5fc107a0a61419faf0a2495ab923aba25a37ddb5 f053 T3-mid T3-mid +34528400072 1823 ac75e587425c60154dbcaa7392c0522764c93647 f076 T5-core T5-core +34551561701 2171 4842e11b2d9f5e6c2ced39d12dc487bcc7d915be f145 T3-mid T3-mid +34595459266 2030 e65bc6b71e32d20aeafb42c00b6097a0e2ded2c0 f146 T4-platform T4-platform +34595482014 2030 eeff3f19ff97d60af4f34298aa38a652fe0705b2 f146 T4-platform T4-platform +34636198205 2179 6091db7104bcaa0b61566272290e04ce448851a7 f147 T4-platform T4-platform +34636557089 2116 f6a29270b72a5373e4d949ed3f4078a40830c57d f135 T4-platform T4-platform +34640188970 2180 527016258e0da33ef883966f618609f4cf58abf8 f148 T4-platform T4-platform +34649361704 2139 53700ee1c72f1a5c2551a83c74d20278e4ac664b f139 T4-platform T4-platform +34649395431 2137 ca36316c6b34a6081de24d69699665a20b16de0e f125 T4-platform T4-platform +34649399412 2113 ca86fdb057b3d7f6d31e179ccbe64629fb1b0921 f132 T4-platform T4-platform +34649404928 2110 33534653f10c052f2a5c7f1953756aeb10499be9 f133 T4-platform T5-core makefile-by-name +34649406520 2115 78a6d19f1eaaa73054639ab0f14c372c53cd1bb3 f134 T4-platform T4-platform +34649409769 2111 951b9ab022df391410528fe98769d091c203fc60 f130 T4-platform T4-platform +34651276769 2181 c8184ed94887a6b5e130726885b44a07c63a47b7 f091 T3-mid T3-mid +34656706072 2182 14bacf8919b64bea19b537b74c894f0f82783bdf f149 T4-platform T4-platform +34676336284 2185 eb5e34cfc141454e849daf413f648e73f74c455a f150 T5-core T5-core +34676377540 2186 f6281874bba9a78ac7543564ff8f76b5262daeef f151 T0b-nonproduct T0b-nonproduct +34676429093 2187 cea35fcc0520a0ba95139bd0210052f09b9cfa20 f150 T5-core T5-core +34696991657 2115 9a460b389ce3002ac164605c3627bb86aa3b9be3 f152 T4-platform T4-platform +34700514102 2188 734906379f235c07cce960ce6a57746ea3be48bb f153 T5-core T5-core +34703996478 2115 fa99ff64eb6579fb15f3aae1aff7da349cb2999a f154 T5-core T5-core +34708249832 2189 22cdf7d03db87bba19a76a072dd517dd88a54d40 f155 T5-core T5-core +34708361984 2190 8c2692c1ee32284c813c05746e833dea5dfedfe0 f156 T0b-nonproduct T0b-nonproduct +34710253103 2189 1ad52f5fb1b3d96255756de01ce30912b1899e09 f157 T5-core T5-core +34712092850 2191 27cacee1c2d3022fa0fe915b03ca4310042b7514 f124 T2-extract T2-extract +34713347879 2190 044fef7957d48a097b48f4ac3c505aa67de8164f f156 T0b-nonproduct T0b-nonproduct +34713405483 2192 9073fe4469bd222ca01bd48249b52bbf7ed5710d f158 T1-tests T1-tests +34720983404 2076 2b80b31306b3dbb9ac8bc0ad50372f724f1dd243 f105 T5-core T5-core +34725488475 2194 09c0e88a6484c5fb696d7fc7b5a5e3f32b8cba68 f159 T1-tests T1-tests +34729075944 1561 e0b9642010cbf003bef9b8b29ba4ebb02b38171c f120 T3-mid T3-mid +34729170802 1561 789c60c86bc75b342c52ddedcaf174d12ccdfa73 f120 T3-mid T3-mid +34745069386 2195 469c3dd996f23cdb629f1bbd6a751bc0f29a99f2 f160 T1-tests T1-tests +34746469011 2195 f9038b84eb3bf82c2dff4cc4ea7f1ec1d8d13f01 f161 T4-platform T4-platform +34747474867 2195 d21e6cc762a1816d8c274174c9bba01601b888ee f162 T5-core T5-core +34747919526 2195 085017ab2172c2f3d4cd3e27251033ecbc5b814b f162 T5-core T5-core +34748079069 2195 ac8f5b8a8137f15e95478be71ee6742e928ba5bc f162 T5-core T5-core +34751940360 2073 232764a12295a59f05619a2bd032c8f92af6f17d f101 T1-tests T1-tests +34752354822 1716 8d4fed61db63559a0961f11f821db21c26e7d24f f096 T5-core T5-core +34752961647 2196 81a8a03c820cb8bccafd0ebac355eede21b5e8bf f163 T5-core T5-core +34752965817 2197 823e720982e1bce047a9243bd9b24d9688dadb72 f164 T5-core T5-core +34752971903 2198 b5b69af7f8d0d1083974e993f0e9b92a435fa253 f165 T5-core T5-core +34753122921 2199 d14bc7051c8a32b2678bf10d5ee711637d606edf f166 T5-core T5-core +34758237901 2201 1167972031d427be6773b86ec6dc9903317360d7 f013 T3-mid T3-mid +34764451388 2202 7fedfe85c63446d3695b75d463a9b8767655730f f167 T5-core T5-core +34769627346 2203 848ce778766d73ab4eb09ba8c98001da8f587fe0 f168 T4-platform T4-platform +34769883104 2203 00411ce7bc41995783bfb16aada673515585446d f168 T4-platform T4-platform +34769958569 2203 d64c9f2afe6b2add648ee1e3eead48acda025579 f168 T4-platform T4-platform +34797944694 2203 581f0c74006beb3edd28babbebeda7e48b3fa1d6 f168 T4-platform T4-platform +34805101784 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 f169 T5-core T5-core +34822774278 2205 e0a6133293556d87de6c2ca43de674e3a643e339 f170 T4-platform T4-platform +34823711563 2158 334e0b0849241430d26c3fdb6ffc8ec7a676965c f141 T2-extract T2-extract +34823714967 2159 63cdde702d9eb366b58006986e1498a18ae7f7d8 f013 T3-mid T3-mid +34823719320 2160 c1dd44151fee8cfd815c693392a7c1c72def98fd f125 T4-platform T4-platform +34823723515 2161 80f8bda5670c8ac1956e2a8fac7221c40c12da47 f091 T3-mid T3-mid +34833703378 2203 3b7f2559213ee80f5cfe0e2329c418211e09749e f168 T4-platform T4-platform +34853838753 2207 45c94be627a3088c275ff4f6acdc1f118515849a f171 T4-platform T4-platform +34858382857 1767 036562df35486924c62cdb11a8c9518e479959d1 f172 T4-platform T4-platform +34876200704 2202 9724d903046626df0640ced218259cb58070e4e8 f173 T5-core T5-core +34884655464 2202 9e69cb7d9b7ad4b40fa5f8cbbb653dc70e108130 f173 T5-core T5-core +34886874258 2202 d89ccabef064661794269c0de726021736d72b51 f173 T5-core T5-core +34891515627 2202 d70b79417978a9f3ab0d1aa849879dbf049793cf f173 T5-core T5-core +34898586377 1823 d4ae05a03dae831972a9dae7d2a2dd2ffce39e35 f076 T5-core T5-core +34943783091 1781 7c88e64eb954e9400273d3ebb9f6f88f1aebc165 f174 T4-platform T4-platform +34986091885 1823 955e4c9c58504b3c8e7d3e42866d16fadbb6ca7d f076 T5-core T5-core +34993109423 2212 172787de9c18352bb29545da2426d9d61b260fc3 f175 T5-core T5-core +35010526411 2213 d401186ca09e3992bc0e804af3945fe1cf81d6d2 f123 T4-platform T4-platform +35010533052 2214 1f1f8334ca25502c3ed1d4e91e8c6144d07bb7e9 f013 T3-mid T3-mid +35010542675 2216 729c3acc0fedae0ff77629281caf6dba1ac487fb f013 T3-mid T3-mid +35010543243 2215 783547e4fd79bb6b38f0741b6118a719e4109059 f127 T1-tests T1-tests +35010546755 2217 193c60d66db7eb36c58a7d0803d3936f5dae1d70 f149 T4-platform T4-platform +35010550773 2218 0a1d229ba89c5a7ad6c63c1587f5a938cc890270 f124 T2-extract T2-extract +35010559332 2219 a335422f63be3b5711ad40742bbaba40b2600f25 f158 T1-tests T1-tests +35016014963 1907 50ee111714baa9d3bfe9bdba3b81e27cd1bdfe38 f033 T3-mid T3-mid +35018285825 1913 da7619474d5ad27ff5073cf505b0b6cd5d0db36e f041 T3-mid T3-mid +35037109751 2220 62fd80b842f00bd72e655d23c6afbfe12e199b6b f176 T4-platform T4-platform +35037798343 2221 22ce867efd88dff9c219f789967ed2612329ee6d f001 T0a-docs T0a-docs +35047093950 2072 e5076d215e3a31ee7f7528d9e5ceda83fd8e8a8a f100 T4-platform T4-platform +35048046352 2072 8199efa4710893eb77d7c98d2730683a6c51327f f100 T4-platform T4-platform +35072023020 2223 17a326a58b591e786790e79ceb3be6dc14ee3988 f177 T0b-nonproduct T0b-nonproduct +35113639467 2223 43620d39d27554548fc2d613ed707efc8676748c f177 T0b-nonproduct T0b-nonproduct +35129477140 2225 58c1c0caa7061ae868305dbe59a6c5e67004849d f178 T0a-docs T0a-docs +35142303658 2227 dce2c5be9710c364eaa3a654d417ffcc392b62a4 f179 T4-platform T4-platform +35145042859 2227 3d848acaa7d22885d5040c957b3aecdcc4376107 f180 T4-platform T4-platform +35186676917 2140 b74c03558045bf29b7ef95c8aca803c15d3c7594 f140 T4-platform T4-platform +35272232918 2230 e0becdeb094dd5e898997a2add826a9f84befbd7 f181 T3-mid T3-mid +35273896570 2231 7054e4ff8af76f4fc1fe89407874e9910d0e412d f017 T2-extract T2-extract +35295711667 2233 f0a86ef2c2bc352a479162cca79d1aa64598f09e f182 T5-core T5-core +35303378012 2140 b52df4b0ce5c38667277716cd97ede964684802c f183 T4-platform T4-platform +35303488221 2140 ca3461170fefc959fa7688fae4e212d6e9f2f99a f183 T4-platform T4-platform +35312734228 2140 d85fe1d513ac97c8044b1065bbd0b7b9ebc87ec9 f184 T4-platform T4-platform +35314622268 2234 9b4d697a57a03f242164db956843a5313168005d f185 T4-platform T4-platform +35330927778 2236 949492b50c7dd81271f0180d8425d32316becbcf f186 T5-core T5-core +35397596933 2233 1d58385b513a414ead34544346e86549dda3c4b1 f187 T5-core T5-core +35443109227 2203 e176ae552b99f0fd08a2dde8bdf52025a3e20059 f168 T4-platform T4-platform +35446976110 2233 8c1a9d61c27292bf7a52f10fb54186d4e24de28f f188 T5-core T5-core +35448941856 2233 9db5217bdbb779212c5d066ed1a84f7ca6fc0282 f188 T5-core T5-core +35467285032 2233 9db483402ea2fd4e0a5ff3bdd2b1fc50a7518ec9 f188 T5-core T5-core +35470450948 808 bf2c54e0ef45d0d9767d7945a160f630f890f27d f016 T4-platform T5-core makefile-by-name +35470489882 808 80d277834a233f2e7a2df68b8c08a1f233cf3dfa f016 T4-platform T5-core makefile-by-name +35472952969 808 5515ca4ec6b20b62ed0530489785e4f131fd5159 f016 T4-platform T5-core makefile-by-name +35473919864 2225 c067c771b9f93d08a3e614e68aee7d710d2f256c f178 T0a-docs T0a-docs +35475706415 2241 6166934defd9bc5b687ab9c8de3779e71a844395 f014 T3-mid T3-mid +35475754479 2242 e8a46c16e233bff111092dff18985cca94890bcc f189 T2-extract T2-extract +35481840120 2236 aac550b732040acfe6ab6c0bfe5d0cbef5d9f9e9 f186 T5-core T5-core +35483670414 2243 243b405afeb3998707ea4d556f3178c8e8419cab f150 T5-core T5-core +35483768695 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a f190 T5-core T5-core +35486887056 1999 aa4da14d23dc28df58b9881dc66d89c0d1c0d788 f021 T2-extract T2-extract +35493947102 2236 2caafc0c41bd083092afeb692824e718a8e1e007 f191 T5-core T5-core +35495099219 808 83667268d9c435c2634994efef45b09daa98904a f016 T4-platform T5-core makefile-by-name +35498544795 2227 656fe99f9934654bbab927d29add13f7822e1bc8 f180 T4-platform T4-platform +35499978643 2244 56de845327dce03cdd1269b8aa73c6a1ecdb5cfa f125 T4-platform T4-platform +35500380927 2245 ed78ff056e89da814ff5ae05da6fb32b7e10c0db f192 T4-platform T4-platform +35501823298 2089 e171bd48b5e5845ec6f00db65ff300caac4f7304 f046 T4-platform T4-platform +35502589515 2140 618fbca0c39da7157c7f01a26071ef3a4d4a5b00 f140 T4-platform T4-platform +35502728596 1766 81820262e61f6e7f11920a5c4596b4297e793cf8 f193 T3-mid T3-mid +35502852336 2246 fed0c1b4e3563f1567b3ff85652ec51229014a83 f194 T5-core T5-core +35502954849 2246 2eeb15f434d9520e0050fe1f5b6c4c8316a4a69e f195 T5-core T5-core +35503248954 1766 4eb3af36c7b055987d5c0764482135658b28c550 f193 T3-mid T3-mid +35503753154 2246 5b4ea78927772d00f11abc41f217f89e714ace54 f195 T5-core T5-core +35508860520 2247 9b1dfda875736a91073c57badce3dea6d0f03080 f196 T3-mid T3-mid +35509728718 2248 96a2fc35dfcf14662cee491430366f4e8d8b8149 f094 T1-tests T1-tests +35514366896 2249 051702c6f9163f0a19dda19ea14ac1b72dc7eb3d f197 T5-core T5-core +35515754123 2090 7036c4d5996253c05d720ec4d0b3008509b4cad4 f198 T2-extract T2-extract +35517816031 2090 f9350caae0ccccf823d8cde85683a900b257f239 f198 T2-extract T2-extract +35517948650 2251 151d6077cf418a42628bc7e38bda4f873877f147 f199 T4-platform T4-platform +35518289769 2252 4ecd8ab2c3995cc92c1a8df4f5e2a1e2d3a8c342 f200 T2-extract T2-extract +35519906910 2253 458b0db1ba3274ceee1e975cfd7300f13aef82e9 f201 T4-platform T4-platform +35519951214 2254 8aa77f89ce1ef56c98d989b03bc35fa4c6289b8a f017 T2-extract T2-extract +35520101152 2249 238f9e6c6cad0e086eb06c19f1a6a303fbb30fdc f197 T5-core T5-core +35520168086 2247 9af73b327e3899c45fc1faf13b590c09efa2078e f196 T3-mid T3-mid +35520178069 2005 fcf451c6a6dda768dfec5a8f61a01ac1e1cfa6bf f034 T2-extract T2-extract +35520796432 2255 0541b033fa874c38167e4f4d204ad452f1d96d08 f202 T2-extract T2-extract +35520869334 2256 b565e9d8b06e8703f1c15a4263a987837dd0c5f3 f013 T3-mid T3-mid +35522168154 2257 af3e003ad8e67cd1fe3b20dafaf4d33a4b351852 f203 T3-mid T3-mid +35522190984 2258 de4a88f22242f53f724fc6cc8dcaed79642dc5cd f204 T2-extract T2-extract +35523164377 2259 460ebefc87df4dd5142bdc339702edd4feec4696 f078 T3-mid T3-mid +35523331086 2260 25d08258e567ec417bf7b18b14a63e30162fc7da f205 T4-platform T4-platform +35532624016 2261 28d62001fd1e5d0a18085a8a662a1b806747815f f206 T0a-docs T0a-docs +35532636527 2262 16a7291b56aafdcde27928f892d477e8b94269eb f207 T0a-docs T0a-docs +35533089935 2257 c1128db8187db2dac27a7ad14120fc09802c8018 f208 T3-mid T3-mid +35533328007 2263 cda5e3c9e5c4d2251467ced00ff305205c368d17 f209 T4-platform T4-platform +35537157817 2005 6eb8992462e506bf0c58aec88274a9a62a07676b f210 T3-mid T3-mid +35537525864 2005 fcf451c6a6dda768dfec5a8f61a01ac1e1cfa6bf f034 T2-extract T2-extract +35551212771 2236 39a8bc3f2a7fbbc4d86ec8379b669a31ed1f8318 f191 T5-core T5-core +35556558511 2140 af4604189bf23c14dceee9bccd89a16f5bfcad8c f140 T4-platform T4-platform +35561381902 1724 42a3d7105548c65ee12f6dffabea718573501601 f211 T5-core T5-core +35565389237 1809 19d9e4ac4d0a8c6839b627c0cd5890188121fc2b f046 T4-platform T4-platform +35565392645 2075 e321b1fceaaf4c9f03fbe82547f02c1079e3b760 f104 T4-platform T4-platform +35565401625 1784 381f64c15ebd0a676b85347acfcfd3f45c806a11 f009 T4-platform T4-platform +35565472800 2260 2dba3cf81750f245d8c1d912997f04be5615df99 f205 T4-platform T4-platform +35565493742 2005 7d01d3ec031dc26759283fff96e979c3e0ce270e f034 T2-extract T2-extract +35565496531 2259 6f2f83618134bee208c98c47931579f27a712809 f078 T3-mid T3-mid +35565502343 1768 6115e5785dc01f5b403cbfe7f184ffbfba058e45 f055 T1-tests T1-tests +35572275939 2158 d4931241adaef732066afb5ebf1cb225e2474403 f141 T2-extract T2-extract +35573014946 1716 95fa63441bf80c9ed871fa090ba925b8b6106d83 f096 T5-core T5-core +35574966373 1724 81ff2cabcfeb4619e24307c0267a1244f38afb40 f211 T5-core T5-core +35575687750 2160 2e4b45931b69671a92402f8fdf51f85542eff268 f125 T4-platform T4-platform +35578606399 2199 fa138acf44525ef94cfd6e16772d601da5623aef f166 T5-core T5-core +35585540293 2198 9574c316e43527d6ca90eb007a9c8fc24fcfd88b f212 T5-core T5-core +35597253045 2268 f0fac62b7140f2cc3ccc011c74e798be7681ec61 f213 T5-core T5-core +35606545382 1724 93f86be139b4bff1d079205fc776babc67f67349 f211 T5-core T5-core +35607169136 1716 bc5b3dd216568c864b41303a674020a2d456ced9 f096 T5-core T5-core +35623513958 1823 c017685d66ed9bca5476313ede52837ad07f17db f076 T5-core T5-core +35632304230 1724 bc81c4a84f19f2f5d90d985ec69dcd5fc357ea25 f214 T5-core T5-core +35637728335 2140 fca33717d1b6a9171decaf4d1755c88f48dcb572 f140 T4-platform T4-platform +35638029971 1724 d48afe8d3070e360ca908d841d85adad344b2ed6 f214 T5-core T5-core +35638134068 1724 3743fe95a0fc40f584b88bf4cbaa0f9bdfd8e52c f214 T5-core T5-core +35639932135 2227 df83d25ace9885e252363cb04be25a17da85c9e8 f180 T4-platform T4-platform +35640451450 2269 65d60e72f5b86f5982b15f0d88826ba79a085aa2 f215 T5-core T5-core +35640454709 2270 f428d583008ce87281bac138c02bd4375c56d735 f216 T0a-docs T0a-docs +35641634636 1999 e032a8ee9409c7a53a5d732ddff67c15721bf1ae f021 T2-extract T2-extract +35641641970 2158 61cfb09b52ba40e8fb3ee9703c618b28739e3298 f141 T2-extract T2-extract +35643957306 1724 5033f97d5f5154cddecee760e5b2a5ad290b181a f217 T5-core T5-core +35664547053 2272 da4bd25a01b612517512ee628bf0ec652000e80a f218 T1-tests T1-tests +35665885098 2273 c314dea4c1409a3a68fb33706b49e272809b5079 f219 T2-extract T2-extract +35665890640 2274 343d7dd5e47b817999098605370f0eab706675f0 f017 T2-extract T2-extract +35666550961 2275 a041794f8e51ddf42342899be7b2ac26f951d777 f128 T4-platform T4-platform +35667708865 1741 c0bfd073ff25484c2d7b3b59dea937be6c92ed08 f072 T0b-nonproduct T0b-nonproduct +35667710693 1742 dfae618b0e9508cd4998b583ebbeb36cf458278e f073 T1-tests T1-tests +35667714864 2170 36730c7d895ae7541f780d650151b3590fc766cd f053 T3-mid T3-mid +35667829346 1724 879aa5f9623bab14e8fb903da23bf84a8d0bc32e f217 T5-core T5-core +35669146114 1724 89030aacb21df9b72f23b42286e3a4eee63fd43e f217 T5-core T5-core +35669196507 2140 2f557f53986976b2df1eb2a1a540c9d48cf91a8e f140 T4-platform T4-platform +35670303027 1724 b47ba846bbd1a6fe7359c86314c81fb33f5adaa4 f217 T5-core T5-core +35696787516 1832 6ae6cd4c3f280cb8e3ffec2858269055e390ffec f220 T4-platform T5-core makefile-by-name +35697012777 1832 9a9534cbc45d40afa4e8bf070030a806acea1f5f f221 T4-platform T5-core makefile-by-name +35697183623 2022 7498e2b75b535171b5a560c6493fe86b5ad881cc f222 T4-platform T4-platform +35697752061 1724 3889b47527084eccc7c54c8a3c7e2592c5d5d30a f217 T5-core T5-core +35698464152 1724 08d5e2062f1ee9aaf8dce8eaf77662cc7c96a1c0 f223 T5-core T5-core +35714885737 2280 f930ed6026c6f1e5f5b146a07664efed9d4da9c7 f224 T2-extract T2-extract +35730933863 2198 f913f0eaf5ee308619ef1fdd4f60534d4509fab5 f212 T5-core T5-core +35734984657 2281 34d172c9038a9b7cee78e42bb7768b93fbb8ed70 f225 T5-core T5-core +35734995796 2282 1d2bbc425988eb78fcd966dc8c9cfbdb814f0685 f226 T4-platform T4-platform +35766202700 2288 427bb73c638481842bd13d73ed171953469eca02 f227 T3-mid T3-mid +35767446435 2288 d7eba5a71d2a99d7b7bc588c666d22516ac0b563 f227 T3-mid T3-mid +35772088984 2289 38fc6ea995c9db78cedb10b9bba2b6aa79f616fc f228 T4-platform T4-platform +35797101033 2290 255463126650ab6deab3e62aab441b71479704f1 f229 T4-platform T4-platform +35797928618 1702 aecfb315d8ddbfab2cacbbd6e4a26b408b973975 f085 T3-mid T3-mid +35800424142 1702 26bd387753a9a08fa6de647481bf36cf338e5c28 f085 T3-mid T3-mid +35812291988 1702 6233394d8e893546af92876fb8d8da81e592cc2c f085 T3-mid T3-mid +35813226724 1702 5770ed83741009e71c12876c40b655758f4f4440 f085 T3-mid T3-mid +35822291393 1702 8165c34015699379d31fd31060c0f16fbe5447de f085 T3-mid T3-mid +35824077381 1702 24795fc665df85200ddc112aaddfe34572171ef8 f085 T3-mid T3-mid +35824588131 1430 6bb8ad6a7a1c99849d101322ca8fe81b6adfbc50 f230 T4-platform T4-platform +35826272552 1430 0d2f840c8ed2695db206acbcad1ad1c36a1f3958 f230 T4-platform T4-platform +35836874903 1702 f5ec03f0e1a7e90ace14c21490dca39c4c8bb092 f085 T3-mid T3-mid +35841856084 1702 492e59a181a27af324c82a8db3c4e2e37382d567 f085 T3-mid T3-mid +35841866366 1702 9e92e67649d62a2de63e57893290851d7247476a f085 T3-mid T3-mid +35842147817 2158 246e4190c3086d1074adbe8afe39bf4261db59b8 f141 T2-extract T2-extract +35846258328 1702 e9339ff1f5b28cb85d2810f434471700e2fc3b5e f085 T3-mid T3-mid +35862574659 2294 54c143ac932b6e291a17be0327fb1b5e86b99984 f013 T3-mid T3-mid +35899891384 1823 4a8c2eb422d9083ac3bf1e01e18e558e470ad099 f076 T5-core T5-core +35918117863 2295 f4c897110cb1f02edee37db7c8849e63894ed8a4 f231 T3-mid T3-mid +35936681521 2296 59345314187021f50c5e5c12da793b27b8485d7b f232 T2-extract T2-extract +35948281533 1724 48f9de8ccd7cc75d7e466b7d5167e2930dfe33f3 f217 T5-core T5-core +35948963693 1724 a8da9a366353bb0a67806b40f840500444ac963f f217 T5-core T5-core +35948982011 2272 9b5bf70dc400fdfe989ed2bf1b623b2103bbf2f0 f218 T1-tests T1-tests +35952295014 2181 282fbfcdabfec79c19c36f5bec5f75c22eb36df5 f091 T3-mid T3-mid +35957866374 2158 cf7eecd7d0b1288c7f2e290a1d1bfc1b1b6f8698 f141 T2-extract T2-extract +35960900747 1716 a6d3d5d820b07ba8544ddae1668e8107d7961532 f096 T5-core T5-core +35960904447 2199 f07c18702ac82957d0eb73f96f37d30184b7e6b5 f166 T5-core T5-core +35960910227 2263 fcdc6bedb7d05489462639da38c4ac755137734f f209 T4-platform T4-platform +35960928169 1768 92cafd1ec888362ef3223ad669a5aa4f378a54d5 f055 T1-tests T1-tests +36006341620 2301 64297ff3e8e142887fc36c933d57fd4d32924cc2 f233 T4-platform T4-platform +36057012376 2304 6f91a16fc2a753db248f122dea7e4b50763972d1 f055 T1-tests T1-tests +36057063533 2305 69b84cacff1b08f449e563c2d24861e6f10694e4 f234 T3-mid T3-mid +36081432074 2307 2a6dd92f658b15d571130b0eee14a67b95a36465 f235 T2-extract T2-extract +36081473175 2308 707f6f0db58a16b706b29d249bced477094ef92c f236 T3-mid T3-mid +36081733263 2309 a9a25357ec2dc57f3c6f2e353e7ca61a7575df06 f168 T4-platform T4-platform +36081918547 2310 2f922141972a18f0d95de4505b33f5362f51164b f237 T4-platform T4-platform +36082054353 2310 8955d98983e0ca1e1df4a2173b4b4273904566c7 f237 T4-platform T4-platform +36087900420 2310 7c24b020457924fee9a7acdd6cb8c5f7e262ca4e f237 T4-platform T4-platform +36093255940 2310 b2e9199773977f28d73a052d41c37b8c111750a5 f237 T4-platform T4-platform +36102327028 2310 d636aa7f21fb001325b61cd41fc431d5f255e21e f237 T4-platform T4-platform +36137231318 2315 9527694e90ee9dfd29d64940425f9f23290ed702 f238 T4-platform T4-platform +36146313096 2310 9b10ba281cfdba2676e9d24c445470c4b6f7d4e3 f237 T4-platform T4-platform +36150749742 2022 7a307abbf8c1c879edee3193dd482ef427c83287 f222 T4-platform T4-platform +36160883414 1766 9d53f32dc1753fcece6c0d2af27a5958f8ce1799 f193 T3-mid T3-mid +36164363201 2318 6d508350c7713e10e0e935625ad74d8401fe89e2 f239 T3-mid T3-mid +36164368040 2319 3faecc78144175a81acf76721c4f695cf80b5676 f240 T3-mid T3-mid +36164372390 2320 4f5c951dde792c9cee4e33e45119cef8fc62765e f017 T2-extract T2-extract +36164376373 2321 c6ccdd9395dbbafc6f66a49040b478b445387946 f241 T2-extract T2-extract +36164382846 2322 19f50de3d2e2bdea5f9054caf07af70f46d74244 f242 T4-platform T4-platform +36164406666 2323 e9f6596e82cac5b3377cf212e7199a286815418f f243 T4-platform T4-platform +36164410674 2324 a808295986d7599ddbfe8324beefacfdd5161c04 f244 T4-platform T4-platform +36164415830 2325 d56e046ddd08ac6bf8b14f5049a88382a10a1636 f245 T3-mid T3-mid +36164420947 2326 5ef8ccff7023db106df18cef0615776e7ed94686 f246 T2-extract T5-core makefile-by-name +36164427320 2327 098fcc883e2c720ee838df90f3475938715d4056 f247 T5-core T5-core +36164450931 2328 a8c4040e8a92dcf831c64f57458694ff8fa6e9f9 f248 T2-extract T2-extract +36164460893 2329 12f51855cab461cda3bd4c4d63d79f09fcb69732 f249 T5-core T5-core +36164465591 2330 e7324bb6904c6d0a295b956e83c8585219198358 f250 T2-extract T2-extract +36164472858 2331 a0c71225f7c0c5e4bee36ef235f966866abffb24 f251 T3-mid T3-mid +36164477427 2332 b9ad9ed1449540e06e9f814ded548d9f2bf52f49 f252 T3-mid T3-mid +36164497120 2333 e65a4e70be9c87366f66e93e252c0f307aa50ac2 f253 T4-platform T4-platform +36164502402 2334 357d71e198ae3cd4614b9e06e54957423291e5c4 f013 T3-mid T3-mid +36164505003 2335 1c73f8676c31e227c6ed478ccbb55662b34d6db2 f254 T4-platform T4-platform +36164512328 2336 b8b88bcf088b8cf3f6b44949e917d172aaa82d2e f255 T2-extract T2-extract +36164516453 2337 8e12fc8a69dc6f83296e660191448ef8ba7819bc f256 T5-core T5-core +36164537929 2338 dad40ccdb633c10557baba4049aec35049fa3b15 f257 T4-platform T4-platform +36164542709 2339 793e0b6127db3c8333b1dc29faf0b35167e3ceda f258 T3-mid T3-mid +36164547188 2340 b3bc2a80aad766d37477a163ae5b158913a20f45 f259 T2-extract T2-extract +36164550014 2341 40dad610eec783fe243e6b6141c1bebc75c23f0d f260 T4-platform T5-core makefile-by-name +36164556221 2342 c650292433534127b5f45edd066e56a06b3376d1 f261 T3-mid T5-core makefile-by-name +36165117354 2181 65fc0bb4f77fdbb6449199c147e1ed112654b151 f091 T3-mid T3-mid +36165123674 2158 d772b1e5af0ce80d08aa6603ea47d1f51f324193 f141 T2-extract T2-extract +36165128132 1716 ff127ea44c56ce21bd5a4f9cf9f51bb27523d0aa f096 T5-core T5-core +36165431134 2343 45f0c430d28cf188753f44536d2cac26564a04ba f262 T3-mid T3-mid +36165866718 2344 4b2b3b90adc44694d70624c9d0372752ffd0e2bf f013 T3-mid T3-mid +36171533714 2326 c0a06b3d0a90513e0d80f79514b869f91cb958f1 f246 T2-extract T5-core makefile-by-name +36171575767 2345 772ea06a6115128c3160f9631322b089aeb85a53 f263 T3-mid T3-mid +36176174515 2347 b220ecec48010db71ac0a77b2888fefe802c9182 f264 T3-mid T3-mid +36176260818 2348 886e6da479891b6dfe386ef25dfe5abe7ed2f080 f265 T4-platform T4-platform +36181164078 2289 8cd2ee5f421d0b7c06022091d59dbe71002de30d f266 T4-platform T4-platform +36183268529 2315 c27a53512da9469dc50fb243c1ecd3a55975ebfc f267 T3-mid T3-mid +36183549298 2350 99ccc7d64a275bba1c015b30df036597637363f5 f268 T0a-docs T0a-docs +36184450486 2351 951b2fddf68a6e6605b1664bd3252a2a6578ef35 f014 T3-mid T3-mid +36185562216 2352 8093d10b812d9a7840ae99d8f9d24a04cb0f9559 f269 T3-mid T3-mid +36185568093 2353 c64ea20feae160d1eeddadf09e7e0df36b08e818 f055 T1-tests T1-tests +36185818550 2354 3ef7d8cdc201115dd76cc373ea2c64a567a2defe f012 T2-extract T2-extract +36186982935 2318 b7bcb15c219761f27ff146063a50481303527563 f239 T3-mid T3-mid +36186985986 2331 6b7628bb6bbf041a8bdf069dd9d6bc8861d84e73 f251 T3-mid T3-mid +36186989409 2332 1d5ff68e508e78b149589396d547166ad41362e0 f270 T3-mid T3-mid +36186989799 2334 87471e1f1d6a01ca8be6ca45b968145d0de95362 f013 T3-mid T3-mid +36186993787 2342 7abaa91dea653af3e890759a63e40a09cfcb81d8 f261 T3-mid T5-core makefile-by-name +36187019606 2327 f5c8631f2032c5598b1bfe12d329b875532f814d f247 T5-core T5-core +36188745373 2355 ae578bc1cc32bb8f159a69c486f938ad745c643f f086 T4-platform T4-platform +36189334816 2356 d5a3110d9e1e82de5416807d1f81e3ef85049c1a f271 T5-core T5-core +36189454232 2357 28b41c35b71cb039988f39f72689dd657e30522d f272 T4-platform T4-platform +36189574830 2358 f3754d6197b626d5432ac84b0363b0d3a5544c5c f273 T3-mid T3-mid +36197747139 2301 ff8dd47011c43c622d3dc82b33be32b4493e4ea9 f274 T4-platform T4-platform +36199747285 2344 bac3fc80b532eb294fd6eda6a2cc93d656df9369 f013 T3-mid T3-mid +36199749518 2345 1810a8964caae06e33b7f3c6f944de570c21c292 f263 T3-mid T3-mid +36201323199 1702 5557f0ec4a54bcaf99bd7442b866579ab68578f4 f085 T3-mid T3-mid +36210099904 2359 efb231285d188ffda16a58f7a5ce8e0e6448638f f101 T1-tests T1-tests +36222963285 2315 2d2c0fd0c008973c63565c3e7f4591ed0b1bb379 f275 T4-platform T4-platform +36227652952 2160 bc960d87d663b7b4fc4c3ba558013c4609073762 f055 T1-tests T1-tests +36232110014 2360 1ccbb432ae1281ac96f743850be317fb541cf1db f276 T3-mid T3-mid +36238137680 2361 4dfeb2cefc402c7d1f1d3d01603a2e8409ed24cc f277 T3-mid T3-mid +36239758139 2281 9dca8d2fede860605943ee8dbd57ba6b927ae41b f225 T5-core T5-core +36239789418 2362 ed991154f212928df63ce5a5cd1159cfc8196f00 f278 T4-platform T4-platform +36239835633 2290 26e2d0cba6df7b1cb53e655d0b902bdb7debdbd4 f279 T4-platform T4-platform +36241153105 1816 3e67838b05e67915d472ec7698c80649ab8ad03a f071 T4-platform T4-platform +36241168954 1766 8f441c19fb8a32b8ccf43ffcf65df7f4dc2bc16a f193 T3-mid T3-mid +36242675954 2363 363575af920b05f911fc76ca964dcfa7ef1c62bb f150 T5-core T5-core +36242676040 2294 6e3d8c296f2f99375d9693e706b4818c72caad13 f280 T3-mid T3-mid +36242724677 2364 e94f5a70d01e4cf62b0c9f496175dcd930bcbd0e f151 T0b-nonproduct T0b-nonproduct +36242808005 2365 7549e6d09c021f6d3a385a37898f591626ae62b8 f150 T5-core T5-core +36245108554 2366 06f97613a28a18bef0145f37c1036fa60e73533e f281 T3-mid T3-mid +36248365534 2367 8f803f7262d1d1e58068f677f3e365048050b728 f012 T2-extract T2-extract +36248371079 2368 a59d53a14b779401621c44aa757e3bc9f0588ef0 f282 T5-core T5-core +36259321198 2370 79a0c92e33a53758c36d87d4124b12cc83dac5be f236 T3-mid T3-mid +36259389383 2371 0dba49208e8537eda2c3355387c0307f6ee95075 f283 T2-extract T2-extract +36259523618 2372 6f17caa7a763d9987ebcd7ed33063d47d34bf635 f284 T4-platform T4-platform +36262536017 2370 12de590a4935488d83380a33865cbb59a7a16c32 f236 T3-mid T3-mid +36266366437 2373 77ab4bea87008547acee60435a8e3df29d90a5cb f285 T2-extract T2-extract +36266372862 2374 7c7811f81aabb4c1fdc0914e78d46b71493a9dce f013 T3-mid T3-mid +36266444913 2375 3ad76fd7728f3b19ca1100b10830e986b7f631b0 f235 T2-extract T2-extract +36266447071 2376 5a9c72d8f8c65b38c66a29a34184b49edf3378ff f104 T4-platform T4-platform +36268746769 1702 8ffd03ecd9ccae5a32508801645fb1cac7e4a4d1 f085 T3-mid T3-mid +36270666016 1784 ca58149ae4757325158af07e4e3b8968d315e5da f009 T4-platform T4-platform +36271539681 1784 71ac6ecc15d3ab3ebcd31f0e467071a506006ca2 f009 T4-platform T4-platform +36271900288 2367 075f40e9d4ccbfead4df0e199c7730f94c2154ff f012 T2-extract T2-extract +36274019750 2304 262d7ca7659b3f1f6ee619cda2b5f6aad2815314 f055 T1-tests T1-tests +36274021183 2305 3d9e5ed5cf1ad1e4bf89b385d0be28c8f011f52f f234 T3-mid T3-mid +36284225343 2370 208be7d9374bb27d2875e3568265b591ae7c4b5b f236 T3-mid T3-mid +36284230521 2371 eafaab15c802c46ee22233b50f0e9474b0a3085d f283 T2-extract T2-extract +36284315887 2280 756d7f42d4aab0045d75e91651cead399e4a7b35 f224 T2-extract T2-extract +36285289867 2315 a1ac80740ed800d84a9d5e44d725bdc29d99b88e f275 T4-platform T4-platform +36286798412 2072 1a37f0dd52ab62cfbb439c5a2f72afddca4cb009 f286 T5-core T5-core +36299419426 2207 631317536426136f580e34d3c25dd5a951d443a8 f171 T4-platform T4-platform +36313881874 2381 9d8f51d7f3340381afa450aa33cfd320879d6ae9 f060 T4-platform T4-platform +36323957389 1702 b46c6d43f2efe6f0a1de8c77b73085183aa5da1a f085 T3-mid T3-mid +36324735265 2376 07eb426c95386e299a6aa6adbf2b4eb9f272ff3b f104 T4-platform T4-platform +36339435944 2370 e9058e11db9ed94e401ad711b2016249970a9040 f236 T3-mid T3-mid +36339438890 2371 4d21c3bbd34b420c8987e8588b88b5fc5446116f f283 T2-extract T2-extract +36346241955 2360 09f704d23f0d990dfc72b916d9daea80acce73ce f287 T3-mid T3-mid +36347358048 2361 0d720d52b253cfb64e7bfb1eee034bc3035f1a04 f288 T3-mid T3-mid +36348236142 2366 06ef2f64caea016fc792e9069d5a3befc30bca4a f289 T3-mid T3-mid +36352906531 2375 7707c9d7918263cd446a4b54d4eb48c3fea91048 f235 T2-extract T2-extract +36353006100 2247 fa2292111802b5f3b66db3e4dd168280b53ac17e f196 T3-mid T3-mid +36353528883 2249 a45a99c9c73dd54a4625d680da700f7cb0387eff f197 T5-core T5-core +36353741468 2385 1b2e8f1fac105b0eaf15e15b95c3ef336bf0a44e f290 T4-platform T4-platform +36354332711 2181 82a6ddff81c6c8583c73f3d628037e41a9b412f0 f091 T3-mid T3-mid +36354336229 1781 fd2456316e7b5dc27125497f640dc7e41760c104 f174 T4-platform T4-platform +36354339289 2065 87ba464596337fc6574a825ef88392e70a733dc9 f093 T3-mid T3-mid +36354790786 2252 df8dd1011bd1987d0ca5a45f324775aad2411f54 f200 T2-extract T2-extract +36355512051 2089 11957ae6554b7d768095dcda95168331bd654363 f046 T4-platform T4-platform +36355610699 2371 ee9d1365e2c17d4e2bbdd84ac10ec6b89cffca5f f283 T2-extract T2-extract +36357921046 2344 7f649b29dc778b0f54c28dfcc2f25b76f3d1947a f013 T3-mid T3-mid +36357921739 2326 259dbb67bee1d3547ae61a98c9d0c1cf4e491d44 f246 T2-extract T5-core makefile-by-name +36357923794 2352 ea6fa49fc2c209de69c1b0f393d9d2cc25b61419 f291 T5-core T5-core +36358160313 2389 27c87104a57eefe33543a04e3e43eb0721df8b4a f292 T2-extract T5-core makefile-by-name +36358451648 2207 8bd9d37b156641ecf1c82b16b0ec0a8246e8c201 f171 T4-platform T4-platform +36358713716 2295 9f5f5ab56fce2baf095b75f1e7a8dc98368dbcad f231 T3-mid T3-mid +36359201669 2073 9130fab63d23b5386f12fa4cfa57961b247ff07b f101 T1-tests T1-tests +36359210035 2385 c03575ef1ab1abb51793d03303ae3e5d4cd3b708 f290 T4-platform T4-platform +36359646097 2391 d450275e20b663e3c5c8a06627030702e61e4184 f017 T2-extract T2-extract +36359899206 2385 cb307a496de36c9803dffea0815f3ea96bf849db f290 T4-platform T4-platform +36360333971 2389 5ff3e8d6fd3fd87d46807084058e2944f6486d76 f293 T2-extract T5-core makefile-by-name +36360464106 2305 8a49fc684ad08ac2d99591e0b364249570c9e676 f234 T3-mid T3-mid +36364475838 2392 19d144477b9558dad148d3f555ec6ede16caa3f5 f294 T4-platform T4-platform +36364478399 2393 dc735c01fbbb4eddbbb73ff4df6007ab68413a4d f295 T4-platform T4-platform +36364479687 2394 26cbccd998819c428589156a82e167db061522ae f296 T5-core T5-core +36370343201 2395 f200d7477f192caedaa5b4cda7d9eccc2022a069 f297 T2-extract T2-extract +36371911963 2396 629e9c2c1f87e2ac8b5d644d38ff4e935928752a f298 T3-mid T3-mid +36371921838 2397 4c7bc08ff02d49afa07808309d5586bc61724ba4 f299 T5-core T5-core +36416318776 2315 3327ee5935a7b03ef5f850c12c163c11ef9d91e2 f300 T4-platform T4-platform +36440070485 2305 853192aa7e76eedceb1fce6e2a6874948cf1c1a4 f301 T3-mid T3-mid +36440993073 2207 343cc949270a3e18e317fd6cd781167ed2fb3dad f171 T4-platform T4-platform +36441585217 2368 f277c5e0a9aad35c730e4189b969b74964614d87 f302 T5-core T5-core +36451035334 2207 1c840df7495e3b71924b8efd7e3bbf53c9076abc f171 T4-platform T4-platform +36453341395 2401 9d5b6e998514b76dcc95b55f362325b3d49945da f264 T3-mid T3-mid +36455603700 2401 874286abb9817db94e7789c7938921f6a03f3448 f264 T3-mid T3-mid +36456464356 2402 555727b50da6086f00c00f57876e2632caa69c45 f303 T2-extract T2-extract +36459403945 2394 78a2ec399fec3dc28e61439a4bcd900b7c64c23f f296 T5-core T5-core +36459418705 2344 b205f0b51dacd89da57f6fcbeb46f55617606091 f013 T3-mid T3-mid +36459497707 2403 95eaf01755d18c501fa8d1c5dae2255cf460b9a2 f001 T0a-docs T0a-docs +36459501942 2404 fe3c285fdc63bd9abe027026edd7adffe5f62677 f013 T3-mid T3-mid +36459506386 2405 460153204ba9833913e5245ca894f783c1d7229d f304 T4-platform T4-platform +36459537782 2406 a1b7c05685e18597f3461783d4bc98661f05427d f305 T2-extract T2-extract +36459545006 2407 c02dff113a90aa6bbc3d89fedda962a53f8b4c64 f306 T2-extract T2-extract +36460081689 2280 6ba9a21eed2165932b90f7ede772d77b046eb08a f224 T2-extract T2-extract +36460156038 2408 23d48336d3367fb27cbde54049b1414a2aea1b48 f307 T1-tests T1-tests +36460162959 2409 4c6ecffdcdb8d78c0704f0d7e825f676ad77c240 f308 T1-tests T1-tests +36460990240 1907 b74cecc53cea20abdc64dbdc484f5ba0b44f5789 f033 T3-mid T3-mid +36461038395 1913 71ebbb9a9b54f2dcef84321de939429dd7707600 f041 T3-mid T3-mid +36466048726 2410 533eee07ece1d01ff2c88da55972c7720cd1604b f309 T4-platform T4-platform +36466057374 2411 0d783bcca4edde896d5716e8a4060c3100595973 f076 T5-core T5-core +36466065600 2412 ed76cf8e88cfb1f26c82110e46a518c13577f788 f310 T4-platform T4-platform +36469132661 2360 5a77358277a83d7651965f762a673be76fb157b3 f311 T3-mid T3-mid +36474434156 2402 0a658840b8f029e7632910284281e32286c1f0aa f303 T2-extract T2-extract +36474499483 2402 f88aaeb8f9db801545c21e085812559df9f80083 f303 T2-extract T2-extract +36474752681 2418 3db66f22ad7d5c435559869a34b6e14b1e54793b f312 T3-mid T3-mid +36476594662 2402 33a9bbe85eb922bf01c5297f09cc50044f39e351 f303 T2-extract T2-extract +36476662334 2419 3f53a308f4034f4d280130fbcc275f545d7bf05e f313 T2-extract T2-extract +36476726058 2420 e36930742a63efd2bbca491996ba8e37bfa5822c f314 T2-extract T2-extract +36476760149 2421 fd1b88374c0c21892c991c29909d22ed499c20fa f315 T3-mid T3-mid +36476819919 2422 ffa0b320047f4db99dc53f09e228c94703436ae1 f316 T2-extract T2-extract +36477490833 2423 ba5e7dd639a3d29c54023d1203ef18fc4e330c7f f013 T3-mid T3-mid +36484216122 2391 f297737db0510ecc573edf57a2a30a25e5fbfcfc f317 T3-mid T3-mid +36484725527 2391 538ac25edda676ca7d8cb8a4c7c5fb3a3a36fda2 f317 T3-mid T3-mid +36488109160 2418 702c3afb6bbab15e18e13f22880d5a61fa3317e1 f312 T3-mid T3-mid +36488232420 2419 82361ffdd66ffe39efc1ff9e00e0cdcc61223e4f f313 T2-extract T2-extract +36488247857 2420 a46dc5a9fbb39310027812234d0459d4bbcbe99c f314 T2-extract T2-extract +36488262530 2421 a85bef05f169e4f2c36cf4cf121a7b6d1309e155 f315 T3-mid T3-mid +36488285902 2422 c695cb728e4850b4152cd7cc54f06b6520372371 f316 T2-extract T2-extract +36491809136 2309 48265f5367d93c22cdaf2203d19dc65fd6eefea4 f318 T4-platform T4-platform +36492368152 2424 904eccabe39b03d8104de989a56f224ccf6d81d3 f319 T3-mid T3-mid +36495192814 2425 e548d971b3e471ca5a149201c39ba62ab1a72261 f013 T3-mid T3-mid +36499773006 2385 534274c1601034b5eca97ec286c0346389076a8f f290 T4-platform T4-platform +36500785757 2360 bb3f1cdd6f4f45ab416ba97ad09a404aebf21e36 f311 T3-mid T3-mid +36500790466 2361 8116eaaa426ff39a37b4078e0abd018b09398e58 f320 T3-mid T3-mid +36500796009 2366 4a0f9660623b53d4f9eccd7149d54482707fdc22 f321 T3-mid T3-mid +36504138642 2371 96f91005f0777e3fee5f5b5f9da69962ddf93695 f283 T2-extract T2-extract +36506100535 2427 f4efd0b86844bf2ac965ba9ec30c8805cc774e40 f322 T3-mid T3-mid +36508613643 2315 232730cb091d852bd7820bf1d5aeedc43aa860f8 f300 T4-platform T4-platform +36509039978 2170 5a3e82520408db86161b8fff08175aa30a0adc6c f323 T3-mid T3-mid +36535949326 2368 cc71a2087d32a7c06b0dcc2f925a9a623335c2ef f302 T5-core T5-core +36538846045 2170 1ebd1e40fe715ac9efb328b2c7cebd2c0a4e140f f323 T3-mid T3-mid diff --git a/scripts/test-impact/fixtures/2026-09-real-failures.tsv b/scripts/test-impact/fixtures/2026-09-real-failures.tsv new file mode 100644 index 000000000..6b6c92cc5 --- /dev/null +++ b/scripts/test-impact/fixtures/2026-09-real-failures.tsv @@ -0,0 +1,26 @@ +# The 22 real (non-flake, non-infra) job failures of the September 2026 PR +# pushes, from the lane analysis ground truth: which lane caught each. The +# replay asserts the selector selects that lane for that push. +# Columns (TAB): run_id, attempt, pr, head_sha, lane, classification. +34263688699 1 2115 013e1f7ec7d4b981a1cd28bf35d5767c195f8447 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) +34288397854 1 2115 92cb3b9a4bebbc79bd283e447104669ca068ea64 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) +34649406520 1 2115 78a6d19f1eaaa73054639ab0f14c372c53cd1bb3 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) +34649406520 2 2115 78a6d19f1eaaa73054639ab0f14c372c53cd1bb3 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) +34676336284 1 2185 eb5e34cfc141454e849daf413f648e73f74c455a codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) +34676429093 1 2187 cea35fcc0520a0ba95139bd0210052f09b9cfa20 codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) +34696991657 1 2115 9a460b389ce3002ac164605c3627bb86aa3b9be3 unix-macos-intel real: cli suite hang on slow runners (arm64, then intel) +34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 lsan-macos real: macOS-only LSan leaks (memory-core) +34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 msan real?: MSan stack overflow (memory-core, branch-specific) +34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 tsan-arm real?: backpressure nap-cycle bound (memory-core) +34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 tsan-mac real?: backpressure nap-cycle bound (memory-core) +34876200704 1 2202 9724d903046626df0640ced218259cb58070e4e8 lsan-macos real: macOS-only LSan leaks (memory-core) +34876200704 1 2202 9724d903046626df0640ced218259cb58070e4e8 msan real?: MSan stack overflow (memory-core, branch-specific) +34876200704 1 2202 9724d903046626df0640ced218259cb58070e4e8 tsan-arm real?: backpressure nap-cycle bound (memory-core) +35448941856 1 2233 9db5217bdbb779212c5d066ed1a84f7ca6fc0282 tsan-arm real: TSan data race system_info.c:274 +35448941856 1 2233 9db5217bdbb779212c5d066ed1a84f7ca6fc0282 tsan-mac real: TSan data race system_info.c:274 +35448941856 1 2233 9db5217bdbb779212c5d066ed1a84f7ca6fc0282 tsan-x86 real: TSan data race system_info.c:274 +35483768695 1 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a pkg-wrappers real?: wrapper publication crash-gate (cache-management) +35483768695 1 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a smoke-mac real: cache CLI broken on macOS/Windows +35483768695 1 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a smoke-win real: cache CLI broken on macOS/Windows +35493947102 1 2236 2caafc0c41bd083092afeb692824e718a8e1e007 smoke-mac real: cache CLI broken on macOS/Windows +36242675954 1 2363 363575af920b05f911fc76ca964dcfa7ef1c62bb codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) diff --git a/scripts/test-impact/replay-selector.sh b/scripts/test-impact/replay-selector.sh new file mode 100755 index 000000000..dfb14600e --- /dev/null +++ b/scripts/test-impact/replay-selector.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# replay-selector.sh — replay every September 2026 PR push through the lane +# selector (scripts/ci/select-lanes.sh) and hold it to the measured history. +# +# The rule set was chosen by replaying this history offline; this keeps the +# committed selector honest against the same data, as a contract step: +# 1. every push (767) gets exactly the tier recorded in fixtures/2026-09- +# pushes.tsv. Where that differs from the analysis tier the row must name +# its documented deviation, and a deviation may only ever raise the tier +# (the fail-safe direction) -- never quietly select less than was measured; +# 2. every real failure of the month (22, fixtures/2026-09-real-failures.tsv) +# had its catching lane selected for its push. +# Pure data, one selector process (--batch over the distinct file sets). +# +# Usage: scripts/test-impact/replay-selector.sh [repo-root] +set -euo pipefail + +case "${1:-}" in +-h | --help) + sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; +-*) + echo "replay-selector.sh: unknown option '$1'. Please consult --help." >&2 + exit 2 + ;; +esac + +ROOT="${1:-$(cd "$(dirname "$0")/../.." && pwd)}" +FIXTURES="$ROOT/scripts/test-impact/fixtures" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/cbm-replay-selector.XXXXXX") +trap 'rm -rf "$WORK"' EXIT + +python3 - "$FIXTURES" "$WORK/batch.jsonl" <<'PY' +import json +import sys + +fixtures, batch = sys.argv[1:3] + + +def rows(name): + with open(f"{fixtures}/{name}", encoding="utf-8") as fh: + for line in fh: + line = line.rstrip("\r\n") + if line and not line.startswith("#"): + yield line.split("\t") + + +with open(batch, "w", encoding="utf-8") as out: + for fileset, paths in rows("2026-09-filesets.tsv"): + out.write(json.dumps({"id": fileset, "files": paths.split(" ")}) + "\n") +PY + +bash "$ROOT/scripts/ci/select-lanes.sh" --batch "$WORK/batch.jsonl" >"$WORK/decisions.jsonl" + +python3 - "$FIXTURES" "$WORK/decisions.jsonl" <<'PY' +import collections +import json +import sys + +fixtures, decisions_path = sys.argv[1:3] +TIERS = ["T0a-docs", "T0b-nonproduct", "T1-tests", "T2-extract", "T3-mid", + "T4-platform", "T5-core"] +DEVIATIONS = {"makefile-by-name"} + + +def rows(name, width): + with open(f"{fixtures}/{name}", encoding="utf-8") as fh: + for number, line in enumerate(fh, 1): + line = line.rstrip("\r\n") + if not line or line.startswith("#"): + continue + cols = line.split("\t") + if len(cols) != width: + sys.exit(f"FAIL: {name}:{number}: expected {width} columns, got {len(cols)}") + yield cols + + +decisions = {} +with open(decisions_path, encoding="utf-8") as fh: + for line in fh: + row = json.loads(line) + decisions[row["id"]] = row + +failures = [] +pushes = {} +matched = deviated = 0 +tiers = collections.Counter() +for run_id, pr, sha, fileset, analysis, expected, note in rows("2026-09-pushes.tsv", 7): + decision = decisions.get(fileset) + if decision is None: + failures.append(f"run {run_id}: fileset {fileset} missing") + continue + pushes[run_id] = decision + tiers[decision["tier"]] += 1 + if decision["tier"] != expected: + failures.append(f"run {run_id} (PR #{pr}, {sha[:10]}): selector tier " + f"{decision['tier']}, expected {expected} -- reasons: " + f"{'; '.join(decision['reasons'])}") + continue + if analysis == expected: + if note: + failures.append(f"run {run_id}: note '{note}' on a row with no deviation") + matched += 1 + elif note not in DEVIATIONS: + failures.append(f"run {run_id}: tier differs from the analysis ({analysis}) " + f"without a documented deviation") + elif TIERS.index(expected) < TIERS.index(analysis): + failures.append(f"run {run_id}: deviation '{note}' LOWERS the tier " + f"{analysis} -> {expected}") + else: + deviated += 1 + +caught = 0 +for run_id, attempt, pr, sha, lane, why in rows("2026-09-real-failures.tsv", 6): + decision = pushes.get(run_id) + if decision is None: + failures.append(f"real failure run {run_id}: no such push in the replay") + elif lane not in decision["lanes"]: + failures.append(f"real failure run {run_id} (PR #{pr}): lane {lane} NOT selected " + f"(tier {decision['tier']}) -- {why}") + else: + caught += 1 + +if failures: + print("SELECTOR HISTORY REPLAY FAILED:") + for failure in failures: + print(f" {failure}") + sys.exit(1) +print(f"selector history replay OK: {len(pushes)} pushes, tier as expected for all " + f"({matched} equal to the analysis, {deviated} documented fail-safe deviations); " + f"{caught} real failures, every catching lane selected") +print(" pushes per tier: " + ", ".join(f"{t} {tiers[t]}" for t in TIERS)) +print(f" full runs: {sum(1 for d in pushes.values() if d['full'])}") +PY diff --git a/scripts/test.sh b/scripts/test.sh index 8b9cade28..9f00f6a9c 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -338,6 +338,14 @@ bash "$ROOT/tests/test_version_metadata_contract.sh" echo "=== Step 0y: VM leg verdict contract ===" bash "$ROOT/tests/test_vm_verdict_contract.sh" +# Step 0z: PR CI runs only the lanes scripts/ci/select-lanes.sh selects, so a +# wrong selection is a silent gate loss. The decision table, and the replay of +# every September 2026 PR push and real failure against it. +echo "=== Step 0z: PR lane selector contract ===" +bash "$ROOT/tests/test_select_lanes.sh" +echo "=== Step 0z2: lane selector history replay ===" +bash "$ROOT/scripts/test-impact/replay-selector.sh" + # Verify compiler supports target arch verify_compiler "$CC" diff --git a/tests/fixtures/select-lanes/cases.tsv b/tests/fixtures/select-lanes/cases.tsv new file mode 100644 index 000000000..cfcad1257 --- /dev/null +++ b/tests/fixtures/select-lanes/cases.tsv @@ -0,0 +1,48 @@ +# Lane-selector contract table: changed files -> tier, full, lanes. +# Driven by tests/test_select_lanes.sh against scripts/ci/select-lanes.sh. +# Columns (TAB-separated): name, files (space-separated; "-" = empty list), +# tier, full, lanes (space-separated, any order; "FULL" = every PR lane, +# "FULL -a -b" = every PR lane except a and b). +docs-only README.md docs/BENCHMARK.md T0a-docs false contracts security-static +issue-template-is-docs .github/ISSUE_TEMPLATE/bug.yml T0a-docs false contracts security-static +local-infra test-infrastructure/run.sh T0b-nonproduct false lint lint-mem security-static +lint-config scripts/memory-core-baseline.txt T0b-nonproduct false lint lint-mem security-static +graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false lint lint-mem security-static smoke-ubuntu windows-guards +memwaste-tools-add-memwaste scripts/memwaste.sh T0b-nonproduct false lint lint-mem memwaste security-static +non-gating-script-runs-contracts-leg scripts/benchmark-index.sh T0b-nonproduct false lint lint-mem security-static shard-completeness unix-x86 +test-impact-tooling scripts/test-impact/replay-selector.sh T0b-nonproduct false lint lint-mem security-static shard-completeness unix-x86 +tests-only tests/test_store.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows +test-fixture tests/fixtures/cpp_include/a.h T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows +contract-test tests/test_version_metadata_contract.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows +fuzz-harness tests/fuzz/fuzz_cypher.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows +docs-plus-tests README.md tests/test_store.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows +lsp-language internal/cbm/lsp/go_lsp.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +lsp-core internal/cbm/lsp/type_registry.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +extraction internal/cbm/extract_calls.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +pipeline src/pipeline/pass_calls.c T3-mid false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false codeql-gate lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +daemon src/daemon/application.c T4-platform false FULL -msan -diag -memwaste +main-c-is-cli src/main.c T4-platform false FULL -msan -diag -memwaste +foundation-non-memory src/foundation/log.c T4-platform false FULL -msan -diag -memwaste +windows-guard-tests tests/windows/guard.ps1 T4-platform false FULL -msan -diag -memwaste +smoke-harness scripts/smoke-local.sh T4-platform false FULL -msan -diag -memwaste +package-wrappers pkg/npm/package.json T4-platform false FULL -msan -diag -memwaste +memory-core src/foundation/mem_core.c T5-core true FULL +gate-script scripts/ci/require-all-green.sh T5-core true FULL +the-selector-itself scripts/ci/select-lanes.sh T5-core true FULL +build-script scripts/build.sh T5-core true FULL +makefile-always-full Makefile.cbm T5-core true FULL +vendored-code vendored/sqlite3/sqlite3.c T5-core true FULL +gate-workflow .github/workflows/codeql.yml T5-core true FULL +unmatched-top-level-path weird/new.file T5-core true FULL +unmatched-src-directory src/newthing/x.c T5-core true FULL +unmatched-script scripts/README.md T5-core true FULL +non-gate-workflow-forces-full .github/workflows/stale.yml T0b-nonproduct true FULL +harness-main-forces-full tests/test_main.c T1-tests true FULL +harness-framework-forces-full tests/test_framework.h T1-tests true FULL +harness-driver-forces-full scripts/test.sh T5-core true FULL +harness-scheduler-forces-full scripts/run-test-wave.py T5-core true FULL +lower-tier-plus-trigger-is-full docs/x.md src/pipeline/a.c tests/test_main.c T3-mid true FULL +empty-list-fails-safe - T0a-docs true FULL diff --git a/tests/test_select_lanes.sh b/tests/test_select_lanes.sh new file mode 100755 index 000000000..a421051c2 --- /dev/null +++ b/tests/test_select_lanes.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Lane-selector contract: scripts/ci/select-lanes.sh decides which PR lanes a +# change set runs, so a wrong answer is a SILENT gate loss -- a lane that +# should have caught a regression simply never starts, and ci-ok (which only +# demands the lanes that were selected) stays green. This pins the decision +# table case by case (tests/fixtures/select-lanes/cases.tsv), and the +# properties the workflows lean on: +# - fail-safe: an unmatched path, an empty list, the selector itself, any +# workflow, the test harness, Makefile.cbm and vendored code select FULL; +# - determinism: order and duplicates never change a byte of the output; +# - one decision per input channel: stdin, a file, --batch and the +# github-output form all agree. +# The history replay (every September PR push + the real failures) is +# scripts/test-impact/replay-selector.sh, a separate step. +# +# Usage: tests/test_select_lanes.sh [repo-root] + +set -euo pipefail + +ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +SELECTOR="$ROOT/scripts/ci/select-lanes.sh" +CASES="$ROOT/tests/fixtures/select-lanes/cases.tsv" +for f in "$SELECTOR" "$CASES"; do + if [ ! -f "$f" ]; then + echo "FAIL: $f not found" >&2 + exit 1 + fi +done + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/cbm-select-lanes.XXXXXX") +trap 'rm -rf "$WORK"' EXIT + +# Embedded Python launches the selector through the CURRENT bash ($BASH, by +# explicit path): a bare "bash" argv can resolve to the WSL alias on Windows. +python3 - "$BASH" "$SELECTOR" "$CASES" "$WORK" <<'PY' +import json +import pathlib +import random +import subprocess +import sys + +bash, selector, cases_path, work = sys.argv[1:5] +work = pathlib.Path(work) +failures = [] + +# Every lane of the full PR pipeline, spelled out here rather than read from +# the selector, so a lane silently dropped from the selector's universe fails. +FULL = { + "codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos", + "memwaste", "msan", "pkg-wrappers", "security-static", + "shard-completeness", "smoke-mac", "smoke-ubuntu", "smoke-win", + "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", + "unix-macos14", "unix-x86", "windows", "windows-guards", +} +UNIVERSE = FULL | {"contracts"} + + +def run(args, stdin=""): + proc = subprocess.run([bash, selector, *args], input=stdin, + capture_output=True, text=True) + return proc.returncode, proc.stdout, proc.stderr + + +def expand(spec): + words = spec.split() + if words and words[0] == "FULL": + return FULL - {w[1:] for w in words[1:]} + return set(words) + + +cases = [] +for number, line in enumerate(open(cases_path, encoding="utf-8"), 1): + line = line.rstrip("\r\n") + if not line or line.startswith("#"): + continue + cols = line.split("\t") + if len(cols) != 5: + failures.append(f"cases.tsv:{number}: expected 5 TAB-separated columns") + continue + name, files, tier, full, lanes = cols + files = [] if files == "-" else files.split() + cases.append((name, files, tier, full == "true", expand(lanes))) + +# 1. The decision table, one selector run per case (stdin channel). +single = {} +for name, files, tier, full, lanes in cases: + rc, out, err = run([], "".join(f + "\n" for f in files)) + if rc != 0: + failures.append(f"{name}: selector exited {rc}: {err.strip()}") + continue + got = json.loads(out) + single[name] = out + if got["tier"] != tier: + failures.append(f"{name}: tier {got['tier']}, expected {tier}") + if got["full"] is not full: + failures.append(f"{name}: full={got['full']}, expected {full}") + if set(got["lanes"]) != lanes: + failures.append( + f"{name}: lanes differ -- missing {sorted(lanes - set(got['lanes']))}, " + f"unexpected {sorted(set(got['lanes']) - lanes)}") + if got["lanes"] != sorted(got["lanes"]) or got["reasons"] != sorted(got["reasons"]): + failures.append(f"{name}: lanes/reasons are not sorted") + if full and not any(r.startswith("full:") for r in got["reasons"]): + failures.append(f"{name}: full=true without a 'full:' reason") + +# 2. Determinism: shuffled, duplicated, CRLF-terminated input with blank lines +# is byte-identical to the canonical run. +rng = random.Random(1) +for name, files, *_ in cases: + if name not in single or not files: + continue + noisy = files * 2 + rng.shuffle(noisy) + rc, out, _ = run([], "\n" + "".join(f + "\r\n" for f in noisy) + "\n") + if rc != 0 or out != single[name]: + failures.append(f"{name}: output depends on order/duplicates/CRLF") + +# 3. File channel == stdin channel; --batch == one run per case. +probe = cases[0] +listing = work / "files.txt" +listing.write_text("".join(f + "\n" for f in probe[1]), encoding="utf-8") +rc, out, _ = run([str(listing)]) +if rc != 0 or out != single.get(probe[0]): + failures.append("file argument and stdin disagree") +batch = work / "batch.jsonl" +batch.write_text("".join(json.dumps({"id": n, "files": f}) + "\n" + for n, f, *_ in cases), encoding="utf-8") +rc, out, err = run(["--batch", str(batch)]) +if rc != 0: + failures.append(f"--batch exited {rc}: {err.strip()}") +else: + for row in map(json.loads, out.splitlines()): + ident = row.pop("id") + if json.dumps(row, sort_keys=True, separators=(",", ":")) + "\n" != single.get(ident): + failures.append(f"--batch disagrees with a single run for {ident}") + +# 4. github-output form carries the same decision as key=value lines. +rc, out, _ = run(["--format", "github-output"], "src/pipeline/a.c\n") +kv = dict(line.split("=", 1) for line in out.splitlines() if "=" in line) +want = json.loads(single["pipeline"]) +if rc != 0 or kv.get("tier") != want["tier"] or kv.get("full") != "false" \ + or json.loads(kv.get("lanes", "null")) != want["lanes"]: + failures.append(f"--format github-output disagrees with json: {out!r}") + +# 5. The lane universe is exactly the PR pipeline's lanes (+ contracts). +rc, out, _ = run(["--list-lanes"]) +if rc != 0 or out.split() != sorted(UNIVERSE): + failures.append(f"--list-lanes is {out.split()}, expected {sorted(UNIVERSE)}") + +# 6. Interface: --help answers, an unknown flag is a usage error (exit 2). +rc, out, _ = run(["--help"]) +if rc != 0 or "Usage:" not in out: + failures.append("--help must exit 0 and print a Usage: block") +rc, _, err = run(["--definitely-not-a-flag"]) +if rc != 2 or "Please consult --help." not in err: + failures.append("an unknown flag must exit 2 with 'Please consult --help.'") + +if failures: + print("LANE SELECTOR CONTRACT VIOLATED:") + for failure in failures: + print(f" {failure}") + sys.exit(1) +print(f"lane-selector contract OK ({len(cases)} cases, determinism, " + f"channels, universe of {len(UNIVERSE)} lanes)") +PY From e78eb87111f6e7aef09319a08e6d7c058dd39d06 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 30 Sep 2026 23:11:56 +0200 Subject: [PATCH 05/50] ci: select-lanes/replay --help stop at the header Both printed the first code line after the comment header. Signed-off-by: Martin Vogel --- scripts/ci/select-lanes.sh | 2 +- scripts/test-impact/replay-selector.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh index 8b8c936a7..98cec269a 100755 --- a/scripts/ci/select-lanes.sh +++ b/scripts/ci/select-lanes.sh @@ -27,7 +27,7 @@ # Exit: 0 = decided · 2 = usage error. set -euo pipefail -usage() { sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'; } +usage() { sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; } FORMAT=json MODE="select" diff --git a/scripts/test-impact/replay-selector.sh b/scripts/test-impact/replay-selector.sh index dfb14600e..5433c588d 100755 --- a/scripts/test-impact/replay-selector.sh +++ b/scripts/test-impact/replay-selector.sh @@ -17,7 +17,7 @@ set -euo pipefail case "${1:-}" in -h | --help) - sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; -*) From fb558a6b1366046277ea57ac7cf1417c6ce45a49 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 30 Sep 2026 23:12:10 +0200 Subject: [PATCH 06/50] ci: lane-aware ci-ok and shard-union gates Under lane selection a PR runs a subset of its jobs, and "skipped" stops meaning "not needed": a selected lane that was skipped, cancelled or never created is a silent gate loss that the needs-level ci-ok waves through. require-all-green.sh keeps the needs-level check and, when given the selector's LANES and the run's JOBS (Actions API job list), requires every selected lane to have at least one job and all of them to have succeeded. Unselected lanes may be skipped or absent but may not fail; memwaste stays report-only. A selection it cannot trust (not a JSON list, unknown lanes, or neither lint nor contracts) is red. Matrix jobs are mapped by the lane value in their name, single jobs by their job id. verify-shard-union.sh takes an optional --lanes: no manifests at all is green only when the selection holds no test leg; without --lanes (dry run, release) or with "all" it fails exactly as before. tests/test_lane_gate_contract.sh drives both with synthetic inputs (selected-skipped/cancelled/missing -> red, unselected-skipped -> green, no legs -> green only for an empty leg selection); Step 0z3. Signed-off-by: Martin Vogel --- scripts/ci/README.md | 4 +- scripts/ci/require-all-green.sh | 110 ++++++++++++++++++-- scripts/ci/verify-shard-union.sh | 40 +++++++- scripts/test.sh | 2 + tests/test_lane_gate_contract.sh | 169 +++++++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 17 deletions(-) create mode 100755 tests/test_lane_gate_contract.sh diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 0504f01d1..3e74b86d7 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -13,9 +13,9 @@ CI and the local infrastructure — both of which the venue-parity contract | `preflight-docker.sh` | Same idea for Colima/docker: prune runner-unlike residue, assert free space on the filesystem backing the docker data root (not the VM's `/`). Build cache + named volumes KEPT (the local analogue of actions/cache); `--deep` drops them. | `test-infrastructure/run.sh` | | `check-glibc-compat.sh` | Run a linux binary in debian:bullseye (glibc 2.31) — the portable binary must start on old glibc. | `_smoke.yml` portable legs | | `generate-sbom.py` | The release SPDX SBOM (vendored versions reviewable here, diffable by vendoring PRs — was inline YAML). | `release.yml` | -| `require-all-green.sh` | The aggregate gate: fail unless every needed job succeeded or legitimately skipped (was inline YAML). | `pr.yml ci-ok` | +| `require-all-green.sh` | The aggregate gate: fail unless every needed job succeeded or legitimately skipped (was inline YAML) — and, under lane selection, every SELECTED lane ran and succeeded (skipped/cancelled/missing = red; unselected lanes may skip). | `pr.yml ci-ok` | | `select-lanes.sh` | Decide which PR lanes a change set runs (tier, lanes, full, reasons) from its file list — the R1 rule set, fail-safe to every lane. Pinned by `tests/test_select_lanes.sh` and the September 2026 history replay (`scripts/test-impact/replay-selector.sh`). | `pr.yml changes` | -| `verify-shard-union.sh` | Prove sharded test legs lost nothing: shard count agreement, indices 1..n, identical suite lists, union of slices == full list (was inline YAML). | `_test.yml` shard-completeness | +| `verify-shard-union.sh` | Prove sharded test legs lost nothing: shard count agreement, indices 1..n, identical suite lists, union of slices == full list (was inline YAML). No manifests is green only when the lane selection holds no test leg. | `_test.yml` shard-completeness | | `prepare-release-candidates.sh` | Copy one linker output into stripped/unstripped candidates, finalize signatures, composition-check them without execution, and record their hashes. | `_build.yml`, local artifact smoke | | `stage-release-candidates.py` | Admit exactly eight candidate artifacts / sixteen byte-distinct binaries into the content-addressed VirusTotal scan set. | `_build.yml` | | `select-release-candidates.py` | Apply the reviewed tuple-local VT truth table, or the explicit dry-run stripped default, and atomically copy one content-bound binary per target. | `_build.yml` | diff --git a/scripts/ci/require-all-green.sh b/scripts/ci/require-all-green.sh index bb9c5a33f..027118287 100755 --- a/scripts/ci/require-all-green.sh +++ b/scripts/ci/require-all-green.sh @@ -1,31 +1,121 @@ #!/usr/bin/env bash # require-all-green.sh — the aggregate gate step: fail unless every needed job -# succeeded (or was legitimately skipped). +# succeeded (or was legitimately skipped) and every SELECTED lane ran green. # # Canonical CI step (called by pr.yml ci-ok with RESULTS = toJSON(needs)). # Lived inline in workflow YAML until 2026-07-26; the venue-parity contract -# forbids logic in workflow run-blocks. `skipped` counts as OK because gated -# jobs (e.g. pr-smoke on docs-only PRs) skip by design — a matrix rename can -# still never silently deadlock a merge, which is this gate's purpose. +# forbids logic in workflow run-blocks. `skipped` counts as OK at the needs +# level because gated jobs (e.g. pr-smoke on docs-only PRs) skip by design — a +# matrix rename can still never silently deadlock a merge, which is this +# gate's purpose. # -# Usage: RESULTS='' scripts/ci/require-all-green.sh +# Lane selection (LANES + JOBS, both set by pr.yml): a PR runs only the lanes +# scripts/ci/select-lanes.sh selected, and there `skipped` is no longer a free +# pass — a selected lane whose jobs were skipped, cancelled, never created or +# are anything but success is a silent gate loss, so it fails. Lanes nobody +# selected may be skipped or absent (but may not fail); memwaste is +# report-only and never gates. The selection itself must be trustworthy: a +# list of known lanes that holds lint or contracts (every tier runs one). +# +# Usage: RESULTS='' \ +# [LANES='' JOBS=] \ +# scripts/ci/require-all-green.sh +# JOBS: this run's jobs as JSON lines {"name","status","conclusion"} +# (gh api .../actions/runs//jobs?filter=latest --jq '.jobs[] | ...'). set -euo pipefail case "${1:-}" in -h | --help) - sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; esac : "${RESULTS:?set RESULTS to the toJSON(needs) context}" +UNIVERSE="" +if [ -n "${LANES+set}" ]; then + UNIVERSE=$(bash "$(dirname "$0")/select-lanes.sh" --list-lanes) +fi + printf '%s' "$RESULTS" | python3 -c " -import json, sys +import json, os, re, sys + needs = json.load(sys.stdin) bad = {k: v['result'] for k, v in needs.items() if v['result'] not in ('success', 'skipped')} -if bad: - print('CI NOT OK:', bad) +problems = [f'needed job {k}: {v}' for k, v in sorted(bad.items())] + +if 'LANES' in os.environ: + universe = set(sys.argv[1].split()) + lanes = None + try: + lanes = json.loads(os.environ['LANES']) + except ValueError: + pass + if not isinstance(lanes, list) or not all(isinstance(l, str) for l in lanes): + problems.append('LANES is not a JSON list of lane names: ' + repr(os.environ['LANES'])) + lanes = [] + selected = set(lanes) + if selected - universe: + problems.append('unknown lane(s) selected: ' + ', '.join(sorted(selected - universe))) + if not selected & {'lint', 'contracts'}: + problems.append('selection has neither lint nor contracts -- refusing to trust it') + + # job name -> lane. Matrix jobs carry their lane among the values in + # parentheses; everything else is named by its (caller /) job id. + single = { + 'lint / lint': 'lint', 'lint / lint-mem': 'lint-mem', + 'security / security-static': 'security-static', + 'security / license-gate': 'license-gate', 'security / codeql-gate': 'codeql-gate', + 'test / test-diag': 'diag', 'test / test-msan': 'msan', + 'test / test-lsan-macos': 'lsan-macos', 'test / test-windows-guards': 'windows-guards', + 'test / shard-completeness': 'shard-completeness', 'contracts': 'contracts', + } + matrix = ('test / test-unix (', 'test / test-windows (', 'test / test-tsan (', 'pr-smoke (') + def lane_of(name): + if name in single: + return single[name] + if name.startswith('test / test-package-wrappers'): + return 'pkg-wrappers' + if name == 'memwaste' or name.startswith('memwaste / '): + return 'memwaste' + if name.startswith(matrix) and name.endswith(')'): + values = name[name.index('(') + 1:-1].split(', ') + hits = [v for v in values if v in universe] + return hits[0] if len(hits) == 1 else None + return None + + jobs_by_lane = {} + path = os.environ.get('JOBS', '') + if not path or not os.path.isfile(path): + problems.append('JOBS (the run\'s job list) is missing: cannot verify the selected lanes') + else: + for line in open(path, encoding='utf-8'): + if line.strip(): + job = json.loads(line) + lane = lane_of(job.get('name', '')) + if lane: + jobs_by_lane.setdefault(lane, []).append(job) + for lane in sorted(universe - {'memwaste'}): + jobs = jobs_by_lane.get(lane, []) + if lane in selected: + if not jobs: + problems.append(f'selected lane {lane}: no job ran') + for job in jobs: + state = job.get('conclusion') if job.get('status') == 'completed' else job.get('status') + if state != 'success': + problems.append(f'selected lane {lane}: {job[\"name\"]} is {state}') + else: + for job in jobs: + if job.get('status') == 'completed' and job.get('conclusion') not in ('success', 'skipped'): + problems.append(f'unselected lane {lane}: {job[\"name\"]} is {job[\"conclusion\"]}') + if not problems: + print('selected lanes green:', ', '.join(sorted(selected))) + +if problems: + print('CI NOT OK:') + for p in problems: + print(' ' + p) sys.exit(1) print('CI OK:', ', '.join(needs)) -" +" "$UNIVERSE" diff --git a/scripts/ci/verify-shard-union.sh b/scripts/ci/verify-shard-union.sh index 70b91ef4b..b2c69d0ff 100755 --- a/scripts/ci/verify-shard-union.sh +++ b/scripts/ci/verify-shard-union.sh @@ -10,22 +10,52 @@ # shard slices equals that list — a rename/re-shard can never silently drop a # suite (gate-quality loss) without failing here. # -# Usage: scripts/ci/verify-shard-union.sh +# No manifests at all is a failure — unless the PR's lane selection (--lanes, +# the JSON list from scripts/ci/select-lanes.sh) holds no test leg, in which +# case no leg ran by design. Without --lanes (or with "all": dry run, +# release) every leg is expected, exactly as before lane selection. +# +# Usage: scripts/ci/verify-shard-union.sh [--lanes |all] set -eu case "${1:-}" in -h | --help) - sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; esac MANIFEST_DIR="${1:?usage: verify-shard-union.sh (see --help)}" +LANES=all +if [ "${2:-}" = "--lanes" ]; then + LANES="${3:-}" +elif [ -n "${2:-}" ]; then + echo "verify-shard-union.sh: unknown argument '$2'. Please consult --help." >&2 + exit 2 +fi +case "$LANES" in +all | \[*\]) ;; +*) + echo "verify-shard-union.sh: --lanes must be 'all' or a JSON list. Please consult --help." >&2 + exit 2 + ;; +esac -files=$(find "$MANIFEST_DIR" -name shard-manifest.txt | sort) +files="" +if [ -d "$MANIFEST_DIR" ]; then + files=$(find "$MANIFEST_DIR" -name shard-manifest.txt | sort) +fi if [ -z "$files" ]; then - echo "FAIL: no shard manifests were uploaded" >&2 - exit 1 + # The test legs are the sharded ones: unix-* and windows (matched with its + # quotes, so the windows-guards lane never reads as the windows leg). + case "$LANES" in + all | *\"unix-* | *\"windows\"*) + echo "FAIL: no shard manifests were uploaded" >&2 + exit 1 + ;; + esac + echo "OK: no test leg was selected ($LANES) — no manifests expected" + exit 0 fi rc=0 for leg in $(grep -h '^leg=' $files | sort -u | sed 's/^leg=//'); do diff --git a/scripts/test.sh b/scripts/test.sh index 9f00f6a9c..3089a7695 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -345,6 +345,8 @@ echo "=== Step 0z: PR lane selector contract ===" bash "$ROOT/tests/test_select_lanes.sh" echo "=== Step 0z2: lane selector history replay ===" bash "$ROOT/scripts/test-impact/replay-selector.sh" +echo "=== Step 0z3: lane-aware aggregate gates (ci-ok, shard union) ===" +bash "$ROOT/tests/test_lane_gate_contract.sh" # Verify compiler supports target arch verify_compiler "$CC" diff --git a/tests/test_lane_gate_contract.sh b/tests/test_lane_gate_contract.sh new file mode 100755 index 000000000..a3eaf928c --- /dev/null +++ b/tests/test_lane_gate_contract.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Lane-gate contract: the two aggregate gates under lane selection. +# +# With lane selection a PR runs a SUBSET of its jobs, and a subset is exactly +# where a silent gate loss hides: before selection a `skipped` job only ever +# meant "not needed for this PR", so ci-ok could wave every skip through. Now a +# selected lane whose jobs were skipped, cancelled or never created looks just +# as quiet. So: +# - scripts/ci/require-all-green.sh (ci-ok): given the selector's LANES and +# the run's JOBS, a SELECTED lane must have run and succeeded; an +# unselected lane may be skipped or absent; memwaste never gates. +# - scripts/ci/verify-shard-union.sh (shard-completeness): "no manifests" is +# green only when the selection holds no test leg at all; with legs (or +# with no selection given: dry run, release) it stays a failure. +# Both scripts are driven with synthetic inputs, never copied. +# +# Usage: tests/test_lane_gate_contract.sh [repo-root] + +set -uo pipefail + +ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +GREEN_GATE="$ROOT/scripts/ci/require-all-green.sh" +UNION="$ROOT/scripts/ci/verify-shard-union.sh" +for f in "$GREEN_GATE" "$UNION"; do + if [ ! -f "$f" ]; then + echo "FAIL: $f not found" >&2 + exit 1 + fi +done + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/cbm-lane-gate.XXXXXX") || exit 1 +trap 'rm -rf "$WORK"' EXIT +failures=0 + +# ── ci-ok ──────────────────────────────────────────────────────────────── +NEEDS_OK='{"changes":{"result":"success"},"security":{"result":"success"},"lint":{"result":"success"},"test":{"result":"success"},"pr-smoke":{"result":"skipped"},"contracts":{"result":"skipped"}}' +NEEDS_RED='{"changes":{"result":"success"},"security":{"result":"success"},"lint":{"result":"failure"},"test":{"result":"skipped"},"pr-smoke":{"result":"skipped"},"contracts":{"result":"skipped"}}' +T1='["lint","lint-mem","security-static","shard-completeness","unix-macos14","unix-x86","windows"]' + +job() { # name conclusion [status] + printf '{"name":"%s","status":"%s","conclusion":%s}\n' "$1" "${3:-completed}" \ + "$([ "$2" = null ] && echo null || echo "\"$2\"")" +} +t1_jobs() { # the jobs of a green T1 run; $1 = conclusion of the x86 leg + job "changes" success + job "security / security-static" success + job "security / license-gate" skipped + job "security / codeql-gate" skipped + job "lint / lint" success + 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-windows (windows-latest, CLANG64, x86_64, windows, 1/1)" success + job "test / test-windows-guards" skipped + job "test / test-package-wrappers" skipped + job "test / test-diag" skipped + job "test / test-msan" skipped + job "test / test-lsan-macos" skipped + job "test / test-tsan" skipped + job "test / shard-completeness" success + job "pr-smoke" skipped + job "contracts" skipped + job "memwaste" skipped + job "test-impact-shadow" failure + job "ci-ok" null in_progress +} + +gate() { # description want needs lanes jobs-file|- + local description="$1" want="$2" needs="$3" lanes="$4" jobs="$5" got + if [ "$lanes" = "-" ]; then + RESULTS="$needs" bash "$GREEN_GATE" >"$WORK/out" 2>&1 + else + RESULTS="$needs" LANES="$lanes" JOBS="$jobs" bash "$GREEN_GATE" >"$WORK/out" 2>&1 + fi + got=$? + if [ "$got" -ne "$want" ]; then + echo "FAIL: ci-ok: $description — exit $got, expected $want" >&2 + sed 's/^/ /' "$WORK/out" >&2 + failures=$((failures + 1)) + fi +} + +t1_jobs success >"$WORK/t1-green" +t1_jobs skipped >"$WORK/t1-x86-skipped" +t1_jobs cancelled >"$WORK/t1-x86-cancelled" +grep -v 'unix-x86' "$WORK/t1-green" >"$WORK/t1-x86-missing" +t1_jobs success | sed 's|"lint / lint","status":"completed","conclusion":"success"|"lint / lint","status":"completed","conclusion":"cancelled"|' >"$WORK/t1-lint-cancelled" +t1_jobs success | sed 's|"test / test-diag","status":"completed","conclusion":"skipped"|"test / test-diag","status":"completed","conclusion":"failure"|' >"$WORK/t1-unselected-failed" +t1_jobs success | sed 's|"lint / lint","status":"completed"|"lint / lint","status":"in_progress"|' >"$WORK/t1-lint-running" +{ t1_jobs success; job "memwaste / event-linux" failure; } >"$WORK/t1-memwaste-red" + +# Without a selection the gate is exactly the old needs-level gate. +gate "no selection, every stage green or skipped" 0 "$NEEDS_OK" - - +gate "no selection, a failed stage" 1 "$NEEDS_RED" - - +# The selection contract. +gate "selected lanes all succeeded; unselected skipped or absent" 0 "$NEEDS_OK" "$T1" "$WORK/t1-green" +gate "a SELECTED lane that was skipped is red" 1 "$NEEDS_OK" "$T1" "$WORK/t1-x86-skipped" +gate "a SELECTED lane that was cancelled is red" 1 "$NEEDS_OK" "$T1" "$WORK/t1-x86-cancelled" +gate "a SELECTED lane with no job at all is red" 1 "$NEEDS_OK" "$T1" "$WORK/t1-x86-missing" +gate "a cancelled selected single job is red" 1 "$NEEDS_OK" "$T1" "$WORK/t1-lint-cancelled" +gate "a selected job that has not completed is red" 1 "$NEEDS_OK" "$T1" "$WORK/t1-lint-running" +gate "an unselected lane that ran and FAILED is red" 1 "$NEEDS_OK" "$T1" "$WORK/t1-unselected-failed" +gate "memwaste is report-only even when selected" 0 "$NEEDS_OK" "${T1%]},\"memwaste\"]" "$WORK/t1-memwaste-red" +gate "the needs-level gate still applies under a selection" 1 "$NEEDS_RED" "$T1" "$WORK/t1-green" +# A selection ci-ok cannot trust is red, never "nothing to check". +gate "an empty selection is red (no floor lane)" 1 "$NEEDS_OK" '[]' "$WORK/t1-green" +gate "a selection without lint or contracts is red" 1 "$NEEDS_OK" '["security-static","unix-x86"]' "$WORK/t1-green" +gate "an unknown lane is red" 1 "$NEEDS_OK" '["lint","security-static","no-such-lane"]' "$WORK/t1-green" +gate "a malformed selection is red" 1 "$NEEDS_OK" 'lint,unix-x86' "$WORK/t1-green" +gate "a selection without the job list is red" 1 "$NEEDS_OK" "$T1" "$WORK/does-not-exist" + +# Matrix jobs carry their lane in the name; the neighbouring single job +# (test-windows-guards) must never be read as the windows leg. +{ + job "security / security-static" success + job "lint / lint" success + job "lint / lint-mem" success + job "test / test-windows-guards" success + job "pr-smoke (macos-14, 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" + +# ── shard-completeness ─────────────────────────────────────────────────── +union() { # description want dir [--lanes value] + local description="$1" want="$2" dir="$3" got + shift 3 + bash "$UNION" "$dir" "$@" >"$WORK/out" 2>&1 + got=$? + if [ "$got" -ne "$want" ]; then + echo "FAIL: shard union: $description — exit $got, expected $want" >&2 + sed 's/^/ /' "$WORK/out" >&2 + failures=$((failures + 1)) + fi +} +manifest() { # dir leg shard slice-suites... + local dir="$1" leg="$2" shard="$3" sha + shift 3 + mkdir -p "$dir" + sha=$(printf '%s\n' a b c | sort | { sha256sum 2>/dev/null || shasum -a 256; } | awk '{print $1}') + { + echo "leg=$leg" + echo "shard=$shard" + echo "list_sha256=$sha" + echo "--- slice ---" + printf '%s\n' "$@" + } >"$dir/shard-manifest.txt" +} +mkdir -p "$WORK/empty" +manifest "$WORK/whole/m1" ubuntu-latest-gcc 1/1 a b c +manifest "$WORK/lost/m1" ubuntu-latest-gcc 1/2 a +manifest "$WORK/lost/m2" ubuntu-latest-gcc 2/2 b + +union "no manifests, no selection given (dry run / release)" 1 "$WORK/empty" +union "no manifests, selection 'all'" 1 "$WORK/empty" --lanes all +union "no manifests and no test leg selected" 0 "$WORK/empty" --lanes '["lint","lint-mem","security-static","windows-guards"]' +union "no download dir at all and no test leg selected" 0 "$WORK/never-created" --lanes '["lint","lint-mem","security-static"]' +union "no manifests but a unix leg selected" 1 "$WORK/empty" --lanes '["lint","security-static","unix-x86"]' +union "no manifests but the windows leg selected" 1 "$WORK/empty" --lanes '["lint","security-static","windows"]' +union "a complete 1/1 leg" 0 "$WORK/whole" --lanes '["lint","security-static","unix-x86"]' +union "a lost slice is still a gate-quality loss" 1 "$WORK/lost" --lanes '["lint","security-static","unix-x86"]' +union "a malformed selection is a usage error" 2 "$WORK/empty" --lanes 'lint,unix-x86' + +if [ "$failures" -ne 0 ]; then + echo "LANE GATE CONTRACT VIOLATED: $failures case(s)" >&2 + exit 1 +fi +echo "lane-gate contract OK (ci-ok lane strictness + shard-union selection)" From adec281d1ad0687bc65c06d9fe406b60b17a5e5b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 30 Sep 2026 23:21:43 +0200 Subject: [PATCH 07/50] ci: run only the selected lanes on PRs; dry run and release unchanged pr.yml's `changes` job now lists the changed files (renames with their old path too), runs scripts/ci/select-lanes.sh in a credential-less step and outputs tier, full, lanes and the pr-smoke matrix. security, lint and test pass the lanes to their reusable workflows, which take a `lanes` input defaulting to "all": dry-run.yml and release.yml pass none and run exactly as before (setup-matrix output for every broad/shard combination is byte-identical; the TSan matrix moved into setup-matrix with the same legs). Under a selection, setup-matrix keeps only the tagged legs of the selected lanes (an untagged leg is an error, never a silent drop) and each lane job has an `if:` on its lane. pr-smoke and memwaste run on their lanes instead of the old product regex. Docs-only PRs (T0a) get a `contracts` job running the new `scripts/test.sh --contracts-only` (Step 0 contracts, no build). ci-ok needs contracts, lists the run's jobs (actions: read) and passes LANES/JOBS to require-all-green.sh; shard-completeness passes --lanes. tests/test_lane_wiring_contract.sh (Step 0z4) pins it: dry run and release pass no lanes and the inputs default to all, pr.yml wires the selection through, and every lane is served by a tagged leg or an `if:` naming it. The smoke-fixture contract now checks the selector's decisions for the smoke-harness paths instead of the removed regex; select-lanes.sh joins the venue-parity interface probes. Signed-off-by: Martin Vogel --- .github/workflows/_lint.yml | 9 +- .github/workflows/_security.yml | 9 +- .github/workflows/_test.yml | 72 +++++++++++--- .github/workflows/pr.yml | 115 +++++++++++++++------ scripts/README.md | 2 +- scripts/test.sh | 22 ++++- tests/test_lane_wiring_contract.sh | 143 +++++++++++++++++++++++++++ tests/test_smoke_fixture_contract.sh | 28 +++--- tests/test_venue_parity_contract.sh | 2 + 9 files changed, 345 insertions(+), 57 deletions(-) create mode 100755 tests/test_lane_wiring_contract.sh diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index d5be0c717..78e0eb952 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -3,13 +3,19 @@ name: Lint & Security on: - workflow_call: {} + workflow_call: + inputs: + lanes: + description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list); "all" = every job (dry run, release)' + type: string + default: all permissions: contents: read jobs: lint: + if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lint') }} runs-on: ubuntu-latest # 15 min proved too tight on a slow runner day and a timed-out lint reads # as "cancelled", which the release graph must treat as a hard stop; keep @@ -60,6 +66,7 @@ jobs: # provability (never suppressed — the NOLINT ban applies here too). # Vendored-tree diagnostics are path-filtered, mirroring .cppcheck. lint-mem: + if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lint-mem') }} runs-on: ubuntu-latest timeout-minutes: 60 steps: diff --git a/.github/workflows/_security.yml b/.github/workflows/_security.yml index ce044eed2..54d3a5a3d 100644 --- a/.github/workflows/_security.yml +++ b/.github/workflows/_security.yml @@ -4,7 +4,12 @@ name: Security Gate on: - workflow_call: {} + workflow_call: + inputs: + lanes: + description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list); "all" = every job (dry run, release)' + type: string + default: all permissions: contents: read @@ -23,6 +28,7 @@ jobs: run: scripts/security-vendored.sh license-gate: + if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'license-gate') }} runs-on: ubuntu-latest timeout-minutes: 240 steps: @@ -39,6 +45,7 @@ jobs: run: scripts/audit-license-provenance.py codeql-gate: + if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'codeql-gate') }} runs-on: ubuntu-latest timeout-minutes: 240 # Without security-events:read this job inherits the workflow's diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 7ccd56bf5..bc19ab8d3 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -16,6 +16,10 @@ on: description: 'Split each C-suite leg across parallel shard jobs (ubuntu x3, windows x2); off = one job per leg, byte-identical to the pre-shard topology' type: boolean default: false + lanes: + description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list): only those legs and jobs run; "all" = every one (dry run, release)' + type: string + default: all permissions: contents: read @@ -30,18 +34,25 @@ jobs: outputs: unix: ${{ steps.set.outputs.unix }} windows: ${{ steps.set.outputs.windows }} + tsan: ${{ steps.set.outputs.tsan }} + has_unix: ${{ steps.set.outputs.has_unix }} + has_windows: ${{ steps.set.outputs.has_windows }} + has_tsan: ${{ steps.set.outputs.has_tsan }} steps: - name: Compute matrices id: set env: BROAD: ${{ inputs.broad_platforms }} SHARDS: ${{ inputs.shard_suites }} + LANES: ${{ inputs.lanes }} 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. CORE_UNIX='[ - {"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-intel","cc":"cc","cxx":"c++"} + {"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-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. @@ -59,7 +70,7 @@ jobs: # interception crashed (interception_win: unhandled instruction). With # the native ARM64 toolchain ASan instruments native ARM64 code, so it # is a real (non-optional) gate, not a tolerated emulated-flake. - CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64"}]' + CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64","lane":"windows"}]' BROAD_WIN='[{"os":"windows-2025","msystem":"CLANG64","pkg":"x86_64"},{"os":"windows-11-arm","msystem":"CLANGARM64","pkg":"aarch64"}]' if [ "$BROAD" = "true" ]; then UNIX=$(jq -cn --argjson a "$CORE_UNIX" --argjson b "$BROAD_UNIX" '$a + $b') @@ -68,6 +79,30 @@ jobs: UNIX=$(jq -cn --argjson a "$CORE_UNIX" '$a') WIN=$(jq -cn --argjson a "$CORE_WIN" '$a') fi + # The ThreadSanitizer legs (test-tsan), tagged the same way. + 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"} + ]' + # Lane selection. "all" (dry run, release) keeps every leg and drops + # the tags, so those matrices -- and their job names -- stay exactly + # as before lane selection. A PR keeps only its selected legs, and + # the tag stays in the job name, where ci-ok reads it back. An + # untagged leg could never be selected: that is a configuration + # error here, never a leg silently dropped. + if [ "$LANES" = "all" ]; then + UNIX=$(jq -c 'map(del(.lane))' <<< "$UNIX") + WIN=$(jq -c 'map(del(.lane))' <<< "$WIN") + TSAN=$(jq -c 'map(del(.lane))' <<< "$TSAN") + else + for M in "$UNIX" "$WIN" "$TSAN"; do + jq -e 'all(.[]; has("lane"))' <<< "$M" > /dev/null || { echo "::error::untagged test leg in $M"; exit 1; } + done + UNIX=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$UNIX") + WIN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$WIN") + TSAN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$TSAN") + fi # Suite sharding (opt-in via the shard_suites input): each platform's # C-suite run is split across N runner jobs. CBM_TEST_SHARD="i/N" # slices deterministically inside run-tests-parallel.sh; each @@ -89,9 +124,16 @@ jobs: fi echo "unix={\"include\":$UNIX}" >> "$GITHUB_OUTPUT" echo "windows={\"include\":$WIN}" >> "$GITHUB_OUTPUT" + echo "tsan={\"include\":$TSAN}" >> "$GITHUB_OUTPUT" + # GitHub rejects an empty matrix, so a job whose legs were all + # deselected is skipped through these flags instead. + echo "has_unix=$(jq 'length > 0' <<< "$UNIX")" >> "$GITHUB_OUTPUT" + echo "has_windows=$(jq 'length > 0' <<< "$WIN")" >> "$GITHUB_OUTPUT" + echo "has_tsan=$(jq 'length > 0' <<< "$TSAN")" >> "$GITHUB_OUTPUT" test-unix: needs: setup-matrix + if: ${{ needs.setup-matrix.outputs.has_unix == 'true' }} strategy: fail-fast: false matrix: ${{ fromJSON(needs.setup-matrix.outputs.unix) }} @@ -205,6 +247,7 @@ jobs: # inode/flock behavior and Windows handle/link behavior are each gating, # without adding Node/Go provisioning to every sanitizer/container leg. test-package-wrappers: + if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'pkg-wrappers') }} strategy: fail-fast: false matrix: @@ -245,6 +288,7 @@ jobs: # parallel suite — catches what an older compiler-rt can miss. Same # canonical scripts/test.sh wave as every other leg. test-diag: + if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'diag') }} runs-on: ubuntu-latest timeout-minutes: 120 steps: @@ -308,6 +352,7 @@ jobs: # list both venues now share. Zero use-of-uninitialized-value findings were # reported across the lane on either architecture. test-msan: + if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'msan') }} runs-on: ubuntu-latest timeout-minutes: 180 steps: @@ -332,6 +377,7 @@ jobs: # so this leg is the ordinary ASan suite built with that toolchain and run # with detect_leaks=1. Mirrors the local `make -f Makefile.cbm test-lsan`. test-lsan-macos: + if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lsan-macos') }} runs-on: macos-latest timeout-minutes: 120 steps: @@ -370,13 +416,11 @@ jobs: key: ccache-lsan-${{ github.ref }}-${{ github.sha }} test-tsan: + needs: setup-matrix + if: ${{ needs.setup-matrix.outputs.has_tsan == 'true' }} strategy: fail-fast: false - matrix: - include: - - { os: ubuntu-latest, cc: clang, cxx: clang++ } - - { os: ubuntu-24.04-arm, cc: clang, cxx: clang++ } - - { os: macos-14, cc: cc, cxx: c++ } + matrix: ${{ fromJSON(needs.setup-matrix.outputs.tsan) }} runs-on: ${{ matrix.os }} timeout-minutes: 120 steps: @@ -429,6 +473,7 @@ jobs: test-windows: needs: setup-matrix + if: ${{ needs.setup-matrix.outputs.has_windows == 'true' }} strategy: fail-fast: false matrix: ${{ fromJSON(needs.setup-matrix.outputs.windows) }} @@ -562,8 +607,12 @@ jobs: with: pattern: shard-manifest-* path: manifests + # A PR with no test leg selected uploads no manifest; the script + # accepts that only when the selection really holds no leg. - name: Verify cross-shard union per leg - run: repo/scripts/ci/verify-shard-union.sh manifests + env: + LANES: ${{ inputs.lanes }} + run: repo/scripts/ci/verify-shard-union.sh manifests --lanes "$LANES" # Windows product-surface regression guards. Distinct from test-windows above # (the sanitizer C suite): these drive a real product binary + external UI pack @@ -574,6 +623,7 @@ jobs: # CLI arguments being mangled by the narrow-argv main() (#423/#20, fixed by the # wide-argv entrypoint that reads GetCommandLineW). test-windows-guards: + if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'windows-guards') }} runs-on: windows-latest timeout-minutes: 60 steps: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8cb464b19..0fd09b878 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,7 +1,10 @@ -# PR validation: security gates + lint + full test suite. Builds, smoke and -# soak stay maintainer-driven via the dry-run workflow_dispatch. Branch +# PR validation: security gates + lint + the test legs this PR's changes need. +# `changes` runs scripts/ci/select-lanes.sh on the PR's file list; every lane +# job below runs only when selected (unknown paths, workflows, the harness, +# Makefile.cbm and vendored code select everything). Builds, smoke and soak at +# full breadth stay maintainer-driven via the dry-run workflow_dispatch. Branch # protection requires `dco` + `ci-ok` — a single stable summary context that -# fails unless every PR stage succeeded. +# fails unless every PR stage succeeded and every selected lane ran green. name: PR on: @@ -21,6 +24,7 @@ concurrency: jobs: security: + needs: [changes] uses: ./.github/workflows/_security.yml # The called job requests security-events:read to count code-scanning # alerts; a reusable workflow cannot request more than its caller grants, @@ -30,15 +34,22 @@ jobs: security-events: read actions: read secrets: inherit + with: + lanes: ${{ needs.changes.outputs.lanes }} lint: + needs: [changes] + if: ${{ contains(fromJSON(needs.changes.outputs.lanes), 'lint') || contains(fromJSON(needs.changes.outputs.lanes), 'lint-mem') }} uses: ./.github/workflows/_lint.yml + with: + lanes: ${{ needs.changes.outputs.lanes }} test: - needs: [lint] + needs: [changes, lint] if: ${{ !cancelled() && needs.lint.result == 'success' }} uses: ./.github/workflows/_test.yml with: + lanes: ${{ needs.changes.outputs.lanes }} # Perf assertions are timing-sensitive on shared runners; they stay in # dry runs and releases where a flaky red does not block a merge. skip_perf: true @@ -47,16 +58,19 @@ jobs: # shard-completeness job). Dry runs and releases shard identically. shard_suites: true - # ── Which files changed? Gate the (heavier) build+smoke on product code so - # docs / CI / test-only PRs stay fast. ── + # ── Which lanes does this PR need? The changed files go through + # scripts/ci/select-lanes.sh (tier, lanes, full, reasons); docs / CI / + # test-only PRs stay fast, anything it cannot place runs everything. ── changes: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - product: ${{ steps.f.outputs.product }} + tier: ${{ steps.select.outputs.tier }} + full: ${{ steps.select.outputs.full }} + lanes: ${{ steps.select.outputs.lanes }} + smoke: ${{ steps.select.outputs.smoke }} steps: - - name: Detect product-code changes - id: f + - name: List the changed files env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} @@ -64,7 +78,9 @@ jobs: run: | # The full .diff endpoint rejects large-but-valid PRs at 20k lines. # The paginated files endpoint remains filename-only for this gate. - if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename'); then + # A rename lists its old path too, as the fallback's --no-renames + # diff does: moving a file away is a change to where it was. + if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[] | .filename, (.previous_filename // empty)'); then echo "file list: pulls/files API" else # The files endpoint renders the diff server-side too, and answers @@ -81,31 +97,41 @@ jobs: "https://github.com/$REPO" "refs/pull/$PR/merge" FILES=$(git --git-dir="$CHANGES_GIT" diff --name-only --no-renames FETCH_HEAD^1 FETCH_HEAD) fi - printf '%s\n' "$FILES" - # A here-string, not a pipe: under this shell's pipefail, grep -q - # exits on its first match, printf dies of SIGPIPE once the list - # outgrows the pipe buffer, and the MATCH is then reported as a - # failure -- product=false, smoke skipped, on the largest PRs. - if grep -qE '^(src/|internal/|install\.(sh|ps1)|scripts/build\.sh|scripts/smoke-test\.sh|scripts/smoke-local\.sh|scripts/smoke-fixture-server\.py|scripts/gen-third-party-notices\.sh|scripts/env\.sh|test-infrastructure/vm/(vm-smoke\.sh|windows-user-path-guard\.ps1)|Makefile\.cbm)' <<<"$FILES"; then - echo "product=true" >> "$GITHUB_OUTPUT" - else - echo "product=false" >> "$GITHUB_OUTPUT" - fi + printf '%s\n' "$FILES" > "$RUNNER_TEMP/changed-files.txt" + cat "$RUNNER_TEMP/changed-files.txt" + + # The selector is PR code: it runs in its own step, after the + # token-bearing one, from a checkout that keeps no credentials. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Select lanes + id: select + run: | + scripts/ci/select-lanes.sh --format github-output "$RUNNER_TEMP/changed-files.txt" > "$RUNNER_TEMP/selection.txt" + cat "$RUNNER_TEMP/selection.txt" + 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"}]' + 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" # ── Light smoke: build the PRODUCTION binary and run the core smoke on the # three RELIABLE NATIVE platforms. Catches built-binary regressions at PR # time (e.g. the Windows CreateProcess argv-quoting class that previously # only surfaced in the release dry run). The full broad/emulated smoke stays - # in the dry run. Only product-code PRs pay this; every leg gates. + # in the dry run. Only the selected legs run (ubuntu from the extraction + # tier up, all three for platform/packaging changes); every leg gates. # The maintained local wrappers add a race-free release fixture so the # download/checksum/install/update phases run here as well. ── pr-smoke: needs: [changes] - if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }} + if: ${{ !cancelled() && needs.changes.result == 'success' && (contains(fromJSON(needs.changes.outputs.lanes), 'smoke-ubuntu') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-mac') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-win')) }} strategy: fail-fast: false - matrix: - os: [ubuntu-latest, macos-14, windows-latest] + matrix: ${{ fromJSON(needs.changes.outputs.smoke) }} runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: @@ -177,27 +203,56 @@ jobs: # ── Waste sanitizer, REPORT-ONLY: memory + CPU waste on this repository and # the per-function scaling lane (Linux). Deliberately NOT in ci-ok's needs: # it uploads evidence and prints the ratchet, it never blocks a merge. Only - # product-code PRs pay for it. ── + # PRs whose selection holds the memwaste lane (memory core, memwaste + # tooling, full runs) pay for it. ── memwaste: needs: [changes] - if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }} + if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'memwaste') }} uses: ./.github/workflows/_memwaste.yml with: mode: pr + # ── Docs-only PRs (tier T0a) select no test leg, so this runs the static + # contract steps of the canonical test leg on their own (they police + # docs surfaces too: language counts, version metadata). Every test leg + # runs the same steps, so the job exists only when no leg does. ── + contracts: + needs: [changes] + if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'contracts') }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Contract steps only + run: scripts/test.sh --contracts-only + ci-ok: # The one required context (besides dco) — fails unless every PR stage - # succeeded, so matrix renames can never silently deadlock merges. `skipped` - # is OK: pr-smoke skips on docs/CI/test-only PRs (changes.product == false). - needs: [security, lint, test, changes, pr-smoke] + # succeeded, so matrix renames can never silently deadlock merges, and + # every lane the selector chose ran green: `skipped` is OK only for a lane + # nobody selected (a selected lane that skipped is a silent gate loss). + # memwaste and the test-impact shadow are report-only and not needed here. + needs: [changes, security, lint, test, pr-smoke, contracts] if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 5 + # actions:read lists this run's jobs, the lane-level truth behind `needs`. + permissions: + contents: read + actions: read steps: # Needs a checkout (unlike before): the gate logic lives in the canonical # scripts/ci entry, not inline YAML (venue-parity contract). - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: All PR stages must have succeeded + - name: List this run's jobs + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=latest&per_page=100" \ + --jq '.jobs[] | {name, status, conclusion}' > "$RUNNER_TEMP/jobs.jsonl" + - name: All PR stages and every selected lane must have succeeded env: RESULTS: ${{ toJSON(needs) }} + LANES: ${{ needs.changes.outputs.lanes }} + JOBS: ${{ runner.temp }}/jobs.jsonl run: scripts/ci/require-all-green.sh diff --git a/scripts/README.md b/scripts/README.md index 619959ebe..93e65b507 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -17,7 +17,7 @@ codes) and rejects unknown flags with exit 2 + `Please consult --help.` | leg | entry | what a run gives you | |---|---|---| -| **test** | `test.sh` | DEFAULT = the venue leg: static contracts (Step 0a–0r) + CLEAN ASan+UBSan build + all suites via the parallel harness + prod-binary guards. `--suites a,b` = iteration mode (incremental rebuild, subset, seconds). `--tsan` = the ThreadSanitizer leg. CLANGARM64 gets CI's trap-UBSan flags automatically. | +| **test** | `test.sh` | DEFAULT = the venue leg: static contracts (Step 0a–0r) + CLEAN ASan+UBSan build + all suites via the parallel harness + prod-binary guards. `--suites a,b` = iteration mode (incremental rebuild, subset, seconds). `--tsan` = the ThreadSanitizer leg. `--contracts-only` = the static contract steps alone (PR CI's docs-only job). CLANGARM64 gets CI's trap-UBSan flags automatically. | | **package wrappers** | `ci/test-package-wrappers.sh` | Go, npm, and PyPI runtime-set publication/lock suites on the current host. CI runs the same entry on Linux and Windows so both platform lock implementations gate. | | **build** | `build.sh` | CLEAN production runtime set (native executable + authenticated integration asset; `--with-ui` adds one content-addressed UI pack). ccache via `env.sh` makes repeats fast; `CCACHE_COMPILERCHECK=content` guarantees a hit is byte-identical to a cold compile — never stale. `--version`, `STATIC=1`, `BUILD_DIR=`. | | **lint** | `lint.sh` | clang-tidy + cppcheck + clang-format (+ no-skips policy). `--ci` = the CI gate set (no clang-tidy). Drives the same make targets as `make lint`/`lint-ci`. | diff --git a/scripts/test.sh b/scripts/test.sh index 3089a7695..bcac86d01 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -10,7 +10,7 @@ cd "$ROOT" usage() { cat <<'EOF' -Usage: scripts/test.sh [--suites LIST] [--arch ARCH] [VAR=VAL ...] +Usage: scripts/test.sh [--suites LIST | --tsan | --contracts-only] [--arch ARCH] [VAR=VAL ...] The canonical test entry: identical in local CI, PR CI, dry run and release. DEFAULT (no --suites) is exactly what CI runs: static contract checks @@ -29,6 +29,10 @@ Modes: --tsan ThreadSanitizer leg (data-race gate): builds and runs the widened TSan runner via make test-tsan — the same leg CI's tsan jobs and the compose test-tsan service run. + --contracts-only + Only the static contract steps (Step 0*) of the default leg: + no compiler, no build, no suites. PR CI runs this for + docs-only changes, where no test leg is selected. Options: --arch ARCH Force target arch (arm64 | x86_64), e.g. under Rosetta. @@ -64,11 +68,13 @@ EOF # silently swallowed — agents must know exactly what a run will do. SUITES="" TSAN=0 +CONTRACTS_ONLY=0 prev_arg="" for arg in "$@"; do case "$arg" in -h|--help) usage; exit 0 ;; --tsan) :;; + --contracts-only) :;; --suites) :;; # next arg is the value, handled below --suites=*) SUITES="${arg#--suites=}" ;; --arch) :;; # next arg is the value, handled below @@ -96,6 +102,7 @@ done for arg in "$@"; do case "$arg" in --tsan) TSAN=1 ;; + --contracts-only) CONTRACTS_ONLY=1 ;; arm64|x86_64) if [[ "${prev_arg2:-}" == "--arch" ]]; then export CBM_ARCH="$arg" @@ -121,6 +128,10 @@ if [ "$TSAN" -eq 1 ] && [ -n "$SUITES" ]; then echo "test.sh: --tsan and --suites are separate modes (the TSan leg has its own suite set). Please consult --help." >&2 exit 2 fi +if [ "$CONTRACTS_ONLY" -eq 1 ] && { [ "$TSAN" -eq 1 ] || [ -n "$SUITES" ]; }; then + echo "test.sh: --contracts-only is its own mode (no build, no suites). Please consult --help." >&2 + exit 2 +fi prev_arg="" # Also support --arch=value @@ -152,7 +163,7 @@ for arg in "$@"; do CC=*|CXX=*) export "${arg}" ;; --arch|--arch=*) ;; # already handled arm64|x86_64) ;; # already handled - --tsan) ;; # already handled + --tsan|--contracts-only) ;; # already handled --suites|--suites=*) ;; # already handled (value skipped via prev_arg below) BUILD_DIR=*) BUILD_DIR="${arg#BUILD_DIR=}"; MAKE_ARGS+=("$arg") ;; SANITIZE=*) @@ -347,6 +358,13 @@ echo "=== Step 0z2: lane selector history replay ===" bash "$ROOT/scripts/test-impact/replay-selector.sh" echo "=== Step 0z3: lane-aware aggregate gates (ci-ok, shard union) ===" bash "$ROOT/tests/test_lane_gate_contract.sh" +echo "=== Step 0z4: lane wiring (PRs select, dry run and release run all) ===" +bash "$ROOT/tests/test_lane_wiring_contract.sh" + +if [ "$CONTRACTS_ONLY" -eq 1 ]; then + echo "=== test.sh: contracts-only — every contract step passed ===" + exit 0 +fi # Verify compiler supports target arch verify_compiler "$CC" diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh new file mode 100755 index 000000000..8aa9e0921 --- /dev/null +++ b/tests/test_lane_wiring_contract.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Lane-wiring contract: the workflows honour the lane selection, and only PRs +# are selected at all. +# +# 1. dry-run.yml and release.yml pass no `lanes` (and no shard profile) to +# _test.yml / _lint.yml / _security.yml, whose inputs default to "all": +# those runs execute every job exactly as before lane selection. +# 2. pr.yml feeds the selector's lanes to every reusable workflow, runs the +# docs-only contracts job, and hands ci-ok both the selection and the +# run's job list (actions: read) -- ci-ok needs `contracts` but never the +# report-only memwaste / shadow jobs. +# 3. every lane of scripts/ci/select-lanes.sh --list-lanes is served by a +# job: matrix lanes as a "lane":"" tag in the leg data (the tag +# ci-ok reads back from the job name), single lanes by an `if:` that +# names them. A lane nothing serves would be selected and never run. +# Text-level checks (no YAML library on every leg), like the other workflow +# contracts. +# +# Usage: tests/test_lane_wiring_contract.sh [repo-root] + +set -euo pipefail + +ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +LANES=$(bash "$ROOT/scripts/ci/select-lanes.sh" --list-lanes) + +python3 - "$ROOT" "$LANES" <<'PY' +import pathlib +import re +import sys + +root = pathlib.Path(sys.argv[1]) +lanes = sys.argv[2].split() +wf = root / ".github" / "workflows" +failures = [] + + +def text(name): + return (wf / name).read_text(encoding="utf-8") + + +def jobs(source): + """job id -> body (the lines indented under ` :`).""" + out = {} + for m in re.finditer(r"(?m)^ ([A-Za-z0-9_-]+):\s*$", source): + rest = source[m.end():] + end = re.search(r"(?m)^ [A-Za-z0-9_#-]", rest) + out[m.group(1)] = rest[:end.start()] if end else rest + return out + + +def require(cond, message): + if not cond: + failures.append(message) + + +# 1. dry run and release: no selection, so every job runs. +REUSABLE = ("_test.yml", "_lint.yml", "_security.yml") +for caller in ("dry-run.yml", "release.yml"): + for job, body in jobs(text(caller)).items(): + called = re.search(r"uses: \./\.github/workflows/(_\w+\.yml)", body) + if called and called.group(1) in REUSABLE: + require(not re.search(r"(?m)^\s+(lanes|shard_profile):", body), + f"{caller} job {job} passes a lane selection to {called.group(1)}; " + f"dry runs and releases must run every lane") +for callee in REUSABLE: + src = text(callee) + m = re.search(r"(?ms)^ lanes:\n(.*?)(?=^ \S|^\S|\Z)", src) + require(m is not None, f"{callee} must declare a `lanes` input") + if m: + require(re.search(r"(?m)^ type: string$", m.group(1)) is not None, + f"{callee} lanes input must be a string") + require(re.search(r"(?m)^ default: all$", m.group(1)) is not None, + f"{callee} lanes input must default to all") + +# 2. pr.yml wiring. +pr = jobs(text("pr.yml")) +changes = pr.get("changes", "") +require("scripts/ci/select-lanes.sh --format github-output" in changes, + "pr.yml changes must run scripts/ci/select-lanes.sh --format github-output") +for key in ("tier", "full", "lanes"): + require(re.search(rf"(?m)^ {key}: \$\{{\{{ steps\.select\.outputs\.{key} \}}\}}$", changes), + f"pr.yml changes must output {key}") +for job in ("security", "lint", "test"): + require("lanes: ${{ needs.changes.outputs.lanes }}" in pr.get(job, ""), + f"pr.yml {job} must pass the selected lanes") +require("scripts/test.sh --contracts-only" in pr.get("contracts", ""), + "pr.yml contracts job must run scripts/test.sh --contracts-only") +ci_ok = pr.get("ci-ok", "") +needs = re.search(r"needs: \[([^\]]*)\]", ci_ok) +needs = {n.strip() for n in needs.group(1).split(",")} if needs else set() +require({"changes", "security", "lint", "test", "pr-smoke", "contracts"} <= needs, + f"ci-ok must need every gating stage incl. contracts, has {sorted(needs)}") +require(not needs & {"memwaste", "test-impact-shadow"}, + "ci-ok must not need the report-only memwaste / shadow jobs") +for needle, why in (("LANES: ${{ needs.changes.outputs.lanes }}", "the selection"), + ("JOBS: ", "the run's job list"), + ("actions: read", "actions: read to list jobs"), + ("jobs?filter=latest", "the latest attempt of every job"), + ("scripts/ci/require-all-green.sh", "the canonical gate")): + require(needle in ci_ok, f"ci-ok must use {why} ({needle})") +test_wf = jobs(text("_test.yml")) +require('--lanes "$LANES"' in test_wf.get("shard-completeness", ""), + "shard-completeness must pass the selection to verify-shard-union.sh") + +# 3. every lane is served by a job. +MATRIX = {"unix-x86": "_test.yml", "unix-arm64": "_test.yml", "unix-macos14": "_test.yml", + "unix-macos-intel": "_test.yml", "windows": "_test.yml", "tsan-x86": "_test.yml", + "tsan-arm": "_test.yml", "tsan-mac": "_test.yml", "smoke-ubuntu": "pr.yml", + "smoke-mac": "pr.yml", "smoke-win": "pr.yml"} +SINGLE = {"lint": ("_lint.yml", "lint"), "lint-mem": ("_lint.yml", "lint-mem"), + "license-gate": ("_security.yml", "license-gate"), + "codeql-gate": ("_security.yml", "codeql-gate"), + "pkg-wrappers": ("_test.yml", "test-package-wrappers"), + "diag": ("_test.yml", "test-diag"), "msan": ("_test.yml", "test-msan"), + "lsan-macos": ("_test.yml", "test-lsan-macos"), + "windows-guards": ("_test.yml", "test-windows-guards"), + "memwaste": ("pr.yml", "memwaste"), "contracts": ("pr.yml", "contracts")} +ALWAYS = {"security-static": ("_security.yml", "security-static"), + "shard-completeness": ("_test.yml", "shard-completeness")} +for lane in lanes: + if lane in MATRIX: + require(f'"lane":"{lane}"' in text(MATRIX[lane]), + f"lane {lane}: no leg in {MATRIX[lane]} is tagged with it") + elif lane in SINGLE: + name, job = SINGLE[lane] + body = jobs(text(name)).get(job) + cond = re.search(r"(?m)^ if: (.*)$", body or "") + require(body is not None and cond is not None and f"'{lane}'" in cond.group(1), + f"lane {lane}: {name} job {job} must exist and run only when selected") + elif lane in ALWAYS: + name, job = ALWAYS[lane] + body = jobs(text(name)).get(job) + require(body is not None, f"lane {lane}: {name} job {job} must exist") + else: + failures.append(f"lane {lane}: served by no job this contract knows") + +if failures: + print("LANE WIRING CONTRACT VIOLATED:") + for failure in failures: + print(f" {failure}") + sys.exit(1) +print(f"lane-wiring contract OK ({len(lanes)} lanes served; dry run and release unselected)") +PY diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh index eb59bfbfc..fd705b934 100755 --- a/tests/test_smoke_fixture_contract.sh +++ b/tests/test_smoke_fixture_contract.sh @@ -5,10 +5,22 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -python3 - "$ROOT" <<'PY' +# PR smoke runs the legs scripts/ci/select-lanes.sh selects, so the paths the +# release-fixture smoke depends on are checked against its real decisions. +SMOKE_DECISIONS="" +for changed_path in install.sh install.ps1 scripts/smoke-local.sh \ + scripts/smoke-fixture-server.py scripts/gen-third-party-notices.sh \ + test-infrastructure/vm/vm-smoke.sh test-infrastructure/vm/windows-user-path-guard.ps1; do + decision=$(printf '%s\n' "$changed_path" | bash "$ROOT/scripts/ci/select-lanes.sh") + SMOKE_DECISIONS="$SMOKE_DECISIONS$changed_path $decision +" +done + +python3 - "$ROOT" "$SMOKE_DECISIONS" <<'PY' from __future__ import annotations import hashlib +import json import os import pathlib import re @@ -408,17 +420,11 @@ require( "Phase 14 must assert update leaves the binary byte-identical", ) -for changed_path in ( - "install\\.(sh|ps1)", - "scripts/smoke-local", - "scripts/smoke-fixture-server", - "scripts/gen-third-party-notices", - "test-infrastructure/vm/vm-smoke", - "windows-user-path-guard", -): +for line in sys.argv[2].splitlines(): + changed_path, decision = line.split(" ", 1) require( - changed_path in pr_workflow, - f"PR product-change detector must include {changed_path}", + {"smoke-ubuntu", "smoke-mac", "smoke-win"} <= set(json.loads(decision)["lanes"]), + f"a change to {changed_path} must select all three PR smoke legs", ) require( "tests/test_smoke_fixture_contract.sh" in test_driver, diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index 81e431a1e..031e9f6d4 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -392,6 +392,7 @@ scripts/smoke-invariants.sh scripts/ci/preflight-docker.sh scripts/ci/require-all-green.sh scripts/ci/verify-shard-union.sh +scripts/ci/select-lanes.sh scripts/ci/generate-sbom.py scripts/package-release.sh scripts/ci/smoke-artifact.sh @@ -436,6 +437,7 @@ test-infrastructure/vm/vm-smoke.sh scripts/smoke-invariants.sh scripts/fuzz.sh scripts/memwaste.sh +scripts/ci/select-lanes.sh " for entry in $STRICT_ENTRIES; do out=$(cd "$ROOT" && bash "$entry" --definitely-not-a-flag 2>&1) && rc=0 || rc=$? From a8d5a8154f9d1e3f9d4f1ab6b4499cc5b7ab8e05 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 30 Sep 2026 23:29:52 +0200 Subject: [PATCH 08/50] ci: report-only test-impact shadow job on PRs A new pr.yml job, test-impact-shadow, predicts from the code graph which C test suites a change can affect and uploads the prediction, so graph-based selection can be judged against what actually failed before anything gates on it. It is continue-on-error and not in ci-ok's needs: it can never block or pass a merge. scripts/ci/test-impact-shadow.sh --fetch-release downloads the latest published release archive for the host plus checksums.txt with the job's read-only token and verifies the SHA-256 (seconds instead of a build, and a PR that breaks the indexer cannot corrupt its own prediction); pr.yml builds the checkout only if that fails. --binary/--out then indexes the checkout into a throwaway HOME/cache with a private CBM_RUNTIME_DIR (short, under /tmp, so the daemon socket fits sun_path), runs detect_changes against the merge commit's base at depth 15 and hands it to predict.py. The graph is only consulted when C sources changed. scripts/test-impact/predict.py ports the replay's mapping: impacted test functions -> registered suites (perf excluded; nested RUN_SUITE children resolve to the registered suite that runs them), changed test files -> whole suite, fixtures -> suites that name them, grammars -> grammar suites, and run-all on the selector's full, a test header, test infrastructure, or a saturated/truncated impact set. It writes prediction.json and a short step-summary table. tests/test_test_impact_predict.sh (Step 0z5) pins the mapping on a synthetic tree; the wiring contract requires the job to stay continue-on-error; the script joins the venue-parity interface probes. Signed-off-by: Martin Vogel --- .github/workflows/pr.yml | 43 ++++++ scripts/ci/README.md | 1 + scripts/ci/test-impact-shadow.sh | 168 ++++++++++++++++++++ scripts/test-impact/predict.py | 232 ++++++++++++++++++++++++++++ scripts/test.sh | 2 + tests/test_lane_wiring_contract.sh | 7 +- tests/test_test_impact_predict.sh | 139 +++++++++++++++++ tests/test_venue_parity_contract.sh | 2 + 8 files changed, 593 insertions(+), 1 deletion(-) create mode 100755 scripts/ci/test-impact-shadow.sh create mode 100644 scripts/test-impact/predict.py create mode 100755 tests/test_test_impact_predict.sh diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0fd09b878..d6d966912 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -212,6 +212,49 @@ jobs: with: mode: pr + # ── Test-impact SHADOW, REPORT-ONLY: predicts from the code graph which C + # suites this change can affect (the latest released binary indexes the + # PR checkout; detect_changes at depth 15 against the base, mapped to + # suites, with the selector's run-all triggers) and uploads the + # prediction, so graph-based selection can be judged against what really + # failed before anything gates on it. continue-on-error and NOT in + # ci-ok's needs: it can never block or pass a merge. ── + test-impact-shadow: + runs-on: ubuntu-latest + timeout-minutes: 20 + continue-on-error: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The PR merge commit and its base parent: the diff to predict for. + fetch-depth: 2 + persist-credentials: false + - name: Fetch the latest released binary + id: release + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/test-impact-shadow.sh --fetch-release "$RUNNER_TEMP/cbm-bin" + - name: Build the binary from this checkout (download failed) + if: ${{ steps.release.outcome == 'failure' }} + run: | + sudo apt-get update && sudo apt-get install -y zlib1g-dev + scripts/build.sh CC=gcc CXX=g++ + mkdir -p "$RUNNER_TEMP/cbm-bin" + cp build/c/codebase-memory-mcp "$RUNNER_TEMP/cbm-bin/" + - name: Predict the impacted suites + run: scripts/ci/test-impact-shadow.sh --binary "$RUNNER_TEMP/cbm-bin/codebase-memory-mcp" --out "$RUNNER_TEMP/test-impact" + - name: Upload the prediction + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-impact-prediction + path: | + ${{ runner.temp }}/test-impact/prediction.json + ${{ runner.temp }}/test-impact/selection.json + ${{ runner.temp }}/test-impact/changed-files.txt + if-no-files-found: warn + # ── Docs-only PRs (tier T0a) select no test leg, so this runs the static # contract steps of the canonical test leg on their own (they police # docs surfaces too: language counts, version metadata). Every test leg diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 3e74b86d7..f38cf36a8 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -15,6 +15,7 @@ CI and the local infrastructure — both of which the venue-parity contract | `generate-sbom.py` | The release SPDX SBOM (vendored versions reviewable here, diffable by vendoring PRs — was inline YAML). | `release.yml` | | `require-all-green.sh` | The aggregate gate: fail unless every needed job succeeded or legitimately skipped (was inline YAML) — and, under lane selection, every SELECTED lane ran and succeeded (skipped/cancelled/missing = red; unselected lanes may skip). | `pr.yml ci-ok` | | `select-lanes.sh` | Decide which PR lanes a change set runs (tier, lanes, full, reasons) from its file list — the R1 rule set, fail-safe to every lane. Pinned by `tests/test_select_lanes.sh` and the September 2026 history replay (`scripts/test-impact/replay-selector.sh`). | `pr.yml changes` | +| `test-impact-shadow.sh` | The report-only test-impact shadow: fetch the latest released binary (checksum-verified) or take a given one, index this checkout into a throwaway cache, run `detect_changes` against the base and map it to suites (`scripts/test-impact/predict.py`). Never gates. | `pr.yml test-impact-shadow` | | `verify-shard-union.sh` | Prove sharded test legs lost nothing: shard count agreement, indices 1..n, identical suite lists, union of slices == full list (was inline YAML). No manifests is green only when the lane selection holds no test leg. | `_test.yml` shard-completeness | | `prepare-release-candidates.sh` | Copy one linker output into stripped/unstripped candidates, finalize signatures, composition-check them without execution, and record their hashes. | `_build.yml`, local artifact smoke | | `stage-release-candidates.py` | Admit exactly eight candidate artifacts / sixteen byte-distinct binaries into the content-addressed VirusTotal scan set. | `_build.yml` | diff --git a/scripts/ci/test-impact-shadow.sh b/scripts/ci/test-impact-shadow.sh new file mode 100755 index 000000000..a6f6f369a --- /dev/null +++ b/scripts/ci/test-impact-shadow.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# test-impact-shadow.sh — the PR test-impact SHADOW: predict from the code graph +# which C test suites a change can affect, and publish the prediction. It is +# REPORT-ONLY: pr.yml runs it continue-on-error and ci-ok does not need it, so +# it can never block or pass a merge. Its predictions accumulate next to the +# lanes that actually ran and failed, which is how graph-based selection gets +# evaluated before anyone gates on it. +# +# The binary is the latest PUBLISHED release, not this checkout's build: a few +# seconds of download instead of minutes of compile, and a PR that breaks the +# indexer cannot corrupt its own prediction. pr.yml builds the checkout only +# if the download fails. +# +# Usage: scripts/ci/test-impact-shadow.sh --fetch-release DIR [--repo OWNER/REPO] +# Download the latest release's archive for this host + checksums.txt +# into DIR, verify the SHA-256, extract DIR/codebase-memory-mcp. +# Needs GH_TOKEN (read-only); $GH overrides the gh command. +# scripts/ci/test-impact-shadow.sh --binary PATH --out DIR [--base REV] [--depth N] +# Index this checkout into a throwaway cache (HOME, XDG dirs and +# CBM_CACHE_DIR under DIR/work, a private CBM_RUNTIME_DIR under /tmp: +# the daemon's socket path must fit sun_path), run +# detect_changes against REV (default HEAD^1, the base side of a PR +# merge commit) at depth N (default 15), and let +# scripts/test-impact/predict.py map it to suites with the lane +# selector's run-all triggers. Writes DIR/prediction.json and +# DIR/summary.md (appended to $GITHUB_STEP_SUMMARY when set). The +# graph is only consulted when C sources changed. +# Exit: 0 = prediction written · 1 = the tooling broke · 2 = usage error. +set -euo pipefail + +usage() { sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; } + +FETCH_DIR="" +REPO="${GITHUB_REPOSITORY:-DeusData/codebase-memory-mcp}" +BINARY="" +OUT="" +BASE="HEAD^1" +DEPTH=15 +while [ $# -gt 0 ]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --fetch-release | --repo | --binary | --out | --base | --depth) + if [ $# -lt 2 ]; then + echo "test-impact-shadow.sh: $1 needs a value. Please consult --help." >&2 + exit 2 + fi + case "$1" in + --fetch-release) FETCH_DIR="$2" ;; + --repo) REPO="$2" ;; + --binary) BINARY="$2" ;; + --out) OUT="$2" ;; + --base) BASE="$2" ;; + --depth) DEPTH="$2" ;; + esac + shift + ;; + *) + echo "test-impact-shadow.sh: unknown argument '$1'. Please consult --help." >&2 + exit 2 + ;; + esac + shift +done + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +sha256_check() { # file with " " lines, run in the archive's dir + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "$1" + else + shasum -a 256 -c "$1" + fi +} + +if [ -n "$FETCH_DIR" ]; then + case "$(uname -s)/$(uname -m)" in + Linux/x86_64) asset=codebase-memory-mcp-linux-amd64.tar.gz ;; + Linux/aarch64 | Linux/arm64) asset=codebase-memory-mcp-linux-arm64.tar.gz ;; + Darwin/arm64) asset=codebase-memory-mcp-darwin-arm64.tar.gz ;; + Darwin/x86_64) asset=codebase-memory-mcp-darwin-amd64.tar.gz ;; + *) + echo "test-impact-shadow.sh: no release archive for $(uname -s)/$(uname -m)" >&2 + exit 1 + ;; + esac + read -r -a gh_cmd <<<"${GH:-gh}" + mkdir -p "$FETCH_DIR" + # No tag: the latest release. + "${gh_cmd[@]}" release download --repo "$REPO" --pattern "$asset" \ + --pattern checksums.txt --dir "$FETCH_DIR" --clobber + grep " $asset\$" "$FETCH_DIR/checksums.txt" >"$FETCH_DIR/asset.sha256" + (cd "$FETCH_DIR" && sha256_check asset.sha256) + tar -xzf "$FETCH_DIR/$asset" -C "$FETCH_DIR" codebase-memory-mcp + echo "release binary: $FETCH_DIR/codebase-memory-mcp ($asset, SHA-256 verified)" + exit 0 +fi + +if [ -z "$BINARY" ] || [ -z "$OUT" ]; then + echo "test-impact-shadow.sh: --binary and --out are required. Please consult --help." >&2 + exit 2 +fi +case "$DEPTH" in +'' | *[!0-9]*) + echo "test-impact-shadow.sh: --depth must be a number. Please consult --help." >&2 + exit 2 + ;; +esac +BINARY="$(cd "$(dirname "$BINARY")" && pwd)/$(basename "$BINARY")" +PREDICT="$ROOT/scripts/test-impact/predict.py" +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" +WORK="$OUT/work" +rm -rf "$WORK" +mkdir -p "$WORK/home/.config" "$WORK/home/.cache" "$WORK/cache" +# /cbm-daemon-/cbm-.sock adds ~42 bytes to this path, and +# sun_path holds 104 (macOS) / 108: a deep $OUT or $TMPDIR does not fit. +RUNTIME=$(mktemp -d /tmp/cbm-rt.XXXXXX) +trap 'rm -rf "$RUNTIME"' EXIT + +BASE_SHA=$(git -C "$ROOT" rev-parse --verify "$BASE^{commit}") +git -C "$ROOT" diff --name-only --no-renames "$BASE_SHA" HEAD >"$OUT/changed-files.txt" +bash "$ROOT/scripts/ci/select-lanes.sh" "$OUT/changed-files.txt" >"$OUT/selection.json" + +# From here on nothing may touch the host's real config, cache or daemon. +export HOME="$WORK/home" +export XDG_CONFIG_HOME="$WORK/home/.config" +export XDG_CACHE_HOME="$WORK/home/.cache" +export CBM_CACHE_DIR="$WORK/cache" +export CBM_RUNTIME_DIR="$RUNTIME" + +VERSION=$("$BINARY" --version 2>/dev/null | head -1 || true) +DETECT_ARGS=() +T_INDEX="-" +T_DETECT="-" +if [ "$(python3 "$PREDICT" --needs-graph --changed "$OUT/changed-files.txt")" = yes ]; then + python3 -c 'import json, sys; json.dump({"repo_path": sys.argv[1]}, open(sys.argv[2], "w"))' \ + "$ROOT" "$WORK/index-args.json" + t0=$(date +%s) + "$BINARY" cli --quiet index_repository --args-file "$WORK/index-args.json" >"$WORK/index.json" + t1=$(date +%s) + python3 -c ' +import json, sys +project = json.load(open(sys.argv[1]))["project"] +json.dump({"project": project, "base_branch": sys.argv[2], "depth": int(sys.argv[3]), + "limit": 5000, "changed_limit": 5000, "module_limit": 0, + "max_output_tokens": 1000000, "format": "json"}, open(sys.argv[4], "w")) +' "$WORK/index.json" "$BASE_SHA" "$DEPTH" "$WORK/detect-args.json" + "$BINARY" cli --quiet detect_changes --args-file "$WORK/detect-args.json" >"$WORK/detect.json" + t2=$(date +%s) + # The CLI started a private daemon under $RUNTIME; stop it (best effort). + "$BINARY" daemon stop >/dev/null 2>&1 || true + T_INDEX="$((t1 - t0))s" + T_DETECT="$((t2 - t1))s" + DETECT_ARGS=(--detect "$WORK/detect.json") +fi + +META=$(python3 -c 'import json, sys; print(json.dumps(dict(zip(sys.argv[1::2], sys.argv[2::2]))))' \ + binary "${VERSION:-unknown}" base "$BASE_SHA" "index time" "$T_INDEX" "detect time" "$T_DETECT") +python3 "$PREDICT" --root "$ROOT" --changed "$OUT/changed-files.txt" \ + --selection "$OUT/selection.json" ${DETECT_ARGS[@]+"${DETECT_ARGS[@]}"} --depth "$DEPTH" \ + --meta "$META" --out "$OUT/prediction.json" --summary "$OUT/summary.md" +cat "$OUT/summary.md" +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + cat "$OUT/summary.md" >>"$GITHUB_STEP_SUMMARY" +fi diff --git a/scripts/test-impact/predict.py b/scripts/test-impact/predict.py new file mode 100644 index 000000000..7658ea2b2 --- /dev/null +++ b/scripts/test-impact/predict.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""predict.py — map a detect_changes impact set to the C test suites it can +affect: the PR test-impact SHADOW (scripts/ci/test-impact-shadow.sh). +Report-only; nothing here decides a merge. + +The mapping is the one the September 2026 test-impact replay measured +(detect_changes at depth 15 plus rule fallbacks, "dcF@15"): + - impacted test FUNCTIONS (graph rows in tests/*.c) map to the suites that + RUN_TEST them, resolved in their own file first; a nested suite (RUN_SUITE + inside another) resolves to the registered suite that runs it; only suites + registered in tests/test_main.c count, perf suites excluded; + - a changed tests/*.c file always selects its whole suite; + - a changed fixture (tests/fixtures//...) selects the suites whose test + files name ; a changed grammar selects the grammar suites; + - run-all: the lane selector's full, a changed test header or + test-infrastructure file, a saturated or truncated impact set, or a C + change the graph was not asked about -> every suite. + +Usage: predict.py --root DIR --changed FILE --selection FILE [--detect FILE] + [--depth N] [--meta JSON] --out FILE [--summary FILE] + predict.py --needs-graph --changed FILE (prints yes|no) +""" +import argparse +import json +import os +import re +import sys + +GRAMMAR_SUITES = re.compile(r"^(grammar_|lang_contract|parse_coverage|extraction|matrix_|" + r"edge_types_probe|convergence_probe|ac$|language$)") +C_SOURCE = re.compile(r"\.(c|h|cc|cpp|inc)$") +SUITE_DEF = re.compile(r"^\s*(?:SUITE\((\w+)\)|void\s+suite_(\w+)\s*\(\s*void\s*\))\s*\{", re.M) +RUN_TEST = re.compile(r"\bRUN_TEST\((\w+)\)") +RUN_SUITE = re.compile(r"\bRUN_SUITE\((\w+)\)") + + +def needs_graph(changed): + """A C source change the graph can place (prod or test code).""" + return any(C_SOURCE.search(p) and (p.startswith(("src/", "tests/")) or ( + p.startswith("internal/cbm/") and not p.startswith("internal/cbm/vendored/"))) + for p in changed) + + +def suitemap(root): + """file -> suites defined there, test -> suites running it, suite universe, + and the resolver from any suite to the registered suites that run it.""" + file_suites, test_suites, sources, parents = {}, {}, {}, {} + for directory, dirs, names in os.walk(os.path.join(root, "tests")): + dirs[:] = sorted(d for d in dirs if not (directory == os.path.join(root, "tests") + and d == "fixtures")) + for name in sorted(names): + if not name.endswith(".c"): + continue + path = os.path.join(directory, name) + rel = os.path.relpath(path, root).replace(os.sep, "/") + with open(path, encoding="utf-8", errors="replace") as fh: + src = fh.read() + sources[rel] = src + for m in SUITE_DEF.finditer(src): + suite = m.group(1) or m.group(2) + start = depth = m.end() - 1 + end = start + depth = 0 + while end < len(src): # the body is the balanced { } block + if src[end] == "{": + depth += 1 + elif src[end] == "}": + depth -= 1 + if depth == 0: + break + end += 1 + file_suites.setdefault(rel, []).append(suite) + for test in RUN_TEST.findall(src[start:end]): + test_suites.setdefault(test, []) + if suite not in test_suites[test]: + test_suites[test].append(suite) + for child in RUN_SUITE.findall(src[start:end]): + parents.setdefault(child, set()).add(suite) + main = sources.get("tests/test_main.c", "") + registered = set(re.findall(r"RUN_SELECTED_SUITE(?:_PERF)?\((\w+)\)", main)) + universe = registered - set(re.findall(r"RUN_SELECTED_SUITE_PERF\((\w+)\)", main)) + + def runnable(suite, seen=frozenset()): + """A registered suite runs itself; a nested one runs inside its parents.""" + if suite in registered: + return {suite} & universe + out = set() + for parent in parents.get(suite, ()): + if parent not in seen: + out |= runnable(parent, seen | {suite}) + return out + return file_suites, test_suites, universe, sources, runnable + + +def predict(root, changed, selection, detect, depth): + file_suites, test_suites, universe, sources, runnable = suitemap(root) + reasons, run_all = [], False + if selection.get("full"): + run_all = True + reasons.append(f"run-all: the lane selector ran everything ({selection.get('tier')})") + for p in changed: + if p.startswith("tests/") and not p.startswith("tests/fixtures/") and p.endswith(".h"): + run_all = True + reasons.append(f"run-all: test header {p}") + elif p.startswith("test-infrastructure/"): + run_all = True + reasons.append(f"run-all: test infrastructure {p}") + + graph = {"consulted": detect is not None} + picked = {} # suite -> "ALL" | set(tests) + if detect is not None: + rows = detect.get("impacted", []) + total = detect.get("impacted_total", len(rows)) + shown = detect.get("impacted_shown", len(rows)) + graph.update(impacted_total=total, impacted_shown=shown, + saturated=bool(detect.get("engine_saturated"))) + if graph["saturated"]: + run_all = True + reasons.append("run-all: detect_changes saturated") + if total > len(rows) or shown > len(rows): + run_all = True + reasons.append(f"run-all: impact set truncated ({len(rows)} of {total} rows)") + tests = 0 + for r in rows: + fp, name = r.get("file", ""), r.get("qn", "").rsplit(".", 1)[-1] + if not fp.startswith("tests/") or r.get("label") != "Function" or r.get("hop", 0) > depth: + continue + local = file_suites.get(fp, []) + if name in local: + continue # the SUITE function itself + candidates = test_suites.get(name, []) + for suite in [s for s in candidates if s in local] or candidates: + for runner in runnable(suite): + picked.setdefault(runner, set()).add(name) + tests += 1 + graph["test_rows"] = tests + elif needs_graph(changed): + run_all = True + reasons.append("run-all: C change but no impact set (graph not consulted)") + + for p in changed: + if p.startswith("tests/fixtures/"): # fixture sources are data, not tests + key = p.split("/")[2] if p.count("/") >= 2 else p + for f, src in sources.items(): + if key in src: + for suite in file_suites.get(f, []): + for runner in runnable(suite): + picked[runner] = "ALL" + reasons.append(f"changed fixture {p}: suites whose tests name '{key}'") + elif p.startswith("tests/") and p.endswith(".c"): + for suite in file_suites.get(p, []): + for runner in runnable(suite): + picked[runner] = "ALL" + reasons.append(f"changed test file {p}: suite {runner}") + elif p.startswith("internal/cbm/vendored/grammars/"): + for suite in universe: + if GRAMMAR_SUITES.match(suite): + picked[suite] = "ALL" + reasons.append(f"changed grammar {p}: grammar suites") + + if run_all: + mode, suites = "all", {s: "ALL" for s in sorted(universe)} + else: + suites = {s: v if v == "ALL" else sorted(v) for s, v in sorted(picked.items())} + mode = "graph" if (graph["consulted"] or suites) else "none" + return {"version": 1, "mode": mode, "depth": depth, + "selector": {"tier": selection.get("tier"), "full": bool(selection.get("full"))}, + "universe": len(universe), "selected_suites": len(suites), "suites": suites, + "graph": graph, "reasons": sorted(set(reasons))} + + +def summary(result, meta): + g = result["graph"] + whole = sum(1 for v in result["suites"].values() if v == "ALL") + tests = sum(len(v) for v in result["suites"].values() if v != "ALL") + lines = ["### Test-impact shadow (report-only, never gates)", "", + "| | |", "|---|---|", + f"| selector | {result['selector']['tier']} (full: {'yes' if result['selector']['full'] else 'no'}) |", + f"| prediction | **{result['mode']}**: {result['selected_suites']} of {result['universe']} " + f"suites ({whole} whole, {tests} tests in the rest) |"] + if g.get("consulted"): + lines.append(f"| graph | {g.get('impacted_shown')} of {g.get('impacted_total')} impacted rows at " + f"depth {result['depth']}, {g.get('test_rows')} test rows" + f"{', saturated' if g.get('saturated') else ''} |") + for key in sorted(meta): + lines.append(f"| {key} | {meta[key]} |") + if result["reasons"]: + lines += ["", "Reasons: " + "; ".join(result["reasons"][:12])] + if result["mode"] == "graph" and result["suites"]: + lines += ["", "Suites: " + ", ".join(result["suites"])[:1500]] + return "\n".join(lines) + "\n" + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--needs-graph", action="store_true") + ap.add_argument("--root") + ap.add_argument("--changed", required=True) + ap.add_argument("--selection") + ap.add_argument("--detect") + ap.add_argument("--depth", type=int, default=15) + ap.add_argument("--meta", default="{}") + ap.add_argument("--out") + ap.add_argument("--summary") + a = ap.parse_args() + with open(a.changed, encoding="utf-8") as fh: + changed = sorted({line.strip() for line in fh if line.strip()}) + if a.needs_graph: + print("yes" if needs_graph(changed) else "no") + return 0 + if not (a.root and a.selection and a.out): + ap.error("--root, --selection and --out are required") + with open(a.selection, encoding="utf-8") as fh: + selection = json.load(fh) + detect = None + if a.detect: + with open(a.detect, encoding="utf-8") as fh: + detect = json.load(fh) + result = predict(a.root, changed, selection, detect, a.depth) + meta = json.loads(a.meta) + result["meta"] = meta + with open(a.out, "w", encoding="utf-8") as fh: + json.dump(result, fh, indent=1, sort_keys=True) + fh.write("\n") + if a.summary: + with open(a.summary, "w", encoding="utf-8") as fh: + fh.write(summary(result, meta)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test.sh b/scripts/test.sh index bcac86d01..bfc299b15 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -360,6 +360,8 @@ echo "=== Step 0z3: lane-aware aggregate gates (ci-ok, shard union) ===" bash "$ROOT/tests/test_lane_gate_contract.sh" echo "=== Step 0z4: lane wiring (PRs select, dry run and release run all) ===" bash "$ROOT/tests/test_lane_wiring_contract.sh" +echo "=== Step 0z5: test-impact shadow prediction contract ===" +bash "$ROOT/tests/test_test_impact_predict.sh" if [ "$CONTRACTS_ONLY" -eq 1 ]; then echo "=== test.sh: contracts-only — every contract step passed ===" diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index 8aa9e0921..171e61b34 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -8,7 +8,7 @@ # 2. pr.yml feeds the selector's lanes to every reusable workflow, runs the # docs-only contracts job, and hands ci-ok both the selection and the # run's job list (actions: read) -- ci-ok needs `contracts` but never the -# report-only memwaste / shadow jobs. +# report-only memwaste / shadow jobs, and the shadow is continue-on-error. # 3. every lane of scripts/ci/select-lanes.sh --list-lanes is served by a # job: matrix lanes as a "lane":"" tag in the leg data (the tag # ci-ok reads back from the job name), single lanes by an `if:` that @@ -98,6 +98,11 @@ for needle, why in (("LANES: ${{ needs.changes.outputs.lanes }}", "the selection ("jobs?filter=latest", "the latest attempt of every job"), ("scripts/ci/require-all-green.sh", "the canonical gate")): require(needle in ci_ok, f"ci-ok must use {why} ({needle})") +shadow = pr.get("test-impact-shadow", "") +require("scripts/ci/test-impact-shadow.sh --binary" in shadow + and re.search(r"(?m)^ continue-on-error: true$", shadow) is not None, + "pr.yml test-impact-shadow must exist, run the canonical script and be " + "continue-on-error (report-only)") test_wf = jobs(text("_test.yml")) require('--lanes "$LANES"' in test_wf.get("shard-completeness", ""), "shard-completeness must pass the selection to verify-shard-union.sh") diff --git a/tests/test_test_impact_predict.sh b/tests/test_test_impact_predict.sh new file mode 100755 index 000000000..598988830 --- /dev/null +++ b/tests/test_test_impact_predict.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Test-impact prediction contract: scripts/test-impact/predict.py turns a +# detect_changes impact set into the C test suites a change can affect. It +# feeds the PR test-impact SHADOW job -- report-only, never a gate -- but a +# shadow that silently under-predicts would make its own evaluation lie, so +# the mapping and its fallbacks are pinned on a synthetic tree: +# - impacted test functions map to their registered suites (perf excluded; +# a nested suite resolves to the registered suite that runs it); +# - a changed test file always selects its whole suite; +# - a changed fixture selects the suites whose tests name it; +# - run-all: the selector's full, a test header, a saturated or truncated +# impact set -> every suite; +# - no C change -> the graph is not consulted, nothing is predicted. +# +# Usage: tests/test_test_impact_predict.sh [repo-root] + +set -euo pipefail + +ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +PREDICT="$ROOT/scripts/test-impact/predict.py" +if [ ! -f "$PREDICT" ]; then + echo "FAIL: $PREDICT not found" >&2 + exit 1 +fi +WORK=$(mktemp -d "${TMPDIR:-/tmp}/cbm-impact-predict.XXXXXX") +trap 'rm -rf "$WORK"' EXIT + +python3 - "$PREDICT" "$WORK" <<'PY' +import json +import pathlib +import subprocess +import sys + +predict, work = sys.argv[1], pathlib.Path(sys.argv[2]) +tree = work / "tree" +(tree / "tests").mkdir(parents=True) +(tree / "tests" / "test_main.c").write_text( + "RUN_SELECTED_SUITE(alpha);\nRUN_SELECTED_SUITE(beta);\n" + "RUN_SELECTED_SUITE(gamma);\nRUN_SELECTED_SUITE_PERF(delta);\n") +(tree / "tests" / "test_alpha.c").write_text( + "TEST(a1) { }\nTEST(a2) { }\nSUITE(alpha) {\n RUN_TEST(a1);\n RUN_TEST(a2);\n}\n") +(tree / "tests" / "test_beta.c").write_text( + 'TEST(b1) { load("tests/fixtures/kx/in.c"); }\nSUITE(beta) {\n RUN_TEST(b1);\n}\n') +(tree / "tests" / "test_gamma.c").write_text( + "TEST(g1) { }\nvoid suite_gamma(void) {\n RUN_TEST(g1);\n}\n") +(tree / "tests" / "test_delta.c").write_text("TEST(d1) { }\nSUITE(delta) {\n RUN_TEST(d1);\n}\n") +# A nested suite runs only inside a registered parent (RUN_SUITE in its body). +(tree / "tests" / "test_nested.c").write_text( + "TEST(n1) { }\nSUITE(inner) {\n RUN_TEST(n1);\n}\nSUITE(outer) {\n RUN_SUITE(inner);\n}\n") +(tree / "tests" / "test_main.c").write_text( + (tree / "tests" / "test_main.c").read_text() + "RUN_SELECTED_SUITE(outer);\n") +failures = [] + + +def row(qn, label, file, hop): + return {"qn": qn, "label": label, "file": file, "hop": hop} + + +def predict_for(name, changed, selection, detect=None): + case = work / name + case.mkdir() + (case / "changed.txt").write_text("".join(f + "\n" for f in changed)) + (case / "selection.json").write_text(json.dumps(selection)) + args = [sys.executable, predict, "--root", str(tree), "--changed", str(case / "changed.txt"), + "--selection", str(case / "selection.json"), "--out", str(case / "p.json"), + "--summary", str(case / "s.md")] + if detect is not None: + (case / "detect.json").write_text(json.dumps(detect)) + args += ["--detect", str(case / "detect.json")] + proc = subprocess.run(args, capture_output=True, text=True) + if proc.returncode != 0: + failures.append(f"{name}: predict.py exited {proc.returncode}: {proc.stderr.strip()}") + return None + return json.loads((case / "p.json").read_text()) + + +def expect(name, got, mode, suites): + if got is None: + return + if got["mode"] != mode: + failures.append(f"{name}: mode {got['mode']}, expected {mode}") + if got["suites"] != suites: + failures.append(f"{name}: suites {got['suites']}, expected {suites}") + + +NARROW = {"tier": "T3-mid", "full": False} +graph = {"impacted_total": 3, "impacted_shown": 3, "impacted": [ + row("p.src.store.store_open", "Function", "src/store/store.c", 1), + row("p.tests.test_alpha.a1", "Function", "tests/test_alpha.c", 2), + row("p.tests.test_alpha.alpha", "Function", "tests/test_alpha.c", 3), + row("p.tests.test_delta.d1", "Function", "tests/test_delta.c", 2)]} + +expect("graph rows map to suites, perf excluded", + predict_for("graph", ["src/store/store.c"], NARROW, graph), "graph", {"alpha": ["a1"]}) +expect("a test in a nested suite maps to its registered parent", + predict_for("nested", ["src/store/store.c"], NARROW, + {"impacted_total": 1, "impacted_shown": 1, + "impacted": [row("p.tests.test_nested.n1", "Function", "tests/test_nested.c", 2)]}), + "graph", {"outer": ["n1"]}) +expect("a changed test file selects its whole suite", + predict_for("changed-test", ["src/store/store.c", "tests/test_gamma.c"], NARROW, graph), + "graph", {"alpha": ["a1"], "gamma": "ALL"}) +expect("a changed fixture selects the suites that name it", + predict_for("fixture", ["tests/fixtures/kx/in.c"], {"tier": "T1-tests", "full": False}, + {"impacted_total": 0, "impacted_shown": 0, "impacted": []}), + "graph", {"beta": "ALL"}) +ALL = {"alpha": "ALL", "beta": "ALL", "gamma": "ALL", "outer": "ALL"} +expect("the selector's full runs every suite", + predict_for("full", ["Makefile.cbm", "src/store/store.c"], {"tier": "T5-core", "full": True}, graph), + "all", ALL) +expect("a changed test header runs every suite", + predict_for("test-header", ["tests/test_helpers.h"], {"tier": "T1-tests", "full": False}, graph), + "all", ALL) +expect("a saturated impact set runs every suite", + predict_for("saturated", ["src/store/store.c"], NARROW, dict(graph, engine_saturated=True)), + "all", ALL) +expect("a truncated impact set runs every suite", + predict_for("truncated", ["src/store/store.c"], NARROW, dict(graph, impacted_total=9000)), + "all", ALL) +expect("no C change predicts nothing", + predict_for("docs", ["README.md"], {"tier": "T0a-docs", "full": False}), "none", {}) +expect("a C change without a graph is not a narrow prediction", + predict_for("no-graph", ["src/store/store.c"], NARROW), "all", ALL) + +for changed, want in ((["README.md"], "no"), (["src/store/store.c"], "yes"), + (["tests/test_alpha.c"], "yes")): + (work / "needs.txt").write_text("".join(f + "\n" for f in changed)) + out = subprocess.run([sys.executable, predict, "--needs-graph", "--changed", + str(work / "needs.txt")], capture_output=True, text=True).stdout.strip() + if out != want: + failures.append(f"--needs-graph for {changed}: {out!r}, expected {want!r}") + +if failures: + print("TEST-IMPACT PREDICTION CONTRACT VIOLATED:") + for failure in failures: + print(f" {failure}") + sys.exit(1) +print("test-impact prediction contract OK (mapping, fallbacks, run-all triggers)") +PY diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index 031e9f6d4..c09a0144d 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -393,6 +393,7 @@ scripts/ci/preflight-docker.sh scripts/ci/require-all-green.sh scripts/ci/verify-shard-union.sh scripts/ci/select-lanes.sh +scripts/ci/test-impact-shadow.sh scripts/ci/generate-sbom.py scripts/package-release.sh scripts/ci/smoke-artifact.sh @@ -438,6 +439,7 @@ scripts/smoke-invariants.sh scripts/fuzz.sh scripts/memwaste.sh scripts/ci/select-lanes.sh +scripts/ci/test-impact-shadow.sh " for entry in $STRICT_ENTRIES; do out=$(cd "$ROOT" && bash "$entry" --definitely-not-a-flag 2>&1) && rc=0 || rc=$? From ae5bb0bdeac4c57ffec96a720bec39c7ff1eb1d7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 30 Sep 2026 23:30:57 +0200 Subject: [PATCH 09/50] ci: PR shard profile (x86 and Windows whole, arm64 x2) _test.yml takes a shard_profile input: "standard" (the default; dry run and release pass nothing and keep ubuntu x3 / windows x2, byte-identical matrices) or "pr", which pr.yml now uses: ubuntu x86 and Windows run as one job each and only arm64, the slowest leg, splits in two. A PR runs few legs and every shard re-pays its setup, so fewer, longer jobs cost less. An unknown profile is an error. The wiring contract pins both the default and PR's use of the profile. Signed-off-by: Martin Vogel --- .github/workflows/_test.yml | 25 ++++++++++++++++++++----- .github/workflows/pr.yml | 8 +++++--- tests/test_lane_wiring_contract.sh | 10 ++++++++-- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index bc19ab8d3..d250dd031 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -16,6 +16,10 @@ on: description: 'Split each C-suite leg across parallel shard jobs (ubuntu x3, windows x2); off = one job per leg, byte-identical to the pre-shard topology' type: boolean default: false + shard_profile: + description: 'Shard counts when shard_suites is on: standard = ubuntu x3, windows x2 (dry run, release); pr = ubuntu x86 x1, ubuntu arm64 x2, windows x1 (fewer jobs re-paying setup)' + type: string + default: standard lanes: description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list): only those legs and jobs run; "all" = every one (dry run, release)' type: string @@ -44,6 +48,7 @@ jobs: env: BROAD: ${{ inputs.broad_platforms }} SHARDS: ${{ inputs.shard_suites }} + SHARD_PROFILE: ${{ inputs.shard_profile }} LANES: ${{ inputs.lanes }} run: | # Every CORE leg is tagged with the lane (scripts/ci/select-lanes.sh) @@ -113,14 +118,24 @@ jobs: # shards); Windows gets 2 (every extra shard re-pays ~5 min of # MSYS2 setup); macOS stays at 1 — mac legs are not the critical # path and mac runner concurrency ceilings are the tightest. + # The pr profile shards less: a PR runs few legs and every shard + # re-pays its setup, so x86 and Windows run whole and only arm64, + # the slowest leg (~72 min unsharded), splits in two. + case "$SHARD_PROFILE" in + standard) N_X86=3; N_ARM=3; N_WIN=2 ;; + pr) N_X86=1; N_ARM=2; N_WIN=1 ;; + *) echo "::error::unknown shard_profile '$SHARD_PROFILE'"; exit 1 ;; + esac if [ "$SHARDS" = "true" ]; then - UNIX=$(jq -c '[.[] | . as $e - | (if ($e.os | startswith("ubuntu")) then 3 else 1 end) as $n + UNIX=$(jq -c --argjson x86 "$N_X86" --argjson arm "$N_ARM" '[.[] | . as $e + | (if ($e.os | startswith("ubuntu")) then + (if ($e.os | endswith("-arm")) then $arm else $x86 end) + else 1 end) as $n | range(1; $n + 1) as $i | $e + {shard: "\($i)/\($n)"}]' <<< "$UNIX") - WIN=$(jq -c '[.[] | . as $e - | range(1; 3) as $i - | $e + {shard: "\($i)/2"}]' <<< "$WIN") + WIN=$(jq -c --argjson n "$N_WIN" '[.[] | . as $e + | range(1; $n + 1) as $i + | $e + {shard: "\($i)/\($n)"}]' <<< "$WIN") fi echo "unix={\"include\":$UNIX}" >> "$GITHUB_OUTPUT" echo "windows={\"include\":$WIN}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d6d966912..9f45fb0cb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -53,10 +53,12 @@ jobs: # Perf assertions are timing-sensitive on shared runners; they stay in # dry runs and releases where a flaky red does not block a merge. skip_perf: true - # Iteration speed: split each C-suite leg across parallel shard jobs - # (ubuntu x3, windows x2; coverage re-proven every run by the - # shard-completeness job). Dry runs and releases shard identically. + # Iteration speed: split C-suite legs across parallel shard jobs + # (coverage re-proven every run by the shard-completeness job). The PR + # profile shards only arm64 (x2); x86 and Windows run whole, since each + # shard re-pays its setup. Dry runs and releases keep x3 / x2. shard_suites: true + shard_profile: pr # ── Which lanes does this PR need? The changed files go through # scripts/ci/select-lanes.sh (tier, lanes, full, reasons); docs / CI / diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index 171e61b34..f57bcb11a 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -3,8 +3,9 @@ # are selected at all. # # 1. dry-run.yml and release.yml pass no `lanes` (and no shard profile) to -# _test.yml / _lint.yml / _security.yml, whose inputs default to "all": -# those runs execute every job exactly as before lane selection. +# _test.yml / _lint.yml / _security.yml, whose inputs default to "all" +# (and "standard"): those runs execute every job, sharded, exactly as +# before lane selection. Only pr.yml asks for the PR shard profile. # 2. pr.yml feeds the selector's lanes to every reusable workflow, runs the # docs-only contracts job, and hands ci-ok both the selection and the # run's job list (actions: read) -- ci-ok needs `contracts` but never the @@ -62,6 +63,9 @@ for caller in ("dry-run.yml", "release.yml"): require(not re.search(r"(?m)^\s+(lanes|shard_profile):", body), f"{caller} job {job} passes a lane selection to {called.group(1)}; " f"dry runs and releases must run every lane") +m = re.search(r"(?ms)^ shard_profile:\n(.*?)(?=^ \S|^\S|\Z)", text("_test.yml")) +require(m is not None and re.search(r"(?m)^ default: standard$", m.group(1)) is not None, + "_test.yml must declare a shard_profile input defaulting to standard") for callee in REUSABLE: src = text(callee) m = re.search(r"(?ms)^ lanes:\n(.*?)(?=^ \S|^\S|\Z)", src) @@ -83,6 +87,8 @@ for key in ("tier", "full", "lanes"): for job in ("security", "lint", "test"): require("lanes: ${{ needs.changes.outputs.lanes }}" in pr.get(job, ""), f"pr.yml {job} must pass the selected lanes") +require(re.search(r"(?m)^ shard_profile: pr$", pr.get("test", "")) is not None, + "pr.yml test must use the PR shard profile") require("scripts/test.sh --contracts-only" in pr.get("contracts", ""), "pr.yml contracts job must run scripts/test.sh --contracts-only") ci_ok = pr.get("ci-ok", "") From d3efe3baecac3347e5b45fd45d6afed3c42f8290 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 30 Sep 2026 23:46:46 +0200 Subject: [PATCH 10/50] ci: a truncated PR file list selects every lane The pulls/files API lists at most 3000 files and says nothing when it stops, and the lane decision is only as complete as that list: a cut list can drop exactly the file that needed a lane. select-lanes.sh takes --expect-files N (the PR's changed_files; batch rows take "expect_files") and decides full with a file_list_truncated reason when the list has fewer distinct paths than N (a complete list never does: renames only add their old path), when N is at or over the 3000-file cap, or when the list itself reaches the cap. The tier still classifies what was listed. An empty or non-numeric count is a usage error, so a workflow that loses the count fails loudly instead of skipping the check. pr.yml passes github.event.pull_request.changed_files. The selector contract covers short, complete, rename-inflated, at-cap and just-under-cap lists and the github-output and batch channels; the wiring contract requires the count to be passed. Signed-off-by: Martin Vogel --- .github/workflows/pr.yml | 6 ++- scripts/ci/select-lanes.sh | 68 ++++++++++++++++++++++++------ tests/test_lane_wiring_contract.sh | 3 ++ tests/test_select_lanes.sh | 48 ++++++++++++++++++++- 4 files changed, 109 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9f45fb0cb..dbaca5098 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -108,10 +108,14 @@ jobs: with: persist-credentials: false + # The pulls/files API lists at most 3000 files: the PR's own count lets + # the selector notice a cut list and select everything instead. - name: Select lanes id: select + env: + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} run: | - scripts/ci/select-lanes.sh --format github-output "$RUNNER_TEMP/changed-files.txt" > "$RUNNER_TEMP/selection.txt" + scripts/ci/select-lanes.sh --format github-output --expect-files "$CHANGED_FILES" "$RUNNER_TEMP/changed-files.txt" > "$RUNNER_TEMP/selection.txt" cat "$RUNNER_TEMP/selection.txt" cat "$RUNNER_TEMP/selection.txt" >> "$GITHUB_OUTPUT" # pr-smoke's legs, each tagged with its lane (kept in the job name, diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh index 98cec269a..6d618bcb7 100755 --- a/scripts/ci/select-lanes.sh +++ b/scripts/ci/select-lanes.sh @@ -10,28 +10,35 @@ # `full` overrides it with every lane. # # It FAILS SAFE: full=true for tier T5, a path no rule knows, an empty file -# list, this script, any workflow, the test harness, Makefile.cbm (source -# registration and flag changes look identical in a file list) and vendored -# code. It is a pure function of the file SET: order, duplicates, blank lines -# and CRLF never change a byte of the output. +# list, a truncated one (file_list_truncated: the pulls/files API stops at +# 3000 files), this script, any workflow, the test harness, Makefile.cbm +# (source registration and flag changes look identical in a file list) and +# vendored code. It is a pure function of the file SET (and the expected +# count): order, duplicates, blank lines and CRLF never change a byte of the +# output. # -# Usage: scripts/ci/select-lanes.sh [--format json|github-output] [FILE] +# Usage: scripts/ci/select-lanes.sh [--format json|github-output] +# [--expect-files N] [FILE] # FILE (default stdin): changed paths, one per line. +# --expect-files N: the PR's changed_files; fewer distinct paths, or +# N or the list at the 3000-file API cap, means truncated -> full. # json (default): {"full","lanes","reasons","tier"} on one line. # github-output: tier=, full=, lanes=, reasons= lines ($GITHUB_OUTPUT). # scripts/ci/select-lanes.sh --batch FILE -# FILE: JSON lines {"id":..., "files":[...]}; prints one json result -# per line with its "id" (the history replay's single process). +# FILE: JSON lines {"id":..., "files":[...], "expect_files"?: N}; +# prints one json result per line with its "id" (the history +# replay's single process). # scripts/ci/select-lanes.sh --list-lanes # Every lane name, one per line (the universe ci-ok checks against). # Exit: 0 = decided · 2 = usage error. set -euo pipefail -usage() { sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; } +usage() { sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; } FORMAT=json MODE="select" INPUT="" +EXPECT="" while [ $# -gt 0 ]; do case "$1" in -h | --help) @@ -49,6 +56,13 @@ while [ $# -gt 0 ]; do [ $# -gt 1 ] && shift ;; --list-lanes) MODE="list" ;; + --expect-files) + # An empty value (a workflow whose count went missing) must fail + # loudly, never quietly skip the truncation check. + EXPECT="${2:-}" + [ -n "$EXPECT" ] || EXPECT="missing" + [ $# -gt 1 ] && shift + ;; -*) echo "select-lanes.sh: unknown option '$1'. Please consult --help." >&2 exit 2 @@ -70,6 +84,12 @@ json | github-output) ;; exit 2 ;; esac +case "${EXPECT:-0}" in +*[!0-9]*) + echo "select-lanes.sh: --expect-files takes a file count. Please consult --help." >&2 + exit 2 + ;; +esac if [ "$MODE" = batch ] && [ -z "$INPUT" ]; then echo "select-lanes.sh: --batch needs a file. Please consult --help." >&2 exit 2 @@ -88,12 +108,13 @@ if [ "$MODE" = select ] && [ -z "$INPUT" ]; then INPUT="$STAGED" fi -python3 - "$MODE" "$FORMAT" "$INPUT" <<'PY' +python3 - "$MODE" "$FORMAT" "$INPUT" "$EXPECT" <<'PY' import json import re import sys mode, fmt, source = sys.argv[1:4] +expect = int(sys.argv[4]) if sys.argv[4] else None # ── Path -> category (first matching rule wins; order is part of the rule) ── GATE_WORKFLOWS = {"pr.yml", "_test.yml", "_lint.yml", "_security.yml", @@ -276,7 +297,23 @@ def triggers(path, cat): return found -def select(paths): +LIST_CAP = 3000 # the pulls/files API lists at most this many files + + +def truncated(paths, expect): + """A list that may be missing files: at or over the API cap, or shorter + than the PR's changed_files (a complete list is never shorter: every + changed file is listed, renames add their old path on top).""" + if len(paths) >= LIST_CAP: + return f"{len(paths)} paths listed, at the {LIST_CAP}-file API cap" + if expect is not None and expect >= LIST_CAP: + return f"the PR changes {expect} files, over the {LIST_CAP}-file API cap" + if expect is not None and len(paths) < expect: + return f"{len(paths)} paths listed for {expect} changed files" + return None + + +def select(paths, expect=None): paths = sorted({p for p in paths if p}) by_cat, by_trigger = {}, {} for path in paths: @@ -302,7 +339,10 @@ def select(paths): reasons.append("full: empty file list (fail safe)") if tier == "T5-core": reasons.append("full: tier T5-core") - full = tier == "T5-core" or bool(by_trigger) or not paths + short = truncated(paths, expect) + if short: + reasons.append(f"full: file_list_truncated: {short} (fail safe)") + full = tier == "T5-core" or bool(by_trigger) or not paths or bool(short) if full: lanes = set(FULL) return {"full": full, "lanes": sorted(lanes), "reasons": sorted(reasons), "tier": tier} @@ -324,13 +364,13 @@ elif mode == "batch": for line in fh: if line.strip(): row = json.loads(line) - print(dump({"id": row["id"], **select(row["files"])})) + print(dump({"id": row["id"], **select(row["files"], row.get("expect_files"))})) elif fmt == "github-output": - decision = select(read_paths(source)) + decision = select(read_paths(source), expect) print(f"tier={decision['tier']}") print(f"full={'true' if decision['full'] else 'false'}") print(f"lanes={dump(decision['lanes'])}") print(f"reasons={dump(decision['reasons'])}") else: - print(dump(select(read_paths(source)))) + print(dump(select(read_paths(source), expect))) PY diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index f57bcb11a..a176079a7 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -81,6 +81,9 @@ pr = jobs(text("pr.yml")) changes = pr.get("changes", "") require("scripts/ci/select-lanes.sh --format github-output" in changes, "pr.yml changes must run scripts/ci/select-lanes.sh --format github-output") +require("--expect-files" in changes and "github.event.pull_request.changed_files" in changes, + "pr.yml changes must pass the PR's changed_files to the selector (--expect-files), " + "so a truncated file list selects everything") for key in ("tier", "full", "lanes"): require(re.search(rf"(?m)^ {key}: \$\{{\{{ steps\.select\.outputs\.{key} \}}\}}$", changes), f"pr.yml changes must output {key}") diff --git a/tests/test_select_lanes.sh b/tests/test_select_lanes.sh index a421051c2..6cdc4bf84 100755 --- a/tests/test_select_lanes.sh +++ b/tests/test_select_lanes.sh @@ -9,7 +9,8 @@ # workflow, the test harness, Makefile.cbm and vendored code select FULL; # - determinism: order and duplicates never change a byte of the output; # - one decision per input channel: stdin, a file, --batch and the -# github-output form all agree. +# github-output form all agree; +# - a truncated file list (the pulls/files API stops at 3000) selects FULL. # The history replay (every September PR push + the real failures) is # scripts/test-impact/replay-selector.sh, a separate step. # @@ -155,6 +156,51 @@ rc, _, err = run(["--definitely-not-a-flag"]) if rc != 2 or "Please consult --help." not in err: failures.append("an unknown flag must exit 2 with 'Please consult --help.'") +# 7. A truncated file list is never trusted. The pulls/files API lists at most +# 3000 files, so fewer distinct paths than the PR's changed_files, a PR at +# the cap, or a list that reaches the cap selects FULL (file_list_truncated); +# the tier still classifies what was listed. A complete list decides as usual +# (renames add their old path, so a complete list may exceed the count). +DOCS = ["README.md", "docs/a.md", "docs/b.md"] +CAP_LIST = [f"docs/page{i:04d}.md" for i in range(3000)] + + +def truncation(label, files, args, want_full): + rc, out, err = run(args, "".join(f + "\n" for f in files)) + if rc != 0: + failures.append(f"truncation: {label}: selector exited {rc}: {err.strip()}") + return + got = json.loads(out) + flagged = any("file_list_truncated" in r for r in got["reasons"]) + if got["full"] is not want_full or flagged is not want_full: + failures.append(f"truncation: {label}: full={got['full']}, file_list_truncated " + f"reason={flagged}, expected {want_full}") + if want_full and set(got["lanes"]) != FULL: + failures.append(f"truncation: {label}: a truncated list must select every lane") + if got["tier"] != "T0a-docs": + failures.append(f"truncation: {label}: tier {got['tier']}, expected T0a-docs") + + +truncation("fewer paths than changed_files", DOCS, ["--expect-files", "5"], True) +truncation("complete list, count matches", DOCS, ["--expect-files", "3"], False) +truncation("renames list their old path too", DOCS, ["--expect-files", "2"], False) +truncation("a PR at the 3000-file cap", DOCS, ["--expect-files", "3000"], True) +truncation("a list that reaches the cap", CAP_LIST, [], True) +truncation("a complete list just under the cap", CAP_LIST[:2999], ["--expect-files", "2999"], False) +rc, out, _ = run(["--format", "github-output", "--expect-files", "5"], "".join(f + "\n" for f in DOCS)) +if rc != 0 or "full=true" not in out.splitlines(): + failures.append(f"truncation: github-output form must say full=true: {out!r}") +trunc_batch = work / "trunc.jsonl" +trunc_batch.write_text(json.dumps({"id": "t", "files": DOCS, "expect_files": 5}) + "\n", encoding="utf-8") +rc, out, _ = run(["--batch", str(trunc_batch)]) +if rc != 0 or not json.loads(out.splitlines()[0])["full"]: + failures.append("truncation: --batch rows must honour expect_files") +for bad in ("many", ""): # "" = a workflow whose count went missing + rc, _, err = run(["--expect-files", bad], "README.md\n") + if rc != 2 or "Please consult --help." not in err: + failures.append(f"--expect-files {bad!r} must be a usage error (exit 2), never " + f"a silently skipped truncation check") + if failures: print("LANE SELECTOR CONTRACT VIOLATED:") for failure in failures: From 16e273281955abea911969c94de138414c66b92a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 30 Sep 2026 23:48:22 +0200 Subject: [PATCH 11/50] ci: non-product PRs (T0b) run the contract steps too Under R1, a T0b push (test-infrastructure/, lint config, graph-ui, memwaste tooling) selected lint and no test leg, so the static contract steps that police exactly those files -- the VM scripts, the compose and run.sh wiring, the venue-parity rules -- ran nowhere. Replayed over September, 13 of the 18 T0b pushes ran no contract step at all. The R1 table now gives T0b lint + contracts, so those PRs get the contracts job (scripts/test.sh --contracts-only) as T0a PRs do. The fixture table's six T0b cases expect it, and the history replay asserts that every push runs the contract steps (the contracts lane or any test leg). Comments that described the job as docs-only are updated. Signed-off-by: Martin Vogel --- .github/workflows/pr.yml | 8 ++++---- scripts/README.md | 2 +- scripts/ci/select-lanes.sh | 5 +++-- scripts/test-impact/replay-selector.sh | 19 ++++++++++++++++--- scripts/test.sh | 2 +- tests/fixtures/select-lanes/cases.tsv | 12 ++++++------ tests/test_lane_wiring_contract.sh | 2 +- 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index dbaca5098..da13aea51 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -261,10 +261,10 @@ jobs: ${{ runner.temp }}/test-impact/changed-files.txt if-no-files-found: warn - # ── Docs-only PRs (tier T0a) select no test leg, so this runs the static - # contract steps of the canonical test leg on their own (they police - # docs surfaces too: language counts, version metadata). Every test leg - # runs the same steps, so the job exists only when no leg does. ── + # ── Docs-only and non-product PRs (tiers T0a, T0b) run the static contract + # steps of the canonical test leg on their own: they police docs + # surfaces (language counts, version metadata), test-infrastructure/ and + # lint config too. Every test leg runs the same steps. ── contracts: needs: [changes] if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'contracts') }} diff --git a/scripts/README.md b/scripts/README.md index 93e65b507..519196c48 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -17,7 +17,7 @@ codes) and rejects unknown flags with exit 2 + `Please consult --help.` | leg | entry | what a run gives you | |---|---|---| -| **test** | `test.sh` | DEFAULT = the venue leg: static contracts (Step 0a–0r) + CLEAN ASan+UBSan build + all suites via the parallel harness + prod-binary guards. `--suites a,b` = iteration mode (incremental rebuild, subset, seconds). `--tsan` = the ThreadSanitizer leg. `--contracts-only` = the static contract steps alone (PR CI's docs-only job). CLANGARM64 gets CI's trap-UBSan flags automatically. | +| **test** | `test.sh` | DEFAULT = the venue leg: static contracts (Step 0a–0r) + CLEAN ASan+UBSan build + all suites via the parallel harness + prod-binary guards. `--suites a,b` = iteration mode (incremental rebuild, subset, seconds). `--tsan` = the ThreadSanitizer leg. `--contracts-only` = the static contract steps alone (PR CI's contracts job for docs-only and non-product changes). CLANGARM64 gets CI's trap-UBSan flags automatically. | | **package wrappers** | `ci/test-package-wrappers.sh` | Go, npm, and PyPI runtime-set publication/lock suites on the current host. CI runs the same entry on Linux and Windows so both platform lock implementations gate. | | **build** | `build.sh` | CLEAN production runtime set (native executable + authenticated integration asset; `--with-ui` adds one content-addressed UI pack). ccache via `env.sh` makes repeats fast; `CCACHE_COMPILERCHECK=content` guarantees a hit is byte-identical to a cold compile — never stale. `--version`, `STATIC=1`, `BUILD_DIR=`. | | **lint** | `lint.sh` | clang-tidy + cppcheck + clang-format (+ no-skips policy). `--ci` = the CI gate set (no clang-tidy). Drives the same make targets as `make lint`/`lint-ci`. | diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh index 6d618bcb7..b21083082 100755 --- a/scripts/ci/select-lanes.sh +++ b/scripts/ci/select-lanes.sh @@ -251,14 +251,15 @@ FULL = {"codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos", "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", "unix-macos14", "unix-x86", "windows", "windows-guards"} -UNIVERSE = FULL | {"contracts"} # contracts: the docs-only job; every test leg runs them too +UNIVERSE = FULL | {"contracts"} # contracts: the T0a/T0b job; every test leg runs them too LINT = {"lint", "lint-mem"} CORE_LEGS = {"unix-x86", "unix-macos14", "windows", "shard-completeness"} TEST_LEGS = {"unix-x86", "unix-arm64", "unix-macos14", "unix-macos-intel", "windows"} ALWAYS = {"security-static"} R1 = { "T0a-docs": {"contracts"}, - "T0b-nonproduct": LINT, + # The contract steps police test-infrastructure/ and lint config too. + "T0b-nonproduct": LINT | {"contracts"}, "T1-tests": LINT | CORE_LEGS, "T2-extract": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"}, "T3-mid": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"}, diff --git a/scripts/test-impact/replay-selector.sh b/scripts/test-impact/replay-selector.sh index 5433c588d..f88237fdc 100755 --- a/scripts/test-impact/replay-selector.sh +++ b/scripts/test-impact/replay-selector.sh @@ -9,7 +9,9 @@ # its documented deviation, and a deviation may only ever raise the tier # (the fail-safe direction) -- never quietly select less than was measured; # 2. every real failure of the month (22, fixtures/2026-09-real-failures.tsv) -# had its catching lane selected for its push. +# had its catching lane selected for its push; +# 3. every push runs the static contract steps (the contracts lane or any +# test leg) -- they police docs, test-infrastructure/ and lint config. # Pure data, one selector process (--batch over the distinct file sets). # # Usage: scripts/test-impact/replay-selector.sh [repo-root] @@ -17,7 +19,7 @@ set -euo pipefail case "${1:-}" in -h | --help) - sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; -*) @@ -111,6 +113,16 @@ for run_id, pr, sha, fileset, analysis, expected, note in rows("2026-09-pushes.t else: deviated += 1 +# The static contract steps police docs, test-infrastructure/ and lint config, +# so every push runs them: in the contracts job or inside any test leg. +CONTRACT_CARRIERS = {"contracts", "unix-x86", "unix-arm64", "unix-macos14", + "unix-macos-intel", "windows"} +uncovered = collections.Counter(d["tier"] for d in pushes.values() + if not set(d["lanes"]) & CONTRACT_CARRIERS) +for tier, count in sorted(uncovered.items()): + failures.append(f"{count} {tier} push(es) run no contract step (neither the " + f"contracts lane nor a test leg)") + caught = 0 for run_id, attempt, pr, sha, lane, why in rows("2026-09-real-failures.tsv", 6): decision = pushes.get(run_id) @@ -129,7 +141,8 @@ if failures: sys.exit(1) print(f"selector history replay OK: {len(pushes)} pushes, tier as expected for all " f"({matched} equal to the analysis, {deviated} documented fail-safe deviations); " - f"{caught} real failures, every catching lane selected") + f"{caught} real failures, every catching lane selected; every push runs the " + f"contract steps") print(" pushes per tier: " + ", ".join(f"{t} {tiers[t]}" for t in TIERS)) print(f" full runs: {sum(1 for d in pushes.values() if d['full'])}") PY diff --git a/scripts/test.sh b/scripts/test.sh index bfc299b15..eae756138 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -32,7 +32,7 @@ Modes: --contracts-only Only the static contract steps (Step 0*) of the default leg: no compiler, no build, no suites. PR CI runs this for - docs-only changes, where no test leg is selected. + docs-only and non-product changes (tiers T0a, T0b). Options: --arch ARCH Force target arch (arm64 | x86_64), e.g. under Rosetta. diff --git a/tests/fixtures/select-lanes/cases.tsv b/tests/fixtures/select-lanes/cases.tsv index cfcad1257..15ce05df5 100644 --- a/tests/fixtures/select-lanes/cases.tsv +++ b/tests/fixtures/select-lanes/cases.tsv @@ -5,12 +5,12 @@ # "FULL -a -b" = every PR lane except a and b). docs-only README.md docs/BENCHMARK.md T0a-docs false contracts security-static issue-template-is-docs .github/ISSUE_TEMPLATE/bug.yml T0a-docs false contracts security-static -local-infra test-infrastructure/run.sh T0b-nonproduct false lint lint-mem security-static -lint-config scripts/memory-core-baseline.txt T0b-nonproduct false lint lint-mem security-static -graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false lint lint-mem security-static smoke-ubuntu windows-guards -memwaste-tools-add-memwaste scripts/memwaste.sh T0b-nonproduct false lint lint-mem memwaste security-static -non-gating-script-runs-contracts-leg scripts/benchmark-index.sh T0b-nonproduct false lint lint-mem security-static shard-completeness unix-x86 -test-impact-tooling scripts/test-impact/replay-selector.sh T0b-nonproduct false lint lint-mem security-static shard-completeness unix-x86 +local-infra test-infrastructure/run.sh T0b-nonproduct false contracts lint lint-mem security-static +lint-config scripts/memory-core-baseline.txt T0b-nonproduct false contracts lint lint-mem security-static +graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards +memwaste-tools-add-memwaste scripts/memwaste.sh T0b-nonproduct false contracts lint lint-mem memwaste security-static +non-gating-script-runs-contracts-leg scripts/benchmark-index.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86 +test-impact-tooling scripts/test-impact/replay-selector.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86 tests-only tests/test_store.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows test-fixture tests/fixtures/cpp_include/a.h T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows contract-test tests/test_version_metadata_contract.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index a176079a7..31a2b00b8 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -7,7 +7,7 @@ # (and "standard"): those runs execute every job, sharded, exactly as # before lane selection. Only pr.yml asks for the PR shard profile. # 2. pr.yml feeds the selector's lanes to every reusable workflow, runs the -# docs-only contracts job, and hands ci-ok both the selection and the +# T0a/T0b contracts job, and hands ci-ok both the selection and the # run's job list (actions: read) -- ci-ok needs `contracts` but never the # report-only memwaste / shadow jobs, and the shadow is continue-on-error. # 3. every lane of scripts/ci/select-lanes.sh --list-lanes is served by a From 3ecac479a6f12269a85f9018c381ea7e0ec1041c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 1 Oct 2026 22:56:52 +0200 Subject: [PATCH 12/50] test(harness): per-test selection and per-test coverage profiles The test-impact selector has to hand the C runner individual tests, and the map behind it has to know what each test executes. The runner could do neither: argv selects whole suites. Selection. CBM_TEST_ONLY (comma-separated) and CBM_TEST_ONLY_FILE (one per line; blank lines and # comments allowed) take suite:test tokens, and are unioned when both are set. A suite no token names never executes its body. Inside a named suite only the named tests run; the rest are counted as deselected and the summary reports the count. Argv still selects suites and the tokens narrow inside that selection, so the parallel harness hands every per-suite process the same list with no change of its own. Tokens are strict: one that names a suite this build does not register, or a test its suite never ran, is printed and fails the run. An unreadable file, a malformed token, and a selection that names no test stop the run before any suite. Without a selection the output is byte-identical to before, and --list-suites ignores the selection. Coverage. TEST_COVERAGE=1 compiles clang source coverage into our own code (the one-shot sources, the LSP unity object, the preprocessor; vendored objects stay uninstrumented) and the runner's hooks into the test runner. `make test-runner-cov` and `make cbm-cov` build the instrumented runner and product binary into build/cov. With CBM_TEST_COVERAGE_DIR set the runner writes /.parent.profraw for itself around each test, points LLVM_PROFILE_FILE at /.%p.profraw for the children the test spawns, and sends what runs between tests to _setup..profraw. No other build compiles any of it. tests/test_harness_test_only.sh pins the selection contract against the built runner and runs as Step 2b of scripts/test.sh, before the suites. Signed-off-by: Martin Vogel --- .gitignore | 3 + Makefile.cbm | 58 ++++- scripts/test.sh | 9 + tests/repro/repro_main.c | 17 ++ tests/test_framework.h | 65 ++++- tests/test_harness_test_only.sh | 249 ++++++++++++++++++++ tests/test_main.c | 404 ++++++++++++++++++++++++++++++++ 7 files changed, 784 insertions(+), 21 deletions(-) create mode 100644 tests/test_harness_test_only.sh diff --git a/.gitignore b/.gitignore index 3705501c2..1533c93e0 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ graph-ui/dist/ # Generated by scripts/embed-frontend.sh into the source tree (the linker needs # it beside the other UI sources). Removed by scripts/clean.sh; never committed. src/ui/embedded_assets.c +# Raw coverage profiles. A coverage-instrumented binary (make test-runner-cov, +# cbm-cov) run without LLVM_PROFILE_FILE drops default.profraw in its cwd. +*.profraw # Generated reports BENCHMARK_REPORT.md diff --git a/Makefile.cbm b/Makefile.cbm index ab5c89db7..b59c5ed00 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -71,13 +71,29 @@ ifeq ($(TEST_SEAMS),1) TEST_SEAM_DEFINE := -DCBM_ENABLE_TEST_SEAMS=1 endif +# Per-test coverage for the test-impact map. TEST_COVERAGE=1 compiles clang +# source coverage into OUR code only -- the one-shot sources, the LSP unity +# object and the preprocessor -- in the test runner and in the product binary, +# and turns on the runner's per-test profile hooks (tests/test_framework.h). +# Vendored objects stay uninstrumented: a vendored change selects tests by rule, +# and every counter compiled in is written out again for every test. Like every +# other flavour it needs its own BUILD_DIR; the test-runner-cov and cbm-cov +# targets pin one. +TEST_COVERAGE ?= 0 +TEST_COVERAGE_FLAGS := +TEST_COVERAGE_DEFINE := +ifeq ($(TEST_COVERAGE),1) +TEST_COVERAGE_FLAGS := -fprofile-instr-generate -fcoverage-mapping +TEST_COVERAGE_DEFINE := -DCBM_TEST_COVERAGE=1 +endif + # Production flags (CFLAGS_EXTRA allows CI to inject -DCBM_VERSION) # CBM_BIND_TS_ALLOCATOR=1: bind the tree-sitter runtime to mimalloc (#424). Only # the prod build uses mimalloc (MI_OVERRIDE=1); the test build is CRT+ASan, where # binding would create an alloc/free mismatch, so the guard is prod-only. CFLAGS_PROD = $(CFLAGS_COMMON) -O2 -DCBM_BIND_TS_ALLOCATOR=1 $(TEST_SEAM_DEFINE) \ - $(GLOBAL_OVERRIDE_DEFINE) $(MEMWASTE_CFLAGS) $(CFLAGS_EXTRA) -CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 $(MEMWASTE_VENDOR_CFLAGS) + $(GLOBAL_OVERRIDE_DEFINE) $(MEMWASTE_CFLAGS) $(TEST_COVERAGE_FLAGS) $(CFLAGS_EXTRA) +CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 $(MEMWASTE_VENDOR_CFLAGS) $(TEST_COVERAGE_FLAGS) # Test flags: debug + sanitizers (override SANITIZE= to disable on Windows) SANITIZE = -fsanitize=address,undefined -fno-omit-frame-pointer @@ -102,8 +118,10 @@ INCREMENTAL_TEST_DEFINE = -DCBM_INCREMENTAL_TEST_API=1 COVERAGE_MARKER_TEST_DEFINE = -DCBM_COVERAGE_MARKER_TEST_API=1 CFLAGS_TEST = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(SANITIZED_DEFINE) \ $(KOTLIN_DEDUP_TEST_DEFINE) $(CALL_REFERENCE_LOOKUP_TEST_DEFINE) \ - $(INCREMENTAL_TEST_DEFINE) $(COVERAGE_MARKER_TEST_DEFINE) -g -O1 $(SANITIZE) -CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) $(CXX_STDLIB_FLAGS) + $(INCREMENTAL_TEST_DEFINE) $(COVERAGE_MARKER_TEST_DEFINE) $(TEST_COVERAGE_DEFINE) \ + -g -O1 $(SANITIZE) $(TEST_COVERAGE_FLAGS) +CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) $(TEST_COVERAGE_FLAGS) \ + $(CXX_STDLIB_FLAGS) # TSan (can't combine with ASan) TSAN_SANITIZE = -fsanitize=thread -fno-omit-frame-pointer @@ -851,7 +869,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o # ── Targets ────────────────────────────────────────────────────── -.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security fuzz +.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke test-runner-cov cbm-cov cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security fuzz $(BUILD_DIR): mkdir -p $(BUILD_DIR) @@ -890,7 +908,7 @@ $(BUILD_DIR)/ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $< $(BUILD_DIR)/lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR) - $(CC) $(GRAMMAR_CFLAGS_TEST) $(SANITIZE) -c -o $@ $< + $(CC) $(GRAMMAR_CFLAGS_TEST) $(SANITIZE) $(TEST_COVERAGE_FLAGS) -c -o $@ $< $(BUILD_DIR)/preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR) $(CXX) $(CXXFLAGS_TEST) -w -I$(CBM_DIR)/vendored -c -o $@ $< @@ -1132,7 +1150,7 @@ $(BUILD_DIR)/prod_ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS) -c -o $@ $< $(BUILD_DIR)/prod_lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR) - $(CC) $(GRAMMAR_CFLAGS) -c -o $@ $< + $(CC) $(GRAMMAR_CFLAGS) $(TEST_COVERAGE_FLAGS) -c -o $@ $< $(BUILD_DIR)/prod_preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR) $(CXX) $(CXXFLAGS_PROD) -w -I$(CBM_DIR)/vendored -c -o $@ $< @@ -1169,7 +1187,7 @@ MAIN_SRC = src/main.c # for trap debugging -- silently re-run the previously instrumented binary, # which during this bisect produced a "crashes without sanitizers too" result # that was pure artifact and sent the investigation down the wrong path. -BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)|MEMWASTE=$(MEMWASTE)|MEMWASTE_ACCESS=$(MEMWASTE_ACCESS)|MEMWASTE_PROFILE=$(MEMWASTE_PROFILE) +BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)|MEMWASTE=$(MEMWASTE)|MEMWASTE_ACCESS=$(MEMWASTE_ACCESS)|MEMWASTE_PROFILE=$(MEMWASTE_PROFILE)|TEST_COVERAGE=$(TEST_COVERAGE) .PHONY: build-config-check build-config-check: $(BUILD_DIR)/.build-config: build-config-check | $(BUILD_DIR) @@ -1365,6 +1383,30 @@ test-lsan: cd $(CURDIR) && ASAN_OPTIONS="detect_leaks=1:halt_on_error=1" \ ./build/lsan/test-runner $(LSAN_SUITES) +# ── Per-test coverage build (test-impact map) ─────────────────── +# The instrumented runner and product binary (TEST_COVERAGE=1 above), in their +# own build directory. Homebrew LLVM rather than Apple clang: the raw profile +# format is versioned with the compiler, so llvm-profdata and llvm-cov have to +# come from the toolchain that built the binary. +# LLVM_PROFILE_FILE=/dev/null CBM_TEST_COVERAGE_DIR= \ +# build/cov/test-runner [suite...] +# leaves one profile set per test (tests/test_main.c). The runner names every +# profile itself, but the profile runtime creates its default file before main +# runs: started without LLVM_PROFILE_FILE it leaves an empty default.profraw in +# the working directory. +# No C suite runs the product binary -- they re-exec the runner itself. The +# shell process regressions do, and they find the instrumented one the way they +# find any other: CBM_TEST_BINARY=build/cov/codebase-memory-mcp, with +# LLVM_PROFILE_FILE set by whoever collects the profiles. +COV_LLVM_BIN ?= /opt/homebrew/opt/llvm/bin +COV_BUILD_DIR ?= build/cov +COV_MAKE = $(MAKE) -f Makefile.cbm CC=$(COV_LLVM_BIN)/clang CXX=$(COV_LLVM_BIN)/clang++ \ + BUILD_DIR=$(COV_BUILD_DIR) SANITIZE= TEST_COVERAGE=1 +test-runner-cov: + $(COV_MAKE) $(COV_BUILD_DIR)/test-runner +cbm-cov: + $(COV_MAKE) $(COV_BUILD_DIR)/codebase-memory-mcp TEST_SEAMS=1 + # ── Coverage-guided fuzzing (libFuzzer) ───────────────────────── # One binary, three targets (tests/fuzz/cbm_fuzz.c): the per-file extractor, # the Cypher parser, the TOML/YAML/JSON config editors. ASan+UBSan as in the diff --git a/scripts/test.sh b/scripts/test.sh index eae756138..81483c115 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -381,6 +381,15 @@ BUILD_DIR="$BUILD_DIR" scripts/clean.sh # CBM_TEST_SEQUENTIAL=1 restores the single-process runner. make -j"$NPROC" -f Makefile.cbm "$BUILD_DIR/test-runner" ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"} assert_test_runner_build_config "$BUILD_DIR/test-runner" "$EXPECTED_SANITIZED" + +# Step 2b: per-test selection process regression. CBM_TEST_ONLY must run exactly +# the tests it names and fail on a token that matches nothing; a suite cannot +# assert that about the runner executing it. Runs against the runner just built +# and BEFORE the suites: it takes seconds, and a selection that silently drops +# tests is a property of the harness every later result depends on. +echo "=== Step 2b: per-test selection regression (CBM_TEST_ONLY) ===" +CBM_TEST_RUNNER="$ROOT/$BUILD_DIR/test-runner" bash "$ROOT/tests/test_harness_test_only.sh" + if [ "${CBM_TEST_SEQUENTIAL:-0}" = "1" ]; then make -f Makefile.cbm test ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"} else diff --git a/tests/repro/repro_main.c b/tests/repro/repro_main.c index 7fc1cc20b..d164fce90 100644 --- a/tests/repro/repro_main.c +++ b/tests/repro/repro_main.c @@ -24,12 +24,29 @@ int tf_pass_count = 0; int tf_fail_count = 0; int tf_skip_count = 0; +int tf_deselected_count = 0; #include "test_framework.h" #include "repro_runner.h" #include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ #include "daemon/bootstrap.h" /* refuse the developer's default daemon rendezvous */ +/* Per-test selection (CBM_TEST_ONLY) and per-test coverage belong to the gating + * runner, tests/test_main.c. This board selects whole suites with + * CBM_REPRO_ONLY, and every test of a selected suite runs. */ +bool tf_test_selected(const char *test) { + (void)test; + return true; +} +#ifdef CBM_TEST_COVERAGE +void tf_coverage_test_begin(const char *test) { + (void)test; +} +void tf_coverage_test_end(const char *test) { + (void)test; +} +#endif + /* Per-suite summary + filter. RUN_SUITE prints a one-line * "[SUITE] P passed, F failed" report (greppable for which suites still * have reds). The shared selector implementation lives with its gating tests in diff --git a/tests/test_framework.h b/tests/test_framework.h index 2af654b4d..ac9539a6d 100644 --- a/tests/test_framework.h +++ b/tests/test_framework.h @@ -6,6 +6,7 @@ * - Test suite grouping * - Pass/fail counting with summary * - Color output (when isatty) + * - Per-test selection (CBM_TEST_ONLY) and per-test coverage profiles * * Usage: * TEST(my_test) { @@ -26,6 +27,7 @@ #ifndef TEST_FRAMEWORK_H #define TEST_FRAMEWORK_H +#include #include #include #include @@ -37,6 +39,32 @@ extern int tf_pass_count; extern int tf_fail_count; extern int tf_skip_count; +extern int tf_deselected_count; + +/* ── Per-test selection (defined by the runner) ────────────────── */ + +/* CBM_TEST_ONLY / CBM_TEST_ONLY_FILE narrow a run to `suite:test` tokens (see + * test_main.c). tf_suite_enter decides whether a suite's body executes at all; + * inside it tf_test_selected decides each RUN_TEST and counts the tests it + * leaves out. Without a selection both are always true. */ +extern const char *tf_current_suite; +bool tf_suite_enter(const char *suite); +void tf_suite_leave(const char *suite); +bool tf_test_selected(const char *test); + +/* ── Per-test coverage (CBM_TEST_COVERAGE builds only) ─────────── */ + +/* A coverage-instrumented runner (make test-runner-cov) writes one profile per + * test under CBM_TEST_COVERAGE_DIR. No other build compiles any of it. */ +#ifdef CBM_TEST_COVERAGE +void tf_coverage_test_begin(const char *test); +void tf_coverage_test_end(const char *test); +#define TF_COVERAGE_TEST_BEGIN(test) tf_coverage_test_begin(test) +#define TF_COVERAGE_TEST_END(test) tf_coverage_test_end(test) +#else +#define TF_COVERAGE_TEST_BEGIN(test) ((void)0) +#define TF_COVERAGE_TEST_END(test) ((void)0) +#endif /* ── Color helpers ─────────────────────────────────────────────── */ @@ -69,10 +97,10 @@ static inline const char *tf_reset(void) { /* Hard failure with a message — for setup/environment failures that must NOT be * silently skipped (a test that cannot establish its preconditions has FAILED, * not "skipped"). Mirrors the ASSERT failure path (red FAIL + file:line). */ -#define FAIL(reason) \ - do { \ +#define FAIL(reason) \ + do { \ printf(" %sFAIL%s %s:%d: %s\n", tf_red(), tf_reset(), __FILE__, __LINE__, (reason)); \ - return 1; \ + return 1; \ } while (0) /* The ONLY tolerable skip: a test that is inherently platform-specific and does @@ -224,9 +252,14 @@ static inline const char *tf_reset(void) { #define RUN_TEST(name) \ do { \ + if (!tf_test_selected(#name)) { \ + break; \ + } \ printf(" %-55s", #name); \ fflush(stdout); \ + TF_COVERAGE_TEST_BEGIN(#name); \ int _result = test_##name(); \ + TF_COVERAGE_TEST_END(#name); \ if (_result == 0) { \ printf("%sPASS%s\n", tf_green(), tf_reset()); \ tf_pass_count++; \ @@ -243,22 +276,28 @@ static inline const char *tf_reset(void) { #define RUN_SUITE(name) \ do { \ + if (!tf_suite_enter(#name)) { \ + break; \ + } \ printf("\n%s=== %s ===%s\n", tf_dim(), #name, tf_reset()); \ suite_##name(); \ + tf_suite_leave(#name); \ } while (0) /* ── Summary ───────────────────────────────────────────────────── */ -#define TEST_SUMMARY() \ - do { \ - printf("\n────────────────────────────────────────────\n"); \ - printf(" %s%d passed%s", tf_green(), tf_pass_count, tf_reset()); \ - if (tf_fail_count > 0) \ - printf(", %s%d failed%s", tf_red(), tf_fail_count, tf_reset()); \ - if (tf_skip_count > 0) \ - printf(", %s%d skipped%s", tf_dim(), tf_skip_count, tf_reset()); \ - printf("\n────────────────────────────────────────────\n\n"); \ - return tf_fail_count > 0 ? 1 : 0; \ +#define TEST_SUMMARY() \ + do { \ + printf("\n────────────────────────────────────────────\n"); \ + printf(" %s%d passed%s", tf_green(), tf_pass_count, tf_reset()); \ + if (tf_fail_count > 0) \ + printf(", %s%d failed%s", tf_red(), tf_fail_count, tf_reset()); \ + if (tf_skip_count > 0) \ + printf(", %s%d skipped%s", tf_dim(), tf_skip_count, tf_reset()); \ + if (tf_deselected_count > 0) \ + printf(", %s%d deselected%s", tf_dim(), tf_deselected_count, tf_reset()); \ + printf("\n────────────────────────────────────────────\n\n"); \ + return tf_fail_count > 0 ? 1 : 0; \ } while (0) #endif /* TEST_FRAMEWORK_H */ diff --git a/tests/test_harness_test_only.sh b/tests/test_harness_test_only.sh new file mode 100644 index 000000000..04318308b --- /dev/null +++ b/tests/test_harness_test_only.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Per-test selection in the C test runner: CBM_TEST_ONLY / CBM_TEST_ONLY_FILE. +# +# The test-impact selector hands the runner `suite:test` tokens. Two properties +# make that safe to gate on, and both are process-level behaviour a suite cannot +# assert about the runner that is executing it: +# 1. exactly the named tests run: the other tests of a named suite are counted +# as deselected, and a suite no token names never executes its body; +# 2. a token that matches nothing is an ERROR. A selection that quietly ran +# fewer tests than it named would report green for work that never ran. +# +# Every case drives the built runner over two small in-memory suites, so the +# whole script is a dozen runner starts. Expected counts are read from the suite +# sources, never hard-coded, so adding a test to either suite cannot stale them. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# scripts/test.sh builds into $BUILD_DIR, which is not build/c on every leg; it +# passes the runner it built. The default keeps a bare manual invocation working. +RUNNER="${CBM_TEST_RUNNER:-${ROOT}/build/c/test-runner}" +if [[ ! -x "${RUNNER}" && -x "${RUNNER}.exe" ]]; then + RUNNER="${RUNNER}.exe" +fi +if [[ ! -x "${RUNNER}" ]]; then + echo "missing test runner: ${RUNNER}" >&2 + exit 2 +fi + +# A selection inherited from the caller would decide every case below. +unset CBM_TEST_ONLY CBM_TEST_ONLY_FILE + +NAMED_SUITE=dyn_array +OTHER_SUITE=str_intern +UNKNOWN_MESSAGE="selected test not compiled into this build or unknown:" + +run_test_count() { + awk '/^[[:space:]]*RUN_TEST\(/ { n++ } END { print n + 0 }' "$1" +} +first_run_test() { + awk -F'[()]' '/^[[:space:]]*RUN_TEST\(/ { print $2; exit }' "$1" +} +NAMED_TOTAL="$(run_test_count "${ROOT}/tests/test_${NAMED_SUITE}.c")" +OTHER_TOTAL="$(run_test_count "${ROOT}/tests/test_${OTHER_SUITE}.c")" +OTHER_TEST="$(first_run_test "${ROOT}/tests/test_${OTHER_SUITE}.c")" +if [[ "${NAMED_TOTAL}" -lt 3 || "${OTHER_TOTAL}" -lt 1 || -z "${OTHER_TEST}" ]]; then + echo "fixture suites changed shape: ${NAMED_SUITE}=${NAMED_TOTAL} ${OTHER_SUITE}=${OTHER_TOTAL}" >&2 + exit 2 +fi + +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/cbm-test-only.XXXXXX")" +trap 'rm -rf "${tmpdir}"' EXIT + +FAILURES=0 +RC=0 +OUT="" + +# run [suite...] — runs the runner under the caller's VAR=VAL prefix and +# records its merged output and exit status. CR is stripped: the Windows CRT +# ends every stdout line with CRLF. +run() { + local name="$1" + shift + OUT="${tmpdir}/${name}.out" + set +e + "${RUNNER}" "$@" > "${OUT}.raw" 2>&1 + RC=$? + set -e + tr -d '\r' < "${OUT}.raw" > "${OUT}" +} + +# The summary line, e.g. " 1 passed, 25 deselected". +summary_count() { + awk -v label="$1" ' + /^ [0-9]+ passed/ { + n = 0 + count = split($0, part, ", ") + for (i = 1; i <= count; i++) { + gsub(/^ +/, "", part[i]) + if (split(part[i], field, " ") == 2 && field[2] == label) n = field[1] + } + found = 1 + } + END { print found ? n : "none" } + ' "${OUT}" +} +pass_lines() { + awk '/PASS$/ { n++ } END { print n + 0 }' "${OUT}" +} +has_line() { + grep -F -- "$1" "${OUT}" > /dev/null +} + +CASE="" +CASE_OK=1 +begin() { + CASE="$1" + CASE_OK=1 +} +expect() { + local what="$1" actual="$2" wanted="$3" + if [[ "${actual}" != "${wanted}" ]]; then + echo " FAIL ${CASE}: ${what} = ${actual}, expected ${wanted}" + CASE_OK=0 + fi +} +expect_nonzero_exit() { + if [[ "${RC}" -eq 0 ]]; then + echo " FAIL ${CASE}: runner exited 0, expected a nonzero exit" + CASE_OK=0 + fi +} +expect_line() { + if ! has_line "$1"; then + echo " FAIL ${CASE}: output lacks: $1" + CASE_OK=0 + fi +} +expect_no_line() { + if has_line "$1"; then + echo " FAIL ${CASE}: output unexpectedly has: $1" + CASE_OK=0 + fi +} +finish() { + if [[ "${CASE_OK}" -eq 1 ]]; then + echo " ok ${CASE}" + else + FAILURES=$((FAILURES + 1)) + echo " ---- ${CASE}: runner exit ${RC}, output tail ----" + tail -6 "${OUT}" | sed 's/^/ | /' + fi +} + +echo "per-test selection: ${RUNNER}" + +# ── No filter: every test of the requested suites runs, nothing is deselected ── +begin "no filter runs every test" +run unfiltered "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" "$((NAMED_TOTAL + OTHER_TOTAL))" +expect "PASS lines" "$(pass_lines)" "$((NAMED_TOTAL + OTHER_TOTAL))" +expect_no_line "deselected" +finish + +# An empty variable is the shell's spelling of "unset": same bytes as no filter. +begin "empty variables are no filter" +CBM_TEST_ONLY="" CBM_TEST_ONLY_FILE="" run empty "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +if ! cmp -s "${tmpdir}/unfiltered.out" "${OUT}"; then + echo " FAIL ${CASE}: output differs from the unfiltered run" + CASE_OK=0 +fi +finish + +# ── CBM_TEST_ONLY: exactly the named test; the rest of its suite is deselected, +# and a requested suite that no token names does not execute at all ── +begin "CBM_TEST_ONLY runs exactly the named test" +CBM_TEST_ONLY="${NAMED_SUITE}:da_last" run only "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" 1 +expect "deselected" "$(summary_count deselected)" "$((NAMED_TOTAL - 1))" +expect "PASS lines" "$(pass_lines)" 1 +expect_line " da_last " +expect_no_line "=== ${OTHER_SUITE} ===" +finish + +# ── Strict: a token that matches nothing fails the run and is named ── +begin "unknown test token fails and is named" +CBM_TEST_ONLY="${NAMED_SUITE}:da_last,${NAMED_SUITE}:no_such_test" run unknown-test "${NAMED_SUITE}" +expect_nonzero_exit +expect_line "${UNKNOWN_MESSAGE} ${NAMED_SUITE}:no_such_test" +expect "passed" "$(summary_count passed)" 1 +finish + +begin "unknown suite token fails and is named" +CBM_TEST_ONLY="no_such_suite:da_last" run unknown-suite "${NAMED_SUITE}" +expect_nonzero_exit +expect_line "${UNKNOWN_MESSAGE} no_such_suite:da_last" +expect "PASS lines" "$(pass_lines)" 0 +finish + +# ── Argv keeps selecting suites; the filter narrows inside that selection. A +# token for a suite this process was not asked to run is out of its scope, not +# an error: the parallel harness gives every per-suite process the whole list ── +begin "token outside the argv selection is not an error" +CBM_TEST_ONLY="${NAMED_SUITE}:da_last,${OTHER_SUITE}:${OTHER_TEST}" run argv-scope "${NAMED_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" 1 +expect_no_line "=== ${OTHER_SUITE} ===" +finish + +# ── CBM_TEST_ONLY_FILE: one token per line; blank lines, comments, CRLF ── +selection="${tmpdir}/selection.txt" +printf '%s\n' "# written by the test-impact selector" "" \ + "${NAMED_SUITE}:da_last" " ${NAMED_SUITE}:da_clear # trailing comment" > "${selection}" +printf '%s\r\n' "${NAMED_SUITE}:da_free" >> "${selection}" + +begin "CBM_TEST_ONLY_FILE runs exactly the listed tests" +CBM_TEST_ONLY_FILE="${selection}" run file "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" 3 +expect "deselected" "$(summary_count deselected)" "$((NAMED_TOTAL - 3))" +expect "PASS lines" "$(pass_lines)" 3 +expect_no_line "=== ${OTHER_SUITE} ===" +finish + +begin "CBM_TEST_ONLY and CBM_TEST_ONLY_FILE select their union" +CBM_TEST_ONLY="${OTHER_SUITE}:${OTHER_TEST}" CBM_TEST_ONLY_FILE="${selection}" \ + run union "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" 4 +expect "deselected" "$(summary_count deselected)" "$((NAMED_TOTAL + OTHER_TOTAL - 4))" +finish + +printf '%s\n' "${NAMED_SUITE}:da_last" "${NAMED_SUITE}:no_such_test" > "${tmpdir}/unknown.txt" +begin "unknown test token in the file fails and is named" +CBM_TEST_ONLY_FILE="${tmpdir}/unknown.txt" run file-unknown "${NAMED_SUITE}" +expect_nonzero_exit +expect_line "${UNKNOWN_MESSAGE} ${NAMED_SUITE}:no_such_test" +finish + +# ── Fail closed: a selection that cannot be read, names nothing, or is not a +# token must never fall back to running everything or nothing in silence ── +begin "unreadable selection file fails before any test" +CBM_TEST_ONLY_FILE="${tmpdir}/does-not-exist.txt" run file-missing "${NAMED_SUITE}" +expect_nonzero_exit +expect_line "does-not-exist.txt" +expect "PASS lines" "$(pass_lines)" 0 +finish + +printf '%s\n' "# nothing selected" "" > "${tmpdir}/empty.txt" +begin "selection file naming no test fails before any test" +CBM_TEST_ONLY_FILE="${tmpdir}/empty.txt" run file-empty "${NAMED_SUITE}" +expect_nonzero_exit +expect "PASS lines" "$(pass_lines)" 0 +finish + +begin "malformed token fails before any test" +CBM_TEST_ONLY="${NAMED_SUITE}" run malformed "${NAMED_SUITE}" +expect_nonzero_exit +expect_line "malformed test selection token: ${NAMED_SUITE}" +expect "PASS lines" "$(pass_lines)" 0 +finish + +if [[ "${FAILURES}" -gt 0 ]]; then + echo "FAIL: ${FAILURES} per-test selection case(s) failed" + exit 1 +fi +echo "PASS: per-test selection is exact, strict, and inert without a filter" diff --git a/tests/test_main.c b/tests/test_main.c index 5c3dcf067..0ad96434a 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -7,6 +7,7 @@ int tf_pass_count = 0; int tf_fail_count = 0; int tf_skip_count = 0; +int tf_deselected_count = 0; #include "test_framework.h" #include "test_helpers.h" @@ -27,6 +28,7 @@ int tf_skip_count = 0; #include "ui/http_server.h" /* deleted-self executable probe */ #include "result_spill.h" /* pinned free disk: spill verdicts ignore the host disk */ #include +#include #include #include #include @@ -821,11 +823,401 @@ static int tf_maybe_run_deleted_self_probe(int argc, char **argv) { #endif } +/* ── Per-test selection: CBM_TEST_ONLY / CBM_TEST_ONLY_FILE ────────── + * The test-impact selector names tests as `suite:test` tokens: comma-separated + * in CBM_TEST_ONLY, one per line (blank lines and # comments allowed) in the + * file CBM_TEST_ONLY_FILE names. Both may be set; the selection is their union. + * Argv keeps selecting suites and the tokens narrow inside that selection, so + * the parallel harness can hand every per-suite process the same list. + * + * Tokens are strict. After the run, one that names a suite this build does not + * register, or a test its suite never ran, fails the run by name: a selection + * that quietly ran less than it named would be a green for work never done. A + * token for a registered suite that argv left out belongs to another process + * and is not this one to judge. */ +typedef enum { + TF_ONLY_UNSEEN = 0, /* no registered suite carries the suite name */ + TF_ONLY_REGISTERED, /* the suite exists; argv did not ask this process for it */ + TF_ONLY_ENTERED, /* the suite ran and no RUN_TEST carried the test name */ + TF_ONLY_MATCHED, +} tf_only_state_t; + +typedef struct { + char *suite; /* one allocation holding "suite\0test" */ + const char *test; + tf_only_state_t state; +} tf_only_token_t; + +static tf_only_token_t *tf_only_tokens = NULL; +static size_t tf_only_count = 0; +static size_t tf_only_cap = 0; +static bool tf_only_active = false; +/* Tokens are sorted by suite, so the running suite has one run [first, end). */ +static size_t tf_only_suite_first = 0; +static size_t tf_only_suite_end = 0; + +const char *tf_current_suite = NULL; + +static bool tf_only_name_valid(const char *name, size_t length) { + for (size_t i = 0; i < length; i++) { + if (!isalnum((unsigned char)name[i]) && name[i] != '_') { + return false; + } + } + return length > 0; +} + +/* Adds the token in text[0..length). Surrounding whitespace is not part of it + * (the CR of a CRLF file included), and an empty span is not a token. */ +static bool tf_only_add(const char *text, size_t length) { + while (length > 0 && isspace((unsigned char)text[0])) { + text++; + length--; + } + while (length > 0 && isspace((unsigned char)text[length - 1])) { + length--; + } + if (length == 0) { + return true; + } + /* No test or suite name comes near this; it also bounds the copy below. */ + const char *colon = length < CBM_SZ_512 ? memchr(text, ':', length) : NULL; + size_t suite_length = colon ? (size_t)(colon - text) : 0; + if (!colon || !tf_only_name_valid(text, suite_length) || + !tf_only_name_valid(colon + 1, length - suite_length - 1)) { + fprintf(stderr, "malformed test selection token: %.*s (expected suite:test)\n", (int)length, + text); + return false; + } + if (tf_only_count == tf_only_cap) { + size_t cap = tf_only_cap ? tf_only_cap * 2 : CBM_SZ_64; + tf_only_token_t *grown = realloc(tf_only_tokens, cap * sizeof(*grown)); + if (!grown) { + fprintf(stderr, "Failed to allocate the test selection\n"); + return false; + } + tf_only_tokens = grown; + tf_only_cap = cap; + } + char *copy = malloc(length + 1); + if (!copy) { + fprintf(stderr, "Failed to allocate the test selection\n"); + return false; + } + memcpy(copy, text, length); + copy[suite_length] = '\0'; + copy[length] = '\0'; + tf_only_tokens[tf_only_count++] = + (tf_only_token_t){.suite = copy, .test = copy + suite_length + 1, .state = TF_ONLY_UNSEEN}; + return true; +} + +static bool tf_only_add_list(const char *list) { + for (const char *cursor = list;;) { + const char *comma = strchr(cursor, ','); + if (!tf_only_add(cursor, comma ? (size_t)(comma - cursor) : strlen(cursor))) { + return false; + } + if (!comma) { + return true; + } + cursor = comma + 1; + } +} + +static bool tf_only_add_file(const char *path) { + FILE *file = cbm_fopen(path, "r"); + if (!file) { + fprintf(stderr, "cannot read test selection file: %s\n", path); + return false; + } + char line[CBM_SZ_1K]; + bool ok = true; + while (ok && fgets(line, sizeof(line), file)) { + size_t length = strlen(line); + if (length == sizeof(line) - 1 && line[length - 1] != '\n') { + /* The rest of the line would be read back as a token of its own. */ + fprintf(stderr, "test selection file line too long: %s\n", path); + ok = false; + } else { + ok = tf_only_add(line, strcspn(line, "#\n")); + } + } + if (ok && ferror(file)) { + fprintf(stderr, "cannot read test selection file: %s\n", path); + ok = false; + } + (void)fclose(file); + return ok; +} + +static int tf_only_compare(const void *left, const void *right) { + const tf_only_token_t *a = left; + const tf_only_token_t *b = right; + int by_suite = strcmp(a->suite, b->suite); + return by_suite != 0 ? by_suite : strcmp(a->test, b->test); +} + +static void tf_only_free(void) { + for (size_t i = 0; i < tf_only_count; i++) { + free(tf_only_tokens[i].suite); + } + free(tf_only_tokens); + tf_only_tokens = NULL; + tf_only_count = 0; + tf_only_cap = 0; + tf_only_active = false; +} + +/* Reads the selection once. False means the selection itself is unusable: the + * caller stops before any suite, because falling back to "everything" or to + * "nothing" would decide a gate by accident. An empty variable is unset, as it + * is in a shell; a selection that is set and names no test is an error, since + * a caller who wants every test leaves both variables unset. */ +static bool tf_only_init(void) { + const char *list = getenv("CBM_TEST_ONLY"); + const char *path = getenv("CBM_TEST_ONLY_FILE"); + bool has_list = list && list[0]; + bool has_path = path && path[0]; + if (!has_list && !has_path) { + return true; + } + if ((has_list && !tf_only_add_list(list)) || (has_path && !tf_only_add_file(path))) { + return false; + } + if (tf_only_count == 0) { + fprintf(stderr, "test selection names no test (CBM_TEST_ONLY, CBM_TEST_ONLY_FILE)\n"); + return false; + } + qsort(tf_only_tokens, tf_only_count, sizeof(*tf_only_tokens), tf_only_compare); + tf_only_active = true; + return true; +} + +/* Returns the first token of `suite` and stores one past its last. */ +static size_t tf_only_suite_run(const char *suite, size_t *end_out) { + size_t first = 0; + while (first < tf_only_count && strcmp(tf_only_tokens[first].suite, suite) != 0) { + first++; + } + size_t end = first; + while (end < tf_only_count && strcmp(tf_only_tokens[end].suite, suite) == 0) { + end++; + } + *end_out = end; + return first; +} + +static void tf_only_raise(size_t first, size_t end, tf_only_state_t state) { + for (size_t i = first; i < end; i++) { + if (tf_only_tokens[i].state < state) { + tf_only_tokens[i].state = state; + } + } +} + +bool tf_suite_enter(const char *suite) { + if (tf_current_suite) { + /* A suite run from inside another: its tests belong to the registered + * suite that runs it, which is the name a token and argv both use. */ + return true; + } + if (tf_only_active) { + tf_only_suite_first = tf_only_suite_run(suite, &tf_only_suite_end); + if (tf_only_suite_first == tf_only_suite_end) { + return false; /* no token names it: its body, setup included, never runs */ + } + tf_only_raise(tf_only_suite_first, tf_only_suite_end, TF_ONLY_ENTERED); + } + tf_current_suite = suite; + return true; +} + +void tf_suite_leave(const char *suite) { + if (tf_current_suite && strcmp(tf_current_suite, suite) == 0) { + tf_current_suite = NULL; + tf_only_suite_first = 0; + tf_only_suite_end = 0; + } +} + +bool tf_test_selected(const char *test) { + if (!tf_only_active) { + return true; + } + bool selected = false; + for (size_t i = tf_only_suite_first; i < tf_only_suite_end; i++) { + if (strcmp(tf_only_tokens[i].test, test) == 0) { + tf_only_tokens[i].state = TF_ONLY_MATCHED; + selected = true; + } + } + if (!selected) { + tf_deselected_count++; + } + return selected; +} + +/* The strict half of the selection: every token this process was responsible + * for must have run a test. Each one that did not is a failure of the run. */ +static void tf_only_report_unmatched(void) { + for (size_t i = 0; i < tf_only_count; i++) { + const tf_only_token_t *token = &tf_only_tokens[i]; + if (token->state != TF_ONLY_UNSEEN && token->state != TF_ONLY_ENTERED) { + continue; + } + if (i > 0 && tf_only_compare(token, &tf_only_tokens[i - 1]) == 0) { + continue; /* named twice, reported once */ + } + fprintf(stderr, "selected test not compiled into this build or unknown: %s:%s\n", + token->suite, token->test); + tf_fail_count++; + } +} + +#ifdef CBM_TEST_COVERAGE +/* ── Per-test coverage profiles ────────────────────────────────────── + * Compiled only into the instrumented runner (make test-runner-cov: clang + * source coverage, no sanitizer). With CBM_TEST_COVERAGE_DIR set, every test + * leaves its own raw profiles under //: + * .parent.profraw what this process executed inside the test + * ..profraw one per child the test spawned. Children + * inherit LLVM_PROFILE_FILE, so a re-exec of this + * runner or a product binary names its own file + * _setup..profraw what ran between tests: suite setup, and the + * prologue and epilogue of the runner itself + * _setup.child..profraw children spawned between tests + * The first two are the coverage of the test; the others belong to no test. */ +void __llvm_profile_set_filename(const char *pattern); +int __llvm_profile_write_file(void); +void __llvm_profile_reset_counters(void); + +enum { TF_COVERAGE_PATH_CAP = CBM_SZ_4K + CBM_SZ_1K }; +static char tf_coverage_dir[CBM_SZ_4K]; /* canonical; empty = not a coverage run */ +/* The runtime keeps the pointer it is handed, not a copy: these outlive it. */ +static char tf_coverage_profile[TF_COVERAGE_PATH_CAP]; +static char tf_coverage_child_pattern[TF_COVERAGE_PATH_CAP]; +static unsigned int tf_coverage_setup_index = 0; + +/* Names the profile this process writes next and the one its children will. */ +static bool tf_coverage_route(const char *directory, const char *own, const char *child) { + int own_length = + snprintf(tf_coverage_profile, sizeof(tf_coverage_profile), "%s/%s.profraw", directory, own); + int child_length = snprintf(tf_coverage_child_pattern, sizeof(tf_coverage_child_pattern), + "%s/%s.%%p.profraw", directory, child); + if (own_length <= 0 || (size_t)own_length >= sizeof(tf_coverage_profile) || child_length <= 0 || + (size_t)child_length >= sizeof(tf_coverage_child_pattern)) { + return false; + } + __llvm_profile_set_filename(tf_coverage_profile); + return cbm_setenv("LLVM_PROFILE_FILE", tf_coverage_child_pattern, 1) == 0; +} + +/* Between tests the interval is suite setup, and so is any child it spawns. */ +static bool tf_coverage_route_setup(const char *suite_dir) { + char own[CBM_SZ_64]; + (void)snprintf(own, sizeof(own), "_setup.%u", tf_coverage_setup_index); + return tf_coverage_route(suite_dir, own, "_setup.child"); +} + +/* Writes what this process executed since the last write to its current profile + * and restarts the counters, so every file holds exactly one interval. */ +static void tf_coverage_flush(void) { + (void)__llvm_profile_write_file(); + __llvm_profile_reset_counters(); +} + +static bool tf_coverage_suite_dir(char *out, size_t cap) { + int length = snprintf(out, cap, "%s/%s", tf_coverage_dir, + tf_current_suite ? tf_current_suite : "_nosuite"); + return length > 0 && (size_t)length < cap && cbm_mkdir_p(out, 0755); +} + +/* A coverage run that loses a profile would report the test as executing + * nothing, and the impact map would trust that: fail the run instead. */ +static void tf_coverage_lost(const char *test) { + fprintf(stderr, "coverage profile could not be routed: %s:%s\n", + tf_current_suite ? tf_current_suite : "_nosuite", test); + tf_fail_count++; +} + +void tf_coverage_test_begin(const char *test) { + char suite_dir[TF_COVERAGE_PATH_CAP]; + char own[CBM_SZ_512]; + if (!tf_coverage_dir[0]) { + return; + } + int own_length = snprintf(own, sizeof(own), "%s.parent", test); + if (own_length <= 0 || (size_t)own_length >= sizeof(own) || + !tf_coverage_suite_dir(suite_dir, sizeof(suite_dir)) || + !tf_coverage_route_setup(suite_dir)) { + tf_coverage_lost(test); + return; + } + tf_coverage_flush(); + tf_coverage_setup_index++; + /* Named before the test runs, not after: a forked child that leaves through + * exit() writes to the name it inherited, which must not be the setup file. */ + if (!tf_coverage_route(suite_dir, own, test)) { + tf_coverage_lost(test); + } +} + +void tf_coverage_test_end(const char *test) { + char suite_dir[TF_COVERAGE_PATH_CAP]; + char own[CBM_SZ_512]; + if (!tf_coverage_dir[0]) { + return; + } + int own_length = snprintf(own, sizeof(own), "%s.parent", test); + if (own_length <= 0 || (size_t)own_length >= sizeof(own) || + !tf_coverage_suite_dir(suite_dir, sizeof(suite_dir)) || + !tf_coverage_route(suite_dir, own, test)) { + tf_coverage_lost(test); + return; + } + tf_coverage_flush(); + /* The runtime writes once more at exit, to whatever name is current. Left + * on the file of this test, that write would replace the profile just + * taken with the teardown that follows it. */ + if (!tf_coverage_route_setup(suite_dir)) { + tf_coverage_lost(test); + } +} + +/* False when the run asked for coverage and cannot have it. Children run in + * other working directories, so they are handed an absolute name. */ +static bool tf_coverage_init(void) { + const char *dir = getenv("CBM_TEST_COVERAGE_DIR"); + if (!dir || !dir[0]) { + return true; + } + if (!cbm_mkdir_p(dir, 0755) || + !cbm_canonical_path(dir, tf_coverage_dir, sizeof(tf_coverage_dir))) { + tf_coverage_dir[0] = '\0'; + fprintf(stderr, "cannot use coverage directory: %s\n", dir); + return false; + } + /* Routing sets the environment, which may move what `dir` points at. */ + if (!tf_coverage_route(tf_coverage_dir, "_runner.%p", "_runner.child")) { + fprintf(stderr, "cannot route coverage profiles under: %s\n", tf_coverage_dir); + tf_coverage_dir[0] = '\0'; + return false; + } + return true; +} +#endif /* CBM_TEST_COVERAGE */ + static int g_suite_argc = 0; static char **g_suite_argv = NULL; static bool *g_suite_arg_matched = NULL; static bool suite_requested(const char *name) { + /* Every registered suite passes through here, requested or not: this is + * where a selection token learns that its suite exists in this build. */ + size_t token_end = 0; + size_t token_first = tf_only_suite_run(name, &token_end); + tf_only_raise(token_first, token_end, TF_ONLY_REGISTERED); if (g_suite_argc <= 1) { return true; } @@ -1173,6 +1565,16 @@ int main(int argc, char **argv) { g_suite_argc = argc; g_suite_argv = argv; } + if (!g_list_only && !tf_only_init()) { + tf_only_free(); + return 2; + } +#ifdef CBM_TEST_COVERAGE + if (!g_list_only && !tf_coverage_init()) { + tf_only_free(); + return 2; + } +#endif if (g_suite_argc > 1) { g_suite_arg_matched = calloc((size_t)argc, sizeof(*g_suite_arg_matched)); if (!g_suite_arg_matched) { @@ -1417,6 +1819,8 @@ int main(int argc, char **argv) { if (g_suite_argc > 1 && !any_suite_matched) { fprintf(stderr, "No matching test suites requested\n"); } + tf_only_report_unmatched(); + tf_only_free(); free(g_suite_arg_matched); g_suite_arg_matched = NULL; From 6b25fff3a1df4146d8de90f646da0c9508ab2039 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 1 Oct 2026 23:04:50 +0200 Subject: [PATCH 13/50] test(harness): coverage runner profiles children that leave through _exit The profile runtime writes from an atexit handler, and the children that do the real work of a test never reach it: a fork that hosts a whole MCP server leaves through _exit(), and the re-exec'd index worker leaves through _Exit(), as the production worker does. Measured on mcp:index_bg_paths_route_through_supervisor_issue832 with the hooks as first written, neither of its two children left a profile and the test mapped to the 19 functions its parent ran. A test-impact map built on that would never select the test for a pipeline change. In the coverage build only (CBM_TEST_COVERAGE, POSIX), the runner image now defines _exit and _Exit to write the profile before calling the libc one, so every call in the image is covered: tests, runner roles and product code. A pthread_atfork child handler gives a forked child the profile name an exec'd child derives from LLVM_PROFILE_FILE, so it cannot write over the profile of its parent. The same test now leaves both child profiles and maps to 1530 functions, 1511 of them executed only in a child. A child that dies by signal still leaves no profile, and none of this is compiled into any other build: the ASan runner has no symbol from it. Signed-off-by: Martin Vogel --- tests/test_main.c | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/test_main.c b/tests/test_main.c index 0ad96434a..e4a494420 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -47,6 +47,10 @@ int tf_deselected_count = 0; #include #include #endif +#ifdef CBM_TEST_COVERAGE +#include /* the libc _exit behind the profile-writing one */ +#include /* pthread_atfork: a forked child names its own profile */ +#endif #endif /* daemon_runtime places an exact copy of this runner at a private PATH entry @@ -1206,6 +1210,59 @@ static bool tf_coverage_init(void) { } return true; } + +#ifndef _WIN32 +/* ── Children that never reach atexit ─────────────────────────────── + * The profile runtime writes from an atexit handler, and the children that do + * the real work of a test do not get there. A fork that hosts a whole MCP + * server leaves through _exit(); the re-exec'd index worker leaves through + * _Exit() (tf_maybe_run_index_worker, mirroring the production worker). + * Measured on mcp:index_bg_paths_route_through_supervisor_issue832: neither + * child left a profile, and the test mapped to the 19 functions of its parent. + * + * Both functions are defined in this image, so every call in it -- a test, a + * runner role, product code -- writes the profile first. exit() inside libc is + * untouched. A child that dies by signal still leaves nothing. */ +static char tf_coverage_fork_profile[TF_COVERAGE_PATH_CAP]; + +/* A forked child inherits the profile name of its parent and would write over + * it. Give it the name an exec'd child derives from LLVM_PROFILE_FILE. */ +static void tf_coverage_fork_child(void) { + const char *pattern = getenv("LLVM_PROFILE_FILE"); + int length = pattern ? snprintf(tf_coverage_fork_profile, sizeof(tf_coverage_fork_profile), + "%s", pattern) + : 0; + if (length > 0 && (size_t)length < sizeof(tf_coverage_fork_profile)) { + __llvm_profile_set_filename(tf_coverage_fork_profile); + } +} + +static void tf_coverage_exit_now(int status) __attribute__((noreturn)); +static void tf_coverage_exit_now(int status) { + (void)__llvm_profile_write_file(); + void (*libc_exit)(int) = (void (*)(int))dlsym(RTLD_NEXT, "_exit"); + if (libc_exit) { + libc_exit(status); + } + abort(); +} + +void _exit(int status) { + tf_coverage_exit_now(status); +} + +void _Exit(int status) { + tf_coverage_exit_now(status); +} +#endif /* !_WIN32 */ + +/* Runs first in every process of this image, role children included: they + * fork too. Windows has neither fork nor an interposable _exit here. */ +static void tf_coverage_process_init(void) { +#ifndef _WIN32 + (void)pthread_atfork(NULL, NULL, tf_coverage_fork_child); +#endif +} #endif /* CBM_TEST_COVERAGE */ static int g_suite_argc = 0; @@ -1420,6 +1477,9 @@ extern void suite_dump_verify_io(void); extern void cbm_kind_in_set_free_cache(void); int main(int argc, char **argv) { +#ifdef CBM_TEST_COVERAGE + tf_coverage_process_init(); +#endif int memory_limit_probe_rc = tf_maybe_run_windows_memory_limit_probe(argc, argv); if (memory_limit_probe_rc >= 0) { return memory_limit_probe_rc; From 38d422f0303a3e91875a0ffb7396783f0b5caaea Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 20 Sep 2026 17:49:27 +0200 Subject: [PATCH 14/50] fix(routes): a document or log path is not an HTTP route literal Distilled from #1245 by Andrew Hundt (a slice of 0370d52f). detect_url_in_args in the parallel resolver runs for every resolved call with no callee gating. A first argument like "/new/file.txt" clears normalize_url_arg (it has a second slash and no whitespace) and then reaches cbm_service_pattern_is_http_route_literal, whose filesystem extension list knew about config, key and socket files but not about documents or logs. So open_file("/new/file.txt") read_file("/docs/guide.md") write_file("/data/app.log") each minted a Route node and an HTTP_CALLS edge -- the graph reported a web endpoint where the program opens a file. Five extensions join the list: .log .md .pdf .rst .txt, alphabetical order preserved. Two things the tests are careful about, because the obvious fixture would not have been RED. A bare Python builtin open(...) never resolves to an indexed target, so it never reaches the URL detector at all; the bug shows only for calls that resolve to a project symbol, which is what the fixture defines. And /var/... is already rejected by the filesystem ROOT list, so the fixture paths start with segments that list does not know, leaving the extension guard as the only thing that can reject them. RED before the fix: infrascan_http_route_literal_guard_rejects_document_extensions FAIL tests/test_infrascan.c:44: ASSERT(!(cbm_service_pattern_is_http_route_literal("/new/file.txt", "open"))) pipeline_arg_url_rejects_document_file_paths FAIL tests/test_pipeline.c:6652: count_nodes_named(s, project, "/new/file.txt") == 1, expected 0 == 0 The pipeline test keeps a positive control above that line -- a genuine requests.get("/api/items") still yields its Route node and HTTP_CALLS edge -- so the guard cannot pass by rejecting everything. GREEN after: infrascan 4 passed; pipeline 282 passed; extraction edge_types_probe route_canon 420 passed. Co-authored-by: Andrew Hundt Signed-off-by: Martin Vogel --- internal/cbm/service_patterns.c | 6 +-- tests/test_infrascan.c | 20 +++++++ tests/test_pipeline.c | 95 +++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index c2425e09e..2963b44c0 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -752,9 +752,9 @@ static bool has_filesystem_extension(const char *path) { ext[ext_len] = '\0'; static const char *const hard_file_exts[] = { - ".cfg", ".conf", ".credentials", ".crt", ".db", ".env", - ".ini", ".key", ".pem", ".pid", ".properties", ".service", - ".sock", ".socket", ".sqlite", ".toml", NULL}; + ".cfg", ".conf", ".credentials", ".crt", ".db", ".env", ".ini", ".key", + ".log", ".md", ".pdf", ".pem", ".pid", ".properties", ".rst", ".service", + ".sock", ".socket", ".sqlite", ".toml", ".txt", NULL}; for (int i = 0; hard_file_exts[i]; i++) { if (path_ext_matches(ext, hard_file_exts[i])) { return true; diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index a9bf1aef9..4ae8a4e42 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -225,6 +225,25 @@ TEST(infrascan_service_pattern_match_keeps_real_library_qns) { PASS(); } +/* Document/log extensions are never HTTP routes (distilled from PR #1245): + * the arg-URL detector runs for every resolved call, so without the + * extension guard `open("/new/file.txt")` minted a Route + HTTP_CALLS. + * First segments deliberately avoid the filesystem-root list so ONLY the + * extension guard can reject them; `/api/...` marker paths stay routes. */ +TEST(infrascan_http_route_literal_guard_rejects_document_extensions) { + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/new/file.txt", "open")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/docs/guide.md", "read")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/data/app.log", "write")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/fake/path.pdf", "open")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/docs/index.rst", "open")); + /* Same extensions under a query string are still files. */ + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/docs/guide.md?raw=1", "open")); + /* Positive controls: extension-less routes and the same callees. */ + ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/api/items", "requests.get")); + ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/docs/guide", "open")); + PASS(); +} + TEST(infrascan_route_nodes_skip_bad_http_url_paths) { cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_guard"); ASSERT_NOT_NULL(gb); @@ -305,6 +324,7 @@ TEST(infrascan_http_calls_join_matching_handler_route) { SUITE(infrascan) { RUN_TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths); + RUN_TEST(infrascan_http_route_literal_guard_rejects_document_extensions); RUN_TEST(infrascan_service_pattern_match_rejects_ids_inside_words); RUN_TEST(infrascan_service_pattern_match_keeps_real_library_qns); RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 56be6b88e..3cd4ae641 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -7517,6 +7517,100 @@ TEST(pipeline_ts_config_object_url_http_calls_issue2235) { PASS(); } +/* Count HTTP_CALLS-style edges whose url_path property contains `keyword`. */ +static int count_edges_with_url_path(cbm_store_t *s, const char *project, const char *keyword) { + cbm_edge_t *es = NULL; + int n = 0; + cbm_store_find_edges_by_url_path(s, project, keyword, &es, &n); + if (es) { + cbm_store_free_edges(es, n); + } + return n; +} + +/* Distilled from PR #1245: detect_url_in_args (parallel resolver, >= 50 files) + * runs for EVERY resolved call with no callee gating, so a filesystem path + * with a document/log extension passed to a project-defined open/read/write + * helper passed normalize_url_arg (second slash, no space) and the route + * literal guard, minting a Route node + an HTTP_CALLS edge for a file. The + * callees are project functions on purpose: a bare builtin `open(...)` never + * resolves to a target and so never reaches emit_service_edge at all. The + * first path segments avoid the filesystem-root list (etc/var/tmp/...) so + * only the extension guard can reject them. requests.get('/api/items') is + * the positive control that must keep its Route + HTTP_CALLS. */ +TEST(pipeline_arg_url_rejects_document_file_paths) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_arg_url_docs_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/files.py", + "import requests\n" + "def open_file(path):\n" + " return path\n" + "def read_file(path):\n" + " return path\n" + "def write_file(path):\n" + " return path\n" + "def load_docs():\n" + " open_file('/new/file.txt')\n" + " read_file('/docs/guide.md')\n" + " write_file('/data/app.log')\n" + " read_file('/reports/summary.pdf')\n" + " open_file('/docs/index.rst')\n" + "def fetch_items():\n" + " return requests.get('/api/items')\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number { return %d; }\n", i, i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/arg_url_docs.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* Positive control first: the genuine HTTP call still mints its route. */ + ASSERT_GTE(count_nodes_named(s, project, "/api/items"), 1); + ASSERT_GTE(count_edges_with_url_path(s, project, "/api/items"), 1); + /* No Route node for any filesystem document path... */ + ASSERT_EQ(count_nodes_named(s, project, "/new/file.txt"), 0); + ASSERT_EQ(count_nodes_named(s, project, "/docs/guide.md"), 0); + ASSERT_EQ(count_nodes_named(s, project, "/data/app.log"), 0); + ASSERT_EQ(count_nodes_named(s, project, "/reports/summary.pdf"), 0); + ASSERT_EQ(count_nodes_named(s, project, "/docs/index.rst"), 0); + /* ...and no HTTP_CALLS edge carrying one as its url_path either. */ + ASSERT_EQ(count_edges_with_url_path(s, project, "/new/file.txt"), 0); + ASSERT_EQ(count_edges_with_url_path(s, project, "/docs/guide.md"), 0); + ASSERT_EQ(count_edges_with_url_path(s, project, "/data/app.log"), 0); + ASSERT_EQ(count_edges_with_url_path(s, project, "/reports/summary.pdf"), 0); + ASSERT_EQ(count_edges_with_url_path(s, project, "/docs/index.rst"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* Native `fetch()` (#856), sequential path (< 50 files → pass_calls.c). A bare * unqualified call to the global fetch API has no import and no local * definition anywhere in this project, so registry resolution comes back @@ -16360,6 +16454,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); RUN_TEST(pipeline_ts_config_object_url_http_calls_issue2235); + RUN_TEST(pipeline_arg_url_rejects_document_file_paths); RUN_TEST(pipeline_native_fetch_classified_as_http_calls); RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892); RUN_TEST(pipeline_swift_http_call_makes_route_issue1892); From dd304b53e4dde79a5fd634c6f998e24f1c1c4d87 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 00:52:13 +0200 Subject: [PATCH 15/50] fix(daemon): keep an inline rejection that beats the client's HELLO daemon_runtime_connection_cap_covers_slow_hello_and_stopping_is_terminal failed ASSERT(capacity_rejected) seven times (TSan macos-14 x3, LSan macOS x1, macos-15-intel x1, local plain macOS x2) and passed on rerun. Two interleavings fail it; each was forced and fails 3 of 3. 1. Production race. At capacity (and while stopping) the accept loop answers a new connection WITHOUT reading its HELLO and closes it at once; on POSIX the reject drain is a no-op. A Unix-socket connect completes from the listen backlog, so the client can still be between connect() and its HELLO send when that close lands. The send then fails with EPIPE, cbm_daemon_runtime_client_connect returned a zeroed result (status ERROR, empty message), and the complete 810-byte rejection sat unread in the client's receive buffer. Forced by holding the client before its send until the daemon had closed: overflow status=0 message="", 810 bytes pending, active_connections=2, active_clients=1. A standalone AF_UNIX probe on macOS shows the same: send rc=-1 errno=EPIPE, then recv returns the 810 bytes, then EOF. Bootstrap then classified the attempt by lock state instead of by the daemon's own "capacity" / "stopping" answer, and the user-visible reason was lost. 2. Test window. The slow-hello connection held its slot only until the daemon's first-frame deadline (request_timeout_ms, 2000 ms in the fixture). A runner stalled that long freed the slot, and the active_connections == 2 conjunct read 1. Forced by waiting for that expiry before the overflow probe: freed after 2002 ms, capacity still reported, active_connections=1. Fix: when the HELLO send fails, the client reads the answer anyway through the new cbm_daemon_ipc_receive_frame_after_failed_send. A failed send consumes no inbound bytes, so the inbound frame boundary is intact. The function lifts the send-side poison for that one receive and restores it afterwards. Only a REJECTED answer is accepted in that state; anything else stays the plain send failure it always was. On Windows a server close discards unread pipe data, so this path just fails there as before. The bounded drain-before-close remains that platform's mechanism. Test rebuild (no timeout raised, no retry): - cbm_daemon_runtime_hold_peer_waits_for_testing: the slow hello keeps its slot until the test closes it, and the Windows reject drain ends on the peer's close, not after 250 ms. Both counts are held states when they are read. - cbm_daemon_runtime_set_hello_hook_for_testing: four points (client connected / sent, daemon rejecting / rejected) let a test pin the order. The existing test now pins "daemon answers after the HELLO is on the wire", which every platform delivers. The new daemon_runtime_capacity_rejection_survives_daemon_close_before_hello pins "daemon answered and closed before the HELLO" (POSIX only; it is compile-gated because Windows discards that data by design). Both assert that the pin really happened, and print the overflow result on failure. - The waits end on guaranteed events (HELLO sent; rejection closed or a third slot admitted, the latter held by the seam), never on a bound. Proof (macOS arm64, ASan/UBSan test-runner): - forced late HELLO, current client: RED 3/3, identical signature; with the fix GREEN 3/3; fix reverted: RED 3/3 again; - forced 6000 ms stall before the overflow probe (3x the deadline that freed the slot): slot still held, both tests GREEN 3/3; - both pinned orders, 200 iterations each in one process: 400 passed; - daemon_runtime suite alone, 50 sequential runs: 50 x 51 passed, 0 failed; - daemon_runtime under TSan (the venue of 3 sightings): 51 passed, no ThreadSanitizer report; - daemon_ipc 54 passed (2 Linux-only platform skips), daemon_application 67 passed; lint-ci clean. Not run here: the Linux and Windows legs (the Windows path is compile-gated and reasoned from the drain's documented contract). Signed-off-by: Martin Vogel --- src/daemon/ipc.c | 18 ++++ src/daemon/ipc_internal.h | 16 ++++ src/daemon/runtime.c | 80 ++++++++++++++-- src/daemon/runtime.h | 21 +++++ tests/test_daemon_runtime.c | 176 +++++++++++++++++++++++++++++------- 5 files changed, 272 insertions(+), 39 deletions(-) diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index eb87e43be..331af57a1 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -7078,3 +7078,21 @@ int cbm_daemon_ipc_receive_frame(cbm_daemon_ipc_connection_t *connection, uint32 return cbm_daemon_ipc_receive_frame_bounded(connection, timeout_ms, CBM_DAEMON_MAX_FRAME_SIZE, frame_out, payload_out); } + +int cbm_daemon_ipc_receive_frame_after_failed_send(cbm_daemon_ipc_connection_t *connection, + uint32_t timeout_ms, uint32_t max_payload_length, + cbm_daemon_frame_t *frame_out, + uint8_t **payload_out) { + if (!connection) { + return cbm_daemon_ipc_receive_frame_bounded(NULL, timeout_ms, max_payload_length, frame_out, + payload_out); + } + /* The poison came from the failed send alone (nothing was read yet), and it + * guards the OUTBOUND frame boundary; the inbound one is intact. Lift it for + * this one receive, then restore it so no later send can reuse the stream. */ + atomic_store_explicit(&connection->poisoned, false, memory_order_release); + int received = cbm_daemon_ipc_receive_frame_bounded(connection, timeout_ms, max_payload_length, + frame_out, payload_out); + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return received; +} diff --git a/src/daemon/ipc_internal.h b/src/daemon/ipc_internal.h index f481b7378..5f86d3a97 100644 --- a/src/daemon/ipc_internal.h +++ b/src/daemon/ipc_internal.h @@ -75,6 +75,22 @@ int cbm_daemon_ipc_receive_frame_bounded(cbm_daemon_ipc_connection_t *connection uint32_t timeout_ms, uint32_t max_payload_length, cbm_daemon_frame_t *frame_out, uint8_t **payload_out); +/* Bounded receive of an answer the peer queued before it closed, on a + * connection whose send just failed. A daemon at capacity or stopping answers + * from its accept loop without reading the request and closes at once; when + * that close lands before the client's first send, the send fails (EPIPE) and + * poisons the connection although the complete answer is still queued for + * reading -- POSIX keeps it readable after the peer's close (a Windows server + * close discards it, so there this fails like any other receive). A failed + * send consumes no inbound bytes, so the inbound frame boundary is intact; + * call this ONLY before anything was read from the connection. The connection + * stays poisoned for sends afterwards. Returns like + * cbm_daemon_ipc_receive_frame_bounded. */ +int cbm_daemon_ipc_receive_frame_after_failed_send(cbm_daemon_ipc_connection_t *connection, + uint32_t timeout_ms, uint32_t max_payload_length, + cbm_daemon_frame_t *frame_out, + uint8_t **payload_out); + /* Narrow crash/fault seams for publication-state and retry-state tests. They * are inert unless a test installs a hook/failure count in its own process. */ typedef enum { diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index 7c9bf3668..9a95a1c45 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -76,8 +76,26 @@ static _Atomic uint32_t runtime_ephemeral_linger_timeout_seam = UINT32_MAX; void cbm_daemon_runtime_service_set_ephemeral_linger_timeout_for_testing(uint32_t timeout_ms) { atomic_store(&runtime_ephemeral_linger_timeout_seam, timeout_ms); } + +/* Connection-cap seams (flaky-ledger item 12): see runtime.h. */ +static atomic_bool runtime_hold_peer_waits_seam; +void cbm_daemon_runtime_hold_peer_waits_for_testing(bool hold) { + atomic_store(&runtime_hold_peer_waits_seam, hold); +} +static _Atomic(cbm_daemon_runtime_hello_hook_t) runtime_hello_hook_seam; +void cbm_daemon_runtime_set_hello_hook_for_testing(cbm_daemon_runtime_hello_hook_t hook) { + atomic_store(&runtime_hello_hook_seam, hook); +} +static void runtime_hello_point(cbm_daemon_runtime_hello_point_t point) { + cbm_daemon_runtime_hello_hook_t hook = atomic_load(&runtime_hello_hook_seam); + if (hook) { + hook(point); + } +} +#define RUNTIME_HELLO_POINT(point) runtime_hello_point(CBM_DAEMON_RUNTIME_HELLO_POINT_##point) #else static void runtime_note_peer_image_hash(void) {} +#define RUNTIME_HELLO_POINT(point) ((void)0) #endif #ifdef _WIN32 @@ -1989,6 +2007,29 @@ static void runtime_worker_handle_activation_shutdown(cbm_daemon_runtime_worker_ runtime_worker_finish(worker); } +/* The first frame is due within the request deadline. Under the item-12 seam a + * connection that never sends one holds its slot until it closes; stop still + * interrupts the wait like any other worker receive. */ +static uint32_t runtime_first_frame_timeout_ms(const cbm_daemon_runtime_service_t *service) { +#ifdef CBM_ENABLE_TEST_SEAMS + if (atomic_load(&runtime_hold_peer_waits_seam)) { + return CBM_DAEMON_IPC_WAIT_FOREVER; + } +#endif + return service->request_timeout_ms; +} + +/* Under the same seam an inline rejection's drain ends only on the peer's + * close, never on the 250 ms bound (the drain only waits on Windows). */ +static uint32_t runtime_reject_drain_timeout_ms(void) { +#ifdef CBM_ENABLE_TEST_SEAMS + if (atomic_load(&runtime_hold_peer_waits_seam)) { + return CBM_DAEMON_IPC_WAIT_FOREVER; + } +#endif + return RUNTIME_REJECT_DRAIN_TIMEOUT_MS; +} + static void *runtime_connection_worker(void *opaque) { cbm_daemon_runtime_worker_t *worker = opaque; cbm_daemon_runtime_service_t *service = worker->service; @@ -1998,7 +2039,7 @@ static void *runtime_connection_worker(void *opaque) { char requested_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; int received = cbm_daemon_ipc_receive_frame_bounded( - worker->connection, service->request_timeout_ms, + worker->connection, runtime_first_frame_timeout_ms(service), CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE, &frame, &payload); if (received != 1 || frame.type != CBM_DAEMON_FRAME_REQUEST) { free(payload); @@ -2274,14 +2315,19 @@ static cbm_daemon_runtime_worker_t *runtime_find_free_worker_locked( return NULL; } +/* Answers WITHOUT reading the peer's HELLO, so on POSIX (where the drain is a + * no-op) the close can land before the peer has even sent it; the client reads + * this answer after its failed send (cbm_daemon_runtime_client_connect). */ static void runtime_reject_inline(cbm_daemon_ipc_connection_t *connection, const char *message) { cbm_daemon_runtime_connect_result_t result; runtime_result_rejected(&result, message); + RUNTIME_HELLO_POINT(DAEMON_REJECTING); (void)runtime_send_hello_response(connection, &result); /* Same Windows named-pipe discard hazard as runtime_worker_finish: the * peer must get to read the rejection before the handle closes. */ - cbm_daemon_ipc_connection_drain(connection, RUNTIME_REJECT_DRAIN_TIMEOUT_MS); + cbm_daemon_ipc_connection_drain(connection, runtime_reject_drain_timeout_ms()); cbm_daemon_ipc_connection_close(connection); + RUNTIME_HELLO_POINT(DAEMON_REJECTED); } static void runtime_accept_connection(cbm_daemon_runtime_service_t *service, @@ -3063,21 +3109,39 @@ cbm_daemon_runtime_client_t *cbm_daemon_runtime_client_connect( return NULL; } cbm_daemon_ipc_connection_t *connection = cbm_daemon_ipc_connect(endpoint, timeout_ms); - if (!connection || !cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_HELLO, request, - (uint32_t)sizeof(request))) { - cbm_daemon_ipc_connection_close(connection); + if (!connection) { return NULL; } + RUNTIME_HELLO_POINT(CLIENT_CONNECTED); + bool hello_sent = + cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, CBM_DAEMON_RUNTIME_OP_HELLO, + request, (uint32_t)sizeof(request)); + RUNTIME_HELLO_POINT(CLIENT_SENT); cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; - int received = cbm_daemon_ipc_receive_frame_bounded( - connection, timeout_ms, CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, &frame, &payload); + /* A daemon at capacity or stopping answers from its accept loop without + * reading this HELLO and closes at once. When that close lands first, the + * send fails (EPIPE) with the whole answer already queued here: read it, + * or the caller gets a bare transport error and bootstrap classifies by + * lock state instead of by the daemon's own "capacity" / "stopping". */ + int received = + hello_sent + ? cbm_daemon_ipc_receive_frame_bounded( + connection, timeout_ms, CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, &frame, &payload) + : cbm_daemon_ipc_receive_frame_after_failed_send( + connection, timeout_ms, CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, &frame, &payload); bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && frame.flags == CBM_DAEMON_RUNTIME_OP_HELLO && frame.length == CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE && runtime_hello_response_decode(payload, result_out); free(payload); + if (!hello_sent && (!valid || result_out->status != CBM_DAEMON_RUNTIME_CONNECT_REJECTED)) { + /* Only a rejection can answer a HELLO the daemon never received; + * anything else stays the plain send failure it always was. */ + memset(result_out, 0, sizeof(*result_out)); + cbm_daemon_ipc_connection_close(connection); + return NULL; + } if (received != 1) { /* The kernel completed the pipe/socket connect, so a server process * exists, yet the HELLO went unanswered. Name that holder: a dead diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index d3bfae47f..53d2a4098 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -471,6 +471,27 @@ void cbm_daemon_runtime_set_containment_hook_for_testing( * in test time. UINT32_MAX restores the production constant; any other value * (0 = expire immediately) overrides. Process-global; reset it after use. */ void cbm_daemon_runtime_service_set_ephemeral_linger_timeout_for_testing(uint32_t timeout_ms); +/* Connection-cap seams (flaky-ledger item 12). While held, the two bounded + * waits on a peer in the connection-cap path end only on that peer: a + * connection that has not sent its first frame keeps its worker slot until it + * closes (or the service stops) instead of until the request deadline, and an + * inline rejection's drain (Windows; a no-op on POSIX) lasts until the peer + * closes instead of 250 ms. A "slow hello" becomes a state the test releases, + * not a window it races. Process-global; reset it after use. */ +void cbm_daemon_runtime_hold_peer_waits_for_testing(bool hold); +/* The four points that order a HELLO against an inline accept-loop rejection. + * The hook runs on the client thread for the CLIENT_* points and on the accept + * thread for the DAEMON_* points (never under the service mutex), so a test can + * pin either order: daemon answers after the HELLO is on the wire, or the + * daemon has answered and closed before the HELLO is sent. NULL removes it. */ +typedef enum { + CBM_DAEMON_RUNTIME_HELLO_POINT_CLIENT_CONNECTED = 0, /* transport up, HELLO not sent */ + CBM_DAEMON_RUNTIME_HELLO_POINT_CLIENT_SENT, /* HELLO send attempted */ + CBM_DAEMON_RUNTIME_HELLO_POINT_DAEMON_REJECTING, /* inline rejection not yet written */ + CBM_DAEMON_RUNTIME_HELLO_POINT_DAEMON_REJECTED, /* inline rejection written + closed */ +} cbm_daemon_runtime_hello_point_t; +typedef void (*cbm_daemon_runtime_hello_hook_t)(cbm_daemon_runtime_hello_point_t point); +void cbm_daemon_runtime_set_hello_hook_for_testing(cbm_daemon_runtime_hello_hook_t hook); #endif #endif /* CBM_DAEMON_RUNTIME_H */ diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index bd078bbca..51353fefb 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -2827,60 +2827,138 @@ TEST(daemon_runtime_authenticated_idle_connection_outlives_lease_interval) { PASS(); } -TEST(daemon_runtime_connection_cap_covers_slow_hello_and_stopping_is_terminal) { +/* Flaky-ledger item 12 (7 sightings, ASSERT(capacity_rejected)): the overflow + * client's HELLO raced the daemon's inline capacity rejection, which the + * accept loop writes WITHOUT reading that HELLO and follows with an immediate + * close. The hook pins one order per run so no window is left to race; the + * hold seam keeps the slow hello's slot until the test closes it, where the + * request deadline used to free it after 2 s. The waits below end on events + * that are guaranteed to arrive (see each comment), never on a bound. */ +typedef enum { + RUNTIME_CAP_HELLO_BEFORE_REJECTION = 0, + RUNTIME_CAP_REJECTION_BEFORE_HELLO = 1, +} runtime_cap_order_t; + +static atomic_int runtime_cap_order; +static atomic_bool runtime_cap_hello_sent; +static atomic_bool runtime_cap_rejection_closed; +static atomic_bool runtime_cap_order_pinned; +static cbm_daemon_runtime_service_t *runtime_cap_service; + +static void runtime_cap_hello_hook(cbm_daemon_runtime_hello_point_t point) { + int order = atomic_load(&runtime_cap_order); + if (point == CBM_DAEMON_RUNTIME_HELLO_POINT_CLIENT_SENT) { + atomic_store(&runtime_cap_hello_sent, true); + } else if (point == CBM_DAEMON_RUNTIME_HELLO_POINT_DAEMON_REJECTED) { + atomic_store(&runtime_cap_rejection_closed, true); + } else if (point == CBM_DAEMON_RUNTIME_HELLO_POINT_DAEMON_REJECTING && + order == RUNTIME_CAP_HELLO_BEFORE_REJECTION) { + /* Accept thread. The client always gets there: its connect already + * completed and a HELLO fits the transport buffer, so its send never + * waits on this thread. */ + while (!atomic_load(&runtime_cap_hello_sent)) { + cbm_usleep(200); + } + atomic_store(&runtime_cap_order_pinned, true); + } else if (point == CBM_DAEMON_RUNTIME_HELLO_POINT_CLIENT_CONNECTED && + order == RUNTIME_CAP_REJECTION_BEFORE_HELLO) { + /* Client (test) thread. Ends on the rejection, or on an admission (a + * broken cap): that worker is held by the seam, so a third slot stays + * counted until this HELLO goes out and the test fails on ACCEPTED. */ + while (!atomic_load(&runtime_cap_rejection_closed) && + cbm_daemon_runtime_service_active_connections(runtime_cap_service) <= 2) { + cbm_usleep(200); + } + atomic_store(&runtime_cap_order_pinned, atomic_load(&runtime_cap_rejection_closed)); + } +} + +typedef struct { + bool started; + bool slow_slot_counted; + bool order_pinned; + bool capacity_rejected; + bool slow_slot_released; + bool exited; + bool no_resurrection; +} runtime_cap_outcome_t; + +static cbm_daemon_runtime_client_t *runtime_cap_overflow_connect( + runtime_test_fixture_t *fixture, const cbm_daemon_build_identity_t *identity, + runtime_cap_order_t order, runtime_cap_outcome_t *outcome) { + cbm_daemon_runtime_connect_result_t result = {0}; + runtime_cap_service = fixture->service; + atomic_store(&runtime_cap_order, (int)order); + atomic_store(&runtime_cap_hello_sent, false); + atomic_store(&runtime_cap_rejection_closed, false); + atomic_store(&runtime_cap_order_pinned, false); + cbm_daemon_runtime_set_hello_hook_for_testing(runtime_cap_hello_hook); + cbm_daemon_runtime_client_t *overflow = cbm_daemon_runtime_client_connect( + fixture->endpoint, identity, RUNTIME_TEST_TIMEOUT_MS, &result); + cbm_daemon_runtime_set_hello_hook_for_testing(NULL); + /* Both counts are HELD here: the slow hello's slot by the seam, the + * accepted client by its open session. */ + size_t connections = cbm_daemon_runtime_service_active_connections(fixture->service); + size_t clients = cbm_daemon_runtime_service_active_clients(fixture->service); + outcome->order_pinned = atomic_load(&runtime_cap_order_pinned); + outcome->capacity_rejected = + overflow == NULL && result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED && + strstr(result.message, "capacity") != NULL && connections == 2 && clients == 1; + if (!outcome->capacity_rejected) { + printf(" overflow: client=%s status=%d message=\"%s\" active_connections=%zu " + "active_clients=%zu order=%d pinned=%d\n", + overflow ? "admitted" : "null", (int)result.status, result.message, connections, + clients, (int)order, outcome->order_pinned); + } + return overflow; +} + +static void runtime_cap_scenario(runtime_cap_order_t order, runtime_cap_outcome_t *outcome) { + memset(outcome, 0, sizeof(*outcome)); cbm_daemon_build_identity_t identity = runtime_test_identity("2.4.0", runtime_test_self_build()); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_limited(&fixture, "connection-cap", &identity, 2); + cbm_daemon_runtime_hold_peer_waits_for_testing(true); + outcome->started = runtime_test_fixture_start_limited(&fixture, "connection-cap", &identity, 2); cbm_daemon_ipc_connection_t *slow_hello = NULL; cbm_daemon_runtime_client_t *accepted = NULL; cbm_daemon_runtime_client_t *overflow = NULL; cbm_daemon_runtime_client_t *resurrection = NULL; cbm_daemon_runtime_connect_result_t accepted_result = {0}; - cbm_daemon_runtime_connect_result_t overflow_result = {0}; cbm_daemon_runtime_connect_result_t resurrection_result = {0}; - bool slow_slot_counted = false; - bool capacity_rejected = false; - bool slow_slot_released = false; - bool exited = false; - bool no_resurrection = false; - if (started) { + if (outcome->started) { slow_hello = cbm_daemon_ipc_connect(fixture.endpoint, RUNTIME_TEST_TIMEOUT_MS); } if (slow_hello) { - slow_slot_counted = cbm_daemon_runtime_service_wait_for_connections( + outcome->slow_slot_counted = cbm_daemon_runtime_service_wait_for_connections( fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); accepted = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &accepted_result); } if (accepted) { - overflow = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, - RUNTIME_TEST_TIMEOUT_MS, &overflow_result); - capacity_rejected = overflow == NULL && - overflow_result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED && - strstr(overflow_result.message, "capacity") != NULL && - cbm_daemon_runtime_service_active_connections(fixture.service) == 2 && - cbm_daemon_runtime_service_active_clients(fixture.service) == 1; + overflow = runtime_cap_overflow_connect(&fixture, &identity, order, outcome); cbm_daemon_ipc_connection_close(slow_hello); slow_hello = NULL; - slow_slot_released = cbm_daemon_runtime_service_wait_for_connections( + outcome->slow_slot_released = cbm_daemon_runtime_service_wait_for_connections( fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); (void)cbm_daemon_runtime_client_close(accepted, RUNTIME_TEST_TIMEOUT_MS); accepted = NULL; - exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); + outcome->exited = + cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); resurrection = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, 100, &resurrection_result); cbm_daemon_runtime_service_state_t terminal_state = cbm_daemon_runtime_service_state(fixture.service); - no_resurrection = resurrection == NULL && - (resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_ERROR || - resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED) && - cbm_daemon_runtime_service_active_clients(fixture.service) == 0 && - (terminal_state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING || - terminal_state == CBM_DAEMON_RUNTIME_SERVICE_EXITED); + outcome->no_resurrection = + resurrection == NULL && + (resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_ERROR || + resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED) && + cbm_daemon_runtime_service_active_clients(fixture.service) == 0 && + (terminal_state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING || + terminal_state == CBM_DAEMON_RUNTIME_SERVICE_EXITED); } if (resurrection) { @@ -2894,16 +2972,49 @@ TEST(daemon_runtime_connection_cap_covers_slow_hello_and_stopping_is_terminal) { } cbm_daemon_ipc_connection_close(slow_hello); runtime_test_fixture_finish(&fixture); + cbm_daemon_runtime_hold_peer_waits_for_testing(false); +} - ASSERT_TRUE(started); - ASSERT_TRUE(slow_slot_counted); - ASSERT_TRUE(capacity_rejected); - ASSERT_TRUE(slow_slot_released); - ASSERT_TRUE(exited); - ASSERT_TRUE(no_resurrection); +/* The daemon answers the overflow only after its HELLO is on the wire: the + * order every platform delivers deterministically. */ +TEST(daemon_runtime_connection_cap_covers_slow_hello_and_stopping_is_terminal) { + runtime_cap_outcome_t outcome; + runtime_cap_scenario(RUNTIME_CAP_HELLO_BEFORE_REJECTION, &outcome); + + ASSERT_TRUE(outcome.started); + ASSERT_TRUE(outcome.slow_slot_counted); + ASSERT_TRUE(outcome.order_pinned); + ASSERT_TRUE(outcome.capacity_rejected); + ASSERT_TRUE(outcome.slow_slot_released); + ASSERT_TRUE(outcome.exited); + ASSERT_TRUE(outcome.no_resurrection); PASS(); } +#ifndef _WIN32 +/* Item 12's production race, pinned: the daemon has written its capacity + * rejection AND closed before the client sends its HELLO. That send fails + * (EPIPE) while the complete rejection sits in the client's receive buffer; + * the client must still report it, not a bare transport error that bootstrap + * would classify by lock state instead of "capacity reached". + * POSIX only: a Windows server close discards unread pipe data, so there the + * rejection is delivered by the drain-before-close instead (which the other + * order covers) and this order cannot carry one by design. */ +TEST(daemon_runtime_capacity_rejection_survives_daemon_close_before_hello) { + runtime_cap_outcome_t outcome; + runtime_cap_scenario(RUNTIME_CAP_REJECTION_BEFORE_HELLO, &outcome); + + ASSERT_TRUE(outcome.started); + ASSERT_TRUE(outcome.slow_slot_counted); + ASSERT_TRUE(outcome.order_pinned); + ASSERT_TRUE(outcome.capacity_rejected); + ASSERT_TRUE(outcome.slow_slot_released); + ASSERT_TRUE(outcome.exited); + ASSERT_TRUE(outcome.no_resurrection); + PASS(); +} +#endif + TEST(daemon_runtime_rejects_forged_identity_extension) { cbm_daemon_build_identity_t identity = runtime_test_identity("2.4.0", runtime_test_self_build()); @@ -5191,6 +5302,9 @@ SUITE(daemon_runtime) { #endif RUN_TEST(daemon_runtime_authenticated_idle_connection_outlives_lease_interval); RUN_TEST(daemon_runtime_connection_cap_covers_slow_hello_and_stopping_is_terminal); +#ifndef _WIN32 + RUN_TEST(daemon_runtime_capacity_rejection_survives_daemon_close_before_hello); +#endif RUN_TEST(daemon_runtime_rejects_forged_identity_extension); RUN_TEST(daemon_runtime_application_response_roundtrip_is_byte_exact); RUN_TEST(daemon_runtime_final_disconnect_rejects_blocked_provisional_session); From 426676b60b52a8ffc2c1db74ed8809d572e2d16e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 00:53:42 +0200 Subject: [PATCH 16/50] fix(graph): typedef'd C aggregates get nodes; member access binds by object type Two defects that fed each other, fixed together because the first alone would let the new Field nodes attract bare-name calls. 1. An anonymous aggregate under a typedef had no nodes. `typedef struct { ... } X;` produced no Class node and none of its members a Field node: the struct_specifier carries no name of its own, and extract_class_def gave up on it. That is the dominant C spelling of a type (37 of the 38 structs in internal/cbm/cbm.h). The enclosing type_definition now names the aggregate through its declarator; only a plain type_identifier declarator counts, since a pointer, array or function typedef names another type. Applies to struct, union and enum specifiers in C and C++ (`.h` is classified C++). 2. A C member access was resolved by its bare member name. The member half of `a.b` / `a->b` reached the short-name registry as `b`, which returned whichever Field (or function, or variable) was called `b`: on this repository 9,150 USAGE and 2,810 WRITES edges onto Fields from C sources, 3,976 of the USAGE edges onto a name several structs share. The C LSP already types the object of a field_expression. It now publishes the owner as a FIELD_REFERENCE row, one per (enclosing function, object type, field): the pipeline edge is function -> Field, so a row per occurrence would only add a record per member access. The usage and read/write resolvers (sequential and parallel) join those rows by function and member name; a C member name with no typed owner binds nothing. Four things had to hold for the owner to be found: - the cross-file registry folded Field defs into their owning type for Go only, so every C/C++ struct from another file (every header) registered with zero fields. The fold now covers C and C++; - c_register_lsp_defs kept the first 63 fields of a type. It is sized from the list now: a cap there silently dropped edges onto later fields; - a struct is registered under the header that declares it while the use site knows its short name. The owner type resolves as written, then module-qualified, then by short name from this module, a directly included header, or as the single candidate. Two same-named structs with no evidence between them resolve to nothing; - the parallel resolver ran the cross-file pass only for files with unqualified call sites. A C member access or member call that still has no owner now requests it too. Calls landing on a Field follow the same rule (cbm_call_onto_field_policy): dropped across languages (the #1928 reference rule; a Bash `command` or a Python `sorted()` is not a C struct member), dropped for a bare C call (`socket(...)`, a function-pointer parameter), and bound by owner for a C or C++ member call. Bare C++ calls and every other language are unchanged. Measured on this repository, old binary vs new: C/H Class nodes 155 -> 1000 C/H Field nodes 1047 -> 5478 enumerators of formerly anonymous enums +911 Variable nodes USAGE onto the new types, enums and constants +23k edges USAGE -> Field from .c, owner type named in the file 58.1% -> 97.6% USAGE onto a non-Field, name only used as a member 146 -> 0 WRITES -> Field from .c 2981 -> 6534 CALLS 159 removed, 3 added index wall time unchanged within noise; peak footprint +2.8% full macOS suite: 8309 passed, 0 failed, 10 skipped Tests: extract_c_anonymous_typedef_aggregate_is_named_by_its_typedef, pipeline_c_member_access_binds_field_by_object_type (+ _parallel), call_onto_field_follows_language_and_shape, c_member_access_binds_by_owner_only. Each was RED on the unchanged tree; the pipeline tests are RED again with the call policy neutralized. Not in this change: a function-pointer member declared inline (`void (*f)(int);`) has no Field node; a `p->f()` call is not resolved (the registry splits on `.` and `::` only); inline functions in `.h` files keep the bare-name member binding; designated initializers are not typed. Signed-off-by: Martin Vogel --- internal/cbm/cbm.h | 4 + internal/cbm/extract_defs.c | 21 +++ internal/cbm/lsp/c_lsp.c | 282 ++++++++++++++++++++++++++++------ internal/cbm/lsp/c_lsp.h | 9 ++ src/pipeline/lsp_resolve.h | 161 +++++++++++++++++++ src/pipeline/pass_calls.c | 20 +++ src/pipeline/pass_lsp_cross.c | 28 ++-- src/pipeline/pass_parallel.c | 136 +++++++++++++++- src/pipeline/pass_usages.c | 52 +++++++ src/pipeline/pipeline.h | 17 ++ src/pipeline/registry.c | 46 ++++++ tests/test_extraction.c | 47 ++++++ tests/test_pipeline.c | 252 ++++++++++++++++++++++++++++++ tests/test_registry.c | 49 ++++++ 14 files changed, 1065 insertions(+), 59 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index fb5e446a5..53d356c1c 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -418,6 +418,10 @@ typedef struct { typedef enum { CBM_RESOLVED_INVOCATION = 0, CBM_RESOLVED_CALL_REFERENCE, + /* C-family member access `a.b` / `a->b`: callee_qn is the Field `b` of the + * type of `a`. One row per (caller, field), not per occurrence, and no site + * span: the join is by enclosing function and member name. */ + CBM_RESOLVED_FIELD_REFERENCE, } CBMResolvedKind; // LSP-resolved invocation/reference: high-confidence type-aware resolution. diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b655d06c1..d22558c4e 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -4708,6 +4708,27 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } break; } + case CBM_LANG_C: + case CBM_LANG_CPP: { // `typedef struct { … } Name;`: the aggregate is + // anonymous and the typedef's declarator names it. + // A pointer/array/function declarator names another + // type, not the aggregate, so only a plain + // type_identifier counts. + if (strcmp(kind, "struct_specifier") != 0 && strcmp(kind, "union_specifier") != 0 && + strcmp(kind, "enum_specifier") != 0) { + break; + } + TSNode parent = ts_node_parent(node); + if (ts_node_is_null(parent) || strcmp(ts_node_type(parent), "type_definition") != 0) { + break; + } + TSNode declarator = ts_node_child_by_field_name(parent, TS_FIELD("declarator")); + if (!ts_node_is_null(declarator) && + strcmp(ts_node_type(declarator), "type_identifier") == 0) { + name_node = declarator; + } + break; + } default: break; } diff --git a/internal/cbm/lsp/c_lsp.c b/internal/cbm/lsp/c_lsp.c index 74b97d69d..1d90bbffa 100644 --- a/internal/cbm/lsp/c_lsp.c +++ b/internal/cbm/lsp/c_lsp.c @@ -2651,12 +2651,14 @@ static uint64_t c_neg_memo_hash(const char *type_qn, const char *member_name) { return h ? h : 1ULL; } -static bool c_neg_memo_contains(const CLSPContext *ctx, uint64_t h) { - if (!ctx->neg_memo || ctx->neg_memo_cap == 0) +// Open-addressing uint64 hash set, 0 = empty slot. Shared by the negative-lookup +// memo and the emitted-field-reference set; both are malloc-owned by the context. +static bool c_u64_set_contains(const uint64_t *table, int cap, uint64_t h) { + if (!table || cap == 0) return false; - uint64_t mask = (uint64_t)ctx->neg_memo_cap - 1; + uint64_t mask = (uint64_t)cap - 1; for (uint64_t i = h & mask;; i = (i + 1) & mask) { - uint64_t slot = ctx->neg_memo[i]; + uint64_t slot = table[i]; if (slot == 0) return false; // empty slot → not present if (slot == h) @@ -2664,23 +2666,24 @@ static bool c_neg_memo_contains(const CLSPContext *ctx, uint64_t h) { } } -static void c_neg_memo_insert(CLSPContext *ctx, uint64_t h) { - // Lazy alloc / grow-by-rehash at 70% load. On OOM, silently disable the memo - // (correctness is unaffected — the full cascade still runs). - if (ctx->neg_memo == NULL) { +// Returns true when h was not in the set before this call. Lazy alloc / +// grow-by-rehash at 70% load. On OOM the set stays as it is and h counts as new: +// every caller is correct without the set, it only saves repeated work. +static bool c_u64_set_insert(uint64_t **table, int *cap, int *count, uint64_t h) { + if (*table == NULL) { uint64_t *nm = (uint64_t *)calloc(1024, sizeof(uint64_t)); if (!nm) - return; - ctx->neg_memo = nm; - ctx->neg_memo_cap = 1024; - ctx->neg_memo_count = 0; - } else if ((ctx->neg_memo_count + 1) * 10 >= ctx->neg_memo_cap * 7) { - int new_cap = ctx->neg_memo_cap * 2; + return true; + *table = nm; + *cap = 1024; + *count = 0; + } else if ((*count + 1) * 10 >= *cap * 7) { + int new_cap = *cap * 2; uint64_t *nm = (uint64_t *)calloc((size_t)new_cap, sizeof(uint64_t)); if (nm) { uint64_t nmask = (uint64_t)new_cap - 1; - for (int j = 0; j < ctx->neg_memo_cap; j++) { - uint64_t v = ctx->neg_memo[j]; + for (int j = 0; j < *cap; j++) { + uint64_t v = (*table)[j]; if (v == 0) continue; uint64_t k = v & nmask; @@ -2688,30 +2691,44 @@ static void c_neg_memo_insert(CLSPContext *ctx, uint64_t h) { k = (k + 1) & nmask; nm[k] = v; } - free(ctx->neg_memo); - ctx->neg_memo = nm; - ctx->neg_memo_cap = new_cap; + free(*table); + *table = nm; + *cap = new_cap; } // if calloc failed: keep the existing table (load may exceed 70%, still correct) } - uint64_t mask = (uint64_t)ctx->neg_memo_cap - 1; + uint64_t mask = (uint64_t)*cap - 1; for (uint64_t i = h & mask;; i = (i + 1) & mask) { - if (ctx->neg_memo[i] == 0) { - ctx->neg_memo[i] = h; - ctx->neg_memo_count++; - return; + if ((*table)[i] == 0) { + (*table)[i] = h; + (*count)++; + return true; } - if (ctx->neg_memo[i] == h) - return; // already recorded + if ((*table)[i] == h) + return false; // already recorded } } +static bool c_neg_memo_contains(const CLSPContext *ctx, uint64_t h) { + return c_u64_set_contains(ctx->neg_memo, ctx->neg_memo_cap, h); +} + +static void c_neg_memo_insert(CLSPContext *ctx, uint64_t h) { + // On OOM the memo is silently disabled (correctness is unaffected — the full + // cascade still runs). + (void)c_u64_set_insert(&ctx->neg_memo, &ctx->neg_memo_cap, &ctx->neg_memo_count, h); +} + +static void c_u64_set_free(uint64_t **table, int *cap, int *count) { + free(*table); + *table = NULL; + *cap = 0; + *count = 0; +} + static void c_neg_memo_free(CLSPContext *ctx) { - if (ctx->neg_memo) - free(ctx->neg_memo); - ctx->neg_memo = NULL; - ctx->neg_memo_cap = 0; - ctx->neg_memo_count = 0; + c_u64_set_free(&ctx->neg_memo, &ctx->neg_memo_cap, &ctx->neg_memo_count); + c_u64_set_free(&ctx->field_ref_seen, &ctx->field_ref_seen_cap, &ctx->field_ref_seen_count); } static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const char *type_qn, @@ -2934,6 +2951,100 @@ static const CBMType *c_lookup_field_type(CLSPContext *ctx, const char *type_qn, return NULL; } +// True when qn is `prefix` followed by a '.' segment boundary. +static bool c_qn_in_module(const char *qn, const char *prefix) { + size_t plen = prefix ? strlen(prefix) : 0; + return plen > 0 && strncmp(qn, prefix, plen) == 0 && qn[plen] == '.'; +} + +// The registered type a member access is made on: as written, then +// module-qualified, then by short name -- a C use site names `struct Alpha` +// while the struct is registered under the header that declares it. +// +// A short name several modules declare is taken from this module, else from a +// directly included header, else only when it is the single candidate. Two +// same-named structs with no evidence between them resolve to NOTHING: binding +// a field of the wrong struct is the defect this lookup exists to remove. +static const CBMRegisteredType *c_resolve_field_owner_type(CLSPContext *ctx, const char *type_qn) { + const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, type_qn); + if (!rt && ctx->module_qn) { + char sbuf[1024]; + const char *prefixed = c_build_module_prefixed(ctx, type_qn, sbuf, sizeof(sbuf)); + if (!prefixed) + prefixed = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, type_qn); + rt = cbm_registry_lookup_type(ctx->registry, prefixed); + } + if (rt || !ctx->registry) + return rt; + + const char *dot = strrchr(type_qn, '.'); + const char *shortn = dot ? dot + 1 : type_qn; + size_t slen = strlen(shortn); + const CBMRegisteredType *included = NULL; + const CBMRegisteredType *only = NULL; + bool included_ambiguous = false; + bool only_ambiguous = false; + CBMTypeShortIter it; + cbm_registry_types_by_short_name_chain(ctx->registry, shortn, &it); + int i; + while ((i = cbm_type_short_iter_next(&it)) >= 0) { + const CBMRegisteredType *cand = &it.reg->types[i]; + const char *q = cand->qualified_name; + size_t qlen = q ? strlen(q) : 0; + if (qlen <= slen + 1 || q[qlen - slen - 1] != '.' || strcmp(q + qlen - slen, shortn) != 0) + continue; + if (c_qn_in_module(q, ctx->module_qn)) + return cand; + if (only && strcmp(only->qualified_name, q) != 0) + only_ambiguous = true; + only = cand; + for (int inc = 0; inc < ctx->include_count; inc++) { + if (!c_qn_in_module(q, ctx->include_ns_qns[inc])) + continue; + if (included && strcmp(included->qualified_name, q) != 0) + included_ambiguous = true; + included = cand; + break; + } + } + if (included) + return included_ambiguous ? NULL : included; + return only_ambiguous ? NULL : only; +} + +// The registered type that DECLARES field_name, reached from type_qn through +// aliases and base classes as c_lookup_field_type walks them. A field whose +// type text did not parse still has an owner, so only the name decides. +static const CBMRegisteredType *c_lookup_field_owner(CLSPContext *ctx, const char *type_qn, + const char *field_name, int depth) { + if (!type_qn || !field_name || depth > 5) + return NULL; + + const CBMRegisteredType *rt = c_resolve_field_owner_type(ctx, type_qn); + if (!rt) + return NULL; + + if (rt->alias_of) + return c_lookup_field_owner(ctx, rt->alias_of, field_name, depth + 1); + + if (rt->field_names) { + for (int i = 0; rt->field_names[i]; i++) { + if (strcmp(rt->field_names[i], field_name) == 0) + return rt; + } + } + + if (rt->embedded_types) { + for (int i = 0; rt->embedded_types[i]; i++) { + const CBMRegisteredType *owner = + c_lookup_field_owner(ctx, rt->embedded_types[i], field_name, depth + 1); + if (owner) + return owner; + } + } + return NULL; +} + // ============================================================================ // c_process_statement: bind variables from statements // ============================================================================ @@ -3871,6 +3982,79 @@ static void c_emit_resolved_reference_at(CLSPContext *ctx, const char *callee_qn cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, resolved); } +// FNV-1a over (caller, type, field) with 0xff separators; 0 is remapped to 1 +// because it is the set's empty-slot sentinel. +static uint64_t c_field_ref_hash(const char *caller_qn, const char *type_qn, + const char *field_name) { + const char *parts[] = {caller_qn, type_qn, field_name}; + uint64_t h = 1469598103934665603ULL; + for (int part = 0; part < 3; part++) { + for (const unsigned char *p = (const unsigned char *)parts[part]; *p; p++) { + h ^= (uint64_t)*p; + h *= 1099511628211ULL; + } + h ^= (uint64_t)0xffu; + h *= 1099511628211ULL; + } + return h ? h : 1ULL; +} + +/* `a.b` / `a->b`: only the type of `a` says WHICH struct's `b` is meant. The + * usage and read/write resolvers see the member name alone, and a bare-name + * lookup of `b` returns whichever Field happens to be called `b`. Publish the + * owner here so they bind the member through its object's type. + * + * One row per (enclosing function, object type, field): the pipeline edge is + * function -> Field, so repeating the row for every occurrence would add a + * record per member access in the corpus and no edge. An object whose type is + * unknown, or whose type declares no such field, publishes nothing. */ +static void c_emit_field_reference(CLSPContext *ctx, TSNode node) { + if (!ctx->resolved_calls || !ctx->enclosing_func_qn) + return; + TSNode arg_node = ts_node_child_by_field_name(node, "argument", 8); + TSNode field_node = ts_node_child_by_field_name(node, "field", 5); + if (ts_node_is_null(arg_node) || ts_node_is_null(field_node) || + strcmp(ts_node_type(field_node), "field_identifier") != 0) + return; + char *field_name = c_node_text(ctx, field_node); + if (!field_name || !field_name[0]) + return; + + const CBMType *obj_type = c_eval_expr_type(ctx, arg_node); + if (cbm_type_is_unknown(obj_type)) + return; + bool is_arrow = false; + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc && !is_arrow; i++) { + TSNode child = ts_node_child(node, i); + is_arrow = !ts_node_is_named(child) && strcmp(ts_node_type(child), "->") == 0; + } + const char *type_qn = type_to_qn(c_simplify_type(ctx, obj_type, is_arrow)); + if (!type_qn) + return; + + /* Decided once per (function, type, field), hit or miss: the owner lookup + * below formats a module-qualified name on its way. */ + if (!c_u64_set_insert(&ctx->field_ref_seen, &ctx->field_ref_seen_cap, + &ctx->field_ref_seen_count, + c_field_ref_hash(ctx->enclosing_func_qn, type_qn, field_name))) + return; + + const CBMRegisteredType *owner = c_lookup_field_owner(ctx, type_qn, field_name, 0); + if (!owner || !owner->qualified_name) + return; + + CBMResolvedCall resolved = {0}; + resolved.caller_qn = ctx->enclosing_func_qn; + resolved.callee_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", owner->qualified_name, field_name); + resolved.strategy = "lsp_field_access"; + resolved.confidence = 0.95f; + resolved.kind = CBM_RESOLVED_FIELD_REFERENCE; + resolved.source_origin = ctx->source_origin; + if (resolved.callee_qn) + cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, resolved); +} + /* A direct identifier argument denotes a precise callable value only when its * nearest lexical binding is a tracked function pointer target, or when no * lexical value shadows one exact registered function. Conditional/composite @@ -3945,6 +4129,11 @@ static void c_resolve_calls_in_node_inner(CLSPContext *ctx, TSNode node) { // Process statements for scope building c_process_statement(ctx, node); + // --- Member access: publish which type owns the field --- + if (strcmp(kind, "field_expression") == 0) { + c_emit_field_reference(ctx, node); + } + // --- Resolve call expressions --- if (strcmp(kind, "call_expression") == 0) { c_resolve_callable_argument_references(ctx, node); @@ -5881,21 +6070,30 @@ static void c_register_lsp_defs(CBMArena *arena, CBMTypeRegistry *reg, const cha } } - // Field defs + // Field defs. Sized from the list itself: a fixed cap here would + // silently drop the later fields of a large struct, and with them + // every member-access edge onto those fields. if (d->field_defs) { + int fcap = 1; + for (const char *c = d->field_defs; *c; c++) { + if (*c == '|') + fcap++; + } + const char **fnarr = + (const char **)cbm_arena_alloc(arena, (fcap + 1) * sizeof(const char *)); + const CBMType **ftarr = + (const CBMType **)cbm_arena_alloc(arena, (fcap + 1) * sizeof(const CBMType *)); const char *fsrc = d->field_defs; - const char *fnames[64]; - const CBMType *ftypes[64]; int fcount = 0; - while (*fsrc && fcount < 63) { + while (fnarr && ftarr && *fsrc && fcount < fcap) { const char *sep = strchr(fsrc, '|'); const char *end = sep ? sep : fsrc + strlen(fsrc); char *pair = cbm_arena_strndup(arena, fsrc, end - fsrc); - char *colon = strchr(pair, ':'); + char *colon = pair ? strchr(pair, ':') : NULL; if (colon) { *colon = '\0'; - fnames[fcount] = pair; - ftypes[fcount] = c_parse_return_type_text( + fnarr[fcount] = pair; + ftarr[fcount] = c_parse_return_type_text( arena, colon + 1, d->def_module_qn ? d->def_module_qn : module_qn); fcount++; } @@ -5904,14 +6102,6 @@ static void c_register_lsp_defs(CBMArena *arena, CBMTypeRegistry *reg, const cha fsrc = sep + 1; } if (fcount > 0) { - const char **fnarr = - (const char **)cbm_arena_alloc(arena, (fcount + 1) * sizeof(const char *)); - const CBMType **ftarr = (const CBMType **)cbm_arena_alloc( - arena, (fcount + 1) * sizeof(const CBMType *)); - for (int j = 0; j < fcount; j++) { - fnarr[j] = fnames[j]; - ftarr[j] = ftypes[j]; - } fnarr[fcount] = NULL; ftarr[fcount] = NULL; rt.field_names = fnarr; diff --git a/internal/cbm/lsp/c_lsp.h b/internal/cbm/lsp/c_lsp.h index af81d5bbd..0cff48f49 100644 --- a/internal/cbm/lsp/c_lsp.h +++ b/internal/cbm/lsp/c_lsp.h @@ -63,6 +63,15 @@ typedef struct { int neg_memo_cap; // power-of-two; 0 until first insert int neg_memo_count; // live entries (grow by rehash at 70% load) + // Field references already emitted for this file, keyed by (enclosing + // function, owner type, field). The function -> Field edge needs one row + // per pair; a row per occurrence would cost a record for every member + // access in the corpus. Same uint64 hash-set shape and ownership as + // neg_memo; freed at end of c_lsp_process_file. + uint64_t *field_ref_seen; + int field_ref_seen_cap; + int field_ref_seen_count; + // Output CBMResolvedCallArray *resolved_calls; CBMSourceOrigin source_origin; // source buffer represented by emitted occurrence spans diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 666f9fd55..235f3f523 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -22,6 +22,7 @@ #define CBM_PIPELINE_LSP_RESOLVE_H #include "cbm.h" +#include "foundation/mem_core.h" /* field-owner index storage */ #include "graph_buffer/graph_buffer.h" #include "foundation/constants.h" @@ -882,6 +883,166 @@ static inline const CBMResolvedCall *cbm_pipeline_find_lsp_reference( return cbm_pipeline_find_lsp_reference_in_graph(arr, usage, allow_tail_match, NULL, NULL); } +/* ── C member access: bind a field through its owner ───────────────── + * + * The member half of `a.b` / `a->b` is not a name in any scope: only the type + * of `a` gives `b` a meaning. The C LSP publishes that as FIELD_REFERENCE rows, + * one per (enclosing function, owner field). The usage and read/write + * resolvers join them by enclosing function and member name, which is exactly + * the granularity of the function -> Field edge they emit. */ + +/* FIELD_REFERENCE rows sorted by (caller, member name, target). A NULL `rows` + * with a nonzero source array means the allocation failed and lookups scan the + * array instead, so no typed edge is ever lost to an index. */ +typedef struct { + const CBMResolvedCall **rows; + int count; +} cbm_pipeline_lsp_field_index_t; + +static inline bool cbm_pipeline_lsp_field_row_usable(const CBMResolvedCall *row) { + return row->kind == CBM_RESOLVED_FIELD_REFERENCE && row->caller_qn && row->callee_qn && + row->confidence >= CBM_LSP_CONFIDENCE_FLOOR; +} + +static inline int cbm_pipeline_lsp_field_key_cmp(const CBMResolvedCall *row, const char *caller_qn, + const char *member) { + int by_caller = strcmp(row->caller_qn, caller_qn); + return by_caller != 0 ? by_caller : strcmp(cbm_lsp_bare_segment(row->callee_qn), member); +} + +static inline int cbm_pipeline_lsp_field_row_cmp(const void *left_ptr, const void *right_ptr) { + const CBMResolvedCall *left = *(const CBMResolvedCall *const *)left_ptr; + const CBMResolvedCall *right = *(const CBMResolvedCall *const *)right_ptr; + int by_key = cbm_pipeline_lsp_field_key_cmp(left, right->caller_qn, + cbm_lsp_bare_segment(right->callee_qn)); + return by_key != 0 ? by_key : strcmp(left->callee_qn, right->callee_qn); +} + +/* False means only allocation failure; lookups then take the linear path. */ +static inline bool cbm_pipeline_lsp_field_index_build(const CBMResolvedCallArray *arr, + cbm_pipeline_lsp_field_index_t *index) { + index->rows = NULL; + index->count = 0; + int field_count = 0; + for (int i = 0; arr && i < arr->count; i++) { + if (cbm_pipeline_lsp_field_row_usable(&arr->items[i])) { + field_count++; + } + } + if (field_count == 0) { + return true; + } + const CBMResolvedCall **rows = (const CBMResolvedCall **)cbm_alloc( + CBM_MEM_CLASS_EXTRACT, (size_t)field_count * sizeof(*rows)); + if (!rows) { + return false; + } + int next = 0; + for (int i = 0; i < arr->count; i++) { + if (cbm_pipeline_lsp_field_row_usable(&arr->items[i])) { + rows[next++] = &arr->items[i]; + } + } + qsort(rows, (size_t)field_count, sizeof(*rows), cbm_pipeline_lsp_field_row_cmp); + index->rows = rows; + index->count = field_count; + return true; +} + +static inline void cbm_pipeline_lsp_field_index_free(cbm_pipeline_lsp_field_index_t *index) { + cbm_free(CBM_MEM_CLASS_EXTRACT, (void *)index->rows); + index->rows = NULL; + index->count = 0; +} + +/* Walks the owner rows of one (function, member name). `index` NULL selects + * the linear scan over `arr`. */ +typedef struct { + const cbm_pipeline_lsp_field_index_t *index; + const CBMResolvedCallArray *arr; + const char *caller_qn; + const char *member; + int next; +} cbm_pipeline_lsp_field_cursor_t; + +static inline cbm_pipeline_lsp_field_cursor_t cbm_pipeline_lsp_field_cursor( + const CBMResolvedCallArray *arr, const cbm_pipeline_lsp_field_index_t *index, + const char *caller_qn, const char *member) { + cbm_pipeline_lsp_field_cursor_t cursor = {index, arr, caller_qn, member, 0}; + if (index && caller_qn && member) { + int low = 0; + int high = index->count; + while (low < high) { + int middle = low + (high - low) / 2; + if (cbm_pipeline_lsp_field_key_cmp(index->rows[middle], caller_qn, member) < 0) { + low = middle + 1; + } else { + high = middle; + } + } + cursor.next = low; + } + return cursor; +} + +static inline const CBMResolvedCall *cbm_pipeline_lsp_field_next( + cbm_pipeline_lsp_field_cursor_t *cursor) { + if (!cursor->caller_qn || !cursor->member) { + return NULL; + } + if (cursor->index) { + if (cursor->next >= cursor->index->count) { + return NULL; + } + const CBMResolvedCall *row = cursor->index->rows[cursor->next]; + if (cbm_pipeline_lsp_field_key_cmp(row, cursor->caller_qn, cursor->member) != 0) { + return NULL; + } + cursor->next++; + return row; + } + while (cursor->arr && cursor->next < cursor->arr->count) { + const CBMResolvedCall *row = &cursor->arr->items[cursor->next++]; + if (cbm_pipeline_lsp_field_row_usable(row) && + cbm_pipeline_lsp_field_key_cmp(row, cursor->caller_qn, cursor->member) == 0) { + return row; + } + } + return NULL; +} + +/* The graph node of an owner row, or NULL when the owner is not a project + * Field (a libc struct, say). Exact QN only: a name fallback here would undo + * the typing the row carries. */ +static inline const cbm_gbuf_node_t *cbm_pipeline_lsp_field_node(const cbm_gbuf_t *gbuf, + const char *project_name, + const CBMResolvedCall *row) { + const cbm_gbuf_node_t *node = + cbm_pipeline_lsp_target_node_policy(gbuf, project_name, row->callee_qn, false, false); + return node && node->label && strcmp(node->label, "Field") == 0 ? node : NULL; +} + +/* The Field a C member call (`cb.close(ctx)`) lands on, decided by the type of + * its object: among the owner rows of (enclosing function, member name) that + * name a project Field, the one with the smallest QN, so the sequential scan + * and the sorted index agree. NULL when no owner is known. */ +static inline const cbm_gbuf_node_t *cbm_pipeline_c_member_call_field( + const CBMResolvedCallArray *arr, const cbm_pipeline_lsp_field_index_t *index, + const cbm_gbuf_t *gbuf, const char *project_name, const char *enclosing_func_qn, + const char *callee_text) { + cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( + arr, index, enclosing_func_qn, cbm_lsp_bare_segment(callee_text)); + const cbm_gbuf_node_t *best = NULL; + const CBMResolvedCall *owner; + while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + const cbm_gbuf_node_t *field = cbm_pipeline_lsp_field_node(gbuf, project_name, owner); + if (field && (!best || strcmp(field->qualified_name, best->qualified_name) < 0)) { + best = field; + } + } + return best; +} + /* Resolve an LSP-emitted callee_qn to a graph-buffer node. * * Per-file LSPs sometimes emit `callee_qn` as the raw package-shaped diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 4d4d8fe03..d5e87ea53 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -873,6 +873,26 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM if (!target_node || source_node->id == target_node->id) { return 0; } + /* A call that resolved onto a struct Field: refused across languages and + * for a bare C call; a C/C++ member call takes the Field its object's type + * names instead of the one the member name happened to find. */ + switch (cbm_call_onto_field_policy(lang, call->callee_name, target_node->label, + target_node->file_path)) { + case CBM_FIELD_CALL_DROP: + return 0; + case CBM_FIELD_CALL_BY_OWNER: + target_node = + cbm_pipeline_c_member_call_field(lsp_calls, NULL, ctx->gbuf, ctx->project_name, + call->enclosing_func_qn, call->callee_name); + if (!target_node || source_node->id == target_node->id) { + return 0; + } + res.qualified_name = target_node->qualified_name; + res.strategy = "lsp_field_access"; + break; + case CBM_FIELD_CALL_KEEP: + break; + } /* #725: suffix_match is language-agnostic and will attach a Python * Store.commit() call to a JS function named commit (or a Bash main * to a Python main). Drop that weak cross-language edge. */ diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 3b74cc1eb..5c823a44d 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -462,24 +462,28 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch return 0; } -/* Go: fold per-field "Field" definitions into their owning struct's +/* Go and C: fold per-field "Field" definitions into their owning type's * field_defs. extract_defs.c emits one flat CBMDefinition per struct field * (label "Field", parent_class = owning struct QN, name = field name, * return_type = raw type text). Those rows are dropped by pxc_build_lsp_def - * (pxc_map_label excludes "Field"), so without this fold every Go struct - * registers with zero fields and field-chain calls (h.svc.Handle) can - * never resolve. Fields are always declared in the same file as their struct, - * so scanning the file's own defs covers every case. Runs inside + * (pxc_map_label excludes "Field"), so without this fold every struct + * registers with zero fields: Go field-chain calls (h.svc.Handle) can never + * resolve, and a C member access on a struct from another file (every header) + * has no owner to bind. Fields are always declared in the same file as their + * struct, so scanning the file's own defs covers every case. Runs inside * cbm_pxc_collect_all_defs — one site covers both the prebuilt-registry path - * and the per-file fallback, since both consume all_defs. */ -static void pxc_fold_go_struct_fields(CBMArena *arena, const CBMFileResult *result, CBMLSPDef *defs, - int start, int end) { + * and the per-file fallback, since both consume all_defs. + * + * type_label is the label the language's aggregate carries: "Struct" for Go, + * "Class" for a C struct or union. */ +static void pxc_fold_struct_fields(CBMArena *arena, const CBMFileResult *result, CBMLSPDef *defs, + int start, int end, const char *type_label) { if (!arena || !result || !defs || start >= end) { return; } for (int si = start; si < end; si++) { CBMLSPDef *dst = &defs[si]; - if (!dst->label || strcmp(dst->label, "Struct") != 0 || !dst->qualified_name) { + if (!dst->label || strcmp(dst->label, type_label) != 0 || !dst->qualified_name) { continue; } int count = 0; @@ -636,7 +640,11 @@ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMArena *are } cbm_pxc_free_import_map(imp_keys, imp_vals, imp_count); /* NULL-safe */ if (files[fi].language == CBM_LANG_GO) { - pxc_fold_go_struct_fields(arena, fr, defs, file_start, idx); + pxc_fold_struct_fields(arena, fr, defs, file_start, idx, "Struct"); + } else if (files[fi].language == CBM_LANG_C || files[fi].language == CBM_LANG_CPP) { + /* Both: a `.h` file is classified C++, and that is where C declares + * the structs its `.c` files access. */ + pxc_fold_struct_fields(arena, fr, defs, file_start, idx, "Class"); } if (files[fi].language == CBM_LANG_RUST) { for (int ii = 0; ii < fr->impl_traits.count; ii++) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 9df46c1af..86e725242 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2893,6 +2893,10 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } } + /* Field-owner rows, indexed on the first C member call that needs them. */ + cbm_pipeline_lsp_field_index_t field_index = {0}; + bool field_index_built = false; + bool field_index_ready = false; for (int c = 0; c < result->calls.count; c++) { CBMCall *call = &result->calls.items[c]; if (!call->callee_name) { @@ -3137,6 +3141,31 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } atomic_fetch_add_explicit(&rc->time_ns_rc_target, extract_now_ns() - _rc_t0, memory_order_relaxed); + /* A call that resolved onto a struct Field: refused across languages + * and for a bare C call; a C/C++ member call takes the Field its + * object's type names. Mirrors the sequential twin (pass_calls.c). */ + if (target_node && source_node->id != target_node->id) { + cbm_field_call_policy_t field_policy = cbm_call_onto_field_policy( + lang, call->callee_name, target_node->label, target_node->file_path); + if (field_policy == CBM_FIELD_CALL_DROP) { + continue; + } + if (field_policy == CBM_FIELD_CALL_BY_OWNER) { + if (!field_index_built) { + field_index_ready = + cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index); + field_index_built = true; + } + target_node = cbm_pipeline_c_member_call_field( + &result->resolved_calls, field_index_ready ? &field_index : NULL, rc->main_gbuf, + rc->project_name, call->enclosing_func_qn, call->callee_name); + if (!target_node || source_node->id == target_node->id) { + continue; + } + res.qualified_name = target_node->qualified_name; + res.strategy = "lsp_field_access"; + } + } if (target_node && source_node->id != target_node->id && cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { /* #725: same guard as pass_calls.c — do not emit a suffix_match @@ -3180,6 +3209,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB cbm_ht_foreach(lsp_legacy_idx, lsp_idx_free_key, NULL); cbm_ht_free(lsp_legacy_idx); } + cbm_pipeline_lsp_field_index_free(&field_index); } /* Resolve usages for one file. */ @@ -3190,6 +3220,9 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, cbm_pipeline_lsp_reference_index_t reference_index = {0}; bool reference_index_ready = cbm_pipeline_lsp_reference_index_build(&result->resolved_calls, &reference_index); + cbm_pipeline_lsp_field_index_t field_index = {0}; + bool field_index_ready = + cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index); for (int u = 0; u < result->usages.count; u++) { CBMUsage *usage = &result->usages.items[u]; if (!usage->ref_name) { @@ -3200,6 +3233,30 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (!src) { continue; } + /* A C member name binds through the type of its object, published by + * the C LSP as field-owner rows, and through nothing else. Must mirror + * the sequential twin (pass_usages.c) exactly. */ + if (usage->kind == CBM_USAGE_VALUE && + cbm_c_member_binds_by_owner(lang == CBM_LANG_C, usage->is_member_access)) { + cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( + &result->resolved_calls, field_index_ready ? &field_index : NULL, + usage->enclosing_func_qn, usage->ref_name); + const CBMResolvedCall *owner; + while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + const cbm_gbuf_node_t *field = + cbm_pipeline_lsp_field_node(rc->main_gbuf, rc->project_name, owner); + if (!field || field->id == src->id) { + continue; + } + char member_esc[CBM_SZ_256]; + cbm_json_escape(member_esc, sizeof(member_esc), usage->ref_name); + char member_props[CBM_SZ_512]; + snprintf(member_props, sizeof(member_props), "{\"callee\":\"%s\"}", member_esc); + cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, "USAGE", member_props); + ws->usages_resolved++; + } + continue; + } const cbm_gbuf_node_t *tgt = NULL; bool precise_call_reference = false; const CBMResolvedCall *semantic_reference = NULL; @@ -3278,6 +3335,7 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, ws->usages_resolved++; } cbm_pipeline_lsp_reference_index_free(&reference_index); + cbm_pipeline_lsp_field_index_free(&field_index); } /* Resolve throws/raises for one file. */ @@ -3315,6 +3373,9 @@ static void resolve_file_throws(resolve_ctx_t *rc, resolve_worker_state_t *ws, static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result, const char *rel, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, CBMLanguage lang) { + cbm_pipeline_lsp_field_index_t field_index = {0}; + bool field_index_ready = + cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index); for (int r = 0; r < result->rw.count; r++) { CBMReadWrite *rw = &result->rw.items[r]; if (!rw->var_name) { @@ -3325,6 +3386,24 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi if (!src) { continue; } + /* C member read/write: same owner join as resolve_file_usages. Mirrors + * the sequential twin (pass_usages.c). */ + if (cbm_c_member_binds_by_owner(lang == CBM_LANG_C, rw->is_member_access)) { + cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( + &result->resolved_calls, field_index_ready ? &field_index : NULL, + rw->enclosing_func_qn, rw->var_name); + const CBMResolvedCall *owner; + while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + const cbm_gbuf_node_t *field = + cbm_pipeline_lsp_field_node(rc->main_gbuf, rc->project_name, owner); + if (!field || field->id == src->id) { + continue; + } + cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, + rw->is_write ? "WRITES" : "READS", "{}"); + } + continue; + } cbm_resolution_t res = cbm_registry_resolve(rc->registry, rw->var_name, module_qn, imp_keys, imp_vals, imp_count); if (!res.qualified_name || res.qualified_name[0] == '\0') { @@ -3348,6 +3427,7 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi const char *etype = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}"); } + cbm_pipeline_lsp_field_index_free(&field_index); } /* Resolve base_classes → INHERITS edges for one definition. */ @@ -3564,6 +3644,54 @@ static bool pp_has_pending_lsp_site(const CBMFileResult *result) { return false; } +/* A C member access binds only through a field-owner row. The per-file walk + * publishes owners for the structs its own file declares; a struct from a + * header is known only to the cross-file pass. A member access that still has + * no owner is therefore a request for that pass, exactly like an unqualified + * call site -- without it a file with no pending call would keep none of its + * edges onto header-declared fields. */ +static bool pp_has_unowned_c_member(const CBMFileResult *result, CBMLanguage lang) { + if (lang != CBM_LANG_C) { + return false; + } + cbm_pipeline_lsp_field_index_t field_index = {0}; + bool ready = cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index); + bool unowned = false; + for (int i = 0; i < result->usages.count && !unowned; i++) { + const CBMUsage *usage = &result->usages.items[i]; + if (usage->kind != CBM_USAGE_VALUE || !usage->is_member_access || !usage->ref_name) { + continue; + } + cbm_pipeline_lsp_field_cursor_t owners = + cbm_pipeline_lsp_field_cursor(&result->resolved_calls, ready ? &field_index : NULL, + usage->enclosing_func_qn, usage->ref_name); + unowned = cbm_pipeline_lsp_field_next(&owners) == NULL; + } + for (int i = 0; i < result->rw.count && !unowned; i++) { + const CBMReadWrite *rw = &result->rw.items[i]; + if (!rw->is_member_access || !rw->var_name) { + continue; + } + cbm_pipeline_lsp_field_cursor_t owners = + cbm_pipeline_lsp_field_cursor(&result->resolved_calls, ready ? &field_index : NULL, + rw->enclosing_func_qn, rw->var_name); + unowned = cbm_pipeline_lsp_field_next(&owners) == NULL; + } + for (int i = 0; i < result->calls.count && !unowned; i++) { + const CBMCall *call = &result->calls.items[i]; + if (!call->callee_name || + (!strchr(call->callee_name, '.') && !strstr(call->callee_name, "->"))) { + continue; + } + cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( + &result->resolved_calls, ready ? &field_index : NULL, call->enclosing_func_qn, + cbm_lsp_bare_segment(call->callee_name)); + unowned = cbm_pipeline_lsp_field_next(&owners) == NULL; + } + cbm_pipeline_lsp_field_index_free(&field_index); + return unowned; +} + static void resolve_worker(int worker_id, void *ctx_ptr) { resolve_ctx_t *rc = ctx_ptr; resolve_worker_state_t *ws = &rc->workers[worker_id]; @@ -3651,11 +3779,13 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { int semantic_sites = result->calls.count + call_reference_sites; int qualified_lsp_sites = pp_qualified_lsp_site_count(result); bool pending_lsp_site = pp_has_pending_lsp_site(result); + bool unowned_c_member = pp_has_unowned_c_member(result, lang); bool cross_lsp_eligible = (rc->all_defs && rc->def_count > 0 && cbm_pxc_has_cross_lsp(lang) && - (semantic_sites > 0 || pending_lsp_site) && - (jvm_cross_lsp || rust_workspace_cross_lsp || pending_lsp_site || - qualified_lsp_sites < semantic_sites) && + (((semantic_sites > 0 || pending_lsp_site) && + (jvm_cross_lsp || rust_workspace_cross_lsp || pending_lsp_site || + qualified_lsp_sites < semantic_sites)) || + unowned_c_member) && !is_generated); /* Skip files with nothing else to resolve and no cross-LSP work. */ diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 9738f7198..9944952b9 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -151,6 +151,9 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res cbm_pipeline_lsp_reference_index_t reference_index = {0}; bool reference_index_ready = cbm_pipeline_lsp_reference_index_build(&result->resolved_calls, &reference_index); + cbm_pipeline_lsp_field_index_t field_index = {0}; + bool field_index_ready = + cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index); for (int u = 0; u < result->usages.count; u++) { CBMUsage *usage = &result->usages.items[u]; if (!usage->ref_name) { @@ -161,6 +164,31 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res continue; } + /* A C member name binds through the type of its object, published by + * the C LSP as field-owner rows, and through nothing else: the bare + * member name must never reach the short-name registry below. */ + if (usage->kind == CBM_USAGE_VALUE && + cbm_c_member_binds_by_owner(lang == CBM_LANG_C, usage->is_member_access)) { + cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( + &result->resolved_calls, field_index_ready ? &field_index : NULL, + usage->enclosing_func_qn, usage->ref_name); + const CBMResolvedCall *owner; + while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + const cbm_gbuf_node_t *field = + cbm_pipeline_lsp_field_node(ctx->gbuf, ctx->project_name, owner); + if (!field || field->id == src->id) { + continue; + } + char member_esc[CBM_SZ_256]; + cbm_json_escape(member_esc, sizeof(member_esc), usage->ref_name); + char member_props[CBM_SZ_512]; + snprintf(member_props, sizeof(member_props), "{\"callee\":\"%s\"}", member_esc); + cbm_gbuf_insert_edge(ctx->gbuf, src->id, field->id, "USAGE", member_props); + resolved++; + } + continue; + } + const cbm_gbuf_node_t *tgt = NULL; bool precise_call_reference = false; const CBMResolvedCall *semantic_reference = NULL; @@ -242,6 +270,7 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res resolved++; } cbm_pipeline_lsp_reference_index_free(&reference_index); + cbm_pipeline_lsp_field_index_free(&field_index); return resolved; } @@ -284,6 +313,9 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, CBMLanguage lang) { int resolved = 0; + cbm_pipeline_lsp_field_index_t field_index = {0}; + bool field_index_ready = + cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index); for (int r = 0; r < result->rw.count; r++) { CBMReadWrite *rw = &result->rw.items[r]; if (!rw->var_name) { @@ -295,6 +327,25 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result continue; } + /* C member read/write: same owner join as resolve_usage_edges. */ + if (cbm_c_member_binds_by_owner(lang == CBM_LANG_C, rw->is_member_access)) { + cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( + &result->resolved_calls, field_index_ready ? &field_index : NULL, + rw->enclosing_func_qn, rw->var_name); + const CBMResolvedCall *owner; + while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + const cbm_gbuf_node_t *field = + cbm_pipeline_lsp_field_node(ctx->gbuf, ctx->project_name, owner); + if (!field || field->id == src->id) { + continue; + } + cbm_gbuf_insert_edge(ctx->gbuf, src->id, field->id, + rw->is_write ? "WRITES" : "READS", "{}"); + resolved++; + } + continue; + } + cbm_resolution_t res = cbm_registry_resolve(ctx->registry, rw->var_name, module_qn, imp_keys, imp_vals, imp_count); if (!res.qualified_name || res.qualified_name[0] == '\0') { @@ -320,6 +371,7 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}"); resolved++; } + cbm_pipeline_lsp_field_index_free(&field_index); return resolved; } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 0ec6d7872..4d0421ad9 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -358,6 +358,23 @@ bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target * unit-tested in test_registry.c. */ bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const char *target_label); +/* What to do with a call whose registry resolution landed on a Field node. + * Pure; unit-tested in test_registry.c. */ +typedef enum { + CBM_FIELD_CALL_KEEP = 0, /* not a Field target, or a language this rule leaves alone */ + CBM_FIELD_CALL_DROP, /* a Field can never be this call's target */ + CBM_FIELD_CALL_BY_OWNER, /* C/C++ member call: the object's type names the Field */ +} cbm_field_call_policy_t; +cbm_field_call_policy_t cbm_call_onto_field_policy(CBMLanguage caller_lang, const char *callee_text, + const char *target_label, + const char *target_file_path); + +/* True when a reference is the member half of a C `a.b` / `a->b`. Such a + * reference binds only through the C LSP's field-owner rows (the type of `a`), + * never through a bare-name lookup of `b`. C only. Pure; unit-tested in + * test_registry.c. */ +bool cbm_c_member_binds_by_owner(bool is_c, bool is_member_access); + /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index bdf6f5944..96f84f94b 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -811,6 +811,52 @@ bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const cha return strcmp(target_label, "Field") == 0; } +cbm_field_call_policy_t cbm_call_onto_field_policy(CBMLanguage caller_lang, const char *callee_text, + const char *target_label, + const char *target_file_path) { + /* The short-name registry holds Field nodes, so a call spelled like some + * struct member can resolve onto that Field. Three shapes are decided here: + * - a caller in another language: nothing but the spelling connects a + * Bash `command` or a Python `sorted()` to a C struct member. The + * unique_name exemption of the CALLS guard (#1572) is about functions; + * for a Field the #1928 reference rule applies, with its JS/TS and + * C/C++ family exemptions; + * - a bare C call (`socket(...)`, a function-pointer parameter + * `want(x)`): a bare C name is never a struct member; + * - a C or C++ member call (`cb.close(ctx)`, `text.size()`): the type of + * the object decides which struct's member is called, never the + * member name alone. The C LSP publishes owners in both modes. + * A bare C++ call keeps its resolution (a method body reaches its own + * members without a selector), and so does every other language. */ + if (!target_label || strcmp(target_label, "Field") != 0) { + return CBM_FIELD_CALL_KEEP; + } + if (cbm_suppress_cross_language_ref(caller_lang, target_file_path)) { + return CBM_FIELD_CALL_DROP; + } + if (caller_lang != CBM_LANG_C && caller_lang != CBM_LANG_CPP) { + return CBM_FIELD_CALL_KEEP; + } + if (callee_text && (strchr(callee_text, '.') || strstr(callee_text, "->"))) { + return CBM_FIELD_CALL_BY_OWNER; + } + return caller_lang == CBM_LANG_C ? CBM_FIELD_CALL_DROP : CBM_FIELD_CALL_KEEP; +} + +bool cbm_c_member_binds_by_owner(bool is_c, bool is_member_access) { + /* The member half of `a.b` / `a->b` is not a name in any C scope: only the + * type of `a` gives `b` a meaning. Handing the bare `b` to the short-name + * registry bound whichever Field (or function, or variable) was called + * `b` -- on this repository 9,150 USAGE and 2,810 WRITES edges onto + * Fields, 3,976 of the USAGE edges on a name several structs share. Such a + * reference binds only through the C LSP's field-owner rows. + * + * C-gated like the Go rule above: a C++ or Objective-C method body + * reaches its own members without a selector, and their member accesses + * keep the existing resolution until their LSPs publish owners too. */ + return is_c && is_member_access; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5a21fe3e2..4a6d5941d 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1069,6 +1069,52 @@ TEST(c_struct) { PASS(); } +/* `typedef struct { … } Name;` is the dominant C spelling of a type: the + * aggregate itself is anonymous and the typedef's declarator names it. Without + * that name the struct had no Class node and none of its members a Field node + * (37 of the 38 structs in internal/cbm/cbm.h), so a member access could only + * bind some OTHER struct's same-named field. */ +TEST(extract_c_anonymous_typedef_aggregate_is_named_by_its_typedef) { + CBMFileResult *r = extract("typedef struct {\n" + " int count;\n" + " const char *label;\n" + "} Tally;\n" + "\n" + "typedef union {\n" + " int as_int;\n" + " float as_float;\n" + "} Slot;\n" + "\n" + "typedef enum { SHADE_RED, SHADE_GREEN } Shade;\n" + "\n" + "typedef struct Named {\n" + " int id;\n" + "} Named;\n" + "\n" + "typedef struct {\n" + " int raw;\n" + "} *TallyRef;\n", + CBM_LANG_C, "t", "tally.h"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* RED before the fix: none of the three anonymous aggregates has a def. */ + ASSERT_TRUE(has_def(r, "Class", "Tally")); + ASSERT_TRUE(has_def_qn(r, "t.tally.Tally.count")); + ASSERT_TRUE(has_def_qn(r, "t.tally.Tally.label")); + ASSERT_TRUE(has_def(r, "Class", "Slot")); + ASSERT_TRUE(has_def_qn(r, "t.tally.Slot.as_int")); + ASSERT_TRUE(has_def_qn(r, "t.tally.Slot.as_float")); + ASSERT_TRUE(has_def_any(r, "Shade")); + /* A struct that names itself keeps exactly one def, under its own name. */ + ASSERT_EQ(count_defs_named(r, "Class", "Named"), 1); + ASSERT_TRUE(has_def_qn(r, "t.tally.Named.id")); + /* A pointer typedef names a pointer type, not the aggregate: no Class. */ + ASSERT_FALSE(has_def(r, "Class", "TallyRef")); + ASSERT_FALSE(has_def(r, "Field", "raw")); + cbm_free_result(r); + PASS(); +} + /* return_type of the first definition named `name`; NULL when there is no such * definition or it carries no return type. */ static const char *def_return_type(CBMFileResult *r, const char *name) { @@ -8934,6 +8980,7 @@ SUITE(extraction) { RUN_TEST(c_function_return_type_preserves_pointer_and_qualifier); RUN_TEST(c_function_return_type_plain_unchanged); RUN_TEST(c_struct); + RUN_TEST(extract_c_anonymous_typedef_aggregate_is_named_by_its_typedef); RUN_TEST(cpp_class); RUN_TEST(cpp_method_return_type_preserves_pointer_and_qualifier); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 03a5cad70..3e0abb38f 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -6745,6 +6745,256 @@ TEST(pipeline_go_bare_ref_never_binds_field_parallel) { PASS(); } +/* True when a node named src_name has an edge of edge_type to the Field + * `field` OWNED BY `owner` (qualified name ends in ".owner.field"). The + * name-only cross_file_edge_exists cannot tell two same-named fields apart, + * which is exactly what the C member-access probes below must do. */ +static bool field_edge_exists(cbm_store_t *s, const char *project, const char *src_name, + const char *owner, const char *field, const char *edge_type) { + char suffix[256]; + snprintf(suffix, sizeof(suffix), ".%s.%s", owner, field); + size_t suffix_len = strlen(suffix); + cbm_node_t *srcs = NULL; + cbm_node_t *tgts = NULL; + int sc = 0; + int tc = 0; + cbm_store_find_nodes_by_name(s, project, src_name, &srcs, &sc); + cbm_store_find_nodes_by_name(s, project, field, &tgts, &tc); + bool found = false; + for (int i = 0; i < sc && !found; i++) { + cbm_edge_t *edges = NULL; + int ec = 0; + cbm_store_find_edges_by_source_type(s, srcs[i].id, edge_type, &edges, &ec); + for (int j = 0; j < ec && !found; j++) { + for (int k = 0; k < tc && !found; k++) { + const char *qn = tgts[k].qualified_name; + size_t qn_len = qn ? strlen(qn) : 0; + found = edges[j].target_id == tgts[k].id && tgts[k].label && + strcmp(tgts[k].label, "Field") == 0 && qn_len >= suffix_len && + strcmp(qn + qn_len - suffix_len, suffix) == 0; + } + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + } + if (srcs) { + cbm_store_free_nodes(srcs, sc); + } + if (tgts) { + cbm_store_free_nodes(tgts, tc); + } + return found; +} + +/* Number of edges of edge_type that END on the Field `field` owned by `owner`, + * whatever their source: the call probes below must hold for a caller in any + * language, including a shell script whose source node has no stable name. */ +static int field_inbound_edge_count(cbm_store_t *s, const char *project, const char *owner, + const char *field, const char *edge_type) { + char suffix[256]; + snprintf(suffix, sizeof(suffix), ".%s.%s", owner, field); + size_t suffix_len = strlen(suffix); + cbm_node_t *tgts = NULL; + int tc = 0; + cbm_store_find_nodes_by_name(s, project, field, &tgts, &tc); + int total = 0; + for (int k = 0; k < tc; k++) { + const char *qn = tgts[k].qualified_name; + size_t qn_len = qn ? strlen(qn) : 0; + if (!tgts[k].label || strcmp(tgts[k].label, "Field") != 0 || qn_len < suffix_len || + strcmp(qn + qn_len - suffix_len, suffix) != 0) { + continue; + } + cbm_edge_t *edges = NULL; + int ec = 0; + cbm_store_find_edges_by_target_type(s, tgts[k].id, edge_type, &edges, &ec); + total += ec; + if (edges) { + cbm_store_free_edges(edges, ec); + } + } + if (tgts) { + cbm_store_free_nodes(tgts, tc); + } + return total; +} + +/* Fixture for the C member-access probes: two structs share the field name + * `count`, and each function touches exactly one of them. A bare-name lookup + * of `count` can only pick one Field for both functions; only the type of the + * object expression says which struct is meant. `use_opaque` reaches `count` + * through a type the project never defines, so no Field may be bound at all. + * + * Calls follow the same rule. The structs carry a function-pointer member + * `notify` (declared through a typedef: a member spelled `void (*notify)(int)` + * has no Field node today, a separate extraction gap). `use_remote` calls + * Beta's `notify` from a file that declares its own struct with a `notify` + * member -- the candidate a bare-name lookup prefers, being in the same + * module. `call_param` calls a PARAMETER named like the field `alpha_only`, + * and `tool.sh` runs a command of that name: neither calls a struct member. */ +static void write_c_member_field_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "alpha.h", + "#ifndef ALPHA_H\n" + "#define ALPHA_H\n" + "typedef void (*alpha_notify_fn)(int);\n" + "struct Alpha {\n" + " int count;\n" + " int alpha_only;\n" + " alpha_notify_fn notify;\n" + "};\n" + "#endif\n"); + write_temp_file(tmp, "beta.h", + "#ifndef BETA_H\n" + "#define BETA_H\n" + "typedef void (*beta_notify_fn)(int);\n" + "typedef struct {\n" + " int count;\n" + " int beta_only;\n" + " beta_notify_fn notify;\n" + "} Beta;\n" + "#endif\n"); + write_temp_file(tmp, "use_alpha.c", + "#include \"alpha.h\"\n" + "\n" + "int use_alpha(struct Alpha *a) {\n" + " a->count = 1;\n" + " return a->count + a->alpha_only;\n" + "}\n" + "\n" + "int call_param(int (*alpha_only)(void)) {\n" + " return alpha_only();\n" + "}\n"); + write_temp_file(tmp, "mix.c", + "#include \"beta.h\"\n" + "\n" + "typedef void (*local_notify_fn)(int);\n" + "struct Local {\n" + " local_notify_fn notify;\n" + "};\n" + "\n" + "void use_remote(Beta b) {\n" + " b.notify(3);\n" + "}\n"); + write_temp_file(tmp, "tool.sh", + "#!/bin/sh\n" + "alpha_only --check\n"); + write_temp_file(tmp, "use_beta.c", + "#include \"beta.h\"\n" + "\n" + "int use_beta(Beta b) {\n" + " b.count = 2;\n" + " return b.count + b.beta_only;\n" + "}\n"); + write_temp_file(tmp, "use_opaque.c", + "struct Opaque;\n" + "\n" + "int use_opaque(struct Opaque *o) {\n" + " return o->count;\n" + "}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.c", i); + snprintf(body, sizeof(body), "int filler%d(void) { return %d; }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +/* Shared assertions for the sequential and the parallel twin. */ +static int assert_c_member_fields_bind_by_type(cbm_store_t *s, const char *project) { + /* The probe means nothing unless both same-named fields exist. */ + ASSERT_GTE(fixture_node_count(s, project, "alpha.h", "count", "Field"), 1); + ASSERT_GTE(fixture_node_count(s, project, "beta.h", "count", "Field"), 1); + /* Reproduce-first: RED while `count` resolves by bare name — both + * functions then bind the SAME Field, so one of each pair is wrong. */ + ASSERT_TRUE(field_edge_exists(s, project, "use_alpha", "Alpha", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "use_alpha", "Beta", "count", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "use_beta", "Beta", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "use_beta", "Alpha", "count", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "use_alpha", "Alpha", "count", "WRITES")); + ASSERT_FALSE(field_edge_exists(s, project, "use_alpha", "Beta", "count", "WRITES")); + ASSERT_TRUE(field_edge_exists(s, project, "use_beta", "Beta", "count", "WRITES")); + ASSERT_FALSE(field_edge_exists(s, project, "use_beta", "Alpha", "count", "WRITES")); + /* A field whose name is unique keeps its edge. */ + ASSERT_TRUE(field_edge_exists(s, project, "use_alpha", "Alpha", "alpha_only", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "use_beta", "Beta", "beta_only", "USAGE")); + /* An object whose type the project never defines binds no Field. */ + ASSERT_FALSE(field_edge_exists(s, project, "use_opaque", "Alpha", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "use_opaque", "Beta", "count", "USAGE")); + /* A call through a function-pointer member lands on the member of its + * object's type, not on the same-named member its own file declares. */ + ASSERT_GTE(fixture_node_count(s, project, "mix.c", "notify", "Field"), 1); + ASSERT_TRUE(field_edge_exists(s, project, "use_remote", "Beta", "notify", "CALLS")); + ASSERT_FALSE(field_edge_exists(s, project, "use_remote", "Local", "notify", "CALLS")); + /* A bare C call of a parameter named `alpha_only` and a shell command of + * that name are not calls of the struct member, however unique the name. */ + ASSERT_EQ(field_inbound_edge_count(s, project, "Alpha", "alpha_only", "CALLS"), 0); + return 0; +} + +TEST(pipeline_c_member_access_binds_field_by_object_type) { + /* `x.f` / `x->f` handed only the member name `f` to the short-name + * registry, which returned whichever Field was called `f`: 9,150 USAGE + * and 2,810 WRITES edges onto Fields on this repository, 3,976 of the + * USAGE edges onto a name several structs share. Sequential-path twin. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_c_field_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_c_member_field_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/c_field.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = assert_c_member_fields_bind_by_type(s, project); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + if (rc != 0) { + return rc; + } + PASS(); +} + +TEST(pipeline_c_member_access_binds_field_by_object_type_parallel) { + /* Parallel twin: resolve_file_usages / resolve_file_rw are independent + * resolvers and must take the same typed join (#1928's lesson). */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_c_fieldp_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_c_member_field_fixture(tmp, 52); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/c_fieldp.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = assert_c_member_fields_bind_by_type(s, project); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + if (rc != 0) { + return rc; + } + PASS(); +} + /* Count nodes with the given exact name in the project (e.g. a Route path). */ static int count_nodes_named(cbm_store_t *s, const char *project, const char *name) { cbm_node_t *ns = NULL; @@ -16264,6 +16514,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_rust_std_receiver_never_binds_project_method_parallel); RUN_TEST(pipeline_go_bare_ref_never_binds_field); RUN_TEST(pipeline_go_bare_ref_never_binds_field_parallel); + RUN_TEST(pipeline_c_member_access_binds_field_by_object_type); + RUN_TEST(pipeline_c_member_access_binds_field_by_object_type_parallel); RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges); RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge); diff --git a/tests/test_registry.c b/tests/test_registry.c index f77b1460b..53895a29a 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1037,6 +1037,53 @@ TEST(go_bare_ref_never_binds_field) { PASS(); } +TEST(call_onto_field_follows_language_and_shape) { + /* A call the registry resolved onto a Field node. */ + /* Another language: only the spelling connects them. */ + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_BASH, "command", "Field", "src/probe.h"), + CBM_FIELD_CALL_DROP); + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_PYTHON, "sorted", "Field", "src/pool.c"), + CBM_FIELD_CALL_DROP); + /* A bare C call is never a struct member. */ + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_C, "socket", "Field", "tests/t.c"), + CBM_FIELD_CALL_DROP); + /* A C member call is decided by its object's type, in a .c or a .h. */ + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_C, "cb.close", "Field", "src/cb.h"), + CBM_FIELD_CALL_BY_OWNER); + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_C, "w->svc->app.cancel", "Field", "src/a.c"), + CBM_FIELD_CALL_BY_OWNER); + /* C++ reaches its own members bare, so a bare call keeps its resolution; + * a member call is typed like C's (`text.size()` is not some struct's + * `size` member). */ + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_CPP, "handler", "Field", "src/a.h"), + CBM_FIELD_CALL_KEEP); + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_CPP, "text.size", "Field", "src/a.h"), + CBM_FIELD_CALL_BY_OWNER); + /* The same language as the Field keeps its resolution too. */ + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_GO, "s.fn", "Field", "pkg/s.go"), + CBM_FIELD_CALL_KEEP); + /* Not a Field target: not this rule's business. */ + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_BASH, "main", "Function", "src/main.c"), + CBM_FIELD_CALL_KEEP); + ASSERT_EQ(cbm_call_onto_field_policy(CBM_LANG_C, "socket", NULL, "tests/t.c"), + CBM_FIELD_CALL_KEEP); + PASS(); +} + +TEST(c_member_access_binds_by_owner_only) { + /* The member half of a C `a.b` / `a->b` means nothing without the type of + * `a`: it binds through the C LSP's field-owner rows and never through a + * bare-name lookup. The selector shape arrives as is_member_access. */ + ASSERT_TRUE(cbm_c_member_binds_by_owner(true, true)); + /* A bare C identifier keeps the ordinary resolution. */ + ASSERT_FALSE(cbm_c_member_binds_by_owner(true, false)); + /* Other languages keep theirs: a C++ or Objective-C method body reaches its + * own members without a selector, and Go has its own rule above. */ + ASSERT_FALSE(cbm_c_member_binds_by_owner(false, true)); + ASSERT_FALSE(cbm_c_member_binds_by_owner(false, false)); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -1296,6 +1343,8 @@ SUITE(registry) { RUN_TEST(registry_tie_break_is_independent_of_registration_order); RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); + RUN_TEST(call_onto_field_follows_language_and_shape); + RUN_TEST(c_member_access_binds_by_owner_only); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); RUN_TEST(python_builtin_member_table_matches_builtin_type_methods); From 52ab4e5d4ce55119e9d414d6991119da40e166cb Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 01:10:56 +0200 Subject: [PATCH 17/50] fix(pkg): apply the Go wrapper's archive size limits to npm and PyPI The Go wrapper caps the compressed release download at 256 MiB and bounds extraction to 64 archive members, 256 MiB per member and 512 MiB expanded in total. The npm and PyPI wrappers had the download cap parameter but never passed it for the release archive, and extracted members without any size bound. Both wrappers now use the same four limits as named constants. The download rejects an over-limit Content-Length before writing (only plain ASCII digits count as a length), stops as soon as the received body exceeds the limit, and removes the partial file. The PyPI extractor checks the member count and the declared sizes before writing and copies each member through a counted loop that reads at most one byte past the limit and compares the actual size with the declared one. The npm tar path keeps the system tar listing and the exact four-name check as the member bound, then extracts only the executable through `tar -xzOf` piped into a file the wrapper creates itself, counting bytes: past the per-member limit the child is stopped and the file removed; a non-zero tar exit carries tar's own diagnostic text; an empty result is a failure. The file is removed only when this run created it, so a pre-existing target is never deleted. The companions are listed but never written. The PowerShell zip path counts entries and declared sizes and copies the executable through a counted loop instead of ExtractToFile, with its own failure text in the install error. Any limit hit fails with a message naming the limit and leaves no partial output behind. Tests cover an over-limit download (declared, actual, and non-numeric declared lengths), a member stopped in the counted writer, a pre-existing target left untouched, tar failing mid-stream, an empty member, too many members and over-limit declared sizes for zip, and a normal archive installing byte-identical with the executable bit set. The PowerShell cases run on Windows hosts only. Signed-off-by: Martin Vogel --- pkg/npm/install.js | 260 +++++++++++++++-- pkg/npm/test/archive-fixtures.js | 113 ++++++++ pkg/npm/test/archive-limits.test.js | 348 +++++++++++++++++++++++ pkg/npm/test/package-publication.test.js | 45 ++- pkg/pypi/src/codebase_memory_mcp/_cli.py | 228 +++++++++++++-- pkg/pypi/tests/test_cli.py | 294 +++++++++++++++++++ 6 files changed, 1221 insertions(+), 67 deletions(-) create mode 100644 pkg/npm/test/archive-fixtures.js create mode 100644 pkg/npm/test/archive-limits.test.js diff --git a/pkg/npm/install.js b/pkg/npm/install.js index 9c86e9337..ada940f27 100644 --- a/pkg/npm/install.js +++ b/pkg/npm/install.js @@ -8,7 +8,7 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { execFileSync, spawn } = require('child_process'); const { pipeline } = require('stream'); const REPO = 'DeusData/codebase-memory-mcp'; @@ -18,6 +18,20 @@ const MAX_REDIRECTS = 5; const DOWNLOAD_HOP_TIMEOUT_MS = 120_000; const CANDIDATE_TIMEOUT_MS = 15_000; const MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024; +// Release archive resource limits. Keep in sync with the Go wrapper +// (pkg/go/cmd/codebase-memory-mcp/main.go) and the PyPI wrapper (_cli.py). +const MAX_RELEASE_ARCHIVE_BYTES = 256 * 1024 * 1024; +const MAX_ARCHIVE_MEMBERS = 64; +const MAX_ARCHIVE_MEMBER_BYTES = 256 * 1024 * 1024; +const MAX_ARCHIVE_EXPANDED_BYTES = 512 * 1024 * 1024; +const DEFAULT_ARCHIVE_LIMITS = Object.freeze({ + compressedBytes: MAX_RELEASE_ARCHIVE_BYTES, + members: MAX_ARCHIVE_MEMBERS, + memberBytes: MAX_ARCHIVE_MEMBER_BYTES, + expandedBytes: MAX_ARCHIVE_EXPANDED_BYTES, +}); +// Bytes of tar's own diagnostics kept for the failure message. +const MAX_TAR_STDERR_CHARS = 4096; const WINDOWS_BINARY_NAME = 'codebase-memory-mcp.exe'; const UNIX_ARCHIVE_NAMES = [ 'codebase-memory-mcp', @@ -90,21 +104,131 @@ function validateExactTarMemberListing(listing, expectedNames) { } } -function extractExactTarArchive( - archivePath, destPath, expectedNames, targetName, runFile = execFileSync, +function validateArchiveLimits(limits) { + if (!limits || !(limits.compressedBytes > 0) || !(limits.members > 0) || + !(limits.memberBytes > 0) || !(limits.expandedBytes > 0)) { + throw new Error('invalid archive resource safety limits'); + } +} + +function requireCompressedArchiveWithinLimit(archivePath, limits) { + const status = fs.statSync(archivePath); + if (!status.isFile()) { + throw new Error('release archive is not a regular file'); + } + if (status.size > limits.compressedBytes) { + throw new Error( + `release archive exceeds the ${limits.compressedBytes}-byte compressed safety limit`, + ); + } +} + +// Streams one member through `tar -xzOf` into a file this call creates, +// counting every byte against the per-member limit. The file is removed only +// when this call created it: a pre-existing target fails untouched. +function extractTarMemberWithLimit( + archivePath, target, memberName, limits, spawnFile = spawn, ) { + return new Promise((resolve, reject) => { + let fd; + try { + fd = fs.openSync(target, 'wx', 0o600); + } catch (err) { + reject(err); + return; + } + let written = 0; + let failure = null; + let settled = false; + let stderr = ''; + const finish = (err) => { + if (settled) return; + settled = true; + try { fs.closeSync(fd); } catch (closeError) { err = err || closeError; } + if (err) { + try { fs.unlinkSync(target); } catch (_) { /* already gone */ } + reject(err); + return; + } + resolve(written); + }; + const child = spawnFile('tar', ['-xzOf', archivePath, memberName], { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + const fail = (err) => { + if (failure) return; + failure = err; + child.kill(); + }; + child.stdout.on('data', (chunk) => { + if (failure) return; + written += chunk.length; + if (written > limits.memberBytes) { + fail(new Error( + `archive member ${JSON.stringify(memberName)} exceeds the ${limits.memberBytes}-byte expanded safety limit`, + )); + return; + } + try { + let offset = 0; + while (offset < chunk.length) { + offset += fs.writeSync(fd, chunk, offset, chunk.length - offset); + } + } catch (err) { + fail(err); + } + }); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (text) => { + if (stderr.length < MAX_TAR_STDERR_CHARS) stderr += text; + }); + child.on('error', (err) => { + failure = failure || err; + // A process that never started (no tar on PATH) has no exit to wait for. + if (child.pid === undefined) finish(failure); + }); + child.on('close', (code, signal) => { + const detail = stderr.trim(); + let err = failure; + if (!err && signal) { + err = new Error( + `tar was terminated by ${signal} while extracting ${memberName}: ${detail}`, + ); + } else if (!err && code !== 0) { + err = new Error( + `tar exited with status ${code} while extracting ${memberName}: ${detail}`, + ); + } else if (!err && written === 0) { + err = new Error(`archive member ${memberName} is empty`); + } + finish(err); + }); + }); +} + +async function extractExactTarArchive( + archivePath, destPath, expectedNames, targetName, + runFile = execFileSync, limits = DEFAULT_ARCHIVE_LIMITS, spawnFile = spawn, +) { + validateArchiveLimits(limits); + requireCompressedArchiveWithinLimit(archivePath, limits); + // The exact root namespace is the member bound: its four names lie well + // within MAX_ARCHIVE_MEMBERS, which the zip path enforces by count. + if (expectedNames.length > limits.members) { + throw new Error(`archive namespace exceeds the ${limits.members}-member safety limit`); + } const listing = runFile('tar', ['-tzf', archivePath], { encoding: 'utf8', maxBuffer: MAX_CHECKSUM_MANIFEST_BYTES, windowsHide: true, }); validateExactTarMemberListing(listing, expectedNames); - // Extract only the fixed root executable. Even a malformed tar implementation - // cannot write an unvalidated companion member outside the private temp tree. - runFile('tar', ['-xzf', archivePath, '-C', destPath, targetName], { - stdio: 'inherit', - windowsHide: true, - }); + // Only the fixed root executable is ever written, through the counted + // writer; the companions are listed but never extracted. + const target = path.join(destPath, targetName); + await extractTarMemberWithLimit(archivePath, target, targetName, limits, spawnFile); + fs.chmodSync(target, 0o755); } function downloadHop(url, dest, maxBytes) { @@ -123,7 +247,11 @@ function downloadHop(url, dest, maxBytes) { const req = https.get(url, (res) => { response = res; - res.on('error', (err) => finish(err)); + let piped = false; + // Before the body is piped, a response error ends the hop directly; + // afterwards the pipeline reports it, so the partial file is discarded + // before the hop settles. + res.on('error', (err) => { if (!piped) finish(err); }); const redirectCodes = new Set([301, 302, 303, 307, 308]); if (redirectCodes.has(res.statusCode)) { const location = res.headers.location; @@ -149,6 +277,12 @@ function downloadHop(url, dest, maxBytes) { finish(new Error(`HTTP ${res.statusCode} for ${url}`)); return; } + const declared = Number(res.headers['content-length']); + if (maxBytes && Number.isFinite(declared) && declared > maxBytes) { + res.destroy(); + finish(new Error(`Download exceeds the ${maxBytes}-byte safety limit`)); + return; + } let received = 0; res.on('data', (chunk) => { @@ -158,8 +292,20 @@ function downloadHop(url, dest, maxBytes) { } }); const file = fs.createWriteStream(dest, { flags: 'w' }); + piped = true; pipeline(res, file, (err) => { - finish(err, { redirect: null }); + if (!err) { + finish(null, { redirect: null }); + return; + } + // Discard the partial file once its handle is closed (Windows refuses + // to unlink an open file), then report the failure. + const discard = () => { + try { fs.unlinkSync(dest); } catch (_) { /* nothing was written */ } + finish(err); + }; + if (file.closed) discard(); + else file.once('close', discard); }); }); req.on('error', (err) => finish(err)); @@ -457,19 +603,37 @@ function installWindowsBinaryAtomically(sourceDir, destDir, verifier = verifyCan } } -function extractZipOnWindows(archivePath, destPath, requiredNames, extractNames) { +function extractZipOnWindows( + archivePath, destPath, requiredNames, extractNames, + limits = DEFAULT_ARCHIVE_LIMITS, shell = 'powershell', +) { + validateArchiveLimits(limits); + requireCompressedArchiveWithinLimit(archivePath, limits); // -EncodedCommand is a constant program. Paths travel only through the child // environment and are consumed with -LiteralPath, so PowerShell never parses // user/TEMP path bytes as source code or wildcard syntax. const script = [ "$ErrorActionPreference = 'Stop'", 'Add-Type -AssemblyName System.IO.Compression.FileSystem', + '$maxMembers = [int]$env:CBM_NPM_MAX_MEMBERS', + '$maxMemberBytes = [long]$env:CBM_NPM_MAX_MEMBER_BYTES', + '$maxExpandedBytes = [long]$env:CBM_NPM_MAX_EXPANDED_BYTES', + '$memberCount = 0', + '$declaredExpanded = [long]0', '$zip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)', "$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)", "$requiredNames = @($env:CBM_NPM_REQUIRED_NAMES.Split('|'))", '$targetCounts = @{}', 'foreach ($requiredName in $requiredNames) { $targetCounts[$requiredName] = 0 }', 'try { foreach ($entry in $zip.Entries) { ' + + '$memberCount = $memberCount + 1; ' + + 'if ($memberCount -gt $maxMembers) { ' + + 'throw "archive exceeds the $maxMembers-member safety limit" }; ' + + 'if ($entry.Length -gt $maxMemberBytes) { ' + + 'throw "archive member $($entry.FullName) exceeds the $maxMemberBytes-byte expanded safety limit" }; ' + + 'if ($declaredExpanded -gt ($maxExpandedBytes - $entry.Length)) { ' + + 'throw "archive exceeds the $maxExpandedBytes-byte aggregate expanded safety limit" }; ' + + '$declaredExpanded = $declaredExpanded + $entry.Length; ' + "$name = $entry.FullName.Replace('\\', '/'); " + "$directory = $name.EndsWith('/'); " + "$segmentsPath = if ($directory) { $name.TrimEnd('/') } else { $name }; " + @@ -491,27 +655,61 @@ function extractZipOnWindows(archivePath, destPath, requiredNames, extractNames) 'if ($seen.Count -ne $requiredNames.Count) { ' + 'throw "archive does not match the exact release root-file allowlist" }', "$extractNames = @($env:CBM_NPM_EXTRACT_NAMES.Split('|'))", + '$actualExpanded = [long]0', + '$buffer = New-Object byte[] 65536', '$extractZip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)', + // Copy by hand with a byte counter: ExtractToFile would write whatever the + // compressed stream yields, regardless of the declared size. 'try { foreach ($extractName in $extractNames) { ' + '$entry = @($extractZip.Entries | Where-Object { $_.FullName -ceq $extractName })[0]; ' + - '[System.IO.Compression.ZipFileExtensions]::ExtractToFile(' + - '$entry, (Join-Path $env:CBM_NPM_DEST_PATH $extractName), $false) ' + + '$targetPath = Join-Path $env:CBM_NPM_DEST_PATH $extractName; ' + + '$remaining = $maxExpandedBytes - $actualExpanded; ' + + '$copied = [long]0; ' + + '$source = $entry.Open(); ' + + '$target = [System.IO.File]::Open($targetPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write); ' + + 'try { while ($true) { ' + + '$count = $source.Read($buffer, 0, $buffer.Length); ' + + 'if ($count -le 0) { break }; ' + + '$copied = $copied + $count; ' + + 'if ($copied -gt $maxMemberBytes) { ' + + 'throw "archive member $extractName exceeds the $maxMemberBytes-byte actual expanded safety limit" }; ' + + 'if ($copied -gt $remaining) { ' + + 'throw "archive exceeds the $maxExpandedBytes-byte aggregate actual expanded safety limit" }; ' + + '$target.Write($buffer, 0, $count) } ' + + '} catch { $target.Dispose(); ' + + 'Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue; throw ' + + '} finally { $target.Dispose(); $source.Dispose() }; ' + + 'if ($copied -ne $entry.Length) { ' + + 'Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue; ' + + 'throw "archive member $extractName actual size $copied does not match declared size $($entry.Length)" }; ' + + '$actualExpanded = $actualExpanded + $copied ' + '} } finally { $extractZip.Dispose() }', ].join('; '); const encoded = Buffer.from(script, 'utf16le').toString('base64'); - execFileSync('powershell', [ - '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded, - ], { - env: { - ...process.env, - CBM_NPM_ARCHIVE_PATH: archivePath, - CBM_NPM_DEST_PATH: destPath, - CBM_NPM_REQUIRED_NAMES: requiredNames.join('|'), - CBM_NPM_EXTRACT_NAMES: extractNames.join('|'), - }, - stdio: 'inherit', - windowsHide: true, - }); + try { + execFileSync(shell, [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded, + ], { + env: { + ...process.env, + CBM_NPM_ARCHIVE_PATH: archivePath, + CBM_NPM_DEST_PATH: destPath, + CBM_NPM_REQUIRED_NAMES: requiredNames.join('|'), + CBM_NPM_EXTRACT_NAMES: extractNames.join('|'), + CBM_NPM_MAX_MEMBERS: String(limits.members), + CBM_NPM_MAX_MEMBER_BYTES: String(limits.memberBytes), + CBM_NPM_MAX_EXPANDED_BYTES: String(limits.expandedBytes), + }, + encoding: 'utf8', + stdio: ['ignore', 'inherit', 'pipe'], + windowsHide: true, + }); + } catch (err) { + // Surface the script's own reason (for example which limit was hit) + // instead of a bare "Command failed". + const detail = err && err.stderr ? String(err.stderr).trim() : ''; + throw new Error(detail ? `zip extraction failed: ${detail}` : err.message); + } } // Fetch checksums.txt and verify the archive hash. @@ -592,12 +790,12 @@ async function main() { const tmpArchive = path.join(tmpDir, `cbm.${ext}`); try { - await download(url, tmpArchive); + await download(url, tmpArchive, MAX_RELEASE_ARCHIVE_BYTES); await verifyChecksum(tmpArchive, archive); // Validate the complete archive namespace, then extract only executables. if (ext === 'tar.gz') { - extractExactTarArchive( + await extractExactTarArchive( tmpArchive, tmpDir, archiveNames, binName, ); } else { @@ -674,9 +872,13 @@ if (require.main === module || module.parent == null) { } module.exports = { + DEFAULT_ARCHIVE_LIMITS, UNIX_ARCHIVE_NAMES, + WINDOWS_ARCHIVE_NAMES, WINDOWS_BINARY_NAME, + download, extractExactTarArchive, + extractZipOnWindows, installWindowsBinaryAtomically, validateExactTarMemberListing, windowsBinaryReady, diff --git a/pkg/npm/test/archive-fixtures.js b/pkg/npm/test/archive-fixtures.js new file mode 100644 index 000000000..5b28a2e91 --- /dev/null +++ b/pkg/npm/test/archive-fixtures.js @@ -0,0 +1,113 @@ +'use strict'; +// Small synthetic release archives for the wrapper tests. + +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +// Deterministic, poorly compressible bytes (xorshift32), so a fixture of a +// few hundred KiB still spans several pipe chunks after gzip. +function patternBytes(length, seed = 0x9e3779b9) { + const out = Buffer.alloc(length); + let state = seed >>> 0; + for (let index = 0; index < length; index += 1) { + state ^= state << 13; + state >>>= 0; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + out[index] = state & 0xff; + } + return out; +} + +// Writes entries ({ name, data }) into a staging directory under `root` and +// packs them with the system tar, the producer the release itself uses. +function writeTarGz(root, archiveName, entries) { + const stage = fs.mkdtempSync(path.join(root, 'stage-')); + for (const entry of entries) { + fs.writeFileSync(path.join(stage, entry.name), entry.data); + } + const archive = path.join(root, archiveName); + execFileSync( + 'tar', + ['-czf', archive, '-C', stage, ...entries.map((entry) => entry.name)], + { + env: { ...process.env, COPYFILE_DISABLE: '1' }, + stdio: ['ignore', 'ignore', 'inherit'], + windowsHide: true, + }, + ); + return archive; +} + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c; + } + return table; +})(); + +function crc32(buffer) { + let crc = -1; + for (const byte of buffer) { + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ -1) >>> 0; +} + +// entries: [{ name, data, declaredSize = data.length }]; stored (no +// compression). Hand-rolled so the directory can claim a size that differs +// from the bytes the entry carries, which no zip writer would produce. +function buildZip(entries) { + const locals = []; + const centrals = []; + let offset = 0; + for (const entry of entries) { + const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data); + const name = Buffer.from(entry.name, 'utf8'); + const declaredSize = entry.declaredSize === undefined + ? data.length + : entry.declaredSize; + const crc = crc32(data); + + const local = Buffer.alloc(30 + name.length); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(declaredSize, 22); + local.writeUInt16LE(name.length, 26); + name.copy(local, 30); + + const central = Buffer.alloc(46 + name.length); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(declaredSize, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(offset, 42); + name.copy(central, 46); + + locals.push(local, data); + centrals.push(central); + offset += local.length + data.length; + } + const directory = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, directory, end]); +} + +module.exports = { buildZip, patternBytes, writeTarGz }; diff --git a/pkg/npm/test/archive-limits.test.js b/pkg/npm/test/archive-limits.test.js new file mode 100644 index 000000000..3aa786954 --- /dev/null +++ b/pkg/npm/test/archive-limits.test.js @@ -0,0 +1,348 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { Readable } = require('node:stream'); +const test = require('node:test'); +const vm = require('node:vm'); + +const { + DEFAULT_ARCHIVE_LIMITS, + UNIX_ARCHIVE_NAMES, + WINDOWS_ARCHIVE_NAMES, + WINDOWS_BINARY_NAME, + extractExactTarArchive, + extractZipOnWindows, +} = require('../install.js'); +const { buildZip, patternBytes, writeTarGz } = require('./archive-fixtures.js'); + +const EXECUTABLE = 'codebase-memory-mcp'; +// Spans several pipe chunks, so an over-limit member is cut off mid-stream. +const LARGE_EXECUTABLE = patternBytes(300 * 1024); + +function withScratch(callback) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cbm-npm-archive-test-')); + return Promise.resolve() + .then(() => callback(root)) + .finally(() => fs.rmSync(root, { recursive: true, force: true })); +} + +function limitsWith(overrides) { + return { ...DEFAULT_ARCHIVE_LIMITS, ...overrides }; +} + +function releaseEntries(names, executableData) { + return names.map((name) => ({ + name, + data: executableData !== undefined && (name === EXECUTABLE || name === WINDOWS_BINARY_NAME) + ? executableData + : `payload of ${name}`, + })); +} + +function makeDestination(root) { + const destination = path.join(root, 'extract'); + fs.mkdirSync(destination); + return destination; +} + +// Stands in for the system tar listing where the archive itself is damaged +// on purpose and the real listing would fail before the writer runs. +function fakeTarListing(names) { + return (command, args) => { + assert.equal(command, 'tar'); + assert.equal(args[0], '-tzf'); + return `${names.join('\n')}\n`; + }; +} + +function neverLists() { + throw new Error('the listing must not run'); +} + +test('tar extraction refuses an archive larger than the compressed limit', () => + withScratch(async (root) => { + const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES)); + const destination = makeDestination(root); + + await assert.rejects( + extractExactTarArchive( + archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE, + neverLists, limitsWith({ compressedBytes: 16 }), + ), + /16-byte compressed safety limit/, + ); + assert.deepEqual(fs.readdirSync(destination), []); + })); + +test('tar extraction refuses an exact namespace wider than the member limit', () => + withScratch(async (root) => { + const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES)); + const destination = makeDestination(root); + + await assert.rejects( + extractExactTarArchive( + archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE, + neverLists, limitsWith({ members: 2 }), + ), + /2-member safety limit/, + ); + assert.deepEqual(fs.readdirSync(destination), []); + })); + +test('tar extraction stops an over-limit member in the counted writer and leaves no file', () => + withScratch(async (root) => { + const archive = writeTarGz( + root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES, LARGE_EXECUTABLE), + ); + const destination = makeDestination(root); + + // The real listing passes (names only); the limit trips while tar is + // still streaming the executable into the writer. + await assert.rejects( + extractExactTarArchive( + archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE, + undefined, limitsWith({ memberBytes: 64 * 1024 }), + ), + /"codebase-memory-mcp" exceeds the 65536-byte expanded safety limit/, + ); + assert.deepEqual(fs.readdirSync(destination), []); + })); + +test('tar extraction leaves a pre-existing target untouched', () => + withScratch(async (root) => { + const archive = writeTarGz(root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES)); + const destination = makeDestination(root); + const target = path.join(destination, EXECUTABLE); + fs.writeFileSync(target, 'keep'); + + await assert.rejects( + extractExactTarArchive(archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE), + /EEXIST/, + ); + assert.equal(fs.readFileSync(target, 'utf8'), 'keep'); + })); + +test('tar extraction leaves no file when tar fails mid-stream', () => + withScratch(async (root) => { + const complete = writeTarGz( + root, 'complete.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES, LARGE_EXECUTABLE), + ); + const bytes = fs.readFileSync(complete); + const archive = path.join(root, 'truncated.tar.gz'); + fs.writeFileSync(archive, bytes.subarray(0, Math.floor(bytes.length * 0.6))); + const destination = makeDestination(root); + + await assert.rejects( + extractExactTarArchive( + archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE, + fakeTarListing(UNIX_ARCHIVE_NAMES), + ), + // Non-zero exit, and tar's own diagnostic text after the colon. + /tar exited with status [1-9]\d* while extracting codebase-memory-mcp: \S/, + ); + assert.deepEqual(fs.readdirSync(destination), []); + })); + +test('tar extraction rejects an empty executable', () => + withScratch(async (root) => { + const archive = writeTarGz( + root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES, Buffer.alloc(0)), + ); + const destination = makeDestination(root); + + await assert.rejects( + extractExactTarArchive(archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE), + /archive member codebase-memory-mcp is empty/, + ); + assert.deepEqual(fs.readdirSync(destination), []); + })); + +test('tar extraction installs the executable byte-identical with the executable bit set', () => + withScratch(async (root) => { + const archive = writeTarGz( + root, 'release.tar.gz', releaseEntries(UNIX_ARCHIVE_NAMES, LARGE_EXECUTABLE), + ); + const destination = makeDestination(root); + + await extractExactTarArchive(archive, destination, UNIX_ARCHIVE_NAMES, EXECUTABLE); + + const target = path.join(destination, EXECUTABLE); + assert.ok(fs.readFileSync(target).equals(LARGE_EXECUTABLE)); + if (process.platform !== 'win32') { + // Windows has no mode bits; everywhere else the file must be runnable. + assert.equal(fs.statSync(target).mode & 0o111, 0o111); + } + assert.deepEqual(fs.readdirSync(destination), [EXECUTABLE]); + })); + +function loadInstallerWithHttps(fakeHttps) { + const installPath = path.join(__dirname, '..', 'install.js'); + const source = fs.readFileSync(installPath, 'utf8'); + const sandbox = { + Buffer, + URL, + __dirname: path.dirname(installPath), + __filename: installPath, + clearTimeout, + console, + exports: {}, + // A parent keeps the postinstall entry point from running on load. + module: { exports: {}, parent: {} }, + process, + require: (specifier) => { + if (specifier === './package.json') return { version: '0.0.0-test' }; + if (specifier === 'https') return fakeHttps; + return require(specifier); + }, + setTimeout, + }; + vm.runInNewContext(source, sandbox, { filename: installPath }); + return sandbox.module.exports; +} + +// Answers every request with one HTTP 200 response shaped by `configure`. +function fakeHttpsServing(configure) { + return { + get(url, callback) { + const request = new EventEmitter(); + request.destroy = (err) => { if (err) request.emit('error', err); }; + setImmediate(() => { + const response = new Readable({ read() {} }); + response.statusCode = 200; + response.headers = {}; + configure(response); + callback(response); + }); + return request; + }, + }; +} + +test('download refuses a response whose declared length exceeds the limit', () => + withScratch(async (root) => { + const { download } = loadInstallerWithHttps(fakeHttpsServing((response) => { + response.headers['content-length'] = '9'; + response.push(null); + })); + const destination = path.join(root, 'release.tar.gz'); + + await assert.rejects( + download('https://example.invalid/release.tar.gz', destination, 8), + /8-byte safety limit/, + ); + assert.equal(fs.existsSync(destination), false); + })); + +test('download refuses a body that exceeds the limit and removes the partial file', () => + withScratch(async (root) => { + const { download } = loadInstallerWithHttps(fakeHttpsServing((response) => { + response.push(Buffer.alloc(1024, 0x78)); + response.push(null); + })); + const destination = path.join(root, 'release.tar.gz'); + + await assert.rejects( + download('https://example.invalid/release.tar.gz', destination, 8), + /8-byte safety limit/, + ); + assert.equal(fs.existsSync(destination), false); + })); + +test('download within the limit stores the body', () => + withScratch(async (root) => { + const { download } = loadInstallerWithHttps(fakeHttpsServing((response) => { + response.headers['content-length'] = '7'; + response.push('payload'); + response.push(null); + })); + const destination = path.join(root, 'release.tar.gz'); + + await download('https://example.invalid/release.tar.gz', destination, 1024); + + assert.equal(fs.readFileSync(destination, 'utf8'), 'payload'); + })); + +// The Windows path runs a constant PowerShell program through powershell.exe, +// which only a Windows host provides (pwsh is not a wrapper dependency, and +// no PowerShell is installed on the Unix wrapper hosts), so these cases run +// in the Windows wrapper leg and are skipped elsewhere. +const WINDOWS_ONLY = process.platform === 'win32' + ? false + : 'zip extraction runs through powershell.exe, which this host does not provide'; + +test('Windows zip extraction refuses an archive with more members than the limit', { skip: WINDOWS_ONLY }, () => + withScratch(async (root) => { + const archive = path.join(root, 'members.zip'); + fs.writeFileSync(archive, buildZip([ + { name: 'one', data: 'a' }, + { name: 'two', data: 'b' }, + { name: 'three', data: 'c' }, + ])); + const destination = makeDestination(root); + + assert.throws( + () => extractZipOnWindows( + archive, destination, ['one', 'two', 'three'], ['one'], + limitsWith({ members: 2 }), + ), + /2-member safety limit/, + ); + assert.deepEqual(fs.readdirSync(destination), []); + })); + +test('Windows zip extraction refuses a member whose declared size exceeds the limit', { skip: WINDOWS_ONLY }, () => + withScratch(async (root) => { + const archive = path.join(root, 'member.zip'); + fs.writeFileSync(archive, buildZip([ + { name: WINDOWS_BINARY_NAME, data: Buffer.alloc(65, 0x78) }, + ])); + const destination = makeDestination(root); + + assert.throws( + () => extractZipOnWindows( + archive, destination, [WINDOWS_BINARY_NAME], [WINDOWS_BINARY_NAME], + limitsWith({ memberBytes: 64 }), + ), + /64-byte expanded safety limit/, + ); + assert.deepEqual(fs.readdirSync(destination), []); + })); + +test('Windows zip extraction stops a member that yields more bytes than it declares', { skip: WINDOWS_ONLY }, () => + withScratch(async (root) => { + const archive = path.join(root, 'longer.zip'); + fs.writeFileSync(archive, buildZip([ + { name: WINDOWS_BINARY_NAME, data: Buffer.alloc(600, 0x78), declaredSize: 10 }, + ])); + const destination = makeDestination(root); + + assert.throws( + () => extractZipOnWindows( + archive, destination, [WINDOWS_BINARY_NAME], [WINDOWS_BINARY_NAME], + limitsWith({ memberBytes: 64 }), + ), + /64-byte actual expanded safety limit/, + ); + assert.deepEqual(fs.readdirSync(destination), []); + })); + +test('Windows zip extraction within the limits installs only the executable', { skip: WINDOWS_ONLY }, () => + withScratch(async (root) => { + const archive = path.join(root, 'release.zip'); + fs.writeFileSync(archive, buildZip(releaseEntries(WINDOWS_ARCHIVE_NAMES))); + const destination = makeDestination(root); + + extractZipOnWindows( + archive, destination, WINDOWS_ARCHIVE_NAMES, [WINDOWS_BINARY_NAME], + ); + + assert.equal( + fs.readFileSync(path.join(destination, WINDOWS_BINARY_NAME), 'utf8'), + `payload of ${WINDOWS_BINARY_NAME}`, + ); + assert.deepEqual(fs.readdirSync(destination), [WINDOWS_BINARY_NAME]); + })); diff --git a/pkg/npm/test/package-publication.test.js b/pkg/npm/test/package-publication.test.js index 560aec700..258bdd74b 100644 --- a/pkg/npm/test/package-publication.test.js +++ b/pkg/npm/test/package-publication.test.js @@ -13,6 +13,7 @@ const { installWindowsBinaryAtomically, validateExactTarMemberListing, } = require('../install.js'); +const { writeTarGz } = require('./archive-fixtures.js'); function exactUnixListing(extra = []) { return [...UNIX_ARCHIVE_NAMES, ...extra].join('\n') + '\n'; @@ -33,23 +34,37 @@ test('Unix archive validation rejects traversal and unexpected members', () => { ); }); -test('Unix extraction requests only the validated root executable', () => { - const calls = []; - const runner = (command, args) => { - calls.push({ command, args: [...args] }); - return calls.length === 1 ? exactUnixListing() : Buffer.alloc(0); - }; +test('Unix extraction lists through the system tar and writes only the root executable', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cbm-npm-extract-test-')); + try { + const archive = writeTarGz( + root, 'release.tar.gz', + UNIX_ARCHIVE_NAMES.map((name) => ({ name, data: `member:${name}` })), + ); + const destination = path.join(root, 'extract'); + fs.mkdirSync(destination); + const calls = []; + const runner = (command, args) => { + calls.push({ command, args: [...args] }); + return exactUnixListing(); + }; - extractExactTarArchive( - '/tmp/release.tar.gz', '/tmp/extract', UNIX_ARCHIVE_NAMES, - 'codebase-memory-mcp', runner, - ); + await extractExactTarArchive( + archive, destination, UNIX_ARCHIVE_NAMES, 'codebase-memory-mcp', runner, + ); - assert.deepEqual(calls[0].args, ['-tzf', '/tmp/release.tar.gz']); - assert.deepEqual( - calls[1].args, - ['-xzf', '/tmp/release.tar.gz', '-C', '/tmp/extract', 'codebase-memory-mcp'], - ); + // The listing runner sees the namespace query only; the executable is + // written by the counted `tar -xzOf` writer, never by `tar -x` into the + // tree, so the companions never touch the disk. + assert.deepEqual(calls.map((call) => call.args), [['-tzf', archive]]); + assert.equal( + fs.readFileSync(path.join(destination, 'codebase-memory-mcp'), 'utf8'), + 'member:codebase-memory-mcp', + ); + assert.deepEqual(fs.readdirSync(destination), ['codebase-memory-mcp']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } }); function writeBinary(directory, tag) { diff --git a/pkg/pypi/src/codebase_memory_mcp/_cli.py b/pkg/pypi/src/codebase_memory_mcp/_cli.py index 458b8c350..9faccc005 100644 --- a/pkg/pypi/src/codebase_memory_mcp/_cli.py +++ b/pkg/pypi/src/codebase_memory_mcp/_cli.py @@ -1,5 +1,6 @@ """Downloads codebase-memory-mcp on first run, then runs its native entry point.""" +import collections import errno import hashlib import json @@ -42,6 +43,22 @@ _MAX_REDIRECTS = 5 _NETWORK_TIMEOUT_SECONDS = 120 _CANDIDATE_TIMEOUT_SECONDS = 15 _MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024 +# Release archive resource limits. Keep in sync with the Go wrapper +# (pkg/go/cmd/codebase-memory-mcp/main.go) and the npm wrapper (install.js). +_MAX_RELEASE_ARCHIVE_BYTES = 256 * 1024 * 1024 +_MAX_ARCHIVE_MEMBERS = 64 +_MAX_ARCHIVE_MEMBER_BYTES = 256 * 1024 * 1024 +_MAX_ARCHIVE_EXPANDED_BYTES = 512 * 1024 * 1024 +_ARCHIVE_COPY_CHUNK_BYTES = 64 * 1024 +_ArchiveLimits = collections.namedtuple( + "_ArchiveLimits", "compressed_bytes members member_bytes expanded_bytes" +) +_DEFAULT_ARCHIVE_LIMITS = _ArchiveLimits( + compressed_bytes=_MAX_RELEASE_ARCHIVE_BYTES, + members=_MAX_ARCHIVE_MEMBERS, + member_bytes=_MAX_ARCHIVE_MEMBER_BYTES, + expanded_bytes=_MAX_ARCHIVE_EXPANDED_BYTES, +) _REDIRECT_CODES = frozenset({301, 302, 303, 307, 308}) _RUNTIME_LOCK_NAME = ".codebase-memory-mcp-runtime.lock" _RUNTIME_LOCK_WAIT_SECONDS = 45 @@ -113,27 +130,50 @@ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None: with response: _validate_url_scheme(response.geturl()) + declared = (response.headers.get("Content-Length") or "").strip() + # Only plain ASCII digits are a length (str.isdigit also accepts + # characters that int() rejects); anything else is left to the + # byte counter below. + if max_bytes and re.fullmatch(r"[0-9]+", declared): + if int(declared) > max_bytes: + raise RuntimeError( + f"download exceeds the {max_bytes}-byte safety limit" + ) deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS total = 0 - with open(dest, "wb") as out: - while True: - if time.monotonic() >= deadline: - raise TimeoutError( - f"download hop timed out: {current_url}" - ) - chunk = response.read(65536) - if not chunk: - return - total += len(chunk) - if max_bytes and total > max_bytes: - raise RuntimeError( - f"download exceeds the {max_bytes}-byte safety limit" - ) - out.write(chunk) + try: + with open(dest, "wb") as out: + while True: + if time.monotonic() >= deadline: + raise TimeoutError( + f"download hop timed out: {current_url}" + ) + chunk = response.read(65536) + if not chunk: + return + total += len(chunk) + if max_bytes and total > max_bytes: + raise RuntimeError( + f"download exceeds the {max_bytes}-byte " + f"safety limit" + ) + out.write(chunk) + except BaseException: + # Never leave a partial download behind for a later step to + # mistake for the complete asset. + _remove_quietly(dest) + raise raise RuntimeError("too many redirects") +def _remove_quietly(path) -> None: + try: + os.unlink(path) + except OSError: + pass + + def _verify_candidate(path: Path) -> None: """Require a staged native candidate to execute successfully.""" try: @@ -196,33 +236,157 @@ def _validate_archive_names(names, archive_names, casefold: bool = False): ) +def _validate_archive_limits(limits) -> None: + if ( + limits.compressed_bytes <= 0 + or limits.members <= 0 + or limits.member_bytes <= 0 + or limits.expanded_bytes <= 0 + ): + sys.exit("codebase-memory-mcp: invalid archive resource safety limits") + + +def _require_compressed_archive_within_limit(archive_path: str, limits) -> None: + status = os.stat(archive_path) + if not stat.S_ISREG(status.st_mode): + sys.exit("codebase-memory-mcp: release archive is not a regular file") + if status.st_size > limits.compressed_bytes: + sys.exit( + f"codebase-memory-mcp: release archive exceeds the " + f"{limits.compressed_bytes}-byte compressed safety limit" + ) + + +def _require_member_count_within_limit(count: int, limits) -> None: + if count > limits.members: + sys.exit( + f"codebase-memory-mcp: archive exceeds the {limits.members}-member " + f"safety limit" + ) + + +def _account_declared_archive_member( + name: str, size: int, declared_expanded: int, limits +) -> int: + """Check one member's declared size; return the new declared total.""" + if size < 0: + sys.exit( + f"codebase-memory-mcp: archive member has a negative declared " + f"size: {name!r}" + ) + if size > limits.member_bytes: + sys.exit( + f"codebase-memory-mcp: archive member {name!r} exceeds the " + f"{limits.member_bytes}-byte expanded safety limit" + ) + if declared_expanded > limits.expanded_bytes - size: + sys.exit( + f"codebase-memory-mcp: archive exceeds the {limits.expanded_bytes}-byte " + f"aggregate expanded safety limit" + ) + return declared_expanded + size + + +def _copy_archive_member_within_limits( + source, output, name: str, declared_size: int, actual_expanded: int, limits +) -> int: + """Copy a member while counting bytes; return the new actual total. + + Reads stop one byte past the smaller of the member and remaining aggregate + limits, so a member that yields more than it declares can never fill the + disk. The caller removes the target on failure. + """ + remaining = limits.expanded_bytes - actual_expanded + copy_limit = min(limits.member_bytes, remaining) + copied = 0 + while True: + wanted = min(_ARCHIVE_COPY_CHUNK_BYTES, copy_limit + 1 - copied) + if wanted <= 0: + break + chunk = source.read(wanted) + if not chunk: + break + copied += len(chunk) + if copied > copy_limit: + break + output.write(chunk) + if copied > limits.member_bytes: + sys.exit( + f"codebase-memory-mcp: archive member {name!r} exceeds the " + f"{limits.member_bytes}-byte actual expanded safety limit" + ) + if copied > remaining: + sys.exit( + f"codebase-memory-mcp: archive exceeds the {limits.expanded_bytes}-byte " + f"aggregate actual expanded safety limit" + ) + if copied != declared_size: + sys.exit( + f"codebase-memory-mcp: archive member {name!r} actual size {copied} " + f"does not match declared size {declared_size}" + ) + return actual_expanded + copied + + +def _write_archive_member_within_limits( + source, target: str, name: str, declared_size: int, actual_expanded: int, limits +) -> int: + try: + with open(target, "xb") as output: + return _copy_archive_member_within_limits( + source, output, name, declared_size, actual_expanded, limits + ) + except BaseException: + _remove_quietly(target) + raise + + def _safe_extract_tar( - tf, dest: str, archive_names=(), extract_names=(), ui: bool = False + tf, + dest: str, + archive_names=(), + extract_names=(), + ui: bool = False, + limits=_DEFAULT_ARCHIVE_LIMITS, ): """Validate a tar namespace, then extract only its authenticated runtime set.""" + _validate_archive_limits(limits) members = tf.getmembers() + _require_member_count_within_limit(len(members), limits) + declared_expanded = 0 for member in members: if not member.isfile(): sys.exit( f"codebase-memory-mcp: refusing unsafe tar entry " f"(not a regular root file: {member.name!r})" ) + declared_expanded = _account_declared_archive_member( + member.name, member.size, declared_expanded, limits + ) _validate_archive_names([member.name for member in members], archive_names) runtime_names = list(extract_names) by_name = {member.name: member for member in members} dest_abs = os.path.abspath(dest) + actual_expanded = 0 for name in runtime_names: source = tf.extractfile(by_name[name]) if source is None: sys.exit(f"codebase-memory-mcp: could not read archive member: {name}") target = os.path.join(dest_abs, name) - with source, open(target, "xb") as output: - shutil.copyfileobj(source, output) + with source: + actual_expanded = _write_archive_member_within_limits( + source, target, name, by_name[name].size, actual_expanded, limits + ) return tuple(runtime_names) def _safe_extract_zip( - zf, dest: str, archive_names=(), extract_names=(), ui: bool = False + zf, + dest: str, + archive_names=(), + extract_names=(), + ui: bool = False, + limits=_DEFAULT_ARCHIVE_LIMITS, ): """Extract a zipfile after validating its Windows-style namespace. @@ -230,9 +394,16 @@ def _safe_extract_zip( members before extraction prevents archive order from selecting the binary that a portable package-manager shim eventually executes. """ + _validate_archive_limits(limits) dest_abs = os.path.abspath(dest) names = [] - for info in zf.infolist(): + infos = zf.infolist() + _require_member_count_within_limit(len(infos), limits) + declared_expanded = 0 + for info in infos: + declared_expanded = _account_declared_archive_member( + info.filename, info.file_size, declared_expanded, limits + ) raw_name = info.filename name = raw_name.replace("\\", "/") segments_name = name[:-1] if name.endswith("/") else name @@ -266,10 +437,18 @@ def _safe_extract_zip( # Extract only the validated runtime files. This avoids relying on # platform-specific zip path rewriting and always creates regular files. runtime_names = list(extract_names) + actual_expanded = 0 for name in runtime_names: target = os.path.join(dest_abs, name) - with zf.open(name) as source, open(target, "xb") as output: - shutil.copyfileobj(source, output) + with zf.open(name) as source: + actual_expanded = _write_archive_member_within_limits( + source, + target, + name, + zf.getinfo(name).file_size, + actual_expanded, + limits, + ) return tuple(runtime_names) @@ -1179,7 +1358,7 @@ def _download(version: str) -> Path: with tempfile.TemporaryDirectory() as tmp: tmp_archive = os.path.join(tmp, f"cbm.{ext}") try: - _download_https(url, tmp_archive) + _download_https(url, tmp_archive, _MAX_RELEASE_ARCHIVE_BYTES) except (OSError, RuntimeError, urllib.error.URLError) as e: sys.exit( f"codebase-memory-mcp: download failed ({e})\n" @@ -1188,6 +1367,9 @@ def _download(version: str) -> Path: ) _verify_checksum(tmp_archive, archive, version) + _require_compressed_archive_within_limit( + tmp_archive, _DEFAULT_ARCHIVE_LIMITS + ) bin_name = ( _WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp" diff --git a/pkg/pypi/tests/test_cli.py b/pkg/pypi/tests/test_cli.py index ca66fbdba..b6d62f7d2 100644 --- a/pkg/pypi/tests/test_cli.py +++ b/pkg/pypi/tests/test_cli.py @@ -1,5 +1,6 @@ import errno import hashlib +import io import json import os import stat @@ -835,3 +836,296 @@ _cli._publish_runtime_set( False, ) + +class _FakeHttpsResponse: + """Minimal stand-in for the opener's response: headers, geturl, read.""" + + def __init__(self, body: bytes, headers=None): + self._body = body + self._offset = 0 + self.headers = dict(headers or {}) + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + def geturl(self): + return "https://example.invalid/release.tar.gz" + + def read(self, size=-1): + if size is None or size < 0: + size = len(self._body) - self._offset + chunk = self._body[self._offset : self._offset + size] + self._offset += len(chunk) + return chunk + + +def _limits_with(**overrides): + return _cli._DEFAULT_ARCHIVE_LIMITS._replace(**overrides) + + +def _write_tar_gz(archive, entries): + with tarfile.open(archive, "w:gz") as tf: + for name, data in entries: + info = tarfile.TarInfo(name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + +def _write_zip(archive, entries): + with zipfile.ZipFile(archive, "w") as zf: + for name, data in entries: + zf.writestr(name, data) + + +def _release_entries(names): + return [(name, f"payload of {name}".encode()) for name in names] + + +class ArchiveLimitTests(unittest.TestCase): + URL = "https://example.invalid/release.tar.gz" + + def _download(self, response, destination, max_bytes): + with mock.patch.object(_cli._HTTPS_OPENER, "open", return_value=response): + _cli._download_https(self.URL, destination, max_bytes) + + def test_download_rejects_a_declared_length_over_the_limit(self): + with tempfile.TemporaryDirectory() as root: + destination = os.path.join(root, "release.tar.gz") + response = _FakeHttpsResponse(b"", {"Content-Length": "9"}) + with self.assertRaises(RuntimeError) as raised: + self._download(response, destination, 8) + self.assertIn("8-byte safety limit", str(raised.exception)) + self.assertFalse(os.path.exists(destination)) + + def test_download_rejects_a_body_over_the_limit_and_removes_the_partial_file( + self, + ): + with tempfile.TemporaryDirectory() as root: + destination = os.path.join(root, "release.tar.gz") + response = _FakeHttpsResponse(b"x" * 1024) + with self.assertRaises(RuntimeError) as raised: + self._download(response, destination, 8) + self.assertIn("8-byte safety limit", str(raised.exception)) + self.assertFalse(os.path.exists(destination)) + + def test_download_within_the_limit_stores_the_body(self): + with tempfile.TemporaryDirectory() as root: + destination = os.path.join(root, "release.tar.gz") + response = _FakeHttpsResponse(b"payload", {"Content-Length": "7"}) + self._download(response, destination, 1024) + with open(destination, "rb") as stored: + self.assertEqual(stored.read(), b"payload") + + def test_download_counts_bytes_when_the_declared_length_is_not_a_number(self): + # A superscript two passes str.isdigit but not int(); such values and + # plain junk must fall through to the byte counter, never crash. + for declared in ("²", "abc", "", "1e3", "-1"): + with self.subTest(declared=declared), tempfile.TemporaryDirectory() as root: + destination = os.path.join(root, "release.tar.gz") + response = _FakeHttpsResponse(b"x" * 1024, {"Content-Length": declared}) + with self.assertRaises(RuntimeError) as raised: + self._download(response, destination, 8) + self.assertIn("8-byte safety limit", str(raised.exception)) + self.assertFalse(os.path.exists(destination)) + with tempfile.TemporaryDirectory() as root: + destination = os.path.join(root, "release.tar.gz") + response = _FakeHttpsResponse(b"payload", {"Content-Length": "²"}) + self._download(response, destination, 1024) + with open(destination, "rb") as stored: + self.assertEqual(stored.read(), b"payload") + + def test_unix_tar_rejects_more_members_than_the_limit(self): + with tempfile.TemporaryDirectory() as root: + archive = Path(root) / "members.tar.gz" + _write_tar_gz(archive, [("one", b"a"), ("two", b"b"), ("three", b"c")]) + destination = Path(root) / "extract" + destination.mkdir() + with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised: + _cli._safe_extract_tar( + tf, + str(destination), + ("one", "two", "three"), + ("one",), + limits=_limits_with(members=2), + ) + self.assertIn("2-member safety limit", str(raised.exception)) + self.assertFalse((destination / "one").exists()) + + def test_unix_tar_rejects_a_member_declared_over_the_limit(self): + with tempfile.TemporaryDirectory() as root: + archive = Path(root) / "member.tar.gz" + _write_tar_gz(archive, [("codebase-memory-mcp", b"x" * 65)]) + destination = Path(root) / "extract" + destination.mkdir() + with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised: + _cli._safe_extract_tar( + tf, + str(destination), + ("codebase-memory-mcp",), + ("codebase-memory-mcp",), + limits=_limits_with(member_bytes=64), + ) + self.assertIn( + "'codebase-memory-mcp' exceeds the 64-byte expanded safety limit", + str(raised.exception), + ) + self.assertFalse((destination / "codebase-memory-mcp").exists()) + + def test_unix_tar_rejects_a_declared_aggregate_over_the_limit(self): + with tempfile.TemporaryDirectory() as root: + archive = Path(root) / "aggregate.tar.gz" + _write_tar_gz(archive, [("one", b"a" * 40), ("two", b"b" * 40)]) + destination = Path(root) / "extract" + destination.mkdir() + with tarfile.open(archive) as tf, self.assertRaises(SystemExit) as raised: + _cli._safe_extract_tar( + tf, + str(destination), + ("one", "two"), + ("one",), + limits=_limits_with(member_bytes=64, expanded_bytes=64), + ) + self.assertIn( + "64-byte aggregate expanded safety limit", str(raised.exception) + ) + + def test_unix_tar_within_the_limits_extracts_only_the_executable(self): + with tempfile.TemporaryDirectory() as root: + archive = Path(root) / "release.tar.gz" + _write_tar_gz(archive, _release_entries(_cli._UNIX_ARCHIVE_NAMES)) + destination = Path(root) / "extract" + destination.mkdir() + with tarfile.open(archive) as tf: + extracted = _cli._safe_extract_tar( + tf, + str(destination), + _cli._UNIX_ARCHIVE_NAMES, + ("codebase-memory-mcp",), + ) + self.assertEqual(extracted, ("codebase-memory-mcp",)) + self.assertEqual( + (destination / "codebase-memory-mcp").read_bytes(), + b"payload of codebase-memory-mcp", + ) + self.assertEqual(os.listdir(destination), ["codebase-memory-mcp"]) + + def test_archive_copy_stops_a_member_that_yields_more_than_the_limit(self): + source = io.BytesIO(b"x" * 100) + output = io.BytesIO() + with self.assertRaises(SystemExit) as raised: + _cli._copy_archive_member_within_limits( + source, + output, + "member", + 100, + 0, + _limits_with(member_bytes=64, expanded_bytes=1024), + ) + self.assertIn( + "'member' exceeds the 64-byte actual expanded safety limit", + str(raised.exception), + ) + # Reading stopped one byte past the limit; nothing beyond it was kept. + self.assertEqual(source.tell(), 65) + self.assertLessEqual(len(output.getvalue()), 64) + + def test_archive_copy_rejects_a_member_that_yields_more_than_it_declares(self): + source = io.BytesIO(b"x" * 100) + with self.assertRaises(SystemExit) as raised: + _cli._copy_archive_member_within_limits( + source, + io.BytesIO(), + "member", + 10, + 0, + _limits_with(member_bytes=1024, expanded_bytes=1024), + ) + self.assertIn( + "'member' actual size 100 does not match declared size 10", + str(raised.exception), + ) + + def test_archive_copy_rejects_an_aggregate_over_the_actual_limit(self): + with self.assertRaises(SystemExit) as raised: + _cli._copy_archive_member_within_limits( + io.BytesIO(b"abcd"), + io.BytesIO(), + "member", + 4, + 3, + _limits_with(member_bytes=10, expanded_bytes=5), + ) + self.assertIn( + "5-byte aggregate actual expanded safety limit", str(raised.exception) + ) + + def test_archive_member_write_removes_the_partial_file_on_failure(self): + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, "codebase-memory-mcp") + with self.assertRaises(SystemExit): + _cli._write_archive_member_within_limits( + io.BytesIO(b"x" * 100), + target, + "codebase-memory-mcp", + 10, + 0, + _limits_with(member_bytes=1024, expanded_bytes=1024), + ) + self.assertFalse(os.path.exists(target)) + + def test_windows_zip_rejects_more_members_than_the_limit(self): + with tempfile.TemporaryDirectory() as root: + archive = Path(root) / "members.zip" + _write_zip(archive, [("one", b"a"), ("two", b"b"), ("three", b"c")]) + destination = Path(root) / "extract" + destination.mkdir() + with zipfile.ZipFile(archive) as zf, self.assertRaises(SystemExit) as raised: + _cli._safe_extract_zip( + zf, + str(destination), + ("one", "two", "three"), + ("one",), + limits=_limits_with(members=2), + ) + self.assertIn("2-member safety limit", str(raised.exception)) + + def test_windows_zip_rejects_a_member_declared_over_the_limit(self): + with tempfile.TemporaryDirectory() as root: + archive = Path(root) / "member.zip" + _write_zip(archive, [(_cli._WINDOWS_BINARY_NAME, b"x" * 65)]) + destination = Path(root) / "extract" + destination.mkdir() + with zipfile.ZipFile(archive) as zf, self.assertRaises(SystemExit) as raised: + _cli._safe_extract_zip( + zf, + str(destination), + (_cli._WINDOWS_BINARY_NAME,), + (_cli._WINDOWS_BINARY_NAME,), + limits=_limits_with(member_bytes=64), + ) + self.assertIn("64-byte expanded safety limit", str(raised.exception)) + self.assertFalse((destination / _cli._WINDOWS_BINARY_NAME).exists()) + + def test_windows_zip_within_the_limits_extracts_only_the_executable(self): + with tempfile.TemporaryDirectory() as root: + archive = Path(root) / "release.zip" + _write_zip(archive, _release_entries(_cli._WINDOWS_ARCHIVE_NAMES)) + destination = Path(root) / "extract" + destination.mkdir() + with zipfile.ZipFile(archive) as zf: + extracted = _cli._safe_extract_zip( + zf, + str(destination), + _cli._WINDOWS_ARCHIVE_NAMES, + (_cli._WINDOWS_BINARY_NAME,), + ) + self.assertEqual(extracted, (_cli._WINDOWS_BINARY_NAME,)) + self.assertEqual( + (destination / _cli._WINDOWS_BINARY_NAME).read_bytes(), + b"payload of codebase-memory-mcp.exe", + ) + self.assertEqual(os.listdir(destination), [_cli._WINDOWS_BINARY_NAME]) + From 02bc9f2516b3f480060de22c5d98cb71822fc783 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 01:35:03 +0200 Subject: [PATCH 18/50] fix(mcp): clamp search_code context on both sides, add sink ceilings The search_code `context` argument had no upper bound while every sibling sizing argument (result_limit, match_limit, source_max_lines, raw_limit, directory_limit) is clamped on both sides. A large value made every hit return its file from the match to the end, and the window arithmetic in attach_result_source could overflow int. `context` now gets the same 0..200 clamp as source_max_lines (MCP_SEARCH_CONTEXT_MAX_LINES), the window computation is overflow-safe on its own, and the tool schema states default, minimum and maximum like its siblings. Values inside the bound return exactly the lines they did before. Sink ceilings: cbm_cypher_execute clamps a caller-supplied max_rows to CYPHER_RESULT_CEILING (nothing above it is ever materialized) and execute_single checks the binding array it sizes from that value instead of writing through a failed allocation. Tests pin the context window and the extreme values (INT_MAX, negative), the query_graph max_rows ceiling, the search_graph limit ceiling, the trace cursor token truncated after the leg character, the quote rejection in file_pattern, and the engine-side max_rows clamp. Signed-off-by: Martin Vogel --- src/cypher/cypher.c | 10 +- src/cypher/cypher.h | 3 +- src/mcp/mcp.c | 25 +++- tests/test_cypher.c | 24 +++ tests/test_mcp.c | 346 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 399 insertions(+), 9 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 3cc31f9d7..3be846b83 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -5138,7 +5138,11 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec /* Build initial bindings with early WHERE */ int bind_cap = scan_count > max_rows ? scan_count : (max_rows > 0 ? max_rows : SKIP_ONE); - binding_t *bindings = malloc((bind_cap + SKIP_ONE) * sizeof(binding_t)); + binding_t *bindings = malloc(((size_t)bind_cap + SKIP_ONE) * sizeof(binding_t)); + if (!bindings) { + cbm_store_free_nodes(scanned, scan_count); + return CBM_NOT_FOUND; /* initial binding array refused */ + } int bind_count = 0; const char *var_name = pat0->nodes[0].variable ? pat0->nodes[0].variable : CYP_ANON_HEAD_VAR; @@ -5448,7 +5452,9 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec g_cypher_trail_truncated = 0; g_cypher_truncated = false; cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */ - if (max_rows <= 0) { + /* max_rows sizes the initial binding array: non-positive means the + * ceiling, and nothing above the ceiling is ever materialized anyway. */ + if (max_rows <= 0 || max_rows > CYPHER_RESULT_CEILING) { max_rows = CYPHER_RESULT_CEILING; } diff --git a/src/cypher/cypher.h b/src/cypher/cypher.h index 9e66b56ec..e9bd5f74c 100644 --- a/src/cypher/cypher.h +++ b/src/cypher/cypher.h @@ -334,7 +334,8 @@ typedef struct { } cbm_cypher_result_t; /* Execute a Cypher query against a store. - * max_rows: limit on output rows (0 = use virtual ceiling of 100k). + * max_rows: limit on output rows; 0 or any value above the 100k ceiling uses + * the ceiling. * project: project name filter (NULL = all projects). * Returns -1 on error (check out->error for message). */ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *project, int max_rows, diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index c877d5110..9f811a0ad 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -42,6 +42,10 @@ enum { MCP_COMPARE_MAX_SCAN_LIMIT = 10000000, MCP_COMPARE_SET_BYTE_BUDGET = 512 * 1024, MCP_QUERY_MAX_VISIBLE_ROWS = 99998, + /* search_code `context`: lines of surrounding source per hit, the same + * ceiling as `source_max_lines`. Clamped at the handler and again where + * the window is computed, so the arithmetic never overflows. */ + MCP_SEARCH_CONTEXT_MAX_LINES = 200, /* max_output_tokens is model-neutral sizing guidance, not a tokenizer * promise. The actual cross-platform contract is this deterministic UTF-8 * byte ceiling, applied only at whole semantic-unit boundaries. */ @@ -701,7 +705,7 @@ static const tool_def_t TOOLS[] = { "\"string\"},\"file_pattern\":{\"type\":\"string\"},\"path_filter\":{\"type\":\"string\"}," "\"mode\":{\"type\":\"string\"," "\"enum\":[\"compact\",\"full\",\"files\"],\"default\":\"compact\"}," - "\"context\":{\"type\":\"integer\"}," + "\"context\":{\"type\":\"integer\",\"default\":0,\"minimum\":0,\"maximum\":200}," "\"regex\":{\"type\":\"boolean\",\"default\":false}," "\"debug\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Add scope_ms/scan_ms/enrich_ms phase timings.\"}," @@ -13328,11 +13332,15 @@ static void attach_result_source(yyjson_mut_doc *doc, yyjson_mut_val *item, sear } } } else if (context_lines > 0 && r->match_count > 0) { - int ctx_start = r->match_lines[0] - context_lines; - int ctx_end = r->match_lines[r->match_count - SKIP_ONE] + context_lines; - if (ctx_start < SKIP_ONE) { - ctx_start = SKIP_ONE; - } + /* Bounded here as well as at the handler: the window must stay + * match ± MCP_SEARCH_CONTEXT_MAX_LINES for any caller, and the + * arithmetic must not overflow for any value. */ + int ctx_lines = context_lines > MCP_SEARCH_CONTEXT_MAX_LINES ? MCP_SEARCH_CONTEXT_MAX_LINES + : context_lines; + int first_match = r->match_lines[0]; + int last_match = r->match_lines[r->match_count - SKIP_ONE]; + int ctx_start = first_match > ctx_lines ? first_match - ctx_lines : SKIP_ONE; + int ctx_end = last_match > INT_MAX - ctx_lines ? INT_MAX : last_match + ctx_lines; char *ctx = read_file_lines(abs_path, ctx_start, ctx_end); if (ctx) { char *safe_context = sanitize_utf8_lossy(ctx); @@ -14902,6 +14910,11 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { int result_limit = cbm_mcp_get_int_arg(args, "result_limit", legacy_limit); int result_offset = cbm_mcp_get_int_arg(args, "result_offset", 0); int context_lines = cbm_mcp_get_int_arg(args, "context", 0); + if (context_lines < 0) { + context_lines = 0; + } else if (context_lines > MCP_SEARCH_CONTEXT_MAX_LINES) { + context_lines = MCP_SEARCH_CONTEXT_MAX_LINES; + } bool use_regex = cbm_mcp_get_bool_arg(args, "regex"); uint64_t search_t0 = cbm_now_ms(); search_metrics_t metrics = {0}; diff --git a/tests/test_cypher.c b/tests/test_cypher.c index abd334683..98c9b6a45 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -9,6 +9,7 @@ #include "../src/foundation/compat_thread.h" #include #include +#include #include #include #include @@ -4862,6 +4863,28 @@ TEST(cypher_exec_deadline_allows_normal_query_issue601) { PASS(); } +/* The caller-supplied output-row limit sizes the initial binding array; a + * value above the engine ceiling is clamped to it before that happens, and a + * value inside the ceiling still bounds the row count. */ +TEST(cypher_exec_max_rows_above_ceiling_is_clamped) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + + int rc = cbm_cypher_execute(s, "MATCH (f:Function)", "test", INT_MAX, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 4); + cbm_cypher_result_free(&r); + + memset(&r, 0, sizeof(r)); + rc = cbm_cypher_execute(s, "MATCH (f:Function)", "test", 2, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + + cbm_store_close(s); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ */ SUITE(cypher) { @@ -5089,4 +5112,5 @@ SUITE(cypher) { RUN_TEST(cypher_exec_prop_array_with_internal_commas); RUN_TEST(cypher_exec_prop_string_with_escaped_quote); RUN_TEST(cypher_single_hop_seeds_from_selective_far_node); + RUN_TEST(cypher_exec_max_rows_above_ceiling_is_clamped); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 22af972af..cdb873e8e 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -20983,6 +20983,346 @@ TEST(tool_result_add_notice_keeps_payload_shape_issue2144) { PASS(); } +/* ── Integer argument bounds ──────────────────────────────────────── + * Sizing arguments are clamped on both sides at the handler. The fixture + * file is much longer than the `context` bound so the window is observable. */ + +enum { CTX_FIXTURE_LINES = 1000, CTX_FIXTURE_NEEDLE_LINE = 500, CTX_BOUND = 200 }; + +static bool write_context_fixture(const char *tmp, char *path, size_t path_sz) { + snprintf(path, path_sz, "%s/project/long.md", tmp); + FILE *fp = cbm_fopen(path, "wb"); + if (!fp) { + return false; + } + for (int line = 1; line <= CTX_FIXTURE_LINES; line++) { + if (line == CTX_FIXTURE_NEEDLE_LINE) { + fprintf(fp, "needle-context-window\n"); + } else { + fprintf(fp, "line %04d\n", line); + } + } + return fclose(fp) == 0; +} + +static bool upsert_context_fixture_node(cbm_mcp_server_t *srv) { + cbm_store_t *st = cbm_mcp_server_store(srv); + if (!st) { + return false; + } + cbm_node_t node = {.project = "test-project", + .label = "Section", + .name = "long", + .qualified_name = "test-project.long", + .file_path = "long.md", + .start_line = 1, + .end_line = CTX_FIXTURE_LINES}; + return cbm_store_upsert_node(st, &node) > 0; +} + +static char *search_code_with_context(cbm_mcp_server_t *srv, const char *context_value) { + char req[256]; + snprintf(req, sizeof(req), + "{\"project\":\"test-project\",\"pattern\":\"needle-context-window\"," + "\"format\":\"json\",\"context\":%s,\"limit\":5}", + context_value); + char *resp = cbm_mcp_handle_tool(srv, "search_code", req); + if (!resp) { + return NULL; + } + char *inner = extract_text_content(resp); + free(resp); + return inner; +} + +/* Row-0 context object of a json-format search_code answer, or NULL when the + * answer carries no context column. *doc_out owns the memory either way. */ +static yyjson_val *search_code_context_object(const char *inner, yyjson_doc **doc_out) { + *doc_out = yyjson_read(inner, strlen(inner), 0); + if (!*doc_out) { + return NULL; + } + yyjson_val *root = yyjson_doc_get_root(*doc_out); + yyjson_val *cols = yyjson_obj_get(root, "cols"); + yyjson_val *rows = yyjson_obj_get(root, "rows"); + if (!cols || !rows || yyjson_arr_size(rows) == 0) { + return NULL; + } + size_t col_count = yyjson_arr_size(cols); + for (size_t i = 0; i < col_count; i++) { + const char *col = yyjson_get_str(yyjson_arr_get(cols, i)); + if (col && strcmp(col, "context") == 0) { + return yyjson_arr_get(yyjson_arr_get(rows, 0), i); + } + } + return NULL; +} + +static int count_newlines(const char *s) { + int n = 0; + for (; *s; s++) { + n += *s == '\n'; + } + return n; +} + +TEST(search_code_context_window_is_bounded) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char fixture[512]; + ASSERT_TRUE(write_context_fixture(tmp, fixture, sizeof(fixture))); + ASSERT_TRUE(upsert_context_fixture_node(srv)); + + /* Far above the bound: the window is match ± 200, not the whole file. */ + char *inner = search_code_with_context(srv, "100000"); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = NULL; + yyjson_val *ctx = search_code_context_object(inner, &doc); + ASSERT_NOT_NULL(ctx); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(ctx, "context_start")), + CTX_FIXTURE_NEEDLE_LINE - CTX_BOUND); + const char *text = yyjson_get_str(yyjson_obj_get(ctx, "context")); + ASSERT_NOT_NULL(text); + ASSERT_EQ(count_newlines(text), 2 * CTX_BOUND + 1); + ASSERT_EQ(strncmp(text, "line 0300\n", 10), 0); + ASSERT_NOT_NULL(strstr(text, "\nneedle-context-window\n")); + ASSERT_NOT_NULL(strstr(text, "\nline 0700\n")); + ASSERT_NULL(strstr(text, "line 0701")); + yyjson_doc_free(doc); + free(inner); + + /* Inside the bound the window is unchanged. */ + inner = search_code_with_context(srv, "3"); + ASSERT_NOT_NULL(inner); + ctx = search_code_context_object(inner, &doc); + ASSERT_NOT_NULL(ctx); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(ctx, "context_start")), CTX_FIXTURE_NEEDLE_LINE - 3); + text = yyjson_get_str(yyjson_obj_get(ctx, "context")); + ASSERT_NOT_NULL(text); + ASSERT_EQ(count_newlines(text), 7); + ASSERT_EQ(strncmp(text, "line 0497\n", 10), 0); + yyjson_doc_free(doc); + free(inner); + + unlink(fixture); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +TEST(search_code_context_extreme_values_answer_normally) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char fixture[512]; + ASSERT_TRUE(write_context_fixture(tmp, fixture, sizeof(fixture))); + ASSERT_TRUE(upsert_context_fixture_node(srv)); + + /* The largest value the argument can carry behaves exactly like the bound. */ + char *inner = search_code_with_context(srv, "2147483647"); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = NULL; + yyjson_val *ctx = search_code_context_object(inner, &doc); + ASSERT_NOT_NULL(ctx); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(ctx, "context_start")), + CTX_FIXTURE_NEEDLE_LINE - CTX_BOUND); + const char *text = yyjson_get_str(yyjson_obj_get(ctx, "context")); + ASSERT_NOT_NULL(text); + ASSERT_EQ(count_newlines(text), 2 * CTX_BOUND + 1); + yyjson_doc_free(doc); + free(inner); + + /* A negative value means no context, exactly like 0: a normal answer. */ + inner = search_code_with_context(srv, "-5"); + ASSERT_NOT_NULL(inner); + ctx = search_code_context_object(inner, &doc); + ASSERT_NULL(ctx); + ASSERT_NOT_NULL(doc); + yyjson_val *rows = yyjson_obj_get(yyjson_doc_get_root(doc), "rows"); + ASSERT_NOT_NULL(rows); + ASSERT_TRUE(yyjson_arr_size(rows) > 0); + yyjson_doc_free(doc); + free(inner); + + unlink(fixture); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +TEST(tool_query_graph_max_rows_above_ceiling_is_capped) { + 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 = "row-ceiling"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/row-ceiling"), CBM_STORE_OK); + /* One row more than the visible ceiling (99998), so the cap is observable. */ + enum { ROW_CEILING = 99998, ROWS = ROW_CEILING + 1 }; + ASSERT_EQ(cbm_store_begin_bulk(store), CBM_STORE_OK); + ASSERT_EQ(cbm_store_begin(store), CBM_STORE_OK); + for (int i = 0; i < ROWS; i++) { + char name[32]; + char qualified_name[64]; + snprintf(name, sizeof(name), "row_%05d", i); + snprintf(qualified_name, sizeof(qualified_name), "row-ceiling.rows.%s", name); + cbm_node_t node = {.project = project, + .label = "Function", + .name = name, + .qualified_name = qualified_name, + .file_path = "rows.c", + .start_line = i + 1, + .end_line = i + 1}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + } + ASSERT_EQ(cbm_store_commit(store), CBM_STORE_OK); + ASSERT_EQ(cbm_store_end_bulk(store), CBM_STORE_OK); + + char *response = cbm_mcp_handle_tool( + srv, "query_graph", + "{\"project\":\"row-ceiling\",\"query\":\"MATCH (n) RETURN n.name\"," + "\"format\":\"json\",\"max_rows\":2147483647,\"max_output_tokens\":1000000}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "returned")), ROW_CEILING); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "total")), ROWS); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(root, "has_more"))); + yyjson_doc_free(doc); + free(inner); + free(response); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_search_graph_limit_above_ceiling_is_capped) { + 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 = "limit-ceiling"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/limit-ceiling"), CBM_STORE_OK); + enum { LIMIT_CEILING = 500, NODES = 600 }; + ASSERT_EQ(cbm_store_begin(store), CBM_STORE_OK); + for (int i = 0; i < NODES; i++) { + char name[32]; + char qualified_name[64]; + snprintf(name, sizeof(name), "fn_%03d", i); + snprintf(qualified_name, sizeof(qualified_name), "limit-ceiling.m.%s", name); + cbm_node_t node = {.project = project, + .label = "Function", + .name = name, + .qualified_name = qualified_name, + .file_path = "m.c", + .start_line = i + 1, + .end_line = i + 1}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + } + ASSERT_EQ(cbm_store_commit(store), CBM_STORE_OK); + + char *response = + cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-ceiling\",\"label\":\"Function\",\"limit\":9999," + "\"format\":\"json\",\"max_output_tokens\":1000000}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "returned")), LIMIT_CEILING); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "total")), NODES); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(root, "has_more"))); + yyjson_doc_free(doc); + free(inner); + free(response); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_trace_cursor_truncated_after_leg_is_rejected) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "cursor-leg"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/cursor-leg"), CBM_STORE_OK); + cbm_node_t hub = {.project = proj, + .label = "Function", + .name = "hub", + .qualified_name = "cursor-leg.h.hub", + .file_path = "h.c", + .start_line = 1, + .end_line = 9}; + int64_t hid = cbm_store_upsert_node(st, &hub); + ASSERT_GT(hid, 0); + cbm_node_t callee = {.project = proj, + .label = "Function", + .name = "leaf", + .qualified_name = "cursor-leg.m.leaf", + .file_path = "m.c", + .start_line = 1, + .end_line = 3}; + int64_t lid = cbm_store_upsert_node(st, &callee); + ASSERT_GT(lid, 0); + cbm_edge_t e = {.project = proj, .source_id = hid, .target_id = lid, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &e), 0); + + /* A token that ends right after the leg character has no generation, + * hash, hop or node fields to read; it is rejected like any other + * malformed token. */ + static const char *const tokens[] = {"c1.o", "c1.i"}; + for (size_t t = 0; t < sizeof(tokens) / sizeof(tokens[0]); t++) { + char req[256]; + snprintf(req, sizeof(req), + "{\"project\":\"cursor-leg\",\"function_name\":\"hub\"," + "\"direction\":\"outbound\",\"limit\":5,\"cursor\":\"%s\"}", + tokens[t]); + char *response = cbm_mcp_handle_tool(srv, "trace_call_path", req); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + free(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "invalid_cursor")); + free(inner); + } + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_code_rejects_quote_in_file_pattern) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Both quote characters are rejected before any file is scanned. */ + static const char *const patterns[] = {"*.go\\\"", "*.go'"}; + for (size_t p = 0; p < sizeof(patterns) / sizeof(patterns[0]); p++) { + char req[256]; + snprintf(req, sizeof(req), + "{\"project\":\"test-project\",\"pattern\":\"HandleRequest\"," + "\"file_pattern\":\"%s\",\"format\":\"json\"}", + patterns[p]); + char *response = cbm_mcp_handle_tool(srv, "search_code", req); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + free(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "path or file_pattern contains invalid characters")); + free(inner); + } + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + SUITE(mcp) { RUN_TEST(index_repository_async_and_status_refused_without_daemon_issue2144); RUN_TEST(index_repository_schema_documents_async_polling_issue2144); @@ -21361,6 +21701,12 @@ SUITE(mcp) { RUN_TEST(autoindex_limit_guards_non_git_root_issue713); RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713); RUN_TEST(autoindex_limit_guards_git_root_issue713); + RUN_TEST(search_code_context_window_is_bounded); + RUN_TEST(search_code_context_extreme_values_answer_normally); + RUN_TEST(tool_query_graph_max_rows_above_ceiling_is_capped); + RUN_TEST(tool_search_graph_limit_above_ceiling_is_capped); + RUN_TEST(tool_trace_cursor_truncated_after_leg_is_rejected); + RUN_TEST(search_code_rejects_quote_in_file_pattern); } /* Kept separate so daemon-coordination regressions can be iterated without From 9db7de7bae49b7273c764685b6a1ef3733d91bc0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 01:55:34 +0200 Subject: [PATCH 19/50] fix(regex): size a pattern before compiling and refuse oversized ones cbm_regcomp() handed every pattern straight to the platform compiler (libc regcomp on POSIX, the vendored TRE on Windows). Both expand bounded repetition at compile time, so the compiled size of a pattern is not proportional to its length. Measured on macOS libc (TRE-derived) with REG_EXTENDED|REG_NOSUB, the compiled form costs about 0.6 KB per expanded atom (1.1 KB with submatch tracking, 1.7 KB with REG_ICASE), a K-way alternation adds about 70 B x K^2 for its position sets, and an alternation under an interval pays that for every copy. The wrapper now sizes a pattern in one linear pass before compiling it: an atom costs 1, a group costs its content, an interval multiplies the element it applies to by its upper bound, and a K-way alternation is charged K^2/8 on top of its branches. Arithmetic saturates and group nesting is bounded. A pattern longer than 32768 bytes, or whose estimate exceeds 32768 units, is refused with CBM_REG_ETOOBIG before anything is allocated. The heaviest accepted compile is a repeated alternation at about 30 MB with REG_NOSUB (about 90 MB under the ICASE-plus-submatch flag set no call site uses). Ordinary intervals, the pipeline's fixed patterns and an alternation of 400 twenty-character identifiers remain accepted. The refusal reads as a normal compile error at every call site. search_code now reports an unusable path_filter as a tool error instead of silently dropping the filter, search_graph reports a failed row scan instead of returning an empty result, and query_graph carries the reason in the result's warning field; all three name it. Signed-off-by: Martin Vogel --- src/cypher/cypher.c | 31 ++++- src/foundation/compat_regex.c | 250 ++++++++++++++++++++++++++++++++++ src/foundation/compat_regex.h | 38 +++++- src/mcp/mcp.c | 64 ++++++--- src/store/store.c | 14 +- tests/test_cypher.c | 38 ++++++ tests/test_mcp.c | 83 +++++++++++ tests/test_platform.c | 234 +++++++++++++++++++++++++++++++ 8 files changed, 724 insertions(+), 28 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 3cc31f9d7..fde4798d1 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -2594,6 +2594,12 @@ static const char *resolve_condition_value(const cbm_condition_t *c, binding_t * return n->name ? n->name : ""; } +/* Set when an `=~` or inline-property pattern was refused by the regex + * wrapper's compile-size guard during the CURRENT execution; cbm_cypher_execute + * turns it into result->warning so an empty result can be told from "no such + * name". Reset at the start of every execution. */ +static _Thread_local bool g_cypher_regex_refused = false; + /* Evaluate a comparison operator between actual and expected strings. */ static bool eval_comparison_op(const char *op, const char *actual, const char *expected) { if (strcmp(op, "=") == 0) { @@ -2604,7 +2610,11 @@ static bool eval_comparison_op(const char *op, const char *actual, const char *e } if (strcmp(op, "=~") == 0) { cbm_regex_t re; - if (cbm_regcomp(&re, expected, CBM_REG_EXTENDED | CBM_REG_NOSUB) != 0) { + int comp_rc = cbm_regcomp(&re, expected, CBM_REG_EXTENDED | CBM_REG_NOSUB); + if (comp_rc != 0) { + if (comp_rc == CBM_REG_ETOOBIG) { + g_cypher_regex_refused = true; + } return false; } int rc = cbm_regexec(&re, actual, 0, NULL, 0); @@ -2784,13 +2794,17 @@ static bool check_inline_props(const cbm_node_t *n, const cbm_prop_filter_t *pro const char *actual = node_prop(n, props[i].key, store); if (looks_like_regex(props[i].value)) { cbm_regex_t re; - if (cbm_regcomp(&re, props[i].value, CBM_REG_EXTENDED | CBM_REG_NOSUB) == 0) { + int comp_rc = cbm_regcomp(&re, props[i].value, CBM_REG_EXTENDED | CBM_REG_NOSUB); + if (comp_rc == 0) { bool matched = cbm_regexec(&re, actual, 0, NULL, 0) == 0; cbm_regfree(&re); if (!matched) { return false; } } else if (strcmp(actual, props[i].value) != 0) { + if (comp_rc == CBM_REG_ETOOBIG) { + g_cypher_regex_refused = true; + } return false; } } else if (strcmp(actual, props[i].value) != 0) { @@ -5447,6 +5461,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec g_cypher_depth_clamped = 0; g_cypher_trail_truncated = 0; g_cypher_truncated = false; + g_cypher_regex_refused = false; cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */ if (max_rows <= 0) { max_rows = CYPHER_RESULT_CEILING; @@ -5534,8 +5549,8 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec /* Any internal ceiling that prevented exhaustive evaluation: a candidate or * traversal budget, or a variable-length range clamped to the engine cap. */ out->truncated = g_cypher_truncated || g_cypher_trail_truncated != 0; + char wbuf[CBM_SZ_512] = ""; if (g_cypher_depth_clamped > 0 || g_cypher_trail_truncated) { - char wbuf[CBM_SZ_256]; if (g_cypher_depth_clamped > 0 && g_cypher_trail_truncated) { snprintf(wbuf, sizeof(wbuf), "variable-length hop range clamped to the engine ceiling (%d) and " @@ -5550,6 +5565,16 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec snprintf(wbuf, sizeof(wbuf), "variable-length traversal budget was exhausted — results may be partial"); } + } + /* A refused `=~` or property pattern matched nothing: say so, once per + * query, next to any traversal warning. */ + if (g_cypher_regex_refused) { + size_t used = strlen(wbuf); + snprintf(wbuf + used, sizeof(wbuf) - used, + "%sa =~ or property " CBM_REG_ETOOBIG_REASON " — the comparison matched nothing", + used ? "; " : ""); + } + if (wbuf[0]) { out->warning = heap_strdup(wbuf); } diff --git a/src/foundation/compat_regex.c b/src/foundation/compat_regex.c index 14bd815fb..f79bd01d2 100644 --- a/src/foundation/compat_regex.c +++ b/src/foundation/compat_regex.c @@ -3,12 +3,254 @@ * * POSIX: direct wrappers around . * Windows: vendored TRE regex library (BSD-licensed). + * + * Both backends expand bounded repetition at compile time, so cbm_regcomp + * sizes a pattern first (the compile-size guard below) and refuses one whose + * compiled form would be too large, before the platform compiler allocates + * anything. */ #include "foundation/constants.h" #include "foundation/compat_regex.h" +#include +#include #include +/* ── Compile-size guard (shared by both backends) ───────────────── + * + * The platform compilers expand `X{m,n}` into n copies of X, so nested + * intervals multiply: `((((a){20}){20}){20}){20}` is 160,000 expanded atoms + * from 25 bytes of pattern. A K-way alternation additionally costs about K^2 + * position-set entries on the TRE-derived backends (macOS libc and the vendored + * TRE), because every union node copies its branches' sets. + * + * Measured on macOS libc with REG_EXTENDED|REG_NOSUB: the nested shape costs + * about 0.6 KB of heap per expanded atom, 1.1 KB with submatch tracking on, + * 1.7 KB with REG_ICASE and 2.3 KB with both; a K-way alternation adds about + * 70 B x K^2 on top of its branches (twice that under REG_ICASE), and an + * alternation under an interval pays that for every copy, about 1 KB per unit + * with REG_NOSUB and 2.8 KB with REG_ICASE plus submatches. glibc costs about + * 0.45 KB per atom and expands intervals the same way. + * + * cbm_regcomp_estimate_units() counts expanded atoms in one linear pass with + * saturating arithmetic and charges a K-way alternation K^2/8 units, the + * measured ratio between the two costs. With CBM_REGEX_COMPILE_BUDGET_UNITS = + * 32768 the heaviest accepted compile is a repeated alternation at about 30 MB + * with REG_NOSUB and about 90 MB under the ICASE-plus-submatch flag set no + * call site uses; the nested shape tops out at 13^4 = 28,561 units, about + * 16 MB. `(\w{1,100}){1,20}` (2,000 units) and an alternation of 500 short + * names (31,250 units plus its letters) remain accepted. */ + +enum { REGEX_GROUP_DEPTH_MAX = 64 }; + +typedef struct { + uint64_t total; /* units of this group so far; includes `last` */ + uint64_t last; /* units of the element a following quantifier applies to */ + uint64_t branches; /* alternatives in this group: 1 + the number of '|' */ +} regex_frame_t; + +static uint64_t regex_sat_add(uint64_t a, uint64_t b) { + return a > UINT64_MAX - b ? UINT64_MAX : a + b; +} + +static uint64_t regex_sat_mul(uint64_t a, uint64_t b) { + if (a == 0 || b == 0) { + return 0; + } + return a > UINT64_MAX / b ? UINT64_MAX : a * b; +} + +static void regex_frame_atom(regex_frame_t *f, uint64_t units) { + f->total = regex_sat_add(f->total, units); + f->last = units; +} + +/* An interval applies to the last element: n copies of it replace the one. */ +static void regex_frame_repeat_last(regex_frame_t *f, uint64_t copies) { + uint64_t grown = regex_sat_mul(f->last, copies); + f->total = regex_sat_add(f->total - f->last, grown); + f->last = grown; +} + +/* Units a closed group contributes to its parent: its content plus the K^2/8 + * alternation share, never less than one atom. */ +static uint64_t regex_frame_units(const regex_frame_t *f) { + uint64_t alternation = regex_sat_mul(f->branches, f->branches) / 8; + uint64_t units = regex_sat_add(f->total, alternation); + return units ? units : 1; +} + +/* Skip the bracket expression opening at p[i] == '['. Returns the index just + * past its closing ']', or the end of the string when it is unterminated. + * Nothing inside is an operator: `[]a]`, `[^]a]` and `[[:alpha:]]` are each + * one atom, and a brace inside brackets never opens an interval. */ +static size_t regex_skip_bracket(const char *p, size_t i) { + size_t j = i + 1; + if (p[j] == '^') { + j++; + } + if (p[j] == ']') { + j++; + } + while (p[j] && p[j] != ']') { + if (p[j] == '[' && (p[j + 1] == ':' || p[j + 1] == '=' || p[j + 1] == '.')) { + char close = p[j + 1]; + size_t k = j + 2; + while (p[k] && !(p[k] == close && p[k + 1] == ']')) { + k++; + } + j = p[k] ? k + 2 : k; + } else { + j++; + } + } + return p[j] ? j + 1 : j; +} + +static bool regex_parse_digits(const char *p, size_t *i, uint64_t *value) { + bool any = false; + *value = 0; + while (p[*i] >= '0' && p[*i] <= '9') { + *value = regex_sat_add(regex_sat_mul(*value, 10), (uint64_t)(p[*i] - '0')); + (*i)++; + any = true; + } + return any; +} + +/* Parse the interval body that starts at p[i], just past the opening brace + * (`{` in extended syntax, `\{` in basic). On success stores the number of + * copies the compiler will make (the upper bound, or the lower bound for + * `{m,}`, at least 1) and the index just past the closing brace. Anything that + * is not an interval leaves the brace to be counted as a literal, which is what + * the backends that accept it do; the others reject the pattern. */ +static bool regex_parse_interval(const char *p, size_t i, bool extended, uint64_t *copies, + size_t *end) { + uint64_t lower = 0; + uint64_t upper = 0; + bool has_lower = regex_parse_digits(p, &i, &lower); + bool has_upper = false; + if (p[i] == ',') { + i++; + has_upper = regex_parse_digits(p, &i, &upper); + } else { + if (!has_lower) { + return false; + } + upper = lower; + has_upper = true; + } + if (extended) { + if (p[i] != '}') { + return false; + } + i++; + } else { + if (p[i] != '\\' || p[i + 1] != '}') { + return false; + } + i += 2; + } + uint64_t n = has_upper ? upper : lower; + if (has_upper && lower > upper) { + n = lower; /* `{5,3}` is rejected by the backends; count the larger bound */ + } + *copies = n ? n : 1; + *end = i; + return true; +} + +uint64_t cbm_regcomp_estimate_units(const char *pattern, int flags) { + const bool extended = (flags & CBM_REG_EXTENDED) != 0; + regex_frame_t frames[REGEX_GROUP_DEPTH_MAX]; + int depth = 0; + frames[0] = (regex_frame_t){0, 0, 1}; + + size_t i = 0; + while (pattern[i]) { + char c = pattern[i]; + if (c == '[') { + i = regex_skip_bracket(pattern, i); + regex_frame_atom(&frames[depth], 1); + continue; + } + /* In extended syntax the bare characters operate and `\x` is one + * literal; in basic syntax only `\(`, `\)`, `\{`, `\|`, `\+`, `\?` and + * a bare `*` operate. */ + char op = 0; + size_t len = 1; + if (c == '\\') { + len = pattern[i + 1] ? 2 : 1; + if (!extended && len == 2 && strchr("(){|+?", pattern[i + 1])) { + op = pattern[i + 1]; + } + } else if (extended ? strchr("(){|*+?", c) != NULL : c == '*') { + op = c; + } + regex_frame_t *f = &frames[depth]; + switch (op) { + case '(': + if (depth + 1 >= REGEX_GROUP_DEPTH_MAX) { + return UINT64_MAX; + } + frames[++depth] = (regex_frame_t){0, 0, 1}; + break; + case ')': + if (depth == 0) { + regex_frame_atom(f, 1); /* unmatched: a literal where accepted */ + break; + } + depth--; + regex_frame_atom(&frames[depth], regex_frame_units(f)); + break; + case '|': + f->branches = regex_sat_add(f->branches, 1); + f->last = 0; + break; + case '{': { + uint64_t copies = 0; + size_t end = 0; + if (regex_parse_interval(pattern, i + len, extended, &copies, &end)) { + regex_frame_repeat_last(f, copies); + i = end; + continue; + } + regex_frame_atom(f, 1); + break; + } + case '*': + case '+': + case '?': + break; /* unbounded or optional repetition is not expanded */ + default: + regex_frame_atom(f, 1); + break; + } + i += len; + } + while (depth > 0) { + regex_frame_t *f = &frames[depth]; + depth--; + regex_frame_atom(&frames[depth], regex_frame_units(f)); + } + return regex_frame_units(&frames[0]); +} + +/* Refuse a pattern before the platform compiler sees it. */ +static int regex_compile_guard(const char *pattern, int flags) { + size_t len = 0; + while (len <= (size_t)CBM_REGEX_PATTERN_MAX_BYTES && pattern[len]) { + len++; + } + if (len > (size_t)CBM_REGEX_PATTERN_MAX_BYTES) { + return CBM_REG_ETOOBIG; + } + if (cbm_regcomp_estimate_units(pattern, flags) > (uint64_t)CBM_REGEX_COMPILE_BUDGET_UNITS) { + return CBM_REG_ETOOBIG; + } + return CBM_REG_OK; +} + #ifdef _WIN32 /* ── Windows: use vendored TRE regex ─────────────────────────── */ @@ -31,6 +273,10 @@ static int translate_flags_tre(int flags) { } int cbm_regcomp(cbm_regex_t *r, const char *pattern, int flags) { + int refused = regex_compile_guard(pattern, flags); + if (refused != CBM_REG_OK) { + return refused; + } regex_t *re = (regex_t *)r->opaque; int rc = tre_regcomp(re, pattern, translate_flags_tre(flags)); return rc == 0 ? CBM_REG_OK : rc; @@ -88,6 +334,10 @@ static int translate_flags(int flags) { } int cbm_regcomp(cbm_regex_t *r, const char *pattern, int flags) { + int refused = regex_compile_guard(pattern, flags); + if (refused != CBM_REG_OK) { + return refused; + } regex_t *re = (regex_t *)r->opaque; int rc = regcomp(re, pattern, translate_flags(flags)); return rc == 0 ? CBM_REG_OK : rc; diff --git a/src/foundation/compat_regex.h b/src/foundation/compat_regex.h index c57245967..7545b2571 100644 --- a/src/foundation/compat_regex.h +++ b/src/foundation/compat_regex.h @@ -11,6 +11,7 @@ #include "foundation/constants.h" #include +#include /* ── Flags ────────────────────────────────────────────────────── */ @@ -23,6 +24,29 @@ #define CBM_REG_OK 0 #define CBM_REG_NOMATCH (-1) +/* cbm_regcomp refused the pattern before compiling it: longer than + * CBM_REGEX_PATTERN_MAX_BYTES, or its compiled form would exceed + * CBM_REGEX_COMPILE_BUDGET_UNITS (see cbm_regcomp_estimate_units). Well above + * every platform compile error code, which are small positive integers. */ +#define CBM_REG_ETOOBIG 100 +/* Reason text for a CBM_REG_ETOOBIG refusal, for user-facing error messages. */ +#define CBM_REG_ETOOBIG_REASON \ + "regex pattern too large to compile: reduce nested {m,n} repetition or alternation" + +/* ── Compile-size guard ───────────────────────────────────────── */ + +enum { + /* Longest pattern cbm_regcomp hands to the platform compiler. Bounds only + * the sizing pass: a pattern without repetition costs one unit per atom, + * so the unit budget below already bounds its compiled size. Equal to the + * budget, so a realistic pattern is only ever refused for its cost. */ + CBM_REGEX_PATTERN_MAX_BYTES = CBM_SZ_32K, + /* Largest estimated compiled size, in units of one expanded atom, that + * cbm_regcomp accepts. Bounds what bounded repetition and alternation can + * expand a pattern into. compat_regex.c documents the measured bytes per + * unit and what the budget admits. */ + CBM_REGEX_COMPILE_BUDGET_UNITS = CBM_SZ_32K +}; /* ── Types ────────────────────────────────────────────────────── */ @@ -40,9 +64,21 @@ typedef struct { /* ── Functions ────────────────────────────────────────────────── */ -/* Compile a regular expression. Returns CBM_REG_OK on success, non-zero on error. */ +/* Compile a regular expression. Returns CBM_REG_OK on success, non-zero on error: + * CBM_REG_ETOOBIG when the pattern is refused before compilation (nothing to + * free), otherwise the platform compiler's error code. */ int cbm_regcomp(cbm_regex_t *r, const char *pattern, int flags); +/* Estimated size of the compiled form of pattern, in units of one expanded + * atom: an atom costs 1, concatenation and alternation add, a group costs its + * content, and an interval `{m}`, `{m,}`, `{m,n}` multiplies the element it + * applies to by the number of copies the compiler makes (the upper bound; m for + * `{m,}`; at least 1), so nested intervals multiply. `*`, `+` and `?` do not + * multiply. A K-way alternation adds K*K/8 for its position sets. Arithmetic + * saturates at UINT64_MAX; a group nesting deeper than the pass tracks returns + * UINT64_MAX. Honors CBM_REG_EXTENDED (basic syntax otherwise). */ +uint64_t cbm_regcomp_estimate_units(const char *pattern, int flags); + /* Execute compiled regex against str. nmatch/matches may be 0/NULL. * eflags: 0 or combination of platform-specific exec flags. * Returns CBM_REG_OK on match, CBM_REG_NOMATCH on no match. */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index c877d5110..f6306077c 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -5336,8 +5336,11 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { max_degree != CBM_NOT_FOUND; bool semantic_only = sq_present && !has_filters; cbm_search_output_t out = {0}; - if (!semantic_only) { - (void)cbm_store_search(store, ¶ms, &out); + char search_error[CBM_SZ_512] = ""; + if (!semantic_only && cbm_store_search(store, ¶ms, &out) != CBM_STORE_OK) { + /* A refused or invalid regex aborts the row scan inside SQLite; the + * store's reason is the caller's only hint about the pattern. */ + snprintf(search_error, sizeof(search_error), "search_graph: %s", cbm_store_error(store)); } const char *diagnostic_hint = NULL; @@ -5420,7 +5423,9 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { free(file_pattern); free(relationship); - char *result = cbm_mcp_text_result(payload ? payload : "out of memory", payload == NULL); + char *result = search_error[0] + ? cbm_mcp_text_result(search_error, true) + : cbm_mcp_text_result(payload ? payload : "out of memory", payload == NULL); free(payload); return result; } @@ -14821,12 +14826,16 @@ static bool search_scratch_open(search_scratch_t *scratch, const char *pattern) return true; } -/* Compile a path filter regex. Returns true if compiled successfully. */ -static bool compile_path_filter(const char *filter, cbm_regex_t *re) { +/* Compile a path filter regex. Returns true when *re holds the compiled filter. + * An absent or empty filter compiles nothing and reports CBM_REG_OK in *rc; a + * filter the regex wrapper refuses or cannot compile reports its error there. */ +static bool compile_path_filter(const char *filter, cbm_regex_t *re, int *rc) { + *rc = CBM_REG_OK; if (!filter || !filter[0]) { return false; } - return cbm_regcomp(re, filter, CBM_REG_EXTENDED | CBM_REG_NOSUB) == CBM_REG_OK; + *rc = cbm_regcomp(re, filter, CBM_REG_EXTENDED | CBM_REG_NOSUB); + return *rc == CBM_REG_OK; } static mcp_scan_cause_t mcp_run_shell_command_cancellable_bounded( @@ -14979,7 +14988,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { size_t byte_budget = (size_t)max_output_tokens * (size_t)MCP_OUTPUT_BYTES_PER_TOKEN_ESTIMATE; cbm_regex_t path_regex; - bool has_path_filter = compile_path_filter(path_filter, &path_regex); + int path_filter_rc = CBM_REG_OK; /* reported with the regex probe below */ + bool has_path_filter = compile_path_filter(path_filter, &path_regex, &path_filter_rc); free(path_filter); path_filter = NULL; @@ -15025,21 +15035,35 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { * unclosed group) makes the underlying grep fail, which the handler would * otherwise report as an empty result set — indistinguishable from a * legitimate no-match. Validate the user's regex up front and return an - * explicit error so callers can tell "broken pattern" from "no matches". */ - if (use_regex) { + * explicit error so callers can tell "broken pattern" from "no matches". + * A path_filter the regex wrapper refused or could not compile is reported + * the same way instead of silently searching unfiltered. */ + const char *regex_error = NULL; + if (path_filter_rc != CBM_REG_OK) { + regex_error = path_filter_rc == CBM_REG_ETOOBIG + ? "path_filter: " CBM_REG_ETOOBIG_REASON + : "invalid path_filter regex: check for unbalanced (), [], or {}"; + } else if (use_regex) { cbm_regex_t probe; - if (cbm_regcomp(&probe, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB) != CBM_REG_OK) { - if (has_path_filter) { - cbm_regfree(&path_regex); - } - free(root_path); - free(pattern); - free(project); - free(file_pattern); - return cbm_mcp_text_result( - "invalid regex pattern (regex=true): check for unbalanced (), [], or {}", true); + int probe_rc = cbm_regcomp(&probe, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB); + if (probe_rc != CBM_REG_OK) { + regex_error = + probe_rc == CBM_REG_ETOOBIG + ? CBM_REG_ETOOBIG_REASON " (regex=true)" + : "invalid regex pattern (regex=true): check for unbalanced (), [], or {}"; + } else { + cbm_regfree(&probe); } - cbm_regfree(&probe); + } + if (regex_error) { + if (has_path_filter) { + cbm_regfree(&path_regex); + } + free(root_path); + free(pattern); + free(project); + free(file_pattern); + return cbm_mcp_text_result(regex_error, true); } /* ── Phase 0.5: Multi-word → regex conversion ───────────── */ diff --git a/src/store/store.c b/src/store/store.c index d66ef0ffb..449dad5cf 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -719,9 +719,12 @@ static void sqlite_regexp(sqlite3_context *ctx, int argc, sqlite3_value **argv) sqlite3_result_error_nomem(ctx); return; } - if (cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB) != 0) { + int rc = cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB); + if (rc != 0) { free(re); - sqlite3_result_error(ctx, "invalid regex", CBM_NOT_FOUND); + sqlite3_result_error(ctx, + rc == CBM_REG_ETOOBIG ? CBM_REG_ETOOBIG_REASON : "invalid regex", + CBM_NOT_FOUND); return; } sqlite3_set_auxdata(ctx, 0, re, regex_free_cb); @@ -747,9 +750,12 @@ static void sqlite_iregexp(sqlite3_context *ctx, int argc, sqlite3_value **argv) sqlite3_result_error_nomem(ctx); return; } - if (cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB | CBM_REG_ICASE) != 0) { + int rc = cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB | CBM_REG_ICASE); + if (rc != 0) { free(re); - sqlite3_result_error(ctx, "invalid regex", CBM_NOT_FOUND); + sqlite3_result_error(ctx, + rc == CBM_REG_ETOOBIG ? CBM_REG_ETOOBIG_REASON : "invalid regex", + CBM_NOT_FOUND); return; } sqlite3_set_auxdata(ctx, 0, re, regex_free_cb); diff --git a/tests/test_cypher.c b/tests/test_cypher.c index abd334683..9bce9043a 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -1442,6 +1442,43 @@ TEST(cypher_exec_where_regex) { PASS(); } +/* An `=~` pattern over the regex wrapper's compile-size budget is refused + * before the platform compiler expands it; the comparison then matches nothing, + * as for any pattern that cannot be compiled, the result carries a warning + * naming the reason, and the engine keeps answering. The pattern is a 509-way + * alternation whose first branch is `.`: once compiled it matches every name, + * and it costs 509 + 509*509/8 = 32,894 units, just over the budget. */ +TEST(cypher_exec_where_regex_oversized_pattern_matches_nothing) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + + char query[1200]; + char *w = query; + w += snprintf(w, sizeof(query), "MATCH (f:Function) WHERE f.name =~ \"(."); + for (int i = 0; i < 508; i++) { + *w++ = '|'; + *w++ = (char)('a' + i % 26); + } + snprintf(w, (size_t)(query + sizeof(query) - w), ")\""); + + int rc = cbm_cypher_execute(s, query, "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 0); + ASSERT_NOT_NULL(r.warning); + ASSERT_NOT_NULL(strstr(r.warning, "too large to compile")); + cbm_cypher_result_free(&r); + + cbm_cypher_result_t r2 = {0}; + rc = cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name =~ \".*Order.*\"", "test", 0, &r2); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r2.row_count, 3); + ASSERT_NULL(r2.warning); + cbm_cypher_result_free(&r2); + + cbm_store_close(s); + PASS(); +} + TEST(cypher_exec_where_contains) { cbm_store_t *s = setup_cypher_store(); cbm_cypher_result_t r = {0}; @@ -4928,6 +4965,7 @@ SUITE(cypher) { RUN_TEST(cypher_exec_varlength_path_semantics_issue797); RUN_TEST(cypher_exec_where_coalesce_issue874); RUN_TEST(cypher_exec_where_regex); + RUN_TEST(cypher_exec_where_regex_oversized_pattern_matches_nothing); RUN_TEST(cypher_exec_where_contains); RUN_TEST(cypher_exec_where_starts_with); RUN_TEST(cypher_exec_return_properties); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 22af972af..dcc2d4d16 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -10992,6 +10992,87 @@ TEST(search_code_path_filter_matches_nothing) { PASS(); } +/* An over-budget regex is refused by the wrapper before the platform compiler + * expands it. search_code reports that as a tool error naming the argument and + * the reason, for path_filter and for a regex=true pattern alike, and keeps + * answering afterwards. The pattern is 14^4 = 38,416 expanded atoms: over the + * 32,768-unit budget, and tens of MB to hold even when unguarded. */ +TEST(search_code_oversized_path_filter_is_tool_error) { + char tmp[512], src_path[768], vendor_path[768]; + cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), + vendor_path, sizeof(vendor_path)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":97,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"," + "\"path_filter\":\"((((a){14}){14}){14}){14}\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(resp, "path_filter")); + ASSERT_NOT_NULL(strstr(resp, "too large to compile")); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":98,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"((((a){14}){14}){14}){14}\",\"regex\":true," + "\"project\":\"prefilter-search\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(resp, "too large to compile")); + ASSERT_NOT_NULL(strstr(resp, "regex=true")); + free(resp); + + /* The server still answers an ordinary filtered call. */ + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"," + "\"path_filter\":\"^src/\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "src/handler.go")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cleanup_prefilter_dir(tmp, src_path, vendor_path); + PASS(); +} + +/* search_graph compiles name_pattern inside SQLite's REGEXP function. An + * over-budget pattern is refused there, the row scan aborts, and the tool + * reports the reason as a tool error instead of an empty result. The next call + * answers normally. */ +TEST(search_graph_oversized_name_pattern_is_tool_error) { + char tmp[512], src_path[768], vendor_path[768]; + cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), + vendor_path, sizeof(vendor_path)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"prefilter-search\",\"name_pattern\":\"((((a){14}){14}){14}){14}\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(resp, "too large to compile")); + free(resp); + + resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"prefilter-search\",\"name_pattern\":\"^HandleRequest$\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL); + ASSERT_NOT_NULL(strstr(resp, "HandleRequest")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_prefilter_dir(tmp, src_path, vendor_path); + PASS(); +} + TEST(search_code_file_pattern_prefilter_boundaries) { ASSERT_TRUE(cbm_search_code_file_pattern_can_prefilter("*.pas")); ASSERT_TRUE(cbm_search_code_file_pattern_can_prefilter("*.PAS")); @@ -21230,6 +21311,8 @@ SUITE(mcp) { RUN_TEST(search_code_path_filter_prefilter_keeps_matches); RUN_TEST(search_code_long_line_does_not_invent_matches); RUN_TEST(search_code_path_filter_matches_nothing); + RUN_TEST(search_code_oversized_path_filter_is_tool_error); + RUN_TEST(search_graph_oversized_name_pattern_is_tool_error); RUN_TEST(search_code_file_pattern_prefilter_boundaries); RUN_TEST(search_code_windows_scope_prefilter_removes_pipeline_filter); RUN_TEST(search_code_cancel_cleans_supervised_scan); diff --git a/tests/test_platform.c b/tests/test_platform.c index 16cc3b5d3..7545ea6c5 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -4,6 +4,7 @@ #include "test_framework.h" #include "../src/foundation/compat.h" /* cbm_setenv / cbm_unsetenv (Windows-portable) */ #include "../src/foundation/compat_fs.h" +#include "../src/foundation/compat_regex.h" #include "../src/foundation/constants.h" #include "../src/foundation/compat_thread.h" #include "../src/foundation/platform.h" @@ -1158,6 +1159,234 @@ TEST(cgroup_no_mem_files) { #endif /* __linux__ */ +/* ── compat_regex: compile-size guard ──────────────────────────── */ + +static uint64_t regex_units_ere(const char *pattern) { + return cbm_regcomp_estimate_units(pattern, CBM_REG_EXTENDED); +} + +static uint64_t regex_units_bre(const char *pattern) { + return cbm_regcomp_estimate_units(pattern, 0); +} + +/* Writes "(a|b|c|...)" with `branches` one-letter alternatives into buf, which + * must hold 2 * branches + 2 bytes. */ +static void regex_one_letter_alternation(char *buf, int branches) { + char *w = buf; + *w++ = '('; + for (int i = 0; i < branches; i++) { + if (i) { + *w++ = '|'; + } + *w++ = (char)('a' + i % 26); + } + *w++ = ')'; + *w = '\0'; +} + +/* Both regex backends expand `X{m,n}` into n copies of X at compile time, so + * nested intervals multiply and a 25-byte pattern can ask for millions of + * atoms. The wrapper sizes the pattern first and refuses one over the budget + * with CBM_REG_ETOOBIG before the platform compiler allocates anything, so + * there is nothing to free on refusal. The over-budget shapes here are small + * enough that an unguarded compile would still only cost tens of MB. */ +TEST(regex_compile_refuses_oversized_expansion) { + cbm_regex_t re; + /* 14^4 = 38,416 expanded atoms from 25 bytes: over the 32,768-unit budget. */ + const char *nested = "((((a){14}){14}){14}){14}"; + ASSERT_EQ(cbm_regcomp(&re, nested, CBM_REG_EXTENDED | CBM_REG_NOSUB), CBM_REG_ETOOBIG); + /* Every flag set the call sites use is refused the same way. */ + ASSERT_EQ(cbm_regcomp(&re, nested, CBM_REG_EXTENDED), CBM_REG_ETOOBIG); + ASSERT_EQ(cbm_regcomp(&re, nested, CBM_REG_EXTENDED | CBM_REG_NOSUB | CBM_REG_ICASE), + CBM_REG_ETOOBIG); + /* A repeated alternation multiplies too: (16 + 16*16/8) x 255 x 3 = 36,720. */ + ASSERT_EQ(cbm_regcomp(&re, "((a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p){255}){3}", + CBM_REG_EXTENDED | CBM_REG_NOSUB), + CBM_REG_ETOOBIG); + /* A plain alternation just over the budget: 509 one-letter branches cost + * 509 + 509*509/8 = 32,894 units; 508 cost 32,766 and stay accepted. */ + char alts[2 * 509 + 2]; + regex_one_letter_alternation(alts, 509); + ASSERT_EQ(regex_units_ere(alts), 32894); + ASSERT_EQ(cbm_regcomp(&re, alts, CBM_REG_EXTENDED | CBM_REG_NOSUB), CBM_REG_ETOOBIG); + regex_one_letter_alternation(alts, 508); + ASSERT_EQ(regex_units_ere(alts), 32766); + ASSERT_LTE(regex_units_ere(alts), (uint64_t)CBM_REGEX_COMPILE_BUDGET_UNITS); + /* Bounds too large to represent saturate instead of wrapping around. */ + ASSERT_EQ(cbm_regcomp(&re, "(a{99999999999999999999}){99999999999999999999}", + CBM_REG_EXTENDED | CBM_REG_NOSUB), + CBM_REG_ETOOBIG); + /* Nesting deeper than the sizing pass tracks is refused, not recursed into. */ + char deep[2 * 70 + 2]; + memset(deep, '(', 70); + deep[70] = 'a'; + memset(deep + 71, ')', 70); + deep[141] = '\0'; + ASSERT_EQ(cbm_regcomp(&re, deep, CBM_REG_EXTENDED | CBM_REG_NOSUB), CBM_REG_ETOOBIG); + PASS(); +} + +/* The same nested shape one step under the budget (13^4 = 28,561 units) + * compiles and matches, so the refusal starts only past the budget. */ +TEST(regex_compile_accepts_expansion_under_budget) { + cbm_regex_t re; + ASSERT_EQ(regex_units_ere("((((a){13}){13}){13}){13}"), 28561); + ASSERT_EQ(cbm_regcomp(&re, "((((a){13}){13}){13}){13}", CBM_REG_EXTENDED | CBM_REG_NOSUB), + CBM_REG_OK); + char *subject = malloc(28561 + 1); + ASSERT_NOT_NULL(subject); + memset(subject, 'a', 28561); + subject[28561] = '\0'; + ASSERT_EQ(cbm_regexec(&re, subject, 0, NULL, 0), CBM_REG_OK); + subject[28560] = '\0'; /* 28,560 a's: one short of 13^4 */ + ASSERT_EQ(cbm_regexec(&re, subject, 0, NULL, 0), CBM_REG_NOMATCH); + free(subject); + cbm_regfree(&re); + PASS(); +} + +/* A pattern longer than CBM_REGEX_PATTERN_MAX_BYTES is refused outright; one + * exactly at the cap still compiles (a literal at the cap is exactly the unit + * budget, which is accepted). */ +TEST(regex_compile_refuses_overlong_pattern) { + cbm_regex_t re; + char *pattern = malloc(CBM_REGEX_PATTERN_MAX_BYTES + 2); + ASSERT_NOT_NULL(pattern); + memset(pattern, 'a', CBM_REGEX_PATTERN_MAX_BYTES + 1); + pattern[CBM_REGEX_PATTERN_MAX_BYTES + 1] = '\0'; + ASSERT_EQ(cbm_regcomp(&re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB), CBM_REG_ETOOBIG); + pattern[CBM_REGEX_PATTERN_MAX_BYTES] = '\0'; + ASSERT_EQ(cbm_regcomp(&re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB), CBM_REG_OK); + cbm_regfree(&re); + free(pattern); + PASS(); +} + +/* Ordinary intervals, the pipeline's fixed patterns and patterns from the + * tool-level tests stay well inside the budget. */ +TEST(regex_compile_accepts_ordinary_intervals) { + static const char *const patterns[] = { + "[a-z]{1,64}", + ".{0,255}", + "(\\w{1,100}){1,20}", + "(foo|bar|baz){2,5}", + "^[A-Za-z_][A-Za-z0-9_]{0,255}$", + "[0-9]{4}-[0-9]{2}-[0-9]{2}", + /* pass_calls.c and pass_envscan.c */ + "Depends\\(([A-Za-z_][A-Za-z0-9_.]*)", + "^(ENV|ARG)[[:space:]]+([A-Za-z0-9_]+)[= ](.*)", + "(default|value)[[:space:]]*=[[:space:]]*\"(https?://[^\"]+)\"", + "(export[[:space:]]+)?([A-Za-z0-9_]+)=[\"']?(https?://[^ \t\"']+)", + /* test_mcp.c and test_cypher.c */ + ".*Order.*", + "^src/", + "^no_such_dir/", + "/api/.*", + "^design/", + }; + cbm_regex_t re; + for (size_t i = 0; i < sizeof(patterns) / sizeof(patterns[0]); i++) { + int rc = cbm_regcomp(&re, patterns[i], CBM_REG_EXTENDED | CBM_REG_NOSUB); + if (rc != CBM_REG_OK) { + printf(" pattern %zu refused with rc=%d: %s\n", i, rc, patterns[i]); + } + ASSERT_EQ(rc, CBM_REG_OK); + cbm_regfree(&re); + } + + /* A long alternation of identifiers, as a search for "any of these names" + * sends it: 400 names of up to nine characters is 3,761 bytes and about + * 23,400 units (20,000 of them the alternation share). It stays under both + * caps and compiles under the search_graph flag set. */ + static const char *const prefixes[] = {"parse", "emit", "visit", "bind", "scan"}; + char names[CBM_SZ_4K]; + char *w = names; + *w++ = '('; + for (int i = 0; i < 400; i++) { + w += snprintf(w, (size_t)(names + sizeof(names) - w), "%s%s_%03d", i ? "|" : "", + prefixes[i % 5], i); + } + *w++ = ')'; + *w = '\0'; + ASSERT_LTE(strlen(names), (size_t)CBM_REGEX_PATTERN_MAX_BYTES); + ASSERT_GTE(regex_units_ere(names), (uint64_t)20000); + ASSERT_LTE(regex_units_ere(names), (uint64_t)CBM_REGEX_COMPILE_BUDGET_UNITS); + ASSERT_EQ(cbm_regcomp(&re, names, CBM_REG_EXTENDED | CBM_REG_NOSUB | CBM_REG_ICASE), + CBM_REG_OK); + ASSERT_EQ(cbm_regexec(&re, "Bind_123", 0, NULL, 0), CBM_REG_OK); + ASSERT_EQ(cbm_regexec(&re, "bind_400", 0, NULL, 0), CBM_REG_NOMATCH); + cbm_regfree(&re); + + /* The same at realistic identifier length: 400 names of 20 characters is + * 8,401 bytes and 28,000 units, inside the budget — the length cap never + * decides a name list of this size, the unit budget does. */ + static const char *const long_prefixes[] = {"compute_checksum", "validate_request", + "serialize_record", "normalize_header", + "dispatch_message"}; + const size_t long_cap = 400 * 21 + 3; + char *long_names = malloc(long_cap); + ASSERT_NOT_NULL(long_names); + w = long_names; + *w++ = '('; + for (int i = 0; i < 400; i++) { + w += snprintf(w, long_cap - (size_t)(w - long_names), "%s%s_%03d", i ? "|" : "", + long_prefixes[i % 5], i); + } + *w++ = ')'; + *w = '\0'; + ASSERT_EQ(strlen(long_names), 8401); + ASSERT_EQ(regex_units_ere(long_names), 28000); + ASSERT_EQ(cbm_regcomp(&re, long_names, CBM_REG_EXTENDED | CBM_REG_NOSUB), CBM_REG_OK); + ASSERT_EQ(cbm_regexec(&re, "normalize_header_123", 0, NULL, 0), CBM_REG_OK); + ASSERT_EQ(cbm_regexec(&re, "normalize_header_400", 0, NULL, 0), CBM_REG_NOMATCH); + cbm_regfree(&re); + free(long_names); + + /* `.{0,500}` is 500 units, inside the budget. Whether it compiles is the + * platform's RE_DUP_MAX (255 on the TRE-derived libcs, 32767 on glibc), + * which the guard does not change. */ + ASSERT_LTE(regex_units_ere(".{0,500}"), (uint64_t)CBM_REGEX_COMPILE_BUDGET_UNITS); +#ifdef __linux__ + ASSERT_EQ(cbm_regcomp(&re, ".{0,500}", CBM_REG_EXTENDED | CBM_REG_NOSUB), CBM_REG_OK); + cbm_regfree(&re); +#endif + PASS(); +} + +/* Sizing must read the syntax the way the compilers do: nothing inside a + * bracket expression operates, a backslash escapes one character, braces that + * do not form an interval are literals, and only an interval multiplies. */ +TEST(regex_estimate_counts_brackets_and_escapes_literally) { + ASSERT_EQ(regex_units_ere("[{]{2}"), 2); + ASSERT_EQ(regex_units_ere("\\{"), 1); + ASSERT_EQ(regex_units_ere("a\\{2\\}"), 4); + ASSERT_EQ(regex_units_ere("[]a]{2}"), 2); + ASSERT_EQ(regex_units_ere("[^]a]{3}"), 3); + ASSERT_EQ(regex_units_ere("[[:alpha:]]{4}"), 4); + ASSERT_EQ(regex_units_ere("[a-z{3}]"), 1); + ASSERT_EQ(regex_units_ere("a{"), 2); + ASSERT_EQ(regex_units_ere("a{2"), 3); + ASSERT_EQ(regex_units_ere("a{x}"), 4); + ASSERT_EQ(regex_units_ere("a{2}"), 2); + ASSERT_EQ(regex_units_ere("a{2,}"), 2); + ASSERT_EQ(regex_units_ere("a{,3}"), 3); + ASSERT_EQ(regex_units_ere("a{2,5}"), 5); + ASSERT_EQ(regex_units_ere("a*b+c?"), 3); + ASSERT_EQ(regex_units_ere("a{3}{3}"), 9); + ASSERT_EQ(regex_units_ere("(ab){3}"), 6); + ASSERT_EQ(regex_units_ere("((ab){3}){4}"), 24); + /* A K-way alternation is charged K*K/8 on top of its branches. */ + ASSERT_EQ(regex_units_ere("a|b|c|d"), 4 + 2); + ASSERT_EQ(regex_units_ere("(a|b|c|d){10}"), 60); + /* Basic syntax: bare braces and parens are literals; `\{ \}` and `\( \)` + * operate. No call site uses it today, but the guard is shared. */ + ASSERT_EQ(regex_units_bre("a{2}"), 4); + ASSERT_EQ(regex_units_bre("a\\{2\\}"), 2); + ASSERT_EQ(regex_units_bre("\\(ab\\)\\{3\\}"), 6); + ASSERT_EQ(regex_units_bre("(ab){3}"), 7); + PASS(); +} + SUITE(platform) { RUN_TEST(platform_file_apis_survive_max_path_overflow); RUN_TEST(platform_mkdir_p_follows_own_symlink_only_when_opted_in); @@ -1192,6 +1421,11 @@ SUITE(platform) { RUN_TEST(platform_env_long_reads_a_clean_number); RUN_TEST(platform_env_long_refuses_what_it_cannot_read); RUN_TEST(platform_system_info); + RUN_TEST(regex_compile_refuses_oversized_expansion); + RUN_TEST(regex_compile_accepts_expansion_under_budget); + RUN_TEST(regex_compile_refuses_overlong_pattern); + RUN_TEST(regex_compile_accepts_ordinary_intervals); + RUN_TEST(regex_estimate_counts_brackets_and_escapes_literally); #ifdef __linux__ RUN_TEST(cgroup_v2_cpu_quota); RUN_TEST(cgroup_v2_cpu_quota_rounds_up); From 193b0a4d066f6ced193d9b68a673b5d5e19c4a56 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 02:21:12 +0200 Subject: [PATCH 20/50] fix(graph): an untyped C member access still binds an unambiguous member name The previous commit bound a C member access only through the type of its object. On this repository that removed 9% of the USAGE edges onto Fields; on the Linux kernel it removed 44% (2,131,043 -> 1,184,323), and 359,236 of the edges it dropped were onto a member name that exactly one Field in the whole tree carries. Those were never a guess. The kernel has far more member accesses the C LSP cannot type: designated initializers, structs reached through transitively included headers, structs duplicated per architecture. The defect in bare-name member resolution was the arbitrary pick among SEVERAL same-named members, not binding by name as such. The rule is now: 1. the object is typed: bind the Field its type names; 2. the object is not typed: bind the member name only when exactly one Field carries it (cbm_registry_unique_field_qn). Symbols of other labels are not candidates for a member; 3. otherwise bind nothing. It applies at all six sites: USAGE and READS/WRITES (sequential and parallel) and a member call that resolved onto a Field. In C++ mode the C LSP now also publishes the object's type when that type declares no such field (`text.size()` on a std::string). The row names no node, so the resolver sees "typed, no project Field meant" and the name fallback does not apply. C keeps the fallback there, because a C aggregate's nested anonymous members are not all registered on it. Linux kernel, old binary vs this commit (same machine, same run): wall 239 s -> 240 s; indexer CPU 1235 s -> 1249 s; peak RSS 15.8 -> 15.4 GB USAGE -> Field 2,131,043 -> 1,571,103 kept 1,248,385; newly typed to the object's struct 322,718 no longer bound 834,502, of which onto a unique member name 1,523 WRITES -> Field 948,187 -> 782,846 CALLS onto a Field 92,440 -> 1,778 nodes: Class +1,658, Enum +782, Field +12,519, Variable +5,646 Redis (from scratch): Class 913 -> 1092, Field 3854 -> 4563, CALLS onto a Field 455 -> 24. The 834,502 edges no longer bound are member names several structs share, on objects the LSP could not type: before, each was attached to one of the same-named Fields by registry preference. Typing designated initializers and following transitive includes would bind them correctly; neither is in this change. Tests: unique_field_qn_needs_exactly_one_field_of_that_name; the pipeline member-access tests gain the untyped-object cases (a shared name binds nothing, a unique one binds). Signed-off-by: Martin Vogel --- internal/cbm/lsp/c_lsp.c | 9 +++++++ src/pipeline/lsp_resolve.h | 14 ++++++---- src/pipeline/pass_calls.c | 15 ++++++++--- src/pipeline/pass_parallel.c | 50 ++++++++++++++++++++++++++++++------ src/pipeline/pass_usages.c | 36 +++++++++++++++++++++----- src/pipeline/pipeline.h | 11 +++++--- src/pipeline/registry.c | 31 +++++++++++++++++++++- tests/test_pipeline.c | 11 +++++--- tests/test_registry.c | 34 ++++++++++++++++++++++++ 9 files changed, 182 insertions(+), 29 deletions(-) diff --git a/internal/cbm/lsp/c_lsp.c b/internal/cbm/lsp/c_lsp.c index 1d90bbffa..aad2c1190 100644 --- a/internal/cbm/lsp/c_lsp.c +++ b/internal/cbm/lsp/c_lsp.c @@ -4041,6 +4041,15 @@ static void c_emit_field_reference(CLSPContext *ctx, TSNode node) { return; const CBMRegisteredType *owner = c_lookup_field_owner(ctx, type_qn, field_name, 0); + if (!owner && ctx->cpp_mode) { + /* C++: an object of a KNOWN type that declares no such field + * (`text.size()` on a std::string) is evidence that no project Field + * is meant. Publish the type anyway: the row names no node, so the + * resolvers bind nothing instead of falling back to the member name. + * C keeps the fallback, since a C aggregate's nested anonymous members + * are not all registered on it. */ + owner = c_resolve_field_owner_type(ctx, type_qn); + } if (!owner || !owner->qualified_name) return; diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 235f3f523..e52448dac 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -1022,19 +1022,23 @@ static inline const cbm_gbuf_node_t *cbm_pipeline_lsp_field_node(const cbm_gbuf_ return node && node->label && strcmp(node->label, "Field") == 0 ? node : NULL; } -/* The Field a C member call (`cb.close(ctx)`) lands on, decided by the type of - * its object: among the owner rows of (enclosing function, member name) that - * name a project Field, the one with the smallest QN, so the sequential scan - * and the sorted index agree. NULL when no owner is known. */ +/* The Field a C/C++ member call (`cb.close(ctx)`) lands on, decided by the + * type of its object: among the owner rows of (enclosing function, member + * name) that name a project Field, the one with the smallest QN, so the + * sequential scan and the sorted index agree. *typed says whether the LSP + * typed the object at all; NULL with *typed set means "typed, and no project + * Field is meant", NULL without it means the caller may try the member name. */ static inline const cbm_gbuf_node_t *cbm_pipeline_c_member_call_field( const CBMResolvedCallArray *arr, const cbm_pipeline_lsp_field_index_t *index, const cbm_gbuf_t *gbuf, const char *project_name, const char *enclosing_func_qn, - const char *callee_text) { + const char *callee_text, bool *typed) { cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( arr, index, enclosing_func_qn, cbm_lsp_bare_segment(callee_text)); const cbm_gbuf_node_t *best = NULL; const CBMResolvedCall *owner; + *typed = false; while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + *typed = true; const cbm_gbuf_node_t *field = cbm_pipeline_lsp_field_node(gbuf, project_name, owner); if (field && (!best || strcmp(field->qualified_name, best->qualified_name) < 0)) { best = field; diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index d5e87ea53..0c324f466 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -880,16 +880,25 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM target_node->file_path)) { case CBM_FIELD_CALL_DROP: return 0; - case CBM_FIELD_CALL_BY_OWNER: + case CBM_FIELD_CALL_BY_OWNER: { + bool typed = false; target_node = cbm_pipeline_c_member_call_field(lsp_calls, NULL, ctx->gbuf, ctx->project_name, - call->enclosing_func_qn, call->callee_name); + call->enclosing_func_qn, call->callee_name, &typed); + if (!target_node && !typed) { + /* Untyped object: only a member name the project holds exactly + * once may still bind. */ + const char *only = cbm_registry_unique_field_qn( + ctx->registry, cbm_lsp_bare_segment(call->callee_name)); + target_node = only ? cbm_gbuf_find_by_qn(ctx->gbuf, only) : NULL; + } if (!target_node || source_node->id == target_node->id) { return 0; } res.qualified_name = target_node->qualified_name; - res.strategy = "lsp_field_access"; + res.strategy = typed ? "lsp_field_access" : "unique_name"; break; + } case CBM_FIELD_CALL_KEEP: break; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 86e725242..14644cd51 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3156,14 +3156,22 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index); field_index_built = true; } + bool typed = false; target_node = cbm_pipeline_c_member_call_field( &result->resolved_calls, field_index_ready ? &field_index : NULL, rc->main_gbuf, - rc->project_name, call->enclosing_func_qn, call->callee_name); + rc->project_name, call->enclosing_func_qn, call->callee_name, &typed); + if (!target_node && !typed) { + /* Untyped object: only a member name the project holds + * exactly once may still bind. */ + const char *only = cbm_registry_unique_field_qn( + rc->registry, cbm_lsp_bare_segment(call->callee_name)); + target_node = only ? cbm_gbuf_find_by_qn(rc->main_gbuf, only) : NULL; + } if (!target_node || source_node->id == target_node->id) { continue; } res.qualified_name = target_node->qualified_name; - res.strategy = "lsp_field_access"; + res.strategy = typed ? "lsp_field_access" : "unique_name"; } } if (target_node && source_node->id != target_node->id && @@ -3241,20 +3249,35 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( &result->resolved_calls, field_index_ready ? &field_index : NULL, usage->enclosing_func_qn, usage->ref_name); + char member_esc[CBM_SZ_256]; + cbm_json_escape(member_esc, sizeof(member_esc), usage->ref_name); + char member_props[CBM_SZ_512]; + snprintf(member_props, sizeof(member_props), "{\"callee\":\"%s\"}", member_esc); const CBMResolvedCall *owner; + bool typed = false; while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + typed = true; const cbm_gbuf_node_t *field = cbm_pipeline_lsp_field_node(rc->main_gbuf, rc->project_name, owner); if (!field || field->id == src->id) { continue; } - char member_esc[CBM_SZ_256]; - cbm_json_escape(member_esc, sizeof(member_esc), usage->ref_name); - char member_props[CBM_SZ_512]; - snprintf(member_props, sizeof(member_props), "{\"callee\":\"%s\"}", member_esc); cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, "USAGE", member_props); ws->usages_resolved++; } + if (!typed) { + /* The LSP could not type the object: only a member name the + * project holds exactly once may still bind. */ + const char *only = cbm_registry_unique_field_qn(rc->registry, usage->ref_name); + const cbm_gbuf_node_t *field = + only ? cbm_gbuf_find_by_qn(rc->main_gbuf, only) : NULL; + if (field && field->id != src->id && + !cbm_suppress_cross_language_ref(lang, field->file_path)) { + cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, "USAGE", + member_props); + ws->usages_resolved++; + } + } continue; } const cbm_gbuf_node_t *tgt = NULL; @@ -3392,15 +3415,26 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( &result->resolved_calls, field_index_ready ? &field_index : NULL, rw->enclosing_func_qn, rw->var_name); + const char *member_edge = rw->is_write ? "WRITES" : "READS"; const CBMResolvedCall *owner; + bool typed = false; while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + typed = true; const cbm_gbuf_node_t *field = cbm_pipeline_lsp_field_node(rc->main_gbuf, rc->project_name, owner); if (!field || field->id == src->id) { continue; } - cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, - rw->is_write ? "WRITES" : "READS", "{}"); + cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, member_edge, "{}"); + } + if (!typed) { + const char *only = cbm_registry_unique_field_qn(rc->registry, rw->var_name); + const cbm_gbuf_node_t *field = + only ? cbm_gbuf_find_by_qn(rc->main_gbuf, only) : NULL; + if (field && field->id != src->id && + !cbm_suppress_cross_language_ref(lang, field->file_path)) { + cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, field->id, member_edge, "{}"); + } } continue; } diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 9944952b9..31f3b015d 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -172,20 +172,33 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( &result->resolved_calls, field_index_ready ? &field_index : NULL, usage->enclosing_func_qn, usage->ref_name); + char member_esc[CBM_SZ_256]; + cbm_json_escape(member_esc, sizeof(member_esc), usage->ref_name); + char member_props[CBM_SZ_512]; + snprintf(member_props, sizeof(member_props), "{\"callee\":\"%s\"}", member_esc); const CBMResolvedCall *owner; + bool typed = false; while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + typed = true; const cbm_gbuf_node_t *field = cbm_pipeline_lsp_field_node(ctx->gbuf, ctx->project_name, owner); if (!field || field->id == src->id) { continue; } - char member_esc[CBM_SZ_256]; - cbm_json_escape(member_esc, sizeof(member_esc), usage->ref_name); - char member_props[CBM_SZ_512]; - snprintf(member_props, sizeof(member_props), "{\"callee\":\"%s\"}", member_esc); cbm_gbuf_insert_edge(ctx->gbuf, src->id, field->id, "USAGE", member_props); resolved++; } + if (!typed) { + /* The LSP could not type the object: only a member name the + * project holds exactly once may still bind. */ + const char *only = cbm_registry_unique_field_qn(ctx->registry, usage->ref_name); + const cbm_gbuf_node_t *field = only ? cbm_gbuf_find_by_qn(ctx->gbuf, only) : NULL; + if (field && field->id != src->id && + !cbm_suppress_cross_language_ref(lang, field->file_path)) { + cbm_gbuf_insert_edge(ctx->gbuf, src->id, field->id, "USAGE", member_props); + resolved++; + } + } continue; } @@ -332,17 +345,28 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result cbm_pipeline_lsp_field_cursor_t owners = cbm_pipeline_lsp_field_cursor( &result->resolved_calls, field_index_ready ? &field_index : NULL, rw->enclosing_func_qn, rw->var_name); + const char *member_edge = rw->is_write ? "WRITES" : "READS"; const CBMResolvedCall *owner; + bool typed = false; while ((owner = cbm_pipeline_lsp_field_next(&owners)) != NULL) { + typed = true; const cbm_gbuf_node_t *field = cbm_pipeline_lsp_field_node(ctx->gbuf, ctx->project_name, owner); if (!field || field->id == src->id) { continue; } - cbm_gbuf_insert_edge(ctx->gbuf, src->id, field->id, - rw->is_write ? "WRITES" : "READS", "{}"); + cbm_gbuf_insert_edge(ctx->gbuf, src->id, field->id, member_edge, "{}"); resolved++; } + if (!typed) { + const char *only = cbm_registry_unique_field_qn(ctx->registry, rw->var_name); + const cbm_gbuf_node_t *field = only ? cbm_gbuf_find_by_qn(ctx->gbuf, only) : NULL; + if (field && field->id != src->id && + !cbm_suppress_cross_language_ref(lang, field->file_path)) { + cbm_gbuf_insert_edge(ctx->gbuf, src->id, field->id, member_edge, "{}"); + resolved++; + } + } continue; } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 4d0421ad9..e27750303 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -370,11 +370,16 @@ cbm_field_call_policy_t cbm_call_onto_field_policy(CBMLanguage caller_lang, cons const char *target_file_path); /* True when a reference is the member half of a C `a.b` / `a->b`. Such a - * reference binds only through the C LSP's field-owner rows (the type of `a`), - * never through a bare-name lookup of `b`. C only. Pure; unit-tested in - * test_registry.c. */ + * reference binds through the C LSP's field-owner rows (the type of `a`); with + * no typed owner it binds only cbm_registry_unique_field_qn(). C only. Pure; + * unit-tested in test_registry.c. */ bool cbm_c_member_binds_by_owner(bool is_c, bool is_member_access); +/* The QN of the Field `member_name` names when exactly one Field in the + * registry carries that name (symbols of other labels are not candidates for + * a member); NULL when none or several do. */ +const char *cbm_registry_unique_field_qn(const cbm_registry_t *r, const char *member_name); + /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 96f84f94b..1fc8fceb1 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -849,7 +849,9 @@ bool cbm_c_member_binds_by_owner(bool is_c, bool is_member_access) { * registry bound whichever Field (or function, or variable) was called * `b` -- on this repository 9,150 USAGE and 2,810 WRITES edges onto * Fields, 3,976 of the USAGE edges on a name several structs share. Such a - * reference binds only through the C LSP's field-owner rows. + * reference binds through the C LSP's field-owner rows; an object the LSP + * could not type binds only a member name that is unique in the project + * (cbm_registry_unique_field_qn), never one of several. * * C-gated like the Go rule above: a C++ or Objective-C method body * reaches its own members without a selector, and their member accesses @@ -1034,6 +1036,33 @@ int cbm_registry_find_by_name(const cbm_registry_t *r, const char *name, const c return 0; } +const char *cbm_registry_unique_field_qn(const cbm_registry_t *r, const char *member_name) { + /* A member name that exactly one Field in the whole project carries names + * that Field without any choice being made. This is what an untyped C + * member access may still bind: the defect in bare-name member resolution + * was the arbitrary pick among SEVERAL same-named members (kernel: 359,236 + * of the edges a typed-only rule dropped were onto such a unique name). + * Only Fields are candidates -- a function or variable of the same name + * can never be what `a.b` means -- and two of them bind nothing. */ + const char **qns = NULL; + int count = 0; + if (!member_name || cbm_registry_find_by_name(r, member_name, &qns, &count) != 0 || !qns) { + return NULL; + } + const char *only = NULL; + for (int i = 0; i < count; i++) { + const char *label = qns[i] ? cbm_registry_label_of(r, qns[i]) : NULL; + if (!label || strcmp(label, "Field") != 0) { + continue; + } + if (only) { + return NULL; + } + only = qns[i]; + } + return only; +} + int cbm_registry_size(const cbm_registry_t *r) { return r ? (int)cbm_ht_count(r->exact) : 0; } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 3e0abb38f..91862bfc8 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -6824,7 +6824,9 @@ static int field_inbound_edge_count(cbm_store_t *s, const char *project, const c * `count`, and each function touches exactly one of them. A bare-name lookup * of `count` can only pick one Field for both functions; only the type of the * object expression says which struct is meant. `use_opaque` reaches `count` - * through a type the project never defines, so no Field may be bound at all. + * through a type the project never defines, so no Field may be bound at all; + * `beta_only` through that same untyped object is the one Field of that name + * in the project, so nothing has to be picked and it binds. * * Calls follow the same rule. The structs carry a function-pointer member * `notify` (declared through a typedef: a member spelled `void (*notify)(int)` @@ -6890,7 +6892,7 @@ static void write_c_member_field_fixture(const char *tmp, int pad_files) { "struct Opaque;\n" "\n" "int use_opaque(struct Opaque *o) {\n" - " return o->count;\n" + " return o->count + o->beta_only;\n" "}\n"); for (int i = 0; i < pad_files; i++) { char name[64]; @@ -6919,9 +6921,12 @@ static int assert_c_member_fields_bind_by_type(cbm_store_t *s, const char *proje /* A field whose name is unique keeps its edge. */ ASSERT_TRUE(field_edge_exists(s, project, "use_alpha", "Alpha", "alpha_only", "USAGE")); ASSERT_TRUE(field_edge_exists(s, project, "use_beta", "Beta", "beta_only", "USAGE")); - /* An object whose type the project never defines binds no Field. */ + /* An object whose type the project never defines binds no Field by a + * name several structs share... */ ASSERT_FALSE(field_edge_exists(s, project, "use_opaque", "Alpha", "count", "USAGE")); ASSERT_FALSE(field_edge_exists(s, project, "use_opaque", "Beta", "count", "USAGE")); + /* ...and still binds a member name the project holds exactly once. */ + ASSERT_TRUE(field_edge_exists(s, project, "use_opaque", "Beta", "beta_only", "USAGE")); /* A call through a function-pointer member lands on the member of its * object's type, not on the same-named member its own file declares. */ ASSERT_GTE(fixture_node_count(s, project, "mix.c", "notify", "Field"), 1); diff --git a/tests/test_registry.c b/tests/test_registry.c index 53895a29a..4f9aba696 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1070,6 +1070,39 @@ TEST(call_onto_field_follows_language_and_shape) { PASS(); } +TEST(unique_field_qn_needs_exactly_one_field_of_that_name) { + /* What an untyped C member access may still bind: a name exactly one + * Field carries. Two Fields would be a choice; a function or variable of + * the same name is not a candidate for a member at all. */ + cbm_registry_t *r = cbm_registry_new(); + ASSERT_NOT_NULL(r); + cbm_registry_add(r, "only_here", "proj.a.Alpha.only_here", "Field"); + cbm_registry_add(r, "count", "proj.a.Alpha.count", "Field"); + cbm_registry_add(r, "count", "proj.b.Beta.count", "Field"); + cbm_registry_add(r, "close", "proj.cb.Callbacks.close", "Field"); + cbm_registry_add(r, "close", "proj.io.close", "Function"); + cbm_registry_add(r, "lonely_fn", "proj.io.lonely_fn", "Function"); + + const char *only = cbm_registry_unique_field_qn(r, "only_here"); + ASSERT_NOT_NULL(only); + ASSERT_STR_EQ(only, "proj.a.Alpha.only_here"); + /* Two fields share the name: no pick. */ + ASSERT_NULL(cbm_registry_unique_field_qn(r, "count")); + /* One field and one function share the name: the function is not a + * candidate for a member, so the field is still the only one. */ + const char *member = cbm_registry_unique_field_qn(r, "close"); + ASSERT_NOT_NULL(member); + ASSERT_STR_EQ(member, "proj.cb.Callbacks.close"); + /* Unique, but not a field. */ + ASSERT_NULL(cbm_registry_unique_field_qn(r, "lonely_fn")); + /* Unknown name and degenerate input. */ + ASSERT_NULL(cbm_registry_unique_field_qn(r, "absent")); + ASSERT_NULL(cbm_registry_unique_field_qn(r, NULL)); + ASSERT_NULL(cbm_registry_unique_field_qn(NULL, "count")); + cbm_registry_free(r); + PASS(); +} + TEST(c_member_access_binds_by_owner_only) { /* The member half of a C `a.b` / `a->b` means nothing without the type of * `a`: it binds through the C LSP's field-owner rows and never through a @@ -1344,6 +1377,7 @@ SUITE(registry) { RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); RUN_TEST(call_onto_field_follows_language_and_shape); + RUN_TEST(unique_field_qn_needs_exactly_one_field_of_that_name); RUN_TEST(c_member_access_binds_by_owner_only); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); From 2e6dc0877c4aa96092515f5821359805dcfd4d18 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 05:34:41 +0200 Subject: [PATCH 21/50] fix(workspace): compare the home directory in its canonical form cbm_workspace_home_dir() returned the raw HOME (or USERPROFILE) string, while cbm_workspace_classify_root() compares it against a canonical, link-resolved path. Where the home directory is reached through a link (/home kept on another volume, a macOS firmlink, a HOME that is itself a link) the two spellings differ and the home rule never fired, so the home directory was not classified as the home directory. The helper now resolves the value through cbm_canonical_path, the same resolver every caller applies to the path it classifies, and falls back to the raw value when the directory cannot be resolved. The result lives in a per-thread buffer recomputed on each call, as cbm_resolve_cache_dir does, so a changed environment is seen at once. The environment is read with cbm_safe_getenv so a non-ASCII USERPROFILE arrives as UTF-8. The ancestor comparison now treats "/" and "\" as the same separator: on Windows the resolver returns backslashes while some callers normalize to forward slashes, and both spell one directory. Two tests cover the helper: one with a plain temp directory (already a firmlink on macOS), one with a home reached through a symbolic link (POSIX; creating a link needs a privilege on Windows). The existing explicit-approval test in the mcp suite now presents its session root in resolved form, as the daemon does; with the raw spelling it would have passed as an ordinary root and stopped exercising the approval path. Signed-off-by: Martin Vogel --- src/foundation/workspace.c | 51 ++++++++++++++++++--- tests/test_mcp.c | 10 ++++- tests/test_workspace.c | 90 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 9 deletions(-) diff --git a/src/foundation/workspace.c b/src/foundation/workspace.c index cb254f3d9..40e009a03 100644 --- a/src/foundation/workspace.c +++ b/src/foundation/workspace.c @@ -6,6 +6,7 @@ #include "foundation/compat.h" #include "foundation/compat_fs.h" +#include "foundation/constants.h" #include "foundation/platform.h" #include "foundation/sha256.h" @@ -239,6 +240,22 @@ static bool ws_is_windows_user_programs_tree(const char *path) { return true; } +/* strncmp that treats "/" and "\" as the same character. On Windows a + * canonical path arrives with backslashes from the resolver and with forward + * slashes from callers that normalize, and both spell one directory. Stops at + * the end of either string like strncmp does. */ +static bool ws_prefix_equal(const char *a, const char *b, size_t len) { + for (size_t i = 0; i < len; i++) { + if (a[i] != b[i] && !(ws_is_sep(a[i]) && ws_is_sep(b[i]))) { + return false; + } + if (a[i] == '\0') { + break; + } + } + return true; +} + /* True when b is a or lives under a. Compares on a separator boundary so * "/a/bc" is not treated as living under "/a/b". */ static bool ws_is_ancestor_or_equal(const char *a, const char *b) { @@ -249,7 +266,7 @@ static bool ws_is_ancestor_or_equal(const char *a, const char *b) { while (la > 1 && ws_is_sep(a[la - 1])) { la--; } - if (strncmp(a, b, la) != 0) { + if (!ws_prefix_equal(a, b, la)) { return false; } return b[la] == '\0' || ws_is_sep(b[la]); @@ -600,14 +617,34 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir /* ── Environment helpers ──────────────────────────────────────────────────── */ /* Callers should not each re-derive these; a caller that resolved the home - * directory differently would classify the same path differently. */ + * directory differently would classify the same path differently. + * + * The value is the CANONICAL form of $HOME (then %USERPROFILE%), through the + * same resolver callers apply to the path they classify. The policy compares + * the two byte for byte, and a home reached through a link — "/home" kept on + * another volume, a macOS firmlink, a HOME that is itself a link — differs + * from its resolved form, so the raw value would never match and the home + * rule would not fire. A home that cannot be resolved (it does not exist) + * falls back to the raw value, which is all the comparison could use anyway. + * + * Storage is per thread and recomputed on every call, as cbm_resolve_cache_dir + * does: no lock, a changed environment is seen at once, and the pointer stays + * valid until the next call on the same thread. */ const char *cbm_workspace_home_dir(void) { - const char *home = getenv("HOME"); - if (home && home[0]) { - return home; + static CBM_TLS char resolved[CBM_SZ_4K]; + char raw[CBM_SZ_4K]; + const char *home = cbm_safe_getenv("HOME", raw, sizeof(raw), NULL); + if (!home || !home[0]) { + home = cbm_safe_getenv("USERPROFILE", raw, sizeof(raw), NULL); } - home = getenv("USERPROFILE"); - return (home && home[0]) ? home : NULL; + if (!home || !home[0]) { + return NULL; + } + if (!cbm_canonical_path(home, resolved, sizeof(resolved))) { + memcpy(resolved, raw, strlen(raw) + 1); + } + cbm_normalize_path_sep(resolved); + return resolved; } const char *cbm_workspace_cache_dir(void) { diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 22af972af..918f53552 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1952,8 +1952,14 @@ TEST(mcp_issue403_sensitive_root_stops_before_discovery_count) { } TEST(mcp_issue403_explicit_approval_preserves_auto_index) { - char *sensitive_home = th_mktempdir("cbm_mcp_403_home"); - ASSERT_NOT_NULL(sensitive_home); + char *created = th_mktempdir("cbm_mcp_403_home"); + ASSERT_NOT_NULL(created); + /* The session root is presented in its resolved form, as the daemon does, + * because the home helper hands out the resolved home: with the raw + * spelling the two differ on macOS (/tmp is a firmlink) and the root + * would pass as an ordinary one, never reaching the approval path. */ + char sensitive_home[4096]; + ASSERT_TRUE(cbm_canonical_path(created, sensitive_home, sizeof(sensitive_home))); const char *saved_home = getenv("HOME"); char *saved_home_copy = saved_home ? strdup(saved_home) : NULL; cbm_setenv("HOME", sensitive_home, 1); diff --git a/tests/test_workspace.c b/tests/test_workspace.c index faf936e1b..afe6726e2 100644 --- a/tests/test_workspace.c +++ b/tests/test_workspace.c @@ -9,8 +9,13 @@ #include "test_helpers.h" #include "foundation/workspace.h" #include "foundation/compat_fs.h" +#include "foundation/platform.h" #include +#include #include +#ifndef _WIN32 +#include +#endif static const char *HOME = "/Users/dev"; static const char *CACHE = "/Users/dev/.cache/codebase-memory-mcp"; @@ -375,6 +380,89 @@ TEST(ws_manifest_approval_refuses_overbroad_requests) { PASS(); } +/* ── Environment helpers ────────────────────────────────────────────────── */ + +static char *ws_env_save(const char *name) { + const char *value = getenv(name); + return value ? strdup(value) : NULL; +} + +static void ws_env_restore(const char *name, char *saved) { + if (saved) { + (void)cbm_setenv(name, saved, 1); + free(saved); + } else { + (void)cbm_unsetenv(name); + } +} + +/* The policy compares the home directory against canonical paths, so the + * helper must hand out the canonical form. A plain temp directory already + * shows the difference: on macOS /tmp is a firmlink to /private/tmp, and on + * Windows the temp path may carry a short (8.3) component. */ +TEST(ws_home_dir_is_resolved) { + char *created = th_mktempdir("cbm_ws_home"); + ASSERT_NOT_NULL(created); + char real[256]; + snprintf(real, sizeof(real), "%s", created); + char expected[4096]; + ASSERT_TRUE(cbm_canonical_path(real, expected, sizeof(expected))); + cbm_normalize_path_sep(expected); + + char *saved_home = ws_env_save("HOME"); + ASSERT_EQ(cbm_setenv("HOME", real, 1), 0); + const char *home = cbm_workspace_home_dir(); + bool resolved = home && strcmp(home, expected) == 0; + ws_env_restore("HOME", saved_home); + th_cleanup(real); + + ASSERT_TRUE(resolved); + PASS(); +} + +/* A home directory reached through a link — "/home" kept on another volume, + * an account whose HOME is itself a link — is still the home directory when + * a caller presents its resolved path, and must be refused as such. */ +TEST(ws_linked_home_classified_as_home) { +#ifdef _WIN32 + /* symlink() does not exist on Windows, and creating a symbolic link there + * needs a privilege an ordinary account (and the CI runner) does not hold. + * A directory junction (cmd.exe mklink /J) is the Windows shape of the + * same case and would need a cmd.exe fixture; the resolution the case + * depends on is covered on Windows by ws_home_dir_is_resolved, and the + * link itself is exercised on POSIX. */ + SKIP_PLATFORM("Windows: symlink() unavailable; links need a privilege"); +#else + char *created = th_mktempdir("cbm_ws_linked_home"); + ASSERT_NOT_NULL(created); + char base[256]; + snprintf(base, sizeof(base), "%s", created); + char real[512]; + char link[512]; + snprintf(real, sizeof(real), "%s/real", base); + snprintf(link, sizeof(link), "%s/link", base); + ASSERT_EQ(cbm_mkdir(real), 0); + ASSERT_EQ(symlink(real, link), 0); + + char canonical_real[4096]; + ASSERT_TRUE(cbm_canonical_path(real, canonical_real, sizeof(canonical_real))); + /* The fixture proves something only when the two spellings differ. */ + ASSERT_TRUE(strcmp(link, canonical_real) != 0); + + char *saved_home = ws_env_save("HOME"); + ASSERT_EQ(cbm_setenv("HOME", link, 1), 0); + const char *home = cbm_workspace_home_dir(); + bool resolved = home && strcmp(home, canonical_real) == 0; + cbm_ws_verdict_t verdict = cbm_workspace_classify_root(canonical_real, home, NULL); + ws_env_restore("HOME", saved_home); + th_cleanup(base); + + ASSERT_TRUE(resolved); + ASSERT_EQ(verdict, CBM_WS_DENY_SENSITIVE); + PASS(); +#endif +} + SUITE(workspace) { RUN_TEST(ws_manifest_absent_is_not_an_error); RUN_TEST(ws_manifest_parses_entries_and_skips_comments); @@ -396,4 +484,6 @@ SUITE(workspace) { RUN_TEST(ws_posix_matching_is_case_sensitive); RUN_TEST(ws_null_context_disables_dependent_checks); RUN_TEST(ws_every_verdict_has_a_reason); + RUN_TEST(ws_home_dir_is_resolved); + RUN_TEST(ws_linked_home_classified_as_home); } From 154aba00629b77342290aa931bd8bb0dea5dc3e9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 05:34:41 +0200 Subject: [PATCH 22/50] fix(workspace): do not follow links in the path-alias config walk find_alias_files descended into every entry whose is_dir flag was set. On Windows that flag is also set for a directory junction and a directory symbolic link, so the tsconfig/jsconfig walk could leave the repository through a link. Discovery and the incremental walker already skip links (safe_stat, cbm_path_info_utf8().is_symlink); the alias walk now does the same on every platform, and skips a child whose metadata cannot be read. On POSIX the walk already stopped at links because readdir reports them as DT_LNK rather than DT_DIR; the symbolic-link test pins that contract. The junction test (cmd.exe mklink /J, no privilege needed) is the case that changes and is compiled on Windows only. Signed-off-by: Martin Vogel --- src/pipeline/path_alias.c | 9 +++ tests/test_path_alias.c | 147 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/src/pipeline/path_alias.c b/src/pipeline/path_alias.c index 8fae754c1..ef999e14d 100644 --- a/src/pipeline/path_alias.c +++ b/src/pipeline/path_alias.c @@ -433,6 +433,15 @@ static void find_alias_files(const char *abs_dir, const char *rel_dir, alias_con if (cbm_pipeline_relpath_is_excluded(child_rel, excluded_dirs, excluded_count)) { continue; } + /* A link is not followed, as discovery does not follow one. is_dir is + * also true for a junction or a directory symbolic link on Windows, + * and either can lead out of the repository. A child whose metadata + * cannot be read is skipped the same way. */ + cbm_path_info_t child_info; + if (cbm_path_info_utf8(child_abs, &child_info) != CBM_PATH_INFO_OK || + child_info.is_symlink) { + continue; + } find_alias_files(child_abs, child_rel, out, count, max_count, depth + 1, excluded_dirs, excluded_count); } diff --git a/tests/test_path_alias.c b/tests/test_path_alias.c index a0bdc8936..9158c0453 100644 --- a/tests/test_path_alias.c +++ b/tests/test_path_alias.c @@ -9,6 +9,7 @@ #include "test_framework.h" #include "../src/pipeline/path_alias.h" #include "../src/foundation/compat.h" +#include "../src/foundation/compat_fs.h" #include #include @@ -407,6 +408,148 @@ TEST(path_alias_loader_no_configs) { PASS(); } +/* ── Loader does not follow a linked directory ─────────────────── */ + +/* The walk must not leave the repository through a link, as discovery does + * not. A config in a real subdirectory is collected; one reachable only + * through a link to a directory outside the tree is not. */ +TEST(path_alias_loader_skips_linked_directory) { +#ifdef _WIN32 + /* symlink() does not exist on Windows, and creating a symbolic link there + * needs a privilege an ordinary account (and the CI runner) does not hold. + * The Windows shape of this case is a directory junction (cmd.exe mklink + * /J), which this file has no cmd.exe fixture for; the junction case is + * not covered here. */ + SKIP_PLATFORM("Windows: symlink() unavailable; links need a privilege"); +#else + char tmpl[256]; + snprintf(tmpl, sizeof(tmpl), "/tmp/cbm_palias_link_XXXXXX"); + char *root = cbm_mkdtemp(tmpl); + ASSERT_NOT_NULL(root); + char outside_tmpl[256]; + snprintf(outside_tmpl, sizeof(outside_tmpl), "/tmp/cbm_palias_outside_XXXXXX"); + char *outside = cbm_mkdtemp(outside_tmpl); + ASSERT_NOT_NULL(outside); + + char sub[512]; + snprintf(sub, sizeof(sub), "%s/pkg", root); + cbm_mkdir(sub); + + char path[512]; + snprintf(path, sizeof(path), "%s/pkg/tsconfig.json", root); + ASSERT_EQ(write_file(path, + "{\n \"compilerOptions\": {\n \"paths\": {\n" + " \"@pkg/*\": [\"./src/*\"]\n }\n }\n}\n"), + 0); + snprintf(path, sizeof(path), "%s/tsconfig.json", outside); + ASSERT_EQ(write_file(path, + "{\n \"compilerOptions\": {\n \"paths\": {\n" + " \"@out/*\": [\"./src/*\"]\n }\n }\n}\n"), + 0); + + char link[512]; + snprintf(link, sizeof(link), "%s/linked", root); + ASSERT_EQ(symlink(outside, link), 0); + + cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root); + ASSERT_NOT_NULL(coll); + /* Only the real subdirectory contributes a scope. */ + ASSERT_EQ(coll->count, 1); + ASSERT_STR_EQ(coll->scopes[0].dir_prefix, "pkg"); + ASSERT_NULL(cbm_path_alias_find_for_file(coll, "linked/src/x.ts")); + cbm_path_alias_collection_free(coll); + + unlink(link); + snprintf(path, sizeof(path), "%s/pkg/tsconfig.json", root); + unlink(path); + snprintf(path, sizeof(path), "%s/pkg", root); + rmdir(path); + rmdir(root); + snprintf(path, sizeof(path), "%s/tsconfig.json", outside); + unlink(path); + rmdir(outside); + PASS(); +#endif +} + +#ifdef _WIN32 +/* ── Loader does not follow a directory junction (Windows) ─────── */ + +/* The Windows shape of the linked-directory case. A junction needs no + * privilege (a symbolic link does), and the directory listing reports it + * with the directory attribute, so is_dir alone would descend through it. + * Compiled out elsewhere: cmd.exe and junctions exist only on Windows, so + * this case runs on the Windows leg and nowhere else. */ +TEST(path_alias_loader_skips_junction) { + char root_tmpl[512]; + char outside_tmpl[512]; + snprintf(root_tmpl, sizeof(root_tmpl), "%s/cbm_palias_junction_XXXXXX", cbm_tmpdir()); + snprintf(outside_tmpl, sizeof(outside_tmpl), "%s/cbm_palias_outside_XXXXXX", cbm_tmpdir()); + char *root = cbm_mkdtemp(root_tmpl); + ASSERT_NOT_NULL(root); + char *outside = cbm_mkdtemp(outside_tmpl); + ASSERT_NOT_NULL(outside); + + char sub[600]; + snprintf(sub, sizeof(sub), "%s/pkg", root); + cbm_mkdir(sub); + + char path[700]; + snprintf(path, sizeof(path), "%s/pkg/tsconfig.json", root); + ASSERT_EQ(write_file(path, + "{\n \"compilerOptions\": {\n \"paths\": {\n" + " \"@pkg/*\": [\"./src/*\"]\n }\n }\n}\n"), + 0); + snprintf(path, sizeof(path), "%s/tsconfig.json", outside); + ASSERT_EQ(write_file(path, + "{\n \"compilerOptions\": {\n \"paths\": {\n" + " \"@out/*\": [\"./src/*\"]\n }\n }\n}\n"), + 0); + + /* cbm_tmpdir() can expose the MSYS spelling C:/msys64/...; cmd's mklink + * builtin treats the slash before "msys64" as another option delimiter. + * Native backslashes are required only at this cmd.exe fixture boundary. */ + char junction[600]; + snprintf(junction, sizeof(junction), "%s/linked", root); + char junction_native[sizeof(junction)]; + char outside_native[sizeof(outside_tmpl)]; + snprintf(junction_native, sizeof(junction_native), "%s", junction); + snprintf(outside_native, sizeof(outside_native), "%s", outside); + for (char *cursor = junction_native; *cursor; cursor++) { + if (*cursor == '/') { + *cursor = '\\'; + } + } + for (char *cursor = outside_native; *cursor; cursor++) { + if (*cursor == '/') { + *cursor = '\\'; + } + } + const char *junction_argv[] = {"cmd.exe", "/d", "/c", "mklink", "/J", + junction_native, outside_native, NULL}; + ASSERT_EQ(cbm_exec_no_shell(junction_argv), 0); + + cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root); + ASSERT_NOT_NULL(coll); + /* Only the real subdirectory contributes a scope. */ + ASSERT_EQ(coll->count, 1); + ASSERT_STR_EQ(coll->scopes[0].dir_prefix, "pkg"); + ASSERT_NULL(cbm_path_alias_find_for_file(coll, "linked/src/x.ts")); + cbm_path_alias_collection_free(coll); + + /* Removing the junction removes the link, not its target. */ + cbm_rmdir(junction); + snprintf(path, sizeof(path), "%s/pkg/tsconfig.json", root); + cbm_unlink(path); + cbm_rmdir(sub); + cbm_rmdir(root); + snprintf(path, sizeof(path), "%s/tsconfig.json", outside); + cbm_unlink(path); + cbm_rmdir(outside); + PASS(); +} +#endif + void suite_path_alias(void); void suite_path_alias(void) { RUN_TEST(path_alias_at_wildcard); @@ -421,4 +564,8 @@ void suite_path_alias(void) { RUN_TEST(path_alias_loader_monorepo_dotdot_climb); RUN_TEST(path_alias_loader_honors_discovery_exclusions); RUN_TEST(path_alias_loader_no_configs); + RUN_TEST(path_alias_loader_skips_linked_directory); +#ifdef _WIN32 + RUN_TEST(path_alias_loader_skips_junction); +#endif } From 6c00f9631152f749aeccb4c257f2e05960b78e59 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 06:50:04 +0200 Subject: [PATCH 23/50] fix(setup): install through install.sh / install.ps1, one install path scripts/setup.sh and scripts/setup-windows.ps1 carried their own copy of the release download: resolve the latest tag, fetch the archive, unpack it into the install directory. Two implementations of the same step drifted apart: the setup scripts did not follow the installers as those gained their checks, their architecture selection and their staging directory. Both setup scripts now fetch the installer from the origin and branch they are themselves served from and hand the download and install over to it, so there is exactly one implementation and the setup scripts behave like the installers: the same conditions stop them with a non-zero exit before anything is installed, setup-windows.ps1 selects the archive through the installer's architecture detection instead of always amd64 (CBM_ARCH pins it) and stages in a freshly created directory, and the file is pure ASCII again. CBM_DOWNLOAD_URL, the installers' download-base override, also moves the installer fetch so both scripts can be driven against a local fixture. Everything the setup scripts do beyond installing the binary is unchanged: the --from-source / -FromSource builds, the interactive Claude Code configuration, the PATH hint, the git-hook activation and the uninstall notes. tests/test_setup_scripts_contract.sh runs setup.sh against a loopback fixture release built from a stub binary, in file mode and in curl | bash mode, covers the cases in which the installer refuses, and pins the static contract for setup-windows.ps1. It is Step 0z of scripts/test.sh. Signed-off-by: Martin Vogel --- scripts/setup-windows.ps1 | 116 +++++++--- scripts/setup.sh | 126 +++++----- scripts/test.sh | 3 + tests/test_setup_scripts_contract.sh | 329 +++++++++++++++++++++++++++ 4 files changed, 483 insertions(+), 91 deletions(-) create mode 100644 tests/test_setup_scripts_contract.sh diff --git a/scripts/setup-windows.ps1 b/scripts/setup-windows.ps1 index 592a9cd49..4a836f95e 100644 --- a/scripts/setup-windows.ps1 +++ b/scripts/setup-windows.ps1 @@ -1,5 +1,5 @@ # codebase-memory-mcp setup script (Windows) -# Default: download pre-built native Windows binary +# Default: install the pre-built native Windows binary through install.ps1 # -FromSource: build from source inside WSL (requires Go + gcc in WSL) param( @@ -123,7 +123,7 @@ if ($Help) { Write-Host "" Write-Host "Usage: .\setup-windows.ps1 [-FromSource] [-Help]" Write-Host "" - Write-Host " Default: Download pre-built Windows binary" + Write-Host " Default: Download the pre-built Windows binary through install.ps1" Write-Host " -FromSource: Build from source inside WSL (requires Go 1.23+ and gcc in WSL)" Write-Host "" exit 0 @@ -225,7 +225,7 @@ if ($FromSource) { exit 1 } - # Configure — WSL binary needs wsl.exe wrapper + # Configure -- WSL binary needs wsl.exe wrapper $mcpConfig = [ordered]@{ type = "stdio" command = "wsl.exe" @@ -243,41 +243,105 @@ if ($FromSource) { Write-Host " wsl.exe -- rm -rf ~/.cache/codebase-memory-mcp/" } else { - # --- Download pre-built native Windows binary --- - Write-Host "Fetching latest release..." -ForegroundColor White + # --- Download + install through install.ps1 --- + # + # install.ps1 is the one implementation of "fetch a release and install + # it": it downloads checksums.txt next to the archive, verifies the + # archive's SHA-256 against it, validates the zip layout and only then + # runs the binary's own `install`. This script used to carry a second + # copy of that download, which did not keep up with the installer. It + # now fetches install.ps1 from the same origin and branch it is itself + # served from and hands over to it, so there is exactly one install path. + # + # CBM_DOWNLOAD_URL (the installers' download-base override, for local + # testing) also moves the installer fetch: install.ps1 is then taken from + # "$env:CBM_DOWNLOAD_URL/install.ps1". + $installerUrl = "https://raw.githubusercontent.com/$Repo/main/install.ps1" + if ($env:CBM_DOWNLOAD_URL) { + $installerUrl = $env:CBM_DOWNLOAD_URL.TrimEnd('/') + "/install.ps1" + } - $releaseUrl = "https://api.github.com/repos/$Repo/releases/latest" - $release = Invoke-RestMethod -Uri $releaseUrl -Headers @{ "User-Agent" = "codebase-memory-mcp-setup" } - $tag = $release.tag_name - - if (-not $tag) { - Write-Fail "Could not determine latest release." - Write-Host " Check: https://github.com/$Repo/releases" + # Same transport rule as install.ps1: HTTPS, or plain HTTP for a loopback + # authority only (the local test fixture). No redirects are followed: both + # origins serve the installer directly. + try { $installerUri = [Uri]$installerUrl } catch { $installerUri = $null } + $loopbackHttp = ( + $installerUri -and $installerUri.IsAbsoluteUri -and + $installerUri.Scheme -eq "http" -and $installerUri.IsLoopback -and + [string]::IsNullOrEmpty($installerUri.UserInfo) + ) + if (-not $installerUri -or -not $installerUri.IsAbsoluteUri -or + ($installerUri.Scheme -ne "https" -and -not $loopbackHttp) -or + -not [string]::IsNullOrEmpty($installerUri.UserInfo)) { + Write-Fail "Refusing non-HTTPS installer URL: $installerUrl" exit 1 } - Write-Ok "Latest release: $tag" - $asset = "codebase-memory-mcp-windows-amd64.zip" - $downloadUrl = "https://github.com/$Repo/releases/download/$tag/$asset" + # TLS 1.2+ for the fetch (older Windows PowerShell defaults to TLS 1.0, + # which GitHub rejects). TLS 1.3 only where schannel can negotiate it; see + # the note in install.ps1. + $protocols = [Net.SecurityProtocolType]::Tls12 + if ([Environment]::OSVersion.Version.Build -ge 20348 -and + ([enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13')) { + $protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13 + } + [Net.ServicePointManager]::SecurityProtocol = $protocols - Write-Host "Downloading $asset..." -ForegroundColor White - - # Create install directory - if (-not (Test-Path $InstallDir)) { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + # A fresh staging directory of its own with an owner-only DACL, the + # way install.ps1 stages its own download. The ACL is best effort: a + # filesystem that cannot carry one must not fail the install. + $stageDir = Join-Path ([System.IO.Path]::GetTempPath()) "cbm-setup-$(Get-Random)" + New-Item -ItemType Directory -Path $stageDir -Force | Out-Null + try { + $stageAcl = New-Object System.Security.AccessControl.DirectorySecurity + $stageAcl.SetAccessRuleProtection($true, $false) + $stageOwner = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User + $stageAcl.SetOwner($stageOwner) + $stageAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + $stageOwner, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + Set-Acl -Path $stageDir -AclObject $stageAcl -ErrorAction Stop + } catch { + Write-Host " note: could not harden the staging directory ACL: $($_.Exception.Message)" } - $tmpZip = Join-Path $env:TEMP $asset - Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing + $installer = Join-Path $stageDir "install.ps1" + $failure = $null + try { + Write-Host "Fetching install.ps1..." -ForegroundColor White + Invoke-WebRequest -Uri $installerUrl -OutFile $installer -UseBasicParsing -MaximumRedirection 0 + if (-not (Test-Path -LiteralPath $installer -PathType Leaf) -or + (Get-Item -LiteralPath $installer).Length -eq 0) { + throw "fetched an empty install.ps1 from $installerUrl" + } + Write-Ok "install.ps1 fetched" - # Extract - Expand-Archive -Path $tmpZip -DestinationPath $InstallDir -Force - Remove-Item $tmpZip -Force + Write-Host "" + Write-Host "Installing through install.ps1..." -ForegroundColor White + # The installer configures nothing (--skip-config): agent configuration + # stays this script's interactive step below. It runs in a child host + # of the same PowerShell so its exit code is unambiguous and its global + # settings do not leak into this session. The pre-seeded exit code + # keeps a child that fails to start from reading as success. + $hostExe = (Get-Process -Id $PID).Path + $global:LASTEXITCODE = 1 + & $hostExe -NoProfile -ExecutionPolicy Bypass -File $installer "--dir=$InstallDir" --skip-config + if ($LASTEXITCODE -ne 0) { + throw "install.ps1 exited with $LASTEXITCODE" + } + } catch { + $failure = "$_" + } finally { + Remove-Item -Recurse -Force $stageDir -ErrorAction SilentlyContinue + } + if ($failure) { + Write-Fail "Installation through install.ps1 failed: $failure" + exit 1 + } $binaryPath = Join-Path $InstallDir "$BinaryName.exe" if (-not (Test-Path $binaryPath)) { - Write-Fail "Binary not found at $binaryPath after extraction" + Write-Fail "Binary not found at $binaryPath after installation" exit 1 } Write-Ok "Installed to $binaryPath" diff --git a/scripts/setup.sh b/scripts/setup.sh index 81a0508d3..ee1a4d424 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -2,14 +2,14 @@ set -euo pipefail # codebase-memory-mcp setup script (macOS + Linux) -# Default: download pre-built binary from GitHub Release +# Default: install the latest pre-built binary through install.sh # --from-source: build from source (requires Go + C compiler) REPO="DeusData/codebase-memory-mcp" INSTALL_DIR="$HOME/.local/bin" BINARY_NAME="codebase-memory-mcp" SOURCE_DIR="$HOME/.local/share/codebase-memory-mcp" -CLEANUP_DIR="" # set by download_binary for EXIT trap +CLEANUP_DIR="" # set by install_release for EXIT trap # --- Colors --- @@ -43,7 +43,7 @@ for arg in "$@"; do --help|-h) echo "Usage: $0 [--from-source]" echo "" - echo " Default: Download pre-built binary from GitHub Release" + echo " Default: Download the pre-built binary through install.sh" echo " --from-source: Clone and build from source (requires Go 1.23+ and a C compiler)" exit 0 ;; @@ -51,36 +51,6 @@ for arg in "$@"; do esac done -# --- Platform detection --- - -detect_platform() { - local os arch - os=$(uname -s) - arch=$(uname -m) - - case "$os" in - Darwin) os="darwin" ;; - Linux) os="linux" ;; - *) die "Unsupported OS: $os. Use WSL2 on Windows." ;; - esac - - case "$arch" in - arm64|aarch64) arch="arm64" ;; - x86_64|amd64) - # On macOS, uname -m returns x86_64 under Rosetta even on Apple Silicon. - # Check the actual hardware to pick the right binary. - if [ "$os" = "darwin" ] && sysctl -n hw.optional.arm64 2>/dev/null | grep -q '1'; then - arch="arm64" - else - arch="amd64" - fi - ;; - *) die "Unsupported architecture: $arch" ;; - esac - - echo "${os}-${arch}" -} - # --- Prerequisite checks --- check_download_tool() { @@ -133,46 +103,74 @@ check_git() { ok "Git found" } -# --- Download binary --- +# --- Download + install through install.sh --- +# +# install.sh is the one implementation of "fetch a release and install it": it +# downloads checksums.txt next to the archive, verifies the archive's SHA-256 +# against it, checks the archive layout, and only then runs the binary's own +# `install`. This script used to carry a second copy of that download, +# which did not keep up with the installer. It now fetches install.sh from the +# same origin and branch it is itself served from and hands over to it, so +# there is exactly one install path. +# +# CBM_DOWNLOAD_URL (the installers' download-base override, for local testing) +# also moves the installer fetch: install.sh is then taken from +# "$CBM_DOWNLOAD_URL/install.sh". -fetch() { - local url="$1" tool="$2" - if [ "$tool" = "curl" ]; then - curl -fsSL "$url" +INSTALLER_URL="https://raw.githubusercontent.com/${REPO}/main/install.sh" +if [ -n "${CBM_DOWNLOAD_URL:-}" ]; then + INSTALLER_URL="${CBM_DOWNLOAD_URL%/}/install.sh" +fi + +# Same transport rule as install.sh: HTTPS everywhere; plain HTTP only for an +# exact loopback authority (the local test fixture), with redirects disabled +# there so a fixture cannot bounce the fetch to the network. +is_loopback_http_url() { + [[ "$1" =~ ^http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?\#].*)?$ ]] +} + +fetch_installer() { + local url="$1" destination="$2" tool="$3" + if is_loopback_http_url "$url"; then + if [ "$tool" = "curl" ]; then + curl -fsS --noproxy '*' --proto '=http' -o "$destination" "$url" + else + wget -q --no-proxy --max-redirect=0 -O "$destination" "$url" + fi + elif [[ "$url" == https://* ]]; then + if [ "$tool" = "curl" ]; then + curl -fsSL --max-redirs 5 --proto '=https' --proto-redir '=https' \ + -o "$destination" "$url" + else + wget -q --https-only --max-redirect=5 -O "$destination" "$url" + fi else - wget -qO- "$url" + die "Refusing non-HTTPS installer URL: $url" fi } -download_binary() { - local platform="$1" tool="$2" +install_release() { + local tool="$1" echo "" - echo "${BOLD}Fetching latest release...${RESET}" - local tag - tag=$(fetch "https://api.github.com/repos/${REPO}/releases/latest" "$tool" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"//;s/".*//') - - if [ -z "$tag" ]; then - die "Could not determine latest release. Check https://github.com/${REPO}/releases" - fi - ok "Latest release: $tag" - - local asset="codebase-memory-mcp-${platform}.tar.gz" - local url="https://github.com/${REPO}/releases/download/${tag}/${asset}" - - echo "${BOLD}Downloading ${asset}...${RESET}" + echo "${BOLD}Fetching install.sh...${RESET}" CLEANUP_DIR=$(mktemp -d) trap 'rm -rf "$CLEANUP_DIR"' EXIT - local tmpdir="$CLEANUP_DIR" + local installer="$CLEANUP_DIR/install.sh" - fetch "$url" "$tool" > "${tmpdir}/${asset}" - tar -xzf "${tmpdir}/${asset}" -C "$tmpdir" + fetch_installer "$INSTALLER_URL" "$installer" "$tool" || + die "Could not fetch install.sh from ${INSTALLER_URL}" + [ -s "$installer" ] || die "Fetched an empty install.sh from ${INSTALLER_URL}" + ok "install.sh fetched" - mkdir -p "$INSTALL_DIR" - mv "${tmpdir}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" - chmod +x "${INSTALL_DIR}/${BINARY_NAME}" - - ok "Installed to ${INSTALL_DIR}/${BINARY_NAME}" + echo "" + echo "${BOLD}Installing through install.sh...${RESET}" + # The installer configures nothing (--skip-config): agent configuration + # stays this script's interactive step below. stdin is detached so that, + # when this script itself arrives through `curl | bash`, nothing the + # installer runs can read the rest of this script as its input. + bash "$installer" --dir "$INSTALL_DIR" --skip-config &2 + exit 1 +} + +# ── Static contract: scripts/setup.sh ────────────────────────────────────── +# +# These gate the functional leg on purpose: a setup.sh that ignores +# CBM_DOWNLOAD_URL cannot be pointed at the fixture and would reach for the +# real release. The contract refuses to run that script at all. +grep -q 'CBM_DOWNLOAD_URL' "$SETUP_SH" || + fail "scripts/setup.sh must honour CBM_DOWNLOAD_URL (the installers' download-base override)" +grep -q 'install\.sh' "$SETUP_SH" || + fail "scripts/setup.sh must install through install.sh" +if grep -Eq 'tar +-[a-zA-Z]*x' "$SETUP_SH"; then + fail "scripts/setup.sh must not unpack the release archive itself" +fi +if grep -Eq 'api\.github\.com|releases/download' "$SETUP_SH"; then + fail "scripts/setup.sh must not resolve or download the release itself (install.sh owns that)" +fi + +# ── Static contract: scripts/setup-windows.ps1 ───────────────────────────── +if LC_ALL=C grep -n $'[^ -~\t]' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must be pure ASCII (PS 5.1 reads BOM-less files as ANSI)" +fi +if grep -q $'\r' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must use LF line endings in the repository" +fi +grep -q 'install\.ps1' "$SETUP_PS1" || + fail "scripts/setup-windows.ps1 must install through install.ps1" +grep -q 'CBM_DOWNLOAD_URL' "$SETUP_PS1" || + fail "scripts/setup-windows.ps1 must honour CBM_DOWNLOAD_URL (the installers' download-base override)" +if grep -q 'Expand-Archive' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must not unpack the release archive itself" +fi +if grep -Eq 'api\.github\.com|releases/download' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must not resolve or download the release itself (install.ps1 owns that)" +fi +if grep -q 'env:TEMP' "$SETUP_PS1"; then + fail "scripts/setup-windows.ps1 must stage in a fresh directory of its own, not directly under %TEMP%" +fi +grep -q 'GetTempPath' "$SETUP_PS1" && grep -q 'Get-Random' "$SETUP_PS1" || + fail "scripts/setup-windows.ps1 must stage in a freshly created private directory (as install.ps1 does)" + +# No PowerShell parser is available on every host; a bracket balance over the +# file with comments and quoted strings removed still catches a truncated or +# mis-pasted script before the Windows leg does. +python3 - "$SETUP_PS1" <<'PY' || fail "scripts/setup-windows.ps1 has unbalanced brackets" +import re +import sys + +text = open(sys.argv[1], encoding="ascii").read() +text = re.sub(r"<#.*?#>", "", text, flags=re.S) +text = re.sub(r"#[^\n]*", "", text) +text = re.sub(r"'(?:[^']|'')*'", "''", text) +text = re.sub(r'"(?:[^"\\`]|`.|\\.)*"', '""', text) +for open_ch, close_ch in (("{", "}"), ("(", ")"), ("[", "]")): + if text.count(open_ch) != text.count(close_ch): + print(f"unbalanced {open_ch}{close_ch}: {text.count(open_ch)} vs {text.count(close_ch)}") + sys.exit(1) +PY + +echo "OK: static contract (setup.sh delegates to install.sh, setup-windows.ps1 to install.ps1)" + +# ── Functional leg: setup.sh against a loopback fixture release ──────────── +case "$(uname -s)" in + Darwin) OS=darwin ;; + Linux) OS=linux ;; + *) + # WHY: setup.sh is the macOS/Linux one-liner; on a Windows host it would + # select no archive at all. The Windows setup script's functional + # coverage is the VM leg that serves a fixture to install.ps1 (the + # script it delegates to). Tried: running the POSIX leg under MSYS -- + # install.sh then targets a windows-*.zip, which is not what setup.sh + # ever installs, so the run would prove nothing about this contract. + echo "SKIP: functional leg needs a macOS/Linux host ($(uname -s)); static contract passed" + exit 0 + ;; +esac +case "$(uname -m)" in + arm64 | aarch64) ARCH=arm64 ;; + x86_64 | amd64) + # Match install.sh: a Rosetta shell reports x86_64 on Apple Silicon. + if [ "$OS" = "darwin" ] && + sysctl -n machdep.cpu.brand_string 2>/dev/null | grep -qi apple; then + ARCH=arm64 + else + ARCH=amd64 + fi + ;; + *) fail "unsupported host architecture $(uname -m)" ;; +esac +PORTABLE="" +[ "$OS" = "linux" ] && PORTABLE="-portable" +ARCHIVE="codebase-memory-mcp-${OS}-${ARCH}${PORTABLE}.tar.gz" + +for tool in python3 curl tar shasum; do + command -v "$tool" >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1 || + fail "functional leg needs $tool" +done + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/cbm-setup-contract-XXXXXX") +chmod 700 "$WORK" +SERVER_PID="" +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +FIXTURE="$WORK/fixture" +STAGE="$WORK/stage" +mkdir -p "$FIXTURE/good" "$FIXTURE/bad" "$FIXTURE/none" "$STAGE" "$WORK/tmp" \ + "$WORK/cache" "$WORK/runtime" "$WORK/trace" "$WORK/cwd" + +# The fixture "binary": a stub that answers --version and performs the minimal +# `install` the installer asks of the product (copy itself into --dir). It +# records every invocation so the failure cases can prove it never ran. +cat > "$STAGE/codebase-memory-mcp" <<'STUB' +#!/bin/sh +set -eu +if [ -n "${CBM_SETUP_STUB_TRACE:-}" ]; then + printf '%s\n' "$*" >> "$CBM_SETUP_STUB_TRACE" +fi +case "${1:-}" in + --version) + echo "codebase-memory-mcp setup-contract-stub 0.0.0" + ;; + install) + dir="" + for arg in "$@"; do + case "$arg" in --dir=*) dir="${arg#--dir=}" ;; esac + done + [ -n "$dir" ] || { echo "stub: install needs --dir=" >&2; exit 2; } + mkdir -p "$dir" + cp "$0" "$dir/codebase-memory-mcp" + chmod 755 "$dir/codebase-memory-mcp" + ;; + *) + echo "stub: unexpected invocation: $*" >&2 + exit 2 + ;; +esac +STUB +chmod 755 "$STAGE/codebase-memory-mcp" +cp "$ROOT/LICENSE" "$ROOT/install.sh" "$STAGE/" +printf 'fixture notices\n' > "$STAGE/THIRD_PARTY_NOTICES.md" + +# Member set and order mirror scripts/package-release.sh; install.sh refuses +# any other inventory. +tar -czf "$STAGE/$ARCHIVE" -C "$STAGE" \ + codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md +if command -v sha256sum >/dev/null 2>&1; then + DIGEST=$(sha256sum "$STAGE/$ARCHIVE" | awk '{print $1}') +else + DIGEST=$(shasum -a 256 "$STAGE/$ARCHIVE" | awk '{print $1}') +fi + +for variant in good bad none; do + cp "$STAGE/$ARCHIVE" "$ROOT/install.sh" "$FIXTURE/$variant/" +done +printf '%s %s\n' "$DIGEST" "$ARCHIVE" > "$FIXTURE/good/checksums.txt" +WRONG_DIGEST=$(printf '%064d' 0) +printf '%s %s\n' "$WRONG_DIGEST" "$ARCHIVE" > "$FIXTURE/bad/checksums.txt" +# "none" ships no checksums.txt at all. + +# The server owns the ephemeral bind and prints its banner only after the +# port is published; reading that banner from a FIFO is the readiness signal +# (no polling, no timer). Request logs go to a file so nothing is written to +# the FIFO after the banner. +mkfifo "$WORK/server.fifo" +python3 "$ROOT/scripts/smoke-fixture-server.py" \ + --directory "$FIXTURE" --port-file "$WORK/port" \ + > "$WORK/server.fifo" 2> "$WORK/server.log" & +SERVER_PID=$! +IFS= read -r SERVER_BANNER < "$WORK/server.fifo" || + fail "fixture server exited before publishing its port" +PORT=$(tr -d '[:space:]' < "$WORK/port") +case "$PORT" in + '' | *[!0-9]*) fail "fixture server published no usable port ($SERVER_BANNER)" ;; +esac + +# Every tool setup.sh, install.sh and the stub may need, EXCEPT a hash tool. +# The no-hash-tool variant runs with PATH restricted to this directory. +mkdir -p "$WORK/tools" +for tool in bash sh env curl wget mktemp uname sysctl grep wc tr awk tar \ + xattr codesign chmod cp mv rm mkdir cat sed head dirname tput; do + if resolved=$(command -v "$tool" 2>/dev/null) && [ -n "$resolved" ]; then + ln -s "$resolved" "$WORK/tools/$tool" + fi +done + +# run_setup