From a030a831df786eb77655b65c3ac74b6bce38dffa Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 26 Sep 2026 21:38:23 +0200 Subject: [PATCH 01/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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 193b0a4d066f6ced193d9b68a673b5d5e19c4a56 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 02:21:12 +0200 Subject: [PATCH 17/44] 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 154aba00629b77342290aa931bd8bb0dea5dc3e9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 2 Oct 2026 05:34:41 +0200 Subject: [PATCH 18/44] 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 e37274f088312a542ea150ff3c520b8ed53c453c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 3 Oct 2026 23:53:16 +0200 Subject: [PATCH 19/44] wip(graph): checkpoint staged member-resolution changes Preserve the original staged graph and LSP work separately from the smart-CI continuation. This is an unfinished checkpoint; final integration and cross-platform verification remain outstanding. Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 210 +++++++++++++++++- internal/cbm/extract_imports.c | 25 ++- internal/cbm/lsp/c_lsp.c | 377 +++++++++++++++++++++++++++++---- internal/cbm/lsp/c_lsp.h | 7 + src/pipeline/lsp_resolve.h | 35 +++ src/pipeline/pass_calls.c | 33 +-- src/pipeline/pass_parallel.c | 46 ++-- src/pipeline/pass_usages.c | 5 +- src/pipeline/pipeline.h | 14 ++ src/pipeline/registry.c | 18 ++ tests/test_extraction.c | 195 +++++++++++++++++ tests/test_pipeline.c | 263 +++++++++++++++++++++++ tests/test_registry.c | 36 ++++ 13 files changed, 1178 insertions(+), 86 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index d22558c4e..10a7a44e4 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -40,6 +40,9 @@ enum { NIX_HEADER_HOP_MAX = 8, DECORATOR_SCAN_LIMIT = 3, C_RETURN_WALK_DEPTH = 5, + /* Declarator nesting around a member name: `char *(*(*tbl[4])(int))(void)` + * is pointer, function, parens, pointer, function, parens, pointer, array. */ + C_FIELD_DECL_WALK_DEPTH = 16, VAR_RECURSION_LIMIT = 8, NESTED_CLASS_STACK_CAP = 128, FP_HASH_MUL = 31, /* FNV-like hash multiplier for identifier dedup */ @@ -3576,9 +3579,10 @@ static bool is_c_declarator_lang(CBMLanguage lang) { * O(depth) with no recursion and needs no depth cap. It stops at the first node * that is neither a pointer nor a reference declarator: for a function returning * a function pointer (`int (*f(void))(int)`) that is the outer - * function_declarator, which leaves the base type as it was. */ + * function_declarator, which leaves the base type as it was. That node is + * handed back through `rest` when the caller asks for it. */ static size_t c_rt_render(c_rt_out_t *out, TSNode func_node, TSNode type_node, TSNode declarator, - const char *source) { + const char *source, TSNode *rest) { size_t added = 0; uint32_t decl_start = ts_node_start_byte(declarator); uint32_t nc = ts_node_named_child_count(func_node); @@ -3632,6 +3636,9 @@ static size_t c_rt_render(c_rt_out_t *out, TSNode func_node, TSNode type_node, T } decl = inner; } + if (rest) { + *rest = decl; /* the declarator the markers wrap; null when none is left */ + } return added; } @@ -3645,7 +3652,7 @@ static char *c_declared_return_type(CBMExtractCtx *ctx, TSNode func_node, TSNode return cbm_node_text(a, type_node, ctx->source); } c_rt_out_t out = {NULL, 0}; - if (c_rt_render(&out, func_node, type_node, declarator, ctx->source) == 0) { + if (c_rt_render(&out, func_node, type_node, declarator, ctx->source, NULL) == 0) { return cbm_node_text(a, type_node, ctx->source); } out.buf = (char *)cbm_arena_alloc(a, out.len + NULL_TERM); @@ -3653,7 +3660,7 @@ static char *c_declared_return_type(CBMExtractCtx *ctx, TSNode func_node, TSNode return cbm_node_text(a, type_node, ctx->source); } out.len = 0; - (void)c_rt_render(&out, func_node, type_node, declarator, ctx->source); + (void)c_rt_render(&out, func_node, type_node, declarator, ctx->source, NULL); out.buf[out.len] = '\0'; return out.buf; } @@ -6927,6 +6934,196 @@ static bool is_func_ptr_field(TSNode field) { return false; } +/* ── C-family member declarations ─────────────────────────────────── + * `type declarator, declarator;` in a struct/union/class body. Each declarator + * is one member: its name is the identifier at the bottom of the declarator, + * its type everything around that identifier. */ + +/* The name of one member declarator, or a null node when the declarator is not + * a data member. A member FUNCTION declaration has the same field_declaration + * + function_declarator shape as a pointer-to-function member; the two differ + * in what the function_declarator wraps: `(*open)` is a parenthesized pointer + * declarator, `resize` (or `(resize)`) a name. */ +static TSNode c_member_declarator_name(TSNode decl) { + TSNode null_node = {0}; + bool need_pointer = false; + for (int depth = 0; depth < C_FIELD_DECL_WALK_DEPTH && !ts_node_is_null(decl); depth++) { + const char *kind = ts_node_type(decl); + if (strcmp(kind, "field_identifier") == 0 || strcmp(kind, "identifier") == 0) { + return need_pointer ? null_node : decl; + } + TSNode inner = ts_node_child_by_field_name(decl, TS_FIELD("declarator")); + bool first_child = false; + if (strcmp(kind, "function_declarator") == 0) { + if (ts_node_is_null(inner) || + strcmp(ts_node_type(inner), "parenthesized_declarator") != 0) { + return null_node; /* a member function declaration */ + } + need_pointer = true; + } else if (strstr(kind, "pointer_declarator") != NULL || + strcmp(kind, "reference_declarator") == 0) { + need_pointer = false; + } else if (strcmp(kind, "attributed_declarator") == 0) { + first_child = true; /* the declarator, then its attributes */ + } else if (strcmp(kind, "array_declarator") != 0 && + strcmp(kind, "parenthesized_declarator") != 0) { + return null_node; /* operator, destructor, qualified name, ... */ + } + if (ts_node_is_null(inner)) { + /* No `declarator` field: a parenthesized declarator holds it last + * (after an optional calling-convention modifier), a reference + * declarator as its only named child. */ + uint32_t named = ts_node_named_child_count(decl); + for (uint32_t k = 0; k < named; k++) { + TSNode cand = ts_node_named_child(decl, first_child ? k : named - SKIP_ONE - k); + if (strcmp(ts_node_type(cand), "comment") != 0) { + inner = cand; + break; + } + } + } + decl = inner; + } + return null_node; +} + +/* Type of one member declarator: the canonical prefix a return type gets from + * c_rt_render (cv-qualifiers, base type, pointer/reference markers), then the + * declarator that is left with the member name cut out, which is how C spells + * an abstract declarator: `int [8]`, `char *[4]`, `void (*)(int fd)`. + * Whitespace runs in that remainder collapse to one space; a `|` in it (the + * separator of the field list the cross-file registry is fed) drops it. */ +static char *c_member_type_text(CBMExtractCtx *ctx, TSNode field, TSNode type_node, TSNode decl, + TSNode name_node) { + CBMArena *a = ctx->arena; + const char *src = ctx->source; + TSNode rest = decl; + c_rt_out_t out = {NULL, 0}; + (void)c_rt_render(&out, field, type_node, decl, src, &rest); + uint32_t rest_start = ts_node_is_null(rest) ? 0 : ts_node_start_byte(rest); + uint32_t rest_end = ts_node_is_null(rest) ? 0 : ts_node_end_byte(rest); + uint32_t cut_start = ts_node_start_byte(name_node); + uint32_t cut_end = ts_node_end_byte(name_node); + /* prefix + one separating space + remainder + NUL */ + char *buf = (char *)cbm_arena_alloc(a, out.len + (size_t)(rest_end - rest_start) + PAIR_LEN); + if (!buf) { + return cbm_node_text(a, type_node, src); + } + out.buf = buf; + out.len = 0; + (void)c_rt_render(&out, field, type_node, decl, src, NULL); + size_t prefix_len = out.len; + size_t n = prefix_len; + bool pending_space = n > 0 && buf[n - SKIP_ONE] != '*' && buf[n - SKIP_ONE] != '&'; + bool wrote_rest = false; + for (uint32_t i = rest_start; i < rest_end; i++) { + if (i >= cut_start && i < cut_end) { + continue; + } + char c = src[i]; + if (c == '|') { + n = prefix_len; + break; + } + if (c == ' ' || c == '\n' || c == '\r' || c == '\t') { + pending_space = pending_space || wrote_rest; + continue; + } + if (pending_space) { + buf[n++] = ' '; + pending_space = false; + } + buf[n++] = c; + wrote_rest = true; + } + buf[n] = '\0'; + return buf; +} + +/* True when `field` is a C-family member declaration: a type and at least one + * declarator. An anonymous struct/union member has no declarator and is left + * to the caller. */ +static bool is_c_member_declaration(CBMExtractCtx *ctx, TSNode field) { + if (!is_c_declarator_lang(ctx->language) || + strcmp(ts_node_type(field), "field_declaration") != 0) { + return false; + } + return !ts_node_is_null(ts_node_child_by_field_name(field, TS_FIELD("type"))) && + !ts_node_is_null(ts_node_child_by_field_name(field, TS_FIELD("declarator"))); +} + +/* A member whose type is a macro invocation (`ql_head(tcache_slow_t) + * tcache_ql;`). The C grammar reads the type as a macro_type_specifier; the + * C++ grammar, which every header gets, reads `(tcache_slow_t)` as a + * parenthesized declarator and leaves the member's real name in an ERROR node + * behind it. Returns that name, or a null node for any other shape. */ +static TSNode c_macro_typed_member_name(TSNode decl) { + TSNode null_node = {0}; + if (strcmp(ts_node_type(decl), "parenthesized_declarator") != 0) { + return null_node; + } + TSNode err = ts_node_next_sibling(decl); + if (ts_node_is_null(err) || strcmp(ts_node_type(err), "ERROR") != 0 || + ts_node_named_child_count(err) != SKIP_ONE) { + return null_node; + } + TSNode name = ts_node_named_child(err, 0); + const char *kind = ts_node_type(name); + if (strcmp(kind, "identifier") != 0 && strcmp(kind, "field_identifier") != 0) { + return null_node; + } + return name; +} + +/* One Field per data-member declarator of a C-family member declaration. */ +static void extract_c_member_fields(CBMExtractCtx *ctx, TSNode field, const char *class_qn, + const CBMLangSpec *spec) { + CBMArena *a = ctx->arena; + TSNode type_node = ts_node_child_by_field_name(field, TS_FIELD("type")); + uint32_t nc = ts_node_child_count(field); + for (uint32_t i = 0; i < nc; i++) { + const char *field_name = ts_node_field_name_for_child(field, i); + if (!field_name || strcmp(field_name, "declarator") != 0) { + continue; + } + TSNode decl = ts_node_child(field, i); + char *name = NULL; + char *type_text = NULL; + TSNode macro_name = c_macro_typed_member_name(decl); + if (!ts_node_is_null(macro_name)) { + /* The type is the macro invocation: the `type` node through the + * misread declarator. */ + uint32_t start = ts_node_start_byte(type_node); + uint32_t end = ts_node_end_byte(decl); + name = cbm_node_text(a, macro_name, ctx->source); + type_text = end > start ? cbm_arena_strndup(a, ctx->source + start, end - start) : NULL; + } else { + TSNode name_node = c_member_declarator_name(decl); + if (ts_node_is_null(name_node)) { + continue; + } + name = cbm_node_text(a, name_node, ctx->source); + type_text = c_member_type_text(ctx, field, type_node, decl, name_node); + } + if (!name || !name[0] || !type_text || !type_text[0]) { + continue; + } + CBMDefinition def; + memset(&def, 0, sizeof(def)); + def.name = name; + def.qualified_name = cbm_arena_sprintf(a, "%s.%s", class_qn, name); + def.label = "Field"; + def.file_path = ctx->rel_path; + def.parent_class = class_qn; + def.return_type = type_text; + def.start_line = ts_node_start_point(field).row + TS_LINE_OFFSET; + def.end_line = ts_node_end_point(field).row + TS_LINE_OFFSET; + def.is_exported = cbm_is_exported(name, ctx->language); + def.decorators = extract_decorators(a, field, ctx->source, ctx->language, spec); + cbm_defs_push(&ctx->result->defs, a, def); + } +} + // Resolve the name node for a field declaration, unwrapping C pointer/array declarators. static TSNode resolve_field_name_node(TSNode child) { TSNode name_node = ts_node_child_by_field_name(child, TS_FIELD("declarator")); @@ -7031,6 +7228,11 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch continue; } + if (is_c_member_declaration(ctx, child)) { + extract_c_member_fields(ctx, child, class_qn, spec); + continue; + } + if (is_func_ptr_field(child)) { continue; } diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 7d416923c..de06d8843 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -13,6 +13,8 @@ enum { USE_PREFIX_LEN = 4, /* strlen("use ") */ MIN_WOLFRAM_CHILDREN = 2, + /* Nested `#if` branches followed while collecting C includes. */ + C_INCLUDE_NEST_LIMIT = 64, SECOND_IDX = 1, }; @@ -663,10 +665,22 @@ static char *strip_angle_brackets(CBMArena *a, char *path) { return path; } -static void parse_c_imports(CBMExtractCtx *ctx) { +/* Node kinds that only group file-level items: the branches of `#if` / + * `#ifdef` (an include guard is one) and the body of `extern "C" { ... }`. */ +static bool is_c_include_container(const char *kind) { + return strcmp(kind, "preproc_if") == 0 || strcmp(kind, "preproc_ifdef") == 0 || + strcmp(kind, "preproc_else") == 0 || strcmp(kind, "preproc_elif") == 0 || + strcmp(kind, "preproc_elifdef") == 0 || strcmp(kind, "linkage_specification") == 0 || + strcmp(kind, "declaration_list") == 0; +} + +/* Collect the includes among `parent`'s children, descending into + * preprocessor branches: every header keeps its includes inside its include + * guard, so the top level alone gave a guarded header no imports at all. */ +static void parse_c_imports_in(CBMExtractCtx *ctx, TSNode parent, int depth) { CBMArena *a = ctx->arena; - TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); + TSTreeCursor cursor = ts_tree_cursor_new(parent); if (!ts_tree_cursor_goto_first_child(&cursor)) { ts_tree_cursor_delete(&cursor); return; @@ -675,6 +689,9 @@ static void parse_c_imports(CBMExtractCtx *ctx) { TSNode node = ts_tree_cursor_current_node(&cursor); const char *kind = ts_node_type(node); if (strcmp(kind, "preproc_include") != 0 && strcmp(kind, "preproc_import") != 0) { + if (depth < C_INCLUDE_NEST_LIMIT && is_c_include_container(kind)) { + parse_c_imports_in(ctx, node, depth + SKIP_ONE); + } continue; } @@ -695,6 +712,10 @@ static void parse_c_imports(CBMExtractCtx *ctx) { ts_tree_cursor_delete(&cursor); } +static void parse_c_imports(CBMExtractCtx *ctx) { + parse_c_imports_in(ctx, ctx->root, 0); +} + // --- Ruby imports --- // call nodes: require("X"), require_relative("X") diff --git a/internal/cbm/lsp/c_lsp.c b/internal/cbm/lsp/c_lsp.c index aad2c1190..32e813dc0 100644 --- a/internal/cbm/lsp/c_lsp.c +++ b/internal/cbm/lsp/c_lsp.c @@ -19,6 +19,7 @@ static void c_emit_resolved_call(CLSPContext *ctx, const char *callee_qn, const static void c_emit_unresolved_call(CLSPContext *ctx, const char *expr_text, const char *reason); static const CBMType *c_lookup_field_type(CLSPContext *ctx, const char *type_qn, const char *field_name, int depth); +static const CBMRegisteredType *c_resolve_field_owner_type(CLSPContext *ctx, const char *type_qn); static void c_process_function(CLSPContext *ctx, TSNode func_node); static void c_process_namespace(CLSPContext *ctx, TSNode ns_node); static void c_process_class(CLSPContext *ctx, TSNode class_node); @@ -2920,11 +2921,11 @@ static const CBMType *c_lookup_field_type(CLSPContext *ctx, const char *type_qn, if (!type_qn || !field_name || depth > 5) return NULL; - const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, type_qn); - if (!rt && ctx->module_qn) { - rt = cbm_registry_lookup_type( - ctx->registry, cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, type_qn)); - } + /* The same type lookup the field-owner rows use: a use site names the + * struct under its own module, the registry holds it under the header + * that declares it. Without it `d->ops->shut` loses its type at the first + * step whenever `d`'s struct comes from a header. */ + const CBMRegisteredType *rt = c_resolve_field_owner_type(ctx, type_qn); if (!rt) return NULL; @@ -2965,20 +2966,21 @@ static bool c_qn_in_module(const char *qn, const char *prefix) { // 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; +// +// Only a type that HAS members can own one. `struct Ops *ops;` inside another +// struct, a forward declaration and a parameter type each register a memberless +// `Ops` of their own scope; counted as candidates they made every such name +// ambiguous and left the object untyped. They are considered only when no +// candidate with members exists (the "typed, declares no such field" answer). +static bool c_type_has_members(const CBMRegisteredType *t) { + return (t->field_names && t->field_names[0]) || (t->embedded_types && t->embedded_types[0]) || + t->alias_of; +} - const char *dot = strrchr(type_qn, '.'); - const char *shortn = dot ? dot + 1 : type_qn; +// One short-name scan of c_resolve_field_owner_type. *found says whether the +// scan decided anything, including "ambiguous" (NULL with *found set). +static const CBMRegisteredType *c_field_owner_by_short_name(CLSPContext *ctx, const char *shortn, + bool members_only, bool *found) { size_t slen = strlen(shortn); const CBMRegisteredType *included = NULL; const CBMRegisteredType *only = NULL; @@ -2987,12 +2989,16 @@ static const CBMRegisteredType *c_resolve_field_owner_type(CLSPContext *ctx, con CBMTypeShortIter it; cbm_registry_types_by_short_name_chain(ctx->registry, shortn, &it); int i; + *found = false; 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 (members_only && !c_type_has_members(cand)) + continue; + *found = true; if (c_qn_in_module(q, ctx->module_qn)) return cand; if (only && strcmp(only->qualified_name, q) != 0) @@ -3012,6 +3018,29 @@ static const CBMRegisteredType *c_resolve_field_owner_type(CLSPContext *ctx, con return only_ambiguous ? NULL : only; } +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 (!ctx->registry || (rt && c_type_has_members(rt))) + return rt; + + const char *dot = strrchr(type_qn, '.'); + const char *shortn = dot ? dot + 1 : type_qn; + bool found = false; + const CBMRegisteredType *owner = c_field_owner_by_short_name(ctx, shortn, true, &found); + if (found) + return owner; + if (rt) + return rt; + return c_field_owner_by_short_name(ctx, shortn, false, &found); +} + // 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. @@ -3537,6 +3566,12 @@ void c_process_statement(CLSPContext *ctx, TSNode node) { target = c_parse_type_node(ctx, child); found_type = true; } else if (found_type && strcmp(ck, "type_identifier") == 0) { + /* `typedef struct { ... } Name;`: the aggregate has no name of + * its own, so `Name` is not an alias of anything -- it IS the + * type, registered with its members under that name. Aliasing + * it to the nameless struct made every `Name *p` untyped. */ + if (target && target->kind == CBM_TYPE_STRUCT) + continue; char *alias_name = c_node_text(ctx, child); if (alias_name) { const char *alias_qn = @@ -4008,6 +4043,8 @@ static uint64_t c_field_ref_hash(const char *caller_qn, const char *type_qn, * 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_publish_field_owner(CLSPContext *ctx, const char *type_qn, const char *field_name); + static void c_emit_field_reference(CLSPContext *ctx, TSNode node) { if (!ctx->resolved_calls || !ctx->enclosing_func_qn) return; @@ -4029,8 +4066,14 @@ static void c_emit_field_reference(CLSPContext *ctx, TSNode node) { 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) + c_publish_field_owner(ctx, type_to_qn(c_simplify_type(ctx, obj_type, is_arrow)), field_name); +} + +/* Publish the owner of member `field_name` of the type `type_qn` for the + * enclosing function (the module, at file scope). */ +static void c_publish_field_owner(CLSPContext *ctx, const char *type_qn, const char *field_name) { + if (!ctx->resolved_calls || !ctx->enclosing_func_qn || !type_qn || !field_name || + !field_name[0]) return; /* Decided once per (function, type, field), hit or miss: the owner lookup @@ -4064,6 +4107,133 @@ static void c_emit_field_reference(CLSPContext *ctx, TSNode node) { cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, resolved); } +/* ── Designated initializers ───────────────────────────────────────── + * `.open = my_open` names a member with no object expression: the type being + * initialized says whose member it is. That type is known where the list + * starts (a declarator, a compound literal, an enclosing list) and is handed + * down the walk; nothing here looks upward, so a table of ten thousand entries + * costs ten thousand steps. */ + +/* Hand `type` to the walk of `list`, when `list` is an initializer list. */ +static void c_hint_initializer_list(CLSPContext *ctx, TSNode list, const CBMType *type) { + if (ts_node_is_null(list) || !type || cbm_type_is_unknown(type) || + strcmp(ts_node_type(list), "initializer_list") != 0) + return; + ctx->init_list_type = type; + ctx->init_list_id = list.id; +} + +/* `Type name[N] = {...}` / `Type *name = ...`: the declared type of one + * init_declarator, its declarator's pointer and array levels applied to the + * declaration's base type. */ +static void c_hint_declared_initializer(CLSPContext *ctx, TSNode init_declarator) { + TSNode value = ts_node_child_by_field_name(init_declarator, "value", 5); + if (ts_node_is_null(value) || strcmp(ts_node_type(value), "initializer_list") != 0) + return; + TSNode declaration = ts_node_parent(init_declarator); + if (ts_node_is_null(declaration) || strcmp(ts_node_type(declaration), "declaration") != 0) + return; + const CBMType *type = c_parse_declaration_type(ctx, declaration); + TSNode decl = ts_node_child_by_field_name(init_declarator, "declarator", 10); + for (int depth = 0; depth < 8 && !ts_node_is_null(decl); depth++) { + const char *dk = ts_node_type(decl); + if (strcmp(dk, "identifier") == 0) { + c_hint_initializer_list(ctx, value, type); + return; + } + if (strcmp(dk, "pointer_declarator") == 0) + type = cbm_type_pointer(ctx->arena, type); + else if (strcmp(dk, "array_declarator") == 0) + type = cbm_type_slice(ctx->arena, type); + else + return; /* a parenthesized or function declarator: not followed */ + decl = ts_node_child_by_field_name(decl, "declarator", 10); + } +} + +/* One `.name` step of a designator on a value of type `type`: publish the + * member's owner and return the member's type (NULL when unknown). */ +static const CBMType *c_designate_member(CLSPContext *ctx, const CBMType *type, TSNode name_node) { + if (!type || ts_node_is_null(name_node)) + return NULL; + const char *type_qn = type_to_qn(c_simplify_type(ctx, type, false)); + char *name = c_node_text(ctx, name_node); + if (!type_qn || !name || !name[0]) + return NULL; + c_publish_field_owner(ctx, type_qn, name); + return c_lookup_field_type(ctx, type_qn, name, 0); +} + +/* The type of one element of an array being initialized, NULL otherwise. */ +static const CBMType *c_initialized_element_type(CLSPContext *ctx, const CBMType *type) { + if (!type) + return NULL; + const CBMType *base = c_simplify_type(ctx, type, false); + return base && base->kind == CBM_TYPE_SLICE ? base->data.slice.elem : NULL; +} + +static void c_walk_initializer_pair(CLSPContext *ctx, TSNode pair, const CBMType *list_type) { + TSNode value = ts_node_child_by_field_name(pair, "value", 5); + const CBMType *type = list_type; + uint32_t nc = ts_node_named_child_count(pair); + for (uint32_t i = 0; i < nc; i++) { + TSNode part = ts_node_named_child(pair, i); + if (!ts_node_is_null(value) && part.id == value.id) + continue; + const char *pk = ts_node_type(part); + if (strcmp(pk, "field_designator") == 0) { + type = c_designate_member(ctx, type, ts_node_named_child(part, 0)); + } else if (strcmp(pk, "field_identifier") == 0) { + type = c_designate_member(ctx, type, part); /* GNU `name: value` */ + } else if (strcmp(pk, "subscript_designator") == 0 || + strcmp(pk, "subscript_range_designator") == 0) { + type = c_initialized_element_type(ctx, type); + c_resolve_calls_in_node(ctx, part); /* the index is an expression */ + } + } + if (!ts_node_is_null(value)) { + c_hint_initializer_list(ctx, value, type); + c_resolve_calls_in_node(ctx, value); + } +} + +static void c_walk_initializer_list(CLSPContext *ctx, TSNode list) { + const CBMType *list_type = ctx->init_list_id == list.id ? ctx->init_list_type : NULL; + ctx->init_list_type = NULL; + ctx->init_list_id = NULL; + + const CBMType *elem_type = c_initialized_element_type(ctx, list_type); + /* A positional nested list initializes the member at its position, as + * long as no designator has moved the position. */ + const CBMRegisteredType *record = NULL; + if (list_type && !elem_type) { + const char *record_qn = type_to_qn(c_simplify_type(ctx, list_type, false)); + record = record_qn ? c_resolve_field_owner_type(ctx, record_qn) : NULL; + for (int hop = 0; record && record->alias_of && hop < 5; hop++) + record = c_resolve_field_owner_type(ctx, record->alias_of); + } + int position = 0; + uint32_t nc = ts_node_named_child_count(list); + for (uint32_t i = 0; i < nc; i++) { + TSNode child = ts_node_named_child(list, i); + const char *ck = ts_node_type(child); + if (strcmp(ck, "comment") == 0) + continue; + if (strcmp(ck, "initializer_pair") == 0) { + record = NULL; + c_walk_initializer_pair(ctx, child, list_type); + continue; + } + if (strcmp(ck, "initializer_list") == 0) { + c_hint_initializer_list(ctx, child, + elem_type ? elem_type + : c_struct_field_type_at(record, position)); + } + position++; + c_resolve_calls_in_node(ctx, child); + } +} + /* 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 @@ -4138,6 +4308,20 @@ static void c_resolve_calls_in_node_inner(CLSPContext *ctx, TSNode node) { // Process statements for scope building c_process_statement(ctx, node); + // --- Initializers: hand the initialized type down to the list --- + if (strcmp(kind, "initializer_list") == 0) { + c_walk_initializer_list(ctx, node); + return; + } + if (strcmp(kind, "init_declarator") == 0) { + c_hint_declared_initializer(ctx, node); + } else if (strcmp(kind, "compound_literal_expression") == 0) { + TSNode literal_type = ts_node_child_by_field_name(node, "type", 4); + if (!ts_node_is_null(literal_type)) + c_hint_initializer_list(ctx, ts_node_child_by_field_name(node, "value", 5), + c_parse_type_node(ctx, literal_type)); + } + // --- Member access: publish which type owns the field --- if (strcmp(kind, "field_expression") == 0) { c_emit_field_reference(ctx, node); @@ -5168,6 +5352,15 @@ static void c_process_function(CLSPContext *ctx, TSNode func_node) { // Process namespace // ============================================================================ +// Node kinds that only group top-level items: the branches of `#if` / `#ifdef` +// (an include guard is one) and the body of `extern "C" { ... }`. +static bool c_is_body_container(const char *kind) { + return strcmp(kind, "preproc_if") == 0 || strcmp(kind, "preproc_ifdef") == 0 || + strcmp(kind, "preproc_else") == 0 || strcmp(kind, "preproc_elif") == 0 || + strcmp(kind, "preproc_elifdef") == 0 || strcmp(kind, "linkage_specification") == 0 || + strcmp(kind, "declaration_list") == 0; +} + // Process a top-level or nested declaration within a namespace/class body, // handling template_declaration wrapping. static void c_process_body_child(CLSPContext *ctx, TSNode child) { @@ -5283,6 +5476,15 @@ static void c_process_body_child(CLSPContext *ctx, TSNode child) { } if (!has_class) { c_resolve_calls_in_node(ctx, child); + } else { + /* The specifier was handled as a class above; what the declaration + * DECLARES is still code. `static struct Ops table = { .open = f };` + * names a struct and never opened its initializer. */ + for (uint32_t di = 0; di < dnc; di++) { + TSNode dch = ts_node_named_child(child, di); + if (strcmp(ts_node_type(dch), "init_declarator") == 0) + c_resolve_calls_in_node(ctx, dch); + } } } else if (strcmp(ck, "concept_definition") == 0) { // C++20 concept: concept Sortable = requires(T a) { a.sort(); }; @@ -5309,6 +5511,22 @@ static void c_process_body_child(CLSPContext *ctx, TSNode child) { ctx->template_param_names = saved_tpn; ctx->template_param_defaults = saved_tpd; ctx->template_param_count = saved_tpc; + } else if (c_is_body_container(ck)) { + /* A preprocessor branch or an `extern "C"` block holds top-level + * items, not statements: its functions are functions. Walked as an + * expression they kept the FILE as their enclosing scope, so every + * typed row of a function behind an include guard or an `#ifdef` was + * attributed to the module and matched no call or member site. + * Cursor walk (O(n)): an include guard holds the whole header. */ + TSTreeCursor cursor = ts_tree_cursor_new(child); + if (ts_tree_cursor_goto_first_child(&cursor)) { + do { + TSNode inner = ts_tree_cursor_current_node(&cursor); + if (ts_node_is_named(inner)) + c_process_body_child(ctx, inner); + } while (ts_tree_cursor_goto_next_sibling(&cursor)); + } + ts_tree_cursor_delete(&cursor); } else { c_resolve_calls_in_node(ctx, child); } @@ -5623,6 +5841,41 @@ static void c_process_class(CLSPContext *ctx, TSNode class_node) { // Process file: top-level walk // ============================================================================ +/* Pass 1 for one file-scope item: bind using declarations, typedefs and global + * variables before any function body is walked. Preprocessor branches and + * `extern "C"` blocks are descended: everything in a header sits inside its + * include guard, and a global behind an `#ifdef` is a global. */ +static void c_bind_file_scope_item(CLSPContext *ctx, TSNode item, int depth) { + const char *ck = ts_node_type(item); + if (strcmp(ck, "using_declaration") == 0 || strcmp(ck, "alias_declaration") == 0 || + strcmp(ck, "type_definition") == 0 || strcmp(ck, "namespace_alias_definition") == 0 || + strcmp(ck, "declaration") == 0) { + c_process_statement(ctx, item); + } else if (strcmp(ck, "template_declaration") == 0) { + // template using Vec = std::vector; + // Unwrap template_declaration to process inner alias/typedef + uint32_t tnc = ts_node_named_child_count(item); + for (uint32_t ti = 0; ti < tnc; ti++) { + TSNode inner = ts_node_named_child(item, ti); + const char *ik = ts_node_type(inner); + if (strcmp(ik, "alias_declaration") == 0 || strcmp(ik, "type_definition") == 0) { + c_process_statement(ctx, inner); + } + } + } else if (c_is_body_container(ck) && depth < C_LSP_MAX_WALK_DEPTH) { + // Cursor walk (O(n)): an include guard holds the whole header. + TSTreeCursor cursor = ts_tree_cursor_new(item); + if (ts_tree_cursor_goto_first_child(&cursor)) { + do { + TSNode inner = ts_tree_cursor_current_node(&cursor); + if (ts_node_is_named(inner)) + c_bind_file_scope_item(ctx, inner, depth + 1); + } while (ts_tree_cursor_goto_next_sibling(&cursor)); + } + ts_tree_cursor_delete(&cursor); + } +} + void c_lsp_process_file(CLSPContext *ctx, TSNode root) { if (ts_node_is_null(root)) return; @@ -5632,30 +5885,11 @@ void c_lsp_process_file(CLSPContext *ctx, TSNode root) { uint32_t kn = 0; TSNode *kids = cbm_lsp_collect_children(ctx->arena, root, &kn); TSNode child; // Hoisted: prevents ASan stack-use-after-scope between passes - TSNode inner; // Pass 1: process using declarations and global variables for (uint32_t i = 0; i < kn; i++) { child = kids[i]; - const char *ck = ts_node_type(child); - - if (strcmp(ck, "using_declaration") == 0 || strcmp(ck, "alias_declaration") == 0 || - strcmp(ck, "type_definition") == 0 || strcmp(ck, "namespace_alias_definition") == 0) { - c_process_statement(ctx, child); - } else if (strcmp(ck, "declaration") == 0) { - c_process_statement(ctx, child); - } else if (strcmp(ck, "template_declaration") == 0) { - // template using Vec = std::vector; - // Unwrap template_declaration to process inner alias/typedef - uint32_t tnc = ts_node_named_child_count(child); - for (uint32_t ti = 0; ti < tnc; ti++) { - inner = ts_node_named_child(child, ti); - const char *ik = ts_node_type(inner); - if (strcmp(ik, "alias_declaration") == 0 || strcmp(ik, "type_definition") == 0) { - c_process_statement(ctx, inner); - } - } - } + c_bind_file_scope_item(ctx, child, 0); } // Pass 2: process functions, namespaces, classes, templates @@ -5678,14 +5912,65 @@ static const CBMType *c_parse_return_type_text(CBMArena *a, const char *text, if (!text || !text[0]) return cbm_type_unknown(); - // Skip const/volatile qualifiers - while (strncmp(text, "const ", 6) == 0) - text += 6; - while (strncmp(text, "volatile ", 9) == 0) - text += 9; + // Skip const/volatile qualifiers and the elaborated-type keyword: a member + // declared `struct Ops *ops;` has the type text `struct Ops`, and the + // struct is registered as `Ops`. + static const char *const skipped[] = {"const ", "volatile ", "struct ", "union ", "enum "}; + for (bool again = true; again;) { + again = false; + for (size_t k = 0; k < sizeof(skipped) / sizeof(skipped[0]); k++) { + size_t klen = strlen(skipped[k]); + if (strncmp(text, skipped[k], klen) == 0) { + text += klen; + again = true; + } + } + } + + // Pointer to function, the way the extractor spells the type of an inline + // pointer-to-function member: `ret (*)(params)`, `ret (*[4])(params)`. A + // call through it yields `ret`. + size_t len = strlen(text); + const char *fp_open = strstr(text, "(*"); + if (fp_open && fp_open > text && len > 0 && text[len - 1] == ')' && + strncmp(text, "decltype(", 9) != 0) { + char *ret_text = cbm_arena_strndup(a, text, (size_t)(fp_open - text)); + size_t rlen = ret_text ? strlen(ret_text) : 0; + while (rlen > 0 && ret_text[rlen - 1] == ' ') + ret_text[--rlen] = '\0'; + const CBMType *rets[2] = { + rlen > 0 ? c_parse_return_type_text(a, ret_text, module_qn) : cbm_type_unknown(), NULL}; + return cbm_type_func(a, NULL, NULL, rets); + } + + // A qualifier on the outermost pointer level (`char *const`) says nothing + // about the type's shape. + static const char *const trailing[] = {"const", "volatile", "restrict", "__restrict"}; + for (size_t k = 0; k < sizeof(trailing) / sizeof(trailing[0]); k++) { + size_t klen = strlen(trailing[k]); + if (len > klen && strcmp(text + len - klen, trailing[k]) == 0 && + (text[len - klen - 1] == '*' || text[len - klen - 1] == ' ')) { + char *inner = cbm_arena_strndup(a, text, len - klen); + size_t ilen = inner ? strlen(inner) : 0; + while (ilen > 0 && inner[ilen - 1] == ' ') + inner[--ilen] = '\0'; + return c_parse_return_type_text(a, inner, module_qn); + } + } + + // Array member (`int [8]`, `char *[4]`): indexing it yields the element. + if (len > 0 && text[len - 1] == ']') { + const char *open = strrchr(text, '['); + if (open && open > text) { + char *inner = cbm_arena_strndup(a, text, (size_t)(open - text)); + size_t ilen = inner ? strlen(inner) : 0; + while (ilen > 0 && inner[ilen - 1] == ' ') + inner[--ilen] = '\0'; + return cbm_type_slice(a, c_parse_return_type_text(a, inner, module_qn)); + } + } // Pointer - size_t len = strlen(text); if (len > 0 && text[len - 1] == '*') { char *inner = cbm_arena_strndup(a, text, len - 1); // Trim trailing space diff --git a/internal/cbm/lsp/c_lsp.h b/internal/cbm/lsp/c_lsp.h index 0cff48f49..ee0290cd3 100644 --- a/internal/cbm/lsp/c_lsp.h +++ b/internal/cbm/lsp/c_lsp.h @@ -72,6 +72,13 @@ typedef struct { int field_ref_seen_cap; int field_ref_seen_count; + // The type an initializer_list is about to initialize, handed from the + // declarator / compound literal / enclosing list that knows it to the walk + // of that list. Bound to the list's node id: a hint nothing consumed must + // never type a later, unrelated list. + const CBMType *init_list_type; + const void *init_list_id; + // 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 e52448dac..693947106 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -25,6 +25,7 @@ #include "foundation/mem_core.h" /* field-owner index storage */ #include "graph_buffer/graph_buffer.h" #include "foundation/constants.h" +#include "pipeline/pipeline.h" /* cbm_registry_unique_field_qn, cbm_resolution_t */ #include #include @@ -1047,6 +1048,40 @@ static inline const cbm_gbuf_node_t *cbm_pipeline_c_member_call_field( return best; } +/* Confidence of a member call bound by the type of its object, and of one + * bound because the project holds exactly one Field of that name. */ +#define CBM_CONF_MEMBER_TYPED 0.95 +#define CBM_CONF_MEMBER_UNIQUE 0.75 + +/* The complete rule for a C/C++ member call: the Field the object's type + * names; for an object the LSP could not type, the one Field of that name in + * the project, where allow_unique_name says the file follows the C member rule + * (cbm_c_member_rule_file) -- in other C++ files an untyped `u->size()` is a + * method of a type the project does not hold far more often than a call + * through some struct's member; otherwise NULL. On a hit *res is rewritten to + * that Field. */ +static inline const cbm_gbuf_node_t *cbm_pipeline_c_member_call_resolve( + const CBMResolvedCallArray *arr, const cbm_pipeline_lsp_field_index_t *index, + const cbm_gbuf_t *gbuf, const cbm_registry_t *registry, const char *project_name, + const char *enclosing_func_qn, const char *callee_text, bool allow_unique_name, + cbm_resolution_t *res) { + bool typed = false; + const cbm_gbuf_node_t *field = cbm_pipeline_c_member_call_field( + arr, index, gbuf, project_name, enclosing_func_qn, callee_text, &typed); + if (!field && !typed && allow_unique_name) { + const char *only = + cbm_registry_unique_field_qn(registry, cbm_lsp_bare_segment(callee_text)); + field = only ? cbm_gbuf_find_by_qn(gbuf, only) : NULL; + } + if (field) { + res->qualified_name = field->qualified_name; + res->strategy = typed ? "lsp_field_access" : "unique_name"; + res->confidence = typed ? CBM_CONF_MEMBER_TYPED : CBM_CONF_MEMBER_UNIQUE; + res->candidate_count = SKIP_ONE; + } + return field; +} + /* 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 0c324f466..1f7b9b4f4 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -743,6 +743,18 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count); } + /* `p->open(fd)`: the registry does not split a callee on the arrow, and a + * member is not a name it could resolve anyway. The object's type decides, + * exactly as for the dot call handled further down. MUST match + * pass_parallel.c. */ + bool arrow_bound = false; + if ((!res.qualified_name || res.qualified_name[0] == '\0') && + cbm_c_arrow_member_call(lang, call->callee_name)) { + arrow_bound = cbm_pipeline_c_member_call_resolve( + lsp_calls, NULL, ctx->gbuf, ctx->registry, ctx->project_name, + call->enclosing_func_qn, call->callee_name, + cbm_c_member_rule_file(lang, rel), &res) != NULL; + } if (!res.qualified_name || res.qualified_name[0] == '\0') { /* Resolution is empty when the callee belongs to an EXTERNAL client * library whose source is not in the indexed tree (e.g. `requests.get`, @@ -880,25 +892,18 @@ 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: { - 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, &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; + case CBM_FIELD_CALL_BY_OWNER: + /* An arrow call was already bound this way above. */ + if (!arrow_bound) { + target_node = cbm_pipeline_c_member_call_resolve( + lsp_calls, NULL, ctx->gbuf, ctx->registry, ctx->project_name, + call->enclosing_func_qn, call->callee_name, cbm_c_member_rule_file(lang, rel), + &res); } if (!target_node || source_node->id == target_node->id) { return 0; } - res.qualified_name = target_node->qualified_name; - 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 14644cd51..0aec4e942 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2998,6 +2998,23 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB res = cbm_registry_resolve(rc->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count); } + /* `p->open(fd)`: the registry does not split a callee on the arrow. + * The object's type decides, as for the dot call handled further + * down. Mirrors pass_calls.c. */ + bool arrow_bound = false; + if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution && + cbm_c_arrow_member_call(lang, call->callee_name)) { + if (!field_index_built) { + field_index_ready = + cbm_pipeline_lsp_field_index_build(&result->resolved_calls, &field_index); + field_index_built = true; + } + arrow_bound = + cbm_pipeline_c_member_call_resolve( + &result->resolved_calls, field_index_ready ? &field_index : NULL, rc->main_gbuf, + rc->registry, rc->project_name, call->enclosing_func_qn, call->callee_name, + cbm_c_member_rule_file(lang, rel), &res) != NULL; + } atomic_fetch_add_explicit(&rc->time_ns_rc_resolve, extract_now_ns() - _rc_t0, memory_order_relaxed); @@ -3150,28 +3167,19 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB if (field_policy == CBM_FIELD_CALL_DROP) { continue; } - if (field_policy == CBM_FIELD_CALL_BY_OWNER) { + if (field_policy == CBM_FIELD_CALL_BY_OWNER && !arrow_bound) { if (!field_index_built) { field_index_ready = 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( + target_node = cbm_pipeline_c_member_call_resolve( &result->resolved_calls, field_index_ready ? &field_index : NULL, rc->main_gbuf, - 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; - } + rc->registry, rc->project_name, call->enclosing_func_qn, call->callee_name, + cbm_c_member_rule_file(lang, rel), &res); if (!target_node || source_node->id == target_node->id) { continue; } - res.qualified_name = target_node->qualified_name; - res.strategy = typed ? "lsp_field_access" : "unique_name"; } } if (target_node && source_node->id != target_node->id && @@ -3245,7 +3253,8 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, * 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_c_member_binds_by_owner(cbm_c_member_rule_file(lang, rel), + 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); @@ -3411,7 +3420,7 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi } /* 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)) { + if (cbm_c_member_binds_by_owner(cbm_c_member_rule_file(lang, rel), 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); @@ -3684,8 +3693,9 @@ static bool pp_has_pending_lsp_site(const CBMFileResult *result) { * 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) { +static bool pp_has_unowned_c_member(const CBMFileResult *result, CBMLanguage lang, + const char *rel) { + if (!cbm_c_member_rule_file(lang, rel)) { return false; } cbm_pipeline_lsp_field_index_t field_index = {0}; @@ -3813,7 +3823,7 @@ 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 unowned_c_member = pp_has_unowned_c_member(result, lang, rel); bool cross_lsp_eligible = (rc->all_defs && rc->def_count > 0 && cbm_pxc_has_cross_lsp(lang) && (((semantic_sites > 0 || pending_lsp_site) && diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 31f3b015d..f6c04ece2 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -168,7 +168,8 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res * 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_c_member_binds_by_owner(cbm_c_member_rule_file(lang, rel), + 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); @@ -341,7 +342,7 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result } /* 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)) { + if (cbm_c_member_binds_by_owner(cbm_c_member_rule_file(lang, rel), 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); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index e27750303..8f18a9ae9 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -369,12 +369,26 @@ cbm_field_call_policy_t cbm_call_onto_field_policy(CBMLanguage caller_lang, cons const char *target_label, const char *target_file_path); +/* True for a C/C++ call spelled through an arrow (`p->open(fd)`). The + * short-name registry splits a callee on `.` and `::` only, so such a call + * comes back from it unresolved; the caller then asks the object's type for + * the member, as for a dot call. Pure; unit-tested in test_registry.c. */ +bool cbm_c_arrow_member_call(CBMLanguage caller_lang, const char *callee_text); + /* True when a reference is the member half of a C `a.b` / `a->b`. Such a * 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); +/* True when a file's member accesses follow the C member rule above: every C + * file, and every `.h`. A `.h` is parsed as C++ because it may be either, and + * `a.b` / `a->b` means the same in both: the type of `a` names `b`. Without it + * the inline functions of a C header kept binding members by bare name. Other + * C++ files are left to their own resolution. Pure; unit-tested in + * test_registry.c. */ +bool cbm_c_member_rule_file(CBMLanguage lang, const char *rel_path); + /* 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. */ diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 1fc8fceb1..dd366f3bd 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -843,6 +843,24 @@ cbm_field_call_policy_t cbm_call_onto_field_policy(CBMLanguage caller_lang, cons return caller_lang == CBM_LANG_C ? CBM_FIELD_CALL_DROP : CBM_FIELD_CALL_KEEP; } +bool cbm_c_arrow_member_call(CBMLanguage caller_lang, const char *callee_text) { + if (caller_lang != CBM_LANG_C && caller_lang != CBM_LANG_CPP) { + return false; + } + return callee_text != NULL && strstr(callee_text, "->") != NULL; +} + +bool cbm_c_member_rule_file(CBMLanguage lang, const char *rel_path) { + if (lang == CBM_LANG_C) { + return true; + } + if (lang != CBM_LANG_CPP || !rel_path) { + return false; + } + size_t len = strlen(rel_path); + return len > PAIR_LEN && strcmp(rel_path + len - PAIR_LEN, ".h") == 0; +} + 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 diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 4a6d5941d..916fc7a64 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1115,6 +1115,57 @@ TEST(extract_c_anonymous_typedef_aggregate_is_named_by_its_typedef) { PASS(); } +/* A pointer-to-function member (`void (*open)(int);`, the shape of every + * kernel ops table) is a field of its struct. It shares the + * field_declaration + function_declarator shape with a C++ member FUNCTION + * declaration, which the field extractor rightly skips; the two differ in + * where the name sits: `(*open)` is a parenthesized pointer declarator. */ +TEST(extract_c_function_pointer_member_is_a_field) { + CBMFileResult *r = extract("struct Ops {\n" + " int flags;\n" + " void (*open)(int fd);\n" + " int (*cmp)(const void *, const void *);\n" + " char *(*name_of)(int id);\n" + " void (*handlers[4])(void);\n" + "};\n", + CBM_LANG_C, "t", "ops.h"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_TRUE(has_def_qn(r, "t.ops.Ops.flags")); + /* RED before the fix: none of the four pointer-to-function members. */ + ASSERT_TRUE(has_def_qn(r, "t.ops.Ops.open")); + ASSERT_TRUE(has_def_qn(r, "t.ops.Ops.cmp")); + ASSERT_TRUE(has_def_qn(r, "t.ops.Ops.name_of")); + ASSERT_TRUE(has_def_qn(r, "t.ops.Ops.handlers")); + ASSERT_EQ(count_defs_with_label(r, "Field"), 5); + /* Each carries a type text, which the cross-file registry needs to list + * it among its struct's fields. */ + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->label && strcmp(d->label, "Field") == 0) { + ASSERT_NOT_NULL(d->return_type); + ASSERT_TRUE(d->return_type[0] != '\0'); + } + } + cbm_free_result(r); + + /* A C++ member function DECLARATION stays out of the fields; a + * pointer-to-function member beside it is one. */ + r = extract("class Widget {\n" + "public:\n" + " void resize(int w);\n" + " int (*hook)(int);\n" + " int width;\n" + "};\n", + CBM_LANG_CPP, "t", "widget.hpp"); + ASSERT_NOT_NULL(r); + ASSERT_TRUE(has_def(r, "Field", "hook")); + ASSERT_TRUE(has_def(r, "Field", "width")); + ASSERT_FALSE(has_def(r, "Field", "resize")); + 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) { @@ -1126,6 +1177,103 @@ static const char *def_return_type(CBMFileResult *r, const char *name) { return NULL; } +/* A member's name is the identifier at the bottom of its declarator, and its + * type is everything around that identifier, in the canonical spelling of the + * function return types below. Taking the first declarator's first child as + * the name published members called `*argv` and `seen[8]`, which no member + * access can ever match; taking only the `type` node typed `struct Node *next` + * as a struct, so `list->next[i]` had no element type; and of + * `struct Node *next, *prev;` only `next` existed. */ +TEST(extract_c_member_declarators_name_and_type) { + CBMFileResult *r = extract("struct Shapes {\n" + " char **argv;\n" + " int seen[8];\n" + " struct Node *next, *prev;\n" + " int a, b;\n" + " const char *label;\n" + " unsigned flags : 3;\n" + " char *const fixed;\n" + " char *names[4];\n" + " int plain;\n" + "};\n", + CBM_LANG_C, "t", "shapes.h"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* RED before the fix: `*argv`, `seen[8]`, `*next`, `*label`, `*names[4]` + * as names, and no `prev` / `b` at all. */ + ASSERT_EQ(count_defs_with_label(r, "Field"), 11); + ASSERT_STR_EQ(def_return_type(r, "argv"), "char **"); + ASSERT_STR_EQ(def_return_type(r, "seen"), "int [8]"); + ASSERT_STR_EQ(def_return_type(r, "next"), "struct Node *"); + ASSERT_STR_EQ(def_return_type(r, "prev"), "struct Node *"); + ASSERT_STR_EQ(def_return_type(r, "a"), "int"); + ASSERT_STR_EQ(def_return_type(r, "b"), "int"); + ASSERT_STR_EQ(def_return_type(r, "label"), "const char *"); + ASSERT_STR_EQ(def_return_type(r, "flags"), "unsigned"); + ASSERT_STR_EQ(def_return_type(r, "fixed"), "char *const"); + ASSERT_STR_EQ(def_return_type(r, "names"), "char *[4]"); + ASSERT_STR_EQ(def_return_type(r, "plain"), "int"); + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->label && strcmp(d->label, "Field") == 0) { + ASSERT_NULL(strpbrk(d->name, "*[]() ")); + } + } + cbm_free_result(r); + + /* Pointer-to-function members spell their type the way C does. */ + r = extract("struct Ops {\n" + " void (*open)(int fd);\n" + " char *(*name_of)(int id);\n" + " void (*handlers[4])(void);\n" + " int (*cmp)(const void *,\n" + " const void *);\n" + "};\n", + CBM_LANG_C, "t", "ops.h"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(def_return_type(r, "open"), "void (*)(int fd)"); + ASSERT_STR_EQ(def_return_type(r, "name_of"), "char *(*)(int id)"); + ASSERT_STR_EQ(def_return_type(r, "handlers"), "void (*[4])(void)"); + ASSERT_STR_EQ(def_return_type(r, "cmp"), "int (*)(const void *, const void *)"); + cbm_free_result(r); + + /* C++: a reference member, and member functions stay out. */ + r = extract("class Widget {\n" + "public:\n" + " Widget *parent, *child;\n" + " const Text &title;\n" + " int *ids() const;\n" + " void (resize)(int w);\n" + "};\n", + CBM_LANG_CPP, "t", "widget.hpp"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(count_defs_with_label(r, "Field"), 3); + ASSERT_STR_EQ(def_return_type(r, "parent"), "Widget *"); + ASSERT_STR_EQ(def_return_type(r, "child"), "Widget *"); + ASSERT_STR_EQ(def_return_type(r, "title"), "const Text &"); + cbm_free_result(r); + + /* A member whose type is a macro invocation (jemalloc's + * `ql_head(tcache_slow_t) tcache_ql;`): the member is `tcache_ql`, never + * the macro's argument. Both grammars: a header is parsed as C++. */ + static const CBMLanguage macro_langs[] = {CBM_LANG_C, CBM_LANG_CPP}; + for (size_t li = 0; li < sizeof(macro_langs) / sizeof(macro_langs[0]); li++) { + r = extract("struct arena_s {\n" + " ql_head(tcache_slow_t) tcache_ql;\n" + " malloc_mutex_t mtx;\n" + "};\n", + macro_langs[li], "t", "arena.h"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(has_def(r, "Field", "tcache_slow_t")); + ASSERT_FALSE(has_def(r, "Field", "(tcache_slow_t)")); + ASSERT_TRUE(has_def(r, "Field", "tcache_ql")); + ASSERT_STR_EQ(def_return_type(r, "tcache_ql"), "ql_head(tcache_slow_t)"); + ASSERT_TRUE(has_def(r, "Field", "mtx")); + cbm_free_result(r); + } + PASS(); +} + /* PR #1245: the C grammar splits a declared return type across the `type` node, * sibling type_qualifier nodes and the pointer_declarator chain wrapping the * function declarator. Taking only the `type` node published `const char *` as @@ -3645,6 +3793,50 @@ TEST(c_imports) { PASS(); } +/* Every header keeps its includes inside its include guard, and plenty of + * sources include behind `#ifdef`. Reading only the top level of the file + * left a guarded header with no imports at all. */ +TEST(c_imports_inside_preprocessor_blocks) { + static const CBMLanguage langs[] = {CBM_LANG_C, CBM_LANG_CPP}; + for (size_t li = 0; li < sizeof(langs) / sizeof(langs[0]); li++) { + CBMFileResult *r = extract("#ifndef GUARD_H\n" + "#define GUARD_H\n" + "#include \"first.h\"\n" + "#include \n" + "#if defined(USE_X)\n" + "#include \"x_only.h\"\n" + "#elif defined(USE_Z)\n" + "#include \"z_only.h\"\n" + "#else\n" + "#include \"y_only.h\"\n" + "#endif\n" + "int guarded(void);\n" + "#endif\n", + langs[li], "t", "guard.h"); + ASSERT_NOT_NULL(r); + ASSERT(has_import(r, "first.h")); + ASSERT(has_import(r, "second.h")); + ASSERT(has_import(r, "x_only.h")); + ASSERT(has_import(r, "z_only.h")); + ASSERT(has_import(r, "y_only.h")); + ASSERT_EQ(r->imports.count, 5); + cbm_free_result(r); + } + /* Top-level includes come out once, as before. */ + CBMFileResult *r = extract("#include \"top.h\"\n" + "#ifdef WITH_EXTRA\n" + "#include \"extra.h\"\n" + "#endif\n" + "int f(void) { return 0; }\n", + CBM_LANG_C, "t", "top.c"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(r->imports.count, 2); + ASSERT(has_import(r, "top.h")); + ASSERT(has_import(r, "extra.h")); + cbm_free_result(r); + PASS(); +} + TEST(ruby_imports) { CBMFileResult *r = extract( "require 'json'\nrequire 'net/http'\nrequire_relative 'helpers'\n\nclass Foo; end\n", @@ -8981,6 +9173,8 @@ SUITE(extraction) { 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(extract_c_function_pointer_member_is_a_field); + RUN_TEST(extract_c_member_declarators_name_and_type); RUN_TEST(cpp_class); RUN_TEST(cpp_method_return_type_preserves_pointer_and_qualifier); @@ -9158,6 +9352,7 @@ SUITE(extraction) { RUN_TEST(java_imports); RUN_TEST(rust_imports); RUN_TEST(c_imports); + RUN_TEST(c_imports_inside_preprocessor_blocks); RUN_TEST(ruby_imports); RUN_TEST(lua_imports); RUN_TEST(import_stress_go); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 91862bfc8..86ad18b6d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -6894,6 +6894,195 @@ static void write_c_member_field_fixture(const char *tmp, int pad_files) { "int use_opaque(struct Opaque *o) {\n" " return o->count + o->beta_only;\n" "}\n"); + /* Inline functions in a header. A `.h` is parsed as C++, and its member + * accesses mean what they mean in the .c files that include it. */ + write_temp_file(tmp, "inline.h", + "#ifndef INLINE_H\n" + "#define INLINE_H\n" + "#include \"alpha.h\"\n" + "#include \"beta.h\"\n" + "static inline int hdr_alpha(struct Alpha *a) {\n" + " return a->count;\n" + "}\n" + "static inline int hdr_beta(Beta *b) {\n" + " b->count = 1;\n" + " return b->count;\n" + "}\n" + "/* A struct the header itself declares, through a typedef. */\n" + "typedef struct {\n" + " int count;\n" + " char *buf;\n" + "} HdrBuf;\n" + "struct HdrNode {\n" + " int count;\n" + "};\n" + "static inline int hdr_own_typedef(HdrBuf *s) {\n" + " return s->count;\n" + "}\n" + "static inline int hdr_own_struct(struct HdrNode *n) {\n" + " return n->count;\n" + "}\n" + "#endif\n"); + /* Functions inside preprocessor branches of a .c file are functions like + * any other. */ + write_temp_file(tmp, "guarded.c", + "#include \"alpha.h\"\n" + "#include \"beta.h\"\n" + "\n" + "#ifdef SOME_CONFIG\n" + "int guarded_alpha(struct Alpha *a) {\n" + " return a->count;\n" + "}\n" + "#else\n" + "int guarded_beta(Beta *b) {\n" + " return b->count;\n" + "}\n" + "#endif\n"); + /* A member call on an object nothing types. In C the one member of that + * name in the project may still bind; in a C++ source such a call is a + * method of some type the project does not hold, and binds nothing. */ + write_temp_file(tmp, "cb.h", + "#ifndef CB_H\n" + "#define CB_H\n" + "struct CbTable {\n" + " int (*only_cb)(int);\n" + "};\n" + "#endif\n"); + write_temp_file(tmp, "untyped_c.c", + "struct Hidden;\n" + "\n" + "int c_untyped_call(struct Hidden *h) {\n" + " return h->only_cb(1);\n" + "}\n"); + write_temp_file(tmp, "untyped_cpp.cpp", + "template int cpp_untyped_call(T *h) {\n" + " return h->only_cb(1);\n" + "}\n"); + /* A global variable declared behind an `#ifndef` types the accesses made + * through it, whether its type is spelled with `struct` or a typedef. */ + write_temp_file(tmp, "globals.c", + "#include \"alpha.h\"\n" + "#include \"beta.h\"\n" + "\n" + "#ifndef NO_GLOBALS\n" + "static struct Alpha g_alpha;\n" + "static Beta g_beta;\n" + "#endif\n" + "\n" + "int read_alpha_global(void) {\n" + " return g_alpha.count;\n" + "}\n" + "\n" + "int read_beta_global(void) {\n" + " return g_beta.count;\n" + "}\n"); + /* Ops tables: pointer-to-function members written inline, called through + * `->`. Two tables share both member names, so only the object's type + * says which is called; `arrow_chain` reaches its table through a member + * whose type is spelled `struct Ops *`. */ + write_temp_file(tmp, "ops.h", + "#ifndef OPS_H\n" + "#define OPS_H\n" + "struct Dev;\n" + "struct Ops {\n" + " void (*open)(int fd);\n" + " int (*shut)(int fd);\n" + " struct Dev *(*owner)(int fd);\n" + "};\n" + "struct Dev {\n" + " struct Ops *ops;\n" + " int id;\n" + "};\n" + "struct Bank {\n" + " struct Dev *devs, *spare;\n" + " struct Dev fixed[2];\n" + " int id;\n" + "};\n" + "#endif\n"); + write_temp_file(tmp, "other_ops.h", + "#ifndef OTHER_OPS_H\n" + "#define OTHER_OPS_H\n" + "struct OtherOps {\n" + " void (*open)(int fd);\n" + " int (*shut)(int fd);\n" + " int id;\n" + "};\n" + "#endif\n"); + write_temp_file(tmp, "use_ops.c", + "#include \"ops.h\"\n" + "#include \"other_ops.h\"\n" + "\n" + "void arrow_ops(struct Ops *o) {\n" + " o->open(1);\n" + "}\n" + "\n" + "void arrow_other(struct OtherOps *x) {\n" + " x->open(2);\n" + "}\n" + "\n" + "int arrow_chain(struct Dev *d) {\n" + " return d->ops->shut(3);\n" + "}\n" + "\n" + "int arrow_ret(struct Ops *o) {\n" + " return o->owner(4)->id;\n" + "}\n" + "\n" + "int index_ptr(struct Bank *b) {\n" + " return b->devs[1].id;\n" + "}\n" + "\n" + "int index_arr(struct Bank *b) {\n" + " return b->fixed[0].id;\n" + "}\n" + "\n" + "int second_decl(struct Bank *b) {\n" + " return b->spare->id;\n" + "}\n"); + /* Designated initializers name members with no object expression at all: + * the type being initialized says whose member `.open` is. At file scope + * (the usual ops table), nested, through an array, in a block and in a + * compound literal. Each table file initializes ONE of the two tables. */ + write_temp_file(tmp, "table_ops.c", + "#include \"ops.h\"\n" + "\n" + "static void my_open(int fd) { (void)fd; }\n" + "static int my_shut(int fd) { return fd; }\n" + "\n" + "static struct Ops table_ops = {\n" + " .open = my_open,\n" + " .shut = my_shut,\n" + "};\n" + "\n" + "static struct Bank bank_init = {\n" + " .fixed = { [0] = { .ops = &table_ops } },\n" + " .id = 3,\n" + "};\n"); + write_temp_file(tmp, "table_other.c", + "#include \"other_ops.h\"\n" + "\n" + "static void their_open(int fd) { (void)fd; }\n" + "\n" + "static struct OtherOps other_table = {\n" + " .open = their_open,\n" + "};\n"); + write_temp_file(tmp, "init_local.c", + "#include \"ops.h\"\n" + "#include \"other_ops.h\"\n" + "\n" + "int init_block(void) {\n" + " struct Dev d = { .id = 1 };\n" + " return d.id;\n" + "}\n" + "\n" + "void init_literal(struct Bank *b) {\n" + " *b->devs = (struct Dev){ .id = 2 };\n" + "}\n" + "\n" + "void init_array(void) {\n" + " struct OtherOps pair[2] = { { .id = 1 }, { .id = 2 } };\n" + " (void)pair;\n" + "}\n"); for (int i = 0; i < pad_files; i++) { char name[64]; char body[128]; @@ -6935,6 +7124,80 @@ static int assert_c_member_fields_bind_by_type(cbm_store_t *s, const char *proje /* 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); + /* `p->f(...)` through an inline pointer-to-function member: the call lands + * on the member of the pointed-to struct, never on the same-named member + * of the other table. */ + ASSERT_GTE(fixture_node_count(s, project, "ops.h", "open", "Field"), 1); + ASSERT_GTE(fixture_node_count(s, project, "other_ops.h", "open", "Field"), 1); + ASSERT_TRUE(field_edge_exists(s, project, "arrow_ops", "Ops", "open", "CALLS")); + ASSERT_FALSE(field_edge_exists(s, project, "arrow_ops", "OtherOps", "open", "CALLS")); + ASSERT_TRUE(field_edge_exists(s, project, "arrow_other", "OtherOps", "open", "CALLS")); + ASSERT_FALSE(field_edge_exists(s, project, "arrow_other", "Ops", "open", "CALLS")); + /* The object of the call is itself a member access whose member is typed + * `struct Ops *`: both steps of the chain are typed. */ + ASSERT_TRUE(field_edge_exists(s, project, "arrow_chain", "Dev", "ops", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "arrow_chain", "Ops", "shut", "CALLS")); + ASSERT_FALSE(field_edge_exists(s, project, "arrow_chain", "OtherOps", "shut", "CALLS")); + /* A call through a pointer-to-function member has that member's return + * type: `o->owner(4)->id` is the `id` of struct Dev. */ + ASSERT_TRUE(field_edge_exists(s, project, "arrow_ret", "Ops", "owner", "CALLS")); + ASSERT_TRUE(field_edge_exists(s, project, "arrow_ret", "Dev", "id", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "arrow_ret", "OtherOps", "id", "USAGE")); + /* A pointer member and an array member have an element type, and the + * second declarator of `struct Dev *devs, *spare;` is a member too. `id` + * is a name three structs share, so each of these needs the full chain. */ + ASSERT_TRUE(field_edge_exists(s, project, "index_ptr", "Bank", "devs", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "index_ptr", "Dev", "id", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "index_ptr", "Bank", "id", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "index_arr", "Bank", "fixed", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "index_arr", "Dev", "id", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "second_decl", "Bank", "spare", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "second_decl", "Dev", "id", "USAGE")); + /* The same rule inside a header's inline functions. */ + ASSERT_TRUE(field_edge_exists(s, project, "hdr_alpha", "Alpha", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "hdr_alpha", "Beta", "count", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "hdr_beta", "Beta", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "hdr_beta", "Alpha", "count", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "hdr_beta", "Beta", "count", "WRITES")); + ASSERT_FALSE(field_edge_exists(s, project, "hdr_beta", "Alpha", "count", "WRITES")); + /* A struct the header declares itself, by typedef or by tag. */ + ASSERT_TRUE(field_edge_exists(s, project, "hdr_own_typedef", "HdrBuf", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "hdr_own_typedef", "Alpha", "count", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "hdr_own_struct", "HdrNode", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "hdr_own_struct", "Beta", "count", "USAGE")); + /* ...and inside `#ifdef` / `#else` branches. */ + ASSERT_TRUE(field_edge_exists(s, project, "guarded_alpha", "Alpha", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "guarded_alpha", "Beta", "count", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "guarded_beta", "Beta", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "guarded_beta", "Alpha", "count", "USAGE")); + /* An untyped member call binds the project's one member of that name in + * C, and nothing in a C++ source. */ + ASSERT_GTE(fixture_node_count(s, project, "cb.h", "only_cb", "Field"), 1); + ASSERT_TRUE(field_edge_exists(s, project, "c_untyped_call", "CbTable", "only_cb", "CALLS")); + ASSERT_FALSE(field_edge_exists(s, project, "cpp_untyped_call", "CbTable", "only_cb", "CALLS")); + /* A global behind `#ifndef` types the accesses through it. */ + ASSERT_TRUE(field_edge_exists(s, project, "read_alpha_global", "Alpha", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "read_alpha_global", "Beta", "count", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "read_beta_global", "Beta", "count", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "read_beta_global", "Alpha", "count", "USAGE")); + /* Designated initializers at file scope: the source is the file. */ + ASSERT_TRUE(field_edge_exists(s, project, "table_ops.c", "Ops", "open", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "table_ops.c", "Ops", "shut", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "table_ops.c", "OtherOps", "open", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "table_other.c", "OtherOps", "open", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "table_other.c", "Ops", "open", "USAGE")); + /* Nested: `.fixed = { [0] = { .ops = ... } }` initializes a Dev. */ + ASSERT_TRUE(field_edge_exists(s, project, "table_ops.c", "Bank", "fixed", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "table_ops.c", "Dev", "ops", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "table_ops.c", "Bank", "id", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "table_ops.c", "Dev", "id", "USAGE")); + /* In a block, in a compound literal, and per element of an array. */ + ASSERT_TRUE(field_edge_exists(s, project, "init_block", "Dev", "id", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "init_block", "Bank", "id", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "init_literal", "Dev", "id", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "init_literal", "OtherOps", "id", "USAGE")); + ASSERT_TRUE(field_edge_exists(s, project, "init_array", "OtherOps", "id", "USAGE")); + ASSERT_FALSE(field_edge_exists(s, project, "init_array", "Dev", "id", "USAGE")); return 0; } diff --git a/tests/test_registry.c b/tests/test_registry.c index 4f9aba696..1057befac 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1070,6 +1070,22 @@ TEST(call_onto_field_follows_language_and_shape) { PASS(); } +TEST(c_arrow_member_call_is_c_family_and_arrow_only) { + /* The registry returns nothing for an arrow callee; these are the calls + * the resolvers then hand to the object's type. */ + ASSERT_TRUE(cbm_c_arrow_member_call(CBM_LANG_C, "o->open")); + ASSERT_TRUE(cbm_c_arrow_member_call(CBM_LANG_C, "d->ops->shut")); + ASSERT_TRUE(cbm_c_arrow_member_call(CBM_LANG_CPP, "self->hook")); + /* A dot call resolves through the registry and its Field policy. */ + ASSERT_FALSE(cbm_c_arrow_member_call(CBM_LANG_C, "cb.close")); + ASSERT_FALSE(cbm_c_arrow_member_call(CBM_LANG_C, "socket")); + ASSERT_FALSE(cbm_c_arrow_member_call(CBM_LANG_C, NULL)); + /* Perl and PHP spell method calls with an arrow too: not this rule. */ + ASSERT_FALSE(cbm_c_arrow_member_call(CBM_LANG_PERL, "$obj->method")); + ASSERT_FALSE(cbm_c_arrow_member_call(CBM_LANG_PHP, "$this->save")); + 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 @@ -1117,6 +1133,24 @@ TEST(c_member_access_binds_by_owner_only) { PASS(); } +TEST(c_member_rule_covers_c_files_and_every_dot_h) { + ASSERT_TRUE(cbm_c_member_rule_file(CBM_LANG_C, "src/a.c")); + ASSERT_TRUE(cbm_c_member_rule_file(CBM_LANG_C, NULL)); + /* A header is classified C++ whatever it holds; its inline functions + * access members like the .c files that include it. */ + ASSERT_TRUE(cbm_c_member_rule_file(CBM_LANG_CPP, "include/linux/list.h")); + /* Other C++ files keep their own resolution. */ + ASSERT_FALSE(cbm_c_member_rule_file(CBM_LANG_CPP, "src/widget.cpp")); + ASSERT_FALSE(cbm_c_member_rule_file(CBM_LANG_CPP, "src/widget.hpp")); + ASSERT_FALSE(cbm_c_member_rule_file(CBM_LANG_CPP, "src/match.hh")); + ASSERT_FALSE(cbm_c_member_rule_file(CBM_LANG_CPP, ".h")); + ASSERT_FALSE(cbm_c_member_rule_file(CBM_LANG_CPP, NULL)); + /* No other language, whatever the file is called. */ + ASSERT_FALSE(cbm_c_member_rule_file(CBM_LANG_OBJC, "ui/View.h")); + ASSERT_FALSE(cbm_c_member_rule_file(CBM_LANG_GO, "pkg/a.go")); + 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. @@ -1377,8 +1411,10 @@ 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(c_arrow_member_call_is_c_family_and_arrow_only); RUN_TEST(unique_field_qn_needs_exactly_one_field_of_that_name); RUN_TEST(c_member_access_binds_by_owner_only); + RUN_TEST(c_member_rule_covers_c_files_and_every_dot_h); 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 a245618a644a76254a3a89c2c9c90c93c8babbe4 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 3 Oct 2026 23:55:18 +0200 Subject: [PATCH 20/44] wip(ci): checkpoint hybrid test-impact implementation Preserve the current selector, frozen-input inventory, result codec, coverage collection, runner filtering, and isolated Windows venue work. Component-level regression proofs are recorded in the PR handover. The live classified provider, public execution integration, artifact admission, fresh oracle/shadow evidence, and full platform gates remain incomplete. This checkpoint does not enable or claim verified narrowing. Signed-off-by: Martin Vogel --- Makefile.cbm | 111 +- internal/cbm/cbm.c | 41 +- internal/cbm/cbm.h | 64 + internal/cbm/extract_defs.c | 783 +++- internal/cbm/extract_unified.c | 14 +- internal/cbm/helpers.c | 12 + internal/cbm/lsp/c_lsp.c | 289 +- internal/cbm/lsp/c_lsp.h | 28 + scripts/ci/clean-test-residue.ps1 | 35 +- scripts/test-impact/coverage-map.py | 351 ++ scripts/test-impact/coverage_live_v2.py | 649 ++++ scripts/test-impact/profile_map_v2.py | 730 ++++ scripts/test-impact/profile_text_parser.py | 295 ++ scripts/test.sh | 8 + src/discover/discover.c | 17 +- src/discover/discover_policy_internal.h | 9 + src/discover/gitignore.c | 274 +- src/discover/gitignore_checked.c | 114 + src/discover/gitignore_checked.h | 137 + src/discover/gitignore_checked_parse.c | 143 + src/discover/gitignore_core.c | 243 ++ src/discover/gitignore_guard.c | 209 + src/discover/gitignore_internal.h | 62 + src/discover/inventory.c | 137 + src/discover/inventory_controls.c | 177 + src/discover/inventory_directories.c | 239 ++ src/discover/inventory_filter.c | 239 ++ src/discover/inventory_filter.h | 190 + src/discover/inventory_guard.c | 172 + src/discover/inventory_internal.h | 63 + src/discover/inventory_limits_internal.h | 9 + src/discover/inventory_metadata.c | 229 ++ src/discover/test_conventions.c | 302 ++ src/discover/test_conventions.h | 62 + src/discover/userconfig.c | 283 +- src/discover/userconfig.h | 37 + src/foundation/git_env.c | 28 +- src/foundation/git_env.h | 11 +- src/foundation/path_syntax_internal.c | 96 + src/foundation/path_syntax_internal.h | 45 + src/foundation/subprocess.c | 239 +- src/foundation/subprocess.h | 19 +- src/foundation/test_selection.h | 8 + src/mcp/mcp.c | 620 ++- src/mcp/test_impact.c | 1391 +++++++ src/mcp/test_impact.h | 553 +++ src/mcp/test_impact_changes.c | 519 +++ src/mcp/test_impact_changes.h | 75 + src/mcp/test_impact_config.c | 209 + src/mcp/test_impact_coverage.c | 1276 ++++++ src/mcp/test_impact_diff.c | 366 ++ src/mcp/test_impact_git.c | 2041 ++++++++++ src/mcp/test_impact_git.h | 275 ++ src/mcp/test_impact_inventory.c | 262 ++ src/mcp/test_impact_inventory.h | 54 + src/mcp/test_impact_inventory_input.c | 201 + src/mcp/test_impact_inventory_internal.h | 47 + src/mcp/test_impact_origins.c | 1074 ++++++ src/mcp/test_impact_origins.h | 167 + src/mcp/test_impact_policy.c | 1044 +++++ src/mcp/test_impact_result.c | 74 + src/mcp/test_impact_result.h | 274 ++ src/mcp/test_impact_result_internal.h | 174 + src/mcp/test_impact_result_json.c | 297 ++ src/mcp/test_impact_result_project.c | 147 + src/mcp/test_impact_result_receipt.c | 281 ++ src/mcp/test_impact_result_rows.c | 287 ++ src/mcp/test_impact_result_util.c | 270 ++ src/mcp/test_impact_result_validate.c | 302 ++ src/mcp/test_impact_result_wire.c | 255 ++ src/mcp/test_impact_select.c | 336 ++ src/mcp/test_impact_tree.c | 138 + src/mcp/test_impact_tree.h | 154 + src/mcp/test_impact_tree_audit.c | 283 ++ src/mcp/test_impact_tree_batch.c | 42 + src/mcp/test_impact_tree_digest.c | 47 + src/mcp/test_impact_tree_internal.h | 157 + src/mcp/test_impact_tree_names.c | 94 + src/mcp/test_impact_tree_plan.c | 242 ++ src/mcp/test_impact_tree_posix.c | 203 + src/mcp/test_impact_tree_posix_acl.c | 124 + src/mcp/test_impact_tree_posix_io.c | 132 + src/mcp/test_impact_tree_read.c | 132 + src/mcp/test_impact_tree_util.c | 195 + src/mcp/test_impact_tree_windows.c | 243 ++ src/mcp/test_impact_tree_windows_acl.c | 126 + src/mcp/test_impact_tree_windows_io.c | 141 + src/pipeline/pass_calls.c | 25 +- src/pipeline/pass_definitions.c | 46 +- src/pipeline/pass_lsp_cross.c | 131 +- src/pipeline/pass_parallel.c | 95 +- src/pipeline/pass_semantic.c | 24 +- src/pipeline/pass_usages.c | 25 +- src/pipeline/pipeline.c | 1115 +++++- src/pipeline/pipeline_incremental.c | 500 ++- src/pipeline/pipeline_internal.h | 297 ++ src/store/store.c | 278 +- src/store/store.h | 25 + src/store/store_graph_digest.c | 732 ++++ src/store/store_graph_digest.h | 60 + src/store/store_impact.c | 636 +++ src/store/store_impact.h | 93 + test-infrastructure/vm/vm-run-tests.sh | 35 +- test-infrastructure/vm/win.sh | 54 +- tests/coverage_live_v2_admin.py | 170 + tests/coverage_live_v2_fault.py | 50 + tests/coverage_live_v2_native.py | 241 ++ tests/coverage_live_v2_support.py | 232 ++ tests/fixtures/profile_map_v2/profiles.tsv | 4 + tests/fixtures/profile_map_v2/tests.tsv | 4 + .../fixtures/profile_text/cold-dense.proftext | 30 + .../profile_text/cold-sparse.proftext | 14 + tests/fixtures/profile_text/fixture.c | 23 + .../fixtures/profile_text/hot-dense.proftext | 30 + .../fixtures/profile_text/hot-sparse.proftext | 22 + tests/if_native_git_path.c | 255 ++ tests/if_native_git_path.h | 17 + tests/test_coverage_children.c | 411 ++ tests/test_coverage_children.py | 504 +++ tests/test_coverage_flush.py | 341 ++ tests/test_coverage_live_v2.py | 193 + tests/test_coverage_map.sh | 218 ++ tests/test_coverage_parent_failure.c | 294 ++ tests/test_coverage_parent_failure.py | 235 ++ tests/test_coverage_terminal.py | 375 ++ tests/test_gitignore.c | 19 + tests/test_gitignore_checked.c | 952 +++++ tests/test_harness_test_only.sh | 157 +- tests/test_inventory_filter.c | 58 + tests/test_inventory_filter_cases.c | 245 ++ tests/test_inventory_filter_errors.c | 210 + tests/test_inventory_filter_internal.h | 77 + tests/test_inventory_filter_limits.c | 159 + tests/test_inventory_filter_native.c | 72 + tests/test_inventory_filter_native_fixture.c | 280 ++ tests/test_inventory_filter_native_internal.h | 25 + tests/test_inventory_filter_native_platform.c | 51 + tests/test_inventory_filter_structure_extra.c | 84 + tests/test_inventory_filter_support.c | 237 ++ tests/test_main.c | 553 ++- tests/test_mcp.c | 789 +++- tests/test_pipeline_conventions.c | 623 +++ tests/test_pipeline_frozen.c | 1508 ++++++++ tests/test_profile_map_v2.py | 647 ++++ tests/test_profile_text_parser.py | 216 ++ tests/test_store_graph_digest.c | 607 +++ tests/test_store_impact.c | 363 ++ tests/test_store_scope.c | 443 +++ tests/test_subprocess.c | 851 ++++ tests/test_test_conventions.c | 799 ++++ tests/test_test_impact.c | 3408 +++++++++++++++++ tests/test_test_impact_changes.c | 307 ++ tests/test_test_impact_git.c | 2381 ++++++++++++ tests/test_test_impact_inventory.c | 211 + tests/test_test_impact_inventory_errors.c | 206 + tests/test_test_impact_inventory_internal.h | 30 + tests/test_test_impact_inventory_support.c | 227 ++ tests/test_test_impact_origins.c | 1949 ++++++++++ tests/test_test_impact_profiles.c | 902 +++++ tests/test_test_impact_runner_filter.c | 170 + tests/test_test_impact_runner_filter.h | 50 + .../test_test_impact_runner_filter_fixture.c | 197 + .../test_test_impact_runner_filter_process.c | 164 + tests/test_test_impact_tree.c | 3096 +++++++++++++++ tests/test_userconfig.c | 181 + tests/test_vm_isolated_dispatch.sh | 81 + tests/test_vm_isolation_contract.sh | 35 + tests/test_windows_preflight_contract.ps1 | 59 + 168 files changed, 53250 insertions(+), 790 deletions(-) create mode 100644 scripts/test-impact/coverage-map.py create mode 100644 scripts/test-impact/coverage_live_v2.py create mode 100644 scripts/test-impact/profile_map_v2.py create mode 100644 scripts/test-impact/profile_text_parser.py create mode 100644 src/discover/discover_policy_internal.h create mode 100644 src/discover/gitignore_checked.c create mode 100644 src/discover/gitignore_checked.h create mode 100644 src/discover/gitignore_checked_parse.c create mode 100644 src/discover/gitignore_core.c create mode 100644 src/discover/gitignore_guard.c create mode 100644 src/discover/gitignore_internal.h create mode 100644 src/discover/inventory.c create mode 100644 src/discover/inventory_controls.c create mode 100644 src/discover/inventory_directories.c create mode 100644 src/discover/inventory_filter.c create mode 100644 src/discover/inventory_filter.h create mode 100644 src/discover/inventory_guard.c create mode 100644 src/discover/inventory_internal.h create mode 100644 src/discover/inventory_limits_internal.h create mode 100644 src/discover/inventory_metadata.c create mode 100644 src/discover/test_conventions.c create mode 100644 src/discover/test_conventions.h create mode 100644 src/foundation/path_syntax_internal.c create mode 100644 src/foundation/path_syntax_internal.h create mode 100644 src/foundation/test_selection.h create mode 100644 src/mcp/test_impact.c create mode 100644 src/mcp/test_impact.h create mode 100644 src/mcp/test_impact_changes.c create mode 100644 src/mcp/test_impact_changes.h create mode 100644 src/mcp/test_impact_config.c create mode 100644 src/mcp/test_impact_coverage.c create mode 100644 src/mcp/test_impact_diff.c create mode 100644 src/mcp/test_impact_git.c create mode 100644 src/mcp/test_impact_git.h create mode 100644 src/mcp/test_impact_inventory.c create mode 100644 src/mcp/test_impact_inventory.h create mode 100644 src/mcp/test_impact_inventory_input.c create mode 100644 src/mcp/test_impact_inventory_internal.h create mode 100644 src/mcp/test_impact_origins.c create mode 100644 src/mcp/test_impact_origins.h create mode 100644 src/mcp/test_impact_policy.c create mode 100644 src/mcp/test_impact_result.c create mode 100644 src/mcp/test_impact_result.h create mode 100644 src/mcp/test_impact_result_internal.h create mode 100644 src/mcp/test_impact_result_json.c create mode 100644 src/mcp/test_impact_result_project.c create mode 100644 src/mcp/test_impact_result_receipt.c create mode 100644 src/mcp/test_impact_result_rows.c create mode 100644 src/mcp/test_impact_result_util.c create mode 100644 src/mcp/test_impact_result_validate.c create mode 100644 src/mcp/test_impact_result_wire.c create mode 100644 src/mcp/test_impact_select.c create mode 100644 src/mcp/test_impact_tree.c create mode 100644 src/mcp/test_impact_tree.h create mode 100644 src/mcp/test_impact_tree_audit.c create mode 100644 src/mcp/test_impact_tree_batch.c create mode 100644 src/mcp/test_impact_tree_digest.c create mode 100644 src/mcp/test_impact_tree_internal.h create mode 100644 src/mcp/test_impact_tree_names.c create mode 100644 src/mcp/test_impact_tree_plan.c create mode 100644 src/mcp/test_impact_tree_posix.c create mode 100644 src/mcp/test_impact_tree_posix_acl.c create mode 100644 src/mcp/test_impact_tree_posix_io.c create mode 100644 src/mcp/test_impact_tree_read.c create mode 100644 src/mcp/test_impact_tree_util.c create mode 100644 src/mcp/test_impact_tree_windows.c create mode 100644 src/mcp/test_impact_tree_windows_acl.c create mode 100644 src/mcp/test_impact_tree_windows_io.c create mode 100644 src/store/store_graph_digest.c create mode 100644 src/store/store_graph_digest.h create mode 100644 src/store/store_impact.c create mode 100644 src/store/store_impact.h create mode 100644 tests/coverage_live_v2_admin.py create mode 100644 tests/coverage_live_v2_fault.py create mode 100644 tests/coverage_live_v2_native.py create mode 100644 tests/coverage_live_v2_support.py create mode 100644 tests/fixtures/profile_map_v2/profiles.tsv create mode 100644 tests/fixtures/profile_map_v2/tests.tsv create mode 100644 tests/fixtures/profile_text/cold-dense.proftext create mode 100644 tests/fixtures/profile_text/cold-sparse.proftext create mode 100644 tests/fixtures/profile_text/fixture.c create mode 100644 tests/fixtures/profile_text/hot-dense.proftext create mode 100644 tests/fixtures/profile_text/hot-sparse.proftext create mode 100644 tests/if_native_git_path.c create mode 100644 tests/if_native_git_path.h create mode 100644 tests/test_coverage_children.c create mode 100644 tests/test_coverage_children.py create mode 100644 tests/test_coverage_flush.py create mode 100644 tests/test_coverage_live_v2.py create mode 100644 tests/test_coverage_map.sh create mode 100644 tests/test_coverage_parent_failure.c create mode 100644 tests/test_coverage_parent_failure.py create mode 100644 tests/test_coverage_terminal.py create mode 100644 tests/test_gitignore_checked.c create mode 100644 tests/test_inventory_filter.c create mode 100644 tests/test_inventory_filter_cases.c create mode 100644 tests/test_inventory_filter_errors.c create mode 100644 tests/test_inventory_filter_internal.h create mode 100644 tests/test_inventory_filter_limits.c create mode 100644 tests/test_inventory_filter_native.c create mode 100644 tests/test_inventory_filter_native_fixture.c create mode 100644 tests/test_inventory_filter_native_internal.h create mode 100644 tests/test_inventory_filter_native_platform.c create mode 100644 tests/test_inventory_filter_structure_extra.c create mode 100644 tests/test_inventory_filter_support.c create mode 100644 tests/test_pipeline_conventions.c create mode 100644 tests/test_pipeline_frozen.c create mode 100644 tests/test_profile_map_v2.py create mode 100644 tests/test_profile_text_parser.py create mode 100644 tests/test_store_graph_digest.c create mode 100644 tests/test_store_impact.c create mode 100644 tests/test_store_scope.c create mode 100644 tests/test_test_conventions.c create mode 100644 tests/test_test_impact.c create mode 100644 tests/test_test_impact_changes.c create mode 100644 tests/test_test_impact_git.c create mode 100644 tests/test_test_impact_inventory.c create mode 100644 tests/test_test_impact_inventory_errors.c create mode 100644 tests/test_test_impact_inventory_internal.h create mode 100644 tests/test_test_impact_inventory_support.c create mode 100644 tests/test_test_impact_origins.c create mode 100644 tests/test_test_impact_profiles.c create mode 100644 tests/test_test_impact_runner_filter.c create mode 100644 tests/test_test_impact_runner_filter.h create mode 100644 tests/test_test_impact_runner_filter_fixture.c create mode 100644 tests/test_test_impact_runner_filter_process.c create mode 100644 tests/test_test_impact_tree.c create mode 100644 tests/test_vm_isolated_dispatch.sh create mode 100644 tests/test_vm_isolation_contract.sh create mode 100644 tests/test_windows_preflight_contract.ps1 diff --git a/Makefile.cbm b/Makefile.cbm index b59c5ed00..a545e246b 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -79,11 +79,15 @@ endif # 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. +# -fprofile-continuous: a child process started with `%c` in LLVM_PROFILE_FILE +# keeps its counters mapped onto its profile file, so a child that is killed +# still leaves a complete profile. Linux needs the flag for `%c` to work at +# all; with it macOS behaves the same way (tests/test_main.c, "Children"). TEST_COVERAGE ?= 0 TEST_COVERAGE_FLAGS := TEST_COVERAGE_DEFINE := ifeq ($(TEST_COVERAGE),1) -TEST_COVERAGE_FLAGS := -fprofile-instr-generate -fcoverage-mapping +TEST_COVERAGE_FLAGS := -fprofile-instr-generate -fcoverage-mapping -fprofile-continuous TEST_COVERAGE_DEFINE := -DCBM_TEST_COVERAGE=1 endif @@ -327,6 +331,7 @@ FOUNDATION_SRCS = \ src/foundation/str_util.c \ src/foundation/workspace.c \ src/foundation/platform.c \ + src/foundation/path_syntax_internal.c \ src/foundation/system_info.c \ src/foundation/slab_alloc.c \ src/foundation/yaml.c \ @@ -417,13 +422,15 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp SQLITE_WRITER_SRC = $(CBM_DIR)/sqlite_writer.c # Store module (new) -STORE_SRCS = src/store/store.c +STORE_SRCS = src/store/store.c src/store/store_impact.c src/store/store_graph_digest.c # Cypher module (new) CYPHER_SRCS = src/cypher/cypher.c # MCP server module (new) -MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c +MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c \ + src/mcp/test_impact.c src/mcp/test_impact_diff.c src/mcp/test_impact_changes.c src/mcp/test_impact_origins.c src/mcp/test_impact_coverage.c src/mcp/test_impact_config.c src/mcp/test_impact_select.c src/mcp/test_impact_policy.c src/mcp/test_impact_git.c src/mcp/test_impact_inventory.c src/mcp/test_impact_inventory_input.c src/mcp/test_impact_tree.c src/mcp/test_impact_tree_audit.c src/mcp/test_impact_tree_read.c src/mcp/test_impact_tree_batch.c src/mcp/test_impact_tree_digest.c src/mcp/test_impact_tree_names.c src/mcp/test_impact_tree_plan.c src/mcp/test_impact_tree_posix.c src/mcp/test_impact_tree_posix_acl.c src/mcp/test_impact_tree_posix_io.c src/mcp/test_impact_tree_util.c src/mcp/test_impact_tree_windows.c src/mcp/test_impact_tree_windows_acl.c src/mcp/test_impact_tree_windows_io.c \ + src/mcp/test_impact_result.c src/mcp/test_impact_result_json.c src/mcp/test_impact_result_project.c src/mcp/test_impact_result_receipt.c src/mcp/test_impact_result_rows.c src/mcp/test_impact_result_util.c src/mcp/test_impact_result_validate.c src/mcp/test_impact_result_wire.c # Shared MCP daemon coordination and local transport DAEMON_SRCS = \ @@ -442,7 +449,18 @@ DAEMON_SRCS = \ DISCOVER_SRCS = \ src/discover/language.c \ src/discover/userconfig.c \ + src/discover/test_conventions.c \ src/discover/gitignore.c \ + src/discover/gitignore_checked.c \ + src/discover/inventory_filter.c \ + src/discover/inventory.c \ + src/discover/inventory_guard.c \ + src/discover/inventory_metadata.c \ + src/discover/inventory_directories.c \ + src/discover/inventory_controls.c \ + src/discover/gitignore_checked_parse.c \ + src/discover/gitignore_core.c \ + src/discover/gitignore_guard.c \ src/discover/discover.c # Graph buffer module (new) @@ -633,6 +651,8 @@ PROJECT_HDRS = $(wildcard src/*.h src/*/*.h $(CBM_DIR)/*.h $(CBM_DIR)/lsp/*.h te TEST_FOUNDATION_SRCS = \ tests/test_main.c \ + tests/test_coverage_children.c \ + tests/test_coverage_parent_failure.c \ tests/test_arena.c \ tests/test_hash_table.c \ tests/test_dyn_array.c \ @@ -651,6 +671,7 @@ TEST_FOUNDATION_SRCS = \ TEST_EXTRACTION_SRCS = \ tests/test_extraction.c \ + tests/test_test_conventions.c \ tests/test_extraction_inheritance.c \ tests/test_extraction_imports.c \ tests/test_parse_coverage.c \ @@ -662,6 +683,9 @@ TEST_EXTRACTION_SRCS = \ TEST_STORE_SRCS = \ tests/test_store_nodes.c \ tests/test_store_edges.c \ + tests/test_store_impact.c \ + tests/test_store_scope.c \ + tests/test_store_graph_digest.c \ tests/test_store_search.c \ tests/test_store_arch.c \ tests/test_store_bulk.c \ @@ -674,6 +698,18 @@ TEST_CYPHER_SRCS = \ TEST_MCP_SRCS = \ tests/test_mcp.c \ + tests/test_test_impact.c \ + tests/test_test_impact_profiles.c \ + tests/test_test_impact_changes.c \ + tests/test_test_impact_origins.c \ + tests/test_test_impact_git.c \ + tests/test_test_impact_tree.c \ + tests/test_test_impact_inventory.c \ + tests/test_test_impact_inventory_errors.c \ + tests/test_test_impact_inventory_support.c \ + tests/test_test_impact_runner_filter.c \ + tests/test_test_impact_runner_filter_fixture.c \ + tests/test_test_impact_runner_filter_process.c \ tests/test_index_supervisor.c TEST_DAEMON_SRCS = \ @@ -691,12 +727,23 @@ TEST_DISCOVER_SRCS = \ tests/test_language.c \ tests/test_userconfig.c \ tests/test_gitignore.c \ + tests/test_gitignore_checked.c \ + tests/if_native_git_path.c \ + tests/test_inventory_filter.c \ + tests/test_inventory_filter_cases.c \ + tests/test_inventory_filter_errors.c \ + tests/test_inventory_filter_limits.c \ + tests/test_inventory_filter_native.c \ + tests/test_inventory_filter_native_fixture.c \ + tests/test_inventory_filter_native_platform.c \ + tests/test_inventory_filter_structure_extra.c \ + tests/test_inventory_filter_support.c \ tests/test_git_context.c \ tests/test_discover.c TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c +TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_pipeline_conventions.c tests/test_pipeline_frozen.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c TEST_WATCHER_SRCS = tests/test_watcher.c @@ -869,7 +916,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o # ── Targets ────────────────────────────────────────────────────── -.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 +.PHONY: test-coverage-flush 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) @@ -1407,6 +1454,60 @@ test-runner-cov: cbm-cov: $(COV_MAKE) $(COV_BUILD_DIR)/codebase-memory-mcp TEST_SEAMS=1 +# Genuine LLVM write failures must fail the parent coverage run. +test-coverage-flush: test-runner-cov + @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-flush.XXXXXX") && \ + printf 'Coverage flush evidence: %s/proof\n' "$$evidence_dir" && \ + python3 tests/test_coverage_flush.py \ + --runner "$(COV_BUILD_DIR)/test-runner" \ + --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \ + --output-dir "$$evidence_dir/proof" + +.PHONY: test-coverage-children +# Genuine child profile/marker and setup-reducer controls; evidence is retained. +test-coverage-children: test-runner-cov + @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-children.XXXXXX") && \ + printf 'Coverage child evidence: %s/proof\n' "$$evidence_dir" && \ + python3 tests/test_coverage_children.py \ + --runner "$(COV_BUILD_DIR)/test-runner" \ + --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \ + --reducer scripts/test-impact/coverage-map.py \ + --output-dir "$$evidence_dir/proof" + +.PHONY: test-coverage-parent-failure +# Positive default plus genuine owned EMFILE; all evidence is retained. +test-coverage-parent-failure: test-runner-cov + @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-parent-failure.XXXXXX") && \ + printf 'Coverage parent failure evidence: %s/proof\n' "$$evidence_dir" && \ + python3 tests/test_coverage_parent_failure.py \ + --runner "$(COV_BUILD_DIR)/test-runner" \ + --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \ + --reducer scripts/test-impact/coverage-map.py \ + --output-dir "$$evidence_dir/proof" + +.PHONY: test-coverage-terminal +# A readable stale terminal profile cannot hide a failed parent write. +test-coverage-terminal: test-runner-cov + @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-terminal.XXXXXX") && \ + printf 'Coverage terminal evidence: %s/proof\n' "$$evidence_dir" && \ + python3 tests/test_coverage_terminal.py \ + --runner "$(COV_BUILD_DIR)/test-runner" \ + --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \ + --helper tests/test_coverage_children.py \ + --reducer scripts/test-impact/coverage-map.py \ + --output-dir "$$evidence_dir/proof" + +.PHONY: test-coverage-live-v2 +# Native full-identity transport remains explicitly unadmitted; evidence is retained. +test-coverage-live-v2: test-runner-cov + @evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-live-v2.XXXXXX") && \ + printf 'Coverage live-v2 evidence: %s/proof\n' "$$evidence_dir" && \ + PYTHONDONTWRITEBYTECODE=1 python3 tests/test_coverage_live_v2.py \ + --collector scripts/test-impact/coverage-map.py \ + --runner "$(COV_BUILD_DIR)/test-runner" \ + --llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \ + --output "$$evidence_dir/proof" $(if $(COV_LIVE_V2_CASE),--case "$(COV_LIVE_V2_CASE)") + # ── 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/internal/cbm/cbm.c b/internal/cbm/cbm.c index 082bca1d6..3c29bd8dd 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -2147,7 +2147,8 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C const char **include_paths, const CBMMacroTable *macro_table, const CBMReturnTypeTable *return_type_table, - CBMArena *scratch) { + CBMArena *scratch, + const cbm_test_declarations_t *test_declarations) { // Allocate result on heap (arena inside for all string data) CBMFileResult *result = cbm_result_alloc(); if (!result) { @@ -2156,6 +2157,9 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C cbm_work_arena_take(&result->arena); CBMArena *a = &result->arena; + if (!cbm_test_declarations_validate(result, test_declarations)) { + return result; + } /* Crash-quarantine hard guard (Stage 3c): a file the supervisor pinned as a * crasher must NEVER be parsed again. Return a clean empty result BEFORE the @@ -2334,11 +2338,14 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C .macro_table = macro_table, .return_type_table = return_type_table, .walk_budget_nodes = cbm_walk_max_nodes(), + .test_declarations = test_declarations, + .test_declarations_raw_source = true, }; // Run extractors: defs + imports use separate walks (unique recursion patterns), // then a single unified cursor walk handles the remaining 7 extractors. cbm_extract_definitions(&ctx); + cbm_test_declarations_finish(&ctx); cbm_extract_imports(&ctx); cbm_extract_unified(&ctx); result->tree_nodes = ts_node_descendant_count(root); @@ -2373,8 +2380,18 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C cbm_run_go_lsp(a, result, source, source_len, root); } if (language == CBM_LANG_C || language == CBM_LANG_CPP || language == CBM_LANG_CUDA) { - cbm_run_c_lsp(a, result, source, source_len, root, language != CBM_LANG_C, - CBM_SOURCE_ORIGIN_RAW); + if (!cbm_run_c_lsp_with_test_owners( + a, result, source, source_len, root, language != CBM_LANG_C, + CBM_SOURCE_ORIGIN_RAW, result)) { + result->has_error = true; + if (result->test_declarations_status == CBM_TEST_EXTRACT_OK) { + result->test_declarations_status = CBM_TEST_EXTRACT_UNSUPPORTED_FORM; + result->test_declaration_index = -1; + result->test_declaration_line = 0; + result->error_msg = cbm_arena_strdup(a, "configured owner source identity mismatch"); + if (!result->error_msg) result->test_declarations_status = CBM_TEST_EXTRACT_OOM; + } + } } if (language == CBM_LANG_PHP) { cbm_run_php_lsp(a, result, source, source_len, root); @@ -2485,6 +2502,10 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C .rel_path = rel_path, .module_qn = result->module_qn, .root = pp_root, + /* Preset switches still apply, but expanded bytes + * cannot acquire a raw configured definition identity. */ + .test_declarations = test_declarations, + .test_declarations_raw_source = false, }; // Re-run unified extraction on expanded source. // This adds macro-expanded calls; duplicates with original calls are @@ -3046,6 +3067,17 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua int64_t timeout_micros, const char **extra_defines, const char **include_paths, const CBMMacroTable *macro_table, const CBMReturnTypeTable *return_type_table) { + return cbm_extract_file_ex_with_tests(source, source_len, language, project, rel_path, + timeout_micros, extra_defines, include_paths, + macro_table, return_type_table, NULL); +} + +CBMFileResult *cbm_extract_file_ex_with_tests( + const char *source, int source_len, CBMLanguage language, const char *project, + const char *rel_path, int64_t timeout_micros, const char **extra_defines, + const char **include_paths, const CBMMacroTable *macro_table, + const CBMReturnTypeTable *return_type_table, + const cbm_test_declarations_t *test_declarations) { CBMArena scratch; if (tl_scratch_live && tl_scratch_slot) { scratch = *tl_scratch_slot; @@ -3056,7 +3088,8 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua } CBMFileResult *result = extract_file_ex_body(source, source_len, language, project, rel_path, timeout_micros, extra_defines, include_paths, - macro_table, return_type_table, &scratch); + macro_table, return_type_table, &scratch, + test_declarations); /* !tl_scratch_live: a nested extraction (an embedded language inside this * file) may already have parked its own; never overwrite it. */ /* Kept up to CBM_EXTRACT_SCRATCH_KEEP_BYTES, grown blocks included: the diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 53d356c1c..b14a4aaca 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -17,6 +17,27 @@ TSNode cbm_ts_child_by_field_name(TSNode node, const char *name, uint32_t name_l * (TS_FIELD("body") expands to "body", 4), which must expand before the call. */ #define ts_node_child_by_field_name(...) cbm_ts_child_by_field_name(__VA_ARGS__) +/* Immutable, explicitly supplied test-declaration snapshot. */ +typedef struct cbm_test_declarations cbm_test_declarations_t; + +typedef enum { + CBM_TEST_ROLE_NONE = 0, + CBM_TEST_ROLE_CASE, + CBM_TEST_ROLE_SUITE +} CBMTestDefinitionRole; + +typedef enum { + CBM_TEST_EXTRACT_OK = 0, + CBM_TEST_EXTRACT_UNSUPPORTED_LANGUAGE, + CBM_TEST_EXTRACT_UNSUPPORTED_PRESET, + CBM_TEST_EXTRACT_UNSUPPORTED_NAME, + CBM_TEST_EXTRACT_MISSING_ARGUMENT, + CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT, + CBM_TEST_EXTRACT_AMBIGUOUS, + CBM_TEST_EXTRACT_UNSUPPORTED_FORM, + CBM_TEST_EXTRACT_OOM +} CBMTestExtractStatus; + // Language enum mirrors lang.Language in Go. // Order must match lang_specs.c tables. typedef enum { @@ -246,6 +267,11 @@ typedef struct { * HTTP_CALLS edge to base + path. Tail fields: zero-init stays valid. */ const char *http_client; const char *http_base_url; + CBMTestDefinitionRole test_role; /* NONE preserves legacy helper/test-file semantics */ + /* Configured raw definitions only; zero for all legacy rows. Exact spans + * are bound to the owning result's source identity before cross-file use. */ + uint32_t test_name_start_byte, test_name_end_byte; + uint32_t test_body_start_byte, test_body_end_byte; } CBMDefinition; /* Argument captured from a call expression */ @@ -610,6 +636,15 @@ typedef struct CBMFileResult { // by cbm_free_result(); ordinary single-file results leave these zeroed. struct CBMFileResult **owned_results; int owned_result_count; + /* A consumed configured definition could not be mapped safely. Pipeline + * callers must abort publication; error_msg is arena-owned as usual. */ + CBMTestExtractStatus test_declarations_status; + int test_declaration_index; /* meaningful on failure; -1 = preset */ + uint32_t test_declaration_line; /* 0 = snapshot preflight */ + bool has_test_definition_owners; + int test_owner_source_len; + CBMLanguage test_owner_language; + char test_owner_source_sha256[65]; /* exact raw source; identity, not authentication */ } CBMFileResult; // --- Enclosing function cache --- @@ -696,8 +731,26 @@ typedef struct { /* How many nodes the unified walk actually visited (whether or not it ran * out of budget) — the measurement the budget has to be expressed in. */ uint32_t walk_nodes_visited; + const cbm_test_declarations_t *test_declarations; /* borrowed for this call only */ + bool test_declarations_raw_source; + struct CBMTestDefinitionMatch *test_definition_matches; /* traversal scratch */ + int test_definition_match_count; + int test_definition_match_cap; } CBMExtractCtx; +/* Internal configured-definition seams. No declarations pointer enters a result. */ +bool cbm_test_declarations_validate(CBMFileResult *result, + const cbm_test_declarations_t *declarations); +void cbm_test_declarations_finish(CBMExtractCtx *ctx); +/* Raw AST candidate only: no declaration/configuration decisions. A C split + * invocation uses its adjacent compound body as scope; ordinary definitions + * use their function node. Callers still validate raw bytes before ownership. */ +bool cbm_test_definition_candidate(TSNode scope, CBMLanguage language, TSNode *name, TSNode *body); +const char *cbm_test_definition_qn(CBMExtractCtx *ctx, TSNode function); +bool cbm_test_definition_owners_match(const CBMFileResult *owners, const char *source, + int source_len, bool cpp_mode, const char *module_qn); +const char *cbm_test_definition_owner_qn(const CBMFileResult *owners, TSNode function); + // --- Public API --- // Bind third-party allocators (tree-sitter, sqlite3) to mimalloc as @@ -800,6 +853,17 @@ CBMFileResult *cbm_extract_file_ex( const CBMReturnTypeTable *return_type_table // OS return types, or NULL ); +/* Additive immutable-snapshot entry point. NULL preserves legacy behavior. + * Snapshot must outlive this call; returned data is independently result-owned. + * A non-OK result status is an explicit configured-mapping failure, not a + * successful empty inventory. error_msg may be NULL on diagnostic OOM. */ +CBMFileResult *cbm_extract_file_ex_with_tests( + const char *source, int source_len, CBMLanguage language, const char *project, + const char *rel_path, int64_t timeout_micros, + const char **extra_defines, const char **include_paths, + const CBMMacroTable *macro_table, const CBMReturnTypeTable *return_type_table, + const cbm_test_declarations_t *test_declarations); + // Free all memory associated with a result. void cbm_free_result(CBMFileResult *result); diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 10a7a44e4..c1349c8a8 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -4,6 +4,8 @@ #include "lang_specs.h" #include "foundation/constants.h" #include "foundation/log.h" // cbm_log_error +#include "discover/test_conventions.h" +#include "foundation/sha256.h" #include "foundation/mem_core.h" // cbm_realloc/cbm_free -- walk_defs stack #include "extract_node_stack.h" #include "simhash/minhash.h" @@ -3790,10 +3792,742 @@ static char *resolve_cpp_test_macro_name(CBMArena *a, const char *macro, TSNode return NULL; } +/* Configured macro definitions are interpreted only in their raw source. + * The map lives in traversal scratch; its QNs live in the result arena. */ +struct CBMTestDefinitionMatch { + uint32_t name_byte; + TSNode name_node; /* same raw tree; never retained beyond extraction */ + TSNode scope_node; /* function, or split invocation's compound body */ + uint32_t line; + int declaration; + const char *qn; +}; + +typedef struct { + CBMTestDefinitionRole role; + int argument; + int declaration; + bool valid; +} TDXRule; + +static int tdx_language(const char *name) { + if (!name) return 0; + if (strcmp(name, "c") == 0 || strcmp(name, "C") == 0) return 1; + if (strcmp(name, "cpp") == 0 || strcmp(name, "c++") == 0 || + strcmp(name, "C++") == 0) return 2; + if (strcmp(name, "cuda") == 0 || strcmp(name, "CUDA") == 0) return 3; + return 0; +} + +static int tdx_source_language(CBMLanguage language) { + return language == CBM_LANG_C ? 1 : language == CBM_LANG_CPP ? 2 : + language == CBM_LANG_CUDA ? 3 : 0; +} + +static bool tdx_issue(CBMFileResult *result, CBMTestExtractStatus status, + int declaration, uint32_t line) { + if (result->test_declarations_status == CBM_TEST_EXTRACT_OOM) return false; + bool earlier = result->test_declarations_status == CBM_TEST_EXTRACT_OK || + status == CBM_TEST_EXTRACT_OOM || line < result->test_declaration_line || + (line == result->test_declaration_line && + (declaration < result->test_declaration_index || + (declaration == result->test_declaration_index && + status < result->test_declarations_status))); + if (!earlier) return false; + static const char *const messages[] = { + "", "unsupported configured definition language", + "unsupported disabled native test preset", "unsupported configured definition name", + "configured definition argument is missing", "configured definition argument is not an identifier", + "ambiguous configured definition mapping", "unsupported configured definition form", + "configured definition allocation failed" + }; + result->has_error = true; + result->test_declarations_status = status; + result->test_declaration_index = declaration; + result->test_declaration_line = line; + result->error_msg = cbm_arena_strdup(&result->arena, messages[status]); + if (!result->error_msg) result->test_declarations_status = CBM_TEST_EXTRACT_OOM; + return false; +} + +bool cbm_test_declarations_validate(CBMFileResult *result, + const cbm_test_declarations_t *declarations) { + if (!declarations) return true; + for (int i = 0; i < CBM_TEST_PRESET_COUNT; i++) { + bool enabled = false; + if (!cbm_test_declarations_preset(declarations, (cbm_test_preset_t)i, &enabled)) + return tdx_issue(result, CBM_TEST_EXTRACT_UNSUPPORTED_PRESET, -1, 0); + if (!enabled && i != CBM_TEST_PRESET_C_CBM && i != CBM_TEST_PRESET_GTEST) + tdx_issue(result, CBM_TEST_EXTRACT_UNSUPPORTED_PRESET, -1, 0); + } + int count = 0; + const cbm_test_declaration_t *items = cbm_test_declarations_items(declarations, &count); + for (int i = 0; i < count; i++) { + const cbm_test_declaration_t *rule = &items[i]; + if (rule->role != CBM_TEST_DECL_CASE && rule->role != CBM_TEST_DECL_SUITE) continue; + if (!tdx_language(rule->language)) + tdx_issue(result, CBM_TEST_EXTRACT_UNSUPPORTED_LANGUAGE, i, 0); + if (rule->name_arg_count != 1 || !rule->name_args || rule->name_args[0] < 0) + tdx_issue(result, CBM_TEST_EXTRACT_UNSUPPORTED_NAME, i, 0); + } + return result->test_declarations_status == CBM_TEST_EXTRACT_OK; +} + +static bool tdx_preset(const CBMExtractCtx *ctx, cbm_test_preset_t preset) { + if (!ctx->test_declarations) return preset != CBM_TEST_PRESET_C_CBM; + bool enabled = false; + return cbm_test_declarations_preset(ctx->test_declarations, preset, &enabled) && enabled; +} + +static bool tdx_equal(const char *name, const char *text, size_t size) { + return name && strlen(name) == size && memcmp(name, text, size) == 0; +} + +static bool tdx_match(CBMExtractCtx *ctx, const char *text, size_t size, + uint32_t line, TDXRule *chosen) { + *chosen = (TDXRule){.argument = -1, .declaration = -1}; + if (!ctx->test_declarations || !ctx->test_declarations_raw_source) return false; + int language = tdx_source_language(ctx->language), count = 0; + if (!language) return false; + const cbm_test_declaration_t *items = + cbm_test_declarations_items(ctx->test_declarations, &count); + bool matched = false, valid = true; + for (int i = 0; i < count; i++) { + const cbm_test_declaration_t *rule = &items[i]; + if ((rule->role != CBM_TEST_DECL_CASE && rule->role != CBM_TEST_DECL_SUITE) || + tdx_language(rule->language) != language || !tdx_equal(rule->define_macro, text, size)) + continue; + CBMTestDefinitionRole role = rule->role == CBM_TEST_DECL_CASE ? + CBM_TEST_ROLE_CASE : CBM_TEST_ROLE_SUITE; + if (rule->name_arg_count != 1 || !rule->name_args || rule->name_args[0] < 0) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_NAME, i, line); + valid = false; + matched = true; + continue; + } + if (matched && (chosen->role != role || chosen->argument != rule->name_args[0])) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_AMBIGUOUS, i, line); + valid = false; + } + if (!matched) *chosen = (TDXRule){role, rule->name_args[0], i, true}; + matched = true; + } + if (language == 1 && tdx_preset(ctx, CBM_TEST_PRESET_C_CBM)) { + CBMTestDefinitionRole role = tdx_equal("TEST", text, size) ? CBM_TEST_ROLE_CASE : + tdx_equal("SUITE", text, size) ? CBM_TEST_ROLE_SUITE : + CBM_TEST_ROLE_NONE; + if (role != CBM_TEST_ROLE_NONE) { + if (matched && (chosen->role != role || chosen->argument != 0)) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_AMBIGUOUS, -1, line); + valid = false; + } + if (!matched) *chosen = (TDXRule){role, 0, -1, true}; + matched = true; + } + } + /* The enabled native GTest detector consumes its own argument tuple. + * A custom single-name mapping must explicitly disable that overlap. */ + if (matched && language != 1 && tdx_preset(ctx, CBM_TEST_PRESET_GTEST) && + (tdx_equal("TEST", text, size) || tdx_equal("TEST_F", text, size) || + tdx_equal("TEST_P", text, size) || tdx_equal("TYPED_TEST", text, size) || + tdx_equal("TYPED_TEST_P", text, size))) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_AMBIGUOUS, -1, line); + valid = false; + } + chosen->valid = valid; + return matched; +} + +static bool tdx_ident_first(unsigned char c) { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} + +static bool tdx_ident(unsigned char c) { + return tdx_ident_first(c) || (c >= '0' && c <= '9'); +} + +/* C/C++ preprocessing-number maximal munch; no numeric evaluation. This + * protects digit separators and user-defined suffixes from quote/identifier + * scanning, while a selected numeric argument still fails the identifier rule. */ +static bool tdx_pp_number(const char *s, size_t n, size_t *at) { + size_t i = *at; + if (i == n || !((s[i] >= '0' && s[i] <= '9') || + (s[i] == '.' && i + 1 < n && s[i + 1] >= '0' && s[i + 1] <= '9'))) + return false; + i++; + while (i < n) { + unsigned char c = (unsigned char)s[i]; + if (tdx_ident(c) || c == '.') { i++; continue; } + if (c == '\'' && i + 1 < n && tdx_ident((unsigned char)s[i + 1])) { + i += 2; + continue; + } + if ((c == '+' || c == '-') && + (s[i - 1] == 'e' || s[i - 1] == 'E' || s[i - 1] == 'p' || s[i - 1] == 'P')) { + i++; + continue; + } + break; + } + *at = i; + return true; +} + +static bool tdx_space(unsigned char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v'; +} + +static bool tdx_splice(const char *s, size_t n, size_t at) { + return at < n && s[at] == '\\' && at + 1 < n && + (s[at + 1] == '\n' || (s[at + 1] == '\r' && at + 2 < n && s[at + 2] == '\n')); +} + +/* Trivia and literals are read as bytes, never as C expressions. Unsupported + * line splicing/raw strings are explicit uncertainty, not token guesses. */ +static bool tdx_trivia(const char *s, size_t n, size_t *at) { + for (;;) { + while (*at < n && tdx_space((unsigned char)s[*at])) (*at)++; + if (*at + 1 >= n || s[*at] != '/') return true; + if (s[*at + 1] == '/') { + *at += 2; + while (*at < n && s[*at] != '\n') { + if (tdx_splice(s, n, *at)) return false; + (*at)++; + } + } else if (s[*at + 1] == '*') { + *at += 2; + while (*at + 1 < n && !(s[*at] == '*' && s[*at + 1] == '/')) { + if (tdx_splice(s, n, *at)) return false; + (*at)++; + } + if (*at + 1 >= n) return false; + *at += 2; + } else return true; + } +} + +static bool tdx_literal(const char *s, size_t n, size_t *at) { + char quote = s[*at]; + if (quote == '"' && *at && s[*at - 1] == 'R') return false; + (*at)++; + while (*at < n) { + char c = s[*at]; + if (c == quote) { (*at)++; return true; } + if (c == '\0' || c == '\n' || c == '\r' || tdx_splice(s, n, *at)) return false; + if (c == '\\') { + if (*at + 1 >= n) return false; + *at += 2; + } else (*at)++; + } + return false; +} + +/* The audit skips complete raw strings outside consumed invocations. The + * invocation adapter remains deliberately stricter (tdx_literal above). */ +static bool tdx_audit_literal(const char *s, size_t n, size_t *at) { + if (s[*at] != '"' || !*at || s[*at - 1] != 'R') return tdx_literal(s, n, at); + size_t delimiter = *at + 1, open = delimiter; + while (open < n && s[open] != '(') { + unsigned char c = (unsigned char)s[open]; + if (open - delimiter == 16 || c <= ' ' || c == ')' || c == '\\' || c == 127) + return false; + open++; + } + if (open == n) return false; + size_t length = open - delimiter; + for (size_t end = open + 1; end < n; end++) { + if (s[end] == ')' && length + 2 <= n - end && + memcmp(s + end + 1, s + delimiter, length) == 0 && + s[end + length + 1] == '"') { + *at = end + length + 2; + return true; + } + } + return false; +} + +static bool tdx_argument(CBMExtractCtx *ctx, size_t at, int wanted, + size_t *name_at, size_t *name_length, size_t *after, + const TDXRule *rule, uint32_t line) { + const char *s = ctx->source; + size_t n = (size_t)ctx->source_len; + if (!tdx_trivia(s, n, &at) || at >= n || s[at] != '(') + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule->declaration, line); + char closing[64] = {')'}; + size_t depth = 1, begin = ++at, selected_begin = 0, selected_end = 0; + int argument = 0; + bool selected = false, closed = false; + while (at < n && depth) { + if (!tdx_trivia(s, n, &at) || at >= n || s[at] == '\0' || tdx_splice(s, n, at)) break; + if (tdx_pp_number(s, n, &at)) continue; + char c = s[at]; + if (c == '"' || c == '\'') { + if (!tdx_literal(s, n, &at)) break; + continue; + } + if (c == '(' || c == '[' || c == '{') { + if (depth == sizeof(closing)) break; + closing[depth++] = c == '(' ? ')' : c == '[' ? ']' : '}'; + } else if (c == ')' || c == ']' || c == '}') { + if (c != closing[depth - 1]) break; + if (--depth == 0) { + if (argument == wanted) { + selected_begin = begin; selected_end = at; selected = true; + } + *after = at + 1; + closed = true; + break; + } + } else if (c == ',' && depth == 1) { + if (argument == wanted) { + selected_begin = begin; selected_end = at; selected = true; + } + argument++; + begin = at + 1; + } + at++; + } + if (!closed) + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule->declaration, line); + if (!selected) + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_MISSING_ARGUMENT, rule->declaration, line); + at = selected_begin; + if (!tdx_trivia(s, selected_end, &at) || at == selected_end) + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_MISSING_ARGUMENT, rule->declaration, line); + if (!tdx_ident_first((unsigned char)s[at])) + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT, rule->declaration, line); + *name_at = at++; + while (at < selected_end && tdx_ident((unsigned char)s[at])) at++; + *name_length = at - *name_at; + if (!tdx_trivia(s, selected_end, &at) || at != selected_end) + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT, rule->declaration, line); + return true; +} + +/* The C grammar represents an unexpanded macro body in two additional + * ways: TYPE(identifier) { ... }, or a call statement with a missing semicolon + * followed by a sibling compound statement. Keep these candidates local to + * configured extraction; they never change the language's function kinds. */ +bool cbm_test_definition_candidate(TSNode scope, CBMLanguage language, TSNode *name, TSNode *body) { + *name = (TSNode){0}; + *body = (TSNode){0}; + if (ts_node_is_null(scope)) + return false; + const char *kind = ts_node_type(scope); + if (language == CBM_LANG_C && strcmp(kind, "compound_statement") == 0) { + TSNode previous = ts_node_prev_named_sibling(scope); + while (!ts_node_is_null(previous) && strcmp(ts_node_type(previous), "comment") == 0) + previous = ts_node_prev_named_sibling(previous); + if (ts_node_is_null(previous) || ts_node_named_child_count(previous) != 1) + return false; + const char *previous_kind = ts_node_type(previous); + bool error_wrapper = strcmp(previous_kind, "ERROR") == 0; + if (!error_wrapper && strcmp(previous_kind, "expression_statement") != 0) + return false; + TSNode call = ts_node_named_child(previous, 0); + if (strcmp(ts_node_type(call), "call_expression") != 0) + return false; + /* An empty invocation such as A(){} is an ERROR wrapping exactly the + * call. Recover only that byte-exact shape; the argument validator then + * reports MISSING_ARGUMENT for A, rather than the audit's generic form + * error. Never consume an error region with extra unparsed content. */ + if (error_wrapper && (ts_node_start_byte(previous) != ts_node_start_byte(call) || + ts_node_end_byte(previous) != ts_node_end_byte(call))) + return false; + TSNode callee = ts_node_child_by_field_name(call, TS_FIELD("function")); + if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "identifier") != 0) + return false; + *name = callee; + *body = scope; + return true; + } + TSNode inner = unwrap_template_inner(scope, language); + *body = ts_node_child_by_field_name(inner, TS_FIELD("body")); + if (language == CBM_LANG_C && strcmp(kind, "function_definition") == 0) { + TSNode type = ts_node_child_by_field_name(scope, TS_FIELD("type")); + TSNode declarator = ts_node_child_by_field_name(scope, TS_FIELD("declarator")); + if (!ts_node_is_null(type) && strcmp(ts_node_type(type), "type_identifier") == 0 && + !ts_node_is_null(declarator) && + strcmp(ts_node_type(declarator), "parenthesized_declarator") == 0) { + *name = type; + return true; + } + } + *name = cbm_resolve_func_name(scope, language); + return !ts_node_is_null(*name); +} + +static bool tdx_observe(CBMExtractCtx *ctx, TSNode name_node, TSNode scope_node, const char *qn, + const TDXRule *rule); + +static bool tdx_resolve(CBMExtractCtx *ctx, TSNode node, TSNode name_node, + const char *macro, const char **name, TDXRule *rule) { + *name = NULL; + uint32_t line = ts_node_start_point(name_node).row + 1; + if (!tdx_match(ctx, macro, strlen(macro), line, rule)) return false; + if (ctx->result->test_declarations_status == CBM_TEST_EXTRACT_OOM) return true; + if (!tdx_observe(ctx, name_node, node, NULL, rule) || !rule->valid) + return true; + uint32_t end = ts_node_end_byte(name_node); + size_t id_at = 0, id_length = 0, after = 0; + if (!ctx->source || ctx->source_len < 0 || end > (uint32_t)ctx->source_len) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule->declaration, line); + return true; + } + if (!tdx_argument(ctx, end, rule->argument, &id_at, &id_length, &after, rule, line)) return true; + TSNode candidate_name, body; + if (!cbm_test_definition_candidate(node, ctx->language, &candidate_name, &body) || + !ts_node_eq(candidate_name, name_node)) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule->declaration, line); + return true; + } + if (!tdx_trivia(ctx->source, (size_t)ctx->source_len, &after) || + after >= (size_t)ctx->source_len || ctx->source[after] != '{' || + ts_node_is_null(body) || ts_node_has_error(body) || ts_node_is_missing(body) || + ts_node_start_byte(body) != after || ts_node_end_byte(body) <= after || + ts_node_end_byte(body) > (uint32_t)ctx->source_len || + ctx->source[ts_node_end_byte(body) - 1] != '}') { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule->declaration, line); + return true; + } + *name = cbm_arena_sprintf(ctx->arena, "%s_%.*s", macro, (int)id_length, ctx->source + id_at); + if (!*name) tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, rule->declaration, line); + return true; +} + +/* NULL qn records a recognized attempt, not a graph definition. A later + * successful extraction fills its QN; the audit must not invent a second, + * synthetic error for an invocation whose local validation already failed. */ +static bool tdx_observe(CBMExtractCtx *ctx, TSNode name_node, TSNode scope_node, const char *qn, + const TDXRule *rule) { + uint32_t line = ts_node_start_point(name_node).row + 1; + int count = ctx->test_definition_match_count; + for (int i = 0; i < count; i++) { + struct CBMTestDefinitionMatch *match = &ctx->test_definition_matches[i]; + if (ts_node_eq(match->name_node, name_node)) { + if (qn) match->qn = qn; + return true; + } + } + if (count == ctx->test_definition_match_cap) { + int cap = ctx->test_definition_match_cap; + if (cap > INT_MAX / 2) + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, rule->declaration, line); + int next = cap ? cap * 2 : 16; + if ((size_t)next > SIZE_MAX / sizeof(struct CBMTestDefinitionMatch)) + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, rule->declaration, line); + CBMArena *scratch = ctx->scratch ? ctx->scratch : ctx->arena; + struct CBMTestDefinitionMatch *items = + cbm_arena_alloc(scratch, (size_t)next * sizeof(*items)); + if (!items) return tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, rule->declaration, line); + if (count) memcpy(items, ctx->test_definition_matches, (size_t)count * sizeof(*items)); + ctx->test_definition_matches = items; + ctx->test_definition_match_cap = next; + } + ctx->test_definition_matches[count] = (struct CBMTestDefinitionMatch){ + ts_node_start_byte(name_node), name_node, scope_node, line, rule->declaration, qn}; + ctx->test_definition_match_count++; + return true; +} + +const char *cbm_test_definition_qn(CBMExtractCtx *ctx, TSNode function) { + if (!ctx->test_declarations_raw_source || ctx->test_definition_match_count == 0 || + ts_node_is_null(function)) return NULL; + for (int i = 0; i < ctx->test_definition_match_count; i++) + if (ts_node_eq(ctx->test_definition_matches[i].scope_node, function)) + return ctx->test_definition_matches[i].qn; + return NULL; +} + +/* Bind the immutable owner rows to these exact raw bytes once per LSP pass. + * A local digest is identity evidence only, never artifact authentication. */ +bool cbm_test_definition_owners_match(const CBMFileResult *owners, const char *source, + int source_len, bool cpp_mode, const char *module_qn) { + if (!owners) return true; + if (owners->test_declarations_status != CBM_TEST_EXTRACT_OK) return false; + if (!owners->has_test_definition_owners) return true; + if (!source || source_len < 0 || source_len != owners->test_owner_source_len || + !module_qn || !owners->module_qn || strcmp(module_qn, owners->module_qn) != 0 || + !tdx_source_language(owners->test_owner_language) || + cpp_mode != (owners->test_owner_language != CBM_LANG_C)) return false; + char digest[65]; + cbm_sha256_hex(source, (size_t)source_len, digest); + return memcmp(digest, owners->test_owner_source_sha256, sizeof(digest)) == 0; +} + +/* The caller has already bound this result to source bytes and RAW origin. + * Exact name AND body spans distinguish nodes, including nested definitions; + * never infer an owner from a line window or a guessed macro name. */ +const char *cbm_test_definition_owner_qn(const CBMFileResult *owners, TSNode function) { + if (!owners || !owners->has_test_definition_owners || ts_node_is_null(function)) return NULL; + TSNode name, body; + if (!cbm_test_definition_candidate(function, owners->test_owner_language, &name, &body) || + ts_node_is_null(body)) + return NULL; + const char *found = NULL; + for (int i = 0; i < owners->defs.count; i++) { + const CBMDefinition *def = &owners->defs.items[i]; + if (def->test_role == CBM_TEST_ROLE_NONE || + def->test_name_start_byte != ts_node_start_byte(name) || + def->test_name_end_byte != ts_node_end_byte(name) || + def->test_body_start_byte != ts_node_start_byte(body) || + def->test_body_end_byte != ts_node_end_byte(body)) continue; + if (found) return NULL; /* duplicate rows are rejected by finish */ + found = def->qualified_name; + } + return found; +} + +/* Bound a directive only after complete comments/literals, not at a physical + * newline inside a block comment. The audit view already has line splices + * removed. This locates its extent; tdx_directive validates consumed content. */ +static bool tdx_directive_end(const char *s, size_t n, size_t *at, uint32_t *line) { + while (*at < n) { + if (s[*at] == '\0') return false; + if (s[*at] == '\n') { (*at)++; (*line)++; return true; } + if (*at + 1 < n && s[*at] == '/' && s[*at + 1] == '*') { + *at += 2; + while (*at + 1 < n && !(s[*at] == '*' && s[*at + 1] == '/')) { + if (s[*at] == '\0') return false; + if (s[*at] == '\n') (*line)++; + (*at)++; + } + if (*at + 1 >= n) return false; + *at += 2; + continue; + } + if (*at + 1 < n && s[*at] == '/' && s[*at + 1] == '/') { + *at += 2; + while (*at < n && s[*at] != '\n') { + if (s[*at] == '\0') return false; + (*at)++; + } + continue; + } + if (tdx_pp_number(s, n, at)) continue; + if (s[*at] == '"' || s[*at] == '\'') { + size_t before = *at; + if (!tdx_audit_literal(s, n, at)) { + /* Let the consumed-directive parser report malformed literal + * content. Do not reinterpret its comment punctuation here. */ + *at = before + 1; + while (*at < n && s[*at] != '\n') { + if (s[*at] == '\0') return false; + (*at)++; + } + } + for (size_t i = before; i < *at; i++) if (s[i] == '\n') (*line)++; + continue; + } + (*at)++; + } + return true; +} + +/* A replacement list referring to a configured definition is not a raw test + * declaration. Surface it as unsupported instead of certifying only the + * handwritten subset. The macro being defined is deliberately skipped. */ +static void tdx_directive(CBMExtractCtx *ctx, size_t begin, size_t end, uint32_t line) { + const char *s = ctx->source + begin; + end -= begin; + /* Joining physical directive lines only detects opaque generated tests; + * the joined bytes never become declaration or graph source evidence. */ + bool spliced = false; + for (size_t i = 0; i < end; i++) + if (tdx_splice(s, end, i)) { spliced = true; break; } + if (spliced) { + CBMArena *scratch = ctx->scratch ? ctx->scratch : ctx->arena; + char *joined = cbm_arena_alloc(scratch, end + 1); + if (!joined) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, -1, line); + return; + } + size_t used = 0; + for (size_t i = 0; i < end;) { + if (tdx_splice(s, end, i)) i += s[i + 1] == '\r' ? 3 : 2; + else joined[used++] = s[i++]; + } + joined[used] = '\0'; + s = joined; + end = used; + } + size_t at = 1; + if (!tdx_trivia(s, end, &at)) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, line); + return; + } + size_t word = at; + while (at < end && tdx_ident((unsigned char)s[at])) at++; + if (!tdx_equal("define", s + word, at - word)) return; + if (!tdx_trivia(s, end, &at) || at == end || !tdx_ident_first((unsigned char)s[at])) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, line); + return; + } + while (at < end && tdx_ident((unsigned char)s[at])) at++; + /* A function-like macro's formal parameters are not its replacement. */ + if (at < end && s[at] == '(') { + while (at < end && s[at] != ')') at++; + if (at == end) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, line); + return; + } + at++; + } + while (at < end) { + if (tdx_splice(s, end, at)) { at += s[at + 1] == '\r' ? 3 : 2; continue; } + if (!tdx_trivia(s, end, &at)) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, line); + return; + } + if (at == end) return; + if (tdx_pp_number(s, end, &at)) continue; + if (s[at] == '"' || s[at] == '\'') { + if (!tdx_audit_literal(s, end, &at)) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, line); + return; + } + continue; + } + if (!tdx_ident_first((unsigned char)s[at])) { at++; continue; } + word = at++; + while (at < end && tdx_ident((unsigned char)s[at])) at++; + TDXRule rule; + if (tdx_match(ctx, s + word, at - word, line, &rule)) + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule.declaration, line); + } +} + +void cbm_test_declarations_finish(CBMExtractCtx *ctx) { + if (!ctx->test_declarations || !ctx->test_declarations_raw_source || + ctx->result->test_declarations_status == CBM_TEST_EXTRACT_OOM) return; + int language = tdx_source_language(ctx->language), count = 0; + if (!language) return; + const cbm_test_declaration_t *items = + cbm_test_declarations_items(ctx->test_declarations, &count); + bool active = language == 1 && tdx_preset(ctx, CBM_TEST_PRESET_C_CBM); + for (int i = 0; i < count && !active; i++) + active = (items[i].role == CBM_TEST_DECL_CASE || items[i].role == CBM_TEST_DECL_SUITE) && + tdx_language(items[i].language) == language; + if (!active) return; + /* Detect configured invocations dropped by a grammar/error region. Never + * invent definitions here; an unmatched raw invocation is uncertainty. + * Normalize line splices for AUDIT ONLY, so a split macro cannot disappear. + * If that view contains a consumed invocation, decline the file; do not + * use normalized offsets as raw graph/owner evidence. A complete audit of + * unrelated code succeeds; an incomplete applicable audit is uncertainty + * even if no configured invocation was reached before it stopped. */ + const char *s = ctx->source; + size_t n = ctx->source_len > 0 ? (size_t)ctx->source_len : 0, at = 0; + uint32_t splice_line = 0, physical_line = 1; + for (size_t i = 0; i < n; i++) { + if (!splice_line && tdx_splice(s, n, i)) splice_line = physical_line; + if (s[i] == '\n') physical_line++; + } + if (splice_line) { + CBMArena *scratch = ctx->scratch ? ctx->scratch : ctx->arena; + char *joined = cbm_arena_alloc(scratch, n + 1); + if (!joined) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, -1, splice_line); + return; + } + size_t used = 0; + for (size_t i = 0; i < n;) { + if (tdx_splice(s, n, i)) i += s[i + 1] == '\r' ? 3 : 2; + else joined[used++] = s[i++]; + } + joined[used] = '\0'; + s = joined; + n = used; + } + CBMExtractCtx audit = *ctx; + audit.source = s; + audit.source_len = (int)n; + uint32_t line = 1, uncertain_line = 0; + bool line_start = true; + while (at < n) { + size_t before = at; + if (!tdx_trivia(s, n, &at)) { uncertain_line = line; break; } + for (size_t i = before; i < at; i++) if (s[i] == '\n') { line++; line_start = true; } + if (at == n) break; + if (s[at] == '\0') { uncertain_line = line; break; } + if (line_start && s[at] == '#') { + size_t directive_begin = at; + uint32_t directive_line = splice_line ? splice_line : line; + if (!tdx_directive_end(s, n, &at, &line)) { + uncertain_line = directive_line; + break; + } + tdx_directive(&audit, directive_begin, at, directive_line); + line_start = true; + continue; + } + line_start = false; + if (tdx_pp_number(s, n, &at)) continue; + if (s[at] == '"' || s[at] == '\'') { + before = at; + if (!tdx_audit_literal(s, n, &at)) { uncertain_line = line; break; } + for (size_t i = before; i < at; i++) if (s[i] == '\n') line++; + continue; + } + if (!tdx_ident_first((unsigned char)s[at])) { at++; continue; } + size_t begin = at++; + while (at < n && tdx_ident((unsigned char)s[at])) at++; + size_t after = at; + if (!tdx_trivia(s, n, &after)) { uncertain_line = line; break; } + if (after == n || s[after] != '(') continue; + TDXRule rule; + uint32_t issue_line = splice_line ? splice_line : line; + if (!tdx_match(ctx, s + begin, at - begin, issue_line, &rule)) continue; + bool found = false; + if (!splice_line) { + for (int i = 0; i < ctx->test_definition_match_count; i++) + if (ctx->test_definition_matches[i].name_byte == begin) { found = true; break; } + } + if (!found) + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule.declaration, issue_line); + } + if (uncertain_line) + tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, + splice_line ? splice_line : uncertain_line); + /* A configured QN collision is explicit rather than a later upsert loss. */ + for (int i = 0; i < ctx->test_definition_match_count; i++) { + const struct CBMTestDefinitionMatch *match = &ctx->test_definition_matches[i]; + if (!match->qn) continue; /* failed invocation; diagnostic already retained */ + int found = 0; + for (int j = 0; j < ctx->result->defs.count; j++) { + const char *qn = ctx->result->defs.items[j].qualified_name; + if (qn && strcmp(qn, match->qn) == 0) found++; + } + if (found != 1) + tdx_issue(ctx->result, CBM_TEST_EXTRACT_AMBIGUOUS, match->declaration, match->line); + } + if (ctx->test_definition_match_count && + ctx->result->test_declarations_status == CBM_TEST_EXTRACT_OK) { + ctx->result->has_test_definition_owners = true; + ctx->result->test_owner_source_len = ctx->source_len; + ctx->result->test_owner_language = ctx->language; + cbm_sha256_hex(ctx->source, (size_t)ctx->source_len, + ctx->result->test_owner_source_sha256); + } +} + static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { CBMArena *a = ctx->arena; TSNode name_node = cbm_resolve_func_name(node, ctx->language); + if (ctx->language == CBM_LANG_C && ctx->test_declarations && + ctx->test_declarations_raw_source) { + TSNode candidate_name, candidate_body; + if (cbm_test_definition_candidate(node, ctx->language, &candidate_name, &candidate_body)) { + uint32_t begin = ts_node_start_byte(candidate_name); + uint32_t end = ts_node_end_byte(candidate_name); + TDXRule rule; + if (end > begin && end <= (uint32_t)ctx->source_len && + tdx_match(ctx, ctx->source + begin, end - begin, + ts_node_start_point(candidate_name).row + 1, &rule)) + name_node = candidate_name; + } + } if (ts_node_is_null(name_node)) { return; } @@ -3816,7 +4550,15 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * Multiple test cases per file collide on qualified name; derive a unique * name from the macro arguments so each gets its own graph node (#1266). */ bool is_gtest = false; - if ((ctx->language == CBM_LANG_CPP || ctx->language == CBM_LANG_CUDA) && + const char *configured_name = NULL; + TDXRule configured_rule; + bool configured = tdx_resolve(ctx, node, name_node, name, &configured_name, &configured_rule); + if (configured) { + if (!configured_name) return; + name = (char *)configured_name; + } + if (!configured && tdx_preset(ctx, CBM_TEST_PRESET_GTEST) && + (ctx->language == CBM_LANG_CPP || ctx->language == CBM_LANG_CUDA) && is_cpp_test_macro(name)) { char *gtest_name = resolve_cpp_test_macro_name(a, name, node, ctx->source); if (gtest_name) { @@ -3839,6 +4581,15 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec memset(&def, 0, sizeof(def)); def.name = name; + def.test_role = configured ? configured_rule.role : CBM_TEST_ROLE_NONE; + if (configured) { + TSNode candidate_name, body; + (void)cbm_test_definition_candidate(node, ctx->language, &candidate_name, &body); + def.test_name_start_byte = ts_node_start_byte(name_node); + def.test_name_end_byte = ts_node_end_byte(name_node); + def.test_body_start_byte = ts_node_start_byte(body); + def.test_body_end_byte = ts_node_end_byte(body); + } /* Nix: a binding's name is a path. The leaf is the name; the leading segments * are scope, so `a.b.fn = …` gets the same QN as `a = { b = { fn = …; }; }`. * Without this every binding whose path shares a leaf name collapsed onto one @@ -3876,7 +4627,9 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } def.label = "Function"; def.file_path = ctx->rel_path; - def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; + def.start_line = + ts_node_start_point(configured && ctx->language == CBM_LANG_C ? name_node : node).row + + TS_LINE_OFFSET; def.end_line = ts_node_end_point(node).row + TS_LINE_OFFSET; def.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET); def.is_exported = cbm_is_exported(name, ctx->language); @@ -3886,7 +4639,9 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } // Parameters — use func_node (inner function for templates) - TSNode params = find_function_params(func_node, ctx->language); + TSNode params = configured && ctx->language == CBM_LANG_C + ? (TSNode){0} + : find_function_params(func_node, ctx->language); if (!ts_node_is_null(params)) { def.signature = cbm_node_text(a, params, ctx->source); def.param_names = extract_param_names(a, params, ctx->source, ctx->language); @@ -3897,7 +4652,7 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec // Return type — use func_node (inner function for templates) static const char *rt_fields[] = {"result", "return_type", "type", NULL}; - for (const char **f = rt_fields; *f; f++) { + for (const char **f = rt_fields; *f && !(configured && ctx->language == CBM_LANG_C); f++) { TSNode rt = ts_node_child_by_field_name(func_node, *f, (uint32_t)strlen(*f)); if (!ts_node_is_null(rt)) { def.return_type = c_declared_return_type(ctx, func_node, rt); @@ -3980,7 +4735,7 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } // C++/CUDA: GoogleTest macros are test functions (#1266). - if (is_gtest) { + if (is_gtest || def.test_role == CBM_TEST_ROLE_CASE) { def.is_test = true; } @@ -4016,6 +4771,15 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec def.is_entry_point = true; } + if (configured) { + if (!def.qualified_name) { + tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, configured_rule.declaration, + ts_node_start_point(name_node).row + 1); + return; + } + if (!tdx_observe(ctx, name_node, node, def.qualified_name, &configured_rule)) + return; + } cbm_defs_push(&ctx->result->defs, a, def); } @@ -8414,6 +9178,14 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, continue; } + /* A split C invocation's body is its scope node. Candidate matching + * and raw argument/body validation remain inside extract_func_def. */ + bool split_c_candidate = ctx->language == CBM_LANG_C && ctx->test_declarations && + ctx->test_declarations_raw_source && + strcmp(kind, "compound_statement") == 0; + if (split_c_candidate) { + extract_func_def(ctx, node, spec); + } if (cbm_kind_in_set(node, spec->function_node_types)) { if (!is_template_class_node(node, ctx->language)) { extract_func_def(ctx, node, spec); @@ -8432,6 +9204,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, // lambdas (`f = a: b: ...`) resolve no name and mint nothing, so the // extra descent adds defs without adding noise. bool descend_into_func = + (ctx->language == CBM_LANG_C && cbm_test_definition_qn(ctx, node) != NULL) || (ctx->language == CBM_LANG_WOLFRAM || ctx->language == CBM_LANG_TYPESCRIPT || ctx->language == CBM_LANG_JAVASCRIPT || ctx->language == CBM_LANG_TSX || ctx->language == CBM_LANG_ARKTS || ctx->language == CBM_LANG_ADA || diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 086f501a0..f7f5e2d4f 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -918,6 +918,13 @@ static const char *objectscript_get_method_qn(CBMExtractCtx *ctx, TSNode node, // Compute function QN for scope tracking (mirrors cbm_enclosing_func_qn logic). static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, WalkState *state) { + /* Raw configured definitions already own the canonical QN, including + * namespace scope. Never rederive it from the bare macro identifier. */ + const char *configured_qn = cbm_test_definition_qn(ctx, node); + if (configured_qn) { + return configured_qn; + } + (void)spec; if (ctx->language == CBM_LANG_WOLFRAM) { return compute_wolfram_func_qn(ctx, node); @@ -2370,7 +2377,12 @@ static void push_lexical_boundary(TSNode node, WalkState *state, uint32_t depth) static void push_boundary_scopes(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, WalkState *state, uint32_t depth, const CBMInvocationDescriptor *invocation) { - if (spec->function_node_types && cbm_kind_in_set(node, spec->function_node_types)) { + const char *configured_body_qn = NULL; + if (ctx->language == CBM_LANG_C && strcmp(ts_node_type(node), "compound_statement") == 0) + configured_body_qn = cbm_test_definition_qn(ctx, node); + if (configured_body_qn) { + (void)push_function_scope(state, depth, configured_body_qn, node); + } else if (spec->function_node_types && cbm_kind_in_set(node, spec->function_node_types)) { /* OCaml: a nested local `let x = e in ...` is itself a value_definition, * but the def walk does not descend into function bodies, so it emits no * node for it. Pushing a func scope here would attribute in-body calls to diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 4c4d340f0..055a4cca9 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1300,6 +1300,18 @@ const char *cbm_enclosing_func_qn(CBMArena *a, TSNode node, CBMLanguage lang, co // --- Cached enclosing function QN --- const char *cbm_enclosing_func_qn_cached(CBMExtractCtx *ctx, TSNode node) { + if (ctx->test_definition_match_count > 0) { + const char **function_kinds = func_kinds_for_lang(ctx->language); + for (TSNode current = node; !ts_node_is_null(current); current = ts_node_parent(current)) { + const char *configured_qn = cbm_test_definition_qn(ctx, current); + if (configured_qn) + return configured_qn; + /* A nested native function owns its own body; do not inherit a + * surrounding configured macro's QN through that boundary. */ + if (cbm_kind_in_set(current, function_kinds)) + break; + } + } uint32_t pos = ts_node_start_byte(node); // Check cache: find a function range that contains this position. diff --git a/internal/cbm/lsp/c_lsp.c b/internal/cbm/lsp/c_lsp.c index 32e813dc0..e7c9883d9 100644 --- a/internal/cbm/lsp/c_lsp.c +++ b/internal/cbm/lsp/c_lsp.c @@ -21,6 +21,7 @@ static const CBMType *c_lookup_field_type(CLSPContext *ctx, const char *type_qn, const char *field_name, int depth); static const CBMRegisteredType *c_resolve_field_owner_type(CLSPContext *ctx, const char *type_qn); static void c_process_function(CLSPContext *ctx, TSNode func_node); +static bool c_process_configured_test_body(CLSPContext *ctx, TSNode scope); static void c_process_namespace(CLSPContext *ctx, TSNode ns_node); static void c_process_class(CLSPContext *ctx, TSNode class_node); static void c_process_body_child(CLSPContext *ctx, TSNode child); @@ -3957,6 +3958,26 @@ static bool c_is_compound_assignment_operator(const char *operator_token) { strcmp(operator_token, "|=") == 0 || strcmp(operator_token, "^=") == 0); } +/* The additive owner APIs publish rows that outlive their input owner, + * definition and registry arenas. A registry entry may borrow a caller's QN, + * so copy the published strings at the common emission boundary. NULL-owner + * legacy calls retain their existing allocation and borrowing behavior. */ +static void c_publish_resolved_call(CLSPContext *ctx, CBMResolvedCall call) { + if (ctx->test_definition_owners) { + const char **strings[] = {&call.caller_qn, &call.callee_qn, &call.strategy, &call.reason}; + for (size_t i = 0; i < sizeof(strings) / sizeof(strings[0]); i++) { + if (!*strings[i]) + continue; + *strings[i] = cbm_arena_strdup(ctx->arena, *strings[i]); + if (!*strings[i]) { + ctx->test_owner_error = true; + return; /* No row containing a borrowed or failed string escapes. */ + } + } + } + cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, call); +} + static void c_emit_resolved_call_orig_at(CLSPContext *ctx, const char *callee_qn, const char *orig, const char *strategy, float confidence, TSNode site) { if (!ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn) @@ -3978,7 +3999,7 @@ static void c_emit_resolved_call_orig_at(CLSPContext *ctx, const char *callee_qn rc.site_start_byte = ts_node_start_byte(site); rc.site_end_byte = ts_node_end_byte(site); } - cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc); + c_publish_resolved_call(ctx, rc); } static void c_emit_resolved_call_orig(CLSPContext *ctx, const char *callee_qn, const char *orig, @@ -4014,7 +4035,7 @@ static void c_emit_resolved_reference_at(CLSPContext *ctx, const char *callee_qn resolved.site_start_byte = ts_node_start_byte(site); resolved.site_end_byte = ts_node_end_byte(site); resolved.source_origin = ctx->source_origin; - cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, resolved); + c_publish_resolved_call(ctx, resolved); } // FNV-1a over (caller, type, field) with 0xff separators; 0 is remapped to 1 @@ -4104,7 +4125,7 @@ static void c_publish_field_owner(CLSPContext *ctx, const char *type_qn, const c 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); + c_publish_resolved_call(ctx, resolved); } /* ── Designated initializers ───────────────────────────────────────── @@ -4276,7 +4297,7 @@ static void c_emit_unresolved_call(CLSPContext *ctx, const char *expr_text, cons rc.confidence = 0.0f; rc.reason = reason; rc.source_origin = ctx->source_origin; - cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc); + c_publish_resolved_call(ctx, rc); } // ============================================================================ @@ -4305,6 +4326,18 @@ static void c_resolve_calls_in_node_inner(CLSPContext *ctx, TSNode node) { return; const char *kind = ts_node_type(node); + if (ctx->test_definition_owners && ctx->test_definition_owners->has_test_definition_owners && + ctx->test_definition_owners->test_owner_language == CBM_LANG_C) { + if (c_process_configured_test_body(ctx, node)) + return; + /* Nested native functions are independent owners even when they occur + * inside the raw body of a configured C definition. */ + if (strcmp(kind, "function_definition") == 0) { + c_process_function(ctx, node); + return; + } + } + // Process statements for scope building c_process_statement(ctx, node); @@ -5164,7 +5197,62 @@ recurse:; // Process function: set enclosing QN, bind params, walk body // ============================================================================ +/* The source digest and RAW origin have already been checked by the entry + * point, which reparses the source. The shared candidate then proves exact + * name/body spans against an owner row in that fresh tree. Macro arguments are + * not native function parameters: enter the proved body directly. */ +static bool c_process_configured_test_body(CLSPContext *ctx, TSNode scope) { + if (!ctx->test_definition_owners || + ctx->test_definition_owners->test_owner_language != CBM_LANG_C) + return false; + const char *kind = ts_node_type(scope); + if (strcmp(kind, "function_definition") != 0 && strcmp(kind, "compound_statement") != 0) + return false; + const char *qn = cbm_test_definition_owner_qn(ctx->test_definition_owners, scope); + if (!qn) + return false; + TSNode name, body; + if (!cbm_test_definition_candidate(scope, ctx->test_definition_owners->test_owner_language, + &name, &body) || + ts_node_is_null(body)) { + ctx->test_owner_error = true; + return true; + } + ctx->test_owner_seen_count++; + if (ctx->arena != &ctx->test_definition_owners->arena) { + qn = cbm_arena_strdup(ctx->arena, qn); + if (!qn) { + ctx->test_owner_error = true; + return true; + } + } + const char *saved_func = ctx->enclosing_func_qn; + CBMScope *saved_scope = ctx->current_scope; + int saved_fp_count = ctx->fp_count; + ctx->enclosing_func_qn = qn; + ctx->current_scope = cbm_scope_push(ctx->arena, saved_scope); + if (!ctx->current_scope) { + ctx->test_owner_error = true; + } else { + /* The compound node itself may be the owner scope. Walk its children + * once so entering it cannot recursively re-enter the same owner. */ + TSTreeCursor cursor = ts_tree_cursor_new(body); + if (ts_tree_cursor_goto_first_child(&cursor)) { + do { + c_resolve_calls_in_node(ctx, ts_tree_cursor_current_node(&cursor)); + } while (ts_tree_cursor_goto_next_sibling(&cursor)); + } + ts_tree_cursor_delete(&cursor); + } + ctx->fp_count = saved_fp_count; + ctx->current_scope = saved_scope; + ctx->enclosing_func_qn = saved_func; + return true; +} + static void c_process_function(CLSPContext *ctx, TSNode func_node) { + if (c_process_configured_test_body(ctx, func_node)) + return; TSNode decl = ts_node_child_by_field_name(func_node, "declarator", 10); if (ts_node_is_null(decl)) return; @@ -5235,43 +5323,57 @@ static void c_process_function(CLSPContext *ctx, TSNode func_node) { if (!func_name || !func_name[0]) return; - // Build enclosing function QN - const char *func_qn = c_build_qn(ctx, func_name); - // For a method defined INLINE inside its class body, func_name is a bare - // identifier ("compute") and enclosing_class_qn was inherited from - // c_process_class (saved_class_qn == enclosing_class_qn). The textual - // extractor and the registry qualify the method as module.Class.method, so - // building func_qn as module.method here (no class) made the LSP-resolved - // call's caller_qn disagree with the textual call's enclosing_func_qn and - // cbm_pipeline_find_lsp_resolution never joined them — every in-method call - // (e.g. lsp_implicit_this) silently lost its type-aware strategy. Prepend - // the enclosing class, mirroring the Go receiver-QN fix. Out-of-line - // definitions (Widget::compute) already carry the class in func_name (a - // qualified_identifier), so c_build_qn produces module.Class.method and the - // enclosing_class_qn was set HERE (saved_class_qn != enclosing_class_qn); - // skip those, and skip names that already contain the class scope. - if (ctx->enclosing_class_qn && saved_class_qn == ctx->enclosing_class_qn && - !strchr(func_qn, '.')) { - func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->enclosing_class_qn, func_qn); - } else if (ctx->enclosing_class_qn && saved_class_qn != ctx->enclosing_class_qn && - strchr(func_qn, '.')) { - /* Out-of-line method `Class::method`: c_build_qn yields the bare - * "Class.method" (no module) — the class scope was resolved HERE to the - * full module-qualified class QN (saved_class_qn != enclosing_class_qn). - * Rebuild as . so the caller_qn matches the - * def walk and call-scope QN, which qualify out-of-line methods the same - * way. Without this the caller_qn stays "Class.method", the exact-equality - * lsp_resolve join misses, and the LSP rescue is discarded (gap #5a). */ - const char *dot = strrchr(func_qn, '.'); - func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->enclosing_class_qn, dot + 1); - } else if (!strchr(func_qn, '.')) { - /* A free function in a namespace is qualified by the namespace scope - * (current_namespace is module_qn.ns), matching the def QN the extractor - * now produces; outside any namespace this falls back to the file module - * so non-namespaced free functions are unchanged. */ - const char *scope = ctx->current_namespace ? ctx->current_namespace : ctx->module_qn; - if (scope) { - func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", scope, func_qn); + const char *func_qn = cbm_test_definition_owner_qn(ctx->test_definition_owners, func_node); + if (func_qn) { + ctx->test_owner_seen_count++; + if (ctx->arena != &ctx->test_definition_owners->arena) { + func_qn = cbm_arena_strdup(ctx->arena, func_qn); + if (!func_qn) { + ctx->test_owner_error = true; + ctx->enclosing_class_qn = saved_class_qn; + return; + } + } + } + if (!func_qn) { + // Build enclosing function QN + func_qn = c_build_qn(ctx, func_name); + // For a method defined INLINE inside its class body, func_name is a bare + // identifier ("compute") and enclosing_class_qn was inherited from + // c_process_class (saved_class_qn == enclosing_class_qn). The textual + // extractor and the registry qualify the method as module.Class.method, so + // building func_qn as module.method here (no class) made the LSP-resolved + // call's caller_qn disagree with the textual call's enclosing_func_qn and + // cbm_pipeline_find_lsp_resolution never joined them — every in-method call + // (e.g. lsp_implicit_this) silently lost its type-aware strategy. Prepend + // the enclosing class, mirroring the Go receiver-QN fix. Out-of-line + // definitions (Widget::compute) already carry the class in func_name (a + // qualified_identifier), so c_build_qn produces module.Class.method and the + // enclosing_class_qn was set HERE (saved_class_qn != enclosing_class_qn); + // skip those, and skip names that already contain the class scope. + if (ctx->enclosing_class_qn && saved_class_qn == ctx->enclosing_class_qn && + !strchr(func_qn, '.')) { + func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->enclosing_class_qn, func_qn); + } else if (ctx->enclosing_class_qn && saved_class_qn != ctx->enclosing_class_qn && + strchr(func_qn, '.')) { + /* Out-of-line method `Class::method`: c_build_qn yields the bare + * "Class.method" (no module) — the class scope was resolved HERE to the + * full module-qualified class QN (saved_class_qn != enclosing_class_qn). + * Rebuild as . so the caller_qn matches the + * def walk and call-scope QN, which qualify out-of-line methods the same + * way. Without this the caller_qn stays "Class.method", the exact-equality + * lsp_resolve join misses, and the LSP rescue is discarded (gap #5a). */ + const char *dot = strrchr(func_qn, '.'); + func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->enclosing_class_qn, dot + 1); + } else if (!strchr(func_qn, '.')) { + /* A free function in a namespace is qualified by the namespace scope + * (current_namespace is module_qn.ns), matching the def QN the extractor + * now produces; outside any namespace this falls back to the file module + * so non-namespaced free functions are unchanged. */ + const char *scope = ctx->current_namespace ? ctx->current_namespace : ctx->module_qn; + if (scope) { + func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", scope, func_qn); + } } } ctx->enclosing_func_qn = func_qn; @@ -5368,6 +5470,8 @@ static void c_process_body_child(CLSPContext *ctx, TSNode child) { return; const char *ck = ts_node_type(child); + if (c_process_configured_test_body(ctx, child)) + return; if (strcmp(ck, "function_definition") == 0) { c_process_function(ctx, child); } else if (strcmp(ck, "namespace_definition") == 0) { @@ -6165,8 +6269,50 @@ static void c_rewrite_proved_destructor_candidates(CBMFileResult *result) { } } +/* Every configured owner must have been visited in this exact raw tree. + * A stale/mismatched cached tree cannot quietly fall back to bare macro QNs. */ +static bool c_test_owners_complete(const CLSPContext *ctx) { + if (ctx->test_owner_error) return false; + if (!ctx->test_definition_owners || + !ctx->test_definition_owners->has_test_definition_owners) return true; + int expected = 0; + for (int i = 0; i < ctx->test_definition_owners->defs.count; i++) + if (ctx->test_definition_owners->defs.items[i].test_role != CBM_TEST_ROLE_NONE) expected++; + return expected == ctx->test_owner_seen_count; +} + void cbm_run_c_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, TSNode root, bool cpp_mode, CBMSourceOrigin source_origin) { + (void)cbm_run_c_lsp_with_test_owners(arena, result, source, source_len, root, + cpp_mode, source_origin, NULL); +} + +bool cbm_run_c_lsp_with_test_owners( + CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root, bool cpp_mode, CBMSourceOrigin source_origin, + const CBMFileResult *test_owners) { + if ((test_owners && test_owners->has_test_definition_owners && + source_origin != CBM_SOURCE_ORIGIN_RAW) || + !cbm_test_definition_owners_match(test_owners, source, source_len, cpp_mode, + result->module_qn)) return false; + + /* TSNode carries no authenticated source-byte provenance. For configured + * owners, parse the verified buffer here rather than trusting a supplied + * root whose numeric spans could coincide with a different expression. */ + TSTree *owner_tree = NULL; + if (test_owners && test_owners->has_test_definition_owners) { + TSParser *parser = ts_parser_new(); + if (!parser) return false; + const TSLanguage *ts_lang = cpp_mode ? tree_sitter_cpp() : tree_sitter_c(); + if (!ts_parser_set_language(parser, ts_lang)) { + ts_parser_delete(parser); + return false; + } + owner_tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)source_len); + ts_parser_delete(parser); + if (!owner_tree) return false; + root = ts_tree_root_node(owner_tree); + } CBMTypeRegistry reg; cbm_registry_init(®, arena); @@ -6309,9 +6455,13 @@ void cbm_run_c_lsp(CBMArena *arena, CBMFileResult *result, const char *source, i CLSPContext ctx; c_lsp_init(&ctx, arena, source, source_len, ®, module_qn, cpp_mode, &result->resolved_calls); ctx.source_origin = source_origin; + ctx.test_definition_owners = test_owners; c_lsp_process_file(&ctx, root); c_rewrite_proved_destructor_candidates(result); + bool owners_complete = c_test_owners_complete(&ctx); + if (owner_tree) ts_tree_delete(owner_tree); + return owners_complete; } // ============================================================================ @@ -6489,28 +6639,48 @@ void cbm_run_c_lsp_cross_with_registry(CBMArena *arena, const char *source, int const char **include_paths, const char **include_ns_qns, int include_count, TSTree *cached_tree, CBMResolvedCallArray *out) { + (void)cbm_run_c_lsp_cross_with_registry_with_test_owners( + arena, source, source_len, module_qn, cpp_mode, reg, include_paths, + include_ns_qns, include_count, cached_tree, out, CBM_SOURCE_ORIGIN_RAW, NULL); +} + +bool cbm_run_c_lsp_cross_with_registry_with_test_owners(CBMArena *arena, const char *source, int source_len, + const char *module_qn, bool cpp_mode, CBMTypeRegistry *reg, + const char **include_paths, const char **include_ns_qns, + int include_count, TSTree *cached_tree, + CBMResolvedCallArray *out, CBMSourceOrigin source_origin, + const CBMFileResult *test_owners) { + if ((test_owners && test_owners->has_test_definition_owners && + source_origin != CBM_SOURCE_ORIGIN_RAW) || + !cbm_test_definition_owners_match(test_owners, source, source_len, cpp_mode, + module_qn)) return false; + if (!source || source_len == 0 || !out || !reg) - return; + return false; TSParser *parser = NULL; - TSTree *tree = cached_tree; + /* No source provenance is carried by cached_tree. Preserve the legacy + * cache fast path only when no configured owner proof is being used. */ + TSTree *tree = test_owners && test_owners->has_test_definition_owners ? NULL : cached_tree; bool owns_tree = false; if (!tree) { parser = ts_parser_new(); if (!parser) - return; + return false; const TSLanguage *ts_lang = cpp_mode ? tree_sitter_cpp() : tree_sitter_c(); ts_parser_set_language(parser, ts_lang); tree = ts_parser_parse_string(parser, NULL, source, source_len); ts_parser_delete(parser); owns_tree = true; if (!tree) - return; + return false; } TSNode root = ts_tree_root_node(tree); CLSPContext ctx; c_lsp_init(&ctx, arena, source, source_len, reg, module_qn, cpp_mode, out); + ctx.source_origin = source_origin; + ctx.test_definition_owners = test_owners; ctx.registry_shared = true; /* Tier-2 shared registry: read-only, see flag doc */ for (int i = 0; i < include_count; i++) { c_lsp_add_include(&ctx, include_paths[i], include_ns_qns[i]); @@ -6520,15 +6690,31 @@ void cbm_run_c_lsp_cross_with_registry(CBMArena *arena, const char *source, int if (owns_tree) { ts_tree_delete(tree); } + return c_test_owners_complete(&ctx); } void cbm_run_c_lsp_cross(CBMArena *arena, const char *source, int source_len, const char *module_qn, bool cpp_mode, CBMLSPDef *defs, int def_count, const char **include_paths, const char **include_ns_qns, int include_count, TSTree *cached_tree, CBMResolvedCallArray *out) { + (void)cbm_run_c_lsp_cross_with_test_owners( + arena, source, source_len, module_qn, cpp_mode, defs, def_count, include_paths, + include_ns_qns, include_count, cached_tree, out, CBM_SOURCE_ORIGIN_RAW, NULL); +} + +bool cbm_run_c_lsp_cross_with_test_owners(CBMArena *arena, const char *source, int source_len, const char *module_qn, + bool cpp_mode, CBMLSPDef *defs, int def_count, const char **include_paths, + const char **include_ns_qns, int include_count, TSTree *cached_tree, + CBMResolvedCallArray *out, CBMSourceOrigin source_origin, + const CBMFileResult *test_owners) { + if ((test_owners && test_owners->has_test_definition_owners && + source_origin != CBM_SOURCE_ORIGIN_RAW) || + !cbm_test_definition_owners_match(test_owners, source, source_len, cpp_mode, + module_qn)) return false; + if (!source || source_len == 0 || !out) - return; + return false; CBMTypeRegistry reg; cbm_registry_init(®, arena); @@ -6543,19 +6729,21 @@ void cbm_run_c_lsp_cross(CBMArena *arena, const char *source, int source_len, co // Use cached tree if available, otherwise parse fresh TSParser *parser = NULL; - TSTree *tree = cached_tree; + /* No source provenance is carried by cached_tree. Preserve the legacy + * cache fast path only when no configured owner proof is being used. */ + TSTree *tree = test_owners && test_owners->has_test_definition_owners ? NULL : cached_tree; bool owns_tree = false; if (!tree) { parser = ts_parser_new(); if (!parser) - return; + return false; const TSLanguage *ts_lang = cpp_mode ? tree_sitter_cpp() : tree_sitter_c(); ts_parser_set_language(parser, ts_lang); tree = ts_parser_parse_string(parser, NULL, source, source_len); ts_parser_delete(parser); owns_tree = true; if (!tree) - return; + return false; } TSNode root = ts_tree_root_node(tree); @@ -6568,6 +6756,8 @@ void cbm_run_c_lsp_cross(CBMArena *arena, const char *source, int source_len, co // Initialize context and run CLSPContext ctx; c_lsp_init(&ctx, arena, source, source_len, ®, module_qn, cpp_mode, out); + ctx.source_origin = source_origin; + ctx.test_definition_owners = test_owners; // Add include mappings for (int i = 0; i < include_count; i++) { @@ -6579,6 +6769,7 @@ void cbm_run_c_lsp_cross(CBMArena *arena, const char *source, int source_len, co if (owns_tree) { ts_tree_delete(tree); } + return c_test_owners_complete(&ctx); } // --- Batch cross-file LSP --- diff --git a/internal/cbm/lsp/c_lsp.h b/internal/cbm/lsp/c_lsp.h index ee0290cd3..dacbcc8dd 100644 --- a/internal/cbm/lsp/c_lsp.h +++ b/internal/cbm/lsp/c_lsp.h @@ -82,6 +82,9 @@ typedef struct { // Output CBMResolvedCallArray *resolved_calls; CBMSourceOrigin source_origin; // source buffer represented by emitted occurrence spans + const CBMFileResult *test_definition_owners; /* borrowed, prevalidated raw source identity */ + int test_owner_seen_count; + bool test_owner_error; // Function pointer targets: lexical binding -> exact target function QN. // A NULL target is an explicitly unknown/ambiguous binding and must shadow @@ -170,6 +173,31 @@ void cbm_run_c_lsp_cross_with_registry(CBMArena *arena, const char *source, int TSTree *cached_tree, // NULL = parse internally CBMResolvedCallArray *out); +/* Additive configured-owner entrypoints. Owners must outlive the call. With + * owners, RAW origin, exact source identity and module identity are required. + * Configured owners fresh-parse verified source: supplied roots/cached trees + * have no source provenance and are not trusted. NULL owners retain caching. + * False leaves output untouched when identity validation fails. A later + * mapping/OOM failure may leave partial output that callers must discard. QNs + * in successful output belong to the supplied output arena. Callers must + * abort publication on false. Legacy wrappers pass NULL and retain previous behavior. */ +bool cbm_run_c_lsp_with_test_owners( + CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root, bool cpp_mode, CBMSourceOrigin source_origin, + const CBMFileResult *test_owners); +bool cbm_run_c_lsp_cross_with_test_owners( + CBMArena *arena, const char *source, int source_len, const char *module_qn, + bool cpp_mode, CBMLSPDef *defs, int def_count, const char **include_paths, + const char **include_ns_qns, int include_count, TSTree *cached_tree, + CBMResolvedCallArray *out, CBMSourceOrigin source_origin, + const CBMFileResult *test_owners); +bool cbm_run_c_lsp_cross_with_registry_with_test_owners( + CBMArena *arena, const char *source, int source_len, const char *module_qn, + bool cpp_mode, CBMTypeRegistry *reg, const char **include_paths, + const char **include_ns_qns, int include_count, TSTree *cached_tree, + CBMResolvedCallArray *out, CBMSourceOrigin source_origin, + const CBMFileResult *test_owners); + // Register C stdlib types and functions into a registry. void cbm_c_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena); diff --git a/scripts/ci/clean-test-residue.ps1 b/scripts/ci/clean-test-residue.ps1 index abc3eae24..b88256df6 100644 --- a/scripts/ci/clean-test-residue.ps1 +++ b/scripts/ci/clean-test-residue.ps1 @@ -26,14 +26,23 @@ CI venue would never see. .PARAMETER SkipGate Sweep and report, but do not fail when free space is short. For inspection only. + +.PARAMETER CheckOnly +Assert the free-space floor without enumerating or removing residue. Isolated +runs use fresh checkout/build/temp roots and must not sweep another run's files. +Cannot be combined with SkipGate. #> [CmdletBinding()] param( [double]$MinFreeGB = 14, - [switch]$SkipGate + [switch]$SkipGate, + [switch]$CheckOnly ) $ErrorActionPreference = 'Stop' +if ($CheckOnly -and $SkipGate) { + throw 'CheckOnly cannot be combined with SkipGate: the disk floor is required.' +} function Get-FreeGB { [math]::Round((Get-PSDrive C).Free / 1GB, 2) @@ -56,21 +65,23 @@ function Remove-ResidueDir([string]$Path) { $before = Get-FreeGB $removed = 0 $failed = @() -$roots = @($env:USERPROFILE, $env:TEMP) | Where-Object { $_ } | Select-Object -Unique - -foreach ($root in $roots) { - if (-not (Test-Path -LiteralPath $root)) { continue } - Get-ChildItem -LiteralPath $root -Directory -Filter 'cbm-*' -ErrorAction SilentlyContinue | - ForEach-Object { - if (Remove-ResidueDir $_.FullName) { - $removed++ - } else { - $failed += $_.FullName +if (-not $CheckOnly) { + $roots = @($env:USERPROFILE, $env:TEMP) | Where-Object { $_ } | Select-Object -Unique + foreach ($root in $roots) { + if (-not (Test-Path -LiteralPath $root)) { continue } + Get-ChildItem -LiteralPath $root -Directory -Filter 'cbm-*' -ErrorAction SilentlyContinue | + ForEach-Object { + if (Remove-ResidueDir $_.FullName) { + $removed++ + } else { + $failed += $_.FullName + } } - } + } } $after = Get-FreeGB +if ($CheckOnly) { Write-Output 'preflight-mode=check-only' } Write-Output "residue-dirs-removed=$removed residue-dirs-failed=$($failed.Count) free-gb-before=$before free-gb-after=$after" foreach ($f in $failed) { Write-Warning "could not sweep: $f" } diff --git a/scripts/test-impact/coverage-map.py b/scripts/test-impact/coverage-map.py new file mode 100644 index 000000000..1ef0c9290 --- /dev/null +++ b/scripts/test-impact/coverage-map.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""coverage-map.py — build the per-test coverage map of the C test runner: for +every test, the set of functions it executed, children included. The test-impact +engine uses it to narrow the suites whose tests cross a process boundary, where +the call graph alone cannot say which test reaches which function. + +Input: the instrumented runner (make -f Makefile.cbm test-runner-cov). Run with +CBM_TEST_COVERAGE_DIR it leaves, per suite, raw profiles named after the test +(tests/test_main.c, "Per-test coverage profiles"): + .parent.profraw the runner inside the test + ..profraw one per child process of the test + ..forked an unaccounted forked child + _setup..profraw the runner between tests + _setup.child..profraw children started between tests + _setup.child..forked unaccounted children started between tests + +Output directory: + functions.tsv TAB TAB + The file is where the function body stands, relative to the + repository root, read from the coverage mapping of the + runner itself (llvm-cov export). The profile only names the + translation unit: a function of a unity build or a static + inline of a header would otherwise carry the wrong file, + and the engine joins the map to the graph by file and name. + tests.tsv : TAB TAB TAB + ids ascending, space separated. :* is what ran BETWEEN + the tests of a suite (its setup); a change there concerns + every test of the suite. + meta.json commit, toolchain, and per suite: tests, incomplete tests, + exit code, wall seconds + +A test is `incomplete` when its coverage cannot be trusted to be whole: a +forked child left its marker, a profile is unreadable, the parent profile is +missing, or the suite did not exit 0. An incomplete test must always be +selected; the map never claims less than it knows. + +Raw profiles are about 7 MiB each, so every suite is reduced and deleted before +the next one runs. + +Usage: + coverage-map.py --runner build/cov/test-runner --out DIR + [--suite NAME ...] [--llvm-bin DIR] [--root DIR] + [--keep-raw] [--timeout SECONDS] +Exit status: 0 map written; 1 a suite failed or timed out (map still written, +its tests incomplete); 2 usage or toolchain error. +""" +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import time + +PROFILE = re.compile(r"^(?P.+?)\.(?Pparent|\d+)\.profraw$") +MARKER = re.compile(r"^(?P.+?)\.(?P\d+)\.forked$") +SETUP = re.compile(r"^_setup\.(?:\d+|child\.\d+)\.profraw$") +# `llvm-profdata show --all-functions` prints one block per function, opened by +# the name at two spaces of indent and a trailing colon. +FUNCTION_LINE = re.compile(r"^ (?P\S.*):$") +SETUP_TEST = "*" + + +def fail(message): + print(f"coverage-map: {message}", file=sys.stderr) + sys.exit(2) + + +def executed_functions(profdata, profile): + """Names of the functions `profile` counted at least once, or None when the + profile cannot be read.""" + proc = subprocess.run([profdata, "show", "--all-functions", "--value-cutoff=1", profile], + capture_output=True, text=True) + if proc.returncode != 0: + return None + names = set() + for line in proc.stdout.splitlines(): + match = FUNCTION_LINE.match(line) + if match: + names.add(match.group("name")) + return names + + +def source_table(llvm_cov, profdata_tool, runner, profile, root, work): + """{profile name: (source file, function name)} for every function compiled + into the runner, from its coverage mapping. `profile` is any raw profile of + that runner: llvm-cov wants one, its counts are not used.""" + indexed = os.path.join(work, "table.profdata") + merged = subprocess.run([profdata_tool, "merge", "-sparse", "-o", indexed, profile], + capture_output=True, text=True) + if merged.returncode != 0: + fail(f"llvm-profdata merge failed: {merged.stderr.strip()[-300:]}") + exported = subprocess.run([llvm_cov, "export", "-format=text", "-skip-expansions", + "-skip-branches", f"-instr-profile={indexed}", runner], + capture_output=True, text=True) + os.remove(indexed) + if exported.returncode != 0: + fail(f"llvm-cov export failed: {exported.stderr.strip()[-300:]}") + base = os.path.realpath(root) + os.sep + table = {} + for record in json.loads(exported.stdout)["data"][0]["functions"]: + files = record.get("filenames") or [] + if not files: + continue + path = os.path.realpath(files[0]) + if path.startswith(base): + path = path[len(base):] + table[record["name"]] = (path.replace(os.sep, "/"), record["name"].rpartition(":")[2]) + return table + + +class Functions: + """Stable ids for (source file, function name), in order of first + appearance. Several profile names can be one function: a static inline of a + header is recorded once per file that includes it.""" + + def __init__(self, table): + self.table = table + self.ids = {} + + def key(self, name): + if name in self.table: + return self.table[name] + # Not in the coverage mapping: keep what the profile says, the + # translation unit for a function with internal linkage. + source, _, short = name.rpartition(":") + return (source, short) + + def ids_of(self, names): + out = set() + for name in names: + key = self.key(name) + if key not in self.ids: + self.ids[key] = len(self.ids) + out.add(self.ids[key]) + return sorted(out) + + def rows(self): + for (source, short), ident in sorted(self.ids.items(), key=lambda item: item[1]): + yield ident, source, short + + +def reduce_suite(suite, suite_dir, profdata, suite_ok): + """Turn one suite's raw profiles into {test: (status, reason, names)}.""" + tests = {} + markers = {} + setup = set() + setup_unreadable = False + setup_markers = [] + entries = sorted(os.listdir(suite_dir)) if os.path.isdir(suite_dir) else [] + for entry in entries: + path = os.path.join(suite_dir, entry) + marker = MARKER.match(entry) + if marker: + if marker.group("stem") == "_setup.child": + setup_markers.append(marker.group("pid")) + else: + markers.setdefault(marker.group("stem"), []).append(marker.group("pid")) + continue + if SETUP.match(entry): + names = executed_functions(profdata, path) + if names is None: + setup_unreadable = True + else: + setup |= names + continue + profile = PROFILE.match(entry) + if not profile: + continue + test = tests.setdefault(profile.group("stem"), + {"names": set(), "parent": False, "unreadable": []}) + names = executed_functions(profdata, path) + if names is None: + test["unreadable"].append(entry) + continue + test["names"] |= names + test["parent"] = test["parent"] or profile.group("who") == "parent" + + result = {} + for stem, pids in markers.items(): + tests.setdefault(stem, {"names": set(), "parent": False, "unreadable": []}) + for stem, test in sorted(tests.items()): + reasons = [] + if stem in markers: + reasons.append("unaccounted forked child: " + ",".join(sorted(markers[stem]))) + if test["unreadable"]: + reasons.append("unreadable profile: " + ",".join(sorted(test["unreadable"]))) + if not test["parent"]: + reasons.append("no parent profile") + if not suite_ok: + reasons.append("suite did not exit 0") + result[stem] = ("incomplete" if reasons else "complete", "; ".join(reasons), test["names"]) + setup_reasons = [] + if setup_markers: + setup_reasons.append("unaccounted forked child: " + ",".join(sorted(setup_markers))) + if setup_unreadable: + setup_reasons.append("unreadable setup profile") + if not suite_ok: + setup_reasons.append("suite did not exit 0") + result[SETUP_TEST] = ("incomplete" if setup_reasons else "complete", + "; ".join(setup_reasons), setup) + return result + + +def tool_version(tool): + proc = subprocess.run([tool, "--version"], capture_output=True, text=True) + lines = [line.strip() for line in proc.stdout.splitlines() if line.strip()] + return lines[0] if lines else "unknown" + + +def git_commit(root): + proc = subprocess.run(["git", "-C", root, "rev-parse", "HEAD"], capture_output=True, text=True) + return proc.stdout.strip() if proc.returncode == 0 else "unknown" + + + +def add_v2_arguments(parser): + parser.add_argument("--format", type=int, choices=(1, 2), default=1, + help="wire format (2 is experimental and unadmitted)") + parser.add_argument("--registry", help="format2 image-bound expected registry JSON") + for flag, default, help_text in ( + ("max-output-bytes", 32 * 1024 * 1024, "combined stdout/stderr bytes per command"), + ("max-total-output-bytes", 512 * 1024 * 1024, "aggregate command stdout/stderr bytes"), + ("max-profile-bytes", 64 * 1024 * 1024, "bytes per raw input profile"), + ("max-total-profile-bytes", 512 * 1024 * 1024, "aggregate raw and separately converted bytes"), + ("max-profile-files", 4096, "raw entries including directories, markers and listing profile"), + ("conversion-timeout", 60, "seconds per tool command"), + ("reduction-timeout", 600, "seconds per strict parse or final pure reduction"), + ("max-map-bytes", 64 * 1024 * 1024, "combined producer wire bytes"), + ): + parser.add_argument("--" + flag, type=int, default=default, + help="format2: %s (default: %s)" % (help_text, default)) + parser.epilog = ((parser.epilog or "") + + " Format2 always retains bounded raw and diagnostic evidence; " + "--keep-raw only changes format1. Format2 supports the observed " + "LLVM 22.1.8 frontend text dialect and POSIX process groups.") + + +def main(): + parser = argparse.ArgumentParser(description="Build the per-test coverage map.") + parser.add_argument("--runner", required=True, help="instrumented test runner") + parser.add_argument("--out", required=True, help="output directory") + parser.add_argument("--suite", action="append", default=[], help="suite to run (repeatable)") + parser.add_argument("--llvm-bin", default=os.environ.get("COV_LLVM_BIN", ""), + help="directory holding llvm-profdata and llvm-cov of the compiler that " + "built the runner") + parser.add_argument("--root", default="", + help="repository root the source files are named relative to " + "(default: the repository this script is in)") + parser.add_argument("--keep-raw", action="store_true", help="keep the raw profiles") + parser.add_argument("--timeout", type=int, default=3600, help="seconds per suite") + add_v2_arguments(parser) + args = parser.parse_args() + if args.format == 2: + from coverage_live_v2 import collect + return collect(args) + + runner = os.path.abspath(args.runner) + if not os.access(runner, os.X_OK): + fail(f"runner is not executable: {runner}") + profdata = os.path.join(args.llvm_bin, "llvm-profdata") if args.llvm_bin else "llvm-profdata" + llvm_cov = os.path.join(args.llvm_bin, "llvm-cov") if args.llvm_bin else "llvm-cov" + for tool in (profdata, llvm_cov): + if not shutil.which(tool): + fail(f"not found: {tool}") + root = args.root or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + out = os.path.abspath(args.out) + raw = os.path.join(out, "raw") + os.makedirs(raw, exist_ok=True) + + # Listing the suites is also the cheapest run of the runner: the profile it + # leaves is the one llvm-cov needs to print the coverage mapping. + listing_profile = os.path.join(out, "listing.profraw") + listed = subprocess.run([runner, "--list-suites"], capture_output=True, text=True, + env=dict(os.environ, LLVM_PROFILE_FILE=listing_profile)) + if listed.returncode != 0: + fail("the runner could not list its suites") + if not os.path.exists(listing_profile): + fail("the runner wrote no profile: is it the instrumented build (make test-runner-cov)?") + table = source_table(llvm_cov, profdata, runner, listing_profile, root, out) + os.remove(listing_profile) + suites = args.suite or [line.strip() for line in listed.stdout.splitlines() if line.strip()] + if not suites: + fail("no suites to run") + + functions = Functions(table) + test_rows = [] + suite_meta = [] + failed = False + env = dict(os.environ) + # A plain name: a runner built for continuous profiles would otherwise start + # in that mode and ignore every per-test name (tests/test_main.c). + env["LLVM_PROFILE_FILE"] = os.devnull + env["CBM_TEST_COVERAGE_DIR"] = raw + for suite in suites: + suite_dir = os.path.join(raw, suite) + shutil.rmtree(suite_dir, ignore_errors=True) + started = time.monotonic() + try: + run = subprocess.run([runner, suite], env=env, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=args.timeout) + code = run.returncode + except subprocess.TimeoutExpired: + code = -1 + wall = time.monotonic() - started + suite_ok = code == 0 + failed = failed or not suite_ok + reduced = reduce_suite(suite, suite_dir, profdata, suite_ok) + incomplete = 0 + for test, (status, reason, names) in sorted(reduced.items()): + if test != SETUP_TEST and status != "complete": + incomplete += 1 + ids = " ".join(str(i) for i in functions.ids_of(names)) + test_rows.append(f"{suite}:{test}\t{status}\t{reason}\t{ids}") + suite_meta.append({"suite": suite, "tests": len(reduced) - 1, "incomplete": incomplete, + "exit": code, "wall_s": round(wall, 2)}) + print(f"{suite}: {len(reduced) - 1} tests, {incomplete} incomplete, exit {code}, " + f"{wall:.1f} s", flush=True) + if not args.keep_raw: + shutil.rmtree(suite_dir, ignore_errors=True) + if not args.keep_raw: + shutil.rmtree(raw, ignore_errors=True) + + with open(os.path.join(out, "functions.tsv"), "w", encoding="utf-8") as handle: + for ident, source, short in functions.rows(): + handle.write(f"{ident}\t{source}\t{short}\n") + with open(os.path.join(out, "tests.tsv"), "w", encoding="utf-8") as handle: + handle.write("\n".join(test_rows) + "\n") + meta = { + "format": 1, + "commit": git_commit(root), + "llvm_profdata": tool_version(profdata), + "platform": sys.platform, + "functions": len(functions.ids), + "functions_compiled": len(set(table.values())), + "tests": sum(entry["tests"] for entry in suite_meta), + "incomplete": sum(entry["incomplete"] for entry in suite_meta), + "suites": suite_meta, + } + with open(os.path.join(out, "meta.json"), "w", encoding="utf-8") as handle: + json.dump(meta, handle, indent=1) + handle.write("\n") + print(f"map: {meta['tests']} tests, {meta['incomplete']} incomplete, " + f"{meta['functions']} functions -> {out}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test-impact/coverage_live_v2.py b/scripts/test-impact/coverage_live_v2.py new file mode 100644 index 000000000..b06121dd1 --- /dev/null +++ b/scripts/test-impact/coverage_live_v2.py @@ -0,0 +1,649 @@ +"""Experimental, bounded POSIX native transport for unadmitted profile-map v2. + +Only actual observations are transported. Registry/image hashes are custody +facts, not evidence that a registry, interval, runtime or image set is complete. +No RowEvidence is created here, so this bundle cannot authorize negative +coverage evidence or test narrowing. Raw evidence is retained, including on +failure. Process groups cannot prove closure of children that leave the group. +""" + +import errno +import hashlib +import json +import os +import re +import selectors +import signal +import stat +import subprocess +import sys +import time + +from profile_map_v2 import ( + ExpectedSuite, ObservationRole, ProfileMapError, ProfileMapLimits, + ProfileMapLimitExceededError, ProfileMapCancelledError, + ProfileObservation, SuiteOutcome, build_profile_map_v2, +) +from profile_text_parser import ( + ParseLimits, ProfileTextError, LimitExceededError, ParseCancelledError, parse_profile_text, +) + + +REGISTRY_CAP = 2 * 1024 * 1024 +ROW_CAP = 65536 +RECORD_CAP = 4000000 +COUNTER_CAP = 32000000 +NAME_CAP = 128 * 1024 * 1024 +GRACE = 2.0 +PROFILE = re.compile(r"^(.+?)\.(parent|[0-9]+)\.profraw$") +SETUP = re.compile(r"^_setup\.(?:[0-9]+|child\.[0-9]+)\.profraw$") +MARKER = re.compile(r"^(.+?)\.([0-9]+)\.forked$") + + +class CollectionError(ValueError): + def __init__(self, kind, message): + super().__init__(message) + self.kind = kind + + +def _error(kind, message): + raise CollectionError(kind, message) + + +def _directory(path): + """Open each absolute component without following symlinks; caller closes.""" + path = os.path.abspath(path) + fd = os.open(os.sep, os.O_RDONLY | os.O_DIRECTORY) + try: + for part in path.split(os.sep): + if not part: + continue + next_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=fd) + os.close(fd) + fd = next_fd + return fd + except BaseException: + os.close(fd) + raise + + +def _regular(path): + parent, name = os.path.split(os.path.abspath(path)) + directory = _directory(parent) + try: + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=directory) + finally: + os.close(directory) + if not stat.S_ISREG(os.fstat(fd).st_mode): + os.close(fd) + _error("input", "input is not a regular file") + return fd + + +def _signature(info): + return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns) + + +def _read(path, limit): + fd = _regular(path) + try: + before = os.fstat(fd) + if before.st_size > limit: + _error("resource_limit", "file byte limit exceeded") + data = bytearray() + while True: + part = os.read(fd, min(65536, limit + 1 - len(data))) + if not part: + break + data.extend(part) + if len(data) > limit: + _error("resource_limit", "file byte limit exceeded") + if _signature(before) != _signature(os.fstat(fd)): + _error("identity", "input changed during capture") + return bytes(data) + finally: + os.close(fd) + + +def _identity(path): + fd = _regular(path) + try: + before = os.fstat(fd) + if before.st_size > 1024 * 1024 * 1024: + _error("resource_limit", "executable exceeds 1GiB identity limit") + digest = hashlib.sha256() + size = 0 + while True: + part = os.read(fd, 65536) + if not part: + break + size += len(part) + if size > 1024 * 1024 * 1024: + _error("resource_limit", "executable grew beyond identity limit") + digest.update(part) + if _signature(before) != _signature(os.fstat(fd)) or size != before.st_size: + _error("identity", "executable changed while hashing") + if not os.access(path, os.X_OK): + _error("input", "executable is not executable") + return {"path": path, "sha256": digest.hexdigest(), "bytes": size} + finally: + os.close(fd) + + +def _pairs(values): + result = {} + for key, value in values: + if key in result: + _error("input", "duplicate registry key") + result[key] = value + return result + + +def _identifier(value, suite=False): + if type(value) is not str or not value: + _error("input", "registry identifier must be a nonempty string") + try: + data = value.encode("utf-8", "strict") + except UnicodeError: + _error("input", "registry identifier is not valid UTF-8") + if any(char in data for char in (0, 9, 10, 13)) or (suite and b":" in data): + _error("input", "registry identifier violates producer grammar") + if not suite and value == "*": + _error("input", "setup test name is reserved") + # Native routing uses each identifier as one filesystem component. The + # pure producer accepts a wider byte grammar; those routes are unsupported. + if value in (".", "..") or "/" in value or "\\" in value: + _error("unsupported", "registry identifier cannot be a native path component") + if not suite and value in ("_setup", "_setup.child"): + _error("unsupported", "registry test collides with native setup routing") + return data + + +def _registry(path, image): + raw = _read(path, REGISTRY_CAP) + try: + value = json.loads(raw.decode("utf-8", "strict"), object_pairs_hook=_pairs, + parse_constant=lambda _: _error("input", "nonfinite JSON number")) + except (UnicodeError, json.JSONDecodeError, RecursionError): + _error("input", "invalid registry JSON") + if type(value) is not dict or set(value) != {"format", "image_sha256", "suites"}: + _error("input", "registry keys do not match v0 schema") + if value["format"] != "cbm.coverage.registry.v0": + _error("input", "unsupported registry format") + declared = value["image_sha256"] + if type(declared) is not str or not re.fullmatch(r"[0-9a-f]{64}", declared): + _error("input", "registry image digest must be lowercase SHA256") + if declared != image["sha256"]: + _error("identity", "registry does not match runner image") + suites = value["suites"] + if type(suites) is not list or not suites: + _error("input", "registry suites must be nonempty") + result = {} + rows = 0 + for item in suites: + if type(item) is not dict or set(item) != {"name", "tests"}: + _error("input", "registry suite keys do not match schema") + name = item["name"] + _identifier(name, suite=True) + if name in result: + _error("input", "duplicate registry suite") + tests = item["tests"] + if type(tests) is not list or not tests: + _error("input", "registry tests must be nonempty") + seen = set() + for test in tests: + _identifier(test) + if test in seen: + _error("input", "duplicate registry test") + seen.add(test) + rows += 1 + len(tests) + if rows > ROW_CAP: + _error("resource_limit", "registry row limit exceeded") + result[name] = tuple(tests) + return result, hashlib.sha256(raw).hexdigest(), raw + + +class Evidence: + def __init__(self, path, args): + self.path = os.path.abspath(path) + self.args = args + self.records = [] + self.uncertainty = [] + self.converted_bytes = 0 + self.captured_bytes = 0 + self.allowed_suites = set() + self.directory = None + parent, name = os.path.split(self.path) + if not name: + _error("input", "output must name a fresh directory") + fd = _directory(parent) + try: + os.mkdir(name, 0o700, dir_fd=fd) + self.directory = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=fd) + finally: + os.close(fd) + try: + self.directory_identity = os.fstat(self.directory) + for name in ("evidence", "raw"): + os.mkdir(name, 0o700, dir_fd=self.directory) + except BaseException: + self.close() + raise + + def close(self): + if self.directory is not None: + os.close(self.directory) + self.directory = None + + def check(self): + fd = _directory(self.path) + try: + actual = os.fstat(fd) + if (actual.st_dev, actual.st_ino) != (self.directory_identity.st_dev, + self.directory_identity.st_ino): + _error("identity", "owned output directory was replaced") + finally: + os.close(fd) + + def write(self, relative, data): + self.check() + parent, name = os.path.split(relative) + fd = _directory(os.path.join(self.path, parent)) + try: + target = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=fd) + try: + with os.fdopen(target, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + except BaseException: + # Partial evidence remains; no marker is created on this path. + raise + finally: + os.close(fd) + return os.path.join(self.path, relative) + + def json(self, relative, value): + data = (json.dumps(value, sort_keys=True, indent=2, + ensure_ascii=True) + "\n").encode("ascii") + if len(data) > 64 * 1024 * 1024: + _error("resource_limit", "metadata exceeds 64MiB bound") + return self.write(relative, data) + + def publish(self, meta): + # A partial JSON file is never a commit marker. The pending complete + # file remains evidence, and hard-link creation cannot overwrite a + # preexisting or symlink destination. + self.json("meta.pending.json", meta) + for name in ("profiles", "tests"): + data = _read(os.path.join(self.path, name + ".tsv"), self.args.max_map_bytes) + if hashlib.sha256(data).hexdigest() != meta[name + "_sha256"]: + _error("identity", "wire changed before publication") + self.check() + os.fsync(self.directory) + os.link("meta.pending.json", "meta.json", src_dir_fd=self.directory, + dst_dir_fd=self.directory, follow_symlinks=False) + + def inventory(self): + """Bound scans while children run; never follow raw tree symlinks.""" + self.check() + root = os.path.join(self.path, "raw") + pending = [root] + files, total, count = [], 0, 1 # listing counts as one profile slot + try: + info = os.stat("listing.profraw", dir_fd=self.directory, follow_symlinks=False) + except FileNotFoundError: + info = None + if info is not None: + if not stat.S_ISREG(info.st_mode): + _error("input", "listing profile is not a regular file") + if info.st_size > self.args.max_profile_bytes: + _error("resource_limit", "listing profile byte limit exceeded") + total = info.st_size + if total > self.args.max_total_profile_bytes: + _error("resource_limit", "aggregate raw byte limit exceeded") + while pending: + directory = pending.pop() + fd = _directory(directory) + try: + with os.scandir(fd) as entries: + for entry in entries: + count += 1 + if count > self.args.max_profile_files: + _error("resource_limit", "raw entry count limit exceeded") + info = entry.stat(follow_symlinks=False) + path = os.path.join(directory, entry.name) + if stat.S_ISDIR(info.st_mode) and directory == root: + if entry.name not in self.allowed_suites: + self.uncertainty.append({"path": path, "reason": "unknown_suite_destination"}) + _error("input", "raw suite directory is outside requested scope") + pending.append(path) + elif stat.S_ISREG(info.st_mode): + if info.st_size > self.args.max_profile_bytes: + _error("resource_limit", "raw file byte limit exceeded") + total += info.st_size + if total > self.args.max_total_profile_bytes: + _error("resource_limit", "aggregate raw byte limit exceeded") + files.append(path) + else: + self.uncertainty.append({"path": path, "reason": "unsupported_raw_entry"}) + _error("input", "unsupported raw tree entry") + finally: + os.close(fd) + return sorted(files) + + +def _group_exists(pid): + try: + os.killpg(pid, 0) + return True + except ProcessLookupError: + return False + + +def _quiesce(proc): + sent = [] + for sig in (signal.SIGTERM, signal.SIGKILL): + if proc.poll() is not None and not _group_exists(proc.pid): + return True, sent + try: + os.killpg(proc.pid, sig) + sent.append(signal.Signals(sig).name) + except ProcessLookupError: + pass + until = time.monotonic() + GRACE + while time.monotonic() < until: + if proc.poll() is not None and not _group_exists(proc.pid): + return True, sent + time.sleep(0.02) + return proc.poll() is not None and not _group_exists(proc.pid), sent + + +def _run(evidence, label, argv, environment, timeout, cwd, watch_raw=False): + selector = selectors.DefaultSelector() + streams = {"stdout": bytearray(), "stderr": bytearray()} + started = time.monotonic() + proc = None + failure = None + quiescent, cleanup = False, [] + try: + proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=environment, cwd=cwd, + start_new_session=True, bufsize=0) + for name, pipe in (("stdout", proc.stdout), ("stderr", proc.stderr)): + os.set_blocking(pipe.fileno(), False) + selector.register(pipe, selectors.EVENT_READ, name) + while True: + if watch_raw: + evidence.inventory() + left = timeout - (time.monotonic() - started) + if left <= 0: + failure = "timeout" + break + for key, _ in selector.select(min(left, 0.05)): + part = os.read(key.fd, 65536) + if not part: + selector.unregister(key.fileobj) + continue + used = sum(len(data) for data in streams.values()) + allowed = min(evidence.args.max_output_bytes - used, + evidence.args.max_total_output_bytes - evidence.captured_bytes - used) + streams[key.data].extend(part[:max(0, allowed)]) + if len(part) > allowed: + failure = "resource_limit" + break + if failure: + break + if proc.poll() is not None: + if _group_exists(proc.pid): + failure = "quiescence" + break + if not selector.get_map(): + break + except CollectionError as error: + failure = error.kind + except (OSError, ValueError): + failure = "process" + finally: + if proc is not None: + quiescent, cleanup = _quiesce(proc) + if not quiescent: + failure = "quiescence" + if cleanup and failure is None: + failure = "quiescence" + proc.stdout.close() + proc.stderr.close() + selector.close() + record = {"label": label, "argv": argv, "returncode": proc.returncode if proc else None, + "failure": failure, "quiescent": quiescent, "cleanup": cleanup, + "wall_s": round(time.monotonic() - started, 6), "timeout_s": timeout} + evidence.captured_bytes += sum(len(data) for data in streams.values()) + for name, data in streams.items(): + path = evidence.write("evidence/" + label + "." + name, bytes(data)) + record[name] = {"path": path, "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "capture": "complete" if failure is None else "bounded_prefix"} + evidence.json("evidence/" + label + ".process.json", record) + evidence.records.append(record) + return record, bytes(streams["stdout"]) + + +def _required(record): + if record["failure"] or record["returncode"] != 0 or not record["quiescent"]: + _error(record["failure"] or "tool", "required native command failed: " + record["label"]) + + +def _environment(): + result = dict(os.environ) + for name in ("CBM_TEST_COVERAGE_DIR", "CBM_TEST_ONLY", "CBM_TEST_ONLY_FILE"): + result.pop(name, None) + return result + + +def _convert(evidence, tool, path, label, environment, root, unavailable=False): + try: + raw = _read(path, evidence.args.max_profile_bytes) + except OSError as error: + if unavailable and error.errno in (errno.EACCES, errno.EPERM, errno.ENOENT): + evidence.uncertainty.append({"path": path, "reason": "profile_unreadable"}) + return None + raise + snapshot = evidence.write("evidence/" + label + ".profraw", raw) + raw_digest = hashlib.sha256(raw).hexdigest() + record, text = _run(evidence, label, [tool, "merge", "--instr", "--text", "--failure-mode=any", + "--num-threads=1", "--output=-", snapshot], + environment, evidence.args.conversion_timeout, root) + record["input"] = {"path": path, "snapshot": snapshot, "sha256": raw_digest, + "bytes": len(raw)} + _required(record) + if hashlib.sha256(_read(snapshot, evidence.args.max_profile_bytes)).hexdigest() != raw_digest: + _error("identity", "conversion input changed") + evidence.converted_bytes += len(text) + if evidence.converted_bytes > evidence.args.max_total_profile_bytes: + _error("resource_limit", "aggregate converted byte limit exceeded") + # Reject malformed transport, even though the pure producer can represent + # unavailable/malformed observations conservatively. Unknown identities and + # counter-shape incompatibilities are rejected by the producer's full join. + deadline = time.monotonic() + evidence.args.reduction_timeout + parse_profile_text(text, ParseLimits(evidence.args.max_output_bytes, RECORD_CAP, + COUNTER_CAP, NAME_CAP), + cancel=lambda _: time.monotonic() >= deadline) + return text + + +def _scope(registry, requested, available): + selected = requested or list(registry) + if len(set(selected)) != len(selected): + _error("input", "duplicate requested suite") + for name in selected: + if name not in registry or name not in available: + _error("input", "requested suite is absent from registry or native list") + return selected + + +def _observations(evidence, tool, registry, selected, environment, root): + observations = [] + for index, path in enumerate(evidence.inventory()): + relative = os.path.relpath(path, os.path.join(evidence.path, "raw")) + parts = relative.split(os.sep) + if len(parts) != 2 or parts[0] not in selected: + evidence.uncertainty.append({"path": path, "reason": "unknown_profile_destination"}) + _error("input", "raw profile destination is outside requested scope") + suite, name = parts + marker = MARKER.fullmatch(name) + setup = SETUP.fullmatch(name) + profile = PROFILE.fullmatch(name) + if setup: + test = "*" + role = ObservationRole.CHILD if name.startswith("_setup.child.") else ObservationRole.SETUP + elif marker: + test = "*" if marker.group(1) == "_setup.child" else marker.group(1) + role = ObservationRole.CHILD + elif profile: + test = profile.group(1) + role = ObservationRole.PARENT if profile.group(2) == "parent" else ObservationRole.CHILD + else: + evidence.uncertainty.append({"path": path, "reason": "unaccounted_raw_entry"}) + _error("input", "unaccounted raw profile entry") + if test != "*" and test not in registry[suite]: + evidence.uncertainty.append({"path": path, "reason": "unknown_test_identity"}) + _error("input", "raw test identifier is outside expected registry") + if marker: + evidence.uncertainty.append({"path": path, "reason": "unaccounted_fork"}) + text = None + else: + text = _convert(evidence, tool, path, "profile-%06d" % index, + environment, root, unavailable=True) + observations.append(ProfileObservation(suite.encode("utf-8"), test.encode("utf-8"), + name.encode("utf-8"), role, text)) + return tuple(observations) + + +def collect(args): + evidence = None + try: + if os.name != "posix" or not all(hasattr(os, name) for name in + ("O_NOFOLLOW", "O_DIRECTORY", "killpg", "set_blocking")): + _error("unsupported", "format2 requires POSIX owned process-group custody") + for name in ("timeout", "max_output_bytes", "max_total_output_bytes", "max_profile_bytes", "max_total_profile_bytes", + "max_profile_files", "conversion_timeout", "reduction_timeout", "max_map_bytes"): + value = getattr(args, name) + if type(value) is not int or value <= 0 or value > sys.maxsize // 2: + _error("input", "resource limits must be positive bounded integers") + if not args.registry or not args.llvm_bin: + _error("input", "format2 requires --registry and explicit --llvm-bin") + runner = os.path.realpath(os.path.abspath(args.runner)) + tool = os.path.realpath(os.path.join(os.path.abspath(args.llvm_bin), "llvm-profdata")) + root = os.path.abspath(args.root or os.path.join(os.path.dirname(__file__), "..", "..")) + root_fd = _directory(root) + os.close(root_fd) + image = _identity(runner) + tool_identity = _identity(tool) + registry_path = os.path.abspath(args.registry) + registry, registry_digest, registry_bytes = _registry(registry_path, image) + # Reject explicit scope errors before any runner/tool action. + _scope(registry, args.suite, set(registry)) + evidence = Evidence(args.out, args) + registry_snapshot = evidence.write("evidence/registry.json", registry_bytes) + environment = _environment() + environment["LLVM_PROFILE_FILE"] = os.devnull + version_record, version = _run(evidence, "tool-version", [tool, "--version"], environment, + args.conversion_timeout, root) + _required(version_record) + if not re.search(rb"LLVM version 22\.1\.8(?:\s|$)", version): + _error("unsupported", "only observed LLVM 22.1.8 proftext transport is supported") + listing_path = os.path.join(evidence.path, "listing.profraw") + listing_env = dict(environment, LLVM_PROFILE_FILE=listing_path) + listed, listing = _run(evidence, "list-suites", [runner, "--list-suites"], listing_env, + args.timeout, root, watch_raw=True) + _required(listed) + try: + available = listing.decode("utf-8", "strict").splitlines() + except UnicodeError: + _error("input", "native suite list is not UTF-8") + if not available or any(not name for name in available) or len(set(available)) != len(available): + _error("input", "native suite list is empty or ambiguous") + for name in available: + _identifier(name, suite=True) + selected = _scope(registry, args.suite, set(available)) + evidence.allowed_suites = set(selected) + enumeration = _convert(evidence, tool, listing_path, "enumeration", environment, root) + evidence.inventory() + suite_env = dict(environment, CBM_TEST_COVERAGE_DIR=os.path.join(evidence.path, "raw")) + outcomes = [] + expected = [] + failed = False + for index, suite in enumerate(selected): + record, _ = _run(evidence, "suite-%06d" % index, [runner, suite], suite_env, + args.timeout, root, watch_raw=True) + if record["failure"] not in (None, "timeout") or not record["quiescent"]: + _error(record["failure"] or "quiescence", "native suite capture failed") + outcome = (SuiteOutcome.TIMED_OUT if record["failure"] == "timeout" else + SuiteOutcome.PASSED if record["returncode"] == 0 else SuiteOutcome.FAILED) + failed = failed or outcome is not SuiteOutcome.PASSED + expected.append(ExpectedSuite(suite.encode("utf-8"), + tuple(test.encode("utf-8") for test in registry[suite]), outcome)) + outcomes.append({"suite": suite, "tests": list(registry[suite]), + "outcome": outcome.value, "exit": record["returncode"], "process": record}) + observations = _observations(evidence, tool, registry, selected, environment, root) + if _identity(runner) != image or _identity(tool) != tool_identity: + _error("identity", "runner or conversion tool changed during collection") + # No affirmative runtime/interval/image/child assertions are supplied. + limits = ProfileMapLimits(args.max_total_profile_bytes + REGISTRY_CAP * 2, ROW_CAP, + args.max_profile_files, RECORD_CAP, RECORD_CAP, COUNTER_CAP, + NAME_CAP, COUNTER_CAP, args.max_map_bytes) + deadline = time.monotonic() + args.reduction_timeout + produced = build_profile_map_v2(bytes.fromhex(image["sha256"]), enumeration, + tuple(expected), observations, (), limits, + cancel=lambda _: time.monotonic() >= deadline) + profiles_path = evidence.write("profiles.tsv", produced.profiles_tsv) + tests_path = evidence.write("tests.tsv", produced.tests_tsv) + meta = {"format": 2, "admission": "unverified", "image_sha256": image["sha256"], + "registry_sha256": registry_digest, "profiles_sha256": produced.profiles_sha256.hex(), + "tests_sha256": produced.tests_sha256.hex(), "registry": { + "path": registry_path, "snapshot": registry_snapshot, "sha256": registry_digest, + "format": "cbm.coverage.registry.v0", "completeness": "unverified"}, + "scope": selected, + "image": image, "llvm_profdata": dict(tool_identity, version=version.decode("utf-8", "replace")), + "profiles": {"path": profiles_path, "sha256": produced.profiles_sha256.hex()}, + "tests": {"path": tests_path, "sha256": produced.tests_sha256.hex()}, + "suites": outcomes, "processes": evidence.records, "uncertainty": evidence.uncertainty, + "evidence_root": os.path.join(evidence.path, "evidence"), + "raw_root": os.path.join(evidence.path, "raw"), + "row_evidence": "absent", "platform": sys.platform, + "raw_retained": True, "captured_bytes": evidence.captured_bytes, + "converted_bytes": evidence.converted_bytes, + "custody": {"method": "POSIX session and process group", + "descendant_closure": "unproved", + "compiler_runtime_compatibility": "unverified"}, + "limits": {name: getattr(args, name) for name in ( + "timeout", "max_output_bytes", "max_total_output_bytes", "max_profile_bytes", + "max_total_profile_bytes", "max_profile_files", "conversion_timeout", + "reduction_timeout", "max_map_bytes")}, + "selection_environment": {"CBM_SKIP_PERF": environment.get("CBM_SKIP_PERF")}} + # Consumer acceptance requires this final marker AND both wire digests. + evidence.publish(meta) + return 1 if failed else 0 + except (CollectionError, ProfileMapError, ProfileTextError, OSError, UnicodeError, + ValueError, MemoryError) as error: + kind = getattr(error, "kind", "input_or_conversion") + if isinstance(error, (ProfileMapLimitExceededError, LimitExceededError, MemoryError)): + kind = "resource_limit" + if isinstance(error, (ProfileMapCancelledError, ParseCancelledError)): + kind = "reduction_timeout" + print("coverage-map: format2 %s: %s" % (kind, str(error)), file=sys.stderr) + if evidence is not None: + try: + evidence.json("failure.json", {"format": 2, "error": kind, + "message": str(error), "processes": evidence.records, + "uncertainty": evidence.uncertainty}) + except (OSError, ValueError, MemoryError): + pass + return 2 + finally: + if evidence is not None: + evidence.close() diff --git a/scripts/test-impact/profile_map_v2.py b/scripts/test-impact/profile_map_v2.py new file mode 100644 index 000000000..6d97b37a3 --- /dev/null +++ b/scripts/test-impact/profile_map_v2.py @@ -0,0 +1,730 @@ +"""Pure bounded single-image profile-map v2 encoding and row reduction. + +Evidence fields are caller claims, not authentication or closure certificates. +Names/hashes/shapes retain exact profile identity; source names are not inferred. +Limits count logical data, not interpreter RSS. Runtime allocation/deallocation +is not preemptible. This module performs no I/O and leaves format 1 unchanged. +""" + +from dataclasses import dataclass +from enum import Enum +import hashlib +import sys +from typing import Callable, Optional, Tuple + +from profile_text_parser import ( + LimitExceededError, MalformedProfileError, ParseCancelledError, + ParseLimits, parse_profile_text, +) + + +class SuiteOutcome(Enum): + PASSED = "passed" + FAILED = "failed" + TIMED_OUT = "timed_out" + UNKNOWN = "unknown" + + +class ObservationRole(Enum): + PARENT = "parent" + SETUP = "setup" + CHILD = "child" + + +@dataclass(frozen=True) +class ExpectedSuite: + __slots__ = ("suite", "tests", "outcome") + + suite: bytes + tests: Tuple[bytes, ...] + outcome: SuiteOutcome + + +@dataclass(frozen=True) +class ProfileObservation: + __slots__ = ("suite", "test", "key", "role", "text") + + suite: bytes + test: bytes + key: bytes + role: ObservationRole + text: Optional[bytes] # None means an explicitly unavailable observation. + + +@dataclass(frozen=True) +class RowEvidence: + __slots__ = ( + "suite", "test", "interval_complete", "runtime_compatible", + "same_image", "children_complete", + ) + + suite: bytes + test: bytes + interval_complete: bool + runtime_compatible: bool + same_image: bool + children_complete: bool + + +@dataclass(frozen=True) +class ProfileMapLimits: + __slots__ = ( + "max_input_bytes", "max_rows", "max_observations", "max_ids", + "max_records", "max_counter_values", "max_name_bytes", + "max_id_references", "max_output_bytes", + ) + + max_input_bytes: int + max_rows: int + max_observations: int + max_ids: int + max_records: int + max_counter_values: int + max_name_bytes: int + max_id_references: int + max_output_bytes: int + + +@dataclass(frozen=True) +class ProducedMap: + __slots__ = ( + "profiles_tsv", "tests_tsv", "profiles_sha256", "tests_sha256", + ) + + profiles_tsv: bytes + tests_tsv: bytes + profiles_sha256: bytes + tests_sha256: bytes + + +class ProfileMapError(ValueError): + """Base for classified build failures; no ProducedMap is returned.""" + + +class ProfileMapInvalidArgumentError(ProfileMapError): + """Invalid exact type, field, routing, membership, duplicate or callback result.""" + + +class ProfileMapEnumerationError(ProfileMapError): + """Enumeration text is malformed or unsupported, including empty text.""" + + +class ProfileMapCompatibilityError(ProfileMapError): + """An observation contains an unknown full identity or mismatching shape.""" + + +class ProfileMapLimitExceededError(ProfileMapError): + """A declared logical or fixed representation bound would be exceeded.""" + + +class ProfileMapCancelledError(ProfileMapError): + """The caller's pure prompt predicate requested cancellation.""" + + +_INT_MAX = 2147483647 +_SHA_BYTES_MAX = ((1 << 64) - 1) // 8 +_POLL_INTERVAL = 4096 +_HEX = b"0123456789abcdef" +_REASONS = ( + b"suite_failed", b"suite_timeout", b"suite_unknown", + b"row_evidence_missing", b"interval_unproved", b"runtime_unproved", + b"image_unproved", b"children_unproved", b"missing_parent", + b"missing_setup", b"observation_unavailable", b"observation_malformed", + b"partial_stream", +) +_SUITE_FAILED, _SUITE_TIMEOUT, _SUITE_UNKNOWN = 0, 1, 2 +_NO_EVIDENCE, _INTERVAL, _RUNTIME, _IMAGE, _CHILDREN = 3, 4, 5, 6, 7 +_NO_PARENT, _NO_SETUP, _UNAVAILABLE, _MALFORMED, _PARTIAL = 8, 9, 10, 11, 12 + + +class _Gate: + __slots__ = ("cancel", "context", "work", "callback_error") + + def __init__(self, cancel, context): + self.cancel = cancel + self.context = context + self.work = 0 + self.callback_error = None + + def poll(self): + self.work = 0 + if self.cancel is None: + return + try: + answer = self.cancel(self.context) + except BaseException as error: + # Identity distinguishes a callback's parser-shaped exception from + # a parser failure; neither its class nor message is sufficient. + self.callback_error = error + raise + if type(answer) is not bool: + raise ProfileMapInvalidArgumentError("cancel must return bool") + if answer: + raise ProfileMapCancelledError("profile map build cancelled") + + def step(self): + self.work += 1 + if self.work == _POLL_INTERVAL: + self.poll() + + def parser_cancel(self, _context): + self.poll() + return False + + +def _bounded_add(current, amount, cap, label): + if amount > cap - current: + raise ProfileMapLimitExceededError(label) + return current + amount + + +class _Budget: + __slots__ = ( + "limits", "input_bytes", "rows", "records", "counter_values", + "name_bytes", "id_references", "output_bytes", + ) + + def __init__(self, limits, enumeration_text): + self.limits = limits + self.input_bytes = _bounded_add( + 32, len(enumeration_text), limits.max_input_bytes, "input byte limit" + ) + self.rows = 0 + self.records = 0 + self.counter_values = 0 + self.name_bytes = 0 + self.id_references = 0 + self.output_bytes = 0 + + def name(self, value): + self.input_bytes = _bounded_add( + self.input_bytes, len(value), self.limits.max_input_bytes, + "input byte limit", + ) + self.name_bytes = _bounded_add( + self.name_bytes, len(value), self.limits.max_name_bytes, + "name byte limit", + ) + + def text(self, value): + self.input_bytes = _bounded_add( + self.input_bytes, len(value), self.limits.max_input_bytes, + "input byte limit", + ) + + def add_rows(self, count): + self.rows = _bounded_add( + self.rows, count, min(self.limits.max_rows, _INT_MAX), "row limit" + ) + + def parsed(self, records, gate): + self.records = _bounded_add( + self.records, len(records), self.limits.max_records, "record limit" + ) + for record in records: + gate.step() + self.counter_values = _bounded_add( + self.counter_values, len(record.counters), + self.limits.max_counter_values, "counter value limit", + ) + self.name_bytes = _bounded_add( + self.name_bytes, len(record.raw_name), + self.limits.max_name_bytes, "name byte limit", + ) + + def reference(self): + self.id_references = _bounded_add( + self.id_references, 1, self.limits.max_id_references, + "ID reference limit", + ) + + def output(self, count): + self.output_bytes = _bounded_add( + self.output_bytes, count, + min(self.limits.max_output_bytes, _SHA_BYTES_MAX), "output byte limit", + ) + + +class _Row: + __slots__ = ( + "suite", "test", "outcome", "evidence", "observations", "ids", + "reasons", "parent_seen", "primary_parsed", + ) + + def __init__(self, suite, test, outcome): + self.suite = suite + self.test = test + self.outcome = outcome + self.evidence = None + self.observations = [] + self.ids = set() + self.reasons = 0 + self.parent_seen = False + self.primary_parsed = False + + def mark(self, reason): + self.reasons |= 1 << reason + + +def _exact(value, cls, label): + if type(value) is not cls: + raise ProfileMapInvalidArgumentError(label) + + +def _validate_top(image, enumeration, suites, observations, evidence, limits, cancel): + _exact(image, bytes, "image digest must be bytes") + if len(image) != 32: + raise ProfileMapInvalidArgumentError("image digest must contain 32 bytes") + _exact(enumeration, bytes, "enumeration must be bytes") + _exact(suites, tuple, "expected suites must be a tuple") + _exact(observations, tuple, "observations must be a tuple") + _exact(evidence, tuple, "row evidence must be a tuple") + _exact(limits, ProfileMapLimits, "invalid limits type") + for field in ProfileMapLimits.__slots__: + value = getattr(limits, field) + if type(value) is not int or value <= 0 or value > sys.maxsize: + raise ProfileMapInvalidArgumentError("limits must be positive bounded ints") + if limits.max_ids > _INT_MAX: + raise ProfileMapInvalidArgumentError("max_ids exceeds INT_MAX") + if cancel is not None and not callable(cancel): + raise ProfileMapInvalidArgumentError("cancel must be callable or None") + + +def _name(value, budget, gate, *, suite=False, declared_test=False): + _exact(value, bytes, "names and keys must be bytes") + if not value: + raise ProfileMapInvalidArgumentError("empty name or key") + budget.name(value) + for char in value: + gate.step() + if char in (0, 9, 10, 13) or (suite and char == 58): + raise ProfileMapInvalidArgumentError("unrepresentable name or key") + if declared_test and _is_setup(value): + raise ProfileMapInvalidArgumentError("setup name is reserved") + + +def _is_setup(test): + return len(test) == 1 and test[0] == 42 + + +def _compare_bytes(left, right, gate): + for index in range(min(len(left), len(right))): + gate.step() + a, b = left[index], right[index] + if a != b: + return -1 if a < b else 1 + gate.step() + return (len(left) > len(right)) - (len(left) < len(right)) + + +def _compare_fields(left, right, fields, gate): + for field in fields: + gate.step() + order = _compare_bytes(getattr(left, field), getattr(right, field), gate) + if order: + return order + return 0 + + +def _merge(source, target, start, middle, end, compare, gate): + left, right = start, middle + for destination in range(start, end): + gate.step() + take_left = right == end or ( + left < middle and compare(source[left], source[right]) <= 0 + ) + if take_left: + target[destination] = source[left] + left += 1 + else: + target[destination] = source[right] + right += 1 + + +def _sort(values, compare, gate): + source, target = [], [] + for value in values: + gate.step() + source.append(value) + target.append(None) + width = 1 + count = len(source) + while width < count: + gate.step() + for start in range(0, count, width * 2): + gate.step() + middle, end = min(start + width, count), min(start + width * 2, count) + _merge(source, target, start, middle, end, compare, gate) + source, target = target, source + width *= 2 + return source + + +def _sort_unique(values, fields, gate, label): + compare = lambda left, right: _compare_fields(left, right, fields, gate) + result = _sort(values, compare, gate) + for index in range(1, len(result)): + gate.step() + if compare(result[index - 1], result[index]) == 0: + raise ProfileMapInvalidArgumentError(label) + return result + + +def _suites(suites, budget, gate): + rows = [] + if len(suites) > min(budget.limits.max_rows, _INT_MAX): + raise ProfileMapLimitExceededError("row limit") + for suite in suites: + gate.step() + _exact(suite, ExpectedSuite, "invalid expected suite type") + _name(suite.suite, budget, gate, suite=True) + _exact(suite.tests, tuple, "expected tests must be a tuple") + _exact(suite.outcome, SuiteOutcome, "invalid suite outcome") + budget.add_rows(1 + len(suite.tests)) + rows.append(_Row(suite.suite, b"*", suite.outcome)) + for test in suite.tests: + gate.step() + _name(test, budget, gate, declared_test=True) + rows.append(_Row(suite.suite, test, suite.outcome)) + _sort_unique(suites, ("suite",), gate, "duplicate expected suite") + return _sort_unique(rows, ("suite", "test"), gate, "duplicate expected test") + + +def _compare_target(row, suite, test, gate): + order = _compare_bytes(row.suite, suite, gate) + return order if order else _compare_bytes(row.test, test, gate) + + +def _find_row(rows, suite, test, gate): + low, high = 0, len(rows) + while low < high: + gate.step() + middle = (low + high) // 2 + order = _compare_target(rows[middle], suite, test, gate) + if order == 0: + return rows[middle] + if order < 0: + low = middle + 1 + else: + high = middle + raise ProfileMapInvalidArgumentError("observation/evidence outside expected rows") + + +def _observation_schema(observation, budget, gate): + _exact(observation, ProfileObservation, "invalid observation type") + _name(observation.suite, budget, gate, suite=True) + _name(observation.test, budget, gate) + _name(observation.key, budget, gate) + _exact(observation.role, ObservationRole, "invalid observation role") + if observation.text is not None: + _exact(observation.text, bytes, "observation text must be bytes or None") + budget.text(observation.text) + + +def _route_observation(observation, row): + setup = _is_setup(row.test) + if ((setup and observation.role is ObservationRole.PARENT) + or (not setup and observation.role is ObservationRole.SETUP)): + raise ProfileMapInvalidArgumentError("invalid role for row") + if observation.role is ObservationRole.PARENT: + if row.parent_seen: + raise ProfileMapInvalidArgumentError("multiple parent observations") + row.parent_seen = True + row.observations.append(observation) + + +def _observations(observations, rows, budget, gate): + if len(observations) > budget.limits.max_observations: + raise ProfileMapLimitExceededError("observation limit") + for observation in observations: + gate.step() + _observation_schema(observation, budget, gate) + ordered = _sort_unique( + observations, ("suite", "test", "key"), gate, "duplicate observation key" + ) + for observation in ordered: + gate.step() + row = _find_row(rows, observation.suite, observation.test, gate) + _route_observation(observation, row) + + +def _evidence(evidence, rows, budget, gate): + if len(evidence) > len(rows): + raise ProfileMapInvalidArgumentError("too many row evidence records") + for item in evidence: + gate.step() + _exact(item, RowEvidence, "invalid row evidence type") + _name(item.suite, budget, gate, suite=True) + _name(item.test, budget, gate) + for field in ("interval_complete", "runtime_compatible", "same_image", "children_complete"): + gate.step() + _exact(getattr(item, field), bool, "evidence claims must be bool") + ordered = _sort_unique(evidence, ("suite", "test"), gate, "duplicate row evidence") + for item in ordered: + gate.step() + _find_row(rows, item.suite, item.test, gate).evidence = item + + +def _preflight(suites, observations, evidence, budget, gate): + rows = _suites(suites, budget, gate) + _observations(observations, rows, budget, gate) + _evidence(evidence, rows, budget, gate) + gate.poll() + return rows + + +def _parser_limits(budget, enumeration): + limits = budget.limits + records = limits.max_records - budget.records + counters = limits.max_counter_values - budget.counter_values + names = limits.max_name_bytes - budget.name_bytes + if enumeration: + records = min(records, limits.max_ids) + if records <= 0 or counters <= 0 or names <= 0: + raise ProfileMapLimitExceededError("no remaining parser item allowance") + return ParseLimits(limits.max_input_bytes, records, counters, names) + + +def _parse(text, budget, gate, *, enumeration=False): + limits = _parser_limits(budget, enumeration) + try: + records = parse_profile_text(text, limits, cancel=gate.parser_cancel) + except MalformedProfileError as error: + if error is gate.callback_error: + raise + if enumeration: + raise ProfileMapEnumerationError("invalid profile enumeration") from error + return None + except LimitExceededError as error: + if error is gate.callback_error: + raise + raise ProfileMapLimitExceededError("profile parser resource limit") from error + except ParseCancelledError as error: + if error is gate.callback_error: + raise + raise ProfileMapCancelledError("profile parsing cancelled") from error + budget.parsed(records, gate) + return records + + +def _record_order(left, right, gate): + order = _compare_bytes(left.raw_name, right.raw_name, gate) + if order: + return order + gate.step() + return (left.function_hash > right.function_hash) - (left.function_hash < right.function_hash) + + +def _has_hit(record, gate): + for value in record.counters: + gate.step() + if value: + return True + return False + + +def _join(records, universe, row, budget, gate): + position = 0 + for record in records: + gate.step() + while position < len(universe): + gate.step() + order = _record_order(universe[position], record, gate) + if order >= 0: + break + position += 1 + if position == len(universe) or order != 0: + raise ProfileMapCompatibilityError("observation identity outside universe") + if len(record.counters) != len(universe[position].counters): + raise ProfileMapCompatibilityError("observation counter shape mismatch") + if _has_hit(record, gate) and position not in row.ids: + budget.reference() + row.ids.add(position) + position += 1 + if len(records) != len(universe): + row.mark(_PARTIAL) + + +def _base_reasons(row, gate): + outcomes = ( + (SuiteOutcome.FAILED, _SUITE_FAILED), + (SuiteOutcome.TIMED_OUT, _SUITE_TIMEOUT), + (SuiteOutcome.UNKNOWN, _SUITE_UNKNOWN), + ) + for outcome, reason in outcomes: + gate.step() + if row.outcome is outcome: + row.mark(reason) + if row.evidence is None: + row.mark(_NO_EVIDENCE) + return + for field, reason in ( + ("interval_complete", _INTERVAL), ("runtime_compatible", _RUNTIME), + ("same_image", _IMAGE), ("children_complete", _CHILDREN), + ): + gate.step() + if not getattr(row.evidence, field): + row.mark(reason) + + +def _reduce_row(row, universe, budget, gate): + _base_reasons(row, gate) + for observation in row.observations: + gate.step() + if observation.text is None: + row.mark(_UNAVAILABLE) + continue + records = _parse(observation.text, budget, gate) + if records is None: + row.mark(_MALFORMED) + continue + _join(records, universe, row, budget, gate) + if observation.role in (ObservationRole.PARENT, ObservationRole.SETUP): + row.primary_parsed = True + if not row.primary_parsed: + row.mark(_NO_SETUP if _is_setup(row.test) else _NO_PARENT) + + +class _Wire: + __slots__ = ("budget", "gate", "data") + + def __init__(self, budget, gate): + self.budget = budget + self.gate = gate + self.data = bytearray() + + def emit(self, value): + self.budget.output(len(value)) + for char in value: + self.gate.step() + self.data.append(char) + + def hex(self, value): + self.budget.output(len(value) * 2) + for char in value: + self.gate.step() + self.gate.step() + self.data.append(_HEX[char >> 4]) + self.gate.step() + self.data.append(_HEX[char & 15]) + + def decimal(self, value): + # All emitted integers are <= uint64, so conversion is <=20 digits. + self.gate.poll() + self.emit(str(value).encode("ascii")) + + def _values(self): + for char in self.data: + self.gate.step() + yield char + + def finish(self): + self.gate.poll() + result = bytes(self._values()) + self.gate.poll() + return result + + +def _profile_wire(image, universe, budget, gate): + out = _Wire(budget, gate) + out.emit(b"CBM_PROFILE_MAP\t2\t") + out.hex(image) + out.emit(b"\t") + out.decimal(len(universe)) + out.emit(b"\n") + for ident, record in enumerate(universe): + gate.step() + out.decimal(ident) + out.emit(b"\t") + out.hex(record.raw_name) + out.emit(b"\t") + gate.poll() + out.emit(format(record.function_hash, "016x").encode("ascii")) + out.emit(b"\t") + out.decimal(len(record.counters)) + out.emit(b"\n") + return out.finish() + + +def _reason_wire(row, out, gate): + first = True + for index, reason in enumerate(_REASONS): + gate.step() + if row.reasons & (1 << index): + if not first: + out.emit(b";") + out.emit(reason) + first = False + + +def _id_wire(row, out, gate): + def compare(left, right): + gate.step() + return (left > right) - (left < right) + + ordered = _sort(row.ids, compare, gate) + for index, ident in enumerate(ordered): + gate.step() + if index: + out.emit(b" ") + out.decimal(ident) + + +def _test_wire(rows, budget, gate): + out = _Wire(budget, gate) + for row in rows: + gate.step() + out.emit(row.suite) + out.emit(b":") + out.emit(row.test) + out.emit(b"\tincomplete\t" if row.reasons else b"\tcomplete\t") + _reason_wire(row, out, gate) + out.emit(b"\t") + _id_wire(row, out, gate) + out.emit(b"\n") + return out.finish() + + +def _digest(data, gate): + digest = hashlib.sha256() + for start in range(0, len(data), _POLL_INTERVAL): + gate.poll() + chunk = data[start:start + _POLL_INTERVAL] + gate.poll() + digest.update(chunk) + gate.poll() + return digest.digest() + + +def build_profile_map_v2( + image_sha256: bytes, + enumeration_text: bytes, + expected_suites: Tuple[ExpectedSuite, ...], + observations: Tuple[ProfileObservation, ...], + row_evidence: Tuple[RowEvidence, ...], + limits: ProfileMapLimits, + *, + cancel: Optional[Callable[[object], bool]] = None, + cancel_context: object = None, +) -> ProducedMap: + """Produce exact v2 wires or raise, without I/O or a partial result. + + Inputs are immutable exact types. Evidence is supplied by the caller; + parsing cannot establish image/process closure, provenance or admission. + """ + _validate_top(image_sha256, enumeration_text, expected_suites, observations, + row_evidence, limits, cancel) + gate = _Gate(cancel, cancel_context) + gate.poll() + budget = _Budget(limits, enumeration_text) + rows = _preflight(expected_suites, observations, row_evidence, budget, gate) + universe = _parse(enumeration_text, budget, gate, enumeration=True) + for row in rows: + gate.step() + _reduce_row(row, universe, budget, gate) + profiles = _profile_wire(image_sha256, universe, budget, gate) + tests = _test_wire(rows, budget, gate) + profiles_digest = _digest(profiles, gate) + tests_digest = _digest(tests, gate) + gate.poll() + return ProducedMap(profiles, tests, profiles_digest, tests_digest) diff --git a/scripts/test-impact/profile_text_parser.py b/scripts/test-impact/profile_text_parser.py new file mode 100644 index 000000000..f3561d887 --- /dev/null +++ b/scripts/test-impact/profile_text_parser.py @@ -0,0 +1,295 @@ +"""Strict data-only parser for the observed LLVM 22.1.8 frontend proftext. + +parse_profile_text(bytes, ParseLimits, *, cancel=None, cancel_context=None) +returns an immutable tuple of ProfileRecord(raw_name, function_hash, counters), +ordered by unsigned raw-name bytes and then uint64 hash. Zero values survive. + +Each record is a nonempty name line, '# Func Hash:', uint64, '# Num Counters:', +positive uint64, '# Counter Values:', exactly that many uint64 lines, and one +mandatory blank line. All delimiters are LF. Decimals are canonical. Empty +documents, CR/NUL, extra sections and duplicate name/hash tuples fail closed. +Names remain opaque bytes: no normalization, decoding or comment processing. + +Four exact positive integer limits bound input bytes, records, total counter +values and total name bytes. Errors derive from ProfileTextError; MemoryError +and callback exceptions propagate without a partial result. A pure, prompt +cancel(context) predicate must return bool. It is called at entry/final publish +and within explicit loops at intervals of at most 4096 byte/element operations. +Python allocation/resizing/deallocation and callbacks are not preemptible. + +Limits bound logical input and result items, not exact interpreter heap use. +This module performs no I/O and makes no image, authentication or closure claim. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import sys +from typing import Callable + + +_UINT64_MAX = (1 << 64) - 1 +_POLL_INTERVAL = 4096 + + +@dataclass(frozen=True) +class ProfileRecord: + __slots__ = ("raw_name", "function_hash", "counters") + + raw_name: bytes + function_hash: int + counters: tuple[int, ...] + + +@dataclass(frozen=True) +class ParseLimits: + __slots__ = ("max_input_bytes", "max_records", "max_counter_values", "max_name_bytes") + + max_input_bytes: int + max_records: int + max_counter_values: int + max_name_bytes: int + + +class ProfileTextError(ValueError): + """Base for parser failures; no partial result is published.""" + + +class InvalidArgumentError(ProfileTextError): + """Invalid public argument, limit, or callback return type.""" + + +class MalformedProfileError(ProfileTextError): + """Unsupported or malformed text, including duplicate identities.""" + + +class LimitExceededError(ProfileTextError): + """A configured logical resource bound would be exceeded.""" + + +class ParseCancelledError(ProfileTextError): + """The caller's predicate requested cancellation.""" + + +class _Gate: + __slots__ = ("cancel", "context", "work") + + def __init__(self, cancel, context): + self.cancel = cancel + self.context = context + self.work = 0 + + def poll(self): + self.work = 0 + if self.cancel is None: + return + answer = self.cancel(self.context) + if type(answer) is not bool: + raise InvalidArgumentError("cancel must return bool") + if answer: + raise ParseCancelledError("profile parsing cancelled") + + def step(self): + self.work += 1 + if self.work == _POLL_INTERVAL: + self.poll() + + +class _Parser: + __slots__ = ("data", "limits", "gate", "position", "names", "values") + + def __init__(self, data, limits, gate): + self.data = data + self.limits = limits + self.gate = gate + self.position = 0 + self.names = 0 + self.values = 0 + + def line(self): + start = self.position + while self.position < len(self.data): + self.gate.step() + value = self.data[self.position] + self.position += 1 + if value == 0 or value == 13: + raise MalformedProfileError("NUL and CR are unsupported") + if value == 10: + return start, self.position - 1 + raise MalformedProfileError("unterminated or missing line") + + def marker(self, expected): + start, end = self.line() + if end - start != len(expected): + raise MalformedProfileError("unexpected record marker") + for index, value in enumerate(expected): + self.gate.step() + if self.data[start + index] != value: + raise MalformedProfileError("unexpected record marker") + + def uint64(self): + start, end = self.line() + length = end - start + if length == 0 or length > 20: + raise MalformedProfileError("invalid unsigned decimal") + if length > 1 and self.data[start] == 48: + raise MalformedProfileError("noncanonical unsigned decimal") + value = 0 + for index in range(start, end): + self.gate.step() + digit = self.data[index] - 48 + if digit < 0 or digit > 9: + raise MalformedProfileError("invalid unsigned decimal") + if value > (_UINT64_MAX - digit) // 10: + raise MalformedProfileError("unsigned decimal overflow") + value = value * 10 + digit + return value + + def name_values(self, start, end): + for index in range(start, end): + self.gate.step() + yield self.data[index] + + def counter_values(self, count): + for _ in range(count): + self.gate.step() + yield self.uint64() + + def record(self): + start, end = self.line() + length = end - start + if length == 0: + raise MalformedProfileError("empty record name") + if length > self.limits.max_name_bytes - self.names: + raise LimitExceededError("total name bytes limit exceeded") + self.names += length + self.marker(b"# Func Hash:") + function_hash = self.uint64() + self.marker(b"# Num Counters:") + count = self.uint64() + if count == 0: + raise MalformedProfileError("counter count must be positive") + if count > self.limits.max_counter_values - self.values: + raise LimitExceededError("total counter values limit exceeded") + self.values += count + self.marker(b"# Counter Values:") + # Each remaining value needs at least '0\n', then the final blank line. + remaining = len(self.data) - self.position + if remaining < 1 or count > (remaining - 1) // 2: + raise MalformedProfileError("truncated counter values") + self.gate.poll() + counters = tuple(self.counter_values(count)) + self.gate.poll() + blank_start, blank_end = self.line() + if blank_start != blank_end: + raise MalformedProfileError("missing record blank line") + self.gate.poll() + name = bytes(self.name_values(start, end)) + self.gate.poll() + return ProfileRecord(name, function_hash, counters) + + def records(self): + self.gate.poll() + records = [] + while self.position < len(self.data): + self.gate.step() + if len(records) == self.limits.max_records: + raise LimitExceededError("record count limit exceeded") + record = self.record() + self.gate.poll() + records.append(record) + if not records: + raise MalformedProfileError("empty document") + return records + + +def _compare(left, right, gate): + common = min(len(left.raw_name), len(right.raw_name)) + for index in range(common): + gate.step() + a = left.raw_name[index] + b = right.raw_name[index] + if a != b: + return -1 if a < b else 1 + gate.step() + if len(left.raw_name) != len(right.raw_name): + return -1 if len(left.raw_name) < len(right.raw_name) else 1 + if left.function_hash == right.function_hash: + return 0 + return -1 if left.function_hash < right.function_hash else 1 + + +def _merge(source, target, start, middle, end, gate): + left, right = start, middle + for destination in range(start, end): + gate.step() + take_left = right == end or ( + left < middle and _compare(source[left], source[right], gate) <= 0 + ) + if take_left: + target[destination] = source[left] + left += 1 + else: + target[destination] = source[right] + right += 1 + + +def _sorted_unique(records, gate): + gate.poll() + scratch = [] + for _ in records: + gate.step() + scratch.append(None) + gate.poll() + width = 1 + length = len(records) + while width < length: + for start in range(0, length, width * 2): + middle = min(start + width, length) + end = min(start + width * 2, length) + _merge(records, scratch, start, middle, end, gate) + records, scratch = scratch, records + width *= 2 + for index in range(1, length): + gate.step() + if _compare(records[index - 1], records[index], gate) == 0: + raise MalformedProfileError("duplicate name/hash identity") + return records + + +def _record_values(records, gate): + for record in records: + gate.step() + yield record + + +def _validate_arguments(data, limits, cancel): + if type(data) is not bytes or type(limits) is not ParseLimits: + raise InvalidArgumentError("expected bytes and ParseLimits") + for value in (limits.max_input_bytes, limits.max_records, + limits.max_counter_values, limits.max_name_bytes): + if type(value) is not int or value <= 0 or value > sys.maxsize: + raise InvalidArgumentError("limits must be positive bounded integers") + if cancel is not None and not callable(cancel): + raise InvalidArgumentError("cancel must be callable or None") + + +def parse_profile_text( + data: bytes, + limits: ParseLimits, + *, + cancel: Callable[[object], bool] | None = None, + cancel_context: object = None, +) -> tuple[ProfileRecord, ...]: + """Parse all records or raise a specific error; never return a partial result.""" + _validate_arguments(data, limits, cancel) + gate = _Gate(cancel, cancel_context) + gate.poll() + if len(data) > limits.max_input_bytes: + raise LimitExceededError("input bytes limit exceeded") + parser = _Parser(data, limits, gate) + records = _sorted_unique(parser.records(), gate) + gate.poll() + result = tuple(_record_values(records, gate)) + gate.poll() + return result diff --git a/scripts/test.sh b/scripts/test.sh index 81483c115..7bc7f4ea5 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -349,6 +349,9 @@ bash "$ROOT/tests/test_version_metadata_contract.sh" echo "=== Step 0y: VM leg verdict contract ===" bash "$ROOT/tests/test_vm_verdict_contract.sh" +echo "=== Step 0y2: isolated Windows venue contract ===" +bash "$ROOT/tests/test_vm_isolation_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. @@ -362,6 +365,11 @@ 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" +# Step 0z6: the per-test coverage map must never report a test as executing +# less than it did: every case where the builder cannot know (a killed forked +# child, an unreadable profile, a failed suite) has to come out `incomplete`. +echo "=== Step 0z6: per-test coverage map builder contract ===" +bash "$ROOT/tests/test_coverage_map.sh" if [ "$CONTRACTS_ONLY" -eq 1 ]; then echo "=== test.sh: contracts-only — every contract step passed ===" diff --git a/src/discover/discover.c b/src/discover/discover.c index 4595a8b9e..5f4febb0a 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -10,6 +10,7 @@ * 5. Language detection for accepted files */ #include "discover/discover.h" +#include "discover/discover_policy_internal.h" #include "cbm.h" // CBMLanguage, CBM_LANG_COUNT, CBM_LANG_JSON #include "foundation/constants.h" @@ -134,8 +135,7 @@ static bool str_in_list(const char *s, const char *const *list) { /* ── Helper: check if string ends with suffix ────────────── */ -static bool ends_with(const char *s, const char *suffix) { - size_t slen = strlen(s); +static bool ends_with(const char *s, size_t slen, const char *suffix) { size_t sufflen = strlen(suffix); if (sufflen > slen) { return false; @@ -349,7 +349,7 @@ static bool resolve_global_excludes_path(char *out, size_t out_sz) { } /* True when rel_path equals a suffix rule or ends with "/" + the rule. */ -static bool rel_path_has_skip_suffix(const char *rel_path) { +bool cbm_discover_path_has_skip_suffix(const char *rel_path) { if (!rel_path) { return false; } @@ -393,15 +393,16 @@ bool cbm_has_ignored_suffix(const char *filename, cbm_index_mode_t mode) { return false; } + size_t filename_length = strlen(filename); for (int i = 0; ALWAYS_IGNORED_SUFFIXES[i]; i++) { - if (ends_with(filename, ALWAYS_IGNORED_SUFFIXES[i])) { + if (ends_with(filename, filename_length, ALWAYS_IGNORED_SUFFIXES[i])) { return true; } } if (mode != CBM_MODE_FULL) { for (int i = 0; FAST_IGNORED_SUFFIXES[i]; i++) { - if (ends_with(filename, FAST_IGNORED_SUFFIXES[i])) { + if (ends_with(filename, filename_length, FAST_IGNORED_SUFFIXES[i])) { return true; } } @@ -709,7 +710,7 @@ static int gitignore_chain_result(const gitignore_link_t *link, const char *rel_ * (.worktrees / .claude-worktrees, the worktree entries in ALWAYS_SKIP_DIRS) * contain parallel checkouts of the same repo whose indexing would duplicate * the whole codebase (#802). */ -static bool is_safety_core_dir(const char *name) { +bool cbm_discover_is_safety_core_directory(const char *name) { static const char *const SAFETY_CORE_DIRS[] = {".git", "node_modules", ".worktrees", ".claude-worktrees", NULL}; return str_in_list(name, SAFETY_CORE_DIRS); @@ -759,12 +760,12 @@ static bool should_skip_directory(const char *entry_name, const char *rel_path, const cbm_gitignore_t *global_gi, const cbm_gitignore_t *cbmignore) { if (cbm_should_skip_dir(entry_name, opts ? opts->mode : CBM_MODE_FULL) || - rel_path_has_skip_suffix(rel_path)) { + cbm_discover_path_has_skip_suffix(rel_path)) { /* #500: a .cbmignore negation (e.g. "!obj/") whose rule is the last * match for this dir un-skips a built-in skip-list dir — except the * non-negatable safety core. Fall through so .gitignore/global/local * rules still apply to the un-skipped dir. */ - bool unskipped = cbmignore && !is_safety_core_dir(entry_name) && + bool unskipped = cbmignore && !cbm_discover_is_safety_core_directory(entry_name) && cbm_gitignore_match_result(cbmignore, rel_path, true) < 0; if (!unskipped) { return true; diff --git a/src/discover/discover_policy_internal.h b/src/discover/discover_policy_internal.h new file mode 100644 index 000000000..09068ec41 --- /dev/null +++ b/src/discover/discover_policy_internal.h @@ -0,0 +1,9 @@ +#ifndef CBM_DISCOVER_POLICY_INTERNAL_H +#define CBM_DISCOVER_POLICY_INTERNAL_H +#include + +/* Pure legacy policy predicates; callers provide live terminated strings. */ +bool cbm_discover_path_has_skip_suffix(const char *relative_path); +bool cbm_discover_is_safety_core_directory(const char *name); + +#endif diff --git a/src/discover/gitignore.c b/src/discover/gitignore.c index 1da8814c6..0d0f74276 100644 --- a/src/discover/gitignore.c +++ b/src/discover/gitignore.c @@ -13,8 +13,9 @@ */ #include "foundation/constants.h" #include "foundation/compat_fs.h" +#include "discover/gitignore_internal.h" -enum { GI_INIT_CAP = 16, GI_CHAR_IDX1 = 1, GI_CHAR_IDX2 = 2, GI_SKIP3 = 3 }; +enum { GI_INIT_CAP = 16 }; #include "discover/discover.h" #include @@ -24,236 +25,22 @@ enum { GI_INIT_CAP = 16, GI_CHAR_IDX1 = 1, GI_CHAR_IDX2 = 2, GI_SKIP3 = 3 }; /* ── Pattern representation ──────────────────────────────────────── */ -typedef struct { - char *pattern; /* the glob pattern (normalized) */ - bool negated; /* starts with ! */ - bool dir_only; /* ends with / */ - bool rooted; /* contains / (anchored to root) */ -} gi_pattern_t; - struct cbm_gitignore { gi_pattern_t *patterns; int count; int capacity; }; -/* ── Pattern matching engine ─────────────────────────────────────── */ - -/* Backtracking budget. `**` retries the remainder at every position, and - * consecutive literal + `**` groups multiply, so cost is exponential in the - * number of groups. Patterns come from a committed .gitignore / .cbmignore and - * every discovered path is matched against every pattern, so a small ignore file - * can make discovery take unbounded time. Cap the match steps per - * (pattern, path) pair. - * - * Exhausting the budget reports "no match", so a pathological pattern fails to - * ignore rather than ignoring the wrong files: the file gets indexed, which is - * the recoverable direction. The cap is far above any real pattern — ordinary - * globs finish in tens of steps. */ -enum { GI_MATCH_MAX_STEPS = 20000 }; - -/* Forward declaration for recursive calls. */ -static bool glob_match(const char *pat, const char *str, - int *budget); // NOLINT(misc-no-recursion) - -/* Match a ** (doublestar-slash) pattern: try rest at every / boundary. */ -static bool glob_match_doublestar_slash(const char *pat, // NOLINT(misc-no-recursion) - const char *str, int *budget) { - if (glob_match(pat, str, budget)) { - return true; - } - for (const char *s = str; *s; s++) { - if (*s == '/' && glob_match(pat, s + SKIP_ONE, budget)) { - return true; - } - } - return false; -} - -/* Match a ** (doublestar) followed by non-slash: try at every position. */ -static bool glob_match_doublestar_any(const char *pat, // NOLINT(misc-no-recursion) - const char *str, int *budget) { - for (const char *s = str;; s++) { - if (glob_match(pat, s, budget)) { - return true; - } - if (!*s) { - return false; - } - } -} - -/* Match a * (single star): match any sequence not containing /. */ -static bool glob_match_star(const char *pat, const char *str, - int *budget) { // NOLINT(misc-no-recursion) - for (const char *s = str;; s++) { - if (glob_match(pat, s, budget)) { - return true; - } - if (!*s || *s == '/') { - return false; - } - } -} - -/* Match a [...] character class at current position. - * Returns true if matched. Advances *pat_out past the closing ']'. */ -static bool glob_match_charclass(const char *pat, char ch, const char **pat_out) { - bool negate_class = false; - if (*pat == '!' || *pat == '^') { - negate_class = true; - pat++; - } - bool matched = false; - char prev = 0; - while (*pat && *pat != ']') { - if (*pat == '-' && prev && pat[GI_CHAR_IDX1] && pat[GI_CHAR_IDX1] != ']') { - pat++; - if (ch >= prev && ch <= *pat) { - matched = true; - } - prev = *pat; - pat++; - } else { - if (ch == *pat) { - matched = true; - } - prev = *pat; - pat++; - } - } - if (*pat == ']') { - pat++; - } - *pat_out = pat; - return negate_class ? !matched : matched; -} - -/* - * Match a glob pattern against a string. - * Handles: * (non-slash), ** (any path), ? (single non-slash), [class] - */ -/* Handle ** at current position. Returns match result. */ -static bool glob_match_doublestar(const char *pat, const char *str, - int *budget) { // NOLINT(misc-no-recursion) - if (pat[GI_CHAR_IDX2] == '/') { - return glob_match_doublestar_slash(pat + GI_SKIP3, str, budget); - } - if (pat[GI_CHAR_IDX2] == '\0') { - return true; - } - return glob_match_doublestar_any(pat + GI_CHAR_IDX2, str, budget); -} - -static bool glob_match(const char *pat, const char *str, - int *budget) { // NOLINT(misc-no-recursion) - if (--*budget <= 0) { - return false; /* budget exhausted — see GI_MATCH_MAX_STEPS */ - } - while (*pat && *str) { - if (pat[0] == '*' && pat[GI_CHAR_IDX1] == '*') { - return glob_match_doublestar(pat, str, budget); - } - - if (*pat == '*') { - return glob_match_star(pat + SKIP_ONE, str, budget); - } - - if (*pat == '?') { - if (*str == '/') { - return false; - } - pat++; - str++; - continue; - } - - if (*pat == '[') { - const char *new_pat = NULL; - if (!glob_match_charclass(pat + SKIP_ONE, *str, &new_pat)) { - return false; - } - pat = new_pat; - str++; - continue; - } - - if (*pat != *str) { - return false; - } - pat++; - str++; - } - - while (*pat == '*') { - pat++; - } - return *pat == '\0' && *str == '\0'; -} - -/* Match one pattern against one path with a fresh backtracking budget. Not - * recursive itself — it seeds the budget and hands off to the engine. */ -static bool glob_match_bounded(const char *pat, const char *str) { - int budget = GI_MATCH_MAX_STEPS; - return glob_match(pat, str, &budget); -} - /* ── Pattern parsing ─────────────────────────────────────────────── */ static void gi_add_pattern(cbm_gitignore_t *gi, const char *line, int len) { - /* Trim trailing whitespace */ - while (len > 0 && (line[len - SKIP_ONE] == ' ' || line[len - SKIP_ONE] == '\t' || - line[len - SKIP_ONE] == '\r')) { - len--; - } - if (len == 0) { + gi_view_t view; + if (!gi_normalize(line, (size_t)len, &view, NULL) || !view.length) { return; } - - gi_pattern_t p = {0}; - - /* Check for negation */ - const char *start = line; - if (*start == '!') { - p.negated = true; - start++; - len--; - } - - if (len == 0) { - return; - } - - /* Check for trailing / (directory-only) */ - if (start[len - SKIP_ONE] == '/') { - p.dir_only = true; - len--; - } - - if (len == 0) { - return; - } - - /* Check for leading / (rooted) */ - if (*start == '/') { - p.rooted = true; - start++; - len--; - } - - if (len == 0) { - return; - } - - /* Check if pattern contains / anywhere (makes it rooted) */ - if (!p.rooted) { - for (int i = 0; i < len; i++) { - if (start[i] == '/') { - p.rooted = true; - break; - } - } - } + gi_pattern_t p = {.negated = view.negated, .dir_only = view.dir_only, .rooted = view.rooted}; + const char *start = view.text; + len = (int)view.length; /* Copy pattern */ p.pattern = malloc(len + SKIP_ONE); @@ -345,56 +132,11 @@ cbm_gitignore_t *cbm_gitignore_load(const char *path) { return gi; } -/* Match a non-rooted pattern against basename and path suffixes. */ -static bool match_unrooted(const char *pattern, const char *rel_path, const char *basename) { - if (glob_match_bounded(pattern, basename)) { - return true; - } - if (!strchr(rel_path, '/')) { - return false; - } - /* Try matching at every / boundary */ - const char *s = rel_path; - while (*s) { - if (glob_match_bounded(pattern, s)) { - return true; - } - const char *next = strchr(s, '/'); - if (!next) { - break; - } - s = next + SKIP_ONE; - } - return false; -} - int cbm_gitignore_match_result(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir) { if (!gi || !rel_path) { return 0; } - - /* Extract the basename for non-rooted pattern matching */ - const char *basename = strrchr(rel_path, '/'); - basename = basename ? basename + SKIP_ONE : rel_path; - - int matched = 0; - - for (int i = 0; i < gi->count; i++) { - const gi_pattern_t *p = &gi->patterns[i]; - - if (p->dir_only && !is_dir) { - continue; - } - - bool this_match = p->rooted ? glob_match_bounded(p->pattern, rel_path) - : match_unrooted(p->pattern, rel_path, basename); - - if (this_match) { - matched = p->negated ? -1 : 1; - } - } - - return matched; + return gi_core_match(gi->patterns, (size_t)gi->count, rel_path, is_dir, NULL); } bool cbm_gitignore_matches(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir) { diff --git a/src/discover/gitignore_checked.c b/src/discover/gitignore_checked.c new file mode 100644 index 000000000..59c16cdce --- /dev/null +++ b/src/discover/gitignore_checked.c @@ -0,0 +1,114 @@ +#include "discover/gitignore_internal.h" +#include +#include + +static bool gi_limits_valid(const cbm_ignore_checked_limits_t *limits) { + return limits && limits->max_bytes && limits->max_work && limits->max_patterns && + limits->max_patterns <= INT_MAX && limits->max_arena_bytes && limits->max_depth && + limits->max_depth <= CBM_IGNORE_CHECKED_DEPTH_MAX; +} + +static cbm_ignore_checked_status_t gi_open(const cbm_ignore_checked_limits_t *limits, + cbm_ignore_checked_t **out, + cbm_ignore_checked_error_t *error, bool fault) { + if (out) { + *out = NULL; + } + gi_error(error, CBM_IGNORE_CHECKED_OK, CBM_IGNORE_CAP_NONE, SIZE_MAX); + if (!out || !gi_limits_valid(limits)) { + gi_error(error, CBM_IGNORE_CHECKED_INVALID, CBM_IGNORE_CAP_NONE, SIZE_MAX); + return CBM_IGNORE_CHECKED_INVALID; + } + if (limits->max_arena_bytes < sizeof(cbm_ignore_checked_t)) { + gi_error(error, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_ARENA, SIZE_MAX); + return CBM_IGNORE_CHECKED_LIMIT; + } + CBMArena arena; + cbm_arena_init_lazy(&arena, CBM_ARENA_DEFAULT_BLOCK_SIZE); + cbm_ignore_checked_t *owner = fault ? NULL : cbm_arena_alloc(&arena, sizeof(*owner)); + if (!owner) { + cbm_arena_destroy(&arena); + gi_error(error, CBM_IGNORE_CHECKED_OOM, CBM_IGNORE_CAP_NONE, SIZE_MAX); + return CBM_IGNORE_CHECKED_OOM; + } + memset(owner, 0, sizeof(*owner)); + owner->arena = arena; + owner->limits = *limits; + owner->usage.arena_requested_bytes = sizeof(*owner); + gi_error(&owner->error, CBM_IGNORE_CHECKED_OK, CBM_IGNORE_CAP_NONE, SIZE_MAX); + *out = owner; + return CBM_IGNORE_CHECKED_OK; +} + +cbm_ignore_checked_status_t cbm_ignore_checked_open(const cbm_ignore_checked_limits_t *limits, + cbm_ignore_checked_t **out, + cbm_ignore_checked_error_t *error) { + return gi_open(limits, out, error, false); +} + +static bool gi_path_valid(gi_guard_t *guard, const char *path, size_t length) { + for (size_t i = 0; i <= length; i++) { + guard->offset = i; + if (!gi_work(guard, 1)) { + return false; + } + if ((path[i] == '\0') != (i == length)) { + return gi_fail(guard, CBM_IGNORE_CHECKED_UNSUPPORTED, CBM_IGNORE_CAP_NONE); + } + } + guard->offset = SIZE_MAX; + return true; +} + +cbm_ignore_checked_status_t cbm_ignore_checked_match( + cbm_ignore_checked_t *owner, const cbm_ignore_program_t *program, const char *path, + size_t length, bool is_directory, const cbm_ignore_checked_control_t *control, + cbm_ignore_decision_t *decision, cbm_ignore_checked_error_t *error) { + if (decision) { + *decision = CBM_IGNORE_UNAVAILABLE; + } + gi_error(error, CBM_IGNORE_CHECKED_OK, CBM_IGNORE_CAP_NONE, SIZE_MAX); + if (!owner || !program || !path || length == SIZE_MAX || !control || !control->deadline_ms || + !decision || program->owner != owner) { + gi_error(error, CBM_IGNORE_CHECKED_INVALID, CBM_IGNORE_CAP_NONE, SIZE_MAX); + return CBM_IGNORE_CHECKED_INVALID; + } + gi_guard_t guard; + if (!gi_begin(owner, control, &guard) || !gi_bytes(&guard, length + 1) || + !gi_path_valid(&guard, path, length)) { + return gi_finish(owner, error); + } + int result = gi_core_match(program->rows, program->count, path, is_directory, &guard); + if (gi_poll(&guard)) { + *decision = (cbm_ignore_decision_t)result; + } + return gi_finish(owner, error); +} + +bool cbm_ignore_checked_usage(const cbm_ignore_checked_t *owner, cbm_ignore_checked_usage_t *out) { + if (!out) { + return false; + } + memset(out, 0, sizeof(*out)); + if (!owner) { + return false; + } + *out = owner->usage; + return true; +} + +void cbm_ignore_checked_free(cbm_ignore_checked_t *owner) { + if (owner) { + /* The owner itself lives in the arena being destroyed. */ + CBMArena arena = owner->arena; + cbm_arena_destroy(&arena); + } +} + +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +cbm_ignore_checked_status_t cbm_ignore_checked_open_oom_for_tests( + const cbm_ignore_checked_limits_t *limits, cbm_ignore_checked_t **out, + cbm_ignore_checked_error_t *error) { + return gi_open(limits, out, error, true); +} +#endif diff --git a/src/discover/gitignore_checked.h b/src/discover/gitignore_checked.h new file mode 100644 index 000000000..0ac945ef5 --- /dev/null +++ b/src/discover/gitignore_checked.h @@ -0,0 +1,137 @@ +/* Checked in-memory ignore parsing and matching; no filesystem/MCP dependency. */ +#ifndef CBM_GITIGNORE_CHECKED_H +#define CBM_GITIGNORE_CHECKED_H + +#include +#include +#include + +typedef struct cbm_ignore_checked cbm_ignore_checked_t; +typedef struct cbm_ignore_program cbm_ignore_program_t; + +typedef enum { + CBM_IGNORE_CHECKED_OK = 0, + CBM_IGNORE_CHECKED_INVALID, + CBM_IGNORE_CHECKED_UNSUPPORTED, + CBM_IGNORE_CHECKED_LIMIT, + CBM_IGNORE_CHECKED_OOM, + CBM_IGNORE_CHECKED_CANCELLED, + CBM_IGNORE_CHECKED_DEADLINE +} cbm_ignore_checked_status_t; + +typedef enum { + CBM_IGNORE_CAP_NONE = 0, + CBM_IGNORE_CAP_BYTES, + CBM_IGNORE_CAP_PATTERNS, + CBM_IGNORE_CAP_WORK, + CBM_IGNORE_CAP_ARENA, + CBM_IGNORE_CAP_DEPTH, + CBM_IGNORE_CAP_ATTEMPT, + CBM_IGNORE_CAP_REPRESENTATION +} cbm_ignore_cap_t; + +typedef enum { + CBM_IGNORE_REINCLUDED = -1, + CBM_IGNORE_NO_OPINION = 0, + CBM_IGNORE_IGNORED = 1, + CBM_IGNORE_UNAVAILABLE = 2 /* every failure clears the decision to this */ +} cbm_ignore_decision_t; + +enum { CBM_IGNORE_CHECKED_DEPTH_MAX = 32 }; + +typedef struct { + uint64_t max_bytes; /* aggregate parse lengths and match path lengths + NUL */ + uint64_t max_work; /* aggregate charged work, including parsing */ + size_t max_patterns; /* cumulative reservations; positive, <= INT_MAX */ + size_t max_arena_bytes; /* cumulative logical requests, not resident memory */ + unsigned max_depth; /* 1..CBM_IGNORE_CHECKED_DEPTH_MAX active glob entries */ +} cbm_ignore_checked_limits_t; + +typedef struct { + uint64_t deadline_ms; /* positive absolute cbm_now_ms deadline */ + bool (*cancelled)(void *context); /* optional prompt, pure, non-reentrant */ + void *context; /* borrowed for this synchronous call only */ +} cbm_ignore_checked_control_t; + +typedef struct { + cbm_ignore_checked_status_t status; + cbm_ignore_cap_t cap; /* LIMIT only; otherwise NONE */ + size_t byte_offset; /* input offset when known, otherwise SIZE_MAX */ + char diagnostic[128]; /* bounded owned text, never input contents */ +} cbm_ignore_checked_error_t; + +typedef struct { + uint64_t bytes_reserved; + uint64_t work_used; + size_t patterns_reserved; + size_t arena_requested_bytes; + cbm_ignore_checked_status_t terminal_status; +} cbm_ignore_checked_usage_t; + +typedef struct { + const cbm_ignore_program_t *program; /* borrowed until owner free */ + size_t pattern_count; /* zero is successful empty/comment-only normalization */ +} cbm_ignore_checked_parse_result_t; + +/* All caps positive; copies limits. Initializes optional error and clears *out. + * No callbacks/IO. Failure publishes no owner. Uses a CBMArena, including owner. + */ +cbm_ignore_checked_status_t cbm_ignore_checked_open(const cbm_ignore_checked_limits_t *limits, + cbm_ignore_checked_t **out, + cbm_ignore_checked_error_t *error); + +/* Exact span: NULL permitted only for length==0; terminator not required. + * Embedded NUL => UNSUPPORTED, never truncation. Result is required/cleared. + * Copies normalized patterns; caller may discard bytes after return. + * Every started failure is terminal for owner; earlier programs remain allocated + * but no further parse/match can succeed. No partial program is published. + */ +cbm_ignore_checked_status_t cbm_ignore_checked_parse(cbm_ignore_checked_t *owner, const void *bytes, + size_t length, + const cbm_ignore_checked_control_t *control, + cbm_ignore_checked_parse_result_t *out, + cbm_ignore_checked_error_t *error); + +/* program must belong to this live owner. path is readable for length+1 bytes, + * with path[length]==0 and no earlier NUL. Empty path is permitted. No decoding + * or path normalization. Inputs borrowed for this call; no match allocation. + * Required decision is initialized to UNAVAILABLE and set only on full success. + */ +cbm_ignore_checked_status_t cbm_ignore_checked_match( + cbm_ignore_checked_t *owner, const cbm_ignore_program_t *program, const char *path, + size_t length, bool is_directory, const cbm_ignore_checked_control_t *control, + cbm_ignore_decision_t *decision, cbm_ignore_checked_error_t *error); + +/* Read-only; clears output, false for NULL owner/output. Does not consume quotas, + * poll or clear a terminal failure. No operation may overlap any other call. + */ +bool cbm_ignore_checked_usage(const cbm_ignore_checked_t *owner, cbm_ignore_checked_usage_t *out); +/* NULL-safe; releases all programs/arena at once. No callback/IO or cancellation. */ +void cbm_ignore_checked_free(cbm_ignore_checked_t *owner); + +/* Test-only per-call allocation failure seams. + * No persistent/global fault, raw allocator replacement or production option. + */ +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +typedef enum { + CBM_IGNORE_PARSE_FAULT_NONE = 0, + CBM_IGNORE_PARSE_FAULT_PROGRAM, + CBM_IGNORE_PARSE_FAULT_PATTERN +} cbm_ignore_parse_fault_t; + +/* Simulate NULL at the owner's first arena allocation, after normal quota checks. */ +cbm_ignore_checked_status_t cbm_ignore_checked_open_oom_for_tests( + const cbm_ignore_checked_limits_t *limits, cbm_ignore_checked_t **out, + cbm_ignore_checked_error_t *error); + +/* PROGRAM: program/header+row-storage allocation. PATTERN: first effective + * pattern-string allocation. Unreached boundary has no effect. Same normal + * implementation/error path; no extra allocations or changed matching logic. + */ +cbm_ignore_checked_status_t cbm_ignore_checked_parse_fault_for_tests( + cbm_ignore_checked_t *owner, const void *bytes, size_t length, + const cbm_ignore_checked_control_t *control, cbm_ignore_parse_fault_t fault, + cbm_ignore_checked_parse_result_t *out, cbm_ignore_checked_error_t *error); +#endif + +#endif diff --git a/src/discover/gitignore_checked_parse.c b/src/discover/gitignore_checked_parse.c new file mode 100644 index 000000000..1f558a15f --- /dev/null +++ b/src/discover/gitignore_checked_parse.c @@ -0,0 +1,143 @@ +#include "discover/gitignore_internal.h" +#include + +typedef struct { + gi_guard_t *guard; + const char *bytes; + size_t length; + cbm_ignore_program_t *program; + size_t count; + unsigned fault; +} gi_parse_t; + +static bool gi_line(gi_parse_t *parse, size_t *cursor, gi_view_t *view) { + size_t start = *cursor; + while (*cursor < parse->length) { + parse->guard->offset = *cursor; + /* Both exact-byte validation and line splitting visit this byte. */ + if (!gi_work(parse->guard, 2)) { + return false; + } + char byte = parse->bytes[*cursor]; + if (!byte) { + return gi_fail(parse->guard, CBM_IGNORE_CHECKED_UNSUPPORTED, CBM_IGNORE_CAP_NONE); + } + if (byte == '\n') { + break; + } + (*cursor)++; + if (*cursor - start > INT_MAX) { + return gi_fail(parse->guard, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_REPRESENTATION); + } + } + size_t length = *cursor - start; + if (*cursor < parse->length) { + (*cursor)++; + } + parse->guard->offset = start; + return gi_normalize(parse->bytes + start, length, view, parse->guard); +} + +static bool gi_store_pattern(gi_parse_t *parse, const gi_view_t *view) { + bool fault = parse->fault == GI_FAULT_PATTERN && parse->count == 0; + char *text = gi_allocate(parse->guard, view->length + 1, fault); + if (!text) { + return false; + } + for (size_t i = 0; i <= view->length; i++) { + if (!gi_work(parse->guard, 1)) { + return false; + } + text[i] = i == view->length ? '\0' : view->text[i]; + } + gi_pattern_t row = {.pattern = text, + .negated = view->negated, + .dir_only = view->dir_only, + .rooted = view->rooted}; + parse->program->rows[parse->count++] = row; + return true; +} + +static bool gi_parse_pass(gi_parse_t *parse) { + size_t cursor = 0; + while (cursor < parse->length) { + gi_view_t view; + if (!gi_line(parse, &cursor, &view)) { + return false; + } + if (!view.length) { + continue; + } + if (parse->program) { + if (!gi_store_pattern(parse, &view)) { + return false; + } + } else { + if (!gi_pattern_reserve(parse->guard)) { + return false; + } + parse->count++; + } + } + return true; +} + +static bool gi_parse_storage(gi_parse_t *parse) { + size_t count = parse->count; + if (count > (SIZE_MAX - sizeof(cbm_ignore_program_t)) / sizeof(gi_pattern_t)) { + return gi_fail(parse->guard, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_ARENA); + } + size_t bytes = sizeof(cbm_ignore_program_t) + count * sizeof(gi_pattern_t); + parse->program = gi_allocate(parse->guard, bytes, parse->fault == GI_FAULT_PROGRAM); + if (!parse->program || !gi_zero(parse->guard, parse->program, bytes)) { + return false; + } + parse->program->owner = parse->guard->owner; + parse->program->count = count; + parse->count = 0; + return true; +} + +static cbm_ignore_checked_status_t gi_parse(cbm_ignore_checked_t *owner, const void *bytes, + size_t length, + const cbm_ignore_checked_control_t *control, + unsigned fault, cbm_ignore_checked_parse_result_t *out, + cbm_ignore_checked_error_t *error) { + if (out) { + *out = (cbm_ignore_checked_parse_result_t){0}; + } + gi_error(error, CBM_IGNORE_CHECKED_OK, CBM_IGNORE_CAP_NONE, SIZE_MAX); + if (!owner || (!bytes && length) || !control || !control->deadline_ms || !out || + fault > GI_FAULT_PATTERN) { + gi_error(error, CBM_IGNORE_CHECKED_INVALID, CBM_IGNORE_CAP_NONE, SIZE_MAX); + return CBM_IGNORE_CHECKED_INVALID; + } + gi_guard_t guard; + if (!gi_begin(owner, control, &guard) || !gi_bytes(&guard, length)) { + return gi_finish(owner, error); + } + gi_parse_t parse = {.guard = &guard, .bytes = bytes, .length = length, .fault = fault}; + if (gi_parse_pass(&parse) && gi_parse_storage(&parse) && gi_parse_pass(&parse) && + gi_poll(&guard)) { + out->program = parse.program; + out->pattern_count = parse.count; + } + return gi_finish(owner, error); +} + +cbm_ignore_checked_status_t cbm_ignore_checked_parse(cbm_ignore_checked_t *owner, const void *bytes, + size_t length, + const cbm_ignore_checked_control_t *control, + cbm_ignore_checked_parse_result_t *out, + cbm_ignore_checked_error_t *error) { + return gi_parse(owner, bytes, length, control, GI_FAULT_NONE, out, error); +} + +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +cbm_ignore_checked_status_t cbm_ignore_checked_parse_fault_for_tests( + cbm_ignore_checked_t *owner, const void *bytes, size_t length, + const cbm_ignore_checked_control_t *control, cbm_ignore_parse_fault_t fault, + cbm_ignore_checked_parse_result_t *out, cbm_ignore_checked_error_t *error) { + return gi_parse(owner, bytes, length, control, (unsigned)fault, out, error); +} +#endif diff --git a/src/discover/gitignore_core.c b/src/discover/gitignore_core.c new file mode 100644 index 000000000..408c831f1 --- /dev/null +++ b/src/discover/gitignore_core.c @@ -0,0 +1,243 @@ +#include "discover/gitignore_internal.h" + +/* These recursive functions and annotations move unchanged in identity from + * gitignore.c. A NULL guard preserves the legacy budget/fallback behavior. */ +static bool glob_match(const char *pat, const char *str, int *budget, + gi_guard_t *guard); // NOLINT(misc-no-recursion) + +static bool glob_match_doublestar_slash(const char *pat, const char *str, int *budget, + gi_guard_t *guard) { // NOLINT(misc-no-recursion) + if (glob_match(pat, str, budget, guard)) { + return true; + } + for (const char *s = str;; s++) { + if (!gi_work(guard, 1) || !*s) { + return false; + } + if (*s == '/' && glob_match(pat, s + 1, budget, guard)) { + return true; + } + } +} + +static bool glob_match_doublestar_any(const char *pat, const char *str, int *budget, + gi_guard_t *guard) { // NOLINT(misc-no-recursion) + for (const char *s = str;; s++) { + if (glob_match(pat, s, budget, guard)) { + return true; + } + if (!gi_work(guard, 1) || !*s) { + return false; + } + } +} + +static bool glob_match_star(const char *pat, const char *str, int *budget, + gi_guard_t *guard) { // NOLINT(misc-no-recursion) + for (const char *s = str;; s++) { + if (glob_match(pat, s, budget, guard)) { + return true; + } + if (!gi_work(guard, 1) || !*s || *s == '/') { + return false; + } + } +} + +static bool glob_match_charclass(const char *pat, char ch, const char **pat_out, + gi_guard_t *guard) { + bool negate_class = false; + if (*pat == '!' || *pat == '^') { + negate_class = true; + pat++; + } + bool matched = false; + char prev = 0; + while (*pat && *pat != ']') { + if (!gi_work(guard, 1)) { + return false; + } + if (*pat == '-' && prev && pat[1] && pat[1] != ']') { + pat++; + if (!gi_work(guard, 1)) { + return false; + } + if (ch >= prev && ch <= *pat) { + matched = true; + } + prev = *pat; + pat++; + } else { + if (ch == *pat) { + matched = true; + } + prev = *pat; + pat++; + } + } + if (*pat == ']') { + pat++; + } + *pat_out = pat; + return negate_class ? !matched : matched; +} + +static bool glob_match_doublestar(const char *pat, const char *str, int *budget, + gi_guard_t *guard) { // NOLINT(misc-no-recursion) + if (pat[2] == '/') { + return glob_match_doublestar_slash(pat + 3, str, budget, guard); + } + if (pat[2] == '\0') { + return true; + } + return glob_match_doublestar_any(pat + 2, str, budget, guard); +} + +static bool glob_match(const char *pat, const char *str, int *budget, + gi_guard_t *guard) { // NOLINT(misc-no-recursion) + if (!gi_work(guard, 1)) { + return false; + } + if (--*budget <= 0) { + return gi_fail(guard, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_ATTEMPT); + } + if (guard) { + if (guard->depth == guard->owner->limits.max_depth) { + return gi_fail(guard, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_DEPTH); + } + guard->depth++; + } + bool matched = false; + while (*pat && *str) { + if (!gi_work(guard, 1)) { + goto done; + } + if (pat[0] == '*' && pat[1] == '*') { + matched = glob_match_doublestar(pat, str, budget, guard); + goto done; + } + if (*pat == '*') { + matched = glob_match_star(pat + 1, str, budget, guard); + goto done; + } + if (*pat == '?') { + if (*str == '/') { + goto done; + } + pat++; + str++; + continue; + } + if (*pat == '[') { + const char *next = NULL; + if (*str == '/' || !glob_match_charclass(pat + 1, *str, &next, guard)) { + goto done; + } + pat = next; + str++; + continue; + } + if (*pat != *str) { + goto done; + } + pat++; + str++; + } + while (*pat == '*') { + if (!gi_work(guard, 1)) { + goto done; + } + pat++; + } + matched = *pat == '\0' && *str == '\0'; +done: + if (guard) { + guard->depth--; + } + return matched; +} + +static bool glob_match_bounded(const char *pat, const char *str, gi_guard_t *guard) { + int budget = GI_MATCH_MAX_STEPS; + return gi_work(guard, 1) && glob_match(pat, str, &budget, guard); +} + +static const char *gi_slash(const char *path, gi_guard_t *guard) { + for (;;) { + if (!gi_work(guard, 1)) { + return NULL; + } + if (*path == '/') { + return path; + } + if (!*path) { + return NULL; + } + path++; + } +} + +static const char *gi_basename(const char *path, gi_guard_t *guard) { + const char *basename = path; + for (;;) { + if (!gi_work(guard, 1)) { + return NULL; + } + if (!*path) { + return basename; + } + if (*path == '/') { + basename = path + 1; + } + path++; + } +} + +static bool match_unrooted(const char *pattern, const char *path, const char *basename, + gi_guard_t *guard) { + if (glob_match_bounded(pattern, basename, guard)) { + return true; + } + if (!gi_slash(path, guard)) { + return false; + } + const char *suffix = path; + while (*suffix) { + if (glob_match_bounded(pattern, suffix, guard)) { + return true; + } + const char *next = gi_slash(suffix, guard); + if (!next) { + break; + } + suffix = next + 1; + } + return false; +} + +int gi_core_match(const gi_pattern_t *rows, size_t count, const char *path, bool is_directory, + gi_guard_t *guard) { + const char *basename = gi_basename(path, guard); + if (!basename) { + return 0; + } + int matched = 0; + for (size_t i = 0; i < count; i++) { + if (!gi_work(guard, 1)) { + return 0; + } + const gi_pattern_t *pattern = &rows[i]; + if (pattern->dir_only && !is_directory) { + continue; + } + bool this_match = pattern->rooted ? glob_match_bounded(pattern->pattern, path, guard) + : match_unrooted(pattern->pattern, path, basename, guard); + if (!gi_active(guard)) { + return 0; + } + if (this_match) { + matched = pattern->negated ? -1 : 1; + } + } + return matched; +} diff --git a/src/discover/gitignore_guard.c b/src/discover/gitignore_guard.c new file mode 100644 index 000000000..fc8ca5336 --- /dev/null +++ b/src/discover/gitignore_guard.c @@ -0,0 +1,209 @@ +#include "discover/gitignore_internal.h" +#include "foundation/platform.h" +#include + +void gi_error(cbm_ignore_checked_error_t *error, cbm_ignore_checked_status_t status, + cbm_ignore_cap_t cap, size_t offset) { + if (!error) { + return; + } + memset(error, 0, sizeof(*error)); + error->status = status; + error->cap = cap; + error->byte_offset = offset; + if (status != CBM_IGNORE_CHECKED_OK) { + static const char message[] = "checked ignore operation failed"; + memcpy(error->diagnostic, message, sizeof(message)); + } +} + +bool gi_active(const gi_guard_t *guard) { + return !guard || guard->owner->usage.terminal_status == CBM_IGNORE_CHECKED_OK; +} + +bool gi_fail(gi_guard_t *guard, cbm_ignore_checked_status_t status, cbm_ignore_cap_t cap) { + if (guard && gi_active(guard)) { + guard->owner->usage.terminal_status = status; + gi_error(&guard->owner->error, status, cap, guard->offset); + } + return false; +} + +bool gi_poll(gi_guard_t *guard) { + if (!gi_active(guard)) { + return false; + } + if (!guard) { + return true; + } + const cbm_ignore_checked_control_t *control = guard->control; + if (control->cancelled && control->cancelled(control->context)) { + return gi_fail(guard, CBM_IGNORE_CHECKED_CANCELLED, CBM_IGNORE_CAP_NONE); + } + if (cbm_now_ms() >= control->deadline_ms) { + return gi_fail(guard, CBM_IGNORE_CHECKED_DEADLINE, CBM_IGNORE_CAP_NONE); + } + guard->ticks = 0; + return true; +} + +bool gi_work(gi_guard_t *guard, size_t units) { + if (!gi_active(guard)) { + return false; + } + if (!guard) { + return true; + } + cbm_ignore_checked_t *owner = guard->owner; + if (units > owner->limits.max_work - owner->usage.work_used) { + return gi_fail(guard, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_WORK); + } + /* All callers submit a constant-size event or a bounded memory chunk. */ + while (units) { + size_t part = GI_POLL_UNITS - guard->ticks; + if (part > units) { + part = units; + } + owner->usage.work_used += part; + guard->ticks += (unsigned)part; + units -= part; + if (guard->ticks == GI_POLL_UNITS && !gi_poll(guard)) { + return false; + } + } + return true; +} + +bool gi_bytes(gi_guard_t *guard, size_t bytes) { + cbm_ignore_checked_t *owner = guard->owner; + if (bytes > owner->limits.max_bytes - owner->usage.bytes_reserved) { + return gi_fail(guard, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_BYTES); + } + owner->usage.bytes_reserved += bytes; + return true; +} + +bool gi_pattern_reserve(gi_guard_t *guard) { + cbm_ignore_checked_t *owner = guard->owner; + if (!gi_work(guard, 1)) { + return false; + } + if (owner->usage.patterns_reserved == owner->limits.max_patterns) { + return gi_fail(guard, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_PATTERNS); + } + owner->usage.patterns_reserved++; + return true; +} + +bool gi_begin(cbm_ignore_checked_t *owner, const cbm_ignore_checked_control_t *control, + gi_guard_t *guard) { + *guard = (gi_guard_t){.owner = owner, .control = control, .offset = SIZE_MAX}; + return gi_poll(guard) && gi_work(guard, 1); +} + +cbm_ignore_checked_status_t gi_finish(const cbm_ignore_checked_t *owner, + cbm_ignore_checked_error_t *error) { + if (error) { + *error = owner->error; + } + return owner->usage.terminal_status; +} + +void *gi_allocate(gi_guard_t *guard, size_t bytes, bool simulate_oom) { + cbm_ignore_checked_t *owner = guard->owner; + CBMArena *arena = &owner->arena; + if (!gi_poll(guard)) { + return NULL; + } + if (bytes > owner->limits.max_arena_bytes - owner->usage.arena_requested_bytes || + bytes > (SIZE_MAX / 2) - GI_ARENA_ALIGN) { + gi_fail(guard, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_ARENA); + return NULL; + } + size_t aligned = (bytes + GI_ARENA_ALIGN) & ~(size_t)GI_ARENA_ALIGN; + if (aligned > SIZE_MAX - arena->used || aligned > SIZE_MAX - arena->total_alloc) { + gi_fail(guard, CBM_IGNORE_CHECKED_LIMIT, CBM_IGNORE_CAP_ARENA); + return NULL; + } + owner->usage.arena_requested_bytes += bytes; + /* Keep the arena's doubling arithmetic representable; no allocator changes. */ + if (arena->grow_size > SIZE_MAX / 2) { + arena->grow_size = CBM_ARENA_DEFAULT_BLOCK_SIZE; + } + void *memory = simulate_oom ? NULL : cbm_arena_alloc(arena, bytes); + if (!memory) { + gi_fail(guard, CBM_IGNORE_CHECKED_OOM, CBM_IGNORE_CAP_NONE); + } + return memory; +} + +bool gi_zero(gi_guard_t *guard, void *storage, size_t bytes) { + unsigned char *output = storage; + enum { ZERO_CHUNK = 256 }; + while (bytes) { + size_t chunk = bytes < ZERO_CHUNK ? bytes : ZERO_CHUNK; + if (!gi_work(guard, chunk)) { + return false; + } + memset(output, 0, chunk); + output += chunk; + bytes -= chunk; + } + return true; +} + +static bool gi_trim(const char *line, size_t *length, gi_guard_t *guard) { + while (*length) { + if (!gi_work(guard, 1)) { + return false; + } + char last = line[*length - 1]; + if (last != ' ' && last != '\t' && last != '\r') { + break; + } + (*length)--; + } + return true; +} + +bool gi_normalize(const char *line, size_t length, gi_view_t *view, gi_guard_t *guard) { + *view = (gi_view_t){0}; + if (!length || !gi_work(guard, 1) || line[0] == '#') { + return gi_active(guard); + } + if (!gi_trim(line, &length, guard) || !length) { + return gi_active(guard); + } + if (!gi_work(guard, 1)) { + return false; + } + if (*line == '!') { + view->negated = true; + line++; + length--; + } + if (!length || !gi_work(guard, 1)) { + return gi_active(guard); + } + if (line[length - 1] == '/') { + view->dir_only = true; + length--; + } + if (!length || !gi_work(guard, 1)) { + return gi_active(guard); + } + if (*line == '/') { + view->rooted = true; + line++; + length--; + } + for (size_t i = 0; !view->rooted && i < length; i++) { + if (!gi_work(guard, 1)) { + return false; + } + view->rooted = line[i] == '/'; + } + view->text = line; + view->length = length; + return true; +} diff --git a/src/discover/gitignore_internal.h b/src/discover/gitignore_internal.h new file mode 100644 index 000000000..2dc1cafa9 --- /dev/null +++ b/src/discover/gitignore_internal.h @@ -0,0 +1,62 @@ +#ifndef CBM_GITIGNORE_INTERNAL_H +#define CBM_GITIGNORE_INTERNAL_H +#include "discover/gitignore_checked.h" +#include "foundation/arena.h" + +typedef struct { + char *pattern; + bool negated; + bool dir_only; + bool rooted; +} gi_pattern_t; + +typedef struct { + const char *text; + size_t length; + bool negated; + bool dir_only; + bool rooted; +} gi_view_t; + +struct cbm_ignore_checked { + CBMArena arena; + cbm_ignore_checked_limits_t limits; + cbm_ignore_checked_usage_t usage; + cbm_ignore_checked_error_t error; +}; + +struct cbm_ignore_program { + const cbm_ignore_checked_t *owner; + size_t count; + gi_pattern_t rows[]; +}; + +typedef struct { + cbm_ignore_checked_t *owner; + const cbm_ignore_checked_control_t *control; + size_t offset; + unsigned ticks; + unsigned depth; +} gi_guard_t; + +enum { GI_POLL_UNITS = 4096, GI_MATCH_MAX_STEPS = 20000, GI_ARENA_ALIGN = 7 }; +enum { GI_FAULT_NONE, GI_FAULT_PROGRAM, GI_FAULT_PATTERN }; + +void gi_error(cbm_ignore_checked_error_t *error, cbm_ignore_checked_status_t status, + cbm_ignore_cap_t cap, size_t offset); +bool gi_fail(gi_guard_t *guard, cbm_ignore_checked_status_t status, cbm_ignore_cap_t cap); +bool gi_active(const gi_guard_t *guard); +bool gi_poll(gi_guard_t *guard); +bool gi_work(gi_guard_t *guard, size_t units); +bool gi_bytes(gi_guard_t *guard, size_t bytes); +bool gi_pattern_reserve(gi_guard_t *guard); +bool gi_begin(cbm_ignore_checked_t *owner, const cbm_ignore_checked_control_t *control, + gi_guard_t *guard); +cbm_ignore_checked_status_t gi_finish(const cbm_ignore_checked_t *owner, + cbm_ignore_checked_error_t *error); +void *gi_allocate(gi_guard_t *guard, size_t bytes, bool simulate_oom); +bool gi_zero(gi_guard_t *guard, void *storage, size_t bytes); +bool gi_normalize(const char *line, size_t length, gi_view_t *view, gi_guard_t *guard); +int gi_core_match(const gi_pattern_t *rows, size_t count, const char *path, bool is_directory, + gi_guard_t *guard); +#endif diff --git a/src/discover/inventory.c b/src/discover/inventory.c new file mode 100644 index 000000000..247e2c15f --- /dev/null +++ b/src/discover/inventory.c @@ -0,0 +1,137 @@ +#include "discover/inventory_internal.h" +#include "discover/inventory_limits_internal.h" +#include +#include + +bool cif_limits_valid(const cbm_inventory_limits_t *l) { + return l && l->max_files && l->max_files <= INT_MAX && l->max_directories && + l->max_directories <= INT_MAX && l->max_arena_bytes && l->max_ignore_arena_bytes && + l->max_ignore_arena_bytes <= l->max_arena_bytes && l->max_control_file_bytes && + l->max_control_total_bytes && l->max_ignore_patterns && + l->max_ignore_patterns <= INT_MAX && l->max_probe_prefix_bytes && l->max_ignore_work && + l->max_verified_file_reads && l->max_verified_content_bytes; +} + +static bool cif_open_ignore(cif_context *c) { + cbm_inventory_limits_t *l = &c->owner->limits; + cbm_ignore_checked_limits_t limits = {.max_bytes = + l->max_control_total_bytes + l->max_ignore_work, + .max_work = l->max_ignore_work, + .max_patterns = l->max_ignore_patterns, + .max_arena_bytes = l->max_ignore_arena_bytes, + .max_depth = CBM_IGNORE_CHECKED_DEPTH_MAX}; + c->file_index = SIZE_MAX; + if (!cif_poll(c)) { + return false; + } + cbm_ignore_checked_status_t status = cbm_ignore_checked_open(&limits, &c->owner->ignore, NULL); + return cif_checked(c, status) && cif_poll(c); +} + +static bool cif_publish(cif_context *c) { + cbm_inventory_filter_t *o = c->owner; + cbm_ignore_checked_usage_t checked; + c->file_index = SIZE_MAX; + if (!cbm_ignore_checked_usage(o->ignore, &checked) || + checked.terminal_status != CBM_IGNORE_CHECKED_OK) { + return cif_fail(c, CBM_INVENTORY_STATE); + } + o->usage.ignore_bytes_reserved = checked.bytes_reserved; + o->usage.ignore_work_used = checked.work_used; + o->usage.ignore_patterns_reserved = checked.patterns_reserved; + o->usage.ignore_arena_requested_bytes = checked.arena_requested_bytes; + size_t d = o->usage.non_ignore_arena_requested_bytes; + size_t i = o->limits.max_ignore_arena_bytes; + if (checked.arena_requested_bytes > i || d > o->limits.max_arena_bytes - i) { + return cif_fail(c, CBM_INVENTORY_STATE); + } + o->usage.arena_requested_bytes = d + checked.arena_requested_bytes; + o->usage.arena_budget_used_bytes = d + i; + o->view.files = o->files; + o->view.rows = o->rows; + o->view.controls = o->controls; + o->view.excluded_directories = o->exclusions; + return cif_poll(c); +} + +cbm_inventory_status_t cbm_inventory_filter_prepare(const cbm_inventory_source_t *source, + const cbm_inventory_limits_t *limits, + const cbm_inventory_control_t *control, + cbm_inventory_filter_t **out, + cbm_inventory_error_t *error) { + cbm_inventory_error_t initial = {.status = CBM_INVENTORY_OK, .file_index = SIZE_MAX}; + if (out) { + *out = NULL; + } + if (error) { + *error = initial; + } + cbm_inventory_filter_t bootstrap = {0}; + cif_context c = {.owner = &bootstrap, + .source = source, + .control = control, + .error = initial, + .file_index = SIZE_MAX}; + if (!out || !source || !cif_limits_valid(limits) || !control || !control->deadline_ms || + !source->native_root || !source->read || (source->file_count && !source->files)) { + cif_fail(&c, CBM_INVENTORY_INVALID); + } else if (source->file_count > limits->max_files || source->file_count > INT_MAX || + limits->max_control_total_bytes > UINT64_MAX - limits->max_ignore_work) { + cif_fail(&c, CBM_INVENTORY_LIMIT); + } else if (cif_poll(&c)) { + bootstrap.limits = *limits; + bootstrap.deadline_ms = control->deadline_ms; + cbm_arena_init_lazy(&bootstrap.arena, CBM_ARENA_DEFAULT_BLOCK_SIZE); + cbm_inventory_filter_t *owner = cif_alloc(&c, 1, sizeof(*owner)); + if (owner && cif_copy(&c, owner, &bootstrap, sizeof(*owner))) { + c.owner = owner; + if (cif_metadata(&c) && cif_directories(&c) && cif_open_ignore(&c) && cif_filter(&c) && + cif_publish(&c)) { + *out = owner; + return CBM_INVENTORY_OK; + } + } + } + cbm_inventory_filter_free(c.owner); + if (error) { + *error = c.error; + } + return c.error.status; +} + +const cbm_inventory_filter_view_t *cbm_inventory_filter_view(const cbm_inventory_filter_t *owner) { + return owner ? &owner->view : NULL; +} + +bool cbm_inventory_filter_usage(const cbm_inventory_filter_t *owner, + cbm_inventory_filter_usage_t *out) { + if (!out) { + return false; + } + memset(out, 0, sizeof(*out)); + if (!owner) { + return false; + } + *out = owner->usage; + return true; +} + +bool cbm_inventory_filter_limits(const cbm_inventory_filter_t *owner, cbm_inventory_limits_t *out) { + if (!out) { + return false; + } + memset(out, 0, sizeof(*out)); + if (!owner) { + return false; + } + *out = owner->limits; + return true; +} + +void cbm_inventory_filter_free(cbm_inventory_filter_t *owner) { + if (owner) { + cbm_ignore_checked_free(owner->ignore); + CBMArena arena = owner->arena; + cbm_arena_destroy(&arena); + } +} diff --git a/src/discover/inventory_controls.c b/src/discover/inventory_controls.c new file mode 100644 index 000000000..3e7c5a366 --- /dev/null +++ b/src/discover/inventory_controls.c @@ -0,0 +1,177 @@ +#include "discover/inventory_internal.h" +#include "foundation/sha256.h" +#include + +static bool cif_control_index(cif_context *c, size_t directory, const char *name, + size_t name_length, size_t *index) { + cbm_inventory_path_t path = c->owner->directories[directory].path; + size_t separator = path.length ? 1 : 0; + *index = SIZE_MAX; + /* A longer candidate cannot occur in this fully validated inventory. */ + if (path.length > CIF_PATH_MAX - name_length - separator) { + return true; + } + unsigned char buffer[CIF_PATH_MAX + 1]; + if (!cif_copy(c, buffer, path.data, path.length) || !cif_bytes(c, 1)) { + return false; + } + if (separator) { + buffer[path.length] = '/'; + } + if (!cif_copy(c, buffer + path.length + separator, name, name_length + 1)) { + return false; + } + cbm_inventory_path_t candidate = {buffer, path.length + separator + name_length}; + if (!cif_find_file(c, candidate, index)) { + return false; + } + if (*index == SIZE_MAX) { + size_t collision = SIZE_MAX; + if (!cif_find_directory(c, candidate, &collision)) { + return false; + } + if (collision != SIZE_MAX) { + return cif_fail(c, CBM_INVENTORY_UNSUPPORTED); + } + } + return true; +} + +static bool cif_reserve_control(cif_context *c, uint64_t length) { + cbm_inventory_filter_t *o = c->owner; + cbm_inventory_limits_t *l = &o->limits; + cbm_inventory_filter_usage_t *u = &o->usage; + if ((uint64_t)(size_t)length != length || length > l->max_control_file_bytes || + u->verified_file_reads_reserved >= l->max_verified_file_reads || + length > l->max_verified_content_bytes - u->verified_content_bytes_reserved || + length > l->max_control_total_bytes - u->control_bytes_reserved) { + return cif_fail(c, CBM_INVENTORY_LIMIT); + } + u->verified_file_reads_reserved++; + u->verified_content_bytes_reserved += length; + u->control_bytes_reserved += length; + return true; +} + +static bool cif_read_control(cif_context *c, size_t index, size_t length) { + cbm_inventory_filter_t *o = c->owner; + if (length > o->scratch_capacity) { + unsigned char *buffer = cif_alloc(c, length, 1); + if (!buffer) { + return false; + } + o->scratch = buffer; + o->scratch_capacity = length; + } + if (!cif_poll(c)) { + return false; + } + size_t copied = 0; + cbm_inventory_error_t error = {.status = CBM_INVENTORY_OK, .file_index = SIZE_MAX}; + cbm_inventory_status_t status = + c->source->read(c->source->read_context, index, length ? o->scratch : NULL, length, &copied, + c->control, &error); + c->error.cleanup_required |= error.cleanup_required; + if ((unsigned)status > (unsigned)CBM_INVENTORY_DEADLINE || error.status != status) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } + if (status != CBM_INVENTORY_OK) { + return cif_fail(c, copied ? CBM_INVENTORY_INVALID : status); + } + if (copied != length || error.cleanup_required) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } + return cif_poll(c); +} + +static bool cif_hash_control(cif_context *c, size_t index, size_t length) { + cbm_sha256_ctx hash; + unsigned char digest[CBM_SHA256_DIGEST_LEN]; + if (!cif_poll(c)) { + return false; + } + cbm_sha256_init(&hash); + for (size_t at = 0; at < length;) { + size_t n = length - at < 512 ? length - at : 512; + if (!cif_poll(c) || !cif_bytes(c, n)) { + return false; + } + cbm_sha256_update(&hash, c->owner->scratch + at, n); + if (!cif_poll(c)) { + return false; + } + at += n; + } + cbm_sha256_final(&hash, digest); + if (!cif_poll(c)) { + return false; + } + if (memcmp(digest, c->owner->files[index].content_sha256, sizeof(digest))) { + return cif_fail(c, CBM_INVENTORY_CHANGED); + } + return true; +} + +static bool cif_load_control(cif_context *c, size_t directory, cbm_inventory_control_kind_t kind, + cbm_inventory_control_row_t *row, + const cbm_ignore_program_t **program) { + cbm_inventory_filter_t *o = c->owner; + c->file_index = SIZE_MAX; + if (!cif_event(c) || !cif_bytes(c, 256)) { + return false; + } + *row = (cbm_inventory_control_row_t){ + .kind = kind, .directory = o->directories[directory].path, .file_index = SIZE_MAX}; + const char *name = kind == CBM_INVENTORY_CONTROL_CBMIGNORE ? ".cbmignore" : ".gitignore"; + size_t index = SIZE_MAX; + if (!cif_control_index(c, directory, name, 10, &index)) { + return false; + } + if (index == SIZE_MAX) { + return true; + } + c->file_index = index; + uint64_t size = o->files[index].content_length; + if (!cif_reserve_control(c, size)) { + return false; + } + size_t length = (size_t)size; + if (!cif_read_control(c, index, length) || !cif_hash_control(c, index, length) || + !cif_poll(c)) { + return false; + } + cbm_ignore_checked_control_t control = cif_ignore_control(c); + cbm_ignore_checked_parse_result_t parsed = {0}; + cbm_ignore_checked_status_t status = cbm_ignore_checked_parse( + o->ignore, length ? o->scratch : NULL, length, &control, &parsed, NULL); + if (!cif_checked(c, status)) { + return false; + } + if (!parsed.program) { + return cif_fail(c, CBM_INVENTORY_STATE); + } + *program = parsed.program; + row->file_index = index; + row->effective_patterns = parsed.pattern_count; + row->outcome = + parsed.pattern_count ? CBM_INVENTORY_CONTROL_APPLIED : CBM_INVENTORY_CONTROL_APPLIED_EMPTY; + o->rows[index].roles |= kind == CBM_INVENTORY_CONTROL_CBMIGNORE + ? CBM_INVENTORY_FILTER_ROLE_CBMIGNORE + : CBM_INVENTORY_FILTER_ROLE_GITIGNORE; + return cif_poll(c); +} + +bool cif_load_controls(cif_context *c, size_t directory) { + cbm_inventory_filter_t *o = c->owner; + if (!directory && !cif_load_control(c, 0, CBM_INVENTORY_CONTROL_CBMIGNORE, &o->cbm_control, + &o->cbm_program)) { + return false; + } + cbm_inventory_control_row_t *row = &o->controls[o->view.control_count]; + if (!cif_load_control(c, directory, CBM_INVENTORY_CONTROL_GITIGNORE, row, + &o->directories[directory].program)) { + return false; + } + o->view.control_count++; + return true; +} diff --git a/src/discover/inventory_directories.c b/src/discover/inventory_directories.c new file mode 100644 index 000000000..c4673acaf --- /dev/null +++ b/src/discover/inventory_directories.c @@ -0,0 +1,239 @@ +#include "discover/inventory_internal.h" + +static bool cif_common(cif_context *c, cbm_inventory_path_t a, cbm_inventory_path_t b, + size_t *length) { + *length = 0; + size_t n = a.length < b.length ? a.length : b.length; + while (*length < n) { + if (!cif_bytes(c, 2)) { + return false; + } + if (a.data[*length] != b.data[*length]) { + break; + } + (*length)++; + } + return true; +} + +static bool cif_directory_init(cif_context *c, cif_directory *d, cbm_inventory_path_t path) { + cif_directory value = {.parent = SIZE_MAX, + .next = SIZE_MAX, + .first_directory = SIZE_MAX, + .last_directory = SIZE_MAX, + .first_file = SIZE_MAX, + .last_file = SIZE_MAX, + .excluded = SIZE_MAX}; + value.path.data = cif_string(c, path.data, path.length); + value.path.length = path.length; + return value.path.data && cif_copy(c, d, &value, sizeof(value)); +} + +/* Equal slash-terminated prefixes occupy one contiguous run of sorted files. + * Their first occurrence is unique; the generated directory order still needs + * sorting (a-/x precedes a/x although directory a precedes directory a-). */ +static bool cif_prefixes(cif_context *c, bool fill, size_t *total) { + cbm_inventory_filter_t *o = c->owner; + size_t count = 1; + for (size_t i = 0; i < o->view.file_count; i++) { + c->file_index = i; + cbm_inventory_path_t path = o->files[i].path; + size_t common = 0; + if (!cif_event(c) || !cif_bytes(c, 128) || + (i && !cif_common(c, o->files[i - 1].path, path, &common))) { + return false; + } + for (size_t j = 0; j < path.length; j++) { + if (!cif_bytes(c, 1)) { + return false; + } + if (path.data[j] != '/' || j < common) { + continue; + } + if (count >= o->limits.max_directories) { + return cif_fail(c, CBM_INVENTORY_LIMIT); + } + if (fill && !cif_directory_init(c, &o->directories[count], + (cbm_inventory_path_t){path.data, j})) { + return false; + } + count++; + } + } + *total = count; + return true; +} + +static bool cif_merge(cif_context *c, const cif_directory *from, cif_directory *to, size_t start, + size_t middle, size_t end) { + size_t left = start, right = middle; + for (size_t at = start; at < end; at++) { + int order = 0; + if (!cif_event(c)) { + return false; + } + if (left < middle && right < end && + !cif_compare(c, from[left].path, from[right].path, &order)) { + return false; + } + size_t selected = right == end || (left < middle && order <= 0) ? left++ : right++; + if (!cif_copy(c, &to[at], &from[selected], sizeof(*to))) { + return false; + } + } + return true; +} + +static bool cif_sort_directories(cif_context *c) { + cbm_inventory_filter_t *o = c->owner; + size_t n = o->view.directory_count; + if (n < 2) { + return true; + } + cif_directory *temporary = cif_alloc(c, n, sizeof(*temporary)); + if (!temporary) { + return false; + } + cif_directory *from = o->directories, *to = temporary; + for (size_t width = 1; width < n;) { + for (size_t start = 0; start < n;) { + size_t middle = start + (width < n - start ? width : n - start); + size_t end = middle + (width < n - middle ? width : n - middle); + if (!cif_merge(c, from, to, start, middle, end)) { + return false; + } + start = end; + } + cif_directory *swap = from; + from = to; + to = swap; + if (width >= n - width) { + break; + } + width *= 2; + } + return from == o->directories || cif_copy(c, o->directories, from, n * sizeof(*from)); +} + +static bool cif_find(cif_context *c, cbm_inventory_path_t path, bool directory, size_t *index) { + cbm_inventory_filter_t *o = c->owner; + size_t low = 0, high = directory ? o->view.directory_count : o->view.file_count; + *index = SIZE_MAX; + while (low < high) { + size_t mid = low + (high - low) / 2; + cbm_inventory_path_t candidate = directory ? o->directories[mid].path : o->files[mid].path; + int order = 0; + if (!cif_compare(c, candidate, path, &order)) { + return false; + } + if (!order) { + *index = mid; + return true; + } + if (order < 0) { + low = mid + 1; + } else { + high = mid; + } + } + return true; +} + +bool cif_find_file(cif_context *c, cbm_inventory_path_t path, size_t *index) { + return cif_find(c, path, false, index); +} + +bool cif_find_directory(cif_context *c, cbm_inventory_path_t path, size_t *index) { + return cif_find(c, path, true, index); +} + +static bool cif_parent(cif_context *c, cbm_inventory_path_t path, size_t *parent) { + size_t length = path.length; + while (length) { + if (!cif_bytes(c, 1)) { + return false; + } + if (path.data[--length] == '/') { + break; + } + } + if (!cif_find_directory(c, (cbm_inventory_path_t){path.data, length}, parent)) { + return false; + } + return *parent != SIZE_MAX || cif_fail(c, CBM_INVENTORY_STATE); +} + +static bool cif_link_directories(cif_context *c) { + cbm_inventory_filter_t *o = c->owner; + for (size_t i = 1; i < o->view.directory_count; i++) { + c->file_index = SIZE_MAX; + size_t collision = SIZE_MAX; + cif_directory *d = &o->directories[i]; + if (!cif_event(c) || !cif_bytes(c, 256) || !cif_find_file(c, d->path, &collision)) { + return false; + } + if (collision != SIZE_MAX) { + c->file_index = collision; + return cif_fail(c, CBM_INVENTORY_INVALID); + } + if (!cif_parent(c, d->path, &d->parent)) { + return false; + } + if (d->parent >= i) { + return cif_fail(c, CBM_INVENTORY_STATE); + } + if (!cif_bytes(c, 128)) { + return false; + } + cif_directory *parent = &o->directories[d->parent]; + if (parent->last_directory != SIZE_MAX) { + o->directories[parent->last_directory].next = i; + } else { + parent->first_directory = i; + } + parent->last_directory = i; + } + return true; +} + +static bool cif_link_files(cif_context *c) { + cbm_inventory_filter_t *o = c->owner; + for (size_t i = 0; i < o->view.file_count; i++) { + c->file_index = i; + size_t at = SIZE_MAX; + if (!cif_event(c) || !cif_bytes(c, 256) || !cif_parent(c, o->files[i].path, &at)) { + return false; + } + if (!cif_bytes(c, 128)) { + return false; + } + cif_directory *parent = &o->directories[at]; + if (parent->last_file != SIZE_MAX) { + o->file_next[parent->last_file] = i; + } else { + parent->first_file = i; + } + parent->last_file = i; + } + return true; +} + +bool cif_directories(cif_context *c) { + cbm_inventory_filter_t *o = c->owner; + size_t count = 0; + if (!cif_prefixes(c, false, &count)) { + return false; + } + c->file_index = SIZE_MAX; + o->view.directory_count = count; + o->directories = cif_alloc(c, count, sizeof(*o->directories)); + o->controls = cif_alloc(c, count + 1, sizeof(*o->controls)); + o->exclusions = cif_alloc(c, count, sizeof(*o->exclusions)); + if (!o->directories || !o->controls || !o->exclusions || + !cif_directory_init(c, &o->directories[0], (cbm_inventory_path_t){NULL, 0}) || + !cif_prefixes(c, true, &count)) { + return false; + } + c->file_index = SIZE_MAX; + return cif_sort_directories(c) && cif_link_directories(c) && cif_link_files(c); +} diff --git a/src/discover/inventory_filter.c b/src/discover/inventory_filter.c new file mode 100644 index 000000000..ec8277ea0 --- /dev/null +++ b/src/discover/inventory_filter.c @@ -0,0 +1,239 @@ +#include "discover/inventory_internal.h" +#include "discover/discover.h" +#include "discover/discover_policy_internal.h" + +static bool cif_match(cif_context *c, const cbm_ignore_program_t *program, + cbm_inventory_path_t path, bool directory, cbm_ignore_decision_t *decision) { + *decision = CBM_IGNORE_NO_OPINION; + if (!program) { + return true; + } + if (!cif_poll(c)) { + return false; + } + cbm_ignore_checked_control_t control = cif_ignore_control(c); + cbm_ignore_checked_status_t status = + cbm_ignore_checked_match(c->owner->ignore, program, (const char *)path.data, path.length, + directory, &control, decision, NULL); + if (!cif_checked(c, status)) { + return false; + } + if (*decision != CBM_IGNORE_REINCLUDED && *decision != CBM_IGNORE_NO_OPINION && + *decision != CBM_IGNORE_IGNORED) { + return cif_fail(c, CBM_INVENTORY_STATE); + } + return cif_poll(c); +} + +static bool cif_git_opinion(cif_context *c, size_t parent, cbm_inventory_path_t path, + bool directory, cbm_ignore_decision_t *decision) { + *decision = CBM_IGNORE_NO_OPINION; + while (parent != SIZE_MAX) { + if (!cif_event(c) || !cif_bytes(c, 128)) { + return false; + } + const cif_directory *d = &c->owner->directories[parent]; + size_t offset = d->path.length ? d->path.length + 1 : 0; + if (offset > path.length) { + return cif_fail(c, CBM_INVENTORY_STATE); + } + cbm_inventory_path_t local = {path.data + offset, path.length - offset}; + if (!cif_match(c, d->program, local, directory, decision)) { + return false; + } + if (*decision != CBM_IGNORE_NO_OPINION) { + return true; + } + parent = d->parent; + } + return true; +} + +static bool cif_ignore_reason(cif_context *c, size_t parent, cbm_inventory_path_t path, + bool directory, cbm_inventory_filter_reason_t *reason) { + cbm_ignore_decision_t decision; + if (!cif_git_opinion(c, parent, path, directory, &decision)) { + return false; + } + if (decision == CBM_IGNORE_IGNORED) { + *reason = CBM_INVENTORY_FILTER_REASON_GITIGNORE; + return true; + } + if (!cif_match(c, c->owner->cbm_program, path, directory, &decision)) { + return false; + } + if (decision == CBM_IGNORE_IGNORED) { + *reason = CBM_INVENTORY_FILTER_REASON_CBMIGNORE; + } + return true; +} + +static const char *cif_basename(const cbm_inventory_filter_t *o, size_t parent, + cbm_inventory_path_t path) { + size_t length = o->directories[parent].path.length; + return (const char *)path.data + (length ? length + 1 : 0); +} + +static bool cif_file_reason(cif_context *c, size_t parent, cbm_inventory_path_t path, + cbm_inventory_filter_reason_t *reason) { + const char *name = cif_basename(c->owner, parent, path); + if (!cif_poll(c)) { + return false; + } + /* Shared FULL helpers have bounded literal tables; suffix length is cached + * in discover.c so a 4095-byte name cannot cause repeated full scans. */ + if (cbm_has_ignored_suffix(name, CBM_MODE_FULL)) { + *reason = CBM_INVENTORY_FILTER_REASON_IGNORED_SUFFIX; + } else if (cbm_should_skip_filename(name, CBM_MODE_FULL)) { + *reason = CBM_INVENTORY_FILTER_REASON_SKIP_LIST; + } else if (cbm_matches_fast_pattern(name, CBM_MODE_FULL)) { + *reason = CBM_INVENTORY_FILTER_REASON_FAST_PATTERN; + } + if (!cif_poll(c)) { + return false; + } + return *reason != CBM_INVENTORY_FILTER_REASON_NONE || + cif_ignore_reason(c, parent, path, false, reason); +} + +static bool cif_directory_reason(cif_context *c, size_t parent, cbm_inventory_path_t path, + cbm_inventory_filter_reason_t *reason) { + const char *name = cif_basename(c->owner, parent, path); + if (!cif_poll(c)) { + return false; + } + bool builtin = cbm_should_skip_dir(name, CBM_MODE_FULL); + bool suffix = !builtin && cbm_discover_path_has_skip_suffix((const char *)path.data); + bool safety = + (builtin || suffix) && c->owner->cbm_program && cbm_discover_is_safety_core_directory(name); + if (!cif_poll(c)) { + return false; + } + if (builtin || suffix) { + cbm_ignore_decision_t decision = CBM_IGNORE_NO_OPINION; + if (!safety && !cif_match(c, c->owner->cbm_program, path, true, &decision)) { + return false; + } + if (decision != CBM_IGNORE_REINCLUDED) { + *reason = builtin ? CBM_INVENTORY_FILTER_REASON_DIRECTORY_BUILTIN + : CBM_INVENTORY_FILTER_REASON_DIRECTORY_SUFFIX; + return true; + } + } + return cif_ignore_reason(c, parent, path, true, reason); +} + +static bool cif_file_entry(cif_context *c, size_t parent, size_t file) { + cbm_inventory_filter_t *o = c->owner; + c->file_index = file; + cbm_inventory_filter_reason_t reason = CBM_INVENTORY_FILTER_REASON_NONE; + if (!cif_file_reason(c, parent, o->files[file].path, &reason) || !cif_bytes(c, 128)) { + return false; + } + o->rows[file].reason = reason; + if (reason != CBM_INVENTORY_FILTER_REASON_NONE) { + o->rows[file].disposition = CBM_INVENTORY_FILTER_IGNORED_FILE; + } + return true; +} + +static bool cif_directory_entry(cif_context *c, size_t parent, size_t directory) { + cbm_inventory_filter_t *o = c->owner; + c->file_index = SIZE_MAX; + cbm_inventory_filter_reason_t reason = CBM_INVENTORY_FILTER_REASON_NONE; + if (!cif_directory_reason(c, parent, o->directories[directory].path, &reason) || + !cif_bytes(c, 128)) { + return false; + } + o->directories[directory].reason = reason; + if (reason != CBM_INVENTORY_FILTER_REASON_NONE) { + o->directories[directory].excluded = directory; + } + return true; +} + +static bool cif_entries(cif_context *c, size_t parent) { + cbm_inventory_filter_t *o = c->owner; + size_t file = o->directories[parent].first_file; + size_t directory = o->directories[parent].first_directory; + while (file != SIZE_MAX || directory != SIZE_MAX) { + if (!cif_event(c) || !cif_bytes(c, 128)) { + return false; + } + int order = directory == SIZE_MAX ? -1 : 1; + if (file != SIZE_MAX && directory != SIZE_MAX && + !cif_compare(c, o->files[file].path, o->directories[directory].path, &order)) { + return false; + } + if (order < 0) { + if (!cif_file_entry(c, parent, file)) { + return false; + } + file = o->file_next[file]; + } else { + if (!cif_directory_entry(c, parent, directory)) { + return false; + } + directory = o->directories[directory].next; + } + } + return true; +} + +static bool cif_excluded_files(cif_context *c, size_t directory) { + cbm_inventory_filter_t *o = c->owner; + const cif_directory *d = &o->directories[directory]; + const cif_directory *excluded = &o->directories[d->excluded]; + for (size_t file = d->first_file; file != SIZE_MAX; file = o->file_next[file]) { + c->file_index = file; + if (!cif_event(c) || !cif_bytes(c, 256)) { + return false; + } + o->rows[file].disposition = CBM_INVENTORY_FILTER_EXCLUDED_SUBTREE; + o->rows[file].reason = excluded->reason; + o->rows[file].excluded_ancestor = excluded->path; + } + return true; +} + +static bool cif_final_ledgers(cif_context *c) { + cbm_inventory_filter_t *o = c->owner; + c->file_index = SIZE_MAX; + /* Git rows were appended in canonical directory order; CBM kind follows. */ + if (!cif_copy(c, &o->controls[o->view.control_count], &o->cbm_control, + sizeof(o->cbm_control))) { + return false; + } + o->view.control_count++; + for (size_t i = 1; i < o->view.directory_count; i++) { + if (!cif_event(c) || !cif_bytes(c, 128)) { + return false; + } + if (o->directories[i].excluded == i) { + o->exclusions[o->view.excluded_count++] = o->directories[i].path; + } + } + return true; +} + +bool cif_filter(cif_context *c) { + cbm_inventory_filter_t *o = c->owner; + for (size_t i = 0; i < o->view.directory_count; i++) { + c->file_index = SIZE_MAX; + if (!cif_event(c) || !cif_bytes(c, 256)) { + return false; + } + cif_directory *d = &o->directories[i]; + if (d->parent != SIZE_MAX && o->directories[d->parent].excluded != SIZE_MAX) { + d->excluded = o->directories[d->parent].excluded; + } + if (d->excluded != SIZE_MAX) { + if (!cif_excluded_files(c, i)) { + return false; + } + } else if (!cif_load_controls(c, i) || !cif_entries(c, i)) { + return false; + } + } + return cif_final_ledgers(c); +} diff --git a/src/discover/inventory_filter.h b/src/discover/inventory_filter.h new file mode 100644 index 000000000..10acf983f --- /dev/null +++ b/src/discover/inventory_filter.h @@ -0,0 +1,190 @@ +/* DRAFT internal pre-language inventory filter; parent review required. + * Proposed destination: src/discover/inventory_filter.h. + * No language classification, full-discovery success, pipeline or admission API. + * Shared inventory data types can be included by the later inventory.h rather + * than redeclared. Normative behavior is in contract.txt. + */ +#ifndef CBM_DISCOVER_INVENTORY_FILTER_H +#define CBM_DISCOVER_INVENTORY_FILTER_H + +#include +#include +#include + +typedef enum { + CBM_INVENTORY_OK = 0, + CBM_INVENTORY_INVALID, + CBM_INVENTORY_STATE, + CBM_INVENTORY_CHANGED, + CBM_INVENTORY_UNSUPPORTED, + CBM_INVENTORY_LIMIT, + CBM_INVENTORY_OOM, + CBM_INVENTORY_IO, + CBM_INVENTORY_CANCELLED, + CBM_INVENTORY_DEADLINE +} cbm_inventory_status_t; + +typedef struct { + cbm_inventory_status_t status; + size_t file_index; /* SIZE_MAX unless a particular input row is known. */ + bool cleanup_required; /* Independent provider cleanup obligation. */ + char diagnostic[256]; /* Owned bounded generic text, no input contents. */ +} cbm_inventory_error_t; + +typedef struct { + const unsigned char *data; + size_t length; +} cbm_inventory_path_t; + +typedef struct { + cbm_inventory_path_t path; /* data[length]==0; no earlier NUL. */ + uint32_t git_mode; /* 0100644 or 0100755. */ + char oid[65]; /* First NUL at 40 or 64; preceding lowercase hex only. */ + uint64_t content_length; + unsigned char content_sha256[32]; +} cbm_inventory_file_t; + +typedef struct { + uint64_t deadline_ms; /* Positive absolute cbm_now_ms deadline. */ + bool (*cancelled)(void *context); /* Optional prompt, pure, non-reentrant. */ + void *context; /* Borrowed only until prepare returns. */ +} cbm_inventory_control_t; + +typedef struct { + size_t max_files; /* Positive, <=INT_MAX; complete inventory, not survivors. */ + size_t max_directories; /* Positive, <=INT_MAX; root plus proper prefixes. */ + size_t max_arena_bytes; /* Total cumulative logical requests/reservations. */ + size_t max_ignore_arena_bytes; /* Positive reserved slice <= total. */ + size_t max_control_file_bytes; + uint64_t max_control_total_bytes; + size_t max_ignore_patterns; /* Positive, <=INT_MAX. */ + size_t max_probe_prefix_bytes; /* Copied/validated; unused before language. */ + uint64_t max_ignore_work; /* Exclusive checked parse+match allowance. */ + uint64_t max_verified_file_reads; + uint64_t max_verified_content_bytes; +} cbm_inventory_limits_t; + +/* Contractual full-file verification followed by min(capacity,length) exact + * prefix bytes. OK publishes copied; error publishes copied==0. No terminator. + * Serial; all inputs borrowed. An error may require caller-owned native cleanup. + * This callback type cannot authenticate a synthetic provider. + */ +typedef cbm_inventory_status_t (*cbm_inventory_read_fn)( + void *context, size_t file_index, unsigned char *prefix, size_t capacity, + size_t *copied, const cbm_inventory_control_t *control, cbm_inventory_error_t *error); + +typedef struct { + const char *native_root; + const cbm_inventory_file_t *files; + size_t file_count; + unsigned char manifest_sha256[32]; /* Copied opaque binding, not authenticated. */ + cbm_inventory_read_fn read; + void *read_context; +} cbm_inventory_source_t; + +/* Separate internal type: never cast to the future final disposition enum. */ +typedef enum { + CBM_INVENTORY_FILTER_NEEDS_LANGUAGE = 0, + CBM_INVENTORY_FILTER_IGNORED_FILE, + CBM_INVENTORY_FILTER_EXCLUDED_SUBTREE +} cbm_inventory_filter_disposition_t; + +typedef enum { + CBM_INVENTORY_FILTER_REASON_NONE = 0, + CBM_INVENTORY_FILTER_REASON_DIRECTORY_BUILTIN, + CBM_INVENTORY_FILTER_REASON_DIRECTORY_SUFFIX, + CBM_INVENTORY_FILTER_REASON_GITIGNORE, + CBM_INVENTORY_FILTER_REASON_CBMIGNORE, + CBM_INVENTORY_FILTER_REASON_IGNORED_SUFFIX, + CBM_INVENTORY_FILTER_REASON_SKIP_LIST, + CBM_INVENTORY_FILTER_REASON_FAST_PATTERN +} cbm_inventory_filter_reason_t; + +enum { + CBM_INVENTORY_FILTER_ROLE_GITIGNORE = 1u << 0, + CBM_INVENTORY_FILTER_ROLE_CBMIGNORE = 1u << 1, + CBM_INVENTORY_FILTER_ROLE_PHYSICAL_PROJECT_CONFIG = 1u << 2 +}; + +typedef struct { + size_t file_index; + cbm_inventory_filter_disposition_t disposition; + cbm_inventory_filter_reason_t reason; + unsigned roles; + cbm_inventory_path_t excluded_ancestor; /* Otherwise {NULL,0}. */ +} cbm_inventory_filter_row_t; + +typedef enum { + CBM_INVENTORY_CONTROL_GITIGNORE = 0, + CBM_INVENTORY_CONTROL_CBMIGNORE +} cbm_inventory_control_kind_t; + +typedef enum { + CBM_INVENTORY_CONTROL_ABSENT = 0, + CBM_INVENTORY_CONTROL_APPLIED_EMPTY, + CBM_INVENTORY_CONTROL_APPLIED +} cbm_inventory_control_outcome_t; + +typedef struct { + cbm_inventory_control_kind_t kind; + cbm_inventory_path_t directory; /* Empty root or reached directory. */ + size_t file_index; /* SIZE_MAX iff ABSENT. */ + cbm_inventory_control_outcome_t outcome; + size_t effective_patterns; +} cbm_inventory_control_row_t; + +typedef struct { + uint64_t verified_file_reads_reserved; + uint64_t verified_content_bytes_reserved; + uint64_t control_bytes_reserved; + uint64_t ignore_bytes_reserved; + uint64_t ignore_work_used; + size_t ignore_patterns_reserved; + size_t ignore_arena_requested_bytes; + size_t non_ignore_arena_requested_bytes; + size_t arena_requested_bytes; /* D+C actual logical requests. */ + size_t arena_budget_used_bytes; /* D+I unavailable quota. */ +} cbm_inventory_filter_usage_t; + +typedef struct cbm_inventory_filter cbm_inventory_filter_t; + +typedef struct { + const char *native_root; + const cbm_inventory_file_t *files; + const cbm_inventory_filter_row_t *rows; + size_t file_count; /* Exactly the complete input count. */ + size_t directory_count; /* Includes excluded directories and root. */ + const cbm_inventory_control_row_t *controls; + size_t control_count; + const cbm_inventory_path_t *excluded_directories; /* Minimal roots only. */ + size_t excluded_count; + unsigned char manifest_sha256[32]; +} cbm_inventory_filter_view_t; + +/* Synchronous FULL/no-size-skip pre-language pass. Clears out/error first. + * Required source/limits/control/out; read callback required even for empty input. + * No config/global/Git/directory enumeration/stat/non-control content read. + * Success owns all returned metadata/rows/strings and one checked-ignore owner; + * retains neither callbacks nor caller contexts. Failure: out==NULL, no partial + * view. Native cleanup remains the provider caller's responsibility. + */ +cbm_inventory_status_t cbm_inventory_filter_prepare( + const cbm_inventory_source_t *source, const cbm_inventory_limits_t *limits, + const cbm_inventory_control_t *control, cbm_inventory_filter_t **out, + cbm_inventory_error_t *error); + +/* Borrowed read-only view until free; NULL for NULL. No IO/poll/allocation. */ +const cbm_inventory_filter_view_t *cbm_inventory_filter_view(const cbm_inventory_filter_t *owner); + +/* Clears out; false for NULL owner/output. Copies usage or original limits. + * These are diagnostics, never a capability to replenish/reset a live owner. + */ +bool cbm_inventory_filter_usage(const cbm_inventory_filter_t *owner, + cbm_inventory_filter_usage_t *out); +bool cbm_inventory_filter_limits(const cbm_inventory_filter_t *owner, + cbm_inventory_limits_t *out); + +/* NULL-safe; frees checked owner before the containing arena; no callback/IO. */ +void cbm_inventory_filter_free(cbm_inventory_filter_t *owner); + +#endif diff --git a/src/discover/inventory_guard.c b/src/discover/inventory_guard.c new file mode 100644 index 000000000..341dbc7c0 --- /dev/null +++ b/src/discover/inventory_guard.c @@ -0,0 +1,172 @@ +#include "discover/inventory_internal.h" +#include "foundation/platform.h" +#include + +bool cif_fail(cif_context *c, cbm_inventory_status_t status) { + if (c->error.status == CBM_INVENTORY_OK) { + c->error.status = status; + c->error.file_index = c->file_index; + strcpy(c->error.diagnostic, "inventory filter could not complete"); + } + return false; +} + +bool cif_poll(cif_context *c) { + if (c->error.status != CBM_INVENTORY_OK) { + return false; + } + if (c->control->cancelled && c->control->cancelled(c->control->context)) { + return cif_fail(c, CBM_INVENTORY_CANCELLED); + } + if (cbm_now_ms() >= c->control->deadline_ms) { + return cif_fail(c, CBM_INVENTORY_DEADLINE); + } + c->byte_gap = CIF_BYTE_GAP; + c->event_gap = CIF_EVENT_GAP; + return true; +} + +bool cif_bytes(cif_context *c, size_t count) { + if (c->error.status != CBM_INVENTORY_OK) { + return false; + } + if (count > CIF_BYTE_GAP) { + return cif_fail(c, CBM_INVENTORY_STATE); + } + if (count > c->byte_gap && !cif_poll(c)) { + return false; + } + c->byte_gap -= count; + return true; +} + +bool cif_event(cif_context *c) { + if (c->error.status != CBM_INVENTORY_OK || (!c->event_gap && !cif_poll(c))) { + return false; + } + c->event_gap--; + return true; +} + +bool cif_copy(cif_context *c, void *destination, const void *source, size_t length) { + unsigned char *out = destination; + const unsigned char *in = source; + while (length) { + size_t n = length < CIF_COPY_CHUNK ? length : CIF_COPY_CHUNK; + if (!cif_bytes(c, n * 2)) { + return false; + } + if (in) { + memcpy(out, in, n); + in += n; + } else { + memset(out, 0, n); + } + out += n; + length -= n; + } + return true; +} + +/* Guard arithmetic also used internally by the existing arena allocator. */ +static bool cif_arena_room(cif_context *c, size_t n) { + CBMArena *a = &c->owner->arena; + if (n > SIZE_MAX - 7) { + return cif_fail(c, CBM_INVENTORY_LIMIT); + } + size_t aligned = (n + 7) & ~(size_t)7; + if (a->used > SIZE_MAX - aligned || a->total_alloc > SIZE_MAX - aligned) { + return cif_fail(c, CBM_INVENTORY_LIMIT); + } + if (!a->nblocks || a->used + aligned > a->block_size) { + size_t next = a->grow_size > aligned ? a->grow_size : aligned; + size_t tail = a->block_size > a->used ? a->block_size - a->used : 0; + if (next > SIZE_MAX / 2 || a->waste_tail > SIZE_MAX - tail) { + return cif_fail(c, CBM_INVENTORY_LIMIT); + } + } + return true; +} + +void *cif_alloc(cif_context *c, size_t count, size_t width) { + if (!count || !width) { + return NULL; + } + if (count > SIZE_MAX / width) { + cif_fail(c, CBM_INVENTORY_LIMIT); + return NULL; + } + size_t n = count * width; + cbm_inventory_filter_t *o = c->owner; + size_t available = o->limits.max_arena_bytes - o->limits.max_ignore_arena_bytes; + if (n > available - o->usage.non_ignore_arena_requested_bytes) { + cif_fail(c, CBM_INVENTORY_LIMIT); + return NULL; + } + if (!cif_poll(c) || !cif_arena_room(c, n)) { + return NULL; + } + o->usage.non_ignore_arena_requested_bytes += n; + void *result = cbm_arena_alloc(&o->arena, n); + if (!result) { + cif_fail(c, CBM_INVENTORY_OOM); + return NULL; + } + if (!cif_poll(c) || !cif_copy(c, result, NULL, n)) { + return NULL; + } + return result; +} + +unsigned char *cif_string(cif_context *c, const unsigned char *source, size_t length) { + if (length == SIZE_MAX) { + cif_fail(c, CBM_INVENTORY_LIMIT); + return NULL; + } + unsigned char *result = cif_alloc(c, length + 1, 1); + return result && cif_copy(c, result, source, length) ? result : NULL; +} + +bool cif_compare(cif_context *c, cbm_inventory_path_t a, cbm_inventory_path_t b, int *result) { + if (!cif_event(c)) { + return false; + } + size_t n = a.length < b.length ? a.length : b.length; + for (size_t i = 0; i < n; i++) { + if (!cif_bytes(c, 2)) { + return false; + } + unsigned char left = a.data[i], right = b.data[i]; + if (left != right) { + *result = left < right ? -1 : 1; + return true; + } + } + *result = a.length < b.length ? -1 : a.length > b.length; + return true; +} + +bool cif_checked(cif_context *c, cbm_ignore_checked_status_t status) { + switch (status) { + case CBM_IGNORE_CHECKED_OK: + return true; + case CBM_IGNORE_CHECKED_INVALID: + return cif_fail(c, CBM_INVENTORY_INVALID); + case CBM_IGNORE_CHECKED_UNSUPPORTED: + return cif_fail(c, CBM_INVENTORY_UNSUPPORTED); + case CBM_IGNORE_CHECKED_LIMIT: + return cif_fail(c, CBM_INVENTORY_LIMIT); + case CBM_IGNORE_CHECKED_OOM: + return cif_fail(c, CBM_INVENTORY_OOM); + case CBM_IGNORE_CHECKED_CANCELLED: + return cif_fail(c, CBM_INVENTORY_CANCELLED); + case CBM_IGNORE_CHECKED_DEADLINE: + return cif_fail(c, CBM_INVENTORY_DEADLINE); + } + return cif_fail(c, CBM_INVENTORY_INVALID); +} + +cbm_ignore_checked_control_t cif_ignore_control(const cif_context *c) { + return (cbm_ignore_checked_control_t){c->control->deadline_ms, c->control->cancelled, + c->control->context}; +} diff --git a/src/discover/inventory_internal.h b/src/discover/inventory_internal.h new file mode 100644 index 000000000..1b74e9023 --- /dev/null +++ b/src/discover/inventory_internal.h @@ -0,0 +1,63 @@ +#ifndef CBM_INVENTORY_INTERNAL_H +#define CBM_INVENTORY_INTERNAL_H + +#include "discover/inventory_filter.h" +#include "discover/gitignore_checked.h" +#include "foundation/arena.h" + +enum { CIF_PATH_MAX = 4095, CIF_BYTE_GAP = 65536, CIF_EVENT_GAP = 4096, CIF_COPY_CHUNK = 4096 }; + +typedef struct { + cbm_inventory_path_t path; + size_t parent, next, first_directory, last_directory, first_file, last_file; + size_t excluded; + cbm_inventory_filter_reason_t reason; + const cbm_ignore_program_t *program; +} cif_directory; + +struct cbm_inventory_filter { + CBMArena arena; + cbm_inventory_limits_t limits; + uint64_t deadline_ms; + cbm_inventory_filter_usage_t usage; + cbm_inventory_filter_view_t view; + cbm_inventory_file_t *files; + cbm_inventory_filter_row_t *rows; + cif_directory *directories; + size_t *file_next; + cbm_inventory_control_row_t *controls; + cbm_inventory_control_row_t cbm_control; + cbm_inventory_path_t *exclusions; + cbm_ignore_checked_t *ignore; + const cbm_ignore_program_t *cbm_program; + unsigned char *scratch; + size_t scratch_capacity; +}; + +/* Only this synchronous stack context borrows caller functions and contexts. */ +typedef struct { + cbm_inventory_filter_t *owner; + const cbm_inventory_source_t *source; + const cbm_inventory_control_t *control; + cbm_inventory_error_t error; + size_t file_index, byte_gap, event_gap; +} cif_context; + +bool cif_fail(cif_context *c, cbm_inventory_status_t status); +bool cif_poll(cif_context *c); +bool cif_bytes(cif_context *c, size_t count); +bool cif_event(cif_context *c); +bool cif_copy(cif_context *c, void *destination, const void *source, size_t length); +void *cif_alloc(cif_context *c, size_t count, size_t width); +unsigned char *cif_string(cif_context *c, const unsigned char *source, size_t length); +bool cif_compare(cif_context *c, cbm_inventory_path_t a, cbm_inventory_path_t b, int *result); +bool cif_checked(cif_context *c, cbm_ignore_checked_status_t status); +cbm_ignore_checked_control_t cif_ignore_control(const cif_context *c); +bool cif_metadata(cif_context *c); +bool cif_directories(cif_context *c); +bool cif_find_file(cif_context *c, cbm_inventory_path_t path, size_t *index); +bool cif_find_directory(cif_context *c, cbm_inventory_path_t path, size_t *index); +bool cif_load_controls(cif_context *c, size_t directory); +bool cif_filter(cif_context *c); + +#endif diff --git a/src/discover/inventory_limits_internal.h b/src/discover/inventory_limits_internal.h new file mode 100644 index 000000000..9d58a58d7 --- /dev/null +++ b/src/discover/inventory_limits_internal.h @@ -0,0 +1,9 @@ +#ifndef CBM_INVENTORY_LIMITS_INTERNAL_H +#define CBM_INVENTORY_LIMITS_INTERNAL_H + +#include "discover/inventory_filter.h" + +/* Shared Boolean configuration check; arithmetic exhaustion is checked separately. */ +bool cif_limits_valid(const cbm_inventory_limits_t *limits); + +#endif diff --git a/src/discover/inventory_metadata.c b/src/discover/inventory_metadata.c new file mode 100644 index 000000000..b0c65edf7 --- /dev/null +++ b/src/discover/inventory_metadata.c @@ -0,0 +1,229 @@ +#include "discover/inventory_internal.h" +#include "foundation/path_syntax_internal.h" +#include + +static bool cif_separator(unsigned char ch) { +#ifdef _WIN32 + return ch == '/' || ch == '\\'; +#else + return ch == '/'; +#endif +} + +#ifdef _WIN32 +static bool cif_windows_encoding(cif_context *c, const char *path, size_t length) { + wchar_t wide[CIF_PATH_MAX + 1]; + char roundtrip[CIF_PATH_MAX + 1]; + if (!cif_poll(c)) { + return false; + } + cbm_path_syntax_result_t s = cbm_path_windows_roundtrip(path, length, wide, CIF_PATH_MAX + 1, + roundtrip, sizeof(roundtrip)); + if (s != CBM_PATH_SYNTAX_OK) { + return cif_fail(c, CBM_INVENTORY_UNSUPPORTED); + } + return cif_poll(c); +} + +static bool cif_device_root(const unsigned char *root, size_t n) { + if (n < 4) { + return false; + } + return (cif_separator(root[0]) && cif_separator(root[1]) && + (root[2] == '?' || root[2] == '.') && cif_separator(root[3])) || + (root[0] == '\\' && root[1] == '?' && root[2] == '?' && root[3] == '\\'); +} +#endif + +static bool cif_root(cif_context *c, size_t *length) { + const unsigned char *root = (const unsigned char *)c->source->native_root; + size_t n = 0; + for (; n <= CIF_PATH_MAX; n++) { + if (!cif_bytes(c, 1)) { + return false; + } + if (!root[n]) { + break; + } + } + if (!n) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } + if (n > CIF_PATH_MAX) { + return cif_fail(c, CBM_INVENTORY_LIMIT); + } +#ifdef _WIN32 + if (cif_device_root(root, n)) { + return cif_fail(c, CBM_INVENTORY_UNSUPPORTED); + } + if (!cif_poll(c)) { + return false; + } + if (cbm_path_windows_absolute((const char *)root, n) != CBM_PATH_SYNTAX_OK) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } + if (!cif_poll(c) || !cif_windows_encoding(c, (const char *)root, n)) { + return false; + } +#else + if (root[0] != '/') { + return cif_fail(c, CBM_INVENTORY_INVALID); + } +#endif + *length = n; + c->owner->view.native_root = (const char *)cif_string(c, root, n); + return c->owner->view.native_root != NULL; +} + +static bool cif_component(cif_context *c, const unsigned char *p, size_t n) { + if (!n || (n == 1 && p[0] == '.') || (n == 2 && p[0] == '.' && p[1] == '.')) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } +#ifdef _WIN32 + if (!cif_poll(c)) { + return false; + } + if (cbm_path_windows_component(p, n) != CBM_PATH_SYNTAX_OK) { + return cif_fail(c, CBM_INVENTORY_UNSUPPORTED); + } + return cif_poll(c); +#else + return true; +#endif +} + +static bool cif_file_path(cif_context *c, cbm_inventory_path_t path, size_t root_length) { + if (path.length > CIF_PATH_MAX) { + return cif_fail(c, CBM_INVENTORY_LIMIT); + } + if (!path.data || !path.length) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } + size_t separator = + cif_separator((unsigned char)c->owner->view.native_root[root_length - 1]) ? 0 : 1; + if (path.length > CIF_PATH_MAX - root_length || + separator > CIF_PATH_MAX - root_length - path.length) { + return cif_fail(c, CBM_INVENTORY_LIMIT); + } + size_t start = 0; + for (size_t i = 0; i <= path.length; i++) { + if (!cif_bytes(c, 1)) { + return false; + } + unsigned char ch = path.data[i]; + if ((ch == 0) != (i == path.length)) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } + if (!ch || ch == '/') { + if (!cif_component(c, path.data + start, i - start)) { + return false; + } + start = i + 1; + } + } +#ifdef _WIN32 + return cif_windows_encoding(c, (const char *)path.data, path.length); +#else + return true; +#endif +} + +static bool cif_oid(cif_context *c, const char oid[65], size_t *width) { + size_t n = 0; + while (n < 65) { + if (!cif_bytes(c, 1)) { + return false; + } + unsigned char ch = (unsigned char)oid[n]; + if (!ch) { + break; + } + if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } + n++; + } + if ((n != 40 && n != 64) || (*width && *width != n)) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } + *width = n; + return true; +} + +static bool cif_file(cif_context *c, size_t i, size_t root_length, size_t *width) { + const cbm_inventory_file_t *in = &c->source->files[i]; + cbm_inventory_file_t *out = &c->owner->files[i]; + c->file_index = i; + if (!cif_event(c) || !cif_bytes(c, 256) || !cif_file_path(c, in->path, root_length)) { + return false; + } + if (i) { + int order = 0; + if (!cif_compare(c, c->owner->files[i - 1].path, in->path, &order)) { + return false; + } + if (order >= 0) { + return cif_fail(c, CBM_INVENTORY_INVALID); + } + } + if (in->git_mode != 0100644 && in->git_mode != 0100755) { + return cif_fail(c, CBM_INVENTORY_UNSUPPORTED); + } + if (in->content_length > INT64_MAX) { + return cif_fail(c, CBM_INVENTORY_LIMIT); + } + if (!cif_oid(c, in->oid, width)) { + return false; + } + out->path.data = cif_string(c, in->path.data, in->path.length); + if (!out->path.data || !cif_bytes(c, 128)) { + return false; + } + out->path.length = in->path.length; + out->git_mode = in->git_mode; + out->content_length = in->content_length; + if (!out->path.data || !cif_copy(c, out->oid, in->oid, *width + 1) || + !cif_copy(c, out->content_sha256, in->content_sha256, 32)) { + return false; + } + cbm_inventory_filter_row_t *row = &c->owner->rows[i]; + row->file_index = i; + row->disposition = CBM_INVENTORY_FILTER_NEEDS_LANGUAGE; + static const unsigned char config[] = ".codebase-memory.json"; + int order = 0; + if (!cif_compare(c, out->path, (cbm_inventory_path_t){config, sizeof(config) - 1}, &order)) { + return false; + } + if (!cif_bytes(c, 128)) { + return false; + } + if (!order) { + row->roles = CBM_INVENTORY_FILTER_ROLE_PHYSICAL_PROJECT_CONFIG; + } + c->owner->file_next[i] = SIZE_MAX; + return true; +} + +bool cif_metadata(cif_context *c) { + cbm_inventory_filter_t *o = c->owner; + size_t root_length = 0, width = 0; + if (!cif_root(c, &root_length)) { + return false; + } + size_t n = c->source->file_count; + o->view.file_count = n; + o->files = cif_alloc(c, n, sizeof(*o->files)); + o->rows = cif_alloc(c, n, sizeof(*o->rows)); + o->file_next = cif_alloc(c, n, sizeof(*o->file_next)); + if ((n && (!o->files || !o->rows || !o->file_next)) || + !cif_copy(c, o->view.manifest_sha256, c->source->manifest_sha256, 32)) { + return false; + } + for (size_t i = 0; i < n; i++) { + if (!cif_file(c, i, root_length, &width)) { + return false; + } + } + c->file_index = SIZE_MAX; + return true; +} diff --git a/src/discover/test_conventions.c b/src/discover/test_conventions.c new file mode 100644 index 000000000..4be005157 --- /dev/null +++ b/src/discover/test_conventions.c @@ -0,0 +1,302 @@ +/* Immutable test-convention declarations from one project JSON snapshot. */ +#include "discover/test_conventions.h" +#include "foundation/arena.h" +#include "foundation/sha256.h" +#include +#include +#include +#include + +enum { TD_MAX_BYTES = 65536, TD_MAX_NAME_ARGS = 64 }; + +struct cbm_test_declarations { + CBMArena arena; + cbm_test_declaration_t *items; + int count; + bool configured; + bool presets[CBM_TEST_PRESET_COUNT]; + char digest[CBM_SHA256_HEX_LEN + 1]; +}; + +static yyjson_val *td_field(yyjson_val *object, const char *name, bool *valid) { + if (!yyjson_is_obj(object)) { + *valid = false; + return NULL; + } + yyjson_val *found = NULL, *key, *value; + size_t index, maximum, len = strlen(name); + yyjson_obj_foreach(object, index, maximum, key, value) { + if (yyjson_get_len(key) == len && memcmp(yyjson_get_str(key), name, len) == 0) { + if (found) { + *valid = false; + return NULL; + } + found = value; + } + } + return found; +} + +static const char *td_string(yyjson_val *value, size_t *len) { + const char *text = yyjson_get_str(value); + size_t size = yyjson_get_len(value); + if (!text || !size || memchr(text, '\0', size) || memchr(text, '\n', size) || + memchr(text, '\r', size)) + return NULL; + if (len) + *len = size; + return text; +} + +static bool td_identifier_first(unsigned char ch) { + return ch == '_' || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); +} + +static bool td_identifier(const char *text, size_t len) { + if (!len || !td_identifier_first((unsigned char)text[0])) + return false; + for (size_t i = 1; i < len; i++) { + unsigned char ch = (unsigned char)text[i]; + if (!td_identifier_first(ch) && !(ch >= '0' && ch <= '9')) + return false; + } + return true; +} + +static bool td_copy_string(cbm_test_declarations_t *declarations, yyjson_val *value, + bool identifier, const char **result) { + size_t len = 0; + const char *text = td_string(value, &len); + if (!text || (identifier && !td_identifier(text, len))) + return false; + *result = cbm_arena_strndup(&declarations->arena, text, len); + return *result != NULL; +} + +static bool td_argument(yyjson_val *value, int *result) { + if (!yyjson_is_uint(value) || yyjson_get_uint(value) > INT_MAX) + return false; + *result = (int)yyjson_get_uint(value); + return true; +} + +static bool td_name_args(cbm_test_declarations_t *declarations, yyjson_val *value, + cbm_test_declaration_t *item) { + if (!yyjson_is_arr(value)) + return false; + size_t count = yyjson_arr_size(value); + if (!count || count > TD_MAX_NAME_ARGS) + return false; + int *args = cbm_arena_alloc(&declarations->arena, count * sizeof(*args)); + if (!args) + return false; + size_t index, maximum; + yyjson_val *argument; + yyjson_arr_foreach(value, index, maximum, argument) { + if (!td_argument(argument, &args[index])) + return false; + } + /* Ordered duplicates carry meaning for an adapter, so retain them. */ + item->name_args = args; + item->name_arg_count = (int)count; + return true; +} + +static bool td_record(cbm_test_declarations_t *declarations, yyjson_val *record, + cbm_test_declaration_t *item) { + bool valid = true; + yyjson_val *language = td_field(record, "language", &valid); + yyjson_val *role = td_field(record, "role", &valid); + yyjson_val *define_macro = td_field(record, "define_macro", &valid); + yyjson_val *name_args = td_field(record, "name_args", &valid); + yyjson_val *runner_id = td_field(record, "runner_id", &valid); + yyjson_val *macro = td_field(record, "macro", &valid); + yyjson_val *test_arg = td_field(record, "test_arg", &valid); + yyjson_val *suite_arg = td_field(record, "suite_arg", &valid); + yyjson_val *perf_macro = td_field(record, "perf_macro", &valid); + const char *role_text = td_string(role, NULL); + if (!valid || !role_text || !td_copy_string(declarations, language, false, &item->language)) + return false; + item->test_arg = -1; + item->suite_arg = -1; + if (strcmp(role_text, "case") == 0 || strcmp(role_text, "suite") == 0) { + bool is_case = strcmp(role_text, "case") == 0; + item->role = is_case ? CBM_TEST_DECL_CASE : CBM_TEST_DECL_SUITE; + if (macro || test_arg || suite_arg || perf_macro || (!is_case && runner_id)) + return false; + return td_copy_string(declarations, define_macro, true, &item->define_macro) && + td_name_args(declarations, name_args, item) && + (!runner_id || td_copy_string(declarations, runner_id, false, &item->runner_id)); + } + if (strcmp(role_text, "registration") == 0) { + item->role = CBM_TEST_DECL_REGISTRATION; + if (define_macro || name_args || runner_id || suite_arg || perf_macro) + return false; + return td_copy_string(declarations, macro, true, &item->macro) && + td_argument(test_arg, &item->test_arg); + } + if (strcmp(role_text, "suite_registration") == 0) { + item->role = CBM_TEST_DECL_SUITE_REGISTRATION; + if (define_macro || name_args || runner_id || test_arg) + return false; + return td_copy_string(declarations, macro, true, &item->macro) && + td_argument(suite_arg, &item->suite_arg) && + (!perf_macro || td_copy_string(declarations, perf_macro, true, &item->perf_macro)); + } + return false; +} + +static bool td_conventions(cbm_test_declarations_t *declarations, yyjson_val *value) { + if (!yyjson_is_arr(value)) + return false; + size_t count = yyjson_arr_size(value); + if (count > INT_MAX || count > SIZE_MAX / sizeof(*declarations->items)) + return false; + if (!count) + return true; + declarations->items = + cbm_arena_calloc(&declarations->arena, count * sizeof(*declarations->items)); + if (!declarations->items) + return false; + size_t index, maximum; + yyjson_val *record; + yyjson_arr_foreach(value, index, maximum, record) { + if (!td_record(declarations, record, &declarations->items[index])) + return false; + } + declarations->count = (int)count; + return true; +} + +static bool td_presets(cbm_test_declarations_t *declarations, yyjson_val *value) { + static const char *const names[CBM_TEST_PRESET_COUNT] = { + [CBM_TEST_PRESET_C_CBM] = "c-cbm", [CBM_TEST_PRESET_PYTEST] = "pytest", + [CBM_TEST_PRESET_GO] = "go", [CBM_TEST_PRESET_JUNIT] = "junit", + [CBM_TEST_PRESET_GTEST] = "gtest", [CBM_TEST_PRESET_RUST] = "rust", + [CBM_TEST_PRESET_JEST] = "jest"}; + if (!yyjson_is_obj(value)) + return false; + bool seen[CBM_TEST_PRESET_COUNT] = {false}; + size_t index, maximum; + yyjson_val *key, *enabled; + yyjson_obj_foreach(value, index, maximum, key, enabled) { + int preset = -1; + size_t len = yyjson_get_len(key); + const char *text = yyjson_get_str(key); + for (int candidate = 0; candidate < CBM_TEST_PRESET_COUNT; candidate++) { + if (len == strlen(names[candidate]) && memcmp(text, names[candidate], len) == 0) { + preset = candidate; + break; + } + } + /* An unknown switch is an unsupported semantic request, not an + * unrelated future field whose intent can safely remain unconsumed. */ + if (preset < 0 || seen[preset] || !yyjson_is_bool(enabled)) + return false; + seen[preset] = true; + declarations->presets[preset] = yyjson_get_bool(enabled); + } + return true; +} + +static bool td_project(cbm_test_declarations_t *declarations, yyjson_val *root) { + bool valid = true; + yyjson_val *impact = td_field(root, "test_impact", &valid); + if (!valid || !impact) + return valid; + yyjson_val *version = td_field(impact, "version", &valid); + yyjson_val *tests = td_field(impact, "tests", &valid); + if (!valid || !yyjson_is_uint(version) || yyjson_get_uint(version) != 1) + return false; + declarations->configured = true; + if (!tests) + return true; + yyjson_val *conventions = td_field(tests, "conventions", &valid); + yyjson_val *presets = td_field(tests, "presets", &valid); + if (!valid) + return false; + return (!conventions || td_conventions(declarations, conventions)) && + (!presets || td_presets(declarations, presets)); +} + +static void td_digest(cbm_test_declarations_t *declarations, const char *bytes, size_t len, + bool absent) { + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + const char *domain = absent ? "cbm-test-config-absent-v1" : "cbm-test-config-bytes-v1"; + cbm_sha256_update(&hash, domain, strlen(domain) + 1); + if (len) + cbm_sha256_update(&hash, bytes, len); + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(&hash, digest); + static const char hex[] = "0123456789abcdef"; + for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) { + declarations->digest[i * 2] = hex[digest[i] >> 4]; + declarations->digest[i * 2 + 1] = hex[digest[i] & 15]; + } + declarations->digest[CBM_SHA256_HEX_LEN] = '\0'; +} + +cbm_test_declarations_t *cbm_test_declarations_parse(const char *bytes, size_t len, bool absent) { + if (len > TD_MAX_BYTES || (absent && len) || (!absent && (!bytes || !len)) || + (len && memchr(bytes, '\0', len))) + return NULL; + CBMArena arena; + cbm_arena_init_lazy(&arena, 4096); + cbm_test_declarations_t *declarations = cbm_arena_calloc(&arena, sizeof(*declarations)); + if (!declarations) { + cbm_arena_destroy(&arena); + return NULL; + } + declarations->arena = arena; + for (int preset = 0; preset < CBM_TEST_PRESET_COUNT; preset++) + declarations->presets[preset] = preset != CBM_TEST_PRESET_C_CBM; + bool valid = true; + if (!absent) { + yyjson_doc *doc = yyjson_read(bytes, len, 0); + if (!doc) { + valid = false; + } else { + valid = td_project(declarations, yyjson_doc_get_root(doc)); + yyjson_doc_free(doc); + } + } + if (!valid) { + cbm_test_declarations_free(declarations); + return NULL; + } + td_digest(declarations, bytes, len, absent); + return declarations; +} + +void cbm_test_declarations_free(cbm_test_declarations_t *declarations) { + if (!declarations) + return; + CBMArena arena = declarations->arena; + cbm_arena_destroy(&arena); +} + +bool cbm_test_declarations_configured(const cbm_test_declarations_t *declarations) { + return declarations && declarations->configured; +} + +const cbm_test_declaration_t *cbm_test_declarations_items( + const cbm_test_declarations_t *declarations, int *count) { + if (count) + *count = declarations ? declarations->count : 0; + return declarations ? declarations->items : NULL; +} + +bool cbm_test_declarations_preset(const cbm_test_declarations_t *declarations, + cbm_test_preset_t preset, bool *enabled) { + if (enabled) + *enabled = false; + if (!declarations || !enabled || (unsigned)preset >= CBM_TEST_PRESET_COUNT) + return false; + *enabled = declarations->presets[preset]; + return true; +} + +const char *cbm_test_declarations_digest(const cbm_test_declarations_t *declarations) { + return declarations ? declarations->digest : ""; +} diff --git a/src/discover/test_conventions.h b/src/discover/test_conventions.h new file mode 100644 index 000000000..4626310cd --- /dev/null +++ b/src/discover/test_conventions.h @@ -0,0 +1,62 @@ +#ifndef CBM_TEST_CONVENTIONS_H +#define CBM_TEST_CONVENTIONS_H +#include +#include + +/* Internal immutable descriptor snapshot for the approved tests.conventions + * and tests.presets schema. This parser classifies no source and creates no + * TESTS/macro edges. Consumers must separately verify that they can implement + * every applicable declaration; an unsupported record is never ignored to + * justify narrowing. */ +typedef enum { + CBM_TEST_DECL_CASE, + CBM_TEST_DECL_SUITE, + CBM_TEST_DECL_REGISTRATION, + CBM_TEST_DECL_SUITE_REGISTRATION +} cbm_test_decl_role_t; + +typedef struct { + const char *language; + cbm_test_decl_role_t role; + const char *define_macro; /* definition roles only */ + const int *name_args; /* definition roles; preserve order */ + int name_arg_count; + const char *runner_id; /* optional case template; NULL != guessed default */ + const char *macro; /* registration roles only */ + int test_arg; /* registration; -1 otherwise */ + int suite_arg; /* suite_registration; -1 otherwise */ + const char *perf_macro; /* optional suite_registration alternate */ +} cbm_test_declaration_t; + +typedef enum { + CBM_TEST_PRESET_C_CBM, + CBM_TEST_PRESET_PYTEST, + CBM_TEST_PRESET_GO, + CBM_TEST_PRESET_JUNIT, + CBM_TEST_PRESET_GTEST, + CBM_TEST_PRESET_RUST, + CBM_TEST_PRESET_JEST, + CBM_TEST_PRESET_COUNT +} cbm_test_preset_t; + +typedef struct cbm_test_declarations cbm_test_declarations_t; + +/* Full project JSON, explicit bytes, max64KiB, independently owned. absent + * requires len=0 (NULL bytes allowed); malformed or unsupported consumed + * syntax => NULL. No path read, global state, env or shell evaluation. + * Missing tests/conventions => empty custom list. Missing preset switches + * preserve existing defaults (c-cbm false; six language presets true). + * A missing impact section is distinct from a configured one with defaults. + * runner_id is retained as opaque data; adapters must validate its template + * language and produce an error for missing/unsupported required mappings. */ +cbm_test_declarations_t *cbm_test_declarations_parse(const char *bytes, size_t len, bool absent); +void cbm_test_declarations_free(cbm_test_declarations_t *declarations); +bool cbm_test_declarations_configured(const cbm_test_declarations_t *declarations); +const cbm_test_declaration_t *cbm_test_declarations_items( + const cbm_test_declarations_t *declarations, int *count); +/* false for invalid object/enum; clears enabled first. */ +bool cbm_test_declarations_preset(const cbm_test_declarations_t *declarations, + cbm_test_preset_t preset, bool *enabled); +const char *cbm_test_declarations_digest(const cbm_test_declarations_t *declarations); + +#endif diff --git a/src/discover/userconfig.c b/src/discover/userconfig.c index 8ba63d5bd..c2b45b788 100644 --- a/src/discover/userconfig.c +++ b/src/discover/userconfig.c @@ -1,3 +1,4 @@ +#include "foundation/arena.h" /* public lazy-arena declarations before cbm.h */ /* * userconfig.c — User-defined extension→language mappings. * @@ -15,6 +16,7 @@ #include "foundation/platform.h" /* cbm_safe_getenv */ #include "foundation/compat_fs.h" #include "foundation/sha256.h" +#include "foundation/arena.h" enum { MAX_CONFIG_SIZE = 65536 }; #include "foundation/log.h" @@ -30,6 +32,21 @@ enum { MAX_CONFIG_SIZE = 65536 }; static const cbm_userconfig_t *g_userconfig = NULL; +struct cbm_userconfig_source { + CBMArena arena; + cbm_userconfig_source_state_t state; + const char *bytes; + size_t len; + bool owns_config_arena; /* false for both legacy loaders */ +}; + +static void userconfig_source_free(cbm_userconfig_source_t *source) { + if (source) { + CBMArena arena = source->arena; + cbm_arena_destroy(&arena); + } +} + static void userconfig_source_digest(const char *state, const void *bytes, size_t len, char out[CBM_SHA256_HEX_LEN + 1]) { static const char domain[] = "cbm-userconfig-source-v1"; @@ -315,14 +332,75 @@ static int load_config_file(const char *path, cbm_userext_t **entries, int *coun return rc; } +/* Retain the project input before parsing it; never reopen for declarations. */ +static int load_project_source(const char *path, cbm_userconfig_t *cfg, + cbm_userext_t **entries, int *count) { + cbm_userconfig_source_t *source = cfg->project_source; + source->state = CBM_USERCONFIG_SOURCE_ERROR; + userconfig_source_digest("read-error", NULL, 0, cfg->project_source_sha256); + cbm_path_info_t info = {0}; + int status = cbm_path_info_utf8(path, &info); + if (status == CBM_PATH_INFO_ABSENT) { + source->state = CBM_USERCONFIG_SOURCE_ABSENT; + /* Preserve the existing digest for actual absence. */ + userconfig_source_digest("missing-or-unreadable", NULL, 0, cfg->project_source_sha256); + return 0; + } + if (status != CBM_PATH_INFO_OK || !info.is_regular || info.size > MAX_CONFIG_SIZE) + return 0; + char *bytes = cbm_arena_alloc(&source->arena, MAX_CONFIG_SIZE + 1U); + if (!bytes) + return CBM_NOT_FOUND; + FILE *file = cbm_fopen(path, "rb"); + if (!file) + return 0; + size_t len = fread(bytes, 1, MAX_CONFIG_SIZE + 1U, file); + bool valid = !ferror(file) && feof(file) && len <= MAX_CONFIG_SIZE; + if (fclose(file) != 0) + valid = false; + if (!valid) + return 0; + bytes[len] = '\0'; + source->state = CBM_USERCONFIG_SOURCE_PRESENT; + source->bytes = bytes; + source->len = len; + userconfig_source_digest(len ? "present" : "empty", bytes, len, cfg->project_source_sha256); + if (!len) + return 0; + yyjson_read_err error = {0}; + yyjson_doc *doc = yyjson_read_opts(bytes, len, 0, NULL, &error); + if (!doc) { + if (error.code == YYJSON_READ_ERROR_MEMORY_ALLOCATION) + return CBM_NOT_FOUND; + cbm_log_warn("userconfig.corrupt_json", "path", path); + return 0; + } + int rc = parse_extra_extensions(yyjson_doc_get_root(doc), entries, count, path); + yyjson_doc_free(doc); + return rc; +} + /* ── Public API ──────────────────────────────────────────────────── */ -cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) { +static cbm_userconfig_t *userconfig_load(const char *repo_path, bool retain_source) { cbm_userconfig_t *cfg = calloc(CBM_ALLOC_ONE, sizeof(cbm_userconfig_t)); if (!cfg) { return NULL; } + if (retain_source) { + CBMArena arena; + cbm_arena_init(&arena); + cfg->project_source = cbm_arena_calloc(&arena, sizeof(*cfg->project_source)); + if (!cfg->project_source) { + cbm_arena_destroy(&arena); + free(cfg); + return NULL; + } + cfg->project_source->arena = arena; + cfg->project_source->state = CBM_USERCONFIG_SOURCE_ABSENT; + } + cbm_userext_t *entries = NULL; int count = 0; @@ -338,6 +416,7 @@ cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) { free(entries[i].ext); } free(entries); + userconfig_source_free(cfg->project_source); free(cfg); return NULL; } @@ -348,14 +427,27 @@ cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) { userconfig_source_digest("not-applicable", NULL, 0, cfg->project_source_sha256); if (repo_path && repo_path[0]) { char project_path[PATH_BUF_SZ]; - snprintf(project_path, sizeof(project_path), "%s/.codebase-memory.json", repo_path); - - if (load_config_file(project_path, &entries, &count, cfg->project_source_sha256) != 0) { + int path_len = snprintf(project_path, sizeof(project_path), "%s/.codebase-memory.json", + repo_path); + int rc; + if (retain_source) { + if (path_len < 0 || (size_t)path_len >= sizeof(project_path)) { + cfg->project_source->state = CBM_USERCONFIG_SOURCE_ERROR; + userconfig_source_digest("path-error", NULL, 0, cfg->project_source_sha256); + rc = 0; + } else { + rc = load_project_source(project_path, cfg, &entries, &count); + } + } else { + rc = load_config_file(project_path, &entries, &count, cfg->project_source_sha256); + } + if (rc != 0) { /* Free already-allocated entries */ for (int i = 0; i < count; i++) { free(entries[i].ext); } free(entries); + userconfig_source_free(cfg->project_source); free(cfg); return NULL; } @@ -398,6 +490,32 @@ cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) { return cfg; } +cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) { + return userconfig_load(repo_path, false); +} + +cbm_userconfig_t *cbm_userconfig_load_with_source(const char *repo_path) { + return userconfig_load(repo_path, true); +} + +cbm_userconfig_source_state_t cbm_userconfig_project_source(const cbm_userconfig_t *cfg, + const char **bytes, size_t *len) { + if (bytes) + *bytes = NULL; + if (len) + *len = 0; + if (!cfg || !cfg->project_source) + return CBM_USERCONFIG_SOURCE_ERROR; + const cbm_userconfig_source_t *source = cfg->project_source; + if (source->state == CBM_USERCONFIG_SOURCE_PRESENT) { + if (bytes) + *bytes = source->bytes; + if (len) + *len = source->len; + } + return source->state; +} + CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext) { if (!cfg || !ext || !ext[0]) { return CBM_LANG_COUNT; @@ -414,9 +532,166 @@ void cbm_userconfig_free(cbm_userconfig_t *cfg) { if (!cfg) { return; } + if (cfg->project_source && cfg->project_source->owns_config_arena) { + CBMArena arena = cfg->project_source->arena; + cbm_arena_destroy(&arena); + return; + } for (int i = 0; i < cfg->count; i++) { free(cfg->entries[i].ext); } free(cfg->entries); + userconfig_source_free(cfg->project_source); free(cfg); } + +/* Frozen project-only input. The legacy pathname loaders remain fail-open. */ +static bool snapshot_key_is(yyjson_val *key, const char *name) { + size_t len = strlen(name); + return yyjson_get_len(key) == len && memcmp(yyjson_get_str(key), name, len) == 0; +} + +static cbm_userconfig_snapshot_status_t snapshot_extensions(yyjson_val *root, yyjson_val **out) { + *out = NULL; + if (!yyjson_is_obj(root)) + return CBM_USERCONFIG_SNAPSHOT_INVALID; + yyjson_obj_iter iter; + yyjson_obj_iter_init(root, &iter); + yyjson_val *key; + while ((key = yyjson_obj_iter_next(&iter)) != NULL) { + if (!snapshot_key_is(key, "extra_extensions")) + continue; + if (*out) + return CBM_USERCONFIG_SNAPSHOT_INVALID; + *out = yyjson_obj_iter_get_val(key); + if (!yyjson_is_obj(*out)) + return CBM_USERCONFIG_SNAPSHOT_INVALID; + } + return CBM_USERCONFIG_SNAPSHOT_OK; +} + +static bool snapshot_string(yyjson_val *value) { + if (!yyjson_is_str(value) || yyjson_get_len(value) == 0) + return false; + return memchr(yyjson_get_str(value), 0, yyjson_get_len(value)) == NULL; +} + +/* Frozen config matching must not depend on the process locale. */ +static CBMLanguage snapshot_language(yyjson_val *value) { + const unsigned char *name = (const unsigned char *)yyjson_get_str(value); + size_t len = yyjson_get_len(value); + for (size_t i = 0; i < LANG_NAME_TABLE_SIZE; i++) { + const char *alias = LANG_NAME_TABLE[i].name; + if (strlen(alias) != len) + continue; + size_t j = 0; + for (; j < len; j++) { + unsigned char byte = name[j]; + if (byte >= 'A' && byte <= 'Z') + byte = (unsigned char)(byte + ('a' - 'A')); + if (byte != (unsigned char)alias[j]) + break; + } + if (j == len) + return LANG_NAME_TABLE[i].lang; + } + return CBM_LANG_COUNT; +} + +static cbm_userconfig_snapshot_status_t snapshot_entry(CBMArena *arena, cbm_userconfig_t *cfg, + yyjson_val *key, yyjson_val *value) { + if (!snapshot_string(key) || !snapshot_string(value) || yyjson_get_str(key)[0] != '.') + return CBM_USERCONFIG_SNAPSHOT_INVALID; + const char *ext = yyjson_get_str(key); + for (int i = 0; i < cfg->count; i++) { + if (strcmp(cfg->entries[i].ext, ext) == 0) + return CBM_USERCONFIG_SNAPSHOT_INVALID; + } + CBMLanguage language = snapshot_language(value); + if (language == CBM_LANG_COUNT) + return CBM_USERCONFIG_SNAPSHOT_UNSUPPORTED; + char *copy = cbm_arena_strndup(arena, ext, yyjson_get_len(key)); + if (!copy) + return CBM_USERCONFIG_SNAPSHOT_OOM; + cfg->entries[cfg->count++] = (cbm_userext_t){.ext = copy, .lang = language}; + return CBM_USERCONFIG_SNAPSHOT_OK; +} + +static cbm_userconfig_snapshot_status_t snapshot_parse(CBMArena *arena, cbm_userconfig_t *cfg) { + cbm_userconfig_source_t *source = cfg->project_source; + yyjson_read_err error = {0}; + yyjson_doc *doc = yyjson_read_opts((char *)source->bytes, source->len, 0, NULL, &error); + if (!doc) + return error.code == YYJSON_READ_ERROR_MEMORY_ALLOCATION ? CBM_USERCONFIG_SNAPSHOT_OOM + : CBM_USERCONFIG_SNAPSHOT_INVALID; + yyjson_val *extra = NULL; + cbm_userconfig_snapshot_status_t status = snapshot_extensions(yyjson_doc_get_root(doc), &extra); + size_t count = extra ? yyjson_obj_size(extra) : 0; + /* The 64 KiB source cap already bounds count; keep allocation math explicit. */ + if (count > MAX_CONFIG_SIZE || count > SIZE_MAX / sizeof(*cfg->entries)) + status = CBM_USERCONFIG_SNAPSHOT_LIMIT; + if (status == CBM_USERCONFIG_SNAPSHOT_OK && count > 0) { + cfg->entries = cbm_arena_calloc(arena, count * sizeof(*cfg->entries)); + if (!cfg->entries) + status = CBM_USERCONFIG_SNAPSHOT_OOM; + } + if (status == CBM_USERCONFIG_SNAPSHOT_OK && extra) { + yyjson_obj_iter iter; + yyjson_obj_iter_init(extra, &iter); + yyjson_val *key; + while ((key = yyjson_obj_iter_next(&iter)) != NULL) { + status = snapshot_entry(arena, cfg, key, yyjson_obj_iter_get_val(key)); + if (status != CBM_USERCONFIG_SNAPSHOT_OK) + break; + } + } + yyjson_doc_free(doc); + return status; +} + +static cbm_userconfig_snapshot_status_t snapshot_input(cbm_userconfig_source_state_t state, + const void *bytes, size_t len) { + if (state == CBM_USERCONFIG_SOURCE_ABSENT) + return (!bytes && len == 0) ? CBM_USERCONFIG_SNAPSHOT_OK : CBM_USERCONFIG_SNAPSHOT_INVALID; + if (state != CBM_USERCONFIG_SOURCE_PRESENT || !bytes || len == 0) + return CBM_USERCONFIG_SNAPSHOT_INVALID; + return len > MAX_CONFIG_SIZE ? CBM_USERCONFIG_SNAPSHOT_LIMIT : CBM_USERCONFIG_SNAPSHOT_OK; +} + +cbm_userconfig_snapshot_status_t +cbm_userconfig_from_project_bytes(cbm_userconfig_source_state_t state, const void *bytes, + size_t len, cbm_userconfig_t **out) { + if (!out) + return CBM_USERCONFIG_SNAPSHOT_INVALID; + *out = NULL; + cbm_userconfig_snapshot_status_t status = snapshot_input(state, bytes, len); + if (status != CBM_USERCONFIG_SNAPSHOT_OK) + return status; + CBMArena arena; + cbm_arena_init_lazy(&arena, CBM_ARENA_DEFAULT_BLOCK_SIZE); + cbm_userconfig_t *cfg = cbm_arena_calloc(&arena, sizeof(*cfg)); + cbm_userconfig_source_t *source = cbm_arena_calloc(&arena, sizeof(*source)); + if (!cfg || !source) { + cbm_arena_destroy(&arena); + return CBM_USERCONFIG_SNAPSHOT_OOM; + } + cfg->project_source = source; + source->state = state; + source->len = len; + if (state == CBM_USERCONFIG_SOURCE_PRESENT) { + source->bytes = cbm_arena_strndup(&arena, bytes, len); + status = source->bytes ? snapshot_parse(&arena, cfg) : CBM_USERCONFIG_SNAPSHOT_OOM; + } + if (status != CBM_USERCONFIG_SNAPSHOT_OK) { + cbm_arena_destroy(&arena); + return status; + } + userconfig_source_digest("disabled-frozen-v1", NULL, 0, cfg->global_source_sha256); + userconfig_source_digest(state == CBM_USERCONFIG_SOURCE_ABSENT ? "missing-or-unreadable" + : "present", + source->bytes, len, cfg->project_source_sha256); + source->owns_config_arena = true; + source->arena = arena; + *out = cfg; + return CBM_USERCONFIG_SNAPSHOT_OK; +} diff --git a/src/discover/userconfig.h b/src/discover/userconfig.h index 5d24ec183..cd946e4aa 100644 --- a/src/discover/userconfig.h +++ b/src/discover/userconfig.h @@ -27,9 +27,17 @@ typedef struct { CBMLanguage lang; /* resolved language enum */ } cbm_userext_t; +typedef struct cbm_userconfig_source cbm_userconfig_source_t; +typedef enum { + CBM_USERCONFIG_SOURCE_ERROR = 0, + CBM_USERCONFIG_SOURCE_ABSENT, + CBM_USERCONFIG_SOURCE_PRESENT +} cbm_userconfig_source_state_t; + typedef struct { cbm_userext_t *entries; /* heap-allocated array */ int count; /* number of entries */ + cbm_userconfig_source_t *project_source; /* optional owned project snapshot */ /* Digests of the exact bytes/state consumed by cbm_userconfig_load(). */ char global_source_sha256[CBM_SHA256_HEX_LEN + 1]; char project_source_sha256[CBM_SHA256_HEX_LEN + 1]; @@ -46,6 +54,18 @@ typedef struct { */ cbm_userconfig_t *cbm_userconfig_load(const char *repo_path); +/* Additive indexing loader: retain exactly the project bytes used for extension + * parsing and project_source_sha256. Global configuration keeps legacy behavior. + * At most 64 KiB; actual absence is distinct from nonregular/read/close/size errors. + * NULL means allocation failure; inspect the snapshot state for other failures. + * Empty or malformed JSON remains PRESENT for downstream strict validation. */ +cbm_userconfig_t *cbm_userconfig_load_with_source(const char *repo_path); + +/* The PRESENT byte view belongs to cfg and remains valid until free. Outputs + * are cleared for ABSENT/ERROR. A legacy loader result has no snapshot: ERROR. */ +cbm_userconfig_source_state_t cbm_userconfig_project_source(const cbm_userconfig_t *cfg, + const char **bytes, size_t *len); + /* * Look up a file extension in the user config. * ext: extension including dot, e.g. ".blade.php" @@ -56,6 +76,23 @@ CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext); /* Free a cbm_userconfig_t returned by cbm_userconfig_load. NULL-safe. */ void cbm_userconfig_free(cbm_userconfig_t *cfg); +/* Pure project-only snapshot; never reads global/home/project paths. ABSENT + * requires NULL/0; PRESENT requires a strict object of 1..65536 bytes. Consumed + * extra_extensions are strict (duplicates/types/NUL rejected, known languages). + * Copies bytes/entries into an owned arena; release through cbm_userconfig_free. + * Clears *out before validation and never exposes a partial config. */ +typedef enum { + CBM_USERCONFIG_SNAPSHOT_OK = 0, + CBM_USERCONFIG_SNAPSHOT_INVALID, + CBM_USERCONFIG_SNAPSHOT_UNSUPPORTED, + CBM_USERCONFIG_SNAPSHOT_LIMIT, + CBM_USERCONFIG_SNAPSHOT_OOM +} cbm_userconfig_snapshot_status_t; + +cbm_userconfig_snapshot_status_t +cbm_userconfig_from_project_bytes(cbm_userconfig_source_state_t state, const void *bytes, + size_t len, cbm_userconfig_t **out); + /* ── Integration hook ───────────────────────────────────────────── */ /* diff --git a/src/foundation/git_env.c b/src/foundation/git_env.c index 894b6fe6c..b53f0ecbc 100644 --- a/src/foundation/git_env.c +++ b/src/foundation/git_env.c @@ -74,13 +74,21 @@ bool cbm_git_env_entry_is_repo_local(const char *entry) { return git_env_name_is_repo_local(entry, name_len); } +/* Explicit diff flags must not be overridden by the caller's environment. + * Keep the public repository-local classification and its Git-derived list + * unchanged; this extra removal belongs only to child construction. */ +static bool git_env_name_should_strip(const char *name, size_t name_len) { + return git_env_name_is_repo_local(name, name_len) || + git_env_name_equals(name, name_len, "GIT_DIFF_OPTS"); +} + #ifdef _WIN32 -/* Wide entry name → is it repository-local? Names we match are ASCII, so any +/* Wide entry name → should the child omit it? Names we match are ASCII, so any * non-ASCII code unit is an immediate mismatch. Windows keeps per-drive cwd * entries ("=C:=C:\\dir") whose name starts with '='; the search for the * separator starts at index 1 so they are kept untouched. */ -static bool git_env_wide_entry_is_repo_local(const wchar_t *entry) { +static bool git_env_wide_entry_should_strip(const wchar_t *entry) { char name[64] = {0}; size_t n = 0; for (size_t i = 0; entry[i] && (i == 0 || entry[i] != L'='); i++) { @@ -89,7 +97,7 @@ static bool git_env_wide_entry_is_repo_local(const wchar_t *entry) { } name[n++] = (char)entry[i]; } - return git_env_name_is_repo_local(name, n); + return git_env_name_should_strip(name, n); } wchar_t *cbm_git_child_env_block(void) { @@ -99,7 +107,7 @@ wchar_t *cbm_git_child_env_block(void) { } size_t kept = 0; for (const wchar_t *e = current; *e; e += wcslen(e) + 1) { - if (!git_env_wide_entry_is_repo_local(e)) { + if (!git_env_wide_entry_should_strip(e)) { kept += wcslen(e) + 1; } } @@ -111,7 +119,7 @@ wchar_t *cbm_git_child_env_block(void) { } size_t pos = 0; for (const wchar_t *e = current; *e; e += wcslen(e) + 1) { - if (!git_env_wide_entry_is_repo_local(e)) { + if (!git_env_wide_entry_should_strip(e)) { size_t len = wcslen(e) + 1; memcpy(block + pos, e, len * sizeof(wchar_t)); pos += len; @@ -127,11 +135,17 @@ wchar_t *cbm_git_child_env_block(void) { #else +static bool git_env_entry_should_strip(const char *entry) { + const char *eq = strchr(entry, '='); + size_t name_len = eq ? (size_t)(eq - entry) : strlen(entry); + return git_env_name_should_strip(entry, name_len); +} + char **cbm_git_child_envp(void) { size_t count = 0; size_t bytes = 0; for (char **e = environ; e && *e; e++) { - if (!cbm_git_env_entry_is_repo_local(*e)) { + if (!git_env_entry_should_strip(*e)) { count++; bytes += strlen(*e) + 1; } @@ -144,7 +158,7 @@ char **cbm_git_child_envp(void) { char *strings = (char *)envp + table; size_t slot = 0; for (char **e = environ; e && *e && slot < count; e++) { - if (!cbm_git_env_entry_is_repo_local(*e)) { + if (!git_env_entry_should_strip(*e)) { size_t len = strlen(*e) + 1; memcpy(strings, *e, len); envp[slot++] = strings; diff --git a/src/foundation/git_env.h b/src/foundation/git_env.h index 703a0e332..2f81c00b8 100644 --- a/src/foundation/git_env.h +++ b/src/foundation/git_env.h @@ -7,8 +7,9 @@ * would otherwise resolve every indexed project against the CALLER's repository * (a non-git directory reported as is_git=true, history and branch data from * the wrong repo). Every git spawn therefore gets a child environment with - * exactly the variables `git rev-parse --local-env-vars` prints removed. The - * scrub is per spawn: cbm's own environment is never modified (unsetenv in the + * the variables `git rev-parse --local-env-vars` prints removed. GIT_DIFF_OPTS + * is also removed so it cannot override explicit unified-context flags. + * The scrub is per spawn: cbm's own environment is never modified (unsetenv in the * parent would race every other thread reading the environment). */ #ifndef CBM_GIT_ENV_H @@ -31,12 +32,14 @@ bool cbm_git_env_entry_is_repo_local(const char *entry); #ifdef _WIN32 /* A CREATE_UNICODE_ENVIRONMENT block: the current environment minus the - * repository-local git variables. One allocation; release with cbm_git_child_env_free(). + * repository-local git variables and GIT_DIFF_OPTS. One allocation; release with + * cbm_git_child_env_free(). * NULL on allocation/snapshot failure. */ wchar_t *cbm_git_child_env_block(void); #else /* A NULL-terminated envp: the current environment minus the repository-local - * git variables. Pointers and strings live in ONE allocation; release with + * git variables and GIT_DIFF_OPTS. Pointers and strings live in ONE allocation; + * release with * cbm_git_child_env_free(). NULL on allocation failure. */ char **cbm_git_child_envp(void); #endif diff --git a/src/foundation/path_syntax_internal.c b/src/foundation/path_syntax_internal.c new file mode 100644 index 000000000..7cf078e90 --- /dev/null +++ b/src/foundation/path_syntax_internal.c @@ -0,0 +1,96 @@ +#include "foundation/path_syntax_internal.h" +#include +#include +#ifdef _WIN32 +#include +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#endif + +static unsigned char path_upper(unsigned char c) { + return c >= 'a' && c <= 'z' ? (unsigned char)(c - 'a' + 'A') : c; +} + +static bool path_device(const unsigned char *p, size_t n) { + size_t base = 0; + while (base < n && p[base] != '.') + base++; + if (base < 3) + return false; + unsigned char a = path_upper(p[0]), b = path_upper(p[1]), d = path_upper(p[2]); + if (base == 3) + return (a == 'C' && b == 'O' && d == 'N') || (a == 'P' && b == 'R' && d == 'N') || + (a == 'A' && b == 'U' && d == 'X') || (a == 'N' && b == 'U' && d == 'L'); + if (!((a == 'C' && b == 'O' && d == 'M') || (a == 'L' && b == 'P' && d == 'T'))) + return false; + if (base == 4) + return p[3] >= '1' && p[3] <= '9'; + return base == 5 && p[3] == 0xc2 && (p[4] == 0xb9 || p[4] == 0xb2 || p[4] == 0xb3); +} + +cbm_path_syntax_result_t cbm_path_windows_component(const unsigned char *component, size_t length) { + if (!component || !length || length > CBM_PATH_SYNTAX_MAX_BYTES) + return CBM_PATH_SYNTAX_INVALID; + if (component[length - 1] == '.' || component[length - 1] == ' ' || + path_device(component, length)) + return CBM_PATH_SYNTAX_DEVICE_OR_TRIMMED; + for (size_t i = 0; i < length; i++) { + unsigned char ch = component[i]; + if (ch < 32 || ch == '\\' || ch == ':' || ch == '<' || ch == '>' || ch == '"' || + ch == '|' || ch == '?' || ch == '*') + return CBM_PATH_SYNTAX_COMPONENT_BYTE; + } + return CBM_PATH_SYNTAX_OK; +} + +cbm_path_syntax_result_t cbm_path_windows_absolute(const char *path, size_t length) { + if (!path || !length || length > CBM_PATH_SYNTAX_MAX_BYTES) + return CBM_PATH_SYNTAX_INVALID; + bool drive = length >= 3 && + ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && + path[1] == ':' && (path[2] == '/' || path[2] == '\\'); + bool unc = length >= 5 && path[0] == '\\' && path[1] == '\\' && path[2] != '?' && + path[2] != '.' && path[2] != '\\'; + if (!drive && !unc) + return CBM_PATH_SYNTAX_NOT_ABSOLUTE; + if (unc) { + size_t i = 2; + while (i < length && path[i] != '\\') + i++; + if (i + 1 >= length || path[i + 1] == '\\') + return CBM_PATH_SYNTAX_INCOMPLETE_UNC; + } + return CBM_PATH_SYNTAX_OK; +} + +#ifdef _WIN32 +static bool path_roundtrip_args(const char *source, size_t length, const wchar_t *wide, + size_t wide_capacity, const char *roundtrip, + size_t roundtrip_capacity) { + if (!source || !wide || !roundtrip || !length || length > CBM_PATH_SYNTAX_MAX_BYTES || + !wide_capacity || wide_capacity > INT_MAX || !roundtrip_capacity || + roundtrip_capacity > INT_MAX) + return false; + return source[length] == '\0' && !memchr(source, '\0', length); +} + +cbm_path_syntax_result_t cbm_path_windows_roundtrip(const char *source, size_t length, + wchar_t *wide, size_t wide_capacity, + char *roundtrip, size_t roundtrip_capacity) { + if (!path_roundtrip_args(source, length, wide, wide_capacity, roundtrip, roundtrip_capacity)) + return CBM_PATH_SYNTAX_INVALID; + /* length <=4095 establishes the checked int conversion including terminal NUL. */ + int source_count = (int)(length + 1); + int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, source, source_count, wide, + (int)wide_capacity); + if (!count) + return CBM_PATH_SYNTAX_UTF8; + int bytes = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wide, count, roundtrip, + (int)roundtrip_capacity, NULL, NULL); + if (bytes != source_count || memcmp(source, roundtrip, length + 1)) + return CBM_PATH_SYNTAX_ROUNDTRIP; + return CBM_PATH_SYNTAX_OK; +} +#endif diff --git a/src/foundation/path_syntax_internal.h b/src/foundation/path_syntax_internal.h new file mode 100644 index 000000000..7f6deaea0 --- /dev/null +++ b/src/foundation/path_syntax_internal.h @@ -0,0 +1,45 @@ +/* Internal lexical checks shared by discovery and the pinned-tree adapter. + * No allocation, IO, normalization, owner, callback or retained pointers. */ +#ifndef CBM_PATH_SYNTAX_INTERNAL_H +#define CBM_PATH_SYNTAX_INTERNAL_H + +#include +#ifdef _WIN32 +#include +#endif + +enum { CBM_PATH_SYNTAX_MAX_BYTES = 4095 }; +typedef enum { + CBM_PATH_SYNTAX_OK = 0, + CBM_PATH_SYNTAX_INVALID, + CBM_PATH_SYNTAX_DEVICE_OR_TRIMMED, + CBM_PATH_SYNTAX_COMPONENT_BYTE, + CBM_PATH_SYNTAX_UTF8, + CBM_PATH_SYNTAX_ROUNDTRIP, + CBM_PATH_SYNTAX_NOT_ABSOLUTE, + CBM_PATH_SYNTAX_INCOMPLETE_UNC +} cbm_path_syntax_result_t; + +/* Byte spans have length 1..MAX_BYTES. No terminator is accessed by these two + * functions. The component caller supplies one already-separated component; + * relative-path structure (including slash/dot components) belongs to caller. + * Device/trailing-dot-or-space rejection precedes forbidden-byte rejection. + * Absolute checks preserve the ordinary drive/backslash-UNC lexical domain; + * these functions do not establish native existence, aliases or permission. */ +cbm_path_syntax_result_t cbm_path_windows_component(const unsigned char *component, size_t length); +cbm_path_syntax_result_t cbm_path_windows_absolute(const char *path, size_t length); + +#ifdef _WIN32 +/* source has exactly length non-NUL bytes followed by an accessible NUL; + * length is 1..MAX_BYTES. Buffers are distinct, caller-owned writable spans. + * Capacities are element counts, positive and <=INT_MAX; wide counts wchar_t. + * Strict UTF8 -> wide -> UTF8 conversion preserves exact bytes including NUL. + * INVALID is a bad pointer/length/capacity/terminated-span argument; UTF8 is a + * forward conversion failure; ROUNDTRIP is reverse failure/length/byte mismatch. + * Outputs may contain partial work on non-OK; only OK certifies conversion. + * Calls are bounded, synchronous and retain neither input nor output storage. */ +cbm_path_syntax_result_t cbm_path_windows_roundtrip(const char *source, size_t length, + wchar_t *wide, size_t wide_capacity, + char *roundtrip, size_t roundtrip_capacity); +#endif +#endif diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c index 2e08da328..1d2f5ee89 100644 --- a/src/foundation/subprocess.c +++ b/src/foundation/subprocess.c @@ -5,6 +5,7 @@ */ #include "subprocess.h" +#include "arena.h" #include "compat.h" /* cbm_nanosleep */ #include "compat_fs.h" #include "git_env.h" /* strip_git_repo_env: scrubbed child environment for git */ @@ -408,6 +409,9 @@ struct cbm_subprocess { size_t argc; char *windows_cmd_payload; char *log_file; + CBMArena stdout_arena; + char *stdout_file; + char *stdin_file; cbm_proc_log_cb on_log_line; void *log_ud; int quiet_timeout_ms; @@ -468,6 +472,7 @@ static void cbm_subprocess_free_config(cbm_subprocess_t *process) { free(process->bin); free(process->windows_cmd_payload); free(process->log_file); + cbm_arena_destroy(&process->stdout_arena); #ifndef _WIN32 cbm_git_child_env_free(process->envp); #endif @@ -478,6 +483,16 @@ static cbm_subprocess_t *cbm_subprocess_copy_opts(const cbm_proc_opts_t *opts) { if (!opts || !opts->bin || !opts->bin[0]) { return NULL; } + if (opts->stdout_file && (!opts->stdout_file[0] || + (opts->log_file && strcmp(opts->stdout_file, opts->log_file) == 0))) { + return NULL; + } + if (opts->stdin_file && + (!opts->stdin_file[0] || + (opts->log_file && strcmp(opts->stdin_file, opts->log_file) == 0) || + (opts->stdout_file && strcmp(opts->stdin_file, opts->stdout_file) == 0))) { + return NULL; + } #ifdef _WIN32 if (opts->windows_cmd_payload && (!opts->windows_cmd_payload[0] || opts->argv || !cbm_win_cmd_path_is_absolute(opts->bin))) { @@ -504,6 +519,7 @@ static cbm_subprocess_t *cbm_subprocess_copy_opts(const cbm_proc_opts_t *opts) { if (!process) { return NULL; } + cbm_arena_init_lazy(&process->stdout_arena, 256); process->bin = cbm_strdup(opts->bin); process->windows_cmd_payload = opts->windows_cmd_payload ? cbm_strdup(opts->windows_cmd_payload) : NULL; @@ -529,6 +545,20 @@ static cbm_subprocess_t *cbm_subprocess_copy_opts(const cbm_proc_opts_t *opts) { return NULL; } } + if (opts->stdout_file) { + process->stdout_file = cbm_arena_strdup(&process->stdout_arena, opts->stdout_file); + if (!process->stdout_file) { + cbm_subprocess_free_config(process); + return NULL; + } + } + if (opts->stdin_file) { + process->stdin_file = cbm_arena_strdup(&process->stdout_arena, opts->stdin_file); + if (!process->stdin_file) { + cbm_subprocess_free_config(process); + return NULL; + } + } process->on_log_line = opts->on_log_line; process->log_ud = opts->log_ud; process->quiet_timeout_ms = opts->quiet_timeout_ms; @@ -646,17 +676,79 @@ static cbm_proc_poll_t cbm_subprocess_finish_failed(cbm_subprocess_t *process, #ifdef _WIN32 -static void cbm_win_close_spawn_handles(HANDLE nul, HANDLE log, LPPROC_THREAD_ATTRIBUTE_LIST attrs, - bool attrs_init) { +/* Match cbm_path_to_wide's file-path spelling using the process arena. Existing + * option conversions retain their legacy heap ownership. */ +static wchar_t *cbm_win_stdin_path(cbm_subprocess_t *process) { + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, process->stdin_file, -1, NULL, 0); + if (length <= 0 || length > 32767) { + return NULL; + } + wchar_t *wide = cbm_arena_alloc(&process->stdout_arena, (size_t)length * sizeof(*wide)); + if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, process->stdin_file, -1, wide, + length) != length) { + return NULL; + } + size_t input_length = (size_t)length - 1U; + if (cbm_win_path_has_namespace_prefix(wide, input_length)) { + cbm_win_path_normalize_wide_separators(wide); + return wide; + } + if (input_length < 240U) { + return wide; + } + DWORD needed = GetFullPathNameW(wide, 0, NULL, NULL); + if (needed == 0U || needed > 32767U) { + return NULL; + } + wchar_t *full = cbm_arena_alloc(&process->stdout_arena, (size_t)needed * sizeof(*full)); + DWORD copied = full ? GetFullPathNameW(wide, needed, full, NULL) : 0U; + if (copied == 0U || copied >= needed) { + return NULL; + } + cbm_win_path_normalize_wide_separators(full); + bool drive_absolute = + copied >= 3U && + ((full[0] >= L'A' && full[0] <= L'Z') || (full[0] >= L'a' && full[0] <= L'z')) && + full[1] == L':' && full[2] == L'\\'; + bool unc_absolute = copied >= 5U && full[0] == L'\\' && full[1] == L'\\'; + if (!drive_absolute && !unc_absolute) { + return NULL; + } + const wchar_t *prefix = drive_absolute ? L"\\\\?\\" : L"\\\\?\\UNC\\"; + size_t prefix_length = drive_absolute ? 4U : 8U; + size_t tail_offset = drive_absolute ? 0U : 2U; + size_t tail_length = (size_t)copied - tail_offset; + size_t extended_length = prefix_length + tail_length; + if (extended_length >= 32767U) { + return NULL; + } + wchar_t *prefixed = + cbm_arena_alloc(&process->stdout_arena, (extended_length + 1U) * sizeof(*prefixed)); + if (!prefixed) { + return NULL; + } + wmemcpy(prefixed, prefix, prefix_length); + wmemcpy(prefixed + prefix_length, full + tail_offset, tail_length + 1U); + return prefixed; +} + +static void cbm_win_close_spawn_handles(HANDLE nul, HANDLE input, HANDLE log, HANDLE stdout_capture, + LPPROC_THREAD_ATTRIBUTE_LIST attrs, bool attrs_init) { if (attrs) { if (attrs_init) { DeleteProcThreadAttributeList(attrs); } free(attrs); } + if (stdout_capture != INVALID_HANDLE_VALUE) { + CloseHandle(stdout_capture); + } if (log != INVALID_HANDLE_VALUE) { CloseHandle(log); } + if (input != INVALID_HANDLE_VALUE) { + CloseHandle(input); + } if (nul != INVALID_HANDLE_VALUE) { CloseHandle(nul); } @@ -704,13 +796,38 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { security.bInheritHandle = TRUE; HANDLE nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, &security, OPEN_EXISTING, 0, NULL); + HANDLE input = INVALID_HANDLE_VALUE; HANDLE log = INVALID_HANDLE_VALUE; + HANDLE stdout_capture = INVALID_HANDLE_VALUE; if (nul == INVALID_HANDLE_VALUE) { CloseHandle(job); free(wbin); free(wcmdline); return -1; } + if (process->stdin_file) { + wchar_t *winput = cbm_win_stdin_path(process); + /* Validate before any output can be truncated; recheck the opened + * handle rather than relying only on path attributes. */ + DWORD attributes = winput ? GetFileAttributesW(winput) : INVALID_FILE_ATTRIBUTES; + if (attributes != INVALID_FILE_ATTRIBUTES && + !(attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT | + FILE_ATTRIBUTE_DEVICE))) { + input = CreateFileW(winput, GENERIC_READ, FILE_SHARE_READ, &security, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); + } + BY_HANDLE_FILE_INFORMATION input_info; + if (input == INVALID_HANDLE_VALUE || GetFileType(input) != FILE_TYPE_DISK || + !GetFileInformationByHandle(input, &input_info) || + (input_info.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DEVICE))) { + cbm_win_close_spawn_handles(nul, input, log, stdout_capture, NULL, false); + CloseHandle(job); + free(wbin); + free(wcmdline); + return -1; + } + } if (process->log_file) { wchar_t *wlog = cbm_path_to_wide(process->log_file); if (wlog) { @@ -724,7 +841,7 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { free(wlog); } if (log == INVALID_HANDLE_VALUE) { - CloseHandle(nul); + cbm_win_close_spawn_handles(nul, input, log, stdout_capture, NULL, false); CloseHandle(job); free(wbin); free(wcmdline); @@ -732,12 +849,34 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { } } - HANDLE inherit[2]; + if (process->stdout_file) { + wchar_t *wstdout = cbm_path_to_wide(process->stdout_file); + if (wstdout) { + stdout_capture = CreateFileW(wstdout, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + free(wstdout); + } + if (stdout_capture == INVALID_HANDLE_VALUE) { + cbm_win_close_spawn_handles(nul, input, log, stdout_capture, NULL, false); + CloseHandle(job); + free(wbin); + free(wcmdline); + return -1; + } + } + + HANDLE inherit[4]; SIZE_T inherit_count = 0; inherit[inherit_count++] = nul; + if (input != INVALID_HANDLE_VALUE) { + inherit[inherit_count++] = input; + } if (log != INVALID_HANDLE_VALUE) { inherit[inherit_count++] = log; } + if (stdout_capture != INVALID_HANDLE_VALUE) { + inherit[inherit_count++] = stdout_capture; + } SIZE_T attrs_size = 0; (void)InitializeProcThreadAttributeList(NULL, 1, 0, &attrs_size); LPPROC_THREAD_ATTRIBUTE_LIST attrs = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attrs_size); @@ -746,7 +885,7 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { attrs, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherit, inherit_count * sizeof(inherit[0]), NULL, NULL); if (!attrs_ready) { - cbm_win_close_spawn_handles(nul, log, attrs, attrs_init); + cbm_win_close_spawn_handles(nul, input, log, stdout_capture, attrs, attrs_init); CloseHandle(job); free(wbin); free(wcmdline); @@ -757,8 +896,10 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { ZeroMemory(&startup, sizeof(startup)); startup.StartupInfo.cb = sizeof(startup); startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; - startup.StartupInfo.hStdInput = nul; - startup.StartupInfo.hStdOutput = log != INVALID_HANDLE_VALUE ? log : nul; + startup.StartupInfo.hStdInput = input != INVALID_HANDLE_VALUE ? input : nul; + startup.StartupInfo.hStdOutput = stdout_capture != INVALID_HANDLE_VALUE + ? stdout_capture + : (log != INVALID_HANDLE_VALUE ? log : nul); startup.StartupInfo.hStdError = log != INVALID_HANDLE_VALUE ? log : nul; startup.lpAttributeList = attrs; @@ -775,7 +916,7 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { CreateProcessW(wbin, wcmdline, NULL, NULL, TRUE, flags, (LPVOID)env, NULL, &startup.StartupInfo, &child); cbm_git_child_env_free(env); /* CreateProcessW copied the block into the child */ - cbm_win_close_spawn_handles(nul, log, attrs, attrs_init); + cbm_win_close_spawn_handles(nul, input, log, stdout_capture, attrs, attrs_init); free(wbin); free(wcmdline); if (!created) { @@ -1072,7 +1213,8 @@ static void cbm_posix_reset_child_signals(void) { /* fork+exec child setup. On Apple this runs ONLY for the exec-failure * fallback (see cbm_posix_spawn_apple), which preserves the documented * "bogus binary => child exits 127" contract across platforms. */ -static void cbm_posix_child_exec(cbm_subprocess_t *process, int input, int output, long max_fd) { +static void cbm_posix_child_exec(cbm_subprocess_t *process, int input, int output, int error, + long max_fd) { if (setpgid(0, 0) < 0) { _exit(127); } @@ -1080,8 +1222,8 @@ static void cbm_posix_child_exec(cbm_subprocess_t *process, int input, int outpu /* Never let a worker consume the MCP transport inherited as stdin. Only * async-signal-safe calls are used between fork and exec. */ - if (input < 0 || output < 0 || dup2(input, STDIN_FILENO) < 0 || - dup2(output, STDOUT_FILENO) < 0 || dup2(output, STDERR_FILENO) < 0) { + if (input < 0 || output < 0 || error < 0 || dup2(input, STDIN_FILENO) < 0 || + dup2(output, STDOUT_FILENO) < 0 || dup2(error, STDERR_FILENO) < 0) { _exit(127); } if (input > STDERR_FILENO) { @@ -1090,6 +1232,9 @@ static void cbm_posix_child_exec(cbm_subprocess_t *process, int input, int outpu if (output > STDERR_FILENO) { (void)close(output); } + if (error > STDERR_FILENO && error != output) { + (void)close(error); + } for (int fd = STDERR_FILENO + 1; fd < max_fd; fd++) { (void)close(fd); } @@ -1141,7 +1286,8 @@ static int cbm_posix_fd_at_least_three(int fd) { * the child's close-everything loop, and the three dup2'd fds stay open * because dup2 clears close-on-exec. * posix_spawnp keeps execvp's PATH semantics for a bare tool name. */ -static int cbm_posix_spawn_apple(cbm_subprocess_t *process, int input, int output, pid_t *pid_out) { +static int cbm_posix_spawn_apple(cbm_subprocess_t *process, int input, int output, int error, + pid_t *pid_out) { /* posix_spawn is the PRIMARY path on macOS; fork+exec is only the * exec-class fallback. Injection therefore has to live here too, or a test * on macOS exercises nothing. */ @@ -1171,7 +1317,7 @@ static int cbm_posix_spawn_apple(cbm_subprocess_t *process, int input, int outpu posix_spawnattr_setsigdefault(&attr, &all_signals) == 0 && posix_spawn_file_actions_adddup2(&actions, input, STDIN_FILENO) == 0 && posix_spawn_file_actions_adddup2(&actions, output, STDOUT_FILENO) == 0 && - posix_spawn_file_actions_adddup2(&actions, output, STDERR_FILENO) == 0; + posix_spawn_file_actions_adddup2(&actions, error, STDERR_FILENO) == 0; pid_t pid = -1; int rc = configured ? posix_spawnp(&pid, process->bin, &actions, &attr, process->argv, process->envp ? process->envp : environ) @@ -1209,7 +1355,26 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { #ifdef O_CLOEXEC input_flags |= O_CLOEXEC; #endif - int input = cbm_posix_fd_at_least_three(open("/dev/null", input_flags)); + if (process->stdin_file) { + /* O_NONBLOCK prevents waiting for a FIFO writer before fstat rejects + * non-regular input. No output has been opened at this point. */ + input_flags |= O_NONBLOCK; +#ifdef O_NOFOLLOW + input_flags |= O_NOFOLLOW; +#endif + } + int input = cbm_posix_fd_at_least_three( + open(process->stdin_file ? process->stdin_file : "/dev/null", input_flags)); + if (input < 0) { + return -1; + } + if (process->stdin_file) { + struct stat input_status; + if (fstat(input, &input_status) != 0 || !S_ISREG(input_status.st_mode)) { + (void)close(input); + return -1; + } + } const char *target = process->log_file ? process->log_file : "/dev/null"; int output_flags = O_WRONLY | O_CREAT | O_TRUNC; #ifdef O_CLOEXEC @@ -1219,13 +1384,8 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { output_flags |= O_NOFOLLOW; #endif int output = cbm_posix_fd_at_least_three(open(target, output_flags, 0600)); - if (input < 0 || output < 0) { - if (input >= 0) { - (void)close(input); - } - if (output >= 0) { - (void)close(output); - } + if (output < 0) { + (void)close(input); return -1; } struct stat output_status; @@ -1241,6 +1401,23 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { return -1; } + int stdout_capture = -1; + if (process->stdout_file) { + stdout_capture = + cbm_posix_fd_at_least_three(open(process->stdout_file, output_flags, 0600)); + struct stat stdout_status; + if (stdout_capture < 0 || fstat(stdout_capture, &stdout_status) != 0 || + !S_ISREG(stdout_status.st_mode) || fchmod(stdout_capture, 0600) != 0) { + if (stdout_capture >= 0) { + (void)close(stdout_capture); + } + (void)close(input); + (void)close(output); + return -1; + } + } + int child_output = stdout_capture >= 0 ? stdout_capture : output; + long max_fd = sysconf(_SC_OPEN_MAX); if (max_fd < 0 || max_fd > 1048576L) { max_fd = 65536L; @@ -1248,11 +1425,11 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { pid_t pid = -1; #ifdef __APPLE__ - int spawn_rc = cbm_posix_spawn_apple(process, input, output, &pid); + int spawn_rc = cbm_posix_spawn_apple(process, input, child_output, output, &pid); for (int attempt = 0; spawn_rc == CBM_SPAWN_RETRY && attempt < CBM_SPAWN_RETRY_ATTEMPTS; attempt++) { cbm_spawn_backoff(attempt); - spawn_rc = cbm_posix_spawn_apple(process, input, output, &pid); + spawn_rc = cbm_posix_spawn_apple(process, input, child_output, output, &pid); } if (spawn_rc == CBM_SPAWN_RETRY) { spawn_rc = -1; /* still exhausted after backoff: a real failure */ @@ -1260,6 +1437,9 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { if (spawn_rc < 0) { (void)close(input); (void)close(output); + if (stdout_capture >= 0) { + (void)close(stdout_capture); + } return -1; } if (spawn_rc > 0) { /* exec-class failure: reproduce the fork+exec 127 */ @@ -1267,10 +1447,13 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { if (pid < 0) { (void)close(input); (void)close(output); + if (stdout_capture >= 0) { + (void)close(stdout_capture); + } return -1; } if (pid == 0) { - cbm_posix_child_exec(process, input, output, max_fd); + cbm_posix_child_exec(process, input, child_output, output, max_fd); } } #else @@ -1278,14 +1461,20 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { if (pid < 0) { (void)close(input); (void)close(output); + if (stdout_capture >= 0) { + (void)close(stdout_capture); + } return -1; } if (pid == 0) { - cbm_posix_child_exec(process, input, output, max_fd); + cbm_posix_child_exec(process, input, child_output, output, max_fd); } #endif (void)close(input); (void)close(output); + if (stdout_capture >= 0) { + (void)close(stdout_capture); + } /* Parent and child both establish the group, removing scheduler-order races. * If the child won and already execed, EACCES is accepted only after proving diff --git a/src/foundation/subprocess.h b/src/foundation/subprocess.h index 333509fb6..27238e3aa 100644 --- a/src/foundation/subprocess.h +++ b/src/foundation/subprocess.h @@ -63,8 +63,9 @@ typedef struct { * be an absolute path ending in cmd.exe and argv must be * NULL. The fixed /D /S /V:OFF /C prefix is added while * this payload is copied verbatim for cmd.exe to parse. */ - const char *log_file; /* child stdout+stderr are redirected here and tailed; - * NULL => discard child output, no tailing */ + const char *log_file; /* stderr and, when stdout_file is NULL, stdout are + * redirected here and tailed; NULL => discard those + * streams, no tailing */ cbm_proc_log_cb on_log_line; /* optional per-line callback */ void *log_ud; /* user data for on_log_line */ int quiet_timeout_ms; /* <= 0 => no timeout; else kill+HANG after this many @@ -77,6 +78,20 @@ typedef struct { bool strip_git_repo_env; /* child env omits git's repository-local variables * (foundation/git_env.h) — set for every git spawn so * an inherited GIT_DIR never overrides `git -C` */ + const char *stdout_file; /* optional separate stdout capture; NULL preserves + * merged log_file behavior. Must be nonempty and + * distinct from log_file; caller supplies private, + * non-aliasing paths and owns capture-file cleanup. + * Not tailed; quiet_timeout_ms observes log_file only. + * delete_log_on_exit never deletes this file. */ + const char *stdin_file; /* optional private, immutable regular binary input; + * NULL => null device, never inherited stdin. + * Must be nonempty and non-aliasing with log_file and + * stdout_file; exact equal strings are rejected. + * Caller closes its writer before spawn and retains + * the file until the child tree is quiescent. + * The child chooses its CRT binary mode on Windows. + * Never modified or deleted by this API. */ } cbm_proc_opts_t; #define CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS 1000 diff --git a/src/foundation/test_selection.h b/src/foundation/test_selection.h new file mode 100644 index 000000000..1cfc3522a --- /dev/null +++ b/src/foundation/test_selection.h @@ -0,0 +1,8 @@ +#ifndef CBM_TEST_SELECTION_H +#define CBM_TEST_SELECTION_H + +/* Bytes in one executable suite or suite:test token, excluding whitespace, + * the line terminator and storage NUL. An individual token includes its colon. */ +enum { CBM_TEST_SELECTION_TOKEN_MAX = 511 }; + +#endif /* CBM_TEST_SELECTION_H */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 2ed29799b..fd3521b11 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -53,7 +53,9 @@ enum { #define SLEN(s) (sizeof(s) - 1) #include "mcp/mcp.h" #include "mcp/mcp_internal.h" +#include "mcp/test_impact.h" #include "store/store.h" +#include "store/store_impact.h" #include #include "cypher/cypher.h" #include "discover/discover.h" @@ -64,6 +66,7 @@ enum { #include "watcher/watcher.h" #include "foundation/mem.h" #include "foundation/mem_core.h" +#include "foundation/arena.h" #include "foundation/diagnostics.h" #include "foundation/platform.h" #include "foundation/compat.h" @@ -775,6 +778,20 @@ static const tool_def_t TOOLS[] = { "\"direction\":{\"type\":\"string\",\"enum\":[\"inbound\",\"outbound\",\"both\"],\"default\":" "\"inbound\",\"description\":\"inbound=callers; outbound=dependencies; both=union.\"}," "\"depth\":{\"type\":\"integer\",\"default\":2}," + "\"edge_types\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," + "\"description\":\"Inbound walk; default CALLS.\"}," + "\"fixpoint\":{\"type\":\"boolean\",\"description\":\"Walk to closure; no depth.\"}," + "\"via\":{\"type\":\"boolean\",\"description\":\"Add via_qn, via_edge.\"}," + "\"seeds\":{\"type\":\"string\",\"enum\":[\"none\",\"list\"],\"default\":\"none\"," + "\"description\":\"List changed-symbol seeds with independent pagination.\"}," + "\"seed_filter\":{\"type\":\"string\",\"enum\":[\"all\",\"product\"],\"default\":\"all\"," + "\"description\":\"Product excludes indexed test metadata and configured test globs.\"}," + "\"config_path\":{\"type\":\"string\",\"description\":\"Project config override; absolute or " + "project-relative.\"}," + "\"seed_limit\":{\"type\":\"integer\",\"default\":50,\"minimum\":0,\"maximum\":1000}," + "\"seed_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," + "\"seed_cursor\":{\"type\":\"string\",\"description\":\"Continue seeds:list; excludes nonzero " + "seed_offset.\"}," "\"limit\":{\"type\":\"integer\",\"default\":200,\"maximum\":5000}," "\"impact_offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"impact_cursor\":{\"type\":\"string\"}," @@ -15558,12 +15575,169 @@ static bool detect_is_seedable_label(const char *lb) { strcmp(lb, "Section") != 0; } +/* Seed presentation is optional. The default request retains its traversal, + * ordering, output bytes and cursor identity. */ +typedef struct { + bool list; + bool product; + int limit; + int offset; + char cursor[80]; + char config_path[CBM_SZ_4K]; +} detect_seed_request_t; + +typedef struct { + const char *qn; + const char *label; + const char *file; + const char *reason; + int start; + int end; +} detect_seed_row_t; + +typedef struct { + CBMArena arena; + detect_seed_row_t *rows; + int count; + int capacity; + bool failed; + bool config_failed; + cbm_test_config_t *config; +} detect_seed_rows_t; + +static bool detect_seed_string(yyjson_val *value, const char **out) { + *out = yyjson_get_str(value); + return *out && strlen(*out) == yyjson_get_len(value); +} + +static void detect_seed_parse(const char *args, detect_seed_request_t *out, char *err, size_t len) { + *out = (detect_seed_request_t){.limit = 50}; + yyjson_doc *doc = yyjson_read(args, strlen(args), 0); + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + const char *keys[] = {"seeds", "seed_filter", "seed_limit", + "seed_offset", "seed_cursor", "config_path"}; + for (int i = 0; i < 6 && !err[0]; i++) { + yyjson_val *value = yyjson_obj_get(root, keys[i]); + if (!value || yyjson_is_null(value)) + continue; + const char *text = NULL; + if (i < 2) { + const char *off = i == 0 ? "none" : "all"; + const char *on = i == 0 ? "list" : "product"; + if (!detect_seed_string(value, &text) || (strcmp(text, off) && strcmp(text, on))) { + snprintf(err, len, "%s must be \"%s\" or \"%s\"", keys[i], off, on); + } else if (i == 0) + out->list = strcmp(text, on) == 0; + else + out->product = strcmp(text, on) == 0; + } else if (i < 4) { + uint64_t maximum = i == 2 ? 1000 : INT_MAX; + if (!yyjson_is_uint(value) || yyjson_get_uint(value) > maximum) { + snprintf(err, len, "%s must be an integer from 0 to %llu", keys[i], + (unsigned long long)maximum); + } else if (i == 2) + out->limit = (int)yyjson_get_uint(value); + else + out->offset = (int)yyjson_get_uint(value); + } else { + char *destination = i == 4 ? out->cursor : out->config_path; + size_t capacity = i == 4 ? sizeof(out->cursor) : sizeof(out->config_path); + if (!detect_seed_string(value, &text) || strlen(text) >= capacity || + (i == 5 && !*text)) { + snprintf(err, len, "%s must be a nonempty string within the path/cursor size limit", + keys[i]); + } else { + memcpy(destination, text, strlen(text) + 1); +#ifdef _WIN32 + if (i == 5) + for (char *p = destination; *p; p++) + if (*p == '\\') + *p = '/'; +#endif + } + } + } + if (!err[0] && out->cursor[0] && !out->list) { + snprintf(err, len, "seed_cursor requires seeds:\"list\""); + } + yyjson_doc_free(doc); +} + +static bool detect_seed_is_test(const cbm_node_t *node) { + if (!node->properties_json) + return false; + yyjson_doc *doc = yyjson_read(node->properties_json, strlen(node->properties_json), 0); + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *test = yyjson_obj_get(root, "is_test"); + yyjson_val *role = yyjson_obj_get(root, "test_role"); + const char *role_name = yyjson_get_str(role); + bool is_test = yyjson_get_bool(test) || (yyjson_is_int(test) && yyjson_get_sint(test) == 1) || + (role_name && strlen(role_name) == yyjson_get_len(role) && + (strcmp(role_name, "case") == 0 || strcmp(role_name, "suite") == 0 || + strcmp(role_name, "helper") == 0)); + yyjson_doc_free(doc); + return is_test; +} + +static void detect_seed_add_row(detect_seed_rows_t *rows, const cbm_node_t *node, bool hunk) { + if (rows->failed) + return; + if (rows->count == rows->capacity) { + if (rows->capacity > INT_MAX / 2) { + rows->failed = true; + return; + } + int capacity = rows->capacity ? rows->capacity * 2 : 16; + if ((size_t)capacity > SIZE_MAX / sizeof(*rows->rows)) { + rows->failed = true; + return; + } + detect_seed_row_t *next = cbm_arena_alloc(&rows->arena, (size_t)capacity * sizeof(*next)); + if (!next) { + rows->failed = true; + return; + } + if (rows->count) + memcpy(next, rows->rows, (size_t)rows->count * sizeof(*next)); + rows->rows = next; + rows->capacity = capacity; + } + detect_seed_row_t *row = &rows->rows[rows->count++]; + *row = (detect_seed_row_t){ + .qn = cbm_arena_strdup(&rows->arena, node->qualified_name ? node->qualified_name : ""), + .label = cbm_arena_strdup(&rows->arena, node->label ? node->label : ""), + .file = cbm_arena_strdup(&rows->arena, node->file_path ? node->file_path : ""), + .reason = hunk ? "hunk" : "whole_file", + .start = node->start_line, + .end = node->end_line}; + rows->failed = !row->qn || !row->label || !row->file; +} + +static int detect_seed_row_compare(const void *left, const void *right) { + const detect_seed_row_t *a = left, *b = right; + int cmp = strcmp(a->qn, b->qn); + if (!cmp) + cmp = strcmp(a->file, b->file); + if (!cmp) + cmp = (a->start > b->start) - (a->start < b->start); + if (!cmp) + cmp = (a->end > b->end) - (a->end < b->end); + if (!cmp) + cmp = strcmp(a->label, b->label); + return cmp; +} + static void detect_collect_seeds(cbm_store_t *store, const char *project, const char *file, const cbm_changed_hunk_t *hunks, int hunk_count, int64_t **seeds, - int *n, int *cap) { + int *n, int *cap, const detect_seed_request_t *request, + detect_seed_rows_t *rows) { cbm_node_t *nodes = NULL; int ncount = 0; - cbm_store_find_nodes_by_file(store, project, file, &nodes, &ncount); + if (cbm_store_find_nodes_by_file(store, project, file, &nodes, &ncount) != CBM_STORE_OK) { + rows->failed = true; + return; + } + bool file_is_test = request->product && cbm_test_config_is_test_file(rows->config, file); bool scope_to_hunks = false; for (int h = 0; h < hunk_count; h++) { if (strcmp(hunks[h].path, file) == 0) { @@ -15595,6 +15769,14 @@ static void detect_collect_seeds(cbm_store_t *store, const char *project, const if (scope_to_hunks && !cbm_detect_node_in_hunks(&nodes[i], hunks, hunk_count, file)) { continue; } + /* Probe overlap before filtering: a test-only hunk must not + * fall back onto untouched product definitions in this file. */ + if (request->product && (file_is_test || detect_seed_is_test(&nodes[i]))) { + continue; + } + if (request->list) { + detect_seed_add_row(rows, &nodes[i], scope_to_hunks); + } if (*n >= *cap) { *cap = *cap ? *cap * 2 : 16; *seeds = safe_realloc(*seeds, (size_t)*cap * sizeof(int64_t)); @@ -15676,14 +15858,28 @@ static void detect_module_rollup_free(detect_module_row_t *modules, int count) { * is hop-ordered, so page zero preserves the closest, highest-signal rows. * Engine saturation is reported as a lower-bound total, never as an exact * count. */ +/* How the walk reached a row: the parent one hop closer and the edge type that + * led there. Empty strings for a row the walk does not know. */ +static void detect_walk_via(const cbm_impact_walk_t *walk, int64_t id, const char **via_qn, + const char **via_edge) { + const cbm_impact_hit_t *hit = cbm_impact_walk_hit(walk, id); + const cbm_impact_hit_t *parent = hit ? cbm_impact_walk_hit(walk, hit->via_id) : NULL; + *via_qn = (parent && parent->qualified_name) ? parent->qualified_name : ""; + *via_edge = (hit && hit->via_edge) ? hit->via_edge : ""; +} + +/* `via_walk` is the open impact walk when the request asked for `via`, and adds + * the via_qn and via_edge columns; NULL keeps the four default columns. */ static void detect_emit_impacted_tree(cbm_sb_t *sb, const cbm_traverse_result_t *tr, int start, - int count, bool engine_saturated) { + int count, bool engine_saturated, + const cbm_impact_walk_t *via_walk) { cbm_tree_scalar_int(sb, "impacted_total", tr->visited_count); cbm_tree_scalar_str(sb, "impacted_total_relation", engine_saturated ? "gte" : "eq"); int shown = count; /* qn order for stable grouping, but keep hop-closeness: sort by (hop) is * lost under qn sort, so group AFTER selecting the nearest `shown` rows — - * the visited array is already (hop,id)-ordered from the BFS. */ + * the visited array is already (hop,id)-ordered from the BFS, and + * (hop,qn)-ordered from the impact walk. */ cbm_tree_scalar_int(sb, "impacted_shown", shown); cbm_node_hop_t *selected = NULL; const cbm_node_hop_t *rows = tr->visited; @@ -15699,24 +15895,28 @@ static void detect_emit_impacted_tree(cbm_sb_t *sb, const cbm_traverse_result_t if (shown > 1 && selected) { qsort(selected, (size_t)shown, sizeof(*selected), tree_hop_cmp_qn); } - static const char *const columns[] = {"qn", "label", "file", "hop"}; - const char **cells = shown > 0 ? calloc((size_t)shown * 4U, sizeof(*cells)) : NULL; + static const char *const columns[] = {"qn", "label", "file", "hop", "via_qn", "via_edge"}; + size_t ncols = via_walk ? 6U : 4U; + const char **cells = shown > 0 ? calloc((size_t)shown * ncols, sizeof(*cells)) : NULL; char (*hop_text)[32] = shown > 0 ? calloc((size_t)shown, sizeof(*hop_text)) : NULL; if (shown == 0 || (cells && hop_text)) { for (int i = 0; i < shown; i++) { - size_t base = (size_t)i * 4U; + size_t base = (size_t)i * ncols; cells[base] = rows[i].node.qualified_name ? rows[i].node.qualified_name : ""; cells[base + 1U] = rows[i].node.label ? rows[i].node.label : ""; cells[base + 2U] = rows[i].node.file_path ? rows[i].node.file_path : ""; snprintf(hop_text[i], sizeof(hop_text[i]), "%d", rows[i].hop); cells[base + 3U] = hop_text[i]; + if (via_walk) { + detect_walk_via(via_walk, rows[i].node.id, &cells[base + 4U], &cells[base + 5U]); + } } - static const bool string_cols[] = {true, true, true, false}; - static const bool prefix_cols[] = {true, false, true, false}; - cbm_tree_table_rows_profiled(sb, "impacted", shown, columns, 4, cells, string_cols, + static const bool string_cols[] = {true, true, true, false, true, true}; + static const bool prefix_cols[] = {true, false, true, false, true, false}; + cbm_tree_table_rows_profiled(sb, "impacted", shown, columns, (int)ncols, cells, string_cols, prefix_cols); } else { - cbm_tree_table_header(sb, "impacted", 0, columns, 4); + cbm_tree_table_header(sb, "impacted", 0, columns, (int)ncols); cbm_tree_scalar_str(sb, "impacted_render_error", "out_of_memory"); } free(hop_text); @@ -15883,6 +16083,199 @@ static uint64_t detect_params_hash(const char *project, const char *base_branch, return cursor_fnv1a64(depth_text, hash); } +/* ── detect_changes: opt-in impact walk ─────────────────────────── + * `edge_types`, `fixpoint` and `via` switch the impact step from the + * depth-bounded CALLS traversal to cbm_impact_walk (store/store_impact.h). A + * request that names none of them keeps the old traversal and its bytes. */ +enum { DETECT_WALK_TYPE_MAX = 16, DETECT_WALK_TYPE_LEN = 64 }; + +typedef struct { + bool active; /* edge_types, fixpoint or via was requested */ + bool fixpoint; /* walk until nothing new is reached; `depth` is ignored */ + bool via; /* rows also say which parent and edge type reached them */ + int type_count; + char types[DETECT_WALK_TYPE_MAX][DETECT_WALK_TYPE_LEN]; +} detect_walk_request_t; + +/* An argument that is absent or JSON null was not given. */ +static yyjson_val *detect_walk_arg(yyjson_val *root, const char *key) { + yyjson_val *val = root ? yyjson_obj_get(root, key) : NULL; + return (val && !yyjson_is_null(val)) ? val : NULL; +} + +/* "unknown edge type" plus the types this index does hold: a walk over a + * mistyped edge type would come back empty and read as "nothing depends on + * this". */ +static void detect_walk_unknown_type(const cbm_schema_info_t *schema, const char *name, char *err, + size_t err_sz) { + int used = snprintf(err, err_sz, "unknown edge type \"%.60s\" — this index has: ", name); + for (int i = 0; i < schema->edge_type_count && used > 0 && (size_t)used < err_sz; i++) { + used += snprintf(err + used, err_sz - (size_t)used, "%s%s", i > 0 ? ", " : "", + schema->edge_types[i].type ? schema->edge_types[i].type : ""); + } +} + +static void detect_walk_parse_types(cbm_store_t *store, const char *project, yyjson_val *types, + detect_walk_request_t *out, char *err, size_t err_sz) { + static const char *const shape = "edge_types must be a non-empty array of at most 16 edge " + "type names, e.g. [\"CALLS\",\"USAGE\"]"; + if (!yyjson_is_arr(types) || yyjson_arr_size(types) == 0 || + yyjson_arr_size(types) > DETECT_WALK_TYPE_MAX) { + snprintf(err, err_sz, "%s", shape); + return; + } + cbm_schema_info_t schema = {0}; + if (cbm_store_get_schema_counts(store, project, &schema) != CBM_STORE_OK) { + snprintf(err, err_sz, + "index_metadata_error: the edge types of this index are unreadable; reindex " + "before detecting changes"); + return; + } + size_t idx; + size_t max; + yyjson_val *val; + yyjson_arr_foreach(types, idx, max, val) { + const char *name = yyjson_is_str(val) ? yyjson_get_str(val) : NULL; + if (!name) { + snprintf(err, err_sz, "%s", shape); + break; + } + bool known = false; + for (int i = 0; i < schema.edge_type_count && !known; i++) { + known = schema.edge_types[i].type && strcmp(schema.edge_types[i].type, name) == 0; + } + if (!known || strlen(name) >= DETECT_WALK_TYPE_LEN) { + detect_walk_unknown_type(&schema, name, err, err_sz); + break; + } + bool twice = false; + for (int i = 0; i < out->type_count && !twice; i++) { + twice = strcmp(out->types[i], name) == 0; + } + if (twice) { + /* The order is the rank that picks a row's `via` parent, so a + * repeated name has no single meaning. */ + snprintf(err, err_sz, "edge type \"%s\" is listed twice in edge_types", name); + break; + } + snprintf(out->types[out->type_count++], DETECT_WALK_TYPE_LEN, "%s", name); + } + cbm_store_schema_free(&schema); +} + +/* Read and validate the walk parameters. `err` stays empty when the request is + * usable; otherwise it holds the teaching error and nothing was corrected. */ +static void detect_walk_parse(cbm_store_t *store, const char *project, const char *args, + const char *direction, detect_walk_request_t *out, char *err, + size_t err_sz) { + memset(out, 0, sizeof(*out)); + yyjson_doc *doc = yyjson_read(args, strlen(args), 0); + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *types = detect_walk_arg(root, "edge_types"); + yyjson_val *fixpoint = detect_walk_arg(root, "fixpoint"); + yyjson_val *via = detect_walk_arg(root, "via"); + if (fixpoint && !yyjson_is_bool(fixpoint)) { + snprintf(err, err_sz, "fixpoint must be true or false"); + } else if (via && !yyjson_is_bool(via)) { + snprintf(err, err_sz, "via must be true or false"); + } else if (types) { + detect_walk_parse_types(store, project, types, out, err, err_sz); + } + if (!err[0]) { + out->fixpoint = fixpoint && yyjson_get_bool(fixpoint); + out->via = via && yyjson_get_bool(via); + out->active = types || out->fixpoint || out->via; + } + if (!err[0] && out->active && strcmp(direction, "inbound") != 0) { + snprintf(err, err_sz, + "edge_types, fixpoint and via walk the blast radius only — use direction " + "\"inbound\" (the default)"); + } + if (!err[0] && out->active && out->type_count == 0) { + snprintf(out->types[out->type_count++], DETECT_WALK_TYPE_LEN, "CALLS"); + } + yyjson_doc_free(doc); +} + +/* The walk parameters are part of the question a cursor continues. A request + * without them hashes exactly as before, so its cursors are unchanged. */ +static uint64_t detect_walk_hash(uint64_t hash, const detect_walk_request_t *walk) { + if (!walk->active) { + return hash; + } + hash = cursor_fnv1a64(walk->fixpoint ? "|walk:fixpoint" : "|walk:bounded", hash); + hash = cursor_fnv1a64(walk->via ? "|via" : "|novia", hash); + for (int i = 0; i < walk->type_count; i++) { + hash = cursor_fnv1a64("|", hash); + hash = cursor_fnv1a64(walk->types[i], hash); + } + return hash; +} + +/* "CALLS,USAGE": the edge types of the walk, in rank order, for the answer. */ +static void detect_walk_types_text(const detect_walk_request_t *walk, char *out, size_t out_sz) { + size_t used = 0; + out[0] = '\0'; + for (int i = 0; i < walk->type_count && used < out_sz; i++) { + int n = snprintf(out + used, out_sz - used, "%s%s", i > 0 ? "," : "", walk->types[i]); + used += n > 0 ? (size_t)n : 0; + } +} + +/* Run the walk and hand its answer over in the traversal's shape, so paging, + * the module rollup and the snapshot fingerprint are shared with the default + * path. Seeds are left out, as in cbm_store_bfs_multi: a changed symbol is not + * its own impact. Rows arrive in the walk's (hop, qualified name) order. The + * walk stays open for the `via` columns; the caller closes it. + * + * false = the walk or a row could not be completed. Nothing shorter is handed + * over: an incomplete impact set would read as "nothing else depends on this". */ +static bool detect_walk_run(cbm_store_t *store, const char *project, + const detect_walk_request_t *req, int depth, const int64_t *seeds, + int seed_count, cbm_traverse_result_t *impact, + cbm_impact_walk_t **walk_out) { + const char *types[DETECT_WALK_TYPE_MAX]; + for (int i = 0; i < req->type_count; i++) { + types[i] = req->types[i]; + } + cbm_impact_policy_t policy = {.project = project, + .edge_types = types, + .edge_type_count = req->type_count, + .max_hops = req->fixpoint ? 0 : depth}; + cbm_impact_walk_t *walk = NULL; + if (cbm_impact_walk_open(store, &policy, &walk) != CBM_STORE_OK) { + return false; + } + bool ok = cbm_impact_walk_run(walk, seeds, seed_count) == CBM_STORE_OK; + int total = ok ? cbm_impact_walk_count(walk) : 0; + const cbm_impact_hit_t *hits = cbm_impact_walk_hits(walk); + /* cbm_store_traverse_free owns and releases these rows. */ + cbm_node_hop_t *rows = total > 0 ? safe_realloc(NULL, (size_t)total * sizeof(*rows)) : NULL; + ok = ok && (total == 0 || rows); + if (rows) { + memset(rows, 0, (size_t)total * sizeof(*rows)); + } + int n = 0; + for (int i = 0; ok && i < total; i++) { + if (hits[i].hop == 0) { + continue; + } + ok = cbm_store_find_node_by_id(store, hits[i].id, &rows[n].node) == CBM_STORE_OK; + if (ok) { + rows[n++].hop = hits[i].hop; + } + } + impact->visited = rows; + impact->visited_count = n; + if (!ok) { + cbm_store_traverse_free(impact); + cbm_impact_walk_close(walk); + return false; + } + *walk_out = walk; + return true; +} + static void detect_cursor_encode(char stream, const char snapshot[33], uint64_t qhash, int offset, char out[80]) { snprintf(out, 80, "d1.%c.%s.%016llx.%d", stream, snapshot, (unsigned long long)qhash, offset); @@ -16169,13 +16562,22 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { } /* Teaching error, same contract as trace_path: never silently correct an * unknown direction — the caller would misread the result's semantics. */ + char errbuf[CBM_SZ_1K] = ""; + detect_walk_request_t walk_req = {0}; + detect_seed_request_t seed_req = {0}; if (strcmp(direction, "inbound") != 0 && strcmp(direction, "outbound") != 0 && strcmp(direction, "both") != 0) { - char errbuf[CBM_SZ_256]; snprintf(errbuf, sizeof(errbuf), "invalid direction \"%s\" — use \"inbound\" (blast radius: transitive callers), " "\"outbound\" (dependencies), or \"both\"", direction); + } else { + /* Same contract for the opt-in walk parameters. */ + detect_walk_parse(store, project, args, direction, &walk_req, errbuf, sizeof(errbuf)); + if (!errbuf[0]) + detect_seed_parse(args, &seed_req, errbuf, sizeof(errbuf)); + } + if (errbuf[0]) { free(direction); free(root_path); free(project); @@ -16523,8 +16925,37 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { char *impact_cursor_arg = cbm_mcp_get_string_arg(args, "impact_cursor"); char *changed_cursor_arg = cbm_mcp_get_string_arg(args, "changed_cursor"); char *module_cursor_arg = cbm_mcp_get_string_arg(args, "module_cursor"); - uint64_t detect_qhash = detect_params_hash(project, base_branch, - want_symbols ? "impact" : "files", direction, depth); + uint64_t detect_qhash = + detect_walk_hash(detect_params_hash(project, base_branch, want_symbols ? "impact" : "files", + direction, depth), + &walk_req); + + if (seed_req.list) + detect_qhash = cursor_fnv1a64("|seeds:list", detect_qhash); + if (seed_req.product) + detect_qhash = cursor_fnv1a64("|seed_filter:product", detect_qhash); + detect_seed_rows_t seed_rows = {0}; + cbm_arena_init_lazy(&seed_rows.arena, 4096); + if (seed_req.product) { + const char *name = seed_req.config_path[0] ? seed_req.config_path : ".codebase-memory.json"; + char *config_file = NULL; + if (repo_path_is_absolute(name)) + config_file = cbm_arena_strdup(&seed_rows.arena, name); + else { + size_t a = strlen(root_path), b = strlen(name); + if (a <= SIZE_MAX - b - 2) { + config_file = cbm_arena_alloc(&seed_rows.arena, a + b + 2); + if (config_file) + snprintf(config_file, a + b + 2, "%s/%s", root_path, name); + } + } + seed_rows.config = + config_file ? cbm_test_config_load(config_file, !seed_req.config_path[0]) : NULL; + seed_rows.config_failed = !seed_rows.config; + seed_rows.failed = seed_rows.config_failed; + detect_qhash = cursor_fnv1a64("|test-config:", detect_qhash); + detect_qhash = cursor_fnv1a64(cbm_test_config_digest(seed_rows.config), detect_qhash); + } /* Changed paths drive traversal seeds and both output encodings. */ int64_t *seeds = NULL; @@ -16550,7 +16981,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { * `files` when the project root is a repository subdirectory (#1951). */ cbm_changed_hunk_t *hunks = NULL; int hunk_count = 0; - if (want_symbols) { + if (!seed_rows.failed && (want_symbols || seed_req.list)) { char hunk_cmd[CBM_SZ_2K]; #ifdef _WIN32 snprintf(hunk_cmd, sizeof(hunk_cmd), @@ -16608,17 +17039,39 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { * decision. */ for (int i = 0; i < file_count; i++) { detect_collect_seeds(store, project, files[i], hunks, hunk_count, &seeds, &seed_count, - &seed_cap); + &seed_cap, &seed_req, &seed_rows); } } + if (!seed_rows.failed && seed_rows.count > 1) + qsort(seed_rows.rows, (size_t)seed_rows.count, sizeof(*seed_rows.rows), + detect_seed_row_compare); + /* The impact traversal: ONE multi-source BFS over all seeds. */ cbm_traverse_result_t impact = {0}; bool engine_saturated = false; - if (want_symbols && seed_count > 0) { + /* The opt-in walk has no result ceiling, so it never saturates: it answers + * completely or the request fails. It stays open while `via` is rendered. */ + cbm_impact_walk_t *impact_walk = NULL; + const char *walk_error = + seed_rows.config_failed + ? "config_invalid: could not read a valid project test configuration" + : seed_rows.failed ? "seed_collection_failed: no partial answer is returned" + : NULL; + if (!walk_error && want_symbols && seed_count > 0 && walk_req.active) { + if (!detect_walk_run(store, project, &walk_req, depth, seeds, seed_count, &impact, + &impact_walk)) { + walk_error = "impact_walk_failed: the impact walk could not be completed — no " + "partial answer is returned; rerun, or drop edge_types, fixpoint and " + "via for the bounded default traversal"; + } + } else if (!walk_error && want_symbols && seed_count > 0) { (void)cbm_store_bfs_multi(store, seeds, seed_count, direction, NULL, 0, depth, MCP_BFS_LIMIT_MAX, &impact, &engine_saturated); } + const cbm_impact_walk_t *via_walk = walk_req.via ? impact_walk : NULL; + char walk_types_text[DETECT_WALK_TYPE_MAX * DETECT_WALK_TYPE_LEN] = ""; + detect_walk_types_text(&walk_req, walk_types_text, sizeof(walk_types_text)); detect_module_row_t *modules = NULL; int nmods = 0; @@ -16633,17 +17086,39 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { * number of pageable rollup rows for the materialized impact set; its * relation becomes `gte` if traversal hit the engine ceiling. */ int module_total = nmods + (module_overflow > 0 ? 1 : 0); - const char *cursor_error = NULL; + const char *cursor_error = walk_error; char detect_snapshot[33] = ""; bool detect_snapshot_complete = detect_snapshot_fingerprint( srv, root_path, head_oid, base_oid, merge_base, generation, files, file_count, &impact, modules, nmods, module_overflow, detect_snapshot); + if (seed_req.list && !seed_rows.failed) { + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + detect_snapshot_add_field(&hash, detect_snapshot); + for (int i = 0; i < seed_rows.count; i++) { + detect_seed_row_t *row = &seed_rows.rows[i]; + detect_snapshot_add_field(&hash, row->qn); + detect_snapshot_add_field(&hash, row->label); + detect_snapshot_add_field(&hash, row->file); + detect_snapshot_add_field(&hash, row->reason); + char location[64]; + snprintf(location, sizeof(location), "%d:%d", row->start, row->end); + detect_snapshot_add_field(&hash, location); + } + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + static const char hex[] = "0123456789abcdef"; + cbm_sha256_final(&hash, digest); + for (int i = 0; i < 16; i++) { + detect_snapshot[i * 2] = hex[digest[i] >> 4]; + detect_snapshot[i * 2 + 1] = hex[digest[i] & 15]; + } + } detect_cursor_t decoded_cursor = {0}; - bool cursor_supplied = (changed_cursor_arg && changed_cursor_arg[0]) || + bool cursor_supplied = seed_req.cursor[0] || (changed_cursor_arg && changed_cursor_arg[0]) || (impact_cursor_arg && impact_cursor_arg[0]) || (module_cursor_arg && module_cursor_arg[0]); - if (cursor_supplied && !detect_snapshot_complete) { + if (!cursor_error && cursor_supplied && !detect_snapshot_complete) { cursor_error = "snapshot_unavailable: changed file bytes could not be fingerprinted — " "rerun without the cursor after the files are readable"; } @@ -16683,7 +17158,21 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { } } } + if (!cursor_error && seed_req.cursor[0]) { + if (seed_req.offset != 0) + cursor_error = + "cursor_params_mismatch: seed_cursor cannot be combined with a nonzero seed_offset"; + else { + cursor_error = detect_cursor_decode(seed_req.cursor, 's', detect_snapshot, detect_qhash, + &decoded_cursor); + if (!cursor_error) + seed_req.offset = decoded_cursor.offset; + } + } if (cursor_error) { + cbm_test_config_free(seed_rows.config); + cbm_arena_destroy(&seed_rows.arena); + cbm_impact_walk_close(impact_walk); cbm_store_traverse_free(&impact); detect_module_rollup_free(modules, nmods); for (int i = 0; i < file_count; i++) { @@ -16703,6 +17192,10 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(cursor_error, true); } + int seed_start = seed_req.offset < seed_rows.count ? seed_req.offset : seed_rows.count; + int seed_returned = seed_rows.count - seed_start; + if (seed_returned > seed_req.limit) + seed_returned = seed_req.limit; int changed_start = changed_offset < file_count ? changed_offset : file_count; int changed_returned = file_count - changed_start; if (changed_returned > changed_limit) { @@ -16725,11 +17218,19 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { char *out_str = NULL; render_detect_output:; + bool seed_has_more = seed_req.list && seed_start + seed_returned < seed_rows.count; + char seed_next_cursor[80] = ""; + if (seed_has_more && seed_returned > 0 && detect_snapshot_complete) + detect_cursor_encode('s', detect_snapshot, detect_qhash, seed_start + seed_returned, + seed_next_cursor); + const char *seed_continuation = seed_req.limit == 0 + ? "seed_continuation_requires_positive_limit" + : "seed_continuation_requires_higher_budget"; bool changed_has_more = changed_start + changed_returned < file_count; bool impacted_has_more = want_symbols && imp_start + imp_returned < impact.visited_count; bool module_has_more = want_symbols && module_start + module_returned < module_total; bool response_truncated = engine_saturated || output_budget_hit || changed_has_more || - impacted_has_more || module_has_more; + impacted_has_more || module_has_more || seed_has_more; if (!legacy_json) { cbm_sb_t sb; cbm_sb_init(&sb); @@ -16738,6 +17239,10 @@ render_detect_output:; cbm_tree_scalar_str(&sb, "merge_base", merge_base); } cbm_tree_scalar_str(&sb, "direction", direction); + if (walk_req.active) { + cbm_tree_scalar_str(&sb, "edge_types", walk_types_text); + cbm_tree_scalar_bool(&sb, "fixpoint", walk_req.fixpoint); + } if (output_budget_hit) { cbm_tree_scalar_str(&sb, "truncation_reason", "output_budget"); cbm_tree_scalar_int(&sb, "max_output_bytes", (long long)output_budget_bytes); @@ -16788,8 +17293,34 @@ render_detect_output:; } free(changed_cells); cbm_tree_scalar_int(&sb, "seed_symbols", seed_count); + if (seed_req.list) { + cbm_tree_scalar_int(&sb, "seed_total", seed_rows.count); + cbm_tree_scalar_int(&sb, "seed_returned", seed_returned); + cbm_tree_scalar_bool(&sb, "seed_has_more", seed_has_more); + if (seed_has_more && seed_returned > 0) { + cbm_tree_scalar_int(&sb, "seed_next_offset", seed_start + seed_returned); + if (seed_next_cursor[0]) + cbm_tree_scalar_str(&sb, "seed_next_cursor", seed_next_cursor); + } else if (seed_has_more) + cbm_tree_scalar_bool(&sb, seed_continuation, true); + static const char *const columns[] = {"qn", "label", "file", + "start", "end", "seed_reason"}; + cbm_tree_table_header(&sb, "seeds", seed_returned, columns, 6); + for (int i = seed_start; i < seed_start + seed_returned; i++) { + const detect_seed_row_t *row = &seed_rows.rows[i]; + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, row->qn, true); + cbm_tree_cell_str(&sb, row->label, false); + cbm_tree_cell_str(&sb, row->file, false); + cbm_tree_cell_int(&sb, row->start, false); + cbm_tree_cell_int(&sb, row->end, false); + cbm_tree_cell_str(&sb, row->reason, false); + cbm_tree_row_end(&sb); + } + } if (want_symbols) { - detect_emit_impacted_tree(&sb, &impact, imp_start, imp_returned, engine_saturated); + detect_emit_impacted_tree(&sb, &impact, imp_start, imp_returned, engine_saturated, + via_walk); if (detect_snapshot_complete && imp_start + imp_returned < impact.visited_count && imp_returned > 0) { char cursor[80]; @@ -16861,6 +17392,14 @@ render_detect_output:; yyjson_mut_obj_add_strcpy(doc, root_obj, "merge_base", merge_base); } yyjson_mut_obj_add_strcpy(doc, root_obj, "direction", direction); + if (walk_req.active) { + yyjson_mut_val *walk_types = yyjson_mut_arr(doc); + for (int i = 0; i < walk_req.type_count; i++) { + yyjson_mut_arr_add_strcpy(doc, walk_types, walk_req.types[i]); + } + yyjson_mut_obj_add_val(doc, root_obj, "edge_types", walk_types); + yyjson_mut_obj_add_bool(doc, root_obj, "fixpoint", walk_req.fixpoint); + } if (output_budget_hit) { yyjson_mut_obj_add_str(doc, root_obj, "truncation_reason", "output_budget"); yyjson_mut_obj_add_uint(doc, root_obj, "max_output_bytes", output_budget_bytes); @@ -16899,6 +17438,31 @@ render_detect_output:; } yyjson_mut_obj_add_val(doc, root_obj, "changed_files", cf); yyjson_mut_obj_add_int(doc, root_obj, "seed_symbols", seed_count); + if (seed_req.list) { + yyjson_mut_obj_add_int(doc, root_obj, "seed_total", seed_rows.count); + yyjson_mut_obj_add_int(doc, root_obj, "seed_returned", seed_returned); + yyjson_mut_obj_add_bool(doc, root_obj, "seed_has_more", seed_has_more); + if (seed_has_more && seed_returned > 0) { + yyjson_mut_obj_add_int(doc, root_obj, "seed_next_offset", + seed_start + seed_returned); + if (seed_next_cursor[0]) + yyjson_mut_obj_add_strcpy(doc, root_obj, "seed_next_cursor", seed_next_cursor); + } else if (seed_has_more) + yyjson_mut_obj_add_bool(doc, root_obj, seed_continuation, true); + yyjson_mut_val *seed_array = yyjson_mut_arr(doc); + for (int i = seed_start; i < seed_start + seed_returned; i++) { + const detect_seed_row_t *row = &seed_rows.rows[i]; + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, item, "qn", row->qn); + yyjson_mut_obj_add_strcpy(doc, item, "label", row->label); + yyjson_mut_obj_add_strcpy(doc, item, "file", row->file); + yyjson_mut_obj_add_int(doc, item, "start", row->start); + yyjson_mut_obj_add_int(doc, item, "end", row->end); + yyjson_mut_obj_add_strcpy(doc, item, "seed_reason", row->reason); + yyjson_mut_arr_add_val(seed_array, item); + } + yyjson_mut_obj_add_val(doc, root_obj, "seeds", seed_array); + } yyjson_mut_obj_add_int(doc, root_obj, "impacted_total", impact.visited_count); yyjson_mut_obj_add_str(doc, root_obj, "impacted_total_relation", engine_saturated ? "gte" : "eq"); @@ -16915,6 +17479,13 @@ render_detect_output:; doc, o, "file", impact.visited[i].node.file_path ? impact.visited[i].node.file_path : ""); yyjson_mut_obj_add_int(doc, o, "hop", impact.visited[i].hop); + if (via_walk) { + const char *via_qn = ""; + const char *via_edge = ""; + detect_walk_via(via_walk, impact.visited[i].node.id, &via_qn, &via_edge); + yyjson_mut_obj_add_strcpy(doc, o, "via_qn", via_qn); + yyjson_mut_obj_add_strcpy(doc, o, "via_edge", via_edge); + } yyjson_mut_arr_add_val(imp, o); } yyjson_mut_obj_add_val(doc, root_obj, "impacted", imp); @@ -16983,6 +17554,8 @@ render_detect_output:; module_returned = 0; } else if (changed_returned > 0) { changed_returned = 0; + } else if (seed_returned > 0) { + seed_returned--; } else if (imp_returned > 0) { /* Prefix-directory activation can make N compact rows smaller * than N-1 direct rows. Probe every smaller whole-row prefix so @@ -17034,6 +17607,9 @@ render_detect_output:; detect_output_done: + cbm_test_config_free(seed_rows.config); + cbm_arena_destroy(&seed_rows.arena); + cbm_impact_walk_close(impact_walk); cbm_store_traverse_free(&impact); detect_module_rollup_free(modules, nmods); for (int i = 0; i < file_count; i++) { diff --git a/src/mcp/test_impact.c b/src/mcp/test_impact.c new file mode 100644 index 000000000..6ad66adb4 --- /dev/null +++ b/src/mcp/test_impact.c @@ -0,0 +1,1391 @@ +/* + * test_impact.c — the test-impact engine behind detect_changes scope:"tests". + * + * Part 1: the test model (see test_impact.h). + */ +#include "mcp/test_impact.h" + +#include "foundation/arena.h" + +#include +#include +#include +#include +#include + +/* ── Model storage ───────────────────────────────────────────────── */ + +enum { + TM_ARENA_BLOCK = 64 * 1024, + TM_INVOCATION_ARENA_BLOCK = 4 * 1024, + TM_FIRST_CAP = 64, + TM_GROWTH = 2, + /* #if nesting the scanner follows. Deeper frames are still counted, so the + * matching #endif is found; only their brace depth is not restored. */ + TM_COND_MAX = 64, +}; + +struct cbm_test_model { + CBMArena arena; /* owns every string and every array below */ + cbm_test_conventions_t conv; + cbm_test_case_t *cases; + int case_count; + int case_cap; + cbm_test_suite_t *suites; + int suite_count; + int suite_cap; + cbm_test_registration_t *regs; + int reg_count; + int reg_cap; + cbm_test_runner_suite_t *runner; + int runner_count; + int runner_cap; + bool descriptor_mode; + cbm_test_declaration_t *declarations; + int declaration_count; + bool presets[CBM_TEST_PRESET_COUNT]; + cbm_test_model_mapping_issue_t mapping; + bool finished; + bool incomplete; /* sticky: a partial scan must never justify omission */ +}; + +const cbm_test_conventions_t *cbm_test_conventions_cbm(void) { + static const cbm_test_conventions_t conv = { + .case_macro = "TEST", + .suite_macro = "SUITE", + .suite_fn_prefix = "suite_", + .run_macro = "RUN_TEST", + .suite_run_macro = "RUN_SELECTED_SUITE", + .suite_run_perf_macro = "RUN_SELECTED_SUITE_PERF", + }; + return &conv; +} + +/* Room for one more item of `item_size` in an arena-backed array. The old + * block stays in the arena; doubling keeps the total under twice the final + * size. NULL = out of memory. */ +static void *tm_grow(CBMArena *arena, void *items, int count, int *cap, size_t item_size) { + if (count < *cap) { + return items; + } + if (*cap < 0 || *cap > INT_MAX / TM_GROWTH) { + return NULL; + } + int new_cap = *cap > 0 ? *cap * TM_GROWTH : TM_FIRST_CAP; + if (item_size && (size_t)new_cap > SIZE_MAX / item_size) { + return NULL; + } + void *grown = cbm_arena_alloc(arena, (size_t)new_cap * item_size); + if (!grown) { + return NULL; + } + if (count > 0) { + memcpy(grown, items, (size_t)count * item_size); + } + *cap = new_cap; + return grown; +} + +static bool tm_copy_convention(CBMArena *arena, const char *in, const char **out) { + *out = NULL; + if (!in || !in[0]) { + return true; + } + *out = cbm_arena_strdup(arena, in); + return *out != NULL; +} + +cbm_test_model_t *cbm_test_model_new(const cbm_test_conventions_t *conventions) { + if (!conventions) { + return NULL; + } + CBMArena arena; + cbm_arena_init_sized(&arena, TM_ARENA_BLOCK); + cbm_test_model_t *m = cbm_arena_calloc(&arena, sizeof(*m)); + if (!m) { + cbm_arena_destroy(&arena); + return NULL; + } + m->arena = arena; /* the model lives in its own arena */ + m->mapping.declaration_index = -1; + bool ok = + tm_copy_convention(&m->arena, conventions->case_macro, &m->conv.case_macro) && + tm_copy_convention(&m->arena, conventions->suite_macro, &m->conv.suite_macro) && + tm_copy_convention(&m->arena, conventions->suite_fn_prefix, &m->conv.suite_fn_prefix) && + tm_copy_convention(&m->arena, conventions->run_macro, &m->conv.run_macro) && + tm_copy_convention(&m->arena, conventions->suite_run_macro, &m->conv.suite_run_macro) && + tm_copy_convention(&m->arena, conventions->suite_run_perf_macro, + &m->conv.suite_run_perf_macro); + if (!ok) { + cbm_test_model_free(m); + return NULL; + } + return m; +} + +void cbm_test_model_free(cbm_test_model_t *m) { + if (!m) { + return; + } + CBMArena arena = m->arena; /* m itself is inside it */ + cbm_arena_destroy(&arena); +} + +/* Config tokens plus the display names returned by cbm_language_name(). */ +static int tm_language_kind(const char *language) { + if (!language) + return 0; + if (strcmp(language, "c") == 0 || strcmp(language, "C") == 0) + return 1; + if (strcmp(language, "cpp") == 0 || strcmp(language, "c++") == 0 || + strcmp(language, "C++") == 0) + return 2; + if (strcmp(language, "cuda") == 0 || strcmp(language, "CUDA") == 0) + return 3; + return 0; +} + +static bool tm_mapping_report(cbm_test_model_t *m, cbm_test_model_mapping_status_t reason, + int declaration, const char *file, int line) { + m->incomplete = true; + const cbm_test_model_mapping_issue_t *old = &m->mapping; + int by_file = strcmp(file ? file : "", old->file ? old->file : ""); + bool earlier = + old->reason == CBM_TEST_MODEL_MAPPING_OK || by_file < 0 || + (by_file == 0 && + (line < old->line || (line == old->line && + (declaration < old->declaration_index || + (declaration == old->declaration_index && reason < old->reason))))); + if (!earlier) + return true; + const char *owned_file = file ? cbm_arena_strdup(&m->arena, file) : NULL; + if (file && !owned_file) + return false; + m->mapping = (cbm_test_model_mapping_issue_t){ + .reason = reason, .declaration_index = declaration, .file = owned_file, .line = line}; + return true; +} + +static bool tm_copy_declaration(cbm_test_model_t *m, const cbm_test_declaration_t *src, + cbm_test_declaration_t *dst) { + *dst = *src; + dst->name_args = NULL; + if (!tm_copy_convention(&m->arena, src->language, &dst->language) || + !tm_copy_convention(&m->arena, src->define_macro, &dst->define_macro) || + !tm_copy_convention(&m->arena, src->runner_id, &dst->runner_id) || + !tm_copy_convention(&m->arena, src->macro, &dst->macro) || + !tm_copy_convention(&m->arena, src->perf_macro, &dst->perf_macro)) + return false; + if (src->name_arg_count > 0) { + size_t count = (size_t)src->name_arg_count; + if (!src->name_args || count > SIZE_MAX / sizeof(int)) + return false; + int *args = cbm_arena_alloc(&m->arena, count * sizeof(*args)); + if (!args) + return false; + memcpy(args, src->name_args, count * sizeof(*args)); + dst->name_args = args; + } else if (src->name_arg_count < 0) + return false; + return true; +} + +cbm_test_model_t *cbm_test_model_new_declarations(const cbm_test_declarations_t *declarations) { + if (!declarations) + return NULL; + cbm_test_conventions_t empty = {0}; + cbm_test_model_t *m = cbm_test_model_new(&empty); + if (!m) + return NULL; + m->descriptor_mode = true; + m->mapping.declaration_index = -1; + const cbm_test_declaration_t *items = + cbm_test_declarations_items(declarations, &m->declaration_count); + if (m->declaration_count < 0 || m->declaration_count > INT_MAX - 4 || + (size_t)m->declaration_count > SIZE_MAX / sizeof(*m->declarations)) + goto failed; + if (m->declaration_count) { + if (!items) + goto failed; + m->declarations = + cbm_arena_calloc(&m->arena, (size_t)m->declaration_count * sizeof(*m->declarations)); + if (!m->declarations) + goto failed; + for (int i = 0; i < m->declaration_count; i++) { + if (!tm_copy_declaration(m, &items[i], &m->declarations[i])) + goto failed; + if (!tm_language_kind(items[i].language) && + !tm_mapping_report(m, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_LANGUAGE, i, NULL, 0)) + goto failed; + } + } + for (int i = 0; i < CBM_TEST_PRESET_COUNT; i++) { + if (!cbm_test_declarations_preset(declarations, (cbm_test_preset_t)i, &m->presets[i])) + goto failed; + } + if (m->presets[CBM_TEST_PRESET_C_CBM] && + !tm_copy_convention(&m->arena, "suite_", &m->conv.suite_fn_prefix)) + goto failed; + return m; +failed: + cbm_test_model_free(m); + return NULL; +} + +cbm_test_model_mapping_status_t cbm_test_model_mapping_status( + const cbm_test_model_t *m, cbm_test_model_mapping_issue_t *issue) { + cbm_test_model_mapping_issue_t result = {.reason = CBM_TEST_MODEL_MAPPING_INVALID_INPUT, + .declaration_index = -1}; + if (m) + result = m->mapping; + if (issue) + *issue = result; + return result.reason; +} + +const cbm_test_declaration_t *cbm_test_model_declarations(const cbm_test_model_t *m, int *count) { + if (count) + *count = m && m->descriptor_mode ? m->declaration_count : 0; + return m && m->descriptor_mode ? m->declarations : NULL; +} + +/* ── Lexer ─────────────────────────────────────────────────────────── + * Just enough C to tell code from comments and literals: identifiers, + * single punctuation characters and whole preprocessor lines. Every read is + * bounded by `end`; the text need not be terminated. */ + +typedef enum { TM_TOK_EOF = 0, TM_TOK_IDENT, TM_TOK_PUNCT, TM_TOK_DIRECTIVE } tm_tok_kind_t; + +typedef struct { + tm_tok_kind_t kind; + const char *text; /* IDENT: the name. DIRECTIVE: what follows the '#'. */ + size_t len; + char ch; /* PUNCT */ + int line; /* 1-based line the token starts on */ +} tm_tok_t; + +typedef struct { + const char *p; + const char *end; + int line; + bool line_start; /* only blanks so far on this line: a '#' opens a directive */ + bool *incomplete; /* shared by lookahead copies; restoring a cursor cannot erase an error */ +} tm_lex_t; + +static bool tm_ident_start(char c) { + return isalpha((unsigned char)c) || c == '_'; +} + +static bool tm_ident_char(char c) { + return isalnum((unsigned char)c) || c == '_'; +} + +static void tm_lex_uncertain(tm_lex_t *lx) { + if (lx->incomplete) { + *lx->incomplete = true; + } +} + +/* Raw strings need a delimiter-aware C++ lexer, which this model does not + * implement. Retain the old rows for diagnostics, but never certify them. */ +static bool tm_raw_literal_starts(const char *p, const char *end) { + size_t left = (size_t)(end - p); + return (left >= 2 && p[0] == 'R' && p[1] == '"') || + (left >= 3 && (p[0] == 'u' || p[0] == 'U' || p[0] == 'L') && p[1] == 'R' && + p[2] == '"') || + (left >= 4 && p[0] == 'u' && p[1] == '8' && p[2] == 'R' && p[3] == '"'); +} + +static void tm_skip_line_comment(tm_lex_t *lx) { + while (lx->p < lx->end && *lx->p != '\n') { + /* Translation-phase line splicing can extend this comment. The + * legacy lexer does not implement it, so its rows are uncertain. */ + if (*lx->p == '\\' && lx->p + 1 < lx->end && + (lx->p[1] == '\n' || (lx->p[1] == '\r' && lx->p + 2 < lx->end && lx->p[2] == '\n'))) { + tm_lex_uncertain(lx); + } + lx->p++; + } +} + +static void tm_skip_block_comment(tm_lex_t *lx) { + lx->p += 2; + while (lx->p < lx->end) { + if (*lx->p == '*' && lx->p + 1 < lx->end && lx->p[1] == '/') { + lx->p += 2; + return; + } + if (*lx->p == '\n') { + lx->line++; + } + lx->p++; + } + tm_lex_uncertain(lx); /* EOF before the closing comment delimiter */ +} + +/* A string or character literal. It ends at its closing quote, or at the end + * of the line when the quote is missing, so one stray quote costs one line. */ +static void tm_skip_literal(tm_lex_t *lx) { + char quote = *lx->p++; + while (lx->p < lx->end && *lx->p != quote && *lx->p != '\n') { + if (*lx->p == '\\' && lx->p + 1 < lx->end) { + if (lx->p[1] == '\n') { + lx->line++; + } + lx->p++; + } + lx->p++; + } + if (lx->p < lx->end && *lx->p == quote) { + lx->p++; + } else { + tm_lex_uncertain(lx); + } +} + +/* The rest of a preprocessor line, backslash continuations included. */ +static void tm_read_directive(tm_lex_t *lx, tm_tok_t *tok) { + lx->p++; /* '#' */ + tok->kind = TM_TOK_DIRECTIVE; + tok->text = lx->p; + while (lx->p < lx->end && *lx->p != '\n') { + if (tm_raw_literal_starts(lx->p, lx->end)) { + tm_lex_uncertain(lx); + } + if (*lx->p == '/' && lx->p + 1 < lx->end && lx->p[1] == '/') { + tm_skip_line_comment(lx); + continue; + } + if (*lx->p == '"' || *lx->p == '\'') { + tm_skip_literal(lx); + continue; + } + if (*lx->p == '\\' && lx->p + 1 < lx->end && lx->p[1] == '\n') { + lx->line++; + lx->p += 2; + continue; + } + if (*lx->p == '/' && lx->p + 1 < lx->end && lx->p[1] == '*') { + tm_skip_block_comment(lx); /* may run on to later lines */ + continue; + } + lx->p++; + } + tok->len = (size_t)(lx->p - tok->text); +} + +static void tm_next(tm_lex_t *lx, tm_tok_t *tok) { + memset(tok, 0, sizeof(*tok)); + while (lx->p < lx->end) { + char c = *lx->p; + if (c == '\n') { + lx->line++; + lx->line_start = true; + lx->p++; + } else if (isspace((unsigned char)c)) { + lx->p++; + } else if (c == '/' && lx->p + 1 < lx->end && lx->p[1] == '/') { + tm_skip_line_comment(lx); + } else if (c == '/' && lx->p + 1 < lx->end && lx->p[1] == '*') { + tm_skip_block_comment(lx); + } else { + break; + } + } + tok->line = lx->line; + if (lx->p >= lx->end) { + return; /* TM_TOK_EOF */ + } + char c = *lx->p; + if (tm_raw_literal_starts(lx->p, lx->end)) { + tm_lex_uncertain(lx); + } + bool line_start = lx->line_start; + lx->line_start = false; + if (c == '#' && line_start) { + tm_read_directive(lx, tok); + } else if (c == '"' || c == '\'') { + tm_skip_literal(lx); + tok->kind = TM_TOK_PUNCT; + tok->ch = c; + } else if (tm_ident_start(c)) { + tok->kind = TM_TOK_IDENT; + tok->text = lx->p; + while (lx->p < lx->end && tm_ident_char(*lx->p)) { + lx->p++; + } + tok->len = (size_t)(lx->p - tok->text); + } else if (isdigit((unsigned char)c)) { + /* A number, suffix included: `0x1F`, `10u`. Never an identifier. */ + while (lx->p < lx->end && tm_ident_char(*lx->p)) { + lx->p++; + } + tok->kind = TM_TOK_PUNCT; + tok->ch = '0'; + } else { + tok->kind = TM_TOK_PUNCT; + tok->ch = c; + lx->p++; + } +} + +static bool tm_tok_is(const tm_tok_t *tok, const char *word) { + return word && tok->kind == TM_TOK_IDENT && strlen(word) == tok->len && + memcmp(tok->text, word, tok->len) == 0; +} + +static bool tm_tok_punct(const tm_tok_t *tok, char ch) { + return tok->kind == TM_TOK_PUNCT && tok->ch == ch; +} + +/* `( name )` right after a macro name. On a match the lexer stands behind the + * `)`; otherwise it is left where it was. */ +static bool tm_macro_arg(tm_lex_t *lx, tm_tok_t *name) { + tm_lex_t at = *lx; + tm_tok_t tok; + tm_next(lx, &tok); + if (tm_tok_punct(&tok, '(')) { + tm_next(lx, name); + if (name->kind == TM_TOK_IDENT) { + tm_next(lx, &tok); + if (tm_tok_punct(&tok, ')')) { + return true; + } + } + } + *lx = at; + return false; +} + +/* Is the next token the `{` of a body? It is consumed on a match. */ +static bool tm_body_opens(tm_lex_t *lx) { + tm_lex_t at = *lx; + tm_tok_t tok; + tm_next(lx, &tok); + if (tm_tok_punct(&tok, '{')) { + return true; + } + *lx = at; + return false; +} + +/* `suite_name ( void )` after `void`: the written-out form of SUITE(name). On + * a match `name` is the part after the prefix and the lexer stands behind the + * `)`. */ +static bool tm_suite_function(tm_lex_t *lx, const char *prefix, tm_tok_t *name) { + if (!prefix) { + return false; + } + tm_lex_t at = *lx; + size_t prefix_len = strlen(prefix); + tm_tok_t tok; + tm_next(lx, name); + if (name->kind == TM_TOK_IDENT && name->len > prefix_len && + memcmp(name->text, prefix, prefix_len) == 0) { + tm_next(lx, &tok); + if (tm_tok_punct(&tok, '(')) { + tm_next(lx, &tok); + if (tm_tok_is(&tok, "void")) { + tm_next(lx, &tok); + if (tm_tok_punct(&tok, ')')) { + name->text += prefix_len; + name->len -= prefix_len; + return true; + } + } + } + } + *lx = at; + return false; +} + +/* ── Scanner ─────────────────────────────────────────────────────── */ + +typedef enum { TM_OPEN_NONE = 0, TM_OPEN_CASE, TM_OPEN_SUITE } tm_open_t; + +typedef struct { + int depth; /* brace depth where the #if stood: each branch starts from it */ + bool guard; /* an include guard, which conditions nothing */ + bool seen_else; +} tm_cond_t; + +typedef struct { + cbm_test_model_t *m; + const char *file; /* arena copy shared by this file's entries */ + int language_kind; /* explicit descriptor input; zero for legacy/unsupported */ + tm_lex_t lx; + int depth; + tm_open_t open; /* the definition whose body the scanner is inside */ + int open_index; + tm_cond_t cond[TM_COND_MAX]; + int cond_count; /* may exceed TM_COND_MAX: the excess is counted only */ + int conditions; /* frames that are real conditions */ + bool macro_registrations; /* a #define of this file registers tests */ + bool seen_token; /* comments/whitespace do not prevent an initial include guard */ + bool guard_closed; /* an ordinary include guard must enclose the remaining file */ + bool ok; +} tm_scan_t; + +static const char *tm_name(tm_scan_t *sc, const tm_tok_t *tok) { + const char *copy = cbm_arena_strndup(&sc->m->arena, tok->text, tok->len); + sc->ok = sc->ok && copy != NULL; + return copy; +} + +static void tm_add_case(tm_scan_t *sc, const tm_tok_t *name, int line) { + cbm_test_model_t *m = sc->m; + cbm_test_case_t *items = + tm_grow(&m->arena, m->cases, m->case_count, &m->case_cap, sizeof(*items)); + const char *copy = tm_name(sc, name); + if (!items || !copy) { + sc->ok = false; + return; + } + m->cases = items; + sc->open = TM_OPEN_CASE; + sc->open_index = m->case_count; + items[m->case_count++] = (cbm_test_case_t){.file = sc->file, + .name = copy, + .start_line = line, + .end_line = line, + .conditional = sc->conditions > 0}; +} + +static void tm_add_suite(tm_scan_t *sc, const tm_tok_t *name, int line) { + cbm_test_model_t *m = sc->m; + cbm_test_suite_t *items = + tm_grow(&m->arena, m->suites, m->suite_count, &m->suite_cap, sizeof(*items)); + const char *copy = tm_name(sc, name); + if (!items || !copy) { + sc->ok = false; + return; + } + m->suites = items; + sc->open = TM_OPEN_SUITE; + sc->open_index = m->suite_count; + items[m->suite_count++] = + (cbm_test_suite_t){.file = sc->file, .name = copy, .start_line = line, .end_line = line}; +} + +static void tm_add_registration(tm_scan_t *sc, const tm_tok_t *test, int line) { + cbm_test_model_t *m = sc->m; + cbm_test_registration_t *items = + tm_grow(&m->arena, m->regs, m->reg_count, &m->reg_cap, sizeof(*items)); + const char *copy = tm_name(sc, test); + if (!items || !copy) { + sc->ok = false; + return; + } + m->regs = items; + items[m->reg_count++] = (cbm_test_registration_t){.file = sc->file, + .suite = m->suites[sc->open_index].name, + .test = copy, + .line = line, + .conditional = sc->conditions > 0}; +} + +static void tm_add_runner_suite(tm_scan_t *sc, const tm_tok_t *name, bool perf) { + cbm_test_model_t *m = sc->m; + cbm_test_runner_suite_t *items = + tm_grow(&m->arena, m->runner, m->runner_count, &m->runner_cap, sizeof(*items)); + const char *copy = tm_name(sc, name); + if (!items || !copy) { + sc->ok = false; + return; + } + m->runner = items; + items[m->runner_count++] = (cbm_test_runner_suite_t){.name = copy, .perf = perf}; +} + +/* Virtual preset records are immutable. Original configured records are kept + * separately and returned unchanged by cbm_test_model_declarations(). */ +static const int tm_cbm_name_arg = 0; +static const cbm_test_declaration_t tm_cbm_rules[] = { + {.language = "c", + .role = CBM_TEST_DECL_CASE, + .define_macro = "TEST", + .name_args = &tm_cbm_name_arg, + .name_arg_count = 1, + .runner_id = "{suite}:{name}", + .test_arg = -1, + .suite_arg = -1}, + {.language = "c", + .role = CBM_TEST_DECL_SUITE, + .define_macro = "SUITE", + .name_args = &tm_cbm_name_arg, + .name_arg_count = 1, + .test_arg = -1, + .suite_arg = -1}, + {.language = "c", + .role = CBM_TEST_DECL_REGISTRATION, + .macro = "RUN_TEST", + .test_arg = 0, + .suite_arg = -1}, + {.language = "c", + .role = CBM_TEST_DECL_SUITE_REGISTRATION, + .macro = "RUN_SELECTED_SUITE", + .perf_macro = "RUN_SELECTED_SUITE_PERF", + .test_arg = -1, + .suite_arg = 0}, +}; + +static bool tm_cbm_active(const tm_scan_t *sc) { + return sc->language_kind == 1 && sc->m->presets[CBM_TEST_PRESET_C_CBM]; +} + +static int tm_rule_count(const tm_scan_t *sc) { + return sc->m->declaration_count + (tm_cbm_active(sc) ? 4 : 0); +} + +static const cbm_test_declaration_t *tm_rule(const tm_scan_t *sc, int index, + int *declaration_index) { + if (index < sc->m->declaration_count) { + *declaration_index = index; + return &sc->m->declarations[index]; + } + *declaration_index = -1; + return &tm_cbm_rules[index - sc->m->declaration_count]; +} + +static bool tm_rule_matches(const tm_scan_t *sc, const cbm_test_declaration_t *rule, + const tm_tok_t *tok, bool *perf) { + *perf = false; + if (tm_language_kind(rule->language) != sc->language_kind) + return false; + if (rule->role == CBM_TEST_DECL_CASE || rule->role == CBM_TEST_DECL_SUITE) + return tm_tok_is(tok, rule->define_macro); + if (rule->role == CBM_TEST_DECL_SUITE_REGISTRATION && tm_tok_is(tok, rule->perf_macro)) { + *perf = true; + return true; + } + return tm_tok_is(tok, rule->macro); +} + +static void tm_scan_mapping(tm_scan_t *sc, cbm_test_model_mapping_status_t reason, int declaration, + int line) { + if (!tm_mapping_report(sc->m, reason, declaration, sc->file, line)) + sc->ok = false; +} + +static bool tm_macro_spelling(const char *name) { + if (!name || !tm_ident_start(*name)) + return false; + for (const char *p = name + 1; *p; p++) { + if (!tm_ident_char(*p)) + return false; + } + return true; +} + +static void tm_descriptor_preflight(tm_scan_t *sc) { + if (!sc->language_kind) { + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_LANGUAGE, -1, 0); + return; + } + /* The native gtest preset has its own suite/runner semantics. Reading + * explicit C++ records alone cannot discharge that enabled contract. */ + if (sc->language_kind != 1 && sc->m->presets[CBM_TEST_PRESET_GTEST]) + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_PRESET, -1, 0); + for (int i = 0; i < sc->m->declaration_count && sc->ok; i++) { + const cbm_test_declaration_t *rule = &sc->m->declarations[i]; + if (tm_language_kind(rule->language) != sc->language_kind) + continue; + if (rule->role == CBM_TEST_DECL_SUITE_REGISTRATION && rule->perf_macro && + strcmp(rule->macro, rule->perf_macro) == 0) + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_AMBIGUOUS, i, 0); + bool definition = rule->role == CBM_TEST_DECL_CASE || rule->role == CBM_TEST_DECL_SUITE; + if (!tm_macro_spelling(definition ? rule->define_macro : rule->macro) || + (rule->perf_macro && !tm_macro_spelling(rule->perf_macro))) + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_FORM, i, 0); + if ((rule->role == CBM_TEST_DECL_CASE || rule->role == CBM_TEST_DECL_SUITE) && + (rule->name_arg_count != 1 || !rule->name_args || rule->name_args[0] < 0)) + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_NAME, i, 0); + if (rule->role == CBM_TEST_DECL_CASE && + (!rule->runner_id || strcmp(rule->runner_id, "{suite}:{name}") != 0)) + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_TEMPLATE, i, 0); + } +} + +typedef struct { + tm_tok_t first; + bool has_token; + bool single_identifier; +} tm_argument_t; + +typedef struct { + CBMArena arena; /* temporary invocation owner, independent of the model arena */ + tm_argument_t *args; /* grows from actual arity, never from configured argument indexes */ + int count; + int cap; + tm_lex_t after; +} tm_invocation_t; + +static bool tm_argument_append(tm_invocation_t *invocation, const tm_argument_t *arg) { + if (invocation->count == invocation->cap) { + if (invocation->cap > INT_MAX / 2) + return false; + int cap = invocation->cap ? invocation->cap * 2 : 8; + if ((size_t)cap > SIZE_MAX / sizeof(*invocation->args)) + return false; + tm_argument_t *args = cbm_arena_alloc(&invocation->arena, (size_t)cap * sizeof(*args)); + if (!args) + return false; + if (invocation->count) + memcpy(args, invocation->args, (size_t)invocation->count * sizeof(*args)); + invocation->args = args; + invocation->cap = cap; + } + invocation->args[invocation->count++] = *arg; + return true; +} + +/* C macro argument commas are shielded by parentheses, not by brackets, + * braces or template angle brackets. The lexer handles comments and ordinary + * literals as units. Unsupported raw/spliced text latches the shared flag. + * Return 1=parsed, 0=malformed, -1=allocation/counter failure. */ +static int tm_invocation_parse(tm_scan_t *sc, tm_lex_t at, tm_invocation_t *invocation) { + tm_tok_t tok; + tm_next(&at, &tok); + if (!tm_tok_punct(&tok, '(')) + return 0; + int depth = 0; + tm_argument_t arg = {0}; + for (;;) { + tm_next(&at, &tok); + if (tok.kind == TM_TOK_EOF || tok.kind == TM_TOK_DIRECTIVE) + return 0; + if (tm_tok_punct(&tok, ')') && depth == 0) { + if ((arg.has_token || invocation->count) && !tm_argument_append(invocation, &arg)) + return -1; + invocation->after = at; + return 1; + } + if (tm_tok_punct(&tok, ',') && depth == 0) { + if (!tm_argument_append(invocation, &arg)) + return -1; + memset(&arg, 0, sizeof(arg)); + continue; + } + /* Consuming an outer invocation must not hide another configured + * invocation in an unselected argument. We cannot infer expansion or + * execution of that inner call, so these rows cannot justify omission. */ + if (tok.kind == TM_TOK_IDENT) { + for (int i = 0; i < tm_rule_count(sc); i++) { + int declaration; + const cbm_test_declaration_t *rule = tm_rule(sc, i, &declaration); + bool perf; + if (!tm_rule_matches(sc, rule, &tok, &perf)) + continue; + tm_lex_t ahead = at; + tm_tok_t next; + tm_next(&ahead, &next); + if (tm_tok_punct(&next, '(')) + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_FORM, declaration, + tok.line); + } + } + if (!arg.has_token) { + arg.first = tok; + arg.has_token = true; + arg.single_identifier = tok.kind == TM_TOK_IDENT; + } else { + arg.single_identifier = false; + } + if (tm_tok_punct(&tok, '(')) { + if (depth == INT_MAX) + return -1; + depth++; + } else if (tm_tok_punct(&tok, ')')) { + depth--; + } + } +} + +/* Inspect all rules against one untouched token/invocation snapshot. Exact + * duplicates are idempotent; disagreement never acquires first-match priority. */ +static bool tm_descriptor_ident(tm_scan_t *sc, const tm_tok_t *tok) { + int first = -2; + for (int i = 0; i < tm_rule_count(sc); i++) { + int declaration; + const cbm_test_declaration_t *rule = tm_rule(sc, i, &declaration); + bool perf; + if (tm_rule_matches(sc, rule, tok, &perf)) { + first = declaration; + break; + } + } + if (first == -2) + return false; + tm_invocation_t invocation = {0}; + cbm_arena_init_sized(&invocation.arena, TM_INVOCATION_ARENA_BLOCK); + int parsed = tm_invocation_parse(sc, sc->lx, &invocation); + if (parsed != 1) { + if (parsed < 0) + sc->ok = false; + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_MALFORMED_INVOCATION, first, tok->line); + cbm_arena_destroy(&invocation.arena); + return true; + } + const cbm_test_declaration_t *chosen = NULL; + int chosen_index = -1, position = -1; + bool chosen_perf = false, valid = true; + for (int i = 0; i < tm_rule_count(sc); i++) { + int declaration; + const cbm_test_declaration_t *rule = tm_rule(sc, i, &declaration); + bool perf; + if (!tm_rule_matches(sc, rule, tok, &perf)) + continue; + int argument = rule->role == CBM_TEST_DECL_REGISTRATION ? rule->test_arg : rule->suite_arg; + if (rule->role == CBM_TEST_DECL_CASE || rule->role == CBM_TEST_DECL_SUITE) { + if (rule->name_arg_count != 1 || !rule->name_args) { + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_NAME, declaration, + tok->line); + valid = false; + continue; + } + argument = rule->name_args[0]; + } + if (rule->role == CBM_TEST_DECL_CASE && + (!rule->runner_id || strcmp(rule->runner_id, "{suite}:{name}") != 0)) { + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_TEMPLATE, declaration, + tok->line); + valid = false; + } + if (argument < 0 || argument >= invocation.count) { + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_MISSING_ARGUMENT, declaration, tok->line); + valid = false; + continue; + } + if (!invocation.args[argument].single_identifier) { + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_ARGUMENT, declaration, + tok->line); + valid = false; + } + if (chosen && (chosen->role != rule->role || position != argument || chosen_perf != perf)) { + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_AMBIGUOUS, declaration, tok->line); + valid = false; + } + if (!chosen) { + chosen = rule; + chosen_index = declaration; + position = argument; + } + chosen_perf = chosen_perf || perf; + } + if (!valid || !chosen || !sc->ok) { + cbm_arena_destroy(&invocation.arena); + return true; + } + tm_tok_t name = invocation.args[position].first; + if (chosen->role == CBM_TEST_DECL_CASE || chosen->role == CBM_TEST_DECL_SUITE) { + tm_lex_t after = invocation.after; + if (sc->depth || sc->open != TM_OPEN_NONE || !tm_body_opens(&after)) { + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_FORM, chosen_index, tok->line); + } else { + sc->lx = after; + sc->depth = 1; + if (chosen->role == CBM_TEST_DECL_CASE) + tm_add_case(sc, &name, tok->line); + else + tm_add_suite(sc, &name, tok->line); + } + } else if (chosen->role == CBM_TEST_DECL_REGISTRATION) { + if (sc->open != TM_OPEN_SUITE || sc->depth == 0) + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_FORM, chosen_index, tok->line); + else { + sc->lx = invocation.after; + tm_add_registration(sc, &name, tok->line); + } + } else { + sc->lx = invocation.after; + tm_add_runner_suite(sc, &name, chosen_perf); + } + cbm_arena_destroy(&invocation.arena); + return true; +} + +static void tm_descriptor_macro_body(tm_scan_t *sc, const tm_tok_t *word) { + for (int i = 0; i < tm_rule_count(sc); i++) { + int declaration; + const cbm_test_declaration_t *rule = tm_rule(sc, i, &declaration); + bool perf; + if (!tm_rule_matches(sc, rule, word, &perf)) + continue; + if (rule->role == CBM_TEST_DECL_REGISTRATION) + sc->macro_registrations = true; + else + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_FORM, declaration, word->line); + } +} + +/* The first word of a directive and what follows it. */ +static void tm_directive_word(const tm_tok_t *tok, tm_tok_t *word, tm_lex_t *rest, + bool *incomplete) { + *rest = (tm_lex_t){ + .p = tok->text, .end = tok->text + tok->len, .line = tok->line, .incomplete = incomplete}; + tm_next(rest, word); +} + +/* `#ifndef X` directly followed by `#define X` is an include guard. */ +static bool tm_is_include_guard(const tm_scan_t *sc, const tm_tok_t *guarded) { + tm_lex_t ahead = sc->lx; + tm_tok_t tok; + tm_next(&ahead, &tok); + if (tok.kind != TM_TOK_DIRECTIVE) { + return false; + } + tm_tok_t word; + tm_lex_t rest; + tm_directive_word(&tok, &word, &rest, sc->lx.incomplete); + if (!tm_tok_is(&word, "define")) { + return false; + } + tm_next(&rest, &word); + return word.kind == TM_TOK_IDENT && word.len == guarded->len && + memcmp(word.text, guarded->text, word.len) == 0; +} + +static void tm_directive(tm_scan_t *sc, const tm_tok_t *tok) { + tm_tok_t word; + tm_lex_t rest; + tm_directive_word(tok, &word, &rest, sc->lx.incomplete); + if (tm_tok_is(&word, "if") || tm_tok_is(&word, "ifdef") || tm_tok_is(&word, "ifndef")) { + bool guard = false; + tm_tok_t condition; + tm_next(&rest, &condition); + if (condition.kind == TM_TOK_EOF || + (!tm_tok_is(&word, "if") && condition.kind != TM_TOK_IDENT)) { + sc->m->incomplete = true; + } + if (tm_tok_is(&word, "ifndef")) { + guard = condition.kind == TM_TOK_IDENT && tm_is_include_guard(sc, &condition); + if (guard && + (sc->seen_token || sc->depth || sc->open != TM_OPEN_NONE || sc->cond_count)) { + /* Keep the legacy diagnostic rows, but only certify a guard + * that starts the file at outermost scope. */ + sc->m->incomplete = true; + } + } + if (sc->cond_count < TM_COND_MAX) { + sc->cond[sc->cond_count] = (tm_cond_t){.depth = sc->depth, .guard = guard}; + } else { + sc->m->incomplete = true; + } + sc->cond_count++; + sc->conditions += guard ? 0 : 1; + } else if (tm_tok_is(&word, "elif") || tm_tok_is(&word, "else")) { + /* Every branch starts from the brace depth the #if stood at. */ + if (tm_tok_is(&word, "elif")) { + tm_tok_t expression; + tm_next(&rest, &expression); + if (expression.kind == TM_TOK_EOF) { + sc->m->incomplete = true; + } + } + if (sc->cond_count > 0 && sc->cond_count <= TM_COND_MAX) { + tm_cond_t *condition = &sc->cond[sc->cond_count - 1]; + if (condition->seen_else || condition->guard) { + /* A guard-like conditional with alternatives is not an + * unconditional include guard. Its old rows are diagnostic. */ + sc->m->incomplete = true; + } + condition->seen_else = tm_tok_is(&word, "else"); + sc->depth = condition->depth; + } else { + sc->m->incomplete = true; + } + } else if (tm_tok_is(&word, "endif")) { + if (sc->cond_count > 0) { + sc->cond_count--; + bool guard = sc->cond_count < TM_COND_MAX && sc->cond[sc->cond_count].guard; + sc->conditions -= guard ? 0 : 1; + if (guard && sc->cond_count == 0) { + sc->guard_closed = true; + } + } else { + sc->m->incomplete = true; + } + } else if (tm_tok_is(&word, "define")) { + /* A macro whose body registers a test: each use of it registers one + * the scanner cannot name. The macro's own name is the first word. */ + tm_next(&rest, &word); + for (tm_next(&rest, &word); word.kind != TM_TOK_EOF; tm_next(&rest, &word)) { + if (sc->m->descriptor_mode) { + tm_descriptor_macro_body(sc, &word); + continue; + } + if (tm_tok_is(&word, sc->m->conv.run_macro)) { + sc->macro_registrations = true; + } + if (tm_tok_is(&word, sc->m->conv.case_macro) || + tm_tok_is(&word, sc->m->conv.suite_macro) || + tm_tok_is(&word, sc->m->conv.suite_run_macro) || + tm_tok_is(&word, sc->m->conv.suite_run_perf_macro)) { + sc->m->incomplete = true; /* hidden definitions or runner membership */ + } + } + } +} + +static void tm_close_brace(tm_scan_t *sc, int line) { + if (sc->depth == 0) { + sc->m->incomplete = true; + return; /* keep existing rows, but do not certify a stray closing brace */ + } + sc->depth--; + if (sc->depth > 0 || sc->open == TM_OPEN_NONE) { + return; + } + if (sc->open == TM_OPEN_CASE) { + sc->m->cases[sc->open_index].end_line = line; + } else { + sc->m->suites[sc->open_index].end_line = line; + } + sc->open = TM_OPEN_NONE; +} + +/* A definition at file scope: TEST(name) {, SUITE(name) { or the written-out + * suite function. The opening brace is consumed with it. */ +static void tm_file_scope_ident(tm_scan_t *sc, const tm_tok_t *tok) { + const cbm_test_conventions_t *conv = &sc->m->conv; + tm_lex_t at = sc->lx; + tm_tok_t name; + bool is_case = tm_tok_is(tok, conv->case_macro); + bool is_suite = !is_case && tm_tok_is(tok, conv->suite_macro); + bool macro_definition = is_case || is_suite; + bool written_suite = false; + bool matched = false; + if (macro_definition) { + matched = tm_macro_arg(&sc->lx, &name); + } else if (tm_tok_is(tok, "void")) { + tm_lex_t ahead = sc->lx; + tm_tok_t candidate; + tm_next(&ahead, &candidate); + size_t prefix_len = conv->suite_fn_prefix ? strlen(conv->suite_fn_prefix) : 0; + written_suite = prefix_len && candidate.kind == TM_TOK_IDENT && + candidate.len >= prefix_len && + memcmp(candidate.text, conv->suite_fn_prefix, prefix_len) == 0; + is_suite = tm_suite_function(&sc->lx, conv->suite_fn_prefix, &name); + matched = is_suite; + } + if (!matched) { + if (macro_definition || written_suite) { + sc->m->incomplete = true; + } + sc->lx = at; + return; + } + if (!tm_body_opens(&sc->lx)) { + /* The written-out suite form also appears as a forward declaration. + * Configured definition macros, however, must supply a body. */ + tm_lex_t ahead = sc->lx; + tm_tok_t next; + tm_next(&ahead, &next); + if (macro_definition || !tm_tok_punct(&next, ';')) { + sc->m->incomplete = true; + } + sc->lx = at; + return; + } + if (sc->open != TM_OPEN_NONE) { + sc->m->incomplete = true; /* a conditional branch replaced an unfinished definition */ + } + sc->depth = 1; + if (is_case) { + tm_add_case(sc, &name, tok->line); + } else { + tm_add_suite(sc, &name, tok->line); + } +} + +static void tm_ident(tm_scan_t *sc, const tm_tok_t *tok) { + const cbm_test_conventions_t *conv = &sc->m->conv; + tm_tok_t name; + if (tm_tok_is(tok, "namespace")) { + sc->m->incomplete = true; /* file-scope-only model cannot map namespace identities */ + } + if (sc->m->descriptor_mode) { + if (tm_descriptor_ident(sc, tok)) + return; + if (tm_cbm_active(sc) && tm_tok_is(tok, "void")) { + if (sc->depth == 0) { + tm_file_scope_ident(sc, tok); + } else { + tm_lex_t ahead = sc->lx; + if (tm_suite_function(&ahead, conv->suite_fn_prefix, &name) && + tm_body_opens(&ahead)) { + tm_scan_mapping(sc, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_FORM, -1, tok->line); + } + } + } + return; + } + bool perf = tm_tok_is(tok, conv->suite_run_perf_macro); + if (perf || tm_tok_is(tok, conv->suite_run_macro)) { + if (tm_macro_arg(&sc->lx, &name)) { + tm_add_runner_suite(sc, &name, perf); + } else { + sc->m->incomplete = true; + } + return; + } + if (sc->depth == 0) { + if (tm_tok_is(tok, conv->run_macro)) { + sc->m->incomplete = true; + } + tm_file_scope_ident(sc, tok); + return; + } + if (tm_tok_is(tok, conv->case_macro) || tm_tok_is(tok, conv->suite_macro)) { + sc->m->incomplete = true; /* configured definitions below file scope are unsupported */ + } + if (tm_tok_is(tok, "void")) { + tm_lex_t ahead = sc->lx; + if (tm_suite_function(&ahead, conv->suite_fn_prefix, &name) && tm_body_opens(&ahead)) { + sc->m->incomplete = true; + } + } + if (tm_tok_is(tok, conv->run_macro)) { + if (sc->open == TM_OPEN_SUITE && tm_macro_arg(&sc->lx, &name)) { + tm_add_registration(sc, &name, tok->line); + } else { + sc->m->incomplete = true; + } + } +} + +static bool tm_add_source(cbm_test_model_t *m, const char *file, const char *text, size_t len, + int language_kind) { + if (!m || m->finished || !file || (!text && len > 0)) { + if (m) { + m->incomplete = true; + } + return false; + } + if (!text) { + text = ""; /* (NULL, 0) is empty input, never NULL pointer arithmetic */ + } + if (memchr(text, '\0', len)) { + m->incomplete = true; + } + /* Translation-phase splicing can join identifiers, punctuators, or + * comment delimiters before this lexer sees them. Without preprocessing, + * even an otherwise handled directive/string splice prevents certainty. */ + for (size_t i = 0; i < len; i++) { + if (text[i] == '\\' && len - i >= 2 && + (text[i + 1] == '\n' || (len - i >= 3 && text[i + 1] == '\r' && text[i + 2] == '\n'))) { + m->incomplete = true; + break; + } + } + tm_scan_t sc = {.m = m, .language_kind = language_kind, .ok = true}; + int first_suite = m->suite_count; + sc.file = cbm_arena_strdup(&m->arena, file); + if (!sc.file) { + m->incomplete = true; + return false; + } + if (m->descriptor_mode) { + tm_descriptor_preflight(&sc); + if (!sc.ok) + return false; + if (!sc.language_kind) + return true; /* preserve uncertainty; do not pretend to parse it */ + } + sc.lx = (tm_lex_t){ + .p = text, .end = text + len, .line = 1, .line_start = true, .incomplete = &m->incomplete}; + tm_tok_t tok; + for (tm_next(&sc.lx, &tok); sc.ok && tok.kind != TM_TOK_EOF; tm_next(&sc.lx, &tok)) { + if (sc.guard_closed) { + m->incomplete = true; /* significant input after the supposed file guard */ + } + if (tok.kind == TM_TOK_DIRECTIVE) { + tm_directive(&sc, &tok); + } else if (tok.kind == TM_TOK_IDENT) { + tm_ident(&sc, &tok); + } else if (tok.ch == '{') { + sc.depth++; + } else if (tok.ch == '}') { + tm_close_brace(&sc, tok.line); + } + sc.seen_token = true; + } + if (!sc.ok || sc.depth || sc.open != TM_OPEN_NONE || sc.cond_count) { + m->incomplete = true; + } + if (sc.open == TM_OPEN_CASE) { + m->cases[sc.open_index].end_line = sc.lx.line; /* body open at end of file */ + } else if (sc.open == TM_OPEN_SUITE) { + m->suites[sc.open_index].end_line = sc.lx.line; + } + for (int i = first_suite; sc.macro_registrations && i < m->suite_count; i++) { + m->suites[i].macro_registrations = true; + } + return sc.ok; +} + +bool cbm_test_model_add_source(cbm_test_model_t *m, const char *file, const char *text, + size_t len) { + if (m && m->descriptor_mode) { + (void)tm_mapping_report(m, CBM_TEST_MODEL_MAPPING_INVALID_INPUT, -1, file, 0); + return false; + } + return tm_add_source(m, file, text, len, 0); +} + +bool cbm_test_model_add_source_language(cbm_test_model_t *m, const char *file, const char *language, + const char *text, size_t len) { + if (!m || !m->descriptor_mode || m->finished || !file || !language || !language[0] || + (!text && len > 0)) { + if (m) + (void)tm_mapping_report(m, CBM_TEST_MODEL_MAPPING_INVALID_INPUT, -1, file, 0); + return false; + } + return tm_add_source(m, file, text, len, tm_language_kind(language)); +} + +/* ── Finish: canonical order and links ───────────────────────────── */ + +static int tm_cmp_text(const char *a, const char *b) { + return strcmp(a ? a : "", b ? b : ""); +} + +static int tm_cmp_case(const void *left, const void *right) { + const cbm_test_case_t *a = left; + const cbm_test_case_t *b = right; + int by_file = tm_cmp_text(a->file, b->file); + if (by_file != 0) { + return by_file; + } + return a->start_line != b->start_line ? (a->start_line < b->start_line ? -1 : 1) + : tm_cmp_text(a->name, b->name); +} + +static int tm_cmp_suite(const void *left, const void *right) { + const cbm_test_suite_t *a = left; + const cbm_test_suite_t *b = right; + int by_file = tm_cmp_text(a->file, b->file); + if (by_file != 0) { + return by_file; + } + return a->start_line != b->start_line ? (a->start_line < b->start_line ? -1 : 1) + : tm_cmp_text(a->name, b->name); +} + +static int tm_cmp_registration(const void *left, const void *right) { + const cbm_test_registration_t *a = left; + const cbm_test_registration_t *b = right; + int by_file = tm_cmp_text(a->file, b->file); + if (by_file != 0) { + return by_file; + } + return a->line != b->line ? (a->line < b->line ? -1 : 1) : tm_cmp_text(a->test, b->test); +} + +static int tm_cmp_runner(const void *left, const void *right) { + const cbm_test_runner_suite_t *a = left; + const cbm_test_runner_suite_t *b = right; + return tm_cmp_text(a->name, b->name); +} + +/* (file, name): the key a registration resolves by. */ +static int tm_cmp_case_key(const void *left, const void *right) { + const cbm_test_case_t *const *a = left; + const cbm_test_case_t *const *b = right; + int by_file = tm_cmp_text((*a)->file, (*b)->file); + return by_file != 0 ? by_file : tm_cmp_text((*a)->name, (*b)->name); +} + +static bool tm_link_registrations(cbm_test_model_t *m) { + if (m->reg_count == 0 || m->case_count == 0) { + return true; + } + const cbm_test_case_t **index = + cbm_arena_alloc(&m->arena, (size_t)m->case_count * sizeof(*index)); + if (!index) { + return false; + } + for (int i = 0; i < m->case_count; i++) { + index[i] = &m->cases[i]; + } + qsort(index, (size_t)m->case_count, sizeof(*index), tm_cmp_case_key); + for (int i = 0; i < m->reg_count; i++) { + cbm_test_case_t key = {.file = m->regs[i].file, .name = m->regs[i].test}; + const cbm_test_case_t *key_ptr = &key; + m->regs[i].resolved = bsearch(&key_ptr, index, (size_t)m->case_count, sizeof(*index), + tm_cmp_case_key) != NULL; + } + return true; +} + +bool cbm_test_model_finish(cbm_test_model_t *m) { + if (!m || m->finished) { + return false; + } + if (m->case_count > 1) { + qsort(m->cases, (size_t)m->case_count, sizeof(*m->cases), tm_cmp_case); + } + if (m->suite_count > 1) { + qsort(m->suites, (size_t)m->suite_count, sizeof(*m->suites), tm_cmp_suite); + } + if (m->reg_count > 1) { + qsort(m->regs, (size_t)m->reg_count, sizeof(*m->regs), tm_cmp_registration); + } + if (m->runner_count > 1) { + qsort(m->runner, (size_t)m->runner_count, sizeof(*m->runner), tm_cmp_runner); + /* One entry per suite; it is a perf suite when any site says so. */ + int kept = 0; + for (int i = 1; i < m->runner_count; i++) { + if (strcmp(m->runner[kept].name, m->runner[i].name) == 0) { + m->runner[kept].perf = m->runner[kept].perf || m->runner[i].perf; + } else { + m->runner[++kept] = m->runner[i]; + } + } + m->runner_count = kept + 1; + } + if (!tm_link_registrations(m)) { + m->incomplete = true; + return false; + } + m->finished = true; + return true; +} + +/* ── Accessors ───────────────────────────────────────────────────── */ + +bool cbm_test_model_complete(const cbm_test_model_t *m) { + return m && m->finished && !m->incomplete; +} + +const cbm_test_case_t *cbm_test_model_cases(const cbm_test_model_t *m, int *count) { + bool ready = m && m->finished; + if (count) { + *count = ready ? m->case_count : 0; + } + return ready ? m->cases : NULL; +} + +const cbm_test_suite_t *cbm_test_model_suites(const cbm_test_model_t *m, int *count) { + bool ready = m && m->finished; + if (count) { + *count = ready ? m->suite_count : 0; + } + return ready ? m->suites : NULL; +} + +const cbm_test_registration_t *cbm_test_model_registrations(const cbm_test_model_t *m, int *count) { + bool ready = m && m->finished; + if (count) { + *count = ready ? m->reg_count : 0; + } + return ready ? m->regs : NULL; +} + +const cbm_test_runner_suite_t *cbm_test_model_runner_suites(const cbm_test_model_t *m, int *count) { + bool ready = m && m->finished; + if (count) { + *count = ready ? m->runner_count : 0; + } + return ready ? m->runner : NULL; +} diff --git a/src/mcp/test_impact.h b/src/mcp/test_impact.h new file mode 100644 index 000000000..87fd6983e --- /dev/null +++ b/src/mcp/test_impact.h @@ -0,0 +1,553 @@ +/* + * test_impact.h — which tests does a change reach? (detect_changes scope:"tests") + * + * Part 1, the test model: what a repository's test sources define and what + * its runner registers, read from the source text by convention. + * + * Suite membership comes from the registration itself — the name inside + * RUN_TEST(...) and the TEST of that name in the same file — and never from a + * resolver edge. An edge can be missing or point at a same-named symbol + * elsewhere; the registration is what the compiler sees, and a selection that + * names a test its suite never runs fails the run (tests/test_main.c). + */ +#ifndef CBM_MCP_TEST_IMPACT_H +#define CBM_MCP_TEST_IMPACT_H + +#include "discover/test_conventions.h" + +#include +#include +#include + +/* The spellings a test framework uses. A NULL entry is a convention the + * framework does not have. */ +typedef struct { + const char *case_macro; /* TEST(name) { ... } */ + const char *suite_macro; /* SUITE(name) { ... } */ + const char *suite_fn_prefix; /* void suite_name(void) { ... }: what SUITE expands to */ + const char *run_macro; /* RUN_TEST(name) inside a suite body */ + const char *suite_run_macro; /* RUN_SELECTED_SUITE(name) in the runner */ + const char *suite_run_perf_macro; /* RUN_SELECTED_SUITE_PERF(name): a perf suite */ +} cbm_test_conventions_t; + +/* This repository's own C test framework (tests/test_framework.h). */ +const cbm_test_conventions_t *cbm_test_conventions_cbm(void); + +typedef struct { + const char *file; /* as given to cbm_test_model_add_source */ + const char *name; + int start_line; /* the line of TEST(name) */ + int end_line; /* the line of the closing brace of its body */ + /* Defined under a preprocessor condition (an include guard is not one): + * some builds do not have this test. */ + bool conditional; +} cbm_test_case_t; + +typedef struct { + const char *file; + const char *name; + int start_line; + int end_line; + /* The file defines a macro that registers tests. The suite may then run + * tests the model cannot name, so it can only be selected whole. */ + bool macro_registrations; +} cbm_test_suite_t; + +typedef struct { + const char *file; + const char *suite; + const char *test; + int line; + bool conditional; /* registered under a preprocessor condition */ + bool resolved; /* a case of that name is defined in the same file */ +} cbm_test_registration_t; + +typedef struct { + const char *name; + bool perf; +} cbm_test_runner_suite_t; + +typedef struct cbm_test_model cbm_test_model_t; + +/* NULL when out of memory. The conventions are copied. */ +cbm_test_model_t *cbm_test_model_new(const cbm_test_conventions_t *conventions); + +/* Read one source file. `text` need not be NUL-terminated; (NULL, 0) is empty. + * false = invalid input/state or out of memory. A successful read can still + * leave parse uncertainty; it does not establish completeness. */ +bool cbm_test_model_add_source(cbm_test_model_t *m, const char *file, const char *text, size_t len); + +/* Order and link what was read. Call once, after the last file and before any + * row accessors. Mapping and declaration accessors also work before finish. + * false = out of memory. */ +bool cbm_test_model_finish(cbm_test_model_t *m); + +void cbm_test_model_free(cbm_test_model_t *m); + +/* True only after finish and no sticky parse/lexical/unsupported-form + * uncertainty in any added source. Rows remain available for diagnostics + * when false, but cannot justify narrowing. This concerns the supplied + * source text only: callers must separately establish that the external + * file/runner inventory is complete and conventions are applicable. */ +bool cbm_test_model_complete(const cbm_test_model_t *m); + +/* Descriptor mode is independent of the legacy six-spelling constructor. + * Copies every original record, ordered name_args, optional template and + * preset switch. NULL means invalid input or allocation failure. Unsupported + * mapping produces an owned diagnostic and prevents complete() from being true. + * Exact supported explicit case template: "{suite}:{name}". Missing templates + * do not acquire a default. Multi-component names are retained but unsupported. + * c-cbm supplies C declarations plus the existing suite_ function form. */ +typedef enum { + CBM_TEST_MODEL_MAPPING_OK = 0, + CBM_TEST_MODEL_MAPPING_INVALID_INPUT, + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_LANGUAGE, + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_PRESET, + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_NAME, + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_TEMPLATE, + CBM_TEST_MODEL_MAPPING_AMBIGUOUS, + CBM_TEST_MODEL_MAPPING_MISSING_ARGUMENT, + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_ARGUMENT, + CBM_TEST_MODEL_MAPPING_MALFORMED_INVOCATION, + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_FORM +} cbm_test_model_mapping_status_t; + +typedef struct { + cbm_test_model_mapping_status_t reason; + int declaration_index; /* original zero-based record index; -1 for builtin/input */ + const char *file; /* borrowed model-owned copy, or NULL for config issues */ + int line; /* 1-based source line, or 0 for config/input issues */ +} cbm_test_model_mapping_issue_t; + +cbm_test_model_t *cbm_test_model_new_declarations(const cbm_test_declarations_t *declarations); +/* Descriptor mode requires explicit language. Accepted canonical config names: + * c/cpp/cuda; also accepts the discover API's C/C++/CUDA display names, and c++. + * This adapter does not handle native presets. Applicable gtest in C++/CUDA + * prevents narrowing; unrelated default native presets do not poison C. + * false means invalid state/input or allocation failure. Mapping uncertainty + * may return true: callers MUST require model_complete() before narrowing. */ +bool cbm_test_model_add_source_language(cbm_test_model_t *model, const char *file, + const char *language, const char *text, size_t len); +/* Canonically smallest issue by (file, line, declaration_index, reason), not + * discovery order. Strings and original declaration rows live until model free. + * NULL model yields INVALID_INPUT and initializes issue when supplied. */ +cbm_test_model_mapping_status_t cbm_test_model_mapping_status( + const cbm_test_model_t *model, cbm_test_model_mapping_issue_t *issue); +const cbm_test_declaration_t *cbm_test_model_declarations(const cbm_test_model_t *model, + int *count); + +/* Cases, suites and registrations are ordered by (file, line); runner suites by + * name, each once. The order never depends on the order the files were added + * in. */ +const cbm_test_case_t *cbm_test_model_cases(const cbm_test_model_t *m, int *count); +const cbm_test_suite_t *cbm_test_model_suites(const cbm_test_model_t *m, int *count); +const cbm_test_registration_t *cbm_test_model_registrations(const cbm_test_model_t *m, int *count); +const cbm_test_runner_suite_t *cbm_test_model_runner_suites(const cbm_test_model_t *m, int *count); + +/* ── Part 2: the change ────────────────────────────────────────────── + * A unified diff with zero context lines, as git writes it under the pinned + * flags below, read into files and hunks. The reader takes text, not a + * repository, so the selection is a function of the diff bytes. */ + +/* The flags that make `git diff` output independent of the user's git + * settings: no external diff or textconv driver, no renames, no color, one + * algorithm, fixed prefixes, zero context. Spliced in as + * `git diff A B`. */ +#define CBM_TEST_IMPACT_GIT_PINS \ + "-c core.quotePath=false -c color.ui=never -c diff.noprefix=false " \ + "-c diff.mnemonicPrefix=false" +#define CBM_TEST_IMPACT_DIFF_FLAGS \ + "--no-ext-diff --no-textconv --no-color --no-renames --diff-algorithm=myers " \ + "--unified=0 --inter-hunk-context=0 --src-prefix=a/ --dst-prefix=b/" + +typedef struct { + /* Lines on the new side: [start, start + count). A hunk that only removes + * lines has count 0, and start is then the line the removal follows. */ + int start; + int count; + const char *const *added; /* the new lines, without the leading '+' */ + int added_count; + const char *const *removed; /* the old lines, without the leading '-' */ + int removed_count; +} cbm_diff_hunk_t; + +typedef struct { + const char *path; + bool created; /* the file did not exist before */ + bool deleted; /* the file does not exist any more */ + bool binary; /* git printed no text for it: there are no hunks to read */ + const cbm_diff_hunk_t *hunks; + int hunk_count; +} cbm_diff_file_t; + +typedef struct cbm_diff cbm_diff_t; + +/* NULL when out of memory. A diff the reader does not fully understand still + * parses; cbm_diff_complete then says so. */ +cbm_diff_t *cbm_diff_parse(const char *text, size_t len); +void cbm_diff_free(cbm_diff_t *d); + +/* Files ordered by path. */ +const cbm_diff_file_t *cbm_diff_files(const cbm_diff_t *d, int *count); + +/* false = something in the text was not understood (a quoted path, a hunk + * shorter than its header says, a line outside any file). What was read is + * then not the whole change, and nothing may be narrowed by it. */ +bool cbm_diff_complete(const cbm_diff_t *d); + +/* Coverage-map format 1 from scripts/test-impact/coverage-map.py. These are + * internal data accessors, not an admission decision: callers must separately + * verify metadata, provenance, ancestry, age and harness/build compatibility. + * Missing tests and incomplete test/setup rows cannot justify narrowing. */ +typedef struct { + int id; + const char *file; /* empty when only an external profile name was available */ + const char *name; +} cbm_coverage_function_t; + +typedef struct { + const char *suite; + const char *name; /* "*" is between-test setup for the entire suite */ + bool complete; + const char *reason; + const int *function_ids; /* ascending, unique; IDs index the function table */ + int function_count; +} cbm_coverage_test_t; + +typedef struct cbm_coverage_map cbm_coverage_map_t; + +/* Explicit single-image profile identity format; no admission is implied. */ +typedef enum { + CBM_COVERAGE_FORMAT_NONE = 0, + CBM_COVERAGE_FORMAT_FUNCTIONS = 1, + CBM_COVERAGE_FORMAT_PROFILES = 2 +} cbm_coverage_format_t; + +typedef struct { + char image_sha256[65]; /* lowercase SHA-256 of the one immutable image */ +} cbm_coverage_profile_binding_t; + +typedef struct { + int id; /* dense 0-based; row index */ + const unsigned char *name; /* owned bytes; NOT a C-string contract */ + size_t name_length; /* positive; embedded NUL is representable */ + uint64_t function_hash; /* all 64 bits, without masking */ + uint64_t counter_count; /* positive shape; no counter values stored */ +} cbm_coverage_profile_t; + +typedef struct { + uint64_t max_input_bytes; /* profiles_len + tests_len; <= UINT64_MAX/8 */ + uint64_t max_items; /* exactly 1 header + profile rows + test rows + ID references */ + size_t max_alloc_bytes; /* cumulative logical allocation requests, including scratch */ + int max_ids; /* positive, <= INT_MAX; dense universe cardinality */ +} cbm_coverage_parse_limits_t; + +typedef bool (*cbm_coverage_parse_cancel_fn)(void *context); + +typedef enum { + CBM_COVERAGE_PARSE_OK = 0, + CBM_COVERAGE_PARSE_INVALID, /* bad pointers/limits, invalid C call */ + CBM_COVERAGE_PARSE_FORMAT, /* malformed/noncanonical identities or invalid test rows */ + CBM_COVERAGE_PARSE_UNSUPPORTED, /* well-formed recognized header, wire version != 2 */ + CBM_COVERAGE_PARSE_LIMIT, /* byte/item/ID/logical allocation/representation limit */ + CBM_COVERAGE_PARSE_CANCELLED, + CBM_COVERAGE_PARSE_OOM +} cbm_coverage_parse_status_t; + +/* profiles.tsv strict header/rows are specified in the companion proposal. + * Clear *out before all other validation. profiles must contain its header; + * tests=NULL is allowed only with tests_len=0. No NUL terminators required. + * limits and out are required; every limit is positive. Callback may be NULL. + * Inputs/limits/context remain immutable for this synchronous call; callback + * is prompt, non-reentrant and cannot mutate/free inputs or call this owner. + * Poll before parsing, within <=65536 bytes or <=4096 visited elements, + * including hash/copy/comparison/sort work, and before publishing success. + * Every non-OK result leaves *out=NULL and frees all call-owned allocations. + * OK owns all data in CBMArena storage and permits unobserved IDs explicitly. + * OK never establishes image/universe/row completeness or artifact admission. + * Use existing cbm_coverage_map_free; no shared mutable or cancellation state. */ +cbm_coverage_parse_status_t cbm_coverage_map_parse_v2( + const void *profiles, size_t profiles_len, + const char *tests, size_t tests_len, + const cbm_coverage_parse_limits_t *limits, + cbm_coverage_parse_cancel_fn cancelled, void *cancel_context, + cbm_coverage_map_t **out); + +/* O(1) borrowed views until map_free. NULL map -> NONE / 0 / NULL. + * Generic count and exact digest work for formats 1 and 2. No reserialization: + * v1 digest = original functions.tsv; v2 digest = entire original profiles.tsv, + * including binding header. SHA-256 is a lowercase 64-hex C string. */ +cbm_coverage_format_t cbm_coverage_map_format(const cbm_coverage_map_t *map); +int cbm_coverage_map_id_count(const cbm_coverage_map_t *map); +const char *cbm_coverage_map_identity_sha256(const cbm_coverage_map_t *map); + +/* Format 2 only; other formats/NULL -> NULL and, for profiles, count=0. + * count may be NULL. For a zero-ID v2 map profiles returns NULL/count=0, + * but binding remains available. Records remain in dense ID order. */ +const cbm_coverage_profile_binding_t *cbm_coverage_map_profile_binding( + const cbm_coverage_map_t *map); +const cbm_coverage_profile_t *cbm_coverage_map_profiles( + const cbm_coverage_map_t *map, int *count); + +/* Existing test/find_test/intersection/free APIs retain their row semantics. + * On v2, old functions() returns NULL/count=0, find_function() returns NULL, + * functions_sha256() returns NULL, metadata_matches() returns false, and + * check_receipt() returns INVALID|METADATA before interpreting v1 evidence. + * All format-1 behavior, uniqueness and observed-ID guards stay unchanged. */ + + +/* Explicit byte lengths; inputs need not be NUL-terminated. LF and CRLF are + * accepted. NULL means invalid/incomplete syntax, inconsistent references, or + * allocation failure; no partial map is returned. Does not read meta.json. */ +cbm_coverage_map_t *cbm_coverage_map_parse(const char *functions, size_t functions_len, + const char *tests, size_t tests_len); +void cbm_coverage_map_free(cbm_coverage_map_t *map); +/* Exact original functions.tsv byte digest; borrowed until map_free. */ +const char *cbm_coverage_map_functions_sha256(const cbm_coverage_map_t *map); +const cbm_coverage_function_t *cbm_coverage_map_functions(const cbm_coverage_map_t *map, + int *count); +/* Rows are ordered by (suite, name), independent of input order. */ +const cbm_coverage_test_t *cbm_coverage_map_tests(const cbm_coverage_map_t *map, int *count); +const cbm_coverage_function_t *cbm_coverage_map_find_function(const cbm_coverage_map_t *map, + const char *file, const char *name); +const cbm_coverage_test_t *cbm_coverage_map_find_test(const cbm_coverage_map_t *map, + const char *suite, const char *name); +/* Coverage intersection only. false does not establish that a test is safe + * to omit: static reachability, incomplete/setup rows and other rules still apply. */ +bool cbm_coverage_test_intersects(const cbm_coverage_test_t *test, const int *changed_ids, + int changed_count); + +/* Cross-check format-1 metadata against every parsed row and the trusted + * artifact manifest's commit. Does not establish age, ancestry, harness/build + * compatibility, provenance or expected runner membership; all remain required. + * Valid incomplete rows stay incomplete and cannot justify narrowing. */ +bool cbm_coverage_map_metadata_matches(const cbm_coverage_map_t *map, const char *metadata, + size_t len, const char *expected_commit); + +/* Query-local project configuration. No process environment or global config + * participates. Only the fields consumed by this API are validated here; + * the selection engine must validate its additional rules before narrowing. */ +typedef struct cbm_test_config cbm_test_config_t; + +/* Read one regular JSON file, at most 64 KiB. optional permits an absent + * default file only; malformed, unreadable and oversized files always fail. + * NULL means invalid configuration or allocation failure. */ +cbm_test_config_t *cbm_test_config_load(const char *path, bool optional); +void cbm_test_config_free(cbm_test_config_t *config); +bool cbm_test_config_is_test_file(const cbm_test_config_t *config, const char *file); +/* Exact source-byte digest (or a domain-separated absent-file digest). */ +const char *cbm_test_config_digest(const cbm_test_config_t *config); + +/* Pure hybrid-selection kernel. It performs no I/O and does not admit an + * artifact. The adapter must establish the evidence flags before calling it. + * A false completeness flag is uncertainty, never proof of no changes. */ +typedef struct { + const char *file; + const char *test; + /* An unambiguous case mapping in the complete static traversal. Merely + * finding a graph node is insufficient. Missing rows mean unknown. */ + bool mapped; + bool reached; + bool changed; +} cbm_test_reach_t; + +enum { + CBM_TEST_SELECT_CHANGED = 1U << 0, + CBM_TEST_SELECT_STATIC = 1U << 1, + CBM_TEST_SELECT_COVERAGE = 1U << 2, + CBM_TEST_SELECT_UNMAPPED = 1U << 3, + CBM_TEST_SELECT_COVERAGE_UNKNOWN = 1U << 4, + CBM_TEST_SELECT_SETUP_HIT = 1U << 5, + CBM_TEST_SELECT_SETUP_UNKNOWN = 1U << 6, + CBM_TEST_SELECT_INVENTORY_UNKNOWN = 1U << 7, + CBM_TEST_SELECT_CONDITIONAL = 1U << 8, + CBM_TEST_SELECT_ARTIFACT_REJECTED = 1U << 9, + CBM_TEST_SELECT_DIFF_INCOMPLETE = 1U << 10, + CBM_TEST_SELECT_STATIC_INCOMPLETE = 1U << 11, + CBM_TEST_SELECT_CHANGE_IDENTITY_UNKNOWN = 1U << 12, + CBM_TEST_SELECT_INVALID_INPUT = 1U << 13, + CBM_TEST_SELECT_CHANGED_UNREGISTERED = 1U << 14, + CBM_TEST_SELECT_RULE = 1U << 15 +}; + +/* Reached/changed suite bodies and explicit suite rules select the whole + * suite even when no individual case is reached. Names come from the runner. */ +typedef struct { + const char *suite; + bool reached; + bool changed; + bool rule; +} cbm_test_suite_trigger_t; + +typedef struct { + const cbm_test_model_t *model; /* finished, borrowed for this call only */ + const cbm_coverage_map_t *coverage; + const cbm_test_reach_t *reach; + int reach_count; + const cbm_test_suite_trigger_t *suite_triggers; + int suite_trigger_count; + const int *changed_function_ids; + int changed_function_count; + bool has_changes; + bool diff_complete; + bool inventory_complete; /* external file/runner inventory, separate from model parse */ + bool static_complete; /* complete seeds and uncapped no-lift traversal */ + bool coverage_admitted; /* independently verified artifact admission */ + bool coverage_changes_complete; /* includes old-side/deleted identities */ +} cbm_test_selection_input_t; + +typedef struct { + const char *name; + bool whole; + unsigned reasons; /* union of whole-suite causes and retained-test reasons */ +} cbm_test_selected_suite_t; + +typedef struct { + const char *suite; + const char *test; + const char *file; + unsigned reasons; +} cbm_test_selected_case_t; + +typedef struct cbm_test_selection cbm_test_selection_t; + +/* NULL means allocation failure: callers MUST use their full-run fallback. + * No successful partial result is returned. Results own all their strings. + * A run-all result has no suite/case rows; its reason mask is nonzero. + * Whole suites dominate individual rows. Selected conditional registrations + * use whole suites until the runner has an explicit optional-test contract. */ +cbm_test_selection_t *cbm_test_select(const cbm_test_selection_input_t *input); +void cbm_test_selection_free(cbm_test_selection_t *selection); +unsigned cbm_test_selection_run_all(const cbm_test_selection_t *selection); +/* Only selected suites/cases appear, ordered by name and (suite,test). */ +const cbm_test_selected_suite_t *cbm_test_selection_suites(const cbm_test_selection_t *selection, + int *count); +const cbm_test_selected_case_t *cbm_test_selection_cases(const cbm_test_selection_t *selection, + int *count); + +/* Coverage receipt-policy checks. This is NOT artifact retrieval or proof of + * provenance: the adapter must independently establish the context below. + * SHA-256 strings use 64 lowercase hex digits and bind exact file bytes. */ +typedef struct { + const char *commit; + const char *functions_sha256; + const char *tests_sha256; + const char *metadata_sha256; + const char *graph_sha256; + const char *compatibility_sha256; + /* Oldest retained test/setup observation, including complete empty rows. + * A full-refresh baseline is valid only if all retained rows were refreshed. + * Incremental publication MUST NOT advance this timestamp by itself. */ + int64_t oldest_observation_at; +} cbm_coverage_receipt_t; + +typedef struct { + /* Authenticates the entire receipt tuple and expected producer/repository, + * not merely a download location. Never populated from artifact JSON. */ + bool source_verified; + const char *trusted_commit; + /* Evidence is for this exact pair, in this one frozen comparison. Git + * errors/unknown ancestry are false; replacement objects must be disabled. */ + bool ancestor_verified; + const char *ancestor_commit; + const char *ancestor_merge_base; + const char *merge_base; + /* Independently measured from the exact team graph imported for this map, + * before the adapter incrementally updates it to the PR head. */ + const char *graph_commit; + const char *graph_sha256; + /* Independently computed with the same versioned input encoding as the + * producer, covering harness, runner, spawn helpers, config, build flags, + * platform and toolchain. Equality cannot establish those inputs itself. */ + const char *compatibility_sha256; + int64_t now; +} cbm_coverage_receipt_context_t; + +enum { + CBM_COVERAGE_RECEIPT_INVALID = 1U << 0, + CBM_COVERAGE_RECEIPT_SOURCE = 1U << 1, + CBM_COVERAGE_RECEIPT_ANCESTRY = 1U << 2, + CBM_COVERAGE_RECEIPT_COMMIT = 1U << 3, + CBM_COVERAGE_RECEIPT_CONTENT = 1U << 4, + CBM_COVERAGE_RECEIPT_AGE = 1U << 5, + CBM_COVERAGE_RECEIPT_COMPATIBILITY = 1U << 6, + CBM_COVERAGE_RECEIPT_METADATA = 1U << 7 +}; + +/* Zero means every receipt-policy check passed against the supplied evidence. + * Newest eligible ancestor discovery, authenticated receipt delivery, actual + * ancestry checks, graph freshness at head and runner inventory stay with the + * adapter. A nonzero result prohibits coverage-based omission. */ +unsigned cbm_coverage_map_check_receipt(const cbm_coverage_map_t *map, const char *metadata, + size_t metadata_len, const cbm_coverage_receipt_t *receipt, + const cbm_coverage_receipt_context_t *context); + +/* Exact bytes retained by the query-local loader; borrowed until config free. + * An optional absent file is distinct from an invalid config (NULL). */ +const char *cbm_test_config_source(const cbm_test_config_t *config, size_t *len, bool *absent); + +typedef enum { + CBM_TEST_RULE_RUN_ALL, + CBM_TEST_RULE_LANES, + CBM_TEST_RULE_SUITES, + CBM_TEST_RULE_REFERENCING_TESTS, + CBM_TEST_RULE_IGNORE +} cbm_test_rule_action_t; + +typedef struct { + const char *id; + cbm_test_rule_action_t action; + const char *const *paths; + int path_count; + const char *const *suites; + int suite_count; + const char *const *lanes; + int lane_count; + int key_segment; /* zero-based path_segment:N; -1 for other actions */ + bool builtin; +} cbm_test_rule_t; + +typedef struct { + const char *name; + const char *const *suites; + int suite_count; + const char *const *exclude_suites; + int exclude_count; + const char *const *signals; + int signal_count; + bool default_run; + bool narrow; /* omitted => false; never changes lane activation itself */ +} cbm_test_lane_t; + +typedef struct cbm_test_policy cbm_test_policy_t; + +/* Parse rules/lanes from the exact loaded source into an independent owner. + * NULL means invalid consumed policy or allocation/expansion failure; no + * partial policy escapes. Presets/conventions/signals definitions are left to + * their adapters. Existing seed-only config loading does not call this API. + * Missing policy uses conservative built-ins and one unit lane (narrow=true, + * all suites except @perf). Project rules precede built-ins, FIRST MATCH wins. + * Pattern grammar: positive gitignore-style globs plus comma brace alternatives + * (including nested/adjacent groups); no shell evaluation, ranges or regex. + * Expansion is bounded; overflow rejects the whole policy. */ +cbm_test_policy_t *cbm_test_policy_new(const cbm_test_config_t *config); +void cbm_test_policy_free(cbm_test_policy_t *policy); +const cbm_test_rule_t *cbm_test_policy_rules(const cbm_test_policy_t *policy, int *count); +const cbm_test_lane_t *cbm_test_policy_lanes(const cbm_test_policy_t *policy, int *count); +const char *cbm_test_policy_digest(const cbm_test_policy_t *policy); + +/* Successful classification returns true and either the first matched rule + * or NULL for no match. false is an evaluation error, NEVER permission to + * fall through or omit. Target applicability is resolved separately: an + * unavailable target does not permit consulting a later rule. */ +bool cbm_test_policy_match_path(const cbm_test_policy_t *policy, const char *path, + const cbm_test_rule_t **rule); +/* Match one known suite against lane membership or a suites rule. @perf is a + * semantic selector. These functions do not establish inventory completeness, + * platform applicability, or whether a lane is activated. false means error. */ +bool cbm_test_policy_lane_selects_suite(const cbm_test_policy_t *policy, int lane, + const char *suite, bool perf, bool *selected); +bool cbm_test_policy_rule_selects_suite(const cbm_test_policy_t *policy, int rule, + const char *suite, bool perf, bool *selected); + +#endif /* CBM_MCP_TEST_IMPACT_H */ diff --git a/src/mcp/test_impact_changes.c b/src/mcp/test_impact_changes.c new file mode 100644 index 000000000..81a86a7e7 --- /dev/null +++ b/src/mcp/test_impact_changes.c @@ -0,0 +1,519 @@ +#include "mcp/test_impact_changes.h" + +#include "foundation/arena.h" + +#include +#include +#include + +struct cbm_changes { + CBMArena arena; + unsigned char *name_status; + size_t name_status_length; + unsigned char *patch; + size_t patch_length; + cbm_diff_t *diff; + cbm_change_file_t *files; + size_t count; + cbm_changes_state_t state; + unsigned issues; + bool reconciled; + bool can_narrow; +}; + +typedef struct { + const cbm_diff_file_t *file; + size_t count; +} changes_match_t; + +typedef struct { + const unsigned char *bytes; + size_t length; +} changes_slice_t; + +/* Git tree paths use '/' on every host; backslash/colon are literal bytes. */ +static bool changes_path_valid(const unsigned char *path, size_t length) { + if (!length || path[0] == '/' || path[length - 1] == '/') { + return false; + } + size_t start = 0; + for (size_t i = 0; i <= length; i++) { + if (i < length && path[i] != '/') { + if (path[i] == 0) { + return false; + } + continue; + } + size_t component = i - start; + if (!component || (component == 1 && path[start] == '.') || + (component == 2 && path[start] == '.' && path[start + 1] == '.')) { + return false; + } + start = i + 1; + } + return true; +} + +static bool changes_record(const unsigned char *bytes, size_t length, size_t *offset, + char *status, changes_slice_t *path) { + size_t at = *offset; + if (length - at < 4 || bytes[at + 1] != 0 || + (bytes[at] != 'A' && bytes[at] != 'M' && bytes[at] != 'D' && bytes[at] != 'T')) { + return false; + } + const unsigned char *first = bytes + at + 2; + const unsigned char *end = memchr(first, 0, length - at - 2); + if (!end) { + return false; + } + path->bytes = first; + path->length = (size_t)(end - first); + if (!changes_path_valid(path->bytes, path->length)) { + return false; + } + *status = (char)bytes[at]; + *offset = (size_t)(end - bytes) + 1; + return true; +} + +static int changes_path_compare(const unsigned char *a, size_t a_length, + const unsigned char *b, size_t b_length) { + size_t common = a_length < b_length ? a_length : b_length; + int order = memcmp(a, b, common); /* specified unsigned-byte comparison */ + return order ? order : (a_length > b_length) - (a_length < b_length); +} + +/* Stable merge order, including equal keys before duplicate rejection. */ +static void changes_sort(cbm_change_file_t *files, cbm_change_file_t *scratch, size_t count) { + cbm_change_file_t *source = files; + cbm_change_file_t *target = scratch; + for (size_t width = 1; width < count;) { + for (size_t left = 0; left < count;) { + size_t middle = left + (width < count - left ? width : count - left); + size_t right = middle + (width < count - middle ? width : count - middle); + size_t a = left; + size_t b = middle; + for (size_t i = left; i < right; i++) { + if (b == right || (a < middle && + changes_path_compare(source[a].path, source[a].path_length, + source[b].path, source[b].path_length) <= 0)) { + target[i] = source[a++]; + } else { + target[i] = source[b++]; + } + } + left = right; + } + cbm_change_file_t *swap = source; + source = target; + target = swap; + if (width > count / 2) { + break; + } + width *= 2; + } + if (source != files) { + memcpy(files, source, count * sizeof(*files)); + } +} + +static size_t changes_find(const cbm_changes_t *changes, const unsigned char *path, + size_t length) { + size_t low = 0; + size_t high = changes->count; + while (low < high) { + size_t middle = low + (high - low) / 2; + const cbm_change_file_t *file = &changes->files[middle]; + int order = changes_path_compare(file->path, file->path_length, path, length); + if (!order) { + return middle; + } + if (order < 0) { + low = middle + 1; + } else { + high = middle; + } + } + return SIZE_MAX; +} + +static bool changes_starts(changes_slice_t line, const char *prefix) { + size_t length = strlen(prefix); + return line.length >= length && memcmp(line.bytes, prefix, length) == 0; +} + +static bool changes_line(const unsigned char *bytes, size_t length, size_t *offset, + changes_slice_t *line) { + if (*offset == length) { + return false; + } + const unsigned char *first = bytes + *offset; + const unsigned char *end = memchr(first, '\n', length - *offset); + line->bytes = first; + line->length = end ? (size_t)(end - first) : length - *offset; + *offset += line->length + (end ? 1 : 0); + return true; +} + +static bool changes_header_path(changes_slice_t line, changes_slice_t *path) { + static const char prefix[] = "diff --git a/"; + const size_t prefix_length = sizeof(prefix) - 1; + if (!changes_starts(line, prefix)) { + return false; + } + size_t rest = line.length - prefix_length; + if (rest < 5 || (rest - 3) % 2) { + return false; + } + size_t length = (rest - 3) / 2; + const unsigned char *first = line.bytes + prefix_length; + if (memcmp(first + length, " b/", 3) || memcmp(first, first + length + 3, length) || + !changes_path_valid(first, length)) { + return false; + } + for (size_t i = 0; i < length; i++) { + /* Git quotes these bytes even with core.quotePath=false. The existing + * reader does not decode that representation. Non-UTF8 remains raw. */ + if (first[i] < 32 || first[i] == 127 || first[i] == '"' || first[i] == '\\') { + return false; + } + } + *path = (changes_slice_t){first, length}; + return true; +} + +static bool changes_mode(changes_slice_t line, const char *prefix) { + size_t length = strlen(prefix); + if (line.length != length + 6) { + return false; + } + const unsigned char *mode = line.bytes + length; + return memcmp(mode, "100644", 6) == 0 || memcmp(mode, "100755", 6) == 0 || + memcmp(mode, "120000", 6) == 0 || memcmp(mode, "160000", 6) == 0; +} + +static bool changes_side_path(changes_slice_t line, const cbm_change_file_t *file, bool old) { + const unsigned char *bytes = line.bytes + 4; /* checked ---/+++ prefix */ + size_t length = line.length - 4; + bool absent = old ? file->status == 'A' : file->status == 'D'; + if (absent) { + return length == 9 && memcmp(bytes, "/dev/null", 9) == 0; + } + if (length < 2 || bytes[0] != (old ? 'a' : 'b') || bytes[1] != '/') { + return false; + } + bytes += 2; + length -= 2; + /* Accept an exact trailing TAB delimiter after the known path, never + * timestamps, path truncation or another spelling. */ + if (length > file->path_length && length - file->path_length == 1 && + bytes[length - 1] == '\t') { + length--; + } + return length == file->path_length && memcmp(bytes, file->path, length) == 0; +} + +/* The legacy reader discards the old start. Keep its old-side range from + * certifying an impossible nonempty range beginning at zero. */ +static bool changes_old_range_usable(changes_slice_t line) { + if (!changes_starts(line, "@@ -")) { + return false; + } + size_t offset = 4; + int values[2] = {0, 1}; + for (int part = 0; part < 2; part++) { + size_t first = offset; + int value = 0; + while (offset < line.length && line.bytes[offset] >= '0' && line.bytes[offset] <= '9') { + int digit = line.bytes[offset++] - '0'; + if (value > (INT_MAX - digit) / 10) { + return false; + } + value = value * 10 + digit; + } + if (offset == first) { + return false; + } + values[part] = value; + if (part == 0 && offset < line.length && line.bytes[offset] == ',') { + offset++; + continue; + } + break; + } + return offset < line.length && line.bytes[offset] == ' ' && + (values[1] == 0 || values[0] > 0); +} + +/* The legacy file result does not retain old/new mode metadata or the old + * hunk start. Audit headers, never payload (removed text can look like ---). */ +static void changes_audit_headers(cbm_changes_t *changes) { + size_t offset = 0; + size_t current = SIZE_MAX; + bool in_hunks = false; + bool old_seen = false; + bool new_seen = false; + changes_slice_t line; + while (changes_line(changes->patch, changes->patch_length, &offset, &line)) { + if (changes_starts(line, "diff ")) { + changes_slice_t path; + current = SIZE_MAX; + in_hunks = false; + old_seen = false; + new_seen = false; + if (!changes_header_path(line, &path)) { + changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; + } else { + current = changes_find(changes, path.bytes, path.length); + if (current == SIZE_MAX) { + changes->issues |= CBM_CHANGES_PATCH_EXTRA_PATH; + } + } + continue; + } + if (changes_starts(line, "@@ ")) { + if (!changes_old_range_usable(line)) { + changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; + } + if (current != SIZE_MAX && (!old_seen || !new_seen)) { + changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; + } + in_hunks = true; + } + if (current == SIZE_MAX || in_hunks) { + continue; + } + cbm_change_file_t *file = &changes->files[current]; + if (changes_starts(line, "old mode ") || changes_starts(line, "new mode ")) { + file->reasons |= CBM_CHANGE_MODE; + if (!changes_mode(line, changes_starts(line, "old mode ") ? "old mode " : "new mode ")) { + changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; + } + } else if (changes_starts(line, "new file mode ") || + changes_starts(line, "deleted file mode ")) { + if (!changes_mode(line, changes_starts(line, "new file mode ") ? + "new file mode " : "deleted file mode ")) { + changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; + } + } else if (changes_starts(line, "--- ")) { + if (old_seen || !changes_side_path(line, file, true)) { + changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; + } + old_seen = true; + } else if (changes_starts(line, "+++ ")) { + if (new_seen || !changes_side_path(line, file, false)) { + changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; + } + new_seen = true; + } + } +} + +static bool changes_hunks_usable(const cbm_diff_file_t *file) { + if (file->hunk_count <= 0 || !file->hunks) { + return false; + } + for (int i = 0; i < file->hunk_count; i++) { + const cbm_diff_hunk_t *hunk = &file->hunks[i]; + if (hunk->start < 0 || hunk->count < 0 || hunk->count > INT_MAX - hunk->start || + hunk->added_count != hunk->count || hunk->removed_count < 0 || + (hunk->count > 0 && (!hunk->added || hunk->start == 0)) || + (hunk->removed_count > 0 && !hunk->removed) || + (hunk->added_count == 0 && hunk->removed_count == 0)) { + return false; + } + } + return true; +} + +static void changes_reconcile(cbm_changes_t *changes, changes_match_t *matches) { + int patch_count = 0; + const cbm_diff_file_t *files = cbm_diff_files(changes->diff, &patch_count); + if (!cbm_diff_complete(changes->diff)) { + changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; + } + for (int i = 0; i < patch_count; i++) { + const cbm_diff_file_t *file = &files[i]; + size_t length = strlen(file->path); + size_t index = changes_path_valid((const unsigned char *)file->path, length) ? + changes_find(changes, (const unsigned char *)file->path, length) : SIZE_MAX; + if (index == SIZE_MAX) { + changes->issues |= CBM_CHANGES_PATCH_EXTRA_PATH; + continue; + } + changes_match_t *match = &matches[index]; + match->file = file; + if (match->count++ != 0) { + changes->issues |= CBM_CHANGES_PATCH_DUPLICATE_PATH; + } + char status = changes->files[index].status; + if (file->created != (status == 'A') || file->deleted != (status == 'D')) { + changes->issues |= CBM_CHANGES_PATCH_FLAG_MISMATCH; + } + } + for (size_t i = 0; i < changes->count; i++) { + if (!matches[i].count) { + changes->issues |= CBM_CHANGES_PATCH_MISSING_PATH; + } + } + if (changes->count == 0 && changes->patch_length != 0) { + changes->issues |= CBM_CHANGES_PATCH_WITHOUT_NAMES; + } + changes_audit_headers(changes); + changes->reconciled = changes->issues == 0; + changes->can_narrow = changes->reconciled; + for (size_t i = 0; i < changes->count; i++) { + cbm_change_file_t *file = &changes->files[i]; + if (file->status == 'T') { + file->reasons |= CBM_CHANGE_TYPE; + } + if (matches[i].count == 1) { + if (matches[i].file->binary) { + file->reasons |= CBM_CHANGE_BINARY; + } + if (!changes_hunks_usable(matches[i].file)) { + file->reasons |= CBM_CHANGE_NO_HUNKS; + } + } + if (!changes->reconciled) { + file->reasons |= CBM_CHANGE_UNRECONCILED; + } + if (file->reasons) { + file->evidence = CBM_CHANGE_WHOLE_FILE; + changes->can_narrow = false; + } else { + file->evidence = CBM_CHANGE_HUNKS; + file->patch_file = matches[i].file; + } + } + changes->state = changes->count ? CBM_CHANGES_NONEMPTY : + (changes->patch_length == 0 && changes->reconciled ? CBM_CHANGES_EMPTY : CBM_CHANGES_UNKNOWN); +} + +cbm_changes_status_t cbm_changes_parse(const unsigned char *name_status, + size_t name_status_length, const unsigned char *patch, + size_t patch_length, cbm_changes_t **out) { + if (out) { + *out = NULL; + } + if (!out || (!name_status && name_status_length) || (!patch && patch_length) || + name_status_length > PTRDIFF_MAX || patch_length > PTRDIFF_MAX) { + return CBM_CHANGES_INVALID; + } + size_t count = 0; + size_t offset = 0; + while (offset < name_status_length) { + char status; + changes_slice_t path; + if (!changes_record(name_status, name_status_length, &offset, &status, &path)) { + return CBM_CHANGES_INVALID; + } + count++; + } + if (count > SIZE_MAX / sizeof(cbm_change_file_t) || count > SIZE_MAX / sizeof(changes_match_t)) { + return CBM_CHANGES_OOM; + } + CBMArena arena; + cbm_arena_init(&arena); + cbm_changes_t *changes = cbm_arena_calloc(&arena, sizeof(*changes)); + if (!changes) { + cbm_arena_destroy(&arena); + return CBM_CHANGES_OOM; + } + changes->arena = arena; + changes->name_status = cbm_arena_alloc(&changes->arena, name_status_length + 1); + changes->patch = cbm_arena_alloc(&changes->arena, patch_length + 1); + if (!changes->name_status || !changes->patch) { + goto oom; + } + if (name_status_length) { + memcpy(changes->name_status, name_status, name_status_length); + } + if (patch_length) { + memcpy(changes->patch, patch, patch_length); + } + changes->name_status[name_status_length] = 0; + changes->patch[patch_length] = 0; + changes->name_status_length = name_status_length; + changes->patch_length = patch_length; + changes->count = count; + changes_match_t *matches = NULL; + if (count) { + changes->files = cbm_arena_calloc(&changes->arena, count * sizeof(*changes->files)); + cbm_change_file_t *scratch = cbm_arena_alloc(&changes->arena, count * sizeof(*scratch)); + matches = cbm_arena_calloc(&changes->arena, count * sizeof(*matches)); + if (!changes->files || !scratch || !matches) { + goto oom; + } + offset = 0; + for (size_t i = 0; i < count; i++) { + changes_slice_t path; + char status; + if (!changes_record(changes->name_status, name_status_length, &offset, &status, &path)) { + cbm_changes_free(changes); + return CBM_CHANGES_INVALID; + } + changes->files[i].path = path.bytes; + changes->files[i].path_length = path.length; + changes->files[i].status = status; + } + changes_sort(changes->files, scratch, count); + for (size_t i = 1; i < count; i++) { + if (changes_path_compare(changes->files[i - 1].path, changes->files[i - 1].path_length, + changes->files[i].path, changes->files[i].path_length) == 0) { + cbm_changes_free(changes); + return CBM_CHANGES_INVALID; + } + } + } + /* Raw NUL is not a representable text patch. Avoid passing it to a legacy + * C-string path consumer; retain the exact bytes and conservative inventory. */ + bool binary_text = patch_length && memchr(changes->patch, 0, patch_length); + changes->diff = cbm_diff_parse(binary_text ? "" : (const char *)changes->patch, + binary_text ? 0 : patch_length); + if (!changes->diff) { + goto oom; + } + if (binary_text) { + changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; + } + changes_reconcile(changes, matches); + *out = changes; + return CBM_CHANGES_OK; + +oom: + cbm_changes_free(changes); + return CBM_CHANGES_OOM; +} + +void cbm_changes_free(cbm_changes_t *changes) { + if (!changes) { + return; + } + cbm_diff_free(changes->diff); + CBMArena arena = changes->arena; + cbm_arena_destroy(&arena); +} + +const cbm_change_file_t *cbm_changes_files(const cbm_changes_t *changes, size_t *count) { + if (count) { + *count = changes ? changes->count : 0; + } + return changes ? changes->files : NULL; +} + +cbm_changes_state_t cbm_changes_state(const cbm_changes_t *changes) { + return changes ? changes->state : CBM_CHANGES_UNKNOWN; +} + +bool cbm_changes_patch_reconciled(const cbm_changes_t *changes) { + return changes && changes->reconciled; +} + +bool cbm_changes_can_narrow(const cbm_changes_t *changes) { + return changes && changes->can_narrow; +} + +unsigned cbm_changes_issues(const cbm_changes_t *changes) { + return changes ? changes->issues : 0; +} diff --git a/src/mcp/test_impact_changes.h b/src/mcp/test_impact_changes.h new file mode 100644 index 000000000..e8553a176 --- /dev/null +++ b/src/mcp/test_impact_changes.h @@ -0,0 +1,75 @@ +#ifndef CBM_TEST_IMPACT_CHANGES_H +#define CBM_TEST_IMPACT_CHANGES_H + +#include "mcp/test_impact.h" + +#include +#include + +/* Pure reconciliation of pinned --name-status -z --no-renames and -U0 bytes. + * This is not a graph/coverage identity or provenance certificate. */ +typedef struct cbm_changes cbm_changes_t; + +typedef enum { + CBM_CHANGES_OK = 0, + CBM_CHANGES_INVALID, + CBM_CHANGES_OOM +} cbm_changes_status_t; + +typedef enum { + CBM_CHANGES_UNKNOWN = 0, + CBM_CHANGES_EMPTY, + CBM_CHANGES_NONEMPTY +} cbm_changes_state_t; + +typedef enum { CBM_CHANGE_HUNKS = 0, CBM_CHANGE_WHOLE_FILE } cbm_change_evidence_t; + +enum { + CBM_CHANGE_NO_HUNKS = 1u << 0, + CBM_CHANGE_BINARY = 1u << 1, + CBM_CHANGE_MODE = 1u << 2, + CBM_CHANGE_TYPE = 1u << 3, + CBM_CHANGE_UNRECONCILED = 1u << 4 +}; + +enum { + CBM_CHANGES_PATCH_INCOMPLETE = 1u << 0, + CBM_CHANGES_PATCH_MISSING_PATH = 1u << 1, + CBM_CHANGES_PATCH_EXTRA_PATH = 1u << 2, + CBM_CHANGES_PATCH_DUPLICATE_PATH = 1u << 3, + CBM_CHANGES_PATCH_FLAG_MISMATCH = 1u << 4, + CBM_CHANGES_PATCH_WITHOUT_NAMES = 1u << 5 +}; + +typedef struct { + const unsigned char *path; /* owned NUL-terminated bytes; length is authoritative */ + size_t path_length; + char status; /* A/M/D/T only */ + cbm_change_evidence_t evidence; + unsigned reasons; + const cbm_diff_file_t *patch_file; /* only HUNKS evidence exposes a pointer */ +} cbm_change_file_t; + +/* Clears *out. Invalid authoritative records expose no prefix. Exact inputs + * and nested diff owner live until free. Own OOM or NULL diff parse returns + * OOM/no owner. Legacy reader may mask header OOM as incomplete: such a result + * retains paths but no hunk pointers and requires outer broad/full fallback. + * NULL input is allowed only at zero length. No I/O, path expansion or recoding. */ +cbm_changes_status_t cbm_changes_parse(const unsigned char *name_status, + size_t name_status_length, const unsigned char *patch, + size_t patch_length, cbm_changes_t **out); +void cbm_changes_free(cbm_changes_t *changes); + +/* Complete authoritative inventory, sorted by unsigned path bytes. Never + * interpret count==0 or NULL patch_file as proof of no changes. */ +const cbm_change_file_t *cbm_changes_files(const cbm_changes_t *changes, size_t *count); +/* EMPTY requires exact empty names AND exact empty complete patch. NULL and + * empty names conflicting with nonempty patch return UNKNOWN. */ +cbm_changes_state_t cbm_changes_state(const cbm_changes_t *changes); +bool cbm_changes_patch_reconciled(const cbm_changes_t *changes); +/* True only for reconciled all-hunk evidence or verified EMPTY. Whole-file + * evidence requires later conservative file-level handling, never omission. */ +bool cbm_changes_can_narrow(const cbm_changes_t *changes); +unsigned cbm_changes_issues(const cbm_changes_t *changes); + +#endif diff --git a/src/mcp/test_impact_config.c b/src/mcp/test_impact_config.c new file mode 100644 index 000000000..010c44af0 --- /dev/null +++ b/src/mcp/test_impact_config.c @@ -0,0 +1,209 @@ +/* Query-local test-path conventions from .codebase-memory.json. */ +#include "mcp/test_impact.h" +#include "discover/discover.h" +#include "foundation/arena.h" +#include "foundation/compat_fs.h" +#include "foundation/sha256.h" +#include +#include +#include + +enum { TEST_CONFIG_MAX_BYTES = 65536 }; + +struct cbm_test_config { + CBMArena arena; + cbm_gitignore_t *test_globs; + const char *source; + size_t source_len; + bool source_absent; + char digest[CBM_SHA256_HEX_LEN + 1]; +}; + +static yyjson_val *tc_field(yyjson_val *object, const char *name, bool *valid) { + if (!yyjson_is_obj(object)) { + *valid = false; + return NULL; + } + yyjson_val *found = NULL, *key, *value; + size_t index, maximum; + size_t len = strlen(name); + yyjson_obj_foreach(object, index, maximum, key, value) { + if (yyjson_get_len(key) == len && memcmp(yyjson_get_str(key), name, len) == 0) { + if (found) { + *valid = false; + return NULL; + } + found = value; + } + } + return found; +} + +static void tc_digest(cbm_test_config_t *config, const char *bytes, size_t len, bool absent) { + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + const char *domain = absent ? "cbm-test-config-absent-v1" : "cbm-test-config-bytes-v1"; + cbm_sha256_update(&hash, domain, strlen(domain) + 1); + if (len) + cbm_sha256_update(&hash, bytes, len); + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(&hash, digest); + static const char hex[] = "0123456789abcdef"; + for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) { + config->digest[i * 2] = hex[digest[i] >> 4]; + config->digest[i * 2 + 1] = hex[digest[i] & 15]; + } + config->digest[CBM_SHA256_HEX_LEN] = '\0'; +} + +static bool tc_parse(cbm_test_config_t *config, const char *bytes, size_t len) { + if (memchr(bytes, '\0', len)) + return false; + yyjson_doc *doc = yyjson_read(bytes, len, 0); + if (!doc) + return false; + bool valid = true; + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *impact = tc_field(root, "test_impact", &valid); + if (!valid || !impact) + goto done; + yyjson_val *version = tc_field(impact, "version", &valid); + if (!yyjson_is_uint(version) || yyjson_get_uint(version) != 1) { + valid = false; + goto done; + } + yyjson_val *tests = tc_field(impact, "tests", &valid); + if (!valid || !tests) + goto done; + yyjson_val *globs = tc_field(tests, "test_globs", &valid); + if (!valid || !globs) + goto done; + if (!yyjson_is_arr(globs)) { + valid = false; + goto done; + } + /* JSON source size bounds decoded pattern bytes plus one newline per + * element. Reject gitignore control records: this list is a union of + * positive patterns, not an ordered include/exclude program. */ + char *patterns = cbm_arena_alloc(&config->arena, len + 1); + if (!patterns) { + valid = false; + goto done; + } + size_t used = 0, index, maximum; + yyjson_val *pattern; + yyjson_arr_foreach(globs, index, maximum, pattern) { + const char *text = yyjson_get_str(pattern); + size_t n = yyjson_get_len(pattern); + if (!text || !n || strlen(text) != n || text[0] == '!' || text[0] == '#' || + memchr(text, '\n', n) || memchr(text, '\r', n) || text[n - 1] == ' ' || + text[n - 1] == '\t' || used > len || n >= len - used) { + valid = false; + goto done; + } + memcpy(patterns + used, text, n); + used += n; + patterns[used++] = '\n'; + } + patterns[used] = '\0'; + config->test_globs = cbm_gitignore_parse(patterns); + if (!config->test_globs) + valid = false; +done: + yyjson_doc_free(doc); + return valid; +} + +cbm_test_config_t *cbm_test_config_load(const char *path, bool optional) { + if (!path || !*path) + return NULL; + CBMArena arena; + cbm_arena_init(&arena); + cbm_test_config_t *config = cbm_arena_calloc(&arena, sizeof(*config)); + if (!config) { + cbm_arena_destroy(&arena); + return NULL; + } + config->arena = arena; + cbm_path_info_t info = {0}; + int status = cbm_path_info_utf8(path, &info); + if (status == CBM_PATH_INFO_ABSENT && optional) { + config->source = ""; + config->source_absent = true; + tc_digest(config, NULL, 0, true); + return config; + } + if (status != CBM_PATH_INFO_OK || !info.is_regular || info.size > TEST_CONFIG_MAX_BYTES) + goto invalid; + FILE *file = cbm_fopen(path, "rb"); + if (!file) + goto invalid; + char *bytes = cbm_arena_alloc(&config->arena, TEST_CONFIG_MAX_BYTES + 1U); + if (!bytes) { + fclose(file); + goto invalid; + } + size_t len = fread(bytes, 1, TEST_CONFIG_MAX_BYTES + 1U, file); + bool read_ok = !ferror(file) && len <= TEST_CONFIG_MAX_BYTES; + if (fclose(file) != 0) + read_ok = false; + if (!read_ok || !tc_parse(config, bytes, len)) + goto invalid; + bytes[len] = '\0'; + config->source = bytes; + config->source_len = len; + tc_digest(config, bytes, len, false); + return config; +invalid: + cbm_test_config_free(config); + return NULL; +} + +void cbm_test_config_free(cbm_test_config_t *config) { + if (!config) + return; + cbm_gitignore_free(config->test_globs); + CBMArena arena = config->arena; + cbm_arena_destroy(&arena); +} + +bool cbm_test_config_is_test_file(const cbm_test_config_t *config, const char *file) { + if (!config || !config->test_globs || !file) + return false; + if (cbm_gitignore_matches(config->test_globs, file, false)) + return true; + if (!strchr(file, '/')) + return false; + /* The discovery walker normally tests each directory on its way down. + * A graph file path needs the same ancestor checks, including patterns + * ending in '/'. Positive patterns make these checks order-independent. */ + CBMArena scratch; + cbm_arena_init_lazy(&scratch, 4096); + char *copy = cbm_arena_strdup(&scratch, file); + bool matched = false; + if (copy) { + for (char *slash = strchr(copy, '/'); slash; slash = strchr(slash + 1, '/')) { + *slash = '\0'; + matched = cbm_gitignore_matches(config->test_globs, copy, true); + *slash = '/'; + if (matched) + break; + } + } + cbm_arena_destroy(&scratch); + /* Allocation failure retains the seed, the conservative side of this + * product-only display/traversal filter. It cannot justify omitting tests. */ + return matched; +} + +const char *cbm_test_config_digest(const cbm_test_config_t *config) { + return config ? config->digest : ""; +} + +const char *cbm_test_config_source(const cbm_test_config_t *config, size_t *len, bool *absent) { + if (len) + *len = config ? config->source_len : 0; + if (absent) + *absent = config && config->source_absent; + return config ? config->source : NULL; +} diff --git a/src/mcp/test_impact_coverage.c b/src/mcp/test_impact_coverage.c new file mode 100644 index 000000000..7ba7bf60d --- /dev/null +++ b/src/mcp/test_impact_coverage.c @@ -0,0 +1,1276 @@ +/* Coverage-map formats 1 and 2: strict syntax/reference validation. Admission of + * an artifact (provenance, metadata, age, ancestry) belongs to the caller. */ +#include "mcp/test_impact.h" + +#include "foundation/arena.h" +#include "foundation/sha256.h" +#include + +#include +#include +#include +#include + +struct cbm_coverage_map { + CBMArena arena; + cbm_coverage_format_t format; + cbm_coverage_profile_binding_t profile_binding; + cbm_coverage_profile_t *profiles; + cbm_coverage_function_t *functions; /* ID order is never changed */ + const cbm_coverage_function_t **function_order; /* source/name lookup */ + int function_count; + cbm_coverage_test_t *tests; + int test_count; + char functions_sha256[CBM_SHA256_HEX_LEN + 1]; + char tests_sha256[CBM_SHA256_HEX_LEN + 1]; +}; + +static void cv_hash_bytes(const char *bytes, size_t len, char hex[CBM_SHA256_HEX_LEN + 1]) { + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + if (len) + cbm_sha256_update(&hash, bytes, len); + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(&hash, digest); + static const char digits[] = "0123456789abcdef"; + for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) { + hex[i * 2] = digits[digest[i] >> 4]; + hex[i * 2 + 1] = digits[digest[i] & 15]; + } + hex[CBM_SHA256_HEX_LEN] = '\0'; +} + +static bool cv_records(const char *text, size_t len, int *count) { + *count = 0; + if ((!text && len) || len == SIZE_MAX || + (len && (text[len - 1] != '\n' || memchr(text, '\0', len)))) { + return false; + } + for (size_t i = 0; i < len; i++) { + if (text[i] == '\n') { + if (*count == INT_MAX) { + return false; + } + (*count)++; + } + } + return true; +} + +static void *cv_array(cbm_coverage_map_t *map, int count, size_t size) { + if (count <= 0 || (size_t)count > SIZE_MAX / size) { + return NULL; + } + return cbm_arena_calloc(&map->arena, (size_t)count * size); +} + +/* Records were counted before copying. Keep empty TSV fields and trim only a + * record-ending CR, as emitted by the Python builder on Windows. */ +static char *cv_line(char **cursor) { + char *line = *cursor; + char *end = strchr(line, '\n'); + if (!end) { + return NULL; + } + *cursor = end + 1; + *end = '\0'; + if (end > line && end[-1] == '\r') { + end[-1] = '\0'; + } + return line; +} + +static bool cv_fields(char *line, char **fields, int count) { + if (!line) { + return false; + } + for (int i = 0; i < count; i++) { + fields[i] = line; + char *tab = strchr(line, '\t'); + if (i + 1 == count) { + return tab == NULL; + } + if (!tab) { + return false; + } + *tab = '\0'; + line = tab + 1; + } + return false; +} + +static bool cv_decimal(const char *text, int *value, const char **end) { + if (*text < '0' || *text > '9') { + return false; + } + int number = 0; + while (*text >= '0' && *text <= '9') { + int digit = *text++ - '0'; + if (number > (INT_MAX - digit) / 10) { + return false; + } + number = number * 10 + digit; + } + *value = number; + *end = text; + return true; +} + +static int cv_function_compare(const void *left, const void *right) { + const cbm_coverage_function_t *a = *(const cbm_coverage_function_t *const *)left; + const cbm_coverage_function_t *b = *(const cbm_coverage_function_t *const *)right; + int cmp = strcmp(a->file, b->file); + return cmp ? cmp : strcmp(a->name, b->name); +} + +static int cv_test_compare(const void *left, const void *right) { + const cbm_coverage_test_t *a = left; + const cbm_coverage_test_t *b = right; + int cmp = strcmp(a->suite, b->suite); + return cmp ? cmp : strcmp(a->name, b->name); +} + +static bool cv_functions(cbm_coverage_map_t *map, char *text) { + for (int i = 0; i < map->function_count; i++) { + char *fields[3]; + int id; + const char *end; + if (!cv_fields(cv_line(&text), fields, 3) || !cv_decimal(fields[0], &id, &end) || *end || + id != i || !fields[2][0]) { + return false; + } + map->functions[i] = + (cbm_coverage_function_t){.id = id, .file = fields[1], .name = fields[2]}; + map->function_order[i] = &map->functions[i]; + } + if (map->function_count > 1) { + qsort(map->function_order, (size_t)map->function_count, sizeof(*map->function_order), + cv_function_compare); + for (int i = 1; i < map->function_count; i++) { + if (cv_function_compare(&map->function_order[i - 1], &map->function_order[i]) == 0) { + return false; + } + } + } + return true; +} + +static bool cv_ids(cbm_coverage_map_t *map, cbm_coverage_test_t *row, const char *text, + bool *seen) { + if (!*text) { + return true; + } + int count = 1; + for (const char *p = text; *p; p++) { + if (*p == ' ') { + if (count == INT_MAX) { + return false; + } + count++; + } + } + /* A row cannot contain more unique IDs than there are function records. + * Allocate from actual input tokens, never from a claimed numeric ID. */ + if (count > map->function_count) { + return false; + } + int *ids = cv_array(map, count, sizeof(*ids)); + if (!ids) { + return false; + } + for (int i = 0; i < count; i++) { + const char *end; + if (!cv_decimal(text, &ids[i], &end) || ids[i] >= map->function_count || + (i > 0 && ids[i] <= ids[i - 1])) { + return false; + } + seen[ids[i]] = true; + if (i + 1 < count) { + if (*end != ' ') { + return false; + } + text = end + 1; + } else if (*end) { + return false; + } + } + row->function_ids = ids; + row->function_count = count; + return true; +} + +static bool cv_tests(cbm_coverage_map_t *map, char *text, bool *seen) { + for (int i = 0; i < map->test_count; i++) { + char *fields[4]; + if (!cv_fields(cv_line(&text), fields, 4)) { + return false; + } + char *colon = strchr(fields[0], ':'); + if (!colon || colon == fields[0] || !colon[1]) { + return false; + } + *colon = '\0'; + bool complete = strcmp(fields[1], "complete") == 0; + if ((!complete && strcmp(fields[1], "incomplete") != 0) || (complete && fields[2][0]) || + (!complete && !fields[2][0])) { + return false; + } + cbm_coverage_test_t *row = &map->tests[i]; + *row = (cbm_coverage_test_t){ + .suite = fields[0], .name = colon + 1, .complete = complete, .reason = fields[2]}; + if (!cv_ids(map, row, fields[3], seen)) { + return false; + } + } + if (map->test_count > 1) { + qsort(map->tests, (size_t)map->test_count, sizeof(*map->tests), cv_test_compare); + } + bool setup = false; + for (int i = 0; i < map->test_count; i++) { + if (i == 0 || strcmp(map->tests[i - 1].suite, map->tests[i].suite) != 0) { + if (i > 0 && !setup) { + return false; + } + setup = false; + } else if (cv_test_compare(&map->tests[i - 1], &map->tests[i]) == 0) { + return false; + } + setup = setup || strcmp(map->tests[i].name, "*") == 0; + } + if (map->test_count && !setup) { + return false; + } + /* The builder emits only functions executed by at least one test/setup. + * An orphan could otherwise be mistaken for known, unneeded coverage. */ + for (int i = 0; i < map->function_count; i++) { + if (!seen[i]) { + return false; + } + } + return true; +} + +cbm_coverage_map_t *cbm_coverage_map_parse(const char *functions, size_t functions_len, + const char *tests, size_t tests_len) { + int function_count; + int test_count; + if (!cv_records(functions, functions_len, &function_count) || + !cv_records(tests, tests_len, &test_count)) { + return NULL; + } + CBMArena arena; + cbm_arena_init(&arena); + cbm_coverage_map_t *map = cbm_arena_calloc(&arena, sizeof(*map)); + if (!map) { + cbm_arena_destroy(&arena); + return NULL; + } + map->arena = arena; + map->format = CBM_COVERAGE_FORMAT_FUNCTIONS; + map->function_count = function_count; + map->test_count = test_count; + map->functions = cv_array(map, function_count, sizeof(*map->functions)); + map->function_order = cv_array(map, function_count, sizeof(*map->function_order)); + map->tests = cv_array(map, test_count, sizeof(*map->tests)); + bool *seen = cv_array(map, function_count, sizeof(*seen)); + char *function_copy = cbm_arena_strndup(&map->arena, functions ? functions : "", functions_len); + char *test_copy = cbm_arena_strndup(&map->arena, tests ? tests : "", tests_len); + if ((function_count && (!map->functions || !map->function_order || !seen)) || + (test_count && !map->tests) || !function_copy || !test_copy || + !cv_functions(map, function_copy) || !cv_tests(map, test_copy, seen)) { + cbm_coverage_map_free(map); + return NULL; + } + cv_hash_bytes(functions, functions_len, map->functions_sha256); + cv_hash_bytes(tests, tests_len, map->tests_sha256); + return map; +} + +void cbm_coverage_map_free(cbm_coverage_map_t *map) { + if (map) { + CBMArena arena = map->arena; + cbm_arena_destroy(&arena); + } +} + +const char *cbm_coverage_map_functions_sha256(const cbm_coverage_map_t *map) { + if (map && map->format != CBM_COVERAGE_FORMAT_FUNCTIONS) + return NULL; + return map ? map->functions_sha256 : NULL; +} + +const cbm_coverage_function_t *cbm_coverage_map_functions(const cbm_coverage_map_t *map, + int *count) { + if (map && map->format != CBM_COVERAGE_FORMAT_FUNCTIONS) { + if (count) + *count = 0; + return NULL; + } + if (count) { + *count = map ? map->function_count : 0; + } + return map ? map->functions : NULL; +} + +const cbm_coverage_test_t *cbm_coverage_map_tests(const cbm_coverage_map_t *map, int *count) { + if (count) { + *count = map ? map->test_count : 0; + } + return map ? map->tests : NULL; +} + +const cbm_coverage_function_t *cbm_coverage_map_find_function(const cbm_coverage_map_t *map, + const char *file, const char *name) { + if (!map || map->format != CBM_COVERAGE_FORMAT_FUNCTIONS || !file || !name) { + return NULL; + } + cbm_coverage_function_t key = {.file = file, .name = name}; + const cbm_coverage_function_t *key_ptr = &key; + int lo = 0; + int hi = map->function_count; + while (lo < hi) { + int mid = lo + (hi - lo) / 2; + int cmp = cv_function_compare(&key_ptr, &map->function_order[mid]); + if (cmp == 0) { + return map->function_order[mid]; + } + if (cmp < 0) { + hi = mid; + } else { + lo = mid + 1; + } + } + return NULL; +} + +const cbm_coverage_test_t *cbm_coverage_map_find_test(const cbm_coverage_map_t *map, + const char *suite, const char *name) { + if (!map || !suite || !name) { + return NULL; + } + cbm_coverage_test_t key = {.suite = suite, .name = name}; + int lo = 0; + int hi = map->test_count; + while (lo < hi) { + int mid = lo + (hi - lo) / 2; + int cmp = cv_test_compare(&key, &map->tests[mid]); + if (cmp == 0) { + return &map->tests[mid]; + } + if (cmp < 0) { + hi = mid; + } else { + lo = mid + 1; + } + } + return NULL; +} + +bool cbm_coverage_test_intersects(const cbm_coverage_test_t *test, const int *changed_ids, + int changed_count) { + if (!test || !changed_ids) { + return false; + } + for (int i = 0; i < changed_count; i++) { + int lo = 0; + int hi = test->function_count; + while (lo < hi) { + int mid = lo + (hi - lo) / 2; + int id = test->function_ids[mid]; + if (id == changed_ids[i]) { + return true; + } + if (id > changed_ids[i]) { + hi = mid; + } else { + lo = mid + 1; + } + } + } + return false; +} + +/* Coverage metadata consistency. */ +typedef struct { + const char *name; + int tests; + int incomplete; + bool setup_complete; + bool seen; +} cv_meta_suite_t; + +/* Known fields must be unambiguous. Unknown fields are ignored for forward + * compatibility; repeated known keys are invalid even when values agree. */ +static yyjson_val *cv_meta_field(yyjson_val *object, const char *name, bool *valid) { + if (!yyjson_is_obj(object)) { + *valid = false; + return NULL; + } + yyjson_val *result = NULL; + size_t index, maximum; + yyjson_val *key, *value; + size_t len = strlen(name); + yyjson_obj_foreach(object, index, maximum, key, value) { + if (yyjson_get_len(key) == len && memcmp(yyjson_get_str(key), name, len) == 0) { + if (result) { + *valid = false; + return NULL; + } + result = value; + } + } + return result; +} + +static bool cv_meta_count(yyjson_val *value, int *out) { + if (yyjson_is_uint(value)) { + uint64_t count = yyjson_get_uint(value); + if (count > INT_MAX) + return false; + *out = (int)count; + return true; + } + if (yyjson_is_sint(value)) { + int64_t count = yyjson_get_sint(value); + if (count < 0 || count > INT_MAX) + return false; + *out = (int)count; + return true; + } + return false; +} + +static bool cv_meta_count_equals(yyjson_val *value, int expected) { + int count; + return cv_meta_count(value, &count) && count == expected; +} + +static const char *cv_meta_string(yyjson_val *value) { + const char *text = yyjson_get_str(value); + return text && *text && strlen(text) == yyjson_get_len(value) ? text : NULL; +} + +static bool cv_meta_commit(const char *commit) { + if (!commit) + return false; + size_t len = strlen(commit); + if (len != 40 && len != 64) + return false; + for (size_t i = 0; i < len; i++) { + char c = commit[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + return false; + } + } + return true; +} + +static cv_meta_suite_t *cv_meta_find_suite(cv_meta_suite_t *suites, int count, const char *name) { + int lo = 0, hi = count; + while (lo < hi) { + int mid = lo + (hi - lo) / 2; + int cmp = strcmp(name, suites[mid].name); + if (!cmp) + return &suites[mid]; + if (cmp < 0) + hi = mid; + else + lo = mid + 1; + } + return NULL; +} + +bool cbm_coverage_map_metadata_matches(const cbm_coverage_map_t *map, const char *metadata, + size_t len, const char *expected_commit) { + if (!map || map->format != CBM_COVERAGE_FORMAT_FUNCTIONS || !metadata || + len == SIZE_MAX || !cv_meta_commit(expected_commit) || + memchr(metadata, '\0', len)) { + return false; + } + yyjson_doc *doc = yyjson_read(metadata, len, 0); + if (!doc) + return false; + CBMArena arena; + cbm_arena_init_lazy(&arena, 4096); + bool result = false; + bool valid = true; + int suite_count = 0, test_count = 0, incomplete_count = 0; + for (int i = 0; i < map->test_count; i++) { + if (i == 0 || strcmp(map->tests[i - 1].suite, map->tests[i].suite) != 0) + suite_count++; + if (strcmp(map->tests[i].name, "*") != 0) { + test_count++; + incomplete_count += !map->tests[i].complete; + } + } + yyjson_val *root = yyjson_doc_get_root(doc); + if (!cv_meta_count_equals(cv_meta_field(root, "format", &valid), 1) || + !cv_meta_count_equals(cv_meta_field(root, "functions", &valid), map->function_count) || + !cv_meta_count_equals(cv_meta_field(root, "tests", &valid), test_count) || + !cv_meta_count_equals(cv_meta_field(root, "incomplete", &valid), incomplete_count)) + goto done; + const char *commit = cv_meta_string(cv_meta_field(root, "commit", &valid)); + if (!commit || strcmp(commit, expected_commit) != 0 || + !cv_meta_string(cv_meta_field(root, "platform", &valid)) || + !cv_meta_string(cv_meta_field(root, "llvm_profdata", &valid))) + goto done; + yyjson_val *compiled = cv_meta_field(root, "functions_compiled", &valid); + int compiled_count; + /* Diagnostic only: fallback profile functions can be absent from the + * runner's source table, so this need not bound the executed functions. */ + if (compiled && !cv_meta_count(compiled, &compiled_count)) + goto done; + yyjson_val *entries = cv_meta_field(root, "suites", &valid); + if (!valid || !yyjson_is_arr(entries) || yyjson_arr_size(entries) != (size_t)suite_count || + (size_t)suite_count > SIZE_MAX / sizeof(cv_meta_suite_t)) + goto done; + cv_meta_suite_t *suites = + suite_count ? cbm_arena_calloc(&arena, (size_t)suite_count * sizeof(*suites)) : NULL; + if (suite_count && !suites) + goto done; + int at = -1; + for (int i = 0; i < map->test_count; i++) { + const cbm_coverage_test_t *row = &map->tests[i]; + if (i == 0 || strcmp(map->tests[i - 1].suite, row->suite) != 0) { + suites[++at].name = row->suite; + } + if (strcmp(row->name, "*") == 0) { + suites[at].setup_complete = row->complete; + } else { + suites[at].tests++; + suites[at].incomplete += !row->complete; + } + } + size_t index, maximum; + yyjson_val *entry; + yyjson_arr_foreach(entries, index, maximum, entry) { + const char *name = cv_meta_string(cv_meta_field(entry, "suite", &valid)); + if (!name) + goto done; + cv_meta_suite_t *suite = cv_meta_find_suite(suites, suite_count, name); + if (!suite || suite->seen || + !cv_meta_count_equals(cv_meta_field(entry, "tests", &valid), suite->tests) || + !cv_meta_count_equals(cv_meta_field(entry, "incomplete", &valid), suite->incomplete)) + goto done; + yyjson_val *exit = cv_meta_field(entry, "exit", &valid); + if (!yyjson_is_int(exit)) + goto done; + if (yyjson_get_uint(exit) != 0 && + (suite->incomplete != suite->tests || suite->setup_complete)) + goto done; + yyjson_val *wall = cv_meta_field(entry, "wall_s", &valid); + if (wall && (!yyjson_is_num(wall) || yyjson_get_num(wall) < 0)) + goto done; + if (!valid) + goto done; + suite->seen = true; + } + result = true; +done: + cbm_arena_destroy(&arena); + yyjson_doc_free(doc); + return result; +} + +static bool cv_receipt_digest(const char *text) { + if (!text || strlen(text) != CBM_SHA256_HEX_LEN) + return false; + for (int i = 0; i < CBM_SHA256_HEX_LEN; i++) + if (!((text[i] >= '0' && text[i] <= '9') || (text[i] >= 'a' && text[i] <= 'f'))) + return false; + return true; +} + +unsigned cbm_coverage_map_check_receipt(const cbm_coverage_map_t *map, const char *metadata, + size_t metadata_len, const cbm_coverage_receipt_t *receipt, + const cbm_coverage_receipt_context_t *context) { + if (map && map->format != CBM_COVERAGE_FORMAT_FUNCTIONS) + return CBM_COVERAGE_RECEIPT_INVALID | CBM_COVERAGE_RECEIPT_METADATA; + if (!map || !metadata || metadata_len == SIZE_MAX || !receipt || !context || + !cv_meta_commit(receipt->commit) || !cv_meta_commit(context->trusted_commit) || + !cv_meta_commit(context->graph_commit) || !cv_meta_commit(context->merge_base) || + !cv_meta_commit(context->ancestor_commit) || + !cv_meta_commit(context->ancestor_merge_base) || + !cv_receipt_digest(receipt->functions_sha256) || + !cv_receipt_digest(receipt->tests_sha256) || !cv_receipt_digest(receipt->metadata_sha256) || + !cv_receipt_digest(receipt->graph_sha256) || + !cv_receipt_digest(receipt->compatibility_sha256) || + !cv_receipt_digest(context->graph_sha256) || + !cv_receipt_digest(context->compatibility_sha256)) + return CBM_COVERAGE_RECEIPT_INVALID; + unsigned reasons = 0; + if (!context->source_verified) + reasons |= CBM_COVERAGE_RECEIPT_SOURCE; + if (!context->ancestor_verified || strcmp(receipt->commit, context->ancestor_commit) != 0 || + strcmp(context->merge_base, context->ancestor_merge_base) != 0) + reasons |= CBM_COVERAGE_RECEIPT_ANCESTRY; + if (strcmp(receipt->commit, context->trusted_commit) != 0 || + strcmp(receipt->commit, context->graph_commit) != 0) + reasons |= CBM_COVERAGE_RECEIPT_COMMIT; + char metadata_sha256[CBM_SHA256_HEX_LEN + 1]; + cv_hash_bytes(metadata, metadata_len, metadata_sha256); + if (strcmp(map->functions_sha256, receipt->functions_sha256) != 0 || + strcmp(map->tests_sha256, receipt->tests_sha256) != 0 || + strcmp(metadata_sha256, receipt->metadata_sha256) != 0 || + strcmp(context->graph_sha256, receipt->graph_sha256) != 0) + reasons |= CBM_COVERAGE_RECEIPT_CONTENT; + if (context->now < 0 || receipt->oldest_observation_at <= 0 || + receipt->oldest_observation_at > context->now || + context->now - receipt->oldest_observation_at > 604800) + reasons |= CBM_COVERAGE_RECEIPT_AGE; + if (strcmp(receipt->compatibility_sha256, context->compatibility_sha256) != 0) + reasons |= CBM_COVERAGE_RECEIPT_COMPATIBILITY; + if (!cbm_coverage_map_metadata_matches(map, metadata, metadata_len, context->trusted_commit)) + reasons |= CBM_COVERAGE_RECEIPT_METADATA; + return reasons; +} + +/* Format 2 is deliberately separate from the legacy parser. The context owns + * its arena until the final publish; no error can expose a partial map. */ +typedef struct { + const unsigned char *data; + size_t length; +} cv2_span_t; + +typedef struct { + CBMArena arena; + const cbm_coverage_parse_limits_t *limits; + cbm_coverage_parse_cancel_fn cancelled; + void *cancel_context; + cbm_coverage_parse_status_t status; + size_t allocated; + uint64_t items; + unsigned work; + int profile_count; + int test_count; + size_t profile_start; + cbm_coverage_profile_binding_t binding; + cbm_coverage_map_t *map; +} cv2_context_t; + +static bool cv2_fail(cv2_context_t *ctx, cbm_coverage_parse_status_t status) { + if (ctx->status == CBM_COVERAGE_PARSE_OK) + ctx->status = status; + return false; +} + +static bool cv2_poll(cv2_context_t *ctx) { + if (ctx->status != CBM_COVERAGE_PARSE_OK) + return false; + ctx->work = 0; + if (ctx->cancelled && ctx->cancelled(ctx->cancel_context)) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_CANCELLED); + return true; +} + +static bool cv2_work(cv2_context_t *ctx, unsigned units) { + ctx->work += units; + if (ctx->work >= 4096) + return cv2_poll(ctx); + return ctx->status == CBM_COVERAGE_PARSE_OK; +} + +static bool cv2_step(cv2_context_t *ctx) { + return cv2_work(ctx, 1); +} + +static bool cv2_items(cv2_context_t *ctx, uint64_t count) { + if (count > ctx->limits->max_items - ctx->items) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_LIMIT); + ctx->items += count; + return true; +} + +/* Chunk all explicit bulk work; allocator internals are not a polling API. */ +static bool cv2_copy(cv2_context_t *ctx, void *to, const void *from, size_t length) { + unsigned char *dst = to; + const unsigned char *src = from; + size_t at = 0; + while (at < length) { + if (!cv2_poll(ctx)) + return false; + size_t chunk = length - at; + if (chunk > 4096) + chunk = 4096; + memcpy(dst + at, src + at, chunk); + at += chunk; + } + return cv2_poll(ctx); +} + +static bool cv2_zero(cv2_context_t *ctx, void *memory, size_t length) { + unsigned char *bytes = memory; + size_t at = 0; + while (at < length) { + if (!cv2_poll(ctx)) + return false; + size_t chunk = length - at; + if (chunk > 4096) + chunk = 4096; + memset(bytes + at, 0, chunk); + at += chunk; + } + return cv2_poll(ctx); +} + +static void *cv2_array(cv2_context_t *ctx, size_t count, size_t size) { + if (!count) + return NULL; + if (size && count > SIZE_MAX / size) { + cv2_fail(ctx, CBM_COVERAGE_PARSE_LIMIT); + return NULL; + } + size_t bytes = count * size; + if (bytes > ctx->limits->max_alloc_bytes - ctx->allocated) { + cv2_fail(ctx, CBM_COVERAGE_PARSE_LIMIT); + return NULL; + } + if (!cv2_poll(ctx)) + return NULL; + ctx->allocated += bytes; + void *memory = cbm_arena_alloc(&ctx->arena, bytes); + if (!memory) { + cv2_fail(ctx, CBM_COVERAGE_PARSE_OOM); + return NULL; + } + return cv2_zero(ctx, memory, bytes) ? memory : NULL; +} + +static char *cv2_string(cv2_context_t *ctx, cv2_span_t span) { + if (span.length == SIZE_MAX) { + cv2_fail(ctx, CBM_COVERAGE_PARSE_LIMIT); + return NULL; + } + char *text = cv2_array(ctx, span.length + 1, 1); + if (!text || !cv2_copy(ctx, text, span.data, span.length)) + return NULL; + return text; +} + +static bool cv2_line(cv2_context_t *ctx, cv2_span_t input, size_t *position, + bool test_row, cv2_span_t *line) { + size_t start = *position; + while (*position < input.length) { + if (!cv2_step(ctx)) + return false; + unsigned char byte = input.data[(*position)++]; + if (!byte || (!test_row && byte == '\r')) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + if (byte == '\n') { + size_t length = *position - start - 1; + if (test_row && length && input.data[start + length - 1] == '\r') + length--; + *line = (cv2_span_t){input.data + start, length}; + return true; + } + } + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); +} + +static bool cv2_fields(cv2_context_t *ctx, cv2_span_t line, cv2_span_t fields[4]) { + size_t start = 0; + int field = 0; + for (size_t i = 0; i < line.length; i++) { + if (!cv2_step(ctx)) + return false; + if (line.data[i] != '\t') + continue; + if (field == 3) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + fields[field++] = (cv2_span_t){line.data + start, i - start}; + start = i + 1; + } + if (field != 3) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + fields[3] = (cv2_span_t){line.data + start, line.length - start}; + return true; +} + +static bool cv2_row(cv2_context_t *ctx, cv2_span_t input, size_t *position, + bool test_row, cv2_span_t fields[4]) { + cv2_span_t line; + return cv2_line(ctx, input, position, test_row, &line) && cv2_fields(ctx, line, fields); +} + +static bool cv2_literal(cv2_span_t span, const char *literal, size_t length) { + return span.length == length && memcmp(span.data, literal, length) == 0; +} + +static bool cv2_decimal(cv2_context_t *ctx, cv2_span_t span, bool canonical, + uint64_t *out) { + if (!span.length || (canonical && span.length > 1 && span.data[0] == '0')) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + uint64_t value = 0; + for (size_t i = 0; i < span.length; i++) { + if (!cv2_step(ctx)) + return false; + unsigned char byte = span.data[i]; + if (byte < '0' || byte > '9') + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + unsigned digit = (unsigned)(byte - '0'); + if (value > (UINT64_MAX - digit) / 10) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + value = value * 10 + digit; + } + *out = value; + return true; +} + +static int cv2_hex_digit(unsigned char byte) { + if (byte >= '0' && byte <= '9') + return byte - '0'; + if (byte >= 'a' && byte <= 'f') + return byte - 'a' + 10; + return -1; +} + +static bool cv2_hex64(cv2_context_t *ctx, cv2_span_t span, uint64_t *out) { + if (span.length != 16) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + uint64_t value = 0; + for (size_t i = 0; i < span.length; i++) { + if (!cv2_step(ctx)) + return false; + int digit = cv2_hex_digit(span.data[i]); + if (digit < 0) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + value = (value << 4) | (unsigned)digit; + } + *out = value; + return true; +} + +static bool cv2_image(cv2_context_t *ctx, cv2_span_t span) { + if (span.length != 64) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + for (size_t i = 0; i < span.length; i++) { + if (!cv2_step(ctx)) + return false; + if (cv2_hex_digit(span.data[i]) < 0) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + ctx->binding.image_sha256[i] = (char)span.data[i]; + } + ctx->binding.image_sha256[64] = '\0'; + return true; +} + +static bool cv2_header(cv2_context_t *ctx, cv2_span_t profiles) { + cv2_span_t fields[4]; + uint64_t version, count; + if (!cv2_row(ctx, profiles, &ctx->profile_start, false, fields)) + return false; + if (!cv2_literal(fields[0], "CBM_PROFILE_MAP", 15)) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + if (!cv2_decimal(ctx, fields[1], true, &version) || !cv2_image(ctx, fields[2]) || + !cv2_decimal(ctx, fields[3], true, &count)) + return false; + if (version != 2) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_UNSUPPORTED); + if (count > INT_MAX || count > (uint64_t)ctx->limits->max_ids) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_LIMIT); + ctx->profile_count = (int)count; + return cv2_items(ctx, 1); +} + +static bool cv2_profile_inventory(cv2_context_t *ctx, cv2_span_t profiles) { + size_t position = ctx->profile_start; + int count = 0; + while (position < profiles.length) { + cv2_span_t fields[4]; + if (!cv2_step(ctx) || !cv2_row(ctx, profiles, &position, false, fields)) + return false; + if (count == ctx->profile_count) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + count++; + if (!cv2_items(ctx, 1)) + return false; + } + return count == ctx->profile_count || cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); +} + +static bool cv2_id_count(cv2_context_t *ctx, cv2_span_t span, int *out) { + int count = span.length ? 1 : 0; + for (size_t i = 0; i < span.length; i++) { + if (!cv2_step(ctx)) + return false; + if (span.data[i] != ' ') + continue; + if (count == INT_MAX) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_LIMIT); + count++; + } + if (count > ctx->profile_count) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + *out = count; + return true; +} + +static bool cv2_test_inventory(cv2_context_t *ctx, cv2_span_t tests) { + size_t position = 0; + while (position < tests.length) { + cv2_span_t fields[4]; + int ids; + if (!cv2_step(ctx) || !cv2_row(ctx, tests, &position, true, fields)) + return false; + if (ctx->test_count == INT_MAX) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_LIMIT); + ctx->test_count++; + if (!cv2_id_count(ctx, fields[3], &ids) || !cv2_items(ctx, 1) || + !cv2_items(ctx, (uint64_t)ids)) + return false; + } + return true; +} + +static bool cv2_prepare(cv2_context_t *ctx) { + ctx->map = cv2_array(ctx, 1, sizeof(*ctx->map)); + if (!ctx->map) + return false; + ctx->map->format = CBM_COVERAGE_FORMAT_PROFILES; + ctx->map->function_count = ctx->profile_count; + ctx->map->test_count = ctx->test_count; + ctx->map->profile_binding = ctx->binding; + ctx->map->profiles = cv2_array(ctx, (size_t)ctx->profile_count, sizeof(*ctx->map->profiles)); + ctx->map->tests = cv2_array(ctx, (size_t)ctx->test_count, sizeof(*ctx->map->tests)); + return ctx->status == CBM_COVERAGE_PARSE_OK; +} + +static bool cv2_name(cv2_context_t *ctx, cv2_span_t span, cbm_coverage_profile_t *row) { + if (!span.length || (span.length & 1)) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + size_t length = span.length / 2; + unsigned char *name = cv2_array(ctx, length, 1); + if (!name) + return false; + for (size_t i = 0; i < length; i++) { + if (!cv2_step(ctx)) + return false; + int high = cv2_hex_digit(span.data[i * 2]); + int low = cv2_hex_digit(span.data[i * 2 + 1]); + if (high < 0 || low < 0) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + name[i] = (unsigned char)((high << 4) | low); + } + row->name = name; + row->name_length = length; + return true; +} + +static bool cv2_profile_compare(cv2_context_t *ctx, const cbm_coverage_profile_t *left, + const cbm_coverage_profile_t *right, int *out) { + size_t common = left->name_length < right->name_length ? left->name_length : right->name_length; + for (size_t i = 0; i < common; i++) { + if (!cv2_step(ctx)) + return false; + if (left->name[i] != right->name[i]) { + *out = left->name[i] < right->name[i] ? -1 : 1; + return true; + } + } + if (left->name_length != right->name_length) { + *out = left->name_length < right->name_length ? -1 : 1; + return true; + } + *out = (left->function_hash > right->function_hash) - + (left->function_hash < right->function_hash); + return true; +} + +static bool cv2_profile(cv2_context_t *ctx, cv2_span_t fields[4], int index) { + uint64_t id, counters; + cbm_coverage_profile_t *row = &ctx->map->profiles[index]; + if (!cv2_decimal(ctx, fields[0], true, &id)) + return false; + if (id != (uint64_t)index) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + row->id = index; + if (!cv2_hex64(ctx, fields[2], &row->function_hash) || + !cv2_decimal(ctx, fields[3], true, &counters)) + return false; + if (!counters) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + row->counter_count = counters; + if (!cv2_name(ctx, fields[1], row)) + return false; + if (index) { + int order; + if (!cv2_profile_compare(ctx, row - 1, row, &order)) + return false; + if (order >= 0) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + } + return true; +} + +static bool cv2_profiles(cv2_context_t *ctx, cv2_span_t profiles) { + size_t position = ctx->profile_start; + for (int i = 0; i < ctx->profile_count; i++) { + cv2_span_t fields[4]; + if (!cv2_step(ctx) || !cv2_row(ctx, profiles, &position, false, fields) || + !cv2_profile(ctx, fields, i)) + return false; + } + return position == profiles.length || cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); +} + +static bool cv2_test_name(cv2_context_t *ctx, cv2_span_t field, cbm_coverage_test_t *row) { + size_t colon = 0; + while (colon < field.length && field.data[colon] != ':') { + if (!cv2_step(ctx)) + return false; + colon++; + } + if (!colon || colon == field.length || colon + 1 == field.length) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + row->suite = cv2_string(ctx, (cv2_span_t){field.data, colon}); + row->name = cv2_string(ctx, (cv2_span_t){field.data + colon + 1, field.length - colon - 1}); + return row->suite && row->name; +} + +static bool cv2_test_state(cv2_context_t *ctx, cv2_span_t fields[4], cbm_coverage_test_t *row) { + bool complete = cv2_literal(fields[1], "complete", 8); + bool incomplete = cv2_literal(fields[1], "incomplete", 10); + if ((!complete && !incomplete) || (complete && fields[2].length) || + (incomplete && !fields[2].length)) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + row->complete = complete; + row->reason = cv2_string(ctx, fields[2]); + return row->reason != NULL; +} + +static bool cv2_test_ids(cv2_context_t *ctx, cv2_span_t span, cbm_coverage_test_t *row) { + int count; + if (!cv2_id_count(ctx, span, &count)) + return false; + if (!count) + return true; + int *ids = cv2_array(ctx, (size_t)count, sizeof(*ids)); + if (!ids) + return false; + size_t start = 0; + for (int i = 0; i < count; i++) { + size_t end = start; + while (end < span.length && span.data[end] != ' ') { + if (!cv2_step(ctx)) + return false; + end++; + } + uint64_t id; + if (!cv2_decimal(ctx, (cv2_span_t){span.data + start, end - start}, false, &id)) + return false; + if (id >= (uint64_t)ctx->profile_count || (i && id <= (uint64_t)ids[i - 1])) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + ids[i] = (int)id; + start = end + 1; + } + row->function_ids = ids; + row->function_count = count; + return true; +} + +static bool cv2_tests(cv2_context_t *ctx, cv2_span_t tests) { + size_t position = 0; + for (int i = 0; i < ctx->test_count; i++) { + cv2_span_t fields[4]; + cbm_coverage_test_t *row = &ctx->map->tests[i]; + if (!cv2_step(ctx) || !cv2_row(ctx, tests, &position, true, fields) || + !cv2_test_name(ctx, fields[0], row) || !cv2_test_state(ctx, fields, row) || + !cv2_test_ids(ctx, fields[3], row)) + return false; + } + return position == tests.length || cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); +} + +static bool cv2_string_compare(cv2_context_t *ctx, const char *a, const char *b, int *out) { + size_t i = 0; + for (;;) { + if (!cv2_step(ctx)) + return false; + unsigned char left = (unsigned char)a[i]; + unsigned char right = (unsigned char)b[i]; + if (left != right || !left) { + *out = (left > right) - (left < right); + return true; + } + i++; + } +} + +static bool cv2_test_compare(cv2_context_t *ctx, const cbm_coverage_test_t *a, + const cbm_coverage_test_t *b, int *out) { + if (!cv2_string_compare(ctx, a->suite, b->suite, out)) + return false; + return *out || cv2_string_compare(ctx, a->name, b->name, out); +} + +static bool cv2_merge(cv2_context_t *ctx, const cbm_coverage_test_t *source, + cbm_coverage_test_t *target, size_t start, size_t middle, size_t end) { + size_t left = start, right = middle; + for (size_t at = start; at < end; at++) { + if (!cv2_work(ctx, (unsigned)sizeof(*target))) + return false; + bool take_left = right == end; + if (!take_left && left < middle) { + int order; + if (!cv2_test_compare(ctx, &source[left], &source[right], &order)) + return false; + take_left = order <= 0; + } + target[at] = take_left ? source[left++] : source[right++]; + } + return true; +} + +static bool cv2_sort_tests(cv2_context_t *ctx) { + size_t count = (size_t)ctx->test_count; + if (count < 2) + return true; + cbm_coverage_test_t *source = ctx->map->tests; + cbm_coverage_test_t *target = cv2_array(ctx, count, sizeof(*target)); + if (!target) + return false; + for (size_t width = 1; width < count;) { + size_t start = 0; + while (start < count) { + size_t half = count - start < width ? count - start : width; + size_t middle = start + half; + half = count - middle < width ? count - middle : width; + size_t end = middle + half; + if (!cv2_merge(ctx, source, target, start, middle, end)) + return false; + start = end; + } + cbm_coverage_test_t *swap = source; + source = target; + target = swap; + if (width > count / 2) + break; + width *= 2; + } + ctx->map->tests = source; + return true; +} + +static bool cv2_validate_tests(cv2_context_t *ctx) { + bool setup = false; + for (int i = 0; i < ctx->test_count; i++) { + if (!cv2_step(ctx)) + return false; + const cbm_coverage_test_t *row = &ctx->map->tests[i]; + int suite_order = 1, name_order = 1; + if (i && !cv2_string_compare(ctx, row[-1].suite, row->suite, &suite_order)) + return false; + if (suite_order) { + if (i && !setup) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + setup = false; + } else { + if (!cv2_string_compare(ctx, row[-1].name, row->name, &name_order)) + return false; + if (!name_order) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + } + if (row->name[0] == '*' && row->name[1] == '\0') + setup = true; + } + return !ctx->test_count || setup || cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); +} + +static bool cv2_hash(cv2_context_t *ctx, cv2_span_t span, char hex[CBM_SHA256_HEX_LEN + 1]) { + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + size_t at = 0; + while (at < span.length) { + if (!cv2_poll(ctx)) + return false; + size_t chunk = span.length - at; + if (chunk > 4096) + chunk = 4096; + cbm_sha256_update(&hash, span.data + at, chunk); + at += chunk; + } + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(&hash, digest); + static const char digits[] = "0123456789abcdef"; + for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) { + if (!cv2_step(ctx)) + return false; + hex[i * 2] = digits[digest[i] >> 4]; + hex[i * 2 + 1] = digits[digest[i] & 15]; + } + hex[CBM_SHA256_HEX_LEN] = '\0'; + return cv2_poll(ctx); +} + +static cbm_coverage_parse_status_t cv2_arguments(const void *profiles, size_t profiles_len, + const char *tests, size_t tests_len, + const cbm_coverage_parse_limits_t *limits) { + if (!profiles || (!tests && tests_len) || !limits) + return CBM_COVERAGE_PARSE_INVALID; + if (!limits->max_input_bytes || limits->max_input_bytes > UINT64_MAX / 8 || + !limits->max_items || !limits->max_alloc_bytes || limits->max_ids <= 0) + return CBM_COVERAGE_PARSE_INVALID; + if (profiles_len > (size_t)PTRDIFF_MAX || tests_len > (size_t)PTRDIFF_MAX) + return CBM_COVERAGE_PARSE_LIMIT; + uint64_t total = (uint64_t)profiles_len; + if ((uint64_t)tests_len > UINT64_MAX - total) + return CBM_COVERAGE_PARSE_LIMIT; + total += (uint64_t)tests_len; + if (total > limits->max_input_bytes) + return CBM_COVERAGE_PARSE_LIMIT; + return CBM_COVERAGE_PARSE_OK; +} + +cbm_coverage_parse_status_t cbm_coverage_map_parse_v2( + const void *profiles, size_t profiles_len, const char *tests, size_t tests_len, + const cbm_coverage_parse_limits_t *limits, cbm_coverage_parse_cancel_fn cancelled, + void *cancel_context, cbm_coverage_map_t **out) { + if (!out) + return CBM_COVERAGE_PARSE_INVALID; + *out = NULL; + cbm_coverage_parse_status_t status = cv2_arguments(profiles, profiles_len, tests, tests_len, limits); + if (status != CBM_COVERAGE_PARSE_OK) + return status; + cv2_context_t ctx = {.limits = limits, .cancelled = cancelled, + .cancel_context = cancel_context, .status = CBM_COVERAGE_PARSE_OK}; + cbm_arena_init_lazy(&ctx.arena, 4096); + cv2_span_t profile_span = {profiles, profiles_len}; + cv2_span_t test_span = {(const unsigned char *)tests, tests_len}; + bool ok = cv2_poll(&ctx) && cv2_header(&ctx, profile_span) && + cv2_profile_inventory(&ctx, profile_span) && cv2_test_inventory(&ctx, test_span) && + cv2_prepare(&ctx) && cv2_profiles(&ctx, profile_span) && cv2_tests(&ctx, test_span) && + cv2_sort_tests(&ctx) && cv2_validate_tests(&ctx) && + cv2_hash(&ctx, profile_span, ctx.map->functions_sha256) && + cv2_hash(&ctx, test_span, ctx.map->tests_sha256) && cv2_poll(&ctx); + if (!ok) { + status = ctx.status == CBM_COVERAGE_PARSE_OK ? CBM_COVERAGE_PARSE_FORMAT : ctx.status; + cbm_arena_destroy(&ctx.arena); + return status; + } + ctx.map->arena = ctx.arena; + *out = ctx.map; + return CBM_COVERAGE_PARSE_OK; +} + +cbm_coverage_format_t cbm_coverage_map_format(const cbm_coverage_map_t *map) { + return map ? map->format : CBM_COVERAGE_FORMAT_NONE; +} + +int cbm_coverage_map_id_count(const cbm_coverage_map_t *map) { + return map ? map->function_count : 0; +} + +const char *cbm_coverage_map_identity_sha256(const cbm_coverage_map_t *map) { + return map ? map->functions_sha256 : NULL; +} + +const cbm_coverage_profile_binding_t *cbm_coverage_map_profile_binding(const cbm_coverage_map_t *map) { + return map && map->format == CBM_COVERAGE_FORMAT_PROFILES ? &map->profile_binding : NULL; +} + +const cbm_coverage_profile_t *cbm_coverage_map_profiles(const cbm_coverage_map_t *map, int *count) { + bool profiles = map && map->format == CBM_COVERAGE_FORMAT_PROFILES; + if (count) + *count = profiles ? map->function_count : 0; + return profiles ? map->profiles : NULL; +} diff --git a/src/mcp/test_impact_diff.c b/src/mcp/test_impact_diff.c new file mode 100644 index 000000000..74d493dd8 --- /dev/null +++ b/src/mcp/test_impact_diff.c @@ -0,0 +1,366 @@ +/* + * test_impact_diff.c — the change the test-impact engine selects for: a + * zero-context unified diff, read into files and hunks (test_impact.h, part 2). + * + * The reader trusts the line counts of each hunk header, not the look of a + * line: a removed line whose text is "-- a/x" arrives as "--- a/x", exactly + * like a file header. Whatever it cannot account for makes the diff + * incomplete, and an incomplete diff narrows nothing. + */ +#include "mcp/test_impact.h" + +#include "foundation/arena.h" + +#include +#include + +enum { + DF_ARENA_BLOCK = 64 * 1024, + DF_FIRST_CAP = 16, + DF_GROWTH = 2, + DF_DECIMAL = 10, + DF_LINE_MAX = 1000000000, /* a line number beyond this is not one */ +}; + +struct cbm_diff { + CBMArena arena; /* owns the files, hunks and every line */ + cbm_diff_file_t *files; + int file_count; + int file_cap; + bool complete; +}; + +typedef struct { + const char *p; /* start of the next unread line */ + const char *end; +} df_reader_t; + +typedef struct { + const char *text; + size_t len; /* without the line terminator */ +} df_line_t; + +static bool df_next_line(df_reader_t *rd, df_line_t *line) { + if (rd->p >= rd->end) { + return false; + } + const char *nl = memchr(rd->p, '\n', (size_t)(rd->end - rd->p)); + line->text = rd->p; + line->len = nl ? (size_t)(nl - rd->p) : (size_t)(rd->end - rd->p); + rd->p = nl ? nl + 1 : rd->end; + return true; +} + +static bool df_starts(const df_line_t *line, const char *prefix) { + size_t n = strlen(prefix); + return line->len >= n && memcmp(line->text, prefix, n) == 0; +} + +static bool df_is(const df_line_t *line, const char *text) { + size_t n = strlen(text); + return line->len == n && memcmp(line->text, text, n) == 0; +} + +/* A decimal number at *p, bounded by end. false when there is none or it is + * out of range. */ +static bool df_number(const char **p, const char *end, int *out) { + const char *q = *p; + long value = 0; + while (q < end && *q >= '0' && *q <= '9') { + value = value * DF_DECIMAL + (*q - '0'); + if (value > DF_LINE_MAX) { + return false; + } + q++; + } + if (q == *p) { + return false; + } + *p = q; + *out = (int)value; + return true; +} + +/* "start[,count]" after a '-' or '+' in a hunk header. No count means 1. */ +static bool df_range(const char **p, const char *end, char sign, int *start, int *count) { + if (*p >= end || **p != sign) { + return false; + } + (*p)++; + if (!df_number(p, end, start)) { + return false; + } + *count = 1; + if (*p < end && **p == ',') { + (*p)++; + return df_number(p, end, count); + } + return true; +} + +/* "@@ -a[,b] +c[,d] @@": the old and new line ranges. */ +static bool df_hunk_header(const df_line_t *line, int *old_count, int *new_start, int *new_count) { + static const char open[] = "@@ "; + const char *p = line->text + (sizeof(open) - 1); + const char *end = line->text + line->len; + int old_start = 0; + if (!df_starts(line, open) || !df_range(&p, end, '-', &old_start, old_count)) { + return false; + } + if (p >= end || *p != ' ') { + return false; + } + p++; + if (!df_range(&p, end, '+', new_start, new_count)) { + return false; + } + return (size_t)(end - p) >= 3 && memcmp(p, " @@", 3) == 0; +} + +/* The path of "diff --git a/P b/P". Without renames both sides name the same + * file, so the line is the path twice; that also reads a path with " b/" in + * it. NULL for anything else: git quotes a path with a quote, a backslash or + * a control character in it, and that form is not read. */ +static const char *df_header_path(cbm_diff_t *d, const df_line_t *line) { + static const char open[] = "diff --git a/"; + static const char mid[] = " b/"; + size_t open_len = sizeof(open) - 1; + size_t mid_len = sizeof(mid) - 1; + if (!df_starts(line, open)) { + return NULL; + } + size_t rest = line->len - open_len; + if (rest < mid_len + 2 || (rest - mid_len) % 2 != 0) { + return NULL; + } + size_t path_len = (rest - mid_len) / 2; + const char *left = line->text + open_len; + const char *right = left + path_len + mid_len; + if (memcmp(left + path_len, mid, mid_len) != 0 || memcmp(left, right, path_len) != 0) { + return NULL; + } + return cbm_arena_strndup(&d->arena, left, path_len); +} + +static cbm_diff_file_t *df_add_file(cbm_diff_t *d, const char *path) { + if (d->file_count == d->file_cap) { + int cap = d->file_cap > 0 ? d->file_cap * DF_GROWTH : DF_FIRST_CAP; + cbm_diff_file_t *grown = cbm_arena_alloc(&d->arena, (size_t)cap * sizeof(*grown)); + if (!grown) { + return NULL; + } + if (d->file_count > 0) { + memcpy(grown, d->files, (size_t)d->file_count * sizeof(*grown)); + } + d->files = grown; + d->file_cap = cap; + } + cbm_diff_file_t *file = &d->files[d->file_count++]; + *file = (cbm_diff_file_t){.path = path}; + return file; +} + +/* The hunks of the file being read; handed to the file when it ends. */ +typedef struct { + cbm_diff_hunk_t *items; + int count; + int cap; +} df_hunks_t; + +static cbm_diff_hunk_t *df_add_hunk(cbm_diff_t *d, df_hunks_t *hunks) { + if (hunks->count == hunks->cap) { + int cap = hunks->cap > 0 ? hunks->cap * DF_GROWTH : DF_FIRST_CAP; + cbm_diff_hunk_t *grown = cbm_arena_alloc(&d->arena, (size_t)cap * sizeof(*grown)); + if (!grown) { + return NULL; + } + if (hunks->count > 0) { + memcpy(grown, hunks->items, (size_t)hunks->count * sizeof(*grown)); + } + hunks->items = grown; + hunks->cap = cap; + } + cbm_diff_hunk_t *hunk = &hunks->items[hunks->count++]; + memset(hunk, 0, sizeof(*hunk)); + return hunk; +} + +typedef enum { DF_OK = 0, DF_SHORT, DF_NO_MEMORY } df_status_t; + +/* The body of one hunk: `old_count` removed lines and `new_count` added ones, + * in the counts the header gave. A "\ No newline at end of file" note may + * follow any of them. DF_SHORT when the text holds fewer; the reader then + * stands on the line that did not fit. */ +static df_status_t df_hunk_body(cbm_diff_t *d, df_reader_t *rd, cbm_diff_hunk_t *hunk, + int old_count, int new_count) { + /* Every line takes at least its marker and a terminator, so a count the + * rest of the text cannot hold is not worth an allocation. */ + size_t room = (size_t)(rd->end - rd->p); + if ((size_t)old_count + (size_t)new_count > room) { + return DF_SHORT; + } + const char **removed = cbm_arena_alloc(&d->arena, ((size_t)old_count + 1) * sizeof(*removed)); + const char **added = cbm_arena_alloc(&d->arena, ((size_t)new_count + 1) * sizeof(*added)); + if (!removed || !added) { + return DF_NO_MEMORY; + } + hunk->removed = removed; + hunk->added = added; + while (hunk->removed_count < old_count || hunk->added_count < new_count) { + df_reader_t at = *rd; + df_line_t line; + if (!df_next_line(rd, &line)) { + return DF_SHORT; + } + if (line.len > 0 && line.text[0] == '\\') { + continue; + } + bool is_removed = hunk->removed_count < old_count && line.len > 0 && line.text[0] == '-'; + bool is_added = + !is_removed && hunk->removed_count == old_count && line.len > 0 && line.text[0] == '+'; + if (!is_removed && !is_added) { + *rd = at; + return DF_SHORT; + } + const char *copy = cbm_arena_strndup(&d->arena, line.text + 1, line.len - 1); + if (!copy) { + return DF_NO_MEMORY; + } + if (is_removed) { + removed[hunk->removed_count++] = copy; + } else { + added[hunk->added_count++] = copy; + } + } + /* The note about a missing final newline can also come last. */ + df_reader_t at = *rd; + df_line_t line; + if (df_next_line(rd, &line) && !(line.len > 0 && line.text[0] == '\\')) { + *rd = at; + } + return DF_OK; +} + +/* A line of a file's header, before its first hunk. false = not one the reader + * knows. */ +static bool df_file_header_line(const df_line_t *line, cbm_diff_file_t *file) { + if (df_starts(line, "new file mode ")) { + file->created = true; + } else if (df_starts(line, "deleted file mode ")) { + file->deleted = true; + } else if (df_starts(line, "Binary files ") || df_is(line, "GIT binary patch")) { + file->binary = true; + } else if (!df_starts(line, "index ") && !df_starts(line, "old mode ") && + !df_starts(line, "new mode ") && !df_starts(line, "--- ") && + !df_starts(line, "+++ ")) { + return false; + } + return true; +} + +static int df_cmp_file(const void *left, const void *right) { + const cbm_diff_file_t *a = left; + const cbm_diff_file_t *b = right; + return strcmp(a->path, b->path); +} + +/* false = out of memory. */ +static bool df_read(cbm_diff_t *d, df_reader_t *rd) { + cbm_diff_file_t *file = NULL; + df_hunks_t hunks = {0}; + bool in_hunks = false; /* past the file's header lines */ + df_line_t line; + while (df_next_line(rd, &line)) { + if (df_starts(&line, "diff ")) { + if (file) { + file->hunks = hunks.items; + file->hunk_count = hunks.count; + } + hunks = (df_hunks_t){0}; + in_hunks = false; + file = NULL; + const char *path = df_header_path(d, &line); + if (!path) { + d->complete = false; /* a header this reader does not read */ + continue; + } + file = df_add_file(d, path); + if (!file) { + return false; + } + continue; + } + int old_count = 0; + int new_start = 0; + int new_count = 0; + if (file && df_hunk_header(&line, &old_count, &new_start, &new_count)) { + in_hunks = true; + cbm_diff_hunk_t *hunk = df_add_hunk(d, &hunks); + if (!hunk) { + return false; + } + hunk->start = new_start; + hunk->count = new_count; + df_status_t status = df_hunk_body(d, rd, hunk, old_count, new_count); + if (status == DF_NO_MEMORY) { + return false; + } + d->complete = d->complete && status == DF_OK; + continue; + } + if (line.len == 0 && !file) { + continue; /* blank lines around the diff */ + } + if (!file || in_hunks || !df_file_header_line(&line, file)) { + d->complete = false; + } + } + if (file) { + file->hunks = hunks.items; + file->hunk_count = hunks.count; + } + return true; +} + +cbm_diff_t *cbm_diff_parse(const char *text, size_t len) { + if (!text && len > 0) { + return NULL; + } + CBMArena arena; + cbm_arena_init_sized(&arena, DF_ARENA_BLOCK); + cbm_diff_t *d = cbm_arena_calloc(&arena, sizeof(*d)); + if (!d) { + cbm_arena_destroy(&arena); + return NULL; + } + d->arena = arena; /* the diff lives in its own arena */ + d->complete = true; + df_reader_t rd = {.p = text, .end = text ? text + len : text}; + if (!df_read(d, &rd)) { + cbm_diff_free(d); + return NULL; + } + if (d->file_count > 1) { + qsort(d->files, (size_t)d->file_count, sizeof(*d->files), df_cmp_file); + } + return d; +} + +void cbm_diff_free(cbm_diff_t *d) { + if (!d) { + return; + } + CBMArena arena = d->arena; /* d itself is inside it */ + cbm_arena_destroy(&arena); +} + +const cbm_diff_file_t *cbm_diff_files(const cbm_diff_t *d, int *count) { + if (count) { + *count = d ? d->file_count : 0; + } + return d ? d->files : NULL; +} + +bool cbm_diff_complete(const cbm_diff_t *d) { + return d && d->complete; +} diff --git a/src/mcp/test_impact_git.c b/src/mcp/test_impact_git.c new file mode 100644 index 000000000..75620e2f8 --- /dev/null +++ b/src/mcp/test_impact_git.c @@ -0,0 +1,2041 @@ +#include "mcp/test_impact_git.h" +#include "mcp/test_impact.h" + +#include "foundation/arena.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/platform.h" +#include "foundation/secure_random.h" +#include "foundation/subprocess.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include "foundation/win_utf8.h" +#else +#include +#include +#include +#endif + +enum { + GF_ARENA_BLOCK = 8192, + GF_PATH_CAP = 4096, + GF_ARG_CAP = 96, + GF_FLAG_CAP = 32, + GF_INPUT_MAX = 65536, + GF_POLL_US = 10000 +}; + +typedef struct { + size_t arena_limit, arena_used; + size_t input_limit, input_used; +} gf_batch_budget_t; + +typedef struct { + const char *commit; + cbm_git_tree_inventory_t inventory; +} gf_inventory_cache_t; + +struct cbm_git_facts { + CBMArena arena; + cbm_git_facts_options_t options; + cbm_git_facts_identity_t identity; + const char + *command_git_dir; /* worktree directory while resolving refs, common directory later */ + const char *git_pins[GF_FLAG_CAP]; + int git_pin_count; + const char *diff_flags[GF_FLAG_CAP]; + int diff_flag_count; + const char *graft_paths[2]; + const char *shallow_paths[2]; + unsigned commands; + size_t output_bytes; + cbm_git_facts_error_t stopped; + gf_batch_budget_t *batch_budget; /* borrowed stack state during one serialized call */ + gf_inventory_cache_t inventory_cache[2]; +}; + +typedef struct { + cbm_git_bytes_t out; + cbm_git_bytes_t err; + int exit_code; +} gf_result_t; + +typedef struct { + char directory[GF_PATH_CAP]; + char out[GF_PATH_CAP]; + char err[GF_PATH_CAP]; + char in[GF_PATH_CAP]; +} gf_capture_t; + +static void gf_error_clear(cbm_git_facts_error_t *error) { + memset(error, 0, sizeof(*error)); + error->exit_code = -1; +} + +static bool gf_error(cbm_git_facts_error_t *error, cbm_git_facts_status_t status, + const char *message) { + error->status = status; + (void)snprintf(error->diagnostic, sizeof(error->diagnostic), "%s", message); + return false; +} + +/* Exhausting any query budget makes the handle terminal. In particular, an + * observed over-cap capture cannot be retried without charging its bytes. */ +static bool gf_limit(cbm_git_facts_t *facts, cbm_git_facts_error_t *error, const char *message) { + gf_error(error, CBM_GIT_FACTS_LIMIT, message); + facts->stopped = *error; + return false; +} + +static size_t gf_input_length(const char *text) { + if (!text) + return SIZE_MAX; + size_t n = 0; + while (n <= GF_INPUT_MAX && text[n]) + n++; + return n <= GF_INPUT_MAX ? n : SIZE_MAX; +} + +static bool gf_absolute(const char *path) { + if (!path || !path[0]) + return false; +#ifdef _WIN32 + bool has_prefix = path[1] && path[2]; + bool drive = has_prefix && + ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && + path[1] == ':' && (path[2] == '/' || path[2] == '\\'); + bool unc = has_prefix && (path[0] == '/' || path[0] == '\\') && + (path[1] == '/' || path[1] == '\\') && path[2] != '/' && path[2] != '\\'; + return drive || unc; +#else + return path[0] == '/'; +#endif +} + +static bool gf_native_executable(const char *path) { + if (!gf_absolute(path)) + return false; +#ifdef _WIN32 + size_t n = strlen(path); + return n >= 4 && path[n - 4] == '.' && (path[n - 3] == 'e' || path[n - 3] == 'E') && + (path[n - 2] == 'x' || path[n - 2] == 'X') && (path[n - 1] == 'e' || path[n - 1] == 'E'); +#else + return true; /* trusted internal resolution, not an arbitrary user program */ +#endif +} + +static bool gf_gate(cbm_git_facts_t *facts, cbm_git_facts_error_t *error) { + if (facts->stopped.status != CBM_GIT_FACTS_OK) { + *error = facts->stopped; + return false; + } + if (facts->options.cancelled && facts->options.cancelled(facts->options.cancel_context)) { + gf_error(error, CBM_GIT_FACTS_CANCELLED, "Git facts request cancelled"); + facts->stopped = *error; + return false; + } + if (cbm_now_ms() >= facts->options.deadline_ms) + return gf_error(error, CBM_GIT_FACTS_DEADLINE, "Git facts deadline expired"); + return true; +} + +/* A batch charges logical requests before allocating, including allocations + * in inherited guards. Legacy calls retain their original allocation policy. */ +static void *gf_allocate(cbm_git_facts_t *facts, CBMArena *arena, size_t size, + cbm_git_facts_error_t *error) { + gf_batch_budget_t *budget = facts->batch_budget; + if (budget) { + if (!gf_gate(facts, error)) + return NULL; + if (size > budget->arena_limit - budget->arena_used) { + gf_limit(facts, error, "Git batch arena limit exceeded"); + return NULL; + } + budget->arena_used += size; + } + void *out = cbm_arena_alloc(arena, size); + if (!out) { + gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain Git facts data"); + return NULL; + } + if (budget && !gf_gate(facts, error)) + return NULL; + return out; +} + +static bool gf_metadata_equal(cbm_git_facts_t *facts, const char *a, const char *b, + bool *equal, cbm_git_facts_error_t *error) { + if (!facts->batch_budget) { + *equal = strcmp(a, b) == 0; + return true; + } + size_t at = 0; + for (;;) { + if ((at & 65535u) == 0 && !gf_gate(facts, error)) + return false; + unsigned char x = (unsigned char)a[at], y = (unsigned char)b[at]; + if (x != y || !x) { + *equal = x == y; + return true; + } + at++; + } +} + +/* These are compile-time project constants containing simple space-separated + * argv tokens, not user command text. No shell quoting or expansion is used. */ +static bool gf_split_flags(cbm_git_facts_t *facts, const char *literal, const char **tokens, + int *count) { + *count = 0; + char *copy = cbm_arena_strdup(&facts->arena, literal); + if (!copy) + return false; + char *p = copy; + while (*p) { + while (*p == ' ') + p++; + if (!*p) + break; + if (*count == GF_FLAG_CAP) + return false; + tokens[(*count)++] = p; + while (*p && *p != ' ') { + if (*p == '\t' || *p == '\n' || *p == '\r' || *p == '\'' || *p == '"') + return false; + p++; + } + if (*p) + *p++ = '\0'; + } + return true; +} + +#ifdef _WIN32 +/* Same owner-only, protected, inheritable ACL as compat.c's private helper, + * but deliberately no inherited-security fallback. TOKEN_USER storage is + * arena-owned; Windows owns the ACL returned by SetEntriesInAclW. */ +static bool gf_private_directory(cbm_git_facts_t *facts, char path[GF_PATH_CAP], + cbm_git_facts_error_t *error) { + CBMArena scratch; + cbm_arena_init_lazy(&scratch, 1024); + HANDLE token = NULL; + TOKEN_USER *user = NULL; + PACL acl = NULL; + DWORD needed = 0; + bool created = false; + const char *tmp = cbm_tmpdir(); + if (!gf_absolute(tmp)) { + gf_error(error, CBM_GIT_FACTS_IO, "An absolute private temp location is required"); + goto done; + } + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token) || + GetTokenInformation(token, TokenUser, NULL, 0, &needed) || + GetLastError() != ERROR_INSUFFICIENT_BUFFER || needed == 0 || needed > 65536) { + gf_error(error, CBM_GIT_FACTS_IO, "Cannot establish private capture owner"); + goto done; + } + user = gf_allocate(facts, &scratch, needed, error); + if (!user) { + if (error->status == CBM_GIT_FACTS_OOM) + gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain private capture owner"); + goto done; + } + if (!GetTokenInformation(token, TokenUser, user, needed, &needed) || !user->User.Sid || + !IsValidSid(user->User.Sid)) { + gf_error(error, CBM_GIT_FACTS_IO, "Cannot establish private capture owner"); + goto done; + } + EXPLICIT_ACCESSW access; + memset(&access, 0, sizeof(access)); + access.grfAccessPermissions = GENERIC_ALL; + access.grfAccessMode = SET_ACCESS; + access.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT; + access.Trustee.TrusteeForm = TRUSTEE_IS_SID; + access.Trustee.TrusteeType = TRUSTEE_IS_USER; + access.Trustee.ptstrName = (LPWSTR)user->User.Sid; + SECURITY_DESCRIPTOR descriptor; + if (SetEntriesInAclW(1, &access, NULL, &acl) != ERROR_SUCCESS || + !InitializeSecurityDescriptor(&descriptor, SECURITY_DESCRIPTOR_REVISION) || + !SetSecurityDescriptorDacl(&descriptor, TRUE, acl, FALSE) || + !SetSecurityDescriptorOwner(&descriptor, user->User.Sid, FALSE) || + !SetSecurityDescriptorControl(&descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED)) { + gf_error(error, CBM_GIT_FACTS_IO, "Cannot establish private capture permissions"); + goto done; + } + SECURITY_ATTRIBUTES attributes = {sizeof(attributes), &descriptor, FALSE}; + static const char hex[] = "0123456789abcdef"; + for (int attempt = 0; attempt < 16; attempt++) { + if (!gf_gate(facts, error)) + goto done; + unsigned char random[16]; + char suffix[33]; + if (!cbm_secure_random(random, sizeof(random))) + break; + for (size_t i = 0; i < sizeof(random); i++) { + suffix[i * 2] = hex[random[i] >> 4]; + suffix[i * 2 + 1] = hex[random[i] & 15]; + } + suffix[32] = '\0'; + int n = snprintf(path, GF_PATH_CAP, "%s/cbm-git-facts-%s", tmp, suffix); + if (n < 0 || n >= GF_PATH_CAP) + break; + wchar_t *wide = cbm_path_to_wide(path); + if (!wide) + break; + created = CreateDirectoryW(wide, &attributes) != 0; + DWORD why = GetLastError(); + free(wide); /* existing UTF-8 conversion helper owns this buffer */ + if (created || (why != ERROR_ALREADY_EXISTS && why != ERROR_FILE_EXISTS)) + break; + } + if (!created) + gf_error(error, CBM_GIT_FACTS_IO, "Private capture directory creation failed"); +done: + if (acl) + (void)LocalFree(acl); + if (token) + (void)CloseHandle(token); + cbm_arena_destroy(&scratch); + if (!created) + path[0] = '\0'; + return created; +} +#else +static bool gf_private_directory(cbm_git_facts_t *facts, char path[GF_PATH_CAP], + cbm_git_facts_error_t *error) { + if (!gf_gate(facts, error)) + return false; + int n = snprintf(path, GF_PATH_CAP, "%s/cbm-git-facts-XXXXXX", cbm_tmpdir()); + if (n < 0 || n >= GF_PATH_CAP || !cbm_mkdtemp(path)) { + path[0] = '\0'; + return gf_error(error, CBM_GIT_FACTS_IO, "Private capture directory creation failed"); + } + return true; +} +#endif + +static bool gf_capture_cleanup(gf_capture_t *capture) { + bool ok = true; + if (capture->out[0] && cbm_unlink(capture->out) != 0 && errno != ENOENT) + ok = false; + if (capture->err[0] && cbm_unlink(capture->err) != 0 && errno != ENOENT) + ok = false; + if (capture->in[0] && cbm_unlink(capture->in) != 0 && errno != ENOENT) + ok = false; + if (capture->directory[0] && cbm_rmdir(capture->directory) != 0 && errno != ENOENT) + ok = false; + return ok; +} + +static bool gf_capture_create(cbm_git_facts_t *facts, gf_capture_t *capture, + cbm_git_facts_error_t *error) { + memset(capture, 0, sizeof(*capture)); + if (!gf_private_directory(facts, capture->directory, error)) + return false; + int a = snprintf(capture->out, sizeof(capture->out), "%s/stdout", capture->directory); + int b = snprintf(capture->err, sizeof(capture->err), "%s/stderr", capture->directory); + if (a < 0 || b < 0 || (size_t)a >= sizeof(capture->out) || (size_t)b >= sizeof(capture->err)) { + capture->out[0] = capture->err[0] = '\0'; + (void)gf_capture_cleanup(capture); + return gf_error(error, CBM_GIT_FACTS_IO, "Capture path exceeds supported length"); + } + return true; +} + +/* The private writer is closed before spawn. Only canonical OID lines reach + * this transport; the child opens its independent read-only binary handle. */ +static bool gf_capture_input(cbm_git_facts_t *facts, gf_capture_t *capture, + const cbm_git_bytes_t *input, cbm_git_facts_error_t *error) { + if (!input) + return true; + gf_batch_budget_t *budget = facts->batch_budget; + if (!budget || (input->length && !input->data)) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid internal Git batch input"); + if (!gf_gate(facts, error)) + return false; + if (input->length > budget->input_limit - budget->input_used) + return gf_limit(facts, error, "Git batch stdin limit exceeded"); + int n = snprintf(capture->in, sizeof(capture->in), "%s/stdin", capture->directory); + if (n < 0 || (size_t)n >= sizeof(capture->in)) { + capture->in[0] = '\0'; + return gf_error(error, CBM_GIT_FACTS_IO, "Batch input path exceeds supported length"); + } + FILE *file = cbm_fopen(capture->in, "wb"); + if (!file) + return gf_error(error, CBM_GIT_FACTS_IO, "Cannot create private Git batch input"); + bool ok = true; + size_t at = 0; + while (at < input->length) { + if (!gf_gate(facts, error)) { + ok = false; + break; + } + size_t chunk = input->length - at; + if (chunk > 65536) + chunk = 65536; + size_t written = fwrite(input->data + at, 1, chunk, file); + at += written; + budget->input_used += written; + if (written != chunk) { + ok = gf_error(error, CBM_GIT_FACTS_IO, "Cannot write private Git batch input"); + break; + } + } + if (fclose(file) != 0 && ok) + ok = gf_error(error, CBM_GIT_FACTS_IO, "Cannot close private Git batch input"); + return ok && gf_gate(facts, error); +} + +static bool gf_capture_sizes(cbm_git_facts_t *facts, const gf_capture_t *capture, size_t *out_size, + size_t *err_size, cbm_git_facts_error_t *error) { + int64_t a = cbm_file_size(capture->out), b = cbm_file_size(capture->err); + if (a < 0 || b < 0) + return gf_error(error, CBM_GIT_FACTS_IO, "Cannot inspect captured output"); + if ((uint64_t)a > facts->options.stdout_limit || (uint64_t)b > facts->options.stderr_limit || + (uint64_t)a > SIZE_MAX - (uint64_t)b) + return gf_limit(facts, error, "Git command output limit exceeded"); + size_t total = (size_t)a + (size_t)b; + if (facts->output_bytes > facts->options.total_output_limit || + total > facts->options.total_output_limit - facts->output_bytes) + return gf_limit(facts, error, "Git request output limit exceeded"); + if (out_size) + *out_size = (size_t)a; + if (err_size) + *err_size = (size_t)b; + return true; +} + +static bool gf_read_capture(cbm_git_facts_t *facts, const char *path, size_t length, + cbm_git_bytes_t *out, cbm_git_facts_error_t *error) { + *out = (cbm_git_bytes_t){0}; + if (length == SIZE_MAX) + return gf_limit(facts, error, "Captured output is too large"); + unsigned char *bytes = gf_allocate(facts, &facts->arena, length + 1, error); + if (!bytes) { + if (error->status == CBM_GIT_FACTS_OOM) + gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain captured output"); + return false; + } + FILE *file = cbm_fopen(path, "rb"); + if (!file) + return gf_error(error, CBM_GIT_FACTS_IO, "Cannot read captured output"); + bool ok = true; + size_t used = 0; + while (used < length) { + if (!gf_gate(facts, error)) { + ok = false; + break; + } + size_t chunk = length - used; + if (chunk > 65536) + chunk = 65536; + size_t got = fread(bytes + used, 1, chunk, file); + used += got; + if (got != chunk) { + ok = gf_error(error, CBM_GIT_FACTS_IO, "Captured output was truncated or unreadable"); + break; + } + } + if (ok && (fgetc(file) != EOF || ferror(file))) + ok = gf_error(error, CBM_GIT_FACTS_IO, "Captured output changed while being read"); + if (fclose(file) != 0 && ok) + ok = gf_error(error, CBM_GIT_FACTS_IO, "Cannot close captured output"); + if (ok) + ok = gf_gate(facts, error); + if (!ok) + return false; + bytes[length] = '\0'; /* convenience sentinel; length remains authoritative */ + *out = (cbm_git_bytes_t){bytes, length}; + return true; +} + +static void gf_command_diagnostic(cbm_git_facts_error_t *error, const gf_result_t *result) { + error->exit_code = result->exit_code; + size_t at = strlen(error->diagnostic); + if (result->err.length && at + 2 < sizeof(error->diagnostic)) { + error->diagnostic[at++] = ':'; + error->diagnostic[at++] = ' '; + } + for (size_t i = 0; i < result->err.length && at + 1 < sizeof(error->diagnostic); i++) { + unsigned char c = result->err.data[i]; + error->diagnostic[at++] = c >= 32 && c < 127 ? (char)c : ' '; + } + error->diagnostic[at] = '\0'; +} + +/* Observed file-size limits request cancellation. They are not instantaneous + * disk quotas; final retained bytes are bounded independently after quiescence. */ +static bool gf_run_input(cbm_git_facts_t *facts, const char *const *arguments, int count, + bool allow_exit_one, const cbm_git_bytes_t *input, + gf_result_t *out, cbm_git_facts_error_t *error) { + memset(out, 0, sizeof(*out)); + out->exit_code = -1; + if (!gf_gate(facts, error)) + return false; + if (facts->commands >= facts->options.command_limit) + return gf_limit(facts, error, "Git command-count limit exceeded"); + const char *argv[GF_ARG_CAP]; + int argc = 0; + const char *prefix[] = {facts->options.git_executable, + "--no-replace-objects", + "--no-pager", + "--literal-pathspecs", + "--no-lazy-fetch", + "-C", + facts->options.root, + "-c", + "protocol.allow=never", + "-c", + "core.fsmonitor=false"}; + size_t prefix_count = sizeof(prefix) / sizeof(prefix[0]); + size_t directory_count = facts->command_git_dir ? 4 : 0; + if (count < 1 || count >= GF_ARG_CAP || + (size_t)count + prefix_count + directory_count + (size_t)facts->git_pin_count + 1 > + GF_ARG_CAP) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid internal Git command"); + for (size_t i = 0; i < prefix_count; i++) + argv[argc++] = prefix[i]; + if (facts->command_git_dir) { + argv[argc++] = "--git-dir"; + argv[argc++] = facts->command_git_dir; + argv[argc++] = "--work-tree"; + argv[argc++] = facts->identity.root; + } + for (int i = 0; i < facts->git_pin_count; i++) + argv[argc++] = facts->git_pins[i]; + for (int i = 0; i < count; i++) + argv[argc++] = arguments[i]; + argv[argc] = NULL; + gf_capture_t capture; + if (!gf_capture_create(facts, &capture, error)) + return false; + bool ok = gf_gate(facts, error) && gf_capture_input(facts, &capture, input, error); + cbm_subprocess_t *process = NULL; + cbm_proc_result_t result = {0}; + if (ok) { + cbm_proc_opts_t options = {.bin = facts->options.git_executable, + .argv = argv, + .log_file = capture.err, + .quiet_timeout_ms = 0, + .cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS, + .strip_git_repo_env = true, + .stdout_file = capture.out, + .stdin_file = input ? capture.in : NULL}; + facts->commands++; + if (cbm_subprocess_spawn(&options, &process) != 0) + ok = gf_error(error, CBM_GIT_FACTS_SPAWN, "Cannot spawn native Git process"); + } + if (process) { + for (;;) { + if (ok) + ok = gf_gate(facts, error) && gf_capture_sizes(facts, &capture, NULL, NULL, error); + if (!ok) + (void)cbm_subprocess_request_cancel(process); + cbm_proc_poll_t polled = cbm_subprocess_poll(process, &result); + if (polled == CBM_PROC_POLL_TERMINAL) + break; + if (polled == CBM_PROC_POLL_ERROR && ok) + ok = gf_error(error, CBM_GIT_FACTS_SUPERVISION, "Git process supervision failed"); + /* Valid owned handles never have an ERROR transition. Continue + * driving cancellation rather than abandon a possibly live tree. */ + cbm_usleep(GF_POLL_US); + } + cbm_subprocess_destroy(process); + if (!result.tree_quiesced || result.supervision_failed) { + gf_error(error, CBM_GIT_FACTS_SUPERVISION, "Git process tree did not quiesce"); + (void)snprintf(error->diagnostic, sizeof(error->diagnostic), + "Git containment failed; retained capture directory: %.400s", + capture.directory); + facts->stopped = *error; + return false; /* do not claim files with possible writers were cleaned */ + } + if (ok && (result.cancellation_requested || result.forced)) + ok = gf_error(error, CBM_GIT_FACTS_CANCELLED, "Git process was cancelled"); + } + size_t stdout_size = 0, stderr_size = 0; + if (ok) + ok = gf_gate(facts, error) && + gf_capture_sizes(facts, &capture, &stdout_size, &stderr_size, error); + gf_result_t captured = {.exit_code = result.exit_code}; + if (ok) { + facts->output_bytes += stdout_size + stderr_size; + ok = gf_read_capture(facts, capture.err, stderr_size, &captured.err, error) && + gf_read_capture(facts, capture.out, stdout_size, &captured.out, error); + } + /* New batches classify a known command failure before cleanup. Cleanup + * must not replace an established error; legacy calls keep their ordering. */ + if (ok && facts->batch_budget && + !(result.outcome == CBM_PROC_CLEAN && result.exit_code == 0) && + !(allow_exit_one && result.outcome == CBM_PROC_EXIT_NONZERO && result.exit_code == 1)) { + ok = gf_error(error, CBM_GIT_FACTS_COMMAND, "Git command failed"); + gf_command_diagnostic(error, &captured); + } + bool cleaned = gf_capture_cleanup(&capture); + if (!cleaned && ok) { + ok = gf_error(error, CBM_GIT_FACTS_IO, "Cannot clean captured Git output"); + (void)snprintf(error->diagnostic, sizeof(error->diagnostic), + "Cannot clean retained capture directory: %.400s", capture.directory); + } + if (ok) + ok = gf_gate(facts, error); + if (!ok) + return false; + bool success = result.outcome == CBM_PROC_CLEAN && result.exit_code == 0; + bool one = allow_exit_one && result.outcome == CBM_PROC_EXIT_NONZERO && result.exit_code == 1; + if (!success && !one) { + gf_error(error, CBM_GIT_FACTS_COMMAND, "Git command failed"); + gf_command_diagnostic(error, &captured); + return false; + } + *out = captured; + return true; +} + +static bool gf_run(cbm_git_facts_t *facts, const char *const *arguments, int count, + bool allow_exit_one, gf_result_t *out, cbm_git_facts_error_t *error) { + return gf_run_input(facts, arguments, count, allow_exit_one, NULL, out, error); +} + +static bool gf_line(const cbm_git_bytes_t *bytes, const unsigned char **text, size_t *length) { + if (!bytes->length || bytes->data[bytes->length - 1] != '\n') + return false; + size_t n = bytes->length - 1; +#ifdef _WIN32 + if (n && bytes->data[n - 1] == '\r') + n--; +#endif + if (memchr(bytes->data, '\0', n) || memchr(bytes->data, '\n', n) || + memchr(bytes->data, '\r', n)) + return false; + *text = bytes->data; + *length = n; + return true; +} + +static bool gf_active_line(cbm_git_facts_t *facts, const cbm_git_bytes_t *bytes, + const unsigned char **text, size_t *length, + cbm_git_facts_error_t *error) { + if (!facts->batch_budget) + return gf_line(bytes, text, length) || + gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git metadata line"); + if (!bytes->length || bytes->data[bytes->length - 1] != '\n') + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git metadata line"); + size_t n = bytes->length - 1; +#ifdef _WIN32 + if (n && bytes->data[n - 1] == '\r') + n--; +#endif + for (size_t at = 0; at < n; at++) { + if ((at & 65535u) == 0 && !gf_gate(facts, error)) + return false; + unsigned char c = bytes->data[at]; + if (!c || c == '\n' || c == '\r') + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git metadata line"); + } + *text = bytes->data; + *length = n; + return gf_gate(facts, error); +} + +static bool gf_owned_line(cbm_git_facts_t *facts, const cbm_git_bytes_t *bytes, const char **out, + cbm_git_facts_error_t *error) { + const unsigned char *text; + size_t n; + if (!gf_active_line(facts, bytes, &text, &n, error)) + return false; + if (!facts->batch_budget) { + *out = cbm_arena_strndup(&facts->arena, (const char *)text, n); + return *out || gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain Git metadata"); + } + if (n == SIZE_MAX) + return gf_limit(facts, error, "Git metadata allocation overflow"); + char *copy = gf_allocate(facts, &facts->arena, n + 1, error); + if (!copy) + return false; + for (size_t at = 0; at < n;) { + if (!gf_gate(facts, error)) + return false; + size_t chunk = n - at; + if (chunk > 65536) + chunk = 65536; + memcpy(copy + at, text + at, chunk); + at += chunk; + } + copy[n] = '\0'; + if (!gf_gate(facts, error)) + return false; + *out = copy; + return true; +} + +static bool gf_oid(char out[65], const unsigned char *text, size_t length, unsigned width) { + if ((width != 40 && width != 64) || length != width) + return false; + for (unsigned i = 0; i < width; i++) { + unsigned char c = text[i]; + if (c >= 'A' && c <= 'F') + c = (unsigned char)(c + ('a' - 'A')); + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) + return false; + out[i] = (char)c; + } + out[width] = '\0'; + return true; +} + +static bool gf_oid_line(cbm_git_facts_t *facts, const cbm_git_bytes_t *bytes, char out[65], + cbm_git_facts_error_t *error) { + const unsigned char *text; + size_t n; + if (!gf_line(bytes, &text, &n) || !gf_oid(out, text, n, facts->identity.oid_hex_length)) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Git returned an invalid full object ID"); + return true; +} + +static bool gf_resolve(cbm_git_facts_t *facts, const char *ref, char out[65], + cbm_git_facts_error_t *error) { + size_t n = gf_input_length(ref); + if (n == SIZE_MAX || n == 0 || ref[0] == '-') + return gf_error(error, CBM_GIT_FACTS_INVALID, "A bounded commit reference is required"); + char *peel = cbm_arena_alloc(&facts->arena, n + sizeof("^{commit}")); + if (!peel) + return gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain commit reference"); + memcpy(peel, ref, n); + memcpy(peel + n, "^{commit}", sizeof("^{commit}")); + const char *args[] = {"rev-parse", "--verify", "--end-of-options", peel}; + gf_result_t result; + return gf_run(facts, args, 4, false, &result, error) && + gf_oid_line(facts, &result.out, out, error); +} + +static bool gf_rev_metadata(cbm_git_facts_t *facts, const char *option, bool absolute, + const char **out, cbm_git_facts_error_t *error) { + const char *args[] = {"rev-parse", "--path-format=absolute", option}; + gf_result_t result; + if (!gf_run(facts, args, 3, false, &result, error) || + !gf_owned_line(facts, &result.out, out, error)) + return false; + if (absolute && !gf_absolute(*out)) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Git metadata path is not absolute"); + return true; +} + +/* Metadata queries call gf_run directly; the executor must not call this guard. + * This checks persistent topology consistency, not an atomic filesystem snapshot. + * Explicit directory selection prevents a root gitfile from redirecting later + * commands; the check also rejects a new commondir indirection at that selection. */ +static bool gf_topology_guard(cbm_git_facts_t *facts, cbm_git_facts_error_t *error) { + if (!gf_gate(facts, error)) + return false; + const char *common_dir; + if (!facts->command_git_dir || !facts->identity.common_dir) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Git directory identity is not established"); + if (!gf_rev_metadata(facts, "--git-common-dir", true, &common_dir, error)) + return false; + bool equal; + if (!gf_metadata_equal(facts, common_dir, facts->identity.common_dir, &equal, error)) + return false; + if (!equal) + return gf_error(error, CBM_GIT_FACTS_IDENTITY_MISMATCH, + "Git common directory identity changed"); + return gf_gate(facts, error); +} + +/* --no-replace-objects is not treated as a promise about legacy info/grafts. + * Check graft and shallow state in both per-worktree and common Git locations. + * No graft parsing or environment mutation; uncertain files block proof. */ +static bool gf_history_file_absent_or_empty(const char *path, cbm_git_facts_error_t *error) { +#ifdef _WIN32 + wchar_t *wide = cbm_path_to_wide(path); + if (!wide) + return gf_error(error, CBM_GIT_FACTS_IO, "Cannot inspect Git history override path"); + HANDLE file = + CreateFileW(wide, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); + DWORD open_error = GetLastError(); + free(wide); + if (file == INVALID_HANDLE_VALUE) { + if (open_error == ERROR_FILE_NOT_FOUND || open_error == ERROR_PATH_NOT_FOUND) + return true; + return gf_error(error, CBM_GIT_FACTS_IO, "Cannot inspect Git history override state"); + } + BY_HANDLE_FILE_INFORMATION info; + bool ok = + GetFileType(file) == FILE_TYPE_DISK && GetFileInformationByHandle(file, &info) != 0 && + (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + info.nFileSizeHigh == 0 && info.nFileSizeLow == 0; + bool closed = CloseHandle(file) != 0; +#else + int flags = O_RDONLY | O_NONBLOCK; +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + int file = open(path, flags); + if (file < 0) { + if (errno == ENOENT) + return true; + return gf_error(error, CBM_GIT_FACTS_IO, "Cannot inspect Git history override state"); + } + struct stat info; + bool ok = fstat(file, &info) == 0 && S_ISREG(info.st_mode) && info.st_size == 0; + bool closed = close(file) == 0; +#endif + if (!ok) + return gf_error(error, CBM_GIT_FACTS_UNSUPPORTED, + "Graft or shallow state prevents history certification"); + return closed || + gf_error(error, CBM_GIT_FACTS_IO, "Cannot close Git history override inspection"); +} + +static bool gf_history_guard(cbm_git_facts_t *facts, cbm_git_facts_error_t *error) { + if (!gf_topology_guard(facts, error)) + return false; + for (int i = 0; i < 2; i++) { + if (i == 1) { + bool equal; + if (!gf_metadata_equal(facts, facts->graft_paths[0], facts->graft_paths[1], + &equal, error)) + return false; + if (equal) + continue; + } + if (!gf_history_file_absent_or_empty(facts->graft_paths[i], error) || + !gf_history_file_absent_or_empty(facts->shallow_paths[i], error)) + return false; + } + return gf_gate(facts, error); +} + +static bool gf_open_identity(cbm_git_facts_t *facts, cbm_git_facts_error_t *error) { + const char *prefix; + if (!gf_rev_metadata(facts, "--show-prefix", false, &prefix, error)) + return false; + if (prefix[0]) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Git root must be a worktree top-level"); + if (!gf_rev_metadata(facts, "--show-toplevel", true, &facts->identity.root, error)) + return false; + facts->options.root = facts->identity.root; + if (!gf_rev_metadata(facts, "--absolute-git-dir", true, &facts->identity.git_dir, error)) + return false; + /* HEAD and worktree-local refs must be resolved in this worktree's Git + * directory, even if its root gitfile is repointed after discovery. */ + facts->command_git_dir = facts->identity.git_dir; + if (!gf_rev_metadata(facts, "--git-common-dir", true, &facts->identity.common_dir, error)) + return false; + facts->graft_paths[0] = + cbm_arena_sprintf(&facts->arena, "%s/info/grafts", facts->identity.git_dir); + facts->graft_paths[1] = + cbm_arena_sprintf(&facts->arena, "%s/info/grafts", facts->identity.common_dir); + facts->shallow_paths[0] = + cbm_arena_sprintf(&facts->arena, "%s/shallow", facts->identity.git_dir); + facts->shallow_paths[1] = + cbm_arena_sprintf(&facts->arena, "%s/shallow", facts->identity.common_dir); + if (!facts->graft_paths[0] || !facts->graft_paths[1] || !facts->shallow_paths[0] || + !facts->shallow_paths[1]) + return gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain Git history guard paths"); + if (!gf_history_guard(facts, error)) + return false; + const char *format, *shallow; + if (!gf_rev_metadata(facts, "--show-object-format=storage", false, &format, error)) + return false; + if (strcmp(format, "sha1") == 0) + facts->identity.oid_hex_length = 40; + else if (strcmp(format, "sha256") == 0) + facts->identity.oid_hex_length = 64; + else + return gf_error(error, CBM_GIT_FACTS_UNSUPPORTED, "Unsupported Git object format"); + if (!gf_rev_metadata(facts, "--is-shallow-repository", false, &shallow, error)) + return false; + if (strcmp(shallow, "false") != 0) + return gf_error(error, CBM_GIT_FACTS_UNSUPPORTED, "Complete local Git history is required"); + if (!gf_resolve(facts, "HEAD", facts->identity.head, error) || + !gf_resolve(facts, facts->options.base_ref, facts->identity.base, error)) + return false; + /* Reject a persistent commondir redirect during ref resolution before + * switching to OID-only access through the captured common directory. */ + if (!gf_history_guard(facts, error)) + return false; + if (facts->options.expected_head) { + char expected[65]; + if (!gf_oid(expected, (const unsigned char *)facts->options.expected_head, + strlen(facts->options.expected_head), facts->identity.oid_hex_length)) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Expected HEAD must be a full object ID"); + if (strcmp(expected, facts->identity.head) != 0) + return gf_error(error, CBM_GIT_FACTS_IDENTITY_MISMATCH, + "HEAD does not match expected identity"); + } + facts->command_git_dir = facts->identity.common_dir; + const char *args[] = {"merge-base", "--all", facts->identity.base, facts->identity.head}; + gf_result_t result; + if (!gf_history_guard(facts, error) || !gf_run(facts, args, 4, true, &result, error) || + !gf_history_guard(facts, error)) + return false; + if (result.exit_code == 1 && result.out.length == 0) + return gf_error(error, CBM_GIT_FACTS_NO_MERGE_BASE, "No local common ancestor exists"); + if (result.exit_code != 0 || result.out.length == 0) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed common ancestor result"); + /* Parse every line before classifying multiple ancestors as ambiguous; + * stderr never participates in the list. No arbitrary first base wins. */ + size_t at = 0; + unsigned bases = 0; + char candidate[65]; + while (at < result.out.length) { + const unsigned char *nl = memchr(result.out.data + at, '\n', result.out.length - at); + if (!nl) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed common ancestor list"); + size_t n = (size_t)(nl - (result.out.data + at)) + 1; + cbm_git_bytes_t line = {result.out.data + at, n}; + if (!gf_oid_line(facts, &line, candidate, error)) + return false; + if (bases == 0) + memcpy(facts->identity.merge_base, candidate, facts->identity.oid_hex_length + 1); + if (bases < 2) + bases++; + at += n; + } + if (bases != 1) + return gf_error(error, CBM_GIT_FACTS_AMBIGUOUS_MERGE_BASE, + "Multiple common ancestors are unsupported"); + return gf_gate(facts, error); +} + +cbm_git_facts_t *cbm_git_facts_open(const cbm_git_facts_options_t *options, + cbm_git_facts_error_t *error) { + cbm_git_facts_error_t local; + if (!error) + error = &local; + gf_error_clear(error); + if (!options || gf_input_length(options->root) == SIZE_MAX || + gf_input_length(options->base_ref) == SIZE_MAX || + gf_input_length(options->git_executable) == SIZE_MAX || + (options->expected_head && gf_input_length(options->expected_head) == SIZE_MAX) || + !gf_absolute(options->root) || !gf_native_executable(options->git_executable) || + !options->base_ref[0] || options->base_ref[0] == '-' || !options->deadline_ms || + !options->command_limit || !options->stdout_limit || !options->stderr_limit || + !options->total_output_limit) { + gf_error(error, CBM_GIT_FACTS_INVALID, + "Invalid Git facts options or missing finite limits"); + return NULL; + } + if (options->expected_head) { + size_t width = strlen(options->expected_head); + char checked[65]; + if ((width != 40 && width != 64) || + !gf_oid(checked, (const unsigned char *)options->expected_head, width, + (unsigned)width)) { + gf_error(error, CBM_GIT_FACTS_INVALID, "Expected HEAD must be a full object ID"); + return NULL; + } + } + CBMArena arena; + cbm_arena_init_sized(&arena, GF_ARENA_BLOCK); + cbm_git_facts_t *facts = cbm_arena_calloc(&arena, sizeof(*facts)); + if (!facts) { + cbm_arena_destroy(&arena); + gf_error(error, CBM_GIT_FACTS_OOM, "Cannot allocate Git facts"); + return NULL; + } + facts->arena = arena; + facts->options = *options; + facts->options.root = cbm_arena_strdup(&facts->arena, options->root); + facts->options.base_ref = cbm_arena_strdup(&facts->arena, options->base_ref); + facts->options.git_executable = cbm_arena_strdup(&facts->arena, options->git_executable); + facts->options.expected_head = + options->expected_head ? cbm_arena_strdup(&facts->arena, options->expected_head) : NULL; + if (!facts->options.root || !facts->options.base_ref || !facts->options.git_executable || + (options->expected_head && !facts->options.expected_head) || + !gf_split_flags(facts, CBM_TEST_IMPACT_GIT_PINS, facts->git_pins, &facts->git_pin_count) || + !gf_split_flags(facts, CBM_TEST_IMPACT_DIFF_FLAGS, facts->diff_flags, + &facts->diff_flag_count)) { + gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain Git facts options"); + cbm_git_facts_free(facts); + return NULL; + } + if (!gf_open_identity(facts, error)) { + cbm_git_facts_free(facts); + return NULL; + } + return facts; +} + +void cbm_git_facts_free(cbm_git_facts_t *facts) { + if (facts) { + CBMArena arena = facts->arena; + cbm_arena_destroy(&arena); + } +} + +const cbm_git_facts_identity_t *cbm_git_facts_identity(const cbm_git_facts_t *facts) { + return facts ? &facts->identity : NULL; +} + +cbm_git_ancestry_t cbm_git_facts_is_ancestor(cbm_git_facts_t *facts, const char *ancestor_oid, + const char *descendant_oid, + cbm_git_facts_error_t *error) { + cbm_git_facts_error_t local; + if (!error) + error = &local; + gf_error_clear(error); + char ancestor[65], descendant[65]; + if (!facts || !ancestor_oid || !descendant_oid || + !gf_oid(ancestor, (const unsigned char *)ancestor_oid, gf_input_length(ancestor_oid), + facts->identity.oid_hex_length) || + !gf_oid(descendant, (const unsigned char *)descendant_oid, gf_input_length(descendant_oid), + facts->identity.oid_hex_length)) { + gf_error(error, CBM_GIT_FACTS_INVALID, "Ancestry requires two full object IDs"); + return CBM_GIT_ANCESTRY_ERROR; + } + const char *args[] = {"merge-base", "--is-ancestor", ancestor, descendant}; + gf_result_t result; + if (!gf_history_guard(facts, error) || !gf_run(facts, args, 4, true, &result, error) || + !gf_history_guard(facts, error)) + return CBM_GIT_ANCESTRY_ERROR; + if (result.out.length) { + gf_error(error, CBM_GIT_FACTS_COMMAND, "Unexpected ancestry stdout"); + return CBM_GIT_ANCESTRY_ERROR; + } + return result.exit_code == 0 ? CBM_GIT_ANCESTRY_YES : CBM_GIT_ANCESTRY_NO; +} + +static bool gf_relative_path(const char *path, size_t n) { + if (!path || !n || n > GF_INPUT_MAX || path[0] == '/' || memchr(path, '\0', n)) + return false; +#ifdef _WIN32 + /* Windows Git normalizes backslashes before lookup. Reject that spelling + * rather than silently selecting a different Git tree path. */ + if (memchr(path, '\\', n) || (n >= 2 && path[1] == ':') || + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, (int)n, NULL, 0) == 0) + return false; +#endif + size_t begin = 0; + for (size_t i = 0; i <= n; i++) { + if (i != n && path[i] != '/') + continue; + size_t length = i - begin; + if (!length || (length == 1 && path[begin] == '.') || + (length == 2 && path[begin] == '.' && path[begin + 1] == '.')) + return false; + begin = i + 1; + } + return true; +} + +static const char *gf_revision(const cbm_git_facts_t *facts, cbm_git_revision_t revision) { + switch (revision) { + case CBM_GIT_REV_HEAD: + return facts->identity.head; + case CBM_GIT_REV_BASE: + return facts->identity.base; + case CBM_GIT_REV_MERGE_BASE: + return facts->identity.merge_base; + default: + return NULL; + } +} + +/* Inventory paths are data, never argv or filesystem paths. Unlike blob + * lookup, this validates only tree-path structure, retaining every literal + * byte spelling so an unsupported relevant entry cannot silently disappear. */ +static bool gf_inventory_path(cbm_git_facts_t *facts, const unsigned char *path, size_t n, + cbm_git_facts_error_t *error) { + if (!n || path[0] == '/') + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed tracked-tree path"); + size_t begin = 0; + for (size_t i = 0; i <= n; i++) { + if ((i & 65535u) == 0 && !gf_gate(facts, error)) + return false; + if (i != n && path[i] != '/') + continue; + size_t length = i - begin; + if (!length || (length == 1 && path[begin] == '.') || + (length == 2 && path[begin] == '.' && path[begin + 1] == '.')) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed tracked-tree path"); + begin = i + 1; + } + return true; +} + +/* Parse one exact TAB NUL record. Bounds + * come from captured length, never a guessed line or a strlen(path). */ +static bool gf_inventory_record(cbm_git_facts_t *facts, const cbm_git_bytes_t *bytes, + size_t *offset, cbm_git_tree_entry_t *out, + cbm_git_facts_error_t *error) { + const unsigned char *end = NULL; + size_t scan = *offset; + while (scan < bytes->length) { + if (!gf_gate(facts, error)) + return false; + size_t chunk = bytes->length - scan; + if (chunk > 65536) + chunk = 65536; + end = memchr(bytes->data + scan, '\0', chunk); + if (end) + break; + scan += chunk; + } + if (!end) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Truncated tracked-tree record"); + const unsigned char *record = bytes->data + *offset; + size_t length = (size_t)(end - record); + if (length < 8 || record[6] != ' ') + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed tracked-tree header"); + cbm_git_tree_entry_t entry = {0}; + for (size_t i = 0; i < 6; i++) { + if (record[i] < '0' || record[i] > '7') + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed tracked-tree mode"); + entry.mode = entry.mode * 8 + (uint32_t)(record[i] - '0'); + } + const char *type; + size_t type_length; + if (entry.mode == 0100644 || entry.mode == 0100755 || entry.mode == 0120000) { + entry.object_type = CBM_GIT_TREE_BLOB; + type = "blob"; + type_length = 4; + } else if (entry.mode == 0160000) { + entry.object_type = CBM_GIT_TREE_COMMIT; + type = "commit"; + type_length = 6; + } else { + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Invalid tracked-tree mode/type"); + } + size_t oid_at = 7 + type_length + 1; + size_t path_at = oid_at + facts->identity.oid_hex_length + 1; + if (length <= path_at || memcmp(record + 7, type, type_length) != 0 || + record[7 + type_length] != ' ' || record[path_at - 1] != '\t' || + !gf_oid(entry.oid, record + oid_at, facts->identity.oid_hex_length, + facts->identity.oid_hex_length)) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed tracked-tree identity/type"); + entry.path = (const char *)record + path_at; + entry.path_length = length - path_at; + if (!gf_inventory_path(facts, record + path_at, entry.path_length, error)) + return false; + *offset = (size_t)(end - bytes->data) + 1; + if (out) + *out = entry; + return true; +} + +static bool gf_inventory_compare(cbm_git_facts_t *facts, const cbm_git_tree_entry_t *a, + const cbm_git_tree_entry_t *b, int *comparison, + cbm_git_facts_error_t *error) { + size_t common = a->path_length < b->path_length ? a->path_length : b->path_length; + for (size_t at = 0; at < common;) { + if (!gf_gate(facts, error)) + return false; + size_t chunk = common - at; + if (chunk > 65536) + chunk = 65536; + int cmp = memcmp(a->path + at, b->path + at, chunk); + if (cmp) { + *comparison = cmp < 0 ? -1 : 1; + return true; + } + at += chunk; + } + *comparison = a->path_length < b->path_length ? -1 : + a->path_length > b->path_length ? 1 : 0; + return true; +} + +/* In-place heapsort: deterministic byte order, no recursive stack or hidden + * allocation, and request gates during comparisons and each extraction. */ +static bool gf_inventory_sift(cbm_git_facts_t *facts, cbm_git_tree_entry_t *entries, + size_t root, size_t count, cbm_git_facts_error_t *error) { + while (root < count / 2) { + size_t child = root * 2 + 1; + int cmp; + if (child + 1 < count) { + if (!gf_inventory_compare(facts, &entries[child], &entries[child + 1], &cmp, error)) + return false; + if (cmp < 0) + child++; + } + if (!gf_inventory_compare(facts, &entries[root], &entries[child], &cmp, error)) + return false; + if (cmp >= 0) + return true; + cbm_git_tree_entry_t swap = entries[root]; + entries[root] = entries[child]; + entries[child] = swap; + root = child; + } + return true; +} + +static bool gf_inventory_sort(cbm_git_facts_t *facts, cbm_git_tree_entry_t *entries, + size_t count, cbm_git_facts_error_t *error) { + for (size_t i = count / 2; i > 0; i--) + if (!gf_inventory_sift(facts, entries, i - 1, count, error)) + return false; + for (size_t end = count; end > 1; end--) { + if (!gf_gate(facts, error)) + return false; + cbm_git_tree_entry_t swap = entries[0]; + entries[0] = entries[end - 1]; + entries[end - 1] = swap; + if (!gf_inventory_sift(facts, entries, 0, end - 1, error)) + return false; + } + for (size_t i = 1; i < count; i++) { + int cmp; + if (!gf_inventory_compare(facts, &entries[i - 1], &entries[i], &cmp, error)) + return false; + if (cmp >= 0) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Duplicate tracked-tree path"); + } + return gf_gate(facts, error); +} + +bool cbm_git_facts_inventory(cbm_git_facts_t *facts, cbm_git_revision_t revision, + cbm_git_tree_inventory_t *out, cbm_git_facts_error_t *error) { + cbm_git_facts_error_t local; + if (!error) + error = &local; + gf_error_clear(error); + if (out) + memset(out, 0, sizeof(*out)); + if (!facts || !out || (revision != CBM_GIT_REV_HEAD && revision != CBM_GIT_REV_MERGE_BASE)) + return gf_error(error, CBM_GIT_FACTS_INVALID, + "Inventory requires pinned HEAD or actual merge base"); + if (!gf_topology_guard(facts, error)) + return false; + const char *args[] = {"ls-tree", "-r", "-z", "--full-tree", gf_revision(facts, revision)}; + gf_result_t result; + if (!gf_run(facts, args, 5, false, &result, error)) + return false; + /* Validate before allocating rows: malformed NUL-heavy output cannot turn + * a small capture into a much larger metadata allocation. Every accepted + * row requires at least one full-OID record in the already charged capture. */ + size_t offset = 0, count = 0; + while (offset < result.out.length) { + if (!gf_inventory_record(facts, &result.out, &offset, NULL, error)) + return false; + if (count == SIZE_MAX / sizeof(cbm_git_tree_entry_t)) + return gf_limit(facts, error, "Tracked-tree inventory allocation limit exceeded"); + count++; + } + if (!gf_gate(facts, error)) + return false; + cbm_git_tree_entry_t *entries = NULL; + if (count) { + entries = gf_allocate(facts, &facts->arena, count * sizeof(*entries), error); + if (!entries) { + if (error->status == CBM_GIT_FACTS_OOM) + gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain tracked-tree inventory"); + return false; + } + offset = 0; + for (size_t i = 0; i < count; i++) + if (!gf_inventory_record(facts, &result.out, &offset, &entries[i], error)) + return false; + if (!gf_inventory_sort(facts, entries, count, error)) + return false; + } + if (!gf_topology_guard(facts, error)) + return false; + *out = (cbm_git_tree_inventory_t){.entries = entries, .count = count}; + return true; +} + +static bool gf_decimal_size(const cbm_git_bytes_t *bytes, size_t *size) { + const unsigned char *text; + size_t n; + if (!gf_line(bytes, &text, &n) || !n) + return false; + size_t value = 0; + for (size_t i = 0; i < n; i++) { + unsigned char c = text[i]; + if (c < '0' || c > '9' || value > (SIZE_MAX - (size_t)(c - '0')) / 10) + return false; + value = value * 10 + (size_t)(c - '0'); + } + *size = value; + return true; +} + +cbm_git_blob_status_t cbm_git_facts_read_blob(cbm_git_facts_t *facts, cbm_git_revision_t revision, + const char *path, size_t path_length, + cbm_git_blob_t *out, cbm_git_facts_error_t *error) { + cbm_git_facts_error_t local; + if (!error) + error = &local; + gf_error_clear(error); + if (out) + memset(out, 0, sizeof(*out)); + const char *commit = facts ? gf_revision(facts, revision) : NULL; + if (!facts || !out || !commit || !gf_relative_path(path, path_length)) { + gf_error(error, CBM_GIT_FACTS_INVALID, + "Blob lookup requires a pinned revision and literal relative path"); + return CBM_GIT_BLOB_ERROR; + } + if (!gf_topology_guard(facts, error)) + return CBM_GIT_BLOB_ERROR; + const char *owned_path = cbm_arena_strndup(&facts->arena, path, path_length); + if (!owned_path) { + gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain literal Git path"); + return CBM_GIT_BLOB_ERROR; + } + const char *tree_args[] = {"ls-tree", "-z", "--full-tree", commit, "--", owned_path}; + gf_result_t result; + if (!gf_run(facts, tree_args, 6, false, &result, error)) + return CBM_GIT_BLOB_ERROR; + if (!result.out.length) + return gf_topology_guard(facts, error) ? CBM_GIT_BLOB_ABSENT : CBM_GIT_BLOB_ERROR; + const unsigned char *data = result.out.data; + size_t length = result.out.length; + const unsigned char *tab = memchr(data, '\t', length); + const unsigned char *end = memchr(data, '\0', length); + if (!tab || !end || end != data + length - 1 || tab >= end || + (size_t)(end - tab - 1) != path_length || memcmp(tab + 1, path, path_length) != 0 || + (size_t)(tab - data) < 8 || data[6] != ' ') { + gf_error(error, CBM_GIT_FACTS_COMMAND, + "Git tree lookup did not return one exact literal path"); + return CBM_GIT_BLOB_ERROR; + } + uint32_t mode = 0; + for (int i = 0; i < 6; i++) { + if (data[i] < '0' || data[i] > '7') { + gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git tree mode"); + return CBM_GIT_BLOB_ERROR; + } + mode = mode * 8 + (uint32_t)(data[i] - '0'); + } + const unsigned char *separator = memchr(data + 7, ' ', (size_t)(tab - data - 7)); + if (!separator) { + gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git tree object type"); + return CBM_GIT_BLOB_ERROR; + } + cbm_git_blob_t blob = {.mode = mode}; + if (!gf_oid(blob.oid, separator + 1, (size_t)(tab - separator - 1), + facts->identity.oid_hex_length)) { + gf_error(error, CBM_GIT_FACTS_COMMAND, "Invalid Git blob identity"); + return CBM_GIT_BLOB_ERROR; + } + if ((size_t)(separator - (data + 7)) != 4 || memcmp(data + 7, "blob", 4) != 0 || + (mode != 0100644 && mode != 0100755)) { + gf_error(error, CBM_GIT_FACTS_UNSUPPORTED, + "Only regular committed source blobs are supported"); + return CBM_GIT_BLOB_ERROR; + } + const char *size_args[] = {"cat-file", "-s", blob.oid}; + size_t expected; + if (!gf_run(facts, size_args, 3, false, &result, error)) + return CBM_GIT_BLOB_ERROR; + if (!gf_decimal_size(&result.out, &expected)) { + gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git blob size"); + return CBM_GIT_BLOB_ERROR; + } + if (expected > facts->options.stdout_limit || + expected > facts->options.total_output_limit - facts->output_bytes) { + gf_limit(facts, error, "Committed blob exceeds the remaining output budget"); + return CBM_GIT_BLOB_ERROR; + } + const char *blob_args[] = {"cat-file", "blob", blob.oid}; + if (!gf_run(facts, blob_args, 3, false, &result, error)) + return CBM_GIT_BLOB_ERROR; + if (result.out.length != expected) { + gf_error(error, CBM_GIT_FACTS_COMMAND, + "Git blob bytes disagree with the immutable object size"); + return CBM_GIT_BLOB_ERROR; + } + if (!gf_topology_guard(facts, error)) + return CBM_GIT_BLOB_ERROR; + blob.bytes = result.out; + *out = blob; + return CBM_GIT_BLOB_FOUND; +} + +static bool gf_names_valid(const cbm_git_bytes_t *bytes) { + size_t at = 0; + while (at < bytes->length) { + if (bytes->length - at < 3 || bytes->data[at + 1] != '\0') + return false; + unsigned char status = bytes->data[at]; + if (status != 'A' && status != 'D' && status != 'M' && status != 'T') + return false; + at += 2; + const unsigned char *end = memchr(bytes->data + at, '\0', bytes->length - at); + if (!end || + !gf_relative_path((const char *)bytes->data + at, (size_t)(end - (bytes->data + at)))) + return false; + at = (size_t)(end - bytes->data) + 1; + } + return true; +} + +bool cbm_git_facts_diff(cbm_git_facts_t *facts, cbm_git_diff_t *out, cbm_git_facts_error_t *error) { + cbm_git_facts_error_t local; + if (!error) + error = &local; + gf_error_clear(error); + if (out) + memset(out, 0, sizeof(*out)); + if (!facts || !out) + return gf_error(error, CBM_GIT_FACTS_INVALID, + "A Git snapshot and diff output are required"); + if (!gf_topology_guard(facts, error)) + return false; + const char *names[] = {"diff", + "--name-status", + "-z", + "--no-renames", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--no-relative", + "--ignore-submodules=none", + "--no-exit-code", + facts->identity.merge_base, + facts->identity.head, + "--"}; + gf_result_t names_result, patch_result; + if (!gf_run(facts, names, (int)(sizeof(names) / sizeof(names[0])), false, &names_result, error)) + return false; + if (!gf_names_valid(&names_result.out)) + return gf_error(error, CBM_GIT_FACTS_COMMAND, + "Malformed or unsupported changed-path metadata"); + const char *args[GF_ARG_CAP]; + int count = 0; + args[count++] = "diff"; + for (int i = 0; i < facts->diff_flag_count; i++) + args[count++] = facts->diff_flags[i]; + const char *extra[] = {"--patch", + "--no-relative", + "--ignore-submodules=none", + "--submodule=short", + "--no-exit-code", + "--no-indent-heuristic", + "--output-indicator-new=+", + "--output-indicator-old=-", + "--output-indicator-context= ", + facts->identity.merge_base, + facts->identity.head, + "--"}; + for (size_t i = 0; i < sizeof(extra) / sizeof(extra[0]); i++) + args[count++] = extra[i]; + if (!gf_run(facts, args, count, false, &patch_result, error)) + return false; + if ((names_result.out.length == 0) != (patch_result.out.length == 0)) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Changed-path metadata and patch disagree"); + if (!gf_topology_guard(facts, error)) + return false; + *out = (cbm_git_diff_t){.patch = patch_result.out, .name_status = names_result.out}; + return true; +} + +/* A-to-H names are output data, never blob lookup arguments. Keep this reader + * separate from gf_names_valid so legacy M-to-H behavior remains unchanged. */ +static bool gf_ancestor_names_valid(cbm_git_facts_t *facts, const cbm_git_bytes_t *bytes, + cbm_git_facts_error_t *error) { + if (bytes->length && !bytes->data) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Missing ancestor changed-path capture"); + size_t at = 0; + while (at < bytes->length) { + if (!gf_gate(facts, error)) + return false; + if (bytes->length - at < 3 || bytes->data[at + 1] != '\0') + return gf_error(error, CBM_GIT_FACTS_COMMAND, + "Malformed ancestor changed-path record"); + unsigned char status = bytes->data[at]; + if (status != 'A' && status != 'M' && status != 'D' && status != 'T') + return gf_error(error, CBM_GIT_FACTS_COMMAND, + "Unsupported ancestor changed-path status"); + at += 2; + size_t start = at; + size_t end = at; + bool terminated = false; + while (at < bytes->length) { + if (!gf_gate(facts, error)) + return false; + size_t chunk = bytes->length - at; + if (chunk > 65536) + chunk = 65536; + const unsigned char *nul = memchr(bytes->data + at, '\0', chunk); + if (nul) { + end = at + (size_t)(nul - (bytes->data + at)); + at = end + 1; + terminated = true; + break; + } + at += chunk; + } + if (!terminated) + return gf_error(error, CBM_GIT_FACTS_COMMAND, + "Truncated ancestor changed-path record"); + if (!gf_gate(facts, error) || + !gf_inventory_path(facts, bytes->data + start, end - start, error)) + return false; + } + return gf_gate(facts, error); +} + +/* merge-base accepts peeled tags. Check the object itself before ancestry so + * a full tag OID cannot silently stand in for the admitted artifact commit. */ +static bool gf_ancestor_commit_type(cbm_git_facts_t *facts, const char *oid, + cbm_git_facts_error_t *error) { + const char *args[] = {"cat-file", "-t", oid}; + gf_result_t result; + if (!gf_run(facts, args, 3, false, &result, error)) + return false; + const unsigned char *type; + size_t length; + /* The longest supported response is "commit\r\n". Reject a large malformed + * response without an unbounded scan through gf_line's line checks. */ + if (result.out.length > 8 || !gf_line(&result.out, &type, &length)) + return gf_error(error, CBM_GIT_FACTS_COMMAND, + "Malformed artifact object-type response"); + if (length == 6 && memcmp(type, "commit", 6) == 0) + return true; + if ((length == 4 && (memcmp(type, "tree", 4) == 0 || memcmp(type, "blob", 4) == 0)) || + (length == 3 && memcmp(type, "tag", 3) == 0)) + return gf_error(error, CBM_GIT_FACTS_INVALID, + "Artifact object ID must identify a commit itself"); + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Unknown artifact object-type response"); +} + +cbm_git_ancestor_changes_status_t cbm_git_facts_ancestor_changes( + cbm_git_facts_t *facts, + const char *artifact_oid, size_t artifact_oid_length, + cbm_git_bytes_t *name_status, + cbm_git_facts_error_t *error) { + cbm_git_facts_error_t local; + if (!error) + error = &local; + gf_error_clear(error); + if (name_status) + *name_status = (cbm_git_bytes_t){0}; + char ancestor[65]; + if (!facts || !artifact_oid || !name_status || + !gf_oid(ancestor, (const unsigned char *)artifact_oid, artifact_oid_length, + facts->identity.oid_hex_length)) { + gf_error(error, CBM_GIT_FACTS_INVALID, + "Ancestor changes require a snapshot, full artifact OID and output"); + return CBM_GIT_ANCESTOR_CHANGES_ERROR; + } + if (!gf_history_guard(facts, error)) + return CBM_GIT_ANCESTOR_CHANGES_ERROR; + if (strcmp(ancestor, facts->identity.merge_base) != 0) { + if (!gf_ancestor_commit_type(facts, ancestor, error)) + return CBM_GIT_ANCESTOR_CHANGES_ERROR; + const char *args[] = {"merge-base", "--is-ancestor", ancestor, + facts->identity.merge_base}; + gf_result_t result; + if (!gf_run(facts, args, 4, true, &result, error)) + return CBM_GIT_ANCESTOR_CHANGES_ERROR; + if (result.out.length) { + gf_error(error, CBM_GIT_FACTS_COMMAND, "Unexpected artifact ancestry stdout"); + return CBM_GIT_ANCESTOR_CHANGES_ERROR; + } + if (result.exit_code == 1) { + if (!gf_history_guard(facts, error) || !gf_gate(facts, error)) + return CBM_GIT_ANCESTOR_CHANGES_ERROR; + return CBM_GIT_ANCESTOR_CHANGES_NOT_ANCESTOR; + } + } + const char *names[] = {"diff", + "--name-status", + "-z", + "--no-renames", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--no-relative", + "--ignore-submodules=none", + "--no-exit-code", + ancestor, + facts->identity.head, + "--"}; + gf_result_t result; + if (!gf_run(facts, names, (int)(sizeof(names) / sizeof(names[0])), false, &result, error) || + !gf_ancestor_names_valid(facts, &result.out, error) || + !gf_history_guard(facts, error) || !gf_gate(facts, error)) + return CBM_GIT_ANCESTOR_CHANGES_ERROR; + *name_status = result.out; + return CBM_GIT_ANCESTOR_CHANGES_OK; +} + +/* Pinned blob batches retain inventory identity separately from payload bytes. + * Per-call objects are sorted by OID; result items retain caller ordering. */ +typedef struct { + const char *oid; + size_t size, frame_length; + cbm_git_bytes_t bytes; +} gf_batch_object_t; + +typedef struct { + const cbm_git_tree_entry_t *entry; + size_t inventory_index, position, object_index; +} gf_batch_pick_t; + +typedef struct { + size_t width, count; + const char *const *oids; /* test wrapper view, never production ownership */ + const size_t *sizes; + gf_batch_object_t *objects; /* optional production output and expected identity */ + bool has_payload; +} gf_batch_frames_t; + +typedef struct { + cbm_git_facts_t *facts; + const cbm_git_blob_batch_request_t *request; + cbm_git_tree_inventory_t inventory; + gf_batch_pick_t *picks; + gf_batch_object_t *objects; + size_t unique_count, line_length; + unsigned char *lines; +} gf_batch_work_t; + +/* NULL facts is used only by the pure parser wrappers. Both paths share every + * grammar/overflow/frame check; the wrapper has no transport or owner state. */ +static bool gf_batch_gate(cbm_git_facts_t *facts, cbm_git_facts_error_t *error) { + return !facts || gf_gate(facts, error); +} + +static bool gf_batch_limit(cbm_git_facts_t *facts, cbm_git_facts_error_t *error, + const char *message) { + return facts ? gf_limit(facts, error, message) : + gf_error(error, CBM_GIT_FACTS_LIMIT, message); +} + +static bool gf_batch_canonical_oid(const char *oid, size_t width) { + if (!oid || (width != 40 && width != 64)) + return false; + for (size_t i = 0; i < width; i++) { + unsigned char c = (unsigned char)oid[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) + return false; + } + return oid[width] == '\0'; +} + +typedef struct { + size_t size, length; +} gf_batch_header_t; + +/* Continue validating digits after overflow so a malformed suffix is never + * mistaken for a valid huge size. Header length includes its terminal LF. */ +static bool gf_batch_header(cbm_git_facts_t *facts, cbm_git_bytes_t capture, + const char *oid, size_t width, gf_batch_header_t *header, + cbm_git_facts_error_t *error) { + *header = (gf_batch_header_t){0}; + if (!gf_batch_gate(facts, error)) + return false; + size_t begin = width + 6; + if (capture.length < begin + 2 || memcmp(capture.data, oid, width) != 0 || + memcmp(capture.data + width, " blob ", 6) != 0) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git batch identity/type"); + size_t value = 0, at = begin; + bool overflow = false; + for (; at < capture.length && capture.data[at] != '\n'; at++) { + if (((at - begin) & 65535u) == 0 && !gf_batch_gate(facts, error)) + return false; + unsigned char c = capture.data[at]; + if (c < '0' || c > '9' || (at > begin && capture.data[begin] == '0')) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git batch object size"); + if (value > (SIZE_MAX - (size_t)(c - '0')) / 10) + overflow = true; + if (!overflow) + value = value * 10 + (size_t)(c - '0'); + } + if (at == begin || at == capture.length) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Truncated Git batch header"); + if (overflow || at == SIZE_MAX - 1 || value > SIZE_MAX - (at + 1) - 1) + return gf_batch_limit(facts, error, "Git batch object frame size overflow"); + if (!gf_batch_gate(facts, error)) + return false; + *header = (gf_batch_header_t){.size = value, .length = at + 1}; + return true; +} + +static bool gf_batch_payload(cbm_git_facts_t *facts, cbm_git_bytes_t capture, + size_t *offset, size_t size, cbm_git_bytes_t *bytes, + cbm_git_facts_error_t *error) { + if (size >= capture.length - *offset) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Truncated Git batch payload"); + size_t start = *offset; + for (size_t left = size; left;) { + if (!gf_batch_gate(facts, error)) + return false; + size_t chunk = left > 65536 ? 65536 : left; + *offset += chunk; + left -= chunk; + } + if (!gf_batch_gate(facts, error)) + return false; + if (capture.data[*offset] != '\n') + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git batch payload separator"); + (*offset)++; + *bytes = (cbm_git_bytes_t){capture.data + start, size}; + return true; +} + +/* One exact capture driver for production and deterministic parser tests. */ +static bool gf_batch_capture(cbm_git_facts_t *facts, cbm_git_bytes_t capture, + const gf_batch_frames_t *frames, cbm_git_facts_error_t *error) { + size_t at = 0; + for (size_t i = 0; i < frames->count; i++) { + if (!gf_batch_gate(facts, error)) + return false; + const char *oid = frames->objects ? frames->objects[i].oid : frames->oids[i]; + cbm_git_bytes_t remaining = {capture.data ? capture.data + at : NULL, + capture.length - at}; + gf_batch_header_t header; + if (!gf_batch_header(facts, remaining, oid, frames->width, &header, error)) + return false; + size_t size = header.size; + bool check_size = frames->sizes || (frames->objects && frames->has_payload); + size_t expected = frames->sizes ? frames->sizes[i] : + frames->objects ? frames->objects[i].size : 0; + if (check_size && size != expected) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Git batch size changed after preflight"); + at += header.length; + cbm_git_bytes_t bytes = {0}; + if (frames->has_payload && !gf_batch_payload(facts, capture, &at, size, &bytes, error)) + return false; + if (frames->objects) { + frames->objects[i].size = size; + frames->objects[i].frame_length = header.length + size + 1; + if (frames->has_payload) + frames->objects[i].bytes = bytes; + } + } + if (at != capture.length) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Unexpected bytes after Git batch frames"); + return gf_batch_gate(facts, error); +} + +#ifdef CBM_ENABLE_TEST_SEAMS +bool cbm_git_facts_test_batch_header(cbm_git_bytes_t capture, const char *expected_oid, + size_t oid_hex_length, size_t *object_size, + size_t *header_length, cbm_git_facts_error_t *error) { + cbm_git_facts_error_t local; + if (!error) + error = &local; + gf_error_clear(error); + if (object_size) + *object_size = 0; + if (header_length) + *header_length = 0; + if (!object_size || !header_length || (capture.length && !capture.data) || + !gf_batch_canonical_oid(expected_oid, oid_hex_length)) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid Git batch header test input"); + gf_batch_header_t header; + if (!gf_batch_header(NULL, capture, expected_oid, oid_hex_length, &header, error)) + return false; + *object_size = header.size; + *header_length = header.length; + return true; +} + +bool cbm_git_facts_test_batch_capture(cbm_git_bytes_t capture, size_t oid_hex_length, + const char *const *expected_oids, + const size_t *expected_sizes, size_t count, + bool has_payload, cbm_git_facts_error_t *error) { + cbm_git_facts_error_t local; + if (!error) + error = &local; + gf_error_clear(error); + if ((capture.length && !capture.data) || (oid_hex_length != 40 && oid_hex_length != 64) || + (count && (!expected_oids || (has_payload && !expected_sizes))) || + count > SIZE_MAX / sizeof(*expected_oids) || count > SIZE_MAX / sizeof(*expected_sizes)) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid Git batch capture test input"); + for (size_t i = 0; i < count; i++) + if (!gf_batch_canonical_oid(expected_oids[i], oid_hex_length)) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid expected Git batch object ID"); + gf_batch_frames_t frames = {.width = oid_hex_length, .count = count, + .oids = expected_oids, .sizes = expected_sizes, + .has_payload = has_payload}; + return gf_batch_capture(NULL, capture, &frames, error); +} +#endif + +static void *gf_batch_array(cbm_git_facts_t *facts, size_t count, size_t size, + cbm_git_facts_error_t *error) { + if (count > SIZE_MAX / size) { + gf_limit(facts, error, "Git batch array size overflow"); + return NULL; + } + return gf_allocate(facts, &facts->arena, count * size, error); +} + +static bool gf_batch_inventory(gf_batch_work_t *work, cbm_git_facts_error_t *error) { + cbm_git_facts_t *facts = work->facts; + const char *commit = gf_revision(facts, work->request->revision); + for (size_t i = 0; i < 2; i++) { + if (facts->inventory_cache[i].commit && + strcmp(facts->inventory_cache[i].commit, commit) == 0) { + work->inventory = facts->inventory_cache[i].inventory; + return gf_gate(facts, error); + } + } + if (!cbm_git_facts_inventory(facts, work->request->revision, &work->inventory, error)) + return false; + for (size_t i = 0; i < 2; i++) { + if (!facts->inventory_cache[i].commit) { + facts->inventory_cache[i] = (gf_inventory_cache_t){commit, work->inventory}; + return true; + } + } + return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid internal pinned inventory cache"); +} + +static int gf_batch_pick_compare(const gf_batch_pick_t *a, const gf_batch_pick_t *b, + size_t width) { + int cmp = memcmp(a->entry->oid, b->entry->oid, width); + if (cmp) + return cmp; + return a->position < b->position ? -1 : a->position > b->position ? 1 : 0; +} + +static bool gf_batch_sift(gf_batch_work_t *work, size_t root, size_t count, + cbm_git_facts_error_t *error) { + gf_batch_pick_t *picks = work->picks; + size_t width = work->facts->identity.oid_hex_length; + while (root < count / 2) { + if (!gf_gate(work->facts, error)) + return false; + size_t child = root * 2 + 1; + if (child + 1 < count && gf_batch_pick_compare(&picks[child], &picks[child + 1], width) < 0) + child++; + if (gf_batch_pick_compare(&picks[root], &picks[child], width) >= 0) + return true; + gf_batch_pick_t swap = picks[root]; + picks[root] = picks[child]; + picks[child] = swap; + root = child; + } + return true; +} + +static bool gf_batch_sort(gf_batch_work_t *work, cbm_git_facts_error_t *error) { + size_t count = work->request->count; + for (size_t i = count / 2; i > 0; i--) + if (!gf_batch_sift(work, i - 1, count, error)) + return false; + for (size_t end = count; end > 1; end--) { + if (!gf_gate(work->facts, error)) + return false; + gf_batch_pick_t swap = work->picks[0]; + work->picks[0] = work->picks[end - 1]; + work->picks[end - 1] = swap; + if (!gf_batch_sift(work, 0, end - 1, error)) + return false; + } + return gf_gate(work->facts, error); +} + +/* Validate the entire selection before allocating its maps or querying any + * object. Index-vector multiplication was checked at the public boundary. */ +static bool gf_batch_selection(gf_batch_work_t *work, cbm_git_facts_error_t *error) { + size_t count = work->request->count; + for (size_t i = 0; i < count; i++) { + if (!gf_gate(work->facts, error)) + return false; + size_t index = work->request->indices[i]; + if (index >= work->inventory.count) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Git batch inventory index is out of range"); + const cbm_git_tree_entry_t *entry = &work->inventory.entries[index]; + if (entry->object_type != CBM_GIT_TREE_BLOB || + (entry->mode != 0100644 && entry->mode != 0100755)) + return gf_error(error, CBM_GIT_FACTS_UNSUPPORTED, "Git batch requires regular blobs"); + } + work->picks = gf_batch_array(work->facts, count, sizeof(*work->picks), error); + if (!work->picks) + return false; + for (size_t i = 0; i < count; i++) { + if (!gf_gate(work->facts, error)) + return false; + size_t index = work->request->indices[i]; + work->picks[i] = (gf_batch_pick_t){.entry = &work->inventory.entries[index], + .inventory_index = index, .position = i}; + } + return gf_batch_sort(work, error); +} + +static bool gf_batch_unique(gf_batch_work_t *work, cbm_git_facts_error_t *error) { + size_t count = work->request->count, width = work->facts->identity.oid_hex_length; + for (size_t i = 0; i < count; i++) { + if (!gf_gate(work->facts, error)) + return false; + if (!i || memcmp(work->picks[i - 1].entry->oid, work->picks[i].entry->oid, width) != 0) + work->unique_count++; + work->picks[i].object_index = work->unique_count - 1; + } + work->objects = gf_batch_array(work->facts, work->unique_count, sizeof(*work->objects), error); + if (!work->objects) + return false; + work->line_length = width + 1; + if (work->unique_count > SIZE_MAX / work->line_length / 2 || + work->unique_count * work->line_length * 2 > work->facts->batch_budget->input_limit) + return gf_limit(work->facts, error, "Git batch cumulative stdin limit exceeded"); + work->lines = gf_batch_array(work->facts, work->unique_count, work->line_length, error); + if (!work->lines) + return false; + for (size_t i = 0; i < count; i++) { + if (!gf_gate(work->facts, error)) + return false; + size_t object = work->picks[i].object_index; + if (i && object == work->picks[i - 1].object_index) + continue; + const char *oid = work->picks[i].entry->oid; + if (!gf_batch_canonical_oid(oid, width)) + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Invalid owned Git batch object ID"); + work->objects[object] = (gf_batch_object_t){.oid = oid}; + unsigned char *line = work->lines + object * work->line_length; + memcpy(line, oid, width); + line[width] = '\n'; + } + return gf_gate(work->facts, error); +} + +static size_t gf_batch_digits(size_t value) { + size_t digits = 1; + while (value >= 10) { + value /= 10; + digits++; + } + return digits; +} + +static bool gf_batch_command(gf_batch_work_t *work, size_t first, size_t count, + bool payload, cbm_git_facts_error_t *error) { + const char *args[] = {"cat-file", payload ? "--batch=%(objectname) %(objecttype) %(objectsize)" : + "--batch-check=%(objectname) %(objecttype) %(objectsize)"}; + cbm_git_bytes_t input = {work->lines + first * work->line_length, count * work->line_length}; + gf_result_t result; + if (!gf_run_input(work->facts, args, 2, false, &input, &result, error)) + return false; + gf_batch_frames_t frames = {.width = work->facts->identity.oid_hex_length, .count = count, + .objects = work->objects + first, .has_payload = payload}; + return gf_batch_capture(work->facts, result.out, &frames, error); +} + +static bool gf_batch_preflight(gf_batch_work_t *work, cbm_git_facts_error_t *error) { + size_t worst_header = work->facts->identity.oid_hex_length + 7 + gf_batch_digits(SIZE_MAX); + size_t batch = work->facts->options.stdout_limit / worst_header; + if (!batch) + batch = 1; + for (size_t first = 0; first < work->unique_count;) { + if (!gf_gate(work->facts, error)) + return false; + size_t count = work->unique_count - first; + if (count > batch) + count = batch; + if (!gf_batch_command(work, first, count, false, error)) + return false; + first += count; + } + size_t total = 0; + for (size_t i = 0; i < work->unique_count; i++) { + if (!gf_gate(work->facts, error)) + return false; + size_t frame = work->objects[i].frame_length; + if (frame > work->facts->options.stdout_limit || frame > SIZE_MAX - total) + return gf_limit(work->facts, error, "Git batch payload frame exceeds output limit"); + total += frame; + } + if (total > work->facts->options.total_output_limit - work->facts->output_bytes) + return gf_limit(work->facts, error, "Git batch payloads exceed remaining output limit"); + return gf_gate(work->facts, error); +} + +static bool gf_batch_read_payloads(gf_batch_work_t *work, cbm_git_facts_error_t *error) { + for (size_t first = 0; first < work->unique_count;) { + size_t count = 0, total = 0; + while (first + count < work->unique_count) { + if (!gf_gate(work->facts, error)) + return false; + size_t frame = work->objects[first + count].frame_length; + if (frame > work->facts->options.stdout_limit - total) + break; + total += frame; + count++; + } + if (!count || total > work->facts->options.total_output_limit - work->facts->output_bytes) + return gf_limit(work->facts, error, "Git batch payloads exceed remaining output limit"); + if (!gf_batch_command(work, first, count, true, error)) + return false; + first += count; + } + return gf_gate(work->facts, error); +} + +static bool gf_batch_publish(gf_batch_work_t *work, cbm_git_blob_batch_t *out, + cbm_git_facts_error_t *error) { + size_t count = work->request->count; + cbm_git_blob_batch_item_t *items = NULL; + if (count) { + items = gf_batch_array(work->facts, count, sizeof(*items), error); + if (!items) + return false; + for (size_t i = 0; i < count; i++) { + if (!gf_gate(work->facts, error)) + return false; + const gf_batch_pick_t *pick = &work->picks[i]; + items[pick->position] = (cbm_git_blob_batch_item_t){ + .inventory_index = pick->inventory_index, .entry = pick->entry, + .bytes = work->objects[pick->object_index].bytes}; + } + } + if (!gf_history_guard(work->facts, error) || !gf_gate(work->facts, error)) + return false; + *out = (cbm_git_blob_batch_t){.revision = work->request->revision, + .commit = gf_revision(work->facts, work->request->revision), + .items = items, .count = count}; + return true; +} + +static bool gf_batch_execute(cbm_git_facts_t *facts, const cbm_git_blob_batch_request_t *request, + const cbm_git_blob_batch_limits_t *limits, + cbm_git_blob_batch_t *out, cbm_git_facts_error_t *error) { + if (!gf_history_guard(facts, error)) + return false; + if (request->count > limits->max_entries || request->count > SIZE_MAX / sizeof(size_t) || + request->count > SIZE_MAX / sizeof(gf_batch_pick_t) || + request->count > SIZE_MAX / sizeof(cbm_git_blob_batch_item_t)) + return gf_limit(facts, error, "Git batch entry limit exceeded"); + gf_batch_work_t work = {.facts = facts, .request = request}; + if (!gf_batch_inventory(&work, error)) + return false; + if (request->count && + (!gf_batch_selection(&work, error) || !gf_batch_unique(&work, error) || + !gf_batch_preflight(&work, error) || !gf_batch_read_payloads(&work, error))) + return false; + return gf_batch_publish(&work, out, error); +} + +bool cbm_git_facts_read_blob_batch(cbm_git_facts_t *facts, + const cbm_git_blob_batch_request_t *request, + const cbm_git_blob_batch_limits_t *limits, + cbm_git_blob_batch_t *out, cbm_git_facts_error_t *error) { + cbm_git_facts_error_t local; + if (!error) + error = &local; + gf_error_clear(error); + if (out) + memset(out, 0, sizeof(*out)); + if (!facts || !request || !limits || !out || !limits->max_entries || + !limits->max_input_bytes || !limits->max_arena_bytes || + (request->revision != CBM_GIT_REV_HEAD && request->revision != CBM_GIT_REV_MERGE_BASE) || + (request->count && !request->indices) || facts->batch_budget) + return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid pinned Git blob batch request"); + gf_batch_budget_t budget = {.arena_limit = limits->max_arena_bytes, + .input_limit = limits->max_input_bytes}; + facts->batch_budget = &budget; + bool ok = gf_batch_execute(facts, request, limits, out, error); + facts->batch_budget = NULL; + if (!ok && error->status == CBM_GIT_FACTS_CANCELLED) + facts->stopped = *error; + return ok; +} diff --git a/src/mcp/test_impact_git.h b/src/mcp/test_impact_git.h new file mode 100644 index 000000000..d0b5c9d33 --- /dev/null +++ b/src/mcp/test_impact_git.h @@ -0,0 +1,275 @@ +#ifndef CBM_TEST_IMPACT_GIT_H +#define CBM_TEST_IMPACT_GIT_H + +#include +#include +#include + +/* Query-local, committed-only facts. Requires the optional stdout_file + * subprocess seam. No shell, checkout, index mutation, fetch or global state. */ +typedef enum { + CBM_GIT_FACTS_OK = 0, + CBM_GIT_FACTS_INVALID, + CBM_GIT_FACTS_OOM, + CBM_GIT_FACTS_IO, + CBM_GIT_FACTS_SPAWN, + CBM_GIT_FACTS_COMMAND, + CBM_GIT_FACTS_CANCELLED, + CBM_GIT_FACTS_DEADLINE, + CBM_GIT_FACTS_LIMIT, + CBM_GIT_FACTS_SUPERVISION, + CBM_GIT_FACTS_UNSUPPORTED, + CBM_GIT_FACTS_NO_MERGE_BASE, + CBM_GIT_FACTS_AMBIGUOUS_MERGE_BASE, + CBM_GIT_FACTS_IDENTITY_MISMATCH +} cbm_git_facts_status_t; + +typedef struct { + cbm_git_facts_status_t status; + int exit_code; /* -1 when unavailable */ + char diagnostic[512]; /* owned bounded text, never a borrowed pointer */ +} cbm_git_facts_error_t; + +typedef bool (*cbm_git_facts_cancel_fn)(void *context); + +typedef struct { + const char *root; /* absolute worktree top-level; Git may normalize its spelling */ + const char *base_ref; /* one commit-ish to resolve once; never a first-parent substitute */ + const char *git_executable; /* trusted internal resolved absolute native binary, not a + * user override; .exe on Windows */ + const char *expected_head; /* optional full OID; mismatch rejects snapshot creation */ + uint64_t deadline_ms; /* absolute cbm_now_ms() deadline; required, not a quiet timeout */ + unsigned command_limit; /* positive query-wide command count */ + size_t stdout_limit; /* positive per-command exact-byte cap */ + size_t stderr_limit; /* positive per-command diagnostic-byte cap */ + size_t total_output_limit; /* positive cumulative stdout+stderr cap across all commands */ + cbm_git_facts_cancel_fn cancelled; /* optional, must return promptly */ + void *cancel_context; /* borrowed until facts_free; callback runs on caller thread */ +} cbm_git_facts_options_t; + +typedef struct { + const char *root; /* Git-reported absolute top-level, possibly normalized from options.root */ + const char *git_dir; + const char *common_dir; + unsigned oid_hex_length; /* 40 (SHA-1) or 64 (SHA-256) */ + char head[65]; + char base[65]; + char merge_base[65]; /* unique ACTUAL merge-base --all result */ +} cbm_git_facts_identity_t; + +typedef struct cbm_git_facts cbm_git_facts_t; + +/* Copies option strings and owns all returned data in a CBMArena. Shallow + * repositories and missing/ambiguous common ancestors fail closed. All later + * source/diff operations use pinned OIDs, even if refs or worktree files change. + * Cancellation and LIMIT are sticky for this handle: later valid operations + * return the latched error without starting work or allocating path copies. + * Error always initializes when supplied; NULL means no usable snapshot. */ +cbm_git_facts_t *cbm_git_facts_open(const cbm_git_facts_options_t *options, + cbm_git_facts_error_t *error); +void cbm_git_facts_free(cbm_git_facts_t *facts); +const cbm_git_facts_identity_t *cbm_git_facts_identity(const cbm_git_facts_t *facts); + +typedef enum { + CBM_GIT_ANCESTRY_ERROR = -1, + CBM_GIT_ANCESTRY_NO = 0, + CBM_GIT_ANCESTRY_YES = 1 +} cbm_git_ancestry_t; + +/* Full OIDs of the snapshot's object format only. Normal exit 1 alone means NO; + * unavailable history/objects, cancellation, caps and other failures mean ERROR. */ +cbm_git_ancestry_t cbm_git_facts_is_ancestor(cbm_git_facts_t *facts, const char *ancestor_oid, + const char *descendant_oid, + cbm_git_facts_error_t *error); + +typedef enum { CBM_GIT_REV_HEAD = 0, CBM_GIT_REV_BASE, CBM_GIT_REV_MERGE_BASE } cbm_git_revision_t; + +typedef struct { + const unsigned char *data; + size_t length; +} cbm_git_bytes_t; + +typedef enum { + CBM_GIT_TREE_BLOB = 1, + CBM_GIT_TREE_COMMIT = 2 +} cbm_git_tree_object_type_t; + +typedef struct { + const char *path; /* owned NUL-terminated bytes; path_length is authoritative */ + size_t path_length; + uint32_t mode; /* 0100644/0100755 regular, 0120000 symlink, 0160000 gitlink */ + cbm_git_tree_object_type_t object_type; + char oid[65]; +} cbm_git_tree_entry_t; + +typedef struct { + const cbm_git_tree_entry_t *entries; + size_t count; +} cbm_git_tree_inventory_t; + +/* Complete recursive non-directory inventory of pinned HEAD or actual merge + * base only (BASE is INVALID). Includes symlink blobs and gitlink commits; + * callers decide which listed entries are relevant and unsupported. Literal + * path bytes, including non-UTF8/platform-unrepresentable names, are retained. + * No worktree/index inventory, filtering, path globbing or object-content read. + * Entries sort by unsigned path bytes, shorter equal prefix first. Invalid + * records/duplicate paths fail COMMAND; no successful prefix is exposed. + * Empty success is {NULL,0}; every failure clears out. All data lives until + * facts_free. Existing guards, cancellation/deadline and query budgets apply. */ +bool cbm_git_facts_inventory(cbm_git_facts_t *facts, cbm_git_revision_t revision, + cbm_git_tree_inventory_t *out, cbm_git_facts_error_t *error); + +typedef struct { + cbm_git_bytes_t bytes; + char oid[65]; + uint32_t mode; /* Git tree mode in ordinary octal notation, e.g. 0100644 */ +} cbm_git_blob_t; + +typedef enum { + CBM_GIT_BLOB_ERROR = -1, + CBM_GIT_BLOB_ABSENT = 0, + CBM_GIT_BLOB_FOUND = 1 +} cbm_git_blob_status_t; + +/* Literal repository-relative path bytes with explicit length, no NUL or + * absolute/dot/dot-dot components. Only regular blob modes are supported; + * symlinks, directories, gitlinks and unsupported argument encodings are ERROR. + * ABSENT requires a successful exact lookup in the pinned tree. Clears out on + * ABSENT/ERROR. No filesystem glob expansion, filters or symlink traversal. */ +cbm_git_blob_status_t cbm_git_facts_read_blob(cbm_git_facts_t *facts, cbm_git_revision_t revision, + const char *path, size_t path_length, + cbm_git_blob_t *out, cbm_git_facts_error_t *error); + +typedef struct { + cbm_git_bytes_t patch; /* pinned existing test-impact diff flags, including -U0 */ + cbm_git_bytes_t name_status; /* --name-status -z --no-renames, exact Git bytes */ +} cbm_git_diff_t; + +/* Unique pinned merge-base -> pinned HEAD only. Includes changed-path metadata + * so binary/mode/deletion-only changes cannot masquerade as an empty patch. + * Attributes may affect text/binary presentation and hunk headers; consumers + * must conservatively select a changed file without usable text hunks. + * Clears out on any failure. Returned bytes live until facts_free. */ +bool cbm_git_facts_diff(cbm_git_facts_t *facts, cbm_git_diff_t *out, cbm_git_facts_error_t *error); + +/* Compare full commit A to pinned HEAD only if A is an ancestor of the pinned + * actual merge-base. A is an explicit 40/64-byte hex span, copied before work. + * Same-owner calls are serialized; callbacks must not re-enter facts operations. + * This shares the existing owner, guards, executor and budgets. No patch is read. + * + * Only OK publishes the complete AMDT/NUL raw name/status stream. NOT_ANCESTOR + * is a normal exit-1 result with error.status OK and zero output, not an error + * or a poisoned owner. ERROR also clears output. Existing sticky errors apply. + * All successful buffers remain valid until facts_free, including earlier diff + * results. Empty OK has length zero, with either NULL or retained capture data. + * + * Output path bytes use raw inventory structural rules on every platform. + * No sort/deduplication or uniqueness/origin certificate is supplied; consumers + * validate cross-record identity separately. Empty A-to-H cannot determine the + * request's M-to-H has_changes, and this API does not authenticate artifact A. */ +typedef enum { + CBM_GIT_ANCESTOR_CHANGES_ERROR = -1, + CBM_GIT_ANCESTOR_CHANGES_NOT_ANCESTOR = 0, + CBM_GIT_ANCESTOR_CHANGES_OK = 1 +} cbm_git_ancestor_changes_status_t; + +cbm_git_ancestor_changes_status_t cbm_git_facts_ancestor_changes( + cbm_git_facts_t *facts, + const char *artifact_oid, size_t artifact_oid_length, + cbm_git_bytes_t *name_status, + cbm_git_facts_error_t *error); + +typedef struct { + cbm_git_revision_t revision; /* HEAD or actual MERGE_BASE; BASE is INVALID */ + const size_t *indices; /* indices into that revision's canonical pinned inventory */ + size_t count; /* duplicates allowed; NULL indices allowed exactly when count==0 */ +} cbm_git_blob_batch_request_t; + +typedef struct { + size_t max_entries; /* positive; bounds request.count, including duplicates */ + size_t max_input_bytes; /* positive; cumulative OID+LF stdin bytes for this call */ + size_t max_arena_bytes; /* positive; all Git-facts-owned logical arena requests during this call */ +} cbm_git_blob_batch_limits_t; + +typedef struct { + size_t inventory_index; + const cbm_git_tree_entry_t *entry; /* facts-owned canonical path/mode/type/OID */ + cbm_git_bytes_t bytes; /* exact blob contents; NOT promised NUL-terminated */ +} cbm_git_blob_batch_item_t; + +typedef struct { + cbm_git_revision_t revision; + const char *commit; /* facts-owned full pinned OID; also present on empty success */ + const cbm_git_blob_batch_item_t *items; + size_t count; +} cbm_git_blob_batch_t; + +/* Read selected regular blobs without caller-supplied path/OID pairs. Internally + * obtain/cache a completely validated inventory; indices have the same unsigned + * raw-path ordering as cbm_git_facts_inventory(). Allow 0100644 and 0100755 only. + * Symlink/gitlink selections are UNSUPPORTED; any index>=inventory.count is + * INVALID. Validate the entire selection before any cat-file command. + * + * Preserve caller order and every duplicate selection. Distinct paths and modes + * sharing one OID remain distinct items. Payload storage may alias between them; + * pointer identity is not an API promise. All returned pointers, including earlier + * successful results, remain immutable and valid until cbm_git_facts_free(). + * No one-process-per-file fallback, shell, source filters or worktree lookup. + * + * A synchronous all-or-error operation. Clear *out before other validation when + * out is non-NULL; initialize optional error on every call. Only true publishes + * items; false leaves every out field zero, including commit/items/count. Empty + * success has items=NULL,count=0 and the selected pinned revision/commit; it does + * not assert that the pinned tree itself is empty. Input views must be valid and + * immutable until return; callbacks may not mutate inputs or re-enter facts. + * Same-owner calls are serialized; different owners are independent. + * + * Native cat-file preflights ALL unique selected OIDs before ANY payload command. + * Exact frame sizes must fit the existing per-command stdout cap; an oversized + * single frame is LIMIT before payload capture. Output is then captured in + * bounded batches, and every count/order/OID/type/size/separator/EOF is validated. + * Malformed/missing frames are COMMAND, never ABSENT or a successful prefix. + * + * Existing command/output/cancellation/deadline/history/topology rules apply, + * including on inventory-cache hits and empty requests. Preflight headers, + * payload framing, diagnostics and guard output all consume existing budgets. + * New positive limits are per call and cannot raise those shared limits. LIMIT + * and cancellation use existing sticky owner errors; prior results survive. + * Parsing/copying/writing work polls at byte chunks no larger than 65536, and + * before final publication. A failure never retries after an owner latch. + * + * Input files are private, closed and immutable before spawn, and retained until + * terminal tree quiescence. Failed containment returns SUPERVISION and retains + * the whole capture directory, including stdin, under existing diagnostics. + * No legacy read_blob/inventory/diff/ancestor-change semantics are changed. */ +bool cbm_git_facts_read_blob_batch(cbm_git_facts_t *facts, + const cbm_git_blob_batch_request_t *request, + const cbm_git_blob_batch_limits_t *limits, + cbm_git_blob_batch_t *out, + cbm_git_facts_error_t *error); + + +#ifdef CBM_ENABLE_TEST_SEAMS +/* Deterministic wrappers around the actual production header/frame parsers. + * No Git, filesystem, environment, runtime fake transport, or facts owner. + * Required expected OIDs are full canonical lowercase strings of width 40/64. + * Header wrapper consumes exactly the first header, allowing following payload. + * It clears both outputs first; valid size overflow is LIMIT, bad grammar COMMAND. + * Capture wrapper validates exact count/order/EOF. In preflight mode sizes may + * be NULL (unknown); otherwise each parsed size must match expected_sizes[i]. + * Payload mode requires expected_sizes for nonzero count. Empty capture/count + * is valid. Bad pointer/width arguments are INVALID. No partial output views. */ +bool cbm_git_facts_test_batch_header(cbm_git_bytes_t capture, + const char *expected_oid, size_t oid_hex_length, + size_t *object_size, size_t *header_length, + cbm_git_facts_error_t *error); +bool cbm_git_facts_test_batch_capture(cbm_git_bytes_t capture, size_t oid_hex_length, + const char *const *expected_oids, + const size_t *expected_sizes, size_t count, + bool has_payload, cbm_git_facts_error_t *error); +#endif + + +/* Local Git identity/content does not authenticate a coverage artifact or + * establish that any index/graph generation covers this snapshot. */ +#endif diff --git a/src/mcp/test_impact_inventory.c b/src/mcp/test_impact_inventory.c new file mode 100644 index 000000000..5f853d456 --- /dev/null +++ b/src/mcp/test_impact_inventory.c @@ -0,0 +1,262 @@ +#include "mcp/test_impact_inventory_internal.h" +#include "discover/inventory_limits_internal.h" +#include "foundation/platform.h" +#include + +bool cni_fail(cni_context *c, cbm_inventory_status_t status) { + if (c->error.status == CBM_INVENTORY_OK) { + c->error.status = status; + c->error.file_index = c->file_index; + strcpy(c->error.diagnostic, "native inventory preparation could not complete"); + } + return false; +} + +bool cni_poll(cni_context *c) { + if (c->error.status != CBM_INVENTORY_OK) { + return false; + } + if (c->control->cancelled && c->control->cancelled(c->control->context)) { + return cni_fail(c, CBM_INVENTORY_CANCELLED); + } + if (cbm_now_ms() >= c->control->deadline_ms) { + return cni_fail(c, CBM_INVENTORY_DEADLINE); + } + c->byte_gap = CNI_BYTE_GAP; + c->event_gap = CNI_EVENT_GAP; + return true; +} + +bool cni_bytes(cni_context *c, size_t count) { + if (c->error.status != CBM_INVENTORY_OK) { + return false; + } + if (count > CNI_BYTE_GAP) { + return cni_fail(c, CBM_INVENTORY_STATE); + } + if (count > c->byte_gap && !cni_poll(c)) { + return false; + } + c->byte_gap -= count; + return true; +} + +bool cni_event(cni_context *c) { + if (c->error.status != CBM_INVENTORY_OK || (!c->event_gap && !cni_poll(c))) { + return false; + } + c->event_gap--; + return true; +} + +bool cni_copy(cni_context *c, void *out, const void *in, size_t length) { + unsigned char *to = out; + const unsigned char *from = in; + while (length) { + size_t count = length < CNI_COPY_CHUNK ? length : CNI_COPY_CHUNK; + if (!cni_bytes(c, count * 2)) { + return false; + } + if (from) { + memcpy(to, from, count); + from += count; + } else { + memset(to, 0, count); + } + to += count; + length -= count; + } + return true; +} + +static bool cni_arena_room(cni_context *c, const CBMArena *arena, size_t size) { + if (size > SIZE_MAX - 7) { + return cni_fail(c, CBM_INVENTORY_LIMIT); + } + size_t aligned = (size + 7) & ~(size_t)7; + if (arena->used > SIZE_MAX - aligned || arena->total_alloc > SIZE_MAX - aligned) { + return cni_fail(c, CBM_INVENTORY_LIMIT); + } + if (!arena->nblocks || arena->used + aligned > arena->block_size) { + size_t next = arena->grow_size > aligned ? arena->grow_size : aligned; + size_t tail = arena->block_size > arena->used ? arena->block_size - arena->used : 0; + if (next > SIZE_MAX / 2 || arena->waste_tail > SIZE_MAX - tail || + arena->waste_grows == SIZE_MAX) { + return cni_fail(c, CBM_INVENTORY_LIMIT); + } + } + return true; +} + +void *cni_alloc(cni_context *c, CBMArena *arena, size_t count, size_t width) { + if (!count || !width) { + return NULL; + } + if (count > SIZE_MAX / width) { + cni_fail(c, CBM_INVENTORY_LIMIT); + return NULL; + } + size_t size = count * width; + size_t available = c->owner->limits.max_arena_bytes - c->owner->limits.max_ignore_arena_bytes; + size_t *charged = &c->owner->usage.attachment_arena_requested_bytes; + if (*charged > available || size > available - *charged) { + cni_fail(c, CBM_INVENTORY_LIMIT); + return NULL; + } + if (!cni_poll(c) || !cni_arena_room(c, arena, size)) { + return NULL; + } + *charged += size; + void *result = cbm_arena_alloc(arena, size); + if (!result) { + cni_fail(c, CBM_INVENTORY_OOM); + return NULL; + } + return cni_poll(c) && cni_copy(c, result, NULL, size) ? result : NULL; +} + +static bool cni_delegate(cni_context *c, cbm_pinned_tree_t *tree, cbm_inventory_source_t *source) { + cbm_test_impact_inventory_t *owner = c->owner; + cbm_inventory_limits_t limits = owner->limits; + size_t charged = owner->usage.attachment_arena_requested_bytes; + c->file_index = SIZE_MAX; + if (charged > limits.max_arena_bytes - limits.max_ignore_arena_bytes) { + return cni_fail(c, CBM_INVENTORY_LIMIT); + } + if (!cni_poll(c)) { + return false; + } + limits.max_arena_bytes -= charged; + cni_reader reader = {.context = c, + .tree = tree, + .files = source->files, + .file_count = source->file_count, + .content_bound = (uint64_t)limits.max_control_file_bytes}; + source->read = cni_read; + source->read_context = &reader; + cbm_inventory_error_t error; + cbm_inventory_status_t status = + cbm_inventory_filter_prepare(source, &limits, c->control, &owner->filter, &error); + /* The filter owns all successful metadata; this release never refunds A. */ + cbm_arena_destroy(&c->temporary); + if (status != CBM_INVENTORY_OK) { + bool cleanup = c->error.cleanup_required || error.cleanup_required; + if (c->error.status == CBM_INVENTORY_OK) { + c->error = error; + } + c->error.cleanup_required = cleanup; + return false; + } + if (!owner->filter) { + return cni_fail(c, CBM_INVENTORY_STATE); + } + return true; +} + +static bool cni_publish(cni_context *c) { + cbm_test_impact_inventory_t *owner = c->owner; + cbm_test_impact_inventory_usage_t *usage = &owner->usage; + c->file_index = SIZE_MAX; + if (!cbm_inventory_filter_usage(owner->filter, &usage->filter)) { + return cni_fail(c, CBM_INVENTORY_STATE); + } + size_t a = usage->attachment_arena_requested_bytes; + size_t d = usage->filter.non_ignore_arena_requested_bytes; + size_t checked = usage->filter.ignore_arena_requested_bytes; + size_t i = owner->limits.max_ignore_arena_bytes; + size_t t = owner->limits.max_arena_bytes; + if (a > t - i || d > t - i - a || checked > i || + usage->filter.arena_requested_bytes != d + checked || + usage->filter.arena_budget_used_bytes != d + i) { + return cni_fail(c, CBM_INVENTORY_STATE); + } + usage->arena_requested_bytes = a + d + checked; + usage->arena_budget_used_bytes = a + d + i; + return cni_poll(c); +} + +cbm_inventory_status_t cbm_test_impact_inventory_prepare(cbm_pinned_tree_t *tree, + const cbm_inventory_limits_t *limits, + const cbm_inventory_control_t *control, + cbm_test_impact_inventory_t **out, + cbm_inventory_error_t *error) { + cbm_inventory_error_t initial = {.status = CBM_INVENTORY_OK, .file_index = SIZE_MAX}; + if (out) { + *out = NULL; + } + if (error) { + *error = initial; + } + cbm_test_impact_inventory_t bootstrap = {0}; + cni_context c = { + .owner = &bootstrap, .control = control, .error = initial, .file_index = SIZE_MAX}; + if (!out || !tree || !cif_limits_valid(limits) || !control || !control->deadline_ms) { + cni_fail(&c, CBM_INVENTORY_INVALID); + } else if (limits->max_control_total_bytes > UINT64_MAX - limits->max_ignore_work || + (size_t)(uint64_t)limits->max_control_file_bytes != limits->max_control_file_bytes) { + cni_fail(&c, CBM_INVENTORY_LIMIT); + } else if (cni_poll(&c)) { + bootstrap.limits = *limits; + bootstrap.deadline_ms = control->deadline_ms; + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(tree); + if (cni_binding(&c, view)) { + cbm_arena_init_lazy(&bootstrap.arena, CBM_ARENA_DEFAULT_BLOCK_SIZE); + cbm_arena_init_lazy(&c.temporary, CBM_ARENA_DEFAULT_BLOCK_SIZE); + cbm_test_impact_inventory_t *owner = cni_alloc(&c, &bootstrap.arena, 1, sizeof(*owner)); + if (owner && cni_copy(&c, owner, &bootstrap, sizeof(*owner))) { + c.owner = owner; + cbm_inventory_source_t source = {0}; + if (cni_snapshot(&c, view, &source) && cni_delegate(&c, tree, &source) && + cni_publish(&c)) { + *out = owner; + return CBM_INVENTORY_OK; + } + } + } + } + cbm_arena_destroy(&c.temporary); + cbm_test_impact_inventory_free(c.owner); + if (error) { + *error = c.error; + } + return c.error.status; +} + +const cbm_inventory_filter_view_t *cbm_test_impact_inventory_view( + const cbm_test_impact_inventory_t *owner) { + return owner ? cbm_inventory_filter_view(owner->filter) : NULL; +} + +bool cbm_test_impact_inventory_usage(const cbm_test_impact_inventory_t *owner, + cbm_test_impact_inventory_usage_t *out) { + if (!out) { + return false; + } + memset(out, 0, sizeof(*out)); + if (!owner) { + return false; + } + *out = owner->usage; + return true; +} + +bool cbm_test_impact_inventory_limits(const cbm_test_impact_inventory_t *owner, + cbm_inventory_limits_t *out) { + if (!out) { + return false; + } + memset(out, 0, sizeof(*out)); + if (!owner) { + return false; + } + *out = owner->limits; + return true; +} + +void cbm_test_impact_inventory_free(cbm_test_impact_inventory_t *owner) { + if (owner) { + cbm_inventory_filter_free(owner->filter); + CBMArena arena = owner->arena; + cbm_arena_destroy(&arena); + } +} diff --git a/src/mcp/test_impact_inventory.h b/src/mcp/test_impact_inventory.h new file mode 100644 index 000000000..0f40a7d1e --- /dev/null +++ b/src/mcp/test_impact_inventory.h @@ -0,0 +1,54 @@ +/* Internal pinned HEAD tree to pre-language inventory bridge. + * Source: src/mcp/test_impact_inventory.h. + * Normative bridge behavior: accompanying contract.txt. + */ +#ifndef CBM_TEST_IMPACT_INVENTORY_H +#define CBM_TEST_IMPACT_INVENTORY_H + +#include "discover/inventory_filter.h" +#include "mcp/test_impact_tree.h" + +typedef struct cbm_test_impact_inventory cbm_test_impact_inventory_t; + +typedef struct { + size_t attachment_arena_requested_bytes; /* A, including freed temporary inputs. */ + cbm_inventory_filter_usage_t filter; /* Existing D/C/I and read/work ledgers. */ + size_t arena_requested_bytes; /* A+D+C, actual logical requests. */ + size_t arena_budget_used_bytes; /* A+D+I, unavailable quota. */ +} cbm_test_impact_inventory_usage_t; + +/* Synchronous HEAD-only bridge from the complete READY native inventory. + * Requires tree/limits/control/out. Clears out and initializes optional error. + * Borrows the native owner; never verifies/closes/frees it independently. + * No provider argument: reads bind exact indices to this tree's read_prefix. + * Success owns one existing filter and original limits/deadline/accounting. + * Retains no native owner/storage, callback, control object or context. + * Failure publishes NULL; native disposal remains caller-owned, including + * after a read makes the native owner disposal-only. No automatic retry. + */ +cbm_inventory_status_t cbm_test_impact_inventory_prepare( + cbm_pinned_tree_t *tree, const cbm_inventory_limits_t *limits, + const cbm_inventory_control_t *control, cbm_test_impact_inventory_t **out, + cbm_inventory_error_t *error); + +/* Existing filter view, borrowed until wrapper free; NULL for NULL owner. + * NEEDS_LANGUAGE survivors remain unfinished. No IO/poll/allocation. + */ +const cbm_inventory_filter_view_t *cbm_test_impact_inventory_view( + const cbm_test_impact_inventory_t *owner); + +/* Clear out first; false for NULL owner/output. Copies diagnostics only. + * limits returns the caller's original limits, including original total T. + * The wrapped filter privately received T-A with unchanged I exactly once. + */ +bool cbm_test_impact_inventory_usage(const cbm_test_impact_inventory_t *owner, + cbm_test_impact_inventory_usage_t *out); +bool cbm_test_impact_inventory_limits(const cbm_test_impact_inventory_t *owner, + cbm_inventory_limits_t *out); + +/* NULL-safe. Frees the wrapped filter, then wrapper arena. No native IO, + * callbacks or tree disposal. Cannot overlap getters or another operation. + */ +void cbm_test_impact_inventory_free(cbm_test_impact_inventory_t *owner); + +#endif diff --git a/src/mcp/test_impact_inventory_input.c b/src/mcp/test_impact_inventory_input.c new file mode 100644 index 000000000..d05feef0e --- /dev/null +++ b/src/mcp/test_impact_inventory_input.c @@ -0,0 +1,201 @@ +#include "mcp/test_impact_inventory_internal.h" +#include + +static bool cni_oid(cni_context *c, const char *oid, size_t width) { + if (!cni_bytes(c, width + 1)) { + return false; + } + for (size_t i = 0; i < width; i++) { + unsigned char ch = (unsigned char)oid[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) { + return cni_fail(c, CBM_INVENTORY_INVALID); + } + } + return !oid[width] || cni_fail(c, CBM_INVENTORY_INVALID); +} + +bool cni_binding(cni_context *c, const cbm_pinned_tree_view_t *view) { + if (!view) { + return cni_fail(c, CBM_INVENTORY_STATE); + } + if (view->revision != CBM_GIT_REV_HEAD) { + bool supported_revision = + view->revision == CBM_GIT_REV_BASE || view->revision == CBM_GIT_REV_MERGE_BASE; + return cni_fail(c, supported_revision ? CBM_INVENTORY_UNSUPPORTED : CBM_INVENTORY_INVALID); + } + if (!view->identity || !view->commit || !view->root || (view->file_count && !view->files) || + !view->directory_count) { + return cni_fail(c, CBM_INVENTORY_INVALID); + } + size_t width = view->identity->oid_hex_length; + if (width != 40 && width != 64) { + return cni_fail(c, CBM_INVENTORY_INVALID); + } + if (!cni_oid(c, view->identity->head, width) || !cni_oid(c, view->commit, width) || + !cni_bytes(c, width * 2)) { + return false; + } + if (memcmp(view->identity->head, view->commit, width)) { + return cni_fail(c, CBM_INVENTORY_CHANGED); + } + if (view->file_count > c->owner->limits.max_files || + view->directory_count > c->owner->limits.max_directories) { + return cni_fail(c, CBM_INVENTORY_LIMIT); + } + return true; +} + +static char *cni_root(cni_context *c, const char *root) { + size_t length = 0; + for (;;) { + if (!cni_bytes(c, 1)) { + return NULL; + } + if (!root[length]) { + break; + } + if (length == CNI_PATH_MAX) { + cni_fail(c, CBM_INVENTORY_LIMIT); + return NULL; + } + length++; + } + if (!length) { + cni_fail(c, CBM_INVENTORY_INVALID); + return NULL; + } + char *copy = cni_alloc(c, &c->temporary, length + 1, 1); + return copy && cni_copy(c, copy, root, length + 1) ? copy : NULL; +} + +static bool cni_file(cni_context *c, const cbm_pinned_tree_file_t *input, + cbm_inventory_file_t *output, size_t oid_width) { + if (!cni_event(c) || !cni_bytes(c, 2 * (sizeof(*input) + sizeof(*output)))) { + return false; + } + size_t length = input->path_length; + if (!input->path || !length) { + return cni_fail(c, CBM_INVENTORY_INVALID); + } + if (length > CNI_PATH_MAX) { + return cni_fail(c, CBM_INVENTORY_LIMIT); + } + if (!cni_oid(c, input->oid, oid_width)) { + return false; + } + unsigned char *path = cni_alloc(c, &c->temporary, length + 1, 1); + if (!path || !cni_copy(c, path, input->path, length + 1) || + !cni_copy(c, output->oid, input->oid, oid_width + 1) || + !cni_copy(c, output->content_sha256, input->content_sha256, 32)) { + return false; + } + output->path = (cbm_inventory_path_t){path, length}; + output->git_mode = input->git_mode; + output->content_length = input->content_length; + return true; +} + +bool cni_snapshot(cni_context *c, const cbm_pinned_tree_view_t *view, + cbm_inventory_source_t *source) { + source->native_root = cni_root(c, view->root); + if (!source->native_root || !cni_copy(c, source->manifest_sha256, view->manifest_sha256, 32)) { + return false; + } + cbm_inventory_file_t *files = + view->file_count ? cni_alloc(c, &c->temporary, view->file_count, sizeof(*files)) : NULL; + if (view->file_count && !files) { + return false; + } + source->files = files; + source->file_count = view->file_count; + for (size_t i = 0; i < view->file_count; i++) { + c->file_index = i; + if (!cni_file(c, &view->files[i], &files[i], view->identity->oid_hex_length)) { + return false; + } + } + c->file_index = SIZE_MAX; + return true; +} + +static cbm_inventory_status_t cni_native_status(cbm_pinned_tree_status_t status) { + switch (status) { + case CBM_PINNED_TREE_OK: + return CBM_INVENTORY_OK; + case CBM_PINNED_TREE_INVALID: + return CBM_INVENTORY_INVALID; + case CBM_PINNED_TREE_UNSUPPORTED: + return CBM_INVENTORY_UNSUPPORTED; + case CBM_PINNED_TREE_LIMIT: + return CBM_INVENTORY_LIMIT; + case CBM_PINNED_TREE_OOM: + return CBM_INVENTORY_OOM; + case CBM_PINNED_TREE_IO: + return CBM_INVENTORY_IO; + case CBM_PINNED_TREE_CANCELLED: + return CBM_INVENTORY_CANCELLED; + case CBM_PINNED_TREE_DEADLINE: + return CBM_INVENTORY_DEADLINE; + case CBM_PINNED_TREE_CHANGED: + return CBM_INVENTORY_CHANGED; + case CBM_PINNED_TREE_COLLISION: + case CBM_PINNED_TREE_GIT: + case CBM_PINNED_TREE_CLEANUP_REQUIRED: + return CBM_INVENTORY_INVALID; + } + return CBM_INVENTORY_INVALID; +} + +static bool cni_read_result(cni_context *c, cbm_pinned_tree_status_t status, + const cbm_pinned_tree_error_t *error, size_t copied, size_t expected) { + c->error.cleanup_required |= status == CBM_PINNED_TREE_CLEANUP_REQUIRED || + error->status == CBM_PINNED_TREE_CLEANUP_REQUIRED; + if (status != error->status) { + return cni_fail(c, CBM_INVENTORY_INVALID); + } + if (status == CBM_PINNED_TREE_CLEANUP_REQUIRED) { + cbm_inventory_status_t cause = cni_native_status(error->cause); + return cni_fail(c, copied || cause == CBM_INVENTORY_OK ? CBM_INVENTORY_INVALID : cause); + } + if (error->cause != status) { + return cni_fail(c, CBM_INVENTORY_INVALID); + } + if (status != CBM_PINNED_TREE_OK) { + return cni_fail(c, copied ? CBM_INVENTORY_INVALID : cni_native_status(status)); + } + return copied == expected || cni_fail(c, CBM_INVENTORY_INVALID); +} + +cbm_inventory_status_t cni_read(void *context, size_t index, unsigned char *prefix, size_t capacity, + size_t *copied, const cbm_inventory_control_t *control, + cbm_inventory_error_t *error) { + cni_reader *reader = context; + cni_context *c = reader->context; + c->file_index = index < reader->file_count ? index : SIZE_MAX; + if (copied) { + *copied = 0; + } + if (!copied || !control || !control->deadline_ms || index >= reader->file_count || + (capacity && !prefix)) { + cni_fail(c, CBM_INVENTORY_INVALID); + } else if (cni_poll(c)) { + /* This length comes from the attachment, before the native view expires. */ + uint64_t length = reader->files[index].content_length; + size_t expected = length < capacity ? (size_t)length : capacity; + size_t native_copied = 0; + cbm_pinned_tree_error_t native_error = {0}; + cbm_pinned_tree_control_t native_control = {.deadline_ms = control->deadline_ms, + .cancelled = control->cancelled, + .cancel_context = control->context}; + cbm_pinned_tree_status_t status = + cbm_pinned_tree_read_prefix(reader->tree, index, reader->content_bound, prefix, + capacity, &native_copied, &native_control, &native_error); + if (cni_read_result(c, status, &native_error, native_copied, expected) && cni_poll(c)) { + *copied = native_copied; + } + } + if (error) { + *error = c->error; + } + return c->error.status; +} diff --git a/src/mcp/test_impact_inventory_internal.h b/src/mcp/test_impact_inventory_internal.h new file mode 100644 index 000000000..51e4a2cbe --- /dev/null +++ b/src/mcp/test_impact_inventory_internal.h @@ -0,0 +1,47 @@ +#ifndef CBM_TEST_IMPACT_INVENTORY_INTERNAL_H +#define CBM_TEST_IMPACT_INVENTORY_INTERNAL_H + +#include "mcp/test_impact_inventory.h" +#include "foundation/arena.h" + +enum { CNI_PATH_MAX = 4095, CNI_BYTE_GAP = 65536, CNI_EVENT_GAP = 4096, CNI_COPY_CHUNK = 4096 }; + +struct cbm_test_impact_inventory { + CBMArena arena; + cbm_inventory_filter_t *filter; + cbm_inventory_limits_t limits; + uint64_t deadline_ms; + cbm_test_impact_inventory_usage_t usage; +}; + +/* All borrowed values and temporary records are confined to this synchronous call. */ +typedef struct { + cbm_test_impact_inventory_t *owner; + CBMArena temporary; + const cbm_inventory_control_t *control; + cbm_inventory_error_t error; + size_t file_index, byte_gap, event_gap; +} cni_context; + +typedef struct { + cni_context *context; + cbm_pinned_tree_t *tree; + const cbm_inventory_file_t *files; + size_t file_count; + uint64_t content_bound; +} cni_reader; + +bool cni_fail(cni_context *c, cbm_inventory_status_t status); +bool cni_poll(cni_context *c); +bool cni_bytes(cni_context *c, size_t count); +bool cni_event(cni_context *c); +bool cni_copy(cni_context *c, void *out, const void *in, size_t length); +void *cni_alloc(cni_context *c, CBMArena *arena, size_t count, size_t width); +bool cni_binding(cni_context *c, const cbm_pinned_tree_view_t *view); +bool cni_snapshot(cni_context *c, const cbm_pinned_tree_view_t *view, + cbm_inventory_source_t *source); +cbm_inventory_status_t cni_read(void *context, size_t index, unsigned char *prefix, size_t capacity, + size_t *copied, const cbm_inventory_control_t *control, + cbm_inventory_error_t *error); + +#endif diff --git a/src/mcp/test_impact_origins.c b/src/mcp/test_impact_origins.c new file mode 100644 index 000000000..158137252 --- /dev/null +++ b/src/mcp/test_impact_origins.c @@ -0,0 +1,1074 @@ +/* Pure authenticated-evidence checker. The adapter supplies trust; this module + * validates exact bindings and joins whole-file origin claims, never proofs. */ +#include "mcp/test_impact_origins.h" + +#include "foundation/arena.h" +#include "foundation/sha256.h" +#include +#include + +enum { OR_BYTE_CHUNK = 65536, OR_ELEMENT_CHUNK = 4096 }; + +struct cbm_coverage_origin_join { + CBMArena arena; + cbm_coverage_origin_binding_t binding; + cbm_coverage_origin_path_t *paths; + size_t path_count; + int *ids; + size_t id_count; + unsigned reasons; + bool broad; + cbm_changes_state_t request_state; +}; + +typedef struct { + CBMArena arena; + cbm_coverage_origin_limits_t limits; + cbm_coverage_origin_cancel_fn cancelled; + void *cancel_context; + cbm_coverage_origin_status_t status; + uint64_t items; + size_t allocated; + unsigned operations; +} or_work_t; + +typedef struct { + or_work_t *work; + cbm_coverage_origin_bytes_t bytes; + size_t position; +} or_reader_t; + +typedef struct { + cbm_coverage_origin_bytes_t path; + unsigned state; + cbm_coverage_origin_disposition_t disposition; + unsigned edits; + bool proof; + int *ids; + size_t id_count; +} or_claim_t; + +typedef struct { + cbm_coverage_origin_bytes_t path; + char status; +} or_change_t; + +static bool or_fail(or_work_t *work, cbm_coverage_origin_status_t status) { + if (work->status == CBM_COVERAGE_ORIGIN_OK) { + work->status = status; + } + return false; +} + +static bool or_poll(or_work_t *work) { + if (work->status != CBM_COVERAGE_ORIGIN_OK) { + return false; + } + if (work->cancelled && work->cancelled(work->cancel_context)) { + return or_fail(work, CBM_COVERAGE_ORIGIN_CANCELLED); + } + return true; +} + +static bool or_step(or_work_t *work) { + if (work->status != CBM_COVERAGE_ORIGIN_OK) { + return false; + } + if (++work->operations == OR_ELEMENT_CHUNK) { + work->operations = 0; + return or_poll(work); + } + return true; +} + +static bool or_items(or_work_t *work, uint64_t count) { + if (count > work->limits.max_items - work->items) { + return or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); + } + work->items += count; + return or_step(work); +} + +static void *or_array(or_work_t *work, size_t count, size_t element_size) { + if (count == 0) { + return NULL; + } + if (element_size == 0 || count > SIZE_MAX / element_size) { + or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); + return NULL; + } + size_t bytes = count * element_size; + /* The arena rounds requests to its eight-byte alignment. */ + if (bytes > SIZE_MAX - 7 || bytes > work->limits.max_alloc_bytes - work->allocated) { + or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); + return NULL; + } + if (!or_poll(work)) { + return NULL; + } + work->allocated += bytes; + void *memory = cbm_arena_alloc(&work->arena, bytes); + if (!memory) { + or_fail(work, CBM_COVERAGE_ORIGIN_OOM); + return NULL; + } + return or_poll(work) ? memory : NULL; +} + +static bool or_copy(or_work_t *work, unsigned char *out, const unsigned char *input, + size_t length) { + for (size_t offset = 0; offset < length;) { + size_t count = length - offset; + if (count > OR_BYTE_CHUNK) { + count = OR_BYTE_CHUNK; + } + if (!or_poll(work)) { + return false; + } + memcpy(out + offset, input + offset, count); + offset += count; + if (!or_poll(work)) { + return false; + } + } + return true; +} + +static bool or_own(or_work_t *work, cbm_coverage_origin_bytes_t input, + cbm_coverage_origin_bytes_t *out) { + *out = (cbm_coverage_origin_bytes_t){0}; + if (!input.length) { + return true; + } + unsigned char *copy = or_array(work, input.length, 1); + if (!copy || !or_copy(work, copy, input.data, input.length)) { + return false; + } + *out = (cbm_coverage_origin_bytes_t){copy, input.length}; + return true; +} + +static bool or_compare(or_work_t *work, cbm_coverage_origin_bytes_t a, + cbm_coverage_origin_bytes_t b, int *order) { + if (!or_step(work)) { + return false; + } + size_t length = a.length < b.length ? a.length : b.length; + for (size_t offset = 0; offset < length;) { + size_t count = length - offset; + if (count > OR_BYTE_CHUNK) { + count = OR_BYTE_CHUNK; + } + if (!or_poll(work)) { + return false; + } + int compared = memcmp(a.data + offset, b.data + offset, count); + if (!or_poll(work)) { + return false; + } + if (compared) { + *order = compared < 0 ? -1 : 1; + return true; + } + offset += count; + } + *order = a.length < b.length ? -1 : a.length > b.length ? 1 : 0; + return true; +} + +static bool or_hash_matches(or_work_t *work, cbm_coverage_origin_bytes_t bytes, + const unsigned char expected[32]) { + if (!or_poll(work)) { + return false; + } + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + for (size_t offset = 0; offset < bytes.length;) { + size_t count = bytes.length - offset; + if (count > OR_BYTE_CHUNK) { + count = OR_BYTE_CHUNK; + } + if (!or_poll(work)) { + return false; + } + cbm_sha256_update(&hash, bytes.data + offset, count); + offset += count; + if (!or_poll(work)) { + return false; + } + } + unsigned char digest[32]; + cbm_sha256_final(&hash, digest); + if (!or_poll(work)) { + return false; + } + return memcmp(digest, expected, 32) == 0 || or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); +} + +static bool or_take(or_reader_t *reader, size_t count, const unsigned char **out) { + if (!or_step(reader->work)) { + return false; + } + if (count > reader->bytes.length - reader->position) { + return or_fail(reader->work, CBM_COVERAGE_ORIGIN_FORMAT); + } + *out = reader->bytes.data + reader->position; + reader->position += count; + return true; +} + +static bool or_u8(or_reader_t *reader, unsigned *out) { + const unsigned char *p; + if (!or_take(reader, 1, &p)) { + return false; + } + *out = p[0]; + return true; +} + +static bool or_u32(or_reader_t *reader, uint32_t *out) { + const unsigned char *p; + if (!or_take(reader, 4, &p)) { + return false; + } + *out = (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; + return true; +} + +static bool or_bytes(or_reader_t *reader, cbm_coverage_origin_bytes_t *out) { + uint32_t length; + const unsigned char *p; + if (!or_u32(reader, &length) || !or_take(reader, length, &p)) { + return false; + } + *out = (cbm_coverage_origin_bytes_t){p, length}; + return true; +} + +static bool or_proof(or_reader_t *reader, bool *out) { + unsigned present; + const unsigned char *ignored; + if (!or_u8(reader, &present)) { + return false; + } + if (present > 1) { + return or_fail(reader->work, CBM_COVERAGE_ORIGIN_FORMAT); + } + *out = present != 0; + return !present || or_take(reader, 32, &ignored); +} + +static bool or_path(or_work_t *work, cbm_coverage_origin_bytes_t path) { + if (!path.length || path.data[0] == '/' || path.data[path.length - 1] == '/') { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + size_t component = 0; + for (size_t offset = 0; offset < path.length;) { + size_t count = path.length - offset; + if (count > OR_BYTE_CHUNK) { + count = OR_BYTE_CHUNK; + } + if (!or_poll(work)) { + return false; + } + size_t end = offset + count; + for (; offset < end; offset++) { + if (!path.data[offset]) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + if (path.data[offset] == '/') { + size_t length = offset - component; + if (!length || (path.data[component] == '.' && + (length == 1 || (length == 2 && path.data[component + 1] == '.')))) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + component = offset + 1; + } + } + if (!or_poll(work)) { + return false; + } + } + size_t length = path.length - component; + if (path.data[component] == '.' && + (length == 1 || (length == 2 && path.data[component + 1] == '.'))) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + return true; +} + +static unsigned or_edit(char status) { + switch (status) { + case 'A': return CBM_COVERAGE_ORIGIN_EDIT_A; + case 'M': return CBM_COVERAGE_ORIGIN_EDIT_M; + case 'D': return CBM_COVERAGE_ORIGIN_EDIT_D; + case 'T': return CBM_COVERAGE_ORIGIN_EDIT_T; + default: return 0; + } +} + +static bool or_manifest(or_work_t *work, cbm_coverage_origin_bytes_t bytes, + const cbm_coverage_origin_binding_t *binding, int function_count, + or_claim_t **claims, size_t *claim_count, unsigned *reasons) { + or_reader_t r = {.work = work, .bytes = bytes}; + static const unsigned char magic[] = "cbm-coverage-origins"; + const unsigned char *p; + uint32_t version, count; + unsigned format, closed; + bool proof; + cbm_coverage_origin_bytes_t key; + int order; + if (!or_take(&r, sizeof(magic), &p)) { + return false; + } + if (memcmp(p, magic, sizeof(magic)) != 0) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + if (!or_u32(&r, &version)) { + return false; + } + if (version != CBM_COVERAGE_ORIGIN_FORMAT_VERSION) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + if (!or_bytes(&r, &key) || !or_u8(&r, &format)) { + return false; + } + if (!key.length || (format != 1 && format != 2)) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + if (!or_compare(work, key, binding->repository_key, &order)) { + return false; + } + if (order || format != (unsigned)binding->object_format) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + size_t oid_size = format == 1 ? 20 : 32; + if (!or_take(&r, oid_size, &p)) { + return false; + } + if (memcmp(p, binding->artifact.bytes, oid_size) != 0) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + if (!or_take(&r, 32, &p)) { + return false; + } + if (memcmp(p, binding->functions_sha256, 32) != 0) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + if (!or_u32(&r, &count)) { + return false; + } + if (count > INT_MAX) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + if (count != (uint32_t)function_count) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + if (!or_take(&r, 32, &p)) { + return false; + } + if (memcmp(p, binding->compatibility_sha256, 32) != 0) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + if (!or_take(&r, 32, &p)) { + return false; + } + if (memcmp(p, binding->producer_profile_sha256, 32) != 0) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + if (!or_u8(&r, &closed)) { + return false; + } + if (closed > 1) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + if (!or_proof(&r, &proof) || !or_u32(&r, &count)) { + return false; + } + *reasons = closed && proof ? 0 : CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN; + /* Even an ALL row needs at least nine bytes, plus the final terminator. */ + size_t remaining = r.bytes.length - r.position; + if (!remaining || (size_t)count > (remaining - 1) / 9) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + if (!or_items(work, count)) { + return false; + } + or_claim_t *rows = or_array(work, count, sizeof(*rows)); + if (count && !rows) { + return false; + } + for (size_t i = 0; i < count; i++) { + or_claim_t row = {0}; + unsigned disposition; + if (!or_bytes(&r, &row.path) || !or_path(work, row.path)) { + return false; + } + if (i) { + if (!or_compare(work, rows[i - 1].path, row.path, &order)) { + return false; + } + if (order >= 0) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + } + if (!or_u8(&r, &row.state)) { + return false; + } + if (row.state > 3) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + if (row.state >= 2) { + uint32_t mode; + if (!or_u32(&r, &mode) || !or_take(&r, oid_size, &p)) { + return false; + } + if ((row.state == 2 && mode != 0100644 && mode != 0100755 && mode != 0120000) || + (row.state == 3 && mode != 0160000)) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + } + if (!or_u8(&r, &disposition) || !or_u8(&r, &row.edits)) { + return false; + } + if (disposition > CBM_COVERAGE_ORIGIN_ALL || (row.edits & ~15u)) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + row.disposition = (cbm_coverage_origin_disposition_t)disposition; + if (!or_proof(&r, &row.proof)) { + return false; + } + if (row.disposition != CBM_COVERAGE_ORIGIN_ALL) { + uint32_t members; + if (!or_u32(&r, &members)) { + return false; + } + if (members > (uint32_t)function_count || + (size_t)members > (r.bytes.length - r.position) / 4) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + if (!or_items(work, members)) { + return false; + } + row.ids = or_array(work, members, sizeof(*row.ids)); + if (members && !row.ids) { + return false; + } + int previous = -1; + for (size_t j = 0; j < members; j++) { + uint32_t id; + if (!or_u32(&r, &id)) { + return false; + } + if (id >= (uint32_t)function_count || (j && id <= (uint32_t)previous)) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + row.ids[j] = (int)id; + previous = (int)id; + } + row.id_count = members; + } + rows[i] = row; + } + unsigned end; + if (!or_u8(&r, &end)) { + return false; + } + if (end != 0x7f || r.position != r.bytes.length) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + *claims = rows; + *claim_count = count; + return true; +} + +static bool or_next_change(or_work_t *work, cbm_coverage_origin_bytes_t bytes, size_t *position, + or_change_t *out, bool validate_path) { + size_t start = *position; + if (!or_step(work)) { + return false; + } + if (bytes.length - start < 4 || !or_edit((char)bytes.data[start]) || bytes.data[start + 1]) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + size_t path_start = start + 2; + size_t end = path_start; + bool found = false; + while (end < bytes.length) { + size_t count = bytes.length - end; + if (count > OR_BYTE_CHUNK) { + count = OR_BYTE_CHUNK; + } + if (!or_poll(work)) { + return false; + } + const unsigned char *zero = memchr(bytes.data + end, 0, count); + if (!or_poll(work)) { + return false; + } + if (zero) { + end = (size_t)(zero - bytes.data); + found = true; + break; + } + end += count; + } + if (!found) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + *out = (or_change_t){.path = {bytes.data + path_start, end - path_start}, + .status = (char)bytes.data[start]}; + if (validate_path && !or_path(work, out->path)) { + return false; + } + *position = end + 1; + return true; +} + +static bool or_change_sift(or_work_t *work, or_change_t *rows, size_t count, size_t root) { + while (root < count / 2) { + size_t child = root * 2 + 1; + int order; + if (child + 1 < count) { + if (!or_compare(work, rows[child].path, rows[child + 1].path, &order)) { + return false; + } + if (order < 0) { + child++; + } + } + if (!or_compare(work, rows[root].path, rows[child].path, &order)) { + return false; + } + if (order >= 0) { + break; + } + or_change_t saved = rows[root]; + rows[root] = rows[child]; + rows[child] = saved; + root = child; + } + return true; +} + +static bool or_changes(or_work_t *work, cbm_coverage_origin_bytes_t bytes, + or_change_t **changes, size_t *change_count) { + size_t count = 0, position = 0; + or_change_t ignored; + while (position < bytes.length) { + if (!or_items(work, 1) || !or_next_change(work, bytes, &position, &ignored, true)) { + return false; + } + if (count == SIZE_MAX) { + return or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); + } + count++; + } + or_change_t *rows = or_array(work, count, sizeof(*rows)); + if (count && !rows) { + return false; + } + position = 0; + for (size_t i = 0; i < count; i++) { + if (!or_next_change(work, bytes, &position, &rows[i], false)) { + return false; + } + } + for (size_t i = count / 2; i > 0; i--) { + if (!or_change_sift(work, rows, count, i - 1)) { + return false; + } + } + for (size_t i = count; i > 1; i--) { + or_change_t saved = rows[0]; + rows[0] = rows[i - 1]; + rows[i - 1] = saved; + if (!or_step(work) || !or_change_sift(work, rows, i - 1, 0)) { + return false; + } + } + for (size_t i = 1; i < count; i++) { + int order; + if (!or_compare(work, rows[i - 1].path, rows[i].path, &order)) { + return false; + } + if (order == 0) { + return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); + } + } + *changes = rows; + *change_count = count; + return true; +} + +static bool or_reconcile(or_work_t *work, const cbm_changes_t *request, + const cbm_coverage_origin_binding_t *binding, + const or_change_t *ah, size_t ah_count, + const or_change_t *mh, size_t mh_count, cbm_changes_state_t *state) { + size_t count; + const cbm_change_file_t *files = cbm_changes_files(request, &count); + if (count != mh_count || (count && !files)) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + *state = cbm_changes_state(request); + if ((*state == CBM_CHANGES_EMPTY && count) || (*state == CBM_CHANGES_NONEMPTY && !count) || + (*state != CBM_CHANGES_EMPTY && *state != CBM_CHANGES_NONEMPTY && + *state != CBM_CHANGES_UNKNOWN)) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + for (size_t i = 0; i < count; i++) { + int order; + if (files[i].status != mh[i].status || files[i].path_length != mh[i].path.length || + !files[i].path) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + cbm_coverage_origin_bytes_t path = {files[i].path, files[i].path_length}; + if (!or_compare(work, path, mh[i].path, &order)) { + return false; + } + if (order) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + } + if (memcmp(binding->merge_base.bytes, binding->head.bytes, 32) == 0 && mh_count) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + if (memcmp(binding->artifact.bytes, binding->head.bytes, 32) == 0 && (ah_count || mh_count)) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + if (memcmp(binding->artifact.bytes, binding->merge_base.bytes, 32) == 0) { + if (ah_count != mh_count) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + for (size_t i = 0; i < ah_count; i++) { + int order; + if (ah[i].status != mh[i].status) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + if (!or_compare(work, ah[i].path, mh[i].path, &order)) { + return false; + } + if (order) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + } + } + return true; +} + +static bool or_read_id(or_work_t *work, const int *ids, size_t index, int *id) { + if (!or_items(work, 1)) { + return false; + } + *id = ids[index]; + return true; +} + +static bool or_id_sift(or_work_t *work, int *ids, size_t count, size_t root) { + while (root < count / 2) { + size_t child = root * 2 + 1; + int child_id, root_id; + if (!or_read_id(work, ids, child, &child_id)) { + return false; + } + if (child + 1 < count) { + int other; + if (!or_read_id(work, ids, child + 1, &other)) { + return false; + } + if (other > child_id) { + child++; + child_id = other; + } + } + if (!or_read_id(work, ids, root, &root_id)) { + return false; + } + if (root_id >= child_id) { + break; + } + ids[root] = child_id; + ids[child] = root_id; + root = child; + } + return true; +} + +static bool or_union_ids(or_work_t *work, cbm_coverage_origin_join_t *result) { + if (result->broad) { + return true; /* The shared ALL array already is the complete union. */ + } + size_t total = 0; + for (size_t i = 0; i < result->path_count; i++) { + if (!or_step(work)) { + return false; + } + if (result->paths[i].function_count > work->limits.max_result_ids) { + return or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); + } + if (result->paths[i].function_count > SIZE_MAX - total) { + return or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); + } + total += result->paths[i].function_count; + } + /* Reserve the known copy visits before allocating the temporary ID bag. */ + if (!or_items(work, (uint64_t)total)) { + return false; + } + int *ids = or_array(work, total, sizeof(*ids)); + if (total && !ids) { + return false; + } + size_t used = 0; + for (size_t i = 0; i < result->path_count; i++) { + if (!or_step(work)) { + return false; + } + for (size_t j = 0; j < result->paths[i].function_count; j++) { + if (!or_step(work)) { + return false; + } + ids[used++] = result->paths[i].function_ids[j]; + } + } + for (size_t i = total / 2; i > 0; i--) { + if (!or_id_sift(work, ids, total, i - 1)) { + return false; + } + } + for (size_t i = total; i > 1; i--) { + int first, last; + if (!or_read_id(work, ids, 0, &first) || !or_read_id(work, ids, i - 1, &last)) { + return false; + } + ids[0] = last; + ids[i - 1] = first; + if (!or_id_sift(work, ids, i - 1, 0)) { + return false; + } + } + size_t unique = 0; + int previous = -1; + for (size_t i = 0; i < total; i++) { + int id; + if (!or_read_id(work, ids, i, &id)) { + return false; + } + if (!unique || id != previous) { + if (unique == work->limits.max_result_ids) { + return or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); + } + ids[unique++] = id; + previous = id; + } + } + result->ids = ids; + result->id_count = unique; + return true; +} + +static bool or_join_paths(or_work_t *work, cbm_coverage_origin_join_t *result, + const or_claim_t *claims, size_t claim_count, + const or_change_t *ah, size_t ah_count, + const or_change_t *mh, size_t mh_count, int function_count) { + if (ah_count > SIZE_MAX - mh_count) { + return or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); + } + size_t capacity = ah_count + mh_count; + result->paths = or_array(work, capacity, sizeof(*result->paths)); + if (capacity && !result->paths) { + return false; + } + size_t ai = 0, mi = 0, ci = 0; + while (ai < ah_count || mi < mh_count) { + if (!or_step(work)) { + return false; + } + int order = ai == ah_count ? 1 : mi == mh_count ? -1 : 0; + if (ai < ah_count && mi < mh_count && + !or_compare(work, ah[ai].path, mh[mi].path, &order)) { + return false; + } + cbm_coverage_origin_path_t path = {0}; + if (order <= 0) { + path.path = ah[ai].path; + path.artifact_status = ah[ai++].status; + path.comparisons |= CBM_COVERAGE_ORIGIN_FROM_ARTIFACT; + } + if (order >= 0) { + path.path = mh[mi].path; + path.merge_base_status = mh[mi++].status; + path.comparisons |= CBM_COVERAGE_ORIGIN_FROM_MERGE_BASE; + } + if (path.artifact_status && path.merge_base_status && + ((path.artifact_status == 'D') != (path.merge_base_status == 'D'))) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + int claim_order = -1; + while (ci < claim_count) { + if (!or_compare(work, claims[ci].path, path.path, &claim_order)) { + return false; + } + if (claim_order >= 0) { + break; + } + ci++; + } + if (ci == claim_count || claim_order != 0) { + path.reasons = CBM_COVERAGE_ORIGIN_PATH_MISSING; + } else { + const or_claim_t *claim = &claims[ci]; + path.disposition = claim->disposition; + path.supported_edits = claim->edits; + if (!claim->state) { + path.reasons |= CBM_COVERAGE_ORIGIN_PRESENCE_UNKNOWN; + } else { + bool present_at_a = claim->state >= 2; + bool expected_present = path.artifact_status + ? path.artifact_status != 'A' + : path.merge_base_status != 'D'; + if (present_at_a != expected_present) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + } + if (claim->disposition == CBM_COVERAGE_ORIGIN_UNKNOWN) { + path.reasons |= CBM_COVERAGE_ORIGIN_CLAIM_UNKNOWN; + } else if (!claim->proof) { + path.reasons |= CBM_COVERAGE_ORIGIN_CLAIM_UNPROVEN; + } + unsigned required = or_edit(path.artifact_status) | or_edit(path.merge_base_status); + if (required & ~claim->edits) { + path.reasons |= CBM_COVERAGE_ORIGIN_EDIT_UNSUPPORTED; + } + if (claim->disposition == CBM_COVERAGE_ORIGIN_ALL) { + if (!result->broad) { + if ((size_t)function_count > work->limits.max_result_ids) { + return or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); + } + if (!or_items(work, (uint64_t)function_count)) { + return false; + } + result->ids = or_array(work, (size_t)function_count, sizeof(*result->ids)); + if (function_count && !result->ids) { + return false; + } + for (int i = 0; i < function_count; i++) { + if (!or_step(work)) { + return false; + } + result->ids[i] = i; + } + result->id_count = (size_t)function_count; + } + result->broad = true; + path.function_ids = result->ids; + path.function_count = result->id_count; + } else { + path.function_ids = claim->ids; + path.function_count = claim->id_count; + } + } + result->reasons |= path.reasons; + result->paths[result->path_count++] = path; + } + return or_union_ids(work, result); +} + +static int or_hex_digit(unsigned char ch) { + if (ch >= '0' && ch <= '9') { + return ch - '0'; + } + return ch >= 'a' && ch <= 'f' ? ch - 'a' + 10 : -1; +} + +static bool or_map(or_work_t *work, const cbm_coverage_map_t *map, + const unsigned char expected[32], int *count) { + cbm_coverage_format_t format = cbm_coverage_map_format(map); + if (format != CBM_COVERAGE_FORMAT_FUNCTIONS && format != CBM_COVERAGE_FORMAT_PROFILES) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + const char *hex = cbm_coverage_map_identity_sha256(map); + if (!hex) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + for (size_t i = 0; i < 32; i++) { + int high = or_hex_digit((unsigned char)hex[i * 2]); + int low = or_hex_digit((unsigned char)hex[i * 2 + 1]); + if (high < 0 || low < 0 || (unsigned char)((high << 4) | low) != expected[i]) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + } + if (hex[64]) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + *count = cbm_coverage_map_id_count(map); + if (*count < 0) { + return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); + } + if (!or_items(work, (uint64_t)*count)) { + return false; + } + /* Opaque map constructors guarantee dense IDs. Retain the existing + * per-ID budget/cancellation visits without projecting identity rows. */ + for (int i = 0; i < *count; i++) { + if (!or_step(work)) { + return false; + } + } + return true; +} + +static bool or_span_valid(cbm_coverage_origin_bytes_t span) { + return span.data || !span.length; +} + +cbm_coverage_origin_status_t cbm_coverage_origin_join( + const cbm_coverage_origin_input_t *input, const cbm_coverage_origin_limits_t *limits, + cbm_coverage_origin_cancel_fn cancelled, void *cancel_context, + cbm_coverage_origin_join_t **out) { + if (out) { + *out = NULL; + } + if (!out || !input || !limits || !input->coverage || !input->request_changes || + !input->context || !or_span_valid(input->manifest) || !input->manifest.length || + !or_span_valid(input->artifact_to_head) || !or_span_valid(input->merge_base_to_head) || + !limits->max_input_bytes || limits->max_input_bytes > UINT64_MAX / 8 || + !limits->max_items || !limits->max_alloc_bytes || !limits->max_result_ids || + limits->max_result_ids > INT_MAX) { + return CBM_COVERAGE_ORIGIN_INVALID; + } + const cbm_coverage_origin_context_t *context = input->context; + const cbm_coverage_origin_binding_t *binding = &context->binding; + if (!or_span_valid(binding->repository_key) || !binding->repository_key.length || + binding->repository_key.length > UINT32_MAX || + (binding->object_format != CBM_COVERAGE_ORIGIN_GIT_SHA1 && + binding->object_format != CBM_COVERAGE_ORIGIN_GIT_SHA256) || + binding->manifest_version != CBM_COVERAGE_ORIGIN_FORMAT_VERSION) { + return CBM_COVERAGE_ORIGIN_INVALID; + } + if (binding->object_format == CBM_COVERAGE_ORIGIN_GIT_SHA1) { + static const unsigned char zero[12] = {0}; + if (memcmp(binding->artifact.bytes + 20, zero, 12) || + memcmp(binding->merge_base.bytes + 20, zero, 12) || + memcmp(binding->head.bytes + 20, zero, 12)) { + return CBM_COVERAGE_ORIGIN_INVALID; + } + } + if (!context->artifact_admitted || !context->origin_source_verified || + !context->origin_attestations_verified || !context->comparisons_verified) { + return CBM_COVERAGE_ORIGIN_UNVERIFIED; + } + const size_t lengths[] = {input->manifest.length, input->artifact_to_head.length, + input->merge_base_to_head.length, binding->repository_key.length}; + uint64_t total = 0; + for (size_t i = 0; i < sizeof(lengths) / sizeof(lengths[0]); i++) { + if ((uint64_t)lengths[i] != lengths[i] || + (uint64_t)lengths[i] > limits->max_input_bytes - total) { + return CBM_COVERAGE_ORIGIN_LIMIT; + } + total += (uint64_t)lengths[i]; + } + or_work_t work = {.limits = *limits, .cancelled = cancelled, + .cancel_context = cancel_context, .status = CBM_COVERAGE_ORIGIN_OK}; + cbm_arena_init_lazy(&work.arena, 1024); + cbm_coverage_origin_join_t *result; + cbm_coverage_origin_bytes_t manifest, ah_bytes, mh_bytes; + or_claim_t *claims; + or_change_t *ah, *mh; + size_t claim_count, ah_count, mh_count; + unsigned reasons; + int function_count; + if (!or_poll(&work) || + !or_hash_matches(&work, input->manifest, binding->manifest_sha256) || + !or_hash_matches(&work, input->artifact_to_head, context->artifact_to_head_sha256) || + !or_hash_matches(&work, input->merge_base_to_head, context->merge_base_to_head_sha256) || + !or_map(&work, input->coverage, binding->functions_sha256, &function_count) || + !or_own(&work, input->manifest, &manifest) || + !or_own(&work, input->artifact_to_head, &ah_bytes) || + !or_own(&work, input->merge_base_to_head, &mh_bytes) || + !or_manifest(&work, manifest, binding, function_count, &claims, &claim_count, &reasons) || + !or_changes(&work, ah_bytes, &ah, &ah_count) || + !or_changes(&work, mh_bytes, &mh, &mh_count)) { + goto failure; + } + result = or_array(&work, 1, sizeof(*result)); + if (!result) { + goto failure; + } + /* Avoid a bulk arena calloc: all large byte operations are chunk-polled. */ + result->binding = *binding; + result->paths = NULL; + result->path_count = 0; + result->ids = NULL; + result->id_count = 0; + result->reasons = reasons; + result->broad = false; + result->request_state = CBM_CHANGES_UNKNOWN; + if (!or_own(&work, binding->repository_key, &result->binding.repository_key) || + !or_reconcile(&work, input->request_changes, binding, ah, ah_count, mh, mh_count, + &result->request_state)) { + goto failure; + } + if (result->request_state == CBM_CHANGES_UNKNOWN) { + result->reasons |= CBM_COVERAGE_ORIGIN_REQUEST_UNKNOWN; + } + if (!or_join_paths(&work, result, claims, claim_count, ah, ah_count, mh, mh_count, + function_count) || !or_poll(&work)) { + goto failure; + } + result->arena = work.arena; + *out = result; + return CBM_COVERAGE_ORIGIN_OK; + +failure: + cbm_arena_destroy(&work.arena); + return work.status; +} + +void cbm_coverage_origin_join_free(cbm_coverage_origin_join_t *join) { + if (join) { + CBMArena arena = join->arena; + cbm_arena_destroy(&arena); + } +} + +const cbm_coverage_origin_binding_t *cbm_coverage_origin_join_binding( + const cbm_coverage_origin_join_t *join) { + return join ? &join->binding : NULL; +} + +const int *cbm_coverage_origin_join_ids(const cbm_coverage_origin_join_t *join, int *count) { + if (count) { + *count = join ? (int)join->id_count : 0; + } + return join ? join->ids : NULL; +} + +const cbm_coverage_origin_path_t *cbm_coverage_origin_join_paths( + const cbm_coverage_origin_join_t *join, size_t *count) { + if (count) { + *count = join ? join->path_count : 0; + } + return join ? join->paths : NULL; +} + +bool cbm_coverage_origin_join_complete(const cbm_coverage_origin_join_t *join) { + return join && !join->reasons; +} + +bool cbm_coverage_origin_join_broad_fallback_required(const cbm_coverage_origin_join_t *join) { + return join && join->broad; +} + +bool cbm_coverage_origin_join_can_narrow(const cbm_coverage_origin_join_t *join) { + return cbm_coverage_origin_join_complete(join) && + !cbm_coverage_origin_join_broad_fallback_required(join); +} + +unsigned cbm_coverage_origin_join_reasons(const cbm_coverage_origin_join_t *join) { + return join ? join->reasons : CBM_COVERAGE_ORIGIN_REQUEST_UNKNOWN; +} + +cbm_changes_state_t cbm_coverage_origin_join_request_state(const cbm_coverage_origin_join_t *join) { + return join ? join->request_state : CBM_CHANGES_UNKNOWN; +} diff --git a/src/mcp/test_impact_origins.h b/src/mcp/test_impact_origins.h new file mode 100644 index 000000000..3b19a52bb --- /dev/null +++ b/src/mcp/test_impact_origins.h @@ -0,0 +1,167 @@ +/* Pure internal whole-file origin join; the adapter supplies authenticated evidence. */ +#ifndef CBM_TEST_IMPACT_ORIGINS_H +#define CBM_TEST_IMPACT_ORIGINS_H + +#include "mcp/test_impact.h" +#include "mcp/test_impact_changes.h" +#include +#include +#include + +#define CBM_COVERAGE_ORIGIN_FORMAT_VERSION 2u +#define CBM_COVERAGE_ORIGIN_SHA256_BYTES 32u + +typedef struct { + const unsigned char *data; + size_t length; +} cbm_coverage_origin_bytes_t; + +typedef enum { + CBM_COVERAGE_ORIGIN_GIT_SHA1 = 1, + CBM_COVERAGE_ORIGIN_GIT_SHA256 = 2 +} cbm_coverage_origin_object_format_t; + +typedef struct { + unsigned char bytes[32]; /* SHA-1 uses first 20; remaining 12 MUST be zero. */ +} cbm_coverage_origin_oid_t; + +typedef struct { + /* Provider-namespaced nonempty opaque identity; never infer from root/URL. */ + cbm_coverage_origin_bytes_t repository_key; + cbm_coverage_origin_object_format_t object_format; + cbm_coverage_origin_oid_t artifact; /* A */ + cbm_coverage_origin_oid_t merge_base; /* unique actual M */ + cbm_coverage_origin_oid_t head; /* pinned H */ + /* Historical field/wire slot: exact identity-table byte digest for this map. + * Format 1: original functions.tsv. Format 2: original profiles.tsv, including + * its wire-version/image/count header and raw-name/hash/counter-count rows. + * The digest binds bytes; it does not authenticate the map or its producer. */ + unsigned char functions_sha256[32]; + uint32_t manifest_version; + unsigned char manifest_sha256[32]; + unsigned char compatibility_sha256[32]; + unsigned char producer_profile_sha256[32]; +} cbm_coverage_origin_binding_t; + +typedef struct { + /* Trusted adapter facts, NEVER copied from parsed manifest declarations. */ + /* Independently established, format-appropriate admission for this exact + * map and its test observations. A v1 receipt cannot admit a v2 map; + * successful parsing supplies no admission evidence. */ + bool artifact_admitted; + bool origin_source_verified; /* authenticated repository/A/table/manifest tuple */ + bool origin_attestations_verified; /* supported producer claim/proof semantics */ + bool comparisons_verified; /* complete same-owner A ancestor of actual M, pinned H */ + cbm_coverage_origin_binding_t binding; + unsigned char artifact_to_head_sha256[32]; + unsigned char merge_base_to_head_sha256[32]; +} cbm_coverage_origin_context_t; + +typedef struct { + const cbm_coverage_map_t *coverage; + const cbm_changes_t *request_changes; /* D4a inventory/state; no hunk use */ + cbm_coverage_origin_bytes_t manifest; + cbm_coverage_origin_bytes_t artifact_to_head; + cbm_coverage_origin_bytes_t merge_base_to_head; + const cbm_coverage_origin_context_t *context; +} cbm_coverage_origin_input_t; + +typedef struct { + uint64_t max_input_bytes; /* manifest + AH + MH + context key; <= UINT64_MAX/8 */ + uint64_t max_items; /* decoded records, borrowed map checks and ID visits; see spec */ + size_t max_alloc_bytes; /* cumulative logical arena allocation requests */ + size_t max_result_ids; /* distinct final union cardinality; positive, <= INT_MAX */ +} cbm_coverage_origin_limits_t; + +typedef bool (*cbm_coverage_origin_cancel_fn)(void *context); + +typedef enum { + CBM_COVERAGE_ORIGIN_OK = 0, + CBM_COVERAGE_ORIGIN_INVALID, + CBM_COVERAGE_ORIGIN_FORMAT, + CBM_COVERAGE_ORIGIN_BINDING, + CBM_COVERAGE_ORIGIN_UNVERIFIED, + CBM_COVERAGE_ORIGIN_LIMIT, + CBM_COVERAGE_ORIGIN_CANCELLED, + CBM_COVERAGE_ORIGIN_OOM +} cbm_coverage_origin_status_t; + +typedef enum { + CBM_COVERAGE_ORIGIN_UNKNOWN = 0, + CBM_COVERAGE_ORIGIN_COMPLETE_IDS = 1, + CBM_COVERAGE_ORIGIN_ALL = 2 +} cbm_coverage_origin_disposition_t; + +enum { + CBM_COVERAGE_ORIGIN_EDIT_A = 1u << 0, + CBM_COVERAGE_ORIGIN_EDIT_M = 1u << 1, + CBM_COVERAGE_ORIGIN_EDIT_D = 1u << 2, + CBM_COVERAGE_ORIGIN_EDIT_T = 1u << 3 +}; + +enum { + CBM_COVERAGE_ORIGIN_FROM_ARTIFACT = 1u << 0, + CBM_COVERAGE_ORIGIN_FROM_MERGE_BASE = 1u << 1 +}; + +enum { + CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN = 1u << 0, + CBM_COVERAGE_ORIGIN_PATH_MISSING = 1u << 1, + CBM_COVERAGE_ORIGIN_PRESENCE_UNKNOWN = 1u << 2, + CBM_COVERAGE_ORIGIN_CLAIM_UNKNOWN = 1u << 3, + CBM_COVERAGE_ORIGIN_CLAIM_UNPROVEN = 1u << 4, + CBM_COVERAGE_ORIGIN_EDIT_UNSUPPORTED = 1u << 5, + CBM_COVERAGE_ORIGIN_REQUEST_UNKNOWN = 1u << 6 +}; + +typedef struct { + cbm_coverage_origin_bytes_t path; /* owned NUL-terminated; length authoritative */ + unsigned comparisons; + char artifact_status; /* A/M/D/T or zero when absent from that comparison */ + char merge_base_status; + cbm_coverage_origin_disposition_t disposition; /* UNKNOWN for missing row */ + unsigned supported_edits; /* zero for missing row */ + unsigned reasons; /* local gaps only; global gaps affect overall completeness */ + const int *function_ids; /* owned ascending unique positive evidence */ + size_t function_count; +} cbm_coverage_origin_path_t; + +typedef struct cbm_coverage_origin_join cbm_coverage_origin_join_t; + +/* Clear *out before all other validation. Borrowed spans, map, D4a owner and + * context stay immutable during this synchronous call. Callback is prompt, + * non-reentrant and cannot mutate/free inputs. All dynamic storage belongs to + * one result-owned CBMArena; mutable state is invocation-local. + * + * OK returns a fully evaluated owner, possibly incomplete and/or requiring + * broad fallback. All other statuses return *out=NULL and release call-owned + * storage. No partial prefix, retained borrowed pointer, or mutation of another + * owner. Missing evidence is OK/incomplete; malformed input, wrong binding, + * missing admission, cancel, limit and OOM are errors. No inferred identities. + * The adapter MUST use can_narrow, never complete alone. */ +cbm_coverage_origin_status_t cbm_coverage_origin_join( + const cbm_coverage_origin_input_t *input, + const cbm_coverage_origin_limits_t *limits, + cbm_coverage_origin_cancel_fn cancelled, void *cancel_context, + cbm_coverage_origin_join_t **out); +void cbm_coverage_origin_join_free(cbm_coverage_origin_join_t *join); + +/* Views are immutable until join_free; path views may share one owned ALL ID + * array. NULL join: pointer=NULL/count=0, complete=false, broad=false, + * can_narrow=false, reasons=REQUEST_UNKNOWN, request_state=UNKNOWN. */ +const cbm_coverage_origin_binding_t *cbm_coverage_origin_join_binding( + const cbm_coverage_origin_join_t *join); +const int *cbm_coverage_origin_join_ids(const cbm_coverage_origin_join_t *join, int *count); +const cbm_coverage_origin_path_t *cbm_coverage_origin_join_paths( + const cbm_coverage_origin_join_t *join, size_t *count); +bool cbm_coverage_origin_join_complete(const cbm_coverage_origin_join_t *join); +bool cbm_coverage_origin_join_broad_fallback_required(const cbm_coverage_origin_join_t *join); +/* Exactly complete(join) && !broad_fallback_required(join). Necessary evidence + * only: it cannot upgrade other independent selector completeness flags. */ +bool cbm_coverage_origin_join_can_narrow(const cbm_coverage_origin_join_t *join); +unsigned cbm_coverage_origin_join_reasons(const cbm_coverage_origin_join_t *join); +/* Preserve cross-checked D4a M->H state. Never derive EMPTY from AH or ID count. */ +cbm_changes_state_t cbm_coverage_origin_join_request_state( + const cbm_coverage_origin_join_t *join); + +#endif diff --git a/src/mcp/test_impact_policy.c b/src/mcp/test_impact_policy.c new file mode 100644 index 000000000..aeb892894 --- /dev/null +++ b/src/mcp/test_impact_policy.c @@ -0,0 +1,1044 @@ +/* Query-local test-selection policy. Project rules retain their declared order. + */ +#include "foundation/arena.h" +#include "mcp/test_impact.h" +#include +#include +#include +#include + +enum { + TP_MAX_PATTERN = 65536, + TP_MAX_EXPANSIONS = 4096, + TP_MAX_EXPANDED_BYTES = 262144, + TP_MAX_BRACE_DEPTH = 16, + TP_MAX_MATCH_BYTES = 65536, + TP_MATCH_MAX_WORK = 16 * 1024 * 1024 +}; + +typedef enum { + TP_LITERAL, + TP_QUESTION, + TP_STAR, + TP_DOUBLE_STAR, + TP_DOUBLE_STAR_SLASH, + TP_CLASS +} tp_token_kind_t; + +typedef struct { + tp_token_kind_t kind; + unsigned char literal; + const unsigned char *class_bits; +} tp_token_t; + +typedef struct tp_pattern tp_pattern_t; +struct tp_pattern { + const char *text; + size_t len; + tp_pattern_t *next; + tp_token_t *tokens; + size_t token_count; + bool rooted; + bool dir_only; + bool disabled; +}; + +typedef struct { + tp_pattern_t *head; + tp_pattern_t *tail; + bool perf; +} tp_patterns_t; + +typedef struct { + tp_patterns_t paths; + tp_patterns_t suites; +} tp_rule_matchers_t; + +typedef struct { + tp_patterns_t suites; + tp_patterns_t excludes; +} tp_lane_matchers_t; + +struct cbm_test_policy { + CBMArena arena; + cbm_test_rule_t *rules; + tp_rule_matchers_t *rule_matchers; + int rule_count; + cbm_test_lane_t *lanes; + tp_lane_matchers_t *lane_matchers; + int lane_count; + const char *digest; + size_t expansion_count; + size_t expanded_bytes; +}; + +/* Expansion uses an acyclic graph with shared continuations. Its explicit + * traversal stack bounds stack use even for thousands of adjacent groups. */ +typedef struct tp_expand_node tp_expand_node_t; +struct tp_expand_node { + const char *text; + size_t len; + tp_expand_node_t *next; + tp_expand_node_t *alternatives; + tp_expand_node_t *sibling; +}; + +typedef struct { + tp_expand_node_t *head; + tp_expand_node_t *tail; +} tp_chain_t; + +typedef struct { + CBMArena arena; + const char *text; + size_t len; + size_t pos; + size_t alternative_count; + bool valid; +} tp_expander_t; + +static yyjson_val *tp_field(yyjson_val *object, const char *name, bool *valid) { + if (!yyjson_is_obj(object)) { + *valid = false; + return NULL; + } + yyjson_val *found = NULL, *key, *value; + size_t index, maximum, len = strlen(name); + yyjson_obj_foreach(object, index, maximum, key, value) { + if (yyjson_get_len(key) == len && memcmp(yyjson_get_str(key), name, len) == 0) { + if (found) { + *valid = false; + return NULL; + } + found = value; + } + } + return found; +} + +static const char *tp_string(yyjson_val *value, size_t *len) { + const char *text = yyjson_get_str(value); + size_t n = yyjson_get_len(value); + if (!text || !n || strlen(text) != n) + return NULL; + if (len) + *len = n; + return text; +} + +static bool tp_pattern_valid(const char *text, size_t len) { + if (!text || !len || len > TP_MAX_PATTERN || text[0] == '!' || text[0] == '#' || + memchr(text, '\0', len) || memchr(text, '\n', len) || memchr(text, '\r', len)) + return false; + if (text[len - 1] == ' ' || text[len - 1] == '\t') { + size_t escapes = 0; + for (size_t i = len - 1; i && text[i - 1] == '\\'; i--) + escapes++; + if (!(escapes & 1U)) + return false; + } + return true; +} + +/* Braces and commas inside a well-formed class are not expansion syntax. + * An unmatched '[' is a literal; it does not hide later brace syntax. */ +static size_t tp_class_end(const char *text, size_t len, size_t open) { + size_t i = open + 1; + if (i < len && (text[i] == '!' || text[i] == '^')) + i++; + if (i < len && text[i] == ']') + i++; + for (; i < len; i++) { + if (text[i] == '\\' && i + 1 < len) { + i++; + continue; + } + if (text[i] == ']') + return i + 1; + } + return open + 1; +} + +static tp_expand_node_t *tp_expand_node(tp_expander_t *exp, const char *text, size_t len) { + tp_expand_node_t *node = cbm_arena_calloc(&exp->arena, sizeof(*node)); + if (!node) { + exp->valid = false; + return NULL; + } + node->text = text; + node->len = len; + return node; +} + +static void tp_chain_append(tp_chain_t *chain, tp_chain_t add) { + if (!add.head) + return; + if (chain->tail) + chain->tail->next = add.head; + else + chain->head = add.head; + chain->tail = add.tail; +} + +static void tp_chain_literal(tp_expander_t *exp, tp_chain_t *chain, const char *text, size_t len) { + tp_expand_node_t *node = tp_expand_node(exp, text, len); + tp_chain_t one = {node, node}; + tp_chain_append(chain, one); +} + +static tp_chain_t tp_parse_sequence(tp_expander_t *exp, unsigned depth, bool in_group); + +static tp_chain_t tp_parse_group(tp_expander_t *exp, unsigned depth) { + tp_chain_t result = {0}; + size_t open = exp->pos++; + if (depth >= TP_MAX_BRACE_DEPTH) { + exp->valid = false; + return result; + } + tp_expand_node_t *first = NULL, *last = NULL; + tp_expand_node_t *join = tp_expand_node(exp, NULL, 0); + size_t count = 0; + while (exp->valid) { + tp_chain_t branch = tp_parse_sequence(exp, depth + 1, true); + if (!exp->valid) + return result; + if (!branch.head) + tp_chain_literal(exp, &branch, NULL, 0); + if (!exp->valid) + return result; + branch.tail->next = join; + if (last) + last->sibling = branch.head; + else + first = branch.head; + last = branch.head; + count++; + if (exp->pos >= exp->len) { + exp->valid = false; + return result; + } + if (exp->text[exp->pos] == '}') { + exp->pos++; + break; + } + /* parse_sequence stops at either a comma or a closing brace. */ + exp->pos++; + } + if (!exp->valid) + return result; + if (count == 1) { + /* A group with no comma preserves its braces, while nested groups + * still expand: {a{b,c}} yields {ab} and {ac}. */ + tp_chain_literal(exp, &result, exp->text + open, 1); + if (!exp->valid) + return (tp_chain_t){0}; + result.tail->next = first; + result.tail = join; + tp_chain_literal(exp, &result, exp->text + exp->pos - 1, 1); + } else { + tp_expand_node_t *choice = tp_expand_node(exp, NULL, 0); + if (!choice) + return (tp_chain_t){0}; + choice->alternatives = first; + result.head = choice; + result.tail = join; + exp->alternative_count += count; + } + return result; +} + +static tp_chain_t tp_parse_sequence(tp_expander_t *exp, unsigned depth, bool in_group) { + tp_chain_t result = {0}; + size_t literal = exp->pos; + while (exp->pos < exp->len && exp->valid) { + char ch = exp->text[exp->pos]; + if (ch == '\\' && exp->pos + 1 < exp->len) { + exp->pos += 2; + continue; + } + if (ch == '[') { + exp->pos = tp_class_end(exp->text, exp->len, exp->pos); + continue; + } + if (ch == '}' || (in_group && ch == ',')) { + if (!in_group) + exp->valid = false; + break; + } + if (ch == '{') { + if (exp->pos > literal) + tp_chain_literal(exp, &result, exp->text + literal, exp->pos - literal); + tp_chain_t group = tp_parse_group(exp, depth); + tp_chain_append(&result, group); + literal = exp->pos; + } else { + exp->pos++; + } + } + if (exp->valid && exp->pos > literal) + tp_chain_literal(exp, &result, exp->text + literal, exp->pos - literal); + return result; +} + +static unsigned char tp_class_char(const char *text, size_t end, size_t *pos) { + if (text[*pos] == '\\' && *pos + 1 < end) + (*pos)++; + return (unsigned char)text[(*pos)++]; +} + +static bool tp_compile_class(cbm_test_policy_t *policy, const char *text, size_t open, size_t end, + tp_token_t *token) { + unsigned char *bits = cbm_arena_calloc(&policy->arena, 32); + if (!bits) + return false; + size_t pos = open + 1, close = end - 1; + bool negate = text[pos] == '!' || text[pos] == '^'; + if (negate) + pos++; + while (pos < close) { + unsigned char first = tp_class_char(text, close, &pos); + if (pos + 1 < close && text[pos] == '-') { + pos++; + unsigned char last = tp_class_char(text, close, &pos); + for (unsigned ch = first; ch <= last; ch++) + bits[ch >> 3] |= (unsigned char)(1U << (ch & 7U)); + } else { + bits[first >> 3] |= (unsigned char)(1U << (first & 7U)); + } + } + if (negate) { + for (int i = 0; i < 32; i++) + bits[i] = (unsigned char)~bits[i]; + } + /* A class, even a negated class or an explicit slash range, cannot + * consume a path separator. */ + bits[(unsigned)'/' >> 3] &= (unsigned char)~(1U << ((unsigned)'/' & 7U)); + token->kind = TP_CLASS; + token->class_bits = bits; + return true; +} + +static bool tp_compile_pattern(cbm_test_policy_t *policy, tp_pattern_t *pattern, bool path_mode) { + const char *text = pattern->text; + size_t start = 0, end = pattern->len; + if (path_mode) { + if (text[0] == '/') { + pattern->rooted = true; + start++; + } + size_t escapes = 0; + for (size_t i = end - 1; i && text[i - 1] == '\\'; i--) + escapes++; + if (text[end - 1] == '/' && !(escapes & 1U)) { + pattern->dir_only = true; + end--; + } + if (end <= start) { + pattern->disabled = true; + return true; + } + /* As in gitignore discovery, any interior slash anchors a path + * pattern, even when it appears within a class or is escaped. */ + if (memchr(text + start, '/', end - start)) + pattern->rooted = true; + } + pattern->tokens = cbm_arena_calloc(&policy->arena, (end - start) * sizeof(tp_token_t)); + if (!pattern->tokens) + return false; + for (size_t pos = start; pos < end;) { + tp_token_t *token = &pattern->tokens[pattern->token_count++]; + unsigned char ch = (unsigned char)text[pos++]; + if (ch == '\\' && pos < end) { + token->kind = TP_LITERAL; + token->literal = (unsigned char)text[pos++]; + } else if (ch == '*') { + size_t star_start = pos - 1; + while (pos < end && text[pos] == '*') + pos++; + /* Globstar is special only as an entire path component. An + * embedded run such as a**b is an ordinary star and + * must neither cross nor consume a separator. */ + bool globstar = pos - star_start >= 2 && + (star_start == start || text[star_start - 1] == '/') && + (pos == end || text[pos] == '/'); + if (globstar && pos < end) { + token->kind = TP_DOUBLE_STAR_SLASH; + pattern->rooted = path_mode; + pos++; + } else { + token->kind = globstar ? TP_DOUBLE_STAR : TP_STAR; + } + } else if (ch == '?') { + token->kind = TP_QUESTION; + } else if (ch == '[') { + size_t class_end = tp_class_end(text, end, pos - 1); + if (class_end > pos) { + if (!tp_compile_class(policy, text, pos - 1, class_end, token)) + return false; + pos = class_end; + } else { + token->kind = TP_LITERAL; + token->literal = ch; + } + } else { + token->kind = TP_LITERAL; + token->literal = ch; + } + if (path_mode && token->kind == TP_LITERAL && token->literal == '/') + pattern->rooted = true; + } + return true; +} + +static bool tp_add_expansion(cbm_test_policy_t *policy, tp_patterns_t *patterns, const char *text, + size_t len, bool semantic_perf) { + if (!tp_pattern_valid(text, len) || policy->expansion_count >= TP_MAX_EXPANSIONS || + len > TP_MAX_EXPANDED_BYTES - policy->expanded_bytes) + return false; + policy->expansion_count++; + policy->expanded_bytes += len; + if (semantic_perf && len == 5 && memcmp(text, "@perf", 5) == 0) { + patterns->perf = true; + return true; + } + tp_pattern_t *pattern = cbm_arena_calloc(&policy->arena, sizeof(*pattern)); + if (!pattern) + return false; + pattern->text = cbm_arena_strndup(&policy->arena, text, len); + if (!pattern->text) + return false; + pattern->len = len; + if (!tp_compile_pattern(policy, pattern, !semantic_perf)) + return false; + if (patterns->tail) + patterns->tail->next = pattern; + else + patterns->head = pattern; + patterns->tail = pattern; + return true; +} + +typedef struct { + tp_expand_node_t *node; + size_t used; +} tp_expand_frame_t; + +static bool tp_expand_pattern(cbm_test_policy_t *policy, tp_patterns_t *patterns, const char *text, + size_t len, bool semantic_perf) { + if (!tp_pattern_valid(text, len)) + return false; + tp_expander_t exp = {.text = text, .len = len, .valid = true}; + cbm_arena_init_lazy(&exp.arena, 4096); + tp_chain_t chain = tp_parse_sequence(&exp, 0, false); + if (!exp.valid || exp.pos != len) + goto invalid; + size_t capacity = exp.alternative_count + 1; + tp_expand_frame_t *stack = cbm_arena_alloc(&exp.arena, capacity * sizeof(*stack)); + char *output = cbm_arena_alloc(&exp.arena, TP_MAX_PATTERN + 1U); + if (!stack || !output) + goto invalid; + size_t pending = 0; + stack[pending++] = (tp_expand_frame_t){chain.head, 0}; + while (pending) { + tp_expand_frame_t frame = stack[--pending]; + size_t used = frame.used; + tp_expand_node_t *node = frame.node; + bool branched = false; + while (node) { + if (node->alternatives) { + size_t first_pending = pending; + for (tp_expand_node_t *alt = node->alternatives; alt; alt = alt->sibling) { + if (pending >= capacity) + goto invalid; + stack[pending++] = (tp_expand_frame_t){alt, used}; + } + /* The LIFO stack visits alternatives in their source order. */ + for (size_t left = first_pending, right = pending - 1; left < right; + left++, right--) { + tp_expand_frame_t swap = stack[left]; + stack[left] = stack[right]; + stack[right] = swap; + } + branched = true; + break; + } + if (node->len > TP_MAX_PATTERN - used) + goto invalid; + if (node->len) + memcpy(output + used, node->text, node->len); + used += node->len; + node = node->next; + } + if (!branched) { + output[used] = '\0'; + if (!tp_add_expansion(policy, patterns, output, used, semantic_perf)) + goto invalid; + } + } + cbm_arena_destroy(&exp.arena); + return true; +invalid: + cbm_arena_destroy(&exp.arena); + return false; +} + +/* Public arrays retain original spellings. Expanded patterns are separate, + * so receipt/reporting clients do not lose the project configuration. */ +static bool tp_string_array(cbm_test_policy_t *policy, yyjson_val *value, bool nonempty, + const char *const **result, int *count, tp_patterns_t *patterns, + bool semantic_perf) { + if (!yyjson_is_arr(value)) + return false; + size_t size = yyjson_arr_size(value); + if ((nonempty && !size) || size > INT_MAX || size > SIZE_MAX / sizeof(char *)) + return false; + const char **items = size ? cbm_arena_alloc(&policy->arena, size * sizeof(*items)) : NULL; + if (size && !items) + return false; + size_t index, maximum; + yyjson_val *item; + yyjson_arr_foreach(value, index, maximum, item) { + size_t len = 0; + const char *text = tp_string(item, &len); + if (!text) + return false; + items[index] = cbm_arena_strndup(&policy->arena, text, len); + if (!items[index] || + (patterns && !tp_expand_pattern(policy, patterns, text, len, semantic_perf))) + return false; + } + *result = items; + *count = (int)size; + return true; +} + +static bool tp_parse_lanes(cbm_test_policy_t *policy, yyjson_val *value) { + if (!yyjson_is_arr(value) || !yyjson_arr_size(value) || yyjson_arr_size(value) > INT_MAX) + return false; + size_t count = yyjson_arr_size(value); + if (count > SIZE_MAX / sizeof(*policy->lanes) || + count > SIZE_MAX / sizeof(*policy->lane_matchers)) + return false; + policy->lanes = cbm_arena_calloc(&policy->arena, count * sizeof(*policy->lanes)); + policy->lane_matchers = + cbm_arena_calloc(&policy->arena, count * sizeof(*policy->lane_matchers)); + if (!policy->lanes || !policy->lane_matchers) + return false; + policy->lane_count = (int)count; + size_t index, maximum; + yyjson_val *value_lane; + yyjson_arr_foreach(value, index, maximum, value_lane) { + bool valid = true; + yyjson_val *name = tp_field(value_lane, "name", &valid); + yyjson_val *suites = tp_field(value_lane, "suites", &valid); + yyjson_val *excludes = tp_field(value_lane, "exclude_suites", &valid); + yyjson_val *signals = tp_field(value_lane, "signals", &valid); + yyjson_val *default_action = tp_field(value_lane, "default", &valid); + yyjson_val *narrow = tp_field(value_lane, "narrow", &valid); + const char *name_text = tp_string(name, NULL); + const char *default_text = tp_string(default_action, NULL); + if (!valid || !name_text || !default_text || + (strcmp(default_text, "run") != 0 && strcmp(default_text, "skip") != 0) || + (narrow && !yyjson_is_bool(narrow))) + return false; + for (size_t earlier = 0; earlier < index; earlier++) { + if (strcmp(policy->lanes[earlier].name, name_text) == 0) + return false; + } + cbm_test_lane_t *lane = &policy->lanes[index]; + tp_lane_matchers_t *matchers = &policy->lane_matchers[index]; + lane->name = cbm_arena_strdup(&policy->arena, name_text); + lane->default_run = strcmp(default_text, "run") == 0; + lane->narrow = narrow && yyjson_get_bool(narrow); + if (!lane->name || + !tp_string_array(policy, suites, true, &lane->suites, &lane->suite_count, + &matchers->suites, true) || + (excludes && !tp_string_array(policy, excludes, false, &lane->exclude_suites, + &lane->exclude_count, &matchers->excludes, true)) || + (signals && !tp_string_array(policy, signals, false, &lane->signals, + &lane->signal_count, NULL, false))) + return false; + } + return true; +} + +static bool tp_key_segment(yyjson_val *value, int *segment) { + const char *text = tp_string(value, NULL); + static const char prefix[] = "path_segment:"; + if (!text || strncmp(text, prefix, sizeof(prefix) - 1) != 0) + return false; + text += sizeof(prefix) - 1; + if (!*text) + return false; + unsigned result = 0; + for (; *text; text++) { + if (*text < '0' || *text > '9') + return false; + unsigned digit = (unsigned)(*text - '0'); + if (result > ((unsigned)INT_MAX - digit) / 10U) + return false; + result = result * 10U + digit; + } + *segment = (int)result; + return true; +} + +static const char *const tp_build_paths[] = { + "Makefile*", "CMakeLists.txt", "*.cmake", "meson.build", "BUILD*", + "WORKSPACE", "package*.json", "go.mod", "go.sum", "Cargo.*", + "pom.xml", "build.gradle*", "pyproject.toml", "setup.*", "requirements*.txt"}; +static const char *const tp_ci_paths[] = {".github/**", ".gitlab-ci.yml", ".circleci/**", + "Jenkinsfile*", ".buildkite/**", "azure-pipelines*.yml"}; +static const char *const tp_vendor_paths[] = {"vendor*/**", "third_party/**"}; +static const char *const tp_conftest_paths[] = {"conftest.py"}; +static const char *const tp_documentation_paths[] = {"docs/**", "**/*.md", "LICENSE*"}; + +typedef struct { + const char *id; + cbm_test_rule_action_t action; + const char *const *paths; + size_t path_count; +} tp_builtin_t; + +#define TP_COUNT(array) (sizeof(array) / sizeof((array)[0])) +static const tp_builtin_t tp_builtins[] = { + {"builtin:build", CBM_TEST_RULE_RUN_ALL, tp_build_paths, TP_COUNT(tp_build_paths)}, + {"builtin:ci", CBM_TEST_RULE_RUN_ALL, tp_ci_paths, TP_COUNT(tp_ci_paths)}, + {"builtin:vendor", CBM_TEST_RULE_RUN_ALL, tp_vendor_paths, TP_COUNT(tp_vendor_paths)}, + {"builtin:conftest", CBM_TEST_RULE_RUN_ALL, tp_conftest_paths, TP_COUNT(tp_conftest_paths)}, + {"builtin:documentation", CBM_TEST_RULE_IGNORE, tp_documentation_paths, + TP_COUNT(tp_documentation_paths)}}; + +static bool tp_parse_rule(cbm_test_policy_t *policy, yyjson_val *value, int index) { + bool valid = true; + yyjson_val *id = tp_field(value, "id", &valid); + yyjson_val *paths = tp_field(value, "paths", &valid); + yyjson_val *action = tp_field(value, "action", &valid); + yyjson_val *lanes = tp_field(value, "lanes", &valid); + yyjson_val *suites = tp_field(value, "suites", &valid); + yyjson_val *key = tp_field(value, "key", &valid); + const char *id_text = tp_string(id, NULL), *action_text = tp_string(action, NULL); + if (!valid || !id_text || !action_text) + return false; + for (int earlier = 0; earlier < index; earlier++) { + if (strcmp(policy->rules[earlier].id, id_text) == 0) + return false; + } + for (size_t builtin = 0; builtin < TP_COUNT(tp_builtins); builtin++) { + if (strcmp(tp_builtins[builtin].id, id_text) == 0) + return false; + } + cbm_test_rule_t *rule = &policy->rules[index]; + tp_rule_matchers_t *matchers = &policy->rule_matchers[index]; + rule->key_segment = -1; + rule->id = cbm_arena_strdup(&policy->arena, id_text); + if (!rule->id || !tp_string_array(policy, paths, true, &rule->paths, &rule->path_count, + &matchers->paths, false)) + return false; + if (strcmp(action_text, "run_all") == 0) { + rule->action = CBM_TEST_RULE_RUN_ALL; + return !lanes && !suites && !key; + } + if (strcmp(action_text, "ignore") == 0) { + rule->action = CBM_TEST_RULE_IGNORE; + return !lanes && !suites && !key; + } + if (strcmp(action_text, "lanes") == 0) { + rule->action = CBM_TEST_RULE_LANES; + return !suites && !key && + tp_string_array(policy, lanes, true, &rule->lanes, &rule->lane_count, NULL, false); + } + if (strcmp(action_text, "suites") == 0) { + rule->action = CBM_TEST_RULE_SUITES; + return !lanes && !key && + tp_string_array(policy, suites, true, &rule->suites, &rule->suite_count, + &matchers->suites, true); + } + if (strcmp(action_text, "referencing_tests") == 0) { + rule->action = CBM_TEST_RULE_REFERENCING_TESTS; + return !lanes && !suites && tp_key_segment(key, &rule->key_segment); + } + return false; +} + +static bool tp_copy_patterns(cbm_test_policy_t *policy, const char *const *source, size_t count, + const char *const **result, tp_patterns_t *patterns, + bool semantic_perf) { + const char **items = cbm_arena_alloc(&policy->arena, count * sizeof(*items)); + if (!items) + return false; + for (size_t i = 0; i < count; i++) { + items[i] = cbm_arena_strdup(&policy->arena, source[i]); + if (!items[i] || + !tp_expand_pattern(policy, patterns, source[i], strlen(source[i]), semantic_perf)) + return false; + } + *result = items; + return true; +} + +static bool tp_default_lane(cbm_test_policy_t *policy) { + static const char *const suites[] = {"*"}; + static const char *const excludes[] = {"@perf"}; + policy->lanes = cbm_arena_calloc(&policy->arena, sizeof(*policy->lanes)); + policy->lane_matchers = cbm_arena_calloc(&policy->arena, sizeof(*policy->lane_matchers)); + if (!policy->lanes || !policy->lane_matchers) + return false; + policy->lane_count = 1; + cbm_test_lane_t *lane = policy->lanes; + lane->name = cbm_arena_strdup(&policy->arena, "unit"); + lane->suite_count = 1; + lane->exclude_count = 1; + lane->default_run = true; + lane->narrow = true; + return lane->name && + tp_copy_patterns(policy, suites, 1, &lane->suites, &policy->lane_matchers->suites, + true) && + tp_copy_patterns(policy, excludes, 1, &lane->exclude_suites, + &policy->lane_matchers->excludes, true); +} + +static bool tp_add_builtins(cbm_test_policy_t *policy, int first) { + for (size_t i = 0; i < TP_COUNT(tp_builtins); i++) { + const tp_builtin_t *source = &tp_builtins[i]; + cbm_test_rule_t *rule = &policy->rules[first + (int)i]; + rule->id = cbm_arena_strdup(&policy->arena, source->id); + rule->action = source->action; + rule->builtin = true; + rule->key_segment = -1; + rule->path_count = (int)source->path_count; + if (!rule->id || !tp_copy_patterns(policy, source->paths, source->path_count, &rule->paths, + &policy->rule_matchers[first + (int)i].paths, false)) + return false; + } + return true; +} + +static bool tp_parse_policy(cbm_test_policy_t *policy, yyjson_val *impact) { + bool valid = true; + yyjson_val *rules = NULL, *lanes = NULL; + if (impact) { + yyjson_val *version = tp_field(impact, "version", &valid); + rules = tp_field(impact, "rules", &valid); + lanes = tp_field(impact, "lanes", &valid); + yyjson_val *unmapped = tp_field(impact, "unmapped_files", &valid); + if (!valid || !yyjson_is_uint(version) || yyjson_get_uint(version) != 1) + return false; + if (unmapped) { + const char *text = tp_string(unmapped, NULL); + if (!text || strcmp(text, "run_all") != 0) + return false; + } + } + if (lanes ? !tp_parse_lanes(policy, lanes) : !tp_default_lane(policy)) + return false; + size_t project_count = rules ? yyjson_arr_size(rules) : 0; + if ((rules && !yyjson_is_arr(rules)) || project_count > INT_MAX - TP_COUNT(tp_builtins)) + return false; + size_t count = project_count + TP_COUNT(tp_builtins); + if (count > SIZE_MAX / sizeof(*policy->rules) || + count > SIZE_MAX / sizeof(*policy->rule_matchers)) + return false; + policy->rules = cbm_arena_calloc(&policy->arena, count * sizeof(*policy->rules)); + policy->rule_matchers = + cbm_arena_calloc(&policy->arena, count * sizeof(*policy->rule_matchers)); + if (!policy->rules || !policy->rule_matchers) + return false; + policy->rule_count = (int)count; + size_t index, maximum; + yyjson_val *rule_value; + yyjson_arr_foreach(rules, index, maximum, rule_value) { + if (!tp_parse_rule(policy, rule_value, (int)index)) + return false; + } + if (!tp_add_builtins(policy, (int)project_count)) + return false; + for (size_t i = 0; i < project_count; i++) { + const cbm_test_rule_t *rule = &policy->rules[i]; + for (int reference = 0; reference < rule->lane_count; reference++) { + bool found = false; + for (int lane = 0; lane < policy->lane_count; lane++) { + if (strcmp(rule->lanes[reference], policy->lanes[lane].name) == 0) { + found = true; + break; + } + } + if (!found) + return false; + } + } + return true; +} + +cbm_test_policy_t *cbm_test_policy_new(const cbm_test_config_t *config) { + if (!config) + return NULL; + size_t len = 0; + bool absent = false; + const char *source = cbm_test_config_source(config, &len, &absent); + if ((!absent && (!source || !len)) || len > TP_MAX_PATTERN || + (len && (!source || memchr(source, '\0', len)))) + return NULL; + CBMArena arena; + cbm_arena_init_lazy(&arena, 4096); + cbm_test_policy_t *policy = cbm_arena_calloc(&arena, sizeof(*policy)); + if (!policy) { + cbm_arena_destroy(&arena); + return NULL; + } + policy->arena = arena; + policy->digest = cbm_arena_strdup(&policy->arena, cbm_test_config_digest(config)); + if (!policy->digest) + goto invalid; + yyjson_doc *doc = absent ? NULL : yyjson_read(source, len, 0); + if (!absent && !doc) + goto invalid; + bool valid = true; + yyjson_val *impact = doc ? tp_field(yyjson_doc_get_root(doc), "test_impact", &valid) : NULL; + valid = valid && tp_parse_policy(policy, impact); + yyjson_doc_free(doc); + if (valid) + return policy; +invalid: + cbm_test_policy_free(policy); + return NULL; +} + +void cbm_test_policy_free(cbm_test_policy_t *policy) { + if (!policy) + return; + CBMArena arena = policy->arena; + cbm_arena_destroy(&arena); +} + +const cbm_test_rule_t *cbm_test_policy_rules(const cbm_test_policy_t *policy, int *count) { + if (count) + *count = policy ? policy->rule_count : 0; + return policy ? policy->rules : NULL; +} + +const cbm_test_lane_t *cbm_test_policy_lanes(const cbm_test_policy_t *policy, int *count) { + if (count) + *count = policy ? policy->lane_count : 0; + return policy ? policy->lanes : NULL; +} + +const char *cbm_test_policy_digest(const cbm_test_policy_t *policy) { + return policy ? policy->digest : ""; +} + +typedef struct { + CBMArena arena; + unsigned char *first; + unsigned char *second; + size_t budget; +} tp_match_context_t; + +static bool tp_match_length(const char *text, size_t *len) { + if (!text || !*text) + return false; + for (size_t i = 0; i <= TP_MAX_MATCH_BYTES; i++) { + if (!text[i]) { + *len = i; + return true; + } + } + return false; +} + +static bool tp_match_begin(tp_match_context_t *context, size_t len) { + memset(context, 0, sizeof(*context)); + cbm_arena_init_lazy(&context->arena, 4096); + context->budget = TP_MATCH_MAX_WORK; + context->first = cbm_arena_alloc(&context->arena, len + 1); + context->second = cbm_arena_alloc(&context->arena, len + 1); + if (!context->first || !context->second) { + cbm_arena_destroy(&context->arena); + return false; + } + return true; +} + +/* Unlike the discovery matcher's best-effort API, this evaluator must never + * turn resource exhaustion into permission to omit a test. Two suffix rows + * evaluate each (token, input byte) once, with an explicit per-operation + * budget shared by every rule and directory ancestor. Suffix/ancestor scans + * also consume one unit per byte. It uses no recursion. + * False is an error; *matched is only meaningful when true is returned. */ +static bool tp_glob_matches(tp_match_context_t *context, const tp_pattern_t *pattern, + const char *text, size_t len, bool direct, bool *matched) { + *matched = false; + if (pattern->disabled) + return true; + unsigned char *next = context->first, *current = context->second; + if (context->budget < len + 1) + return false; + context->budget -= len + 1; + memset(next, 0, len + 1); + next[len] = 1; + for (size_t token_index = pattern->token_count; token_index > 0; token_index--) { + const tp_token_t *token = &pattern->tokens[token_index - 1]; + if (context->budget < len + 1) + return false; + context->budget -= len + 1; + bool slash_suffix = false; + for (size_t remaining = len + 1; remaining > 0; remaining--) { + size_t pos = remaining - 1; + bool has_char = pos < len; + unsigned char ch = has_char ? (unsigned char)text[pos] : 0; + switch (token->kind) { + case TP_LITERAL: + current[pos] = has_char && ch == token->literal && next[pos + 1]; + break; + case TP_QUESTION: + current[pos] = has_char && ch != '/' && next[pos + 1]; + break; + case TP_STAR: + current[pos] = next[pos] || (has_char && ch != '/' && current[pos + 1]); + break; + case TP_DOUBLE_STAR: + current[pos] = next[pos] || (has_char && current[pos + 1]); + break; + case TP_DOUBLE_STAR_SLASH: + if (has_char && ch == '/' && next[pos + 1]) + slash_suffix = true; + current[pos] = next[pos] || slash_suffix; + break; + case TP_CLASS: + current[pos] = + has_char && (token->class_bits[ch >> 3] & (1U << (ch & 7U))) && next[pos + 1]; + break; + } + } + unsigned char *swap = next; + next = current; + current = swap; + } + if (next[0]) { + *matched = true; + return true; + } + if (!direct && !pattern->rooted) { + for (size_t pos = 0; pos < len; pos++) { + if (!context->budget) + return false; + context->budget--; + if (text[pos] == '/' && next[pos + 1]) { + *matched = true; + break; + } + } + } + return true; +} + +static bool tp_path_patterns_match(tp_match_context_t *context, const tp_patterns_t *patterns, + const char *path, size_t len, bool *matched) { + *matched = false; + for (const tp_pattern_t *pattern = patterns->head; pattern; pattern = pattern->next) { + if (!pattern->dir_only) { + if (!tp_glob_matches(context, pattern, path, len, false, matched)) + return false; + if (*matched) + return true; + } + /* Discovery evaluates each ancestor as a directory before reaching + * its files. Prefix lengths give the same semantics without a copy + * or allocation whose failure might be mistaken for a non-match. */ + for (size_t end = 1; end < len; end++) { + if (!context->budget) + return false; + context->budget--; + if (path[end] == '/') { + if (!tp_glob_matches(context, pattern, path, end, false, matched)) + return false; + if (*matched) + return true; + } + } + } + return true; +} + +static bool tp_suite_patterns_match(tp_match_context_t *context, const tp_patterns_t *patterns, + const char *suite, size_t len, bool perf, bool *matched) { + *matched = patterns->perf && perf; + if (*matched) + return true; + for (const tp_pattern_t *pattern = patterns->head; pattern; pattern = pattern->next) { + if (!tp_glob_matches(context, pattern, suite, len, true, matched)) + return false; + if (*matched) + return true; + } + return true; +} + +bool cbm_test_policy_match_path(const cbm_test_policy_t *policy, const char *path, + const cbm_test_rule_t **rule) { + if (rule) + *rule = NULL; + size_t len = 0; + if (!policy || !rule || !tp_match_length(path, &len)) + return false; + tp_match_context_t context; + if (!tp_match_begin(&context, len)) + return false; + bool valid = true; + for (int i = 0; i < policy->rule_count; i++) { + bool matched = false; + if (!tp_path_patterns_match(&context, &policy->rule_matchers[i].paths, path, len, + &matched)) { + valid = false; + break; + } + if (matched) { + *rule = &policy->rules[i]; + break; + } + } + cbm_arena_destroy(&context.arena); + return valid; +} + +bool cbm_test_policy_lane_selects_suite(const cbm_test_policy_t *policy, int lane, + const char *suite, bool perf, bool *selected) { + if (selected) + *selected = false; + size_t len = 0; + if (!policy || !selected || lane < 0 || lane >= policy->lane_count || + !tp_match_length(suite, &len)) + return false; + tp_match_context_t context; + if (!tp_match_begin(&context, len)) + return false; + const tp_lane_matchers_t *matchers = &policy->lane_matchers[lane]; + bool included = false, excluded = false; + bool valid = tp_suite_patterns_match(&context, &matchers->suites, suite, len, perf, &included); + if (valid && included) + valid = tp_suite_patterns_match(&context, &matchers->excludes, suite, len, perf, &excluded); + if (valid) + *selected = included && !excluded; + cbm_arena_destroy(&context.arena); + return valid; +} + +bool cbm_test_policy_rule_selects_suite(const cbm_test_policy_t *policy, int rule, + const char *suite, bool perf, bool *selected) { + if (selected) + *selected = false; + size_t len = 0; + if (!policy || !selected || rule < 0 || rule >= policy->rule_count || + policy->rules[rule].action != CBM_TEST_RULE_SUITES || !tp_match_length(suite, &len)) + return false; + tp_match_context_t context; + if (!tp_match_begin(&context, len)) + return false; + bool matched = false; + bool valid = tp_suite_patterns_match(&context, &policy->rule_matchers[rule].suites, suite, len, + perf, &matched); + if (valid) + *selected = matched; + cbm_arena_destroy(&context.arena); + return valid; +} diff --git a/src/mcp/test_impact_result.c b/src/mcp/test_impact_result.c new file mode 100644 index 000000000..783aec9e8 --- /dev/null +++ b/src/mcp/test_impact_result.c @@ -0,0 +1,74 @@ +#include "mcp/test_impact_result_internal.h" + +static tir_json *tir_result_object(tir_context *c, cbm_test_result_t *result) { + const char *decision = c->global ? "run_all" : (c->runnable ? "selected" : "nothing"); + tir_json *o = tir_node(c, TIR_OBJECT); + tir_add(c, o, "schema", tir_text(c, "cbm.test_impact.v0")); + tir_add(c, o, "experimental", tir_bool(c, true)); + tir_add(c, o, "decision", tir_text(c, decision)); + tir_add(c, o, "run_all_reasons", c->global_json); + tir_add(c, o, "lanes", c->lane_json); + tir_add(c, o, "warnings", tir_warnings_json(c)); + tir_add(c, o, "totals", tir_totals_json(c)); + tir_add(c, o, "receipt", tir_receipt_json(c, result->digest)); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +static bool tir_build_wire(tir_context *c, cbm_test_result_t *result) { + for (size_t i = 0; i < 64; i++) + result->digest[i] = '0'; + tir_json *object = tir_result_object(c, result); + if (!object || !tir_render(c, object, &result->json, &result->length, true)) + return false; + if (!tir_decision_hash(c, result->json, result->length, result->digest)) + return false; + return tir_copy(c, result->json + c->digest_offset, result->digest, 64); +} + +cbm_test_result_status_t cbm_test_result_build(const cbm_test_result_input_t *input, + const cbm_test_result_limits_t *limits, + cbm_test_result_cancel_fn cancelled, + void *cancel_context, cbm_test_result_t **out) { + if (!out) + return CBM_TEST_RESULT_INVALID; + *out = NULL; + if (!input || !input->receipt || !limits || !limits->max_input_bytes || !limits->max_items || + !limits->max_alloc_bytes || !limits->max_output_bytes) + return CBM_TEST_RESULT_INVALID; + tir_context context = {0}; + context.input = input; + context.limits = limits; + context.cancelled = cancelled; + context.cancel_context = cancel_context; + cbm_arena_init_lazy(&context.arena, 4096); + cbm_test_result_t *result = NULL; + if (tir_poll(&context)) + result = tir_alloc(&context, 1, sizeof(*result)); + bool ok = result && tir_preflight(&context) && tir_prepare(&context) && tir_project(&context) && + tir_build_wire(&context, result) && tir_forget_borrowed(&context, result) && + tir_poll(&context); + if (ok) { + result->arena = context.arena; + *out = result; + return CBM_TEST_RESULT_OK; + } + cbm_arena_destroy(&context.arena); + return context.status == CBM_TEST_RESULT_OK ? CBM_TEST_RESULT_INVALID : context.status; +} + +const char *cbm_test_result_json(const cbm_test_result_t *result, size_t *length) { + if (length) + *length = result ? result->length : 0; + return result ? result->json : NULL; +} + +const char *cbm_test_result_decision_sha256(const cbm_test_result_t *result) { + return result ? result->digest : NULL; +} + +void cbm_test_result_free(cbm_test_result_t *result) { + if (!result) + return; + CBMArena arena = result->arena; + cbm_arena_destroy(&arena); +} diff --git a/src/mcp/test_impact_result.h b/src/mcp/test_impact_result.h new file mode 100644 index 000000000..25fcc6d64 --- /dev/null +++ b/src/mcp/test_impact_result.h @@ -0,0 +1,274 @@ +/* Pure test-impact lane projection and result codec. + * Provider evidence remains the caller's responsibility. + */ +#ifndef CBM_MCP_TEST_IMPACT_RESULT_H +#define CBM_MCP_TEST_IMPACT_RESULT_H + +#include "mcp/test_impact.h" +#include +#include +#include + +typedef struct { + const unsigned char *data; + size_t length; +} cbm_test_result_bytes_t; + +typedef enum { + CBM_TEST_RESULT_OK = 0, + CBM_TEST_RESULT_INVALID, + CBM_TEST_RESULT_UNSUPPORTED, + CBM_TEST_RESULT_LIMIT, + CBM_TEST_RESULT_CANCELLED, + CBM_TEST_RESULT_OOM, + /* Existing policy matcher returned false without a classified cause. */ + CBM_TEST_RESULT_MATCH_FAILED +} cbm_test_result_status_t; + +/* Ordinals are internal, not wire values. These supplement the existing + * CBM_TEST_SELECT_* reason mask and have fixed uppercase wire spellings. + */ +typedef enum { + CBM_TEST_RESULT_FALLBACK_POLICY_UNAVAILABLE = 0, + CBM_TEST_RESULT_FALLBACK_ACTIVATION_UNKNOWN, + CBM_TEST_RESULT_FALLBACK_RULE_TARGET_UNKNOWN, + CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + CBM_TEST_RESULT_FALLBACK_GRAPH_REJECTED, + CBM_TEST_RESULT_FALLBACK_CAPABILITY_MISSING, + CBM_TEST_RESULT_FALLBACK_GIT_UNAVAILABLE, + CBM_TEST_RESULT_FALLBACK_CONFIG_INVALID, + CBM_TEST_RESULT_FALLBACK_ENGINE_SATURATED, + CBM_TEST_RESULT_FALLBACK_UNMAPPED_FILE, + CBM_TEST_RESULT_FALLBACK_UNRESOLVED_CHANGED_TEST, + CBM_TEST_RESULT_FALLBACK_SELECTION_UNAVAILABLE, + CBM_TEST_RESULT_FALLBACK_RULE_RUN_ALL, + CBM_TEST_RESULT_FALLBACK_POLICY_EVALUATION_FAILED, + CBM_TEST_RESULT_FALLBACK_TRAVERSAL_POLICY_UNKNOWN, + CBM_TEST_RESULT_FALLBACK_COUNT +} cbm_test_result_fallback_t; + +typedef enum { + CBM_TEST_RESULT_COMPARISON_EMPTY = 0, + CBM_TEST_RESULT_COMPARISON_CHANGED, + CBM_TEST_RESULT_COMPARISON_UNAVAILABLE +} cbm_test_result_comparison_t; + +typedef struct { + int rule_index; /* zero-based index in the supplied policy */ + /* One aggregate per causally matched rule. True means EVERY represented + * target was resolved. Required true for run_all/ignore (no target). + * First-match classification and path completeness remain upstream facts. + */ + bool target_resolved; +} cbm_test_result_rule_match_t; + +typedef enum { + CBM_TEST_RESULT_GRAPH_UNAVAILABLE = 0, + CBM_TEST_RESULT_GRAPH_REJECTED, + CBM_TEST_RESULT_GRAPH_CERTIFIED +} cbm_test_result_graph_state_t; + +typedef enum { + CBM_TEST_RESULT_COVERAGE_UNAVAILABLE = 0, + CBM_TEST_RESULT_COVERAGE_REJECTED, + CBM_TEST_RESULT_COVERAGE_ADMITTED +} cbm_test_result_coverage_state_t; + +typedef enum { + CBM_TEST_RESULT_OBJECT_UNKNOWN = 0, + CBM_TEST_RESULT_OBJECT_SHA1, + CBM_TEST_RESULT_OBJECT_SHA256 +} cbm_test_result_object_format_t; + +/* UNKNOWN is absent and requires all zero bytes. SHA1 uses bytes[0..19] + * with bytes[20..31] zero. SHA256 uses all bytes. No textual abbreviation. + */ +typedef struct { + cbm_test_result_object_format_t format; + unsigned char bytes[32]; +} cbm_test_result_oid_t; + +typedef enum { + CBM_TEST_RESULT_COVERAGE_FORMAT_UNKNOWN = 0, + CBM_TEST_RESULT_COVERAGE_FORMAT_FUNCTIONS_V1, + CBM_TEST_RESULT_COVERAGE_FORMAT_PROFILES_V2 +} cbm_test_result_coverage_format_t; + +/* Reporting codes, not evidence-verification operations. */ +typedef enum { + CBM_TEST_RESULT_EVIDENCE_PROVIDER_UNAVAILABLE = 0, + CBM_TEST_RESULT_EVIDENCE_SOURCE_UNVERIFIED, + CBM_TEST_RESULT_EVIDENCE_IDENTITY_MISMATCH, + CBM_TEST_RESULT_EVIDENCE_CONTENT_MISMATCH, + CBM_TEST_RESULT_EVIDENCE_COMMIT_MISMATCH, + CBM_TEST_RESULT_EVIDENCE_ANCESTRY_UNPROVED, + CBM_TEST_RESULT_EVIDENCE_TOO_OLD, + CBM_TEST_RESULT_EVIDENCE_COMPATIBILITY_MISMATCH, + CBM_TEST_RESULT_EVIDENCE_METADATA_INVALID, + CBM_TEST_RESULT_EVIDENCE_FORMAT_UNSUPPORTED, + CBM_TEST_RESULT_EVIDENCE_INVENTORY_INCOMPLETE, + CBM_TEST_RESULT_EVIDENCE_DIAGNOSTICS_INCOMPLETE, + CBM_TEST_RESULT_EVIDENCE_CAPABILITY_MISSING, + CBM_TEST_RESULT_EVIDENCE_SEMANTIC_INPUTS_UNPROVED, + CBM_TEST_RESULT_EVIDENCE_APPLICABILITY_UNPROVED, + CBM_TEST_RESULT_EVIDENCE_OBSERVATIONS_INCOMPLETE, + CBM_TEST_RESULT_EVIDENCE_IMAGE_UNPROVED, + CBM_TEST_RESULT_EVIDENCE_COUNT +} cbm_test_result_evidence_reason_t; + +typedef struct { + const cbm_test_result_evidence_reason_t *values; + size_t count; +} cbm_test_result_evidence_reasons_t; + +typedef enum { + CBM_TEST_RESULT_APPLICABILITY_UNAVAILABLE = 0, + CBM_TEST_RESULT_APPLICABILITY_REJECTED, + CBM_TEST_RESULT_APPLICABILITY_ESTABLISHED +} cbm_test_result_applicability_state_t; + +typedef enum { + CBM_TEST_RESULT_APPLICABILITY_KIND_UNKNOWN = 0, + CBM_TEST_RESULT_APPLICABILITY_ALL_MODIFICATIONS, + CBM_TEST_RESULT_APPLICABILITY_EXACT_QUERY +} cbm_test_result_applicability_kind_t; + +typedef struct { + bool present; + unsigned char bytes[32]; +} cbm_test_result_digest_t; + +typedef struct { + bool present; + int64_t value; +} cbm_test_result_timestamp_t; + +typedef struct { + cbm_test_result_graph_state_t state; + cbm_test_result_oid_t commit; + cbm_test_result_bytes_t generation; + cbm_test_result_digest_t sha256; + cbm_test_result_evidence_reasons_t rejection_reasons; +} cbm_test_result_graph_receipt_t; + +typedef struct { + cbm_test_result_applicability_state_t state; + cbm_test_result_applicability_kind_t kind; + uint32_t version; /* zero = unknown; established all-modifications = 2 */ + cbm_test_result_bytes_t profile; /* empty = unknown; exact-query needs one */ + cbm_test_result_digest_t certificate_sha256; + cbm_test_result_oid_t artifact_commit; + cbm_test_result_oid_t merge_base; + cbm_test_result_oid_t head; + cbm_test_result_evidence_reasons_t rejection_reasons; +} cbm_test_result_applicability_receipt_t; + +typedef struct { + cbm_test_result_coverage_state_t state; + cbm_test_result_coverage_format_t format; + cbm_test_result_bytes_t artifact; + cbm_test_result_oid_t commit; + cbm_test_result_digest_t image_sha256; + cbm_test_result_digest_t identities_sha256; + cbm_test_result_digest_t tests_sha256; + cbm_test_result_digest_t metadata_sha256; + cbm_test_result_digest_t graph_sha256; + cbm_test_result_digest_t compatibility_sha256; + cbm_test_result_timestamp_t oldest_observation_at; + cbm_test_result_evidence_reasons_t rejection_reasons; + cbm_test_result_applicability_receipt_t applicability; +} cbm_test_result_coverage_receipt_t; + +typedef struct { + cbm_test_result_bytes_t language; + cbm_test_result_bytes_t edge; +} cbm_test_result_extension_t; + +typedef enum { + CBM_TEST_RESULT_ROUTES_NOT_USED = 0, + CBM_TEST_RESULT_ROUTES_FOLLOWED, + CBM_TEST_RESULT_ROUTES_UNKNOWN +} cbm_test_result_route_state_t; + +typedef struct { + cbm_test_result_object_format_t object_format; + cbm_test_result_oid_t base; + cbm_test_result_oid_t head; + cbm_test_result_oid_t merge_base; + cbm_test_result_digest_t diff_sha256; + cbm_test_result_digest_t name_status_sha256; + cbm_test_result_digest_t config_sha256; + cbm_test_result_digest_t policy_sha256; + cbm_test_result_graph_receipt_t graph; + cbm_test_result_coverage_receipt_t coverage; + const cbm_test_result_extension_t *extensions; + size_t extension_count; + cbm_test_result_route_state_t routes; +} cbm_test_result_receipt_t; + +typedef enum { + CBM_TEST_RESULT_WARNING_TEST_NOT_REGISTERED = 0, + CBM_TEST_RESULT_WARNING_SOURCE_MAPPING_UNKNOWN, + CBM_TEST_RESULT_WARNING_DIAGNOSTICS_INCOMPLETE, + CBM_TEST_RESULT_WARNING_UNSUPPORTED_MAPPING, + CBM_TEST_RESULT_WARNING_COUNT +} cbm_test_result_warning_code_t; + +typedef struct { + cbm_test_result_warning_code_t code; + cbm_test_result_bytes_t file; /* raw path bytes; empty = unavailable */ + uint64_t line; /* zero = unavailable */ +} cbm_test_result_warning_t; + +typedef struct { + cbm_test_result_comparison_t comparison; + const cbm_test_selection_t *selection; + const cbm_test_model_t *model; + const cbm_test_policy_t *policy; + bool inventory_complete; /* external runner inventory, not model parsing */ + bool activation_complete; /* complete upstream first-match/activation facts */ + const cbm_test_result_rule_match_t *matched_rules; + size_t matched_rule_count; + const cbm_test_result_fallback_t *fallbacks; + size_t fallback_count; + const cbm_test_result_warning_t *warnings; + size_t warning_count; + const cbm_test_result_receipt_t *receipt; +} cbm_test_result_input_t; + +typedef struct { + uint64_t max_input_bytes; + uint64_t max_items; + size_t max_alloc_bytes; + size_t max_output_bytes; +} cbm_test_result_limits_t; + +typedef bool (*cbm_test_result_cancel_fn)(void *context); +typedef struct cbm_test_result cbm_test_result_t; + +/* Pure synchronous build; borrows immutable input only for this call. + * Clear *out before validation. All limits positive. Non-OK: *out=NULL. + * Does not certify provider facts, resolve rules, read files or execute tests. + * Lane precedence: (1) nonempty global set -> fallback, including EMPTY; + * (2) empty global set plus EMPTY -> shortcut; (3) remaining CHANGED -> + * ordinary projection. UNAVAILABLE necessarily has a global reason. + * Opaque owners must be valid, immutable, and model finished. Receipt is + * required, including when it contains only unavailable evidence. + * Existing policy matcher calls retain their separately stated work bound + * and allocation allowance; cancellation applies to owned loops only. + * max_output_bytes covers raw codec JSON, excluding its storage NUL. An outer + * MCP duplication/escaping gate is separate and mandatory for transport use. + */ +cbm_test_result_status_t cbm_test_result_build(const cbm_test_result_input_t *input, + const cbm_test_result_limits_t *limits, + cbm_test_result_cancel_fn cancelled, + void *cancel_context, cbm_test_result_t **out); + +/* Borrowed UTF-8 NUL-terminated wire (length excludes NUL), or NULL/zero. + * The digest is lowercase 64-hex with NUL. Both views last until free. + */ +const char *cbm_test_result_json(const cbm_test_result_t *result, size_t *length); +const char *cbm_test_result_decision_sha256(const cbm_test_result_t *result); +void cbm_test_result_free(cbm_test_result_t *result); + +#endif diff --git a/src/mcp/test_impact_result_internal.h b/src/mcp/test_impact_result_internal.h new file mode 100644 index 000000000..476877e1d --- /dev/null +++ b/src/mcp/test_impact_result_internal.h @@ -0,0 +1,174 @@ +#ifndef CBM_TEST_IMPACT_RESULT_INTERNAL_H +#define CBM_TEST_IMPACT_RESULT_INTERNAL_H + +#include "mcp/test_impact_result.h" +#include "foundation/arena.h" +#include "foundation/sha256.h" + +typedef cbm_test_result_bytes_t tir_bytes; +typedef struct tir_json tir_json; +typedef struct tir_link { + const char *key; + tir_json *value; + struct tir_link *next; +} tir_link; +typedef enum { TIR_NULL, TIR_BOOL, TIR_UINT, TIR_INT, TIR_STRING, TIR_ARRAY, TIR_OBJECT } tir_kind; +struct tir_json { + tir_kind kind; + union { + bool boolean; + uint64_t number; + int64_t integer; + tir_bytes string; + struct { + tir_link *first; + tir_link *last; + } children; + } as; +}; + +typedef struct { + tir_bytes name; + const char *original; + bool perf; +} tir_runner; +typedef struct { + tir_bytes file, name; + int line; +} tir_model_case; +typedef struct { + tir_bytes name; + bool whole; + uint64_t reasons; + size_t begin, count; +} tir_suite; +typedef struct { + tir_bytes suite, test, file; + uint64_t reasons, line; +} tir_case; +typedef struct { + const cbm_test_rule_t *source; + tir_bytes id; + tir_bytes *lanes; + bool matched, resolved; +} tir_rule; +typedef struct { + const cbm_test_lane_t *source; + tir_bytes name; + bool active; + tir_json *rules; +} tir_lane; +typedef struct { + cbm_test_result_warning_code_t code; + tir_bytes file; + uint64_t line; +} tir_warning; +typedef struct tir_allocation { + struct tir_allocation *next; + size_t size; + void *data; +} tir_allocation; + +enum { + TIR_SELECT_BITS = 16, + TIR_REASON_POLICY_INACTIVE = TIR_SELECT_BITS + CBM_TEST_RESULT_FALLBACK_COUNT, + TIR_REASON_NO_CHANGES, + TIR_REASON_NO_SELECTED_TESTS, + TIR_REASON_NO_MEMBER_SUITES, + TIR_REASON_NARROW_DISABLED, + TIR_REASON_COUNT +}; +#define TIR_REASON(bit) (UINT64_C(1) << (bit)) +#define TIR_FALLBACK(code) TIR_REASON(TIR_SELECT_BITS + (code)) +#define TIR_SELECTION_MASK ((UINT64_C(1) << TIR_SELECT_BITS) - 1) + +typedef struct { + CBMArena arena; + const cbm_test_result_input_t *input; + const cbm_test_result_limits_t *limits; + cbm_test_result_cancel_fn cancelled; + void *cancel_context; + cbm_test_result_status_t status; + uint64_t input_bytes, items; + size_t allocations, byte_steps, item_steps; + tir_allocation *owned; + tir_runner *runners; + size_t runner_count; + tir_model_case *definitions; + size_t definition_count; + tir_suite *suites; + size_t suite_count, raw_suite_count; + tir_case *cases; + size_t case_count, raw_case_count; + tir_rule *rules; + size_t rule_count; + tir_lane *lanes; + size_t lane_count; + tir_warning *warnings; + size_t warning_count, warning_capacity; + cbm_test_result_extension_t *extensions; + uint64_t global; + uint64_t runnable, suite_occurrences, test_occurrences; + bool whole_occurrence; + tir_json *lane_json, *global_json, *digest_json; + size_t lane_offset, lane_length, global_offset, global_length, digest_offset; +} tir_context; + +struct cbm_test_result { + CBMArena arena; + char *json; + size_t length; + char digest[65]; +}; + +bool tir_fail(tir_context *c, cbm_test_result_status_t status); +bool tir_poll(tir_context *c); +bool tir_step(tir_context *c, size_t bytes, size_t items); +bool tir_charge(tir_context *c, uint64_t *used, uint64_t amount, uint64_t limit); +bool tir_items(tir_context *c, uint64_t count); +bool tir_input_bytes(tir_context *c, size_t count); +bool tir_reserve(tir_context *c, size_t count); +void *tir_alloc(tir_context *c, size_t count, size_t size); +bool tir_forget_borrowed(tir_context *c, const cbm_test_result_t *result); +bool tir_copy(tir_context *c, void *dest, const void *source, size_t count); +bool tir_cstring(tir_context *c, const char *s, tir_bytes *out); +bool tir_span(tir_context *c, tir_bytes b, bool text); +bool tir_utf8(tir_context *c, tir_bytes b); +bool tir_identifier(tir_context *c, tir_bytes b, bool suite); +int tir_compare(tir_context *c, tir_bytes a, tir_bytes b); +tir_bytes tir_literal(const char *s); +typedef int (*tir_compare_fn)(tir_context *, const void *, const void *); +bool tir_sort(tir_context *c, void *rows, size_t count, size_t size, tir_compare_fn compare); +bool tir_preflight(tir_context *c); +bool tir_receipt_validate(tir_context *c); +bool tir_prepare(tir_context *c); +bool tir_project(tir_context *c); +size_t tir_find_suite(tir_context *c, tir_bytes name); +tir_json *tir_case_json(tir_context *c, const tir_case *test); +tir_json *tir_suite_json(tir_context *c, tir_bytes name, const tir_suite *selection, bool whole, + uint64_t reasons); +tir_json *tir_filter_json(tir_context *c, tir_json *suites); +tir_json *tir_totals_json(tir_context *c); +tir_json *tir_receipt_json(tir_context *c, const char *digest); +tir_json *tir_warnings_json(tir_context *c); + +tir_json *tir_node(tir_context *c, tir_kind kind); +tir_json *tir_null(tir_context *c); +tir_json *tir_bool(tir_context *c, bool value); +tir_json *tir_uint(tir_context *c, uint64_t value); +tir_json *tir_int(tir_context *c, int64_t value); +tir_json *tir_string(tir_context *c, tir_bytes value); +tir_json *tir_text(tir_context *c, const char *value); +bool tir_add(tir_context *c, tir_json *container, const char *key, tir_json *value); +bool tir_path(tir_context *c, tir_json *object, tir_bytes file); +tir_json *tir_hex(tir_context *c, const unsigned char *data, size_t count); +tir_json *tir_reasons(tir_context *c, uint64_t reasons); +tir_json *tir_evidence(tir_context *c, cbm_test_result_evidence_reasons_t reasons); +tir_json *tir_digest(tir_context *c, cbm_test_result_digest_t digest); +tir_json *tir_oid(tir_context *c, cbm_test_result_oid_t oid); +bool tir_render(tir_context *c, tir_json *node, char **bytes, size_t *length, bool output); +bool tir_decision_hash(tir_context *c, const char *data, size_t length, char out[65]); +const char *tir_reason_name(unsigned index); +const char *tir_warning_name(cbm_test_result_warning_code_t code); + +#endif diff --git a/src/mcp/test_impact_result_json.c b/src/mcp/test_impact_result_json.c new file mode 100644 index 000000000..b3fbfcc64 --- /dev/null +++ b/src/mcp/test_impact_result_json.c @@ -0,0 +1,297 @@ +#include "mcp/test_impact_result_internal.h" +#include + +tir_json *tir_node(tir_context *c, tir_kind kind) { + tir_json *node = tir_alloc(c, 1, sizeof(*node)); + if (node) + node->kind = kind; + return node; +} +tir_json *tir_null(tir_context *c) { + return tir_node(c, TIR_NULL); +} +tir_json *tir_bool(tir_context *c, bool value) { + tir_json *node = tir_node(c, TIR_BOOL); + if (node) + node->as.boolean = value; + return node; +} +tir_json *tir_uint(tir_context *c, uint64_t value) { + tir_json *node = tir_node(c, TIR_UINT); + if (node) + node->as.number = value; + return node; +} +tir_json *tir_int(tir_context *c, int64_t value) { + tir_json *node = tir_node(c, TIR_INT); + if (node) + node->as.integer = value; + return node; +} +tir_json *tir_string(tir_context *c, tir_bytes value) { + tir_json *node = tir_node(c, TIR_STRING); + if (node) + node->as.string = value; + return node; +} +tir_json *tir_text(tir_context *c, const char *value) { + return tir_string(c, tir_literal(value)); +} + +bool tir_add(tir_context *c, tir_json *container, const char *key, tir_json *value) { + if (!container || !value) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_step(c, 0, 1)) + return false; + tir_link *entry = tir_alloc(c, 1, sizeof(*entry)); + if (!entry) + return false; + entry->key = key; + entry->value = value; + if (container->kind == TIR_ARRAY && !key) { + if (container->as.children.last) + container->as.children.last->next = entry; + else + container->as.children.first = entry; + container->as.children.last = entry; + return true; + } + if (container->kind != TIR_OBJECT || !key) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + tir_link **at = &container->as.children.first; + while (*at) { + int order = tir_compare(c, tir_literal((*at)->key), tir_literal(key)); + if (!tir_step(c, 0, 1)) + return false; + if (!order) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (order > 0) + break; + at = &(*at)->next; + } + entry->next = *at; + *at = entry; + return true; +} + +tir_json *tir_hex(tir_context *c, const unsigned char *data, size_t count) { + static const char digits[] = "0123456789abcdef"; + if (count > (SIZE_MAX - 1) / 2) { + tir_fail(c, CBM_TEST_RESULT_LIMIT); + return NULL; + } + unsigned char *out = tir_alloc(c, count * 2 + 1, 1); + if (!out) + return NULL; + for (size_t i = 0; i < count; i++) { + if (!tir_step(c, 1, 0)) + return NULL; + out[i * 2] = (unsigned char)digits[data[i] >> 4]; + out[i * 2 + 1] = (unsigned char)digits[data[i] & 15]; + } + return tir_string(c, (tir_bytes){out, count * 2}); +} + +bool tir_path(tir_context *c, tir_json *object, tir_bytes file) { + bool utf8 = tir_utf8(c, file); + if (c->status != CBM_TEST_RESULT_OK) + return false; + tir_json *text = file.length && utf8 ? tir_string(c, file) : tir_null(c); + tir_json *hex = file.length && !utf8 ? tir_hex(c, file.data, file.length) : tir_null(c); + return tir_add(c, object, "file", text) && tir_add(c, object, "file_hex", hex); +} + +tir_json *tir_digest(tir_context *c, cbm_test_result_digest_t digest) { + return digest.present ? tir_hex(c, digest.bytes, 32) : tir_null(c); +} +tir_json *tir_oid(tir_context *c, cbm_test_result_oid_t oid) { + if (oid.format == CBM_TEST_RESULT_OBJECT_UNKNOWN) + return tir_null(c); + return tir_hex(c, oid.bytes, oid.format == CBM_TEST_RESULT_OBJECT_SHA1 ? 20 : 32); +} + +typedef struct { + tir_context *context; + char *bytes; + size_t used, limit; +} tir_writer; + +static bool tir_write(tir_writer *w, const void *bytes, size_t count) { + if (count > w->limit - w->used) + return tir_fail(w->context, CBM_TEST_RESULT_LIMIT); + const unsigned char *source = bytes; + for (size_t pos = 0; pos < count;) { + size_t step = count - pos < 4096 ? count - pos : 4096; + if (!tir_step(w->context, step, 0)) + return false; + if (w->bytes) + memcpy(w->bytes + w->used + pos, source + pos, step); + pos += step; + } + w->used += count; + return true; +} + +static bool tir_write_literal(tir_writer *w, const char *s) { + tir_bytes b = tir_literal(s); + return tir_write(w, b.data, b.length); +} + +static bool tir_write_string(tir_writer *w, tir_bytes b) { + static const char hex[] = "0123456789abcdef"; + if (!tir_write(w, "\"", 1)) + return false; + for (size_t i = 0; i < b.length; i++) { + unsigned char ch = b.data[i]; + if (ch < 32) { + char escaped[6] = {'\\', 'u', '0', '0', hex[ch >> 4], hex[ch & 15]}; + if (!tir_write(w, escaped, sizeof(escaped))) + return false; + } else if (ch == '\\' || ch == '"') { + char escaped[2] = {'\\', (char)ch}; + if (!tir_write(w, escaped, sizeof(escaped))) + return false; + } else if (!tir_write(w, &ch, 1)) + return false; + } + return tir_write(w, "\"", 1); +} + +static bool tir_write_number(tir_writer *w, uint64_t number, bool negative) { + char buffer[21]; + size_t count = 0; + do { + buffer[count++] = (char)('0' + number % 10); + number /= 10; + } while (number); + if (negative && !tir_write(w, "-", 1)) + return false; + while (count) + if (!tir_write(w, &buffer[--count], 1)) + return false; + return true; +} + +static bool tir_write_node(tir_writer *w, const tir_json *node); +static bool tir_write_children(tir_writer *w, const tir_json *node) { + bool object = node->kind == TIR_OBJECT; + if (!tir_write(w, object ? "{" : "[", 1)) + return false; + bool first = true; + for (const tir_link *entry = node->as.children.first; entry; entry = entry->next) { + if (!tir_step(w->context, 0, 1)) + return false; + if (!first && !tir_write(w, ",", 1)) + return false; + first = false; + if (object && (!tir_write_string(w, tir_literal(entry->key)) || !tir_write(w, ":", 1))) + return false; + if (!tir_write_node(w, entry->value)) + return false; + } + return tir_write(w, object ? "}" : "]", 1); +} + +static bool tir_write_value(tir_writer *w, const tir_json *node) { + if (!node || !tir_step(w->context, 0, 1)) + return false; + switch (node->kind) { + case TIR_NULL: + return tir_write_literal(w, "null"); + case TIR_BOOL: + return tir_write_literal(w, node->as.boolean ? "true" : "false"); + case TIR_UINT: + return tir_write_number(w, node->as.number, false); + case TIR_INT: { + int64_t n = node->as.integer; + uint64_t magnitude = n < 0 ? (uint64_t)(-(n + 1)) + 1 : (uint64_t)n; + return tir_write_number(w, magnitude, n < 0); + } + case TIR_STRING: + return tir_write_string(w, node->as.string); + case TIR_ARRAY: + case TIR_OBJECT: + return tir_write_children(w, node); + } + return tir_fail(w->context, CBM_TEST_RESULT_INVALID); +} + +static bool tir_write_node(tir_writer *w, const tir_json *node) { + size_t start = w->used; + if (!tir_write_value(w, node)) + return false; + if (!w->bytes) + return true; + tir_context *c = w->context; + if (node == c->lane_json) { + c->lane_offset = start; + c->lane_length = w->used - start; + } + if (node == c->global_json) { + c->global_offset = start; + c->global_length = w->used - start; + } + if (node == c->digest_json) + c->digest_offset = start + 1; + return true; +} + +bool tir_render(tir_context *c, tir_json *node, char **bytes, size_t *length, bool output) { + uint64_t bound = UINT64_MAX / 8; + size_t limit = bound < SIZE_MAX - 1 ? (size_t)bound : SIZE_MAX - 1; + if (output && c->limits->max_output_bytes < limit) + limit = c->limits->max_output_bytes; + tir_writer measure = {c, NULL, 0, limit}; + if (!tir_write_node(&measure, node)) + return false; + char *data = tir_alloc(c, measure.used + 1, 1); + if (!data) + return false; + tir_writer writer = {c, data, 0, measure.used}; + if (!tir_write_node(&writer, node)) + return false; + *bytes = data; + *length = writer.used; + return true; +} + +static bool tir_hash_part(tir_context *c, cbm_sha256_ctx *hash, tir_bytes bytes) { + size_t length = bytes.length; + for (size_t pos = 0; pos < length;) { + size_t step = length - pos < 4096 ? length - pos : 4096; + if (!tir_step(c, step, 0)) + return false; + cbm_sha256_update(hash, bytes.data + pos, step); + pos += step; + } + return true; +} + +bool tir_decision_hash(tir_context *c, const char *data, size_t length, char out[65]) { + static const char hex[] = "0123456789abcdef"; + if (c->lane_offset > length || c->lane_length > length - c->lane_offset || + c->global_offset > length || c->global_length > length - c->global_offset || + c->digest_offset > length || 64 > length - c->digest_offset) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + tir_bytes pieces[5] = {tir_literal("{\"lanes\":"), + {(const unsigned char *)data + c->lane_offset, c->lane_length}, + tir_literal(",\"run_all_reasons\":"), + {(const unsigned char *)data + c->global_offset, c->global_length}, + tir_literal("}")}; + uint64_t total = 0; + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + for (size_t i = 0; i < 5; i++) { + if (!tir_charge(c, &total, pieces[i].length, UINT64_MAX / 8) || + !tir_hash_part(c, &hash, pieces[i])) + return false; + } + unsigned char digest[32]; + cbm_sha256_final(&hash, digest); + for (size_t i = 0; i < 32; i++) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 15]; + } + out[64] = 0; + return true; +} diff --git a/src/mcp/test_impact_result_project.c b/src/mcp/test_impact_result_project.c new file mode 100644 index 000000000..21539432b --- /dev/null +++ b/src/mcp/test_impact_result_project.c @@ -0,0 +1,147 @@ +#include "mcp/test_impact_result_internal.h" + +typedef enum { TIR_SKIP, TIR_SELECTED, TIR_ALL } tir_lane_decision; +typedef struct { + tir_json *suites; + uint64_t reasons; + size_t members; + tir_lane_decision decision; +} tir_lane_plan; + +static bool tir_membership(tir_context *c, size_t lane, const tir_runner *runner, bool *member) { + if (runner->name.length > (SIZE_MAX / 2) - 1) + return tir_fail(c, CBM_TEST_RESULT_LIMIT); + if (!tir_reserve(c, 2 * (runner->name.length + 1)) || !tir_poll(c)) + return false; + bool ok = cbm_test_policy_lane_selects_suite(c->input->policy, (int)lane, runner->original, + runner->perf, member); + if (!tir_poll(c)) + return false; + if (!ok) + return tir_fail(c, CBM_TEST_RESULT_MATCH_FAILED); + return true; +} + +static bool tir_member_suite(tir_context *c, size_t lane_index, tir_lane_plan *plan, + const tir_runner *runner) { + const tir_lane *lane = &c->lanes[lane_index]; + size_t found = tir_find_suite(c, runner->name); + const tir_suite *selected = found == SIZE_MAX ? NULL : &c->suites[found]; + if (!c->global && lane->source->narrow && !selected) + return c->status == CBM_TEST_RESULT_OK; + bool whole = c->global || !lane->source->narrow || (selected && selected->whole); + uint64_t reasons = c->global | (selected ? selected->reasons : 0); + if (!lane->source->narrow) + reasons |= TIR_REASON(TIR_REASON_NARROW_DISABLED); + if (!tir_add(c, plan->suites, NULL, tir_suite_json(c, runner->name, selected, whole, reasons))) + return false; + if (c->global) + return true; + plan->reasons |= reasons; + if (!tir_charge(c, &c->suite_occurrences, 1, UINT64_MAX)) + return false; + if (whole) + c->whole_occurrence = true; + else if (!tir_charge(c, &c->test_occurrences, selected->count, UINT64_MAX)) + return false; + return true; +} + +static bool tir_members(tir_context *c, size_t lane_index, tir_lane_plan *plan) { + for (size_t i = 0; i < c->runner_count; i++) { + if (!tir_step(c, 0, 1)) + return false; + bool member = false; + if (!tir_membership(c, lane_index, &c->runners[i], &member)) + return false; + if (!member) + continue; + plan->members++; + if (!tir_member_suite(c, lane_index, plan, &c->runners[i])) + return false; + } + return true; +} + +static bool tir_lane_plan_build(tir_context *c, size_t index, tir_lane_plan *plan) { + const tir_lane *lane = &c->lanes[index]; + plan->suites = tir_node(c, TIR_ARRAY); + if (!plan->suites) + return false; + if (!c->global && c->input->comparison == CBM_TEST_RESULT_COMPARISON_EMPTY) { + plan->reasons = TIR_REASON(TIR_REASON_NO_CHANGES); + return true; + } + if (!lane->active) { + plan->reasons = TIR_REASON(TIR_REASON_POLICY_INACTIVE); + return true; + } + if (c->global) { + plan->decision = TIR_ALL; + plan->reasons = c->global; + if (!lane->source->narrow) + plan->reasons |= TIR_REASON(TIR_REASON_NARROW_DISABLED); + if (!c->input->inventory_complete || !c->input->model || + !cbm_test_model_complete(c->input->model)) { + plan->suites = tir_null(c); + return plan->suites != NULL; + } + return tir_members(c, index, plan); + } + if (!tir_members(c, index, plan)) + return false; + if (!plan->members) + plan->reasons = TIR_REASON(TIR_REASON_NO_MEMBER_SUITES); + else if (!plan->suites->as.children.first) + plan->reasons = TIR_REASON(TIR_REASON_NO_SELECTED_TESTS); + else { + plan->decision = lane->source->narrow ? TIR_SELECTED : TIR_ALL; + if (!tir_charge(c, &c->runnable, 1, UINT64_MAX)) + return false; + } + return true; +} + +static tir_json *tir_because(tir_context *c, const tir_lane *lane, uint64_t reasons) { + tir_json *activation = tir_node(c, TIR_ARRAY); + if (lane->source->default_run) + tir_add(c, activation, NULL, tir_text(c, "default")); + if (lane->rules->as.children.first) + tir_add(c, activation, NULL, tir_text(c, "rule")); + tir_json *o = tir_node(c, TIR_OBJECT); + tir_add(c, o, "activation", activation); + tir_add(c, o, "rules", lane->rules); + tir_add(c, o, "reasons", tir_reasons(c, reasons)); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +static tir_json *tir_lane_json(tir_context *c, size_t index) { + static const char *const decisions[] = {"skip", "selected", "run_all"}; + tir_lane_plan plan = {0}; + if (!tir_lane_plan_build(c, index, &plan)) + return NULL; + tir_json *filter = tir_null(c); + if (!c->global && plan.decision != TIR_SKIP) + filter = tir_filter_json(c, plan.suites); + tir_json *o = tir_node(c, TIR_OBJECT); + tir_add(c, o, "lane", tir_string(c, c->lanes[index].name)); + tir_add(c, o, "decision", tir_text(c, decisions[plan.decision])); + tir_add(c, o, "because", tir_because(c, &c->lanes[index], plan.reasons)); + tir_add(c, o, "suites", plan.suites); + tir_add(c, o, "runner_filter", filter); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +bool tir_project(tir_context *c) { + c->global_json = tir_reasons(c, c->global); + if (!c->input->policy || (c->global && !c->input->activation_complete)) { + c->lane_json = tir_null(c); + return c->status == CBM_TEST_RESULT_OK; + } + c->lane_json = tir_node(c, TIR_ARRAY); + for (size_t i = 0; i < c->lane_count; i++) { + if (!tir_step(c, 0, 1) || !tir_add(c, c->lane_json, NULL, tir_lane_json(c, i))) + return false; + } + return c->status == CBM_TEST_RESULT_OK; +} diff --git a/src/mcp/test_impact_result_receipt.c b/src/mcp/test_impact_result_receipt.c new file mode 100644 index 000000000..842acdef3 --- /dev/null +++ b/src/mcp/test_impact_result_receipt.c @@ -0,0 +1,281 @@ +#include "mcp/test_impact_result_internal.h" + +static bool tir_zero(tir_context *c, const unsigned char *p, size_t count) { + for (size_t i = 0; i < count; i++) { + if (!tir_step(c, 1, 0)) + return false; + if (p[i]) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + } + return true; +} + +static bool tir_check_digest(tir_context *c, cbm_test_result_digest_t d) { + return d.present ? tir_input_bytes(c, 32) : tir_zero(c, d.bytes, 32); +} + +static bool tir_check_oid(tir_context *c, cbm_test_result_oid_t oid) { + if (oid.format < CBM_TEST_RESULT_OBJECT_UNKNOWN || oid.format > CBM_TEST_RESULT_OBJECT_SHA256) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (oid.format == CBM_TEST_RESULT_OBJECT_UNKNOWN) + return tir_zero(c, oid.bytes, 32); + if (oid.format != c->input->receipt->object_format) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + size_t count = oid.format == CBM_TEST_RESULT_OBJECT_SHA1 ? 20 : 32; + return tir_input_bytes(c, count) && tir_zero(c, oid.bytes + count, 32 - count); +} + +static bool tir_same_oid(tir_context *c, cbm_test_result_oid_t a, cbm_test_result_oid_t b) { + if (a.format == CBM_TEST_RESULT_OBJECT_UNKNOWN || a.format != b.format) + return false; + return tir_compare(c, (tir_bytes){a.bytes, 32}, (tir_bytes){b.bytes, 32}) == 0 && + c->status == CBM_TEST_RESULT_OK; +} + +static bool tir_check_rejections(tir_context *c, cbm_test_result_evidence_reasons_t reasons, + int state) { + if ((reasons.count && !reasons.values) || (state == 1 && !reasons.count) || + (state == 2 && reasons.count)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (reasons.count > SIZE_MAX / sizeof(*reasons.values)) + return tir_fail(c, CBM_TEST_RESULT_LIMIT); + if (!tir_items(c, reasons.count)) + return false; + for (size_t i = 0; i < reasons.count; i++) { + if (!tir_step(c, 0, 1)) + return false; + if (reasons.values[i] < 0 || reasons.values[i] >= CBM_TEST_RESULT_EVIDENCE_COUNT) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + } + return true; +} + +static bool tir_check_graph(tir_context *c) { + const cbm_test_result_graph_receipt_t *g = &c->input->receipt->graph; + if (g->state < CBM_TEST_RESULT_GRAPH_UNAVAILABLE || g->state > CBM_TEST_RESULT_GRAPH_CERTIFIED) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_check_oid(c, g->commit) || !tir_span(c, g->generation, true) || + !tir_check_digest(c, g->sha256) || !tir_check_rejections(c, g->rejection_reasons, g->state)) + return false; + if (g->state == CBM_TEST_RESULT_GRAPH_CERTIFIED && + (!g->sha256.present || !tir_same_oid(c, g->commit, c->input->receipt->head))) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + return true; +} + +static bool tir_check_applicability(tir_context *c) { + const cbm_test_result_receipt_t *r = c->input->receipt; + const cbm_test_result_applicability_receipt_t *a = &r->coverage.applicability; + if (a->state < CBM_TEST_RESULT_APPLICABILITY_UNAVAILABLE || + a->state > CBM_TEST_RESULT_APPLICABILITY_ESTABLISHED || + a->kind < CBM_TEST_RESULT_APPLICABILITY_KIND_UNKNOWN || + a->kind > CBM_TEST_RESULT_APPLICABILITY_EXACT_QUERY) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_check_oid(c, a->artifact_commit) || !tir_check_oid(c, a->merge_base) || + !tir_check_oid(c, a->head) || !tir_check_digest(c, a->certificate_sha256) || + !tir_span(c, a->profile, true) || !tir_check_rejections(c, a->rejection_reasons, a->state)) + return false; + if (a->kind == CBM_TEST_RESULT_APPLICABILITY_KIND_UNKNOWN && (a->version || a->profile.length)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (a->state != CBM_TEST_RESULT_APPLICABILITY_ESTABLISHED) + return true; + bool binding = tir_same_oid(c, a->artifact_commit, r->coverage.commit) && + tir_same_oid(c, a->merge_base, r->merge_base) && + tir_same_oid(c, a->head, r->head); + bool profile = a->kind == CBM_TEST_RESULT_APPLICABILITY_ALL_MODIFICATIONS + ? a->version == 2 + : a->kind == CBM_TEST_RESULT_APPLICABILITY_EXACT_QUERY && a->profile.length; + if (!binding || !profile || !a->version || !a->certificate_sha256.present || + r->coverage.state != CBM_TEST_RESULT_COVERAGE_ADMITTED) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + return true; +} + +static bool tir_coverage_required(const cbm_test_result_coverage_receipt_t *v) { + return v->format != CBM_TEST_RESULT_COVERAGE_FORMAT_UNKNOWN && v->artifact.length && + v->commit.format != CBM_TEST_RESULT_OBJECT_UNKNOWN && v->identities_sha256.present && + v->tests_sha256.present && v->metadata_sha256.present && v->graph_sha256.present && + v->compatibility_sha256.present && v->oldest_observation_at.present && + (v->format != CBM_TEST_RESULT_COVERAGE_FORMAT_PROFILES_V2 || v->image_sha256.present); +} + +static bool tir_check_coverage(tir_context *c) { + const cbm_test_result_coverage_receipt_t *v = &c->input->receipt->coverage; + if (v->state < CBM_TEST_RESULT_COVERAGE_UNAVAILABLE || + v->state > CBM_TEST_RESULT_COVERAGE_ADMITTED || + v->format < CBM_TEST_RESULT_COVERAGE_FORMAT_UNKNOWN || + v->format > CBM_TEST_RESULT_COVERAGE_FORMAT_PROFILES_V2) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_span(c, v->artifact, true) || !tir_check_oid(c, v->commit) || + !tir_check_digest(c, v->image_sha256) || !tir_check_digest(c, v->identities_sha256) || + !tir_check_digest(c, v->tests_sha256) || !tir_check_digest(c, v->metadata_sha256) || + !tir_check_digest(c, v->graph_sha256) || !tir_check_digest(c, v->compatibility_sha256) || + !tir_check_rejections(c, v->rejection_reasons, v->state)) + return false; + if (v->oldest_observation_at.present) { + if (!tir_input_bytes(c, 8)) + return false; + } else if (v->oldest_observation_at.value) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (v->state == CBM_TEST_RESULT_COVERAGE_ADMITTED && !tir_coverage_required(v)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + return tir_check_applicability(c); +} + +static bool tir_check_extensions(tir_context *c) { + const cbm_test_result_receipt_t *r = c->input->receipt; + if (r->extension_count && !r->extensions) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_items(c, r->extension_count)) + return false; + c->extensions = tir_alloc(c, r->extension_count, sizeof(*c->extensions)); + if (r->extension_count && !c->extensions) + return false; + for (size_t i = 0; i < r->extension_count; i++) { + const cbm_test_result_extension_t *e = &r->extensions[i]; + if (!tir_step(c, 0, 1) || !tir_span(c, e->language, true) || !tir_span(c, e->edge, true)) + return false; + if (!e->language.length || !e->edge.length) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + c->extensions[i] = *e; + } + return true; +} + +bool tir_receipt_validate(tir_context *c) { + const cbm_test_result_receipt_t *r = c->input->receipt; + if (r->object_format < CBM_TEST_RESULT_OBJECT_UNKNOWN || + r->object_format > CBM_TEST_RESULT_OBJECT_SHA256 || + r->routes < CBM_TEST_RESULT_ROUTES_NOT_USED || r->routes > CBM_TEST_RESULT_ROUTES_UNKNOWN) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_check_oid(c, r->base) || !tir_check_oid(c, r->head) || + !tir_check_oid(c, r->merge_base) || !tir_check_digest(c, r->diff_sha256) || + !tir_check_digest(c, r->name_status_sha256) || !tir_check_digest(c, r->config_sha256) || + !tir_check_digest(c, r->policy_sha256)) + return false; + if (c->input->comparison != CBM_TEST_RESULT_COMPARISON_UNAVAILABLE && + (r->object_format == CBM_TEST_RESULT_OBJECT_UNKNOWN || + r->base.format == CBM_TEST_RESULT_OBJECT_UNKNOWN || + r->head.format == CBM_TEST_RESULT_OBJECT_UNKNOWN || + r->merge_base.format == CBM_TEST_RESULT_OBJECT_UNKNOWN || !r->diff_sha256.present || + !r->name_status_sha256.present)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + return tir_check_graph(c) && tir_check_coverage(c) && tir_check_extensions(c); +} + +static tir_json *tir_optional(tir_context *c, tir_bytes b) { + return b.length ? tir_string(c, b) : tir_null(c); +} + +static tir_json *tir_graph_json(tir_context *c) { + static const char *const states[] = {"unavailable", "rejected", "certified"}; + const cbm_test_result_graph_receipt_t *g = &c->input->receipt->graph; + tir_json *o = tir_node(c, TIR_OBJECT); + tir_add(c, o, "state", tir_text(c, states[g->state])); + tir_add(c, o, "commit", tir_oid(c, g->commit)); + tir_add(c, o, "generation", tir_optional(c, g->generation)); + tir_add(c, o, "sha256", tir_digest(c, g->sha256)); + tir_add(c, o, "rejection_reasons", tir_evidence(c, g->rejection_reasons)); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +static tir_json *tir_applicability_json(tir_context *c) { + static const char *const states[] = {"unavailable", "rejected", "established"}; + const cbm_test_result_applicability_receipt_t *a = &c->input->receipt->coverage.applicability; + tir_json *o = tir_node(c, TIR_OBJECT); + tir_json *kind = tir_null(c); + if (a->kind == CBM_TEST_RESULT_APPLICABILITY_ALL_MODIFICATIONS) + kind = tir_text(c, "all_modifications"); + if (a->kind == CBM_TEST_RESULT_APPLICABILITY_EXACT_QUERY) + kind = tir_text(c, "exact_query"); + tir_add(c, o, "state", tir_text(c, states[a->state])); + tir_add(c, o, "kind", kind); + tir_add(c, o, "version", a->version ? tir_uint(c, a->version) : tir_null(c)); + tir_add(c, o, "profile", tir_optional(c, a->profile)); + tir_add(c, o, "certificate_sha256", tir_digest(c, a->certificate_sha256)); + tir_add(c, o, "artifact_commit", tir_oid(c, a->artifact_commit)); + tir_add(c, o, "merge_base", tir_oid(c, a->merge_base)); + tir_add(c, o, "head", tir_oid(c, a->head)); + tir_add(c, o, "rejection_reasons", tir_evidence(c, a->rejection_reasons)); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +static tir_json *tir_coverage_json(tir_context *c) { + static const char *const states[] = {"unavailable", "rejected", "admitted"}; + const cbm_test_result_coverage_receipt_t *v = &c->input->receipt->coverage; + tir_json *o = tir_node(c, TIR_OBJECT); + tir_json *kind = tir_null(c); + if (v->format == CBM_TEST_RESULT_COVERAGE_FORMAT_FUNCTIONS_V1) + kind = tir_text(c, "functions"); + if (v->format == CBM_TEST_RESULT_COVERAGE_FORMAT_PROFILES_V2) + kind = tir_text(c, "profiles"); + tir_add(c, o, "state", tir_text(c, states[v->state])); + tir_add(c, o, "format", v->format ? tir_uint(c, (uint64_t)v->format) : tir_null(c)); + tir_add(c, o, "identity_kind", kind); + tir_add(c, o, "artifact", tir_optional(c, v->artifact)); + tir_add(c, o, "commit", tir_oid(c, v->commit)); + tir_add(c, o, "image_sha256", tir_digest(c, v->image_sha256)); + tir_add(c, o, "identities_sha256", tir_digest(c, v->identities_sha256)); + tir_add(c, o, "tests_sha256", tir_digest(c, v->tests_sha256)); + tir_add(c, o, "metadata_sha256", tir_digest(c, v->metadata_sha256)); + tir_add(c, o, "graph_sha256", tir_digest(c, v->graph_sha256)); + tir_add(c, o, "compatibility_sha256", tir_digest(c, v->compatibility_sha256)); + tir_add(c, o, "oldest_observation_at", + v->oldest_observation_at.present ? tir_int(c, v->oldest_observation_at.value) + : tir_null(c)); + tir_add(c, o, "rejection_reasons", tir_evidence(c, v->rejection_reasons)); + tir_add(c, o, "applicability", tir_applicability_json(c)); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +static tir_json *tir_traversal_json(tir_context *c) { + static const char *const edges[] = {"ASYNC_CALLS", "CALLS", "CALL_REFERENCE", "READS", "USAGE"}; + const cbm_test_result_receipt_t *r = c->input->receipt; + tir_json *o = tir_node(c, TIR_OBJECT); + tir_json *defaults = tir_node(c, TIR_ARRAY), *extensions = tir_node(c, TIR_ARRAY); + for (size_t i = 0; i < 5; i++) + tir_add(c, defaults, NULL, tir_text(c, edges[i])); + for (size_t i = 0; i < r->extension_count; i++) { + if (!tir_step(c, 0, 1)) + return NULL; + tir_json *e = tir_node(c, TIR_OBJECT); + tir_add(c, e, "language", tir_string(c, c->extensions[i].language)); + tir_add(c, e, "edge", tir_string(c, c->extensions[i].edge)); + tir_add(c, extensions, NULL, e); + } + tir_add(c, o, "default_edges", defaults); + tir_add(c, o, "extensions", extensions); + tir_add(c, o, "direction", tir_text(c, "inbound")); + tir_add(c, o, "fixpoint", tir_bool(c, true)); + tir_add(c, o, "lift", tir_bool(c, false)); + tir_add(c, o, "include_low_confidence", tir_bool(c, true)); + tir_add(c, o, "follow_routes", + r->routes == CBM_TEST_RESULT_ROUTES_UNKNOWN + ? tir_null(c) + : tir_bool(c, r->routes == CBM_TEST_RESULT_ROUTES_FOLLOWED)); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +tir_json *tir_receipt_json(tir_context *c, const char *digest) { + const cbm_test_result_receipt_t *r = c->input->receipt; + tir_json *o = tir_node(c, TIR_OBJECT); + tir_json *format = tir_null(c); + if (r->object_format == CBM_TEST_RESULT_OBJECT_SHA1) + format = tir_text(c, "sha1"); + if (r->object_format == CBM_TEST_RESULT_OBJECT_SHA256) + format = tir_text(c, "sha256"); + tir_add(c, o, "object_format", format); + tir_add(c, o, "base", tir_oid(c, r->base)); + tir_add(c, o, "head", tir_oid(c, r->head)); + tir_add(c, o, "merge_base", tir_oid(c, r->merge_base)); + tir_add(c, o, "diff_sha256", tir_digest(c, r->diff_sha256)); + tir_add(c, o, "name_status_sha256", tir_digest(c, r->name_status_sha256)); + tir_add(c, o, "config_sha256", tir_digest(c, r->config_sha256)); + tir_add(c, o, "policy_sha256", tir_digest(c, r->policy_sha256)); + tir_add(c, o, "graph", tir_graph_json(c)); + tir_add(c, o, "coverage", tir_coverage_json(c)); + tir_add(c, o, "traversal", tir_traversal_json(c)); + c->digest_json = tir_text(c, digest); + tir_add(c, o, "decision_sha256", c->digest_json); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} diff --git a/src/mcp/test_impact_result_rows.c b/src/mcp/test_impact_result_rows.c new file mode 100644 index 000000000..fccbd7d1c --- /dev/null +++ b/src/mcp/test_impact_result_rows.c @@ -0,0 +1,287 @@ +#include "mcp/test_impact_result_internal.h" + +static int tir_runner_compare(tir_context *c, const void *a, const void *b) { + return tir_compare(c, ((const tir_runner *)a)->name, ((const tir_runner *)b)->name); +} +static int tir_suite_compare(tir_context *c, const void *a, const void *b) { + return tir_compare(c, ((const tir_suite *)a)->name, ((const tir_suite *)b)->name); +} +static int tir_case_compare(tir_context *c, const void *left, const void *right) { + const tir_case *a = left, *b = right; + int order = tir_compare(c, a->suite, b->suite); + return order ? order : tir_compare(c, a->test, b->test); +} +static int tir_definition_compare(tir_context *c, const void *left, const void *right) { + const tir_model_case *a = left, *b = right; + int order = tir_compare(c, a->file, b->file); + return order ? order : tir_compare(c, a->name, b->name); +} +static int tir_warning_compare(tir_context *c, const void *left, const void *right) { + const tir_warning *a = left, *b = right; + int order = tir_compare(c, tir_literal(tir_warning_name(a->code)), + tir_literal(tir_warning_name(b->code))); + if (!order) + order = tir_compare(c, a->file, b->file); + return order ? order : (a->line > b->line) - (a->line < b->line); +} +static int tir_extension_compare(tir_context *c, const void *left, const void *right) { + const cbm_test_result_extension_t *a = left, *b = right; + int order = tir_compare(c, a->language, b->language); + return order ? order : tir_compare(c, a->edge, b->edge); +} + +size_t tir_find_suite(tir_context *c, tir_bytes name) { + size_t lo = 0, hi = c->suite_count; + while (lo < hi) { + if (!tir_step(c, 0, 1)) + return SIZE_MAX; + size_t mid = lo + (hi - lo) / 2; + int order = tir_compare(c, name, c->suites[mid].name); + if (!order) + return mid; + if (order < 0) + hi = mid; + else + lo = mid + 1; + } + return SIZE_MAX; +} + +static bool tir_runner_contains(tir_context *c, tir_bytes name) { + size_t lo = 0, hi = c->runner_count; + while (lo < hi) { + if (!tir_step(c, 0, 1)) + return false; + size_t mid = lo + (hi - lo) / 2; + int order = tir_compare(c, name, c->runners[mid].name); + if (!order) + return true; + if (order < 0) + hi = mid; + else + lo = mid + 1; + } + return false; +} + +static bool tir_normalize_suites(tir_context *c) { + if (!tir_sort(c, c->suites, c->suite_count, sizeof(*c->suites), tir_suite_compare)) + return false; + size_t used = 0; + bool known = + c->input->inventory_complete && c->input->model && cbm_test_model_complete(c->input->model); + for (size_t i = 0; i < c->suite_count; i++) { + if (!tir_step(c, 0, 1)) + return false; + tir_suite *row = &c->suites[i]; + if (known && !tir_runner_contains(c, row->name)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (used && !tir_compare(c, c->suites[used - 1].name, row->name)) { + c->suites[used - 1].whole |= row->whole; + c->suites[used - 1].reasons |= row->reasons; + } else + c->suites[used++] = *row; + } + c->suite_count = used; + return c->status == CBM_TEST_RESULT_OK; +} + +static bool tir_normalize_cases(tir_context *c) { + if (!tir_sort(c, c->cases, c->case_count, sizeof(*c->cases), tir_case_compare)) + return false; + size_t used = 0; + for (size_t i = 0; i < c->case_count; i++) { + if (!tir_step(c, 0, 1)) + return false; + tir_case *row = &c->cases[i]; + size_t at = tir_find_suite(c, row->suite); + if (at == SIZE_MAX) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + tir_suite *suite = &c->suites[at]; + suite->reasons |= row->reasons; + if (used && !tir_case_compare(c, &c->cases[used - 1], row)) { + tir_case *previous = &c->cases[used - 1]; + previous->reasons |= row->reasons; + if (tir_compare(c, previous->file, row->file)) { + suite->whole = true; + suite->reasons |= CBM_TEST_SELECT_INVENTORY_UNKNOWN; + } + } else { + if (!suite->count) + suite->begin = used; + suite->count++; + c->cases[used++] = *row; + } + } + c->case_count = used; + for (size_t i = 0; i < c->suite_count; i++) { + if (!tir_step(c, 0, 1)) + return false; + if (!c->suites[i].whole && !c->suites[i].count) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + } + return c->status == CBM_TEST_RESULT_OK; +} + +static uint64_t tir_source_line(tir_context *c, const tir_case *test) { + tir_model_case key = {test->file, test->test, 0}; + size_t lo = 0, hi = c->definition_count; + while (lo < hi) { + if (!tir_step(c, 0, 1)) + return 0; + size_t mid = lo + (hi - lo) / 2; + int order = tir_definition_compare(c, &key, &c->definitions[mid]); + if (order > 0) + lo = mid + 1; + else + hi = mid; + } + if (lo == c->definition_count || tir_definition_compare(c, &key, &c->definitions[lo])) + return 0; + if (lo + 1 < c->definition_count && !tir_definition_compare(c, &key, &c->definitions[lo + 1])) + return 0; + return c->definitions[lo].line > 0 ? (uint64_t)c->definitions[lo].line : 0; +} + +static bool tir_source_lines(tir_context *c) { + if (!tir_sort(c, c->definitions, c->definition_count, sizeof(*c->definitions), + tir_definition_compare)) + return false; + for (size_t i = 0; i < c->case_count; i++) { + if (!tir_step(c, 0, 1)) + return false; + tir_case *row = &c->cases[i]; + size_t suite = tir_find_suite(c, row->suite); + if (suite == SIZE_MAX) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (c->suites[suite].whole) + continue; + row->line = tir_source_line(c, row); + if (!row->line) { + if (c->warning_count == c->warning_capacity) + return tir_fail(c, CBM_TEST_RESULT_LIMIT); + c->warnings[c->warning_count++] = + (tir_warning){CBM_TEST_RESULT_WARNING_SOURCE_MAPPING_UNKNOWN, row->file, 0}; + } + } + return c->status == CBM_TEST_RESULT_OK; +} + +static bool tir_diagnostic_order(tir_context *c) { + if (!tir_sort(c, c->warnings, c->warning_count, sizeof(*c->warnings), tir_warning_compare)) + return false; + size_t used = 0; + for (size_t i = 0; i < c->warning_count; i++) { + if (!tir_step(c, 0, 1)) + return false; + if (!used || tir_warning_compare(c, &c->warnings[used - 1], &c->warnings[i])) + c->warnings[used++] = c->warnings[i]; + } + c->warning_count = used; + size_t count = c->input->receipt->extension_count; + if (!tir_sort(c, c->extensions, count, sizeof(*c->extensions), tir_extension_compare)) + return false; + for (size_t i = 1; i < count; i++) { + if (!tir_step(c, 0, 1)) + return false; + if (!tir_extension_compare(c, &c->extensions[i - 1], &c->extensions[i])) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + } + return c->status == CBM_TEST_RESULT_OK; +} + +static bool tir_lane_references(tir_context *c) { + for (size_t i = 0; i < c->rule_count; i++) { + tir_rule *rule = &c->rules[i]; + for (int j = 0; j < rule->source->lane_count; j++) { + bool found = false; + for (size_t k = 0; k < c->lane_count; k++) { + if (!tir_step(c, 0, 1)) + return false; + if (!tir_compare(c, rule->lanes[j], c->lanes[k].name)) + found = true; + } + if (!found) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + } + } + return true; +} + +static bool tir_rule_names_lane(tir_context *c, const tir_rule *rule, tir_bytes name, + bool *matched) { + *matched = false; + for (int i = 0; i < rule->source->lane_count; i++) { + if (!tir_step(c, 0, 1)) + return false; + if (!tir_compare(c, name, rule->lanes[i])) + *matched = true; + } + return c->status == CBM_TEST_RESULT_OK; +} + +static bool tir_activate_lane(tir_context *c, tir_lane *lane) { + lane->active = lane->source->default_run; + lane->rules = tir_node(c, TIR_ARRAY); + for (size_t i = 0; i < c->rule_count; i++) { + if (!tir_step(c, 0, 1)) + return false; + tir_rule *rule = &c->rules[i]; + if (!rule->matched || rule->source->action != CBM_TEST_RULE_LANES) + continue; + bool names_lane = false; + if (!tir_rule_names_lane(c, rule, lane->name, &names_lane)) + return false; + if (names_lane) { + lane->active = true; + if (!tir_add(c, lane->rules, NULL, tir_string(c, rule->id))) + return false; + } + } + return c->status == CBM_TEST_RESULT_OK; +} + +static bool tir_activate(tir_context *c) { + if (!tir_lane_references(c)) + return false; + for (size_t i = 0; i < c->lane_count; i++) { + if (!tir_step(c, 0, 1) || !tir_activate_lane(c, &c->lanes[i])) + return false; + } + return true; +} + +static void tir_global_floors(tir_context *c) { + const cbm_test_result_input_t *in = c->input; + if (in->comparison == CBM_TEST_RESULT_COMPARISON_UNAVAILABLE) + c->global |= TIR_FALLBACK(CBM_TEST_RESULT_FALLBACK_GIT_UNAVAILABLE); + bool empty_shortcut = + in->comparison == CBM_TEST_RESULT_COMPARISON_EMPTY && !c->global && !in->matched_rule_count; + if (!in->policy && !empty_shortcut) + c->global |= TIR_FALLBACK(CBM_TEST_RESULT_FALLBACK_POLICY_UNAVAILABLE); + if (in->policy && !in->activation_complete && + in->comparison != CBM_TEST_RESULT_COMPARISON_EMPTY) + c->global |= TIR_FALLBACK(CBM_TEST_RESULT_FALLBACK_ACTIVATION_UNKNOWN); + if (in->comparison != CBM_TEST_RESULT_COMPARISON_CHANGED) + return; + if (!in->inventory_complete || !in->model || !cbm_test_model_complete(in->model)) + c->global |= CBM_TEST_SELECT_INVENTORY_UNKNOWN; + if (!in->selection) + c->global |= TIR_FALLBACK(CBM_TEST_RESULT_FALLBACK_SELECTION_UNAVAILABLE); +} + +bool tir_prepare(tir_context *c) { + if (!tir_sort(c, c->runners, c->runner_count, sizeof(*c->runners), tir_runner_compare)) + return false; + for (size_t i = 1; i < c->runner_count; i++) { + if (!tir_step(c, 0, 1)) + return false; + if (!tir_compare(c, c->runners[i - 1].name, c->runners[i].name)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + } + if (!tir_normalize_suites(c) || !tir_normalize_cases(c) || !tir_source_lines(c) || + !tir_diagnostic_order(c) || !tir_activate(c)) + return false; + tir_global_floors(c); + return c->status == CBM_TEST_RESULT_OK; +} diff --git a/src/mcp/test_impact_result_util.c b/src/mcp/test_impact_result_util.c new file mode 100644 index 000000000..1c11af15f --- /dev/null +++ b/src/mcp/test_impact_result_util.c @@ -0,0 +1,270 @@ +#include "mcp/test_impact_result_internal.h" +#include + +bool tir_fail(tir_context *c, cbm_test_result_status_t status) { + if (c->status == CBM_TEST_RESULT_OK) + c->status = status; + return false; +} + +bool tir_poll(tir_context *c) { + if (c->status != CBM_TEST_RESULT_OK) + return false; + if (c->cancelled && c->cancelled(c->cancel_context)) + return tir_fail(c, CBM_TEST_RESULT_CANCELLED); + return true; +} + +bool tir_step(tir_context *c, size_t bytes, size_t items) { + if (c->status != CBM_TEST_RESULT_OK) + return false; + c->byte_steps += bytes; + c->item_steps += items; + if (c->byte_steps < 4096 && c->item_steps < 1024) + return true; + c->byte_steps = 0; + c->item_steps = 0; + return tir_poll(c); +} + +bool tir_charge(tir_context *c, uint64_t *used, uint64_t amount, uint64_t limit) { + if (*used > limit || amount > limit - *used) + return tir_fail(c, CBM_TEST_RESULT_LIMIT); + *used += amount; + return true; +} + +bool tir_items(tir_context *c, uint64_t count) { + return tir_charge(c, &c->items, count, c->limits->max_items); +} + +bool tir_input_bytes(tir_context *c, size_t count) { + return tir_charge(c, &c->input_bytes, count, c->limits->max_input_bytes); +} + +bool tir_reserve(tir_context *c, size_t count) { + if (count > c->limits->max_alloc_bytes - c->allocations) + return tir_fail(c, CBM_TEST_RESULT_LIMIT); + c->allocations += count; + return true; +} + +bool tir_copy(tir_context *c, void *dest, const void *source, size_t count) { + unsigned char *out = dest; + const unsigned char *in = source; + for (size_t pos = 0; pos < count;) { + size_t step = count - pos < 4096 ? count - pos : 4096; + if (!tir_step(c, step, 0)) + return false; + memcpy(out + pos, in + pos, step); + pos += step; + } + return true; +} + +void *tir_alloc(tir_context *c, size_t count, size_t size) { + if (!count || !size || c->status != CBM_TEST_RESULT_OK) + return NULL; + if (count > (SIZE_MAX - 7 - sizeof(tir_allocation)) / size) { + tir_fail(c, CBM_TEST_RESULT_LIMIT); + return NULL; + } + size_t payload = count * size; + size_t bytes = payload + sizeof(tir_allocation); + size_t aligned = (bytes + 7) & ~(size_t)7; + if (aligned > SIZE_MAX - c->arena.used || aligned > SIZE_MAX - c->arena.total_alloc || + !tir_reserve(c, bytes) || !tir_poll(c)) { + tir_fail(c, CBM_TEST_RESULT_LIMIT); + return NULL; + } + void *out = cbm_arena_alloc(&c->arena, bytes); + if (!out) { + tir_fail(c, CBM_TEST_RESULT_OOM); + return NULL; + } + for (size_t pos = 0; pos < bytes;) { + size_t step = bytes - pos < 4096 ? bytes - pos : 4096; + if (!tir_step(c, step, 0)) + return NULL; + memset((unsigned char *)out + pos, 0, step); + pos += step; + } + tir_allocation *entry = out; + entry->next = c->owned; + entry->size = payload; + entry->data = entry + 1; + c->owned = entry; + return entry->data; +} + +bool tir_forget_borrowed(tir_context *c, const cbm_test_result_t *result) { + tir_allocation *entry = c->owned; + while (entry) { + tir_allocation *next = entry->next; + if (!tir_step(c, 0, 1)) + return false; + if (entry->data != result && entry->data != result->json) { + for (size_t pos = 0; pos < entry->size;) { + size_t step = entry->size - pos < 4096 ? entry->size - pos : 4096; + if (!tir_step(c, step, 0)) + return false; + memset((unsigned char *)entry->data + pos, 0, step); + pos += step; + } + } + memset(entry, 0, sizeof(*entry)); + entry = next; + } + c->owned = NULL; + return true; +} + +tir_bytes tir_literal(const char *s) { + /* Only bounded internal literals and the fixed 64-hex digest use this. */ + tir_bytes b = {(const unsigned char *)s, 0}; + while (s[b.length]) + b.length++; + return b; +} + +bool tir_cstring(tir_context *c, const char *s, tir_bytes *out) { + if (!s) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + size_t length = 0; + for (;;) { + if (!tir_input_bytes(c, 1) || !tir_step(c, 1, 0)) + return false; + if (!s[length]) + break; + if (length == SIZE_MAX - 1) + return tir_fail(c, CBM_TEST_RESULT_LIMIT); + length++; + } + *out = (tir_bytes){(const unsigned char *)s, length}; + return true; +} + +static bool tir_utf8_tail(tir_context *c, tir_bytes b, size_t *at, unsigned count, uint32_t value, + uint32_t minimum) { + if (count > b.length - *at) + return false; + for (unsigned i = 0; i < count; i++) { + if (!tir_step(c, 1, 0)) + return false; + unsigned ch = b.data[(*at)++]; + if ((ch & 0xc0) != 0x80) + return false; + value = (value << 6) | (ch & 0x3f); + } + return value >= minimum && value <= 0x10ffff && !(value >= 0xd800 && value <= 0xdfff); +} + +bool tir_utf8(tir_context *c, tir_bytes b) { + for (size_t i = 0; i < b.length;) { + if (!tir_step(c, 1, 0)) + return false; + unsigned ch = b.data[i++]; + if (ch < 0x80) + continue; + unsigned count; + uint32_t value, minimum; + if (ch >= 0xc2 && ch <= 0xdf) { + count = 1; + value = ch & 0x1f; + minimum = 0x80; + } else if (ch >= 0xe0 && ch <= 0xef) { + count = 2; + value = ch & 0x0f; + minimum = 0x800; + } else if (ch >= 0xf0 && ch <= 0xf4) { + count = 3; + value = ch & 7; + minimum = 0x10000; + } else { + return false; + } + if (!tir_utf8_tail(c, b, &i, count, value, minimum)) + return false; + } + return true; +} + +bool tir_span(tir_context *c, tir_bytes b, bool text) { + if (b.length && !b.data) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_input_bytes(c, b.length)) + return false; + if (text && !tir_utf8(c, b)) + return tir_fail(c, CBM_TEST_RESULT_UNSUPPORTED); + return c->status == CBM_TEST_RESULT_OK; +} + +bool tir_identifier(tir_context *c, tir_bytes b, bool suite) { + if (!b.length || (suite && b.length > 65536)) + return tir_fail(c, CBM_TEST_RESULT_UNSUPPORTED); + for (size_t i = 0; i < b.length; i++) { + if (!tir_step(c, 1, 0)) + return false; + unsigned ch = b.data[i]; + bool letter = ch == '_' || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); + if (!letter && !(i && ch >= '0' && ch <= '9')) + return tir_fail(c, CBM_TEST_RESULT_UNSUPPORTED); + } + return true; +} + +int tir_compare(tir_context *c, tir_bytes a, tir_bytes b) { + size_t common = a.length < b.length ? a.length : b.length; + for (size_t i = 0; i < common; i++) { + if (!tir_step(c, 1, 0)) + return 0; + if (a.data[i] != b.data[i]) + return a.data[i] < b.data[i] ? -1 : 1; + } + return (a.length > b.length) - (a.length < b.length); +} + +typedef struct { + unsigned char *rows, *scratch; + size_t size, middle, end; + tir_compare_fn compare; +} tir_merge; + +static bool tir_merge_part(tir_context *c, tir_merge *m, size_t left) { + size_t a = left, b = m->middle; + for (size_t dest = left; dest < m->end; dest++) { + if (!tir_step(c, 0, 1)) + return false; + bool take_a = b == m->end; + if (!take_a && a < m->middle) + take_a = m->compare(c, m->rows + a * m->size, m->rows + b * m->size) <= 0; + size_t source = take_a ? a++ : b++; + if (!tir_copy(c, m->scratch + dest * m->size, m->rows + source * m->size, m->size)) + return false; + } + return true; +} + +bool tir_sort(tir_context *c, void *rows, size_t count, size_t size, tir_compare_fn compare) { + if (count < 2) + return c->status == CBM_TEST_RESULT_OK; + unsigned char *scratch = tir_alloc(c, count, size); + if (!scratch) + return false; + for (size_t width = 1; width < count;) { + for (size_t left = 0; left < count;) { + size_t middle = left + (count - left < width ? count - left : width); + size_t end = middle + (count - middle < width ? count - middle : width); + tir_merge merge = {rows, scratch, size, middle, end, compare}; + if (!tir_merge_part(c, &merge, left)) + return false; + left = end; + } + if (!tir_copy(c, rows, scratch, count * size)) + return false; + if (width >= count - width) + break; + width *= 2; + } + return true; +} diff --git a/src/mcp/test_impact_result_validate.c b/src/mcp/test_impact_result_validate.c new file mode 100644 index 000000000..ac5200741 --- /dev/null +++ b/src/mcp/test_impact_result_validate.c @@ -0,0 +1,302 @@ +#include "mcp/test_impact_result_internal.h" + +static bool tir_count(tir_context *c, const void *rows, int count, size_t *out) { + if (count < 0 || (count && !rows)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + *out = (size_t)count; + return tir_items(c, (uint64_t)count); +} + +static bool tir_array_input(tir_context *c, const void *rows, size_t count, size_t size) { + if (count && !rows) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (count > SIZE_MAX / size) + return tir_fail(c, CBM_TEST_RESULT_LIMIT); + return tir_items(c, count); +} + +static bool tir_label(tir_context *c, const char *s, tir_bytes *out) { + if (!tir_cstring(c, s, out)) + return false; + if (!out->length) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_utf8(c, *out)) + return tir_fail(c, CBM_TEST_RESULT_UNSUPPORTED); + return true; +} + +static bool tir_string_array(tir_context *c, const char *const *strings, int count, + tir_bytes **saved) { + size_t n; + if (!tir_count(c, strings, count, &n)) + return false; + tir_bytes *copy = saved ? tir_alloc(c, n, sizeof(*copy)) : NULL; + if (n && saved && !copy) + return false; + for (size_t i = 0; i < n; i++) { + tir_bytes b; + if (!tir_step(c, 0, 1) || !tir_cstring(c, strings[i], &b)) + return false; + if (saved) + copy[i] = b; + } + if (saved) + *saved = copy; + return true; +} + +static bool tir_read_rules(tir_context *c) { + int count = 0; + const cbm_test_rule_t *rows = cbm_test_policy_rules(c->input->policy, &count); + if (!tir_count(c, rows, count, &c->rule_count)) + return false; + c->rules = tir_alloc(c, c->rule_count, sizeof(*c->rules)); + if (count && !c->rules) + return false; + for (size_t i = 0; i < c->rule_count; i++) { + const cbm_test_rule_t *row = &rows[i]; + tir_rule *out = &c->rules[i]; + if (!tir_step(c, 0, 1)) + return false; + if (row->action < CBM_TEST_RULE_RUN_ALL || row->action > CBM_TEST_RULE_IGNORE) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + out->source = row; + if (!tir_label(c, row->id, &out->id) || + !tir_string_array(c, row->paths, row->path_count, NULL) || + !tir_string_array(c, row->suites, row->suite_count, NULL) || + !tir_string_array(c, row->lanes, row->lane_count, &out->lanes)) + return false; + for (size_t j = 0; j < i; j++) { + if (!tir_step(c, 0, 1)) + return false; + if (!tir_compare(c, out->id, c->rules[j].id)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + } + } + return true; +} + +static bool tir_read_lanes(tir_context *c) { + int count = 0; + const cbm_test_lane_t *rows = cbm_test_policy_lanes(c->input->policy, &count); + if (!tir_count(c, rows, count, &c->lane_count)) + return false; + c->lanes = tir_alloc(c, c->lane_count, sizeof(*c->lanes)); + if (count && !c->lanes) + return false; + for (size_t i = 0; i < c->lane_count; i++) { + const cbm_test_lane_t *row = &rows[i]; + tir_lane *out = &c->lanes[i]; + out->source = row; + if (!tir_step(c, 0, 1) || !tir_label(c, row->name, &out->name) || + !tir_string_array(c, row->suites, row->suite_count, NULL) || + !tir_string_array(c, row->exclude_suites, row->exclude_count, NULL) || + !tir_string_array(c, row->signals, row->signal_count, NULL)) + return false; + for (size_t j = 0; j < i; j++) { + if (!tir_step(c, 0, 1)) + return false; + if (!tir_compare(c, out->name, c->lanes[j].name)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + } + } + return true; +} + +static int tir_hex_digit(unsigned char ch) { + if (ch >= '0' && ch <= '9') + return ch - '0'; + if (ch >= 'a' && ch <= 'f') + return ch - 'a' + 10; + return -1; +} + +static bool tir_policy_digest(tir_context *c) { + tir_bytes digest; + if (!tir_cstring(c, cbm_test_policy_digest(c->input->policy), &digest)) + return false; + const cbm_test_result_digest_t *supplied = &c->input->receipt->policy_sha256; + if (digest.length != 64 || !supplied->present) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + for (size_t i = 0; i < 32; i++) { + int a = tir_hex_digit(digest.data[i * 2]); + int b = tir_hex_digit(digest.data[i * 2 + 1]); + if (a < 0 || b < 0 || (unsigned)(a * 16 + b) != supplied->bytes[i]) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + } + return true; +} + +static bool tir_read_model(tir_context *c) { + if (!c->input->model) + return true; + int nr = 0, nd = 0; + const cbm_test_runner_suite_t *r = cbm_test_model_runner_suites(c->input->model, &nr); + const cbm_test_case_t *d = cbm_test_model_cases(c->input->model, &nd); + if (!tir_count(c, r, nr, &c->runner_count) || !tir_count(c, d, nd, &c->definition_count)) + return false; + c->runners = tir_alloc(c, c->runner_count, sizeof(*c->runners)); + c->definitions = tir_alloc(c, c->definition_count, sizeof(*c->definitions)); + if ((nr && !c->runners) || (nd && !c->definitions)) + return false; + for (size_t i = 0; i < c->runner_count; i++) { + tir_runner *out = &c->runners[i]; + if (!tir_step(c, 0, 1) || !tir_cstring(c, r[i].name, &out->name) || + !tir_identifier(c, out->name, true)) + return false; + out->original = r[i].name; + out->perf = r[i].perf; + } + for (size_t i = 0; i < c->definition_count; i++) { + tir_model_case *out = &c->definitions[i]; + if (!tir_step(c, 0, 1) || !tir_cstring(c, d[i].file, &out->file) || + !tir_cstring(c, d[i].name, &out->name)) + return false; + out->line = d[i].start_line; + } + return true; +} + +static bool tir_mask(tir_context *c, unsigned mask, bool require) { + if (((uint64_t)mask & ~TIR_SELECTION_MASK) || (require && !mask)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + return true; +} + +static bool tir_selection_suites(tir_context *c, const cbm_test_selected_suite_t *s) { + for (size_t i = 0; i < c->suite_count; i++) { + if (!tir_step(c, 0, 1) || !tir_mask(c, s[i].reasons, true) || + !tir_cstring(c, s[i].name, &c->suites[i].name) || + !tir_identifier(c, c->suites[i].name, true)) + return false; + c->suites[i].reasons = s[i].reasons; + c->suites[i].whole = s[i].whole; + } + return true; +} + +static bool tir_selection_cases(tir_context *c, const cbm_test_selected_case_t *t) { + for (size_t i = 0; i < c->case_count; i++) { + tir_case *out = &c->cases[i]; + if (!tir_step(c, 0, 1) || !tir_mask(c, t[i].reasons, true) || + !tir_cstring(c, t[i].suite, &out->suite) || !tir_cstring(c, t[i].test, &out->test) || + !tir_cstring(c, t[i].file, &out->file)) + return false; + if (!out->file.length || !out->test.length || !out->suite.length) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + out->reasons = t[i].reasons; + } + return true; +} + +static bool tir_read_selection(tir_context *c) { + const cbm_test_selection_t *selection = c->input->selection; + if (!selection) + return true; + unsigned global = cbm_test_selection_run_all(selection); + if (!tir_mask(c, global, false)) + return false; + c->global |= global; + int ns = 0, nt = 0; + const cbm_test_selected_suite_t *s = cbm_test_selection_suites(selection, &ns); + const cbm_test_selected_case_t *t = cbm_test_selection_cases(selection, &nt); + if (!tir_count(c, s, ns, &c->suite_count) || !tir_count(c, t, nt, &c->case_count)) + return false; + if ((global || c->input->comparison == CBM_TEST_RESULT_COMPARISON_EMPTY) && (ns || nt)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + c->raw_suite_count = c->suite_count; + c->raw_case_count = c->case_count; + c->suites = tir_alloc(c, c->suite_count, sizeof(*c->suites)); + c->cases = tir_alloc(c, c->case_count, sizeof(*c->cases)); + if ((ns && !c->suites) || (nt && !c->cases)) + return false; + return tir_selection_suites(c, s) && tir_selection_cases(c, t); +} + +static bool tir_match_record(tir_context *c, const cbm_test_result_rule_match_t *match) { + if (match->rule_index < 0 || (size_t)match->rule_index >= c->rule_count) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + tir_rule *rule = &c->rules[match->rule_index]; + if (rule->matched) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + rule->matched = true; + rule->resolved = match->target_resolved; + cbm_test_rule_action_t action = rule->source->action; + if (!rule->resolved && (action == CBM_TEST_RULE_IGNORE || action == CBM_TEST_RULE_RUN_ALL)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!rule->resolved) + c->global |= TIR_FALLBACK(CBM_TEST_RESULT_FALLBACK_RULE_TARGET_UNKNOWN); + if (action == CBM_TEST_RULE_RUN_ALL) + c->global |= TIR_FALLBACK(CBM_TEST_RESULT_FALLBACK_RULE_RUN_ALL); + return true; +} + +static bool tir_ledger(tir_context *c) { + const cbm_test_result_input_t *in = c->input; + if (!tir_array_input(c, in->matched_rules, in->matched_rule_count, + sizeof(*in->matched_rules)) || + !tir_array_input(c, in->fallbacks, in->fallback_count, sizeof(*in->fallbacks))) + return false; + if (in->matched_rule_count && + (!in->policy || in->comparison == CBM_TEST_RESULT_COMPARISON_EMPTY)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + for (size_t i = 0; i < in->matched_rule_count; i++) { + if (!tir_step(c, 0, 1) || !tir_match_record(c, &in->matched_rules[i])) + return false; + } + for (size_t i = 0; i < in->fallback_count; i++) { + unsigned code = (unsigned)in->fallbacks[i]; + if (!tir_step(c, 0, 1)) + return false; + if (code >= CBM_TEST_RESULT_FALLBACK_COUNT) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + c->global |= TIR_FALLBACK(code); + } + return true; +} + +static bool tir_read_warnings(tir_context *c) { + const cbm_test_result_input_t *in = c->input; + if (!tir_array_input(c, in->warnings, in->warning_count, sizeof(*in->warnings))) + return false; + if (in->warning_count > SIZE_MAX - c->raw_case_count) + return tir_fail(c, CBM_TEST_RESULT_LIMIT); + c->warning_capacity = in->warning_count + c->raw_case_count; + c->warnings = tir_alloc(c, c->warning_capacity, sizeof(*c->warnings)); + if (c->warning_capacity && !c->warnings) + return false; + for (size_t i = 0; i < in->warning_count; i++) { + const cbm_test_result_warning_t *w = &in->warnings[i]; + if (!tir_step(c, 0, 1)) + return false; + if (w->code < 0 || w->code >= CBM_TEST_RESULT_WARNING_COUNT || (w->line && !w->file.length)) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_span(c, w->file, false)) + return false; + c->warnings[c->warning_count++] = (tir_warning){w->code, w->file, w->line}; + } + return true; +} + +static bool tir_pairs(tir_context *c) { + uint64_t rows = 0; + if (!tir_charge(c, &rows, c->runner_count, UINT64_MAX) || + !tir_charge(c, &rows, c->raw_suite_count, UINT64_MAX) || + !tir_charge(c, &rows, c->raw_case_count, UINT64_MAX)) + return false; + if (c->lane_count && rows > UINT64_MAX / c->lane_count) + return tir_fail(c, CBM_TEST_RESULT_LIMIT); + return tir_items(c, rows * c->lane_count); +} + +bool tir_preflight(tir_context *c) { + int comparison = c->input->comparison; + if (comparison < CBM_TEST_RESULT_COMPARISON_EMPTY || + comparison > CBM_TEST_RESULT_COMPARISON_UNAVAILABLE) + return tir_fail(c, CBM_TEST_RESULT_INVALID); + if (!tir_items(c, 5) || !tir_read_model(c) || !tir_read_selection(c)) + return false; + if (c->input->policy && (!tir_read_rules(c) || !tir_read_lanes(c) || !tir_policy_digest(c))) + return false; + return tir_ledger(c) && tir_read_warnings(c) && tir_receipt_validate(c) && tir_pairs(c); +} diff --git a/src/mcp/test_impact_result_wire.c b/src/mcp/test_impact_result_wire.c new file mode 100644 index 000000000..88d4c5ae8 --- /dev/null +++ b/src/mcp/test_impact_result_wire.c @@ -0,0 +1,255 @@ +#include "mcp/test_impact_result_internal.h" +#include "foundation/test_selection.h" + +static const char *const tir_reason_names[TIR_REASON_COUNT] = {"CHANGED", + "STATIC", + "COVERAGE", + "UNMAPPED", + "COVERAGE_UNKNOWN", + "SETUP_HIT", + "SETUP_UNKNOWN", + "INVENTORY_UNKNOWN", + "CONDITIONAL", + "ARTIFACT_REJECTED", + "DIFF_INCOMPLETE", + "STATIC_INCOMPLETE", + "CHANGE_IDENTITY_UNKNOWN", + "INVALID_INPUT", + "CHANGED_UNREGISTERED", + "RULE", + "POLICY_UNAVAILABLE", + "ACTIVATION_UNKNOWN", + "RULE_TARGET_UNKNOWN", + "GRAPH_UNAVAILABLE", + "GRAPH_REJECTED", + "CAPABILITY_MISSING", + "GIT_UNAVAILABLE", + "CONFIG_INVALID", + "ENGINE_SATURATED", + "UNMAPPED_FILE", + "UNRESOLVED_CHANGED_TEST", + "SELECTION_UNAVAILABLE", + "RULE_RUN_ALL", + "POLICY_EVALUATION_FAILED", + "TRAVERSAL_POLICY_UNKNOWN", + "POLICY_INACTIVE", + "NO_CHANGES", + "NO_SELECTED_TESTS", + "NO_MEMBER_SUITES", + "NARROW_DISABLED"}; +static const char *const tir_evidence_names[CBM_TEST_RESULT_EVIDENCE_COUNT] = { + "PROVIDER_UNAVAILABLE", + "SOURCE_UNVERIFIED", + "IDENTITY_MISMATCH", + "CONTENT_MISMATCH", + "COMMIT_MISMATCH", + "ANCESTRY_UNPROVED", + "TOO_OLD", + "COMPATIBILITY_MISMATCH", + "METADATA_INVALID", + "FORMAT_UNSUPPORTED", + "INVENTORY_INCOMPLETE", + "DIAGNOSTICS_INCOMPLETE", + "CAPABILITY_MISSING", + "SEMANTIC_INPUTS_UNPROVED", + "APPLICABILITY_UNPROVED", + "OBSERVATIONS_INCOMPLETE", + "IMAGE_UNPROVED"}; +_Static_assert(TIR_REASON_COUNT < 64, "reason mask capacity"); + +const char *tir_reason_name(unsigned index) { + return index < TIR_REASON_COUNT ? tir_reason_names[index] : ""; +} +const char *tir_warning_name(cbm_test_result_warning_code_t code) { + static const char *const names[] = {"TEST_NOT_REGISTERED", "SOURCE_MAPPING_UNKNOWN", + "DIAGNOSTICS_INCOMPLETE", "UNSUPPORTED_MAPPING"}; + return code >= 0 && code < CBM_TEST_RESULT_WARNING_COUNT ? names[code] : ""; +} + +static tir_json *tir_named_set(tir_context *c, uint64_t mask, const char *const *names, + size_t count) { + tir_json *array = tir_node(c, TIR_ARRAY); + while (mask) { + size_t best = SIZE_MAX; + for (size_t i = 0; i < count; i++) { + if (!tir_step(c, 0, 1)) + return NULL; + if (!(mask & TIR_REASON(i))) + continue; + if (best == SIZE_MAX || + tir_compare(c, tir_literal(names[i]), tir_literal(names[best])) < 0) + best = i; + } + if (best == SIZE_MAX) { + tir_fail(c, CBM_TEST_RESULT_INVALID); + return NULL; + } + if (!tir_add(c, array, NULL, tir_text(c, names[best]))) + return NULL; + mask &= ~TIR_REASON(best); + } + return array; +} + +tir_json *tir_reasons(tir_context *c, uint64_t reasons) { + return tir_named_set(c, reasons, tir_reason_names, TIR_REASON_COUNT); +} + +tir_json *tir_evidence(tir_context *c, cbm_test_result_evidence_reasons_t reasons) { + uint64_t mask = 0; + for (size_t i = 0; i < reasons.count; i++) { + if (!tir_step(c, 0, 1)) + return NULL; + mask |= TIR_REASON(reasons.values[i]); + } + return tir_named_set(c, mask, tir_evidence_names, CBM_TEST_RESULT_EVIDENCE_COUNT); +} + +static tir_json *tir_case_id(tir_context *c, const tir_case *test) { + if (test->test.length > SIZE_MAX - 2 || test->suite.length > SIZE_MAX - test->test.length - 2) { + tir_fail(c, CBM_TEST_RESULT_LIMIT); + return NULL; + } + size_t length = test->suite.length + 1 + test->test.length; + unsigned char *bytes = tir_alloc(c, length + 1, 1); + if (!bytes || !tir_copy(c, bytes, test->suite.data, test->suite.length) || + !tir_copy(c, bytes + test->suite.length + 1, test->test.data, test->test.length)) + return NULL; + bytes[test->suite.length] = ':'; + return tir_string(c, (tir_bytes){bytes, length}); +} + +tir_json *tir_case_json(tir_context *c, const tir_case *test) { + if (!tir_identifier(c, test->test, false)) + return NULL; + tir_json *o = tir_node(c, TIR_OBJECT); + tir_add(c, o, "test", tir_string(c, test->test)); + tir_add(c, o, "id", tir_case_id(c, test)); + tir_path(c, o, test->file); + tir_add(c, o, "line", test->line ? tir_uint(c, test->line) : tir_null(c)); + tir_add(c, o, "reasons", tir_reasons(c, test->reasons)); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +tir_json *tir_suite_json(tir_context *c, tir_bytes name, const tir_suite *selection, bool whole, + uint64_t reasons) { + tir_json *o = tir_node(c, TIR_OBJECT); + tir_add(c, o, "suite", tir_string(c, name)); + tir_add(c, o, "mode", tir_text(c, whole ? "whole" : "tests")); + if (whole) { + tir_add(c, o, "whole_reason", tir_reasons(c, reasons)); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; + } + if (!selection || !selection->count || selection->begin > c->case_count || + selection->count > c->case_count - selection->begin) { + tir_fail(c, CBM_TEST_RESULT_INVALID); + return NULL; + } + tir_json *tests = tir_node(c, TIR_ARRAY); + for (size_t i = 0; i < selection->count; i++) { + if (!tir_step(c, 0, 1) || + !tir_add(c, tests, NULL, tir_case_json(c, &c->cases[selection->begin + i]))) + return NULL; + } + tir_add(c, o, "tests", tests); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +static tir_json *tir_member(tir_context *c, const tir_json *object, const char *key) { + for (tir_link *entry = object->as.children.first; entry; entry = entry->next) { + if (!tir_step(c, 0, 1)) + return NULL; + if (!tir_compare(c, tir_literal(entry->key), tir_literal(key))) + return entry->value; + } + return NULL; +} + +typedef struct { + tir_context *context; + unsigned char *bytes; + size_t length; +} tir_filter_writer; +static bool tir_filter_token(tir_filter_writer *w, tir_json *token) { + if (!token || token->kind != TIR_STRING) + return tir_fail(w->context, CBM_TEST_RESULT_INVALID); + tir_bytes b = token->as.string; + if (b.length > CBM_TEST_SELECTION_TOKEN_MAX) + return tir_fail(w->context, CBM_TEST_RESULT_UNSUPPORTED); + if (w->length > SIZE_MAX - 2 || b.length > SIZE_MAX - 2 - w->length) + return tir_fail(w->context, CBM_TEST_RESULT_LIMIT); + if (w->bytes) { + if (!tir_copy(w->context, w->bytes + w->length, b.data, b.length)) + return false; + w->bytes[w->length + b.length] = '\n'; + } else if (!tir_step(w->context, 0, 1)) + return false; + w->length += b.length + 1; + return true; +} + +static bool tir_filter_suite(tir_filter_writer *w, const tir_json *suite) { + tir_context *c = w->context; + tir_json *tests = tir_member(c, suite, "tests"); + if (!tests) + return tir_filter_token(w, tir_member(c, suite, "suite")); + for (tir_link *test = tests->as.children.first; test; test = test->next) { + if (!tir_step(c, 0, 1) || !tir_filter_token(w, tir_member(c, test->value, "id"))) + return false; + } + return true; +} + +static bool tir_filter_pass(tir_filter_writer *w, const tir_json *suites) { + for (tir_link *suite = suites->as.children.first; suite; suite = suite->next) { + if (!tir_step(w->context, 0, 1) || !tir_filter_suite(w, suite->value)) + return false; + } + return true; +} + +tir_json *tir_filter_json(tir_context *c, tir_json *suites) { + tir_filter_writer measure = {c, NULL, 0}; + if (!tir_filter_pass(&measure, suites)) + return NULL; + if (!measure.length) { + tir_fail(c, CBM_TEST_RESULT_INVALID); + return NULL; + } + unsigned char *bytes = tir_alloc(c, measure.length + 1, 1); + if (!bytes) + return NULL; + tir_filter_writer write = {c, bytes, 0}; + if (!tir_filter_pass(&write, suites)) + return NULL; + tir_json *o = tir_node(c, TIR_OBJECT); + tir_add(c, o, "encoding", tir_text(c, "cbm-test-only-lines")); + tir_add(c, o, "value", tir_string(c, (tir_bytes){bytes, write.length})); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +tir_json *tir_totals_json(tir_context *c) { + tir_json *o = tir_node(c, TIR_OBJECT); + tir_add(c, o, "lanes", c->input->policy ? tir_uint(c, c->lane_count) : tir_null(c)); + tir_add(c, o, "runnable_lanes", c->global ? tir_null(c) : tir_uint(c, c->runnable)); + tir_add(c, o, "suites", c->global ? tir_null(c) : tir_uint(c, c->suite_occurrences)); + tir_add(c, o, "tests", + c->global || c->whole_occurrence ? tir_null(c) : tir_uint(c, c->test_occurrences)); + return c->status == CBM_TEST_RESULT_OK ? o : NULL; +} + +tir_json *tir_warnings_json(tir_context *c) { + tir_json *array = tir_node(c, TIR_ARRAY); + for (size_t i = 0; i < c->warning_count; i++) { + if (!tir_step(c, 0, 1)) + return NULL; + const tir_warning *w = &c->warnings[i]; + tir_json *o = tir_node(c, TIR_OBJECT); + tir_add(c, o, "code", tir_text(c, tir_warning_name(w->code))); + tir_path(c, o, w->file); + tir_add(c, o, "line", w->line ? tir_uint(c, w->line) : tir_null(c)); + if (!tir_add(c, array, NULL, o)) + return NULL; + } + return array; +} diff --git a/src/mcp/test_impact_select.c b/src/mcp/test_impact_select.c new file mode 100644 index 000000000..e88d2ff99 --- /dev/null +++ b/src/mcp/test_impact_select.c @@ -0,0 +1,336 @@ +/* Pure selection: static no-lift reachability UNION per-test coverage. + * Only complete, admitted evidence can justify an omission. */ +#include "mcp/test_impact.h" +#include "foundation/arena.h" +#include +#include +#include + +struct cbm_test_selection { + CBMArena arena; + unsigned run_all; + cbm_test_selected_suite_t *suites; + int suite_count; + cbm_test_selected_case_t *cases; + int case_count; +}; + +typedef struct { + const cbm_test_reach_t *row; + bool registered; +} sl_reach_t; + +static void *sl_array(cbm_test_selection_t *result, int count, size_t size) { + if (count <= 0 || (size_t)count > SIZE_MAX / size) + return NULL; + return cbm_arena_calloc(&result->arena, (size_t)count * size); +} + +static int sl_key(const char *af, const char *an, const char *bf, const char *bn) { + int cmp = strcmp(af, bf); + return cmp ? cmp : strcmp(an, bn); +} + +static int sl_reach_compare(const void *left, const void *right) { + const cbm_test_reach_t *a = ((const sl_reach_t *)left)->row; + const cbm_test_reach_t *b = ((const sl_reach_t *)right)->row; + return sl_key(a->file, a->test, b->file, b->test); +} + +static sl_reach_t *sl_reach_find(sl_reach_t *rows, int count, const char *file, const char *name) { + int lo = 0, hi = count; + while (lo < hi) { + int mid = lo + (hi - lo) / 2; + int cmp = sl_key(file, name, rows[mid].row->file, rows[mid].row->test); + if (!cmp) + return &rows[mid]; + if (cmp < 0) + hi = mid; + else + lo = mid + 1; + } + return NULL; +} + +static int sl_case_compare(const void *left, const void *right) { + const cbm_test_case_t *a = *(const cbm_test_case_t *const *)left; + const cbm_test_case_t *b = *(const cbm_test_case_t *const *)right; + return sl_key(a->file, a->name, b->file, b->name); +} + +/* Ambiguous definitions, including alternative preprocessor definitions, do + * not establish a unique graph/source identity. Return no mapping. */ +static const cbm_test_case_t *sl_case_find(const cbm_test_case_t **rows, int count, + const char *file, const char *name) { + int lo = 0, hi = count; + while (lo < hi) { + int mid = lo + (hi - lo) / 2; + int cmp = sl_key(file, name, rows[mid]->file, rows[mid]->name); + if (!cmp) { + if ((mid && sl_case_compare(&rows[mid - 1], &rows[mid]) == 0) || + (mid + 1 < count && sl_case_compare(&rows[mid], &rows[mid + 1]) == 0)) + return NULL; + return rows[mid]; + } + if (cmp < 0) + hi = mid; + else + lo = mid + 1; + } + return NULL; +} + +static int sl_suite_find(const cbm_test_selected_suite_t *rows, int count, const char *name) { + int lo = 0, hi = count; + while (lo < hi) { + int mid = lo + (hi - lo) / 2; + int cmp = strcmp(name, rows[mid].name); + if (!cmp) + return mid; + if (cmp < 0) + hi = mid; + else + lo = mid + 1; + } + return -1; +} + +static int sl_registration_compare(const void *left, const void *right) { + const cbm_test_registration_t *a = *(const cbm_test_registration_t *const *)left; + const cbm_test_registration_t *b = *(const cbm_test_registration_t *const *)right; + int cmp = sl_key(a->suite, a->test, b->suite, b->test); + return cmp ? cmp : strcmp(a->file, b->file); +} + +static void sl_whole(cbm_test_selected_suite_t *suite, unsigned reason) { + suite->whole = true; + suite->reasons |= reason; +} + +static cbm_test_selection_t *sl_full(cbm_test_selection_t *result, unsigned reasons) { + result->run_all |= reasons; + result->suite_count = 0; + result->case_count = 0; + return result; +} + +cbm_test_selection_t *cbm_test_select(const cbm_test_selection_input_t *input) { + CBMArena arena; + cbm_arena_init(&arena); + cbm_test_selection_t *result = cbm_arena_calloc(&arena, sizeof(*result)); + if (!result) { + cbm_arena_destroy(&arena); + return NULL; + } + result->arena = arena; + if (!input || input->reach_count < 0 || input->changed_function_count < 0 || + input->suite_trigger_count < 0 || (input->suite_trigger_count && !input->suite_triggers) || + (input->reach_count && !input->reach) || + (input->changed_function_count && !input->changed_function_ids)) + return sl_full(result, CBM_TEST_SELECT_INVALID_INPUT); + if (!input->diff_complete) + return sl_full(result, CBM_TEST_SELECT_DIFF_INCOMPLETE); + /* Empty is asserted from the complete diff, not inferred from the number + * of graph seeds or coverage IDs that happened to resolve. */ + if (!input->has_changes) + return result; + unsigned incomplete = 0; + if (!input->inventory_complete || !cbm_test_model_complete(input->model)) + incomplete |= CBM_TEST_SELECT_INVENTORY_UNKNOWN; + if (!input->static_complete) + incomplete |= CBM_TEST_SELECT_STATIC_INCOMPLETE; + bool coverage_ok = input->coverage_admitted && input->coverage; + if (coverage_ok && !input->coverage_changes_complete) + incomplete |= CBM_TEST_SELECT_CHANGE_IDENTITY_UNKNOWN; + if (incomplete) + return sl_full(result, incomplete); + if (coverage_ok) { + int functions = cbm_coverage_map_id_count(input->coverage); + for (int i = 0; i < input->changed_function_count; i++) { + if (input->changed_function_ids[i] < 0 || input->changed_function_ids[i] >= functions) + return sl_full(result, CBM_TEST_SELECT_INVALID_INPUT); + } + } + + int runner_count, definition_count, registration_count, case_count; + const cbm_test_runner_suite_t *runners = + cbm_test_model_runner_suites(input->model, &runner_count); + const cbm_test_suite_t *definitions = cbm_test_model_suites(input->model, &definition_count); + const cbm_test_registration_t *registrations = + cbm_test_model_registrations(input->model, ®istration_count); + const cbm_test_case_t *cases = cbm_test_model_cases(input->model, &case_count); + if (!runner_count) + return sl_full(result, CBM_TEST_SELECT_INVENTORY_UNKNOWN); + result->suites = sl_array(result, runner_count, sizeof(*result->suites)); + result->cases = sl_array(result, registration_count, sizeof(*result->cases)); + int *definition_counts = sl_array(result, runner_count, sizeof(*definition_counts)); + int *registration_counts = sl_array(result, runner_count, sizeof(*registration_counts)); + sl_reach_t *reach = sl_array(result, input->reach_count, sizeof(*reach)); + const cbm_test_case_t **case_order = sl_array(result, case_count, sizeof(*case_order)); + const cbm_test_registration_t **order = sl_array(result, registration_count, sizeof(*order)); + if (!result->suites || !definition_counts || !registration_counts || + (registration_count && (!result->cases || !order)) || (input->reach_count && !reach) || + (case_count && !case_order)) + goto exhausted; + result->suite_count = runner_count; + for (int i = 0; i < runner_count; i++) { + cbm_test_selected_suite_t *suite = &result->suites[i]; + suite->name = cbm_arena_strdup(&result->arena, runners[i].name); + if (!suite->name) + goto exhausted; + if (!coverage_ok) { + sl_whole(suite, CBM_TEST_SELECT_ARTIFACT_REJECTED); + continue; + } + const cbm_coverage_test_t *setup = + cbm_coverage_map_find_test(input->coverage, suite->name, "*"); + if (!setup || !setup->complete) + sl_whole(suite, CBM_TEST_SELECT_SETUP_UNKNOWN); + if (cbm_coverage_test_intersects(setup, input->changed_function_ids, + input->changed_function_count)) + sl_whole(suite, CBM_TEST_SELECT_SETUP_HIT); + } + for (int i = 0; i < input->suite_trigger_count; i++) { + const cbm_test_suite_trigger_t *trigger = &input->suite_triggers[i]; + if (!trigger->suite || !*trigger->suite) + return sl_full(result, CBM_TEST_SELECT_INVALID_INPUT); + int at = sl_suite_find(result->suites, runner_count, trigger->suite); + if (at < 0) + return sl_full(result, CBM_TEST_SELECT_INVENTORY_UNKNOWN); + unsigned reasons = (trigger->reached ? CBM_TEST_SELECT_STATIC : 0) | + (trigger->changed ? CBM_TEST_SELECT_CHANGED : 0) | + (trigger->rule ? CBM_TEST_SELECT_RULE : 0); + if (reasons) + sl_whole(&result->suites[at], reasons); + } + for (int i = 0; i < definition_count; i++) { + int at = sl_suite_find(result->suites, runner_count, definitions[i].name); + if (at >= 0) { + definition_counts[at]++; + if (definitions[i].macro_registrations) + sl_whole(&result->suites[at], CBM_TEST_SELECT_INVENTORY_UNKNOWN); + } + } + for (int i = 0; i < input->reach_count; i++) { + if (!input->reach[i].file || !*input->reach[i].file || !input->reach[i].test || + !*input->reach[i].test) + return sl_full(result, CBM_TEST_SELECT_INVALID_INPUT); + reach[i].row = &input->reach[i]; + } + if (input->reach_count > 1) { + qsort(reach, (size_t)input->reach_count, sizeof(*reach), sl_reach_compare); + for (int i = 1; i < input->reach_count; i++) + if (sl_reach_compare(&reach[i - 1], &reach[i]) == 0) + return sl_full(result, CBM_TEST_SELECT_INVALID_INPUT); + } + for (int i = 0; i < case_count; i++) + case_order[i] = &cases[i]; + if (case_count > 1) + qsort(case_order, (size_t)case_count, sizeof(*case_order), sl_case_compare); + for (int i = 0; i < registration_count; i++) + order[i] = ®istrations[i]; + if (registration_count > 1) + qsort(order, (size_t)registration_count, sizeof(*order), sl_registration_compare); + + for (int i = 0; i < registration_count; i++) { + const cbm_test_registration_t *reg = order[i]; + int at = sl_suite_find(result->suites, runner_count, reg->suite); + if (at < 0) + return sl_full(result, CBM_TEST_SELECT_INVENTORY_UNKNOWN); + cbm_test_selected_suite_t *suite = &result->suites[at]; + registration_counts[at]++; + sl_reach_t *evidence = sl_reach_find(reach, input->reach_count, reg->file, reg->test); + if (evidence) + evidence->registered = true; + const cbm_test_case_t *test = sl_case_find(case_order, case_count, reg->file, reg->test); + if (!reg->resolved || !test) + sl_whole(suite, CBM_TEST_SELECT_INVENTORY_UNKNOWN); + bool repeated = + i > 0 && sl_key(order[i - 1]->suite, order[i - 1]->test, reg->suite, reg->test) == 0; + if (repeated && strcmp(order[i - 1]->file, reg->file) != 0) + sl_whole(suite, CBM_TEST_SELECT_INVENTORY_UNKNOWN); + unsigned reasons = 0; + if (!evidence || !evidence->row->mapped) + reasons |= CBM_TEST_SELECT_UNMAPPED; + if (evidence && evidence->row->reached) + reasons |= CBM_TEST_SELECT_STATIC; + if (evidence && evidence->row->changed) + reasons |= CBM_TEST_SELECT_CHANGED; + if (coverage_ok) { + const cbm_coverage_test_t *covered = + cbm_coverage_map_find_test(input->coverage, reg->suite, reg->test); + if (!covered || !covered->complete) + reasons |= CBM_TEST_SELECT_COVERAGE_UNKNOWN; + if (cbm_coverage_test_intersects(covered, input->changed_function_ids, + input->changed_function_count)) + reasons |= CBM_TEST_SELECT_COVERAGE; + } + suite->reasons |= reasons; + if (reasons && (reg->conditional || (test && test->conditional))) + sl_whole(suite, CBM_TEST_SELECT_CONDITIONAL); + if (suite->whole || !reasons) + continue; + if (repeated && result->case_count && + sl_key(result->cases[result->case_count - 1].suite, + result->cases[result->case_count - 1].test, reg->suite, reg->test) == 0) { + result->cases[result->case_count - 1].reasons |= reasons; + continue; + } + cbm_test_selected_case_t *out = &result->cases[result->case_count++]; + out->suite = suite->name; + out->test = cbm_arena_strdup(&result->arena, reg->test); + out->file = cbm_arena_strdup(&result->arena, reg->file); + out->reasons = reasons; + if (!out->test || !out->file) + goto exhausted; + } + for (int i = 0; i < input->reach_count; i++) + if (reach[i].row->changed && !reach[i].registered) + return sl_full(result, CBM_TEST_SELECT_CHANGED_UNREGISTERED); + for (int i = 0; i < runner_count; i++) + if (definition_counts[i] != 1 || !registration_counts[i]) + sl_whole(&result->suites[i], CBM_TEST_SELECT_INVENTORY_UNKNOWN); + /* A later registration can promote an earlier test's suite to whole. + * Compact cases while the complete, sorted suite table is still present. */ + int used = 0; + for (int i = 0; i < result->case_count; i++) { + int at = sl_suite_find(result->suites, runner_count, result->cases[i].suite); + if (!result->suites[at].whole) + result->cases[used++] = result->cases[i]; + } + result->case_count = used; + used = 0; + for (int i = 0; i < runner_count; i++) + if (result->suites[i].whole || result->suites[i].reasons) + result->suites[used++] = result->suites[i]; + result->suite_count = used; + return result; +exhausted: + cbm_test_selection_free(result); + return NULL; +} + +void cbm_test_selection_free(cbm_test_selection_t *selection) { + if (selection) { + CBMArena arena = selection->arena; + cbm_arena_destroy(&arena); + } +} + +unsigned cbm_test_selection_run_all(const cbm_test_selection_t *selection) { + return selection ? selection->run_all : CBM_TEST_SELECT_INVALID_INPUT; +} + +const cbm_test_selected_suite_t *cbm_test_selection_suites(const cbm_test_selection_t *selection, + int *count) { + if (count) + *count = selection ? selection->suite_count : 0; + return selection ? selection->suites : NULL; +} + +const cbm_test_selected_case_t *cbm_test_selection_cases(const cbm_test_selection_t *selection, + int *count) { + if (count) + *count = selection ? selection->case_count : 0; + return selection ? selection->cases : NULL; +} diff --git a/src/mcp/test_impact_tree.c b/src/mcp/test_impact_tree.c new file mode 100644 index 000000000..ed3f64ca0 --- /dev/null +++ b/src/mcp/test_impact_tree.c @@ -0,0 +1,138 @@ +#include "mcp/test_impact_tree_internal.h" + +static cbm_pinned_tree_t *tpt_owner(tpt_context *c, const cbm_pinned_tree_options_t *o) { + cbm_pinned_tree_t temporary = {0}; + temporary.limits = o->limits; + cbm_arena_init_lazy(&temporary.arena, 65536); + c->tree = &temporary; + cbm_pinned_tree_t *t = tpt_alloc(c, 1, sizeof(*t)); + if (!t) { + cbm_arena_destroy(&temporary.arena); + c->tree = NULL; + return NULL; + } + *t = temporary; + t->parent.handle = TPT_INVALID_HANDLE; + t->parent.directory = true; + t->parent.parent = SIZE_MAX; +#ifdef _WIN32 + t->token = INVALID_HANDLE_VALUE; + t->probe = INVALID_HANDLE_VALUE; + t->impersonation = INVALID_HANDLE_VALUE; + t->enumeration = INVALID_HANDLE_VALUE; +#else + t->enumeration_fd = -1; +#endif + c->tree = t; + return t; +} + +static bool tpt_load(tpt_context *c, const cbm_pinned_tree_options_t *o) { + if (!tpt_identity_copy(c, o) || !tpt_poll(c)) + return false; + cbm_git_tree_inventory_t inventory = {0}; + cbm_git_facts_error_t error = {0}; + bool ok = cbm_git_facts_inventory(o->facts, o->revision, &inventory, &error); + if (!tpt_after_facts(c, ok, &error) || !tpt_plan(c, o, &inventory)) + return false; + cbm_git_blob_batch_t batch = {0}; + return tpt_batch(c, o, &batch) && tpt_native_prepare(c) && tpt_build(c, &batch) && + tpt_audit(c) && tpt_manifest(c) && tpt_poll(c); +} + +static void tpt_cleanup_error(tpt_context *c, const cbm_pinned_tree_error_t *primary) { + cbm_pinned_tree_error_t cleanup = c->error; + c->error = *primary; + c->error.status = CBM_PINNED_TREE_CLEANUP_REQUIRED; + if (primary->status == CBM_PINNED_TREE_OK) { + c->error.cause = cleanup.status == CBM_PINNED_TREE_CHANGED ? CBM_PINNED_TREE_CHANGED + : CBM_PINNED_TREE_IO; + memcpy(c->error.diagnostic, cleanup.diagnostic, sizeof(c->error.diagnostic)); + } + cbm_pinned_tree_t *t = c->tree; + for (size_t i = 0; t->objects && i < t->object_count; i++) { + if (t->objects[i].created) { + memcpy(c->error.cleanup_path, t->root_path, TPT_PATH_CAP); + break; + } + } +} + +cbm_pinned_tree_status_t cbm_pinned_tree_create(const cbm_pinned_tree_options_t *o, + cbm_pinned_tree_t **out, + cbm_pinned_tree_error_t *error) { + if (out) + *out = NULL; + tpt_context c = {0}; + tpt_error_init(&c.error); + if (error) + *error = c.error; + if (!out || !tpt_options_valid(o)) { + tpt_fail(&c, CBM_PINNED_TREE_INVALID, "invalid materializer options"); + } else { + c.control = &o->control; + c.building = true; + if (tpt_owner(&c, o) && tpt_load(&c, o)) { + c.tree->ready = true; + *out = c.tree; + } else if (c.tree) { + cbm_pinned_tree_error_t primary = c.error; + tpt_error_init(&c.error); + c.control = NULL; + if (tpt_cleanup(&c)) { + tpt_dispose_memory(c.tree); + c.error = primary; + } else { + tpt_cleanup_error(&c, &primary); + *out = c.tree; + } + } + } + if (error) + *error = c.error; + return c.error.status; +} + +const cbm_pinned_tree_view_t *cbm_pinned_tree_view(const cbm_pinned_tree_t *t) { + return t && t->ready ? &t->view : NULL; +} + +cbm_pinned_tree_status_t cbm_pinned_tree_verify(cbm_pinned_tree_t *t, + const cbm_pinned_tree_control_t *control, + cbm_pinned_tree_error_t *error) { + tpt_context c = {.tree = t, .control = control}; + tpt_error_init(&c.error); + if (!t || !t->ready || !control || !control->deadline_ms) { + tpt_fail(&c, CBM_PINNED_TREE_INVALID, "invalid verification arguments"); + } else { + t->ready = false; + if (tpt_poll(&c) && tpt_audit(&c) && tpt_poll(&c)) + t->ready = true; + } + if (error) + *error = c.error; + return c.error.status; +} + +cbm_pinned_tree_status_t cbm_pinned_tree_close(cbm_pinned_tree_t **owner, + cbm_pinned_tree_error_t *error) { + tpt_context c = {0}; + tpt_error_init(&c.error); + if (!owner) { + tpt_fail(&c, CBM_PINNED_TREE_INVALID, "missing owner pointer"); + } else if (*owner) { + c.tree = *owner; + c.tree->ready = false; + if (tpt_cleanup(&c)) { + tpt_dispose_memory(c.tree); + *owner = NULL; + } else { + cbm_pinned_tree_error_t primary; + tpt_error_init(&primary); + tpt_cleanup_error(&c, &primary); + } + } + if (error) + *error = c.error; + return c.error.status; +} diff --git a/src/mcp/test_impact_tree.h b/src/mcp/test_impact_tree.h new file mode 100644 index 000000000..34895a9a1 --- /dev/null +++ b/src/mcp/test_impact_tree.h @@ -0,0 +1,154 @@ +/* Internal pinned-tree materializer. No trust or admission claim. */ +#ifndef CBM_TEST_IMPACT_TREE_H +#define CBM_TEST_IMPACT_TREE_H + +#include "mcp/test_impact_git.h" +#include +#include +#include + +typedef struct cbm_pinned_tree cbm_pinned_tree_t; + +typedef enum { + CBM_PINNED_TREE_OK = 0, + CBM_PINNED_TREE_INVALID, + CBM_PINNED_TREE_UNSUPPORTED, + CBM_PINNED_TREE_COLLISION, + CBM_PINNED_TREE_LIMIT, + CBM_PINNED_TREE_OOM, + CBM_PINNED_TREE_IO, + CBM_PINNED_TREE_GIT, + CBM_PINNED_TREE_CANCELLED, + CBM_PINNED_TREE_DEADLINE, + CBM_PINNED_TREE_CHANGED, + CBM_PINNED_TREE_CLEANUP_REQUIRED +} cbm_pinned_tree_status_t; + +typedef struct { + cbm_pinned_tree_status_t status; + /* Primary construction/verification cause when cleanup also failed; + * otherwise equal to status. Never a borrowed diagnostic or path. */ + cbm_pinned_tree_status_t cause; + cbm_git_facts_error_t git; /* initialized; meaningful for GIT cause */ + char diagnostic[512]; + char cleanup_path[4096]; /* owned child root only; empty if none remains */ +} cbm_pinned_tree_error_t; + +typedef struct { + size_t max_files; /* positive; complete non-directory inventory */ + size_t max_directories; /* positive; includes the owned root */ + size_t max_total_content_bytes; /* positive; each distinct path counts in full */ + size_t max_relative_path_bytes; /* positive; 1..4095, excludes terminator */ + size_t max_arena_bytes; /* positive; logical materializer allocation requests */ + cbm_git_blob_batch_limits_t blob_batch; /* all positive; separate facts-owned budget */ +} cbm_pinned_tree_limits_t; + +typedef struct { + uint64_t deadline_ms; /* positive absolute cbm_now_ms deadline for this operation */ + cbm_git_facts_cancel_fn cancelled; /* optional, prompt, pure predicate */ + void *cancel_context; /* borrowed only until synchronous operation returns */ +} cbm_pinned_tree_control_t; + +typedef struct { + cbm_git_facts_t *facts; /* borrowed/serialized only for create; not retained */ + cbm_git_revision_t revision; /* HEAD or actual MERGE_BASE only */ + /* Existing absolute private physical directory. POSIX rejects extended + * access ACLs and every nonempty default ACL; only an absent or minimal + * mode-equivalent Linux + * access ACL is allowed. Required inspection failure is UNSUPPORTED. + * Caller-parent ACLs are never modified. See normative native policy. */ + const char *private_parent; + cbm_pinned_tree_limits_t limits; + cbm_pinned_tree_control_t control; +} cbm_pinned_tree_options_t; + +typedef struct { + const unsigned char *path; /* owned exact Git bytes plus convenience NUL */ + size_t path_length; + uint32_t git_mode; /* 0100644 or 0100755, independent of native permissions */ + char oid[65]; + uint64_t content_length; + unsigned char content_sha256[32]; +} cbm_pinned_tree_file_t; + +typedef struct { + const char *root; /* owned native absolute root, <=4095 bytes excluding NUL */ + const cbm_git_facts_identity_t *identity; /* deep copy; not authenticated */ + cbm_git_revision_t revision; + const char *commit; /* full selected pinned OID */ + const cbm_pinned_tree_file_t *files; /* unsigned raw-path order; NULL when empty */ + size_t file_count; + size_t directory_count; /* root plus unique proper path prefixes */ + uint64_t total_content_bytes; + unsigned char manifest_sha256[32]; /* exact framing in normative text */ +} cbm_pinned_tree_view_t; + +/* Clear *out first and initialize error when supplied. Only OK yields a READY + * owner/view. Ordinary failure yields NULL after checked cleanup. If cleanup + * fails, return CLEANUP_REQUIRED and a disposal-only owner through *out; all + * views are unavailable, but close() may be retried. Thus callers must dispose + * any non-NULL *out even after failure. No materialized file is executed. + * Establish/check native owner/mode/ACL policy before any payload bytes. + * Source buffers/option strings may be discarded and facts freed on return. */ +cbm_pinned_tree_status_t cbm_pinned_tree_create(const cbm_pinned_tree_options_t *options, + cbm_pinned_tree_t **out, + cbm_pinned_tree_error_t *error); + +/* NULL unless READY. Borrowed immutable view until close or the next valid + * verify/read_prefix call. No getter can turn a failed/disposal-only owner into a usable tree. */ +const cbm_pinned_tree_view_t *cbm_pinned_tree_view(const cbm_pinned_tree_t *tree); + +/* Recheck exact complete inventory, bytes, identities and native mode/ACL policy after + * consumers are quiescent; does not prove quiescence itself. No Git/facts use. + * Invalid arguments leave READY unchanged. Once valid verification starts, + * any non-OK result permanently invalidates views; only close remains valid. + * Uses preallocated bounded scratch, retains no callback/context. */ +cbm_pinned_tree_status_t cbm_pinned_tree_verify(cbm_pinned_tree_t *tree, + const cbm_pinned_tree_control_t *control, + cbm_pinned_tree_error_t *error); + +/* Verified selected-file prefix read. + * Serialized READY-only operation. Verify the full indexed file and native + * policy of its parent/ancestor chain before publishing at most capacity bytes. + * No NUL conversion; copied is required and cleared on entry, error optional. + * Invalid arguments and the preflight content cap leave READY unchanged. + * A valid started failure leaves the owner disposal-only; only close is valid. + * Writable outputs, control/context and borrowed owner storage must not alias. + * All operands remain live until return. Violating nonaliasing is invalid use; no overlap detector + * is provided. + */ +cbm_pinned_tree_status_t cbm_pinned_tree_read_prefix(cbm_pinned_tree_t *tree, size_t file_index, + uint64_t max_content_bytes, + unsigned char *prefix, size_t capacity, + size_t *copied, + const cbm_pinned_tree_control_t *control, + cbm_pinned_tree_error_t *error); + +/* Test-build-only boundary simulations. No global state/raw handles. + * Faults affect only read_prefix and are consumed by its next valid started call. + */ +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +typedef enum { + CBM_PINNED_TREE_READ_FAULT_NONE = 0, + CBM_PINNED_TREE_READ_FAULT_READ, + CBM_PINNED_TREE_READ_FAULT_EOF, + CBM_PINNED_TREE_READ_FAULT_CLOSE +} cbm_pinned_tree_read_fault_t; + +/* READY-only: false/unchanged for NULL, disposal-only or invalid enum. NONE clears + * a pending fault. No overlap with another owner operation. No IO/allocation. */ +bool cbm_pinned_tree_test_set_read_fault(cbm_pinned_tree_t *tree, + cbm_pinned_tree_read_fault_t fault); +#endif + +/* Explicit, checked disposal. Requires all consumers/children quiescent and no + * overlapping owner operation. Ignores cancellation/deadlines; does not run + * processes. Removes only this owner's tracked resources, never unexpected + * descendants or a replacement object at an owned path. On OK frees owner and + * sets *tree=NULL. On CLEANUP_REQUIRED retains a disposal-only owner for retry, + * with bounded cleanup_path. NULL tree-pointer is INVALID; *tree==NULL is OK. + * No unreported free/destructor exists and no process-global cleanup is used. */ +cbm_pinned_tree_status_t cbm_pinned_tree_close(cbm_pinned_tree_t **tree, + cbm_pinned_tree_error_t *error); + +#endif diff --git a/src/mcp/test_impact_tree_audit.c b/src/mcp/test_impact_tree_audit.c new file mode 100644 index 000000000..661797516 --- /dev/null +++ b/src/mcp/test_impact_tree_audit.c @@ -0,0 +1,283 @@ +#include "mcp/test_impact_tree_internal.h" +#include "foundation/secure_random.h" + +bool tpt_join(tpt_context *c, const tpt_object *o) { + cbm_pinned_tree_t *t = c->tree; + const char *base = t->root_path; + const char *suffix = o->path; + size_t suffix_length = o->length, base_length = 0; + if (o == &t->parent) { + base = t->parent_path; + suffix_length = 0; + } else if (o == &t->objects[0]) { + suffix_length = 0; + } + if (!tpt_length(c, base, TPT_PATH_CAP - 1, &base_length)) + return false; + size_t separator = suffix_length ? 1 : 0; + if (base_length + separator >= TPT_PATH_CAP || + suffix_length > TPT_PATH_CAP - 1 - base_length - separator) + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "joined native path limit"); + memcpy(t->path_scratch, base, base_length); + if (separator) + t->path_scratch[base_length++] = '/'; + memcpy(t->path_scratch + base_length, suffix, suffix_length); + t->path_scratch[base_length + suffix_length] = 0; + return true; +} + +bool tpt_chain(tpt_context *c, const tpt_object *o) { + cbm_pinned_tree_t *t = c->tree; + size_t count = 0, parent = o->parent; + while (parent != SIZE_MAX) { + if (!tpt_poll(c) || count >= t->view.directory_count) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "invalid tracked ancestor plan"); + t->ancestors[count++] = parent; + parent = t->objects[parent].parent; + } + bool absent = false; + if (!tpt_native_identity(c, &t->parent, &absent)) + return false; + if (absent) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "tracked parent absent"); + while (count) { + tpt_object *p = &t->objects[t->ancestors[--count]]; + if (!tpt_poll(c) || !tpt_native_identity(c, p, &absent)) + return false; + if (absent) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "tracked ancestor absent"); + } + return true; +} + +bool tpt_record_identity(tpt_context *c, tpt_object *o, tpt_identity identity) { + cbm_pinned_tree_t *t = c->tree; + o->identity = identity; + o->identified = true; + for (size_t i = 0; i < t->object_count; i++) { + if (!tpt_poll(c)) + return false; + const tpt_object *other = &t->objects[i]; + if (other != o && other->identified && tpt_native_id_equal(other->identity, identity)) + return tpt_fail(c, CBM_PINNED_TREE_COLLISION, "native object aliases tracked name"); + } + o->identity = identity; + o->identified = true; + return true; +} + +static bool tpt_stream_next(tpt_context *c, tpt_object *o, const tpt_stream_options *options, + uint64_t total, size_t *count) { + if (!tpt_poll(c)) + return false; + if (!options->bounded) + return tpt_native_read(c, o, count); + uint64_t remaining = c->tree->files[o->file].content_length - total; + size_t request = remaining > TPT_CHUNK ? TPT_CHUNK : (size_t)remaining; + return tpt_native_read_bounded(c, o, request ? request : 1, count); +} + +static bool tpt_stream_chunk(tpt_context *c, cbm_sha256_ctx *hash, + const tpt_stream_options *options, uint64_t total, size_t count) { + const unsigned char *bytes = c->tree->io_scratch; + if (options->expected && !tpt_equal(c, bytes, options->expected->data + (size_t)total, count)) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "file readback differs from pinned blob"); + if (!tpt_hash(c, hash, bytes, count)) + return false; + if (total < options->capacity) { + size_t room = options->capacity - (size_t)total; + size_t retained = count < room ? count : room; + if (!tpt_copy(c, options->prefix + (size_t)total, bytes, retained)) + return false; + } + return true; +} + +bool tpt_read_stream(tpt_context *c, tpt_object *o, const tpt_stream_options *options) { + cbm_pinned_tree_file_t *file = &c->tree->files[o->file]; + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + uint64_t total = 0; + for (;;) { + size_t n = 0; + if (!tpt_stream_next(c, o, options, total, &n)) + return false; + if (!n) { + if (options->eof_fault) + return tpt_fail(c, CBM_PINNED_TREE_IO, "simulated prefix EOF failure"); + break; + } + if (n > file->content_length - total) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "file exceeds pinned length"); + if (!tpt_stream_chunk(c, &hash, options, total, n)) + return false; + total += n; + if (options->read_fault) + return tpt_fail(c, CBM_PINNED_TREE_IO, "simulated prefix read failure"); + } + if (total != file->content_length) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "file shorter than pinned length"); + unsigned char digest[32]; + cbm_sha256_final(&hash, digest); + if (options->expected) + memcpy(file->content_sha256, digest, 32); + else if (!tpt_equal(c, digest, file->content_sha256, 32)) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "file digest changed"); + return true; +} + +bool tpt_readback(tpt_context *c, tpt_object *o, const cbm_git_bytes_t *expected) { + tpt_stream_options options = {.expected = expected}; + return tpt_chain(c, o) && tpt_native_check(c, o, false) && tpt_read_stream(c, o, &options) && + tpt_native_check(c, o, false) && tpt_native_close(c, &o->handle); +} + +bool tpt_entry(tpt_context *c, size_t directory, const char *name, size_t length) { + cbm_pinned_tree_t *t = c->tree; + for (size_t i = 1; i < t->object_count; i++) { + if (!tpt_poll(c)) + return false; + tpt_object *o = &t->objects[i]; + if (o->parent != directory) + continue; + const char *leaf = tpt_leaf(o); + size_t n = o->length - (size_t)(leaf - o->path); + if (n != length || !tpt_equal(c, leaf, name, n)) + continue; + if (o->seen) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "duplicate directory entry"); + o->seen = true; + return o->directory ? tpt_native_check(c, o, false) : tpt_readback(c, o, NULL); + } + return tpt_fail(c, c->building ? CBM_PINNED_TREE_UNSUPPORTED : CBM_PINNED_TREE_CHANGED, + "unexpected or nonroundtrip directory entry"); +} + +bool tpt_audit(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + if (!tpt_native_check(c, &t->parent, false) || !tpt_native_check(c, &t->objects[0], false)) + return false; + for (size_t i = 0; i < t->object_count; i++) { + if (!tpt_poll(c)) + return false; + t->objects[i].seen = false; + } + t->objects[0].seen = true; + for (size_t i = 0; i < t->view.directory_count; i++) { + if (!tpt_poll(c) || !tpt_chain(c, &t->objects[i]) || + !tpt_native_check(c, &t->objects[i], false) || !tpt_native_enumerate(c, i)) + return false; + } + for (size_t i = 0; i < t->object_count; i++) { + if (!tpt_poll(c)) + return false; + if (!t->objects[i].seen) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "expected entry missing"); + } + return true; +} + +static bool tpt_root_name(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + unsigned char random[16]; + if (!tpt_poll(c) || !cbm_secure_random(random, sizeof(random))) + return tpt_fail(c, CBM_PINNED_TREE_IO, "secure random source failed"); + static const char hex[] = "0123456789abcdef"; + memcpy(t->basename, "cbm-tree-", 9); + for (size_t i = 0; i < sizeof(random); i++) { + t->basename[9 + i * 2] = hex[random[i] >> 4]; + t->basename[10 + i * 2] = hex[random[i] & 15]; + } + t->basename[41] = 0; + size_t n = 0; + if (!tpt_length(c, t->parent_path, TPT_PATH_CAP - 1, &n)) + return false; + size_t separator = n && t->parent_path[n - 1] != '/' ? 1 : 0; +#ifdef _WIN32 + if (n && t->parent_path[n - 1] == '\\') + separator = 0; +#endif + if (n > TPT_PATH_CAP - 42 - separator) + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "owned root path limit"); + memcpy(t->root_path, t->parent_path, n); + if (separator) + t->root_path[n++] = '/'; + memcpy(t->root_path + n, t->basename, 42); + for (size_t i = 1; i < t->object_count; i++) { + if (!tpt_poll(c) || !tpt_join(c, &t->objects[i])) + return false; + } + return true; +} + +bool tpt_build(tpt_context *c, const cbm_git_blob_batch_t *batch) { + cbm_pinned_tree_t *t = c->tree; + bool root = false; + for (unsigned attempt = 0; attempt < 128; attempt++) { + if (!tpt_root_name(c)) + return false; + if (tpt_native_create(c, &t->objects[0])) { + root = true; + break; + } + if (c->error.status != CBM_PINNED_TREE_COLLISION || t->objects[0].created) + return false; + tpt_error_init(&c->error); + } + if (!root) + return tpt_fail(c, CBM_PINNED_TREE_COLLISION, "random child collision budget"); + for (size_t i = 1; i < t->view.directory_count; i++) { + if (!tpt_poll(c) || !tpt_native_create(c, &t->objects[i])) + return false; + } + for (size_t i = 0; i < t->view.file_count; i++) { + tpt_object *o = &t->objects[t->view.directory_count + i]; + if (!tpt_poll(c) || !tpt_native_create(c, o) || + !tpt_native_write(c, o, batch->items[i].bytes) || !tpt_native_finish(c, o) || + !tpt_readback(c, o, &batch->items[i].bytes)) + return false; + } + return true; +} + +bool tpt_absent_disposable(tpt_context *c, const tpt_object *o) { + if (!o->directory) + return true; + cbm_pinned_tree_t *t = c->tree; + size_t index = (size_t)(o - t->objects); + for (size_t i = 0; i < t->object_count; i++) { + if (t->objects[i].created && t->objects[i].parent == index) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "absent ancestor has undisposed children"); + } + return true; +} + +bool tpt_cleanup(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + bool ok = tpt_native_release(c); + bool owned = false; + for (size_t i = t->objects ? t->object_count : 0; i > 0; i--) { + tpt_object *o = &t->objects[i - 1]; + if (!o->created) + continue; + tpt_context step = {.tree = t}; + tpt_error_init(&step.error); + if (!tpt_native_remove(&step, o)) { + if (ok) + c->error = step.error; + ok = false; + } + if (o->created) + owned = true; + } + if (!owned) { + tpt_context parent = {.tree = t}; + tpt_error_init(&parent.error); + if (!tpt_native_close(&parent, &t->parent.handle)) { + if (ok) + c->error = parent.error; + ok = false; + } + } + return ok && !owned; +} diff --git a/src/mcp/test_impact_tree_batch.c b/src/mcp/test_impact_tree_batch.c new file mode 100644 index 000000000..37a2c2f31 --- /dev/null +++ b/src/mcp/test_impact_tree_batch.c @@ -0,0 +1,42 @@ +#include "mcp/test_impact_tree_internal.h" + +static bool tpt_batch_row(tpt_context *c, const cbm_git_blob_batch_item_t *item, size_t index) { + cbm_pinned_tree_t *t = c->tree; + cbm_pinned_tree_file_t *file = &t->files[index]; + const cbm_git_tree_entry_t *e = item->entry; + if (!e || !e->path || item->inventory_index != index || e->object_type != CBM_GIT_TREE_BLOB || + e->mode != file->git_mode || e->path_length != file->path_length || + !tpt_oid(c, e->oid, t->identity.oid_hex_length)) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "batch row identity"); + if (!tpt_equal(c, e->path, file->path, file->path_length) || + !tpt_equal(c, e->oid, file->oid, t->identity.oid_hex_length + 1) || + (item->bytes.length && !item->bytes.data)) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "batch row correspondence"); + if (item->bytes.length > t->limits.max_total_content_bytes - t->view.total_content_bytes) + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "complete content size limit"); + file->content_length = item->bytes.length; + t->view.total_content_bytes += item->bytes.length; + return true; +} + +bool tpt_batch(tpt_context *c, const cbm_pinned_tree_options_t *o, cbm_git_blob_batch_t *out) { + cbm_pinned_tree_t *t = c->tree; + cbm_git_blob_batch_request_t request = {o->revision, t->indices, t->view.file_count}; + if (!tpt_poll(c)) + return false; + cbm_git_facts_error_t error = {0}; + bool ok = cbm_git_facts_read_blob_batch(o->facts, &request, &o->limits.blob_batch, out, &error); + if (!tpt_after_facts(c, ok, &error)) + return false; + if (out->revision != o->revision || out->count != t->view.file_count || + (out->count && !out->items) || (!out->count && out->items) || + out->count > SIZE_MAX / sizeof(*out->items) || + !tpt_oid(c, out->commit, t->identity.oid_hex_length) || + !tpt_equal(c, out->commit, t->view.commit, t->identity.oid_hex_length + 1)) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "batch revision correspondence"); + for (size_t i = 0; i < out->count; i++) { + if (!tpt_poll(c) || !tpt_batch_row(c, &out->items[i], i)) + return false; + } + return true; +} diff --git a/src/mcp/test_impact_tree_digest.c b/src/mcp/test_impact_tree_digest.c new file mode 100644 index 000000000..51f6c7b31 --- /dev/null +++ b/src/mcp/test_impact_tree_digest.c @@ -0,0 +1,47 @@ +#include "mcp/test_impact_tree_internal.h" + +static bool tpt_number(tpt_context *c, cbm_sha256_ctx *h, uint64_t number, size_t width) { + unsigned char bytes[8]; + for (size_t i = 0; i < width; i++) + bytes[width - 1 - i] = (unsigned char)(number >> (i * 8)); + return tpt_hash(c, h, bytes, width); +} + +static bool tpt_span(tpt_context *c, cbm_sha256_ctx *h, const void *p, size_t n) { + return tpt_number(c, h, n, 8) && tpt_hash(c, h, p, n); +} + +static bool tpt_text(tpt_context *c, cbm_sha256_ctx *h, const char *s) { + size_t n = 0; + return tpt_length(c, s, TPT_PATH_CAP - 1, &n) && tpt_span(c, h, s, n); +} + +static bool tpt_manifest_identity(tpt_context *c, cbm_sha256_ctx *h) { + const cbm_git_facts_identity_t *id = &c->tree->identity; + const cbm_pinned_tree_view_t *v = &c->tree->view; + static const char domain[] = "cbm.pinned-tree.v1"; + return tpt_hash(c, h, domain, sizeof(domain)) && + tpt_number(c, h, id->oid_hex_length == 40 ? 1 : 2, 1) && tpt_text(c, h, id->root) && + tpt_text(c, h, id->git_dir) && tpt_text(c, h, id->common_dir) && + tpt_text(c, h, id->head) && tpt_text(c, h, id->base) && tpt_text(c, h, id->merge_base) && + tpt_number(c, h, v->revision == CBM_GIT_REV_HEAD ? 1 : 2, 1) && + tpt_text(c, h, v->commit) && tpt_number(c, h, v->file_count, 8) && + tpt_number(c, h, v->directory_count, 8) && tpt_number(c, h, v->total_content_bytes, 8); +} + +bool tpt_manifest(tpt_context *c) { + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + if (!tpt_manifest_identity(c, &hash)) + return false; + for (size_t i = 0; i < c->tree->view.file_count; i++) { + const cbm_pinned_tree_file_t *f = &c->tree->files[i]; + if (!tpt_poll(c) || !tpt_number(c, &hash, 'F', 1) || + !tpt_span(c, &hash, f->path, f->path_length) || !tpt_number(c, &hash, f->git_mode, 4) || + !tpt_text(c, &hash, f->oid) || !tpt_number(c, &hash, f->content_length, 8) || + !tpt_hash(c, &hash, f->content_sha256, 32)) + return false; + } + cbm_sha256_final(&hash, c->tree->view.manifest_sha256); + return tpt_poll(c); +} diff --git a/src/mcp/test_impact_tree_internal.h b/src/mcp/test_impact_tree_internal.h new file mode 100644 index 000000000..ab32f2f8a --- /dev/null +++ b/src/mcp/test_impact_tree_internal.h @@ -0,0 +1,157 @@ +#ifndef CBM_TEST_IMPACT_TREE_INTERNAL_H +#define CBM_TEST_IMPACT_TREE_INTERNAL_H + +#ifndef _WIN32 +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#ifndef _DARWIN_C_SOURCE +#define _DARWIN_C_SOURCE +#endif +#endif + +#include "mcp/test_impact_tree.h" +#include "foundation/arena.h" +#include "foundation/sha256.h" +#include +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +typedef HANDLE tpt_handle; +#define TPT_INVALID_HANDLE INVALID_HANDLE_VALUE +typedef struct { + ULONGLONG volume; + unsigned char file[16]; +} tpt_identity; +#else +#include +#include +typedef int tpt_handle; +#define TPT_INVALID_HANDLE (-1) +typedef struct { + dev_t device; + ino_t inode; +} tpt_identity; +#endif + +enum { TPT_PATH_CAP = 4096, TPT_CHUNK = 65536, TPT_SECURITY_CAP = 65536 }; +typedef struct { + const char *path; + size_t length, parent, file; + bool directory, created, identified, seen; + tpt_identity identity; + tpt_handle handle; +} tpt_object; + +struct cbm_pinned_tree { + CBMArena arena; + size_t allocated; + cbm_pinned_tree_limits_t limits; + cbm_pinned_tree_view_t view; + cbm_git_facts_identity_t identity; + cbm_pinned_tree_file_t *files; + tpt_object *objects; + size_t object_count; + size_t *indices, *ancestors; + tpt_object parent; + char *parent_path, *supplied_parent, *root_path, *path_scratch, *name_scratch; + unsigned char *io_scratch, *security_scratch, *enumeration_scratch; + char basename[42]; + bool ready, close_uncertain; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + cbm_pinned_tree_read_fault_t read_fault; +#endif +#ifdef _WIN32 + wchar_t *wide_path, *wide_parent, *wide_root; + HANDLE token, enumeration, probe, impersonation; + void *token_user; + SECURITY_DESCRIPTOR descriptor; + ACL *owned_acl; + SECURITY_ATTRIBUTES attributes; +#else + DIR *enumeration; + int enumeration_fd; + void *pending_acl; + uid_t uid; +#endif +}; + +typedef struct { + cbm_pinned_tree_t *tree; + const cbm_pinned_tree_control_t *control; + cbm_pinned_tree_error_t error; + bool building; + bool close_failed; /* passive observation; prefix read must not retry that close */ +} tpt_context; + +typedef struct { + const cbm_git_bytes_t *expected; /* construction readback only */ + unsigned char *prefix; + size_t capacity; + bool bounded; /* exact remaining length plus one-byte EOF probe */ + bool read_fault, eof_fault; /* one call-local simulation, otherwise false */ +} tpt_stream_options; + +bool tpt_fail(tpt_context *c, cbm_pinned_tree_status_t status, const char *message); +void tpt_error_init(cbm_pinned_tree_error_t *error); +bool tpt_poll(tpt_context *c); +bool tpt_after_facts(tpt_context *c, bool ok, const cbm_git_facts_error_t *error); +void *tpt_alloc(tpt_context *c, size_t count, size_t size); +bool tpt_copy(tpt_context *c, void *target, const void *source, size_t size); +bool tpt_equal(tpt_context *c, const void *a, const void *b, size_t size); +bool tpt_length(tpt_context *c, const char *s, size_t max, size_t *out); +char *tpt_string(tpt_context *c, const char *s, size_t size); +bool tpt_oid(tpt_context *c, const char *oid, unsigned width); +int tpt_compare(tpt_context *c, const unsigned char *a, size_t an, const unsigned char *b, + size_t bn); +bool tpt_hash(tpt_context *c, cbm_sha256_ctx *hash, const void *bytes, size_t length); +bool tpt_options_valid(const cbm_pinned_tree_options_t *o); +bool tpt_plan(tpt_context *c, const cbm_pinned_tree_options_t *o, + const cbm_git_tree_inventory_t *inventory); +bool tpt_identity_copy(tpt_context *c, const cbm_pinned_tree_options_t *o); +bool tpt_batch(tpt_context *c, const cbm_pinned_tree_options_t *o, cbm_git_blob_batch_t *out); +bool tpt_manifest(tpt_context *c); +bool tpt_path_valid(tpt_context *c, const unsigned char *path, size_t length); +bool tpt_parent_syntax(tpt_context *c, const char *path); +const char *tpt_leaf(const tpt_object *object); +tpt_object *tpt_parent(cbm_pinned_tree_t *tree, const tpt_object *object); +bool tpt_join(tpt_context *c, const tpt_object *object); +bool tpt_chain(tpt_context *c, const tpt_object *object); +bool tpt_native_prepare(tpt_context *c); +bool tpt_native_create(tpt_context *c, tpt_object *object); +bool tpt_native_check(tpt_context *c, tpt_object *object, bool writing); +bool tpt_native_identity(tpt_context *c, tpt_object *object, bool *absent); +bool tpt_native_write(tpt_context *c, tpt_object *object, cbm_git_bytes_t bytes); +bool tpt_native_read(tpt_context *c, tpt_object *object, size_t *read_count); +bool tpt_native_read_bounded(tpt_context *c, tpt_object *object, size_t request, + size_t *read_count); +bool tpt_native_finish(tpt_context *c, tpt_object *object); +bool tpt_native_close(tpt_context *c, tpt_handle *handle); +bool tpt_native_enumerate(tpt_context *c, size_t directory); +bool tpt_native_remove(tpt_context *c, tpt_object *object); +bool tpt_native_release(tpt_context *c); +bool tpt_native_acl(tpt_context *c, tpt_object *object, unsigned mode, bool changed); +bool tpt_native_id_equal(tpt_identity a, tpt_identity b); +bool tpt_record_identity(tpt_context *c, tpt_object *object, tpt_identity identity); +bool tpt_readback(tpt_context *c, tpt_object *object, const cbm_git_bytes_t *expected); +/* File already opened/checked at offset zero; never closes or publishes copied. */ +bool tpt_read_stream(tpt_context *c, tpt_object *object, const tpt_stream_options *options); +bool tpt_audit(tpt_context *c); +bool tpt_entry(tpt_context *c, size_t directory, const char *name, size_t length); +bool tpt_cleanup(tpt_context *c); +bool tpt_absent_disposable(tpt_context *c, const tpt_object *object); +bool tpt_build(tpt_context *c, const cbm_git_blob_batch_t *batch); +void tpt_dispose_memory(cbm_pinned_tree_t *tree); +#ifdef _WIN32 +bool tpt_wide(tpt_context *c, const char *source, wchar_t *target); +bool tpt_utf8(tpt_context *c, const wchar_t *source, char *target); +bool tpt_win_security(tpt_context *c); +bool tpt_win_effective(tpt_context *c); +bool tpt_win_path(tpt_context *c, const tpt_object *object); +bool tpt_win_error(tpt_context *c, DWORD code, bool creating); +#else +bool tpt_posix_error(tpt_context *c, int code, bool creating); +#endif +#endif diff --git a/src/mcp/test_impact_tree_names.c b/src/mcp/test_impact_tree_names.c new file mode 100644 index 000000000..e8aa35f03 --- /dev/null +++ b/src/mcp/test_impact_tree_names.c @@ -0,0 +1,94 @@ +#include "mcp/test_impact_tree_internal.h" +#include "foundation/path_syntax_internal.h" + +#ifdef _WIN32 +static bool tpt_component(tpt_context *c, const unsigned char *p, size_t n) { + cbm_path_syntax_result_t result = cbm_path_windows_component(p, n); + if (result == CBM_PATH_SYNTAX_DEVICE_OR_TRIMMED) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "Windows device or trimmed component"); + if (result != CBM_PATH_SYNTAX_OK) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "Windows unsupported path component"); + return true; +} + +bool tpt_wide(tpt_context *c, const char *source, wchar_t *target) { + if (!source || !target) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "invalid native UTF-8 path"); + size_t length = 0; + while (length < TPT_PATH_CAP && source[length]) + length++; + /* The shared helper accepts positive lengths. Preserve this private + * conversion's historical empty-string result without broadening it. */ + if (!length) { + target[0] = 0; + if (!c->tree->name_scratch) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "path encoding did not roundtrip"); + c->tree->name_scratch[0] = 0; + return true; + } + cbm_path_syntax_result_t result = cbm_path_windows_roundtrip( + source, length, target, TPT_PATH_CAP, c->tree->name_scratch, TPT_PATH_CAP); + if (result == CBM_PATH_SYNTAX_ROUNDTRIP) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "path encoding did not roundtrip"); + if (result != CBM_PATH_SYNTAX_OK) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "invalid native UTF-8 path"); + return true; +} + +bool tpt_utf8(tpt_context *c, const wchar_t *source, char *target) { + int n = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, source, -1, target, TPT_PATH_CAP, + NULL, NULL); + if (!n) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "native name cannot roundtrip"); + int wide = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, target, n, c->tree->wide_path, + TPT_PATH_CAP); + if (!wide || memcmp(source, c->tree->wide_path, (size_t)wide * sizeof(wchar_t))) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "native name changed encoding"); + return true; +} +#endif + +bool tpt_path_valid(tpt_context *c, const unsigned char *path, size_t length) { + if (!length || !path || path[0] == '/' || path[length - 1] == '/') + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "nonrelative inventory path"); + size_t start = 0; + for (size_t i = 0; i <= length; i++) { + if (i < length && !path[i]) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "NUL in inventory path"); + if (i < length && path[i] != '/') + continue; + size_t n = i - start; + if (!n || (n == 1 && path[start] == '.') || + (n == 2 && path[start] == '.' && path[start + 1] == '.')) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "unsafe inventory component"); +#ifdef _WIN32 + if (!tpt_component(c, path + start, n)) + return false; +#endif + start = i + 1; + } +#ifdef _WIN32 + memcpy(c->tree->path_scratch, path, length); + c->tree->path_scratch[length] = 0; + return tpt_wide(c, c->tree->path_scratch, c->tree->wide_path); +#else + return tpt_poll(c); +#endif +} + +bool tpt_parent_syntax(tpt_context *c, const char *path) { + size_t n = 0; + if (!tpt_length(c, path, TPT_PATH_CAP - 1, &n)) + return false; +#ifdef _WIN32 + cbm_path_syntax_result_t result = cbm_path_windows_absolute(path, n); + if (result == CBM_PATH_SYNTAX_INCOMPLETE_UNC) + return tpt_fail(c, CBM_PINNED_TREE_INVALID, "incomplete UNC parent"); + if (result != CBM_PATH_SYNTAX_OK) + return tpt_fail(c, CBM_PINNED_TREE_INVALID, "parent must be absolute drive or UNC path"); + return tpt_wide(c, path, c->tree->wide_parent); +#else + return n && path[0] == '/' ? true + : tpt_fail(c, CBM_PINNED_TREE_INVALID, "parent must be absolute"); +#endif +} diff --git a/src/mcp/test_impact_tree_plan.c b/src/mcp/test_impact_tree_plan.c new file mode 100644 index 000000000..4fe533e11 --- /dev/null +++ b/src/mcp/test_impact_tree_plan.c @@ -0,0 +1,242 @@ +#include "mcp/test_impact_tree_internal.h" + +bool tpt_options_valid(const cbm_pinned_tree_options_t *o) { + if (!o || !o->facts || !o->private_parent || !o->control.deadline_ms) + return false; + if (o->revision != CBM_GIT_REV_HEAD && o->revision != CBM_GIT_REV_MERGE_BASE) + return false; + const cbm_pinned_tree_limits_t *l = &o->limits; + return l->max_files && l->max_directories && l->max_total_content_bytes && + l->max_relative_path_bytes && l->max_relative_path_bytes < TPT_PATH_CAP && + l->max_arena_bytes && l->blob_batch.max_entries && l->blob_batch.max_input_bytes && + l->blob_batch.max_arena_bytes; +} + +static bool tpt_copy_identity_string(tpt_context *c, const char *source, const char **out) { + size_t n = 0; + if (!tpt_length(c, source, TPT_PATH_CAP - 1, &n)) + return false; + *out = tpt_string(c, source, n); + return *out != NULL; +} + +bool tpt_identity_copy(tpt_context *c, const cbm_pinned_tree_options_t *o) { + if (!tpt_poll(c)) + return false; + const cbm_git_facts_identity_t *id = cbm_git_facts_identity(o->facts); + if (!tpt_poll(c)) + return false; + if (!id) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "missing pinned facts identity"); + cbm_pinned_tree_t *t = c->tree; + unsigned width = id->oid_hex_length; + if (!tpt_oid(c, id->head, width) || !tpt_oid(c, id->base, width) || + !tpt_oid(c, id->merge_base, width)) + return false; + t->identity.oid_hex_length = width; + memcpy(t->identity.head, id->head, width + 1); + memcpy(t->identity.base, id->base, width + 1); + memcpy(t->identity.merge_base, id->merge_base, width + 1); + if (!tpt_copy_identity_string(c, id->root, &t->identity.root) || + !tpt_copy_identity_string(c, id->git_dir, &t->identity.git_dir) || + !tpt_copy_identity_string(c, id->common_dir, &t->identity.common_dir)) + return false; + t->view.identity = &t->identity; + t->view.revision = o->revision; + t->view.commit = o->revision == CBM_GIT_REV_HEAD ? t->identity.head : t->identity.merge_base; + return true; +} + +static bool tpt_inventory_row(tpt_context *c, const cbm_git_tree_entry_t *entry, + const cbm_git_tree_entry_t *previous) { + if (!entry->path) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "missing inventory path"); + if (entry->path_length > c->tree->limits.max_relative_path_bytes) + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "inventory path bound"); + if (entry->object_type != CBM_GIT_TREE_BLOB || + (entry->mode != 0100644 && entry->mode != 0100755)) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "nonregular inventory entry"); + if (!tpt_oid(c, entry->oid, c->tree->identity.oid_hex_length) || + !tpt_path_valid(c, (const unsigned char *)entry->path, entry->path_length)) + return false; + if (!previous) + return true; + if (tpt_compare(c, (const unsigned char *)previous->path, previous->path_length, + (const unsigned char *)entry->path, entry->path_length) >= 0) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "inventory ordering or duplicate"); + if (previous->path_length < entry->path_length && entry->path[previous->path_length] == '/' && + tpt_equal(c, previous->path, entry->path, previous->path_length)) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "file and directory conflict"); + return tpt_poll(c); +} + +static bool tpt_prefix_new(tpt_context *c, const cbm_git_tree_entry_t *entry, + const cbm_git_tree_entry_t *previous, size_t length) { + return !previous || previous->path_length <= length || + !tpt_equal(c, entry->path, previous->path, length + 1); +} + +static bool tpt_file_prefix(tpt_context *c, const cbm_git_tree_inventory_t *inventory, + const char *path, size_t length) { + size_t lo = 0, hi = inventory->count; + while (lo < hi) { + if (!tpt_poll(c)) + return false; + size_t mid = lo + (hi - lo) / 2; + const cbm_git_tree_entry_t *entry = &inventory->entries[mid]; + int order = tpt_compare(c, (const unsigned char *)entry->path, entry->path_length, + (const unsigned char *)path, length); + if (!order) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "file and directory conflict"); + if (order < 0) + lo = mid + 1; + else + hi = mid; + } + return tpt_poll(c); +} + +static bool tpt_counts(tpt_context *c, const cbm_git_tree_inventory_t *inventory, + size_t *directories) { + const cbm_git_tree_entry_t *previous = NULL; + *directories = 1; + for (size_t i = 0; i < inventory->count; i++) { + if (!tpt_poll(c) || !tpt_inventory_row(c, &inventory->entries[i], previous)) + return false; + previous = &inventory->entries[i]; + } + previous = NULL; + for (size_t i = 0; i < inventory->count; i++) { + const cbm_git_tree_entry_t *entry = &inventory->entries[i]; + if (!tpt_poll(c)) + return false; + for (size_t j = 0; j < entry->path_length; j++) { + if (entry->path[j] != '/' || !tpt_prefix_new(c, entry, previous, j)) + continue; + if (!tpt_file_prefix(c, inventory, entry->path, j)) + return false; + if (*directories >= c->tree->limits.max_directories) + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "directory count limit"); + (*directories)++; + } + previous = entry; + } + return tpt_poll(c); +} + +static size_t tpt_find_directory(tpt_context *c, const char *path, size_t n, size_t used) { + if (!n) + return 0; + for (size_t i = 1; i < used; i++) { + if (!tpt_poll(c)) + return SIZE_MAX; + tpt_object *d = &c->tree->objects[i]; + if (d->length == n && tpt_equal(c, d->path, path, n)) + return i; + } + return SIZE_MAX; +} + +static bool tpt_plan_row(tpt_context *c, const cbm_git_tree_entry_t *entry, size_t index, + size_t *used) { + cbm_pinned_tree_t *t = c->tree; + char *path = tpt_string(c, entry->path, entry->path_length); + if (!path) + return false; + size_t parent = 0; + for (size_t j = 0; j < entry->path_length; j++) { + if (path[j] != '/') + continue; + size_t found = tpt_find_directory(c, path, j, *used); + if (found == SIZE_MAX) { + if (!tpt_poll(c) || *used >= t->view.directory_count) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "directory plan inconsistency"); + found = (*used)++; + tpt_object *d = &t->objects[found]; + d->path = tpt_string(c, path, j); + d->length = j; + d->parent = parent; + d->directory = true; + if (!d->path) + return false; + } + parent = found; + } + cbm_pinned_tree_file_t *file = &t->files[index]; + file->path = (const unsigned char *)path; + file->path_length = entry->path_length; + file->git_mode = entry->mode; + memcpy(file->oid, entry->oid, t->identity.oid_hex_length + 1); + tpt_object *object = &t->objects[t->view.directory_count + index]; + object->path = path; + object->length = entry->path_length; + object->parent = parent; + object->file = index; + t->indices[index] = index; + return true; +} + +static bool tpt_scratch(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + t->root_path = tpt_alloc(c, TPT_PATH_CAP, 1); + t->path_scratch = tpt_alloc(c, TPT_PATH_CAP, 1); + t->name_scratch = tpt_alloc(c, TPT_PATH_CAP, 1); + t->parent_path = tpt_alloc(c, TPT_PATH_CAP, 1); + t->io_scratch = tpt_alloc(c, TPT_CHUNK, 1); + t->security_scratch = tpt_alloc(c, TPT_SECURITY_CAP, 1); +#ifdef _WIN32 + t->enumeration_scratch = tpt_alloc(c, TPT_CHUNK, 1); + t->wide_path = tpt_alloc(c, TPT_PATH_CAP + 8, sizeof(wchar_t)); + t->wide_parent = tpt_alloc(c, TPT_PATH_CAP + 8, sizeof(wchar_t)); + t->wide_root = tpt_alloc(c, TPT_PATH_CAP + 8, sizeof(wchar_t)); +#endif + return tpt_poll(c); +} + +bool tpt_plan(tpt_context *c, const cbm_pinned_tree_options_t *o, + const cbm_git_tree_inventory_t *inventory) { + cbm_pinned_tree_t *t = c->tree; + if (inventory->count > t->limits.max_files || + inventory->count > SIZE_MAX / sizeof(*inventory->entries)) + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "inventory count limit"); + if ((inventory->count && !inventory->entries) || (!inventory->count && inventory->entries)) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "missing complete inventory"); + if (!tpt_scratch(c) || !tpt_parent_syntax(c, o->private_parent)) + return false; + size_t parent_length = 0, directories = 0; + if (!tpt_length(c, o->private_parent, TPT_PATH_CAP - 1, &parent_length) || + !tpt_copy(c, t->parent_path, o->private_parent, parent_length + 1) || + !tpt_counts(c, inventory, &directories)) + return false; + t->supplied_parent = tpt_string(c, o->private_parent, parent_length); + if (!t->supplied_parent) + return false; + if (directories > SIZE_MAX - inventory->count) + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "object count overflow"); + t->object_count = directories + inventory->count; + t->objects = tpt_alloc(c, t->object_count, sizeof(*t->objects)); + t->files = tpt_alloc(c, inventory->count, sizeof(*t->files)); + t->indices = tpt_alloc(c, inventory->count, sizeof(*t->indices)); + t->ancestors = tpt_alloc(c, directories, sizeof(*t->ancestors)); + if (!tpt_poll(c)) + return false; + t->view.file_count = inventory->count; + t->view.directory_count = directories; + t->view.files = t->files; + t->view.root = t->root_path; + for (size_t i = 0; i < t->object_count; i++) { + if (!tpt_poll(c)) + return false; + t->objects[i].handle = TPT_INVALID_HANDLE; + } + t->objects[0].path = t->basename; + t->objects[0].length = 41; + t->objects[0].parent = SIZE_MAX; + t->objects[0].directory = true; + size_t used = 1; + for (size_t i = 0; i < inventory->count; i++) { + if (!tpt_poll(c) || !tpt_plan_row(c, &inventory->entries[i], i, &used)) + return false; + } + return used == directories || tpt_fail(c, CBM_PINNED_TREE_GIT, "directory plan count"); +} diff --git a/src/mcp/test_impact_tree_posix.c b/src/mcp/test_impact_tree_posix.c new file mode 100644 index 000000000..bf7447c34 --- /dev/null +++ b/src/mcp/test_impact_tree_posix.c @@ -0,0 +1,203 @@ +#include "mcp/test_impact_tree_internal.h" +#ifndef _WIN32 +#include +#include +#include +#include +#include + +bool tpt_posix_error(tpt_context *c, int e, bool creating) { + if (creating && e == EEXIST) + return tpt_fail(c, CBM_PINNED_TREE_COLLISION, "exclusive native name already exists"); + if (e == ENOMEM) + return tpt_fail(c, CBM_PINNED_TREE_OOM, "native allocation failed"); + if (e == ENAMETOOLONG || e == EILSEQ || e == ENOSYS || e == ENOTSUP || e == EINVAL) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "unsupported native representation"); + return tpt_fail(c, CBM_PINNED_TREE_IO, "native filesystem operation failed"); +} + +bool tpt_native_id_equal(tpt_identity a, tpt_identity b) { + return a.device == b.device && a.inode == b.inode; +} + +static tpt_identity tpt_stat_id(const struct stat *st) { + return (tpt_identity){st->st_dev, st->st_ino}; +} + +static bool tpt_kind(const struct stat *st, bool directory) { + return directory ? S_ISDIR(st->st_mode) : S_ISREG(st->st_mode) && st->st_nlink == 1; +} + +static int tpt_stat_path(cbm_pinned_tree_t *t, tpt_object *o, struct stat *st) { + if (o == &t->parent) + return lstat(t->parent_path, st); + return fstatat(tpt_parent(t, o)->handle, tpt_leaf(o), st, AT_SYMLINK_NOFOLLOW); +} + +static bool tpt_open_object(tpt_context *c, tpt_object *o) { + if (o->handle != TPT_INVALID_HANDLE) + return true; + int flags = O_RDONLY | O_NOFOLLOW | O_CLOEXEC; + if (o->directory) + flags |= O_DIRECTORY; + o->handle = openat(tpt_parent(c->tree, o)->handle, tpt_leaf(o), flags); + return o->handle >= 0 || tpt_posix_error(c, errno, false); +} + +bool tpt_native_identity(tpt_context *c, tpt_object *o, bool *absent) { + *absent = false; + struct stat st; + if (tpt_stat_path(c->tree, o, &st) != 0) { + if (errno == ENOENT) { + *absent = true; + return true; + } + return tpt_posix_error(c, errno, false); + } + if (!o->identified || !tpt_kind(&st, o->directory) || + !tpt_native_id_equal(tpt_stat_id(&st), o->identity)) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "tracked native identity replaced"); + if (o->directory && o != &c->tree->parent && !tpt_open_object(c, o)) + return false; + if (o->handle != TPT_INVALID_HANDLE) { + if (fstat(o->handle, &st) != 0) + return tpt_posix_error(c, errno, false); + if (!tpt_kind(&st, o->directory) || !tpt_native_id_equal(tpt_stat_id(&st), o->identity)) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "tracked native handle changed"); + } + return true; +} + +static unsigned tpt_mode(const cbm_pinned_tree_t *t, const tpt_object *o, bool writing) { + if (o->directory) + return 0700; + if (writing) + return 0600; + return t->files[o->file].git_mode == 0100755 ? 0500 : 0400; +} + +static bool tpt_posix_policy(tpt_context *c, tpt_object *o, const struct stat *st, bool writing) { + unsigned mode = tpt_mode(c->tree, o, writing); + if (!tpt_native_id_equal(tpt_stat_id(st), o->identity)) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "opened object identity mismatch"); + if (st->st_uid != c->tree->uid || (st->st_mode & 07777) != mode || !tpt_kind(st, o->directory)) + return tpt_fail(c, writing ? CBM_PINNED_TREE_UNSUPPORTED : CBM_PINNED_TREE_CHANGED, + "native owner, mode or link policy mismatch"); + if (!o->directory && !writing && + (st->st_size < 0 || (uint64_t)st->st_size != c->tree->files[o->file].content_length)) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "native file length changed"); + return true; +} + +bool tpt_native_check(tpt_context *c, tpt_object *o, bool writing) { + if (geteuid() != c->tree->uid) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "effective user changed"); + bool absent = false; + if (!tpt_native_identity(c, o, &absent)) + return false; + if (absent) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "expected native object missing"); + struct stat st; + if (tpt_stat_path(c->tree, o, &st) != 0) + return tpt_posix_error(c, errno, false); + if (!tpt_posix_policy(c, o, &st, writing)) + return false; + if (o != &c->tree->parent && !tpt_open_object(c, o)) + return false; + if (fstat(o->handle, &st) != 0) + return tpt_posix_error(c, errno, false); + return tpt_posix_policy(c, o, &st, writing) && + tpt_native_acl(c, o, tpt_mode(c->tree, o, writing), !writing); +} + +bool tpt_native_prepare(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + size_t parent_length = 0; + if (!tpt_length(c, t->parent_path, TPT_PATH_CAP - 1, &parent_length)) + return false; + while (parent_length > 1 && t->parent_path[parent_length - 1] == '/') + t->parent_path[--parent_length] = 0; + struct stat before; + if (lstat(t->parent_path, &before) != 0) + return tpt_posix_error(c, errno, false); + if (!S_ISDIR(before.st_mode) || before.st_uid != geteuid() || (before.st_mode & 07777) != 0700) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "parent must be a private owned directory"); + t->parent.handle = open(t->parent_path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (t->parent.handle < 0) + return tpt_posix_error(c, errno, false); + t->uid = geteuid(); + struct stat st; + if (fstat(t->parent.handle, &st) != 0) + return tpt_posix_error(c, errno, false); + t->parent.identity = tpt_stat_id(&st); + t->parent.identified = true; + if (!tpt_native_id_equal(tpt_stat_id(&before), t->parent.identity)) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "parent changed while opening"); + if (!realpath(t->parent_path, t->path_scratch)) + return tpt_posix_error(c, errno, false); + size_t n = 0; + if (!tpt_length(c, t->path_scratch, TPT_PATH_CAP - 1, &n)) + return false; + memcpy(t->parent_path, t->path_scratch, n + 1); + t->parent.path = t->parent_path; + t->parent.length = n; + return tpt_native_check(c, &t->parent, true); +} + +bool tpt_native_create(tpt_context *c, tpt_object *o) { + if (!tpt_poll(c) || !tpt_chain(c, o)) + return false; + int parent = tpt_parent(c->tree, o)->handle; + const char *name = tpt_leaf(o); + if (o->directory) { + if (mkdirat(parent, name, 0700) != 0) + return tpt_posix_error(c, errno, true); + o->created = true; + } else { + o->handle = openat(parent, name, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (o->handle < 0) + return tpt_posix_error(c, errno, true); + o->created = true; + } + struct stat st; + if (fstatat(parent, name, &st, AT_SYMLINK_NOFOLLOW) != 0) + return tpt_posix_error(c, errno, false); + if (!tpt_kind(&st, o->directory)) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "created object type changed"); + if (!tpt_record_identity(c, o, tpt_stat_id(&st)) || !tpt_open_object(c, o)) + return false; + if (fchmod(o->handle, o->directory ? 0700 : 0600) != 0) + return tpt_posix_error(c, errno, false); + return tpt_native_check(c, o, true); +} + +bool tpt_native_close(tpt_context *c, tpt_handle *handle) { + if (*handle == TPT_INVALID_HANDLE) + return true; + int fd = *handle; + *handle = TPT_INVALID_HANDLE; + if (close(fd) == 0) + return true; + /* Never retry an ambiguously closed POSIX fd: another thread may reuse it. */ + c->tree->close_uncertain = true; + c->close_failed = true; + return tpt_fail(c, CBM_PINNED_TREE_IO, "native close could not be established"); +} + +bool tpt_native_remove(tpt_context *c, tpt_object *o) { + if (!tpt_chain(c, o)) + return false; + bool absent = false; + if (!tpt_native_identity(c, o, &absent)) + return false; + if (absent && !tpt_absent_disposable(c, o)) + return false; + if (!tpt_native_close(c, &o->handle)) + return false; + if (!absent && + unlinkat(tpt_parent(c->tree, o)->handle, tpt_leaf(o), o->directory ? AT_REMOVEDIR : 0) != 0) + return tpt_posix_error(c, errno, false); + o->created = false; + return true; +} +#endif diff --git a/src/mcp/test_impact_tree_posix_acl.c b/src/mcp/test_impact_tree_posix_acl.c new file mode 100644 index 000000000..85ce933c4 --- /dev/null +++ b/src/mcp/test_impact_tree_posix_acl.c @@ -0,0 +1,124 @@ +#include "mcp/test_impact_tree_internal.h" +#ifndef _WIN32 +#include +#if defined(__APPLE__) +#include + +bool tpt_native_acl(tpt_context *c, tpt_object *o, unsigned mode, bool changed) { + (void)mode; + acl_t acl = acl_get_fd_np(o->handle, ACL_TYPE_EXTENDED); + if (!acl) { + int error = errno; + /* macOS reports an absent ACL as ENOENT on a valid descriptor. The sole + * caller verifies the named object and fstat identity/policy of this held + * descriptor immediately before this query; no pathname lookup occurs here. */ + if (error == ENOENT) + return true; + return tpt_fail(c, error == ENOMEM ? CBM_PINNED_TREE_OOM : CBM_PINNED_TREE_UNSUPPORTED, + "required extended ACL inspection failed"); + } + c->tree->pending_acl = acl; + bool valid = acl_valid(acl) == 0; + int validation_error = errno; + if (!valid) + tpt_fail(c, validation_error == ENOMEM ? CBM_PINNED_TREE_OOM : CBM_PINNED_TREE_UNSUPPORTED, + "required extended ACL validation failed"); + acl_entry_t entry; + errno = 0; + int result = valid ? acl_get_entry(acl, ACL_FIRST_ENTRY, &entry) : -1; + int terminal = errno; + bool empty = valid && result == -1 && terminal == EINVAL; + if (acl_free(acl) != 0) + return tpt_fail(c, CBM_PINNED_TREE_IO, "native ACL release failed"); + c->tree->pending_acl = NULL; + if (!valid) + return false; + if (result == 0) + return tpt_fail(c, changed ? CBM_PINNED_TREE_CHANGED : CBM_PINNED_TREE_UNSUPPORTED, + "extended ACL entry rejected"); + return empty || tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "ACL absence not established"); +} +#elif defined(__linux__) +#include + +static uint32_t tpt_little(const unsigned char *p, size_t n) { + uint32_t value = 0; + for (size_t i = 0; i < n; i++) + value |= (uint32_t)p[i] << (8 * i); + return value; +} + +static int tpt_linux_access(tpt_context *c, const unsigned char *p, size_t n, unsigned mode, + bool is_default) { + /* Linux UAPI posix_acl_xattr: LE32 version 2; LE16 tag/perm + LE32 id. */ + if (n < 4 || (n - 4) % 8 || tpt_little(p, 4) != 2) + return -1; + if (is_default) + return n == 4 ? 1 : 0; + unsigned seen = 0; + bool extended = false; + for (size_t i = 4; i < n; i += 8) { + if (!tpt_poll(c)) + return -1; + uint32_t tag = tpt_little(p + i, 2), perm = tpt_little(p + i + 2, 2); + uint32_t id = tpt_little(p + i + 4, 4); + if (perm > 7) + return -1; + if (tag == 2 || tag == 8 || tag == 16) { + extended = true; + continue; + } + unsigned bit, expected; + if (tag == 1) { + bit = 1; + expected = (mode >> 6) & 7; + } else if (tag == 4) { + bit = 2; + expected = (mode >> 3) & 7; + } else if (tag == 32) { + bit = 4; + expected = mode & 7; + } else { + return -1; + } + if (id != UINT32_MAX) + return -1; + if ((seen & bit) || perm != expected) + extended = true; + seen |= bit; + } + return !extended && seen == 7 && n == 28 ? 1 : 0; +} + +static bool tpt_linux_acl(tpt_context *c, tpt_object *o, unsigned mode, bool is_default, + bool changed) { + const char *name = is_default ? "system.posix_acl_default" : "system.posix_acl_access"; + ssize_t n = fgetxattr(o->handle, name, c->tree->security_scratch, TPT_SECURITY_CAP); + if (n < 0) { + if (errno == ENODATA) + return true; + return tpt_fail(c, errno == ENOMEM ? CBM_PINNED_TREE_OOM : CBM_PINNED_TREE_UNSUPPORTED, + "required POSIX ACL xattr inspection failed"); + } + const unsigned char *p = c->tree->security_scratch; + int allowed = tpt_linux_access(c, p, (size_t)n, mode, is_default); + if (allowed < 0) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "undecodable POSIX ACL representation"); + return allowed == 1 || + tpt_fail(c, changed ? CBM_PINNED_TREE_CHANGED : CBM_PINNED_TREE_UNSUPPORTED, + "extended or inherited POSIX ACL rejected"); +} + +bool tpt_native_acl(tpt_context *c, tpt_object *o, unsigned mode, bool changed) { + return tpt_linux_acl(c, o, mode, false, changed) && + (!o->directory || tpt_linux_acl(c, o, mode, true, changed)); +} +#else +bool tpt_native_acl(tpt_context *c, tpt_object *o, unsigned mode, bool changed) { + (void)o; + (void)mode; + (void)changed; + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "required native ACL interface unavailable"); +} +#endif +#endif diff --git a/src/mcp/test_impact_tree_posix_io.c b/src/mcp/test_impact_tree_posix_io.c new file mode 100644 index 000000000..594fd2229 --- /dev/null +++ b/src/mcp/test_impact_tree_posix_io.c @@ -0,0 +1,132 @@ +#include "mcp/test_impact_tree_internal.h" +#ifndef _WIN32 +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#endif + +bool tpt_native_write(tpt_context *c, tpt_object *o, cbm_git_bytes_t bytes) { + size_t offset = 0; + while (offset < bytes.length) { + if (!tpt_poll(c)) + return false; + size_t n = bytes.length - offset; + if (n > TPT_CHUNK) + n = TPT_CHUNK; + ssize_t written = write(o->handle, bytes.data + offset, n); + if (written < 0 && errno == EINTR) + continue; + if (written <= 0) + return tpt_fail(c, CBM_PINNED_TREE_IO, "native payload write failed"); + offset += (size_t)written; + } + return true; +} + +bool tpt_native_read_bounded(tpt_context *c, tpt_object *o, size_t request, size_t *count) { + *count = 0; + if (!request || request > TPT_CHUNK) + return tpt_fail(c, CBM_PINNED_TREE_INVALID, "invalid native read bound"); + for (;;) { + if (!tpt_poll(c)) + return false; + ssize_t n = read(o->handle, c->tree->io_scratch, request); + if (n < 0 && errno == EINTR) + continue; + if (n < 0) + return tpt_posix_error(c, errno, false); + *count = (size_t)n; + return true; + } +} + +bool tpt_native_read(tpt_context *c, tpt_object *o, size_t *count) { + return tpt_native_read_bounded(c, o, TPT_CHUNK, count); +} + +bool tpt_native_finish(tpt_context *c, tpt_object *o) { + mode_t mode = c->tree->files[o->file].git_mode == 0100755 ? 0500 : 0400; + if (fchmod(o->handle, mode) != 0) + return tpt_posix_error(c, errno, false); + return tpt_native_check(c, o, false) && tpt_native_close(c, &o->handle); +} + +static bool tpt_directory_close(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + if (!t->enumeration) + return true; + DIR *dir = t->enumeration; + t->enumeration = NULL; + if (closedir(dir) == 0) + return true; + t->close_uncertain = true; + return tpt_fail(c, CBM_PINNED_TREE_IO, "directory close failed"); +} + +bool tpt_native_enumerate(tpt_context *c, size_t directory) { + cbm_pinned_tree_t *t = c->tree; + t->enumeration_fd = + openat(t->objects[directory].handle, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (t->enumeration_fd < 0) + return tpt_posix_error(c, errno, false); + t->enumeration = fdopendir(t->enumeration_fd); + if (!t->enumeration) + return tpt_posix_error(c, errno, false); + t->enumeration_fd = -1; + for (;;) { + if (!tpt_poll(c)) + return false; + errno = 0; + struct dirent *entry = readdir(t->enumeration); + if (!entry) { + if (errno) + return tpt_posix_error(c, errno, false); + return tpt_directory_close(c); + } + size_t n = 0; + if (!tpt_length(c, entry->d_name, TPT_PATH_CAP - 1, &n)) + return false; + if ((n == 1 && entry->d_name[0] == '.') || + (n == 2 && entry->d_name[0] == '.' && entry->d_name[1] == '.')) + continue; + if (!tpt_entry(c, directory, entry->d_name, n)) + return false; + } +} + +bool tpt_native_release(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + bool ok = tpt_directory_close(c); + if (!tpt_native_close(c, &t->enumeration_fd)) + ok = false; +#ifdef __APPLE__ + if (t->pending_acl) { + if (acl_free(t->pending_acl) != 0) { + tpt_fail(c, CBM_PINNED_TREE_IO, "pending ACL release failed"); + ok = false; + } else { + t->pending_acl = NULL; + } + } +#endif + bool owned = false; + for (size_t i = 0; t->objects && i < t->object_count; i++) { + tpt_object *o = &t->objects[i]; + if (!o->created) + continue; + owned = true; + if (!o->directory && !tpt_native_close(c, &o->handle)) + ok = false; + } + if (!owned && !tpt_native_close(c, &t->parent.handle)) + ok = false; + if (t->close_uncertain) { + tpt_fail(c, CBM_PINNED_TREE_IO, "a native close remains indeterminate"); + ok = false; + } + return ok; +} +#endif diff --git a/src/mcp/test_impact_tree_read.c b/src/mcp/test_impact_tree_read.c new file mode 100644 index 000000000..5d43f0e5f --- /dev/null +++ b/src/mcp/test_impact_tree_read.c @@ -0,0 +1,132 @@ +#include "mcp/test_impact_tree_internal.h" + +static bool tpt_read_valid(tpt_context *c, size_t index, uint64_t limit, + const unsigned char *prefix, size_t capacity, const size_t *copied) { + cbm_pinned_tree_t *t = c->tree; + if (!t || !t->ready || !copied || !c->control || !c->control->deadline_ms || !limit || + index >= t->view.file_count || (capacity && !prefix)) + return tpt_fail(c, CBM_PINNED_TREE_INVALID, "invalid prefix read arguments"); + if (t->files[index].content_length > limit) + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "prefix full-content bound"); + return true; +} + +/* Unlike tpt_chain, this checks full policy on every ancestor, not just identity. */ +static bool tpt_read_policy(tpt_context *c, tpt_object *o) { + cbm_pinned_tree_t *t = c->tree; + size_t count = 0, parent = o->parent; + while (parent != SIZE_MAX) { + if (!tpt_poll(c)) + return false; + if (parent >= t->view.directory_count || count >= t->view.directory_count) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "invalid prefix ancestor plan"); + t->ancestors[count++] = parent; + parent = t->objects[parent].parent; + } + if (!tpt_poll(c) || !tpt_native_check(c, &t->parent, false)) + return false; + while (count) { + tpt_object *ancestor = &t->objects[t->ancestors[--count]]; + if (!tpt_poll(c) || !tpt_native_check(c, ancestor, false)) + return false; + } + return tpt_poll(c) && tpt_native_check(c, o, false); +} + +static bool tpt_read_cleanup_error(tpt_context *c, const tpt_context *cleanup) { + if (c->error.status == CBM_PINNED_TREE_OK) + c->error = cleanup->error; + if (c->error.status == CBM_PINNED_TREE_OK) + tpt_fail(c, CBM_PINNED_TREE_IO, "prefix resource release incomplete"); + c->error.status = CBM_PINNED_TREE_CLEANUP_REQUIRED; + memcpy(c->error.cleanup_path, c->tree->root_path, TPT_PATH_CAP); + return false; +} + +static bool tpt_read_release_already_failed(const tpt_context *c) { + if (c->close_failed) + return true; +#ifdef __APPLE__ + /* A retained ACL here means native checking already tried and failed to + * free it. Leave it for a later explicit close, not an immediate retry. */ + if (c->tree->pending_acl) + return true; +#endif + return false; +} + +/* Selected-file release happens once even when an auxiliary close failed. + * Cleanup ignores cancellation and never unlinks this owner's namespace. */ +static bool tpt_read_finish(tpt_context *c, tpt_object *o, bool close_fault) { + tpt_context cleanup = {.tree = c->tree}; + tpt_error_init(&cleanup.error); + bool closed; + if (close_fault && o->handle != TPT_INVALID_HANDLE) + closed = tpt_fail(&cleanup, CBM_PINNED_TREE_IO, "simulated prefix close failure"); + else + closed = tpt_native_close(&cleanup, &o->handle); + if (!closed || tpt_read_release_already_failed(c)) + return tpt_read_cleanup_error(c, &cleanup); + if (c->error.status == CBM_PINNED_TREE_OK && tpt_poll(c)) + return true; + if (!tpt_native_release(&cleanup)) + return tpt_read_cleanup_error(c, &cleanup); + return false; +} + +static bool tpt_read_take_fault(cbm_pinned_tree_t *t, tpt_stream_options *stream) { +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + cbm_pinned_tree_read_fault_t fault = t->read_fault; + t->read_fault = CBM_PINNED_TREE_READ_FAULT_NONE; + stream->read_fault = fault == CBM_PINNED_TREE_READ_FAULT_READ; + stream->eof_fault = fault == CBM_PINNED_TREE_READ_FAULT_EOF; + return fault == CBM_PINNED_TREE_READ_FAULT_CLOSE; +#else + (void)t; + (void)stream; + return false; +#endif +} + +cbm_pinned_tree_status_t cbm_pinned_tree_read_prefix(cbm_pinned_tree_t *t, size_t file_index, + uint64_t max_content_bytes, + unsigned char *prefix, size_t capacity, + size_t *copied, + const cbm_pinned_tree_control_t *control, + cbm_pinned_tree_error_t *error) { + if (copied) + *copied = 0; + tpt_context c = {.tree = t, .control = control}; + tpt_error_init(&c.error); + if (error) + *error = c.error; + if (tpt_read_valid(&c, file_index, max_content_bytes, prefix, capacity, copied)) { + t->ready = false; + tpt_stream_options stream = {.prefix = prefix, .capacity = capacity, .bounded = true}; + bool close_fault = tpt_read_take_fault(t, &stream); + tpt_object *o = &t->objects[t->view.directory_count + file_index]; + if (tpt_poll(&c)) { + if (o->handle != TPT_INVALID_HANDLE) + tpt_fail(&c, CBM_PINNED_TREE_CHANGED, "prefix file handle was not closed"); + else if (tpt_read_policy(&c, o) && tpt_read_stream(&c, o, &stream)) + (void)tpt_read_policy(&c, o); + } + if (tpt_read_finish(&c, o, close_fault)) { + uint64_t length = t->files[file_index].content_length; + t->ready = true; + *copied = length < capacity ? (size_t)length : capacity; + } + } + if (error) + *error = c.error; + return c.error.status; +} + +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +bool cbm_pinned_tree_test_set_read_fault(cbm_pinned_tree_t *t, cbm_pinned_tree_read_fault_t fault) { + if (!t || !t->ready || (unsigned)fault > CBM_PINNED_TREE_READ_FAULT_CLOSE) + return false; + t->read_fault = fault; + return true; +} +#endif diff --git a/src/mcp/test_impact_tree_util.c b/src/mcp/test_impact_tree_util.c new file mode 100644 index 000000000..68a8c544e --- /dev/null +++ b/src/mcp/test_impact_tree_util.c @@ -0,0 +1,195 @@ +#include "mcp/test_impact_tree_internal.h" +#include "foundation/platform.h" + +void tpt_error_init(cbm_pinned_tree_error_t *e) { + memset(e, 0, sizeof(*e)); + e->git.exit_code = -1; +} + +bool tpt_fail(tpt_context *c, cbm_pinned_tree_status_t status, const char *message) { + if (c->error.status == CBM_PINNED_TREE_OK) { + c->error.status = status; + c->error.cause = status; + size_t i = 0; + while (message[i] && i + 1 < sizeof(c->error.diagnostic)) { + c->error.diagnostic[i] = message[i]; + i++; + } + c->error.diagnostic[i] = 0; + } + return false; +} + +bool tpt_poll(tpt_context *c) { + if (c->error.status != CBM_PINNED_TREE_OK) + return false; + if (!c->control) + return true; + if (c->control->cancelled && c->control->cancelled(c->control->cancel_context)) + return tpt_fail(c, CBM_PINNED_TREE_CANCELLED, "materializer cancelled"); + if (cbm_now_ms() >= c->control->deadline_ms) + return tpt_fail(c, CBM_PINNED_TREE_DEADLINE, "materializer deadline"); + return true; +} + +bool tpt_after_facts(tpt_context *c, bool ok, const cbm_git_facts_error_t *error) { + if (!ok) { + c->error.git = *error; + tpt_fail(c, CBM_PINNED_TREE_GIT, "pinned facts operation failed"); + } + tpt_context guard = {.tree = c->tree, .control = c->control}; + tpt_error_init(&guard.error); + bool live = tpt_poll(&guard); + if (ok && !live) + c->error = guard.error; + return ok && live; +} + +void *tpt_alloc(tpt_context *c, size_t count, size_t size) { + cbm_pinned_tree_t *t = c->tree; + if (!tpt_poll(c)) + return NULL; + if (!count || !size) + return NULL; + if (count > SIZE_MAX / size) { + tpt_fail(c, CBM_PINNED_TREE_LIMIT, "allocation multiplication"); + return NULL; + } + size_t n = count * size; + if (n > t->limits.max_arena_bytes - t->allocated || n > SIZE_MAX - 7 || + t->arena.used > SIZE_MAX - ((n + 7) & ~(size_t)7) || + t->arena.total_alloc > SIZE_MAX - ((n + 7) & ~(size_t)7)) { + tpt_fail(c, CBM_PINNED_TREE_LIMIT, "materializer arena limit"); + return NULL; + } + size_t aligned = (n + 7) & ~(size_t)7; + if (!t->arena.nblocks || aligned > t->arena.block_size - t->arena.used) { + size_t block = aligned > t->arena.grow_size ? aligned : t->arena.grow_size; + if (block > SIZE_MAX / 2) { + tpt_fail(c, CBM_PINNED_TREE_LIMIT, "arena growth arithmetic bound"); + return NULL; + } + } + t->allocated += n; + void *memory = cbm_arena_alloc(&t->arena, n); + if (!memory) { + tpt_fail(c, CBM_PINNED_TREE_OOM, "materializer allocation"); + return NULL; + } + unsigned char *p = memory; + while (n) { + size_t chunk = n > TPT_CHUNK ? TPT_CHUNK : n; + if (!tpt_poll(c)) + return NULL; + memset(p, 0, chunk); + p += chunk; + n -= chunk; + } + return memory; +} + +bool tpt_copy(tpt_context *c, void *target, const void *source, size_t n) { + unsigned char *to = target; + const unsigned char *from = source; + while (n) { + size_t chunk = n > TPT_CHUNK ? TPT_CHUNK : n; + if (!tpt_poll(c)) + return false; + memcpy(to, from, chunk); + to += chunk; + from += chunk; + n -= chunk; + } + return true; +} + +bool tpt_equal(tpt_context *c, const void *a, const void *b, size_t n) { + const unsigned char *x = a, *y = b; + while (n) { + size_t chunk = n > TPT_CHUNK ? TPT_CHUNK : n; + if (!tpt_poll(c) || memcmp(x, y, chunk)) + return false; + x += chunk; + y += chunk; + n -= chunk; + } + return true; +} + +bool tpt_length(tpt_context *c, const char *s, size_t max, size_t *out) { + if (!s) + return tpt_fail(c, CBM_PINNED_TREE_INVALID, "missing string"); + for (size_t i = 0; i <= max; i++) { + if (i % 4096 == 0 && !tpt_poll(c)) + return false; + if (!s[i]) { + *out = i; + return true; + } + } + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "string length bound"); +} + +char *tpt_string(tpt_context *c, const char *s, size_t n) { + if (n == SIZE_MAX) { + tpt_fail(c, CBM_PINNED_TREE_LIMIT, "string size overflow"); + return NULL; + } + char *copy = tpt_alloc(c, n + 1, 1); + return copy && tpt_copy(c, copy, s, n) ? copy : NULL; +} + +bool tpt_oid(tpt_context *c, const char *oid, unsigned width) { + if (!oid || (width != 40 && width != 64)) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "invalid pinned object identity"); + for (unsigned i = 0; i < width; i++) { + unsigned char ch = (unsigned char)oid[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) + return tpt_fail(c, CBM_PINNED_TREE_GIT, "noncanonical pinned OID"); + } + return !oid[width] || tpt_fail(c, CBM_PINNED_TREE_GIT, "pinned OID width"); +} + +int tpt_compare(tpt_context *c, const unsigned char *a, size_t an, const unsigned char *b, + size_t bn) { + size_t n = an < bn ? an : bn; + for (size_t i = 0; i < n; i++) { + if (i % 4096 == 0 && !tpt_poll(c)) + return 0; + if (a[i] != b[i]) + return a[i] < b[i] ? -1 : 1; + } + return (an > bn) - (an < bn); +} + +bool tpt_hash(tpt_context *c, cbm_sha256_ctx *hash, const void *bytes, size_t n) { + const unsigned char *p = bytes; + uint64_t used = hash->bitlen / 8 + hash->buflen; + if ((uint64_t)n > UINT64_MAX / 8 - used) + return tpt_fail(c, CBM_PINNED_TREE_LIMIT, "SHA256 length bound"); + while (n) { + size_t chunk = n > TPT_CHUNK ? TPT_CHUNK : n; + if (!tpt_poll(c)) + return false; + cbm_sha256_update(hash, p, chunk); + p += chunk; + n -= chunk; + } + return true; +} + +const char *tpt_leaf(const tpt_object *o) { + size_t start = o->length; + while (start && o->path[start - 1] != '/') + start--; + return o->path + start; +} + +tpt_object *tpt_parent(cbm_pinned_tree_t *t, const tpt_object *o) { + return o->parent == SIZE_MAX ? &t->parent : &t->objects[o->parent]; +} + +void tpt_dispose_memory(cbm_pinned_tree_t *t) { + CBMArena arena = t->arena; + cbm_arena_destroy(&arena); +} diff --git a/src/mcp/test_impact_tree_windows.c b/src/mcp/test_impact_tree_windows.c new file mode 100644 index 000000000..617aa42bb --- /dev/null +++ b/src/mcp/test_impact_tree_windows.c @@ -0,0 +1,243 @@ +#include "mcp/test_impact_tree_internal.h" +#ifdef _WIN32 + +bool tpt_win_error(tpt_context *c, DWORD e, bool creating) { + if (creating && (e == ERROR_ALREADY_EXISTS || e == ERROR_FILE_EXISTS)) + return tpt_fail(c, CBM_PINNED_TREE_COLLISION, "exclusive native name already exists"); + if (e == ERROR_NOT_ENOUGH_MEMORY || e == ERROR_OUTOFMEMORY) + return tpt_fail(c, CBM_PINNED_TREE_OOM, "native allocation failed"); + if (e == ERROR_INVALID_NAME || e == ERROR_FILENAME_EXCED_RANGE || e == ERROR_NOT_SUPPORTED || + e == ERROR_INVALID_FUNCTION) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "unsupported native representation"); + return tpt_fail(c, CBM_PINNED_TREE_IO, "native filesystem operation failed"); +} + +bool tpt_native_id_equal(tpt_identity a, tpt_identity b) { + return a.volume == b.volume && memcmp(a.file, b.file, sizeof(a.file)) == 0; +} + +static bool tpt_win_id(tpt_context *c, HANDLE handle, tpt_identity *out) { + FILE_ID_INFO id; + if (!GetFileInformationByHandleEx(handle, FileIdInfo, &id, sizeof(id))) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "stable native file identity unavailable"); + out->volume = id.VolumeSerialNumber; + memcpy(out->file, id.FileId.Identifier, sizeof(out->file)); + return true; +} + +bool tpt_win_path(tpt_context *c, const tpt_object *o) { + if (!tpt_join(c, o) || !tpt_wide(c, c->tree->path_scratch, c->tree->wide_path)) + return false; + wchar_t *p = c->tree->wide_path; + size_t n = 0; + while (p[n]) { + if (p[n] == L'/') + p[n] = L'\\'; + n++; + } + if (n >= 2 && p[0] == L'\\' && p[1] == L'\\') { + memmove(p + 8, p + 2, (n - 1) * sizeof(wchar_t)); + memcpy(p, L"\\\\?\\UNC\\", 8 * sizeof(wchar_t)); + } else { + memmove(p + 4, p, (n + 1) * sizeof(wchar_t)); + memcpy(p, L"\\\\?\\", 4 * sizeof(wchar_t)); + } + return true; +} + +static bool tpt_win_info(tpt_context *c, tpt_object *o, BY_HANDLE_FILE_INFORMATION *info) { + if (!GetFileInformationByHandle(o->handle, info)) + return tpt_win_error(c, GetLastError(), false); + bool directory = (info->dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + if ((info->dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) || directory != o->directory || + (!directory && info->nNumberOfLinks != 1) || GetFileType(o->handle) != FILE_TYPE_DISK) + return tpt_fail(c, o->identified ? CBM_PINNED_TREE_CHANGED : CBM_PINNED_TREE_UNSUPPORTED, + "native type, reparse or link mismatch"); + return true; +} + +static bool tpt_win_open(tpt_context *c, tpt_object *o) { + if (o->handle != INVALID_HANDLE_VALUE) + return true; + if (!tpt_win_path(c, o)) + return false; + o->handle = CreateFileW(c->tree->wide_path, GENERIC_READ | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); + return o->handle != INVALID_HANDLE_VALUE || tpt_win_error(c, GetLastError(), false); +} + +static bool tpt_win_probe_identity(tpt_context *c, const tpt_object *o) { + BY_HANDLE_FILE_INFORMATION path_info; + if (!GetFileInformationByHandle(c->tree->probe, &path_info)) + return tpt_win_error(c, GetLastError(), false); + tpt_identity path_id; + if (!tpt_win_id(c, c->tree->probe, &path_id)) + return false; + return (o->identified && tpt_native_id_equal(o->identity, path_id)) || + tpt_fail(c, CBM_PINNED_TREE_CHANGED, "tracked path identity replaced"); +} + +static bool tpt_win_probe(tpt_context *c, const tpt_object *o) { + if (!tpt_native_close(c, &c->tree->probe)) + return false; + c->tree->probe = CreateFileW(c->tree->wide_path, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); + if (c->tree->probe == INVALID_HANDLE_VALUE) + return tpt_win_error(c, GetLastError(), false); + bool matched = tpt_win_probe_identity(c, o); + /* Always close a completed probe, including mismatch/inspection failures. + * A close failure retains its handle; tpt_fail preserves any primary error. */ + bool closed = tpt_native_close(c, &c->tree->probe); + return matched && closed; +} + +bool tpt_native_identity(tpt_context *c, tpt_object *o, bool *absent) { + *absent = false; + if (!tpt_win_path(c, o)) + return false; + DWORD attributes = GetFileAttributesW(c->tree->wide_path); + if (attributes == INVALID_FILE_ATTRIBUTES) { + DWORD e = GetLastError(); + if (e == ERROR_FILE_NOT_FOUND || e == ERROR_PATH_NOT_FOUND) { + *absent = true; + return true; + } + return tpt_win_error(c, e, false); + } + if ((attributes & FILE_ATTRIBUTE_REPARSE_POINT) || + ((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) != o->directory) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "tracked path type replaced"); + if (!tpt_win_probe(c, o) || !tpt_win_open(c, o)) + return false; + BY_HANDLE_FILE_INFORMATION info; + if (!tpt_win_info(c, o, &info)) + return false; + tpt_identity handle_id; + if (!tpt_win_id(c, o->handle, &handle_id)) + return false; + if (!o->identified || !tpt_native_id_equal(o->identity, handle_id)) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "tracked native identity replaced"); + return true; +} + +bool tpt_native_check(tpt_context *c, tpt_object *o, bool writing) { + if (!tpt_win_effective(c)) + return false; + bool absent = false; + if (!tpt_native_identity(c, o, &absent)) + return false; + if (absent) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "expected native object missing"); + BY_HANDLE_FILE_INFORMATION info; + if (!tpt_win_info(c, o, &info)) + return false; + if (!o->directory && !writing) { + uint64_t size = ((uint64_t)info.nFileSizeHigh << 32) | info.nFileSizeLow; + if (!(info.dwFileAttributes & FILE_ATTRIBUTE_READONLY) || + size != c->tree->files[o->file].content_length) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, + "native file size or read-only policy changed"); + } + return tpt_native_acl(c, o, 0, !writing); +} + +bool tpt_native_prepare(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + if (!tpt_win_security(c)) + return false; + size_t parent_length = 0; + while (t->wide_parent[parent_length]) + parent_length++; + while (parent_length > 3 && (t->wide_parent[parent_length - 1] == L'/' || + t->wide_parent[parent_length - 1] == L'\\')) + t->wide_parent[--parent_length] = 0; + DWORD n = GetFullPathNameW(t->wide_parent, TPT_PATH_CAP, t->wide_root, NULL); + if (!n || n >= TPT_PATH_CAP) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "parent canonical path unavailable"); + if (!tpt_utf8(c, t->wide_root, t->parent_path)) + return false; + t->parent.path = t->parent_path; + if (!tpt_length(c, t->parent_path, TPT_PATH_CAP - 1, &t->parent.length) || + !tpt_win_open(c, &t->parent)) + return false; + BY_HANDLE_FILE_INFORMATION info; + if (!tpt_win_info(c, &t->parent, &info)) + return false; + if (!tpt_win_id(c, t->parent.handle, &t->parent.identity)) + return false; + t->parent.identified = true; + return tpt_native_check(c, &t->parent, true); +} + +bool tpt_native_create(tpt_context *c, tpt_object *o) { + if (!tpt_poll(c) || !tpt_chain(c, o) || !tpt_win_path(c, o)) + return false; + void *ace = NULL; + if (!GetAce(c->tree->owned_acl, 0, &ace)) + return tpt_win_error(c, GetLastError(), false); + ((ACE_HEADER *)ace)->AceFlags = o->directory ? OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE : 0; + if (o->directory) { + if (!CreateDirectoryW(c->tree->wide_path, &c->tree->attributes)) + return tpt_win_error(c, GetLastError(), true); + o->created = true; + if (!tpt_win_open(c, o)) + return false; + } else { + o->handle = + CreateFileW(c->tree->wide_path, GENERIC_READ | GENERIC_WRITE | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, &c->tree->attributes, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (o->handle == INVALID_HANDLE_VALUE) + return tpt_win_error(c, GetLastError(), true); + o->created = true; + } + BY_HANDLE_FILE_INFORMATION info; + tpt_identity identity; + if (!tpt_win_info(c, o, &info) || !tpt_win_id(c, o->handle, &identity) || + !tpt_record_identity(c, o, identity)) + return false; + return tpt_native_check(c, o, true); +} + +bool tpt_native_close(tpt_context *c, tpt_handle *handle) { + if (*handle == INVALID_HANDLE_VALUE) + return true; + if (!CloseHandle(*handle)) { + c->close_failed = true; + return tpt_win_error(c, GetLastError(), false); + } + *handle = INVALID_HANDLE_VALUE; + return true; +} + +bool tpt_native_remove(tpt_context *c, tpt_object *o) { + if (!tpt_chain(c, o)) + return false; + bool absent = false; + if (!tpt_native_identity(c, o, &absent)) + return false; + if (absent && !tpt_absent_disposable(c, o)) + return false; + if (!tpt_native_close(c, &o->handle)) + return false; + if (absent) { + o->created = false; + return true; + } + if (!tpt_win_path(c, o)) + return false; + if (!o->directory) { + DWORD attributes = GetFileAttributesW(c->tree->wide_path); + if (attributes == INVALID_FILE_ATTRIBUTES || + !SetFileAttributesW(c->tree->wide_path, attributes & ~FILE_ATTRIBUTE_READONLY)) + return tpt_win_error(c, GetLastError(), false); + } + BOOL ok = o->directory ? RemoveDirectoryW(c->tree->wide_path) : DeleteFileW(c->tree->wide_path); + if (!ok) + return tpt_win_error(c, GetLastError(), false); + o->created = false; + return true; +} +#endif diff --git a/src/mcp/test_impact_tree_windows_acl.c b/src/mcp/test_impact_tree_windows_acl.c new file mode 100644 index 000000000..93aa1725c --- /dev/null +++ b/src/mcp/test_impact_tree_windows_acl.c @@ -0,0 +1,126 @@ +#include "mcp/test_impact_tree_internal.h" +#ifdef _WIN32 +#include + +bool tpt_win_effective(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + if (t->impersonation != INVALID_HANDLE_VALUE) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "impersonated owner unavailable"); + if (OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &t->impersonation)) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "impersonated materializer unsupported"); + t->impersonation = INVALID_HANDLE_VALUE; + return GetLastError() == ERROR_NO_TOKEN || + tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "effective identity inspection failed"); +} + +static bool tpt_token(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + if (!tpt_win_effective(c)) + return false; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &t->token)) + return tpt_win_error(c, GetLastError(), false); + DWORD needed = 0; + if (GetTokenInformation(t->token, TokenUser, NULL, 0, &needed) || + GetLastError() != ERROR_INSUFFICIENT_BUFFER || needed < sizeof(TOKEN_USER) || + needed > TPT_SECURITY_CAP) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "unsupported process token representation"); + t->token_user = tpt_alloc(c, needed, 1); + if (!t->token_user) + return false; + if (!GetTokenInformation(t->token, TokenUser, t->token_user, needed, &needed)) + return tpt_win_error(c, GetLastError(), false); + PSID sid = ((TOKEN_USER *)t->token_user)->User.Sid; + return IsValidSid(sid) || tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "invalid process user SID"); +} + +bool tpt_win_security(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + if (!tpt_token(c)) + return false; + PSID sid = ((TOKEN_USER *)t->token_user)->User.Sid; + DWORD length = GetLengthSid(sid); + if (length > SECURITY_MAX_SID_SIZE) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "unsupported user SID length"); + DWORD bytes = (DWORD)(sizeof(ACL) + sizeof(ACCESS_ALLOWED_ACE) - sizeof(DWORD)) + length; + t->owned_acl = tpt_alloc(c, bytes, 1); + if (!t->owned_acl) + return false; + if (!InitializeAcl(t->owned_acl, bytes, ACL_REVISION) || + !AddAccessAllowedAceEx(t->owned_acl, ACL_REVISION, + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, FILE_ALL_ACCESS, sid) || + !InitializeSecurityDescriptor(&t->descriptor, SECURITY_DESCRIPTOR_REVISION) || + !SetSecurityDescriptorOwner(&t->descriptor, sid, FALSE) || + !SetSecurityDescriptorDacl(&t->descriptor, TRUE, t->owned_acl, FALSE) || + !SetSecurityDescriptorControl(&t->descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED)) + return tpt_win_error(c, GetLastError(), false); + t->attributes.nLength = sizeof(t->attributes); + t->attributes.lpSecurityDescriptor = &t->descriptor; + t->attributes.bInheritHandle = FALSE; + return true; +} + +static int tpt_owner_acl(cbm_pinned_tree_t *t, PSECURITY_DESCRIPTOR sd) { + PSID owner = NULL; + PACL dacl = NULL; + BOOL owner_defaulted = FALSE, present = FALSE, defaulted = FALSE; + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + if (!IsValidSecurityDescriptor(sd) || + !GetSecurityDescriptorOwner(sd, &owner, &owner_defaulted) || + !GetSecurityDescriptorDacl(sd, &present, &dacl, &defaulted) || + !GetSecurityDescriptorControl(sd, &control, &revision)) + return -1; + PSID sid = ((TOKEN_USER *)t->token_user)->User.Sid; + if (!owner || !IsValidSid(owner)) + return -1; + if (!EqualSid(owner, sid) || !(control & SE_DACL_PROTECTED) || !present || !dacl) + return 0; + if (!IsValidAcl(dacl)) + return -1; + ACL_SIZE_INFORMATION info; + if (!GetAclInformation(dacl, &info, sizeof(info), AclSizeInformation)) + return -1; + if (info.AceCount != 1) + return 0; + void *raw = NULL; + if (!GetAce(dacl, 0, &raw)) + return -1; + ACCESS_ALLOWED_ACE *ace = raw; + if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE) + return 0; + size_t prefix = offsetof(ACCESS_ALLOWED_ACE, SidStart); + if (ace->Header.AceSize < prefix + 8) + return -1; + if ((ace->Header.AceFlags & ~(OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE)) || + (ace->Mask != FILE_ALL_ACCESS && ace->Mask != GENERIC_ALL)) + return 0; + const SID *principal = (const SID *)&ace->SidStart; + size_t sid_size = 8 + (size_t)principal->SubAuthorityCount * sizeof(DWORD); + if (sid_size > ace->Header.AceSize - prefix || !IsValidSid(&ace->SidStart)) + return -1; + return EqualSid(&ace->SidStart, sid) ? 1 : 0; +} + +bool tpt_native_acl(tpt_context *c, tpt_object *o, unsigned mode, bool changed) { + (void)mode; + DWORD required = 0; + if (!GetKernelObjectSecurity(o->handle, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + c->tree->security_scratch, TPT_SECURITY_CAP, &required)) { + DWORD e = GetLastError(); + return tpt_fail(c, + e == ERROR_NOT_ENOUGH_MEMORY || e == ERROR_OUTOFMEMORY + ? CBM_PINNED_TREE_OOM + : CBM_PINNED_TREE_UNSUPPORTED, + "required owner/DACL inspection failed"); + } + if (!required || required > TPT_SECURITY_CAP) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "unbounded native security descriptor"); + int allowed = tpt_owner_acl(c->tree, c->tree->security_scratch); + if (allowed < 0) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, + "native security descriptor cannot be decoded"); + return allowed == 1 || + tpt_fail(c, changed ? CBM_PINNED_TREE_CHANGED : CBM_PINNED_TREE_UNSUPPORTED, + "protected owner-only DACL policy mismatch"); +} +#endif diff --git a/src/mcp/test_impact_tree_windows_io.c b/src/mcp/test_impact_tree_windows_io.c new file mode 100644 index 000000000..34028aa94 --- /dev/null +++ b/src/mcp/test_impact_tree_windows_io.c @@ -0,0 +1,141 @@ +#include "mcp/test_impact_tree_internal.h" +#ifdef _WIN32 + +bool tpt_native_write(tpt_context *c, tpt_object *o, cbm_git_bytes_t bytes) { + size_t offset = 0; + while (offset < bytes.length) { + if (!tpt_poll(c)) + return false; + size_t count = bytes.length - offset; + DWORD n = count > TPT_CHUNK ? TPT_CHUNK : (DWORD)count; + DWORD written = 0; + if (!WriteFile(o->handle, bytes.data + offset, n, &written, NULL) || !written) + return tpt_win_error(c, GetLastError(), false); + offset += written; + } + return true; +} + +bool tpt_native_read_bounded(tpt_context *c, tpt_object *o, size_t request, size_t *count) { + *count = 0; + if (!request || request > TPT_CHUNK) + return tpt_fail(c, CBM_PINNED_TREE_INVALID, "invalid native read bound"); + DWORD n = 0; + if (!ReadFile(o->handle, c->tree->io_scratch, (DWORD)request, &n, NULL)) + return tpt_win_error(c, GetLastError(), false); + *count = n; + return true; +} + +bool tpt_native_read(tpt_context *c, tpt_object *o, size_t *count) { + return tpt_native_read_bounded(c, o, TPT_CHUNK, count); +} + +bool tpt_native_finish(tpt_context *c, tpt_object *o) { + if (!tpt_native_close(c, &o->handle) || !tpt_chain(c, o)) + return false; + bool absent = false; + if (!tpt_native_identity(c, o, &absent)) + return false; + if (absent) + return tpt_fail(c, CBM_PINNED_TREE_CHANGED, "created file disappeared"); + if (!tpt_win_path(c, o)) + return false; + DWORD attributes = GetFileAttributesW(c->tree->wide_path); + if (attributes == INVALID_FILE_ATTRIBUTES || + !SetFileAttributesW(c->tree->wide_path, attributes | FILE_ATTRIBUTE_READONLY)) + return tpt_win_error(c, GetLastError(), false); + return tpt_native_check(c, o, false) && tpt_native_close(c, &o->handle); +} + +static bool tpt_windows_entries(tpt_context *c, size_t directory) { + cbm_pinned_tree_t *t = c->tree; + size_t offset = 0; + for (;;) { + if (!tpt_poll(c)) + return false; + size_t prefix = offsetof(FILE_ID_BOTH_DIR_INFO, FileName); + if (offset > TPT_CHUNK - prefix) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "native directory record overflow"); + FILE_ID_BOTH_DIR_INFO *entry = (void *)(t->enumeration_scratch + offset); + size_t bytes = entry->FileNameLength; + if (bytes % sizeof(wchar_t) || bytes > TPT_CHUNK - offset - prefix || + bytes / sizeof(wchar_t) >= TPT_PATH_CAP) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "native directory name overflow"); + size_t chars = bytes / sizeof(wchar_t); + memcpy(t->wide_root, entry->FileName, bytes); + t->wide_root[chars] = 0; + for (size_t i = 0; i < chars; i++) { + if (!t->wide_root[i]) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "NUL in native directory name"); + } + bool dot = chars == 1 && t->wide_root[0] == L'.'; + bool dots = chars == 2 && t->wide_root[0] == L'.' && t->wide_root[1] == L'.'; + if (!dot && !dots) { + if (!tpt_utf8(c, t->wide_root, t->name_scratch)) + return false; + size_t length = 0; + if (!tpt_length(c, t->name_scratch, TPT_PATH_CAP - 1, &length) || + !tpt_entry(c, directory, t->name_scratch, length)) + return false; + } + DWORD next = entry->NextEntryOffset; + if (!next) + return true; + if (next < prefix + bytes || next > TPT_CHUNK - offset || next % 8) + return tpt_fail(c, CBM_PINNED_TREE_UNSUPPORTED, "native directory record offset"); + offset += next; + } +} + +bool tpt_native_enumerate(tpt_context *c, size_t directory) { + cbm_pinned_tree_t *t = c->tree; + if (!tpt_win_path(c, &t->objects[directory])) + return false; + t->enumeration = CreateFileW(t->wide_path, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (t->enumeration == INVALID_HANDLE_VALUE) + return tpt_win_error(c, GetLastError(), false); + bool first = true; + for (;;) { + if (!tpt_poll(c)) + return false; + FILE_INFO_BY_HANDLE_CLASS kind = + first ? FileIdBothDirectoryRestartInfo : FileIdBothDirectoryInfo; + if (!GetFileInformationByHandleEx(t->enumeration, kind, t->enumeration_scratch, + TPT_CHUNK)) { + DWORD e = GetLastError(); + if (e == ERROR_NO_MORE_FILES) + return tpt_native_close(c, &t->enumeration); + return tpt_win_error(c, e, false); + } + first = false; + if (!tpt_windows_entries(c, directory)) + return false; + } +} + +bool tpt_native_release(tpt_context *c) { + cbm_pinned_tree_t *t = c->tree; + bool ok = tpt_native_close(c, &t->enumeration); + if (!tpt_native_close(c, &t->probe)) + ok = false; + if (!tpt_native_close(c, &t->token)) + ok = false; + if (!tpt_native_close(c, &t->impersonation)) + ok = false; + bool owned = false; + for (size_t i = 0; t->objects && i < t->object_count; i++) { + tpt_object *o = &t->objects[i]; + if (!o->created) + continue; + owned = true; + if (!o->directory && !tpt_native_close(c, &o->handle)) + ok = false; + } + if (!owned && !tpt_native_close(c, &t->parent.handle)) + ok = false; + return ok; +} +#endif diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 1f7b9b4f4..e5092e356 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -45,6 +45,7 @@ static bool pc_module_is_dir(CBMLanguage lang) { /* Read entire file into heap-allocated buffer. Caller must free(). */ static char *read_file(const char *path, int *out_len) { + *out_len = -1; /* unknown/read failure; zero is a proven empty file */ FILE *f = cbm_fopen(path, "rb"); if (!f) { return NULL; @@ -55,6 +56,9 @@ static char *read_file(const char *path, int *out_len) { (void)fseek(f, 0, SEEK_SET); if (size <= 0 || size > cbm_max_file_bytes()) { /* generous, env-configurable cap (B4) */ + if (size == 0) { + *out_len = 0; + } (void)fclose(f); return NULL; } @@ -1004,12 +1008,17 @@ static CBMFileResult *calls_get_or_extract(cbm_pipeline_ctx_t *ctx, int idx, int slen = 0; char *src = read_file(fi->path, &slen); if (!src) { + if (slen != 0) { + (void)cbm_pipeline_test_extraction_ok(ctx, fi->language, NULL); + } return NULL; } - CBMFileResult *r = cbm_extract_file_ex(src, slen, fi->language, ctx->project_name, fi->rel_path, - CBM_EXTRACT_BUDGET, NULL, NULL, ctx->macro_table, - ctx->return_type_table); + CBMFileResult *r = cbm_pipeline_test_force_extract_null(ctx, fi->language) ? NULL : + cbm_extract_file_ex_with_tests( + src, slen, fi->language, ctx->project_name, fi->rel_path, CBM_EXTRACT_BUDGET, + NULL, NULL, ctx->macro_table, ctx->return_type_table, ctx->test_declarations); free(src); + (void)cbm_pipeline_test_extraction_ok(ctx, fi->language, r); if (r) { *owned = true; } @@ -1043,10 +1052,20 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file bool result_owned = false; CBMFileResult *result = calls_get_or_extract(ctx, i, &files[i], &result_owned); if (!result) { + if (atomic_load(&ctx->test_declarations_failed)) { + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } errors++; continue; } + if (!cbm_pipeline_test_result_ok(ctx, result)) { + if (result_owned) { + cbm_free_result(result); + } + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } + if (result->calls.count == 0) { if (result_owned) { cbm_free_result(result); diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 804ffba65..f0673c9e9 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -256,6 +256,9 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) * dump. Gate the block to functions; other labels keep the lean base. */ const bool is_fn = def->label && (strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0); + const char *test_role = def->test_role == CBM_TEST_ROLE_CASE ? ",\"test_role\":\"case\"" + : def->test_role == CBM_TEST_ROLE_SUITE ? ",\"test_role\":\"suite\"" + : ""; int n; if (is_fn) { n = snprintf(buf, bufsize, @@ -263,20 +266,20 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) "\"self_recursive\":%s,\"param_count\":%d,\"max_access_depth\":%d," "\"linear_scan_in_loop\":%d,\"alloc_in_loop\":%d,\"recursion_in_loop\":%s," "\"unguarded_recursion\":%s," - "\"lines\":%d,\"is_exported\":%s,\"is_test\":%s,\"is_entry_point\":%s", + "\"lines\":%d,\"is_exported\":%s,\"is_test\":%s,\"is_entry_point\":%s%s", def->complexity, def->cognitive, def->loop_count, def->loop_depth, def->is_recursive ? "true" : "false", def->param_count, def->max_access_depth, def->linear_scan_in_loop, def->alloc_in_loop, def->recursion_in_loop ? "true" : "false", def->unguarded_recursion ? "true" : "false", def->lines, def->is_exported ? "true" : "false", def->is_test ? "true" : "false", - def->is_entry_point ? "true" : "false"); + def->is_entry_point ? "true" : "false", test_role); } else { n = snprintf(buf, bufsize, "{\"complexity\":%d,\"lines\":%d,\"is_exported\":%s,\"is_test\":%s," - "\"is_entry_point\":%s", + "\"is_entry_point\":%s%s", def->complexity, def->lines, def->is_exported ? "true" : "false", - def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false"); + def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false", test_role); } if (n <= 0 || (size_t)n >= bufsize) { @@ -797,6 +800,9 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t : "quarantined after crash"; cbm_pipeline_add_file_error(ctx->pipeline, rel, reason, phase); errors++; + if (!cbm_pipeline_test_extraction_ok(ctx, lang, NULL)) { + goto configured_failure; + } continue; } @@ -822,26 +828,43 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t } else if (rst == CBM_READ_OPEN_FAIL || rst == CBM_READ_OOM) { cbm_pipeline_add_file_error(ctx->pipeline, rel, "read failed", "read"); } - /* CBM_READ_EMPTY: benign 0-byte file — nothing to index, not reported. */ + /* Proven empty files contain no invocation; other missing source + * cannot certify an active configured-definition mapping. */ + if (rst != CBM_READ_EMPTY && !cbm_pipeline_test_extraction_ok(ctx, lang, NULL)) { + goto configured_failure; + } continue; } /* Studio Export XML is transformed to one cacheable aggregate so later * passes see the same calls/usages/semantic carriers as native UDL. */ CBMFileResult *result = + cbm_pipeline_test_force_extract_null(ctx, lang) ? NULL : lang == CBM_LANG_OBJECTSCRIPT_EXPORT ? cbm_pipeline_extract_objectscript_export(source, source_len, ctx->project_name, rel, ctx->macro_table, NULL) - : cbm_extract_file_ex( + : cbm_extract_file_ex_with_tests( source, source_len, lang, ctx->project_name, rel, CBM_EXTRACT_BUDGET, NULL, - NULL /* no extra defines or include paths */, ctx->macro_table, NULL); + NULL /* no extra defines or include paths */, ctx->macro_table, NULL, + ctx->test_declarations); free(source); + if (!cbm_pipeline_test_extraction_ok(ctx, lang, result)) { + cbm_free_result(result); + goto configured_failure; + } if (!result) { errors++; cbm_pipeline_add_file_error(ctx->pipeline, rel, "extract failed", "extract"); continue; } + if (result->has_test_definition_owners && result->lsp_skipped) { + cbm_pipeline_test_owner_error(result); + } + if (!cbm_pipeline_test_result_ok(ctx, result)) { + cbm_free_result(result); + goto configured_failure; + } /* Consume the previously-ignored has_error flag: a parse timeout / * parse failure / unsupported-grammar result carries no defs but must * still be reported (phase "extract", reason = the extractor's message). @@ -928,4 +951,13 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t itoa_log(total_calls), "imports", itoa_log(total_imports), "errors", itoa_log(errors)); return 0; + +configured_failure: + if (owns_local_cache) { + for (int j = 0; j < file_count; j++) { + cbm_free_result(local_cache[j]); + } + free(local_cache); + } + return CBM_PIPELINE_ABORT_PRESERVE_DB; } diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 5c823a44d..5b63ec871 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -32,6 +32,7 @@ #include "foundation/constants.h" #include "foundation/hash_table.h" #include "foundation/log.h" +#include "foundation/platform.h" #include "foundation/compat_fs.h" #include "foundation/compat.h" /* CBM_TLS */ @@ -558,17 +559,21 @@ static int pxc_build_rust_impl_relation(CBMArena *arena, const CBMImplTrait *imp /* Collect a project-wide CBMLSPDef[] from all cached results. Returns a * malloc'd array (caller frees) of length *out_count. String fields are - * borrowed from cache[i]->arena and from def_modules[i] (also borrowed). */ + * borrowed from cache[i]->arena and from def_modules[i] (also borrowed). + * Required configured-owner spill load failure: NULL, *out_count = -1, and + * cleared out_def_starts. No partial aggregate is usable in that case. */ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMArena *arena, CBMFileResult **cache, const cbm_file_info_t *files, int file_count, const char *project_name, char **def_modules, int *out_count, int *out_def_starts) { const cbm_result_spill_t *spill = ctx ? ctx->spill : NULL; + bool owner_walk_required = ctx && atomic_load(&ctx->test_definition_owners_seen); int total = 0; for (int i = 0; i < file_count; i++) { int defs = 0; int impls = 0; if (cache[i]) { + owner_walk_required = owner_walk_required || cache[i]->has_test_definition_owners; defs = cache[i]->defs.count; impls = cache[i]->impl_traits.count; } else if (spill && cbm_result_spill_has(spill, i)) { @@ -603,8 +608,25 @@ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMArena *are CBMFileResult *fr = cache[fi]; bool fr_loaded = false; if (!fr && spill && cbm_result_spill_has(spill, fi)) { + /* The test bit applies only here: registry and subsequent direct + * loads remain real. Returning immediately forces exactly the + * first actual collector spilled-load boundary, without timing. */ +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + bool force_null = owner_walk_required && ctx && + (ctx->test_fault_mask & CBM_PIPELINE_TEST_FAULT_COLLECT_SPILL_NULL); + fr = force_null ? NULL : cbm_result_spill_load(spill, fi); +#else fr = cbm_result_spill_load(spill, fi); +#endif fr_loaded = fr != NULL; + if (!fr && owner_walk_required) { + free(defs); + *out_count = -1; + if (out_def_starts) { + memset(out_def_starts, 0, (size_t)(file_count + 1) * sizeof(int)); + } + return NULL; + } } if (!fr) continue; @@ -1309,8 +1331,18 @@ void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int * imports — the existing pipeline doesn't carry C-style include * resolution as a separate map, so pass NULL/0 and let the LSP * fall back to its own #include scan. */ - cbm_run_c_lsp_cross(&scratch, source, source_len, module_qn, cpp_mode, defs, def_count, - NULL, NULL, 0, tree, &out); + if (r->has_test_definition_owners) { + if (!cbm_run_c_lsp_cross_with_test_owners( + &scratch, source, source_len, module_qn, cpp_mode, defs, def_count, + NULL, NULL, 0, tree, &out, CBM_SOURCE_ORIGIN_RAW, r)) { + cbm_pipeline_test_owner_error(r); + pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch); + return; /* out may be partial: never append it */ + } + } else { + cbm_run_c_lsp_cross(&scratch, source, source_len, module_qn, cpp_mode, defs, def_count, + NULL, NULL, 0, tree, &out); + } break; } case CBM_LANG_PYTHON: @@ -1427,6 +1459,9 @@ void cbm_pxc_dispatch_file(CBMLanguage lang, CBMFileResult *result, const char * int imp_count, CBMTypeRegistry *(*rust_shared_get)(void *), void *rust_shared_ctx) { if (result && result->lsp_skipped) { + if (result->has_test_definition_owners) { + cbm_pipeline_test_owner_error(result); + } return; } if (!result) { @@ -1473,9 +1508,20 @@ void cbm_pxc_dispatch_file(CBMLanguage lang, CBMFileResult *result, const char * case CBM_LANG_C: case CBM_LANG_CPP: case CBM_LANG_CUDA: - cbm_run_c_lsp_cross_with_registry(&scratch, source, source_len, def_module, - (lang != CBM_LANG_C), &overlay, imp_keys, imp_vals, - imp_count, result->cached_tree, &out); + if (result->has_test_definition_owners) { + if (!cbm_run_c_lsp_cross_with_registry_with_test_owners( + &scratch, source, source_len, def_module, (lang != CBM_LANG_C), + &overlay, imp_keys, imp_vals, imp_count, result->cached_tree, + &out, CBM_SOURCE_ORIGIN_RAW, result)) { + cbm_pipeline_test_owner_error(result); + pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch); + return; /* no append and no fallback on an invalid proof */ + } + } else { + cbm_run_c_lsp_cross_with_registry(&scratch, source, source_len, def_module, + (lang != CBM_LANG_C), &overlay, imp_keys, imp_vals, + imp_count, result->cached_tree, &out); + } used_prebuilt = true; break; case CBM_LANG_CSHARP: @@ -1618,8 +1664,16 @@ bool cbm_pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *marena int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count, CBMFileResult **cache) { - if (!ctx || !files || file_count <= 0 || !cache) + if (!ctx) { return 0; + } + if (!files || file_count <= 0 || !cache) { + if (atomic_load(&ctx->test_definition_owners_seen)) { + atomic_store(&ctx->test_declarations_failed, 1); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } + return 0; + } cbm_log_info("pass.start", "pass", "lsp_cross", "files", itoa_buf(file_count)); @@ -1629,11 +1683,21 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * * the pointer through the file-static set below. */ bool have_rust = false; for (int i = 0; i < file_count; i++) { + if (!cbm_pipeline_test_result_ok(ctx, cache[i])) { + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } if (cache[i] && files[i].language == CBM_LANG_RUST) { have_rust = true; - break; } } + bool owner_walk_required = atomic_load(&ctx->test_definition_owners_seen) != 0; + char cross_disabled[CBM_SZ_16]; + if (owner_walk_required && + cbm_safe_getenv("CBM_DISABLE_LSP_CROSS", cross_disabled, + sizeof(cross_disabled), NULL) != NULL) { + atomic_store(&ctx->test_declarations_failed, 1); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } CBMArena cargo_arena; CBMCargoManifest cargo_manifest; bool have_manifest = false; @@ -1645,9 +1709,18 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * /* Per-file module QN cache so we don't recompute it once per def + once * per call. cbm_pipeline_fqn_module mallocs; freed at end. */ - char **def_modules = (char **)calloc((size_t)file_count, sizeof(char *)); + char **def_modules = cbm_pipeline_test_force_def_modules_null(ctx) ? NULL : + (char **)calloc((size_t)file_count, sizeof(char *)); if (!def_modules) { cbm_log_error("pass.err", "pass", "lsp_cross", "phase", "alloc"); + if (have_rust) { + cbm_pxc_set_rust_manifest(NULL); + cbm_arena_destroy(&cargo_arena); + } + if (owner_walk_required) { + atomic_store(&ctx->test_declarations_failed, 1); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } return 0; } @@ -1662,6 +1735,20 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * CBMLSPDef *all_defs = cbm_pxc_collect_all_defs(ctx, &ctx->seq_cross_arena, cache, files, file_count, ctx->project_name, def_modules, &def_count, def_starts); + if (def_count < 0 || (owner_walk_required && (!all_defs || def_count <= 0))) { + atomic_store(&ctx->test_declarations_failed, 1); + free(def_starts); + free(all_defs); + for (int i = 0; i < file_count; i++) { + free(def_modules[i]); + } + free(def_modules); + if (have_rust) { + cbm_pxc_set_rust_manifest(NULL); + cbm_arena_destroy(&cargo_arena); + } + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } /* Same seam as the parallel driver: serialize per-file surfaces while the * result cache is alive. Failure only degrades to a full rebuild on the * next incremental run. */ @@ -1709,8 +1796,16 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * for (int i = 0; i < file_count; i++) { if (!cache[i]) continue; + if (!cbm_pipeline_test_result_ok(ctx, cache[i])) { + break; + } CBMLanguage lang = files[i].language; if (!cbm_pxc_has_cross_lsp(lang)) { + if (cache[i]->has_test_definition_owners) { + cbm_pipeline_test_owner_error(cache[i]); + (void)cbm_pipeline_test_result_ok(ctx, cache[i]); + break; + } skipped_no_lsp++; continue; } @@ -1719,6 +1814,11 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * char *source = pxc_read_file(files[i].path, &source_len); if (!source || source_len <= 0) { free(source); + if (cache[i]->has_test_definition_owners) { + cbm_pipeline_test_owner_error(cache[i]); + (void)cbm_pipeline_test_result_ok(ctx, cache[i]); + break; + } skipped_no_source++; continue; } @@ -1728,6 +1828,13 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * pxc_module_is_dir(files[i].language)); } + if (!def_modules[i] && cache[i]->has_test_definition_owners) { + free(source); + cbm_pipeline_test_owner_error(cache[i]); + (void)cbm_pipeline_test_result_ok(ctx, cache[i]); + break; + } + const char **imp_keys = NULL; const char **imp_vals = NULL; int imp_count = 0; @@ -1747,6 +1854,9 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * cbm_pxc_free_import_map(imp_keys, imp_vals, imp_count); free(source); + if (!cbm_pipeline_test_result_ok(ctx, cache[i])) { + break; + } } cbm_pxc_free_module_def_index(module_def_index); @@ -1772,7 +1882,8 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * "files_skipped_no_lsp", itoa_buf(skipped_no_lsp), "files_skipped_no_source", itoa_buf(skipped_no_source), "defs_total", itoa_buf(def_count), "lsp_calls", itoa_buf(per_lang_calls)); - return 0; + return atomic_load(&ctx->test_declarations_failed) + ? CBM_PIPELINE_ABORT_PRESERVE_DB : 0; } /* ── Per-module def index (gopls "package summary" pattern) ──── */ diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 0aec4e942..0332b41c3 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -485,6 +485,9 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) * pass_definitions.c::build_def_props — keep both in sync. */ const bool is_fn = def->label && (strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0); + const char *test_role = def->test_role == CBM_TEST_ROLE_CASE ? ",\"test_role\":\"case\"" + : def->test_role == CBM_TEST_ROLE_SUITE ? ",\"test_role\":\"suite\"" + : ""; int n; if (is_fn) { n = snprintf(buf, bufsize, @@ -492,20 +495,20 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) "\"self_recursive\":%s,\"param_count\":%d,\"max_access_depth\":%d," "\"linear_scan_in_loop\":%d,\"alloc_in_loop\":%d,\"recursion_in_loop\":%s," "\"unguarded_recursion\":%s," - "\"lines\":%d,\"is_exported\":%s,\"is_test\":%s,\"is_entry_point\":%s", + "\"lines\":%d,\"is_exported\":%s,\"is_test\":%s,\"is_entry_point\":%s%s", def->complexity, def->cognitive, def->loop_count, def->loop_depth, def->is_recursive ? "true" : "false", def->param_count, def->max_access_depth, def->linear_scan_in_loop, def->alloc_in_loop, def->recursion_in_loop ? "true" : "false", def->unguarded_recursion ? "true" : "false", def->lines, def->is_exported ? "true" : "false", def->is_test ? "true" : "false", - def->is_entry_point ? "true" : "false"); + def->is_entry_point ? "true" : "false", test_role); } else { n = snprintf(buf, bufsize, "{\"complexity\":%d,\"lines\":%d,\"is_exported\":%s,\"is_test\":%s," - "\"is_entry_point\":%s", + "\"is_entry_point\":%s%s", def->complexity, def->lines, def->is_exported ? "true" : "false", - def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false"); + def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false", test_role); } if (n <= 0 || (size_t)n >= bufsize) { buf[0] = '\0'; @@ -1190,6 +1193,7 @@ static void extract_worker(int worker_id, void *ctx_ptr) { : "quarantined after crash"; pp_err_add(errs, fi->rel_path, reason, phase); ws->errors++; + (void)cbm_pipeline_test_extraction_ok(ec->pctx, fi->language, NULL); continue; } @@ -1218,7 +1222,9 @@ static void extract_worker(int worker_id, void *ctx_ptr) { } else if (rst == CBM_READ_OPEN_FAIL || rst == CBM_READ_OOM) { pp_err_add(errs, fi->rel_path, "read failed", "read"); } - /* CBM_READ_EMPTY: benign 0-byte file — not reported. */ + if (rst != CBM_READ_EMPTY) { + (void)cbm_pipeline_test_extraction_ok(ec->pctx, fi->language, NULL); + } continue; } @@ -1243,16 +1249,26 @@ static void extract_worker(int worker_id, void *ctx_ptr) { * generated classes are composed before entering the common registry * and resolution lifecycle. */ CBMFileResult *result = + cbm_pipeline_test_force_extract_null(ec->pctx, fi->language) ? NULL : fi->language == CBM_LANG_OBJECTSCRIPT_EXPORT ? cbm_pipeline_extract_objectscript_export(source, source_len, ec->project_name, fi->rel_path, ec->macro_table, ec->return_type_table) - : cbm_extract_file_ex(source, source_len, fi->language, ec->project_name, - fi->rel_path, CBM_EXTRACT_BUDGET, NULL, NULL, ec->macro_table, - ec->return_type_table); + : cbm_extract_file_ex_with_tests( + source, source_len, fi->language, ec->project_name, fi->rel_path, + CBM_EXTRACT_BUDGET, NULL, NULL, ec->macro_table, + ec->return_type_table, ec->pctx->test_declarations); uint64_t file_elapsed_ms = (extract_now_ns() - file_t0) / PP_USEC_PER_MS; + if (!cbm_pipeline_test_extraction_ok(ec->pctx, fi->language, result)) { + cbm_free_result(result); + free_source(source); + cbm_destroy_thread_parser(); + cbm_slab_reclaim(); + cbm_mem_collect(); + continue; + } if (!result) { log_extract_fail(sort_pos, file_elapsed_ms, fi->rel_path); free_source(source); @@ -1260,6 +1276,17 @@ static void extract_worker(int worker_id, void *ctx_ptr) { pp_err_add(errs, fi->rel_path, "extract failed", "extract"); continue; } + if (result->has_test_definition_owners && result->lsp_skipped) { + cbm_pipeline_test_owner_error(result); + } + if (!cbm_pipeline_test_result_ok(ec->pctx, result)) { + cbm_free_result(result); + free_source(source); + cbm_destroy_thread_parser(); + cbm_slab_reclaim(); + cbm_mem_collect(); + continue; + } log_extract_done(sort_pos, file_elapsed_ms, result->defs.count, fi->rel_path); /* Consume the previously-ignored has_error flag: a parse timeout / parse @@ -1639,6 +1666,10 @@ int cbm_parallel_extract_ex(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file /* The over-budget verdict outranks the cancel sentinel: a caller must be * able to name the cause, and the orchestrator discards the staging DB on * every non-zero code alike (the live generation is never touched). */ + if (atomic_load(&ctx->test_declarations_failed)) { + cbm_log_error("pipeline.err", "phase", "test_declarations_extract"); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } if (atomic_load(&ec.over_budget_abort)) { return CBM_PIPELINE_ABORT_OVER_BUDGET; } @@ -1813,11 +1844,22 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t if (!result && ctx->spill && cbm_result_spill_has(ctx->spill, i)) { result = cbm_result_spill_load(ctx->spill, i); loaded = result != NULL; + if (!cbm_pipeline_test_extraction_ok(ctx, files[i].language, result)) { + cbm_pipeline_result_release(result, loaded); + cbm_pipeline_namespace_map_free(namespace_map); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } } if (!result) { continue; } + if (!cbm_pipeline_test_result_ok(ctx, result)) { + cbm_pipeline_result_release(result, loaded); + cbm_pipeline_namespace_map_free(namespace_map); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } + const char *rel = files[i].rel_path; /* Register callable symbols + DEFINES/DEFINES_METHOD edges */ @@ -3778,12 +3820,16 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { cbm_result_spill_has(rc->pctx->spill, file_idx)) { result = cbm_result_spill_load(rc->pctx->spill, file_idx); ws->loaded = result; + (void)cbm_pipeline_test_extraction_ok(rc->pctx, rc->files[file_idx].language, result); } if (!result) { atomic_fetch_add_explicit(&rc->time_ns_total_loop, extract_now_ns() - _loop_t0, memory_order_relaxed); continue; } + if (!cbm_pipeline_test_result_ok(rc->pctx, result)) { + continue; + } atomic_fetch_add_explicit(&rc->total_files_visited, 1, memory_order_relaxed); CBMLanguage lang = rc->files[file_idx].language; @@ -3807,6 +3853,14 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { (strstr(rel, ".pb-c.c") != NULL) || (strstr(rel, ".pb-c.h") != NULL); } + if (result->has_test_definition_owners && + (!rc->all_defs || rc->def_count <= 0 || !cbm_pxc_has_cross_lsp(lang) || + is_generated || result->lsp_skipped)) { + cbm_pipeline_test_owner_error(result); + (void)cbm_pipeline_test_result_ok(rc->pctx, result); + continue; + } + /* Cross-file LSP is a per-file tree-sitter re-parse + AST walk + * registry lookups — ~50-150ms per file. It can only resolve semantic * sites present in that AST: invocations plus explicit callable @@ -3830,7 +3884,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { (jvm_cross_lsp || rust_workspace_cross_lsp || pending_lsp_site || qualified_lsp_sites < semantic_sites)) || unowned_c_member) && - !is_generated); + !is_generated) || result->has_test_definition_owners; /* Skip files with nothing else to resolve and no cross-LSP work. */ if (result->calls.count == 0 && result->usages.count == 0 && result->throws.count == 0 && @@ -3938,6 +3992,9 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { itoa_log((int)lsp_elapsed_ms), "path", rel); } atomic_fetch_add_explicit(&rc->lsp_cross_processed, SKIP_ONE, memory_order_relaxed); + if (!cbm_pipeline_test_result_ok(rc->pctx, result)) { + goto resolve_file_cleanup; + } } else { /* Source unavailable even after the re-read fallback (file * deleted / unreadable / oversized) → the cross-file LSP @@ -3947,6 +4004,12 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { * doing so would flood skipped[] with false positives (itself a * false-guard bug). The "cross_lsp" phase string is reserved for * Track C's real crash-attribution signal; leave it unwired. */ + free_source(lsp_source_owned); + if (result->has_test_definition_owners) { + cbm_pipeline_test_owner_error(result); + (void)cbm_pipeline_test_result_ok(rc->pctx, result); + goto resolve_file_cleanup; + } atomic_fetch_add_explicit(&rc->lsp_cross_skipped_no_source, SKIP_ONE, memory_order_relaxed); } @@ -3984,6 +4047,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { atomic_fetch_add_explicit(&rc->time_ns_semantic, extract_now_ns() - _ph_t0, memory_order_relaxed); +resolve_file_cleanup: cbm_registry_reach_cache_end(); cbm_registry_import_map_cache_end(); cbm_registry_resolve_cache_end(); @@ -4023,6 +4087,14 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, return 0; } + /* Preparation or an opt-out cannot erase owners already found by + * extraction, including results currently parked in the spill store. */ + if (atomic_load(&ctx->test_declarations_failed) || + (atomic_load(&ctx->test_definition_owners_seen) && (!all_defs || def_count <= 0))) { + atomic_store(&ctx->test_declarations_failed, 1); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } + cbm_log_info("parallel.resolve.start", "files", itoa_log(file_count), "workers", itoa_log(worker_count)); @@ -4122,6 +4194,11 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, cbm_aligned_free(workers); + if (atomic_load(&ctx->test_declarations_failed)) { + cbm_log_error("pipeline.err", "phase", "test_declarations_resolve"); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } + /* Go-style implicit interface satisfaction (needs full graph, serial) */ int go_impl = cbm_pipeline_implements_go(ctx); diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 48dab0083..b9782a983 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -37,6 +37,7 @@ static bool ps_module_is_dir(CBMLanguage lang) { } static char *read_file(const char *path, int *out_len) { + *out_len = -1; /* unknown/read failure; zero is a proven empty file */ FILE *f = cbm_fopen(path, "rb"); if (!f) { return NULL; @@ -45,6 +46,9 @@ static char *read_file(const char *path, int *out_len) { long size = ftell(f); (void)fseek(f, 0, SEEK_SET); if (size <= 0 || size > cbm_max_file_bytes()) { /* generous, env-configurable cap (B4) */ + if (size == 0) { + *out_len = 0; + } (void)fclose(f); return NULL; } @@ -661,11 +665,17 @@ static CBMFileResult *sem_get_or_extract(cbm_pipeline_ctx_t *ctx, int file_idx, int source_len = 0; char *source = read_file(fi->path, &source_len); if (!source) { + if (source_len != 0) { + (void)cbm_pipeline_test_extraction_ok(ctx, fi->language, NULL); + } return NULL; } - CBMFileResult *r = cbm_extract_file(source, source_len, fi->language, ctx->project_name, - fi->rel_path, CBM_EXTRACT_BUDGET, NULL, NULL); + CBMFileResult *r = cbm_pipeline_test_force_extract_null(ctx, fi->language) ? NULL : + cbm_extract_file_ex_with_tests( + source, source_len, fi->language, ctx->project_name, fi->rel_path, + CBM_EXTRACT_BUDGET, NULL, NULL, NULL, NULL, ctx->test_declarations); cbm_free(CBM_MEM_CLASS_SEMANTIC, source); + (void)cbm_pipeline_test_extraction_ok(ctx, fi->language, r); if (r) { *owned = true; } @@ -721,10 +731,20 @@ int cbm_pipeline_pass_semantic(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *f bool result_owned = false; CBMFileResult *result = sem_get_or_extract(ctx, i, &files[i], &result_owned); if (!result) { + if (atomic_load(&ctx->test_declarations_failed)) { + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } errors++; continue; } + if (!cbm_pipeline_test_result_ok(ctx, result)) { + if (result_owned) { + cbm_free_result(result); + } + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } + /* Build import map for resolution */ const char **imp_keys = NULL; const char **imp_vals = NULL; diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index f6c04ece2..169dbeffc 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -56,6 +56,7 @@ static bool pu_module_is_dir(CBMLanguage lang) { /* Read file into heap buffer. Caller must free(). */ static char *read_file(const char *path, int *out_len) { + *out_len = -1; /* unknown/read failure; zero is a proven empty file */ FILE *f = cbm_fopen(path, "rb"); if (!f) { return NULL; @@ -64,6 +65,9 @@ static char *read_file(const char *path, int *out_len) { long size = ftell(f); (void)fseek(f, 0, SEEK_SET); if (size <= 0 || size > cbm_max_file_bytes()) { /* generous, env-configurable cap (B4) */ + if (size == 0) { + *out_len = 0; + } (void)fclose(f); return NULL; } @@ -426,12 +430,22 @@ int cbm_pipeline_pass_usages(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *fil int source_len = 0; char *source = read_file(path, &source_len); if (!source) { + if (source_len != 0 && + !cbm_pipeline_test_extraction_ok(ctx, files[i].language, NULL)) { + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } errors++; continue; } - result = cbm_extract_file(source, source_len, files[i].language, ctx->project_name, rel, - CBM_EXTRACT_BUDGET, NULL, NULL); + result = cbm_pipeline_test_force_extract_null(ctx, files[i].language) ? NULL : + cbm_extract_file_ex_with_tests( + source, source_len, files[i].language, ctx->project_name, rel, + CBM_EXTRACT_BUDGET, NULL, NULL, NULL, NULL, ctx->test_declarations); free(source); + if (!cbm_pipeline_test_extraction_ok(ctx, files[i].language, result)) { + cbm_free_result(result); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } if (!result) { errors++; continue; @@ -439,6 +453,13 @@ int cbm_pipeline_pass_usages(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *fil result_owned = true; } + if (!cbm_pipeline_test_result_ok(ctx, result)) { + if (result_owned) { + cbm_free_result(result); + } + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } + if (result->usages.count == 0 && result->throws.count == 0 && result->rw.count == 0) { if (result_owned) { cbm_free_result(result); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index ba41b877c..21d8b5541 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -45,6 +45,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6 }; #include #include +#include #include #include #include @@ -180,6 +181,7 @@ void cbm_pipeline_unlock(void) { /* ── Internal state ──────────────────────────────────────────────── */ struct cbm_pipeline { + cbm_pipeline_frozen_t *frozen; /* NULL for every ordinary caller */ char *repo_path; char *db_path; char *project_name; @@ -228,6 +230,10 @@ struct cbm_pipeline { /* User-defined extension overrides (loaded once per run) */ cbm_userconfig_t *userconfig; + cbm_test_declarations_t *test_declarations; /* same project bytes, run-owned */ +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + unsigned test_fault_mask; +#endif /* Committed graph size at dump time (-1 = dump did not run). #334 gate axis. */ int committed_nodes; @@ -268,6 +274,326 @@ struct cbm_pipeline { void *rename_hook_ctx; }; +/* Private, single-use candidate owner; never a provenance certificate. */ +enum { FROZEN_READY, FROZEN_BUILDING, FROZEN_CANDIDATE, FROZEN_FAILED }; +struct cbm_pipeline_frozen { + CBMArena arena; + cbm_pipeline_t *pipeline; + const char *git_props; /* owned, complete JSON for frozen Branch/HAS_BRANCH */ + atomic_int *external_cancelled; + int state; + unsigned diagnostic_flags; /* monotone; independent of record storage */ + cbm_pipeline_frozen_status_t failure; + cbm_file_hash_t *baseline; + int baseline_count; + char canonical_root[CBM_SZ_4K]; + char canonical_parent[CBM_SZ_4K]; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + cbm_pipeline_frozen_route_observer_t observer; + void *observer_context; + cbm_pipeline_frozen_diag_fault_t diagnostic_fault; +#endif +}; + +bool cbm_pipeline_is_frozen(const cbm_pipeline_t *p) { return p && p->frozen; } + +int cbm_pipeline_frozen_checkpoint(cbm_pipeline_t *p) { + if (!p || !p->frozen) + return 0; + atomic_int *external = p->frozen->external_cancelled; + if (external && atomic_load(external)) + atomic_store(p->cancelled, 1); + return atomic_load(p->cancelled) ? CBM_NOT_FOUND : 0; +} + +const cbm_git_context_t *cbm_pipeline_frozen_git(const cbm_pipeline_t *p) { + return cbm_pipeline_is_frozen(p) ? &p->git_ctx : NULL; +} + +const cbm_userconfig_t *cbm_pipeline_frozen_config(const cbm_pipeline_t *p) { + return cbm_pipeline_is_frozen(p) ? p->userconfig : NULL; +} + +static void frozen_observe(cbm_pipeline_t *p, unsigned kind, unsigned workers) { +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + if (p->frozen && p->frozen->observer) { + cbm_pipeline_frozen_route_event_t event = {.kind = (cbm_pipeline_frozen_route_kind_t)kind, + .worker_count = workers}; + p->frozen->observer(&event, p->frozen->observer_context); + } +#else + (void)p; + (void)kind; + (void)workers; +#endif +} + +void cbm_pipeline_frozen_manifest_dispatch(cbm_pipeline_t *p) { + if (cbm_pipeline_is_frozen(p)) + frozen_observe(p, 1, 1); +} + +static bool frozen_string(const char *s, bool nonempty) { + if (!s || (nonempty && !s[0])) + return false; + for (size_t i = 0; i < CBM_SZ_4K; i++) { + if (s[i] == '\0') + return true; + } + return false; +} + +static bool frozen_absolute(const char *s) { + if (!frozen_string(s, true)) + return false; +#ifdef _WIN32 + if ((s[0] == '\\' && s[1] == '\\') || (s[0] == '/' && s[1] == '/')) + return true; + bool drive_letter = (s[0] >= 'A' && s[0] <= 'Z') || (s[0] >= 'a' && s[0] <= 'z'); + return drive_letter && s[1] == ':' && (s[2] == '/' || s[2] == '\\'); +#else + return s[0] == '/'; +#endif +} + +static bool frozen_oid(const char *oid) { + if (!frozen_string(oid, true)) + return false; + size_t len = strlen(oid); + if (len != 40 && len != 64) + return false; + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)oid[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) + return false; + } + return true; +} + +static bool frozen_git_valid(const cbm_git_context_t *git) { + if (!git || !git->is_git || !git->root_exists) + return false; + const char *strings[] = {git->input_path, git->worktree_root, git->git_dir, + git->git_common_dir, git->canonical_root, git->branch, + git->branch_slug}; + for (size_t i = 0; i < sizeof(strings) / sizeof(strings[0]); i++) { + if (!frozen_string(strings[i], i >= 4)) + return false; + } + return frozen_oid(git->head_sha) && frozen_oid(git->base_sha) && + strlen(git->head_sha) == strlen(git->base_sha); +} + +static bool frozen_policy_valid(const cbm_index_resource_policy_t *policy) { + if (policy->max_files.enabled && + (policy->max_files.value == 0 || policy->max_files.value > CBM_INDEX_MAX_FILES_VALUE)) + return false; + if (policy->max_source_bytes.enabled && + (policy->max_source_bytes.value == 0 || + policy->max_source_bytes.value > CBM_INDEX_MAX_SOURCE_MB_VALUE * CBM_INDEX_MIB_BYTES)) + return false; + return true; +} + +static bool frozen_config_span_valid(const cbm_pipeline_frozen_inputs_t *in) { + if (in->config_state == CBM_USERCONFIG_SOURCE_ABSENT) + return !in->config_bytes && in->config_len == 0; + return in->config_state == CBM_USERCONFIG_SOURCE_PRESENT && in->config_bytes; +} + +static bool frozen_inputs_valid(const cbm_pipeline_frozen_inputs_t *in) { + return in && frozen_absolute(in->source_root) && frozen_absolute(in->candidate_db_path) && + frozen_string(in->project, true) && cbm_validate_project_name(in->project) && + frozen_git_valid(in->pinned_git) && frozen_policy_valid(&in->resource_policy) && + frozen_config_span_valid(in); +} + +static bool frozen_copy_git(cbm_git_context_t *to, const cbm_git_context_t *from) { + to->is_git = from->is_git; + to->is_worktree = from->is_worktree; + to->is_detached = from->is_detached; + to->root_exists = from->root_exists; + char **targets[] = {&to->input_path, &to->worktree_root, &to->git_dir, + &to->git_common_dir, &to->canonical_root, &to->branch, + &to->branch_slug, &to->head_sha, &to->base_sha}; + const char *sources[] = {from->input_path, from->worktree_root, from->git_dir, + from->git_common_dir, from->canonical_root, from->branch, + from->branch_slug, from->head_sha, from->base_sha}; + for (size_t i = 0; i < sizeof(targets) / sizeof(targets[0]); i++) { + *targets[i] = strdup(sources[i]); + if (!*targets[i]) + return false; + } + for (size_t i = 0; to->head_sha[i]; i++) { + to->head_sha[i] = (char)tolower((unsigned char)to->head_sha[i]); + to->base_sha[i] = (char)tolower((unsigned char)to->base_sha[i]); + } + return true; +} + +static cbm_pipeline_frozen_status_t frozen_config(cbm_pipeline_t *p, + const cbm_pipeline_frozen_inputs_t *in) { + cbm_userconfig_snapshot_status_t status = cbm_userconfig_from_project_bytes( + in->config_state, in->config_bytes, in->config_len, &p->userconfig); + switch (status) { + case CBM_USERCONFIG_SNAPSHOT_OOM: + return CBM_PIPELINE_FROZEN_OOM; + case CBM_USERCONFIG_SNAPSHOT_LIMIT: + return CBM_PIPELINE_FROZEN_LIMIT; + case CBM_USERCONFIG_SNAPSHOT_UNSUPPORTED: + return CBM_PIPELINE_FROZEN_UNSUPPORTED; + case CBM_USERCONFIG_SNAPSHOT_INVALID: + return CBM_PIPELINE_FROZEN_CONFIG; + case CBM_USERCONFIG_SNAPSHOT_OK: + break; + default: + return CBM_PIPELINE_FROZEN_CONFIG; + } + const char *bytes = NULL; + size_t len = 0; + cbm_userconfig_source_state_t state = + cbm_userconfig_project_source(p->userconfig, &bytes, &len); + p->test_declarations = + cbm_test_declarations_parse(bytes, len, state == CBM_USERCONFIG_SOURCE_ABSENT); + if (!p->test_declarations) + return CBM_PIPELINE_FROZEN_CONFIG; + CBMFileResult validation = {0}; + cbm_arena_init_lazy(&validation.arena, CBM_SZ_4K); + bool valid = cbm_test_declarations_validate(&validation, p->test_declarations); + CBMTestExtractStatus validation_status = validation.test_declarations_status; + cbm_arena_destroy(&validation.arena); + if (validation_status == CBM_TEST_EXTRACT_OOM) + return CBM_PIPELINE_FROZEN_OOM; + return valid ? CBM_PIPELINE_FROZEN_OK : CBM_PIPELINE_FROZEN_UNSUPPORTED; +} + +static cbm_pipeline_frozen_status_t frozen_git_props(cbm_pipeline_frozen_t *owner) { + /* Six serialized string values, worst-case six-byte JSON escaping, plus + * fixed keys/booleans. Input validation caps each string below 4096 bytes. */ + enum { FROZEN_GIT_PROPS_CAP = 6 * CBM_SZ_4K * 6 + CBM_SZ_4K }; + char *json = cbm_arena_alloc(&owner->arena, FROZEN_GIT_PROPS_CAP); + if (!json) + return CBM_PIPELINE_FROZEN_OOM; + if (cbm_git_context_props_json(&owner->pipeline->git_ctx, json, FROZEN_GIT_PROPS_CAP) <= 0) + return CBM_PIPELINE_FROZEN_PIPELINE_ERROR; + owner->git_props = json; + return CBM_PIPELINE_FROZEN_OK; +} + +static cbm_pipeline_frozen_status_t +frozen_allocate_pipeline(cbm_pipeline_frozen_t *owner, const cbm_pipeline_frozen_inputs_t *in) { + cbm_pipeline_t *p = calloc(1, sizeof(*p)); + if (!p) + return CBM_PIPELINE_FROZEN_OOM; + owner->pipeline = p; + p->frozen = owner; + p->repo_path = strdup(in->source_root); + p->db_path = strdup(in->candidate_db_path); + p->project_name = strdup(in->project); + p->requested_mode = CBM_MODE_FULL; + p->mode = CBM_MODE_FULL; + p->committed_nodes = -1; + p->committed_edges = -1; + p->resource_policy = in->resource_policy; + atomic_init(&p->cancelled_storage, 0); + p->cancelled = &p->cancelled_storage; + if (!p->repo_path || !p->db_path || !p->project_name || + !frozen_copy_git(&p->git_ctx, in->pinned_git)) + return CBM_PIPELINE_FROZEN_OOM; + p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx); + if (!p->branch_qn) + return CBM_PIPELINE_FROZEN_OOM; + cbm_pipeline_frozen_status_t status = frozen_git_props(owner); + return status == CBM_PIPELINE_FROZEN_OK ? frozen_config(p, in) : status; +} + +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_create(const cbm_pipeline_frozen_inputs_t *inputs, + cbm_pipeline_frozen_t **out) { + if (!out) + return CBM_PIPELINE_FROZEN_INVALID; + *out = NULL; + if (!frozen_inputs_valid(inputs)) + return CBM_PIPELINE_FROZEN_INVALID; + CBMArena arena; + cbm_arena_init_lazy(&arena, CBM_ARENA_DEFAULT_BLOCK_SIZE); + cbm_pipeline_frozen_t *owner = cbm_arena_calloc(&arena, sizeof(*owner)); + if (!owner) { + cbm_arena_destroy(&arena); + return CBM_PIPELINE_FROZEN_OOM; + } + owner->arena = arena; + owner->external_cancelled = inputs->cancelled; + cbm_pipeline_frozen_status_t status = frozen_allocate_pipeline(owner, inputs); + if (status != CBM_PIPELINE_FROZEN_OK) { + cbm_pipeline_frozen_free(owner); + return status; + } + *out = owner; + return CBM_PIPELINE_FROZEN_OK; +} + +void cbm_pipeline_frozen_cancel(cbm_pipeline_frozen_t *owner) { + if (owner) + cbm_pipeline_cancel(owner->pipeline); +} + +const cbm_pipeline_t *cbm_pipeline_frozen_diagnostics(const cbm_pipeline_frozen_t *owner) { + return owner ? owner->pipeline : NULL; +} + +bool cbm_pipeline_frozen_negative_evidence(const cbm_pipeline_frozen_t *owner, + unsigned *out_flags) { + if (out_flags) + *out_flags = 0; + if (!owner || !out_flags || owner->state == FROZEN_BUILDING) + return false; + *out_flags = owner->diagnostic_flags; + return true; +} + +const char *cbm_pipeline_frozen_candidate_path(const cbm_pipeline_frozen_t *owner) { + return owner && owner->state == FROZEN_CANDIDATE ? owner->pipeline->db_path : NULL; +} + +void cbm_pipeline_frozen_free(cbm_pipeline_frozen_t *owner) { + if (!owner) + return; + cbm_pipeline_free_semantic_manifest(owner->baseline, owner->baseline_count); + cbm_pipeline_free(owner->pipeline); + CBMArena arena = owner->arena; + cbm_arena_destroy(&arena); +} + +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_set_diagnostic_fault_for_tests( + cbm_pipeline_frozen_t *owner, cbm_pipeline_frozen_diag_fault_t fault) { + if (!owner || owner->state != FROZEN_READY || + (unsigned)fault > CBM_PIPELINE_FROZEN_DIAG_FAULT_PHASE) + return CBM_PIPELINE_FROZEN_INVALID; + owner->diagnostic_fault = fault; + return CBM_PIPELINE_FROZEN_OK; +} + +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_set_route_observer_for_tests( + cbm_pipeline_frozen_t *owner, cbm_pipeline_frozen_route_observer_t observer, void *context) { + if (!owner || owner->state != FROZEN_READY) + return CBM_PIPELINE_FROZEN_INVALID; + owner->observer = observer; + owner->observer_context = observer ? context : NULL; + return CBM_PIPELINE_FROZEN_OK; +} + +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_set_publish_hooks_for_tests( + cbm_pipeline_frozen_t *owner, void (*before_publish)(cbm_pipeline_t *, const char *, void *), + int (*rename_stage)(const char *, const char *, void *), void *context) { + if (!owner || owner->state != FROZEN_READY) + return CBM_PIPELINE_FROZEN_INVALID; + cbm_pipeline_set_before_publish_hook_for_tests(owner->pipeline, before_publish, context); + cbm_pipeline_set_rename_hook_for_tests(owner->pipeline, rename_stage, context); + return CBM_PIPELINE_FROZEN_OK; +} +#endif + /* ── Global pkgmap (one active pipeline at a time) ─────────────── */ static CBMHashTable *g_pkgmap = NULL; @@ -431,6 +757,19 @@ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *row p->surface_row_count = count; } +static void pipeline_release_test_config(cbm_pipeline_t *p) { + if (!p) { + return; + } + if (p->userconfig && cbm_get_user_lang_config() == p->userconfig) { + cbm_set_user_lang_config(NULL); + } + cbm_test_declarations_free(p->test_declarations); + p->test_declarations = NULL; + cbm_userconfig_free(p->userconfig); + p->userconfig = NULL; +} + void cbm_pipeline_free(cbm_pipeline_t *p) { if (!p) { return; @@ -463,12 +802,8 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { p->surface_row_count = 0; cbm_git_context_free(&p->git_ctx); /* gbuf, store, registry freed during/after run */ - /* Defensively free userconfig in case run() was never called or panicked */ - if (p->userconfig) { - cbm_set_user_lang_config(NULL); - cbm_userconfig_free(p->userconfig); - p->userconfig = NULL; - } + /* Defensive owner cleanup; normal runs release after publication. */ + pipeline_release_test_config(p); free(p); } @@ -505,6 +840,65 @@ const char *cbm_pipeline_project_name(const cbm_pipeline_t *p) { return p ? p->project_name : NULL; } +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +void cbm_pipeline_test_set_fault_mask(cbm_pipeline_t *p, unsigned mask) { + if (p) { + p->test_fault_mask = mask; + } +} +unsigned cbm_pipeline_test_fault_mask(const cbm_pipeline_t *p) { + return p ? p->test_fault_mask : 0; +} +#endif + +const cbm_test_declarations_t *cbm_pipeline_test_declarations(const cbm_pipeline_t *p) { + return p ? p->test_declarations : NULL; +} + +bool cbm_pipeline_project_source_matches(const cbm_pipeline_t *p, + const cbm_userconfig_t *fresh) { + const char *current = NULL; + size_t current_len = 0; + cbm_userconfig_source_state_t current_state = + cbm_userconfig_project_source(fresh, ¤t, ¤t_len); + if (!p || current_state == CBM_USERCONFIG_SOURCE_ERROR) { + return false; + } + if (!p->userconfig) { + return true; /* standalone manifest inspection, no active run */ + } + const char *original = NULL; + size_t original_len = 0; + cbm_userconfig_source_state_t original_state = + cbm_userconfig_project_source(p->userconfig, &original, &original_len); + return original_state != CBM_USERCONFIG_SOURCE_ERROR && original_state == current_state && + original_len == current_len && + (original_len == 0 || memcmp(original, current, original_len) == 0); +} + +static bool pipeline_load_test_config(cbm_pipeline_t *p) { + p->userconfig = cbm_userconfig_load_with_source(p->repo_path); + const char *bytes = NULL; + size_t len = 0; + cbm_userconfig_source_state_t state = + cbm_userconfig_project_source(p->userconfig, &bytes, &len); + if (state == CBM_USERCONFIG_SOURCE_ERROR) { + return false; + } + p->test_declarations = cbm_test_declarations_parse( + bytes, len, state == CBM_USERCONFIG_SOURCE_ABSENT); + if (!p->test_declarations) { + return false; + } + /* Validate even for an empty repository: unsupported consumed requests + * cannot disappear merely because discovery finds no source files. */ + CBMFileResult validation = {0}; + cbm_arena_init(&validation.arena); + bool valid = cbm_test_declarations_validate(&validation, p->test_declarations); + cbm_arena_destroy(&validation.arena); + return valid; +} + const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p) { return p ? p->repo_path : NULL; } @@ -547,11 +941,109 @@ static char *fe_strdup(const char *s) { return d; } +/* Private indices select only the frozen recorder's allocation boundaries. */ +enum { FROZEN_DIAG_GROW, FROZEN_DIAG_PATH, FROZEN_DIAG_REASON, FROZEN_DIAG_PHASE }; + +static bool frozen_diag_fail_allocation(cbm_pipeline_t *p, unsigned allocation) { +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + static const cbm_pipeline_frozen_diag_fault_t faults[] = { + CBM_PIPELINE_FROZEN_DIAG_FAULT_GROW, CBM_PIPELINE_FROZEN_DIAG_FAULT_PATH, + CBM_PIPELINE_FROZEN_DIAG_FAULT_REASON, CBM_PIPELINE_FROZEN_DIAG_FAULT_PHASE}; + if (p->frozen->diagnostic_fault == faults[allocation]) { + p->frozen->diagnostic_fault = CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE; + return true; + } +#else + (void)p; + (void)allocation; +#endif + return false; +} + +static void frozen_diag_incomplete(cbm_pipeline_t *p, bool oom) { + p->frozen->diagnostic_flags |= CBM_PIPELINE_FROZEN_DIAGNOSTIC_INCOMPLETE; + if (oom) + p->frozen->diagnostic_flags |= CBM_PIPELINE_FROZEN_DIAGNOSTIC_OOM; +} + +static bool frozen_diag_reserve(cbm_pipeline_t *p) { + if (p->file_errors_count < 0 || p->file_errors_cap < 0 || + p->file_errors_count > p->file_errors_cap) { + frozen_diag_incomplete(p, false); + return false; + } + if (p->file_errors_count < p->file_errors_cap) + return true; + if (p->file_errors_cap > INT_MAX / 2) { + frozen_diag_incomplete(p, false); + return false; + } + int capacity = p->file_errors_cap ? p->file_errors_cap * 2 : 16; + if ((size_t)capacity > SIZE_MAX / sizeof(*p->file_errors)) { + frozen_diag_incomplete(p, false); + return false; + } + cbm_file_error_t *grown = + frozen_diag_fail_allocation(p, FROZEN_DIAG_GROW) + ? NULL + : realloc(p->file_errors, (size_t)capacity * sizeof(*p->file_errors)); + if (!grown) { + frozen_diag_incomplete(p, true); + return false; + } + p->file_errors = grown; + p->file_errors_cap = capacity; + return true; +} + +static char *frozen_diag_copy(cbm_pipeline_t *p, const char *source, unsigned allocation) { + size_t length = strlen(source); + if (length == SIZE_MAX) { + frozen_diag_incomplete(p, false); + return NULL; + } + char *copy = frozen_diag_fail_allocation(p, allocation) ? NULL : malloc(length + 1); + if (!copy) { + frozen_diag_incomplete(p, true); + return NULL; + } + memcpy(copy, source, length + 1); + return copy; +} + +static void frozen_add_file_error(cbm_pipeline_t *p, const char *path, const char *reason, + const char *phase) { + p->frozen->diagnostic_flags |= CBM_PIPELINE_FROZEN_FILE_DIAGNOSTIC; + if (!path || !reason || !phase) { + frozen_diag_incomplete(p, false); + return; + } + if (!frozen_diag_reserve(p)) + return; + cbm_file_error_t row = {0}; + char **copies[] = {&row.path, &row.reason, &row.phase}; + const char *sources[] = {path, reason, phase}; + for (unsigned i = 0; i < 3; i++) { + *copies[i] = frozen_diag_copy(p, sources[i], FROZEN_DIAG_PATH + i); + if (!*copies[i]) { + free(row.path); + free(row.reason); + free(row.phase); + return; + } + } + p->file_errors[p->file_errors_count++] = row; +} + void cbm_pipeline_add_file_error(cbm_pipeline_t *p, const char *path, const char *reason, const char *phase) { if (!p) { return; } + if (p->frozen) { + frozen_add_file_error(p, path, reason, phase); + return; + } if (p->file_errors_count >= p->file_errors_cap) { int ncap = p->file_errors_cap ? p->file_errors_cap * 2 : 16; cbm_file_error_t *grown = @@ -658,6 +1150,8 @@ static char *resolve_db_path(const cbm_pipeline_t *p) { } static int check_cancel(const cbm_pipeline_t *p) { + if (p->frozen && p->frozen->external_cancelled && atomic_load(p->frozen->external_cancelled)) + atomic_store(p->cancelled, 1); return atomic_load(p->cancelled) ? CBM_NOT_FOUND : 0; } @@ -724,8 +1218,9 @@ static int pass_structure(cbm_pipeline_t *p, const cbm_file_info_t *files, int f const char *branch_qn = p->branch_qn ? p->branch_qn : p->project_name; const char *branch_name = p->git_ctx.branch ? p->git_ctx.branch : "working-tree"; char branch_props[CBM_SZ_2K]; - const char *branch_props_json = "{}"; - if (cbm_git_context_props_json(&p->git_ctx, branch_props, sizeof(branch_props)) > 0) { + const char *branch_props_json = p->frozen ? p->frozen->git_props : "{}"; + if (!p->frozen && + cbm_git_context_props_json(&p->git_ctx, branch_props, sizeof(branch_props)) > 0) { branch_props_json = branch_props; } if (p->branch_qn) { @@ -1344,8 +1839,16 @@ static void run_predump_passes(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { * no extracted results to feed cross-file resolution. */ static int seq_pass_lsp_cross_dispatch(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count) { - if (!ctx || !ctx->result_cache) + if (!ctx) { return 0; + } + if (!ctx->result_cache) { + if (atomic_load(&ctx->test_definition_owners_seen)) { + atomic_store(&ctx->test_declarations_failed, 1); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } + return 0; + } /* Cross-file LSP runs in every mode. */ return cbm_pipeline_pass_lsp_cross(ctx, files, file_count, ctx->result_cache); } @@ -1448,13 +1951,16 @@ static int run_sequential_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, for (int si = 0; si < PL_SEQ_PASSES && rc == 0; si++) { cbm_clock_gettime(CLOCK_MONOTONIC, t); int pr = seq_passes[si].fn(ctx, files, file_count); - if (pr != 0 && !seq_passes[si].ignore_err) { + if (pr == CBM_PIPELINE_ABORT_PRESERVE_DB || + atomic_load(&ctx->test_declarations_failed)) { + rc = CBM_PIPELINE_ABORT_PRESERVE_DB; + } else if (pr != 0 && !seq_passes[si].ignore_err) { rc = pr; } cbm_log_info("pass.timing", "pass", seq_passes[si].name, "elapsed_ms", itoa_buf((int)elapsed_ms(*t))); pipeline_phase_mark(seq_passes[si].name); - if (check_cancel(p)) { + if (rc != CBM_PIPELINE_ABORT_PRESERVE_DB && check_cancel(p)) { rc = CBM_NOT_FOUND; } } @@ -1616,6 +2122,10 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, def_modules, &def_count, def_starts) : NULL; } + if (def_count < 0) { + atomic_store(&ctx->test_declarations_failed, 1); + def_count = 0; /* keep metric counts valid; the fatal latch survives */ + } CBM_PROF_END_N("lsp_cross_prepare", "1_collect_all_defs", t_collect_defs, def_count); /* Serialize per-file LSP surfaces NOW — the result cache dies with this * pass, and the rows are what lets an incremental run detect body-only @@ -1904,6 +2414,16 @@ static bool promote_mode_to_existing_coverage(cbm_pipeline_t *p) { /* Defined below, next to the other publication helpers. */ static char *create_staging_path(const char *final_path); +static char *create_staging_path_impl(const char *final_path, cbm_pipeline_t *frozen); +static int publish_staged_impl(char *path, const cbm_pipeline_generation_t *generation, + bool fts_wholesale, bool healthy, cbm_pipeline_t *frozen); +static int finalize_staged_impl(char *path, const char *destination, atomic_int *cancelled, + bool healthy, cbm_pipeline_t *frozen); + +static void frozen_cleanup_note(cbm_pipeline_t *p, int status) { + if (p && p->frozen && status != 0) + p->frozen->failure = CBM_PIPELINE_FROZEN_IO; +} /* ── Stage ownership (#1839) ───────────────────────────────────── * @@ -1985,6 +2505,17 @@ void cbm_pipeline_stage_lock_drop(const char *stage_path, int lock_fd) { } } +static int stage_lock_drop_checked(const char *path, int fd) { + char *sidecar = stage_lock_sidecar_path(path); + cbm_pipeline_stage_lock_drop(path, fd); + if (!sidecar) + return CBM_NOT_FOUND; + cbm_path_info_t info; + bool absent = cbm_path_info_utf8(sidecar, &info) == CBM_PATH_INFO_ABSENT; + free(sidecar); + return absent ? 0 : CBM_NOT_FOUND; +} + /* Record an already-held stage lock in the per-process owner table, keyed by * path so publish/finalize/discard can release it later. On success the table * owns lock_fd; on failure the caller still does and must drop it. @@ -2013,10 +2544,9 @@ static bool stage_owner_adopt(const char *stage_path, int lock_fd) { /* Release ownership of a stage that no longer exists under this name. A path * this process never registered is a no-op. */ -static void stage_owner_release(const char *stage_path) { - if (!stage_path) { - return; - } +static int stage_owner_release(const char *stage_path) { + if (!stage_path) + return 0; stage_owner_t *found = NULL; stage_owners_lock(); for (stage_owner_t **link = &g_stage_owners; *link; link = &(*link)->next) { @@ -2027,26 +2557,35 @@ static void stage_owner_release(const char *stage_path) { } } stage_owners_unlock(); - if (!found) { - return; - } - cbm_pipeline_stage_lock_drop(found->stage_path, found->lock_fd); + if (!found) + return 0; + int rc = stage_lock_drop_checked(found->stage_path, found->lock_fd); free(found->stage_path); free(found); + return rc; } -/* Remove a stage's main file and SQLite sidecars, keeping ownership. */ -static void remove_stage_files(const char *stage_path) { - (void)cbm_unlink(stage_path); - (void)cbm_remove_db_sidecars(stage_path); -} - -static void discard_generation_stage(const char *stage_path) { - if (!stage_path) { - return; +/* Remove only owned stage files; status is consumed by the frozen path. */ +static int remove_stage_files(const char *stage_path) { + int rc = cbm_unlink(stage_path); + if (rc != 0) { + cbm_path_info_t info; + rc = cbm_path_info_utf8(stage_path, &info) == CBM_PATH_INFO_ABSENT ? 0 : CBM_NOT_FOUND; } - remove_stage_files(stage_path); - stage_owner_release(stage_path); + int sidecars = cbm_remove_db_sidecars(stage_path); + return rc == 0 && sidecars == 0 ? 0 : CBM_NOT_FOUND; +} + +static int discard_generation_stage(const char *stage_path) { + if (!stage_path) + return 0; + int rc = remove_stage_files(stage_path); + int release = stage_owner_release(stage_path); + return rc == 0 && release == 0 ? 0 : CBM_NOT_FOUND; +} + +static void discard_generation_frozen(const char *path, cbm_pipeline_t *frozen) { + frozen_cleanup_note(frozen, discard_generation_stage(path)); } typedef struct { @@ -2201,7 +2740,8 @@ static int prepare_existing_generation_for_replace(const char *db_path, return 0; } -int cbm_pipeline_publish_generation(const cbm_pipeline_generation_t *generation) { +static int publish_generation_impl(const cbm_pipeline_generation_t *generation, + cbm_pipeline_t *frozen) { if (!generation || !generation->gbuf || !generation->final_db_path || !generation->project || generation->manifest_count < 0 || (generation->manifest_count > 0 && !generation->manifest) || @@ -2224,31 +2764,31 @@ int cbm_pipeline_publish_generation(const cbm_pipeline_generation_t *generation) * The old unlink-first step goes with it. It existed to clear a leftover * file at a name we might reuse; a freshly minted name cannot collide, * and its sidecars cannot pre-exist either. */ - char *stage_path = create_staging_path(generation->final_db_path); + char *stage_path = create_staging_path_impl(generation->final_db_path, frozen); if (!stage_path) { return CBM_PIPELINE_PERSIST_FAILED; } int dump_rc = cbm_gbuf_dump_to_sqlite(generation->gbuf, stage_path); if (dump_rc != 0) { - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); free(stage_path); return CBM_PIPELINE_PERSIST_FAILED; } #if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API if (cbm_pipeline_persist_test_take_failure_after_stage_dump()) { - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); free(stage_path); return CBM_PIPELINE_PERSIST_FAILED; } #endif if (generation->cancelled && atomic_load(generation->cancelled)) { - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); free(stage_path); return CBM_PIPELINE_ABORT_PRESERVE_DB; } - return cbm_pipeline_publish_staged(stage_path, generation, true, false); + return publish_staged_impl(stage_path, generation, true, false, frozen); } /* Complete and publish an already-materialized staging database: metadata @@ -2257,13 +2797,14 @@ int cbm_pipeline_publish_generation(const cbm_pipeline_generation_t *generation) * the dump path's delete-all-and-rebuild; the delta path passes false * because its patch step already wrote row-level FTS inserts for exactly * the nodes it created. */ -int cbm_pipeline_publish_staged(char *stage_path, const cbm_pipeline_generation_t *generation, - bool fts_wholesale, bool destination_known_healthy) { +static int publish_staged_impl(char *stage_path, const cbm_pipeline_generation_t *generation, + bool fts_wholesale, bool destination_known_healthy, + cbm_pipeline_t *frozen) { struct timespec t_pub; cbm_clock_gettime(CLOCK_MONOTONIC, &t_pub); cbm_store_t *store = cbm_store_open_path(stage_path); if (!store) { - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); free(stage_path); return CBM_PIPELINE_PERSIST_FAILED; } @@ -2348,16 +2889,25 @@ int cbm_pipeline_publish_staged(char *stage_path, const cbm_pipeline_generation_ cbm_log_info("publish.timing", "block", "seal", "elapsed_ms", itoa_buf((int)elapsed_ms(t_pub))); cbm_store_close(store); if (!ok) { - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); free(stage_path); return CBM_PIPELINE_PERSIST_FAILED; } - int fin_rc = cbm_pipeline_finalize_staged_generation( - stage_path, generation->final_db_path, generation->cancelled, destination_known_healthy); + int fin_rc = finalize_staged_impl(stage_path, generation->final_db_path, generation->cancelled, + destination_known_healthy, frozen); free(stage_path); return fin_rc; } +int cbm_pipeline_publish_generation(const cbm_pipeline_generation_t *generation) { + return publish_generation_impl(generation, NULL); +} + +int cbm_pipeline_publish_staged(char *path, const cbm_pipeline_generation_t *generation, + bool fts_wholesale, bool healthy) { + return publish_staged_impl(path, generation, fts_wholesale, healthy, NULL); +} + char *cbm_pipeline_create_staging_path(const char *final_path) { return create_staging_path(final_path); } @@ -2371,8 +2921,8 @@ void cbm_pipeline_discard_stage(const char *stage_path) { * previous generation, and atomically rename. Owns discarding the stage on * every failure path. The store handle must already be CLOSED — sidecar * removal and rename act on the bare file. */ -int cbm_pipeline_finalize_staged_generation(char *stage_path, const char *final_db_path, - atomic_int *cancelled, bool destination_known_healthy) { +static int finalize_staged_impl(char *stage_path, const char *final_db_path, atomic_int *cancelled, + bool destination_known_healthy, cbm_pipeline_t *frozen) { struct timespec t_fin; cbm_clock_gettime(CLOCK_MONOTONIC, &t_fin); if (cbm_remove_db_sidecars(stage_path) != 0) { @@ -2385,11 +2935,11 @@ int cbm_pipeline_finalize_staged_generation(char *stage_path, const char *final_ char errno_text[16]; (void)snprintf(errno_text, sizeof(errno_text), "%d", errno); cbm_log_error("finalize.sidecar_removal_failed", "errno", errno_text, "stage", stage_path); - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); return CBM_PIPELINE_PERSIST_FAILED; } if (cancelled && atomic_load(cancelled)) { - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); return CBM_PIPELINE_ABORT_PRESERVE_DB; } cbm_log_info("finalize.timing", "block", "stage_sidecars", "elapsed_ms", @@ -2411,11 +2961,11 @@ int cbm_pipeline_finalize_staged_generation(char *stage_path, const char *final_ (void)snprintf(errno_text, sizeof(errno_text), "%d", errno); cbm_log_error("finalize.prepare_failed", "reason", "healthy_sidecar_cleanup", "errno", errno_text, "path", final_db_path); - cbm_pipeline_discard_stage(stage_path); + discard_generation_frozen(stage_path, frozen); return CBM_PIPELINE_PERSIST_FAILED; } } else if (prepare_existing_generation_for_replace(final_db_path, &prepared, false) != 0) { - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); return CBM_PIPELINE_PERSIST_FAILED; } #if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API @@ -2425,7 +2975,7 @@ int cbm_pipeline_finalize_staged_generation(char *stage_path, const char *final_ #endif if (cancelled && atomic_load(cancelled)) { int rollback_rc = rollback_quarantined_generation(final_db_path, &prepared); - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); return rollback_rc == 0 ? CBM_PIPELINE_ABORT_PRESERVE_DB : CBM_PIPELINE_PERSIST_FAILED; } cbm_log_info("finalize.timing", "block", "prepare_live", "elapsed_ms", @@ -2437,15 +2987,20 @@ int cbm_pipeline_finalize_staged_generation(char *stage_path, const char *final_ cbm_log_error("finalize.rename_failed", "errno", errno_text, "stage", stage_path, "dest", final_db_path); (void)rollback_quarantined_generation(final_db_path, &prepared); - discard_generation_stage(stage_path); + discard_generation_frozen(stage_path, frozen); return CBM_PIPELINE_PERSIST_FAILED; } - stage_owner_release(stage_path); + frozen_cleanup_note(frozen, stage_owner_release(stage_path)); cbm_log_info("finalize.timing", "block", "rename", "elapsed_ms", itoa_buf((int)elapsed_ms(t_fin))); return 0; } +int cbm_pipeline_finalize_staged_generation(char *path, const char *destination, + atomic_int *cancelled, bool healthy) { + return finalize_staged_impl(path, destination, cancelled, healthy, NULL); +} + /* Dump graph to SQLite and persist file hashes for incremental indexing. */ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *baseline_manifest, int baseline_count, struct timespec *t) { @@ -2488,8 +3043,14 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas return manifest_rc == CBM_DISCOVER_LIMIT_EXCEEDED ? CBM_PIPELINE_RESOURCE_LIMIT : CBM_PIPELINE_ABORT_PRESERVE_DB; } - if (!cbm_pipeline_semantic_manifests_equal(baseline_manifest, baseline_count, manifest, - manifest_count)) { + bool manifests_equal = + p->frozen ? cbm_pipeline_frozen_manifests_equal(p, baseline_manifest, baseline_count, + manifest, manifest_count) + : cbm_pipeline_semantic_manifests_equal(baseline_manifest, baseline_count, + manifest, manifest_count); + if (!manifests_equal) { + if (p->frozen) + p->frozen->failure = CBM_PIPELINE_FROZEN_INPUT_CHANGED; cbm_log_warn("pipeline.abort", "reason", "semantic_inputs_changed"); cbm_pipeline_free_semantic_manifest(manifest, manifest_count); free(db_dir); @@ -2555,7 +3116,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas * committed_nodes at 0, so the #334 plausibility gate never fired. */ p->committed_nodes = cbm_gbuf_node_count(p->gbuf); p->committed_edges = cbm_gbuf_edge_count(p->gbuf); - int rc = cbm_pipeline_publish_generation(&generation); + int rc = publish_generation_impl(&generation, p->frozen ? p : NULL); free(cov); cbm_pipeline_free_semantic_manifest(manifest, manifest_count); if (rc != 0) { @@ -2581,6 +3142,12 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas /* Run githistory pass. */ static int run_githistory(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { + if (p->frozen) { + if (check_cancel(p)) + return CBM_NOT_FOUND; + frozen_observe(p, 2, 0); + return 0; + } struct timespec t_gh; cbm_clock_gettime(CLOCK_MONOTONIC, &t_gh); @@ -2690,12 +3257,17 @@ static int run_extraction_phase(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, return CBM_NOT_FOUND; } - int worker_count = effective_worker_count(true); + int worker_count = p->frozen ? 1 : effective_worker_count(true); + if (p->frozen) + frozen_observe(p, 0, 1); CBM_PROF_START(t_extract_total); int rc = (worker_count > SKIP_ONE && file_count > MIN_FILES_FOR_PARALLEL) ? run_parallel_pipeline(p, ctx, files, file_count, worker_count, &t) : run_sequential_pipeline(p, ctx, files, file_count, &t); CBM_PROF_END_N("pipeline", "2_extraction_total", t_extract_total, file_count); + if (rc == CBM_PIPELINE_ABORT_PRESERVE_DB) { + return rc; + } if (check_cancel(p)) { return CBM_NOT_FOUND; } @@ -2721,12 +3293,12 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { bool restore_requested_discovery = false; p->mode = p->requested_mode; - bool mode_promoted = promote_mode_to_existing_coverage(p); + bool mode_promoted = p->frozen ? false : promote_mode_to_existing_coverage(p); /* cbm_pipeline_new() may precede the actual run by an arbitrary interval. * Refresh once here, then use this exact snapshot for both Branch graph * construction and the baseline semantic manifest. */ - if (pipeline_refresh_git_context(p) != 0) { + if (!p->frozen && pipeline_refresh_git_context(p) != 0) { return CBM_NOT_FOUND; } @@ -2735,9 +3307,13 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { * and fast skip them entirely. Set before any extraction dispatch. */ cbm_set_macro_extraction(p->mode == CBM_MODE_FULL); - /* Load user-defined extension overrides (fail-open: NULL on error) */ + /* One project read supplies extensions, declarations and manifest digest. + * Snapshot ownership extends through the outer publication guard. */ CBM_PROF_START(t_userconfig); - p->userconfig = cbm_userconfig_load(p->repo_path); + if (!p->frozen && !pipeline_load_test_config(p)) { + cbm_log_error("pipeline.err", "phase", "test_declarations_config"); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } cbm_set_user_lang_config(p->userconfig); CBM_PROF_END("pipeline", "0_userconfig_load", t_userconfig); @@ -2782,7 +3358,10 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { /* Snapshot every semantic input once before routing/extraction. The same * bytes drive exact no-op comparison and are checked against a fresh * rediscovery immediately before any replacement is published. */ - rc = mode_promoted + rc = p->frozen ? cbm_pipeline_build_frozen_manifest(p, files, file_count, p->excluded_dirs, + p->excluded_count, &baseline_manifest, + &baseline_count) + : mode_promoted ? cbm_pipeline_build_fresh_semantic_manifest(p, p->project_name, &baseline_manifest, &baseline_count) : cbm_pipeline_build_semantic_manifest(p->project_name, p->repo_path, files, @@ -2796,8 +3375,9 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { } /* Check for existing DB → try incremental or delete for reindex */ - rc = try_incremental_or_delete_db(p, files, file_count, baseline_manifest, baseline_count, - mode_promoted); + rc = p->frozen ? CBM_PIPELINE_FORCE_FULL_REINDEX + : try_incremental_or_delete_db(p, files, file_count, baseline_manifest, + baseline_count, mode_promoted); if (rc == CBM_PIPELINE_ABORT_PRESERVE_DB || rc == CBM_PIPELINE_PERSIST_FAILED) { goto cleanup; } @@ -2844,6 +3424,11 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { /* Phase 2: Create graph buffer and registry */ p->gbuf = cbm_gbuf_new(p->project_name, p->repo_path); p->registry = cbm_registry_new(); + if (p->frozen && (!p->gbuf || !p->registry)) { + p->frozen->failure = CBM_PIPELINE_FROZEN_OOM; + rc = CBM_NOT_FOUND; + goto cleanup; + } /* Phase 2b: Load build-tool path aliases (tsconfig/jsconfig today). NULL * when no usable configs are found — non-TS projects pay nothing. */ @@ -2858,6 +3443,10 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { .registry = p->registry, .cancelled = p->cancelled, .pipeline = p, /* so passes can record per-file skips (Track B) */ + .test_declarations = p->test_declarations, +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + .test_fault_mask = p->test_fault_mask, +#endif .mode = (int)p->mode, .path_aliases = path_aliases, .excluded_dirs = p->excluded_dirs, @@ -2882,7 +3471,12 @@ cleanup: cbm_pkgmap_free(cbm_pipeline_get_pkgmap()); cbm_pipeline_set_pkgmap(NULL); cbm_discover_free(files, file_count); - cbm_pipeline_free_semantic_manifest(baseline_manifest, baseline_count); + if (p->frozen) { + p->frozen->baseline = baseline_manifest; + p->frozen->baseline_count = baseline_count; + } else { + cbm_pipeline_free_semantic_manifest(baseline_manifest, baseline_count); + } cbm_gbuf_free(p->gbuf); p->gbuf = NULL; cbm_registry_free(p->registry); @@ -2897,10 +3491,9 @@ cleanup: p->ignored_count = requested_ignored_count; p->ignored_total = requested_ignored_total; } - /* Clear and free user extension config */ + /* Workers have joined. Stop publishing the extension override, but keep + * both immutable owners for the outer final source comparison. */ cbm_set_user_lang_config(NULL); - cbm_userconfig_free(p->userconfig); - p->userconfig = NULL; return rc; } @@ -2981,98 +3574,86 @@ static size_t stage_root_length(const char *path) { return stage_suffix_at(path + root_len) ? root_len : len; } -static char *create_staging_path(const char *final_path) { - if (!final_path) { +static char *stage_allocate_template(const char *final_path) { + if (!final_path) return NULL; - } static const char suffix[] = ".stage.XXXXXX"; size_t final_len = stage_root_length(final_path); - if (final_len > SIZE_MAX - sizeof(suffix)) { + if (final_len > SIZE_MAX - sizeof(suffix)) return NULL; - } size_t path_size = final_len + sizeof(suffix); #ifdef _WIN32 - /* The Windows cbm_mkstemp compatibility contract may expand a /tmp/ - * prefix in-place and copies through a 4 KiB scratch path. Give it that - * full capacity, and reject longer inputs exactly rather than truncating. */ - if (path_size > CBM_SZ_4K) { + if (path_size > CBM_SZ_4K) return NULL; - } path_size = CBM_SZ_4K; #endif - char *path = (char *)malloc(path_size); - if (!path) { - return NULL; + char *path = malloc(path_size); + if (path) { + memcpy(path, final_path, final_len); + memcpy(path + final_len, suffix, sizeof(suffix)); } - memcpy(path, final_path, final_len); - memcpy(path + final_len, suffix, sizeof(suffix)); - /* The six random chars sit directly after the ".stage." marker; each - * attempt overwrites the "XXXXXX" template in place. Alphanumerics only, - * matching stage_suffix_at()/stage_entry_stage_length() so the sweep - * recognises the minted name and its sidecars. */ - char *random_at = path + final_len + (sizeof(cbm_stage_marker) - 1); - static const char alphabet[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return path; +} - /* Lock BEFORE the stage becomes visible: take the sidecar lock first, then - * create the stage's main file with O_EXCL. The main file is therefore - * never present on disk without its lock already held, so a concurrent - * run's sweep_orphan_stages() against the same final_path can only ever - * find this stage lock-held -- it reaches the stage through the .lock - * sidecar too (stage_entry_stage_length() matches it), probes the lock, - * sees a live holder, and keeps it. That closes the old create->register - * window that let a racing sweep delete a live-but-unlocked stage (POSIX) - * or collide on the sidecar with EACCES (Windows) -- #2111's windows-guards - * red. A pre-lock-era orphan minted by an OLDER binary still carries no - * lock, so the sweep's ENOENT path still removes it (#1839 preserved). - * - * A minted suffix collides with an existing stage only about 1 in 62^6; - * retry a bounded number of times, the way mkstemp/mkdtemp do, then fail. */ - for (int attempt = 0; attempt < 128; attempt++) { - unsigned char rnd[CBM_STAGE_SUFFIX_RANDOM_CHARS]; - if (!cbm_secure_random(rnd, sizeof(rnd))) { - free(path); - errno = EIO; - return NULL; - } - for (size_t i = 0; i < sizeof(rnd); i++) { - random_at[i] = alphabet[rnd[i] % (sizeof(alphabet) - 1)]; - } - errno = 0; - int lock_fd = cbm_pipeline_stage_lock_hold(path); - if (lock_fd < 0) { - /* A live twin already owns this exact suffix's sidecar (EAGAIN / - * EACCES), or the sidecar could not be created. Mint a fresh suffix - * and try again rather than contend for this one. */ - continue; - } - FILE *main_file = cbm_fopen(path, "wbx"); - if (!main_file) { - /* The suffix collided with a lock-less orphan's main file -- its - * sidecar was takeable, so it is not a live writer. Never inherit a - * stranger's bytes: drop the lock, remove the sidecar we just took, - * and mint a fresh suffix. The orphan's main file is left for a - * later sweep, which removes it as a pre-lock-era orphan. */ - cbm_pipeline_stage_lock_drop(path, lock_fd); - continue; - } - (void)fclose(main_file); +static int stage_abandon_creation(const char *path, int fd, cbm_pipeline_t *frozen) { + if (frozen) { + frozen_cleanup_note(frozen, remove_stage_files(path)); + frozen_cleanup_note(frozen, stage_lock_drop_checked(path, fd)); + } else { + cbm_pipeline_stage_lock_drop(path, fd); + (void)cbm_unlink(path); + } + return -1; +} + +/* 1: adopted; 0: collision/retry; -1: stop. Lock precedes O_EXCL visibility. */ +static int stage_create_attempt(char *path, char *random_at, cbm_pipeline_t *frozen) { + unsigned char random[CBM_STAGE_SUFFIX_RANDOM_CHARS]; + if (!cbm_secure_random(random, sizeof(random))) { + errno = EIO; + return -1; + } + static const char alphabet[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + for (size_t i = 0; i < sizeof(random); i++) + random_at[i] = alphabet[random[i] % (sizeof(alphabet) - 1)]; + errno = 0; + int fd = cbm_pipeline_stage_lock_hold(path); + if (fd < 0) { + if (frozen) + frozen->frozen->failure = CBM_PIPELINE_FROZEN_IO; + return frozen ? -1 : 0; + } + FILE *file = cbm_fopen(path, "wbx"); + if (!file) { + int rc = stage_lock_drop_checked(path, fd); + frozen_cleanup_note(frozen, rc); + return frozen && rc != 0 ? -1 : 0; + } + int close_rc = fclose(file); + if (frozen && close_rc != 0) + return stage_abandon_creation(path, fd, frozen); #if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API - /* Main file now exists and its lock is already held. Under the OLD - * create-then-lock ordering this was the unlocked window; the - * concurrent-sweep test fires here to prove the stage now survives a - * racing sweep, and to bind RED if that ordering ever regresses. */ - cbm_pipeline_persist_test_run_after_stage_created(); + cbm_pipeline_persist_test_run_after_stage_created(); #endif - if (!stage_owner_adopt(path, lock_fd)) { - cbm_pipeline_stage_lock_drop(path, lock_fd); - (void)cbm_unlink(path); + if (!stage_owner_adopt(path, fd)) + return stage_abandon_creation(path, fd, frozen); + return 1; +} + +static char *create_staging_path_impl(const char *final_path, cbm_pipeline_t *frozen) { + char *path = stage_allocate_template(final_path); + if (!path) + return NULL; + char *random_at = path + stage_root_length(final_path) + sizeof(cbm_stage_marker) - 1; + for (int attempt = 0; attempt < 128; attempt++) { + int rc = stage_create_attempt(path, random_at, frozen); + if (rc > 0) + return path; + if (rc < 0) { free(path); return NULL; } - return path; } - /* Every attempt failed to take a lock -- keep the observability the old - * stage_owner_register() emitted for a lock failure. */ char errno_text[16]; (void)snprintf(errno_text, sizeof(errno_text), "%d", errno); cbm_log_warn("pipeline.stage", "action", "lock_failed", "errno", errno_text, "path", path); @@ -3081,6 +3662,10 @@ static char *create_staging_path(const char *final_path) { return NULL; } +static char *create_staging_path(const char *final_path) { + return create_staging_path_impl(final_path, NULL); +} + /* A backup-failed destination may still have the only recoverable WAL or * rollback journal. Publication may replace its main file only when no * sidecar exists; otherwise fail without mutating the old generation. */ @@ -3414,6 +3999,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { if (!p) { return CBM_NOT_FOUND; } + pipeline_release_test_config(p); p->export_error[0] = '\0'; char *final_path = resolve_db_path(p); if (!final_path || !ensure_db_parent(final_path)) { @@ -3466,18 +4052,21 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { cleanup_staging_db(staging_path); free(staging_path); free(final_path); + pipeline_release_test_config(p); return rc; } if (check_cancel(p)) { cleanup_staging_db(staging_path); free(staging_path); free(final_path); + pipeline_release_test_config(p); return CBM_PIPELINE_ABORT_PRESERVE_DB; } if (seal_staging_db(staging_path) != 0) { cleanup_staging_db(staging_path); free(staging_path); free(final_path); + pipeline_release_test_config(p); return CBM_PIPELINE_PERSIST_FAILED; } @@ -3488,6 +4077,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { cleanup_staging_db(staging_path); free(staging_path); free(final_path); + pipeline_release_test_config(p); return CBM_PIPELINE_ABORT_PRESERVE_DB; } @@ -3497,9 +4087,24 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { cleanup_staging_db(staging_path); free(staging_path); free(final_path); + pipeline_release_test_config(p); return CBM_PIPELINE_PERSIST_FAILED; } + /* Check after the before-publish hook and the final seal, while the + * original bytes are still owned. ERROR and absent/present changes abort. */ + cbm_userconfig_t *fresh_config = cbm_userconfig_load_with_source(p->repo_path); + bool source_matches = cbm_pipeline_project_source_matches(p, fresh_config); + cbm_userconfig_free(fresh_config); + if (!source_matches) { + cbm_log_error("pipeline.err", "phase", "test_declarations_source_changed"); + cleanup_staging_db(staging_path); + free(staging_path); + free(final_path); + pipeline_release_test_config(p); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } + cbm_replacement_prepare_t prepared = {0}; int prepare_rc = prepare_publish_destination(final_path, final_existed, backup_succeeded, &prepared); @@ -3508,6 +4113,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { cleanup_staging_db(staging_path); free(staging_path); free(final_path); + pipeline_release_test_config(p); return prepare_rc; } if ((p->rename_hook ? p->rename_hook(staging_path, final_path, p->rename_hook_ctx) @@ -3520,6 +4126,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { cleanup_staging_db(staging_path); free(staging_path); free(final_path); + pipeline_release_test_config(p); return CBM_PIPELINE_PERSIST_FAILED; } @@ -3527,5 +4134,231 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { rc = export_after_publish(p, final_path); free(staging_path); free(final_path); + pipeline_release_test_config(p); return rc; } + +/* Frozen candidate publication: a private destination, never artifact export. */ +static bool frozen_separator(char c) { +#ifdef _WIN32 + return c == '/' || c == '\\'; +#else + return c == '/'; +#endif +} + +static unsigned char frozen_path_char(unsigned char c) { +#ifdef _WIN32 + if (c == '\\') + return '/'; + if (c >= 'A' && c <= 'Z') + return (unsigned char)(c + ('a' - 'A')); +#endif + return c; +} + +static bool frozen_same_path(const char *a, const char *b) { + while (*a && *b && frozen_path_char((unsigned char)*a) == frozen_path_char((unsigned char)*b)) { + a++; + b++; + } + return *a == '\0' && *b == '\0'; +} + +static bool frozen_under_root(const char *root, const char *path) { + size_t i = 0; + while (root[i] && path[i] && + frozen_path_char((unsigned char)root[i]) == frozen_path_char((unsigned char)path[i])) + i++; + return root[i] == '\0' && (path[i] == '\0' || frozen_separator(path[i]) || + (i > 0 && frozen_separator(root[i - 1]))); +} + +static bool frozen_parent_path(const char *path, char out[CBM_SZ_4K]) { + const char *last = NULL; + for (const char *at = path; *at; at++) { + if (frozen_separator(*at)) + last = at; + } + if (!last || !last[1] || strcmp(last + 1, ".") == 0 || strcmp(last + 1, "..") == 0) + return false; + size_t len = (size_t)(last - path); + if (len == 0) + len = 1; +#ifdef _WIN32 + if (len == 2 && path[1] == ':') + len = 3; +#endif + memcpy(out, path, len); + out[len] = '\0'; + return true; +} + +static cbm_pipeline_frozen_status_t frozen_absent_destination(const char *path) { + static const char *const suffixes[] = {"", "-wal", "-shm", "-journal"}; + for (size_t i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) { + char buffer[CBM_SZ_4K + 16]; + int n = snprintf(buffer, sizeof(buffer), "%s%s", path, suffixes[i]); + if (n < 0 || (size_t)n >= sizeof(buffer)) + return CBM_PIPELINE_FROZEN_INVALID; + cbm_path_info_t info; + int rc = cbm_path_info_utf8(buffer, &info); + if (rc == CBM_PATH_INFO_OK) + return CBM_PIPELINE_FROZEN_DEST_EXISTS; + if (rc != CBM_PATH_INFO_ABSENT) + return CBM_PIPELINE_FROZEN_IO; + } + return CBM_PIPELINE_FROZEN_OK; +} + +static cbm_pipeline_frozen_status_t frozen_check_directories(cbm_pipeline_frozen_t *owner, + bool initial) { + cbm_pipeline_t *p = owner->pipeline; + char parent[CBM_SZ_4K]; + if (!frozen_parent_path(p->db_path, parent)) + return CBM_PIPELINE_FROZEN_INVALID; + cbm_path_info_t root_info, parent_info; + if (cbm_path_info_utf8(p->repo_path, &root_info) != CBM_PATH_INFO_OK || + cbm_path_info_utf8(parent, &parent_info) != CBM_PATH_INFO_OK) + return CBM_PIPELINE_FROZEN_IO; + if (!root_info.is_directory || root_info.is_symlink || !parent_info.is_directory || + parent_info.is_symlink) + return CBM_PIPELINE_FROZEN_INVALID; + char canonical_root[CBM_SZ_4K], canonical_parent[CBM_SZ_4K]; + if (!cbm_canonical_path(p->repo_path, canonical_root, sizeof(canonical_root)) || + !cbm_canonical_path(parent, canonical_parent, sizeof(canonical_parent))) + return CBM_PIPELINE_FROZEN_IO; + if (frozen_under_root(canonical_root, canonical_parent)) + return CBM_PIPELINE_FROZEN_INVALID; + if (!initial && (!frozen_same_path(canonical_root, owner->canonical_root) || + !frozen_same_path(canonical_parent, owner->canonical_parent))) + return CBM_PIPELINE_FROZEN_INPUT_CHANGED; + if (initial) { + memcpy(owner->canonical_root, canonical_root, strlen(canonical_root) + 1); + memcpy(owner->canonical_parent, canonical_parent, strlen(canonical_parent) + 1); + } + return frozen_absent_destination(p->db_path); +} + +static cbm_pipeline_frozen_status_t frozen_diagnostic_status(const cbm_pipeline_t *p) { + unsigned flags = p->frozen->diagnostic_flags; + if (flags & CBM_PIPELINE_FROZEN_DIAGNOSTIC_OOM) + return CBM_PIPELINE_FROZEN_OOM; + return flags ? CBM_PIPELINE_FROZEN_PIPELINE_ERROR : CBM_PIPELINE_FROZEN_OK; +} + +static cbm_pipeline_frozen_status_t frozen_run_status(cbm_pipeline_t *p, int rc) { + if (p->frozen->failure == CBM_PIPELINE_FROZEN_IO) + return CBM_PIPELINE_FROZEN_IO; + if (check_cancel(p)) + return CBM_PIPELINE_FROZEN_CANCELLED; + if (p->frozen->failure != CBM_PIPELINE_FROZEN_OK) + return p->frozen->failure; + if (rc == CBM_PIPELINE_RESOURCE_LIMIT || rc == CBM_PIPELINE_ABORT_OVER_BUDGET) + return CBM_PIPELINE_FROZEN_LIMIT; + if (rc == CBM_PIPELINE_PERSIST_FAILED) + return CBM_PIPELINE_FROZEN_IO; + return rc == 0 ? frozen_diagnostic_status(p) : CBM_PIPELINE_FROZEN_PIPELINE_ERROR; +} + +static cbm_pipeline_frozen_status_t frozen_run_stage(cbm_pipeline_t *p, const char *stage) { + char *destination = p->db_path; + char *stage_copy = strdup(stage); + if (!stage_copy) + return CBM_PIPELINE_FROZEN_OOM; + p->db_path = stage_copy; + p->existing_generation = false; + p->final_existed = false; + int rc = cbm_pipeline_run_staged(p); + p->db_path = destination; + free(stage_copy); + return frozen_run_status(p, rc); +} + +static cbm_pipeline_frozen_status_t frozen_reconcile(cbm_pipeline_t *p) { + cbm_file_hash_t *fresh = NULL; + int count = 0; + int rc = cbm_pipeline_build_fresh_semantic_manifest(p, p->project_name, &fresh, &count); + bool equal = rc == 0 && cbm_pipeline_frozen_manifests_equal( + p, p->frozen->baseline, p->frozen->baseline_count, fresh, count); + cbm_pipeline_free_semantic_manifest(fresh, count); + if (check_cancel(p)) + return CBM_PIPELINE_FROZEN_CANCELLED; + if (rc == CBM_DISCOVER_LIMIT_EXCEEDED) + return CBM_PIPELINE_FROZEN_LIMIT; + if (rc != 0) + return CBM_PIPELINE_FROZEN_IO; + return equal ? CBM_PIPELINE_FROZEN_OK : CBM_PIPELINE_FROZEN_INPUT_CHANGED; +} + +static cbm_pipeline_frozen_status_t frozen_before_commit(cbm_pipeline_t *p, const char *stage) { + if (check_cancel(p)) + return CBM_PIPELINE_FROZEN_CANCELLED; + if (seal_staging_db(stage) != 0) + return CBM_PIPELINE_FROZEN_IO; + if (p->before_publish_hook) + p->before_publish_hook(p, stage, p->before_publish_hook_ctx); + if (check_cancel(p)) + return CBM_PIPELINE_FROZEN_CANCELLED; + if (seal_staging_db(stage) != 0) + return CBM_PIPELINE_FROZEN_IO; + cbm_pipeline_frozen_status_t status = frozen_reconcile(p); + if (status != CBM_PIPELINE_FROZEN_OK) + return status; + status = frozen_check_directories(p->frozen, false); + if (status != CBM_PIPELINE_FROZEN_OK) + return status; + return check_cancel(p) ? CBM_PIPELINE_FROZEN_CANCELLED : frozen_diagnostic_status(p); +} + +static cbm_pipeline_frozen_status_t frozen_commit(cbm_pipeline_t *p, const char *stage) { + int rc = p->rename_hook ? p->rename_hook(stage, p->db_path, p->rename_hook_ctx) + : cbm_rename_noreplace(stage, p->db_path); + if (rc != 0) { + cbm_pipeline_frozen_status_t absence = frozen_absent_destination(p->db_path); + return absence == CBM_PIPELINE_FROZEN_DEST_EXISTS ? absence : CBM_PIPELINE_FROZEN_IO; + } + cbm_path_info_t info; + if (cbm_path_info_utf8(p->db_path, &info) != CBM_PATH_INFO_OK || !info.is_regular || + info.is_symlink || cbm_path_info_utf8(stage, &info) != CBM_PATH_INFO_ABSENT) + return CBM_PIPELINE_FROZEN_IO; + return stage_owner_release(stage) == 0 ? CBM_PIPELINE_FROZEN_OK : CBM_PIPELINE_FROZEN_IO; +} + +static cbm_pipeline_frozen_status_t frozen_build_locked(cbm_pipeline_frozen_t *owner) { + cbm_pipeline_t *p = owner->pipeline; + if (check_cancel(p)) + return CBM_PIPELINE_FROZEN_CANCELLED; + cbm_pipeline_frozen_status_t status = frozen_check_directories(owner, true); + if (status != CBM_PIPELINE_FROZEN_OK) + return status; + char *stage = create_staging_path_impl(p->db_path, p); + if (!stage) + return CBM_PIPELINE_FROZEN_IO; + if (owner->failure == CBM_PIPELINE_FROZEN_IO) + status = CBM_PIPELINE_FROZEN_IO; + if (status == CBM_PIPELINE_FROZEN_OK) + status = frozen_run_stage(p, stage); + if (status == CBM_PIPELINE_FROZEN_OK) + status = frozen_before_commit(p, stage); + if (status == CBM_PIPELINE_FROZEN_OK) + status = frozen_commit(p, stage); + if (status != CBM_PIPELINE_FROZEN_OK && discard_generation_stage(stage) != 0) + status = CBM_PIPELINE_FROZEN_IO; + free(stage); + return status; +} + +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_build(cbm_pipeline_frozen_t *owner) { + if (!owner || owner->state != FROZEN_READY) + return CBM_PIPELINE_FROZEN_INVALID; + if (!cbm_pipeline_try_lock()) + return CBM_PIPELINE_FROZEN_BUSY; + owner->state = FROZEN_BUILDING; + const cbm_userconfig_t *previous = cbm_get_user_lang_config(); + cbm_pipeline_frozen_status_t status = frozen_build_locked(owner); + cbm_set_user_lang_config(previous); + cbm_pipeline_unlock(); + owner->state = status == CBM_PIPELINE_FROZEN_OK ? FROZEN_CANDIDATE : FROZEN_FAILED; + return status; +} diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 154518658..7be2c88d6 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -37,6 +37,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24 }; #include #include #include +#include /* ── Constants ───────────────────────────────────────────────────── */ @@ -122,6 +123,7 @@ typedef struct { int count; int cap; CBMHashTable *seen_paths; + cbm_pipeline_t *frozen; /* optional cooperative frozen checkpoint */ } semantic_manifest_builder_t; static int semantic_manifest_cmp(const void *a, const void *b) { @@ -149,48 +151,58 @@ void cbm_pipeline_free_semantic_manifest(cbm_file_hash_t *manifest, int count) { /* Hash a stable file generation. A concurrent edit is retried once; a second * race fails closed so a mixed-generation manifest can never be published. */ -static int semantic_manifest_hash_file(const char *abs_path, char out[CBM_SHA256_HEX_LEN + 1], - int64_t *mtime_ns, int64_t *size) { +static bool semantic_manifest_read_digest(const char *path, cbm_sha256_ctx *sha, + cbm_pipeline_t *frozen) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) + return false; + (void)setvbuf(file, NULL, _IONBF, 0); + unsigned char buf[CBM_SZ_64K]; + size_t len; + bool valid = true; + while ((len = fread(buf, 1, sizeof(buf), file)) > 0) { + if (cbm_pipeline_frozen_checkpoint(frozen)) { + valid = false; + break; + } + cbm_sha256_update(sha, buf, len); + } + valid = valid && !ferror(file); + if (fclose(file) != 0) + valid = false; + return valid; +} + +static bool semantic_manifest_regular(const char *path, cbm_path_info_t *info) { + return cbm_path_info_utf8(path, info) == 0 && info->is_regular && !info->is_symlink; +} + +static void semantic_manifest_finish_digest(cbm_sha256_ctx *sha, char out[CBM_SHA256_HEX_LEN + 1]) { + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(sha, digest); + static const char hex[] = "0123456789abcdef"; + for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 0x0f]; + } + out[CBM_SHA256_HEX_LEN] = '\0'; +} + +static int semantic_manifest_hash_file_ex(const char *abs_path, char out[CBM_SHA256_HEX_LEN + 1], + int64_t *mtime_ns, int64_t *size, + cbm_pipeline_t *frozen) { for (int attempt = 0; attempt < 2; attempt++) { - cbm_path_info_t before; - if (cbm_path_info_utf8(abs_path, &before) != 0 || !before.is_regular || before.is_symlink) { + cbm_path_info_t before, after; + if (!semantic_manifest_regular(abs_path, &before)) return CBM_NOT_FOUND; - } - FILE *f = cbm_fopen(abs_path, "rb"); - if (!f) { - return CBM_NOT_FOUND; - } - /* Reads go through buf below: a stdio buffer of its own was one unused - * 4 KB allocation per file (43 k on the Go corpus, waste sanitizer - * 2026-09-17). */ - (void)setvbuf(f, NULL, _IONBF, 0); cbm_sha256_ctx sha; cbm_sha256_init(&sha); - unsigned char buf[CBM_SZ_64K]; - size_t n; - while ((n = fread(buf, 1, sizeof(buf), f)) > 0) { - cbm_sha256_update(&sha, buf, n); - } - bool read_ok = !ferror(f); - if (fclose(f) != 0) { - read_ok = false; - } - cbm_path_info_t after; - if (!read_ok || cbm_path_info_utf8(abs_path, &after) != 0 || !after.is_regular || - after.is_symlink) { + if (!semantic_manifest_read_digest(abs_path, &sha, frozen) || + !semantic_manifest_regular(abs_path, &after)) return CBM_NOT_FOUND; - } - if (before.size != after.size || before.mtime_ns != after.mtime_ns) { + if (before.size != after.size || before.mtime_ns != after.mtime_ns) continue; - } - uint8_t digest[CBM_SHA256_DIGEST_LEN]; - cbm_sha256_final(&sha, digest); - static const char hex[] = "0123456789abcdef"; - for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) { - out[i * 2] = hex[digest[i] >> 4]; - out[i * 2 + 1] = hex[digest[i] & 0x0f]; - } - out[CBM_SHA256_HEX_LEN] = '\0'; + semantic_manifest_finish_digest(&sha, out); *mtime_ns = after.mtime_ns; *size = after.size; return 0; @@ -199,6 +211,27 @@ static int semantic_manifest_hash_file(const char *abs_path, char out[CBM_SHA256 return CBM_NOT_FOUND; } +static int semantic_manifest_hash_file(const char *path, char out[CBM_SHA256_HEX_LEN + 1], + int64_t *mtime, int64_t *size) { + return semantic_manifest_hash_file_ex(path, out, mtime, size, NULL); +} + +static bool semantic_manifest_reserve(semantic_manifest_builder_t *builder) { + if (builder->count != builder->cap) + return true; + if (builder->cap > INT_MAX / 2) + return false; + int capacity = builder->cap > 0 ? builder->cap * 2 : CBM_SZ_64; + if ((size_t)capacity > SIZE_MAX / sizeof(*builder->items)) + return false; + cbm_file_hash_t *items = realloc(builder->items, (size_t)capacity * sizeof(*items)); + if (!items) + return false; + builder->items = items; + builder->cap = capacity; + return true; +} + static int semantic_manifest_add_digest(semantic_manifest_builder_t *builder, const char *project, const char *rel_path, const char *sha, int64_t mtime_ns, int64_t size) { @@ -211,15 +244,8 @@ static int semantic_manifest_add_digest(semantic_manifest_builder_t *builder, co return CBM_NOT_FOUND; } } - if (builder->count == builder->cap) { - int new_cap = builder->cap > 0 ? builder->cap * 2 : CBM_SZ_64; - cbm_file_hash_t *grown = realloc(builder->items, (size_t)new_cap * sizeof(*builder->items)); - if (!grown) { - return CBM_NOT_FOUND; - } - builder->items = grown; - builder->cap = new_cap; - } + if (!semantic_manifest_reserve(builder)) + return CBM_NOT_FOUND; char *project_copy = strdup(project); char *path_copy = strdup(rel_path); char *sha_copy = strdup(sha); @@ -249,6 +275,8 @@ static int semantic_manifest_add_digest(semantic_manifest_builder_t *builder, co static int semantic_manifest_add(semantic_manifest_builder_t *builder, const char *project, const char *rel_path, const char *abs_path) { + if (cbm_pipeline_frozen_checkpoint(builder->frozen)) + return CBM_NOT_FOUND; if (!rel_path || !rel_path[0]) { return 0; } @@ -262,7 +290,7 @@ static int semantic_manifest_add(semantic_manifest_builder_t *builder, const cha char sha[CBM_SHA256_HEX_LEN + 1]; int64_t mtime_ns = 0; int64_t size = 0; - if (semantic_manifest_hash_file(abs_path, sha, &mtime_ns, &size) != 0) { + if (semantic_manifest_hash_file_ex(abs_path, sha, &mtime_ns, &size, builder->frozen) != 0) { cbm_log_error("semantic_manifest.err", "path", abs_path); return CBM_NOT_FOUND; } @@ -320,60 +348,61 @@ static bool semantic_manifest_package_control(const char *name) { (len >= 8 && strcmp(name + len - 8, ".gemspec") == 0); } +static int semantic_manifest_walk_controls(semantic_manifest_builder_t *builder, + const char *project, const char *abs_dir, + const char *rel_dir, int depth, char **excluded_dirs, + int excluded_count); + +static int semantic_manifest_control_entry(semantic_manifest_builder_t *builder, + const char *project, const char *abs_dir, + const char *rel_dir, int depth, char **excluded_dirs, + int excluded_count, const char *name) { + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) + return 0; + char abs_path[CBM_SZ_4K], rel_path[CBM_SZ_4K]; + int abs_n = snprintf(abs_path, sizeof(abs_path), "%s/%s", abs_dir, name); + int rel_n = rel_dir && rel_dir[0] ? snprintf(rel_path, sizeof(rel_path), "%s/%s", rel_dir, name) + : snprintf(rel_path, sizeof(rel_path), "%s", name); + if (abs_n < 0 || (size_t)abs_n >= sizeof(abs_path) || rel_n < 0 || + (size_t)rel_n >= sizeof(rel_path)) + return CBM_NOT_FOUND; + cbm_path_info_t info; + if (cbm_path_info_utf8(abs_path, &info) != 0) + return CBM_NOT_FOUND; + if (info.is_symlink) + return 0; + if (info.is_directory) { + if (cbm_should_skip_dir(name, CBM_MODE_FULL) || + cbm_pipeline_relpath_is_excluded(rel_path, excluded_dirs, excluded_count)) + return 0; + return semantic_manifest_walk_controls(builder, project, abs_path, rel_path, depth + 1, + excluded_dirs, excluded_count); + } + bool root_control = (!rel_dir || !rel_dir[0]) && (strcmp(name, ".cbmignore") == 0 || + strcmp(name, ".codebase-memory.json") == 0); + bool control = + strcmp(name, ".gitignore") == 0 || root_control || semantic_manifest_package_control(name); + return info.is_regular && control ? semantic_manifest_add(builder, project, rel_path, abs_path) + : 0; +} + static int semantic_manifest_walk_controls(semantic_manifest_builder_t *builder, const char *project, const char *abs_dir, const char *rel_dir, int depth, char **excluded_dirs, int excluded_count) { enum { MANIFEST_WALK_MAX_DEPTH = 64 }; - if (depth >= MANIFEST_WALK_MAX_DEPTH) { + if (depth >= MANIFEST_WALK_MAX_DEPTH) return 0; - } cbm_dir_t *dir = cbm_opendir(abs_dir); - if (!dir) { + if (!dir) return CBM_NOT_FOUND; - } int rc = 0; cbm_dirent_t *entry; while (rc == 0 && (entry = cbm_readdir(dir)) != NULL) { - const char *name = entry->name; - if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) { - continue; - } - char abs_path[CBM_SZ_4K]; - char rel_path[CBM_SZ_4K]; - int abs_n = snprintf(abs_path, sizeof(abs_path), "%s/%s", abs_dir, name); - int rel_n = rel_dir && rel_dir[0] - ? snprintf(rel_path, sizeof(rel_path), "%s/%s", rel_dir, name) - : snprintf(rel_path, sizeof(rel_path), "%s", name); - if (abs_n < 0 || abs_n >= (int)sizeof(abs_path) || rel_n < 0 || - rel_n >= (int)sizeof(rel_path)) { - rc = CBM_NOT_FOUND; - break; - } - cbm_path_info_t path_info; - if (cbm_path_info_utf8(abs_path, &path_info) != 0) { - rc = CBM_NOT_FOUND; - break; - } - if (path_info.is_symlink) { - continue; - } - if (path_info.is_directory) { - if (cbm_should_skip_dir(name, CBM_MODE_FULL) || - cbm_pipeline_relpath_is_excluded(rel_path, excluded_dirs, excluded_count)) { - continue; - } - rc = semantic_manifest_walk_controls(builder, project, abs_path, rel_path, depth + 1, - excluded_dirs, excluded_count); - continue; - } - bool root_control = - (!rel_dir || !rel_dir[0]) && - (strcmp(name, ".cbmignore") == 0 || strcmp(name, ".codebase-memory.json") == 0); - if (path_info.is_regular && (strcmp(name, ".gitignore") == 0 || root_control || - semantic_manifest_package_control(name))) { - rc = semantic_manifest_add(builder, project, rel_path, abs_path); - } + rc = cbm_pipeline_frozen_checkpoint(builder->frozen); + if (rc == 0) + rc = semantic_manifest_control_entry(builder, project, abs_dir, rel_dir, depth, + excluded_dirs, excluded_count, entry->name); } cbm_closedir(dir); return rc; @@ -601,6 +630,166 @@ bool cbm_pipeline_semantic_manifests_equal(const cbm_file_hash_t *left, int left return equal; } +/* Frozen manifests use the same rows and source controls, but never ambient + * config/Git refresh, parallel hashing, or non-cancellable qsort. */ +static int frozen_manifest_seed(semantic_manifest_builder_t *builder, cbm_pipeline_t *p) { + const char *project = cbm_pipeline_project_name(p); + const cbm_userconfig_t *config = cbm_pipeline_frozen_config(p); + char git_digest[CBM_SHA256_HEX_LEN + 1]; + semantic_manifest_git_digest(cbm_pipeline_frozen_git(p), git_digest); + int rc = semantic_manifest_add_digest(builder, project, CBM_SEMANTIC_INPUT_GIT_CONTEXT, + git_digest, 0, 0); + if (rc == 0) + rc = semantic_manifest_add_digest(builder, project, CBM_SEMANTIC_INPUT_GLOBAL_CONFIG, + config->global_source_sha256, 0, 0); + if (rc == 0) + rc = semantic_manifest_add_digest(builder, project, CBM_SEMANTIC_INPUT_PROJECT_CONFIG, + config->project_source_sha256, 0, 0); + return rc; +} + +static int frozen_manifest_aliases(semantic_manifest_builder_t *builder, cbm_pipeline_t *p, + char **excluded, int excluded_count) { + const char *root = cbm_pipeline_repo_path(p); + cbm_path_alias_collection_t *aliases = + cbm_load_path_aliases_excluded(root, excluded, excluded_count); + int rc = cbm_pipeline_frozen_checkpoint(p); + if (!aliases) + return rc; /* Existing API conflates no aliases with unavailable aliases. */ + for (int i = 0; rc == 0 && i < aliases->count; i++) { + rc = cbm_pipeline_frozen_checkpoint(p); + const char *rel = aliases->scopes[i].source_rel_path; + if (rc != 0 || !rel) + continue; + char abs_path[CBM_SZ_4K]; + int n = snprintf(abs_path, sizeof(abs_path), "%s/%s", root, rel); + rc = n < 0 || (size_t)n >= sizeof(abs_path) + ? CBM_NOT_FOUND + : semantic_manifest_add(builder, cbm_pipeline_project_name(p), rel, abs_path); + } + cbm_path_alias_collection_free(aliases); + return rc; +} + +static int frozen_manifest_sift(cbm_pipeline_t *p, cbm_file_hash_t *items, size_t root, + size_t count) { + while (root < count / 2) { + if (cbm_pipeline_frozen_checkpoint(p)) + return CBM_NOT_FOUND; + size_t child = root * 2 + 1; + if (child + 1 < count && semantic_manifest_cmp(&items[child], &items[child + 1]) < 0) + child++; + if (semantic_manifest_cmp(&items[root], &items[child]) >= 0) + break; + cbm_file_hash_t saved = items[root]; + items[root] = items[child]; + items[child] = saved; + root = child; + } + return 0; +} + +static int frozen_manifest_sort(cbm_pipeline_t *p, cbm_file_hash_t *items, int count) { + for (size_t i = (size_t)count / 2; i > 0; i--) { + if (frozen_manifest_sift(p, items, i - 1, (size_t)count)) + return CBM_NOT_FOUND; + } + for (size_t end = (size_t)count; end > 1; end--) { + if (cbm_pipeline_frozen_checkpoint(p)) + return CBM_NOT_FOUND; + cbm_file_hash_t saved = items[0]; + items[0] = items[end - 1]; + items[end - 1] = saved; + if (frozen_manifest_sift(p, items, 0, end - 1)) + return CBM_NOT_FOUND; + } + return cbm_pipeline_frozen_checkpoint(p); +} + +int cbm_pipeline_build_frozen_manifest(cbm_pipeline_t *p, const cbm_file_info_t *files, + int file_count, char **excluded, int excluded_count, + cbm_file_hash_t **out, int *out_count) { + if (!out || !out_count) + return CBM_NOT_FOUND; + *out = NULL; + *out_count = 0; + if (!cbm_pipeline_is_frozen(p) || file_count < 0 || (file_count && !files)) + return CBM_NOT_FOUND; + semantic_manifest_builder_t builder = {.frozen = p}; + builder.seen_paths = cbm_ht_create(CBM_SZ_64); + if (!builder.seen_paths) + return CBM_NOT_FOUND; + int rc = cbm_pipeline_frozen_checkpoint(p); + if (rc == 0) + rc = frozen_manifest_seed(&builder, p); + if (rc == 0 && file_count > 0) + cbm_pipeline_frozen_manifest_dispatch(p); + for (int i = 0; rc == 0 && i < file_count; i++) { + rc = cbm_pipeline_frozen_checkpoint(p); + if (rc == 0) + rc = semantic_manifest_add(&builder, cbm_pipeline_project_name(p), files[i].rel_path, + files[i].path); + } + if (rc == 0) + rc = semantic_manifest_walk_controls(&builder, cbm_pipeline_project_name(p), + cbm_pipeline_repo_path(p), "", 0, excluded, + excluded_count); + if (rc == 0) + rc = frozen_manifest_aliases(&builder, p, excluded, excluded_count); + if (rc == 0) + rc = frozen_manifest_sort(p, builder.items, builder.count); + cbm_ht_free(builder.seen_paths); + if (rc != 0) { + cbm_pipeline_free_semantic_manifest(builder.items, builder.count); + return rc; + } + *out = builder.items; + *out_count = builder.count; + return 0; +} + +bool cbm_pipeline_frozen_manifests_equal(cbm_pipeline_t *p, const cbm_file_hash_t *left, + int left_count, const cbm_file_hash_t *right, + int right_count) { + if (left_count < 0 || left_count != right_count || (left_count && (!left || !right))) + return false; + for (int i = 0; i < left_count; i++) { + if (cbm_pipeline_frozen_checkpoint(p) || strcmp(left[i].rel_path, right[i].rel_path) != 0 || + strcmp(left[i].sha256, right[i].sha256) != 0) + return false; + } + return cbm_pipeline_frozen_checkpoint(p) == 0; +} + +static int frozen_fresh_manifest(cbm_pipeline_t *p, cbm_file_hash_t **out, int *out_count) { + *out = NULL; + *out_count = 0; + if (cbm_pipeline_frozen_checkpoint(p)) + return CBM_NOT_FOUND; + cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL, + .resource_policy = cbm_pipeline_resource_policy(p), + .resource_violation = cbm_pipeline_resource_violation(p)}; + cbm_file_info_t *files = NULL; + int count = 0; + char **excluded = NULL; + int excluded_count = 0; + cbm_ignored_file_t *ignored = NULL; + int ignored_count = 0; + int ignored_total = 0; + const cbm_userconfig_t *previous = cbm_get_user_lang_config(); + cbm_set_user_lang_config(cbm_pipeline_frozen_config(p)); + int rc = cbm_discover_ex2(cbm_pipeline_repo_path(p), &opts, &files, &count, &excluded, + &excluded_count, &ignored, &ignored_count, &ignored_total); + if (rc == 0) + rc = cbm_pipeline_build_frozen_manifest(p, files, count, excluded, excluded_count, out, + out_count); + cbm_set_user_lang_config(previous); + cbm_discover_free(files, count); + cbm_discover_free_excluded(excluded, excluded_count); + cbm_discover_free_ignored(ignored, ignored_count); + return rc; +} + int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *project, cbm_file_hash_t **out, int *out_count) { const char *repo_path = cbm_pipeline_repo_path(p); @@ -609,6 +798,8 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr } *out = NULL; *out_count = 0; + if (cbm_pipeline_is_frozen(p)) + return frozen_fresh_manifest(p, out, out_count); cbm_discover_opts_t opts = { .mode = (cbm_index_mode_t)cbm_pipeline_get_mode(p), .ignore_file = NULL, @@ -623,10 +814,12 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr cbm_ignored_file_t *fresh_ignored = NULL; int fresh_ignored_count = 0; int fresh_ignored_total = 0; - cbm_userconfig_t *fresh_userconfig = cbm_userconfig_load(repo_path); + cbm_userconfig_t *fresh_userconfig = cbm_userconfig_load_with_source(repo_path); cbm_git_context_t fresh_git_ctx = {0}; const cbm_userconfig_t *previous_userconfig = cbm_get_user_lang_config(); - int rc = fresh_userconfig ? cbm_git_context_resolve(repo_path, &fresh_git_ctx) : CBM_NOT_FOUND; + int rc = cbm_pipeline_project_source_matches(p, fresh_userconfig) + ? cbm_git_context_resolve(repo_path, &fresh_git_ctx) + : CBM_PIPELINE_ABORT_PRESERVE_DB; if (rc == 0) { cbm_set_user_lang_config(fresh_userconfig); rc = cbm_discover_ex2(repo_path, &opts, &fresh_files, &fresh_file_count, &fresh_excluded, @@ -1105,6 +1298,7 @@ typedef struct { int count; int n_changed; int n_dependents; + int fatal_rc; /* configured probe failures cannot decline to a full retry */ cbm_lsp_surface_row_t *stored_rows; /* whole previous generation */ int stored_count; } closure_plan_t; @@ -1278,10 +1472,22 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed CBMModuleDefIndex *module_def_index = NULL; CBMCrossLspRegistries cross_registries = {0}; CBMCrossLspRegistries *registries_arg = NULL; + CBMArena legacy_cross_arena; + bool legacy_cross_arena_live = false; + bool owner_walk_required = atomic_load(&ctx->test_definition_owners_seen) != 0; + char cross_disabled[CBM_SZ_16]; + if (owner_walk_required && + cbm_safe_getenv("CBM_DISABLE_LSP_CROSS", cross_disabled, + sizeof(cross_disabled), NULL) != NULL) { + atomic_store(&ctx->test_declarations_failed, 1); + rc = CBM_PIPELINE_ABORT_PRESERVE_DB; + goto cross_resolve_cleanup; + } if (closure) { /* Fresh surfaces for the re-parsed files, from the same collect * path a full build uses; then registries over stored + fresh. */ - def_modules = (char **)calloc((size_t)ci, sizeof(char *)); + def_modules = cbm_pipeline_test_force_def_modules_null(ctx) ? NULL : + (char **)calloc((size_t)ci, sizeof(char *)); int *def_starts = (int *)calloc((size_t)ci + 1, sizeof(int)); int fresh_count = 0; CBMLSPDef *fresh_defs = @@ -1290,6 +1496,18 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed ctx->project_name, def_modules, &fresh_count, def_starts) : NULL; + if (fresh_count < 0 || (owner_walk_required && (!fresh_defs || fresh_count <= 0))) { + /* Do not substitute the previous generation's definitions for + * configured owners that failed fresh preparation. */ + free(def_starts); + free(fresh_defs); + closure->def_modules = def_modules; + closure->def_module_count = def_modules ? ci : 0; + def_modules = NULL; + atomic_store(&ctx->test_declarations_failed, 1); + rc = CBM_PIPELINE_ABORT_PRESERVE_DB; + goto cross_resolve_cleanup; + } if ((fresh_defs || fresh_count == 0) && def_starts && cbm_lsp_surface_build_rows(ctx, ctx->project_name, cache, changed_files, ci, fresh_defs, def_starts, &closure->fresh_rows, @@ -1341,16 +1559,40 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed /* The resolve workers borrow def_modules strings; ownership moves * to the closure ctx so they outlive resolve + calls. */ closure->def_modules = def_modules; - closure->def_module_count = ci; + closure->def_module_count = def_modules ? ci : 0; def_modules = NULL; cbm_log_info("incremental.closure_registries", "base", itoa_buf(closure->base_def_count), "fresh", itoa_buf(fresh_count), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); + } else if (owner_walk_required) { + /* The legacy route formerly passed no cross inputs. Prepare its + * changed-file definitions with the same helper as the full path; + * CALLS resolution still uses the loaded project-wide registry. + * Only configured owners require this additional cross walk. */ + cbm_arena_init(&legacy_cross_arena); + legacy_cross_arena_live = true; + def_modules = cbm_arena_alloc(&legacy_cross_arena, + (size_t)ci * sizeof(*def_modules)); + if (def_modules) { + memset(def_modules, 0, (size_t)ci * sizeof(*def_modules)); + all_defs = cbm_pxc_collect_all_defs( + ctx, &legacy_cross_arena, cache, changed_files, ci, + ctx->project_name, def_modules, &all_def_count, NULL); + } + if (!all_defs || all_def_count <= 0) { + atomic_store(&ctx->test_declarations_failed, 1); + rc = CBM_PIPELINE_ABORT_PRESERVE_DB; + goto cross_resolve_cleanup; + } + module_def_index = cbm_pxc_build_module_def_index(all_defs, all_def_count); } cbm_clock_gettime(CLOCK_MONOTONIC, &t); rc = cbm_parallel_resolve(ctx, changed_files, ci, cache, &shared_ids, worker_count, - all_defs, all_def_count, closure ? closure->def_modules : NULL, + all_defs, all_def_count, + closure ? closure->def_modules : def_modules, module_def_index, registries_arg); + +cross_resolve_cleanup: if (module_def_index) { cbm_pxc_free_module_def_index(module_def_index); } @@ -1361,6 +1603,15 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed itoa_buf((int)elapsed_ms(t))); cbm_gbuf_set_next_id(ctx->gbuf, atomic_load(&shared_ids)); free_incremental_result_cache(cache, ci); + if (legacy_cross_arena_live) { + free(all_defs); /* array returned by the existing collect helper */ + if (def_modules) { + for (int i = 0; i < ci; i++) { + free(def_modules[i]); /* existing module-QN helper ownership */ + } + } + cbm_arena_destroy(&legacy_cross_arena); + } return rc; } else { cbm_log_info("incremental.mode", "mode", "sequential", "changed", itoa_buf(ci)); @@ -1386,6 +1637,11 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed if (rc == 0) { rc = cbm_pipeline_check_cancel(ctx); } + /* Keep ordinary legacy routing unchanged; configured owners require + * the same origin-aware cross pass as a full sequential run. */ + if (rc == 0 && atomic_load(&ctx->test_definition_owners_seen)) { + rc = cbm_pipeline_pass_lsp_cross(ctx, changed_files, ci, cache); + } if (rc == 0) { rc = cbm_pipeline_pass_calls(ctx, changed_files, ci); } @@ -1408,6 +1664,20 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed free_incremental_result_cache(cache, ci); ctx->result_cache = prior_cache; } + /* Cross results borrow these registries/module strings through the + * last consumer above, exactly as in the full sequential driver. */ + if (ctx->seq_cross_arena_live) { + cbm_arena_destroy(&ctx->seq_cross_arena); + ctx->seq_cross_arena_live = false; + } + if (ctx->seq_cross_def_modules) { + for (int i = 0; i < ctx->seq_cross_def_module_count; i++) { + free(ctx->seq_cross_def_modules[i]); + } + free(ctx->seq_cross_def_modules); + ctx->seq_cross_def_modules = NULL; + ctx->seq_cross_def_module_count = 0; + } return rc; } } @@ -1578,6 +1848,10 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project, .gbuf = probe_gbuf, .registry = NULL, .cancelled = cbm_pipeline_cancelled_ptr(p), + .test_declarations = cbm_pipeline_test_declarations(p), +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + .test_fault_mask = cbm_pipeline_test_fault_mask(p), +#endif .pipeline = NULL, /* probe must not record file errors twice */ .mode = cbm_pipeline_get_mode(p), .path_aliases = aliases, @@ -1602,8 +1876,10 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project, if (def_modules && def_starts) { defs = cbm_pxc_collect_all_defs(NULL, &probe_arena, cache, probe_files, probe_count, project, def_modules, &def_count, def_starts); - rc = cbm_lsp_surface_build_rows(NULL, project, cache, probe_files, probe_count, defs, - def_starts, out_rows, out_count); + rc = def_count < 0 + ? CBM_PIPELINE_ABORT_PRESERVE_DB + : cbm_lsp_surface_build_rows(NULL, project, cache, probe_files, probe_count, + defs, def_starts, out_rows, out_count); } else { rc = -1; } @@ -1810,9 +2086,15 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p } probe_files[i] = *info; } - if (n_changed > 0 && closure_probe_surfaces(p, project, probe_files, n_changed, &probe_rows, - &probe_count) != 0) { + int probe_rc = n_changed > 0 + ? closure_probe_surfaces(p, project, probe_files, n_changed, + &probe_rows, &probe_count) + : 0; + if (probe_rc != 0) { cbm_ht_free(rows_by_path); + if (probe_rc == CBM_PIPELINE_ABORT_PRESERVE_DB) { + plan->fatal_rc = probe_rc; + } decline = "probe_failed"; goto done; } @@ -1934,7 +2216,9 @@ done: cbm_log_info("incremental.closure_decline", "reason", decline, "elapsed_ms", itoa_buf((int)elapsed_ms(t))); cbm_store_free_lsp_surfaces(plan->stored_rows, plan->stored_count); + int fatal_rc = plan->fatal_rc; memset(plan, 0, sizeof(*plan)); + plan->fatal_rc = fatal_rc; return 0; } return 1; @@ -2154,6 +2438,10 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .gbuf = gbuf, .registry = registry, .cancelled = cbm_pipeline_cancelled_ptr(p), + .test_declarations = cbm_pipeline_test_declarations(p), +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + .test_fault_mask = cbm_pipeline_test_fault_mask(p), +#endif .pipeline = p, .mode = cbm_pipeline_get_mode(p), .path_aliases = path_aliases, @@ -2198,6 +2486,9 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char cbm_pipeline_set_pkgmap(NULL); if (phase_rc != 0) { cbm_log_error("delta.err", "phase", "extract_resolve", "rc", itoa_buf(phase_rc)); + if (phase_rc == CBM_PIPELINE_ABORT_PRESERVE_DB) { + result = phase_rc; + } goto out; } cbm_log_info("delta.repair", "files", itoa_buf(ci), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); @@ -2374,8 +2665,10 @@ out: surface_name_set_free(stale_surface_set); if (cr_arena_live) { cbm_store_free_lsp_surfaces(cr.fresh_rows, cr.fresh_count); - for (int i = 0; i < cr.def_module_count; i++) { - free(cr.def_modules[i]); + if (cr.def_modules) { + for (int i = 0; i < cr.def_module_count; i++) { + free(cr.def_modules[i]); + } } free(cr.def_modules); for (int i = 0; i < cr.rehydrate_arena_count; i++) { @@ -2501,6 +2794,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil #if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API incr_test_set_last_route(CBM_INCREMENTAL_ROUTE_FORCED_FULL); #endif + if (closure_plan.fatal_rc) { + return closure_plan.fatal_rc; + } cbm_log_info("incremental.force_full", "reason", "semantic_manifest_changed"); return CBM_PIPELINE_FORCE_FULL_REINDEX; } @@ -2722,6 +3018,10 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .gbuf = existing, .registry = registry, .cancelled = cbm_pipeline_cancelled_ptr(p), + .test_declarations = cbm_pipeline_test_declarations(p), +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + .test_fault_mask = cbm_pipeline_test_fault_mask(p), +#endif .pipeline = p, /* so passes can record per-file skips (Track B) */ .mode = cbm_pipeline_get_mode(p), .path_aliases = path_aliases, diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 28daa5434..0f96c0b19 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -14,6 +14,7 @@ #include "store/store.h" #include "discover/discover.h" #include "discover/userconfig.h" +#include "discover/test_conventions.h" #include "git/git_context.h" #include "foundation/hash_table.h" #include "cbm.h" @@ -21,6 +22,181 @@ #include #include +typedef struct cbm_pipeline_frozen cbm_pipeline_frozen_t; + +typedef enum { + CBM_PIPELINE_FROZEN_OK = 0, + CBM_PIPELINE_FROZEN_INVALID, + CBM_PIPELINE_FROZEN_CONFIG, + CBM_PIPELINE_FROZEN_UNSUPPORTED, + CBM_PIPELINE_FROZEN_BUSY, + CBM_PIPELINE_FROZEN_DEST_EXISTS, + CBM_PIPELINE_FROZEN_INPUT_CHANGED, + CBM_PIPELINE_FROZEN_CANCELLED, + CBM_PIPELINE_FROZEN_LIMIT, + CBM_PIPELINE_FROZEN_OOM, + CBM_PIPELINE_FROZEN_IO, + CBM_PIPELINE_FROZEN_PIPELINE_ERROR +} cbm_pipeline_frozen_status_t; + +typedef struct { + /* Explicit native absolute paths. Source root and destination parent are + * private, caller-owned directories for the entire owner lifetime. + * DB is outside source_root and must not exist (including sidecars). + */ + const char *source_root; + const char *candidate_db_path; + const char *project; /* exact accepted project label; never silently renamed */ + + cbm_userconfig_source_state_t config_state; /* ABSENT or PRESENT */ + const void *config_bytes; + size_t config_len; /* PRESENT: strict JSON object, 1..65536 bytes */ + + /* Display/semantic-input data, NOT authenticated facts. All nine string + * members are required non-NULL and copied. head_sha is pinned H and + * base_sha is actual pinned merge-base M, each full 40/64 hex characters. + * Their lengths must match; copied OIDs normalize to lowercase. + * No resolver is invoked and no path in this context is traversed. + */ + const cbm_git_context_t *pinned_git; + + /* Existing discovery policy value, copied. Existing disabled/finite-limit + * semantics remain; this is not a new whole-process memory/time budget. + */ + cbm_index_resource_policy_t resource_policy; + + /* Optional external cancellation flag. Borrowed until owner free, including + * after build returns; caller initializes it and keeps it alive. Observed + * cancellation is latched internally even if caller later clears the flag. + * Sampled at owner/orchestration/manifest checkpoints. Nested existing + * consumers may see only the internal latch until the next such sample. + */ + atomic_int *cancelled; +} cbm_pipeline_frozen_inputs_t; + +/* Clear *out first. Deep-copy/validate all input; no Git/config filesystem + * reads, no pipeline/global changes, no directory/DB creation. Caller input + * buffers may be destroyed after return. NULL result on any failure. + */ +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_create(const cbm_pipeline_frozen_inputs_t *inputs, + cbm_pipeline_frozen_t **out); + +/* Synchronous, single-use after successful start. Internally try-acquires the + * existing global pipeline lock; BUSY leaves the owner READY and may retry. + * Fixed FULL / one worker / no Git history / no artifact export; no existing DB + * backup, incremental route or mode promotion. Final reconciliation compares + * the retained baseline with freshly read tree inputs using the OWNED config + * and Git context. OK means a sealed private candidate exists, not admission. + * No call is allowed to overlap build except cancel. Ordinary setters/run are + * never exposed on this owner's pipeline. See text for downstream limits. + */ +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_build(cbm_pipeline_frozen_t *owner); + +/* Thread-safe while owner is alive; sets the internal atomic immediately. + * No concurrent free. This does not await a sample of the external flag. */ +void cbm_pipeline_frozen_cancel(cbm_pipeline_frozen_t *owner); + +/* Borrowed immutable diagnostic source, only before/after (not during) build. + * Valid until owner free. Use existing const getters for file errors, ignored + * paths, exclusions, counts and resource violations. Never cast away const or + * pass this to a mutating ordinary-pipeline API. No completeness getter exists. + */ +const cbm_pipeline_t *cbm_pipeline_frozen_diagnostics(const cbm_pipeline_frozen_t *owner); + +/* Monotone negative observations; zero is NOT a completeness certificate. */ +enum { + CBM_PIPELINE_FROZEN_FILE_DIAGNOSTIC = 1, + CBM_PIPELINE_FROZEN_DIAGNOSTIC_INCOMPLETE = 2, + CBM_PIPELINE_FROZEN_DIAGNOSTIC_OOM = 4 +}; + +/* Clears supplied output first. Returns false for NULL owner/output or while + * BUILDING; otherwise copies the mask. No overlap with build is permitted. + * Evidence survives failed/invalid retries until owner free; no reset API. + */ +bool cbm_pipeline_frozen_negative_evidence(const cbm_pipeline_frozen_t *owner, + unsigned *out_flags); + +/* Borrowed copied path only after build returned OK; otherwise NULL. */ +const char *cbm_pipeline_frozen_candidate_path(const cbm_pipeline_frozen_t *owner); + +/* NULL-safe; never concurrent with build/cancel. Does not delete the caller's + * source tree, candidate DB or destination directory. Build cleans its own + * temporary stages; caller owns final namespace cleanup and later publication. + */ +void cbm_pipeline_frozen_free(cbm_pipeline_frozen_t *owner); + +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +/* One-shot failures at the frozen recorder's allocation boundaries only. */ +typedef enum { + CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE = 0, + CBM_PIPELINE_FROZEN_DIAG_FAULT_GROW = 1, + CBM_PIPELINE_FROZEN_DIAG_FAULT_PATH = 2, + CBM_PIPELINE_FROZEN_DIAG_FAULT_REASON = 3, + CBM_PIPELINE_FROZEN_DIAG_FAULT_PHASE = 4 +} cbm_pipeline_frozen_diag_fault_t; + +/* READY-only. Invalid input leaves the prior setting unchanged; NONE clears. + * A fault is consumed only at its matching allocation, not by other records + * or allocations. This simulates NULL, not physical allocator exhaustion. + */ +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_set_diagnostic_fault_for_tests( + cbm_pipeline_frozen_t *owner, cbm_pipeline_frozen_diag_fault_t fault); + +/* Actual dispatch observations, emitted only when the corresponding frozen + * orchestration branch is reached. worker_count includes the calling thread + * for a serial dispatch (1); it is 0 for HISTORY_SKIPPED. It says nothing about + * nested pass threads or successful completion of the dispatched work. + */ +typedef enum { + CBM_PIPELINE_FROZEN_ROUTE_EXTRACTION_WORKERS = 0, + CBM_PIPELINE_FROZEN_ROUTE_MANIFEST_WORKERS, + CBM_PIPELINE_FROZEN_ROUTE_HISTORY_SKIPPED +} cbm_pipeline_frozen_route_kind_t; + +typedef struct { + cbm_pipeline_frozen_route_kind_t kind; + unsigned worker_count; +} cbm_pipeline_frozen_route_event_t; + +typedef void (*cbm_pipeline_frozen_route_observer_t)(const cbm_pipeline_frozen_route_event_t *event, + void *context); + +/* READY-only, instance-local, synchronous observer. NULL clears it/context. + * The event is borrowed only during the callback; copy it to retain it. + * Context remains borrowed until cleared or owner free. The callback only + * records observations: no pipeline/store/scope reentry, mutation or freeing. + * No callback-count/order promise; repeated reconciliation may emit repeated + * manifest events. No globals and no effect on production routing or results. + */ +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_set_route_observer_for_tests( + cbm_pipeline_frozen_t *owner, cbm_pipeline_frozen_route_observer_t observer, void *context); + +/* A READY-only adapter to existing per-instance publication/rename hooks. + * No new fault allocator or phase/callback-count contract. These hooks may + * mutate fixture inputs/cancel or simulate rename failure. The before-publish + * hook may also invoke cbm_pipeline_add_file_error on its supplied pipeline. + * They cannot run a pipeline, free owner, install config/options, or + * publish/admit a witness. + */ +cbm_pipeline_frozen_status_t cbm_pipeline_frozen_set_publish_hooks_for_tests( + cbm_pipeline_frozen_t *owner, void (*before_publish)(cbm_pipeline_t *, const char *, void *), + int (*rename_stage)(const char *, const char *, void *), void *context); +#endif + +/* Frozen-only orchestration/manifest bridge; NULL/ordinary p are no-ops. */ +bool cbm_pipeline_is_frozen(const cbm_pipeline_t *p); +int cbm_pipeline_frozen_checkpoint(cbm_pipeline_t *p); +const cbm_git_context_t *cbm_pipeline_frozen_git(const cbm_pipeline_t *p); +const cbm_userconfig_t *cbm_pipeline_frozen_config(const cbm_pipeline_t *p); +void cbm_pipeline_frozen_manifest_dispatch(cbm_pipeline_t *p); +int cbm_pipeline_build_frozen_manifest(cbm_pipeline_t *p, const cbm_file_info_t *files, + int file_count, char **excluded, int excluded_count, + cbm_file_hash_t **out, int *out_count); +bool cbm_pipeline_frozen_manifests_equal(cbm_pipeline_t *p, const cbm_file_hash_t *left, + int left_count, const cbm_file_hash_t *right, + int right_count); + /* ── Shared pipeline constants ─────────────────────────────────── */ /* Per-file tree-sitter parse budget, in MICROSECONDS of this thread's CPU time @@ -104,6 +280,17 @@ typedef struct { * Indexed by file position in the files[] array. Owned by pipeline.c. */ CBMFileResult **result_cache; + /* Run-owned immutable snapshot; direct borrow also works for probe contexts + * whose pipeline back-pointer is NULL. Workers only write the fatal latch. */ + const cbm_test_declarations_t *test_declarations; + _Atomic int test_declarations_failed; + /* Negative-obligation witness, not a completeness certificate: at least + * one extracted definition needs the configured-owner cross walk. */ + _Atomic int test_definition_owners_seen; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + unsigned test_fault_mask; /* copied from one pipeline instance, immutable */ +#endif + /* Build-tool path aliases (tsconfig/jsconfig today; webpack/vite-style * configs are an easy follow-on). NULL when no usable configs were found. * Owned by pipeline.c / pipeline_incremental.c. */ @@ -158,6 +345,112 @@ typedef struct { bool spill_allowed; } cbm_pipeline_ctx_t; +/* Origin-aware LSP returns only success/failure. Never retain partial output + * when its configured owner proof cannot be applied to current source. */ +static inline void cbm_pipeline_test_owner_error(CBMFileResult *result) { + if (!result || result->test_declarations_status != CBM_TEST_EXTRACT_OK) { + return; + } + result->test_declarations_status = CBM_TEST_EXTRACT_UNSUPPORTED_FORM; + result->test_declaration_index = -1; + result->test_declaration_line = 0; + result->has_error = true; + result->error_msg = cbm_arena_strdup(&result->arena, + "configured test body ownership could not be verified"); + if (!result->error_msg) { + result->test_declarations_status = CBM_TEST_EXTRACT_OOM; + } +} + +static inline bool cbm_pipeline_test_result_ok(cbm_pipeline_ctx_t *ctx, + const CBMFileResult *result) { + if (result && result->test_declarations_status != CBM_TEST_EXTRACT_OK) { + atomic_store(&ctx->test_declarations_failed, 1); + return false; + } + if (result && result->has_test_definition_owners) { + atomic_store(&ctx->test_definition_owners_seen, 1); + } + return true; +} + +/* Mirror the extractor's consumed definition language/preset activation. + * Registration records and the legacy native presets do not activate the + * configured-definition adapter. NULL/default snapshots keep legacy skips. */ +static inline bool cbm_pipeline_test_definitions_apply(const cbm_pipeline_ctx_t *ctx, + CBMLanguage language) { + const cbm_test_declarations_t *snapshot = ctx ? ctx->test_declarations : NULL; + if (!snapshot || (language != CBM_LANG_C && language != CBM_LANG_CPP && + language != CBM_LANG_CUDA)) { + return false; + } + bool enabled = false; + if (language == CBM_LANG_C && + cbm_test_declarations_preset(snapshot, CBM_TEST_PRESET_C_CBM, &enabled) && enabled) { + return true; + } + int count = 0; + const cbm_test_declaration_t *items = cbm_test_declarations_items(snapshot, &count); + for (int i = 0; i < count; i++) { + if (items[i].role != CBM_TEST_DECL_CASE && items[i].role != CBM_TEST_DECL_SUITE) { + continue; + } + const char *name = items[i].language; + if (name && ((language == CBM_LANG_C && + (strcmp(name, "c") == 0 || strcmp(name, "C") == 0)) || + (language == CBM_LANG_CPP && + (strcmp(name, "cpp") == 0 || strcmp(name, "c++") == 0 || + strcmp(name, "C++") == 0)) || + (language == CBM_LANG_CUDA && + (strcmp(name, "cuda") == 0 || strcmp(name, "CUDA") == 0)))) { + return true; + } + } + return false; +} + +static inline bool cbm_pipeline_test_extraction_ok(cbm_pipeline_ctx_t *ctx, + CBMLanguage language, + const CBMFileResult *result) { + if (!result && cbm_pipeline_test_definitions_apply(ctx, language)) { + atomic_store(&ctx->test_declarations_failed, 1); + return false; + } + return cbm_pipeline_test_result_ok(ctx, result); +} + +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +enum { + CBM_PIPELINE_TEST_FAULT_EXTRACT_NULL = 1u << 0, + CBM_PIPELINE_TEST_FAULT_DEF_MODULES_NULL = 1u << 1, + CBM_PIPELINE_TEST_FAULT_COLLECT_SPILL_NULL = 1u << 2 +}; +void cbm_pipeline_test_set_fault_mask(cbm_pipeline_t *p, unsigned mask); +unsigned cbm_pipeline_test_fault_mask(const cbm_pipeline_t *p); +#endif + +static inline bool cbm_pipeline_test_force_extract_null(const cbm_pipeline_ctx_t *ctx, + CBMLanguage language) { +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + return ctx && (ctx->test_fault_mask & CBM_PIPELINE_TEST_FAULT_EXTRACT_NULL) && + cbm_pipeline_test_definitions_apply(ctx, language); +#else + (void)ctx; + (void)language; + return false; +#endif +} + +static inline bool cbm_pipeline_test_force_def_modules_null(const cbm_pipeline_ctx_t *ctx) { +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + return ctx && (ctx->test_fault_mask & CBM_PIPELINE_TEST_FAULT_DEF_MODULES_NULL) && + atomic_load(&ctx->test_definition_owners_seen); +#else + (void)ctx; + return false; +#endif +} + /* ── Result-cache access contract (spill mode) ──────────────────────── * After extraction a slot of the result cache is either the in-memory result * or NULL with the result parked on disk (ctx->spill). Every consumer reads a @@ -895,6 +1188,10 @@ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *row /* Pipeline accessors for incremental use */ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p); +const cbm_test_declarations_t *cbm_pipeline_test_declarations(const cbm_pipeline_t *p); +/* Without an active run snapshot this only validates fresh's source state. */ +bool cbm_pipeline_project_source_matches(const cbm_pipeline_t *p, + const cbm_userconfig_t *fresh); const cbm_index_resource_policy_t *cbm_pipeline_resource_policy(const cbm_pipeline_t *p); cbm_index_resource_violation_t *cbm_pipeline_resource_violation(cbm_pipeline_t *p); atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p); diff --git a/src/store/store.c b/src/store/store.c index d66ef0ffb..19ff6ac01 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -11,6 +11,7 @@ #include #include #include "foundation/constants.h" +#include "foundation/arena.h" #include "foundation/hash_table.h" #include "foundation/sha256.h" @@ -113,6 +114,7 @@ static int bind_text(sqlite3_stmt *s, int col, const char *v) { struct cbm_store { sqlite3 *db; + const void *progress_owner; /* exclusive native progress guard token */ const char *db_path; /* heap-allocated, or NULL for :memory: */ char errbuf[CBM_SZ_512]; @@ -1336,6 +1338,240 @@ int cbm_store_rollback(cbm_store_t *s) { return exec_sql(s, "ROLLBACK;"); } +/* One exclusive native progress owner per connection. SQLite has no getter + * for an externally installed handler; direct replacement during this borrow + * is outside the request-owned connection contract. */ +static bool store_progress_acquire(cbm_store_t *s, const void *owner, int interval, + int (*callback)(void *), void *context) { + if (!s || !s->db || !owner || s->progress_owner) { + return false; + } + s->progress_owner = owner; + sqlite3_progress_handler(s->db, interval, callback, context); + return true; +} + +static bool store_progress_release(cbm_store_t *s, const void *owner) { + if (!s || !s->db || s->progress_owner != owner) { + return false; + } + sqlite3_progress_handler(s->db, 0, NULL, NULL); + s->progress_owner = NULL; + return true; +} + +struct cbm_store_read_scope { + CBMArena arena; + cbm_store_t *store; + cbm_store_cancel_fn cancel; + void *context; + int status; + int saved_query_only; + int saved_busy_timeout; + bool query_only_saved; + bool busy_timeout_saved; + bool query_only_changed; + bool busy_timeout_changed; + bool transaction; + bool progress_owned; + sqlite3_stmt *pin; +}; + +int cbm_store_read_scope_fail(cbm_store_read_scope_t *scope, int status) { + if (!scope) { + return CBM_STORE_ERR; + } + if (scope->status == CBM_STORE_OK) { + scope->status = status == CBM_STORE_CANCELLED ? CBM_STORE_CANCELLED : CBM_STORE_ERR; + } + return scope->status; +} + +/* This helper is also called from SQLite. It must not enter SQLite itself. */ +static int store_scope_poll(cbm_store_read_scope_t *scope) { + if (scope->status == CBM_STORE_OK && scope->cancel && scope->cancel(scope->context)) { + scope->status = CBM_STORE_CANCELLED; + } + return scope->status; +} + +static int store_scope_progress(void *context) { + return store_scope_poll((cbm_store_read_scope_t *)context) != CBM_STORE_OK; +} + +int cbm_store_read_scope_check(cbm_store_read_scope_t *scope) { + if (!scope) { + return CBM_STORE_ERR; + } + if (store_scope_poll(scope) != CBM_STORE_OK) { + return scope->status; + } + if (!scope->transaction || !scope->store || !scope->store->db || + scope->store->progress_owner != scope || sqlite3_get_autocommit(scope->store->db)) { + return cbm_store_read_scope_fail(scope, CBM_STORE_ERR); + } + return CBM_STORE_OK; +} + +cbm_store_t *cbm_store_read_scope_store(const cbm_store_read_scope_t *scope) { + return scope ? scope->store : NULL; +} + +static bool store_scope_pragma_get(cbm_store_t *s, const char *sql, int *out) { + sqlite3_stmt *stmt = NULL; + bool ok = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK; + if (ok) { + ok = sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) == SQLITE_INTEGER; + if (ok) { + *out = sqlite3_column_int(stmt, 0); + ok = sqlite3_step(stmt) == SQLITE_DONE; + } + } + if (sqlite3_finalize(stmt) != SQLITE_OK) { + ok = false; + } + return ok; +} + +/* No callback is invoked during cleanup. Even failed open has to report a + * restoration error distinctly: the caller has no scope left to inspect. */ +static int store_scope_cleanup(cbm_store_read_scope_t *scope) { + cbm_store_t *s = scope->store; + bool clean = true; + if (scope->progress_owned) { + clean = store_progress_release(s, scope); + scope->progress_owned = false; + } + if (scope->pin) { + /* reset may report the preceding interrupted step, not a new cleanup + * failure. finalize still destroys this exclusively owned statement. */ + sqlite3_reset(scope->pin); + if (sqlite3_finalize(scope->pin) != SQLITE_OK) { + clean = false; + } + scope->pin = NULL; + } + if (scope->transaction) { + if (sqlite3_get_autocommit(s->db)) { + cbm_store_read_scope_fail(scope, CBM_STORE_ERR); + } else if (exec_sql(s, "ROLLBACK;") != CBM_STORE_OK || !sqlite3_get_autocommit(s->db)) { + clean = false; + } + scope->transaction = false; + } + if (scope->query_only_changed && scope->query_only_saved) { + const char *sql = scope->saved_query_only ? "PRAGMA query_only=ON;" : "PRAGMA query_only=OFF;"; + int restored = -1; + if (exec_sql(s, sql) != CBM_STORE_OK || + !store_scope_pragma_get(s, "PRAGMA query_only;", &restored) || + restored != scope->saved_query_only) { + clean = false; + } + } + if (scope->busy_timeout_changed && scope->busy_timeout_saved) { + int restored = -1; + if (sqlite3_busy_timeout(s->db, scope->saved_busy_timeout) != SQLITE_OK || + !store_scope_pragma_get(s, "PRAGMA busy_timeout;", &restored) || + restored != scope->saved_busy_timeout) { + clean = false; + } + } + int status = clean ? scope->status : CBM_STORE_SCOPE_DISCARD; + CBMArena arena = scope->arena; /* scope itself lives in this arena */ + cbm_arena_destroy(&arena); + return status; +} + +int cbm_store_read_scope_close(cbm_store_read_scope_t *scope) { + return scope ? store_scope_cleanup(scope) : CBM_STORE_OK; +} + +int cbm_store_read_scope_open(cbm_store_t *s, cbm_store_cancel_fn cancel, void *context, + cbm_store_read_scope_t **out) { + if (out) { + *out = NULL; + } + if (!s || !s->db || !out || s->progress_owner || !sqlite3_get_autocommit(s->db)) { + return CBM_STORE_ERR; + } + for (sqlite3_stmt *stmt = sqlite3_next_stmt(s->db, NULL); stmt; + stmt = sqlite3_next_stmt(s->db, stmt)) { + if (sqlite3_stmt_busy(stmt)) { + return CBM_STORE_ERR; + } + } + CBMArena arena; + cbm_arena_init(&arena); + cbm_store_read_scope_t *scope = cbm_arena_calloc(&arena, sizeof(*scope)); + if (!scope) { + cbm_arena_destroy(&arena); + return CBM_STORE_ERR; + } + scope->arena = arena; + scope->store = s; + scope->cancel = cancel; + scope->context = context; + if (store_scope_poll(scope) != CBM_STORE_OK) { + return store_scope_cleanup(scope); + } + if (!store_progress_acquire(s, scope, 1000, store_scope_progress, scope)) { + cbm_store_read_scope_fail(scope, CBM_STORE_ERR); + return store_scope_cleanup(scope); + } + scope->progress_owned = true; + if (!store_scope_pragma_get(s, "PRAGMA busy_timeout;", &scope->saved_busy_timeout) || + scope->saved_busy_timeout < 0) { + goto fail; + } + scope->busy_timeout_saved = true; + scope->busy_timeout_changed = true; + if (sqlite3_busy_timeout(s->db, 0) != SQLITE_OK || store_scope_poll(scope) != CBM_STORE_OK) { + goto fail; + } + if (!store_scope_pragma_get(s, "PRAGMA query_only;", &scope->saved_query_only) || + (scope->saved_query_only != 0 && scope->saved_query_only != 1)) { + goto fail; + } + scope->query_only_saved = true; + scope->query_only_changed = true; + int query_only = 0; + if (exec_sql(s, "PRAGMA query_only=ON;") != CBM_STORE_OK || + !store_scope_pragma_get(s, "PRAGMA query_only;", &query_only) || query_only != 1 || + store_scope_poll(scope) != CBM_STORE_OK) { + goto fail; + } + int begin_status = exec_sql(s, "BEGIN DEFERRED;"); + scope->transaction = !sqlite3_get_autocommit(s->db); + if (begin_status != CBM_STORE_OK || !scope->transaction || + store_scope_poll(scope) != CBM_STORE_OK) { + goto fail; + } + /* BEGIN alone is not a snapshot. This real main-table read opens one even + * for an empty graph; finalizing the statement retains the transaction. */ + if (sqlite3_prepare_v2(s->db, "SELECT id FROM main.nodes LIMIT 1;", CBM_NOT_FOUND, + &scope->pin, NULL) != SQLITE_OK) { + goto fail; + } + int step = sqlite3_step(scope->pin); + if (step != SQLITE_ROW && step != SQLITE_DONE) { + goto fail; + } + if (sqlite3_reset(scope->pin) != SQLITE_OK) { + goto fail; + } + int final = sqlite3_finalize(scope->pin); + scope->pin = NULL; + if (final != SQLITE_OK || cbm_store_read_scope_check(scope) != CBM_STORE_OK) { + goto fail; + } + *out = scope; + return CBM_STORE_OK; + +fail: + cbm_store_read_scope_fail(scope, CBM_STORE_ERR); + return store_scope_cleanup(scope); +} + /* ── Graph comparison ────────────────────────────────────────────── */ typedef struct { @@ -1749,9 +1985,15 @@ int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project, bool base_transaction = false; bool target_transaction = false; int progress_interval = graph_compare_progress_interval(); - sqlite3_progress_handler(base_store->db, progress_interval, graph_compare_progress, &progress); - sqlite3_progress_handler(target_store->db, progress_interval, graph_compare_progress, - &progress); + if (!store_progress_acquire(base_store, &progress, progress_interval, + graph_compare_progress, &progress)) { + return CBM_STORE_ERR; + } + if (!store_progress_acquire(target_store, &progress, progress_interval, + graph_compare_progress, &progress)) { + store_progress_release(base_store, &progress); + return CBM_STORE_ERR; + } rc = exec_sql(base_store, "BEGIN;"); if (rc == CBM_STORE_OK) { @@ -1787,8 +2029,8 @@ int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project, rc = CBM_STORE_CANCELLED; } - sqlite3_progress_handler(base_store->db, 0, NULL, NULL); - sqlite3_progress_handler(target_store->db, 0, NULL, NULL); + store_progress_release(base_store, &progress); + store_progress_release(target_store, &progress); if (target_transaction && exec_sql(target_store, "ROLLBACK;") != CBM_STORE_OK && rc == CBM_STORE_OK) { rc = CBM_STORE_ERR; @@ -2962,20 +3204,22 @@ int cbm_store_get_file_outline(cbm_store_t *s, const char *project, const char * if (file_outline_cancelled(&guard)) { return CBM_STORE_CANCELLED; } - if (cancel) { - sqlite3_progress_handler(s->db, 1000, file_outline_progress_cancel, &guard); + /* Even a NULL cancellation callback must claim ownership: otherwise + * this operation's cleanup could remove a surrounding scope's handler. */ + if (!store_progress_acquire(s, &guard, 1000, file_outline_progress_cancel, &guard)) { + return CBM_STORE_ERR; } char sql[ST_SQL_BUF]; if (!file_outline_build_sql(sql, sizeof(sql), true, label_count)) { - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); return CBM_STORE_ERR; } sqlite3_stmt *stmt = NULL; int rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL); if (rc != SQLITE_OK) { store_set_error_sqlite(s, "file outline count prepare"); - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); return CBM_STORE_ERR; } file_outline_bind_common(stmt, project, file_path, labels, label_count); @@ -2985,19 +3229,19 @@ int cbm_store_get_file_outline(cbm_store_t *s, const char *project, const char * } sqlite3_finalize(stmt); if (rc == SQLITE_INTERRUPT || file_outline_cancelled(&guard)) { - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); *total = 0; return CBM_STORE_CANCELLED; } if (rc != SQLITE_ROW) { store_set_error_sqlite(s, "file outline count"); - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); *total = 0; return CBM_STORE_ERR; } if (!file_outline_build_sql(sql, sizeof(sql), false, label_count)) { - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); *total = 0; return CBM_STORE_ERR; } @@ -3005,7 +3249,7 @@ int cbm_store_get_file_outline(cbm_store_t *s, const char *project, const char * rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL); if (rc != SQLITE_OK) { store_set_error_sqlite(s, "file outline prepare"); - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); *total = 0; return CBM_STORE_ERR; } @@ -3016,7 +3260,7 @@ int cbm_store_get_file_outline(cbm_store_t *s, const char *project, const char * cbm_file_outline_row_t *rows = calloc((size_t)limit, sizeof(*rows)); if (!rows) { sqlite3_finalize(stmt); - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); *total = 0; return CBM_STORE_ERR; } @@ -3040,7 +3284,7 @@ int cbm_store_get_file_outline(cbm_store_t *s, const char *project, const char * (size_t)sqlite3_column_bytes(stmt, 2) + 3U; if (row_bytes > CBM_STORE_FILE_OUTLINE_MAX_TEXT_BYTES - text_bytes) { sqlite3_finalize(stmt); - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); cbm_store_free_file_outline(rows, n); *total = 0; return CBM_STORE_SCAN_LIMIT; @@ -3052,7 +3296,7 @@ int cbm_store_get_file_outline(cbm_store_t *s, const char *project, const char * rows[n].end_line = sqlite3_column_int(stmt, 4); if (!rows[n].name || !rows[n].label || !rows[n].qualified_name) { sqlite3_finalize(stmt); - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); cbm_store_free_file_outline(rows, n + 1); *total = 0; return CBM_STORE_ERR; @@ -3062,7 +3306,7 @@ int cbm_store_get_file_outline(cbm_store_t *s, const char *project, const char * } was_cancelled = was_cancelled || rc == SQLITE_INTERRUPT || file_outline_cancelled(&guard); sqlite3_finalize(stmt); - sqlite3_progress_handler(s->db, 0, NULL, NULL); + store_progress_release(s, &guard); if (was_cancelled) { cbm_store_free_file_outline(rows, n); *total = 0; diff --git a/src/store/store.h b/src/store/store.h index 06ea1fbb7..6a90c0073 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -17,6 +17,7 @@ /* ── Opaque handle ──────────────────────────────────────────────── */ typedef struct cbm_store cbm_store_t; +typedef struct cbm_store_read_scope cbm_store_read_scope_t; /* ── Result codes ───────────────────────────────────────────────── */ @@ -27,6 +28,8 @@ typedef struct cbm_store cbm_store_t; #define CBM_STORE_CANCELLED (-3) #define CBM_STORE_SCAN_LIMIT (-4) #define CBM_STORE_CALLBACK_ERR (-5) +/* Read-scope cleanup could not restore the connection: close/discard it. */ +#define CBM_STORE_SCOPE_DISCARD (-6) #define CBM_STORE_FILE_OUTLINE_MAX_LIMIT 200 #define CBM_STORE_FILE_OUTLINE_MAX_LABELS 16 @@ -425,6 +428,28 @@ int cbm_store_commit(cbm_store_t *s); /* Rollback the current transaction. */ int cbm_store_rollback(cbm_store_t *s); +/* Stable request read scope. Borrows an exclusively owned, single-thread + * store and callback context. Refuses a transaction, busy statement or owned + * progress handler; idle cached statements are allowed. Saves query_only and + * busy_timeout, sets ON/0, begins DEFERRED and pins main.nodes before returning. + * The cancellation callback is a pure predicate: no SQLite/store/scope/walk + * calls. Do not replace connection handlers, transactions or close the store + * during the borrow. Close walks first, then the scope, then the store. + * + * Open clears *out. Any cleanup/restoration failure, including failed open, + * returns SCOPE_DISCARD: the caller MUST discard this connection. */ +int cbm_store_read_scope_open(cbm_store_t *s, cbm_store_cancel_fn cancel, void *context, + cbm_store_read_scope_t **out); +/* First cancellation/error is sticky. Check also rejects a lost transaction. */ +int cbm_store_read_scope_check(cbm_store_read_scope_t *scope); +/* Consumer failure: accepts ERR or CANCELLED; other values latch ERR. */ +int cbm_store_read_scope_fail(cbm_store_read_scope_t *scope, int status); +/* Borrowed store binding, not a health check or certification. */ +cbm_store_t *cbm_store_read_scope_store(const cbm_store_read_scope_t *scope); +/* Disables its handler before cleanup. Returns latched status unless cleanup + * fails (SCOPE_DISCARD). NULL is OK. Frees scope on every non-NULL call. */ +int cbm_store_read_scope_close(cbm_store_read_scope_t *scope); + /* ── Bulk write optimization ────────────────────────────────────── */ /* Tune pragmas for bulk write throughput (synchronous=OFF, large cache). diff --git a/src/store/store_graph_digest.c b/src/store/store_graph_digest.c new file mode 100644 index 000000000..48f4ac76f --- /dev/null +++ b/src/store/store_graph_digest.c @@ -0,0 +1,732 @@ +/* Canonical stored-content digest. This module establishes no provenance. */ +#include "store/store_graph_digest.h" + +#include "foundation/sha256.h" + +#include +#include +#include + +enum { + GD_NULL = 0, + GD_INTEGER = 1, + GD_TEXT = 2, + GD_BLOB = 3, + GD_ALLOW_NULL = 1 << GD_NULL, + GD_ALLOW_INTEGER = 1 << GD_INTEGER, + GD_ALLOW_TEXT = 1 << GD_TEXT, + GD_ALLOW_BLOB = 1 << GD_BLOB, + GD_TABLE_COUNT = 9, + GD_MAX_COLUMNS = 9, + GD_STORE_META = 8, + GD_CHUNK_BYTES = 65536 +}; + +typedef struct { + const char *name; + unsigned char types; + unsigned char kind; + int primary_key; +} gd_column_t; + +typedef struct { + const char *name; + const gd_column_t *columns; + int column_count; + const char *column_query; + const char *row_query; +} gd_table_t; + +#define GD_COLUMNS(a) ((int)(sizeof(a) / sizeof((a)[0]))) +#define GD_I GD_ALLOW_INTEGER +#define GD_T GD_ALLOW_TEXT +#define GD_N GD_ALLOW_NULL +#define GD_B GD_ALLOW_BLOB + +static const gd_column_t gd_projects[] = { + {"name", GD_T, 0, 1}, {"indexed_at", GD_T, 0, 0}, {"root_path", GD_T, 0, 0} +}; +static const gd_column_t gd_file_hashes[] = { + {"project", GD_T, 0, 1}, {"rel_path", GD_T, 0, 2}, {"sha256", GD_T, 0, 0}, + {"mtime_ns", GD_I, 0, 0}, {"size", GD_I, 0, 0} +}; +static const gd_column_t gd_nodes[] = { + {"id", GD_I, 0, 1}, {"project", GD_T, 0, 0}, {"label", GD_T, 0, 0}, + {"name", GD_T, 0, 0}, {"qualified_name", GD_T, 0, 0}, + {"file_path", GD_T | GD_N, 0, 0}, {"start_line", GD_I | GD_N, 0, 0}, + {"end_line", GD_I | GD_N, 0, 0}, {"properties", GD_T | GD_N, 0, 0} +}; +static const gd_column_t gd_edges[] = { + {"id", GD_I, 0, 1}, {"project", GD_T, 0, 0}, {"source_id", GD_I, 0, 0}, + {"target_id", GD_I, 0, 0}, {"type", GD_T, 0, 0}, + {"properties", GD_T | GD_N, 0, 0}, {"url_path_gen", GD_T | GD_N, 2, 0}, + {"local_name_gen", GD_T | GD_N, 2, 0} +}; +static const gd_column_t gd_project_summaries[] = { + {"project", GD_T, 0, 1}, {"summary", GD_T, 0, 0}, {"source_hash", GD_T, 0, 0}, + {"created_at", GD_T, 0, 0}, {"updated_at", GD_T, 0, 0} +}; +static const gd_column_t gd_lsp_surface[] = { + {"project", GD_T, 0, 1}, {"rel_path", GD_T, 0, 2}, {"surface_sha", GD_T, 0, 0}, + {"defs_json", GD_T, 0, 0}, {"ref_bloom", GD_B | GD_N, 0, 0}, + {"config_ctx", GD_T, 0, 0} +}; +static const gd_column_t gd_index_coverage[] = { + {"project", GD_T, 0, 1}, {"rel_path", GD_T, 0, 2}, {"kind", GD_T, 0, 3}, + {"detail", GD_T | GD_N, 0, 0} +}; +static const gd_column_t gd_index_coverage_meta[] = { + {"project", GD_T, 0, 1}, {"generation", GD_T, 0, 0}, {"index_mode", GD_T, 0, 0}, + {"recorded_at", GD_T, 0, 0}, {"recording_status", GD_T, 0, 0}, + {"ignored_files_stored", GD_I, 0, 0}, {"ignored_files_total", GD_I, 0, 0}, + {"coverage_version", GD_I, 0, 0}, {"hash_records_complete", GD_I, 0, 0} +}; +static const gd_column_t gd_store_meta[] = { + {"k", GD_T, 0, 1}, {"v", GD_T, 0, 0} +}; + +/* These descriptors and SQL strings are immutable; all other state is local. */ +static const gd_table_t gd_tables[GD_TABLE_COUNT] = { + {"projects", gd_projects, GD_COLUMNS(gd_projects), + "PRAGMA main.table_xinfo('projects');", + "SELECT name,indexed_at,root_path FROM main.projects ORDER BY CAST(name AS BLOB) ASC;"}, + {"file_hashes", gd_file_hashes, GD_COLUMNS(gd_file_hashes), + "PRAGMA main.table_xinfo('file_hashes');", + "SELECT project,rel_path,sha256,mtime_ns,size FROM main.file_hashes " + "ORDER BY CAST(project AS BLOB) ASC,CAST(rel_path AS BLOB) ASC;"}, + {"nodes", gd_nodes, GD_COLUMNS(gd_nodes), "PRAGMA main.table_xinfo('nodes');", + "SELECT id,project,label,name,qualified_name,file_path,start_line,end_line,properties " + "FROM main.nodes ORDER BY id ASC;"}, + {"edges", gd_edges, GD_COLUMNS(gd_edges), "PRAGMA main.table_xinfo('edges');", + "SELECT id,project,source_id,target_id,type,properties,url_path_gen,local_name_gen " + "FROM main.edges ORDER BY id ASC;"}, + {"project_summaries", gd_project_summaries, GD_COLUMNS(gd_project_summaries), + "PRAGMA main.table_xinfo('project_summaries');", + "SELECT project,summary,source_hash,created_at,updated_at FROM main.project_summaries " + "ORDER BY CAST(project AS BLOB) ASC;"}, + {"lsp_surface", gd_lsp_surface, GD_COLUMNS(gd_lsp_surface), + "PRAGMA main.table_xinfo('lsp_surface');", + "SELECT project,rel_path,surface_sha,defs_json,ref_bloom,config_ctx FROM main.lsp_surface " + "ORDER BY CAST(project AS BLOB) ASC,CAST(rel_path AS BLOB) ASC;"}, + {"index_coverage", gd_index_coverage, GD_COLUMNS(gd_index_coverage), + "PRAGMA main.table_xinfo('index_coverage');", + "SELECT project,rel_path,kind,detail FROM main.index_coverage " + "ORDER BY CAST(project AS BLOB) ASC,CAST(rel_path AS BLOB) ASC,CAST(kind AS BLOB) ASC;"}, + {"index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta), + "PRAGMA main.table_xinfo('index_coverage_meta');", + "SELECT project,generation,index_mode,recorded_at,recording_status,ignored_files_stored," + "ignored_files_total,coverage_version,hash_records_complete FROM main.index_coverage_meta " + "ORDER BY CAST(project AS BLOB) ASC;"}, + {"store_meta", gd_store_meta, GD_COLUMNS(gd_store_meta), + "PRAGMA main.table_xinfo('store_meta');", + "SELECT k,v FROM main.store_meta ORDER BY CAST(k AS BLOB) ASC;"} +}; + +#undef GD_B +#undef GD_N +#undef GD_T +#undef GD_I +#undef GD_COLUMNS + +typedef struct { + cbm_store_read_scope_t *scope; + sqlite3 *db; + sqlite3_stmt *stmt; + cbm_sha256_ctx sha; + cbm_store_graph_digest_limits_t limits; + cbm_store_graph_digest_status_t status; + uint64_t rows; + uint64_t bytes; + bool have_uid; + bool have_counter; +} gd_context_t; + +typedef struct { + const unsigned char *data; + size_t length; +} gd_text_t; + +static bool gd_fail(gd_context_t *g, cbm_store_graph_digest_status_t status) { + if (g->status == CBM_STORE_GRAPH_DIGEST_OK) { + g->status = status; + if (g->scope) { + (void)cbm_store_read_scope_fail( + g->scope, status == CBM_STORE_GRAPH_DIGEST_CANCELLED ? + CBM_STORE_CANCELLED : CBM_STORE_ERR); + } + } + return false; +} + +static bool gd_check(gd_context_t *g) { + if (g->scope) { + int status = cbm_store_read_scope_check(g->scope); + if (status != CBM_STORE_OK) { + return gd_fail(g, status == CBM_STORE_CANCELLED ? + CBM_STORE_GRAPH_DIGEST_CANCELLED : CBM_STORE_GRAPH_DIGEST_ERROR); + } + } + return g->status == CBM_STORE_GRAPH_DIGEST_OK; +} + +static void gd_sql_failure(gd_context_t *g, int rc) { + if (rc == SQLITE_INTERRUPT) { + (void)gd_check(g); /* Only a D5-latched cancellation earns CANCELLED. */ + } + (void)gd_fail(g, CBM_STORE_GRAPH_DIGEST_ERROR); +} + +static bool gd_prepare(gd_context_t *g, const char *sql) { + if (!gd_check(g)) { + return false; + } + if (g->stmt) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_ERROR); + } + int rc = sqlite3_prepare_v2(g->db, sql, -1, &g->stmt, NULL); + if (rc != SQLITE_OK) { + gd_sql_failure(g, rc); + } + return gd_check(g); +} + +static int gd_step(gd_context_t *g) { + if (!gd_check(g)) { + return SQLITE_ERROR; + } + int rc = sqlite3_step(g->stmt); + if (rc != SQLITE_ROW && rc != SQLITE_DONE) { + gd_sql_failure(g, rc); + } + return gd_check(g) ? rc : SQLITE_ERROR; +} + +/* Destruction is unconditional, including after the scope has been poisoned. */ +static bool gd_finalize(gd_context_t *g) { + if (g->stmt) { + (void)gd_check(g); + sqlite3_stmt *stmt = g->stmt; + g->stmt = NULL; + int rc = sqlite3_finalize(stmt); + if (rc != SQLITE_OK) { + gd_sql_failure(g, rc); + } + (void)gd_check(g); + } + return g->status == CBM_STORE_GRAPH_DIGEST_OK; +} + +static bool gd_bind_name(gd_context_t *g, const char *name) { + int rc = sqlite3_bind_text(g->stmt, 1, name, -1, SQLITE_STATIC); + if (rc != SQLITE_OK) { + gd_sql_failure(g, rc); + } + return g->status == CBM_STORE_GRAPH_DIGEST_OK; +} + +/* No conversion is allowed to erase the distinction between TEXT and BLOB. */ +static bool gd_read_text(gd_context_t *g, int column, gd_text_t *out) { + if (sqlite3_column_type(g->stmt, column) != SQLITE_TEXT) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + const unsigned char *data = sqlite3_column_text(g->stmt, column); + int length = sqlite3_column_bytes(g->stmt, column); + if (!data || length < 0 || sqlite3_errcode(g->db) == SQLITE_NOMEM) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_ERROR); + } + out->data = data; + out->length = (size_t)length; + return true; +} + +static bool gd_text_eq(gd_text_t text, const char *literal) { + size_t length = strlen(literal); /* Only internal constant strings. */ + return text.length == length && memcmp(text.data, literal, length) == 0; +} + +static bool gd_text_ascii_eq(gd_text_t text, const char *literal) { + size_t length = strlen(literal); + if (text.length != length) { + return false; + } + for (size_t i = 0; i < length; i++) { + unsigned char c = text.data[i]; + unsigned char d = (unsigned char)literal[i]; + if (c >= 'A' && c <= 'Z') { + c = (unsigned char)(c + ('a' - 'A')); + } + if (d >= 'A' && d <= 'Z') { + d = (unsigned char)(d + ('a' - 'A')); + } + if (c != d) { + return false; + } + } + return true; +} + +static int gd_table_index(gd_text_t name) { + for (int i = 0; i < GD_TABLE_COUNT; i++) { + if (gd_text_ascii_eq(name, gd_tables[i].name)) { + return i; + } + } + return -1; +} + +static bool gd_bytes(gd_context_t *g, const void *data, size_t length) { + if (!gd_check(g)) { + return false; + } + uint64_t count = (uint64_t)length; + if ((size_t)count != length || count > g->limits.max_framed_bytes - g->bytes) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_LIMIT); + } + if (length && !data) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_ERROR); + } + const unsigned char *p = data; + while (length) { + size_t chunk = length > GD_CHUNK_BYTES ? GD_CHUNK_BYTES : length; + if (!gd_check(g)) { + return false; + } + cbm_sha256_update(&g->sha, p, chunk); + g->bytes += (uint64_t)chunk; + if (!gd_check(g)) { + return false; + } + p += chunk; + length -= chunk; + } + return true; +} + +static bool gd_u8(gd_context_t *g, unsigned char value) { + return gd_bytes(g, &value, 1); +} + +static void gd_pack_u64(uint64_t value, unsigned char bytes[8]) { + for (unsigned int i = 0; i < 8; i++) { + bytes[7 - i] = (unsigned char)(value >> (8 * i)); + } +} + +static bool gd_u32(gd_context_t *g, uint32_t value) { + unsigned char bytes[4]; + for (unsigned int i = 0; i < 4; i++) { + bytes[3 - i] = (unsigned char)(value >> (8 * i)); + } + return gd_bytes(g, bytes, sizeof(bytes)); +} + +static bool gd_u64(gd_context_t *g, uint64_t value) { + unsigned char bytes[8]; + gd_pack_u64(value, bytes); + return gd_bytes(g, bytes, sizeof(bytes)); +} + +static bool gd_value(gd_context_t *g, unsigned char tag, const void *data, size_t length) { + uint64_t count = (uint64_t)length; + if ((size_t)count != length) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_LIMIT); + } + return gd_u8(g, tag) && gd_u64(g, count) && gd_bytes(g, data, length); +} + +static bool gd_literal(gd_context_t *g, const char *literal) { + return gd_value(g, GD_TEXT, literal, strlen(literal)); +} + +static bool gd_encoding(gd_context_t *g) { + if (!gd_prepare(g, "PRAGMA main.encoding;")) { + return false; + } + if (sqlite3_column_count(g->stmt) != 1 || gd_step(g) != SQLITE_ROW) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + gd_text_t encoding; + if (!gd_read_text(g, 0, &encoding)) { + return false; + } + if (!gd_text_eq(encoding, "UTF-8") || gd_step(g) != SQLITE_DONE) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + return gd_finalize(g); +} + +static bool gd_table_kinds(gd_context_t *g, bool present[GD_TABLE_COUNT]) { + if (!gd_prepare(g, "PRAGMA main.table_list;")) { + return false; + } + /* Unsupported PRAGMAs may return DONE without an error or columns. */ + if (sqlite3_column_count(g->stmt) < 3) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + int rc; + while ((rc = gd_step(g)) == SQLITE_ROW) { + gd_text_t schema, name, type; + if (!gd_read_text(g, 0, &schema) || !gd_read_text(g, 1, &name) || + !gd_read_text(g, 2, &type)) { + return false; + } + if (!gd_text_eq(schema, "main")) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + int index = gd_table_index(name); + if (index >= 0) { + if (present[index] || !gd_text_eq(name, gd_tables[index].name) || + !gd_text_eq(type, "table")) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + present[index] = true; + } + } + if (rc != SQLITE_DONE || !gd_finalize(g)) { + return false; + } + for (int i = 0; i < GD_STORE_META; i++) { + if (!present[i]) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + } + if (!gd_prepare(g, "SELECT name FROM temp.sqlite_schema WHERE type IN ('table','view');")) { + return false; + } + while ((rc = gd_step(g)) == SQLITE_ROW) { + gd_text_t name; + if (!gd_read_text(g, 0, &name)) { + return false; + } + if (gd_table_index(name) >= 0) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + } + return rc == SQLITE_DONE && gd_finalize(g); +} + +static bool gd_columns(gd_context_t *g, const gd_table_t *table) { + bool seen[GD_MAX_COLUMNS] = {false}; + if (!gd_prepare(g, table->column_query)) { + return false; + } + if (sqlite3_column_count(g->stmt) != 7) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + int rc; + int count = 0; + while ((rc = gd_step(g)) == SQLITE_ROW) { + gd_text_t name; + if (!gd_read_text(g, 1, &name)) { + return false; + } + int column = -1; + for (int i = 0; i < table->column_count; i++) { + if (gd_text_eq(name, table->columns[i].name)) { + column = i; + break; + } + } + if (column < 0 || seen[column] || count == table->column_count || + sqlite3_column_type(g->stmt, 5) != SQLITE_INTEGER || + sqlite3_column_type(g->stmt, 6) != SQLITE_INTEGER) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + const gd_column_t *expected = &table->columns[column]; + if (sqlite3_column_int64(g->stmt, 5) != expected->primary_key || + sqlite3_column_int64(g->stmt, 6) != expected->kind) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + if (expected->primary_key && expected->types == GD_ALLOW_INTEGER) { + gd_text_t declared_type; + if (!gd_read_text(g, 2, &declared_type)) { + return false; + } + if (!gd_text_ascii_eq(declared_type, "INTEGER")) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + } + seen[column] = true; + count++; + } + if (rc != SQLITE_DONE || count != table->column_count) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + return gd_finalize(g); +} + +static bool gd_project(gd_context_t *g, const unsigned char *project, size_t length) { + if (!gd_prepare(g, "SELECT 1 FROM main.projects WHERE CAST(name AS BLOB)=?1 LIMIT 2;")) { + return false; + } + int rc = sqlite3_bind_blob(g->stmt, 1, project, (int)length, SQLITE_STATIC); + if (rc != SQLITE_OK) { + gd_sql_failure(g, rc); + return false; + } + rc = gd_step(g); + if (rc == SQLITE_DONE) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_PROJECT_MISSING); + } + if (rc != SQLITE_ROW) { + return false; + } + rc = gd_step(g); + if (rc == SQLITE_ROW) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + return rc == SQLITE_DONE && gd_finalize(g); +} + +static bool gd_table_header(gd_context_t *g, int index, bool present) { + const gd_table_t *table = &gd_tables[index]; + if (!gd_u8(g, 0x20) || !gd_u32(g, (uint32_t)index) || !gd_literal(g, table->name) || + !gd_u32(g, (uint32_t)table->column_count)) { + return false; + } + for (int i = 0; i < table->column_count; i++) { + const gd_column_t *column = &table->columns[i]; + if (!gd_literal(g, column->name) || !gd_u8(g, column->types) || + !gd_u8(g, column->kind)) { + return false; + } + } + if (!gd_u8(g, present ? 1 : 0)) { + return false; + } + if (!present) { + return gd_value(g, GD_NULL, NULL, 0); + } + if (!gd_prepare(g, "SELECT name,type,sql FROM main.sqlite_schema " + "WHERE name=?1 COLLATE NOCASE AND type IN ('table','view');") || + !gd_bind_name(g, table->name)) { + return false; + } + if (gd_step(g) != SQLITE_ROW) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + gd_text_t name, type, sql; + if (!gd_read_text(g, 0, &name) || !gd_read_text(g, 1, &type) || + !gd_read_text(g, 2, &sql)) { + return false; + } + if (!gd_text_eq(name, table->name) || !gd_text_eq(type, "table")) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + if (!gd_value(g, GD_TEXT, sql.data, sql.length)) { + return false; + } + if (gd_step(g) != SQLITE_DONE) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + return gd_finalize(g); +} + +static bool gd_generation(gd_context_t *g) { + gd_text_t key, value; + if (!gd_read_text(g, 0, &key) || !gd_read_text(g, 1, &value)) { + return false; + } + if (gd_text_eq(key, "db_uid")) { + if (g->have_uid || value.length != 16) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + for (size_t i = 0; i < value.length; i++) { + unsigned char c = value.data[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + } + g->have_uid = true; + } else if (gd_text_eq(key, "mutation_gen")) { + if (g->have_counter || value.length == 0 || value.length > 20 || + (value.length > 1 && value.data[0] == '0')) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + uint64_t number = 0; + for (size_t i = 0; i < value.length; i++) { + unsigned char c = value.data[i]; + if (c < '0' || c > '9') { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + uint64_t digit = (uint64_t)(c - '0'); + if (number > (UINT64_MAX - digit) / 10) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + number = number * 10 + digit; + } + g->have_counter = true; + } + return true; +} + +static bool gd_column_value(gd_context_t *g, int index, const gd_column_t *column) { + int sql_type = sqlite3_column_type(g->stmt, index); + unsigned char tag; + switch (sql_type) { + case SQLITE_NULL: tag = GD_NULL; break; + case SQLITE_INTEGER: tag = GD_INTEGER; break; + case SQLITE_TEXT: tag = GD_TEXT; break; + case SQLITE_BLOB: tag = GD_BLOB; break; + default: return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + if (!(column->types & (1u << tag))) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + if (tag == GD_NULL) { + return gd_value(g, tag, NULL, 0); + } + if (tag == GD_INTEGER) { + unsigned char bytes[8]; + gd_pack_u64((uint64_t)sqlite3_column_int64(g->stmt, index), bytes); + return gd_value(g, tag, bytes, sizeof(bytes)); + } + if (tag == GD_TEXT) { + gd_text_t value; + return gd_read_text(g, index, &value) && + gd_value(g, tag, value.data, value.length); + } + const void *blob = sqlite3_column_blob(g->stmt, index); + int length = sqlite3_column_bytes(g->stmt, index); + if (length < 0 || (length && !blob) || sqlite3_errcode(g->db) == SQLITE_NOMEM) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_ERROR); + } + return gd_value(g, tag, blob, (size_t)length); +} + +static bool gd_table_rows(gd_context_t *g, int index, bool present) { + uint64_t rows = 0; + const gd_table_t *table = &gd_tables[index]; + if (present) { + if (!gd_prepare(g, table->row_query)) { + return false; + } + if (sqlite3_column_count(g->stmt) != table->column_count) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + int rc; + while ((rc = gd_step(g)) == SQLITE_ROW) { + if (!gd_check(g)) { + return false; + } + if (g->rows == g->limits.max_rows || g->rows == UINT64_MAX || rows == UINT64_MAX) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_LIMIT); + } + if (index == GD_STORE_META && !gd_generation(g)) { + return false; + } + if (!gd_u8(g, 0x21) || !gd_u32(g, (uint32_t)table->column_count)) { + return false; + } + for (int i = 0; i < table->column_count; i++) { + if (!gd_column_value(g, i, &table->columns[i])) { + return false; + } + } + rows++; + g->rows++; + } + if (rc != SQLITE_DONE || !gd_finalize(g)) { + return false; + } + if (index == GD_STORE_META && (!g->have_uid || !g->have_counter)) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + } + } + return gd_u8(g, 0x22) && gd_u64(g, rows); +} + +static bool gd_project_argument(gd_context_t *g, const unsigned char *project, size_t length) { + while (length) { + size_t chunk = length > GD_CHUNK_BYTES ? GD_CHUNK_BYTES : length; + if (!gd_check(g)) { + return false; + } + if (memchr(project, 0, chunk)) { + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_INVALID); + } + if (!gd_check(g)) { + return false; + } + project += chunk; + length -= chunk; + } + return true; +} + +cbm_store_graph_digest_status_t cbm_store_graph_digest( + cbm_store_read_scope_t *scope, + const unsigned char *project, size_t project_len, + const cbm_store_graph_digest_limits_t *limits, + cbm_store_graph_digest_t *out) { + if (out) { + memset(out, 0, sizeof(*out)); + } + gd_context_t g = {0}; + cbm_store_graph_digest_t result; + memset(&result, 0, sizeof(result)); + bool complete = false; + g.scope = scope; + if (scope && !gd_check(&g)) { + return g.status; + } + if (!scope || !project || !project_len || project_len > INT_MAX || !limits || !out || + !limits->max_rows || !limits->max_framed_bytes || + limits->max_framed_bytes > UINT64_MAX / 8) { + (void)gd_fail(&g, CBM_STORE_GRAPH_DIGEST_INVALID); + return g.status; + } + if (!gd_project_argument(&g, project, project_len)) { + return g.status; + } + g.limits = *limits; + cbm_store_t *store = cbm_store_read_scope_store(scope); + g.db = store ? cbm_store_get_db(store) : NULL; + if (!g.db) { + (void)gd_fail(&g, CBM_STORE_GRAPH_DIGEST_ERROR); + return g.status; + } + bool present[GD_TABLE_COUNT] = {false}; + if (!gd_encoding(&g) || !gd_table_kinds(&g, present)) { + goto done; + } + for (int i = 0; i < GD_TABLE_COUNT; i++) { + if (present[i] && !gd_columns(&g, &gd_tables[i])) { + goto done; + } + } + if (!gd_project(&g, project, project_len)) { + goto done; + } + cbm_sha256_init(&g.sha); + static const unsigned char domain[] = "cbm-store-graph-digest"; + if (!gd_bytes(&g, domain, sizeof(domain)) || !gd_u32(&g, CBM_STORE_GRAPH_DIGEST_VERSION) || + !gd_u8(&g, 0x10) || !gd_value(&g, GD_TEXT, project, project_len)) { + goto done; + } + for (int i = 0; i < GD_TABLE_COUNT; i++) { + if (!gd_table_header(&g, i, present[i]) || !gd_table_rows(&g, i, present[i])) { + goto done; + } + } + if (!gd_u8(&g, 0x7f) || !gd_u64(&g, g.rows) || !gd_check(&g)) { + goto done; + } + result.version = CBM_STORE_GRAPH_DIGEST_VERSION; + result.rows = g.rows; + result.framed_bytes = g.bytes; + cbm_sha256_final(&g.sha, result.sha256); + complete = gd_check(&g); + +done: + (void)gd_finalize(&g); + if (!complete && g.status == CBM_STORE_GRAPH_DIGEST_OK) { + (void)gd_fail(&g, CBM_STORE_GRAPH_DIGEST_ERROR); + } + if (complete && gd_check(&g)) { + memcpy(out, &result, sizeof(*out)); + } else { + memset(out, 0, sizeof(*out)); + } + return g.status; +} diff --git a/src/store/store_graph_digest.h b/src/store/store_graph_digest.h new file mode 100644 index 000000000..545086473 --- /dev/null +++ b/src/store/store_graph_digest.h @@ -0,0 +1,60 @@ +/* Versioned content binding for one stable store read snapshot. */ +#ifndef CBM_STORE_GRAPH_DIGEST_H +#define CBM_STORE_GRAPH_DIGEST_H + +#include "store/store.h" + +#include +#include + +#define CBM_STORE_GRAPH_DIGEST_VERSION 1u +#define CBM_STORE_GRAPH_DIGEST_BYTES 32u + +typedef enum { + CBM_STORE_GRAPH_DIGEST_OK = 0, + CBM_STORE_GRAPH_DIGEST_INVALID, + CBM_STORE_GRAPH_DIGEST_SCHEMA, + CBM_STORE_GRAPH_DIGEST_PROJECT_MISSING, + CBM_STORE_GRAPH_DIGEST_LIMIT, + CBM_STORE_GRAPH_DIGEST_CANCELLED, + CBM_STORE_GRAPH_DIGEST_ERROR +} cbm_store_graph_digest_status_t; + +typedef struct { + uint64_t max_rows; + uint64_t max_framed_bytes; +} cbm_store_graph_digest_limits_t; + +typedef struct { + uint32_t version; + unsigned char sha256[CBM_STORE_GRAPH_DIGEST_BYTES]; + uint64_t rows; + uint64_t framed_bytes; +} cbm_store_graph_digest_t; + +/* Borrow a live, exclusive D5 scope. Hash the version-1 canonical stream of + * all nine known tables, including their exact CREATE SQL and explicit project + * identity. The database must use UTF-8. No new handler or transaction is used. + * Independent scopes/connections may run concurrently; no mutable state is + * shared. The callback/connection restrictions of D5 continue to apply. + * + * All arguments are required. project is nonempty, length <= INT_MAX, and has + * no embedded NUL; other database values are length-aware. Limits are positive, + * with max_framed_bytes <= UINT64_MAX/8. Bytes count the entire framed input; + * rows count data rows only. Limits do not cap SQLite's internal sort/value + * memory. Large values are hashed with scope checks at most 65536 bytes apart. + * + * Clear out before validation. Any failure leaves it zero and invalidates a + * supplied live scope. LIMIT latches D5 ERR; cancellation latches CANCELLED. + * Statements are always finalized. There is no result owner to free. The + * caller closes the scope and must reject the digest and discard the connection + * if closing returns SCOPE_DISCARD. A digest is content binding only, never + * authenticity, source identity, extraction completeness or permission to + * narrow selection. Do not reuse it as certification for another read scope. */ +cbm_store_graph_digest_status_t cbm_store_graph_digest( + cbm_store_read_scope_t *scope, + const unsigned char *project, size_t project_len, + const cbm_store_graph_digest_limits_t *limits, + cbm_store_graph_digest_t *out); + +#endif /* CBM_STORE_GRAPH_DIGEST_H */ diff --git a/src/store/store_impact.c b/src/store/store_impact.c new file mode 100644 index 000000000..a0e32c898 --- /dev/null +++ b/src/store/store_impact.c @@ -0,0 +1,636 @@ +/* + * store_impact.c — fixpoint impact walk over the stored graph. See + * store_impact.h for the contract. + * + * The walk is level-synchronous. Each level's frontier is sorted by qualified + * name and expanded edge type by edge type, in the rank order of the request: + * the first time a node is seen is therefore through its smallest (edge rank, + * parent name), which is the parent the contract promises, with no comparison + * in the inner loop. + */ +#include "store/store_impact.h" + +#include "foundation/constants.h" +#include "foundation/mem_core.h" + +#include +#include +#include +#include + +enum { + IMPACT_INIT_CAP = 64, + IMPACT_GROWTH = 2, + IMPACT_COL_QN = 0, + IMPACT_COL_LABEL = 1, + IMPACT_BIND_ID = 1, + IMPACT_BIND_TYPE = 2, + IMPACT_ROUTE_IN_COUNT = 3, +}; + +static const char IMPACT_HANDLES[] = "HANDLES"; +static const char IMPACT_ROUTE_LABEL[] = "Route"; +static const char *const IMPACT_ROUTE_IN[IMPACT_ROUTE_IN_COUNT] = {"HTTP_CALLS", "ASYNC_CALLS", + "GRPC_CALLS"}; + +struct cbm_impact_walk { + sqlite3 *db; + cbm_store_read_scope_t *scope; /* borrowed; NULL preserves legacy path */ + int status; /* sticky, keep count intact for allocated-row cleanup */ + char *project; + char **edge_types; /* owned copies: a hit's via_edge points into these */ + int edge_type_count; + bool follow_routes; + int max_hops; /* 0 = fixpoint */ + + int64_t max_id; /* largest node id when the walk opened */ + int32_t *slot; /* [max_id + 1]: index into hits + 1; 0 = not reached */ + uint8_t *sink; /* [max_id + 1]: reached, never expanded */ + + cbm_impact_hit_t *hits; + int count; + int cap; + int max_hop; + + sqlite3_stmt *stmt_node; /* qualified name and label of one node */ + sqlite3_stmt *stmt_in; /* sources of the edges of one type into a node */ + sqlite3_stmt *stmt_out; /* targets of the edges of one type out of a node */ +}; + +/* One frontier entry: the name to sort by and the hit it belongs to. The name + * pointer stays valid while the hit array grows; an index into it does too. */ +typedef struct { + const char *qualified_name; + int hit; +} impact_frontier_t; + +typedef struct { + impact_frontier_t *items; + int count; + int cap; +} impact_frontier_list_t; + +static int impact_frontier_cmp(const void *left, const void *right) { + const impact_frontier_t *a = (const impact_frontier_t *)left; + const impact_frontier_t *b = (const impact_frontier_t *)right; + return strcmp(a->qualified_name, b->qualified_name); +} + +static int impact_hit_cmp(const void *left, const void *right) { + const cbm_impact_hit_t *a = (const cbm_impact_hit_t *)left; + const cbm_impact_hit_t *b = (const cbm_impact_hit_t *)right; + if (a->hop != b->hop) { + return a->hop < b->hop ? -1 : 1; + } + return strcmp(a->qualified_name, b->qualified_name); +} + +/* Keep allocated rows for close even when their public view is invalidated. */ +static int impact_fail(cbm_impact_walk_t *w, int status) { + if (!w) { + return CBM_STORE_ERR; + } + if (w->status == CBM_STORE_OK) { + w->status = w->scope ? cbm_store_read_scope_fail(w->scope, status) : + (status == CBM_STORE_CANCELLED ? CBM_STORE_CANCELLED : CBM_STORE_ERR); + } + sqlite3_reset(w->stmt_node); + sqlite3_reset(w->stmt_in); + sqlite3_reset(w->stmt_out); + return w->status; +} + +static bool impact_check(cbm_impact_walk_t *w) { + if (!w || w->status != CBM_STORE_OK) { + return false; + } + int status = w->scope ? cbm_store_read_scope_check(w->scope) : CBM_STORE_OK; + if (status != CBM_STORE_OK) { + impact_fail(w, status); + return false; + } + return true; +} + +static bool impact_visible(const cbm_impact_walk_t *w) { + return w && w->status == CBM_STORE_OK && + (!w->scope || cbm_store_read_scope_check(w->scope) == CBM_STORE_OK); +} + +/* Scoped sorting polls inside comparisons and heap work. Each swap is atomic + * with respect to cancellation so the array remains a permutation for cleanup. + * The legacy path retains its existing qsort and comparators exactly. */ +static bool impact_sort_compare(cbm_impact_walk_t *w, const void *a, const void *b, + bool hits, int *order) { + if (!impact_check(w)) { + return false; + } + const char *left; + const char *right; + if (hits) { + const cbm_impact_hit_t *x = a; + const cbm_impact_hit_t *y = b; + if (x->hop != y->hop) { + *order = x->hop < y->hop ? -1 : 1; + return true; + } + left = x->qualified_name; + right = y->qualified_name; + } else { + left = ((const impact_frontier_t *)a)->qualified_name; + right = ((const impact_frontier_t *)b)->qualified_name; + } + const unsigned char *x = (const unsigned char *)left; + const unsigned char *y = (const unsigned char *)right; + unsigned ticks = 0; + while (*x && *x == *y) { + x++; + y++; + if (++ticks == 256) { + ticks = 0; + if (!impact_check(w)) { + return false; + } + } + } + *order = (*x > *y) - (*x < *y); + return true; +} + +static void impact_sort_swap(unsigned char *a, unsigned char *b, size_t size) { + for (size_t i = 0; i < size; i++) { + unsigned char byte = a[i]; + a[i] = b[i]; + b[i] = byte; + } +} + +static bool impact_sift(cbm_impact_walk_t *w, unsigned char *items, size_t count, + size_t root, size_t size, bool hits) { + while (root < count / 2) { + if (!impact_check(w)) { + return false; + } + size_t child = root * 2 + 1; + int order; + if (child + 1 < count) { + if (!impact_sort_compare(w, items + child * size, items + (child + 1) * size, + hits, &order)) { + return false; + } + if (order < 0) { + child++; + } + } + if (!impact_sort_compare(w, items + root * size, items + child * size, hits, &order)) { + return false; + } + if (order >= 0) { + break; + } + impact_sort_swap(items + root * size, items + child * size, size); + root = child; + } + return true; +} + +static bool impact_sort(cbm_impact_walk_t *w, void *array, int count, size_t size, bool hits) { + if (!impact_check(w)) { + return false; + } + if (!w->scope) { + if (count > 1) { + qsort(array, (size_t)count, size, hits ? impact_hit_cmp : impact_frontier_cmp); + } + return true; + } + unsigned char *items = array; + for (size_t root = (size_t)count / 2; root > 0; root--) { + if (!impact_sift(w, items, (size_t)count, root - 1, size, hits)) { + return false; + } + } + for (size_t end = (size_t)count; end > 1; end--) { + if (!impact_check(w)) { + return false; + } + impact_sort_swap(items, items + (end - 1) * size, size); + if (!impact_sift(w, items, end - 1, 0, size, hits)) { + return false; + } + } + return impact_check(w); +} + +static bool impact_frontier_push(impact_frontier_list_t *list, const char *qualified_name, + int hit) { + if (list->count == list->cap) { + if (list->cap > INT_MAX / IMPACT_GROWTH) { + return false; + } + int cap = list->cap ? list->cap * IMPACT_GROWTH : IMPACT_INIT_CAP; + impact_frontier_t *grown = (impact_frontier_t *)cbm_realloc( + CBM_MEM_CLASS_STORE, list->items, (size_t)cap * sizeof(*grown)); + if (!grown) { + return false; + } + list->items = grown; + list->cap = cap; + } + list->items[list->count].qualified_name = qualified_name; + list->items[list->count].hit = hit; + list->count++; + return true; +} + +static bool impact_known_id(const cbm_impact_walk_t *w, int64_t id) { + return id > 0 && id <= w->max_id; +} + +typedef enum { + IMPACT_ADD_OK = 0, + IMPACT_ADD_NO_NODE, /* the id names no node of this project */ + IMPACT_ADD_ERROR, /* allocation or SQLite failure */ +} impact_add_t; + +/* Record `id` as reached and queue it for expansion. The caller has checked + * that it is in range and not reached yet. */ +static impact_add_t impact_add_hit(cbm_impact_walk_t *w, impact_frontier_list_t *next, int64_t id, + int hop, int64_t via_id, const char *via_edge) { + if (!impact_check(w) || sqlite3_reset(w->stmt_node) != SQLITE_OK || + sqlite3_bind_int64(w->stmt_node, IMPACT_BIND_ID, id) != SQLITE_OK) { + return IMPACT_ADD_ERROR; + } + int rc = sqlite3_step(w->stmt_node); + if (!impact_check(w)) { + return IMPACT_ADD_ERROR; + } + if (rc == SQLITE_DONE) { + sqlite3_reset(w->stmt_node); + return IMPACT_ADD_NO_NODE; + } + if (rc != SQLITE_ROW) { + return IMPACT_ADD_ERROR; + } + const char *qn = (const char *)sqlite3_column_text(w->stmt_node, IMPACT_COL_QN); + const char *label = (const char *)sqlite3_column_text(w->stmt_node, IMPACT_COL_LABEL); + if (w->count == w->cap) { + if (w->cap > INT_MAX / IMPACT_GROWTH) { + return IMPACT_ADD_ERROR; + } + int cap = w->cap ? w->cap * IMPACT_GROWTH : IMPACT_INIT_CAP; + cbm_impact_hit_t *grown = (cbm_impact_hit_t *)cbm_realloc(CBM_MEM_CLASS_STORE, w->hits, + (size_t)cap * sizeof(*grown)); + if (!grown) { + return IMPACT_ADD_ERROR; + } + w->hits = grown; + w->cap = cap; + } + char *qn_copy = cbm_mem_strdup(CBM_MEM_CLASS_STORE, qn ? qn : ""); + char *label_copy = cbm_mem_strdup(CBM_MEM_CLASS_STORE, label ? label : ""); + if (!qn_copy || !label_copy || !impact_frontier_push(next, qn_copy, w->count)) { + cbm_free(CBM_MEM_CLASS_STORE, qn_copy); + cbm_free(CBM_MEM_CLASS_STORE, label_copy); + return IMPACT_ADD_ERROR; + } + cbm_impact_hit_t *hit = &w->hits[w->count]; + hit->id = id; + hit->hop = hop; + hit->via_id = via_id; + hit->via_edge = via_edge; + hit->qualified_name = qn_copy; + hit->label = label_copy; + w->slot[id] = w->count + SKIP_ONE; + w->count++; + if (hop > w->max_hop) { + w->max_hop = hop; + } + if (sqlite3_reset(w->stmt_node) != SQLITE_OK || !impact_check(w)) { + return IMPACT_ADD_ERROR; + } + return IMPACT_ADD_OK; +} + +/* Follow one edge type from `from` (a frontier hit) to every neighbour not + * reached yet. stmt selects the far end of the edges of that type. */ +static bool impact_expand(cbm_impact_walk_t *w, sqlite3_stmt *stmt, int from, + const char *edge_type, impact_frontier_list_t *next) { + if (!impact_check(w) || w->hits[from].hop == INT_MAX) { + return false; + } + int64_t from_id = w->hits[from].id; + int hop = w->hits[from].hop + SKIP_ONE; + if (sqlite3_reset(stmt) != SQLITE_OK || + sqlite3_bind_int64(stmt, IMPACT_BIND_ID, from_id) != SQLITE_OK || + sqlite3_bind_text(stmt, IMPACT_BIND_TYPE, edge_type, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK) { + return false; + } + int rc; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + if (!impact_check(w)) { + return false; + } + int64_t id = sqlite3_column_int64(stmt, 0); + if (!impact_known_id(w, id) || w->slot[id] != 0) { + continue; + } + /* An edge whose far end has no node row (or one of another project) + * leads nowhere; it is not an error of the request. */ + if (impact_add_hit(w, next, id, hop, from_id, edge_type) == IMPACT_ADD_ERROR) { + return false; + } + } + return rc == SQLITE_DONE && sqlite3_reset(stmt) == SQLITE_OK && impact_check(w); +} + +static bool impact_expand_level(cbm_impact_walk_t *w, const impact_frontier_list_t *frontier, + impact_frontier_list_t *next) { + for (int t = 0; t < w->edge_type_count; t++) { + for (int f = 0; f < frontier->count; f++) { + if (!impact_check(w)) { + return false; + } + int hit = frontier->items[f].hit; + if (w->sink[w->hits[hit].id]) { + continue; + } + if (!impact_expand(w, w->stmt_in, hit, w->edge_types[t], next)) { + return false; + } + } + } + if (!w->follow_routes) { + return true; + } + for (int f = 0; f < frontier->count; f++) { + if (!impact_check(w)) { + return false; + } + int hit = frontier->items[f].hit; + if (!w->sink[w->hits[hit].id] && + !impact_expand(w, w->stmt_out, hit, IMPACT_HANDLES, next)) { + return false; + } + } + for (int t = 0; t < IMPACT_ROUTE_IN_COUNT; t++) { + for (int f = 0; f < frontier->count; f++) { + if (!impact_check(w)) { + return false; + } + int hit = frontier->items[f].hit; + if (w->sink[w->hits[hit].id] || + strcmp(w->hits[hit].label, IMPACT_ROUTE_LABEL) != 0) { + continue; + } + if (!impact_expand(w, w->stmt_in, hit, IMPACT_ROUTE_IN[t], next)) { + return false; + } + } + } + return true; +} + +static void impact_free_strings(cbm_impact_walk_t *w) { + for (int i = 0; i < w->count; i++) { + cbm_free(CBM_MEM_CLASS_STORE, (char *)w->hits[i].qualified_name); + cbm_free(CBM_MEM_CLASS_STORE, (char *)w->hits[i].label); + } +} + +void cbm_impact_walk_close(cbm_impact_walk_t *w) { + if (!w) { + return; + } + sqlite3_finalize(w->stmt_node); + sqlite3_finalize(w->stmt_in); + sqlite3_finalize(w->stmt_out); + impact_free_strings(w); + cbm_free(CBM_MEM_CLASS_STORE, w->hits); + cbm_free(CBM_MEM_CLASS_STORE, w->slot); + cbm_free(CBM_MEM_CLASS_STORE, w->sink); + for (int i = 0; i < w->edge_type_count; i++) { + cbm_free(CBM_MEM_CLASS_STORE, w->edge_types[i]); + } + cbm_free(CBM_MEM_CLASS_STORE, w->edge_types); + cbm_free(CBM_MEM_CLASS_STORE, w->project); + cbm_free(CBM_MEM_CLASS_STORE, w); +} + +static bool impact_copy_policy(cbm_impact_walk_t *w, const cbm_impact_policy_t *policy) { + w->project = cbm_mem_strdup(CBM_MEM_CLASS_STORE, policy->project); + w->edge_types = (char **)cbm_calloc(CBM_MEM_CLASS_STORE, + (size_t)policy->edge_type_count * sizeof(char *)); + if (!w->project || !w->edge_types) { + return false; + } + for (int i = 0; i < policy->edge_type_count; i++) { + if (!impact_check(w)) { + return false; + } + if (!policy->edge_types[i] || !policy->edge_types[i][0]) { + return false; + } + w->edge_types[i] = cbm_mem_strdup(CBM_MEM_CLASS_STORE, policy->edge_types[i]); + if (!w->edge_types[i]) { + return false; + } + w->edge_type_count = i + SKIP_ONE; + } + w->follow_routes = policy->follow_routes; + w->max_hops = policy->max_hops > 0 ? policy->max_hops : 0; + return true; +} + +static bool impact_prepare(cbm_impact_walk_t *w) { + sqlite3_stmt *max_stmt = NULL; + if (!impact_check(w) || + sqlite3_prepare_v2(w->db, "SELECT COALESCE(MAX(id), 0) FROM nodes", CBM_NOT_FOUND, + &max_stmt, NULL) != SQLITE_OK) { + return false; + } + bool have_max = sqlite3_step(max_stmt) == SQLITE_ROW; + if (have_max) { + w->max_id = sqlite3_column_int64(max_stmt, 0); + } + int finalized = sqlite3_finalize(max_stmt); + if (!have_max || finalized != SQLITE_OK || !impact_check(w) || + w->max_id < 0 || w->max_id >= INT32_MAX) { + return false; + } + /* The whole per-node state, sized once from the graph: whether the walk + * can run is decided here, not while it is half way through. */ + size_t slots = (size_t)w->max_id + SKIP_ONE; + w->slot = (int32_t *)cbm_calloc(CBM_MEM_CLASS_STORE, slots * sizeof(int32_t)); + w->sink = (uint8_t *)cbm_calloc(CBM_MEM_CLASS_STORE, slots); + if (!w->slot || !w->sink) { + return false; + } + if (!impact_check(w) || sqlite3_prepare_v2(w->db, + "SELECT qualified_name, label FROM nodes WHERE id = ?1 AND project = ?2", + CBM_NOT_FOUND, &w->stmt_node, NULL) != SQLITE_OK || + !impact_check(w) || sqlite3_prepare_v2(w->db, "SELECT source_id FROM edges WHERE target_id = ?1 AND type = ?2", + CBM_NOT_FOUND, &w->stmt_in, NULL) != SQLITE_OK || + !impact_check(w) || sqlite3_prepare_v2(w->db, "SELECT target_id FROM edges WHERE source_id = ?1 AND type = ?2", + CBM_NOT_FOUND, &w->stmt_out, NULL) != SQLITE_OK) { + return false; + } + /* The project never changes for the life of the walk. */ + return sqlite3_bind_text(w->stmt_node, IMPACT_BIND_TYPE, w->project, CBM_NOT_FOUND, + SQLITE_STATIC) == SQLITE_OK && impact_check(w); +} + +static int impact_open(cbm_store_t *s, cbm_store_read_scope_t *scope, + const cbm_impact_policy_t *policy, cbm_impact_walk_t **out) { + if (out) { + *out = NULL; + } + if (scope) { + int status = cbm_store_read_scope_check(scope); + if (status != CBM_STORE_OK) { + return status; + } + } + sqlite3 *db = cbm_store_get_db(s); + if (!db || !out || !policy || !policy->project || !policy->project[0] || + !policy->edge_types || policy->edge_type_count <= 0) { + return scope ? cbm_store_read_scope_fail(scope, CBM_STORE_ERR) : CBM_STORE_ERR; + } + cbm_impact_walk_t *w = (cbm_impact_walk_t *)cbm_calloc(CBM_MEM_CLASS_STORE, sizeof(*w)); + if (!w) { + return scope ? cbm_store_read_scope_fail(scope, CBM_STORE_ERR) : CBM_STORE_ERR; + } + w->db = db; + w->scope = scope; + if (!impact_copy_policy(w, policy) || !impact_prepare(w) || !impact_check(w)) { + int status = impact_fail(w, CBM_STORE_ERR); + cbm_impact_walk_close(w); + return status; + } + *out = w; + return CBM_STORE_OK; +} + +int cbm_impact_walk_open(cbm_store_t *s, const cbm_impact_policy_t *policy, + cbm_impact_walk_t **out) { + return impact_open(s, NULL, policy, out); +} + +int cbm_impact_walk_open_scoped(cbm_store_read_scope_t *scope, + const cbm_impact_policy_t *policy, cbm_impact_walk_t **out) { + if (!scope) { + if (out) { + *out = NULL; + } + return CBM_STORE_ERR; + } + return impact_open(cbm_store_read_scope_store(scope), scope, policy, out); +} + +/* True when `id` names a node of the walk's project. */ +static bool impact_node_exists(cbm_impact_walk_t *w, int64_t id) { + if (!impact_check(w) || !impact_known_id(w, id) || + sqlite3_reset(w->stmt_node) != SQLITE_OK || + sqlite3_bind_int64(w->stmt_node, IMPACT_BIND_ID, id) != SQLITE_OK) { + return false; + } + int rc = sqlite3_step(w->stmt_node); + int reset = sqlite3_reset(w->stmt_node); + return rc == SQLITE_ROW && reset == SQLITE_OK && impact_check(w); +} + +int cbm_impact_walk_add_sinks(cbm_impact_walk_t *w, const int64_t *ids, int count) { + if (!impact_check(w) || count < 0 || (count > 0 && !ids)) { + return impact_fail(w, CBM_STORE_ERR); + } + for (int i = 0; i < count; i++) { + if (!impact_node_exists(w, ids[i])) { + return impact_fail(w, CBM_STORE_ERR); + } + } + for (int i = 0; i < count; i++) { + if (!impact_check(w)) { + return impact_fail(w, CBM_STORE_ERR); + } + w->sink[ids[i]] = SKIP_ONE; + } + return impact_check(w) ? CBM_STORE_OK : impact_fail(w, CBM_STORE_ERR); +} + +static bool impact_canonical_order(cbm_impact_walk_t *w) { + if (!impact_sort(w, w->hits, w->count, sizeof(*w->hits), true)) { + return false; + } + for (int i = 0; i < w->count; i++) { + if (!impact_check(w)) { + return false; + } + w->slot[w->hits[i].id] = i + SKIP_ONE; + } + return impact_check(w); +} + +int cbm_impact_walk_run(cbm_impact_walk_t *w, const int64_t *seeds, int count) { + if (!impact_check(w) || count < 0 || (count > 0 && !seeds) || + (w->count > 0 && w->max_hop == INT_MAX)) { + return impact_fail(w, CBM_STORE_ERR); + } + for (int i = 0; i < count; i++) { + if (!impact_check(w) || !impact_known_id(w, seeds[i])) { + return impact_fail(w, CBM_STORE_ERR); + } + } + impact_frontier_list_t frontier = {0}; + impact_frontier_list_t next = {0}; + int base_hop = w->count > 0 ? w->max_hop + SKIP_ONE : 0; + int level = 0; /* hops walked from the seeds of this run */ + bool ok = true; + for (int i = 0; i < count && ok; i++) { + if (!impact_check(w)) { + ok = false; + break; + } + if (w->slot[seeds[i]] != 0) { + continue; /* reached already, or the same seed twice */ + } + ok = impact_add_hit(w, &frontier, seeds[i], base_hop, 0, NULL) == IMPACT_ADD_OK; + } + while (ok && frontier.count > 0 && (w->max_hops == 0 || level < w->max_hops)) { + if (level == INT_MAX || + !impact_sort(w, frontier.items, frontier.count, sizeof(*frontier.items), false)) { + ok = false; + break; + } + level++; + next.count = 0; + ok = impact_expand_level(w, &frontier, &next); + impact_frontier_list_t done = frontier; + frontier = next; + next = done; + } + cbm_free(CBM_MEM_CLASS_STORE, frontier.items); + cbm_free(CBM_MEM_CLASS_STORE, next.items); + sqlite3_reset(w->stmt_node); + sqlite3_reset(w->stmt_in); + sqlite3_reset(w->stmt_out); + if (!ok || !impact_canonical_order(w) || !impact_check(w)) { + return impact_fail(w, CBM_STORE_ERR); + } + return CBM_STORE_OK; +} + +int cbm_impact_walk_count(const cbm_impact_walk_t *w) { + return impact_visible(w) ? w->count : 0; +} + +const cbm_impact_hit_t *cbm_impact_walk_hits(const cbm_impact_walk_t *w) { + return impact_visible(w) ? w->hits : NULL; +} + +bool cbm_impact_walk_reached(const cbm_impact_walk_t *w, int64_t id) { + return impact_visible(w) && impact_known_id(w, id) && w->slot[id] != 0; +} + +const cbm_impact_hit_t *cbm_impact_walk_hit(const cbm_impact_walk_t *w, int64_t id) { + return cbm_impact_walk_reached(w, id) ? &w->hits[w->slot[id] - SKIP_ONE] : NULL; +} diff --git a/src/store/store_impact.h b/src/store/store_impact.h new file mode 100644 index 000000000..fce10bd84 --- /dev/null +++ b/src/store/store_impact.h @@ -0,0 +1,93 @@ +/* + * store_impact.h — fixpoint impact walk over the stored graph. + * + * "What depends on these nodes?" Starting from a set of seeds, the walk follows + * the requested edge types BACKWARDS (`caller --CALLS--> callee`: a change to + * the callee reaches the caller) until nothing new is reached. + * + * It exists next to cbm_store_bfs_multi because test selection needs three + * things the depth-bounded recursive CTE cannot give: + * - a true fixpoint. Every node is expanded once, so a cycle terminates and + * the cost is O(nodes reached + edges probed); no depth or row cap decides + * the result; + * - a result that is a pure function of the graph and the request. Hits come + * back ordered by (hop, qualified name), and the parent recorded for a hit + * is, among the parents one hop closer, the one with the smallest (rank of + * the edge type in the request, qualified name). Node ids, row order and + * seed order never take part; + * - walk state that is sized when the walk opens. A refused allocation is an + * error of the request, never a shorter answer. + */ +#ifndef CBM_STORE_IMPACT_H +#define CBM_STORE_IMPACT_H + +#include "store/store.h" + +#include +#include + +typedef struct cbm_impact_walk cbm_impact_walk_t; + +typedef struct { + const char *project; + /* Edge types to walk, each from its target to its sources. The order is + * the rank used to pick the recorded parent. At least one. */ + const char *const *edge_types; + int edge_type_count; + /* Also cross HTTP/async/gRPC routes: a handler reaches the Route it + * HANDLES, and a Route reaches whoever calls it over HTTP_CALLS, + * ASYNC_CALLS or GRPC_CALLS. These rank after every requested type. */ + bool follow_routes; + /* 0 walks to the fixpoint. A positive value stops expanding nodes that are + * that many hops from the seeds of their run: the bounded neighbourhood, + * with the same ordering and parents. */ + int max_hops; +} cbm_impact_policy_t; + +typedef struct { + int64_t id; + int hop; /* 0 for a seed of the first run */ + int64_t via_id; /* the parent this node was reached from; 0 for a seed */ + const char *via_edge; /* edge type from the parent; NULL for a seed */ + const char *qualified_name; /* owned by the walk */ + const char *label; /* owned by the walk */ +} cbm_impact_hit_t; + +/* Open a walk. The policy is copied. CBM_STORE_ERR on a missing store, project + * or edge type, and when the walk state cannot be allocated. */ +int cbm_impact_walk_open(cbm_store_t *s, const cbm_impact_policy_t *policy, + cbm_impact_walk_t **out); + +/* Same walk within a pinned read scope, borrowed until close. All work, + * including ordering, checks cancellation. The first walk error invalidates + * this walk and its scope. Close the walk before closing the scope. */ +int cbm_impact_walk_open_scoped(cbm_store_read_scope_t *scope, + const cbm_impact_policy_t *policy, cbm_impact_walk_t **out); + +/* Nodes that are reached but never expanded: a test case that reaches the + * change is selected, and what calls the test case is not impacted by it. May + * be called before any run and between runs. An id the store does not hold is + * CBM_STORE_ERR. */ +int cbm_impact_walk_add_sinks(cbm_impact_walk_t *w, const int64_t *ids, int count); + +/* Add seeds and walk to the fixpoint. A later call adds seeds to the state the + * earlier ones left (a "lift"): they enter one hop beyond everything reached + * so far, nodes already reached keep their hop and parent, and the walk goes + * on to the new fixpoint. A seed the store does not hold is CBM_STORE_ERR: an + * empty answer would read as "nothing depends on this". After an error the + * walk can only be closed. */ +int cbm_impact_walk_run(cbm_impact_walk_t *w, const int64_t *seeds, int count); + +/* Everything reached, seeds included, ordered by (hop, qualified name). The + * array is valid until the next operation or close. After any error or scope + * failure all accessors hide every hit, including prior lifts; previously + * borrowed hit pointers must not be used. Only close remains valid. */ +int cbm_impact_walk_count(const cbm_impact_walk_t *w); +const cbm_impact_hit_t *cbm_impact_walk_hits(const cbm_impact_walk_t *w); +bool cbm_impact_walk_reached(const cbm_impact_walk_t *w, int64_t id); +/* The hit of one node, or NULL when the walk did not reach it. */ +const cbm_impact_hit_t *cbm_impact_walk_hit(const cbm_impact_walk_t *w, int64_t id); + +void cbm_impact_walk_close(cbm_impact_walk_t *w); + +#endif /* CBM_STORE_IMPACT_H */ diff --git a/test-infrastructure/vm/vm-run-tests.sh b/test-infrastructure/vm/vm-run-tests.sh index ec17f771d..57565bf6a 100644 --- a/test-infrastructure/vm/vm-run-tests.sh +++ b/test-infrastructure/vm/vm-run-tests.sh @@ -107,21 +107,33 @@ fi RUNNER="${CBM_VM_RUNNER:-}" -# Per-run identity. The VM holds ONE checkout (/c/cbm), so two concurrent runs -# shared a FIXED log path and the same build dir: the later run's output -# replaced the earlier one's, and -PruneStale could delete a live run's temp -# root out from under it. The run id namespaces the log and the build dir; the -# protected temp root is already unique per run. +# A supplied run id namespaces the log/build paths and disables shared pruning. CALLER_RUN_ID="${CBM_CI_RUN_ID:-}" +if [ -n "$CALLER_RUN_ID" ]; then + if [[ ! "$CALLER_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]]; then + echo "FATAL: CBM_CI_RUN_ID must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}." >&2 + exit 2 + fi + case "${CBM_CI_KEEP:-0}" in + 0 | 1) ;; + *) echo "FATAL: CBM_CI_KEEP must be 0 or 1." >&2; exit 2 ;; + esac +fi RUN_ID="${CBM_CI_RUN_ID:-$$-$(date +%s)}" export CBM_CI_RUN_ID="$RUN_ID" LOG="${CBM_VM_TEST_LOG:-/tmp/win-test-${RUN_ID}.log}" +EFFECTIVE_BUILD_DIR=build/c +TEMP_ROOT_ARGS=(-Prefix 'cbm-vm-tmp-' -PruneStale) # A caller that sets CBM_CI_RUN_ID is declaring concurrency, so give that run # its own BUILD_DIR; the default single-run path keeps the shared one and its # incremental reuse (a per-run build dir on the VM costs a full rebuild). if [ -n "$CALLER_RUN_ID" ]; then - BUILD_ARGS=("BUILD_DIR=build/vm-${RUN_ID}") + EFFECTIVE_BUILD_DIR="build/vm-${RUN_ID}" + BUILD_ARGS=("BUILD_DIR=$EFFECTIVE_BUILD_DIR") + LOG="${CBM_VM_TEST_LOG:-$PWD/.vm-logs/$RUN_ID/test.log}" + # Neither legacy cbm-* nor cbm-vm-tmp-* cleanup can select this namespace. + TEMP_ROOT_ARGS=(-Prefix "vm-cbm-${RUN_ID}-") else BUILD_ARGS=() fi @@ -148,17 +160,20 @@ else # Default path: suites run through the canonical scripts/test.sh (which # builds its own runner, same as CI's test jobs). ACL-protect the build # directory it will use. - artifact="build/c/test-runner" + artifact="$EFFECTIVE_BUILD_DIR/test-runner" fi -# Stale roots from earlier runs are removed up front; the current root is kept -# after the run for post-mortem inspection. The root itself is created by the +# Shared-mode stale roots are removed up front; isolated mode never prunes. +# The current root is kept after the run for inspection. It is created by the # same script CI uses (scripts/ci/new-protected-temp-root.ps1) so the two venues # cannot drift apart on the ACL shape the daemon suites are validated against. +if [ -n "$CALLER_RUN_ID" ]; then + mkdir -p -- "$(dirname "$LOG")" || exit 2 +fi root_windows="$(MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile \ -ExecutionPolicy Bypass \ -File "$(cygpath -w scripts/ci/new-protected-temp-root.ps1)" \ - -Prefix 'cbm-vm-tmp-' -PruneStale \ + "${TEMP_ROOT_ARGS[@]}" \ -ProtectDir "$(cygpath -w "$(dirname "$artifact")")" | tr -d '\r')" [ -n "$root_windows" ] || { echo "ERROR: protected temp root creation failed" >&2; exit 2; } diff --git a/test-infrastructure/vm/win.sh b/test-infrastructure/vm/win.sh index 2f373e21a..15ec75a10 100755 --- a/test-infrastructure/vm/win.sh +++ b/test-infrastructure/vm/win.sh @@ -71,6 +71,9 @@ Environment: CBM_VM_BRANCH branch for update (default: current local branch) CBM_VM_MIN_FREE_GB preflight free-disk floor (default 14, runner spec) CBM_VM_SKIP_PREFLIGHT=1 bootstrap-only escape (checkout predates the script) + CBM_CI_RUN_ID isolated checkout, build, temp and log namespace; + checks disk/Defender without shared residue cleanup + CBM_CI_KEEP=0|1 retain isolated test build on success when set to 1 Exit codes: 0 success · 2 usage error · other = the canonical leg's own code. EOF @@ -101,12 +104,41 @@ USER_="${CBM_VM_USER:-test}" # A local clone hardlinks .git/objects, so it costs seconds and little disk. # Unset (the default) keeps the historical single-tree behaviour. VM_BASE_REPO="/c/cbm" +VM_RUN_ENV="" +VM_UBSAN_RUNNER_ENV="" +VM_LOG_SETUP="" +VM_BUILD_LOG=/tmp/win-build.log +VM_TEST_PAR_LOG=/tmp/win-test-par.log +VM_SOAK_LOG=/tmp/win-soak.log +VM_UBSAN_BUILD_LOG=/tmp/win-ubsan-build.log +VM_UBSAN_TEST_LOG=/tmp/win-ubsan-test.log +VM_TRAP_BUILD_LOG=/tmp/win-trap-ubsan-build.log +VM_TRAP_TEST_LOG=/tmp/win-trap-ubsan-test.log if [ -n "${CBM_CI_RUN_ID:-}" ]; then if [[ ! "$CBM_CI_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]]; then echo "FATAL: CBM_CI_RUN_ID must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}." >&2 exit 2 fi + case "${CBM_CI_KEEP:-0}" in + 0 | 1) ;; + *) echo "FATAL: CBM_CI_KEEP must be 0 or 1." >&2; exit 2 ;; + esac + if [ "${CBM_VM_SKIP_PREFLIGHT:-0}" = "1" ]; then + echo "FATAL: isolated runs require disk and Defender preflight." >&2 + exit 2 + fi VM_REPO="/c/cbm-run-${CBM_CI_RUN_ID}" + VM_RUN_ENV="CBM_CI_RUN_ID='$CBM_CI_RUN_ID' CBM_CI_KEEP='${CBM_CI_KEEP:-0}'" + VM_UBSAN_RUNNER_ENV="CBM_VM_RUNNER=build/c/test-runner" + VM_LOG_DIR="$VM_REPO/.vm-logs/$CBM_CI_RUN_ID" + VM_LOG_SETUP="mkdir -p '$VM_LOG_DIR' && " + VM_BUILD_LOG="$VM_LOG_DIR/build.log" + VM_TEST_PAR_LOG="$VM_LOG_DIR/test-par.log" + VM_SOAK_LOG="$VM_LOG_DIR/soak.log" + VM_UBSAN_BUILD_LOG="$VM_LOG_DIR/ubsan-build.log" + VM_UBSAN_TEST_LOG="$VM_LOG_DIR/ubsan-test.log" + VM_TRAP_BUILD_LOG="$VM_LOG_DIR/trap-ubsan-build.log" + VM_TRAP_TEST_LOG="$VM_LOG_DIR/trap-ubsan-test.log" else VM_REPO="$VM_BASE_REPO" fi @@ -189,8 +221,10 @@ vm_preflight() { echo "=== win.sh: preflight SKIPPED (CBM_VM_SKIP_PREFLIGHT=1) ===" return 0 } + local cleanup_mode="" + [ -z "${CBM_CI_RUN_ID:-}" ] || cleanup_mode=" -CheckOnly" vm_cmd "powershell -NoProfile -ExecutionPolicy Bypass -File \ -${VM_REPO_WIN}\\scripts\\ci\\clean-test-residue.ps1 -MinFreeGB ${CBM_VM_MIN_FREE_GB:-14}" +${VM_REPO_WIN}\\scripts\\ci\\clean-test-residue.ps1 -MinFreeGB ${CBM_VM_MIN_FREE_GB:-14}${cleanup_mode}" # Defender parity: every Windows venue (this VM and the GitHub runners) # tests with real-time protection ACTIVE — the same canonical script the # CI jobs run, so a drifted-off Defender fails loudly, never silently. @@ -270,7 +304,7 @@ build) # NOT via CC='ccache clang', which bypassed the verified-cache env layer. # The test-runner is no longer built here: the test leg (scripts/test.sh) # builds its own runner, same as CI's test jobs. - vm clangarm64 "cd $VM_REPO && scripts/build.sh CC=clang CXX=clang++ > /tmp/win-build.log 2>&1 && echo BUILD_OK || (echo BUILD_FAIL; tail -20 /tmp/win-build.log; exit 1)" + vm clangarm64 "cd $VM_REPO && ${VM_LOG_SETUP}scripts/build.sh CC=clang CXX=clang++ > '$VM_BUILD_LOG' 2>&1 && echo BUILD_OK || (echo BUILD_FAIL; tail -20 '$VM_BUILD_LOG'; exit 1)" ;; test) [ $# -ge 1 ] || { echo "usage: win.sh test " >&2; exit 2; } @@ -283,7 +317,7 @@ test) # before spending a leg on it — cheap insurance against a concurrent # session having re-synced the shared checkout. [ -z "${CBM_VM_EXPECT_HEAD:-}" ] || vm_assert_head "$CBM_VM_EXPECT_HEAD" - vm clangarm64 "cd $VM_REPO && bash test-infrastructure/vm/vm-run-tests.sh $*" + vm clangarm64 "cd $VM_REPO && $VM_RUN_ENV bash test-infrastructure/vm/vm-run-tests.sh $*" [ -z "${CBM_VM_EXPECT_HEAD:-}" ] || vm_assert_head "$CBM_VM_EXPECT_HEAD" ;; guards) @@ -331,7 +365,7 @@ soak) echo "usage: win.sh soak [positive-minutes]" >&2 exit 2 fi - vm clangarm64 "cd $VM_REPO && CBM_VM_TEST_LOG=/tmp/win-soak.log bash \ + vm clangarm64 "cd $VM_REPO && $VM_RUN_ENV CBM_VM_TEST_LOG='$VM_SOAK_LOG' bash \ test-infrastructure/vm/vm-run-tests.sh --soak '$duration'" ;; sh) @@ -348,11 +382,11 @@ ubsan-build) # Validated: UBSan needs no interceptors, so it builds, runs, AND reports # correctly under emulation. (ASan does NOT: no aarch64 runtime exists and # the x86_64 runtime faults in emulated process-init — ASan stays CI-only.) - vm clang64 "cd $VM_REPO && make -j${JOBS} -f Makefile.cbm CC=clang CXX=clang++ SANITIZE='-fsanitize=undefined -fno-omit-frame-pointer' build/c/test-runner > /tmp/win-ubsan-build.log 2>&1 && echo UBSAN_BUILD_OK || (echo UBSAN_BUILD_FAIL; tail -20 /tmp/win-ubsan-build.log; exit 1)" + vm clang64 "cd $VM_REPO && ${VM_LOG_SETUP}make -j${JOBS} -f Makefile.cbm CC=clang CXX=clang++ SANITIZE='-fsanitize=undefined -fno-omit-frame-pointer' build/c/test-runner > '$VM_UBSAN_BUILD_LOG' 2>&1 && echo UBSAN_BUILD_OK || (echo UBSAN_BUILD_FAIL; tail -20 '$VM_UBSAN_BUILD_LOG'; exit 1)" ;; ubsan-test) [ $# -ge 1 ] || { echo "usage: win.sh ubsan-test " >&2; exit 2; } - vm clang64 "cd $VM_REPO && CBM_VM_TEST_LOG=/tmp/win-ubsan-test.log bash test-infrastructure/vm/vm-run-tests.sh $*" + vm clang64 "cd $VM_REPO && $VM_RUN_ENV $VM_UBSAN_RUNNER_ENV CBM_VM_TEST_LOG='$VM_UBSAN_TEST_LOG' bash test-infrastructure/vm/vm-run-tests.sh $*" ;; trap-ubsan-build) # NATIVE ARM64 UBSan via trap mode. -fsanitize-trap=undefined needs NO @@ -364,13 +398,13 @@ trap-ubsan-build) # fired, reproduce under the emulated `win.sh ubsan-build`/`ubsan-test`, # which carries the full runtime + message. BUILD_DIR isolated so it never # clobbers the plain test-runner. - vm clangarm64 "cd $VM_REPO && make -j${JOBS} -f Makefile.cbm CC='ccache clang' CXX='ccache clang++' SANITIZE='-fsanitize=undefined -fsanitize-trap=undefined -fstack-protector-strong -fno-omit-frame-pointer' BUILD_DIR=build/trap-ubsan build/trap-ubsan/test-runner > /tmp/win-trap-ubsan-build.log 2>&1 && echo TRAP_UBSAN_BUILD_OK || (echo TRAP_UBSAN_BUILD_FAIL; tail -20 /tmp/win-trap-ubsan-build.log; exit 1)" + vm clangarm64 "cd $VM_REPO && ${VM_LOG_SETUP}make -j${JOBS} -f Makefile.cbm CC='ccache clang' CXX='ccache clang++' SANITIZE='-fsanitize=undefined -fsanitize-trap=undefined -fstack-protector-strong -fno-omit-frame-pointer' BUILD_DIR=build/trap-ubsan build/trap-ubsan/test-runner > '$VM_TRAP_BUILD_LOG' 2>&1 && echo TRAP_UBSAN_BUILD_OK || (echo TRAP_UBSAN_BUILD_FAIL; tail -20 '$VM_TRAP_BUILD_LOG'; exit 1)" ;; trap-ubsan-test) [ $# -ge 1 ] || { echo "usage: win.sh trap-ubsan-test " >&2; exit 2; } # A UB trap crashes the runner with SIGILL (exit 132); the harness reports # the failing suite so the emulated diagnosis loop can name the check. - vm clangarm64 "cd $VM_REPO && CBM_VM_RUNNER=build/trap-ubsan/test-runner CBM_VM_TEST_LOG=/tmp/win-trap-ubsan-test.log bash test-infrastructure/vm/vm-run-tests.sh $*" + vm clangarm64 "cd $VM_REPO && $VM_RUN_ENV CBM_VM_RUNNER=build/trap-ubsan/test-runner CBM_VM_TEST_LOG='$VM_TRAP_TEST_LOG' bash test-infrastructure/vm/vm-run-tests.sh $*" ;; pageheap) # OS-level heap verification (page-granular overflow/UAF detection) for the @@ -393,7 +427,9 @@ test-par) # under the CI-shaped protected temp root with FULL output (this leg once # ran under the MSYS-shared /tmp and piped through `tail -25` — the same # truncated-blindness class that hid 40 Windows failures from `test`). - vm clangarm64 "cd $VM_REPO && CBM_VM_TEST_LOG=/tmp/win-test-par.log bash test-infrastructure/vm/vm-run-tests.sh --par" + [ -z "${CBM_VM_EXPECT_HEAD:-}" ] || vm_assert_head "$CBM_VM_EXPECT_HEAD" + vm clangarm64 "cd $VM_REPO && $VM_RUN_ENV CBM_VM_TEST_LOG='$VM_TEST_PAR_LOG' bash test-infrastructure/vm/vm-run-tests.sh --par" + [ -z "${CBM_VM_EXPECT_HEAD:-}" ] || vm_assert_head "$CBM_VM_EXPECT_HEAD" ;; help | -h | --help) print_help diff --git a/tests/coverage_live_v2_admin.py b/tests/coverage_live_v2_admin.py new file mode 100644 index 000000000..149f831bd --- /dev/null +++ b/tests/coverage_live_v2_admin.py @@ -0,0 +1,170 @@ +"""Administrative command doubles: never positive native profile evidence.""" + +import json +from pathlib import Path +import sys + +from coverage_live_v2_native import retained_profiles +from coverage_live_v2_support import digest, require + + +def executable(path, body): + require(not any(char.isspace() for char in sys.executable), "test Python shebang path contains whitespace") + path.write_text("#!" + sys.executable + "\n" + body, encoding="utf-8") + path.chmod(0o700) + return path + + +def untouched_runner(case): + marker = case / "runner-was-invoked" + script = executable(case / "administrative-runner", "from pathlib import Path\n" + + "Path(%r).write_text('invoked\\n')\nraise SystemExit(93)\n" % str(marker)) + return script, marker + + +def no_marker(process, destination): + require(process["exit"] == 2, "invalid/unsupported/resource input must exit2") + require(not (destination / "meta.json").exists(), "invalid collection committed a success marker") + + +def image_mismatch(context, case): + runner, marker = untouched_runner(case) + image = digest(runner) + wrong = ("1" if image[0] == "0" else "0") + image[1:] + registry = context.registry(case, image=wrong) + process, destination = context.collect(case, registry, runner=runner) + no_marker(process, destination) + require(not marker.exists(), "image mismatch executed runner before rejection") + return {"class": "administrative", "runner_invoked": False} + + +def existing_output(context, case, symlink=False): + runner, marker = untouched_runner(case) + registry = context.registry(case, image=digest(runner)) + original = case / "caller-directory" + original.mkdir() + sentinel = original / "caller-data" + sentinel.write_bytes(b"caller-owned bytes\x00unchanged\n") + old_meta = original / "meta.json" + old_meta.write_bytes(b'{"caller":"preexisting-marker"}\n') + before = {path.name: digest(path) for path in original.iterdir()} + destination = case / "bundle" if symlink else original + if symlink: + destination.symlink_to(original, target_is_directory=True) + process, _ = context.collect(case, registry, runner=runner, destination=destination) + require(process["exit"] == 2, "existing output must be rejected") + require(not marker.exists(), "existing output rejection executed runner") + require({path.name: digest(path) for path in original.iterdir()} == before, + "collector changed preexisting caller data/marker") + require(not symlink or destination.is_symlink(), "collector replaced caller symlink") + return {"class": "administrative", "caller_bytes_preserved": True, "symlink": symlink} + + +def invalid_registry(context, case): + runner, marker = untouched_runner(case) + image = digest(runner) + valid = {"format": "cbm.coverage.registry.v0", "image_sha256": image, + "suites": [{"name": "coverage_setup_success", "tests": ["coverage_setup_first"]}]} + samples = { + "duplicate-key": ('{"format":"cbm.coverage.registry.v0","format":"cbm.coverage.registry.v0",' + '"image_sha256":"%s","suites":[]}' % image).encode(), + "invalid-utf8": b"\xff", + "oversize": b" " * (2 * 1024 * 1024 + 1), + "extra-evidence": json.dumps(dict(valid, runtime_supported=True)).encode(), + } + for index, test in enumerate(("../escape", "a/b", "a\\b", ".", "..", "_setup", "_setup.child")): + item = dict(valid, suites=[{"name": "coverage_setup_success", "tests": [test]}]) + samples["routing-%d" % index] = json.dumps(item).encode() + for name, content in samples.items(): + directory = case / name + directory.mkdir() + registry = directory / "registry.json" + registry.write_bytes(content) + process, destination = context.collect(directory, registry, runner=runner) + no_marker(process, destination) + require(not marker.exists(), "invalid registry reached runner") + linked = case / "linked-registry" + linked.mkdir() + target = linked / "actual.json" + target.write_text(json.dumps(valid), encoding="utf-8") + registry = linked / "registry.json" + registry.symlink_to(target) + before = digest(target) + process, destination = context.collect(linked, registry, runner=runner) + no_marker(process, destination) + require(registry.is_symlink() and digest(target) == before and not marker.exists(), + "registry symlink was followed or changed") + return {"class": "administrative", "invalid_samples": list(samples), "symlink_preserved": True} + + +def bounds(context, case): + runner, marker = untouched_runner(case) + options = ["--max-output-bytes", "--max-total-output-bytes", "--max-profile-bytes", + "--max-total-profile-bytes", "--max-profile-files", "--conversion-timeout", + "--max-map-bytes", "--reduction-timeout", "--timeout"] + for number, option in enumerate(options): + directory = case / ("zero-%d" % number) + directory.mkdir() + registry = context.registry(directory, image=digest(runner)) + process, destination = context.collect(directory, registry, runner=runner, extra=[option, "0"]) + no_marker(process, destination) + require(not marker.exists(), "invalid positive bounds reached runner") + directory = case / "native-output-bound" + directory.mkdir() + registry = context.registry(directory) + process, destination = context.collect(directory, registry, extra=["--max-output-bytes", "1"]) + no_marker(process, destination) + directory = case / "native-file-count-bound" + directory.mkdir() + registry = context.registry(directory) + process, destination = context.collect(directory, registry, extra=["--max-profile-files", "1"]) + no_marker(process, destination) + require(len(retained_profiles(destination)) >= 2, "profile bound was not reached after real native evidence") + return {"class": "administrative-bounds-with-native-file-control", "positive_options": options} + + +def converter_errors(context, case): + results = [] + for mode in ("malformed", "unknown", "nonzero", "unsupported-version"): + directory = case / mode + directory.mkdir() + tools = directory / "tools" + tools.mkdir() + witness = directory / "administrative-conversion-witness" + body = """import os +from pathlib import Path +import sys +real = %r +mode = %r +witness = Path(%r) +args = sys.argv[1:] +if mode == 'unsupported-version' and '--version' in args: + witness.write_text('administrative unsupported version\\n') + print('LLVM version 0.0.0') + raise SystemExit(0) +observation_conversion = False +if args and args[0] == 'merge': + counter = witness.with_name('administrative-merge-count') + count = int(counter.read_text()) + 1 if counter.exists() else 1 + counter.write_text(str(count)) + observation_conversion = count > 1 +if observation_conversion: + witness.write_text('administrative converter response: ' + mode + '\\n') + if mode == 'nonzero': + print('administrative converter failure', file=sys.stderr) + raise SystemExit(73) + if mode == 'malformed': + sys.stdout.buffer.write(b'unsupported proftext response\\n') + else: + sys.stdout.buffer.write(b'__cbm_live_v2_test_unknown_identity__\\n# Func Hash:\\n18446744073709551615\\n# Num Counters:\\n1\\n# Counter Values:\\n1\\n\\n') + raise SystemExit(0) +os.execv(real, [real] + args) +""" % (context.tool, mode, str(witness)) + executable(tools / "llvm-profdata", body) + registry = context.registry(directory) + process, destination = context.collect(directory, registry, llvm_bin=tools) + no_marker(process, destination) + require(witness.is_file(), "administrative converter boundary was not reached") + results.append(mode) + return {"class": "administrative-converter-doubles", "responses": results, + "native_positive_evidence": False} diff --git a/tests/coverage_live_v2_fault.py b/tests/coverage_live_v2_fault.py new file mode 100644 index 000000000..5ec61d923 --- /dev/null +++ b/tests/coverage_live_v2_fault.py @@ -0,0 +1,50 @@ +"""Test-only invocation adapter: one fixed resource fault in the real native child.""" + +import json +import os +from pathlib import Path +import resource +import runpy +import subprocess +import sys + + +def main(): + collector, runner, suite, receipt, *arguments = sys.argv[1:] + original = subprocess.Popen + children = [] + + def nofile(): + resource.setrlimit(resource.RLIMIT_NOFILE, (7, 7)) + + def launch(args, *positional, **keywords): + selected = (isinstance(args, (list, tuple)) and len(args) == 2 + and os.fspath(args[0]) == runner and os.fspath(args[1]) == suite) + if selected: + if keywords.get("preexec_fn") is not None: + raise RuntimeError("native fault boundary already has a pre-exec callback") + keywords["preexec_fn"] = nofile + process = original(args, *positional, **keywords) + if selected: + children.append((process, {"pid": process.pid, "argv": list(args), + "rlimit_nofile": 7, + "start_new_session": keywords.get("start_new_session", False)})) + return process + + subprocess.Popen = launch + sys.path.insert(0, str(Path(collector).parent)) + sys.argv = [collector] + arguments + try: + runpy.run_path(collector, run_name="__main__") + finally: + rows = [] + for process, row in children: + row["exit"] = process.returncode + rows.append(row) + with open(receipt, "x", encoding="utf-8") as stream: + json.dump({"fault": "native_child_fixed_nofile_7", "children": rows}, stream, indent=2) + stream.write("\n") + + +if __name__ == "__main__": + main() diff --git a/tests/coverage_live_v2_native.py b/tests/coverage_live_v2_native.py new file mode 100644 index 000000000..1523ec808 --- /dev/null +++ b/tests/coverage_live_v2_native.py @@ -0,0 +1,241 @@ +"""Native fixture controls and independent live-v2 bundle assertions.""" + +import json +import os +from pathlib import Path +import re + +from coverage_live_v2_support import (Infrastructure, Regression, clean_env, digest, proftext, + profile_wire, require, test_rows) + +SUITE = "coverage_setup_success" +TESTS = ["coverage_setup_first", "coverage_setup_second"] +FUNCTION = b"cbm_cov_child_count_setup" +MISSING = "coverage.expected.absent" +ANSI = re.compile(rb"\x1b\[[0-9;]*m") + + +def read(path, maximum=64 * 1024 * 1024): + path = Path(path) + require(not path.is_symlink() and path.is_file(), "missing/unsafe evidence file: " + str(path)) + with path.open("rb") as stream: + data = stream.read(maximum + 1) + require(len(data) <= maximum, "evidence file exceeds test bound") + return data + + +def json_read(path): + def pairs(items): + result = {} + for key, value in items: + require(key not in result, "duplicate metadata key") + result[key] = value + return result + return json.loads(read(path, 4 * 1024 * 1024).decode("utf-8"), object_pairs_hook=pairs) + + +def native_summary(data, failed=False): + clean = ANSI.sub(b"", data) + rows = re.findall(rb"(?m)^ ([A-Za-z_][A-Za-z0-9_]*)[ \t]+(PASS|FAIL|SKIP)\b", clean) + require(rows == [(name.encode(), b"PASS") for name in TESTS], "native fixture tests did not pass exactly once") + summaries = re.findall(rb"(?m)^[ \t]*(\d+) passed(?:, (\d+) failed)?(?:, (\d+) skipped)?" + rb"(?:, (\d+) deselected)?[ \t]*$", clean) + require(len(summaries) == 1, "native summary absent/ambiguous") + counts = [int(value or b"0") for value in summaries[0]] + require(counts == [2, 1 if failed else 0, 0, 0], "unexpected native summary counts") + + +def convert(context, raw, label): + process = context.processes.run(label, [context.tool, "merge", "--instr", "--text", + "--failure-mode=any", "--num-threads=1", "--output=-", raw], + clean_env(), timeout=70) + require(process["exit"] == 0, "native profdata conversion failed") + return proftext(read(process["stdout"])) + + +def dependency_controls(context): + root = context.output / "native-controls" + root.mkdir() + raw = root / "raw" + raw.mkdir() + env = clean_env() + env["LLVM_PROFILE_FILE"] = str(root / "listing.profraw") + listing = context.processes.run("native-list", [context.runner, "--list-suites"], env, timeout=70) + require(listing["exit"] == 0 and SUITE.encode() in read(listing["stdout"]).splitlines(), + "real runner does not list setup fixture") + universe = convert(context, root / "listing.profraw", "native-list-text") + require(any(not any(values) for values in universe.values()), "native enumeration has no zero-record control") + require(any(key[0] == FUNCTION for key in universe), "native enumeration lacks exact setup identity") + require(not any(key[0] == b"__cbm_live_v2_test_unknown_identity__" for key in universe), + "administrative unknown-identity sentinel collides with native universe") + env["LLVM_PROFILE_FILE"] = os.devnull + env["CBM_TEST_COVERAGE_DIR"] = str(raw) + suite = context.processes.run("native-setup", [context.runner, SUITE], env, timeout=70) + require(suite["exit"] == 0, "real setup fixture process failed") + native_summary(read(suite["stdout"])) + events = [json.loads(line[len(b"COV_CHILD "):]) for line in read(suite["stderr"]).splitlines() + if line.startswith(b"COV_CHILD ")] + selected = [event for event in events if event.get("id") == "setup_ok"] + require([event.get("stage") for event in selected] == ["ready", "reaped"], + "setup child custody control differs") + require(selected[0].get("marker") == 1 and selected[1].get("marker") == 0 + and selected[1].get("code") == 0 and selected[0].get("pid") == selected[1].get("pid"), + "setup child did not successfully write, retire marker and reap") + child = raw / SUITE / ("_setup.child.%d.profraw" % selected[1]["pid"]) + child_records = convert(context, child, "native-setup-child-text") + require(set(child_records) == set(universe) and all(len(child_records[key]) == len(universe[key]) + for key in universe), "native full-record shape control") + require(any(any(values) for key, values in child_records.items() if key[0] == FUNCTION), + "genuine setup child did not hit setup counter") + for test in TESTS: + require(bool(read(raw / SUITE / (test + ".parent.profraw"))), "missing native parent control") + return universe, {"listing": listing, "suite": suite, "child_profile": str(child), + "child_sha256": digest(child), "events": selected, + "universe_records": len(universe), "actual_setup_hit": True} + + +def bundle(context, destination, registry, tests, outcome, exit_code): + metadata = json_read(destination / "meta.json") + require(isinstance(metadata, dict) and metadata.get("format") == 2 and metadata.get("admission") == "unverified", + "bundle format/admission marker") + profiles, tests_data = read(destination / "profiles.tsv"), read(destination / "tests.tsv") + expected_digests = {"image_sha256": context.runner_identity["sha256"], + "registry_sha256": digest(registry), + "profiles_sha256": digest(destination / "profiles.tsv"), + "tests_sha256": digest(destination / "tests.tsv")} + for key, expected in expected_digests.items(): + require(metadata.get(key) == expected, "metadata identity/digest mismatch: " + key) + tool = metadata.get("llvm_profdata", {}) + require(isinstance(tool, dict) and tool.get("path") == context.tool and tool.get("sha256") == context.tool_identity["sha256"] + and isinstance(tool.get("version"), str) and "22.1.8" in tool["version"], + "metadata native tool identity/version") + require(metadata.get("scope") == [SUITE], "unexpected collected registry scope") + outcomes = metadata.get("suites") + require(isinstance(outcomes, list) and len(outcomes) == 1, "suite outcome cardinality") + row = outcomes[0] + require(isinstance(row, dict) and row.get("suite") == SUITE + and isinstance(row.get("tests"), list) and len(row["tests"]) == len(tests) + and all(isinstance(test, str) for test in row["tests"]) and set(row["tests"]) == set(tests) + and row.get("outcome") == outcome and type(row.get("exit")) is int + and row["exit"] == exit_code, "suite outcome/expected-registry mismatch") + process = row.get("process", {}) + require(isinstance(process, dict) and process.get("argv") == [context.runner, SUITE] + and process.get("returncode") == exit_code and process.get("quiescent") is True, + "native process identity/status/custody receipt") + stdout = native_log(destination, process.get("stdout")) + stderr = native_log(destination, process.get("stderr")) + native_summary(stdout, failed=outcome == "failed") + if outcome == "failed": + require(b"coverage child fixture failed: setup_ok" in stderr, + "fixed native failure did not reach the actual fixture failure branch") + ids = profile_wire(profiles, context.runner_identity["sha256"], context.universe) + rows = test_rows(tests_data) + require(set(rows) == {(SUITE + ":" + test).encode() for test in ["*"] + tests}, + "producer lost or invented expected rows") + require(all(number < len(ids) for reasons, numbers in rows.values() for number in numbers), + "test row references unknown profile identity") + return rows, ids, metadata + + +def native_log(destination, record): + require(isinstance(record, dict) and isinstance(record.get("path"), str) + and "capture" in record, "native capture log metadata") + path = Path(record["path"]) + require(path.is_absolute(), "native capture path must be absolute") + try: + relative = path.relative_to(destination) + except ValueError as exc: + raise Regression("native capture path escaped owned bundle") from exc + require(relative.parts and ".." not in relative.parts, "native capture path traversal") + current = destination + for part in relative.parts: + current = current / part + require(not current.is_symlink(), "native capture path contains symlink") + data = read(path, 32 * 1024 * 1024) + require(type(record.get("bytes")) is int and record["bytes"] == len(data) + and record.get("sha256") == digest(path), "native capture length/digest mismatch") + return data + + +def retained_profiles(destination): + result = [] + entries = 0 + for directory, dirs, files in os.walk(destination, followlinks=False): + for name in dirs + files: + entries += 1 + require(entries <= 10000, "owned bundle evidence entry bound") + require(not (Path(directory) / name).is_symlink(), "unexpected symlink in owned evidence") + for name in files: + if name.endswith(".profraw"): + result.append(Path(directory) / name) + return result + + +def exact_native_hits(context, raw, rows, ids, label): + routed = {"*": [], TESTS[0]: [], TESTS[1]: []} + for path in raw: + if re.fullmatch(r"_setup\.(?:child\.)?[0-9]+\.profraw", path.name): + routed["*"].append(path) + for test in TESTS: + if path.name == test + ".parent.profraw": + routed[test].append(path) + require(routed["*"] and all(len(routed[test]) == 1 for test in TESTS), + "retained real parent/setup routes missing or duplicate") + for test, paths in routed.items(): + hits = set() + for index, path in enumerate(sorted(paths)): + native = convert(context, path, "%s-row-%s-%d" % (label, "setup" if test == "*" else test, index)) + require(set(native) == set(context.universe) + and all(len(native[key]) == len(context.universe[key]) for key in native), + "retained native profile identity/shape mismatch") + hits.update(ids[key] for key, values in native.items() if any(values)) + require(rows[(SUITE + ":" + test).encode()][1] == sorted(hits), + "v2 row differs from exact genuine native counter hits: " + test) + + +def positive(context, case, missing=False): + tests = TESTS + ([MISSING] if missing else []) + registry = context.registry(case, tests=tests) + process, destination = context.collect(case, registry, poison=True) + require(process["exit"] == 0, "format2 native positive exited %r (missing integration on baseline)" % process["exit"]) + rows, ids, _ = bundle(context, destination, registry, tests, "passed", 0) + for test in ["*"] + TESTS: + require(rows[(SUITE + ":" + test).encode()][0] == [b"row_evidence_missing"], + "positive native row has unexpected/missing evidence reasons") + hit_ids = {number for key, number in ids.items() if key[0] == FUNCTION} + require(bool(hit_ids.intersection(rows[(SUITE + ":*").encode()][1])), "actual setup hit lost in v2") + if missing: + require(rows[(SUITE + ":" + MISSING).encode()] == ([b"row_evidence_missing", b"missing_parent"], []), + "missing expected parent row must remain explicit and empty") + raw = retained_profiles(destination) + children = [path for path in raw if re.fullmatch(r"_setup\.child\.[0-9]+\.profraw", path.name)] + require(len(children) == 1, "native raw setup evidence not retained") + exact_native_hits(context, raw, rows, ids, case.name) + return {"bundle": str(destination), "rows": len(rows), "native_setup_hit": True, + "all_rows_incomplete": True, "missing_row": missing} + + +def failed_native(context, case): + registry = context.registry(case) + process, destination = context.collect(case, registry, fault=True) + fault = json_read(case / "native-fault.json") + children = fault.get("children", []) + require(len(children) == 1, "native failure boundary was not reached (missing integration on baseline)") + child = children[0] + require(child.get("argv") == [context.runner, SUITE] and child.get("rlimit_nofile") == 7 + and child.get("start_new_session") is True, "native fault custody boundary changed") + if not isinstance(child.get("exit"), int) or child["exit"] <= 0: + raise Infrastructure("fixed native RLIMIT_NOFILE=7 failure precondition unmet; no tuning/retry") + raw = retained_profiles(destination) + parents = [path for path in raw if path.name == TESTS[0] + ".parent.profraw"] + if len(parents) != 1: + raise Infrastructure("native fixed fault failed before genuine first-parent evidence existed") + actual = convert(context, parents[0], "failed-native-parent-text") + require(set(actual) == set(context.universe), "native failure parent universe changed") + require(any(any(values) for values in actual.values()), "failed native parent has no observed counters") + require(process["exit"] == 1, "native suite failure must publish conservative bundle and exit1") + rows, _, _ = bundle(context, destination, registry, TESTS, "failed", child["exit"]) + require(all(b"suite_failed" in reasons for reasons, _ in rows.values()), + "failed native suite rows lost failure reason") + return {"native_exit": child["exit"], "fault": fault, "genuine_parent": str(parents[0]), + "bundle": str(destination), "all_rows_incomplete": True} diff --git a/tests/coverage_live_v2_support.py b/tests/coverage_live_v2_support.py new file mode 100644 index 000000000..c4925f4a9 --- /dev/null +++ b/tests/coverage_live_v2_support.py @@ -0,0 +1,232 @@ +"""Independent, standard-library-only support for the live-v2 regression driver.""" + +import hashlib +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import threading +import time + +STOP = threading.Event() + + +class Regression(Exception): + pass + + +class Infrastructure(Exception): + pass + + +def require(condition, message): + if not condition: + raise Regression(message) + + +def digest(path): + result = hashlib.sha256() + with Path(path).open("rb") as stream: + for block in iter(lambda: stream.read(65536), b""): + result.update(block) + return result.hexdigest() + + +def write_json(path, value): + with Path(path).open("x", encoding="utf-8") as stream: + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + + +def clean_env(): + result = os.environ.copy() + for key in ("CBM_TEST_COVERAGE_DIR", "CBM_TEST_ONLY", "CBM_TEST_ONLY_FILE"): + result.pop(key, None) + return result + + +def identity(path, executable=False): + target = Path(path).resolve(strict=True) + if not target.is_file() or (executable and not os.access(target, os.X_OK)): + raise Infrastructure("not a regular readable/executable input: " + str(target)) + return {"path": str(target), "sha256": digest(target), "bytes": target.stat().st_size} + + +class Processes: + """Own only newly started process groups; preserve bounded complete command logs.""" + + def __init__(self, root): + self.root = Path(root) + self.root.mkdir() + self.records = [] + self.total_output_bytes = 0 + + @staticmethod + def group_alive(pgid): + try: + os.killpg(pgid, 0) + return True + except ProcessLookupError: + return False + + def run(self, label, argv, env=None, timeout=180): + if STOP.is_set(): + raise KeyboardInterrupt("driver stop requested") + directory = self.root / ("%03d-%s" % (len(self.records), label)) + directory.mkdir() + record = {"label": label, "argv": [str(x) for x in argv], "timeout": timeout, + "stdout": str(directory / "stdout.log"), + "stderr": str(directory / "stderr.log"), "cleanup": []} + self.records.append(record) + state = {"bytes": 0, "overflow": False, "errors": []} + lock = threading.Lock() + stop = threading.Event() + started = time.monotonic() + try: + process = subprocess.Popen(record["argv"], env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True, bufsize=0) + except OSError as exc: + record["launch_error"] = str(exc) + write_json(directory / "process.json", record) + raise Infrastructure("command launch failed: " + label) from exc + record["pid"] = process.pid + + def drain(source, destination): + try: + with Path(destination).open("xb") as output: + while True: + block = source.read(65536) + if not block: + break + with lock: + remaining = max(0, min(64 * 1024 * 1024 - state["bytes"], + 512 * 1024 * 1024 - self.total_output_bytes)) + state["bytes"] += len(block) + self.total_output_bytes += len(block) + if len(block) > remaining: + state["overflow"] = True + stop.set() + output.write(block[:remaining]) + except Exception as exc: + with lock: + state["errors"].append(str(exc)) + stop.set() + finally: + source.close() + + threads = [threading.Thread(target=drain, args=(source, record[name]), daemon=True) + for source, name in ((process.stdout, "stdout"), (process.stderr, "stderr"))] + timed_out, interrupted = False, None + try: + write_json(directory / "started.json", record) + for thread in threads: + thread.start() + while process.poll() is None and not stop.is_set() and not STOP.is_set(): + if time.monotonic() - started >= timeout: + timed_out = True + break + stop.wait(0.02) + except BaseException as exc: + interrupted = exc + descendants_after_exit = process.poll() is not None and self.group_alive(process.pid) + for signum in (signal.SIGTERM, signal.SIGKILL): + if not self.group_alive(process.pid): + break + record["cleanup"].append(signum.name) + try: + os.killpg(process.pid, signum) + except ProcessLookupError: + break + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + process.poll() + if not self.group_alive(process.pid): + break + time.sleep(0.02) + quiescent = not self.group_alive(process.pid) + for thread, source in zip(threads, (process.stdout, process.stderr)): + if thread.ident is None: + source.close() + else: + thread.join(5) + record.update(exit=process.poll(), elapsed=time.monotonic() - started, + timed_out=timed_out, quiescent=quiescent, + descendants_after_exit=descendants_after_exit, capture=state, + drainers_done=all(not thread.is_alive() for thread in threads)) + write_json(directory / "process.json", record) + if interrupted is not None: + raise interrupted + if STOP.is_set(): + raise KeyboardInterrupt("driver stop requested") + if (timed_out or not quiescent or descendants_after_exit or state["overflow"] + or state["errors"] or not record["drainers_done"]): + raise Infrastructure("bounded process custody/capture failed: " + label) + return record + + +def proftext(data): + """Read the pinned LLVM frontend text grammar independently of project imports.""" + require(data and b"\r" not in data and b"\x00" not in data, "invalid proftext bytes") + lines = data.split(b"\n") + require(lines[-1] == b"", "proftext lacks final LF") + records, position = {}, 0 + while position < len(lines) - 1: + require(position + 6 < len(lines), "truncated proftext header") + name, hash_label, hash_text, size_label, size_text, values_label = lines[position:position + 6] + require(bool(name) and hash_label == b"# Func Hash:" and size_label == b"# Num Counters:" + and values_label == b"# Counter Values:", "unsupported proftext record") + require(re.fullmatch(rb"0|[1-9][0-9]*", hash_text) is not None, + "noncanonical native function hash") + require(re.fullmatch(rb"[1-9][0-9]*", size_text) is not None, + "noncanonical native counter count") + hash_value, count = int(hash_text), int(size_text) + require(hash_value < 2**64 and count <= 1000000, "proftext numeric bound") + begin, end = position + 6, position + 6 + count + require(end < len(lines) - 1 and lines[end] == b"", "proftext record terminator") + values = [] + for value in lines[begin:end]: + require(re.fullmatch(rb"0|[1-9][0-9]*", value) is not None, + "noncanonical native counter") + values.append(int(value)) + require(values[-1] < 2**64, "native counter overflow") + key = (name, hash_value) + require(key not in records, "duplicate native identity") + records[key] = tuple(values) + position = end + 1 + require(bool(records), "empty native universe") + return records + + +def profile_wire(data, image, universe): + expected = [b"CBM_PROFILE_MAP\t2\t" + image.encode() + b"\t" + str(len(universe)).encode()] + ids = {} + for number, key in enumerate(sorted(universe)): + name, hash_value = key + ids[key] = number + expected.append(b"%d\t%s\t%016x\t%d" % + (number, name.hex().encode(), hash_value, len(universe[key]))) + require(data == b"\n".join(expected) + b"\n", + "profiles.tsv differs from full native identities/shapes, including zero records") + return ids + + +def test_rows(data): + require(data.endswith(b"\n"), "tests.tsv final LF") + rows, order = {}, [] + for line in data.splitlines(): + fields = line.split(b"\t") + require(len(fields) == 4 and fields[0] not in rows, "tests.tsv row framing/uniqueness") + key, status, reasons, raw_ids = fields + require(status == b"incomplete", "CLI fabricated complete evidence") + values = [] if not raw_ids else raw_ids.split(b" ") + require(all(re.fullmatch(rb"0|[1-9][0-9]*", value) for value in values), "test ID syntax") + ids = [int(value) for value in values] + require(ids == sorted(set(ids)), "test IDs must be sorted/unique") + rows[key] = (reasons.split(b";"), ids) + require(b"row_evidence_missing" in rows[key][0], "missing unadmitted row evidence reason") + order.append(key) + require(order == sorted(order), "test rows not sorted") + return rows diff --git a/tests/fixtures/profile_map_v2/profiles.tsv b/tests/fixtures/profile_map_v2/profiles.tsv new file mode 100644 index 000000000..bbe6f9b6f --- /dev/null +++ b/tests/fixtures/profile_map_v2/profiles.tsv @@ -0,0 +1,4 @@ +CBM_PROFILE_MAP 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef 3 +0 61 0000000000000000 2 +1 61 8000000000000000 1 +2 ff3a09207a ffffffffffffffff 2 diff --git a/tests/fixtures/profile_map_v2/tests.tsv b/tests/fixtures/profile_map_v2/tests.tsv new file mode 100644 index 000000000..717b9845f --- /dev/null +++ b/tests/fixtures/profile_map_v2/tests.tsv @@ -0,0 +1,4 @@ +sþ:* complete +sþ:hit:rawý complete 0 2 +sþ:missing incomplete missing_parent +sþ:quiet complete diff --git a/tests/fixtures/profile_text/cold-dense.proftext b/tests/fixtures/profile_text/cold-dense.proftext new file mode 100644 index 000000000..7221bcd13 --- /dev/null +++ b/tests/fixtures/profile_text/cold-dense.proftext @@ -0,0 +1,30 @@ +cbm_profile_probe_hit +# Func Hash: +24 +# Num Counters: +1 +# Counter Values: +0 + +main +# Func Hash: +490457921399630188 +# Num Counters: +7 +# Counter Values: +1 +0 +1 +1 +1 +0 +0 + +cbm_profile_probe_never +# Func Hash: +24 +# Num Counters: +1 +# Counter Values: +0 + diff --git a/tests/fixtures/profile_text/cold-sparse.proftext b/tests/fixtures/profile_text/cold-sparse.proftext new file mode 100644 index 000000000..88e690a37 --- /dev/null +++ b/tests/fixtures/profile_text/cold-sparse.proftext @@ -0,0 +1,14 @@ +main +# Func Hash: +490457921399630188 +# Num Counters: +7 +# Counter Values: +1 +0 +1 +1 +1 +0 +0 + diff --git a/tests/fixtures/profile_text/fixture.c b/tests/fixtures/profile_text/fixture.c new file mode 100644 index 000000000..953b23472 --- /dev/null +++ b/tests/fixtures/profile_text/fixture.c @@ -0,0 +1,23 @@ +#include + +/* External names and a live volatile pointer keep the cold record retained. */ +__attribute__((noinline, used)) int cbm_profile_probe_hit(void) { + return 42; +} + +__attribute__((noinline, used)) int cbm_profile_probe_never(void) { + return 37; +} + +__attribute__((used)) int (*volatile cbm_profile_probe_keep_never)(void) = + cbm_profile_probe_never; + +int main(int argc, char **argv) { + if (argc != 2 || !cbm_profile_probe_keep_never) + return 2; + if (strcmp(argv[1], "--list") == 0) + return 0; + if (strcmp(argv[1], "--hit") == 0) + return cbm_profile_probe_hit() == 42 ? 0 : 3; + return 2; +} diff --git a/tests/fixtures/profile_text/hot-dense.proftext b/tests/fixtures/profile_text/hot-dense.proftext new file mode 100644 index 000000000..b776f2a39 --- /dev/null +++ b/tests/fixtures/profile_text/hot-dense.proftext @@ -0,0 +1,30 @@ +cbm_profile_probe_hit +# Func Hash: +24 +# Num Counters: +1 +# Counter Values: +1 + +main +# Func Hash: +490457921399630188 +# Num Counters: +7 +# Counter Values: +1 +0 +1 +1 +0 +1 +1 + +cbm_profile_probe_never +# Func Hash: +24 +# Num Counters: +1 +# Counter Values: +0 + diff --git a/tests/fixtures/profile_text/hot-sparse.proftext b/tests/fixtures/profile_text/hot-sparse.proftext new file mode 100644 index 000000000..97c225d99 --- /dev/null +++ b/tests/fixtures/profile_text/hot-sparse.proftext @@ -0,0 +1,22 @@ +main +# Func Hash: +490457921399630188 +# Num Counters: +7 +# Counter Values: +1 +0 +1 +1 +0 +1 +1 + +cbm_profile_probe_hit +# Func Hash: +24 +# Num Counters: +1 +# Counter Values: +1 + diff --git a/tests/if_native_git_path.c b/tests/if_native_git_path.c new file mode 100644 index 000000000..ddbd69c82 --- /dev/null +++ b/tests/if_native_git_path.c @@ -0,0 +1,255 @@ +#include "if_native_git_path.h" + +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#include +#include +#include +#endif + +enum { + IF_GIT_PATH_CAP = 32768, + IF_GIT_COMPONENTS = 256, + IF_GIT_NATIVE_CAP = 4096, + IF_GIT_MISS = 0, + IF_GIT_FOUND = 1, + IF_GIT_ERROR = -1 +}; + +static bool if_git_publish(char *out, size_t capacity, const char *candidate, size_t length) { + if (!length || length >= capacity) + return false; + memcpy(out, candidate, length + 1); + return true; +} + +#ifdef _WIN32 + +typedef struct { + wchar_t path[IF_GIT_PATH_CAP]; + wchar_t roundtrip[IF_GIT_PATH_CAP]; + char encoded[IF_GIT_PATH_CAP]; + wchar_t candidate[IF_GIT_NATIVE_CAP]; + wchar_t full[IF_GIT_NATIVE_CAP]; +} if_git_windows_scratch; + +static bool if_git_separator(wchar_t c) { + return c == L'/' || c == L'\\'; +} + +static bool if_git_windows_absolute(const wchar_t *path, size_t length) { + bool drive = length >= 3 && + ((path[0] >= L'A' && path[0] <= L'Z') || + (path[0] >= L'a' && path[0] <= L'z')) && + path[1] == L':' && if_git_separator(path[2]); + if (drive) + return true; + if (length < 5 || !if_git_separator(path[0]) || !if_git_separator(path[1]) || + if_git_separator(path[2]) || path[2] == L'?' || path[2] == L'.') + return false; + size_t server_end = 2; + while (server_end < length && !if_git_separator(path[server_end])) + server_end++; + return server_end + 1 < length && !if_git_separator(path[server_end + 1]); +} + +/* All conversion buffers are fixed and caller-owned. No ANSI code page, + * replacement conversion, separator normalization or heap wrapper is used. */ +static bool if_git_windows_roundtrip(if_git_windows_scratch *s, const wchar_t *wide, + size_t length, size_t *byte_length) { + if (length >= IF_GIT_PATH_CAP) + return false; + int bytes = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wide, (int)length + 1, + s->encoded, IF_GIT_PATH_CAP, NULL, NULL); + if (bytes <= 0 || s->encoded[bytes - 1] != '\0') + return false; + int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s->encoded, bytes, + s->roundtrip, IF_GIT_PATH_CAP); + if (count != (int)length + 1 || + memcmp(wide, s->roundtrip, ((size_t)count) * sizeof(wchar_t)) != 0) + return false; + *byte_length = (size_t)bytes - 1; + return true; +} + +static bool if_git_windows_preflight(const wchar_t *path, size_t length) { + size_t start = 0, components = 0; + for (size_t i = 0; i <= length; i++) { + if (i < length && path[i] != L';') { + /* Quoted/device namespace PATH spellings are outside this small + * test locator; never silently reinterpret them. */ + if (path[i] == L'"') + return false; + continue; + } + if (++components > IF_GIT_COMPONENTS || i - start + 9 > IF_GIT_NATIVE_CAP) + return false; + if (i - start >= 4 && if_git_separator(path[start]) && + if_git_separator(path[start + 1]) && + (path[start + 2] == L'?' || path[start + 2] == L'.') && + if_git_separator(path[start + 3])) + return false; + start = i + 1; + } + return true; +} + +static int if_git_windows_candidate(if_git_windows_scratch *s, const wchar_t *component, + size_t length, size_t *byte_length) { + if (!if_git_windows_absolute(component, length)) + return IF_GIT_MISS; + memcpy(s->candidate, component, length * sizeof(wchar_t)); + size_t used = length; + if (!if_git_separator(s->candidate[used - 1])) + s->candidate[used++] = L'\\'; + static const wchar_t tail[] = L"git.exe"; + memcpy(s->candidate + used, tail, sizeof(tail)); + DWORD count = GetFullPathNameW(s->candidate, IF_GIT_NATIVE_CAP, s->full, NULL); + if (!count || count >= IF_GIT_NATIVE_CAP || + !if_git_windows_absolute(s->full, count)) + return IF_GIT_ERROR; + if (!if_git_windows_roundtrip(s, s->full, count, byte_length) || + *byte_length >= IF_GIT_NATIVE_CAP) + return IF_GIT_ERROR; + DWORD attributes = GetFileAttributesW(s->full); + if (attributes == INVALID_FILE_ATTRIBUTES) { + DWORD error = GetLastError(); + return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND || + error == ERROR_ACCESS_DENIED + ? IF_GIT_MISS + : IF_GIT_ERROR; + } + if (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_DEVICE | + FILE_ATTRIBUTE_REPARSE_POINT)) + return IF_GIT_MISS; + DWORD binary_type = 0; + if (!GetBinaryTypeW(s->full, &binary_type)) { + DWORD error = GetLastError(); + return error == ERROR_BAD_EXE_FORMAT || error == ERROR_FILE_NOT_FOUND || + error == ERROR_PATH_NOT_FOUND || error == ERROR_ACCESS_DENIED + ? IF_GIT_MISS + : IF_GIT_ERROR; + } + if (binary_type != SCS_32BIT_BINARY && binary_type != SCS_64BIT_BINARY) + return IF_GIT_MISS; + /* This is image-format evidence, not FILE_EXECUTE authorization or Git + * identity. The existing real Git fixture commands are the positive oracle. */ + return IF_GIT_FOUND; +} + +static bool if_git_lookup(char *out, size_t capacity) { + if_git_windows_scratch scratch; + DWORD length = GetEnvironmentVariableW(L"PATH", scratch.path, IF_GIT_PATH_CAP); + if (!length || length >= IF_GIT_PATH_CAP) + return false; + size_t path_bytes = 0; + if (!if_git_windows_roundtrip(&scratch, scratch.path, length, &path_bytes) || + !if_git_windows_preflight(scratch.path, length)) + return false; + size_t start = 0; + for (size_t i = 0; i <= length; i++) { + if (i < length && scratch.path[i] != L';') + continue; + size_t bytes = 0; + int result = if_git_windows_candidate(&scratch, scratch.path + start, i - start, &bytes); + if (result == IF_GIT_ERROR) + return false; + if (result == IF_GIT_FOUND) + return if_git_publish(out, capacity, scratch.encoded, bytes); + start = i + 1; + } + return false; +} + +#else + +static bool if_git_posix_snapshot(char path[IF_GIT_PATH_CAP], size_t *length) { + const char *raw = getenv("PATH"); + if (!raw) + return false; + size_t n = 0; + while (n < IF_GIT_PATH_CAP && raw[n]) + n++; + if (!n || n == IF_GIT_PATH_CAP) + return false; + memcpy(path, raw, n + 1); + *length = n; + size_t start = 0, components = 0; + for (size_t i = 0; i <= n; i++) { + if (i < n && path[i] != ':') + continue; + if (++components > IF_GIT_COMPONENTS || i - start + 5 > IF_GIT_NATIVE_CAP) + return false; + start = i + 1; + } + return true; +} + +static int if_git_posix_candidate(const char *component, size_t length, + char canonical[IF_GIT_NATIVE_CAP], size_t *canonical_length) { + if (!length || component[0] != '/') + return IF_GIT_MISS; + char candidate[IF_GIT_NATIVE_CAP]; + memcpy(candidate, component, length); + size_t used = length; + if (candidate[used - 1] != '/') + candidate[used++] = '/'; + memcpy(candidate + used, "git", sizeof("git")); + struct stat st; + if (stat(candidate, &st) != 0) { + return errno == ENOENT || errno == ENOTDIR || errno == EACCES ? IF_GIT_MISS : IF_GIT_ERROR; + } + if (!S_ISREG(st.st_mode)) + return IF_GIT_MISS; + if (access(candidate, X_OK) != 0) + return errno == EACCES ? IF_GIT_MISS : IF_GIT_ERROR; + /* Clean existing POSIX implementation uses caller-buffer realpath. Its + * documented >=4096 output precondition is met; no NULL-buffer allocation. */ + if (!cbm_canonical_path(candidate, canonical, IF_GIT_NATIVE_CAP)) + return IF_GIT_ERROR; + size_t n = 0; + while (n < IF_GIT_NATIVE_CAP && canonical[n]) + n++; + if (!n || n == IF_GIT_NATIVE_CAP || canonical[0] != '/') + return IF_GIT_ERROR; + if (stat(canonical, &st) != 0 || !S_ISREG(st.st_mode) || access(canonical, X_OK) != 0) + return IF_GIT_ERROR; + *canonical_length = n; + return IF_GIT_FOUND; +} + +static bool if_git_lookup(char *out, size_t capacity) { + char path[IF_GIT_PATH_CAP], canonical[IF_GIT_NATIVE_CAP]; + size_t length = 0; + if (!if_git_posix_snapshot(path, &length)) + return false; + size_t start = 0; + for (size_t i = 0; i <= length; i++) { + if (i < length && path[i] != ':') + continue; + size_t found_length = 0; + int result = if_git_posix_candidate(path + start, i - start, canonical, &found_length); + if (result == IF_GIT_ERROR) + return false; + if (result == IF_GIT_FOUND) + return if_git_publish(out, capacity, canonical, found_length); + start = i + 1; + } + return false; +} + +#endif + +bool if_native_git_path(char *out, size_t capacity) { + if (out && capacity) + out[0] = '\0'; + return out && capacity && if_git_lookup(out, capacity); +} diff --git a/tests/if_native_git_path.h b/tests/if_native_git_path.h new file mode 100644 index 000000000..341ae47b1 --- /dev/null +++ b/tests/if_native_git_path.h @@ -0,0 +1,17 @@ +#ifndef IF_NATIVE_GIT_PATH_H +#define IF_NATIVE_GIT_PATH_H + +#include +#include + +/* Test-only bounded PATH lookup, not a production executable trust decision. + * out[0] is cleared on every failure when out/capacity permit it. + * No printing, environment mutation, allocation or process execution. + * Caller serializes environment mutation and keeps out disjoint from PATH. + * Empty/relative PATH entries (including '.') never trigger a current-directory + * search. An explicitly absolute entry is honored, even if it names cwd. + * The fixture MUST validate the returned candidate with genuine Git commands. + */ +bool if_native_git_path(char *out, size_t capacity); + +#endif diff --git a/tests/test_coverage_children.c b/tests/test_coverage_children.c new file mode 100644 index 000000000..16ac5e46c --- /dev/null +++ b/tests/test_coverage_children.c @@ -0,0 +1,411 @@ +/* Genuine fork/exec fixtures. Marker feature verdicts belong to the external driver. */ +#if defined(CBM_TEST_COVERAGE) && !defined(_WIN32) +#include "test_framework.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; +static const char *child_runner; +static volatile unsigned int child_counter_sink; + +__attribute__((noinline)) void cbm_cov_child_count_lower(void) { + child_counter_sink++; +} +__attribute__((noinline)) void cbm_cov_child_count_upper(void) { + child_counter_sink++; +} +__attribute__((noinline)) void cbm_cov_child_count_normal(void) { + child_counter_sink++; +} +__attribute__((noinline)) void cbm_cov_child_count_failed_exec(void) { + child_counter_sink++; +} +__attribute__((noinline)) void cbm_cov_child_count_setup(void) { + child_counter_sink++; +} + +enum ChildOperation { + LOWER, + UPPER, + NORMAL, + EXEC_VE, + EXEC_V, + EXEC_VP, + EXEC_L, + FAILED_EXEC, + SETUP_SUCCESS, + SETUP_KILL +}; +typedef struct { + const char *id; + enum ChildOperation operation; + bool obstruct; + pid_t pid; + int ack[2]; + int gate[2]; + char profile[4096]; + char marker[4096]; + char receiver_dir[4096]; + char receiver_profile[4096]; + char missing[4096]; + struct stat obstruction; + bool owns_obstruction; +} Child; + +static bool transfer(int fd, char *value, bool writing) { + struct pollfd pollfd = {.fd = fd, .events = writing ? POLLOUT : POLLIN}; + int ready; + do { + ready = poll(&pollfd, 1, 60000); + } while (ready < 0 && errno == EINTR); + if (ready <= 0) + return false; + ssize_t count; + do { + count = writing ? write(fd, value, 1) : read(fd, value, 1); + } while (count < 0 && errno == EINTR); + return count == 1; +} + +static bool send_byte(int fd, char value) { + return transfer(fd, &value, true); +} +static bool expect_byte(int fd, char expected) { + char value = 0; + return transfer(fd, &value, false) && value == expected; +} + +static int marker_present(const Child *child) { + struct stat info; + if (lstat(child->marker, &info) == 0) + return S_ISREG(info.st_mode) ? 1 : -1; + return errno == ENOENT ? 0 : -1; +} + +static bool note(const Child *child, const char *stage, int marker, int code) { + return fprintf(stderr, + "COV_CHILD {\"id\":\"%s\",\"pid\":%ld,\"stage\":\"%s\"," + "\"marker\":%d,\"code\":%d}\n", + child->id, (long)child->pid, stage, marker, code) > 0 && + fflush(stderr) == 0; +} + +static bool path_fits(int length, size_t capacity) { + return length > 0 && (size_t)length < capacity; +} + +static bool prepare_receiver(Child *child, const char *root) { + if (!path_fits( + snprintf(child->receiver_dir, sizeof(child->receiver_dir), "%s/_receiver.XXXXXX", root), + sizeof(child->receiver_dir)) || + !mkdtemp(child->receiver_dir)) + return false; + return path_fits(snprintf(child->receiver_profile, sizeof(child->receiver_profile), + "%s/receiver.profraw", child->receiver_dir), + sizeof(child->receiver_profile)) && + path_fits(snprintf(child->missing, sizeof(child->missing), "%s/missing-executable", + child->receiver_dir), + sizeof(child->missing)); +} + +static int execute(enum ChildOperation operation, const char *path, char **args) { + switch (operation) { + case EXEC_VE: + return execve(path, args, environ); + case EXEC_V: + return execv(path, args); + case EXEC_VP: + return execvp(path, args); + case EXEC_L: + return execl(path, args[0], args[1], args[2], args[3], (char *)NULL); + default: + return -2; + } +} + +static void child_body(Child *child) __attribute__((noreturn)); +static void child_body(Child *child) { + if (close(child->ack[0]) != 0 || close(child->gate[1]) != 0 || !send_byte(child->ack[1], 'R') || + !expect_byte(child->gate[0], 'G')) + _exit(90); + char ack[32], gate[32]; + (void)snprintf(ack, sizeof(ack), "%d", child->ack[1]); + (void)snprintf(gate, sizeof(gate), "%d", child->gate[0]); + char *args[] = {(char *)child_runner, "--coverage-child-receiver", ack, gate, NULL}; + if (child->operation >= EXEC_VE && child->operation <= EXEC_L) { + if (unsetenv("CBM_TEST_COVERAGE_DIR") != 0 || + setenv("LLVM_PROFILE_FILE", child->receiver_profile, 1) != 0) + _exit(91); + (void)execute(child->operation, child_runner, args); + _exit(92); + } + if (child->operation == FAILED_EXEC) { + for (int i = 0; i < 4; i++) { + errno = 0; + int result = execute((enum ChildOperation)(EXEC_VE + i), child->missing, args); + if (result != -1 || errno != ENOENT || !send_byte(child->ack[1], (char)('0' + i)) || + !expect_byte(child->gate[0], 'G')) + _exit(93); + } + cbm_cov_child_count_failed_exec(); + _exit(0); + } + switch (child->operation) { + case LOWER: + cbm_cov_child_count_lower(); + _exit(0); + case UPPER: + cbm_cov_child_count_upper(); + _Exit(0); + case NORMAL: + cbm_cov_child_count_normal(); + exit(0); + case SETUP_SUCCESS: + cbm_cov_child_count_setup(); + _exit(0); + default: + _exit(94); + } +} + +static bool close_fd(int *fd) { + if (*fd < 0) + return true; + int value = *fd; + *fd = -1; + return close(value) == 0; +} + +static bool reap(Child *child, bool terminate, int *status, bool *reaped) { + if (child->pid < 0) + return true; + bool ok = true; + if (terminate && kill(child->pid, SIGKILL) != 0 && errno != ESRCH) + ok = false; + pid_t result; + do { + result = waitpid(child->pid, status, 0); + } while (result < 0 && errno == EINTR); + if (result == child->pid || (result < 0 && errno == ECHILD)) + *reaped = true; + return result == child->pid && ok; +} + +static bool profile_paths(Child *child, const char *root, const char *stem) { + return path_fits(snprintf(child->profile, sizeof(child->profile), "%s/%s/%s.%ld.profraw", root, + tf_current_suite, stem, (long)child->pid), + sizeof(child->profile)) && + path_fits(snprintf(child->marker, sizeof(child->marker), "%s/%s/%s.%ld.forked", root, + tf_current_suite, stem, (long)child->pid), + sizeof(child->marker)); +} + +static bool hold_obstruction(Child *child) { + if (mkdir(child->profile, 0700) != 0) + return false; + child->owns_obstruction = true; + if (lstat(child->profile, &child->obstruction) != 0 || !S_ISDIR(child->obstruction.st_mode)) + return false; + return note(child, "obstacle_held", -1, 1); +} + +static bool remove_obstruction(Child *child) { + struct stat now; + if (!child->owns_obstruction || lstat(child->profile, &now) != 0 || !S_ISDIR(now.st_mode) || + now.st_dev != child->obstruction.st_dev || now.st_ino != child->obstruction.st_ino || + !note(child, "obstacle_reaped", -1, 1)) + return false; + /* rmdir removes only our same, still-empty directory, after child reap and receipt. */ + if (rmdir(child->profile) != 0) + return false; + child->owns_obstruction = false; + return note(child, "obstacle_removed", -1, 1); +} + +static bool held_protocol(Child *child) { + if (child->operation >= EXEC_VE && child->operation <= EXEC_L) { + if (!expect_byte(child->ack[0], 'E')) + return false; + int marker = marker_present(child); + return marker >= 0 && note(child, "exec_held", marker, 0) && send_byte(child->gate[1], 'G'); + } + if (child->operation == FAILED_EXEC) { + const char *stages[] = {"execve_failed", "execv_failed", "execvp_failed", "execl_failed"}; + for (int i = 0; i < 4; i++) { + if (!expect_byte(child->ack[0], (char)('0' + i))) + return false; + int marker = marker_present(child); + if (marker < 0 || !note(child, stages[i], marker, ENOENT) || + !send_byte(child->gate[1], 'G')) + return false; + } + } + return true; +} + +static bool start_child(Child *child, const char *root) { + if (child->operation >= EXEC_VE && child->operation <= FAILED_EXEC && + !prepare_receiver(child, root)) + return false; + if (pipe(child->ack) != 0 || pipe(child->gate) != 0 || fflush(NULL) != 0) + return false; + child->pid = fork(); + if (child->pid == 0) + child_body(child); + return child->pid > 0 && close_fd(&child->ack[1]) && close_fd(&child->gate[0]); +} + +static bool ready_child(Child *child, const char *root, const char *stem) { + if (!profile_paths(child, root, stem) || !expect_byte(child->ack[0], 'R')) + return false; + int initial = marker_present(child); + if (!note(child, "ready", initial, 0) || initial != 1) + return false; + return !child->obstruct || hold_obstruction(child); +} + +static bool finish_child(Child *child, int *status, bool *reaped) { + if (child->operation == SETUP_KILL) { + if (!reap(child, true, status, reaped)) + return false; + } else { + if (!send_byte(child->gate[1], 'G') || !held_protocol(child) || + !reap(child, false, status, reaped)) + return false; + } + bool expected = child->operation == SETUP_KILL + ? WIFSIGNALED(*status) && WTERMSIG(*status) == SIGKILL + : WIFEXITED(*status) && WEXITSTATUS(*status) == 0; + if (!expected || (child->obstruct && !remove_obstruction(child))) + return false; + int final = marker_present(child); + return final >= 0 && + note(child, "reaped", final, + WIFSIGNALED(*status) ? 128 + WTERMSIG(*status) : WEXITSTATUS(*status)); +} + +static int run_child(const char *stem, const char *id, enum ChildOperation operation, + bool obstruct) { + Child child = {.id = id, + .operation = operation, + .obstruct = obstruct, + .pid = -1, + .ack = {-1, -1}, + .gate = {-1, -1}}; + const char *root = getenv("CBM_TEST_COVERAGE_DIR"); + if (!root || root[0] != '/' || !child_runner || child_runner[0] != '/') + return 1; + struct sigaction ignore = {.sa_handler = SIG_IGN}, previous; + (void)sigemptyset(&ignore.sa_mask); + if (sigaction(SIGPIPE, &ignore, &previous) != 0) + return 1; + bool reaped = false; + int status = 0; + bool ok = start_child(&child, root) && ready_child(&child, root, stem) && + finish_child(&child, &status, &reaped); + if (child.pid > 0 && !reaped && !reap(&child, true, &status, &reaped)) + ok = false; + if (!close_fd(&child.ack[0])) + ok = false; + if (!close_fd(&child.ack[1])) + ok = false; + if (!close_fd(&child.gate[0])) + ok = false; + if (!close_fd(&child.gate[1])) + ok = false; + if (sigaction(SIGPIPE, &previous, NULL) != 0) + ok = false; + if (!ok) + fprintf(stderr, "coverage child fixture failed: %s\n", id); + return ok ? 0 : 1; +} + +static int descriptor(const char *text) { + char *end = NULL; + errno = 0; + long value = strtol(text, &end, 10); + if (errno || !text[0] || *end || value < 3 || value > INT_MAX) + return -1; + return fcntl((int)value, F_GETFD) >= 0 ? (int)value : -1; +} + +int tf_coverage_children_dispatch(int argc, char **argv) { + child_runner = argc > 0 && argv ? argv[0] : NULL; + if (argc != 4 || strcmp(argv[1], "--coverage-child-receiver") != 0) + return -1; + int ack = descriptor(argv[2]), gate = descriptor(argv[3]); + if (ack < 0 || gate < 0 || ack == gate) + return 95; + bool ok = send_byte(ack, 'E') && expect_byte(gate, 'G'); + if (!close_fd(&ack)) + ok = false; + if (!close_fd(&gate)) + ok = false; + return ok ? 0 : 96; +} + +TEST(coverage_explicit_success) { + if (run_child(__func__ + 5, "lower", LOWER, false) || + run_child(__func__ + 5, "upper", UPPER, false)) + return 1; + PASS(); +} +TEST(coverage_explicit_write_failure) { + if (run_child(__func__ + 5, "lower", LOWER, true) || + run_child(__func__ + 5, "upper", UPPER, true)) + return 1; + PASS(); +} +TEST(coverage_normal_exit) { + if (run_child(__func__ + 5, "normal_ok", NORMAL, false) || + run_child(__func__ + 5, "normal_bad", NORMAL, true)) + return 1; + PASS(); +} +TEST(coverage_exec_wrappers) { + const char *ids[] = {"execve", "execv", "execvp", "execl"}; + for (int i = 0; i < 4; i++) { + if (run_child(__func__ + 5, ids[i], (enum ChildOperation)(EXEC_VE + i), false)) + return 1; + } + if (run_child(__func__ + 5, "failed_exec", FAILED_EXEC, false)) + return 1; + PASS(); +} +TEST(coverage_setup_first) { + PASS(); +} +TEST(coverage_setup_second) { + PASS(); +} + +SUITE(coverage_children) { + RUN_TEST(coverage_explicit_success); + RUN_TEST(coverage_explicit_write_failure); + RUN_TEST(coverage_normal_exit); + RUN_TEST(coverage_exec_wrappers); +} +SUITE(coverage_setup_success) { + int before = tf_pass_count; + RUN_TEST(coverage_setup_first); + if (tf_pass_count > before && run_child("_setup.child", "setup_ok", SETUP_SUCCESS, false)) + tf_fail_count++; + RUN_TEST(coverage_setup_second); +} +SUITE(coverage_setup_uncertain) { + int before = tf_pass_count; + RUN_TEST(coverage_setup_first); + if (tf_pass_count > before && run_child("_setup.child", "setup_killed", SETUP_KILL, false)) + tf_fail_count++; + RUN_TEST(coverage_setup_second); +} +#else +typedef int coverage_children_disabled_translation_unit; +#endif diff --git a/tests/test_coverage_children.py b/tests/test_coverage_children.py new file mode 100644 index 000000000..8bd45830f --- /dev/null +++ b/tests/test_coverage_children.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +"""Six genuine canonical-runner child-coverage cases; one oracle in every phase.""" +import argparse +import errno +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import signal +import stat +import subprocess +import sys +import threading +import time + +DEADLINE = 60 +GRACE = 5 +LOG_CAP = 1024 * 1024 +ANSI = re.compile(r"\x1b\[[0-9;]*m") +COUNT_PREFIX = "cbm_cov_child_count_" +PROCESS_RECORDS = [] +MATRIX = ( + ("explicit_success", "coverage_children", ["coverage_explicit_success"], ["lower", "upper"]), + ("explicit_write_failure", "coverage_children", ["coverage_explicit_write_failure"], ["lower", "upper"]), + ("normal_exit", "coverage_children", ["coverage_normal_exit"], ["normal_ok", "normal_bad"]), + ("exec_wrappers", "coverage_children", ["coverage_exec_wrappers"], + ["execve", "execv", "execvp", "execl", "failed_exec"]), + ("setup_success", "coverage_setup_success", ["coverage_setup_first", "coverage_setup_second"], ["setup_ok"]), + ("setup_uncertain", "coverage_setup_uncertain", ["coverage_setup_first", "coverage_setup_second"], ["setup_killed"]), +) + + +class Infrastructure(Exception): + pass + + +def save(path, value): + with path.open("x", encoding="utf-8") as stream: + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + + +def identity(path, executable=False): + path = Path(path).resolve(strict=True) + info = path.stat() + if not stat.S_ISREG(info.st_mode) or (executable and not os.access(str(path), os.X_OK)): + raise Infrastructure("invalid input file: " + str(path)) + digest = hashlib.sha256() + with path.open("rb") as stream: + for data in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(data) + return {"path": str(path), "bytes": info.st_size, "sha256": digest.hexdigest()} + + +def drain(pipe, destination, state, stop): + try: + with destination.open("xb", buffering=0) as stream: + while True: + data = pipe.read(4096) + if not data: + break + state["seen"] += len(data) + part = memoryview(data)[:max(0, LOG_CAP - state["saved"])] + while part: + size = stream.write(part) + if not size: + raise OSError("short evidence write") + state["saved"] += size + part = part[size:] + if state["seen"] > LOG_CAP: + state["overflow"] = True + stop.set() + except (OSError, ValueError) as exc: + state["error"] = str(exc) + stop.set() + finally: + pipe.close() + + +def group_exists(pid): + try: + os.killpg(pid, 0) + return True + except ProcessLookupError: + return False + + +def signal_group(pid, sig): + try: + os.killpg(pid, sig) + except ProcessLookupError: + pass + + +def quiesce(proc): + # Every spawned process is the leader of its own new session/group. Nested + # reducer tools inherit that group. C fixtures separately prove native reaps. + notes = [] + for sig in (signal.SIGTERM, signal.SIGKILL): + if proc.poll() is not None and not group_exists(proc.pid): + return True, notes + signal_group(proc.pid, sig) + notes.append("sent " + signal.Signals(sig).name) + until = time.monotonic() + GRACE + while time.monotonic() < until: + if proc.poll() is not None and not group_exists(proc.pid): + return True, notes + time.sleep(min(0.05, max(0, until - time.monotonic()))) + return proc.poll() is not None and not group_exists(proc.pid), notes + + +def run_process(directory, label, argv, environment): + record = {"argv": argv, "deadline_seconds": DEADLINE, "returncode": None, + "failure": None, "quiescent": False, + "stdout": label + ".stdout.log", "stderr": label + ".stderr.log"} + start = time.monotonic() + proc = None + threads, started_pipes, states = [], [], {} + stop = threading.Event() + try: + proc = subprocess.Popen(argv, env=environment, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + bufsize=0, start_new_session=True) + record["pid"] = proc.pid + for kind, pipe in (("stdout", proc.stdout), ("stderr", proc.stderr)): + state = {"seen": 0, "saved": 0, "overflow": False, "error": None} + thread = threading.Thread(target=drain, + args=(pipe, directory / record[kind], state, stop), daemon=True) + thread.start() + threads.append(thread) + started_pipes.append(pipe) + states[kind] = state + while proc.poll() is None: + left = DEADLINE - (time.monotonic() - start) + if stop.is_set() or left <= 0: + record["failure"] = "capture limit/error" if stop.is_set() else "timeout" + break + try: + proc.wait(timeout=min(left, 0.1)) + except subprocess.TimeoutExpired: + pass + except (OSError, RuntimeError, KeyboardInterrupt) as exc: + record["failure"] = type(exc).__name__ + ": " + str(exc) + finally: + if proc is not None: + try: + record["quiescent"], record["cleanup"] = quiesce(proc) + if record["cleanup"] and record["failure"] is None: + record["failure"] = "unexpected processes remained after leader exit" + except (OSError, KeyboardInterrupt) as exc: + record["cleanup_error"] = str(exc) + if not record["quiescent"]: + record["failure"] = record["failure"] or "owned process group did not quiesce" + for pipe in (proc.stdout, proc.stderr): + if pipe not in started_pipes: + pipe.close() + until = time.monotonic() + GRACE + for thread in threads: + thread.join(timeout=max(0, until - time.monotonic())) + if any(thread.is_alive() for thread in threads): + record["failure"] = record["failure"] or "pipe readers did not quiesce" + record["returncode"] = proc.poll() + if any(state["overflow"] or state["error"] for state in states.values()): + record["failure"] = record["failure"] or "bounded capture failed" + record["streams"] = states + record["elapsed_seconds"] = round(time.monotonic() - start, 3) + PROCESS_RECORDS.append(record) + save(directory / (label + ".process.json"), record) + return record + + +def require_process(record): + if record["failure"] or not record["quiescent"] or record["returncode"] != 0: + raise Infrastructure("process failed: " + json.dumps(record)) + + +def regular_nonempty(path): + try: + value = path.lstat() + return stat.S_ISREG(value.st_mode) and value.st_size > 0 + except OSError: + return False + + +def read_log(directory, record, kind): + return (directory / record[kind]).read_text(encoding="utf-8", errors="replace") + + +def validate_test_output(stdout, tests): + clean = ANSI.sub("", stdout) + rows = re.findall(r"(?m)^ ([A-Za-z_][A-Za-z0-9_]*)[ \t]+(PASS|FAIL|SKIP)\b", clean) + starts = re.findall(r"(?m)^ (coverage_[A-Za-z0-9_]+)\b", clean) + summaries = re.findall(r"(?m)^[ \t]*(\d+) passed(?:, (\d+) failed)?(?:, (\d+) skipped)?" + r"(?:, (\d+) deselected)?[ \t]*$", clean) + if rows != [(name, "PASS") for name in tests] or starts != tests or len(summaries) != 1: + raise Infrastructure("selected native tests did not run exactly once and pass") + if [int(value or 0) for value in summaries[0]][:3] != [len(tests), 0, 0]: + raise Infrastructure("native suite summary failed") + + +def parse_events(stderr, ids): + events = {name: [] for name in ids} + for line in stderr.splitlines(): + if not line.startswith("COV_CHILD "): + continue + try: + item = json.loads(line[len("COV_CHILD "):]) + except ValueError as exc: + raise Infrastructure("invalid fixture event") from exc + if (not isinstance(item, dict) or set(item) != {"id", "pid", "stage", "marker", "code"} + or not isinstance(item["id"], str) or item["id"] not in events + or type(item["pid"]) is not int or item["pid"] <= 0 + or type(item["marker"]) is not int or item["marker"] not in (-1, 0, 1) + or type(item["code"]) is not int or not isinstance(item["stage"], str)): + raise Infrastructure("invalid fixture event fields") + events[item["id"]].append(item) + return events + + +def validate_events(case_name, events): + pids, final = set(), {} + for name, rows in events.items(): + obstacle = case_name == "explicit_write_failure" or name == "normal_bad" + stages = ["ready"] + if obstacle: + stages += ["obstacle_held", "obstacle_reaped", "obstacle_removed"] + if name in ("execve", "execv", "execvp", "execl"): + stages += ["exec_held"] + if name == "failed_exec": + stages += ["execve_failed", "execv_failed", "execvp_failed", "execl_failed"] + stages += ["reaped"] + if [row["stage"] for row in rows] != stages: + raise Infrastructure("missing, duplicate, or unordered child events: " + name) + pid = rows[0]["pid"] + if pid in pids or any(row["pid"] != pid for row in rows): + raise Infrastructure("child PID evidence inconsistent") + pids.add(pid) + if rows[0]["marker"] != 1 or rows[0]["code"] != 0: + raise Infrastructure("post-atfork initial marker was not observed") + expected_status = 128 + signal.SIGKILL if name == "setup_killed" else 0 + if rows[-1]["code"] != expected_status or rows[-1]["marker"] < 0: + raise Infrastructure("child was not reaped with its expected status") + for row in rows[1:-1]: + if row["stage"].startswith("obstacle_"): + good = row["code"] == 1 and row["marker"] == -1 + else: + good = row["marker"] >= 0 and row["code"] == (errno.ENOENT if name == "failed_exec" else 0) + if not good: + raise Infrastructure("child gate/obstacle control failed") + final[name] = rows[-1] + return final + + +def validate_native_errors(stderr, expected_paths): + found = set() + for line in stderr.splitlines(): + if line.startswith("coverage child fixture failed:") or line.startswith("coverage profile write failed:"): + raise Infrastructure("native fixture or normal-parent coverage failed") + if line.startswith("LLVM Profile Error:"): + match = re.fullmatch(r'LLVM Profile Error: Failed to write file "(.*)": (.+)', line) + if not match: + raise Infrastructure("unknown LLVM diagnostic precondition") + path = os.path.normpath(match.group(1)) + if path not in expected_paths: + raise Infrastructure("LLVM write failure did not name an owned obstacle") + found.add(path) + if found != expected_paths: + raise Infrastructure("intended genuine LLVM write failure was not observed") + + +def exact_positive_count(text, function): + blocks = [] + current = None + for line in text.splitlines(): + heading = re.fullmatch(r" (.+):", line) + if heading: + current = {"name": heading.group(1), "counts": []} + blocks.append(current) + elif current is not None: + count = re.fullmatch(r" Function count: ([0-9]+)", line) + if count: + current["counts"].append(int(count.group(1))) + exact = [block for block in blocks if block["name"] == function] + if len(exact) != 1 or len(exact[0]["counts"]) != 1 or exact[0]["counts"][0] <= 0: + raise Infrastructure("exact child-only function lacks one positive native count: " + function) + return exact[0]["counts"][0] + + +def profile_controls(case, suite_dir, tool, environment, witnesses): + profiles = sorted(suite_dir.glob("*.profraw")) + if not profiles or len(profiles) > 64: + raise Infrastructure("unexpected finite profile inventory") + results = [] + for index, raw in enumerate(profiles): + if not regular_nonempty(raw): + raise Infrastructure("profile is not regular/nonempty: " + str(raw)) + merged = case / ("profile-%02d.profdata" % index) + record = run_process(case, "merge-%02d" % index, + [tool, "merge", "--instr", "--output=" + str(merged), str(raw)], environment) + require_process(record) + if not regular_nonempty(merged): + raise Infrastructure("matching profdata produced no valid indexed profile") + item = {"raw": str(raw), "merged": str(merged), "valid": True} + if raw.name in witnesses: + function = witnesses[raw.name] + shown = run_process(case, "count-%02d" % index, + [tool, "show", "--counts", "--function=" + function, str(merged)], environment) + require_process(shown) + item["function"] = function + item["function_count"] = exact_positive_count(read_log(case, shown, "stdout"), function) + results.append(item) + if set(witnesses) - {Path(item["raw"]).name for item in results}: + raise Infrastructure("expected genuine child profile is absent") + return results + + +def reducer_worker(): + parser = argparse.ArgumentParser() + parser.add_argument("--reduce-worker", action="store_true", required=True) + parser.add_argument("--reducer", required=True) + parser.add_argument("--suite", required=True) + parser.add_argument("--suite-dir", required=True) + parser.add_argument("--profdata", required=True) + parser.add_argument("--runner-returncode", required=True, type=int) + args = parser.parse_args() + sys.dont_write_bytecode = True + spec = importlib.util.spec_from_file_location("coverage_child_actual_reducer", args.reducer) + if spec is None or spec.loader is None: + raise Infrastructure("cannot load actual reducer") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + result = module.reduce_suite(args.suite, args.suite_dir, args.profdata, args.runner_returncode == 0) + rows = {name: {"status": row[0], "reason": row[1], "names_count": len(row[2])} + for name, row in result.items()} + print(json.dumps(rows, sort_keys=True)) + return 0 + + +def reduce_actual(case, suite, suite_dir, tool, reducer, runner_record, environment): + argv = [sys.executable, str(Path(__file__).resolve()), "--reduce-worker", + "--reducer", reducer, "--suite", suite, "--suite-dir", str(suite_dir), + "--profdata", tool, "--runner-returncode", str(runner_record["returncode"])] + record = run_process(case, "reduce", argv, environment) + require_process(record) + try: + rows = json.loads(read_log(case, record, "stdout")) + except ValueError as exc: + raise Infrastructure("actual reducer did not return valid JSON") from exc + if not isinstance(rows, dict) or len(rows) > 16: + raise Infrastructure("unexpected reducer row inventory") + for row in rows.values(): + if (not isinstance(row, dict) or set(row) != {"status", "reason", "names_count"} + or row["status"] not in ("complete", "incomplete") or not isinstance(row["reason"], str) + or type(row["names_count"]) is not int or row["names_count"] < 0): + raise Infrastructure("invalid actual reducer row") + return rows + + +def features(case_name, tests, events, final, rows): + problems = [] + retained = [] + for name, row in final.items(): + expected = case_name in ("explicit_write_failure", "normal_exit", "setup_uncertain") + expected = expected or (case_name == "exec_wrappers" and name != "failed_exec") + if row["marker"] != int(expected): + problems.append(name + ": final marker retirement incorrect") + if expected: + retained.append(str(row["pid"])) + for held in events[name]: + if held["stage"] == "exec_held" or held["stage"].endswith("_failed"): + if held["marker"] != 1: + problems.append(name + ": marker absent at " + held["stage"]) + expected_keys = set(tests) | {"*"} + if set(rows) != expected_keys: + problems.append("actual reducer emitted missing or synthetic runnable rows") + uncertain_key = "*" if case_name == "setup_uncertain" else tests[0] + reason = "unaccounted forked child: " + ",".join(sorted(retained)) if retained else "" + for key in expected_keys: + expected_reason = reason if key == uncertain_key else "" + status = "incomplete" if expected_reason else "complete" + row = rows.get(key) + if row is None or row["status"] != status or row["reason"] != expected_reason: + problems.append("actual reducer status/reason mismatch: " + key) + return problems + + +def run_case(root, definition, runner, tool, reducer): + name, suite, tests, ids = definition + case = root / name + case.mkdir(mode=0o700) + coverage = case / "coverage" + coverage.mkdir(mode=0o700) + suite_dir = coverage / suite + result = {"case": name, "suite": suite, "tests": tests, "controls_passed": False} + environment = os.environ.copy() + environment["LLVM_PROFILE_FILE"] = "/dev/null" + environment["CBM_TEST_COVERAGE_DIR"] = str(coverage) + environment["CBM_TEST_ONLY"] = ",".join(suite + ":" + test for test in tests) + environment.pop("CBM_TEST_ONLY_FILE", None) + try: + record = run_process(case, "runner", [runner, suite], environment) + result["runner"] = record + require_process(record) + stdout, stderr = read_log(case, record, "stdout"), read_log(case, record, "stderr") + validate_test_output(stdout, tests) + events = parse_events(stderr, ids) + final = validate_events(name, events) + result["events"] = events + witnesses, obstacles = {}, set() + stem = "_setup.child" if name.startswith("setup_") else tests[0] + suffixes = {"lower": "lower", "upper": "upper", "normal_ok": "normal", + "failed_exec": "failed_exec", "setup_ok": "setup"} + for child_id, event in final.items(): + raw = suite_dir / (stem + "." + str(event["pid"]) + ".profraw") + marker = raw.with_suffix(".forked") + present = marker.exists() + if present != bool(event["marker"]) or (present and not stat.S_ISREG(marker.lstat().st_mode)): + raise Infrastructure("recorded final marker differs from completed filesystem") + obstructed = name == "explicit_write_failure" or child_id == "normal_bad" + if obstructed: + obstacles.add(os.path.normpath(str(raw))) + if os.path.lexists(str(raw)): + raise Infrastructure("owned empty obstruction was not removed after reap") + elif child_id == "setup_killed": + if os.path.lexists(str(raw)): + raise Infrastructure("deliberately killed held child unexpectedly wrote a profile") + elif child_id in suffixes: + witnesses[raw.name] = COUNT_PREFIX + suffixes[child_id] + validate_native_errors(stderr, obstacles) + for test in tests: + if not regular_nonempty(suite_dir / (test + ".parent.profraw")): + raise Infrastructure("passing selected test lacks its genuine parent profile") + result["profiles"] = profile_controls(case, suite_dir, tool, environment, witnesses) + result["rows"] = reduce_actual(case, suite, suite_dir, tool, reducer, record, environment) + result["feature_problems"] = features(name, tests, events, final, result["rows"]) + if name in ("explicit_success", "setup_success") and result["feature_problems"]: + raise Infrastructure("positive retirement/reducer control failed") + result["controls_passed"] = True + except (Infrastructure, OSError, ValueError, KeyboardInterrupt) as exc: + result["infrastructure_error"] = type(exc).__name__ + ": " + str(exc) + finally: + save(case / "result.json", result) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runner", required=True) + parser.add_argument("--llvm-profdata", required=True) + parser.add_argument("--reducer", required=True) + parser.add_argument("--output-dir", required=True, help="new owned evidence directory") + args = parser.parse_args() + if os.name != "posix": + print("INFRASTRUCTURE: this fixture requires the frozen POSIX coverage boundary", file=sys.stderr) + return 2 + try: + inputs = {"runner": identity(args.runner, True), "profdata": identity(args.llvm_profdata, True), + "reducer": identity(args.reducer), "driver": identity(__file__)} + root = Path(args.output_dir).resolve() + if any(char in str(root) for char in ("%", "\n", "\r")): + raise Infrastructure("output path must not contain LLVM pattern tokens or newlines") + root.mkdir(mode=0o700) + except (OSError, Infrastructure) as exc: + print("INFRASTRUCTURE: " + str(exc), file=sys.stderr) + return 2 + receipt = {"schema": 1, "fixed_oracle": True, "runner_invocations": 6, + "deadline_seconds": DEADLINE, "termination_grace_seconds": GRACE, + "log_bytes_per_stream": LOG_CAP, "inputs": inputs, "cases": [], + "overall": "infrastructure_error", + "capture_limit": "driver streams only; existing reducer internal capture is unchanged"} + try: + for definition in MATRIX: + result = run_case(root, definition, inputs["runner"]["path"], inputs["profdata"]["path"], + inputs["reducer"]["path"]) + receipt["cases"].append(result) + if not result["controls_passed"]: + break + for name, value in inputs.items(): + if identity(value["path"], name in ("runner", "profdata")) != value: + raise Infrastructure("proof input changed: " + name) + if len(receipt["cases"]) == 6 and all(case["controls_passed"] for case in receipt["cases"]): + failed = False + for case in receipt["cases"]: + case["classification"] = "regression_failure" if case["feature_problems"] else "pass" + failed = failed or bool(case["feature_problems"]) + print(case["case"] + ": " + case["classification"]) + receipt["overall"] = "regression_failure" if failed else "pass" + except (Infrastructure, OSError, ValueError, KeyboardInterrupt) as exc: + receipt["error"] = type(exc).__name__ + ": " + str(exc) + finally: + receipt["processes"] = PROCESS_RECORDS + receipt["all_owned_groups_quiescent"] = all(record["quiescent"] for record in PROCESS_RECORDS + if "pid" in record) + commands = {(inputs["runner"]["path"], definition[1]) for definition in MATRIX} + receipt["runner_invocations_actual"] = sum(tuple(record["argv"]) in commands + for record in PROCESS_RECORDS) + save(root / "result.json", receipt) + return {"pass": 0, "regression_failure": 1, "infrastructure_error": 2}[receipt["overall"]] + + +if __name__ == "__main__": + sys.exit(reducer_worker() if "--reduce-worker" in sys.argv[1:] else main()) diff --git a/tests/test_coverage_flush.py b/tests/test_coverage_flush.py new file mode 100644 index 000000000..7d7456c9a --- /dev/null +++ b/tests/test_coverage_flush.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Real canonical-runner regression for normal-parent profile write failures. + +The same fixed oracle applies to every source tree. There is no baseline mode. +This driver neither changes the collector nor substitutes an LLVM writer. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import threading +import time + +DEADLINE_SECONDS = 60 +REAP_SECONDS = 5 +LOG_BYTES = 1024 * 1024 +TEST = "gi_exact_file" +SELECTOR = "gitignore:" + TEST +ANSI = re.compile(r"\x1b\[[0-9;]*m") +BAD_SETUP = ( + "Unknown test suite:", + "No matching test suites requested", + "selected test not compiled into this build or unknown:", + "test selection names no test", + "cannot use coverage directory:", + "cannot route coverage profiles under:", + "a coverage run starts with LLVM_PROFILE_FILE=", + "failed to create isolated test cache", +) + + +def identity(path): + resolved = Path(path).resolve(strict=True) + info = resolved.stat() + if not stat.S_ISREG(info.st_mode) or not os.access(str(resolved), os.X_OK): + raise ValueError("not an executable regular file: " + str(resolved)) + digest = hashlib.sha256() + with resolved.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return {"path": str(resolved), "sha256": digest.hexdigest(), "bytes": info.st_size} + + +def save_json(path, value): + with path.open("x", encoding="utf-8") as stream: + json.dump(value, stream, indent=2, sort_keys=True) + stream.write("\n") + + +def drain(pipe, destination, state, stop): + try: + with destination.open("xb", buffering=0) as log: + while True: + data = pipe.read(4096) + if not data: + break + state["seen_bytes"] += len(data) + remaining = LOG_BYTES - state["saved_bytes"] + prefix = memoryview(data)[:max(remaining, 0)] + while prefix: + count = log.write(prefix) + if not count: + raise OSError("short log write") + state["saved_bytes"] += count + prefix = prefix[count:] + if state["seen_bytes"] > LOG_BYTES: + state["overflow"] = True + stop.set() + except (OSError, ValueError) as exc: + state["error"] = str(exc) + stop.set() + finally: + pipe.close() + + +def reap(proc): + if proc.poll() is not None: + return None + try: + proc.terminate() + except OSError: + pass + try: + proc.wait(timeout=REAP_SECONDS) + return None + except subprocess.TimeoutExpired: + pass + try: + proc.kill() + proc.wait(timeout=REAP_SECONDS) + return None + except (OSError, subprocess.TimeoutExpired): + return "owned child did not terminate after kill" + + +def run_process(directory, label, argv, environment): + record = {"argv": argv, "deadline_seconds": DEADLINE_SECONDS, "returncode": None, + "failure": None, "stdout": label + ".stdout.log", "stderr": label + ".stderr.log"} + started = time.monotonic() + try: + proc = subprocess.Popen(argv, env=environment, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) + except OSError as exc: + record["failure"] = "spawn: " + str(exc) + return record + record["pid"] = proc.pid + stop = threading.Event() + streams = [] + threads = [] + started_pipes = [] + try: + for kind, pipe in (("stdout", proc.stdout), ("stderr", proc.stderr)): + state = {"seen_bytes": 0, "saved_bytes": 0, "overflow": False, "error": None} + thread = threading.Thread(target=drain, + args=(pipe, directory / record[kind], state, stop), daemon=True) + thread.start() + streams.append(state) + threads.append(thread) + started_pipes.append(pipe) + while proc.poll() is None: + remaining = DEADLINE_SECONDS - (time.monotonic() - started) + if stop.is_set() or remaining <= 0: + record["failure"] = "log limit/read failure" if stop.is_set() else "timeout" + break + try: + proc.wait(timeout=min(remaining, 0.1)) + except subprocess.TimeoutExpired: + pass + except (OSError, RuntimeError, KeyboardInterrupt) as exc: + record["failure"] = type(exc).__name__ + ": " + str(exc) + finally: + cleanup_error = reap(proc) + if cleanup_error: + record["cleanup_error"] = cleanup_error + record["failure"] = record["failure"] or cleanup_error + for pipe in (proc.stdout, proc.stderr): + if pipe not in started_pipes: + pipe.close() + join_until = time.monotonic() + REAP_SECONDS + for thread in threads: + thread.join(timeout=max(0, join_until - time.monotonic())) + if any(thread.is_alive() for thread in threads): + record["failure"] = "pipe reader did not quiesce; unexpected inherited pipe" + if any(item["overflow"] or item["error"] for item in streams): + record["failure"] = record["failure"] or "bounded log capture failed" + record["returncode"] = proc.poll() + record["elapsed_seconds"] = round(time.monotonic() - started, 3) + record["streams"] = dict(zip(("stdout", "stderr"), streams)) + return record + + +def regular_nonempty(path): + try: + info = path.lstat() + return stat.S_ISREG(info.st_mode) and info.st_size > 0 + except OSError: + return False + + +def same_path(left, right): + return os.path.normcase(os.path.normpath(left)) == os.path.normcase(os.path.normpath(str(right))) + + +def write_error_evidence(stderr, obstacle): + # Static LLVM22 library evidence; first native RED must confirm this binding. + # Different wording is an unknown precondition, never guessed to be success. + errors = [line for line in stderr.splitlines() if line.startswith("LLVM Profile Error:")] + matched = [] + for line in errors: + found = re.fullmatch(r'LLVM Profile Error: Failed to write file "(.*)": (.+)', line) + if not found or not same_path(found.group(1), obstacle): + return False, errors + matched.append(line) + return bool(matched), matched + + +def test_output(stdout): + clean = ANSI.sub("", stdout) + starts = re.findall(r"(?m)^ " + TEST + r"\b", clean) + rows = re.findall(r"(?m)^ ([A-Za-z_][A-Za-z0-9_]*)[ \t]+(PASS|FAIL|SKIP)\b", clean) + exact_passes = re.findall(r"(?m)^ " + TEST + r"[ \t]+PASS[ \t]*$", clean) + summaries = re.findall( + r"(?m)^[ \t]*(\d+) passed(?:, (\d+) failed)?(?:, (\d+) skipped)?" + r"(?:, (\d+) deselected)?[ \t]*$", clean) + return {"started": len(starts), "passed": len(exact_passes), "rows": rows, + "summaries": [[int(value or 0) for value in row] for row in summaries]} + + +def expected_test_once(observation): + return (observation["started"] == 1 and observation["passed"] == 1 + and observation["rows"] == [(TEST, "PASS")]) + + +def merge_profile(case, raw, name, tool, environment): + output = case / (name + ".profdata") + result = {"raw": str(raw), "merged": str(output), "valid": False} + if not regular_nonempty(raw): + result["failure"] = "raw profile is not a regular nonempty file" + return result + command = [tool, "merge", "--instr", "--output=" + str(output), str(raw)] + result["process"] = run_process(case, "merge-" + name, command, environment) + process = result["process"] + result["valid"] = (not process["failure"] and process["returncode"] == 0 + and regular_nonempty(output)) + if not result["valid"]: + result["failure"] = "matching llvm-profdata did not validate the raw profile" + return result + + +def classify(case, mode, process, obstacle): + answer = {"classification": "infrastructure_error", "reasons": [], "observed_test": None} + if process["failure"]: + answer["reasons"].append(process["failure"]) + return answer + rc = process["returncode"] + if rc is None or rc < 0 or rc > 255: + answer["reasons"].append("child did not exit normally") + return answer + stdout = (case / process["stdout"]).read_text(encoding="utf-8", errors="replace") + stderr = (case / process["stderr"]).read_text(encoding="utf-8", errors="replace") + observed = test_output(stdout) + answer["observed_test"] = observed + if any(text in stderr for text in BAD_SETUP): + answer["reasons"].append("initialization or selection failed") + return answer + if observed["started"] > 1 or any(row != (TEST, "PASS") for row in observed["rows"]): + answer["reasons"].append("unexpected test execution or body failure") + return answer + if mode != "begin" and not expected_test_once(observed): + answer["reasons"].append("selected test did not run exactly once and pass") + return answer + if mode == "success": + if rc or len(observed["summaries"]) != 1 or observed["summaries"][0][:3] != [1, 0, 0]: + answer["reasons"].append("positive runner control failed") + elif "LLVM Profile Error:" in stderr or "coverage profile" in stderr.lower(): + answer["reasons"].append("positive control reported a coverage error") + else: + answer["classification"] = "pass" + return answer + try: + is_directory = stat.S_ISDIR(obstacle.lstat().st_mode) + except OSError: + is_directory = False + if not is_directory: + answer["reasons"].append("owned obstacle is no longer a directory") + return answer + known_error, evidence = write_error_evidence(stderr, obstacle) + answer["llvm_write_error_lines"] = evidence + if not known_error: + answer["classification"] = "unknown_error_precondition" + answer["reasons"].append("intended LLVM target-path write failure was not recognized") + return answer + if rc == 0: + answer["classification"] = "regression_failure" + answer["reasons"].append("coverage write failed but runner silently exited 0") + elif not any(line.startswith("coverage profile write failed:") for line in stderr.splitlines()): + answer["reasons"].append("nonzero exit lacks the runner-owned coverage failure diagnostic") + else: + answer["classification"] = "pass" + return answer + + +def run_case(root, mode, runner, tool): + case = root / mode + case.mkdir(mode=0o700) + coverage = case / "coverage" + suite = coverage / "gitignore" + suite.mkdir(mode=0o700, parents=True) + setup = suite / "_setup.0.profraw" + parent = suite / (TEST + ".parent.profraw") + obstacle = setup if mode == "begin" else parent if mode == "end" else None + if obstacle is not None: + obstacle.mkdir(mode=0o700) + environment = os.environ.copy() + environment["LLVM_PROFILE_FILE"] = "/dev/null" + environment["CBM_TEST_COVERAGE_DIR"] = str(coverage) + environment["CBM_TEST_ONLY"] = SELECTOR + environment.pop("CBM_TEST_ONLY_FILE", None) + process = run_process(case, "runner", [runner, "gitignore"], environment) + result = {"case": mode, "runner": process, "coverage_root": str(coverage), + "obstacle": str(obstacle) if obstacle is not None else None} + result.update(classify(case, mode, process, obstacle)) + profiles = [] + eligible = result["classification"] in ("pass", "regression_failure") + if mode in ("success", "end") and eligible: + profiles.append(merge_profile(case, setup, "setup", tool, environment)) + if mode == "success" and eligible and all(profile["valid"] for profile in profiles): + profiles.append(merge_profile(case, parent, "parent", tool, environment)) + result["profile_checks"] = profiles + if any(not profile["valid"] for profile in profiles): + result["classification"] = "infrastructure_error" + result["reasons"].append("required success/setup profile could not be validated") + save_json(case / "result.json", result) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runner", required=True, help="canonical instrumented test-runner") + parser.add_argument("--llvm-profdata", required=True, help="matching LLVM tool") + parser.add_argument("--output-dir", required=True, help="new owned evidence directory") + args = parser.parse_args() + try: + runner = identity(args.runner) + tool = identity(args.llvm_profdata) + root = Path(args.output_dir).resolve() + root.mkdir(mode=0o700) + except (OSError, ValueError) as exc: + print("INFRASTRUCTURE ERROR: " + str(exc), file=sys.stderr) + return 2 + receipt = {"schema": 1, "fixed_oracle": True, "selector": SELECTOR, + "deadline_seconds": DEADLINE_SECONDS, "reap_grace_seconds": REAP_SECONDS, + "log_bytes_per_stream": LOG_BYTES, "runner": runner, "llvm_profdata": tool, + "cwd": str(Path.cwd()), "cases": [], "overall": "infrastructure_error"} + try: + for mode in ("success", "begin", "end"): + result = run_case(root, mode, runner["path"], tool["path"]) + receipt["cases"].append(result) + print(mode + ": " + result["classification"]) + if result["classification"] in ("infrastructure_error", "unknown_error_precondition"): + break + unchanged = identity(runner["path"]) == runner and identity(tool["path"]) == tool + if not unchanged: + receipt["error"] = "runner or llvm-profdata changed during proof" + classes = [case["classification"] for case in receipt["cases"]] + if unchanged and len(classes) == 3 and all(item in ("pass", "regression_failure") for item in classes): + receipt["overall"] = "regression_failure" if "regression_failure" in classes else "pass" + except (OSError, ValueError, KeyboardInterrupt) as exc: + receipt["error"] = type(exc).__name__ + ": " + str(exc) + finally: + save_json(root / "result.json", receipt) + return {"pass": 0, "regression_failure": 1, "infrastructure_error": 2}[receipt["overall"]] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_coverage_live_v2.py b/tests/test_coverage_live_v2.py new file mode 100644 index 000000000..3f7c766ed --- /dev/null +++ b/tests/test_coverage_live_v2.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Fixed native transport and administrative rejection tests for coverage --format 2. + +Exit 0: all assertions passed. Exit 1: regression. Exit 2: unsupported venue, +missing dependency, failed fixed native-fault precondition, or custody failure. +No project module is imported; the collector is exercised as a command boundary. +""" + +import argparse +import json +import os +from pathlib import Path +import signal +import sys + +sys.dont_write_bytecode = True + +from coverage_live_v2_admin import (bounds, converter_errors, existing_output, + image_mismatch, invalid_registry) +from coverage_live_v2_native import (SUITE, TESTS, dependency_controls, failed_native, + positive, read) +from coverage_live_v2_support import (STOP, Infrastructure, Processes, Regression, clean_env, + digest, identity, require, write_json) + + +class Context: + def __init__(self, args, output): + self.output = output + self.collector_identity = identity(args.collector) + self.runner_identity = identity(args.runner, executable=True) + self.tool_identity = identity(args.llvm_profdata, executable=True) + self.collector = self.collector_identity["path"] + self.runner = self.runner_identity["path"] + self.tool = self.tool_identity["path"] + self.processes = Processes(output / "commands") + self.universe = None + self.owned_scripts = [identity(Path(__file__).with_name(name)) for name in + ("test_coverage_live_v2.py", "coverage_live_v2_support.py", + "coverage_live_v2_native.py", "coverage_live_v2_admin.py", + "coverage_live_v2_fault.py")] + + def registry(self, case, tests=None, image=None): + path = case / "registry.json" + write_json(path, {"format": "cbm.coverage.registry.v0", + "image_sha256": image or self.runner_identity["sha256"], + "suites": [{"name": SUITE, "tests": tests if tests is not None else TESTS}]}) + return path + + def collect(self, case, registry, runner=None, destination=None, llvm_bin=None, + extra=None, poison=False, fault=False): + destination = destination or (case / "bundle") + runner = str(runner or self.runner) + arguments = ["--format", "2", "--runner", runner, "--registry", str(registry), + "--out", str(destination), "--llvm-bin", str(llvm_bin or Path(self.tool).parent), + "--timeout", "70"] + (extra or []) + argv = [sys.executable, self.collector] + arguments + if fault: + adapter = str(Path(__file__).with_name("coverage_live_v2_fault.py")) + argv = [sys.executable, adapter, self.collector, self.runner, SUITE, + str(case / "native-fault.json")] + arguments + env = clean_env() + env["PYTHONDONTWRITEBYTECODE"] = "1" + env["LLVM_PROFILE_FILE"] = os.devnull + sentinel = None + if poison: + poison_root = case / "caller-coverage" + poison_root.mkdir() + sentinel = poison_root / "sentinel" + sentinel.write_bytes(b"caller coverage is not the collection destination\n") + only_file = case / "caller-selection.txt" + only_file.write_text("nonexistent_suite:nonexistent_test\n", encoding="utf-8") + env.update(CBM_TEST_COVERAGE_DIR=str(poison_root), + CBM_TEST_ONLY="nonexistent_suite:nonexistent_test", CBM_TEST_ONLY_FILE=str(only_file)) + process = self.processes.run(case.name, argv, env, timeout=600) + if sentinel is not None: + require(list(sentinel.parent.iterdir()) == [sentinel] + and sentinel.read_bytes() == b"caller coverage is not the collection destination\n", + "collector used or changed inherited caller coverage directory") + return process, destination + + +def fresh_output(value): + requested = Path(value).absolute() + if os.path.lexists(requested): + raise Infrastructure("test output already exists; preserving caller data") + parent = requested.parent.resolve(strict=True) + if not parent.is_dir(): + raise Infrastructure("test output parent is not a directory") + result = parent / requested.name + if any(character in str(result) for character in ("\n", "\r", "%")): + raise Infrastructure("unsupported LLVM evidence path characters") + result.mkdir(mode=0o700) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--collector", required=True) + parser.add_argument("--runner", required=True) + parser.add_argument("--llvm-profdata", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--case", choices=("native-positive", "native-missing-row", + "native-failed-process", "image-mismatch", "existing-output", "output-symlink", + "invalid-registry", "resource-bounds", "converter-errors"), + help="run one case with the same native dependency controls") + args = parser.parse_args() + if os.name != "posix": + print("unsupported: this driver requires POSIX owned process groups", file=sys.stderr) + return 2 + def interrupted(signum, frame): + STOP.set() + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGINT, interrupted) + output, context = None, None + receipt = {"schema": "cbm.coverage.live-v2-regression.v0", "overall": "infrastructure_failure", + "dependency_controls_passed": False, "cases": [], "no_completeness_claim": True, + "fault_policy": "fixed RLIMIT_NOFILE=7; no tuning or retries", + "scope": "live native transport; administrative rejection; no authenticated admission"} + code = 2 + try: + output = fresh_output(args.output) + context = Context(args, output) + receipt["inputs"] = {"collector": context.collector_identity, + "runner": context.runner_identity, "llvm_profdata": context.tool_identity, + "tests": context.owned_scripts} + receipt["preserved_cbm_skip_perf"] = os.environ.get("CBM_SKIP_PERF") + version = context.processes.run("native-tool-version", [context.tool, "--version"], clean_env(), 30) + require(version["exit"] == 0 and b"22.1.8" in read(version["stdout"]), + "unsupported proftext tool version: this test contract pins LLVM22.1.8") + try: + context.universe, receipt["native_controls"] = dependency_controls(context) + except Regression as exc: + raise Infrastructure("native dependency control failed: " + str(exc)) from exc + receipt["dependency_controls_passed"] = True + receipt["selected_case"] = args.case or "all" + definitions = [ + ("native-positive", lambda case: positive(context, case)), + ("native-missing-row", lambda case: positive(context, case, missing=True)), + ("native-failed-process", lambda case: failed_native(context, case)), + ("image-mismatch", lambda case: image_mismatch(context, case)), + ("existing-output", lambda case: existing_output(context, case)), + ("output-symlink", lambda case: existing_output(context, case, symlink=True)), + ("invalid-registry", lambda case: invalid_registry(context, case)), + ("resource-bounds", lambda case: bounds(context, case)), + ("converter-errors", lambda case: converter_errors(context, case)), + ] + for name, check in definitions: + if args.case is not None and name != args.case: + continue + case = output / name + case.mkdir() + result = {"case": name} + try: + result.update(status="pass", evidence=check(case)) + except Regression as exc: + result.update(status="regression_failure", error=str(exc)) + except (Infrastructure, OSError, ValueError) as exc: + result.update(status="infrastructure_failure", error=str(exc)) + receipt["cases"].append(result) + write_json(case / "result.json", result) + raise Infrastructure(name + ": " + str(exc)) from exc + receipt["cases"].append(result) + write_json(case / "result.json", result) + code = 1 if any(case["status"] != "pass" for case in receipt["cases"]) else 0 + receipt["overall"] = "regression_failure" if code else "pass" + except (Infrastructure, OSError, ValueError, Regression, KeyboardInterrupt) as exc: + receipt["error"] = type(exc).__name__ + ": " + str(exc) + code = 2 + finally: + if context is not None: + receipt["processes"] = context.processes.records + checks = [context.collector_identity, context.runner_identity, context.tool_identity] + context.owned_scripts + unchanged = [] + for item in checks: + try: + unchanged.append(digest(item["path"]) == item["sha256"]) + except OSError: + unchanged.append(False) + receipt["input_identities_unchanged"] = all(unchanged) + if not all(unchanged): + receipt["overall"] = "infrastructure_failure" + receipt["error"] = "test or native/collector input changed during run" + code = 2 + if output is not None: + write_json(output / "result.json", receipt) + print(json.dumps({"overall": receipt["overall"], "receipt": str(output / "result.json")}, sort_keys=True)) + else: + print(receipt.get("error", "unsupported test output"), file=sys.stderr) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_coverage_map.sh b/tests/test_coverage_map.sh new file mode 100644 index 000000000..c91b61675 --- /dev/null +++ b/tests/test_coverage_map.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# The per-test coverage map builder: scripts/test-impact/coverage-map.py. +# +# The map decides which tests of a spawn-heavy suite a change can reach, so what +# it must never do is claim that a test executed less than it did. This script +# pins the cases where it cannot know, each of which has to come out +# `incomplete` (always selected) instead of as a short function list: +# - a forked child left its marker (its coverage is not accounted for); +# - a profile that cannot be read; +# - a test with child profiles but none of the runner itself; +# - a suite that did not exit 0. +# It also pins the bookkeeping the engine relies on: +# - a function is named by the SOURCE FILE its body stands in, taken from the +# coverage mapping of the runner, not by the translation unit the profile +# names: a unity build and a header's static inline would otherwise carry a +# file the graph does not know them under; +# - one function seen from two translation units is one row; +# - a function the mapping does not list keeps what the profile says; +# - ids in tests.tsv name rows of functions.tsv; +# - what ran between tests lands on `:*`; +# - the runner is started with a plain LLVM_PROFILE_FILE; +# - no raw profile survives the run. +# +# Hermetic: a fake runner writes the profile files a real instrumented runner +# would, a fake llvm-profdata reads function names out of them and a fake +# llvm-cov prints a coverage mapping. Building the real instrumented runner +# takes minutes and belongs to the map job. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILDER="${ROOT}/scripts/test-impact/coverage-map.py" +PYTHON="${PYTHON:-python3}" +if ! command -v "${PYTHON}" >/dev/null 2>&1; then + echo "missing interpreter: ${PYTHON}" >&2 + exit 2 +fi + +# Strict LLVM text records retain zero-only identities and full counter values. +PYTHONDONTWRITEBYTECODE=1 "${PYTHON}" "${ROOT}/tests/test_profile_text_parser.py" + +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT +mkdir -p "${WORK}/bin" "${WORK}/repo" +REPO="$(cd "${WORK}/repo" && pwd -P)" + +# What the fake runner writes stands in for a raw profile: the names of the +# functions it "executed", one per line, as a profile would name them. +cat >"${WORK}/bin/runner" <<'RUNNER' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "--list-suites" ]]; then + # The listing run leaves the profile the builder reads the mapping with. + printf 'main\n' >"${LLVM_PROFILE_FILE}" + printf 'alpha\nbeta\n' + exit 0 +fi +if [[ "${LLVM_PROFILE_FILE:-unset}" != "/dev/null" ]]; then + echo "runner started with LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE:-unset}" >&2 + exit 9 +fi +dir="${CBM_TEST_COVERAGE_DIR}/$1" +mkdir -p "${dir}" +case "$1" in +alpha) + printf 'setup_fn\nshared_fn\n' >"${dir}/_setup.0.profraw" + printf 'between_fn\n' >"${dir}/_setup.child.77.profraw" + printf 'fa\nfb\nunity.c:static_fn\n' >"${dir}/t_one.parent.profraw" + printf 'fc\nfirst.c:inline_fn\nsecond.c:inline_fn\n' >"${dir}/t_one.123.profraw" + printf 'fb\n' >"${dir}/t_two.parent.profraw" + : >"${dir}/t_two.456.forked" + printf 'unlisted.c:fd\n' >"${dir}/t_three.789.profraw" + printf 'UNREADABLE\n' >"${dir}/t_bad.parent.profraw" + ;; +beta) + printf 'fb\n' >"${dir}/t_late.parent.profraw" + exit 3 + ;; +esac +RUNNER + +cat >"${WORK}/bin/llvm-profdata" <<'PROFDATA' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "--version" ]]; then + echo "fake llvm-profdata" + exit 0 +fi +if [[ "${1:-}" == "merge" ]]; then + # merge -sparse -o OUT IN + cp "${!#}" "$4" + exit 0 +fi +file="${!#}" +if grep -q UNREADABLE "${file}"; then + echo "error: ${file}: malformed raw profile" >&2 + exit 1 +fi +echo "Counters:" +while IFS= read -r name; do + printf ' %s:\n Hash: 0x0\n Counters: 1\n Function count: 1\n' "${name}" +done <"${file}" +echo "Functions shown: 1" +PROFDATA + +# The coverage mapping: where each function body stands. `unity.c:static_fn` +# was compiled through a unity file, `inline_fn` lives in a header two files +# include, and `unlisted.c:fd` is deliberately absent. +cat >"${WORK}/bin/llvm-cov" <"${WORK}/stdout" 2>"${WORK}/stderr" || STATUS=$? + +FAILED=0 +fail() { + echo "FAIL: $*" >&2 + FAILED=1 +} + +# A failed suite fails the build of the map, and the map is written anyway. +[[ "${STATUS}" -eq 1 ]] || fail "exit status ${STATUS}, expected 1 (suite beta exits 3)" +for name in functions.tsv tests.tsv meta.json; do + [[ -s "${OUT}/${name}" ]] || fail "missing output: ${name}" +done +[[ ! -e "${OUT}/raw" ]] || fail "raw profiles were left behind" +if compgen -G "${OUT}/*.profraw" >/dev/null || compgen -G "${OUT}/*.profdata" >/dev/null; then + fail "the profile of the listing run was left behind" +fi + +# `:` of every function of a row, resolved through functions.tsv. +row_functions() { + awk -F'\t' -v key="$1" ' + NR == FNR { name[$1] = $2 ":" $3; next } + $1 == key { n = split($4, ids, " "); for (i = 1; i <= n; i++) print name[ids[i]] } + ' "${OUT}/functions.tsv" "${OUT}/tests.tsv" | sort | tr '\n' ' ' +} +row_field() { + awk -F'\t' -v key="$1" -v col="$2" '$1 == key { print $col }' "${OUT}/tests.tsv" +} +expect_row() { + local key="$1" status="$2" reason="$3" functions="$4" + [[ "$(row_field "${key}" 2)" == "${status}" ]] || + fail "${key}: status '$(row_field "${key}" 2)', expected '${status}'" + [[ "$(row_field "${key}" 3)" == *"${reason}"* ]] || + fail "${key}: reason '$(row_field "${key}" 3)', expected to contain '${reason}'" + [[ "$(row_functions "${key}")" == "${functions}" ]] || + fail "${key}: functions '$(row_functions "${key}")', expected '${functions}'" +} + +# Parent and child profiles are one test. The unity function carries its real +# source file, and the header function seen from two files is one function. +expect_row "alpha:t_one" complete "" \ + "src/a.c:fa src/b.c:fb src/c.c:fc src/lsp/real_source.c:static_fn src/shared.h:inline_fn " +[[ -z "$(row_field "alpha:t_one" 3)" ]] || fail "alpha:t_one: a complete test carries no reason" +# A marker without anything else still makes the test incomplete. +expect_row "alpha:t_two" incomplete "unaccounted forked child: 456" "src/b.c:fb " +# A function the mapping does not list keeps the name of the profile. +expect_row "alpha:t_three" incomplete "no parent profile" "unlisted.c:fd " +expect_row "alpha:t_bad" incomplete "unreadable profile: t_bad.parent.profraw" "" +# What ran between tests concerns every test of the suite. +expect_row "alpha:*" complete "" "src/setup.c:between_fn src/setup.c:setup_fn src/setup.c:shared_fn " +# A suite that did not exit 0: nothing it reported is trusted to be whole. +expect_row "beta:t_late" incomplete "suite did not exit 0" "src/b.c:fb " +expect_row "beta:*" incomplete "suite did not exit 0" "" + +# One row for the header function, none for a function no test executed. +[[ "$(awk -F'\t' '$3 == "inline_fn"' "${OUT}/functions.tsv" | wc -l | tr -d ' ')" == "1" ]] || + fail "functions.tsv: inline_fn of src/shared.h is not exactly one row" +if awk -F'\t' '$3 == "never_run" { found = 1 } END { exit !found }' "${OUT}/functions.tsv"; then + fail "functions.tsv: lists a function no test executed" +fi +# Ids are dense and unique. +awk -F'\t' '$1 != NR - 1 { bad = 1 } END { exit bad }' "${OUT}/functions.tsv" || + fail "functions.tsv: ids are not 0..n-1 in order" + +grep -q '"incomplete": 4' "${OUT}/meta.json" || fail "meta.json: expected 4 incomplete tests" +grep -q '"tests": 5' "${OUT}/meta.json" || fail "meta.json: expected 5 tests" +# The mapping lists ten distinct functions, executed or not. +grep -q '"functions_compiled": 10' "${OUT}/meta.json" || + fail "meta.json: expected 10 compiled functions" + +# A runner that is not there is a usage error, not an empty map. +if "${PYTHON}" "${BUILDER}" --runner "${WORK}/bin/absent" --out "${WORK}/none" \ + --llvm-bin "${WORK}/bin" >/dev/null 2>&1; then + fail "a missing runner did not fail the builder" +fi +[[ ! -e "${WORK}/none/tests.tsv" ]] || fail "a missing runner still produced a map" + +if [[ "${FAILED}" -ne 0 ]]; then + echo "--- builder stdout" >&2 + cat "${WORK}/stdout" >&2 + echo "--- builder stderr" >&2 + cat "${WORK}/stderr" >&2 + exit 1 +fi +echo "coverage map builder: all cases passed" + +# Pure single-image v2 producer; legacy parser and builder controls ran above. +PYTHONDONTWRITEBYTECODE=1 "${PYTHON}" "${ROOT}/tests/test_profile_map_v2.py" diff --git a/tests/test_coverage_parent_failure.c b/tests/test_coverage_parent_failure.c new file mode 100644 index 000000000..14c289867 --- /dev/null +++ b/tests/test_coverage_parent_failure.c @@ -0,0 +1,294 @@ +/* Native soft-limit fixture; production never consumes this fixture's mode. */ +#if defined(CBM_TEST_COVERAGE) && !defined(_WIN32) +#include "test_framework.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static bool restricted_requested; +static bool fixture_entered; +static volatile unsigned int count_sink; + +__attribute__((noinline)) void cbm_cov_parent_failure_child_count(void) { + count_sink++; +} + +int tf_coverage_parent_failure_args(int *argc, char **argv) { + int position = -1; + for (int i = 1; i < *argc; i++) { + if (strcmp(argv[i], "--coverage-marker-open-emfile") == 0) { + if (position >= 0) + goto invalid; + position = i; + } + } + if (position < 0) + return 0; + if (*argc != 3 || strcmp(argv[position == 1 ? 2 : 1], "coverage_parent_failure") != 0) + goto invalid; + for (int i = position; i < *argc; i++) + argv[i] = argv[i + 1]; + (*argc)--; + restricted_requested = true; + return 0; +invalid: + fprintf(stderr, + "coverage parent fixture usage error: restricted option requires only its suite\n"); + return -1; +} + +void tf_coverage_parent_failure_finish(void) { + if (restricted_requested && !fixture_entered) { + fprintf(stderr, "coverage parent fixture usage error: requested fixture did not enter\n"); + tf_fail_count++; + } +} + +typedef struct { + int control_errno; + bool valid; + struct rlimit observed; + struct rlimit restored; +} ChildReceipt; + +typedef struct { + pid_t pid; + int ack[2]; + int gate[2]; + struct rlimit saved; + struct rlimit parent_restored; + bool parent_restore_pending; + bool parent_restore_failed; + bool reaped; + int status; + char control[4096]; + char marker[4096]; + ChildReceipt child; +} Fixture; + +static bool same_limit(const struct rlimit *a, const struct rlimit *b) { + return a->rlim_cur == b->rlim_cur && a->rlim_max == b->rlim_max; +} + +static bool transfer(int fd, void *bytes, size_t size, bool writing) { + unsigned char *cursor = bytes; + while (size) { + struct pollfd item = {.fd = fd, .events = writing ? POLLOUT : POLLIN}; + int ready; + do { + ready = poll(&item, 1, 60000); + } while (ready < 0 && errno == EINTR); + if (ready <= 0) + return false; + ssize_t n; + do { + n = writing ? write(fd, cursor, size) : read(fd, cursor, size); + } while (n < 0 && errno == EINTR); + if (n <= 0) + return false; + cursor += (size_t)n; + size -= (size_t)n; + } + return true; +} + +static bool close_fd(int *fd) { + if (*fd < 0) + return true; + int owned = *fd; + *fd = -1; + return close(owned) == 0; +} + +static bool restore_parent(Fixture *f) { + if (f->parent_restore_pending) { + f->parent_restore_pending = false; + if (setrlimit(RLIMIT_NOFILE, &f->saved) != 0) { + f->parent_restore_failed = true; + return false; + } + } + if (getrlimit(RLIMIT_NOFILE, &f->parent_restored) != 0 || + !same_limit(&f->saved, &f->parent_restored)) { + f->parent_restore_failed = true; + return false; + } + return true; +} + +static void child_run(Fixture *f) __attribute__((noreturn)); +static void child_run(Fixture *f) { + ChildReceipt receipt = {0}; + bool ok = close_fd(&f->ack[0]); + if (!close_fd(&f->gate[1])) + ok = false; + if (getrlimit(RLIMIT_NOFILE, &receipt.observed) != 0) + ok = false; + errno = 0; + int fd = open(f->control, O_RDONLY); + receipt.control_errno = fd < 0 ? errno : 0; + if (fd >= 0 && close(fd) != 0) + ok = false; + if (restricted_requested && setrlimit(RLIMIT_NOFILE, &f->saved) != 0) + ok = false; + if (getrlimit(RLIMIT_NOFILE, &receipt.restored) != 0 || + !same_limit(&f->saved, &receipt.restored)) + ok = false; + if (receipt.observed.rlim_max != f->saved.rlim_max || + receipt.observed.rlim_cur != (restricted_requested ? 0 : f->saved.rlim_cur) || + receipt.control_errno != (restricted_requested ? EMFILE : 0)) + ok = false; + receipt.valid = ok; + bool sent = transfer(f->ack[1], &receipt, sizeof(receipt), true); + if (!sent || !ok) + _exit(90); + char release = 0; + if (!transfer(f->gate[0], &release, 1, false) || release != 'G') + _exit(91); + if (!close_fd(&f->ack[1]) || !close_fd(&f->gate[0])) + _exit(92); + cbm_cov_parent_failure_child_count(); + _exit(0); +} + +static int marker_present(const Fixture *f) { + struct stat info; + if (lstat(f->marker, &info) == 0) + return S_ISREG(info.st_mode) ? 1 : -1; + return errno == ENOENT ? 0 : -1; +} + +static bool event(const Fixture *f, const char *stage, int marker, int exit_code) { + return fprintf(stderr, + "COV_PARENT {\"mode\":\"%s\",\"stage\":\"%s\",\"owner_pid\":%ld," + "\"child_pid\":%ld,\"control_errno\":%d,\"marker\":%d,\"exit_code\":%d," + "\"saved_soft\":%ju,\"saved_hard\":%ju,\"parent_soft\":%ju," + "\"parent_hard\":%ju,\"child_seen_soft\":%ju,\"child_seen_hard\":%ju," + "\"child_restored_soft\":%ju,\"child_restored_hard\":%ju}\n", + restricted_requested ? "emfile" : "control", stage, (long)getpid(), (long)f->pid, + f->child.control_errno, marker, exit_code, (uintmax_t)f->saved.rlim_cur, + (uintmax_t)f->saved.rlim_max, (uintmax_t)f->parent_restored.rlim_cur, + (uintmax_t)f->parent_restored.rlim_max, (uintmax_t)f->child.observed.rlim_cur, + (uintmax_t)f->child.observed.rlim_max, (uintmax_t)f->child.restored.rlim_cur, + (uintmax_t)f->child.restored.rlim_max) > 0 && + fflush(stderr) == 0; +} + +static bool setup(Fixture *f, const char *root) { + int n = snprintf(f->control, sizeof(f->control), "%s/parent-control.XXXXXX", root); + if (n <= 0 || (size_t)n >= sizeof(f->control)) + return false; + int fd = mkstemp(f->control); + if (fd < 0) + return false; + struct stat info; + bool ok = fstat(fd, &info) == 0 && S_ISREG(info.st_mode); + if (close(fd) != 0) + ok = false; + return ok && pipe(f->ack) == 0 && pipe(f->gate) == 0 && + getrlimit(RLIMIT_NOFILE, &f->saved) == 0 && fflush(NULL) == 0; +} + +static bool launch(Fixture *f) { + if (restricted_requested) { + struct rlimit limited = f->saved, observed; + limited.rlim_cur = 0; + f->parent_restore_pending = true; + if (setrlimit(RLIMIT_NOFILE, &limited) != 0 || getrlimit(RLIMIT_NOFILE, &observed) != 0 || + !same_limit(&limited, &observed)) + return false; + } + f->pid = fork(); + if (f->pid == 0) + child_run(f); + /* Restore before closing handles, formatting paths, or inspecting the child. */ + bool restored = restore_parent(f); + return restored && f->pid > 0 && close_fd(&f->ack[1]) && close_fd(&f->gate[0]); +} + +static bool reap(Fixture *f, bool terminate) { + if (f->pid < 0 || f->reaped) + return true; + bool ok = true; + if (terminate && kill(f->pid, SIGKILL) != 0 && errno != ESRCH) + ok = false; + pid_t result; + do { + result = waitpid(f->pid, &f->status, 0); + } while (result < 0 && errno == EINTR); + if (result == f->pid || (result < 0 && errno == ECHILD)) + f->reaped = true; + return result == f->pid && ok; +} + +static bool observe_and_release(Fixture *f, const char *root, const char *test) { + int n = snprintf(f->marker, sizeof(f->marker), "%s/%s/%s.%ld.forked", root, tf_current_suite, + test, (long)f->pid); + if (n <= 0 || (size_t)n >= sizeof(f->marker) || + !transfer(f->ack[0], &f->child, sizeof(f->child), false) || !f->child.valid) + return false; + int held = marker_present(f); + if (held != (restricted_requested ? 0 : 1) || !event(f, "held", held, -1)) + return false; + char release = 'G'; + if (!transfer(f->gate[1], &release, 1, true) || !reap(f, false) || !WIFEXITED(f->status) || + WEXITSTATUS(f->status) != 0 || !restore_parent(f)) + return false; + int final = marker_present(f); + return final == 0 && event(f, "reaped", final, 0); +} + +static int fixture(const char *test) { + Fixture f = {.pid = -1, .ack = {-1, -1}, .gate = {-1, -1}}; + const char *root = getenv("CBM_TEST_COVERAGE_DIR"); + if (!root || root[0] != '/' || !tf_current_suite) + return 1; + struct sigaction ignore = {.sa_handler = SIG_IGN}, previous; + (void)sigemptyset(&ignore.sa_mask); + if (sigaction(SIGPIPE, &ignore, &previous) != 0) + return 1; + bool ok = setup(&f, root) && launch(&f) && observe_and_release(&f, root, test); + if (f.parent_restore_pending && !restore_parent(&f)) + ok = false; + if (!reap(&f, true)) + ok = false; + if (!close_fd(&f.ack[0])) + ok = false; + if (!close_fd(&f.ack[1])) + ok = false; + if (!close_fd(&f.gate[0])) + ok = false; + if (!close_fd(&f.gate[1])) + ok = false; + if (sigaction(SIGPIPE, &previous, NULL) != 0) + ok = false; + if (!ok || f.parent_restore_failed) + fprintf(stderr, "coverage parent fixture failed: native protocol or restoration\n"); + if (f.parent_restore_failed) { + (void)fflush(stderr); + _Exit(2); /* Never continue the runner with an unverified parent limit. */ + } + return ok ? 0 : 1; +} + +TEST(coverage_marker_creation) { + fixture_entered = true; + return fixture(__func__ + 5); +} +TEST(coverage_marker_sentinel) { + PASS(); +} +SUITE(coverage_parent_failure) { + RUN_TEST(coverage_marker_creation); + RUN_TEST(coverage_marker_sentinel); +} +#else +typedef int coverage_parent_failure_disabled_translation_unit; +#endif diff --git a/tests/test_coverage_parent_failure.py b/tests/test_coverage_parent_failure.py new file mode 100644 index 000000000..1a2ce9aa5 --- /dev/null +++ b/tests/test_coverage_parent_failure.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Two native marker-open controls; unchanged oracle for baseline/fix/revert.""" +import argparse +import errno +import json +import os +from pathlib import Path +import re +import sys + +# Checked-in sibling, not scratch: bounded groups/capture and the actual reducer +# worker are shared with the six-case child proof. Its __main__ guard is inert. +sys.dont_write_bytecode = True +import test_coverage_children as common + +SUITE = "coverage_parent_failure" +TESTS = ["coverage_marker_creation", "coverage_marker_sentinel"] +FUNCTION = "cbm_cov_parent_failure_child_count" +OPTION = "--coverage-marker-open-emfile" +FIELDS = {"mode", "stage", "owner_pid", "child_pid", "control_errno", "marker", "exit_code", + "saved_soft", "saved_hard", "parent_soft", "parent_hard", "child_seen_soft", + "child_seen_hard", "child_restored_soft", "child_restored_hard"} + + +def output_controls(stdout, code): + clean = common.ANSI.sub("", stdout) + rows = re.findall(r"(?m)^ ([A-Za-z_][A-Za-z0-9_]*)[ \t]+(PASS|FAIL|SKIP)\b", clean) + starts = re.findall(r"(?m)^ (coverage_[A-Za-z0-9_]+)\b", clean) + summaries = re.findall(r"(?m)^[ \t]*(\d+) passed(?:, (\d+) failed)?(?:, (\d+) skipped)?" + r"(?:, (\d+) deselected)?[ \t]*$", clean) + if rows != [(name, "PASS") for name in TESTS] or starts != TESTS or len(summaries) != 1: + raise common.Infrastructure("native fixture and later sentinel must both PASS exactly once") + counts = [int(value or 0) for value in summaries[0]] + if code not in (0, 1) or counts[:3] != [2, code, 0]: + raise common.Infrastructure("unrelated native failure or unexpected report-once summary") + return counts + + +def event_controls(stderr, mode, owner_pid): + rows = [] + for line in stderr.splitlines(): + if not line.startswith("COV_PARENT "): + continue + try: + row = json.loads(line[len("COV_PARENT "):]) + except ValueError as exc: + raise common.Infrastructure("invalid fixture receipt") from exc + if not isinstance(row, dict) or set(row) != FIELDS: + raise common.Infrastructure("wrong fixture receipt schema") + if row["mode"] != mode or not isinstance(row["stage"], str): + raise common.Infrastructure("wrong or missing fixture mode") + if any(type(value) is not int for key, value in row.items() if key not in ("mode", "stage")): + raise common.Infrastructure("noninteger native receipt") + rows.append(row) + if len(rows) != 2 or [row["stage"] for row in rows] != ["held", "reaped"]: + raise common.Infrastructure("missing, duplicate or unordered held/reaped receipts") + held, reaped = rows + if held["owner_pid"] != owner_pid or held["child_pid"] <= 0 or held["child_pid"] == owner_pid: + raise common.Infrastructure("receipt is not from the original parent/owned child") + for key in FIELDS - {"stage", "marker", "exit_code"}: + if held[key] != reaped[key]: + raise common.Infrastructure("child identity or limit receipt changed") + if held["marker"] != (0 if mode == "emfile" else 1) or held["exit_code"] != -1: + raise common.Infrastructure("wrong marker state before explicit exit") + if reaped["marker"] != 0 or reaped["exit_code"] != 0: + raise common.Infrastructure("child did not reap successfully with retired/absent marker") + if held["saved_soft"] <= 0 or held["saved_hard"] < held["saved_soft"]: + raise common.Infrastructure("invalid saved limits") + pairs = (("parent_soft", "saved_soft"), ("parent_hard", "saved_hard"), + ("child_seen_hard", "saved_hard"), ("child_restored_soft", "saved_soft"), + ("child_restored_hard", "saved_hard")) + if any(held[left] != held[right] for left, right in pairs): + raise common.Infrastructure("parent or child did not restore exact original soft/hard limits") + expected_soft = 0 if mode == "emfile" else held["saved_soft"] + if held["child_seen_soft"] != expected_soft or held["control_errno"] != (errno.EMFILE if mode == "emfile" else 0): + raise common.Infrastructure("native open restriction was not exactly established") + return rows + + +def accounting_controls(stderr, owner_pid): + diagnostics = [] + for line in stderr.splitlines(): + if (line.startswith("LLVM Profile Error:") or line.startswith("coverage profile write failed:") + or line.startswith("coverage parent fixture failed:") + or line.startswith("coverage parent fixture usage error:") + or line.startswith("coverage child accounting unavailable:")): + raise common.Infrastructure("unrelated writer/fixture/activation failure: " + line) + if line.startswith("coverage child accounting failed:"): + expected = "coverage child accounting failed: owner_pid=%d reason=child-marker" % owner_pid + if line != expected: + raise common.Infrastructure("accounting diagnostic is not the original parent's pending marker event") + diagnostics.append(line) + if len(diagnostics) > 1: + raise common.Infrastructure("accounting error was not reported exactly once") + return diagnostics + + +def complete_rows(rows): + return set(rows) == set(TESTS) | {"*"} and all( + row["status"] == "complete" and row["reason"] == "" for row in rows.values()) + + +def verdict(mode, code, diagnostics, rows): + if mode == "control": + problems = [] + if code != 0 or diagnostics: + problems.append("unrestricted positive child caused parent accounting failure") + if not complete_rows(rows): + problems.append("unrestricted positive reducer rows were not complete") + return problems, False + if code == 0: + if diagnostics or not complete_rows(rows): + raise common.Infrastructure("zero-status restricted run did not demonstrate the frozen baseline false success") + return ["valid native marker-open failure was silently accepted by original parent"], True + if len(diagnostics) != 1: + raise common.Infrastructure("nonzero parent status lacks exactly one original-parent accounting diagnostic") + required = {TESTS[0], "*"} + problems = [] + if not required <= set(rows) or not set(rows) <= set(TESTS) | {"*"}: + problems.append("actual reducer omitted fixture/setup or emitted unrelated rows") + for key, row in rows.items(): + if row["status"] != "incomplete" or "suite did not exit 0" not in row["reason"].split("; "): + problems.append("actual reducer did not retain suite failure floor: " + key) + return problems, False + + +def run_case(root, mode, inputs): + case = root / mode + case.mkdir(mode=0o700) + coverage = case / "coverage" + coverage.mkdir(mode=0o700) + suite_dir = coverage / SUITE + result = {"mode": mode, "controls_passed": False} + environment = os.environ.copy() + environment["LLVM_PROFILE_FILE"] = "/dev/null" + environment["CBM_TEST_COVERAGE_DIR"] = str(coverage) + environment["CBM_TEST_ONLY"] = ",".join(SUITE + ":" + test for test in TESTS) + environment.pop("CBM_TEST_ONLY_FILE", None) + argv = [inputs["runner"]["path"]] + ([OPTION] if mode == "emfile" else []) + [SUITE] + try: + process = common.run_process(case, "runner", argv, environment) + result["runner"] = process + if process["failure"] or not process["quiescent"] or process["returncode"] not in (0, 1): + raise common.Infrastructure("runner infrastructure/usage/containment failed") + code = process["returncode"] + stdout = common.read_log(case, process, "stdout") + stderr = common.read_log(case, process, "stderr") + result["summary"] = output_controls(stdout, code) + events = event_controls(stderr, mode, process["pid"]) + result["events"] = events + diagnostics = accounting_controls(stderr, process["pid"]) + result["accounting_diagnostics"] = diagnostics + child_pid = events[0]["child_pid"] + raw = suite_dir / (TESTS[0] + "." + str(child_pid) + ".profraw") + if os.path.lexists(str(raw.with_suffix(".forked"))): + raise common.Infrastructure("final marker receipt differs from actual filesystem") + if code == 0: + for test in TESTS: + if not common.regular_nonempty(suite_dir / (test + ".parent.profraw")): + raise common.Infrastructure("zero-status parent lacks its ordinary genuine profile") + result["profiles"] = common.profile_controls(case, suite_dir, inputs["profdata"]["path"], + environment, {raw.name: FUNCTION}) + result["rows"] = common.reduce_actual(case, SUITE, suite_dir, inputs["profdata"]["path"], + inputs["reducer"]["path"], process, environment) + result["feature_problems"], result["baseline_false_success"] = verdict(mode, code, diagnostics, result["rows"]) + result["controls_passed"] = True + except (common.Infrastructure, OSError, ValueError, KeyboardInterrupt) as exc: + result["infrastructure_error"] = type(exc).__name__ + ": " + str(exc) + finally: + common.save(case / "result.json", result) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runner", required=True) + parser.add_argument("--llvm-profdata", required=True) + parser.add_argument("--reducer", required=True) + parser.add_argument("--output-dir", required=True) + args = parser.parse_args() + if os.name != "posix": + print("INFRASTRUCTURE: native fixture requires the frozen POSIX boundary", file=sys.stderr) + return 2 + try: + inputs = {"runner": common.identity(args.runner, True), + "profdata": common.identity(args.llvm_profdata, True), + "reducer": common.identity(args.reducer), "driver": common.identity(__file__), + "shared_driver": common.identity(common.__file__)} + expected_helper = Path(__file__).resolve().with_name("test_coverage_children.py") + if Path(inputs["shared_driver"]["path"]) != expected_helper: + raise common.Infrastructure("shared process helper is not the checked-in sibling") + root = Path(args.output_dir).resolve() + if any(char in str(root) for char in ("%", "\n", "\r")): + raise common.Infrastructure("output path contains LLVM pattern tokens/newlines") + root.mkdir(mode=0o700) + except (OSError, common.Infrastructure) as exc: + print("INFRASTRUCTURE: " + str(exc), file=sys.stderr) + return 2 + receipt = {"schema": 1, "fixed_oracle": True, "runner_invocations": 2, + "inputs": inputs, "cases": [], "overall": "infrastructure_error", + "deadline_seconds": common.DEADLINE, "grace_seconds_per_phase": common.GRACE, + "log_bytes_per_stream": common.LOG_CAP, "stdin": "DEVNULL", + "capture_limit": "external streams only; reducer internal capture unchanged"} + try: + for mode in ("control", "emfile"): + case = run_case(root, mode, inputs) + receipt["cases"].append(case) + if not case["controls_passed"]: + break + for name, value in inputs.items(): + if common.identity(value["path"], name in ("runner", "profdata")) != value: + raise common.Infrastructure("proof input changed: " + name) + if len(receipt["cases"]) == 2 and all(case["controls_passed"] for case in receipt["cases"]): + failed = False + for case in receipt["cases"]: + case["classification"] = "regression_failure" if case["feature_problems"] else "pass" + failed = failed or bool(case["feature_problems"]) + print(case["mode"] + ": " + case["classification"]) + receipt["overall"] = "regression_failure" if failed else "pass" + except (common.Infrastructure, OSError, ValueError, KeyboardInterrupt) as exc: + receipt["error"] = type(exc).__name__ + ": " + str(exc) + finally: + receipt["processes"] = common.PROCESS_RECORDS + receipt["all_owned_groups_quiescent"] = all(record["quiescent"] for record in common.PROCESS_RECORDS + if "pid" in record) + commands = {(inputs["runner"]["path"], SUITE), (inputs["runner"]["path"], OPTION, SUITE)} + receipt["runner_invocations_actual"] = sum(tuple(record["argv"]) in commands + for record in common.PROCESS_RECORDS) + common.save(root / "result.json", receipt) + return {"pass": 0, "regression_failure": 1, "infrastructure_error": 2}[receipt["overall"]] + + +if __name__ == "__main__": + sys.dont_write_bytecode = True + sys.exit(main()) diff --git a/tests/test_coverage_terminal.py b/tests/test_coverage_terminal.py new file mode 100644 index 000000000..4f3945db5 --- /dev/null +++ b/tests/test_coverage_terminal.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""One fixed native terminal-write oracle; no baseline-pass mode or retries.""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import stat +import sys + +HELPER_SHA = "a807273c03a001f713cc9425779c3e8e423eae812e0fc73fb35a6f38fc537f1e" +REDUCER_SHA = "503ba9c3be9671761c505d7819e1f638de006f30c321f98e7ad0c53a23ac224a" +SUITE = "str_intern" +TEST = "intern_basic" +FINAL_FUNCTION = "tf_coverage_channel_finish" +WORK_CAP = 48 # Direct/reserved native processes plus distinct admitted input files. +RAW_NAMES = ("_setup.0.profraw", "_setup.1.profraw", "intern_basic.parent.profraw") + + +class Infrastructure(Exception): + pass + + +def require(condition, reason): + if not condition: + raise Infrastructure(reason) + + +def guarded_import(path, expected, name): + path = Path(path).resolve(strict=True) + with path.open("rb") as stream: + snapshot = stream.read(256 * 1024 + 1) + require(len(snapshot) <= 256 * 1024 and hashlib.sha256(snapshot).hexdigest() == expected, + "reviewed source snapshot differs: " + str(path)) + spec = importlib.util.spec_from_file_location(name, str(path)) + require(spec is not None, "cannot construct guarded import") + module = importlib.util.module_from_spec(spec) + exec(compile(snapshot, str(path), "exec"), module.__dict__) + return module + + +def reducer_worker(): + parser = argparse.ArgumentParser() + parser.add_argument("--reduce-worker", action="store_true", required=True) + parser.add_argument("--helper", required=True) + parser.add_argument("--reducer", required=True) + parser.add_argument("--suite-dir", required=True) + parser.add_argument("--profdata", required=True) + parser.add_argument("--runner-returncode", required=True, type=int) + args = parser.parse_args() + guarded_import(args.helper, HELPER_SHA, "terminal_verified_helpers") + reducer = guarded_import(args.reducer, REDUCER_SHA, "terminal_actual_reducer") + directory = Path(args.suite_dir) + require(sorted(item.name for item in directory.iterdir()) == sorted(RAW_NAMES), + "reducer input inventory changed") + rows = reducer.reduce_suite(SUITE, str(directory), args.profdata, args.runner_returncode == 0) + print(json.dumps({name: {"status": row[0], "reason": row[1], "names_count": len(row[2])} + for name, row in rows.items()}, sort_keys=True)) + return 0 + + +class Proof: + def __init__(self, args, root, receipt): + self.common = guarded_import(args.helper, HELPER_SHA, "terminal_verified_helpers") + require((self.common.DEADLINE, self.common.GRACE, self.common.LOG_CAP) == (60, 5, 1048576), + "reviewed process-helper bounds differ") + self.root, self.receipt = root, receipt + self.work, self.admitted = [], set() + receipt["work"] = self.work + receipt["processes"] = self.common.PROCESS_RECORDS + self.inputs = {} + for name, path, executable in ( + ("runner", args.runner, True), ("profdata", args.llvm_profdata, True), + ("reducer", args.reducer, False), ("helper", args.helper, False), + ("driver", __file__, False)): + self.admit(Path(path).resolve(strict=True)) + self.inputs[name] = self.common.identity(path, executable) + require(self.inputs["helper"]["sha256"] == HELPER_SHA, "helper identity differs") + require(self.inputs["reducer"]["sha256"] == REDUCER_SHA, "reducer identity differs") + receipt["inputs"] = self.inputs + self.runner = self.inputs["runner"]["path"] + self.tool = self.inputs["profdata"]["path"] + self.environment = os.environ.copy() + self.environment["LLVM_PROFILE_FILE"] = "/dev/null" + self.environment.pop("CBM_TEST_COVERAGE_DIR", None) + self.environment.pop("CBM_TEST_ONLY", None) + self.environment.pop("CBM_TEST_ONLY_FILE", None) + self.seed = None + self.seed_identity = None + + def charge(self, kind, detail): + require(len(self.work) < WORK_CAP, "combined finite work cap exhausted") + self.work.append({"kind": kind, "detail": detail}) + + def admit(self, path): + key = str(path) + if key not in self.admitted: + self.charge("input_file", key) + self.admitted.add(key) + + def run(self, directory, label, argv, environment, success=False): + self.charge("process", label) + record = self.common.run_process(directory, label, argv, environment) + record["evidence_directory"] = str(directory) + record["process_receipt"] = str(directory / (label + ".process.json")) + require(not record["failure"] and record["quiescent"] + and type(record["returncode"]) is int and record["returncode"] >= 0, + "process infrastructure failure: " + label) + if success: + require(record["returncode"] == 0, "native tool/control failed: " + label) + return record + + def log(self, directory, record, kind): + return self.common.ANSI.sub("", self.common.read_log(directory, record, kind)) + + def fingerprint(self, path): + value = path.lstat() + require(stat.S_ISREG(value.st_mode) and value.st_nlink == 1 and value.st_uid == os.geteuid(), + "owned seed/profile is not a private regular file") + identity = self.common.identity(path) + identity.update({"device": value.st_dev, "inode": value.st_ino, + "mode": stat.S_IMODE(value.st_mode), "owner": value.st_uid, + "links": value.st_nlink}) + return identity + + def inventory(self, directory): + names = [] + with os.scandir(str(directory)) as entries: + for entry in entries: + require(len(names) < len(RAW_NAMES), "unexpected extra suite artifact") + names.append(entry.name) + require(sorted(names) == sorted(RAW_NAMES), "expected three profile artifacts are absent") + for name in RAW_NAMES: + path = directory / name + self.admit(path) + require(self.common.regular_nonempty(path), "invalid native raw profile: " + name) + return sorted(names) + + def count(self, case, label, raw, function): + self.admit(raw) + require(self.common.regular_nonempty(raw), "raw profile is absent/unreadable") + indexed = case / (label + ".profdata") + self.run(case, label + "-merge", [self.tool, "merge", "--instr", + "--output=" + str(indexed), str(raw)], + self.environment, success=True) + require(self.common.regular_nonempty(indexed), "merge produced no indexed profile") + self.admit(indexed) + shown = self.run(case, label + "-count", + [self.tool, "show", "--counts", "--function=" + function, str(indexed)], + self.environment, success=True) + blocks, current = [], None + for line in self.log(case, shown, "stdout").splitlines(): + heading = re.fullmatch(r" (.+):", line) + if heading: + current = {"name": heading.group(1), "counts": []} + blocks.append(current) + elif current is not None: + value = re.fullmatch(r" Function count: ([0-9]+)", line) + if value: + current["counts"].append(int(value.group(1))) + exact = [block for block in blocks if block["name"] == function + or block["name"].endswith(":" + function)] + require(len(exact) == 1 and len(exact[0]["counts"]) == 1, + "missing/ambiguous exact function record: " + function) + return {"raw": str(raw), "indexed": str(indexed), "function": exact[0]["name"], + "count": exact[0]["counts"][0]} + + def reduce(self, case, directory, code): + # The pinned reducer invokes exactly one profdata reader per admitted raw + # profile here. Reserve those nested processes before launching its group. + self.inventory(directory) + for name in RAW_NAMES: + self.charge("reserved_reducer_process", name) + argv = [sys.executable, self.inputs["driver"]["path"], "--reduce-worker", + "--helper", self.inputs["helper"]["path"], + "--reducer", self.inputs["reducer"]["path"], + "--suite-dir", str(directory), "--profdata", self.tool, + "--runner-returncode", str(code)] + record = self.run(case, "actual-reducer", argv, self.environment, success=True) + rows = json.loads(self.log(case, record, "stdout")) + require(isinstance(rows, dict) and set(rows) == {TEST, "*"}, "unexpected actual reducer rows") + for row in rows.values(): + require(isinstance(row, dict) and set(row) == {"status", "reason", "names_count"} + and row["status"] in ("complete", "incomplete") + and isinstance(row["reason"], str) and type(row["names_count"]) is int + and row["names_count"] >= 0, "invalid actual reducer response") + return rows + + def body(self, stdout, code): + rows = re.findall(r"(?m)^ ([A-Za-z_][A-Za-z0-9_]*)[ \t]+(PASS|FAIL|SKIP)\b", stdout) + starts = re.findall(r"(?m)^ " + re.escape(TEST) + r"\b", stdout) + summaries = re.findall(r"(?m)^[ \t]*(\d+) passed(?:, (\d+) failed)?(?:, (\d+) skipped)?" + r"(?:, (\d+) deselected)?[ \t]*$", stdout) + require(rows == [(TEST, "PASS")] and len(starts) == 1 and len(summaries) == 1, + "exact selected body did not PASS once") + numbers = [int(value or 0) for value in summaries[0]] + require(numbers[0] == 1 and numbers[2] == 0 and numbers[1] in (0, 1), + "unrelated body/summary failure") + if code == 0: + require(numbers[1] == 0, "zero exit with failed summary") + return numbers + + def errors(self, stderr, target, required): + native, owned = [], [] + for line in stderr.splitlines(): + if line.startswith("LLVM Profile Error:"): + match = re.fullmatch(r'LLVM Profile Error: Failed to write file "(.*)": (.+)', line) + require(match is not None and match.group(1) == str(target), + "unrecognized or wrong-path native LLVM error") + native.append({"line": line, "reason": match.group(2)}) + elif line.startswith("coverage profile write failed:"): + require(line == "coverage profile write failed: " + str(target), + "wrong-path runner-owned profile failure") + owned.append(line) + elif (line.startswith("coverage child accounting ") + or line.startswith("coverage profile could not be routed:") + or line.startswith("a coverage run starts with") + or line.startswith("cannot use coverage directory:") + or line.startswith("cannot route coverage profiles under:")): + raise Infrastructure("unrelated coverage activation/accounting failure") + require(bool(native) == required, "intended native terminal-write precondition not observed") + require(len(owned) <= 1, "duplicate runner-owned final failure") + if not required: + require(not owned, "positive control reported final writer failure") + return {"native": native, "runner_owned": owned} + + def case(self, mode, source_seed=None): + case = self.root / mode + case.mkdir(mode=0o700) + coverage = case / "coverage" + directory = coverage / SUITE + directory.mkdir(mode=0o700, parents=True) + terminal = directory / "_setup.1.profraw" + result = {"mode": mode, "coverage_root": str(coverage), "controls_passed": False} + self.receipt["cases"].append(result) + if source_seed is not None: + self.seed = terminal + result["seed_source"] = self.common.identity(source_seed) + with source_seed.open("rb") as source, terminal.open("xb") as output: + os.fchmod(output.fileno(), 0o600) + for block in iter(lambda: source.read(1024 * 1024), b""): + output.write(block) + output.flush() + os.fchmod(output.fileno(), 0o400) + self.admit(terminal) + self.seed_identity = self.fingerprint(terminal) + require(self.seed_identity["mode"] == 0o400, "seed did not retain the read-only mode") + require(self.seed_identity["sha256"] == result["seed_source"]["sha256"], + "seed copy differs from genuine successful initial profile") + result["seed_before"] = self.seed_identity + environment = self.environment.copy() + environment["CBM_TEST_COVERAGE_DIR"] = str(coverage) + environment["CBM_TEST_ONLY"] = SUITE + ":" + TEST + result["input_environment"] = {"LLVM_PROFILE_FILE": "/dev/null", + "CBM_TEST_COVERAGE_DIR": str(coverage), + "CBM_TEST_ONLY": SUITE + ":" + TEST, + "CBM_TEST_ONLY_FILE": "absent"} + process = self.run(case, "runner", [self.runner, SUITE], environment) + result["runner"] = process + code = process["returncode"] + result["summary"] = self.body(self.log(case, process, "stdout"), code) + result["errors"] = self.errors(self.log(case, process, "stderr"), terminal, source_seed is not None) + if source_seed is not None: + result["seed_after_quiescence"] = self.fingerprint(terminal) + require(result["seed_after_quiescence"] == self.seed_identity, + "native run replaced/changed seeded terminal artifact; precondition unmet") + result["inventory"] = self.inventory(directory) + result["initial"] = self.count(case, "initial", directory / "_setup.0.profraw", FINAL_FUNCTION) + result["terminal"] = self.count(case, "terminal", terminal, FINAL_FUNCTION) + result["body"] = self.count(case, "body", directory / (TEST + ".parent.profraw"), "test_" + TEST) + require(result["initial"]["count"] == 0 and result["body"]["count"] > 0, + "current initial/body profile controls failed") + require((result["terminal"]["count"] > 0) if source_seed is None + else (result["terminal"]["count"] == 0), "terminal native count precondition failed") + rows = self.reduce(case, directory, code) + result["rows"] = rows + require(all(row["reason"] in ("", "suite did not exit 0") for row in rows.values()), + "actual reduction reports unrelated unreadable/missing-profile evidence") + complete = all(row["status"] == "complete" and row["reason"] == "" for row in rows.values()) + conservative = all(row["status"] == "incomplete" + and "suite did not exit 0" in row["reason"].split("; ") for row in rows.values()) + if source_seed is None: + require(code == 0 and complete, "successful control failed") + result["feature_passed"] = True + elif code == 0: + require(not result["errors"]["runner_owned"] and complete, + "zero-status result differs from the bounded stale-complete regression") + result["feature_passed"] = False + result["regression"] = "failed automatic final write accepted with readable stale setup" + else: + require(code == 1 and result["summary"][1] == 1, + "terminal failure did not produce the exact runner failure status/summary") + require(len(result["errors"]["runner_owned"]) == 1, + "nonzero status lacks attributed final profile failure") + result["feature_passed"] = conservative + if not conservative: + result["regression"] = "actual reducer did not retain the final failure floor" + result["controls_passed"] = True + self.common.save(case / "result.json", result) + return directory / "_setup.0.profraw" + + def restore_seed_mode(self): + if self.seed is None or self.seed_identity is None: + return + if not all(row["quiescent"] for row in self.common.PROCESS_RECORDS): + self.receipt["seed_cleanup"] = "retained unchanged: quiescence not established" + return + if self.fingerprint(self.seed) != self.seed_identity: + self.receipt["seed_cleanup"] = "retained unchanged: original seed identity/content differs" + raise Infrastructure("seed changed before controlled mode restoration") + os.chmod(str(self.seed), 0o600) + self.receipt["seed_cleanup"] = {"owned_mode_restored": 0o600, + "post_cleanup": self.fingerprint(self.seed)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("runner", "llvm-profdata", "reducer", "helper", "output-dir"): + parser.add_argument("--" + name, required=True) + args = parser.parse_args() + require(os.name == "posix", "this finite permission/native-profile proof requires POSIX") + root = Path(args.output_dir).resolve() + require(not any(char in str(root) for char in ("%", "\n", "\r")), "invalid owned profile root") + root.mkdir(mode=0o700) # Fresh exclusive evidence, never delete/reuse an old run. + receipt = {"schema": 1, "oracle": "terminal-parent-write-v1", "cases": [], + "classification": "infrastructure_failure", "exit_code": 2, + "combined_work_cap": WORK_CAP, "process_deadline_seconds": 60, + "grace_seconds_per_phase": 5, "stream_cap_bytes": 1048576, + "limits": ["Shared helper/reducer source guarded before import/exec.", + "Reducer internal captures are not covered by the outer stream cap.", + "No proof of later atexit callbacks or all-future process/image closure."]} + proof = None + try: + proof = Proof(args, root, receipt) + version = proof.run(root, "profdata-version", [proof.tool, "--version"], + proof.environment, success=True) + require(re.search(r"\bversion 22\.1\.8\b", proof.log(root, version, "stdout")), + "matching LLVM 22.1.8 tool precondition failed") + seed = proof.case("control") + proof.case("readonly-terminal", seed) + for name, identity in proof.inputs.items(): + require(proof.common.identity(identity["path"], name in ("runner", "profdata")) == identity, + "proof input changed: " + name) + passed = all(case["feature_passed"] for case in receipt["cases"]) + receipt["classification"] = "pass" if passed else "regression_failure" + receipt["exit_code"] = 0 if passed else 1 + except (Exception, KeyboardInterrupt) as exc: + receipt["error"] = type(exc).__name__ + ": " + str(exc) + finally: + try: + if proof is not None: + proof.restore_seed_mode() + except (Exception, KeyboardInterrupt) as exc: + receipt["cleanup_error"] = type(exc).__name__ + ": " + str(exc) + receipt["classification"], receipt["exit_code"] = "infrastructure_failure", 2 + records = receipt.get("processes", []) + receipt["all_owned_groups_quiescent"] = all(row.get("quiescent", False) for row in records) + if not receipt["all_owned_groups_quiescent"]: + receipt["classification"], receipt["exit_code"] = "infrastructure_failure", 2 + with (root / "result.json").open("x", encoding="utf-8") as stream: + json.dump(receipt, stream, indent=2, sort_keys=True) + stream.write("\n") + print(receipt["classification"] + ": " + str(root / "result.json")) + return receipt["exit_code"] + + +if __name__ == "__main__": + sys.dont_write_bytecode = True + try: + sys.exit(reducer_worker() if sys.argv[1:2] == ["--reduce-worker"] else main()) + except (Exception, KeyboardInterrupt) as error: + print("infrastructure_failure: " + type(error).__name__ + ": " + str(error), file=sys.stderr) + sys.exit(2) diff --git a/tests/test_gitignore.c b/tests/test_gitignore.c index 7cae17362..b6bd73d79 100644 --- a/tests/test_gitignore.c +++ b/tests/test_gitignore.c @@ -334,7 +334,26 @@ TEST(gi_doublestar_backtracking_terminates) { /* ── Suite ─────────────────────────────────────────────────────── */ +TEST(gi_character_classes_do_not_cross_path_separators) { + const char *patterns[] = {"src[!x]test.c", "src[/]test.c", "src[.-0]test.c"}; + for (size_t i = 0; i < sizeof(patterns) / sizeof(patterns[0]); i++) { + cbm_gitignore_t *gi = cbm_gitignore_parse(patterns[i]); + ASSERT_NOT_NULL(gi); + bool crossed = cbm_gitignore_matches(gi, "src/test.c", false); + cbm_gitignore_free(gi); + ASSERT_FALSE(crossed); + } + cbm_gitignore_t *gi = cbm_gitignore_parse("dir/src[!x]test.c\n"); + ASSERT_NOT_NULL(gi); + ASSERT_TRUE(cbm_gitignore_matches(gi, "dir/srcatest.c", false)); + ASSERT_FALSE(cbm_gitignore_matches(gi, "dir/srcxtest.c", false)); + ASSERT_FALSE(cbm_gitignore_matches(gi, "dir/src/test.c", false)); + cbm_gitignore_free(gi); + PASS(); +} + SUITE(gitignore) { + RUN_TEST(gi_character_classes_do_not_cross_path_separators); RUN_TEST(gi_doublestar_backtracking_terminates); RUN_TEST(gi_empty_pattern); RUN_TEST(gi_exact_file); diff --git a/tests/test_gitignore_checked.c b/tests/test_gitignore_checked.c new file mode 100644 index 000000000..327e9b510 --- /dev/null +++ b/tests/test_gitignore_checked.c @@ -0,0 +1,952 @@ +/* Independent tests for the frozen exact-byte checked-ignore API. + * No loader, global hooks, allocation pressure, timing races or admission claims. */ +#include "test_framework.h" +#include "discover/discover.h" +#include "discover/gitignore_checked.h" +#include "foundation/arena.h" +#include "foundation/platform.h" +#include +#include + +/* Existing legacy symbol; discover.h currently declares the bool wrapper only. */ +extern int cbm_gitignore_match_result(const cbm_gitignore_t *, const char *, bool); + +#define GIC_CHECK(expression) \ + do { \ + if (!(expression)) { \ + fprintf(stderr, "checked-ignore assertion %s:%d: %s\n", __FILE__, __LINE__, \ + #expression); \ + goto done; \ + } \ + } while (0) + +typedef struct { + CBMArena input; + cbm_ignore_checked_t *owner[3]; +} gic_fixture; + +static void gic_init(gic_fixture *f) { + memset(f, 0, sizeof(*f)); + cbm_arena_init(&f->input); +} + +static int gic_finish(gic_fixture *f, int result) { + for (size_t i = 0; i < 3; i++) + cbm_ignore_checked_free(f->owner[i]); + cbm_arena_destroy(&f->input); + return result; +} + +static cbm_ignore_checked_limits_t gic_limits(void) { + return (cbm_ignore_checked_limits_t){.max_bytes = 4 * 1024 * 1024, + .max_work = 100000000, + .max_patterns = 1024, + .max_arena_bytes = 4 * 1024 * 1024, + .max_depth = CBM_IGNORE_CHECKED_DEPTH_MAX}; +} + +static cbm_ignore_checked_control_t gic_control(void) { + return (cbm_ignore_checked_control_t){.deadline_ms = UINT64_MAX}; +} + +static bool gic_status(cbm_ignore_checked_status_t actual, cbm_ignore_checked_status_t expected, + const cbm_ignore_checked_error_t *error) { + if (actual == expected && error->status == expected && + (expected == CBM_IGNORE_CHECKED_LIMIT || error->cap == CBM_IGNORE_CAP_NONE)) + return true; + fprintf(stderr, "checked-ignore status actual=%d expected=%d error=%d cap=%d offset=%zu\n", + (int)actual, (int)expected, (int)error->status, (int)error->cap, error->byte_offset); + return false; +} + +static bool gic_open(cbm_ignore_checked_t **owner, const cbm_ignore_checked_limits_t *limits) { + cbm_ignore_checked_error_t error; + memset(&error, 0xa5, sizeof(error)); + bool ok = + gic_status(cbm_ignore_checked_open(limits, owner, &error), CBM_IGNORE_CHECKED_OK, &error) && + *owner; + if (!ok) { + cbm_ignore_checked_free(*owner); + *owner = NULL; + } + return ok; +} + +static bool gic_parse(cbm_ignore_checked_t *owner, const void *bytes, size_t length, size_t count, + const cbm_ignore_program_t **program) { + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_checked_parse_result_t out = {0}; + cbm_ignore_checked_error_t error; + cbm_ignore_checked_status_t status = + cbm_ignore_checked_parse(owner, bytes, length, &control, &out, &error); + if (!gic_status(status, CBM_IGNORE_CHECKED_OK, &error) || !out.program || + out.pattern_count != count) + return false; + *program = out.program; + return true; +} + +static bool gic_match(cbm_ignore_checked_t *owner, const cbm_ignore_program_t *program, + const char *path, bool directory, cbm_ignore_decision_t expected) { + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_checked_error_t error; + cbm_ignore_decision_t decision = CBM_IGNORE_UNAVAILABLE; + cbm_ignore_checked_status_t status = cbm_ignore_checked_match( + owner, program, path, strlen(path), directory, &control, &decision, &error); + return gic_status(status, CBM_IGNORE_CHECKED_OK, &error) && decision == expected; +} + +static bool gic_usage_equal(const cbm_ignore_checked_usage_t *a, + const cbm_ignore_checked_usage_t *b) { + return a->bytes_reserved == b->bytes_reserved && a->work_used == b->work_used && + a->patterns_reserved == b->patterns_reserved && + a->arena_requested_bytes == b->arena_requested_bytes && + a->terminal_status == b->terminal_status; +} + +typedef struct { + const char *patterns, *path; + bool directory; + size_t count; + cbm_ignore_decision_t expected; +} gic_semantic; + +static bool gic_semantic_one(const gic_semantic *row) { + /* Legacy controls run first, so a broken fixture is not a feature failure. */ + cbm_gitignore_t *legacy = cbm_gitignore_parse(row->patterns); + if (!legacy) + return false; + int old = cbm_gitignore_match_result(legacy, row->path, row->directory); + bool old_bool = cbm_gitignore_matches(legacy, row->path, row->directory); + cbm_gitignore_free(legacy); + if (old != (int)row->expected || old_bool != (row->expected == CBM_IGNORE_IGNORED)) + return false; + cbm_ignore_checked_t *owner = NULL; + cbm_ignore_checked_limits_t limits = gic_limits(); + const cbm_ignore_program_t *program = NULL; + bool ok = gic_open(&owner, &limits) && + gic_parse(owner, row->patterns, strlen(row->patterns), row->count, &program) && + gic_match(owner, program, row->path, row->directory, row->expected); + cbm_ignore_checked_free(owner); + return ok; +} + +TEST(gic_a_semantics_and_legacy_parity) { + const gic_semantic rows[] = { + {"", "foo", false, 0, CBM_IGNORE_NO_OPINION}, + {"#comment\n\n \t\r\n!\n/\n!/\n", "foo", false, 0, CBM_IGNORE_NO_OPINION}, + {"*.tmp \t\r\n!keep.tmp\r\n", "keep.tmp", false, 2, CBM_IGNORE_REINCLUDED}, + {"*.tmp \t\r\n!keep.tmp\r\n", "deep/x.tmp", false, 2, CBM_IGNORE_IGNORED}, + {" #literal\n", " #literal", false, 1, CBM_IGNORE_IGNORED}, + {"a\rb", "a\rb", false, 1, CBM_IGNORE_IGNORED}, + {"x\nx\n!x\nx", "x", false, 4, CBM_IGNORE_IGNORED}, + {"!x", "x", false, 1, CBM_IGNORE_REINCLUDED}, + {"/build", "src/build", true, 1, CBM_IGNORE_NO_OPINION}, + {"/build", "build", true, 1, CBM_IGNORE_IGNORED}, + {"doc/frotz", "src/doc/frotz", false, 1, CBM_IGNORE_NO_OPINION}, + {"doc/frotz", "doc/frotz", false, 1, CBM_IGNORE_IGNORED}, + {"build/", "deep/build", true, 1, CBM_IGNORE_IGNORED}, + {"build/", "build", false, 1, CBM_IGNORE_NO_OPINION}, + {"build/", "build/x", false, 1, CBM_IGNORE_NO_OPINION}, + {"**/build", "a/b/build", true, 1, CBM_IGNORE_IGNORED}, + {"a/**/b", "a/b", false, 1, CBM_IGNORE_IGNORED}, + {"logs/**", "logs/a/b", false, 1, CBM_IGNORE_IGNORED}, + {"a**b", "a/x/b", false, 1, CBM_IGNORE_IGNORED}, + {"/a*b", "a/x/b", false, 1, CBM_IGNORE_NO_OPINION}, + {"file?.txt", "file12.txt", false, 1, CBM_IGNORE_NO_OPINION}, + {"file[0-9].txt", "file3.txt", false, 1, CBM_IGNORE_IGNORED}, + {"[a", "a", false, 1, CBM_IGNORE_IGNORED}, + {"[]", "a", false, 1, CBM_IGNORE_NO_OPINION}, + {"[!]", "z", false, 1, CBM_IGNORE_IGNORED}, + {"[^a]", "a", false, 1, CBM_IGNORE_NO_OPINION}, + {"src[.-0]test.c", "src/test.c", false, 1, CBM_IGNORE_NO_OPINION}, + {"src[!x]test.c", "src/test.c", false, 1, CBM_IGNORE_NO_OPINION}, + {"src[/]test.c", "src/test.c", false, 1, CBM_IGNORE_NO_OPINION}, + {"\\#literal", "\\#literal", false, 1, CBM_IGNORE_IGNORED}, + {"\\!literal", "!literal", false, 1, CBM_IGNORE_NO_OPINION}, + {"tail\\ \t\r\n", "tail\\", false, 1, CBM_IGNORE_IGNORED}, + {"/x", "./x", false, 1, CBM_IGNORE_NO_OPINION}, + {"a/b", "a//b", false, 1, CBM_IGNORE_NO_OPINION}, + {"\x80", "\x80", false, 1, CBM_IGNORE_IGNORED}, + {"[\x80-z]", "A", false, 1, CHAR_MIN < 0 ? CBM_IGNORE_IGNORED : CBM_IGNORE_NO_OPINION}, + {"*", "", false, 1, CBM_IGNORE_IGNORED}, + }; + for (size_t i = 0; i < sizeof(rows) / sizeof(rows[0]); i++) { + if (!gic_semantic_one(&rows[i])) { + fprintf(stderr, "checked-ignore semantic row=%zu\n", i); + return 1; + } + } + PASS(); +} + +/* A latched failure leaves every accounting field and earlier program intact; + * neither an ordinary parse nor a match may turn it into successful no-opinion. */ +static bool gic_terminal(cbm_ignore_checked_t *owner, const cbm_ignore_program_t *old_program, + cbm_ignore_checked_status_t expected) { + cbm_ignore_checked_usage_t before, after; + if (!cbm_ignore_checked_usage(owner, &before) || before.terminal_status != expected) + return false; + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_checked_parse_result_t out = {.program = old_program, .pattern_count = 77}; + cbm_ignore_checked_error_t error; + if (!gic_status(cbm_ignore_checked_parse(owner, "x", 1, &control, &out, &error), expected, + &error) || + out.program || out.pattern_count) + return false; + if (old_program) { + cbm_ignore_decision_t decision = CBM_IGNORE_IGNORED; + if (!gic_status(cbm_ignore_checked_match(owner, old_program, "x", 1, false, &control, + &decision, &error), + expected, &error) || + decision != CBM_IGNORE_UNAVAILABLE) + return false; + } + return cbm_ignore_checked_usage(owner, &after) && gic_usage_equal(&before, &after); +} + +static bool gic_bad_span(cbm_ignore_checked_t *owner, const cbm_ignore_program_t *old_program, + unsigned kind) { + cbm_ignore_checked_usage_t before, after; + if (!cbm_ignore_checked_usage(owner, &before)) + return false; + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_checked_error_t error; + cbm_ignore_checked_status_t status; + if (kind == 0) { + const unsigned char input[] = {'x', '\n', 0, 'y'}; + cbm_ignore_checked_parse_result_t out = {.program = old_program, .pattern_count = 77}; + status = cbm_ignore_checked_parse(owner, input, sizeof(input), &control, &out, &error); + if (out.program || out.pattern_count) + return false; + } else { + const char missing[] = {'x', 'y'}; + const char embedded[] = {'x', 0, 'y', 0}; + cbm_ignore_decision_t decision = CBM_IGNORE_IGNORED; + status = cbm_ignore_checked_match(owner, old_program, kind == 1 ? missing : embedded, + kind == 1 ? 1 : 3, false, &control, &decision, &error); + if (decision != CBM_IGNORE_UNAVAILABLE) + return false; + } + uint64_t reserved = kind == 1 ? 2 : 4; + return cbm_ignore_checked_usage(owner, &after) && + after.bytes_reserved == before.bytes_reserved + reserved && + gic_status(status, CBM_IGNORE_CHECKED_UNSUPPORTED, &error) && + gic_terminal(owner, old_program, CBM_IGNORE_CHECKED_UNSUPPORTED); +} + +TEST(gic_b_exact_spans_and_input_lifetime) { + gic_fixture f; + gic_init(&f); + int result = 1; + cbm_ignore_checked_limits_t limits = gic_limits(); + const cbm_ignore_program_t *program = NULL, *empty = NULL; + GIC_CHECK(gic_open(&f.owner[0], &limits)); + GIC_CHECK(gic_parse(f.owner[0], NULL, 0, 0, &empty)); + GIC_CHECK(gic_match(f.owner[0], empty, "", false, CBM_IGNORE_NO_OPINION)); + unsigned char *input = cbm_arena_alloc(&f.input, 4); + GIC_CHECK(input); + memcpy(input, "a\nbX", 4); + GIC_CHECK(gic_parse(f.owner[0], input, 3, 2, &program)); + memset(input, 0xa5, 4); + cbm_arena_destroy(&f.input); + cbm_arena_init(&f.input); + GIC_CHECK(gic_match(f.owner[0], program, "a", false, CBM_IGNORE_IGNORED)); + GIC_CHECK(gic_match(f.owner[0], program, "b", false, CBM_IGNORE_IGNORED)); + GIC_CHECK(gic_match(f.owner[0], program, "bX", false, CBM_IGNORE_NO_OPINION)); + cbm_ignore_checked_free(f.owner[0]); + f.owner[0] = NULL; + for (unsigned kind = 0; kind < 3; kind++) { + GIC_CHECK(gic_open(&f.owner[0], &limits)); + GIC_CHECK(gic_parse(f.owner[0], "x", 1, 1, &program)); + GIC_CHECK(gic_match(f.owner[0], program, "x", false, CBM_IGNORE_IGNORED)); + GIC_CHECK(gic_bad_span(f.owner[0], program, kind)); + cbm_ignore_checked_free(f.owner[0]); + f.owner[0] = NULL; + } + result = 0; +done: + return gic_finish(&f, result); +} + +typedef struct { + size_t calls, stop; +} gic_counter; +static bool gic_cancel(void *context) { + gic_counter *counter = context; + counter->calls++; + return counter->stop && counter->calls >= counter->stop; +} + +static bool gic_invalid_parse_call(cbm_ignore_checked_t *owner, const cbm_ignore_program_t *program, + unsigned kind, cbm_ignore_checked_control_t *control, + cbm_ignore_checked_error_t *error, + cbm_ignore_checked_status_t *status) { + if (kind == 3) + control->deadline_ms = 0; + cbm_ignore_checked_parse_result_t out = {.program = program, .pattern_count = 77}; + *status = cbm_ignore_checked_parse(kind == 0 ? NULL : owner, kind == 1 ? NULL : "x", 1, + kind == 2 ? NULL : control, kind == 4 ? NULL : &out, error); + return kind == 4 || (!out.program && out.pattern_count == 0); +} + +static bool gic_invalid_match_call(cbm_ignore_checked_t *owner, const cbm_ignore_program_t *program, + const cbm_ignore_program_t *foreign, unsigned kind, + cbm_ignore_checked_control_t *control, + cbm_ignore_checked_error_t *error, + cbm_ignore_checked_status_t *status) { + if (kind == 11) + control->deadline_ms = 0; + const cbm_ignore_program_t *chosen = program; + if (kind == 5) + chosen = foreign; + if (kind == 6) + chosen = NULL; + cbm_ignore_decision_t decision = CBM_IGNORE_IGNORED; + *status = cbm_ignore_checked_match(kind == 12 ? NULL : owner, chosen, kind == 7 ? NULL : "x", + kind == 8 ? SIZE_MAX : 1, false, kind == 9 ? NULL : control, + kind == 10 ? NULL : &decision, error); + return kind == 10 || decision == CBM_IGNORE_UNAVAILABLE; +} + +static bool gic_preflight(cbm_ignore_checked_t *owner, const cbm_ignore_program_t *program, + const cbm_ignore_program_t *foreign, unsigned kind) { + cbm_ignore_checked_usage_t before, after; + if (!cbm_ignore_checked_usage(owner, &before)) + return false; + cbm_ignore_checked_control_t control = gic_control(); + gic_counter counter = {0}; + control.cancelled = gic_cancel; + control.context = &counter; + cbm_ignore_checked_error_t error; + cbm_ignore_checked_status_t status; + bool cleared = + kind < 5 ? gic_invalid_parse_call(owner, program, kind, &control, &error, &status) + : gic_invalid_match_call(owner, program, foreign, kind, &control, &error, &status); + return cleared && gic_status(status, CBM_IGNORE_CHECKED_INVALID, &error) && + counter.calls == 0 && cbm_ignore_checked_usage(owner, &after) && + gic_usage_equal(&before, &after); +} + +static bool gic_invalid_open(unsigned kind) { + cbm_ignore_checked_limits_t limits = gic_limits(); + if (kind == 0) + limits.max_bytes = 0; + if (kind == 1) + limits.max_work = 0; + if (kind == 2) + limits.max_patterns = 0; + if (kind == 3) + limits.max_arena_bytes = 0; + if (kind == 4) + limits.max_depth = 0; + if (kind == 5) + limits.max_depth = CBM_IGNORE_CHECKED_DEPTH_MAX + 1; + if (kind == 6) + limits.max_patterns = (size_t)INT_MAX + 1; + cbm_ignore_checked_t *owner = NULL; + cbm_ignore_checked_error_t error; + cbm_ignore_checked_status_t status = + cbm_ignore_checked_open(kind == 7 ? NULL : &limits, kind == 8 ? NULL : &owner, &error); + bool ok = gic_status(status, CBM_IGNORE_CHECKED_INVALID, &error) && !owner; + cbm_ignore_checked_free(owner); + return ok; +} + +static bool gic_optional_errors(cbm_ignore_checked_t *retained) { + cbm_ignore_checked_t *owner = NULL; + cbm_ignore_checked_limits_t limits = gic_limits(); + if (cbm_ignore_checked_open(&limits, &owner, NULL) != CBM_IGNORE_CHECKED_OK || !owner) { + cbm_ignore_checked_free(owner); + return false; + } + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_checked_parse_result_t out = {0}; + cbm_ignore_decision_t decision = CBM_IGNORE_UNAVAILABLE; + bool ok = + cbm_ignore_checked_parse(owner, "x", 1, &control, &out, NULL) == CBM_IGNORE_CHECKED_OK && + out.program && out.pattern_count == 1; + if (ok) + ok = cbm_ignore_checked_match(owner, out.program, "x", 1, false, &control, &decision, + NULL) == CBM_IGNORE_CHECKED_OK && + decision == CBM_IGNORE_IGNORED; + cbm_ignore_checked_free(owner); + owner = retained; /* Retained ownership remains in the caller's fixture. */ + cbm_ignore_checked_error_t error; + return gic_status(cbm_ignore_checked_open(NULL, &owner, &error), CBM_IGNORE_CHECKED_INVALID, + &error) && + !owner && ok; +} + +TEST(gic_c_ownership_preflight_and_terminal) { + gic_fixture f; + gic_init(&f); + int result = 1; + cbm_ignore_checked_limits_t limits = gic_limits(); + const cbm_ignore_program_t *program = NULL, *foreign = NULL; + GIC_CHECK(gic_open(&f.owner[0], &limits) && gic_open(&f.owner[1], &limits)); + memset(&limits, 0, sizeof(limits)); /* Owners must retain their copied caps. */ + GIC_CHECK(gic_parse(f.owner[0], "x", 1, 1, &program)); + GIC_CHECK(gic_parse(f.owner[1], "x", 1, 1, &foreign)); + GIC_CHECK(gic_match(f.owner[0], program, "x", false, CBM_IGNORE_IGNORED)); + for (unsigned kind = 0; kind < 9; kind++) + GIC_CHECK(gic_invalid_open(kind)); + for (unsigned kind = 0; kind < 13; kind++) + GIC_CHECK(gic_preflight(f.owner[0], program, foreign, kind)); + GIC_CHECK(gic_match(f.owner[0], program, "x", false, CBM_IGNORE_IGNORED)); + GIC_CHECK(gic_match(f.owner[1], foreign, "x", false, CBM_IGNORE_IGNORED)); + GIC_CHECK(gic_optional_errors(f.owner[1])); + GIC_CHECK(gic_bad_span(f.owner[0], program, 0)); + GIC_CHECK(gic_preflight(f.owner[0], program, foreign, 5)); + GIC_CHECK(gic_terminal(f.owner[0], program, CBM_IGNORE_CHECKED_UNSUPPORTED)); + GIC_CHECK(gic_match(f.owner[1], foreign, "x", false, CBM_IGNORE_IGNORED)); + cbm_ignore_checked_usage_t usage; + memset(&usage, 0xa5, sizeof(usage)); + GIC_CHECK(!cbm_ignore_checked_usage(NULL, &usage)); + GIC_CHECK(usage.bytes_reserved == 0 && usage.work_used == 0 && usage.patterns_reserved == 0 && + usage.arena_requested_bytes == 0 && usage.terminal_status == CBM_IGNORE_CHECKED_OK); + GIC_CHECK(!cbm_ignore_checked_usage(f.owner[1], NULL)); + cbm_ignore_checked_free(NULL); + result = 0; +done: + return gic_finish(&f, result); +} + +static const char gic_rules0[] = "*.tmp\n!keep.tmp\n"; +static const char gic_rules1[] = "file?\n"; +static const char *const gic_paths[] = {"drop.tmp", "keep.tmp", "file1", "other", "drop.tmp"}; +static const cbm_ignore_decision_t gic_decisions[] = {CBM_IGNORE_IGNORED, CBM_IGNORE_REINCLUDED, + CBM_IGNORE_IGNORED, CBM_IGNORE_NO_OPINION, + CBM_IGNORE_IGNORED}; + +typedef struct { + cbm_ignore_checked_status_t status; + cbm_ignore_cap_t cap; + cbm_ignore_checked_usage_t usage; +} gic_replay_result; + +static bool gic_replay_parse(cbm_ignore_checked_t *owner, size_t step, + const cbm_ignore_program_t **program, + cbm_ignore_checked_status_t *status, + cbm_ignore_checked_error_t *error) { + const char *rules = step == 0 ? gic_rules0 : gic_rules1; + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_checked_parse_result_t out = {.pattern_count = 77}; + *status = cbm_ignore_checked_parse(owner, rules, strlen(rules), &control, &out, error); + if (*status != CBM_IGNORE_CHECKED_OK) + return !out.program && out.pattern_count == 0; + *program = out.program; + return out.program && out.pattern_count == (step == 0 ? 2 : 1); +} + +static bool gic_replay_match(cbm_ignore_checked_t *owner, const cbm_ignore_program_t **program, + size_t step, cbm_ignore_checked_status_t *status, + cbm_ignore_checked_error_t *error) { + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_decision_t decision = CBM_IGNORE_IGNORED; + size_t row = step - 2; + *status = cbm_ignore_checked_match(owner, program[row == 2 ? 1 : 0], gic_paths[row], + strlen(gic_paths[row]), false, &control, &decision, error); + return decision == + (*status == CBM_IGNORE_CHECKED_OK ? gic_decisions[row] : CBM_IGNORE_UNAVAILABLE); +} + +static bool gic_replay(const cbm_ignore_checked_limits_t *limits, gic_replay_result *out) { + cbm_ignore_checked_t *owner = NULL; + if (!gic_open(&owner, limits)) + return false; + const cbm_ignore_program_t *program[2] = {NULL, NULL}; + cbm_ignore_checked_error_t error = {0}; + cbm_ignore_checked_status_t status = CBM_IGNORE_CHECKED_OK; + bool ok = true; + size_t arena_after_parse = 0; + for (size_t step = 0; step < 7 && ok && status == CBM_IGNORE_CHECKED_OK; step++) { + cbm_ignore_checked_usage_t before, after; + ok = cbm_ignore_checked_usage(owner, &before); + if (!ok) + break; + ok = step < 2 ? gic_replay_parse(owner, step, &program[step], &status, &error) + : gic_replay_match(owner, program, step, &status, &error); + ok = ok && error.status == status && cbm_ignore_checked_usage(owner, &after); + if (!ok) + break; + if (step == 1) + arena_after_parse = after.arena_requested_bytes; + if (step >= 2) + ok = after.arena_requested_bytes == arena_after_parse; + if (status == CBM_IGNORE_CHECKED_LIMIT && error.cap == CBM_IGNORE_CAP_BYTES) + ok = ok && before.bytes_reserved == after.bytes_reserved; + } + out->status = status; + out->cap = error.cap; + ok = ok && cbm_ignore_checked_usage(owner, &out->usage); + if (status != CBM_IGNORE_CHECKED_OK) + ok = ok && gic_terminal(owner, program[0], status); + cbm_ignore_checked_free(owner); + return ok; +} + +static bool gic_quota_pair(const gic_replay_result *full, cbm_ignore_cap_t cap) { + cbm_ignore_checked_limits_t limits = gic_limits(); + if (cap == CBM_IGNORE_CAP_BYTES) + limits.max_bytes = full->usage.bytes_reserved; + if (cap == CBM_IGNORE_CAP_PATTERNS) + limits.max_patterns = full->usage.patterns_reserved; + if (cap == CBM_IGNORE_CAP_ARENA) + limits.max_arena_bytes = full->usage.arena_requested_bytes; + if (cap == CBM_IGNORE_CAP_WORK) + limits.max_work = full->usage.work_used; + gic_replay_result exact = {0}, short_cap = {0}; + if (!gic_replay(&limits, &exact) || exact.status != CBM_IGNORE_CHECKED_OK || + exact.cap != CBM_IGNORE_CAP_NONE || !gic_usage_equal(&full->usage, &exact.usage)) + return false; + if (cap == CBM_IGNORE_CAP_BYTES) + limits.max_bytes--; + if (cap == CBM_IGNORE_CAP_PATTERNS) + limits.max_patterns--; + if (cap == CBM_IGNORE_CAP_ARENA) + limits.max_arena_bytes--; + if (cap == CBM_IGNORE_CAP_WORK) + limits.max_work--; + return gic_replay(&limits, &short_cap) && short_cap.status == CBM_IGNORE_CHECKED_LIMIT && + short_cap.cap == cap && short_cap.usage.terminal_status == CBM_IGNORE_CHECKED_LIMIT && + short_cap.usage.bytes_reserved <= limits.max_bytes && + short_cap.usage.patterns_reserved <= limits.max_patterns && + short_cap.usage.arena_requested_bytes <= limits.max_arena_bytes && + short_cap.usage.work_used <= limits.max_work; +} + +TEST(gic_d_aggregate_quota_boundaries) { + cbm_ignore_checked_limits_t limits = gic_limits(); + gic_replay_result full = {0}; + ASSERT_TRUE(gic_replay(&limits, &full)); + ASSERT_EQ(full.status, CBM_IGNORE_CHECKED_OK); + uint64_t expected_bytes = sizeof(gic_rules0) - 1 + sizeof(gic_rules1) - 1; + for (size_t i = 0; i < 5; i++) + expected_bytes += strlen(gic_paths[i]) + 1; + ASSERT_EQ(full.usage.bytes_reserved, expected_bytes); + ASSERT_EQ(full.usage.patterns_reserved, 3); + ASSERT_GT(full.usage.work_used, 1); + ASSERT_GT(full.usage.arena_requested_bytes, 1); + ASSERT_TRUE(gic_quota_pair(&full, CBM_IGNORE_CAP_BYTES)); + ASSERT_TRUE(gic_quota_pair(&full, CBM_IGNORE_CAP_PATTERNS)); + ASSERT_TRUE(gic_quota_pair(&full, CBM_IGNORE_CAP_ARENA)); + ASSERT_TRUE(gic_quota_pair(&full, CBM_IGNORE_CAP_WORK)); + PASS(); +} + +static bool gic_match_limit(cbm_ignore_checked_t *owner, const cbm_ignore_program_t *program, + const char *path, cbm_ignore_cap_t cap) { + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_checked_error_t error; + cbm_ignore_decision_t decision = CBM_IGNORE_IGNORED; + cbm_ignore_checked_status_t status = cbm_ignore_checked_match( + owner, program, path, strlen(path), false, &control, &decision, &error); + return gic_status(status, CBM_IGNORE_CHECKED_LIMIT, &error) && error.cap == cap && + decision == CBM_IGNORE_UNAVAILABLE && + gic_terminal(owner, program, CBM_IGNORE_CHECKED_LIMIT); +} + +static bool gic_depth_case(unsigned depth, bool earlier) { + cbm_ignore_checked_limits_t limits = gic_limits(); + limits.max_depth = depth; + cbm_ignore_checked_t *owner = NULL; + const cbm_ignore_program_t *program = NULL; + const char *rules = earlier ? "a\n!*a" : "*a"; + bool ok = gic_open(&owner, &limits) && + gic_parse(owner, rules, strlen(rules), earlier ? 2 : 1, &program); + if (ok && depth == 1) + ok = gic_match_limit(owner, program, "a", CBM_IGNORE_CAP_DEPTH); + else if (ok) + ok = gic_match(owner, program, "a", false, + earlier ? CBM_IGNORE_REINCLUDED : CBM_IGNORE_IGNORED); + cbm_ignore_checked_free(owner); + return ok; +} + +static bool gic_attempt_case(bool earlier) { + char pattern[96], path[64]; + size_t n = 0; + if (earlier) { + memcpy(pattern, "*\n!", 3); + n = 3; + } + for (size_t i = 0; i < 20; i++) { + memcpy(pattern + n, "a**", 3); + n += 3; + } + pattern[n++] = 'X'; + pattern[n] = 0; + memset(path, 'a', sizeof(path) - 1); + path[sizeof(path) - 1] = 0; + cbm_gitignore_t *legacy = cbm_gitignore_parse(pattern); + if (!legacy) + return false; + int old = cbm_gitignore_match_result(legacy, path, false); + cbm_gitignore_free(legacy); + if (old != (earlier ? 1 : 0)) + return false; + cbm_ignore_checked_t *owner = NULL; + const cbm_ignore_program_t *program = NULL; + cbm_ignore_checked_limits_t limits = gic_limits(); + bool ok = gic_open(&owner, &limits) && + gic_parse(owner, pattern, n, earlier ? 2 : 1, &program) && + gic_match_limit(owner, program, path, CBM_IGNORE_CAP_ATTEMPT); + cbm_ignore_checked_free(owner); + return ok; +} + +static bool gic_max_depth_boundary(void) { + char pattern[2 * CBM_IGNORE_CHECKED_DEPTH_MAX + 1]; + char path[CBM_IGNORE_CHECKED_DEPTH_MAX]; + size_t groups = CBM_IGNORE_CHECKED_DEPTH_MAX - 1; + for (size_t i = 0; i < groups; i++) { + pattern[2 * i] = '*'; + pattern[2 * i + 1] = 'a'; + path[i] = 'a'; + } + pattern[2 * groups] = 0; + path[groups] = 0; + cbm_ignore_checked_limits_t limits = gic_limits(); + cbm_ignore_checked_t *owner = NULL; + const cbm_ignore_program_t *program = NULL; + bool ok = gic_open(&owner, &limits) && gic_parse(owner, pattern, 2 * groups, 1, &program) && + gic_match(owner, program, path, false, CBM_IGNORE_IGNORED); + cbm_ignore_checked_free(owner); + owner = NULL; + if (!ok) + return false; + limits.max_depth--; + ok = gic_open(&owner, &limits) && gic_parse(owner, pattern, 2 * groups, 1, &program) && + gic_match_limit(owner, program, path, CBM_IGNORE_CAP_DEPTH); + cbm_ignore_checked_free(owner); + return ok; +} + +TEST(gic_e_depth_attempt_and_no_partial_decision) { + const gic_semantic ordinary = {"a**Z", "a/x/Z", false, 1, CBM_IGNORE_IGNORED}; + ASSERT_TRUE(gic_semantic_one(&ordinary)); + ASSERT_TRUE(gic_max_depth_boundary()); + ASSERT_TRUE(gic_depth_case(2, false)); + ASSERT_TRUE(gic_depth_case(2, true)); + ASSERT_TRUE(gic_depth_case(1, false)); + ASSERT_TRUE(gic_depth_case(1, true)); + ASSERT_TRUE(gic_attempt_case(false)); + ASSERT_TRUE(gic_attempt_case(true)); + PASS(); +} + +enum { GIC_LONG = 8193 }; +typedef struct { + char *patterns, *path; + size_t pattern_length; + cbm_ignore_decision_t expected; +} gic_long_input; + +static bool gic_long_make(gic_fixture *f, unsigned kind, gic_long_input *out) { + out->patterns = cbm_arena_alloc(&f->input, GIC_LONG + 3); + out->path = cbm_arena_alloc(&f->input, GIC_LONG + 1); + if (!out->patterns || !out->path) + return false; + memset(out->patterns, 'a', GIC_LONG + 3); + memset(out->path, 'a', GIC_LONG); + out->path[GIC_LONG] = 0; + out->pattern_length = GIC_LONG; + out->expected = CBM_IGNORE_IGNORED; + if (kind == 1) { + out->patterns[0] = '['; + out->patterns[GIC_LONG + 1] = ']'; + out->pattern_length = GIC_LONG + 2; + out->path[1] = 0; + } + if (kind == 2) { + out->patterns[0] = 'x'; + out->pattern_length = 1; + for (size_t i = 1; i < GIC_LONG; i += 2) + out->path[i] = '/'; + out->expected = CBM_IGNORE_NO_OPINION; + } + out->patterns[out->pattern_length] = 0; + return true; +} + +typedef struct { + size_t polls; + uint64_t work; + bool cancelled; +} gic_observation; + +static bool gic_long_operation(cbm_ignore_checked_t *owner, const cbm_ignore_program_t *program, + const gic_long_input *input, bool parsing, + const cbm_ignore_checked_control_t *control, + const cbm_ignore_program_t **parsed, + cbm_ignore_checked_status_t *status) { + cbm_ignore_checked_error_t error; + if (parsing) { + cbm_ignore_checked_parse_result_t out = {.program = program, .pattern_count = 77}; + *status = cbm_ignore_checked_parse(owner, input->patterns, input->pattern_length, control, + &out, &error); + if (*status == CBM_IGNORE_CHECKED_OK) { + *parsed = out.program; + return error.status == *status && out.program && out.pattern_count == 1; + } + return error.status == *status && !out.program && out.pattern_count == 0; + } + cbm_ignore_decision_t decision = CBM_IGNORE_IGNORED; + *status = cbm_ignore_checked_match(owner, program, input->path, strlen(input->path), false, + control, &decision, &error); + return error.status == *status && + decision == + (*status == CBM_IGNORE_CHECKED_OK ? input->expected : CBM_IGNORE_UNAVAILABLE); +} + +static bool gic_long_run(const gic_long_input *input, bool parsing, size_t stop, + gic_observation *observation) { + cbm_ignore_checked_limits_t limits = gic_limits(); + cbm_ignore_checked_t *owner = NULL; + const cbm_ignore_program_t *program = NULL; + bool ok = gic_open(&owner, &limits); + if (ok) + ok = parsing ? gic_parse(owner, "keep", 4, 1, &program) + : gic_parse(owner, input->patterns, input->pattern_length, 1, &program); + cbm_ignore_checked_usage_t before = {0}, after = {0}; + ok = ok && cbm_ignore_checked_usage(owner, &before); + gic_counter counter = {.stop = stop}; + cbm_ignore_checked_control_t control = gic_control(); + control.cancelled = gic_cancel; + control.context = &counter; + cbm_ignore_checked_status_t status = CBM_IGNORE_CHECKED_INVALID; + const cbm_ignore_program_t *parsed = NULL; + if (ok) + ok = gic_long_operation(owner, program, input, parsing, &control, &parsed, &status); + ok = ok && cbm_ignore_checked_usage(owner, &after); + if (ok && status == CBM_IGNORE_CHECKED_OK) + ok = (!stop || counter.calls < stop) && after.terminal_status == CBM_IGNORE_CHECKED_OK; + else if (ok) + ok = status == CBM_IGNORE_CHECKED_CANCELLED && stop && counter.calls >= stop && + gic_terminal(owner, program, CBM_IGNORE_CHECKED_CANCELLED); + observation->polls = counter.calls; + observation->work = after.work_used - before.work_used; + observation->cancelled = status == CBM_IGNORE_CHECKED_CANCELLED; + /* Necessary aggregate bound only. This does not observe individual gaps; + * the callback never reenters usage or any other owner operation. */ + if (ok) + ok = counter.calls > 0 && counter.calls <= UINT64_MAX / 4096 && + observation->work <= (uint64_t)counter.calls * 4096; + if (ok && parsing && status == CBM_IGNORE_CHECKED_OK) + ok = gic_match(owner, parsed, input->path, false, input->expected); + size_t polls_before_free = counter.calls; + cbm_ignore_checked_free(owner); + return ok && counter.calls == polls_before_free; +} + +static bool gic_cancel_sweep(const gic_long_input *input, bool parsing) { + gic_observation normal = {0}; + if (!gic_long_run(input, parsing, 0, &normal) || normal.cancelled || normal.work <= 4096 || + !normal.polls || normal.polls >= SIZE_MAX - 1) + return false; + const size_t stops[] = {1, normal.polls / 2 + 1, normal.polls, normal.polls + 1}; + bool cancelled = false; + for (size_t i = 0; i < 4; i++) { + gic_observation sample = {0}; + if (!gic_long_run(input, parsing, stops[i], &sample)) + return false; + cancelled = cancelled || sample.cancelled; + } + return cancelled; +} + +static bool gic_expired(cbm_ignore_checked_t *owner, const cbm_ignore_program_t *program, + bool parsing) { + cbm_ignore_checked_control_t control = gic_control(); + control.deadline_ms = 1; + cbm_ignore_checked_error_t error; + cbm_ignore_checked_status_t status; + if (cbm_now_ms() <= 1) + return false; + if (parsing) { + cbm_ignore_checked_parse_result_t out = {.program = program, .pattern_count = 77}; + status = cbm_ignore_checked_parse(owner, "x", 1, &control, &out, &error); + if (out.program || out.pattern_count) + return false; + } else { + cbm_ignore_decision_t decision = CBM_IGNORE_IGNORED; + status = + cbm_ignore_checked_match(owner, program, "x", 1, false, &control, &decision, &error); + if (decision != CBM_IGNORE_UNAVAILABLE) + return false; + } + return gic_status(status, CBM_IGNORE_CHECKED_DEADLINE, &error) && + gic_terminal(owner, program, CBM_IGNORE_CHECKED_DEADLINE); +} + +TEST(gic_f_cancellation_deadline_and_poll_evidence) { + gic_fixture f; + gic_init(&f); + int result = 1; + gic_long_input input; + GIC_CHECK(gic_long_make(&f, 0, &input)); + GIC_CHECK(gic_cancel_sweep(&input, true)); + GIC_CHECK(gic_cancel_sweep(&input, false)); + for (unsigned kind = 1; kind < 3; kind++) { + gic_observation sample = {0}; + GIC_CHECK(gic_long_make(&f, kind, &input)); + GIC_CHECK(gic_long_run(&input, false, 0, &sample)); + GIC_CHECK(!sample.cancelled && sample.work > 4096); + } + cbm_ignore_checked_limits_t limits = gic_limits(); + const cbm_ignore_program_t *program[3] = {NULL, NULL, NULL}; + for (size_t i = 0; i < 3; i++) { + GIC_CHECK(gic_open(&f.owner[i], &limits)); + GIC_CHECK(gic_parse(f.owner[i], "x", 1, 1, &program[i])); + GIC_CHECK(gic_match(f.owner[i], program[i], "x", false, CBM_IGNORE_IGNORED)); + } + GIC_CHECK(gic_expired(f.owner[0], program[0], true)); + GIC_CHECK(gic_expired(f.owner[1], program[1], false)); + GIC_CHECK(gic_match(f.owner[2], program[2], "x", false, CBM_IGNORE_IGNORED)); + result = 0; +done: + return gic_finish(&f, result); +} + +static bool gic_owner_oom(size_t owner_request) { + cbm_ignore_checked_limits_t limits = gic_limits(); + limits.max_arena_bytes = owner_request; + cbm_ignore_checked_t *owner = NULL; + cbm_ignore_checked_error_t error; + cbm_ignore_checked_status_t status = + cbm_ignore_checked_open_oom_for_tests(&limits, &owner, &error); + bool ok = gic_status(status, CBM_IGNORE_CHECKED_OOM, &error) && !owner; + cbm_ignore_checked_free(owner); + if (!ok || !owner_request) + return false; + limits.max_arena_bytes--; + status = cbm_ignore_checked_open_oom_for_tests(&limits, &owner, &error); + cbm_ignore_checked_status_t wanted = + owner_request == 1 ? CBM_IGNORE_CHECKED_INVALID : CBM_IGNORE_CHECKED_LIMIT; + ok = gic_status(status, wanted, &error) && + (wanted != CBM_IGNORE_CHECKED_LIMIT || error.cap == CBM_IGNORE_CAP_ARENA) && !owner; + cbm_ignore_checked_free(owner); + return ok; +} + +static bool gic_parse_oom(cbm_ignore_parse_fault_t fault, cbm_ignore_checked_usage_t *before, + cbm_ignore_checked_usage_t *usage) { + cbm_ignore_checked_t *owner = NULL; + cbm_ignore_checked_limits_t limits = gic_limits(); + if (!gic_open(&owner, &limits)) + return false; + const cbm_ignore_program_t *old_program = NULL; + if (!gic_parse(owner, "keep", 4, 1, &old_program) || !cbm_ignore_checked_usage(owner, before)) { + cbm_ignore_checked_free(owner); + return false; + } + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_checked_error_t error; + cbm_ignore_checked_parse_result_t out = {.program = old_program, .pattern_count = 77}; + const char input[] = "one\ntwo\n"; + cbm_ignore_checked_status_t status = cbm_ignore_checked_parse_fault_for_tests( + owner, input, sizeof(input) - 1, &control, fault, &out, &error); + bool ok = gic_status(status, CBM_IGNORE_CHECKED_OOM, &error) && !out.program && + out.pattern_count == 0 && cbm_ignore_checked_usage(owner, usage) && + usage->bytes_reserved == before->bytes_reserved + sizeof(input) - 1 && + usage->patterns_reserved == before->patterns_reserved + 2 && + usage->arena_requested_bytes > before->arena_requested_bytes && + usage->terminal_status == CBM_IGNORE_CHECKED_OOM && + gic_terminal(owner, old_program, CBM_IGNORE_CHECKED_OOM); + cbm_ignore_checked_free(owner); + return ok; +} + +static bool gic_unreached_and_invalid_faults(cbm_ignore_checked_t *owner, + const cbm_ignore_program_t *program) { + cbm_ignore_checked_control_t control = gic_control(); + gic_counter counter = {0}; + control.cancelled = gic_cancel; + control.context = &counter; + cbm_ignore_checked_usage_t before, after; + if (!cbm_ignore_checked_usage(owner, &before)) + return false; + cbm_ignore_checked_parse_result_t out = {.program = program, .pattern_count = 77}; + cbm_ignore_checked_error_t error; + cbm_ignore_checked_status_t status = cbm_ignore_checked_parse_fault_for_tests( + owner, "x", 1, &control, (cbm_ignore_parse_fault_t)999, &out, &error); + if (!gic_status(status, CBM_IGNORE_CHECKED_INVALID, &error) || out.program || + out.pattern_count || counter.calls || !cbm_ignore_checked_usage(owner, &after) || + !gic_usage_equal(&before, &after)) + return false; + status = cbm_ignore_checked_parse_fault_for_tests(owner, NULL, 0, &control, + CBM_IGNORE_PARSE_FAULT_PATTERN, &out, &error); + if (!gic_status(status, CBM_IGNORE_CHECKED_OK, &error) || !out.program || out.pattern_count || + !gic_match(owner, out.program, "x", false, CBM_IGNORE_NO_OPINION)) + return false; + status = cbm_ignore_checked_parse_fault_for_tests(owner, "z", 1, &control, + CBM_IGNORE_PARSE_FAULT_NONE, &out, &error); + return gic_status(status, CBM_IGNORE_CHECKED_OK, &error) && out.program && + out.pattern_count == 1 && + gic_match(owner, out.program, "z", false, CBM_IGNORE_IGNORED) && + gic_match(owner, program, "one", false, CBM_IGNORE_IGNORED); +} + +static bool gic_oom_quota_first(cbm_ignore_parse_fault_t fault, size_t owner_request) { + cbm_ignore_checked_limits_t limits = gic_limits(); + limits.max_arena_bytes = owner_request; + cbm_ignore_checked_t *owner = NULL; + if (!gic_open(&owner, &limits)) + return false; + cbm_ignore_checked_control_t control = gic_control(); + cbm_ignore_checked_error_t error; + cbm_ignore_checked_parse_result_t out = {.pattern_count = 77}; + cbm_ignore_checked_status_t status = + cbm_ignore_checked_parse_fault_for_tests(owner, "x", 1, &control, fault, &out, &error); + cbm_ignore_checked_usage_t usage; + bool ok = gic_status(status, CBM_IGNORE_CHECKED_LIMIT, &error) && + error.cap == CBM_IGNORE_CAP_ARENA && !out.program && out.pattern_count == 0 && + cbm_ignore_checked_usage(owner, &usage) && + usage.arena_requested_bytes == owner_request && + gic_terminal(owner, NULL, CBM_IGNORE_CHECKED_LIMIT); + cbm_ignore_checked_free(owner); + return ok; +} + +TEST(gic_g_call_local_simulated_oom) { + gic_fixture f; + gic_init(&f); + int result = 1; + cbm_ignore_checked_limits_t limits = gic_limits(); + cbm_ignore_checked_usage_t opened, normal, program_failure, pattern_failure; + cbm_ignore_checked_usage_t program_before, pattern_before; + const cbm_ignore_program_t *program = NULL; + GIC_CHECK(gic_open(&f.owner[0], &limits)); + GIC_CHECK(cbm_ignore_checked_usage(f.owner[0], &opened)); + GIC_CHECK(gic_parse(f.owner[0], "keep", 4, 1, &program)); + GIC_CHECK(gic_parse(f.owner[0], "one\ntwo\n", 8, 2, &program)); + GIC_CHECK(gic_match(f.owner[0], program, "one", false, CBM_IGNORE_IGNORED)); + GIC_CHECK(cbm_ignore_checked_usage(f.owner[0], &normal)); + GIC_CHECK(gic_owner_oom(opened.arena_requested_bytes)); + GIC_CHECK(gic_parse_oom(CBM_IGNORE_PARSE_FAULT_PROGRAM, &program_before, &program_failure)); + GIC_CHECK(gic_parse_oom(CBM_IGNORE_PARSE_FAULT_PATTERN, &pattern_before, &pattern_failure)); + GIC_CHECK(gic_usage_equal(&program_before, &pattern_before)); + GIC_CHECK(program_failure.arena_requested_bytes > program_before.arena_requested_bytes); + GIC_CHECK(pattern_failure.arena_requested_bytes > program_failure.arena_requested_bytes); + GIC_CHECK(pattern_failure.arena_requested_bytes <= normal.arena_requested_bytes); + GIC_CHECK(program_failure.work_used > opened.work_used && pattern_failure.work_used > 0); + GIC_CHECK(gic_oom_quota_first(CBM_IGNORE_PARSE_FAULT_PROGRAM, opened.arena_requested_bytes)); + GIC_CHECK(gic_oom_quota_first(CBM_IGNORE_PARSE_FAULT_PATTERN, opened.arena_requested_bytes)); + GIC_CHECK(gic_unreached_and_invalid_faults(f.owner[0], program)); + /* Failed calls in other owners cannot poison this retained real program. */ + GIC_CHECK(gic_match(f.owner[0], program, "two", false, CBM_IGNORE_IGNORED)); + result = 0; +done: + return gic_finish(&f, result); +} + +SUITE(gitignore_checked) { + RUN_TEST(gic_a_semantics_and_legacy_parity); + RUN_TEST(gic_b_exact_spans_and_input_lifetime); + RUN_TEST(gic_c_ownership_preflight_and_terminal); + RUN_TEST(gic_d_aggregate_quota_boundaries); + RUN_TEST(gic_e_depth_attempt_and_no_partial_decision); + RUN_TEST(gic_f_cancellation_deadline_and_poll_evidence); + RUN_TEST(gic_g_call_local_simulated_oom); +} diff --git a/tests/test_harness_test_only.sh b/tests/test_harness_test_only.sh index 04318308b..9c6ed6635 100644 --- a/tests/test_harness_test_only.sh +++ b/tests/test_harness_test_only.sh @@ -1,7 +1,8 @@ #!/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 +# The test-impact selector hands the runner bare suites or `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 @@ -9,9 +10,9 @@ # 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. +# Every case drives the built runner over two small in-memory suites. 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)" @@ -236,12 +237,156 @@ expect "PASS lines" "$(pass_lines)" 0 finish begin "malformed token fails before any test" -CBM_TEST_ONLY="${NAMED_SUITE}" run malformed "${NAMED_SUITE}" +CBM_TEST_ONLY="${NAMED_SUITE}:" run malformed "${NAMED_SUITE}" expect_nonzero_exit -expect_line "malformed test selection token: ${NAMED_SUITE}" +expect_line "malformed test selection token: ${NAMED_SUITE}:" expect "PASS lines" "$(pass_lines)" 0 finish +# ── Whole suites use the same executable filter in environment and file form ── +begin "CBM_TEST_ONLY runs every test of a bare suite" +CBM_TEST_ONLY="${NAMED_SUITE}" run whole-env "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" "${NAMED_TOTAL}" +expect "deselected" "$(summary_count deselected)" 0 +expect "PASS lines" "$(pass_lines)" "${NAMED_TOTAL}" +expect_no_line "=== ${OTHER_SUITE} ===" +finish + +whole_selection="${tmpdir}/whole.txt" +printf '%s\r\n' "# whole suite" "" " ${NAMED_SUITE} # trailing comment" > "${whole_selection}" +begin "CBM_TEST_ONLY_FILE accepts a bare suite with whitespace comments and CRLF" +CBM_TEST_ONLY_FILE="${whole_selection}" run whole-file "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" "${NAMED_TOTAL}" +expect "deselected" "$(summary_count deselected)" 0 +expect "PASS lines" "$(pass_lines)" "${NAMED_TOTAL}" +expect_no_line "=== ${OTHER_SUITE} ===" +finish + +begin "whole suite and individual tokens compose in the environment" +CBM_TEST_ONLY="${NAMED_SUITE},${OTHER_SUITE}:${OTHER_TEST}" \ + run whole-mixed-env "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" "$((NAMED_TOTAL + 1))" +expect "deselected" "$(summary_count deselected)" "$((OTHER_TOTAL - 1))" +expect "PASS lines" "$(pass_lines)" "$((NAMED_TOTAL + 1))" +expect_line " ${OTHER_TEST} " +finish + +printf '%s\n' "${NAMED_SUITE}" "${OTHER_SUITE}:${OTHER_TEST}" > "${tmpdir}/mixed.txt" +begin "whole suite and individual tokens compose in a file" +CBM_TEST_ONLY_FILE="${tmpdir}/mixed.txt" run whole-mixed-file "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" "$((NAMED_TOTAL + 1))" +expect "deselected" "$(summary_count deselected)" "$((OTHER_TOTAL - 1))" +expect "PASS lines" "$(pass_lines)" "$((NAMED_TOTAL + 1))" +expect_line " ${OTHER_TEST} " +finish + +printf '%s\n' "${NAMED_SUITE}" "${NAMED_SUITE}" "${NAMED_SUITE}:da_last" \ + "${OTHER_SUITE}:${OTHER_TEST}" "${OTHER_SUITE}:${OTHER_TEST}" > "${tmpdir}/duplicates.txt" +begin "duplicate whole and individual tokens never duplicate execution" +CBM_TEST_ONLY="${NAMED_SUITE}:da_last,${NAMED_SUITE},${OTHER_SUITE}:${OTHER_TEST}" \ + CBM_TEST_ONLY_FILE="${tmpdir}/duplicates.txt" \ + run whole-duplicates "${NAMED_SUITE}" "${OTHER_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" "$((NAMED_TOTAL + 1))" +expect "deselected" "$(summary_count deselected)" "$((OTHER_TOTAL - 1))" +expect "PASS lines" "$(pass_lines)" "$((NAMED_TOTAL + 1))" +finish + +begin "unknown bare suite is named as unknown rather than malformed" +CBM_TEST_ONLY="no_such_whole_suite" run whole-unknown "${NAMED_SUITE}" +expect_nonzero_exit +expect_line "${UNKNOWN_MESSAGE} no_such_whole_suite" +expect_no_line "malformed test selection token:" +expect "PASS lines" "$(pass_lines)" 0 +finish + +printf '%s\n' "no_such_whole_suite" > "${tmpdir}/unknown-whole.txt" +begin "unknown bare suite in a file remains an error" +CBM_TEST_ONLY_FILE="${tmpdir}/unknown-whole.txt" run whole-unknown-file "${NAMED_SUITE}" +expect_nonzero_exit +expect_line "${UNKNOWN_MESSAGE} no_such_whole_suite" +expect_no_line "malformed test selection token:" +expect "PASS lines" "$(pass_lines)" 0 +finish + +begin "whole suite never hides a separately unknown individual" +CBM_TEST_ONLY="${NAMED_SUITE},${NAMED_SUITE}:no_such_test" \ + run whole-unknown-individual "${NAMED_SUITE}" +expect_nonzero_exit +expect_line "${UNKNOWN_MESSAGE} ${NAMED_SUITE}:no_such_test" +expect "passed" "$(summary_count passed)" "${NAMED_TOTAL}" +expect "deselected" "$(summary_count deselected)" 0 +expect "PASS lines" "$(pass_lines)" "${NAMED_TOTAL}" +finish + +printf '%s\n' "${NAMED_SUITE}:no_such_test" > "${tmpdir}/unknown-individual.txt" +begin "whole suite never hides an unknown individual from the other input" +CBM_TEST_ONLY="${NAMED_SUITE}" CBM_TEST_ONLY_FILE="${tmpdir}/unknown-individual.txt" \ + run whole-unknown-union "${NAMED_SUITE}" +expect_nonzero_exit +expect_line "${UNKNOWN_MESSAGE} ${NAMED_SUITE}:no_such_test" +expect "passed" "$(summary_count passed)" "${NAMED_TOTAL}" +expect "PASS lines" "$(pass_lines)" "${NAMED_TOTAL}" +finish + +begin "whole suite outside argv belongs to another runner process" +CBM_TEST_ONLY="${OTHER_SUITE},${NAMED_SUITE}:da_last" run whole-argv "${NAMED_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_no_line "=== ${OTHER_SUITE} ===" +finish + +begin "individual outside argv does not interfere with a selected whole suite" +CBM_TEST_ONLY="${NAMED_SUITE},${OTHER_SUITE}:${OTHER_TEST}" run whole-argv-inverse "${NAMED_SUITE}" +expect "exit status" "${RC}" 0 +expect "passed" "$(summary_count passed)" "${NAMED_TOTAL}" +expect "deselected" "$(summary_count deselected)" 0 +expect "PASS lines" "$(pass_lines)" "${NAMED_TOTAL}" +expect_no_line "=== ${OTHER_SUITE} ===" +finish + +# ── The wire limit counts token bytes, including the individual-token colon. +# These names intentionally do not exist: valid syntax must reach unknown-token +# validation, while an over-width token must fail before executing any test. +for token_kind in suite individual; do + for width in 511 512; do + token_prefix="" + if [[ "${token_kind}" == individual ]]; then + token_prefix="${NAMED_SUITE}:" + fi + printf -v token_suffix '%*s' "$((width - ${#token_prefix}))" '' + token_suffix="${token_suffix// /q}" + width_token="${token_prefix}${token_suffix}" + printf '%s\n' "${width_token}" > "${tmpdir}/width.txt" + for input_kind in env file; do + begin "${width}-byte ${token_kind} token through ${input_kind}" + if [[ "${input_kind}" == env ]]; then + CBM_TEST_ONLY="${width_token}" \ + run "width-${token_kind}-${width}-${input_kind}" "${NAMED_SUITE}" + else + CBM_TEST_ONLY_FILE="${tmpdir}/width.txt" \ + run "width-${token_kind}-${width}-${input_kind}" "${NAMED_SUITE}" + fi + expect_nonzero_exit + expect "PASS lines" "$(pass_lines)" 0 + if [[ "${width}" -eq 511 ]]; then + expect_line "${UNKNOWN_MESSAGE} ${width_token}" + expect_no_line "malformed test selection token:" + else + expect_line "malformed test selection token:" + expect_no_line "${UNKNOWN_MESSAGE}" + fi + finish + done + done +done + if [[ "${FAILURES}" -gt 0 ]]; then echo "FAIL: ${FAILURES} per-test selection case(s) failed" exit 1 diff --git a/tests/test_inventory_filter.c b/tests/test_inventory_filter.c new file mode 100644 index 000000000..416b95229 --- /dev/null +++ b/tests/test_inventory_filter.c @@ -0,0 +1,58 @@ +#include "test_inventory_filter_internal.h" +TEST(inventory_a_native_head_verified_controls) { + ASSERT_EQ(if_case_native(), 0); + PASS(); +} +TEST(inventory_b_literal_policy_and_legacy_controls) { + ASSERT_EQ(if_case_policy(), 0); + PASS(); +} +TEST(inventory_c_complete_control_ledger) { + ASSERT_EQ(if_case_controls(), 0); + PASS(); +} +TEST(inventory_c_structural_and_provider_contract) { + ASSERT_EQ(if_case_structure(), 0); + PASS(); +} +TEST(inventory_c_namespace_bounds) { + ASSERT_EQ(if_case_namespace(), 0); + PASS(); +} +TEST(inventory_c_required_arguments) { + ASSERT_EQ(if_case_required_arguments(), 0); + PASS(); +} +TEST(inventory_d_cumulative_limits) { + ASSERT_EQ(if_case_limits(), 0); + PASS(); +} +TEST(inventory_e_typed_provider_failures) { + ASSERT_EQ(if_case_provider(), 0); + PASS(); +} +TEST(inventory_e_cancel_deadline) { + ASSERT_EQ(if_case_cancel(), 0); + PASS(); +} +TEST(inventory_e_native_read_eof_close) { + ASSERT_EQ(if_case_native_faults(), 0); + PASS(); +} +TEST(inventory_f_owned_lifetime_and_getters) { + ASSERT_EQ(if_case_ownership(), 0); + PASS(); +} +SUITE(inventory_filter) { + RUN_TEST(inventory_a_native_head_verified_controls); + RUN_TEST(inventory_b_literal_policy_and_legacy_controls); + RUN_TEST(inventory_c_complete_control_ledger); + RUN_TEST(inventory_c_structural_and_provider_contract); + RUN_TEST(inventory_c_namespace_bounds); + RUN_TEST(inventory_c_required_arguments); + RUN_TEST(inventory_d_cumulative_limits); + RUN_TEST(inventory_e_typed_provider_failures); + RUN_TEST(inventory_e_cancel_deadline); + RUN_TEST(inventory_e_native_read_eof_close); + RUN_TEST(inventory_f_owned_lifetime_and_getters); +} diff --git a/tests/test_inventory_filter_cases.c b/tests/test_inventory_filter_cases.c new file mode 100644 index 000000000..8bac46ab8 --- /dev/null +++ b/tests/test_inventory_filter_cases.c @@ -0,0 +1,245 @@ +#include "test_inventory_filter_internal.h" +#define NEED CBM_INVENTORY_FILTER_NEEDS_LANGUAGE +#define IGN CBM_INVENTORY_FILTER_IGNORED_FILE +#define SUB CBM_INVENTORY_FILTER_EXCLUDED_SUBTREE +#define NONE CBM_INVENTORY_FILTER_REASON_NONE +#define BUILTIN CBM_INVENTORY_FILTER_REASON_DIRECTORY_BUILTIN +#define SUFFIX CBM_INVENTORY_FILTER_REASON_DIRECTORY_SUFFIX +#define GIT CBM_INVENTORY_FILTER_REASON_GITIGNORE +#define CBM CBM_INVENTORY_FILTER_REASON_CBMIGNORE +#define EXT CBM_INVENTORY_FILTER_REASON_IGNORED_SUFFIX +#define GR CBM_INVENTORY_FILTER_ROLE_GITIGNORE +#define CR CBM_INVENTORY_FILTER_ROLE_CBMIGNORE +#define GK CBM_INVENTORY_CONTROL_GITIGNORE +#define CK CBM_INVENTORY_CONTROL_CBMIGNORE +#define ABS CBM_INVENTORY_CONTROL_ABSENT +#define EMPTY CBM_INVENTORY_CONTROL_APPLIED_EMPTY +#define APPLIED CBM_INVENTORY_CONTROL_APPLIED + +int if_case_policy(void) { + if_fixture f; + if_init(&f); + int result = 1; + static const if_row expected[] = {{".cbmignore", NEED, NONE, CR, NULL}, + {".gitignore", NEED, NONE, GR, NULL}, + {"LICENSE", NEED, NONE, 0, NULL}, + {"a.png", IGN, EXT, 0, NULL}, + {"backend/storage/framework/views/deep/x.c", SUB, SUFFIX, 0, + "backend/storage/framework/views"}, + {"cb-only.c", IGN, CBM, 0, NULL}, + {"node_modules/.gitignore", SUB, BUILTIN, 0, "node_modules"}, + {"node_modules/keep.c", SUB, BUILTIN, 0, "node_modules"}, + {"package.json", NEED, NONE, 0, NULL}, + {"source.c", NEED, NONE, 0, NULL}, + {"sub/.gitignore", NEED, NONE, GR, NULL}, + {"sub/blocked/.gitignore", SUB, GIT, 0, "sub/blocked"}, + {"sub/blocked/child.c", SUB, GIT, 0, "sub/blocked"}, + {"sub/cb-only.c", IGN, GIT, 0, NULL}, + {"sub/keep.drop", NEED, NONE, 0, NULL}, + {"sub/no.drop", IGN, GIT, 0, NULL}, + {"thing.generated.c", NEED, NONE, 0, NULL}, + {"unknown.weird", NEED, NONE, 0, NULL}, + {"vendor/.gitignore", NEED, NONE, GR, NULL}, + {"vendor/file.c", NEED, NONE, 0, NULL}}; + static const if_control_row controls[] = {{GK, "", 1, APPLIED, 3}, + {GK, "backend", SIZE_MAX, ABS, 0}, + {GK, "backend/storage", SIZE_MAX, ABS, 0}, + {GK, "backend/storage/framework", SIZE_MAX, ABS, 0}, + {GK, "sub", 10, APPLIED, 4}, + {GK, "vendor", 18, EMPTY, 0}, + {CK, "", 0, APPLIED, 3}}; + IF_CHECK(if_dependency_control()); + for (size_t i = 0; i < sizeof(expected) / sizeof(expected[0]); i++) + IF_CHECK(if_text(&f, expected[i].path, "body")); + const char *cb = "!vendor/\n!node_modules/\ncb-only.c\n"; + const char *gi = "*.png\n*.drop\nsub/blocked/\n"; + const char *nested = "!keep.drop\nkeep.drop\n!keep.drop\ncb-only.c\n"; + /* Rebuild the same literal inventory with exact control bytes. */ + if_init(&f); + for (size_t i = 0; i < sizeof(expected) / sizeof(expected[0]); i++) { + const char *bytes = i == 0 ? cb : i == 1 ? gi : i == 10 ? nested : i == 18 ? "" : "body"; + IF_CHECK(if_text(&f, expected[i].path, bytes)); + } + f.bytes[6][1] = 0; + f.bytes[11][1] = 0; /* Excluded controls are deliberately invalid. */ + IF_CHECK(if_prepare(&f)); + IF_CHECK(if_rows(&f, expected, sizeof(expected) / sizeof(expected[0])) && + if_controls(&f, controls, 7)); + const size_t reads[] = {0, 1, 10, 18}; + IF_CHECK(if_reads(&f, reads, 4)); + const cbm_inventory_filter_view_t *v = cbm_inventory_filter_view(f.owner); + IF_CHECK(v->directory_count == 10 && v->excluded_count == 3); + IF_CHECK( + !strcmp((const char *)v->excluded_directories[0].data, "backend/storage/framework/views")); + IF_CHECK(!strcmp((const char *)v->excluded_directories[1].data, "node_modules")); + IF_CHECK(!strcmp((const char *)v->excluded_directories[2].data, "sub/blocked")); + result = 0; +done: + return if_finish(&f, result); +} + +int if_case_controls(void) { + if_fixture f; + if_init(&f); + int result = 1; + static const if_control_row absent[] = {{GK, "", SIZE_MAX, ABS, 0}, {CK, "", SIZE_MAX, ABS, 0}}; + IF_CHECK(if_dependency_control() && if_prepare(&f)); + const cbm_inventory_filter_view_t *v = cbm_inventory_filter_view(f.owner); + IF_CHECK(v->file_count == 0 && v->directory_count == 1 && v->excluded_count == 0 && + f.calls == 0 && if_controls(&f, absent, 2)); + if_finish(&f, 0); + if_init(&f); + IF_CHECK(if_text(&f, ".cbmignore", "") && if_text(&f, ".codebase-memory.json", "not JSON") && + if_text(&f, ".gitignore", "# comment\n \t\n") && + if_add(&f, "sub/.cbmignore", "x\0y", 3) && if_text(&f, "sub/.gitignore", "drop.c\n") && + if_text(&f, "sub/drop.c", "body") && if_text(&f, "sub/keep.c", "body")); + static const if_row rows[] = {{".cbmignore", NEED, NONE, CR, NULL}, + {".codebase-memory.json", NEED, NONE, + CBM_INVENTORY_FILTER_ROLE_PHYSICAL_PROJECT_CONFIG, NULL}, + {".gitignore", NEED, NONE, GR, NULL}, + {"sub/.cbmignore", NEED, NONE, 0, NULL}, + {"sub/.gitignore", NEED, NONE, GR, NULL}, + {"sub/drop.c", IGN, GIT, 0, NULL}, + {"sub/keep.c", NEED, NONE, 0, NULL}}; + static const if_control_row controls[] = { + {GK, "", 2, EMPTY, 0}, {GK, "sub", 4, APPLIED, 1}, {CK, "", 0, EMPTY, 0}}; + IF_CHECK(if_prepare(&f) && if_rows(&f, rows, 7) && if_controls(&f, controls, 3)); + const size_t reads[] = {0, 2, 4}; + IF_CHECK(if_reads(&f, reads, 3)); + v = cbm_inventory_filter_view(f.owner); + IF_CHECK(v->directory_count == 2 && v->excluded_count == 0); + result = 0; +done: + return if_finish(&f, result); +} + +static bool if_invalid_case(unsigned which) { + if_fixture f; + if_init(&f); + bool ok = false; + if (!if_text(&f, ".gitignore", "x\n") || !if_text(&f, "a", "body")) + return false; + cbm_inventory_status_t status = CBM_INVENTORY_INVALID; + switch (which) { + case 0: + f.files[1].path.length = 0; + break; + case 1: + strcpy(f.paths[1], "../a"); + f.files[1].path.length = 4; + break; + case 2: + strcpy(f.paths[1], "a//b"); + f.files[1].path.length = 4; + break; + case 3: + f.paths[1][0] = 0; + break; + case 4: + f.paths[1][1] = 'x'; + break; + case 5: + f.files[1].oid[0] = 'A'; + break; + case 6: + f.files[1].oid[39] = 0; + break; + case 7: + memset(f.files[1].oid, 'a', 64); + f.files[1].oid[64] = 0; + break; + case 8: + f.files[1].git_mode = 0120000; + status = CBM_INVENTORY_UNSUPPORTED; + break; + case 9: + f.files[1].content_length = UINT64_MAX; + status = CBM_INVENTORY_LIMIT; + break; + case 10: + f.files[1].path.length = SIZE_MAX; + status = CBM_INVENTORY_LIMIT; + break; + case 11: + f.source.read = NULL; + break; + case 12: + f.control.deadline_ms = 0; + break; + case 13: + f.limits.max_probe_prefix_bytes = 0; + break; + case 14: + f.source.files = NULL; + break; + case 15: + f.source.file_count = SIZE_MAX; + status = CBM_INVENTORY_LIMIT; + break; + default: + return false; + } + ok = if_error(&f, status, which <= 10 ? 1 : SIZE_MAX, false) && f.calls == 0; + if_finish(&f, 0); + return ok; +} +static bool if_structural_tail(unsigned which) { + if_fixture f; + if_init(&f); + bool ok = false; + cbm_inventory_status_t expected = CBM_INVENTORY_INVALID; + switch (which) { + case 0: + if (!if_text(&f, "a", "x") || !if_text(&f, "a-b", "x") || !if_text(&f, "a/b", "x")) + return false; + break; + case 1: + if (!if_text(&f, "a", "x") || !if_text(&f, "a", "y")) + return false; + break; + case 2: + if (!if_text(&f, "z", "x") || !if_text(&f, "a", "y")) + return false; + break; + case 3: + if (!if_text(&f, ".gitignore/a", "x")) + return false; + expected = CBM_INVENTORY_UNSUPPORTED; + break; + case 4: + if (!if_add(&f, ".gitignore", "x\0y", 3)) + return false; + expected = CBM_INVENTORY_UNSUPPORTED; + break; + case 5: + if (!if_text(&f, ".gitignore", "x\n")) + return false; + f.files[0].content_sha256[0] ^= 1; + expected = CBM_INVENTORY_CHANGED; + break; + default: + return false; + } + /* A collision/order pair can implicate either existing input row; never an + * unknown or impossible index. An implied control directory has no file row. */ + ok = which < 3 ? if_error_known_row(&f, expected, false) + : if_error(&f, expected, which >= 4 ? 0 : SIZE_MAX, false); + ok = ok && f.calls == (which >= 4 ? 1U : 0U); + if_finish(&f, 0); + return ok; +} +int if_case_structure(void) { + if_fixture f; + if_init(&f); + int result = 1; + IF_CHECK(if_dependency_control() && if_text(&f, ".gitignore", "x\n") && + if_text(&f, "a", "body") && if_prepare(&f)); + if_finish(&f, 0); + for (unsigned i = 0; i < 16; i++) + IF_CHECK(if_invalid_case(i)); + for (unsigned i = 0; i < 6; i++) + IF_CHECK(if_structural_tail(i)); + result = 0; +done: + return if_finish(&f, result); +} diff --git a/tests/test_inventory_filter_errors.c b/tests/test_inventory_filter_errors.c new file mode 100644 index 000000000..0e3da59c7 --- /dev/null +++ b/tests/test_inventory_filter_errors.c @@ -0,0 +1,210 @@ +#include "test_inventory_filter_internal.h" + +int if_case_provider(void) { + if_fixture f; + if_init(&f); + int result = 1; + IF_CHECK(if_dependency_control() && if_text(&f, ".cbmignore", "x\n") && + if_text(&f, ".gitignore", "y\n") && if_prepare(&f)); + if_finish(&f, 0); + for (int status = CBM_INVENTORY_INVALID; status <= CBM_INVENTORY_DEADLINE; status++) { + for (unsigned cleanup = 0; cleanup < 2; cleanup++) { + if_init(&f); + IF_CHECK(if_text(&f, ".cbmignore", "x\n") && if_text(&f, ".gitignore", "y\n")); + f.fail_index = 0; + f.returned = f.reported = (cbm_inventory_status_t)status; + f.cleanup = cleanup != 0; + IF_CHECK(if_error(&f, (cbm_inventory_status_t)status, 0, f.cleanup) && f.calls == 1); + } + } + /* Each malformed success changes one documented provider obligation. Valid + * full bytes are written within capacity even when copied deliberately lies. */ + static const struct { + cbm_inventory_status_t returned, reported; + bool cleanup; + size_t copied; + } malformed[] = { + {CBM_INVENTORY_OK, CBM_INVENTORY_OK, false, 1}, /* isolated short copied */ + {CBM_INVENTORY_OK, CBM_INVENTORY_IO, false, 2}, /* isolated status mismatch */ + {CBM_INVENTORY_OK, CBM_INVENTORY_OK, true, 2}, /* isolated cleanup */ + {CBM_INVENTORY_OK, CBM_INVENTORY_OK, false, 3}, /* overreport, never overwrite */ + {CBM_INVENTORY_IO, CBM_INVENTORY_STATE, true, 0}, + {(cbm_inventory_status_t)99, (cbm_inventory_status_t)99, true, 0}, + {CBM_INVENTORY_IO, CBM_INVENTORY_IO, false, 1}, + }; + const size_t first[] = {0}; + for (size_t i = 0; i < sizeof(malformed) / sizeof(malformed[0]); i++) { + if_init(&f); + IF_CHECK(if_text(&f, ".cbmignore", "x\n") && if_text(&f, ".gitignore", "y\n")); + f.fail_index = 0; + f.returned = malformed[i].returned; + f.reported = malformed[i].reported; + f.cleanup = malformed[i].cleanup; + f.reply_bytes = true; + f.reply_copied = malformed[i].copied; + IF_CHECK(if_error(&f, CBM_INVENTORY_INVALID, 0, f.cleanup) && if_reads(&f, first, 1)); + } + result = 0; +done: + return if_finish(&f, result); +} + +typedef struct { + size_t observed, stop; +} if_cancel; +static bool if_cancelled(void *context) { + if_cancel *c = context; + c->observed++; + return c->observed >= c->stop; +} +static bool if_cancel_fixture(if_fixture *f) { + if_init(f); + return if_text(f, ".cbmignore", "one\ntwo\n") && if_text(f, ".gitignore", "*.c\n!keep.c\n") && + if_text(f, "keep.c", "body"); +} +int if_case_cancel(void) { + if_fixture f; + if_init(&f); + int result = 1; + if_cancel c = {0, SIZE_MAX}; + IF_CHECK(if_dependency_control() && if_cancel_fixture(&f)); + f.control.cancelled = if_cancelled; + f.control.context = &c; + IF_CHECK(if_prepare(&f) && c.observed > 2 && c.observed < 65536); + size_t total = c.observed; + if_finish(&f, 0); + const size_t stops[] = {1, total / 2, total}; + for (size_t i = 0; i < 3; i++) { + IF_CHECK(if_cancel_fixture(&f)); + c = (if_cancel){0, stops[i]}; + f.control.cancelled = if_cancelled; + f.control.context = &c; + if (i == 0) { + /* Live deadline: initial cancellation alone must prevent every read. */ + IF_CHECK(f.control.deadline_ms == UINT64_MAX); + IF_CHECK(if_error(&f, CBM_INVENTORY_CANCELLED, SIZE_MAX, false) && f.calls == 0); + } else { + /* Mid/last observed polls may occur with a current input row or globally. */ + IF_CHECK(if_error_during_work(&f, CBM_INVENTORY_CANCELLED, false) && f.calls <= 2); + } + IF_CHECK(c.observed >= stops[i]); + } + IF_CHECK(if_cancel_fixture(&f) && cbm_now_ms() > 1); + f.control.deadline_ms = 1; + IF_CHECK(if_error(&f, CBM_INVENTORY_DEADLINE, SIZE_MAX, false) && f.calls == 0); + IF_CHECK(if_cancel_fixture(&f)); + c = (if_cancel){0, 1}; + f.control.deadline_ms = 1; + f.control.cancelled = if_cancelled; + f.control.context = &c; + IF_CHECK(if_error(&f, CBM_INVENTORY_CANCELLED, SIZE_MAX, false) && f.calls == 0); + result = 0; +done: + return if_finish(&f, result); +} + +static bool if_same_limits(const cbm_inventory_limits_t *a, const cbm_inventory_limits_t *b) { + return a->max_files == b->max_files && a->max_directories == b->max_directories && + a->max_arena_bytes == b->max_arena_bytes && + a->max_ignore_arena_bytes == b->max_ignore_arena_bytes && + a->max_control_file_bytes == b->max_control_file_bytes && + a->max_control_total_bytes == b->max_control_total_bytes && + a->max_ignore_patterns == b->max_ignore_patterns && + a->max_probe_prefix_bytes == b->max_probe_prefix_bytes && + a->max_ignore_work == b->max_ignore_work && + a->max_verified_file_reads == b->max_verified_file_reads && + a->max_verified_content_bytes == b->max_verified_content_bytes; +} +static bool if_owned_metadata(const cbm_inventory_filter_view_t *v, const unsigned char sha[32]) { + if (v->file_count != 2 || strcmp((const char *)v->files[0].path.data, ".gitignore") || + strcmp((const char *)v->files[1].path.data, "keep.c") || v->files[0].git_mode != 0100644 || + v->files[0].content_length != 7 || memcmp(v->files[0].content_sha256, sha, 32)) + return false; + for (size_t i = 0; i < 40; i++) + if (v->files[0].oid[i] != 'a') + return false; + for (size_t i = 40; i < 65; i++) + if (v->files[0].oid[i] != 0) + return false; + for (size_t i = 0; i < 32; i++) + if (v->manifest_sha256[i] != 0x4a) + return false; +#ifdef _WIN32 + return !strcmp(v->native_root, "C:/inventory-fixture"); +#else + return !strcmp(v->native_root, "/inventory-fixture"); +#endif +} +static bool if_owned_getters(if_fixture *f, const cbm_inventory_limits_t *original) { + cbm_inventory_limits_t copied; + cbm_inventory_filter_usage_t usage; + const cbm_inventory_filter_view_t *v = cbm_inventory_filter_view(f->owner); + if (!cbm_inventory_filter_limits(f->owner, &copied) || !if_same_limits(original, &copied) || + !cbm_inventory_filter_usage(f->owner, &usage)) + return false; + copied.max_files = 1; + memset(&usage, 0, sizeof(usage)); + if (!cbm_inventory_filter_limits(f->owner, &copied) || !if_same_limits(original, &copied) || + !cbm_inventory_filter_usage(f->owner, &usage) || usage.verified_file_reads_reserved != 1) + return false; + return v->rows[0].roles == CBM_INVENTORY_FILTER_ROLE_GITIGNORE && + v->rows[1].disposition == CBM_INVENTORY_FILTER_NEEDS_LANGUAGE && + v->controls[0].directory.length == 0 && v->controls[0].file_index == 0 && + v->controls[0].effective_patterns == 1 && !cbm_inventory_filter_usage(f->owner, NULL) && + !cbm_inventory_filter_limits(f->owner, NULL); +} +static bool if_null_getters(void) { + cbm_inventory_limits_t limits; + cbm_inventory_filter_usage_t usage; + memset(&limits, 0xa5, sizeof(limits)); + memset(&usage, 0xa5, sizeof(usage)); + if (cbm_inventory_filter_view(NULL) || cbm_inventory_filter_limits(NULL, &limits) || + cbm_inventory_filter_usage(NULL, &usage)) + return false; + cbm_inventory_limits_t zero = {0}; + if (!if_same_limits(&limits, &zero)) + return false; + uint64_t values = usage.verified_file_reads_reserved | usage.verified_content_bytes_reserved | + usage.control_bytes_reserved | usage.ignore_bytes_reserved | + usage.ignore_work_used | usage.ignore_patterns_reserved | + usage.ignore_arena_requested_bytes | usage.non_ignore_arena_requested_bytes | + usage.arena_requested_bytes | usage.arena_budget_used_bytes; + return !values && !cbm_inventory_filter_usage(NULL, NULL) && + !cbm_inventory_filter_limits(NULL, NULL); +} +int if_case_ownership(void) { + if_fixture f; + if_init(&f); + int result = 1; + if_cancel c = {0, SIZE_MAX}; + IF_CHECK(if_dependency_control() && if_text(&f, ".gitignore", "drop.c\n") && + if_text(&f, "keep.c", "body")); + memset(f.files[0].oid + 41, 0xd5, 24); + cbm_inventory_limits_t original = f.limits; + unsigned char original_sha[32]; + memcpy(original_sha, f.files[0].content_sha256, 32); + f.control.cancelled = if_cancelled; + f.control.context = &c; + IF_CHECK(if_prepare(&f)); + size_t polls = c.observed, calls = f.calls; + const cbm_inventory_filter_view_t *v = cbm_inventory_filter_view(f.owner); + IF_CHECK(v && v->files != f.files && v->native_root != f.root && + v->files[0].path.data != (const unsigned char *)f.paths[0]); + memset(f.paths, 0xa5, sizeof(f.paths)); + memset(f.bytes, 0xa5, sizeof(f.bytes)); + memset(f.files, 0xa5, sizeof(f.files)); + memset(f.root, 0xa5, sizeof(f.root)); + memset(&f.source, 0xa5, sizeof(f.source)); + memset(&f.limits, 0xa5, sizeof(f.limits)); + memset(&f.control, 0xa5, sizeof(f.control)); + c.stop = 0; + IF_CHECK(if_owned_metadata(v, original_sha) && if_owned_getters(&f, &original)); + IF_CHECK(c.observed == polls && f.calls == calls); + cbm_inventory_filter_free(f.owner); + f.owner = NULL; + IF_CHECK(c.observed == polls && f.calls == calls && if_null_getters()); + cbm_inventory_filter_free(NULL); + result = 0; +done: + return if_finish(&f, result); +} diff --git a/tests/test_inventory_filter_internal.h b/tests/test_inventory_filter_internal.h new file mode 100644 index 000000000..4ffe4f97d --- /dev/null +++ b/tests/test_inventory_filter_internal.h @@ -0,0 +1,77 @@ +#ifndef TEST_INVENTORY_FILTER_INTERNAL_H +#define TEST_INVENTORY_FILTER_INTERNAL_H +#include "test_framework.h" +#include "test_helpers.h" +#include "discover/inventory_filter.h" +#include "discover/discover.h" +#include "foundation/sha256.h" +#include "mcp/test_impact_tree.h" +#include +#include +#define IF_CHECK(c) \ + do { \ + if (!(c)) { \ + fprintf(stderr, "inventory assertion %s:%d: %s\n", __FILE__, __LINE__, #c); \ + goto done; \ + } \ + } while (0) +enum { IF_ROWS = 40, IF_PATH = 128, IF_BYTES = 512, IF_CALLS = 80 }; +typedef struct { + char root[4096], paths[IF_ROWS][IF_PATH]; + unsigned char bytes[IF_ROWS][IF_BYTES]; + cbm_inventory_file_t files[IF_ROWS]; + cbm_inventory_source_t source; + cbm_inventory_limits_t limits; + cbm_inventory_control_t control; + cbm_inventory_filter_t *owner; + size_t calls, indices[IF_CALLS], capacities[IF_CALLS]; + size_t fail_index; + cbm_inventory_status_t returned, reported; + bool cleanup, reply_bytes, overflowed; + size_t reply_copied; + cbm_pinned_tree_t *tree; +} if_fixture; +typedef struct { + const char *path; + cbm_inventory_filter_disposition_t disposition; + cbm_inventory_filter_reason_t reason; + unsigned roles; + const char *ancestor; +} if_row; +typedef struct { + cbm_inventory_control_kind_t kind; + const char *directory; + size_t index; + cbm_inventory_control_outcome_t outcome; + size_t patterns; +} if_control_row; +void if_init(if_fixture *f); +bool if_add(if_fixture *f, const char *path, const void *bytes, size_t size); +bool if_text(if_fixture *f, const char *path, const char *bytes); +bool if_prepare(if_fixture *f); +bool if_error(if_fixture *f, cbm_inventory_status_t expected, size_t index, bool cleanup); +/* Exact helper above includes SIZE_MAX. These explicit variants never accept an out-of-range row. + */ +bool if_error_known_row(if_fixture *f, cbm_inventory_status_t expected, bool cleanup); +/* Only for operations whose internal allocation/match phase may have no current row. */ +bool if_error_during_work(if_fixture *f, cbm_inventory_status_t expected, bool cleanup); +int if_finish(if_fixture *f, int result); +bool if_rows(const if_fixture *f, const if_row *rows, size_t count); +bool if_controls(const if_fixture *f, const if_control_row *rows, size_t count); +bool if_reads(const if_fixture *f, const size_t *indices, size_t count); +void if_reset_reads(if_fixture *f); +bool if_dependency_control(void); +cbm_inventory_status_t if_read(void *, size_t, unsigned char *, size_t, size_t *, + const cbm_inventory_control_t *, cbm_inventory_error_t *); +int if_case_policy(void); +int if_case_controls(void); +int if_case_structure(void); +int if_case_namespace(void); +int if_case_required_arguments(void); +int if_case_provider(void); +int if_case_ownership(void); +int if_case_limits(void); +int if_case_cancel(void); +int if_case_native(void); +int if_case_native_faults(void); +#endif diff --git a/tests/test_inventory_filter_limits.c b/tests/test_inventory_filter_limits.c new file mode 100644 index 000000000..f9ca953bc --- /dev/null +++ b/tests/test_inventory_filter_limits.c @@ -0,0 +1,159 @@ +#include "test_inventory_filter_internal.h" +static bool if_limit_fixture(if_fixture *f) { + if_init(f); + return if_text(f, ".cbmignore", "one\n") && if_text(f, ".gitignore", "two\n") && + if_text(f, "a/z.c", "body"); +} +static bool if_usage_valid(if_fixture *f, cbm_inventory_filter_usage_t *u) { + if (!cbm_inventory_filter_usage(f->owner, u)) + return false; + /* Two 4-byte controls; matches for root files, a, a/z.c against both programs. */ + return u->verified_file_reads_reserved == 2 && u->verified_content_bytes_reserved == 8 && + u->control_bytes_reserved == 8 && u->ignore_bytes_reserved == 68 && + u->ignore_patterns_reserved == 2 && u->ignore_work_used > 0 && + u->ignore_work_used <= f->limits.max_ignore_work && + u->ignore_arena_requested_bytes > 0 && + u->ignore_arena_requested_bytes < f->limits.max_ignore_arena_bytes && + u->non_ignore_arena_requested_bytes > 0 && + u->arena_requested_bytes == + u->non_ignore_arena_requested_bytes + u->ignore_arena_requested_bytes && + u->arena_budget_used_bytes == + u->non_ignore_arena_requested_bytes + f->limits.max_ignore_arena_bytes && + u->arena_budget_used_bytes <= f->limits.max_arena_bytes; +} +static void if_cap(if_fixture *f, unsigned which, uint64_t value) { + switch (which) { + case 0: + f->limits.max_files = (size_t)value; + break; + case 1: + f->limits.max_directories = (size_t)value; + break; + case 2: + f->limits.max_control_file_bytes = (size_t)value; + break; + case 3: + f->limits.max_control_total_bytes = value; + break; + case 4: + f->limits.max_verified_file_reads = value; + break; + case 5: + f->limits.max_verified_content_bytes = value; + break; + case 6: + f->limits.max_ignore_patterns = (size_t)value; + break; + default: + break; + } +} +static bool if_fixed_cap_round(unsigned which, uint64_t exact) { + if_fixture f; + if_init(&f); + bool ok = false; + if (!if_limit_fixture(&f)) + return false; + if_cap(&f, which, exact); + if (!if_prepare(&f)) + goto done; + if_finish(&f, 0); + if (!if_limit_fixture(&f)) + goto done; + if_cap(&f, which, exact - 1); + /* Directory planning may be global or carry its originating row. Control + * reservations/parsing always identify the next exact requested control. */ + if (which == 1) + ok = if_error_during_work(&f, CBM_INVENTORY_LIMIT, false); + else + ok = if_error(&f, CBM_INVENTORY_LIMIT, which == 0 ? SIZE_MAX : which == 2 ? 0 : 1, false); + if (which <= 2) + ok = ok && f.calls == 0; + if (which == 3 || which == 4 || which == 5) + ok = ok && f.calls == 1 && f.indices[0] == 0; + if (which == 6) + ok = ok && f.calls == 2; +done: + if_finish(&f, 0); + return ok; +} +static bool if_derived_cap_round(unsigned which, const cbm_inventory_filter_usage_t *u) { + if_fixture f; + if_init(&f); + bool ok = false; + if (!if_limit_fixture(&f)) + return false; + if (which == 0) + f.limits.max_ignore_work = u->ignore_work_used; + if (which == 1) + f.limits.max_arena_bytes = u->arena_budget_used_bytes; + if (which == 2) + f.limits.max_ignore_arena_bytes = u->ignore_arena_requested_bytes; + if (!if_prepare(&f)) + goto done; + if_finish(&f, 0); + if (!if_limit_fixture(&f)) + goto done; + if (which == 0) + f.limits.max_ignore_work = u->ignore_work_used - 1; + if (which == 1) + f.limits.max_arena_bytes = u->arena_budget_used_bytes - 1; + if (which == 2) + f.limits.max_ignore_arena_bytes = u->ignore_arena_requested_bytes - 1; + /* Work or allocation can stop before a row is current or while handling one. */ + ok = if_error_during_work(&f, CBM_INVENTORY_LIMIT, false); +done: + if_finish(&f, 0); + return ok; +} +static bool if_reservation_round(const cbm_inventory_filter_usage_t *u) { + if_fixture f; + if_init(&f); + bool ok = false; + if (!if_limit_fixture(&f)) + return false; + f.limits.max_arena_bytes = f.limits.max_ignore_arena_bytes; + if (!if_error(&f, CBM_INVENTORY_LIMIT, SIZE_MAX, false) || f.calls != 0) + goto done; + if (!if_limit_fixture(&f)) + goto done; + f.limits.max_arena_bytes = u->arena_requested_bytes + f.limits.max_ignore_arena_bytes; + if (!if_prepare(&f)) + goto done; + if_finish(&f, 0); + if (!if_limit_fixture(&f)) + goto done; + /* D+C fits this T but D+I exceeds it by exactly one; unused I cannot be reused. */ + f.limits.max_arena_bytes = + f.limits.max_ignore_arena_bytes + u->non_ignore_arena_requested_bytes - 1; + /* Work or allocation can stop before a row is current or while handling one. */ + ok = if_error_during_work(&f, CBM_INVENTORY_LIMIT, false); +done: + if_finish(&f, 0); + return ok; +} +int if_case_limits(void) { + if_fixture f; + if_init(&f); + int result = 1; + cbm_inventory_filter_usage_t u; + IF_CHECK(if_dependency_control() && if_limit_fixture(&f) && if_prepare(&f) && + if_usage_valid(&f, &u)); + IF_CHECK(u.ignore_work_used > 1 && u.ignore_arena_requested_bytes > 1); + if_finish(&f, 0); + const uint64_t bounds[] = {3, 2, 4, 8, 2, 8, 2}; + for (unsigned i = 0; i < 7; i++) + IF_CHECK(if_fixed_cap_round(i, bounds[i])); + for (unsigned i = 0; i < 3; i++) + IF_CHECK(if_derived_cap_round(i, &u)); + IF_CHECK(if_reservation_round(&u)); + IF_CHECK(if_limit_fixture(&f)); + f.limits.max_control_total_bytes = UINT64_MAX; + IF_CHECK(if_error(&f, CBM_INVENTORY_LIMIT, SIZE_MAX, false) && f.calls == 0); + IF_CHECK(if_limit_fixture(&f)); + f.limits.max_files = (size_t)INT_MAX + 1; + IF_CHECK(if_error(&f, CBM_INVENTORY_INVALID, SIZE_MAX, false) && f.calls == 0); + result = 0; +done: + return if_finish(&f, result); +} diff --git a/tests/test_inventory_filter_native.c b/tests/test_inventory_filter_native.c new file mode 100644 index 000000000..d0e539466 --- /dev/null +++ b/tests/test_inventory_filter_native.c @@ -0,0 +1,72 @@ +#include "test_inventory_filter_native_internal.h" +#define N CBM_INVENTORY_FILTER_NEEDS_LANGUAGE +#define I CBM_INVENTORY_FILTER_IGNORED_FILE +#define NONE CBM_INVENTORY_FILTER_REASON_NONE +#define GIT CBM_INVENTORY_FILTER_REASON_GITIGNORE +int if_case_native(void) { + if_native n = {0}; + int result = 1; + IF_CHECK(if_dependency_control() && if_native_start(&n) && if_native_dependency(&n)); + /* Dependency witnesses finish before the first new-feature assertion. */ + IF_CHECK(if_prepare(&n.input)); + const if_row rows[] = { + {".cbmignore", N, NONE, CBM_INVENTORY_FILTER_ROLE_CBMIGNORE, NULL}, + {".codebase-memory.json", N, NONE, CBM_INVENTORY_FILTER_ROLE_PHYSICAL_PROJECT_CONFIG, NULL}, + {".gitignore", N, NONE, CBM_INVENTORY_FILTER_ROLE_GITIGNORE, NULL}, + {"a.data", N, NONE, 0, NULL}, + {"drop.c", I, GIT, 0, NULL}, + {"plain", N, NONE, 0, NULL}, + {"source.c", N, NONE, 0, NULL}, + {"sub/.gitignore", N, NONE, CBM_INVENTORY_FILTER_ROLE_GITIGNORE, NULL}, + {"sub/keep.c", N, NONE, 0, NULL}, + {"sub/nested.c", I, GIT, 0, NULL}}; + const if_control_row controls[] = { + {CBM_INVENTORY_CONTROL_GITIGNORE, "", 2, CBM_INVENTORY_CONTROL_APPLIED, 1}, + {CBM_INVENTORY_CONTROL_GITIGNORE, "sub", 7, CBM_INVENTORY_CONTROL_APPLIED, 1}, + {CBM_INVENTORY_CONTROL_CBMIGNORE, "", 0, CBM_INVENTORY_CONTROL_APPLIED_EMPTY, 0}}; + const size_t calls[] = {0, 2, 7}; + IF_CHECK(if_rows(&n.input, rows, 10) && if_controls(&n.input, controls, 3) && + if_reads(&n.input, calls, 3)); + const cbm_inventory_filter_view_t *v = cbm_inventory_filter_view(n.input.owner); + IF_CHECK(v->directory_count == 2 && v->excluded_count == 0 && + v->native_root != n.input.source.native_root); + cbm_pinned_tree_error_t error; + cbm_pinned_tree_control_t control = {.deadline_ms = UINT64_MAX}; + IF_CHECK(cbm_pinned_tree_verify(n.input.tree, &control, &error) == CBM_PINNED_TREE_OK); + IF_CHECK(if_native_close(&n)); + cbm_git_facts_free(n.facts); + n.facts = NULL; + IF_CHECK(if_rows(&n.input, rows, 10) && if_controls(&n.input, controls, 3) && + n.input.calls == 3); + result = 0; +done: + return if_native_finish(&n, result); +} +int if_case_native_faults(void) { + if_native n = {0}; + int result = 1; + IF_CHECK(if_native_start(&n) && if_native_dependency(&n) && if_prepare(&n.input)); + if_finish(&n.input, 0); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + const cbm_pinned_tree_read_fault_t faults[] = {CBM_PINNED_TREE_READ_FAULT_READ, + CBM_PINNED_TREE_READ_FAULT_EOF, + CBM_PINNED_TREE_READ_FAULT_CLOSE}; + for (size_t i = 0; i < 3; i++) { + if (i) + IF_CHECK(if_native_tree(&n) && if_native_dependency(&n)); + if_reset_reads(&n.input); + IF_CHECK(cbm_pinned_tree_test_set_read_fault(n.input.tree, faults[i])); + IF_CHECK( + if_error(&n.input, CBM_INVENTORY_IO, 0, faults[i] == CBM_PINNED_TREE_READ_FAULT_CLOSE)); + IF_CHECK(n.input.calls == 1 && n.input.indices[0] == 0 && n.input.tree && + !cbm_pinned_tree_view(n.input.tree)); + /* CLOSE is the existing known-handle simulation, not a real failed close retry. */ + IF_CHECK(if_native_close(&n) && !n.input.tree); + } + result = 0; +#else + fprintf(stderr, "inventory native fault tests require canonical CBM_ENABLE_TEST_SEAMS\n"); +#endif +done: + return if_native_finish(&n, result); +} diff --git a/tests/test_inventory_filter_native_fixture.c b/tests/test_inventory_filter_native_fixture.c new file mode 100644 index 000000000..43dd4ba9c --- /dev/null +++ b/tests/test_inventory_filter_native_fixture.c @@ -0,0 +1,280 @@ +#include "test_inventory_filter_native_internal.h" + +_Noreturn void if_native_stop(const if_native *n, const char *reason) { + fprintf(stderr, + "inventory native fail-stop: %s; exit=2; fixture=%s parent=%s; " + "no quiescence/disposal success is claimed\n", + reason, n->home, n->parent); + fflush(stderr); + _Exit(2); +} + +bool if_native_join(char *out, const char *base, const char *rel) { + int n = snprintf(out, IF_NATIVE_PATH, "%s/%s", base, rel); + return n > 0 && n < IF_NATIVE_PATH; +} +static bool if_native_write(const char *path, const void *bytes, size_t length) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) + return false; + bool ok = fwrite(bytes, 1, length, file) == length; + return fclose(file) == 0 && ok; +} +static bool if_native_run(if_native *n, const cbm_proc_opts_t *options) { + cbm_subprocess_t *process = NULL; + if (cbm_subprocess_spawn(options, &process) != 0) + return false; + uint64_t deadline = cbm_now_ms() + 60000; + bool cancelled = false; + cbm_proc_result_t result = {0}; + for (;;) { + cbm_proc_poll_t status = cbm_subprocess_poll(process, &result); + if (status == CBM_PROC_POLL_TERMINAL) + break; + if (status == CBM_PROC_POLL_ERROR || cbm_now_ms() >= deadline) { + if (!cancelled && status == CBM_PROC_POLL_ERROR) + deadline = cbm_now_ms(); + cbm_subprocess_request_cancel(process); + cancelled = true; + } + if (cancelled && cbm_now_ms() >= deadline + 5000) { + n->unquiesced = true; + if_native_stop(n, "process containment deadline expired; resources retained"); + } + cbm_usleep(1000); + } + n->unquiesced = !result.tree_quiesced || result.supervision_failed; + if (n->unquiesced) + if_native_stop(n, "terminal subprocess containment unresolved; resources retained"); + cbm_subprocess_destroy(process); + bool ok = + !cancelled && !n->unquiesced && result.outcome == CBM_PROC_CLEAN && result.exit_code == 0; + if (!ok) + fprintf(stderr, "inventory native Git outcome=%d exit=%d quiescent=%d\n", result.outcome, + result.exit_code, result.tree_quiesced); + return ok; +} +bool if_native_git(if_native *n, const char *const *tail, char *out, size_t capacity) { + const char *argv[40] = {n->git, "-C", n->repo, "-c", "core.autocrlf=false"}; + size_t used = 5; + for (size_t i = 0; tail[i]; i++) { + if (used + 1 >= 40) + return false; + argv[used++] = tail[i]; + } + argv[used] = NULL; + cbm_proc_opts_t options = {.bin = n->git, + .argv = argv, + .stdout_file = n->capture, + .log_file = n->log, + .strip_git_repo_env = true, + .quiet_timeout_ms = 10000, + .cancel_grace_ms = 1000}; + if (!if_native_run(n, &options)) + return false; + if (!out) + return true; + FILE *file = cbm_fopen(n->capture, "rb"); + if (!file || capacity < 2) { + if (file) + fclose(file); + return false; + } + size_t count = fread(out, 1, capacity - 1, file); + out[count] = 0; + bool ok = fgetc(file) == EOF && !ferror(file); + return fclose(file) == 0 && ok; +} +static bool if_native_oid(if_native *n, const char *const *tail, char oid[65]) { + char out[80]; + if (!if_native_git(n, tail, out, sizeof(out))) + return false; + size_t length = strlen(out); + while (length && (out[length - 1] == '\r' || out[length - 1] == '\n')) + out[--length] = 0; + if (length != 40) + return false; + for (size_t i = 0; i < 40; i++) + if (!((out[i] >= '0' && out[i] <= '9') || (out[i] >= 'a' && out[i] <= 'f'))) + return false; + memcpy(oid, out, 41); + return true; +} +static bool if_native_object(if_native *n, const char *type, const void *bytes, size_t length, + char oid[65]) { + const char *args[] = {"hash-object", "-w", "--no-filters", "-t", type, "--", n->payload, NULL}; + return if_native_write(n->payload, bytes, length) && if_native_oid(n, args, oid); +} +static bool if_native_commit(if_native *n) { + const char *clear[] = {"read-tree", "--empty", NULL}; + if (!if_native_git(n, clear, NULL, 0)) + return false; + for (size_t i = 0; i < n->input.source.file_count; i++) { + cbm_inventory_file_t *f = &n->input.files[i]; + if (!if_native_object(n, "blob", n->input.bytes[i], (size_t)f->content_length, f->oid)) + return false; + char entry[IF_PATH + 80]; + int length = snprintf(entry, sizeof(entry), "100644,%s,%s", f->oid, n->input.paths[i]); + const char *add[] = {"update-index", "--add", "--cacheinfo", entry, NULL}; + if (length <= 0 || (size_t)length >= sizeof(entry) || !if_native_git(n, add, NULL, 0)) + return false; + } + char tree[65], bytes[512]; + const char *write[] = {"write-tree", NULL}; + if (!if_native_oid(n, write, tree)) + return false; + int length = snprintf( + bytes, sizeof(bytes), + "tree %s\nauthor Inventory Fixture 946684800 +0000\ncommitter " + "Inventory Fixture 946684800 +0000\n\nInventory fixture\n", + tree); + if (length <= 0 || (size_t)length >= sizeof(bytes) || + !if_native_object(n, "commit", bytes, (size_t)length, n->head)) + return false; + const char *ref[] = {"update-ref", "refs/heads/topic", n->head, NULL}; + return if_native_git(n, ref, NULL, 0); +} +static bool if_native_rows(if_fixture *f) { + return if_text(f, ".cbmignore", "# native\n") && + if_text(f, ".codebase-memory.json", "not JSON") && + if_text(f, ".gitignore", "drop.c\n") && if_add(f, "a.data", "A\0Z", 3) && + if_text(f, "drop.c", "int drop;\n") && if_text(f, "plain", "#!/not/executed\n") && + if_text(f, "source.c", "int source;\n") && if_text(f, "sub/.gitignore", "nested.c\n") && + if_text(f, "sub/keep.c", "int keep;\n") && if_text(f, "sub/nested.c", "int nested;\n"); +} +bool if_native_start(if_native *n) { + memset(n, 0, sizeof(*n)); + if_init(&n->input); + const char *home = th_mktempdir("cbm-inventory-filter"); + if (!home || strlen(home) >= sizeof(n->home)) + return false; + strcpy(n->home, home); + if (!if_native_join(n->repo, n->home, "repo") || + !if_native_join(n->capture, n->home, "stdout") || + !if_native_join(n->log, n->home, "stderr") || + !if_native_join(n->payload, n->home, "payload") || th_mkdir_p(n->repo) != 0 || + !if_native_private_parent(n) || !if_native_git_path(n->git, sizeof(n->git))) + return false; + const char *init[] = {"-c", "init.templateDir=", "init", + "--quiet", "--object-format=sha1", "--initial-branch=topic", + NULL}; + if (!if_native_git(n, init, NULL, 0) || !if_native_rows(&n->input) || !if_native_commit(n)) + return false; + cbm_git_facts_options_t options = {.root = n->repo, + .base_ref = "HEAD", + .git_executable = n->git, + .expected_head = n->head, + .deadline_ms = cbm_now_ms() + 120000, + .command_limit = 512, + .stdout_limit = 1024 * 1024, + .stderr_limit = 65536, + .total_output_limit = 8 * 1024 * 1024}; + cbm_git_facts_error_t error; + n->facts = cbm_git_facts_open(&options, &error); + if (error.status == CBM_GIT_FACTS_SUPERVISION) + n->unquiesced = true; + const cbm_git_facts_identity_t *identity = cbm_git_facts_identity(n->facts); + if (!identity || error.status != CBM_GIT_FACTS_OK || strcmp(identity->head, n->head) || + strcmp(identity->merge_base, n->head)) + return false; + cbm_git_tree_inventory_t inventory; + if (!cbm_git_facts_inventory(n->facts, CBM_GIT_REV_HEAD, &inventory, &error) || + inventory.count != n->input.source.file_count) { + if (error.status == CBM_GIT_FACTS_SUPERVISION) + n->unquiesced = true; + return false; + } + for (size_t i = 0; i < inventory.count; i++) + if (strcmp(inventory.entries[i].path, n->input.paths[i]) || + strcmp(inventory.entries[i].oid, n->input.files[i].oid)) + return false; + return if_native_tree(n); +} +bool if_native_tree(if_native *n) { + cbm_pinned_tree_options_t options = { + .facts = n->facts, + .revision = CBM_GIT_REV_HEAD, + .private_parent = n->parent, + .limits = {.max_files = IF_ROWS, + .max_directories = 16, + .max_total_content_bytes = 16384, + .max_relative_path_bytes = IF_PATH, + .max_arena_bytes = 4 * 1024 * 1024, + .blob_batch = {.max_entries = IF_ROWS, + .max_input_bytes = 16384, + .max_arena_bytes = 4 * 1024 * 1024}}, + .control = {.deadline_ms = cbm_now_ms() + 120000}}; + cbm_pinned_tree_error_t error; + if (cbm_pinned_tree_create(&options, &n->input.tree, &error) != CBM_PINNED_TREE_OK) { + if (error.git.status == CBM_GIT_FACTS_SUPERVISION) + n->unquiesced = true; + fprintf(stderr, "inventory native create status=%d cause=%d\n", error.status, error.cause); + return false; + } + const cbm_pinned_tree_view_t *v = cbm_pinned_tree_view(n->input.tree); + if (!v || v->revision != CBM_GIT_REV_HEAD || strcmp(v->commit, n->head) || + v->file_count != n->input.source.file_count) + return false; + for (size_t i = 0; i < v->file_count; i++) { + if (strcmp((const char *)v->files[i].path, n->input.paths[i]) || + memcmp(v->files[i].content_sha256, n->input.files[i].content_sha256, 32)) + return false; + cbm_inventory_file_t *f = &n->input.files[i]; + f->git_mode = v->files[i].git_mode; + memcpy(f->oid, v->files[i].oid, 65); + f->content_length = v->files[i].content_length; + } + if (strlen(v->root) >= sizeof(n->input.root)) + return false; + strcpy(n->input.root, v->root); + n->input.source.native_root = n->input.root; + memcpy(n->input.source.manifest_sha256, v->manifest_sha256, 32); + if_reset_reads(&n->input); + return true; +} +bool if_native_dependency(if_native *n) { + for (size_t i = 0; i < n->input.source.file_count; i++) { + unsigned char bytes[IF_BYTES]; + size_t copied = SIZE_MAX; + cbm_pinned_tree_error_t error; + cbm_pinned_tree_control_t control = {.deadline_ms = UINT64_MAX}; + if (cbm_pinned_tree_read_prefix(n->input.tree, i, 16384, bytes, sizeof(bytes), &copied, + &control, &error) != CBM_PINNED_TREE_OK || + copied != n->input.files[i].content_length || memcmp(bytes, n->input.bytes[i], copied)) + return false; + } + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(n->input.tree); + return view && view->file_count == 10; +} +bool if_native_close(if_native *n) { + if (!n->input.tree) + return true; + if (n->close_attempted) + if_native_stop(n, "native tree already had an unresolved disposal attempt"); + n->close_attempted = true; + cbm_pinned_tree_error_t error; + if (cbm_pinned_tree_close(&n->input.tree, &error) != CBM_PINNED_TREE_OK) { + fprintf(stderr, "inventory native disposal retained status=%d cause=%d root=%s\n", + error.status, error.cause, n->parent); + if_native_stop(n, "native tree disposal unresolved; no retry"); + } + if (n->input.tree) + if_native_stop(n, "successful native disposal did not clear owner"); + n->close_attempted = false; + return true; +} +int if_native_finish(if_native *n, int result) { + if_finish(&n->input, 0); + if (n->unquiesced) + if_native_stop(n, "native provider containment unresolved; resources retained"); + bool closed = if_native_close(n); + cbm_git_facts_free(n->facts); + n->facts = NULL; + if (!closed) + if_native_stop(n, "native tree disposal unresolved; fixture retained"); + if (n->parent[0] && th_rmtree(n->parent) != 0) + result = 1; + if (n->home[0] && th_rmtree(n->home) != 0) + result = 1; + return result; +} diff --git a/tests/test_inventory_filter_native_internal.h b/tests/test_inventory_filter_native_internal.h new file mode 100644 index 000000000..9947220c5 --- /dev/null +++ b/tests/test_inventory_filter_native_internal.h @@ -0,0 +1,25 @@ +#ifndef TEST_INVENTORY_FILTER_NATIVE_INTERNAL_H +#define TEST_INVENTORY_FILTER_NATIVE_INTERNAL_H +#include "test_inventory_filter_internal.h" +#include "if_native_git_path.h" +#include "foundation/subprocess.h" +enum { IF_NATIVE_PATH = 4096 }; +typedef struct { + if_fixture input; + char home[IF_NATIVE_PATH], repo[IF_NATIVE_PATH], parent[IF_NATIVE_PATH]; + char git[IF_NATIVE_PATH], capture[IF_NATIVE_PATH], log[IF_NATIVE_PATH], payload[IF_NATIVE_PATH]; + char head[65]; + cbm_git_facts_t *facts; + bool unquiesced, close_attempted; +} if_native; +/* Locator returns a candidate; genuine Git commands remain the positive control. */ +_Noreturn void if_native_stop(const if_native *n, const char *reason); +bool if_native_start(if_native *n); +bool if_native_tree(if_native *n); +bool if_native_dependency(if_native *n); +bool if_native_close(if_native *n); +int if_native_finish(if_native *n, int result); +bool if_native_join(char *out, const char *base, const char *rel); +bool if_native_private_parent(if_native *n); +bool if_native_git(if_native *n, const char *const *tail, char *out, size_t capacity); +#endif diff --git a/tests/test_inventory_filter_native_platform.c b/tests/test_inventory_filter_native_platform.c new file mode 100644 index 000000000..a321f7c62 --- /dev/null +++ b/tests/test_inventory_filter_native_platform.c @@ -0,0 +1,51 @@ +#include "test_inventory_filter_native_internal.h" +#ifdef _WIN32 +#include +static bool if_native_windows_parent(const char *path) { + HANDLE token = NULL; + union { + max_align_t align; + unsigned char bytes[2048]; + } user; + DWORD size = sizeof(user.bytes); + union { + max_align_t alignment; + unsigned char bytes[1024]; + } acl_storage; + wchar_t wide[IF_NATIVE_PATH]; + SECURITY_DESCRIPTOR descriptor; + ACL *acl = (ACL *)acl_storage.bytes; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return false; + bool ok = GetTokenInformation(token, TokenUser, user.bytes, size, &size) != 0; + ok = CloseHandle(token) != 0 && ok; + if (!ok || + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, wide, IF_NATIVE_PATH) <= 0) + return false; + PSID owner = ((TOKEN_USER *)user.bytes)->User.Sid; + return InitializeAcl(acl, sizeof(acl_storage.bytes), ACL_REVISION) && + AddAccessAllowedAceEx(acl, ACL_REVISION, OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + FILE_ALL_ACCESS, owner) && + InitializeSecurityDescriptor(&descriptor, SECURITY_DESCRIPTOR_REVISION) && + SetSecurityDescriptorOwner(&descriptor, owner, FALSE) && + SetSecurityDescriptorDacl(&descriptor, TRUE, acl, FALSE) && + SetSecurityDescriptorControl(&descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED) && + SetFileSecurityW(wide, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION | + PROTECTED_DACL_SECURITY_INFORMATION, + &descriptor); +} +#endif +bool if_native_private_parent(if_native *n) { + /* The helper may fill a template even when creation fails. Publish only a + * successfully created owned path; subsequent ACL failure retains ownership. */ + char candidate[IF_NATIVE_PATH] = {0}; + if (!th_secure_runtime_parent_new(candidate, sizeof(candidate), "inventory-filter")) + return false; + strcpy(n->parent, candidate); +#ifdef _WIN32 + return if_native_windows_parent(n->parent); +#else + return chmod(n->parent, 0700) == 0; +#endif +} diff --git a/tests/test_inventory_filter_structure_extra.c b/tests/test_inventory_filter_structure_extra.c new file mode 100644 index 000000000..c56854ece --- /dev/null +++ b/tests/test_inventory_filter_structure_extra.c @@ -0,0 +1,84 @@ +#include "test_inventory_filter_internal.h" +static bool if_root_bound(bool overflow) { + if_fixture f; + if_init(&f); + bool ok = false; + size_t length = overflow ? 4094 : 4093; + memset(f.root, 'r', length); + f.root[length] = 0; +#ifdef _WIN32 + f.root[0] = 'C'; + f.root[1] = ':'; + f.root[2] = '/'; +#else + f.root[0] = '/'; +#endif + if (if_text(&f, "a", "body")) + ok = overflow ? if_error(&f, CBM_INVENTORY_LIMIT, 0, false) : if_prepare(&f); + ok = ok && f.calls == 0; + if_finish(&f, 0); + return ok; +} +int if_case_namespace(void) { + if_fixture f; + if_init(&f); + int result = 1; + IF_CHECK(if_dependency_control() && if_root_bound(false) && if_root_bound(true)); + static const char *paths[] = {"CON", "a:b", "a\\b", "a.", "a\xff"}; + for (size_t i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) { + if_init(&f); + IF_CHECK(if_text(&f, paths[i], "body")); +#ifdef _WIN32 + IF_CHECK(if_error(&f, CBM_INVENTORY_UNSUPPORTED, 0, false)); +#else + IF_CHECK(if_prepare(&f)); + const cbm_inventory_filter_view_t *v = cbm_inventory_filter_view(f.owner); + IF_CHECK(v->rows[0].disposition == CBM_INVENTORY_FILTER_NEEDS_LANGUAGE && + !memcmp(v->files[0].path.data, paths[i], strlen(paths[i]) + 1)); + if_finish(&f, 0); +#endif + IF_CHECK(f.calls == 0); + } + if_init(&f); + strcpy(f.root, "relative"); + IF_CHECK(if_error(&f, CBM_INVENTORY_INVALID, SIZE_MAX, false) && f.calls == 0); + if_init(&f); + IF_CHECK(if_text(&f, "a", "body")); + memset(f.files[0].oid, 'b', 64); + f.files[0].oid[64] = 0; + IF_CHECK(if_prepare(&f)); + const cbm_inventory_filter_view_t *wide = cbm_inventory_filter_view(f.owner); + IF_CHECK(wide && strlen(wide->files[0].oid) == 64 && + wide->rows[0].disposition == CBM_INVENTORY_FILTER_NEEDS_LANGUAGE); + result = 0; +done: + return if_finish(&f, result); +} +int if_case_required_arguments(void) { + if_fixture f; + if_init(&f); + int result = 1; + cbm_inventory_error_t error; + IF_CHECK(if_dependency_control() && if_prepare(&f)); + if_finish(&f, 0); + for (unsigned i = 0; i < 4; i++) { + cbm_inventory_filter_t *sentinel = (cbm_inventory_filter_t *)(uintptr_t)1; + f.owner = sentinel; + memset(&error, 0xa5, sizeof(error)); + cbm_inventory_status_t status = cbm_inventory_filter_prepare( + i == 0 ? NULL : &f.source, i == 1 ? NULL : &f.limits, i == 2 ? NULL : &f.control, + i == 3 ? NULL : &f.owner, &error); + bool cleared = i == 3 || f.owner == NULL; + if (f.owner == sentinel) + f.owner = NULL; + IF_CHECK(status == CBM_INVENTORY_INVALID && error.status == CBM_INVENTORY_INVALID && + cleared && error.file_index == SIZE_MAX && !error.cleanup_required && + f.calls == 0); + } + IF_CHECK(cbm_inventory_filter_prepare(&f.source, &f.limits, &f.control, &f.owner, NULL) == + CBM_INVENTORY_OK && + f.owner); + result = 0; +done: + return if_finish(&f, result); +} diff --git a/tests/test_inventory_filter_support.c b/tests/test_inventory_filter_support.c new file mode 100644 index 000000000..ca5713d2a --- /dev/null +++ b/tests/test_inventory_filter_support.c @@ -0,0 +1,237 @@ +#include "test_inventory_filter_internal.h" +#include "discover/gitignore_checked.h" + +void if_init(if_fixture *f) { + memset(f, 0, sizeof(*f)); +#ifdef _WIN32 + strcpy(f->root, "C:/inventory-fixture"); +#else + strcpy(f->root, "/inventory-fixture"); +#endif + f->source = (cbm_inventory_source_t){ + .native_root = f->root, .files = f->files, .read = if_read, .read_context = f}; + memset(f->source.manifest_sha256, 0x4a, 32); + f->limits = (cbm_inventory_limits_t){.max_files = IF_ROWS, + .max_directories = 128, + .max_arena_bytes = 2 * 1024 * 1024, + .max_ignore_arena_bytes = 512 * 1024, + .max_control_file_bytes = IF_BYTES, + .max_control_total_bytes = 16384, + .max_ignore_patterns = 256, + .max_probe_prefix_bytes = 1, + .max_ignore_work = 1000000, + .max_verified_file_reads = IF_ROWS, + .max_verified_content_bytes = 16384}; + f->control.deadline_ms = UINT64_MAX; + f->fail_index = SIZE_MAX; +} +bool if_add(if_fixture *f, const char *path, const void *bytes, size_t size) { + size_t i = f->source.file_count, n = strlen(path); + if (i >= IF_ROWS || n >= IF_PATH || size > IF_BYTES) + return false; + strcpy(f->paths[i], path); + if (size) + memcpy(f->bytes[i], bytes, size); + f->files[i] = (cbm_inventory_file_t){ + .path = {(unsigned char *)f->paths[i], n}, .git_mode = 0100644, .content_length = size}; + memset(f->files[i].oid, 'a', 40); + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + cbm_sha256_update(&hash, bytes, size); + cbm_sha256_final(&hash, f->files[i].content_sha256); + f->source.file_count++; + return true; +} +bool if_text(if_fixture *f, const char *path, const char *bytes) { + return if_add(f, path, bytes, strlen(bytes)); +} +void if_reset_reads(if_fixture *f) { + f->calls = 0; + f->overflowed = false; +} +/* The clean native enum has no STATE member; native invalid-state use is INVALID. + * Generic provider STATE forwarding is tested independently. */ +static cbm_inventory_status_t if_native_status(cbm_pinned_tree_status_t status) { + switch (status) { + case CBM_PINNED_TREE_OK: + return CBM_INVENTORY_OK; + case CBM_PINNED_TREE_INVALID: + return CBM_INVENTORY_INVALID; + case CBM_PINNED_TREE_UNSUPPORTED: + return CBM_INVENTORY_UNSUPPORTED; + case CBM_PINNED_TREE_LIMIT: + return CBM_INVENTORY_LIMIT; + case CBM_PINNED_TREE_OOM: + return CBM_INVENTORY_OOM; + case CBM_PINNED_TREE_IO: + return CBM_INVENTORY_IO; + case CBM_PINNED_TREE_CHANGED: + return CBM_INVENTORY_CHANGED; + case CBM_PINNED_TREE_CANCELLED: + return CBM_INVENTORY_CANCELLED; + case CBM_PINNED_TREE_DEADLINE: + return CBM_INVENTORY_DEADLINE; + default: + return CBM_INVENTORY_INVALID; + } +} +cbm_inventory_status_t if_read(void *context, size_t i, unsigned char *prefix, size_t cap, + size_t *copied, const cbm_inventory_control_t *control, + cbm_inventory_error_t *error) { + if_fixture *f = context; + bool initialized = + *copied == 0 && error->status == CBM_INVENTORY_OK && !error->cleanup_required; + *copied = 0; + *error = (cbm_inventory_error_t){.status = CBM_INVENTORY_OK, .file_index = SIZE_MAX}; + if (!initialized || f->calls >= IF_CALLS || i >= f->source.file_count) { + f->overflowed = true; + error->status = CBM_INVENTORY_LIMIT; + return error->status; + } + f->indices[f->calls] = i; + f->capacities[f->calls++] = cap; + if (i == f->fail_index) { + error->status = f->reported; + error->cleanup_required = f->cleanup; + strcpy(error->diagnostic, "provider-private-bytes"); + if (f->reply_bytes) { + size_t amount = (size_t)f->files[i].content_length; + if (amount > cap) + amount = cap; + if (amount) + memcpy(prefix, f->bytes[i], amount); + } + *copied = f->reply_copied; + return f->returned; + } + if (f->tree) { + cbm_pinned_tree_control_t c = {.deadline_ms = control->deadline_ms, + .cancelled = control->cancelled, + .cancel_context = control->context}; + cbm_pinned_tree_error_t e; + cbm_pinned_tree_status_t s = + cbm_pinned_tree_read_prefix(f->tree, i, 16384, prefix, cap, copied, &c, &e); + error->cleanup_required = s == CBM_PINNED_TREE_CLEANUP_REQUIRED; + error->status = if_native_status(error->cleanup_required ? e.cause : s); + return error->status; + } + size_t n = (size_t)f->files[i].content_length; + if (cap < n) + n = cap; + if (n) + memcpy(prefix, f->bytes[i], n); + *copied = n; + return CBM_INVENTORY_OK; +} +bool if_prepare(if_fixture *f) { + cbm_inventory_error_t error; + memset(&error, 0xa5, sizeof(error)); + cbm_inventory_status_t status = + cbm_inventory_filter_prepare(&f->source, &f->limits, &f->control, &f->owner, &error); + if (status != CBM_INVENTORY_OK || !f->owner) { + fprintf(stderr, "inventory prepare status=%d error=%d index=%zu cleanup=%d\n", status, + error.status, error.file_index, error.cleanup_required); + return false; + } + return error.status == CBM_INVENTORY_OK && error.file_index == SIZE_MAX && + !error.cleanup_required && !error.diagnostic[0] && !f->overflowed; +} +typedef enum { IF_INDEX_EXACT, IF_INDEX_KNOWN_ROW, IF_INDEX_DURING_WORK } if_index_mode; +static bool if_error_impl(if_fixture *f, cbm_inventory_status_t expected, size_t index, + bool cleanup, if_index_mode mode) { + cbm_inventory_error_t error; + memset(&error, 0xa5, sizeof(error)); + cbm_inventory_filter_t *sentinel = (cbm_inventory_filter_t *)(uintptr_t)1; + f->owner = sentinel; + cbm_inventory_status_t actual = + cbm_inventory_filter_prepare(&f->source, &f->limits, &f->control, &f->owner, &error); + bool cleared = f->owner == NULL; + if (f->owner == sentinel) + f->owner = NULL; + bool index_ok = mode == IF_INDEX_EXACT ? error.file_index == index + : mode == IF_INDEX_KNOWN_ROW + ? error.file_index < f->source.file_count + : error.file_index == SIZE_MAX || error.file_index < f->source.file_count; + bool ok = actual == expected && error.status == expected && cleared && !f->overflowed && + error.cleanup_required == cleanup && index_ok && + memchr(error.diagnostic, 0, sizeof(error.diagnostic)) && + !strstr(error.diagnostic, "provider-private-bytes"); + if (!ok) + fprintf(stderr, + "inventory error actual=%d expected=%d error=%d index=%zu cleanup=%d cleared=%d\n", + actual, expected, error.status, error.file_index, error.cleanup_required, cleared); + return ok; +} +bool if_error(if_fixture *f, cbm_inventory_status_t expected, size_t index, bool cleanup) { + return if_error_impl(f, expected, index, cleanup, IF_INDEX_EXACT); +} +bool if_error_known_row(if_fixture *f, cbm_inventory_status_t expected, bool cleanup) { + return if_error_impl(f, expected, 0, cleanup, IF_INDEX_KNOWN_ROW); +} +bool if_error_during_work(if_fixture *f, cbm_inventory_status_t expected, bool cleanup) { + return if_error_impl(f, expected, 0, cleanup, IF_INDEX_DURING_WORK); +} +int if_finish(if_fixture *f, int result) { + cbm_inventory_filter_free(f->owner); + f->owner = NULL; + return result; +} +static bool if_path(cbm_inventory_path_t path, const char *expected) { + if (expected && !expected[0]) + return path.length == 0 && (!path.data || !path.data[0]); + return expected ? path.data && path.length == strlen(expected) && + !memcmp(path.data, expected, path.length + 1) + : !path.data && !path.length; +} +bool if_rows(const if_fixture *f, const if_row *rows, size_t count) { + const cbm_inventory_filter_view_t *v = cbm_inventory_filter_view(f->owner); + if (!v || v->file_count != count || memcmp(v->manifest_sha256, f->source.manifest_sha256, 32)) + return false; + for (size_t i = 0; i < count; i++) { + const cbm_inventory_filter_row_t *r = &v->rows[i]; + if (!if_path(v->files[i].path, rows[i].path) || + v->files[i].git_mode != f->files[i].git_mode || + v->files[i].content_length != f->files[i].content_length || + strcmp(v->files[i].oid, f->files[i].oid) || + memcmp(v->files[i].content_sha256, f->files[i].content_sha256, 32) || + r->file_index != i || r->disposition != rows[i].disposition || + r->reason != rows[i].reason || r->roles != rows[i].roles || + !if_path(r->excluded_ancestor, rows[i].ancestor)) + return false; + } + return true; +} +bool if_controls(const if_fixture *f, const if_control_row *rows, size_t count) { + const cbm_inventory_filter_view_t *v = cbm_inventory_filter_view(f->owner); + if (!v || v->control_count != count) + return false; + for (size_t i = 0; i < count; i++) { + const cbm_inventory_control_row_t *r = &v->controls[i]; + if (r->kind != rows[i].kind || !if_path(r->directory, rows[i].directory) || + r->file_index != rows[i].index || r->outcome != rows[i].outcome || + r->effective_patterns != rows[i].patterns) + return false; + } + return true; +} +bool if_reads(const if_fixture *f, const size_t *indices, size_t count) { + if (f->calls != count || f->overflowed) + return false; + for (size_t i = 0; i < count; i++) + if (f->indices[i] != indices[i] || f->capacities[i] != f->files[indices[i]].content_length) + return false; + return true; +} +bool if_dependency_control(void) { + cbm_gitignore_t *gi = cbm_gitignore_parse("*.tmp\n!keep.tmp\n"); + if (!gi) + return false; + bool ok = cbm_gitignore_matches(gi, "drop.tmp", false) && + !cbm_gitignore_matches(gi, "keep.tmp", false) && + cbm_should_skip_dir("node_modules", CBM_MODE_FULL) && + cbm_has_ignored_suffix("a.png", CBM_MODE_FULL) && + !cbm_should_skip_filename("LICENSE", CBM_MODE_FULL) && + !cbm_matches_fast_pattern("thing.generated.c", CBM_MODE_FULL); + cbm_gitignore_free(gi); + return ok; +} diff --git a/tests/test_main.c b/tests/test_main.c index e4a494420..d96ef9347 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -19,6 +19,7 @@ int tf_deselected_count = 0; #include "foundation/mem.h" /* cbm_mem_init — worker budget */ #include "foundation/log.h" /* worker liveness heartbeat probe */ #include "foundation/platform.h" /* cbm_file_exists — blocking-git marker */ +#include "foundation/test_selection.h" /* shared executable selection token bound */ #include "daemon/bootstrap.h" /* runner rendezvous isolation */ #include "daemon/runtime.h" /* bounded worker response probe */ #include "daemon/ipc.h" /* Windows private-lock re-exec probe */ @@ -48,8 +49,12 @@ int tf_deselected_count = 0; #include #endif #ifdef CBM_TEST_COVERAGE -#include /* the libc _exit behind the profile-writing one */ +#include /* the libc functions behind the profile-keeping ones */ +#include /* the marker of a forked child */ +#include /* non-consuming child-failure observation */ #include /* pthread_atfork: a forked child names its own profile */ +#include /* execl */ +#include /* reject detectable failure-channel descriptor replacement */ #endif #endif @@ -828,9 +833,10 @@ static int tf_maybe_run_deleted_self_probe(int argc, char **argv) { } /* ── 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. + * The test-impact selector names whole suites as `suite` and individual tests + * as `suite:test`. CBM_TEST_ONLY accepts comma-separated tokens; the file named + * by CBM_TEST_ONLY_FILE accepts one per line, with blank lines and # comments. + * 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. * @@ -847,8 +853,8 @@ typedef enum { } tf_only_state_t; typedef struct { - char *suite; /* one allocation holding "suite\0test" */ - const char *test; + char *suite; /* one allocation holding "suite\0test" or "suite\0" */ + const char *test; /* empty names a whole suite; otherwise points past the colon */ tf_only_state_t state; } tf_only_token_t; @@ -885,12 +891,12 @@ static bool tf_only_add(const char *text, size_t length) { 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); + const char *colon = length <= CBM_TEST_SELECTION_TOKEN_MAX ? memchr(text, ':', length) : NULL; + size_t suite_length = colon ? (size_t)(colon - text) : length; + if (length > CBM_TEST_SELECTION_TOKEN_MAX || !tf_only_name_valid(text, suite_length) || + (colon && !tf_only_name_valid(colon + 1, length - suite_length - 1))) { + fprintf(stderr, "malformed test selection token: %.*s (expected suite or suite:test)\n", + (int)length, text); return false; } if (tf_only_count == tf_only_cap) { @@ -911,8 +917,8 @@ static bool tf_only_add(const char *text, size_t length) { 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}; + tf_only_tokens[tf_only_count++] = (tf_only_token_t){ + .suite = copy, .test = copy + suite_length + (colon ? 1 : 0), .state = TF_ONLY_UNSEEN}; return true; } @@ -1032,6 +1038,12 @@ bool tf_suite_enter(const char *suite) { 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); + for (size_t i = tf_only_suite_first; i < tf_only_suite_end; i++) { + if (!tf_only_tokens[i].test[0]) { + /* Entering fulfills a whole-suite token even when it has no tests. */ + tf_only_tokens[i].state = TF_ONLY_MATCHED; + } + } } tf_current_suite = suite; return true; @@ -1051,7 +1063,7 @@ bool tf_test_selected(const char *test) { } 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) { + if (!tf_only_tokens[i].test[0] || strcmp(tf_only_tokens[i].test, test) == 0) { tf_only_tokens[i].state = TF_ONLY_MATCHED; selected = true; } @@ -1063,7 +1075,7 @@ bool tf_test_selected(const char *test) { } /* 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. */ + * for must have entered its whole suite or matched its explicit test. */ 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]; @@ -1073,8 +1085,8 @@ static void tf_only_report_unmatched(void) { 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); + fprintf(stderr, "selected test not compiled into this build or unknown: %s%s%s\n", + token->suite, token->test[0] ? ":" : "", token->test); tf_fail_count++; } } @@ -1088,6 +1100,8 @@ static void tf_only_report_unmatched(void) { * ..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 + * ..forked an unaccounted forked child: the coverage of + * this test remains incomplete * _setup..profraw what ran between tests: suite setup, and the * prologue and epilogue of the runner itself * _setup.child..profraw children spawned between tests @@ -1102,13 +1116,211 @@ static char tf_coverage_dir[CBM_SZ_4K]; /* canonical; empty = not a coverage run 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; +/* Keep the first failed interval named and unreset for the rest of the run. */ +static bool tf_coverage_write_failed = false; +/* This process started with a `%c` profile: its counters are mapped onto the + * file, and it neither names nor writes a profile itself (see Children). */ +static bool tf_coverage_continuous = false; + +#ifndef _WIN32 +/* Negative evidence only: these private pipe ends must retain exclusive custody. + * Identity/flag checks reject detectable damage, not arbitrary aliasing/history. + * Supported children keep BOTH inherited ends; parent disposal cannot remove + * their last reader. No reader drains this pipe, even after failure is latched. */ +static struct { + int fd[2]; + struct stat identity[2]; + pid_t owner; + bool started; + bool active; + bool failed; + bool reported; + bool transport_broken; /* child-local unsupported delivery failure */ +} tf_coverage_channel = {.fd = {-1, -1}}; +static bool tf_coverage_atfork_attempted; +static int tf_coverage_atfork_status = -1; + +static int tf_coverage_channel_flags(int fd, int command) { + int result; + do { + result = fcntl(fd, command); + } while (result < 0 && errno == EINTR); + return result; +} + +static bool tf_coverage_channel_end(int index, bool require_flags) { + int fd = tf_coverage_channel.fd[index]; + struct stat current; + if (fd < 0) { + return false; + } + int status; + do { + status = fstat(fd, ¤t); + } while (status < 0 && errno == EINTR); + if (status != 0 || !S_ISFIFO(current.st_mode) || + current.st_dev != tf_coverage_channel.identity[index].st_dev || + current.st_ino != tf_coverage_channel.identity[index].st_ino) { + return false; + } + int flags = tf_coverage_channel_flags(fd, F_GETFL); + if (flags < 0 || (flags & O_ACCMODE) != (index == 0 ? O_RDONLY : O_WRONLY)) { + return false; + } + if (!require_flags) { + return true; + } + int descriptor_flags = tf_coverage_channel_flags(fd, F_GETFD); + return (flags & O_NONBLOCK) != 0 && descriptor_flags >= 0 && + (descriptor_flags & FD_CLOEXEC) != 0; +} + +static bool tf_coverage_channel_intact(void) { + return tf_coverage_channel.fd[0] >= 0 && tf_coverage_channel.fd[1] >= 0 && + tf_coverage_channel.fd[0] != tf_coverage_channel.fd[1] && + tf_coverage_channel_end(0, true) && tf_coverage_channel_end(1, true); +} + +static void tf_coverage_channel_fail(const char *reason) { + if (getpid() != tf_coverage_channel.owner) { + return; + } + tf_coverage_channel.failed = true; + if (!tf_coverage_channel.reported) { + tf_coverage_channel.reported = true; + fprintf(stderr, "coverage child accounting failed: owner_pid=%ld reason=%s\n", + (long)tf_coverage_channel.owner, reason); + tf_fail_count++; + } +} + +static void tf_coverage_channel_observe(void) { + if (!tf_coverage_channel.active || getpid() != tf_coverage_channel.owner) { + return; + } + if (!tf_coverage_channel_intact()) { + tf_coverage_channel_fail("channel-invalid"); + return; + } + struct pollfd event = {.fd = tf_coverage_channel.fd[0], .events = POLLIN}; + int ready; + do { + ready = poll(&event, 1, 0); + } while (ready < 0 && errno == EINTR); + if (ready < 0 || (ready > 0 && (event.revents & (POLLERR | POLLHUP | POLLNVAL)))) { + tf_coverage_channel_fail("observation"); + } else if (ready > 0) { + tf_coverage_channel_fail((event.revents & POLLIN) ? "child-marker" : "observation"); + } +} + +static bool tf_coverage_channel_blocked(void) { + return getpid() == tf_coverage_channel.owner && tf_coverage_channel.failed; +} + +static bool tf_coverage_channel_prepare_end(int index) { + int fd = tf_coverage_channel.fd[index]; + if (fstat(fd, &tf_coverage_channel.identity[index]) != 0 || + !S_ISFIFO(tf_coverage_channel.identity[index].st_mode)) { + return false; + } + int flags = fcntl(fd, F_GETFL); + int descriptor_flags = fcntl(fd, F_GETFD); + return flags >= 0 && descriptor_flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0 && + fcntl(fd, F_SETFD, descriptor_flags | FD_CLOEXEC) == 0; +} + +/* Only the single-threaded setup path uses this: both obtained ends are still + * exclusively owned even if preparing their identity/flags failed. */ +static bool tf_coverage_channel_abort_setup(void) { + bool closed = true; + for (int i = 0; i < 2; i++) { + int fd = tf_coverage_channel.fd[i]; + tf_coverage_channel.fd[i] = -1; + if (fd >= 0 && close(fd) != 0) { + closed = false; + } + } + return closed; +} + +static bool tf_coverage_channel_unavailable(const char *reason) { + tf_coverage_channel.failed = true; + fprintf(stderr, "coverage child accounting unavailable: reason=%s\n", reason); + return false; +} + +static bool tf_coverage_channel_activate(void) { + if (tf_coverage_channel.started) { + return tf_coverage_channel_unavailable("already-started"); + } + tf_coverage_channel.started = true; + tf_coverage_channel.owner = getpid(); + if (pipe(tf_coverage_channel.fd) != 0) { + return tf_coverage_channel_unavailable("pipe"); + } + if (!tf_coverage_channel_prepare_end(0) || !tf_coverage_channel_prepare_end(1) || + !tf_coverage_channel_intact()) { + bool closed = tf_coverage_channel_abort_setup(); + return tf_coverage_channel_unavailable(closed ? "pipe-setup" : "pipe-setup-close"); + } + tf_coverage_channel.active = true; + return true; +} + +static void tf_coverage_channel_finish(void) { + if (!tf_coverage_channel.active || getpid() != tf_coverage_channel.owner) { + return; + } + tf_coverage_channel_observe(); + tf_coverage_channel.active = false; + if (tf_coverage_channel.fd[0] == tf_coverage_channel.fd[1]) { + tf_coverage_channel_fail("channel-invalid"); + tf_coverage_channel.fd[0] = tf_coverage_channel.fd[1] = -1; + return; + } + for (int i = 0; i < 2; i++) { + int fd = tf_coverage_channel.fd[i]; + bool owned = tf_coverage_channel_end(i, false); + tf_coverage_channel.fd[i] = -1; + if (!owned) { + /* Do not close a detectable replacement. Unknown ownership remains + * for kernel process-exit cleanup, never a blind retry/close. */ + tf_coverage_channel_fail("channel-invalid"); + } else if (close(fd) != 0) { + tf_coverage_channel_fail("channel-close"); + } + } +} + +/* Fixed storage and descriptor syscalls only in the post-fork failure path. + * EAGAIN is evidence solely under intact, never-drained exclusive custody. + * Unsupported transport loss cannot report itself magically to the parent. */ +static void tf_coverage_channel_notify(void) { + int saved_errno = errno; + if (tf_coverage_channel.active && !tf_coverage_channel.transport_broken) { + bool delivered = false; + if (tf_coverage_channel_intact()) { + const unsigned char byte = 1; + ssize_t written; + do { + written = write(tf_coverage_channel.fd[1], &byte, sizeof(byte)); + } while (written < 0 && errno == EINTR); + delivered = written == 1 || (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)); + } + tf_coverage_channel.transport_broken = !delivered; + } + errno = saved_errno; +} +#endif /* !_WIN32 */ /* 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); + /* `%c`: an exec'd child maps its counters onto the file (see Children). */ int child_length = snprintf(tf_coverage_child_pattern, sizeof(tf_coverage_child_pattern), - "%s/%s.%%p.profraw", directory, child); + "%s/%s.%%p%%c.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; @@ -1124,11 +1336,35 @@ static bool tf_coverage_route_setup(const char *suite_dir) { 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(); +/* Writes the current interval and resets counters only after a successful write. */ +static bool tf_coverage_flush(void) { + if (__llvm_profile_write_file() != 0) { + tf_coverage_write_failed = true; + fprintf(stderr, "coverage profile write failed: %s\n", tf_coverage_profile); + tf_fail_count++; + return false; + } __llvm_profile_reset_counters(); + return true; +} + +/* Check the current parent interval before choosing the runner status. Keep + * counters and routing intact: the automatic exit writer still follows, and + * this checked prefix does not certify its outcome or later callbacks. */ +static void tf_coverage_finish(void) { + if (!tf_coverage_dir[0] || tf_coverage_write_failed) { + return; + } +#ifndef _WIN32 + if (getpid() != tf_coverage_channel.owner || tf_coverage_channel_blocked()) { + return; + } +#endif + if (__llvm_profile_write_file() != 0) { + tf_coverage_write_failed = true; + fprintf(stderr, "coverage profile write failed: %s\n", tf_coverage_profile); + tf_fail_count++; + } } static bool tf_coverage_suite_dir(char *out, size_t cap) { @@ -1146,9 +1382,15 @@ static void tf_coverage_lost(const char *test) { } void tf_coverage_test_begin(const char *test) { +#ifndef _WIN32 + tf_coverage_channel_observe(); + if (tf_coverage_channel_blocked()) { + return; + } +#endif char suite_dir[TF_COVERAGE_PATH_CAP]; char own[CBM_SZ_512]; - if (!tf_coverage_dir[0]) { + if (!tf_coverage_dir[0] || tf_coverage_write_failed) { return; } int own_length = snprintf(own, sizeof(own), "%s.parent", test); @@ -1158,7 +1400,9 @@ void tf_coverage_test_begin(const char *test) { tf_coverage_lost(test); return; } - tf_coverage_flush(); + if (!tf_coverage_flush()) { + return; + } 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. */ @@ -1168,9 +1412,15 @@ void tf_coverage_test_begin(const char *test) { } void tf_coverage_test_end(const char *test) { +#ifndef _WIN32 + tf_coverage_channel_observe(); + if (tf_coverage_channel_blocked()) { + return; + } +#endif char suite_dir[TF_COVERAGE_PATH_CAP]; char own[CBM_SZ_512]; - if (!tf_coverage_dir[0]) { + if (!tf_coverage_dir[0] || tf_coverage_write_failed) { return; } int own_length = snprintf(own, sizeof(own), "%s.parent", test); @@ -1180,7 +1430,9 @@ void tf_coverage_test_end(const char *test) { tf_coverage_lost(test); return; } - tf_coverage_flush(); + if (!tf_coverage_flush()) { + return; + } /* 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. */ @@ -1196,50 +1448,116 @@ static bool tf_coverage_init(void) { if (!dir || !dir[0]) { return true; } +#ifndef _WIN32 + /* A binary built for continuous profiles starts in that mode unless + * LLVM_PROFILE_FILE names a plain file, and then ignores every name this + * runner gives it: all tests would land in one default.profraw. */ + const char *initial = getenv("LLVM_PROFILE_FILE"); + if (!initial || !initial[0] || tf_coverage_continuous) { + fprintf(stderr, + "a coverage run starts with LLVM_PROFILE_FILE=/dev/null " + "(got: %s)\n", + initial && initial[0] ? initial : "unset"); + return false; + } + if (!tf_coverage_atfork_attempted || tf_coverage_atfork_status != 0) { + fprintf(stderr, "coverage child accounting unavailable: reason=atfork code=%d\n", + tf_coverage_atfork_status); + return false; + } +#endif 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; } +#ifndef _WIN32 + if (!tf_coverage_channel_activate()) { + tf_coverage_dir[0] = '\0'; + return false; + } +#endif /* 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'; +#ifndef _WIN32 + tf_coverage_channel_finish(); +#endif return false; } 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. +/* ── Children ──────────────────────────────────────────────────────── + * A same-image, non-continuous fork gets its own profile and an obligation + * marker. Explicit _exit/_Exit writes retire that marker only after the native + * writer reports success. A failed write or signal leaves the marker behind. * - * 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. */ + * Normal exit retains its marker: delegating to libc does not establish the + * outcome of the final atexit writer. Exec retains the original-image marker + * too; a successor profile cannot establish that this image was accounted for. + * A failed exec may return to this image and later finish with a successful + * explicit write, which can then retire its marker. + * + * These wrappers establish retirement behavior only for markers that were + * successfully placed. The private channel reports marker-open/close failures + * for controlled same-image children retaining exclusive descriptor custody. + * Continuous mode, successor images, libc-internal spawning and arbitrary + * descendants remain separate obligations; marker absence certifies none. */ static char tf_coverage_fork_profile[TF_COVERAGE_PATH_CAP]; +static char tf_coverage_fork_marker[TF_COVERAGE_PATH_CAP]; /* empty: not a counted fork */ -/* 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_fork_marker_place(void) { + if (tf_coverage_fork_marker[0]) { + int fd = open(tf_coverage_fork_marker, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0 || close(fd) != 0) { + tf_coverage_channel_notify(); + } } } +static void tf_coverage_fork_marker_lift(void) { + if (tf_coverage_fork_marker[0]) { + (void)unlink(tf_coverage_fork_marker); + } +} + +/* Runs in the child of every fork() of this image. Only what is safe between + * fork and exec of a threaded process: no allocation, static buffers. */ +static void tf_coverage_fork_child(void) { + static const char tail[] = ".%p%c.profraw"; + tf_coverage_fork_marker[0] = '\0'; + if (tf_coverage_continuous) { + return; /* the counters are mapped onto the file of the parent */ + } + const char *pattern = getenv("LLVM_PROFILE_FILE"); + size_t length = pattern ? strlen(pattern) : 0; + if (length <= sizeof(tail) - 1 || strcmp(pattern + length - (sizeof(tail) - 1), tail) != 0) { + return; /* not a coverage run */ + } + int stem = (int)(length - (sizeof(tail) - 1)); + long pid = (long)getpid(); + int profile_length = snprintf(tf_coverage_fork_profile, sizeof(tf_coverage_fork_profile), + "%.*s.%ld.profraw", stem, pattern, pid); + int marker_length = snprintf(tf_coverage_fork_marker, sizeof(tf_coverage_fork_marker), + "%.*s.%ld.forked", stem, pattern, pid); + if (profile_length <= 0 || (size_t)profile_length >= sizeof(tf_coverage_fork_profile) || + marker_length <= 0 || (size_t)marker_length >= sizeof(tf_coverage_fork_marker)) { + tf_coverage_fork_marker[0] = '\0'; + return; + } + __llvm_profile_set_filename(tf_coverage_fork_profile); + tf_coverage_fork_marker_place(); +} + static void tf_coverage_exit_now(int status) __attribute__((noreturn)); static void tf_coverage_exit_now(int status) { - (void)__llvm_profile_write_file(); + if (!tf_coverage_continuous && __llvm_profile_write_file() == 0) { + tf_coverage_fork_marker_lift(); + } void (*libc_exit)(int) = (void (*)(int))dlsym(RTLD_NEXT, "_exit"); if (libc_exit) { libc_exit(status); @@ -1254,13 +1572,63 @@ void _exit(int status) { void _Exit(int status) { tf_coverage_exit_now(status); } + +void exit(int status) { + void (*libc_exit)(int) = (void (*)(int))dlsym(RTLD_NEXT, "exit"); + if (libc_exit) { + libc_exit(status); + } + abort(); +} + +/* Keep the original-image obligation across both successful and failed exec. */ +int execve(const char *path, char *const argv[], char *const envp[]) { + int (*libc_execve)(const char *, char *const[], char *const[]) = + (int (*)(const char *, char *const[], char *const[]))dlsym(RTLD_NEXT, "execve"); + return libc_execve ? libc_execve(path, argv, envp) : -1; +} + +int execv(const char *path, char *const argv[]) { + int (*libc_execv)(const char *, char *const[]) = + (int (*)(const char *, char *const[]))dlsym(RTLD_NEXT, "execv"); + return libc_execv ? libc_execv(path, argv) : -1; +} + +int execvp(const char *file, char *const argv[]) { + int (*libc_execvp)(const char *, char *const[]) = + (int (*)(const char *, char *const[]))dlsym(RTLD_NEXT, "execvp"); + return libc_execvp ? libc_execvp(file, argv) : -1; +} + +enum { TF_COVERAGE_EXECL_ARG_CAP = 64 }; + +int execl(const char *path, const char *arg0, ...) { + char *argv[TF_COVERAGE_EXECL_ARG_CAP]; + int argc = 0; + va_list args; + va_start(args, arg0); + for (const char *arg = arg0; arg != NULL && argc < TF_COVERAGE_EXECL_ARG_CAP - 1; + arg = va_arg(args, const char *)) { + argv[argc++] = (char *)arg; + } + va_end(args); + argv[argc] = NULL; + return execv(path, argv); +} #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); + /* Read before anything routes: tf_coverage_route puts a `%c` pattern into + * the environment of a process that is not continuous itself. */ + const char *initial = getenv("LLVM_PROFILE_FILE"); + tf_coverage_continuous = initial != NULL && strstr(initial, "%c") != NULL; + if (!tf_coverage_atfork_attempted) { + tf_coverage_atfork_attempted = true; + tf_coverage_atfork_status = pthread_atfork(NULL, NULL, tf_coverage_fork_child); + } #endif } #endif /* CBM_TEST_COVERAGE */ @@ -1341,6 +1709,7 @@ extern void suite_subprocess(void); extern void suite_private_file_lock(void); extern void suite_lock_registry(void); extern void suite_extraction(void); +extern void suite_test_conventions(void); extern void suite_extraction_inheritance(void); extern void suite_extraction_imports(void); extern void suite_parse_coverage(void); @@ -1350,6 +1719,18 @@ extern void suite_grammar_imports(void); extern void suite_ac(void); extern void suite_store_nodes(void); extern void suite_store_edges(void); +extern void suite_store_impact(void); +extern void suite_store_scope(void); +extern void suite_store_graph_digest(void); +extern void suite_test_impact(void); +extern void suite_test_impact_profiles(void); +extern void suite_test_impact_changes(void); +extern void suite_test_impact_origins(void); +extern void suite_test_impact_git(void); +extern void suite_test_impact_tree(void); +extern void suite_test_impact_tree_read(void); +extern void suite_test_impact_inventory(void); +extern void suite_test_impact_runner_filter(void); extern void suite_store_search(void); extern void suite_cypher(void); extern void suite_mcp(void); @@ -1367,11 +1748,24 @@ extern void suite_daemon_ipc(void); extern void suite_language(void); extern void suite_userconfig(void); extern void suite_gitignore(void); +extern void suite_gitignore_checked(void); +extern void suite_inventory_filter(void); +#if defined(CBM_TEST_COVERAGE) && !defined(_WIN32) +extern void suite_coverage_children(void); +extern void suite_coverage_setup_success(void); +extern void suite_coverage_setup_uncertain(void); +extern int tf_coverage_children_dispatch(int argc, char **argv); +extern void suite_coverage_parent_failure(void); +extern int tf_coverage_parent_failure_args(int *argc, char **argv); +extern void tf_coverage_parent_failure_finish(void); +#endif extern void suite_git_context(void); extern void suite_discover(void); extern void suite_graph_buffer(void); extern void suite_registry(void); extern void suite_pipeline(void); +extern void suite_pipeline_conventions(void); +extern void suite_pipeline_frozen(void); extern void suite_importance(void); extern void suite_pipeline_semantic_manifest_repro(void); extern void suite_cross_repo(void); @@ -1476,10 +1870,30 @@ extern void suite_dump_verify_io(void); * caches at thread teardown (pass_parallel.c). */ extern void cbm_kind_in_set_free_cache(void); +/* Native subprocess capture probe; implemented with its tests. */ +extern int tf_maybe_run_subprocess_stdout_probe(int argc, char **argv); +extern void tf_test_impact_runner_filter_set_binary(const char *path); +extern int tf_maybe_run_git_facts_diff_probe(int argc, char **argv); + int main(int argc, char **argv) { + tf_test_impact_runner_filter_set_binary(argc > 0 && argv ? argv[0] : NULL); #ifdef CBM_TEST_COVERAGE tf_coverage_process_init(); +#ifndef _WIN32 + int coverage_child_rc = tf_coverage_children_dispatch(argc, argv); + if (coverage_child_rc >= 0) { + return coverage_child_rc; + } #endif +#endif + int stdout_probe_rc = tf_maybe_run_subprocess_stdout_probe(argc, argv); + if (stdout_probe_rc >= 0) { + return stdout_probe_rc; + } + int git_facts_probe_rc = tf_maybe_run_git_facts_diff_probe(argc, argv); + if (git_facts_probe_rc >= 0) { + return git_facts_probe_rc; + } 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; @@ -1616,6 +2030,11 @@ int main(int argc, char **argv) { return 2; } +#if defined(CBM_TEST_COVERAGE) && !defined(_WIN32) + if (tf_coverage_parent_failure_args(&argc, argv) != 0) { + return 2; + } +#endif const char *skip_perf_env = getenv("CBM_SKIP_PERF"); g_skip_perf = skip_perf_env != NULL && strcmp(skip_perf_env, "1") == 0; if (argc == 2 && strcmp(argv[1], "--list-suites") == 0) { @@ -1631,6 +2050,9 @@ int main(int argc, char **argv) { } #ifdef CBM_TEST_COVERAGE if (!g_list_only && !tf_coverage_init()) { +#ifndef _WIN32 + tf_coverage_channel_finish(); +#endif tf_only_free(); return 2; } @@ -1639,6 +2061,9 @@ int main(int argc, char **argv) { g_suite_arg_matched = calloc((size_t)argc, sizeof(*g_suite_arg_matched)); if (!g_suite_arg_matched) { fprintf(stderr, "Failed to allocate test-suite argument tracking\n"); +#if defined(CBM_TEST_COVERAGE) && !defined(_WIN32) + tf_coverage_channel_finish(); +#endif return 1; } } @@ -1669,6 +2094,7 @@ int main(int argc, char **argv) { /* Existing C code regression tests */ RUN_SELECTED_SUITE(ac); RUN_SELECTED_SUITE(extraction); + RUN_SELECTED_SUITE(test_conventions); RUN_SELECTED_SUITE(extraction_inheritance); RUN_SELECTED_SUITE(extraction_imports); RUN_SELECTED_SUITE(parse_coverage); @@ -1679,6 +2105,18 @@ int main(int argc, char **argv) { /* Store (M5) */ RUN_SELECTED_SUITE(store_nodes); RUN_SELECTED_SUITE(store_edges); + RUN_SELECTED_SUITE(store_impact); + RUN_SELECTED_SUITE(store_scope); + RUN_SELECTED_SUITE(store_graph_digest); + RUN_SELECTED_SUITE(test_impact); + RUN_SELECTED_SUITE(test_impact_profiles); + RUN_SELECTED_SUITE(test_impact_changes); + RUN_SELECTED_SUITE(test_impact_origins); + RUN_SELECTED_SUITE(test_impact_git); + RUN_SELECTED_SUITE(test_impact_tree); + RUN_SELECTED_SUITE(test_impact_tree_read); + RUN_SELECTED_SUITE(test_impact_inventory); + RUN_SELECTED_SUITE(test_impact_runner_filter); RUN_SELECTED_SUITE(store_search); RUN_SELECTED_SUITE(store_bulk); RUN_SELECTED_SUITE(store_pragmas); @@ -1708,6 +2146,14 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(language); RUN_SELECTED_SUITE(userconfig); RUN_SELECTED_SUITE(gitignore); + RUN_SELECTED_SUITE(gitignore_checked); + RUN_SELECTED_SUITE(inventory_filter); +#if defined(CBM_TEST_COVERAGE) && !defined(_WIN32) + RUN_SELECTED_SUITE(coverage_children); + RUN_SELECTED_SUITE(coverage_setup_success); + RUN_SELECTED_SUITE(coverage_setup_uncertain); + RUN_SELECTED_SUITE(coverage_parent_failure); +#endif RUN_SELECTED_SUITE(git_context); RUN_SELECTED_SUITE(discover); @@ -1717,6 +2163,8 @@ int main(int argc, char **argv) { /* Pipeline (M8) */ RUN_SELECTED_SUITE(registry); RUN_SELECTED_SUITE(pipeline); + RUN_SELECTED_SUITE(pipeline_conventions); + RUN_SELECTED_SUITE(pipeline_frozen); RUN_SELECTED_SUITE(importance); RUN_SELECTED_SUITE(index_format); RUN_SELECTED_SUITE(pipeline_semantic_manifest_repro); @@ -1879,6 +2327,9 @@ int main(int argc, char **argv) { if (g_suite_argc > 1 && !any_suite_matched) { fprintf(stderr, "No matching test suites requested\n"); } +#if defined(CBM_TEST_COVERAGE) && !defined(_WIN32) + tf_coverage_parent_failure_finish(); +#endif tf_only_report_unmatched(); tf_only_free(); free(g_suite_arg_matched); @@ -1887,5 +2338,11 @@ int main(int argc, char **argv) { /* Release process-lifetime caches so LeakSanitizer reports no leaks. */ cbm_kind_in_set_free_cache(); sqlite3_shutdown(); +#if defined(CBM_TEST_COVERAGE) && !defined(_WIN32) + tf_coverage_channel_finish(); +#endif +#ifdef CBM_TEST_COVERAGE + tf_coverage_finish(); +#endif TEST_SUMMARY(); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 7a2219c5a..5daae1c00 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1359,10 +1359,10 @@ TEST(mcp_metadata_byte_budget) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); - /* 15 KiB covered the lean surface at the branch point; get_file_outline, - * compare_graphs, manage_adr set_sections, and the search_code debug and - * list_projects include_details parameters landed on main since. */ - ASSERT_LT((int)strlen(json), 18 * 1024); + /* Seed discovery and independent pagination have a reviewed budget: + * 20 KiB overall; only detect_changes may exceed the 3200-byte per-tool + * ceiling, with its own 4096-byte bound. */ + ASSERT_LT((int)strlen(json), 20 * 1024); yyjson_doc *doc = yyjson_read(json, strlen(json), 0); ASSERT_NOT_NULL(doc); @@ -1375,7 +1375,9 @@ TEST(mcp_metadata_byte_budget) { yyjson_arr_foreach(tools, idx, max, tool) { char *serialized = yyjson_val_write(tool, 0, NULL); ASSERT_NOT_NULL(serialized); - ASSERT_LT((int)strlen(serialized), 3200); + const char *name = yyjson_get_str(yyjson_obj_get(tool, "name")); + ASSERT_LT((int)strlen(serialized), + name && strcmp(name, "detect_changes") == 0 ? 4096 : 3200); free(serialized); } yyjson_doc_free(doc); @@ -6048,9 +6050,9 @@ TEST(tool_check_index_coverage_freshness_uses_indexer_mtime_source_issue1714) { ASSERT_EQ(cbm_path_info_utf8(source_path, &info), 0); /* The hash exactly as the indexer writes it: same source, ns precision. */ - ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", info.mtime_ns, - info.size), - CBM_STORE_OK); + ASSERT_EQ( + cbm_store_upsert_file_hash(store, "test-project", "main.go", "", info.mtime_ns, info.size), + CBM_STORE_OK); /* format=json, like every other coverage test here: the DEFAULT response is * the compact table, in which a field name never appears. Keeping the @@ -6067,11 +6069,11 @@ TEST(tool_check_index_coverage_freshness_uses_indexer_mtime_source_issue1714) { /* A hash stored at seconds precision — what a stat-based reader previously * compared against — must NOT match an unchanged file: the comparison must * stay nanosecond-exact, or part of mtime resolution is silently dropped. */ - int64_t seconds_mtime_ns = (info.mtime_ns / (int64_t)CBM_NSEC_PER_SEC) * - (int64_t)CBM_NSEC_PER_SEC; + int64_t seconds_mtime_ns = + (info.mtime_ns / (int64_t)CBM_NSEC_PER_SEC) * (int64_t)CBM_NSEC_PER_SEC; if (seconds_mtime_ns != info.mtime_ns) { - ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", - seconds_mtime_ns, info.size), + ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", seconds_mtime_ns, + info.size), CBM_STORE_OK); response = cbm_mcp_handle_tool(srv, "check_index_coverage", "{\"project\":\"test-project\",\"paths\":[\"main.go\"]," @@ -15500,6 +15502,758 @@ TEST(detect_changes_zero_overlap_falls_back_issue1363) { PASS(); } +/* ── detect_changes: opt-in impact walk (edge_types / fixpoint / via) ── + * + * One git fixture for the tests below. `leaf` is the changed function; a call + * chain four deep sits on top of it, and `holder` only names it. */ +typedef struct { + char repo[512]; + cbm_mcp_server_t *srv; + char *project; +} dc_walk_fixture_t; + +#define DC_WALK_SRC_HEAD "def leaf():\n" +#define DC_WALK_SRC_TAIL \ + " return x\n" \ + "\n" \ + "\n" \ + "def mid1():\n" \ + " return leaf()\n" \ + "\n" \ + "\n" \ + "def mid2():\n" \ + " return mid1()\n" \ + "\n" \ + "\n" \ + "def mid3():\n" \ + " return mid2()\n" \ + "\n" \ + "\n" \ + "def top():\n" \ + " return mid3()\n" \ + "\n" \ + "\n" \ + "def holder():\n" \ + " f = leaf\n" \ + " return f\n" + +static bool dc_walk_fixture_open(dc_walk_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + snprintf(fx->repo, sizeof(fx->repo), "%s/cbm-detect-walk-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(fx->repo)) { + return false; + } + char src[600]; + snprintf(src, sizeof(src), "%s/mod.py", fx->repo); + if (th_write_file(src, DC_WALK_SRC_HEAD " x = 1\n" DC_WALK_SRC_TAIL) != 0) { + return false; + } + /* Same git invocation rules as the #1363 fixture above. */ + char cmd[1200]; + const char *steps[] = {"init -q", "add -A", "commit -q -m init"}; + for (size_t s = 0; s < sizeof(steps) / sizeof(steps[0]); s++) { + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" -c user.name=t -c user.email=t@t.io -c init.defaultBranch=main " + "-c commit.gpgsign=false %s", + fx->repo, steps[s]); + if (system(cmd) != 0) { + return false; + } + } + fx->srv = cbm_mcp_server_new(NULL); + if (!fx->srv) { + return false; + } + char idx_args[700]; + snprintf(idx_args, sizeof(idx_args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", fx->repo); + char *idx_resp = cbm_mcp_handle_tool(fx->srv, "index_repository", idx_args); + free(idx_resp); + /* The change: one line inside leaf(), line count unchanged. */ + if (th_write_file(src, DC_WALK_SRC_HEAD " x = 11\n" DC_WALK_SRC_TAIL) != 0) { + return false; + } + fx->project = cbm_project_name_from_path(fx->repo); + return fx->project != NULL; +} + +static void dc_walk_fixture_close(dc_walk_fixture_t *fx) { + free(fx->project); + if (fx->srv) { + cbm_mcp_server_free(fx->srv); + } + if (fx->repo[0]) { + th_rmtree(fx->repo); + } +} + +/* detect_changes on the fixture; `extra` is appended to the argument object + * (start it with a comma). Returns the tool's text content. */ +static char *dc_walk_call(dc_walk_fixture_t *fx, const char *extra) { + char args[900]; + snprintf(args, sizeof(args), "{\"project\":\"%s\"%s}", fx->project, extra ? extra : ""); + char *resp = cbm_mcp_handle_tool(fx->srv, "detect_changes", args); + char *text = resp ? extract_text_content(resp) : NULL; + free(resp); + return text; +} + +/* The impacted row whose qualified name ends in `.name`, from a format:"json" + * answer. NULL when the row is absent. */ +static yyjson_val *dc_walk_row(yyjson_doc *doc, const char *name) { + yyjson_val *rows = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), "impacted") : NULL; + size_t want = strlen(name); + size_t idx; + size_t max; + yyjson_val *row; + yyjson_arr_foreach(rows, idx, max, row) { + const char *qn = yyjson_get_str(yyjson_obj_get(row, "qn")); + size_t len = qn ? strlen(qn) : 0; + if (len > want && qn[len - want - 1U] == '.' && strcmp(qn + len - want, name) == 0) { + return row; + } + } + return NULL; +} + +static int dc_walk_total(yyjson_doc *doc) { + return doc ? (int)yyjson_get_int(yyjson_obj_get(yyjson_doc_get_root(doc), "impacted_total")) + : -1; +} + +/* The default answer stops at `depth` (2). fixpoint:true walks until nothing + * new is reached, and spelling the new parameters out as false changes not one + * byte of the default answer. */ +/* Seed rows describe the actual walk inputs and page independently. */ +TEST(detect_changes_seed_list_reports_hunk_without_walk) { + dc_walk_fixture_t fx; + ASSERT_TRUE(dc_walk_fixture_open(&fx)); + char *text = dc_walk_call(&fx, ",\"format\":\"json\",\"scope\":\"files\",\"seeds\":\"list\""); + ASSERT_NOT_NULL(text); + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "seed_total")), 1); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "seed_symbols")), 1); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "seed_returned")), 1); + ASSERT_EQ(dc_walk_total(doc), 0); + yyjson_val *row = yyjson_arr_get_first(yyjson_obj_get(root, "seeds")); + ASSERT_NOT_NULL(row); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(row, "seed_reason")), "hunk"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(row, "file")), "mod.py"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(row, "label")), "Function"); + ASSERT_NOT_NULL(strstr(yyjson_get_str(yyjson_obj_get(row, "qn")), ".leaf")); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(row, "start")), 1); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(row, "end")), 3); + yyjson_doc_free(doc); + free(text); + const char *formats[] = {"json", "tree"}; + for (int i = 0; i < 2; i++) { + char extra[200]; + snprintf(extra, sizeof(extra), ",\"format\":\"%s\"", formats[i]); + char *normal = dc_walk_call(&fx, extra); + snprintf(extra, sizeof(extra), + ",\"format\":\"%s\",\"seeds\":\"none\",\"seed_filter\":\"all\"", formats[i]); + char *explicit_defaults = dc_walk_call(&fx, extra); + ASSERT_STR_EQ(normal, explicit_defaults); + free(normal); + free(explicit_defaults); + } + dc_walk_fixture_close(&fx); + PASS(); +} + +TEST(detect_changes_seed_pages_bind_semantics_and_snapshot) { + dc_walk_fixture_t fx; + ASSERT_TRUE(dc_walk_fixture_open(&fx)); + char path[600]; + snprintf(path, sizeof(path), "%s/mod.py", fx.repo); + /* No definition overlaps this insertion: whole-file fallback. */ + ASSERT_EQ(th_write_file(path, "import os\n" DC_WALK_SRC_HEAD " x = 1\n" DC_WALK_SRC_TAIL), + 0); + /* Move indexed definitions past the import so this is truly zero overlap. */ + cbm_store_t *store = cbm_store_open(fx.project); + ASSERT_NOT_NULL(store); + cbm_node_t *nodes = NULL; + int count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_file(store, fx.project, "mod.py", &nodes, &count), + CBM_STORE_OK); + for (int i = 0; i < count; i++) { + nodes[i].start_line += 1; + nodes[i].end_line += 1; + ASSERT_GT(cbm_store_upsert_node(store, &nodes[i]), 0); + } + cbm_store_free_nodes(nodes, count); + cbm_store_close(store); + const char *base = ",\"format\":\"json\",\"scope\":\"files\",\"seeds\":\"list\""; + char extra[500]; + snprintf(extra, sizeof(extra), "%s,\"seed_limit\":2", base); + char *first = dc_walk_call(&fx, extra); + yyjson_doc *doc = yyjson_read(first, strlen(first), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "seed_total")), 6); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "seed_returned")), 2); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(root, "seed_has_more"))); + const char *cursor = yyjson_get_str(yyjson_obj_get(root, "seed_next_cursor")); + ASSERT_NOT_NULL(cursor); + yyjson_val *rows = yyjson_obj_get(root, "seeds"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get_first(rows), "seed_reason")), + "whole_file"); + const char *last = yyjson_get_str(yyjson_obj_get(yyjson_arr_get(rows, 1), "qn")); + snprintf(extra, sizeof(extra), "%s,\"seed_limit\":3,\"seed_cursor\":\"%s\"", base, cursor); + char *next = dc_walk_call(&fx, extra); + yyjson_doc *ndoc = yyjson_read(next, strlen(next), 0); + ASSERT_NOT_NULL(ndoc); + yyjson_val *nroot = yyjson_doc_get_root(ndoc); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(nroot, "seed_returned")), 3); + const char *next_qn = + yyjson_get_str(yyjson_obj_get(yyjson_arr_get_first(yyjson_obj_get(nroot, "seeds")), "qn")); + ASSERT_LT(strcmp(last, next_qn), 0); + snprintf(extra, sizeof(extra), "%s,\"seed_limit\":3,\"seed_offset\":2", base); + char *offset = dc_walk_call(&fx, extra); + ASSERT_STR_EQ(next, offset); + free(offset); + yyjson_doc_free(ndoc); + free(next); + const char *mismatches[] = {",\"seed_filter\":\"product\"", ",\"seed_offset\":1", + ",\"depth\":3"}; + for (int i = 0; i < 3; i++) { + snprintf(extra, sizeof(extra), "%s%s,\"seed_cursor\":\"%s\"", base, mismatches[i], cursor); + char *bad = dc_walk_call(&fx, extra); + ASSERT_NOT_NULL(strstr(bad, "cursor_params_mismatch")); + free(bad); + } + snprintf(extra, sizeof(extra), "%s,\"seed_limit\":0", base); + char *zero = dc_walk_call(&fx, extra); + ASSERT_NOT_NULL(strstr(zero, "seed_continuation_requires_positive_limit")); + free(zero); + + int token_budget = (int)((strlen(first) + 3) / 4); + snprintf(extra, sizeof(extra), "%s,\"max_output_tokens\":%d", base, token_budget); + char *budget = dc_walk_call(&fx, extra); + ASSERT_LTE(strlen(budget), (size_t)token_budget * 4); + yyjson_doc *bdoc = yyjson_read(budget, strlen(budget), 0); + ASSERT_NOT_NULL(bdoc); + yyjson_val *broot = yyjson_doc_get_root(bdoc); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(broot, "seed_total")), 6); + int returned = (int)yyjson_get_int(yyjson_obj_get(broot, "seed_returned")); + ASSERT_LT(returned, 6); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(broot, "seed_has_more"))); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(broot, "truncation_reason")), "output_budget"); + ASSERT_NOT_NULL(yyjson_obj_get(broot, returned ? "seed_next_cursor" + : "seed_continuation_requires_higher_budget")); + yyjson_doc_free(bdoc); + free(budget); + char *tree = dc_walk_call( + &fx, ",\"format\":\"tree\",\"scope\":\"files\",\"seeds\":\"list\",\"seed_limit\":1"); + ASSERT_NOT_NULL(strstr(tree, "seed_total: 6")); + ASSERT_NOT_NULL(strstr(tree, "seed_returned: 1")); + ASSERT_NOT_NULL(strstr(tree, "seed_next_cursor:")); + ASSERT_NOT_NULL(strstr(tree, "whole_file")); + free(tree); + ASSERT_EQ(th_write_file(path, "import sys\n" DC_WALK_SRC_HEAD " x = 1\n" DC_WALK_SRC_TAIL), + 0); + snprintf(extra, sizeof(extra), "%s,\"seed_cursor\":\"%s\"", base, cursor); + char *stale = dc_walk_call(&fx, extra); + ASSERT_NOT_NULL(strstr(stale, "snapshot_changed")); + free(stale); + yyjson_doc_free(doc); + free(first); + dc_walk_fixture_close(&fx); + PASS(); +} + +TEST(detect_changes_product_filter_does_not_inflate_fallback) { + dc_walk_fixture_t fx; + ASSERT_TRUE(dc_walk_fixture_open(&fx)); + cbm_store_t *store = cbm_store_open(fx.project); + ASSERT_NOT_NULL(store); + cbm_node_t *nodes = NULL; + int count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_file(store, fx.project, "mod.py", &nodes, &count), + CBM_STORE_OK); + int leaf = -1; + for (int i = 0; i < count; i++) + if (strcmp(nodes[i].name, "leaf") == 0) + leaf = i; + ASSERT_GTE(leaf, 0); + const char *props[] = {"{\"is_test\":true}", "{\"test_role\":\"case\"}", + "{\"test_role\":\"suite\"}", "{\"test_role\":\"helper\"}"}; + const char *saved = nodes[leaf].properties_json; + for (int i = 0; i < 4; i++) { + nodes[leaf].properties_json = props[i]; + ASSERT_GT(cbm_store_upsert_node(store, &nodes[leaf]), 0); + char *text = dc_walk_call( + &fx, ",\"format\":\"json\",\"seeds\":\"list\",\"seed_filter\":\"product\""); + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_NOT_NULL(yyjson_obj_get(root, "seed_total")); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "seed_total")), 0); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "seed_symbols")), 0); + ASSERT_EQ(dc_walk_total(doc), 0); + yyjson_doc_free(doc); + free(text); + } + nodes[leaf].properties_json = saved; + cbm_store_free_nodes(nodes, count); + cbm_store_close(store); + dc_walk_fixture_close(&fx); + PASS(); +} + +TEST(detect_changes_seed_params_reject_misuse) { + dc_walk_fixture_t fx; + ASSERT_TRUE(dc_walk_fixture_open(&fx)); + const char *extras[] = { + ",\"seeds\":\"yes\"", ",\"seeds\":1", ",\"seed_filter\":\"tests\"", + ",\"seed_limit\":-1", ",\"seed_limit\":1001", ",\"seed_limit\":1.5", + ",\"seed_offset\":2147483648", ",\"seed_cursor\":7", ",\"seed_cursor\":\"invalid\""}; + for (size_t i = 0; i < sizeof(extras) / sizeof(extras[0]); i++) { + char args[900]; + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"seeds\":\"list\"%s}", fx.project, + extras[i]); + /* Avoid duplicate seeds keys in the enum/type cases. */ + if (i < 2) + snprintf(args, sizeof(args), "{\"project\":\"%s\"%s}", fx.project, extras[i]); + char *response = cbm_mcp_handle_tool(fx.srv, "detect_changes", args); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "\"isError\":true")); + free(response); + } + dc_walk_fixture_close(&fx); + PASS(); +} + +TEST(detect_changes_product_filter_uses_project_config_and_override) { + dc_walk_fixture_t fx; + ASSERT_TRUE(dc_walk_fixture_open(&fx)); + char path[600]; + snprintf(path, sizeof(path), "%s/.codebase-memory.json", fx.repo); + ASSERT_EQ( + th_write_file(path, + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[\"mod.py\"]}}}"), + 0); + char *text = + dc_walk_call(&fx, ",\"format\":\"json\",\"seeds\":\"list\",\"seed_filter\":\"product\""); + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(yyjson_doc_get_root(doc), "seed_total")), 0); + yyjson_doc_free(doc); + free(text); + snprintf(path, sizeof(path), "%s/selection.json", fx.repo); + ASSERT_EQ( + th_write_file(path, + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[\"spec/**\"]}}}"), + 0); + text = dc_walk_call(&fx, ",\"format\":\"json\",\"seeds\":\"list\",\"seed_filter\":\"product\"," + "\"config_path\":\"selection.json\""); + doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(yyjson_doc_get_root(doc), "seed_total")), 1); + yyjson_doc_free(doc); + free(text); + const char *invalid[] = { + ",\"config_path\":\"missing.json\"", + ",\"config_path\":7", + ",\"config_path\":\"\"", + ",\"config_path\":\"selection.json\\u0000ignored\"", + }; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + char args[1200]; + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"seed_filter\":\"product\"%s}", + fx.project, invalid[i]); + char *response = cbm_mcp_handle_tool(fx.srv, "detect_changes", args); + ASSERT_NOT_NULL(strstr(response, "\"isError\":true")); + free(response); + } + dc_walk_fixture_close(&fx); + PASS(); +} + +TEST(detect_changes_fixpoint_walks_past_depth) { + dc_walk_fixture_t fx; + if (!dc_walk_fixture_open(&fx)) { + dc_walk_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *plain = dc_walk_call(&fx, ",\"format\":\"json\""); + char *spelled = dc_walk_call(&fx, ",\"format\":\"json\",\"fixpoint\":false,\"via\":false"); + char *fixed = dc_walk_call(&fx, ",\"format\":\"json\",\"fixpoint\":true"); + ASSERT_NOT_NULL(plain); + ASSERT_NOT_NULL(spelled); + ASSERT_NOT_NULL(fixed); + ASSERT_STR_EQ(plain, spelled); + + yyjson_doc *pdoc = yyjson_read(plain, strlen(plain), 0); + ASSERT_NOT_NULL(pdoc); + ASSERT_EQ(dc_walk_total(pdoc), 2); + ASSERT_NOT_NULL(dc_walk_row(pdoc, "mid2")); + ASSERT_NULL(dc_walk_row(pdoc, "mid3")); + ASSERT_NULL(yyjson_obj_get(yyjson_doc_get_root(pdoc), "fixpoint")); + ASSERT_NULL(yyjson_obj_get(yyjson_doc_get_root(pdoc), "edge_types")); + + yyjson_doc *fdoc = yyjson_read(fixed, strlen(fixed), 0); + ASSERT_NOT_NULL(fdoc); + yyjson_val *froot = yyjson_doc_get_root(fdoc); + ASSERT_EQ(dc_walk_total(fdoc), 4); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(froot, "impacted_total_relation")), "eq"); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(froot, "fixpoint"))); + ASSERT_EQ(yyjson_arr_size(yyjson_obj_get(froot, "edge_types")), 1); + ASSERT_STR_EQ(yyjson_get_str(yyjson_arr_get_first(yyjson_obj_get(froot, "edge_types"))), + "CALLS"); + yyjson_val *top = dc_walk_row(fdoc, "top"); + ASSERT_NOT_NULL(top); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(top, "hop")), 4); + /* The changed function is the seed, never its own impact; a name is not a + * call. */ + ASSERT_NULL(dc_walk_row(fdoc, "leaf")); + ASSERT_NULL(dc_walk_row(fdoc, "holder")); + /* Without `via` the rows keep their four columns. */ + ASSERT_NULL(yyjson_obj_get(top, "via_qn")); + + /* fixpoint ignores depth: the fixpoint holds every bounded answer. */ + char *deep = dc_walk_call(&fx, ",\"format\":\"json\",\"fixpoint\":true,\"depth\":1"); + ASSERT_NOT_NULL(deep); + yyjson_doc *ddoc = yyjson_read(deep, strlen(deep), 0); + ASSERT_EQ(dc_walk_total(ddoc), 4); + yyjson_doc_free(ddoc); + free(deep); + + /* A bounded walk through the same engine gives the bounded answer. */ + char *bounded = dc_walk_call(&fx, ",\"format\":\"json\",\"edge_types\":[\"CALLS\"]," + "\"depth\":3"); + ASSERT_NOT_NULL(bounded); + yyjson_doc *bdoc = yyjson_read(bounded, strlen(bounded), 0); + ASSERT_EQ(dc_walk_total(bdoc), 3); + ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(yyjson_doc_get_root(bdoc), "fixpoint"))); + ASSERT_NOT_NULL(dc_walk_row(bdoc, "mid3")); + ASSERT_NULL(dc_walk_row(bdoc, "top")); + + yyjson_doc_free(bdoc); + yyjson_doc_free(fdoc); + yyjson_doc_free(pdoc); + free(bounded); + free(fixed); + free(spelled); + free(plain); + dc_walk_fixture_close(&fx); + PASS(); +} + +/* edge_types widens what counts as "depends on": a function that only names + * the changed one is reached over CALL_REFERENCE, never over CALLS. */ +TEST(detect_changes_edge_types_reach_references) { + dc_walk_fixture_t fx; + if (!dc_walk_fixture_open(&fx)) { + dc_walk_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *calls = dc_walk_call(&fx, ",\"format\":\"json\",\"edge_types\":[\"CALLS\"]"); + char *both = + dc_walk_call(&fx, ",\"format\":\"json\",\"edge_types\":[\"CALLS\",\"CALL_REFERENCE\"]"); + ASSERT_NOT_NULL(calls); + ASSERT_NOT_NULL(both); + yyjson_doc *cdoc = yyjson_read(calls, strlen(calls), 0); + yyjson_doc *bdoc = yyjson_read(both, strlen(both), 0); + ASSERT_NOT_NULL(cdoc); + ASSERT_NOT_NULL(bdoc); + ASSERT_NULL(dc_walk_row(cdoc, "holder")); + yyjson_val *holder = dc_walk_row(bdoc, "holder"); + ASSERT_NOT_NULL(holder); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(holder, "hop")), 1); + ASSERT_NOT_NULL(dc_walk_row(bdoc, "mid1")); + ASSERT_EQ(yyjson_arr_size(yyjson_obj_get(yyjson_doc_get_root(bdoc), "edge_types")), 2); + + yyjson_doc_free(bdoc); + yyjson_doc_free(cdoc); + free(both); + free(calls); + dc_walk_fixture_close(&fx); + PASS(); +} + +/* via:true says how each row was reached: the parent one hop closer and the + * edge type that led there. Both encodings carry it. */ +TEST(detect_changes_via_names_the_parent) { + dc_walk_fixture_t fx; + if (!dc_walk_fixture_open(&fx)) { + dc_walk_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *json = dc_walk_call(&fx, ",\"format\":\"json\",\"via\":true,\"fixpoint\":true," + "\"edge_types\":[\"CALLS\",\"CALL_REFERENCE\"]"); + ASSERT_NOT_NULL(json); + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *mid1 = dc_walk_row(doc, "mid1"); + yyjson_val *mid2 = dc_walk_row(doc, "mid2"); + yyjson_val *holder = dc_walk_row(doc, "holder"); + ASSERT_NOT_NULL(mid1); + ASSERT_NOT_NULL(mid2); + ASSERT_NOT_NULL(holder); + /* A row one hop out names the seed, though seeds are not rows. */ + const char *leaf_qn = yyjson_get_str(yyjson_obj_get(mid1, "via_qn")); + ASSERT_NOT_NULL(leaf_qn); + ASSERT_NOT_NULL(strstr(leaf_qn, ".leaf")); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(mid1, "via_edge")), "CALLS"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(mid2, "via_qn")), + yyjson_get_str(yyjson_obj_get(mid1, "qn"))); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(mid2, "via_edge")), "CALLS"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(holder, "via_qn")), leaf_qn); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(holder, "via_edge")), "CALL_REFERENCE"); + + char *tree = dc_walk_call(&fx, ",\"via\":true"); + ASSERT_NOT_NULL(tree); + ASSERT_NOT_NULL(strstr(tree, "via_qn")); + ASSERT_NOT_NULL(strstr(tree, "via_edge")); + char *plain_tree = dc_walk_call(&fx, ""); + ASSERT_NOT_NULL(plain_tree); + ASSERT_NULL(strstr(plain_tree, "via_qn")); + + free(plain_tree); + free(tree); + yyjson_doc_free(doc); + free(json); + dc_walk_fixture_close(&fx); + PASS(); +} + +/* Every misuse is a teaching error; none of them is corrected silently, because + * a walk over the wrong edges reads as "nothing depends on this". */ +TEST(detect_changes_walk_params_teach_on_misuse) { + dc_walk_fixture_t fx; + if (!dc_walk_fixture_open(&fx)) { + dc_walk_fixture_close(&fx); + FAIL("fixture setup failed"); + } + static const struct { + const char *extra; + const char *expect; + } cases[] = { + {",\"edge_types\":[\"CALL\"]", "unknown edge type \"CALL\""}, + {",\"edge_types\":[\"CALL\"]", "CALLS"}, /* the error lists what the index has */ + {",\"edge_types\":[]", "edge_types must be"}, + {",\"edge_types\":\"CALLS\"", "edge_types must be"}, + {",\"edge_types\":[\"CALLS\",7]", "edge_types must be"}, + {",\"edge_types\":[\"CALLS\",\"CALLS\"]", "listed twice"}, + {",\"fixpoint\":\"yes\"", "fixpoint must be true or false"}, + {",\"via\":1", "via must be true or false"}, + {",\"fixpoint\":true,\"direction\":\"outbound\"", "direction \"inbound\""}, + {",\"via\":true,\"direction\":\"both\"", "direction \"inbound\""}, + {",\"edge_types\":[\"CALLS\"],\"direction\":\"outbound\"", "direction \"inbound\""}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char args[900]; + snprintf(args, sizeof(args), "{\"project\":\"%s\"%s}", fx.project, cases[i].extra); + char *resp = cbm_mcp_handle_tool(fx.srv, "detect_changes", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + char *text = extract_text_content(resp); + ASSERT_NOT_NULL(text); + if (!strstr(text, cases[i].expect)) { + printf(" case %zu: expected \"%s\" in: %s\n", i, cases[i].expect, text); + } + ASSERT_NOT_NULL(strstr(text, cases[i].expect)); + free(text); + free(resp); + } + dc_walk_fixture_close(&fx); + PASS(); +} + +/* A cursor continues the question it was minted for. The walk parameters are + * part of that question. */ +TEST(detect_changes_cursor_bound_to_walk_params) { + dc_walk_fixture_t fx; + if (!dc_walk_fixture_open(&fx)) { + dc_walk_fixture_close(&fx); + FAIL("fixture setup failed"); + } + char *first = dc_walk_call(&fx, ",\"format\":\"json\",\"fixpoint\":true,\"limit\":1"); + ASSERT_NOT_NULL(first); + yyjson_doc *doc = yyjson_read(first, strlen(first), 0); + ASSERT_NOT_NULL(doc); + const char *cursor = + yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "impacted_next_cursor")); + ASSERT_NOT_NULL(cursor); + + char extra[400]; + snprintf(extra, sizeof(extra), + ",\"format\":\"json\",\"fixpoint\":true,\"limit\":1,\"impact_cursor\":\"%s\"", cursor); + char *second = dc_walk_call(&fx, extra); + ASSERT_NOT_NULL(second); + yyjson_doc *sdoc = yyjson_read(second, strlen(second), 0); + ASSERT_NOT_NULL(sdoc); + /* Rows come in (hop, qualified name) order: the second page is hop 2. */ + ASSERT_EQ(dc_walk_total(sdoc), 4); + yyjson_val *mid2 = dc_walk_row(sdoc, "mid2"); + ASSERT_NOT_NULL(mid2); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(mid2, "hop")), 2); + + static const char *const other_questions[] = { + ",\"format\":\"json\",\"limit\":1", + ",\"format\":\"json\",\"fixpoint\":true,\"via\":true,\"limit\":1", + ",\"format\":\"json\",\"fixpoint\":true,\"limit\":1,\"edge_types\":[\"CALL_REFERENCE\"]", + }; + for (size_t i = 0; i < sizeof(other_questions) / sizeof(other_questions[0]); i++) { + snprintf(extra, sizeof(extra), "%s,\"impact_cursor\":\"%s\"", other_questions[i], cursor); + char *refused = dc_walk_call(&fx, extra); + ASSERT_NOT_NULL(refused); + ASSERT_NOT_NULL(strstr(refused, "cursor_params_mismatch")); + free(refused); + } + + yyjson_doc_free(sdoc); + free(second); + yyjson_doc_free(doc); + free(first); + dc_walk_fixture_close(&fx); + PASS(); +} + +/* Golden: a request that names none of the walk parameters gets the answer it + * got before they existed, byte for byte, in both encodings. The project name + * and every qualified name are fixed, so the only varying bytes are the commit + * id of the fixture, which is replaced before comparing. */ +static void dc_golden_blank_oids(char *text) { + for (char *p = text; *p;) { + size_t run = 0; + while (isxdigit((unsigned char)p[run])) { + run++; + } + if (run == 40) { + memset(p, '#', run); + } + p += run ? run : 1; + } +} + +TEST(detect_changes_default_output_is_byte_identical_golden) { + char repo[CBM_SZ_4K]; + snprintf(repo, sizeof(repo), "%s/cbm-detect-golden-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(repo)); + char cache[CBM_SZ_4K]; + snprintf(cache, sizeof(cache), "%s/cbm-detect-golden-cache-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + char src[CBM_SZ_4K]; + snprintf(src, sizeof(src), "%s/mod.py", repo); + ASSERT_EQ(th_write_file(src, DC_WALK_SRC_HEAD " x = 1\n" DC_WALK_SRC_TAIL), 0); + + const char *const init_args[] = {"-c", "init.defaultBranch=main", "init", "-q", NULL}; + const char *const add_args[] = {"add", "-A", NULL}; + const char *const commit_args[] = { + "-c", "user.name=cbm-test", + "-c", "user.email=cbm-test@example.invalid", + "-c", "commit.gpgsign=false", + "commit", "-q", + "-m", "fixture", + NULL, + }; + ASSERT_EQ(mcp_test_git(repo, init_args), 0); + ASSERT_EQ(mcp_test_git(repo, add_args), 0); + ASSERT_EQ(mcp_test_git(repo, commit_args), 0); + ASSERT_EQ(th_write_file(src, DC_WALK_SRC_HEAD " x = 11\n" DC_WALK_SRC_TAIL), 0); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "detect-golden"; + ASSERT_EQ(cbm_store_upsert_project(store, project, repo), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, project); + + /* The graph of DC_WALK_SRC: leaf <- mid1 <- mid2 <- mid3 <- top. */ + static const struct { + const char *name; + int start; + int end; + } defs[] = { + {"leaf", 1, 3}, {"mid1", 6, 7}, {"mid2", 10, 11}, {"mid3", 14, 15}, {"top", 18, 19}}; + int64_t ids[5]; + for (int i = 0; i < 5; i++) { + char qn[64]; + snprintf(qn, sizeof(qn), "detect-golden.mod.%s", defs[i].name); + cbm_node_t node = {.project = project, + .label = "Function", + .name = defs[i].name, + .qualified_name = qn, + .file_path = "mod.py", + .start_line = defs[i].start, + .end_line = defs[i].end}; + ids[i] = cbm_store_upsert_node(store, &node); + ASSERT_GT(ids[i], 0); + } + for (int i = 1; i < 5; i++) { + cbm_edge_t edge = { + .project = project, .source_id = ids[i], .target_id = ids[i - 1], .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(store, &edge), 0); + } + + char *tree_response = cbm_mcp_handle_tool( + srv, "detect_changes", "{\"project\":\"detect-golden\",\"base_branch\":\"HEAD\"}"); + char *tree = extract_text_content(tree_response); + char *json_response = cbm_mcp_handle_tool( + srv, "detect_changes", + "{\"project\":\"detect-golden\",\"base_branch\":\"HEAD\",\"format\":\"json\"}"); + char *json = extract_text_content(json_response); + ASSERT_NOT_NULL(tree); + ASSERT_NOT_NULL(json); + dc_golden_blank_oids(tree); + dc_golden_blank_oids(json); + if (getenv("CBM_PRINT_DETECT_GOLDEN")) { + printf("GOLDEN-TREE<<%s>>\nGOLDEN-JSON<<%s>>\n", tree, json); + } + static const char *const golden_tree = "base: HEAD\n" + "merge_base: ########################################\n" + "direction: inbound\n" + "changed_total: 1\n" + "changed_returned: 1\n" + "changed_has_more: false\n" + "changed_files: 1 (cols: path)\n" + " mod.py\n" + "seed_symbols: 1\n" + "impacted_total: 2\n" + "impacted_total_relation: eq\n" + "impacted_shown: 2\n" + "impacted: 2 (cols: qn label file hop)\n" + " detect-golden.mod.mid1 Function mod.py 1\n" + " detect-golden.mod.mid2 Function mod.py 2\n" + "impacted_has_more: false\n" + "module_total: 1\n" + "module_total_relation: eq\n" + "module_returned: 1\n" + "module_has_more: false\n" + "impacted_modules: 1 (cols: module count)\n" + " mod.py 2\n"; + static const char *const golden_json = + "{\"base\":\"HEAD\",\"merge_base\":\"########################################\",\"dir" + "ection\":\"inbound\",\"changed_total\":1,\"changed_returned\":1,\"changed_has_more\"" + ":false,\"changed_files\":[\"mod.py\"],\"seed_symbols\":1,\"impacted_total\":2,\"impa" + "cted_total_relation\":\"eq\",\"impacted_shown\":2,\"impacted\":[{\"qn\":\"detect-gol" + "den.mod.mid1\",\"label\":\"Function\",\"file\":\"mod.py\",\"hop\":1},{\"qn\":\"detec" + "t-golden.mod.mid2\",\"label\":\"Function\",\"file\":\"mod.py\",\"hop\":2}],\"impacte" + "d_has_more\":false,\"module_total\":1,\"module_total_relation\":\"eq\",\"module_retu" + "rned\":1,\"module_has_more\":false,\"impacted_modules\":[{\"module\":\"mod.py\",\"co" + "unt\":2}],\"truncated\":false}"; + ASSERT_STR_EQ(tree, golden_tree); + ASSERT_STR_EQ(json, golden_json); + + free(json); + free(json_response); + free(tree); + free(tree_response); + cbm_mcp_server_free(srv); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + ASSERT_EQ(th_rmtree(cache), 0); + ASSERT_EQ(th_rmtree(repo), 0); + PASS(); +} + TEST(tool_ingest_traces_basic) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -21131,6 +21885,17 @@ SUITE(mcp) { RUN_TEST(detect_changes_node_in_hunks_overlap_issue1363); RUN_TEST(detect_changes_seeds_only_touched_symbol_issue1363); RUN_TEST(detect_changes_zero_overlap_falls_back_issue1363); + RUN_TEST(detect_changes_seed_list_reports_hunk_without_walk); + RUN_TEST(detect_changes_seed_pages_bind_semantics_and_snapshot); + RUN_TEST(detect_changes_product_filter_does_not_inflate_fallback); + RUN_TEST(detect_changes_seed_params_reject_misuse); + RUN_TEST(detect_changes_product_filter_uses_project_config_and_override); + RUN_TEST(detect_changes_fixpoint_walks_past_depth); + RUN_TEST(detect_changes_edge_types_reach_references); + RUN_TEST(detect_changes_via_names_the_parent); + RUN_TEST(detect_changes_walk_params_teach_on_misuse); + RUN_TEST(detect_changes_cursor_bound_to_walk_params); + RUN_TEST(detect_changes_default_output_is_byte_identical_golden); RUN_TEST(tool_ingest_traces_basic); RUN_TEST(tool_ingest_traces_empty); diff --git a/tests/test_pipeline_conventions.c b/tests/test_pipeline_conventions.c new file mode 100644 index 000000000..a851dbfda --- /dev/null +++ b/tests/test_pipeline_conventions.c @@ -0,0 +1,623 @@ +#include +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include "result_spill.h" +#include "yyjson/yyjson.h" +#include +#include + +#if !defined(CBM_INCREMENTAL_TEST_API) || !CBM_INCREMENTAL_TEST_API || \ + !defined(CBM_ENABLE_TEST_SEAMS) || !CBM_ENABLE_TEST_SEAMS +#error "pipeline_conventions requires the canonical test runner's existing test API defines" +#endif + +#define PC_PROJECT "pipelineconventions" +#define PC_REC(role_, macro_, arg_) \ + "{\"language\":\"c\",\"role\":\"" role_ "\",\"define_macro\":\"" macro_ "\",\"name_args\":[" arg_ "]}" +#define PC_CONFIG(arg_) "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[" \ + PC_REC("case", "CHECK", arg_) "," PC_REC("suite", "GROUP", "0") "]}}}" +static const char pc_config0[] = PC_CONFIG("0"); +static const char pc_config1[] = PC_CONFIG("1"); +static const char pc_empty_config[] = "{\"test_impact\":{\"version\":1,\"tests\":{}}}"; +static const char pc_cpp_config[] = "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[" + "{\"language\":\"cpp\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[0]}]}}}"; +static const char pc_legacy_source[] = + "void sink(void) {}\nvoid preserved(void) { sink(); }\n"; +static const char pc_source[] = + "void case_sink(void) {}\nvoid suite_sink(void) {}\n" + "CHECK(before, after) { case_sink(); }\nGROUP(group) { suite_sink(); }\n"; +/* Retain the old declarations: a graph-size drop must not satisfy the abort oracle. */ +static const char pc_bad_source[] = + "void sink(void) {}\nvoid preserved(void) { sink(); }\nCHECK() { sink(); }\n"; +static const char pc_cpp_source[] = + "#include \"engine.h\"\nCHECK(alpha) { Engine engine; engine.work(); }\n"; +static int pc_init_status; + +typedef struct { char home[1024], repo[1024], db[1024], config[1024], saved[1024]; } pc_fixture_t; +typedef struct { char generation[128]; } pc_snapshot_t; +typedef struct { const char *name; bool present; char value[4096]; } pc_env_value_t; +typedef struct { pc_env_value_t values[4]; bool saved; } pc_env_t; + +static bool pc_path(char *out, size_t cap, const char *base, const char *leaf) { + int n = snprintf(out, cap, "%s/%s", base, leaf); + return n > 0 && (size_t)n < cap; +} +static bool pc_write(const char *path, const char *text) { + FILE *f = cbm_fopen(path, "wb"); + if (!f) return false; + size_t size = strlen(text); + bool ok = fwrite(text, 1, size, f) == size; + return fclose(f) == 0 && ok; +} +static bool pc_source_write(pc_fixture_t *f, const char *leaf, const char *text) { + char path[1024]; + return pc_path(path, sizeof(path), f->repo, leaf) && pc_write(path, text); +} +static bool pc_copy(const char *from, const char *to) { + FILE *a = cbm_fopen(from, "rb"), *b = cbm_fopen(to, "wb"); + bool ok = a && b; + unsigned char bytes[4096]; + while (ok) { + size_t n = fread(bytes, 1, sizeof(bytes), a); + if (n && fwrite(bytes, 1, n, b) != n) { ok = false; break; } + if (n < sizeof(bytes)) { ok = !ferror(a); break; } + } + if (a && fclose(a) != 0) ok = false; + if (b && fclose(b) != 0) ok = false; + return ok; +} +static bool pc_files_equal(const char *left, const char *right) { + FILE *a = cbm_fopen(left, "rb"), *b = cbm_fopen(right, "rb"); + bool ok = a && b; + unsigned char x[4096], y[4096]; + while (ok) { + size_t nx = fread(x, 1, sizeof(x), a), ny = fread(y, 1, sizeof(y), b); + if (nx != ny || memcmp(x, y, nx) != 0) { ok = false; break; } + if (nx < sizeof(x)) { ok = !ferror(a) && !ferror(b); break; } + } + if (a && fclose(a) != 0) ok = false; + if (b && fclose(b) != 0) ok = false; + return ok; +} +static bool pc_fixture_open(pc_fixture_t *f, bool parallel) { + memset(f, 0, sizeof(*f)); + const char *home = th_mktempdir("cbm-pipeline-conventions"); + if (!home || strlen(home) >= sizeof(f->home)) return false; + memcpy(f->home, home, strlen(home) + 1); + bool ok = pc_path(f->repo, sizeof(f->repo), home, "repo") && + pc_path(f->db, sizeof(f->db), home, "published.db") && + pc_path(f->saved, sizeof(f->saved), home, "saved.db") && + pc_path(f->config, sizeof(f->config), f->repo, ".codebase-memory.json") && + th_mkdir_p(f->repo) == 0 && + pc_source_write(f, "aux.c", "void auxiliary(void) {}\n"); + /* The existing full driver needs >50 filtered files as well as >1 worker. */ + for (int i = 0; ok && parallel && i < 51; i++) { + char leaf[40], body[80]; + int a = snprintf(leaf, sizeof(leaf), "filler_%02d.c", i); + int b = snprintf(body, sizeof(body), "void filler_%02d(void) {}\n", i); + ok = a > 0 && (size_t)a < sizeof(leaf) && b > 0 && (size_t)b < sizeof(body) && + pc_source_write(f, leaf, body); + } + if (!ok) { (void)th_rmtree(f->home); f->home[0] = '\0'; } + return ok; +} +static bool pc_env_restore(pc_env_t *env) { + bool ok = true; + if (!env->saved) return true; + for (size_t i = 0; i < 4; i++) { + pc_env_value_t *v = &env->values[i]; + if (v->present) { if (cbm_setenv(v->name, v->value, 1) != 0) ok = false; } + else (void)cbm_unsetenv(v->name); + const char *now = getenv(v->name); + if (v->present ? (!now || strcmp(now, v->value) != 0) : now != NULL) ok = false; + } + env->saved = false; + return ok; +} +static bool pc_env_begin(pc_env_t *env, bool parallel) { + memset(env, 0, sizeof(*env)); + const char *names[] = {"CBM_WORKERS", "CBM_INDEX_SINGLE_THREAD", "CBM_DISABLE_LSP_CROSS", "CBM_MEM_SPILL"}; + for (size_t i = 0; i < 4; i++) { + env->values[i].name = names[i]; + const char *value = getenv(names[i]); + env->values[i].present = value != NULL; + if (value) { + if (strlen(value) >= sizeof(env->values[i].value)) return false; + memcpy(env->values[i].value, value, strlen(value) + 1); + } + } + env->saved = true; + bool ok = cbm_setenv("CBM_WORKERS", "2", 1) == 0; + if (parallel) (void)cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + else ok = cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1) == 0 && ok; + (void)cbm_unsetenv("CBM_DISABLE_LSP_CROSS"); + (void)cbm_unsetenv("CBM_MEM_SPILL"); + const char *single = getenv("CBM_INDEX_SINGLE_THREAD"); + ok = ok && cbm_default_worker_count(true) == 2 && + (parallel ? single == NULL : single && strcmp(single, "1") == 0) && + getenv("CBM_DISABLE_LSP_CROSS") == NULL && getenv("CBM_MEM_SPILL") == NULL; + if (!ok) (void)pc_env_restore(env); + return ok; +} +static bool pc_text(const char *a, const char *b) { return a && b && strcmp(a, b) == 0; } +static bool pc_role(const cbm_node_t *n, const char *role) { + if (!n || !n->properties_json) return false; + yyjson_doc *doc = yyjson_read(n->properties_json, strlen(n->properties_json), 0); + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *value = yyjson_is_obj(root) ? yyjson_obj_get(root, "test_role") : NULL; + bool ok = yyjson_is_obj(root) && (role ? yyjson_is_str(value) && + pc_text(yyjson_get_str(value), role) : value == NULL); + yyjson_doc_free(doc); + return ok; +} +static bool pc_node(cbm_store_t *s, const char *qn, const char *name, const char *file, + const char *role, cbm_node_t *out) { + memset(out, 0, sizeof(*out)); + return cbm_store_find_node_by_qn(s, PC_PROJECT, qn, out) == CBM_STORE_OK && + pc_text(out->qualified_name, qn) && pc_text(out->name, name) && + pc_text(out->file_path, file) && pc_role(out, role); +} +static bool pc_call(cbm_store_t *s, int64_t source, int64_t target) { + cbm_edge_t *edges = NULL; int count = 0; + bool read = cbm_store_find_edges_by_source_type(s, source, "CALLS", &edges, &count) == CBM_STORE_OK; + bool found = false; + if (read) for (int i = 0; i < count; i++) + if (edges[i].source_id == source && edges[i].target_id == target && pc_text(edges[i].type, "CALLS")) found = true; + cbm_store_free_edges(edges, count); + return read && found; +} +static bool pc_c_graph(const char *db, bool after) { + cbm_store_t *s = cbm_store_open_path_query(db); + if (!s) return false; + cbm_node_t c = {0}, g = {0}, cs = {0}, gs = {0}; + bool ok = pc_node(s, after ? PC_PROJECT ".unit.CHECK_after" : PC_PROJECT ".unit.CHECK_before", + after ? "CHECK_after" : "CHECK_before", "unit.c", "case", &c) && + pc_node(s, PC_PROJECT ".unit.GROUP_group", "GROUP_group", "unit.c", "suite", &g) && + pc_node(s, PC_PROJECT ".unit.case_sink", "case_sink", "unit.c", NULL, &cs) && + pc_node(s, PC_PROJECT ".unit.suite_sink", "suite_sink", "unit.c", NULL, &gs) && + pc_call(s, c.id, cs.id) && pc_call(s, g.id, gs.id); + cbm_node_free_fields(&c); cbm_node_free_fields(&g); cbm_node_free_fields(&cs); cbm_node_free_fields(&gs); + cbm_store_close(s); return ok; +} +static bool pc_ordinary_graph(const char *db, const char *name) { + cbm_store_t *s = cbm_store_open_path_query(db); + if (!s) return false; + char qn[256]; int n = snprintf(qn, sizeof(qn), PC_PROJECT ".unit.%s", name); + cbm_node_t owner = {0}, sink = {0}; + bool ok = n > 0 && (size_t)n < sizeof(qn) && pc_node(s, qn, name, "unit.c", NULL, &owner) && + pc_node(s, PC_PROJECT ".unit.sink", "sink", "unit.c", NULL, &sink) && pc_call(s, owner.id, sink.id); + cbm_node_free_fields(&owner); cbm_node_free_fields(&sink); cbm_store_close(s); return ok; +} +static bool pc_no_roles(const char *db) { + cbm_store_t *s = cbm_store_open_path_query(db); + cbm_node_t *nodes = NULL; int count = 0; + bool ok = s && cbm_store_find_nodes(s, PC_PROJECT, &nodes, &count) == CBM_STORE_OK && count > 0; + if (ok) for (int i = 0; i < count; i++) if (!pc_role(&nodes[i], NULL)) ok = false; + cbm_store_free_nodes(nodes, count); cbm_store_close(s); return ok; +} +static bool pc_generation(const char *db, char out[128]) { + cbm_store_t *s = cbm_store_open_path_query(db); + bool ok = s && cbm_store_generation(s, out, 128) == CBM_STORE_OK && out[0] && strcmp(out, "legacy") != 0; + cbm_store_close(s); return ok; +} +static bool pc_snapshot(pc_fixture_t *f, pc_snapshot_t *out) { + return pc_generation(f->db, out->generation) && pc_copy(f->db, f->saved); +} +static bool pc_preserved(pc_fixture_t *f, const pc_snapshot_t *before) { + char now[128]; + return pc_files_equal(f->db, f->saved) && pc_generation(f->db, now) && + strcmp(now, before->generation) == 0 && pc_ordinary_graph(f->db, "preserved"); +} +static bool pc_source_hash(const char *db, char out[65]) { + cbm_store_t *s = cbm_store_open_path_query(db); cbm_file_hash_t h = {0}; + bool ok = s && cbm_store_get_file_hash(s, PC_PROJECT, "unit.c", &h) == CBM_STORE_OK && h.sha256 && strlen(h.sha256) == 64; + if (ok) memcpy(out, h.sha256, 65); + cbm_store_clear_file_hash(&h); cbm_store_close(s); return ok; +} +static bool pc_missing(const char *db, const char *qn) { + cbm_store_t *s = cbm_store_open_path_query(db); cbm_node_t n = {0}; + bool ok = s && cbm_store_find_node_by_qn(s, PC_PROJECT, qn, &n) == CBM_STORE_NOT_FOUND; + cbm_node_free_fields(&n); cbm_store_close(s); return ok; +} +/* A direct discovery witness for both conditions of the existing full-driver gate. + * This is not a runtime worker/route observer; the pipeline handle is opaque. */ +static bool pc_parallel_inputs(pc_fixture_t *f, cbm_index_mode_t mode) { + cbm_file_info_t *files = NULL; int count = 0; + cbm_discover_opts_t opts = {.mode = mode}; + bool ok = cbm_default_worker_count(true) == 2 && getenv("CBM_INDEX_SINGLE_THREAD") == NULL && + cbm_discover(f->repo, &opts, &files, &count) == 0 && count > 50; + for (int n = 0; ok && n < 51; n++) { + char leaf[40]; (void)snprintf(leaf, sizeof(leaf), "filler_%02d.c", n); + int matches = 0; + for (int i = 0; i < count; i++) + if (pc_text(files[i].rel_path, leaf) && files[i].language == CBM_LANG_C) matches++; + ok = matches == 1; + } + cbm_discover_free(files, count); return ok; +} +static bool pc_snapshot_equal(pc_fixture_t *f, const pc_snapshot_t *before) { + char now[128]; + return pc_files_equal(f->db, f->saved) && pc_generation(f->db, now) && + strcmp(now, before->generation) == 0; +} +static int pc_run(pc_fixture_t *f, cbm_index_mode_t mode, bool legacy, unsigned faults, + void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx, + cbm_incremental_route_t *route) { + cbm_pipeline_incremental_test_reset_faults(); + if (getenv("CBM_INDEX_SINGLE_THREAD") == NULL && !pc_parallel_inputs(f, mode)) return -997; + cbm_pipeline_t *p = cbm_pipeline_new(f->repo, f->db, mode); + if (!p) return -999; + bool named = cbm_pipeline_set_project_name(p, PC_PROJECT); + cbm_pipeline_set_persistence(p, false); + if (hook) cbm_pipeline_set_before_publish_hook_for_tests(p, hook, ctx); + cbm_pipeline_test_set_fault_mask(p, faults); + if (legacy) cbm_pipeline_incremental_test_force_legacy_partial_once(); + int rc = named ? cbm_pipeline_run(p) : -998; + if (route) *route = cbm_pipeline_incremental_test_last_route(); + cbm_pipeline_free(p); + cbm_pipeline_incremental_test_reset_faults(); + return rc; +} +static bool pc_seed(pc_fixture_t *f, const char *config) { + return pc_source_write(f, "unit.c", pc_legacy_source) && + (!config || pc_write(f->config, config)) && pc_run(f, CBM_MODE_FULL, false, 0, NULL, NULL, NULL) == 0 && + pc_ordinary_graph(f->db, "preserved"); +} +static bool pc_close(pc_fixture_t *f, pc_env_t *env) { + cbm_pipeline_incremental_test_reset_faults(); + bool removed = !f->home[0] || th_rmtree(f->home) == 0; + return pc_env_restore(env) && removed; +} + +static int pc_configured_case(bool parallel) { + pc_env_t env; pc_fixture_t f = {0}; + bool setup = pc_env_begin(&env, parallel) && pc_fixture_open(&f, parallel) && + pc_write(f.config, pc_config0) && pc_source_write(&f, "unit.c", pc_source); + int rc = setup ? pc_run(&f, CBM_MODE_FAST, false, 0, NULL, NULL, NULL) : -999; + bool graph = setup && pc_c_graph(f.db, false); + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(cleanup); ASSERT_EQ(rc, 0); ASSERT_TRUE(graph); PASS(); +} +TEST(pipeline_conventions_serial_roles_qns_and_calls) { + ASSERT_EQ(pc_init_status, 0); return pc_configured_case(false); +} +TEST(pipeline_conventions_parallel_roles_qns_and_calls) { + ASSERT_EQ(pc_init_status, 0); return pc_configured_case(true); +} +TEST(pipeline_conventions_config_only_change_rebuilds_same_source) { + ASSERT_EQ(pc_init_status, 0); + pc_env_t env; pc_fixture_t f = {0}; char before_hash[65], after_hash[65], before_gen[128], after_gen[128]; + bool setup = pc_env_begin(&env, true) && pc_fixture_open(&f, true) && pc_write(f.config, pc_config0) && + pc_source_write(&f, "unit.c", pc_source); + bool control = setup && pc_run(&f, CBM_MODE_FAST, false, 0, NULL, NULL, NULL) == 0 && + pc_c_graph(f.db, false) && pc_source_hash(f.db, before_hash) && pc_generation(f.db, before_gen); + bool changed = control && pc_write(f.config, pc_config1); /* Source is deliberately not rewritten. */ + int rc = changed ? pc_run(&f, CBM_MODE_FAST, false, 0, NULL, NULL, NULL) : -999; + bool rebuilt = changed && pc_c_graph(f.db, true) && pc_missing(f.db, PC_PROJECT ".unit.CHECK_before") && + pc_source_hash(f.db, after_hash) && strcmp(before_hash, after_hash) == 0 && + pc_generation(f.db, after_gen) && strcmp(before_gen, after_gen) != 0; + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(control); ASSERT_TRUE(changed); ASSERT_TRUE(cleanup); + ASSERT_EQ(rc, 0); ASSERT_TRUE(rebuilt); PASS(); +} +TEST(pipeline_conventions_absent_and_default_omit_role_properties) { + ASSERT_EQ(pc_init_status, 0); + pc_env_t env; pc_fixture_t f = {0}; + bool setup = pc_env_begin(&env, false) && pc_fixture_open(&f, false); + bool absent = setup && pc_seed(&f, NULL) && pc_no_roles(f.db); + bool configured = absent && pc_write(f.config, pc_empty_config); + /* Both boundary faults are inapplicable without consumed declarations/owners. */ + int rc = configured ? pc_run(&f, CBM_MODE_FULL, false, + CBM_PIPELINE_TEST_FAULT_EXTRACT_NULL | CBM_PIPELINE_TEST_FAULT_DEF_MODULES_NULL, + NULL, NULL, NULL) : -999; + bool defaults = configured && pc_ordinary_graph(f.db, "preserved") && pc_no_roles(f.db); + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(absent); ASSERT_TRUE(configured); ASSERT_TRUE(cleanup); + ASSERT_EQ(rc, 0); ASSERT_TRUE(defaults); PASS(); +} +static int pc_typed_failure(bool legacy) { + pc_env_t env; pc_fixture_t f = {0}; pc_snapshot_t snapshot; + bool setup = pc_env_begin(&env, legacy) && pc_fixture_open(&f, legacy); + bool control = setup && pc_seed(&f, pc_config0) && pc_snapshot(&f, &snapshot); + bool changed = control && pc_source_write(&f, "unit.c", pc_bad_source); + cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE; + int rc = changed ? pc_run(&f, legacy ? CBM_MODE_FULL : CBM_MODE_FAST, legacy, 0, NULL, NULL, &route) : -999; + bool preserved = changed && pc_preserved(&f, &snapshot); + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(control); ASSERT_TRUE(changed); ASSERT_TRUE(cleanup); + ASSERT_EQ(route, legacy ? CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL : CBM_INCREMENTAL_ROUTE_FORCED_FULL); + ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); ASSERT_TRUE(preserved); PASS(); +} +TEST(pipeline_conventions_typed_failure_full_preserves_generation) { + ASSERT_EQ(pc_init_status, 0); return pc_typed_failure(false); +} +TEST(pipeline_conventions_typed_failure_incremental_preserves_generation) { + ASSERT_EQ(pc_init_status, 0); return pc_typed_failure(true); +} + +typedef struct { pc_fixture_t *fixture; int called; bool staged, written; } pc_hook_t; +static void pc_mutate_config(cbm_pipeline_t *p, const char *staging, void *opaque) { + (void)p; pc_hook_t *h = opaque; + h->called++; + h->staged = pc_ordinary_graph(staging, "staged"); + h->written = pc_write(h->fixture->config, pc_config1); +} +TEST(pipeline_conventions_publication_config_mutation_preserves_generation) { + ASSERT_EQ(pc_init_status, 0); + pc_env_t env; pc_fixture_t f = {0}; pc_snapshot_t snapshot; + bool setup = pc_env_begin(&env, true) && pc_fixture_open(&f, true); + bool control = setup && pc_seed(&f, pc_config0) && pc_snapshot(&f, &snapshot); + bool changed = control && pc_source_write(&f, "unit.c", "void sink(void) {}\nvoid staged(void) { sink(); }\n"); + pc_hook_t hook = {.fixture = &f}; + int rc = changed ? pc_run(&f, CBM_MODE_FAST, false, 0, pc_mutate_config, &hook, NULL) : -999; + bool preserved = changed && pc_preserved(&f, &snapshot); + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(control); ASSERT_TRUE(changed); ASSERT_TRUE(cleanup); + ASSERT_EQ(hook.called, 1); ASSERT_TRUE(hook.staged); ASSERT_TRUE(hook.written); + ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); ASSERT_TRUE(preserved); PASS(); +} +TEST(pipeline_conventions_published_owners_survive_join_and_config_edit) { + ASSERT_EQ(pc_init_status, 0); + pc_env_t env; pc_fixture_t f = {0}; pc_snapshot_t snapshot; + bool setup = pc_env_begin(&env, true) && pc_fixture_open(&f, true) && pc_write(f.config, pc_config0) && + pc_source_write(&f, "unit.c", pc_source); + bool control = setup && pc_run(&f, CBM_MODE_FAST, false, 0, NULL, NULL, NULL) == 0 && + pc_c_graph(f.db, false) && pc_snapshot(&f, &snapshot); + cbm_store_t *store = control ? cbm_store_open_path_query(f.db) : NULL; + cbm_node_t owner = {0}, suite = {0}; + bool acquired = store && pc_node(store, PC_PROJECT ".unit.CHECK_before", "CHECK_before", "unit.c", "case", &owner) && + pc_node(store, PC_PROJECT ".unit.GROUP_group", "GROUP_group", "unit.c", "suite", &suite); + cbm_store_close(store); /* Returned node strings are owned by the caller. */ + bool edited = acquired && pc_write(f.config, "{not valid JSON after the completed run"); + char now[128]; + bool lifetime = edited && pc_text(owner.qualified_name, PC_PROJECT ".unit.CHECK_before") && pc_role(&owner, "case") && + pc_text(suite.qualified_name, PC_PROJECT ".unit.GROUP_group") && pc_role(&suite, "suite") && + pc_c_graph(f.db, false) && pc_files_equal(f.db, f.saved) && pc_generation(f.db, now) && + strcmp(now, snapshot.generation) == 0; + cbm_node_free_fields(&owner); cbm_node_free_fields(&suite); + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(control); ASSERT_TRUE(acquired); ASSERT_TRUE(edited); + ASSERT_TRUE(cleanup); ASSERT_TRUE(lifetime); PASS(); +} + +static bool pc_cpp_files(pc_fixture_t *f) { + char old[1024]; + return pc_path(old, sizeof(old), f->repo, "unit.c") && cbm_unlink(old) == 0 && + pc_write(f->config, pc_cpp_config) && + pc_source_write(f, "engine.h", "struct Engine { void work() {} };\n") && + pc_source_write(f, "unit.cpp", pc_cpp_source); +} +static bool pc_cpp_graph(const char *db) { + cbm_store_t *s = cbm_store_open_path_query(db); cbm_node_t owner = {0}, *nodes = NULL; int count = 0; + bool ok = s && pc_node(s, PC_PROJECT ".unit.CHECK_alpha", "CHECK_alpha", "unit.cpp", "case", &owner) && + cbm_store_find_nodes_by_file(s, PC_PROJECT, "engine.h", &nodes, &count) == CBM_STORE_OK; + int matches = 0; int64_t target = 0; + if (ok) for (int i = 0; i < count; i++) { + if (pc_text(nodes[i].name, "work") && pc_text(nodes[i].label, "Method") && + pc_text(nodes[i].qualified_name, PC_PROJECT ".engine.Engine.work")) { + matches++; target = nodes[i].id; + } + } + ok = ok && matches == 1 && pc_call(s, owner.id, target); + cbm_node_free_fields(&owner); cbm_store_free_nodes(nodes, count); cbm_store_close(s); return ok; +} +TEST(pipeline_conventions_disabled_required_parallel_crosswalk_preserves_generation) { + ASSERT_EQ(pc_init_status, 0); + pc_env_t env; pc_fixture_t f = {0}; pc_snapshot_t snapshot; + bool setup = pc_env_begin(&env, true) && pc_fixture_open(&f, true); + bool control = setup && pc_seed(&f, NULL) && pc_snapshot(&f, &snapshot); + bool changed = control && pc_cpp_files(&f) && cbm_setenv("CBM_DISABLE_LSP_CROSS", "1", 1) == 0; + cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE; + int rc = changed ? pc_run(&f, CBM_MODE_FAST, false, 0, NULL, NULL, &route) : -999; + bool preserved = changed && pc_preserved(&f, &snapshot); + (void)cbm_unsetenv("CBM_DISABLE_LSP_CROSS"); + bool recovery = changed && getenv("CBM_DISABLE_LSP_CROSS") == NULL && + pc_run(&f, CBM_MODE_FAST, false, 0, NULL, NULL, NULL) == 0 && pc_cpp_graph(f.db); + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(control); ASSERT_TRUE(changed); ASSERT_TRUE(cleanup); + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL); ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); + ASSERT_TRUE(preserved); ASSERT_TRUE(recovery); PASS(); +} +TEST(pipeline_conventions_configured_null_result_preserves_generation) { + ASSERT_EQ(pc_init_status, 0); + for (int parallel = 0; parallel < 2; parallel++) { + pc_env_t env; pc_fixture_t f = {0}; pc_snapshot_t snapshot; + bool setup = pc_env_begin(&env, parallel != 0) && pc_fixture_open(&f, parallel != 0); + bool control = setup && pc_seed(&f, pc_config0) && pc_snapshot(&f, &snapshot); + bool changed = control && pc_source_write(&f, "unit.c", pc_source); + cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE; + int rc = changed ? pc_run(&f, CBM_MODE_FAST, false, CBM_PIPELINE_TEST_FAULT_EXTRACT_NULL, + NULL, NULL, &route) : -999; + bool preserved = changed && pc_preserved(&f, &snapshot); + bool recovery = changed && pc_run(&f, CBM_MODE_FAST, false, 0, NULL, NULL, NULL) == 0 && pc_c_graph(f.db, false); + bool cleanup = pc_close(&f, &env); + if (rc != CBM_PIPELINE_ABORT_PRESERVE_DB || !preserved || !recovery) + fprintf(stderr, "configured NULL-result boundary (%s)\n", parallel ? "parallel" : "serial"); + ASSERT_TRUE(setup); ASSERT_TRUE(control); ASSERT_TRUE(changed); ASSERT_TRUE(cleanup); + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL); ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); + ASSERT_TRUE(preserved); ASSERT_TRUE(recovery); + } + PASS(); +} +TEST(pipeline_conventions_required_def_modules_null_preserves_generation) { + ASSERT_EQ(pc_init_status, 0); + pc_env_t env; pc_fixture_t f = {0}; pc_snapshot_t snapshot; + bool setup = pc_env_begin(&env, false) && pc_fixture_open(&f, false); + bool control = setup && pc_seed(&f, NULL) && pc_snapshot(&f, &snapshot); + bool changed = control && pc_cpp_files(&f); + cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE; + int rc = changed ? pc_run(&f, CBM_MODE_FAST, false, CBM_PIPELINE_TEST_FAULT_DEF_MODULES_NULL, + NULL, NULL, &route) : -999; + bool preserved = changed && pc_preserved(&f, &snapshot); + bool recovery = changed && pc_run(&f, CBM_MODE_FAST, false, 0, NULL, NULL, NULL) == 0 && pc_cpp_graph(f.db); + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(control); ASSERT_TRUE(changed); ASSERT_TRUE(cleanup); + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL); ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); + ASSERT_TRUE(preserved); ASSERT_TRUE(recovery); PASS(); +} + +/* Body-only edit: unchanged declarations, mode, config and inventory keep the + * natural closure candidate valid. The route assertion prevents full fallback. */ +TEST(pipeline_conventions_closure_def_modules_null_preserves_generation) { + ASSERT_EQ(pc_init_status, 0); + pc_env_t env; pc_fixture_t f = {0}; pc_snapshot_t snapshot; char old_hash[65], new_hash[65]; + bool setup = pc_env_begin(&env, false) && pc_fixture_open(&f, false) && + pc_write(f.config, pc_config0) && pc_source_write(&f, "unit.c", pc_source); + bool control = setup && pc_run(&f, CBM_MODE_FULL, false, 0, NULL, NULL, NULL) == 0 && + pc_c_graph(f.db, false) && pc_source_hash(f.db, old_hash) && pc_snapshot(&f, &snapshot); + const char changed_source[] = + "void case_sink(void) {}\nvoid suite_sink(void) {}\n" + "CHECK(before, after) { case_sink(); case_sink(); }\nGROUP(group) { suite_sink(); }\n"; + bool changed = control && pc_source_write(&f, "unit.c", changed_source); + cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE, retry_route = CBM_INCREMENTAL_ROUTE_NONE; + int rc = changed ? pc_run(&f, CBM_MODE_FULL, false, CBM_PIPELINE_TEST_FAULT_DEF_MODULES_NULL, + NULL, NULL, &route) : -999; + bool preserved = changed && pc_snapshot_equal(&f, &snapshot) && pc_c_graph(f.db, false); + bool recovery = changed && pc_run(&f, CBM_MODE_FULL, false, 0, NULL, NULL, &retry_route) == 0 && + pc_c_graph(f.db, false) && pc_source_hash(f.db, new_hash) && strcmp(old_hash, new_hash) != 0; + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(control); ASSERT_TRUE(changed); ASSERT_TRUE(cleanup); + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); ASSERT_TRUE(preserved); + ASSERT_EQ(retry_route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); ASSERT_TRUE(recovery); PASS(); +} + +typedef struct { + bool setup, parked, registry, positive, rejected, reload; +} pc_spill_witness_t; +static bool pc_result_case(const CBMFileResult *r, const char *name, const char *qn) { + if (!r || r->test_declarations_status != CBM_TEST_EXTRACT_OK || !r->has_test_definition_owners) + return false; + int matches = 0; + for (int i = 0; i < r->defs.count; i++) + if (pc_text(r->defs.items[i].name, name) && pc_text(r->defs.items[i].qualified_name, qn) && + r->defs.items[i].test_role == CBM_TEST_ROLE_CASE) matches++; + return matches == 1; +} +/* First file contributes real definitions before the second file's actual + * spilled-load boundary fails. Registry and post-failure reload stay real. */ +static pc_spill_witness_t pc_collect_spill_witness(pc_fixture_t *f) { + pc_spill_witness_t w = {0}; char dir[1024], plain_path[1024], owner_path[1024]; + const char plain[] = "void contributor(void) {}\n"; + const char owner[] = "CHECK(parked) {}\n"; + cbm_test_declarations_t *decl = NULL; + CBMFileResult *cache[2] = {NULL, NULL}, *reload = NULL; + cbm_result_spill_t *spill = NULL; + CBMLSPDef *defs = NULL; + char *modules[2] = {NULL, NULL}; int starts[3] = {-7, -7, -7}, count = -7; + CBMArena arena; cbm_arena_init_lazy(&arena, 4096); + atomic_int cancelled; atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {0}; + ctx.project_name = PC_PROJECT; ctx.repo_path = f->repo; ctx.cancelled = &cancelled; + atomic_init(&ctx.test_declarations_failed, 0); + atomic_init(&ctx.test_definition_owners_seen, 0); + atomic_init(&ctx.spill_mode, 0); + if (!pc_path(dir, sizeof(dir), f->home, "collector-spill") || th_mkdir_p(dir) != 0 || + !pc_path(plain_path, sizeof(plain_path), f->repo, "contributor.c") || + !pc_path(owner_path, sizeof(owner_path), f->repo, "parked.c")) goto done; + decl = cbm_test_declarations_parse(pc_config0, strlen(pc_config0), false); + if (!decl) goto done; + cache[0] = cbm_extract_file_ex_with_tests(plain, (int)strlen(plain), CBM_LANG_C, + PC_PROJECT, "contributor.c", 0, NULL, NULL, NULL, NULL, decl); + if (!cache[0] || cache[0]->test_declarations_status != CBM_TEST_EXTRACT_OK || cache[0]->defs.count < 1) + goto done; + cbm_result_compact(cache[0]); + cache[1] = cbm_extract_file_ex_with_tests(owner, (int)strlen(owner), CBM_LANG_C, + PC_PROJECT, "parked.c", 0, NULL, NULL, NULL, NULL, decl); + if (!pc_result_case(cache[1], "CHECK_parked", PC_PROJECT ".parked.CHECK_parked")) goto done; + cbm_result_compact(cache[1]); + ctx.test_declarations = decl; + atomic_store(&ctx.test_definition_owners_seen, 1); /* Actual configured owner was just verified. */ + ctx.gbuf = cbm_gbuf_new(PC_PROJECT, f->repo); ctx.registry = cbm_registry_new(); + spill = cbm_result_spill_open(dir, 1, 2); ctx.spill = spill; + w.setup = ctx.gbuf && ctx.registry && spill; + if (!w.setup) goto done; + if (!cbm_result_spill_park(spill, 0, 1, cache[1])) goto done; + cache[1] = NULL; /* park consumed it. */ + int64_t parked = 0, bytes = 0, loads_before = 0, loads_registry = 0, loads_control = 0, loads_after = 0; + cbm_result_spill_stats(spill, &parked, &bytes, &loads_before); + w.parked = cbm_result_spill_has(spill, 1) && parked == 1 && bytes > 0 && cache[0]->defs.count > 0; + if (!w.parked) goto done; + cbm_file_info_t files[2] = { + {.path = plain_path, .rel_path = "contributor.c", .language = CBM_LANG_C, .size = sizeof(plain)-1}, + {.path = owner_path, .rel_path = "parked.c", .language = CBM_LANG_C, .size = sizeof(owner)-1} + }; + int registry_rc = cbm_build_registry_from_cache(&ctx, files, 2, cache); + cbm_result_spill_stats(spill, &parked, &bytes, &loads_registry); + cbm_resolution_t resolved = cbm_registry_resolve(ctx.registry, "CHECK_parked", + PC_PROJECT ".parked", NULL, NULL, 0); + w.registry = registry_rc == 0 && loads_registry > loads_before && + pc_text(resolved.qualified_name, PC_PROJECT ".parked.CHECK_parked"); + if (!w.registry) goto done; + defs = cbm_pxc_collect_all_defs(&ctx, &arena, cache, files, 2, PC_PROJECT, modules, &count, starts); + cbm_result_spill_stats(spill, &parked, &bytes, &loads_control); + w.positive = defs && starts[0] == 0 && starts[1] > 0 && starts[2] > starts[1] && + count == starts[2] && loads_control > loads_registry; + free(defs); defs = NULL; + for (int i = 0; i < 2; i++) { free(modules[i]); modules[i] = NULL; } + if (!w.positive) goto done; + ctx.test_fault_mask = CBM_PIPELINE_TEST_FAULT_COLLECT_SPILL_NULL; + count = -7; starts[0] = starts[1] = starts[2] = -7; + defs = cbm_pxc_collect_all_defs(&ctx, &arena, cache, files, 2, PC_PROJECT, modules, &count, starts); + w.rejected = defs == NULL && count == -1 && starts[0] == 0 && starts[1] == 0 && starts[2] == 0; + reload = cbm_result_spill_load(spill, 1); /* Keep mask set: only collector loads are intercepted. */ + cbm_result_spill_stats(spill, &parked, &bytes, &loads_after); + w.reload = pc_result_case(reload, "CHECK_parked", PC_PROJECT ".parked.CHECK_parked") && + cbm_result_spill_has(spill, 1) && loads_after > loads_control; +done: + free(defs); + for (int i = 0; i < 2; i++) { free(modules[i]); cbm_free_result(cache[i]); } + cbm_free_result(reload); cbm_registry_free(ctx.registry); cbm_gbuf_free(ctx.gbuf); + if (spill) cbm_result_spill_close(spill); + cbm_arena_destroy(&arena); cbm_test_declarations_free(decl); + return w; +} +TEST(pipeline_conventions_spilled_collect_failure_preserves_generation) { + ASSERT_EQ(pc_init_status, 0); + pc_env_t env; pc_fixture_t f = {0}; pc_snapshot_t snapshot; + size_t floor = cbm_result_spill_free_floor_bytes_for_tests(), room = (size_t)1 << 30; + bool pin_valid = floor <= SIZE_MAX - room; + size_t old_pin = pin_valid ? cbm_result_spill_pin_free_bytes_for_tests(floor + room) : 0; + bool setup = pc_env_begin(&env, true) && pc_fixture_open(&f, true) && pin_valid; + pc_spill_witness_t witness = setup ? pc_collect_spill_witness(&f) : (pc_spill_witness_t){0}; + bool control = setup && pc_seed(&f, NULL) && pc_snapshot(&f, &snapshot); + bool changed = control && pc_write(f.config, pc_config0) && pc_source_write(&f, "unit.c", pc_source) && + cbm_setenv("CBM_MEM_SPILL", "1", 1) == 0 && pc_text(getenv("CBM_MEM_SPILL"), "1") && + pc_parallel_inputs(&f, CBM_MODE_FAST); + cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE; + int rc = changed ? pc_run(&f, CBM_MODE_FAST, false, CBM_PIPELINE_TEST_FAULT_COLLECT_SPILL_NULL, + NULL, NULL, &route) : -999; + bool preserved = changed && pc_preserved(&f, &snapshot); + bool recovery = changed && pc_run(&f, CBM_MODE_FAST, false, 0, NULL, NULL, NULL) == 0 && + pc_c_graph(f.db, false); + if (pin_valid) (void)cbm_result_spill_pin_free_bytes_for_tests(old_pin); + bool cleanup = pc_close(&f, &env); + ASSERT_TRUE(setup); ASSERT_TRUE(cleanup); + ASSERT_TRUE(witness.setup); ASSERT_TRUE(witness.parked); ASSERT_TRUE(witness.registry); + ASSERT_TRUE(witness.positive); ASSERT_TRUE(witness.reload); ASSERT_TRUE(witness.rejected); + ASSERT_TRUE(control); ASSERT_TRUE(changed); ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL); + ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); ASSERT_TRUE(preserved); ASSERT_TRUE(recovery); PASS(); +} + +SUITE(pipeline_conventions) { + pc_init_status = cbm_init(); + RUN_TEST(pipeline_conventions_serial_roles_qns_and_calls); + RUN_TEST(pipeline_conventions_parallel_roles_qns_and_calls); + RUN_TEST(pipeline_conventions_config_only_change_rebuilds_same_source); + RUN_TEST(pipeline_conventions_absent_and_default_omit_role_properties); + RUN_TEST(pipeline_conventions_typed_failure_full_preserves_generation); + RUN_TEST(pipeline_conventions_typed_failure_incremental_preserves_generation); + RUN_TEST(pipeline_conventions_publication_config_mutation_preserves_generation); + RUN_TEST(pipeline_conventions_published_owners_survive_join_and_config_edit); + RUN_TEST(pipeline_conventions_disabled_required_parallel_crosswalk_preserves_generation); + RUN_TEST(pipeline_conventions_configured_null_result_preserves_generation); + RUN_TEST(pipeline_conventions_required_def_modules_null_preserves_generation); + RUN_TEST(pipeline_conventions_closure_def_modules_null_preserves_generation); + RUN_TEST(pipeline_conventions_spilled_collect_failure_preserves_generation); + cbm_work_arena_release(); +} diff --git a/tests/test_pipeline_frozen.c b/tests/test_pipeline_frozen.c new file mode 100644 index 000000000..d48338a09 --- /dev/null +++ b/tests/test_pipeline_frozen.c @@ -0,0 +1,1508 @@ +/* Independent R2 frozen-input candidate tests. No registry/admission claims. */ +#include "test_framework.h" +#include "test_helpers.h" +#include "yyjson/yyjson.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(CBM_ENABLE_TEST_SEAMS) || !CBM_ENABLE_TEST_SEAMS +#error "pipeline_frozen requires the canonical CBM_ENABLE_TEST_SEAMS build" +#endif + +#define PF_PROJECT "frozenprobe" +#define PF_H "abcdef0123456789abcdef0123456789abcdef01" +#define PF_H_UPPER "ABCDEF0123456789ABCDEF0123456789ABCDEF01" +#define PF_M "1234567890abcdef1234567890abcdef12345678" +#define PF_DISABLED "537fa3e574ba1b46eabb9e69cbc7b21658a5ffbab54f1d9f42893917212b217a" +#define PF_ABSENT "c3c85f57db7b7d6a97ba99edd37a47f71e9bbacf13b7ac613885dceae1b00c3f" +#define PF_EMPTY "25c76ed59928c7e5c1418d9746738114f6dbb13c95cc715dda3cb7e3d8f3c1b2" +#define PF_CONFIG0_SHA "6ed72ee01069298818620c9ea4199dc8a0875712431a208f2a6af19dba922b75" +#define PF_CONFIG_ARG(arg) \ + "{\"extra_extensions\":{\".pfsource\":\"C\"},\"test_impact\":{\"version\":" \ + "1,\"tests\":{\"conventions\":[" \ + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_" \ + "args\":[" arg "]}," \ + "{\"language\":\"c\",\"role\":\"suite\",\"define_macro\":\"GROUP\",\"name_" \ + "args\":[0]}]}}}" +static const char pf_config0[] = PF_CONFIG_ARG("0"); +static const char pf_config1[] = PF_CONFIG_ARG("1"); +static const char pf_source[] = "void case_sink(void) {}\nvoid suite_sink(void) {}\n" + "CHECK(before, after) {\n" + " case_sink();\n" + "}\n" + "GROUP(group) {\n" + " suite_sink();\n" + "}\n"; +static const char pf_ordinary[] = "void sink(void) {}\nvoid ordinary(void) { sink(); }\n"; +static const char pf_physical_config[] = "{\"extra_extensions\":{\".pfsource\":\"python\"}}\n"; +static int pf_init_status; + +typedef struct { + const char *name; + bool present; + char value[4096]; +} pf_env_value_t; +typedef struct { + char home[1024], source[1024], destination[1024], db[1024]; + char control[1024], saved[1024], config[1024]; + pf_env_value_t env[4]; + bool env_saved; + const cbm_userconfig_t *previous_global; +} pf_fixture_t; + +static bool pf_text(const char *a, const char *b) { + return a && b && !strcmp(a, b); +} +static bool pf_path(char *out, size_t size, const char *base, const char *leaf) { + int n = snprintf(out, size, "%s/%s", base, leaf); + return n > 0 && (size_t)n < size; +} +static bool pf_write(const char *path, const char *text) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) + return false; + size_t len = strlen(text); + bool ok = fwrite(text, 1, len, file) == len; + return fclose(file) == 0 && ok; +} +static bool pf_source_write(pf_fixture_t *f, const char *leaf, const char *text) { + char path[1024]; + return pf_path(path, sizeof(path), f->source, leaf) && pf_write(path, text); +} +static bool pf_absent(const char *path) { + cbm_path_info_t info; + return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_ABSENT; +} +static bool pf_file_is(const char *path, const char *text) { + char data[4096]; + size_t len = strlen(text); + if (len >= sizeof(data)) + return false; + FILE *file = cbm_fopen(path, "rb"); + if (!file) + return false; + size_t n = fread(data, 1, len + 1, file); + bool ok = n == len && !memcmp(data, text, len) && !ferror(file); + return fclose(file) == 0 && ok; +} +static bool pf_files_equal(const char *a, const char *b) { + FILE *left = cbm_fopen(a, "rb"), *right = cbm_fopen(b, "rb"); + bool ok = left && right; + unsigned char x[4096], y[4096]; + while (ok) { + size_t nx = fread(x, 1, sizeof(x), left), ny = fread(y, 1, sizeof(y), right); + if (nx != ny || memcmp(x, y, nx)) { + ok = false; + break; + } + if (nx < sizeof(x)) { + ok = !ferror(left) && !ferror(right); + break; + } + } + if (left && fclose(left)) + ok = false; + if (right && fclose(right)) + ok = false; + return ok; +} +static bool pf_directory_only(const char *directory, const char *allowed) { + cbm_dir_t *dir = cbm_opendir(directory); + if (!dir) + return false; + bool ok = true; + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (pf_text(entry->name, ".") || pf_text(entry->name, "..")) + continue; + if (!allowed || !pf_text(entry->name, allowed)) + ok = false; + } + cbm_closedir(dir); + return ok; +} +static bool pf_env_start(pf_fixture_t *f) { + static const char *const names[] = {"CBM_WORKERS", "CBM_INDEX_SINGLE_THREAD", + "CBM_DISABLE_LSP_CROSS", "CBM_MEM_SPILL"}; + f->previous_global = cbm_get_user_lang_config(); + for (size_t i = 0; i < 4; ++i) { + f->env[i].name = names[i]; + const char *value = getenv(names[i]); + f->env[i].present = value != NULL; + if (value) { + if (strlen(value) >= sizeof(f->env[i].value)) + return false; + memcpy(f->env[i].value, value, strlen(value) + 1); + } + } + f->env_saved = true; + return cbm_setenv(names[0], "2", 1) == 0 && cbm_setenv(names[1], "1", 1) == 0 && + cbm_unsetenv(names[2]) == 0 && cbm_unsetenv(names[3]) == 0; +} +static bool pf_close(pf_fixture_t *f) { + cbm_set_user_lang_config(f->previous_global); + bool ok = true; + if (f->env_saved) + for (size_t i = 0; i < 4; ++i) { + pf_env_value_t *v = &f->env[i]; + int rc = v->present ? cbm_setenv(v->name, v->value, 1) : cbm_unsetenv(v->name); + const char *now = getenv(v->name); + if (rc || (v->present ? !pf_text(now, v->value) : now != NULL)) + ok = false; + } + if (f->home[0] && th_rmtree(f->home)) + ok = false; + return ok; +} +static bool pf_open(pf_fixture_t *f) { + memset(f, 0, sizeof(*f)); + f->previous_global = cbm_get_user_lang_config(); + const char *home = th_mktempdir("cbm-frozen-input"); + if (!home || strlen(home) >= sizeof(f->home)) + return false; + memcpy(f->home, home, strlen(home) + 1); + return pf_path(f->source, sizeof(f->source), home, "source") && + pf_path(f->destination, sizeof(f->destination), home, "destination") && + pf_path(f->db, sizeof(f->db), f->destination, "candidate.db") && + pf_path(f->control, sizeof(f->control), home, "control.db") && + pf_path(f->saved, sizeof(f->saved), home, "saved.db") && + pf_path(f->config, sizeof(f->config), f->source, ".codebase-memory.json") && + th_mkdir_p(f->source) == 0 && th_mkdir_p(f->destination) == 0 && pf_env_start(f); +} +static bool pf_role(const cbm_node_t *node, const char *role) { + if (!node->properties_json) + return false; + yyjson_doc *doc = yyjson_read(node->properties_json, strlen(node->properties_json), 0); + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *field = yyjson_is_obj(root) ? yyjson_obj_get(root, "test_role") : NULL; + bool ok = yyjson_is_obj(root) && (role ? pf_text(yyjson_get_str(field), role) : !field); + yyjson_doc_free(doc); + return ok; +} +static bool pf_node(cbm_store_t *store, const char *qn, const char *name, const char *file, + const char *role, cbm_node_t *out) { + memset(out, 0, sizeof(*out)); + return cbm_store_find_node_by_qn(store, PF_PROJECT, qn, out) == CBM_STORE_OK && + pf_text(out->project, PF_PROJECT) && pf_text(out->qualified_name, qn) && + pf_text(out->name, name) && pf_text(out->file_path, file) && pf_role(out, role); +} +static bool pf_call(cbm_store_t *store, int64_t from, int64_t to) { + cbm_edge_t *edges = NULL; + int count = 0; + bool read = + cbm_store_find_edges_by_source_type(store, from, "CALLS", &edges, &count) == CBM_STORE_OK; + bool found = false; + if (read) + for (int i = 0; i < count; ++i) + if (edges[i].source_id == from && edges[i].target_id == to && + pf_text(edges[i].type, "CALLS")) + found = true; + cbm_store_free_edges(edges, count); + return read && found; +} +static bool pf_ordinary_graph(const char *path) { + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) + return false; + cbm_node_t a = {0}, b = {0}; + bool ok = pf_node(store, PF_PROJECT ".ordinary.ordinary", "ordinary", "ordinary.c", NULL, &a) && + pf_node(store, PF_PROJECT ".ordinary.sink", "sink", "ordinary.c", NULL, &b) && + pf_call(store, a.id, b.id); + cbm_node_free_fields(&a); + cbm_node_free_fields(&b); + cbm_store_close(store); + return ok; +} +static bool pf_real_control(pf_fixture_t *f) { + if (!pf_source_write(f, "ordinary.c", pf_ordinary)) + return false; + cbm_pipeline_t *pipeline = cbm_pipeline_new(f->source, f->control, CBM_MODE_FULL); + if (!pipeline) + return false; + cbm_pipeline_set_persistence(pipeline, false); + bool ok = + cbm_pipeline_set_project_name(pipeline, PF_PROJECT) && cbm_pipeline_run(pipeline) == 0; + cbm_pipeline_free(pipeline); + return ok && pf_ordinary_graph(f->control) && cbm_clone_or_copy_file(f->control, f->saved) == 0; +} +static cbm_git_context_t pf_git(pf_fixture_t *f) { + return (cbm_git_context_t){.is_git = true, + .is_detached = true, + .root_exists = true, + .input_path = f->source, + .worktree_root = f->source, + .git_dir = "", + .git_common_dir = "", + .canonical_root = f->source, + .branch = "frozen-branch", + .branch_slug = "frozen-branch", + .head_sha = PF_H_UPPER, + .base_sha = PF_M}; +} +static cbm_pipeline_frozen_inputs_t pf_inputs(pf_fixture_t *f, const char *config, + cbm_git_context_t *git, atomic_int *cancelled) { + return (cbm_pipeline_frozen_inputs_t){.source_root = f->source, + .candidate_db_path = f->db, + .project = PF_PROJECT, + .config_state = config ? CBM_USERCONFIG_SOURCE_PRESENT + : CBM_USERCONFIG_SOURCE_ABSENT, + .config_bytes = config, + .config_len = config ? strlen(config) : 0, + .pinned_git = git, + .resource_policy = {0}, + .cancelled = cancelled}; +} +static cbm_pipeline_frozen_status_t pf_create(pf_fixture_t *f, const char *config, + atomic_int *cancelled, cbm_pipeline_frozen_t **out) { + cbm_git_context_t git = pf_git(f); + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, config, &git, cancelled); + return cbm_pipeline_frozen_create(&in, out); +} +static bool pf_fixture_graph(const char *path, bool after) { + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) + return false; + cbm_node_t a = {0}, b = {0}, c = {0}, d = {0}; + bool ok = + pf_node(store, after ? PF_PROJECT ".unit.CHECK_after" : PF_PROJECT ".unit.CHECK_before", + after ? "CHECK_after" : "CHECK_before", "unit.pfsource", "case", &a) && + pf_node(store, PF_PROJECT ".unit.GROUP_group", "GROUP_group", "unit.pfsource", "suite", + &b) && + pf_node(store, PF_PROJECT ".unit.case_sink", "case_sink", "unit.pfsource", NULL, &c) && + pf_node(store, PF_PROJECT ".unit.suite_sink", "suite_sink", "unit.pfsource", NULL, &d) && + pf_call(store, a.id, c.id) && pf_call(store, b.id, d.id); + cbm_node_free_fields(&a); + cbm_node_free_fields(&b); + cbm_node_free_fields(&c); + cbm_node_free_fields(&d); + cbm_store_close(store); + return ok; +} +static bool pf_branch_graph(pf_fixture_t *f, const char *path) { + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) + return false; + cbm_git_context_t git = pf_git(f); + git.head_sha = PF_H; + char expected[2048]; + char *qn = cbm_git_context_branch_qn(PF_PROJECT, &git); + bool encoded = qn && cbm_git_context_props_json(&git, expected, sizeof(expected)) > 0; + cbm_node_t *nodes = NULL; + int count = 0; + bool ok = encoded && + cbm_store_find_nodes_by_label(store, PF_PROJECT, "Branch", &nodes, &count) == + CBM_STORE_OK && + count == 1 && pf_text(nodes[0].name, "frozen-branch") && + pf_text(nodes[0].qualified_name, qn) && pf_text(nodes[0].properties_json, expected); + free(qn); + cbm_store_free_nodes(nodes, count); + cbm_store_close(store); + return ok; +} +static bool pf_feature_control(pf_fixture_t *f) { + cbm_pipeline_frozen_t *owner = NULL; + bool ok = pf_create(f, "{}", NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner && + cbm_pipeline_frozen_build(owner) == CBM_PIPELINE_FROZEN_OK && + pf_text(cbm_pipeline_frozen_candidate_path(owner), f->db) && pf_ordinary_graph(f->db); + cbm_pipeline_frozen_free(owner); + bool retained = !pf_absent(f->db); + bool removed = retained && cbm_unlink(f->db) == 0; + return ok && retained && removed && pf_directory_only(f->destination, NULL); +} +static bool pf_snapshot_view(const cbm_userconfig_t *config, cbm_userconfig_source_state_t state, + const char *expected, size_t len) { + const char *bytes = "guard"; + size_t length = 123; + cbm_userconfig_source_state_t got = cbm_userconfig_project_source(config, &bytes, &length); + if (got != state || length != len) + return false; + return state == CBM_USERCONFIG_SOURCE_PRESENT ? bytes && !memcmp(bytes, expected, len) + : bytes == NULL; +} +static bool pf_config_failure(cbm_userconfig_t *guard, cbm_userconfig_source_state_t state, + const void *bytes, size_t len, + cbm_userconfig_snapshot_status_t expected) { + cbm_userconfig_t *out = guard; + cbm_userconfig_snapshot_status_t status = + cbm_userconfig_from_project_bytes(state, bytes, len, &out); + bool ok = status == expected && out == NULL; + if (out != guard) + cbm_userconfig_free(out); + return ok; +} +static bool pf_config_negatives(cbm_userconfig_t *guard) { + const char *invalid[] = {"", + "{", + "[]", + "null", + "7", + "{\"extra_extensions\":null}", + "{\"extra_extensions\":[]}", + "{\"extra_extensions\":{},\"extra_extensions\":{}}", + "{\"extra_extensions\":{\".x\":\"c\",\".x\":\"cpp\"}}", + "{\"extra_extensions\":{\".x\":\"c\",\"\\u002ex\":\"c\"}}", + "{\"extra_extensions\":{\"x\":\"c\"}}", + "{\"extra_extensions\":{\"\":\"c\"}}", + "{\"extra_extensions\":{\".x\":\"\"}}", + "{\"extra_extensions\":{\".x\":9}}", + "{\"extra_extensions\":{\".x\\u0000y\":\"c\"}}", + "{\"extra_extensions\":{\".x\":\"c\\u0000pp\"}}"}; + bool ok = true; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); ++i) { + bool one = pf_config_failure(guard, CBM_USERCONFIG_SOURCE_PRESENT, invalid[i], + strlen(invalid[i]), CBM_USERCONFIG_SNAPSHOT_INVALID); + if (!one) + printf(" invalid frozen config case %zu\n", i); + ok = one && ok; + } + const char unknown[] = "{\"extra_extensions\":{\".x\":\"unsupported-language\"}}"; + return pf_config_failure(guard, CBM_USERCONFIG_SOURCE_PRESENT, unknown, sizeof(unknown) - 1, + CBM_USERCONFIG_SNAPSHOT_UNSUPPORTED) && + pf_config_failure(guard, CBM_USERCONFIG_SOURCE_ABSENT, "", 0, + CBM_USERCONFIG_SNAPSHOT_INVALID) && + pf_config_failure(guard, CBM_USERCONFIG_SOURCE_PRESENT, NULL, 1, + CBM_USERCONFIG_SNAPSHOT_INVALID) && + pf_config_failure(guard, CBM_USERCONFIG_SOURCE_ERROR, NULL, 0, + CBM_USERCONFIG_SNAPSHOT_INVALID) && + pf_config_failure(guard, (cbm_userconfig_source_state_t)99, NULL, 0, + CBM_USERCONFIG_SNAPSHOT_INVALID) && + ok; +} +static bool pf_config_boundary(cbm_userconfig_t *guard) { + CBMArena arena; + cbm_arena_init(&arena); + char *bytes = cbm_arena_alloc(&arena, 65537); + cbm_userconfig_t *out = NULL; + bool ok = bytes != NULL; + if (bytes) { + memset(bytes, ' ', 65537); + bytes[0] = '{'; + bytes[65535] = '}'; + ok = cbm_userconfig_from_project_bytes(CBM_USERCONFIG_SOURCE_PRESENT, bytes, 65536, &out) == + CBM_USERCONFIG_SNAPSHOT_OK && + pf_snapshot_view(out, CBM_USERCONFIG_SOURCE_PRESENT, bytes, 65536); + cbm_userconfig_free(out); + ok = pf_config_failure(guard, CBM_USERCONFIG_SOURCE_PRESENT, bytes, 65537, + CBM_USERCONFIG_SNAPSHOT_LIMIT) && + ok; + } + cbm_arena_destroy(&arena); + return ok; +} +static bool pf_unconsumed_config_fields(void) { + static const char bytes[] = "{\"future\":1,\"future\":2,\"extra_extensions\":" + "{\".CUST\":\"CpP\",\".other\":\"c\"}}"; + cbm_userconfig_t *config = NULL; + bool ok = + cbm_userconfig_from_project_bytes(CBM_USERCONFIG_SOURCE_PRESENT, bytes, sizeof(bytes) - 1, + &config) == CBM_USERCONFIG_SNAPSHOT_OK && + config && config->count == 2 && config->entries && + pf_text(config->entries[0].ext, ".CUST") && config->entries[0].lang == CBM_LANG_CPP && + pf_text(config->entries[1].ext, ".other") && config->entries[1].lang == CBM_LANG_C && + pf_snapshot_view(config, CBM_USERCONFIG_SOURCE_PRESENT, bytes, sizeof(bytes) - 1) && + pf_text(config->global_source_sha256, PF_DISABLED); + cbm_userconfig_free(config); + return ok; +} +TEST(pipeline_frozen_config_snapshot_contract) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + cbm_userconfig_t *legacy = NULL, *snapshot = NULL, *absent = NULL, *empty = NULL; + bool setup = pf_open(&f) && pf_write(f.config, "{\"extra_extensions\":{\".legacy\":" + "\"c\",\".bad\":\"not-a-language\"}}"); + if (setup) + legacy = cbm_userconfig_load_with_source(f.source); + bool control = legacy && cbm_userconfig_lookup(legacy, ".legacy") == CBM_LANG_C && + cbm_userconfig_lookup(legacy, ".bad") == CBM_LANG_COUNT; + CBMArena arena; + cbm_arena_init(&arena); + char *copy = cbm_arena_strdup(&arena, pf_config0); + bool created = + control && copy && + cbm_userconfig_from_project_bytes(CBM_USERCONFIG_SOURCE_PRESENT, copy, strlen(copy), + &snapshot) == CBM_USERCONFIG_SNAPSHOT_OK && + snapshot; + if (copy) + memset(copy, 0xa5, strlen(pf_config0)); + cbm_arena_destroy(&arena); + cbm_test_declarations_t *declarations = NULL; + const char *raw = NULL; + size_t len = 0; + if (created && + cbm_userconfig_project_source(snapshot, &raw, &len) == CBM_USERCONFIG_SOURCE_PRESENT) + declarations = cbm_test_declarations_parse(raw, len, false); + int count = 0; + const cbm_test_declaration_t *rows = cbm_test_declarations_items(declarations, &count); + bool owned = + created && snapshot->count == 1 && snapshot->entries && + pf_text(snapshot->entries[0].ext, ".pfsource") && snapshot->entries[0].lang == CBM_LANG_C && + cbm_userconfig_lookup(snapshot, ".pfsource") == CBM_LANG_C && + pf_snapshot_view(snapshot, CBM_USERCONFIG_SOURCE_PRESENT, pf_config0, strlen(pf_config0)) && + pf_text(snapshot->global_source_sha256, PF_DISABLED) && + pf_text(snapshot->project_source_sha256, PF_CONFIG0_SHA) && rows && count == 2 && + rows[0].role == CBM_TEST_DECL_CASE && pf_text(rows[0].define_macro, "CHECK") && + rows[1].role == CBM_TEST_DECL_SUITE && pf_text(rows[1].define_macro, "GROUP"); + bool states = created && + cbm_userconfig_from_project_bytes(CBM_USERCONFIG_SOURCE_ABSENT, NULL, 0, + &absent) == CBM_USERCONFIG_SNAPSHOT_OK && + cbm_userconfig_from_project_bytes(CBM_USERCONFIG_SOURCE_PRESENT, "{}", 2, + &empty) == CBM_USERCONFIG_SNAPSHOT_OK && + absent && empty && absent->count == 0 && empty->count == 0 && + pf_snapshot_view(absent, CBM_USERCONFIG_SOURCE_ABSENT, NULL, 0) && + pf_text(absent->project_source_sha256, PF_ABSENT) && + pf_snapshot_view(empty, CBM_USERCONFIG_SOURCE_PRESENT, "{}", 2) && + pf_text(empty->project_source_sha256, PF_EMPTY) && + pf_text(empty->global_source_sha256, PF_DISABLED) && + pf_text(absent->global_source_sha256, PF_DISABLED); + bool unrelated = created && pf_unconsumed_config_fields(); + bool negative = unrelated && pf_config_negatives(legacy) && pf_config_boundary(legacy) && + cbm_userconfig_from_project_bytes(CBM_USERCONFIG_SOURCE_PRESENT, "{}", 2, + NULL) == CBM_USERCONFIG_SNAPSHOT_INVALID; + cbm_test_declarations_free(declarations); + cbm_userconfig_free(snapshot); + cbm_userconfig_free(absent); + cbm_userconfig_free(empty); + cbm_userconfig_free(legacy); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(created); + ASSERT_TRUE(owned); + ASSERT_TRUE(states); + ASSERT_TRUE(unrelated); + ASSERT_TRUE(negative); + PASS(); +} + +static cbm_pipeline_frozen_status_t pf_create_poisoned(pf_fixture_t *f, + cbm_pipeline_frozen_t **out) { + CBMArena arena; + cbm_arena_init(&arena); + cbm_git_context_t git = pf_git(f); + char **fields[] = {&git.input_path, &git.worktree_root, &git.git_dir, + &git.git_common_dir, &git.canonical_root, &git.branch, + &git.branch_slug, &git.head_sha, &git.base_sha}; + bool ok = true; + for (size_t i = 0; i < 9; ++i) { + *fields[i] = cbm_arena_strdup(&arena, *fields[i]); + if (!*fields[i]) + ok = false; + } + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, pf_config0, &git, NULL); + in.source_root = cbm_arena_strdup(&arena, f->source); + in.candidate_db_path = cbm_arena_strdup(&arena, f->db); + in.project = cbm_arena_strdup(&arena, PF_PROJECT); + in.config_bytes = cbm_arena_strdup(&arena, pf_config0); + ok = ok && in.source_root && in.candidate_db_path && in.project && in.config_bytes; + cbm_pipeline_frozen_status_t status = + ok ? cbm_pipeline_frozen_create(&in, out) : CBM_PIPELINE_FROZEN_OOM; + if (ok) { + for (size_t i = 0; i < 9; ++i) + memset(*fields[i], 0x5a, strlen(*fields[i])); + memset((void *)in.source_root, 0x5a, strlen(in.source_root)); + memset((void *)in.candidate_db_path, 0x5a, strlen(in.candidate_db_path)); + memset((void *)in.project, 0x5a, strlen(in.project)); + memset((void *)in.config_bytes, 0x5a, in.config_len); + } + cbm_arena_destroy(&arena); + return status; +} +static bool pf_builtin_graph(const char *path) { + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) + return false; + cbm_node_t test = {0}, suite = {0}, sink = {0}; + bool ok = pf_node(store, PF_PROJECT ".builtin.TEST_alpha", "TEST_alpha", "builtin.c", "case", + &test) && + pf_node(store, PF_PROJECT ".builtin.SUITE_core", "SUITE_core", "builtin.c", "suite", + &suite) && + pf_node(store, PF_PROJECT ".builtin.builtin_sink", "builtin_sink", "builtin.c", NULL, + &sink) && + pf_call(store, test.id, sink.id) && pf_call(store, suite.id, sink.id); + cbm_node_free_fields(&test); + cbm_node_free_fields(&suite); + cbm_node_free_fields(&sink); + cbm_store_close(store); + return ok; +} +static bool pf_builtin_control(pf_fixture_t *f) { + static const char source[] = "void builtin_sink(void) {}\nTEST(alpha) { builtin_sink(); " + "}\nSUITE(core) { builtin_sink(); }\n"; + static const char config[] = "{\"test_impact\":{\"version\":1,\"tests\":{" + "\"presets\":{\"c-cbm\":true}}}}"; + if (!pf_source_write(f, "builtin.c", source)) + return false; + cbm_pipeline_frozen_t *owner = NULL; + bool ok = pf_create(f, config, NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner && + cbm_pipeline_frozen_build(owner) == CBM_PIPELINE_FROZEN_OK && + pf_builtin_graph(f->db) && pf_ordinary_graph(f->db) && + pf_files_equal(f->control, f->saved); + cbm_pipeline_frozen_free(owner); + return ok; +} +static cbm_pipeline_frozen_status_t pf_build_expected_ok(cbm_pipeline_frozen_t *owner) { + cbm_pipeline_frozen_status_t status = cbm_pipeline_frozen_build(owner); + if (status != CBM_PIPELINE_FROZEN_OK) { + unsigned flags = 0; + cbm_file_error_t *rows = NULL; + int count = 0; + bool evidence = cbm_pipeline_frozen_negative_evidence(owner, &flags); + cbm_pipeline_get_file_errors(cbm_pipeline_frozen_diagnostics(owner), &rows, &count); + printf(" frozen positive failed: status=%d evidence=%d flags=%u rows=%d\n", (int)status, + evidence, flags, count); + for (int i = 0; rows && i < count; i++) + printf(" path=%s phase=%s reason=%s\n", rows[i].path ? rows[i].path : "(null)", + rows[i].phase ? rows[i].phase : "(null)", + rows[i].reason ? rows[i].reason : "(null)"); + } + return status; +} + +TEST(pipeline_frozen_detached_graph_and_owned_context) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + cbm_pipeline_frozen_t *owner = NULL; + bool setup = pf_open(&f); + bool control = setup && pf_real_control(&f); + bool source = control && pf_source_write(&f, "unit.pfsource", pf_source); + char git_path[1024]; + bool detached = + source && pf_path(git_path, sizeof(git_path), f.source, ".git") && pf_absent(git_path); + cbm_pipeline_frozen_status_t created = + detached ? pf_create_poisoned(&f, &owner) : CBM_PIPELINE_FROZEN_INVALID; + bool ready = created == CBM_PIPELINE_FROZEN_OK && owner && + !cbm_pipeline_frozen_candidate_path(owner) && + pf_directory_only(f.destination, NULL); + const cbm_pipeline_t *diag = ready ? cbm_pipeline_frozen_diagnostics(owner) : NULL; + bool diagnostics = diag && cbm_pipeline_get_mode(diag) == CBM_MODE_FULL && + pf_text(cbm_pipeline_project_name(diag), PF_PROJECT); + cbm_pipeline_frozen_status_t status = + ready ? pf_build_expected_ok(owner) : CBM_PIPELINE_FROZEN_INVALID; + bool graph = status == CBM_PIPELINE_FROZEN_OK && + pf_text(cbm_pipeline_frozen_candidate_path(owner), f.db) && + pf_fixture_graph(f.db, false) && pf_branch_graph(&f, f.db) && + pf_files_equal(f.control, f.saved); + cbm_pipeline_frozen_free(owner); + bool survives = graph && pf_fixture_graph(f.db, false); + bool builtin = survives && cbm_unlink(f.db) == 0 && pf_builtin_control(&f); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(source); + ASSERT_TRUE(detached); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(ready); + ASSERT_TRUE(diagnostics); + ASSERT_EQ(status, CBM_PIPELINE_FROZEN_OK); + ASSERT_TRUE(graph); + ASSERT_TRUE(survives); + ASSERT_TRUE(builtin); + PASS(); +} + +typedef enum { + PF_CHANGE_SOURCE, + PF_CHANGE_CONTROL, + PF_CHANGE_CONFIG, + PF_ADD_SOURCE, + PF_DELETE_SOURCE, + PF_ADD_CONTROL, + PF_DELETE_CONTROL, + PF_ADD_CONFIG, + PF_DELETE_CONFIG, + PF_CANCEL_EXTERNAL, + PF_CANCEL_OWNER +} pf_hook_kind_t; +typedef struct { + pf_fixture_t *fixture; + pf_hook_kind_t kind; + bool called, changed; + cbm_pipeline_frozen_t *owner; + atomic_int *external; + char stage[4096]; + bool rename_called; +} pf_hook_t; +static void pf_before_publish(cbm_pipeline_t *pipeline, const char *stage, void *context) { + (void)pipeline; + (void)stage; + pf_hook_t *hook = context; + pf_fixture_t *f = hook->fixture; + hook->called = true; + char path[1024]; + switch (hook->kind) { + case PF_CHANGE_SOURCE: + hook->changed = pf_source_write( + f, "ordinary.c", "void sink(void) {}\nvoid ordinary(void) { sink(); sink(); }\n"); + break; + case PF_CHANGE_CONTROL: + hook->changed = pf_source_write(f, ".gitignore", "# changed physical control\n"); + break; + case PF_CHANGE_CONFIG: + hook->changed = pf_write(f->config, "{\"future\":2}\n"); + break; + case PF_ADD_SOURCE: + hook->changed = pf_source_write(f, "late.c", "void late_added(void) {}\n"); + break; + case PF_DELETE_SOURCE: + hook->changed = + pf_path(path, sizeof(path), f->source, "ordinary.c") && cbm_unlink(path) == 0; + break; + case PF_ADD_CONTROL: + hook->changed = pf_source_write(f, ".cbmignore", "# newly added\n"); + break; + case PF_DELETE_CONTROL: + hook->changed = + pf_path(path, sizeof(path), f->source, ".gitignore") && cbm_unlink(path) == 0; + break; + case PF_ADD_CONFIG: + hook->changed = pf_write(f->config, "{\"future\":1}\n"); + break; + case PF_DELETE_CONFIG: + hook->changed = cbm_unlink(f->config) == 0; + break; + case PF_CANCEL_EXTERNAL: + atomic_store(hook->external, 1); + hook->changed = true; + break; + case PF_CANCEL_OWNER: + cbm_pipeline_frozen_cancel(hook->owner); + hook->changed = true; + break; + } +} +static int pf_fail_rename(const char *stage, const char *destination, void *context) { + (void)destination; + pf_hook_t *hook = context; + hook->rename_called = true; + if (strlen(stage) < sizeof(hook->stage)) + memcpy(hook->stage, stage, strlen(stage) + 1); + errno = EACCES; + return -1; +} +static bool pf_hook_failure(pf_fixture_t *f, pf_hook_kind_t kind) { + cbm_pipeline_frozen_t *owner = NULL; + if (!pf_source_write(f, "ordinary.c", pf_ordinary) || + !pf_source_write(f, ".gitignore", "# baseline\n") || + !pf_write(f->config, pf_physical_config)) + return false; + char added[1024]; + if (!pf_path(added, sizeof(added), f->source, ".cbmignore")) + return false; + if (!pf_absent(added) && cbm_unlink(added)) + return false; + if (kind == PF_ADD_CONFIG && cbm_unlink(f->config)) + return false; + pf_hook_t hook = {.fixture = f, .kind = kind}; + bool ready = pf_create(f, pf_config0, NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner && + cbm_pipeline_frozen_set_publish_hooks_for_tests(owner, pf_before_publish, NULL, + &hook) == CBM_PIPELINE_FROZEN_OK; + cbm_pipeline_frozen_status_t status = + ready ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + bool ok = ready && hook.called && hook.changed && status == CBM_PIPELINE_FROZEN_INPUT_CHANGED && + !cbm_pipeline_frozen_candidate_path(owner) && pf_absent(f->db) && + pf_directory_only(f->destination, NULL) && pf_files_equal(f->control, f->saved); + cbm_pipeline_frozen_free(owner); + if (!ok) + printf(" final reconciliation variant %d status %d hook %d changed %d\n", (int)kind, + (int)status, hook.called, hook.changed); + return ok; +} +TEST(pipeline_frozen_final_reconciliation_and_explicit_override) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + cbm_pipeline_frozen_t *owner = NULL; + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + bool source = control && pf_source_write(&f, "unit.pfsource", pf_source) && + pf_write(f.config, pf_physical_config); + bool ready = + source && pf_create(&f, pf_config0, NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + bool positive = ready && pf_build_expected_ok(owner) == CBM_PIPELINE_FROZEN_OK && + pf_fixture_graph(f.db, false) && pf_file_is(f.config, pf_physical_config); + cbm_pipeline_frozen_free(owner); + bool removed = positive && cbm_unlink(f.db) == 0; + bool rejected = removed; + for (int kind = PF_CHANGE_SOURCE; rejected && kind <= PF_DELETE_CONFIG; ++kind) + rejected = pf_hook_failure(&f, (pf_hook_kind_t)kind); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(source); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(ready); + ASSERT_TRUE(positive); + ASSERT_TRUE(removed); + ASSERT_TRUE(rejected); + PASS(); +} + +static bool pf_sidecar_refusal(pf_fixture_t *f, const char *suffix) { + char path[1100]; + int n = snprintf(path, sizeof(path), "%s%s", f->db, suffix); + if (n <= 0 || (size_t)n >= sizeof(path) || !pf_write(path, "caller-owned-sidecar\n")) + return false; + cbm_pipeline_frozen_t *owner = NULL; + bool ready = pf_create(f, "{}", NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + cbm_pipeline_frozen_status_t status = + ready ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + bool ok = ready && status == CBM_PIPELINE_FROZEN_DEST_EXISTS && + !cbm_pipeline_frozen_candidate_path(owner) && + pf_file_is(path, "caller-owned-sidecar\n") && pf_files_equal(f->control, f->saved); + cbm_pipeline_frozen_free(owner); + return cbm_unlink(path) == 0 && ok && pf_directory_only(f->destination, NULL); +} +static bool pf_path_refusal(pf_fixture_t *f, int variant) { + char bad[1024]; + cbm_git_context_t git = pf_git(f); + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, "{}", &git, NULL); + if (variant == 0) { + if (!pf_path(bad, sizeof(bad), f->source, "inside.db")) + return false; + in.candidate_db_path = bad; + } else if (variant == 1) { + if (!pf_path(bad, sizeof(bad), f->home, "not-created/candidate.db")) + return false; + in.candidate_db_path = bad; + } else { + if (!pf_path(bad, sizeof(bad), f->source, "ordinary.c")) + return false; + in.source_root = bad; + } + cbm_pipeline_frozen_t *owner = NULL; + cbm_pipeline_frozen_status_t created = cbm_pipeline_frozen_create(&in, &owner); + bool ready = created == CBM_PIPELINE_FROZEN_OK && owner; + cbm_pipeline_frozen_status_t status = ready ? cbm_pipeline_frozen_build(owner) : created; + bool input_preserved = variant == 2 ? pf_file_is(bad, pf_ordinary) : pf_absent(bad); + bool ok = ready && status != CBM_PIPELINE_FROZEN_OK && status != CBM_PIPELINE_FROZEN_BUSY && + !cbm_pipeline_frozen_candidate_path(owner) && pf_absent(f->db) && + pf_directory_only(f->destination, NULL) && input_preserved; + cbm_pipeline_frozen_free(owner); + return ok && pf_files_equal(f->control, f->saved); +} +typedef struct { + char path[1100]; + bool called, created; +} pf_collision_t; +static void pf_publish_collision(cbm_pipeline_t *pipeline, const char *stage, void *context) { + (void)pipeline; + (void)stage; + pf_collision_t *collision = context; + collision->called = true; + collision->created = pf_write(collision->path, "arrived-before-commit\n"); +} +static bool pf_final_destination_refusal(pf_fixture_t *f, const char *suffix) { + pf_collision_t collision = {0}; + int n = snprintf(collision.path, sizeof(collision.path), "%s%s", f->db, suffix); + if (n <= 0 || (size_t)n >= sizeof(collision.path)) + return false; + cbm_pipeline_frozen_t *owner = NULL; + bool ready = pf_create(f, "{}", NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner && + cbm_pipeline_frozen_set_publish_hooks_for_tests( + owner, pf_publish_collision, NULL, &collision) == CBM_PIPELINE_FROZEN_OK; + cbm_pipeline_frozen_status_t status = + ready ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + bool ok = ready && collision.called && collision.created && + status == CBM_PIPELINE_FROZEN_DEST_EXISTS && + !cbm_pipeline_frozen_candidate_path(owner) && + pf_file_is(collision.path, "arrived-before-commit\n") && + pf_files_equal(f->control, f->saved); + cbm_pipeline_frozen_free(owner); + return cbm_unlink(collision.path) == 0 && ok && pf_directory_only(f->destination, NULL); +} +TEST(pipeline_frozen_preserves_artifacts_and_destination_namespace) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + cbm_pipeline_frozen_t *owner = NULL; + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + char artifact[1024], graph[1024], metadata[1024], attributes[1024]; + bool paths = control && pf_path(artifact, sizeof(artifact), f.source, ".codebase-memory") && + th_mkdir_p(artifact) == 0 && + pf_path(graph, sizeof(graph), artifact, "graph.db.zst") && + pf_path(metadata, sizeof(metadata), artifact, "meta.json") && + pf_path(attributes, sizeof(attributes), f.source, ".gitattributes") && + pf_write(graph, "not-an-artifact-but-owned\n") && + pf_write(metadata, "{\"sentinel\":true}\n") && + pf_write(attributes, "# caller-owned attributes\n"); + bool ready = paths && pf_create(&f, "{}", NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + bool positive = ready && cbm_pipeline_frozen_build(owner) == CBM_PIPELINE_FROZEN_OK && + pf_ordinary_graph(f.db); + cbm_pipeline_frozen_free(owner); + bool preserved = positive && pf_file_is(graph, "not-an-artifact-but-owned\n") && + pf_file_is(metadata, "{\"sentinel\":true}\n") && + pf_file_is(attributes, "# caller-owned attributes\n") && + pf_file_is(TH_PATH(f.source, "ordinary.c"), pf_ordinary); + bool removed = preserved && cbm_unlink(f.db) == 0; + const char *suffixes[] = {"", "-wal", "-shm", "-journal"}; + bool refused = removed; + for (size_t i = 0; refused && i < 4; ++i) + refused = pf_sidecar_refusal(&f, suffixes[i]); + for (size_t i = 0; refused && i < 4; ++i) + refused = pf_final_destination_refusal(&f, suffixes[i]); + for (int i = 0; refused && i < 3; ++i) + refused = pf_path_refusal(&f, i); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(paths); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(ready); + ASSERT_TRUE(positive); + ASSERT_TRUE(preserved); + ASSERT_TRUE(removed); + ASSERT_TRUE(refused); + PASS(); +} + +static bool pf_cancel_failure(pf_fixture_t *f, int variant) { + atomic_int cancelled; + atomic_init(&cancelled, variant == 0 ? 1 : 0); + cbm_pipeline_frozen_t *owner = NULL; + bool ready = pf_create(f, "{}", &cancelled, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + pf_hook_t hook = {.fixture = f, + .kind = variant == 2 ? PF_CANCEL_EXTERNAL : PF_CANCEL_OWNER, + .owner = owner, + .external = &cancelled}; + if (ready && variant == 1) + cbm_pipeline_frozen_cancel(owner); + if (ready && variant >= 2) + ready = cbm_pipeline_frozen_set_publish_hooks_for_tests(owner, pf_before_publish, NULL, + &hook) == CBM_PIPELINE_FROZEN_OK; + cbm_pipeline_frozen_status_t status = + ready ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + bool ok = ready && status == CBM_PIPELINE_FROZEN_CANCELLED && + !cbm_pipeline_frozen_candidate_path(owner) && + (variant < 2 || (hook.called && hook.changed)) && pf_absent(f->db) && + pf_directory_only(f->destination, NULL); + atomic_store(&cancelled, 0); + ok = cbm_pipeline_frozen_build(owner) == CBM_PIPELINE_FROZEN_INVALID && ok; + cbm_pipeline_frozen_free(owner); + return ok && pf_files_equal(f->control, f->saved); +} +static bool pf_rename_failure(pf_fixture_t *f) { + cbm_pipeline_frozen_t *owner = NULL; + pf_hook_t hook = {.fixture = f}; + bool ready = pf_create(f, "{}", NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner && + cbm_pipeline_frozen_set_publish_hooks_for_tests(owner, NULL, pf_fail_rename, + &hook) == CBM_PIPELINE_FROZEN_OK; + cbm_pipeline_frozen_status_t status = + ready ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + bool ok = ready && hook.rename_called && hook.stage[0] && status == CBM_PIPELINE_FROZEN_IO && + !cbm_pipeline_frozen_candidate_path(owner) && pf_absent(hook.stage) && + pf_absent(f->db) && pf_directory_only(f->destination, NULL) && + pf_files_equal(f->control, f->saved); + cbm_pipeline_frozen_free(owner); + return ok; +} +static bool pf_busy_retry(pf_fixture_t *f) { + cbm_pipeline_frozen_t *owner = NULL; + bool ready = pf_create(f, "{}", NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + bool locked = ready && cbm_pipeline_try_lock(); + cbm_pipeline_frozen_status_t status = + locked ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + bool busy = locked && status == CBM_PIPELINE_FROZEN_BUSY && + !cbm_pipeline_frozen_candidate_path(owner) && + pf_directory_only(f->destination, NULL); + if (locked) + cbm_pipeline_unlock(); + bool retry = busy && cbm_pipeline_frozen_build(owner) == CBM_PIPELINE_FROZEN_OK && + pf_ordinary_graph(f->db); + cbm_pipeline_frozen_free(owner); + return retry && pf_files_equal(f->control, f->saved); +} +TEST(pipeline_frozen_cancel_rename_and_busy_retry) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + bool positive = control && pf_feature_control(&f); + bool cancelled = positive; + for (int variant = 0; cancelled && variant < 4; ++variant) + cancelled = pf_cancel_failure(&f, variant); + bool rename = cancelled && pf_rename_failure(&f); + bool retry = rename && pf_busy_retry(&f); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(positive); + ASSERT_TRUE(cancelled); + ASSERT_TRUE(rename); + ASSERT_TRUE(retry); + PASS(); +} + +typedef struct { + bool extraction, manifest, history, invalid; +} pf_route_t; +static void pf_route(const cbm_pipeline_frozen_route_event_t *event, void *context) { + pf_route_t *seen = context; + if (!event) { + seen->invalid = true; + return; + } + switch (event->kind) { + case CBM_PIPELINE_FROZEN_ROUTE_EXTRACTION_WORKERS: + seen->extraction = true; + if (event->worker_count != 1) + seen->invalid = true; + break; + case CBM_PIPELINE_FROZEN_ROUTE_MANIFEST_WORKERS: + seen->manifest = true; + if (event->worker_count != 1) + seen->invalid = true; + break; + case CBM_PIPELINE_FROZEN_ROUTE_HISTORY_SKIPPED: + seen->history = true; + if (event->worker_count != 0) + seen->invalid = true; + break; + default: + seen->invalid = true; + break; + } +} +static bool pf_many_sources(pf_fixture_t *f) { + for (int i = 0; i < 65; ++i) { + char leaf[40], text[80]; + int a = snprintf(leaf, sizeof(leaf), "many_%02d.c", i); + int b = snprintf(text, sizeof(text), "void many_%02d(void) {}\n", i); + if (a <= 0 || (size_t)a >= sizeof(leaf) || b <= 0 || (size_t)b >= sizeof(text) || + !pf_source_write(f, leaf, text)) + return false; + } + cbm_file_info_t *files = NULL; + int count = 0; + cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL}; + bool ok = cbm_discover(f->source, &opts, &files, &count) == 0 && count > 64; + cbm_discover_free(files, count); + return ok; +} +static bool pf_resource_limit(pf_fixture_t *f) { + cbm_git_context_t git = pf_git(f); + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, "{}", &git, NULL); + in.resource_policy.max_files.enabled = true; + in.resource_policy.max_files.value = 1; + cbm_pipeline_frozen_t *owner = NULL; + bool ready = cbm_pipeline_frozen_create(&in, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + cbm_pipeline_frozen_status_t status = + ready ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + const cbm_pipeline_t *diag = cbm_pipeline_frozen_diagnostics(owner); + cbm_index_resource_violation_t violation = {0}; + if (diag) + cbm_pipeline_get_resource_violation(diag, &violation); + bool ok = ready && status == CBM_PIPELINE_FROZEN_LIMIT && diag && + violation.resource == CBM_INDEX_RESOURCE_FILES && violation.limit == 1 && + violation.observed > 1 && !cbm_pipeline_frozen_candidate_path(owner) && + pf_absent(f->db); + cbm_pipeline_frozen_free(owner); + return ok && pf_directory_only(f->destination, NULL); +} +TEST(pipeline_frozen_actual_serial_dispatch_and_history_skip) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + cbm_pipeline_frozen_t *owner = NULL; + pf_route_t seen = {0}, cleared = {0}; + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + bool many = control && pf_many_sources(&f) && cbm_setenv("CBM_WORKERS", "8", 1) == 0 && + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD") == 0 && pf_text(getenv("CBM_WORKERS"), "8"); + bool ready = many && pf_create(&f, "{}", NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + bool installed = ready && + cbm_pipeline_frozen_set_route_observer_for_tests(owner, pf_route, &cleared) == + CBM_PIPELINE_FROZEN_OK && + cbm_pipeline_frozen_set_route_observer_for_tests(owner, NULL, &cleared) == + CBM_PIPELINE_FROZEN_OK && + cbm_pipeline_frozen_set_route_observer_for_tests(owner, pf_route, &seen) == + CBM_PIPELINE_FROZEN_OK; + cbm_pipeline_frozen_status_t status = + installed ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + bool graph = + status == CBM_PIPELINE_FROZEN_OK && pf_ordinary_graph(f.db) && pf_branch_graph(&f, f.db); + bool observed = seen.extraction && seen.manifest && seen.history && !seen.invalid && + !cleared.extraction && !cleared.manifest && !cleared.history && + !cleared.invalid; + bool terminal = graph && + cbm_pipeline_frozen_set_route_observer_for_tests(owner, NULL, NULL) == + CBM_PIPELINE_FROZEN_INVALID && + cbm_pipeline_frozen_set_publish_hooks_for_tests(owner, NULL, NULL, NULL) == + CBM_PIPELINE_FROZEN_INVALID; + cbm_pipeline_frozen_free(owner); + bool limit = terminal && cbm_unlink(f.db) == 0 && pf_resource_limit(&f); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(many); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(ready); + ASSERT_TRUE(installed); + ASSERT_EQ(status, CBM_PIPELINE_FROZEN_OK); + ASSERT_TRUE(graph); + ASSERT_TRUE(observed); + ASSERT_TRUE(terminal); + ASSERT_TRUE(limit); + PASS(); +} + +static bool pf_create_rejects(const cbm_pipeline_frozen_inputs_t *in, + cbm_pipeline_frozen_t *guard) { + cbm_pipeline_frozen_t *out = guard; + cbm_pipeline_frozen_status_t status = cbm_pipeline_frozen_create(in, &out); + bool ok = status == CBM_PIPELINE_FROZEN_INVALID && !out; + if (out != guard) + cbm_pipeline_frozen_free(out); + return ok; +} +static bool pf_wide_oid_control(pf_fixture_t *f) { + cbm_git_context_t git = pf_git(f); + git.head_sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"; + git.base_sha = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, "{}", &git, NULL); + cbm_pipeline_frozen_t *owner = NULL; + bool ok = cbm_pipeline_frozen_create(&in, &owner) == CBM_PIPELINE_FROZEN_OK && owner && + cbm_pipeline_frozen_diagnostics(owner) && + !cbm_pipeline_frozen_candidate_path(owner) && pf_directory_only(f->destination, NULL); + cbm_pipeline_frozen_free(owner); + return ok; +} +static bool pf_required_context_fields(pf_fixture_t *f, cbm_pipeline_frozen_t *guard) { + cbm_git_context_t git = pf_git(f); + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, "{}", &git, NULL); + char **fields[] = {&git.input_path, &git.worktree_root, &git.git_dir, + &git.git_common_dir, &git.canonical_root, &git.branch, + &git.branch_slug, &git.head_sha, &git.base_sha}; + bool ok = true; + for (size_t i = 0; i < 9; ++i) { + char *saved = *fields[i]; + *fields[i] = NULL; + bool one = pf_create_rejects(&in, guard); + *fields[i] = saved; + if (!one) + printf(" required frozen context field %zu\n", i); + ok = one && ok; + } + git.is_git = false; + ok = pf_create_rejects(&in, guard) && ok; + git.is_git = true; + git.root_exists = false; + ok = pf_create_rejects(&in, guard) && ok; + git.root_exists = true; + git.canonical_root = ""; + return pf_create_rejects(&in, guard) && ok; +} +static bool pf_invalid_create(pf_fixture_t *f, cbm_pipeline_frozen_t *guard) { + cbm_git_context_t git = pf_git(f); + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, "{}", &git, NULL); + bool ok = pf_wide_oid_control(f) && pf_required_context_fields(f, guard); + in.project = "bad/project"; + ok = pf_create_rejects(&in, guard) && ok; + in.project = PF_PROJECT; + git.head_sha = "abc"; + ok = pf_create_rejects(&in, guard) && ok; + git.head_sha = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + ok = pf_create_rejects(&in, guard) && ok; /* Full but different OID widths. */ + git.head_sha = PF_H; + git.branch = ""; + ok = pf_create_rejects(&in, guard) && ok; + git.branch = "frozen-branch"; + char long_path[4097]; + memset(long_path, 'x', 4096); + long_path[0] = '/'; + long_path[4096] = '\0'; + in.source_root = long_path; + ok = pf_create_rejects(&in, guard) && ok; + in.source_root = f->source; + return pf_create_rejects(NULL, guard) && + cbm_pipeline_frozen_create(&in, NULL) == CBM_PIPELINE_FROZEN_INVALID && ok; +} +static bool pf_unsupported_create(pf_fixture_t *f, cbm_pipeline_frozen_t *guard) { + const char *configs[] = { + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[{" + "\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_" + "args\":[0,1]}]}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"pytest\":" + "false}}}}"}; + char missing[1024]; + if (!pf_path(missing, sizeof(missing), f->home, "missing-source") || !pf_absent(missing)) + return false; + cbm_git_context_t git = pf_git(f); + git.input_path = missing; + git.worktree_root = missing; + git.canonical_root = missing; + bool ok = true; + for (size_t i = 0; i < 2; ++i) { + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, configs[i], &git, NULL); + in.source_root = missing; + cbm_pipeline_frozen_t *out = guard; + cbm_pipeline_frozen_status_t status = cbm_pipeline_frozen_create(&in, &out); + bool one = status == CBM_PIPELINE_FROZEN_UNSUPPORTED && !out; + if (out != guard) + cbm_pipeline_frozen_free(out); + ok = one && ok; + } + cbm_pipeline_frozen_t *out = guard; + cbm_pipeline_frozen_status_t status = + pf_create(f, "{\"test_impact\":{\"version\":1,\"tests\":null}}", NULL, &out); + ok = status == CBM_PIPELINE_FROZEN_CONFIG && !out && ok; + if (out != guard) + cbm_pipeline_frozen_free(out); + return ok && pf_absent(missing); +} +static bool pf_mutate_independent_controls(pf_fixture_t *f, atomic_int *cancelled) { + cbm_pipeline_t *ordinary = cbm_pipeline_new(f->source, f->control, CBM_MODE_FAST); + if (!ordinary) + return false; + cbm_index_resource_policy_t policy = {0}; + policy.max_files.enabled = true; + policy.max_files.value = 1; + cbm_pipeline_set_persistence(ordinary, true); + cbm_pipeline_set_resource_policy(ordinary, &policy); + cbm_pipeline_bind_cancel_flag(ordinary, cancelled); + bool ok = cbm_pipeline_set_project_name(ordinary, "ordinarychanged") && + pf_text(cbm_pipeline_project_name(ordinary), "ordinarychanged") && + cbm_pipeline_get_mode(ordinary) == CBM_MODE_FAST; + cbm_pipeline_free(ordinary); + return ok; +} +TEST(pipeline_frozen_owner_isolation_state_and_global_restoration) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + cbm_pipeline_frozen_t *first = NULL, *second = NULL, *failed = NULL; + cbm_userconfig_t *ambient = NULL; + atomic_int ordinary_cancelled; + atomic_init(&ordinary_cancelled, 1); + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + bool source = control && pf_source_write(&f, "unit.pfsource", pf_source) && + pf_write(f.config, pf_physical_config); + if (source) + ambient = cbm_userconfig_load_with_source(f.source); + bool ambient_ok = ambient && cbm_userconfig_lookup(ambient, ".pfsource") == CBM_LANG_PYTHON; + if (ambient_ok) + cbm_set_user_lang_config(ambient); + bool ready = ambient_ok && pf_create(&f, pf_config0, NULL, &first) == CBM_PIPELINE_FROZEN_OK && + first && pf_create(&f, pf_config1, NULL, &second) == CBM_PIPELINE_FROZEN_OK && + second && cbm_get_user_lang_config() == ambient; + const cbm_pipeline_t *first_diag = ready ? cbm_pipeline_frozen_diagnostics(first) : NULL; + const cbm_pipeline_t *second_diag = ready ? cbm_pipeline_frozen_diagnostics(second) : NULL; + bool separate = + first_diag && second_diag && first_diag != second_diag && + cbm_pipeline_test_declarations(first_diag) != cbm_pipeline_test_declarations(second_diag); + bool controls = ready && pf_mutate_independent_controls(&f, &ordinary_cancelled) && + cbm_get_user_lang_config() == ambient; + bool invalid = controls && pf_invalid_create(&f, first) && pf_unsupported_create(&f, first) && + cbm_pipeline_frozen_build(NULL) == CBM_PIPELINE_FROZEN_INVALID && + !cbm_pipeline_frozen_candidate_path(NULL) && + !cbm_pipeline_frozen_diagnostics(NULL) && + cbm_pipeline_frozen_set_route_observer_for_tests(NULL, NULL, NULL) == + CBM_PIPELINE_FROZEN_INVALID; + bool built_first = invalid && pf_build_expected_ok(first) == CBM_PIPELINE_FROZEN_OK && + cbm_get_user_lang_config() == ambient && pf_fixture_graph(f.db, false); + bool state = built_first && cbm_pipeline_frozen_build(first) == CBM_PIPELINE_FROZEN_INVALID && + pf_text(cbm_pipeline_frozen_candidate_path(first), f.db) && + cbm_pipeline_frozen_set_publish_hooks_for_tests(first, NULL, NULL, NULL) == + CBM_PIPELINE_FROZEN_INVALID; + bool removed = state && cbm_unlink(f.db) == 0; + cbm_pipeline_frozen_free(first); + first = NULL; + bool built_second = removed && cbm_pipeline_frozen_build(second) == CBM_PIPELINE_FROZEN_OK && + cbm_get_user_lang_config() == ambient && pf_fixture_graph(f.db, true) && + cbm_pipeline_get_mode(second_diag) == CBM_MODE_FULL; + bool failed_publish = built_second && cbm_unlink(f.db) == 0 && pf_rename_failure(&f) && + cbm_get_user_lang_config() == ambient; + bool reset = + failed_publish && pf_create(&f, "{}", NULL, &failed) == CBM_PIPELINE_FROZEN_OK && failed; + if (reset) + cbm_pipeline_frozen_cancel(failed); + bool restored = reset && cbm_pipeline_frozen_build(failed) == CBM_PIPELINE_FROZEN_CANCELLED && + cbm_get_user_lang_config() == ambient && + !cbm_pipeline_frozen_candidate_path(failed) && + pf_directory_only(f.destination, NULL) && pf_files_equal(f.control, f.saved); + cbm_pipeline_frozen_free(first); + cbm_pipeline_frozen_free(second); + cbm_pipeline_frozen_free(failed); + cbm_pipeline_frozen_cancel(NULL); + cbm_pipeline_frozen_free(NULL); + cbm_set_user_lang_config(f.previous_global); + cbm_userconfig_free(ambient); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(source); + ASSERT_TRUE(ambient_ok); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(ready); + ASSERT_TRUE(separate); + ASSERT_TRUE(controls); + ASSERT_TRUE(invalid); + ASSERT_TRUE(built_first); + ASSERT_TRUE(state); + ASSERT_TRUE(built_second); + ASSERT_TRUE(failed_publish); + ASSERT_TRUE(restored); + PASS(); +} + +/* Independent recorder-integrity checks; append before the frozen suite. */ +typedef struct { + cbm_pipeline_frozen_t *owner; + bool incomplete, followup, cancel, called, rename_called, building_getter; + int count_during_hook; + char stage[4096]; +} pf_diag_hook_t; + +static void pf_diag_before_publish(cbm_pipeline_t *pipeline, const char *stage, void *context) { + pf_diag_hook_t *hook = context; + hook->called = true; + if (strlen(stage) < sizeof(hook->stage)) + memcpy(hook->stage, stage, strlen(stage) + 1); + unsigned flags = 123; + hook->building_getter = + !cbm_pipeline_frozen_negative_evidence(hook->owner, &flags) && flags == 0; + char path[] = "ordinary.c", reason[] = "observed fixture diagnostic", phase[] = "parse_partial"; + cbm_pipeline_add_file_error(pipeline, path, hook->incomplete ? NULL : reason, phase); + memset(path, 'x', sizeof(path) - 1); + memset(reason, 'y', sizeof(reason) - 1); + memset(phase, 'z', sizeof(phase) - 1); + if (hook->followup) + cbm_pipeline_add_file_error(pipeline, "followup.c", "retained later diagnostic", "extract"); + cbm_file_error_t *rows = NULL; + cbm_pipeline_get_file_errors(pipeline, &rows, &hook->count_during_hook); + if (hook->cancel) + cbm_pipeline_frozen_cancel(hook->owner); +} + +static int pf_diag_forbidden_rename(const char *stage, const char *destination, void *context) { + (void)stage; + (void)destination; + pf_diag_hook_t *hook = context; + hook->rename_called = true; + errno = EACCES; + return -1; +} + +static bool pf_diag_ordinary_control(pf_fixture_t *f) { + cbm_pipeline_t *p = cbm_pipeline_new(f->source, f->control, CBM_MODE_FULL); + if (!p) + return false; + char path[] = "ordinary.c", reason[] = "legacy record", phase[] = "extract"; + cbm_pipeline_add_file_error(p, path, reason, phase); + memset(path, 'x', sizeof(path) - 1); + memset(reason, 'y', sizeof(reason) - 1); + memset(phase, 'z', sizeof(phase) - 1); + cbm_file_error_t *rows = NULL; + int count = 0; + cbm_pipeline_get_file_errors(p, &rows, &count); + bool ok = count == 1 && rows && pf_text(rows[0].path, "ordinary.c") && + pf_text(rows[0].reason, "legacy record") && pf_text(rows[0].phase, "extract"); + cbm_pipeline_free(p); + return ok; +} + +static bool pf_diag_run(pf_fixture_t *f, cbm_pipeline_frozen_diag_fault_t fault, bool incomplete, + bool cancel) { + cbm_pipeline_frozen_t *owner = NULL; + bool ready = pf_create(f, "{}", NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + pf_diag_hook_t hook = {.owner = owner, + .incomplete = incomplete, + .followup = incomplete || fault != CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE, + .cancel = cancel}; + if (ready) + ready = cbm_pipeline_frozen_set_diagnostic_fault_for_tests(owner, fault) == + CBM_PIPELINE_FROZEN_OK && + cbm_pipeline_frozen_set_publish_hooks_for_tests(owner, pf_diag_before_publish, + pf_diag_forbidden_rename, + &hook) == CBM_PIPELINE_FROZEN_OK; + cbm_pipeline_frozen_status_t got = + ready ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + cbm_pipeline_frozen_status_t want = cancel ? CBM_PIPELINE_FROZEN_CANCELLED + : fault != CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE + ? CBM_PIPELINE_FROZEN_OOM + : CBM_PIPELINE_FROZEN_PIPELINE_ERROR; + unsigned flags = 0, expected = CBM_PIPELINE_FROZEN_FILE_DIAGNOSTIC; + if (incomplete || fault != CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE) + expected |= CBM_PIPELINE_FROZEN_DIAGNOSTIC_INCOMPLETE; + if (fault != CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE) + expected |= CBM_PIPELINE_FROZEN_DIAGNOSTIC_OOM; + bool evidence = cbm_pipeline_frozen_negative_evidence(owner, &flags) && flags == expected; + cbm_file_error_t *rows = NULL; + int count = 0; + cbm_pipeline_get_file_errors(cbm_pipeline_frozen_diagnostics(owner), &rows, &count); + bool recorded = count == 1 && rows && hook.count_during_hook == 1 && + pf_text(rows[0].path, hook.followup ? "followup.c" : "ordinary.c") && + pf_text(rows[0].reason, hook.followup ? "retained later diagnostic" + : "observed fixture diagnostic") && + pf_text(rows[0].phase, hook.followup ? "extract" : "parse_partial"); + bool failed_state = + !cbm_pipeline_frozen_candidate_path(owner) && + cbm_pipeline_frozen_build(owner) == CBM_PIPELINE_FROZEN_INVALID && + cbm_pipeline_frozen_set_diagnostic_fault_for_tests( + owner, CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE) == CBM_PIPELINE_FROZEN_INVALID; + unsigned again = 0; + bool sticky = cbm_pipeline_frozen_negative_evidence(owner, &again) && again == expected; + bool preserved = pf_absent(f->db) && pf_directory_only(f->destination, NULL) && hook.stage[0] && + pf_absent(hook.stage) && pf_files_equal(f->control, f->saved); + bool ok = ready && hook.called && got == want && !hook.rename_called && hook.building_getter && + evidence && recorded && failed_state && sticky && preserved; + if (!ok) + printf(" diagnostic fault=%d incomplete=%d cancel=%d status=%d expected=%d flags=%u " + "rows=%d rename=%d\n", + (int)fault, incomplete, cancel, (int)got, (int)want, flags, count, + hook.rename_called); + cbm_pipeline_frozen_free(owner); + return ok; +} + +static bool pf_diag_positive(pf_fixture_t *f) { + cbm_pipeline_frozen_t *owner = NULL; + bool ready = pf_create(f, "{}", NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + unsigned flags = 99; + bool invalid = !cbm_pipeline_frozen_negative_evidence(NULL, &flags) && flags == 0 && + !cbm_pipeline_frozen_negative_evidence(owner, NULL) && + cbm_pipeline_frozen_set_diagnostic_fault_for_tests( + NULL, CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE) == CBM_PIPELINE_FROZEN_INVALID; + bool controls = + ready && + cbm_pipeline_frozen_set_diagnostic_fault_for_tests( + owner, (cbm_pipeline_frozen_diag_fault_t)-1) == CBM_PIPELINE_FROZEN_INVALID && + cbm_pipeline_frozen_set_diagnostic_fault_for_tests( + owner, CBM_PIPELINE_FROZEN_DIAG_FAULT_PATH) == CBM_PIPELINE_FROZEN_OK && + cbm_pipeline_frozen_set_diagnostic_fault_for_tests( + owner, CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE) == CBM_PIPELINE_FROZEN_OK; + bool built = ready && cbm_pipeline_frozen_build(owner) == CBM_PIPELINE_FROZEN_OK && + pf_ordinary_graph(f->db); + flags = 99; + bool zero = cbm_pipeline_frozen_negative_evidence(owner, &flags) && flags == 0; + cbm_pipeline_frozen_free(owner); + bool removed = built && cbm_unlink(f->db) == 0 && pf_directory_only(f->destination, NULL); + return ready && invalid && controls && built && zero && removed; +} + +TEST(pipeline_frozen_late_diagnostic_blocks_publication) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + bool legacy = control && pf_diag_ordinary_control(&f); + bool positive = legacy && pf_feature_control(&f); + bool rejected = positive && pf_diag_run(&f, CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE, false, false); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(legacy); + ASSERT_TRUE(positive); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(pipeline_frozen_incomplete_diagnostic_is_sticky) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + bool positive = control && pf_feature_control(&f); + bool rejected = positive && pf_diag_run(&f, CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE, true, false); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(positive); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(pipeline_frozen_diagnostic_allocation_failures) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + bool positive = control && pf_feature_control(&f); + bool rejected = positive; + for (int fault = CBM_PIPELINE_FROZEN_DIAG_FAULT_GROW; + positive && fault <= CBM_PIPELINE_FROZEN_DIAG_FAULT_PHASE; fault++) + rejected = + pf_diag_run(&f, (cbm_pipeline_frozen_diag_fault_t)fault, false, false) && rejected; + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(positive); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(pipeline_frozen_diagnostic_state_and_cancel_precedence) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + bool positive = control && pf_feature_control(&f); + bool cancelled = positive && pf_diag_run(&f, CBM_PIPELINE_FROZEN_DIAG_FAULT_NONE, false, true); + bool isolated = cancelled && pf_diag_positive(&f); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(positive); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(cancelled); + ASSERT_TRUE(isolated); + PASS(); +} + +/* The recovered one-line macro retains a real parse-coverage diagnostic. + * Keep it as a publication-rejection case while the positive owner/config + * cases use multiline recovered definitions with unambiguous body ranges. */ +TEST(pipeline_frozen_real_parse_diagnostic_blocks_publication) { + ASSERT_EQ(pf_init_status, 0); + static const char one_line[] = + "void case_sink(void) {}\nvoid suite_sink(void) {}\n" + "CHECK(before, after) { case_sink(); }\nGROUP(group) { suite_sink(); }\n"; + pf_fixture_t f = {0}; + cbm_pipeline_frozen_t *owner = NULL; + bool setup = pf_open(&f), control = setup && pf_real_control(&f); + bool source = control && pf_source_write(&f, "unit.pfsource", one_line); + bool ready = + source && pf_create(&f, pf_config0, NULL, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + cbm_pipeline_frozen_status_t status = + ready ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + unsigned flags = 0; + bool evidence = cbm_pipeline_frozen_negative_evidence(owner, &flags); + cbm_file_error_t *rows = NULL; + int count = 0; + cbm_pipeline_get_file_errors(cbm_pipeline_frozen_diagnostics(owner), &rows, &count); + bool recorded = evidence && flags == CBM_PIPELINE_FROZEN_FILE_DIAGNOSTIC && rows && + count == 1 && pf_text(rows[0].path, "unit.pfsource") && + pf_text(rows[0].phase, "parse_partial") && pf_text(rows[0].reason, "3-3"); + bool rejected = status == CBM_PIPELINE_FROZEN_PIPELINE_ERROR && + !cbm_pipeline_frozen_candidate_path(owner) && pf_absent(f.db) && + pf_directory_only(f.destination, NULL) && pf_files_equal(f.control, f.saved); + cbm_pipeline_frozen_free(owner); + bool cleanup = pf_close(&f); + ASSERT_TRUE(setup); + ASSERT_TRUE(control); + ASSERT_TRUE(source); + ASSERT_TRUE(ready); + ASSERT_TRUE(cleanup); + ASSERT_TRUE(recorded); + ASSERT_TRUE(rejected); + PASS(); +} + +SUITE(pipeline_frozen) { + RUN_TEST(pipeline_frozen_real_parse_diagnostic_blocks_publication); + pf_init_status = cbm_init(); + RUN_TEST(pipeline_frozen_config_snapshot_contract); + RUN_TEST(pipeline_frozen_detached_graph_and_owned_context); + RUN_TEST(pipeline_frozen_final_reconciliation_and_explicit_override); + RUN_TEST(pipeline_frozen_preserves_artifacts_and_destination_namespace); + RUN_TEST(pipeline_frozen_cancel_rename_and_busy_retry); + RUN_TEST(pipeline_frozen_actual_serial_dispatch_and_history_skip); + RUN_TEST(pipeline_frozen_owner_isolation_state_and_global_restoration); + RUN_TEST(pipeline_frozen_late_diagnostic_blocks_publication); + RUN_TEST(pipeline_frozen_incomplete_diagnostic_is_sticky); + RUN_TEST(pipeline_frozen_diagnostic_allocation_failures); + RUN_TEST(pipeline_frozen_diagnostic_state_and_cancel_precedence); + cbm_work_arena_release(); +} diff --git a/tests/test_profile_map_v2.py b/tests/test_profile_map_v2.py new file mode 100644 index 000000000..0565499c4 --- /dev/null +++ b/tests/test_profile_map_v2.py @@ -0,0 +1,647 @@ +#!/usr/bin/env python3 +"""Independent frozen-contract tests for the pure single-image v2 producer. + +Python 3.9 compatible. --module selects the producer under test; --parser can +select its unchanged real dependency for a scratch proof. --fixtures selects +the shared native golden fixture directory. No LLVM, compiler, +runner, network, admission or source-origin proof is performed by this suite. +""" + +import argparse +from dataclasses import FrozenInstanceError, replace +import hashlib +import importlib.util +from pathlib import Path +import sys +import unittest + + +M = None +P = None +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures/profile_map_v2" +IMAGE_HEX = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +IMAGE = bytes.fromhex(IMAGE_HEX.decode("ascii")) +HIGH = 1 << 63 +U64 = (1 << 64) - 1 +GOLD_PROFILES = ( + b"CBM_PROFILE_MAP\t2\t" + IMAGE_HEX + b"\t3\n" + b"0\t61\t0000000000000000\t2\n" + b"1\t61\t8000000000000000\t1\n" + b"2\tff3a09207a\tffffffffffffffff\t2\n" +) +GOLD_TESTS = ( + b"s\xfe:*\tcomplete\t\t\n" + b"s\xfe:hit:raw\xfd\tcomplete\t\t0 2\n" + b"s\xfe:missing\tincomplete\tmissing_parent\t\n" + b"s\xfe:quiet\tcomplete\t\t\n" +) +GOLD_PROFILES_SHA = bytes.fromhex("ecc78b966bef4eb27a144eba98c2794ebf5f31a318f269e5e21d3453afd02d76") +GOLD_TESTS_SHA = bytes.fromhex("fe662544cf1d1eca44cc08ef6706ab04c59db76481b59aa3a4117a4c6c82da43") + + +def record(name, function_hash, counters): + """Hand-shaped supported proftext fixture, not a producer-output oracle.""" + return (name + b"\n# Func Hash:\n" + str(function_hash).encode("ascii") + + b"\n# Num Counters:\n" + str(len(counters)).encode("ascii") + + b"\n# Counter Values:\n" + + b"".join(str(value).encode("ascii") + b"\n" for value in counters) + b"\n") + + +def text(records): + return b"".join(record(*row) for row in records) + + +def ample(): + return M.ProfileMapLimits(8 * 1024 * 1024, 512, 1024, 5000, 50000, + 100000, 8 * 1024 * 1024, 10000, 16 * 1024 * 1024) + + +def claims(suite, test, flags=(True, True, True, True)): + return M.RowEvidence(suite, test, *flags) + + +def observation(suite, test, key, role, value): + return M.ProfileObservation(suite, test, key, role, value) + + +def gold_fixture(): + suite = b"s\xfe" + zero = text(((b"a", 0, (0, 0)), (b"a", HIGH, (0,)), (b"\xff:\t z", U64, (0, 0)))) + enumeration = text(((b"\xff:\t z", U64, (0, 0)), (b"a", HIGH, (0,)), (b"a", 0, (9, 0)))) + parent = text(((b"a", 0, (0, 7)), (b"a", HIGH, (0,)), (b"\xff:\t z", U64, (0, 0)))) + child = text(((b"a", 0, (0, 0)), (b"a", HIGH, (0,)), (b"\xff:\t z", U64, (0, 3)))) + return dict(image_sha256=IMAGE, enumeration_text=enumeration, + expected_suites=(M.ExpectedSuite(suite, (b"quiet", b"missing", b"hit:raw\xfd"), + M.SuiteOutcome.PASSED),), + observations=(observation(suite, b"hit:raw\xfd", b"\xff:child/ opaque", M.ObservationRole.CHILD, child), + observation(suite, b"quiet", b"quiet", M.ObservationRole.PARENT, zero), + observation(suite, b"*", b"setup", M.ObservationRole.SETUP, zero), + observation(suite, b"hit:raw\xfd", b"a-parent", M.ObservationRole.PARENT, parent)), + row_evidence=tuple(claims(suite, name) for name in (b"quiet", b"*", b"hit:raw\xfd", b"missing")), + limits=ample()) + + +def simple_fixture(): + zero = text(((b"a", 1, (0,)), (b"b", 2, (0, 0)))) + return dict(image_sha256=IMAGE, + enumeration_text=text(((b"a", 1, (5,)), (b"b", 2, (0, 0)))), + expected_suites=(M.ExpectedSuite(b"s", (b"t",), M.SuiteOutcome.PASSED),), + observations=(observation(b"s", b"*", b"s", M.ObservationRole.SETUP, zero), + observation(b"s", b"t", b"z", M.ObservationRole.PARENT, zero)), + row_evidence=(claims(b"s", b"*"), claims(b"s", b"t")), limits=ample()) + + +SIMPLE_PROFILES = (b"CBM_PROFILE_MAP\t2\t" + IMAGE_HEX + b"\t2\n" + b"0\t61\t0000000000000001\t1\n1\t62\t0000000000000002\t2\n") +SIMPLE_TESTS = b"s:*\tcomplete\t\t\ns:t\tcomplete\t\t\n" + + +def row_view(wire): + """Small assertion-side splitter for the already-expected four-field wire.""" + if wire and (not wire.endswith(b"\n") or b"\r" in wire or b"\0" in wire): + raise AssertionError("producer test wire has unsupported record delimiters") + rows = {} + for line in wire.split(b"\n")[:-1]: + key, state, reason, ids = line.split(b"\t") + if key in rows: + raise AssertionError("producer duplicated an expected row") + rows[key] = (state, reason, ids) + return rows + + +class CountCancel: + def __init__(self, at=0, failure=None): + self.at = at + self.failure = failure + self.calls = 0 + + def __call__(self, context): + self.calls += 1 + if self.at and self.calls >= self.at: + if self.failure is not None: + raise self.failure + return True + return False + + +class DependencyControl(unittest.TestCase): + def test_real_parser_preserves_full_identity_and_zero_records(self): + raw = text(((b"\xff:\t z", U64, (0, 0)), (b"a", HIGH, (0,)), (b"a", 0, (0, 7)))) + limits = P.ParseLimits(4096, 3, 5, 7) + parsed = P.parse_profile_text(raw, limits) + self.assertEqual(tuple((r.raw_name, r.function_hash, r.counters) for r in parsed), + ((b"a", 0, (0, 7)), (b"a", HIGH, (0,)), (b"\xff:\t z", U64, (0, 0)))) + with self.assertRaises(P.MalformedProfileError): + P.parse_profile_text(raw[:-1], limits) + + +class ProfileMapV2Tests(unittest.TestCase): + def assert_result(self, result, profiles, tests): + self.assertIs(type(result), M.ProducedMap) + self.assertIs(type(result.profiles_tsv), bytes) + self.assertIs(type(result.tests_tsv), bytes) + self.assertIs(type(result.profiles_sha256), bytes) + self.assertIs(type(result.tests_sha256), bytes) + self.assertEqual(result.profiles_tsv, profiles) + self.assertEqual(result.tests_tsv, tests) + self.assertEqual(result.profiles_sha256, hashlib.sha256(profiles).digest()) + self.assertEqual(result.tests_sha256, hashlib.sha256(tests).digest()) + self.assertEqual(len(result.profiles_sha256), 32) + self.assertEqual(len(result.tests_sha256), 32) + + def gold(self): + result = M.build_profile_map_v2(**gold_fixture()) + self.assert_result(result, GOLD_PROFILES, GOLD_TESTS) + self.assertEqual(result.profiles_sha256, GOLD_PROFILES_SHA) + self.assertEqual(result.tests_sha256, GOLD_TESTS_SHA) + self.assertEqual(result.profiles_tsv, (FIXTURE_DIR / "profiles.tsv").read_bytes()) + self.assertEqual(result.tests_tsv, (FIXTURE_DIR / "tests.tsv").read_bytes()) + return result + + def assert_row(self, fixture, key, state, reason, ids=b""): + result = M.build_profile_map_v2(**fixture) + self.assert_result(result, SIMPLE_PROFILES, result.tests_tsv) + rows = row_view(result.tests_tsv) + self.assertEqual(set(rows), {b"s:*", b"s:t"}) + self.assertEqual(rows[key], (state, reason, ids)) + return result + + def test_exact_wire_full_identity_zero_ids_and_input_order(self): + self.gold() + f = gold_fixture() + f["expected_suites"] = (replace(f["expected_suites"][0], + tests=tuple(reversed(f["expected_suites"][0].tests))),) + f["observations"] = tuple(reversed(f["observations"])) + f["row_evidence"] = tuple(reversed(f["row_evidence"])) + self.assert_result(M.build_profile_map_v2(**f), GOLD_PROFILES, GOLD_TESTS) + # Distinct raw names may share the entire hash; neither is collapsed. + old = b"\n18446744073709551615\n# Num Counters:" + new = b"\n0\n# Num Counters:" + f = gold_fixture() + f["enumeration_text"] = f["enumeration_text"].replace(old, new) + f["observations"] = tuple(replace(o, text=o.text.replace(old, new)) for o in f["observations"]) + self.assert_result(M.build_profile_map_v2(**f), + GOLD_PROFILES.replace(b"ffffffffffffffff", b"0000000000000000"), GOLD_TESTS) + empty = simple_fixture() + self.assert_result(M.build_profile_map_v2(**empty), SIMPLE_PROFILES, SIMPLE_TESTS) + empty["expected_suites"] = () + empty["observations"] = () + empty["row_evidence"] = () + self.assert_result(M.build_profile_map_v2(**empty), SIMPLE_PROFILES, b"") + empty["expected_suites"] = (M.ExpectedSuite(b"s", (), M.SuiteOutcome.PASSED),) + self.assert_result(M.build_profile_map_v2(**empty), SIMPLE_PROFILES, + b"s:*\tincomplete\trow_evidence_missing;missing_setup\t\n") + empty["observations"] = simple_fixture()["observations"][:1] + empty["row_evidence"] = (claims(b"s", b"*"),) + self.assert_result(M.build_profile_map_v2(**empty), SIMPLE_PROFILES, b"s:*\tcomplete\t\t\n") + + def test_expected_rows_primary_setup_outcomes_and_all_reason_codes(self): + self.gold() + base = simple_fixture() + self.assert_result(M.build_profile_map_v2(**base), SIMPLE_PROFILES, SIMPLE_TESTS) + for outcome, reason in ((M.SuiteOutcome.FAILED, b"suite_failed"), + (M.SuiteOutcome.TIMED_OUT, b"suite_timeout"), + (M.SuiteOutcome.UNKNOWN, b"suite_unknown")): + with self.subTest(outcome=outcome): + f = simple_fixture() + f["expected_suites"] = (replace(f["expected_suites"][0], outcome=outcome),) + result = self.assert_row(f, b"s:t", b"incomplete", reason) + self.assertEqual(row_view(result.tests_tsv)[b"s:*"], (b"incomplete", reason, b"")) + fields = (("interval_complete", b"interval_unproved"), + ("runtime_compatible", b"runtime_unproved"), + ("same_image", b"image_unproved"), ("children_complete", b"children_unproved")) + for field, reason in fields: + with self.subTest(field=field): + f = simple_fixture() + f["row_evidence"] = (f["row_evidence"][0], replace(f["row_evidence"][1], **{field: False})) + self.assert_row(f, b"s:t", b"incomplete", reason) + f = simple_fixture() + f["row_evidence"] = f["row_evidence"][:1] + self.assert_row(f, b"s:t", b"incomplete", b"row_evidence_missing") + for test_name, reason in ((b"t", b"missing_parent"), (b"*", b"missing_setup")): + f = simple_fixture() + f["observations"] = tuple(o for o in f["observations"] if o.test != test_name) + self.assert_row(f, b"s:" + test_name, b"incomplete", reason) + for value, reason in ((None, b"missing_parent;observation_unavailable"), + (b"", b"missing_parent;observation_malformed")): + f = simple_fixture() + f["observations"] = (f["observations"][0], replace(f["observations"][1], text=value)) + self.assert_row(f, b"s:t", b"incomplete", reason) + f = simple_fixture() + f["observations"] = (f["observations"][0], replace(f["observations"][1], + text=record(b"a", 1, (7,)))) + self.assert_row(f, b"s:t", b"incomplete", b"partial_stream", b"0") + + def test_stream_unions_child_does_not_replace_primary_and_reason_order(self): + self.gold() + full = text(((b"a", 1, (0,)), (b"b", 2, (0, U64)))) + f = simple_fixture() + child = observation(b"s", b"t", b"child", M.ObservationRole.CHILD, full) + f["observations"] += (child, replace(child, key=b"child-again")) + f["limits"] = replace(ample(), max_id_references=1) + self.assert_row(f, b"s:t", b"complete", b"", b"1") + f["observations"] = (f["observations"][0], child) + self.assert_row(f, b"s:t", b"incomplete", b"missing_parent", b"1") + setup_child = replace(child, test=b"*", key=b"setup-child") + f = simple_fixture() + f["observations"] = (setup_child, f["observations"][1]) + self.assert_row(f, b"s:*", b"incomplete", b"missing_setup", b"1") + f = simple_fixture() + f["observations"] += (replace(setup_child, role=M.ObservationRole.SETUP, key=b"second-setup"),) + self.assert_row(f, b"s:*", b"complete", b"", b"1") + f = simple_fixture() + f["expected_suites"] = (replace(f["expected_suites"][0], outcome=M.SuiteOutcome.FAILED),) + f["row_evidence"] = (f["row_evidence"][0], claims(b"s", b"t", (False,) * 4)) + f["observations"] = (f["observations"][0], + replace(f["observations"][1], text=None), + replace(child, key=b"bad", text=b"not a complete record"), + replace(child, key=b"partial", text=record(b"a", 1, (2,)))) + reason = (b"suite_failed;interval_unproved;runtime_unproved;image_unproved;" + b"children_unproved;missing_parent;observation_unavailable;" + b"observation_malformed;partial_stream") + self.assert_row(f, b"s:t", b"incomplete", reason, b"0") + f["row_evidence"] = f["row_evidence"][:1] + reason = (b"suite_failed;row_evidence_missing;missing_parent;observation_unavailable;" + b"observation_malformed;partial_stream") + self.assert_row(f, b"s:t", b"incomplete", reason, b"0") + + def test_each_aggregate_cap_at_boundary_and_one_below(self): + self.gold() + f, expected, exact = budget_fixture() + f["limits"] = exact + self.assert_result(M.build_profile_map_v2(**f), SIMPLE_PROFILES, expected) + for field in M.ProfileMapLimits.__slots__: + value = getattr(exact, field) + self.assertGreater(value, 1, "one-below must remain a valid positive configured cap") + with self.subTest(cap=field): + f["limits"] = replace(exact, **{field: value - 1}) + with self.assertRaises(M.ProfileMapLimitExceededError): + M.build_profile_map_v2(**f) + f["limits"] = exact + self.assert_result(M.build_profile_map_v2(**f), SIMPLE_PROFILES, expected) + + def test_malformed_attempt_discard_and_zero_remaining_allowance(self): + self.gold() + f, expected, exact = budget_fixture() + f["limits"] = exact + self.assert_result(M.build_profile_map_v2(**f), SIMPLE_PROFILES, expected) + # The malformed child's positive prefix must not leak a hit or consume + # successful record/name/counter allowances needed by the later parent. + zero = simple_fixture()["observations"][1].text + malformed = record(b"a", 1, (7,)) + b"b\n# Wrong marker\n" + f["observations"] = (f["observations"][0], replace(f["observations"][1], text=zero), + observation(b"s", b"t", b"a", M.ObservationRole.CHILD, malformed)) + f["limits"] = replace(exact, max_observations=3, + max_input_bytes=exact.max_input_bytes + len(malformed) + 3, + max_name_bytes=exact.max_name_bytes + 3, max_output_bytes=4096) + result = self.assert_row(f, b"s:t", b"incomplete", b"observation_malformed") + self.assertEqual(row_view(result.tests_tsv)[b"s:*"], (b"complete", b"", b"0")) + for field, consumed in (("max_records", 4), ("max_counter_values", 6), ("max_name_bytes", 16)): + f, _, _ = budget_fixture() + f["limits"] = replace(ample(), **{field: consumed}) + f["observations"] = (f["observations"][0], replace(f["observations"][1], text=None)) + with self.subTest(exhausted=field): + self.assert_row(f, b"s:t", b"incomplete", b"missing_parent;observation_unavailable") + f["observations"] = (f["observations"][0], replace(f["observations"][1], text=b"")) + with self.assertRaises(M.ProfileMapLimitExceededError): + M.build_profile_map_v2(**f) + + def test_whole_call_preflight_and_parser_limit_is_not_malformed(self): + self.gold() + f = simple_fixture() + f["enumeration_text"] = b"" + f["observations"] += (observation(b"s", b"t", b"zz", M.ObservationRole.CHILD, b"x" * 4096),) + f["limits"] = replace(ample(), max_input_bytes=1024) + with self.assertRaises(M.ProfileMapLimitExceededError): + M.build_profile_map_v2(**f) + f = simple_fixture() + f["enumeration_text"] = b"" + f["observations"] += (observation(b"s", b"unexpected", b"zz", M.ObservationRole.CHILD, None),) + with self.assertRaises(M.ProfileMapInvalidArgumentError): + M.build_profile_map_v2(**f) + # An attempt sees a genuine record cap before its later malformed tail. + f = simple_fixture() + f["observations"] = (f["observations"][0], replace(f["observations"][1], + text=record(b"a", 1, (0,)) + record(b"b", 2, (0, 0)) + b"broken")) + f["limits"] = replace(ample(), max_records=5) + with self.assertRaises(M.ProfileMapLimitExceededError) as caught: + M.build_profile_map_v2(**f) + self.assertIsInstance(caught.exception.__cause__, P.LimitExceededError) + + def test_callback_provenance_invalid_results_and_entry_order(self): + held = self.gold() + f = simple_fixture() + observed = CountCancel() + self.assert_result(M.build_profile_map_v2(**f, cancel=observed), SIMPLE_PROFILES, SIMPLE_TESTS) + self.assertGreater(observed.calls, 0) + token = object() + + def context_predicate(context): + if context is not token: + raise AssertionError("callback context was not preserved") + return False + + self.assert_result(M.build_profile_map_v2(**f, cancel=context_predicate, + cancel_context=token), SIMPLE_PROFILES, SIMPLE_TESTS) + positions = sampled_positions(observed.calls) + failures = (ValueError("caller sentinel"), P.MalformedProfileError("caller malformed class"), + P.LimitExceededError("caller limit class"), P.ParseCancelledError("caller cancel class"), + M.ProfileMapEnumerationError("caller map class"), MemoryError("caller memory class")) + for failure in failures: + for position in positions: + with self.subTest(error=type(failure).__name__, position=position): + callback = CountCancel(position, failure) + caught = None + result = None + try: + result = M.build_profile_map_v2(**f, cancel=callback) + except BaseException as error: + caught = error + if callback.calls >= position: + self.assertIs(caught, failure) + self.assertIsNone(result) + else: + self.assertIsNone(caught) + self.assert_result(result, SIMPLE_PROFILES, SIMPLE_TESTS) + if position == 1: + self.assertIs(caught, failure) + for value in (None, 0, 1, b"", object()): + with self.subTest(callback_result=repr(value)): + with self.assertRaises(M.ProfileMapInvalidArgumentError): + M.build_profile_map_v2(**f, cancel=lambda context, answer=value: answer) + invalid = dict(f, image_sha256=b"short") + never = CountCancel(1) + with self.assertRaises(M.ProfileMapInvalidArgumentError): + M.build_profile_map_v2(**invalid, cancel=never) + self.assertEqual(never.calls, 0) + self.assert_result(held, GOLD_PROFILES, GOLD_TESTS) + self.assert_result(M.build_profile_map_v2(**f), SIMPLE_PROFILES, SIMPLE_TESTS) + + def test_bounded_cancellation_immutable_results_and_independent_calls(self): + held = self.gold() + f, profiles, tests = stress_fixture() + observed = CountCancel() + result = M.build_profile_map_v2(**f, cancel=observed, cancel_context=object()) + self.assert_result(result, profiles, tests) + self.assertGreater(observed.calls, 0) + for position in sampled_positions(observed.calls): + callback = CountCancel(position) + returned = None + cancelled = False + try: + returned = M.build_profile_map_v2(**f, cancel=callback) + except M.ProfileMapCancelledError: + cancelled = True + with self.subTest(position=position): + if callback.calls >= position: + self.assertTrue(cancelled) + self.assertIsNone(returned) + else: + self.assertFalse(cancelled) + self.assert_result(returned, profiles, tests) + if position == 1: + self.assertTrue(cancelled) + self.assert_result(result, profiles, tests) + self.assert_result(held, GOLD_PROFILES, GOLD_TESTS) + self.assert_result(M.build_profile_map_v2(**f), profiles, tests) + del f + self.assert_result(result, profiles, tests) + for field in M.ProducedMap.__slots__: + with self.subTest(immutable=field): + with self.assertRaises((FrozenInstanceError, AttributeError)): + setattr(result, field, b"changed") + self.assert_result(result, profiles, tests) + + def test_strict_schema_membership_roles_names_and_exact_types(self): + self.gold() + for label, fixture in bad_schema_cases(): + with self.subTest(case=label): + with self.assertRaises(M.ProfileMapInvalidArgumentError): + M.build_profile_map_v2(**fixture) + for label, fixture in bad_type_cases(): + with self.subTest(case=label): + with self.assertRaises(M.ProfileMapInvalidArgumentError): + M.build_profile_map_v2(**fixture) + + def test_enumeration_failure_and_observation_incompatibility_are_fatal(self): + self.gold() + valid = simple_fixture()["enumeration_text"] + malformed = (b"", valid[:-1], valid + b"# unknown section\n", + record(b"a\r", 1, (0,)), record(b"a\0", 1, (0,)), + record(b"a", 1, (0,)) * 2, + record(b"a", 1, (0,)) + record(b"a", 1, (0, 0)), + valid.replace(b"# Func Hash:", b"# Unknown:", 1), + valid.replace(b"\n1\n# Num", b"\n01\n# Num", 1), + record(b"a", U64 + 1, (0,))) + for value in malformed: + f = simple_fixture() + f["enumeration_text"] = value + with self.subTest(enumeration=value[:24]): + with self.assertRaises(M.ProfileMapEnumerationError) as caught: + M.build_profile_map_v2(**f) + self.assertIsInstance(caught.exception.__cause__, P.MalformedProfileError) + incompatible = (record(b"a", 3, (0,)), record(b"new", 1, (0,)), + record(b"a", 1, (0, 0))) + for value in incompatible: + for incomplete in (False, True): + f = simple_fixture() + f["observations"] = (f["observations"][0], replace(f["observations"][1], text=value)) + if incomplete: + f["expected_suites"] = (replace(f["expected_suites"][0], outcome=M.SuiteOutcome.FAILED),) + f["row_evidence"] = (f["row_evidence"][0], claims(b"s", b"t", (False,) * 4)) + with self.subTest(identity=value[:24], incomplete=incomplete): + with self.assertRaises(M.ProfileMapCompatibilityError): + M.build_profile_map_v2(**f) + + +def budget_fixture(): + f = simple_fixture() + setup = text(((b"a", 1, (1,)), (b"b", 2, (0, 0)))) + parent = text(((b"a", 1, (1,)), (b"b", 2, (0, 1)))) + f["observations"] = (replace(f["observations"][0], text=setup), + replace(f["observations"][1], text=parent)) + expected = b"s:*\tcomplete\t\t0\ns:t\tcomplete\t\t0 1\n" + # B = expected (1+1) + two observation routes (1+1+1)*2 + + # two evidence routes (1+1)*2 = 12. Three two-record streams contribute + # six names of one byte and nine counter values. Emitted ID refs = 1+2. + exact = M.ProfileMapLimits(32 + len(f["enumeration_text"]) + len(setup) + len(parent) + 12, + 2, 2, 2, 6, 9, 18, 3, len(SIMPLE_PROFILES) + len(expected)) + return f, expected, exact + + +def sampled_positions(calls): + # At most five observed positions, with no phase/count or time guarantee. + return tuple(sorted({1, calls // 4 + 1, calls // 2 + 1, calls - calls // 4, calls})) + + +def stress_fixture(): + first = b"a" * 4097 + second = b"a" * 4096 + b"b" + zero = text(((first, 0, (0, 0)), (second, U64, (0,)))) + hit = text(((first, 0, (0, 1)), (second, U64, (0,)))) + enumeration = text(((second, U64, (0,)), (first, 0, (9, 0)))) + names = tuple(b"x" * 4097 + ("%03d" % i).encode("ascii") for i in range(128)) + expected = (M.ExpectedSuite(b"s", tuple(reversed(names)), M.SuiteOutcome.PASSED),) + observations = (observation(b"s", b"*", b"setup", M.ObservationRole.SETUP, zero),) + tuple( + observation(b"s", name, ("k%03d" % i).encode("ascii"), M.ObservationRole.PARENT, hit) + for i, name in enumerate(reversed(names))) + evidence = (claims(b"s", b"*"),) + tuple(claims(b"s", name) for name in names) + profiles = (b"CBM_PROFILE_MAP\t2\t" + IMAGE_HEX + b"\t2\n0\t" + first.hex().encode("ascii") + + b"\t0000000000000000\t2\n1\t" + second.hex().encode("ascii") + + b"\tffffffffffffffff\t1\n") + tests = b"s:*\tcomplete\t\t\n" + b"".join(b"s:" + name + b"\tcomplete\t\t0\n" for name in names) + f = dict(image_sha256=IMAGE, enumeration_text=enumeration, expected_suites=expected, + observations=observations, row_evidence=evidence, limits=ample()) + return f, profiles, tests + + +def renamed_fixture(suite=None, test=None): + f = simple_fixture() + suite = b"s" if suite is None else suite + test = b"t" if test is None else test + f["expected_suites"] = (M.ExpectedSuite(suite, (test,), M.SuiteOutcome.PASSED),) + f["observations"] = tuple(replace(o, suite=suite, test=test if o.test == b"t" else b"*") + for o in f["observations"]) + f["row_evidence"] = tuple(replace(e, suite=suite, test=test if e.test == b"t" else b"*") + for e in f["row_evidence"]) + return f + + +def bad_schema_cases(): + for value in (b"", b"s:", b"s\0", b"s\t", b"s\r", b"s\n"): + yield ("suite-name-" + repr(value), renamed_fixture(suite=value)) + for value in (b"", b"*", b"t\0", b"t\t", b"t\r", b"t\n"): + yield ("test-name-" + repr(value), renamed_fixture(test=value)) + for value in (b"", b"k\0", b"k\t", b"k\r", b"k\n"): + f = simple_fixture() + f["observations"] = (f["observations"][0], replace(f["observations"][1], key=value)) + yield ("observation-key-" + repr(value), f) + f = simple_fixture() + f["expected_suites"] *= 2 + yield ("duplicate-suite", f) + f = simple_fixture() + f["expected_suites"] = (replace(f["expected_suites"][0], tests=(b"t", b"t")),) + yield ("duplicate-test", f) + for field in ("observations", "row_evidence"): + f = simple_fixture() + f[field] += (f[field][0],) + yield ("duplicate-" + field, f) + f = simple_fixture() + f["observations"] += (replace(f["observations"][1], key=b"another-parent"),) + yield ("two-parent-keys", f) + f = simple_fixture() + f["observations"] += (replace(f["observations"][1], role=M.ObservationRole.CHILD),) + yield ("same-key-different-role", f) + f = simple_fixture() + f["observations"] += (replace(f["observations"][1], text=None),) + yield ("same-key-different-text", f) + for target in ("observations", "row_evidence"): + for field, value in (("suite", b"other"), ("test", b"other")): + f = simple_fixture() + f[target] = (f[target][0], replace(f[target][1], **{field: value})) + yield ("unexpected-" + target + "-" + field, f) + for index, role in ((0, M.ObservationRole.PARENT), (1, M.ObservationRole.SETUP)): + f = simple_fixture() + observations = list(f["observations"]) + observations[index] = replace(observations[index], role=role) + f["observations"] = tuple(observations) + yield ("invalid-role-" + str(index), f) + + +def bad_type_cases(): + for field, value in (("image_sha256", b"x" * 31), ("image_sha256", b"x" * 33), + ("image_sha256", bytearray(32)), ("enumeration_text", "text"), + ("expected_suites", []), ("observations", []), ("row_evidence", []), + ("limits", None), ("cancel", 17)): + f = simple_fixture() + f[field] = value + yield ("top-level-" + field + "-" + repr(type(value)), f) + derived_bytes = type("DerivedBytes", (bytes,), {}) + derived_tuple = type("DerivedTuple", (tuple,), {}) + for field, value in (("image_sha256", derived_bytes(IMAGE)), + ("observations", derived_tuple(simple_fixture()["observations"]))): + f = simple_fixture() + f[field] = value + yield ("subclass-" + field, f) + for field, value in (("tests", [b"t"]), ("outcome", "passed"), ("suite", "s")): + f = simple_fixture() + f["expected_suites"] = (replace(f["expected_suites"][0], **{field: value}),) + yield ("expected-field-" + field, f) + for field, value in (("role", "parent"), ("text", bytearray()), ("key", "key"), + ("suite", b"s".decode("ascii")), ("test", 1)): + f = simple_fixture() + f["observations"] = (f["observations"][0], replace(f["observations"][1], **{field: value})) + yield ("observation-field-" + field, f) + for field in ("interval_complete", "runtime_compatible", "same_image", "children_complete"): + f = simple_fixture() + f["row_evidence"] = (f["row_evidence"][0], replace(f["row_evidence"][1], **{field: 1})) + yield ("evidence-bool-" + field, f) + for field in ("expected_suites", "observations", "row_evidence"): + f = simple_fixture() + f[field] = (object(),) + yield ("record-type-" + field, f) + f = simple_fixture() + derived_expected = type("DerivedExpectedSuite", (M.ExpectedSuite,), {}) + f["expected_suites"] = (derived_expected(b"s", (b"t",), M.SuiteOutcome.PASSED),) + yield ("dataclass-subclass", f) + for field in ("observations", "row_evidence"): + f = simple_fixture() + old = f[field][0] + derived = type("DerivedRecord", (type(old),), {}) + f[field] = (derived(*(getattr(old, name) for name in type(old).__slots__)),) + f[field][1:] + yield ("record-subclass-" + field, f) + f = simple_fixture() + derived_limits = type("DerivedLimits", (M.ProfileMapLimits,), {}) + f["limits"] = derived_limits(*(getattr(f["limits"], name) for name in M.ProfileMapLimits.__slots__)) + yield ("limits-subclass", f) + f = simple_fixture() + derived_int = type("DerivedInt", (int,), {}) + f["limits"] = replace(f["limits"], max_rows=derived_int(512)) + yield ("limit-value-subclass", f) + for field in M.ProfileMapLimits.__slots__: + for value in (0, -1, True, sys.maxsize + 1): + f = simple_fixture() + f["limits"] = replace(f["limits"], **{field: value}) + yield ("invalid-limit-" + field + "-" + repr(value), f) + f = simple_fixture() + f["limits"] = replace(f["limits"], max_ids=2147483648) + yield ("ids-int-representation", f) + + +def load_module(name, path): + spec = importlib.util.spec_from_file_location(name, str(path)) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load requested module: " + str(path)) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def main(): + global M, P, FIXTURE_DIR + arguments = argparse.ArgumentParser(description=__doc__) + default_module = Path(__file__).resolve().parents[1] / "scripts/test-impact/profile_map_v2.py" + arguments.add_argument("--module", type=Path, default=default_module) + arguments.add_argument("--parser", type=Path, + help="real unchanged parser; default profile_text_parser.py beside --module") + arguments.add_argument("--fixtures", type=Path, default=FIXTURE_DIR, + help="shared golden directory; default fixtures/profile_map_v2 beside this test") + selected = arguments.parse_args() + FIXTURE_DIR = selected.fixtures.resolve() + module_path = selected.module.resolve() + parser_path = (selected.parser or module_path.with_name("profile_text_parser.py")).resolve() + if not module_path.is_file() or not parser_path.is_file(): + arguments.error("--module and --parser must name existing source files") + sys.dont_write_bytecode = True + # Freeze the actual dependency before importing the chosen producer; both + # baseline and implementation use this real parser, never a fake grammar. + P = load_module("profile_text_parser", parser_path) + M = load_module("profile_map_v2_under_test", module_path) + suite = unittest.TestSuite((unittest.defaultTestLoader.loadTestsFromTestCase(DependencyControl), + unittest.defaultTestLoader.loadTestsFromTestCase(ProfileMapV2Tests))) + return 0 if unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful() else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_profile_text_parser.py b/tests/test_profile_text_parser.py new file mode 100644 index 000000000..2a880baa9 --- /dev/null +++ b/tests/test_profile_text_parser.py @@ -0,0 +1,216 @@ +"""Contract tests for complete LLVM frontend profile text records.""" +import dataclasses +import hashlib +from pathlib import Path +import sys +import threading +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'scripts/test-impact')) +import profile_text_parser as p + +CAPTURES = ROOT / 'tests/fixtures/profile_text' +BIG = p.ParseLimits(2_000_000, 20_000, 100_000, 1_000_000) +MAX = 18446744073709551615 + + +def wire(name=b'f', function_hash=24, counters=(0,)): + return (name + b'\n# Func Hash:\n' + str(function_hash).encode() + + b'\n# Num Counters:\n' + str(len(counters)).encode() + + b'\n# Counter Values:\n' + + b''.join(str(x).encode() + b'\n' for x in counters) + b'\n') + + +def values(rows): + return tuple((x.raw_name, x.function_hash, x.counters) for x in rows) + + +class ProfileTextContract(unittest.TestCase): + def test_native_dense_and_sparse_captures(self): + expected = { + 'cold-dense.proftext': ('b8f1bd2ca4c7dd9e2ca567b410897ca8f5c0c648a1d73648f58724a708a93daf', + ((b'cbm_profile_probe_hit',24,(0,)), (b'cbm_profile_probe_never',24,(0,)), + (b'main',490457921399630188,(1,0,1,1,1,0,0)))), + 'hot-dense.proftext': ('3f77bbbda8d76b4d440109d24f0431f8b9bca6628bc0241470d1dbbb2c2695ec', + ((b'cbm_profile_probe_hit',24,(1,)), (b'cbm_profile_probe_never',24,(0,)), + (b'main',490457921399630188,(1,0,1,1,0,1,1)))), + 'cold-sparse.proftext': ('50979a6198fcfcdabcc3f09057009be6cc54053eea4af581aadf4ec91b3199a7', + ((b'main',490457921399630188,(1,0,1,1,1,0,0)),)), + 'hot-sparse.proftext': ('72bf344ccd0897cfe3c5625657e6912d645deb1fb495d6dea551b40b89f9dc68', + ((b'cbm_profile_probe_hit',24,(1,)), + (b'main',490457921399630188,(1,0,1,1,0,1,1))))} + for name, (digest, oracle) in expected.items(): + with self.subTest(name=name): + data = (CAPTURES/name).read_bytes() + self.assertEqual(hashlib.sha256(data).hexdigest(), digest) + self.assertEqual(values(p.parse_profile_text(data, BIG)), oracle) + + def test_full_identity_order_and_all_counters(self): + rows = ((b'\xff:name\t #',MAX,(0,MAX)), (b'z',10,(0,)), + (b'z',2,(0,0)), (b' a ',0,(1,)), (b'# Func Hash:',24,(0,))) + data = b''.join(wire(*r) for r in rows) + parsed = p.parse_profile_text(data, BIG) + self.assertEqual(values(parsed), (rows[3], rows[4], rows[2], rows[1], rows[0])) + self.assertEqual(values(p.parse_profile_text(b''.join(wire(*r) for r in reversed(rows)), BIG)), + values(parsed)) + self.assertTrue(any(parsed[-1].counters)) + self.assertEqual(parsed[-1].counters[0], 0) + + def test_exact_grammar_and_unsigned_decimals(self): + good = wire() + malformed = [b'', b'\n', b'\n'+good, good+b'\n', good+b'# unknown\n', + good.replace(b'\n',b'\r\n'), good.replace(b'f\n',b'f\x00\n',1), + good.replace(b'# Func Hash:',b'# Function Hash:'), + good.replace(b'# Num Counters:',b'# Num Counters: '), + good.replace(b'# Counter Values:',b'# Values:'), + good.replace(b'\n1\n# Counter Values:',b'\n0\n# Counter Values:'), + good.replace(b'\n0\n\n',b'\n0\n0\n\n'), + good.replace(b'\n0\n\n',b'\n\n'), b':irprof\n'+good] + for decimal in (b'', b'00', b'024', b'+24', b'-1', b' 24', b'24 ', b'0x18', + b'2.4', b'2e1', b'18446744073709551616'): + malformed.append(good.replace(b'\n24\n',b'\n'+decimal+b'\n')) + malformed.append(good.replace(b'\n0\n\n',b'\n'+decimal+b'\n\n')) + self.assertEqual(values(p.parse_profile_text(good, BIG)), ((b'f',24,(0,)),)) + for index,data in enumerate(malformed): + with self.subTest(index=index): + with self.assertRaises(p.MalformedProfileError): + p.parse_profile_text(data, BIG) + + def test_all_proper_prefixes_and_suffix_rejection(self): + data = wire(b'prefix:name',MAX,(0,1,MAX)) + self.assertEqual(len(p.parse_profile_text(data,BIG)),1) + for length in range(len(data)): + with self.subTest(length=length): + with self.assertRaises(p.MalformedProfileError): + p.parse_profile_text(data[:length],BIG) + for tail in (b'x',b'\x00',wire()[:-1],b'# Num Value Kinds:\n0\n'): + with self.assertRaises(p.MalformedProfileError): + p.parse_profile_text(data+tail,BIG) + + def test_duplicate_identity_and_hash_distinction(self): + one=wire(b'name',1,(0,)); other=wire(b'other',1,(1,)) + self.assertEqual(len(p.parse_profile_text(one+wire(b'name',2,(0,0)),BIG)),2) + for duplicate in (one,wire(b'name',1,(1,)),wire(b'name',1,(0,0))): + with self.assertRaises(p.MalformedProfileError): + p.parse_profile_text(one+other+duplicate,BIG) + + def test_inclusive_resource_limits_and_claimed_counts(self): + data=wire(b'abc',1,(0,1))+wire(b'de',2,(0,))+wire(b'f',3,(0,0)) + exact=(len(data),3,5,6) + self.assertEqual(len(p.parse_profile_text(data,p.ParseLimits(*exact))),3) + for index in range(4): + limited=list(exact);limited[index]-=1 + with self.subTest(index=index): + with self.assertRaises(p.LimitExceededError): + p.parse_profile_text(data,p.ParseLimits(*limited)) + claimed=b'f\n# Func Hash:\n0\n# Num Counters:\n18446744073709551615\n# Counter Values:\n' + with self.assertRaises(p.LimitExceededError): + p.parse_profile_text(claimed,BIG) + with self.assertRaises(p.MalformedProfileError): + p.parse_profile_text(claimed.replace(str(MAX).encode(),str(MAX+1).encode()),BIG) + + def test_argument_types_and_limit_validation(self): + good=wire() + for invalid in (None,good.decode(),bytearray(good),memoryview(good)): + with self.assertRaises(p.InvalidArgumentError): + p.parse_profile_text(invalid,BIG) + for value in (0,-1,True,1.0,None,sys.maxsize+1): + for index in range(4): + limits=[1000,1000,1000,1000];limits[index]=value + with self.assertRaises(p.InvalidArgumentError): + p.parse_profile_text(good,p.ParseLimits(*limits)) + with self.assertRaises(p.InvalidArgumentError): + p.parse_profile_text(good,None) + for callback in (False,1,'callback'): + with self.assertRaises(p.InvalidArgumentError): + p.parse_profile_text(good,BIG,cancel=callback) + for answer in (None,0,1,'true'): + with self.assertRaises(p.InvalidArgumentError): + p.parse_profile_text(good,BIG,cancel=lambda _:answer) + + def test_cancellation_and_exception_propagation_are_owner_local(self): + marker=object();calls=[] + def cancelled(context): + self.assertIs(context,marker);calls.append(context);return True + with self.assertRaises(p.ParseCancelledError): + p.parse_profile_text(b'not a profile',BIG,cancel=cancelled,cancel_context=marker) + self.assertEqual(len(calls),1) + class CallbackFailure(Exception): pass + sentinel=CallbackFailure('callback marker') + def explode(_): raise sentinel + with self.assertRaises(CallbackFailure) as caught: + p.parse_profile_text(wire(),BIG,cancel=explode) + self.assertIs(caught.exception,sentinel) + big=wire(b'x'*100_000,MAX,(0,1)) + for cutoff in (2,3): + state=[0] + def later(_): state[0]+=1;return state[0]>=cutoff + with self.assertRaises(p.ParseCancelledError): + p.parse_profile_text(big,BIG,cancel=later) + self.assertEqual(state[0],cutoff) + self.assertEqual(values(p.parse_profile_text(wire(),BIG)),((b'f',24,(0,)),)) + + def test_cancellation_sweep_including_sort_and_final_publication(self): + rows=tuple((b'q'*5000+bytes((97+i,)),i,(0,1)) for i in reversed(range(8))) + data=b''.join(wire(*row) for row in rows) + calls=[0] + def observe(_): calls[0]+=1;return False + expected=values(p.parse_profile_text(data,BIG,cancel=observe)) + self.assertEqual(expected,tuple(reversed(rows))) + self.assertGreater(calls[0],3) + self.assertLessEqual(calls[0],512) # Bounds the deterministic sweep itself. + for cutoff in range(1,calls[0]+1): + state=[0] + def cancel_at(_): state[0]+=1;return state[0]>=cutoff + with self.subTest(cutoff=cutoff): + with self.assertRaises(p.ParseCancelledError): + p.parse_profile_text(data,BIG,cancel=cancel_at) + self.assertEqual(state[0],cutoff) + self.assertEqual(values(p.parse_profile_text(data,BIG)),expected) + + def test_held_owner_survives_concurrent_cancellation(self): + self.assertEqual(values(p.parse_profile_text(wire(),BIG)),((b'f',24,(0,)),)) + entered=threading.Event();release=threading.Event();out=[];errors=[] + def run(): + try: + owner=p.parse_profile_text(wire(b'held',MAX,(0,1)),BIG) + entered.set() + if not release.wait(10): raise AssertionError('release gate failed') + out.append(owner) + except BaseException as error: errors.append(error) + thread=threading.Thread(target=run);thread.start() + try: + self.assertTrue(entered.wait(10),'entry gate failed') + other=p.parse_profile_text(wire(b'other',2,(1,)),BIG) + with self.assertRaises(p.ParseCancelledError): + p.parse_profile_text(wire(b'cancelled'),BIG,cancel=lambda _:True) + self.assertEqual(values(other),((b'other',2,(1,)),)) + finally: + release.set();thread.join(10) + self.assertFalse(thread.is_alive());self.assertEqual(errors,[]) + self.assertEqual(len(out),1);self.assertEqual(values(out[0]),((b'held',MAX,(0,1)),)) + + def test_result_immutability_and_independent_owners(self): + a=p.parse_profile_text(wire(b'a',1,(0,MAX)),BIG) + b=p.parse_profile_text(wire(b'b',2,(1,)),BIG) + self.assertIsInstance(a,tuple);self.assertIsInstance(a[0].counters,tuple) + with self.assertRaises((dataclasses.FrozenInstanceError,AttributeError)): + a[0].raw_name=b'changed' + self.assertEqual(values(a),((b'a',1,(0,MAX)),));self.assertEqual(values(b),((b'b',2,(1,)),)) + start=threading.Event();out=[None,None];errors=[] + def run(slot): + try: + if not start.wait(10): raise AssertionError('start gate failed') + out[slot]=p.parse_profile_text(wire(bytes((97+slot,)),slot,(0,)),BIG) + except BaseException as error: errors.append(error) + threads=[threading.Thread(target=run,args=(i,)) for i in range(2)] + for thread in threads: thread.start() + start.set() + for thread in threads: thread.join(10);self.assertFalse(thread.is_alive()) + self.assertEqual(errors,[]) + self.assertEqual(values(out[0]),((b'a',0,(0,)),));self.assertEqual(values(out[1]),((b'b',1,(0,)),)) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_store_graph_digest.c b/tests/test_store_graph_digest.c new file mode 100644 index 000000000..6e69fa9b8 --- /dev/null +++ b/tests/test_store_graph_digest.c @@ -0,0 +1,607 @@ +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include + +/* Fixed D2a fixture; generated by the independent contract oracle. */ +static const char *const gd_schema[] = { + "CREATE TABLE projects(name TEXT PRIMARY KEY,indexed_at TEXT,root_path TEXT)", + "CREATE TABLE file_hashes(project TEXT,rel_path TEXT,sha256 TEXT,mtime_ns INTEGER,size INTEGER,PRIMARY KEY(project,rel_path))", + "CREATE TABLE nodes(id INTEGER PRIMARY KEY,project TEXT,label TEXT,name TEXT,qualified_name TEXT,file_path TEXT,start_line INTEGER,end_line INTEGER,properties TEXT)", + "CREATE TABLE edges(id INTEGER PRIMARY KEY,project TEXT,source_id INTEGER,target_id INTEGER,type TEXT,properties TEXT,url_path_gen TEXT GENERATED ALWAYS AS (properties || ':u'),local_name_gen TEXT GENERATED ALWAYS AS (properties || ':l'))", + "CREATE TABLE project_summaries(project TEXT PRIMARY KEY,summary TEXT,source_hash TEXT,created_at TEXT,updated_at TEXT)", + "CREATE TABLE lsp_surface(project TEXT,rel_path TEXT,surface_sha TEXT,defs_json TEXT,ref_bloom BLOB,config_ctx TEXT,PRIMARY KEY(project,rel_path))", + "CREATE TABLE index_coverage(project TEXT,rel_path TEXT,kind TEXT,detail TEXT,PRIMARY KEY(project,rel_path,kind))", + "CREATE TABLE index_coverage_meta(project TEXT PRIMARY KEY,generation TEXT,index_mode TEXT,recorded_at TEXT,recording_status TEXT,ignored_files_stored INTEGER,ignored_files_total INTEGER,coverage_version INTEGER,hash_records_complete INTEGER)", + "CREATE TABLE store_meta(k TEXT PRIMARY KEY,v TEXT)", +}; +static const char *const gd_inserts[] = { + "INSERT INTO projects(name,indexed_at,root_path) VALUES(CAST(X'70' AS TEXT),CAST(X'7430' AS TEXT),CAST(X'2f726f6f74' AS TEXT));", + "INSERT INTO projects(name,indexed_at,root_path) VALUES(CAST(X'71' AS TEXT),CAST(X'7431' AS TEXT),CAST(X'2f6f74686572' AS TEXT));", + "INSERT INTO file_hashes(project,rel_path,sha256,mtime_ns,size) VALUES(CAST(X'70' AS TEXT),CAST(X'' AS TEXT),CAST(X'656d707479' AS TEXT),0,0);", + "INSERT INTO file_hashes(project,rel_path,sha256,mtime_ns,size) VALUES(CAST(X'70' AS TEXT),CAST(X'61' AS TEXT),CAST(X'736861' AS TEXT),-1,1);", + "INSERT INTO file_hashes(project,rel_path,sha256,mtime_ns,size) VALUES(CAST(X'70' AS TEXT),CAST(X'610062' AS TEXT),CAST(X'73686132' AS TEXT),2,3);", + "INSERT INTO file_hashes(project,rel_path,sha256,mtime_ns,size) VALUES(CAST(X'70' AS TEXT),CAST(X'ff' AS TEXT),CAST(X'73686133' AS TEXT),4,5);", + "INSERT INTO nodes(id,project,label,name,qualified_name,file_path,start_line,end_line,properties) VALUES(-7,CAST(X'70' AS TEXT),CAST(X'46756e6374696f6e' AS TEXT),CAST(X'' AS TEXT),CAST(X'702e61' AS TEXT),NULL,-9223372036854775808,9223372036854775807,CAST(X'7b7d0078' AS TEXT));", + "INSERT INTO nodes(id,project,label,name,qualified_name,file_path,start_line,end_line,properties) VALUES(11,CAST(X'71' AS TEXT),CAST(X'46756e6374696f6e' AS TEXT),CAST(X'62' AS TEXT),CAST(X'712e62' AS TEXT),CAST(X'' AS TEXT),NULL,-1,NULL);", + "INSERT INTO edges(id,project,source_id,target_id,type,properties) VALUES(3,CAST(X'71' AS TEXT),11,-7,CAST(X'43414c4c53' AS TEXT),CAST(X'' AS TEXT));", + "INSERT INTO edges(id,project,source_id,target_id,type,properties) VALUES(8,CAST(X'70' AS TEXT),-7,11,CAST(X'5553414745' AS TEXT),NULL);", + "INSERT INTO project_summaries(project,summary,source_hash,created_at,updated_at) VALUES(CAST(X'70' AS TEXT),CAST(X'6c696e65310a6c696e6532' AS TEXT),CAST(X'68' AS TEXT),CAST(X'63' AS TEXT),CAST(X'75' AS TEXT));", + "INSERT INTO lsp_surface(project,rel_path,surface_sha,defs_json,ref_bloom,config_ctx) VALUES(CAST(X'70' AS TEXT),CAST(X'61' AS TEXT),CAST(X'7330' AS TEXT),CAST(X'7b7d' AS TEXT),NULL,CAST(X'637478' AS TEXT));", + "INSERT INTO lsp_surface(project,rel_path,surface_sha,defs_json,ref_bloom,config_ctx) VALUES(CAST(X'70' AS TEXT),CAST(X'6161' AS TEXT),CAST(X'7331' AS TEXT),CAST(X'5b5d' AS TEXT),X'',CAST(X'' AS TEXT));", + "INSERT INTO lsp_surface(project,rel_path,surface_sha,defs_json,ref_bloom,config_ctx) VALUES(CAST(X'70' AS TEXT),CAST(X'62' AS TEXT),CAST(X'7332' AS TEXT),CAST(X'7b7d' AS TEXT),X'00ff800d0a',CAST(X'63747832' AS TEXT));", + "INSERT INTO index_coverage(project,rel_path,kind,detail) VALUES(CAST(X'70' AS TEXT),CAST(X'61' AS TEXT),CAST(X'7061727469616c' AS TEXT),NULL);", + "INSERT INTO index_coverage(project,rel_path,kind,detail) VALUES(CAST(X'70' AS TEXT),CAST(X'62' AS TEXT),CAST(X'736b6970' AS TEXT),CAST(X'' AS TEXT));", + "INSERT INTO index_coverage_meta(project,generation,index_mode,recorded_at,recording_status,ignored_files_stored,ignored_files_total,coverage_version,hash_records_complete) VALUES(CAST(X'70' AS TEXT),CAST(X'67656e' AS TEXT),CAST(X'66756c6c' AS TEXT),CAST(X'74' AS TEXT),CAST(X'636f6d706c657465' AS TEXT),-1,0,1,1);", +}; +static const unsigned char gd_golden_sha[32] = { 0x46, 0x44, 0x71, 0x72, 0xdc, 0x9b, 0x2d, 0xa8, 0xda, 0xc8, 0x54, 0x5a, 0x56, 0x88, 0xab, 0x59, 0x1d, 0xad, 0x39, 0xe2, 0x9b, 0xe6, 0x33, 0x9c, 0x3d, 0x26, 0x49, 0x27, 0xd0, 0x94, 0xe9, 0x3e }; +#define GD_GOLDEN_ROWS UINT64_C(17) +#define GD_GOLDEN_BYTES UINT64_C(4038) + + +static const cbm_store_graph_digest_limits_t gd_room = {UINT64_C(100000), UINT64_C(8388608)}; +static int gd_sql(sqlite3 *db, const char *sql) { + return sqlite3_exec(db, sql, NULL, NULL, NULL); +} +static bool gd_int(sqlite3 *db, const char *sql, int64_t want) { + sqlite3_stmt *q=NULL; + bool ok=sqlite3_prepare_v2(db,sql,-1,&q,NULL)==SQLITE_OK && sqlite3_step(q)==SQLITE_ROW && + sqlite3_column_int64(q,0)==want && sqlite3_step(q)==SQLITE_DONE; + if (q && sqlite3_finalize(q)!=SQLITE_OK) ok=false; + return ok; +} +static bool gd_seed(sqlite3 *db, bool reverse, bool meta) { + static const char drop[] = "PRAGMA foreign_keys=OFF;" + "DROP TABLE IF EXISTS edges;DROP TABLE IF EXISTS nodes;DROP TABLE IF EXISTS file_hashes;" + "DROP TABLE IF EXISTS project_summaries;DROP TABLE IF EXISTS lsp_surface;" + "DROP TABLE IF EXISTS index_coverage;DROP TABLE IF EXISTS index_coverage_meta;" + "DROP TABLE IF EXISTS projects;DROP TABLE IF EXISTS store_meta;"; + if (!db || gd_sql(db,drop)!=SQLITE_OK) return false; + for (size_t i=0;i<8+(size_t)meta;i++) if (gd_sql(db,gd_schema[i])!=SQLITE_OK) return false; + size_t n=sizeof(gd_inserts)/sizeof(gd_inserts[0]); + for (size_t i=0;iversion || v->rows || v->framed_bytes) return false; + for (size_t i=0;isha256);i++) if (v->sha256[i]) return false; + return true; +} +static bool gd_equal(const cbm_store_graph_digest_t *a,const cbm_store_graph_digest_t *b) { + return a->version==b->version && a->rows==b->rows && a->framed_bytes==b->framed_bytes && + memcmp(a->sha256,b->sha256,sizeof(a->sha256))==0; +} +static bool gd_different(const cbm_store_graph_digest_t *a,const cbm_store_graph_digest_t *b) { + return memcmp(a->sha256,b->sha256,sizeof(a->sha256))!=0; +} +static bool gd_read(cbm_store_t *s,const unsigned char *project,size_t len, + cbm_store_graph_digest_t *out) { + cbm_store_read_scope_t *scope=NULL; + int opened=s?cbm_store_read_scope_open(s,NULL,NULL,&scope):CBM_STORE_ERR; + int rc=opened==CBM_STORE_OK?cbm_store_graph_digest(scope,project,len,&gd_room,out):CBM_STORE_GRAPH_DIGEST_ERROR; + int closed=cbm_store_read_scope_close(scope); + return opened==CBM_STORE_OK && rc==CBM_STORE_GRAPH_DIGEST_OK && closed==CBM_STORE_OK; +} +static bool gd_p(cbm_store_t *s,cbm_store_graph_digest_t *out) { + return gd_read(s,(const unsigned char *)"p",1,out); +} +/* Caller closes the connection immediately after this failure helper. */ +static bool gd_reject(cbm_store_t *s,cbm_store_graph_digest_status_t want) { + cbm_store_read_scope_t *scope=NULL; cbm_store_graph_digest_t out; + memset(&out,0xa5,sizeof(out)); + int opened=cbm_store_read_scope_open(s,NULL,NULL,&scope); + int rc=opened==CBM_STORE_OK?cbm_store_graph_digest(scope,(const unsigned char *)"p",1,&gd_room,&out):-1; + bool zero=gd_zero(&out); + int latch=scope?cbm_store_read_scope_check(scope):0; + int closed=cbm_store_read_scope_close(scope); + return opened==CBM_STORE_OK && rc==(int)want && zero && latch==CBM_STORE_ERR && closed==CBM_STORE_ERR; +} +static int gd_stmt_count(sqlite3 *db) { + int n=0;for(sqlite3_stmt *s=sqlite3_next_stmt(db,NULL);s;s=sqlite3_next_stmt(db,s))n++; + return n; +} + +typedef struct { uint64_t calls,trip; bool armed,stop; } gd_cancel_t; +/* Pure callback, including cancellation: only caller-owned scalar state. */ +static bool gd_cancel(void *opaque) { + gd_cancel_t *c=opaque; + if (c->armed && c->callscalls++; + return c->stop || (c->armed && c->trip && c->calls>=c->trip); +} + +TEST(store_graph_digest_independent_canonical_golden) { + cbm_store_t *s=gd_fixture(false,false); cbm_store_graph_digest_t out={0}; + bool setup=s && gd_int(cbm_store_get_db(s),"SELECT count(*) FROM nodes",2) && + gd_int(cbm_store_get_db(s),"SELECT length(CAST(properties AS BLOB)) FROM nodes WHERE id=-7",4) && + gd_int(cbm_store_get_db(s),"SELECT count(*) FROM lsp_surface WHERE typeof(ref_bloom)='blob'",2); + bool got=setup && gd_p(s,&out); cbm_store_close(s); + ASSERT_TRUE(setup); ASSERT_TRUE(got); ASSERT_EQ(out.version,1); + ASSERT_TRUE(out.rows==GD_GOLDEN_ROWS); ASSERT_TRUE(out.framed_bytes==GD_GOLDEN_BYTES); + ASSERT_TRUE(memcmp(out.sha256,gd_golden_sha,32)==0); PASS(); +} + +TEST(store_graph_digest_logical_order_and_excluded_storage_are_stable) { + cbm_store_t *a=gd_fixture(false,false),*b=gd_fixture(true,false); + cbm_store_graph_digest_t x={0},y={0},z={0},q={0}; + bool setup=a && b; + bool controls=setup && gd_p(a,&x) && gd_p(b,&y); + bool layout=controls && gd_sql(cbm_store_get_db(b), + "CREATE TABLE noise(id INTEGER PRIMARY KEY AUTOINCREMENT,value BLOB);" + "INSERT INTO noise VALUES(999,zeroblob(70000));DELETE FROM noise;" + "CREATE INDEX irrelevant_node_name ON nodes(name);PRAGMA reverse_unordered_selects=ON;") == SQLITE_OK; + bool again=layout && gd_p(b,&z) && gd_read(b,(const unsigned char *)"q",1,&q); + char dir[1024]={0},path[1200]={0};const char *tmp=th_mktempdir("cbm-digest-layout"); + bool named=tmp && strlen(tmp)0 && (size_t)n0 && (size_t)n0 && (size_t)n1); + /* Nine bounded, observed positions. Counts do not identify SHA/SQL phases. */ + for(unsigned i=0;i<9;i++) { + cbm_store_t *s=gd_fixture(false,false);bool setup=s && gd_sql(cbm_store_get_db(s),"UPDATE lsp_surface SET ref_bloom=zeroblob(262145) WHERE rel_path='b'")==SQLITE_OK; + gd_cancel_t cancel={0};scope=NULL;opened=setup?cbm_store_read_scope_open(s,gd_cancel,&cancel,&scope):CBM_STORE_ERR; + cbm_store_graph_digest_t out,retry;memset(&out,0xa5,sizeof(out));memset(&retry,0xa5,sizeof(retry)); + cancel.trip=1+(polls-1)*i/8;cancel.armed=true; + rc=scope?cbm_store_graph_digest(scope,(const unsigned char *)"p",1,&gd_room,&out):-1; + cancel.armed=false;int latch=scope?cbm_store_read_scope_check(scope):0; + int again=scope?cbm_store_graph_digest(scope,(const unsigned char *)"p",1,&gd_room,&retry):-1; + closed=cbm_store_read_scope_close(scope);cbm_store_close(s); + ASSERT_TRUE(setup);ASSERT_EQ(opened,CBM_STORE_OK);ASSERT_EQ(rc,CBM_STORE_GRAPH_DIGEST_CANCELLED);ASSERT_TRUE(gd_zero(&out));ASSERT_EQ(latch,CBM_STORE_CANCELLED);ASSERT_EQ(again,CBM_STORE_GRAPH_DIGEST_CANCELLED);ASSERT_TRUE(gd_zero(&retry));ASSERT_EQ(closed,CBM_STORE_CANCELLED); + } + PASS(); +} + +typedef struct { const char *denied_table; bool restore; unsigned denials; } gd_auth_t; +static int gd_authorizer(void *opaque,int op,const char *a,const char *b,const char *db,const char *trigger) { + gd_auth_t *c=opaque;(void)db;(void)trigger; + if(op==SQLITE_READ && a && c->denied_table && + (strcmp(a,c->denied_table)==0 || (strcmp(c->denied_table,"@schema")==0 && + (strcmp(a,"sqlite_master")==0 || strcmp(a,"sqlite_schema")==0)))) { + c->denials++;return SQLITE_DENY; + } + if(c->restore && op==SQLITE_PRAGMA && a && b && strcmp(a,"query_only")==0)return SQLITE_DENY; + return SQLITE_OK; +} +TEST(store_graph_digest_sql_errors_cleanup_no_mutation_and_close_discard) { + for(int phase=0;phase<2;phase++) { + cbm_store_t *s=gd_fixture(false,false);sqlite3 *db=s?cbm_store_get_db(s):NULL; + gd_auth_t auth={0}; + bool installed=db && sqlite3_set_authorizer(db,gd_authorizer,&auth)==SQLITE_OK; + cbm_store_read_scope_t *scope=NULL;int opened=installed?cbm_store_read_scope_open(s,NULL,NULL,&scope):CBM_STORE_ERR; + auth.denied_table=phase?"file_hashes":"@schema"; + int before=db?gd_stmt_count(db):-1;cbm_store_graph_digest_t out;memset(&out,0xa5,sizeof(out)); + int rc=scope?cbm_store_graph_digest(scope,(const unsigned char *)"p",1,&gd_room,&out):-1; + int after=db?gd_stmt_count(db):-2; + int closed=cbm_store_read_scope_close(scope);cbm_store_close(s); + ASSERT_TRUE(installed);ASSERT_TRUE(auth.denials>0);ASSERT_EQ(opened,CBM_STORE_OK);ASSERT_EQ(rc,CBM_STORE_GRAPH_DIGEST_ERROR);ASSERT_TRUE(gd_zero(&out));ASSERT_EQ(before,after);ASSERT_EQ(closed,CBM_STORE_ERR); + } + cbm_store_t *s=gd_fixture(false,false);sqlite3 *db=s?cbm_store_get_db(s):NULL; + int changes=db?sqlite3_total_changes(db):-1; + cbm_store_graph_digest_t a={0},b={0};bool read=s && gd_p(s,&a) && gd_p(s,&b); + bool unchanged=read && sqlite3_total_changes(db)==changes && gd_equal(&a,&b) && + gd_int(db,"SELECT count(*) FROM sqlite_schema WHERE name='store_meta'",0) && + gd_int(db,"SELECT count(*) FROM projects WHERE name='p' AND indexed_at='t0'",1); + cbm_store_close(s);ASSERT_TRUE(read);ASSERT_TRUE(unchanged); + s=gd_fixture(false,false);db=s?cbm_store_get_db(s):NULL;gd_auth_t auth={0}; + bool installed=db && sqlite3_set_authorizer(db,gd_authorizer,&auth)==SQLITE_OK; + cbm_store_read_scope_t *scope=NULL;int opened=installed?cbm_store_read_scope_open(s,NULL,NULL,&scope):CBM_STORE_ERR; + int rc=scope?cbm_store_graph_digest(scope,(const unsigned char *)"p",1,&gd_room,&a):-1; + auth.restore=true;int closed=cbm_store_read_scope_close(scope); + /* A successful calculation is deliberately not consumed after DISCARD. */ + memset(&a,0,sizeof(a));cbm_store_close(s); + ASSERT_TRUE(installed);ASSERT_EQ(opened,CBM_STORE_OK);ASSERT_EQ(rc,CBM_STORE_GRAPH_DIGEST_OK);ASSERT_EQ(closed,CBM_STORE_SCOPE_DISCARD); + s=gd_fixture(false,false);scope=NULL;opened=s?cbm_store_read_scope_open(s,NULL,NULL,&scope):CBM_STORE_ERR; + bool lost=scope && gd_sql(cbm_store_get_db(s),"ROLLBACK")==SQLITE_OK;memset(&a,0xa5,sizeof(a)); + rc=lost?cbm_store_graph_digest(scope,(const unsigned char *)"p",1,&gd_room,&a):-1; + closed=cbm_store_read_scope_close(scope);cbm_store_close(s); + ASSERT_EQ(opened,CBM_STORE_OK);ASSERT_TRUE(lost);ASSERT_EQ(rc,CBM_STORE_GRAPH_DIGEST_ERROR);ASSERT_TRUE(gd_zero(&a));ASSERT_TRUE(closed==CBM_STORE_ERR || closed==CBM_STORE_SCOPE_DISCARD);PASS(); +} + +typedef struct { + cbm_store_t *store;cbm_mutex_t gate;atomic_bool arrived,done; + uint64_t calls,trip;bool armed,cancel; + int opened,rc,latch,closed;cbm_store_graph_digest_t out; +} gd_job_t; +/* Synchronizes only caller-owned state. No SQLite/store/scope calls in predicate. */ +static bool gd_gate_cancel(void *opaque) { + gd_job_t *j=opaque; + if(!j->armed)return false; + j->calls++; + if(j->trip && j->calls==j->trip) { + atomic_store_explicit(&j->arrived,true,memory_order_release); + cbm_mutex_lock(&j->gate);cbm_mutex_unlock(&j->gate); + return j->cancel; + } + return false; +} +static void *gd_worker(void *opaque) { + gd_job_t *j=opaque;cbm_store_read_scope_t *scope=NULL; + j->opened=cbm_store_read_scope_open(j->store,gd_gate_cancel,j,&scope); + memset(&j->out,0xa5,sizeof(j->out));j->armed=true; + j->rc=scope?cbm_store_graph_digest(scope,(const unsigned char *)"p",1,&gd_room,&j->out):-1; + j->armed=false;j->latch=scope?cbm_store_read_scope_check(scope):CBM_STORE_ERR; + j->closed=cbm_store_read_scope_close(scope); + atomic_store_explicit(&j->done,true,memory_order_release);return NULL; +} +static void gd_job_init(gd_job_t *j,cbm_store_t *s) { + memset(j,0,sizeof(*j));j->store=s;atomic_init(&j->arrived,false);atomic_init(&j->done,false);cbm_mutex_init(&j->gate); +} +static bool gd_wait_arrived(gd_job_t *j) { + while(!atomic_load_explicit(&j->arrived,memory_order_acquire) && + !atomic_load_explicit(&j->done,memory_order_acquire)) { /* Held-gate rendezvous, no clock. */ } + return atomic_load_explicit(&j->arrived,memory_order_acquire); +} +TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates) { + cbm_store_t *a=gd_fixture(false,false),*b=gd_fixture(true,true); + gd_job_t control_a,control_b;gd_job_init(&control_a,a);gd_job_init(&control_b,b); + bool setup=a && b; + if(setup){gd_worker(&control_a);gd_worker(&control_b);} + bool controls=setup && control_a.opened==CBM_STORE_OK && control_b.opened==CBM_STORE_OK && + control_a.rc==CBM_STORE_GRAPH_DIGEST_OK && control_b.rc==CBM_STORE_GRAPH_DIGEST_OK && + control_a.closed==CBM_STORE_OK && control_b.closed==CBM_STORE_OK && + control_a.calls>1 && control_b.calls>1 && gd_different(&control_a.out,&control_b.out); + cbm_mutex_destroy(&control_a.gate);cbm_mutex_destroy(&control_b.gate); + if(!controls){cbm_store_close(a);cbm_store_close(b);ASSERT_TRUE(setup);ASSERT_TRUE(controls);} + for(int cancel=0;cancel<2;cancel++) { + gd_job_t ja,jb;gd_job_init(&ja,a);gd_job_init(&jb,b); + ja.trip=control_a.calls/2;jb.trip=control_b.calls/2;ja.cancel=cancel!=0; + cbm_mutex_lock(&ja.gate);cbm_mutex_lock(&jb.gate); + cbm_thread_t ta,tb;int ca=cbm_thread_create(&ta,0,gd_worker,&ja); + int cb=ca==0?cbm_thread_create(&tb,0,gd_worker,&jb):-1; + bool arrived_a=ca==0 && cb==0 && gd_wait_arrived(&ja); + bool arrived_b=ca==0 && cb==0 && gd_wait_arrived(&jb); + int joined_a=-1,joined_b=-1; + if(cancel) { + /* Cancellation completes while B is suspended; B must still resume correctly. */ + cbm_mutex_unlock(&ja.gate);if(ca==0)joined_a=cbm_thread_join(&ta); + cbm_mutex_unlock(&jb.gate);if(cb==0)joined_b=cbm_thread_join(&tb); + } else { + /* B finishes while A retains its in-progress state. */ + cbm_mutex_unlock(&jb.gate);if(cb==0)joined_b=cbm_thread_join(&tb); + cbm_mutex_unlock(&ja.gate);if(ca==0)joined_a=cbm_thread_join(&ta); + } + bool b_ok=jb.opened==CBM_STORE_OK && jb.rc==CBM_STORE_GRAPH_DIGEST_OK && jb.latch==CBM_STORE_OK && + jb.closed==CBM_STORE_OK && gd_equal(&jb.out,&control_b.out); + bool a_ok=ja.opened==CBM_STORE_OK && (cancel? + (ja.rc==CBM_STORE_GRAPH_DIGEST_CANCELLED && ja.latch==CBM_STORE_CANCELLED && ja.closed==CBM_STORE_CANCELLED && gd_zero(&ja.out)): + (ja.rc==CBM_STORE_GRAPH_DIGEST_OK && ja.latch==CBM_STORE_OK && ja.closed==CBM_STORE_OK && gd_equal(&ja.out,&control_a.out))); + cbm_mutex_destroy(&ja.gate);cbm_mutex_destroy(&jb.gate); + if(ca || cb || !arrived_a || !arrived_b || joined_a || joined_b || !a_ok || !b_ok) { + cbm_store_close(a);cbm_store_close(b);FAIL("held-gate invocation isolation failed"); + } + } + cbm_store_close(a);cbm_store_close(b);PASS(); +} + +SUITE(store_graph_digest) { + RUN_TEST(store_graph_digest_independent_canonical_golden); + RUN_TEST(store_graph_digest_logical_order_and_excluded_storage_are_stable); + RUN_TEST(store_graph_digest_every_known_value_and_cross_project_edge_is_bound); + RUN_TEST(store_graph_digest_raw_types_project_bytes_and_schema_sql_are_distinct); + RUN_TEST(store_graph_digest_legacy_generation_and_live_schema_compatibility); + RUN_TEST(store_graph_digest_schema_and_runtime_types_fail_closed); + RUN_TEST(store_graph_digest_wal_pins_old_content_then_observes_next_scope); + RUN_TEST(store_graph_digest_exact_budgets_zero_output_and_terminal_scope); + RUN_TEST(store_graph_digest_invalid_arguments_missing_project_and_initial_latches); + RUN_TEST(store_graph_digest_cancellation_large_blob_never_publishes_prefix); + RUN_TEST(store_graph_digest_sql_errors_cleanup_no_mutation_and_close_discard); + RUN_TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates); +} diff --git a/tests/test_store_impact.c b/tests/test_store_impact.c new file mode 100644 index 000000000..0c1224fc6 --- /dev/null +++ b/tests/test_store_impact.c @@ -0,0 +1,363 @@ +/* + * test_store_impact.c — the fixpoint impact walk (src/store/store_impact.c). + * + * The walk answers "what depends on these nodes" for test selection, so its + * result must be a pure function of the graph and the request: complete (no + * depth bound, cycles included), and identical whatever order rows, node ids + * or seeds happen to arrive in. + */ +#include "test_framework.h" +#include +#include +#include +#include +#include + +static int64_t add_node(cbm_store_t *s, const char *label, const char *name) { + char qn[64]; + snprintf(qn, sizeof(qn), "test.%s", name); + cbm_node_t node = {.project = "test", .label = label, .name = name, .qualified_name = qn}; + return cbm_store_upsert_node(s, &node); +} + +/* `from` depends on `to`: the edge the walk follows backwards. */ +static void add_edge(cbm_store_t *s, int64_t from, int64_t to, const char *type) { + cbm_edge_t e = {.project = "test", .source_id = from, .target_id = to, .type = type}; + (void)cbm_store_insert_edge(s, &e); +} + +static cbm_store_t *new_store(void) { + cbm_store_t *s = cbm_store_open_memory(); + if (s) { + cbm_store_upsert_project(s, "test", "/tmp/test"); + } + return s; +} + +static const char *const CALLS_ONLY[] = {"CALLS"}; +static const char *const CALLS_USAGE[] = {"CALLS", "USAGE"}; + +static cbm_impact_walk_t *open_walk(cbm_store_t *s, const char *const *types, int count, + bool routes) { + cbm_impact_policy_t policy = { + .project = "test", .edge_types = types, .edge_type_count = count, .follow_routes = routes}; + cbm_impact_walk_t *w = NULL; + return cbm_impact_walk_open(s, &policy, &w) == CBM_STORE_OK ? w : NULL; +} + +static const cbm_impact_hit_t *hit_of(const cbm_impact_walk_t *w, int64_t id) { + const cbm_impact_hit_t *hits = cbm_impact_walk_hits(w); + for (int i = 0; i < cbm_impact_walk_count(w); i++) { + if (hits[i].id == id) { + return &hits[i]; + } + } + return NULL; +} + +TEST(impact_walk_follows_a_chain_to_its_end) { + /* n30 calls n29 calls ... calls n0. No depth bound: a change to n0 reaches + * all thirty, each at its own hop. */ + enum { LEN = 31 }; + cbm_store_t *s = new_store(); + ASSERT_NOT_NULL(s); + int64_t ids[LEN]; + for (int i = 0; i < LEN; i++) { + char name[16]; + snprintf(name, sizeof(name), "n%02d", i); + ids[i] = add_node(s, "Function", name); + } + for (int i = 1; i < LEN; i++) { + add_edge(s, ids[i], ids[i - 1], "CALLS"); + } + cbm_impact_walk_t *w = open_walk(s, CALLS_ONLY, 1, false); + ASSERT_NOT_NULL(w); + ASSERT_EQ(cbm_impact_walk_run(w, &ids[0], 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), LEN); + for (int i = 0; i < LEN; i++) { + const cbm_impact_hit_t *h = hit_of(w, ids[i]); + ASSERT_NOT_NULL(h); + ASSERT_EQ(h->hop, i); + ASSERT_EQ(h->via_id, i == 0 ? 0 : ids[i - 1]); + } + ASSERT_NULL(hit_of(w, ids[0])->via_edge); + ASSERT_STR_EQ(hit_of(w, ids[1])->via_edge, "CALLS"); + ASSERT_TRUE(cbm_impact_walk_hit(w, ids[7]) == hit_of(w, ids[7])); + cbm_impact_walk_close(w); + + /* A hop limit keeps the same answer, cut at that distance. */ + cbm_impact_policy_t bounded = { + .project = "test", .edge_types = CALLS_ONLY, .edge_type_count = 1, .max_hops = 3}; + ASSERT_EQ(cbm_impact_walk_open(s, &bounded, &w), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_run(w, &ids[0], 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 4); + ASSERT_EQ(cbm_impact_walk_hit(w, ids[3])->hop, 3); + ASSERT_NULL(cbm_impact_walk_hit(w, ids[4])); + cbm_impact_walk_close(w); + cbm_store_close(s); + PASS(); +} + +TEST(impact_walk_terminates_on_a_cycle) { + /* a -> b -> c -> a, and a self-loop on b. Every node is reached once. */ + cbm_store_t *s = new_store(); + int64_t a = add_node(s, "Function", "a"); + int64_t b = add_node(s, "Function", "b"); + int64_t c = add_node(s, "Function", "c"); + add_edge(s, a, b, "CALLS"); + add_edge(s, b, c, "CALLS"); + add_edge(s, c, a, "CALLS"); + add_edge(s, b, b, "CALLS"); + cbm_impact_walk_t *w = open_walk(s, CALLS_ONLY, 1, false); + ASSERT_NOT_NULL(w); + ASSERT_EQ(cbm_impact_walk_run(w, &a, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 3); + ASSERT_EQ(hit_of(w, a)->hop, 0); + ASSERT_EQ(hit_of(w, c)->hop, 1); /* c calls a */ + ASSERT_EQ(hit_of(w, b)->hop, 2); /* b calls c */ + cbm_impact_walk_close(w); + cbm_store_close(s); + PASS(); +} + +/* seed <- {parent_a, parent_b} <- x. Which parent x is reached THROUGH must not + * depend on the order nodes or edges were inserted in. */ +static int64_t build_diamond(cbm_store_t *s, bool reversed, int64_t *seed, int64_t *parent_a, + int64_t *parent_b) { + int64_t x; + if (reversed) { + x = add_node(s, "Function", "x"); + *parent_b = add_node(s, "Function", "parent_b"); + *parent_a = add_node(s, "Function", "parent_a"); + *seed = add_node(s, "Function", "seed"); + add_edge(s, x, *parent_b, "CALLS"); + add_edge(s, x, *parent_a, "CALLS"); + add_edge(s, *parent_b, *seed, "CALLS"); + add_edge(s, *parent_a, *seed, "CALLS"); + } else { + *seed = add_node(s, "Function", "seed"); + *parent_a = add_node(s, "Function", "parent_a"); + *parent_b = add_node(s, "Function", "parent_b"); + x = add_node(s, "Function", "x"); + add_edge(s, *parent_a, *seed, "CALLS"); + add_edge(s, *parent_b, *seed, "CALLS"); + add_edge(s, x, *parent_a, "CALLS"); + add_edge(s, x, *parent_b, "CALLS"); + } + return x; +} + +TEST(impact_walk_via_parent_is_the_smallest_qualified_name) { + for (int reversed = 0; reversed < 2; reversed++) { + cbm_store_t *s = new_store(); + int64_t seed; + int64_t parent_a; + int64_t parent_b; + int64_t x = build_diamond(s, reversed != 0, &seed, &parent_a, &parent_b); + cbm_impact_walk_t *w = open_walk(s, CALLS_ONLY, 1, false); + ASSERT_NOT_NULL(w); + ASSERT_EQ(cbm_impact_walk_run(w, &seed, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 4); + ASSERT_EQ(hit_of(w, x)->hop, 2); + ASSERT_EQ(hit_of(w, x)->via_id, parent_a); + /* Canonical order: by hop, then by qualified name. */ + const cbm_impact_hit_t *hits = cbm_impact_walk_hits(w); + ASSERT_EQ(hits[0].id, seed); + ASSERT_EQ(hits[1].id, parent_a); + ASSERT_EQ(hits[2].id, parent_b); + ASSERT_EQ(hits[3].id, x); + ASSERT_STR_EQ(hits[1].qualified_name, "test.parent_a"); + cbm_impact_walk_close(w); + cbm_store_close(s); + } + PASS(); +} + +TEST(impact_walk_via_edge_rank_beats_the_name) { + /* x reaches the change through `zeta` by CALLS and through `alpha` by + * USAGE. The request ranks CALLS first, so the path shown is the call, + * although `alpha` sorts first. */ + cbm_store_t *s = new_store(); + int64_t seed = add_node(s, "Function", "seed"); + int64_t alpha = add_node(s, "Function", "alpha"); + int64_t zeta = add_node(s, "Function", "zeta"); + int64_t x = add_node(s, "Function", "x"); + add_edge(s, alpha, seed, "CALLS"); + add_edge(s, zeta, seed, "CALLS"); + add_edge(s, x, alpha, "USAGE"); + add_edge(s, x, zeta, "CALLS"); + cbm_impact_walk_t *w = open_walk(s, CALLS_USAGE, 2, false); + ASSERT_NOT_NULL(w); + ASSERT_EQ(cbm_impact_walk_run(w, &seed, 1), CBM_STORE_OK); + ASSERT_EQ(hit_of(w, x)->via_id, zeta); + ASSERT_STR_EQ(hit_of(w, x)->via_edge, "CALLS"); + cbm_impact_walk_close(w); + + /* Ranked the other way round, the same graph shows the usage. */ + static const char *const usage_first[] = {"USAGE", "CALLS"}; + w = open_walk(s, usage_first, 2, false); + ASSERT_NOT_NULL(w); + ASSERT_EQ(cbm_impact_walk_run(w, &seed, 1), CBM_STORE_OK); + ASSERT_EQ(hit_of(w, x)->via_id, alpha); + ASSERT_STR_EQ(hit_of(w, x)->via_edge, "USAGE"); + cbm_impact_walk_close(w); + cbm_store_close(s); + PASS(); +} + +TEST(impact_walk_follows_only_the_requested_edge_types) { + cbm_store_t *s = new_store(); + int64_t seed = add_node(s, "Function", "seed"); + int64_t caller = add_node(s, "Function", "caller"); + int64_t reader = add_node(s, "Function", "reader"); + int64_t importer = add_node(s, "Module", "importer"); + add_edge(s, caller, seed, "CALLS"); + add_edge(s, reader, seed, "USAGE"); + add_edge(s, importer, seed, "IMPORTS"); + cbm_impact_walk_t *w = open_walk(s, CALLS_ONLY, 1, false); + ASSERT_EQ(cbm_impact_walk_run(w, &seed, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 2); + ASSERT_NOT_NULL(hit_of(w, caller)); + ASSERT_NULL(hit_of(w, reader)); + cbm_impact_walk_close(w); + + w = open_walk(s, CALLS_USAGE, 2, false); + ASSERT_EQ(cbm_impact_walk_run(w, &seed, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 3); + ASSERT_NOT_NULL(hit_of(w, reader)); + ASSERT_NULL(hit_of(w, importer)); + ASSERT_TRUE(cbm_impact_walk_reached(w, reader)); + ASSERT_FALSE(cbm_impact_walk_reached(w, importer)); + cbm_impact_walk_close(w); + cbm_store_close(s); + PASS(); +} + +TEST(impact_walk_reaches_a_sink_and_stops_there) { + /* test_case calls seed, runner calls test_case. A test case is a sink: it + * is selected, and what calls it is not "impacted by the change". */ + cbm_store_t *s = new_store(); + int64_t seed = add_node(s, "Function", "seed"); + int64_t test_case = add_node(s, "Function", "test_case"); + int64_t runner = add_node(s, "Function", "runner"); + int64_t helper = add_node(s, "Function", "helper"); + int64_t other = add_node(s, "Function", "other"); + add_edge(s, test_case, seed, "CALLS"); + add_edge(s, runner, test_case, "CALLS"); + add_edge(s, helper, seed, "CALLS"); + add_edge(s, other, helper, "CALLS"); + cbm_impact_walk_t *w = open_walk(s, CALLS_ONLY, 1, false); + ASSERT_EQ(cbm_impact_walk_add_sinks(w, &test_case, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_run(w, &seed, 1), CBM_STORE_OK); + ASSERT_NOT_NULL(hit_of(w, test_case)); + ASSERT_NULL(hit_of(w, runner)); + ASSERT_NOT_NULL(hit_of(w, other)); + ASSERT_EQ(cbm_impact_walk_count(w), 4); + cbm_impact_walk_close(w); + + /* A sink that is itself the change is not expanded either. */ + w = open_walk(s, CALLS_ONLY, 1, false); + ASSERT_EQ(cbm_impact_walk_add_sinks(w, &test_case, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_run(w, &test_case, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 1); + cbm_impact_walk_close(w); + cbm_store_close(s); + PASS(); +} + +TEST(impact_walk_second_run_continues_from_the_first) { + /* A lift adds seeds after the first fixpoint: they enter one hop beyond + * everything reached so far, and what was reached keeps its place. */ + cbm_store_t *s = new_store(); + int64_t seed = add_node(s, "Function", "seed"); + int64_t caller = add_node(s, "Function", "caller"); + int64_t spawner = add_node(s, "Function", "spawner"); + int64_t spawn_user = add_node(s, "Function", "spawn_user"); + add_edge(s, caller, seed, "CALLS"); + add_edge(s, spawn_user, spawner, "CALLS"); + add_edge(s, spawn_user, caller, "CALLS"); + cbm_impact_walk_t *w = open_walk(s, CALLS_ONLY, 1, false); + ASSERT_EQ(cbm_impact_walk_run(w, &seed, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 3); + ASSERT_EQ(hit_of(w, spawn_user)->hop, 2); + int64_t lift[] = {spawner, caller}; + ASSERT_EQ(cbm_impact_walk_run(w, lift, 2), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 4); + ASSERT_EQ(hit_of(w, spawner)->hop, 3); + ASSERT_EQ(hit_of(w, spawner)->via_id, 0); + ASSERT_EQ(hit_of(w, caller)->hop, 1); + ASSERT_EQ(hit_of(w, spawn_user)->hop, 2); + cbm_impact_walk_close(w); + cbm_store_close(s); + PASS(); +} + +TEST(impact_walk_crosses_a_route_only_when_asked) { + /* handler --HANDLES--> route <--HTTP_CALLS-- client. A change to the + * handler concerns the client that calls its route. */ + cbm_store_t *s = new_store(); + int64_t handler = add_node(s, "Function", "handler"); + int64_t route = add_node(s, "Route", "GET_orders"); + int64_t client = add_node(s, "Function", "client"); + int64_t client_caller = add_node(s, "Function", "client_caller"); + add_edge(s, handler, route, "HANDLES"); + add_edge(s, client, route, "HTTP_CALLS"); + add_edge(s, client_caller, client, "CALLS"); + cbm_impact_walk_t *w = open_walk(s, CALLS_ONLY, 1, false); + ASSERT_EQ(cbm_impact_walk_run(w, &handler, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 1); + cbm_impact_walk_close(w); + + w = open_walk(s, CALLS_ONLY, 1, true); + ASSERT_EQ(cbm_impact_walk_run(w, &handler, 1), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 4); + ASSERT_EQ(hit_of(w, route)->hop, 1); + ASSERT_STR_EQ(hit_of(w, route)->via_edge, "HANDLES"); + ASSERT_EQ(hit_of(w, client)->hop, 2); + ASSERT_STR_EQ(hit_of(w, client)->via_edge, "HTTP_CALLS"); + ASSERT_EQ(hit_of(w, client_caller)->hop, 3); + cbm_impact_walk_close(w); + cbm_store_close(s); + PASS(); +} + +TEST(impact_walk_rejects_what_it_cannot_answer) { + cbm_store_t *s = new_store(); + int64_t seed = add_node(s, "Function", "seed"); + cbm_impact_walk_t *w = NULL; + cbm_impact_policy_t no_types = {.project = "test", .edge_types = NULL, .edge_type_count = 0}; + ASSERT_EQ(cbm_impact_walk_open(s, &no_types, &w), CBM_STORE_ERR); + ASSERT_NULL(w); + ASSERT_EQ(cbm_impact_walk_open(NULL, &no_types, &w), CBM_STORE_ERR); + + /* A seed the store does not hold is an error, never an empty answer: an + * empty answer would read as "nothing depends on this". */ + w = open_walk(s, CALLS_ONLY, 1, false); + ASSERT_NOT_NULL(w); + int64_t unknown = seed + 1000; + ASSERT_EQ(cbm_impact_walk_run(w, &unknown, 1), CBM_STORE_ERR); + ASSERT_EQ(cbm_impact_walk_add_sinks(w, &unknown, 1), CBM_STORE_ERR); + cbm_impact_walk_close(w); + + /* Duplicate seeds are one seed. */ + w = open_walk(s, CALLS_ONLY, 1, false); + int64_t twice[] = {seed, seed}; + ASSERT_EQ(cbm_impact_walk_run(w, twice, 2), CBM_STORE_OK); + ASSERT_EQ(cbm_impact_walk_count(w), 1); + cbm_impact_walk_close(w); + cbm_impact_walk_close(NULL); + cbm_store_close(s); + PASS(); +} + +SUITE(store_impact) { + RUN_TEST(impact_walk_follows_a_chain_to_its_end); + RUN_TEST(impact_walk_terminates_on_a_cycle); + RUN_TEST(impact_walk_via_parent_is_the_smallest_qualified_name); + RUN_TEST(impact_walk_via_edge_rank_beats_the_name); + RUN_TEST(impact_walk_follows_only_the_requested_edge_types); + RUN_TEST(impact_walk_reaches_a_sink_and_stops_there); + RUN_TEST(impact_walk_second_run_continues_from_the_first); + RUN_TEST(impact_walk_crosses_a_route_only_when_asked); + RUN_TEST(impact_walk_rejects_what_it_cannot_answer); +} diff --git a/tests/test_store_scope.c b/tests/test_store_scope.c new file mode 100644 index 000000000..788d07813 --- /dev/null +++ b/tests/test_store_scope.c @@ -0,0 +1,443 @@ +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include + +#define SS_PROJECT "scopetests" +static char ss_sentinel; +#define SS_SENTINEL ((cbm_store_read_scope_t *)(void *)&ss_sentinel) +static const char *const ss_edges[] = {"USAGE", "CALLS"}; +static const cbm_impact_policy_t ss_policy = { + .project = SS_PROJECT, .edge_types = ss_edges, .edge_type_count = 2, + .follow_routes = false, .max_hops = 0 +}; +typedef struct { int64_t root, a, z, top, island, tail; } ss_ids_t; +typedef struct { char dir[1024], path[1024]; cbm_store_t *writer, *reader; ss_ids_t ids; } ss_file_t; +typedef struct { uint64_t calls, trip; bool armed, stop; } ss_cancel_t; +/* Pure predicate: no SQLite, store, scope or walker call, including on cancellation. */ +static bool ss_cancel(void *opaque) { + ss_cancel_t *c = opaque; + if (c->calls < UINT64_MAX) c->calls++; + return c->stop || (c->armed && c->calls >= c->trip); +} +static bool ss_eq(const char *a, const char *b) { return a && b && strcmp(a,b)==0; } +static int ss_sql(cbm_store_t *s, const char *sql) { + return sqlite3_exec(cbm_store_get_db(s), sql, NULL, NULL, NULL); +} +static bool ss_integer(cbm_store_t *s, const char *sql, int64_t want) { + sqlite3_stmt *q = NULL; + bool ok = sqlite3_prepare_v2(cbm_store_get_db(s), sql, -1, &q, NULL)==SQLITE_OK && + sqlite3_step(q)==SQLITE_ROW && sqlite3_column_int64(q,0)==want && sqlite3_step(q)==SQLITE_DONE; + if (q && sqlite3_finalize(q)!=SQLITE_OK) ok=false; + return ok; +} +static bool ss_text(cbm_store_t *s, const char *sql, const char *want) { + sqlite3_stmt *q = NULL; + bool ok = sqlite3_prepare_v2(cbm_store_get_db(s), sql, -1, &q, NULL)==SQLITE_OK && + sqlite3_step(q)==SQLITE_ROW && ss_eq((const char *)sqlite3_column_text(q,0),want) && + sqlite3_step(q)==SQLITE_DONE; + if (q && sqlite3_finalize(q)!=SQLITE_OK) ok=false; + return ok; +} +static int64_t ss_node(cbm_store_t *s, const char *name, int line) { + char qn[128]; int n=snprintf(qn,sizeof(qn),SS_PROJECT ".%s",name); + if (n<=0 || (size_t)n>=sizeof(qn)) return -1; + cbm_node_t row={.project=SS_PROJECT,.label="Function",.name=name,.qualified_name=qn, + .file_path="unit.c",.start_line=line,.end_line=line,.properties_json="{}"}; + return cbm_store_upsert_node(s,&row); +} +static bool ss_edge(cbm_store_t *s,int64_t from,int64_t to,const char *type) { + cbm_edge_t e={.project=SS_PROJECT,.source_id=from,.target_id=to,.type=type,.properties_json="{}"}; + return cbm_store_insert_edge(s,&e)>0; +} +static bool ss_seed(cbm_store_t *s,ss_ids_t *id) { + if (!s || cbm_store_upsert_project(s,SS_PROJECT,"/scope-fixture")!=CBM_STORE_OK) return false; + /* Deliberately nonlexical insertion order. */ + id->z=ss_node(s,"z",30); id->root=ss_node(s,"root",11); id->top=ss_node(s,"top",40); + id->a=ss_node(s,"a",20); id->tail=ss_node(s,"tail",60); id->island=ss_node(s,"island",50); + return id->z>0 && id->root>0 && id->top>0 && id->a>0 && id->tail>0 && id->island>0 && + ss_edge(s,id->a,id->root,"CALLS") && ss_edge(s,id->z,id->root,"USAGE") && + ss_edge(s,id->top,id->a,"CALLS") && ss_edge(s,id->top,id->z,"USAGE") && + ss_edge(s,id->tail,id->island,"CALLS") && cbm_store_generation_advance(s)==CBM_STORE_OK && + cbm_store_count_nodes(s,SS_PROJECT)==6; +} +static bool ss_line(cbm_store_t *s,int64_t id,int line) { + cbm_node_t row={0}; bool ok=cbm_store_find_node_by_id(s,id,&row)==CBM_STORE_OK && row.start_line==line; + cbm_node_free_fields(&row); return ok; +} +static bool ss_file_open(ss_file_t *f) { + memset(f,0,sizeof(*f)); const char *dir=th_mktempdir("cbm-store-scope"); + if (!dir || strlen(dir)>=sizeof(f->dir)) return false; + memcpy(f->dir,dir,strlen(dir)+1); + int n=snprintf(f->path,sizeof(f->path),"%s/graph.db",dir); + if (n<=0 || (size_t)n>=sizeof(f->path)) return false; + f->writer=cbm_store_open_path(f->path); + if (!ss_seed(f->writer,&f->ids) || !ss_text(f->writer,"PRAGMA journal_mode","wal")) return false; + f->reader=cbm_store_open_path_query(f->path); + return f->reader && ss_line(f->reader,f->ids.root,11); +} +static bool ss_file_close(ss_file_t *f) { + cbm_store_close(f->reader); cbm_store_close(f->writer); f->reader=f->writer=NULL; + return !f->dir[0] || th_rmtree(f->dir)==0; +} +static int ss_scope_close(cbm_store_read_scope_t *s) { + return s==SS_SENTINEL ? CBM_STORE_ERR : cbm_store_read_scope_close(s); +} +static bool ss_hidden(cbm_impact_walk_t *w,int64_t previously_reached) { + return w && cbm_impact_walk_count(w)==0 && cbm_impact_walk_hits(w)==NULL && + cbm_impact_walk_hit(w,previously_reached)==NULL && !cbm_impact_walk_reached(w,previously_reached); +} +static bool ss_hit(const cbm_impact_hit_t *h,int64_t id,int hop,int64_t via,const char *edge,const char *qn) { + return h && h->id==id && h->hop==hop && h->via_id==via && + (edge ? ss_eq(h->via_edge,edge) : h->via_edge==NULL) && ss_eq(h->qualified_name,qn) && ss_eq(h->label,"Function"); +} +static bool ss_golden(cbm_impact_walk_t *w,const ss_ids_t *id,bool lifted) { + int count=cbm_impact_walk_count(w); const cbm_impact_hit_t *h=cbm_impact_walk_hits(w); + bool ok=count==(lifted?6:4) && h && + ss_hit(&h[0],id->root,0,0,NULL,SS_PROJECT ".root") && + ss_hit(&h[1],id->a,1,id->root,"CALLS",SS_PROJECT ".a") && + ss_hit(&h[2],id->z,1,id->root,"USAGE",SS_PROJECT ".z") && + ss_hit(&h[3],id->top,2,id->z,"USAGE",SS_PROJECT ".top"); + return ok && (!lifted || (ss_hit(&h[4],id->island,3,0,NULL,SS_PROJECT ".island") && + ss_hit(&h[5],id->tail,4,id->island,"CALLS",SS_PROJECT ".tail"))); +} +static bool ss_normal_walk(cbm_store_t *s,const ss_ids_t *id) { + cbm_impact_walk_t *w=NULL; + bool ok=cbm_impact_walk_open(s,&ss_policy,&w)==CBM_STORE_OK && + cbm_impact_walk_run(w,&id->root,1)==CBM_STORE_OK && ss_golden(w,id,false); + if (w) { cbm_impact_walk_close(w); } return ok; +} + +TEST(store_scope_wal_snapshot_pins_metadata_nodes_and_edges) { + ss_file_t f; bool setup=ss_file_open(&f), legacy=setup && ss_normal_walk(f.reader,&f.ids); + char before[128]={0},during[128]={0},after[128]={0}; + bool metadata=legacy && cbm_store_generation(f.reader,before,sizeof(before))==CBM_STORE_OK; + cbm_store_read_scope_t *scope=NULL; cbm_impact_walk_t *walk=NULL; + int opened=metadata ? cbm_store_read_scope_open(f.reader,NULL,NULL,&scope) : CBM_STORE_ERR; + bool bound=opened==CBM_STORE_OK && cbm_store_read_scope_store(scope)==f.reader; + int64_t fresh=0; + bool committed=bound && cbm_store_begin(f.writer)==CBM_STORE_OK && + ss_node(f.writer,"root",99)==f.ids.root && (fresh=ss_node(f.writer,"newcaller",70))>0 && + ss_edge(f.writer,fresh,f.ids.root,"CALLS") && cbm_store_generation_advance(f.writer)==CBM_STORE_OK && + cbm_store_commit(f.writer)==CBM_STORE_OK; + bool old=committed && cbm_store_generation(f.reader,during,sizeof(during))==CBM_STORE_OK && + strcmp(before,during)==0 && ss_line(f.reader,f.ids.root,11) && + cbm_impact_walk_open_scoped(scope,&ss_policy,&walk)==CBM_STORE_OK && + cbm_impact_walk_run(walk,&f.ids.root,1)==CBM_STORE_OK && ss_golden(walk,&f.ids,false) && + !cbm_impact_walk_reached(walk,fresh); + if (walk) { cbm_impact_walk_close(walk); } walk=NULL; + int closed=ss_scope_close(scope); scope=NULL; + if (closed==CBM_STORE_SCOPE_DISCARD) { cbm_store_close(f.reader); f.reader=NULL; } + int reopened=committed && f.reader ? cbm_store_read_scope_open(f.reader,NULL,NULL,&scope) : CBM_STORE_ERR; + bool newer=reopened==CBM_STORE_OK && cbm_store_generation(f.reader,after,sizeof(after))==CBM_STORE_OK && + strcmp(before,after)!=0 && ss_line(f.reader,f.ids.root,99) && + cbm_impact_walk_open_scoped(scope,&ss_policy,&walk)==CBM_STORE_OK && + cbm_impact_walk_run(walk,&f.ids.root,1)==CBM_STORE_OK && cbm_impact_walk_count(walk)==5 && + cbm_impact_walk_reached(walk,fresh); + if (walk) { cbm_impact_walk_close(walk); } + int reclosed=ss_scope_close(scope); bool cleanup=ss_file_close(&f); + ASSERT_TRUE(setup); ASSERT_TRUE(legacy); ASSERT_TRUE(metadata); ASSERT_TRUE(cleanup); + ASSERT_EQ(opened,CBM_STORE_OK); ASSERT_TRUE(bound); ASSERT_TRUE(committed); ASSERT_TRUE(old); + ASSERT_EQ(closed,CBM_STORE_OK); ASSERT_EQ(reopened,CBM_STORE_OK); ASSERT_TRUE(newer); + ASSERT_EQ(reclosed,CBM_STORE_OK); PASS(); +} + +TEST(store_scope_refuses_existing_transaction_and_busy_statement) { + cbm_store_t *s=cbm_store_open_memory(); ss_ids_t id={0}; + bool setup=ss_seed(s,&id) && ss_sql(s,"PRAGMA busy_timeout=321; PRAGMA query_only=0")==SQLITE_OK; + bool begun=setup && cbm_store_begin(s)==CBM_STORE_OK; + cbm_store_read_scope_t *scope=SS_SENTINEL; + int txrc=begun ? cbm_store_read_scope_open(s,NULL,NULL,&scope) : CBM_STORE_ERR; + bool tx_clear=scope==NULL, tx_owned=begun && txrc!=CBM_STORE_SCOPE_DISCARD && !sqlite3_get_autocommit(cbm_store_get_db(s)); + if (scope && scope!=SS_SENTINEL) { (void)ss_scope_close(scope); scope=NULL; } + bool tx_unchanged=tx_owned && ss_integer(s,"PRAGMA busy_timeout",321) && ss_integer(s,"PRAGMA query_only",0) && + ss_node(s,"root",88)==id.root && cbm_store_rollback(s)==CBM_STORE_OK && ss_line(s,id.root,11); + sqlite3_stmt *q=NULL; + bool busy=tx_unchanged && sqlite3_prepare_v2(cbm_store_get_db(s),"SELECT id FROM main.nodes ORDER BY id",-1,&q,NULL)==SQLITE_OK && + sqlite3_step(q)==SQLITE_ROW && sqlite3_stmt_busy(q); + scope=SS_SENTINEL; + int busyrc=busy ? cbm_store_read_scope_open(s,NULL,NULL,&scope) : CBM_STORE_ERR; + bool busy_clear=scope==NULL; + bool busy_unchanged=busy && busyrc!=CBM_STORE_SCOPE_DISCARD && sqlite3_stmt_busy(q) && sqlite3_step(q)==SQLITE_ROW && + ss_integer(s,"PRAGMA busy_timeout",321) && ss_integer(s,"PRAGMA query_only",0); + if (scope && scope!=SS_SENTINEL) { (void)ss_scope_close(scope); scope=NULL; } + if (q) { sqlite3_finalize(q); } + bool idle=busy_unchanged && ss_line(s,id.root,11); /* Leaves ordinary cached statements idle. */ + scope=NULL; int goodrc=idle ? cbm_store_read_scope_open(s,NULL,NULL,&scope) : CBM_STORE_ERR; + int closed=ss_scope_close(scope); cbm_store_close(s); + ASSERT_TRUE(setup); ASSERT_TRUE(begun); ASSERT_EQ(txrc,CBM_STORE_ERR); ASSERT_TRUE(tx_clear); ASSERT_TRUE(tx_unchanged); + ASSERT_TRUE(busy); ASSERT_EQ(busyrc,CBM_STORE_ERR); ASSERT_TRUE(busy_clear); ASSERT_TRUE(busy_unchanged); + ASSERT_TRUE(idle); ASSERT_EQ(goodrc,CBM_STORE_OK); ASSERT_EQ(closed,CBM_STORE_OK); PASS(); +} + +TEST(store_scope_restores_query_only_timeout_and_cancelled_open) { + for (int prior=0;prior<=1;prior++) { + cbm_store_t *s=cbm_store_open_memory(); ss_ids_t id={0}; ss_cancel_t cancel={0}; + bool setup=ss_seed(s,&id) && ss_sql(s,prior?"PRAGMA query_only=1; PRAGMA busy_timeout=4321":"PRAGMA query_only=0; PRAGMA busy_timeout=4321")==SQLITE_OK; + cbm_store_read_scope_t *scope=NULL; + int opened=setup?cbm_store_read_scope_open(s,ss_cancel,&cancel,&scope):CBM_STORE_ERR; + bool during=opened==CBM_STORE_OK && !sqlite3_get_autocommit(cbm_store_get_db(s)) && + ss_integer(s,"PRAGMA query_only",1) && ss_integer(s,"PRAGMA busy_timeout",0); + int write_rc=during?ss_sql(s,"UPDATE main.nodes SET start_line=98"):SQLITE_ERROR; + int normalized=opened==CBM_STORE_OK && prior?cbm_store_read_scope_fail(scope,CBM_STORE_NOT_FOUND):CBM_STORE_OK; + int closed=ss_scope_close(scope); scope=SS_SENTINEL; + bool restored=setup && opened!=CBM_STORE_SCOPE_DISCARD && closed!=CBM_STORE_SCOPE_DISCARD && sqlite3_get_autocommit(cbm_store_get_db(s)) && ss_integer(s,"PRAGMA query_only",prior) && + ss_integer(s,"PRAGMA busy_timeout",4321) && ss_line(s,id.root,11); + cancel.stop=true; + int cancelled=restored?cbm_store_read_scope_open(s,ss_cancel,&cancel,&scope):CBM_STORE_ERR; + bool cleared=scope==NULL; + if (scope && scope!=SS_SENTINEL) (void)ss_scope_close(scope); + bool after=restored && cancelled!=CBM_STORE_SCOPE_DISCARD && sqlite3_get_autocommit(cbm_store_get_db(s)) && ss_integer(s,"PRAGMA query_only",prior) && + ss_integer(s,"PRAGMA busy_timeout",4321) && ss_integer(s,"SELECT 1",1); + cbm_store_close(s); + ASSERT_TRUE(setup); ASSERT_EQ(opened,CBM_STORE_OK); ASSERT_TRUE(during); + ASSERT_EQ(write_rc&255,SQLITE_READONLY); ASSERT_EQ(normalized,prior?CBM_STORE_ERR:CBM_STORE_OK); ASSERT_EQ(closed,prior?CBM_STORE_ERR:CBM_STORE_OK); ASSERT_TRUE(restored); + ASSERT_EQ(cancelled,CBM_STORE_CANCELLED); ASSERT_TRUE(cleared); ASSERT_TRUE(after); + } + ASSERT_EQ(cbm_store_read_scope_close(NULL),CBM_STORE_OK); PASS(); +} + +TEST(store_scope_nested_outline_and_compare_preserve_owned_guard) { + ss_file_t f; bool setup=ss_file_open(&f); + cbm_store_t *other=setup?cbm_store_open_path_query(f.path):NULL; + cbm_graph_compare_result_t comparison={0}; + cbm_file_outline_row_t *rows=NULL; int count=0,total=0; + bool compare_control=other && cbm_store_compare_graphs(f.reader,SS_PROJECT,other,SS_PROJECT,1000,NULL,NULL,NULL,NULL,&comparison)==CBM_STORE_OK; + bool outline_control=compare_control && cbm_store_get_file_outline(other,SS_PROJECT,"unit.c",NULL,0,20,0,NULL,NULL,&rows,&count,&total)==CBM_STORE_OK && count==6 && total==6; + cbm_store_free_file_outline(rows,count); rows=NULL; count=total=0; + ss_cancel_t cancel={0}; cbm_store_read_scope_t *scope=NULL,*base_scope=NULL; + int opened=outline_control?cbm_store_read_scope_open(other,ss_cancel,&cancel,&scope):CBM_STORE_ERR; + int null_outline=opened==CBM_STORE_OK?cbm_store_get_file_outline(other,SS_PROJECT,"unit.c",NULL,0,20,0,NULL,NULL,&rows,&count,&total):CBM_STORE_OK; + bool null_empty=rows==NULL && count==0; cbm_store_free_file_outline(rows,count); rows=NULL; count=total=0; + ss_cancel_t nested={0}; + int cb_outline=opened==CBM_STORE_OK?cbm_store_get_file_outline(other,SS_PROJECT,"unit.c",NULL,0,20,0,ss_cancel,&nested,&rows,&count,&total):CBM_STORE_OK; + bool cb_empty=rows==NULL && count==0; cbm_store_free_file_outline(rows,count); + int compare_rc=opened==CBM_STORE_OK?cbm_store_compare_graphs(f.reader,SS_PROJECT,other,SS_PROJECT,1000,NULL,NULL,NULL,NULL,&comparison):CBM_STORE_OK; + /* A refused second acquisition must not strand the first connection's guard/transaction. */ + int base_open=opened==CBM_STORE_OK?cbm_store_read_scope_open(f.reader,NULL,NULL,&base_scope):CBM_STORE_ERR; + int base_close=ss_scope_close(base_scope); + cancel.stop=true; + int sql_rc=opened==CBM_STORE_OK?ss_sql(other,"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x+1 FROM n WHERE x<10000) SELECT sum(x) FROM n"):SQLITE_ERROR; + int check=scope?cbm_store_read_scope_check(scope):CBM_STORE_ERR; + int closed=ss_scope_close(scope); cbm_store_close(other); bool cleanup=ss_file_close(&f); + ASSERT_TRUE(setup); ASSERT_TRUE(compare_control); ASSERT_TRUE(outline_control); ASSERT_TRUE(cleanup); + ASSERT_EQ(opened,CBM_STORE_OK); ASSERT_EQ(null_outline,CBM_STORE_ERR); ASSERT_TRUE(null_empty); + ASSERT_EQ(cb_outline,CBM_STORE_ERR); ASSERT_TRUE(cb_empty); ASSERT_EQ(compare_rc,CBM_STORE_ERR); + ASSERT_EQ(base_open,CBM_STORE_OK); ASSERT_EQ(base_close,CBM_STORE_OK); + ASSERT_EQ(sql_rc&255,SQLITE_INTERRUPT); ASSERT_EQ(check,CBM_STORE_CANCELLED); ASSERT_EQ(closed,CBM_STORE_CANCELLED); PASS(); +} + +TEST(store_scope_sql_progress_cancellation_latches_and_unwinds) { + cbm_store_t *s=cbm_store_open_memory(); ss_ids_t id={0}; ss_cancel_t cancel={0}; + bool setup=ss_seed(s,&id) && ss_integer(s,"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x+1 FROM n WHERE x<10000) SELECT sum(x) FROM n",50005000); + cbm_store_read_scope_t *scope=NULL; + int opened=setup?cbm_store_read_scope_open(s,ss_cancel,&cancel,&scope):CBM_STORE_ERR; + cancel.calls=0; + bool sql_control=opened==CBM_STORE_OK && ss_integer(s,"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x+1 FROM n WHERE x<10000) SELECT sum(x) FROM n",50005000); + uint64_t polls=cancel.calls; + cancel.calls=0; cancel.trip=polls/2+polls%2; cancel.armed=true; + int sql_rc=sql_control && polls>0?ss_sql(s,"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x+1 FROM n WHERE x<10000) SELECT sum(x) FROM n"):SQLITE_ERROR; + bool polled=polls>0 && cancel.calls>=cancel.trip; + cancel.armed=false; cancel.stop=false; + int checked=scope?cbm_store_read_scope_check(scope):CBM_STORE_ERR; + int closed=ss_scope_close(scope); + bool released=setup && opened!=CBM_STORE_SCOPE_DISCARD && closed!=CBM_STORE_SCOPE_DISCARD && sqlite3_get_autocommit(cbm_store_get_db(s)) && ss_integer(s,"SELECT 7",7) && ss_line(s,id.root,11); + cbm_store_close(s); + ASSERT_TRUE(setup); ASSERT_EQ(opened,CBM_STORE_OK); ASSERT_TRUE(sql_control); ASSERT_GT(polls,0); ASSERT_TRUE(polled); ASSERT_EQ(sql_rc&255,SQLITE_INTERRUPT); + ASSERT_EQ(checked,CBM_STORE_CANCELLED); ASSERT_EQ(closed,CBM_STORE_CANCELLED); ASSERT_TRUE(released); PASS(); +} + +typedef struct { bool setup, open, success, cancelled, hidden, restored; uint64_t calls; } ss_sweep_result_t; +static ss_sweep_result_t ss_sweep_run(uint64_t trip) { + ss_sweep_result_t out={0}; cbm_store_t *s=cbm_store_open_memory(); ss_ids_t id={0}; ss_cancel_t cancel={0}; + out.setup=ss_seed(s,&id); + for (int i=31;out.setup && i>=0;i--) { + char name[48]; (void)snprintf(name,sizeof(name),"fan_%02d",i); + int64_t node=ss_node(s,name,100+i); + out.setup=node>0 && ss_edge(s,node,id.root,"CALLS"); + } + cbm_store_read_scope_t *scope=NULL; cbm_impact_walk_t *walk=NULL; + int rc=out.setup?cbm_store_read_scope_open(s,ss_cancel,&cancel,&scope):CBM_STORE_ERR; + out.open=rc==CBM_STORE_OK; + cancel.calls=0; cancel.trip=trip; cancel.armed=trip!=0; + if (rc==CBM_STORE_OK) rc=cbm_impact_walk_open_scoped(scope,&ss_policy,&walk); + int64_t sinks[4]={id.z,id.tail,id.z,id.tail}; + if (rc==CBM_STORE_OK) rc=cbm_impact_walk_add_sinks(walk,sinks,4); + int64_t seeds[4]={id.root,id.root,id.root,id.root}; + if (rc==CBM_STORE_OK) rc=cbm_impact_walk_run(walk,seeds,4); + if (rc==CBM_STORE_OK) rc=cbm_impact_walk_run(walk,&id.island,1); + out.calls=cancel.calls; + out.success=rc==CBM_STORE_OK && walk && cbm_impact_walk_count(walk)==38 && + cbm_impact_walk_reached(walk,id.root) && cbm_impact_walk_reached(walk,id.tail); + if (trip) { + /* Stop asserting the predicate, then require the operation's latch to persist. */ + cancel.armed=false; cancel.stop=false; + out.cancelled=rc==CBM_STORE_CANCELLED && scope && cbm_store_read_scope_check(scope)==CBM_STORE_CANCELLED; + out.hidden=walk?ss_hidden(walk,id.root):rc!=CBM_STORE_OK; + } + if (walk) { cbm_impact_walk_close(walk); } + int closed=ss_scope_close(scope); + out.restored=out.open && closed==(trip?CBM_STORE_CANCELLED:CBM_STORE_OK) && + sqlite3_get_autocommit(cbm_store_get_db(s)) && ss_integer(s,"PRAGMA query_only",0) && ss_integer(s,"SELECT 1",1); + cbm_store_close(s); return out; +} +TEST(store_scope_counter_sweep_never_exposes_a_cancelled_prefix) { + ss_sweep_result_t control=ss_sweep_run(0); + ASSERT_TRUE(control.setup); ASSERT_TRUE(control.open); ASSERT_TRUE(control.success); ASSERT_TRUE(control.restored); + ASSERT_GT(control.calls,1); + /* Nine evenly spaced positions from this successful fixed fixture. Counts + * are observed, never hardcoded. This is not a sort/loop phase identifier. */ + uint64_t previous=0; + for (unsigned i=0;i<9;i++) { + uint64_t span=control.calls-1; + uint64_t trip=1+(span/8)*i+((span%8)*i)/8; + if (trip==previous) { continue; } + previous=trip; + ss_sweep_result_t result=ss_sweep_run(trip); + if (!result.cancelled || !result.hidden) + fprintf(stderr,"scope callback sweep position %llu of %llu\n",(unsigned long long)trip,(unsigned long long)control.calls); + ASSERT_TRUE(result.setup); ASSERT_TRUE(result.open); ASSERT_TRUE(result.cancelled); + ASSERT_TRUE(result.hidden); ASSERT_TRUE(result.restored); ASSERT_GTE(result.calls,trip); + } + PASS(); +} + +TEST(store_scope_failed_later_lift_hides_previous_hits) { + for (int invalid=0;invalid<2;invalid++) { + cbm_store_t *s=cbm_store_open_memory(); ss_ids_t id={0}; ss_cancel_t cancel={0}; + bool setup=ss_seed(s,&id) && ss_normal_walk(s,&id); + cbm_store_read_scope_t *scope=NULL; cbm_impact_walk_t *walk=NULL; + int opened=setup?cbm_store_read_scope_open(s,ss_cancel,&cancel,&scope):CBM_STORE_ERR; + bool prior=opened==CBM_STORE_OK && cbm_impact_walk_open_scoped(scope,&ss_policy,&walk)==CBM_STORE_OK && + cbm_impact_walk_run(walk,&id.root,1)==CBM_STORE_OK && ss_golden(walk,&id,false); + cancel.stop=!invalid; + int64_t next=invalid?INT64_MAX:id.island; + int rc=prior?cbm_impact_walk_run(walk,&next,1):CBM_STORE_OK; + cancel.stop=false; + int expected=invalid?CBM_STORE_ERR:CBM_STORE_CANCELLED; + bool hidden=walk && ss_hidden(walk,id.root); + int checked=scope?cbm_store_read_scope_check(scope):CBM_STORE_OK; + int retained=scope?cbm_store_read_scope_fail(scope,invalid?CBM_STORE_CANCELLED:CBM_STORE_ERR):CBM_STORE_OK; + if (walk) { cbm_impact_walk_close(walk); } + int closed=ss_scope_close(scope); + bool usable=setup && opened!=CBM_STORE_SCOPE_DISCARD && closed!=CBM_STORE_SCOPE_DISCARD && ss_normal_walk(s,&id); + cbm_store_close(s); + ASSERT_TRUE(setup); ASSERT_EQ(opened,CBM_STORE_OK); ASSERT_TRUE(prior); + ASSERT_EQ(rc,expected); ASSERT_TRUE(hidden); ASSERT_EQ(checked,expected); ASSERT_EQ(retained,expected); + ASSERT_EQ(closed,expected); ASSERT_TRUE(usable); + } + PASS(); +} + +static int ss_busy_count(void *opaque,int attempt) { + (void)attempt; int *calls=opaque; (*calls)++; return 0; /* Never waits or sleeps. */ +} +TEST(store_scope_held_lock_refuses_without_calling_busy_handler) { + ss_file_t f; bool setup=ss_file_open(&f); + /* DELETE mode supplies a deterministic conflicting main-table read lock. */ + cbm_store_close(f.reader); f.reader=NULL; + bool mode=setup && ss_sql(f.writer,"PRAGMA journal_mode=DELETE")==SQLITE_OK && ss_text(f.writer,"PRAGMA journal_mode","delete"); + if (mode) f.reader=cbm_store_open_path_query(f.path); + int busy_calls=0; + bool ready=f.reader && ss_line(f.reader,f.ids.root,11) && + sqlite3_busy_handler(cbm_store_get_db(f.reader),ss_busy_count,&busy_calls)==SQLITE_OK; + bool locked=ready && ss_sql(f.writer,"BEGIN EXCLUSIVE")==SQLITE_OK; + cbm_store_read_scope_t *scope=SS_SENTINEL; + int rc=locked?cbm_store_read_scope_open(f.reader,NULL,NULL,&scope):CBM_STORE_OK; + bool cleared=scope==NULL; + if (scope && scope!=SS_SENTINEL) (void)ss_scope_close(scope); + int observed=busy_calls; + bool unlocked=locked && ss_sql(f.writer,"ROLLBACK")==SQLITE_OK; + scope=NULL; + int good=unlocked && rc!=CBM_STORE_SCOPE_DISCARD?cbm_store_read_scope_open(f.reader,NULL,NULL,&scope):CBM_STORE_ERR; + int closed=ss_scope_close(scope); + bool cleanup=ss_file_close(&f); + ASSERT_TRUE(setup); ASSERT_TRUE(mode); ASSERT_TRUE(ready); ASSERT_TRUE(locked); ASSERT_TRUE(cleanup); + ASSERT_EQ(rc,CBM_STORE_ERR); ASSERT_TRUE(cleared); ASSERT_EQ(observed,0); ASSERT_TRUE(unlocked); + ASSERT_EQ(good,CBM_STORE_OK); ASSERT_EQ(closed,CBM_STORE_OK); PASS(); +} + +typedef struct { bool deny_restore, deny_pin, pin_seen, restore_armed; int restore_denied; } ss_auth_t; +static int ss_authorize(void *opaque,int action,const char *a,const char *b,const char *db,const char *trigger) { + (void)trigger; ss_auth_t *auth=opaque; + if (action==SQLITE_READ && ss_eq(a,"nodes") && ss_eq(db,"main") && auth->deny_pin) { + auth->pin_seen=true; auth->restore_armed=true; return SQLITE_DENY; + } + if (action==SQLITE_PRAGMA && a && sqlite3_stricmp(a,"query_only")==0 && b && + auth->deny_restore && auth->restore_armed) { + auth->restore_denied++; return SQLITE_DENY; + } + return SQLITE_OK; +} +TEST(store_scope_close_restore_denial_requires_connection_discard) { + cbm_store_t *s=cbm_store_open_memory(); ss_ids_t id={0}; + bool setup=ss_seed(s,&id) && ss_sql(s,"PRAGMA query_only=0; PRAGMA busy_timeout=2468")==SQLITE_OK; + cbm_store_read_scope_t *scope=NULL; + int opened=setup?cbm_store_read_scope_open(s,NULL,NULL,&scope):CBM_STORE_ERR; + ss_auth_t auth={.deny_restore=true,.restore_armed=true}; + bool installed=opened==CBM_STORE_OK && sqlite3_set_authorizer(cbm_store_get_db(s),ss_authorize,&auth)==SQLITE_OK; + /* An operation error must not mask a subsequent restoration failure. */ + int latched=installed?cbm_store_read_scope_fail(scope,CBM_STORE_CANCELLED):CBM_STORE_ERR; + int closed=ss_scope_close(scope); scope=NULL; + cbm_store_close(s); s=NULL; /* DISCARD: do not query/reuse the connection or close the scope twice. */ + ASSERT_TRUE(setup); ASSERT_EQ(opened,CBM_STORE_OK); ASSERT_TRUE(installed); + ASSERT_EQ(latched,CBM_STORE_CANCELLED); ASSERT_GT(auth.restore_denied,0); + ASSERT_EQ(closed,CBM_STORE_SCOPE_DISCARD); PASS(); +} +TEST(store_scope_failed_open_reports_cleanup_discard_without_a_handle) { + for (int denial=0;denial<=1;denial++) { + cbm_store_t *s=cbm_store_open_memory(); ss_ids_t id={0}; + bool setup=ss_seed(s,&id) && ss_sql(s,"PRAGMA query_only=0; PRAGMA busy_timeout=2468")==SQLITE_OK; + ss_auth_t auth={.deny_restore=denial!=0,.deny_pin=true}; + bool installed=setup && sqlite3_set_authorizer(cbm_store_get_db(s),ss_authorize,&auth)==SQLITE_OK; + cbm_store_read_scope_t *scope=SS_SENTINEL; + int rc=installed?cbm_store_read_scope_open(s,NULL,NULL,&scope):CBM_STORE_OK; + bool cleared=scope==NULL; + if (scope && scope!=SS_SENTINEL) { (void)ss_scope_close(scope); scope=NULL; } + bool restored=false; + if (installed && !denial && rc!=CBM_STORE_SCOPE_DISCARD) { + restored=sqlite3_set_authorizer(cbm_store_get_db(s),NULL,NULL)==SQLITE_OK && + sqlite3_get_autocommit(cbm_store_get_db(s)) && ss_integer(s,"PRAGMA query_only",0) && + ss_integer(s,"PRAGMA busy_timeout",2468) && ss_normal_walk(s,&id); + } + cbm_store_close(s); s=NULL; /* Mandatory disposal on the denial branch. */ + ASSERT_TRUE(setup); ASSERT_TRUE(installed); ASSERT_TRUE(auth.pin_seen); ASSERT_TRUE(cleared); + ASSERT_EQ(rc,denial?CBM_STORE_SCOPE_DISCARD:CBM_STORE_ERR); + if (denial) ASSERT_GT(auth.restore_denied,0); else ASSERT_TRUE(restored); + } + PASS(); +} + +TEST(store_scope_legacy_golden_order_parents_and_lifts_are_preserved) { + cbm_store_t *s=cbm_store_open_memory(); ss_ids_t id={0}; bool setup=ss_seed(s,&id); + cbm_impact_walk_t *legacy=NULL,*scoped=NULL; + bool original=setup && cbm_impact_walk_open(s,&ss_policy,&legacy)==CBM_STORE_OK && + cbm_impact_walk_run(legacy,&id.root,1)==CBM_STORE_OK && ss_golden(legacy,&id,false) && + cbm_impact_walk_run(legacy,&id.island,1)==CBM_STORE_OK && ss_golden(legacy,&id,true); + cbm_store_read_scope_t *scope=NULL; + int opened=original?cbm_store_read_scope_open(s,NULL,NULL,&scope):CBM_STORE_ERR; + bool equivalent=opened==CBM_STORE_OK && cbm_impact_walk_open_scoped(scope,&ss_policy,&scoped)==CBM_STORE_OK && + cbm_impact_walk_run(scoped,&id.root,1)==CBM_STORE_OK && ss_golden(scoped,&id,false) && + cbm_impact_walk_run(scoped,&id.island,1)==CBM_STORE_OK && ss_golden(scoped,&id,true); + if (scoped) { cbm_impact_walk_close(scoped); } + if (legacy) { cbm_impact_walk_close(legacy); } + int closed=ss_scope_close(scope); cbm_store_close(s); + ASSERT_TRUE(setup); ASSERT_TRUE(original); ASSERT_EQ(opened,CBM_STORE_OK); ASSERT_TRUE(equivalent); + ASSERT_EQ(closed,CBM_STORE_OK); PASS(); +} + +SUITE(store_scope) { + RUN_TEST(store_scope_wal_snapshot_pins_metadata_nodes_and_edges); + RUN_TEST(store_scope_refuses_existing_transaction_and_busy_statement); + RUN_TEST(store_scope_restores_query_only_timeout_and_cancelled_open); + RUN_TEST(store_scope_nested_outline_and_compare_preserve_owned_guard); + RUN_TEST(store_scope_sql_progress_cancellation_latches_and_unwinds); + RUN_TEST(store_scope_counter_sweep_never_exposes_a_cancelled_prefix); + RUN_TEST(store_scope_failed_later_lift_hides_previous_hits); + RUN_TEST(store_scope_held_lock_refuses_without_calling_busy_handler); + RUN_TEST(store_scope_close_restore_denial_requires_connection_discard); + RUN_TEST(store_scope_failed_open_reports_cleanup_discard_without_a_handle); + RUN_TEST(store_scope_legacy_golden_order_parents_and_lifts_are_preserved); +} diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index fb9788f76..631b1ca3d 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -9,6 +9,7 @@ * (SKIP_PLATFORM on Windows, which lacks it). */ #include "test_framework.h" +#include "test_helpers.h" #include "../src/foundation/subprocess.h" #include "../src/foundation/compat.h" #include "../src/foundation/platform.h" @@ -28,6 +29,8 @@ #include #include #else +#include +#include #include #include "../src/foundation/win_utf8.h" #endif @@ -1368,7 +1371,855 @@ TEST(popen_git_strips_repo_env_and_reports_exit_status) { #endif } +/* Native fixed-byte child and independent stdout-capture contract tests. */ +enum { SP_STDOUT_BYTES = 2305, SP_STDOUT_PATH = 1024 }; +static const char *sp_stdout_test_binary; +static const char sp_stderr_text[] = "stderr-first\nstderr-tail"; +static const char sp_stdout_text[] = "stdout-only\n"; + +static void sp_stdout_payload(unsigned char bytes[SP_STDOUT_BYTES]) { + for (int i = 0; i < SP_STDOUT_BYTES; i++) + bytes[i] = (unsigned char)('a' + i % 26); + bytes[0] = 'B'; + bytes[1] = '\0'; + bytes[2] = '\r'; + bytes[3] = '\n'; + bytes[4] = '\n'; + bytes[1022] = 'X'; + bytes[1023] = '\r'; + bytes[1024] = '\n'; + bytes[2048] = '\0'; + bytes[SP_STDOUT_BYTES - 1] = 'Z'; /* final byte has no newline */ +} + +/* Called before normal harness setup. Normal entry remembers argv[0], whose + * storage lasts through main; the private exact argv grammar never runs suites. */ +static int sp_stdin_probe(int argc, char **argv); +int tf_maybe_run_subprocess_stdout_probe(int argc, char **argv); +int tf_maybe_run_subprocess_stdout_probe(int argc, char **argv) { + if (argc >= 2 && argv && strcmp(argv[1], "__cbm_subprocess_stdin_probe") == 0) + return sp_stdin_probe(argc, argv); + if (argc < 2 || !argv || strcmp(argv[1], "__cbm_subprocess_stdout_probe") != 0) { + sp_stdout_test_binary = argc > 0 && argv ? argv[0] : NULL; + return -1; + } + if (argc != 4 || (strcmp(argv[2], "binary") != 0 && strcmp(argv[2], "text") != 0 && + strcmp(argv[2], "empty") != 0)) + return 91; +#ifdef _WIN32 + if (_setmode(cbm_fileno(stdout), _O_BINARY) == -1 || + _setmode(cbm_fileno(stderr), _O_BINARY) == -1) + return 92; +#endif + if (argv[3][0]) { + /* A state barrier, not a transient timing assertion. The deadline only + * bounds cleanup if the parent fails before releasing this child. */ + uint64_t deadline = cbm_now_ms() + 30000U; + while (!cbm_file_exists(argv[3])) { + if (cbm_now_ms() >= deadline) + return 93; + cbm_usleep(1000); + } + } + if (strcmp(argv[2], "binary") == 0) { + unsigned char bytes[SP_STDOUT_BYTES]; + sp_stdout_payload(bytes); + if (fwrite(bytes, 1, sizeof(bytes), stdout) != sizeof(bytes)) + return 94; + } else if (strcmp(argv[2], "text") == 0) { + if (fwrite(sp_stdout_text, 1, sizeof(sp_stdout_text) - 1, stdout) != + sizeof(sp_stdout_text) - 1) + return 94; + } + if (fflush(stdout) != 0) + return 94; + if (fwrite(sp_stderr_text, 1, sizeof(sp_stderr_text) - 1, stderr) != + sizeof(sp_stderr_text) - 1 || + fflush(stderr) != 0) + return 95; + return 0; +} + +typedef struct { + char root[SP_STDOUT_PATH]; + char out[SP_STDOUT_PATH]; + char log[SP_STDOUT_PATH]; + char other[SP_STDOUT_PATH]; + char gate[SP_STDOUT_PATH]; +} sp_stdout_fixture_t; + +static bool sp_stdout_path(char *out, const char *root, const char *leaf) { + int n = snprintf(out, SP_STDOUT_PATH, "%s/%s", root, leaf); + return n > 0 && n < SP_STDOUT_PATH; +} + +static bool sp_stdout_fixture_open(sp_stdout_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + if (!sp_stdout_test_binary || !sp_stdout_test_binary[0]) + return false; + const char *root = th_mktempdir("cbm-sp-stdout"); + if (!root) + return false; + int n = snprintf(fx->root, sizeof(fx->root), "%s", root); + bool ok = n > 0 && (size_t)n < sizeof(fx->root) && sp_stdout_path(fx->out, root, "out.bin") && + sp_stdout_path(fx->log, root, "err.log") && + sp_stdout_path(fx->other, root, "other.bin") && + sp_stdout_path(fx->gate, root, "release"); + if (!ok) + (void)th_rmtree(root); + return ok; +} + +static bool sp_stdout_file_equals(const char *path, const void *bytes, size_t count) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) + return false; + const unsigned char *expected = bytes; + bool equal = true; + for (size_t i = 0; i < count; i++) { + int byte = fgetc(file); + if (byte == EOF || (unsigned char)byte != expected[i]) { + equal = false; + break; + } + } + if (equal) + equal = fgetc(file) == EOF && !ferror(file); + if (fclose(file) != 0) + equal = false; + return equal; +} + +typedef struct { + char lines[4][64]; + int count; + bool overflow; +} sp_stdout_logs_t; + +static void sp_stdout_log(const char *line, void *opaque) { + sp_stdout_logs_t *log = opaque; + if (log->count >= 4 || strlen(line) >= sizeof(log->lines[0])) { + log->overflow = true; + } else { + memcpy(log->lines[log->count], line, strlen(line) + 1); + } + log->count++; +} + +static bool sp_stdout_stderr_only(const sp_stdout_logs_t *log) { + return !log->overflow && log->count == 2 && strcmp(log->lines[0], "stderr-first") == 0 && + strcmp(log->lines[1], "stderr-tail") == 0; +} + +static int sp_stdout_run(const char *mode, cbm_proc_opts_t *opts, cbm_proc_result_t *result) { + const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdout_probe", mode, "", NULL}; + opts->bin = sp_stdout_test_binary; + opts->argv = argv; + return cbm_subprocess_run(opts, result); +} + +static bool sp_stdout_poll_terminal(cbm_subprocess_t *process, cbm_proc_result_t *result, + uint64_t deadline) { + do { + cbm_proc_poll_t state = cbm_subprocess_poll(process, result); + if (state == CBM_PROC_POLL_TERMINAL) + return true; + if (state == CBM_PROC_POLL_ERROR) + return false; + cbm_usleep(1000); + } while (cbm_now_ms() < deadline); + return false; +} + +/* The finite child normally exits immediately. On a RED path, cancel and drain + * before destroying; never pass a live handle to destroy. */ +static bool sp_stdout_reap(cbm_subprocess_t *process, cbm_proc_result_t *result) { + bool terminal = sp_stdout_poll_terminal(process, result, cbm_now_ms() + 10000U); + bool cleaned = terminal; + if (!cleaned) { + (void)cbm_subprocess_request_cancel(process); + cleaned = sp_stdout_poll_terminal(process, result, cbm_now_ms() + 4000U); + } + if (cleaned) + cbm_subprocess_destroy(process); + return terminal; +} + +TEST(subprocess_stdout_null_preserves_merged_log) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + sp_stdout_logs_t logs = {0}; + cbm_proc_opts_t opts = { + .log_file = fx.log, .stdout_file = NULL, .on_log_line = sp_stdout_log, .log_ud = &logs}; + cbm_proc_result_t result = {0}; + int rc = sp_stdout_run("text", &opts, &result); + const char expected[] = "stdout-only\nstderr-first\nstderr-tail"; + bool exact = sp_stdout_file_equals(fx.log, expected, sizeof(expected) - 1); + bool callbacks = + !logs.overflow && logs.count == 3 && strcmp(logs.lines[0], "stdout-only") == 0 && + strcmp(logs.lines[1], "stderr-first") == 0 && strcmp(logs.lines[2], "stderr-tail") == 0; + int cleanup = th_rmtree(fx.root); + ASSERT_EQ(cleanup, 0); + ASSERT_EQ(rc, 0); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(result.tree_quiesced); + ASSERT_TRUE(exact); + ASSERT_TRUE(callbacks); + PASS(); +} + +TEST(subprocess_stdout_capture_exact_binary_and_stderr_isolation) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + sp_stdout_logs_t logs = {0}; + cbm_proc_opts_t opts = { + .stdout_file = fx.out, .log_file = fx.log, .on_log_line = sp_stdout_log, .log_ud = &logs}; + cbm_proc_result_t result = {0}; + int rc = sp_stdout_run("binary", &opts, &result); + unsigned char expected[SP_STDOUT_BYTES]; + sp_stdout_payload(expected); + bool exact = sp_stdout_file_equals(fx.out, expected, sizeof(expected)); + bool stderr_exact = sp_stdout_file_equals(fx.log, sp_stderr_text, sizeof(sp_stderr_text) - 1); + bool callbacks = sp_stdout_stderr_only(&logs); + int cleanup = th_rmtree(fx.root); + ASSERT_EQ(cleanup, 0); + ASSERT_EQ(rc, 0); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(result.tree_quiesced); + ASSERT_TRUE(exact); + ASSERT_TRUE(stderr_exact); + ASSERT_TRUE(callbacks); + PASS(); +} + +TEST(subprocess_stdout_capture_without_log_discards_only_stderr) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + sp_stdout_logs_t logs = {0}; + cbm_proc_opts_t opts = { + .stdout_file = fx.out, .log_file = NULL, .on_log_line = sp_stdout_log, .log_ud = &logs}; + cbm_proc_result_t result = {0}; + int rc = sp_stdout_run("binary", &opts, &result); + unsigned char expected[SP_STDOUT_BYTES]; + sp_stdout_payload(expected); + bool exact = sp_stdout_file_equals(fx.out, expected, sizeof(expected)); + bool absent_log = !cbm_file_exists(fx.log); + int cleanup = th_rmtree(fx.root); + ASSERT_EQ(cleanup, 0); + ASSERT_EQ(rc, 0); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(exact); + ASSERT_TRUE(absent_log); + ASSERT_EQ(logs.count, 0); + PASS(); +} + +TEST(subprocess_stdout_empty_capture_survives_log_deletion) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + sp_stdout_logs_t logs = {0}; + cbm_proc_opts_t opts = {.stdout_file = fx.out, + .log_file = fx.log, + .on_log_line = sp_stdout_log, + .log_ud = &logs, + .delete_log_on_exit = true}; + cbm_proc_result_t result = {0}; + int rc = sp_stdout_run("empty", &opts, &result); + bool exact_empty = sp_stdout_file_equals(fx.out, "", 0); + bool deleted_log = !cbm_file_exists(fx.log); + bool callbacks = sp_stdout_stderr_only(&logs); + int cleanup = th_rmtree(fx.root); + ASSERT_EQ(cleanup, 0); + ASSERT_EQ(rc, 0); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(exact_empty); + ASSERT_TRUE(deleted_log); + ASSERT_TRUE(callbacks); + PASS(); +} + +TEST(subprocess_stdout_capture_owns_caller_path) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + char caller_path[SP_STDOUT_PATH]; + memcpy(caller_path, fx.out, strlen(fx.out) + 1); + const char sentinel[] = "untouched-alternate-file"; + int sentinel_rc = th_write_file(fx.other, sentinel); + const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdout_probe", "binary", fx.gate, + NULL}; + cbm_proc_opts_t opts = { + .bin = sp_stdout_test_binary, .argv = argv, .stdout_file = caller_path, .log_file = fx.log}; + cbm_subprocess_t *process = NULL; + int spawn_rc = sentinel_rc == 0 ? cbm_subprocess_spawn(&opts, &process) : -1; + bool got_process = process != NULL; + /* The child cannot emit before this mutation: its gate is still absent. */ + memcpy(caller_path, fx.other, strlen(fx.other) + 1); + int gate_rc = th_write_file(fx.gate, "release"); + cbm_proc_result_t result = {0}; + bool terminal = process && sp_stdout_reap(process, &result); + unsigned char expected[SP_STDOUT_BYTES]; + sp_stdout_payload(expected); + bool exact = sp_stdout_file_equals(fx.out, expected, sizeof(expected)); + bool alternate_unchanged = sp_stdout_file_equals(fx.other, sentinel, sizeof(sentinel) - 1); + int cleanup = th_rmtree(fx.root); + ASSERT_EQ(cleanup, 0); + ASSERT_EQ(sentinel_rc, 0); + ASSERT_EQ(spawn_rc, 0); + ASSERT_TRUE(got_process); + ASSERT_EQ(gate_rc, 0); + ASSERT_TRUE(terminal); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(result.tree_quiesced); + ASSERT_TRUE(exact); + ASSERT_TRUE(alternate_unchanged); + PASS(); +} + +TEST(subprocess_stdout_rejects_invalid_capture_paths_and_cleans_up) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + char missing_parent[SP_STDOUT_PATH]; + bool path_ok = sp_stdout_path(missing_parent, fx.root, "missing-parent/out.bin"); + const char *paths[] = {"", fx.log, fx.root, missing_parent}; + bool rejected[4] = {false}; + bool null_handle[4] = {false}; + bool cleanup_terminal[4] = {true, true, true, true}; + if (path_ok) { + for (int i = 0; i < 4; i++) { + const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdout_probe", "text", + "", NULL}; + cbm_proc_opts_t opts = {.bin = sp_stdout_test_binary, + .argv = argv, + .stdout_file = paths[i], + .log_file = fx.log}; + cbm_subprocess_t *process = NULL; + int rc = cbm_subprocess_spawn(&opts, &process); + rejected[i] = rc == -1; + null_handle[i] = process == NULL; + if (process) { + cbm_proc_result_t ignored; + cleanup_terminal[i] = sp_stdout_reap(process, &ignored); + } + } + } + int cleanup = th_rmtree(fx.root); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(path_ok); + for (int i = 0; i < 4; i++) { + ASSERT_TRUE(cleanup_terminal[i]); + ASSERT_TRUE(rejected[i]); + ASSERT_TRUE(null_handle[i]); + } + PASS(); +} + +/* Independent stdin_file contract fixtures. No shell and no owned heap allocation. */ +enum { SP_STDIN_BYTES = 4099 }; +static const char sp_stdin_out_sentinel[] = "output-sentinel"; +static const char sp_stdin_log_sentinel[] = "log-sentinel"; + +static void sp_stdin_payload(unsigned char bytes[SP_STDIN_BYTES]) { + for (int i = 0; i < SP_STDIN_BYTES; i++) + bytes[i] = (unsigned char)(i % 256); + bytes[0] = 'I'; + bytes[1] = 0; + bytes[2] = '\r'; + bytes[3] = '\n'; + bytes[4] = '\n'; + bytes[5] = 0x1a; /* Windows text-mode EOF must not truncate the fixture. */ + bytes[1023] = 0; + bytes[SP_STDIN_BYTES - 1] = 0x7f; /* No final LF. */ +} + +static bool sp_stdin_write(const char *path, const void *bytes, size_t count) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) + return false; + bool ok = count == 0 || fwrite(bytes, 1, count, file) == count; + if (fclose(file) != 0) + ok = false; + return ok; +} + +static bool sp_stdin_clean(int rc, const cbm_proc_result_t *result) { + return rc == 0 && result->outcome == CBM_PROC_CLEAN && result->exit_code == 0 && + result->tree_quiesced && !result->supervision_failed; +} + +/* Do not remove inputs/captures following failed containment. Terminal handles + * may be destroyed, but a nonquiescent tree's fixture is intentionally retained. */ +static bool sp_stdin_safe_run(int rc, const cbm_proc_result_t *result) { + return result->tree_quiesced || + (rc == -1 && result->outcome == CBM_PROC_SPAWN_FAILED && + !result->supervision_failed); +} + +static bool sp_stdin_finish(cbm_subprocess_t *process, cbm_proc_result_t *result) { + bool terminal = sp_stdout_poll_terminal(process, result, cbm_now_ms() + 10000U); + if (!terminal) { + (void)cbm_subprocess_request_cancel(process); + terminal = sp_stdout_poll_terminal(process, result, cbm_now_ms() + 4000U); + } + if (terminal) + cbm_subprocess_destroy(process); + return terminal && result->tree_quiesced && !result->supervision_failed; +} + +static int sp_stdin_run(cbm_proc_opts_t *opts, cbm_proc_result_t *result) { + const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "echo", "", + NULL}; + opts->bin = sp_stdout_test_binary; + opts->argv = argv; + opts->quiet_timeout_ms = 10000; /* Finite containment guard, not a timing oracle. */ + return cbm_subprocess_run(opts, result); +} + +typedef struct { + bool prepared; + bool rejected; + bool no_handle; + bool outputs_unchanged; + bool no_child_marker; + bool reusable; + bool safe_cleanup; +} sp_stdin_rejection_t; + +static sp_stdin_rejection_t sp_stdin_check_rejection(const char *binary, const char *input, + const char *out, const char *log, + const char *marker, + const char *valid_empty_input) { + sp_stdin_rejection_t check = {.safe_cleanup = true}; + (void)cbm_unlink(marker); + check.prepared = sp_stdin_write(out, sp_stdin_out_sentinel, + sizeof(sp_stdin_out_sentinel) - 1) && + sp_stdin_write(log, sp_stdin_log_sentinel, + sizeof(sp_stdin_log_sentinel) - 1) && + sp_stdout_file_equals(valid_empty_input, "", 0) && + !cbm_file_exists(marker); + if (!check.prepared) + return check; + const char *argv[] = {binary, "__cbm_subprocess_stdin_probe", "stamp", marker, NULL}; + cbm_proc_opts_t opts = {.bin = binary, + .argv = argv, + .stdin_file = input, + .stdout_file = out, + .log_file = log, + .quiet_timeout_ms = 10000}; + cbm_subprocess_t *process = NULL; + int rc = cbm_subprocess_spawn(&opts, &process); + check.rejected = rc == -1; + check.no_handle = process == NULL; + if (process) { + cbm_proc_result_t result = {0}; + check.safe_cleanup = sp_stdin_finish(process, &result); + } + if (!check.safe_cleanup) + return check; + check.outputs_unchanged = + sp_stdout_file_equals(out, sp_stdin_out_sentinel, sizeof(sp_stdin_out_sentinel) - 1) && + sp_stdout_file_equals(log, sp_stdin_log_sentinel, sizeof(sp_stdin_log_sentinel) - 1); + check.no_child_marker = !cbm_file_exists(marker); + + /* A real empty regular file remains a usable input after each rejection. + * This control also passes when the API-only baseline ignores stdin_file. */ + const char *valid_argv[] = {binary, "__cbm_subprocess_stdin_probe", "echo", "", NULL}; + opts.argv = valid_argv; + opts.stdin_file = valid_empty_input; + cbm_proc_result_t result = {0}; + rc = cbm_subprocess_run(&opts, &result); + check.safe_cleanup = sp_stdin_safe_run(rc, &result); + check.reusable = sp_stdin_clean(rc, &result) && + sp_stdout_file_equals(out, "", 0) && + sp_stdout_file_equals(log, sp_stderr_text, sizeof(sp_stderr_text) - 1) && + sp_stdout_file_equals(valid_empty_input, "", 0); + return check; +} + +/* Dispatched through the existing early native probe entry, before harness setup. */ +static int sp_stdin_probe(int argc, char **argv) { +#ifdef _WIN32 + if (_setmode(cbm_fileno(stdin), _O_BINARY) == -1 || + _setmode(cbm_fileno(stdout), _O_BINARY) == -1 || + _setmode(cbm_fileno(stderr), _O_BINARY) == -1) + return 101; +#endif + if (argc == 8 && strcmp(argv[2], "reject") == 0) { + sp_stdin_rejection_t check = sp_stdin_check_rejection( + argv[0], argv[3], argv[4], argv[5], argv[6], argv[7]); + if (!check.safe_cleanup) + return 125; /* Parent must retain files if nested containment failed. */ + if (!check.prepared) + return 102; + if (!check.reusable) + return 103; + return check.rejected && check.no_handle && check.outputs_unchanged && + check.no_child_marker + ? 0 + : 104; + } + if (argc == 6 && strcmp(argv[2], "parent-eof") == 0) { + /* Isolated process only: furnish genuinely readable parent stdin, then + * verify a NULL stdin_file child still gets the null device. */ + FILE *file = cbm_fopen(argv[3], "rb"); + if (!file) + return 105; +#ifdef _WIN32 + int duplicated = _dup2(cbm_fileno(file), cbm_fileno(stdin)); +#else + int duplicated = dup2(cbm_fileno(file), cbm_fileno(stdin)); +#endif + bool closed = fclose(file) == 0; + if (duplicated < 0 || !closed) + return 106; +#ifdef _WIN32 + if (!SetStdHandle(STD_INPUT_HANDLE, (HANDLE)_get_osfhandle(cbm_fileno(stdin)))) + return 106; +#endif + /* Exercise the descriptor directly: a stdio-buffer-only rewind must not + * leave the inherited OS offset at EOF and make the control vacuous. */ + unsigned char witness; +#ifdef _WIN32 + if (_read(cbm_fileno(stdin), &witness, 1) != 1 || + _lseek(cbm_fileno(stdin), 0, SEEK_SET) != 0) + return 107; +#else + if (read(cbm_fileno(stdin), &witness, 1) != 1 || + lseek(cbm_fileno(stdin), 0, SEEK_SET) != 0) + return 107; +#endif + const char *child_argv[] = {argv[0], "__cbm_subprocess_stdin_probe", "echo", "", NULL}; + cbm_proc_opts_t opts = {.bin = argv[0], + .argv = child_argv, + .stdin_file = NULL, + .stdout_file = argv[4], + .log_file = argv[5], + .quiet_timeout_ms = 10000}; + cbm_proc_result_t result = {0}; + int rc = cbm_subprocess_run(&opts, &result); + if (!sp_stdin_safe_run(rc, &result)) + return 125; + return sp_stdin_clean(rc, &result) && sp_stdout_file_equals(argv[4], "", 0) && + sp_stdout_file_equals(argv[5], sp_stderr_text, sizeof(sp_stderr_text) - 1) + ? 0 + : 108; + } + if (argc != 4) + return 109; + if (strcmp(argv[2], "stamp") == 0) { + if (!sp_stdin_write(argv[3], "child-started", 13) || + fwrite(sp_stdout_text, 1, sizeof(sp_stdout_text) - 1, stdout) != + sizeof(sp_stdout_text) - 1) + return 110; + } else if (strcmp(argv[2], "echo") == 0) { + if (argv[3][0]) { + /* The parent releases this state barrier only after mutating its + * borrowed path. The deadline merely bounds an abandoned fixture. */ + uint64_t deadline = cbm_now_ms() + 30000U; + while (!cbm_file_exists(argv[3])) { + if (cbm_now_ms() >= deadline) + return 111; + cbm_usleep(1000); + } + } + unsigned char bytes[512]; + size_t count; + while ((count = fread(bytes, 1, sizeof(bytes), stdin)) != 0) { + if (fwrite(bytes, 1, count, stdout) != count) + return 112; + } + if (ferror(stdin)) + return 112; + } else { + return 109; + } + if (fflush(stdout) != 0 || + fwrite(sp_stderr_text, 1, sizeof(sp_stderr_text) - 1, stderr) != + sizeof(sp_stderr_text) - 1 || + fflush(stderr) != 0) + return 113; + return 0; +} + +TEST(subprocess_stdin_null_is_eof_even_with_readable_parent_input) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + unsigned char input[SP_STDIN_BYTES]; + sp_stdin_payload(input); + bool prepared = sp_stdin_write(fx.other, input, sizeof(input)); + const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "parent-eof", + fx.other, fx.out, fx.log, NULL}; + cbm_proc_opts_t opts = {.bin = sp_stdout_test_binary, .argv = argv, .quiet_timeout_ms = 20000}; + cbm_proc_result_t result = {0}; + int rc = prepared ? cbm_subprocess_run(&opts, &result) : -1; + bool safe = !prepared || (sp_stdin_safe_run(rc, &result) && result.exit_code != 125); + bool unchanged = sp_stdout_file_equals(fx.other, input, sizeof(input)); + bool empty = sp_stdout_file_equals(fx.out, "", 0); + int cleanup = safe ? th_rmtree(fx.root) : -1; + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + ASSERT_TRUE(sp_stdin_clean(rc, &result)); + ASSERT_TRUE(unchanged); + ASSERT_TRUE(empty); + PASS(); +} + +TEST(subprocess_stdin_empty_regular_file_is_success) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + bool prepared = sp_stdin_write(fx.other, "", 0); + sp_stdout_logs_t logs = {0}; + cbm_proc_opts_t opts = {.stdin_file = fx.other, + .stdout_file = fx.out, + .log_file = fx.log, + .on_log_line = sp_stdout_log, + .log_ud = &logs}; + cbm_proc_result_t result = {0}; + int rc = prepared ? sp_stdin_run(&opts, &result) : -1; + bool safe = !prepared || sp_stdin_safe_run(rc, &result); + bool exact = sp_stdout_file_equals(fx.out, "", 0) && + sp_stdout_file_equals(fx.other, "", 0) && + sp_stdout_file_equals(fx.log, sp_stderr_text, sizeof(sp_stderr_text) - 1); + int cleanup = safe ? th_rmtree(fx.root) : -1; + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + ASSERT_TRUE(sp_stdin_clean(rc, &result)); + ASSERT_TRUE(exact); + ASSERT_TRUE(sp_stdout_stderr_only(&logs)); + PASS(); +} + +TEST(subprocess_stdin_binary_roundtrip_and_stderr_isolation) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + unsigned char input[SP_STDIN_BYTES]; + sp_stdin_payload(input); + bool prepared = sp_stdin_write(fx.other, input, sizeof(input)); + sp_stdout_logs_t logs = {0}; + cbm_proc_opts_t opts = {.stdin_file = fx.other, + .stdout_file = fx.out, + .log_file = fx.log, + .on_log_line = sp_stdout_log, + .log_ud = &logs}; + cbm_proc_result_t result = {0}; + int rc = prepared ? sp_stdin_run(&opts, &result) : -1; + bool safe = !prepared || sp_stdin_safe_run(rc, &result); + bool exact = sp_stdout_file_equals(fx.out, input, sizeof(input)); + bool unchanged = sp_stdout_file_equals(fx.other, input, sizeof(input)); + bool stderr_exact = sp_stdout_file_equals(fx.log, sp_stderr_text, sizeof(sp_stderr_text) - 1); + int cleanup = safe ? th_rmtree(fx.root) : -1; + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + ASSERT_TRUE(sp_stdin_clean(rc, &result)); + ASSERT_TRUE(exact); + ASSERT_TRUE(unchanged); + ASSERT_TRUE(stderr_exact); + ASSERT_TRUE(sp_stdout_stderr_only(&logs)); + PASS(); +} + +TEST(subprocess_stdin_stream_combinations_preserve_input) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + unsigned char input[SP_STDIN_BYTES]; + sp_stdin_payload(input); + bool prepared = sp_stdin_write(fx.other, input, sizeof(input)); + bool all_clean = true, all_exact = true, all_unchanged = true, all_logs = true; + bool safe = true; + for (int mode = 0; prepared && safe && mode < 3; mode++) { + (void)cbm_unlink(fx.log); + sp_stdout_logs_t logs = {0}; + cbm_proc_opts_t opts = {.stdin_file = fx.other, + .stdout_file = mode == 2 ? NULL : fx.out, + .log_file = mode == 1 ? NULL : fx.log, + .delete_log_on_exit = mode == 0, + .on_log_line = mode == 2 ? NULL : sp_stdout_log, + .log_ud = &logs}; + cbm_proc_result_t result = {0}; + int rc = sp_stdin_run(&opts, &result); + safe = sp_stdin_safe_run(rc, &result); + all_clean = all_clean && sp_stdin_clean(rc, &result); + all_unchanged = all_unchanged && sp_stdout_file_equals(fx.other, input, sizeof(input)); + if (mode == 2) { + unsigned char merged[SP_STDIN_BYTES + sizeof(sp_stderr_text) - 1]; + memcpy(merged, input, sizeof(input)); + memcpy(merged + sizeof(input), sp_stderr_text, sizeof(sp_stderr_text) - 1); + all_exact = all_exact && sp_stdout_file_equals(fx.log, merged, sizeof(merged)); + } else { + all_exact = all_exact && sp_stdout_file_equals(fx.out, input, sizeof(input)); + all_logs = all_logs && !cbm_file_exists(fx.log) && + (mode == 0 ? sp_stdout_stderr_only(&logs) : logs.count == 0); + } + } + int cleanup = safe ? th_rmtree(fx.root) : -1; + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + ASSERT_TRUE(all_clean); + ASSERT_TRUE(all_exact); + ASSERT_TRUE(all_unchanged); + ASSERT_TRUE(all_logs); + PASS(); +} + +TEST(subprocess_stdin_caller_path_mutation_before_gate_release) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + char alternate[SP_STDOUT_PATH], caller_path[SP_STDOUT_PATH]; + unsigned char input[SP_STDIN_BYTES]; + sp_stdin_payload(input); + static const char alternate_bytes[] = "different-input-must-not-be-read"; + bool prepared = sp_stdout_path(alternate, fx.root, "alternate.bin") && + sp_stdin_write(fx.other, input, sizeof(input)) && + sp_stdin_write(alternate, alternate_bytes, sizeof(alternate_bytes) - 1); + memcpy(caller_path, fx.other, strlen(fx.other) + 1); + const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "echo", fx.gate, + NULL}; + cbm_proc_opts_t opts = {.bin = sp_stdout_test_binary, + .argv = argv, + .stdin_file = caller_path, + .stdout_file = fx.out, + .log_file = fx.log}; + cbm_subprocess_t *process = NULL; + int rc = prepared ? cbm_subprocess_spawn(&opts, &process) : -1; + bool got_process = process != NULL; + if (prepared) + memcpy(caller_path, alternate, strlen(alternate) + 1); + int gate_rc = prepared ? th_write_file(fx.gate, "release") : -1; + cbm_proc_result_t result = {0}; + bool safe = !process || sp_stdin_finish(process, &result); + bool exact = sp_stdout_file_equals(fx.out, input, sizeof(input)); + bool original_kept = sp_stdout_file_equals(fx.other, input, sizeof(input)); + bool alternate_kept = prepared && + sp_stdout_file_equals(alternate, alternate_bytes, + sizeof(alternate_bytes) - 1); + int cleanup = safe ? th_rmtree(fx.root) : -1; + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(got_process); + ASSERT_EQ(gate_rc, 0); + ASSERT_TRUE(sp_stdin_clean(rc, &result)); + ASSERT_TRUE(exact); + ASSERT_TRUE(original_kept); + ASSERT_TRUE(alternate_kept); + PASS(); +} + +TEST(subprocess_stdin_rejects_invalid_paths_before_outputs) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + char missing[SP_STDOUT_PATH]; + bool prepared = sp_stdout_path(missing, fx.root, "missing-input") && + sp_stdin_write(fx.other, "", 0); + const char *paths[] = {"", missing, fx.root}; + sp_stdin_rejection_t checks[3] = {{0}}; + bool safe = true; + for (int i = 0; prepared && safe && i < 3; i++) { + checks[i] = sp_stdin_check_rejection(sp_stdout_test_binary, paths[i], fx.out, fx.log, + fx.gate, fx.other); + safe = checks[i].safe_cleanup; + } + int cleanup = safe ? th_rmtree(fx.root) : -1; + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + for (int i = 0; i < 3; i++) { + ASSERT_TRUE(checks[i].prepared); + ASSERT_TRUE(checks[i].reusable); + ASSERT_TRUE(checks[i].rejected); + ASSERT_TRUE(checks[i].no_handle); + ASSERT_TRUE(checks[i].outputs_unchanged); + ASSERT_TRUE(checks[i].no_child_marker); + } + PASS(); +} + +TEST(subprocess_stdin_rejects_exact_output_aliases_before_outputs) { + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + bool prepared = sp_stdin_write(fx.other, "", 0); + const char *paths[] = {fx.log, fx.out}; + sp_stdin_rejection_t checks[2] = {{0}}; + bool safe = true; + for (int i = 0; prepared && safe && i < 2; i++) { + checks[i] = sp_stdin_check_rejection(sp_stdout_test_binary, paths[i], fx.out, fx.log, + fx.gate, fx.other); + safe = checks[i].safe_cleanup; + } + int cleanup = safe ? th_rmtree(fx.root) : -1; + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + for (int i = 0; i < 2; i++) { + ASSERT_TRUE(checks[i].prepared); + ASSERT_TRUE(checks[i].reusable); + ASSERT_TRUE(checks[i].rejected); + ASSERT_TRUE(checks[i].no_handle); + ASSERT_TRUE(checks[i].outputs_unchanged); + ASSERT_TRUE(checks[i].no_child_marker); + } + PASS(); +} + +TEST(subprocess_stdin_posix_fifo_and_symlink_reject_without_blocking_runner) { +#ifdef _WIN32 + /* WHITELIST: POSIX FIFO/symlink semantics only. Directory rejection is covered + * above on Windows. No verified unprivileged Windows reparse fixture exists + * in this package; that branch is a recorded coverage limit, not a PASS. */ + SKIP_PLATFORM("POSIX FIFO and symlink fixture; Windows directory covered separately"); +#else + sp_stdout_fixture_t fx; + ASSERT_TRUE(sp_stdout_fixture_open(&fx)); + char fifo[SP_STDOUT_PATH], link[SP_STDOUT_PATH]; + bool prepared = sp_stdout_path(fifo, fx.root, "input.fifo") && + sp_stdout_path(link, fx.root, "input.link") && + sp_stdin_write(fx.other, "", 0) && mkfifo(fifo, 0600) == 0 && + symlink(fx.other, link) == 0; + struct stat fifo_stat, link_stat; + bool kinds = prepared && lstat(fifo, &fifo_stat) == 0 && S_ISFIFO(fifo_stat.st_mode) && + lstat(link, &link_stat) == 0 && S_ISLNK(link_stat.st_mode); + const char *paths[] = {fifo, link}; + bool clean[2] = {false}, no_child[2] = {false}; + bool safe = true; + for (int i = 0; kinds && safe && i < 2; i++) { + (void)cbm_unlink(fx.gate); + const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "reject", + paths[i], fx.out, fx.log, fx.gate, fx.other, NULL}; + /* The probe can itself block inside spawn on a broken blocking FIFO + * open. Only that isolated process is then killed by this safety guard. + * No elapsed-time threshold is used as evidence of correct rejection. */ + cbm_proc_opts_t opts = { + .bin = sp_stdout_test_binary, .argv = argv, .quiet_timeout_ms = 20000}; + cbm_proc_result_t result = {0}; + int rc = cbm_subprocess_run(&opts, &result); + safe = sp_stdin_safe_run(rc, &result) && result.exit_code != 125; + clean[i] = sp_stdin_clean(rc, &result); + no_child[i] = !cbm_file_exists(fx.gate); + } + int cleanup = safe ? th_rmtree(fx.root) : -1; + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + ASSERT_TRUE(kinds); + for (int i = 0; i < 2; i++) { + ASSERT_TRUE(clean[i]); + ASSERT_TRUE(no_child[i]); + } + PASS(); +#endif +} + SUITE(subprocess) { + RUN_TEST(subprocess_stdin_null_is_eof_even_with_readable_parent_input); + RUN_TEST(subprocess_stdin_empty_regular_file_is_success); + RUN_TEST(subprocess_stdin_binary_roundtrip_and_stderr_isolation); + RUN_TEST(subprocess_stdin_stream_combinations_preserve_input); + RUN_TEST(subprocess_stdin_caller_path_mutation_before_gate_release); + RUN_TEST(subprocess_stdin_rejects_invalid_paths_before_outputs); + RUN_TEST(subprocess_stdin_rejects_exact_output_aliases_before_outputs); + RUN_TEST(subprocess_stdin_posix_fifo_and_symlink_reject_without_blocking_runner); + RUN_TEST(subprocess_stdout_null_preserves_merged_log); + RUN_TEST(subprocess_stdout_capture_exact_binary_and_stderr_isolation); + RUN_TEST(subprocess_stdout_capture_without_log_discards_only_stderr); + RUN_TEST(subprocess_stdout_empty_capture_survives_log_deletion); + RUN_TEST(subprocess_stdout_capture_owns_caller_path); + RUN_TEST(subprocess_stdout_rejects_invalid_capture_paths_and_cleans_up); RUN_TEST(subprocess_classify_clean); RUN_TEST(subprocess_classify_exit_nonzero); RUN_TEST(subprocess_classify_windows_crash_codes); diff --git a/tests/test_test_conventions.c b/tests/test_test_conventions.c new file mode 100644 index 000000000..13a52062b --- /dev/null +++ b/tests/test_test_conventions.c @@ -0,0 +1,799 @@ +#include "test_framework.h" +#include "test_helpers.h" +#include "../internal/cbm/cbm.h" +#include "../internal/cbm/lsp/c_lsp.h" +#include "../internal/cbm/result_spill.h" +#include + +#include +#include +#include + +/* Independent tests of the frozen extractor API. No extractor implementation + * helpers or implementation drafts are used by these fixtures. */ +static int tc_init_status; + +#define TC_CASE0 "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[0]}" +#define TC_CASE1 "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[1]}" +#define TC_SUITE0 "{\"language\":\"c\",\"role\":\"suite\",\"define_macro\":\"GROUP\",\"name_args\":[0]}" + +static bool tc_text(const char *left, const char *right) { + return left && right ? strcmp(left, right) == 0 : left == right; +} + +static cbm_test_declarations_t *tc_config(const char *records, const char *presets) { + char json[8192]; + int n = snprintf(json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[%s],\"presets\":{%s}}}}", + records ? records : "", presets ? presets : ""); + return n > 0 && (size_t)n < sizeof(json) ? + cbm_test_declarations_parse(json, (size_t)n, false) : NULL; +} + +static CBMFileResult *tc_extract(const char *source, CBMLanguage language, const char *path, + const cbm_test_declarations_t *config) { + return cbm_extract_file_ex_with_tests(source, (int)strlen(source), language, + "convproj", path, 0, NULL, NULL, NULL, NULL, config); +} + +static CBMFileResult *tc_legacy(const char *source, CBMLanguage language, const char *path) { + return cbm_extract_file_ex(source, (int)strlen(source), language, + "convproj", path, 0, NULL, NULL, NULL, NULL); +} + +static const CBMDefinition *tc_def(const CBMFileResult *result, const char *name) { + const CBMDefinition *found = NULL; + if (!result) return NULL; + for (int i = 0; i < result->defs.count; i++) { + const CBMDefinition *def = &result->defs.items[i]; + if (!tc_text(def->name, name)) continue; + if (found) return NULL; /* duplicate identities never satisfy a unique lookup */ + found = def; + } + return found; +} + +static bool tc_ok(const CBMFileResult *result) { + return result && result->test_declarations_status == CBM_TEST_EXTRACT_OK; +} + +static bool tc_role(const CBMFileResult *result, const char *name, + CBMTestDefinitionRole role) { + const CBMDefinition *def = tc_def(result, name); + char expected[512]; + if (!def || !result->module_qn || def->test_role != role) return false; + int n = snprintf(expected, sizeof(expected), "%s.%s", result->module_qn, name); + return n > 0 && (size_t)n < sizeof(expected) && tc_text(def->qualified_name, expected); +} + +static bool tc_none(const CBMFileResult *result) { + if (!tc_ok(result)) return false; + for (int i = 0; i < result->defs.count; i++) + if (result->defs.items[i].test_role != CBM_TEST_ROLE_NONE) return false; + return true; +} + +static bool tc_only_case(const CBMFileResult *result, const char *name) { + if (!tc_ok(result) || !tc_role(result, name, CBM_TEST_ROLE_CASE)) return false; + int cases = 0; + for (int i = 0; i < result->defs.count; i++) { + const CBMDefinition *def = &result->defs.items[i]; + if (def->test_role == CBM_TEST_ROLE_NONE) continue; + if (def->test_role != CBM_TEST_ROLE_CASE || !tc_text(def->name, name)) return false; + cases++; + } + return cases == 1; +} + +/* Compare the existing definition/carrier identities, not raw pointer bytes. + * The new scalar has to be zero on both paths. This is an extraction oracle, + * not a claim to serialize or certify a complete pipeline graph. */ +static bool tc_legacy_equal(const CBMFileResult *left, const CBMFileResult *right) { + if (!tc_none(left) || !tc_none(right) || !tc_text(left->module_qn, right->module_qn) || + left->has_error != right->has_error || left->is_test_file != right->is_test_file || + left->defs.count != right->defs.count || left->calls.count != right->calls.count || + left->usages.count != right->usages.count) return false; + for (int i = 0; i < left->defs.count; i++) { + const CBMDefinition *a = &left->defs.items[i], *b = &right->defs.items[i]; + if (!tc_text(a->name, b->name) || !tc_text(a->qualified_name, b->qualified_name) || + !tc_text(a->label, b->label) || !tc_text(a->file_path, b->file_path) || + !tc_text(a->signature, b->signature) || a->start_line != b->start_line || + a->end_line != b->end_line || a->is_test != b->is_test) return false; + } + for (int i = 0; i < left->calls.count; i++) { + const CBMCall *a = &left->calls.items[i], *b = &right->calls.items[i]; + if (!tc_text(a->callee_name, b->callee_name) || + !tc_text(a->enclosing_func_qn, b->enclosing_func_qn) || + a->start_line != b->start_line || a->site_start_byte != b->site_start_byte || + a->site_end_byte != b->site_end_byte || a->source_origin != b->source_origin) + return false; + } + for (int i = 0; i < left->usages.count; i++) { + const CBMUsage *a = &left->usages.items[i], *b = &right->usages.items[i]; + if (!tc_text(a->ref_name, b->ref_name) || + !tc_text(a->enclosing_func_qn, b->enclosing_func_qn) || + a->site_start_byte != b->site_start_byte || a->site_end_byte != b->site_end_byte || + a->source_origin != b->source_origin) return false; + } + return true; +} + +static unsigned tc_line_at(const char *source, uint32_t offset) { + size_t length = strlen(source); + if (offset >= length) return 0; + unsigned line = 1; + for (uint32_t i = 0; i < offset; i++) if (source[i] == '\n') line++; + return line; +} + +static bool tc_body_owner(const CBMFileResult *result, const char *source, + const char *name, unsigned line) { + const CBMDefinition *def = tc_def(result, name); + if (!def || !def->qualified_name) return false; + int calls = 0, usages = 0; + for (int i = 0; i < result->calls.count; i++) { + const CBMCall *call = &result->calls.items[i]; + if (call->source_origin != CBM_SOURCE_ORIGIN_RAW || call->start_line != (int)line || + !tc_text(call->callee_name, "sink")) continue; + if (!tc_text(call->enclosing_func_qn, def->qualified_name)) return false; + calls++; + } + for (int i = 0; i < result->usages.count; i++) { + const CBMUsage *usage = &result->usages.items[i]; + if (usage->source_origin != CBM_SOURCE_ORIGIN_RAW || + tc_line_at(source, usage->site_start_byte) != line || + !tc_text(usage->ref_name, "payload")) continue; + if (!tc_text(usage->enclosing_func_qn, def->qualified_name)) return false; + usages++; + } + return calls > 0 && usages > 0; +} + +static bool tc_issue(const CBMFileResult *result, CBMTestExtractStatus status, + int index, uint32_t line) { + return result && result->has_error && result->error_msg && result->error_msg[0] && + result->test_declarations_status == status && + (index == -2 ? result->test_declaration_index >= 0 : + result->test_declaration_index == index) && + result->test_declaration_line == line; +} + +TEST(conventions_null_absent_and_defaults_preserve_legacy) { + ASSERT_EQ(tc_init_status, 0); + cbm_test_declarations_t *absent = cbm_test_declarations_parse(NULL, 0, true); + cbm_test_declarations_t *defaults = tc_config(NULL, NULL); + const char *sources[] = { + "int payload;\nvoid sink(int x) {}\nvoid helper(void) { sink(payload); }\n", + "TEST(Group, Alpha) { }\nTEST_F(Fixture, Beta) { }\nvoid helper() {}\n" + }; + const CBMLanguage languages[] = {CBM_LANG_C, CBM_LANG_CPP}; + const char *paths[] = {"tests/test_defaults.c", "src/defaults.cpp"}; + bool snapshots = absent && defaults; + bool same = snapshots; + for (size_t i = 0; i < 2; i++) { + CBMFileResult *old = tc_legacy(sources[i], languages[i], paths[i]); + CBMFileResult *nil = tc_extract(sources[i], languages[i], paths[i], NULL); + CBMFileResult *missing = absent ? tc_extract(sources[i], languages[i], paths[i], absent) : NULL; + CBMFileResult *configured = defaults ? tc_extract(sources[i], languages[i], paths[i], defaults) : NULL; + same = old && old->defs.count > 0 && tc_legacy_equal(old, nil) && + tc_legacy_equal(old, missing) && tc_legacy_equal(old, configured) && same; + const CBMDefinition *helper = tc_def(configured, "helper"); + same = helper && helper->test_role == CBM_TEST_ROLE_NONE && same; + cbm_free_result(old); cbm_free_result(nil); + cbm_free_result(missing); cbm_free_result(configured); + } + cbm_test_declarations_free(absent); cbm_test_declarations_free(defaults); + ASSERT_TRUE(snapshots); + ASSERT_TRUE(same); + PASS(); +} + +TEST(conventions_names_roles_arguments_and_body_owners) { + ASSERT_EQ(tc_init_status, 0); + const char *source = + "int payload;\nvoid sink(int x) {}\n" + "CHECK(pair(1, 2), alpha) { sink(payload); }\n" + "CHECK(\"left,right\", beta) { sink(payload); }\n" + "GROUP(core) { sink(payload); }\n" + "void helper(void) { sink(payload); }\n"; + cbm_test_declarations_t *config = tc_config(TC_CASE1 "," TC_SUITE0, NULL); + bool parsed = config != NULL; + CBMFileResult *result = config ? tc_extract(source, CBM_LANG_C, "src/conventions.c", config) : NULL; + cbm_test_declarations_free(config); + bool roles = tc_ok(result) && tc_role(result, "CHECK_alpha", CBM_TEST_ROLE_CASE) && + tc_role(result, "CHECK_beta", CBM_TEST_ROLE_CASE) && + tc_role(result, "GROUP_core", CBM_TEST_ROLE_SUITE) && + tc_role(result, "helper", CBM_TEST_ROLE_NONE); + const CBMDefinition *alpha = tc_def(result, "CHECK_alpha"), *beta = tc_def(result, "CHECK_beta"); + const CBMDefinition *suite = tc_def(result, "GROUP_core"); + roles = alpha && beta && suite && alpha->is_test && beta->is_test && !suite->is_test && roles; + bool owners = tc_body_owner(result, source, "CHECK_alpha", 3) && + tc_body_owner(result, source, "CHECK_beta", 4) && + tc_body_owner(result, source, "GROUP_core", 5) && + tc_body_owner(result, source, "helper", 6); + cbm_free_result(result); + ASSERT_TRUE(parsed); + ASSERT_TRUE(roles); + ASSERT_TRUE(owners); + PASS(); +} + +TEST(conventions_duplicates_coalesce_and_conflicts_fail_typed) { + ASSERT_EQ(tc_init_status, 0); + cbm_test_declarations_t *duplicate = tc_config(TC_CASE0 "," TC_CASE0, NULL); + bool parsed = duplicate != NULL; + CBMFileResult *result = duplicate ? tc_extract("CHECK(alpha) {}\n", CBM_LANG_C, + "src/duplicate.c", duplicate) : NULL; + bool coalesced = tc_ok(result) && tc_role(result, "CHECK_alpha", CBM_TEST_ROLE_CASE); + cbm_free_result(result); cbm_test_declarations_free(duplicate); + const char *records[] = { + TC_CASE0 ",{\"language\":\"c\",\"role\":\"suite\",\"define_macro\":\"CHECK\",\"name_args\":[0]}", + "{\"language\":\"c\",\"role\":\"suite\",\"define_macro\":\"CHECK\",\"name_args\":[0]}," TC_CASE0, + TC_CASE0 "," TC_CASE1 + }; + bool rejected = true; + for (size_t i = 0; i < 3; i++) { + cbm_test_declarations_t *config = tc_config(records[i], NULL); + parsed = config && parsed; + CBMFileResult *first = config ? tc_extract("CHECK(alpha, alpha) {}\n", CBM_LANG_C, + "src/conflict.c", config) : NULL; + CBMFileResult *again = config ? tc_extract("CHECK(alpha, alpha) {}\n", CBM_LANG_C, + "src/conflict.c", config) : NULL; + bool bound = tc_issue(first, CBM_TEST_EXTRACT_AMBIGUOUS, -2, 1) && + first->test_declaration_index < 2 && tc_issue(again, CBM_TEST_EXTRACT_AMBIGUOUS, + first->test_declaration_index, 1) && tc_text(first->error_msg, again->error_msg); + if (!bound) fprintf(stderr, "convention conflict fixture %zu failed\n", i); + rejected = bound && rejected; + cbm_free_result(first); cbm_free_result(again); cbm_test_declarations_free(config); + } + ASSERT_TRUE(parsed); + ASSERT_TRUE(coalesced); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(conventions_invalid_invocations_have_exact_typed_locations) { + ASSERT_EQ(tc_init_status, 0); + const char *sources[] = { + "CHECK(only) {}\n", "CHECK(ignored, 123) {}\n", + "CHECK(ignored, \"title\") {}\n", "CHECK(ignored, alpha + beta) {}\n" + }; + const CBMTestExtractStatus statuses[] = {CBM_TEST_EXTRACT_MISSING_ARGUMENT, + CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT, CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT, + CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT}; + cbm_test_declarations_t *config = tc_config(TC_CASE1, NULL); + bool parsed = config != NULL; + bool rejected = config != NULL; + for (size_t i = 0; i < 4; i++) { + CBMFileResult *result = config ? tc_extract(sources[i], CBM_LANG_C, "src/invalid.c", config) : NULL; + bool bound = tc_issue(result, statuses[i], 0, 1); + if (!bound) fprintf(stderr, "convention argument fixture %zu failed\n", i); + rejected = bound && rejected; + cbm_free_result(result); + } + cbm_test_declarations_free(config); + config = tc_config("{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[2147483647]}", NULL); + parsed = config && parsed; + CBMFileResult *huge = config ? tc_extract("CHECK(alpha) {}\n", CBM_LANG_C, "src/huge.c", config) : NULL; + bool huge_missing = tc_issue(huge, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1); + cbm_free_result(huge); cbm_test_declarations_free(config); + config = tc_config( + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"LATER\",\"name_args\":[1]}," + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"EARLIER\",\"name_args\":[1]}", NULL); + parsed = config && parsed; + CBMFileResult *ordered = config ? tc_extract("\nEARLIER(only) {}\nLATER(only) {}\n", CBM_LANG_C, + "src/order.c", config) : NULL; + bool earliest = tc_issue(ordered, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 1, 2); + cbm_free_result(ordered); cbm_test_declarations_free(config); + ASSERT_TRUE(parsed); + ASSERT_TRUE(rejected); + ASSERT_TRUE(huge_missing); + ASSERT_TRUE(earliest); + PASS(); +} + +TEST(conventions_unsupported_consumed_descriptors_fail_preflight) { + ASSERT_EQ(tc_init_status, 0); + const char *records[] = { + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[0,1]}", + "{\"language\":\"future-language\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[0]}", + "" + }; + const char *presets[] = {"", "", "\"pytest\":false"}; + const CBMTestExtractStatus statuses[] = {CBM_TEST_EXTRACT_UNSUPPORTED_NAME, + CBM_TEST_EXTRACT_UNSUPPORTED_LANGUAGE, CBM_TEST_EXTRACT_UNSUPPORTED_PRESET}; + bool rejected = true, parsed = true; + for (size_t i = 0; i < 3; i++) { + cbm_test_declarations_t *config = tc_config(records[i], presets[i]); + parsed = config && parsed; + CBMFileResult *result = config ? tc_extract("", CBM_LANG_C, "src/preflight.c", config) : NULL; + bool bound = tc_issue(result, statuses[i], i == 2 ? -1 : 0, 0); + if (!bound) fprintf(stderr, "convention preflight fixture %zu failed\n", i); + rejected = bound && rejected; + cbm_free_result(result); cbm_test_declarations_free(config); + } + /* These retained fields belong to the runtime adapter, not definition naming. */ + cbm_test_declarations_t *opaque = tc_config( + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[0],\"runner_id\":\"opaque:{future}\"}," + "{\"language\":\"future-language\",\"role\":\"registration\",\"macro\":\"REG\",\"test_arg\":0}," + "{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":\"RUN\",\"suite_arg\":0,\"perf_macro\":\"PERF\"}", NULL); + parsed = opaque && parsed; + CBMFileResult *result = opaque ? tc_extract("CHECK(alpha) {}\n", CBM_LANG_C, + "src/opaque.c", opaque) : NULL; + bool retained = tc_ok(result) && tc_role(result, "CHECK_alpha", CBM_TEST_ROLE_CASE); + cbm_free_result(result); cbm_test_declarations_free(opaque); + ASSERT_TRUE(parsed); + ASSERT_TRUE(rejected); + ASSERT_TRUE(retained); + PASS(); +} + +TEST(conventions_presets_and_languages_are_isolated) { + ASSERT_EQ(tc_init_status, 0); + const char *source = "TEST(alpha) {}\nSUITE(core) {}\nvoid helper(void) {}\n"; + cbm_test_declarations_t *cbm = tc_config(NULL, "\"c-cbm\":true"); + bool parsed = cbm != NULL; + CBMFileResult *c = cbm ? tc_extract(source, CBM_LANG_C, "tests/test_preset.c", cbm) : NULL; + bool c_roles = tc_ok(c) && tc_role(c, "TEST_alpha", CBM_TEST_ROLE_CASE) && + tc_role(c, "SUITE_core", CBM_TEST_ROLE_SUITE) && + tc_role(c, "helper", CBM_TEST_ROLE_NONE); + cbm_free_result(c); + const CBMLanguage others[] = {CBM_LANG_CPP, CBM_LANG_CUDA}; + const char *paths[] = {"src/preset.cpp", "src/preset.cu"}; + bool isolated = cbm != NULL; + for (size_t i = 0; i < 2; i++) { + CBMFileResult *old = tc_legacy(source, others[i], paths[i]); + CBMFileResult *result = cbm ? tc_extract(source, others[i], paths[i], cbm) : NULL; + isolated = tc_def(old, "helper") && tc_legacy_equal(old, result) && isolated; + cbm_free_result(old); cbm_free_result(result); + } + cbm_test_declarations_free(cbm); + cbm_test_declarations_t *disabled = tc_config(NULL, "\"gtest\":false"); + parsed = disabled && parsed; + const char *gtest = "TEST(Group, Alpha) {}\n"; + CBMFileResult *old = tc_legacy(gtest, CBM_LANG_CPP, "src/gtest.cpp"); + CBMFileResult *off = disabled ? tc_extract(gtest, CBM_LANG_CPP, "src/gtest.cpp", disabled) : NULL; + const char *legacy_name = NULL; + if (old) for (int i = 0; i < old->defs.count; i++) { + const CBMDefinition *def = &old->defs.items[i]; + if (def->name && strstr(def->name, "Alpha")) legacy_name = def->name; + } + bool gtest_control = legacy_name != NULL; + bool switched = gtest_control && tc_none(off) && !tc_def(off, legacy_name); + cbm_free_result(old); cbm_free_result(off); cbm_test_declarations_free(disabled); + cbm_test_declarations_t *explicit_languages = tc_config(TC_CASE0 "," + "{\"language\":\"cpp\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[0]}," + "{\"language\":\"cuda\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[0]}", NULL); + parsed = explicit_languages && parsed; + const CBMLanguage languages[] = {CBM_LANG_C, CBM_LANG_CPP, CBM_LANG_CUDA}; + bool mapped = explicit_languages != NULL; + for (size_t i = 0; i < 3; i++) { + CBMFileResult *result = explicit_languages ? tc_extract("CHECK(value) {}\n", languages[i], + "src/languages.c", explicit_languages) : NULL; + mapped = tc_ok(result) && tc_role(result, "CHECK_value", CBM_TEST_ROLE_CASE) && mapped; + cbm_free_result(result); + } + cbm_test_declarations_free(explicit_languages); + ASSERT_TRUE(parsed); ASSERT_TRUE(gtest_control); + ASSERT_TRUE(c_roles); ASSERT_TRUE(isolated); ASSERT_TRUE(switched); ASSERT_TRUE(mapped); + PASS(); +} + +TEST(conventions_snapshot_and_result_strings_are_independently_owned) { + ASSERT_EQ(tc_init_status, 0); + char json_a[] = "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[" TC_CASE0 "]}}}"; + char json_b[] = "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[" + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"ALT\",\"name_args\":[0]}]}}}"; + cbm_test_declarations_t *a = cbm_test_declarations_parse(json_a, strlen(json_a), false); + cbm_test_declarations_t *b = cbm_test_declarations_parse(json_b, strlen(json_b), false); + bool parsed = a && b; + memset(json_a, 'x', sizeof(json_a) - 1); memset(json_b, 'y', sizeof(json_b) - 1); + char source[] = "CHECK(alpha) {}\nALT(beta) {}\n"; + CBMFileResult *first = a ? tc_extract(source, CBM_LANG_C, "src/owned.c", a) : NULL; + CBMFileResult *other = b ? tc_extract(source, CBM_LANG_C, "src/owned.c", b) : NULL; + CBMFileResult *again = a ? tc_extract(source, CBM_LANG_C, "src/owned.c", a) : NULL; + cbm_test_declarations_free(a); cbm_test_declarations_free(b); + memset(source, 'z', sizeof(source) - 1); + bool owned = tc_ok(first) && tc_ok(other) && tc_ok(again) && + tc_only_case(first, "CHECK_alpha") && + tc_only_case(again, "CHECK_alpha") && + tc_only_case(other, "ALT_beta") && + tc_text(tc_def(first, "CHECK_alpha")->qualified_name, + tc_def(again, "CHECK_alpha")->qualified_name); + cbm_free_result(first); cbm_free_result(other); cbm_free_result(again); + ASSERT_TRUE(parsed); + ASSERT_TRUE(owned); + PASS(); +} + +TEST(conventions_roles_and_errors_survive_compaction_and_spill) { + ASSERT_EQ(tc_init_status, 0); + cbm_test_declarations_t *config = tc_config(TC_CASE1, NULL); + bool parsed = config != NULL; + CBMFileResult *good = config ? tc_extract("CHECK(ignore, alive) {}\n", CBM_LANG_C, + "src/spill.c", config) : NULL; + CBMFileResult *bad = config ? tc_extract("CHECK(only) {}\n", CBM_LANG_C, + "src/spill_error.c", config) : NULL; + cbm_test_declarations_free(config); + char error_copy[1024] = {0}; + bool before = tc_ok(good) && tc_role(good, "CHECK_alive", CBM_TEST_ROLE_CASE) && + tc_issue(bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1) && + strlen(bad->error_msg) < sizeof(error_copy); + if (before) memcpy(error_copy, bad->error_msg, strlen(bad->error_msg) + 1); + if (good) cbm_result_compact(good); + if (bad) cbm_result_compact(bad); + bool compacted = tc_ok(good) && tc_role(good, "CHECK_alive", CBM_TEST_ROLE_CASE) && + tc_issue(bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1) && tc_text(bad->error_msg, error_copy); + const char *temporary = th_mktempdir("cbm-test-conventions"); + char directory[512] = {0}; + bool dir_ok = temporary && strlen(temporary) < sizeof(directory); + if (dir_ok) memcpy(directory, temporary, strlen(temporary) + 1); + cbm_result_spill_t *spill = dir_ok ? cbm_result_spill_open(directory, 1, 2) : NULL; + bool spill_opened = spill != NULL; + bool parked_good = spill && good && cbm_result_spill_park(spill, 0, 0, good); + if (parked_good) good = NULL; /* park consumed ownership */ + bool parked_bad = spill && bad && cbm_result_spill_park(spill, 0, 1, bad); + if (parked_bad) bad = NULL; + CBMFileResult header = {0}; + bool header_ok = parked_bad && cbm_result_spill_peek_header(spill, 1, &header) && + header.test_declarations_status == CBM_TEST_EXTRACT_MISSING_ARGUMENT && + header.test_declaration_index == 0 && header.test_declaration_line == 1 && header.has_error; + CBMFileResult *loaded_good = parked_good ? cbm_result_spill_load(spill, 0) : NULL; + CBMFileResult *loaded_bad = parked_bad ? cbm_result_spill_load(spill, 1) : NULL; + if (spill) cbm_result_spill_close(spill); + bool reloaded = tc_ok(loaded_good) && tc_role(loaded_good, "CHECK_alive", CBM_TEST_ROLE_CASE) && + tc_def(loaded_good, "CHECK_alive")->is_test && + tc_issue(loaded_bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1) && + tc_text(loaded_bad->error_msg, error_copy); + cbm_free_result(good); cbm_free_result(bad); + cbm_free_result(loaded_good); cbm_free_result(loaded_bad); + int cleanup = dir_ok ? th_rmtree(directory) : -1; + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(parsed); ASSERT_TRUE(spill_opened); + ASSERT_TRUE(before); ASSERT_TRUE(compacted); + ASSERT_TRUE(parked_good); ASSERT_TRUE(parked_bad); ASSERT_TRUE(header_ok); ASSERT_TRUE(reloaded); + PASS(); +} + +TEST(conventions_only_raw_invocations_receive_configured_roles) { + ASSERT_EQ(tc_init_status, 0); + const char *source = + "void sink(void);\n" + "#define OPEN_CASE(name) void CHECK_##name(void) {\n" + "#define CLOSE_CASE }\n" + "OPEN_CASE(hidden)\n" + " sink();\n" + "CLOSE_CASE\n" + "CHECK(visible) {}\n"; + cbm_test_declarations_t *config = tc_config(TC_CASE0, NULL); + bool parsed = config != NULL; + CBMFileResult *result = config ? tc_extract(source, CBM_LANG_C, "src/origin.c", config) : NULL; + cbm_test_declarations_free(config); + /* Expanded definitions need not be adopted into the raw definition set. + * A call with an expanded-source origin and the generated function owner + * proves this fixture actually exercised preprocessing. */ + bool preprocessed_control = false; + if (result && result->module_qn) { + char enclosing[512]; + int n = snprintf(enclosing, sizeof(enclosing), "%s.CHECK_hidden", result->module_qn); + if (n > 0 && (size_t)n < sizeof(enclosing)) { + for (int i = 0; i < result->calls.count; i++) { + const CBMCall *call = &result->calls.items[i]; + if (call->source_origin == CBM_SOURCE_ORIGIN_PREPROCESSED && + tc_text(call->callee_name, "sink") && + tc_text(call->enclosing_func_qn, enclosing)) + preprocessed_control = true; + } + } + } + /* The raw visible case is the only allowed configured role. This also + * rejects promotion of a generated definition if one is retained. */ + bool origin = tc_only_case(result, "CHECK_visible"); + cbm_free_result(result); + ASSERT_TRUE(parsed); ASSERT_TRUE(preprocessed_control); + ASSERT_TRUE(origin); + PASS(); +} + +/* Add below the original independent fixtures, before SUITE(test_conventions). */ +#define TC_OWNER_SOURCE "void foo(int x) {} int bar;\nCHECK(one) { foo (bar); }\n" +#define TC_STALE_SOURCE "void foo(int x) {} int bar;\nCHECK(one) { foo + bar; }\n" + +typedef struct { + CBMFileResult *owners; + CBMFileResult *stale; + const CBMDefinition *one; + CBMLSPDef foo; + char caller[512]; + char callee[512]; +} tc_owner_fixture_t; + +static bool tc_owner_open(tc_owner_fixture_t *f) { + *f = (tc_owner_fixture_t){0}; + cbm_test_declarations_t *config = tc_config(TC_CASE0, NULL); + f->owners = config ? tc_extract(TC_OWNER_SOURCE, CBM_LANG_C, + "src/owner.c", config) : NULL; + cbm_test_declarations_free(config); + f->stale = tc_legacy(TC_STALE_SOURCE, CBM_LANG_C, "src/owner.c"); + f->one = tc_def(f->owners, "CHECK_one"); + if (!tc_ok(f->owners) || !tc_role(f->owners, "CHECK_one", CBM_TEST_ROLE_CASE) || + !f->owners->has_test_definition_owners || !f->owners->cached_tree || + !f->stale || !f->stale->cached_tree || !f->one || + strlen(TC_OWNER_SOURCE) != strlen(TC_STALE_SOURCE)) return false; + int a = snprintf(f->caller, sizeof(f->caller), "%s", f->one->qualified_name); + int b = snprintf(f->callee, sizeof(f->callee), "%s.foo", f->owners->module_qn); + if (a <= 0 || (size_t)a >= sizeof(f->caller) || + b <= 0 || (size_t)b >= sizeof(f->callee)) return false; + static const char *parameters[] = {"int"}; + f->foo = (CBMLSPDef){ + .qualified_name = f->callee, .short_name = "foo", .label = "Function", + .def_module_qn = f->owners->module_qn, .return_types = "void", + .signature_param_types = parameters, .signature_param_count = 1, + .lang = CBM_LANG_C}; + return true; +} + +static void tc_owner_close(tc_owner_fixture_t *f) { + cbm_free_result(f->owners); + cbm_free_result(f->stale); + f->owners = NULL; + f->stale = NULL; + f->one = NULL; +} + +static bool tc_resolved_equal(const CBMResolvedCallArray *a, + const CBMResolvedCallArray *b) { + if (a->count != b->count) return false; + for (int i = 0; i < a->count; i++) { + const CBMResolvedCall *x = &a->items[i], *y = &b->items[i]; + if (!tc_text(x->caller_qn, y->caller_qn) || !tc_text(x->callee_qn, y->callee_qn) || + !tc_text(x->strategy, y->strategy) || !tc_text(x->reason, y->reason) || + x->confidence != y->confidence || x->kind != y->kind || + x->site_start_byte != y->site_start_byte || x->site_end_byte != y->site_end_byte || + x->source_origin != y->source_origin) return false; + } + return true; +} + +static bool tc_resolved_foo(const CBMResolvedCallArray *out, + const char *caller, const char *callee) { + int matches = 0; + const char *source = TC_OWNER_SOURCE; + const char *site = strstr(source, "foo (bar)"); + uint32_t start = (uint32_t)(site - source); + for (int i = 0; i < out->count; i++) { + const CBMResolvedCall *call = &out->items[i]; + if (!tc_text(call->callee_qn, callee) || call->kind != CBM_RESOLVED_INVOCATION) continue; + if (!tc_text(call->caller_qn, caller) || call->source_origin != CBM_SOURCE_ORIGIN_RAW || + call->site_start_byte != start || call->site_end_byte <= start || + call->site_end_byte > start + 9) return false; + matches++; + } + return matches == 1; +} + +static bool tc_owner_cross(tc_owner_fixture_t *f, CBMArena *arena, + CBMResolvedCallArray *out, CBMTypeRegistry *registry, + const char *source, int length, const char *module, + bool cpp, CBMSourceOrigin origin, TSTree *tree) { + if (registry) + return cbm_run_c_lsp_cross_with_registry_with_test_owners(arena, source, length, + module, cpp, registry, NULL, NULL, 0, tree, out, origin, f->owners); + return cbm_run_c_lsp_cross_with_test_owners(arena, source, length, module, cpp, + &f->foo, 1, NULL, NULL, 0, tree, out, origin, f->owners); +} + +TEST(conventions_cached_body_tree_cannot_drop_calls) { + ASSERT_EQ(tc_init_status, 0); + tc_owner_fixture_t f; + bool fixture = tc_owner_open(&f); + CBMArena registry_arena; + cbm_arena_init_lazy(®istry_arena, 4096); + CBMTypeRegistry *registry = fixture ? + cbm_c_build_cross_registry(®istry_arena, &f.foo, 1) : NULL; + bool registry_ok = registry != NULL; + bool controls = fixture && registry_ok, protected = controls; + for (int variant = 0; variant < 2 && fixture && registry_ok; variant++) { + CBMArena fresh_arena, stale_arena; + cbm_arena_init_lazy(&fresh_arena, 4096); + cbm_arena_init_lazy(&stale_arena, 4096); + CBMResolvedCallArray fresh = {0}, stale = {0}; + CBMTypeRegistry *selected = variant ? registry : NULL; + bool fresh_ok = tc_owner_cross(&f, &fresh_arena, &fresh, selected, + TC_OWNER_SOURCE, (int)strlen(TC_OWNER_SOURCE), f.owners->module_qn, false, + CBM_SOURCE_ORIGIN_RAW, NULL); + bool stale_ok = tc_owner_cross(&f, &stale_arena, &stale, selected, + TC_OWNER_SOURCE, (int)strlen(TC_OWNER_SOURCE), f.owners->module_qn, false, + CBM_SOURCE_ORIGIN_RAW, f.stale->cached_tree); + bool control = fresh_ok && tc_resolved_foo(&fresh, f.caller, f.callee); + bool bound = !stale_ok || tc_resolved_equal(&fresh, &stale); + if (!control || !bound) fprintf(stderr, "convention stale cross variant %d failed\n", variant); + controls = control && controls; + protected = bound && protected; + cbm_arena_destroy(&fresh_arena); + cbm_arena_destroy(&stale_arena); + } + cbm_arena_destroy(®istry_arena); + tc_owner_close(&f); + ASSERT_TRUE(fixture); ASSERT_TRUE(registry_ok); ASSERT_TRUE(controls); + ASSERT_TRUE(protected); + PASS(); +} + +TEST(conventions_root_body_tree_cannot_drop_calls) { + ASSERT_EQ(tc_init_status, 0); + tc_owner_fixture_t f; + bool fixture = tc_owner_open(&f); + CBMFileResult *fresh = tc_legacy(TC_OWNER_SOURCE, CBM_LANG_C, "src/owner.c"); + CBMFileResult *stale = tc_legacy(TC_OWNER_SOURCE, CBM_LANG_C, "src/owner.c"); + bool inputs = fixture && fresh && fresh->cached_tree && stale; + bool fresh_ok = false, stale_ok = false; + if (inputs) { + fresh->resolved_calls = (CBMResolvedCallArray){0}; + stale->resolved_calls = (CBMResolvedCallArray){0}; + fresh_ok = cbm_run_c_lsp_with_test_owners(&fresh->arena, fresh, + TC_OWNER_SOURCE, (int)strlen(TC_OWNER_SOURCE), + ts_tree_root_node(fresh->cached_tree), false, CBM_SOURCE_ORIGIN_RAW, f.owners); + stale_ok = cbm_run_c_lsp_with_test_owners(&stale->arena, stale, + TC_OWNER_SOURCE, (int)strlen(TC_OWNER_SOURCE), + ts_tree_root_node(f.stale->cached_tree), false, CBM_SOURCE_ORIGIN_RAW, f.owners); + } + bool control = inputs && fresh_ok && tc_resolved_foo(&fresh->resolved_calls, f.caller, f.callee); + bool protected = inputs && (!stale_ok || tc_resolved_equal(&fresh->resolved_calls, + &stale->resolved_calls)); + cbm_free_result(fresh); cbm_free_result(stale); tc_owner_close(&f); + ASSERT_TRUE(inputs); ASSERT_TRUE(control); ASSERT_TRUE(protected); + PASS(); +} + +TEST(conventions_owner_identity_and_output_lifetime) { + ASSERT_EQ(tc_init_status, 0); + tc_owner_fixture_t f; + bool fixture = tc_owner_open(&f); + CBMArena registry_arena, first_arena, second_arena; + cbm_arena_init_lazy(®istry_arena, 4096); + cbm_arena_init_lazy(&first_arena, 4096); + cbm_arena_init_lazy(&second_arena, 4096); + CBMTypeRegistry *registry = fixture ? + cbm_c_build_cross_registry(®istry_arena, &f.foo, 1) : NULL; + CBMResolvedCallArray first = {0}, second = {0}; + CBMFileResult *single = tc_legacy(TC_OWNER_SOURCE, CBM_LANG_C, "src/owner.c"); + bool inputs = fixture && registry && single && single->cached_tree; + bool controls = false, rejected = inputs; + if (inputs) { + single->resolved_calls = (CBMResolvedCallArray){0}; + bool a = tc_owner_cross(&f, &first_arena, &first, NULL, TC_OWNER_SOURCE, + (int)strlen(TC_OWNER_SOURCE), f.owners->module_qn, false, CBM_SOURCE_ORIGIN_RAW, NULL); + bool b = tc_owner_cross(&f, &second_arena, &second, registry, TC_OWNER_SOURCE, + (int)strlen(TC_OWNER_SOURCE), f.owners->module_qn, false, CBM_SOURCE_ORIGIN_RAW, NULL); + bool c = cbm_run_c_lsp_with_test_owners(&single->arena, single, + TC_OWNER_SOURCE, (int)strlen(TC_OWNER_SOURCE), ts_tree_root_node(single->cached_tree), + false, CBM_SOURCE_ORIGIN_RAW, f.owners); + controls = a && b && c && tc_resolved_foo(&first, f.caller, f.callee) && + tc_resolved_foo(&second, f.caller, f.callee) && + tc_resolved_foo(&single->resolved_calls, f.caller, f.callee); + } + for (int variant = 0; variant < 5 && inputs; variant++) { + const char *source = variant == 0 ? TC_STALE_SOURCE : TC_OWNER_SOURCE; + int length = (int)strlen(source) - (variant == 4 ? 1 : 0); + const char *module = variant == 3 ? "convproj.wrong" : f.owners->module_qn; + bool cpp = variant == 2; + CBMSourceOrigin origin = variant == 1 ? CBM_SOURCE_ORIGIN_PREPROCESSED : CBM_SOURCE_ORIGIN_RAW; + for (int api = 0; api < 2; api++) { + CBMArena arena; + cbm_arena_init_lazy(&arena, 4096); + CBMResolvedCallArray out = {0}; + bool ok = tc_owner_cross(&f, &arena, &out, api ? registry : NULL, + source, length, module, cpp, origin, f.owners->cached_tree); + bool bound = !ok && out.count == 0; + if (!bound) fprintf(stderr, "convention owner identity variant %d API %d failed\n", variant, api); + rejected = bound && rejected; + cbm_arena_destroy(&arena); + } + CBMFileResult *out = tc_legacy(TC_OWNER_SOURCE, CBM_LANG_C, "src/owner.c"); + if (!out) { rejected = false; continue; } + out->resolved_calls = (CBMResolvedCallArray){0}; + if (variant == 3) out->module_qn = "convproj.wrong"; + bool ok = cbm_run_c_lsp_with_test_owners(&out->arena, out, source, length, + ts_tree_root_node(f.owners->cached_tree), cpp, origin, f.owners); + bool bound = !ok && out->resolved_calls.count == 0; + if (!bound) fprintf(stderr, "convention owner identity variant %d root API failed\n", variant); + rejected = bound && rejected; + cbm_free_result(out); + } + char expected_caller[512] = {0}, expected_callee[512] = {0}; + if (inputs) { + memcpy(expected_caller, f.caller, sizeof(expected_caller)); + memcpy(expected_callee, f.callee, sizeof(expected_callee)); + /* These strings are documented result-arena-owned. Poison while still + * live: borrowed output fails deterministically, without allocator luck. */ + memset((char *)f.one->qualified_name, 'X', strlen(f.one->qualified_name)); + memset(f.callee, 'Y', strlen(f.callee)); + } + bool copied = inputs && tc_resolved_foo(&first, expected_caller, expected_callee) && + tc_resolved_foo(&second, expected_caller, expected_callee) && + tc_resolved_foo(&single->resolved_calls, expected_caller, expected_callee); + tc_owner_close(&f); + cbm_arena_destroy(®istry_arena); + bool alive = copied && tc_resolved_foo(&first, expected_caller, expected_callee) && + tc_resolved_foo(&second, expected_caller, expected_callee) && + tc_resolved_foo(&single->resolved_calls, expected_caller, expected_callee); + cbm_arena_destroy(&first_arena); cbm_arena_destroy(&second_arena); cbm_free_result(single); + ASSERT_TRUE(inputs); ASSERT_TRUE(controls); ASSERT_TRUE(rejected); + ASSERT_TRUE(copied); ASSERT_TRUE(alive); + PASS(); +} + +TEST(conventions_cpp_digit_separator_does_not_hide_mapping) { + ASSERT_EQ(tc_init_status, 0); + cbm_test_declarations_t *config = tc_config( + "{\"language\":\"cpp\",\"role\":\"case\",\"define_macro\":\"CHECK\",\"name_args\":[0]}", NULL); + bool parsed = config != NULL; + CBMFileResult *good = config ? tc_extract("int n=1'000;\nCHECK(alpha) {}\n", + CBM_LANG_CPP, "src/digits.cpp", config) : NULL; + CBMFileResult *bad = config ? tc_extract("int n=1'000;\nCHECK(alpha);\n", + CBM_LANG_CPP, "src/digits.cpp", config) : NULL; + bool control = tc_ok(good) && tc_only_case(good, "CHECK_alpha"); + bool rejected = bad && bad->has_error && bad->test_declarations_status != CBM_TEST_EXTRACT_OK && + bad->error_msg && bad->error_msg[0] && bad->test_declaration_index == 0 && + bad->test_declaration_line == 2; + cbm_free_result(good); cbm_free_result(bad); cbm_test_declarations_free(config); + ASSERT_TRUE(parsed); ASSERT_TRUE(control); ASSERT_TRUE(rejected); + PASS(); +} + +TEST(conventions_define_multiline_comment_preserves_complete_audit) { + ASSERT_EQ(tc_init_status, 0); + cbm_test_declarations_t *config = tc_config(TC_CASE0, NULL); + bool parsed = config != NULL; + CBMFileResult *good = config ? tc_extract( + "#define N 1 /* ordinary\ncomment */\nCHECK(ok) {}\n", + CBM_LANG_C, "src/comments.c", config) : NULL; + CBMFileResult *bad = config ? tc_extract( + "#define N 1 /* ordinary\ncomment\nCHECK(ok) {}\n", + CBM_LANG_C, "src/comments.c", config) : NULL; + bool control = tc_ok(good) && tc_only_case(good, "CHECK_ok"); + bool rejected = bad && bad->has_error && bad->test_declarations_status != CBM_TEST_EXTRACT_OK && + bad->error_msg && bad->error_msg[0]; + cbm_free_result(good); cbm_free_result(bad); cbm_test_declarations_free(config); + ASSERT_TRUE(parsed); ASSERT_TRUE(control); ASSERT_TRUE(rejected); + PASS(); +} + +TEST(conventions_same_line_error_keeps_failing_declaration) { + ASSERT_EQ(tc_init_status, 0); + cbm_test_declarations_t *config = tc_config( + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"B\",\"name_args\":[0]}," + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"A\",\"name_args\":[0]}", NULL); + bool parsed = config != NULL; + CBMFileResult *good = config ? tc_extract("A(yes){} B(ok){}\n", CBM_LANG_C, + "src/same_line.c", config) : NULL; + CBMFileResult *bad = config ? tc_extract("A(){} B(ok){}\n", CBM_LANG_C, + "src/same_line.c", config) : NULL; + bool control = tc_ok(good) && tc_role(good, "A_yes", CBM_TEST_ROLE_CASE) && + tc_role(good, "B_ok", CBM_TEST_ROLE_CASE); + bool rejected = tc_issue(bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 1, 1); + cbm_free_result(good); cbm_free_result(bad); cbm_test_declarations_free(config); + ASSERT_TRUE(parsed); ASSERT_TRUE(control); ASSERT_TRUE(rejected); + PASS(); +} + +SUITE(test_conventions) { + tc_init_status = cbm_init(); + RUN_TEST(conventions_null_absent_and_defaults_preserve_legacy); + RUN_TEST(conventions_names_roles_arguments_and_body_owners); + RUN_TEST(conventions_duplicates_coalesce_and_conflicts_fail_typed); + RUN_TEST(conventions_invalid_invocations_have_exact_typed_locations); + RUN_TEST(conventions_unsupported_consumed_descriptors_fail_preflight); + RUN_TEST(conventions_presets_and_languages_are_isolated); + RUN_TEST(conventions_snapshot_and_result_strings_are_independently_owned); + RUN_TEST(conventions_roles_and_errors_survive_compaction_and_spill); + RUN_TEST(conventions_only_raw_invocations_receive_configured_roles); + RUN_TEST(conventions_cached_body_tree_cannot_drop_calls); + RUN_TEST(conventions_root_body_tree_cannot_drop_calls); + RUN_TEST(conventions_owner_identity_and_output_lifetime); + RUN_TEST(conventions_cpp_digit_separator_does_not_hide_mapping); + RUN_TEST(conventions_define_multiline_comment_preserves_complete_audit); + RUN_TEST(conventions_same_line_error_keeps_failing_declaration); + cbm_work_arena_release(); +} diff --git a/tests/test_test_impact.c b/tests/test_test_impact.c new file mode 100644 index 000000000..596621f42 --- /dev/null +++ b/tests/test_test_impact.c @@ -0,0 +1,3408 @@ +/* + * test_test_impact.c — the test model of the test-impact engine + * (src/mcp/test_impact.c). + * + * The model says which test belongs to which suite, and the selection names + * tests by exactly that. A wrong entry is a red run (a test named that its + * suite never runs) or, worse, a test that should have run and was not named. + */ +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include +#include + +typedef struct { + const char *file; + const char *text; +} tm_source_t; + +static cbm_test_model_t *model_of(const tm_source_t *sources, int count) { + cbm_test_model_t *m = cbm_test_model_new(cbm_test_conventions_cbm()); + if (!m) { + return NULL; + } + for (int i = 0; i < count; i++) { + if (!cbm_test_model_add_source(m, sources[i].file, sources[i].text, + strlen(sources[i].text))) { + cbm_test_model_free(m); + return NULL; + } + } + if (!cbm_test_model_finish(m)) { + cbm_test_model_free(m); + return NULL; + } + return m; +} + +static const cbm_test_case_t *case_named(const cbm_test_model_t *m, const char *name) { + int n = 0; + const cbm_test_case_t *cases = cbm_test_model_cases(m, &n); + for (int i = 0; i < n; i++) { + if (strcmp(cases[i].name, name) == 0) { + return &cases[i]; + } + } + return NULL; +} + +static const cbm_test_registration_t *registration_of(const cbm_test_model_t *m, const char *test) { + int n = 0; + const cbm_test_registration_t *regs = cbm_test_model_registrations(m, &n); + for (int i = 0; i < n; i++) { + if (strcmp(regs[i].test, test) == 0) { + return ®s[i]; + } + } + return NULL; +} + +static int count_cases(const cbm_test_model_t *m) { + int n = 0; + (void)cbm_test_model_cases(m, &n); + return n; +} + +static int count_suites(const cbm_test_model_t *m) { + int n = 0; + (void)cbm_test_model_suites(m, &n); + return n; +} + +static int count_registrations(const cbm_test_model_t *m) { + int n = 0; + (void)cbm_test_model_registrations(m, &n); + return n; +} + +TEST(test_model_reads_cases_suites_and_registrations) { + static const tm_source_t src[] = {{"tests/test_a.c", "#include \"test_framework.h\"\n" /* 1 */ + "\n" /* 2 */ + "static int helper(void) {\n" /* 3 */ + " return 1;\n" /* 4 */ + "}\n" /* 5 */ + "\n" /* 6 */ + "TEST(alpha) {\n" /* 7 */ + " ASSERT_EQ(helper(), 1);\n" /* 8 */ + " PASS();\n" /* 9 */ + "}\n" /* 10 */ + "\n" /* 11 */ + "TEST(beta)\n" /* 12 */ + "{\n" /* 13 */ + " if (helper()) {\n" /* 14 */ + " PASS();\n" /* 15 */ + " }\n" /* 16 */ + " FAIL(\"no\");\n" /* 17 */ + "}\n" /* 18 */ + "\n" /* 19 */ + "SUITE(a) {\n" /* 20 */ + " RUN_TEST(alpha);\n" /* 21 */ + " RUN_TEST(beta);\n" /* 22 */ + "}\n"}}; /* 23 */ + cbm_test_model_t *m = model_of(src, 1); + ASSERT_NOT_NULL(m); + ASSERT_EQ(count_cases(m), 2); + ASSERT_EQ(count_suites(m), 1); + ASSERT_EQ(count_registrations(m), 2); + + const cbm_test_case_t *alpha = case_named(m, "alpha"); + const cbm_test_case_t *beta = case_named(m, "beta"); + ASSERT_NOT_NULL(alpha); + ASSERT_NOT_NULL(beta); + ASSERT_STR_EQ(alpha->file, "tests/test_a.c"); + ASSERT_EQ(alpha->start_line, 7); + ASSERT_EQ(alpha->end_line, 10); + ASSERT_FALSE(alpha->conditional); + /* The body's own braces do not end the case. */ + ASSERT_EQ(beta->start_line, 12); + ASSERT_EQ(beta->end_line, 18); + + int n = 0; + const cbm_test_suite_t *suites = cbm_test_model_suites(m, &n); + ASSERT_STR_EQ(suites[0].name, "a"); + ASSERT_STR_EQ(suites[0].file, "tests/test_a.c"); + ASSERT_EQ(suites[0].start_line, 20); + ASSERT_EQ(suites[0].end_line, 23); + + const cbm_test_registration_t *regs = cbm_test_model_registrations(m, &n); + ASSERT_STR_EQ(regs[0].test, "alpha"); + ASSERT_STR_EQ(regs[0].suite, "a"); + ASSERT_EQ(regs[0].line, 21); + ASSERT_TRUE(regs[0].resolved); + ASSERT_FALSE(regs[0].conditional); + ASSERT_STR_EQ(regs[1].test, "beta"); + ASSERT_EQ(regs[1].line, 22); + cbm_test_model_free(m); + PASS(); +} + +/* What a comment or a string says is not code: a commented-out registration + * names a test its suite does not run. */ +TEST(test_model_ignores_comments_and_literals) { + static const tm_source_t src[] = { + {"tests/test_b.c", + "/* TEST(in_block_comment) { } */\n" + "// TEST(in_line_comment) {\n" + "static const char *text = \"TEST(in_string) { RUN_TEST(in_string); }\";\n" + "static const char quote = '\"';\n" + "static const char *escaped = \"a \\\" TEST(after_escape) {\";\n" + "TEST(real) {\n" + " const char *s = \"}\"; /* a brace that closes nothing */\n" + " char c = '}';\n" + " PASS();\n" + "}\n" + "SUITE(b) {\n" + " RUN_TEST(real);\n" + " // RUN_TEST(commented_out);\n" + " /* RUN_TEST(blocked_out); */\n" + "}\n"}}; + cbm_test_model_t *m = model_of(src, 1); + ASSERT_NOT_NULL(m); + ASSERT_EQ(count_cases(m), 1); + ASSERT_EQ(count_registrations(m), 1); + const cbm_test_case_t *real = case_named(m, "real"); + ASSERT_NOT_NULL(real); + ASSERT_EQ(real->start_line, 6); + ASSERT_EQ(real->end_line, 10); + ASSERT_NOT_NULL(registration_of(m, "real")); + ASSERT_NULL(registration_of(m, "commented_out")); + ASSERT_NULL(registration_of(m, "blocked_out")); + ASSERT_NULL(registration_of(m, "in_string")); + cbm_test_model_free(m); + PASS(); +} + +/* `void suite_x(void) { ... }` is what SUITE(x) expands to, and some files + * write it out. A prototype of it is not a suite, and a registration belongs + * to the suite whose body holds it. */ +TEST(test_model_reads_suites_written_as_functions) { + static const tm_source_t src[] = {{"tests/test_c.c", "void suite_c_two(void);\n" /* 1 */ + "TEST(one) {\n" /* 2 */ + " PASS();\n" /* 3 */ + "}\n" /* 4 */ + "TEST(two) {\n" /* 5 */ + " PASS();\n" /* 6 */ + "}\n" /* 7 */ + "void suite_c_one(void) {\n" /* 8 */ + " RUN_TEST(one);\n" /* 9 */ + "}\n" /* 10 */ + /* after a suite, and in no suite */ + "static void not_a_suite(void) {\n" + " RUN_TEST(one);\n" + "}\n" + "void suite_c_two( void )\n" /* 14 */ + "{\n" /* 15 */ + " RUN_TEST(two);\n" /* 16 */ + "}\n"}}; + cbm_test_model_t *m = model_of(src, 1); + ASSERT_NOT_NULL(m); + int n = 0; + const cbm_test_suite_t *suites = cbm_test_model_suites(m, &n); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(suites[0].name, "c_one"); + ASSERT_EQ(suites[0].start_line, 8); + ASSERT_EQ(suites[0].end_line, 10); + ASSERT_STR_EQ(suites[1].name, "c_two"); + ASSERT_EQ(suites[1].start_line, 14); + const cbm_test_registration_t *regs = cbm_test_model_registrations(m, &n); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(regs[0].suite, "c_one"); + ASSERT_STR_EQ(regs[0].test, "one"); + ASSERT_EQ(regs[0].line, 9); + ASSERT_STR_EQ(regs[1].suite, "c_two"); + ASSERT_STR_EQ(regs[1].test, "two"); + ASSERT_EQ(regs[1].line, 16); + cbm_test_model_free(m); + PASS(); +} + +/* A registration under #if is one that some builds do not have. The selection + * must know, because the runner fails a named test its suite never ran. An + * include guard is not such a condition. */ +TEST(test_model_marks_what_is_under_a_condition) { + static const tm_source_t src[] = {{"tests/test_d.c", "#ifndef TEST_D_GUARD\n" + "#define TEST_D_GUARD\n" + "TEST(everywhere) {\n" + " PASS();\n" + "}\n" + "#ifdef _WIN32\n" + "TEST(windows_only) {\n" + " PASS();\n" + "}\n" + "#else\n" + "TEST(posix_only) {\n" + " PASS();\n" + "}\n" + "#endif\n" + "TEST(after) {\n" + " PASS();\n" + "}\n" + "SUITE(d) {\n" + " RUN_TEST(everywhere);\n" + "#ifdef _WIN32\n" + " RUN_TEST(windows_only);\n" + "#else\n" + " RUN_TEST(posix_only);\n" + "#endif\n" + "#if defined(SEAMS) && \\\n" + " SEAMS\n" + " RUN_TEST(after);\n" + "#endif\n" + "}\n" + "#endif\n"}}; + cbm_test_model_t *m = model_of(src, 1); + ASSERT_NOT_NULL(m); + ASSERT_EQ(count_cases(m), 4); + ASSERT_EQ(count_registrations(m), 4); + ASSERT_FALSE(case_named(m, "everywhere")->conditional); + ASSERT_TRUE(case_named(m, "windows_only")->conditional); + ASSERT_TRUE(case_named(m, "posix_only")->conditional); + ASSERT_FALSE(case_named(m, "after")->conditional); + ASSERT_FALSE(registration_of(m, "everywhere")->conditional); + ASSERT_TRUE(registration_of(m, "windows_only")->conditional); + ASSERT_TRUE(registration_of(m, "posix_only")->conditional); + /* The case is unconditional, its registration is not. */ + ASSERT_TRUE(registration_of(m, "after")->conditional); + cbm_test_model_free(m); + PASS(); +} + +/* Both branches of an #if may open the same block. Counting both braces would + * leave the scanner one level deep for the rest of the file, and every later + * test and suite would be missed. */ +TEST(test_model_keeps_brace_depth_across_else_branches) { + static const tm_source_t src[] = {{"tests/test_e.c", "TEST(branchy) {\n" /* 1 */ + "#ifdef _WIN32\n" /* 2 */ + " if (win()) {\n" /* 3 */ + "#elif defined(X)\n" /* 4 */ + " if (x()) {\n" /* 5 */ + "#else\n" /* 6 */ + " if (posix()) {\n" /* 7 */ + "#endif\n" /* 8 */ + " PASS();\n" /* 9 */ + " }\n" /* 10 */ + " FAIL(\"no\");\n" /* 11 */ + "}\n" /* 12 */ + "TEST(next) {\n" /* 13 */ + " PASS();\n" /* 14 */ + "}\n" /* 15 */ + "SUITE(e) {\n" /* 16 */ + " RUN_TEST(branchy);\n" + " RUN_TEST(next);\n" + "}\n"}}; + cbm_test_model_t *m = model_of(src, 1); + ASSERT_NOT_NULL(m); + ASSERT_EQ(count_cases(m), 2); + ASSERT_EQ(case_named(m, "branchy")->end_line, 12); + ASSERT_NOT_NULL(case_named(m, "next")); + ASSERT_EQ(case_named(m, "next")->start_line, 13); + ASSERT_EQ(count_suites(m), 1); + ASSERT_EQ(count_registrations(m), 2); + cbm_test_model_free(m); + PASS(); +} + +/* A registration resolves to the TEST of that name in the SAME file. A test + * a macro generates has no TEST of its own, and a same-named test in another + * file is a different test. */ +TEST(test_model_resolves_registrations_in_their_own_file) { + static const tm_source_t src[] = {{"tests/test_f.c", "#define GEN(n) static int n(void)\n" + "GEN(generated) { return 0; }\n" + "TEST(written) {\n" + " PASS();\n" + "}\n" + "SUITE(f) {\n" + " RUN_TEST(written);\n" + " RUN_TEST(generated);\n" + " RUN_TEST(elsewhere);\n" + "}\n"}, + {"tests/test_g.c", "TEST(elsewhere) {\n" + " PASS();\n" + "}\n" + "TEST(unregistered) {\n" + " PASS();\n" + "}\n" + "SUITE(g) {\n" + " RUN_TEST(elsewhere);\n" + "}\n"}}; + cbm_test_model_t *m = model_of(src, 2); + ASSERT_NOT_NULL(m); + int n = 0; + const cbm_test_registration_t *regs = cbm_test_model_registrations(m, &n); + ASSERT_EQ(n, 4); + ASSERT_STR_EQ(regs[0].test, "written"); + ASSERT_TRUE(regs[0].resolved); + ASSERT_STR_EQ(regs[1].test, "generated"); + ASSERT_FALSE(regs[1].resolved); + ASSERT_STR_EQ(regs[2].test, "elsewhere"); + ASSERT_STR_EQ(regs[2].file, "tests/test_f.c"); + ASSERT_FALSE(regs[2].resolved); + ASSERT_STR_EQ(regs[3].test, "elsewhere"); + ASSERT_STR_EQ(regs[3].file, "tests/test_g.c"); + ASSERT_TRUE(regs[3].resolved); + /* A macro's own definition line registers nothing. */ + ASSERT_EQ(count_cases(m), 3); + cbm_test_model_free(m); + PASS(); +} + +/* A file may register tests through a macro of its own. The names are then + * out of the scanner's reach, and the suite must say so: it can only run + * whole. The macro's body itself registers nothing. */ +TEST(test_model_marks_suites_that_register_through_a_macro) { + static const tm_source_t src[] = {{"tests/test_m.c", + "#define RUN_ROW(test_name_, lang_) RUN_TEST(test_name_);\n" + "#define ROWS(X) X(row_one, c) X(row_two, go)\n" + "TEST(plain) {\n" + " PASS();\n" + "}\n" + "SUITE(m) {\n" + " RUN_TEST(plain);\n" + " ROWS(RUN_ROW)\n" + "}\n"}, + {"tests/test_n.c", "#define UNRELATED(x) ((x) + 1)\n" + "TEST(only) {\n" + " PASS();\n" + "}\n" + "SUITE(n) {\n" + " RUN_TEST(only);\n" + "}\n"}}; + cbm_test_model_t *m = model_of(src, 2); + ASSERT_NOT_NULL(m); + int n = 0; + const cbm_test_suite_t *suites = cbm_test_model_suites(m, &n); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(suites[0].name, "m"); + ASSERT_TRUE(suites[0].macro_registrations); + ASSERT_STR_EQ(suites[1].name, "n"); + ASSERT_FALSE(suites[1].macro_registrations); + ASSERT_EQ(count_registrations(m), 2); + ASSERT_NULL(registration_of(m, "test_name_")); + cbm_test_model_free(m); + PASS(); +} + +/* The runner's list of suites is the runnable universe. The macro's own + * #define names no suite. */ +TEST(test_model_reads_the_runner_suites) { + static const tm_source_t src[] = { + {"tests/test_main.c", "#define RUN_SELECTED_SUITE(name) run_suite(#name, suite_##name)\n" + "#define RUN_SELECTED_SUITE_PERF(name) \\\n" + " run_perf_suite(#name, suite_##name)\n" + "int main(void) {\n" + " RUN_SELECTED_SUITE(zeta);\n" + " RUN_SELECTED_SUITE(alpha);\n" + " RUN_SELECTED_SUITE_PERF(bench);\n" + " RUN_SELECTED_SUITE(alpha);\n" + " return 0;\n" + "}\n"}}; + cbm_test_model_t *m = model_of(src, 1); + ASSERT_NOT_NULL(m); + int n = 0; + const cbm_test_runner_suite_t *suites = cbm_test_model_runner_suites(m, &n); + ASSERT_EQ(n, 3); + ASSERT_STR_EQ(suites[0].name, "alpha"); + ASSERT_FALSE(suites[0].perf); + ASSERT_STR_EQ(suites[1].name, "bench"); + ASSERT_TRUE(suites[1].perf); + ASSERT_STR_EQ(suites[2].name, "zeta"); + ASSERT_FALSE(suites[2].perf); + cbm_test_model_free(m); + PASS(); +} + +/* The model is a function of the sources, not of the order they were read + * in: a directory listing differs between file systems. */ +TEST(test_model_order_is_independent_of_input_order) { + static const tm_source_t forward[] = {{"tests/test_a.c", "TEST(a1) {\n" + " PASS();\n" + "}\n" + "SUITE(a) {\n" + " RUN_TEST(a1);\n" + "}\n"}, + {"tests/test_b.c", "TEST(b1) {\n" + " PASS();\n" + "}\n" + "SUITE(b) {\n" + " RUN_TEST(b1);\n" + "}\n"}}; + const tm_source_t backward[] = {forward[1], forward[0]}; + cbm_test_model_t *m1 = model_of(forward, 2); + cbm_test_model_t *m2 = model_of(backward, 2); + ASSERT_NOT_NULL(m1); + ASSERT_NOT_NULL(m2); + int n1 = 0; + int n2 = 0; + const cbm_test_case_t *c1 = cbm_test_model_cases(m1, &n1); + const cbm_test_case_t *c2 = cbm_test_model_cases(m2, &n2); + ASSERT_EQ(n1, 2); + ASSERT_EQ(n2, 2); + for (int i = 0; i < n1; i++) { + ASSERT_STR_EQ(c1[i].name, c2[i].name); + ASSERT_STR_EQ(c1[i].file, c2[i].file); + } + ASSERT_STR_EQ(c1[0].name, "a1"); + const cbm_test_registration_t *r1 = cbm_test_model_registrations(m1, &n1); + const cbm_test_registration_t *r2 = cbm_test_model_registrations(m2, &n2); + ASSERT_EQ(n1, n2); + for (int i = 0; i < n1; i++) { + ASSERT_STR_EQ(r1[i].test, r2[i].test); + ASSERT_STR_EQ(r1[i].suite, r2[i].suite); + ASSERT_TRUE(r1[i].resolved && r2[i].resolved); + } + cbm_test_model_free(m1); + cbm_test_model_free(m2); + PASS(); +} + +/* Text that ends in the middle of a comment, a literal or a body must end the + * scan, not run past the buffer. The text is deliberately not NUL-terminated + * where the scanner would look. */ +TEST(test_model_survives_truncated_sources) { + static const char *const shapes[] = { + "TEST(open) {\n PASS();\n", + "TEST(", + "TEST(x", + "TEST(x)", + "SUITE(s) { RUN_TEST(", + "/* never closed", + "const char *s = \"never closed", + "char c = '", + "int half = 1 /", + "const char *s = \"ends in a backslash\\", + "#define CONTINUED \\", + "/* ends in a star *", + "#if", + "#ifdef A\nTEST(a) {\n", + "void suite_", + "void suite_x(void", + "}}}}\nTEST(after_stray_braces) {\n}\n", + "#endif\n#endif\n#else\nTEST(after_stray_directives) {\n}\n", + }; + for (size_t i = 0; i < sizeof(shapes) / sizeof(shapes[0]); i++) { + size_t len = strlen(shapes[i]); + char *exact = malloc(len ? len : 1); /* no terminator: ASan sees an over-read */ + ASSERT_NOT_NULL(exact); + memcpy(exact, shapes[i], len); + cbm_test_model_t *m = cbm_test_model_new(cbm_test_conventions_cbm()); + ASSERT_NOT_NULL(m); + ASSERT_TRUE(cbm_test_model_add_source(m, "tests/test_t.c", exact, len)); + ASSERT_TRUE(cbm_test_model_finish(m)); + free(exact); + if (strstr(shapes[i], "after_stray_braces")) { + ASSERT_NOT_NULL(case_named(m, "after_stray_braces")); + } + if (strstr(shapes[i], "after_stray_directives")) { + ASSERT_NOT_NULL(case_named(m, "after_stray_directives")); + } + cbm_test_model_free(m); + } + PASS(); +} + +/* ── Part 2: the diff reader ─────────────────────────────────────── */ + +static cbm_diff_t *diff_of(const char *text) { + return cbm_diff_parse(text, strlen(text)); +} + +static const cbm_diff_file_t *diff_file(const cbm_diff_t *d, const char *path) { + int n = 0; + const cbm_diff_file_t *files = cbm_diff_files(d, &n); + for (int i = 0; i < n; i++) { + if (strcmp(files[i].path, path) == 0) { + return &files[i]; + } + } + return NULL; +} + +static int diff_file_count(const cbm_diff_t *d) { + int n = 0; + (void)cbm_diff_files(d, &n); + return n; +} + +TEST(test_diff_reads_files_and_hunks) { + cbm_diff_t *d = diff_of("diff --git a/src/b.c b/src/b.c\n" + "index 1111111..2222222 100644\n" + "--- a/src/b.c\n" + "+++ b/src/b.c\n" + "@@ -3 +3 @@ static int helper(void)\n" + "- return 1;\n" + "+ return 2;\n" + "@@ -10,2 +10,3 @@\n" + "-old a\n" + "-old b\n" + "+new a\n" + "+new b\n" + "+new c\n" + "diff --git a/src/a.c b/src/a.c\n" + "index 3333333..4444444 100644\n" + "--- a/src/a.c\n" + "+++ b/src/a.c\n" + "@@ -7,0 +8,2 @@\n" + "+added one\n" + "+added two\n"); + ASSERT_NOT_NULL(d); + ASSERT_TRUE(cbm_diff_complete(d)); + int n = 0; + const cbm_diff_file_t *files = cbm_diff_files(d, &n); + ASSERT_EQ(n, 2); + /* Ordered by path, whatever order the text had. */ + ASSERT_STR_EQ(files[0].path, "src/a.c"); + ASSERT_STR_EQ(files[1].path, "src/b.c"); + ASSERT_FALSE(files[1].created || files[1].deleted || files[1].binary); + + ASSERT_EQ(files[1].hunk_count, 2); + const cbm_diff_hunk_t *h = files[1].hunks; + ASSERT_EQ(h[0].start, 3); + ASSERT_EQ(h[0].count, 1); + ASSERT_EQ(h[0].removed_count, 1); + ASSERT_EQ(h[0].added_count, 1); + ASSERT_STR_EQ(h[0].removed[0], " return 1;"); + ASSERT_STR_EQ(h[0].added[0], " return 2;"); + ASSERT_EQ(h[1].start, 10); + ASSERT_EQ(h[1].count, 3); + ASSERT_EQ(h[1].removed_count, 2); + ASSERT_EQ(h[1].added_count, 3); + ASSERT_STR_EQ(h[1].removed[1], "old b"); + ASSERT_STR_EQ(h[1].added[2], "new c"); + + ASSERT_EQ(files[0].hunk_count, 1); + ASSERT_EQ(files[0].hunks[0].start, 8); + ASSERT_EQ(files[0].hunks[0].count, 2); + ASSERT_EQ(files[0].hunks[0].removed_count, 0); + ASSERT_STR_EQ(files[0].hunks[0].added[1], "added two"); + cbm_diff_free(d); + PASS(); +} + +/* A hunk that only removes lines has no line of its own on the new side: it + * names the line it follows. Created and deleted files say so. */ +TEST(test_diff_reads_removals_creations_and_deletions) { + cbm_diff_t *d = diff_of("diff --git a/gone.c b/gone.c\n" + "deleted file mode 100644\n" + "index 1111111..0000000\n" + "--- a/gone.c\n" + "+++ /dev/null\n" + "@@ -1,2 +0,0 @@\n" + "-int gone(void);\n" + "-int also_gone(void);\n" + "diff --git a/kept.c b/kept.c\n" + "index 1111111..2222222 100644\n" + "--- a/kept.c\n" + "+++ b/kept.c\n" + "@@ -5,2 +4,0 @@ int kept(void)\n" + "- removed();\n" + "- removed_too();\n" + "diff --git a/new.c b/new.c\n" + "new file mode 100644\n" + "index 0000000..3333333\n" + "--- /dev/null\n" + "+++ b/new.c\n" + "@@ -0,0 +1 @@\n" + "+int fresh(void);\n" + "diff --git a/empty.c b/empty.c\n" + "new file mode 100644\n" + "index 0000000..e69de29\n"); + ASSERT_NOT_NULL(d); + ASSERT_TRUE(cbm_diff_complete(d)); + ASSERT_EQ(diff_file_count(d), 4); + const cbm_diff_file_t *gone = diff_file(d, "gone.c"); + const cbm_diff_file_t *kept = diff_file(d, "kept.c"); + const cbm_diff_file_t *fresh = diff_file(d, "new.c"); + const cbm_diff_file_t *empty = diff_file(d, "empty.c"); + ASSERT_NOT_NULL(gone); + ASSERT_NOT_NULL(kept); + ASSERT_NOT_NULL(fresh); + ASSERT_NOT_NULL(empty); + ASSERT_TRUE(gone->deleted); + ASSERT_FALSE(gone->created); + ASSERT_EQ(gone->hunks[0].count, 0); + ASSERT_EQ(gone->hunks[0].removed_count, 2); + ASSERT_FALSE(kept->deleted || kept->created); + ASSERT_EQ(kept->hunk_count, 1); + ASSERT_EQ(kept->hunks[0].start, 4); + ASSERT_EQ(kept->hunks[0].count, 0); + ASSERT_EQ(kept->hunks[0].added_count, 0); + ASSERT_STR_EQ(kept->hunks[0].removed[0], " removed();"); + ASSERT_TRUE(fresh->created); + ASSERT_EQ(fresh->hunks[0].start, 1); + ASSERT_EQ(fresh->hunks[0].count, 1); + ASSERT_TRUE(empty->created); + ASSERT_EQ(empty->hunk_count, 0); + cbm_diff_free(d); + PASS(); +} + +/* The counts in the hunk header decide what a line is, not its look: removed + * and added text can read exactly like a file header, a hunk header or the + * start of the next file. */ +TEST(test_diff_content_that_looks_like_structure_stays_content) { + cbm_diff_t *d = diff_of("diff --git a/notes.txt b/notes.txt\n" + "index 1111111..2222222 100644\n" + "--- a/notes.txt\n" + "+++ b/notes.txt\n" + "@@ -1,2 +1,4 @@\n" + "--- a/looks/like/a/header\n" + "-diff --git a/x b/x\n" + "+++ b/looks/like/a/header\n" + "+@@ -1 +1 @@\n" + "+diff --git a/y b/y\n" + "+\n" + "@@ -9 +11 @@\n" + "-last\n" + "\\ No newline at end of file\n" + "+last line\n" + "\\ No newline at end of file\n"); + ASSERT_NOT_NULL(d); + ASSERT_TRUE(cbm_diff_complete(d)); + ASSERT_EQ(diff_file_count(d), 1); + const cbm_diff_file_t *f = diff_file(d, "notes.txt"); + ASSERT_NOT_NULL(f); + ASSERT_EQ(f->hunk_count, 2); + ASSERT_EQ(f->hunks[0].removed_count, 2); + ASSERT_EQ(f->hunks[0].added_count, 4); + ASSERT_STR_EQ(f->hunks[0].removed[0], "-- a/looks/like/a/header"); + ASSERT_STR_EQ(f->hunks[0].removed[1], "diff --git a/x b/x"); + ASSERT_STR_EQ(f->hunks[0].added[0], "++ b/looks/like/a/header"); + ASSERT_STR_EQ(f->hunks[0].added[1], "@@ -1 +1 @@"); + ASSERT_STR_EQ(f->hunks[0].added[3], ""); + /* The "no newline" note is neither an old nor a new line. */ + ASSERT_EQ(f->hunks[1].removed_count, 1); + ASSERT_EQ(f->hunks[1].added_count, 1); + ASSERT_STR_EQ(f->hunks[1].added[0], "last line"); + cbm_diff_free(d); + PASS(); +} + +TEST(test_diff_reads_binary_mode_only_and_awkward_paths) { + cbm_diff_t *d = diff_of("diff --git a/assets/logo.png b/assets/logo.png\n" + "index 1111111..2222222 100644\n" + "Binary files a/assets/logo.png and b/assets/logo.png differ\n" + "diff --git a/dir b/with space.c b/dir b/with space.c\n" + "index 1111111..2222222 100644\n" + "--- a/dir b/with space.c\n" + "+++ b/dir b/with space.c\n" + "@@ -1 +1 @@\n" + "-a\r\n" + "+b\r\n" + "diff --git a/scripts/run.sh b/scripts/run.sh\n" + "old mode 100644\n" + "new mode 100755\n"); + ASSERT_NOT_NULL(d); + ASSERT_TRUE(cbm_diff_complete(d)); + ASSERT_EQ(diff_file_count(d), 3); + const cbm_diff_file_t *logo = diff_file(d, "assets/logo.png"); + ASSERT_NOT_NULL(logo); + ASSERT_TRUE(logo->binary); + ASSERT_EQ(logo->hunk_count, 0); + /* " b/" inside a path does not split it. */ + const cbm_diff_file_t *spaced = diff_file(d, "dir b/with space.c"); + ASSERT_NOT_NULL(spaced); + ASSERT_FALSE(spaced->binary); + /* A carriage return is part of the line, as git printed it. */ + ASSERT_STR_EQ(spaced->hunks[0].added[0], "b\r"); + const cbm_diff_file_t *mode = diff_file(d, "scripts/run.sh"); + ASSERT_NOT_NULL(mode); + ASSERT_EQ(mode->hunk_count, 0); + cbm_diff_free(d); + + d = diff_of(""); + ASSERT_NOT_NULL(d); + ASSERT_TRUE(cbm_diff_complete(d)); + ASSERT_EQ(diff_file_count(d), 0); + cbm_diff_free(d); + PASS(); +} + +/* Whatever the reader cannot account for makes the diff incomplete: the + * caller then knows that what it holds is not the whole change. The files it + * did read are still there. */ +TEST(test_diff_says_when_it_did_not_read_everything) { + static const struct { + const char *why; + const char *text; + int files; + } cases[] = { + {"a quoted path", + "diff --git \"a/tab\\there.c\" \"b/tab\\there.c\"\n" + "index 1111111..2222222 100644\n" + "--- \"a/tab\\there.c\"\n" + "+++ \"b/tab\\there.c\"\n" + "@@ -1 +1 @@\n" + "-a\n" + "+b\n" + "diff --git a/ok.c b/ok.c\n" + "index 1111111..2222222 100644\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1 +1 @@\n" + "-a\n" + "+b\n", + 1}, + {"a hunk shorter than its header", + "diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1,2 +1,2 @@\n" + "-a\n" + "+b\n", + 1}, + {"text that ends inside a hunk", + "diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1,3 +1,3 @@\n" + "-a\n", + 1}, + {"a count the text cannot hold", + "diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1,900000000 +1,900000000 @@\n" + "-a\n" + "+b\n", + 1}, + {"a line before any file", + "warning: something git printed\n" + "diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1 +1 @@\n" + "-a\n" + "+b\n", + 1}, + {"a line after a hunk that is no hunk", + "diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1 +1 @@\n" + "-a\n" + "+b\n" + "+one line too many\n", + 1}, + {"an unknown header line", + "diff --git a/ok.c b/ok.c\n" + "similarity index 90%\n" + "--- a/ok.c\n" + "+++ b/ok.c\n", + 1}, + {"a renamed pair", + "diff --git a/old.c b/new.c\n" + "--- a/old.c\n" + "+++ b/new.c\n", + 0}, + {"a combined diff", + "diff --cc merged.c\n" + "index 1111111,2222222..3333333\n", + 0}, + {"a hunk header that is not one", + "diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -x +1 @@\n", + 1}, + {"a hunk header without its end", + "diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1,0 +1,0\n", + 1}, + {"text that ends between the old and the new lines", + "diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1 +1 @@\n" + "-a\n", + 1}, + {"added lines before the removed ones are done", + "diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1,2 +1 @@\n" + "-a\n" + "+b\n" + "-c\n", + 1}, + {"only a quoted header", "diff --git \"a/tab\\there.c\" \"b/tab\\there.c\"\n", 0}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + size_t len = strlen(cases[i].text); + char *exact = malloc(len ? len : 1); /* no terminator: ASan sees an over-read */ + ASSERT_NOT_NULL(exact); + memcpy(exact, cases[i].text, len); + cbm_diff_t *d = cbm_diff_parse(exact, len); + free(exact); + ASSERT_NOT_NULL(d); + if (cbm_diff_complete(d) || diff_file_count(d) != cases[i].files) { + printf(" case \"%s\": complete=%d files=%d\n", cases[i].why, cbm_diff_complete(d), + diff_file_count(d)); + } + ASSERT_FALSE(cbm_diff_complete(d)); + ASSERT_EQ(diff_file_count(d), cases[i].files); + cbm_diff_free(d); + } + PASS(); +} + +/* A tiny, truncated diff must not reserve memory proportional to a claimed + * line count. Check retained allocator bytes, not RSS or whether a large + * virtual allocation happens to succeed on this host. */ +TEST(test_diff_rejects_impossible_counts_without_large_allocations) { + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + cbm_diff_t *d = diff_of("diff --git a/ok.c b/ok.c\n" + "--- a/ok.c\n" + "+++ b/ok.c\n" + "@@ -1,1000000 +1,1000000 @@\n" + "-a\n" + "+b\n"); + ASSERT_NOT_NULL(d); + bool complete = cbm_diff_complete(d); + size_t retained = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + cbm_diff_free(d); + ASSERT_FALSE(complete); + ASSERT_TRUE(retained >= before); + /* Generous room for parser bookkeeping and allocator rounding; allocating + * the two claimed pointer arrays would retain at least 8 MiB on 32-bit. */ + ASSERT_TRUE(retained - before < 1024 * 1024); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +typedef struct { + char text[4096]; + size_t length; + int hunks; + int context_lines; + bool overflow; +} diff_git_output_t; + +static void diff_git_capture(const char *line, void *ud) { + diff_git_output_t *output = ud; + size_t length = strlen(line); + if (length + 2 > sizeof(output->text) - output->length) { + output->overflow = true; + return; + } + memcpy(output->text + output->length, line, length); + output->length += length; + output->text[output->length++] = '\n'; + output->text[output->length] = '\0'; + output->hunks += strncmp(line, "@@ ", 3) == 0; + output->context_lines += line[0] == ' '; +} + +/* Split the production flags (literal, space-separated arguments) so this + * exercises the same pins without a shell or a second copy of their values. */ +static bool diff_git_run(const char *root, const char *config, diff_git_output_t *output) { + char pins[] = CBM_TEST_IMPACT_GIT_PINS; + char flags[] = CBM_TEST_IMPACT_DIFF_FLAGS; + const char *git = "git"; +#ifdef _WIN32 + git = cbm_find_cli("git", cbm_get_home_dir()); + if (!git) { + return false; + } +#endif + const char *argv[48] = {git, "-C", root, "-c", config}; + size_t count = 5; + char *groups[] = {pins, flags}; + for (size_t group = 0; group < 2; group++) { + if (group == 1) { + argv[count++] = "diff"; + } + char *part = groups[group]; + while (*part) { + while (*part == ' ') { + part++; + } + if (!*part) { + break; + } + if (count + 6 >= sizeof(argv) / sizeof(argv[0])) { + return false; + } + argv[count++] = part; + while (*part && *part != ' ') { + part++; + } + if (*part) { + *part++ = '\0'; + } + } + } + argv[count++] = "--no-index"; + argv[count++] = "--"; + argv[count++] = "before.c"; + argv[count++] = "after.c"; + argv[count] = NULL; + cbm_proc_opts_t options = { + .bin = git, + .argv = argv, + .log_file = TH_PATH(root, "diff.log"), + .on_log_line = diff_git_capture, + .log_ud = output, + .quiet_timeout_ms = 10000, + .delete_log_on_exit = true, + .strip_git_repo_env = true, + }; + cbm_proc_result_t result = {0}; + return cbm_subprocess_run(&options, &result) == 0 && result.outcome == CBM_PROC_EXIT_NONZERO && + result.exit_code == 1 && !output->overflow; +} + +TEST(test_diff_git_flags_ignore_inter_hunk_config) { + char *root = th_mktempdir("cbm-diff-pins"); + ASSERT_NOT_NULL(root); + bool written = th_write_file(TH_PATH(root, "before.c"), + "old first\nkeep one\nkeep two\nkeep three\nold last\n") == 0 && + th_write_file(TH_PATH(root, "after.c"), + "new first\nkeep one\nkeep two\nkeep three\nnew last\n") == 0; + diff_git_output_t baseline = {0}; + diff_git_output_t hostile = {0}; + bool baseline_ok = written && diff_git_run(root, "diff.interHunkContext=0", &baseline); + bool hostile_ok = written && diff_git_run(root, "diff.interHunkContext=999", &hostile); + int cleanup = th_rmtree(root); + ASSERT_TRUE(written); + ASSERT_TRUE(baseline_ok); + ASSERT_TRUE(hostile_ok); + ASSERT_EQ(cleanup, 0); + ASSERT_EQ(baseline.hunks, 2); + ASSERT_EQ(baseline.context_lines, 0); + ASSERT_EQ(hostile.hunks, 2); + ASSERT_EQ(hostile.context_lines, 0); + ASSERT_STR_EQ(hostile.text, baseline.text); + PASS(); +} + +static cbm_coverage_map_t *coverage_of(const char *functions, const char *tests) { + return cbm_coverage_map_parse(functions, strlen(functions), tests, strlen(tests)); +} + +TEST(test_coverage_map_reads_sources_setup_and_incomplete_tests) { + cbm_coverage_map_t *map = coverage_of( + "0\tsrc/a.c\twork\n1\tsrc/b.c\twork\n2\t\texternal\n3\tinclude/shared.h\tinline_fn\n", + "z:other\tcomplete\t\t1\n" + "a:t:parameter\tcomplete\t\t0 2 3\n" + "a:missing\tincomplete\tno parent profile\t\n" + "z:*\tcomplete\t\t\n" + "a:*\tcomplete\t\t0\n" + "a:empty\tcomplete\t\t\n"); + ASSERT_NOT_NULL(map); + int count = 0; + const cbm_coverage_function_t *functions = cbm_coverage_map_functions(map, &count); + ASSERT_EQ(count, 4); + ASSERT_STR_EQ(functions[3].file, "include/shared.h"); + const cbm_coverage_function_t *function = + cbm_coverage_map_find_function(map, "src/b.c", "work"); + ASSERT_NOT_NULL(function); + ASSERT_EQ(function->id, 1); + function = cbm_coverage_map_find_function(map, "", "external"); + ASSERT_NOT_NULL(function); + ASSERT_EQ(function->id, 2); + ASSERT_TRUE(cbm_coverage_map_find_function(map, "absent.c", "work") == NULL); + const cbm_coverage_test_t *rows = cbm_coverage_map_tests(map, &count); + ASSERT_EQ(count, 6); + ASSERT_STR_EQ(rows[0].suite, "a"); + ASSERT_STR_EQ(rows[0].name, "*"); + const cbm_coverage_test_t *row = cbm_coverage_map_find_test(map, "a", "t:parameter"); + ASSERT_NOT_NULL(row); + ASSERT_TRUE(row->complete); + ASSERT_EQ(row->function_count, 3); + int changed[] = {1, 3}; + ASSERT_TRUE(cbm_coverage_test_intersects(row, changed, 2)); + ASSERT_FALSE(cbm_coverage_test_intersects(row, changed, 1)); + int unsorted[] = {9, 0, 3}; + ASSERT_TRUE(cbm_coverage_test_intersects(row, unsorted, 3)); + row = cbm_coverage_map_find_test(map, "a", "missing"); + ASSERT_NOT_NULL(row); + ASSERT_FALSE(row->complete); + ASSERT_STR_EQ(row->reason, "no parent profile"); + ASSERT_EQ(row->function_count, 0); + ASSERT_TRUE(cbm_coverage_map_find_test(map, "a", "unknown") == NULL); + cbm_coverage_map_free(map); + PASS(); +} + +TEST(test_coverage_map_reads_exact_lengths_and_crlf) { + const char functions[] = {'0', '\t', 'x', '\r', 'y', '\t', 'f', '\r', '\n'}; + const char tests[] = {'s', ':', '*', '\t', 'i', 'n', 'c', 'o', 'm', 'p', + 'l', 'e', 't', 'e', '\t', 'x', '\t', '0', '\r', '\n'}; + cbm_coverage_map_t *map = + cbm_coverage_map_parse(functions, sizeof(functions), tests, sizeof(tests)); + ASSERT_NOT_NULL(map); + ASSERT_NOT_NULL(cbm_coverage_map_find_function(map, "x\ry", "f")); + const cbm_coverage_test_t *row = cbm_coverage_map_find_test(map, "s", "*"); + ASSERT_NOT_NULL(row); + ASSERT_FALSE(row->complete); + ASSERT_STR_EQ(row->reason, "x"); + int id = 0; + ASSERT_TRUE(cbm_coverage_test_intersects(row, &id, 1)); + cbm_coverage_map_free(map); + PASS(); +} + +TEST(test_coverage_map_rejects_inconsistent_artifacts) { + const char *functions = "0\tsrc/a.c\tf\n1\tsrc/b.c\tg\n"; + const char *valid = "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t1\n"; + const char *bad_functions[] = { + "1\tsrc/a.c\tf\n2\tsrc/b.c\tg\n", /* first ID missing */ + "0\tsrc/a.c\tf\n0\tsrc/b.c\tg\n", /* duplicate ID */ + "0\tsrc/a.c\tf\n2\tsrc/b.c\tg\n", /* sparse IDs */ + "0\tsrc/a.c\tf\n1\tsrc/a.c\tf\n", /* duplicate source/name */ + "0\tsrc/a.c\t\n1\tsrc/b.c\tg\n", /* empty name */ + "4294967296\tsrc/a.c\tf\n1\tsrc/b.c\tg\n", /* integer overflow */ + "0\tsrc/a.c\tf", /* truncated record */ + "0\tsrc/a.c\tf\textra\n1\tsrc/b.c\tg\n", /* extra field */ + }; + for (size_t i = 0; i < sizeof(bad_functions) / sizeof(bad_functions[0]); i++) { + cbm_coverage_map_t *map = coverage_of(bad_functions[i], valid); + bool rejected = map == NULL; + cbm_coverage_map_free(map); + ASSERT_TRUE(rejected); + } + const char *bad_tests[] = { + "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t2\n", /* unknown function */ + "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t1 1\n", /* duplicate ID */ + "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t1 0\n", /* reversed IDs */ + "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t-1\n", + "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t999999999999999999999\n", + "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t1 \n", + "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t 1\n", + "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t1x\n", + "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t1\ns:t\tcomplete\t\t1\n", + "s:*\tunknown\t\t0 1\n", + "s:*\tcomplete\tproblem\t0 1\n", + "s:*\tincomplete\t\t0 1\n", + "s\tcomplete\t\t0 1\n", /* no suite separator */ + ":*\tcomplete\t\t0 1\n", + "s:*\tcomplete\t\t0\ns:\tcomplete\t\t1\n", + "s:t\tcomplete\t\t0 1\n", /* setup omitted */ + "s:*\tcomplete\t\t0\n", /* orphan function row */ + "s:*\tcomplete\t\t0 1\ns:t\tcomplete\t\t", /* truncated final record */ + "s:*\tcomplete\t\t0 1\textra\n", + }; + for (size_t i = 0; i < sizeof(bad_tests) / sizeof(bad_tests[0]); i++) { + cbm_coverage_map_t *map = coverage_of(functions, bad_tests[i]); + bool rejected = map == NULL; + cbm_coverage_map_free(map); + if (!rejected) { + printf(" malformed coverage case %zu was accepted\n", i); + } + ASSERT_TRUE(rejected); + } + const char embedded_nul[] = "s:*\tcomplete\t\t0\0 1\n"; + cbm_coverage_map_t *map = cbm_coverage_map_parse(functions, strlen(functions), embedded_nul, + sizeof(embedded_nul) - 1); + bool rejected = map == NULL; + cbm_coverage_map_free(map); + ASSERT_TRUE(rejected); + map = coverage_of("0\tsrc/a.c\tf\n1\tsrc/b.c\tg", "s:*\tcomplete\t\t0\n"); + rejected = map == NULL; + cbm_coverage_map_free(map); + ASSERT_TRUE(rejected); + PASS(); +} + +#define COVERAGE_META_COMMIT "193b0a4d066f6ced193d9b68a673b5d5e19c4a56" +#define COVERAGE_META_SUITES \ + "[{\"suite\":\"b\",\"tests\":1,\"incomplete\":1,\"exit\":-1,\"wall_s\":0.2}," \ + "{\"suite\":\"a\",\"tests\":1,\"incomplete\":0,\"exit\":0,\"wall_s\":0.1}]" + +static cbm_coverage_map_t *coverage_metadata_fixture(void) { + return coverage_of("0\tsrc/a.c\tf\n1\tsrc/b.c\tg\n", + "a:*\tcomplete\t\t0\na:t\tcomplete\t\t1\n" + "b:*\tincomplete\tsuite did not exit 0\t\n" + "b:t\tincomplete\tsuite did not exit 0\t0\n"); +} + +static const char *coverage_metadata_json(const char *functions, const char *tests, + const char *incomplete, const char *suites, + const char *extra) { + static char text[4096]; + snprintf(text, sizeof(text), + "{\"format\":1,\"commit\":\"" COVERAGE_META_COMMIT "\"," + "\"platform\":\"darwin\",\"llvm_profdata\":\"test LLVM\"," + "\"functions\":%s,\"functions_compiled\":3,\"tests\":%s,\"incomplete\":%s," + "\"suites\":%s%s}", + functions, tests, incomplete, suites, extra); + return text; +} + +static const char *coverage_metadata_replace(const char *from, const char *to) { + const char *base = coverage_metadata_json("2", "2", "1", COVERAGE_META_SUITES, ""); + const char *position = strstr(base, from); + static char text[4096]; + if (!position) + return NULL; + snprintf(text, sizeof(text), "%.*s%s%s", (int)(position - base), base, to, + position + strlen(from)); + return text; +} + +static cbm_coverage_map_t *coverage_metadata_variant(bool setup_complete, bool test_complete) { + char text[1024]; + snprintf(text, sizeof(text), + "a:*\tcomplete\t\t0\na:t\tcomplete\t\t1\n" + "b:*\t%s\t%s\t\nb:t\t%s\t%s\t0\n", + setup_complete ? "complete" : "incomplete", setup_complete ? "" : "unreadable setup", + test_complete ? "complete" : "incomplete", test_complete ? "" : "unreadable profile"); + return coverage_of("0\tsrc/a.c\tf\n1\tsrc/b.c\tg\n", text); +} + +TEST(test_coverage_metadata_matches_rows_and_commit) { + cbm_coverage_map_t *map = coverage_metadata_fixture(); + ASSERT_NOT_NULL(map); + const char *json = coverage_metadata_json("2", "2", "1", COVERAGE_META_SUITES, ""); + bool matched = cbm_coverage_map_metadata_matches(map, json, strlen(json), COVERAGE_META_COMMIT); + cbm_coverage_map_free(map); + ASSERT_TRUE(matched); + PASS(); +} + +TEST(test_coverage_metadata_accepts_builder_variations) { + cbm_coverage_map_t *map = coverage_metadata_fixture(); + ASSERT_NOT_NULL(map); + const char *long_commit = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + struct { + const char *from; + const char *to; + const char *commit; + } cases[] = { + {"\"functions_compiled\":3,", "", COVERAGE_META_COMMIT}, + {"\"functions_compiled\":3", "\"functions_compiled\":0", COVERAGE_META_COMMIT}, + {"\"exit\":-1", "\"exit\":0", COVERAGE_META_COMMIT}, + {",\"wall_s\":0.2", "", COVERAGE_META_COMMIT}, + {COVERAGE_META_COMMIT, long_commit, long_commit}, + }; + bool accepted = true; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + const char *json = coverage_metadata_replace(cases[i].from, cases[i].to); + bool matched = + json && cbm_coverage_map_metadata_matches(map, json, strlen(json), cases[i].commit); + if (!matched) + printf(" valid metadata variation %zu was rejected\n", i); + accepted = accepted && matched; + } + cbm_coverage_map_free(map); + ASSERT_TRUE(accepted); + map = coverage_metadata_variant(false, true); + ASSERT_NOT_NULL(map); + const char *json = + coverage_metadata_json("2", "2", "0", + "[{\"suite\":\"a\",\"tests\":1,\"incomplete\":0,\"exit\":0}," + "{\"suite\":\"b\",\"tests\":1,\"incomplete\":0,\"exit\":0}]", + ""); + bool matched = cbm_coverage_map_metadata_matches(map, json, strlen(json), COVERAGE_META_COMMIT); + cbm_coverage_map_free(map); + ASSERT_TRUE(matched); /* incomplete setup does not count as an incomplete test */ + PASS(); +} + +TEST(test_coverage_metadata_rejects_inconsistent_artifacts) { + cbm_coverage_map_t *map = coverage_metadata_fixture(); + ASSERT_NOT_NULL(map); + struct { + const char *functions; + const char *tests; + const char *incomplete; + const char *suites; + const char *extra; + } cases[] = { + {"3", "2", "1", COVERAGE_META_SUITES, ""}, /* recovered stale function count */ + {"2", "3", "1", COVERAGE_META_SUITES, ""}, + {"2", "2", "0", COVERAGE_META_SUITES, ""}, + {"2.0", "2", "1", COVERAGE_META_SUITES, ""}, + {"-1", "2", "1", COVERAGE_META_SUITES, ""}, + {"18446744073709551615", "2", "1", COVERAGE_META_SUITES, ""}, + {"\"2\"", "2", "1", COVERAGE_META_SUITES, ""}, + {"2", "2", "1", COVERAGE_META_SUITES, ",\"functions\":2"}, + {"2", "2", "1", COVERAGE_META_SUITES, ",\"commit\":\"" COVERAGE_META_COMMIT "\""}, + {"2", "2", "1", "[]", ""}, + {"2", "2", "1", "null", ""}, + {"2", "2", "1", + "[{\"suite\":\"a\",\"tests\":1,\"incomplete\":0,\"exit\":0}," + "{\"suite\":\"a\",\"tests\":1,\"incomplete\":0,\"exit\":0}]", + ""}, + {"2", "2", "1", + "[{\"suite\":\"a\",\"tests\":2,\"incomplete\":0,\"exit\":0}," + "{\"suite\":\"b\",\"tests\":1,\"incomplete\":1,\"exit\":-1}]", + ""}, + {"2", "2", "1", + "[{\"suite\":\"a\",\"tests\":1,\"incomplete\":0,\"exit\":7}," + "{\"suite\":\"b\",\"tests\":1,\"incomplete\":1,\"exit\":-1}]", + ""}, + {"2", "2", "1", + "[{\"suite\":\"absent\",\"tests\":1,\"incomplete\":0,\"exit\":0}," + "{\"suite\":\"b\",\"tests\":1,\"incomplete\":1,\"exit\":-1}]", + ""}, + {"2", "2", "1", + "[{\"suite\":\"a\\u0000x\",\"tests\":1,\"incomplete\":0,\"exit\":0}," + "{\"suite\":\"b\",\"tests\":1,\"incomplete\":1,\"exit\":-1}]", + ""}, + {"2", "2", "1", + "[{\"suite\":\"a\",\"tests\":1,\"tests\":1,\"incomplete\":0,\"exit\":0}," + "{\"suite\":\"b\",\"tests\":1,\"incomplete\":1,\"exit\":-1}]", + ""}, + }; + bool accepted_any = false; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + const char *json = + coverage_metadata_json(cases[i].functions, cases[i].tests, cases[i].incomplete, + cases[i].suites, cases[i].extra); + bool matched = + cbm_coverage_map_metadata_matches(map, json, strlen(json), COVERAGE_META_COMMIT); + if (matched) + printf(" inconsistent metadata case %zu was accepted\n", i); + accepted_any = accepted_any || matched; + } + struct { + const char *from; + const char *to; + } replacements[] = { + {"\"format\":1", "\"format\":2"}, + {"\"platform\":\"darwin\"", "\"platform\":\"\""}, + {"\"llvm_profdata\":\"test LLVM\"", "\"llvm_profdata\":42"}, + {"\"functions_compiled\":3", "\"functions_compiled\":-1"}, + {"\"functions_compiled\":3", "\"functions_compiled\":3.5"}, + {"\"wall_s\":0.2", "\"wall_s\":-0.2"}, + }; + for (size_t i = 0; i < sizeof(replacements) / sizeof(replacements[0]); i++) { + const char *json = coverage_metadata_replace(replacements[i].from, replacements[i].to); + ASSERT_NOT_NULL(json); + bool matched = + cbm_coverage_map_metadata_matches(map, json, strlen(json), COVERAGE_META_COMMIT); + if (matched) + printf(" invalid metadata replacement %zu was accepted\n", i); + accepted_any = accepted_any || matched; + } + const char *bad_commit = "g93b0a4d066f6ced193d9b68a673b5d5e19c4a56"; + const char *changed_commit = coverage_metadata_replace(COVERAGE_META_COMMIT, bad_commit); + ASSERT_NOT_NULL(changed_commit); + accepted_any |= + cbm_coverage_map_metadata_matches(map, changed_commit, strlen(changed_commit), bad_commit); + const char *json = coverage_metadata_json("2", "2", "1", COVERAGE_META_SUITES, ""); + ASSERT_FALSE(cbm_coverage_map_metadata_matches(map, json, strlen(json), + "0000000000000000000000000000000000000000")); + ASSERT_FALSE(cbm_coverage_map_metadata_matches(map, json, strlen(json), NULL)); + ASSERT_FALSE( + cbm_coverage_map_metadata_matches(map, json, strlen(json) - 1, COVERAGE_META_COMMIT)); + ASSERT_FALSE(cbm_coverage_map_metadata_matches(map, "{}", 2, COVERAGE_META_COMMIT)); + cbm_coverage_map_free(map); + ASSERT_FALSE(accepted_any); + map = coverage_metadata_variant(true, false); + ASSERT_NOT_NULL(map); + json = coverage_metadata_json("2", "2", "1", COVERAGE_META_SUITES, ""); + bool matched = cbm_coverage_map_metadata_matches(map, json, strlen(json), COVERAGE_META_COMMIT); + cbm_coverage_map_free(map); + ASSERT_FALSE(matched); /* failed suite, incomplete test, but complete setup */ + map = coverage_metadata_variant(false, true); + ASSERT_NOT_NULL(map); + json = coverage_metadata_json("2", "2", "0", + "[{\"suite\":\"a\",\"tests\":1,\"incomplete\":0,\"exit\":0}," + "{\"suite\":\"b\",\"tests\":1,\"incomplete\":0,\"exit\":-1}]", + ""); + matched = cbm_coverage_map_metadata_matches(map, json, strlen(json), COVERAGE_META_COMMIT); + cbm_coverage_map_free(map); + ASSERT_FALSE(matched); /* failed suite, incomplete setup, but complete test */ + PASS(); +} + +TEST(test_test_config_loads_globs_and_optional_absence) { + char dir[512], path[600]; + snprintf(dir, sizeof(dir), "%s/cbm-impact-config-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(dir)); + snprintf(path, sizeof(path), "%s/project.json", dir); + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + cbm_test_config_t *config = cbm_test_config_load(path, true); + ASSERT_NOT_NULL(config); + ASSERT_EQ(strlen(cbm_test_config_digest(config)), 64); + ASSERT_FALSE(cbm_test_config_is_test_file(config, "tests/example.c")); + char missing_digest[65]; + snprintf(missing_digest, sizeof(missing_digest), "%s", cbm_test_config_digest(config)); + cbm_test_config_free(config); + ASSERT_NULL(cbm_test_config_load(path, false)); + ASSERT_EQ(th_write_file(path, "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[" + "\"spec/**\",\"**/*_test.c\"]}}}"), + 0); + config = cbm_test_config_load(path, false); + ASSERT_NOT_NULL(config); + ASSERT_TRUE(cbm_test_config_is_test_file(config, "spec/nested/test.c")); + ASSERT_TRUE(cbm_test_config_is_test_file(config, "src/parser_test.c")); + ASSERT_TRUE(cbm_test_config_is_test_file(config, "parser_test.c")); + ASSERT_FALSE(cbm_test_config_is_test_file(config, "src/parser.c")); + ASSERT_FALSE(cbm_test_config_is_test_file(config, "other/spec/parser.c")); + ASSERT_TRUE(strcmp(missing_digest, cbm_test_config_digest(config)) != 0); + char digest[65]; + snprintf(digest, sizeof(digest), "%s", cbm_test_config_digest(config)); + cbm_test_config_free(config); + config = cbm_test_config_load(path, false); + ASSERT_NOT_NULL(config); + ASSERT_STR_EQ(digest, cbm_test_config_digest(config)); + cbm_test_config_free(config); + + const char *empty[] = {"{}", "{\"extra_extensions\":{\".foo\":\"c\"}}", + "{\"test_impact\":{\"version\":1}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[]}}}"}; + for (size_t i = 0; i < sizeof(empty) / sizeof(empty[0]); i++) { + ASSERT_EQ(th_write_file(path, empty[i]), 0); + config = cbm_test_config_load(path, false); + ASSERT_NOT_NULL(config); + ASSERT_FALSE(cbm_test_config_is_test_file(config, "spec/example.c")); + cbm_test_config_free(config); + } + char *boundary = malloc(65538); + ASSERT_NOT_NULL(boundary); + memset(boundary, ' ', 65537); + boundary[0] = '{'; + boundary[1] = '}'; + boundary[65536] = '\0'; + ASSERT_EQ(th_write_file(path, boundary), 0); + config = cbm_test_config_load(path, false); + ASSERT_NOT_NULL(config); + cbm_test_config_free(config); + boundary[65536] = ' '; + boundary[65537] = '\0'; + ASSERT_EQ(th_write_file(path, boundary), 0); + ASSERT_NULL(cbm_test_config_load(path, false)); + free(boundary); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + th_rmtree(dir); + PASS(); +} + +TEST(test_test_config_rejects_malformed_consumed_fields) { + char dir[512], path[600]; + snprintf(dir, sizeof(dir), "%s/cbm-impact-invalid-config-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(dir)); + snprintf(path, sizeof(path), "%s/project.json", dir); + const char *bad[] = { + "", + "[]", + "{", + "{\"test_impact\":null}", + "{\"test_impact\":{}}", + "{\"test_impact\":{\"version\":2}}", + "{\"test_impact\":{\"version\":1.0}}", + "{\"test_impact\":{\"version\":1,\"version\":1}}", + "{\"test_impact\":{\"version\":1,\"tests\":[]}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":\"test*\"}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[3]}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[\"\"]}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[\"!spec/**\"]}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[\"#spec/**\"]}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[\"spec/**\\nother/**\"]}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[\"spec/**\\u0000other/**\"]}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[],\"test_globs\":[]}}}", + }; + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + ASSERT_EQ(th_write_file(path, bad[i]), 0); + cbm_test_config_t *config = cbm_test_config_load(path, true); + bool accepted = config != NULL; + cbm_test_config_free(config); + if (accepted) + printf(" invalid config case %zu accepted\n", i); + ASSERT_FALSE(accepted); + } + ASSERT_NULL(cbm_test_config_load(dir, true)); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + th_rmtree(dir); + PASS(); +} + +TEST(test_test_config_directory_globs_match_descendants) { + char dir[512], path[600]; + snprintf(dir, sizeof(dir), "%s/cbm-impact-directory-globs-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(dir)); + snprintf(path, sizeof(path), "%s/project.json", dir); + ASSERT_EQ(th_write_file(path, "{\"test_impact\":{\"version\":1,\"tests\":{\"test_globs\":[" + "\"tests/\",\"/spec/\",\"fixtures\"]}}}"), + 0); + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + cbm_test_config_t *config = cbm_test_config_load(path, false); + ASSERT_NOT_NULL(config); + ASSERT_TRUE(cbm_test_config_is_test_file(config, "tests/case.c")); + ASSERT_TRUE(cbm_test_config_is_test_file(config, "nested/tests/case.c")); + ASSERT_TRUE(cbm_test_config_is_test_file(config, "spec/case.c")); + ASSERT_TRUE(cbm_test_config_is_test_file(config, "fixtures/data.c")); + ASSERT_FALSE(cbm_test_config_is_test_file(config, "nested/spec/case.c")); + ASSERT_FALSE(cbm_test_config_is_test_file(config, "tests")); + ASSERT_FALSE(cbm_test_config_is_test_file(config, "src/testing.c")); + cbm_test_config_free(config); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + th_rmtree(dir); + PASS(); +} + +/* Rule B: static reach OR observed per-test execution, with uncertainty + * retained. These fixtures deliberately have disjoint static/coverage hits. */ +static const char *selection_source = + "TEST(stat) {}\nTEST(cov) {}\nTEST(changed) {}\nTEST(quiet) {}\n" + "SUITE(alpha) { RUN_TEST(stat); RUN_TEST(cov); RUN_TEST(changed); RUN_TEST(quiet); }\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n"; +static const char *selection_functions = "0\tsrc/product.c\tchanged_fn\n1\tsrc/other.c\tother\n"; +static const char *selection_rows = "alpha:*\tcomplete\t\t1\nalpha:stat\tcomplete\t\t1\n" + "alpha:cov\tcomplete\t\t0\nalpha:changed\tcomplete\t\t1\n" + "alpha:quiet\tcomplete\t\t1\n"; + +static cbm_test_model_t *selection_model(const char *source) { + tm_source_t sources[] = {{"tests/cases.c", source}}; + return model_of(sources, 1); +} + +static cbm_coverage_map_t *selection_map(const char *rows) { + return cbm_coverage_map_parse(selection_functions, strlen(selection_functions), rows, + strlen(rows)); +} + +static cbm_test_selection_input_t selection_input(cbm_test_model_t *model, cbm_coverage_map_t *map, + cbm_test_reach_t *reach, int *changed) { + return (cbm_test_selection_input_t){.model = model, + .coverage = map, + .reach = reach, + .reach_count = 4, + .changed_function_ids = changed, + .changed_function_count = 1, + .has_changes = true, + .diff_complete = true, + .inventory_complete = true, + .static_complete = true, + .coverage_admitted = true, + .coverage_changes_complete = true}; +} + +static const cbm_test_selected_case_t *selection_case(const cbm_test_selection_t *result, + const char *suite, const char *test) { + int count; + const cbm_test_selected_case_t *rows = cbm_test_selection_cases(result, &count); + for (int i = 0; i < count; i++) + if (strcmp(rows[i].suite, suite) == 0 && strcmp(rows[i].test, test) == 0) + return &rows[i]; + return NULL; +} + +static unsigned selection_case_reasons(const cbm_test_selection_t *result, const char *suite, + const char *test) { + const cbm_test_selected_case_t *row = selection_case(result, suite, test); + return row ? row->reasons : 0; +} + +#define SELECTION_REACH \ + { \ + {"tests/cases.c", "stat", true, true, false}, \ + {"tests/cases.c", "cov", true, false, false}, \ + {"tests/cases.c", "changed", true, false, true}, { \ + "tests/cases.c", "quiet", true, false, false \ + } \ + } + +TEST(test_selection_unions_static_coverage_and_changed_tests) { + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + cbm_test_model_t *model = selection_model(selection_source); + cbm_coverage_map_t *map = selection_map(selection_rows); + ASSERT_NOT_NULL(model); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = SELECTION_REACH; + int changed[] = {0}; + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count; + const cbm_test_selected_case_t *cases = cbm_test_selection_cases(result, &count); + ASSERT_EQ(count, 3); + ASSERT_STR_EQ(cases[0].test, "changed"); + ASSERT_EQ(cases[0].reasons, CBM_TEST_SELECT_CHANGED); + ASSERT_STR_EQ(cases[1].test, "cov"); + ASSERT_EQ(cases[1].reasons, CBM_TEST_SELECT_COVERAGE); + ASSERT_STR_EQ(cases[2].test, "stat"); + ASSERT_EQ(cases[2].reasons, CBM_TEST_SELECT_STATIC); + ASSERT_NULL(selection_case(result, "alpha", "quiet")); + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 1); + ASSERT_FALSE(suites[0].whole); + ASSERT_EQ(suites[0].reasons, + CBM_TEST_SELECT_CHANGED | CBM_TEST_SELECT_COVERAGE | CBM_TEST_SELECT_STATIC); + cbm_test_selection_free(result); + cbm_coverage_map_free(map); + cbm_test_model_free(model); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_selection_retains_missing_and_incomplete_test_evidence) { + cbm_test_model_t *model = selection_model(selection_source); + cbm_coverage_map_t *map = + selection_map("alpha:*\tcomplete\t\t1\nalpha:stat\tcomplete\t\t1\n" + "alpha:changed\tcomplete\t\t0\nalpha:quiet\tincomplete\tno profile\t\n"); + ASSERT_NOT_NULL(model); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = SELECTION_REACH; + reach[0].mapped = false; + reach[0].reached = false; + reach[2].changed = false; + int changed[] = {0}; + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + ASSERT_EQ(selection_case_reasons(result, "alpha", "stat"), CBM_TEST_SELECT_UNMAPPED); + ASSERT_EQ(selection_case_reasons(result, "alpha", "cov"), CBM_TEST_SELECT_COVERAGE_UNKNOWN); + ASSERT_EQ(selection_case_reasons(result, "alpha", "quiet"), CBM_TEST_SELECT_COVERAGE_UNKNOWN); + ASSERT_EQ(selection_case_reasons(result, "alpha", "changed"), CBM_TEST_SELECT_COVERAGE); + int count; + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 1); + ASSERT_FALSE(suites[0].whole); + cbm_test_selection_free(result); + /* A completely absent static row has the same conservative meaning. */ + input.reach = reach + 1; + input.reach_count = 3; + result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(selection_case_reasons(result, "alpha", "stat"), CBM_TEST_SELECT_UNMAPPED); + cbm_test_selection_free(result); + cbm_coverage_map_free(map); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_selection_setup_and_rejected_artifacts_select_whole_suites) { + cbm_test_model_t *model = selection_model(selection_source); + ASSERT_NOT_NULL(model); + const char *maps[] = { + "alpha:*\tcomplete\t\t0\nalpha:stat\tcomplete\t\t1\n", + "alpha:*\tincomplete\tlost setup\t0\nalpha:stat\tcomplete\t\t1\n", + "other:*\tcomplete\t\t0 1\n", + selection_rows, + }; + unsigned reasons[] = {CBM_TEST_SELECT_SETUP_HIT, CBM_TEST_SELECT_SETUP_UNKNOWN, + CBM_TEST_SELECT_SETUP_UNKNOWN, CBM_TEST_SELECT_ARTIFACT_REJECTED}; + cbm_test_reach_t reach[] = SELECTION_REACH; + int changed[] = {0}; + for (int i = 0; i < 4; i++) { + cbm_coverage_map_t *map = selection_map(maps[i]); + ASSERT_NOT_NULL(map); + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + input.coverage_admitted = i != 3; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count; + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 1); + ASSERT_TRUE(suites[0].whole); + ASSERT_TRUE(suites[0].reasons & reasons[i]); + cbm_test_selection_cases(result, &count); + ASSERT_EQ(count, 0); + cbm_test_selection_free(result); + cbm_coverage_map_free(map); + } + cbm_test_model_free(model); + PASS(); +} + +TEST(test_selection_incomplete_global_evidence_never_narrows) { + cbm_test_model_t *model = selection_model(selection_source); + cbm_coverage_map_t *map = selection_map(selection_rows); + ASSERT_NOT_NULL(model); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = SELECTION_REACH; + int changed[] = {0}; + unsigned expected[] = {CBM_TEST_SELECT_DIFF_INCOMPLETE, CBM_TEST_SELECT_INVENTORY_UNKNOWN, + CBM_TEST_SELECT_STATIC_INCOMPLETE, + CBM_TEST_SELECT_CHANGE_IDENTITY_UNKNOWN}; + for (int i = 0; i < 4; i++) { + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + if (i == 0) + input.diff_complete = false; + if (i == 1) + input.inventory_complete = false; + if (i == 2) + input.static_complete = false; + if (i == 3) + input.coverage_changes_complete = false; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), expected[i]); + int count; + cbm_test_selection_cases(result, &count); + ASSERT_EQ(count, 0); + cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 0); + cbm_test_selection_free(result); + } + cbm_coverage_map_free(map); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_selection_inventory_ambiguity_and_conditionals_use_whole_suite) { + const char *sources[] = { + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n", + "SUITE(alpha) { RUN_TEST(unknown); }\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n", + "#define REG(name) RUN_TEST(name)\nTEST(stat) {}\nTEST(quiet) {}\n" + "SUITE(alpha) { REG(stat); RUN_TEST(quiet); }\nvoid main(void) { " + "RUN_SELECTED_SUITE(alpha); }\n", + "#ifdef FEATURE\nTEST(stat) {}\n#endif\nSUITE(alpha) { RUN_TEST(stat); }\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n", + "TEST(stat) {}\nSUITE(alpha) {\n#ifdef FEATURE\nRUN_TEST(stat);\n#endif\n}\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n", + }; + cbm_coverage_map_t *map = selection_map(selection_rows); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = SELECTION_REACH; + int changed[] = {0}; + for (int i = 0; i < 5; i++) { + cbm_test_model_t *model = selection_model(sources[i]); + ASSERT_NOT_NULL(model); + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + /* No changed evidence for cases absent in these deliberately tiny models. */ + reach[2].changed = false; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count; + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 1); + ASSERT_TRUE(suites[0].whole); + ASSERT_TRUE(suites[0].reasons & + (i < 3 ? CBM_TEST_SELECT_INVENTORY_UNKNOWN : CBM_TEST_SELECT_CONDITIONAL)); + cbm_test_selection_cases(result, &count); + ASSERT_EQ(count, 0); + cbm_test_selection_free(result); + cbm_test_model_free(model); + } + cbm_coverage_map_free(map); + PASS(); +} + +TEST(test_selection_unregistered_changes_and_unknown_runner_membership_run_all) { + const char *sources[] = { + "TEST(changed) {}\nTEST(stat) {}\nSUITE(alpha) { RUN_TEST(stat); }\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n", + "TEST(stat) {}\nSUITE(hidden) { RUN_TEST(stat); }\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n", + }; + cbm_coverage_map_t *map = selection_map(selection_rows); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = SELECTION_REACH; + int changed[] = {0}; + for (int i = 0; i < 2; i++) { + cbm_test_model_t *model = selection_model(sources[i]); + ASSERT_NOT_NULL(model); + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + if (i == 1) + reach[2].changed = false; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_TRUE(cbm_test_selection_run_all(result) & + (i ? CBM_TEST_SELECT_INVENTORY_UNKNOWN : CBM_TEST_SELECT_CHANGED_UNREGISTERED)); + cbm_test_selection_free(result); + cbm_test_model_free(model); + } + cbm_coverage_map_free(map); + PASS(); +} + +TEST(test_selection_invalid_evidence_cannot_produce_a_partial_result) { + cbm_test_model_t *model = selection_model(selection_source); + cbm_coverage_map_t *map = selection_map(selection_rows); + ASSERT_NOT_NULL(model); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = SELECTION_REACH; + cbm_test_reach_t duplicate[] = {reach[0], reach[0]}; + int changed[] = {0}; + for (int i = 0; i < 6; i++) { + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + if (i == 0) + input.changed_function_count = -1; + if (i == 1) { + changed[0] = 2; + } + if (i == 2) { + input.reach = NULL; + } + if (i == 3) { + input.reach = duplicate; + input.reach_count = 2; + } + if (i == 4) { + reach[0].test = NULL; + } + if (i == 5) { + input.changed_function_ids = NULL; + } + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_TRUE(cbm_test_selection_run_all(result) & CBM_TEST_SELECT_INVALID_INPUT); + int count; + cbm_test_selection_cases(result, &count); + ASSERT_EQ(count, 0); + cbm_test_selection_free(result); + changed[0] = 0; + reach[0].test = "stat"; + } + ASSERT_TRUE(cbm_test_selection_run_all(NULL) != 0); + cbm_test_selection_free(NULL); + cbm_coverage_map_free(map); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_selection_is_deterministic_deduplicated_and_owns_strings) { + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + tm_source_t sources[] = { + {"tests/cases.c", "TEST(stat) {}\nSUITE(zeta) { RUN_TEST(stat); RUN_TEST(stat); }\n" + "SUITE(alpha) { RUN_TEST(stat); }\n"}, + {"tests/main.c", + "void main(void) { RUN_SELECTED_SUITE(zeta); RUN_SELECTED_SUITE(alpha); }\n"}, + }; + cbm_coverage_map_t *map = selection_map("zeta:*\tcomplete\t\t1\nzeta:stat\tcomplete\t\t0\n" + "alpha:*\tcomplete\t\t1\nalpha:stat\tcomplete\t\t0\n"); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = {{"tests/cases.c", "stat", true, true, false}}; + int changed[] = {0}; + for (int pass = 0; pass < 2; pass++) { + cbm_test_model_t *model = model_of(sources, 2); + ASSERT_NOT_NULL(model); + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + input.reach_count = 1; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + cbm_test_model_free(model); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count; + const cbm_test_selected_case_t *cases = cbm_test_selection_cases(result, &count); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(cases[0].suite, "alpha"); + ASSERT_STR_EQ(cases[1].suite, "zeta"); + ASSERT_STR_EQ(cases[1].file, "tests/cases.c"); + ASSERT_EQ(cases[0].reasons, CBM_TEST_SELECT_STATIC | CBM_TEST_SELECT_COVERAGE); + ASSERT_EQ(cases[1].reasons, cases[0].reasons); + cbm_test_selection_free(result); + tm_source_t swap = sources[0]; + sources[0] = sources[1]; + sources[1] = swap; + } + cbm_coverage_map_free(map); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_selection_proven_empty_diff_is_distinct_from_unresolved_changes) { + cbm_test_selection_input_t input = {.diff_complete = true, .has_changes = false}; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count; + cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 0); + cbm_test_selection_free(result); + input.diff_complete = false; + result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_TRUE(cbm_test_selection_run_all(result) & CBM_TEST_SELECT_DIFF_INCOMPLETE); + cbm_test_selection_free(result); + PASS(); +} + +TEST(test_selection_suite_body_and_rule_triggers_dominate_cases) { + cbm_test_model_t *model = selection_model(selection_source); + cbm_coverage_map_t *map = selection_map(selection_rows); + ASSERT_NOT_NULL(model); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = SELECTION_REACH; + reach[0].reached = false; + reach[2].changed = false; + int changed[] = {0}; + cbm_test_suite_trigger_t trigger = {.suite = "alpha"}; + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + input.changed_function_count = 0; + input.suite_triggers = &trigger; + input.suite_trigger_count = 1; + unsigned reasons[] = {CBM_TEST_SELECT_STATIC, CBM_TEST_SELECT_CHANGED, CBM_TEST_SELECT_RULE}; + for (int i = 0; i < 4; i++) { + reach[0].reached = i < 3; + trigger.reached = i == 0; + trigger.changed = i == 1; + trigger.rule = i == 2; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count; + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, i == 3 ? 0 : 1); + if (i < 3) { + ASSERT_TRUE(suites[0].whole); + ASSERT_EQ(suites[0].reasons, reasons[i] | CBM_TEST_SELECT_STATIC); + } + cbm_test_selection_cases(result, &count); + ASSERT_EQ(count, 0); + cbm_test_selection_free(result); + } + trigger.suite = "not_in_runner"; + trigger.changed = true; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_TRUE(cbm_test_selection_run_all(result) & CBM_TEST_SELECT_INVENTORY_UNKNOWN); + cbm_test_selection_free(result); + cbm_coverage_map_free(map); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_selection_ambiguous_source_identity_cannot_choose_first_match) { + tm_source_t sources[] = { + {"tests/cases.c", "TEST(stat) {}\nSUITE(alpha) { RUN_TEST(stat); }\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n"}, + {"tests/other.c", "TEST(stat) {}\nSUITE(alpha) { RUN_TEST(stat); }\n"}, + }; + cbm_test_model_t *model = model_of(sources, 2); + cbm_coverage_map_t *map = selection_map(selection_rows); + ASSERT_NOT_NULL(model); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = {{"tests/cases.c", "stat", true, false, false}, + {"tests/other.c", "stat", true, false, false}}; + int changed[] = {0}; + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + input.reach_count = 2; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count; + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 1); + ASSERT_TRUE(suites[0].whole); + ASSERT_TRUE(suites[0].reasons & CBM_TEST_SELECT_INVENTORY_UNKNOWN); + cbm_test_selection_free(result); + cbm_coverage_map_free(map); + cbm_test_model_free(model); + PASS(); +} + +#undef SELECTION_REACH + +/* Independent byte hashes below are generated by Python hashlib from these + * literal artifacts; policy timestamps are pinned, never the wall clock. */ +static const char *receipt_functions = "0\tsrc/source.c\tone\n1\tsrc/source.c\ttwo\n"; +static const char *receipt_tests = + "alpha:*\tcomplete\t\t1\nalpha:one\tcomplete\t\t0\nalpha:two\tcomplete\t\t1\n"; +static const char *receipt_metadata = + "{\"format\":1,\"commit\":\"1111111111111111111111111111111111111111\",\"functions\":2," + "\"tests\":2,\"incomplete\":0,\"platform\":\"fixture\",\"llvm_profdata\":\"fixture\"," + "\"suites\":[{\"suite\":\"alpha\",\"tests\":2,\"incomplete\":0,\"exit\":0}]}\n"; +static const char *receipt_bad_metadata = + "{\"format\":1,\"commit\":\"1111111111111111111111111111111111111111\",\"functions\":2," + "\"tests\":3,\"incomplete\":0,\"platform\":\"fixture\",\"llvm_profdata\":\"fixture\"," + "\"suites\":[{\"suite\":\"alpha\",\"tests\":2,\"incomplete\":0,\"exit\":0}]}\n"; + +static cbm_coverage_receipt_t receipt_fixture(void) { + return (cbm_coverage_receipt_t){ + .commit = "1111111111111111111111111111111111111111", + .functions_sha256 = "9fb6ff76736fdda3558510f3732c3af3be254476dc29e479bc1e2aa67db14c91", + .tests_sha256 = "9301b8969a11a4b758f4d0d7128ba71bc00a90da1b6f89acbf175988abef362c", + .metadata_sha256 = "ebdeebf656b0eace38a267f808365b6fea7eefb89f9feabd41c70d4d2cf156aa", + .graph_sha256 = "4444444444444444444444444444444444444444444444444444444444444444", + .compatibility_sha256 = "3333333333333333333333333333333333333333333333333333333333333333", + .oldest_observation_at = INT64_C(2000000000), + }; +} + +static cbm_coverage_receipt_context_t receipt_context(void) { + return (cbm_coverage_receipt_context_t){ + .source_verified = true, + .trusted_commit = "1111111111111111111111111111111111111111", + .ancestor_verified = true, + .ancestor_commit = "1111111111111111111111111111111111111111", + .ancestor_merge_base = "2222222222222222222222222222222222222222", + .merge_base = "2222222222222222222222222222222222222222", + .graph_commit = "1111111111111111111111111111111111111111", + .graph_sha256 = "4444444444444444444444444444444444444444444444444444444444444444", + .compatibility_sha256 = "3333333333333333333333333333333333333333333333333333333333333333", + .now = INT64_C(2000000000) + 604800, + }; +} + +static cbm_coverage_map_t *receipt_map(void) { + return cbm_coverage_map_parse(receipt_functions, strlen(receipt_functions), receipt_tests, + strlen(receipt_tests)); +} + +TEST(test_coverage_receipt_accepts_bound_evidence_at_age_boundary) { + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + cbm_coverage_map_t *map = receipt_map(); + ASSERT_NOT_NULL(map); + cbm_coverage_receipt_t receipt = receipt_fixture(); + cbm_coverage_receipt_context_t context = receipt_context(); + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &receipt, &context), + 0); + context.now = receipt.oldest_observation_at; + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &receipt, &context), + 0); + cbm_coverage_map_free(map); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_coverage_receipt_requires_bound_source_ancestry_and_graph) { + cbm_coverage_map_t *map = receipt_map(); + ASSERT_NOT_NULL(map); + unsigned flags[] = {CBM_COVERAGE_RECEIPT_SOURCE, CBM_COVERAGE_RECEIPT_ANCESTRY, + CBM_COVERAGE_RECEIPT_ANCESTRY, CBM_COVERAGE_RECEIPT_ANCESTRY, + CBM_COVERAGE_RECEIPT_COMMIT, CBM_COVERAGE_RECEIPT_COMMIT, + CBM_COVERAGE_RECEIPT_CONTENT, CBM_COVERAGE_RECEIPT_COMPATIBILITY}; + for (int i = 0; i < 8; i++) { + cbm_coverage_receipt_t receipt = receipt_fixture(); + cbm_coverage_receipt_context_t context = receipt_context(); + if (i == 0) + context.source_verified = false; + if (i == 1) + context.ancestor_verified = false; + if (i == 2) + context.ancestor_commit = context.merge_base; + if (i == 3) + context.ancestor_merge_base = context.trusted_commit; + if (i == 4) + context.graph_commit = context.merge_base; + if (i == 5) + context.trusted_commit = context.merge_base; + if (i == 6) + context.graph_sha256 = context.compatibility_sha256; + if (i == 7) + context.compatibility_sha256 = context.graph_sha256; + unsigned reasons = cbm_coverage_map_check_receipt( + map, receipt_metadata, strlen(receipt_metadata), &receipt, &context); + ASSERT_TRUE(reasons & flags[i]); + ASSERT_FALSE(reasons & CBM_COVERAGE_RECEIPT_INVALID); + } + cbm_coverage_map_free(map); + PASS(); +} + +TEST(test_coverage_receipt_binds_exact_bytes_and_metadata_consistency) { + cbm_coverage_receipt_t receipt = receipt_fixture(); + cbm_coverage_receipt_context_t context = receipt_context(); + cbm_coverage_map_t *map = receipt_map(); + ASSERT_NOT_NULL(map); + cbm_coverage_receipt_t changed = receipt; + changed.functions_sha256 = receipt.graph_sha256; + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &changed, &context), + CBM_COVERAGE_RECEIPT_CONTENT); + changed = receipt; + changed.tests_sha256 = receipt.graph_sha256; + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &changed, &context), + CBM_COVERAGE_RECEIPT_CONTENT); + changed = receipt; + changed.metadata_sha256 = receipt.graph_sha256; + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &changed, &context), + CBM_COVERAGE_RECEIPT_CONTENT); + /* Matching byte provenance cannot make inconsistent semantic counts valid. */ + changed = receipt; + changed.metadata_sha256 = "70944a2f6b8ab0318e9fe086326a28d333c0fe5e0836734f8bc246312f71b212"; + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_bad_metadata, + strlen(receipt_bad_metadata), &changed, &context), + CBM_COVERAGE_RECEIPT_METADATA); + cbm_coverage_map_free(map); + const char *modified_functions = "0\tsrc/renamed.c\tone\n1\tsrc/source.c\ttwo\n"; + map = cbm_coverage_map_parse(modified_functions, strlen(modified_functions), receipt_tests, + strlen(receipt_tests)); + ASSERT_NOT_NULL(map); + ASSERT_TRUE(cbm_coverage_map_metadata_matches(map, receipt_metadata, strlen(receipt_metadata), + receipt.commit)); + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &receipt, &context), + CBM_COVERAGE_RECEIPT_CONTENT); + cbm_coverage_map_free(map); + const char *modified_tests = "alpha:*\tcomplete\t\t1\n" + "alpha:one\tcomplete\t\t0\n" + "alpha:two\tcomplete\t\t0\n"; + map = cbm_coverage_map_parse(receipt_functions, strlen(receipt_functions), modified_tests, + strlen(modified_tests)); + ASSERT_NOT_NULL(map); + ASSERT_TRUE(cbm_coverage_map_metadata_matches(map, receipt_metadata, strlen(receipt_metadata), + receipt.commit)); + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &receipt, &context), + CBM_COVERAGE_RECEIPT_CONTENT); + cbm_coverage_map_free(map); + PASS(); +} + +TEST(test_coverage_receipt_rejects_stale_future_and_missing_age) { + cbm_coverage_map_t *map = receipt_map(); + ASSERT_NOT_NULL(map); + for (int i = 0; i < 6; i++) { + cbm_coverage_receipt_t receipt = receipt_fixture(); + cbm_coverage_receipt_context_t context = receipt_context(); + if (i == 0) + context.now++; + if (i == 1) + receipt.oldest_observation_at = context.now + 1; + if (i == 2) + receipt.oldest_observation_at = 0; + if (i == 3) + receipt.oldest_observation_at = INT64_MIN; + if (i == 4) + context.now = INT64_MAX; + if (i == 5) + context.now = -1; + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &receipt, &context), + CBM_COVERAGE_RECEIPT_AGE); + } + cbm_coverage_map_free(map); + PASS(); +} + +TEST(test_coverage_receipt_missing_or_malformed_evidence_fails_closed) { + cbm_coverage_map_t *map = receipt_map(); + ASSERT_NOT_NULL(map); + for (int i = 0; i < 9; i++) { + cbm_coverage_receipt_t receipt = receipt_fixture(); + cbm_coverage_receipt_context_t context = receipt_context(); + if (i == 0) + receipt.commit = "HEAD"; + if (i == 1) + receipt.functions_sha256 = ""; + if (i == 2) + receipt.tests_sha256 = NULL; + if (i == 3) + receipt.metadata_sha256 = + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; + if (i == 4) + context.merge_base = NULL; + if (i == 5) + context.ancestor_merge_base = "main"; + if (i == 6) + context.graph_commit = ""; + if (i == 7) + context.graph_sha256 = "abcd"; + if (i == 8) + context.compatibility_sha256 = NULL; + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &receipt, &context), + CBM_COVERAGE_RECEIPT_INVALID); + } + cbm_coverage_receipt_t receipt = receipt_fixture(); + cbm_coverage_receipt_context_t context = receipt_context(); + ASSERT_EQ(cbm_coverage_map_check_receipt(NULL, receipt_metadata, strlen(receipt_metadata), + &receipt, &context), + CBM_COVERAGE_RECEIPT_INVALID); + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), NULL, + &context), + CBM_COVERAGE_RECEIPT_INVALID); + ASSERT_EQ(cbm_coverage_map_check_receipt(map, receipt_metadata, strlen(receipt_metadata), + &receipt, NULL), + CBM_COVERAGE_RECEIPT_INVALID); + cbm_coverage_map_free(map); + PASS(); +} + +/* Policies consume the exact query-local snapshot and classify paths before + * target applicability is known. */ +static cbm_test_policy_t *policy_of(const char *body) { + char dir[512], path[600]; + snprintf(dir, sizeof(dir), "%s/cbm-impact-policy-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(dir)) + return NULL; + snprintf(path, sizeof(path), "%s/config.json", dir); + cbm_test_policy_t *policy = NULL; + if (th_write_file(path, body) == 0) { + cbm_test_config_t *config = cbm_test_config_load(path, false); + if (config) + policy = cbm_test_policy_new(config); + cbm_test_config_free(config); + } + th_rmtree(dir); + return policy; +} + +static bool policy_matches(const cbm_test_policy_t *policy, const char *path, const char *id) { + const cbm_test_rule_t *rule = NULL; + if (!cbm_test_policy_match_path(policy, path, &rule)) + return false; + return id ? rule && strcmp(rule->id, id) == 0 : rule == NULL; +} + +TEST(test_policy_retains_exact_snapshot_and_owns_result) { + char dir[512], path[600]; + snprintf(dir, sizeof(dir), "%s/cbm-impact-policy-source-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(dir)); + snprintf(path, sizeof(path), "%s/config.json", dir); + const char *json = " {\"test_impact\":{\"version\":1,\"rules\":[{\"id\":\"mine\"," + "\"paths\":[\"special/**\"],\"action\":\"ignore\"}]}}\n"; + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), len = 99; + bool absent = false; + cbm_test_config_t *config = cbm_test_config_load(path, true); + ASSERT_NOT_NULL(config); + const char *source = cbm_test_config_source(config, &len, &absent); + ASSERT_NOT_NULL(source); + ASSERT_EQ(len, 0); + ASSERT_TRUE(absent); + cbm_test_policy_t *missing = cbm_test_policy_new(config); + ASSERT_NOT_NULL(missing); + cbm_test_policy_free(missing); + cbm_test_config_free(config); + ASSERT_EQ(th_write_file(path, json), 0); + config = cbm_test_config_load(path, false); + ASSERT_NOT_NULL(config); + source = cbm_test_config_source(config, &len, &absent); + ASSERT_FALSE(absent); + ASSERT_EQ(len, strlen(json)); + ASSERT_NOT_NULL(source); + ASSERT_EQ(memcmp(source, json, len), 0); + char digest[65]; + snprintf(digest, sizeof(digest), "%s", cbm_test_config_digest(config)); + ASSERT_EQ(th_write_file(path, "{}"), 0); + cbm_test_policy_t *policy = cbm_test_policy_new(config); + ASSERT_NOT_NULL(policy); + cbm_test_config_free(config); + th_rmtree(dir); + ASSERT_STR_EQ(cbm_test_policy_digest(policy), digest); + ASSERT_TRUE(policy_matches(policy, "special/file.c", "mine")); + cbm_test_policy_free(policy); + len = 99; + absent = false; + ASSERT_NULL(cbm_test_config_source(NULL, &len, &absent)); + ASSERT_EQ(len, 0); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_policy_defaults_and_first_match_before_target_resolution) { + cbm_test_policy_t *policy = policy_of("{}"); + ASSERT_NOT_NULL(policy); + const char *full[] = {"Makefile.cbm", "sub/CMakeLists.txt", "go.mod", + "package-lock.json", ".github/workflows/ci.yml", "third_party/lib/x.c", + "vendor_lib/x.c", "tests/conftest.py"}; + for (size_t i = 0; i < sizeof(full) / sizeof(full[0]); i++) { + const cbm_test_rule_t *rule = NULL; + ASSERT_TRUE(cbm_test_policy_match_path(policy, full[i], &rule)); + ASSERT_NOT_NULL(rule); + ASSERT_TRUE(rule->builtin); + ASSERT_EQ(rule->action, CBM_TEST_RULE_RUN_ALL); + } + const cbm_test_rule_t *rule = NULL; + ASSERT_TRUE(cbm_test_policy_match_path(policy, "docs/guide.md", &rule)); + ASSERT_NOT_NULL(rule); + ASSERT_EQ(rule->action, CBM_TEST_RULE_IGNORE); + ASSERT_TRUE(policy_matches(policy, "src/ordinary.c", NULL)); + int count = 0; + const cbm_test_lane_t *lanes = cbm_test_policy_lanes(policy, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(lanes[0].name, "unit"); + ASSERT_TRUE(lanes[0].default_run); + ASSERT_TRUE(lanes[0].narrow); + bool selected = false; + ASSERT_TRUE(cbm_test_policy_lane_selects_suite(policy, 0, "alpha", false, &selected)); + ASSERT_TRUE(selected); + ASSERT_TRUE(cbm_test_policy_lane_selects_suite(policy, 0, "alpha", true, &selected)); + ASSERT_FALSE(selected); + cbm_test_policy_free(policy); + policy = policy_of( + "{\"test_impact\":{\"version\":1,\"rules\":[" + "{\"id\":\"first\",\"paths\":[\"docs/**\"],\"action\":\"suites\",\"suites\":[\"absent\"]}," + "{\"id\":\"later\",\"paths\":[\"**\"],\"action\":\"run_all\"}]}}"); + ASSERT_NOT_NULL(policy); + ASSERT_TRUE(policy_matches(policy, "docs/guide.md", "first")); + ASSERT_TRUE(policy_matches(policy, "src/x.c", "later")); + rule = cbm_test_policy_rules(policy, &count); + ASSERT_TRUE(count > 2); + ASSERT_EQ(rule[0].action, CBM_TEST_RULE_SUITES); + ASSERT_FALSE(rule[0].builtin); + ASSERT_EQ(rule[0].key_segment, -1); + ASSERT_STR_EQ(rule[0].suites[0], "absent"); + cbm_test_policy_free(policy); + PASS(); +} + +TEST(test_policy_lanes_and_semantic_perf_membership) { + cbm_test_policy_t *policy = policy_of( + "{\"test_impact\":{\"version\":1," + "\"lanes\":[{\"name\":\"fast\",\"suites\":[\"alpha*\",\"@perf\"]," + "\"exclude_suites\":[\"alpha_slow\"],\"signals\":[\"lang\"],\"default\":\"skip\"}," + "{\"name\":\"all\",\"suites\":[\"*\"],\"default\":\"run\",\"narrow\":true}]," + "\"rules\":[{\"id\":\"lane\",\"paths\":[\"src/" + "**\"],\"action\":\"lanes\",\"lanes\":[\"fast\"]}," + "{\"id\":\"suite\",\"paths\":[\"perf/**\"],\"action\":\"suites\",\"suites\":[\"@perf\"]}," + "{\"id\":\"refs\",\"paths\":[\"fixtures/" + "**\"],\"action\":\"referencing_tests\",\"key\":\"path_segment:1\"}]}}"); + ASSERT_NOT_NULL(policy); + int count = 0; + const cbm_test_lane_t *lanes = cbm_test_policy_lanes(policy, &count); + ASSERT_EQ(count, 2); + ASSERT_FALSE(lanes[0].default_run); + ASSERT_FALSE(lanes[0].narrow); + ASSERT_STR_EQ(lanes[0].signals[0], "lang"); + bool selected = false; + ASSERT_TRUE(cbm_test_policy_lane_selects_suite(policy, 0, "alpha", false, &selected)); + ASSERT_TRUE(selected); + ASSERT_TRUE(cbm_test_policy_lane_selects_suite(policy, 0, "alpha_slow", true, &selected)); + ASSERT_FALSE(selected); + ASSERT_TRUE(cbm_test_policy_lane_selects_suite(policy, 0, "bench", true, &selected)); + ASSERT_TRUE(selected); + ASSERT_TRUE(cbm_test_policy_lane_selects_suite(policy, 0, "bench", false, &selected)); + ASSERT_FALSE(selected); + ASSERT_TRUE(cbm_test_policy_rule_selects_suite(policy, 1, "bench", true, &selected)); + ASSERT_TRUE(selected); + ASSERT_TRUE(cbm_test_policy_rule_selects_suite(policy, 1, "@perf", false, &selected)); + ASSERT_FALSE(selected); + const cbm_test_rule_t *rules = cbm_test_policy_rules(policy, &count); + ASSERT_STR_EQ(rules[0].lanes[0], "fast"); + ASSERT_EQ(rules[2].key_segment, 1); + cbm_test_policy_free(policy); + PASS(); +} + +TEST(test_policy_globs_braces_escapes_and_directory_boundaries) { + cbm_test_policy_t *policy = policy_of( + "{\"test_impact\":{\"version\":1,\"rules\":[" + "{\"id\":\"alt\",\"paths\":[\"/{src,{lib,pkg}}/{a,b}{1,2}.c\"],\"action\":\"ignore\"}," + "{\"id\":\"literal\",\"paths\":[\"literal/\\\\*.c\",\"literal/" + "\\\\{a\\\\,b\\\\}.c\",\"literal/{word}.c\",\"literal/[{},].c\"],\"action\":\"ignore\"}," + "{\"id\":\"dir\",\"paths\":[\"fixtures/\",\"/rooted/\"],\"action\":\"ignore\"}," + "{\"id\":\"class\",\"paths\":[\"x[.-0]y\",\"**/case?.[ch]\"],\"action\":\"ignore\"}]}}"); + ASSERT_NOT_NULL(policy); + const char *alternatives[] = {"src/a1.c", "src/b2.c", "lib/b1.c", "pkg/a2.c"}; + for (size_t i = 0; i < sizeof(alternatives) / sizeof(alternatives[0]); i++) + ASSERT_TRUE(policy_matches(policy, alternatives[i], "alt")); + ASSERT_TRUE(policy_matches(policy, "other/src/a1.c", NULL)); + ASSERT_TRUE(policy_matches(policy, "literal/*.c", "literal")); + ASSERT_TRUE(policy_matches(policy, "literal/{a,b}.c", "literal")); + ASSERT_TRUE(policy_matches(policy, "literal/{word}.c", "literal")); + ASSERT_TRUE(policy_matches(policy, "literal/,.c", "literal")); + ASSERT_TRUE(policy_matches(policy, "literal/normal.c", NULL)); + ASSERT_TRUE(policy_matches(policy, "fixtures/a/b.c", "dir")); + ASSERT_TRUE(policy_matches(policy, "nested/fixtures/a.c", "dir")); + ASSERT_TRUE(policy_matches(policy, "rooted/a.c", "dir")); + ASSERT_TRUE(policy_matches(policy, "nested/rooted/a.c", NULL)); + ASSERT_TRUE(policy_matches(policy, "fixtures", NULL)); + ASSERT_TRUE(policy_matches(policy, "x/y", NULL)); + ASSERT_TRUE(policy_matches(policy, "x.y", "class")); + ASSERT_TRUE(policy_matches(policy, "case1.c", "class")); + ASSERT_TRUE(policy_matches(policy, "a/b/case2.h", "class")); + cbm_test_policy_free(policy); + PASS(); +} + +TEST(test_policy_rejects_invalid_consumed_fields_without_changing_seed_loader) { + const char *bad[] = { + "\"rules\":null", + "\"rules\":[],\"rules\":[]", + "\"lanes\":[]", + "\"lanes\":{}", + "\"unmapped_files\":\"ignore\"", + "\"unmapped_files\":\"run_all\",\"unmapped_files\":\"run_all\"", + "\"rules\":[{\"id\":\"a\",\"id\":\"b\",\"paths\":[\"*\"],\"action\":\"ignore\"}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"*\"],\"action\":\"unknown\"}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[],\"action\":\"ignore\"}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"!x\"],\"action\":\"ignore\"}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"x{a,b\"],\"action\":\"ignore\"}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"x}a,b\"],\"action\":\"ignore\"}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"{,}\"],\"action\":\"ignore\"}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"*\"],\"action\":\"ignore\",\"suites\":[\"a\"]}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"*\"],\"action\":\"suites\",\"suites\":[]}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"*\"],\"action\":\"lanes\",\"lanes\":[\"undeclared\"]" + "}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"*\"],\"action\":\"referencing_tests\",\"key\":" + "\"path_segment:-1\"}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"*\"],\"action\":\"referencing_tests\",\"key\":" + "\"path_segment:1x\"}]", + "\"rules\":[{\"id\":\"a\",\"paths\":[\"*\"],\"action\":\"ignore\"},{\"id\":\"a\",\"paths\":" + "[\"q\"],\"action\":\"run_all\"}]", + "\"rules\":[{\"id\":\"builtin:build\",\"paths\":[\"*\"],\"action\":\"ignore\"}]", + "\"lanes\":[{\"name\":\"a\",\"suites\":[\"*\"]}]", + "\"lanes\":[{\"name\":\"a\",\"suites\":[\"*\"],\"default\":\"maybe\"}]", + "\"lanes\":[{\"name\":\"a\",\"suites\":[\"*\"],\"default\":\"run\",\"narrow\":1}]", + "\"lanes\":[{\"name\":\"a\",\"suites\":[\"*\"],\"default\":\"run\"},{\"name\":\"a\"," + "\"suites\":[\"*\"],\"default\":\"skip\"}]"}; + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + char json[2048]; + snprintf(json, sizeof(json), "{\"test_impact\":{\"version\":1,%s}}", bad[i]); + char dir[512], path[600]; + snprintf(dir, sizeof(dir), "%s/cbm-impact-policy-invalid-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(dir)); + snprintf(path, sizeof(path), "%s/config.json", dir); + ASSERT_EQ(th_write_file(path, json), 0); + cbm_test_config_t *config = cbm_test_config_load(path, false); + ASSERT_NOT_NULL(config); + cbm_test_policy_t *policy = cbm_test_policy_new(config); + bool accepted = policy != NULL; + cbm_test_policy_free(policy); + cbm_test_config_free(config); + th_rmtree(dir); + if (accepted) + printf(" invalid policy %zu accepted\n", i); + ASSERT_FALSE(accepted); + } + cbm_test_policy_t *policy = policy_of("{\"test_impact\":{\"version\":1,\"future\":null," + "\"rules\":[],\"unmapped_files\":\"run_all\"}}"); + ASSERT_NOT_NULL(policy); + cbm_test_policy_free(policy); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_policy_expansion_limits_reject_whole_policy) { + char json[4096], pattern[2048]; + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + const char *prefix = + "{\"test_impact\":{\"version\":1,\"rules\":[{\"id\":\"limit\",\"paths\":[\""; + const char *suffix = "\"],\"action\":\"ignore\"}]}}"; + /* 8192 expansions; bounded source size must not permit combinatorial work. */ + pattern[0] = '\0'; + for (int i = 0; i < 13; i++) + strcat(pattern, "{a,b}"); + snprintf(json, sizeof(json), "%s%s%s", prefix, pattern, suffix); + ASSERT_NULL(policy_of(json)); + /* 1024 alternatives of >300 bytes exceeds expanded-byte cap. */ + memset(pattern, 'x', 300); + pattern[300] = '\0'; + for (int i = 0; i < 10; i++) + strcat(pattern, "{a,b}"); + snprintf(json, sizeof(json), "%s%s%s", prefix, pattern, suffix); + ASSERT_NULL(policy_of(json)); + int n = 0; + for (int i = 0; i < 17; i++) + pattern[n++] = '{'; + pattern[n++] = 'x'; + for (int i = 0; i < 17; i++) { + pattern[n++] = ','; + pattern[n++] = 'y'; + pattern[n++] = '}'; + } + pattern[n] = '\0'; + snprintf(json, sizeof(json), "%s%s%s", prefix, pattern, suffix); + ASSERT_NULL(policy_of(json)); + /* Many adjacent groups with one empty branch are legal within the caps. */ + snprintf(json, sizeof(json), "%sprefix{,a}{,b}%s", prefix, suffix); + cbm_test_policy_t *policy = policy_of(json); + ASSERT_NOT_NULL(policy); + ASSERT_TRUE(policy_matches(policy, "prefix", "limit")); + ASSERT_TRUE(policy_matches(policy, "prefixab", "limit")); + cbm_test_policy_free(policy); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_policy_evaluation_errors_clear_outputs_and_never_fall_through) { + char json[2048], pattern[401]; + memset(pattern, '?', 400); + pattern[400] = '\0'; + snprintf( + json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"rules\":[{\"id\":\"costly\",\"paths\":[\"%s\"]," + "\"action\":\"run_all\"},{\"id\":\"later\",\"paths\":[\"*\"],\"action\":\"ignore\"}]}}", + pattern); + cbm_test_policy_t *policy = policy_of(json); + ASSERT_NOT_NULL(policy); + char *path = malloc(65538); + ASSERT_NOT_NULL(path); + memset(path, 'a', 65537); + path[65536] = '\0'; + const cbm_test_rule_t *rule = (const cbm_test_rule_t *)policy; + ASSERT_FALSE(cbm_test_policy_match_path(policy, path, &rule)); + ASSERT_NULL(rule); + path[65536] = 'a'; + path[65537] = '\0'; + rule = (const cbm_test_rule_t *)policy; + ASSERT_FALSE(cbm_test_policy_match_path(policy, path, &rule)); + ASSERT_NULL(rule); + rule = (const cbm_test_rule_t *)policy; + ASSERT_FALSE(cbm_test_policy_match_path(NULL, "x", &rule)); + ASSERT_NULL(rule); + rule = (const cbm_test_rule_t *)policy; + ASSERT_FALSE(cbm_test_policy_match_path(policy, NULL, &rule)); + ASSERT_NULL(rule); + bool selected = true; + ASSERT_FALSE(cbm_test_policy_lane_selects_suite(policy, 99, "a", false, &selected)); + ASSERT_FALSE(selected); + selected = true; + ASSERT_FALSE(cbm_test_policy_rule_selects_suite(policy, 0, "a", false, &selected)); + ASSERT_FALSE(selected); + cbm_test_policy_free(policy); + snprintf(json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"lanes\":[{\"name\":\"costly\"," + "\"suites\":[\"*\"],\"exclude_suites\":[\"%s\"],\"default\":\"run\"}]," + "\"rules\":[{\"id\":\"costly\",\"paths\":[\"src/**\"]," + "\"action\":\"suites\",\"suites\":[\"%s\"]}]}}", + pattern, pattern); + policy = policy_of(json); + ASSERT_NOT_NULL(policy); + path[65536] = '\0'; + selected = true; + ASSERT_FALSE(cbm_test_policy_lane_selects_suite(policy, 0, path, false, &selected)); + ASSERT_FALSE(selected); /* inclusion succeeded before exclusion exhausted */ + selected = true; + ASSERT_FALSE(cbm_test_policy_rule_selects_suite(policy, 0, path, false, &selected)); + ASSERT_FALSE(selected); + free(path); + cbm_test_policy_free(policy); + PASS(); +} + +TEST(test_policy_embedded_stars_do_not_cross_or_remove_separators) { + cbm_test_policy_t *policy = policy_of( + "{\"test_impact\":{\"version\":1,\"rules\":[" + "{\"id\":\"embedded\",\"paths\":[\"/src/a**b.c\",\"/a**/b\"],\"action\":\"ignore\"}," + "{\"id\":\"segments\",\"paths\":[\"/lib/**/x.c\",\"/deep/**\"],\"action\":\"run_all\"}]}}"); + ASSERT_NOT_NULL(policy); + bool nested = policy_matches(policy, "src/a/x/b.c", NULL); + bool removed = policy_matches(policy, "ab", NULL); + ASSERT_TRUE(policy_matches(policy, "src/aXXb.c", "embedded")); + ASSERT_TRUE(policy_matches(policy, "aXX/b", "embedded")); + ASSERT_TRUE(policy_matches(policy, "lib/x.c", "segments")); + ASSERT_TRUE(policy_matches(policy, "lib/a/b/x.c", "segments")); + ASSERT_TRUE(policy_matches(policy, "deep/a/b.c", "segments")); + cbm_test_policy_free(policy); + ASSERT_TRUE(nested); + ASSERT_TRUE(removed); + PASS(); +} + +/* The common declaration snapshot is data, not a lossy six-macro adapter. */ +static cbm_test_declarations_t *declarations_of(const char *json) { + return cbm_test_declarations_parse(json, strlen(json), false); +} + +TEST(test_declarations_defaults_preserve_absent_and_configured_identity) { + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + cbm_test_declarations_t *missing = cbm_test_declarations_parse(NULL, 0, true); + ASSERT_NOT_NULL(missing); + ASSERT_FALSE(cbm_test_declarations_configured(missing)); + ASSERT_STR_EQ(cbm_test_declarations_digest(missing), + "f79ea6058b0b9bc5faea14c64282c1dece1b88816fbcbbd021dd88e611078c93"); + int count = 99; + (void)cbm_test_declarations_items(missing, &count); + ASSERT_EQ(count, 0); + for (int i = 0; i < CBM_TEST_PRESET_COUNT; i++) { + bool enabled = false; + ASSERT_TRUE(cbm_test_declarations_preset(missing, (cbm_test_preset_t)i, &enabled)); + ASSERT_EQ(enabled, i != CBM_TEST_PRESET_C_CBM); + } + cbm_test_declarations_t *empty = declarations_of("{}"); + ASSERT_NOT_NULL(empty); + ASSERT_FALSE(cbm_test_declarations_configured(empty)); + ASSERT_STR_EQ(cbm_test_declarations_digest(empty), + "30532ab60754ce0077e67d2967daf1bbd2047ad2ed8bd1767374f71ebd916f8c"); + cbm_test_declarations_t *configured = + declarations_of("{ \"test_impact\" : {\"version\":1} }\n"); + ASSERT_NOT_NULL(configured); + ASSERT_TRUE(cbm_test_declarations_configured(configured)); + ASSERT_STR_EQ(cbm_test_declarations_digest(configured), + "928be92116c30e969a1769755e9fd1206501aa39311ea21e202ea9ded955dded"); + cbm_test_declarations_free(missing); + cbm_test_declarations_free(empty); + cbm_test_declarations_free(configured); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_declarations_preserve_all_roles_arguments_and_owned_strings) { + const char *json = + "{\"test_impact\":{\"version\":1,\"tests\":{" + "\"presets\":{\"c-cbm\":true,\"pytest\":false},\"conventions\":[" + "{\"language\":\"cpp\",\"role\":\"case\",\"define_macro\":\"SPEC_CASE\",\"name_args\":[1,0," + "1],\"runner_id\":\"{suite}/{name}\"}," + "{\"language\":\"c\",\"role\":\"suite\",\"define_macro\":\"GROUP\",\"name_args\":[0]}," + "{\"language\":\"c\",\"role\":\"registration\",\"macro\":\"EXEC_CASE\",\"test_arg\":1}," + "{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":\"EXEC_GROUP\",\"suite_" + "arg\":2,\"perf_macro\":\"EXEC_PERF\"}," + "{\"language\":\"custom-lang\",\"role\":\"case\",\"define_macro\":\"CUSTOM\",\"name_args\":" + "[0]}," + "{\"language\":\"custom-lang\",\"role\":\"case\",\"define_macro\":\"OPAQUE\",\"name_args\":" + "[0],\"runner_id\":\"external::{unsupported}/%s\"}]}}}"; + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), len = strlen(json); + char *input = malloc(len); + ASSERT_NOT_NULL(input); + memcpy(input, json, len); + cbm_test_declarations_t *d = cbm_test_declarations_parse(input, len, false); + memset(input, '?', len); + free(input); + ASSERT_NOT_NULL(d); + int count = 0; + const cbm_test_declaration_t *items = cbm_test_declarations_items(d, &count); + ASSERT_EQ(count, 6); + ASSERT_NOT_NULL(items); + ASSERT_STR_EQ(items[0].language, "cpp"); + ASSERT_EQ(items[0].role, CBM_TEST_DECL_CASE); + ASSERT_STR_EQ(items[0].define_macro, "SPEC_CASE"); + ASSERT_EQ(items[0].name_arg_count, 3); + ASSERT_EQ(items[0].name_args[0], 1); + ASSERT_EQ(items[0].name_args[1], 0); + ASSERT_EQ(items[0].name_args[2], 1); + ASSERT_STR_EQ(items[0].runner_id, "{suite}/{name}"); + ASSERT_NULL(items[0].macro); + ASSERT_EQ(items[0].test_arg, -1); + ASSERT_EQ(items[0].suite_arg, -1); + ASSERT_EQ(items[1].role, CBM_TEST_DECL_SUITE); + ASSERT_STR_EQ(items[1].define_macro, "GROUP"); + ASSERT_NULL(items[1].runner_id); + ASSERT_EQ(items[1].name_arg_count, 1); + ASSERT_EQ(items[2].role, CBM_TEST_DECL_REGISTRATION); + ASSERT_STR_EQ(items[2].macro, "EXEC_CASE"); + ASSERT_EQ(items[2].test_arg, 1); + ASSERT_EQ(items[2].suite_arg, -1); + ASSERT_EQ(items[2].name_arg_count, 0); + ASSERT_NULL(items[2].define_macro); + ASSERT_EQ(items[3].role, CBM_TEST_DECL_SUITE_REGISTRATION); + ASSERT_EQ(items[3].suite_arg, 2); + ASSERT_EQ(items[3].test_arg, -1); + ASSERT_STR_EQ(items[3].perf_macro, "EXEC_PERF"); + ASSERT_STR_EQ(items[4].language, "custom-lang"); + ASSERT_NULL(items[4].runner_id); + ASSERT_STR_EQ(items[5].runner_id, "external::{unsupported}/%s"); + bool enabled = false; + ASSERT_TRUE(cbm_test_declarations_preset(d, CBM_TEST_PRESET_C_CBM, &enabled)); + ASSERT_TRUE(enabled); + ASSERT_TRUE(cbm_test_declarations_preset(d, CBM_TEST_PRESET_PYTEST, &enabled)); + ASSERT_FALSE(enabled); + ASSERT_TRUE(cbm_test_declarations_preset(d, CBM_TEST_PRESET_GTEST, &enabled)); + ASSERT_TRUE(enabled); + cbm_test_declarations_free(d); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_declarations_independent_snapshots_match_loaded_config_digest) { + const char *json = + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"go\":false,\"c-cbm\":true}," + "\"conventions\":[{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"A\",\"name_" + "args\":[0]}]}}}"; + char dir[512], path[600]; + snprintf(dir, sizeof(dir), "%s/cbm-declarations-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(dir)); + snprintf(path, sizeof(path), "%s/config.json", dir); + ASSERT_EQ(th_write_file(path, json), 0); + cbm_test_config_t *config = cbm_test_config_load(path, false); + ASSERT_NOT_NULL(config); + size_t len = 0; + bool absent = false; + const char *source = cbm_test_config_source(config, &len, &absent); + cbm_test_declarations_t *first = cbm_test_declarations_parse(source, len, absent); + ASSERT_NOT_NULL(first); + ASSERT_STR_EQ(cbm_test_declarations_digest(first), cbm_test_config_digest(config)); + cbm_test_config_free(config); + th_rmtree(dir); + cbm_test_declarations_t *second = declarations_of( + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"pytest\":false}}}}"); + ASSERT_NOT_NULL(second); + bool enabled = false; + ASSERT_TRUE(cbm_test_declarations_preset(second, CBM_TEST_PRESET_GO, &enabled)); + ASSERT_TRUE(enabled); + ASSERT_TRUE(cbm_test_declarations_preset(first, CBM_TEST_PRESET_GO, &enabled)); + ASSERT_FALSE(enabled); + ASSERT_TRUE(cbm_test_declarations_preset(first, CBM_TEST_PRESET_PYTEST, &enabled)); + ASSERT_TRUE(enabled); + cbm_test_declarations_free(second); + int count = 0; + const cbm_test_declaration_t *items = cbm_test_declarations_items(first, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0].define_macro, "A"); + cbm_test_declarations_free(first); + PASS(); +} + +TEST(test_declarations_invalid_consumed_structure_fails_without_partial_result) { + const char *bad[] = { + "", + "[]", + "{", + "{\"test_impact\":null}", + "{\"test_impact\":{}}", + "{\"test_impact\":{\"version\":1.0}}", + "{\"test_impact\":{\"version\":2}}", + "{\"test_impact\":{\"version\":1},\"test_impact\":{\"version\":1}}", + "{\"test_impact\":{\"version\":1,\"version\":1}}", + "{\"test_impact\":{\"version\":1,\"tests\":null}}", + "{\"test_impact\":{\"version\":1,\"tests\":{},\"tests\":{}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":null}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[],\"conventions\":[]}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":[]}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{},\"presets\":{}}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"go\":0}}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"go\":false,\"go\":true}}}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"unsupported\":true}}}}"}; + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + cbm_test_declarations_t *d = declarations_of(bad[i]); + bool accepted = d != NULL; + cbm_test_declarations_free(d); + if (accepted) + printf(" invalid declarations structure %zu accepted\n", i); + ASSERT_FALSE(accepted); + } + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + cbm_test_declarations_t *d = + declarations_of("{\"test_impact\":{\"version\":1,\"future\":null,\"tests\":{\"future\":42," + "\"conventions\":[],\"presets\":{}}}}"); + ASSERT_NOT_NULL(d); + cbm_test_declarations_free(d); + PASS(); +} + +TEST(test_declarations_invalid_role_payloads_are_not_ignored) { + const char *bad[] = { + "null", + "{}", + "{\"language\":\"c\",\"role\":\"helper\"}", + "{\"language\":\"\",\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[0]}", + "{\"language\":\"c\\u0000x\",\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[0]" + "}", + "{\"language\":\"c\",\"language\":\"cpp\",\"role\":\"case\",\"define_macro\":\"TEST\"," + "\"name_args\":[0]}", + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"9TEST\",\"name_args\":[0]}", + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[]}", + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[-1]}", + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[1.0]}", + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[2147483648]" + "}", + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[0]," + "\"runner_id\":null}", + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[0],\"test_" + "arg\":0}", + "{\"language\":\"c\",\"role\":\"suite\",\"define_macro\":\"GROUP\",\"name_args\":[0]," + "\"runner_id\":\"x\"}", + "{\"language\":\"c\",\"role\":\"registration\",\"macro\":\"RUN\"}", + "{\"language\":\"c\",\"role\":\"registration\",\"macro\":\"RUN-TEST\",\"test_arg\":0}", + "{\"language\":\"c\",\"role\":\"registration\",\"macro\":\"RUN\",\"test_arg\":0,\"suite_" + "arg\":0}", + "{\"language\":\"c\",\"role\":\"registration\",\"macro\":\"RUN\",\"test_arg\":0,\"name_" + "args\":[0]}", + "{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":\"RUN_SUITE\",\"suite_arg\":" + "-1}", + "{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":\"RUN_SUITE\",\"suite_arg\":" + "0,\"perf_macro\":\"\"}", + "{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":\"RUN_SUITE\",\"suite_arg\":" + "0,\"suite_arg\":1}"}; + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + char json[4096]; + snprintf(json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[{\"language\":\"c\"," + "\"role\":\"case\",\"define_macro\":\"VALID\",\"name_args\":[0]},%s]}}}", + bad[i]); + cbm_test_declarations_t *d = declarations_of(json); + bool accepted = d != NULL; + cbm_test_declarations_free(d); + if (accepted) + printf(" invalid declaration payload %zu accepted\n", i); + ASSERT_FALSE(accepted); + } + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_declarations_exact_length_limits_and_safe_accessors) { + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + char *bytes = malloc(65537); + ASSERT_NOT_NULL(bytes); + memset(bytes, ' ', 65537); + bytes[0] = '{'; + bytes[1] = '}'; + cbm_test_declarations_t *d = cbm_test_declarations_parse(bytes, 65536, false); + ASSERT_NOT_NULL(d); + cbm_test_declarations_free(d); + ASSERT_NULL(cbm_test_declarations_parse(bytes, 65537, false)); + bytes[5] = '\0'; + ASSERT_NULL(cbm_test_declarations_parse(bytes, 65536, false)); + free(bytes); + ASSERT_NULL(cbm_test_declarations_parse(NULL, 1, false)); + ASSERT_NULL(cbm_test_declarations_parse(NULL, 0, false)); + ASSERT_NULL(cbm_test_declarations_parse("{}", 2, true)); + d = cbm_test_declarations_parse("{}trailing", 2, false); + ASSERT_NOT_NULL(d); + cbm_test_declarations_free(d); + ASSERT_NULL(cbm_test_declarations_parse("{}trailing", 10, false)); + bool enabled = true; + ASSERT_FALSE(cbm_test_declarations_preset(NULL, CBM_TEST_PRESET_GO, &enabled)); + ASSERT_FALSE(enabled); + d = declarations_of("{}"); + ASSERT_NOT_NULL(d); + enabled = true; + ASSERT_FALSE(cbm_test_declarations_preset(d, CBM_TEST_PRESET_COUNT, &enabled)); + ASSERT_FALSE(enabled); + enabled = true; + ASSERT_FALSE(cbm_test_declarations_preset(d, (cbm_test_preset_t)-1, &enabled)); + ASSERT_FALSE(enabled); + cbm_test_declarations_free(d); + int count = 77; + ASSERT_NULL(cbm_test_declarations_items(NULL, &count)); + ASSERT_EQ(count, 0); + ASSERT_FALSE(cbm_test_declarations_configured(NULL)); + ASSERT_STR_EQ(cbm_test_declarations_digest(NULL), ""); + cbm_test_declarations_free(NULL); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_declarations_argument_boundaries_and_order_are_preserved) { + char json[4096], args[1024]; + size_t used = 0; + for (int i = 0; i < 64; i++) + used += (size_t)snprintf(args + used, sizeof(args) - used, "%s%d", i ? "," : "", i); + snprintf(json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[{\"language\":\"c\"," + "\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[%s]}]}}}", + args); + cbm_test_declarations_t *d = declarations_of(json); + ASSERT_NOT_NULL(d); + int count = 0; + const cbm_test_declaration_t *items = cbm_test_declarations_items(d, &count); + ASSERT_EQ(count, 1); + ASSERT_EQ(items[0].name_arg_count, 64); + ASSERT_EQ(items[0].name_args[0], 0); + ASSERT_EQ(items[0].name_args[63], 63); + cbm_test_declarations_free(d); + strcat(args, ",64"); + snprintf(json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[{\"language\":\"c\"," + "\"role\":\"case\",\"define_macro\":\"TEST\",\"name_args\":[%s]}]}}}", + args); + ASSERT_NULL(declarations_of(json)); + d = declarations_of( + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[{\"language\":\"c\",\"role\":" + "\"registration\",\"macro\":\"RUN\",\"test_arg\":2147483647},{\"language\":\"c\",\"role\":" + "\"registration\",\"macro\":\"RUN\",\"test_arg\":0},{\"language\":\"c\",\"role\":" + "\"registration\",\"macro\":\"RUN\",\"test_arg\":0}]}}}"); + ASSERT_NOT_NULL(d); + items = cbm_test_declarations_items(d, &count); + ASSERT_EQ(count, 3); + ASSERT_EQ(items[0].test_arg, 2147483647); + ASSERT_EQ(items[1].test_arg, 0); + ASSERT_EQ(items[2].test_arg, 0); + ASSERT_STR_EQ(items[1].macro, items[2].macro); + cbm_test_declarations_free(d); + PASS(); +} + +TEST(test_model_completeness_requires_finished_success_and_stays_uncertain) { + ASSERT_FALSE(cbm_test_model_complete(NULL)); + cbm_test_model_t *model = cbm_test_model_new(cbm_test_conventions_cbm()); + ASSERT_NOT_NULL(model); + ASSERT_FALSE(cbm_test_model_complete(model)); + ASSERT_TRUE(cbm_test_model_add_source(model, "empty.c", "", 0)); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_TRUE(cbm_test_model_complete(model)); + cbm_test_model_free(model); + model = cbm_test_model_new(cbm_test_conventions_cbm()); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_add_source(model, "first.c", "/* unfinished", 13)); + ASSERT_TRUE( + cbm_test_model_add_source(model, "second.c", selection_source, strlen(selection_source))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + ASSERT_EQ(count_cases(model), 4); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_model_reports_truncated_and_unsupported_syntax) { + const char *uncertain[] = {"/* unfinished", + "const char *s = \"unfinished", + "char x = '\\", + "TEST(unfinished) {", + "SUITE(unfinished) {", + "#if FEATURE\nTEST(hidden) {}\n", + "#else\n", + "#endif\n", + "}\n", + "void helper(void) {\n", + "TEST(,bad) {}\n", + "TEST(a,b) {}\n", + "TEST(no_body);\n", + "SUITE(no_body);\n", + "void main(void) { RUN_SELECTED_SUITE(a,b); }\n", + "SUITE(alpha) { RUN_TEST(a,b); }\n", + "namespace nested { TEST(hidden) {} }\n", + "void helper(void) { TEST(hidden) {} }\n", + "const char *s = R\"tag(ordinary text)tag\";\n", + "const char *s = u8R\"tag(ordinary text)tag\";\n"}; + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + for (size_t i = 0; i < sizeof(uncertain) / sizeof(uncertain[0]); i++) { + tm_source_t sources[] = {{"tests/incomplete.c", uncertain[i]}}; + cbm_test_model_t *model = model_of(sources, 1); + ASSERT_NOT_NULL(model); + bool complete = cbm_test_model_complete(model); + cbm_test_model_free(model); + if (complete) + printf(" incomplete source %zu reported complete\n", i); + ASSERT_FALSE(complete); + } + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_model_complete_sources_keep_normal_declarations_and_literals) { + const char *source = + "/* TEST(fake) {} */\n" + "void suite_alpha(void);\nvoid helper(void);\n" + "const char *s = \"TEST(fake) {} and escaped \\\" quotes\";\n" + "char quote = '\\'';\n" + "#if FEATURE\nTEST(one) {}\n#else\nTEST(two) {}\n#endif\n" + "SUITE(alpha) {\n#if FEATURE\nRUN_TEST(one);\n#else\nRUN_TEST(two);\n#endif\n}\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n"; + cbm_test_model_t *model = selection_model(source); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_complete(model)); + ASSERT_EQ(count_cases(model), 2); + ASSERT_EQ(count_suites(model), 1); + ASSERT_EQ(count_registrations(model), 2); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_model_conditional_limit_is_explicit_and_deterministic) { + for (int nesting = 64; nesting <= 65; nesting++) { + char source[4096]; + size_t used = 0; + for (int i = 0; i < nesting; i++) + used += (size_t)snprintf(source + used, sizeof(source) - used, "#if FEATURE\n"); + used += (size_t)snprintf(source + used, sizeof(source) - used, "TEST(one) {}\n"); + for (int i = 0; i < nesting; i++) + used += (size_t)snprintf(source + used, sizeof(source) - used, "#endif\n"); + cbm_test_model_t *model = selection_model(source); + ASSERT_NOT_NULL(model); + bool complete = cbm_test_model_complete(model); + cbm_test_model_free(model); + ASSERT_EQ(complete, nesting == 64); + } + PASS(); +} + +TEST(test_selection_cannot_override_partial_model_with_complete_input_flag) { + const char *suffixes[] = {"/* unfinished", "#if FEATURE\n", "TEST(incomplete) {", + "namespace nested { TEST(hidden) {} }\n"}; + cbm_coverage_map_t *map = selection_map(selection_rows); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = {{"tests/cases.c", "stat", true, true, false}, + {"tests/cases.c", "cov", true, false, false}, + {"tests/cases.c", "changed", true, false, true}, + {"tests/cases.c", "quiet", true, false, false}}; + int changed[] = {0}; + for (size_t i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) { + char source[2048]; + snprintf(source, sizeof(source), "%s%s", selection_source, suffixes[i]); + cbm_test_model_t *model = selection_model(source); + ASSERT_NOT_NULL(model); + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + ASSERT_TRUE(input.inventory_complete); + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + unsigned flags = cbm_test_selection_run_all(result); + int count = 99; + cbm_test_selection_cases(result, &count); + cbm_test_selection_free(result); + cbm_test_model_free(model); + ASSERT_EQ(flags, CBM_TEST_SELECT_INVENTORY_UNKNOWN); + ASSERT_EQ(count, 0); + } + cbm_coverage_map_free(map); + PASS(); +} + +TEST(test_model_split_identifiers_cannot_hide_registrations) { + const char *joins[] = {"\\\n", "\\\r\n"}; + for (size_t i = 0; i < sizeof(joins) / sizeof(joins[0]); i++) { + char source[512]; + snprintf(source, sizeof(source), + "TEST(hidden) {}\nSUITE(alpha) { RUN_TE%sST(hidden); }\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n", + joins[i]); + cbm_test_model_t *model = selection_model(source); + ASSERT_NOT_NULL(model); + int count = 0; + cbm_test_model_registrations(model, &count); + bool safe = !cbm_test_model_complete(model) || count == 1; + cbm_test_model_free(model); + ASSERT_TRUE(safe); + } + PASS(); +} + +TEST(test_model_guard_like_conditions_cannot_certify_unconditional_cases) { + const char *sources[] = { + ("TEST(one) {}\nTEST(two) {}\nSUITE(alpha) {\n#ifndef SWITCH\n#define SWITCH\n" + "RUN_TEST(one);\n#else\nRUN_TEST(two);\n#endif\n}\n"), + "#ifndef GUARD\n#define GUARD\nTEST(one) {}\n#else\nTEST(two) {}\n#endif\n", + "#ifndef GUARD\n#define GUARD\nTEST(one) {}\n#elif FEATURE\nTEST(two) {}\n#endif\n"}; + for (size_t i = 0; i < sizeof(sources) / sizeof(sources[0]); i++) { + cbm_test_model_t *model = selection_model(sources[i]); + ASSERT_NOT_NULL(model); + bool safe = !cbm_test_model_complete(model); + if (!safe) { + int count = 0; + if (i == 0) { + const cbm_test_registration_t *items = cbm_test_model_registrations(model, &count); + safe = count == 2 && items[0].conditional && items[1].conditional; + } else { + const cbm_test_case_t *items = cbm_test_model_cases(model, &count); + safe = count == 2 && items[0].conditional && items[1].conditional; + } + } + cbm_test_model_free(model); + ASSERT_TRUE(safe); + } + PASS(); +} + +TEST(test_model_empty_elif_is_not_certified) { + const char *branches[] = {"#elif\n", "#elif /* no expression */\n", "#elif // empty\n"}; + for (size_t i = 0; i < sizeof(branches) / sizeof(branches[0]); i++) { + char source[256]; + snprintf(source, sizeof(source), "#if FEATURE\n%s#endif\n", branches[i]); + cbm_test_model_t *model = selection_model(source); + ASSERT_NOT_NULL(model); + bool complete = cbm_test_model_complete(model); + cbm_test_model_free(model); + ASSERT_FALSE(complete); + } + PASS(); +} + +TEST(test_model_null_empty_source_is_safe) { + cbm_test_model_t *model = cbm_test_model_new(cbm_test_conventions_cbm()); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_add_source(model, "empty.c", NULL, 0)); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_TRUE(cbm_test_model_complete(model)); + cbm_test_model_free(model); + PASS(); +} + +static cbm_test_model_t *descriptor_model(const char *json) { + cbm_test_declarations_t *d = declarations_of(json); + if (!d) + return NULL; + cbm_test_model_t *model = cbm_test_model_new_declarations(d); + cbm_test_declarations_free(d); + return model; +} + +static const char *descriptor_config = + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[" + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CASE_AT\",\"name_args\":[1],\"runner_" + "id\":\"{suite}:{name}\"}," + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"OTHER_CASE\",\"name_args\":[0]," + "\"runner_id\":\"{suite}:{name}\"}," + "{\"language\":\"c\",\"role\":\"suite\",\"define_macro\":\"GROUP_AT\",\"name_args\":[1]}," + "{\"language\":\"c\",\"role\":\"registration\",\"macro\":\"USE_AT\",\"test_arg\":1}," + "{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":\"ENTER_AT\",\"suite_arg\":1," + "\"perf_macro\":\"ENTER_PERF\"}]}}}"; + +TEST(test_model_descriptors_preserve_owned_records_and_multiple_spellings) { + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + cbm_test_model_t *model = descriptor_model(descriptor_config); + ASSERT_NOT_NULL(model); + char source[] = + "CASE_AT(call(a,b),first) {}\nOTHER_CASE(second) {}\n" + "GROUP_AT((a,b),alpha) { USE_AT(\"ignore,comma\",first); USE_AT(array[1],second); }\n" + "void main(void) { ENTER_AT((a,b),alpha); ENTER_PERF(ignored,alpha); }\n"; + ASSERT_TRUE( + cbm_test_model_add_source_language(model, "tests/custom.c", "c", source, strlen(source))); + memset(source, 'x', sizeof(source) - 1); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_TRUE(cbm_test_model_complete(model)); + ASSERT_EQ(cbm_test_model_mapping_status(model, NULL), CBM_TEST_MODEL_MAPPING_OK); + int n = 0; + const cbm_test_declaration_t *d = cbm_test_model_declarations(model, &n); + ASSERT_EQ(n, 5); + ASSERT_STR_EQ(d[0].define_macro, "CASE_AT"); + ASSERT_EQ(d[0].name_args[0], 1); + ASSERT_STR_EQ(d[0].runner_id, "{suite}:{name}"); + ASSERT_STR_EQ(d[4].perf_macro, "ENTER_PERF"); + const cbm_test_case_t *cases = cbm_test_model_cases(model, &n); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(cases[0].name, "first"); + ASSERT_STR_EQ(cases[1].name, "second"); + ASSERT_EQ(count_suites(model), 1); + ASSERT_EQ(count_registrations(model), 2); + const cbm_test_runner_suite_t *runner = cbm_test_model_runner_suites(model, &n); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(runner[0].name, "alpha"); + ASSERT_TRUE(runner[0].perf); + cbm_test_model_free(model); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA), before); + PASS(); +} + +TEST(test_model_descriptors_apply_only_to_explicit_source_language) { + const char *json = + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"gtest\":false},\"conventions\":[" + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"C_CASE\",\"name_args\":[0]," + "\"runner_id\":\"{suite}:{name}\"}," + "{\"language\":\"cpp\",\"role\":\"case\",\"define_macro\":\"CPP_CASE\",\"name_args\":[0]," + "\"runner_id\":\"{suite}:{name}\"}," + "{\"language\":\"cuda\",\"role\":\"case\",\"define_macro\":\"CUDA_CASE\",\"name_args\":[0]," + "\"runner_id\":\"{suite}:{name}\"}]}}}"; + cbm_test_model_t *model = descriptor_model(json); + ASSERT_NOT_NULL(model); + const char *source = "C_CASE(c_one) {} CPP_CASE(cpp_one) {} CUDA_CASE(cuda_one) {}"; + ASSERT_TRUE(cbm_test_model_add_source_language(model, "one.h", "C", source, strlen(source))); + ASSERT_TRUE(cbm_test_model_add_source_language(model, "two.h", "C++", source, strlen(source))); + ASSERT_TRUE( + cbm_test_model_add_source_language(model, "three.h", "CUDA", source, strlen(source))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_TRUE(cbm_test_model_complete(model)); + int n = 0; + const cbm_test_case_t *cases = cbm_test_model_cases(model, &n); + ASSERT_EQ(n, 3); + ASSERT_STR_EQ(cases[0].file, "one.h"); + ASSERT_STR_EQ(cases[0].name, "c_one"); + ASSERT_STR_EQ(cases[1].file, "three.h"); + ASSERT_STR_EQ(cases[1].name, "cuda_one"); + ASSERT_STR_EQ(cases[2].file, "two.h"); + ASSERT_STR_EQ(cases[2].name, "cpp_one"); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_model_descriptors_keep_unrenderable_names_and_templates_explicit) { + const char *payloads[] = { + "\"language\":\"c\",\"name_args\":[1,0,1],\"runner_id\":\"{suite}:{name}\"", + "\"language\":\"c\",\"name_args\":[0],\"runner_id\":\"opaque({unknown})\"", + "\"language\":\"c\",\"name_args\":[0]", + "\"language\":\"unknown-lang\",\"name_args\":[0],\"runner_id\":\"{suite}:{name}\""}; + cbm_test_model_mapping_status_t expected[] = { + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_NAME, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_TEMPLATE, + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_TEMPLATE, CBM_TEST_MODEL_MAPPING_UNSUPPORTED_LANGUAGE}; + for (size_t i = 0; i < sizeof(payloads) / sizeof(payloads[0]); i++) { + char json[1024]; + snprintf(json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[{\"role\":\"case\"," + "\"define_macro\":\"CASE\",%s}]}}}", + payloads[i]); + cbm_test_model_t *model = descriptor_model(json); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/config.c", "c", "", 0)); + cbm_test_model_mapping_issue_t issue = {0}; + ASSERT_EQ(cbm_test_model_mapping_status(model, &issue), expected[i]); + ASSERT_EQ(issue.reason, expected[i]); + ASSERT_EQ(issue.declaration_index, 0); + int n = 0; + const cbm_test_declaration_t *d = cbm_test_model_declarations(model, &n); + ASSERT_EQ(n, 1); + if (i == 0) { + ASSERT_EQ(d[0].name_arg_count, 3); + ASSERT_EQ(d[0].name_args[0], 1); + ASSERT_EQ(d[0].name_args[1], 0); + ASSERT_EQ(d[0].name_args[2], 1); + } + if (i == 1) + ASSERT_STR_EQ(d[0].runner_id, "opaque({unknown})"); + if (i == 2) + ASSERT_NULL(d[0].runner_id); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + cbm_test_model_free(model); + } + PASS(); +} + +TEST(test_model_descriptors_duplicates_are_idempotent_and_conflicts_never_narrow) { + for (int conflict = 0; conflict <= 1; conflict++) + for (int reverse = 0; reverse <= 1; reverse++) { + int a = reverse ? conflict : 0, b = reverse ? 0 : conflict; + char json[2048]; + snprintf(json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[" + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CASE\",\"name_" + "args\":[%d],\"runner_id\":\"{suite}:{name}\"}," + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CASE\",\"name_" + "args\":[%d],\"runner_id\":\"{suite}:{name}\"}]}}}", + a, b); + cbm_test_model_t *model = descriptor_model(json); + ASSERT_NOT_NULL(model); + const char *source = "CASE(one,two) {}"; + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/custom.c", "c", source, + strlen(source))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_EQ(cbm_test_model_complete(model), conflict == 0); + ASSERT_EQ(cbm_test_model_mapping_status(model, NULL), + conflict ? CBM_TEST_MODEL_MAPPING_AMBIGUOUS : CBM_TEST_MODEL_MAPPING_OK); + int n = 0; + cbm_test_model_declarations(model, &n); + ASSERT_EQ(n, 2); + if (!conflict) + ASSERT_EQ(count_cases(model), 1); + cbm_test_model_free(model); + } + PASS(); +} + +TEST(test_model_descriptors_argument_failures_have_owned_source_diagnostics) { + const char *sources[] = {"CASE_AT(one) {}", "CASE_AT(ignore,make_name()) {}", + "CASE_AT(ignore,unterminated"}; + cbm_test_model_mapping_status_t expected[] = {CBM_TEST_MODEL_MAPPING_MISSING_ARGUMENT, + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_ARGUMENT, + CBM_TEST_MODEL_MAPPING_MALFORMED_INVOCATION}; + for (size_t i = 0; i < sizeof(sources) / sizeof(sources[0]); i++) { + cbm_test_model_t *model = descriptor_model(descriptor_config); + ASSERT_NOT_NULL(model); + char file[] = "tests/malformed.c"; + ASSERT_TRUE( + cbm_test_model_add_source_language(model, file, "c", sources[i], strlen(sources[i]))); + memset(file, 'x', sizeof(file) - 1); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + cbm_test_model_mapping_issue_t issue = {0}; + ASSERT_EQ(cbm_test_model_mapping_status(model, &issue), expected[i]); + ASSERT_STR_EQ(issue.file, "tests/malformed.c"); + ASSERT_EQ(issue.line, 1); + ASSERT_EQ(issue.declaration_index, 0); + cbm_test_model_free(model); + } + PASS(); +} + +TEST(test_model_descriptors_registration_links_stay_in_their_source_file) { + cbm_test_model_t *model = descriptor_model(descriptor_config); + ASSERT_NOT_NULL(model); + const char *a = "OTHER_CASE(same) {} GROUP_AT(ignore,alpha) { USE_AT(ignore,same); }"; + const char *b = "GROUP_AT(ignore,beta) { USE_AT(ignore,same); }"; + const char *c = "OTHER_CASE(same) {} GROUP_AT(ignore,gamma) { USE_AT(ignore,same); }"; + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/b.c", "c", b, strlen(b))); + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/a.c", "c", a, strlen(a))); + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/c.c", "c", c, strlen(c))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_TRUE(cbm_test_model_complete(model)); + int n = 0; + const cbm_test_registration_t *regs = cbm_test_model_registrations(model, &n); + ASSERT_EQ(n, 3); + ASSERT_EQ(count_cases(model), 2); + ASSERT_STR_EQ(regs[0].file, "tests/a.c"); + ASSERT_TRUE(regs[0].resolved); + ASSERT_STR_EQ(regs[1].file, "tests/b.c"); + ASSERT_FALSE(regs[1].resolved); + ASSERT_STR_EQ(regs[2].file, "tests/c.c"); + ASSERT_TRUE(regs[2].resolved); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_model_descriptors_presets_are_explicit_and_native_gaps_are_visible) { + const char *configs[] = { + "{\"test_impact\":{\"version\":1}}", + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"c-cbm\":true}}}}"}; + for (int enabled = 0; enabled <= 1; enabled++) { + cbm_test_model_t *model = descriptor_model(configs[enabled]); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/cases.c", "c", + selection_source, strlen(selection_source))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_TRUE(cbm_test_model_complete(model)); + ASSERT_EQ(count_cases(model), enabled ? 4 : 0); + ASSERT_EQ(count_suites(model), enabled ? 1 : 0); + cbm_test_model_free(model); + } + cbm_test_model_t *model = descriptor_model(configs[0]); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/test.cpp", "cpp", "", 0)); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + ASSERT_EQ(cbm_test_model_mapping_status(model, NULL), + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_PRESET); + cbm_test_model_free(model); + model = descriptor_model(configs[0]); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_add_source_language(model, "ambiguous.h", "unknown", "", 0)); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + ASSERT_EQ(cbm_test_model_mapping_status(model, NULL), + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_LANGUAGE); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_model_descriptors_hidden_configured_macros_cannot_certify_inventory) { + const char *sources[] = {"#define HIDDEN(name) CASE_AT(ignore,name) {}\n", + "#define HIDDEN(name) ENTER_AT(ignore,name)\n", + "void helper(void) { CASE_AT(ignore,nested) {} }\n"}; + for (size_t i = 0; i < sizeof(sources) / sizeof(sources[0]); i++) { + cbm_test_model_t *model = descriptor_model(descriptor_config); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/hidden.c", "c", sources[i], + strlen(sources[i]))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + cbm_test_model_free(model); + } + PASS(); +} + +TEST(test_model_descriptors_literals_are_not_identifier_names) { + const char *arguments[] = {"L\"title\"", "u8\"title\"", "name \"suffix\"", + "\"prefix\" name", "'x'", "\"a,b\""}; + for (size_t i = 0; i < sizeof(arguments) / sizeof(arguments[0]); i++) { + cbm_test_model_t *model = descriptor_model(descriptor_config); + ASSERT_NOT_NULL(model); + char source[256]; + snprintf(source, sizeof(source), "CASE_AT(ignore,%s) {}", arguments[i]); + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/literals.c", "c", source, + strlen(source))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + ASSERT_EQ(cbm_test_model_mapping_status(model, NULL), + CBM_TEST_MODEL_MAPPING_UNSUPPORTED_ARGUMENT); + cbm_test_model_free(model); + } + PASS(); +} + +TEST(test_model_descriptors_large_argument_index_does_not_size_an_allocation) { + const char *json = "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[" + "{\"language\":\"c\",\"role\":\"case\",\"define_macro\":\"CASE\",\"name_" + "args\":[2147483647],\"runner_id\":\"{suite}:{name}\"}]}}}"; + cbm_test_model_t *model = descriptor_model(json); + ASSERT_NOT_NULL(model); + size_t before = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + const char *source = "CASE(only) {}"; + ASSERT_TRUE( + cbm_test_model_add_source_language(model, "tests/index.c", "c", source, strlen(source))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + ASSERT_EQ(cbm_test_model_mapping_status(model, NULL), CBM_TEST_MODEL_MAPPING_MISSING_ARGUMENT); + ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA) - before < 65536); + cbm_test_model_free(model); + PASS(); +} + +TEST(test_model_descriptors_same_site_perf_conflicts_are_ambiguous) { + const char *normal = "{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":\"ENTER\"," + "\"suite_arg\":0,\"perf_macro\":\"PERF\"}"; + const char *perf = "{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":\"OTHER\"," + "\"suite_arg\":0,\"perf_macro\":\"ENTER\"}"; + const char *same = "{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":\"ENTER\"," + "\"suite_arg\":0,\"perf_macro\":\"ENTER\"}"; + for (int variant = 0; variant < 4; variant++) { + char json[2048]; + if (variant == 3) { + snprintf( + json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"c-cbm\":true}," + "\"conventions\":[{\"language\":\"c\",\"role\":\"suite_registration\",\"macro\":" + "\"OTHER\",\"suite_arg\":0,\"perf_macro\":\"RUN_SELECTED_SUITE\"}]}}}"); + } else if (variant == 2) + snprintf(json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[%s]}}}", same); + else + snprintf(json, sizeof(json), + "{\"test_impact\":{\"version\":1,\"tests\":{\"conventions\":[%s,%s]}}}", + variant ? perf : normal, variant ? normal : perf); + cbm_test_model_t *model = descriptor_model(json); + ASSERT_NOT_NULL(model); + const char *source = variant == 3 ? "void main(void) { RUN_SELECTED_SUITE(alpha); }" + : "void main(void) { ENTER(alpha); }"; + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/runner.c", "c", source, + strlen(source))); + ASSERT_TRUE(cbm_test_model_finish(model)); + bool complete = cbm_test_model_complete(model); + cbm_test_model_mapping_status_t status = cbm_test_model_mapping_status(model, NULL); + cbm_test_model_free(model); + ASSERT_FALSE(complete); + ASSERT_EQ(status, CBM_TEST_MODEL_MAPPING_AMBIGUOUS); + } + PASS(); +} + +SUITE(test_impact) { + RUN_TEST(test_model_descriptors_same_site_perf_conflicts_are_ambiguous); + RUN_TEST(test_model_descriptors_preserve_owned_records_and_multiple_spellings); + RUN_TEST(test_model_descriptors_apply_only_to_explicit_source_language); + RUN_TEST(test_model_descriptors_keep_unrenderable_names_and_templates_explicit); + RUN_TEST(test_model_descriptors_duplicates_are_idempotent_and_conflicts_never_narrow); + RUN_TEST(test_model_descriptors_argument_failures_have_owned_source_diagnostics); + RUN_TEST(test_model_descriptors_registration_links_stay_in_their_source_file); + RUN_TEST(test_model_descriptors_presets_are_explicit_and_native_gaps_are_visible); + RUN_TEST(test_model_descriptors_hidden_configured_macros_cannot_certify_inventory); + RUN_TEST(test_model_descriptors_literals_are_not_identifier_names); + RUN_TEST(test_model_descriptors_large_argument_index_does_not_size_an_allocation); + + RUN_TEST(test_model_completeness_requires_finished_success_and_stays_uncertain); + RUN_TEST(test_model_reports_truncated_and_unsupported_syntax); + RUN_TEST(test_model_complete_sources_keep_normal_declarations_and_literals); + RUN_TEST(test_model_conditional_limit_is_explicit_and_deterministic); + RUN_TEST(test_selection_cannot_override_partial_model_with_complete_input_flag); + RUN_TEST(test_model_split_identifiers_cannot_hide_registrations); + RUN_TEST(test_model_guard_like_conditions_cannot_certify_unconditional_cases); + RUN_TEST(test_model_empty_elif_is_not_certified); + RUN_TEST(test_model_null_empty_source_is_safe); + + RUN_TEST(test_declarations_defaults_preserve_absent_and_configured_identity); + RUN_TEST(test_declarations_preserve_all_roles_arguments_and_owned_strings); + RUN_TEST(test_declarations_independent_snapshots_match_loaded_config_digest); + RUN_TEST(test_declarations_invalid_consumed_structure_fails_without_partial_result); + RUN_TEST(test_declarations_invalid_role_payloads_are_not_ignored); + RUN_TEST(test_declarations_exact_length_limits_and_safe_accessors); + RUN_TEST(test_declarations_argument_boundaries_and_order_are_preserved); + + RUN_TEST(test_policy_embedded_stars_do_not_cross_or_remove_separators); + RUN_TEST(test_policy_retains_exact_snapshot_and_owns_result); + RUN_TEST(test_policy_defaults_and_first_match_before_target_resolution); + RUN_TEST(test_policy_lanes_and_semantic_perf_membership); + RUN_TEST(test_policy_globs_braces_escapes_and_directory_boundaries); + RUN_TEST(test_policy_rejects_invalid_consumed_fields_without_changing_seed_loader); + RUN_TEST(test_policy_expansion_limits_reject_whole_policy); + RUN_TEST(test_policy_evaluation_errors_clear_outputs_and_never_fall_through); + + RUN_TEST(test_coverage_receipt_accepts_bound_evidence_at_age_boundary); + RUN_TEST(test_coverage_receipt_requires_bound_source_ancestry_and_graph); + RUN_TEST(test_coverage_receipt_binds_exact_bytes_and_metadata_consistency); + RUN_TEST(test_coverage_receipt_rejects_stale_future_and_missing_age); + RUN_TEST(test_coverage_receipt_missing_or_malformed_evidence_fails_closed); + + RUN_TEST(test_selection_suite_body_and_rule_triggers_dominate_cases); + RUN_TEST(test_selection_ambiguous_source_identity_cannot_choose_first_match); + RUN_TEST(test_selection_unions_static_coverage_and_changed_tests); + RUN_TEST(test_selection_retains_missing_and_incomplete_test_evidence); + RUN_TEST(test_selection_setup_and_rejected_artifacts_select_whole_suites); + RUN_TEST(test_selection_incomplete_global_evidence_never_narrows); + RUN_TEST(test_selection_inventory_ambiguity_and_conditionals_use_whole_suite); + RUN_TEST(test_selection_unregistered_changes_and_unknown_runner_membership_run_all); + RUN_TEST(test_selection_invalid_evidence_cannot_produce_a_partial_result); + RUN_TEST(test_selection_is_deterministic_deduplicated_and_owns_strings); + RUN_TEST(test_selection_proven_empty_diff_is_distinct_from_unresolved_changes); + + RUN_TEST(test_test_config_directory_globs_match_descendants); + RUN_TEST(test_test_config_loads_globs_and_optional_absence); + RUN_TEST(test_test_config_rejects_malformed_consumed_fields); + RUN_TEST(test_coverage_metadata_matches_rows_and_commit); + RUN_TEST(test_coverage_metadata_accepts_builder_variations); + RUN_TEST(test_coverage_metadata_rejects_inconsistent_artifacts); + RUN_TEST(test_coverage_map_reads_sources_setup_and_incomplete_tests); + RUN_TEST(test_coverage_map_reads_exact_lengths_and_crlf); + RUN_TEST(test_coverage_map_rejects_inconsistent_artifacts); + RUN_TEST(test_diff_git_flags_ignore_inter_hunk_config); + RUN_TEST(test_diff_rejects_impossible_counts_without_large_allocations); + RUN_TEST(test_diff_reads_files_and_hunks); + RUN_TEST(test_diff_reads_removals_creations_and_deletions); + RUN_TEST(test_diff_content_that_looks_like_structure_stays_content); + RUN_TEST(test_diff_reads_binary_mode_only_and_awkward_paths); + RUN_TEST(test_diff_says_when_it_did_not_read_everything); + RUN_TEST(test_model_reads_cases_suites_and_registrations); + RUN_TEST(test_model_ignores_comments_and_literals); + RUN_TEST(test_model_reads_suites_written_as_functions); + RUN_TEST(test_model_marks_what_is_under_a_condition); + RUN_TEST(test_model_keeps_brace_depth_across_else_branches); + RUN_TEST(test_model_resolves_registrations_in_their_own_file); + RUN_TEST(test_model_marks_suites_that_register_through_a_macro); + RUN_TEST(test_model_reads_the_runner_suites); + RUN_TEST(test_model_order_is_independent_of_input_order); + RUN_TEST(test_model_survives_truncated_sources); +} diff --git a/tests/test_test_impact_changes.c b/tests/test_test_impact_changes.c new file mode 100644 index 000000000..90339e2b3 --- /dev/null +++ b/tests/test_test_impact_changes.c @@ -0,0 +1,307 @@ +#include "test_framework.h" +#include +#include + +#define CI_MOD(path, oldline, newline) \ + "diff --git a/" path " b/" path "\n" \ + "index 1111111..2222222 100644\n--- a/" path "\n+++ b/" path "\n" \ + "@@ -1 +1 @@\n-" oldline "\n+" newline "\n" +#define CI_ADD(path) \ + "diff --git a/" path " b/" path "\nnew file mode 100644\n" \ + "index 0000000..1111111\n--- /dev/null\n+++ b/" path "\n" \ + "@@ -0,0 +1,2 @@\n+alpha\n+beta\n" +#define CI_DEL(path) \ + "diff --git a/" path " b/" path "\ndeleted file mode 100644\n" \ + "index 1111111..0000000\n--- a/" path "\n+++ /dev/null\n" \ + "@@ -4,2 +3,0 @@\n-old one\n-old two\n" +static const unsigned char ci_empty[]=""; +static const unsigned char ci_mixed_names[]="M\0z.c\0" "D\0gone.c\0" "A\0a.c\0"; +static const unsigned char ci_mixed_patch[]=CI_MOD("z.c","before_z","after_z") CI_DEL("gone.c") CI_ADD("a.c"); +static const unsigned char ci_two_names[]="M\0z.c\0" "M\0a.c\0"; +static char ci_sentinel_byte; +#define CI_SENTINEL ((cbm_changes_t *)(void *)&ci_sentinel_byte) +static bool ci_eq(const char *a,const char *b) { return a && b && strcmp(a,b)==0; } +static bool ci_path(const cbm_change_file_t *row,const unsigned char *path,size_t n,char status) { + return row && row->path && row->path_length==n && memcmp(row->path,path,n)==0 && + row->path[n]==0 && row->status==status; +} +static bool ci_cpath(const cbm_change_file_t *row,const char *path,char status) { + return ci_path(row,(const unsigned char *)path,strlen(path),status); +} +static bool ci_floor(const cbm_changes_t *c,size_t want,unsigned issue) { + size_t count=777; const cbm_change_file_t *rows=cbm_changes_files(c,&count); + if (!c || count!=want || (want && !rows) || cbm_changes_patch_reconciled(c) || + cbm_changes_can_narrow(c) || (cbm_changes_issues(c)&issue)!=issue) return false; + for (size_t i=0;ihunk_count!=1 || !rows[i].patch_file->hunks) return false; + const cbm_diff_file_t *a=rows[0].patch_file,*d=rows[1].patch_file,*m=rows[2].patch_file; + const cbm_diff_hunk_t *ha=&a->hunks[0],*hd=&d->hunks[0],*hm=&m->hunks[0]; + return ci_eq(a->path,"a.c") && a->created && !a->deleted && !a->binary && ha->start==1 && ha->count==2 && + ha->added_count==2 && ha->removed_count==0 && ha->added && ci_eq(ha->added[0],"alpha") && ci_eq(ha->added[1],"beta") && + ci_eq(d->path,"gone.c") && d->deleted && !d->created && !d->binary && hd->start==3 && hd->count==0 && + hd->added_count==0 && hd->removed_count==2 && hd->removed && ci_eq(hd->removed[0],"old one") && ci_eq(hd->removed[1],"old two") && + ci_eq(m->path,"z.c") && !m->created && !m->deleted && !m->binary && hm->start==1 && hm->count==1 && + hm->added_count==1 && hm->removed_count==1 && hm->added && hm->removed && ci_eq(hm->added[0],"after_z") && ci_eq(hm->removed[0],"before_z"); +} +TEST(change_identity_mixed_names_match_legacy_hunks_and_unsigned_order) { + bool control=ci_legacy(ci_mixed_patch,sizeof(ci_mixed_patch)-1,true,3); + cbm_changes_t *c=NULL; + cbm_changes_status_t rc=cbm_changes_parse(ci_mixed_names,sizeof(ci_mixed_names)-1,ci_mixed_patch,sizeof(ci_mixed_patch)-1,&c); + bool result=ci_mixed_result(c); cbm_changes_free(c); + ASSERT_TRUE(control); ASSERT_EQ(rc,CBM_CHANGES_OK); ASSERT_TRUE(result); PASS(); +} +TEST(change_identity_whole_file_reasons_preserve_mode_binary_type_and_no_hunks) { + const unsigned char names[]="M\0mode.c\0" "M\0binary.dat\0" "T\0kind.c\0" "A\0nohunk.c\0"; + const unsigned char patch[]= + "diff --git a/mode.c b/mode.c\nold mode 100644\nnew mode 100755\n" + "index 1111111..2222222\n--- a/mode.c\n+++ b/mode.c\n@@ -1 +1 @@\n-before\n+after\n" + "diff --git a/binary.dat b/binary.dat\nindex 1111111..2222222 100644\nBinary files a/binary.dat and b/binary.dat differ\n" + CI_MOD("kind.c","regular","target") + "diff --git a/nohunk.c b/nohunk.c\nnew file mode 100644\nindex 0000000..e69de29\n"; + bool control=ci_legacy(patch,sizeof(patch)-1,true,4); + cbm_changes_t *c=NULL; cbm_changes_status_t rc=cbm_changes_parse(names,sizeof(names)-1,patch,sizeof(patch)-1,&c); + size_t count=0; const cbm_change_file_t *rows=cbm_changes_files(c,&count); + bool valid=c && count==4 && rows && cbm_changes_state(c)==CBM_CHANGES_NONEMPTY && + cbm_changes_patch_reconciled(c) && !cbm_changes_can_narrow(c) && cbm_changes_issues(c)==0; + const char *paths[]={"binary.dat","kind.c","mode.c","nohunk.c"}; + const unsigned reasons[]={CBM_CHANGE_BINARY,CBM_CHANGE_TYPE,CBM_CHANGE_MODE,CBM_CHANGE_NO_HUNKS}; + const char statuses[]={'M','T','M','A'}; + for (size_t i=0;valid && i<4;i++) + valid=ci_cpath(&rows[i],paths[i],statuses[i]) && rows[i].evidence==CBM_CHANGE_WHOLE_FILE && + (rows[i].reasons&reasons[i])==reasons[i] && !(rows[i].reasons&CBM_CHANGE_UNRECONCILED) && !rows[i].patch_file; + cbm_changes_free(c); + ASSERT_TRUE(control); ASSERT_EQ(rc,CBM_CHANGES_OK); ASSERT_TRUE(valid); PASS(); +} +TEST(change_identity_raw_path_bytes_are_complete_literal_and_stably_ordered) { + const unsigned char names[]= + "M\0\xff.c\0" "M\0tab\tname.c\0" "M\0back\\slash.c\0" "M\0aa\0" "M\0a/b\0" + "M\0a\0" "M\0:literal\0" "M\0[glob]*?.c\0" "M\0-dash\0" "M\0ctl\x01.c\0" + "M\0line\nbreak.c\0" "M\0\x80.c\0" "M\0.hidden/..cache.c\0"; + const char *expected[]={"-dash",".hidden/..cache.c",":literal","[glob]*?.c","a","a/b","aa","back\\slash.c","ctl\x01.c","line\nbreak.c","tab\tname.c","\x80.c","\xff.c"}; + bool control=ci_legacy(ci_empty,0,true,0); + cbm_changes_t *c=NULL; cbm_changes_status_t rc=cbm_changes_parse(names,sizeof(names)-1,ci_empty,0,&c); + size_t count=0; const cbm_change_file_t *rows=cbm_changes_files(c,&count); + bool retained=ci_floor(c,13,CBM_CHANGES_PATCH_MISSING_PATH) && cbm_changes_state(c)==CBM_CHANGES_NONEMPTY && count==13 && rows; + for (size_t i=0;retained && i<13;i++) retained=ci_cpath(&rows[i],expected[i],'M'); + cbm_changes_free(c); + ASSERT_TRUE(control); ASSERT_EQ(rc,CBM_CHANGES_OK); ASSERT_TRUE(retained); PASS(); +} + +typedef struct { const unsigned char *bytes; size_t length; const char *label; } ci_bad_t; +#define CI_BAD(label, bytes) {(const unsigned char *)(bytes),sizeof(bytes)-1,label} +TEST(change_identity_rejects_malformed_authoritative_stream_without_prefix) { + static const ci_bad_t cases[]={ + CI_BAD("missing status separator","Mpath.c\0"), CI_BAD("missing path terminator","M\0path.c"), + CI_BAD("empty path","M\0\0"), CI_BAD("extra trailing NUL","M\0safe.c\0\0"), + CI_BAD("rename","M\0safe.c\0R100\0old.c\0new.c\0"), CI_BAD("copy","C\0copy.c\0"), + CI_BAD("unmerged","U\0conflict.c\0"), CI_BAD("lowercase","m\0file.c\0"), + CI_BAD("two-byte status","MM\0file.c\0"), CI_BAD("leading slash","M\0safe.c\0M\0/absolute.c\0"), + CI_BAD("trailing slash","M\0safe.c\0M\0trailing/\0"), CI_BAD("empty component","M\0a//b\0"), + CI_BAD("dot component","M\0a/./b\0"), CI_BAD("dotdot component","M\0a/../b\0"), + CI_BAD("dot path","M\0.\0"), CI_BAD("dotdot path","M\0..\0"), + CI_BAD("duplicate same status","M\0safe.c\0M\0safe.c\0"), + CI_BAD("duplicate different status","A\0safe.c\0D\0safe.c\0"), + CI_BAD("partial second record","M\0safe.c\0A\0later.c"), + CI_BAD("embedded separator cannot truncate into success","M\0a\0junk\0") + }; + bool control=ci_legacy(ci_mixed_patch,sizeof(ci_mixed_patch)-1,true,3); + ASSERT_TRUE(control); + for (size_t i=0;ievidence==CBM_CHANGE_HUNKS && gr->reasons==0 && gr->patch_file && gr->patch_file->hunk_count==1 && + gr->patch_file->hunks && gr->patch_file->hunks[0].start==1 && gr->patch_file->hunks[0].count==1 && + gr->patch_file->hunks[0].added_count==1 && gr->patch_file->hunks[0].added && + ci_eq(gr->patch_file->hunks[0].added[0],"new") && gr->patch_file->hunks[0].removed_count==1 && + gr->patch_file->hunks[0].removed && ci_eq(gr->patch_file->hunks[0].removed[0],"old"); + bool insertion_ok=in==1 && ir && ci_cpath(ir,"a.c",'M') && cbm_changes_state(insert)==CBM_CHANGES_NONEMPTY && + cbm_changes_patch_reconciled(insert) && cbm_changes_can_narrow(insert) && cbm_changes_issues(insert)==0 && + ir->evidence==CBM_CHANGE_HUNKS && ir->reasons==0 && ir->patch_file && ir->patch_file->hunk_count==1 && + ir->patch_file->hunks && ir->patch_file->hunks[0].start==1 && ir->patch_file->hunks[0].count==1 && + ir->patch_file->hunks[0].added_count==1 && ir->patch_file->hunks[0].added && + ci_eq(ir->patch_file->hunks[0].added[0],"new") && ir->patch_file->hunks[0].removed_count==0; + cbm_changes_free(bad); cbm_changes_free(good); cbm_changes_free(insert); + ASSERT_TRUE(legacy_control); ASSERT_EQ(rg,CBM_CHANGES_OK); ASSERT_TRUE(replacement_ok); + ASSERT_EQ(ri,CBM_CHANGES_OK); ASSERT_TRUE(insertion_ok); + ASSERT_EQ(rb,CBM_CHANGES_OK); ASSERT_TRUE(conservative); PASS(); +} + +SUITE(test_impact_changes) { + RUN_TEST(change_identity_mixed_names_match_legacy_hunks_and_unsigned_order); + RUN_TEST(change_identity_whole_file_reasons_preserve_mode_binary_type_and_no_hunks); + RUN_TEST(change_identity_raw_path_bytes_are_complete_literal_and_stably_ordered); + RUN_TEST(change_identity_rejects_malformed_authoritative_stream_without_prefix); + RUN_TEST(change_identity_missing_extra_duplicate_patch_paths_never_expose_partial_hunks); + RUN_TEST(change_identity_creation_and_deletion_flag_mismatches_are_not_hunk_evidence); + RUN_TEST(change_identity_verified_empty_is_distinct_from_unknown_and_missing_evidence); + RUN_TEST(change_identity_truncated_and_quoted_patch_keep_complete_names_and_no_hunk_views); + RUN_TEST(change_identity_owns_inputs_and_nested_hunk_strings_across_other_results); + RUN_TEST(change_identity_nonempty_old_range_zero_start_is_non_narrowable); +} diff --git a/tests/test_test_impact_git.c b/tests/test_test_impact_git.c new file mode 100644 index 000000000..498a846cc --- /dev/null +++ b/tests/test_test_impact_git.c @@ -0,0 +1,2381 @@ +/* Independent local-object fixtures for mcp/test_impact_git.h. + * No implementation helpers or command-count assumptions are used. */ +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include +#include + +enum { GF_PATH = 4096, GF_SMALL = 512 }; +static const unsigned char gf_binary[] = {'A', 0, '\r', '\n', '\n', 'Z'}; +static const unsigned char gf_new_binary[] = {'N', 0, 'B'}; +static const char gf_old_text[] = "before first\ncontext\nbefore last\n"; +static const char gf_head_text[] = "after first\ncontext\nafter last\n"; +static const char gf_base_text[] = "base branch only\n"; +static const char *gf_runner_binary; + +typedef struct { + char home[GF_PATH], repo[GF_PATH], git[GF_PATH]; + char capture[GF_PATH], log[GF_PATH], object_file[GF_PATH]; + char a[65], p[65], q[65], head[65], base[65]; + char binary[65], newer[65], old_text[65], head_text[65], base_text[65], attrs[65], link[65]; + unsigned oid_length; +} gf_fixture_t; + +static bool gf_path(char *out, const char *root, const char *leaf) { + int n = snprintf(out, GF_PATH, "%s/%s", root, leaf); + return n > 0 && n < GF_PATH; +} + +static bool gf_absolute(const char *path) { +#ifdef _WIN32 + return path && ((path[0] && path[1] == ':' && (path[2] == '/' || path[2] == '\\')) || + (path[0] == '\\' && path[1] == '\\')); +#else + return path && path[0] == '/'; +#endif +} + +static bool gf_write(const char *path, const void *bytes, size_t length) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) + return false; + bool ok = fwrite(bytes, 1, length, file) == length; + if (fclose(file) != 0) + ok = false; + return ok; +} + +/* Native executable fixture for Git's optional diff/textconv command. Extra + * arguments added by Git are deliberately ignored. Normal harness entry keeps + * argv[0] for a private executable copy; neither mode changes the environment. */ +int tf_maybe_run_git_facts_diff_probe(int argc, char **argv); +int tf_maybe_run_git_facts_diff_probe(int argc, char **argv) { + if (argc >= 2 && argv && strcmp(argv[1], "__cbm_git_facts_diff_probe") == 0) { + return argc >= 3 && gf_write(argv[2], "invoked\n", 8) ? 0 : 96; + } + if (argc == 2 && argv && strcmp(argv[1], "__cbm_git_facts_hostile_diff_probe") == 0) { + /* Only this isolated child mutates its environment, before harness threads start. + * Re-enter the existing exact-diff test; the parent process remains untouched. */ + if (cbm_setenv("GIT_DIFF_OPTS", "--unified=999", 1) != 0 || + cbm_setenv( + "CBM_TEST_ONLY", + "test_impact_git:test_git_facts_diff_uses_merge_base_zero_context_and_nul_metadata", + 1) != 0) { + return 96; + } + argv[1] = "test_impact_git"; + } + gf_runner_binary = argc > 0 && argv ? argv[0] : NULL; + return -1; +} + +/* Git's configured external-driver values are shell command strings. Quote + * only fixture-owned paths; Git queries themselves always use native argv. */ +static bool gf_driver_command(char *out, size_t capacity, const char *binary, const char *marker) { + const char *parts[] = {binary, "__cbm_git_facts_diff_probe", marker}; + size_t used = 0; + for (size_t part = 0; part < 3; part++) { + if (used + 2 >= capacity) + return false; + if (part) + out[used++] = ' '; + out[used++] = '\''; + for (const char *p = parts[part]; *p; p++) { + const char *escaped = *p == '\'' ? "'\\''" : NULL; + size_t length = escaped ? 4 : 1; + if (used + length + 2 >= capacity) + return false; + if (escaped) + memcpy(out + used, escaped, length); + else + out[used] = *p; + used += length; + } + out[used++] = '\''; + } + if (used >= capacity) + return false; + out[used] = '\0'; + return true; +} + +static bool gf_git_exit(gf_fixture_t *fx, const char *const *tail, int expected_exit, char *output, + size_t capacity) { + const char *argv[64] = { + fx->git, "-C", fx->repo, "-c", "commit.gpgSign=false", "-c", "core.autocrlf=false"}; + size_t n = 7; + for (size_t i = 0; tail[i]; i++) { + if (n + 1 >= sizeof(argv) / sizeof(argv[0])) + return false; + argv[n++] = tail[i]; + } + argv[n] = NULL; + cbm_proc_opts_t opts = {.bin = fx->git, + .argv = argv, + .stdout_file = fx->capture, + .log_file = fx->log, + .strip_git_repo_env = true, + .quiet_timeout_ms = 10000}; + cbm_proc_result_t result = {0}; + cbm_proc_outcome_t expected_outcome = + expected_exit == 0 ? CBM_PROC_CLEAN : CBM_PROC_EXIT_NONZERO; + if (cbm_subprocess_run(&opts, &result) != 0 || result.outcome != expected_outcome || + result.exit_code != expected_exit || !result.tree_quiesced) + return false; + if (!output) + return true; + FILE *file = cbm_fopen(fx->capture, "rb"); + if (!file || capacity == 0) { + if (file) + (void)fclose(file); + return false; + } + size_t used = fread(output, 1, capacity - 1, file); + int extra = fgetc(file); + bool ok = extra == EOF && !ferror(file); + if (fclose(file) != 0) + ok = false; + output[used] = '\0'; + return ok; +} + +static bool gf_git(gf_fixture_t *fx, const char *const *tail, char *output, size_t capacity) { + return gf_git_exit(fx, tail, 0, output, capacity); +} + +static bool gf_oid(gf_fixture_t *fx, const char *const *argv, char out[65]) { + char text[80]; + if (!gf_git(fx, argv, text, sizeof(text))) + return false; + size_t n = strlen(text); + if (n && text[n - 1] == '\n') + text[--n] = '\0'; + if (n && text[n - 1] == '\r') + text[--n] = '\0'; + if (n != fx->oid_length) + return false; + for (size_t i = 0; i < n; i++) { + if (!((text[i] >= '0' && text[i] <= '9') || (text[i] >= 'a' && text[i] <= 'f'))) + return false; + } + memcpy(out, text, n + 1); + return true; +} + +static bool gf_object(gf_fixture_t *fx, const char *type, const void *bytes, size_t length, + char out[65]) { + const char *argv[] = {"hash-object", "-w", "--no-filters", "-t", + type, "--", fx->object_file, NULL}; + return gf_write(fx->object_file, bytes, length) && gf_oid(fx, argv, out); +} + +static bool gf_index(gf_fixture_t *fx, const char *mode, const char *oid, const char *path) { + char entry[GF_SMALL]; + int n = snprintf(entry, sizeof(entry), "%s,%s,%s", mode, oid, path); + if (n <= 0 || (size_t)n >= sizeof(entry)) + return false; + const char *argv[] = {"update-index", "--add", "--cacheinfo", entry, NULL}; + return gf_git(fx, argv, NULL, 0); +} + +static bool gf_tree(gf_fixture_t *fx, int version, char out[65]) { + const char *empty[] = {"read-tree", "--empty", NULL}; + const char *write[] = {"write-tree", NULL}; + if (!gf_git(fx, empty, NULL, 0)) + return false; + const char *regular[] = {"binary.bin", ":colon.bin", "-dash.bin", "[glob]*.bin", + "dir/file.bin"}; + for (size_t i = 0; i < sizeof(regular) / sizeof(regular[0]); i++) { + if (!gf_index(fx, "100644", fx->binary, regular[i])) + return false; + } + const char *text = version == 1 ? fx->head_text : version == 2 ? fx->base_text : fx->old_text; + if (!gf_index(fx, "100644", text, "p.txt") || + !gf_index(fx, "100644", fx->attrs, ".gitattributes") || + !gf_index(fx, "120000", fx->link, "link")) + return false; + if (version == 1) { + if (!gf_index(fx, "100644", fx->newer, "new.bin") || + !gf_index(fx, "160000", fx->a, "submodule")) + return false; + } else if (!gf_index(fx, "100644", fx->binary, "deleted.bin")) + return false; + return gf_oid(fx, write, out); +} + +static bool gf_commit(gf_fixture_t *fx, const char *tree, const char *parent_one, + const char *parent_two, const char *label, char out[65]) { + char commit[1024]; + int n = snprintf( + commit, sizeof(commit), + "tree %s\n%s%s%s%s%s%s" + "author Git Facts Fixture 946684800 +0000\n" + "committer Git Facts Fixture 946684800 +0000\n\n%s\n", + tree, parent_one ? "parent " : "", parent_one ? parent_one : "", parent_one ? "\n" : "", + parent_two ? "parent " : "", parent_two ? parent_two : "", parent_two ? "\n" : "", label); + return n > 0 && (size_t)n < sizeof(commit) && gf_object(fx, "commit", commit, (size_t)n, out); +} + +static bool gf_ref(gf_fixture_t *fx, const char *name, const char *oid) { + const char *argv[] = {"update-ref", name, oid, NULL}; + return gf_git(fx, argv, NULL, 0); +} + +static bool gf_config(gf_fixture_t *fx, const char *key, const char *value) { + const char *argv[] = {"config", "--local", key, value, NULL}; + return gf_git(fx, argv, NULL, 0); +} + +/* A--P---M(topic/HEAD) + * \ / + * Q--+--B(base) + * merge-base(M,B)=Q, which deliberately differs from first-parent(M)=P. + * Raw commit objects give fixed timestamps/identities without changing env. */ +static bool gf_fixture_open(gf_fixture_t *fx, bool sha256) { + memset(fx, 0, sizeof(*fx)); + const char *resolved = cbm_find_cli("git", cbm_get_home_dir()); + if (!resolved || !gf_absolute(resolved) || strlen(resolved) >= sizeof(fx->git)) + return false; + memcpy(fx->git, resolved, strlen(resolved) + 1); + const char *home = th_mktempdir("cbm-git-facts"); + if (!home || strlen(home) >= sizeof(fx->home)) + return false; + memcpy(fx->home, home, strlen(home) + 1); + bool ok = gf_path(fx->repo, home, "repo") && gf_path(fx->capture, home, "stdout.bin") && + gf_path(fx->log, home, "stderr.log") && gf_path(fx->object_file, home, "object.bin"); + fx->oid_length = sha256 ? 64U : 40U; + if (ok) + ok = th_mkdir_p(fx->repo) == 0; + const char *init[] = {"-c", + "init.templateDir=", + "init", + "--quiet", + sha256 ? "--object-format=sha256" : "--object-format=sha1", + "--initial-branch=topic", + NULL}; + const char attrs[] = "p.txt diff=fixturetrap\n"; + if (ok) + ok = gf_git(fx, init, NULL, 0) && gf_config(fx, "core.protectNTFS", "false") && + gf_config(fx, "core.protectHFS", "false") && gf_config(fx, "core.autocrlf", "false") && + gf_config(fx, "core.worktree", fx->repo) && + gf_object(fx, "blob", gf_binary, sizeof(gf_binary), fx->binary) && + gf_object(fx, "blob", gf_new_binary, sizeof(gf_new_binary), fx->newer) && + gf_object(fx, "blob", gf_old_text, sizeof(gf_old_text) - 1, fx->old_text) && + gf_object(fx, "blob", gf_head_text, sizeof(gf_head_text) - 1, fx->head_text) && + gf_object(fx, "blob", gf_base_text, sizeof(gf_base_text) - 1, fx->base_text) && + gf_object(fx, "blob", attrs, sizeof(attrs) - 1, fx->attrs) && + gf_object(fx, "blob", "p.txt", 5, fx->link); + char tree_a[65], tree_h[65], tree_b[65]; + if (ok) + ok = gf_tree(fx, 0, tree_a) && gf_commit(fx, tree_a, NULL, NULL, "A", fx->a) && + gf_commit(fx, tree_a, fx->a, NULL, "P", fx->p) && + gf_commit(fx, tree_a, fx->a, NULL, "Q", fx->q) && gf_tree(fx, 1, tree_h) && + gf_commit(fx, tree_h, fx->p, fx->q, "M", fx->head) && gf_tree(fx, 2, tree_b) && + gf_commit(fx, tree_b, fx->q, NULL, "B", fx->base) && + gf_ref(fx, "refs/heads/topic", fx->head) && gf_ref(fx, "refs/heads/base", fx->base); + if (!ok) { + fprintf(stderr, "Git facts fixture setup failed (%s, native Git: %s)\n", + sha256 ? "required SHA-256" : "SHA-1", fx->git); + (void)th_rmtree(fx->home); + } + return ok; +} + +static cbm_git_facts_options_t gf_options(gf_fixture_t *fx) { + return (cbm_git_facts_options_t){.root = fx->repo, + .base_ref = "refs/heads/base", + .git_executable = fx->git, + .expected_head = fx->head, + .deadline_ms = cbm_now_ms() + 60000U, + .command_limit = 256, + .stdout_limit = 1024 * 1024, + .stderr_limit = 65536, + .total_output_limit = 4 * 1024 * 1024}; +} + +static bool gf_bytes_equal(cbm_git_bytes_t bytes, const void *expected, size_t length) { + return bytes.length == length && + (length == 0 || (bytes.data && memcmp(bytes.data, expected, length) == 0)); +} + +static bool gf_contains(cbm_git_bytes_t bytes, const char *needle) { + size_t length = strlen(needle); + if (!bytes.data || length > bytes.length) + return false; + for (size_t i = 0; i <= bytes.length - length; i++) { + if (memcmp(bytes.data + i, needle, length) == 0) + return true; + } + return false; +} + +static bool gf_blob_cleared(const cbm_git_blob_t *blob) { + return !blob->bytes.data && blob->bytes.length == 0 && blob->oid[0] == '\0' && blob->mode == 0; +} + +TEST(test_git_facts_pins_actual_merge_base_and_both_refs) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + const cbm_git_facts_identity_t *id = facts ? cbm_git_facts_identity(facts) : NULL; + bool matched = id && id->oid_hex_length == 40 && gf_absolute(id->root) && id->git_dir && + id->common_dir && strcmp(id->head, fx.head) == 0 && + strcmp(id->base, fx.base) == 0 && strcmp(id->merge_base, fx.q) == 0 && + strcmp(id->merge_base, fx.p) != 0; + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_EQ(error.status, CBM_GIT_FACTS_OK); + ASSERT_TRUE(matched); + PASS(); +} + +TEST(test_git_facts_ref_index_and_worktree_mutation_cannot_change_snapshot) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + cbm_git_diff_t before = {0}, after = {0}; + bool before_ok = facts && cbm_git_facts_diff(facts, &before, &error); + unsigned char patch[16384], names[1024]; + bool copied = before_ok && before.patch.data && before.patch.length > 0 && + before.name_status.data && before.name_status.length > 0 && + before.patch.length <= sizeof(patch) && + before.name_status.length <= sizeof(names); + size_t patch_length = copied ? before.patch.length : 0; + size_t names_length = copied ? before.name_status.length : 0; + if (copied) { + memcpy(patch, before.patch.data, patch_length); + memcpy(names, before.name_status.data, names_length); + } + char path[GF_PATH]; + bool mutated = gf_path(path, fx.repo, "p.txt") && gf_write(path, "dirty\n", 6) && + gf_index(&fx, "100644", fx.base_text, "p.txt") && + gf_ref(&fx, "refs/heads/topic", fx.a) && gf_ref(&fx, "refs/heads/base", fx.p); + bool after_ok = facts && cbm_git_facts_diff(facts, &after, &error); + cbm_git_blob_t blob = {0}; + cbm_git_blob_status_t read = + facts ? cbm_git_facts_read_blob(facts, CBM_GIT_REV_HEAD, "p.txt", 5, &blob, &error) + : CBM_GIT_BLOB_ERROR; + bool same = copied && after_ok && gf_bytes_equal(after.patch, patch, patch_length) && + gf_bytes_equal(after.name_status, names, names_length) && + read == CBM_GIT_BLOB_FOUND && + gf_bytes_equal(blob.bytes, gf_head_text, sizeof(gf_head_text) - 1); + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(before_ok); + ASSERT_TRUE(mutated); + ASSERT_TRUE(same); + PASS(); +} + +TEST(test_git_facts_reads_exact_binary_literal_paths_and_all_revisions) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + const char *paths[] = {"binary.bin", ":colon.bin", "-dash.bin", "[glob]*.bin", "dir/file.bin"}; + bool exact = facts != NULL; + for (size_t i = 0; facts && i < sizeof(paths) / sizeof(paths[0]); i++) { + cbm_git_blob_t blob = {0}; + exact = cbm_git_facts_read_blob(facts, CBM_GIT_REV_HEAD, paths[i], strlen(paths[i]), &blob, + &error) == CBM_GIT_BLOB_FOUND && + exact; + exact = gf_bytes_equal(blob.bytes, gf_binary, sizeof(gf_binary)) && blob.mode == 0100644 && + strcmp(blob.oid, fx.binary) == 0 && exact; + } + const char *texts[] = {gf_head_text, gf_base_text, gf_old_text}; + for (int i = 0; facts && i < 3; i++) { + const char path[] = {'p', '.', 't', 'x', + 't', '!', '!'}; /* explicit length, no terminator */ + cbm_git_blob_t blob = {0}; + exact = cbm_git_facts_read_blob(facts, (cbm_git_revision_t)i, path, 5, &blob, &error) == + CBM_GIT_BLOB_FOUND && + gf_bytes_equal(blob.bytes, texts[i], strlen(texts[i])) && exact; + } + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(test_git_facts_distinguishes_absent_invalid_and_nonregular_paths) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + cbm_git_blob_t blob; + memset(&blob, 0xa5, sizeof(blob)); + cbm_git_blob_status_t absent = + facts ? cbm_git_facts_read_blob(facts, CBM_GIT_REV_HEAD, "missing.bin", 11, &blob, &error) + : CBM_GIT_BLOB_ERROR; + bool absent_clear = gf_blob_cleared(&blob); + const char *invalid[] = {"", "/p.txt", "./p.txt", "dir/../p.txt", + "p\0.txt", "link", "dir", "submodule"}; + const size_t lengths[] = {0, 6, 7, 12, 6, 4, 3, 9}; + bool rejected = facts != NULL; + for (size_t i = 0; facts && i < sizeof(invalid) / sizeof(invalid[0]); i++) { + memset(&blob, 0xa5, sizeof(blob)); + rejected = cbm_git_facts_read_blob(facts, CBM_GIT_REV_HEAD, invalid[i], lengths[i], &blob, + &error) == CBM_GIT_BLOB_ERROR && + gf_blob_cleared(&blob) && rejected; + } + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_EQ(absent, CBM_GIT_BLOB_ABSENT); + ASSERT_TRUE(absent_clear); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(test_git_facts_ancestry_distinguishes_no_from_invalid_and_missing_objects) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + char missing[65]; + memset(missing, 'f', fx.oid_length); + missing[fx.oid_length] = '\0'; + cbm_git_ancestry_t yes = facts ? cbm_git_facts_is_ancestor(facts, fx.q, fx.head, &error) : -1; + cbm_git_ancestry_t no = facts ? cbm_git_facts_is_ancestor(facts, fx.base, fx.head, &error) : -1; + cbm_git_ancestry_t invalid = + facts ? cbm_git_facts_is_ancestor(facts, "HEAD", fx.head, &error) : 0; + cbm_git_ancestry_t absent = + facts ? cbm_git_facts_is_ancestor(facts, missing, fx.head, &error) : 0; + cbm_git_ancestry_t noncommit = + facts ? cbm_git_facts_is_ancestor(facts, fx.binary, fx.head, &error) : 0; + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_EQ(yes, CBM_GIT_ANCESTRY_YES); + ASSERT_EQ(no, CBM_GIT_ANCESTRY_NO); + ASSERT_EQ(invalid, CBM_GIT_ANCESTRY_ERROR); + ASSERT_EQ(absent, CBM_GIT_ANCESTRY_ERROR); + ASSERT_EQ(noncommit, CBM_GIT_ANCESTRY_ERROR); + PASS(); +} + +TEST(test_git_facts_diff_uses_merge_base_zero_context_and_nul_metadata) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + bool configured = + gf_config(&fx, "diff.interHunkContext", "100") && gf_config(&fx, "diff.renames", "true"); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + cbm_git_diff_t diff = {0}; + bool diff_ok = facts && cbm_git_facts_diff(facts, &diff, &error); + const char names[] = "D\0deleted.bin\0A\0new.bin\0M\0p.txt\0A\0submodule\0"; + bool metadata = diff_ok && gf_bytes_equal(diff.name_status, names, sizeof(names) - 1); + bool patch = + diff_ok && gf_contains(diff.patch, "@@ -1 +1 @@") && + gf_contains(diff.patch, "@@ -3 +3 @@") && gf_contains(diff.patch, "-before first\n") && + gf_contains(diff.patch, "+after first\n") && !gf_contains(diff.patch, "base branch only") && + !gf_contains(diff.patch, "\n context\n"); + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(configured); + ASSERT_TRUE(diff_ok); + ASSERT_TRUE(metadata); + ASSERT_TRUE(patch); + PASS(); +} + +TEST(test_git_facts_rejects_malformed_options_and_expected_head_mismatch) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t good = gf_options(&fx); + bool invalid = true; + for (int i = 0; i < 9; i++) { + cbm_git_facts_options_t opts = good; + if (i == 0) + opts.root = NULL; + if (i == 1) + opts.root = "relative"; + if (i == 2) + opts.base_ref = NULL; + if (i == 3) + opts.git_executable = "git"; + if (i == 4) + opts.deadline_ms = 0; + if (i == 5) + opts.command_limit = 0; + if (i == 6) + opts.stdout_limit = 0; + if (i == 7) + opts.stderr_limit = 0; + if (i == 8) + opts.total_output_limit = 0; + cbm_git_facts_error_t error; + memset(&error, 0xa5, sizeof(error)); + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + invalid = !facts && error.status == CBM_GIT_FACTS_INVALID && + memchr(error.diagnostic, '\0', sizeof(error.diagnostic)) && invalid; + cbm_git_facts_free(facts); + } + good.expected_head = fx.a; + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&good, &error); + bool mismatch = !facts && error.status == CBM_GIT_FACTS_IDENTITY_MISMATCH; + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(invalid); + ASSERT_TRUE(mismatch); + PASS(); +} + +static bool gf_cancelled(void *context) { + return *(const bool *)context; +} + +TEST(test_git_facts_precancel_and_expired_deadline_are_deterministic) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t opts = gf_options(&fx); + bool cancelled = true; + opts.cancelled = gf_cancelled; + opts.cancel_context = &cancelled; + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + bool cancel_ok = !facts && error.status == CBM_GIT_FACTS_CANCELLED; + cbm_git_facts_free(facts); + opts.cancelled = NULL; + opts.deadline_ms = 1; /* fixed past absolute deadline, not a measured race */ + facts = cbm_git_facts_open(&opts, &error); + bool deadline_ok = !facts && error.status == CBM_GIT_FACTS_DEADLINE; + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(cancel_ok); + ASSERT_TRUE(deadline_ok); + PASS(); +} + +TEST(test_git_facts_command_and_output_caps_fail_closed) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + bool limited = true; + for (int i = 0; i < 3; i++) { + cbm_git_facts_options_t opts = gf_options(&fx); + if (i == 0) + opts.command_limit = 1; + if (i == 1) + opts.stdout_limit = 1; + if (i == 2) + opts.total_output_limit = 1; + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + limited = !facts && error.status == CBM_GIT_FACTS_LIMIT && limited; + cbm_git_facts_free(facts); + } + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(limited); + PASS(); +} + +TEST(test_git_facts_required_sha256_objects_and_identity) { + gf_fixture_t fx; + /* Required fixture: unsupported installed Git fails setup explicitly. */ + ASSERT_TRUE(gf_fixture_open(&fx, true)); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + const cbm_git_facts_identity_t *id = facts ? cbm_git_facts_identity(facts) : NULL; + bool identity = id && id->oid_hex_length == 64 && strlen(id->head) == 64 && + strcmp(id->head, fx.head) == 0 && strcmp(id->base, fx.base) == 0 && + strcmp(id->merge_base, fx.q) == 0; + cbm_git_blob_t blob = {0}; + bool exact = facts && + cbm_git_facts_read_blob(facts, CBM_GIT_REV_HEAD, ":colon.bin", 10, &blob, + &error) == CBM_GIT_BLOB_FOUND && + strlen(blob.oid) == 64 && gf_bytes_equal(blob.bytes, gf_binary, sizeof(gf_binary)); + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(identity); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(test_git_facts_never_runs_external_diff_or_textconv) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + char binary[GF_PATH], marker[GF_PATH], command[GF_PATH * 3]; + bool prepared = gf_path(binary, fx.home, +#ifdef _WIN32 + "diff-probe.exe" +#else + "diff-probe" +#endif + ) && + gf_path(marker, fx.home, "driver-invoked") && gf_runner_binary; + if (prepared) { + cbm_normalize_path_sep(binary); + cbm_normalize_path_sep(marker); + prepared = cbm_copy_file(gf_runner_binary, binary) == 0; + } + if (prepared) + th_make_executable(binary); + char attributes_path[GF_PATH]; + static const char attributes[] = "p.txt diff=fixturetrap\n"; + prepared = prepared && gf_path(attributes_path, fx.repo, ".gitattributes") && + gf_write(attributes_path, attributes, sizeof(attributes) - 1) && + gf_driver_command(command, sizeof(command), binary, marker); + bool control[2] = {false}, isolated[2] = {false}; + for (int i = 0; prepared && i < 2; i++) { + const char *key = i == 0 ? "diff.external" : "diff.fixturetrap.textconv"; + bool configured = gf_config(&fx, key, command); + const char *probe[] = { + "diff", i == 0 ? "--ext-diff" : "--textconv", fx.q, fx.head, "--", "p.txt", NULL}; + bool probe_ok = configured && gf_git(&fx, probe, NULL, 0); + bool marker_exists = cbm_file_exists(marker); + control[i] = probe_ok && marker_exists; + if (!control[i]) { + fprintf(stderr, "Git driver control %d: configured=%d command=%d marker=%d\n", i, + configured, probe_ok, marker_exists); + FILE *diagnostic = cbm_fopen(fx.log, "rb"); + if (diagnostic) { + char detail[512]; + size_t length = fread(detail, 1, sizeof(detail) - 1, diagnostic); + detail[length] = '\0'; + fprintf(stderr, "Git driver fixture stderr: %s\n", detail); + (void)fclose(diagnostic); + } + } + bool reset = cbm_unlink(marker) == 0; + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + cbm_git_diff_t diff = {0}; + isolated[i] = reset && facts && cbm_git_facts_diff(facts, &diff, &error) && + gf_contains(diff.patch, "+after first\n") && !cbm_file_exists(marker); + cbm_git_facts_free(facts); + const char *unset[] = {"config", "--local", "--unset", key, NULL}; + prepared = gf_git(&fx, unset, NULL, 0) && prepared; + } + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + ASSERT_TRUE(control[0]); + ASSERT_TRUE(control[1]); + ASSERT_TRUE(isolated[0]); + ASSERT_TRUE(isolated[1]); + PASS(); +} + +TEST(test_git_facts_rejects_local_grafts_that_change_ancestry) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + char info[GF_PATH], path[GF_PATH], graft[160]; + int length = snprintf(graft, sizeof(graft), "%s %s\n", fx.head, fx.a); + bool written = length > 0 && (size_t)length < sizeof(graft) && + gf_path(info, fx.repo, ".git/info") && th_mkdir_p(info) == 0 && + gf_path(path, info, "grafts") && gf_write(path, graft, (size_t)length); + const char *merge[] = {"merge-base", "--all", fx.head, fx.base, NULL}; + char altered[65]; + bool graft_effective = written && gf_oid(&fx, merge, altered) && strcmp(altered, fx.a) == 0 && + strcmp(altered, fx.q) != 0; + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + bool rejected = !facts && error.status == CBM_GIT_FACTS_UNSUPPORTED; + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(graft_effective); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(test_git_facts_rejects_no_common_ancestor) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + char tree_spec[80], tree[65], orphan[65], control_output[8]; + int n = snprintf(tree_spec, sizeof(tree_spec), "%s^{tree}", fx.a); + const char *resolve_tree[] = {"rev-parse", "--verify", tree_spec, NULL}; + bool created = n > 0 && (size_t)n < sizeof(tree_spec) && gf_oid(&fx, resolve_tree, tree) && + gf_commit(&fx, tree, NULL, NULL, "unrelated-root", orphan) && + gf_ref(&fx, "refs/heads/base", orphan); + const char *merge[] = {"merge-base", "--all", fx.head, orphan, NULL}; + bool unrelated = created && + gf_git_exit(&fx, merge, 1, control_output, sizeof(control_output)) && + control_output[0] == '\0'; + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + bool rejected = !facts && error.status == CBM_GIT_FACTS_NO_MERGE_BASE; + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(unrelated); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(test_git_facts_rejects_multiple_criss_cross_merge_bases) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + char tree_spec[80], tree[65], other_merge[65], control_output[160]; + int n = snprintf(tree_spec, sizeof(tree_spec), "%s^{tree}", fx.head); + const char *resolve_tree[] = {"rev-parse", "--verify", tree_spec, NULL}; + /* Existing HEAD merges P,Q. A separate Q,P merge makes P and Q exactly + * the two best common ancestors; neither is an ancestor of the other. */ + bool created = n > 0 && (size_t)n < sizeof(tree_spec) && gf_oid(&fx, resolve_tree, tree) && + gf_commit(&fx, tree, fx.q, fx.p, "independent-reverse-merge", other_merge) && + gf_ref(&fx, "refs/heads/base", other_merge); + const char *merge[] = {"merge-base", "--all", fx.head, other_merge, NULL}; + char first[65], second[65], extra; + bool two = created && gf_git(&fx, merge, control_output, sizeof(control_output)) && + sscanf(control_output, "%64s %64s %c", first, second, &extra) == 2 && + ((strcmp(first, fx.p) == 0 && strcmp(second, fx.q) == 0) || + (strcmp(first, fx.q) == 0 && strcmp(second, fx.p) == 0)); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + bool rejected = !facts && error.status == CBM_GIT_FACTS_AMBIGUOUS_MERGE_BASE; + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(two); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(test_git_facts_rejects_local_shallow_state) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + char path[GF_PATH], contents[80], control_output[16]; + int n = snprintf(contents, sizeof(contents), "%s\n", fx.q); + bool written = n > 0 && (size_t)n < sizeof(contents) && + gf_path(path, fx.repo, ".git/shallow") && gf_write(path, contents, (size_t)n); + const char *probe[] = {"rev-parse", "--is-shallow-repository", NULL}; + bool shallow = + written && gf_git(&fx, probe, control_output, sizeof(control_output)) && + (strcmp(control_output, "true\n") == 0 || strcmp(control_output, "true\r\n") == 0); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + bool rejected = !facts && error.status == CBM_GIT_FACTS_UNSUPPORTED; + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(shallow); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(test_git_facts_rejects_history_override_added_after_open) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + bool opened = facts && error.status == CBM_GIT_FACTS_OK; + bool before = + facts && cbm_git_facts_is_ancestor(facts, fx.q, fx.head, &error) == CBM_GIT_ANCESTRY_YES; + char info[GF_PATH], path[GF_PATH], graft[160], altered[65]; + int n = snprintf(graft, sizeof(graft), "%s %s\n", fx.head, fx.a); + bool written = opened && n > 0 && (size_t)n < sizeof(graft) && + gf_path(info, fx.repo, ".git/info") && th_mkdir_p(info) == 0 && + gf_path(path, info, "grafts") && gf_write(path, graft, (size_t)n); + const char *merge[] = {"merge-base", "--all", fx.head, fx.base, NULL}; + bool override_active = written && gf_oid(&fx, merge, altered) && strcmp(altered, fx.a) == 0; + cbm_git_ancestry_t after = + facts ? cbm_git_facts_is_ancestor(facts, fx.q, fx.head, &error) : CBM_GIT_ANCESTRY_NO; + bool rejected = after == CBM_GIT_ANCESTRY_ERROR && error.status == CBM_GIT_FACTS_UNSUPPORTED; + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(opened); + ASSERT_TRUE(before); + ASSERT_TRUE(override_active); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(test_git_facts_cancellation_after_open_is_latched) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + bool cancelled = false; + cbm_git_facts_options_t opts = gf_options(&fx); + opts.cancelled = gf_cancelled; + opts.cancel_context = &cancelled; + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + bool opened = facts && error.status == CBM_GIT_FACTS_OK; + bool before = + facts && cbm_git_facts_is_ancestor(facts, fx.q, fx.head, &error) == CBM_GIT_ANCESTRY_YES; + cancelled = true; + cbm_git_diff_t diff; + memset(&diff, 0xa5, sizeof(diff)); + bool diff_ok = facts && cbm_git_facts_diff(facts, &diff, &error); + bool first = facts && !diff_ok && error.status == CBM_GIT_FACTS_CANCELLED && !diff.patch.data && + diff.patch.length == 0 && !diff.name_status.data && diff.name_status.length == 0; + cancelled = false; /* the same cancelled handle must not resume */ + cbm_git_blob_t blob; + memset(&blob, 0xa5, sizeof(blob)); + cbm_git_blob_status_t read = + facts ? cbm_git_facts_read_blob(facts, CBM_GIT_REV_HEAD, "p.txt", 5, &blob, &error) + : CBM_GIT_BLOB_FOUND; + bool latched = read == CBM_GIT_BLOB_ERROR && error.status == CBM_GIT_FACTS_CANCELLED && + gf_blob_cleared(&blob); + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(opened); + ASSERT_TRUE(before); + ASSERT_TRUE(first); + ASSERT_TRUE(latched); + PASS(); +} + +/* Insert before SUITE(test_impact_git), then register this test in that suite. + * Reuses the existing independent fixture helpers; no new implementation seam. */ +TEST(test_git_facts_gitfile_redirection_cannot_certify_false_ancestry) { + gf_fixture_t original, redirected; + ASSERT_TRUE(gf_fixture_open(&original, false)); + bool second_ready = gf_fixture_open(&redirected, false); + if (!second_ready) { + int cleanup = th_rmtree(original.home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(second_ready); + } + /* Objects are generated from identical fixed bytes in both native repos. + * Their identities therefore stay the same while their topology differs. */ + bool same_objects = strcmp(original.head, redirected.head) == 0 && + strcmp(original.base, redirected.base) == 0 && + strcmp(original.q, redirected.q) == 0; + char original_git[GF_PATH]; + bool path_ready = gf_path(original_git, original.home, "original-git"); + const char *relocate[] = { + "-c", "init.templateDir=", "init", "--quiet", "--separate-git-dir", original_git, NULL}; + bool relocated = same_objects && path_ready && gf_git(&original, relocate, NULL, 0); + cbm_git_facts_options_t opts = gf_options(&original); + cbm_git_facts_error_t error = {0}; + cbm_git_facts_t *facts = relocated ? cbm_git_facts_open(&opts, &error) : NULL; + bool opened = facts && error.status == CBM_GIT_FACTS_OK; + bool originally_no = facts && + cbm_git_facts_is_ancestor(facts, original.base, original.head, &error) == + CBM_GIT_ANCESTRY_NO && + error.status == CBM_GIT_FACTS_OK; + + char target_git[GF_PATH], info[GF_PATH], graft_path[GF_PATH], gitfile_path[GF_PATH]; + char graft[160], gitfile[GF_PATH + 32]; + int graft_length = snprintf(graft, sizeof(graft), "%s %s\n", original.head, original.base); + bool target_ready = opened && gf_path(target_git, redirected.repo, ".git") && + gf_path(info, target_git, "info") && th_mkdir_p(info) == 0 && + gf_path(graft_path, info, "grafts") && graft_length > 0 && + (size_t)graft_length < sizeof(graft) && + gf_write(graft_path, graft, (size_t)graft_length); + int gitfile_length = + target_ready ? snprintf(gitfile, sizeof(gitfile), "gitdir: %s\n", target_git) : -1; + bool repointed = target_ready && gitfile_length > 0 && + (size_t)gitfile_length < sizeof(gitfile) && + gf_path(gitfile_path, original.repo, ".git") && + gf_write(gitfile_path, gitfile, (size_t)gitfile_length); + const char *control[] = {"--no-replace-objects", "merge-base", "--is-ancestor", + original.base, original.head, NULL}; + /* -C discovery now returns YES through B's graft, even with the adapter's + * no-replace flag. The original separate Git directory is left intact. */ + bool redirected_yes = repointed && gf_git(&original, control, NULL, 0); + cbm_git_ancestry_t after = + facts ? cbm_git_facts_is_ancestor(facts, original.base, original.head, &error) + : CBM_GIT_ANCESTRY_YES; + bool retained_or_rejected = + (after == CBM_GIT_ANCESTRY_NO && error.status == CBM_GIT_FACTS_OK) || + (after == CBM_GIT_ANCESTRY_ERROR && error.status != CBM_GIT_FACTS_OK); + cbm_git_facts_free(facts); + int cleanup_original = th_rmtree(original.home); + int cleanup_redirected = th_rmtree(redirected.home); + ASSERT_EQ(cleanup_original, 0); + ASSERT_EQ(cleanup_redirected, 0); + ASSERT_TRUE(same_objects); + ASSERT_TRUE(relocated); + ASSERT_TRUE(opened); + ASSERT_TRUE(originally_no); + ASSERT_TRUE(redirected_yes); + ASSERT_TRUE(retained_or_rejected); + PASS(); +} + +TEST(test_git_facts_inherited_diff_options_cannot_override_zero_context) { + const char *home = th_mktempdir("cbm-git-diff-env"); + ASSERT_NOT_NULL(home); + char log[GF_PATH]; + bool prepared = gf_runner_binary && gf_path(log, home, "child.log"); + const char *argv[] = {gf_runner_binary, "__cbm_git_facts_hostile_diff_probe", NULL}; + cbm_proc_opts_t options = { + .bin = gf_runner_binary, .argv = argv, .log_file = log, .quiet_timeout_ms = 60000}; + cbm_proc_result_t result = {0}; + int launched = prepared ? cbm_subprocess_run(&options, &result) : -1; + bool passed = launched == 0 && result.outcome == CBM_PROC_CLEAN && result.exit_code == 0 && + result.tree_quiesced; + if (!passed && prepared) { + FILE *file = cbm_fopen(log, "rb"); + if (file) { + char detail[4096]; + size_t n = fread(detail, 1, sizeof(detail) - 1, file); + detail[n] = '\0'; + fprintf(stderr, "Isolated hostile-diff child: %s\n", detail); + (void)fclose(file); + } + } + int cleanup = th_rmtree(home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(prepared); + ASSERT_EQ(launched, 0); + ASSERT_TRUE(passed); + PASS(); +} + +/* Independent D1 inventory tests. Reuse the native local-object gf_* fixture + * helpers above; construct byte-only names in Git objects, never on disk. */ +typedef struct { + const char *path; + size_t length; + uint32_t mode; + cbm_git_tree_object_type_t type; + const char *oid; +} gfi_expected_t; + +static bool gfi_matches(const cbm_git_tree_inventory_t *inventory, + const gfi_expected_t *expected, size_t count) { + if (inventory->count != count || (count && !inventory->entries)) + return false; + if (!count) + return inventory->entries == NULL; + for (size_t i = 0; i < count; i++) { + const cbm_git_tree_entry_t *entry = &inventory->entries[i]; + size_t oid_length = strlen(expected[i].oid); + if (!entry->path || entry->path_length != expected[i].length || + memcmp(entry->path, expected[i].path, expected[i].length) != 0 || + entry->path[entry->path_length] != '\0' || entry->mode != expected[i].mode || + entry->object_type != expected[i].type || oid_length >= sizeof(entry->oid) || + entry->oid[oid_length] != '\0' || memcmp(entry->oid, expected[i].oid, oid_length) != 0) + return false; + } + return true; +} + +static bool gfi_cleared(const cbm_git_tree_inventory_t *inventory) { + return inventory->entries == NULL && inventory->count == 0; +} + +static cbm_git_tree_inventory_t gfi_poisoned(void) { + static const cbm_git_tree_entry_t sentinel = {.path = "old-prefix", .path_length = 10}; + return (cbm_git_tree_inventory_t){.entries = &sentinel, .count = 1}; +} + +/* Tree object encoding is Git's fixture format: ASCII octal mode, space, + * basename bytes, NUL, raw object ID. Records supplied here are preordered in + * native Git tree order; the inventory oracle below is independently literal. */ +typedef struct { + const char *mode; + const char *name; + size_t length; + const char *oid; +} gfi_raw_entry_t; + +static int gfi_hex(unsigned char ch) { + if (ch >= '0' && ch <= '9') + return ch - '0'; + if (ch >= 'a' && ch <= 'f') + return ch - 'a' + 10; + return -1; +} + +static bool gfi_raw_tree_format(gf_fixture_t *fx, const gfi_raw_entry_t *rows, size_t count, + bool validate_format, char out[65]) { + size_t bytes = 0, oid_bytes = fx->oid_length / 2; + for (size_t i = 0; i < count; i++) { + size_t mode = strlen(rows[i].mode); + if (!rows[i].length || rows[i].length > SIZE_MAX - mode - oid_bytes - 2 || + bytes > SIZE_MAX - (mode + rows[i].length + oid_bytes + 2) || + strlen(rows[i].oid) != fx->oid_length) + return false; + bytes += mode + rows[i].length + oid_bytes + 2; + } + CBMArena arena; + cbm_arena_init_lazy(&arena, 4096); + unsigned char *buffer = cbm_arena_alloc(&arena, bytes ? bytes : 1); + bool ok = buffer != NULL; + size_t used = 0; + for (size_t i = 0; i < count && ok; i++) { + size_t mode = strlen(rows[i].mode); + memcpy(buffer + used, rows[i].mode, mode); used += mode; + buffer[used++] = ' '; + memcpy(buffer + used, rows[i].name, rows[i].length); used += rows[i].length; + buffer[used++] = 0; + for (size_t j = 0; j < oid_bytes; j++) { + int high = gfi_hex((unsigned char)rows[i].oid[2 * j]); + int low = gfi_hex((unsigned char)rows[i].oid[2 * j + 1]); + if (high < 0 || low < 0) { ok = false; break; } + buffer[used++] = (unsigned char)((high << 4) | low); + } + } + ok = ok && used == bytes; + if (ok && validate_format) { + ok = gf_object(fx, "tree", buffer, bytes, out); + } else if (ok) { + /* One synthetic output-parser fixture intentionally exceeds Git fsck's + * pathname recommendation. Keep its raw tree bytes; never materialize + * that name in the filesystem or relax normal fixture validation. */ + const char *argv[] = {"hash-object", "-w", "--no-filters", "--literally", + "-t", "tree", "--", fx->object_file, NULL}; + ok = gf_write(fx->object_file, buffer, bytes) && gf_oid(fx, argv, out); + } + cbm_arena_destroy(&arena); + return ok; +} + +static bool gfi_raw_tree(gf_fixture_t *fx, const gfi_raw_entry_t *rows, size_t count, + char out[65]) { + return gfi_raw_tree_format(fx, rows, count, true, out); +} + +static bool gfi_head_tree(gf_fixture_t *fx, const char *tree) { + char head[65]; + if (!gf_commit(fx, tree, fx->head, NULL, "inventory head", head) || + !gf_ref(fx, "refs/heads/topic", head)) + return false; + memcpy(fx->head, head, fx->oid_length + 1); + return true; +} + +#define GFI_BLOB(path_, oid_) {path_, sizeof(path_) - 1, 0100644, CBM_GIT_TREE_BLOB, oid_} + +TEST(test_git_facts_inventory_pins_complete_head_and_actual_merge_base) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + const char *read_head[] = {"read-tree", fx.head, NULL}; + const char *write_tree[] = {"write-tree", NULL}; + char tree[65]; + bool configured = gf_git(&fx, read_head, NULL, 0) && + gf_index(&fx, "100755", fx.binary, "run.sh") && + gf_oid(&fx, write_tree, tree) && gfi_head_tree(&fx, tree); + const gfi_expected_t head_rows[] = { + GFI_BLOB("-dash.bin", fx.binary), GFI_BLOB(".gitattributes", fx.attrs), + GFI_BLOB(":colon.bin", fx.binary), GFI_BLOB("[glob]*.bin", fx.binary), + GFI_BLOB("binary.bin", fx.binary), GFI_BLOB("dir/file.bin", fx.binary), + {"link", 4, 0120000, CBM_GIT_TREE_BLOB, fx.link}, + GFI_BLOB("new.bin", fx.newer), GFI_BLOB("p.txt", fx.head_text), + {"run.sh", 6, 0100755, CBM_GIT_TREE_BLOB, fx.binary}, + {"submodule", 9, 0160000, CBM_GIT_TREE_COMMIT, fx.a} + }; + const gfi_expected_t base_rows[] = { + GFI_BLOB("-dash.bin", fx.binary), GFI_BLOB(".gitattributes", fx.attrs), + GFI_BLOB(":colon.bin", fx.binary), GFI_BLOB("[glob]*.bin", fx.binary), + GFI_BLOB("binary.bin", fx.binary), GFI_BLOB("deleted.bin", fx.binary), + GFI_BLOB("dir/file.bin", fx.binary), + {"link", 4, 0120000, CBM_GIT_TREE_BLOB, fx.link}, GFI_BLOB("p.txt", fx.old_text) + }; + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = configured ? cbm_git_facts_open(&opts, &error) : NULL; + const cbm_git_facts_identity_t *id = facts ? cbm_git_facts_identity(facts) : NULL; + bool opened = id && strcmp(id->head, fx.head) == 0 && strcmp(id->merge_base, fx.q) == 0 && + strcmp(id->merge_base, fx.p) != 0; + char product[GF_PATH], untracked[GF_PATH]; + bool missing = gf_path(product, fx.repo, "p.txt") && !cbm_file_exists(product); + cbm_git_tree_inventory_t head = gfi_poisoned(), base = gfi_poisoned(); + bool first = facts && cbm_git_facts_inventory(facts, CBM_GIT_REV_HEAD, &head, &error) && + error.status == CBM_GIT_FACTS_OK && gfi_matches(&head, head_rows, 11); + const char *empty_index[] = {"read-tree", "--empty", NULL}; + bool mutated = missing && gf_git(&fx, empty_index, NULL, 0) && + gf_write(product, "ambient edit\n", 13) && gf_path(untracked, fx.repo, "untracked.c") && + gf_write(untracked, "not in commit\n", 14) && gf_ref(&fx, "refs/heads/topic", fx.a) && + gf_ref(&fx, "refs/heads/base", fx.a); + bool merge_base = facts && cbm_git_facts_inventory(facts, CBM_GIT_REV_MERGE_BASE, &base, &error) && + error.status == CBM_GIT_FACTS_OK && gfi_matches(&base, base_rows, 9); + cbm_git_tree_inventory_t again = gfi_poisoned(); + bool pinned = facts && cbm_git_facts_inventory(facts, CBM_GIT_REV_HEAD, &again, &error) && + error.status == CBM_GIT_FACTS_OK && gfi_matches(&again, head_rows, 11); + /* Earlier views remain owned by the same handle after later operations. */ + bool retained = first && merge_base && gfi_matches(&head, head_rows, 11) && + gfi_matches(&base, base_rows, 9); + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); ASSERT_TRUE(configured); ASSERT_TRUE(opened); ASSERT_TRUE(missing); + ASSERT_TRUE(mutated); ASSERT_TRUE(first); ASSERT_TRUE(merge_base); + ASSERT_TRUE(pinned); ASSERT_TRUE(retained); + PASS(); +} + +TEST(test_git_facts_inventory_preserves_literal_bytes_and_sha256) { + for (int algorithm = 0; algorithm < 2; algorithm++) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, algorithm != 0)); /* SHA-256 is required, never skipped. */ + char nested[65], tree[65]; + const gfi_raw_entry_t child[] = {{"100644", "leaf\nname", 9, fx.binary}}; + bool configured = gfi_raw_tree(&fx, child, 1, nested); + const gfi_raw_entry_t raw[] = { + {"100644", "a", 1, fx.binary}, {"100644", "aa", 2, fx.newer}, + {"40000", "dir", 3, nested}, {"100644", "line\nname", 9, fx.binary}, + {"100644", "tab\tname", 8, fx.binary}, {"100644", "z", 1, fx.binary}, + {"100644", "\x80" "high", 5, fx.binary}, {"100644", "\xff" "tail", 5, fx.newer} + }; + configured = configured && gfi_raw_tree(&fx, raw, 8, tree) && gfi_head_tree(&fx, tree); + const gfi_expected_t expected[] = { + GFI_BLOB("a", fx.binary), GFI_BLOB("aa", fx.newer), + GFI_BLOB("dir/leaf\nname", fx.binary), GFI_BLOB("line\nname", fx.binary), + GFI_BLOB("tab\tname", fx.binary), GFI_BLOB("z", fx.binary), + GFI_BLOB("\x80" "high", fx.binary), GFI_BLOB("\xff" "tail", fx.newer) + }; + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = configured ? cbm_git_facts_open(&opts, &error) : NULL; + const cbm_git_facts_identity_t *id = facts ? cbm_git_facts_identity(facts) : NULL; + bool opened = id && id->oid_hex_length == (algorithm ? 64U : 40U); + cbm_git_tree_inventory_t inventory = gfi_poisoned(); + bool exact = facts && cbm_git_facts_inventory(facts, CBM_GIT_REV_HEAD, &inventory, &error) && + error.status == CBM_GIT_FACTS_OK && gfi_matches(&inventory, expected, 8); + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + if (!configured || !opened || !exact) + fprintf(stderr, "Git inventory byte-path fixture failed (%s)\n", algorithm ? "SHA-256" : "SHA-1"); + ASSERT_EQ(cleanup, 0); ASSERT_TRUE(configured); ASSERT_TRUE(opened); ASSERT_TRUE(exact); + } + PASS(); +} + +TEST(test_git_facts_inventory_empty_and_invalid_revision_clear_output) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + const char *empty_index[] = {"read-tree", "--empty", NULL}; + const char *write_tree[] = {"write-tree", NULL}; + char tree[65]; + bool configured = gf_git(&fx, empty_index, NULL, 0) && gf_oid(&fx, write_tree, tree) && + gfi_head_tree(&fx, tree) && gf_ref(&fx, "refs/heads/base", fx.head); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = configured ? cbm_git_facts_open(&opts, &error) : NULL; + const cbm_git_facts_identity_t *id = facts ? cbm_git_facts_identity(facts) : NULL; + bool opened = id && strcmp(id->merge_base, fx.head) == 0; + cbm_git_tree_inventory_t inventory = gfi_poisoned(); + bool head_empty = facts && cbm_git_facts_inventory(facts, CBM_GIT_REV_HEAD, &inventory, &error) && + error.status == CBM_GIT_FACTS_OK && gfi_cleared(&inventory); + inventory = gfi_poisoned(); + bool invalid = facts && !cbm_git_facts_inventory(facts, CBM_GIT_REV_BASE, &inventory, &error) && + error.status == CBM_GIT_FACTS_INVALID && gfi_cleared(&inventory); + inventory = gfi_poisoned(); + bool base_empty = facts && cbm_git_facts_inventory(facts, CBM_GIT_REV_MERGE_BASE, &inventory, &error) && + error.status == CBM_GIT_FACTS_OK && gfi_cleared(&inventory); + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); ASSERT_TRUE(configured); ASSERT_TRUE(opened); + ASSERT_TRUE(head_empty); ASSERT_TRUE(invalid); ASSERT_TRUE(base_empty); + PASS(); +} + +TEST(test_git_facts_inventory_cancellation_clears_prefix_and_latches) { + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + bool cancelled = false; + cbm_git_facts_options_t opts = gf_options(&fx); + opts.cancelled = gf_cancelled; + opts.cancel_context = &cancelled; + cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + bool opened = facts && error.status == CBM_GIT_FACTS_OK; + cbm_git_blob_t blob; + bool control = facts && cbm_git_facts_read_blob(facts, CBM_GIT_REV_HEAD, "binary.bin", 10, + &blob, &error) == CBM_GIT_BLOB_FOUND && gf_bytes_equal(blob.bytes, gf_binary, sizeof(gf_binary)); + cbm_git_tree_inventory_t before = gfi_poisoned(); + bool inventoried = facts && cbm_git_facts_inventory(facts, CBM_GIT_REV_HEAD, &before, &error) && + error.status == CBM_GIT_FACTS_OK && before.count == 10 && before.entries; + cancelled = true; + cbm_git_tree_inventory_t rejected = gfi_poisoned(); + bool first = facts && !cbm_git_facts_inventory(facts, CBM_GIT_REV_HEAD, &rejected, &error) && + error.status == CBM_GIT_FACTS_CANCELLED && gfi_cleared(&rejected); + cancelled = false; /* Explicit state transition; no timing window or racing thread. */ + rejected = gfi_poisoned(); + bool latched = facts && !cbm_git_facts_inventory(facts, CBM_GIT_REV_MERGE_BASE, &rejected, &error) && + error.status == CBM_GIT_FACTS_CANCELLED && gfi_cleared(&rejected); + memset(&blob, 0xa5, sizeof(blob)); + bool existing_latch = facts && cbm_git_facts_read_blob(facts, CBM_GIT_REV_HEAD, "binary.bin", 10, + &blob, &error) == CBM_GIT_BLOB_ERROR && error.status == CBM_GIT_FACTS_CANCELLED && + gf_blob_cleared(&blob); + cbm_git_facts_free(facts); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); ASSERT_TRUE(opened); ASSERT_TRUE(control); ASSERT_TRUE(inventoried); + ASSERT_TRUE(first); ASSERT_TRUE(latched); ASSERT_TRUE(existing_latch); + PASS(); +} + +TEST(test_git_facts_inventory_output_limit_has_no_prefix_and_latches) { + enum { ITEMS = 512, NAME_LENGTH = 80, QUERY_CAP = 32768 }; + gf_fixture_t fx; + ASSERT_TRUE(gf_fixture_open(&fx, false)); + char names[ITEMS][NAME_LENGTH + 1]; + gfi_raw_entry_t rows[ITEMS]; + gfi_expected_t expected[ITEMS]; + bool configured = true; + for (int i = 0; i < ITEMS; i++) { + int n = snprintf(names[i], sizeof(names[i]), "entry-%04d-", i); + if (n <= 0 || n > NAME_LENGTH) { configured = false; break; } + memset(names[i] + n, 'x', (size_t)(NAME_LENGTH - n)); names[i][NAME_LENGTH] = '\0'; + rows[i] = (gfi_raw_entry_t){"100644", names[i], NAME_LENGTH, fx.binary}; + expected[i] = (gfi_expected_t){names[i], NAME_LENGTH, 0100644, CBM_GIT_TREE_BLOB, fx.binary}; + } + char tree[65]; + configured = configured && (size_t)ITEMS * NAME_LENGTH > QUERY_CAP && + gfi_raw_tree(&fx, rows, ITEMS, tree) && gfi_head_tree(&fx, tree); + cbm_git_facts_options_t opts = gf_options(&fx); + cbm_git_facts_error_t error; + cbm_git_facts_t *ample = configured ? cbm_git_facts_open(&opts, &error) : NULL; + bool ample_open = ample && error.status == CBM_GIT_FACTS_OK; + cbm_git_tree_inventory_t complete = gfi_poisoned(); + bool control = ample && cbm_git_facts_inventory(ample, CBM_GIT_REV_HEAD, &complete, &error) && + error.status == CBM_GIT_FACTS_OK && gfi_matches(&complete, expected, ITEMS); + cbm_git_facts_free(ample); + /* Aggregate byte budget: independent of how many native commands the API + * uses. The literal path bytes alone exceed this cap. Open must succeed. */ + opts = gf_options(&fx); + opts.total_output_limit = QUERY_CAP; + cbm_git_facts_t *limited = configured ? cbm_git_facts_open(&opts, &error) : NULL; + bool limited_open = limited && error.status == CBM_GIT_FACTS_OK; + cbm_git_tree_inventory_t inventory = gfi_poisoned(); + bool limit = limited && !cbm_git_facts_inventory(limited, CBM_GIT_REV_HEAD, &inventory, &error) && + error.status == CBM_GIT_FACTS_LIMIT && gfi_cleared(&inventory); + inventory = gfi_poisoned(); + bool latched = limited && !cbm_git_facts_inventory(limited, CBM_GIT_REV_MERGE_BASE, &inventory, &error) && + error.status == CBM_GIT_FACTS_LIMIT && gfi_cleared(&inventory); + cbm_git_blob_t blob; + memset(&blob, 0xa5, sizeof(blob)); + bool existing_latch = limited && cbm_git_facts_read_blob(limited, CBM_GIT_REV_HEAD, "entry-0000-", 11, + &blob, &error) == CBM_GIT_BLOB_ERROR && error.status == CBM_GIT_FACTS_LIMIT && + gf_blob_cleared(&blob); + cbm_git_facts_free(limited); + int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); ASSERT_TRUE(configured); ASSERT_TRUE(ample_open); ASSERT_TRUE(control); + ASSERT_TRUE(limited_open); ASSERT_TRUE(limit); ASSERT_TRUE(latched); ASSERT_TRUE(existing_latch); + PASS(); +} + +/* Independent D1b tests. Native objects/argv helpers above remain unchanged. */ +static const unsigned char gfa_old_names[] = "D\0deleted.bin\0A\0new.bin\0M\0p.txt\0A\0submodule\0"; +static const unsigned char gfa_renamed_names[] = "A\0new.c\0D\0old.c\0D\0removed.c\0"; +static const unsigned char gfa_merge_names[] = "M\0new.c\0M\0revert.c\0"; +static cbm_git_bytes_t gfa_poisoned(void) { + static const unsigned char stale[] = "stale"; + return (cbm_git_bytes_t){stale, sizeof(stale)}; +} +static bool gfa_clear(cbm_git_bytes_t out) { return out.data == NULL && out.length == 0; } +static bool gfa_clean_error(const cbm_git_facts_error_t *e) { + return e->status == CBM_GIT_FACTS_OK && e->exit_code == -1 && e->diagnostic[0] == '\0'; +} +static bool gfa_linear(gf_fixture_t *fx, const char *tree_a, const char *tree_m, const char *tree_h) { + char a[65], m[65], h[65]; + bool ok = gf_commit(fx, tree_a, NULL, NULL, "ancestor A", a) && + gf_commit(fx, tree_m, a, NULL, "ancestor M", m) && + gf_commit(fx, tree_h, m, NULL, "ancestor H", h) && + gf_ref(fx, "refs/heads/topic", h) && gf_ref(fx, "refs/heads/base", m); + if (ok) { + memcpy(fx->a, a, fx->oid_length + 1); memcpy(fx->q, m, fx->oid_length + 1); + memcpy(fx->base, m, fx->oid_length + 1); memcpy(fx->head, h, fx->oid_length + 1); + } + return ok; +} +static bool gfa_rename_history(gf_fixture_t *fx) { + const gfi_raw_entry_t a[] = { + {"100644", "old.c", 5, fx->old_text}, {"100644", "removed.c", 9, fx->binary}, + {"100644", "revert.c", 8, fx->old_text} + }; + const gfi_raw_entry_t m[] = { + {"100644", "new.c", 5, fx->old_text}, {"100644", "revert.c", 8, fx->head_text} + }; + const gfi_raw_entry_t h[] = { + {"100644", "new.c", 5, fx->head_text}, {"100644", "revert.c", 8, fx->old_text} + }; + char ta[65], tm[65], th[65]; + return gfi_raw_tree(fx, a, 3, ta) && gfi_raw_tree(fx, m, 2, tm) && + gfi_raw_tree(fx, h, 2, th) && gfa_linear(fx, ta, tm, th) && + gf_config(fx, "diff.renames", "true"); +} + +TEST(test_git_ancestor_changes_requires_ancestor_of_actual_merge_base) { + gf_fixture_t fx; ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t opts = gf_options(&fx); cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); + const cbm_git_facts_identity_t *id = facts ? cbm_git_facts_identity(facts) : NULL; + bool identity = id && strcmp(id->merge_base, fx.q) == 0 && strcmp(id->merge_base, fx.p) != 0; + bool controls = facts && cbm_git_facts_is_ancestor(facts, fx.p, fx.head, &error) == CBM_GIT_ANCESTRY_YES && + cbm_git_facts_is_ancestor(facts, fx.p, fx.q, &error) == CBM_GIT_ANCESTRY_NO; + cbm_git_bytes_t out = gfa_poisoned(); memset(&error, 0xa5, sizeof(error)); + int no = facts ? cbm_git_facts_ancestor_changes(facts, fx.p, fx.oid_length, &out, &error) : -9; + bool normal_no = no == CBM_GIT_ANCESTOR_CHANGES_NOT_ANCESTOR && gfa_clear(out) && gfa_clean_error(&error); + out = gfa_poisoned(); memset(&error, 0xa5, sizeof(error)); + int head = facts ? cbm_git_facts_ancestor_changes(facts, fx.head, fx.oid_length, &out, &error) : -9; + bool head_no = head == CBM_GIT_ANCESTOR_CHANGES_NOT_ANCESTOR && gfa_clear(out) && gfa_clean_error(&error); + out = gfa_poisoned(); memset(&error, 0xa5, sizeof(error)); + int yes = facts ? cbm_git_facts_ancestor_changes(facts, fx.a, fx.oid_length, &out, &error) : -9; + bool reused = yes == CBM_GIT_ANCESTOR_CHANGES_OK && gfa_clean_error(&error) && + gf_bytes_equal(out, gfa_old_names, sizeof(gfa_old_names)-1); + cbm_git_facts_free(facts); int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); ASSERT_TRUE(identity); ASSERT_TRUE(controls); + ASSERT_TRUE(normal_no); ASSERT_TRUE(head_no); ASSERT_TRUE(reused); PASS(); +} + +TEST(test_git_ancestor_changes_preserves_earlier_paths_reversion_and_pinned_head) { + gf_fixture_t fx; ASSERT_TRUE(gf_fixture_open(&fx, false)); + bool configured = gfa_rename_history(&fx); + cbm_git_facts_options_t opts = gf_options(&fx); cbm_git_facts_error_t error; + cbm_git_facts_t *facts = configured ? cbm_git_facts_open(&opts, &error) : NULL; + const cbm_git_facts_identity_t *id = facts ? cbm_git_facts_identity(facts) : NULL; + bool identity = id && strcmp(id->merge_base, fx.q) == 0 && strcmp(id->head, fx.head) == 0; + cbm_git_diff_t old = {0}; + bool legacy = facts && cbm_git_facts_diff(facts, &old, &error) && + gf_bytes_equal(old.name_status, gfa_merge_names, sizeof(gfa_merge_names)-1); + char path[GF_PATH]; const char *empty[] = {"read-tree", "--empty", NULL}; + bool moved = legacy && gf_ref(&fx, "refs/heads/topic", fx.a) && + gf_ref(&fx, "refs/heads/base", fx.a) && gf_git(&fx, empty, NULL, 0) && + gf_index(&fx, "100644", fx.binary, "ambient.c") && + gf_path(path, fx.repo, "new.c") && gf_write(path, "ambient\n", 8); + cbm_git_bytes_t out = gfa_poisoned(); + int rc = facts ? cbm_git_facts_ancestor_changes(facts, fx.a, fx.oid_length, &out, &error) : -9; + bool exact = rc == CBM_GIT_ANCESTOR_CHANGES_OK && gfa_clean_error(&error) && + gf_bytes_equal(out, gfa_renamed_names, sizeof(gfa_renamed_names)-1); + cbm_git_diff_t again = {0}; bool unchanged = facts && cbm_git_facts_diff(facts, &again, &error) && + gf_bytes_equal(again.name_status, gfa_merge_names, sizeof(gfa_merge_names)-1); + cbm_git_facts_free(facts); int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); ASSERT_TRUE(configured); ASSERT_TRUE(identity); ASSERT_TRUE(legacy); + ASSERT_TRUE(moved); ASSERT_TRUE(exact); ASSERT_TRUE(unchanged); PASS(); +} + +TEST(test_git_ancestor_changes_equalities_and_empty_reversion_are_distinct) { + gf_fixture_t fx; ASSERT_TRUE(gf_fixture_open(&fx, false)); + cbm_git_facts_options_t opts = gf_options(&fx); cbm_git_facts_error_t error; + cbm_git_facts_t *facts = cbm_git_facts_open(&opts, &error); cbm_git_diff_t old = {0}; + bool legacy = facts && cbm_git_facts_diff(facts, &old, &error); + cbm_git_bytes_t out = gfa_poisoned(); + bool equal_m = facts && cbm_git_facts_ancestor_changes(facts, fx.q, fx.oid_length, &out, &error) == CBM_GIT_ANCESTOR_CHANGES_OK && + gfa_clean_error(&error) && legacy && gf_bytes_equal(out, old.name_status.data, old.name_status.length); + cbm_git_facts_free(facts); + bool base_at_head = gf_ref(&fx, "refs/heads/base", fx.head); + opts = gf_options(&fx); facts = base_at_head ? cbm_git_facts_open(&opts, &error) : NULL; + const cbm_git_facts_identity_t *id = facts ? cbm_git_facts_identity(facts) : NULL; + bool identity = id && strcmp(id->head, id->merge_base) == 0; + out = gfa_poisoned(); bool all_equal = facts && cbm_git_facts_ancestor_changes(facts, fx.head, fx.oid_length, &out, &error) == CBM_GIT_ANCESTOR_CHANGES_OK && + gfa_clean_error(&error) && out.length == 0; + cbm_git_facts_free(facts); + /* Empty A->H with nonempty M->H: empty output cannot clear has_changes. */ + const gfi_raw_entry_t a[] = {{"100644", "revert.c", 8, fx.old_text}}; + const gfi_raw_entry_t m[] = {{"100644", "revert.c", 8, fx.head_text}}; + char ta[65], tm[65]; bool history = gfi_raw_tree(&fx, a, 1, ta) && gfi_raw_tree(&fx, m, 1, tm) && gfa_linear(&fx, ta, tm, ta); + opts = gf_options(&fx); facts = history ? cbm_git_facts_open(&opts, &error) : NULL; + old = (cbm_git_diff_t){0}; const char changed[] = "M\0revert.c\0"; + bool nonempty = facts && cbm_git_facts_diff(facts, &old, &error) && gf_bytes_equal(old.name_status, changed, sizeof(changed)-1); + out = gfa_poisoned(); bool reverted = facts && cbm_git_facts_ancestor_changes(facts, fx.a, fx.oid_length, &out, &error) == CBM_GIT_ANCESTOR_CHANGES_OK && + gfa_clean_error(&error) && out.length == 0; + cbm_git_facts_free(facts); int cleanup = th_rmtree(fx.home); + ASSERT_EQ(cleanup, 0); ASSERT_TRUE(legacy); ASSERT_TRUE(equal_m); ASSERT_TRUE(base_at_head); + ASSERT_TRUE(identity); ASSERT_TRUE(all_equal); ASSERT_TRUE(history); ASSERT_TRUE(nonempty); ASSERT_TRUE(reverted); PASS(); +} + +TEST(test_git_ancestor_changes_validates_full_commit_object_and_argument_span) { + gf_fixture_t fx; ASSERT_TRUE(gf_fixture_open(&fx, false)); + char expression[96], tree[65], tag[65], tag_bytes[512], missing[65]; + int n = snprintf(expression, sizeof(expression), "%s^{tree}", fx.head); + const char *tree_args[] = {"rev-parse", "--verify", expression, NULL}; + bool tree_ok = n > 0 && (size_t)n < sizeof(expression) && gf_oid(&fx, tree_args, tree); + n = snprintf(tag_bytes, sizeof(tag_bytes), "object %s\ntype commit\ntag fixture\ntagger Git Facts Fixture 946684800 +0000\n\nfixture tag\n", fx.a); + bool tag_ok = n > 0 && (size_t)n < sizeof(tag_bytes) && gf_object(&fx, "tag", tag_bytes, (size_t)n, tag); + memset(missing, '0', fx.oid_length); missing[fx.oid_length] = '\0'; + const char *missing_type[] = {"cat-file", "-t", missing, NULL}; + bool absent_control = gf_git_exit(&fx, missing_type, 128, NULL, 0); + cbm_git_facts_options_t opts = gf_options(&fx); cbm_git_facts_error_t error; + cbm_git_facts_t *facts = tree_ok && tag_ok && absent_control ? cbm_git_facts_open(&opts, &error) : NULL; + bool opened = facts != NULL, invalid = opened; + for (unsigned i=0; facts && i<12; i++) { + char input[65]; memset(input, 'a', sizeof(input)); memcpy(input, fx.a, fx.oid_length); + const char *arg = input; size_t len = fx.oid_length; + if (i==0) arg=NULL; + if (i==1) len=0; + if (i==2) len=fx.oid_length-1; + if (i==3) len=64; /* SHA-256 width is invalid for this SHA-1 owner. */ + if (i==4) input[4]='\0'; + if (i==5) input[4]='g'; + if (i==6) input[4]=' '; + if (i==7) input[4]='^'; + if (i==8) { arg="HEAD"; len=4; } + if (i==9) input[0]='-'; + cbm_git_bytes_t out=gfa_poisoned(); memset(&error,0xa5,sizeof(error)); + int rc=cbm_git_facts_ancestor_changes(i==10?NULL:facts,arg,len,i==11?NULL:&out,&error); + invalid = rc==CBM_GIT_ANCESTOR_CHANGES_ERROR && error.status==CBM_GIT_FACTS_INVALID && + (i==11 || gfa_clear(out)) && invalid; + } + const char *wrong_type[] = {tree, fx.binary, tag}; bool rejected=opened; + for (size_t i=0; facts && i<3; i++) { + cbm_git_bytes_t out=gfa_poisoned(); memset(&error,0xa5,sizeof(error)); + int rc=cbm_git_facts_ancestor_changes(facts,wrong_type[i],fx.oid_length,&out,&error); + rejected = rc==CBM_GIT_ANCESTOR_CHANGES_ERROR && error.status==CBM_GIT_FACTS_INVALID && gfa_clear(out) && rejected; + } + cbm_git_bytes_t out=gfa_poisoned(); int absent=facts?cbm_git_facts_ancestor_changes(facts,missing,fx.oid_length,&out,&error):-9; + bool missing_error=absent==CBM_GIT_ANCESTOR_CHANGES_ERROR && error.status==CBM_GIT_FACTS_COMMAND && gfa_clear(out); + out=gfa_poisoned(); bool reusable=facts && cbm_git_facts_ancestor_changes(facts,fx.a,fx.oid_length,&out,NULL)==CBM_GIT_ANCESTOR_CHANGES_OK && + gf_bytes_equal(out,gfa_old_names,sizeof(gfa_old_names)-1); + cbm_git_facts_free(facts); int cleanup=th_rmtree(fx.home); + ASSERT_EQ(cleanup,0); ASSERT_TRUE(tree_ok); ASSERT_TRUE(tag_ok); ASSERT_TRUE(absent_control); ASSERT_TRUE(opened); + ASSERT_TRUE(invalid); ASSERT_TRUE(rejected); ASSERT_TRUE(missing_error); ASSERT_TRUE(reusable); PASS(); +} + +typedef struct { char *input; size_t length; bool armed, overwritten; } gfa_borrow_t; +/* Prompt and non-reentrant: poisons only the caller's borrowed bytes at the first gate. */ +static bool gfa_poison_borrowed_oid(void *context) { + gfa_borrow_t *c=context; + if(c->armed && !c->overwritten) { memset(c->input,'#',c->length);c->overwritten=true; } + return false; +} +TEST(test_git_ancestor_changes_sha256_raw_paths_and_local_oid_copy) { + enum { LONG_PATH=65537, EXPECTED_CAP=LONG_PATH+1024 }; + for(int algorithm=0;algorithm<2;algorithm++) { + gf_fixture_t fx; ASSERT_TRUE(gf_fixture_open(&fx,algorithm!=0)); + char long_name[LONG_PATH+1];memset(long_name,'z',LONG_PATH);long_name[LONG_PATH]='\0'; + const gfi_raw_entry_t a[] = { + {"100644","binary",6,fx.binary},{"100644","deleted",7,fx.binary}, + {"160000","gitlink",7,fx.a},{"100644","mode",4,fx.binary},{"100644","type",4,fx.binary} + }; + const gfi_raw_entry_t h[] = { + {"100644","-dash",5,fx.binary},{"100644",":colon",6,fx.binary},{"100644","[glob]*",7,fx.binary}, + {"100644","back\\slash",10,fx.binary},{"100644","binary",6,fx.newer}, + {"160000","gitlink",7,fx.p},{"100644","line\nname",9,fx.binary},{"100755","mode",4,fx.binary}, + {"100644","tab\tname",8,fx.binary},{"120000","type",4,fx.link}, + {"100644",long_name,LONG_PATH,fx.binary},{"100644","\x80" "high",5,fx.binary} + }; + char ta[65],th[65];bool history=gfi_raw_tree(&fx,a,5,ta) && gfi_raw_tree_format(&fx,h,12,false,th) && gfa_linear(&fx,ta,ta,th); + const char prefix[]="A\0-dash\0A\0:colon\0A\0[glob]*\0A\0back\\slash\0M\0binary\0D\0deleted\0M\0gitlink\0A\0line\nname\0M\0mode\0A\0tab\tname\0T\0type\0A\0"; + const char suffix[]="A\0\x80" "high\0"; + unsigned char expected[EXPECTED_CAP];size_t used=0; + memcpy(expected+used,prefix,sizeof(prefix)-1);used+=sizeof(prefix)-1; + memcpy(expected+used,long_name,LONG_PATH);used+=LONG_PATH;expected[used++]=0; + memcpy(expected+used,suffix,sizeof(suffix)-1);used+=sizeof(suffix)-1; + char caller_oid[64];memset(caller_oid,'?',sizeof(caller_oid));memcpy(caller_oid,fx.a,fx.oid_length); + unsigned uppercase=0; + for(unsigned i=0;i='a' && caller_oid[i]<='f'){caller_oid[i]=(char)(caller_oid[i]-'a'+'A');uppercase++;} + gfa_borrow_t borrow={.input=caller_oid,.length=fx.oid_length}; + cbm_git_facts_options_t opts=gf_options(&fx);opts.cancelled=gfa_poison_borrowed_oid;opts.cancel_context=&borrow; + cbm_git_facts_error_t error;cbm_git_facts_t *facts=history?cbm_git_facts_open(&opts,&error):NULL; + const cbm_git_facts_identity_t *id=facts?cbm_git_facts_identity(facts):NULL; + bool identity=id && id->oid_hex_length==(algorithm?64U:40U) && strcmp(id->merge_base,fx.q)==0; + cbm_git_bytes_t out=gfa_poisoned();borrow.armed=true; + int rc=facts?cbm_git_facts_ancestor_changes(facts,caller_oid,fx.oid_length,&out,&error):-9; + borrow.armed=false; + bool exact=rc==CBM_GIT_ANCESTOR_CHANGES_OK && gfa_clean_error(&error) && gf_bytes_equal(out,expected,used); + memset(caller_oid,'!',sizeof(caller_oid));bool retained=exact && gf_bytes_equal(out,expected,used); + cbm_git_bytes_t again=gfa_poisoned(); + bool equal_m=facts && cbm_git_facts_ancestor_changes(facts,fx.q,fx.oid_length,&again,&error)==CBM_GIT_ANCESTOR_CHANGES_OK && gf_bytes_equal(again,expected,used); + bool wrong_width=true; + if(algorithm) { again=gfa_poisoned();int bad=cbm_git_facts_ancestor_changes(facts,fx.a,40,&again,&error);wrong_width=bad==CBM_GIT_ANCESTOR_CHANGES_ERROR && error.status==CBM_GIT_FACTS_INVALID && gfa_clear(again); } + cbm_git_facts_free(facts);int cleanup=th_rmtree(fx.home); + ASSERT_EQ(cleanup,0);ASSERT_TRUE(history);ASSERT_TRUE(identity);ASSERT_TRUE(uppercase>0);ASSERT_TRUE(borrow.overwritten); + ASSERT_TRUE(exact);ASSERT_TRUE(retained);ASSERT_TRUE(equal_m);ASSERT_TRUE(wrong_width); + } + PASS(); +} + +TEST(test_git_ancestor_changes_repeats_preserve_all_previous_owner_buffers) { + gf_fixture_t fx;ASSERT_TRUE(gf_fixture_open(&fx,false));bool configured=gfa_rename_history(&fx); + cbm_git_facts_options_t opts=gf_options(&fx);cbm_git_facts_error_t error; + cbm_git_facts_t *facts=configured?cbm_git_facts_open(&opts,&error):NULL;cbm_git_diff_t legacy={0}; + bool old=facts && cbm_git_facts_diff(facts,&legacy,&error); + unsigned char patch[16384],names[1024];bool copied=old && legacy.patch.length>0 && legacy.patch.length<=sizeof(patch) && legacy.name_status.length<=sizeof(names); + if(copied){memcpy(patch,legacy.patch.data,legacy.patch.length);memcpy(names,legacy.name_status.data,legacy.name_status.length);} + const unsigned char *old_patch=legacy.patch.data,*old_names=legacy.name_status.data; + cbm_git_bytes_t output=gfa_poisoned();bool first=facts && cbm_git_facts_ancestor_changes(facts,fx.a,fx.oid_length,&output,&error)==CBM_GIT_ANCESTOR_CHANGES_OK; + cbm_git_bytes_t alias_a=output; + bool second=facts && cbm_git_facts_ancestor_changes(facts,fx.q,fx.oid_length,&output,&error)==CBM_GIT_ANCESTOR_CHANGES_OK; + cbm_git_bytes_t alias_m=output; + bool no=facts && cbm_git_facts_ancestor_changes(facts,fx.head,fx.oid_length,&output,&error)==CBM_GIT_ANCESTOR_CHANGES_NOT_ANCESTOR && gfa_clear(output) && gfa_clean_error(&error); + bool invalid=facts && cbm_git_facts_ancestor_changes(facts,"HEAD",4,&output,&error)==CBM_GIT_ANCESTOR_CHANGES_ERROR && error.status==CBM_GIT_FACTS_INVALID && gfa_clear(output); + cbm_git_diff_t later={0};bool repeated=facts && cbm_git_facts_diff(facts,&later,&error); + bool retained=copied && first && second && legacy.patch.data==old_patch && legacy.name_status.data==old_names && + gf_bytes_equal(legacy.patch,patch,legacy.patch.length) && gf_bytes_equal(legacy.name_status,names,legacy.name_status.length) && + gf_bytes_equal(alias_a,gfa_renamed_names,sizeof(gfa_renamed_names)-1) && + gf_bytes_equal(alias_m,gfa_merge_names,sizeof(gfa_merge_names)-1) && repeated && + gf_bytes_equal(later.patch,patch,legacy.patch.length) && gf_bytes_equal(later.name_status,names,legacy.name_status.length); + cbm_git_facts_free(facts);int cleanup=th_rmtree(fx.home); + ASSERT_EQ(cleanup,0);ASSERT_TRUE(configured);ASSERT_TRUE(old);ASSERT_TRUE(copied);ASSERT_TRUE(first);ASSERT_TRUE(second); + ASSERT_TRUE(no);ASSERT_TRUE(invalid);ASSERT_TRUE(repeated);ASSERT_TRUE(retained);PASS(); +} + +TEST(test_git_ancestor_changes_cancellation_is_shared_terminal_and_preserves_views) { + gf_fixture_t fx;ASSERT_TRUE(gf_fixture_open(&fx,false));bool cancelled=false; + cbm_git_facts_options_t opts=gf_options(&fx);opts.cancelled=gf_cancelled;opts.cancel_context=&cancelled; + cbm_git_facts_error_t error;cbm_git_facts_t *facts=cbm_git_facts_open(&opts,&error); + cbm_git_bytes_t old=gfa_poisoned();bool control=facts && cbm_git_facts_ancestor_changes(facts,fx.q,fx.oid_length,&old,&error)==CBM_GIT_ANCESTOR_CHANGES_OK && + gf_bytes_equal(old,gfa_old_names,sizeof(gfa_old_names)-1); + cancelled=true;cbm_git_bytes_t out=gfa_poisoned(); + bool rejected=facts && cbm_git_facts_ancestor_changes(facts,fx.a,fx.oid_length,&out,&error)==CBM_GIT_ANCESTOR_CHANGES_ERROR && + error.status==CBM_GIT_FACTS_CANCELLED && gfa_clear(out); + cancelled=false;out=gfa_poisoned(); + bool sticky=facts && cbm_git_facts_ancestor_changes(facts,fx.q,fx.oid_length,&out,&error)==CBM_GIT_ANCESTOR_CHANGES_ERROR && + error.status==CBM_GIT_FACTS_CANCELLED && gfa_clear(out); + cbm_git_diff_t diff;memset(&diff,0xa5,sizeof(diff)); + bool shared=facts && !cbm_git_facts_diff(facts,&diff,&error) && error.status==CBM_GIT_FACTS_CANCELLED && + gfa_clear(diff.patch) && gfa_clear(diff.name_status); + bool retained=control && gf_bytes_equal(old,gfa_old_names,sizeof(gfa_old_names)-1); + cbm_git_facts_free(facts);int cleanup=th_rmtree(fx.home); + ASSERT_EQ(cleanup,0);ASSERT_TRUE(control);ASSERT_TRUE(rejected);ASSERT_TRUE(sticky);ASSERT_TRUE(shared);ASSERT_TRUE(retained);PASS(); +} + +TEST(test_git_ancestor_changes_output_and_cumulative_limits_publish_no_prefix) { + enum { ITEMS=512, LENGTH=80, CAP=32768 }; + gf_fixture_t fx;ASSERT_TRUE(gf_fixture_open(&fx,false)); + char names[ITEMS][LENGTH+1];gfi_raw_entry_t rows[ITEMS];bool configured=true; + for(int i=0;iLENGTH){configured=false;break;} + memset(names[i]+n,'x',(size_t)(LENGTH-n));names[i][LENGTH]='\0'; + rows[i]=(gfi_raw_entry_t){"100644",names[i],LENGTH,fx.binary}; + } + char tree[65];configured=configured && gfi_raw_tree(&fx,rows,ITEMS,tree) && gfi_head_tree(&fx,tree); + cbm_git_facts_options_t opts=gf_options(&fx);cbm_git_facts_error_t error; + cbm_git_facts_t *ample=configured?cbm_git_facts_open(&opts,&error):NULL;cbm_git_bytes_t complete=gfa_poisoned(); + bool control=ample && cbm_git_facts_ancestor_changes(ample,fx.q,fx.oid_length,&complete,&error)==CBM_GIT_ANCESTOR_CHANGES_OK && + gfa_clean_error(&error) && complete.length>CAP && complete.data; + bool all_entries=control; + for(int i=0;i0 && gf_bytes_equal(first,gfa_old_names,sizeof(gfa_old_names)-1) && gf_bytes_equal(kept.bytes,gf_binary,sizeof(gf_binary)); + cbm_git_facts_free(facts);int cleanup=th_rmtree(fx.home); + ASSERT_EQ(cleanup,0);ASSERT_TRUE(opened);ASSERT_TRUE(prior);ASSERT_TRUE(successes>0);ASSERT_TRUE(!unexpected);ASSERT_TRUE(limit);ASSERT_TRUE(sticky);ASSERT_TRUE(shared);ASSERT_TRUE(retained);PASS(); +} + +static bool gfa_topology_stays_pinned(void) { + gf_fixture_t original,redirected; + if(!gf_fixture_open(&original,false))return false; + if(!gf_fixture_open(&redirected,false)){(void)th_rmtree(original.home);return false;} + bool same=strcmp(original.q,redirected.q)==0 && strcmp(original.p,redirected.p)==0; + char original_git[GF_PATH];bool named=gf_path(original_git,original.home,"ancestor-original-git"); + const char *relocate[]={"-c","init.templateDir=","init","--quiet","--separate-git-dir",original_git,NULL}; + bool relocated=same && named && gf_git(&original,relocate,NULL,0); + cbm_git_facts_options_t opts=gf_options(&original);cbm_git_facts_error_t error; + cbm_git_facts_t *facts=relocated?cbm_git_facts_open(&opts,&error):NULL; + cbm_git_bytes_t out=gfa_poisoned(); + bool before=facts && cbm_git_facts_ancestor_changes(facts,original.p,original.oid_length,&out,&error)==CBM_GIT_ANCESTOR_CHANGES_NOT_ANCESTOR && gfa_clear(out) && gfa_clean_error(&error); + char target[GF_PATH],info[GF_PATH],graft_path[GF_PATH],gitfile_path[GF_PATH]; + char graft[160],gitfile[GF_PATH+32];int n=snprintf(graft,sizeof(graft),"%s %s\n",original.q,original.p); + bool grafted=n>0 && (size_t)n0 && (size_t)n0 && (size_t)narena, 4096); + if (!gf_fixture_open(&f->git, sha256)) + return false; + bool ok = gf_object(&f->git, "blob", "", 0, f->empty_oid) && + gf_object(&f->git, "blob", gfb_binary, sizeof(gfb_binary), f->binary_oid) && + gf_object(&f->git, "blob", gfb_header_body, sizeof(gfb_header_body)-1, f->header_oid); + for (int j = 0; ok && j < 3; j++) { + f->large[j] = cbm_arena_alloc(&f->arena, GFB_BODY); + ok = f->large[j] != NULL; + for (size_t i = 0; ok && i < GFB_BODY; i++) + f->large[j][i] = (unsigned char)((i + (size_t)j * 31) % 251); + if (ok) + ok = gf_object(&f->git, "blob", f->large[j], GFB_BODY, f->large_oid[j]); + } + const char *paths[] = {":colon", "[g]*", "back\\slash", "empty", "header", "large-a", + "large-b", "large-c", "line\nbreak", "link", "p.txt", "submodule", "tab\tname", gfb_utf}; + const char *oids[] = {f->binary_oid, f->binary_oid, f->binary_oid, f->empty_oid, + f->header_oid, f->large_oid[0], f->large_oid[1], f->large_oid[2], f->binary_oid, + f->git.link, f->git.head_text, f->git.a, f->binary_oid, f->binary_oid}; + gfi_raw_entry_t rows[GFB_COUNT]; + for (size_t i = 0; i < GFB_COUNT; i++) { + uint32_t mode = i == GFB_EXEC ? 0100755 : i == GFB_LINK ? 0120000 : + i == GFB_SUBMODULE ? 0160000 : 0100644; + const char *mode_text = i == GFB_EXEC ? "100755" : i == GFB_LINK ? "120000" : + i == GFB_SUBMODULE ? "160000" : "100644"; + size_t length = i == GFB_UTF ? sizeof(gfb_utf) : strlen(paths[i]); + f->expected[i] = (gfi_expected_t){paths[i], length, mode, + i == GFB_SUBMODULE ? CBM_GIT_TREE_COMMIT : CBM_GIT_TREE_BLOB, oids[i]}; + rows[i] = (gfi_raw_entry_t){mode_text, paths[i], length, oids[i]}; + } + char th[65], tm[65], q[65], h[65], b[65]; + ok = ok && gfi_raw_tree(&f->git, rows, GFB_COUNT, th); + rows[GFB_TEXT].oid = f->git.old_text; + ok = ok && gfi_raw_tree(&f->git, rows, GFB_COUNT, tm) && + gf_commit(&f->git, tm, f->git.a, NULL, "batch Q", q) && + gf_commit(&f->git, th, f->git.p, q, "batch H", h) && + gf_commit(&f->git, tm, q, NULL, "batch B", b) && + gf_ref(&f->git, "refs/heads/topic", h) && gf_ref(&f->git, "refs/heads/base", b); + if (ok) { + memcpy(f->git.q, q, f->git.oid_length+1); + memcpy(f->git.head, h, f->git.oid_length+1); + memcpy(f->git.base, b, f->git.oid_length+1); + } + return ok; +} + +static int gfb_close(gfb_fixture_t *f) { + cbm_arena_destroy(&f->arena); + return f->git.home[0] ? th_rmtree(f->git.home) : 0; +} + +static cbm_git_blob_batch_limits_t gfb_limits(void) { + return (cbm_git_blob_batch_limits_t){.max_entries=1024, .max_input_bytes=1024*1024, + .max_arena_bytes=32U*1024U*1024U}; +} + +static cbm_git_blob_batch_t gfb_poisoned(void) { + static const cbm_git_blob_batch_item_t old = {.inventory_index=123}; + return (cbm_git_blob_batch_t){.revision=CBM_GIT_REV_MERGE_BASE, + .commit="old", .items=&old, .count=1}; +} + +static bool gfb_cleared(cbm_git_blob_batch_t out) { + return out.revision == 0 && out.commit == NULL && out.items == NULL && out.count == 0; +} + +static cbm_git_bytes_t gfb_expected_bytes(gfb_fixture_t *f, cbm_git_revision_t rev, size_t index) { + if (index == GFB_EMPTY) return (cbm_git_bytes_t){NULL,0}; + if (index == GFB_HEADER) return (cbm_git_bytes_t){(const unsigned char *)gfb_header_body, + sizeof(gfb_header_body)-1}; + if (index >= GFB_LARGE_A && index <= GFB_LARGE_C) + return (cbm_git_bytes_t){f->large[index-GFB_LARGE_A],GFB_BODY}; + if (index == GFB_TEXT) { + const char *text = rev == CBM_GIT_REV_HEAD ? gf_head_text : gf_old_text; + return (cbm_git_bytes_t){(const unsigned char *)text,strlen(text)}; + } + return (cbm_git_bytes_t){gfb_binary,sizeof(gfb_binary)}; +} + +static bool gfb_matches(gfb_fixture_t *f, cbm_git_revision_t rev, const size_t *indices, + size_t count, const cbm_git_blob_batch_t *out) { + const char *commit = rev == CBM_GIT_REV_HEAD ? f->git.head : f->git.q; + if (out->revision != rev || !out->commit || strcmp(out->commit,commit) || + out->count != count || (count ? !out->items : out->items != NULL)) return false; + for (size_t i=0;i=GFB_COUNT || index==GFB_LINK || index==GFB_SUBMODULE) return false; + const cbm_git_blob_batch_item_t *item=&out->items[i]; + const cbm_git_tree_entry_t *e=item->entry; + const gfi_expected_t *want=&f->expected[index]; + const char *oid=index==GFB_TEXT && rev==CBM_GIT_REV_MERGE_BASE ? f->git.old_text : want->oid; + cbm_git_bytes_t bytes=gfb_expected_bytes(f,rev,index); + if (item->inventory_index!=index || !e || !e->path || + e->path_length!=want->length || memcmp(e->path,want->path,want->length) || + e->path[e->path_length]!=0 || e->mode!=want->mode || e->object_type!=CBM_GIT_TREE_BLOB || + strcmp(e->oid,oid) || !gf_bytes_equal(item->bytes,bytes.data,bytes.length)) return false; + } + return true; +} + +static bool gfb_inventory_control(gfb_fixture_t *f, cbm_git_facts_t *facts) { + cbm_git_facts_error_t error; + cbm_git_tree_inventory_t inventory={0}; + const cbm_git_facts_identity_t *id=cbm_git_facts_identity(facts); + return id && strcmp(id->merge_base,f->git.q)==0 && strcmp(id->merge_base,f->git.p)!=0 && + cbm_git_facts_inventory(facts,CBM_GIT_REV_HEAD,&inventory,&error) && + gfi_matches(&inventory,f->expected,GFB_COUNT); +} + +static bool gfb_good(gfb_fixture_t *f, cbm_git_facts_t *facts, cbm_git_revision_t rev, + const size_t *indices, size_t count, const cbm_git_blob_batch_limits_t *limits, + cbm_git_blob_batch_t *keep) { + cbm_git_blob_batch_request_t request={rev,indices,count}; + cbm_git_blob_batch_t out=gfb_poisoned(); cbm_git_facts_error_t error; + memset(&error,0xa5,sizeof(error)); + bool ok=cbm_git_facts_read_blob_batch(facts,&request,limits,&out,&error) && + gfa_clean_error(&error) && gfb_matches(f,rev,indices,count,&out); + if (keep) *keep=out; + return ok; +} + +static bool gfb_error(cbm_git_facts_t *facts, const cbm_git_blob_batch_request_t *request, + const cbm_git_blob_batch_limits_t *limits, cbm_git_facts_status_t expected) { + cbm_git_blob_batch_t out=gfb_poisoned(); cbm_git_facts_error_t error; + memset(&error,0xa5,sizeof(error)); + return !cbm_git_facts_read_blob_batch(facts,request,limits,&out,&error) && + error.status==expected && gfb_cleared(out); +} + +TEST(test_git_blob_batch_native_bytes_and_both_object_formats) { + bool setups=true, exact=true, clean=true; + for (int algorithm=0;algorithm<2;algorithm++) { + gfb_fixture_t f; bool configured=gfb_open(&f,algorithm!=0); + cbm_git_facts_options_t opts=gf_options(&f.git); cbm_git_facts_error_t error; + cbm_git_facts_t *facts=configured?cbm_git_facts_open(&opts,&error):NULL; + bool control=facts && gfb_inventory_control(&f,facts); + const size_t head[]={GFB_EMPTY,GFB_COLON,GFB_HEADER,GFB_LARGE_A,GFB_EXEC,GFB_TEXT}; + const size_t merge[]={GFB_TEXT,GFB_LARGE_B,GFB_EMPTY}; + cbm_git_blob_batch_limits_t limits=gfb_limits(); + exact=control && gfb_good(&f,facts,CBM_GIT_REV_HEAD,head,6,&limits,NULL) && + gfb_good(&f,facts,CBM_GIT_REV_MERGE_BASE,merge,3,&limits,NULL) && exact; + setups=configured && control && setups; + cbm_git_facts_free(facts); clean=gfb_close(&f)==0 && clean; + } + ASSERT_TRUE(clean); ASSERT_TRUE(setups); ASSERT_TRUE(exact); PASS(); +} + +TEST(test_git_blob_batch_keeps_request_order_duplicates_and_raw_paths) { + gfb_fixture_t f; bool configured=gfb_open(&f,true); + cbm_git_facts_options_t opts=gf_options(&f.git); cbm_git_facts_error_t error; + cbm_git_facts_t *facts=configured?cbm_git_facts_open(&opts,&error):NULL; + bool control=facts && gfb_inventory_control(&f,facts); + const size_t indices[]={GFB_UTF,GFB_EXEC,GFB_COLON,GFB_TAB,GFB_LINE,GFB_GLOB,GFB_EXEC,GFB_UTF}; + cbm_git_blob_batch_limits_t limits=gfb_limits(); cbm_git_blob_batch_t out={0}; + bool exact=control && gfb_good(&f,facts,CBM_GIT_REV_HEAD,indices,8,&limits,&out); + bool distinct=exact && out.items[1].entry->mode==0100755 && out.items[2].entry->mode==0100644 && + strcmp(out.items[1].entry->oid,out.items[2].entry->oid)==0 && + out.items[1].entry->path_length!=out.items[2].entry->path_length; + cbm_git_facts_free(facts); int cleanup=gfb_close(&f); + ASSERT_EQ(cleanup,0); ASSERT_TRUE(configured); ASSERT_TRUE(control); ASSERT_TRUE(exact); + ASSERT_TRUE(distinct); PASS(); +} + +TEST(test_git_blob_batch_pinned_revisions_and_cache_history_guards) { + gfb_fixture_t f; bool configured=gfb_open(&f,false); + cbm_git_facts_options_t opts=gf_options(&f.git); cbm_git_facts_error_t error; + cbm_git_facts_t *facts=configured?cbm_git_facts_open(&opts,&error):NULL; + const size_t index=GFB_TEXT; cbm_git_blob_batch_limits_t limits=gfb_limits(); + bool control=facts && gfb_inventory_control(&f,facts); + bool before=control && gfb_good(&f,facts,CBM_GIT_REV_HEAD,&index,1,&limits,NULL); + char path[GF_PATH]; + const char *empty_index[]={"read-tree","--empty",NULL}; + bool mutation=configured && gf_path(path,f.git.repo,"p.txt") && gf_write(path,"ambient\n",8) && + gf_git(&f.git,empty_index,NULL,0) && gf_ref(&f.git,"refs/heads/topic",f.git.a) && + gf_ref(&f.git,"refs/heads/base",f.git.p); + bool pinned=before && mutation && gfb_good(&f,facts,CBM_GIT_REV_HEAD,&index,1,&limits,NULL) && + gfb_good(&f,facts,CBM_GIT_REV_MERGE_BASE,&index,1,&limits,NULL); + char graft[160],info[GF_PATH];int n=snprintf(graft,sizeof(graft),"%s %s\n",f.git.head,f.git.a); + bool written=control && n>0 && (size_t)n0 && (size_t)n0 && (size_t)n=10;n/=10)digits++; + return width+1+4+1+digits+1+size+1; +} + +TEST(test_git_blob_batch_exact_frame_caps_and_native_split_batches) { + gfb_fixture_t f;bool configured=gfb_open(&f,false); + const size_t indices[]={GFB_LARGE_C,GFB_LARGE_A,GFB_LARGE_B}; + cbm_git_blob_batch_limits_t limits=gfb_limits(); + size_t exact=gfb_frame_size(f.git.oid_length,GFB_BODY); + bool controls=configured, outcomes=true; + for(int variant=0;configured && variant<3;variant++) { + cbm_git_facts_options_t opts=gf_options(&f.git); + opts.stdout_limit=variant==2?exact-1:exact; + cbm_git_facts_error_t error;cbm_git_facts_t *facts=cbm_git_facts_open(&opts,&error); + bool opened=facts && gfb_inventory_control(&f,facts); + controls=opened && controls; + if(variant==0)outcomes=opened && + gfb_good(&f,facts,CBM_GIT_REV_HEAD,indices,1,&limits,NULL) && outcomes; + if(variant==1)outcomes=opened && + gfb_good(&f,facts,CBM_GIT_REV_HEAD,indices,3,&limits,NULL) && outcomes; + if(variant==2) { + const size_t mixed[]={GFB_EMPTY,GFB_COLON,GFB_LARGE_C}; + cbm_git_blob_batch_request_t request={CBM_GIT_REV_HEAD,mixed,3}; + bool limited=opened && gfb_error(facts,&request,&limits,CBM_GIT_FACTS_LIMIT); + cbm_git_blob_batch_request_t empty={CBM_GIT_REV_HEAD,NULL,0}; + outcomes=limited && gfb_error(facts,&empty,&limits,CBM_GIT_FACTS_LIMIT) && outcomes; + } + cbm_git_facts_free(facts); + } + int cleanup=gfb_close(&f); + ASSERT_EQ(cleanup,0);ASSERT_TRUE(configured);ASSERT_TRUE(controls);ASSERT_TRUE(outcomes);PASS(); +} + +TEST(test_git_blob_batch_per_call_and_existing_shared_budgets) { + gfb_fixture_t f;bool configured=gfb_open(&f,false); + const size_t repeated[]={GFB_COLON,GFB_EXEC,GFB_HEADER,GFB_COLON}; + /* Four entries, but exactly two distinct OIDs and two physical phases. */ + size_t exact_input=2U*2U*(f.git.oid_length+1U); + cbm_git_blob_batch_request_t request={CBM_GIT_REV_HEAD,repeated,4}; + cbm_git_blob_batch_limits_t ample=gfb_limits();bool controls=configured, outcomes=true; + for(int variant=0;configured && variant<4;variant++) { + cbm_git_facts_options_t opts=gf_options(&f.git);cbm_git_facts_error_t error; + cbm_git_facts_t *facts=cbm_git_facts_open(&opts,&error); + cbm_git_blob_t retained={0}; + bool before=facts && gfb_inventory_control(&f,facts) && + cbm_git_facts_read_blob(facts,CBM_GIT_REV_HEAD,"p.txt",5,&retained,&error)==CBM_GIT_BLOB_FOUND && + gf_bytes_equal(retained.bytes,gf_head_text,sizeof(gf_head_text)-1); + controls=before && controls; + cbm_git_blob_batch_limits_t limit=ample; + if(variant==0) { + limit.max_entries=4;limit.max_input_bytes=exact_input; + outcomes=before && gfb_good(&f,facts,CBM_GIT_REV_HEAD,repeated,4,&limit,NULL) && + gfb_good(&f,facts,CBM_GIT_REV_HEAD,repeated,4,&limit,NULL) && outcomes; + } else { + cbm_git_blob_batch_request_t chosen=request; + if(variant==1)limit.max_entries=3; + if(variant==2)limit.max_input_bytes=exact_input-1; + if(variant==3) { + /* No selected objects/items: guard/helper allocations still count. */ + chosen=(cbm_git_blob_batch_request_t){CBM_GIT_REV_HEAD,NULL,0}; + bool empty_control=before && gfb_good(&f,facts,CBM_GIT_REV_HEAD,NULL,0,&le,NULL); + outcomes=empty_control && outcomes; + limit.max_arena_bytes=1; + } + bool limited=before && gfb_error(facts,&chosen,&limit,CBM_GIT_FACTS_LIMIT); + outcomes=limited && gfb_error(facts,&request,&le,CBM_GIT_FACTS_LIMIT) && outcomes; + } + outcomes=gf_bytes_equal(retained.bytes,gf_head_text,sizeof(gf_head_text)-1) && outcomes; + cbm_git_facts_free(facts); + } + /* Repeated successful calls cannot reset shared native-command/output caps. + * Bounds below use public limits and a minimum of one real command/body per + * success, never an exact internal command count or phase callback number. */ + for(int budget=0;configured && budget<2;budget++) { + cbm_git_facts_options_t opts=gf_options(&f.git); + if(budget==0)opts.command_limit=64; + else opts.total_output_limit=3U*GFB_BODY+32768U; + cbm_git_facts_error_t error;cbm_git_facts_t *facts=cbm_git_facts_open(&opts,&error); + cbm_git_blob_t retained={0}; + bool before=facts && cbm_git_facts_read_blob(facts,CBM_GIT_REV_HEAD,"p.txt",5,&retained,&error)==CBM_GIT_BLOB_FOUND && + gf_bytes_equal(retained.bytes,gf_head_text,sizeof(gf_head_text)-1); + controls=before && controls; + size_t index=budget==0?GFB_HEADER:GFB_LARGE_A; + cbm_git_blob_batch_request_t one={CBM_GIT_REV_HEAD,&index,1}; + cbm_git_blob_batch_t first={0};unsigned successes=0; + bool limited=false, unexpected=false; + unsigned bound=budget==0?opts.command_limit+1U:5U; + for(unsigned i=0;before && i0 && gfb_matches(&f,CBM_GIT_REV_HEAD,&index,1,&first) && + gf_bytes_equal(retained.bytes,gf_head_text,sizeof(gf_head_text)-1); + outcomes=before && successes>0 && !unexpected && limited && sticky && retained_ok && outcomes; + cbm_git_facts_free(facts); + } + int cleanup=gfb_close(&f); + ASSERT_EQ(cleanup,0);ASSERT_TRUE(configured);ASSERT_TRUE(controls);ASSERT_TRUE(outcomes);PASS(); +} + +static bool gfb_header_error(cbm_git_bytes_t bytes,const char *oid,size_t width, + cbm_git_facts_status_t expected) { + size_t size=123, header=456;cbm_git_facts_error_t error; + memset(&error,0xa5,sizeof(error)); + return !cbm_git_facts_test_batch_header(bytes,oid,width,&size,&header,&error) && + error.status==expected && size==0 && header==0; +} + +TEST(test_git_blob_batch_strict_headers_and_native_missing_object) { + bool grammar=true; + for(size_t width=40;width<=64;width+=24) { + char oid[65];memset(oid,'a',width);oid[width]=0; + char capture[256];int n=snprintf(capture,sizeof(capture),"%s blob 3\nXYZ\n",oid); + size_t size=123,header=456;cbm_git_facts_error_t error; + bool positive=n>0 && (size_t)n0 && (size_t)n0 && (size_t)n0 && (size_t)n0 && (size_t)narmed)return false; + c->calls++; + return c->cancel_at && c->calls>=c->cancel_at; +} + +TEST(test_git_blob_batch_cancellation_is_sticky_and_owner_local) { + gfb_fixture_t f;bool configured=gfb_open(&f,false); + const size_t index=GFB_LARGE_A;cbm_git_blob_batch_limits_t limits=gfb_limits(); + cbm_git_blob_batch_request_t request={CBM_GIT_REV_HEAD,&index,1}; + gfb_cancel_t observed={0};cbm_git_facts_options_t opts=gf_options(&f.git); + opts.cancelled=gfb_cancel;opts.cancel_context=&observed; + cbm_git_facts_error_t error;cbm_git_facts_t *healthy=configured?cbm_git_facts_open(&opts,&error):NULL; + bool control=healthy && gfb_inventory_control(&f,healthy); + observed.armed=true; + bool positive=control && gfb_good(&f,healthy,CBM_GIT_REV_HEAD,&index,1,&limits,NULL) && observed.calls>=2; + observed.armed=false; + bool stopped=true; + /* Entry and final publication require at least two polls on any successful + * nonempty call. These two early thresholds do not identify a phase or rely + * on the process-supervision loop's variable callback count. */ + for(size_t threshold=1;configured && threshold<=2;threshold++) { + gfb_cancel_t cancel={0};opts=gf_options(&f.git);opts.cancelled=gfb_cancel;opts.cancel_context=&cancel; + cbm_git_facts_t *facts=cbm_git_facts_open(&opts,&error); + const size_t small=GFB_COLON;cbm_git_blob_batch_t kept={0}; + bool before=facts && gfb_good(&f,facts,CBM_GIT_REV_HEAD,&small,1,&limits,&kept); + cancel.armed=true;cancel.cancel_at=threshold; + bool rejected=before && gfb_error(facts,&request,&limits,CBM_GIT_FACTS_CANCELLED); + cancel.armed=false; + cbm_git_blob_batch_request_t empty={CBM_GIT_REV_HEAD,NULL,0}; + bool latched=rejected && gfb_error(facts,&empty,&limits,CBM_GIT_FACTS_CANCELLED) && + gfb_error(facts,&request,&limits,CBM_GIT_FACTS_CANCELLED); + bool retained=before && gfb_matches(&f,CBM_GIT_REV_HEAD,&small,1,&kept); + stopped=before && rejected && latched && retained && stopped; + cbm_git_facts_free(facts); + } + bool independent=control && gfb_good(&f,healthy,CBM_GIT_REV_HEAD,&index,1,&limits,NULL); + cbm_git_facts_free(healthy); + /* Existing deterministic expired-open boundary; no live-clock race. */ + opts=gf_options(&f.git);opts.deadline_ms=1; + cbm_git_facts_t *expired=configured?cbm_git_facts_open(&opts,&error):NULL; + bool deadline=configured && !expired && error.status==CBM_GIT_FACTS_DEADLINE; + cbm_git_facts_free(expired);int cleanup=gfb_close(&f); + ASSERT_EQ(cleanup,0);ASSERT_TRUE(configured);ASSERT_TRUE(control);ASSERT_TRUE(positive); + ASSERT_TRUE(stopped);ASSERT_TRUE(independent);ASSERT_TRUE(deadline);PASS(); +} + +static cbm_git_bytes_t gfb_frame(CBMArena *arena,const char *oid,const void *body,size_t length, + bool payload) { + char header[128];int n=snprintf(header,sizeof(header),"%s blob %zu\n",oid,length); + if(n<=0 || (size_t)n>=sizeof(header) || length>SIZE_MAX-(size_t)n-1) + return (cbm_git_bytes_t){NULL,0}; + size_t total=(size_t)n+(payload?length+1:0); + unsigned char *bytes=cbm_arena_alloc(arena,total); + if(!bytes)return (cbm_git_bytes_t){NULL,0}; + memcpy(bytes,header,(size_t)n); + if(payload) { + if(length)memcpy(bytes+n,body,length); + bytes[total-1]='\n'; + } + return (cbm_git_bytes_t){bytes,total}; +} + +static cbm_git_bytes_t gfb_concat(CBMArena *arena,cbm_git_bytes_t a,cbm_git_bytes_t b) { + if(a.length>SIZE_MAX-b.length)return (cbm_git_bytes_t){NULL,0}; + size_t size=a.length+b.length;unsigned char *bytes=cbm_arena_alloc(arena,size?size:1); + if(!bytes)return (cbm_git_bytes_t){NULL,0}; + if(a.length)memcpy(bytes,a.data,a.length);if(b.length)memcpy(bytes+a.length,b.data,b.length); + return (cbm_git_bytes_t){bytes,size}; +} + +static bool gfb_capture_error(cbm_git_bytes_t capture,size_t width,const char *const *oids, + const size_t *sizes,size_t count,bool payload, + cbm_git_facts_status_t expected) { + cbm_git_facts_error_t error;memset(&error,0xa5,sizeof(error)); + return !cbm_git_facts_test_batch_capture(capture,width,oids,sizes,count,payload,&error) && + error.status==expected; +} + +TEST(test_git_blob_batch_exact_payload_frames_and_no_prefix_acceptance) { + bool controls=true, outcomes=true; + for(size_t width=40;width<=64;width+=24) { + CBMArena arena;cbm_arena_init_lazy(&arena,4096); + char a_oid[65],b_oid[65];memset(a_oid,'a',width);memset(b_oid,'b',width); + a_oid[width]=b_oid[width]=0; + const char *oids[]={a_oid,b_oid}; + const size_t sizes[]={sizeof(gfb_binary),sizeof(gfb_header_body)-1}; + cbm_git_bytes_t a=gfb_frame(&arena,a_oid,gfb_binary,sizes[0],true); + cbm_git_bytes_t b=gfb_frame(&arena,b_oid,gfb_header_body,sizes[1],true); + bool allocated=a.data && b.data; + cbm_git_bytes_t complete=allocated?gfb_concat(&arena,a,b):(cbm_git_bytes_t){NULL,0}; + cbm_git_facts_error_t error;memset(&error,0xa5,sizeof(error)); + bool positive=complete.data && cbm_git_facts_test_batch_capture(complete,width,oids,sizes,2,true,&error) && + gfa_clean_error(&error); + controls=positive && controls; + if(complete.data) { + for(size_t cut=0;cut0; + if(copied)memcpy(patch,diff.patch.data,diff.patch.length); + CBMArena input;cbm_arena_init_lazy(&input,4096); + size_t *indices=cbm_arena_alloc(&input,3*sizeof(*indices)); + static const size_t wanted[]={GFB_LARGE_A,GFB_UTF,GFB_EMPTY}; + if(indices)memcpy(indices,wanted,sizeof(wanted)); + cbm_git_blob_batch_limits_t limits=gfb_limits();cbm_git_blob_batch_t first={0},second={0}; + bool one=legacy && indices && gfb_good(&f,facts,CBM_GIT_REV_HEAD,indices,3,&limits,&first); + if(indices)for(size_t i=0;i<3;i++)indices[i]=SIZE_MAX; + cbm_arena_destroy(&input); + const size_t merge[]={GFB_TEXT,GFB_HEADER}; + bool two=legacy && gfb_good(&f,facts,CBM_GIT_REV_MERGE_BASE,merge,2,&limits,&second); + size_t invalid=GFB_COUNT;cbm_git_blob_batch_request_t request={CBM_GIT_REV_HEAD,&invalid,1}; + bool ordinary=legacy && gfb_error(facts,&request,&limits,CBM_GIT_FACTS_INVALID); + const size_t next=GFB_EXEC;request=(cbm_git_blob_batch_request_t){CBM_GIT_REV_HEAD,&next,1}; + cbm_git_blob_batch_t optional=gfb_poisoned(); + bool again=legacy && cbm_git_facts_read_blob_batch(facts,&request,&limits,&optional,NULL) && + gfb_matches(&f,CBM_GIT_REV_HEAD,&next,1,&optional); + cbm_git_blob_batch_limits_t limited=limits;limited.max_entries=1; + request=(cbm_git_blob_batch_request_t){CBM_GIT_REV_HEAD,wanted,3}; + bool stopped=legacy && gfb_error(facts,&request,&limited,CBM_GIT_FACTS_LIMIT); + bool stable=one && two && copied && gfb_matches(&f,CBM_GIT_REV_HEAD,wanted,3,&first) && + gfb_matches(&f,CBM_GIT_REV_MERGE_BASE,merge,2,&second) && + gfi_matches(&inventory,f.expected,GFB_COUNT) && + gf_bytes_equal(blob.bytes,gf_head_text,sizeof(gf_head_text)-1) && + gf_bytes_equal(diff.patch,patch,diff.patch.length) && + gf_bytes_equal(diff.name_status,changed,sizeof(changed)-1) && + gf_bytes_equal(ancestor,changed,sizeof(changed)-1); + cbm_git_facts_free(facts);int cleanup=gfb_close(&f); + ASSERT_EQ(cleanup,0);ASSERT_TRUE(configured);ASSERT_TRUE(legacy);ASSERT_TRUE(copied); + ASSERT_TRUE(one);ASSERT_TRUE(two);ASSERT_TRUE(ordinary);ASSERT_TRUE(again);ASSERT_TRUE(stopped); + ASSERT_TRUE(stable);PASS(); +} + +SUITE(test_impact_git) { + RUN_TEST(test_git_facts_inventory_pins_complete_head_and_actual_merge_base); + RUN_TEST(test_git_facts_inventory_preserves_literal_bytes_and_sha256); + RUN_TEST(test_git_facts_inventory_empty_and_invalid_revision_clear_output); + RUN_TEST(test_git_facts_inventory_cancellation_clears_prefix_and_latches); + RUN_TEST(test_git_facts_inventory_output_limit_has_no_prefix_and_latches); + RUN_TEST(test_git_facts_inherited_diff_options_cannot_override_zero_context); + RUN_TEST(test_git_facts_gitfile_redirection_cannot_certify_false_ancestry); + RUN_TEST(test_git_facts_pins_actual_merge_base_and_both_refs); + RUN_TEST(test_git_facts_ref_index_and_worktree_mutation_cannot_change_snapshot); + RUN_TEST(test_git_facts_reads_exact_binary_literal_paths_and_all_revisions); + RUN_TEST(test_git_facts_distinguishes_absent_invalid_and_nonregular_paths); + RUN_TEST(test_git_facts_ancestry_distinguishes_no_from_invalid_and_missing_objects); + RUN_TEST(test_git_facts_diff_uses_merge_base_zero_context_and_nul_metadata); + RUN_TEST(test_git_facts_rejects_malformed_options_and_expected_head_mismatch); + RUN_TEST(test_git_facts_precancel_and_expired_deadline_are_deterministic); + RUN_TEST(test_git_facts_command_and_output_caps_fail_closed); + RUN_TEST(test_git_facts_required_sha256_objects_and_identity); + RUN_TEST(test_git_facts_never_runs_external_diff_or_textconv); + RUN_TEST(test_git_facts_rejects_local_grafts_that_change_ancestry); + RUN_TEST(test_git_facts_rejects_no_common_ancestor); + RUN_TEST(test_git_facts_rejects_multiple_criss_cross_merge_bases); + RUN_TEST(test_git_facts_rejects_local_shallow_state); + RUN_TEST(test_git_facts_rejects_history_override_added_after_open); + RUN_TEST(test_git_facts_cancellation_after_open_is_latched); + RUN_TEST(test_git_ancestor_changes_requires_ancestor_of_actual_merge_base); + RUN_TEST(test_git_ancestor_changes_preserves_earlier_paths_reversion_and_pinned_head); + RUN_TEST(test_git_ancestor_changes_equalities_and_empty_reversion_are_distinct); + RUN_TEST(test_git_ancestor_changes_validates_full_commit_object_and_argument_span); + RUN_TEST(test_git_ancestor_changes_sha256_raw_paths_and_local_oid_copy); + RUN_TEST(test_git_ancestor_changes_repeats_preserve_all_previous_owner_buffers); + RUN_TEST(test_git_ancestor_changes_cancellation_is_shared_terminal_and_preserves_views); + RUN_TEST(test_git_ancestor_changes_output_and_cumulative_limits_publish_no_prefix); + RUN_TEST(test_git_ancestor_changes_repeated_calls_share_existing_command_budget); + RUN_TEST(test_git_ancestor_changes_history_and_external_driver_guards_apply); + RUN_TEST(test_git_blob_batch_native_bytes_and_both_object_formats); + RUN_TEST(test_git_blob_batch_keeps_request_order_duplicates_and_raw_paths); + RUN_TEST(test_git_blob_batch_pinned_revisions_and_cache_history_guards); + RUN_TEST(test_git_blob_batch_validates_full_selection_and_empty_requests); + RUN_TEST(test_git_blob_batch_exact_frame_caps_and_native_split_batches); + RUN_TEST(test_git_blob_batch_per_call_and_existing_shared_budgets); + RUN_TEST(test_git_blob_batch_strict_headers_and_native_missing_object); + RUN_TEST(test_git_blob_batch_cancellation_is_sticky_and_owner_local); + RUN_TEST(test_git_blob_batch_exact_payload_frames_and_no_prefix_acceptance); + RUN_TEST(test_git_blob_batch_owner_lifetimes_and_legacy_views_survive); +} diff --git a/tests/test_test_impact_inventory.c b/tests/test_test_impact_inventory.c new file mode 100644 index 000000000..173ee4a9b --- /dev/null +++ b/tests/test_test_impact_inventory.c @@ -0,0 +1,211 @@ +#include "test_test_impact_inventory_internal.h" + +static bool ni_path(cbm_inventory_path_t path, const char *expected) { + size_t length = strlen(expected); + return path.length == length && (length ? path.data && !memcmp(path.data, expected, length + 1) + : !path.data || !path.data[0]); +} + +static bool ni_default_file(const if_native *n, const cbm_inventory_filter_view_t *view, size_t i, + const char *path) { + const cbm_inventory_file_t *actual = &view->files[i], *expected = &n->input.files[i]; + const cbm_inventory_filter_row_t *row = &view->rows[i]; + bool ignored = i == 4 || i == 9; + unsigned roles = i == 0 ? CBM_INVENTORY_FILTER_ROLE_CBMIGNORE + : i == 1 ? CBM_INVENTORY_FILTER_ROLE_PHYSICAL_PROJECT_CONFIG + : i == 2 || i == 7 ? CBM_INVENTORY_FILTER_ROLE_GITIGNORE + : 0; + return ni_path(actual->path, path) && actual->git_mode == 0100644 && + !strcmp(actual->oid, expected->oid) && + actual->content_length == expected->content_length && + !memcmp(actual->content_sha256, expected->content_sha256, 32) && row->file_index == i && + row->disposition == (ignored ? CBM_INVENTORY_FILTER_IGNORED_FILE + : CBM_INVENTORY_FILTER_NEEDS_LANGUAGE) && + row->reason == (ignored ? CBM_INVENTORY_FILTER_REASON_GITIGNORE + : CBM_INVENTORY_FILTER_REASON_NONE) && + row->roles == roles && !row->excluded_ancestor.data && !row->excluded_ancestor.length; +} + +static bool ni_default_controls(const cbm_inventory_filter_view_t *view) { + if (view->control_count != 3) + return false; + const cbm_inventory_control_row_t *c = view->controls; + return c[0].kind == CBM_INVENTORY_CONTROL_GITIGNORE && ni_path(c[0].directory, "") && + c[0].file_index == 2 && c[0].outcome == CBM_INVENTORY_CONTROL_APPLIED && + c[0].effective_patterns == 1 && c[1].kind == CBM_INVENTORY_CONTROL_GITIGNORE && + ni_path(c[1].directory, "sub") && c[1].file_index == 7 && + c[1].outcome == CBM_INVENTORY_CONTROL_APPLIED && c[1].effective_patterns == 1 && + c[2].kind == CBM_INVENTORY_CONTROL_CBMIGNORE && ni_path(c[2].directory, "") && + c[2].file_index == 0 && c[2].outcome == CBM_INVENTORY_CONTROL_APPLIED_EMPTY && + c[2].effective_patterns == 0; +} + +bool ni_default_view(const if_native *n, const cbm_test_impact_inventory_t *owner) { + static const char *const paths[] = { + ".cbmignore", ".codebase-memory.json", ".gitignore", "a.data", "drop.c", "plain", + "source.c", "sub/.gitignore", "sub/keep.c", "sub/nested.c"}; + const cbm_inventory_filter_view_t *view = cbm_test_impact_inventory_view(owner); + if (!view || view->file_count != 10 || view->directory_count != 2 || view->excluded_count || + strcmp(view->native_root, n->input.root) || + memcmp(view->manifest_sha256, n->input.source.manifest_sha256, 32)) + return false; + for (size_t i = 0; i < 10; i++) { + if (!ni_default_file(n, view, i, paths[i])) + return false; + } + return ni_default_controls(view); +} + +TEST(native_inventory_head_metadata_and_ambient_independence) { + if_native n = {0}; + cbm_test_impact_inventory_t *owner = NULL; + int result = 1; + NI_CHECK(ni_start(&n)); + /* Real HEAD, index and worktree changes precede any adapter assertion. */ + NI_CHECK(ni_ambient_change(&n) && ni_dependency(&n, CBM_GIT_REV_HEAD)); + NI_CHECK(ni_prepare(&n, &owner) && ni_default_view(&n, owner)); + cbm_test_impact_inventory_usage_t usage; + NI_CHECK(cbm_test_impact_inventory_usage(owner, &usage)); + NI_CHECK(usage.filter.verified_file_reads_reserved == 3 && + usage.filter.verified_content_bytes_reserved == 25 && + usage.filter.control_bytes_reserved == 25); + NI_CHECK(cbm_pinned_tree_view(n.input.tree) != NULL); + result = 0; +done: + return ni_finish(&n, owner, result); +} + +TEST(native_inventory_merge_base_rejected_even_when_equal) { + if_native n = {0}; + cbm_test_impact_inventory_t *owner = NULL; + int result = 1; + NI_CHECK(ni_start(&n)); + NI_CHECK(if_native_close(&n) && ni_tree(&n, CBM_GIT_REV_MERGE_BASE) && + ni_dependency(&n, CBM_GIT_REV_MERGE_BASE)); + const cbm_pinned_tree_view_t *native = cbm_pinned_tree_view(n.input.tree); + NI_CHECK(native && native->revision == CBM_GIT_REV_MERGE_BASE && + !strcmp(native->commit, native->identity->head)); + NI_CHECK(ni_error(&n, CBM_INVENTORY_UNSUPPORTED, SIZE_MAX, false, false)); + size_t arena_total = n.input.limits.max_arena_bytes; + n.input.limits.max_arena_bytes = n.input.limits.max_ignore_arena_bytes; + NI_CHECK(ni_error(&n, CBM_INVENTORY_UNSUPPORTED, SIZE_MAX, false, false)); + n.input.limits.max_arena_bytes = arena_total; + NI_CHECK(cbm_pinned_tree_view(n.input.tree) != NULL); + NI_CHECK(if_native_close(&n) && ni_tree(&n, CBM_GIT_REV_HEAD) && + ni_dependency(&n, CBM_GIT_REV_HEAD)); + /* Ensures the missing-feature baseline does not pass on UNSUPPORTED alone. */ + NI_CHECK(ni_prepare(&n, &owner) && ni_default_view(&n, owner)); + result = 0; +done: + return ni_finish(&n, owner, result); +} + +static bool ni_empty_result(const cbm_test_impact_inventory_t *owner, bool controls) { + const cbm_inventory_filter_view_t *view = cbm_test_impact_inventory_view(owner); + cbm_test_impact_inventory_usage_t usage; + if (!view || view->file_count != (controls ? 3u : 0u) || view->directory_count != 1 || + view->control_count != 2 || view->excluded_count || + !cbm_test_impact_inventory_usage(owner, &usage)) + return false; + const cbm_inventory_control_row_t *c = view->controls; + cbm_inventory_control_outcome_t outcome = + controls ? CBM_INVENTORY_CONTROL_APPLIED_EMPTY : CBM_INVENTORY_CONTROL_ABSENT; + if (c[0].kind != CBM_INVENTORY_CONTROL_GITIGNORE || !ni_path(c[0].directory, "") || + c[0].file_index != (controls ? 1u : SIZE_MAX) || c[0].outcome != outcome || + c[0].effective_patterns || c[1].kind != CBM_INVENTORY_CONTROL_CBMIGNORE || + !ni_path(c[1].directory, "") || c[1].file_index != (controls ? 0u : SIZE_MAX) || + c[1].outcome != outcome || c[1].effective_patterns || + usage.filter.verified_file_reads_reserved != (controls ? 2u : 0u) || + usage.filter.verified_content_bytes_reserved || usage.filter.control_bytes_reserved) + return false; + if (!controls) + return true; + const char *paths[] = {".cbmignore", ".gitignore", "source.c"}; + for (size_t i = 0; i < 3; i++) { + unsigned role = i == 0 ? CBM_INVENTORY_FILTER_ROLE_CBMIGNORE + : i == 1 ? CBM_INVENTORY_FILTER_ROLE_GITIGNORE + : 0; + if (!ni_path(view->files[i].path, paths[i]) || + view->files[i].git_mode != (i == 2 ? 0100755u : 0100644u) || + view->rows[i].file_index != i || view->rows[i].roles != role || + view->rows[i].disposition != CBM_INVENTORY_FILTER_NEEDS_LANGUAGE || + view->rows[i].reason != CBM_INVENTORY_FILTER_REASON_NONE) + return false; + } + return true; +} + +TEST(native_inventory_empty_tree_and_empty_control_files) { + if_native empty = {0}, controls = {0}; + cbm_test_impact_inventory_t *owner = NULL; + int result = 1; + /* Establish both real fixtures before the first adapter assertion, so an + * absent bridge cannot hide a faulty empty-control fixture. */ + NI_CHECK(ni_start(&empty) && ni_replace_snapshot(&empty, false)); + NI_CHECK(ni_start(&controls) && ni_replace_snapshot(&controls, true)); + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(empty.input.tree); + NI_CHECK(view && view->file_count == 0 && view->directory_count == 1); + view = cbm_pinned_tree_view(controls.input.tree); + NI_CHECK(view && view->file_count == 3 && view->files[0].content_length == 0 && + view->files[1].content_length == 0 && view->files[2].git_mode == 0100755); + NI_CHECK(ni_prepare(&empty, &owner) && ni_empty_result(owner, false)); + cbm_test_impact_inventory_free(owner); + owner = NULL; + NI_CHECK(ni_prepare(&controls, &owner) && ni_empty_result(owner, true)); + result = 0; +done: + result = ni_finish(&controls, owner, result); + return if_native_finish(&empty, result); +} + +TEST(native_inventory_copied_owner_outlives_native_owners) { + if_native n = {0}; + cbm_test_impact_inventory_t *first = NULL, *second = NULL; + int result = 1; + NI_CHECK(ni_start(&n)); + NI_CHECK(ni_prepare(&n, &first) && ni_prepare(&n, &second)); + NI_CHECK(ni_default_view(&n, first) && ni_default_view(&n, second)); + cbm_inventory_limits_t original = n.input.limits, actual; + cbm_test_impact_inventory_usage_t before, after; + NI_CHECK(cbm_test_impact_inventory_usage(second, &before)); + NI_CHECK(if_native_close(&n)); + cbm_git_facts_free(n.facts); + n.facts = NULL; + memset(&n.input.limits, 0xa5, sizeof(n.input.limits)); + memset(&n.input.control, 0, sizeof(n.input.control)); + cbm_test_impact_inventory_free(first); + first = NULL; + NI_CHECK(ni_default_view(&n, second)); + NI_CHECK(cbm_test_impact_inventory_limits(second, &actual) && + ni_limits_equal(&actual, &original)); + NI_CHECK(cbm_test_impact_inventory_usage(second, &after) && + after.attachment_arena_requested_bytes == before.attachment_arena_requested_bytes && + after.arena_requested_bytes == before.arena_requested_bytes && + after.arena_budget_used_bytes == before.arena_budget_used_bytes && + after.filter.verified_file_reads_reserved == 3 && + after.filter.verified_content_bytes_reserved == 25); + result = 0; +done: + cbm_test_impact_inventory_free(first); + return ni_finish(&n, second, result); +} + +TEST(native_inventory_read_faults_keep_caller_cleanup) { + return ni_case_faults(); +} +TEST(native_inventory_entry_cancel_deadline_and_state) { + return ni_case_entry(); +} +TEST(native_inventory_attachment_and_read_budget_boundaries) { + return ni_case_limits(); +} + +SUITE(test_impact_inventory) { + RUN_TEST(native_inventory_head_metadata_and_ambient_independence); + RUN_TEST(native_inventory_merge_base_rejected_even_when_equal); + RUN_TEST(native_inventory_empty_tree_and_empty_control_files); + RUN_TEST(native_inventory_read_faults_keep_caller_cleanup); + RUN_TEST(native_inventory_entry_cancel_deadline_and_state); + RUN_TEST(native_inventory_attachment_and_read_budget_boundaries); + RUN_TEST(native_inventory_copied_owner_outlives_native_owners); +} diff --git a/tests/test_test_impact_inventory_errors.c b/tests/test_test_impact_inventory_errors.c new file mode 100644 index 000000000..83c8ded72 --- /dev/null +++ b/tests/test_test_impact_inventory_errors.c @@ -0,0 +1,206 @@ +#include "test_test_impact_inventory_internal.h" + +static bool ni_cancelled(void *context) { + return *(const bool *)context; +} + +int ni_case_faults(void) { + if_native n = {0}; + cbm_test_impact_inventory_t *owner = NULL; + int result = 1; + NI_CHECK(ni_start(&n)); + NI_CHECK(ni_prepare(&n, &owner) && ni_default_view(&n, owner)); + cbm_test_impact_inventory_free(owner); + owner = NULL; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + const cbm_pinned_tree_read_fault_t faults[] = {CBM_PINNED_TREE_READ_FAULT_READ, + CBM_PINNED_TREE_READ_FAULT_EOF, + CBM_PINNED_TREE_READ_FAULT_CLOSE}; + for (size_t i = 0; i < sizeof(faults) / sizeof(faults[0]); i++) { + if (i) + NI_CHECK(ni_tree(&n, CBM_GIT_REV_HEAD) && ni_dependency(&n, CBM_GIT_REV_HEAD)); + NI_CHECK(cbm_pinned_tree_test_set_read_fault(n.input.tree, faults[i])); + NI_CHECK(ni_error(&n, CBM_INVENTORY_IO, 0, faults[i] == CBM_PINNED_TREE_READ_FAULT_CLOSE, + false)); + NI_CHECK(n.input.tree && !cbm_pinned_tree_view(n.input.tree)); + NI_CHECK(ni_error(&n, CBM_INVENTORY_STATE, SIZE_MAX, false, false)); + /* The CLOSE seam retains a known open handle; this is its first actual + * close, never a retry of a native close whose outcome is unknown. */ + NI_CHECK(if_native_close(&n) && !n.input.tree); + } + NI_CHECK(ni_tree(&n, CBM_GIT_REV_HEAD) && ni_dependency(&n, CBM_GIT_REV_HEAD)); + NI_CHECK(ni_prepare(&n, &owner) && ni_default_view(&n, owner)); + result = 0; +#else + fprintf(stderr, "native inventory faults require canonical CBM_ENABLE_TEST_SEAMS\n"); +#endif +done: + return ni_finish(&n, owner, result); +} + +static bool ni_invalid_arguments(if_native *n) { + cbm_inventory_error_t error; + for (unsigned missing = 0; missing < 4; missing++) { + cbm_test_impact_inventory_t *sentinel = (cbm_test_impact_inventory_t *)(uintptr_t)1; + cbm_test_impact_inventory_t *owner = sentinel; + memset(&error, 0xa5, sizeof(error)); + cbm_inventory_status_t status = cbm_test_impact_inventory_prepare( + missing == 0 ? NULL : n->input.tree, missing == 1 ? NULL : &n->input.limits, + missing == 2 ? NULL : &n->input.control, missing == 3 ? NULL : &owner, &error); + bool cleared = missing == 3 || owner == NULL; + if (owner != sentinel) + cbm_test_impact_inventory_free(owner); + if (status != CBM_INVENTORY_INVALID || error.status != status || !cleared || + error.file_index != SIZE_MAX || error.cleanup_required || + !memchr(error.diagnostic, 0, sizeof(error.diagnostic))) + return false; + } + return true; +} + +static bool ni_zero_getters(void) { + cbm_test_impact_inventory_usage_t usage; + cbm_inventory_limits_t limits, zero_limits = {0}; + memset(&usage, 0xa5, sizeof(usage)); + memset(&limits, 0xa5, sizeof(limits)); + cbm_test_impact_inventory_free(NULL); + return !cbm_test_impact_inventory_view(NULL) && + !cbm_test_impact_inventory_usage(NULL, &usage) && + !usage.attachment_arena_requested_bytes && !usage.arena_requested_bytes && + !usage.arena_budget_used_bytes && !usage.filter.verified_file_reads_reserved && + !usage.filter.verified_content_bytes_reserved && !usage.filter.control_bytes_reserved && + !usage.filter.ignore_bytes_reserved && !usage.filter.ignore_work_used && + !usage.filter.ignore_patterns_reserved && !usage.filter.ignore_arena_requested_bytes && + !usage.filter.non_ignore_arena_requested_bytes && !usage.filter.arena_requested_bytes && + !usage.filter.arena_budget_used_bytes && + !cbm_test_impact_inventory_limits(NULL, &limits) && + ni_limits_equal(&limits, &zero_limits) && !cbm_test_impact_inventory_usage(NULL, NULL) && + !cbm_test_impact_inventory_limits(NULL, NULL); +} + +int ni_case_entry(void) { + if_native n = {0}; + cbm_test_impact_inventory_t *owner = NULL; + int result = 1; + NI_CHECK(ni_start(&n)); + NI_CHECK(ni_invalid_arguments(&n) && ni_zero_getters()); + NI_CHECK(cbm_pinned_tree_view(n.input.tree) != NULL); + bool cancel = true; + n.input.control.cancelled = ni_cancelled; + n.input.control.context = &cancel; + n.input.control.deadline_ms = 1; + /* Both pending: cancellation is the specified primary cause. */ + NI_CHECK(cbm_now_ms() > 1); + NI_CHECK(ni_error(&n, CBM_INVENTORY_CANCELLED, SIZE_MAX, false, false)); + NI_CHECK(cbm_pinned_tree_view(n.input.tree) != NULL); + cancel = false; + NI_CHECK(ni_error(&n, CBM_INVENTORY_DEADLINE, SIZE_MAX, false, false)); + NI_CHECK(cbm_pinned_tree_view(n.input.tree) != NULL); + n.input.control.deadline_ms = 0; + NI_CHECK(ni_error(&n, CBM_INVENTORY_INVALID, SIZE_MAX, false, false)); + n.input.control.deadline_ms = UINT64_MAX; + n.input.limits.max_ignore_arena_bytes = n.input.limits.max_arena_bytes + 1; + NI_CHECK(ni_error(&n, CBM_INVENTORY_INVALID, SIZE_MAX, false, false)); + n.input.limits.max_ignore_arena_bytes = 512 * 1024; + n.input.limits.max_files = 0; + NI_CHECK(ni_error(&n, CBM_INVENTORY_INVALID, SIZE_MAX, false, false)); + n.input.limits.max_files = IF_ROWS; + NI_CHECK(ni_prepare(&n, &owner) && ni_default_view(&n, owner)); + cbm_test_impact_inventory_free(owner); + owner = NULL; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + unsigned char bytes[IF_BYTES]; + size_t copied = SIZE_MAX; + cbm_pinned_tree_error_t native_error; + cbm_pinned_tree_control_t native_control = {.deadline_ms = UINT64_MAX}; + NI_CHECK(cbm_pinned_tree_test_set_read_fault(n.input.tree, CBM_PINNED_TREE_READ_FAULT_READ)); + NI_CHECK(cbm_pinned_tree_read_prefix(n.input.tree, 0, 16384, bytes, sizeof(bytes), &copied, + &native_control, &native_error) == CBM_PINNED_TREE_IO && + copied == 0 && !cbm_pinned_tree_view(n.input.tree)); + NI_CHECK(ni_error(&n, CBM_INVENTORY_STATE, SIZE_MAX, false, false)); + n.input.limits.max_arena_bytes = n.input.limits.max_ignore_arena_bytes; + NI_CHECK(ni_error(&n, CBM_INVENTORY_STATE, SIZE_MAX, false, false)); + result = 0; +#else + fprintf(stderr, "native inventory state fixture requires canonical test seams\n"); +#endif +done: + return ni_finish(&n, owner, result); +} + +static bool ni_accounting(const cbm_test_impact_inventory_usage_t *u, + const cbm_inventory_limits_t *limits) { + size_t a = u->attachment_arena_requested_bytes; + size_t d = u->filter.non_ignore_arena_requested_bytes; + size_t c = u->filter.ignore_arena_requested_bytes; + size_t i = limits->max_ignore_arena_bytes; + if (!a || a > limits->max_arena_bytes || i > limits->max_arena_bytes - a || + d > limits->max_arena_bytes - a - i || c > i) + return false; + return u->filter.arena_requested_bytes == d + c && u->filter.arena_budget_used_bytes == d + i && + u->arena_requested_bytes == a + d + c && u->arena_budget_used_bytes == a + d + i && + u->filter.verified_file_reads_reserved == 3 && + u->filter.verified_content_bytes_reserved == 25 && + u->filter.control_bytes_reserved == 25; +} + +static bool ni_usage_same(const cbm_test_impact_inventory_usage_t *a, + const cbm_test_impact_inventory_usage_t *b) { + return a->attachment_arena_requested_bytes == b->attachment_arena_requested_bytes && + a->arena_requested_bytes == b->arena_requested_bytes && + a->arena_budget_used_bytes == b->arena_budget_used_bytes && + a->filter.ignore_arena_requested_bytes == b->filter.ignore_arena_requested_bytes && + a->filter.non_ignore_arena_requested_bytes == + b->filter.non_ignore_arena_requested_bytes && + a->filter.ignore_bytes_reserved == b->filter.ignore_bytes_reserved && + a->filter.ignore_work_used == b->filter.ignore_work_used && + a->filter.ignore_patterns_reserved == b->filter.ignore_patterns_reserved; +} + +int ni_case_limits(void) { + if_native n = {0}; + cbm_test_impact_inventory_t *owner = NULL; + int result = 1; + NI_CHECK(ni_start(&n)); + n.input.limits.max_verified_file_reads = 3; + n.input.limits.max_verified_content_bytes = 25; + n.input.limits.max_control_total_bytes = 25; + NI_CHECK(ni_prepare(&n, &owner) && ni_default_view(&n, owner)); + cbm_test_impact_inventory_usage_t high, exact; + cbm_inventory_limits_t returned; + NI_CHECK(cbm_test_impact_inventory_usage(owner, &high) && + ni_accounting(&high, &n.input.limits)); + NI_CHECK(cbm_test_impact_inventory_limits(owner, &returned) && + ni_limits_equal(&returned, &n.input.limits)); + cbm_test_impact_inventory_free(owner); + owner = NULL; + size_t total = n.input.limits.max_arena_bytes; + NI_CHECK(high.arena_budget_used_bytes > n.input.limits.max_ignore_arena_bytes); + n.input.limits.max_arena_bytes = high.arena_budget_used_bytes; + NI_CHECK(ni_prepare(&n, &owner) && ni_default_view(&n, owner)); + NI_CHECK(cbm_test_impact_inventory_usage(owner, &exact) && + ni_accounting(&exact, &n.input.limits) && ni_usage_same(&high, &exact)); + NI_CHECK(cbm_test_impact_inventory_limits(owner, &returned) && + ni_limits_equal(&returned, &n.input.limits)); + cbm_test_impact_inventory_free(owner); + owner = NULL; + n.input.limits.max_arena_bytes--; + NI_CHECK(ni_error(&n, CBM_INVENTORY_LIMIT, SIZE_MAX, false, true)); + NI_CHECK(cbm_pinned_tree_view(n.input.tree) != NULL); + n.input.limits.max_arena_bytes = n.input.limits.max_ignore_arena_bytes; + NI_CHECK(ni_error(&n, CBM_INVENTORY_LIMIT, SIZE_MAX, false, false)); + NI_CHECK(cbm_pinned_tree_view(n.input.tree) != NULL); + n.input.limits.max_arena_bytes = total; + n.input.limits.max_verified_file_reads = 2; + NI_CHECK(ni_error(&n, CBM_INVENTORY_LIMIT, 7, false, false)); + n.input.limits.max_verified_file_reads = 3; + n.input.limits.max_verified_content_bytes = 24; + NI_CHECK(ni_error(&n, CBM_INVENTORY_LIMIT, 7, false, false)); + n.input.limits.max_verified_content_bytes = 25; + NI_CHECK(ni_prepare(&n, &owner) && ni_default_view(&n, owner)); + NI_CHECK(cbm_test_impact_inventory_usage(owner, &exact) && + ni_accounting(&exact, &n.input.limits) && ni_usage_same(&high, &exact)); + result = 0; +done: + return ni_finish(&n, owner, result); +} diff --git a/tests/test_test_impact_inventory_internal.h b/tests/test_test_impact_inventory_internal.h new file mode 100644 index 000000000..dac0055a4 --- /dev/null +++ b/tests/test_test_impact_inventory_internal.h @@ -0,0 +1,30 @@ +#ifndef TEST_TEST_IMPACT_INVENTORY_INTERNAL_H +#define TEST_TEST_IMPACT_INVENTORY_INTERNAL_H +#include "test_inventory_filter_native_internal.h" +#include "mcp/test_impact_inventory.h" + +#define NI_CHECK(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "native adapter assertion %s:%d: %s\n", __FILE__, __LINE__, \ + #condition); \ + goto done; \ + } \ + } while (0) + +bool ni_write(if_native *n, const char *path, const void *bytes, size_t size); +bool ni_ambient_change(if_native *n); +bool ni_replace_snapshot(if_native *n, bool empty_controls); +bool ni_tree(if_native *n, cbm_git_revision_t revision); +bool ni_dependency(if_native *n, cbm_git_revision_t revision); +bool ni_start(if_native *n); +bool ni_prepare(if_native *n, cbm_test_impact_inventory_t **owner); +bool ni_error(if_native *n, cbm_inventory_status_t expected, size_t index, bool cleanup, + bool allow_unknown_index); +bool ni_default_view(const if_native *n, const cbm_test_impact_inventory_t *owner); +bool ni_limits_equal(const cbm_inventory_limits_t *a, const cbm_inventory_limits_t *b); +int ni_finish(if_native *n, cbm_test_impact_inventory_t *owner, int result); +int ni_case_faults(void); +int ni_case_entry(void); +int ni_case_limits(void); +#endif diff --git a/tests/test_test_impact_inventory_support.c b/tests/test_test_impact_inventory_support.c new file mode 100644 index 000000000..88924cbed --- /dev/null +++ b/tests/test_test_impact_inventory_support.c @@ -0,0 +1,227 @@ +#include "test_test_impact_inventory_internal.h" + +bool ni_write(if_native *n, const char *path, const void *bytes, size_t size) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) + return false; + bool ok = fwrite(bytes, 1, size, file) == size; + if (fclose(file) != 0) + if_native_stop(n, "adapter fixture file close uncertain; resources retained"); + return ok; +} + +static bool ni_oid(if_native *n, const char *const *args, char oid[65]) { + char output[80]; + if (!if_native_git(n, args, output, sizeof(output))) + return false; + size_t length = strlen(output); + while (length && (output[length - 1] == '\r' || output[length - 1] == '\n')) + output[--length] = 0; + if (length != 40) + return false; + for (size_t i = 0; i < length; i++) + if (!((output[i] >= '0' && output[i] <= '9') || (output[i] >= 'a' && output[i] <= 'f'))) + return false; + memset(oid, 0, 65); + memcpy(oid, output, length); + return true; +} + +static bool ni_object(if_native *n, const char *type, const void *bytes, size_t size, + char oid[65]) { + const char *args[] = {"hash-object", "-w", "--no-filters", "-t", type, "--", n->payload, NULL}; + return ni_write(n, n->payload, bytes, size) && ni_oid(n, args, oid); +} + +static bool ni_commit_index(if_native *n, char oid[65]) { + char tree[65], bytes[512]; + const char *write[] = {"write-tree", NULL}; + if (!ni_oid(n, write, tree)) + return false; + int count = + snprintf(bytes, sizeof(bytes), + "tree %s\nauthor Native Adapter 946684800 +0000\n" + "committer Native Adapter 946684800 +0000\n\n" + "Native adapter fixture\n", + tree); + return count > 0 && (size_t)count < sizeof(bytes) && + ni_object(n, "commit", bytes, (size_t)count, oid); +} + +bool ni_ambient_change(if_native *n) { + char path[IF_NATIVE_PATH], next[65], actual[65]; + const char changed[] = "source.c\n"; + const char *clear[] = {"read-tree", "--empty", NULL}; + const char *ref[] = {"update-ref", "refs/heads/topic", next, NULL}; + const char *head[] = {"rev-parse", "HEAD", NULL}; + return if_native_join(path, n->repo, ".gitignore") && + ni_write(n, path, changed, sizeof(changed) - 1) && if_native_git(n, clear, NULL, 0) && + ni_commit_index(n, next) && strcmp(next, n->head) != 0 && + if_native_git(n, ref, NULL, 0) && ni_oid(n, head, actual) && !strcmp(actual, next); +} + +bool ni_tree(if_native *n, cbm_git_revision_t revision) { + cbm_pinned_tree_options_t options = { + .facts = n->facts, + .revision = revision, + .private_parent = n->parent, + .limits = {.max_files = IF_ROWS, + .max_directories = 16, + .max_total_content_bytes = 16384, + .max_relative_path_bytes = IF_PATH, + .max_arena_bytes = 4 * 1024 * 1024, + .blob_batch = {.max_entries = IF_ROWS, + .max_input_bytes = 16384, + .max_arena_bytes = 4 * 1024 * 1024}}, + .control = {.deadline_ms = UINT64_MAX}}; + cbm_pinned_tree_error_t error; + cbm_pinned_tree_status_t status = cbm_pinned_tree_create(&options, &n->input.tree, &error); + if (error.git.status == CBM_GIT_FACTS_SUPERVISION) + n->unquiesced = true; + if (status != CBM_PINNED_TREE_OK) + return false; + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(n->input.tree); + if (!view || view->revision != revision || strcmp(view->commit, n->head) || + strlen(view->root) >= sizeof(n->input.root)) + return false; + strcpy(n->input.root, view->root); + n->input.source.native_root = n->input.root; + memcpy(n->input.source.manifest_sha256, view->manifest_sha256, 32); + return true; +} + +bool ni_dependency(if_native *n, cbm_git_revision_t revision) { + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(n->input.tree); + if (!view || !view->identity || view->revision != revision || + view->identity->oid_hex_length != 40 || strcmp(view->identity->head, n->head) || + strcmp(view->identity->merge_base, n->head) || strcmp(view->commit, n->head) || + view->file_count != n->input.source.file_count) + return false; + /* Each returned native view expires at the next valid read. Reacquire it. */ + for (size_t i = 0; i < n->input.source.file_count; i++) { + view = cbm_pinned_tree_view(n->input.tree); + const cbm_inventory_file_t *expected = &n->input.files[i]; + if (!view || strcmp((const char *)view->files[i].path, n->input.paths[i]) || + view->files[i].path_length != strlen(n->input.paths[i]) || + view->files[i].git_mode != expected->git_mode || + strcmp(view->files[i].oid, expected->oid) || + view->files[i].content_length != expected->content_length || + memcmp(view->files[i].content_sha256, expected->content_sha256, 32)) + return false; + unsigned char bytes[IF_BYTES]; + size_t copied = SIZE_MAX; + cbm_pinned_tree_error_t error; + cbm_pinned_tree_control_t control = {.deadline_ms = UINT64_MAX}; + if (cbm_pinned_tree_read_prefix(n->input.tree, i, 16384, bytes, sizeof(bytes), &copied, + &control, &error) != CBM_PINNED_TREE_OK || + error.status != CBM_PINNED_TREE_OK || copied != expected->content_length || + memcmp(bytes, n->input.bytes[i], copied)) + return false; + } + cbm_pinned_tree_error_t error; + cbm_pinned_tree_control_t control = {.deadline_ms = UINT64_MAX}; + return cbm_pinned_tree_verify(n->input.tree, &control, &error) == CBM_PINNED_TREE_OK && + cbm_pinned_tree_view(n->input.tree) != NULL; +} + +bool ni_start(if_native *n) { + return if_native_start(n) && ni_dependency(n, CBM_GIT_REV_HEAD); +} + +bool ni_replace_snapshot(if_native *n, bool empty_controls) { + if (!if_native_close(n)) + return false; + cbm_git_facts_free(n->facts); + n->facts = NULL; + if_init(&n->input); + if (empty_controls && + (!if_text(&n->input, ".cbmignore", "") || !if_text(&n->input, ".gitignore", "") || + !if_text(&n->input, "source.c", "int source;\n"))) + return false; + const char *clear[] = {"read-tree", "--empty", NULL}; + if (!if_native_git(n, clear, NULL, 0)) + return false; + for (size_t i = 0; i < n->input.source.file_count; i++) { + cbm_inventory_file_t *file = &n->input.files[i]; + if (!ni_object(n, "blob", n->input.bytes[i], (size_t)file->content_length, file->oid)) + return false; + char entry[IF_PATH + 80]; + file->git_mode = i == 2 ? 0100755 : 0100644; + int length = snprintf(entry, sizeof(entry), "%o,%s,%s", (unsigned)file->git_mode, file->oid, + n->input.paths[i]); + const char *add[] = {"update-index", "--add", "--cacheinfo", entry, NULL}; + if (length <= 0 || (size_t)length >= sizeof(entry) || !if_native_git(n, add, NULL, 0)) + return false; + } + const char *ref[] = {"update-ref", "refs/heads/topic", n->head, NULL}; + if (!ni_commit_index(n, n->head) || !if_native_git(n, ref, NULL, 0)) + return false; + cbm_git_facts_options_t options = {.root = n->repo, + .base_ref = "HEAD", + .git_executable = n->git, + .expected_head = n->head, + .deadline_ms = UINT64_MAX, + .command_limit = 512, + .stdout_limit = 1024 * 1024, + .stderr_limit = 65536, + .total_output_limit = 8 * 1024 * 1024}; + cbm_git_facts_error_t error; + n->facts = cbm_git_facts_open(&options, &error); + if (error.status == CBM_GIT_FACTS_SUPERVISION) + n->unquiesced = true; + return n->facts && error.status == CBM_GIT_FACTS_OK && ni_tree(n, CBM_GIT_REV_HEAD) && + ni_dependency(n, CBM_GIT_REV_HEAD); +} + +bool ni_prepare(if_native *n, cbm_test_impact_inventory_t **owner) { + cbm_inventory_error_t error; + memset(&error, 0xa5, sizeof(error)); + cbm_inventory_status_t status = cbm_test_impact_inventory_prepare( + n->input.tree, &n->input.limits, &n->input.control, owner, &error); + if (status != CBM_INVENTORY_OK || !*owner) + fprintf(stderr, "native adapter status=%d error=%d index=%zu cleanup=%d\n", status, + error.status, error.file_index, error.cleanup_required); + return status == CBM_INVENTORY_OK && *owner && error.status == CBM_INVENTORY_OK && + error.file_index == SIZE_MAX && !error.cleanup_required && !error.diagnostic[0]; +} + +bool ni_error(if_native *n, cbm_inventory_status_t expected, size_t index, bool cleanup, + bool allow_unknown_index) { + cbm_test_impact_inventory_t *sentinel = (cbm_test_impact_inventory_t *)(uintptr_t)1; + cbm_test_impact_inventory_t *owner = sentinel; + cbm_inventory_error_t error; + memset(&error, 0xa5, sizeof(error)); + cbm_inventory_status_t status = cbm_test_impact_inventory_prepare( + n->input.tree, &n->input.limits, &n->input.control, &owner, &error); + bool no_owner = owner == NULL; + if (owner != sentinel) + cbm_test_impact_inventory_free(owner); + bool index_ok = allow_unknown_index ? error.file_index == SIZE_MAX || + error.file_index < n->input.source.file_count + : error.file_index == index; + bool diagnostic_ok = memchr(error.diagnostic, 0, sizeof(error.diagnostic)) != NULL; + bool ok = status == expected && error.status == expected && no_owner && index_ok && + error.cleanup_required == cleanup && diagnostic_ok; + if (!ok) + fprintf(stderr, "native adapter error actual=%d expected=%d index=%zu cleanup=%d null=%d\n", + status, expected, error.file_index, error.cleanup_required, no_owner); + return ok; +} + +bool ni_limits_equal(const cbm_inventory_limits_t *a, const cbm_inventory_limits_t *b) { + return a->max_files == b->max_files && a->max_directories == b->max_directories && + a->max_arena_bytes == b->max_arena_bytes && + a->max_ignore_arena_bytes == b->max_ignore_arena_bytes && + a->max_control_file_bytes == b->max_control_file_bytes && + a->max_control_total_bytes == b->max_control_total_bytes && + a->max_ignore_patterns == b->max_ignore_patterns && + a->max_probe_prefix_bytes == b->max_probe_prefix_bytes && + a->max_ignore_work == b->max_ignore_work && + a->max_verified_file_reads == b->max_verified_file_reads && + a->max_verified_content_bytes == b->max_verified_content_bytes; +} + +int ni_finish(if_native *n, cbm_test_impact_inventory_t *owner, int result) { + cbm_test_impact_inventory_free(owner); + return if_native_finish(n, result); +} diff --git a/tests/test_test_impact_origins.c b/tests/test_test_impact_origins.c new file mode 100644 index 000000000..303677a35 --- /dev/null +++ b/tests/test_test_impact_origins.c @@ -0,0 +1,1949 @@ +/* Independent format-2 coverage-origin contracts. Admission flags below are + * synthetic unit-test evidence, never a real producer/provider verification. */ +#include "test_framework.h" +#include "../src/mcp/test_impact_origins.h" +#include "../src/foundation/arena.h" +#include "../src/foundation/sha256.h" +#include "../src/foundation/compat_thread.h" +#include +#include +#include +#include + +static const char ov_functions[] = + "0\tsrc/a.c\tshared\n1\tinc/common.h\tshared\n2\t\t__external_fallback\n" + "3\tsrc/generated.c\tgenerated\n"; +static const char ov_tests[] = + "core:alpha\tcomplete\t\t0 2 3\ncore:empty\tcomplete\t\t\ncore:*\tcomplete\t\t1\n"; +static const char ov_empty_tests[] = "core:empty\tcomplete\t\t\ncore:*\tcomplete\t\t\n"; +static const char ov_lf_sha[] = "2e7f7a09b3da6912f8198faf3d6a8ec2de60d8cd632d9d282b05512463f1ef84"; +static const char ov_crlf_sha[] = "32836d30f88a9e9ebdad43359f7bf86b245004e6411f6cc00e86d0df732ae46e"; +static const char ov_wire_sha[] = "7a75653ffd6aaded0582dbf559ce346bfa7fa2266593ba90c0bd6b3eee7f027a"; +static const char ov_wire_hex[] = + "63626d2d636f7665726167652d6f726967696e73000000000200000009746573742d7265706f0111" + "111111111111111111111111111111111111112e7f7a09b3da6912f8198faf3d6a8ec2de60d8cd63" + "2d9d282b05512463f1ef840000000444444444444444444444444444444444444444444444444444" + "44444444444444555555555555555555555555555555555555555555555555555555555555555501" + "01666666666666666666666666666666666666666666666666666666666666666600000001000000" + "077372632f612e6302000081a47777777777777777777777777777777777777777010f0188888888" + "88888888888888888888888888888888888888888888888888888888000000020000000100000003" + "7f"; + +typedef struct { + const unsigned char *path; + size_t path_length; + unsigned state; + uint32_t mode; + unsigned disposition; + unsigned edits; + bool proof; + const uint32_t *ids; + size_t count; +} ov_row_t; + +typedef struct { + size_t path, state, mode, disposition, edits, proof, id_count, ids; +} ov_layout_t; + +typedef struct { + CBMArena arena; + cbm_coverage_map_t *map; + cbm_changes_t *changes; + cbm_coverage_origin_context_t context; + cbm_coverage_origin_input_t input; + cbm_coverage_origin_limits_t limits; + unsigned char *wire; + size_t length, capacity; + bool encoded, universe, universe_proof; + size_t global_flag, global_proof, path_count; + ov_layout_t rows[32]; + int function_count; +} ov_fixture_t; + +static void ov_hash(const void *bytes, size_t length, unsigned char out[32]) { + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + if (length) + cbm_sha256_update(&hash, bytes, length); + cbm_sha256_final(&hash, out); +} + +static void *ov_copy(ov_fixture_t *f, const void *bytes, size_t length) { + unsigned char *copy = cbm_arena_alloc(&f->arena, length + 1); + if (!copy) + return NULL; + if (length) + memcpy(copy, bytes, length); + copy[length] = 0; + return copy; +} + +static void ov_close(ov_fixture_t *f) { + cbm_changes_free(f->changes); + cbm_coverage_map_free(f->map); + cbm_arena_destroy(&f->arena); + memset(f, 0, sizeof(*f)); +} + +static bool ov_names(ov_fixture_t *f, const void *ah, size_t ah_length, + const void *mh, size_t mh_length, bool unknown_request) { + unsigned char *a = ov_copy(f, ah, ah_length), *m = ov_copy(f, mh, mh_length); + if (!a || !m) + return false; + cbm_changes_free(f->changes); + f->changes = NULL; + /* NULL+zero spans are a valid empty diff. An explicit incomplete patch + * is needed for a real UNKNOWN D4a owner with an empty path inventory. */ + static const unsigned char incomplete_patch[] = {0}; + cbm_changes_status_t status = cbm_changes_parse( + m, mh_length, unknown_request ? incomplete_patch : (const unsigned char *)"", + unknown_request ? sizeof(incomplete_patch) : 0, &f->changes); + if (status != CBM_CHANGES_OK || !f->changes) + return false; + if (unknown_request && !(cbm_changes_issues(f->changes) & CBM_CHANGES_PATCH_INCOMPLETE)) + return false; + f->input.artifact_to_head = (cbm_coverage_origin_bytes_t){a, ah_length}; + f->input.merge_base_to_head = (cbm_coverage_origin_bytes_t){m, mh_length}; + f->input.request_changes = f->changes; + ov_hash(a, ah_length, f->context.artifact_to_head_sha256); + ov_hash(m, mh_length, f->context.merge_base_to_head_sha256); + return cbm_changes_state(f->changes) == + (unknown_request ? CBM_CHANGES_UNKNOWN : + mh_length ? CBM_CHANGES_NONEMPTY : CBM_CHANGES_EMPTY); +} + +static bool ov_open(ov_fixture_t *f, const char *functions, size_t function_bytes, + const char *tests, size_t test_bytes) { + memset(f, 0, sizeof(*f)); + cbm_arena_init_lazy(&f->arena, 4096); + char *owned_functions = ov_copy(f, functions, function_bytes); + char *owned_tests = ov_copy(f, tests, test_bytes); + unsigned char *key = ov_copy(f, "test-repo", 9); + if (!owned_functions || !owned_tests || !key) + return false; + f->map = cbm_coverage_map_parse(owned_functions, function_bytes, owned_tests, test_bytes); + if (!f->map) + return false; + (void)cbm_coverage_map_functions(f->map, &f->function_count); + f->context.artifact_admitted = true; + f->context.origin_source_verified = true; + f->context.origin_attestations_verified = true; + f->context.comparisons_verified = true; + f->context.binding.repository_key = (cbm_coverage_origin_bytes_t){key, 9}; + f->context.binding.object_format = CBM_COVERAGE_ORIGIN_GIT_SHA1; + memset(f->context.binding.artifact.bytes, 0x11, 20); + memset(f->context.binding.merge_base.bytes, 0x22, 20); + memset(f->context.binding.head.bytes, 0x33, 20); + ov_hash(functions, function_bytes, f->context.binding.functions_sha256); + f->context.binding.manifest_version = 2; + memset(f->context.binding.compatibility_sha256, 0x44, 32); + memset(f->context.binding.producer_profile_sha256, 0x55, 32); + f->input.coverage = f->map; + f->input.context = &f->context; + f->limits = (cbm_coverage_origin_limits_t){ + .max_input_bytes = 8U * 1024U * 1024U, .max_items = 10000000, + .max_alloc_bytes = 32U * 1024U * 1024U, .max_result_ids = INT_MAX}; + f->universe = f->universe_proof = true; + return ov_names(f, "", 0, "", 0, false); +} + +static bool ov_default(ov_fixture_t *f) { + return ov_open(f, ov_functions, sizeof(ov_functions) - 1, ov_tests, sizeof(ov_tests) - 1); +} + +static ov_row_t ov_row(const char *path, unsigned state, unsigned disposition, + unsigned edits, const uint32_t *ids, size_t count) { + return (ov_row_t){(const unsigned char *)path, strlen(path), state, 0100644, + disposition, edits, true, ids, count}; +} + +static void ov_put(ov_fixture_t *f, const void *data, size_t n) { + if (!f->encoded || n > f->capacity - f->length) { + f->encoded = false; + return; + } + if (n) + memcpy(f->wire + f->length, data, n); + f->length += n; +} + +static void ov_u8(ov_fixture_t *f, unsigned n) { + unsigned char byte = (unsigned char)n; + ov_put(f, &byte, 1); +} + +static void ov_u32(ov_fixture_t *f, uint32_t n) { + unsigned char bytes[] = {(unsigned char)(n >> 24), (unsigned char)(n >> 16), + (unsigned char)(n >> 8), (unsigned char)n}; + ov_put(f, bytes, sizeof(bytes)); +} + +static void ov_proof(ov_fixture_t *f, bool present, unsigned char marker) { + ov_u8(f, present ? 1 : 0); + if (present) { + unsigned char bytes[32]; + memset(bytes, marker, sizeof(bytes)); + ov_put(f, bytes, sizeof(bytes)); + } +} + +static void ov_rehash(ov_fixture_t *f) { + f->input.manifest = (cbm_coverage_origin_bytes_t){f->wire, f->length}; + ov_hash(f->wire, f->length, f->context.binding.manifest_sha256); +} + +/* Deliberately simple field writer, independent of the production parser. */ +static bool ov_wire(ov_fixture_t *f, const ov_row_t *rows, size_t count) { + if (count > 32) + return false; + f->capacity = 512; + for (size_t i = 0; i < count; i++) + f->capacity += rows[i].path_length + 128 + rows[i].count * 4; + f->wire = cbm_arena_alloc(&f->arena, f->capacity); + f->length = 0; + f->encoded = f->wire != NULL; + memset(f->rows, 0, sizeof(f->rows)); + ov_put(f, "cbm-coverage-origins", sizeof("cbm-coverage-origins")); + ov_u32(f, 2); + ov_u32(f, (uint32_t)f->context.binding.repository_key.length); + ov_put(f, f->context.binding.repository_key.data, f->context.binding.repository_key.length); + ov_u8(f, f->context.binding.object_format); + size_t width = f->context.binding.object_format == CBM_COVERAGE_ORIGIN_GIT_SHA1 ? 20 : 32; + ov_put(f, f->context.binding.artifact.bytes, width); + ov_put(f, f->context.binding.functions_sha256, 32); + ov_u32(f, (uint32_t)f->function_count); + ov_put(f, f->context.binding.compatibility_sha256, 32); + ov_put(f, f->context.binding.producer_profile_sha256, 32); + f->global_flag = f->length; + ov_u8(f, f->universe ? 1 : 0); + f->global_proof = f->length; + ov_proof(f, f->universe_proof, 0x66); + f->path_count = f->length; + ov_u32(f, (uint32_t)count); + for (size_t i = 0; i < count; i++) { + const ov_row_t *r = &rows[i]; + ov_layout_t *l = &f->rows[i]; + ov_u32(f, (uint32_t)r->path_length); + l->path = f->length; + ov_put(f, r->path, r->path_length); + l->state = f->length; + ov_u8(f, r->state); + if (r->state == 2 || r->state == 3) { + l->mode = f->length; + ov_u32(f, r->mode); + unsigned char oid[32]; + memset(oid, 0x77, sizeof(oid)); + ov_put(f, oid, width); + } + l->disposition = f->length; + ov_u8(f, r->disposition); + l->edits = f->length; + ov_u8(f, r->edits); + l->proof = f->length; + ov_proof(f, r->proof, 0x88); + if (r->disposition != CBM_COVERAGE_ORIGIN_ALL) { + l->id_count = f->length; + ov_u32(f, (uint32_t)r->count); + l->ids = f->length; + for (size_t j = 0; j < r->count; j++) + ov_u32(f, r->ids[j]); + } + } + ov_u8(f, 0x7f); + if (!f->encoded) + return false; + ov_rehash(f); + return true; +} + +static bool ov_ids_equal(const int *got, size_t count, const int *want, size_t wanted) { + if (count != wanted || (count && !got)) + return false; + for (size_t i = 0; i < count; i++) + if (got[i] != want[i]) + return false; + return true; +} + +static bool ov_path_equal(const cbm_coverage_origin_path_t *path, + const void *bytes, size_t length) { + return path && path->path.data && path->path.length == length && + memcmp(path->path.data, bytes, length) == 0 && path->path.data[length] == 0; +} + +static bool ov_success(ov_fixture_t *f, unsigned reasons, bool broad, + const int *ids, size_t count) { + cbm_coverage_origin_join_t *join = NULL; + cbm_coverage_origin_status_t status = cbm_coverage_origin_join( + &f->input, &f->limits, NULL, NULL, &join); + int got_count = -1; + const int *got = cbm_coverage_origin_join_ids(join, &got_count); + bool complete = cbm_coverage_origin_join_complete(join); + bool actual_broad = cbm_coverage_origin_join_broad_fallback_required(join); + bool ok = status == CBM_COVERAGE_ORIGIN_OK && join && got_count >= 0 && + ov_ids_equal(got, (size_t)got_count, ids, count) && + cbm_coverage_origin_join_reasons(join) == reasons && complete == (reasons == 0) && + actual_broad == broad && + cbm_coverage_origin_join_can_narrow(join) == (complete && !actual_broad) && + cbm_coverage_origin_join_request_state(join) == cbm_changes_state(f->changes); + cbm_coverage_origin_join_free(join); + return ok; +} + +static bool ov_error(ov_fixture_t *f, cbm_coverage_origin_status_t expected) { + cbm_coverage_origin_join_t *join = NULL; + cbm_coverage_origin_status_t status = cbm_coverage_origin_join( + &f->input, &f->limits, NULL, NULL, &join); + bool ok = status == expected && join == NULL; + cbm_coverage_origin_join_free(join); + return ok; +} + +static bool ov_hex_equal(const unsigned char *bytes, size_t count, const char *hex) { + static const char digits[] = "0123456789abcdef"; + if (strlen(hex) != count * 2) + return false; + for (size_t i = 0; i < count; i++) + if (hex[i * 2] != digits[bytes[i] >> 4] || hex[i * 2 + 1] != digits[bytes[i] & 15]) + return false; + return true; +} + +TEST(origin_v2_wire_and_actual_map_digest) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const unsigned char names[] = "M\0src/a.c\0"; + static const uint32_t listed[] = {1, 3}; + static const int wanted[] = {1, 3}; + ov_row_t row = ov_row("src/a.c", 2, CBM_COVERAGE_ORIGIN_COMPLETE_IDS, 15, listed, 2); + bool control = ready && f.function_count == 4 && + ov_names(&f, names, sizeof(names) - 1, names, sizeof(names) - 1, false) && + ov_wire(&f, &row, 1); + bool golden = control && f.length == 281 && ov_hex_equal(f.wire, f.length, ov_wire_hex); + char digest[65]; + if (control) + cbm_sha256_hex(f.wire, f.length, digest); + golden = golden && strcmp(digest, ov_wire_sha) == 0; + const char *actual = ready ? cbm_coverage_map_functions_sha256(f.map) : NULL; + bool getter = actual && strcmp(actual, ov_lf_sha) == 0; + bool ok = control && ov_success(&f, 0, false, wanted, 2); + if (control) { + f.context.binding.object_format = CBM_COVERAGE_ORIGIN_GIT_SHA256; + memset(f.context.binding.artifact.bytes, 0x11, 32); + memset(f.context.binding.merge_base.bytes, 0x22, 32); + memset(f.context.binding.head.bytes, 0x33, 32); + ok = ov_wire(&f, &row, 1) && ov_success(&f, 0, false, wanted, 2) && ok; + char crlf[sizeof(ov_functions) * 2]; + size_t at = 0; + for (size_t i = 0; i < sizeof(ov_functions) - 1; i++) { + if (ov_functions[i] == '\n') + crlf[at++] = '\r'; + crlf[at++] = ov_functions[i]; + } + cbm_coverage_map_t *other = cbm_coverage_map_parse(crlf, at, ov_tests, sizeof(ov_tests) - 1); + const char *other_sha = cbm_coverage_map_functions_sha256(other); + getter = other && other_sha && strcmp(other_sha, ov_crlf_sha) == 0 && getter; + f.input.coverage = other; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + f.input.coverage = f.map; + cbm_coverage_map_free(other); + } + ov_close(&f); + ASSERT_TRUE(control); + ASSERT_TRUE(golden); + ASSERT_TRUE(getter); + ASSERT_TRUE(ok); + PASS(); +} + +TEST(origin_v2_union_and_request_state) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const unsigned char ah[] = "M\0new.c\0D\0old.c\0"; + static const unsigned char mh[] = "M\0reverted.c\0M\0new.c\0"; + static const uint32_t a[] = {0}, b[] = {1}, c[] = {2}; + static const int wanted[] = {0, 1, 2}; + ov_row_t rows[] = {ov_row("new.c", 2, 1, 15, a, 1), ov_row("old.c", 2, 1, 15, b, 1), + ov_row("reverted.c", 2, 1, 15, c, 1)}; + bool control = ready && ov_names(&f, ah, sizeof(ah) - 1, mh, sizeof(mh) - 1, false) && + ov_wire(&f, rows, 3); + bool ok = control && ov_success(&f, 0, false, wanted, 3); + cbm_coverage_origin_join_t *join = NULL; + if (control) { + cbm_coverage_origin_status_t status = cbm_coverage_origin_join( + &f.input, &f.limits, NULL, NULL, &join); + size_t count = 0; + const cbm_coverage_origin_path_t *paths = cbm_coverage_origin_join_paths(join, &count); + ok = status == CBM_COVERAGE_ORIGIN_OK && count == 3 && paths && + ov_path_equal(&paths[0], "new.c", 5) && paths[0].comparisons == 3 && + paths[0].artifact_status == 'M' && paths[0].merge_base_status == 'M' && + ov_path_equal(&paths[1], "old.c", 5) && paths[1].comparisons == 1 && + paths[1].artifact_status == 'D' && paths[1].merge_base_status == 0 && + ov_path_equal(&paths[2], "reverted.c", 10) && paths[2].comparisons == 2 && + paths[2].artifact_status == 0 && paths[2].merge_base_status == 'M' && ok; + cbm_coverage_origin_join_free(join); + static const unsigned char reverted[] = "M\0reverted.c\0"; + ok = ov_names(&f, "", 0, reverted, sizeof(reverted) - 1, false) && + ov_wire(&f, rows, 3) && ov_success(&f, 0, false, (const int[]){2}, 1) && ok; + rows[2].state = 1; /* AH empty proves same presence at A and H. */ + ok = ov_wire(&f, rows, 3) && ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + rows[2].state = 2; + ok = ov_wire(&f, rows, 3) && ov_success(&f, 0, false, (const int[]){2}, 1) && ok; + f.context.binding.merge_base = f.context.binding.head; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + memset(f.context.binding.merge_base.bytes, 0x22, 20); + f.context.binding.artifact = f.context.binding.merge_base; + ok = ov_wire(&f, rows, 3) && ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + f.context.binding.artifact = f.context.binding.head; + ok = ov_wire(&f, rows, 3) && ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + memset(f.context.binding.artifact.bytes, 0x11, 20); + ok = ov_names(&f, "", 0, "", 0, true) && ov_wire(&f, rows, 3) && + ov_success(&f, CBM_COVERAGE_ORIGIN_REQUEST_UNKNOWN, false, NULL, 0) && ok; + static const unsigned char one[] = "M\0new.c\0", gone[] = "D\0new.c\0"; + ok = ov_names(&f, gone, sizeof(gone) - 1, one, sizeof(one) - 1, false) && + ov_wire(&f, rows, 3) && ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + ok = ov_names(&f, one, sizeof(one) - 1, one, sizeof(one) - 1, false) && + ov_wire(&f, rows, 3) && ov_success(&f, 0, false, (const int[]){0}, 1) && ok; + /* A separately valid D4a owner with a different inventory cannot bind. */ + cbm_changes_t *wrong = NULL; + bool wrong_ready = cbm_changes_parse(reverted, sizeof(reverted) - 1, + (const unsigned char *)"", 0, &wrong) == CBM_CHANGES_OK && wrong; + f.input.request_changes = wrong; + ok = wrong_ready && ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + f.input.request_changes = f.changes; + cbm_changes_free(wrong); + /* Matching equal-commit cases remain valid, not a blanket refusal. */ + f.context.binding.artifact = f.context.binding.merge_base; + ok = ov_wire(&f, rows, 3) && ov_success(&f, 0, false, (const int[]){0}, 1) && ok; + memset(f.context.binding.artifact.bytes, 0x11, 20); + f.context.binding.merge_base = f.context.binding.head; + ok = ov_names(&f, one, sizeof(one) - 1, "", 0, false) && + ov_wire(&f, rows, 3) && ov_success(&f, 0, false, (const int[]){0}, 1) && ok; + f.context.binding.artifact = f.context.binding.head; + ok = ov_names(&f, "", 0, "", 0, false) && ov_wire(&f, rows, 3) && + ov_success(&f, 0, false, NULL, 0) && ok; + } + ov_close(&f); + ASSERT_TRUE(control); + ASSERT_TRUE(ok); + PASS(); +} + +TEST(origin_v2_many_to_many_and_unknown_ids) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const unsigned char names[] = "M\0macro.h\0M\0generated.c\0M\0inline.h\0"; + static const uint32_t ids_a[] = {0, 2}, ids_b[] = {1, 2}, ids_c[] = {2, 3}; + static const int wanted[] = {0, 1, 2, 3}; + ov_row_t rows[] = {ov_row("generated.c", 2, 1, 15, ids_a, 2), + ov_row("inline.h", 2, 1, 15, ids_b, 2), + ov_row("macro.h", 2, 1, 15, ids_c, 2)}; + bool control = ready && ov_names(&f, names, sizeof(names) - 1, names, sizeof(names) - 1, false) && + ov_wire(&f, rows, 3); + bool ok = control && ov_success(&f, 0, false, wanted, 4); + if (control) { + rows[1].disposition = CBM_COVERAGE_ORIGIN_UNKNOWN; + rows[1].proof = false; + ok = ov_wire(&f, rows, 3) && ov_success(&f, CBM_COVERAGE_ORIGIN_CLAIM_UNKNOWN, + false, wanted, 4) && ok; + cbm_coverage_origin_join_t *join = NULL; + cbm_coverage_origin_status_t status = cbm_coverage_origin_join( + &f.input, &f.limits, NULL, NULL, &join); + size_t count = 0; + const cbm_coverage_origin_path_t *paths = cbm_coverage_origin_join_paths(join, &count); + ok = status == CBM_COVERAGE_ORIGIN_OK && count == 3 && paths && + ov_ids_equal(paths[1].function_ids, paths[1].function_count, + (const int[]){1, 2}, 2) && + paths[1].disposition == CBM_COVERAGE_ORIGIN_UNKNOWN && + paths[1].reasons == CBM_COVERAGE_ORIGIN_CLAIM_UNKNOWN && ok; + cbm_coverage_origin_join_free(join); + } + ov_close(&f); + ASSERT_TRUE(control); + ASSERT_TRUE(ok); + PASS(); +} + +TEST(origin_v2_edit_class_obligations) { + ov_fixture_t f; + bool ready = ov_default(&f), ok = ready; + static const uint32_t id[] = {1}; + static const int wanted[] = {1}; + const char statuses[] = {'A', 'M', 'D', 'T'}; + const unsigned bits[] = {1, 2, 4, 8}; + bool control = ready; + for (size_t i = 0; ready && i < 4; i++) { + unsigned char names[] = {'M', 0, 'f', 0}; + names[0] = (unsigned char)statuses[i]; + ov_row_t row = ov_row("f", statuses[i] == 'A' ? 1 : 2, 1, bits[i], id, 1); + bool setup = ov_names(&f, names, sizeof(names), names, sizeof(names), false) && + ov_wire(&f, &row, 1); + control = setup && control; + ok = setup && ov_success(&f, 0, false, wanted, 1) && ok; + row.edits = 0; + ok = ov_wire(&f, &row, 1) && + ov_success(&f, CBM_COVERAGE_ORIGIN_EDIT_UNSUPPORTED, false, wanted, 1) && ok; + } + if (ready) { + static const unsigned char ah[] = "A\0f\0", mh[] = "M\0f\0"; + ov_row_t row = ov_row("f", 1, 1, CBM_COVERAGE_ORIGIN_EDIT_A, id, 1); + ok = ov_names(&f, ah, sizeof(ah) - 1, mh, sizeof(mh) - 1, false) && + ov_wire(&f, &row, 1) && + ov_success(&f, CBM_COVERAGE_ORIGIN_EDIT_UNSUPPORTED, false, wanted, 1) && ok; + row.edits = CBM_COVERAGE_ORIGIN_EDIT_A | CBM_COVERAGE_ORIGIN_EDIT_M; + ok = ov_wire(&f, &row, 1) && ov_success(&f, 0, false, wanted, 1) && ok; + /* M covers binary/mode/symlink changes without consulting hunk evidence. */ + static const unsigned char modified[] = "M\0f\0"; + row.state = 2; + row.mode = 0120000; + row.edits = CBM_COVERAGE_ORIGIN_EDIT_M; + ok = ov_names(&f, modified, sizeof(modified) - 1, modified, sizeof(modified) - 1, false) && + ov_wire(&f, &row, 1) && ov_success(&f, 0, false, wanted, 1) && + !cbm_changes_can_narrow(f.changes) && ok; + row.mode = 0100644; + ok = ov_wire(&f, &row, 1) && ov_success(&f, 0, false, wanted, 1) && ok; + const char *patches[] = { + "diff --git a/f b/f\nold mode 100644\nnew mode 100755\n", + "diff --git a/f b/f\nBinary files a/f and b/f differ\n"}; + const unsigned evidence[] = {CBM_CHANGE_MODE, CBM_CHANGE_BINARY}; + for (size_t i = 0; i < 2; i++) { + cbm_changes_t *with_patch = NULL; + bool parsed = cbm_changes_parse(modified, sizeof(modified) - 1, + (const unsigned char *)patches[i], strlen(patches[i]), &with_patch) == + CBM_CHANGES_OK && with_patch; + size_t count = 0; + const cbm_change_file_t *files = cbm_changes_files(with_patch, &count); + bool patch_control = parsed && count == 1 && files && + (files[0].reasons & evidence[i]) != 0 && !cbm_changes_can_narrow(with_patch); + f.input.request_changes = with_patch; + ok = patch_control && ov_success(&f, 0, false, wanted, 1) && ok; + f.input.request_changes = f.changes; + cbm_changes_free(with_patch); + } + static const unsigned char type_change[] = "T\0f\0"; + row.state = 3; + row.mode = 0160000; + row.edits = CBM_COVERAGE_ORIGIN_EDIT_T; + ok = ov_names(&f, type_change, sizeof(type_change) - 1, + type_change, sizeof(type_change) - 1, false) && + ov_wire(&f, &row, 1) && ov_success(&f, 0, false, wanted, 1) && ok; + } + ov_close(&f); + ASSERT_TRUE(control); + ASSERT_TRUE(ok); + PASS(); +} + +TEST(origin_v2_optional_header_and_certified_zero) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const unsigned char names[] = "A\0optional.h\0"; + ov_row_t row = ov_row("optional.h", 1, 1, CBM_COVERAGE_ORIGIN_EDIT_A, NULL, 0); + bool control = ready && ov_names(&f, names, sizeof(names) - 1, names, sizeof(names) - 1, false) && + ov_wire(&f, &row, 1); + bool ok = control && ov_success(&f, 0, false, NULL, 0); + if (control) { + ok = ov_wire(&f, NULL, 0) && + ov_success(&f, CBM_COVERAGE_ORIGIN_PATH_MISSING, false, NULL, 0) && ok; + row.disposition = CBM_COVERAGE_ORIGIN_UNKNOWN; + ok = ov_wire(&f, &row, 1) && + ov_success(&f, CBM_COVERAGE_ORIGIN_CLAIM_UNKNOWN, false, NULL, 0) && ok; + row.disposition = CBM_COVERAGE_ORIGIN_COMPLETE_IDS; + row.proof = false; + ok = ov_wire(&f, &row, 1) && + ov_success(&f, CBM_COVERAGE_ORIGIN_CLAIM_UNPROVEN, false, NULL, 0) && ok; + row.proof = true; + row.state = 0; + ok = ov_wire(&f, &row, 1) && + ov_success(&f, CBM_COVERAGE_ORIGIN_PRESENCE_UNKNOWN, false, NULL, 0) && ok; + row.state = 2; /* AH=A contradicts tracked presence at A. */ + ok = ov_wire(&f, &row, 1) && ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + row.state = 1; + f.universe_proof = false; + ok = ov_wire(&f, &row, 1) && + ov_success(&f, CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN, false, NULL, 0) && ok; + f.universe_proof = true; + ok = ov_wire(&f, &row, 1) && ov_success(&f, 0, false, NULL, 0) && + cbm_changes_state(f.changes) == CBM_CHANGES_NONEMPTY && ok; + } + ov_close(&f); + ASSERT_TRUE(control); + ASSERT_TRUE(ok); + PASS(); +} + +TEST(origin_v2_all_sticky_broad_floor) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const unsigned char ah[] = "M\0all.c\0M\0last.c\0"; + static const uint32_t one[] = {1}, all_ids[] = {0, 1, 2, 3}; + static const int wanted[] = {0, 1, 2, 3}; + ov_row_t rows[] = {ov_row("all.c", 2, CBM_COVERAGE_ORIGIN_ALL, 15, NULL, 0), + ov_row("last.c", 2, CBM_COVERAGE_ORIGIN_COMPLETE_IDS, 15, one, 1)}; + bool control = ready && ov_names(&f, ah, sizeof(ah) - 1, "", 0, false) && ov_wire(&f, rows, 2); + const cbm_coverage_test_t *empty = ready ? cbm_coverage_map_find_test(f.map, "core", "empty") : NULL; + bool empty_control = empty && empty->complete && empty->function_count == 0 && + !cbm_coverage_test_intersects(empty, wanted, 4); + bool ok = control && ov_success(&f, 0, true, wanted, 4) && + cbm_changes_state(f.changes) == CBM_CHANGES_EMPTY; + if (control) { + rows[0].proof = false; + rows[0].state = 0; + rows[0].edits = 0; + ok = ov_wire(&f, rows, 2) && ov_success(&f, + CBM_COVERAGE_ORIGIN_CLAIM_UNPROVEN | CBM_COVERAGE_ORIGIN_PRESENCE_UNKNOWN | + CBM_COVERAGE_ORIGIN_EDIT_UNSUPPORTED, true, wanted, 4) && ok; + rows[0].proof = true; + rows[0].state = 2; + rows[0].edits = 15; + static const unsigned char last[] = "M\0last.c\0"; + ok = ov_names(&f, last, sizeof(last) - 1, last, sizeof(last) - 1, false) && + ov_wire(&f, rows, 2) && ov_success(&f, 0, false, (const int[]){1}, 1) && ok; + rows[1].disposition = CBM_COVERAGE_ORIGIN_ALL; + ok = ov_names(&f, ah, sizeof(ah) - 1, ah, sizeof(ah) - 1, false) && + ov_wire(&f, rows, 2) && ov_success(&f, 0, true, wanted, 4) && ok; + rows[0].disposition = rows[1].disposition = CBM_COVERAGE_ORIGIN_COMPLETE_IDS; + rows[0].ids = rows[1].ids = all_ids; + rows[0].count = rows[1].count = 4; + ok = ov_wire(&f, rows, 2) && ov_success(&f, 0, false, wanted, 4) && ok; + } + ov_close(&f); + ov_fixture_t zero; + bool zero_ready = ov_open(&zero, "", 0, ov_empty_tests, sizeof(ov_empty_tests) - 1); + static const unsigned char add[] = "A\0optional.h\0"; + ov_row_t all = ov_row("optional.h", 1, CBM_COVERAGE_ORIGIN_ALL, 15, NULL, 0); + bool zero_control = zero_ready && zero.function_count == 0 && + ov_names(&zero, add, sizeof(add) - 1, add, sizeof(add) - 1, false) && + ov_wire(&zero, &all, 1); + bool zero_ok = zero_control && ov_success(&zero, 0, true, NULL, 0); + if (zero_control) { + zero.universe_proof = false; + zero_ok = ov_wire(&zero, &all, 1) && ov_success(&zero, + CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN, true, NULL, 0) && zero_ok; + zero_ok = ov_names(&zero, "", 0, "", 0, false) && ov_wire(&zero, NULL, 0) && + ov_success(&zero, CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN, + false, NULL, 0) && zero_ok; + zero.universe_proof = true; + zero_ok = ov_wire(&zero, NULL, 0) && ov_success(&zero, 0, false, NULL, 0) && zero_ok; + } + ov_close(&zero); + ASSERT_TRUE(control); + ASSERT_TRUE(empty_control); + ASSERT_TRUE(zero_control); + ASSERT_TRUE(ok); + ASSERT_TRUE(zero_ok); + PASS(); +} + +TEST(origin_v2_exact_reason_masks) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const unsigned char names[] = "M\0f\0"; + static const uint32_t id[] = {2}; + static const int wanted[] = {2}; + ov_row_t base = ov_row("f", 2, 1, 15, id, 1); + bool control = ready && ov_names(&f, names, sizeof(names) - 1, names, sizeof(names) - 1, false) && + ov_wire(&f, &base, 1); + bool ok = control && ov_success(&f, 0, false, wanted, 1); + for (int variant = 0; control && variant < 6; variant++) { + ov_row_t row = base; + unsigned expected = 0; + f.universe = f.universe_proof = true; + if (variant == 0) { f.universe = false; expected = CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN; } + if (variant == 1) { f.universe_proof = false; expected = CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN; } + if (variant == 2) { row.state = 0; expected = CBM_COVERAGE_ORIGIN_PRESENCE_UNKNOWN; } + if (variant == 3) { row.disposition = 0; row.proof = false; expected = CBM_COVERAGE_ORIGIN_CLAIM_UNKNOWN; } + if (variant == 4) { row.proof = false; expected = CBM_COVERAGE_ORIGIN_CLAIM_UNPROVEN; } + if (variant == 5) { row.edits = 0; expected = CBM_COVERAGE_ORIGIN_EDIT_UNSUPPORTED; } + ok = ov_wire(&f, &row, 1) && ov_success(&f, expected, false, wanted, 1) && ok; + } + if (control) { + f.universe = f.universe_proof = true; + ok = ov_wire(&f, NULL, 0) && ov_success(&f, CBM_COVERAGE_ORIGIN_PATH_MISSING, + false, NULL, 0) && ok; + cbm_coverage_origin_join_t *join = NULL; + cbm_coverage_origin_status_t status = cbm_coverage_origin_join( + &f.input, &f.limits, NULL, NULL, &join); + size_t n = 0; + const cbm_coverage_origin_path_t *p = cbm_coverage_origin_join_paths(join, &n); + ok = status == CBM_COVERAGE_ORIGIN_OK && p && n == 1 && + p[0].reasons == CBM_COVERAGE_ORIGIN_PATH_MISSING && + p[0].disposition == CBM_COVERAGE_ORIGIN_UNKNOWN && p[0].supported_edits == 0 && ok; + cbm_coverage_origin_join_free(join); + ov_row_t rows[] = {base, ov_row("z-unqueried", 0, 0, 0, id, 1)}; + rows[1].proof = false; + ok = ov_wire(&f, rows, 2) && ov_success(&f, 0, false, wanted, 1) && ok; + rows[0].state = 0; + rows[0].disposition = CBM_COVERAGE_ORIGIN_UNKNOWN; + rows[0].edits = 0; + rows[0].proof = false; + f.universe = false; + unsigned combined = CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN | + CBM_COVERAGE_ORIGIN_PRESENCE_UNKNOWN | CBM_COVERAGE_ORIGIN_CLAIM_UNKNOWN | + CBM_COVERAGE_ORIGIN_EDIT_UNSUPPORTED | CBM_COVERAGE_ORIGIN_REQUEST_UNKNOWN; + ok = ov_names(&f, names, sizeof(names) - 1, "", 0, true) && ov_wire(&f, rows, 2) && + ov_success(&f, combined, false, wanted, 1) && ok; + ok = ov_names(&f, "", 0, "", 0, false) && ov_wire(&f, rows, 2) && + ov_success(&f, CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN, false, NULL, 0) && ok; + } + ov_close(&f); + ASSERT_TRUE(control); + ASSERT_TRUE(ok); + PASS(); +} + +static bool ov_records(ov_fixture_t *f, const ov_row_t *rows, size_t count, + const unsigned char **out, size_t *length) { + size_t n = 0; + for (size_t i = 0; i < count; i++) + n += rows[i].path_length + 3; + unsigned char *bytes = cbm_arena_alloc(&f->arena, n + 1); + if (!bytes) + return false; + size_t at = 0; + for (size_t i = count; i > 0; i--) { + const ov_row_t *r = &rows[i - 1]; + bytes[at++] = 'M'; bytes[at++] = 0; + memcpy(bytes + at, r->path, r->path_length); + at += r->path_length; + bytes[at++] = 0; + } + bytes[at] = 0; + *out = bytes; + *length = at; + return true; +} + +TEST(origin_v2_strict_wire_and_raw_streams) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const uint32_t id[] = {1}; + static const int wanted[] = {1}; + static const unsigned char low[] = {'x', '/', 0x7f}; + static const unsigned char nonutf[] = {'x', '/', 0x80, 0xff}; + ov_row_t raw[] = {ov_row(":-*.c", 2, 1, 15, id, 1), + ov_row("back\\slash.c", 2, 1, 15, id, 1), + ov_row("tab\tline\n.c", 2, 1, 15, id, 1), + ov_row("unused-low", 2, 1, 15, id, 1), + ov_row("unused-high", 2, 1, 15, id, 1)}; + raw[3].path = low; raw[3].path_length = sizeof(low); + raw[4].path = nonutf; raw[4].path_length = sizeof(nonutf); + const size_t raw_count = sizeof(raw) / sizeof(raw[0]); + const unsigned char *records = NULL; + size_t record_bytes = 0; + bool control = ready && ov_records(&f, raw, raw_count, &records, &record_bytes) && + ov_names(&f, records, record_bytes, records, record_bytes, false) && + ov_wire(&f, raw, raw_count); + bool ok = control && ov_success(&f, 0, false, wanted, 1); + if (control) { + cbm_coverage_origin_join_t *join = NULL; + cbm_coverage_origin_status_t status = cbm_coverage_origin_join( + &f.input, &f.limits, NULL, NULL, &join); + size_t count = 0; + const cbm_coverage_origin_path_t *paths = cbm_coverage_origin_join_paths(join, &count); + bool paths_ok = status == CBM_COVERAGE_ORIGIN_OK && paths && count == raw_count; + for (size_t i = 0; paths_ok && i < raw_count; i++) + paths_ok = ov_path_equal(&paths[i], raw[i].path, raw[i].path_length); + ok = paths_ok && ok; + cbm_coverage_origin_join_free(join); + static const unsigned char name[] = "M\0f\0"; + static const uint32_t two_ids[] = {1, 3}; + ov_row_t row = ov_row("f", 2, 1, 15, two_ids, 2); + ok = ov_names(&f, name, sizeof(name) - 1, name, sizeof(name) - 1, false) && + ov_wire(&f, &row, 1) && ov_success(&f, 0, false, (const int[]){1, 3}, 2) && ok; + size_t full_length = f.length; + for (size_t cut = 1; cut < full_length; cut++) { + f.length = cut; + ov_rehash(&f); + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + } + f.length = full_length; + ov_rehash(&f); + size_t positions[] = {0, sizeof("cbm-coverage-origins") + 3, + sizeof("cbm-coverage-origins") + 8 + + f.context.binding.repository_key.length, + f.global_flag, f.global_proof, f.rows[0].state, + f.rows[0].mode + 3, f.rows[0].disposition, + f.rows[0].edits, f.rows[0].proof}; + unsigned char values[] = {'x', 1, 3, 2, 2, 4, 0, 3, 16, 2}; + for (size_t i = 0; i < sizeof(positions) / sizeof(positions[0]); i++) { + unsigned char saved = f.wire[positions[i]]; + f.wire[positions[i]] = values[i]; + ov_rehash(&f); + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + f.wire[positions[i]] = saved; + } + f.wire[f.length++] = 0; + ov_rehash(&f); + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + f.length--; + ov_rehash(&f); + static const uint32_t duplicate[] = {1, 1}, unsorted[] = {3, 1}, outside[] = {4}; + const uint32_t *bad_ids[] = {duplicate, unsorted, outside}; + for (size_t i = 0; i < 3; i++) { + row.ids = bad_ids[i]; row.count = i == 2 ? 1 : 2; + ok = ov_wire(&f, &row, 1) && ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + } + row.ids = id; row.count = 1; + ov_row_t duplicate_rows[] = {row, row}; + ok = ov_wire(&f, duplicate_rows, 2) && ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + ov_row_t bad_order[] = {ov_row("z", 2, 1, 15, id, 1), row}; + ok = ov_wire(&f, bad_order, 2) && ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + ov_row_t hidden[] = {row, ov_row("z", 0, 0, 0, outside, 1)}; + ok = ov_wire(&f, hidden, 2) && ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + const char *unsafe_paths[] = {"", "/", "/f", "f/", "a//f", "./f", "a/../f", "a/./f"}; + for (size_t i = 0; i < sizeof(unsafe_paths) / sizeof(unsafe_paths[0]); i++) { + ov_row_t bad_path = ov_row(unsafe_paths[i], 2, 1, 15, id, 1); + ok = ov_wire(&f, &bad_path, 1) && ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + } + static const unsigned char embedded_nul[] = {'f', 0, 'x'}; + ov_row_t bad_path = row; + bad_path.path = embedded_nul; bad_path.path_length = sizeof(embedded_nul); + ok = ov_wire(&f, &bad_path, 1) && ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + static const unsigned char bad0[] = "M\0f", bad1[] = "R100\0f\0", + bad2[] = "MM\0f\0", bad3[] = "M\0\0", bad4[] = "M\0a/../f\0", + bad5[] = "M\0a//f\0", bad6[] = "M\0/f\0", bad7[] = "M\0f/\0", + bad8[] = "M\0f\0M\0f\0", bad9[] = "M\0./f\0", bad10[] = "X\0f\0"; + struct { const unsigned char *bytes; size_t length; } bad[] = { + {bad0, sizeof(bad0) - 1}, {bad1, sizeof(bad1) - 1}, {bad2, sizeof(bad2) - 1}, + {bad3, sizeof(bad3) - 1}, {bad4, sizeof(bad4) - 1}, {bad5, sizeof(bad5) - 1}, + {bad6, sizeof(bad6) - 1}, {bad7, sizeof(bad7) - 1}, {bad8, sizeof(bad8) - 1}, + {bad9, sizeof(bad9) - 1}, {bad10, sizeof(bad10) - 1}}; + ok = ov_wire(&f, &row, 1) && ov_success(&f, 0, false, wanted, 1) && ok; + cbm_coverage_origin_bytes_t saved = f.input.artifact_to_head; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + f.input.artifact_to_head = (cbm_coverage_origin_bytes_t){bad[i].bytes, bad[i].length}; + ov_hash(bad[i].bytes, bad[i].length, f.context.artifact_to_head_sha256); + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + } + f.input.artifact_to_head = saved; + ov_hash(saved.data, saved.length, f.context.artifact_to_head_sha256); + cbm_coverage_origin_bytes_t saved_mh = f.input.merge_base_to_head; + f.input.merge_base_to_head = (cbm_coverage_origin_bytes_t){bad8, sizeof(bad8) - 1}; + ov_hash(bad8, sizeof(bad8) - 1, f.context.merge_base_to_head_sha256); + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_FORMAT) && ok; + f.input.merge_base_to_head = saved_mh; + ov_hash(saved_mh.data, saved_mh.length, f.context.merge_base_to_head_sha256); + ok = ov_success(&f, 0, false, wanted, 1) && ok; + } + ov_close(&f); + ASSERT_TRUE(control); + ASSERT_TRUE(ok); + PASS(); +} + +TEST(origin_v2_admission_and_tuple_binding) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const unsigned char names[] = "M\0f\0"; + static const uint32_t id[] = {1}; + static const int wanted[] = {1}; + ov_row_t row = ov_row("f", 2, 1, 15, id, 1); + bool control = ready && ov_names(&f, names, sizeof(names) - 1, names, sizeof(names) - 1, false) && + ov_wire(&f, &row, 1); + bool ok = control && ov_success(&f, 0, false, wanted, 1); + if (control) { + bool *flags[] = {&f.context.artifact_admitted, &f.context.origin_source_verified, + &f.context.origin_attestations_verified, &f.context.comparisons_verified}; + for (size_t i = 0; i < 4; i++) { + *flags[i] = false; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_UNVERIFIED) && ok; + *flags[i] = true; + } + unsigned char *digests[] = {f.context.binding.functions_sha256, + f.context.binding.manifest_sha256, f.context.binding.compatibility_sha256, + f.context.binding.producer_profile_sha256, f.context.artifact_to_head_sha256, + f.context.merge_base_to_head_sha256, f.context.binding.artifact.bytes}; + for (size_t i = 0; i < sizeof(digests) / sizeof(digests[0]); i++) { + digests[i][0] ^= 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + digests[i][0] ^= 1; + } + cbm_coverage_origin_bytes_t key = f.context.binding.repository_key; + f.context.binding.repository_key = + (cbm_coverage_origin_bytes_t){(const unsigned char *)"other-repo", 10}; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + f.context.binding.repository_key = key; + f.context.binding.object_format = CBM_COVERAGE_ORIGIN_GIT_SHA256; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + f.context.binding.object_format = CBM_COVERAGE_ORIGIN_GIT_SHA1; + f.context.binding.artifact.bytes[31] = 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_INVALID) && ok; + f.context.binding.artifact.bytes[31] = 0; + f.context.binding.manifest_version = 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_INVALID) && ok; + f.context.binding.manifest_version = 2; + int count = f.function_count; + f.function_count = 3; /* independently wrong header count; selected ID remains valid */ + ok = ov_wire(&f, &row, 1) && ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && ok; + f.function_count = count; + ok = ov_wire(&f, &row, 1) && ov_success(&f, 0, false, wanted, 1) && ok; + cbm_coverage_origin_join_t *join = NULL; + cbm_coverage_origin_status_t status = cbm_coverage_origin_join( + &f.input, &f.limits, NULL, NULL, &join); + const cbm_coverage_origin_binding_t *bound = cbm_coverage_origin_join_binding(join); + ok = status == CBM_COVERAGE_ORIGIN_OK && bound && bound->repository_key.length == 9 && + memcmp(bound->repository_key.data, "test-repo", 9) == 0 && + memcmp(bound->artifact.bytes, f.context.binding.artifact.bytes, 32) == 0 && + memcmp(bound->merge_base.bytes, f.context.binding.merge_base.bytes, 32) == 0 && + memcmp(bound->head.bytes, f.context.binding.head.bytes, 32) == 0 && + memcmp(bound->functions_sha256, f.context.binding.functions_sha256, 32) == 0 && + memcmp(bound->manifest_sha256, f.context.binding.manifest_sha256, 32) == 0 && + memcmp(bound->compatibility_sha256, f.context.binding.compatibility_sha256, 32) == 0 && + memcmp(bound->producer_profile_sha256, f.context.binding.producer_profile_sha256, 32) == 0 && + bound->manifest_version == 2 && bound->object_format == 1 && ok; + /* A nonempty caller output slot is cleared before even NULL-input + * validation, while its previously returned owner remains caller-owned. */ + cbm_coverage_origin_join_t *previous = join; + if (previous) { + status = cbm_coverage_origin_join(NULL, &f.limits, NULL, NULL, &join); + ok = status == CBM_COVERAGE_ORIGIN_INVALID && join == NULL && + cbm_coverage_origin_join_can_narrow(previous) && ok; + if (join != previous) + cbm_coverage_origin_join_free(join); + } + cbm_coverage_origin_join_free(previous); + const cbm_coverage_map_t *saved_map = f.input.coverage; + const cbm_changes_t *saved_changes = f.input.request_changes; + const cbm_coverage_origin_context_t *saved_context = f.input.context; + f.input.coverage = NULL; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_INVALID) && ok; + f.input.coverage = saved_map; + f.input.request_changes = NULL; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_INVALID) && ok; + f.input.request_changes = saved_changes; + f.input.context = NULL; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_INVALID) && ok; + f.input.context = saved_context; + static const unsigned char opaque_key[] = {'r', 0, 0xff}; + f.context.binding.repository_key = + (cbm_coverage_origin_bytes_t){opaque_key, sizeof(opaque_key)}; + ok = ov_wire(&f, &row, 1) && ov_success(&f, 0, false, wanted, 1) && ok; + join = NULL; + status = cbm_coverage_origin_join(&f.input, &f.limits, NULL, NULL, &join); + bound = cbm_coverage_origin_join_binding(join); + ok = status == CBM_COVERAGE_ORIGIN_OK && bound && + bound->repository_key.length == sizeof(opaque_key) && + memcmp(bound->repository_key.data, opaque_key, sizeof(opaque_key)) == 0 && ok; + cbm_coverage_origin_join_free(join); + } + ov_close(&f); + ASSERT_TRUE(control); + ASSERT_TRUE(ok); + PASS(); +} + +TEST(origin_v2_resource_limits) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const unsigned char names[] = "M\0f\0"; + static const uint32_t id[] = {1}, all_ids[] = {0, 1, 2, 3}; + static const int wanted[] = {1}; + ov_row_t rows[] = {ov_row("f", 2, 1, 15, id, 1), ov_row("z", 0, 0, 0, all_ids, 4)}; + bool control = ready && ov_names(&f, names, sizeof(names) - 1, names, sizeof(names) - 1, false) && + ov_wire(&f, rows, 2); + bool ok = control && ov_success(&f, 0, false, wanted, 1); + if (control) { + cbm_coverage_origin_limits_t ample = f.limits; + uint64_t exact = f.input.manifest.length + f.input.artifact_to_head.length + + f.input.merge_base_to_head.length + f.context.binding.repository_key.length; + f.limits.max_input_bytes = exact; + f.limits.max_result_ids = 1; /* Unqueried IDs must not consume union cardinality. */ + ok = ov_success(&f, 0, false, wanted, 1) && ok; + f.limits.max_input_bytes = exact - 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_LIMIT) && ok; + f.limits = ample; + f.limits.max_items = 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_LIMIT) && ok; + f.limits = ample; + f.limits.max_alloc_bytes = 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_LIMIT) && ok; + for (int i = 0; i < 6; i++) { + f.limits = ample; + if (i == 0) f.limits.max_input_bytes = 0; + if (i == 1) f.limits.max_items = 0; + if (i == 2) f.limits.max_alloc_bytes = 0; + if (i == 3) f.limits.max_result_ids = 0; + if (i == 4) f.limits.max_input_bytes = UINT64_MAX / 8 + 1; + if (i == 5) f.limits.max_result_ids = (size_t)INT_MAX + 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_INVALID) && ok; + } + f.limits = ample; + rows[0].disposition = CBM_COVERAGE_ORIGIN_ALL; + ok = ov_wire(&f, rows, 2) && + ov_success(&f, 0, true, (const int[]){0, 1, 2, 3}, 4) && ok; + f.limits.max_result_ids = 3; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_LIMIT) && ok; + f.limits = ample; + ok = ov_success(&f, 0, true, (const int[]){0, 1, 2, 3}, 4) && ok; + /* The cap applies to the distinct global union, not each row alone. */ + static const unsigned char pair_names[] = "M\0f\0M\0g\0"; + static const uint32_t other_id[] = {2}; + ov_row_t singletons[] = {ov_row("f", 2, 1, 15, id, 1), + ov_row("g", 2, 1, 15, other_id, 1)}; + bool singleton_control = ov_names(&f, pair_names, sizeof(pair_names) - 1, + pair_names, sizeof(pair_names) - 1, false) && ov_wire(&f, singletons, 2); + control = singleton_control && control; + if (singleton_control) { + f.limits.max_result_ids = 2; + ok = ov_success(&f, 0, false, (const int[]){1, 2}, 2) && ok; + f.limits.max_result_ids = 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_LIMIT) && ok; + f.limits.max_result_ids = 2; + ok = ov_success(&f, 0, false, (const int[]){1, 2}, 2) && ok; + singletons[1].ids = id; + f.limits.max_result_ids = 1; + ok = ov_wire(&f, singletons, 2) && ov_success(&f, 0, false, wanted, 1) && ok; + } + } + ov_close(&f); + /* A tiny manifest/empty query still has to charge validation of a borrowed + * large map. Build a real valid map; never pass a fabricated huge span. */ + CBMArena generation; + cbm_arena_init_lazy(&generation, 4096); + const int rows_count = 4097; + size_t capacity = (size_t)rows_count * 48; + char *functions = cbm_arena_alloc(&generation, capacity); + size_t used = 0; + bool generated = functions != NULL; + for (int i = 0; generated && i < rows_count; i++) { + int n = snprintf(functions + used, capacity - used, "%d\tf%d.c\tfn%d\n", i, i, i); + generated = n > 0 && (size_t)n < capacity - used; + if (generated) + used += (size_t)n; + } + /* The existing map contract requires every table ID to be observed and + * every suite to have setup. This is synthetic fixture data, not admission. */ + size_t observation_capacity = (size_t)rows_count * 12 + 128; + char *observations = cbm_arena_alloc(&generation, observation_capacity); + size_t observation_used = 0; + generated = observations && generated; + if (generated) { + int n = snprintf(observations, observation_capacity, "core:observed\tcomplete\t\t"); + generated = n > 0 && (size_t)n < observation_capacity; + if (generated) + observation_used = (size_t)n; + } + for (int i = 0; generated && i < rows_count; i++) { + int n = snprintf(observations + observation_used, + observation_capacity - observation_used, "%s%d", i ? " " : "", i); + generated = n > 0 && (size_t)n < observation_capacity - observation_used; + if (generated) + observation_used += (size_t)n; + } + if (generated) { + int n = snprintf(observations + observation_used, + observation_capacity - observation_used, + "\ncore:empty\tcomplete\t\t\ncore:*\tcomplete\t\t\n"); + generated = n > 0 && (size_t)n < observation_capacity - observation_used; + if (generated) + observation_used += (size_t)n; + } + ov_fixture_t large; + memset(&large, 0, sizeof(large)); + bool large_control = generated && ov_open(&large, functions, used, + observations, observation_used) && + large.function_count == rows_count && ov_wire(&large, NULL, 0); + if (large_control) { + const cbm_coverage_test_t *observed = cbm_coverage_map_find_test(large.map, "core", "observed"); + const cbm_coverage_test_t *setup = cbm_coverage_map_find_test(large.map, "core", "*"); + large_control = observed && observed->complete && observed->function_ids && + observed->function_count == rows_count && setup && + setup->complete && setup->function_count == 0; + for (int i = 0; large_control && i < rows_count; i++) + large_control = observed->function_ids[i] == i; + } + cbm_arena_destroy(&generation); + bool large_ok = large_control && ov_success(&large, 0, false, NULL, 0); + if (large_control) { + large.limits.max_items = (uint64_t)rows_count - 1; + large_ok = ov_error(&large, CBM_COVERAGE_ORIGIN_LIMIT) && large_ok; + } + ov_close(&large); + ASSERT_TRUE(control); + ASSERT_TRUE(large_control); + ASSERT_TRUE(ok); + ASSERT_TRUE(large_ok); + PASS(); +} + +typedef struct { size_t calls, cancel_at; } ov_cancel_t; +static bool ov_cancel(void *opaque) { + ov_cancel_t *c = opaque; + c->calls++; + return c->cancel_at && c->calls >= c->cancel_at; +} + +typedef struct { + cbm_mutex_t *start; + const cbm_coverage_origin_input_t *input; + const cbm_coverage_origin_limits_t *limits; + ov_cancel_t cancellation; + cbm_coverage_origin_status_t status; + cbm_coverage_origin_join_t *result; +} ov_call_t; + +static void *ov_call_thread(void *opaque) { + ov_call_t *call = opaque; + cbm_mutex_lock(call->start); + cbm_mutex_unlock(call->start); + call->status = cbm_coverage_origin_join(call->input, call->limits, ov_cancel, + &call->cancellation, &call->result); + return NULL; +} + +/* The held start gate permits concurrent scheduling without assuming internal + * overlap. No callback waits, scheduler ordering, sleeps or timing assertions. */ +static bool ov_independent_calls(ov_fixture_t *f) { + cbm_mutex_t start; + cbm_mutex_init(&start); + cbm_mutex_lock(&start); + ov_call_t healthy = {.start = &start, .input = &f->input, .limits = &f->limits}; + ov_call_t cancelled = {.start = &start, .input = &f->input, .limits = &f->limits, + .cancellation = {.cancel_at = 1}}; + cbm_thread_t threads[2]; + bool first = cbm_thread_create(&threads[0], 0, ov_call_thread, &healthy) == 0; + bool second = cbm_thread_create(&threads[1], 0, ov_call_thread, &cancelled) == 0; + cbm_mutex_unlock(&start); + bool first_joined = !first || cbm_thread_join(&threads[0]) == 0; + bool second_joined = !second || cbm_thread_join(&threads[1]) == 0; + cbm_mutex_destroy(&start); + int count = 0; + const int *ids = cbm_coverage_origin_join_ids(healthy.result, &count); + bool ok = first && second && first_joined && second_joined && + healthy.status == CBM_COVERAGE_ORIGIN_OK && healthy.result && + cbm_coverage_origin_join_can_narrow(healthy.result) && count == 2 && + ov_ids_equal(ids, (size_t)count, (const int[]){1, 3}, 2) && + cancelled.status == CBM_COVERAGE_ORIGIN_CANCELLED && + cancelled.result == NULL && cancelled.cancellation.calls > 0; + cbm_coverage_origin_join_free(healthy.result); + cbm_coverage_origin_join_free(cancelled.result); + return ok; +} + +TEST(origin_v2_cancellation_ownership_and_null_views) { + ov_fixture_t f; + bool ready = ov_default(&f); + size_t path_length = 70001; + unsigned char *path = ready ? cbm_arena_alloc(&f.arena, path_length + 1) : NULL; + if (path) { + memset(path, 'x', path_length); + path[path_length - 1] = 'A'; path[path_length] = 0; + } + static const uint32_t listed[] = {1, 3}; + ov_row_t row = ov_row("unused", 2, 1, 15, listed, 2); + row.path = path; row.path_length = path_length; + unsigned char *second = ready ? cbm_arena_alloc(&f.arena, path_length + 1) : NULL; + if (second && path) { + memcpy(second, path, path_length + 1); + second[path_length - 1] = 'B'; + } + ov_row_t rows[] = {row, row}; + rows[1].path = second; + const unsigned char *names = NULL; + size_t names_length = 0; + bool control = ready && path && second && ov_records(&f, rows, 2, &names, &names_length) && + ov_names(&f, names, names_length, names, names_length, false) && + ov_wire(&f, rows, 2); + ov_cancel_t observed = {0}; + cbm_coverage_origin_join_t *join = NULL; + cbm_coverage_origin_status_t status = control ? cbm_coverage_origin_join( + &f.input, &f.limits, ov_cancel, &observed, &join) : CBM_COVERAGE_ORIGIN_INVALID; + bool ok = status == CBM_COVERAGE_ORIGIN_OK && join && observed.calls > 0; + cbm_coverage_origin_join_free(join); + join = NULL; + if (control && observed.calls) { + size_t positions[] = {1, observed.calls / 2 + 1, observed.calls}; + for (size_t i = 0; i < 3; i++) { + ov_cancel_t cancellation = {.cancel_at = positions[i]}; + status = cbm_coverage_origin_join(&f.input, &f.limits, ov_cancel, &cancellation, &join); + ok = status == CBM_COVERAGE_ORIGIN_CANCELLED && join == NULL && + cancellation.calls >= positions[i] && ok; + cbm_coverage_origin_join_free(join); + join = NULL; + } + ok = ov_independent_calls(&f) && ok; + /* A separate call on the same immutable borrowed owners remains usable. */ + ok = ov_success(&f, 0, false, (const int[]){1, 3}, 2) && ok; + status = cbm_coverage_origin_join(&f.input, &f.limits, NULL, NULL, &join); + ok = status == CBM_COVERAGE_ORIGIN_OK && join && ok; + } + if (control) { + memset(f.wire, 0xa5, f.length); + memset((void *)f.input.artifact_to_head.data, 0xa5, f.input.artifact_to_head.length); + memset((void *)f.input.merge_base_to_head.data, 0xa5, f.input.merge_base_to_head.length); + memset((void *)f.context.binding.repository_key.data, 0xa5, 9); + memset(path, 0xa5, path_length); + memset(second, 0xa5, path_length); + } + ov_close(&f); + if (join) { + size_t count = 0; + const cbm_coverage_origin_path_t *paths = cbm_coverage_origin_join_paths(join, &count); + const cbm_coverage_origin_binding_t *bound = cbm_coverage_origin_join_binding(join); + int surviving_count = -1; + const int *surviving_ids = cbm_coverage_origin_join_ids(join, &surviving_count); + bool owned = surviving_count == 2 && + ov_ids_equal(surviving_ids, (size_t)surviving_count, + (const int[]){1, 3}, 2) && count == 2 && paths && paths[0].path.length == path_length && + paths[0].path.data && paths[0].path.data[path_length] == 0 && + paths[0].path.data[path_length - 1] == 'A' && + paths[1].path.length == path_length && paths[1].path.data && + paths[1].path.data[path_length] == 0 && + paths[1].path.data[path_length - 1] == 'B' && bound && + bound->repository_key.length == 9 && + memcmp(bound->repository_key.data, "test-repo", 9) == 0 && + ov_ids_equal(paths[0].function_ids, paths[0].function_count, + (const int[]){1, 3}, 2) && + ov_ids_equal(paths[1].function_ids, paths[1].function_count, + (const int[]){1, 3}, 2); + for (size_t i = 0; owned && i + 1 < path_length; i++) + owned = paths[0].path.data[i] == 'x' && paths[1].path.data[i] == 'x'; + ok = owned && cbm_coverage_origin_join_can_narrow(join) && ok; + } + cbm_coverage_origin_join_free(join); + int ids_count = -1; + size_t paths_count = SIZE_MAX; + bool null_contract = cbm_coverage_origin_join_binding(NULL) == NULL && + cbm_coverage_origin_join_ids(NULL, &ids_count) == NULL && ids_count == 0 && + cbm_coverage_origin_join_paths(NULL, &paths_count) == NULL && paths_count == 0 && + !cbm_coverage_origin_join_complete(NULL) && + !cbm_coverage_origin_join_broad_fallback_required(NULL) && + !cbm_coverage_origin_join_can_narrow(NULL) && + cbm_coverage_origin_join_reasons(NULL) == CBM_COVERAGE_ORIGIN_REQUEST_UNKNOWN && + cbm_coverage_origin_join_request_state(NULL) == CBM_CHANGES_UNKNOWN && + cbm_coverage_map_functions_sha256(NULL) == NULL; + cbm_coverage_origin_join_free(NULL); + ASSERT_TRUE(control); + ASSERT_TRUE(ok); + ASSERT_TRUE(null_contract); + PASS(); +} + +/* Generic identity-table compatibility. These synthetic context flags are + * unit fixtures, not evidence of artifact admission or producer soundness. */ +#define OVP_IMAGE "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +static const char ovp_profiles[] = + "CBM_PROFILE_MAP\t2\t" OVP_IMAGE "\t2\n" + "0\t61\t0000000000000000\t1\n" + "1\t620063\t8000000000000000\t2\n"; +static const char ovp_profiles_sha[] = + "10de0263d12f9388a74967ae61a6fae824f7017e3563527e2a5e56efc9fdc258"; + +static bool ovp_legacy_control(void) { + ov_fixture_t f; + bool ready = ov_default(&f); + static const unsigned char names[] = "M\0f\0"; + static const uint32_t id[] = {1}; + ov_row_t row = ov_row("f", 2, CBM_COVERAGE_ORIGIN_COMPLETE_IDS, + CBM_COVERAGE_ORIGIN_EDIT_M, id, 1); + const char *digest = ready ? cbm_coverage_map_identity_sha256(f.map) : NULL; + bool ok = ready && cbm_coverage_map_format(f.map) == CBM_COVERAGE_FORMAT_FUNCTIONS && + cbm_coverage_map_id_count(f.map) == 4 && digest && + strcmp(digest, ov_lf_sha) == 0 && + ov_names(&f, names, sizeof(names) - 1, names, sizeof(names) - 1, false) && + ov_wire(&f, &row, 1) && ov_success(&f, 0, false, (const int[]){1}, 1); + ov_close(&f); + return ok; +} + +static bool ovp_empty_observations(const cbm_coverage_map_t *map) { + const cbm_coverage_test_t *empty = cbm_coverage_map_find_test(map, "core", "empty"); + const cbm_coverage_test_t *setup = cbm_coverage_map_find_test(map, "core", "*"); + return empty && empty->complete && empty->function_count == 0 && + setup && setup->complete && setup->function_count == 0 && + !cbm_coverage_test_intersects(empty, (const int[]){0, 1}, 2) && + !cbm_coverage_test_intersects(setup, (const int[]){0, 1}, 2); +} + +static bool ovp_parse(const void *bytes, size_t length, cbm_coverage_map_t **out) { + const cbm_coverage_parse_limits_t limits = { + .max_input_bytes = 8U * 1024U * 1024U, .max_items = 100000, + .max_alloc_bytes = 32U * 1024U * 1024U, .max_ids = 8192}; + cbm_coverage_parse_status_t status = cbm_coverage_map_parse_v2( + bytes, length, ov_empty_tests, sizeof(ov_empty_tests) - 1, + &limits, NULL, NULL, out); + return status == CBM_COVERAGE_PARSE_OK && *out; +} + +static bool ovp_map_control(const cbm_coverage_map_t *map, const void *bytes, + size_t length, int count, const char *image) { + int legacy_count = -1, profile_count = -1; + const cbm_coverage_function_t *legacy = cbm_coverage_map_functions(map, &legacy_count); + const cbm_coverage_profile_t *profiles = cbm_coverage_map_profiles(map, &profile_count); + const cbm_coverage_profile_binding_t *binding = cbm_coverage_map_profile_binding(map); + const char *digest = cbm_coverage_map_identity_sha256(map); + char expected[65]; + cbm_sha256_hex(bytes, length, expected); + return map && cbm_coverage_map_format(map) == CBM_COVERAGE_FORMAT_PROFILES && + cbm_coverage_map_id_count(map) == count && profile_count == count && + (count ? profiles != NULL : profiles == NULL) && binding && + strcmp(binding->image_sha256, image) == 0 && digest && + strcmp(digest, expected) == 0 && !legacy && legacy_count == 0 && + cbm_coverage_map_functions_sha256(map) == NULL && ovp_empty_observations(map); +} + +/* Retain the unchanged native helper, replacing its map only after a genuine + * format-2 parse succeeds. No fake map, projection or observation is created. */ +static bool ovp_open(ov_fixture_t *f, const void *bytes, size_t length, int count) { + if (!ov_default(f)) + return false; + cbm_coverage_map_t *map = NULL; + bool ready = ovp_parse(bytes, length, &map) && + ovp_map_control(map, bytes, length, count, OVP_IMAGE); + if (!ready) { + cbm_coverage_map_free(map); + return false; + } + cbm_coverage_map_free(f->map); + f->map = map; + f->input.coverage = map; + f->function_count = count; + ov_hash(bytes, length, f->context.binding.functions_sha256); + return true; +} + +static bool ovp_two_profiles(const cbm_coverage_map_t *map, unsigned variant) { + int count = -1; + const cbm_coverage_profile_t *rows = cbm_coverage_map_profiles(map, &count); + const unsigned char a[] = {variant == 1 ? 'A' : 'a'}; + const unsigned char b[] = {'b', variant == 2 ? 0xff : 0, 'c'}; + return rows && count == 2 && rows[0].id == 0 && rows[1].id == 1 && + rows[0].name_length == 1 && rows[0].name && memcmp(rows[0].name, a, 1) == 0 && + rows[1].name_length == 3 && rows[1].name && memcmp(rows[1].name, b, 3) == 0 && + rows[0].function_hash == 0 && rows[0].counter_count == 1 && + rows[1].function_hash == (variant == 3 ? 0 : UINT64_C(0x8000000000000000)) && + rows[1].counter_count == (variant == 4 ? 3U : 2U); +} + +static bool ovp_one_row(ov_fixture_t *f, unsigned disposition, + const uint32_t *ids, size_t count) { + static const unsigned char names[] = "M\0f\0"; + ov_row_t row = ov_row("f", 2, disposition, CBM_COVERAGE_ORIGIN_EDIT_M, ids, count); + return ov_names(f, names, sizeof(names) - 1, names, sizeof(names) - 1, false) && + ov_wire(f, &row, 1); +} + +static bool ovp_binding_equal(const cbm_coverage_origin_binding_t *a, + const cbm_coverage_origin_binding_t *b) { + return a && b && a->repository_key.length == b->repository_key.length && + a->repository_key.data && b->repository_key.data && + memcmp(a->repository_key.data, b->repository_key.data, a->repository_key.length) == 0 && + a->object_format == b->object_format && a->manifest_version == b->manifest_version && + memcmp(a->artifact.bytes, b->artifact.bytes, 32) == 0 && + memcmp(a->merge_base.bytes, b->merge_base.bytes, 32) == 0 && + memcmp(a->head.bytes, b->head.bytes, 32) == 0 && + memcmp(a->functions_sha256, b->functions_sha256, 32) == 0 && + memcmp(a->manifest_sha256, b->manifest_sha256, 32) == 0 && + memcmp(a->compatibility_sha256, b->compatibility_sha256, 32) == 0 && + memcmp(a->producer_profile_sha256, b->producer_profile_sha256, 32) == 0; +} + +static bool ovp_one_result(const ov_fixture_t *f, const cbm_coverage_origin_join_t *join, + unsigned disposition, bool broad, const int *ids, size_t count) { + int id_count = -1; + size_t path_count = 0; + const int *actual = cbm_coverage_origin_join_ids(join, &id_count); + const cbm_coverage_origin_path_t *paths = cbm_coverage_origin_join_paths(join, &path_count); + const cbm_coverage_origin_binding_t *binding = cbm_coverage_origin_join_binding(join); + bool common = join && cbm_coverage_origin_join_complete(join) && + cbm_coverage_origin_join_reasons(join) == 0 && + cbm_coverage_origin_join_broad_fallback_required(join) == broad && + cbm_coverage_origin_join_can_narrow(join) == !broad && + cbm_coverage_origin_join_request_state(join) == CBM_CHANGES_NONEMPTY && + id_count >= 0 && ov_ids_equal(actual, (size_t)id_count, ids, count) && + ovp_binding_equal(binding, &f->context.binding); + return common && paths && path_count == 1 && ov_path_equal(paths, "f", 1) && + paths[0].comparisons == (CBM_COVERAGE_ORIGIN_FROM_ARTIFACT | + CBM_COVERAGE_ORIGIN_FROM_MERGE_BASE) && + paths[0].artifact_status == 'M' && paths[0].merge_base_status == 'M' && + paths[0].disposition == disposition && paths[0].supported_edits == CBM_COVERAGE_ORIGIN_EDIT_M && + paths[0].reasons == 0 && ov_ids_equal(paths[0].function_ids, paths[0].function_count, ids, count); +} + +TEST(origin_profiles_unobserved_identity) { + ASSERT_TRUE(ovp_legacy_control()); + ov_fixture_t f; + bool parser = ovp_open(&f, ovp_profiles, sizeof(ovp_profiles) - 1, 2) && + ovp_two_profiles(f.map, 5) && + strcmp(cbm_coverage_map_identity_sha256(f.map), ovp_profiles_sha) == 0; + bool fixture = parser && ovp_one_row(&f, CBM_COVERAGE_ORIGIN_COMPLETE_IDS, + (const uint32_t[]){1}, 1); + cbm_coverage_origin_join_t *join = NULL; + cbm_coverage_origin_status_t status = fixture ? cbm_coverage_origin_join( + &f.input, &f.limits, NULL, NULL, &join) : CBM_COVERAGE_ORIGIN_INVALID; + bool ok = status == CBM_COVERAGE_ORIGIN_OK && + ovp_one_result(&f, join, CBM_COVERAGE_ORIGIN_COMPLETE_IDS, false, + (const int[]){1}, 1) && ovp_empty_observations(f.map); + cbm_coverage_origin_join_free(join); + ov_close(&f); + ASSERT_TRUE(parser); + ASSERT_TRUE(fixture); + ASSERT_TRUE(ok); + PASS(); +} + +static bool ovp_missing_evidence(ov_fixture_t *f, ov_row_t row) { + bool ok = ov_wire(f, NULL, 0) && + ov_success(f, CBM_COVERAGE_ORIGIN_PATH_MISSING, false, NULL, 0); + row.proof = false; + ok = ov_wire(f, &row, 1) && + ov_success(f, CBM_COVERAGE_ORIGIN_CLAIM_UNPROVEN, false, NULL, 0) && ok; + row.proof = true; + row.disposition = CBM_COVERAGE_ORIGIN_UNKNOWN; + ok = ov_wire(f, &row, 1) && + ov_success(f, CBM_COVERAGE_ORIGIN_CLAIM_UNKNOWN, false, NULL, 0) && ok; + row.disposition = CBM_COVERAGE_ORIGIN_COMPLETE_IDS; + f->universe_proof = false; + ok = ov_wire(f, &row, 1) && ov_success(f, + CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN, false, NULL, 0) && ok; + f->universe_proof = true; + f->universe = false; + ok = ov_wire(f, &row, 1) && ov_success(f, + CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN, false, NULL, 0) && ok; + f->universe = true; + return ok; +} + +static bool ovp_floor_cases(ov_fixture_t *f, int count) { + static const unsigned char names[] = "M\0f\0"; + static const int all[] = {0, 1}; + ov_row_t row = ov_row("f", 2, CBM_COVERAGE_ORIGIN_COMPLETE_IDS, + CBM_COVERAGE_ORIGIN_EDIT_M, NULL, 0); + bool ok = ov_names(f, names, sizeof(names) - 1, names, sizeof(names) - 1, false) && + ov_wire(f, &row, 1) && ov_success(f, 0, false, NULL, 0) && + cbm_changes_state(f->changes) == CBM_CHANGES_NONEMPTY; + row.disposition = CBM_COVERAGE_ORIGIN_ALL; + ok = ov_wire(f, &row, 1) && ov_success(f, 0, true, all, (size_t)count) && + ovp_empty_observations(f->map) && ok; + f->universe_proof = false; + ok = ov_wire(f, &row, 1) && ov_success(f, + CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN, true, all, (size_t)count) && ok; + f->universe_proof = true; + row.disposition = CBM_COVERAGE_ORIGIN_COMPLETE_IDS; + ok = ovp_missing_evidence(f, row) && ok; + /* Nonempty AH does not change the independently established empty MH. */ + ok = ov_names(f, names, sizeof(names) - 1, "", 0, false) && + ov_wire(f, &row, 1) && ov_success(f, 0, false, NULL, 0) && + cbm_changes_state(f->changes) == CBM_CHANGES_EMPTY && ok; + row.disposition = CBM_COVERAGE_ORIGIN_ALL; + return ov_wire(f, &row, 1) && ov_success(f, 0, true, all, (size_t)count) && ok; +} + +TEST(origin_profiles_zero_and_broad_floors) { + ASSERT_TRUE(ovp_legacy_control()); + static const char zero_table[] = "CBM_PROFILE_MAP\t2\t" OVP_IMAGE "\t0\n"; + ov_fixture_t zero, nonzero; + bool zero_parser = ovp_open(&zero, zero_table, sizeof(zero_table) - 1, 0); + bool nonzero_parser = ovp_open(&nonzero, ovp_profiles, sizeof(ovp_profiles) - 1, 2) && + ovp_two_profiles(nonzero.map, 5); + bool zero_ok = zero_parser && ovp_floor_cases(&zero, 0); + bool nonzero_ok = nonzero_parser && ovp_floor_cases(&nonzero, 2); + ov_close(&zero); + ov_close(&nonzero); + ASSERT_TRUE(zero_parser); + ASSERT_TRUE(nonzero_parser); + ASSERT_TRUE(zero_ok); + ASSERT_TRUE(nonzero_ok); + PASS(); +} + +static bool ovp_mutate(char *table, unsigned variant, char image[65]) { + memcpy(table, ovp_profiles, sizeof(ovp_profiles)); + memcpy(image, OVP_IMAGE, 65); + char *first = strstr(table, "0\t61\t0000000000000000\t1\n"); + char *second = strstr(table, "1\t620063\t8000000000000000\t2\n"); + if (!first || !second || variant > 4) + return false; + if (variant == 0) { + table[sizeof("CBM_PROFILE_MAP\t2\t") - 1] = '1'; + image[0] = '1'; + } + if (variant == 1) + first[2] = '4'; /* raw a -> A; remains before raw b-NUL-c */ + if (variant == 2) + memcpy(second + 4, "ff", 2); /* raw b-NUL-c -> b-FF-c */ + if (variant == 3) + second[9] = '0'; /* change only bit 63 of the full hash */ + if (variant == 4) + second[26] = '3'; /* shape 2 -> 3, no counter values involved */ + return true; +} + +TEST(origin_profiles_exact_table_binding) { + ASSERT_TRUE(ovp_legacy_control()); + ov_fixture_t f; + bool parser = ovp_open(&f, ovp_profiles, sizeof(ovp_profiles) - 1, 2) && + ovp_two_profiles(f.map, 5); + bool fixture = parser && ovp_one_row(&f, CBM_COVERAGE_ORIGIN_COMPLETE_IDS, + (const uint32_t[]){1}, 1); + bool positive = fixture && ov_success(&f, 0, false, (const int[]){1}, 1); + bool alternatives = parser, rejected = positive; + for (unsigned variant = 0; fixture && variant < 5; variant++) { + char table[sizeof(ovp_profiles)], image[65]; + cbm_coverage_map_t *other = NULL; + bool valid = ovp_mutate(table, variant, image) && + ovp_parse(table, sizeof(table) - 1, &other) && + ovp_map_control(other, table, sizeof(table) - 1, 2, image) && + ovp_two_profiles(other, variant) && + strcmp(cbm_coverage_map_identity_sha256(other), ovp_profiles_sha) != 0; + alternatives = valid && alternatives; + /* Only the actual map changes: manifest and trusted digest stay fixed. */ + f.input.coverage = other; + rejected = valid && ov_error(&f, CBM_COVERAGE_ORIGIN_BINDING) && rejected; + f.input.coverage = f.map; + cbm_coverage_map_free(other); + } + bool recovery = fixture && ov_success(&f, 0, false, (const int[]){1}, 1); + ov_close(&f); + ASSERT_TRUE(parser); + ASSERT_TRUE(fixture); + ASSERT_TRUE(alternatives); + ASSERT_TRUE(positive); + ASSERT_TRUE(rejected); + ASSERT_TRUE(recovery); + PASS(); +} + +static bool ovp_generated_table(CBMArena *arena, int count, char **table, size_t *length) { + *table = NULL; + *length = 0; + if (count < 0 || count > 4097) + return false; + size_t capacity = 128 + (size_t)count * 64; + char *bytes = cbm_arena_alloc(arena, capacity); + if (!bytes) + return false; + int n = snprintf(bytes, capacity, "CBM_PROFILE_MAP\t2\t%s\t%d\n", OVP_IMAGE, count); + if (n <= 0 || (size_t)n >= capacity) + return false; + size_t used = (size_t)n; + static const char digits[] = "0123456789abcdef"; + for (int i = 0; i < count; i++) { + char raw[7], hex[13]; + if (snprintf(raw, sizeof(raw), "p%05d", i) != 6) + return false; + for (size_t j = 0; j < 6; j++) { + hex[j * 2] = digits[(unsigned char)raw[j] >> 4]; + hex[j * 2 + 1] = digits[(unsigned char)raw[j] & 15]; + } + hex[12] = 0; + n = snprintf(bytes + used, capacity - used, "%d\t%s\t%016llx\t%d\n", + i, hex, (unsigned long long)i, i + 1); + if (n <= 0 || (size_t)n >= capacity - used) + return false; + used += (size_t)n; + } + *table = bytes; + *length = used; + return true; +} + +static bool ovp_generated_profiles(const cbm_coverage_map_t *map, int wanted) { + int count = -1; + const cbm_coverage_profile_t *rows = cbm_coverage_map_profiles(map, &count); + if (count != wanted || (count && !rows)) + return false; + for (int i = 0; i < count; i++) { + char name[7]; + if (snprintf(name, sizeof(name), "p%05d", i) != 6 || rows[i].id != i || + rows[i].name_length != 6 || !rows[i].name || memcmp(rows[i].name, name, 6) != 0 || + rows[i].function_hash != (uint64_t)i || rows[i].counter_count != (uint64_t)i + 1) + return false; + } + return true; +} + +static bool ovp_empty_result(const ov_fixture_t *f, const cbm_coverage_origin_join_t *join) { + int count = -1; + size_t paths = SIZE_MAX; + (void)cbm_coverage_origin_join_ids(join, &count); + (void)cbm_coverage_origin_join_paths(join, &paths); + return join && count == 0 && paths == 0 && cbm_coverage_origin_join_complete(join) && + cbm_coverage_origin_join_reasons(join) == 0 && + !cbm_coverage_origin_join_broad_fallback_required(join) && + cbm_coverage_origin_join_can_narrow(join) && + cbm_coverage_origin_join_request_state(join) == CBM_CHANGES_EMPTY && + ovp_binding_equal(cbm_coverage_origin_join_binding(join), &f->context.binding); +} + +static bool ovp_item_probe(ov_fixture_t *f, uint64_t cap, bool *success) { + uint64_t saved = f->limits.max_items; + f->limits.max_items = cap; + cbm_coverage_origin_join_t *join = NULL; + cbm_coverage_origin_status_t status = cbm_coverage_origin_join( + &f->input, &f->limits, NULL, NULL, &join); + *success = status == CBM_COVERAGE_ORIGIN_OK; + bool ok = *success ? ovp_empty_result(f, join) : + status == CBM_COVERAGE_ORIGIN_LIMIT && join == NULL; + cbm_coverage_origin_join_free(join); + f->limits.max_items = saved; + return ok; +} + +/* At most 22 calls: one bounded upper control, <=20 bisections, exact control. + * No callback phase, allocation count or undocumented absolute tariff is used. */ +static bool ovp_min_items(ov_fixture_t *f, uint64_t *minimum) { + uint64_t low = 1, high = UINT64_C(1) << 20; + bool success = false; + if (!ovp_item_probe(f, high, &success) || !success) + return false; + unsigned iterations = 0; + while (low < high && iterations < 20) { + uint64_t middle = low + (high - low) / 2; + if (!ovp_item_probe(f, middle, &success)) + return false; + if (success) + high = middle; + else + low = middle + 1; + iterations++; + } + if (low != high || !ovp_item_probe(f, low, &success) || !success) + return false; + *minimum = low; + return true; +} + +static bool ovp_tariff_pair(int count, uint64_t *native_min, uint64_t *profile_min, + bool *fixtures) { + ov_fixture_t native, profile; + memset(&profile, 0, sizeof(profile)); + bool native_ready = count ? ov_default(&native) : + ov_open(&native, "", 0, ov_empty_tests, sizeof(ov_empty_tests) - 1); + char *table = NULL; + size_t length = 0; + bool generated = native_ready && ovp_generated_table(&native.arena, count, &table, &length); + bool profile_ready = generated && ovp_open(&profile, table, length, count) && + ovp_generated_profiles(profile.map, count); + *fixtures = native_ready && profile_ready && native.function_count == count && + ov_wire(&native, NULL, 0) && ov_wire(&profile, NULL, 0); + bool ok = *fixtures && ovp_min_items(&native, native_min) && + ovp_min_items(&profile, profile_min) && *native_min == *profile_min; + if (ok && count) { + bool native_success = true, profile_success = true; + ok = *native_min > 1 && + ovp_item_probe(&native, *native_min - 1, &native_success) && !native_success && + ovp_item_probe(&profile, *profile_min - 1, &profile_success) && !profile_success; + } + ov_close(&profile); + ov_close(&native); + return ok; +} + +static bool ovp_cancel_samples(ov_fixture_t *f, const int *all, size_t count) { + cbm_coverage_origin_join_t *join = NULL; + ov_cancel_t observed = {0}; + cbm_coverage_origin_status_t status = cbm_coverage_origin_join( + &f->input, &f->limits, ov_cancel, &observed, &join); + bool ok = status == CBM_COVERAGE_ORIGIN_OK && observed.calls > 0 && + ovp_one_result(f, join, CBM_COVERAGE_ORIGIN_ALL, true, all, count); + cbm_coverage_origin_join_free(join); + join = NULL; + if (!ok) + return false; + size_t positions[] = {1, observed.calls / 4 + 1, observed.calls / 2 + 1, + observed.calls - observed.calls / 4, observed.calls}; + for (size_t i = 0; i < sizeof(positions) / sizeof(positions[0]); i++) { + ov_cancel_t cancellation = {.cancel_at = positions[i]}; + status = cbm_coverage_origin_join(&f->input, &f->limits, ov_cancel, &cancellation, &join); + if (i == 0) + ok = status == CBM_COVERAGE_ORIGIN_CANCELLED && join == NULL && ok; + if (status == CBM_COVERAGE_ORIGIN_CANCELLED) + ok = join == NULL && cancellation.calls >= positions[i] && ok; + else + ok = status == CBM_COVERAGE_ORIGIN_OK && cancellation.calls < positions[i] && + ovp_one_result(f, join, CBM_COVERAGE_ORIGIN_ALL, true, all, count) && ok; + cbm_coverage_origin_join_free(join); + join = NULL; + } + status = cbm_coverage_origin_join(&f->input, &f->limits, NULL, NULL, &join); + ok = status == CBM_COVERAGE_ORIGIN_OK && + ovp_one_result(f, join, CBM_COVERAGE_ORIGIN_ALL, true, all, count) && ok; + cbm_coverage_origin_join_free(join); + return ok; +} + +static bool ovp_large_budget_case(bool *fixture) { + CBMArena arena; + cbm_arena_init_lazy(&arena, 4096); + const int count = 4097; + char *table = NULL; + size_t length = 0; + ov_fixture_t f; + memset(&f, 0, sizeof(f)); + int *all = cbm_arena_alloc(&arena, (size_t)count * sizeof(int)); + bool generated = all && ovp_generated_table(&arena, count, &table, &length); + for (int i = 0; generated && i < count; i++) + all[i] = i; + *fixture = generated && ovp_open(&f, table, length, count) && + ovp_generated_profiles(f.map, count) && ov_wire(&f, NULL, 0); + bool ok = *fixture && ov_success(&f, 0, false, NULL, 0); + if (*fixture) { + uint64_t ample_items = f.limits.max_items; + f.limits.max_items = (uint64_t)count - 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_LIMIT) && ok; + f.limits.max_items = ample_items; + ok = ovp_one_row(&f, CBM_COVERAGE_ORIGIN_ALL, NULL, 0) && ok; + f.limits.max_result_ids = (size_t)count; + ok = ov_success(&f, 0, true, all, (size_t)count) && ok; + f.limits.max_result_ids = (size_t)count - 1; + ok = ov_error(&f, CBM_COVERAGE_ORIGIN_LIMIT) && ok; + f.limits.max_result_ids = (size_t)count; + ok = ovp_cancel_samples(&f, all, (size_t)count) && ovp_empty_observations(f.map) && ok; + } + ov_close(&f); + cbm_arena_destroy(&arena); + return ok; +} + +TEST(origin_profiles_item_and_cancellation_compatibility) { + ASSERT_TRUE(ovp_legacy_control()); + uint64_t native_zero = 0, profile_zero = 0, native_four = 0, profile_four = 0; + bool zero_fixtures = false, four_fixtures = false, large_fixture = false; + bool zero = ovp_tariff_pair(0, &native_zero, &profile_zero, &zero_fixtures); + bool four = ovp_tariff_pair(4, &native_four, &profile_four, &four_fixtures); + bool large = ovp_large_budget_case(&large_fixture); + ASSERT_TRUE(zero_fixtures); + ASSERT_TRUE(four_fixtures); + ASSERT_TRUE(large_fixture); + ASSERT_TRUE(zero); + ASSERT_TRUE(four); + ASSERT_TRUE(native_four > native_zero); + ASSERT_TRUE(profile_four > profile_zero); + ASSERT_TRUE(large); + PASS(); +} + +static bool ovp_legacy_orphan_guard(void) { + static const char functions[] = "0\tf.c\tfn\n"; + static const char observed[] = "core:*\tcomplete\t\t\ncore:seen\tcomplete\t\t0\n"; + cbm_coverage_map_t *valid = cbm_coverage_map_parse(functions, sizeof(functions) - 1, + observed, sizeof(observed) - 1); + const cbm_coverage_test_t *seen = cbm_coverage_map_find_test(valid, "core", "seen"); + bool positive = valid && cbm_coverage_map_id_count(valid) == 1 && seen && seen->complete && + seen->function_count == 1 && seen->function_ids && seen->function_ids[0] == 0; + cbm_coverage_map_t *orphan = cbm_coverage_map_parse(functions, sizeof(functions) - 1, + ov_empty_tests, sizeof(ov_empty_tests) - 1); + bool ok = positive && orphan == NULL; + cbm_coverage_map_free(orphan); + cbm_coverage_map_free(valid); + return ok; +} + +TEST(origin_profiles_trust_is_not_parser_success) { + ASSERT_TRUE(ovp_legacy_control()); + ASSERT_TRUE(ovp_legacy_orphan_guard()); + ov_fixture_t f; + bool parser = ovp_open(&f, ovp_profiles, sizeof(ovp_profiles) - 1, 2) && + ovp_two_profiles(f.map, 5); + bool fixture = parser && ovp_one_row(&f, CBM_COVERAGE_ORIGIN_COMPLETE_IDS, + (const uint32_t[]){1}, 1); + bool positive = fixture && ov_success(&f, 0, false, (const int[]){1}, 1); + bool refused = positive; + if (fixture) { + cbm_coverage_origin_context_t trusted = f.context; + for (unsigned flag = 0; flag < 4; flag++) { + f.context = trusted; + if (flag == 0) f.context.artifact_admitted = false; + if (flag == 1) f.context.origin_source_verified = false; + if (flag == 2) f.context.origin_attestations_verified = false; + if (flag == 3) f.context.comparisons_verified = false; + refused = ov_error(&f, CBM_COVERAGE_ORIGIN_UNVERIFIED) && refused; + } + f.context = trusted; + /* This legacy checker explicitly refuses v2; it cannot set the flag. */ + unsigned receipt = cbm_coverage_map_check_receipt(f.map, "", 0, NULL, NULL); + refused = receipt == (CBM_COVERAGE_RECEIPT_INVALID | CBM_COVERAGE_RECEIPT_METADATA) && refused; + f.context.artifact_admitted = false; + refused = ov_error(&f, CBM_COVERAGE_ORIGIN_UNVERIFIED) && refused; + f.context = trusted; + } + bool recovery = fixture && ov_success(&f, 0, false, (const int[]){1}, 1); + ov_close(&f); + ASSERT_TRUE(parser); + ASSERT_TRUE(fixture); + ASSERT_TRUE(positive); + ASSERT_TRUE(refused); + ASSERT_TRUE(recovery); + PASS(); +} + +/* Authorized follow-on: isolate the selector's ID-domain validation from + * origin-join support. Synthetic admission below is not a provider result. */ +#define OVP_SELECTOR_CASES \ + "core:gap\tincomplete\tmissing profile\t\n" \ + "core:hit\tcomplete\t\t0\ncore:quiet\tcomplete\t\t1\n" +static const char *const ovp_selector_rows[] = { + "core:*\tcomplete\t\t\n" OVP_SELECTOR_CASES, + "core:*\tcomplete\t\t0\n" OVP_SELECTOR_CASES, + "core:*\tincomplete\tmissing setup\t\n" OVP_SELECTOR_CASES}; + +static cbm_test_model_t *ovp_selector_model(void) { + static const char source[] = + "TEST(hit) {}\nTEST(quiet) {}\nTEST(gap) {}\nTEST(missing) {}\n" + "SUITE(core) { RUN_TEST(hit); RUN_TEST(quiet); RUN_TEST(gap); RUN_TEST(missing); }\n" + "void main(void) { RUN_SELECTED_SUITE(core); }\n"; + cbm_test_model_t *model = cbm_test_model_new(cbm_test_conventions_cbm()); + bool ready = model && cbm_test_model_add_source(model, "tests/profile_cases.c", + source, sizeof(source) - 1) && cbm_test_model_finish(model) && cbm_test_model_complete(model); + if (!ready) { + cbm_test_model_free(model); + return NULL; + } + int count = -1; + const cbm_test_registration_t *registrations = cbm_test_model_registrations(model, &count); + ready = ready && registrations && count == 4; + for (int i = 0; ready && i < count; i++) + ready = registrations[i].resolved && !registrations[i].conditional; + const cbm_test_runner_suite_t *suites = cbm_test_model_runner_suites(model, &count); + ready = ready && suites && count == 1 && strcmp(suites[0].name, "core") == 0 && !suites[0].perf; + if (!ready) { + cbm_test_model_free(model); + return NULL; + } + return model; +} + +static bool ovp_selector_observations(const cbm_coverage_map_t *map, unsigned variant) { + const cbm_coverage_test_t *setup = cbm_coverage_map_find_test(map, "core", "*"); + const cbm_coverage_test_t *hit = cbm_coverage_map_find_test(map, "core", "hit"); + const cbm_coverage_test_t *quiet = cbm_coverage_map_find_test(map, "core", "quiet"); + const cbm_coverage_test_t *gap = cbm_coverage_map_find_test(map, "core", "gap"); + bool cases = hit && hit->complete && hit->function_count == 1 && hit->function_ids && + hit->function_ids[0] == 0 && quiet && quiet->complete && quiet->function_count == 1 && + quiet->function_ids && quiet->function_ids[0] == 1 && gap && !gap->complete && + gap->function_count == 0 && cbm_coverage_map_find_test(map, "core", "missing") == NULL; + if (!cases || !setup || setup->complete != (variant != 2)) + return false; + if (variant == 1) + return setup->function_count == 1 && setup->function_ids && setup->function_ids[0] == 0; + return setup->function_count == 0; +} + +static bool ovp_selector_map(bool profiles, unsigned variant, cbm_coverage_map_t **out) { + *out = NULL; + if (variant >= sizeof(ovp_selector_rows) / sizeof(ovp_selector_rows[0])) + return false; + const char *rows = ovp_selector_rows[variant]; + if (profiles) { + const cbm_coverage_parse_limits_t limits = {.max_input_bytes = 4096, .max_items = 100, + .max_alloc_bytes = 1024U * 1024U, .max_ids = 2}; + cbm_coverage_parse_status_t status = cbm_coverage_map_parse_v2( + ovp_profiles, sizeof(ovp_profiles) - 1, rows, strlen(rows), &limits, NULL, NULL, out); + if (status != CBM_COVERAGE_PARSE_OK || !*out || !ovp_two_profiles(*out, 5)) + return false; + const char *digest = cbm_coverage_map_identity_sha256(*out); + if (!digest || strcmp(digest, ovp_profiles_sha) != 0) + return false; + } else { + static const char functions[] = "0\tf.c\tchanged\n1\tg.c\tquiet\n"; + *out = cbm_coverage_map_parse(functions, sizeof(functions) - 1, rows, strlen(rows)); + } + return *out && cbm_coverage_map_id_count(*out) == 2 && + cbm_coverage_map_format(*out) == (profiles ? CBM_COVERAGE_FORMAT_PROFILES : + CBM_COVERAGE_FORMAT_FUNCTIONS) && + ovp_selector_observations(*out, variant); +} + +static bool ovp_selected_cases(const cbm_test_selection_t *result) { + int count = -1; + const cbm_test_selected_case_t *cases = cbm_test_selection_cases(result, &count); + static const char *const names[] = {"gap", "hit", "missing"}; + static const unsigned reasons[] = {CBM_TEST_SELECT_COVERAGE_UNKNOWN, + CBM_TEST_SELECT_COVERAGE, CBM_TEST_SELECT_COVERAGE_UNKNOWN}; + if (!cases || count != 3) + return false; + for (int i = 0; i < count; i++) + if (strcmp(cases[i].suite, "core") != 0 || strcmp(cases[i].test, names[i]) != 0 || + strcmp(cases[i].file, "tests/profile_cases.c") != 0 || cases[i].reasons != reasons[i]) + return false; + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + return suites && count == 1 && strcmp(suites[0].name, "core") == 0 && !suites[0].whole && + suites[0].reasons == (CBM_TEST_SELECT_COVERAGE | CBM_TEST_SELECT_COVERAGE_UNKNOWN); +} + +static bool ovp_selection_result(const cbm_test_selection_t *result, unsigned variant) { + if (!result) + return false; + unsigned run_all = cbm_test_selection_run_all(result); + int count = -1; + if (variant == 3) { + (void)cbm_test_selection_cases(result, &count); + bool empty = count == 0; + (void)cbm_test_selection_suites(result, &count); + return run_all == CBM_TEST_SELECT_INVALID_INPUT && empty && count == 0; + } + if (run_all) + return false; + if (variant == 0) + return ovp_selected_cases(result); + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + unsigned expected = variant == 1 ? CBM_TEST_SELECT_SETUP_HIT : CBM_TEST_SELECT_SETUP_UNKNOWN; + bool whole = suites && count == 1 && strcmp(suites[0].name, "core") == 0 && + suites[0].whole && (suites[0].reasons & expected) != 0; + (void)cbm_test_selection_cases(result, &count); + return whole && count == 0; +} + +static bool ovp_select(cbm_test_model_t *model, cbm_coverage_map_t *map, unsigned variant) { + cbm_test_reach_t reach[] = { + {"tests/profile_cases.c", "hit", true, false, false}, + {"tests/profile_cases.c", "quiet", true, false, false}, + {"tests/profile_cases.c", "gap", true, false, false}, + {"tests/profile_cases.c", "missing", true, false, false}}; + int changed = variant == 3 ? 2 : 0; + cbm_test_selection_input_t input = {.model = model, .coverage = map, .reach = reach, + .reach_count = 4, .changed_function_ids = &changed, .changed_function_count = 1, + .has_changes = true, .diff_complete = true, .inventory_complete = true, + .static_complete = true, .coverage_admitted = true, .coverage_changes_complete = true}; + cbm_test_selection_t *result = cbm_test_select(&input); + bool ok = ovp_selection_result(result, variant); + cbm_test_selection_free(result); + return ok; +} + +TEST(origin_profiles_selector_generic_id_domain) { + /* No origin-join precondition: this independently binds the selector seam. */ + cbm_test_model_t *model = ovp_selector_model(); + cbm_coverage_map_t *native = NULL; + bool native_parser = model && ovp_selector_map(false, 0, &native); + bool native_ok = native_parser && ovp_select(model, native, 0) && ovp_select(model, native, 3); + cbm_coverage_map_free(native); + bool parsers = model != NULL, selected = native_ok; + for (unsigned variant = 0; model && variant < 3; variant++) { + cbm_coverage_map_t *map = NULL; + bool parser = ovp_selector_map(true, variant, &map); + parsers = parser && parsers; + selected = parser && ovp_select(model, map, variant) && selected; + if (variant == 0) + selected = parser && ovp_select(model, map, 3) && selected; + cbm_coverage_map_free(map); + } + cbm_test_model_free(model); + ASSERT_TRUE(native_parser); + ASSERT_TRUE(native_ok); + ASSERT_TRUE(parsers); + ASSERT_TRUE(selected); + PASS(); +} + +SUITE(test_impact_origins) { + RUN_TEST(origin_v2_wire_and_actual_map_digest); + RUN_TEST(origin_v2_union_and_request_state); + RUN_TEST(origin_v2_many_to_many_and_unknown_ids); + RUN_TEST(origin_v2_edit_class_obligations); + RUN_TEST(origin_v2_optional_header_and_certified_zero); + RUN_TEST(origin_v2_all_sticky_broad_floor); + RUN_TEST(origin_v2_exact_reason_masks); + RUN_TEST(origin_v2_strict_wire_and_raw_streams); + RUN_TEST(origin_v2_admission_and_tuple_binding); + RUN_TEST(origin_v2_resource_limits); + RUN_TEST(origin_v2_cancellation_ownership_and_null_views); + RUN_TEST(origin_profiles_unobserved_identity); + RUN_TEST(origin_profiles_zero_and_broad_floors); + RUN_TEST(origin_profiles_exact_table_binding); + RUN_TEST(origin_profiles_item_and_cancellation_compatibility); + RUN_TEST(origin_profiles_trust_is_not_parser_success); + RUN_TEST(origin_profiles_selector_generic_id_domain); +} diff --git a/tests/test_test_impact_profiles.c b/tests/test_test_impact_profiles.c new file mode 100644 index 000000000..41dd961c4 --- /dev/null +++ b/tests/test_test_impact_profiles.c @@ -0,0 +1,902 @@ +/* Independent contract tests for the single-image format-2 coverage consumer. */ +#include "test_framework.h" +#include +#include +#include +#include +#include +#include +#include + +#define PV2_IMAGE "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +#define PV2_UPPER_IMAGE "0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef" +#define PV2_HEADER(n) "CBM_PROFILE_MAP\t2\t" PV2_IMAGE "\t" n "\n" +#define PV2_HASH "784ae7d054308c51cab4db32e73312ae3aaedb346aafd0806826a529c280b249" +#define PV2_SIZE(s) (sizeof(s) - 1) + +_Static_assert(PV2_SIZE(PV2_UPPER_IMAGE) == 64, "uppercase image fixture must have 64 hex bytes"); + +static const char pv2_golden[] = + PV2_HEADER("3") + "0\t610062\t0000000000000000\t1\n" + "1\t610062\t8000000000000000\t2\n" + "2\tff\tffffffffffffffff\t18446744073709551615\n"; +static const char pv2_tests[] = "core:*\tcomplete\t\t\ncore:seen\tcomplete\t\t0 1\n"; +static const char pv2_one[] = PV2_HEADER("1") "0\t61\t0000000000000018\t1\n"; +static const char pv2_one_tests[] = "s:*\tcomplete\t\t0\n"; +static const char pv2_empty[] = PV2_HEADER("0"); + +/* Existing native v1 evidence: none of these controls calls an additive API. */ +static const char pv2_v1_functions[] = "0\tsrc/source.c\tone\n1\tsrc/source.c\ttwo\n"; +static const char pv2_v1_tests[] = + "alpha:*\tcomplete\t\t1\nalpha:one\tcomplete\t\t0\nalpha:two\tcomplete\t\t1\n"; +static const char pv2_v1_meta[] = + "{\"format\":1,\"commit\":\"1111111111111111111111111111111111111111\",\"functions\":2," + "\"tests\":2,\"incomplete\":0,\"platform\":\"fixture\",\"llvm_profdata\":\"fixture\"," + "\"suites\":[{\"suite\":\"alpha\",\"tests\":2,\"incomplete\":0,\"exit\":0}]}\n"; + +static cbm_coverage_receipt_t pv2_receipt(void) { + return (cbm_coverage_receipt_t){ + .commit = "1111111111111111111111111111111111111111", + .functions_sha256 = "9fb6ff76736fdda3558510f3732c3af3be254476dc29e479bc1e2aa67db14c91", + .tests_sha256 = "9301b8969a11a4b758f4d0d7128ba71bc00a90da1b6f89acbf175988abef362c", + .metadata_sha256 = "ebdeebf656b0eace38a267f808365b6fea7eefb89f9feabd41c70d4d2cf156aa", + .graph_sha256 = "4444444444444444444444444444444444444444444444444444444444444444", + .compatibility_sha256 = "3333333333333333333333333333333333333333333333333333333333333333", + .oldest_observation_at = INT64_C(2000000000), + }; +} + +static cbm_coverage_receipt_context_t pv2_receipt_context(void) { + return (cbm_coverage_receipt_context_t){ + .source_verified = true, + .trusted_commit = "1111111111111111111111111111111111111111", + .ancestor_verified = true, + .ancestor_commit = "1111111111111111111111111111111111111111", + .ancestor_merge_base = "2222222222222222222222222222222222222222", + .merge_base = "2222222222222222222222222222222222222222", + .graph_commit = "1111111111111111111111111111111111111111", + .graph_sha256 = "4444444444444444444444444444444444444444444444444444444444444444", + .compatibility_sha256 = "3333333333333333333333333333333333333333333333333333333333333333", + .now = INT64_C(2000000000) + 604800, + }; +} + +static cbm_coverage_map_t *pv2_v1_map(void) { + return cbm_coverage_map_parse(pv2_v1_functions, PV2_SIZE(pv2_v1_functions), + pv2_v1_tests, PV2_SIZE(pv2_v1_tests)); +} + +static bool pv2_native_controls(void) { + cbm_coverage_map_t *map = pv2_v1_map(); + if (!map) + return false; + cbm_coverage_receipt_t receipt = pv2_receipt(); + cbm_coverage_receipt_context_t context = pv2_receipt_context(); + int count = -1; + const cbm_coverage_function_t *rows = cbm_coverage_map_functions(map, &count); + bool ok = rows && count == 2 && !strcmp(rows[1].name, "two") && + cbm_coverage_map_check_receipt(map, pv2_v1_meta, PV2_SIZE(pv2_v1_meta), + &receipt, &context) == 0; + cbm_coverage_map_free(map); + const char orphan[] = "alpha:*\tcomplete\t\t0\n"; + map = cbm_coverage_map_parse(pv2_v1_functions, PV2_SIZE(pv2_v1_functions), + orphan, PV2_SIZE(orphan)); + ok = (map == NULL) && ok; + cbm_coverage_map_free(map); + const char duplicate[] = "0\tsrc/source.c\tone\n1\tsrc/source.c\tone\n"; + map = cbm_coverage_map_parse(duplicate, PV2_SIZE(duplicate), + pv2_v1_tests, PV2_SIZE(pv2_v1_tests)); + ok = (map == NULL) && ok; + cbm_coverage_map_free(map); + return ok; +} + +static cbm_coverage_parse_limits_t pv2_limits(void) { + return (cbm_coverage_parse_limits_t){ + .max_input_bytes = UINT64_C(16) * 1024 * 1024, + .max_items = UINT64_C(1000000), + .max_alloc_bytes = (size_t)64 * 1024 * 1024, + .max_ids = 4096, + }; +} + +static cbm_coverage_parse_status_t pv2_parse(const void *p, size_t n, const char *t, size_t m, + cbm_coverage_map_t **out) { + cbm_coverage_parse_limits_t limits = pv2_limits(); + return cbm_coverage_map_parse_v2(p, n, t, m, &limits, NULL, NULL, out); +} + +static bool pv2_one_shape(const cbm_coverage_map_t *map) { + int count = -1; + const cbm_coverage_profile_t *rows = cbm_coverage_map_profiles(map, &count); + const cbm_coverage_profile_binding_t *binding = cbm_coverage_map_profile_binding(map); + return rows && count == 1 && rows[0].id == 0 && rows[0].name && + rows[0].name_length == 1 && rows[0].name[0] == 'a' && + rows[0].function_hash == UINT64_C(24) && rows[0].counter_count == 1 && + binding && !strcmp(binding->image_sha256, PV2_IMAGE); +} + +static bool pv2_feature_control(void) { + cbm_coverage_map_t *map = NULL; + cbm_coverage_parse_status_t status = pv2_parse(pv2_one, PV2_SIZE(pv2_one), + pv2_one_tests, PV2_SIZE(pv2_one_tests), &map); + bool ok = status == CBM_COVERAGE_PARSE_OK && pv2_one_shape(map); + cbm_coverage_map_free(map); + return ok; +} + +/* A real live guard, never an invented pointer, verifies clearing and isolation. */ +static bool pv2_failure(const void *p, size_t n, const char *t, size_t m, + const cbm_coverage_parse_limits_t *limits, + cbm_coverage_parse_status_t expected, cbm_coverage_map_t *guard) { + cbm_coverage_map_t *out = guard; + cbm_coverage_parse_status_t status = + cbm_coverage_map_parse_v2(p, n, t, m, limits, NULL, NULL, &out); + bool ok = status == expected && out == NULL; + if (out != guard) + cbm_coverage_map_free(out); + return ok; +} + +static bool pv2_golden_shape(const cbm_coverage_map_t *map) { + static const unsigned char name[] = {'a', 0, 'b'}; + int count = -1; + const cbm_coverage_profile_t *rows = cbm_coverage_map_profiles(map, &count); + if (!rows || count != 3 || cbm_coverage_map_id_count(map) != 3) + return false; + for (int i = 0; i < 2; ++i) { + if (rows[i].id != i || !rows[i].name || rows[i].name_length != sizeof(name) || + memcmp(rows[i].name, name, sizeof(name))) + return false; + } + const cbm_coverage_profile_binding_t *binding = cbm_coverage_map_profile_binding(map); + const char *digest = cbm_coverage_map_identity_sha256(map); + return rows[0].function_hash == 0 && rows[0].counter_count == 1 && + rows[1].function_hash == UINT64_C(0x8000000000000000) && + rows[1].counter_count == 2 && rows[2].id == 2 && rows[2].name && + rows[2].name_length == 1 && rows[2].name[0] == 255 && + rows[2].function_hash == UINT64_MAX && rows[2].counter_count == UINT64_MAX && + binding && !strcmp(binding->image_sha256, PV2_IMAGE) && + digest && !strcmp(digest, PV2_HASH); +} + +TEST(test_profile_v2_exact_golden_and_unobserved_ids) { + ASSERT_TRUE(pv2_native_controls()); + ASSERT_TRUE(pv2_feature_control()); + cbm_coverage_map_t *map = NULL; + cbm_coverage_parse_status_t status = pv2_parse(pv2_golden, PV2_SIZE(pv2_golden), + pv2_tests, PV2_SIZE(pv2_tests), &map); + bool shape = status == CBM_COVERAGE_PARSE_OK && pv2_golden_shape(map); + bool format = cbm_coverage_map_format(map) == CBM_COVERAGE_FORMAT_PROFILES; + int count = -1; + const cbm_coverage_test_t *tests = map ? cbm_coverage_map_tests(map, &count) : NULL; + bool rows = tests && count == 2 && !strcmp(tests[0].suite, "core") && + !strcmp(tests[0].name, "*") && tests[0].complete && + tests[0].function_count == 0 && !strcmp(tests[1].name, "seen") && + tests[1].function_count == 2 && tests[1].function_ids[0] == 0 && + tests[1].function_ids[1] == 1; + int unobserved = 2; + int observed = 1; + bool hits = rows && !cbm_coverage_test_intersects(&tests[0], &unobserved, 1) && + !cbm_coverage_test_intersects(&tests[1], &unobserved, 1) && + cbm_coverage_test_intersects(&tests[1], &observed, 1); + cbm_coverage_map_free(map); + ASSERT_EQ(PV2_SIZE(pv2_golden), 184); + ASSERT_EQ(PV2_SIZE(pv2_tests), 42); + ASSERT_TRUE(shape && format && rows && hits); + PASS(); +} + +TEST(test_profile_v2_identity_order_and_duplicates) { + ASSERT_TRUE(pv2_native_controls()); + ASSERT_TRUE(pv2_feature_control()); + const char ordered[] = PV2_HEADER("6") + "0\t61\t0000000000000000\t1\n1\t61\t8000000000000000\t2\n" + "2\t610062\t0000000000000000\t1\n3\t6161\t0000000000000000\t1\n" + "4\t80\t0000000000000000\t1\n5\tff\tffffffffffffffff\t1\n"; + cbm_coverage_map_t *map = NULL; + cbm_coverage_parse_status_t status = pv2_parse(ordered, PV2_SIZE(ordered), NULL, 0, &map); + int count = -1; + const cbm_coverage_profile_t *rows = cbm_coverage_map_profiles(map, &count); + bool positive = status == CBM_COVERAGE_PARSE_OK && rows && count == 6 && + rows[0].name_length == 1 && rows[2].name_length == 3 && + rows[4].name[0] == 128 && rows[5].name[0] == 255; + cbm_coverage_map_free(map); + ASSERT_TRUE(positive); + const char *bad[] = { + PV2_HEADER("2") "0\t61\t0000000000000000\t1\n1\t61\t0000000000000000\t2\n", + PV2_HEADER("2") "0\t61\t8000000000000000\t1\n1\t61\t0000000000000000\t1\n", + PV2_HEADER("2") "0\t610062\t0000000000000000\t1\n1\t61\t0000000000000000\t1\n", + PV2_HEADER("2") "0\tff\t0000000000000000\t1\n1\t80\t0000000000000000\t1\n", + PV2_HEADER("2") "0\t61\t0000000000000000\t1\n2\t62\t0000000000000000\t1\n", + PV2_HEADER("2") "0\t61\t0000000000000000\t1\n0\t62\t0000000000000000\t1\n", + PV2_HEADER("1") "1\t61\t0000000000000000\t1\n", + PV2_HEADER("2") "0\t61\t0000000000000000\t1\n", + PV2_HEADER("0") "0\t61\t0000000000000000\t1\n", + }; + cbm_coverage_map_t *guard = pv2_v1_map(); + ASSERT_NOT_NULL(guard); + cbm_coverage_parse_limits_t limits = pv2_limits(); + bool rejected = true; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); ++i) { + bool one = pv2_failure(bad[i], strlen(bad[i]), NULL, 0, &limits, + CBM_COVERAGE_PARSE_FORMAT, guard); + if (!one) + printf(" profile identity case %zu failed\n", i); + rejected = one && rejected; + } + cbm_coverage_map_free(guard); + ASSERT_TRUE(rejected); + PASS(); +} + +typedef struct { + const char *text; + size_t size; + cbm_coverage_parse_status_t expected; +} pv2_bad_wire_t; +#define PV2_BAD(s) {s, sizeof(s) - 1, CBM_COVERAGE_PARSE_FORMAT} + +TEST(test_profile_v2_strict_wire_and_all_truncations) { + ASSERT_TRUE(pv2_native_controls()); + ASSERT_TRUE(pv2_feature_control()); + static const pv2_bad_wire_t bad[] = { + PV2_BAD(""), PV2_BAD("\xef\xbb\xbf" PV2_HEADER("0")), + PV2_BAD("OTHER\t2\t" PV2_IMAGE "\t0\n"), + PV2_BAD("CBM_PROFILE_MAP\t02\t" PV2_IMAGE "\t0\n"), + PV2_BAD("CBM_PROFILE_MAP\t18446744073709551616\t" PV2_IMAGE "\t0\n"), + PV2_BAD("CBM_PROFILE_MAP\t2\tshort\t0\n"), + PV2_BAD("CBM_PROFILE_MAP\t2\t" PV2_UPPER_IMAGE "\t0\n"), + PV2_BAD(PV2_HEADER("00")), PV2_BAD(PV2_HEADER("-1")), + PV2_BAD(PV2_HEADER("18446744073709551616")), + PV2_BAD("CBM_PROFILE_MAP\t2\t" PV2_IMAGE "\t0\textra\n"), + PV2_BAD("CBM_PROFILE_MAP\t2\t" PV2_IMAGE "\t0\r\n"), + PV2_BAD(PV2_HEADER("0") "\n"), PV2_BAD(PV2_HEADER("0") "trailer\n"), + PV2_BAD(PV2_HEADER("0") "\0"), + PV2_BAD(PV2_HEADER("1") "00\t61\t0000000000000000\t1\n"), + PV2_BAD(PV2_HEADER("1") "0\t6\t0000000000000000\t1\n"), + PV2_BAD(PV2_HEADER("1") "0\t6A\t0000000000000000\t1\n"), + PV2_BAD(PV2_HEADER("1") "0\tgg\t0000000000000000\t1\n"), + PV2_BAD(PV2_HEADER("1") "0\t\t0000000000000000\t1\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t00000000000000FF\t1\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t000000000000000\t1\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t10000000000000000\t1\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t0x00000000000000\t1\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t0000000000000000\t0\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t0000000000000000\t01\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t0000000000000000\t+1\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t0000000000000000\t 1\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t0000000000000000\t18446744073709551616\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t0000000000000000\t1\textra\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t0000000000000000\t1\r\n"), + PV2_BAD(PV2_HEADER("1") "0\t61\t0000000000000000\t1\0\n"), + PV2_BAD(PV2_HEADER("0") PV2_HEADER("0")), + {"CBM_PROFILE_MAP\t3\t" PV2_IMAGE "\t0\n", + sizeof("CBM_PROFILE_MAP\t3\t" PV2_IMAGE "\t0\n") - 1, + CBM_COVERAGE_PARSE_UNSUPPORTED}, + {"CBM_PROFILE_MAP\t3\t" PV2_IMAGE "\t0\nnot-interpreted\n", + sizeof("CBM_PROFILE_MAP\t3\t" PV2_IMAGE "\t0\nnot-interpreted\n") - 1, + CBM_COVERAGE_PARSE_UNSUPPORTED}, + }; + cbm_coverage_map_t *guard = pv2_v1_map(); + ASSERT_NOT_NULL(guard); + cbm_coverage_parse_limits_t limits = pv2_limits(); + bool rejected = true; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); ++i) { + bool one = pv2_failure(bad[i].text, bad[i].size, NULL, 0, &limits, + bad[i].expected, guard); + if (!one) + printf(" profile wire case %zu failed\n", i); + rejected = one && rejected; + } + /* Every strict prefix of this declared-three-record table is incomplete. */ + for (size_t size = 0; size < PV2_SIZE(pv2_golden); ++size) { + bool one = pv2_failure(pv2_golden, size, NULL, 0, &limits, + CBM_COVERAGE_PARSE_FORMAT, guard); + if (!one) + printf(" profile table truncation %zu failed\n", size); + rejected = one && rejected; + } + cbm_coverage_map_free(guard); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(test_profile_v2_test_rows_and_zero_universe) { + ASSERT_TRUE(pv2_native_controls()); + ASSERT_TRUE(pv2_feature_control()); + const char good[] = + "z:*\tcomplete\t\t\r\na:t:part\tcomplete\t\t00 01\r\n" + "a:missing\tincomplete\tmissing\rprofile\t\r\na:*\tcomplete\t\t0\n" + "a:empty\tcomplete\t\t\n\xff:*\tcomplete\t\t\n\x80:*\tcomplete\t\t\n"; + cbm_coverage_map_t *map = NULL; + cbm_coverage_parse_status_t status = pv2_parse(pv2_golden, PV2_SIZE(pv2_golden), + good, PV2_SIZE(good), &map); + int count = -1; + const cbm_coverage_test_t *rows = map ? cbm_coverage_map_tests(map, &count) : NULL; + const cbm_coverage_test_t *part = map ? cbm_coverage_map_find_test(map, "a", "t:part") : NULL; + const cbm_coverage_test_t *missing = map ? cbm_coverage_map_find_test(map, "a", "missing") : NULL; + bool positive = status == CBM_COVERAGE_PARSE_OK && rows && count == 7 && + !strcmp(rows[0].suite, "a") && !strcmp(rows[0].name, "*") && + !strcmp(rows[4].suite, "z") && (unsigned char)rows[5].suite[0] == 128 && + (unsigned char)rows[6].suite[0] == 255 && part && part->complete && + part->function_count == 2 && part->function_ids[0] == 0 && + part->function_ids[1] == 1 && missing && !missing->complete && + !strcmp(missing->reason, "missing\rprofile") && + cbm_coverage_map_find_test(map, "absent", "x") == NULL; + cbm_coverage_map_free(map); + ASSERT_TRUE(positive); + const char *empty_rows[] = {"", "s:*\tcomplete\t\t\n", + "s:*\tincomplete\tunknown\t\ns:t\tcomplete\t\t\n"}; + bool zero_ok = true; + for (size_t i = 0; i < sizeof(empty_rows) / sizeof(empty_rows[0]); ++i) { + map = NULL; + status = pv2_parse(pv2_empty, PV2_SIZE(pv2_empty), empty_rows[i], strlen(empty_rows[i]), &map); + count = -1; + const cbm_coverage_profile_t *profiles = cbm_coverage_map_profiles(map, &count); + zero_ok = status == CBM_COVERAGE_PARSE_OK && map && count == 0 && !profiles && + cbm_coverage_map_profile_binding(map) && cbm_coverage_map_id_count(map) == 0 && zero_ok; + cbm_coverage_map_free(map); + } + map = NULL; + status = pv2_parse(pv2_golden, PV2_SIZE(pv2_golden), NULL, 0, &map); + if (map) + cbm_coverage_map_tests(map, &count); + bool no_rows = status == CBM_COVERAGE_PARSE_OK && pv2_golden_shape(map) && count == 0; + cbm_coverage_map_free(map); + ASSERT_TRUE(zero_ok && no_rows); + static const pv2_bad_wire_t bad[] = { + PV2_BAD("s:t\tcomplete\t\t0\n"), + PV2_BAD("s:*\tcomplete\t\t0\ns:*\tcomplete\t\t1\n"), + PV2_BAD("s:*\tcomplete\t\t0\ns:t\tcomplete\t\t1\ns:t\tcomplete\t\t1\n"), + PV2_BAD("s:*\tcomplete\t\t0 0\n"), PV2_BAD("s:*\tcomplete\t\t1 0\n"), + PV2_BAD("s:*\tcomplete\t\t3\n"), PV2_BAD("s:*\tcomplete\t\t-1\n"), + PV2_BAD("s:*\tcomplete\t\t+1\n"), PV2_BAD("s:*\tcomplete\t\t2147483648\n"), + PV2_BAD("s:*\tcomplete\t\t18446744073709551616\n"), + PV2_BAD("s:*\tcomplete\t\t 0\n"), PV2_BAD("s:*\tcomplete\t\t0 \n"), + PV2_BAD("s:*\tcomplete\t\t0 1\n"), PV2_BAD("s:*\tcomplete\t\t0x\n"), + PV2_BAD("s:*\tcomplete\tproblem\t0\n"), PV2_BAD("s:*\tincomplete\t\t0\n"), + PV2_BAD("s:*\tunknown\t\t0\n"), PV2_BAD("s\tcomplete\t\t0\n"), + PV2_BAD(":*\tcomplete\t\t0\n"), PV2_BAD("s:\tcomplete\t\t0\n"), + PV2_BAD("s:*\tcomplete\t\t0\textra\n"), PV2_BAD("s:*\tcomplete\t\t0"), + PV2_BAD("s:*\tcomplete\t\t0\0 1\n"), + }; + cbm_coverage_map_t *guard = pv2_v1_map(); + ASSERT_NOT_NULL(guard); + cbm_coverage_parse_limits_t limits = pv2_limits(); + bool rejected = true; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); ++i) { + bool one = pv2_failure(pv2_golden, PV2_SIZE(pv2_golden), bad[i].text, bad[i].size, + &limits, CBM_COVERAGE_PARSE_FORMAT, guard); + if (!one) + printf(" profile test-row case %zu failed\n", i); + rejected = one && rejected; + } + cbm_coverage_map_free(guard); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(test_profile_v2_limits_and_invalid_arguments) { + ASSERT_TRUE(pv2_native_controls()); + ASSERT_TRUE(pv2_feature_control()); + cbm_coverage_parse_limits_t exact = pv2_limits(); + exact.max_input_bytes = 226; + exact.max_items = 8; + exact.max_ids = 3; + cbm_coverage_map_t *map = NULL; + cbm_coverage_parse_status_t status = cbm_coverage_map_parse_v2( + pv2_golden, PV2_SIZE(pv2_golden), pv2_tests, PV2_SIZE(pv2_tests), &exact, NULL, NULL, &map); + bool boundary = status == CBM_COVERAGE_PARSE_OK && pv2_golden_shape(map); + cbm_coverage_map_free(map); + ASSERT_TRUE(boundary); + cbm_coverage_parse_limits_t empty_limit = exact; + empty_limit.max_input_bytes = 85; + empty_limit.max_items = 1; + empty_limit.max_ids = 1; + map = NULL; + status = cbm_coverage_map_parse_v2(pv2_empty, PV2_SIZE(pv2_empty), NULL, 0, + &empty_limit, NULL, NULL, &map); + bool empty_boundary = status == CBM_COVERAGE_PARSE_OK && map && + cbm_coverage_map_id_count(map) == 0; + cbm_coverage_map_free(map); + ASSERT_TRUE(empty_boundary); + const char repeated[] = "s:*\tcomplete\t\t0\ns:t\tcomplete\t\t0\n"; + cbm_coverage_parse_limits_t repeated_limit = pv2_limits(); + repeated_limit.max_items = 6; /* header + profile + two rows + two references */ + map = NULL; + status = cbm_coverage_map_parse_v2(pv2_one, PV2_SIZE(pv2_one), repeated, + PV2_SIZE(repeated), &repeated_limit, NULL, NULL, &map); + bool repeated_boundary = status == CBM_COVERAGE_PARSE_OK && pv2_one_shape(map); + cbm_coverage_map_free(map); + ASSERT_TRUE(repeated_boundary); + cbm_coverage_map_t *guard = pv2_v1_map(); + ASSERT_NOT_NULL(guard); + bool ok = true; + repeated_limit.max_items = 5; + ok = pv2_failure(pv2_one, PV2_SIZE(pv2_one), repeated, PV2_SIZE(repeated), + &repeated_limit, CBM_COVERAGE_PARSE_LIMIT, guard) && ok; + for (int kind = 0; kind < 4; ++kind) { + cbm_coverage_parse_limits_t limit = exact; + if (kind == 0) limit.max_input_bytes--; + if (kind == 1) limit.max_items--; + if (kind == 2) limit.max_ids--; + if (kind == 3) limit.max_alloc_bytes = 1; + ok = pv2_failure(pv2_golden, PV2_SIZE(pv2_golden), pv2_tests, PV2_SIZE(pv2_tests), + &limit, CBM_COVERAGE_PARSE_LIMIT, guard) && ok; + } + empty_limit.max_input_bytes = 84; + ok = pv2_failure(pv2_empty, PV2_SIZE(pv2_empty), NULL, 0, &empty_limit, + CBM_COVERAGE_PARSE_LIMIT, guard) && ok; + for (int kind = 0; kind < 6; ++kind) { + cbm_coverage_parse_limits_t limit = exact; + if (kind == 0) limit.max_input_bytes = 0; + if (kind == 1) limit.max_items = 0; + if (kind == 2) limit.max_ids = 0; + if (kind == 3) limit.max_alloc_bytes = 0; + if (kind == 4) limit.max_ids = -1; + if (kind == 5) limit.max_input_bytes = UINT64_MAX / 8 + 1; + ok = pv2_failure(pv2_golden, PV2_SIZE(pv2_golden), pv2_tests, PV2_SIZE(pv2_tests), + &limit, CBM_COVERAGE_PARSE_INVALID, guard) && ok; + } + ok = pv2_failure(NULL, 0, NULL, 0, &exact, CBM_COVERAGE_PARSE_INVALID, guard) && ok; + ok = pv2_failure(pv2_golden, PV2_SIZE(pv2_golden), NULL, 1, &exact, + CBM_COVERAGE_PARSE_INVALID, guard) && ok; + ok = pv2_failure(pv2_golden, PV2_SIZE(pv2_golden), NULL, 0, NULL, + CBM_COVERAGE_PARSE_INVALID, guard) && ok; + status = cbm_coverage_map_parse_v2(pv2_golden, PV2_SIZE(pv2_golden), NULL, 0, + &exact, NULL, NULL, NULL); + ok = status == CBM_COVERAGE_PARSE_INVALID && ok; + ok = cbm_coverage_map_find_function(guard, "src/source.c", "two") != NULL && ok; + cbm_coverage_map_free(guard); + ASSERT_TRUE(ok); + PASS(); +} + +typedef struct { size_t calls; size_t stop_at; } pv2_cancel_t; + +static bool pv2_cancel(void *context) { + pv2_cancel_t *state = context; + state->calls++; + return state->stop_at && state->calls >= state->stop_at; +} + +typedef struct { char *data; size_t used; size_t capacity; } pv2_buffer_t; + +static bool pv2_append(pv2_buffer_t *buffer, const char *text, size_t size) { + if (size > buffer->capacity - buffer->used) + return false; + memcpy(buffer->data + buffer->used, text, size); + buffer->used += size; + return true; +} + +static bool pv2_stress_input(CBMArena *arena, pv2_buffer_t *profiles, pv2_buffer_t *tests) { + const size_t name_size = 70000; + profiles->capacity = 300000; + tests->capacity = 90000; + profiles->data = cbm_arena_alloc(arena, profiles->capacity); + tests->data = cbm_arena_alloc(arena, tests->capacity); + if (!profiles->data || !tests->data) + return false; + const char header[] = PV2_HEADER("2"); + if (!pv2_append(profiles, header, PV2_SIZE(header))) + return false; + for (int row = 0; row < 2; ++row) { + const char *start = row ? "1\t" : "0\t"; + if (!pv2_append(profiles, start, 2)) + return false; + for (size_t i = 0; i < name_size; ++i) { + if (!pv2_append(profiles, "61", 2)) + return false; + } + if (row && !pv2_append(profiles, "62", 2)) + return false; + const char suffix[] = "\t0000000000000000\t1\n"; + if (!pv2_append(profiles, suffix, PV2_SIZE(suffix))) + return false; + } + const char setup[] = "s:*\tcomplete\t\t0\ns:why\tincomplete\t"; + if (!pv2_append(tests, setup, PV2_SIZE(setup))) + return false; + for (size_t i = 0; i < name_size; ++i) { + if (!pv2_append(tests, "r", 1)) + return false; + } + if (!pv2_append(tests, "\t1\n", 3)) + return false; + for (int i = 127; i >= 0; --i) { + char row[64]; + int size = snprintf(row, sizeof(row), "s:t%03d\tcomplete\t\t0 1\n", i); + if (size < 0 || (size_t)size >= sizeof(row) || !pv2_append(tests, row, (size_t)size)) + return false; + } + return true; +} + +static bool pv2_repeated_bytes(const unsigned char *bytes, size_t count, unsigned char value) { + if (!bytes) + return false; + for (size_t i = 0; i < count; ++i) { + if (bytes[i] != value) + return false; + } + return true; +} + +static bool pv2_stress_profiles(const cbm_coverage_map_t *map) { + if (!map || cbm_coverage_map_format(map) != CBM_COVERAGE_FORMAT_PROFILES || + cbm_coverage_map_id_count(map) != 2) + return false; + int count = -1; + const cbm_coverage_profile_t *rows = cbm_coverage_map_profiles(map, &count); + const cbm_coverage_profile_binding_t *binding = cbm_coverage_map_profile_binding(map); + if (!rows || count != 2 || !binding || strcmp(binding->image_sha256, PV2_IMAGE)) + return false; + for (int i = 0; i < 2; ++i) { + if (rows[i].id != i || rows[i].name_length != 70000 + (size_t)i || + rows[i].function_hash != 0 || rows[i].counter_count != 1 || + !pv2_repeated_bytes(rows[i].name, 70000, 'a')) + return false; + } + return rows[1].name[70000] == 'b'; +} + +static bool pv2_stress_setup(const cbm_coverage_test_t *row) { + return !strcmp(row->name, "*") && row->complete && row->reason[0] == '\0' && + row->function_count == 1 && row->function_ids && row->function_ids[0] == 0; +} + +static bool pv2_stress_why(const cbm_coverage_test_t *row) { + if (strcmp(row->name, "why") || row->complete || row->function_count != 1 || + !row->function_ids || row->function_ids[0] != 1) + return false; + return pv2_repeated_bytes((const unsigned char *)row->reason, 70000, 'r') && + row->reason[70000] == '\0'; +} + +static bool pv2_stress_cases(const cbm_coverage_test_t *rows) { + for (int i = 0; i < 128; ++i) { + char expected[8]; + if (snprintf(expected, sizeof(expected), "t%03d", i) != 4) + return false; + const cbm_coverage_test_t *row = &rows[i]; + if (strcmp(row->name, expected) || !row->complete || row->reason[0] != '\0' || + row->function_count != 2 || !row->function_ids || + row->function_ids[0] != 0 || row->function_ids[1] != 1) + return false; + } + return true; +} + +static bool pv2_stress_map(const cbm_coverage_map_t *map) { + if (!pv2_stress_profiles(map)) + return false; + int count = -1; + const cbm_coverage_test_t *rows = cbm_coverage_map_tests(map, &count); + if (!rows || count != 130) + return false; + for (int i = 0; i < count; ++i) { + if (!rows[i].suite || strcmp(rows[i].suite, "s") || !rows[i].name || !rows[i].reason) + return false; + } + return pv2_stress_setup(&rows[0]) && pv2_stress_cases(&rows[1]) && pv2_stress_why(&rows[129]); +} + +TEST(test_profile_v2_cancellation_and_long_fields) { + ASSERT_TRUE(pv2_native_controls()); + ASSERT_TRUE(pv2_feature_control()); + CBMArena arena; + cbm_arena_init(&arena); + pv2_buffer_t profiles = {0}, tests = {0}; + bool built = pv2_stress_input(&arena, &profiles, &tests); + if (!built) { + cbm_arena_destroy(&arena); + FAIL("could not allocate bounded cancellation fixture"); + } + cbm_coverage_parse_limits_t limits = pv2_limits(); + pv2_cancel_t control = {0}; + cbm_coverage_map_t *map = NULL; + cbm_coverage_parse_status_t status = cbm_coverage_map_parse_v2( + profiles.data, profiles.used, tests.data, tests.used, &limits, pv2_cancel, &control, &map); + bool positive = status == CBM_COVERAGE_PARSE_OK && pv2_stress_map(map) && control.calls >= 2; + cbm_coverage_map_free(map); + if (!positive) { + cbm_arena_destroy(&arena); + FAIL("uncancelled long-field control failed"); + } + bool cancelled_ok = true; + size_t positions[11] = {1, 2}; + for (size_t i = 0; i < 9; ++i) + positions[i + 2] = 1 + (control.calls - 1) / 8 * i; + positions[10] = control.calls; + for (size_t i = 0; i < sizeof(positions) / sizeof(positions[0]); ++i) { + bool duplicate = false; + for (size_t j = 0; j < i; ++j) + duplicate = duplicate || positions[j] == positions[i]; + if (duplicate) + continue; + pv2_cancel_t cancel = {.stop_at = positions[i]}; + map = NULL; + status = cbm_coverage_map_parse_v2(profiles.data, profiles.used, tests.data, tests.used, + &limits, pv2_cancel, &cancel, &map); + /* Counts/phases are not API: require cancellation whenever the predicate + * actually fired; a shorter successful traversal must remain complete. */ + bool one = cancel.calls >= cancel.stop_at + ? status == CBM_COVERAGE_PARSE_CANCELLED && map == NULL + : status == CBM_COVERAGE_PARSE_OK && pv2_stress_map(map); + if (positions[i] <= 2) + one = one && status == CBM_COVERAGE_PARSE_CANCELLED; + cancelled_ok = one && cancelled_ok; + cbm_coverage_map_free(map); + } + map = NULL; + status = pv2_parse(profiles.data, profiles.used, tests.data, tests.used, &map); + bool recovery = status == CBM_COVERAGE_PARSE_OK && pv2_stress_map(map); + cbm_coverage_map_free(map); + cbm_arena_destroy(&arena); + ASSERT_TRUE(cancelled_ok && recovery); + PASS(); +} + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t condition; + bool ready; + bool release; + int wait_error; + bool retained_ok; + cbm_coverage_parse_status_t status; + cbm_coverage_map_t *map; +} pv2_held_owner_t; + +static void *pv2_held_owner(void *context) { + pv2_held_owner_t *held = context; + held->status = pv2_parse(pv2_one, PV2_SIZE(pv2_one), pv2_one_tests, + PV2_SIZE(pv2_one_tests), &held->map); + pthread_mutex_lock(&held->mutex); + held->ready = true; + pthread_cond_broadcast(&held->condition); + while (!held->release && !held->wait_error) + held->wait_error = pthread_cond_wait(&held->condition, &held->mutex); + held->retained_ok = held->status == CBM_COVERAGE_PARSE_OK && pv2_one_shape(held->map); + cbm_coverage_map_t *map = held->map; + held->map = NULL; + pthread_mutex_unlock(&held->mutex); + cbm_coverage_map_free(map); + return NULL; +} + +static bool pv2_owner_isolation(void) { + pv2_held_owner_t held = {0}; + if (pthread_mutex_init(&held.mutex, NULL)) + return false; + if (pthread_cond_init(&held.condition, NULL)) { + pthread_mutex_destroy(&held.mutex); + return false; + } + pthread_t thread; + int created = pthread_create(&thread, NULL, pv2_held_owner, &held); + if (created) { + pthread_cond_destroy(&held.condition); + pthread_mutex_destroy(&held.mutex); + return false; + } + pthread_mutex_lock(&held.mutex); + int waited = 0; + while (!held.ready && !waited) + waited = pthread_cond_wait(&held.condition, &held.mutex); + bool first = !waited && !held.wait_error && held.status == CBM_COVERAGE_PARSE_OK && + pv2_one_shape(held.map); + cbm_coverage_map_t *second = NULL; + cbm_coverage_parse_status_t status = pv2_parse(pv2_golden, PV2_SIZE(pv2_golden), + pv2_tests, PV2_SIZE(pv2_tests), &second); + bool distinct = first && status == CBM_COVERAGE_PARSE_OK && second != held.map && + pv2_golden_shape(second); + cbm_coverage_map_t *cancelled = second; + pv2_cancel_t cancel = {.stop_at = 1}; + cbm_coverage_parse_limits_t limits = pv2_limits(); + status = cbm_coverage_map_parse_v2(pv2_golden, PV2_SIZE(pv2_golden), pv2_tests, + PV2_SIZE(pv2_tests), &limits, pv2_cancel, &cancel, &cancelled); + bool isolated = status == CBM_COVERAGE_PARSE_CANCELLED && !cancelled && + pv2_golden_shape(second) && first && pv2_one_shape(held.map); + if (cancelled != second) + cbm_coverage_map_free(cancelled); + cbm_coverage_map_free(second); + held.release = true; + pthread_cond_broadcast(&held.condition); + pthread_mutex_unlock(&held.mutex); + int joined = pthread_join(thread, NULL); + bool retained = held.retained_ok && !held.wait_error; + int cond_destroyed = pthread_cond_destroy(&held.condition); + int mutex_destroyed = pthread_mutex_destroy(&held.mutex); + return first && distinct && isolated && retained && !joined && !cond_destroyed && !mutex_destroyed; +} + +TEST(test_profile_v2_owned_bytes_and_held_owner_isolation) { + ASSERT_TRUE(pv2_native_controls()); + ASSERT_TRUE(pv2_feature_control()); + CBMArena arena; + cbm_arena_init(&arena); + char *profiles = cbm_arena_alloc(&arena, PV2_SIZE(pv2_golden)); + char *tests = cbm_arena_alloc(&arena, PV2_SIZE(pv2_tests)); + if (!profiles || !tests) { + cbm_arena_destroy(&arena); + FAIL("could not allocate ownership fixture"); + } + memcpy(profiles, pv2_golden, PV2_SIZE(pv2_golden)); + memcpy(tests, pv2_tests, PV2_SIZE(pv2_tests)); + cbm_coverage_map_t *map = NULL; + cbm_coverage_parse_status_t status = pv2_parse(profiles, PV2_SIZE(pv2_golden), tests, + PV2_SIZE(pv2_tests), &map); + memset(profiles, 0xa5, PV2_SIZE(pv2_golden)); + memset(tests, 0x5a, PV2_SIZE(pv2_tests)); + cbm_arena_destroy(&arena); + const cbm_coverage_test_t *row = map ? cbm_coverage_map_find_test(map, "core", "seen") : NULL; + bool retained = status == CBM_COVERAGE_PARSE_OK && pv2_golden_shape(map) && row && + row->complete && !strcmp(row->reason, "") && row->function_count == 2 && + row->function_ids[0] == 0 && row->function_ids[1] == 1; + cbm_coverage_map_free(map); + ASSERT_TRUE(retained); + ASSERT_TRUE(pv2_owner_isolation()); + PASS(); +} + +TEST(test_profile_v2_legacy_accessors_and_receipt_boundary) { + ASSERT_TRUE(pv2_native_controls()); + ASSERT_TRUE(pv2_feature_control()); + cbm_coverage_map_t *legacy = pv2_v1_map(); + ASSERT_NOT_NULL(legacy); + int count = -1; + const cbm_coverage_profile_t *profiles = cbm_coverage_map_profiles(legacy, &count); + const char *generic = cbm_coverage_map_identity_sha256(legacy); + const char *original = cbm_coverage_map_functions_sha256(legacy); + bool legacy_ok = cbm_coverage_map_format(legacy) == CBM_COVERAGE_FORMAT_FUNCTIONS && + cbm_coverage_map_id_count(legacy) == 2 && !profiles && count == 0 && + !cbm_coverage_map_profile_binding(legacy) && generic && original && + !strcmp(generic, original) && !strcmp(original, pv2_receipt().functions_sha256); + cbm_coverage_map_free(legacy); + ASSERT_TRUE(legacy_ok); + cbm_coverage_map_t *map = NULL; + cbm_coverage_parse_status_t status = pv2_parse(pv2_golden, PV2_SIZE(pv2_golden), + pv2_tests, PV2_SIZE(pv2_tests), &map); + count = -1; + const cbm_coverage_function_t *functions = map ? cbm_coverage_map_functions(map, &count) : NULL; + cbm_coverage_receipt_t receipt = pv2_receipt(); + cbm_coverage_receipt_context_t context = pv2_receipt_context(); + bool unavailable = status == CBM_COVERAGE_PARSE_OK && map && !functions && count == 0 && + !cbm_coverage_map_find_function(map, "src/source.c", "one") && + !cbm_coverage_map_functions_sha256(map) && + !cbm_coverage_map_metadata_matches(map, pv2_v1_meta, + PV2_SIZE(pv2_v1_meta), receipt.commit); + unsigned refused = cbm_coverage_map_check_receipt(map, pv2_v1_meta, PV2_SIZE(pv2_v1_meta), + &receipt, &context); + unsigned refused_null = cbm_coverage_map_check_receipt(map, NULL, 0, NULL, NULL); + cbm_coverage_map_free(map); + count = 123; + bool null_ok = cbm_coverage_map_format(NULL) == CBM_COVERAGE_FORMAT_NONE && + cbm_coverage_map_id_count(NULL) == 0 && !cbm_coverage_map_identity_sha256(NULL) && + !cbm_coverage_map_profile_binding(NULL) && + !cbm_coverage_map_profiles(NULL, &count) && count == 0 && + !cbm_coverage_map_profiles(NULL, NULL); + ASSERT_TRUE(unavailable && null_ok); + ASSERT_EQ(refused, CBM_COVERAGE_RECEIPT_INVALID | CBM_COVERAGE_RECEIPT_METADATA); + ASSERT_EQ(refused_null, CBM_COVERAGE_RECEIPT_INVALID | CBM_COVERAGE_RECEIPT_METADATA); + PASS(); +} + +/* These exact files are also checked against the pure Python producer output. */ +static bool pv2_shared_read(const char *path, void *bytes, size_t expected) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) + return false; + size_t count = fread(bytes, 1, expected, file); + int extra = fgetc(file); + bool ok = count == expected && extra == EOF && !ferror(file); + if (fclose(file) != 0) + ok = false; + return ok; +} + +static bool pv2_shared_binding(const cbm_coverage_map_t *map) { + const cbm_coverage_profile_binding_t *binding = cbm_coverage_map_profile_binding(map); + const char *digest = cbm_coverage_map_identity_sha256(map); + return cbm_coverage_map_format(map) == CBM_COVERAGE_FORMAT_PROFILES && + cbm_coverage_map_id_count(map) == 3 && binding && + !strcmp(binding->image_sha256, PV2_IMAGE) && digest && + !strcmp(digest, "ecc78b966bef4eb27a144eba98c2794ebf5f31a318f269e5e21d3453afd02d76"); +} + +static bool pv2_shared_profiles(const cbm_coverage_map_t *map) { + static const unsigned char opaque_name[] = {0xff, ':', '\t', ' ', 'z'}; + int count = -1; + const cbm_coverage_profile_t *profiles = cbm_coverage_map_profiles(map, &count); + if (!profiles || count != 3 || !pv2_shared_binding(map)) + return false; + for (int i = 0; i < 2; ++i) { + if (profiles[i].id != i || !profiles[i].name || profiles[i].name_length != 1 || + profiles[i].name[0] != 'a') + return false; + } + return profiles[0].function_hash == 0 && profiles[0].counter_count == 2 && + profiles[1].function_hash == UINT64_C(0x8000000000000000) && + profiles[1].counter_count == 1 && profiles[2].id == 2 && + profiles[2].name && profiles[2].name_length == sizeof(opaque_name) && + !memcmp(profiles[2].name, opaque_name, sizeof(opaque_name)) && + profiles[2].function_hash == UINT64_MAX && profiles[2].counter_count == 2; +} + +static bool pv2_shared_empty_row(const cbm_coverage_test_t *row, const char *name, + bool complete, const char *reason) { + return row->suite && !strcmp(row->suite, "s\xfe") && row->name && + !strcmp(row->name, name) && row->complete == complete && row->reason && + !strcmp(row->reason, reason) && row->function_count == 0; +} + +static bool pv2_shared_hit_row(const cbm_coverage_test_t *row) { + return row->suite && !strcmp(row->suite, "s\xfe") && row->name && + !strcmp(row->name, "hit:raw\xfd") && row->complete && row->reason && + !strcmp(row->reason, "") && row->function_count == 2 && row->function_ids && + row->function_ids[0] == 0 && row->function_ids[1] == 2; +} + +static bool pv2_shared_intersections(const cbm_coverage_test_t *rows) { + int unobserved = 1; + for (int i = 0; i < 4; ++i) { + if (cbm_coverage_test_intersects(&rows[i], &unobserved, 1)) + return false; + } + for (int id = 0; id <= 2; id += 2) { + for (int i = 0; i < 4; ++i) { + if (cbm_coverage_test_intersects(&rows[i], &id, 1) != (i == 1)) + return false; + } + } + return true; +} + +static bool pv2_shared_tests(const cbm_coverage_map_t *map) { + int count = -1; + const cbm_coverage_test_t *rows = cbm_coverage_map_tests(map, &count); + if (!rows || count != 4) + return false; + return pv2_shared_empty_row(&rows[0], "*", true, "") && + pv2_shared_hit_row(&rows[1]) && + pv2_shared_empty_row(&rows[2], "missing", false, "missing_parent") && + pv2_shared_empty_row(&rows[3], "quiet", true, "") && + cbm_coverage_map_find_test(map, "s\xfe", "hit:raw\xfd") == &rows[1] && + pv2_shared_intersections(rows); +} + +TEST(test_profile_v2_shared_producer_fixture) { + ASSERT_TRUE(pv2_native_controls()); + ASSERT_TRUE(pv2_feature_control()); + unsigned char profiles[165]; + char tests[100]; + if (!pv2_shared_read("tests/fixtures/profile_map_v2/profiles.tsv", profiles, sizeof(profiles))) + FAIL("shared profiles.tsv fixture is missing, unreadable, or not exactly 165 bytes"); + if (!pv2_shared_read("tests/fixtures/profile_map_v2/tests.tsv", tests, sizeof(tests))) + FAIL("shared tests.tsv fixture is missing, unreadable, or not exactly 100 bytes"); + cbm_coverage_map_t *map = NULL; + cbm_coverage_parse_status_t status = + pv2_parse(profiles, sizeof(profiles), tests, sizeof(tests), &map); + bool profiles_ok = status == CBM_COVERAGE_PARSE_OK && pv2_shared_profiles(map); + bool tests_ok = status == CBM_COVERAGE_PARSE_OK && pv2_shared_tests(map); + cbm_coverage_map_free(map); + ASSERT_TRUE(profiles_ok); + ASSERT_TRUE(tests_ok); + PASS(); +} + +SUITE(test_impact_profiles) { + RUN_TEST(test_profile_v2_exact_golden_and_unobserved_ids); + RUN_TEST(test_profile_v2_identity_order_and_duplicates); + RUN_TEST(test_profile_v2_strict_wire_and_all_truncations); + RUN_TEST(test_profile_v2_test_rows_and_zero_universe); + RUN_TEST(test_profile_v2_limits_and_invalid_arguments); + RUN_TEST(test_profile_v2_cancellation_and_long_fields); + RUN_TEST(test_profile_v2_owned_bytes_and_held_owner_isolation); + RUN_TEST(test_profile_v2_legacy_accessors_and_receipt_boundary); + RUN_TEST(test_profile_v2_shared_producer_fixture); +} diff --git a/tests/test_test_impact_runner_filter.c b/tests/test_test_impact_runner_filter.c new file mode 100644 index 000000000..3907798f3 --- /dev/null +++ b/tests/test_test_impact_runner_filter.c @@ -0,0 +1,170 @@ +/* This consumer boundary does not replace the broader result-codec suite. */ +#include "test_framework.h" +#include "test_test_impact_runner_filter.h" + +static bool rf_clean_run(const rf_run_t *run, int expected) { + return run->process.outcome == CBM_PROC_CLEAN && run->process.exit_code == 0 && + run->passed == expected && run->pass_lines == expected && + !strstr(run->text, "malformed test selection token:") && + !strstr(run->text, "selected test not compiled into this build or unknown:"); +} + +static bool rf_baseline(int *count) { + rf_fixture_t fixture; + rf_run_t run = {0}; + bool opened = rf_fixture_open(&fixture); + bool ran = opened && rf_run_child(&fixture, "str_intern", NULL, false, &run); + bool ok = ran && run.passed > 0 && rf_clean_run(&run, run.passed); + *count = run.passed; + bool removed = rf_fixture_close(&fixture); + return ok && removed; +} + +static bool rf_consume(const rf_spec_t *spec, const char *expected, int count) { + rf_fixture_t fixture; + rf_run_t run = {0}; + bool prepared = rf_fixture_open(&fixture) && rf_prepare(&fixture, spec); + cbm_test_result_status_t status = + prepared ? rf_build(&fixture, RF_JSON_CAP, false) : CBM_TEST_RESULT_INVALID; + bool generated = status == CBM_TEST_RESULT_OK && rf_exact_filter(&fixture, expected); + bool ran = generated && rf_run_child(&fixture, "dyn_array", "str_intern", true, &run); + bool ok = ran && rf_clean_run(&run, count); + if (!spec->whole) { + ok = ok && strstr(run.text, " da_last ") && !strstr(run.text, " da_clear "); + } + if (!ok) { + printf(" runner filter consumer: suite=%s whole=%d mixed=%d status=%d passed=%d\n", + spec->suite, spec->whole, spec->mixed, status, run.passed); + } + bool removed = rf_fixture_close(&fixture); + return ok && removed; +} + +TEST(test_impact_runner_filter_consumes_public_codec_bytes) { + int whole_count = 0; + bool baseline = rf_baseline(&whole_count); + const rf_spec_t whole = {.suite = "str_intern", .test = "rf_case", .whole = true}; + const rf_spec_t individual = {.suite = "dyn_array", .test = "da_last"}; + const rf_spec_t mixed = {.suite = "dyn_array", .test = "da_last", .mixed = true}; + bool whole_ok = baseline && rf_consume(&whole, "str_intern\n", whole_count); + bool individual_ok = baseline && rf_consume(&individual, "dyn_array:da_last\n", 1); + bool mixed_ok = + baseline && rf_consume(&mixed, "dyn_array:da_last\nstr_intern\n", whole_count + 1); + ASSERT_TRUE(baseline); + ASSERT_TRUE(whole_ok); + ASSERT_TRUE(individual_ok); + ASSERT_TRUE(mixed_ok); + PASS(); +} + +static bool rf_unknown_run(rf_fixture_t *fixture, const char *token, size_t length) { + rf_run_t run = {0}; + char diagnostic[640]; + int size = + snprintf(diagnostic, sizeof(diagnostic), + "selected test not compiled into this build or unknown: %.*s", (int)length, token); + bool ran = rf_run_child(fixture, "dyn_array", NULL, true, &run); + return size > 0 && (size_t)size < sizeof(diagnostic) && ran && + run.process.outcome == CBM_PROC_EXIT_NONZERO && run.process.exit_code != 0 && + run.pass_lines == 0 && strstr(run.text, diagnostic) && + !strstr(run.text, "malformed test selection token:"); +} + +static void rf_identifier(char *name, size_t length) { + memset(name, 'q', length); + name[length] = '\0'; +} + +static bool rf_width_case(bool whole, size_t width) { + char name[513]; + rf_identifier(name, whole ? width : width - strlen("dyn_array:")); + rf_spec_t spec = { + .suite = whole ? name : "dyn_array", .test = whole ? "rf_case" : name, .whole = whole}; + char expected[515]; + int length = snprintf(expected, sizeof(expected), "%s%s%s\n", spec.suite, whole ? "" : ":", + whole ? "" : spec.test); + rf_fixture_t fixture; + bool prepared = rf_fixture_open(&fixture) && rf_prepare(&fixture, &spec); + cbm_test_result_status_t status = + prepared ? rf_build(&fixture, RF_JSON_CAP, false) : CBM_TEST_RESULT_INVALID; + bool ok; + if (width == 511) { + ok = length == 512 && status == CBM_TEST_RESULT_OK && rf_exact_filter(&fixture, expected) && + rf_unknown_run(&fixture, expected, width); + } else { + ok = length == 513 && status == CBM_TEST_RESULT_UNSUPPORTED && fixture.result == NULL; + } + if (!ok) { + printf(" runner filter width: whole=%d width=%zu status=%d\n", whole, width, status); + } + bool removed = rf_fixture_close(&fixture); + return ok && removed; +} + +TEST(test_impact_runner_filter_literal_width_boundaries) { + /* Literal wire boundaries keep this test sensitive to a changed shared constant. */ + bool whole_511 = rf_width_case(true, 511); + bool whole_512 = rf_width_case(true, 512); + bool individual_511 = rf_width_case(false, 511); + bool individual_512 = rf_width_case(false, 512); + ASSERT_TRUE(whole_511); + ASSERT_TRUE(whole_512); + ASSERT_TRUE(individual_511); + ASSERT_TRUE(individual_512); + PASS(); +} + +static bool rf_limit_control(void) { + rf_fixture_t fixture; + const rf_spec_t spec = {.suite = "dyn_array", .test = "da_last"}; + bool prepared = rf_fixture_open(&fixture) && rf_prepare(&fixture, &spec); + cbm_test_result_status_t limited = + prepared ? rf_build(&fixture, 1, false) : CBM_TEST_RESULT_INVALID; + bool empty = fixture.result == NULL; + cbm_test_result_status_t full = + prepared ? rf_build(&fixture, RF_JSON_CAP, false) : CBM_TEST_RESULT_INVALID; + bool ok = limited == CBM_TEST_RESULT_LIMIT && empty && full == CBM_TEST_RESULT_OK && + rf_exact_filter(&fixture, "dyn_array:da_last\n"); + bool removed = rf_fixture_close(&fixture); + return ok && removed; +} + +static bool rf_diagnostic_control(void) { + char name[513]; + rf_identifier(name, 512); + const rf_spec_t spec = {.suite = name, .test = "rf_case", .whole = true}; + rf_fixture_t fixture; + bool prepared = rf_fixture_open(&fixture) && rf_prepare(&fixture, &spec); + cbm_test_result_status_t status = + prepared ? rf_build(&fixture, RF_JSON_CAP, true) : CBM_TEST_RESULT_INVALID; + bool parsed = status == CBM_TEST_RESULT_OK && rf_parse(&fixture); + yyjson_val *root = yyjson_doc_get_root(fixture.json); + yyjson_val *lane = rf_lane(&fixture); + yyjson_val *suites = yyjson_obj_get(lane, "suites"); + yyjson_val *suite = yyjson_arr_get(suites, 0); + yyjson_val *reasons = yyjson_obj_get(root, "run_all_reasons"); + bool ok = parsed && yyjson_equals_str(yyjson_obj_get(root, "decision"), "run_all") && + yyjson_equals_str(yyjson_obj_get(lane, "decision"), "run_all") && + yyjson_is_null(yyjson_obj_get(lane, "runner_filter")) && + yyjson_arr_size(suites) == 1 && + yyjson_equals_str(yyjson_obj_get(suite, "suite"), name) && + yyjson_equals_str(yyjson_obj_get(suite, "mode"), "whole") && + yyjson_arr_size(reasons) == 1 && + yyjson_equals_str(yyjson_arr_get(reasons, 0), "GRAPH_UNAVAILABLE"); + bool removed = rf_fixture_close(&fixture); + return ok && removed; +} + +TEST(test_impact_runner_filter_limits_and_diagnostic_domain) { + bool limit_ok = rf_limit_control(); + bool diagnostic_ok = rf_diagnostic_control(); + ASSERT_TRUE(limit_ok); + ASSERT_TRUE(diagnostic_ok); + PASS(); +} + +SUITE(test_impact_runner_filter) { + RUN_TEST(test_impact_runner_filter_consumes_public_codec_bytes); + RUN_TEST(test_impact_runner_filter_literal_width_boundaries); + RUN_TEST(test_impact_runner_filter_limits_and_diagnostic_domain); +} diff --git a/tests/test_test_impact_runner_filter.h b/tests/test_test_impact_runner_filter.h new file mode 100644 index 000000000..8bee58858 --- /dev/null +++ b/tests/test_test_impact_runner_filter.h @@ -0,0 +1,50 @@ +#ifndef TEST_TEST_IMPACT_RUNNER_FILTER_H +#define TEST_TEST_IMPACT_RUNNER_FILTER_H + +#include +#include +#include + +enum { RF_PATH_CAP = 1200, RF_OUTPUT_CAP = 32768, RF_JSON_CAP = 65536 }; + +typedef struct { + const char *suite; + const char *test; + bool whole; + bool mixed; +} rf_spec_t; + +typedef struct { + char root[1024]; + char filter_path[RF_PATH_CAP]; + char log_path[RF_PATH_CAP]; + cbm_test_model_t *model; + cbm_coverage_map_t *coverage; + cbm_test_config_t *config; + cbm_test_policy_t *policy; + cbm_test_selection_t *selection; + cbm_test_result_t *result; + yyjson_doc *json; + const char *filter; + size_t filter_length; +} rf_fixture_t; + +typedef struct { + cbm_proc_result_t process; + char text[RF_OUTPUT_CAP]; + int passed; + int pass_lines; +} rf_run_t; + +void tf_test_impact_runner_filter_set_binary(const char *path); +bool rf_fixture_open(rf_fixture_t *fixture); +bool rf_fixture_close(rf_fixture_t *fixture); +bool rf_prepare(rf_fixture_t *fixture, const rf_spec_t *spec); +cbm_test_result_status_t rf_build(rf_fixture_t *fixture, size_t output_limit, bool fallback); +bool rf_parse(rf_fixture_t *fixture); +yyjson_val *rf_lane(const rf_fixture_t *fixture); +bool rf_exact_filter(rf_fixture_t *fixture, const char *expected); +bool rf_run_child(rf_fixture_t *fixture, const char *suite, const char *other, bool filtered, + rf_run_t *run); + +#endif diff --git a/tests/test_test_impact_runner_filter_fixture.c b/tests/test_test_impact_runner_filter_fixture.c new file mode 100644 index 000000000..16dbff2e2 --- /dev/null +++ b/tests/test_test_impact_runner_filter_fixture.c @@ -0,0 +1,197 @@ +/* Public-API fixtures only: synthetic facts never establish provider admission. */ +#include "test_test_impact_runner_filter.h" +#include "test_helpers.h" + +bool rf_fixture_open(rf_fixture_t *fixture) { + memset(fixture, 0, sizeof(*fixture)); + const char *root = th_mktempdir("cbm-runner-filter"); + if (!root || strlen(root) >= sizeof(fixture->root)) { + return false; + } + memcpy(fixture->root, root, strlen(root) + 1); + int a = snprintf(fixture->filter_path, sizeof(fixture->filter_path), "%s/filter.txt", root); + int b = snprintf(fixture->log_path, sizeof(fixture->log_path), "%s/child.log", root); + return a > 0 && (size_t)a < sizeof(fixture->filter_path) && b > 0 && + (size_t)b < sizeof(fixture->log_path); +} + +bool rf_fixture_close(rf_fixture_t *fixture) { + yyjson_doc_free(fixture->json); + cbm_test_result_free(fixture->result); + cbm_test_selection_free(fixture->selection); + cbm_test_policy_free(fixture->policy); + cbm_test_config_free(fixture->config); + cbm_coverage_map_free(fixture->coverage); + cbm_test_model_free(fixture->model); + bool removed = !fixture->root[0] || th_rmtree(fixture->root) == 0; + memset(fixture, 0, sizeof(*fixture)); + return removed; +} + +static bool rf_model(rf_fixture_t *fixture, const rf_spec_t *spec) { + char source[8192]; + int count = snprintf(source, sizeof(source), + "TEST(%s) {}\nTEST(rf_spare) {}\n" + "SUITE(%s) { RUN_TEST(%s); RUN_TEST(rf_spare); }\n" + "void main(void) { RUN_SELECTED_SUITE(%s); }\n", + spec->test, spec->suite, spec->test, spec->suite); + fixture->model = cbm_test_model_new(cbm_test_conventions_cbm()); + if (count < 0 || (size_t)count >= sizeof(source) || !fixture->model || + !cbm_test_model_add_source(fixture->model, "tests/rf_subject.c", source, (size_t)count)) { + return false; + } + static const char other[] = "TEST(rf_other) {}\nSUITE(str_intern) { RUN_TEST(rf_other); }\n" + "void other_main(void) { RUN_SELECTED_SUITE(str_intern); }\n"; + if (spec->mixed && + !cbm_test_model_add_source(fixture->model, "tests/rf_whole.c", other, sizeof(other) - 1)) { + return false; + } + return cbm_test_model_finish(fixture->model) && cbm_test_model_complete(fixture->model); +} + +static bool rf_selection(rf_fixture_t *fixture, const rf_spec_t *spec) { + char rows[4096]; + int count = snprintf(rows, sizeof(rows), + "%s:*\tcomplete\t\t1\n%s:%s\tcomplete\t\t0\n" + "%s:rf_spare\tcomplete\t\t1\n%s", + spec->suite, spec->suite, spec->test, spec->suite, + spec->mixed ? "str_intern:*\tcomplete\t\t1\n" + "str_intern:rf_other\tcomplete\t\t1\n" + : ""); + static const char functions[] = "0\tsrc/changed.c\tchanged\n1\tsrc/quiet.c\tquiet\n"; + if (count < 0 || (size_t)count >= sizeof(rows)) { + return false; + } + fixture->coverage = + cbm_coverage_map_parse(functions, sizeof(functions) - 1, rows, (size_t)count); + cbm_test_reach_t reach[] = { + {.file = "tests/rf_subject.c", .test = spec->test, .mapped = true, .reached = true}, + {.file = "tests/rf_subject.c", .test = "rf_spare", .mapped = true}, + {.file = "tests/rf_whole.c", .test = "rf_other", .mapped = true}, + }; + cbm_test_suite_trigger_t trigger = {.suite = spec->mixed ? "str_intern" : spec->suite, + .reached = true}; + const int changed = 0; + cbm_test_selection_input_t input = { + .model = fixture->model, + .coverage = fixture->coverage, + .reach = reach, + .reach_count = spec->mixed ? 3 : 2, + .suite_triggers = &trigger, + .suite_trigger_count = spec->whole || spec->mixed ? 1 : 0, + .changed_function_ids = &changed, + .changed_function_count = 1, + .has_changes = true, + .diff_complete = true, + .inventory_complete = true, + .static_complete = true, + .coverage_admitted = true, + .coverage_changes_complete = true, + }; + if (!fixture->coverage) { + return false; + } + fixture->selection = cbm_test_select(&input); + return fixture->selection && cbm_test_selection_run_all(fixture->selection) == 0; +} + +bool rf_prepare(rf_fixture_t *fixture, const rf_spec_t *spec) { + char absent[RF_PATH_CAP]; + int count = snprintf(absent, sizeof(absent), "%s/absent-policy.json", fixture->root); + if (count < 0 || (size_t)count >= sizeof(absent) || !rf_model(fixture, spec) || + !rf_selection(fixture, spec)) { + return false; + } + fixture->config = cbm_test_config_load(absent, true); + fixture->policy = fixture->config ? cbm_test_policy_new(fixture->config) : NULL; + return fixture->policy != NULL; +} + +static bool rf_policy_digest(const cbm_test_policy_t *policy, cbm_test_result_digest_t *digest) { + const char *text = cbm_test_policy_digest(policy); + if (!text || strlen(text) != 64) { + return false; + } + static const char digits[] = "0123456789abcdef"; + for (size_t i = 0; i < 32; i++) { + const char *high = strchr(digits, text[i * 2]); + const char *low = strchr(digits, text[i * 2 + 1]); + if (!high || !low) { + return false; + } + digest->bytes[i] = (unsigned char)((high - digits) * 16 + (low - digits)); + } + digest->present = true; + return true; +} + +cbm_test_result_status_t rf_build(rf_fixture_t *fixture, size_t output_limit, bool fallback) { + yyjson_doc_free(fixture->json); + fixture->json = NULL; + cbm_test_result_free(fixture->result); + fixture->result = NULL; + fixture->filter = NULL; + fixture->filter_length = 0; + cbm_test_result_receipt_t receipt = {0}; + receipt.object_format = CBM_TEST_RESULT_OBJECT_SHA1; + receipt.base.format = CBM_TEST_RESULT_OBJECT_SHA1; + receipt.head.format = CBM_TEST_RESULT_OBJECT_SHA1; + receipt.merge_base.format = CBM_TEST_RESULT_OBJECT_SHA1; + receipt.diff_sha256.present = true; + receipt.name_status_sha256.present = true; + if (!rf_policy_digest(fixture->policy, &receipt.policy_sha256)) { + return CBM_TEST_RESULT_INVALID; + } + cbm_test_result_fallback_t reason = CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE; + cbm_test_result_input_t input = { + .comparison = CBM_TEST_RESULT_COMPARISON_CHANGED, + .selection = fixture->selection, + .model = fixture->model, + .policy = fixture->policy, + .inventory_complete = true, + .activation_complete = true, + .receipt = &receipt, + .fallbacks = &reason, + .fallback_count = fallback ? 1 : 0, + }; + cbm_test_result_limits_t limits = { + .max_input_bytes = 1048576, + .max_items = 100000, + .max_alloc_bytes = 16777216, + .max_output_bytes = output_limit, + }; + return cbm_test_result_build(&input, &limits, NULL, NULL, &fixture->result); +} + +bool rf_parse(rf_fixture_t *fixture) { + size_t length = 0; + const char *json = cbm_test_result_json(fixture->result, &length); + if (!json || !length || length > RF_JSON_CAP) { + return false; + } + fixture->json = yyjson_read(json, length, 0); + return fixture->json != NULL; +} + +yyjson_val *rf_lane(const rf_fixture_t *fixture) { + yyjson_val *root = yyjson_doc_get_root(fixture->json); + yyjson_val *lanes = yyjson_obj_get(root, "lanes"); + return yyjson_arr_size(lanes) == 1 ? yyjson_arr_get(lanes, 0) : NULL; +} + +bool rf_exact_filter(rf_fixture_t *fixture, const char *expected) { + if (!rf_parse(fixture)) { + return false; + } + yyjson_val *root = yyjson_doc_get_root(fixture->json); + yyjson_val *lane = rf_lane(fixture); + yyjson_val *filter = yyjson_obj_get(lane, "runner_filter"); + yyjson_val *value = yyjson_obj_get(filter, "value"); + fixture->filter = yyjson_get_str(value); + fixture->filter_length = yyjson_get_len(value); + return yyjson_equals_str(yyjson_obj_get(root, "decision"), "selected") && + yyjson_equals_str(yyjson_obj_get(lane, "decision"), "selected") && + yyjson_equals_str(yyjson_obj_get(filter, "encoding"), "cbm-test-only-lines") && + fixture->filter && fixture->filter_length == strlen(expected) && + memcmp(fixture->filter, expected, fixture->filter_length) == 0; +} diff --git a/tests/test_test_impact_runner_filter_process.c b/tests/test_test_impact_runner_filter_process.c new file mode 100644 index 000000000..a203c76ce --- /dev/null +++ b/tests/test_test_impact_runner_filter_process.c @@ -0,0 +1,164 @@ +/* Execute only existing canonical suites; no shell or private child command. */ +#include "test_test_impact_runner_filter.h" +#include "test_helpers.h" + +static const char *rf_binary; + +void tf_test_impact_runner_filter_set_binary(const char *path) { + rf_binary = path; +} + +typedef struct { + const char *name; + char *value; + bool present; +} rf_environment_t; + +static bool rf_environment_save(rf_environment_t *saved, const char *name) { + saved->name = name; + const char *value = getenv(name); + saved->present = value != NULL; + saved->value = value ? cbm_strdup(value) : NULL; + return !saved->present || saved->value != NULL; +} + +static bool rf_environment_restore(rf_environment_t *saved) { + if (!saved->name) { + return true; + } + bool ok = false; + if (saved->present) { + ok = saved->value && cbm_setenv(saved->name, saved->value, 1) == 0; + } else { + ok = cbm_unsetenv(saved->name) == 0; + } + free(saved->value); + memset(saved, 0, sizeof(*saved)); + return ok; +} + +static bool rf_write_filter(const rf_fixture_t *fixture) { + FILE *file = cbm_fopen(fixture->filter_path, "wb"); + if (!file) { + return false; + } + bool written = + fwrite(fixture->filter, 1, fixture->filter_length, file) == fixture->filter_length; + bool closed = fclose(file) == 0; + return written && closed; +} + +static bool rf_spawn(const rf_fixture_t *fixture, const char *const *argv, bool filtered, + cbm_subprocess_t **process) { + rf_environment_t saved[3] = {0}; + bool captured = rf_environment_save(&saved[0], "CBM_TEST_ONLY") && + rf_environment_save(&saved[1], "CBM_TEST_ONLY_FILE") && + rf_environment_save(&saved[2], "CBM_TEST_COVERAGE_DIR"); + /* The child keeps its inherited LLVM profile, attributed to this test. + * It must not start a second per-test collection with that continuous file. */ + bool set = captured && cbm_unsetenv("CBM_TEST_COVERAGE_DIR") == 0 && + cbm_setenv("CBM_TEST_ONLY", "", 1) == 0 && + cbm_setenv("CBM_TEST_ONLY_FILE", filtered ? fixture->filter_path : "", 1) == 0; + cbm_proc_opts_t options = { + .bin = rf_binary, + .argv = argv, + .log_file = fixture->log_path, + .quiet_timeout_ms = 10000, + .cancel_grace_ms = 1000, + }; + int spawned = set ? cbm_subprocess_spawn(&options, process) : -1; + /* Restore before polling or assertions, including failed spawn/set paths. */ + bool first = rf_environment_restore(&saved[0]); + bool second = rf_environment_restore(&saved[1]); + bool third = rf_environment_restore(&saved[2]); + return spawned == 0 && first && second && third; +} + +static bool rf_wait(cbm_subprocess_t *process, bool setup_ok, cbm_proc_result_t *result) { + uint64_t started = cbm_now_ms(); + bool cancelled = !setup_ok; + if (cancelled) { + (void)cbm_subprocess_request_cancel(process); + } + for (;;) { + cbm_proc_poll_t state = cbm_subprocess_poll(process, result); + if (state == CBM_PROC_POLL_TERMINAL) { + if (!result->tree_quiesced || result->supervision_failed) { + fprintf(stderr, + "runner-filter: critical child containment failure; retaining files\n"); + exit(2); + } + cbm_subprocess_destroy(process); + return !cancelled; + } + uint64_t elapsed = cbm_now_ms() - started; + if (state == CBM_PROC_POLL_ERROR || elapsed >= 30000) { + cancelled = true; + (void)cbm_subprocess_request_cancel(process); + } + if (elapsed >= 35000) { + /* Never destroy a running owner or remove files under a live child. */ + fprintf(stderr, "runner-filter: child did not quiesce; retaining owner and files\n"); + exit(2); + } + cbm_usleep(1000); + } +} + +static bool rf_read_output(const char *path, char *text, size_t capacity) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + return false; + } + size_t length = fread(text, 1, capacity - 1, file); + bool complete = !ferror(file) && fgetc(file) == EOF && !ferror(file); + bool closed = fclose(file) == 0; + size_t kept = 0; + for (size_t i = 0; i < length; i++) { + if (text[i] != '\r') { + text[kept++] = text[i]; + } + } + text[kept] = '\0'; + return complete && closed && memchr(text, '\0', kept) == NULL; +} + +static void rf_count_output(rf_run_t *run) { + run->passed = -1; + const char *line = run->text; + while (*line) { + const char *end = strchr(line, '\n'); + size_t length = end ? (size_t)(end - line) : strlen(line); + if (length >= 4 && memcmp(line + length - 4, "PASS", 4) == 0) { + run->pass_lines++; + } + int count = -1; + int used = 0; + if (sscanf(line, " %d passed%n", &count, &used) == 1 && used > 0) { + run->passed = count; + } + line += length + (end != NULL); + } +} + +bool rf_run_child(rf_fixture_t *fixture, const char *suite, const char *other, bool filtered, + rf_run_t *run) { + memset(run, 0, sizeof(*run)); + run->passed = -1; + if (!rf_binary || !rf_binary[0] || + (filtered && (!fixture->filter || !fixture->filter_length || !rf_write_filter(fixture)))) { + return false; + } + const char *argv[] = {rf_binary, suite, other, NULL}; + cbm_subprocess_t *process = NULL; + bool spawned = rf_spawn(fixture, argv, filtered, &process); + if (!process) { + return false; + } + bool ended = rf_wait(process, spawned, &run->process); + bool read = rf_read_output(fixture->log_path, run->text, sizeof(run->text)); + if (read) { + rf_count_output(run); + } + return spawned && ended && read; +} diff --git a/tests/test_test_impact_tree.c b/tests/test_test_impact_tree.c new file mode 100644 index 000000000..15765cf46 --- /dev/null +++ b/tests/test_test_impact_tree.c @@ -0,0 +1,3096 @@ +/* Independent acceptance for the frozen pinned-tree contract. + * All repository mutations below belong to private fixture repositories. */ +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#include +#include +#else +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#include +#else +#include +#endif +#endif + +enum { PT_PATH = 4096, PT_ROWS = 12, PT_LARGE = 131073 }; +static const unsigned char pt_binary[] = {'A', 0, '\r', '\n', 255, 'Z'}; +static const unsigned char pt_shared[] = "shared\n"; +static const unsigned char pt_before[] = "before\n"; +static const unsigned char pt_after[] = "after!\n"; + +typedef struct { + const char *path; + uint32_t mode; + const unsigned char *bytes; + size_t length; + char oid[65]; +} pt_row; + +typedef struct { + CBMArena arena; + char home[PT_PATH], repo[PT_PATH], parent[PT_PATH], git[PT_PATH]; + char capture[PT_PATH], log[PT_PATH], input[PT_PATH]; + char a[65], p[65], q[65], head[65], base[65], before_oid[65]; + unsigned width; + unsigned char *large; + pt_row rows[PT_ROWS]; + size_t count; + cbm_git_facts_t *facts; + cbm_pinned_tree_t *tree, *second; +} pt_fixture; + +#define PT_CHECK(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "pinned-tree assertion %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + goto done; \ + } \ + } while (0) + +/* Diagnostics preserve the operation's result and its last-error state. */ +static bool pt_setup_step(const char *stage, bool ok) { + if (ok) + return true; + int saved_errno = errno; +#ifdef _WIN32 + DWORD saved_error = GetLastError(); + fprintf(stderr, "pinned-tree fixture stage=%s errno=%d win32=%lu\n", stage, saved_errno, + (unsigned long)saved_error); + SetLastError(saved_error); +#else + fprintf(stderr, "pinned-tree fixture stage=%s errno=%d\n", stage, saved_errno); +#endif + errno = saved_errno; + return false; +} + +static void pt_diag_bytes(const unsigned char *bytes, size_t length) { + for (size_t i = 0; i < length; i++) { + unsigned char c = bytes[i]; + if (c >= 32 && c <= 126 && c != '\\' && c != '\'') + fputc(c, stderr); + else + fprintf(stderr, "\\x%02x", (unsigned)c); + } +} + +static void pt_diag_git_tail(const char *const *tail) { + fputs("pinned-tree fixture git tail:", stderr); + size_t i = 0; + for (; i < 12 && tail[i]; i++) { + size_t length = 0; + while (length < 64 && tail[i][length]) + length++; + fputs(" '", stderr); + pt_diag_bytes((const unsigned char *)tail[i], length); + fputs(length == 64 ? "'[possibly truncated]" : "'", stderr); + } + fputs(i == 12 ? " [remaining arguments omitted]\n" : "\n", stderr); +} + +static void pt_diag_git_stderr(const char *path) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + fprintf(stderr, "pinned-tree fixture stderr unavailable errno=%d\n", errno); + return; + } + unsigned char bytes[2048]; + bool seek_ok = fseek(file, 0, SEEK_END) == 0; + long end = seek_ok ? ftell(file) : -1; + long start = end > (long)sizeof(bytes) ? end - (long)sizeof(bytes) : 0; + if (end < 0 || fseek(file, start, SEEK_SET) != 0) { + fprintf(stderr, "pinned-tree fixture stderr seek failed errno=%d\n", errno); + (void)fclose(file); + return; + } + size_t length = fread(bytes, 1, sizeof(bytes), file); + int read_error = ferror(file); + fprintf(stderr, "pinned-tree fixture stderr tail offset=%ld bytes=%zu read_error=%d: '", start, + length, read_error); + pt_diag_bytes(bytes, length); + fputs("' (capture may predate an unsuccessful spawn)\n", stderr); + if (fclose(file) != 0) + fprintf(stderr, "pinned-tree fixture stderr close failed errno=%d\n", errno); +} + +static bool pt_git_failed(const pt_fixture *fx, const char *const *tail, const char *stage, + int run_rc, const cbm_proc_result_t *result) { + int saved_errno = errno; +#ifdef _WIN32 + DWORD saved_error = GetLastError(); +#endif + fprintf(stderr, + "pinned-tree fixture git failure stage=%s run_rc=%d outcome=%d exit=%d " + "quiesced=%d supervision_failed=%d errno=%d\n", + stage, run_rc, result ? (int)result->outcome : -1, result ? result->exit_code : -1, + result ? (int)result->tree_quiesced : 0, result ? (int)result->supervision_failed : 0, + saved_errno); + pt_diag_git_tail(tail); + pt_diag_git_stderr(fx->log); +#ifdef _WIN32 + SetLastError(saved_error); +#endif + errno = saved_errno; + return false; +} + +static bool pt_join(char out[PT_PATH], const char *root, const char *leaf) { + int n = snprintf(out, PT_PATH, "%s/%s", root, leaf); + return n > 0 && n < PT_PATH; +} + +static bool pt_write(const char *path, const void *bytes, size_t length) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) + return false; + bool ok = fwrite(bytes, 1, length, file) == length; + return fclose(file) == 0 && ok; +} + +static bool pt_equal_file(const char *path, const void *bytes, size_t length) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) + return false; + unsigned char buffer[4096]; + size_t offset = 0; + bool ok = true; + while (offset < length && ok) { + size_t amount = length - offset; + if (amount > sizeof(buffer)) + amount = sizeof(buffer); + ok = fread(buffer, 1, amount, file) == amount && + memcmp(buffer, (const unsigned char *)bytes + offset, amount) == 0; + offset += amount; + } + ok = ok && fgetc(file) == EOF && !ferror(file); + return fclose(file) == 0 && ok; +} + +#ifdef _WIN32 +static bool pt_wide(const char *path, wchar_t out[PT_PATH]) { + return MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, out, PT_PATH) > 0; +} + +static PSID pt_current_sid(pt_fixture *fx) { + HANDLE token = NULL; + DWORD length = 0; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return NULL; + (void)GetTokenInformation(token, TokenUser, NULL, 0, &length); + TOKEN_USER *user = length ? cbm_arena_alloc(&fx->arena, length) : NULL; + bool ok = user && GetTokenInformation(token, TokenUser, user, length, &length); + ok = CloseHandle(token) && ok; + return ok ? user->User.Sid : NULL; +} + +static bool pt_windows_acl(pt_fixture *fx, const char *path, bool extra) { + PSID owner = pt_current_sid(fx); + wchar_t wide[PT_PATH]; + unsigned char acl_bytes[1024], world_bytes[SECURITY_MAX_SID_SIZE]; + DWORD world_length = sizeof(world_bytes); + ACL *acl = (ACL *)acl_bytes; + SECURITY_DESCRIPTOR descriptor; + if (!owner || !pt_wide(path, wide) || !InitializeAcl(acl, sizeof(acl_bytes), ACL_REVISION) || + !AddAccessAllowedAceEx(acl, ACL_REVISION, OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + FILE_ALL_ACCESS, owner)) + return false; + if (extra && (!CreateWellKnownSid(WinWorldSid, NULL, world_bytes, &world_length) || + !AddAccessAllowedAceEx(acl, ACL_REVISION, 0, FILE_GENERIC_READ, world_bytes))) + return false; + return InitializeSecurityDescriptor(&descriptor, SECURITY_DESCRIPTOR_REVISION) && + SetSecurityDescriptorOwner(&descriptor, owner, FALSE) && + SetSecurityDescriptorDacl(&descriptor, TRUE, acl, FALSE) && + SetSecurityDescriptorControl(&descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED) && + SetFileSecurityW(wide, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION | + PROTECTED_DACL_SECURITY_INFORMATION, + &descriptor); +} + +static bool pt_owner_is_current(PSID owner) { + HANDLE token = NULL; + union { + max_align_t alignment; + unsigned char bytes[1024]; + } buffer; + DWORD size = sizeof(buffer.bytes); + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + return false; + bool ok = GetTokenInformation(token, TokenUser, buffer.bytes, size, &size) && + EqualSid(owner, ((TOKEN_USER *)buffer.bytes)->User.Sid); + return CloseHandle(token) && ok; +} + +static bool pt_policy(const char *path, bool directory, bool executable) { + wchar_t wide[PT_PATH]; + if (!pt_wide(path, wide)) + return false; + PSECURITY_DESCRIPTOR descriptor = NULL; + PSID owner = NULL; + PACL acl = NULL; + DWORD result = GetNamedSecurityInfoW(wide, SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, NULL, &acl, NULL, &descriptor); + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + bool ok = result == ERROR_SUCCESS && owner && pt_owner_is_current(owner) && acl && + acl->AceCount == 1 && GetSecurityDescriptorControl(descriptor, &control, &revision) && + (control & SE_DACL_PROTECTED) != 0; + void *raw = NULL; + if (ok) + ok = GetAce(acl, 0, &raw) && raw; + if (ok) { + ACCESS_ALLOWED_ACE *ace = raw; + ok = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && EqualSid(owner, &ace->SidStart) && + (ace->Mask & FILE_ALL_ACCESS) == FILE_ALL_ACCESS; + } + DWORD attributes = GetFileAttributesW(wide); + ok = ok && attributes != INVALID_FILE_ATTRIBUTES && + !(attributes & FILE_ATTRIBUTE_REPARSE_POINT) && + (((attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) == directory) && + (directory || (attributes & FILE_ATTRIBUTE_READONLY)); + (void)executable; + if (descriptor) + LocalFree(descriptor); + return ok; +} + +static bool pt_make_writable(const char *path) { + wchar_t wide[PT_PATH]; + if (!pt_wide(path, wide)) + return false; + DWORD attributes = GetFileAttributesW(wide); + return attributes != INVALID_FILE_ATTRIBUTES && + SetFileAttributesW(wide, attributes & ~(DWORD)FILE_ATTRIBUTE_READONLY); +} +#else +#ifdef __APPLE__ +static bool pt_mac_same_object(const struct stat *a, const struct stat *b) { + return a->st_dev == b->st_dev && a->st_ino == b->st_ino && a->st_mode == b->st_mode && + a->st_uid == b->st_uid && a->st_nlink == b->st_nlink && a->st_nlink > 0; +} + +/* Native probing confirms absent extended ACLs can yield ENOENT through an fd. + * Only a no-follow descriptor tied to this still-existing object may interpret + * that result as absence. Path lookup failure is never an absent ACL. */ +static bool pt_mac_read_acl(const char *path, acl_t *out, bool *absent) { + *out = NULL; + *absent = false; + struct stat named, before, after, final; + if (lstat(path, &named) != 0 || !(S_ISDIR(named.st_mode) || S_ISREG(named.st_mode))) + return false; + int fd = open(path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) + return false; + bool ok = fstat(fd, &before) == 0 && pt_mac_same_object(&named, &before); + acl_t acl = NULL; + int acl_error = 0; + if (ok) { + errno = 0; + acl = acl_get_fd_np(fd, ACL_TYPE_EXTENDED); + acl_error = errno; + ok = acl != NULL || acl_error == ENOENT; + } + if (ok) + ok = fstat(fd, &after) == 0 && lstat(path, &final) == 0 && + pt_mac_same_object(&before, &after) && pt_mac_same_object(&after, &final); + int close_rc = close(fd); + if (!ok || close_rc != 0) { + int saved_errno = errno; + fprintf(stderr, + "pinned-tree verified-fd ACL failed null=%d acl_errno=%d " + "object_ok=%d close_rc=%d errno=%d\n", + acl == NULL, acl_error, (int)ok, close_rc, saved_errno); + if (acl) + (void)acl_free(acl); + errno = saved_errno; + return false; + } + *out = acl; + *absent = acl == NULL; + return true; +} +#endif + +static bool pt_acl_absent(const char *path, bool directory) { +#ifdef __APPLE__ + acl_t acl = NULL; + bool absent = false; + if (!pt_mac_read_acl(path, &acl, &absent)) + return false; + if (absent) + return true; + acl_entry_t entry; + errno = 0; + int result = acl_get_entry(acl, ACL_FIRST_ENTRY, &entry); + int entry_errno = errno; + bool ok = result == -1 && errno == EINVAL; + (void)directory; + int free_rc = acl_free(acl); + int free_errno = errno; + if (!ok || free_rc != 0) { + fprintf(stderr, + "pinned-tree native ACL get_null=0 entry_result=%d " + "entry_errno=%d free_rc=%d free_errno=%d\n", + result, entry_errno, free_rc, free_errno); + errno = free_errno; + } + return free_rc == 0 && ok; +#else + unsigned char data[64]; + ssize_t n = getxattr(path, "system.posix_acl_access", data, sizeof(data)); + bool ok = n == -1 && errno == ENODATA; + if (n == 28) { + static const unsigned char tags[3] = {1, 4, 32}; + struct stat st; + ok = data[0] == 2 && data[1] == 0 && data[2] == 0 && data[3] == 0 && stat(path, &st) == 0; + for (size_t i = 0; i < 3 && ok; i++) { + const unsigned char *p = data + 4 + 8 * i; + unsigned expected = ((unsigned)st.st_mode >> (6U - 3U * (unsigned)i)) & 7U; + ok = p[0] == tags[i] && p[1] == 0 && p[2] == expected && p[3] == 0 && p[4] == 255 && + p[5] == 255 && p[6] == 255 && p[7] == 255; + } + } + if (directory && ok) { + n = getxattr(path, "system.posix_acl_default", data, sizeof(data)); + ok = (n == -1 && errno == ENODATA) || n == 0 || + (n == 4 && data[0] == 2 && data[1] == 0 && data[2] == 0 && data[3] == 0); + } + return ok; +#endif +} + +static bool pt_policy(const char *path, bool directory, bool executable) { + struct stat st; + if (lstat(path, &st) != 0) + return false; + mode_t expected = directory ? 0700 : executable ? 0500 : 0400; + return st.st_uid == geteuid() && (st.st_mode & 07777) == expected && + (directory ? S_ISDIR(st.st_mode) : S_ISREG(st.st_mode) && st.st_nlink == 1) && + pt_acl_absent(path, directory); +} + +static bool pt_make_writable(const char *path) { + return chmod(path, 0600) == 0; +} +#endif + +#ifndef _WIN32 +static void pt_parent_policy_diagnostic(const char *path) { + int saved_errno = errno; + struct stat st; + int rc = lstat(path, &st); + if (rc == 0) + fprintf(stderr, + "pinned-tree parent policy mode=%lo uid=%lu euid=%lu nlink=%lu " + "directory=%d expected_mode=700\n", + (unsigned long)st.st_mode, (unsigned long)st.st_uid, (unsigned long)geteuid(), + (unsigned long)st.st_nlink, (int)S_ISDIR(st.st_mode)); + else + fprintf(stderr, "pinned-tree parent policy lstat_rc=%d errno=%d\n", rc, errno); + errno = saved_errno; +} +#endif + +static bool pt_private_parent(pt_fixture *fx) { + if (!pt_setup_step( + "private-parent:create", + th_secure_runtime_parent_new(fx->parent, sizeof(fx->parent), "pinned-parent"))) + return false; +#ifdef _WIN32 + return pt_setup_step("private-parent:native-ACL", pt_windows_acl(fx, fx->parent, false)) && + pt_setup_step("private-parent:native-policy", pt_policy(fx->parent, true, false)); +#else + if (!pt_setup_step("private-parent:chmod", chmod(fx->parent, 0700) == 0)) + return false; + bool ok = pt_policy(fx->parent, true, false); + if (!ok) + pt_parent_policy_diagnostic(fx->parent); + return pt_setup_step("private-parent:native-policy", ok); +#endif +} + +/* A checked native iterator is the test's own no-leftover witness. */ +static bool pt_parent_only_sentinel(const char *parent) { + size_t count = 0; + bool ok = true; +#ifdef _WIN32 + wchar_t pattern[PT_PATH]; + char joined[PT_PATH]; + if (!pt_join(joined, parent, "*") || !pt_wide(joined, pattern)) + return false; + WIN32_FIND_DATAW entry; + HANDLE handle = FindFirstFileW(pattern, &entry); + if (handle == INVALID_HANDLE_VALUE) + return false; + do { + if (!wcscmp(entry.cFileName, L".") || !wcscmp(entry.cFileName, L"..")) + continue; + count++; + if (wcscmp(entry.cFileName, L"sentinel")) + ok = false; + } while (FindNextFileW(handle, &entry)); + ok = GetLastError() == ERROR_NO_MORE_FILES && ok; + return FindClose(handle) && ok && count == 1; +#else + DIR *directory = opendir(parent); + if (!directory) + return false; + for (;;) { + errno = 0; + struct dirent *entry = readdir(directory); + if (!entry) { + if (errno) + ok = false; + break; + } + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) + continue; + count++; + if (strcmp(entry->d_name, "sentinel")) + ok = false; + } + return closedir(directory) == 0 && ok && count == 1; +#endif +} + +static bool pt_git(pt_fixture *fx, const char *const *tail, char *output, size_t capacity) { + const char *argv[48] = { + fx->git, "-C", fx->repo, "-c", "commit.gpgSign=false", "-c", "core.autocrlf=false"}; + size_t used = 7; + for (size_t i = 0; tail[i]; i++) { + if (used + 1 >= sizeof(argv) / sizeof(argv[0])) + return pt_git_failed(fx, tail, "argv-capacity", -1, NULL); + argv[used++] = tail[i]; + } + argv[used] = NULL; + cbm_proc_opts_t options = {.bin = fx->git, + .argv = argv, + .stdout_file = fx->capture, + .log_file = fx->log, + .quiet_timeout_ms = 10000, + .strip_git_repo_env = true}; + cbm_proc_result_t result = {0}; + int run_rc = cbm_subprocess_run(&options, &result); + if (run_rc != 0 || result.outcome != CBM_PROC_CLEAN || result.exit_code != 0 || + !result.tree_quiesced || result.supervision_failed) + return pt_git_failed(fx, tail, "run", run_rc, &result); + if (!output) + return true; + FILE *file = cbm_fopen(fx->capture, "rb"); + if (!file || !capacity) { + if (file) + (void)fclose(file); + return pt_git_failed(fx, tail, "capture-open-or-capacity", run_rc, &result); + } + size_t length = fread(output, 1, capacity - 1, file); + bool ok = fgetc(file) == EOF && !ferror(file); + output[length] = 0; + int close_rc = fclose(file); + if (close_rc != 0 || !ok) + return pt_git_failed(fx, tail, "capture-read-EOF-or-close", run_rc, &result); + return true; +} + +static bool pt_oid(pt_fixture *fx, const char *const *argv, char out[65]) { + char bytes[80]; + if (!pt_git(fx, argv, bytes, sizeof(bytes))) + return false; + size_t n = strlen(bytes); + if (n && bytes[n - 1] == '\n') + bytes[--n] = 0; + if (n && bytes[n - 1] == '\r') + bytes[--n] = 0; + if (n != fx->width) + return false; + for (size_t i = 0; i < n; i++) + if (!((bytes[i] >= '0' && bytes[i] <= '9') || (bytes[i] >= 'a' && bytes[i] <= 'f'))) + return false; + memcpy(out, bytes, n + 1); + return true; +} + +static bool pt_object(pt_fixture *fx, const char *type, const void *bytes, size_t length, + char out[65]) { + const char *argv[] = {"hash-object", "-w", "--no-filters", "-t", type, "--", fx->input, NULL}; + return pt_write(fx->input, bytes, length) && pt_oid(fx, argv, out); +} + +static bool pt_commit(pt_fixture *fx, const char *tree, const char *one, const char *two, + const char *name, char out[65]) { + char bytes[1024]; + int n = snprintf(bytes, sizeof(bytes), + "tree %s\n%s%s%s%s%s%s" + "author Tree Fixture 946684800 +0000\n" + "committer Tree Fixture 946684800 " + "+0000\n\n%s\n", + tree, one ? "parent " : "", one ? one : "", one ? "\n" : "", + two ? "parent " : "", two ? two : "", two ? "\n" : "", name); + return n > 0 && (size_t)n < sizeof(bytes) && pt_object(fx, "commit", bytes, (size_t)n, out); +} + +static bool pt_ref(pt_fixture *fx, const char *name, const char *oid) { + const char *args[] = {"update-ref", name, oid, NULL}; + return pt_git(fx, args, NULL, 0); +} + +static bool pt_index_tree(pt_fixture *fx, pt_row *rows, size_t count, char out[65]) { + const char *clear[] = {"read-tree", "--empty", NULL}; + const char *write[] = {"write-tree", NULL}; + if (!pt_git(fx, clear, NULL, 0)) + return false; + for (size_t i = 0; i < count; i++) { + char entry[PT_PATH + 80]; + int n = snprintf(entry, sizeof(entry), "%o,%s,%s", rows[i].mode, rows[i].oid, rows[i].path); + const char *add[] = {"update-index", "--add", "--cacheinfo", entry, NULL}; + if (n <= 0 || (size_t)n >= sizeof(entry) || !pt_git(fx, add, NULL, 0)) + return false; + } + return pt_oid(fx, write, out); +} + +static bool pt_repo_config(pt_fixture *fx) { + const char *keys[] = {"core.protectNTFS", "core.protectHFS", "core.ignorecase"}; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { + const char *args[] = {"config", "--local", keys[i], "false", NULL}; + if (!pt_git(fx, args, NULL, 0)) + return false; + } + const char *mode[] = {"config", "--local", "core.filemode", "true", NULL}; + return pt_git(fx, mode, NULL, 0); +} + +static bool pt_init(pt_fixture *fx, unsigned width) { + memset(fx, 0, sizeof(*fx)); + cbm_arena_init(&fx->arena); + fx->width = width; + const char *home = th_mktempdir("cbm-pinned-tree"); + if (!home || strlen(home) >= sizeof(fx->home)) + return pt_setup_step("init:home", false); + strcpy(fx->home, home); + /* Owned fixture home prevents an unrelated user directory probe. */ + const char *git = cbm_find_cli("git", fx->home); + if (!git || strlen(git) >= sizeof(fx->git)) + return pt_setup_step("init:git-resolution", false); +#ifdef _WIN32 + if (!((git[0] && git[1] == ':' && (git[2] == '/' || git[2] == '\\')) || + (git[0] == '\\' && git[1] == '\\'))) + return pt_setup_step("init:git-absolute-path", false); +#else + if (git[0] != '/') + return pt_setup_step("init:git-absolute-path", false); +#endif + strcpy(fx->git, git); + if (!pt_join(fx->repo, fx->home, "repo") || !pt_join(fx->capture, fx->home, "stdout") || + !pt_join(fx->log, fx->home, "stderr") || !pt_join(fx->input, fx->home, "input") || + th_mkdir_p(fx->repo) != 0) + return pt_setup_step("init:path-and-mkdir", false); + if (!pt_private_parent(fx)) + return pt_setup_step("init:private-parent", false); + const char *init[] = {"-c", + "init.templateDir=", + "init", + "--quiet", + width == 40 ? "--object-format=sha1" : "--object-format=sha256", + "--initial-branch=topic", + NULL}; + char sentinel[PT_PATH]; + return pt_setup_step("init:git-init", pt_git(fx, init, NULL, 0)) && + pt_setup_step("init:git-config", pt_repo_config(fx)) && + pt_setup_step("init:sentinel-path", pt_join(sentinel, fx->parent, "sentinel")) && + pt_setup_step("init:sentinel-write", pt_write(sentinel, "parent-owned\n", 13)) && + pt_setup_step("init:sentinel-audit", pt_parent_only_sentinel(fx->parent)); +} + +static bool pt_populate(pt_fixture *fx) { + fx->large = cbm_arena_alloc(&fx->arena, PT_LARGE); + if (!fx->large) + return false; + for (size_t i = 0; i < PT_LARGE; i++) + fx->large[i] = (unsigned char)(i % 251U); + fx->rows[0] = (pt_row){ + .path = "binary.bin", .mode = 0100644, .bytes = pt_binary, .length = sizeof(pt_binary)}; + fx->rows[1] = (pt_row){ + .path = "copy.bin", .mode = 0100644, .bytes = pt_shared, .length = sizeof(pt_shared) - 1}; + fx->rows[2] = + (pt_row){.path = "empty", .mode = 0100644, .bytes = (const unsigned char *)"", .length = 0}; + fx->rows[3] = + (pt_row){.path = "large", .mode = 0100644, .bytes = fx->large, .length = PT_LARGE}; + fx->rows[4] = (pt_row){.path = "nested/source.c", + .mode = 0100644, + .bytes = pt_after, + .length = sizeof(pt_after) - 1}; + fx->rows[5] = (pt_row){ + .path = "run", .mode = 0100755, .bytes = pt_shared, .length = sizeof(pt_shared) - 1}; + fx->count = 6; + for (size_t i = 0; i < fx->count; i++) + if (!pt_object(fx, "blob", fx->rows[i].bytes, fx->rows[i].length, fx->rows[i].oid)) + return false; + pt_row ancestor[PT_ROWS]; + memcpy(ancestor, fx->rows, sizeof(ancestor)); + ancestor[4].bytes = pt_before; + ancestor[4].length = sizeof(pt_before) - 1; + if (!pt_object(fx, "blob", pt_before, sizeof(pt_before) - 1, ancestor[4].oid)) + return false; + strcpy(fx->before_oid, ancestor[4].oid); + char empty[65], before[65], after[65]; + return pt_index_tree(fx, NULL, 0, empty) && pt_commit(fx, empty, NULL, NULL, "A", fx->a) && + pt_commit(fx, empty, fx->a, NULL, "P", fx->p) && + pt_index_tree(fx, ancestor, fx->count, before) && + pt_commit(fx, before, fx->a, NULL, "Q", fx->q) && + pt_index_tree(fx, fx->rows, fx->count, after) && + pt_commit(fx, after, fx->p, fx->q, "H", fx->head) && + pt_commit(fx, before, fx->q, NULL, "B", fx->base) && + pt_ref(fx, "refs/heads/topic", fx->head) && pt_ref(fx, "refs/heads/base", fx->base); +} + +static bool pt_open_facts(pt_fixture *fx) { + cbm_git_facts_options_t options = {.root = fx->repo, + .base_ref = "refs/heads/base", + .git_executable = fx->git, + .expected_head = fx->head, + .deadline_ms = cbm_now_ms() + 120000U, + .command_limit = 4096, + .stdout_limit = 4 * 1024 * 1024, + .stderr_limit = 65536, + .total_output_limit = 64 * 1024 * 1024}; + cbm_git_facts_error_t error; + fx->facts = cbm_git_facts_open(&options, &error); + const cbm_git_facts_identity_t *id = cbm_git_facts_identity(fx->facts); + return fx->facts && error.status == CBM_GIT_FACTS_OK && id && id->oid_hex_length == fx->width && + !strcmp(id->head, fx->head) && !strcmp(id->base, fx->base) && + !strcmp(id->merge_base, fx->q); +} + +static cbm_pinned_tree_options_t pt_options(pt_fixture *fx, cbm_git_revision_t revision) { + return (cbm_pinned_tree_options_t){ + .facts = fx->facts, + .revision = revision, + .private_parent = fx->parent, + .limits = {.max_files = 64, + .max_directories = 64, + .max_total_content_bytes = 4 * 1024 * 1024, + .max_relative_path_bytes = 1024, + .max_arena_bytes = 16 * 1024 * 1024, + .blob_batch = {.max_entries = 64, + .max_input_bytes = 16384, + .max_arena_bytes = 16 * 1024 * 1024}}, + .control = {.deadline_ms = cbm_now_ms() + 120000U}}; +} + +static bool pt_close_one(cbm_pinned_tree_t **tree) { + if (!*tree) + return true; + cbm_pinned_tree_error_t error; + return cbm_pinned_tree_close(tree, &error) == CBM_PINNED_TREE_OK && !*tree; +} + +static int pt_finish(pt_fixture *fx, int result) { + bool closed = pt_close_one(&fx->tree) && pt_close_one(&fx->second); + cbm_git_facts_free(fx->facts); + if (!closed) { + fprintf(stderr, "pinned-tree fixture retained for disposition: %s\n", fx->parent); + return 1; + } + if (fx->parent[0] && th_rmtree(fx->parent) != 0) + result = 1; + if (fx->home[0] && th_rmtree(fx->home) != 0) + result = 1; + cbm_arena_destroy(&fx->arena); + return result; +} + +static size_t pt_total(const pt_fixture *fx) { + size_t total = 0; + for (size_t i = 0; i < fx->count; i++) + total += fx->rows[i].length; + return total; +} + +static bool pt_fact_control(pt_fixture *fx) { + cbm_git_tree_inventory_t inventory; + cbm_git_facts_error_t error; + if (!cbm_git_facts_inventory(fx->facts, CBM_GIT_REV_HEAD, &inventory, &error) || + inventory.count != fx->count) + return false; + for (size_t i = 0; i < fx->count; i++) { + const cbm_git_tree_entry_t *entry = &inventory.entries[i]; + if (entry->path_length != strlen(fx->rows[i].path) || + memcmp(entry->path, fx->rows[i].path, entry->path_length) || + entry->mode != fx->rows[i].mode || strcmp(entry->oid, fx->rows[i].oid)) + return false; + } + cbm_git_blob_t blob; + return cbm_git_facts_read_blob(fx->facts, CBM_GIT_REV_HEAD, "binary.bin", 10, &blob, &error) == + CBM_GIT_BLOB_FOUND && + blob.bytes.length == sizeof(pt_binary) && + !memcmp(blob.bytes.data, pt_binary, sizeof(pt_binary)); +} + +static void pt_hash(const void *bytes, size_t length, unsigned char out[32]) { + cbm_sha256_ctx hash; + cbm_sha256_init(&hash); + cbm_sha256_update(&hash, bytes, length); + cbm_sha256_final(&hash, out); +} + +static bool pt_view_matches(pt_fixture *fx, const cbm_pinned_tree_view_t *view, bool ancestor) { + if (!view || view->file_count != fx->count || view->directory_count != 2 || + view->total_content_bytes != pt_total(fx) || !view->identity || + strcmp(view->commit, ancestor ? fx->q : fx->head) || + view->revision != (ancestor ? CBM_GIT_REV_MERGE_BASE : CBM_GIT_REV_HEAD) || + !pt_policy(view->root, true, false)) + return false; + for (size_t i = 0; i < fx->count; i++) { + const cbm_pinned_tree_file_t *row = &view->files[i]; + const unsigned char *bytes = ancestor && i == 4 ? pt_before : fx->rows[i].bytes; + unsigned char digest[32]; + char path[PT_PATH]; + pt_hash(bytes, fx->rows[i].length, digest); + if (row->path_length != strlen(fx->rows[i].path) || + memcmp(row->path, fx->rows[i].path, row->path_length + 1) || + row->git_mode != fx->rows[i].mode || row->content_length != fx->rows[i].length || + memcmp(row->content_sha256, digest, 32) || + !pt_join(path, view->root, fx->rows[i].path) || + !pt_equal_file(path, bytes, fx->rows[i].length) || + !pt_policy(path, false, fx->rows[i].mode == 0100755)) + return false; + if (strcmp(row->oid, ancestor && i == 4 ? fx->before_oid : fx->rows[i].oid)) + return false; + } + char nested[PT_PATH]; + return pt_join(nested, view->root, "nested") && pt_policy(nested, true, false); +} + +typedef struct { + unsigned char bytes[8192]; + size_t used; + bool ok; +} pt_wire; + +static void pt_wbytes(pt_wire *wire, const void *bytes, size_t length) { + if (!wire->ok || length > sizeof(wire->bytes) - wire->used) { + wire->ok = false; + return; + } + if (length) + memcpy(wire->bytes + wire->used, bytes, length); + wire->used += length; +} + +static void pt_wnumber(pt_wire *wire, uint64_t number, size_t width) { + unsigned char bytes[8]; + for (size_t i = 0; i < width; i++) + bytes[width - 1 - i] = (unsigned char)(number >> (i * 8)); + pt_wbytes(wire, bytes, width); +} + +static void pt_wstring(pt_wire *wire, const char *value) { + size_t length = strlen(value); + pt_wnumber(wire, length, 8); + pt_wbytes(wire, value, length); +} + +/* Expected transcript consumes only independently constructed fixture rows and + * the already checked Git facts identity; no materializer encoder/view input. + */ +static bool pt_expected_digest(const cbm_git_facts_identity_t *identity, const pt_row *rows, + size_t count, size_t directories, cbm_git_revision_t revision, + unsigned char out[32]) { + pt_wire wire = {.ok = true}; + static const char domain[] = "cbm.pinned-tree.v1"; + const char *selected = revision == CBM_GIT_REV_HEAD ? identity->head : identity->merge_base; + uint64_t total = 0; + for (size_t i = 0; i < count; i++) + total += rows[i].length; + pt_wbytes(&wire, domain, sizeof(domain)); + pt_wnumber(&wire, identity->oid_hex_length == 40 ? 1 : 2, 1); + pt_wstring(&wire, identity->root); + pt_wstring(&wire, identity->git_dir); + pt_wstring(&wire, identity->common_dir); + pt_wstring(&wire, identity->head); + pt_wstring(&wire, identity->base); + pt_wstring(&wire, identity->merge_base); + pt_wnumber(&wire, revision == CBM_GIT_REV_HEAD ? 1 : 2, 1); + pt_wstring(&wire, selected); + pt_wnumber(&wire, count, 8); + pt_wnumber(&wire, directories, 8); + pt_wnumber(&wire, total, 8); + for (size_t i = 0; i < count; i++) { + unsigned char content[32]; + pt_hash(rows[i].bytes, rows[i].length, content); + pt_wnumber(&wire, 'F', 1); + pt_wstring(&wire, rows[i].path); + pt_wnumber(&wire, rows[i].mode, 4); + pt_wstring(&wire, rows[i].oid); + pt_wnumber(&wire, rows[i].length, 8); + pt_wbytes(&wire, content, sizeof(content)); + } + if (wire.ok) + pt_hash(wire.bytes, wire.used, out); + return wire.ok; +} + +static void pt_hex(const unsigned char digest[32], char out[65]) { + static const char digits[] = "0123456789abcdef"; + for (size_t i = 0; i < 32; i++) { + out[2 * i] = digits[digest[i] >> 4]; + out[2 * i + 1] = digits[digest[i] & 15]; + } + out[64] = 0; +} + +static bool pt_golden_control(void) { + cbm_git_facts_identity_t id = { + .root = "/repo", .git_dir = "/repo/.git", .common_dir = "/repo/.git", .oid_hex_length = 40}; + memset(id.head, '1', 40); + memset(id.base, '2', 40); + memset(id.merge_base, '3', 40); + pt_row row = {.path = "a", + .mode = 0100644, + .bytes = (const unsigned char *)"", + .length = 0, + .oid = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"}; + unsigned char digest[32]; + char hex[65]; + if (!pt_expected_digest(&id, &row, 1, 1, CBM_GIT_REV_HEAD, digest)) + return false; + pt_hex(digest, hex); + return !strcmp(hex, "cde0e3e17a8263cd24439742f6af0f265217607aa7b1bd8b912fcad055e05e46"); +} + +static bool pt_independent_files(const char *root) { + char first[PT_PATH], second[PT_PATH]; + if (!pt_join(first, root, "copy.bin") || !pt_join(second, root, "run")) + return false; +#ifdef _WIN32 + wchar_t a[PT_PATH], b[PT_PATH]; + if (!pt_wide(first, a) || !pt_wide(second, b)) + return false; + HANDLE one = + CreateFileW(a, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); + HANDLE two = + CreateFileW(b, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION ia, ib; + bool ok = one != INVALID_HANDLE_VALUE && two != INVALID_HANDLE_VALUE && + GetFileInformationByHandle(one, &ia) && GetFileInformationByHandle(two, &ib) && + ia.nNumberOfLinks == 1 && ib.nNumberOfLinks == 1 && + (ia.nFileIndexHigh != ib.nFileIndexHigh || ia.nFileIndexLow != ib.nFileIndexLow || + ia.dwVolumeSerialNumber != ib.dwVolumeSerialNumber); + if (one != INVALID_HANDLE_VALUE) + ok = CloseHandle(one) && ok; + if (two != INVALID_HANDLE_VALUE) + ok = CloseHandle(two) && ok; + return ok; +#else + struct stat a, b; + return lstat(first, &a) == 0 && lstat(second, &b) == 0 && a.st_nlink == 1 && b.st_nlink == 1 && + (a.st_dev != b.st_dev || a.st_ino != b.st_ino); +#endif +} + +static int pt_pinned_round(unsigned width) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(pt_setup_step("setup:init", pt_init(&fx, width)) && + pt_setup_step("setup:populate", pt_populate(&fx)) && + pt_setup_step("setup:facts", pt_open_facts(&fx))); + PT_CHECK(pt_setup_step("setup:control", pt_fact_control(&fx))); + const cbm_git_facts_identity_t *id = cbm_git_facts_identity(fx.facts); + PT_CHECK(strcmp(id->merge_base, fx.p) != 0); + unsigned char expected_head[32], expected_merge[32]; + pt_row ancestor_rows[PT_ROWS]; + memcpy(ancestor_rows, fx.rows, sizeof(ancestor_rows)); + ancestor_rows[4].bytes = pt_before; + strcpy(ancestor_rows[4].oid, fx.before_oid); + PT_CHECK(pt_expected_digest(id, fx.rows, fx.count, 2, CBM_GIT_REV_HEAD, expected_head)); + PT_CHECK( + pt_expected_digest(id, ancestor_rows, fx.count, 2, CBM_GIT_REV_MERGE_BASE, expected_merge)); + char *original_root = cbm_arena_strdup(&fx.arena, id->root); + char *original_git = cbm_arena_strdup(&fx.arena, id->git_dir); + char *original_common = cbm_arena_strdup(&fx.arena, id->common_dir); + PT_CHECK(original_root && original_git && original_common); + const char *empty_index[] = {"read-tree", "--empty", NULL}; + char dirty[PT_PATH]; + PT_CHECK(pt_ref(&fx, "refs/heads/topic", fx.a) && pt_ref(&fx, "refs/heads/base", fx.a)); + PT_CHECK(pt_git(&fx, empty_index, NULL, 0) && pt_join(dirty, fx.repo, "binary.bin")); + PT_CHECK(pt_write(dirty, "dirty", 5)); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_HEAD); + char parent_copy[PT_PATH]; + strcpy(parent_copy, fx.parent); + options.private_parent = parent_copy; + cbm_pinned_tree_error_t error; + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + memset(parent_copy, 0xa5, sizeof(parent_copy)); + PT_CHECK(pt_view_matches(&fx, cbm_pinned_tree_view(fx.tree), false)); + PT_CHECK(pt_independent_files(cbm_pinned_tree_view(fx.tree)->root)); + options = pt_options(&fx, CBM_GIT_REV_MERGE_BASE); + PT_CHECK(cbm_pinned_tree_create(&options, &fx.second, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_view_matches(&fx, cbm_pinned_tree_view(fx.second), true)); + PT_CHECK(!memcmp(cbm_pinned_tree_view(fx.tree)->manifest_sha256, expected_head, 32)); + PT_CHECK(!memcmp(cbm_pinned_tree_view(fx.second)->manifest_sha256, expected_merge, 32)); + PT_CHECK(memcmp(expected_head, expected_merge, 32) != 0); + cbm_git_facts_free(fx.facts); + fx.facts = NULL; + memset(&options, 0xa5, sizeof(options)); + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(fx.tree); + PT_CHECK(view && !strcmp(view->identity->root, original_root) && + !strcmp(view->identity->git_dir, original_git) && + !strcmp(view->identity->common_dir, original_common) && + !strcmp(view->identity->head, fx.head) && !strcmp(view->identity->base, fx.base)); + cbm_pinned_tree_control_t control = {.deadline_ms = cbm_now_ms() + 120000U}; + PT_CHECK(cbm_pinned_tree_verify(fx.tree, &control, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_view_matches(&fx, cbm_pinned_tree_view(fx.tree), false)); + PT_CHECK(pt_close_one(&fx.tree) && pt_close_one(&fx.second)); + PT_CHECK(pt_parent_only_sentinel(fx.parent) && pt_equal_file(dirty, "dirty", 5)); + result = 0; +done: + return pt_finish(&fx, result); +} + +TEST(tree_a_pinned_head_merge_base_and_owned_lifetime) { + ASSERT_EQ(pt_pinned_round(40), 0); + ASSERT_EQ(pt_pinned_round(64), 0); + PASS(); +} + +TEST(tree_b_manifest_golden_and_empty_inventory) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(pt_golden_control()); + PT_CHECK(pt_setup_step("setup:init", pt_init(&fx, 40)) && + pt_setup_step("setup:populate", pt_populate(&fx)) && + pt_setup_step("setup:facts", pt_open_facts(&fx))); + PT_CHECK(pt_setup_step("setup:control", pt_fact_control(&fx))); + unsigned char expected[32]; + PT_CHECK(pt_expected_digest(cbm_git_facts_identity(fx.facts), fx.rows, fx.count, 2, + CBM_GIT_REV_HEAD, expected)); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_HEAD); + cbm_pinned_tree_error_t error; + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(cbm_pinned_tree_create(&options, &fx.second, &error) == CBM_PINNED_TREE_OK); + const cbm_pinned_tree_view_t *one = cbm_pinned_tree_view(fx.tree); + const cbm_pinned_tree_view_t *two = cbm_pinned_tree_view(fx.second); + PT_CHECK(one && two && strcmp(one->root, two->root) && + !memcmp(one->manifest_sha256, expected, 32) && + !memcmp(two->manifest_sha256, expected, 32)); + PT_CHECK(pt_close_one(&fx.tree) && pt_close_one(&fx.second)); + cbm_git_facts_free(fx.facts); + fx.facts = NULL; + strcpy(fx.head, fx.a); + strcpy(fx.base, fx.a); + strcpy(fx.q, fx.a); + PT_CHECK(pt_ref(&fx, "refs/heads/topic", fx.a) && pt_ref(&fx, "refs/heads/base", fx.a)); + PT_CHECK(pt_open_facts(&fx)); + cbm_git_tree_inventory_t inventory; + cbm_git_facts_error_t git_error; + PT_CHECK(cbm_git_facts_inventory(fx.facts, CBM_GIT_REV_HEAD, &inventory, &git_error)); + PT_CHECK(!inventory.entries && inventory.count == 0); + PT_CHECK(pt_expected_digest(cbm_git_facts_identity(fx.facts), NULL, 0, 1, CBM_GIT_REV_HEAD, + expected)); + options = pt_options(&fx, CBM_GIT_REV_HEAD); + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + one = cbm_pinned_tree_view(fx.tree); + PT_CHECK(one && !one->files && one->file_count == 0 && one->directory_count == 1 && + one->total_content_bytes == 0 && !memcmp(one->manifest_sha256, expected, 32)); + PT_CHECK(cbm_pinned_tree_verify(fx.tree, &options.control, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_close_one(&fx.tree) && pt_parent_only_sentinel(fx.parent)); + result = 0; +done: + return pt_finish(&fx, result); +} + +static unsigned char pt_unhex(char c) { + return (unsigned char)(c <= '9' ? c - '0' : c - 'a' + 10); +} + +/* Raw Git tree objects allow Windows-inexpressible Git names without creating + * them in the fixture working tree. Caller supplies Git's canonical flat order. + */ +static bool pt_flat_tree(pt_fixture *fx, pt_row *rows, size_t count, char out[65]) { + unsigned char bytes[8192]; + size_t used = 0; + for (size_t i = 0; i < count; i++) { + char mode[16]; + int n = snprintf(mode, sizeof(mode), "%o ", rows[i].mode); + size_t length = strlen(rows[i].path); + if (n <= 0 || used + (size_t)n + length + 1 + fx->width / 2 > sizeof(bytes)) + return false; + memcpy(bytes + used, mode, (size_t)n); + used += (size_t)n; + memcpy(bytes + used, rows[i].path, length + 1); + used += length + 1; + for (size_t j = 0; j < fx->width; j += 2) + bytes[used++] = + (unsigned char)((pt_unhex(rows[i].oid[j]) << 4) | pt_unhex(rows[i].oid[j + 1])); + } + return pt_object(fx, "tree", bytes, used, out); +} + +static bool pt_replace_tree(pt_fixture *fx, pt_row *rows, size_t count) { + cbm_git_facts_free(fx->facts); + fx->facts = NULL; + char tree[65]; + if (!pt_flat_tree(fx, rows, count, tree) || + !pt_commit(fx, tree, fx->a, NULL, "special", fx->head)) + return false; + strcpy(fx->base, fx->head); + strcpy(fx->q, fx->head); + if (!pt_ref(fx, "refs/heads/topic", fx->head) || !pt_ref(fx, "refs/heads/base", fx->head) || + !pt_open_facts(fx)) + return false; + cbm_git_tree_inventory_t inventory; + cbm_git_facts_error_t error; + if (!cbm_git_facts_inventory(fx->facts, CBM_GIT_REV_HEAD, &inventory, &error) || + inventory.count != count) + return false; + for (size_t i = 0; i < count; i++) + if (strcmp(inventory.entries[i].path, rows[i].path) || + inventory.entries[i].mode != rows[i].mode) + return false; + return true; +} + +static bool pt_clean_failure(pt_fixture *fx, cbm_pinned_tree_options_t *options, + cbm_pinned_tree_status_t expected) { + cbm_pinned_tree_error_t error; + memset(&error, 0xa5, sizeof(error)); + cbm_pinned_tree_status_t result = cbm_pinned_tree_create(options, &fx->tree, &error); + return result == expected && error.status == expected && error.cause == expected && !fx->tree && + pt_parent_only_sentinel(fx->parent); +} + +#ifndef _WIN32 +/* Checked native spelling witness; 0 exact,1 changed spelling,-1 I/O. */ +static int pt_posix_names(const char *root, const char *first, const char *second) { + DIR *dir = opendir(root); + if (!dir) + return -1; + bool seen_first = false, seen_second = second == NULL, ok = true; + for (;;) { + errno = 0; + struct dirent *entry = readdir(dir); + if (!entry) { + ok = errno == 0; + break; + } + if (!strcmp(entry->d_name, first)) + seen_first = true; + if (second && !strcmp(entry->d_name, second)) + seen_second = true; + } + if (closedir(dir) != 0) + ok = false; + if (!ok) + return -1; + return seen_first && seen_second ? 0 : 1; +} + +/* Native refusal/spelling is observed separately from the feature result. */ +static int pt_raw_name_support(pt_fixture *fx, const char *name) { + char root[PT_PATH], path[PT_PATH]; + if (!pt_join(root, fx->parent, "raw-probe") || cbm_mkdir(root) != 0 || + !pt_join(path, root, name)) + return -1; + int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600); + int result = -1; + if (fd < 0) { + if (errno == EINVAL || errno == EILSEQ || errno == ENAMETOOLONG) + result = 1; + } else if (close(fd) == 0) + result = pt_posix_names(root, name, NULL); + if (th_rmtree(root) != 0) + result = -1; + return result; +} +#endif + +TEST(tree_c_complete_inventory_and_native_names) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(pt_setup_step("setup:init", pt_init(&fx, 40)) && + pt_setup_step("setup:populate", pt_populate(&fx)) && + pt_setup_step("setup:facts", pt_open_facts(&fx)) && + pt_setup_step("setup:control", pt_fact_control(&fx))); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_HEAD); + cbm_pinned_tree_error_t error; + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_close_one(&fx.tree)); + pt_row rows[2] = {fx.rows[0], fx.rows[0]}; + rows[0].path = "good"; + rows[1].path = "unsupported"; + const uint32_t modes[] = {0120000, 0160000}; + for (size_t i = 0; i < 2; i++) { + rows[1].mode = modes[i]; + strcpy(rows[1].oid, i == 0 ? fx.rows[0].oid : fx.a); + PT_CHECK(pt_replace_tree(&fx, rows, 2)); + options = pt_options(&fx, CBM_GIT_REV_HEAD); + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_UNSUPPORTED)); + } + rows[1] = fx.rows[0]; +#ifdef _WIN32 + /* WHY: these are Win32 encoding/device restrictions. POSIX raw-name cases + * below are the tried positive counterpart; no platform is silently skipped. + */ + const char *names[] = {"CON.txt", "LPT1", "bad:stream", "bad\\slash", "bad.", + "bad ", "bad\tname", "bad*name", "bad\xff", "COM\xc2\xb9.txt"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + pt_row one = fx.rows[0]; + one.path = names[i]; + PT_CHECK(pt_replace_tree(&fx, &one, 1)); + options = pt_options(&fx, CBM_GIT_REV_HEAD); + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_UNSUPPORTED)); + } +#else + const char *names[] = {"-dash", ":colon", "[glob]*", "back\\slash", + "line\nname", "tab\tname", "z\xff"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + int support = pt_raw_name_support(&fx, names[i]); + PT_CHECK(support >= 0); + fprintf(stderr, "pinned-tree POSIX raw-name native support=%d at case%zu\n", support, i); + pt_row one = fx.rows[0]; + one.path = names[i]; + PT_CHECK(pt_replace_tree(&fx, &one, 1)); + options = pt_options(&fx, CBM_GIT_REV_HEAD); + if (support != 0) { + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_UNSUPPORTED)); + continue; + } + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(fx.tree); + char path[PT_PATH]; + PT_CHECK(view && view->file_count == 1 && view->files[0].path_length == strlen(names[i]) && + !memcmp(view->files[0].path, names[i], strlen(names[i]) + 1)); + PT_CHECK(pt_join(path, view->root, names[i]) && + pt_equal_file(path, pt_binary, sizeof(pt_binary))); + PT_CHECK(pt_close_one(&fx.tree)); + } +#endif + PT_CHECK(pt_parent_only_sentinel(fx.parent)); + result = 0; +done: + return pt_finish(&fx, result); +} + +/* 0: both exact names; 1: native alias; 2: spelling changed; -1: setup error. + */ +static int pt_native_pair(pt_fixture *fx, const char *first, const char *second) { + char root[PT_PATH], a[PT_PATH], b[PT_PATH]; + if (!pt_join(root, fx->parent, "native-probe") || cbm_mkdir(root) != 0 || + !pt_join(a, root, first) || !pt_join(b, root, second)) + return -1; + int result = -1; +#ifdef _WIN32 + wchar_t wa[PT_PATH], wb[PT_PATH]; + if (!pt_wide(a, wa) || !pt_wide(b, wb)) + goto cleanup; + HANDLE one = CreateFileW(wa, GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL); + if (one == INVALID_HANDLE_VALUE) + goto cleanup; + if (!CloseHandle(one)) + goto cleanup; + HANDLE two = CreateFileW(wb, GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL); + if (two == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + if (error == ERROR_FILE_EXISTS || error == ERROR_ALREADY_EXISTS) + result = 1; + } else if (CloseHandle(two)) + result = 0; +#else + int one = open(a, O_WRONLY | O_CREAT | O_EXCL, 0600); + if (one < 0) + goto cleanup; + if (close(one) != 0) + goto cleanup; + int two = open(b, O_WRONLY | O_CREAT | O_EXCL, 0600); + if (two < 0) { + if (errno == EEXIST) + result = 1; + } else if (close(two) == 0) + result = 0; + if (result == 0) { + int spelling = pt_posix_names(root, first, second); + result = spelling < 0 ? -1 : spelling == 0 ? 0 : 2; + } +#endif +cleanup: + if (th_rmtree(root) != 0) + result = -1; + return result; +} + +static bool pt_pair_case(pt_fixture *fx, const char *first, const char *second) { + int native = pt_native_pair(fx, first, second); + if (native < 0 || !pt_parent_only_sentinel(fx->parent)) + return false; + fprintf(stderr, "pinned-tree native pair classification=%d for %s / %s\n", native, first, + second); + pt_row rows[2] = {fx->rows[0], fx->rows[1]}; + rows[0].path = first; + rows[1].path = second; + if (strcmp(first, second) > 0) { + pt_row temporary = rows[0]; + rows[0] = rows[1]; + rows[1] = temporary; + } + if (!pt_replace_tree(fx, rows, 2)) + return false; + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + if (native != 0) + return pt_clean_failure( + fx, &options, native == 1 ? CBM_PINNED_TREE_COLLISION : CBM_PINNED_TREE_UNSUPPORTED); + cbm_pinned_tree_error_t error; + if (cbm_pinned_tree_create(&options, &fx->tree, &error) != CBM_PINNED_TREE_OK) + return false; + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(fx->tree); + bool ok = view && view->file_count == 2; + for (size_t i = 0; i < 2 && ok; i++) { + char path[PT_PATH]; + ok = !strcmp((const char *)view->files[i].path, rows[i].path) && + pt_join(path, view->root, rows[i].path) && + pt_equal_file(path, rows[i].bytes, rows[i].length); + } + return pt_close_one(&fx->tree) && ok; +} + +#ifdef _WIN32 +static bool pt_short_alias_case(pt_fixture *fx) { + static const char long_name[] = "Long fixture filename for alias.bin"; + char root[PT_PATH], path[PT_PATH]; + wchar_t wide[PT_PATH], short_path[PT_PATH]; + if (!pt_join(root, fx->parent, "short-probe") || cbm_mkdir(root) != 0 || + !pt_join(path, root, long_name) || !pt_write(path, "probe", 5) || !pt_wide(path, wide)) + return false; + DWORD length = GetShortPathNameW(wide, short_path, PT_PATH); + if (!length || length >= PT_PATH) { + (void)th_rmtree(root); + return false; + } + const wchar_t *leaf = short_path; + for (const wchar_t *p = short_path; *p; p++) + if (*p == L'\\' || *p == L'/') + leaf = p + 1; + char alias[512]; + int encoded = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, leaf, -1, alias, sizeof(alias), + NULL, NULL); + if (th_rmtree(root) != 0 || encoded <= 0) + return false; + if (!strcmp(alias, long_name)) { + fprintf(stderr, "pinned-tree alias applicability: native volume allocated " + "no short alias; " + "WHY no distinct alias exists; tried GetShortPathNameW on " + "an actual created file\n"); + return true; + } + pt_row rows[2] = {fx->rows[0], fx->rows[1]}; + rows[0].path = long_name; + rows[1].path = alias; + if (strcmp(rows[0].path, rows[1].path) > 0) { + pt_row swap = rows[0]; + rows[0] = rows[1]; + rows[1] = swap; + } + if (!pt_replace_tree(fx, rows, 2)) + return false; + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + cbm_pinned_tree_error_t error; + cbm_pinned_tree_status_t status = cbm_pinned_tree_create(&options, &fx->tree, &error); + if (status == CBM_PINNED_TREE_COLLISION) + return !fx->tree && error.status == status && pt_parent_only_sentinel(fx->parent); + if (status != CBM_PINNED_TREE_OK) + return false; + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(fx->tree); + bool ok = view && view->file_count == 2; + for (size_t i = 0; i < 2 && ok; i++) + ok = !strcmp((const char *)view->files[i].path, rows[i].path) && + pt_join(path, view->root, rows[i].path) && + pt_equal_file(path, rows[i].bytes, rows[i].length); + /* Creation order may reserve the explicit short-looking name first, making + * Git's two spellings independently representable. Both must then retain + * different expected bytes, so accidental alias reuse cannot pass. */ + return pt_close_one(&fx->tree) && ok; +} +#endif + +TEST(tree_d_native_collision_and_exact_spelling) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(pt_setup_step("setup:init", pt_init(&fx, 40)) && + pt_setup_step("setup:populate", pt_populate(&fx)) && + pt_setup_step("setup:facts", pt_open_facts(&fx)) && + pt_setup_step("setup:control", pt_fact_control(&fx))); + PT_CHECK(pt_pair_case(&fx, "exact-one", "exact-two")); + PT_CHECK(pt_pair_case(&fx, "Case", "case")); +#ifdef _WIN32 + PT_CHECK(pt_short_alias_case(&fx)); +#endif + PT_CHECK(pt_pair_case(&fx, "e\xcc\x81", "\xc3\xa9")); + /* The same observed case rule also applies when an expected file would + * otherwise reuse the native directory of a different Git prefix. */ + int native = pt_native_pair(&fx, "A", "a"); + PT_CHECK(native == 0 || native == 1); + cbm_git_facts_free(fx.facts); + fx.facts = NULL; + pt_row rows[2] = {fx.rows[0], fx.rows[1]}; + rows[0].path = "A"; + rows[1].path = "a/leaf"; + char tree[65]; + PT_CHECK(pt_index_tree(&fx, rows, 2, tree) && + pt_commit(&fx, tree, fx.a, NULL, "prefix", fx.head)); + strcpy(fx.base, fx.head); + strcpy(fx.q, fx.head); + PT_CHECK(pt_ref(&fx, "refs/heads/topic", fx.head) && pt_ref(&fx, "refs/heads/base", fx.head)); + PT_CHECK(pt_open_facts(&fx)); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_HEAD); + cbm_pinned_tree_error_t error; + if (native == 1) + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_COLLISION)); + else { + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(cbm_pinned_tree_view(fx.tree)->file_count == 2); + PT_CHECK(pt_close_one(&fx.tree)); + } + PT_CHECK(pt_parent_only_sentinel(fx.parent)); + result = 0; +done: + return pt_finish(&fx, result); +} + +#ifndef _WIN32 +static bool pt_passing_posix_mode(const char *path, mode_t mode) { + struct stat st; + return stat(path, &st) == 0 && st.st_uid == geteuid() && (st.st_mode & 07777) == mode; +} +#ifdef __APPLE__ +static bool pt_mac_named_acl(const char *path, bool install) { + acl_t acl = acl_init(1); + if (!acl) + return false; + bool ok = true; + if (install) { + struct passwd *principal = getpwnam("nobody"); + uuid_t uuid; + acl_entry_t entry; + acl_permset_t permissions; + acl_flagset_t flags; + ok = principal && principal->pw_uid != geteuid() && + mbr_uid_to_uuid(principal->pw_uid, uuid) == 0 && acl_create_entry(&acl, &entry) == 0 && + acl_set_tag_type(entry, ACL_EXTENDED_ALLOW) == 0 && + acl_set_qualifier(entry, uuid) == 0 && acl_get_permset(entry, &permissions) == 0 && + acl_clear_perms(permissions) == 0 && + acl_add_perm(permissions, ACL_LIST_DIRECTORY) == 0 && + acl_add_perm(permissions, ACL_SEARCH) == 0 && + acl_set_permset(entry, permissions) == 0 && acl_get_flagset_np(entry, &flags) == 0 && + acl_clear_flags_np(flags) == 0 && + acl_add_flag_np(flags, ACL_ENTRY_FILE_INHERIT) == 0 && + acl_add_flag_np(flags, ACL_ENTRY_DIRECTORY_INHERIT) == 0 && + acl_set_flagset_np(entry, flags) == 0; + } + if (ok) + ok = acl_set_file(path, ACL_TYPE_EXTENDED, acl) == 0; + return acl_free(acl) == 0 && ok; +} + +static bool pt_mac_named_observed(const char *path) { + struct passwd *principal = getpwnam("nobody"); + uuid_t expected; + if (!principal || principal->pw_uid == geteuid() || + mbr_uid_to_uuid(principal->pw_uid, expected) != 0) + return false; + acl_t acl = acl_get_file(path, ACL_TYPE_EXTENDED); + if (!acl) + return false; + acl_entry_t entry; + acl_tag_t tag; + acl_permset_t permissions; + acl_flagset_t flags; + bool ok = + acl_get_entry(acl, ACL_FIRST_ENTRY, &entry) == 0 && acl_get_tag_type(entry, &tag) == 0 && + tag == ACL_EXTENDED_ALLOW && acl_get_permset(entry, &permissions) == 0 && + acl_get_perm_np(permissions, ACL_LIST_DIRECTORY) == 1 && + acl_get_perm_np(permissions, ACL_SEARCH) == 1 && acl_get_flagset_np(entry, &flags) == 0 && + acl_get_flag_np(flags, ACL_ENTRY_FILE_INHERIT) == 1 && + acl_get_flag_np(flags, ACL_ENTRY_DIRECTORY_INHERIT) == 1; + void *qualifier = ok ? acl_get_qualifier(entry) : NULL; + ok = ok && qualifier && !memcmp(qualifier, expected, sizeof(expected)); + if (qualifier) + ok = acl_free(qualifier) == 0 && ok; + return acl_free(acl) == 0 && ok; +} +#else +static void pt_le16(unsigned char *out, unsigned n) { + out[0] = (unsigned char)n; + out[1] = (unsigned char)(n >> 8); +} +static void pt_le32(unsigned char *out, uint32_t n) { + for (size_t i = 0; i < 4; i++) + out[i] = (unsigned char)(n >> (8 * i)); +} + +static bool pt_linux_acl(const char *path, bool defaults, bool install) { + const char *key = defaults ? "system.posix_acl_default" : "system.posix_acl_access"; + if (!install) + return removexattr(path, key) == 0 || errno == ENODATA; + unsigned char bytes[44] = {0}; + const unsigned access_tags[5] = {1, 2, 4, 16, 32}; + const unsigned default_tags[3] = {1, 4, 32}; + const unsigned *tags = defaults ? default_tags : access_tags; + size_t count = defaults ? 3 : 5; + struct passwd *principal = getpwnam("nobody"); + if (!principal || principal->pw_uid == geteuid()) + return false; + pt_le32(bytes, 2); + for (size_t i = 0; i < count; i++) { + unsigned char *entry = bytes + 4 + 8 * i; + unsigned permission = tags[i] == 1 || tags[i] == 2 ? 7 : 0; + pt_le16(entry, tags[i]); + pt_le16(entry + 2, permission); + pt_le32(entry + 4, tags[i] == 2 ? (uint32_t)principal->pw_uid : UINT32_MAX); + } + size_t length = 4 + 8 * count; + if (setxattr(path, key, bytes, length, 0) != 0) + return false; + unsigned char observed[44]; + ssize_t n = getxattr(path, key, observed, sizeof(observed)); + return n == (ssize_t)length && !memcmp(bytes, observed, length); +} +#endif +#endif + +/* Full native parent-security snapshots for family E. The test serialization + * contains semantic fields or documented external ACL bytes, never pointers or + * an in-memory security descriptor's padding. Unknown/oversized state fails the + * preservation assertion; no successful prefix is accepted. */ +#ifdef _WIN32 +static bool pt_acl_windows_sid(pt_wire *snapshot, PSID sid) { + pt_wnumber(snapshot, sid != NULL, 1); + if (!sid) + return snapshot->ok; + if (!IsValidSid(sid)) + return false; + DWORD length = GetLengthSid(sid); + pt_wnumber(snapshot, length, 4); + pt_wbytes(snapshot, sid, length); + return snapshot->ok; +} + +static bool pt_acl_windows_ace(pt_wire *snapshot, const ACE_HEADER *header) { + /* The fixture installs ordinary allow ACEs. Deny ACEs are also completely + * represented so changing the type cannot hide behind a policy predicate. + * Any other post-call ACE form is itself a failed preservation witness. */ + if (header->AceType != ACCESS_ALLOWED_ACE_TYPE && header->AceType != ACCESS_DENIED_ACE_TYPE) + return false; + size_t offset = offsetof(ACCESS_ALLOWED_ACE, SidStart); + if (header->AceSize < offset + 8) + return false; + const unsigned char *bytes = (const unsigned char *)header; + const unsigned char *sid_bytes = bytes + offset; + size_t sid_length = 8 + 4 * (size_t)sid_bytes[1]; + if (sid_length > (size_t)header->AceSize - offset) + return false; + DWORD rights; + memcpy(&rights, bytes + offsetof(ACCESS_ALLOWED_ACE, Mask), sizeof(rights)); + pt_wnumber(snapshot, header->AceType, 1); + pt_wnumber(snapshot, header->AceFlags, 1); + pt_wnumber(snapshot, rights, 4); + return pt_acl_windows_sid(snapshot, (PSID)sid_bytes); +} + +static bool pt_acl_windows_descriptor(pt_wire *snapshot, PSECURITY_DESCRIPTOR descriptor) { + SECURITY_DESCRIPTOR_CONTROL control; + DWORD revision; + PSID owner = NULL; + PACL dacl = NULL; + BOOL owner_defaulted = FALSE, present = FALSE, defaulted = FALSE; + if (!IsValidSecurityDescriptor(descriptor) || + !GetSecurityDescriptorControl(descriptor, &control, &revision) || + !GetSecurityDescriptorOwner(descriptor, &owner, &owner_defaulted) || + !GetSecurityDescriptorDacl(descriptor, &present, &dacl, &defaulted)) + return false; + /* SELF_RELATIVE describes storage form, not a security-policy difference. */ + pt_wnumber(snapshot, control & ~(unsigned)SE_SELF_RELATIVE, 4); + pt_wnumber(snapshot, revision, 4); + pt_wnumber(snapshot, owner_defaulted != FALSE, 1); + if (!pt_acl_windows_sid(snapshot, owner)) + return false; + pt_wnumber(snapshot, present != FALSE, 1); + pt_wnumber(snapshot, defaulted != FALSE, 1); + pt_wnumber(snapshot, dacl != NULL, 1); + if (!dacl) + return snapshot->ok; + if (!IsValidAcl(dacl)) + return false; + pt_wnumber(snapshot, dacl->AclRevision, 1); + pt_wnumber(snapshot, dacl->AceCount, 4); + for (DWORD i = 0; i < dacl->AceCount; i++) { + void *entry = NULL; + if (!GetAce(dacl, i, &entry) || !entry || !pt_acl_windows_ace(snapshot, entry)) + return false; + } + return snapshot->ok; +} + +static bool pt_acl_snapshot(pt_fixture *fx, const char *path, pt_wire *snapshot) { + *snapshot = (pt_wire){.ok = true}; + wchar_t wide[PT_PATH]; + if (!pt_wide(path, wide)) + return false; + PSECURITY_DESCRIPTOR descriptor = NULL; + DWORD status = GetNamedSecurityInfoW(wide, SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + NULL, NULL, NULL, NULL, &descriptor); + bool ok = + status == ERROR_SUCCESS && descriptor && pt_acl_windows_descriptor(snapshot, descriptor); + if (descriptor && LocalFree(descriptor) != NULL) + ok = false; + (void)fx; + return ok; +} +#elif defined(__APPLE__) +static bool pt_acl_mac_entries(acl_t acl, pt_wire *snapshot) { + size_t count = 0; + int cursor = ACL_FIRST_ENTRY; + for (;;) { + acl_entry_t entry; + errno = 0; + if (acl_get_entry(acl, cursor, &entry) != 0) { + if (errno != EINVAL) + return false; + break; + } + if (++count > ACL_MAX_ENTRIES) + return false; + cursor = ACL_NEXT_ENTRY; + acl_tag_t tag; + acl_permset_mask_t permissions; + if (acl_get_tag_type(entry, &tag) != 0 || acl_get_permset_mask_np(entry, &permissions) != 0) + return false; + void *principal = acl_get_qualifier(entry); + if (!principal) + return false; + pt_wnumber(snapshot, (uint32_t)tag, 4); + pt_wnumber(snapshot, permissions, 8); + pt_wbytes(snapshot, principal, sizeof(uuid_t)); + if (acl_free(principal) != 0 || !snapshot->ok) + return false; + } + pt_wnumber(snapshot, count, 4); + return snapshot->ok; +} + +static bool pt_acl_snapshot(pt_fixture *fx, const char *path, pt_wire *snapshot) { + *snapshot = (pt_wire){.ok = true}; + acl_t acl = NULL; + bool absent = false; + if (!pt_mac_read_acl(path, &acl, &absent)) + return false; + /* Preserve absent versus present native state; a returned empty ACL still + * retains its complete external bytes and flags below. */ + pt_wnumber(snapshot, !absent, 1); + if (absent) + return snapshot->ok; + ssize_t length = acl_size(acl); + bool ok = length > 0 && (size_t)length <= sizeof(snapshot->bytes); + unsigned char *external = ok ? cbm_arena_calloc(&fx->arena, (size_t)length) : NULL; + ok = external && acl_copy_ext_native(external, acl, length) == length; + if (ok) { + /* Public, serializable external representation binds ALL ACL/ACE flags, + * including reserved native bits. Its internal format is not assumed. */ + pt_wnumber(snapshot, (uint64_t)length, 8); + pt_wbytes(snapshot, external, (size_t)length); + ok = pt_acl_mac_entries(acl, snapshot); + } + return acl_free(acl) == 0 && ok && snapshot->ok; +} +#else +static bool pt_acl_linux_attribute(pt_fixture *fx, const char *path, const char *key, + pt_wire *snapshot) { + errno = 0; + ssize_t length = getxattr(path, key, NULL, 0); + if (length < 0) { + if (errno != ENODATA) + return false; + pt_wnumber(snapshot, 0, 1); + return snapshot->ok; + } + if ((size_t)length > 4096) + return false; + size_t capacity = length ? (size_t)length : 1; + unsigned char *bytes = cbm_arena_alloc(&fx->arena, capacity); + if (!bytes || getxattr(path, key, bytes, capacity) != length) + return false; + pt_wnumber(snapshot, 1, 1); + pt_wnumber(snapshot, (uint64_t)length, 8); + pt_wbytes(snapshot, bytes, (size_t)length); + return snapshot->ok; +} + +static bool pt_acl_snapshot(pt_fixture *fx, const char *path, pt_wire *snapshot) { + *snapshot = (pt_wire){.ok = true}; + return pt_acl_linux_attribute(fx, path, "system.posix_acl_access", snapshot) && + pt_acl_linux_attribute(fx, path, "system.posix_acl_default", snapshot); +} +#endif + +static bool pt_acl_preserved(pt_fixture *fx, const char *path, const pt_wire *before) { + pt_wire after; + return before->ok && pt_acl_snapshot(fx, path, &after) && before->used == after.used && + !memcmp(before->bytes, after.bytes, before->used); +} + +TEST(tree_e_native_permissions_and_acl_policy) { + pt_fixture fx = {0}; + int result = 1; + bool parent_acl = false, tracked_acl = false; + char tracked[PT_PATH] = {0}; + pt_wire parent_before; + PT_CHECK(pt_setup_step("setup:init", pt_init(&fx, 40)) && + pt_setup_step("setup:populate", pt_populate(&fx)) && + pt_setup_step("setup:facts", pt_open_facts(&fx)) && + pt_setup_step("setup:control", pt_fact_control(&fx))); + PT_CHECK(pt_policy(fx.parent, true, false)); +#ifdef __APPLE__ + char missing_acl[PT_PATH]; + struct stat missing_stat; + pt_wire missing_snapshot; + PT_CHECK(pt_join(missing_acl, fx.parent, "acl-nonexistent-control")); + errno = 0; + PT_CHECK(lstat(missing_acl, &missing_stat) == -1 && errno == ENOENT); + PT_CHECK(!pt_acl_absent(missing_acl, true)); + PT_CHECK(!pt_acl_snapshot(&fx, missing_acl, &missing_snapshot)); +#endif + PT_CHECK(pt_acl_snapshot(&fx, fx.parent, &parent_before)); + PT_CHECK(pt_acl_preserved(&fx, fx.parent, &parent_before)); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_HEAD); + cbm_pinned_tree_error_t error; + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_view_matches(&fx, cbm_pinned_tree_view(fx.tree), false)); + PT_CHECK(cbm_pinned_tree_verify(fx.tree, &options.control, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_close_one(&fx.tree)); + PT_CHECK(pt_acl_preserved(&fx, fx.parent, &parent_before)); +#ifdef _WIN32 + /* WHY: Win32 DACL semantics have no POSIX-mode equivalent. The native + * owner-only positive above precedes an observed extra-world ACE. */ + parent_acl = true; + PT_CHECK(pt_windows_acl(&fx, fx.parent, true)); + PT_CHECK(!pt_policy(fx.parent, true, false)); + PT_CHECK(pt_acl_snapshot(&fx, fx.parent, &parent_before)); + PT_CHECK(pt_acl_preserved(&fx, fx.parent, &parent_before)); + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_UNSUPPORTED)); + PT_CHECK(pt_acl_preserved(&fx, fx.parent, &parent_before)); + PT_CHECK(!pt_policy(fx.parent, true, false)); + PT_CHECK(pt_windows_acl(&fx, fx.parent, false)); + parent_acl = false; +#elif defined(__APPLE__) + PT_CHECK(pt_passing_posix_mode(fx.parent, 0700)); + parent_acl = true; + PT_CHECK(pt_mac_named_acl(fx.parent, true)); + PT_CHECK(pt_passing_posix_mode(fx.parent, 0700) && pt_mac_named_observed(fx.parent)); + PT_CHECK(pt_acl_snapshot(&fx, fx.parent, &parent_before)); + PT_CHECK(pt_acl_preserved(&fx, fx.parent, &parent_before)); + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_UNSUPPORTED)); + PT_CHECK(pt_acl_preserved(&fx, fx.parent, &parent_before)); + PT_CHECK(pt_passing_posix_mode(fx.parent, 0700) && pt_mac_named_observed(fx.parent)); + PT_CHECK(pt_mac_named_acl(fx.parent, false)); + parent_acl = false; +#else + for (unsigned defaults = 0; defaults < 2; defaults++) { + parent_acl = true; + PT_CHECK(pt_linux_acl(fx.parent, defaults != 0, true)); + PT_CHECK(pt_passing_posix_mode(fx.parent, 0700)); + PT_CHECK(pt_acl_snapshot(&fx, fx.parent, &parent_before)); + PT_CHECK(pt_acl_preserved(&fx, fx.parent, &parent_before)); + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_UNSUPPORTED)); + PT_CHECK(pt_acl_preserved(&fx, fx.parent, &parent_before)); + PT_CHECK(pt_linux_acl(fx.parent, defaults != 0, false)); + parent_acl = false; + } +#endif + PT_CHECK(pt_policy(fx.parent, true, false)); + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_join(tracked, cbm_pinned_tree_view(fx.tree)->root, "nested")); +#ifdef _WIN32 + tracked_acl = true; + PT_CHECK(pt_windows_acl(&fx, tracked, true)); +#elif defined(__APPLE__) + tracked_acl = true; + PT_CHECK(pt_mac_named_acl(tracked, true)); + PT_CHECK(pt_passing_posix_mode(tracked, 0700) && pt_mac_named_observed(tracked)); +#else + tracked_acl = true; + PT_CHECK(pt_linux_acl(tracked, false, true)); + PT_CHECK(pt_passing_posix_mode(tracked, 0700)); +#endif + PT_CHECK(cbm_pinned_tree_verify(fx.tree, &options.control, &error) == CBM_PINNED_TREE_CHANGED); + PT_CHECK(!cbm_pinned_tree_view(fx.tree)); + result = 0; +done: +#ifdef _WIN32 + if (parent_acl && !pt_windows_acl(&fx, fx.parent, false)) + result = 1; + if (tracked_acl && !pt_windows_acl(&fx, tracked, false)) + result = 1; +#elif defined(__APPLE__) + if (parent_acl && !pt_mac_named_acl(fx.parent, false)) + result = 1; + if (tracked_acl && !pt_mac_named_acl(tracked, false)) + result = 1; +#else + if (parent_acl && + (!pt_linux_acl(fx.parent, false, false) || !pt_linux_acl(fx.parent, true, false))) + result = 1; + if (tracked_acl && !pt_linux_acl(tracked, false, false)) + result = 1; +#endif + return pt_finish(&fx, result); +} + +typedef struct { + size_t calls, stop; +} pt_cancel; +static bool pt_cancelled(void *context) { + pt_cancel *control = context; + control->calls++; + return control->stop && control->calls >= control->stop; +} + +static bool pt_create_ok(pt_fixture *fx, cbm_pinned_tree_options_t *options) { + cbm_pinned_tree_error_t error; + return cbm_pinned_tree_create(options, &fx->tree, &error) == CBM_PINNED_TREE_OK && + error.status == CBM_PINNED_TREE_OK && error.cause == CBM_PINNED_TREE_OK && + pt_view_matches(fx, cbm_pinned_tree_view(fx->tree), false); +} + +static bool pt_limit_pairs(pt_fixture *fx) { + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + size_t *fields[] = {&options.limits.max_files, &options.limits.max_directories, + &options.limits.max_total_content_bytes, + &options.limits.max_relative_path_bytes}; + const size_t exact[] = {fx->count, 2, pt_total(fx), strlen("nested/source.c")}; + for (size_t i = 0; i < 4; i++) { + options = pt_options(fx, CBM_GIT_REV_HEAD); + *fields[i] = exact[i]; + if (!pt_create_ok(fx, &options) || !pt_close_one(&fx->tree)) + return false; + *fields[i] = exact[i] - 1; + if (!pt_clean_failure(fx, &options, CBM_PINNED_TREE_LIMIT)) + return false; + } + options = pt_options(fx, CBM_GIT_REV_HEAD); + options.limits.max_arena_bytes = 1; + if (!pt_clean_failure(fx, &options, CBM_PINNED_TREE_LIMIT)) + return false; + /* Find this platform's logical tariff without encoding allocation layout. + * Upper cap and at most24 bisections are explicit and bounded. */ + size_t low = 1, high = 16U * 1024U * 1024U; + options.limits.max_arena_bytes = high; + if (!pt_create_ok(fx, &options) || !pt_close_one(&fx->tree)) + return false; + for (unsigned step = 0; low < high && step < 24; step++) { + size_t middle = low + (high - low) / 2; + options.limits.max_arena_bytes = middle; + cbm_pinned_tree_error_t error; + cbm_pinned_tree_status_t status = cbm_pinned_tree_create(&options, &fx->tree, &error); + if (status == CBM_PINNED_TREE_OK) { + if (!pt_view_matches(fx, cbm_pinned_tree_view(fx->tree), false) || + !pt_close_one(&fx->tree)) + return false; + high = middle; + } else { + if (status != CBM_PINNED_TREE_LIMIT || error.status != status || fx->tree || + !pt_parent_only_sentinel(fx->parent)) + return false; + low = middle + 1; + } + } + if (low != high || low <= 1) + return false; + options.limits.max_arena_bytes = low; + if (!pt_create_ok(fx, &options) || !pt_close_one(&fx->tree)) + return false; + options.limits.max_arena_bytes = low - 1; + return pt_clean_failure(fx, &options, CBM_PINNED_TREE_LIMIT); +} + +static bool pt_cancel_create_cases(pt_fixture *fx) { + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + pt_cancel counter = {0}; + options.control.cancelled = pt_cancelled; + options.control.cancel_context = &counter; + if (!pt_create_ok(fx, &options) || !counter.calls || !pt_close_one(&fx->tree)) + return false; + size_t total = counter.calls; + size_t stops[] = {1, (total + 1) / 2, total, total + 1}; + size_t cancelled = 0; + for (size_t i = 0; i < 4; i++) { + counter = (pt_cancel){.stop = stops[i]}; + cbm_pinned_tree_error_t error; + cbm_pinned_tree_status_t status = cbm_pinned_tree_create(&options, &fx->tree, &error); + if (status == CBM_PINNED_TREE_OK) { + if (counter.calls >= counter.stop || + !pt_view_matches(fx, cbm_pinned_tree_view(fx->tree), false) || + !pt_close_one(&fx->tree)) + return false; + } else { + if (status != CBM_PINNED_TREE_CANCELLED || error.status != status || + counter.calls < counter.stop || fx->tree || !pt_parent_only_sentinel(fx->parent)) + return false; + cancelled++; + } + } + options.control.cancelled = NULL; + options.control.cancel_context = NULL; + return cancelled && pt_create_ok(fx, &options) && pt_close_one(&fx->tree); +} + +static bool pt_cancel_verify_cases(pt_fixture *fx) { + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + pt_cancel counter = {0}; + cbm_pinned_tree_control_t control = options.control; + control.cancelled = pt_cancelled; + control.cancel_context = &counter; + cbm_pinned_tree_error_t error; + if (!pt_create_ok(fx, &options) || + cbm_pinned_tree_verify(fx->tree, &control, &error) != CBM_PINNED_TREE_OK || + !counter.calls || !pt_view_matches(fx, cbm_pinned_tree_view(fx->tree), false) || + !pt_close_one(&fx->tree)) + return false; + size_t total = counter.calls; + const size_t stops[] = {1, (total + 1) / 2, total, total + 1}; + size_t cancelled = 0; + for (size_t i = 0; i < 4; i++) { + if (!pt_create_ok(fx, &options)) + return false; + counter = (pt_cancel){.stop = stops[i]}; + cbm_pinned_tree_status_t status = cbm_pinned_tree_verify(fx->tree, &control, &error); + if (status == CBM_PINNED_TREE_OK) { + if (counter.calls >= counter.stop || + !pt_view_matches(fx, cbm_pinned_tree_view(fx->tree), false)) + return false; + } else { + if (status != CBM_PINNED_TREE_CANCELLED || counter.calls < counter.stop || + cbm_pinned_tree_view(fx->tree)) + return false; + cancelled++; + } + if (!pt_close_one(&fx->tree) || !pt_parent_only_sentinel(fx->parent)) + return false; + } + return cancelled != 0; +} + +TEST(tree_f_resource_limits_and_local_control) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(pt_setup_step("setup:init", pt_init(&fx, 40)) && + pt_setup_step("setup:populate", pt_populate(&fx)) && + pt_setup_step("setup:facts", pt_open_facts(&fx)) && + pt_setup_step("setup:control", pt_fact_control(&fx))); + PT_CHECK(pt_limit_pairs(&fx)); + PT_CHECK(pt_cancel_create_cases(&fx) && pt_cancel_verify_cases(&fx)); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_HEAD); + PT_CHECK(pt_create_ok(&fx, &options) && pt_close_one(&fx.tree)); + options.control.deadline_ms = 1; + PT_CHECK(cbm_now_ms() > 1 && pt_clean_failure(&fx, &options, CBM_PINNED_TREE_DEADLINE)); + options = pt_options(&fx, CBM_GIT_REV_HEAD); + options.limits.max_files = 0; + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_INVALID)); + options = pt_options(&fx, CBM_GIT_REV_HEAD); + options.limits.max_relative_path_bytes = 4096; + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_INVALID)); + options = pt_options(&fx, CBM_GIT_REV_BASE); + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_INVALID)); + options = pt_options(&fx, CBM_GIT_REV_HEAD); + options.control.deadline_ms = 0; + PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_INVALID)); + options = pt_options(&fx, CBM_GIT_REV_HEAD); + options.limits.blob_batch.max_entries = 1; + cbm_pinned_tree_error_t error; + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_GIT); + PT_CHECK(!fx.tree && error.status == CBM_PINNED_TREE_GIT && + error.git.status == CBM_GIT_FACTS_LIMIT && pt_parent_only_sentinel(fx.parent)); + result = 0; +done: + return pt_finish(&fx, result); +} + +static bool pt_restore_readonly(const char *path, bool executable) { +#ifdef _WIN32 + wchar_t wide[PT_PATH]; + if (!pt_wide(path, wide)) + return false; + DWORD attributes = GetFileAttributesW(wide); + (void)executable; + return attributes != INVALID_FILE_ATTRIBUTES && + SetFileAttributesW(wide, attributes | FILE_ATTRIBUTE_READONLY); +#else + return chmod(path, executable ? 0500 : 0400) == 0; +#endif +} + +/* Each mutation happens between synchronous calls, with no worker or child + * using the tree. Type/link cases have explicit native counterparts below. */ +static bool pt_mutation_case(pt_fixture *fx, unsigned kind) { + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + if (!pt_create_ok(fx, &options)) + return false; + char path[PT_PATH], other[PT_PATH]; + const char *root = cbm_pinned_tree_view(fx->tree)->root; + if (!pt_join(path, root, "binary.bin") || !pt_join(other, root, "unexpected")) + return false; + bool ok = false; + if (kind == 0) + ok = pt_write(other, "new", 3); + else if (kind == 1) + ok = pt_make_writable(path) && cbm_unlink(path) == 0; + else if (kind == 2) + ok = cbm_rename_noreplace(path, other) == 0; + else if (kind == 3) + ok = pt_make_writable(path) && pt_write(path, pt_binary, sizeof(pt_binary) - 1) && + pt_restore_readonly(path, false); + else if (kind == 4) { + unsigned char changed[sizeof(pt_binary)]; + memcpy(changed, pt_binary, sizeof(changed)); + changed[0] = 'B'; + ok = pt_make_writable(path) && pt_write(path, changed, sizeof(changed)) && + pt_restore_readonly(path, false); + } else if (kind == 5) + ok = pt_make_writable(path); + if (!ok) + return false; + cbm_pinned_tree_error_t error; + ok = cbm_pinned_tree_verify(fx->tree, &options.control, &error) == CBM_PINNED_TREE_CHANGED && + error.status == CBM_PINNED_TREE_CHANGED && !cbm_pinned_tree_view(fx->tree); + if (kind == 0) + ok = cbm_unlink(other) == 0 && ok; + if (kind == 2) + ok = cbm_rename_noreplace(other, path) == 0 && ok; + if (kind == 5) + ok = pt_restore_readonly(path, false) && ok; + /* A failed owner cannot be turned into a newly approved snapshot. */ + ok = cbm_pinned_tree_verify(fx->tree, &options.control, &error) != CBM_PINNED_TREE_OK && + !cbm_pinned_tree_view(fx->tree) && ok; + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent) && ok; +} + +TEST(tree_g_verify_state_and_complete_inventory) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(pt_setup_step("setup:init", pt_init(&fx, 40)) && + pt_setup_step("setup:populate", pt_populate(&fx)) && + pt_setup_step("setup:facts", pt_open_facts(&fx)) && + pt_setup_step("setup:control", pt_fact_control(&fx))); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_HEAD); + PT_CHECK(pt_create_ok(&fx, &options)); + cbm_pinned_tree_error_t error; + unsigned char digest[32]; + memcpy(digest, cbm_pinned_tree_view(fx.tree)->manifest_sha256, sizeof(digest)); + cbm_pinned_tree_control_t invalid = {.deadline_ms = 0}; + PT_CHECK(cbm_pinned_tree_verify(fx.tree, &invalid, &error) == CBM_PINNED_TREE_INVALID); + PT_CHECK(pt_view_matches(&fx, cbm_pinned_tree_view(fx.tree), false)); + PT_CHECK(cbm_pinned_tree_verify(fx.tree, &options.control, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(!memcmp(cbm_pinned_tree_view(fx.tree)->manifest_sha256, digest, sizeof(digest))); + PT_CHECK(pt_close_one(&fx.tree)); + for (unsigned kind = 0; kind < 6; kind++) + PT_CHECK(pt_mutation_case(&fx, kind)); + PT_CHECK(pt_create_ok(&fx, &options)); + cbm_pinned_tree_control_t expired = {.deadline_ms = 1}; + PT_CHECK(cbm_pinned_tree_verify(fx.tree, &expired, &error) == CBM_PINNED_TREE_DEADLINE); + PT_CHECK(!cbm_pinned_tree_view(fx.tree) && pt_close_one(&fx.tree)); + PT_CHECK(cbm_pinned_tree_close(NULL, &error) == CBM_PINNED_TREE_INVALID); + PT_CHECK(cbm_pinned_tree_close(&fx.tree, &error) == CBM_PINNED_TREE_OK && !fx.tree); + result = 0; +done: + return pt_finish(&fx, result); +} + +/* Windows may retain a directory handle without delete-sharing. This is an + * observed native applicability branch, never a whole-family platform escape. + */ +/* These paths belong exclusively to this fixture; no concurrent namespace + * writers are admitted. POSIX checks an absent destination before rename under + * that custody assumption. This is not a general atomic no-replace primitive. */ +static bool pt_move_owned_directory(const char *source, const char *destination) { +#ifdef _WIN32 + wchar_t from[PT_PATH], to[PT_PATH]; + return pt_wide(source, from) && pt_wide(destination, to) && MoveFileExW(from, to, 0) != 0; +#else + struct stat st; + if (lstat(source, &st) != 0) + return false; + if (!S_ISDIR(st.st_mode)) { + errno = ENOTDIR; + return false; + } + if (lstat(destination, &st) == 0) { + errno = EEXIST; + return false; + } + if (errno != ENOENT) + return false; + return rename(source, destination) == 0; +#endif +} + +static bool pt_relocate_directory(const char *source, const char *destination, bool *blocked) { + *blocked = false; +#ifdef _WIN32 + wchar_t from[PT_PATH], to[PT_PATH]; + if (!pt_wide(source, from) || !pt_wide(destination, to)) + return false; + if (MoveFileExW(from, to, 0)) + return true; + DWORD error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION && error != ERROR_ACCESS_DENIED) + return false; + fprintf(stderr, + "pinned-tree relocation applicability: WHY native directory mutation " + "denied; " + "tried MoveFileExW, observed error=%lu; unchanged-tree verification " + "follows\n", + (unsigned long)error); + *blocked = true; + return true; +#else + return pt_move_owned_directory(source, destination); +#endif +} + +/* H-only diagnostics: each predicate and API call retains its original order. */ +static bool pt_h_tree_step(const char *helper, const char *stage, bool ok, + cbm_pinned_tree_status_t status, const cbm_pinned_tree_error_t *error) { + if (ok) + return true; + int saved_errno = errno; +#ifdef _WIN32 + DWORD saved_error = GetLastError(); +#endif + fprintf(stderr, + "pinned-tree H helper=%s stage=%s return=%d error.status=%d cause=%d " + "errno=%d diagnostic='", + helper, stage, (int)status, (int)error->status, (int)error->cause, saved_errno); + size_t length = 0; + while (length < sizeof(error->diagnostic) && error->diagnostic[length]) + length++; + pt_diag_bytes((const unsigned char *)error->diagnostic, length); + fputs("'\n", stderr); +#ifdef _WIN32 + SetLastError(saved_error); +#endif + errno = saved_errno; + return false; +} + +static bool pt_h_status(const char *helper, const char *stage, cbm_pinned_tree_status_t actual, + cbm_pinned_tree_status_t expected, const cbm_pinned_tree_error_t *error) { + return pt_h_tree_step(helper, stage, actual == expected, actual, error); +} + +static bool pt_h_create_ok(const char *helper, pt_fixture *fx, cbm_pinned_tree_options_t *options) { + cbm_pinned_tree_error_t error; + cbm_pinned_tree_status_t status = cbm_pinned_tree_create(options, &fx->tree, &error); + return pt_h_status(helper, "create", status, CBM_PINNED_TREE_OK, &error) && + pt_h_tree_step(helper, "create-error-state", + error.status == CBM_PINNED_TREE_OK && error.cause == CBM_PINNED_TREE_OK, + status, &error) && + pt_h_tree_step(helper, "create-view", + pt_view_matches(fx, cbm_pinned_tree_view(fx->tree), false), status, + &error); +} + +static bool pt_h_close_one(const char *helper, cbm_pinned_tree_t **tree) { + if (!*tree) + return true; + cbm_pinned_tree_error_t error; + cbm_pinned_tree_status_t status = cbm_pinned_tree_close(tree, &error); + return pt_h_tree_step(helper, "final-close", status == CBM_PINNED_TREE_OK && !*tree, status, + &error); +} + +static bool pt_unchanged_after_blocked_move(const char *helper, pt_fixture *fx, + cbm_pinned_tree_options_t *options) { + cbm_pinned_tree_error_t error; + cbm_pinned_tree_status_t status = cbm_pinned_tree_verify(fx->tree, &options->control, &error); + bool ok = + pt_h_status(helper, "blocked-move-verify", status, CBM_PINNED_TREE_OK, &error) && + pt_h_tree_step(helper, "blocked-move-view", + pt_view_matches(fx, cbm_pinned_tree_view(fx->tree), false), status, &error); + return pt_h_close_one(helper, &fx->tree) && + pt_setup_step("H:blocked-move-parent-preserve", pt_parent_only_sentinel(fx->parent)) && + ok; +} + +#ifdef _WIN32 +/* A mount-point reparse fixture uses an owned empty directory and an owned + * target. It does not depend on symbolic-link privilege or developer mode. */ +static bool pt_junction(const char *path, const char *target) { + wchar_t wide[PT_PATH], destination[PT_PATH]; + if (!pt_wide(path, wide) || !pt_wide(target, destination)) + return false; + for (wchar_t *p = destination; *p; p++) + if (*p == L'/') + *p = L'\\'; + if (!destination[0] || destination[1] != L':') + return false; + struct { + DWORD tag; + WORD data_length, reserved, substitute_offset, substitute_length, print_offset, + print_length; + WCHAR path[PT_PATH + 8]; + } buffer = {0}; + size_t length = wcslen(destination); + if (length + 5 >= sizeof(buffer.path) / sizeof(buffer.path[0])) + return false; + buffer.tag = IO_REPARSE_TAG_MOUNT_POINT; + memcpy(buffer.path, L"\\??\\", 4 * sizeof(WCHAR)); + memcpy(buffer.path + 4, destination, (length + 1) * sizeof(WCHAR)); + buffer.substitute_length = (WORD)((length + 4) * sizeof(WCHAR)); + buffer.print_offset = (WORD)(buffer.substitute_length + sizeof(WCHAR)); + buffer.print_length = 0; + buffer.path[length + 5] = 0; + buffer.data_length = (WORD)(8 + buffer.substitute_length + 2 * sizeof(WCHAR)); + HANDLE directory = CreateFileW(wide, GENERIC_WRITE, 0, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (directory == INVALID_HANDLE_VALUE) + return false; + DWORD returned = 0; + bool ok = DeviceIoControl(directory, FSCTL_SET_REPARSE_POINT, &buffer, + (DWORD)(8 + buffer.data_length), NULL, 0, &returned, NULL) != 0; + return CloseHandle(directory) && ok; +} +#endif + +static bool pt_link_case(pt_fixture *fx) { + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + if (!pt_h_create_ok("link", fx, &options)) + return false; + char root[PT_PATH], tracked[PT_PATH], saved[PT_PATH], outside[PT_PATH], sentinel[PT_PATH]; + strcpy(root, cbm_pinned_tree_view(fx->tree)->root); + if (!pt_join(tracked, root, "nested") || !pt_join(saved, root, "parked-dir") || + !pt_join(outside, fx->home, "link-target") || cbm_mkdir(outside) != 0 || + !pt_join(sentinel, outside, "keep") || !pt_write(sentinel, "target", 6)) + return pt_setup_step("H:link:paths-outside-sentinel", false); + bool blocked = false; + if (!pt_relocate_directory(tracked, saved, &blocked)) + return pt_setup_step("H:link:relocate", false); + if (blocked) + return pt_unchanged_after_blocked_move("link", fx, &options) && + pt_setup_step("H:link:blocked-preserve", pt_equal_file(sentinel, "target", 6)); + bool installed = false, ok = false; +#ifdef _WIN32 + if (!pt_setup_step("H:link:install-mkdir", cbm_mkdir(tracked) == 0)) + goto restore; + installed = true; + if (!pt_setup_step("H:link:install-junction", pt_junction(tracked, outside))) + goto restore; +#else + if (!pt_setup_step("H:link:install-symlink", symlink(outside, tracked) == 0)) + goto restore; + installed = true; +#endif + cbm_pinned_tree_error_t error; + ok = pt_h_status("link", "verify", cbm_pinned_tree_verify(fx->tree, &options.control, &error), + CBM_PINNED_TREE_CHANGED, &error) && + pt_setup_step("H:link:verify-view-unavailable", !cbm_pinned_tree_view(fx->tree)) && + pt_h_status("link", "close", cbm_pinned_tree_close(&fx->tree, &error), + CBM_PINNED_TREE_CLEANUP_REQUIRED, &error) && + pt_setup_step("H:link:close-owner-retained", fx->tree != NULL) && + pt_setup_step("H:link:close-view-unavailable", !cbm_pinned_tree_view(fx->tree)) && + pt_setup_step("H:link:preserve-target", pt_equal_file(sentinel, "target", 6)); +restore: + if (installed) { +#ifdef _WIN32 + wchar_t wide[PT_PATH]; + if (!pt_wide(tracked, wide) || !RemoveDirectoryW(wide)) { + (void)pt_setup_step("H:link:restore-remove-junction", false); + ok = false; + } +#else + if (!pt_setup_step("H:link:restore-remove-symlink", cbm_unlink(tracked) == 0)) + ok = false; +#endif + } + if (!pt_setup_step("H:link:restore-directory", pt_move_owned_directory(saved, tracked))) + ok = false; + return pt_h_close_one("link", &fx->tree) && + pt_setup_step("H:link:final-preserve-target", pt_equal_file(sentinel, "target", 6)) && + ok; +} + +static bool pt_root_replacement_case(pt_fixture *fx) { + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + if (!pt_h_create_ok("root-replacement", fx, &options)) + return false; + char original[PT_PATH], moved[PT_PATH], marker[PT_PATH], preserved[PT_PATH]; + strcpy(original, cbm_pinned_tree_view(fx->tree)->root); + bool blocked = false; + if (!pt_join(moved, fx->parent, "parked-root") || + !pt_relocate_directory(original, moved, &blocked)) + return pt_setup_step("H:root-replacement:relocate", false); + if (blocked) + return pt_unchanged_after_blocked_move("root-replacement", fx, &options); + bool replacement = + pt_setup_step("H:root-replacement:install-directory", cbm_mkdir(original) == 0); + bool ok = + replacement && + pt_setup_step("H:root-replacement:marker-path", pt_join(marker, original, "other-owner")) && + pt_setup_step("H:root-replacement:marker-write", pt_write(marker, "keep", 4)) && + pt_setup_step("H:root-replacement:preserved-path", pt_join(preserved, moved, "binary.bin")); + cbm_pinned_tree_error_t error; + if (ok) + ok = + pt_h_status("root-replacement", "close", cbm_pinned_tree_close(&fx->tree, &error), + CBM_PINNED_TREE_CLEANUP_REQUIRED, &error) && + pt_setup_step("H:root-replacement:close-owner-retained", fx->tree != NULL) && + pt_setup_step("H:root-replacement:close-view-unavailable", + !cbm_pinned_tree_view(fx->tree)) && + pt_setup_step("H:root-replacement:cleanup-path", + !strcmp(error.cleanup_path, original)) && + pt_setup_step("H:root-replacement:preserve-marker", pt_equal_file(marker, "keep", 4)) && + pt_setup_step("H:root-replacement:preserve-original", + pt_equal_file(preserved, pt_binary, sizeof(pt_binary))); + if (replacement && + !pt_setup_step("H:root-replacement:remove-replacement", th_rmtree(original) == 0)) + ok = false; + if (!pt_setup_step("H:root-replacement:restore-original", + pt_move_owned_directory(moved, original))) + ok = false; + return pt_h_close_one("root-replacement", &fx->tree) && + pt_setup_step("H:root-replacement:final-parent-preserve", + pt_parent_only_sentinel(fx->parent)) && + ok; +} + +static bool pt_hardlink_case(pt_fixture *fx) { + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + if (!pt_h_create_ok("hardlink", fx, &options)) + return false; + char tracked[PT_PATH], alias[PT_PATH]; + if (!pt_join(tracked, cbm_pinned_tree_view(fx->tree)->root, "binary.bin") || + !pt_join(alias, fx->home, "outside-hardlink")) + return pt_setup_step("H:hardlink:paths", false); +#ifdef _WIN32 + wchar_t a[PT_PATH], b[PT_PATH]; + if (!pt_wide(alias, a) || !pt_wide(tracked, b) || !CreateHardLinkW(a, b, NULL)) + return pt_setup_step("H:hardlink:install", false); +#else + if (!pt_setup_step("H:hardlink:install", link(tracked, alias) == 0)) + return false; +#endif + cbm_pinned_tree_error_t error; + bool ok = + pt_h_status("hardlink", "verify", + cbm_pinned_tree_verify(fx->tree, &options.control, &error), + CBM_PINNED_TREE_CHANGED, &error) && + pt_setup_step("H:hardlink:verify-view-unavailable", !cbm_pinned_tree_view(fx->tree)) && + pt_setup_step("H:hardlink:preserve-alias", + pt_equal_file(alias, pt_binary, sizeof(pt_binary))); + /* Explicit fixture removal of the alias restores the owned file's single + * link identity before the materializer is asked to dispose it. */ +#ifdef _WIN32 + if (!pt_setup_step("H:hardlink:restore-writable", pt_make_writable(alias))) + ok = false; +#endif + if (!pt_setup_step("H:hardlink:restore-unlink", cbm_unlink(alias) == 0)) + ok = false; + return pt_h_close_one("hardlink", &fx->tree) && ok; +} + +#ifdef _WIN32 +/* No handle acquired by these fixture checks survives a native rename attempt. */ +static bool pt_retry_directory_info(const char *path, BY_HANDLE_FILE_INFORMATION *info) { + wchar_t wide[PT_PATH]; + if (!pt_wide(path, wide)) + return false; + HANDLE handle = CreateFileW( + wide, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (handle == INVALID_HANDLE_VALUE) + return false; + memset(info, 0, sizeof(*info)); + bool ok = GetFileInformationByHandle(handle, info) != 0; + if (!CloseHandle(handle)) + ok = false; + return ok && (info->dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (info->dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; +} + +static bool pt_retry_path_absent(const wchar_t *path) { + if (GetFileAttributesW(path) != INVALID_FILE_ATTRIBUTES) + return false; + DWORD error = GetLastError(); + return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND; +} + +static bool pt_retry_same_id(const BY_HANDLE_FILE_INFORMATION *a, + const BY_HANDLE_FILE_INFORMATION *b) { + return a->dwVolumeSerialNumber == b->dwVolumeSerialNumber && + a->nFileIndexHigh == b->nFileIndexHigh && a->nFileIndexLow == b->nFileIndexLow; +} + +static bool pt_retry_directory_is(const char *path, const BY_HANDLE_FILE_INFORMATION *expected) { + BY_HANDLE_FILE_INFORMATION actual; + return pt_retry_directory_info(path, &actual) && pt_retry_same_id(&actual, expected); +} + +typedef struct { + char root[PT_PATH], moved[PT_PATH], child[PT_PATH], moved_child[PT_PATH]; + BY_HANDLE_FILE_INFORMATION original; + cbm_pinned_tree_t *owner; +} pt_cleanup_retry; + +static bool pt_retry_prepare(pt_fixture *fx, pt_cleanup_retry *retry) { + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + if (!pt_create_ok(fx, &options)) + return false; + strcpy(retry->root, cbm_pinned_tree_view(fx->tree)->root); + retry->owner = fx->tree; + if (!pt_join(retry->moved, fx->parent, "retry-parked-root") || + !pt_join(retry->child, retry->root, "retry-unexpected.bin") || + !pt_join(retry->moved_child, retry->moved, "retry-unexpected.bin") || + !pt_retry_directory_info(retry->root, &retry->original) || + !pt_write(retry->child, "caller-retained", 15)) + return false; + cbm_pinned_tree_error_t error; + return cbm_pinned_tree_close(&fx->tree, &error) == CBM_PINNED_TREE_CLEANUP_REQUIRED && + error.status == CBM_PINNED_TREE_CLEANUP_REQUIRED && fx->tree == retry->owner && + !cbm_pinned_tree_view(fx->tree) && !strcmp(error.cleanup_path, retry->root) && + pt_retry_directory_is(retry->root, &retry->original) && + pt_equal_file(retry->child, "caller-retained", 15); +} + +static bool pt_retry_first_move(const pt_cleanup_retry *retry, bool *blocked) { + wchar_t from[PT_PATH], to[PT_PATH]; + *blocked = false; + if (!pt_wide(retry->root, from) || !pt_wide(retry->moved, to)) + return false; + if (MoveFileExW(from, to, 0)) + return true; + DWORD error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION && error != ERROR_ACCESS_DENIED) + return false; + fprintf(stderr, + "pinned-tree cleanup retry applicability: WHY first native relocation " + "denied; tried MoveFileExW after nonempty close, observed error=%lu; " + "retained native identity and child are checked before recovery\n", + (unsigned long)error); + *blocked = true; + return true; +} + +static bool pt_retry_replacement(pt_fixture *fx, const pt_cleanup_retry *retry) { + wchar_t root[PT_PATH], moved[PT_PATH]; + BY_HANDLE_FILE_INFORMATION replacement; + if (!pt_wide(retry->root, root) || !pt_wide(retry->moved, moved) || + !pt_retry_directory_is(retry->moved, &retry->original) || !pt_retry_path_absent(root) || + cbm_mkdir(retry->root) != 0 || !pt_retry_directory_info(retry->root, &replacement) || + pt_retry_same_id(&replacement, &retry->original)) + return false; + cbm_pinned_tree_error_t error; + if (cbm_pinned_tree_close(&fx->tree, &error) != CBM_PINNED_TREE_CLEANUP_REQUIRED || + error.status != CBM_PINNED_TREE_CLEANUP_REQUIRED || fx->tree != retry->owner || + cbm_pinned_tree_view(fx->tree) || strcmp(error.cleanup_path, retry->root) || + !pt_retry_directory_is(retry->root, &replacement) || + !pt_retry_directory_is(retry->moved, &retry->original) || + !pt_equal_file(retry->moved_child, "caller-retained", 15)) + return false; + /* This removal MUST succeed. Unlike the first relocation, a refusal here + * is a regression: failed close may not retain custody of a replacement. */ + if (!RemoveDirectoryW(root)) { + fprintf(stderr, + "pinned-tree replacement removal failed after close retry: error=%lu; " + "original retained at %s\n", + (unsigned long)GetLastError(), retry->moved); + return false; + } + return MoveFileExW(moved, root, 0) != 0; +} + +static bool pt_windows_cleanup_retry_case(pt_fixture *fx) { + pt_cleanup_retry retry = {0}; + if (!pt_retry_prepare(fx, &retry)) + return false; + bool blocked = false; + if (!pt_retry_first_move(&retry, &blocked)) + return false; + if (blocked) { + wchar_t moved[PT_PATH]; + if (!pt_wide(retry.moved, moved) || !pt_retry_path_absent(moved)) + return false; + } else if (!pt_retry_replacement(fx, &retry)) { + fprintf(stderr, "pinned-tree cleanup retry retained fixture: original=%s moved=%s\n", + retry.root, retry.moved); + return false; + } + /* After the initial close, READY verification is forbidden. These native + * checks prove the retained original/child are intact in either branch. */ + if (fx->tree != retry.owner || cbm_pinned_tree_view(fx->tree) || + !pt_retry_directory_is(retry.root, &retry.original) || + !pt_equal_file(retry.child, "caller-retained", 15) || cbm_unlink(retry.child) != 0) + return false; + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent); +} +#endif + +TEST(tree_h_cleanup_owner_and_unrelated_resources) { + pt_fixture fx = {0}; + int result = 1; + char root[PT_PATH] = {0}, unknown[PT_PATH] = {0}, tracked[PT_PATH] = {0}, moved[PT_PATH] = {0}; + bool have_unknown = false, replacement = false; + PT_CHECK(pt_setup_step("setup:init", pt_init(&fx, 40)) && + pt_setup_step("setup:populate", pt_populate(&fx)) && + pt_setup_step("setup:facts", pt_open_facts(&fx)) && + pt_setup_step("setup:control", pt_fact_control(&fx))); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_HEAD); + PT_CHECK(pt_create_ok(&fx, &options)); + strcpy(root, cbm_pinned_tree_view(fx.tree)->root); + PT_CHECK(pt_join(unknown, root, "unrelated.bin") && pt_write(unknown, "unrelated", 9)); + have_unknown = true; + cbm_pinned_tree_t *identity = fx.tree; + cbm_pinned_tree_error_t error; + PT_CHECK(cbm_pinned_tree_close(&fx.tree, &error) == CBM_PINNED_TREE_CLEANUP_REQUIRED); + PT_CHECK(fx.tree == identity && !cbm_pinned_tree_view(fx.tree) && + error.status == CBM_PINNED_TREE_CLEANUP_REQUIRED && !strcmp(error.cleanup_path, root)); + PT_CHECK(pt_equal_file(unknown, "unrelated", 9)); + cbm_git_facts_free(fx.facts); + fx.facts = NULL; + memset(&options, 0xa5, sizeof(options)); + PT_CHECK(cbm_pinned_tree_close(&fx.tree, &error) == CBM_PINNED_TREE_CLEANUP_REQUIRED); + PT_CHECK(pt_equal_file(unknown, "unrelated", 9)); + PT_CHECK(cbm_unlink(unknown) == 0); + have_unknown = false; + PT_CHECK(pt_close_one(&fx.tree) && pt_parent_only_sentinel(fx.parent)); + PT_CHECK(pt_open_facts(&fx) && pt_fact_control(&fx)); + options = pt_options(&fx, CBM_GIT_REV_HEAD); + PT_CHECK(pt_create_ok(&fx, &options)); + strcpy(root, cbm_pinned_tree_view(fx.tree)->root); + PT_CHECK(pt_join(tracked, root, "binary.bin") && pt_join(moved, root, "saved-original")); + PT_CHECK(cbm_rename_noreplace(tracked, moved) == 0); + replacement = true; + PT_CHECK(pt_write(tracked, "replacement", 11)); + identity = fx.tree; + PT_CHECK(cbm_pinned_tree_close(&fx.tree, &error) == CBM_PINNED_TREE_CLEANUP_REQUIRED); + PT_CHECK(fx.tree == identity && !cbm_pinned_tree_view(fx.tree) && + error.cause == CBM_PINNED_TREE_CHANGED); + PT_CHECK(pt_equal_file(tracked, "replacement", 11) && + pt_equal_file(moved, pt_binary, sizeof(pt_binary))); + PT_CHECK(cbm_unlink(tracked) == 0 && cbm_rename_noreplace(moved, tracked) == 0); + replacement = false; + PT_CHECK(pt_close_one(&fx.tree) && pt_parent_only_sentinel(fx.parent)); + PT_CHECK(pt_link_case(&fx)); + PT_CHECK(pt_hardlink_case(&fx)); + PT_CHECK(pt_root_replacement_case(&fx)); +#ifdef _WIN32 + PT_CHECK(pt_windows_cleanup_retry_case(&fx)); +#else + /* WHY native Windows handle recovery is inapplicable here; tried the POSIX + * replacement/unexpected-child paths above, which retain their assertions. */ +#endif + /* Git and caller-owned parent sentinels are still usable after retries. */ + PT_CHECK(pt_setup_step("setup:control", pt_fact_control(&fx))); + char sentinel[PT_PATH]; + PT_CHECK(pt_join(sentinel, fx.parent, "sentinel") && + pt_equal_file(sentinel, "parent-owned\n", 13)); + result = 0; +done: + if (have_unknown && cbm_unlink(unknown) != 0) + result = 1; + if (replacement) { + if (cbm_file_exists(tracked) && th_unlink_force(tracked) != 0) + result = 1; + if (cbm_rename_noreplace(moved, tracked) != 0) + result = 1; + } + return pt_finish(&fx, result); +} + +SUITE(test_impact_tree) { + RUN_TEST(tree_a_pinned_head_merge_base_and_owned_lifetime); + RUN_TEST(tree_b_manifest_golden_and_empty_inventory); + RUN_TEST(tree_c_complete_inventory_and_native_names); + RUN_TEST(tree_d_native_collision_and_exact_spelling); + RUN_TEST(tree_e_native_permissions_and_acl_policy); + RUN_TEST(tree_f_resource_limits_and_local_control); + RUN_TEST(tree_g_verify_state_and_complete_inventory); + RUN_TEST(tree_h_cleanup_owner_and_unrelated_resources); +} + + +/* Independent prefix-read acceptance: no publisher/admission claims. All + * mutations are synchronous and restricted to the fixture's owned namespace. */ +static bool ptr_setup(pt_fixture *fx, unsigned width) { + return pt_setup_step("read:init", pt_init(fx, width)) && + pt_setup_step("read:populate", pt_populate(fx)) && + pt_setup_step("read:facts", pt_open_facts(fx)) && + pt_setup_step("read:control", pt_fact_control(fx)); +} + +static cbm_pinned_tree_control_t ptr_control(void) { + return (cbm_pinned_tree_control_t){.deadline_ms = cbm_now_ms() + 120000U}; +} + +static bool ptr_start(pt_fixture *fx) { + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + return pt_create_ok(fx, &options); +} + +static bool ptr_status(const char *stage, cbm_pinned_tree_status_t actual, + cbm_pinned_tree_status_t expected, const cbm_pinned_tree_error_t *error) { + return pt_h_tree_step("prefix-read", stage, actual == expected && error->status == expected, + actual, error); +} + +static bool ptr_canary(const unsigned char *bytes, size_t begin, size_t end) { + for (size_t i = begin; i < end; i++) + if (bytes[i] != 0xa5) + return false; + return true; +} + +static bool ptr_read_ok(pt_fixture *fx, cbm_pinned_tree_t *tree, size_t index, size_t capacity, + bool ancestor) { + const pt_row *row = &fx->rows[index]; + const unsigned char *expected = ancestor && index == 4 ? pt_before : row->bytes; + unsigned char *bytes = cbm_arena_alloc(&fx->arena, capacity + 16); + if (!bytes) + return false; + memset(bytes, 0xa5, capacity + 16); + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(tree); + if (!view) + return false; + unsigned char digest[32]; + memcpy(digest, view->manifest_sha256, sizeof(digest)); + cbm_pinned_tree_control_t control = ptr_control(); + cbm_pinned_tree_error_t error; + memset(&error, 0xa5, sizeof(error)); + size_t copied = SIZE_MAX; + cbm_pinned_tree_status_t status = cbm_pinned_tree_read_prefix( + tree, index, row->length ? row->length : 1, bytes, capacity, &copied, &control, &error); + size_t wanted = capacity < row->length ? capacity : row->length; + view = cbm_pinned_tree_view(tree); + return ptr_status("positive", status, CBM_PINNED_TREE_OK, &error) && + error.cause == CBM_PINNED_TREE_OK && copied == wanted && + (!wanted || !memcmp(bytes, expected, wanted)) && + ptr_canary(bytes, wanted, capacity + 16) && view && + !memcmp(digest, view->manifest_sha256, sizeof(digest)); +} + +static bool ptr_zero_null(cbm_pinned_tree_t *tree) { + cbm_pinned_tree_control_t control = ptr_control(); + size_t copied = SIZE_MAX; + return cbm_pinned_tree_read_prefix(tree, 3, PT_LARGE, NULL, 0, &copied, &control, NULL) == + CBM_PINNED_TREE_OK && + copied == 0 && cbm_pinned_tree_view(tree); +} + +static int ptr_bytes_width(unsigned width) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(ptr_setup(&fx, width) && ptr_start(&fx)); + for (size_t i = 0; i < fx.count; i++) + PT_CHECK(ptr_read_ok(&fx, fx.tree, i, fx.rows[i].length + 7, false)); + PT_CHECK(ptr_read_ok(&fx, fx.tree, 3, 37, false)); /* Includes raw Ctrl-Z. */ + PT_CHECK(ptr_read_ok(&fx, fx.tree, 0, 2, false)); /* Includes raw NUL. */ + PT_CHECK(ptr_read_ok(&fx, fx.tree, 3, 0, false) && ptr_zero_null(fx.tree)); + cbm_pinned_tree_control_t control = ptr_control(); + cbm_pinned_tree_error_t error; + PT_CHECK(cbm_pinned_tree_verify(fx.tree, &control, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_close_one(&fx.tree) && pt_parent_only_sentinel(fx.parent)); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_MERGE_BASE); + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_view_matches(&fx, cbm_pinned_tree_view(fx.tree), true)); + cbm_git_facts_free(fx.facts); + fx.facts = NULL; + PT_CHECK(ptr_read_ok(&fx, fx.tree, 4, 32, true)); + PT_CHECK(ptr_read_ok(&fx, fx.tree, 3, PT_LARGE, true)); + PT_CHECK(cbm_pinned_tree_verify(fx.tree, &control, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_close_one(&fx.tree) && pt_parent_only_sentinel(fx.parent)); + result = 0; +done: + return pt_finish(&fx, result); +} + +TEST(tree_read_a_bytes_and_lifetime) { + if (ptr_bytes_width(40) != 0) + return 1; + return ptr_bytes_width(64); +} + +/* Disposed owners cannot publish another read or be made READY by verify. */ +static bool ptr_disposal(cbm_pinned_tree_t *tree) { + cbm_pinned_tree_control_t control = ptr_control(); + pt_cancel counter = {0}; + control.cancelled = pt_cancelled; + control.cancel_context = &counter; + cbm_pinned_tree_error_t error; + unsigned char byte = 0xa5; + size_t copied = SIZE_MAX; + cbm_pinned_tree_status_t status = + cbm_pinned_tree_read_prefix(tree, 0, 6, &byte, 1, &copied, &control, &error); + return ptr_status("disposal-preflight", status, CBM_PINNED_TREE_INVALID, &error) && + copied == 0 && byte == 0xa5 && counter.calls == 0 && !cbm_pinned_tree_view(tree) && + !cbm_pinned_tree_test_set_read_fault(tree, CBM_PINNED_TREE_READ_FAULT_NONE) && + cbm_pinned_tree_verify(tree, &control, &error) != CBM_PINNED_TREE_OK && + !cbm_pinned_tree_view(tree); +} + +/* All bad operands are valid memory; the test does not exercise alias UB. */ +static bool ptr_preflight_case(pt_fixture *fx, unsigned kind) { + cbm_pinned_tree_t *tree = kind == 0 ? NULL : fx->tree; + size_t index = kind == 1 ? SIZE_MAX : kind == 2 ? fx->count : 0; + uint64_t cap = kind == 3 ? 0 : kind == 8 ? sizeof(pt_binary) - 1 : sizeof(pt_binary); + unsigned char byte = 0xa5; + unsigned char *output = kind == 4 ? NULL : &byte; + size_t copied = SIZE_MAX; + size_t *copied_arg = kind == 5 ? NULL : &copied; + cbm_pinned_tree_control_t control = ptr_control(); + pt_cancel counter = {0}; + control.cancelled = pt_cancelled; + control.cancel_context = &counter; + if (kind == 7) + control.deadline_ms = 0; + const cbm_pinned_tree_control_t *control_arg = kind == 6 ? NULL : &control; + cbm_pinned_tree_error_t error; + memset(&error, 0xa5, sizeof(error)); + cbm_pinned_tree_status_t wanted = kind == 8 ? CBM_PINNED_TREE_LIMIT : CBM_PINNED_TREE_INVALID; + cbm_pinned_tree_status_t status = + cbm_pinned_tree_read_prefix(tree, index, cap, output, 1, copied_arg, control_arg, &error); + return ptr_status("argument-preflight", status, wanted, &error) && (kind == 5 || copied == 0) && + byte == 0xa5 && counter.calls == 0 && + pt_view_matches(fx, cbm_pinned_tree_view(fx->tree), false); +} + +TEST(tree_read_b_preflight_and_pending_fault) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(ptr_setup(&fx, 40) && ptr_start(&fx)); + PT_CHECK(ptr_read_ok(&fx, fx.tree, 0, sizeof(pt_binary), false)); + PT_CHECK(!cbm_pinned_tree_test_set_read_fault(NULL, CBM_PINNED_TREE_READ_FAULT_READ)); + PT_CHECK(cbm_pinned_tree_test_set_read_fault(fx.tree, CBM_PINNED_TREE_READ_FAULT_READ)); + PT_CHECK(!cbm_pinned_tree_test_set_read_fault(fx.tree, (cbm_pinned_tree_read_fault_t)999)); + for (unsigned kind = 0; kind < 9; kind++) + PT_CHECK(ptr_preflight_case(&fx, kind)); + cbm_pinned_tree_control_t control = ptr_control(); + cbm_pinned_tree_error_t error; + unsigned char byte = 0xa5; + size_t copied = SIZE_MAX; + cbm_pinned_tree_status_t status = + cbm_pinned_tree_read_prefix(fx.tree, 0, 6, &byte, 1, &copied, &control, &error); + PT_CHECK(ptr_status("pending-read-fault", status, CBM_PINNED_TREE_IO, &error)); + PT_CHECK(copied == 0 && ptr_disposal(fx.tree)); + PT_CHECK(pt_close_one(&fx.tree) && pt_parent_only_sentinel(fx.parent)); + PT_CHECK(ptr_start(&fx)); + PT_CHECK(ptr_preflight_case(&fx, 8)); + PT_CHECK(ptr_read_ok(&fx, fx.tree, 0, 1, false)); /* Exact cap, after LIMIT. */ + result = 0; +done: + return pt_finish(&fx, result); +} + +static bool ptr_cancel_once(pt_fixture *fx, size_t stop, bool *cancelled) { + if (!ptr_start(fx)) + return false; + cbm_pinned_tree_control_t control = ptr_control(); + pt_cancel counter = {.stop = stop}; + control.cancelled = pt_cancelled; + control.cancel_context = &counter; + cbm_pinned_tree_error_t error; + unsigned char prefix[37]; + size_t copied = SIZE_MAX; + cbm_pinned_tree_status_t status = cbm_pinned_tree_read_prefix( + fx->tree, 3, PT_LARGE, prefix, sizeof(prefix), &copied, &control, &error); + bool ok = error.status == status; + if (status == CBM_PINNED_TREE_OK) + ok = ok && counter.calls < stop && copied == sizeof(prefix) && + !memcmp(prefix, fx->large, sizeof(prefix)) && + pt_view_matches(fx, cbm_pinned_tree_view(fx->tree), false); + else { + ok = ok && status == CBM_PINNED_TREE_CANCELLED && counter.calls >= stop && copied == 0 && + ptr_disposal(fx->tree); + *cancelled = true; + } + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent) && ok; +} + +TEST(tree_read_c_cancellation_and_deadline) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(ptr_setup(&fx, 40) && ptr_start(&fx)); + PT_CHECK(ptr_read_ok(&fx, fx.tree, 3, 37, false)); + cbm_pinned_tree_control_t control = ptr_control(); + pt_cancel counter = {0}; + control.cancelled = pt_cancelled; + control.cancel_context = &counter; + unsigned char prefix[37]; + cbm_pinned_tree_error_t error; + size_t copied = SIZE_MAX; + PT_CHECK(cbm_pinned_tree_read_prefix(fx.tree, 3, PT_LARGE, prefix, sizeof(prefix), &copied, + &control, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(copied == sizeof(prefix) && !memcmp(prefix, fx.large, sizeof(prefix))); + PT_CHECK(counter.calls > 0 && counter.calls < SIZE_MAX / 8 && pt_close_one(&fx.tree)); + size_t total = counter.calls; + bool cancelled = false; + for (size_t i = 0; i < 7; i++) { + size_t stop = i == 6 ? total + 1 : 1 + (total - 1) * i / 5; + PT_CHECK(ptr_cancel_once(&fx, stop, &cancelled)); + } + PT_CHECK(cancelled && ptr_start(&fx)); + control = ptr_control(); + control.deadline_ms = 1; + copied = SIZE_MAX; + PT_CHECK(cbm_now_ms() > 1); + cbm_pinned_tree_status_t status = cbm_pinned_tree_read_prefix( + fx.tree, 0, 6, prefix, sizeof(prefix), &copied, &control, &error); + PT_CHECK(ptr_status("expired", status, CBM_PINNED_TREE_DEADLINE, &error)); + PT_CHECK(copied == 0 && ptr_disposal(fx.tree)); + PT_CHECK(pt_close_one(&fx.tree) && pt_parent_only_sentinel(fx.parent)); + PT_CHECK(ptr_start(&fx) && ptr_read_ok(&fx, fx.tree, 3, PT_LARGE, false)); + result = 0; +done: + return pt_finish(&fx, result); +} + +static bool ptr_changed(pt_fixture *fx, size_t index, bool missing) { + cbm_pinned_tree_control_t control = ptr_control(); + cbm_pinned_tree_error_t error; + unsigned char byte = 0xa5; + size_t copied = SIZE_MAX; + cbm_pinned_tree_status_t status = + cbm_pinned_tree_read_prefix(fx->tree, index, PT_LARGE, &byte, 1, &copied, &control, &error); + bool typed = status == CBM_PINNED_TREE_CHANGED || (missing && status == CBM_PINNED_TREE_IO); + return pt_h_tree_step("prefix-read", "native-change", typed && error.status == status, status, + &error) && + copied == 0 && ptr_disposal(fx->tree); +} + +static bool ptr_change_suffix(const char *path) { + if (!pt_make_writable(path)) + return false; + FILE *file = cbm_fopen(path, "r+b"); + if (!file) + return false; + unsigned char changed = (unsigned char)(((PT_LARGE - 1) % 251) ^ 0xff); + bool ok = fseek(file, PT_LARGE - 1, SEEK_SET) == 0 && fwrite(&changed, 1, 1, file) == 1; + if (fclose(file) != 0) + ok = false; + return pt_restore_readonly(path, false) && ok; +} + +static bool ptr_content_case(pt_fixture *fx, unsigned kind) { + if (!ptr_start(fx)) + return false; + size_t index = kind == 0 ? 3 : 0; + if (!ptr_read_ok(fx, fx->tree, index, 1, false)) + return false; + char path[PT_PATH]; + if (!pt_join(path, cbm_pinned_tree_view(fx->tree)->root, fx->rows[index].path)) + return false; + bool changed; + if (kind == 0) + changed = ptr_change_suffix(path); + else { + unsigned char bytes[sizeof(pt_binary) + 1]; + memcpy(bytes, pt_binary, sizeof(pt_binary)); + bytes[sizeof(pt_binary)] = 0xee; + size_t length = kind == 1 ? sizeof(pt_binary) - 1 : sizeof(bytes); + changed = pt_make_writable(path) && pt_write(path, bytes, length) && + pt_restore_readonly(path, false); + } + bool ok = changed && ptr_changed(fx, index, false); + /* These remain owned identities. No byte restoration is needed for disposal. */ + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent) && ok; +} + +static bool ptr_file_identity_case(pt_fixture *fx, bool replacement) { + if (!ptr_start(fx) || !ptr_read_ok(fx, fx->tree, 0, 1, false)) + return false; + char path[PT_PATH], saved[PT_PATH]; + if (!pt_join(path, cbm_pinned_tree_view(fx->tree)->root, "binary.bin") || + !pt_join(saved, fx->parent, "read-parked-file") || cbm_rename_noreplace(path, saved) != 0) + return false; + bool installed = false; + bool ready = true; + if (replacement) { + installed = pt_write(path, pt_binary, sizeof(pt_binary)); + ready = installed && pt_restore_readonly(path, false); + } + bool ok = ready && ptr_changed(fx, 0, !replacement) && + pt_equal_file(saved, pt_binary, sizeof(pt_binary)); + if (installed && (!pt_make_writable(path) || cbm_unlink(path) != 0)) + return false; + if (cbm_rename_noreplace(saved, path) != 0) + return false; + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent) && ok; +} + +static bool ptr_make_link(const char *path, const char *alias) { +#ifdef _WIN32 + wchar_t a[PT_PATH], b[PT_PATH]; + return pt_wide(alias, a) && pt_wide(path, b) && CreateHardLinkW(a, b, NULL); +#else + return link(path, alias) == 0; +#endif +} + +static bool ptr_hardlink_case(pt_fixture *fx) { + if (!ptr_start(fx) || !ptr_read_ok(fx, fx->tree, 0, 1, false)) + return false; + char path[PT_PATH], alias[PT_PATH]; + if (!pt_join(path, cbm_pinned_tree_view(fx->tree)->root, "binary.bin") || + !pt_join(alias, fx->parent, "read-outside-link") || !ptr_make_link(path, alias)) + return false; + bool ok = ptr_changed(fx, 0, false) && pt_equal_file(alias, pt_binary, sizeof(pt_binary)); +#ifdef _WIN32 + if (!pt_make_writable(alias)) + return false; +#endif + if (cbm_unlink(alias) != 0 || !pt_restore_readonly(path, false)) + return false; + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent) && ok; +} + +TEST(tree_read_d_selected_file_mutations) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(ptr_setup(&fx, 40)); + for (unsigned kind = 0; kind < 3; kind++) + PT_CHECK(ptr_content_case(&fx, kind)); + PT_CHECK(ptr_file_identity_case(&fx, false)); + PT_CHECK(ptr_file_identity_case(&fx, true)); + PT_CHECK(ptr_hardlink_case(&fx)); + result = 0; +done: + return pt_finish(&fx, result); +} + +static bool ptr_ancestor_path(pt_fixture *fx, unsigned level, char path[PT_PATH]) { + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(fx->tree); + if (!view) + return false; + if (level == 2) + return pt_join(path, view->root, "nested"); + strcpy(path, level == 0 ? fx->parent : view->root); + return true; +} + +static bool ptr_set_acl(pt_fixture *fx, const char *path, bool add) { +#ifdef _WIN32 + return pt_windows_acl(fx, path, add); +#elif defined(__APPLE__) + (void)fx; + return pt_mac_named_acl(path, add); +#else + (void)fx; + return pt_linux_acl(path, false, add); +#endif +} + +static bool ptr_ancestor_policy(pt_fixture *fx, unsigned level, bool acl) { + if (!ptr_start(fx) || !ptr_read_ok(fx, fx->tree, 4, 1, false)) + return false; + char path[PT_PATH]; + if (!ptr_ancestor_path(fx, level, path)) + return false; + bool installed = false; + if (acl) + installed = ptr_set_acl(fx, path, true); +#ifndef _WIN32 + else + installed = chmod(path, 0750) == 0; +#endif + pt_wire before; + bool observed = installed; + if (acl) + observed = + observed && pt_acl_snapshot(fx, path, &before) && pt_acl_preserved(fx, path, &before); +#ifndef _WIN32 + if (!acl) + observed = observed && pt_passing_posix_mode(path, 0750); +#endif + bool ok = observed && ptr_changed(fx, 4, false); + if (acl && observed) + ok = pt_acl_preserved(fx, path, &before) && ok; +#ifndef _WIN32 + if (!acl && observed) + ok = pt_passing_posix_mode(path, 0750) && ok; +#endif + bool restored = false; + if (acl) + restored = ptr_set_acl(fx, path, false); +#ifndef _WIN32 + else + restored = chmod(path, 0700) == 0; +#endif + if (!restored || !pt_policy(path, true, false)) + return false; + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent) && ok; +} + +static bool ptr_ancestor_replacement(pt_fixture *fx, unsigned level) { + if (!ptr_start(fx) || !ptr_read_ok(fx, fx->tree, 4, 1, false)) + return false; + char original[PT_PATH], saved[PT_PATH], marker[PT_PATH]; + if (!ptr_ancestor_path(fx, level, original) || + !pt_join(saved, fx->parent, "read-parked-directory")) + return false; + bool blocked = false; + if (!pt_relocate_directory(original, saved, &blocked)) + return false; + if (blocked) { + /* WHY/observed native error are recorded by the relocation helper. */ + bool ok = ptr_read_ok(fx, fx->tree, 4, 7, false); + cbm_pinned_tree_control_t control = ptr_control(); + cbm_pinned_tree_error_t error; + ok = cbm_pinned_tree_verify(fx->tree, &control, &error) == CBM_PINNED_TREE_OK && ok; + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent) && ok; + } + bool installed = cbm_mkdir(original) == 0; + bool marked = + installed && pt_join(marker, original, "caller-owned") && pt_write(marker, "keep", 4); + bool ok = marked && ptr_changed(fx, 4, false) && pt_equal_file(marker, "keep", 4); + if (marked && cbm_unlink(marker) != 0) + return false; + if (installed && th_rmtree(original) != 0) + return false; + if (!pt_move_owned_directory(saved, original)) + return false; + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent) && ok; +} + +TEST(tree_read_e_ancestor_policy_and_identity) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(ptr_setup(&fx, 40)); + for (unsigned level = 0; level < 3; level++) { + PT_CHECK(ptr_ancestor_policy(&fx, level, true)); +#ifndef _WIN32 + PT_CHECK(ptr_ancestor_policy(&fx, level, false)); +#else + fprintf(stderr, "prefix-read mode applicability: WHY Windows has no POSIX mode policy; " + "tried the native protected-DACL mutation on this same ancestor\n"); +#endif + } + PT_CHECK(ptr_ancestor_replacement(&fx, 1)); + PT_CHECK(ptr_ancestor_replacement(&fx, 2)); + result = 0; +done: + return pt_finish(&fx, result); +} + +static bool ptr_present(const char *path) { +#ifdef _WIN32 + wchar_t wide[PT_PATH]; + return pt_wide(path, wide) && GetFileAttributesW(wide) != INVALID_FILE_ATTRIBUTES; +#else + struct stat st; + return lstat(path, &st) == 0; +#endif +} + +static bool ptr_fault_case(pt_fixture *fx, cbm_pinned_tree_read_fault_t fault, bool corrupt) { + if (!ptr_start(fx)) + return false; + size_t index = fault == CBM_PINNED_TREE_READ_FAULT_EOF ? 2 : 3; + if (!ptr_read_ok(fx, fx->tree, index, 1, false)) + return false; + char root[PT_PATH], path[PT_PATH]; + strcpy(root, cbm_pinned_tree_view(fx->tree)->root); + if (!pt_join(path, root, fx->rows[index].path) || + !cbm_pinned_tree_test_set_read_fault(fx->tree, fault)) + return false; + if (corrupt && !ptr_change_suffix(path)) + return false; + cbm_pinned_tree_control_t control = ptr_control(); + cbm_pinned_tree_error_t error; + unsigned char prefix[37]; + size_t copied = SIZE_MAX; + cbm_pinned_tree_status_t status = cbm_pinned_tree_read_prefix( + fx->tree, index, PT_LARGE, prefix, sizeof(prefix), &copied, &control, &error); + cbm_pinned_tree_status_t expected = fault == CBM_PINNED_TREE_READ_FAULT_CLOSE + ? CBM_PINNED_TREE_CLEANUP_REQUIRED + : CBM_PINNED_TREE_IO; + bool ok = ptr_status("simulated-boundary", status, expected, &error) && copied == 0 && + error.cause == (corrupt ? CBM_PINNED_TREE_CHANGED : CBM_PINNED_TREE_IO) && + !cbm_pinned_tree_view(fx->tree) && fx->tree; + if (fault == CBM_PINNED_TREE_READ_FAULT_CLOSE) + ok = ok && !strcmp(error.cleanup_path, root); + /* Namespace persists; CLOSE retains a known handle, not a real failed POSIX + * close. Ordinary disposal is the required recovery, with no rewrite. */ + ok = ok && ptr_present(root) && ptr_present(path) && ptr_disposal(fx->tree); + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent) && ok; +} + +static bool ptr_fault_consumption(pt_fixture *fx) { + if (!ptr_start(fx) || !ptr_read_ok(fx, fx->tree, 2, 1, false)) + return false; + if (!cbm_pinned_tree_test_set_read_fault(fx->tree, CBM_PINNED_TREE_READ_FAULT_READ) || + !ptr_read_ok(fx, fx->tree, 2, 1, false) || !ptr_read_ok(fx, fx->tree, 0, 6, false)) + return false; + if (!cbm_pinned_tree_test_set_read_fault(fx->tree, CBM_PINNED_TREE_READ_FAULT_EOF) || + !cbm_pinned_tree_test_set_read_fault(fx->tree, CBM_PINNED_TREE_READ_FAULT_NONE) || + !ptr_read_ok(fx, fx->tree, 0, 6, false)) + return false; + return pt_close_one(&fx->tree) && pt_parent_only_sentinel(fx->parent); +} + +static bool ptr_owner_isolation(pt_fixture *fx) { + if (!ptr_start(fx) || !ptr_read_ok(fx, fx->tree, 0, 6, false)) + return false; + cbm_pinned_tree_options_t options = pt_options(fx, CBM_GIT_REV_HEAD); + cbm_pinned_tree_error_t error; + if (cbm_pinned_tree_create(&options, &fx->second, &error) != CBM_PINNED_TREE_OK || + !pt_view_matches(fx, cbm_pinned_tree_view(fx->second), false) || + !cbm_pinned_tree_test_set_read_fault(fx->tree, CBM_PINNED_TREE_READ_FAULT_EOF) || + !ptr_read_ok(fx, fx->second, 3, PT_LARGE, false)) + return false; + cbm_pinned_tree_control_t control = ptr_control(); + size_t copied = SIZE_MAX; + cbm_pinned_tree_status_t status = + cbm_pinned_tree_read_prefix(fx->tree, 2, 1, NULL, 0, &copied, &control, &error); + bool ok = ptr_status("per-owner-fault", status, CBM_PINNED_TREE_IO, &error) && copied == 0 && + ptr_disposal(fx->tree) && ptr_read_ok(fx, fx->second, 0, 6, false); + return pt_close_one(&fx->tree) && pt_close_one(&fx->second) && + pt_parent_only_sentinel(fx->parent) && ok; +} + +TEST(tree_read_f_faults_and_owner_isolation) { + pt_fixture fx = {0}; + int result = 1; + PT_CHECK(ptr_setup(&fx, 40)); + PT_CHECK(ptr_fault_consumption(&fx)); + PT_CHECK(ptr_fault_case(&fx, CBM_PINNED_TREE_READ_FAULT_READ, false)); + PT_CHECK(ptr_fault_case(&fx, CBM_PINNED_TREE_READ_FAULT_EOF, false)); + PT_CHECK(ptr_fault_case(&fx, CBM_PINNED_TREE_READ_FAULT_CLOSE, false)); + PT_CHECK(ptr_fault_case(&fx, CBM_PINNED_TREE_READ_FAULT_CLOSE, true)); + PT_CHECK(ptr_owner_isolation(&fx)); + result = 0; +done: + return pt_finish(&fx, result); +} + +SUITE(test_impact_tree_read) { + RUN_TEST(tree_read_a_bytes_and_lifetime); + RUN_TEST(tree_read_b_preflight_and_pending_fault); + RUN_TEST(tree_read_c_cancellation_and_deadline); + RUN_TEST(tree_read_d_selected_file_mutations); + RUN_TEST(tree_read_e_ancestor_policy_and_identity); + RUN_TEST(tree_read_f_faults_and_owner_isolation); +} diff --git a/tests/test_userconfig.c b/tests/test_userconfig.c index 09ec7d1b6..a9c3b4a5e 100644 --- a/tests/test_userconfig.c +++ b/tests/test_userconfig.c @@ -220,9 +220,190 @@ TEST(userconfig_free_null) { PASS(); } +/* One-read project config evidence for declaration/index consumers. */ +typedef struct { + char dir[1024]; + char path[1280]; +} source_fixture_t; + +static bool source_fixture(source_fixture_t *f) { + int n = snprintf(f->dir, sizeof(f->dir), "%s/uc_source_XXXXXX", cbm_tmpdir()); + if (n < 0 || (size_t)n >= sizeof(f->dir) || !cbm_mkdtemp(f->dir)) + return false; + n = snprintf(f->path, sizeof(f->path), "%s/.codebase-memory.json", f->dir); + return n > 0 && (size_t)n < sizeof(f->path); +} + +static int source_write(const char *path, const char *bytes) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) return -1; + size_t len = strlen(bytes); + bool ok = fwrite(bytes, 1, len, file) == len; + if (fclose(file) != 0) ok = false; + return ok ? 0 : -1; +} + +static void source_fixture_close(source_fixture_t *f) { + (void)remove(f->path); + (void)cbm_rmdir(f->path); + (void)cbm_rmdir(f->dir); +} + +TEST(userconfig_source_owned_and_shared) { + source_fixture_t f; + ASSERT(source_fixture(&f)); + const char before[] = " {\"extra_extensions\":{\".snap\":\"c\"}} \n"; + const char after[] = " {\"extra_extensions\":{\".snap\":\"r\"}} \n"; + ASSERT_EQ(source_write(f.path, before), 0); + cbm_userconfig_t *legacy = cbm_userconfig_load(f.dir); + cbm_userconfig_t *first = cbm_userconfig_load_with_source(f.dir); + ASSERT_EQ(source_write(f.path, after), 0); + cbm_userconfig_t *second = cbm_userconfig_load_with_source(f.dir); + const char *bytes = NULL; + size_t len = 0; + bool captured = cbm_userconfig_project_source(first, &bytes, &len) == + CBM_USERCONFIG_SOURCE_PRESENT && + bytes && len == sizeof(before) - 1 && memcmp(bytes, before, len) == 0; + bool shared = legacy && first && + cbm_userconfig_lookup(legacy, ".snap") == CBM_LANG_C && + cbm_userconfig_lookup(first, ".snap") == CBM_LANG_C && + strcmp(legacy->project_source_sha256, first->project_source_sha256) == 0; + const char *new_bytes = NULL; + size_t new_len = 0; + bool changed = cbm_userconfig_project_source(second, &new_bytes, &new_len) == + CBM_USERCONFIG_SOURCE_PRESENT && + new_bytes && new_len == sizeof(after) - 1 && + memcmp(new_bytes, after, new_len) == 0 && first && second && + strcmp(first->project_source_sha256, second->project_source_sha256) != 0; + cbm_userconfig_free(legacy); + cbm_userconfig_free(second); + source_fixture_close(&f); + /* Closing another owner and deleting the file cannot invalidate this view. */ + captured = captured && bytes && memcmp(bytes, before, sizeof(before) - 1) == 0; + cbm_userconfig_free(first); + ASSERT(captured); + ASSERT(shared); + ASSERT(changed); + PASS(); +} + +TEST(userconfig_source_absence_is_explicit) { + source_fixture_t f; + ASSERT(source_fixture(&f)); + cbm_userconfig_t *legacy = cbm_userconfig_load(f.dir); + cbm_userconfig_t *cfg = cbm_userconfig_load_with_source(f.dir); + const char *bytes = "sentinel"; + size_t len = 99; + bool absent = cbm_userconfig_project_source(cfg, &bytes, &len) == + CBM_USERCONFIG_SOURCE_ABSENT && + !bytes && !len; + bool same_digest = legacy && cfg && + strcmp(legacy->project_source_sha256, cfg->project_source_sha256) == 0; + cbm_userconfig_free(legacy); + cbm_userconfig_free(cfg); + source_fixture_close(&f); + ASSERT(absent); + ASSERT(same_digest); + PASS(); +} + +TEST(userconfig_source_empty_and_malformed_are_present) { + source_fixture_t f; + ASSERT(source_fixture(&f)); + ASSERT_EQ(source_write(f.path, ""), 0); + cbm_userconfig_t *empty = cbm_userconfig_load_with_source(f.dir); + const char *bytes = NULL; + size_t len = 17; + bool empty_present = cbm_userconfig_project_source(empty, &bytes, &len) == + CBM_USERCONFIG_SOURCE_PRESENT && + bytes && !len && bytes[0] == '\0'; + const char malformed[] = "{ incomplete"; + ASSERT_EQ(source_write(f.path, malformed), 0); + cbm_userconfig_t *bad = cbm_userconfig_load_with_source(f.dir); + bool bad_present = cbm_userconfig_project_source(bad, &bytes, &len) == + CBM_USERCONFIG_SOURCE_PRESENT && + bytes && len == sizeof(malformed) - 1 && + memcmp(bytes, malformed, len) == 0; + bool distinct = empty && bad && + strcmp(empty->project_source_sha256, bad->project_source_sha256) != 0; + cbm_userconfig_free(empty); + cbm_userconfig_free(bad); + source_fixture_close(&f); + ASSERT(empty_present); + ASSERT(bad_present); + ASSERT(distinct); + PASS(); +} + +TEST(userconfig_source_limit_and_errors) { + source_fixture_t f; + ASSERT(source_fixture(&f)); + char bytes[65538]; + memset(bytes, ' ', sizeof(bytes)); + bytes[0] = '{'; + bytes[1] = '}'; + bytes[65536] = '\0'; + ASSERT_EQ(source_write(f.path, bytes), 0); + cbm_userconfig_t *maximum = cbm_userconfig_load_with_source(f.dir); + const char *view = NULL; + size_t len = 0; + bool max_ok = cbm_userconfig_project_source(maximum, &view, &len) == + CBM_USERCONFIG_SOURCE_PRESENT && + view && len == 65536 && memcmp(view, bytes, len) == 0; + bytes[65536] = ' '; + bytes[65537] = '\0'; + ASSERT_EQ(source_write(f.path, bytes), 0); + cbm_userconfig_t *oversized = cbm_userconfig_load_with_source(f.dir); + view = "sentinel"; + len = 99; + bool oversize_error = oversized && + cbm_userconfig_project_source(oversized, &view, &len) == + CBM_USERCONFIG_SOURCE_ERROR && !view && !len; + ASSERT_EQ(remove(f.path), 0); + ASSERT(cbm_mkdir_p(f.path, 0755)); + cbm_userconfig_t *directory = cbm_userconfig_load_with_source(f.dir); + view = "sentinel"; + len = 99; + bool directory_error = directory && + cbm_userconfig_project_source(directory, &view, &len) == + CBM_USERCONFIG_SOURCE_ERROR && !view && !len; + cbm_userconfig_free(maximum); + cbm_userconfig_free(oversized); + cbm_userconfig_free(directory); + source_fixture_close(&f); + ASSERT(max_ok); + ASSERT(oversize_error); + ASSERT(directory_error); + PASS(); +} + +TEST(userconfig_source_unavailable_clears_views) { + const char *bytes = "sentinel"; + size_t len = 13; + ASSERT_EQ(cbm_userconfig_project_source(NULL, &bytes, &len), CBM_USERCONFIG_SOURCE_ERROR); + ASSERT_NULL(bytes); + ASSERT_EQ(len, 0); + cbm_userconfig_t *legacy = cbm_userconfig_load(NULL); + ASSERT_NOT_NULL(legacy); + bytes = "sentinel"; + len = 13; + bool unavailable = cbm_userconfig_project_source(legacy, &bytes, &len) == + CBM_USERCONFIG_SOURCE_ERROR && !bytes && !len; + cbm_userconfig_free(legacy); + ASSERT(unavailable); + PASS(); +} + + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(userconfig) { + RUN_TEST(userconfig_source_owned_and_shared); + RUN_TEST(userconfig_source_absence_is_explicit); + RUN_TEST(userconfig_source_empty_and_malformed_are_present); + RUN_TEST(userconfig_source_limit_and_errors); + RUN_TEST(userconfig_source_unavailable_clears_views); + RUN_TEST(userconfig_project_basic); RUN_TEST(userconfig_global_via_env); RUN_TEST(userconfig_project_wins_over_global); diff --git a/tests/test_vm_isolated_dispatch.sh b/tests/test_vm_isolated_dispatch.sh new file mode 100644 index 000000000..027ed9ba6 --- /dev/null +++ b/tests/test_vm_isolated_dispatch.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Exercise the real VM wrapper with inert OS adapters and canonical-entry spies. +# This checks dispatch/ownership only; it is not a native Windows or suite gate. +set -uo pipefail +task_source_root="${1:?source repository required}" +task_case_root="${2:?fresh test output directory required}" +if [ -e "$task_case_root" ]; then + echo 'Test output directory must not preexist' >&2 + exit 2 +fi +mkdir -p "$task_case_root/bin" "$task_case_root/case/scripts/ci" +task_case_root="$(cd "$task_case_root" && pwd)" +export CBM_VM_ISOLATION_EVENTS="$task_case_root/events" +export CBM_VM_ISOLATION_TEMP="$task_case_root/protected-root" +cat > "$task_case_root/bin/powershell.exe" <<'SH' +#!/usr/bin/env bash +printf 'powershell' >> "$CBM_VM_ISOLATION_EVENTS" +printf ' <%s>' "$@" >> "$CBM_VM_ISOLATION_EVENTS" +printf '\n' >> "$CBM_VM_ISOLATION_EVENTS" +printf '%s\n' "$CBM_VM_ISOLATION_TEMP" +SH +cat > "$task_case_root/bin/cygpath" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$2" +SH +cat > "$task_case_root/bin/icacls" <<'SH' +#!/usr/bin/env bash +printf 'icacls' >> "$CBM_VM_ISOLATION_EVENTS" +printf ' <%s>' "$@" >> "$CBM_VM_ISOLATION_EVENTS" +printf '\n' >> "$CBM_VM_ISOLATION_EVENTS" +SH +cat > "$task_case_root/case/scripts/test.sh" <<'SH' +#!/usr/bin/env bash +printf 'canonical-test' >> "$CBM_VM_ISOLATION_EVENTS" +printf ' <%s>' "$@" >> "$CBM_VM_ISOLATION_EVENTS" +printf '\n' >> "$CBM_VM_ISOLATION_EVENTS" +printf '1 passed\n' +if [ "${1:-}" != '--suites' ]; then printf '=== All tests passed ===\n'; fi +SH +chmod +x "$task_case_root/bin/"* "$task_case_root/case/scripts/test.sh" +task_wrapper="$(cd "$task_source_root" && pwd)/test-infrastructure/vm/vm-run-tests.sh" +task_failures=0 +check() { + if "$@"; then return; fi + task_failures=$((task_failures + 1)) + printf 'FAIL: %s\n' "$*" >&2 +} +for task_run in alpha beta; do + : > "$CBM_VM_ISOLATION_EVENTS" + ( + cd "$task_case_root/case" || exit 2 + PATH="$task_case_root/bin:$PATH" CBM_CI_RUN_ID="$task_run" CBM_CI_KEEP=1 \ + CBM_VM_TEST_LOG="$task_case_root/$task_run.log" \ + bash "$task_wrapper" --par + ) > "$task_case_root/$task_run.output" 2>&1 + check test "$?" -eq 0 + if grep -F -- '-PruneStale' "$CBM_VM_ISOLATION_EVENTS" >/dev/null; then + printf 'FAIL: isolated %s requested shared pruning\n' "$task_run" >&2 + task_failures=$((task_failures + 1)) + fi + check grep -F -- "<-ProtectDir> " "$CBM_VM_ISOLATION_EVENTS" + check grep -F -- "icacls " "$CBM_VM_ISOLATION_EVENTS" + check grep -F -- "canonical-test " "$CBM_VM_ISOLATION_EVENTS" + check grep -F -- '=== All tests passed ===' "$task_case_root/$task_run.log" + cp "$CBM_VM_ISOLATION_EVENTS" "$task_case_root/$task_run.events" +done +# Invalid external identifiers must fail before an OS adapter is invoked. +: > "$CBM_VM_ISOLATION_EVENTS" +( + cd "$task_case_root/case" || exit 2 + PATH="$task_case_root/bin:$PATH" CBM_CI_RUN_ID='../foreign' CBM_CI_KEEP=1 \ + CBM_VM_TEST_LOG="$task_case_root/invalid.log" bash "$task_wrapper" --par +) > "$task_case_root/invalid.output" 2>&1 +task_invalid_exit=$? +check test "$task_invalid_exit" -eq 2 +check test ! -s "$CBM_VM_ISOLATION_EVENTS" +if [ "$task_failures" -ne 0 ]; then + printf 'VM isolation dispatch: %s failed checks\n' "$task_failures" >&2 + exit 1 +fi +printf 'VM isolation dispatch passed: two isolated full entries and invalid identifier rejection\n' diff --git a/tests/test_vm_isolation_contract.sh b/tests/test_vm_isolation_contract.sh new file mode 100644 index 000000000..9c7f5236f --- /dev/null +++ b/tests/test_vm_isolation_contract.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Keep isolated Windows runs out of shared cleanup and on their own build paths. +set -euo pipefail +task_repo="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +task_contract_dir="$(mktemp -d "${TMPDIR:-/tmp}/vm-cbm-isolation.XXXXXX")" +task_contract_done=0 +task_contract_cleanup() { + if [ "$task_contract_done" -eq 1 ]; then + rm -rf -- "$task_contract_dir" + else + printf 'VM isolation diagnostics retained: %s\n' "$task_contract_dir" >&2 + fi +} +trap task_contract_cleanup EXIT +env -u CBM_VM_RUNNER -u CBM_VM_SOAK_BINARY bash \ + "$task_repo/tests/test_vm_isolated_dispatch.sh" "$task_repo" "$task_contract_dir/dispatch" +case "$(uname -s)" in +MINGW* | MSYS* | CYGWIN*) + MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoLogo -NoProfile -NonInteractive \ + -ExecutionPolicy Bypass \ + -File "$(cygpath -w "$task_repo/tests/test_windows_preflight_contract.ps1")" \ + -ScriptPath "$(cygpath -w "$task_repo/scripts/ci/clean-test-residue.ps1")" \ + -OutputDirectory "$(cygpath -w "$task_contract_dir/native")" \ + > "$task_contract_dir/native.log" 2>&1 + cat "$task_contract_dir/native.log" + grep -Fq 'WINDOWS_CHECK_ONLY_COMPLETE failures=0 sentinels=2' "$task_contract_dir/native.log" + ;; +*) + # Native PowerShell/DACL behavior is covered by this same contract on the + # real Windows leg. Unix runs exercise the shared Bash dispatch above. + echo 'Native preflight cases require Windows; Bash dispatch verified here.' + ;; +esac +task_contract_done=1 +echo 'VM isolation contract passed' diff --git a/tests/test_windows_preflight_contract.ps1 b/tests/test_windows_preflight_contract.ps1 new file mode 100644 index 000000000..41a8ca640 --- /dev/null +++ b/tests/test_windows_preflight_contract.ps1 @@ -0,0 +1,59 @@ +# Native Windows checks for a non-destructive capacity preflight. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$ScriptPath, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) +$ErrorActionPreference = 'Stop' +if (Test-Path -LiteralPath $OutputDirectory) { throw 'Test output must be fresh' } +New-Item -ItemType Directory -Path $OutputDirectory | Out-Null +$taskFirst = Join-Path $OutputDirectory 'cbm-foreign-sentinel' +$taskSecond = Join-Path $OutputDirectory 'cbm-vm-tmp-foreign-sentinel' +New-Item -ItemType Directory -Path $taskFirst, $taskSecond | Out-Null +[IO.File]::WriteAllText((Join-Path $taskFirst 'keep.txt'), 'first owned sentinel') +[IO.File]::WriteAllText((Join-Path $taskSecond 'keep.txt'), 'second owned sentinel') +$taskBefore = @{} +foreach ($taskPath in @($taskFirst, $taskSecond)) { + $taskBefore[$taskPath] = (Get-FileHash -LiteralPath (Join-Path $taskPath 'keep.txt')).Hash +} +$taskSavedProfile = $env:USERPROFILE +$taskSavedTemp = $env:TEMP +$taskFailures = 0 +try { + # Only this disposable test process and its child receive these roots. + $env:USERPROFILE = $OutputDirectory + $env:TEMP = $OutputDirectory + $taskPowerShell = Join-Path $PSHOME 'powershell.exe' + $taskCases = @( + @{ Name = 'capacity_accept'; Extra = @('-CheckOnly', '-MinFreeGB', '0'); Expected = 0 }, + @{ Name = 'capacity_reject'; Extra = @('-CheckOnly', '-MinFreeGB', '1048576'); Expected = 1 }, + @{ Name = 'skip_gate_conflict'; Extra = @('-CheckOnly', '-SkipGate'); Expected = 1 } + ) + foreach ($taskCase in $taskCases) { + $taskOut = Join-Path $OutputDirectory ($taskCase.Name + '.out') + $taskErr = Join-Path $OutputDirectory ($taskCase.Name + '.err') + $taskArgs = @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath) + $taskCase.Extra + $ErrorActionPreference = 'Continue' + & $taskPowerShell @taskArgs 1>$taskOut 2>$taskErr + $taskExit = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + $taskPassed = if ($taskCase.Expected -eq 0) { $taskExit -eq 0 } else { $taskExit -ne 0 } + if ($taskCase.Name -eq 'capacity_reject') { + $taskPassed = $taskPassed -and ((Get-Content -Raw -LiteralPath $taskErr) -match 'BLOCKED:') + } + if (-not $taskPassed) { $taskFailures++ } + Write-Output ($taskCase.Name + ' exit=' + $taskExit + ' expected=' + $taskCase.Expected + ' passed=' + $taskPassed) + foreach ($taskPath in @($taskFirst, $taskSecond)) { + $taskFile = Join-Path $taskPath 'keep.txt' + if (-not (Test-Path -LiteralPath $taskFile) -or + (Get-FileHash -LiteralPath $taskFile).Hash -ne $taskBefore[$taskPath]) { + throw 'The preflight changed a sentinel belonging to another run' + } + } + } +} finally { + $env:USERPROFILE = $taskSavedProfile + $env:TEMP = $taskSavedTemp +} +Write-Output ('WINDOWS_CHECK_ONLY_COMPLETE failures=' + $taskFailures + ' sentinels=2') +if ($taskFailures) { exit 1 } From c5abb64943ec12c7c6e8f89e13c4378bf5a92f53 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 5 Oct 2026 02:15:08 +0200 Subject: [PATCH 21/44] feat(test-impact): detect_changes scope:"tests" end to end One request pins the change (merge base .. HEAD), builds the graph and the runnable test registry of exactly the HEAD snapshot, seeds the graph from the change, walks what depends on it and answers which tests to run (cbm.test_impact). Missing or doubtful evidence never narrows: it becomes a run-all answer that names why. Snapshot (classified ledger -> frozen graph -> registry): - one shared language classifier for discovery and pinned blobs (raw bytes on every platform); the classified ledger feeds the frozen build, which indexes exactly the listed files and records partial parses as per-file PARSE_GAP instead of refusing the graph; - the pinned tree skips symlinks and submodule links (counted), as discovery never indexes them; - the test model scopes uncertainty to the suites of its file (they run whole), reads line splices where they occur, treats a mid-file `#ifndef X` + `#define X` as an ordinary condition, and no longer reads a plain identifier named like a macro as an invocation. On this repository the registry is now narrowable and matches a text census exactly (8,365 cases, 141 runner suites). Seeding and reach (port of the measured replay, rule B): - hunk -> overlapping definitions, comment-only hunks dropped, pure deletions -> enclosing definition, no-definition hunks -> whole file, deleted names found by text, header changes propagated through the include closure and the headers' macros (pastes included), file-level escalation for unclassifiable header lines; - inbound fixpoint walk over CALLS, ASYNC_CALLS, USAGE, CALL_REFERENCE, READS (+ routes); cases, suites and the runner's entry points are sinks; a reached runner main runs everything (RUNNER_REACHED); - a parse gap whose text names a reached definition reaches the definition around it (or its file); - macro-registered tests run their suites whole; cases and suites map to graph nodes by their start line. The line patterns reproduce the reference regexes exactly on 1,383,850 lines of this repository and 37,167,926 kernel lines; on 24 replayed changes the engine selects at least what the reference selected (13 identical, the rest only conservative supersets on the same graph). Also: - configured test declarations that cannot be mapped degrade their file (no configured roles, a test_declarations diagnostic) instead of aborting the whole index; - the selector keeps other runners' suites out of the selection, and the result codec judges the model by narrowability like the selector; - a change to .codebase-memory.json runs everything (built-in rule); - .codebase-memory.json with the c-cbm preset and this repository's rules. Gate fixes (suites, lint-ci, Windows cross-compile): - the request runs on its own thread with a default-size stack (daemon tool calls run on 256 KiB threads, where the in-process build died); - a request-supplied config_path is no longer followed by an unlink of an uninitialized path buffer; - the inventory's frame-local reader no longer stays reachable from the caller's source after the filter call; - FILE_ID_INFO needs _WIN32_WINNT >= 0x0602; the pinned-tree header pins it before any system header (toolchains defaulting lower failed); - memory-core ratchet: the win_utf8 converters gain core-backed `_in(cls, ...)` forms over one implementation (the legacy names keep the C library for unmigrated callers), the Git facts module uses them, and the Windows spawn path releases everything through one failure exit; pipeline, extraction and config allocations that this work touched moved to the core in matched pairs; baselines tightened; - the branch's C files formatted with the project's clang-format. Signed-off-by: Martin Vogel --- .codebase-memory.json | 49 + Makefile.cbm | 6 +- internal/cbm/cbm.c | 27 +- internal/cbm/cbm.h | 31 +- internal/cbm/extract_defs.c | 437 ++++++--- scripts/memory-core-baseline.txt | 12 +- src/discover/discover.c | 71 +- src/discover/discover.h | 27 + src/discover/inventory_filter.h | 48 +- src/discover/language.c | 410 ++++++--- src/discover/userconfig.c | 37 +- src/discover/userconfig.h | 11 +- src/foundation/subprocess.c | 78 +- src/foundation/win_utf8.h | 91 +- src/mcp/mcp.c | 81 +- src/mcp/test_impact.c | 144 ++- src/mcp/test_impact.h | 49 +- src/mcp/test_impact_changes.c | 55 +- src/mcp/test_impact_changes.h | 12 +- src/mcp/test_impact_classify.c | 363 ++++++++ src/mcp/test_impact_classify.h | 80 ++ src/mcp/test_impact_coverage.c | 62 +- src/mcp/test_impact_engine.c | 650 +++++++++++++ src/mcp/test_impact_engine.h | 55 ++ src/mcp/test_impact_engine_internal.h | 115 +++ src/mcp/test_impact_engine_reach.c | 694 ++++++++++++++ src/mcp/test_impact_engine_snapshot.c | 350 +++++++ src/mcp/test_impact_git.c | 146 ++- src/mcp/test_impact_git.h | 48 +- src/mcp/test_impact_inventory.c | 3 + src/mcp/test_impact_inventory.h | 17 +- src/mcp/test_impact_origins.c | 69 +- src/mcp/test_impact_origins.h | 32 +- src/mcp/test_impact_policy.c | 5 + src/mcp/test_impact_result.h | 3 + src/mcp/test_impact_result_project.c | 2 +- src/mcp/test_impact_result_rows.c | 8 +- src/mcp/test_impact_result_wire.c | 1 + src/mcp/test_impact_seed.c | 941 +++++++++++++++++++ src/mcp/test_impact_seed.h | 104 +++ src/mcp/test_impact_select.c | 15 +- src/mcp/test_impact_source.c | 965 ++++++++++++++++++++ src/mcp/test_impact_source.h | 117 +++ src/mcp/test_impact_tree.c | 45 +- src/mcp/test_impact_tree.h | 5 + src/mcp/test_impact_tree_batch.c | 6 +- src/mcp/test_impact_tree_digest.c | 2 +- src/mcp/test_impact_tree_internal.h | 7 + src/pipeline/pass_calls.c | 10 +- src/pipeline/pass_definitions.c | 37 +- src/pipeline/pass_lsp_cross.c | 48 +- src/pipeline/pass_parallel.c | 34 +- src/pipeline/pass_semantic.c | 10 +- src/pipeline/pass_usages.c | 9 +- src/pipeline/pipeline.c | 234 ++++- src/pipeline/pipeline_incremental.c | 62 +- src/pipeline/pipeline_internal.h | 73 +- src/store/store.c | 19 +- src/store/store_graph_digest.c | 133 +-- src/store/store_graph_digest.h | 6 +- src/store/store_impact.c | 73 +- src/store/store_impact.h | 4 +- tests/test_language.c | 179 ++++ tests/test_main.c | 8 + tests/test_mcp.c | 25 + tests/test_pipeline_conventions.c | 32 +- tests/test_pipeline_frozen.c | 163 +++- tests/test_test_conventions.c | 37 +- tests/test_test_impact.c | 356 +++++++- tests/test_test_impact_classify.c | 388 ++++++++ tests/test_test_impact_engine.c | 305 +++++++ tests/test_test_impact_inventory_internal.h | 2 + tests/test_test_impact_inventory_support.c | 14 +- tests/test_test_impact_seed.c | 295 ++++++ tests/test_test_impact_source.c | 325 +++++++ tests/test_test_impact_tree.c | 19 +- 76 files changed, 8413 insertions(+), 1073 deletions(-) create mode 100644 .codebase-memory.json create mode 100644 src/mcp/test_impact_classify.c create mode 100644 src/mcp/test_impact_classify.h create mode 100644 src/mcp/test_impact_engine.c create mode 100644 src/mcp/test_impact_engine.h create mode 100644 src/mcp/test_impact_engine_internal.h create mode 100644 src/mcp/test_impact_engine_reach.c create mode 100644 src/mcp/test_impact_engine_snapshot.c create mode 100644 src/mcp/test_impact_seed.c create mode 100644 src/mcp/test_impact_seed.h create mode 100644 src/mcp/test_impact_source.c create mode 100644 src/mcp/test_impact_source.h create mode 100644 tests/test_test_impact_classify.c create mode 100644 tests/test_test_impact_engine.c create mode 100644 tests/test_test_impact_seed.c create mode 100644 tests/test_test_impact_source.c diff --git a/.codebase-memory.json b/.codebase-memory.json new file mode 100644 index 000000000..855970754 --- /dev/null +++ b/.codebase-memory.json @@ -0,0 +1,49 @@ +{ + "test_impact": { + "version": 1, + "tests": { + "presets": { + "c-cbm": true + } + }, + "rules": [ + { + "id": "harness", + "paths": ["tests/test_framework.h", "tests/test_helpers.*", "tests/test_main.c"], + "action": "run_all" + }, + { + "id": "engine", + "paths": [".codebase-memory.json", "src/mcp/test_impact*", "src/store/store_impact.*"], + "action": "run_all" + }, + { + "id": "build", + "paths": ["scripts/**", "test-infrastructure/**", "pkg/**"], + "action": "run_all" + }, + { + "id": "grammars", + "paths": ["internal/cbm/vendored/**"], + "action": "run_all" + }, + { + "id": "fixtures", + "paths": ["tests/fixtures/*/**"], + "action": "referencing_tests", + "key": "path_segment:2" + }, + { + "id": "contracts", + "paths": ["tests/**/*.{sh,py,json,txt}"], + "action": "ignore" + }, + { + "id": "notices", + "paths": ["NOTICE*", "CHANGELOG*", "THIRD_PARTY*"], + "action": "ignore" + } + ], + "unmapped_files": "run_all" + } +} diff --git a/Makefile.cbm b/Makefile.cbm index a545e246b..3c4e3d2a5 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -429,7 +429,7 @@ CYPHER_SRCS = src/cypher/cypher.c # MCP server module (new) MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c \ - src/mcp/test_impact.c src/mcp/test_impact_diff.c src/mcp/test_impact_changes.c src/mcp/test_impact_origins.c src/mcp/test_impact_coverage.c src/mcp/test_impact_config.c src/mcp/test_impact_select.c src/mcp/test_impact_policy.c src/mcp/test_impact_git.c src/mcp/test_impact_inventory.c src/mcp/test_impact_inventory_input.c src/mcp/test_impact_tree.c src/mcp/test_impact_tree_audit.c src/mcp/test_impact_tree_read.c src/mcp/test_impact_tree_batch.c src/mcp/test_impact_tree_digest.c src/mcp/test_impact_tree_names.c src/mcp/test_impact_tree_plan.c src/mcp/test_impact_tree_posix.c src/mcp/test_impact_tree_posix_acl.c src/mcp/test_impact_tree_posix_io.c src/mcp/test_impact_tree_util.c src/mcp/test_impact_tree_windows.c src/mcp/test_impact_tree_windows_acl.c src/mcp/test_impact_tree_windows_io.c \ + src/mcp/test_impact.c src/mcp/test_impact_diff.c src/mcp/test_impact_classify.c src/mcp/test_impact_source.c src/mcp/test_impact_seed.c src/mcp/test_impact_engine.c src/mcp/test_impact_engine_snapshot.c src/mcp/test_impact_engine_reach.c src/mcp/test_impact_changes.c src/mcp/test_impact_origins.c src/mcp/test_impact_coverage.c src/mcp/test_impact_config.c src/mcp/test_impact_select.c src/mcp/test_impact_policy.c src/mcp/test_impact_git.c src/mcp/test_impact_inventory.c src/mcp/test_impact_inventory_input.c src/mcp/test_impact_tree.c src/mcp/test_impact_tree_audit.c src/mcp/test_impact_tree_read.c src/mcp/test_impact_tree_batch.c src/mcp/test_impact_tree_digest.c src/mcp/test_impact_tree_names.c src/mcp/test_impact_tree_plan.c src/mcp/test_impact_tree_posix.c src/mcp/test_impact_tree_posix_acl.c src/mcp/test_impact_tree_posix_io.c src/mcp/test_impact_tree_util.c src/mcp/test_impact_tree_windows.c src/mcp/test_impact_tree_windows_acl.c src/mcp/test_impact_tree_windows_io.c \ src/mcp/test_impact_result.c src/mcp/test_impact_result_json.c src/mcp/test_impact_result_project.c src/mcp/test_impact_result_receipt.c src/mcp/test_impact_result_rows.c src/mcp/test_impact_result_util.c src/mcp/test_impact_result_validate.c src/mcp/test_impact_result_wire.c # Shared MCP daemon coordination and local transport @@ -705,6 +705,10 @@ TEST_MCP_SRCS = \ tests/test_test_impact_git.c \ tests/test_test_impact_tree.c \ tests/test_test_impact_inventory.c \ + tests/test_test_impact_classify.c \ + tests/test_test_impact_source.c \ + tests/test_test_impact_seed.c \ + tests/test_test_impact_engine.c \ tests/test_test_impact_inventory_errors.c \ tests/test_test_impact_inventory_support.c \ tests/test_test_impact_runner_filter.c \ diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 3c29bd8dd..ceb08843e 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -2380,16 +2380,18 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C cbm_run_go_lsp(a, result, source, source_len, root); } if (language == CBM_LANG_C || language == CBM_LANG_CPP || language == CBM_LANG_CUDA) { - if (!cbm_run_c_lsp_with_test_owners( - a, result, source, source_len, root, language != CBM_LANG_C, - CBM_SOURCE_ORIGIN_RAW, result)) { + if (!cbm_run_c_lsp_with_test_owners(a, result, source, source_len, root, + language != CBM_LANG_C, CBM_SOURCE_ORIGIN_RAW, + result)) { result->has_error = true; if (result->test_declarations_status == CBM_TEST_EXTRACT_OK) { result->test_declarations_status = CBM_TEST_EXTRACT_UNSUPPORTED_FORM; result->test_declaration_index = -1; result->test_declaration_line = 0; - result->error_msg = cbm_arena_strdup(a, "configured owner source identity mismatch"); - if (!result->error_msg) result->test_declarations_status = CBM_TEST_EXTRACT_OOM; + result->error_msg = + cbm_arena_strdup(a, "configured owner source identity mismatch"); + if (!result->error_msg) + result->test_declarations_status = CBM_TEST_EXTRACT_OOM; } } } @@ -3068,16 +3070,15 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua const char **include_paths, const CBMMacroTable *macro_table, const CBMReturnTypeTable *return_type_table) { return cbm_extract_file_ex_with_tests(source, source_len, language, project, rel_path, - timeout_micros, extra_defines, include_paths, - macro_table, return_type_table, NULL); + timeout_micros, extra_defines, include_paths, macro_table, + return_type_table, NULL); } CBMFileResult *cbm_extract_file_ex_with_tests( const char *source, int source_len, CBMLanguage language, const char *project, const char *rel_path, int64_t timeout_micros, const char **extra_defines, const char **include_paths, const CBMMacroTable *macro_table, - const CBMReturnTypeTable *return_type_table, - const cbm_test_declarations_t *test_declarations) { + const CBMReturnTypeTable *return_type_table, const cbm_test_declarations_t *test_declarations) { CBMArena scratch; if (tl_scratch_live && tl_scratch_slot) { scratch = *tl_scratch_slot; @@ -3086,10 +3087,9 @@ CBMFileResult *cbm_extract_file_ex_with_tests( } else { cbm_arena_init_lazy(&scratch, CBM_EXTRACT_SCRATCH_BLOCK); } - CBMFileResult *result = extract_file_ex_body(source, source_len, language, project, rel_path, - timeout_micros, extra_defines, include_paths, - macro_table, return_type_table, &scratch, - test_declarations); + CBMFileResult *result = extract_file_ex_body( + source, source_len, language, project, rel_path, timeout_micros, extra_defines, + include_paths, macro_table, return_type_table, &scratch, test_declarations); /* !tl_scratch_live: a nested extraction (an embedded language inside this * file) may already have parked its own; never overwrite it. */ /* Kept up to CBM_EXTRACT_SCRATCH_KEEP_BYTES, grown blocks included: the @@ -3110,6 +3110,7 @@ CBMFileResult *cbm_extract_file_ex_with_tests( } else { cbm_arena_destroy(&scratch); } + cbm_test_declarations_degrade(result); return result; } diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index b14a4aaca..9595f1aff 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -636,12 +636,21 @@ typedef struct CBMFileResult { // by cbm_free_result(); ordinary single-file results leave these zeroed. struct CBMFileResult **owned_results; int owned_result_count; - /* A consumed configured definition could not be mapped safely. Pipeline - * callers must abort publication; error_msg is arena-owned as usual. */ + /* A consumed configured definition could not be mapped safely. Only an + * allocation failure (OOM) still aborts publication: any other status is + * degraded per file by cbm_test_declarations_degrade. error_msg is + * arena-owned as usual. */ CBMTestExtractStatus test_declarations_status; - int test_declaration_index; /* meaningful on failure; -1 = preset */ + int test_declaration_index; /* meaningful on failure; -1 = preset */ uint32_t test_declaration_line; /* 0 = snapshot preflight */ bool has_test_definition_owners; + /* has_error was raised by a configured-definition issue alone. */ + bool test_error_only; + /* The file's configured test forms could not be mapped: it carries no + * configured test roles; the status names why (no pointer: compaction + * and spill relocate only the strings they know). */ + bool test_declarations_degraded; + CBMTestExtractStatus test_declarations_degraded_status; int test_owner_source_len; CBMLanguage test_owner_language; char test_owner_source_sha256[65]; /* exact raw source; identity, not authentication */ @@ -739,6 +748,15 @@ typedef struct { } CBMExtractCtx; /* Internal configured-definition seams. No declarations pointer enters a result. */ +/* A configured test form the extractor cannot map degrades the FILE, not + * the index (user decision 2026-10-04): its configured test roles, owner + * spans and owner flag are dropped, the status reads OK again, an error the + * issue alone raised is cleared, and test_declarations_degraded keeps the + * reason for a per-file diagnostic. OK and OOM results are left unchanged: + * an allocation failure still fails. Idempotent. */ +void cbm_test_declarations_degrade(CBMFileResult *result); +/* The fixed description of a configured-definition status (static). */ +const char *cbm_test_extract_status_message(CBMTestExtractStatus status); bool cbm_test_declarations_validate(CBMFileResult *result, const cbm_test_declarations_t *declarations); void cbm_test_declarations_finish(CBMExtractCtx *ctx); @@ -859,10 +877,9 @@ CBMFileResult *cbm_extract_file_ex( * successful empty inventory. error_msg may be NULL on diagnostic OOM. */ CBMFileResult *cbm_extract_file_ex_with_tests( const char *source, int source_len, CBMLanguage language, const char *project, - const char *rel_path, int64_t timeout_micros, - const char **extra_defines, const char **include_paths, - const CBMMacroTable *macro_table, const CBMReturnTypeTable *return_type_table, - const cbm_test_declarations_t *test_declarations); + const char *rel_path, int64_t timeout_micros, const char **extra_defines, + const char **include_paths, const CBMMacroTable *macro_table, + const CBMReturnTypeTable *return_type_table, const cbm_test_declarations_t *test_declarations); // Free all memory associated with a result. void cbm_free_result(CBMFileResult *result); diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index c1349c8a8..a00300910 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3,7 +3,7 @@ #include "helpers.h" #include "lang_specs.h" #include "foundation/constants.h" -#include "foundation/log.h" // cbm_log_error +#include "foundation/log.h" // cbm_log_error #include "discover/test_conventions.h" #include "foundation/sha256.h" #include "foundation/mem_core.h" // cbm_realloc/cbm_free -- walk_defs stack @@ -3796,7 +3796,7 @@ static char *resolve_cpp_test_macro_name(CBMArena *a, const char *macro, TSNode * The map lives in traversal scratch; its QNs live in the result arena. */ struct CBMTestDefinitionMatch { uint32_t name_byte; - TSNode name_node; /* same raw tree; never retained beyond extraction */ + TSNode name_node; /* same raw tree; never retained beyond extraction */ TSNode scope_node; /* function, or split invocation's compound body */ uint32_t line; int declaration; @@ -3811,48 +3811,88 @@ typedef struct { } TDXRule; static int tdx_language(const char *name) { - if (!name) return 0; - if (strcmp(name, "c") == 0 || strcmp(name, "C") == 0) return 1; - if (strcmp(name, "cpp") == 0 || strcmp(name, "c++") == 0 || - strcmp(name, "C++") == 0) return 2; - if (strcmp(name, "cuda") == 0 || strcmp(name, "CUDA") == 0) return 3; + if (!name) + return 0; + if (strcmp(name, "c") == 0 || strcmp(name, "C") == 0) + return 1; + if (strcmp(name, "cpp") == 0 || strcmp(name, "c++") == 0 || strcmp(name, "C++") == 0) + return 2; + if (strcmp(name, "cuda") == 0 || strcmp(name, "CUDA") == 0) + return 3; return 0; } static int tdx_source_language(CBMLanguage language) { - return language == CBM_LANG_C ? 1 : language == CBM_LANG_CPP ? 2 : - language == CBM_LANG_CUDA ? 3 : 0; + return language == CBM_LANG_C ? 1 + : language == CBM_LANG_CPP ? 2 + : language == CBM_LANG_CUDA ? 3 + : 0; } -static bool tdx_issue(CBMFileResult *result, CBMTestExtractStatus status, - int declaration, uint32_t line) { - if (result->test_declarations_status == CBM_TEST_EXTRACT_OOM) return false; - bool earlier = result->test_declarations_status == CBM_TEST_EXTRACT_OK || - status == CBM_TEST_EXTRACT_OOM || line < result->test_declaration_line || - (line == result->test_declaration_line && - (declaration < result->test_declaration_index || - (declaration == result->test_declaration_index && - status < result->test_declarations_status))); - if (!earlier) return false; - static const char *const messages[] = { - "", "unsupported configured definition language", - "unsupported disabled native test preset", "unsupported configured definition name", - "configured definition argument is missing", "configured definition argument is not an identifier", - "ambiguous configured definition mapping", "unsupported configured definition form", - "configured definition allocation failed" - }; +const char *cbm_test_extract_status_message(CBMTestExtractStatus status) { + static const char *const messages[] = {"", + "unsupported configured definition language", + "unsupported disabled native test preset", + "unsupported configured definition name", + "configured definition argument is missing", + "configured definition argument is not an identifier", + "ambiguous configured definition mapping", + "unsupported configured definition form", + "configured definition allocation failed"}; + size_t count = sizeof(messages) / sizeof(messages[0]); + return (size_t)status < count ? messages[status] : "configured definition issue"; +} + +static bool tdx_issue(CBMFileResult *result, CBMTestExtractStatus status, int declaration, + uint32_t line) { + if (result->test_declarations_status == CBM_TEST_EXTRACT_OOM) + return false; + bool earlier = + result->test_declarations_status == CBM_TEST_EXTRACT_OK || status == CBM_TEST_EXTRACT_OOM || + line < result->test_declaration_line || + (line == result->test_declaration_line && (declaration < result->test_declaration_index || + (declaration == result->test_declaration_index && + status < result->test_declarations_status))); + if (!earlier) + return false; + if (!result->has_error) + result->test_error_only = true; + result->has_error = true; result->test_declarations_status = status; result->test_declaration_index = declaration; result->test_declaration_line = line; - result->error_msg = cbm_arena_strdup(&result->arena, messages[status]); - if (!result->error_msg) result->test_declarations_status = CBM_TEST_EXTRACT_OOM; + result->error_msg = cbm_arena_strdup(&result->arena, cbm_test_extract_status_message(status)); + if (!result->error_msg) + result->test_declarations_status = CBM_TEST_EXTRACT_OOM; return false; } +void cbm_test_declarations_degrade(CBMFileResult *result) { + if (!result || result->test_declarations_status == CBM_TEST_EXTRACT_OK || + result->test_declarations_status == CBM_TEST_EXTRACT_OOM) + return; + result->test_declarations_degraded = true; + result->test_declarations_degraded_status = result->test_declarations_status; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + def->test_role = CBM_TEST_ROLE_NONE; + def->test_name_start_byte = def->test_name_end_byte = 0; + def->test_body_start_byte = def->test_body_end_byte = 0; + } + result->has_test_definition_owners = false; + result->test_declarations_status = CBM_TEST_EXTRACT_OK; + if (result->test_error_only) { + result->has_error = false; + result->error_msg = NULL; + result->test_error_only = false; + } +} + bool cbm_test_declarations_validate(CBMFileResult *result, const cbm_test_declarations_t *declarations) { - if (!declarations) return true; + if (!declarations) + return true; for (int i = 0; i < CBM_TEST_PRESET_COUNT; i++) { bool enabled = false; if (!cbm_test_declarations_preset(declarations, (cbm_test_preset_t)i, &enabled)) @@ -3864,7 +3904,8 @@ bool cbm_test_declarations_validate(CBMFileResult *result, const cbm_test_declaration_t *items = cbm_test_declarations_items(declarations, &count); for (int i = 0; i < count; i++) { const cbm_test_declaration_t *rule = &items[i]; - if (rule->role != CBM_TEST_DECL_CASE && rule->role != CBM_TEST_DECL_SUITE) continue; + if (rule->role != CBM_TEST_DECL_CASE && rule->role != CBM_TEST_DECL_SUITE) + continue; if (!tdx_language(rule->language)) tdx_issue(result, CBM_TEST_EXTRACT_UNSUPPORTED_LANGUAGE, i, 0); if (rule->name_arg_count != 1 || !rule->name_args || rule->name_args[0] < 0) @@ -3874,7 +3915,8 @@ bool cbm_test_declarations_validate(CBMFileResult *result, } static bool tdx_preset(const CBMExtractCtx *ctx, cbm_test_preset_t preset) { - if (!ctx->test_declarations) return preset != CBM_TEST_PRESET_C_CBM; + if (!ctx->test_declarations) + return preset != CBM_TEST_PRESET_C_CBM; bool enabled = false; return cbm_test_declarations_preset(ctx->test_declarations, preset, &enabled) && enabled; } @@ -3883,12 +3925,14 @@ static bool tdx_equal(const char *name, const char *text, size_t size) { return name && strlen(name) == size && memcmp(name, text, size) == 0; } -static bool tdx_match(CBMExtractCtx *ctx, const char *text, size_t size, - uint32_t line, TDXRule *chosen) { +static bool tdx_match(CBMExtractCtx *ctx, const char *text, size_t size, uint32_t line, + TDXRule *chosen) { *chosen = (TDXRule){.argument = -1, .declaration = -1}; - if (!ctx->test_declarations || !ctx->test_declarations_raw_source) return false; + if (!ctx->test_declarations || !ctx->test_declarations_raw_source) + return false; int language = tdx_source_language(ctx->language), count = 0; - if (!language) return false; + if (!language) + return false; const cbm_test_declaration_t *items = cbm_test_declarations_items(ctx->test_declarations, &count); bool matched = false, valid = true; @@ -3897,8 +3941,8 @@ static bool tdx_match(CBMExtractCtx *ctx, const char *text, size_t size, if ((rule->role != CBM_TEST_DECL_CASE && rule->role != CBM_TEST_DECL_SUITE) || tdx_language(rule->language) != language || !tdx_equal(rule->define_macro, text, size)) continue; - CBMTestDefinitionRole role = rule->role == CBM_TEST_DECL_CASE ? - CBM_TEST_ROLE_CASE : CBM_TEST_ROLE_SUITE; + CBMTestDefinitionRole role = + rule->role == CBM_TEST_DECL_CASE ? CBM_TEST_ROLE_CASE : CBM_TEST_ROLE_SUITE; if (rule->name_arg_count != 1 || !rule->name_args || rule->name_args[0] < 0) { tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_NAME, i, line); valid = false; @@ -3909,19 +3953,21 @@ static bool tdx_match(CBMExtractCtx *ctx, const char *text, size_t size, tdx_issue(ctx->result, CBM_TEST_EXTRACT_AMBIGUOUS, i, line); valid = false; } - if (!matched) *chosen = (TDXRule){role, rule->name_args[0], i, true}; + if (!matched) + *chosen = (TDXRule){role, rule->name_args[0], i, true}; matched = true; } if (language == 1 && tdx_preset(ctx, CBM_TEST_PRESET_C_CBM)) { - CBMTestDefinitionRole role = tdx_equal("TEST", text, size) ? CBM_TEST_ROLE_CASE : - tdx_equal("SUITE", text, size) ? CBM_TEST_ROLE_SUITE : - CBM_TEST_ROLE_NONE; + CBMTestDefinitionRole role = tdx_equal("TEST", text, size) ? CBM_TEST_ROLE_CASE + : tdx_equal("SUITE", text, size) ? CBM_TEST_ROLE_SUITE + : CBM_TEST_ROLE_NONE; if (role != CBM_TEST_ROLE_NONE) { if (matched && (chosen->role != role || chosen->argument != 0)) { tdx_issue(ctx->result, CBM_TEST_EXTRACT_AMBIGUOUS, -1, line); valid = false; } - if (!matched) *chosen = (TDXRule){role, 0, -1, true}; + if (!matched) + *chosen = (TDXRule){role, 0, -1, true}; matched = true; } } @@ -3957,7 +4003,10 @@ static bool tdx_pp_number(const char *s, size_t n, size_t *at) { i++; while (i < n) { unsigned char c = (unsigned char)s[i]; - if (tdx_ident(c) || c == '.') { i++; continue; } + if (tdx_ident(c) || c == '.') { + i++; + continue; + } if (c == '\'' && i + 1 < n && tdx_ident((unsigned char)s[i + 1])) { i += 2; continue; @@ -3986,38 +4035,51 @@ static bool tdx_splice(const char *s, size_t n, size_t at) { * line splicing/raw strings are explicit uncertainty, not token guesses. */ static bool tdx_trivia(const char *s, size_t n, size_t *at) { for (;;) { - while (*at < n && tdx_space((unsigned char)s[*at])) (*at)++; - if (*at + 1 >= n || s[*at] != '/') return true; + while (*at < n && tdx_space((unsigned char)s[*at])) + (*at)++; + if (*at + 1 >= n || s[*at] != '/') + return true; if (s[*at + 1] == '/') { *at += 2; while (*at < n && s[*at] != '\n') { - if (tdx_splice(s, n, *at)) return false; + if (tdx_splice(s, n, *at)) + return false; (*at)++; } } else if (s[*at + 1] == '*') { *at += 2; while (*at + 1 < n && !(s[*at] == '*' && s[*at + 1] == '/')) { - if (tdx_splice(s, n, *at)) return false; + if (tdx_splice(s, n, *at)) + return false; (*at)++; } - if (*at + 1 >= n) return false; + if (*at + 1 >= n) + return false; *at += 2; - } else return true; + } else + return true; } } static bool tdx_literal(const char *s, size_t n, size_t *at) { char quote = s[*at]; - if (quote == '"' && *at && s[*at - 1] == 'R') return false; + if (quote == '"' && *at && s[*at - 1] == 'R') + return false; (*at)++; while (*at < n) { char c = s[*at]; - if (c == quote) { (*at)++; return true; } - if (c == '\0' || c == '\n' || c == '\r' || tdx_splice(s, n, *at)) return false; + if (c == quote) { + (*at)++; + return true; + } + if (c == '\0' || c == '\n' || c == '\r' || tdx_splice(s, n, *at)) + return false; if (c == '\\') { - if (*at + 1 >= n) return false; + if (*at + 1 >= n) + return false; *at += 2; - } else (*at)++; + } else + (*at)++; } return false; } @@ -4025,7 +4087,8 @@ static bool tdx_literal(const char *s, size_t n, size_t *at) { /* The audit skips complete raw strings outside consumed invocations. The * invocation adapter remains deliberately stricter (tdx_literal above). */ static bool tdx_audit_literal(const char *s, size_t n, size_t *at) { - if (s[*at] != '"' || !*at || s[*at - 1] != 'R') return tdx_literal(s, n, at); + if (s[*at] != '"' || !*at || s[*at - 1] != 'R') + return tdx_literal(s, n, at); size_t delimiter = *at + 1, open = delimiter; while (open < n && s[open] != '(') { unsigned char c = (unsigned char)s[open]; @@ -4033,12 +4096,12 @@ static bool tdx_audit_literal(const char *s, size_t n, size_t *at) { return false; open++; } - if (open == n) return false; + if (open == n) + return false; size_t length = open - delimiter; for (size_t end = open + 1; end < n; end++) { if (s[end] == ')' && length + 2 <= n - end && - memcmp(s + end + 1, s + delimiter, length) == 0 && - s[end + length + 1] == '"') { + memcmp(s + end + 1, s + delimiter, length) == 0 && s[end + length + 1] == '"') { *at = end + length + 2; return true; } @@ -4046,9 +4109,8 @@ static bool tdx_audit_literal(const char *s, size_t n, size_t *at) { return false; } -static bool tdx_argument(CBMExtractCtx *ctx, size_t at, int wanted, - size_t *name_at, size_t *name_length, size_t *after, - const TDXRule *rule, uint32_t line) { +static bool tdx_argument(CBMExtractCtx *ctx, size_t at, int wanted, size_t *name_at, + size_t *name_length, size_t *after, const TDXRule *rule, uint32_t line) { const char *s = ctx->source; size_t n = (size_t)ctx->source_len; if (!tdx_trivia(s, n, &at) || at >= n || s[at] != '(') @@ -4058,21 +4120,28 @@ static bool tdx_argument(CBMExtractCtx *ctx, size_t at, int wanted, int argument = 0; bool selected = false, closed = false; while (at < n && depth) { - if (!tdx_trivia(s, n, &at) || at >= n || s[at] == '\0' || tdx_splice(s, n, at)) break; - if (tdx_pp_number(s, n, &at)) continue; + if (!tdx_trivia(s, n, &at) || at >= n || s[at] == '\0' || tdx_splice(s, n, at)) + break; + if (tdx_pp_number(s, n, &at)) + continue; char c = s[at]; if (c == '"' || c == '\'') { - if (!tdx_literal(s, n, &at)) break; + if (!tdx_literal(s, n, &at)) + break; continue; } if (c == '(' || c == '[' || c == '{') { - if (depth == sizeof(closing)) break; + if (depth == sizeof(closing)) + break; closing[depth++] = c == '(' ? ')' : c == '[' ? ']' : '}'; } else if (c == ')' || c == ']' || c == '}') { - if (c != closing[depth - 1]) break; + if (c != closing[depth - 1]) + break; if (--depth == 0) { if (argument == wanted) { - selected_begin = begin; selected_end = at; selected = true; + selected_begin = begin; + selected_end = at; + selected = true; } *after = at + 1; closed = true; @@ -4080,7 +4149,9 @@ static bool tdx_argument(CBMExtractCtx *ctx, size_t at, int wanted, } } else if (c == ',' && depth == 1) { if (argument == wanted) { - selected_begin = begin; selected_end = at; selected = true; + selected_begin = begin; + selected_end = at; + selected = true; } argument++; begin = at + 1; @@ -4095,12 +4166,15 @@ static bool tdx_argument(CBMExtractCtx *ctx, size_t at, int wanted, if (!tdx_trivia(s, selected_end, &at) || at == selected_end) return tdx_issue(ctx->result, CBM_TEST_EXTRACT_MISSING_ARGUMENT, rule->declaration, line); if (!tdx_ident_first((unsigned char)s[at])) - return tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT, rule->declaration, line); + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT, rule->declaration, + line); *name_at = at++; - while (at < selected_end && tdx_ident((unsigned char)s[at])) at++; + while (at < selected_end && tdx_ident((unsigned char)s[at])) + at++; *name_length = at - *name_at; if (!tdx_trivia(s, selected_end, &at) || at != selected_end) - return tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT, rule->declaration, line); + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_ARGUMENT, rule->declaration, + line); return true; } @@ -4160,12 +4234,14 @@ bool cbm_test_definition_candidate(TSNode scope, CBMLanguage language, TSNode *n static bool tdx_observe(CBMExtractCtx *ctx, TSNode name_node, TSNode scope_node, const char *qn, const TDXRule *rule); -static bool tdx_resolve(CBMExtractCtx *ctx, TSNode node, TSNode name_node, - const char *macro, const char **name, TDXRule *rule) { +static bool tdx_resolve(CBMExtractCtx *ctx, TSNode node, TSNode name_node, const char *macro, + const char **name, TDXRule *rule) { *name = NULL; uint32_t line = ts_node_start_point(name_node).row + 1; - if (!tdx_match(ctx, macro, strlen(macro), line, rule)) return false; - if (ctx->result->test_declarations_status == CBM_TEST_EXTRACT_OOM) return true; + if (!tdx_match(ctx, macro, strlen(macro), line, rule)) + return false; + if (ctx->result->test_declarations_status == CBM_TEST_EXTRACT_OOM) + return true; if (!tdx_observe(ctx, name_node, node, NULL, rule) || !rule->valid) return true; uint32_t end = ts_node_end_byte(name_node); @@ -4174,7 +4250,8 @@ static bool tdx_resolve(CBMExtractCtx *ctx, TSNode node, TSNode name_node, tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule->declaration, line); return true; } - if (!tdx_argument(ctx, end, rule->argument, &id_at, &id_length, &after, rule, line)) return true; + if (!tdx_argument(ctx, end, rule->argument, &id_at, &id_length, &after, rule, line)) + return true; TSNode candidate_name, body; if (!cbm_test_definition_candidate(node, ctx->language, &candidate_name, &body) || !ts_node_eq(candidate_name, name_node)) { @@ -4182,16 +4259,16 @@ static bool tdx_resolve(CBMExtractCtx *ctx, TSNode node, TSNode name_node, return true; } if (!tdx_trivia(ctx->source, (size_t)ctx->source_len, &after) || - after >= (size_t)ctx->source_len || ctx->source[after] != '{' || - ts_node_is_null(body) || ts_node_has_error(body) || ts_node_is_missing(body) || - ts_node_start_byte(body) != after || ts_node_end_byte(body) <= after || - ts_node_end_byte(body) > (uint32_t)ctx->source_len || + after >= (size_t)ctx->source_len || ctx->source[after] != '{' || ts_node_is_null(body) || + ts_node_has_error(body) || ts_node_is_missing(body) || ts_node_start_byte(body) != after || + ts_node_end_byte(body) <= after || ts_node_end_byte(body) > (uint32_t)ctx->source_len || ctx->source[ts_node_end_byte(body) - 1] != '}') { tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule->declaration, line); return true; } *name = cbm_arena_sprintf(ctx->arena, "%s_%.*s", macro, (int)id_length, ctx->source + id_at); - if (!*name) tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, rule->declaration, line); + if (!*name) + tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, rule->declaration, line); return true; } @@ -4205,7 +4282,8 @@ static bool tdx_observe(CBMExtractCtx *ctx, TSNode name_node, TSNode scope_node, for (int i = 0; i < count; i++) { struct CBMTestDefinitionMatch *match = &ctx->test_definition_matches[i]; if (ts_node_eq(match->name_node, name_node)) { - if (qn) match->qn = qn; + if (qn) + match->qn = qn; return true; } } @@ -4219,8 +4297,10 @@ static bool tdx_observe(CBMExtractCtx *ctx, TSNode name_node, TSNode scope_node, CBMArena *scratch = ctx->scratch ? ctx->scratch : ctx->arena; struct CBMTestDefinitionMatch *items = cbm_arena_alloc(scratch, (size_t)next * sizeof(*items)); - if (!items) return tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, rule->declaration, line); - if (count) memcpy(items, ctx->test_definition_matches, (size_t)count * sizeof(*items)); + if (!items) + return tdx_issue(ctx->result, CBM_TEST_EXTRACT_OOM, rule->declaration, line); + if (count) + memcpy(items, ctx->test_definition_matches, (size_t)count * sizeof(*items)); ctx->test_definition_matches = items; ctx->test_definition_match_cap = next; } @@ -4232,7 +4312,8 @@ static bool tdx_observe(CBMExtractCtx *ctx, TSNode name_node, TSNode scope_node, const char *cbm_test_definition_qn(CBMExtractCtx *ctx, TSNode function) { if (!ctx->test_declarations_raw_source || ctx->test_definition_match_count == 0 || - ts_node_is_null(function)) return NULL; + ts_node_is_null(function)) + return NULL; for (int i = 0; i < ctx->test_definition_match_count; i++) if (ts_node_eq(ctx->test_definition_matches[i].scope_node, function)) return ctx->test_definition_matches[i].qn; @@ -4243,13 +4324,17 @@ const char *cbm_test_definition_qn(CBMExtractCtx *ctx, TSNode function) { * A local digest is identity evidence only, never artifact authentication. */ bool cbm_test_definition_owners_match(const CBMFileResult *owners, const char *source, int source_len, bool cpp_mode, const char *module_qn) { - if (!owners) return true; - if (owners->test_declarations_status != CBM_TEST_EXTRACT_OK) return false; - if (!owners->has_test_definition_owners) return true; - if (!source || source_len < 0 || source_len != owners->test_owner_source_len || - !module_qn || !owners->module_qn || strcmp(module_qn, owners->module_qn) != 0 || + if (!owners) + return true; + if (owners->test_declarations_status != CBM_TEST_EXTRACT_OK) + return false; + if (!owners->has_test_definition_owners) + return true; + if (!source || source_len < 0 || source_len != owners->test_owner_source_len || !module_qn || + !owners->module_qn || strcmp(module_qn, owners->module_qn) != 0 || !tdx_source_language(owners->test_owner_language) || - cpp_mode != (owners->test_owner_language != CBM_LANG_C)) return false; + cpp_mode != (owners->test_owner_language != CBM_LANG_C)) + return false; char digest[65]; cbm_sha256_hex(source, (size_t)source_len, digest); return memcmp(digest, owners->test_owner_source_sha256, sizeof(digest)) == 0; @@ -4259,7 +4344,8 @@ bool cbm_test_definition_owners_match(const CBMFileResult *owners, const char *s * Exact name AND body spans distinguish nodes, including nested definitions; * never infer an owner from a line window or a guessed macro name. */ const char *cbm_test_definition_owner_qn(const CBMFileResult *owners, TSNode function) { - if (!owners || !owners->has_test_definition_owners || ts_node_is_null(function)) return NULL; + if (!owners || !owners->has_test_definition_owners || ts_node_is_null(function)) + return NULL; TSNode name, body; if (!cbm_test_definition_candidate(function, owners->test_owner_language, &name, &body) || ts_node_is_null(body)) @@ -4271,8 +4357,10 @@ const char *cbm_test_definition_owner_qn(const CBMFileResult *owners, TSNode fun def->test_name_start_byte != ts_node_start_byte(name) || def->test_name_end_byte != ts_node_end_byte(name) || def->test_body_start_byte != ts_node_start_byte(body) || - def->test_body_end_byte != ts_node_end_byte(body)) continue; - if (found) return NULL; /* duplicate rows are rejected by finish */ + def->test_body_end_byte != ts_node_end_byte(body)) + continue; + if (found) + return NULL; /* duplicate rows are rejected by finish */ found = def->qualified_name; } return found; @@ -4283,28 +4371,38 @@ const char *cbm_test_definition_owner_qn(const CBMFileResult *owners, TSNode fun * removed. This locates its extent; tdx_directive validates consumed content. */ static bool tdx_directive_end(const char *s, size_t n, size_t *at, uint32_t *line) { while (*at < n) { - if (s[*at] == '\0') return false; - if (s[*at] == '\n') { (*at)++; (*line)++; return true; } + if (s[*at] == '\0') + return false; + if (s[*at] == '\n') { + (*at)++; + (*line)++; + return true; + } if (*at + 1 < n && s[*at] == '/' && s[*at + 1] == '*') { *at += 2; while (*at + 1 < n && !(s[*at] == '*' && s[*at + 1] == '/')) { - if (s[*at] == '\0') return false; - if (s[*at] == '\n') (*line)++; + if (s[*at] == '\0') + return false; + if (s[*at] == '\n') + (*line)++; (*at)++; } - if (*at + 1 >= n) return false; + if (*at + 1 >= n) + return false; *at += 2; continue; } if (*at + 1 < n && s[*at] == '/' && s[*at + 1] == '/') { *at += 2; while (*at < n && s[*at] != '\n') { - if (s[*at] == '\0') return false; + if (s[*at] == '\0') + return false; (*at)++; } continue; } - if (tdx_pp_number(s, n, at)) continue; + if (tdx_pp_number(s, n, at)) + continue; if (s[*at] == '"' || s[*at] == '\'') { size_t before = *at; if (!tdx_audit_literal(s, n, at)) { @@ -4312,11 +4410,14 @@ static bool tdx_directive_end(const char *s, size_t n, size_t *at, uint32_t *lin * content. Do not reinterpret its comment punctuation here. */ *at = before + 1; while (*at < n && s[*at] != '\n') { - if (s[*at] == '\0') return false; + if (s[*at] == '\0') + return false; (*at)++; } } - for (size_t i = before; i < *at; i++) if (s[i] == '\n') (*line)++; + for (size_t i = before; i < *at; i++) + if (s[i] == '\n') + (*line)++; continue; } (*at)++; @@ -4334,7 +4435,10 @@ static void tdx_directive(CBMExtractCtx *ctx, size_t begin, size_t end, uint32_t * the joined bytes never become declaration or graph source evidence. */ bool spliced = false; for (size_t i = 0; i < end; i++) - if (tdx_splice(s, end, i)) { spliced = true; break; } + if (tdx_splice(s, end, i)) { + spliced = true; + break; + } if (spliced) { CBMArena *scratch = ctx->scratch ? ctx->scratch : ctx->arena; char *joined = cbm_arena_alloc(scratch, end + 1); @@ -4344,8 +4448,10 @@ static void tdx_directive(CBMExtractCtx *ctx, size_t begin, size_t end, uint32_t } size_t used = 0; for (size_t i = 0; i < end;) { - if (tdx_splice(s, end, i)) i += s[i + 1] == '\r' ? 3 : 2; - else joined[used++] = s[i++]; + if (tdx_splice(s, end, i)) + i += s[i + 1] == '\r' ? 3 : 2; + else + joined[used++] = s[i++]; } joined[used] = '\0'; s = joined; @@ -4357,16 +4463,20 @@ static void tdx_directive(CBMExtractCtx *ctx, size_t begin, size_t end, uint32_t return; } size_t word = at; - while (at < end && tdx_ident((unsigned char)s[at])) at++; - if (!tdx_equal("define", s + word, at - word)) return; + while (at < end && tdx_ident((unsigned char)s[at])) + at++; + if (!tdx_equal("define", s + word, at - word)) + return; if (!tdx_trivia(s, end, &at) || at == end || !tdx_ident_first((unsigned char)s[at])) { tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, line); return; } - while (at < end && tdx_ident((unsigned char)s[at])) at++; + while (at < end && tdx_ident((unsigned char)s[at])) + at++; /* A function-like macro's formal parameters are not its replacement. */ if (at < end && s[at] == '(') { - while (at < end && s[at] != ')') at++; + while (at < end && s[at] != ')') + at++; if (at == end) { tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, line); return; @@ -4374,13 +4484,18 @@ static void tdx_directive(CBMExtractCtx *ctx, size_t begin, size_t end, uint32_t at++; } while (at < end) { - if (tdx_splice(s, end, at)) { at += s[at + 1] == '\r' ? 3 : 2; continue; } + if (tdx_splice(s, end, at)) { + at += s[at + 1] == '\r' ? 3 : 2; + continue; + } if (!tdx_trivia(s, end, &at)) { tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, line); return; } - if (at == end) return; - if (tdx_pp_number(s, end, &at)) continue; + if (at == end) + return; + if (tdx_pp_number(s, end, &at)) + continue; if (s[at] == '"' || s[at] == '\'') { if (!tdx_audit_literal(s, end, &at)) { tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, -1, line); @@ -4388,9 +4503,13 @@ static void tdx_directive(CBMExtractCtx *ctx, size_t begin, size_t end, uint32_t } continue; } - if (!tdx_ident_first((unsigned char)s[at])) { at++; continue; } + if (!tdx_ident_first((unsigned char)s[at])) { + at++; + continue; + } word = at++; - while (at < end && tdx_ident((unsigned char)s[at])) at++; + while (at < end && tdx_ident((unsigned char)s[at])) + at++; TDXRule rule; if (tdx_match(ctx, s + word, at - word, line, &rule)) tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule.declaration, line); @@ -4399,16 +4518,19 @@ static void tdx_directive(CBMExtractCtx *ctx, size_t begin, size_t end, uint32_t void cbm_test_declarations_finish(CBMExtractCtx *ctx) { if (!ctx->test_declarations || !ctx->test_declarations_raw_source || - ctx->result->test_declarations_status == CBM_TEST_EXTRACT_OOM) return; + ctx->result->test_declarations_status == CBM_TEST_EXTRACT_OOM) + return; int language = tdx_source_language(ctx->language), count = 0; - if (!language) return; + if (!language) + return; const cbm_test_declaration_t *items = cbm_test_declarations_items(ctx->test_declarations, &count); bool active = language == 1 && tdx_preset(ctx, CBM_TEST_PRESET_C_CBM); for (int i = 0; i < count && !active; i++) active = (items[i].role == CBM_TEST_DECL_CASE || items[i].role == CBM_TEST_DECL_SUITE) && tdx_language(items[i].language) == language; - if (!active) return; + if (!active) + return; /* Detect configured invocations dropped by a grammar/error region. Never * invent definitions here; an unmatched raw invocation is uncertainty. * Normalize line splices for AUDIT ONLY, so a split macro cannot disappear. @@ -4420,8 +4542,10 @@ void cbm_test_declarations_finish(CBMExtractCtx *ctx) { size_t n = ctx->source_len > 0 ? (size_t)ctx->source_len : 0, at = 0; uint32_t splice_line = 0, physical_line = 1; for (size_t i = 0; i < n; i++) { - if (!splice_line && tdx_splice(s, n, i)) splice_line = physical_line; - if (s[i] == '\n') physical_line++; + if (!splice_line && tdx_splice(s, n, i)) + splice_line = physical_line; + if (s[i] == '\n') + physical_line++; } if (splice_line) { CBMArena *scratch = ctx->scratch ? ctx->scratch : ctx->arena; @@ -4432,8 +4556,10 @@ void cbm_test_declarations_finish(CBMExtractCtx *ctx) { } size_t used = 0; for (size_t i = 0; i < n;) { - if (tdx_splice(s, n, i)) i += s[i + 1] == '\r' ? 3 : 2; - else joined[used++] = s[i++]; + if (tdx_splice(s, n, i)) + i += s[i + 1] == '\r' ? 3 : 2; + else + joined[used++] = s[i++]; } joined[used] = '\0'; s = joined; @@ -4446,10 +4572,21 @@ void cbm_test_declarations_finish(CBMExtractCtx *ctx) { bool line_start = true; while (at < n) { size_t before = at; - if (!tdx_trivia(s, n, &at)) { uncertain_line = line; break; } - for (size_t i = before; i < at; i++) if (s[i] == '\n') { line++; line_start = true; } - if (at == n) break; - if (s[at] == '\0') { uncertain_line = line; break; } + if (!tdx_trivia(s, n, &at)) { + uncertain_line = line; + break; + } + for (size_t i = before; i < at; i++) + if (s[i] == '\n') { + line++; + line_start = true; + } + if (at == n) + break; + if (s[at] == '\0') { + uncertain_line = line; + break; + } if (line_start && s[at] == '#') { size_t directive_begin = at; uint32_t directive_line = splice_line ? splice_line : line; @@ -4462,26 +4599,44 @@ void cbm_test_declarations_finish(CBMExtractCtx *ctx) { continue; } line_start = false; - if (tdx_pp_number(s, n, &at)) continue; + if (tdx_pp_number(s, n, &at)) + continue; if (s[at] == '"' || s[at] == '\'') { before = at; - if (!tdx_audit_literal(s, n, &at)) { uncertain_line = line; break; } - for (size_t i = before; i < at; i++) if (s[i] == '\n') line++; + if (!tdx_audit_literal(s, n, &at)) { + uncertain_line = line; + break; + } + for (size_t i = before; i < at; i++) + if (s[i] == '\n') + line++; + continue; + } + if (!tdx_ident_first((unsigned char)s[at])) { + at++; continue; } - if (!tdx_ident_first((unsigned char)s[at])) { at++; continue; } size_t begin = at++; - while (at < n && tdx_ident((unsigned char)s[at])) at++; + while (at < n && tdx_ident((unsigned char)s[at])) + at++; size_t after = at; - if (!tdx_trivia(s, n, &after)) { uncertain_line = line; break; } - if (after == n || s[after] != '(') continue; + if (!tdx_trivia(s, n, &after)) { + uncertain_line = line; + break; + } + if (after == n || s[after] != '(') + continue; TDXRule rule; uint32_t issue_line = splice_line ? splice_line : line; - if (!tdx_match(ctx, s + begin, at - begin, issue_line, &rule)) continue; + if (!tdx_match(ctx, s + begin, at - begin, issue_line, &rule)) + continue; bool found = false; if (!splice_line) { for (int i = 0; i < ctx->test_definition_match_count; i++) - if (ctx->test_definition_matches[i].name_byte == begin) { found = true; break; } + if (ctx->test_definition_matches[i].name_byte == begin) { + found = true; + break; + } } if (!found) tdx_issue(ctx->result, CBM_TEST_EXTRACT_UNSUPPORTED_FORM, rule.declaration, issue_line); @@ -4492,11 +4647,13 @@ void cbm_test_declarations_finish(CBMExtractCtx *ctx) { /* A configured QN collision is explicit rather than a later upsert loss. */ for (int i = 0; i < ctx->test_definition_match_count; i++) { const struct CBMTestDefinitionMatch *match = &ctx->test_definition_matches[i]; - if (!match->qn) continue; /* failed invocation; diagnostic already retained */ + if (!match->qn) + continue; /* failed invocation; diagnostic already retained */ int found = 0; for (int j = 0; j < ctx->result->defs.count; j++) { const char *qn = ctx->result->defs.items[j].qualified_name; - if (qn && strcmp(qn, match->qn) == 0) found++; + if (qn && strcmp(qn, match->qn) == 0) + found++; } if (found != 1) tdx_issue(ctx->result, CBM_TEST_EXTRACT_AMBIGUOUS, match->declaration, match->line); @@ -4506,8 +4663,7 @@ void cbm_test_declarations_finish(CBMExtractCtx *ctx) { ctx->result->has_test_definition_owners = true; ctx->result->test_owner_source_len = ctx->source_len; ctx->result->test_owner_language = ctx->language; - cbm_sha256_hex(ctx->source, (size_t)ctx->source_len, - ctx->result->test_owner_source_sha256); + cbm_sha256_hex(ctx->source, (size_t)ctx->source_len, ctx->result->test_owner_source_sha256); } } @@ -4554,7 +4710,8 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec TDXRule configured_rule; bool configured = tdx_resolve(ctx, node, name_node, name, &configured_name, &configured_rule); if (configured) { - if (!configured_name) return; + if (!configured_name) + return; name = (char *)configured_name; } if (!configured && tdx_preset(ctx, CBM_TEST_PRESET_GTEST) && diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index 138ef0f77..c5cbe0727 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -44,15 +44,15 @@ src/foundation/platform.c 11 src/foundation/platform.h 5 src/foundation/private_file_lock.c 56 src/foundation/str_intern.c 7 -src/foundation/subprocess.c 24 -src/foundation/win_utf8.h 17 +src/foundation/subprocess.c 17 +src/foundation/win_utf8.h 7 src/foundation/workspace.c 3 src/foundation/yaml.c 10 src/git/git_context.c 18 src/main.c 32 src/mcp/compact_out.c 39 src/mcp/index_supervisor.c 8 -src/mcp/mcp.c 784 +src/mcp/mcp.c 779 src/pipeline/artifact.c 38 src/pipeline/fqn.c 23 src/pipeline/lsp_resolve.h 4 @@ -62,7 +62,7 @@ src/pipeline/pass_compile_commands.c 17 src/pipeline/pass_complexity.c 15 src/pipeline/pass_configlink.c 3 src/pipeline/pass_cross_repo.c 8 -src/pipeline/pass_definitions.c 14 +src/pipeline/pass_definitions.c 11 src/pipeline/pass_enrichment.c 15 src/pipeline/pass_ensemble_routing.c 10 src/pipeline/pass_envscan.c 2 @@ -70,7 +70,7 @@ src/pipeline/pass_githistory.c 27 src/pipeline/pass_importance.c 4 src/pipeline/pass_infrascan.c 2 src/pipeline/pass_k8s.c 6 -src/pipeline/pass_lsp_cross.c 50 +src/pipeline/pass_lsp_cross.c 49 src/pipeline/pass_parallel.c 31 src/pipeline/pass_pkgmap.c 82 src/pipeline/pass_semantic.c 3 @@ -79,7 +79,7 @@ src/pipeline/pass_usages.c 4 src/pipeline/path_alias.c 42 src/pipeline/pipeline.c 134 src/pipeline/pipeline_delta.c 19 -src/pipeline/pipeline_incremental.c 144 +src/pipeline/pipeline_incremental.c 143 src/pipeline/registry.c 17 src/pipeline/worker_pool.c 2 src/store/store.c 358 diff --git a/src/discover/discover.c b/src/discover/discover.c index 5f4febb0a..003084edf 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -108,20 +108,6 @@ static const char *FAST_PATTERNS[] = {".d.ts", ".bundle.", ".chunk.", ".gen "_string.go", "mock_", "_mock.", "_test_helpers.", ".stories.", ".spec.", ".test.", NULL}; -/* ── Ignored JSON filenames ──────────────────────── */ - -static const char *IGNORED_JSON_FILES[] = { - "package.json", "package-lock.json", "tsconfig.json", - "jsconfig.json", "composer.json", "composer.lock", - "yarn.lock", "openapi.json", "swagger.json", - "jest.config.json", ".eslintrc.json", ".prettierrc.json", - ".babelrc.json", "tslint.json", "angular.json", - "firebase.json", "renovate.json", "lerna.json", - "turbo.json", ".stylelintrc.json", "pnpm-lock.json", - "deno.json", "biome.json", "devcontainer.json", - ".devcontainer.json", "launch.json", "settings.json", - "extensions.json", "tasks.json", NULL}; - /* ── Helper: check if string is in NULL-terminated array ─────────── */ static bool str_in_list(const char *s, const char *const *list) { @@ -826,60 +812,11 @@ static const char *file_skip_reason(const char *entry_name, const char *rel_path return global_ignored ? "gitignore" : NULL; } -/* Detect language for a file, handling .m disambiguation and JSON filtering. */ +/* Detect language for a file: its name, and its first bytes where the name is + * not enough (cbm_language_classify; the pinned test-impact inventory uses the + * same rule on git blobs). */ static CBMLanguage detect_file_language(const char *entry_name, const char *abs_path) { - CBMLanguage lang = cbm_language_for_filename(entry_name); - if (lang == CBM_LANG_COUNT) { - /* Filename/extension detection failed: fall back to a conservative - * shebang probe so extensionless scripts get indexed (#1199). Filename - * detection stays authoritative — this runs only when it returns - * unknown. */ - return cbm_language_from_shebang(abs_path); - } - /* Special: .m files need content-based disambiguation */ - const char *dot = strrchr(entry_name, '.'); - if (dot && strcmp(dot, ".m") == 0) { - lang = cbm_disambiguate_m(abs_path); - } - /* Special: .cls is shared by ObjectScript UDL, Apex and VB6 class modules */ - if (dot && strcmp(dot, ".cls") == 0) { - lang = cbm_disambiguate_cls(abs_path); - } - /* Special: .inc is shared by BitBake and ObjectScript include files */ - if (dot && strcmp(dot, ".inc") == 0) { - lang = cbm_disambiguate_inc(abs_path); - } - /* Special: .cfc components may be script-dialect or tag-dialect () */ - if (dot && strcmp(dot, ".cfc") == 0) { - lang = cbm_disambiguate_cfc(abs_path); - } - /* Special: .frm is shared by FORM and VB6 forms (#721) */ - if (dot && strcmp(dot, ".frm") == 0) { - lang = cbm_disambiguate_frm(abs_path); - } - /* Special: .res is also a binary Godot / Windows resource (#2176) */ - if (dot && strcmp(dot, ".res") == 0) { - lang = cbm_disambiguate_res(abs_path); - } - /* Special: ObjectScript Studio Export XML () is - * detected by content; otherwise .xml stays XML. */ - if (lang == CBM_LANG_XML) { - FILE *xf = cbm_fopen(abs_path, "r"); - if (xf) { - char xbuf[CBM_SZ_256]; - size_t xn = fread(xbuf, SKIP_ONE, sizeof(xbuf) - SKIP_ONE, xf); - (void)fclose(xf); - xbuf[xn] = '\0'; - if (strstr(xbuf, " 0) { + memcpy(dst, head, len); + } + dst[len] = '\0'; +} + +/* The first `cap` bytes of a file, raw. false when the file cannot be opened. + * A read error keeps the bytes read before it, as the probes always did. */ +static bool lang_read_head(const char *path, unsigned char *buf, size_t cap, size_t *n) { + *n = 0; + FILE *f = path ? cbm_fopen(path, "rb") : NULL; if (!f) { - return CBM_LANG_COUNT; /* fail closed on read error */ + return false; } + *n = fread(buf, SKIP_ONE, cap, f); + (void)fclose(f); + return true; +} - /* Read only a bounded first line. */ - char buf[CBM_SZ_256]; - size_t n = fread(buf, SKIP_ONE, sizeof(buf) - SKIP_ONE, f); - - /* Fail closed on any read error rather than parsing a partial buffer. */ - if (ferror(f)) { - (void)fclose(f); +/* `more`: the file has bytes beyond head[0..n). A first line that fills the + * probe and goes on is cut, and a cut line names no interpreter. */ +static CBMLanguage lang_shebang(const unsigned char *head, size_t n, bool more) { + if (n > LANG_PROBE_LINE) { + n = LANG_PROBE_LINE; + more = true; + } + if (n < PAIR_LEN) { + return CBM_LANG_COUNT; /* too short to begin with "#!" */ + } + char buf[LANG_PROBE_LINE + SKIP_ONE]; + memcpy(buf, head, n); + bool have_newline = (memchr(buf, '\n', n) != NULL); + if (!have_newline && n == LANG_PROBE_LINE && more) { return CBM_LANG_COUNT; } - /* If the bounded buffer filled without containing a newline, the first - * line may extend past our bound. Probe a single extra byte to tell an - * exact EOF (the whole file is <= 255 bytes) from a truncated longer - * line: any surviving byte -- including a newline just beyond the bound -- - * means the first line was cut off, so fail closed. A probe read error - * fails closed too. This keeps the read bounded (no unbounded line read - * or allocation). */ - bool have_newline = (memchr(buf, '\n', n) != NULL); - if (!have_newline && n == sizeof(buf) - SKIP_ONE) { - int probe = fgetc(f); - if (probe != EOF || ferror(f)) { - (void)fclose(f); - return CBM_LANG_COUNT; - } - } - (void)fclose(f); - /* Must begin with "#!". */ - if (n < PAIR_LEN || buf[0] != '#' || buf[1] != '!') { + if (buf[0] != '#' || buf[1] != '!') { return CBM_LANG_COUNT; } @@ -1174,6 +1196,29 @@ CBMLanguage cbm_language_from_shebang(const char *path) { return lang_for_interpreter(base); } +CBMLanguage cbm_language_from_shebang(const char *path) { + if (!path) { + return CBM_LANG_COUNT; + } + + FILE *f = cbm_fopen(path, "rb"); + if (!f) { + return CBM_LANG_COUNT; /* fail closed on read error */ + } + + /* Read only a bounded first line, plus one byte to tell an exact end of + * file from a longer first line. A read error fails closed rather than + * parsing a partial buffer. */ + unsigned char buf[LANG_PROBE_LINE + SKIP_ONE]; + size_t n = fread(buf, SKIP_ONE, sizeof(buf), f); + bool failed = ferror(f) != 0; + (void)fclose(f); + if (failed) { + return CBM_LANG_COUNT; + } + return lang_shebang(buf, n > LANG_PROBE_LINE ? LANG_PROBE_LINE : n, n > LANG_PROBE_LINE); +} + /* ── .m file disambiguation ──────────────────────────────────────── */ /* Simple substring search helper */ @@ -1238,22 +1283,7 @@ static bool has_matlab_line_markers(const char *buf) { return false; } -CBMLanguage cbm_disambiguate_m(const char *path) { - if (!path) { - return CBM_LANG_MATLAB; - } - - FILE *f = cbm_fopen(path, "r"); - if (!f) { - return CBM_LANG_MATLAB; - } - - /* Read first 4KB */ - char buf[CBM_SZ_4K + SKIP_ONE]; - size_t n = fread(buf, SKIP_ONE, CBM_SZ_4K, f); - buf[n] = '\0'; - (void)fclose(f); - +static CBMLanguage lang_m_text(const char *buf) { if (has_objc_markers(buf)) { return CBM_LANG_OBJC; } @@ -1271,6 +1301,17 @@ CBMLanguage cbm_disambiguate_m(const char *path) { return CBM_LANG_MATLAB; } +CBMLanguage cbm_disambiguate_m(const char *path) { + unsigned char head[LANG_PROBE_HEAD]; + size_t n = 0; + if (!lang_read_head(path, head, sizeof(head), &n)) { + return CBM_LANG_MATLAB; + } + char buf[LANG_PROBE_HEAD + SKIP_ONE]; + lang_head_text(buf, LANG_PROBE_HEAD, head, n); + return lang_m_text(buf); +} + /* Visual Basic 6 / VBA source exports are recognisable from their header (#721): * every module carries `Attribute VB_Name = "..."`, class modules open with * `VERSION 1.0 CLASS`, forms/controls with `VERSION 5.00` + `Begin VB.Form` / @@ -1286,21 +1327,7 @@ static bool has_vb6_markers(const char *buf) { * VB6 form is reported as unsupported (CBM_LANG_COUNT) rather than handed to * the FORM grammar, which yields no defs and stray junk nodes. Defaults to * FORM on any doubt (preserves existing behaviour). */ -CBMLanguage cbm_disambiguate_frm(const char *path) { - if (!path) { - return CBM_LANG_FORM; - } - - FILE *f = cbm_fopen(path, "r"); - if (!f) { - return CBM_LANG_FORM; - } - - char buf[CBM_SZ_4K + SKIP_ONE]; - size_t n = fread(buf, SKIP_ONE, CBM_SZ_4K, f); - buf[n] = '\0'; - (void)fclose(f); - +static CBMLanguage lang_frm_text(const char *buf) { /* VB6 form files open with "VERSION x.yy" on line 1. */ if (strncmp(buf, "VERSION ", SLEN("VERSION ")) == 0 && isdigit((unsigned char)buf[SLEN("VERSION ")])) { @@ -1309,25 +1336,34 @@ CBMLanguage cbm_disambiguate_frm(const char *path) { return has_vb6_markers(buf) ? CBM_LANG_COUNT : CBM_LANG_FORM; } +CBMLanguage cbm_disambiguate_frm(const char *path) { + unsigned char head[LANG_PROBE_HEAD]; + size_t n = 0; + if (!lang_read_head(path, head, sizeof(head), &n)) { + return CBM_LANG_FORM; + } + char buf[LANG_PROBE_HEAD + SKIP_ONE]; + lang_head_text(buf, LANG_PROBE_HEAD, head, n); + return lang_frm_text(buf); +} + /* Disambiguate .res files (#2176): ReScript source shares the extension with * two binary formats -- Godot resources (RSRC/RSCC magic, the default save * format for imported meshes) and Windows compiled resource files. Binary * content has no ReScript meaning, and a NUL byte never occurs in ReScript * text while both binary formats carry NULs in their first bytes. */ +static CBMLanguage lang_res_bytes(const unsigned char *head, size_t n) { + size_t len = n < LANG_PROBE_HEAD ? n : LANG_PROBE_HEAD; + return (len > 0 && memchr(head, '\0', len)) ? CBM_LANG_COUNT : CBM_LANG_RESCRIPT; +} + CBMLanguage cbm_disambiguate_res(const char *path) { - if (!path) { + unsigned char head[LANG_PROBE_HEAD]; + size_t n = 0; + if (!lang_read_head(path, head, sizeof(head), &n)) { return CBM_LANG_RESCRIPT; } - - FILE *f = cbm_fopen(path, "rb"); - if (!f) { - return CBM_LANG_RESCRIPT; - } - - char buf[CBM_SZ_4K]; - size_t n = fread(buf, SKIP_ONE, sizeof(buf), f); - (void)fclose(f); - return memchr(buf, '\0', n) ? CBM_LANG_COUNT : CBM_LANG_RESCRIPT; + return lang_res_bytes(head, n); } /* Disambiguate .cls files: shared by InterSystems ObjectScript UDL, Salesforce @@ -1335,21 +1371,7 @@ CBMLanguage cbm_disambiguate_res(const char *path) { * with a line of the form "Class ..."; VB6 class modules * carry the VB6 header markers and are reported as unsupported (CBM_LANG_COUNT) * until a Visual Basic grammar exists. Defaults to Apex on any doubt. */ -CBMLanguage cbm_disambiguate_cls(const char *path) { - if (!path) { - return CBM_LANG_APEX; - } - - FILE *f = cbm_fopen(path, "r"); - if (!f) { - return CBM_LANG_APEX; - } - - char buf[CBM_SZ_4K + SKIP_ONE]; - size_t n = fread(buf, SKIP_ONE, CBM_SZ_4K, f); - buf[n] = '\0'; - (void)fclose(f); - +static CBMLanguage lang_cls_text(const char *buf) { if (has_vb6_markers(buf)) { return CBM_LANG_COUNT; } @@ -1369,6 +1391,17 @@ CBMLanguage cbm_disambiguate_cls(const char *path) { return CBM_LANG_APEX; } +CBMLanguage cbm_disambiguate_cls(const char *path) { + unsigned char head[LANG_PROBE_HEAD]; + size_t n = 0; + if (!lang_read_head(path, head, sizeof(head), &n)) { + return CBM_LANG_APEX; + } + char buf[LANG_PROBE_HEAD + SKIP_ONE]; + lang_head_text(buf, LANG_PROBE_HEAD, head, n); + return lang_cls_text(buf); +} + /* Disambiguate .inc files: shared by BitBake include fragments and * InterSystems ObjectScript include (macro) files. ObjectScript .inc files are * predominantly macro definitions ("#define NAME ..." / "#def1arg NAME ..."); @@ -1378,21 +1411,7 @@ CBMLanguage cbm_disambiguate_cls(const char *path) { * We therefore match ObjectScript preprocessor directives ('#' immediately * followed by 'def'/';'), which BitBake never produces. Defaults to BitBake on * any doubt (preserves existing behaviour). */ -CBMLanguage cbm_disambiguate_inc(const char *path) { - if (!path) { - return CBM_LANG_BITBAKE; - } - - FILE *f = cbm_fopen(path, "r"); - if (!f) { - return CBM_LANG_BITBAKE; - } - - char buf[CBM_SZ_4K + SKIP_ONE]; - size_t n = fread(buf, SKIP_ONE, CBM_SZ_4K, f); - buf[n] = '\0'; - (void)fclose(f); - +static CBMLanguage lang_inc_text(const char *buf) { const char *line = buf; while (*line) { /* ObjectScript include header: a line beginning "ROUTINE ". */ @@ -1417,6 +1436,17 @@ CBMLanguage cbm_disambiguate_inc(const char *path) { return CBM_LANG_BITBAKE; } +CBMLanguage cbm_disambiguate_inc(const char *path) { + unsigned char head[LANG_PROBE_HEAD]; + size_t n = 0; + if (!lang_read_head(path, head, sizeof(head), &n)) { + return CBM_LANG_BITBAKE; + } + char buf[LANG_PROBE_HEAD + SKIP_ONE]; + lang_head_text(buf, LANG_PROBE_HEAD, head, n); + return lang_inc_text(buf); +} + /* Case-insensitive prefix match (portable — no strncasecmp dependency). */ static bool starts_with_ci(const char *s, const char *prefix) { for (; *prefix; s++, prefix++) { @@ -1442,23 +1472,7 @@ static bool starts_with_ci(const char *s, const char *prefix) { * - a different leading tag (e.g. in a bare-tag file) ⇒ cfml; * - anything else ("component { ... }") ⇒ cfscript. * Defaults to CBM_LANG_CFSCRIPT on any doubt (preserves table behaviour). */ -CBMLanguage cbm_disambiguate_cfc(const char *path) { - if (!path) { - return CBM_LANG_CFSCRIPT; - } - - FILE *f = cbm_fopen(path, "r"); - if (!f) { - return CBM_LANG_CFSCRIPT; - } - - /* Read a generous head: tag components can carry a large license/revision - * comment block before the opener. */ - char buf[CBM_SZ_16K + SKIP_ONE]; - size_t n = fread(buf, SKIP_ONE, CBM_SZ_16K, f); - buf[n] = '\0'; - (void)fclose(f); - +static CBMLanguage lang_cfc_text(const char *buf) { /* Rule 1: explicit tag-component markers ⇒ tag dialect. */ if (cbm_strcasestr(buf, " probe; + return cbm_language_classify(filename, head, more ? probe : n, more, !failed); +} diff --git a/src/discover/userconfig.c b/src/discover/userconfig.c index c2b45b788..8f906ef07 100644 --- a/src/discover/userconfig.c +++ b/src/discover/userconfig.c @@ -333,8 +333,8 @@ static int load_config_file(const char *path, cbm_userext_t **entries, int *coun } /* Retain the project input before parsing it; never reopen for declarations. */ -static int load_project_source(const char *path, cbm_userconfig_t *cfg, - cbm_userext_t **entries, int *count) { +static int load_project_source(const char *path, cbm_userconfig_t *cfg, cbm_userext_t **entries, + int *count) { cbm_userconfig_source_t *source = cfg->project_source; source->state = CBM_USERCONFIG_SOURCE_ERROR; userconfig_source_digest("read-error", NULL, 0, cfg->project_source_sha256); @@ -383,23 +383,25 @@ static int load_project_source(const char *path, cbm_userconfig_t *cfg, /* ── Public API ──────────────────────────────────────────────────── */ static cbm_userconfig_t *userconfig_load(const char *repo_path, bool retain_source) { - cbm_userconfig_t *cfg = calloc(CBM_ALLOC_ONE, sizeof(cbm_userconfig_t)); - if (!cfg) { - return NULL; - } - + /* The retained source first: a failure here has nothing else to undo. */ + cbm_userconfig_source_t *source = NULL; if (retain_source) { CBMArena arena; cbm_arena_init(&arena); - cfg->project_source = cbm_arena_calloc(&arena, sizeof(*cfg->project_source)); - if (!cfg->project_source) { + source = cbm_arena_calloc(&arena, sizeof(*source)); + if (!source) { cbm_arena_destroy(&arena); - free(cfg); return NULL; } - cfg->project_source->arena = arena; - cfg->project_source->state = CBM_USERCONFIG_SOURCE_ABSENT; + source->arena = arena; + source->state = CBM_USERCONFIG_SOURCE_ABSENT; } + cbm_userconfig_t *cfg = calloc(CBM_ALLOC_ONE, sizeof(cbm_userconfig_t)); + if (!cfg) { + userconfig_source_free(source); + return NULL; + } + cfg->project_source = source; cbm_userext_t *entries = NULL; int count = 0; @@ -427,8 +429,8 @@ static cbm_userconfig_t *userconfig_load(const char *repo_path, bool retain_sour userconfig_source_digest("not-applicable", NULL, 0, cfg->project_source_sha256); if (repo_path && repo_path[0]) { char project_path[PATH_BUF_SZ]; - int path_len = snprintf(project_path, sizeof(project_path), "%s/.codebase-memory.json", - repo_path); + int path_len = + snprintf(project_path, sizeof(project_path), "%s/.codebase-memory.json", repo_path); int rc; if (retain_source) { if (path_len < 0 || (size_t)path_len >= sizeof(project_path)) { @@ -499,7 +501,7 @@ cbm_userconfig_t *cbm_userconfig_load_with_source(const char *repo_path) { } cbm_userconfig_source_state_t cbm_userconfig_project_source(const cbm_userconfig_t *cfg, - const char **bytes, size_t *len) { + const char **bytes, size_t *len) { if (bytes) *bytes = NULL; if (len) @@ -658,9 +660,8 @@ static cbm_userconfig_snapshot_status_t snapshot_input(cbm_userconfig_source_sta return len > MAX_CONFIG_SIZE ? CBM_USERCONFIG_SNAPSHOT_LIMIT : CBM_USERCONFIG_SNAPSHOT_OK; } -cbm_userconfig_snapshot_status_t -cbm_userconfig_from_project_bytes(cbm_userconfig_source_state_t state, const void *bytes, - size_t len, cbm_userconfig_t **out) { +cbm_userconfig_snapshot_status_t cbm_userconfig_from_project_bytes( + cbm_userconfig_source_state_t state, const void *bytes, size_t len, cbm_userconfig_t **out) { if (!out) return CBM_USERCONFIG_SNAPSHOT_INVALID; *out = NULL; diff --git a/src/discover/userconfig.h b/src/discover/userconfig.h index cd946e4aa..7f6a15a29 100644 --- a/src/discover/userconfig.h +++ b/src/discover/userconfig.h @@ -35,8 +35,8 @@ typedef enum { } cbm_userconfig_source_state_t; typedef struct { - cbm_userext_t *entries; /* heap-allocated array */ - int count; /* number of entries */ + cbm_userext_t *entries; /* heap-allocated array */ + int count; /* number of entries */ cbm_userconfig_source_t *project_source; /* optional owned project snapshot */ /* Digests of the exact bytes/state consumed by cbm_userconfig_load(). */ char global_source_sha256[CBM_SHA256_HEX_LEN + 1]; @@ -64,7 +64,7 @@ cbm_userconfig_t *cbm_userconfig_load_with_source(const char *repo_path); /* The PRESENT byte view belongs to cfg and remains valid until free. Outputs * are cleared for ABSENT/ERROR. A legacy loader result has no snapshot: ERROR. */ cbm_userconfig_source_state_t cbm_userconfig_project_source(const cbm_userconfig_t *cfg, - const char **bytes, size_t *len); + const char **bytes, size_t *len); /* * Look up a file extension in the user config. @@ -89,9 +89,8 @@ typedef enum { CBM_USERCONFIG_SNAPSHOT_OOM } cbm_userconfig_snapshot_status_t; -cbm_userconfig_snapshot_status_t -cbm_userconfig_from_project_bytes(cbm_userconfig_source_state_t state, const void *bytes, - size_t len, cbm_userconfig_t **out); +cbm_userconfig_snapshot_status_t cbm_userconfig_from_project_bytes( + cbm_userconfig_source_state_t state, const void *bytes, size_t len, cbm_userconfig_t **out); /* ── Integration hook ───────────────────────────────────────────── */ diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c index 1d2f5ee89..16e8d78d6 100644 --- a/src/foundation/subprocess.c +++ b/src/foundation/subprocess.c @@ -679,7 +679,8 @@ static cbm_proc_poll_t cbm_subprocess_finish_failed(cbm_subprocess_t *process, /* Match cbm_path_to_wide's file-path spelling using the process arena. Existing * option conversions retain their legacy heap ownership. */ static wchar_t *cbm_win_stdin_path(cbm_subprocess_t *process) { - int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, process->stdin_file, -1, NULL, 0); + int length = + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, process->stdin_file, -1, NULL, 0); if (length <= 0 || length > 32767) { return NULL; } @@ -764,12 +765,19 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { if (!built) { return -1; } + /* Everything a failure must release, so every failure leaves through + * the one exit at the end. */ + HANDLE job = NULL; + HANDLE nul = INVALID_HANDLE_VALUE; + HANDLE input = INVALID_HANDLE_VALUE; + HANDLE log = INVALID_HANDLE_VALUE; + HANDLE stdout_capture = INVALID_HANDLE_VALUE; + LPPROC_THREAD_ATTRIBUTE_LIST attrs = NULL; + bool attrs_init = false; wchar_t *wbin = cbm_utf8_to_wide(process->bin); wchar_t *wcmdline = cbm_utf8_to_wide(cmdline); if (!wbin || !wcmdline) { - free(wbin); - free(wcmdline); - return -1; + goto fail; } JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits; @@ -779,31 +787,20 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { limits.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_JOB_MEMORY; limits.JobMemoryLimit = (SIZE_T)process->memory_limit_bytes; } - HANDLE job = CreateJobObjectW(NULL, NULL); + job = CreateJobObjectW(NULL, NULL); if (!job || !SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) { - if (job) { - CloseHandle(job); - } - free(wbin); - free(wcmdline); - return -1; + goto fail; } SECURITY_ATTRIBUTES security; ZeroMemory(&security, sizeof(security)); security.nLength = sizeof(security); security.bInheritHandle = TRUE; - HANDLE nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, &security, OPEN_EXISTING, 0, NULL); - HANDLE input = INVALID_HANDLE_VALUE; - HANDLE log = INVALID_HANDLE_VALUE; - HANDLE stdout_capture = INVALID_HANDLE_VALUE; + nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, OPEN_EXISTING, 0, NULL); if (nul == INVALID_HANDLE_VALUE) { - CloseHandle(job); - free(wbin); - free(wcmdline); - return -1; + goto fail; } if (process->stdin_file) { wchar_t *winput = cbm_win_stdin_path(process); @@ -811,8 +808,8 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { * handle rather than relying only on path attributes. */ DWORD attributes = winput ? GetFileAttributesW(winput) : INVALID_FILE_ATTRIBUTES; if (attributes != INVALID_FILE_ATTRIBUTES && - !(attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT | - FILE_ATTRIBUTE_DEVICE))) { + !(attributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DEVICE))) { input = CreateFileW(winput, GENERIC_READ, FILE_SHARE_READ, &security, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL); } @@ -821,11 +818,7 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { !GetFileInformationByHandle(input, &input_info) || (input_info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DEVICE))) { - cbm_win_close_spawn_handles(nul, input, log, stdout_capture, NULL, false); - CloseHandle(job); - free(wbin); - free(wcmdline); - return -1; + goto fail; } } if (process->log_file) { @@ -841,11 +834,7 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { free(wlog); } if (log == INVALID_HANDLE_VALUE) { - cbm_win_close_spawn_handles(nul, input, log, stdout_capture, NULL, false); - CloseHandle(job); - free(wbin); - free(wcmdline); - return -1; + goto fail; } } @@ -857,11 +846,7 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { free(wstdout); } if (stdout_capture == INVALID_HANDLE_VALUE) { - cbm_win_close_spawn_handles(nul, input, log, stdout_capture, NULL, false); - CloseHandle(job); - free(wbin); - free(wcmdline); - return -1; + goto fail; } } @@ -879,17 +864,13 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { } SIZE_T attrs_size = 0; (void)InitializeProcThreadAttributeList(NULL, 1, 0, &attrs_size); - LPPROC_THREAD_ATTRIBUTE_LIST attrs = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attrs_size); - bool attrs_init = attrs && InitializeProcThreadAttributeList(attrs, 1, 0, &attrs_size); + attrs = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attrs_size); + attrs_init = attrs && InitializeProcThreadAttributeList(attrs, 1, 0, &attrs_size); bool attrs_ready = attrs_init && UpdateProcThreadAttribute( attrs, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherit, inherit_count * sizeof(inherit[0]), NULL, NULL); if (!attrs_ready) { - cbm_win_close_spawn_handles(nul, input, log, stdout_capture, attrs, attrs_init); - CloseHandle(job); - free(wbin); - free(wcmdline); - return -1; + goto fail; } STARTUPINFOEXW startup; @@ -948,6 +929,15 @@ static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { process->process_id = child.dwProcessId; process->job = job; return 0; + +fail: + cbm_win_close_spawn_handles(nul, input, log, stdout_capture, attrs, attrs_init); + if (job) { + CloseHandle(job); + } + free(wbin); + free(wcmdline); + return -1; } static bool cbm_win_job_active(cbm_subprocess_t *process, bool *known) { diff --git a/src/foundation/win_utf8.h b/src/foundation/win_utf8.h index 2e187c028..756c1e558 100644 --- a/src/foundation/win_utf8.h +++ b/src/foundation/win_utf8.h @@ -10,7 +10,41 @@ #include #include -static inline wchar_t *cbm_utf8_to_wide(const char *utf8) { +#include "mem_core.h" + +/* Converter heaps. The `*_in(cls, ...)` forms allocate through the memory + * core and their result is released with cbm_free(cls, p). The legacy names + * allocate with the C library for the callers that still release with free(); + * that route is the residual the memory-core ratchet drives down, so new + * callers use the `_in` forms. */ +typedef struct { + int core; + cbm_mem_class_t cls; +} cbm_wide_heap_t; + +static inline cbm_wide_heap_t cbm_wide_heap_core(cbm_mem_class_t cls) { + cbm_wide_heap_t heap = {1, cls}; + return heap; +} + +static inline cbm_wide_heap_t cbm_wide_heap_libc(void) { + cbm_wide_heap_t heap = {0, CBM_MEM_CLASS_OTHER}; + return heap; +} + +static inline void *cbm_wide_heap_alloc(cbm_wide_heap_t heap, size_t bytes) { + return heap.core ? cbm_alloc(heap.cls, bytes) : malloc(bytes); +} + +static inline void cbm_wide_heap_free(cbm_wide_heap_t heap, void *block) { + if (heap.core) { + cbm_free(heap.cls, block); + } else { + free(block); + } +} + +static inline wchar_t *cbm_utf8_to_wide_heap(cbm_wide_heap_t heap, const char *utf8) { if (!utf8) { return NULL; } @@ -18,14 +52,22 @@ static inline wchar_t *cbm_utf8_to_wide(const char *utf8) { if (len <= 0) { return NULL; } - wchar_t *w = (wchar_t *)malloc((size_t)len * sizeof(wchar_t)); + wchar_t *w = (wchar_t *)cbm_wide_heap_alloc(heap, (size_t)len * sizeof(wchar_t)); if (!w || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8, -1, w, len) != len) { - free(w); + cbm_wide_heap_free(heap, w); return NULL; } return w; } +static inline wchar_t *cbm_utf8_to_wide(const char *utf8) { + return cbm_utf8_to_wide_heap(cbm_wide_heap_libc(), utf8); +} + +static inline wchar_t *cbm_utf8_to_wide_in(cbm_mem_class_t cls, const char *utf8) { + return cbm_utf8_to_wide_heap(cbm_wide_heap_core(cls), utf8); +} + static inline int cbm_win_path_separator(wchar_t value) { return value == L'\\' || value == L'/'; } @@ -54,7 +96,8 @@ static inline void cbm_win_path_normalize_wide_separators(wchar_t *path) { * This makes behavior independent of the process manifest and LongPathsEnabled * policy. Never use this for non-file strings (command lines, pipe names, * registry paths). */ -static inline wchar_t *cbm_wide_path_to_extended(const wchar_t *wide_path) { +static inline wchar_t *cbm_wide_path_to_extended_heap(cbm_wide_heap_t heap, + const wchar_t *wide_path) { if (!wide_path) { return NULL; } @@ -62,7 +105,7 @@ static inline wchar_t *cbm_wide_path_to_extended(const wchar_t *wide_path) { if (input_length >= 32767U) { return NULL; } - wchar_t *wide = (wchar_t *)malloc((input_length + 1U) * sizeof(*wide)); + wchar_t *wide = (wchar_t *)cbm_wide_heap_alloc(heap, (input_length + 1U) * sizeof(*wide)); if (!wide) { return NULL; } @@ -77,11 +120,12 @@ static inline wchar_t *cbm_wide_path_to_extended(const wchar_t *wide_path) { } DWORD needed = GetFullPathNameW(wide, 0, NULL, NULL); - wchar_t *full = needed > 0U ? (wchar_t *)malloc((size_t)needed * sizeof(wchar_t)) : NULL; + wchar_t *full = + needed > 0U ? (wchar_t *)cbm_wide_heap_alloc(heap, (size_t)needed * sizeof(wchar_t)) : NULL; DWORD copied = full ? GetFullPathNameW(wide, needed, full, NULL) : 0U; - free(wide); + cbm_wide_heap_free(heap, wide); if (!full || copied == 0U || copied >= needed) { - free(full); + cbm_wide_heap_free(heap, full); return NULL; } cbm_win_path_normalize_wide_separators(full); @@ -92,7 +136,7 @@ static inline wchar_t *cbm_wide_path_to_extended(const wchar_t *wide_path) { full[1] == L':' && full[2] == L'\\'; int unc_absolute = copied >= 5U && full[0] == L'\\' && full[1] == L'\\'; if (!drive_absolute && !unc_absolute) { - free(full); + cbm_wide_heap_free(heap, full); return NULL; } @@ -103,27 +147,40 @@ static inline wchar_t *cbm_wide_path_to_extended(const wchar_t *wide_path) { size_t extended_length = prefix_length + tail_length; /* The documented extended-length limit includes the terminating NUL. */ if (extended_length >= 32767U) { - free(full); + cbm_wide_heap_free(heap, full); return NULL; } - wchar_t *prefixed = (wchar_t *)malloc((extended_length + 1U) * sizeof(wchar_t)); + wchar_t *prefixed = + (wchar_t *)cbm_wide_heap_alloc(heap, (extended_length + 1U) * sizeof(wchar_t)); if (!prefixed) { - free(full); + cbm_wide_heap_free(heap, full); return NULL; } wmemcpy(prefixed, prefix, prefix_length); wmemcpy(prefixed + prefix_length, full + tail_offset, tail_length + 1U); - free(full); + cbm_wide_heap_free(heap, full); return prefixed; } -static inline wchar_t *cbm_path_to_wide(const char *utf8_path) { - wchar_t *wide = cbm_utf8_to_wide(utf8_path); - wchar_t *extended = cbm_wide_path_to_extended(wide); - free(wide); +static inline wchar_t *cbm_wide_path_to_extended(const wchar_t *wide_path) { + return cbm_wide_path_to_extended_heap(cbm_wide_heap_libc(), wide_path); +} + +static inline wchar_t *cbm_path_to_wide_heap(cbm_wide_heap_t heap, const char *utf8_path) { + wchar_t *wide = cbm_utf8_to_wide_heap(heap, utf8_path); + wchar_t *extended = cbm_wide_path_to_extended_heap(heap, wide); + cbm_wide_heap_free(heap, wide); return extended; } +static inline wchar_t *cbm_path_to_wide(const char *utf8_path) { + return cbm_path_to_wide_heap(cbm_wide_heap_libc(), utf8_path); +} + +static inline wchar_t *cbm_path_to_wide_in(cbm_mem_class_t cls, const char *utf8_path) { + return cbm_path_to_wide_heap(cbm_wide_heap_core(cls), utf8_path); +} + /* GetModuleFileNameW can preserve the \\?\ spelling used to launch a process. * Internal UTF-8 paths use conventional DOS/UNC spelling and add the prefix * only at the Win32 file-API boundary, so remove it before widening callers diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index fd3521b11..92460bf58 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -54,6 +54,7 @@ enum { #include "mcp/mcp.h" #include "mcp/mcp_internal.h" #include "mcp/test_impact.h" +#include "mcp/test_impact_engine.h" #include "store/store.h" #include "store/store_impact.h" #include @@ -774,7 +775,7 @@ static const tool_def_t TOOLS[] = { {"detect_changes", "Map a Git diff to files and impact. Page with snapshot cursors.", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"scope\":{\"type\":" - "\"string\",\"enum\":[\"files\",\"impact\"],\"default\":\"impact\"}," + "\"string\",\"enum\":[\"files\",\"impact\",\"tests\"],\"default\":\"impact\"}," "\"direction\":{\"type\":\"string\",\"enum\":[\"inbound\",\"outbound\",\"both\"],\"default\":" "\"inbound\",\"description\":\"inbound=callers; outbound=dependencies; both=union.\"}," "\"depth\":{\"type\":\"integer\",\"default\":2}," @@ -16476,6 +16477,68 @@ static bool detect_snapshot_fingerprint(cbm_mcp_server_t *srv, const char *root_ return complete; } +/* scope:"tests": which tests the change needs (mcp/test_impact_engine.h). The + * answer is computed from a graph of exactly the pinned HEAD commit, built + * for this request; the live index is not consulted. Missing evidence is a + * run-all answer that names why; only a failed request is an error. */ +static char *detect_test_impact(const char *args, const char *root_path, const char *base_branch) { + detect_seed_request_t seed_req = {0}; + char err[CBM_SZ_1K] = ""; + detect_seed_parse(args, &seed_req, err, sizeof(err)); + if (err[0]) { + return cbm_mcp_text_result(err, true); + } + char config_file[CBM_SZ_4K] = ""; + if (seed_req.config_path[0]) { + if (repo_path_is_absolute(seed_req.config_path)) { + snprintf(config_file, sizeof(config_file), "%s", seed_req.config_path); + } else { + snprintf(config_file, sizeof(config_file), "%s/%s", root_path, seed_req.config_path); + } + } + char work[CBM_SZ_4K]; + snprintf(work, sizeof(work), "%s/test-impact", cbm_resolve_cache_dir()); + if (!cbm_mkdir_p(work, 0700)) { + return cbm_mcp_text_result("test_impact_failed: no private work directory", true); + } + cbm_test_impact_request_t request = {.repo_root = root_path, + .base_ref = base_branch, + .work_parent = work, + .config_path = config_file[0] ? config_file : NULL, + .deadline_ms = cbm_now_ms() + 30ULL * 60ULL * 1000ULL}; + cbm_test_result_t *result = NULL; + char diagnostic[CBM_SZ_512] = ""; + cbm_test_impact_status_t status = + cbm_test_impact_run(&request, &result, diagnostic, sizeof(diagnostic)); + if (status != CBM_TEST_IMPACT_OK) { + char text[CBM_SZ_1K]; + snprintf(text, sizeof(text), "test_impact_failed: status %d%s%s", (int)status, + diagnostic[0] ? ": " : "", diagnostic); + return cbm_mcp_text_result(text, true); + } + size_t length = 0; + const char *json = cbm_test_result_json(result, &length); + char *res = NULL; + size_t total = length + sizeof("{\"test_impact\":}"); + char *text = json ? cbm_alloc(CBM_MEM_CLASS_OTHER, total) : NULL; + if (text) { + snprintf(text, total, "{\"test_impact\":%s}", json); + res = cbm_mcp_text_result(text, false); + cbm_free(CBM_MEM_CLASS_OTHER, text); + } + cbm_test_result_free(result); + return res ? res : cbm_mcp_text_result("test_impact_failed: out of memory", true); +} + +/* The request strings handle_detect_changes owns, released on every early + * return (NULL is fine). */ +static void detect_release(char *root_path, char *project, char *base_branch, char *scope) { + free(root_path); + free(project); + free(base_branch); + free(scope); +} + static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); char *base_branch = cbm_mcp_get_string_arg(args, "base_branch"); @@ -16530,23 +16593,23 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { if (!validate_search_path_arg(root_path) || !validate_windows_cmd_interpolation_arg(root_path)) { - free(root_path); - free(project); - free(base_branch); - free(scope); + detect_release(root_path, project, base_branch, scope); return cbm_mcp_text_result("project path contains invalid characters", true); } + if (scope && strcmp(scope, "tests") == 0) { + char *res = detect_test_impact(args, root_path, base_branch); + detect_release(root_path, project, base_branch, scope); + return res; + } + /* Every detect snapshot and cursor is generation-bound. Validate the * metadata immediately after store resolution so fresh requests and * cursor replays fail identically before Git work or cursor minting. */ cbm_store_t *store = srv->store; char generation[96] = ""; if (cbm_store_generation(store, generation, sizeof(generation)) != CBM_STORE_OK) { - free(root_path); - free(project); - free(base_branch); - free(scope); + detect_release(root_path, project, base_branch, scope); return cbm_mcp_text_result( "index_metadata_error: generation metadata is unreadable; reindex before detecting " "changes", diff --git a/src/mcp/test_impact.c b/src/mcp/test_impact.c index 6ad66adb4..fe678c5fd 100644 --- a/src/mcp/test_impact.c +++ b/src/mcp/test_impact.c @@ -46,7 +46,8 @@ struct cbm_test_model { bool presets[CBM_TEST_PRESET_COUNT]; cbm_test_model_mapping_issue_t mapping; bool finished; - bool incomplete; /* sticky: a partial scan must never justify omission */ + bool incomplete; /* sticky: a partial scan must never justify omission */ + bool scoped_uncertain; /* some suite is `uncertain`; see tm_add_source */ }; const cbm_test_conventions_t *cbm_test_conventions_cbm(void) { @@ -269,6 +270,7 @@ typedef struct { const char *end; int line; bool line_start; /* only blanks so far on this line: a '#' opens a directive */ + bool directive; /* lexing the text of one directive, continuations included */ bool *incomplete; /* shared by lookahead copies; restoring a cursor cannot erase an error */ } tm_lex_t; @@ -286,6 +288,21 @@ static void tm_lex_uncertain(tm_lex_t *lx) { } } +/* The length of a line splice (backslash, then LF or CRLF) at p, else 0. */ +static size_t tm_splice_len(const char *p, const char *end) { + if (p >= end || *p != '\\') { + return 0; + } + if (p + 1 < end && p[1] == '\n') { + return 2; + } + return p + 2 < end && p[1] == '\r' && p[2] == '\n' ? 3 : 0; +} + +static bool tm_blank(char c) { + return c == ' ' || c == '\t'; +} + /* Raw strings need a delimiter-aware C++ lexer, which this model does not * implement. Retain the old rows for diagnostics, but never certify them. */ static bool tm_raw_literal_starts(const char *p, const char *end) { @@ -300,8 +317,7 @@ static void tm_skip_line_comment(tm_lex_t *lx) { while (lx->p < lx->end && *lx->p != '\n') { /* Translation-phase line splicing can extend this comment. The * legacy lexer does not implement it, so its rows are uncertain. */ - if (*lx->p == '\\' && lx->p + 1 < lx->end && - (lx->p[1] == '\n' || (lx->p[1] == '\r' && lx->p + 2 < lx->end && lx->p[2] == '\n'))) { + if (tm_splice_len(lx->p, lx->end)) { tm_lex_uncertain(lx); } lx->p++; @@ -315,6 +331,9 @@ static void tm_skip_block_comment(tm_lex_t *lx) { lx->p += 2; return; } + if (*lx->p == '*' && tm_splice_len(lx->p + 1, lx->end)) { + tm_lex_uncertain(lx); /* the splice may close the comment */ + } if (*lx->p == '\n') { lx->line++; } @@ -360,9 +379,19 @@ static void tm_read_directive(tm_lex_t *lx, tm_tok_t *tok) { tm_skip_literal(lx); continue; } - if (*lx->p == '\\' && lx->p + 1 < lx->end && lx->p[1] == '\n') { + size_t splice = tm_splice_len(lx->p, lx->end); + if (splice) { + /* Next to a blank a continuation only separates tokens; with + * token characters on both sides it could join them, which the + * re-lexing of this text (tm_directive_word) does not do. The + * '#' precedes the text, so p[-1] is in bounds. */ + const char *after = lx->p + splice; + if (!tm_blank(lx->p[-1]) && after < lx->end && !tm_blank(*after) && *after != '\n' && + *after != '\r') { + tm_lex_uncertain(lx); + } lx->line++; - lx->p += 2; + lx->p = after; continue; } if (*lx->p == '/' && lx->p + 1 < lx->end && lx->p[1] == '*') { @@ -388,6 +417,10 @@ static void tm_next(tm_lex_t *lx, tm_tok_t *tok) { tm_skip_line_comment(lx); } else if (c == '/' && lx->p + 1 < lx->end && lx->p[1] == '*') { tm_skip_block_comment(lx); + } else if (lx->directive && tm_splice_len(lx->p, lx->end)) { + /* A continuation tm_read_directive already followed and judged. */ + lx->line++; + lx->p += tm_splice_len(lx->p, lx->end); } else { break; } @@ -400,6 +433,9 @@ static void tm_next(tm_lex_t *lx, tm_tok_t *tok) { if (tm_raw_literal_starts(lx->p, lx->end)) { tm_lex_uncertain(lx); } + if (tm_splice_len(lx->p, lx->end)) { + tm_lex_uncertain(lx); /* a splice in plain code can join tokens */ + } bool line_start = lx->line_start; lx->line_start = false; if (c == '#' && line_start) { @@ -594,7 +630,8 @@ static void tm_add_runner_suite(tm_scan_t *sc, const tm_tok_t *name, bool perf) return; } m->runner = items; - items[m->runner_count++] = (cbm_test_runner_suite_t){.name = copy, .perf = perf}; + items[m->runner_count++] = + (cbm_test_runner_suite_t){.name = copy, .perf = perf, .file = sc->file, .line = name->line}; } /* Virtual preset records are immutable. Original configured records are kept @@ -816,6 +853,15 @@ static bool tm_descriptor_ident(tm_scan_t *sc, const tm_tok_t *tok) { } if (first == -2) return false; + /* A function-like macro name not followed by '(' is not an invocation: + * the preprocessor leaves it alone (an array or variable that happens to + * carry the name), so it defines and registers nothing. An opened '(' + * that does not close stays a malformed invocation below. */ + tm_lex_t peek = sc->lx; + tm_tok_t next; + tm_next(&peek, &next); + if (!tm_tok_punct(&next, '(')) + return false; tm_invocation_t invocation = {0}; cbm_arena_init_sized(&invocation.arena, TM_INVOCATION_ARENA_BLOCK); int parsed = tm_invocation_parse(sc, sc->lx, &invocation); @@ -921,8 +967,11 @@ static void tm_descriptor_macro_body(tm_scan_t *sc, const tm_tok_t *word) { /* The first word of a directive and what follows it. */ static void tm_directive_word(const tm_tok_t *tok, tm_tok_t *word, tm_lex_t *rest, bool *incomplete) { - *rest = (tm_lex_t){ - .p = tok->text, .end = tok->text + tok->len, .line = tok->line, .incomplete = incomplete}; + *rest = (tm_lex_t){.p = tok->text, + .end = tok->text + tok->len, + .line = tok->line, + .directive = true, + .incomplete = incomplete}; tm_next(rest, word); } @@ -958,13 +1007,12 @@ static void tm_directive(tm_scan_t *sc, const tm_tok_t *tok) { sc->m->incomplete = true; } if (tm_tok_is(&word, "ifndef")) { - guard = condition.kind == TM_TOK_IDENT && tm_is_include_guard(sc, &condition); - if (guard && - (sc->seen_token || sc->depth || sc->open != TM_OPEN_NONE || sc->cond_count)) { - /* Keep the legacy diagnostic rows, but only certify a guard - * that starts the file at outermost scope. */ - sc->m->incomplete = true; - } + /* Only a guard that starts the file at outermost scope conditions + * nothing. The same pair anywhere else is the define-if-undefined + * idiom: an ordinary condition, so what it encloses is conditional. */ + guard = condition.kind == TM_TOK_IDENT && !sc->seen_token && !sc->depth && + sc->open == TM_OPEN_NONE && !sc->cond_count && + tm_is_include_guard(sc, &condition); } if (sc->cond_count < TM_COND_MAX) { sc->cond[sc->cond_count] = (tm_cond_t){.depth = sc->depth, .guard = guard}; @@ -1154,30 +1202,18 @@ static void tm_ident(tm_scan_t *sc, const tm_tok_t *tok) { } } -static bool tm_add_source(cbm_test_model_t *m, const char *file, const char *text, size_t len, - int language_kind) { - if (!m || m->finished || !file || (!text && len > 0)) { - if (m) { - m->incomplete = true; - } - return false; - } +static bool tm_scan_source(cbm_test_model_t *m, const char *file, const char *text, size_t len, + int language_kind) { if (!text) { text = ""; /* (NULL, 0) is empty input, never NULL pointer arithmetic */ } if (memchr(text, '\0', len)) { m->incomplete = true; } - /* Translation-phase splicing can join identifiers, punctuators, or - * comment delimiters before this lexer sees them. Without preprocessing, - * even an otherwise handled directive/string splice prevents certainty. */ - for (size_t i = 0; i < len; i++) { - if (text[i] == '\\' && len - i >= 2 && - (text[i + 1] == '\n' || (len - i >= 3 && text[i + 1] == '\r' && text[i + 2] == '\n'))) { - m->incomplete = true; - break; - } - } + /* Translation-phase line splicing is followed where the lexer reads it + * correctly (a directive's continuation lines, an escaped newline in a + * literal); a splice it cannot follow (in plain code, or extending a line + * comment) marks the file uncertain where it is met. */ tm_scan_t sc = {.m = m, .language_kind = language_kind, .ok = true}; int first_suite = m->suite_count; sc.file = cbm_arena_strdup(&m->arena, file); @@ -1224,6 +1260,44 @@ static bool tm_add_source(cbm_test_model_t *m, const char *file, const char *tex return sc.ok; } +/* Scan one file and scope what it left uncertain. The uncertainty of a file + * that defines its own suite stays with those suites, which then run whole; + * a file that registers runner suites or defines no suite has nothing to + * scope it to, and a failure is never scoped. */ +static bool tm_add_source(cbm_test_model_t *m, const char *file, const char *text, size_t len, + int language_kind) { + if (!m || m->finished || !file || (!text && len > 0)) { + if (m) { + m->incomplete = true; + } + return false; + } + bool before = m->incomplete; + int first_suite = m->suite_count; + int first_runner = m->runner_count; + m->incomplete = false; + bool ok = tm_scan_source(m, file, text, len, language_kind); + bool uncertain = m->incomplete; + m->incomplete = before; + if (!ok) { + m->incomplete = true; + return false; + } + if (uncertain) { + bool own_suite = m->suite_count > first_suite; + bool registers_runner = m->runner_count > first_runner; + if (!own_suite || registers_runner) { + m->incomplete = true; + } else { + for (int i = first_suite; i < m->suite_count; i++) { + m->suites[i].uncertain = true; + } + m->scoped_uncertain = true; + } + } + return true; +} + bool cbm_test_model_add_source(cbm_test_model_t *m, const char *file, const char *text, size_t len) { if (m && m->descriptor_mode) { @@ -1355,6 +1429,10 @@ bool cbm_test_model_finish(cbm_test_model_t *m) { /* ── Accessors ───────────────────────────────────────────────────── */ bool cbm_test_model_complete(const cbm_test_model_t *m) { + return m && m->finished && !m->incomplete && !m->scoped_uncertain; +} + +bool cbm_test_model_narrowable(const cbm_test_model_t *m) { return m && m->finished && !m->incomplete; } diff --git a/src/mcp/test_impact.h b/src/mcp/test_impact.h index 87fd6983e..7153e0e8d 100644 --- a/src/mcp/test_impact.h +++ b/src/mcp/test_impact.h @@ -51,6 +51,10 @@ typedef struct { /* The file defines a macro that registers tests. The suite may then run * tests the model cannot name, so it can only be selected whole. */ bool macro_registrations; + /* The model could not read this suite's file with certainty (for + * example tests defined by a macro). The suite can only run whole; other + * suites stay narrowable (cbm_test_model_narrowable). */ + bool uncertain; } cbm_test_suite_t; typedef struct { @@ -65,6 +69,8 @@ typedef struct { typedef struct { const char *name; bool perf; + const char *file; /* the runner file that registers it */ + int line; } cbm_test_runner_suite_t; typedef struct cbm_test_model cbm_test_model_t; @@ -91,6 +97,13 @@ void cbm_test_model_free(cbm_test_model_t *m); * file/runner inventory is complete and conventions are applicable. */ bool cbm_test_model_complete(const cbm_test_model_t *m); +/* True after finish when every uncertainty the model found is scoped to the + * suites of the file it was found in (those suites are marked `uncertain` and + * can only run whole). Uncertainty in a file that registers runner suites, in + * a file with no suite of its own, in the configuration, or a failure keeps + * this false, and nothing may be narrowed. complete() implies narrowable(). */ +bool cbm_test_model_narrowable(const cbm_test_model_t *m); + /* Descriptor mode is independent of the legacy six-spelling constructor. * Copies every original record, ordered name_args, optional template and * preset switch. NULL means invalid input or allocation failure. Unsupported @@ -228,28 +241,28 @@ typedef struct { } cbm_coverage_profile_binding_t; typedef struct { - int id; /* dense 0-based; row index */ + int id; /* dense 0-based; row index */ const unsigned char *name; /* owned bytes; NOT a C-string contract */ - size_t name_length; /* positive; embedded NUL is representable */ - uint64_t function_hash; /* all 64 bits, without masking */ - uint64_t counter_count; /* positive shape; no counter values stored */ + size_t name_length; /* positive; embedded NUL is representable */ + uint64_t function_hash; /* all 64 bits, without masking */ + uint64_t counter_count; /* positive shape; no counter values stored */ } cbm_coverage_profile_t; typedef struct { uint64_t max_input_bytes; /* profiles_len + tests_len; <= UINT64_MAX/8 */ - uint64_t max_items; /* exactly 1 header + profile rows + test rows + ID references */ - size_t max_alloc_bytes; /* cumulative logical allocation requests, including scratch */ - int max_ids; /* positive, <= INT_MAX; dense universe cardinality */ + uint64_t max_items; /* exactly 1 header + profile rows + test rows + ID references */ + size_t max_alloc_bytes; /* cumulative logical allocation requests, including scratch */ + int max_ids; /* positive, <= INT_MAX; dense universe cardinality */ } cbm_coverage_parse_limits_t; typedef bool (*cbm_coverage_parse_cancel_fn)(void *context); typedef enum { CBM_COVERAGE_PARSE_OK = 0, - CBM_COVERAGE_PARSE_INVALID, /* bad pointers/limits, invalid C call */ - CBM_COVERAGE_PARSE_FORMAT, /* malformed/noncanonical identities or invalid test rows */ + CBM_COVERAGE_PARSE_INVALID, /* bad pointers/limits, invalid C call */ + CBM_COVERAGE_PARSE_FORMAT, /* malformed/noncanonical identities or invalid test rows */ CBM_COVERAGE_PARSE_UNSUPPORTED, /* well-formed recognized header, wire version != 2 */ - CBM_COVERAGE_PARSE_LIMIT, /* byte/item/ID/logical allocation/representation limit */ + CBM_COVERAGE_PARSE_LIMIT, /* byte/item/ID/logical allocation/representation limit */ CBM_COVERAGE_PARSE_CANCELLED, CBM_COVERAGE_PARSE_OOM } cbm_coverage_parse_status_t; @@ -266,12 +279,12 @@ typedef enum { * OK owns all data in CBMArena storage and permits unobserved IDs explicitly. * OK never establishes image/universe/row completeness or artifact admission. * Use existing cbm_coverage_map_free; no shared mutable or cancellation state. */ -cbm_coverage_parse_status_t cbm_coverage_map_parse_v2( - const void *profiles, size_t profiles_len, - const char *tests, size_t tests_len, - const cbm_coverage_parse_limits_t *limits, - cbm_coverage_parse_cancel_fn cancelled, void *cancel_context, - cbm_coverage_map_t **out); +cbm_coverage_parse_status_t cbm_coverage_map_parse_v2(const void *profiles, size_t profiles_len, + const char *tests, size_t tests_len, + const cbm_coverage_parse_limits_t *limits, + cbm_coverage_parse_cancel_fn cancelled, + void *cancel_context, + cbm_coverage_map_t **out); /* O(1) borrowed views until map_free. NULL map -> NONE / 0 / NULL. * Generic count and exact digest work for formats 1 and 2. No reserialization: @@ -286,8 +299,7 @@ const char *cbm_coverage_map_identity_sha256(const cbm_coverage_map_t *map); * but binding remains available. Records remain in dense ID order. */ const cbm_coverage_profile_binding_t *cbm_coverage_map_profile_binding( const cbm_coverage_map_t *map); -const cbm_coverage_profile_t *cbm_coverage_map_profiles( - const cbm_coverage_map_t *map, int *count); +const cbm_coverage_profile_t *cbm_coverage_map_profiles(const cbm_coverage_map_t *map, int *count); /* Existing test/find_test/intersection/free APIs retain their row semantics. * On v2, old functions() returns NULL/count=0, find_function() returns NULL, @@ -295,7 +307,6 @@ const cbm_coverage_profile_t *cbm_coverage_map_profiles( * check_receipt() returns INVALID|METADATA before interpreting v1 evidence. * All format-1 behavior, uniqueness and observed-ID guards stay unchanged. */ - /* Explicit byte lengths; inputs need not be NUL-terminated. LF and CRLF are * accepted. NULL means invalid/incomplete syntax, inconsistent references, or * allocation failure; no partial map is returned. Does not read meta.json. */ diff --git a/src/mcp/test_impact_changes.c b/src/mcp/test_impact_changes.c index 81a86a7e7..897701d2a 100644 --- a/src/mcp/test_impact_changes.c +++ b/src/mcp/test_impact_changes.c @@ -54,8 +54,8 @@ static bool changes_path_valid(const unsigned char *path, size_t length) { return true; } -static bool changes_record(const unsigned char *bytes, size_t length, size_t *offset, - char *status, changes_slice_t *path) { +static bool changes_record(const unsigned char *bytes, size_t length, size_t *offset, char *status, + changes_slice_t *path) { size_t at = *offset; if (length - at < 4 || bytes[at + 1] != 0 || (bytes[at] != 'A' && bytes[at] != 'M' && bytes[at] != 'D' && bytes[at] != 'T')) { @@ -76,8 +76,8 @@ static bool changes_record(const unsigned char *bytes, size_t length, size_t *of return true; } -static int changes_path_compare(const unsigned char *a, size_t a_length, - const unsigned char *b, size_t b_length) { +static int changes_path_compare(const unsigned char *a, size_t a_length, const unsigned char *b, + size_t b_length) { size_t common = a_length < b_length ? a_length : b_length; int order = memcmp(a, b, common); /* specified unsigned-byte comparison */ return order ? order : (a_length > b_length) - (a_length < b_length); @@ -94,9 +94,9 @@ static void changes_sort(cbm_change_file_t *files, cbm_change_file_t *scratch, s size_t a = left; size_t b = middle; for (size_t i = left; i < right; i++) { - if (b == right || (a < middle && - changes_path_compare(source[a].path, source[a].path_length, - source[b].path, source[b].path_length) <= 0)) { + if (b == right || (a < middle && changes_path_compare( + source[a].path, source[a].path_length, + source[b].path, source[b].path_length) <= 0)) { target[i] = source[a++]; } else { target[i] = source[b++]; @@ -117,8 +117,7 @@ static void changes_sort(cbm_change_file_t *files, cbm_change_file_t *scratch, s } } -static size_t changes_find(const cbm_changes_t *changes, const unsigned char *path, - size_t length) { +static size_t changes_find(const cbm_changes_t *changes, const unsigned char *path, size_t length) { size_t low = 0; size_t high = changes->count; while (low < high) { @@ -143,7 +142,7 @@ static bool changes_starts(changes_slice_t line, const char *prefix) { } static bool changes_line(const unsigned char *bytes, size_t length, size_t *offset, - changes_slice_t *line) { + changes_slice_t *line) { if (*offset == length) { return false; } @@ -241,8 +240,7 @@ static bool changes_old_range_usable(changes_slice_t line) { } break; } - return offset < line.length && line.bytes[offset] == ' ' && - (values[1] == 0 || values[0] > 0); + return offset < line.length && line.bytes[offset] == ' ' && (values[1] == 0 || values[0] > 0); } /* The legacy file result does not retain old/new mode metadata or the old @@ -286,13 +284,15 @@ static void changes_audit_headers(cbm_changes_t *changes) { cbm_change_file_t *file = &changes->files[current]; if (changes_starts(line, "old mode ") || changes_starts(line, "new mode ")) { file->reasons |= CBM_CHANGE_MODE; - if (!changes_mode(line, changes_starts(line, "old mode ") ? "old mode " : "new mode ")) { + if (!changes_mode(line, + changes_starts(line, "old mode ") ? "old mode " : "new mode ")) { changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; } } else if (changes_starts(line, "new file mode ") || changes_starts(line, "deleted file mode ")) { - if (!changes_mode(line, changes_starts(line, "new file mode ") ? - "new file mode " : "deleted file mode ")) { + if (!changes_mode(line, changes_starts(line, "new file mode ") + ? "new file mode " + : "deleted file mode ")) { changes->issues |= CBM_CHANGES_PATCH_INCOMPLETE; } } else if (changes_starts(line, "--- ")) { @@ -335,8 +335,9 @@ static void changes_reconcile(cbm_changes_t *changes, changes_match_t *matches) for (int i = 0; i < patch_count; i++) { const cbm_diff_file_t *file = &files[i]; size_t length = strlen(file->path); - size_t index = changes_path_valid((const unsigned char *)file->path, length) ? - changes_find(changes, (const unsigned char *)file->path, length) : SIZE_MAX; + size_t index = changes_path_valid((const unsigned char *)file->path, length) + ? changes_find(changes, (const unsigned char *)file->path, length) + : SIZE_MAX; if (index == SIZE_MAX) { changes->issues |= CBM_CHANGES_PATCH_EXTRA_PATH; continue; @@ -386,13 +387,15 @@ static void changes_reconcile(cbm_changes_t *changes, changes_match_t *matches) file->patch_file = matches[i].file; } } - changes->state = changes->count ? CBM_CHANGES_NONEMPTY : - (changes->patch_length == 0 && changes->reconciled ? CBM_CHANGES_EMPTY : CBM_CHANGES_UNKNOWN); + changes->state = + changes->count ? CBM_CHANGES_NONEMPTY + : (changes->patch_length == 0 && changes->reconciled ? CBM_CHANGES_EMPTY + : CBM_CHANGES_UNKNOWN); } -cbm_changes_status_t cbm_changes_parse(const unsigned char *name_status, - size_t name_status_length, const unsigned char *patch, - size_t patch_length, cbm_changes_t **out) { +cbm_changes_status_t cbm_changes_parse(const unsigned char *name_status, size_t name_status_length, + const unsigned char *patch, size_t patch_length, + cbm_changes_t **out) { if (out) { *out = NULL; } @@ -410,7 +413,8 @@ cbm_changes_status_t cbm_changes_parse(const unsigned char *name_status, } count++; } - if (count > SIZE_MAX / sizeof(cbm_change_file_t) || count > SIZE_MAX / sizeof(changes_match_t)) { + if (count > SIZE_MAX / sizeof(cbm_change_file_t) || + count > SIZE_MAX / sizeof(changes_match_t)) { return CBM_CHANGES_OOM; } CBMArena arena; @@ -449,7 +453,8 @@ cbm_changes_status_t cbm_changes_parse(const unsigned char *name_status, for (size_t i = 0; i < count; i++) { changes_slice_t path; char status; - if (!changes_record(changes->name_status, name_status_length, &offset, &status, &path)) { + if (!changes_record(changes->name_status, name_status_length, &offset, &status, + &path)) { cbm_changes_free(changes); return CBM_CHANGES_INVALID; } @@ -470,7 +475,7 @@ cbm_changes_status_t cbm_changes_parse(const unsigned char *name_status, * C-string path consumer; retain the exact bytes and conservative inventory. */ bool binary_text = patch_length && memchr(changes->patch, 0, patch_length); changes->diff = cbm_diff_parse(binary_text ? "" : (const char *)changes->patch, - binary_text ? 0 : patch_length); + binary_text ? 0 : patch_length); if (!changes->diff) { goto oom; } diff --git a/src/mcp/test_impact_changes.h b/src/mcp/test_impact_changes.h index e8553a176..2c923967b 100644 --- a/src/mcp/test_impact_changes.h +++ b/src/mcp/test_impact_changes.h @@ -10,11 +10,7 @@ * This is not a graph/coverage identity or provenance certificate. */ typedef struct cbm_changes cbm_changes_t; -typedef enum { - CBM_CHANGES_OK = 0, - CBM_CHANGES_INVALID, - CBM_CHANGES_OOM -} cbm_changes_status_t; +typedef enum { CBM_CHANGES_OK = 0, CBM_CHANGES_INVALID, CBM_CHANGES_OOM } cbm_changes_status_t; typedef enum { CBM_CHANGES_UNKNOWN = 0, @@ -55,9 +51,9 @@ typedef struct { * OOM/no owner. Legacy reader may mask header OOM as incomplete: such a result * retains paths but no hunk pointers and requires outer broad/full fallback. * NULL input is allowed only at zero length. No I/O, path expansion or recoding. */ -cbm_changes_status_t cbm_changes_parse(const unsigned char *name_status, - size_t name_status_length, const unsigned char *patch, - size_t patch_length, cbm_changes_t **out); +cbm_changes_status_t cbm_changes_parse(const unsigned char *name_status, size_t name_status_length, + const unsigned char *patch, size_t patch_length, + cbm_changes_t **out); void cbm_changes_free(cbm_changes_t *changes); /* Complete authoritative inventory, sorted by unsigned path bytes. Never diff --git a/src/mcp/test_impact_classify.c b/src/mcp/test_impact_classify.c new file mode 100644 index 000000000..df8e1ef37 --- /dev/null +++ b/src/mcp/test_impact_classify.c @@ -0,0 +1,363 @@ +/* + * test_impact_classify.c — the classified ledger of a pinned HEAD inventory + * (test_impact_classify.h). + */ +#include "mcp/test_impact_classify.h" + +#include "discover/discover.h" +#include "foundation/arena.h" +#include "foundation/mem_core.h" +#include "mcp/test_impact_inventory_internal.h" + +#include +#include +#include + +struct cbm_test_impact_ledger { + CBMArena arena; /* owns this struct and the rows */ + cbm_test_impact_classified_t *rows; + size_t count; + unsigned char manifest[32]; + cbm_test_impact_classify_usage_t usage; +}; + +/* What the inventory's limits leave for this pass after the filter. */ +typedef struct { + uint64_t reads; + uint64_t content_bytes; + size_t arena_bytes; + size_t probe_bytes; +} cls_budget_t; + +static bool cls_fail(cni_context *c, cbm_inventory_status_t status, const char *what) { + if (c->error.status == CBM_INVENTORY_OK) { + c->error.status = status; + c->error.file_index = c->file_index; + snprintf(c->error.diagnostic, sizeof(c->error.diagnostic), "%s", what); + } + return false; +} + +static const char *cls_basename(const cbm_inventory_path_t *path) { + const char *name = (const char *)path->data; + const char *slash = strrchr(name, '/'); + return slash ? slash + 1 : name; +} + +static bool cls_budget(cni_context *c, const cbm_test_impact_inventory_t *inventory, + cls_budget_t *out) { + cbm_inventory_limits_t limits; + cbm_test_impact_inventory_usage_t usage; + if (!cbm_test_impact_inventory_limits(inventory, &limits) || + !cbm_test_impact_inventory_usage(inventory, &usage)) { + return cls_fail(c, CBM_INVENTORY_STATE, "inventory has no limits"); + } + if (usage.filter.verified_file_reads_reserved > limits.max_verified_file_reads || + usage.filter.verified_content_bytes_reserved > limits.max_verified_content_bytes || + usage.arena_budget_used_bytes > limits.max_arena_bytes) { + return cls_fail(c, CBM_INVENTORY_STATE, "inventory usage exceeds its limits"); + } + out->reads = limits.max_verified_file_reads - usage.filter.verified_file_reads_reserved; + out->content_bytes = + limits.max_verified_content_bytes - usage.filter.verified_content_bytes_reserved; + out->arena_bytes = limits.max_arena_bytes - usage.arena_budget_used_bytes; + out->probe_bytes = limits.max_probe_prefix_bytes; + return true; +} + +/* The inventory and the tree describe the same snapshot. */ +static bool cls_bound(cni_context *c, const cbm_inventory_filter_view_t *view, + cbm_pinned_tree_t *tree) { + const cbm_pinned_tree_view_t *native = cbm_pinned_tree_view(tree); + if (!native) { + return cls_fail(c, CBM_INVENTORY_STATE, "pinned tree is not ready"); + } + if (native->file_count != view->file_count || + memcmp(native->manifest_sha256, view->manifest_sha256, sizeof(view->manifest_sha256)) != + 0) { + return cls_fail(c, CBM_INVENTORY_CHANGED, "pinned tree is not the inventory's snapshot"); + } + return true; +} + +static size_t cls_survivors(const cbm_inventory_filter_view_t *view) { + size_t n = 0; + for (size_t i = 0; i < view->file_count; i++) { + n += view->rows[i].disposition == CBM_INVENTORY_FILTER_NEEDS_LANGUAGE; + } + return n; +} + +/* One survivor: the name, and the verified blob prefix where the name needs + * it. Charges the read to the budget. */ +static bool cls_one(cni_context *c, cni_reader *reader, const cbm_userconfig_t *config, + cls_budget_t *budget, cbm_test_impact_ledger_t *ledger, size_t index, + CBMLanguage *out) { + const cbm_inventory_file_t *file = &reader->files[index]; + const char *name = cls_basename(&file->path); + size_t probe = cbm_language_probe_bytes_with(config, name); + if (probe == 0) { + *out = cbm_language_classify_with(config, name, NULL, 0, false, true); + return true; + } + if (probe > budget->probe_bytes || probe > CBM_LANGUAGE_PROBE_MAX) { + return cls_fail(c, CBM_INVENTORY_LIMIT, "language probe exceeds the probe limit"); + } + if (budget->reads == 0 || file->content_length > budget->content_bytes) { + return cls_fail(c, CBM_INVENTORY_LIMIT, "language probe exceeds the read budget"); + } + unsigned char head[CBM_LANGUAGE_PROBE_MAX]; + size_t copied = 0; + reader->content_bound = budget->content_bytes; + cbm_inventory_error_t read_error; + if (cni_read(reader, index, head, probe, &copied, c->control, &read_error) != + CBM_INVENTORY_OK) { + return false; /* cni_read recorded the error in c */ + } + budget->reads--; + budget->content_bytes -= file->content_length; + ledger->usage.verified_file_reads++; + ledger->usage.verified_content_bytes += file->content_length; + *out = + cbm_language_classify_with(config, name, head, copied, file->content_length > copied, true); + return true; +} + +static bool cls_run(cni_context *c, const cbm_inventory_filter_view_t *view, + cbm_pinned_tree_t *tree, const cbm_userconfig_t *config, cls_budget_t *budget, + cbm_test_impact_ledger_t *ledger) { + cni_reader reader = { + .context = c, .tree = tree, .files = view->files, .file_count = view->file_count}; + for (size_t i = 0; i < view->file_count; i++) { + const cbm_inventory_filter_row_t *row = &view->rows[i]; + if (row->disposition != CBM_INVENTORY_FILTER_NEEDS_LANGUAGE) { + continue; + } + c->file_index = row->file_index; + if (row->file_index >= view->file_count || !cni_event(c)) { + return c->error.status != CBM_INVENTORY_OK || + cls_fail(c, CBM_INVENTORY_STATE, "inventory row names no file"); + } + CBMLanguage language = CBM_LANG_COUNT; + if (!cls_one(c, &reader, config, budget, ledger, row->file_index, &language)) { + return false; + } + ledger->rows[ledger->count++] = + (cbm_test_impact_classified_t){.file_index = row->file_index, .language = language}; + } + c->file_index = SIZE_MAX; + return cni_poll(c); +} + +cbm_inventory_status_t cbm_test_impact_classify(const cbm_test_impact_inventory_t *inventory, + cbm_pinned_tree_t *tree, + const cbm_userconfig_t *config, + const cbm_inventory_control_t *control, + cbm_test_impact_ledger_t **out, + cbm_inventory_error_t *error) { + cbm_inventory_error_t initial = {.status = CBM_INVENTORY_OK, .file_index = SIZE_MAX}; + if (out) { + *out = NULL; + } + if (error) { + *error = initial; + } + cni_context c = {.control = control, .error = initial, .file_index = SIZE_MAX}; + const cbm_inventory_filter_view_t *view = cbm_test_impact_inventory_view(inventory); + cls_budget_t budget = {0}; + cbm_test_impact_ledger_t *ledger = NULL; + if (!out || !inventory || !tree || !control || !control->deadline_ms) { + cls_fail(&c, CBM_INVENTORY_INVALID, "invalid classification arguments"); + } else if (!view) { + cls_fail(&c, CBM_INVENTORY_STATE, "inventory has no view"); + } else if (cni_poll(&c) && cls_bound(&c, view, tree) && cls_budget(&c, inventory, &budget)) { + size_t survivors = cls_survivors(view); + size_t need = sizeof(*ledger) + survivors * sizeof(*ledger->rows); + if (survivors > (SIZE_MAX - sizeof(*ledger)) / sizeof(*ledger->rows) || + need > budget.arena_bytes) { + cls_fail(&c, CBM_INVENTORY_LIMIT, "classified ledger exceeds the arena budget"); + } else { + CBMArena arena; + cbm_arena_init_exact(&arena, need); + ledger = cbm_arena_calloc(&arena, sizeof(*ledger)); + cbm_test_impact_classified_t *rows = + survivors ? cbm_arena_calloc(&arena, survivors * sizeof(*rows)) : NULL; + if (!ledger || (survivors && !rows)) { + cbm_arena_destroy(&arena); + ledger = NULL; + cls_fail(&c, CBM_INVENTORY_OOM, "classified ledger allocation failed"); + } else { + ledger->arena = arena; /* the ledger lives in its own arena */ + ledger->rows = rows; + ledger->usage.arena_requested_bytes = need; + memcpy(ledger->manifest, view->manifest_sha256, sizeof(ledger->manifest)); + if (cls_run(&c, view, tree, config, &budget, ledger)) { + *out = ledger; + return CBM_INVENTORY_OK; + } + } + } + } + cbm_test_impact_ledger_free(ledger); + if (error) { + *error = c.error; + } + return c.error.status; +} + +const cbm_test_impact_classified_t *cbm_test_impact_ledger_rows( + const cbm_test_impact_ledger_t *ledger, size_t *count) { + if (count) { + *count = ledger ? ledger->count : 0; + } + return ledger ? ledger->rows : NULL; +} + +const unsigned char *cbm_test_impact_ledger_manifest(const cbm_test_impact_ledger_t *ledger) { + return ledger ? ledger->manifest : NULL; +} + +bool cbm_test_impact_ledger_usage(const cbm_test_impact_ledger_t *ledger, + cbm_test_impact_classify_usage_t *out) { + if (!out) { + return false; + } + memset(out, 0, sizeof(*out)); + if (!ledger) { + return false; + } + *out = ledger->usage; + return true; +} + +void cbm_test_impact_ledger_free(cbm_test_impact_ledger_t *ledger) { + if (ledger) { + CBMArena arena = ledger->arena; /* ledger itself is inside it */ + cbm_arena_destroy(&arena); + } +} + +/* ── The runnable registry of the same snapshot ─────────────────── */ + +/* The ledger languages the declarations cover. The model reads C-family + * conventions only; a declared language it does not support is reported by + * the model itself and keeps it from being complete. */ +static void reg_languages(const cbm_test_declarations_t *declarations, bool *wanted) { + static const struct { + const char *name; + CBMLanguage language; + } NAMES[] = {{"c", CBM_LANG_C}, {"C", CBM_LANG_C}, {"cpp", CBM_LANG_CPP}, + {"c++", CBM_LANG_CPP}, {"C++", CBM_LANG_CPP}, {"cuda", CBM_LANG_CUDA}, + {"CUDA", CBM_LANG_CUDA}}; + bool c_cbm = false; + if (cbm_test_declarations_preset(declarations, CBM_TEST_PRESET_C_CBM, &c_cbm) && c_cbm) { + wanted[CBM_LANG_C] = true; + } + int count = 0; + const cbm_test_declaration_t *items = cbm_test_declarations_items(declarations, &count); + for (int i = 0; i < count; i++) { + for (size_t j = 0; items[i].language && j < sizeof(NAMES) / sizeof(NAMES[0]); j++) { + if (strcmp(items[i].language, NAMES[j].name) == 0) { + wanted[NAMES[j].language] = true; + } + } + } +} + +/* The full verified blob of one ledger file into the model. */ +static bool reg_add(cni_context *c, cni_reader *reader, cls_budget_t *budget, + cbm_test_model_t *model, const cbm_test_impact_classified_t *row) { + const cbm_inventory_file_t *file = &reader->files[row->file_index]; + if (budget->reads == 0 || file->content_length > budget->content_bytes || + file->content_length > (uint64_t)SIZE_MAX) { + return cls_fail(c, CBM_INVENTORY_LIMIT, "test registry exceeds the read budget"); + } + size_t len = (size_t)file->content_length; + char *text = cbm_alloc(CBM_MEM_CLASS_EXTRACT, len ? len : 1); + if (!text) { + return cls_fail(c, CBM_INVENTORY_OOM, "test registry source allocation failed"); + } + size_t copied = 0; + reader->content_bound = budget->content_bytes; + cbm_inventory_error_t read_error; + bool ok = cni_read(reader, row->file_index, (unsigned char *)text, len, &copied, c->control, + &read_error) == CBM_INVENTORY_OK; + if (ok && copied != len) { + ok = cls_fail(c, CBM_INVENTORY_CHANGED, "test registry source length changed"); + } + if (ok) { + budget->reads--; + budget->content_bytes -= file->content_length; + if (!cbm_test_model_add_source_language(model, (const char *)file->path.data, + cbm_language_name(row->language), text, len)) { + ok = cls_fail(c, CBM_INVENTORY_OOM, "test registry source was not accepted"); + } + } + cbm_free(CBM_MEM_CLASS_EXTRACT, text); + return ok; +} + +cbm_inventory_status_t cbm_test_impact_registry( + const cbm_test_impact_ledger_t *ledger, const cbm_test_impact_inventory_t *inventory, + cbm_pinned_tree_t *tree, const cbm_test_declarations_t *declarations, + const cbm_inventory_control_t *control, cbm_test_model_t **out, cbm_inventory_error_t *error) { + cbm_inventory_error_t initial = {.status = CBM_INVENTORY_OK, .file_index = SIZE_MAX}; + if (out) { + *out = NULL; + } + if (error) { + *error = initial; + } + cni_context c = {.control = control, .error = initial, .file_index = SIZE_MAX}; + const cbm_inventory_filter_view_t *view = cbm_test_impact_inventory_view(inventory); + cls_budget_t budget = {0}; + cbm_test_model_t *model = NULL; + if (!out || !ledger || !inventory || !tree || !declarations || !control || + !control->deadline_ms) { + cls_fail(&c, CBM_INVENTORY_INVALID, "invalid registry arguments"); + } else if (!view) { + cls_fail(&c, CBM_INVENTORY_STATE, "inventory has no view"); + } else if (memcmp(ledger->manifest, view->manifest_sha256, sizeof(ledger->manifest)) != 0) { + cls_fail(&c, CBM_INVENTORY_CHANGED, "ledger is not the inventory's snapshot"); + } else if (cni_poll(&c) && cls_bound(&c, view, tree) && cls_budget(&c, inventory, &budget)) { + /* What the ledger already read is spent. */ + budget.reads -= budget.reads < ledger->usage.verified_file_reads + ? budget.reads + : ledger->usage.verified_file_reads; + budget.content_bytes -= budget.content_bytes < ledger->usage.verified_content_bytes + ? budget.content_bytes + : ledger->usage.verified_content_bytes; + model = cbm_test_model_new_declarations(declarations); + if (!model) { + cls_fail(&c, CBM_INVENTORY_OOM, "test registry could not start"); + } else { + bool wanted[CBM_LANG_COUNT + 1] = {false}; + reg_languages(declarations, wanted); + cni_reader reader = { + .context = &c, .tree = tree, .files = view->files, .file_count = view->file_count}; + bool ok = true; + for (size_t i = 0; ok && i < ledger->count; i++) { + const cbm_test_impact_classified_t *row = &ledger->rows[i]; + c.file_index = row->file_index; + if (row->language < 0 || row->language >= CBM_LANG_COUNT || + !wanted[row->language]) { + continue; + } + ok = cni_event(&c) && reg_add(&c, &reader, &budget, model, row); + } + c.file_index = SIZE_MAX; + if (ok && !cbm_test_model_finish(model)) { + ok = cls_fail(&c, CBM_INVENTORY_OOM, "test registry could not finish"); + } + if (ok && cni_poll(&c)) { + *out = model; + return CBM_INVENTORY_OK; + } + } + } + cbm_test_model_free(model); + if (error) { + *error = c.error; + } + return c.error.status; +} diff --git a/src/mcp/test_impact_classify.h b/src/mcp/test_impact_classify.h new file mode 100644 index 000000000..198f5dd67 --- /dev/null +++ b/src/mcp/test_impact_classify.h @@ -0,0 +1,80 @@ +/* + * test_impact_classify.h — the classified ledger of a pinned HEAD inventory. + * + * The inventory filter leaves every file it keeps as "needs language". This + * pass gives each of them the language discovery gives the same file: the + * shared rule of cbm_language_classify, applied to the base name and, where + * the name is not enough, to the first bytes of the verified git blob. What + * the ledger holds is what a frozen build indexes and what the reconciliation + * compares against. + * + * A blob that cannot be read is an error of the whole pass, never a default + * language: a ledger that quietly classified a file as something else would + * index a different graph than the one the selection claims to use. + */ +#ifndef CBM_TEST_IMPACT_CLASSIFY_H +#define CBM_TEST_IMPACT_CLASSIFY_H + +#include "discover/test_conventions.h" +#include "discover/userconfig.h" +#include "mcp/test_impact.h" +#include "mcp/test_impact_inventory.h" +#include "mcp/test_impact_tree.h" + +typedef struct { + size_t file_index; /* index in the pinned tree and the inventory view */ + CBMLanguage language; /* CBM_LANG_COUNT: no supported language, not indexed */ +} cbm_test_impact_classified_t; + +typedef struct { + uint64_t verified_file_reads; /* blobs read by this pass */ + uint64_t verified_content_bytes; /* their full lengths: each read verifies the whole blob */ + size_t arena_requested_bytes; /* the ledger's own memory */ +} cbm_test_impact_classify_usage_t; + +typedef struct cbm_test_impact_ledger cbm_test_impact_ledger_t; + +/* Classify every "needs language" survivor of `inventory`, reading from + * `tree`, the READY pinned tree the inventory was prepared from, under the + * snapshot's language config `config` (NULL: no user overrides). Reads, + * content bytes and ledger memory are charged to what the inventory's limits + * leave after the filter; a probe longer than max_probe_prefix_bytes is a + * LIMIT. Clears *out and initializes *error first. On failure *out stays + * NULL; a started failed read may leave the tree disposal-only, exactly as + * for the filter's own reads. */ +cbm_inventory_status_t cbm_test_impact_classify(const cbm_test_impact_inventory_t *inventory, + cbm_pinned_tree_t *tree, + const cbm_userconfig_t *config, + const cbm_inventory_control_t *control, + cbm_test_impact_ledger_t **out, + cbm_inventory_error_t *error); + +/* Rows in file-index order, one per survivor. Borrowed until the ledger is + * freed. */ +const cbm_test_impact_classified_t *cbm_test_impact_ledger_rows( + const cbm_test_impact_ledger_t *ledger, size_t *count); + +/* The manifest the inventory was bound to, so a consumer can check that the + * ledger and a tree describe the same snapshot. */ +const unsigned char *cbm_test_impact_ledger_manifest(const cbm_test_impact_ledger_t *ledger); + +bool cbm_test_impact_ledger_usage(const cbm_test_impact_ledger_t *ledger, + cbm_test_impact_classify_usage_t *out); + +void cbm_test_impact_ledger_free(cbm_test_impact_ledger_t *ledger); + +/* The runnable test registry of the same snapshot: the finished test model + * (mcp/test_impact.h) built under `declarations` (the snapshot's config, as + * the frozen build was given it) from the full verified blobs of every ledger + * file in a language those declarations cover (C for the c-cbm preset). More + * files rather than fewer: no path filter narrows what is scanned. Blob reads + * are charged to what the inventory's limits leave after the filter and the + * ledger; a read that fails or does not fit is an error, never a smaller + * registry. The model may still report mapping uncertainty: callers must + * require cbm_test_model_complete() before narrowing anything with it. */ +cbm_inventory_status_t cbm_test_impact_registry( + const cbm_test_impact_ledger_t *ledger, const cbm_test_impact_inventory_t *inventory, + cbm_pinned_tree_t *tree, const cbm_test_declarations_t *declarations, + const cbm_inventory_control_t *control, cbm_test_model_t **out, cbm_inventory_error_t *error); + +#endif /* CBM_TEST_IMPACT_CLASSIFY_H */ diff --git a/src/mcp/test_impact_coverage.c b/src/mcp/test_impact_coverage.c index 7ba7bf60d..aa76eae50 100644 --- a/src/mcp/test_impact_coverage.c +++ b/src/mcp/test_impact_coverage.c @@ -482,9 +482,8 @@ static cv_meta_suite_t *cv_meta_find_suite(cv_meta_suite_t *suites, int count, c bool cbm_coverage_map_metadata_matches(const cbm_coverage_map_t *map, const char *metadata, size_t len, const char *expected_commit) { - if (!map || map->format != CBM_COVERAGE_FORMAT_FUNCTIONS || !metadata || - len == SIZE_MAX || !cv_meta_commit(expected_commit) || - memchr(metadata, '\0', len)) { + if (!map || map->format != CBM_COVERAGE_FORMAT_FUNCTIONS || !metadata || len == SIZE_MAX || + !cv_meta_commit(expected_commit) || memchr(metadata, '\0', len)) { return false; } yyjson_doc *doc = yyjson_read(metadata, len, 0); @@ -747,8 +746,8 @@ static char *cv2_string(cv2_context_t *ctx, cv2_span_t span) { return text; } -static bool cv2_line(cv2_context_t *ctx, cv2_span_t input, size_t *position, - bool test_row, cv2_span_t *line) { +static bool cv2_line(cv2_context_t *ctx, cv2_span_t input, size_t *position, bool test_row, + cv2_span_t *line) { size_t start = *position; while (*position < input.length) { if (!cv2_step(ctx)) @@ -786,8 +785,8 @@ static bool cv2_fields(cv2_context_t *ctx, cv2_span_t line, cv2_span_t fields[4] return true; } -static bool cv2_row(cv2_context_t *ctx, cv2_span_t input, size_t *position, - bool test_row, cv2_span_t fields[4]) { +static bool cv2_row(cv2_context_t *ctx, cv2_span_t input, size_t *position, bool test_row, + cv2_span_t fields[4]) { cv2_span_t line; return cv2_line(ctx, input, position, test_row, &line) && cv2_fields(ctx, line, fields); } @@ -796,8 +795,7 @@ static bool cv2_literal(cv2_span_t span, const char *literal, size_t length) { return span.length == length && memcmp(span.data, literal, length) == 0; } -static bool cv2_decimal(cv2_context_t *ctx, cv2_span_t span, bool canonical, - uint64_t *out) { +static bool cv2_decimal(cv2_context_t *ctx, cv2_span_t span, bool canonical, uint64_t *out) { if (!span.length || (canonical && span.length > 1 && span.data[0] == '0')) return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); uint64_t value = 0; @@ -885,7 +883,9 @@ static bool cv2_profile_inventory(cv2_context_t *ctx, cv2_span_t profiles) { if (!cv2_items(ctx, 1)) return false; } - return count == ctx->profile_count || cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + if (count != ctx->profile_count) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + return true; } static bool cv2_id_count(cv2_context_t *ctx, cv2_span_t span, int *out) { @@ -971,8 +971,8 @@ static bool cv2_profile_compare(cv2_context_t *ctx, const cbm_coverage_profile_t *out = left->name_length < right->name_length ? -1 : 1; return true; } - *out = (left->function_hash > right->function_hash) - - (left->function_hash < right->function_hash); + *out = + (left->function_hash > right->function_hash) - (left->function_hash < right->function_hash); return true; } @@ -1010,7 +1010,9 @@ static bool cv2_profiles(cv2_context_t *ctx, cv2_span_t profiles) { !cv2_profile(ctx, fields, i)) return false; } - return position == profiles.length || cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + if (position != profiles.length) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + return true; } static bool cv2_test_name(cv2_context_t *ctx, cv2_span_t field, cbm_coverage_test_t *row) { @@ -1078,7 +1080,9 @@ static bool cv2_tests(cv2_context_t *ctx, cv2_span_t tests) { !cv2_test_ids(ctx, fields[3], row)) return false; } - return position == tests.length || cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + if (position != tests.length) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + return true; } static bool cv2_string_compare(cv2_context_t *ctx, const char *a, const char *b, int *out) { @@ -1173,7 +1177,9 @@ static bool cv2_validate_tests(cv2_context_t *ctx) { if (row->name[0] == '*' && row->name[1] == '\0') setup = true; } - return !ctx->test_count || setup || cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + if (ctx->test_count && !setup) + return cv2_fail(ctx, CBM_COVERAGE_PARSE_FORMAT); + return true; } static bool cv2_hash(cv2_context_t *ctx, cv2_span_t span, char hex[CBM_SHA256_HEX_LEN + 1]) { @@ -1203,8 +1209,8 @@ static bool cv2_hash(cv2_context_t *ctx, cv2_span_t span, char hex[CBM_SHA256_HE } static cbm_coverage_parse_status_t cv2_arguments(const void *profiles, size_t profiles_len, - const char *tests, size_t tests_len, - const cbm_coverage_parse_limits_t *limits) { + const char *tests, size_t tests_len, + const cbm_coverage_parse_limits_t *limits) { if (!profiles || (!tests && tests_len) || !limits) return CBM_COVERAGE_PARSE_INVALID; if (!limits->max_input_bytes || limits->max_input_bytes > UINT64_MAX / 8 || @@ -1221,18 +1227,23 @@ static cbm_coverage_parse_status_t cv2_arguments(const void *profiles, size_t pr return CBM_COVERAGE_PARSE_OK; } -cbm_coverage_parse_status_t cbm_coverage_map_parse_v2( - const void *profiles, size_t profiles_len, const char *tests, size_t tests_len, - const cbm_coverage_parse_limits_t *limits, cbm_coverage_parse_cancel_fn cancelled, - void *cancel_context, cbm_coverage_map_t **out) { +cbm_coverage_parse_status_t cbm_coverage_map_parse_v2(const void *profiles, size_t profiles_len, + const char *tests, size_t tests_len, + const cbm_coverage_parse_limits_t *limits, + cbm_coverage_parse_cancel_fn cancelled, + void *cancel_context, + cbm_coverage_map_t **out) { if (!out) return CBM_COVERAGE_PARSE_INVALID; *out = NULL; - cbm_coverage_parse_status_t status = cv2_arguments(profiles, profiles_len, tests, tests_len, limits); + cbm_coverage_parse_status_t status = + cv2_arguments(profiles, profiles_len, tests, tests_len, limits); if (status != CBM_COVERAGE_PARSE_OK) return status; - cv2_context_t ctx = {.limits = limits, .cancelled = cancelled, - .cancel_context = cancel_context, .status = CBM_COVERAGE_PARSE_OK}; + cv2_context_t ctx = {.limits = limits, + .cancelled = cancelled, + .cancel_context = cancel_context, + .status = CBM_COVERAGE_PARSE_OK}; cbm_arena_init_lazy(&ctx.arena, 4096); cv2_span_t profile_span = {profiles, profiles_len}; cv2_span_t test_span = {(const unsigned char *)tests, tests_len}; @@ -1264,7 +1275,8 @@ const char *cbm_coverage_map_identity_sha256(const cbm_coverage_map_t *map) { return map ? map->functions_sha256 : NULL; } -const cbm_coverage_profile_binding_t *cbm_coverage_map_profile_binding(const cbm_coverage_map_t *map) { +const cbm_coverage_profile_binding_t *cbm_coverage_map_profile_binding( + const cbm_coverage_map_t *map) { return map && map->format == CBM_COVERAGE_FORMAT_PROFILES ? &map->profile_binding : NULL; } diff --git a/src/mcp/test_impact_engine.c b/src/mcp/test_impact_engine.c new file mode 100644 index 000000000..39aece400 --- /dev/null +++ b/src/mcp/test_impact_engine.c @@ -0,0 +1,650 @@ +/* + * test_impact_engine.c — detect_changes scope:"tests", end to end: git, the + * merge base's rules, classification of every changed path, the HEAD + * snapshot, seeding, the walk, the selection and the answer. + */ +#include "mcp/test_impact_engine_internal.h" + +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/compat_thread.h" +#include "foundation/mem_core.h" +#include "foundation/platform.h" +#include "foundation/sha256.h" +#include "store/store_graph_digest.h" +#include "store/store_impact.h" + +#include +#include +#include +#include +#ifndef _WIN32 +#include +#endif + +/* ── Shared helpers ──────────────────────────────────────────────── */ + +bool te_grow(void **items, int *cap, int count, size_t size) { + if (count < *cap) { + return true; + } + int next = *cap ? *cap * 2 : 16; + void *grown = cbm_realloc(CBM_MEM_CLASS_EXTRACT, *items, (size_t)next * size); + if (!grown) { + return false; + } + *items = grown; + *cap = next; + return true; +} + +bool te_oom(te_ctx_t *c) { + c->oom = true; + return false; +} + +static void te_note(te_ctx_t *c, const char *text) { + if (c->diagnostic && c->diagnostic_len && !c->diagnostic[0] && text) { + snprintf(c->diagnostic, c->diagnostic_len, "%s", text); + } +} + +bool te_fallback(te_ctx_t *c, cbm_test_result_fallback_t code, const char *diagnostic) { + te_note(c, diagnostic); + for (int i = 0; i < c->fallback_count; i++) { + if (c->fallbacks[i] == code) { + return true; + } + } + if (!te_grow((void **)&c->fallbacks, &c->fallback_cap, c->fallback_count, + sizeof(*c->fallbacks))) { + return te_oom(c); + } + c->fallbacks[c->fallback_count++] = code; + return true; +} + +/* ── git ─────────────────────────────────────────────────────────── */ + +bool cbm_test_impact_find_git(char *out, size_t out_len) { + const char *path = getenv("PATH"); +#ifdef _WIN32 + const char sep = ';'; + const char *exe = "git.exe"; +#else + const char sep = ':'; + const char *exe = "git"; +#endif + if (!path || !out || out_len == 0) { + return false; + } + const char *p = path; + while (*p) { + const char *e = strchr(p, sep); + size_t len = e ? (size_t)(e - p) : strlen(p); + bool absolute = len > 0 && (p[0] == '/' || (len > 2 && p[1] == ':')); + if (absolute && len + 1 + strlen(exe) < out_len) { + snprintf(out, out_len, "%.*s/%s", (int)len, p, exe); + struct stat st; + if (stat(out, &st) == 0 && S_ISREG(st.st_mode) +#ifndef _WIN32 + && access(out, X_OK) == 0 +#endif + ) { + return true; + } + } + if (!e) { + break; + } + p = e + 1; + } + out[0] = '\0'; + return false; +} + +static bool te_git(te_ctx_t *c) { + char git[4096]; + if (!cbm_test_impact_find_git(git, sizeof(git))) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GIT_UNAVAILABLE, "no git on PATH") && false; + } + cbm_git_facts_options_t options = {.root = c->rq->repo_root, + .base_ref = c->rq->base_ref, + .git_executable = git, + .deadline_ms = c->rq->deadline_ms, + .command_limit = 4096, + .stdout_limit = (size_t)512 << 20, + .stderr_limit = 65536, + .total_output_limit = (size_t)2048 << 20}; + cbm_git_facts_error_t error; + c->facts = cbm_git_facts_open(&options, &error); + if (!c->facts || !cbm_git_facts_diff(c->facts, &c->diff, &error)) { + char text[700]; + snprintf(text, sizeof(text), "git: %s", error.diagnostic); + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GIT_UNAVAILABLE, text) && false; + } + c->id = cbm_git_facts_identity(c->facts); + cbm_changes_status_t status = + cbm_changes_parse(c->diff.name_status.data, c->diff.name_status.length, c->diff.patch.data, + c->diff.patch.length, &c->changes); + if (status == CBM_CHANGES_OOM) { + return te_oom(c); + } + cbm_changes_state_t state = cbm_changes_state(c->changes); + c->diff_complete = status == CBM_CHANGES_OK && state != CBM_CHANGES_UNKNOWN && + cbm_changes_patch_reconciled(c->changes); + c->has_changes = state != CBM_CHANGES_EMPTY; + return true; +} + +/* ── Query-time configuration: the merge base's (M-4) ────────────── */ + +static bool te_config(te_ctx_t *c) { + const char *path = c->rq->config_path; + char file[4200] = ""; + bool optional = false; + if (!path) { + snprintf(file, sizeof(file), "%s/merge-base-config.json", c->work_dir); + path = file; + optional = true; + cbm_git_blob_t blob; + cbm_git_facts_error_t error; + static const char name[] = ".codebase-memory.json"; + cbm_git_blob_status_t found = cbm_git_facts_read_blob( + c->facts, CBM_GIT_REV_MERGE_BASE, name, sizeof(name) - 1, &blob, &error); + if (found == CBM_GIT_BLOB_ERROR) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_CONFIG_INVALID, + "the merge base's .codebase-memory.json could not be read") && + false; + } + if (found == CBM_GIT_BLOB_FOUND) { + FILE *f = cbm_fopen(file, "wb"); + bool ok = f && fwrite(blob.bytes.data, 1, blob.bytes.length, f) == blob.bytes.length; + if (f) { + ok = fclose(f) == 0 && ok; + } + if (!ok) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_CONFIG_INVALID, + "the merge base's configuration could not be staged") && + false; + } + } + } + c->config = cbm_test_config_load(path, optional); + if (optional) { + (void)cbm_unlink(file); /* only the merge-base copy staged above */ + } + if (!c->config) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_CONFIG_INVALID, + "the test_impact configuration is invalid") && + false; + } + c->policy = cbm_test_policy_new(c->config); + if (!c->policy) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_POLICY_UNAVAILABLE, + "the test_impact rules are invalid") && + false; + } + return true; +} + +/* ── Classification of the changed paths ─────────────────────────── */ + +static bool te_match(te_ctx_t *c, int rule_index) { + for (int i = 0; i < c->match_count; i++) { + if (c->matches[i].rule_index == rule_index) { + return true; + } + } + if (!te_grow((void **)&c->matches, &c->match_cap, c->match_count, sizeof(*c->matches))) { + return te_oom(c); + } + c->matches[c->match_count++] = (te_match_t){.rule_index = rule_index, .resolved = true}; + return true; +} + +static bool te_fixture_path(te_ctx_t *c, const char *path, int rule_index) { + if (!te_grow((void **)&c->fixture_paths, &c->fixture_cap, c->fixture_count, + sizeof(*c->fixture_paths)) || + !te_grow((void **)&c->fixture_rules, &c->fixture_rule_cap, c->fixture_count, + sizeof(*c->fixture_rules))) { + return te_oom(c); + } + c->fixture_paths[c->fixture_count] = path; + c->fixture_rules[c->fixture_count++] = rule_index; + return true; +} + +static bool te_c_family(const char *path) { + static const char *const ext[] = {".c", ".h", ".cc", ".cpp", ".hpp", + ".m", ".mm", ".cxx", ".hh"}; + size_t n = strlen(path); + for (size_t i = 0; i < sizeof(ext) / sizeof(ext[0]); i++) { + size_t e = strlen(ext[i]); + if (n > e && strcmp(path + n - e, ext[i]) == 0) { + return true; + } + } + return false; +} + +/* First pass: the rules. A path no rule matches is decided after the + * snapshot (te_classify_unmatched). Returns whether the snapshot is needed: + * a run-all rule decides everything without it. */ +static bool te_classify_rules(te_ctx_t *c, bool *need_snapshot, bool **unmatched) { + size_t count = 0; + const cbm_change_file_t *files = cbm_changes_files(c->changes, &count); + int rule_count = 0; + const cbm_test_rule_t *rules = cbm_test_policy_rules(c->policy, &rule_count); + *unmatched = cbm_arena_calloc(&c->arena, count + 1); + if (!*unmatched) { + return te_oom(c); + } + bool run_all = false; + c->activation_complete = true; + for (size_t i = 0; i < count; i++) { + const char *path = (const char *)files[i].path; + const cbm_test_rule_t *rule = NULL; + if (!cbm_test_policy_match_path(c->policy, path, &rule)) { + c->activation_complete = false; + if (!te_fallback(c, CBM_TEST_RESULT_FALLBACK_POLICY_EVALUATION_FAILED, path)) { + return false; + } + continue; + } + if (!rule) { + (*unmatched)[i] = true; + continue; + } + int index = (int)(rule - rules); + if (!te_match(c, index)) { + return false; + } + run_all = run_all || rule->action == CBM_TEST_RULE_RUN_ALL; + if (rule->action == CBM_TEST_RULE_REFERENCING_TESTS && !te_fixture_path(c, path, index)) { + return false; + } + } + *need_snapshot = !run_all && c->fallback_count == 0; + return true; +} + +static bool te_in_ledger(te_ctx_t *c, const char *path) { + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(c->tree); + size_t n = 0; + const cbm_test_impact_classified_t *rows = cbm_test_impact_ledger_rows(c->ledger, &n); + size_t lo = 0; + size_t hi = n; + /* Ledger rows follow the tree's raw-path order. */ + while (lo < hi) { + size_t mid = lo + (hi - lo) / 2; + int cmp = strcmp((const char *)view->files[rows[mid].file_index].path, path); + if (cmp == 0) { + return true; + } + if (cmp < 0) { + lo = mid + 1; + } else { + hi = mid; + } + } + return false; +} + +static bool te_add_seed_change(te_ctx_t *c, const cbm_change_file_t *f) { + if (!te_grow((void **)&c->seed_changes, &c->seed_change_cap, c->seed_change_count, + sizeof(*c->seed_changes))) { + return te_oom(c); + } + cbm_ti_change_t change = {.path = (const char *)f->path}; + if (f->status == 'D') { + change.deleted = true; + cbm_git_blob_t blob; + cbm_git_facts_error_t error; + cbm_git_blob_status_t found = cbm_git_facts_read_blob( + c->facts, CBM_GIT_REV_MERGE_BASE, (const char *)f->path, f->path_length, &blob, &error); + if (found != CBM_GIT_BLOB_FOUND) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GIT_UNAVAILABLE, + "a deleted file's base text could not be read"); + } + change.base_text = (const char *)blob.bytes.data; + change.base_len = blob.bytes.length; + } else if (f->evidence == CBM_CHANGE_HUNKS && f->patch_file) { + change.hunks = f->patch_file->hunks; + change.hunk_count = f->patch_file->hunk_count; + } else { + change.whole_file = true; + } + c->seed_changes[c->seed_change_count++] = change; + return true; +} + +/* Second pass: a path no rule matched is read through the graph when it is + * a C-family source of the snapshot (or deleted from it); anything else is a + * change the test-runner lane cannot reason about (M-10): run everything. */ +static bool te_classify_unmatched(te_ctx_t *c, const bool *unmatched) { + size_t count = 0; + const cbm_change_file_t *files = cbm_changes_files(c->changes, &count); + for (size_t i = 0; i < count; i++) { + if (!unmatched[i]) { + continue; + } + const char *path = (const char *)files[i].path; + bool readable = te_c_family(path) && (files[i].status == 'D' || te_in_ledger(c, path)); + bool ok = readable ? te_add_seed_change(c, &files[i]) + : te_fallback(c, CBM_TEST_RESULT_FALLBACK_UNMAPPED_FILE, path); + if (!ok) { + return false; + } + } + return true; +} + +/* ── Graph scope, seeding, selection ─────────────────────────────── */ + +static bool te_open_scope(te_ctx_t *c) { + if (cbm_store_read_scope_open(c->store, NULL, NULL, &c->scope) != CBM_STORE_OK) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "the graph of HEAD could not be read") && + false; + } + static const unsigned char project[] = "test-impact"; + cbm_store_graph_digest_limits_t limits = {.max_rows = (uint64_t)1 << 32, + .max_framed_bytes = (uint64_t)1 << 40}; + cbm_store_graph_digest_t digest; + if (cbm_store_graph_digest(c->scope, project, sizeof(project) - 1, &limits, &digest) != + CBM_STORE_GRAPH_DIGEST_OK) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_REJECTED, + "the graph of HEAD could not be digested") && + false; + } + memcpy(c->graph_sha256, digest.sha256, sizeof(c->graph_sha256)); + c->graph_digest = true; + return true; +} + +static bool te_seed(te_ctx_t *c) { + cbm_ti_seed_input_t in = {.changes = c->seed_changes, + .change_count = c->seed_change_count, + .model = c->model, + .graph = c->graph, + .source = c->source}; + cbm_ti_seed_status_t status = cbm_ti_seed(&in, &c->seeds); + if (status == CBM_TI_SEED_OOM) { + return te_oom(c); + } + if (status != CBM_TI_SEED_OK) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "seeding the graph failed") && + false; + } + return true; +} + +static bool te_select(te_ctx_t *c) { + cbm_test_selection_input_t input = {.model = c->model, + .reach = c->reach, + .reach_count = c->reach_count, + .suite_triggers = c->triggers, + .suite_trigger_count = c->trigger_count, + .has_changes = c->has_changes, + .diff_complete = c->diff_complete, + .inventory_complete = c->snapshot_ok, + .static_complete = c->static_complete, + .coverage_admitted = false}; + c->selection = cbm_test_select(&input); + if (!c->selection) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_SELECTION_UNAVAILABLE, + "the selection could not be computed"); + } + return true; +} + +/* ── The answer ──────────────────────────────────────────────────── */ + +static bool te_hex_bytes(const char *hex, unsigned char *out, size_t count) { + if (!hex || strlen(hex) != count * 2) { + return false; + } + for (size_t i = 0; i < count; i++) { + unsigned v = 0; + for (int k = 0; k < 2; k++) { + char ch = hex[i * 2 + (size_t)k]; + unsigned d = ch >= '0' && ch <= '9' ? (unsigned)(ch - '0') + : ch >= 'a' && ch <= 'f' ? (unsigned)(ch - 'a' + 10) + : ch >= 'A' && ch <= 'F' ? (unsigned)(ch - 'A' + 10) + : 16U; + if (d > 15) { + return false; + } + v = v * 16 + d; + } + out[i] = (unsigned char)v; + } + return true; +} + +static cbm_test_result_oid_t te_oid(const te_ctx_t *c, const char *hex) { + cbm_test_result_oid_t oid = {0}; + if (!c->id || !hex || !hex[0]) { + return oid; + } + size_t bytes = c->id->oid_hex_length == 64 ? 32 : 20; + if (te_hex_bytes(hex, oid.bytes, bytes)) { + oid.format = bytes == 32 ? CBM_TEST_RESULT_OBJECT_SHA256 : CBM_TEST_RESULT_OBJECT_SHA1; + } else { + memset(oid.bytes, 0, sizeof(oid.bytes)); + } + return oid; +} + +static cbm_test_result_digest_t te_digest_hex(const char *hex) { + cbm_test_result_digest_t d = {0}; + d.present = te_hex_bytes(hex, d.bytes, 32); + if (!d.present) { + memset(d.bytes, 0, sizeof(d.bytes)); + } + return d; +} + +static cbm_test_result_digest_t te_digest_of(const cbm_git_bytes_t *b) { + cbm_test_result_digest_t d = {.present = true}; + cbm_sha256_ctx ctx; + cbm_sha256_init(&ctx); + cbm_sha256_update(&ctx, b->data ? b->data : (const unsigned char *)"", b->length); + cbm_sha256_final(&ctx, d.bytes); + return d; +} + +static void te_receipt(te_ctx_t *c, cbm_test_result_receipt_t *r, + cbm_test_result_evidence_reason_t *graph_reason) { + memset(r, 0, sizeof(*r)); + r->routes = CBM_TEST_RESULT_ROUTES_FOLLOWED; + if (c->id) { + r->object_format = c->id->oid_hex_length == 64 ? CBM_TEST_RESULT_OBJECT_SHA256 + : CBM_TEST_RESULT_OBJECT_SHA1; + r->base = te_oid(c, c->id->base); + r->head = te_oid(c, c->id->head); + r->merge_base = te_oid(c, c->id->merge_base); + } + if (c->changes) { + r->diff_sha256 = te_digest_of(&c->diff.patch); + r->name_status_sha256 = te_digest_of(&c->diff.name_status); + } + if (c->config) { + r->config_sha256 = te_digest_hex(cbm_test_config_digest(c->config)); + } + if (c->policy) { + r->policy_sha256 = te_digest_hex(cbm_test_policy_digest(c->policy)); + } + if (c->graph_digest) { + r->graph.state = CBM_TEST_RESULT_GRAPH_CERTIFIED; + r->graph.commit = r->head; + r->graph.sha256.present = true; + memcpy(r->graph.sha256.bytes, c->graph_sha256, 32); + } else if (c->tree) { + r->graph.state = CBM_TEST_RESULT_GRAPH_REJECTED; + *graph_reason = CBM_TEST_RESULT_EVIDENCE_DIAGNOSTICS_INCOMPLETE; + r->graph.rejection_reasons = (cbm_test_result_evidence_reasons_t){graph_reason, 1}; + } + r->coverage.state = CBM_TEST_RESULT_COVERAGE_UNAVAILABLE; +} + +static cbm_test_impact_status_t te_answer(te_ctx_t *c, cbm_test_result_t **out) { + cbm_test_result_rule_match_t *matches = + cbm_arena_calloc(&c->arena, (size_t)(c->match_count + 1) * sizeof(*matches)); + if (!matches) { + return CBM_TEST_IMPACT_OOM; + } + for (int i = 0; i < c->match_count; i++) { + matches[i] = (cbm_test_result_rule_match_t){.rule_index = c->matches[i].rule_index, + .target_resolved = c->matches[i].resolved}; + } + cbm_test_result_receipt_t receipt; + cbm_test_result_evidence_reason_t graph_reason = CBM_TEST_RESULT_EVIDENCE_PROVIDER_UNAVAILABLE; + te_receipt(c, &receipt, &graph_reason); + cbm_test_result_comparison_t comparison = !c->changes || !c->diff_complete + ? CBM_TEST_RESULT_COMPARISON_UNAVAILABLE + : c->has_changes ? CBM_TEST_RESULT_COMPARISON_CHANGED + : CBM_TEST_RESULT_COMPARISON_EMPTY; + if (comparison == CBM_TEST_RESULT_COMPARISON_UNAVAILABLE && c->fallback_count == 0 && + !te_fallback(c, CBM_TEST_RESULT_FALLBACK_GIT_UNAVAILABLE, "the change is not complete")) { + return CBM_TEST_IMPACT_OOM; + } + bool with_selection = c->selection && comparison == CBM_TEST_RESULT_COMPARISON_CHANGED; + cbm_test_result_input_t input = { + .comparison = comparison, + .selection = with_selection ? c->selection : NULL, + .model = with_selection ? c->model : NULL, + .policy = comparison == CBM_TEST_RESULT_COMPARISON_EMPTY ? NULL : c->policy, + .inventory_complete = c->snapshot_ok, + .activation_complete = c->activation_complete, + .matched_rules = comparison == CBM_TEST_RESULT_COMPARISON_EMPTY ? NULL : matches, + .matched_rule_count = + comparison == CBM_TEST_RESULT_COMPARISON_EMPTY ? 0 : (size_t)c->match_count, + .fallbacks = c->fallbacks, + .fallback_count = (size_t)c->fallback_count, + .warnings = c->warnings, + .warning_count = (size_t)c->warning_count, + .receipt = &receipt}; + cbm_test_result_limits_t limits = {.max_input_bytes = (uint64_t)1 << 30, + .max_items = (uint64_t)1 << 26, + .max_alloc_bytes = (size_t)1 << 30, + .max_output_bytes = (size_t)256 << 20}; + cbm_test_result_status_t status = cbm_test_result_build(&input, &limits, NULL, NULL, out); + if (status == CBM_TEST_RESULT_OOM) { + return CBM_TEST_IMPACT_OOM; + } + if (status != CBM_TEST_RESULT_OK) { + char text[64]; + snprintf(text, sizeof(text), "result codec status %d", (int)status); + te_note(c, text); + return CBM_TEST_IMPACT_INVALID; + } + return CBM_TEST_IMPACT_OK; +} + +/* ── The request ─────────────────────────────────────────────────── */ + +static void te_free(te_ctx_t *c) { + cbm_test_selection_free(c->selection); + cbm_ti_seeds_free(c->seeds); + if (c->scope) { + (void)cbm_store_read_scope_close(c->scope); + } + te_snapshot_free(c); + cbm_test_policy_free(c->policy); + cbm_test_config_free(c->config); + cbm_changes_free(c->changes); + cbm_git_facts_free(c->facts); + void *arrays[] = {c->matches, c->fallbacks, c->warnings, c->seed_changes, + c->fixture_paths, c->fixture_rules, c->triggers}; + for (size_t i = 0; i < sizeof(arrays) / sizeof(arrays[0]); i++) { + cbm_free(CBM_MEM_CLASS_EXTRACT, arrays[i]); + } + cbm_arena_destroy(&c->arena); +} + +static bool te_valid(const cbm_test_impact_request_t *rq) { + return rq && rq->repo_root && rq->repo_root[0] && rq->base_ref && rq->base_ref[0] && + rq->work_parent && rq->work_parent[0] && rq->deadline_ms > 0; +} + +/* The evidence steps. Each false is either a recorded run-all reason (the + * answer still comes) or exhausted memory (c->oom). */ +static void te_steps(te_ctx_t *c) { + if (!te_work_dir(c) || !te_git(c) || !te_config(c)) { + return; + } + if (!c->has_changes || !c->diff_complete) { + return; /* nothing changed, or the change is not known in full */ + } + bool need_snapshot = false; + bool *unmatched = NULL; + if (!te_classify_rules(c, &need_snapshot, &unmatched) || !need_snapshot) { + return; + } + if (!te_snapshot(c) || !te_classify_unmatched(c, unmatched) || c->fallback_count) { + return; + } + if (!te_open_scope(c) || !te_seed(c) || !te_reach(c)) { + return; + } + (void)te_select(c); +} + +typedef struct { + const cbm_test_impact_request_t *request; + cbm_test_result_t **out; + char *diagnostic; + size_t diagnostic_len; + cbm_test_impact_status_t status; +} te_job_t; + +static void *te_job_main(void *arg) { + te_job_t *job = arg; + te_ctx_t c = { + .rq = job->request, .diagnostic = job->diagnostic, .diagnostic_len = job->diagnostic_len}; + cbm_arena_init(&c.arena); + te_steps(&c); + cbm_test_impact_status_t status = c.oom ? CBM_TEST_IMPACT_OOM : te_answer(&c, job->out); + if (!c.oom && cbm_now_ms() > job->request->deadline_ms && status == CBM_TEST_IMPACT_OK) { + cbm_test_result_free(*job->out); + *job->out = NULL; + status = CBM_TEST_IMPACT_CANCELLED; + } + te_free(&c); + job->status = status; + return NULL; +} + +cbm_test_impact_status_t cbm_test_impact_run(const cbm_test_impact_request_t *request, + cbm_test_result_t **out, char *diagnostic, + size_t diagnostic_len) { + if (diagnostic && diagnostic_len) { + diagnostic[0] = '\0'; + } + if (!out) { + return CBM_TEST_IMPACT_INVALID; + } + *out = NULL; + if (!te_valid(request)) { + return CBM_TEST_IMPACT_INVALID; + } + te_job_t job = {.request = request, + .out = out, + .diagnostic = diagnostic, + .diagnostic_len = diagnostic_len, + .status = CBM_TEST_IMPACT_OOM}; + /* The request runs a whole (frozen) index pipeline. Daemon tool calls run + * on 256 KiB application threads; the pipeline needs the stack every + * pipeline thread gets (cbm_thread_create's default), so the request runs + * on its own thread whoever calls it. On cbm, the in-daemon call died + * silently inside the pipeline without this. */ + cbm_thread_t thread; + if (cbm_thread_create(&thread, 0, te_job_main, &job) != 0) { + return CBM_TEST_IMPACT_OOM; + } + (void)cbm_thread_join(&thread); + return job.status; +} diff --git a/src/mcp/test_impact_engine.h b/src/mcp/test_impact_engine.h new file mode 100644 index 000000000..89cc50050 --- /dev/null +++ b/src/mcp/test_impact_engine.h @@ -0,0 +1,55 @@ +/* + * test_impact_engine.h — detect_changes scope:"tests", end to end. + * + * One request pins the change (merge base .. HEAD of a git worktree), builds + * the graph and the runnable test registry of exactly the HEAD snapshot, + * seeds the graph from the change, walks what depends on the seeds, maps + * reached tests to their suites and lanes, and returns the result codec's + * answer (cbm.test_impact). The selection rule is the measured rule B: + * a test runs when the static walk reaches it, when it changed, or when its + * recorded coverage meets the change. + * + * Missing or doubtful evidence never narrows: git, configuration, graph, + * registry or coverage that cannot be established turns into a run-all + * answer that names why. Only a malformed request, exhausted memory or the + * deadline end the request without an answer. + */ +#ifndef CBM_TEST_IMPACT_ENGINE_H +#define CBM_TEST_IMPACT_ENGINE_H + +#include "mcp/test_impact_result.h" + +#include +#include +#include + +typedef struct { + const char *repo_root; /* absolute top level of the git worktree */ + const char *base_ref; /* commit-ish the change merges into */ + /* A private directory (mode 0700) for the pinned snapshot and its graph; + * the request creates and removes its own entries inside. */ + const char *work_parent; + /* NULL: the test_impact section of the merge base's .codebase-memory.json + * (a change cannot loosen its own selection). Otherwise that file. */ + const char *config_path; + uint64_t deadline_ms; /* absolute cbm_now_ms() deadline */ +} cbm_test_impact_request_t; + +typedef enum { + CBM_TEST_IMPACT_OK = 0, /* an answer: selected, nothing, or run-all with reasons */ + CBM_TEST_IMPACT_INVALID, + CBM_TEST_IMPACT_OOM, + CBM_TEST_IMPACT_CANCELLED +} cbm_test_impact_status_t; + +/* *out owns the answer (cbm_test_result_json). diagnostic receives a short + * reason for a non-OK status and for every run-all fallback. */ +cbm_test_impact_status_t cbm_test_impact_run(const cbm_test_impact_request_t *request, + cbm_test_result_t **out, char *diagnostic, + size_t diagnostic_len); + +/* The absolute path of the `git` the engine runs: the first regular, + * executable `git` (`git.exe` on Windows) on PATH. false when none. */ +bool cbm_test_impact_find_git(char *out, size_t out_len); + +#endif /* CBM_TEST_IMPACT_ENGINE_H */ diff --git a/src/mcp/test_impact_engine_internal.h b/src/mcp/test_impact_engine_internal.h new file mode 100644 index 000000000..2fae9eaf4 --- /dev/null +++ b/src/mcp/test_impact_engine_internal.h @@ -0,0 +1,115 @@ +/* + * test_impact_engine_internal.h — the state of one scope:"tests" request, + * shared by the engine's steps (test_impact_engine*.c). + */ +#ifndef CBM_TEST_IMPACT_ENGINE_INTERNAL_H +#define CBM_TEST_IMPACT_ENGINE_INTERNAL_H + +#include "mcp/test_impact.h" +#include "mcp/test_impact_changes.h" +#include "mcp/test_impact_classify.h" +#include "mcp/test_impact_engine.h" +#include "mcp/test_impact_git.h" +#include "mcp/test_impact_inventory.h" +#include "mcp/test_impact_result.h" +#include "mcp/test_impact_seed.h" +#include "mcp/test_impact_source.h" +#include "mcp/test_impact_tree.h" +#include "discover/test_conventions.h" +#include "foundation/arena.h" +#include "store/store.h" + +#include +#include +#include + +/* Every rule, fallback, warning and path the request records lives in the + * arena; dynamic arrays grow through the memory core. */ +typedef struct { + int rule_index; + bool resolved; /* every path it matched resolved its target */ +} te_match_t; + +typedef struct { + const cbm_test_impact_request_t *rq; + char *diagnostic; + size_t diagnostic_len; + CBMArena arena; + bool oom; + + /* git */ + cbm_git_facts_t *facts; + const cbm_git_facts_identity_t *id; + cbm_git_diff_t diff; + cbm_changes_t *changes; + bool diff_complete; + bool has_changes; + + /* query-time configuration (the merge base's, M-4) */ + cbm_test_config_t *config; + cbm_test_policy_t *policy; + + /* the HEAD snapshot */ + cbm_pinned_tree_t *tree; + cbm_test_impact_inventory_t *inventory; + cbm_test_impact_ledger_t *ledger; + cbm_test_declarations_t *declarations; + cbm_test_model_t *model; + char work_dir[4096]; + char candidate_db[4096]; + cbm_store_t *store; + cbm_store_read_scope_t *scope; /* one stable read of the candidate graph */ + unsigned char graph_sha256[32]; + bool graph_digest; + cbm_ti_graph_t *graph; + cbm_ti_source_t *source; + bool snapshot_ok; + + /* classification of the changed paths */ + te_match_t *matches; + int match_count; + int match_cap; + cbm_test_result_fallback_t *fallbacks; + int fallback_count; + int fallback_cap; + cbm_test_result_warning_t *warnings; + int warning_count; + int warning_cap; + cbm_ti_change_t *seed_changes; /* C-family paths the graph seeding reads */ + int seed_change_count; + int seed_change_cap; + const char **fixture_paths; /* paths a `referencing_tests` rule matched */ + int *fixture_rules; /* their rule index */ + int fixture_rule_cap; + int fixture_count; + int fixture_cap; + bool activation_complete; + + /* selection inputs */ + cbm_ti_seeds_t *seeds; + cbm_test_reach_t *reach; + int reach_count; + cbm_test_suite_trigger_t *triggers; + int trigger_count; + int trigger_cap; + bool static_complete; + + cbm_test_selection_t *selection; +} te_ctx_t; + +/* Record a run-all reason once; the first diagnostic wins. false only when + * out of memory. */ +bool te_fallback(te_ctx_t *c, cbm_test_result_fallback_t code, const char *diagnostic); +bool te_grow(void **items, int *cap, int count, size_t size); +bool te_oom(te_ctx_t *c); + +/* test_impact_engine_snapshot.c */ +bool te_work_dir(te_ctx_t *c); +bool te_snapshot(te_ctx_t *c); +void te_snapshot_free(te_ctx_t *c); + +/* test_impact_engine_reach.c */ +bool te_fixtures(te_ctx_t *c, int64_t **extra_seeds, int *extra_count, bool **fixture_cases); +bool te_reach(te_ctx_t *c); + +#endif /* CBM_TEST_IMPACT_ENGINE_INTERNAL_H */ diff --git a/src/mcp/test_impact_engine_reach.c b/src/mcp/test_impact_engine_reach.c new file mode 100644 index 000000000..a3f96d0ba --- /dev/null +++ b/src/mcp/test_impact_engine_reach.c @@ -0,0 +1,694 @@ +/* + * test_impact_engine_reach.c — from seeds to the selection's inputs: the + * fixture scan, the walk (rule B: the static walk without the process lift), + * reach rows for every registered test, and whole-suite triggers. + * + * Ports replay4.py run() 474-489 (cases, suites and runner as sinks), 584-615 + * (fixtures by path literal) and 616-621 (header file-level escalation), and + * score4.py static_nolift (reached and changed suite bodies run whole; a + * reached runner main runs everything). + */ +#include "mcp/test_impact_engine_internal.h" + +#include "foundation/hash_table.h" +#include "foundation/mem_core.h" +#include "store/store_impact.h" + +#include +#include + +/* The c-cbm runner's process-entry handlers: the runner re-executes itself + * with a role argument and these run product code in that child. They are + * reached, never expanded (their caller is the runner's main). */ +#define TE_RUNNER_ROLE_PREFIX "tf_maybe_run_" + +static const char *const te_walk_types[] = {"CALLS", "ASYNC_CALLS", "USAGE", "CALL_REFERENCE", + "READS"}; + +typedef struct { + int64_t *items; + int count; + int cap; +} te_ids_t; + +static bool te_ids_push(te_ctx_t *c, te_ids_t *a, int64_t id) { + if (!te_grow((void **)&a->items, &a->cap, a->count, sizeof(*a->items))) { + return te_oom(c); + } + a->items[a->count++] = id; + return true; +} + +static bool te_is_function(const cbm_ti_node_t *n) { + return strcmp(n->label, "Function") == 0 || strcmp(n->label, "Method") == 0; +} + +/* The function nodes of a test case or suite of `file`: those whose + * definition starts on the line of its TEST(...) / SUITE(...) — graphs name + * them differently (`x` without configured declarations, `TEST_x` with) — + * else every function named `name`. *count of them, ids appended. */ +static bool te_named(te_ctx_t *c, const char *file, const char *name, int line, te_ids_t *out, + int *count) { + bool ok = true; + int n = 0; + const cbm_ti_node_t *nodes = cbm_ti_graph_file(c->graph, file, &n, &ok); + if (!ok) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, "graph read failed") && + false; + } + *count = 0; + for (int pass = 0; pass < 2 && *count == 0; pass++) { + for (int i = 0; i < n; i++) { + bool match = + te_is_function(&nodes[i]) && + (pass == 0 ? nodes[i].start_line == line : strcmp(nodes[i].name, name) == 0); + if (!match) { + continue; + } + (*count)++; + if (out && !te_ids_push(c, out, nodes[i].id)) { + return false; + } + } + } + return true; +} + +static bool te_is_runner_suite(te_ctx_t *c, const char *name) { + int n = 0; + const cbm_test_runner_suite_t *runner = cbm_test_model_runner_suites(c->model, &n); + for (int i = 0; i < n; i++) { + if (strcmp(runner[i].name, name) == 0) { + return true; + } + } + return false; +} + +/* ── Fixtures (replay4 584-615) ──────────────────────────────────── */ + +/* Test files: the files that define a suite. */ +static bool te_test_file(te_ctx_t *c, const char *path) { + int n = 0; + const cbm_test_suite_t *suites = cbm_test_model_suites(c->model, &n); + for (int i = 0; i < n; i++) { + if (strcmp(suites[i].file, path) == 0) { + return true; + } + } + return false; +} + +static int te_case_index(te_ctx_t *c, const char *file, const char *name) { + int n = 0; + const cbm_test_case_t *cases = cbm_test_model_cases(c->model, &n); + for (int i = 0; i < n; i++) { + if (strcmp(cases[i].file, file) == 0 && strcmp(cases[i].name, name) == 0) { + return i; + } + } + return -1; +} + +/* Every line of a test file that contains `needle`: its enclosing + * definition is a fixture case or a seed. */ +static bool te_scan_literal(te_ctx_t *c, const char *needle, te_ids_t *seeds, bool *cases, + bool *hit) { + int files = cbm_ti_source_file_count(c->source); + size_t nlen = strlen(needle); + for (int f = 0; f < files; f++) { + const char *path = cbm_ti_source_path(c->source, f); + if (!te_test_file(c, path)) { + continue; + } + int lines = cbm_ti_source_line_count(c->source, f); + for (int line = 1; line <= lines; line++) { + size_t len = 0; + const char *text = cbm_ti_source_line(c->source, f, line, &len); + bool found = false; + for (size_t i = 0; !found && nlen && i + nlen <= len; i++) { + found = memcmp(text + i, needle, nlen) == 0; + } + if (!found) { + continue; + } + bool ok = true; + const cbm_ti_node_t *d = cbm_ti_graph_enclosing(c->graph, path, line, &ok); + if (!ok) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "graph read failed") && + false; + } + if (!d) { + continue; + } + *hit = true; + int ci = te_is_function(d) ? te_case_index(c, path, d->name) : -1; + if (ci >= 0) { + cases[ci] = true; + } else if (!te_ids_push(c, seeds, d->id)) { + return false; + } + } + } + return true; +} + +/* One fixture path: the path itself, then each parent down to the rule's + * key segment; each with and without its first segment. The first key some + * test file mentions decides. */ +static bool te_fixture(te_ctx_t *c, const char *path, int min_segments, te_ids_t *seeds, + bool *cases, bool *hit) { + char key[4096]; + size_t len = strlen(path); + if (len >= sizeof(key)) { + return true; /* unresolved: run-all through the rule */ + } + memcpy(key, path, len + 1); + int segments = 1; + for (size_t i = 0; i < len; i++) { + segments += key[i] == '/'; + } + *hit = false; + while (!*hit && segments >= min_segments) { + const char *rest = strchr(key, '/'); + if (!te_scan_literal(c, key, seeds, cases, hit) || + (rest && !te_scan_literal(c, rest + 1, seeds, cases, hit))) { + return false; + } + char *cut = strrchr(key, '/'); + if (!cut) { + break; + } + *cut = '\0'; + segments--; + } + return true; +} + +bool te_fixtures(te_ctx_t *c, int64_t **extra_seeds, int *extra_count, bool **fixture_cases) { + int case_count = 0; + (void)cbm_test_model_cases(c->model, &case_count); + bool *cases = cbm_arena_calloc(&c->arena, (size_t)case_count + 1); + te_ids_t seeds = {0}; + if (!cases) { + return te_oom(c); + } + int rule_count = 0; + const cbm_test_rule_t *rules = cbm_test_policy_rules(c->policy, &rule_count); + for (int i = 0; i < c->fixture_count; i++) { + int r = c->fixture_rules[i]; + bool hit = false; + if (!te_fixture(c, c->fixture_paths[i], rules[r].key_segment + 1, &seeds, cases, &hit)) { + cbm_free(CBM_MEM_CLASS_EXTRACT, seeds.items); + return false; + } + for (int m = 0; !hit && m < c->match_count; m++) { + if (c->matches[m].rule_index == r) { + c->matches[m].resolved = false; /* no test names it: run-all */ + } + } + } + *extra_seeds = seeds.items; + *extra_count = seeds.count; + *fixture_cases = cases; + return true; +} + +/* ── The walk ────────────────────────────────────────────────────── */ + +typedef struct { + te_ids_t sinks; + te_ids_t runner_main; +} te_sinks_t; + +static bool te_runner_sinks(te_ctx_t *c, te_sinks_t *s) { + int n = 0; + const cbm_test_runner_suite_t *runner = cbm_test_model_runner_suites(c->model, &n); + for (int i = 0; i < n; i++) { + if (!runner[i].file || + (i > 0 && runner[i - 1].file && strcmp(runner[i - 1].file, runner[i].file) == 0)) { + continue; + } + bool ok = true; + int count = 0; + const cbm_ti_node_t *nodes = cbm_ti_graph_file(c->graph, runner[i].file, &count, &ok); + if (!ok) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "graph read failed") && + false; + } + for (int k = 0; k < count; k++) { + if (!te_is_function(&nodes[k])) { + continue; + } + bool main_fn = strcmp(nodes[k].name, "main") == 0; + bool role = strncmp(nodes[k].name, TE_RUNNER_ROLE_PREFIX, + sizeof(TE_RUNNER_ROLE_PREFIX) - 1) == 0; + if ((main_fn || role) && !te_ids_push(c, &s->sinks, nodes[k].id)) { + return false; + } + if (main_fn && !te_ids_push(c, &s->runner_main, nodes[k].id)) { + return false; + } + } + } + return true; +} + +static bool te_collect_sinks(te_ctx_t *c, te_sinks_t *s) { + int n = 0; + int count = 0; + const cbm_test_case_t *cases = cbm_test_model_cases(c->model, &n); + for (int i = 0; i < n; i++) { + if (!te_named(c, cases[i].file, cases[i].name, cases[i].start_line, &s->sinks, &count)) { + return false; + } + } + const cbm_test_suite_t *suites = cbm_test_model_suites(c->model, &n); + for (int i = 0; i < n; i++) { + if (!te_named(c, suites[i].file, suites[i].name, suites[i].start_line, &s->sinks, &count)) { + return false; + } + } + return te_runner_sinks(c, s); +} + +static bool te_walk(te_ctx_t *c, const te_sinks_t *sinks, const int64_t *extra, int extra_count, + cbm_impact_walk_t **out) { + cbm_impact_policy_t policy = {.project = "test-impact", + .edge_types = te_walk_types, + .edge_type_count = + (int)(sizeof(te_walk_types) / sizeof(te_walk_types[0])), + .follow_routes = true}; + *out = NULL; + if (cbm_impact_walk_open_scoped(c->scope, &policy, out) != CBM_STORE_OK) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_ENGINE_SATURATED, + "the impact walk could not start") && + false; + } + int seed_count = 0; + const int64_t *seeds = cbm_ti_seeds_nodes(c->seeds, &seed_count); + bool ok = + cbm_impact_walk_add_sinks(*out, sinks->sinks.items, sinks->sinks.count) == CBM_STORE_OK; + if (ok && seed_count) { + ok = cbm_impact_walk_run(*out, seeds, seed_count) == CBM_STORE_OK; + } + if (ok && extra_count) { + ok = cbm_impact_walk_run(*out, extra, extra_count) == CBM_STORE_OK; + } + if (!ok) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_ENGINE_SATURATED, + "the impact walk failed") && + false; + } + return true; +} + +/* ── Parse gaps (user decision 2026-10-04: handled per file) ──────── + * A partial parse leaves a few lines (index_coverage `parse_partial`, detail + * "a-b,c-d") the graph has no edges from. A gap can only hide references + * written in its own lines, so when a gap's text names a definition the walk + * reached, the definition around the gap is reached too (its whole file when + * the gap lies outside every definition) and the walk goes on, to a + * fixpoint. Only C-family sources are read; tests are C. More, never fewer. */ + +typedef struct { + const char *file; + int start; + int end; + bool fired; +} te_gap_t; + +typedef struct { + te_gap_t *items; + int count; + int cap; +} te_gaps_t; + +static bool te_gap_push(te_ctx_t *c, te_gaps_t *gaps, const char *file, int start, int end) { + if (!te_grow((void **)&gaps->items, &gaps->cap, gaps->count, sizeof(*gaps->items))) { + return te_oom(c); + } + gaps->items[gaps->count++] = (te_gap_t){.file = file, .start = start, .end = end}; + return true; +} + +/* "a-b,c-d,e" line ranges. */ +static bool te_parse_ranges(te_ctx_t *c, te_gaps_t *gaps, const char *file, const char *detail) { + const char *p = detail; + while (p && *p) { + char *end = NULL; + long a = strtol(p, &end, 10); + long b = a; + if (end == p) { + break; + } + if (*end == '-') { + p = end + 1; + b = strtol(p, &end, 10); + } + if (a > 0 && b >= a && b - a < 100000 && !te_gap_push(c, gaps, file, (int)a, (int)b)) { + return false; + } + p = *end == ',' ? end + 1 : NULL; + } + return true; +} + +static bool te_load_gaps(te_ctx_t *c, te_gaps_t *gaps) { + cbm_coverage_row_t *rows = NULL; + int n = 0; + if (cbm_store_coverage_get(c->store, "test-impact", &rows, &n) != CBM_STORE_OK) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "the graph's parse gaps could not be read") && + false; + } + bool ok = true; + for (int i = 0; ok && i < n; i++) { + if (!rows[i].kind || strcmp(rows[i].kind, "parse_partial") != 0 || !rows[i].rel_path || + cbm_ti_source_find(c->source, rows[i].rel_path) < 0) { + continue; + } + const char *file = cbm_arena_strdup(&c->arena, rows[i].rel_path); + ok = + file ? te_parse_ranges(c, gaps, file, rows[i].detail ? rows[i].detail : "") : te_oom(c); + } + cbm_store_free_coverage(rows, n); + return ok; +} + +/* The short names of everything the walk reached (last QN segment). */ +static bool te_reached_names(te_ctx_t *c, cbm_impact_walk_t *walk, CBMHashTable *names) { + const cbm_impact_hit_t *hits = cbm_impact_walk_hits(walk); + int n = cbm_impact_walk_count(walk); + for (int i = 0; i < n; i++) { + const char *qn = hits[i].qualified_name; + const char *dot = qn ? strrchr(qn, '.') : NULL; + const char *name = dot ? dot + 1 : qn; + if (!name || !*name || cbm_ht_get(names, name)) { + continue; + } + const char *owned = cbm_arena_strdup(&c->arena, name); + if (!owned) { + return te_oom(c); + } + cbm_ht_set(names, owned, (void *)1); + } + return true; +} + +static bool te_gap_names_reached(te_ctx_t *c, const te_gap_t *g, CBMHashTable *names) { + int file = cbm_ti_source_find(c->source, g->file); + for (int line = g->start; line <= g->end; line++) { + size_t len = 0; + const char *text = cbm_ti_source_line(c->source, file, line, &len); + for (size_t i = 0; text && i < len;) { + char ch = text[i]; + bool start = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'; + if (!start) { + i++; + continue; + } + size_t e = i; + char word[256]; + while (e < len && + ((text[e] >= 'a' && text[e] <= 'z') || (text[e] >= 'A' && text[e] <= 'Z') || + (text[e] >= '0' && text[e] <= '9') || text[e] == '_')) { + e++; + } + if (e - i < sizeof(word)) { + memcpy(word, text + i, e - i); + word[e - i] = '\0'; + if (cbm_ht_get(names, word)) { + return true; + } + } + i = e; + } + } + return false; +} + +/* Seeds for a gap that fired: the definition around it, else its file. */ +static bool te_gap_seeds(te_ctx_t *c, const te_gap_t *g, te_ids_t *out) { + bool ok = true; + const cbm_ti_node_t *d = cbm_ti_graph_enclosing(c->graph, g->file, g->start, &ok); + if (ok && d) { + return te_ids_push(c, out, d->id); + } + int n = 0; + const cbm_ti_node_t *nodes = ok ? cbm_ti_graph_file(c->graph, g->file, &n, &ok) : NULL; + for (int i = 0; ok && i < n; i++) { + ok = te_ids_push(c, out, nodes[i].id); + } + return ok || te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, "graph read failed"); +} + +static bool te_parse_gaps(te_ctx_t *c, cbm_impact_walk_t *walk) { + te_gaps_t gaps = {0}; + CBMHashTable *names = NULL; + bool ok = te_load_gaps(c, &gaps); + bool grew = ok && gaps.count > 0; + while (ok && grew) { + grew = false; + if (names) { + cbm_ht_free(names); + } + names = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + te_ids_t seeds = {0}; + ok = names && te_reached_names(c, walk, names); + for (int i = 0; ok && i < gaps.count; i++) { + if (!gaps.items[i].fired && te_gap_names_reached(c, &gaps.items[i], names)) { + gaps.items[i].fired = true; + ok = te_gap_seeds(c, &gaps.items[i], &seeds); + } + } + if (ok && seeds.count) { + ok = cbm_impact_walk_run(walk, seeds.items, seeds.count) == CBM_STORE_OK || + (te_fallback(c, CBM_TEST_RESULT_FALLBACK_ENGINE_SATURATED, + "the impact walk failed") && + false); + grew = ok; + } + cbm_free(CBM_MEM_CLASS_EXTRACT, seeds.items); + } + if (names) { + cbm_ht_free(names); + } + cbm_free(CBM_MEM_CLASS_EXTRACT, gaps.items); + return ok; +} + +/* ── Reach rows and triggers ─────────────────────────────────────── */ + +static bool te_trigger(te_ctx_t *c, const char *suite, bool reached, bool changed, bool rule); + +/* Whether a suite of `file` registers tests the model cannot name (a macro + * registers them) or is uncertain: its cases cannot be narrowed one by one. */ +static bool te_whole_only_file(te_ctx_t *c, const char *file) { + int n = 0; + const cbm_test_suite_t *suites = cbm_test_model_suites(c->model, &n); + for (int i = 0; i < n; i++) { + if ((suites[i].macro_registrations || suites[i].uncertain) && + strcmp(suites[i].file, file) == 0) { + return true; + } + } + return false; +} + +/* A reached or changed case of such a file runs every suite of the file + * whole: a macro may register it in any of them (the reference walked on + * from such a case to its suite). */ +static bool te_trigger_file_suites(te_ctx_t *c, const char *file, bool reached, bool changed) { + int n = 0; + const cbm_test_suite_t *suites = cbm_test_model_suites(c->model, &n); + for (int i = 0; i < n; i++) { + if (strcmp(suites[i].file, file) == 0 && + !te_trigger(c, suites[i].name, reached, changed, false)) { + return false; + } + } + return true; +} + +static bool te_reach_rows(te_ctx_t *c, cbm_impact_walk_t *walk, const bool *fixture_cases) { + int n = 0; + const cbm_test_case_t *cases = cbm_test_model_cases(c->model, &n); + int changed_n = 0; + const int *changed = cbm_ti_seeds_changed_cases(c->seeds, &changed_n); + c->reach = cbm_arena_calloc(&c->arena, (size_t)(n ? n : 1) * sizeof(*c->reach)); + if (!c->reach) { + return te_oom(c); + } + for (int i = 0; i < n; i++) { + int at = -1; + for (int k = 0; k < c->reach_count; k++) { + if (strcmp(c->reach[k].file, cases[i].file) == 0 && + strcmp(c->reach[k].test, cases[i].name) == 0) { + at = k; + } + } + te_ids_t ids = {0}; + int count = 0; + if (!te_named(c, cases[i].file, cases[i].name, cases[i].start_line, &ids, &count)) { + cbm_free(CBM_MEM_CLASS_EXTRACT, ids.items); + return false; + } + bool reached = false; + for (int k = 0; k < ids.count; k++) { + reached = reached || cbm_impact_walk_reached(walk, ids.items[k]); + } + cbm_free(CBM_MEM_CLASS_EXTRACT, ids.items); + bool was_changed = fixture_cases[i]; + for (int k = 0; k < changed_n; k++) { + was_changed = was_changed || changed[k] == i; + } + if (te_whole_only_file(c, cases[i].file)) { + if ((reached || was_changed) && + !te_trigger_file_suites(c, cases[i].file, reached, was_changed)) { + return false; + } + continue; + } + if (at >= 0) { + /* the same name twice in one file: ambiguous, never narrowed */ + c->reach[at].mapped = false; + c->reach[at].reached = c->reach[at].reached || reached; + c->reach[at].changed = c->reach[at].changed || was_changed; + continue; + } + c->reach[c->reach_count++] = (cbm_test_reach_t){.file = cases[i].file, + .test = cases[i].name, + .mapped = count == 1, + .reached = reached, + .changed = was_changed}; + } + return true; +} + +static bool te_trigger(te_ctx_t *c, const char *suite, bool reached, bool changed, bool rule) { + if (!te_is_runner_suite(c, suite)) { + return true; /* outside this runner's selection */ + } + if (!te_grow((void **)&c->triggers, &c->trigger_cap, c->trigger_count, sizeof(*c->triggers))) { + return te_oom(c); + } + c->triggers[c->trigger_count++] = (cbm_test_suite_trigger_t){ + .suite = suite, .reached = reached, .changed = changed, .rule = rule}; + return true; +} + +static bool te_suite_triggers(te_ctx_t *c, cbm_impact_walk_t *walk) { + int n = 0; + const cbm_test_suite_t *suites = cbm_test_model_suites(c->model, &n); + int changed_n = 0; + const int *changed = cbm_ti_seeds_changed_suites(c->seeds, &changed_n); + for (int i = 0; i < n; i++) { + te_ids_t ids = {0}; + int count = 0; + if (!te_named(c, suites[i].file, suites[i].name, suites[i].start_line, &ids, &count)) { + cbm_free(CBM_MEM_CLASS_EXTRACT, ids.items); + return false; + } + bool reached = false; + for (int k = 0; k < ids.count; k++) { + reached = reached || cbm_impact_walk_reached(walk, ids.items[k]); + } + cbm_free(CBM_MEM_CLASS_EXTRACT, ids.items); + bool was_changed = false; + for (int k = 0; k < changed_n; k++) { + was_changed = was_changed || changed[k] == i; + } + if ((reached || was_changed) && + !te_trigger(c, suites[i].name, reached, was_changed, false)) { + return false; + } + } + return true; +} + +/* Suites a `suites` rule names, among the runner's. */ +static bool te_rule_triggers(te_ctx_t *c) { + int n = 0; + const cbm_test_runner_suite_t *runner = cbm_test_model_runner_suites(c->model, &n); + int rule_count = 0; + const cbm_test_rule_t *rules = cbm_test_policy_rules(c->policy, &rule_count); + for (int m = 0; m < c->match_count; m++) { + int r = c->matches[m].rule_index; + if (rules[r].action != CBM_TEST_RULE_SUITES) { + continue; + } + for (int i = 0; i < n; i++) { + bool selected = false; + if (!cbm_test_policy_rule_selects_suite(c->policy, r, runner[i].name, runner[i].perf, + &selected)) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_POLICY_EVALUATION_FAILED, + "suite rule evaluation failed"); + } + if (selected && !te_trigger(c, runner[i].name, false, false, true)) { + return false; + } + } + } + return true; +} + +/* A header changed at file level: every suite of every file including it + * runs whole (replay4 616-621). */ +static bool te_header_triggers(te_ctx_t *c) { + int headers_n = 0; + const char *const *headers = cbm_ti_seeds_file_level_headers(c->seeds, &headers_n); + if (!headers_n) { + return true; + } + int files = cbm_ti_source_file_count(c->source); + bool *marked = cbm_arena_calloc(&c->arena, (size_t)files + 1); + if (!marked) { + return te_oom(c); + } + for (int h = 0; h < headers_n; h++) { + int at = cbm_ti_source_find(c->source, headers[h]); + if (at >= 0) { + marked[at] = true; + } + } + cbm_ti_source_include_closure(c->source, marked); + int n = 0; + const cbm_test_suite_t *suites = cbm_test_model_suites(c->model, &n); + for (int i = 0; i < n; i++) { + int at = cbm_ti_source_find(c->source, suites[i].file); + if (at >= 0 && marked[at] && !te_trigger(c, suites[i].name, true, false, false)) { + return false; + } + } + return true; +} + +bool te_reach(te_ctx_t *c) { + int64_t *extra = NULL; + int extra_count = 0; + bool *fixture_cases = NULL; + te_sinks_t sinks = {0}; + cbm_impact_walk_t *walk = NULL; + bool ok = te_fixtures(c, &extra, &extra_count, &fixture_cases) && te_collect_sinks(c, &sinks) && + te_walk(c, &sinks, extra, extra_count, &walk) && te_parse_gaps(c, walk); + if (ok) { + for (int i = 0; i < sinks.runner_main.count; i++) { + if (cbm_impact_walk_reached(walk, sinks.runner_main.items[i])) { + ok = te_fallback(c, CBM_TEST_RESULT_FALLBACK_RUNNER_REACHED, + "the change reaches the test runner's main"); + break; + } + } + } + ok = ok && te_reach_rows(c, walk, fixture_cases) && te_suite_triggers(c, walk) && + te_rule_triggers(c) && te_header_triggers(c); + c->static_complete = ok; + cbm_impact_walk_close(walk); + cbm_free(CBM_MEM_CLASS_EXTRACT, extra); + cbm_free(CBM_MEM_CLASS_EXTRACT, sinks.sinks.items); + cbm_free(CBM_MEM_CLASS_EXTRACT, sinks.runner_main.items); + return ok; +} diff --git a/src/mcp/test_impact_engine_snapshot.c b/src/mcp/test_impact_engine_snapshot.c new file mode 100644 index 000000000..0b5433774 --- /dev/null +++ b/src/mcp/test_impact_engine_snapshot.c @@ -0,0 +1,350 @@ +/* + * test_impact_engine_snapshot.c — the HEAD snapshot of one scope:"tests" + * request: the pinned tree, its classified ledger, the runnable registry, the + * frozen graph of exactly the ledger's files, and the text index of its + * C-family sources. All of it is a function of the pinned HEAD commit (D5): + * nothing reads the worktree. + */ +#include "mcp/test_impact_engine_internal.h" + +#include "discover/discover.h" +#include "discover/userconfig.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/mem_core.h" +#include "foundation/platform.h" +#include "pipeline/pipeline.h" +#include "pipeline/pipeline_internal.h" + +#include +#include +#include + +enum { + TE_MAX_FILES = 1000000, + TE_MAX_DIRECTORIES = 400000, + TE_MAX_CONFIG_BYTES = 65536, +}; + +static bool te_snapshot_fail(te_ctx_t *c, cbm_test_result_fallback_t code, const char *what, + const char *detail) { + char text[512]; + snprintf(text, sizeof(text), "%s%s%s", what, detail && *detail ? ": " : "", + detail ? detail : ""); + (void)te_fallback(c, code, text); + return false; +} + +/* A private (0700) directory for this request under work_parent. */ +bool te_work_dir(te_ctx_t *c) { + snprintf(c->work_dir, sizeof(c->work_dir), "%s/ti-XXXXXX", c->rq->work_parent); + if (!cbm_mkdtemp(c->work_dir)) { + c->work_dir[0] = '\0'; + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "no private work directory", c->rq->work_parent); + } + char sub[4200]; + snprintf(sub, sizeof(sub), "%s/pin", c->work_dir); + bool ok = cbm_mkdir_p(sub, 0700); + snprintf(sub, sizeof(sub), "%s/graph", c->work_dir); + ok = ok && cbm_mkdir_p(sub, 0700); + snprintf(c->candidate_db, sizeof(c->candidate_db), "%s/graph/candidate.db", c->work_dir); + return ok || te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "work directory setup failed", c->work_dir); +} + +static bool te_pin(te_ctx_t *c) { + char parent[4200]; + snprintf(parent, sizeof(parent), "%s/pin", c->work_dir); + cbm_pinned_tree_options_t options = { + .facts = c->facts, + .revision = CBM_GIT_REV_HEAD, + .private_parent = parent, + .limits = {.max_files = TE_MAX_FILES, + .max_directories = TE_MAX_DIRECTORIES, + .max_total_content_bytes = (size_t)8 << 30, + .max_relative_path_bytes = 4095, + .max_arena_bytes = (size_t)2 << 30, + .blob_batch = {.max_entries = TE_MAX_FILES, + .max_input_bytes = (size_t)256 << 20, + .max_arena_bytes = (size_t)8 << 30}}, + .control = {.deadline_ms = c->rq->deadline_ms}}; + cbm_pinned_tree_error_t error; + cbm_pinned_tree_status_t status = cbm_pinned_tree_create(&options, &c->tree, &error); + if (status != CBM_PINNED_TREE_OK) { + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "pinning HEAD failed", error.diagnostic); + } + return true; +} + +static bool te_inventory(te_ctx_t *c) { + cbm_inventory_limits_t limits = {.max_files = TE_MAX_FILES, + .max_directories = TE_MAX_DIRECTORIES, + .max_arena_bytes = (size_t)1 << 30, + .max_ignore_arena_bytes = (size_t)64 << 20, + .max_control_file_bytes = 1 << 20, + .max_control_total_bytes = 64u << 20, + .max_ignore_patterns = 100000, + .max_probe_prefix_bytes = CBM_LANGUAGE_PROBE_MAX, + .max_ignore_work = 1000000000ULL, + .max_verified_file_reads = TE_MAX_FILES, + .max_verified_content_bytes = (uint64_t)16 << 30}; + cbm_inventory_control_t control = {.deadline_ms = c->rq->deadline_ms}; + cbm_inventory_error_t error; + if (cbm_test_impact_inventory_prepare(c->tree, &limits, &control, &c->inventory, &error) != + CBM_INVENTORY_OK) { + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "inventory of HEAD failed", error.diagnostic); + } + return true; +} + +/* A configuration file's bytes, at most 64 KiB; absent leaves NULL. */ +static bool te_read_config(te_ctx_t *c, const char *path, char **bytes, size_t *len) { + *bytes = NULL; + *len = 0; + FILE *f = cbm_fopen(path, "rb"); + if (!f) { + return true; /* absent */ + } + char *buf = cbm_arena_alloc(&c->arena, TE_MAX_CONFIG_BYTES + 1); + size_t got = buf ? fread(buf, 1, TE_MAX_CONFIG_BYTES + 1, f) : 0; + bool err = ferror(f) != 0; + fclose(f); + if (!buf) { + return te_oom(c); + } + if (err || got > TE_MAX_CONFIG_BYTES) { + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_CONFIG_INVALID, + "configuration unreadable or over 64 KiB", path); + } + buf[got] = '\0'; + *bytes = buf; + *len = got; + return true; +} + +/* HEAD's own .codebase-memory.json: what the graph is built under. */ +static bool te_head_config(te_ctx_t *c, char **bytes, size_t *len) { + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(c->tree); + char path[4200]; + snprintf(path, sizeof(path), "%s/.codebase-memory.json", view->root); + return te_read_config(c, path, bytes, len); +} + +/* The test declarations: the request's configuration when it names one + * (read in place of the project file, for replay), else HEAD's. */ +static bool te_ledger_and_registry(te_ctx_t *c, const char *config_bytes, size_t config_len) { + if (c->rq->config_path) { + char *override = NULL; + size_t override_len = 0; + if (!te_read_config(c, c->rq->config_path, &override, &override_len)) { + return false; + } + config_bytes = override; + config_len = override_len; + } + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(c->tree); + cbm_userconfig_t *userconfig = cbm_userconfig_load(view->root); + cbm_inventory_control_t control = {.deadline_ms = c->rq->deadline_ms}; + cbm_inventory_error_t error; + cbm_inventory_status_t status = + cbm_test_impact_classify(c->inventory, c->tree, userconfig, &control, &c->ledger, &error); + cbm_userconfig_free(userconfig); + if (status != CBM_INVENTORY_OK) { + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "classifying HEAD failed", error.diagnostic); + } + c->declarations = cbm_test_declarations_parse(config_bytes, config_len, config_bytes == NULL); + if (!c->declarations) { + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_CONFIG_INVALID, + "HEAD test declarations invalid", NULL); + } + status = cbm_test_impact_registry(c->ledger, c->inventory, c->tree, c->declarations, &control, + &c->model, &error); + if (status != CBM_INVENTORY_OK) { + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "test registry of HEAD failed", error.diagnostic); + } + return true; +} + +static bool te_frozen_build(te_ctx_t *c, const char *config_bytes, size_t config_len) { + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(c->tree); + size_t rows_n = 0; + const cbm_test_impact_classified_t *rows = cbm_test_impact_ledger_rows(c->ledger, &rows_n); + cbm_pipeline_frozen_file_t *list = + cbm_arena_alloc(&c->arena, (rows_n ? rows_n : 1) * sizeof(*list)); + if (!list) { + return te_oom(c); + } + for (size_t i = 0; i < rows_n; i++) { + list[i] = (cbm_pipeline_frozen_file_t){ + .rel_path = (const char *)view->files[rows[i].file_index].path, + .language = rows[i].language}; + } + /* The context takes mutable strings; these are copies of the pinned facts. */ + char root[4096]; + char head[65]; + char merge_base[65]; + char empty[1] = ""; + char branch[] = "pinned"; + snprintf(root, sizeof(root), "%s", view->root); + snprintf(head, sizeof(head), "%s", c->id->head); + snprintf(merge_base, sizeof(merge_base), "%s", c->id->merge_base); + cbm_git_context_t git = {.is_git = true, + .is_detached = true, + .root_exists = true, + .input_path = root, + .worktree_root = root, + .git_dir = empty, + .git_common_dir = empty, + .canonical_root = root, + .branch = branch, + .branch_slug = branch, + .head_sha = head, + .base_sha = merge_base}; + cbm_pipeline_frozen_inputs_t in = {.source_root = view->root, + .candidate_db_path = c->candidate_db, + .project = "test-impact", + .config_state = config_bytes ? CBM_USERCONFIG_SOURCE_PRESENT + : CBM_USERCONFIG_SOURCE_ABSENT, + .config_bytes = config_bytes, + .config_len = config_len, + .pinned_git = &git, + .files = list, + .file_count = rows_n}; + cbm_pipeline_frozen_t *owner = NULL; + cbm_pipeline_frozen_status_t status = cbm_pipeline_frozen_create(&in, &owner); + if (status == CBM_PIPELINE_FROZEN_OK) { + status = cbm_pipeline_frozen_build(owner); + } + char detail[512] = ""; + if (status != CBM_PIPELINE_FROZEN_OK) { + /* Name what refused the graph: the build's negative evidence and its + * first file error. */ + unsigned flags = 0; + cbm_file_error_t *errors = NULL; + int error_count = 0; + (void)cbm_pipeline_frozen_negative_evidence(owner, &flags); + const cbm_pipeline_t *diagnostics = owner ? cbm_pipeline_frozen_diagnostics(owner) : NULL; + if (diagnostics) { + cbm_pipeline_get_file_errors(diagnostics, &errors, &error_count); + } + snprintf(detail, sizeof(detail), "frozen status %d, evidence %u, %d file error(s)%s%s%s%s", + (int)status, flags, error_count, error_count ? ": " : "", + error_count && errors[0].path ? errors[0].path : "", error_count ? " " : "", + error_count && errors[0].reason ? errors[0].reason : ""); + } + cbm_pipeline_frozen_free(owner); + if (status != CBM_PIPELINE_FROZEN_OK) { + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_REJECTED, + "the graph of HEAD could not be built", detail); + } + c->store = cbm_store_open_path_query(c->candidate_db); + c->graph = c->store ? cbm_ti_graph_new(c->store, "test-impact") : NULL; + if (!c->graph) { + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "the graph of HEAD could not be opened", NULL); + } + return true; +} + +/* The C-family files the reference reads (replay4 is_c: by extension). */ +static bool te_c_family_path(const char *path) { + static const char *const ext[] = {".c", ".h", ".cc", ".cpp", ".hpp", + ".m", ".mm", ".cxx", ".hh"}; + size_t n = strlen(path); + for (size_t i = 0; i < sizeof(ext) / sizeof(ext[0]); i++) { + size_t e = strlen(ext[i]); + if (n > e && strcmp(path + n - e, ext[i]) == 0) { + return true; + } + } + return false; +} + +static bool te_source_index(te_ctx_t *c) { + const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(c->tree); + size_t rows_n = 0; + const cbm_test_impact_classified_t *rows = cbm_test_impact_ledger_rows(c->ledger, &rows_n); + c->source = cbm_ti_source_new(); + if (!c->source) { + return te_oom(c); + } + cbm_pinned_tree_control_t control = {.deadline_ms = c->rq->deadline_ms}; + for (size_t i = 0; i < rows_n; i++) { + const cbm_pinned_tree_file_t *file = &view->files[rows[i].file_index]; + const char *path = (const char *)file->path; + if (!te_c_family_path(path)) { + continue; + } + size_t len = (size_t)file->content_length; + unsigned char *text = cbm_alloc(CBM_MEM_CLASS_EXTRACT, len ? len : 1); + if (!text) { + return te_oom(c); + } + size_t copied = 0; + cbm_pinned_tree_error_t error; + cbm_pinned_tree_status_t status = + cbm_pinned_tree_read_prefix(c->tree, rows[i].file_index, file->content_length, text, + len, &copied, &control, &error); + bool ok = status == CBM_PINNED_TREE_OK && copied == len && + cbm_ti_source_add(c->source, path, (const char *)text, len); + cbm_free(CBM_MEM_CLASS_EXTRACT, text); + if (!ok) { + return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, + "reading HEAD sources failed", path); + } + } + return cbm_ti_source_finish(c->source) || te_oom(c); +} + +bool te_snapshot(te_ctx_t *c) { + char *config_bytes = NULL; + size_t config_len = 0; + c->snapshot_ok = te_pin(c) && te_inventory(c) && + te_head_config(c, &config_bytes, &config_len) && + te_ledger_and_registry(c, config_bytes, config_len) && + te_frozen_build(c, config_bytes, config_len) && te_source_index(c); + return c->snapshot_ok; +} + +static void te_remove(const char *path) { + if (path && *path) { + (void)cbm_unlink(path); + } +} + +void te_snapshot_free(te_ctx_t *c) { + cbm_ti_source_free(c->source); + cbm_ti_graph_free(c->graph); + if (c->store) { + cbm_store_close(c->store); + } + cbm_test_model_free(c->model); + cbm_test_declarations_free(c->declarations); + cbm_test_impact_ledger_free(c->ledger); + cbm_test_impact_inventory_free(c->inventory); + if (c->tree) { + cbm_pinned_tree_error_t error; + (void)cbm_pinned_tree_close(&c->tree, &error); + } + if (c->candidate_db[0]) { + char side[4200]; + te_remove(c->candidate_db); + snprintf(side, sizeof(side), "%s-wal", c->candidate_db); + te_remove(side); + snprintf(side, sizeof(side), "%s-shm", c->candidate_db); + te_remove(side); + } + if (c->work_dir[0]) { + char sub[4200]; + snprintf(sub, sizeof(sub), "%s/graph", c->work_dir); + (void)cbm_rmdir(sub); + snprintf(sub, sizeof(sub), "%s/pin", c->work_dir); + (void)cbm_rmdir(sub); + (void)cbm_rmdir(c->work_dir); + } +} diff --git a/src/mcp/test_impact_git.c b/src/mcp/test_impact_git.c index 75620e2f8..299ae03f2 100644 --- a/src/mcp/test_impact_git.c +++ b/src/mcp/test_impact_git.c @@ -98,9 +98,7 @@ static bool gf_limit(cbm_git_facts_t *facts, cbm_git_facts_error_t *error, const static size_t gf_input_length(const char *text) { if (!text) return SIZE_MAX; - size_t n = 0; - while (n <= GF_INPUT_MAX && text[n]) - n++; + size_t n = strnlen(text, (size_t)GF_INPUT_MAX + 1); return n <= GF_INPUT_MAX ? n : SIZE_MAX; } @@ -171,8 +169,8 @@ static void *gf_allocate(cbm_git_facts_t *facts, CBMArena *arena, size_t size, return out; } -static bool gf_metadata_equal(cbm_git_facts_t *facts, const char *a, const char *b, - bool *equal, cbm_git_facts_error_t *error) { +static bool gf_metadata_equal(cbm_git_facts_t *facts, const char *a, const char *b, bool *equal, + cbm_git_facts_error_t *error) { if (!facts->batch_budget) { *equal = strcmp(a, b) == 0; return true; @@ -287,12 +285,12 @@ static bool gf_private_directory(cbm_git_facts_t *facts, char path[GF_PATH_CAP], int n = snprintf(path, GF_PATH_CAP, "%s/cbm-git-facts-%s", tmp, suffix); if (n < 0 || n >= GF_PATH_CAP) break; - wchar_t *wide = cbm_path_to_wide(path); + wchar_t *wide = cbm_path_to_wide_in(CBM_MEM_CLASS_OTHER, path); if (!wide) break; created = CreateDirectoryW(wide, &attributes) != 0; DWORD why = GetLastError(); - free(wide); /* existing UTF-8 conversion helper owns this buffer */ + cbm_free(CBM_MEM_CLASS_OTHER, wide); if (created || (why != ERROR_ALREADY_EXISTS && why != ERROR_FILE_EXISTS)) break; } @@ -474,8 +472,8 @@ static void gf_command_diagnostic(cbm_git_facts_error_t *error, const gf_result_ /* Observed file-size limits request cancellation. They are not instantaneous * disk quotas; final retained bytes are bounded independently after quiescence. */ static bool gf_run_input(cbm_git_facts_t *facts, const char *const *arguments, int count, - bool allow_exit_one, const cbm_git_bytes_t *input, - gf_result_t *out, cbm_git_facts_error_t *error) { + bool allow_exit_one, const cbm_git_bytes_t *input, gf_result_t *out, + cbm_git_facts_error_t *error) { memset(out, 0, sizeof(*out)); out->exit_code = -1; if (!gf_gate(facts, error)) @@ -572,8 +570,7 @@ static bool gf_run_input(cbm_git_facts_t *facts, const char *const *arguments, i } /* New batches classify a known command failure before cleanup. Cleanup * must not replace an established error; legacy calls keep their ordering. */ - if (ok && facts->batch_budget && - !(result.outcome == CBM_PROC_CLEAN && result.exit_code == 0) && + if (ok && facts->batch_budget && !(result.outcome == CBM_PROC_CLEAN && result.exit_code == 0) && !(allow_exit_one && result.outcome == CBM_PROC_EXIT_NONZERO && result.exit_code == 1)) { ok = gf_error(error, CBM_GIT_FACTS_COMMAND, "Git command failed"); gf_command_diagnostic(error, &captured); @@ -621,8 +618,8 @@ static bool gf_line(const cbm_git_bytes_t *bytes, const unsigned char **text, si } static bool gf_active_line(cbm_git_facts_t *facts, const cbm_git_bytes_t *bytes, - const unsigned char **text, size_t *length, - cbm_git_facts_error_t *error) { + const unsigned char **text, size_t *length, + cbm_git_facts_error_t *error) { if (!facts->batch_budget) return gf_line(bytes, text, length) || gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed Git metadata line"); @@ -754,14 +751,14 @@ static bool gf_topology_guard(cbm_git_facts_t *facts, cbm_git_facts_error_t *err * No graft parsing or environment mutation; uncertain files block proof. */ static bool gf_history_file_absent_or_empty(const char *path, cbm_git_facts_error_t *error) { #ifdef _WIN32 - wchar_t *wide = cbm_path_to_wide(path); + wchar_t *wide = cbm_path_to_wide_in(CBM_MEM_CLASS_OTHER, path); if (!wide) return gf_error(error, CBM_GIT_FACTS_IO, "Cannot inspect Git history override path"); HANDLE file = CreateFileW(wide, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); DWORD open_error = GetLastError(); - free(wide); + cbm_free(CBM_MEM_CLASS_OTHER, wide); if (file == INVALID_HANDLE_VALUE) { if (open_error == ERROR_FILE_NOT_FOUND || open_error == ERROR_PATH_NOT_FOUND) return true; @@ -804,8 +801,8 @@ static bool gf_history_guard(cbm_git_facts_t *facts, cbm_git_facts_error_t *erro for (int i = 0; i < 2; i++) { if (i == 1) { bool equal; - if (!gf_metadata_equal(facts, facts->graft_paths[0], facts->graft_paths[1], - &equal, error)) + if (!gf_metadata_equal(facts, facts->graft_paths[0], facts->graft_paths[1], &equal, + error)) return false; if (equal) continue; @@ -1143,15 +1140,14 @@ static bool gf_inventory_compare(cbm_git_facts_t *facts, const cbm_git_tree_entr } at += chunk; } - *comparison = a->path_length < b->path_length ? -1 : - a->path_length > b->path_length ? 1 : 0; + *comparison = a->path_length < b->path_length ? -1 : a->path_length > b->path_length ? 1 : 0; return true; } /* In-place heapsort: deterministic byte order, no recursive stack or hidden * allocation, and request gates during comparisons and each extraction. */ -static bool gf_inventory_sift(cbm_git_facts_t *facts, cbm_git_tree_entry_t *entries, - size_t root, size_t count, cbm_git_facts_error_t *error) { +static bool gf_inventory_sift(cbm_git_facts_t *facts, cbm_git_tree_entry_t *entries, size_t root, + size_t count, cbm_git_facts_error_t *error) { while (root < count / 2) { size_t child = root * 2 + 1; int cmp; @@ -1173,8 +1169,8 @@ static bool gf_inventory_sift(cbm_git_facts_t *facts, cbm_git_tree_entry_t *entr return true; } -static bool gf_inventory_sort(cbm_git_facts_t *facts, cbm_git_tree_entry_t *entries, - size_t count, cbm_git_facts_error_t *error) { +static bool gf_inventory_sort(cbm_git_facts_t *facts, cbm_git_tree_entry_t *entries, size_t count, + cbm_git_facts_error_t *error) { for (size_t i = count / 2; i > 0; i--) if (!gf_inventory_sift(facts, entries, i - 1, count, error)) return false; @@ -1198,7 +1194,7 @@ static bool gf_inventory_sort(cbm_git_facts_t *facts, cbm_git_tree_entry_t *entr } bool cbm_git_facts_inventory(cbm_git_facts_t *facts, cbm_git_revision_t revision, - cbm_git_tree_inventory_t *out, cbm_git_facts_error_t *error) { + cbm_git_tree_inventory_t *out, cbm_git_facts_error_t *error) { cbm_git_facts_error_t local; if (!error) error = &local; @@ -1445,8 +1441,7 @@ static bool gf_ancestor_names_valid(cbm_git_facts_t *facts, const cbm_git_bytes_ if (!gf_gate(facts, error)) return false; if (bytes->length - at < 3 || bytes->data[at + 1] != '\0') - return gf_error(error, CBM_GIT_FACTS_COMMAND, - "Malformed ancestor changed-path record"); + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed ancestor changed-path record"); unsigned char status = bytes->data[at]; if (status != 'A' && status != 'M' && status != 'D' && status != 'T') return gf_error(error, CBM_GIT_FACTS_COMMAND, @@ -1471,8 +1466,7 @@ static bool gf_ancestor_names_valid(cbm_git_facts_t *facts, const cbm_git_bytes_ at += chunk; } if (!terminated) - return gf_error(error, CBM_GIT_FACTS_COMMAND, - "Truncated ancestor changed-path record"); + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Truncated ancestor changed-path record"); if (!gf_gate(facts, error) || !gf_inventory_path(facts, bytes->data + start, end - start, error)) return false; @@ -1483,7 +1477,7 @@ static bool gf_ancestor_names_valid(cbm_git_facts_t *facts, const cbm_git_bytes_ /* merge-base accepts peeled tags. Check the object itself before ancestry so * a full tag OID cannot silently stand in for the admitted artifact commit. */ static bool gf_ancestor_commit_type(cbm_git_facts_t *facts, const char *oid, - cbm_git_facts_error_t *error) { + cbm_git_facts_error_t *error) { const char *args[] = {"cat-file", "-t", oid}; gf_result_t result; if (!gf_run(facts, args, 3, false, &result, error)) @@ -1493,8 +1487,7 @@ static bool gf_ancestor_commit_type(cbm_git_facts_t *facts, const char *oid, /* The longest supported response is "commit\r\n". Reject a large malformed * response without an unbounded scan through gf_line's line checks. */ if (result.out.length > 8 || !gf_line(&result.out, &type, &length)) - return gf_error(error, CBM_GIT_FACTS_COMMAND, - "Malformed artifact object-type response"); + return gf_error(error, CBM_GIT_FACTS_COMMAND, "Malformed artifact object-type response"); if (length == 6 && memcmp(type, "commit", 6) == 0) return true; if ((length == 4 && (memcmp(type, "tree", 4) == 0 || memcmp(type, "blob", 4) == 0)) || @@ -1504,11 +1497,11 @@ static bool gf_ancestor_commit_type(cbm_git_facts_t *facts, const char *oid, return gf_error(error, CBM_GIT_FACTS_COMMAND, "Unknown artifact object-type response"); } -cbm_git_ancestor_changes_status_t cbm_git_facts_ancestor_changes( - cbm_git_facts_t *facts, - const char *artifact_oid, size_t artifact_oid_length, - cbm_git_bytes_t *name_status, - cbm_git_facts_error_t *error) { +cbm_git_ancestor_changes_status_t cbm_git_facts_ancestor_changes(cbm_git_facts_t *facts, + const char *artifact_oid, + size_t artifact_oid_length, + cbm_git_bytes_t *name_status, + cbm_git_facts_error_t *error) { cbm_git_facts_error_t local; if (!error) error = &local; @@ -1528,8 +1521,7 @@ cbm_git_ancestor_changes_status_t cbm_git_facts_ancestor_changes( if (strcmp(ancestor, facts->identity.merge_base) != 0) { if (!gf_ancestor_commit_type(facts, ancestor, error)) return CBM_GIT_ANCESTOR_CHANGES_ERROR; - const char *args[] = {"merge-base", "--is-ancestor", ancestor, - facts->identity.merge_base}; + const char *args[] = {"merge-base", "--is-ancestor", ancestor, facts->identity.merge_base}; gf_result_t result; if (!gf_run(facts, args, 4, true, &result, error)) return CBM_GIT_ANCESTOR_CHANGES_ERROR; @@ -1558,8 +1550,8 @@ cbm_git_ancestor_changes_status_t cbm_git_facts_ancestor_changes( "--"}; gf_result_t result; if (!gf_run(facts, names, (int)(sizeof(names) / sizeof(names[0])), false, &result, error) || - !gf_ancestor_names_valid(facts, &result.out, error) || - !gf_history_guard(facts, error) || !gf_gate(facts, error)) + !gf_ancestor_names_valid(facts, &result.out, error) || !gf_history_guard(facts, error) || + !gf_gate(facts, error)) return CBM_GIT_ANCESTOR_CHANGES_ERROR; *name_status = result.out; return CBM_GIT_ANCESTOR_CHANGES_OK; @@ -1604,8 +1596,7 @@ static bool gf_batch_gate(cbm_git_facts_t *facts, cbm_git_facts_error_t *error) static bool gf_batch_limit(cbm_git_facts_t *facts, cbm_git_facts_error_t *error, const char *message) { - return facts ? gf_limit(facts, error, message) : - gf_error(error, CBM_GIT_FACTS_LIMIT, message); + return facts ? gf_limit(facts, error, message) : gf_error(error, CBM_GIT_FACTS_LIMIT, message); } static bool gf_batch_canonical_oid(const char *oid, size_t width) { @@ -1625,9 +1616,8 @@ typedef struct { /* Continue validating digits after overflow so a malformed suffix is never * mistaken for a valid huge size. Header length includes its terminal LF. */ -static bool gf_batch_header(cbm_git_facts_t *facts, cbm_git_bytes_t capture, - const char *oid, size_t width, gf_batch_header_t *header, - cbm_git_facts_error_t *error) { +static bool gf_batch_header(cbm_git_facts_t *facts, cbm_git_bytes_t capture, const char *oid, + size_t width, gf_batch_header_t *header, cbm_git_facts_error_t *error) { *header = (gf_batch_header_t){0}; if (!gf_batch_gate(facts, error)) return false; @@ -1658,9 +1648,8 @@ static bool gf_batch_header(cbm_git_facts_t *facts, cbm_git_bytes_t capture, return true; } -static bool gf_batch_payload(cbm_git_facts_t *facts, cbm_git_bytes_t capture, - size_t *offset, size_t size, cbm_git_bytes_t *bytes, - cbm_git_facts_error_t *error) { +static bool gf_batch_payload(cbm_git_facts_t *facts, cbm_git_bytes_t capture, size_t *offset, + size_t size, cbm_git_bytes_t *bytes, cbm_git_facts_error_t *error) { if (size >= capture.length - *offset) return gf_error(error, CBM_GIT_FACTS_COMMAND, "Truncated Git batch payload"); size_t start = *offset; @@ -1688,15 +1677,15 @@ static bool gf_batch_capture(cbm_git_facts_t *facts, cbm_git_bytes_t capture, if (!gf_batch_gate(facts, error)) return false; const char *oid = frames->objects ? frames->objects[i].oid : frames->oids[i]; - cbm_git_bytes_t remaining = {capture.data ? capture.data + at : NULL, - capture.length - at}; + cbm_git_bytes_t remaining = {capture.data ? capture.data + at : NULL, capture.length - at}; gf_batch_header_t header; if (!gf_batch_header(facts, remaining, oid, frames->width, &header, error)) return false; size_t size = header.size; bool check_size = frames->sizes || (frames->objects && frames->has_payload); - size_t expected = frames->sizes ? frames->sizes[i] : - frames->objects ? frames->objects[i].size : 0; + size_t expected = frames->sizes ? frames->sizes[i] + : frames->objects ? frames->objects[i].size + : 0; if (check_size && size != expected) return gf_error(error, CBM_GIT_FACTS_COMMAND, "Git batch size changed after preflight"); at += header.length; @@ -1740,8 +1729,8 @@ bool cbm_git_facts_test_batch_header(cbm_git_bytes_t capture, const char *expect bool cbm_git_facts_test_batch_capture(cbm_git_bytes_t capture, size_t oid_hex_length, const char *const *expected_oids, - const size_t *expected_sizes, size_t count, - bool has_payload, cbm_git_facts_error_t *error) { + const size_t *expected_sizes, size_t count, bool has_payload, + cbm_git_facts_error_t *error) { cbm_git_facts_error_t local; if (!error) error = &local; @@ -1753,8 +1742,10 @@ bool cbm_git_facts_test_batch_capture(cbm_git_bytes_t capture, size_t oid_hex_le for (size_t i = 0; i < count; i++) if (!gf_batch_canonical_oid(expected_oids[i], oid_hex_length)) return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid expected Git batch object ID"); - gf_batch_frames_t frames = {.width = oid_hex_length, .count = count, - .oids = expected_oids, .sizes = expected_sizes, + gf_batch_frames_t frames = {.width = oid_hex_length, + .count = count, + .oids = expected_oids, + .sizes = expected_sizes, .has_payload = has_payload}; return gf_batch_capture(NULL, capture, &frames, error); } @@ -1790,8 +1781,7 @@ static bool gf_batch_inventory(gf_batch_work_t *work, cbm_git_facts_error_t *err return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid internal pinned inventory cache"); } -static int gf_batch_pick_compare(const gf_batch_pick_t *a, const gf_batch_pick_t *b, - size_t width) { +static int gf_batch_pick_compare(const gf_batch_pick_t *a, const gf_batch_pick_t *b, size_t width) { int cmp = memcmp(a->entry->oid, b->entry->oid, width); if (cmp) return cmp; @@ -1844,7 +1834,8 @@ static bool gf_batch_selection(gf_batch_work_t *work, cbm_git_facts_error_t *err return false; size_t index = work->request->indices[i]; if (index >= work->inventory.count) - return gf_error(error, CBM_GIT_FACTS_INVALID, "Git batch inventory index is out of range"); + return gf_error(error, CBM_GIT_FACTS_INVALID, + "Git batch inventory index is out of range"); const cbm_git_tree_entry_t *entry = &work->inventory.entries[index]; if (entry->object_type != CBM_GIT_TREE_BLOB || (entry->mode != 0100644 && entry->mode != 0100755)) @@ -1857,8 +1848,8 @@ static bool gf_batch_selection(gf_batch_work_t *work, cbm_git_facts_error_t *err if (!gf_gate(work->facts, error)) return false; size_t index = work->request->indices[i]; - work->picks[i] = (gf_batch_pick_t){.entry = &work->inventory.entries[index], - .inventory_index = index, .position = i}; + work->picks[i] = (gf_batch_pick_t){ + .entry = &work->inventory.entries[index], .inventory_index = index, .position = i}; } return gf_batch_sort(work, error); } @@ -1908,16 +1899,19 @@ static size_t gf_batch_digits(size_t value) { return digits; } -static bool gf_batch_command(gf_batch_work_t *work, size_t first, size_t count, - bool payload, cbm_git_facts_error_t *error) { - const char *args[] = {"cat-file", payload ? "--batch=%(objectname) %(objecttype) %(objectsize)" : - "--batch-check=%(objectname) %(objecttype) %(objectsize)"}; +static bool gf_batch_command(gf_batch_work_t *work, size_t first, size_t count, bool payload, + cbm_git_facts_error_t *error) { + const char *args[] = {"cat-file", + payload ? "--batch=%(objectname) %(objecttype) %(objectsize)" + : "--batch-check=%(objectname) %(objecttype) %(objectsize)"}; cbm_git_bytes_t input = {work->lines + first * work->line_length, count * work->line_length}; gf_result_t result; if (!gf_run_input(work->facts, args, 2, false, &input, &result, error)) return false; - gf_batch_frames_t frames = {.width = work->facts->identity.oid_hex_length, .count = count, - .objects = work->objects + first, .has_payload = payload}; + gf_batch_frames_t frames = {.width = work->facts->identity.oid_hex_length, + .count = count, + .objects = work->objects + first, + .has_payload = payload}; return gf_batch_capture(work->facts, result.out, &frames, error); } @@ -1983,22 +1977,24 @@ static bool gf_batch_publish(gf_batch_work_t *work, cbm_git_blob_batch_t *out, if (!gf_gate(work->facts, error)) return false; const gf_batch_pick_t *pick = &work->picks[i]; - items[pick->position] = (cbm_git_blob_batch_item_t){ - .inventory_index = pick->inventory_index, .entry = pick->entry, - .bytes = work->objects[pick->object_index].bytes}; + items[pick->position] = + (cbm_git_blob_batch_item_t){.inventory_index = pick->inventory_index, + .entry = pick->entry, + .bytes = work->objects[pick->object_index].bytes}; } } if (!gf_history_guard(work->facts, error) || !gf_gate(work->facts, error)) return false; *out = (cbm_git_blob_batch_t){.revision = work->request->revision, .commit = gf_revision(work->facts, work->request->revision), - .items = items, .count = count}; + .items = items, + .count = count}; return true; } static bool gf_batch_execute(cbm_git_facts_t *facts, const cbm_git_blob_batch_request_t *request, - const cbm_git_blob_batch_limits_t *limits, - cbm_git_blob_batch_t *out, cbm_git_facts_error_t *error) { + const cbm_git_blob_batch_limits_t *limits, cbm_git_blob_batch_t *out, + cbm_git_facts_error_t *error) { if (!gf_history_guard(facts, error)) return false; if (request->count > limits->max_entries || request->count > SIZE_MAX / sizeof(size_t) || @@ -2025,8 +2021,8 @@ bool cbm_git_facts_read_blob_batch(cbm_git_facts_t *facts, gf_error_clear(error); if (out) memset(out, 0, sizeof(*out)); - if (!facts || !request || !limits || !out || !limits->max_entries || - !limits->max_input_bytes || !limits->max_arena_bytes || + if (!facts || !request || !limits || !out || !limits->max_entries || !limits->max_input_bytes || + !limits->max_arena_bytes || (request->revision != CBM_GIT_REV_HEAD && request->revision != CBM_GIT_REV_MERGE_BASE) || (request->count && !request->indices) || facts->batch_budget) return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid pinned Git blob batch request"); diff --git a/src/mcp/test_impact_git.h b/src/mcp/test_impact_git.h index d0b5c9d33..b15523129 100644 --- a/src/mcp/test_impact_git.h +++ b/src/mcp/test_impact_git.h @@ -89,10 +89,7 @@ typedef struct { size_t length; } cbm_git_bytes_t; -typedef enum { - CBM_GIT_TREE_BLOB = 1, - CBM_GIT_TREE_COMMIT = 2 -} cbm_git_tree_object_type_t; +typedef enum { CBM_GIT_TREE_BLOB = 1, CBM_GIT_TREE_COMMIT = 2 } cbm_git_tree_object_type_t; typedef struct { const char *path; /* owned NUL-terminated bytes; path_length is authoritative */ @@ -117,7 +114,7 @@ typedef struct { * Empty success is {NULL,0}; every failure clears out. All data lives until * facts_free. Existing guards, cancellation/deadline and query budgets apply. */ bool cbm_git_facts_inventory(cbm_git_facts_t *facts, cbm_git_revision_t revision, - cbm_git_tree_inventory_t *out, cbm_git_facts_error_t *error); + cbm_git_tree_inventory_t *out, cbm_git_facts_error_t *error); typedef struct { cbm_git_bytes_t bytes; @@ -173,28 +170,29 @@ typedef enum { CBM_GIT_ANCESTOR_CHANGES_OK = 1 } cbm_git_ancestor_changes_status_t; -cbm_git_ancestor_changes_status_t cbm_git_facts_ancestor_changes( - cbm_git_facts_t *facts, - const char *artifact_oid, size_t artifact_oid_length, - cbm_git_bytes_t *name_status, - cbm_git_facts_error_t *error); +cbm_git_ancestor_changes_status_t cbm_git_facts_ancestor_changes(cbm_git_facts_t *facts, + const char *artifact_oid, + size_t artifact_oid_length, + cbm_git_bytes_t *name_status, + cbm_git_facts_error_t *error); typedef struct { cbm_git_revision_t revision; /* HEAD or actual MERGE_BASE; BASE is INVALID */ - const size_t *indices; /* indices into that revision's canonical pinned inventory */ + const size_t *indices; /* indices into that revision's canonical pinned inventory */ size_t count; /* duplicates allowed; NULL indices allowed exactly when count==0 */ } cbm_git_blob_batch_request_t; typedef struct { - size_t max_entries; /* positive; bounds request.count, including duplicates */ + size_t max_entries; /* positive; bounds request.count, including duplicates */ size_t max_input_bytes; /* positive; cumulative OID+LF stdin bytes for this call */ - size_t max_arena_bytes; /* positive; all Git-facts-owned logical arena requests during this call */ + size_t + max_arena_bytes; /* positive; all Git-facts-owned logical arena requests during this call */ } cbm_git_blob_batch_limits_t; typedef struct { size_t inventory_index; const cbm_git_tree_entry_t *entry; /* facts-owned canonical path/mode/type/OID */ - cbm_git_bytes_t bytes; /* exact blob contents; NOT promised NUL-terminated */ + cbm_git_bytes_t bytes; /* exact blob contents; NOT promised NUL-terminated */ } cbm_git_blob_batch_item_t; typedef struct { @@ -243,11 +241,9 @@ typedef struct { * the whole capture directory, including stdin, under existing diagnostics. * No legacy read_blob/inventory/diff/ancestor-change semantics are changed. */ bool cbm_git_facts_read_blob_batch(cbm_git_facts_t *facts, - const cbm_git_blob_batch_request_t *request, - const cbm_git_blob_batch_limits_t *limits, - cbm_git_blob_batch_t *out, - cbm_git_facts_error_t *error); - + const cbm_git_blob_batch_request_t *request, + const cbm_git_blob_batch_limits_t *limits, + cbm_git_blob_batch_t *out, cbm_git_facts_error_t *error); #ifdef CBM_ENABLE_TEST_SEAMS /* Deterministic wrappers around the actual production header/frame parsers. @@ -259,17 +255,15 @@ bool cbm_git_facts_read_blob_batch(cbm_git_facts_t *facts, * be NULL (unknown); otherwise each parsed size must match expected_sizes[i]. * Payload mode requires expected_sizes for nonzero count. Empty capture/count * is valid. Bad pointer/width arguments are INVALID. No partial output views. */ -bool cbm_git_facts_test_batch_header(cbm_git_bytes_t capture, - const char *expected_oid, size_t oid_hex_length, - size_t *object_size, size_t *header_length, - cbm_git_facts_error_t *error); +bool cbm_git_facts_test_batch_header(cbm_git_bytes_t capture, const char *expected_oid, + size_t oid_hex_length, size_t *object_size, + size_t *header_length, cbm_git_facts_error_t *error); bool cbm_git_facts_test_batch_capture(cbm_git_bytes_t capture, size_t oid_hex_length, - const char *const *expected_oids, - const size_t *expected_sizes, size_t count, - bool has_payload, cbm_git_facts_error_t *error); + const char *const *expected_oids, + const size_t *expected_sizes, size_t count, bool has_payload, + cbm_git_facts_error_t *error); #endif - /* Local Git identity/content does not authenticate a coverage artifact or * establish that any index/graph generation covers this snapshot. */ #endif diff --git a/src/mcp/test_impact_inventory.c b/src/mcp/test_impact_inventory.c index 5f853d456..eea5dbebd 100644 --- a/src/mcp/test_impact_inventory.c +++ b/src/mcp/test_impact_inventory.c @@ -137,6 +137,9 @@ static bool cni_delegate(cni_context *c, cbm_pinned_tree_t *tree, cbm_inventory_ cbm_inventory_error_t error; cbm_inventory_status_t status = cbm_inventory_filter_prepare(source, &limits, c->control, &owner->filter, &error); + /* The reader lives in this frame; the caller's source must not keep it. */ + source->read = NULL; + source->read_context = NULL; /* The filter owns all successful metadata; this release never refunds A. */ cbm_arena_destroy(&c->temporary); if (status != CBM_INVENTORY_OK) { diff --git a/src/mcp/test_impact_inventory.h b/src/mcp/test_impact_inventory.h index 0f40a7d1e..8e275d8b8 100644 --- a/src/mcp/test_impact_inventory.h +++ b/src/mcp/test_impact_inventory.h @@ -13,8 +13,8 @@ typedef struct cbm_test_impact_inventory cbm_test_impact_inventory_t; typedef struct { size_t attachment_arena_requested_bytes; /* A, including freed temporary inputs. */ cbm_inventory_filter_usage_t filter; /* Existing D/C/I and read/work ledgers. */ - size_t arena_requested_bytes; /* A+D+C, actual logical requests. */ - size_t arena_budget_used_bytes; /* A+D+I, unavailable quota. */ + size_t arena_requested_bytes; /* A+D+C, actual logical requests. */ + size_t arena_budget_used_bytes; /* A+D+I, unavailable quota. */ } cbm_test_impact_inventory_usage_t; /* Synchronous HEAD-only bridge from the complete READY native inventory. @@ -26,10 +26,11 @@ typedef struct { * Failure publishes NULL; native disposal remains caller-owned, including * after a read makes the native owner disposal-only. No automatic retry. */ -cbm_inventory_status_t cbm_test_impact_inventory_prepare( - cbm_pinned_tree_t *tree, const cbm_inventory_limits_t *limits, - const cbm_inventory_control_t *control, cbm_test_impact_inventory_t **out, - cbm_inventory_error_t *error); +cbm_inventory_status_t cbm_test_impact_inventory_prepare(cbm_pinned_tree_t *tree, + const cbm_inventory_limits_t *limits, + const cbm_inventory_control_t *control, + cbm_test_impact_inventory_t **out, + cbm_inventory_error_t *error); /* Existing filter view, borrowed until wrapper free; NULL for NULL owner. * NEEDS_LANGUAGE survivors remain unfinished. No IO/poll/allocation. @@ -42,9 +43,9 @@ const cbm_inventory_filter_view_t *cbm_test_impact_inventory_view( * The wrapped filter privately received T-A with unchanged I exactly once. */ bool cbm_test_impact_inventory_usage(const cbm_test_impact_inventory_t *owner, - cbm_test_impact_inventory_usage_t *out); + cbm_test_impact_inventory_usage_t *out); bool cbm_test_impact_inventory_limits(const cbm_test_impact_inventory_t *owner, - cbm_inventory_limits_t *out); + cbm_inventory_limits_t *out); /* NULL-safe. Frees the wrapped filter, then wrapper arena. No native IO, * callbacks or tree disposal. Cannot overlap getters or another operation. diff --git a/src/mcp/test_impact_origins.c b/src/mcp/test_impact_origins.c index 158137252..dc8f9d6f8 100644 --- a/src/mcp/test_impact_origins.c +++ b/src/mcp/test_impact_origins.c @@ -278,8 +278,9 @@ static bool or_path(or_work_t *work, cbm_coverage_origin_bytes_t path) { } if (path.data[offset] == '/') { size_t length = offset - component; - if (!length || (path.data[component] == '.' && - (length == 1 || (length == 2 && path.data[component + 1] == '.')))) { + if (!length || + (path.data[component] == '.' && + (length == 1 || (length == 2 && path.data[component + 1] == '.')))) { return or_fail(work, CBM_COVERAGE_ORIGIN_FORMAT); } component = offset + 1; @@ -299,11 +300,16 @@ static bool or_path(or_work_t *work, cbm_coverage_origin_bytes_t path) { static unsigned or_edit(char status) { switch (status) { - case 'A': return CBM_COVERAGE_ORIGIN_EDIT_A; - case 'M': return CBM_COVERAGE_ORIGIN_EDIT_M; - case 'D': return CBM_COVERAGE_ORIGIN_EDIT_D; - case 'T': return CBM_COVERAGE_ORIGIN_EDIT_T; - default: return 0; + case 'A': + return CBM_COVERAGE_ORIGIN_EDIT_A; + case 'M': + return CBM_COVERAGE_ORIGIN_EDIT_M; + case 'D': + return CBM_COVERAGE_ORIGIN_EDIT_D; + case 'T': + return CBM_COVERAGE_ORIGIN_EDIT_T; + default: + return 0; } } @@ -551,8 +557,8 @@ static bool or_change_sift(or_work_t *work, or_change_t *rows, size_t count, siz return true; } -static bool or_changes(or_work_t *work, cbm_coverage_origin_bytes_t bytes, - or_change_t **changes, size_t *change_count) { +static bool or_changes(or_work_t *work, cbm_coverage_origin_bytes_t bytes, or_change_t **changes, + size_t *change_count) { size_t count = 0, position = 0; or_change_t ignored; while (position < bytes.length) { @@ -602,9 +608,9 @@ static bool or_changes(or_work_t *work, cbm_coverage_origin_bytes_t bytes, } static bool or_reconcile(or_work_t *work, const cbm_changes_t *request, - const cbm_coverage_origin_binding_t *binding, - const or_change_t *ah, size_t ah_count, - const or_change_t *mh, size_t mh_count, cbm_changes_state_t *state) { + const cbm_coverage_origin_binding_t *binding, const or_change_t *ah, + size_t ah_count, const or_change_t *mh, size_t mh_count, + cbm_changes_state_t *state) { size_t count; const cbm_change_file_t *files = cbm_changes_files(request, &count); if (count != mh_count || (count && !files)) { @@ -768,9 +774,9 @@ static bool or_union_ids(or_work_t *work, cbm_coverage_origin_join_t *result) { } static bool or_join_paths(or_work_t *work, cbm_coverage_origin_join_t *result, - const or_claim_t *claims, size_t claim_count, - const or_change_t *ah, size_t ah_count, - const or_change_t *mh, size_t mh_count, int function_count) { + const or_claim_t *claims, size_t claim_count, const or_change_t *ah, + size_t ah_count, const or_change_t *mh, size_t mh_count, + int function_count) { if (ah_count > SIZE_MAX - mh_count) { return or_fail(work, CBM_COVERAGE_ORIGIN_LIMIT); } @@ -785,8 +791,7 @@ static bool or_join_paths(or_work_t *work, cbm_coverage_origin_join_t *result, return false; } int order = ai == ah_count ? 1 : mi == mh_count ? -1 : 0; - if (ai < ah_count && mi < mh_count && - !or_compare(work, ah[ai].path, mh[mi].path, &order)) { + if (ai < ah_count && mi < mh_count && !or_compare(work, ah[ai].path, mh[mi].path, &order)) { return false; } cbm_coverage_origin_path_t path = {0}; @@ -824,9 +829,8 @@ static bool or_join_paths(or_work_t *work, cbm_coverage_origin_join_t *result, path.reasons |= CBM_COVERAGE_ORIGIN_PRESENCE_UNKNOWN; } else { bool present_at_a = claim->state >= 2; - bool expected_present = path.artifact_status - ? path.artifact_status != 'A' - : path.merge_base_status != 'D'; + bool expected_present = path.artifact_status ? path.artifact_status != 'A' + : path.merge_base_status != 'D'; if (present_at_a != expected_present) { return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); } @@ -881,8 +885,8 @@ static int or_hex_digit(unsigned char ch) { return ch >= 'a' && ch <= 'f' ? ch - 'a' + 10 : -1; } -static bool or_map(or_work_t *work, const cbm_coverage_map_t *map, - const unsigned char expected[32], int *count) { +static bool or_map(or_work_t *work, const cbm_coverage_map_t *map, const unsigned char expected[32], + int *count) { cbm_coverage_format_t format = cbm_coverage_map_format(map); if (format != CBM_COVERAGE_FORMAT_FUNCTIONS && format != CBM_COVERAGE_FORMAT_PROFILES) { return or_fail(work, CBM_COVERAGE_ORIGIN_BINDING); @@ -922,10 +926,11 @@ static bool or_span_valid(cbm_coverage_origin_bytes_t span) { return span.data || !span.length; } -cbm_coverage_origin_status_t cbm_coverage_origin_join( - const cbm_coverage_origin_input_t *input, const cbm_coverage_origin_limits_t *limits, - cbm_coverage_origin_cancel_fn cancelled, void *cancel_context, - cbm_coverage_origin_join_t **out) { +cbm_coverage_origin_status_t cbm_coverage_origin_join(const cbm_coverage_origin_input_t *input, + const cbm_coverage_origin_limits_t *limits, + cbm_coverage_origin_cancel_fn cancelled, + void *cancel_context, + cbm_coverage_origin_join_t **out) { if (out) { *out = NULL; } @@ -968,8 +973,10 @@ cbm_coverage_origin_status_t cbm_coverage_origin_join( } total += (uint64_t)lengths[i]; } - or_work_t work = {.limits = *limits, .cancelled = cancelled, - .cancel_context = cancel_context, .status = CBM_COVERAGE_ORIGIN_OK}; + or_work_t work = {.limits = *limits, + .cancelled = cancelled, + .cancel_context = cancel_context, + .status = CBM_COVERAGE_ORIGIN_OK}; cbm_arena_init_lazy(&work.arena, 1024); cbm_coverage_origin_join_t *result; cbm_coverage_origin_bytes_t manifest, ah_bytes, mh_bytes; @@ -978,8 +985,7 @@ cbm_coverage_origin_status_t cbm_coverage_origin_join( size_t claim_count, ah_count, mh_count; unsigned reasons; int function_count; - if (!or_poll(&work) || - !or_hash_matches(&work, input->manifest, binding->manifest_sha256) || + if (!or_poll(&work) || !or_hash_matches(&work, input->manifest, binding->manifest_sha256) || !or_hash_matches(&work, input->artifact_to_head, context->artifact_to_head_sha256) || !or_hash_matches(&work, input->merge_base_to_head, context->merge_base_to_head_sha256) || !or_map(&work, input->coverage, binding->functions_sha256, &function_count) || @@ -1013,7 +1019,8 @@ cbm_coverage_origin_status_t cbm_coverage_origin_join( result->reasons |= CBM_COVERAGE_ORIGIN_REQUEST_UNKNOWN; } if (!or_join_paths(&work, result, claims, claim_count, ah, ah_count, mh, mh_count, - function_count) || !or_poll(&work)) { + function_count) || + !or_poll(&work)) { goto failure; } result->arena = work.arena; diff --git a/src/mcp/test_impact_origins.h b/src/mcp/test_impact_origins.h index 3b19a52bb..754c4475a 100644 --- a/src/mcp/test_impact_origins.h +++ b/src/mcp/test_impact_origins.h @@ -49,9 +49,9 @@ typedef struct { * map and its test observations. A v1 receipt cannot admit a v2 map; * successful parsing supplies no admission evidence. */ bool artifact_admitted; - bool origin_source_verified; /* authenticated repository/A/table/manifest tuple */ + bool origin_source_verified; /* authenticated repository/A/table/manifest tuple */ bool origin_attestations_verified; /* supported producer claim/proof semantics */ - bool comparisons_verified; /* complete same-owner A ancestor of actual M, pinned H */ + bool comparisons_verified; /* complete same-owner A ancestor of actual M, pinned H */ cbm_coverage_origin_binding_t binding; unsigned char artifact_to_head_sha256[32]; unsigned char merge_base_to_head_sha256[32]; @@ -68,9 +68,9 @@ typedef struct { typedef struct { uint64_t max_input_bytes; /* manifest + AH + MH + context key; <= UINT64_MAX/8 */ - uint64_t max_items; /* decoded records, borrowed map checks and ID visits; see spec */ - size_t max_alloc_bytes; /* cumulative logical arena allocation requests */ - size_t max_result_ids; /* distinct final union cardinality; positive, <= INT_MAX */ + uint64_t max_items; /* decoded records, borrowed map checks and ID visits; see spec */ + size_t max_alloc_bytes; /* cumulative logical arena allocation requests */ + size_t max_result_ids; /* distinct final union cardinality; positive, <= INT_MAX */ } cbm_coverage_origin_limits_t; typedef bool (*cbm_coverage_origin_cancel_fn)(void *context); @@ -99,10 +99,7 @@ enum { CBM_COVERAGE_ORIGIN_EDIT_T = 1u << 3 }; -enum { - CBM_COVERAGE_ORIGIN_FROM_ARTIFACT = 1u << 0, - CBM_COVERAGE_ORIGIN_FROM_MERGE_BASE = 1u << 1 -}; +enum { CBM_COVERAGE_ORIGIN_FROM_ARTIFACT = 1u << 0, CBM_COVERAGE_ORIGIN_FROM_MERGE_BASE = 1u << 1 }; enum { CBM_COVERAGE_ORIGIN_PROFILE_UNIVERSE_UNKNOWN = 1u << 0, @@ -120,8 +117,8 @@ typedef struct { char artifact_status; /* A/M/D/T or zero when absent from that comparison */ char merge_base_status; cbm_coverage_origin_disposition_t disposition; /* UNKNOWN for missing row */ - unsigned supported_edits; /* zero for missing row */ - unsigned reasons; /* local gaps only; global gaps affect overall completeness */ + unsigned supported_edits; /* zero for missing row */ + unsigned reasons; /* local gaps only; global gaps affect overall completeness */ const int *function_ids; /* owned ascending unique positive evidence */ size_t function_count; } cbm_coverage_origin_path_t; @@ -139,11 +136,11 @@ typedef struct cbm_coverage_origin_join cbm_coverage_origin_join_t; * owner. Missing evidence is OK/incomplete; malformed input, wrong binding, * missing admission, cancel, limit and OOM are errors. No inferred identities. * The adapter MUST use can_narrow, never complete alone. */ -cbm_coverage_origin_status_t cbm_coverage_origin_join( - const cbm_coverage_origin_input_t *input, - const cbm_coverage_origin_limits_t *limits, - cbm_coverage_origin_cancel_fn cancelled, void *cancel_context, - cbm_coverage_origin_join_t **out); +cbm_coverage_origin_status_t cbm_coverage_origin_join(const cbm_coverage_origin_input_t *input, + const cbm_coverage_origin_limits_t *limits, + cbm_coverage_origin_cancel_fn cancelled, + void *cancel_context, + cbm_coverage_origin_join_t **out); void cbm_coverage_origin_join_free(cbm_coverage_origin_join_t *join); /* Views are immutable until join_free; path views may share one owned ALL ID @@ -161,7 +158,6 @@ bool cbm_coverage_origin_join_broad_fallback_required(const cbm_coverage_origin_ bool cbm_coverage_origin_join_can_narrow(const cbm_coverage_origin_join_t *join); unsigned cbm_coverage_origin_join_reasons(const cbm_coverage_origin_join_t *join); /* Preserve cross-checked D4a M->H state. Never derive EMPTY from AH or ID count. */ -cbm_changes_state_t cbm_coverage_origin_join_request_state( - const cbm_coverage_origin_join_t *join); +cbm_changes_state_t cbm_coverage_origin_join_request_state(const cbm_coverage_origin_join_t *join); #endif diff --git a/src/mcp/test_impact_policy.c b/src/mcp/test_impact_policy.c index aeb892894..a85cf5c7c 100644 --- a/src/mcp/test_impact_policy.c +++ b/src/mcp/test_impact_policy.c @@ -591,6 +591,9 @@ static const char *const tp_ci_paths[] = {".github/**", ".gitlab-ci.yml", ".ci "Jenkinsfile*", ".buildkite/**", "azure-pipelines*.yml"}; static const char *const tp_vendor_paths[] = {"vendor*/**", "third_party/**"}; static const char *const tp_conftest_paths[] = {"conftest.py"}; +/* The selection's own configuration: a change to it may loosen what it + * selects, so it runs everything (smart-ci-design review M-4). */ +static const char *const tp_config_paths[] = {".codebase-memory.json"}; static const char *const tp_documentation_paths[] = {"docs/**", "**/*.md", "LICENSE*"}; typedef struct { @@ -602,6 +605,8 @@ typedef struct { #define TP_COUNT(array) (sizeof(array) / sizeof((array)[0])) static const tp_builtin_t tp_builtins[] = { + {"builtin:test-impact-config", CBM_TEST_RULE_RUN_ALL, tp_config_paths, + TP_COUNT(tp_config_paths)}, {"builtin:build", CBM_TEST_RULE_RUN_ALL, tp_build_paths, TP_COUNT(tp_build_paths)}, {"builtin:ci", CBM_TEST_RULE_RUN_ALL, tp_ci_paths, TP_COUNT(tp_ci_paths)}, {"builtin:vendor", CBM_TEST_RULE_RUN_ALL, tp_vendor_paths, TP_COUNT(tp_vendor_paths)}, diff --git a/src/mcp/test_impact_result.h b/src/mcp/test_impact_result.h index 25fcc6d64..f07beb447 100644 --- a/src/mcp/test_impact_result.h +++ b/src/mcp/test_impact_result.h @@ -44,6 +44,9 @@ typedef enum { CBM_TEST_RESULT_FALLBACK_RULE_RUN_ALL, CBM_TEST_RESULT_FALLBACK_POLICY_EVALUATION_FAILED, CBM_TEST_RESULT_FALLBACK_TRAVERSAL_POLICY_UNKNOWN, + /* The walk reached the test runner's entry point: every suite may run + * changed code at start-up (smart-ci-design review M-3). */ + CBM_TEST_RESULT_FALLBACK_RUNNER_REACHED, CBM_TEST_RESULT_FALLBACK_COUNT } cbm_test_result_fallback_t; diff --git a/src/mcp/test_impact_result_project.c b/src/mcp/test_impact_result_project.c index 21539432b..01cc334f9 100644 --- a/src/mcp/test_impact_result_project.c +++ b/src/mcp/test_impact_result_project.c @@ -82,7 +82,7 @@ static bool tir_lane_plan_build(tir_context *c, size_t index, tir_lane_plan *pla if (!lane->source->narrow) plan->reasons |= TIR_REASON(TIR_REASON_NARROW_DISABLED); if (!c->input->inventory_complete || !c->input->model || - !cbm_test_model_complete(c->input->model)) { + !cbm_test_model_narrowable(c->input->model)) { plan->suites = tir_null(c); return plan->suites != NULL; } diff --git a/src/mcp/test_impact_result_rows.c b/src/mcp/test_impact_result_rows.c index fccbd7d1c..67549df7f 100644 --- a/src/mcp/test_impact_result_rows.c +++ b/src/mcp/test_impact_result_rows.c @@ -68,8 +68,10 @@ static bool tir_normalize_suites(tir_context *c) { if (!tir_sort(c, c->suites, c->suite_count, sizeof(*c->suites), tir_suite_compare)) return false; size_t used = 0; - bool known = - c->input->inventory_complete && c->input->model && cbm_test_model_complete(c->input->model); + /* Narrowable, not complete: a suite the model is unsure of arrives whole + * in the selection, so only uncertainty it cannot scope is global. */ + bool known = c->input->inventory_complete && c->input->model && + cbm_test_model_narrowable(c->input->model); for (size_t i = 0; i < c->suite_count; i++) { if (!tir_step(c, 0, 1)) return false; @@ -264,7 +266,7 @@ static void tir_global_floors(tir_context *c) { c->global |= TIR_FALLBACK(CBM_TEST_RESULT_FALLBACK_ACTIVATION_UNKNOWN); if (in->comparison != CBM_TEST_RESULT_COMPARISON_CHANGED) return; - if (!in->inventory_complete || !in->model || !cbm_test_model_complete(in->model)) + if (!in->inventory_complete || !in->model || !cbm_test_model_narrowable(in->model)) c->global |= CBM_TEST_SELECT_INVENTORY_UNKNOWN; if (!in->selection) c->global |= TIR_FALLBACK(CBM_TEST_RESULT_FALLBACK_SELECTION_UNAVAILABLE); diff --git a/src/mcp/test_impact_result_wire.c b/src/mcp/test_impact_result_wire.c index 88d4c5ae8..e8e88f7c8 100644 --- a/src/mcp/test_impact_result_wire.c +++ b/src/mcp/test_impact_result_wire.c @@ -32,6 +32,7 @@ static const char *const tir_reason_names[TIR_REASON_COUNT] = {"CHANGED", "RULE_RUN_ALL", "POLICY_EVALUATION_FAILED", "TRAVERSAL_POLICY_UNKNOWN", + "RUNNER_REACHED", "POLICY_INACTIVE", "NO_CHANGES", "NO_SELECTED_TESTS", diff --git a/src/mcp/test_impact_seed.c b/src/mcp/test_impact_seed.c new file mode 100644 index 000000000..ccade1138 --- /dev/null +++ b/src/mcp/test_impact_seed.c @@ -0,0 +1,941 @@ +/* + * test_impact_seed.c — what a change seeds in the graph. + * + * Each step names the lines of the reference (scratchpad tia3/replay4.py) it + * ports, so a differential check can be read side by side. + */ +#include "mcp/test_impact_seed.h" + +#include "foundation/arena.h" +#include "foundation/hash_table.h" +#include "foundation/mem_core.h" + +#include +#include +#include +#include + +/* ── Growable arrays (memory core) ───────────────────────────────── */ + +static bool ts_grow(void **items, int *cap, int count, size_t size) { + if (count < *cap) { + return true; + } + int next = *cap ? *cap * 2 : 16; + void *grown = cbm_realloc(CBM_MEM_CLASS_EXTRACT, *items, (size_t)next * size); + if (!grown) { + return false; + } + *items = grown; + *cap = next; + return true; +} + +typedef struct { + int64_t *items; + int count; + int cap; +} ts_ids_t; + +typedef struct { + int *items; + int count; + int cap; +} ts_ints_t; + +typedef struct { + const char **items; + int count; + int cap; +} ts_strs_t; + +static bool ts_ids_push(ts_ids_t *a, int64_t v) { + if (!ts_grow((void **)&a->items, &a->cap, a->count, sizeof(*a->items))) { + return false; + } + a->items[a->count++] = v; + return true; +} + +static bool ts_ints_push(ts_ints_t *a, int v) { + if (!ts_grow((void **)&a->items, &a->cap, a->count, sizeof(*a->items))) { + return false; + } + a->items[a->count++] = v; + return true; +} + +static bool ts_strs_push(ts_strs_t *a, const char *v) { + if (!ts_grow((void **)&a->items, &a->cap, a->count, sizeof(*a->items))) { + return false; + } + a->items[a->count++] = v; + return true; +} + +/* ── The graph, per file ─────────────────────────────────────────── */ + +typedef struct { + cbm_ti_node_t *nodes; + int count; +} ts_file_t; + +struct cbm_ti_graph { + cbm_store_t *store; + const char *project; + CBMArena arena; + CBMHashTable *files; /* path -> ts_file_t* (arena) */ + CBMHashTable *names; /* name -> 1 present / 2 absent */ +}; + +static bool ts_seedless(const char *label) { + static const char *const seedless[] = {"File", "Folder", "Project", "Module", + "Package", "Section", "Route", "Resource"}; + for (size_t i = 0; i < sizeof(seedless) / sizeof(seedless[0]); i++) { + if (label && strcmp(label, seedless[i]) == 0) { + return true; + } + } + return false; +} + +cbm_ti_graph_t *cbm_ti_graph_new(cbm_store_t *store, const char *project) { + if (!store || !project) { + return NULL; + } + cbm_ti_graph_t *g = cbm_alloc(CBM_MEM_CLASS_EXTRACT, sizeof(*g)); + if (!g) { + return NULL; + } + memset(g, 0, sizeof(*g)); + cbm_arena_init(&g->arena); + g->store = store; + g->project = cbm_arena_strdup(&g->arena, project); + g->files = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + g->names = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + if (!g->project || !g->files || !g->names) { + cbm_ti_graph_free(g); + return NULL; + } + return g; +} + +void cbm_ti_graph_free(cbm_ti_graph_t *g) { + if (!g) { + return; + } + if (g->files) { + cbm_ht_free(g->files); + } + if (g->names) { + cbm_ht_free(g->names); + } + cbm_arena_destroy(&g->arena); + cbm_free(CBM_MEM_CLASS_EXTRACT, g); +} + +static int ts_node_compare(const void *left, const void *right) { + const cbm_ti_node_t *a = left; + const cbm_ti_node_t *b = right; + if (a->start_line != b->start_line) { + return a->start_line < b->start_line ? -1 : 1; + } + if (a->end_line != b->end_line) { + return a->end_line < b->end_line ? -1 : 1; + } + return a->id < b->id ? -1 : (a->id > b->id ? 1 : 0); +} + +static ts_file_t *ts_load_file(cbm_ti_graph_t *g, const char *file) { + cbm_node_t *rows = NULL; + int count = 0; + if (cbm_store_find_nodes_by_file(g->store, g->project, file, &rows, &count) != CBM_STORE_OK) { + return NULL; + } + ts_file_t *f = cbm_arena_calloc(&g->arena, sizeof(*f)); + cbm_ti_node_t *nodes = cbm_arena_alloc(&g->arena, (size_t)(count ? count : 1) * sizeof(*nodes)); + const char *key = cbm_arena_strdup(&g->arena, file); + bool ok = f && nodes && key; + int kept = 0; + for (int i = 0; ok && i < count; i++) { + if (ts_seedless(rows[i].label)) { + continue; + } + cbm_ti_node_t *n = &nodes[kept++]; + n->id = rows[i].id; + n->label = cbm_arena_strdup(&g->arena, rows[i].label ? rows[i].label : ""); + n->name = cbm_arena_strdup(&g->arena, rows[i].name ? rows[i].name : ""); + n->qualified_name = + cbm_arena_strdup(&g->arena, rows[i].qualified_name ? rows[i].qualified_name : ""); + n->start_line = rows[i].start_line; + n->end_line = rows[i].end_line; + ok = n->label && n->name && n->qualified_name; + } + cbm_store_free_nodes(rows, count); + if (!ok) { + return NULL; + } + if (kept > 1) { + qsort(nodes, (size_t)kept, sizeof(*nodes), ts_node_compare); + } + f->nodes = nodes; + f->count = kept; + cbm_ht_set(g->files, key, f); + return f; +} + +const cbm_ti_node_t *cbm_ti_graph_file(cbm_ti_graph_t *g, const char *file, int *count, bool *ok) { + *count = 0; + *ok = g && file; + if (!*ok) { + return NULL; + } + ts_file_t *f = cbm_ht_get(g->files, file); + if (!f) { + f = ts_load_file(g, file); + } + if (!f) { + *ok = false; + return NULL; + } + *count = f->count; + return f->nodes; +} + +/* replay4 `_enclosing`: the smallest span containing the line; among equal + * spans the first in id order (the reference iterates the store's rows). */ +const cbm_ti_node_t *cbm_ti_graph_enclosing(cbm_ti_graph_t *g, const char *file, int line, + bool *ok) { + int count = 0; + const cbm_ti_node_t *nodes = cbm_ti_graph_file(g, file, &count, ok); + const cbm_ti_node_t *best = NULL; + for (int i = 0; i < count; i++) { + const cbm_ti_node_t *n = &nodes[i]; + if (n->start_line > line || n->end_line < line) { + continue; + } + int span = n->end_line - n->start_line; + int best_span = best ? best->end_line - best->start_line : 0; + if (!best || span < best_span || (span == best_span && n->id < best->id)) { + best = n; + } + } + return best; +} + +bool cbm_ti_graph_has_name(cbm_ti_graph_t *g, const char *name, bool *ok) { + *ok = g && name; + if (!*ok) { + return false; + } + intptr_t known = (intptr_t)cbm_ht_get(g->names, name); + if (known) { + return known == 1; + } + cbm_node_t *rows = NULL; + int count = 0; + if (cbm_store_find_nodes_by_name(g->store, g->project, name, &rows, &count) != CBM_STORE_OK) { + *ok = false; + return false; + } + cbm_store_free_nodes(rows, count); + const char *key = cbm_arena_strdup(&g->arena, name); + if (!key) { + *ok = false; + return false; + } + cbm_ht_set(g->names, key, (void *)(intptr_t)(count > 0 ? 1 : 2)); + return count > 0; +} + +/* ── The seeding state ───────────────────────────────────────────── */ + +struct cbm_ti_seeds { + CBMArena arena; + ts_ids_t nodes; + ts_ints_t cases; + ts_ints_t suites; + ts_strs_t file_level; + ts_strs_t deleted; +}; + +typedef struct { + const cbm_ti_seed_input_t *in; + cbm_ti_seeds_t *out; + CBMHashTable *seen_nodes; /* "id" -> 1 */ + CBMHashTable *whole; /* path -> 1 */ + ts_strs_t whole_list; /* paths in insertion order */ + CBMHashTable *deleted; /* name -> 1 */ + CBMHashTable *case_index; /* "file\x1fname" -> case index + 1 */ + CBMHashTable *suite_index; /* "file\x1fname" -> suite index + 1 */ + CBMHashTable *changed_path; /* path -> 1 */ + bool *case_changed; + bool *suite_changed; + int case_count; + int suite_count; + cbm_ti_seed_status_t status; +} ts_state_t; + +static bool ts_fail(ts_state_t *st, cbm_ti_seed_status_t status) { + if (st->status == CBM_TI_SEED_OK) { + st->status = status; + } + return false; +} + +static const char *ts_key(ts_state_t *st, const char *file, const char *name) { + size_t a = strlen(file); + size_t b = strlen(name); + char *key = cbm_arena_alloc(&st->out->arena, a + b + 2); + if (!key) { + return NULL; + } + memcpy(key, file, a); + key[a] = '\x1f'; + memcpy(key + a + 1, name, b + 1); + return key; +} + +static bool ts_add_node(ts_state_t *st, int64_t id) { + char key[32]; + snprintf(key, sizeof(key), "%lld", (long long)id); + if (cbm_ht_get(st->seen_nodes, key)) { + return true; + } + const char *owned = cbm_arena_strdup(&st->out->arena, key); + if (!owned || !ts_ids_push(&st->out->nodes, id)) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + cbm_ht_set(st->seen_nodes, owned, (void *)1); + return true; +} + +static bool ts_add_whole(ts_state_t *st, const char *path) { + if (cbm_ht_get(st->whole, path)) { + return true; + } + const char *owned = cbm_arena_strdup(&st->out->arena, path); + if (!owned || !ts_strs_push(&st->whole_list, owned)) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + cbm_ht_set(st->whole, owned, (void *)1); + return true; +} + +static bool ts_add_deleted(ts_state_t *st, const char *line, cbm_ti_span_t span) { + char *name = cbm_arena_strndup(&st->out->arena, line + span.start, span.len); + if (!name) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + if (!cbm_ht_get(st->deleted, name)) { + cbm_ht_set(st->deleted, name, (void *)1); + if (!ts_strs_push(&st->out->deleted, name)) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + } + return true; +} + +/* A test case or suite body is reported, never seeded (replay4 558-566). */ +static int ts_case_of(ts_state_t *st, const char *file, const cbm_ti_node_t *n) { + if (strcmp(n->label, "Function") != 0 && strcmp(n->label, "Method") != 0) { + return -1; + } + char stack[512]; + size_t a = strlen(file); + size_t b = strlen(n->name); + if (a + b + 2 > sizeof(stack)) { + return -1; + } + memcpy(stack, file, a); + stack[a] = '\x1f'; + memcpy(stack + a + 1, n->name, b + 1); + return (int)(intptr_t)cbm_ht_get(st->case_index, stack) - 1; +} + +static int ts_suite_of(ts_state_t *st, const char *file, const cbm_ti_node_t *n) { + if (strcmp(n->label, "Function") != 0 && strcmp(n->label, "Method") != 0) { + return -1; + } + char stack[512]; + size_t a = strlen(file); + size_t b = strlen(n->name); + if (a + b + 2 > sizeof(stack)) { + return -1; + } + memcpy(stack, file, a); + stack[a] = '\x1f'; + memcpy(stack + a + 1, n->name, b + 1); + return (int)(intptr_t)cbm_ht_get(st->suite_index, stack) - 1; +} + +/* The names a removed line declared (replay4 548-552): define, tag + * declaration or prototype, when the line does not end in ';'. Every + * pattern that matches adds its name. */ +static bool ts_note_removed_line(ts_state_t *st, const char *line) { + size_t n = strlen(line); + size_t e = n; + while (e > 0 && (line[e - 1] == ' ' || line[e - 1] == '\t' || line[e - 1] == '\r' || + line[e - 1] == '\f' || line[e - 1] == '\v')) { + e--; + } + if (e > 0 && line[e - 1] == ';') { + return true; + } + cbm_ti_span_t span; + if (cbm_ti_match_define(line, n, &span, NULL) && !ts_add_deleted(st, line, span)) { + return false; + } + if (cbm_ti_match_tag_declaration(line, n, &span) && !ts_add_deleted(st, line, span)) { + return false; + } + if (cbm_ti_match_prototype(line, n, &span) && !ts_add_deleted(st, line, span)) { + return false; + } + return true; +} + +static bool ts_comment_only(const cbm_diff_hunk_t *h) { + for (int i = 0; i < h->added_count; i++) { + if (!cbm_ti_line_is_comment(h->added[i], strlen(h->added[i]))) { + return false; + } + } + for (int i = 0; i < h->removed_count; i++) { + if (!cbm_ti_line_is_comment(h->removed[i], strlen(h->removed[i]))) { + return false; + } + } + return true; +} + +/* A seed, or a changed case/suite (replay4 558-566). */ +static bool ts_take_definition(ts_state_t *st, const char *file, const cbm_ti_node_t *n) { + int c = ts_case_of(st, file, n); + if (c >= 0) { + st->case_changed[c] = true; + return true; + } + int s = ts_suite_of(st, file, n); + if (s >= 0) { + st->suite_changed[s] = true; + return true; + } + return ts_add_node(st, n->id); +} + +/* The definition around a pure deletion: at the line the removal follows, + * else the next one (replay4 546). */ +static const cbm_ti_node_t *ts_around_deletion(ts_state_t *st, const char *file, int line, + bool *ok) { + const cbm_ti_node_t *d = cbm_ti_graph_enclosing(st->in->graph, file, line, ok); + if (!d && *ok) { + d = cbm_ti_graph_enclosing(st->in->graph, file, line + 1, ok); + } + return d; +} + +/* One hunk of a source (not header) file (replay4 540-566). */ +static bool ts_source_hunk(ts_state_t *st, const char *file, const cbm_diff_hunk_t *h) { + if (ts_comment_only(h)) { + return true; + } + bool ok = true; + int overlapped = 0; + if (h->count > 0) { + int count = 0; + const cbm_ti_node_t *nodes = cbm_ti_graph_file(st->in->graph, file, &count, &ok); + int last = h->start + h->count - 1; + for (int i = 0; ok && i < count; i++) { + if (nodes[i].start_line <= last && nodes[i].end_line >= h->start) { + overlapped++; + ok = ts_take_definition(st, file, &nodes[i]); + } + } + } else { + const cbm_ti_node_t *d = ts_around_deletion(st, file, h->start, &ok); + if (d && ok) { + overlapped = 1; + ok = ts_take_definition(st, file, d); + } + } + if (!ok) { + return st->status == CBM_TI_SEED_OK ? ts_fail(st, CBM_TI_SEED_STORE) : false; + } + for (int i = 0; i < h->removed_count; i++) { + if (!ts_note_removed_line(st, h->removed[i])) { + return false; + } + } + return overlapped ? true : ts_add_whole(st, file); +} + +/* The names a deleted file declared (replay4 521-528). */ +static bool ts_deleted_file(ts_state_t *st, const cbm_ti_change_t *c) { + const char *p = c->base_text ? c->base_text : ""; + const char *end = p + c->base_len; + while (p <= end) { + const char *nl = memchr(p, '\n', (size_t)(end - p)); + size_t len = nl ? (size_t)(nl - p) : (size_t)(end - p); + char *line = cbm_arena_strndup(&st->out->arena, p, len); + if (!line) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + if (!ts_note_removed_line(st, line)) { + return false; + } + if (!nl) { + break; + } + p = nl + 1; + } + return true; +} + +/* ── Header symbol propagation (replay4 header_seeds, 290-374) ──── */ + +typedef struct { + CBMHashTable *set; /* name -> 1 */ + ts_strs_t list; +} ts_names_t; + +static bool ts_names_add(ts_state_t *st, ts_names_t *names, const char *name, size_t len) { + char *copy = cbm_arena_strndup(&st->out->arena, name, len); + if (!copy) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + if (cbm_ht_get(names->set, copy)) { + return true; + } + if (!ts_strs_push(&names->list, copy)) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + cbm_ht_set(names->set, copy, (void *)1); + return true; +} + +static bool ts_names_has(const ts_names_t *names, const char *name) { + return cbm_ht_get(names->set, name) != NULL; +} + +/* Classify the lines of one header hunk. false = error; *file_level set when + * a line cannot be classified or is a preprocessor conditional. */ +static bool ts_header_hunk(ts_state_t *st, const char *header, const cbm_diff_hunk_t *h, + ts_names_t *changed, bool *file_level) { + bool ok = true; + int overlap_count = 0; + if (h->count > 0) { + int count = 0; + const cbm_ti_node_t *nodes = cbm_ti_graph_file(st->in->graph, header, &count, &ok); + int last = h->start + h->count - 1; + for (int i = 0; ok && i < count; i++) { + if (nodes[i].start_line <= last && nodes[i].end_line >= h->start) { + overlap_count++; + ok = ts_names_add(st, changed, nodes[i].name, strlen(nodes[i].name)); + } + } + } + int total = h->added_count + h->removed_count; + for (int k = 0; ok && k < total; k++) { + const char *ln = k < h->added_count ? h->added[k] : h->removed[k - h->added_count]; + size_t len = strlen(ln); + if (cbm_ti_line_is_comment(ln, len)) { + continue; + } + if (cbm_ti_line_is_pp_condition(ln, len)) { + *file_level = true; + continue; + } + cbm_ti_span_t span; + if (cbm_ti_match_declaration(ln, len, &span)) { + ok = ts_names_add(st, changed, ln + span.start, span.len); + continue; + } + if (h->count > 0 && overlap_count > 0) { + continue; /* inside a definition at head: named by the overlap */ + } + if (h->count == 0) { + const cbm_ti_node_t *d = ts_around_deletion(st, header, h->start, &ok); + if (d && ok) { + ok = ts_names_add(st, changed, d->name, strlen(d->name)); + continue; + } + } + if (ok) { + *file_level = true; + } + } + if (!ok && st->status == CBM_TI_SEED_OK) { + ts_fail(st, CBM_TI_SEED_STORE); + } + return ok; +} + +static bool ts_starts_with(const char *s, const char *prefix) { + return strncmp(s, prefix, strlen(prefix)) == 0; +} + +static bool ts_ends_with(const char *s, const char *suffix) { + size_t a = strlen(s); + size_t b = strlen(suffix); + return a >= b && strcmp(s + a - b, suffix) == 0; +} + +static bool ts_macro_hit(const cbm_ti_macro_t *m, const ts_names_t *changed) { + for (int i = 0; i < m->token_count; i++) { + if (ts_names_has(changed, m->tokens[i])) { + return true; + } + } + for (int k = 0; k < changed->list.count; k++) { + const char *c = changed->list.items[k]; + for (int i = 0; i < m->paste_prefix_count; i++) { + if (ts_starts_with(c, m->paste_prefixes[i])) { + return true; + } + } + for (int i = 0; i < m->paste_suffix_count; i++) { + if (ts_ends_with(c, m->paste_suffixes[i])) { + return true; + } + } + } + return false; +} + +/* Close the changed names over the headers' macros (replay4 328-345). */ +static bool ts_macro_fixpoint(ts_state_t *st, ts_names_t *changed) { + int count = 0; + const cbm_ti_macro_t *macros = cbm_ti_source_macros(st->in->source, &count); + bool grew = true; + while (grew) { + grew = false; + for (int i = 0; i < count; i++) { + if (ts_names_has(changed, macros[i].name) || !ts_macro_hit(¯os[i], changed)) { + continue; + } + if (!ts_names_add(st, changed, macros[i].name, strlen(macros[i].name))) { + return false; + } + grew = true; + } + } + return true; +} + +static bool ts_is_function(const cbm_ti_node_t *n) { + return strcmp(n->label, "Function") == 0 || strcmp(n->label, "Method") == 0; +} + +/* One occurrence of a propagated name in a file including the header + * (replay4 355-373). */ +static bool ts_header_occurrence(ts_state_t *st, int file, int line, ts_names_t *work) { + cbm_ti_source_t *src = st->in->source; + const char *path = cbm_ti_source_path(src, file); + bool ok = true; + const cbm_ti_node_t *d = cbm_ti_graph_enclosing(st->in->graph, path, line, &ok); + if (!ok) { + return ts_fail(st, CBM_TI_SEED_STORE); + } + if (d) { + if (!ts_add_node(st, d->id)) { + return false; + } + if (cbm_ti_source_is_header(src, file) && !ts_is_function(d) && d->name[0]) { + return ts_names_add(st, work, d->name, strlen(d->name)); + } + return true; + } + if (!cbm_ti_source_is_header(src, file)) { + return ts_add_whole(st, path); + } + size_t len = 0; + const char *text = cbm_ti_source_line(src, file, line, &len); + cbm_ti_span_t span; + if (text && cbm_ti_match_declaration(text, len, &span)) { + return ts_names_add(st, work, text + span.start, span.len); + } + return true; +} + +/* Seed what mentions a propagated name inside the include closure; names + * found on the way join the work list (replay4 346-374). The work list is the + * set of names in insertion order, so each is scanned once. */ +static bool ts_header_scan(ts_state_t *st, const bool *closure, ts_names_t *work) { + cbm_ti_source_t *src = st->in->source; + if (!cbm_ti_source_build_occurrences(src)) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + for (int k = 0; k < work->list.count; k++) { + const char *name = work->list.items[k]; + int count = 0; + const cbm_ti_place_t *places = cbm_ti_source_occurrences(src, name, strlen(name), &count); + for (int i = 0; i < count; i++) { + if (closure[places[i].file] && + !ts_header_occurrence(st, places[i].file, places[i].line, work)) { + return false; + } + } + } + return true; +} + +static bool ts_header(ts_state_t *st, const cbm_ti_change_t *c) { + ts_names_t changed = {.set = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0)}; + bool *closure = NULL; + bool file_level = false; + bool ok = changed.set != NULL || ts_fail(st, CBM_TI_SEED_OOM); + for (int i = 0; ok && i < c->hunk_count; i++) { + ok = ts_header_hunk(st, c->path, &c->hunks[i], &changed, &file_level); + } + if (ok && (file_level || c->whole_file)) { + const char *owned = cbm_arena_strdup(&st->out->arena, c->path); + ok = (owned && ts_strs_push(&st->out->file_level, owned)) || ts_fail(st, CBM_TI_SEED_OOM); + } else if (ok) { + int files = cbm_ti_source_file_count(st->in->source); + int at = cbm_ti_source_find(st->in->source, c->path); + closure = cbm_alloc(CBM_MEM_CLASS_EXTRACT, (size_t)(files ? files : 1) * sizeof(bool)); + ok = closure != NULL || ts_fail(st, CBM_TI_SEED_OOM); + if (ok) { + memset(closure, 0, (size_t)(files ? files : 1) * sizeof(bool)); + if (at >= 0) { + closure[at] = true; + } + cbm_ti_source_include_closure(st->in->source, closure); + ok = ts_macro_fixpoint(st, &changed) && ts_header_scan(st, closure, &changed); + } + } + cbm_free(CBM_MEM_CLASS_EXTRACT, closure); + cbm_free(CBM_MEM_CLASS_EXTRACT, changed.list.items); + if (changed.set) { + cbm_ht_free(changed.set); + } + return ok; +} + +/* ── Deleted names (replay4 name_reference_seeds, 377-389) ────────── */ + +static bool ts_name_references(ts_state_t *st) { + cbm_ti_source_t *src = st->in->source; + bool built = false; + for (int k = 0; k < st->out->deleted.count; k++) { + const char *name = st->out->deleted.items[k]; + if (cbm_ti_is_keyword(name, strlen(name))) { + continue; + } + bool ok = true; + if (cbm_ti_graph_has_name(st->in->graph, name, &ok) || !ok) { + if (!ok) { + return ts_fail(st, CBM_TI_SEED_STORE); + } + continue; /* still defined at head: an ordinary seed already */ + } + if (!built && !cbm_ti_source_build_occurrences(src)) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + built = true; + int count = 0; + const cbm_ti_place_t *places = cbm_ti_source_occurrences(src, name, strlen(name), &count); + for (int i = 0; i < count; i++) { + const char *path = cbm_ti_source_path(src, places[i].file); + const cbm_ti_node_t *d = + cbm_ti_graph_enclosing(st->in->graph, path, places[i].line, &ok); + if (!ok) { + return ts_fail(st, CBM_TI_SEED_STORE); + } + if (d ? !ts_add_node(st, d->id) + : (!cbm_ti_source_is_header(src, places[i].file) && !ts_add_whole(st, path))) { + return false; + } + } + } + return true; +} + +/* Every definition of a whole file is a seed; a case in a changed file is a + * changed test (replay4 577-581). Suites and cases are seeded too: the walk + * holds them as reached sinks. */ +static bool ts_whole_files(ts_state_t *st) { + for (int k = 0; k < st->whole_list.count; k++) { + const char *path = st->whole_list.items[k]; + bool ok = true; + int count = 0; + const cbm_ti_node_t *nodes = cbm_ti_graph_file(st->in->graph, path, &count, &ok); + if (!ok) { + return ts_fail(st, CBM_TI_SEED_STORE); + } + bool changed = cbm_ht_get(st->changed_path, path) != NULL; + for (int i = 0; i < count; i++) { + int c = ts_case_of(st, path, &nodes[i]); + if (c >= 0 && changed) { + st->case_changed[c] = true; + } + if (!ts_add_node(st, nodes[i].id)) { + return false; + } + } + } + return true; +} + +/* ── Driver ──────────────────────────────────────────────────────── */ + +static bool ts_index_model(ts_state_t *st) { + const cbm_test_case_t *cases = cbm_test_model_cases(st->in->model, &st->case_count); + const cbm_test_suite_t *suites = cbm_test_model_suites(st->in->model, &st->suite_count); + st->case_changed = cbm_arena_calloc(&st->out->arena, (size_t)st->case_count + 1); + st->suite_changed = cbm_arena_calloc(&st->out->arena, (size_t)st->suite_count + 1); + if (!st->case_changed || !st->suite_changed) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + for (int i = 0; i < st->case_count; i++) { + const char *key = ts_key(st, cases[i].file, cases[i].name); + if (!key) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + cbm_ht_set(st->case_index, key, (void *)(intptr_t)(i + 1)); + } + for (int i = 0; i < st->suite_count; i++) { + const char *key = ts_key(st, suites[i].file, suites[i].name); + if (!key) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + cbm_ht_set(st->suite_index, key, (void *)(intptr_t)(i + 1)); + } + for (int i = 0; i < st->in->change_count; i++) { + cbm_ht_set(st->changed_path, st->in->changes[i].path, (void *)1); + } + return true; +} + +static bool ts_change(ts_state_t *st, const cbm_ti_change_t *c) { + if (c->deleted) { + return ts_deleted_file(st, c); + } + size_t n = strlen(c->path); + bool header = (n >= 2 && strcmp(c->path + n - 2, ".h") == 0) || + (n >= 4 && strcmp(c->path + n - 4, ".hpp") == 0) || + (n >= 3 && strcmp(c->path + n - 3, ".hh") == 0); + if (header) { + return ts_header(st, c); + } + if (c->whole_file) { + return ts_add_whole(st, c->path); /* no hunks to read: more, never fewer */ + } + for (int i = 0; i < c->hunk_count; i++) { + if (!ts_source_hunk(st, c->path, &c->hunks[i])) { + return false; + } + } + return true; +} + +static int ts_id_compare(const void *left, const void *right) { + int64_t a = *(const int64_t *)left; + int64_t b = *(const int64_t *)right; + return a < b ? -1 : (a > b ? 1 : 0); +} + +static bool ts_collect(ts_state_t *st) { + if (st->out->nodes.count > 1) { + qsort(st->out->nodes.items, (size_t)st->out->nodes.count, sizeof(int64_t), ts_id_compare); + } + for (int i = 0; i < st->case_count; i++) { + if (st->case_changed[i] && !ts_ints_push(&st->out->cases, i)) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + } + for (int i = 0; i < st->suite_count; i++) { + if (st->suite_changed[i] && !ts_ints_push(&st->out->suites, i)) { + return ts_fail(st, CBM_TI_SEED_OOM); + } + } + return true; +} + +static void ts_state_free(ts_state_t *st) { + CBMHashTable *tables[] = {st->seen_nodes, st->whole, st->deleted, + st->case_index, st->suite_index, st->changed_path}; + for (size_t i = 0; i < sizeof(tables) / sizeof(tables[0]); i++) { + if (tables[i]) { + cbm_ht_free(tables[i]); + } + } + cbm_free(CBM_MEM_CLASS_EXTRACT, st->whole_list.items); +} + +cbm_ti_seed_status_t cbm_ti_seed(const cbm_ti_seed_input_t *in, cbm_ti_seeds_t **out) { + if (!out) { + return CBM_TI_SEED_INVALID; + } + *out = NULL; + if (!in || !in->model || !in->graph || !in->source || (in->change_count && !in->changes)) { + return CBM_TI_SEED_INVALID; + } + cbm_ti_seeds_t *seeds = cbm_alloc(CBM_MEM_CLASS_EXTRACT, sizeof(*seeds)); + if (!seeds) { + return CBM_TI_SEED_OOM; + } + memset(seeds, 0, sizeof(*seeds)); + cbm_arena_init(&seeds->arena); + ts_state_t st = {.in = in, .out = seeds}; + st.seen_nodes = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + st.whole = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + st.deleted = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + st.case_index = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + st.suite_index = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + st.changed_path = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + bool ok = (st.seen_nodes && st.whole && st.deleted && st.case_index && st.suite_index && + st.changed_path) || + ts_fail(&st, CBM_TI_SEED_OOM); + ok = ok && ts_index_model(&st); + for (int i = 0; ok && i < in->change_count; i++) { + ok = ts_change(&st, &in->changes[i]); + } + ok = ok && ts_name_references(&st) && ts_whole_files(&st) && ts_collect(&st); + ts_state_free(&st); + if (!ok) { + cbm_ti_seeds_free(seeds); + return st.status == CBM_TI_SEED_OK ? CBM_TI_SEED_OOM : st.status; + } + *out = seeds; + return CBM_TI_SEED_OK; +} + +void cbm_ti_seeds_free(cbm_ti_seeds_t *s) { + if (!s) { + return; + } + cbm_free(CBM_MEM_CLASS_EXTRACT, s->nodes.items); + cbm_free(CBM_MEM_CLASS_EXTRACT, s->cases.items); + cbm_free(CBM_MEM_CLASS_EXTRACT, s->suites.items); + cbm_free(CBM_MEM_CLASS_EXTRACT, s->file_level.items); + cbm_free(CBM_MEM_CLASS_EXTRACT, s->deleted.items); + cbm_arena_destroy(&s->arena); + cbm_free(CBM_MEM_CLASS_EXTRACT, s); +} + +const int64_t *cbm_ti_seeds_nodes(const cbm_ti_seeds_t *s, int *count) { + *count = s ? s->nodes.count : 0; + return s ? s->nodes.items : NULL; +} + +const int *cbm_ti_seeds_changed_cases(const cbm_ti_seeds_t *s, int *count) { + *count = s ? s->cases.count : 0; + return s ? s->cases.items : NULL; +} + +const int *cbm_ti_seeds_changed_suites(const cbm_ti_seeds_t *s, int *count) { + *count = s ? s->suites.count : 0; + return s ? s->suites.items : NULL; +} + +const char *const *cbm_ti_seeds_file_level_headers(const cbm_ti_seeds_t *s, int *count) { + *count = s ? s->file_level.count : 0; + return s ? s->file_level.items : NULL; +} + +const char *const *cbm_ti_seeds_deleted_names(const cbm_ti_seeds_t *s, int *count) { + *count = s ? s->deleted.count : 0; + return s ? s->deleted.items : NULL; +} diff --git a/src/mcp/test_impact_seed.h b/src/mcp/test_impact_seed.h new file mode 100644 index 000000000..23e97114b --- /dev/null +++ b/src/mcp/test_impact_seed.h @@ -0,0 +1,104 @@ +/* + * test_impact_seed.h — what a change seeds in the graph (smart-ci-design 5.1, + * 5.8), for detect_changes scope:"tests". + * + * A port of the measured reference (scratchpad tia3/replay4.py, seeding and + * header symbol propagation), rule for rule: + * - a hunk with lines at head seeds the definitions it overlaps; a hunk of + * comment lines only seeds nothing; a pure deletion seeds the definition + * around the deletion point; a hunk that overlaps no definition seeds its + * whole file; + * - the names a `-` line declared and head no longer defines are looked up + * by text: every definition whose lines mention one is a seed + * (NAME_REFERENCE), and a mention outside any definition of a source file + * seeds that file whole; a deleted file contributes every name it had; + * - a header change names what it changes (declared names, overlapped + * definitions), closes those names over the headers' macros (pastes + * included), and seeds every definition that mentions one in a file + * including the header; a line it cannot classify, or any preprocessor + * conditional, makes the header "file level": every test file including it + * runs its suites whole; + * - a changed test case or suite body is reported as such, never seeded. + * Every approximation selects more tests, never fewer. + */ +#ifndef CBM_TEST_IMPACT_SEED_H +#define CBM_TEST_IMPACT_SEED_H + +#include "mcp/test_impact.h" +#include "mcp/test_impact_source.h" +#include "store/store.h" + +#include +#include + +/* The graph of the snapshot, as the seeding reads it. */ +typedef struct cbm_ti_graph cbm_ti_graph_t; + +typedef struct { + int64_t id; + const char *label; + const char *name; + const char *qualified_name; + int start_line; + int end_line; +} cbm_ti_node_t; + +/* Borrows the store until free. Nodes of a file are read once, on first use. */ +cbm_ti_graph_t *cbm_ti_graph_new(cbm_store_t *store, const char *project); +void cbm_ti_graph_free(cbm_ti_graph_t *g); +/* The seedable definitions of a file (every label but File, Folder, Project, + * Module, Package, Section, Route, Resource), ordered by (start, end, id). + * NULL with *ok=false when the store failed: a failed read is never "none". */ +const cbm_ti_node_t *cbm_ti_graph_file(cbm_ti_graph_t *g, const char *file, int *count, bool *ok); +/* The innermost definition containing the line: smallest span, then the + * earliest start, then the smallest id. NULL when none (or *ok=false). */ +const cbm_ti_node_t *cbm_ti_graph_enclosing(cbm_ti_graph_t *g, const char *file, int line, + bool *ok); +/* Whether any node of the snapshot has this name (deleted-name check). */ +bool cbm_ti_graph_has_name(cbm_ti_graph_t *g, const char *name, bool *ok); + +/* One changed path the seeding reads: C-family, with its hunks unless the + * change has no usable text (then whole_file). */ +typedef struct { + const char *path; + bool deleted; /* gone at head: only its base names matter */ + bool whole_file; /* no hunks to read: seed the whole file */ + const cbm_diff_hunk_t *hunks; + int hunk_count; + /* For a deleted file: its text at the merge base (names it declared). */ + const char *base_text; + size_t base_len; +} cbm_ti_change_t; + +typedef struct { + const cbm_ti_change_t *changes; + int change_count; + const cbm_test_model_t *model; /* cases and suites at head */ + cbm_ti_graph_t *graph; + cbm_ti_source_t *source; /* finished; occurrences built on demand */ +} cbm_ti_seed_input_t; + +typedef struct cbm_ti_seeds cbm_ti_seeds_t; + +typedef enum { + CBM_TI_SEED_OK = 0, + CBM_TI_SEED_INVALID, + CBM_TI_SEED_STORE, /* the graph could not be read */ + CBM_TI_SEED_OOM +} cbm_ti_seed_status_t; + +cbm_ti_seed_status_t cbm_ti_seed(const cbm_ti_seed_input_t *in, cbm_ti_seeds_t **out); +void cbm_ti_seeds_free(cbm_ti_seeds_t *seeds); + +/* Seed node ids, ascending, unique. */ +const int64_t *cbm_ti_seeds_nodes(const cbm_ti_seeds_t *s, int *count); +/* Model cases whose definition a hunk touched (indices into the model's + * cases), and suites whose body a hunk touched (indices into its suites). */ +const int *cbm_ti_seeds_changed_cases(const cbm_ti_seeds_t *s, int *count); +const int *cbm_ti_seeds_changed_suites(const cbm_ti_seeds_t *s, int *count); +/* Headers changed at file level (paths). */ +const char *const *cbm_ti_seeds_file_level_headers(const cbm_ti_seeds_t *s, int *count); +/* Names head no longer defines that were looked up by text. */ +const char *const *cbm_ti_seeds_deleted_names(const cbm_ti_seeds_t *s, int *count); + +#endif /* CBM_TEST_IMPACT_SEED_H */ diff --git a/src/mcp/test_impact_select.c b/src/mcp/test_impact_select.c index e88d2ff99..44e423f99 100644 --- a/src/mcp/test_impact_select.c +++ b/src/mcp/test_impact_select.c @@ -135,7 +135,7 @@ cbm_test_selection_t *cbm_test_select(const cbm_test_selection_input_t *input) { if (!input->has_changes) return result; unsigned incomplete = 0; - if (!input->inventory_complete || !cbm_test_model_complete(input->model)) + if (!input->inventory_complete || !cbm_test_model_narrowable(input->model)) incomplete |= CBM_TEST_SELECT_INVENTORY_UNKNOWN; if (!input->static_complete) incomplete |= CBM_TEST_SELECT_STATIC_INCOMPLETE; @@ -207,7 +207,7 @@ cbm_test_selection_t *cbm_test_select(const cbm_test_selection_input_t *input) { int at = sl_suite_find(result->suites, runner_count, definitions[i].name); if (at >= 0) { definition_counts[at]++; - if (definitions[i].macro_registrations) + if (definitions[i].macro_registrations || definitions[i].uncertain) sl_whole(&result->suites[at], CBM_TEST_SELECT_INVENTORY_UNKNOWN); } } @@ -235,13 +235,16 @@ cbm_test_selection_t *cbm_test_select(const cbm_test_selection_input_t *input) { for (int i = 0; i < registration_count; i++) { const cbm_test_registration_t *reg = order[i]; int at = sl_suite_find(result->suites, runner_count, reg->suite); - if (at < 0) - return sl_full(result, CBM_TEST_SELECT_INVENTORY_UNKNOWN); - cbm_test_selected_suite_t *suite = &result->suites[at]; - registration_counts[at]++; sl_reach_t *evidence = sl_reach_find(reach, input->reach_count, reg->file, reg->test); if (evidence) evidence->registered = true; + if (at < 0) + /* A suite this runner never registers runs elsewhere (another + * runner's list) or not at all: its tests are outside this + * selection, and a change to one is no unregistered change. */ + continue; + cbm_test_selected_suite_t *suite = &result->suites[at]; + registration_counts[at]++; const cbm_test_case_t *test = sl_case_find(case_order, case_count, reg->file, reg->test); if (!reg->resolved || !test) sl_whole(suite, CBM_TEST_SELECT_INVENTORY_UNKNOWN); diff --git a/src/mcp/test_impact_source.c b/src/mcp/test_impact_source.c new file mode 100644 index 000000000..25a1ffca8 --- /dev/null +++ b/src/mcp/test_impact_source.c @@ -0,0 +1,965 @@ +/* + * test_impact_source.c — text facts about the C-family sources of one snapshot. + * + * Each line pattern reproduces one regular expression of the measured + * reference (replay4.py) by hand, so its matches are the same without a regex + * engine on any platform. The comment above each function quotes the + * expression it reproduces. + */ +#include "mcp/test_impact_source.h" + +#include "foundation/arena.h" +#include "foundation/hash_table.h" +#include "foundation/mem_core.h" + +#include +#include +#include + +/* ── Characters ───────────────────────────────────────────────────── */ + +static bool ti_space(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\f' || c == '\v' || c == '\n'; +} + +static bool ti_word(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; +} + +static bool ti_start(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; +} + +static size_t ti_skip_space(const char *s, size_t i, size_t n) { + while (i < n && ti_space(s[i])) { + i++; + } + return i; +} + +/* The end of s[from..n) without its trailing whitespace. */ +static size_t ti_trim_end(const char *s, size_t from, size_t n) { + while (n > from && ti_space(s[n - 1])) { + n--; + } + return n; +} + +static size_t ti_word_end(const char *s, size_t i, size_t n) { + while (i < n && ti_word(s[i])) { + i++; + } + return i; +} + +static bool ti_at(const char *s, size_t i, size_t n, const char *word) { + size_t len = strlen(word); + return i + len <= n && memcmp(s + i, word, len) == 0; +} + +/* `word` at i followed by a word boundary. */ +static bool ti_keyword_at(const char *s, size_t i, size_t n, const char *word) { + size_t len = strlen(word); + return ti_at(s, i, n, word) && (i + len == n || !ti_word(s[i + len])); +} + +/* `word` at i followed by at least one blank: `word\s+`. */ +static bool ti_word_then_space(const char *s, size_t i, size_t n, const char *word) { + size_t len = strlen(word); + return ti_at(s, i, n, word) && i + len < n && ti_space(s[i + len]); +} + +/* An identifier may start at i: `\b[A-Za-z_]`. */ +static bool ti_identifier_at(const char *s, size_t i) { + return ti_start(s[i]) && (i == 0 || !ti_word(s[i - 1])); +} + +/* After an identifier ending at e: `\s*(?:\[[^\]]*\])?\s*;`. */ +static bool ti_array_then_semicolon(const char *s, size_t e, size_t n) { + size_t j = ti_skip_space(s, e, n); + if (j < n && s[j] == ';') { + return true; + } + if (j >= n || s[j] != '[') { + return false; + } + const char *close = memchr(s + j + 1, ']', n - j - 1); + if (!close) { + return false; + } + j = ti_skip_space(s, (size_t)(close - s) + 1, n); + return j < n && s[j] == ';'; +} + +/* ── Keywords (replay4 C_KEYWORDS), sorted for bsearch ───────────────── */ + +static const char *const ti_keywords[] = { + "NULL", "auto", "bool", "break", "case", "char", "const", "continue", + "default", "define", "defined", "do", "double", "elif", "else", "endif", + "enum", "extern", "false", "float", "for", "goto", "if", "ifdef", + "ifndef", "include", "inline", "int", "int16_t", "int32_t", "int64_t", "int8_t", + "long", "pragma", "register", "restrict", "return", "short", "signed", "size_t", + "sizeof", "static", "struct", "switch", "true", "typedef", "uint16_t", "uint32_t", + "uint64_t", "uint8_t", "union", "unsigned", "void", "volatile", "while"}; + +bool cbm_ti_is_keyword(const char *word, size_t len) { + size_t lo = 0; + size_t hi = sizeof(ti_keywords) / sizeof(ti_keywords[0]); + while (lo < hi) { + size_t mid = lo + (hi - lo) / 2; + const char *k = ti_keywords[mid]; + size_t klen = strlen(k); + int c = memcmp(word, k, len < klen ? len : klen); + if (c == 0) { + c = len < klen ? -1 : (len > klen ? 1 : 0); + } + if (c == 0) { + return true; + } + if (c < 0) { + hi = mid; + } else { + lo = mid + 1; + } + } + return false; +} + +/* ── Line patterns ─────────────────────────────────────────────────── */ + +/* `^\s*(//.*|/\*.*|\*.*|.*\*\/\s*)?$` */ +bool cbm_ti_line_is_comment(const char *s, size_t n) { + size_t i = ti_skip_space(s, 0, n); + if (i == n || s[i] == '*') { + return true; + } + if (s[i] == '/' && i + 1 < n && (s[i + 1] == '/' || s[i + 1] == '*')) { + return true; + } + size_t e = ti_trim_end(s, i, n); + return e - i >= 2 && s[e - 2] == '*' && s[e - 1] == '/'; +} + +/* `^\s*#\s*(if|ifdef|ifndef|elif|else|endif|undef|include)\b` */ +bool cbm_ti_line_is_pp_condition(const char *s, size_t n) { + static const char *const words[] = {"if", "ifdef", "ifndef", "elif", + "else", "endif", "undef", "include"}; + size_t i = ti_skip_space(s, 0, n); + if (i >= n || s[i] != '#') { + return false; + } + i = ti_skip_space(s, i + 1, n); + size_t e = ti_word_end(s, i, n); + for (size_t k = 0; k < sizeof(words) / sizeof(words[0]); k++) { + if (e - i == strlen(words[k]) && memcmp(s + i, words[k], e - i) == 0) { + return true; + } + } + return false; +} + +/* `^\s*#\s*define\s+([A-Za-z_]\w*)(\()?` */ +bool cbm_ti_match_define(const char *s, size_t n, cbm_ti_span_t *name, size_t *body) { + size_t i = ti_skip_space(s, 0, n); + if (i >= n || s[i] != '#') { + return false; + } + i = ti_skip_space(s, i + 1, n); + if (!ti_word_then_space(s, i, n, "define")) { + return false; + } + i = ti_skip_space(s, i + 6, n); + if (i >= n || !ti_start(s[i])) { + return false; + } + size_t e = ti_word_end(s, i, n); + *name = (cbm_ti_span_t){i, e - i}; + if (body) { + *body = e < n && s[e] == '(' ? e + 1 : e; + } + return true; +} + +/* `\btypedef\b.*?\b([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*;`, searched */ +bool cbm_ti_match_typedef(const char *s, size_t n, cbm_ti_span_t *name) { + for (size_t p = 0; p + 7 <= n; p++) { + if (!ti_keyword_at(s, p, n, "typedef") || (p > 0 && ti_word(s[p - 1]))) { + continue; + } + for (size_t q = p + 7; q < n; q++) { + if (!ti_identifier_at(s, q)) { + continue; + } + size_t e = ti_word_end(s, q, n); + if (ti_array_then_semicolon(s, e, n)) { + *name = (cbm_ti_span_t){q, e - q}; + return true; + } + } + } + return false; +} + +/* `^\s*(?:typedef\s+)?(?:struct|union|enum)\s+([A-Za-z_]\w*)\s*\{` */ +bool cbm_ti_match_tag_declaration(const char *s, size_t n, cbm_ti_span_t *name) { + size_t i = ti_skip_space(s, 0, n); + if (ti_word_then_space(s, i, n, "typedef")) { + i = ti_skip_space(s, i + 7, n); + } + size_t kw = ti_word_then_space(s, i, n, "struct") ? 6 + : ti_word_then_space(s, i, n, "union") ? 5 + : ti_word_then_space(s, i, n, "enum") ? 4 + : 0; + if (!kw) { + return false; + } + i = ti_skip_space(s, i + kw, n); + if (i >= n || !ti_start(s[i])) { + return false; + } + size_t e = ti_word_end(s, i, n); + size_t j = ti_skip_space(s, e, n); + if (j >= n || s[j] != '{') { + return false; + } + *name = (cbm_ti_span_t){i, e - i}; + return true; +} + +/* `^\s*extern\b[^;(]*?\b([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*;` */ +bool cbm_ti_match_extern(const char *s, size_t n, cbm_ti_span_t *name) { + size_t i = ti_skip_space(s, 0, n); + if (!ti_keyword_at(s, i, n, "extern")) { + return false; + } + for (size_t q = i + 6; q < n && s[q] != ';' && s[q] != '('; q++) { + if (!ti_identifier_at(s, q)) { + continue; + } + size_t e = ti_word_end(s, q, n); + if (ti_array_then_semicolon(s, e, n)) { + *name = (cbm_ti_span_t){q, e - q}; + return true; + } + } + return false; +} + +/* `^[A-Za-z_][\w \t\*]*?\b([A-Za-z_]\w*)\s*\(` */ +bool cbm_ti_match_prototype(const char *s, size_t n, cbm_ti_span_t *name) { + if (n == 0 || !ti_start(s[0])) { + return false; + } + for (size_t k = 1; k < n; k++) { + if (ti_identifier_at(s, k)) { + size_t e = ti_word_end(s, k, n); + size_t j = ti_skip_space(s, e, n); + if (j < n && s[j] == '(') { + *name = (cbm_ti_span_t){k, e - k}; + return true; + } + } + if (!ti_word(s[k]) && s[k] != ' ' && s[k] != '\t' && s[k] != '*') { + return false; + } + } + return false; +} + +bool cbm_ti_match_declaration(const char *s, size_t n, cbm_ti_span_t *name) { + return cbm_ti_match_define(s, n, name, NULL) || cbm_ti_match_typedef(s, n, name) || + cbm_ti_match_tag_declaration(s, n, name) || cbm_ti_match_extern(s, n, name) || + cbm_ti_match_prototype(s, n, name); +} + +/* `^\s*#\s*include\s+"([^"]+)"` */ +bool cbm_ti_match_include(const char *s, size_t n, cbm_ti_span_t *path) { + size_t i = ti_skip_space(s, 0, n); + if (i >= n || s[i] != '#') { + return false; + } + i = ti_skip_space(s, i + 1, n); + if (!ti_word_then_space(s, i, n, "include")) { + return false; + } + i = ti_skip_space(s, i + 7, n); + if (i >= n || s[i] != '"') { + return false; + } + const char *close = memchr(s + i + 1, '"', n - i - 1); + if (!close || close == s + i + 1) { + return false; + } + *path = (cbm_ti_span_t){i + 1, (size_t)(close - s) - i - 1}; + return true; +} + +/* ── The index ─────────────────────────────────────────────────────── */ + +typedef struct { + int *items; + int count; + int cap; +} ti_ints_t; + +typedef struct { + cbm_ti_place_t *items; + int count; + int cap; +} ti_places_t; + +typedef struct { + const char *path; + const char *text; + size_t len; + size_t *line_starts; /* line i (0-based) starts at line_starts[i] */ + int line_count; + bool header; + ti_ints_t includers; /* files that include this one */ +} ti_file_t; + +struct cbm_ti_source { + CBMArena arena; + ti_file_t *files; + int file_count; + int file_cap; + bool finished; + bool failed; + CBMHashTable *by_path; /* path -> file index + 1 */ + cbm_ti_macro_t *macros; + int macro_count; + int macro_cap; + CBMHashTable *occurrences; /* token -> ti_places_t* */ + bool occurrences_built; +}; + +static bool ti_grow(void **items, int *cap, int count, size_t size) { + if (count < *cap) { + return true; + } + int next = *cap ? *cap * 2 : 8; + void *grown = cbm_realloc(CBM_MEM_CLASS_EXTRACT, *items, (size_t)next * size); + if (!grown) { + return false; + } + *items = grown; + *cap = next; + return true; +} + +static bool ti_ints_push(ti_ints_t *a, int v) { + if (!ti_grow((void **)&a->items, &a->cap, a->count, sizeof(*a->items))) { + return false; + } + a->items[a->count++] = v; + return true; +} + +cbm_ti_source_t *cbm_ti_source_new(void) { + cbm_ti_source_t *s = cbm_alloc(CBM_MEM_CLASS_EXTRACT, sizeof(*s)); + if (!s) { + return NULL; + } + memset(s, 0, sizeof(*s)); + cbm_arena_init(&s->arena); + s->by_path = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + s->occurrences = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + if (!s->by_path || !s->occurrences) { + cbm_ti_source_free(s); + return NULL; + } + return s; +} + +static void ti_free_places(const char *key, void *value, void *userdata) { + (void)key; + (void)userdata; + ti_places_t *places = value; + cbm_free(CBM_MEM_CLASS_EXTRACT, places->items); + cbm_free(CBM_MEM_CLASS_EXTRACT, places); +} + +void cbm_ti_source_free(cbm_ti_source_t *s) { + if (!s) { + return; + } + for (int i = 0; i < s->file_count; i++) { + cbm_free(CBM_MEM_CLASS_EXTRACT, s->files[i].line_starts); + cbm_free(CBM_MEM_CLASS_EXTRACT, s->files[i].includers.items); + } + cbm_free(CBM_MEM_CLASS_EXTRACT, s->files); + cbm_free(CBM_MEM_CLASS_EXTRACT, s->macros); + if (s->occurrences) { + cbm_ht_foreach(s->occurrences, ti_free_places, NULL); + cbm_ht_free(s->occurrences); + } + if (s->by_path) { + cbm_ht_free(s->by_path); + } + cbm_arena_destroy(&s->arena); + cbm_free(CBM_MEM_CLASS_EXTRACT, s); +} + +static bool ti_is_header_path(const char *path) { + size_t n = strlen(path); + return (n >= 2 && strcmp(path + n - 2, ".h") == 0) || + (n >= 4 && strcmp(path + n - 4, ".hpp") == 0) || + (n >= 3 && strcmp(path + n - 3, ".hh") == 0); +} + +bool cbm_ti_source_add(cbm_ti_source_t *s, const char *path, const char *text, size_t len) { + if (!s || s->finished || !path || (!text && len) || cbm_ht_has(s->by_path, path)) { + return false; + } + if (!ti_grow((void **)&s->files, &s->file_cap, s->file_count, sizeof(*s->files))) { + return false; + } + char *own_text = cbm_arena_alloc(&s->arena, len + 1); + char *own_path = cbm_arena_strdup(&s->arena, path); + if (!own_text || !own_path) { + return false; + } + if (len) { + memcpy(own_text, text, len); + } + own_text[len] = '\0'; + s->files[s->file_count] = (ti_file_t){ + .path = own_path, .text = own_text, .len = len, .header = ti_is_header_path(own_path)}; + s->file_count++; + return true; +} + +/* Lines as the reference splits them (`text.split('\n')`): a final newline + * leaves one more, empty line. */ +static bool ti_split_lines(ti_file_t *f) { + int count = 1; + for (size_t i = 0; i < f->len; i++) { + count += f->text[i] == '\n'; + } + f->line_starts = cbm_alloc(CBM_MEM_CLASS_EXTRACT, (size_t)count * sizeof(size_t)); + if (!f->line_starts) { + return false; + } + int at = 0; + f->line_starts[at++] = 0; + for (size_t i = 0; i < f->len; i++) { + if (f->text[i] == '\n') { + f->line_starts[at++] = i + 1; + } + } + f->line_count = count; + return true; +} + +static int ti_path_compare(const void *left, const void *right) { + return strcmp(((const ti_file_t *)left)->path, ((const ti_file_t *)right)->path); +} + +int cbm_ti_source_file_count(const cbm_ti_source_t *s) { + return s ? s->file_count : 0; +} + +const char *cbm_ti_source_path(const cbm_ti_source_t *s, int file) { + return s && file >= 0 && file < s->file_count ? s->files[file].path : NULL; +} + +int cbm_ti_source_find(const cbm_ti_source_t *s, const char *path) { + if (!s || !path) { + return -1; + } + intptr_t at = (intptr_t)cbm_ht_get(s->by_path, path); + return at > 0 ? (int)at - 1 : -1; +} + +bool cbm_ti_source_is_header(const cbm_ti_source_t *s, int file) { + return s && file >= 0 && file < s->file_count && s->files[file].header; +} + +int cbm_ti_source_line_count(const cbm_ti_source_t *s, int file) { + return s && file >= 0 && file < s->file_count ? s->files[file].line_count : 0; +} + +const char *cbm_ti_source_line(const cbm_ti_source_t *s, int file, int line, size_t *len) { + if (!s || file < 0 || file >= s->file_count || line < 1 || line > s->files[file].line_count) { + if (len) { + *len = 0; + } + return NULL; + } + const ti_file_t *f = &s->files[file]; + size_t start = f->line_starts[line - 1]; + size_t end = line < f->line_count ? f->line_starts[line] - 1 : f->len; + if (len) { + *len = end - start; + } + return f->text + start; +} + +/* ── Includes ──────────────────────────────────────────────────────── */ + +/* os.path.normpath(os.path.join(dirname(from), inc)) for '/' paths. */ +static char *ti_normalize_join(CBMArena *arena, const char *from, const char *inc, size_t inc_len) { + size_t from_len = strlen(from); + const char *slash = NULL; + for (size_t i = 0; i < from_len; i++) { + if (from[i] == '/') { + slash = from + i; + } + } + size_t dir_len = inc_len && inc[0] == '/' ? 0 : (slash ? (size_t)(slash - from) : 0); + size_t total = dir_len + 1 + inc_len; + char *joined = cbm_arena_alloc(arena, total + 1); + char *out = cbm_arena_alloc(arena, total + 2); + if (!joined || !out) { + return NULL; + } + memcpy(joined, from, dir_len); + joined[dir_len] = '/'; + memcpy(joined + dir_len + 1, inc, inc_len); + joined[total] = '\0'; + bool absolute = inc_len && inc[0] == '/'; + size_t used = 0; + size_t depth = 0; /* segments that can be removed by a later ".." */ + const char *p = dir_len ? joined : joined + 1; + while (*p) { + const char *e = strchr(p, '/'); + size_t seg = e ? (size_t)(e - p) : strlen(p); + if (seg == 0 || (seg == 1 && p[0] == '.')) { + /* skip */ + } else if (seg == 2 && p[0] == '.' && p[1] == '.' && depth > 0) { + while (used > 0 && out[used - 1] != '/') { + used--; + } + if (used > 0) { + used--; + } + depth--; + } else if (!(seg == 2 && p[0] == '.' && p[1] == '.' && absolute)) { + if (used) { + out[used++] = '/'; + } + memcpy(out + used, p, seg); + used += seg; + depth += !(seg == 2 && p[0] == '.' && p[1] == '.'); + } + p += seg + (e ? 1 : 0); + } + out[used] = '\0'; + return out; +} + +/* Every segment suffix of every path, for includes that do not resolve from + * the including file's directory. */ +static bool ti_add_suffixes(cbm_ti_source_t *s, CBMHashTable *suffixes, ti_ints_t **lists, + int *list_count, int *list_cap) { + for (int f = 0; f < s->file_count; f++) { + const char *path = s->files[f].path; + for (const char *p = path; p; p = strchr(p, '/') ? strchr(p, '/') + 1 : NULL) { + intptr_t at = (intptr_t)cbm_ht_get(suffixes, p); + if (!at) { + if (!ti_grow((void **)lists, list_cap, *list_count, sizeof(**lists))) { + return false; + } + (*lists)[*list_count] = (ti_ints_t){0}; + at = ++*list_count; + cbm_ht_set(suffixes, p, (void *)at); + } + if (!ti_ints_push(&(*lists)[at - 1], f)) { + return false; + } + } + } + return true; +} + +static bool ti_add_includer(cbm_ti_source_t *s, int target, int from) { + ti_ints_t *in = &s->files[target].includers; + if (in->count && in->items[in->count - 1] == from) { + return true; + } + for (int i = 0; i < in->count; i++) { + if (in->items[i] == from) { + return true; + } + } + return ti_ints_push(in, from); +} + +static bool ti_resolve_include(cbm_ti_source_t *s, CBMHashTable *suffixes, const ti_ints_t *lists, + int from, const char *inc, size_t inc_len) { + char *normal = ti_normalize_join(&s->arena, s->files[from].path, inc, inc_len); + if (!normal) { + return false; + } + int direct = cbm_ti_source_find(s, normal); + if (direct >= 0) { + return ti_add_includer(s, direct, from); + } + size_t skip = 0; + while (skip < inc_len && (inc[skip] == '.' || inc[skip] == '/')) { + skip++; + } + char *key = cbm_arena_strndup(&s->arena, inc + skip, inc_len - skip); + if (!key) { + return false; + } + intptr_t at = (intptr_t)cbm_ht_get(suffixes, key); + for (int i = 0; at && i < lists[at - 1].count; i++) { + if (!ti_add_includer(s, lists[at - 1].items[i], from)) { + return false; + } + } + return true; +} + +static bool ti_build_includes(cbm_ti_source_t *s) { + CBMHashTable *suffixes = cbm_ht_create_in(CBM_MEM_CLASS_EXTRACT, 0); + ti_ints_t *lists = NULL; + int list_count = 0; + int list_cap = 0; + bool ok = suffixes && ti_add_suffixes(s, suffixes, &lists, &list_count, &list_cap); + for (int f = 0; ok && f < s->file_count; f++) { + for (int line = 1; ok && line <= s->files[f].line_count; line++) { + size_t len = 0; + const char *text = cbm_ti_source_line(s, f, line, &len); + cbm_ti_span_t inc; + if (cbm_ti_match_include(text, len, &inc)) { + ok = ti_resolve_include(s, suffixes, lists, f, text + inc.start, inc.len); + } + } + } + for (int i = 0; i < list_count; i++) { + cbm_free(CBM_MEM_CLASS_EXTRACT, lists[i].items); + } + cbm_free(CBM_MEM_CLASS_EXTRACT, lists); + if (suffixes) { + cbm_ht_free(suffixes); + } + return ok; +} + +void cbm_ti_source_include_closure(const cbm_ti_source_t *s, bool *marked) { + if (!s || !marked || !s->finished) { + return; + } + int *stack = cbm_alloc(CBM_MEM_CLASS_EXTRACT, (size_t)(s->file_count + 1) * sizeof(int)); + if (!stack) { + /* Out of memory: mark everything, which can only select more. */ + for (int i = 0; i < s->file_count; i++) { + marked[i] = true; + } + return; + } + int top = 0; + for (int i = 0; i < s->file_count; i++) { + if (marked[i]) { + stack[top++] = i; + } + } + while (top > 0) { + const ti_ints_t *in = &s->files[stack[--top]].includers; + for (int i = 0; i < in->count; i++) { + if (!marked[in->items[i]]) { + marked[in->items[i]] = true; + stack[top++] = in->items[i]; + } + } + } + cbm_free(CBM_MEM_CLASS_EXTRACT, stack); +} + +/* ── Header macros ─────────────────────────────────────────────────── */ + +typedef struct { + const char **items; + int count; + int cap; +} ti_strs_t; + +static bool ti_strs_add_unique(CBMArena *arena, ti_strs_t *list, const char *word, size_t len) { + for (int i = 0; i < list->count; i++) { + if (strlen(list->items[i]) == len && memcmp(list->items[i], word, len) == 0) { + return true; + } + } + char *copy = cbm_arena_strndup(arena, word, len); + if (!copy || !ti_grow((void **)&list->items, &list->cap, list->count, sizeof(*list->items))) { + return false; + } + list->items[list->count++] = copy; + return true; +} + +static int ti_str_compare(const void *left, const void *right) { + return strcmp(*(const char *const *)left, *(const char *const *)right); +} + +/* `re.findall(r'([A-Za-z_]\w*)\s*##|##\s*([A-Za-z_]\w*)', text)` */ +static bool ti_scan_pastes(CBMArena *arena, const char *t, size_t n, ti_strs_t *pre, + ti_strs_t *suf) { + size_t i = 0; + while (i < n) { + if (ti_start(t[i])) { + size_t e = ti_word_end(t, i, n); + size_t j = ti_skip_space(t, e, n); + if (j + 1 < n && t[j] == '#' && t[j + 1] == '#') { + if (!ti_strs_add_unique(arena, pre, t + i, e - i)) { + return false; + } + i = j + 2; + continue; + } + } + if (i + 1 < n && t[i] == '#' && t[i + 1] == '#') { + size_t j = ti_skip_space(t, i + 2, n); + if (j < n && ti_start(t[j])) { + size_t e = ti_word_end(t, j, n); + if (!ti_strs_add_unique(arena, suf, t + j, e - j)) { + return false; + } + i = e; + continue; + } + } + i++; + } + return true; +} + +/* `set(TOKEN_RX.findall(text)) - C_KEYWORDS - {name}` */ +static bool ti_scan_tokens(CBMArena *arena, const char *t, size_t n, const char *name, + ti_strs_t *out) { + size_t i = 0; + while (i < n) { + if (!ti_start(t[i])) { + i++; + continue; + } + size_t e = ti_word_end(t, i, n); + bool own = strlen(name) == e - i && memcmp(name, t + i, e - i) == 0; + if (!own && !cbm_ti_is_keyword(t + i, e - i) && + !ti_strs_add_unique(arena, out, t + i, e - i)) { + return false; + } + i = e; + } + return true; +} + +static bool ti_line_continues(const char *line, size_t len) { + while (len > 0 && ti_space(line[len - 1])) { + len--; + } + return len > 0 && line[len - 1] == '\\'; +} + +/* One `#define` starting at *line; *line ends on its last continuation line. + * The body is the text after the name (and '('), then each continuation + * line, joined with one blank. */ +static bool ti_read_macro(cbm_ti_source_t *s, int file, int *line, cbm_ti_span_t name, + size_t body) { + size_t len = 0; + const char *text = cbm_ti_source_line(s, file, *line, &len); + size_t cap = len - body + 1; + int last = *line; + const char *tail = text + body; + size_t tail_len = len - body; + while (ti_line_continues(tail, tail_len) && last < s->files[file].line_count) { + last++; + tail = cbm_ti_source_line(s, file, last, &tail_len); + cap += tail_len + 1; + } + char *joined = cbm_arena_alloc(&s->arena, cap + 1); + char *macro_name = cbm_arena_strndup(&s->arena, text + name.start, name.len); + if (!joined || !macro_name) { + return false; + } + size_t used = len - body; + memcpy(joined, text + body, used); + for (int l = *line + 1; l <= last; l++) { + const char *more = cbm_ti_source_line(s, file, l, &tail_len); + joined[used++] = ' '; + memcpy(joined + used, more, tail_len); + used += tail_len; + } + joined[used] = '\0'; + ti_strs_t tokens = {0}; + ti_strs_t pre = {0}; + ti_strs_t suf = {0}; + bool ok = ti_scan_tokens(&s->arena, joined, used, macro_name, &tokens) && + ti_scan_pastes(&s->arena, joined, used, &pre, &suf) && + ti_grow((void **)&s->macros, &s->macro_cap, s->macro_count, sizeof(*s->macros)); + if (ok && tokens.count > 1) { + qsort(tokens.items, (size_t)tokens.count, sizeof(*tokens.items), ti_str_compare); + } + const char **kept[3] = {NULL, NULL, NULL}; + ti_strs_t *lists[3] = {&tokens, &pre, &suf}; + for (int k = 0; ok && k < 3; k++) { + size_t bytes = (size_t)(lists[k]->count ? lists[k]->count : 1) * sizeof(char *); + kept[k] = cbm_arena_alloc(&s->arena, bytes); + ok = kept[k] != NULL; + if (ok && lists[k]->count) { + memcpy(kept[k], lists[k]->items, (size_t)lists[k]->count * sizeof(char *)); + } + } + if (ok) { + s->macros[s->macro_count++] = (cbm_ti_macro_t){.name = macro_name, + .file = file, + .tokens = kept[0], + .token_count = tokens.count, + .paste_prefixes = kept[1], + .paste_prefix_count = pre.count, + .paste_suffixes = kept[2], + .paste_suffix_count = suf.count}; + } + for (int k = 0; k < 3; k++) { + cbm_free(CBM_MEM_CLASS_EXTRACT, lists[k]->items); + } + *line = last; + return ok; +} + +static bool ti_build_macros(cbm_ti_source_t *s) { + for (int f = 0; f < s->file_count; f++) { + if (!s->files[f].header) { + continue; + } + for (int line = 1; line <= s->files[f].line_count; line++) { + size_t len = 0; + const char *text = cbm_ti_source_line(s, f, line, &len); + cbm_ti_span_t name; + size_t body = 0; + if (cbm_ti_match_define(text, len, &name, &body) && + !ti_read_macro(s, f, &line, name, body)) { + return false; + } + } + } + return true; +} + +const cbm_ti_macro_t *cbm_ti_source_macros(const cbm_ti_source_t *s, int *count) { + if (count) { + *count = s ? s->macro_count : 0; + } + return s ? s->macros : NULL; +} + +bool cbm_ti_source_finish(cbm_ti_source_t *s) { + if (!s || s->finished) { + return false; + } + if (s->file_count > 1) { + qsort(s->files, (size_t)s->file_count, sizeof(*s->files), ti_path_compare); + } + for (int f = 0; f < s->file_count; f++) { + if (!ti_split_lines(&s->files[f])) { + return false; + } + cbm_ht_set(s->by_path, s->files[f].path, (void *)(intptr_t)(f + 1)); + } + s->finished = true; + return ti_build_includes(s) && ti_build_macros(s); +} + +/* ── Occurrences ───────────────────────────────────────────────────── */ + +static bool ti_note_occurrence(cbm_ti_source_t *s, const char *word, size_t len, int file, + int line) { + char key[256]; + const char *lookup; + char *heap_key = NULL; + if (len < sizeof(key)) { + memcpy(key, word, len); + key[len] = '\0'; + lookup = key; + } else { + heap_key = cbm_arena_strndup(&s->arena, word, len); + if (!heap_key) { + return false; + } + lookup = heap_key; + } + ti_places_t *places = cbm_ht_get(s->occurrences, lookup); + if (!places) { + places = cbm_alloc(CBM_MEM_CLASS_EXTRACT, sizeof(*places)); + const char *owned = heap_key ? heap_key : cbm_arena_strndup(&s->arena, word, len); + if (!places || !owned) { + cbm_free(CBM_MEM_CLASS_EXTRACT, places); + return false; + } + *places = (ti_places_t){0}; + cbm_ht_set(s->occurrences, owned, places); + } + if (places->count && places->items[places->count - 1].file == file && + places->items[places->count - 1].line == line) { + return true; /* one place per line */ + } + if (!ti_grow((void **)&places->items, &places->cap, places->count, sizeof(*places->items))) { + return false; + } + places->items[places->count++] = (cbm_ti_place_t){file, line}; + return true; +} + +/* `for t in set(TOKEN_RX.findall(ln))` on lines not starting `//` or `*`. */ +static bool ti_scan_line(cbm_ti_source_t *s, int file, int line) { + size_t len = 0; + const char *text = cbm_ti_source_line(s, file, line, &len); + size_t i = ti_skip_space(text, 0, len); + if (i < len && (text[i] == '*' || (text[i] == '/' && i + 1 < len && text[i + 1] == '/'))) { + return true; + } + while (i < len) { + if (!ti_start(text[i])) { + i++; + continue; + } + size_t e = ti_word_end(text, i, len); + if (!cbm_ti_is_keyword(text + i, e - i) && + !ti_note_occurrence(s, text + i, e - i, file, line)) { + return false; + } + i = e; + } + return true; +} + +bool cbm_ti_source_build_occurrences(cbm_ti_source_t *s) { + if (!s || !s->finished || s->failed) { + return false; + } + if (s->occurrences_built) { + return true; + } + for (int f = 0; f < s->file_count; f++) { + for (int line = 1; line <= s->files[f].line_count; line++) { + if (!ti_scan_line(s, f, line)) { + s->failed = true; + return false; + } + } + } + s->occurrences_built = true; + return true; +} + +const cbm_ti_place_t *cbm_ti_source_occurrences(const cbm_ti_source_t *s, const char *token, + size_t len, int *count) { + *count = 0; + if (!s || !s->occurrences_built || !token) { + return NULL; + } + char key[256]; + if (len >= sizeof(key)) { + return NULL; /* never indexed under a stack key; looked up below */ + } + memcpy(key, token, len); + key[len] = '\0'; + const ti_places_t *places = cbm_ht_get(s->occurrences, key); + if (!places) { + return NULL; + } + *count = places->count; + return places->items; +} diff --git a/src/mcp/test_impact_source.h b/src/mcp/test_impact_source.h new file mode 100644 index 000000000..37cdbd65f --- /dev/null +++ b/src/mcp/test_impact_source.h @@ -0,0 +1,117 @@ +/* + * test_impact_source.h — text facts about the C-family sources of one snapshot. + * + * Some seeding rules of the test-impact engine (smart-ci-design 5.1 and 5.8) + * ask questions the graph cannot answer: which files include a header, + * directly or not; where an identifier is written; which macros the headers + * define and what their bodies name; what a changed line declares. This index + * answers them from the exact bytes of the snapshot's C-family files. + * + * It ports the measured reference (scratchpad tia3/replay4.py: build_occ, + * include_closure, header_macros and its line patterns) rule for rule, + * including where the rules over-approximate: identifiers inside strings and + * block comments count as occurrences. Every rule here can only add seeds, so + * an approximation selects more tests, never fewer. Identifiers are ASCII + * ([A-Za-z_][A-Za-z0-9_]*); a byte outside ASCII ends one. + */ +#ifndef CBM_TEST_IMPACT_SOURCE_H +#define CBM_TEST_IMPACT_SOURCE_H + +#include +#include + +/* ── Line patterns ───────────────────────────────────────────────── + * Each takes one line without its newline (a trailing '\r' is blank space) + * and reports the declared name as an offset and length into the line. */ + +typedef struct { + size_t start; + size_t len; +} cbm_ti_span_t; + +/* Blank, or a comment line: starts with `//`, `/ *` or `*`, or ends with the + * close of a block comment. */ +bool cbm_ti_line_is_comment(const char *line, size_t len); +/* `#if`, `#ifdef`, `#ifndef`, `#elif`, `#else`, `#endif`, `#undef` or + * `#include`. */ +bool cbm_ti_line_is_pp_condition(const char *line, size_t len); +/* `#define NAME`; *body is where the replacement text starts (after the + * parameter list's '(' for a function-like macro, as the reference reads it). */ +bool cbm_ti_match_define(const char *line, size_t len, cbm_ti_span_t *name, size_t *body); +/* The name a `typedef ... NAME [N];` introduces, anywhere on the line. */ +bool cbm_ti_match_typedef(const char *line, size_t len, cbm_ti_span_t *name); +/* `[typedef] struct|union|enum NAME {` at the start of the line. */ +bool cbm_ti_match_tag_declaration(const char *line, size_t len, cbm_ti_span_t *name); +/* `extern ... NAME [N];` at the start of the line. */ +bool cbm_ti_match_extern(const char *line, size_t len, cbm_ti_span_t *name); +/* A function name followed by '(' on a line that starts with an identifier + * character, not the first word: `int f(`, `static T *g (`. */ +bool cbm_ti_match_prototype(const char *line, size_t len, cbm_ti_span_t *name); +/* The name a changed header line declares, trying define, typedef, tag + * declaration, extern and prototype in that order (replay4 header_seeds). */ +bool cbm_ti_match_declaration(const char *line, size_t len, cbm_ti_span_t *name); +/* `#include "PATH"`: the quoted path. */ +bool cbm_ti_match_include(const char *line, size_t len, cbm_ti_span_t *path); +bool cbm_ti_is_keyword(const char *word, size_t len); + +/* ── The index ─────────────────────────────────────────────────────── */ + +typedef struct cbm_ti_source cbm_ti_source_t; + +typedef struct { + int file; /* index in path order */ + int line; /* 1-based */ +} cbm_ti_place_t; + +typedef struct { + const char *name; + int file; + /* Identifiers of the replacement text, keywords and the macro's own name + * removed. Sorted, unique. */ + const char *const *tokens; + int token_count; + /* `X ##` and `## X` operands: a changed name that starts with a prefix or + * ends with a suffix may be what the paste builds. */ + const char *const *paste_prefixes; + int paste_prefix_count; + const char *const *paste_suffixes; + int paste_suffix_count; +} cbm_ti_macro_t; + +/* NULL when out of memory. */ +cbm_ti_source_t *cbm_ti_source_new(void); +/* Add one file: its repository-relative path and exact bytes (both copied). + * false on a repeated path, after finish, or when out of memory. */ +bool cbm_ti_source_add(cbm_ti_source_t *s, const char *path, const char *text, size_t len); +/* Split lines, resolve includes and read the headers' macros. Files are then + * in path order. false when out of memory. */ +bool cbm_ti_source_finish(cbm_ti_source_t *s); +void cbm_ti_source_free(cbm_ti_source_t *s); + +int cbm_ti_source_file_count(const cbm_ti_source_t *s); +const char *cbm_ti_source_path(const cbm_ti_source_t *s, int file); +/* -1 when the snapshot has no such C-family file. */ +int cbm_ti_source_find(const cbm_ti_source_t *s, const char *path); +/* .h, .hpp, .hh */ +bool cbm_ti_source_is_header(const cbm_ti_source_t *s, int file); +int cbm_ti_source_line_count(const cbm_ti_source_t *s, int file); +/* Line text without its newline; NULL outside the file. */ +const char *cbm_ti_source_line(const cbm_ti_source_t *s, int file, int line, size_t *len); + +/* Mark every file that includes a marked file, directly or through other + * files. `marked` holds one entry per file. A quoted include names the file + * it resolves to from the including file's directory when that file exists, + * otherwise every file whose path ends with the included path. */ +void cbm_ti_source_include_closure(const cbm_ti_source_t *s, bool *marked); + +/* The `#define`s of the header files, in (file, line) order. */ +const cbm_ti_macro_t *cbm_ti_source_macros(const cbm_ti_source_t *s, int *count); + +/* Where an identifier is written, in (file, line) order, one place per line. + * Lines whose first non-blank text is `//` or `*` hold none, nor do keywords. + * The index is built on the first call: false from build means out of memory. */ +bool cbm_ti_source_build_occurrences(cbm_ti_source_t *s); +const cbm_ti_place_t *cbm_ti_source_occurrences(const cbm_ti_source_t *s, const char *token, + size_t len, int *count); + +#endif /* CBM_TEST_IMPACT_SOURCE_H */ diff --git a/src/mcp/test_impact_tree.c b/src/mcp/test_impact_tree.c index ed3f64ca0..6f3437623 100644 --- a/src/mcp/test_impact_tree.c +++ b/src/mcp/test_impact_tree.c @@ -27,14 +27,57 @@ static cbm_pinned_tree_t *tpt_owner(tpt_context *c, const cbm_pinned_tree_option return t; } +/* The entries the tree materializes: everything but symlinks and submodule + * links, which are counted instead. Other non-regular kinds stay in, so the + * plan still refuses them. Entries borrow the facts' strings, which outlive + * the create call. */ +static bool tpt_without_links(tpt_context *c, const cbm_git_tree_inventory_t *all, + cbm_git_tree_inventory_t *kept, size_t **original) { + *kept = (cbm_git_tree_inventory_t){0}; + *original = NULL; + if (all->count == 0) + return true; + cbm_git_tree_entry_t *entries = tpt_alloc(c, all->count, sizeof(*entries)); + size_t *positions = tpt_alloc(c, all->count, sizeof(*positions)); + if (!entries || !positions) + return false; + size_t n = 0; + size_t skipped = 0; + for (size_t i = 0; i < all->count; i++) { + if (!tpt_poll(c)) + return false; + const cbm_git_tree_entry_t *entry = &all->entries[i]; + bool link = (entry->mode == 0120000 && entry->object_type == CBM_GIT_TREE_BLOB) || + (entry->mode == 0160000 && entry->object_type == CBM_GIT_TREE_COMMIT); + if (link) { + skipped++; + } else { + positions[n] = i; + entries[n++] = *entry; + } + } + kept->entries = entries; + kept->count = n; + *original = positions; + c->tree->view.skipped_link_count = skipped; + return true; +} + static bool tpt_load(tpt_context *c, const cbm_pinned_tree_options_t *o) { if (!tpt_identity_copy(c, o) || !tpt_poll(c)) return false; cbm_git_tree_inventory_t inventory = {0}; + cbm_git_tree_inventory_t regular = {0}; + size_t *original = NULL; cbm_git_facts_error_t error = {0}; bool ok = cbm_git_facts_inventory(o->facts, o->revision, &inventory, &error); - if (!tpt_after_facts(c, ok, &error) || !tpt_plan(c, o, &inventory)) + if (!tpt_after_facts(c, ok, &error) || !tpt_without_links(c, &inventory, ®ular, &original) || + !tpt_plan(c, o, ®ular)) return false; + /* The plan numbered the kept entries; the blob batch names positions in + * the facts' own inventory. */ + for (size_t i = 0; original && i < c->tree->view.file_count; i++) + c->tree->indices[i] = original[c->tree->indices[i]]; cbm_git_blob_batch_t batch = {0}; return tpt_batch(c, o, &batch) && tpt_native_prepare(c) && tpt_build(c, &batch) && tpt_audit(c) && tpt_manifest(c) && tpt_poll(c); diff --git a/src/mcp/test_impact_tree.h b/src/mcp/test_impact_tree.h index 34895a9a1..b3e579ca2 100644 --- a/src/mcp/test_impact_tree.h +++ b/src/mcp/test_impact_tree.h @@ -80,6 +80,11 @@ typedef struct { size_t file_count; size_t directory_count; /* root plus unique proper path prefixes */ uint64_t total_content_bytes; + /* Symlink (0120000) and submodule (0160000) entries of the revision. They + * are not materialized and not in `files`: discovery never indexes a + * symlink, and a submodule's content is not in this repository. Any other + * non-regular entry still fails the pin. */ + size_t skipped_link_count; unsigned char manifest_sha256[32]; /* exact framing in normative text */ } cbm_pinned_tree_view_t; diff --git a/src/mcp/test_impact_tree_batch.c b/src/mcp/test_impact_tree_batch.c index 37a2c2f31..695421eb1 100644 --- a/src/mcp/test_impact_tree_batch.c +++ b/src/mcp/test_impact_tree_batch.c @@ -4,9 +4,9 @@ static bool tpt_batch_row(tpt_context *c, const cbm_git_blob_batch_item_t *item, cbm_pinned_tree_t *t = c->tree; cbm_pinned_tree_file_t *file = &t->files[index]; const cbm_git_tree_entry_t *e = item->entry; - if (!e || !e->path || item->inventory_index != index || e->object_type != CBM_GIT_TREE_BLOB || - e->mode != file->git_mode || e->path_length != file->path_length || - !tpt_oid(c, e->oid, t->identity.oid_hex_length)) + if (!e || !e->path || item->inventory_index != t->indices[index] || + e->object_type != CBM_GIT_TREE_BLOB || e->mode != file->git_mode || + e->path_length != file->path_length || !tpt_oid(c, e->oid, t->identity.oid_hex_length)) return tpt_fail(c, CBM_PINNED_TREE_GIT, "batch row identity"); if (!tpt_equal(c, e->path, file->path, file->path_length) || !tpt_equal(c, e->oid, file->oid, t->identity.oid_hex_length + 1) || diff --git a/src/mcp/test_impact_tree_digest.c b/src/mcp/test_impact_tree_digest.c index 51f6c7b31..df0812ba7 100644 --- a/src/mcp/test_impact_tree_digest.c +++ b/src/mcp/test_impact_tree_digest.c @@ -1,7 +1,7 @@ #include "mcp/test_impact_tree_internal.h" static bool tpt_number(tpt_context *c, cbm_sha256_ctx *h, uint64_t number, size_t width) { - unsigned char bytes[8]; + unsigned char bytes[8] = {0}; for (size_t i = 0; i < width; i++) bytes[width - 1 - i] = (unsigned char)(number >> (i * 8)); return tpt_hash(c, h, bytes, width); diff --git a/src/mcp/test_impact_tree_internal.h b/src/mcp/test_impact_tree_internal.h index ab32f2f8a..d481b6a59 100644 --- a/src/mcp/test_impact_tree_internal.h +++ b/src/mcp/test_impact_tree_internal.h @@ -1,6 +1,13 @@ #ifndef CBM_TEST_IMPACT_TREE_INTERNAL_H #define CBM_TEST_IMPACT_TREE_INTERNAL_H +#ifdef _WIN32 +/* FILE_ID_INFO (stable 128-bit file identity) is a Windows 8 API; pin it + * before any system header so toolchains with an older default see it. */ +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0602 +#endif +#endif #ifndef _WIN32 #ifndef _GNU_SOURCE #define _GNU_SOURCE diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index e5092e356..b9f78045c 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -1013,10 +1013,12 @@ static CBMFileResult *calls_get_or_extract(cbm_pipeline_ctx_t *ctx, int idx, } return NULL; } - CBMFileResult *r = cbm_pipeline_test_force_extract_null(ctx, fi->language) ? NULL : - cbm_extract_file_ex_with_tests( - src, slen, fi->language, ctx->project_name, fi->rel_path, CBM_EXTRACT_BUDGET, - NULL, NULL, ctx->macro_table, ctx->return_type_table, ctx->test_declarations); + CBMFileResult *r = + cbm_pipeline_test_force_extract_null(ctx, fi->language) + ? NULL + : cbm_extract_file_ex_with_tests( + src, slen, fi->language, ctx->project_name, fi->rel_path, CBM_EXTRACT_BUDGET, + NULL, NULL, ctx->macro_table, ctx->return_type_table, ctx->test_declarations); free(src); (void)cbm_pipeline_test_extraction_ok(ctx, fi->language, r); if (r) { diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index f0673c9e9..291da2da0 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -25,6 +25,7 @@ enum { PD_JSON_FIELD_OVERHEAD = 6 }; #include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/limits.h" +#include "foundation/mem_core.h" #include "foundation/str_util.h" #include "cbm.h" #include "arena.h" @@ -256,9 +257,9 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) * dump. Gate the block to functions; other labels keep the lean base. */ const bool is_fn = def->label && (strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0); - const char *test_role = def->test_role == CBM_TEST_ROLE_CASE ? ",\"test_role\":\"case\"" + const char *test_role = def->test_role == CBM_TEST_ROLE_CASE ? ",\"test_role\":\"case\"" : def->test_role == CBM_TEST_ROLE_SUITE ? ",\"test_role\":\"suite\"" - : ""; + : ""; int n; if (is_fn) { n = snprintf(buf, bufsize, @@ -279,7 +280,8 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) "{\"complexity\":%d,\"lines\":%d,\"is_exported\":%s,\"is_test\":%s," "\"is_entry_point\":%s%s", def->complexity, def->lines, def->is_exported ? "true" : "false", - def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false", test_role); + def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false", + test_role); } if (n <= 0 || (size_t)n >= bufsize) { @@ -754,7 +756,8 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t CBMFileResult **local_cache = ctx->result_cache; bool owns_local_cache = false; if (!local_cache) { - local_cache = (CBMFileResult **)calloc((size_t)file_count, sizeof(CBMFileResult *)); + local_cache = (CBMFileResult **)cbm_calloc(CBM_MEM_CLASS_EXTRACT, + (size_t)file_count * sizeof(CBMFileResult *)); owns_local_cache = (local_cache != NULL); } @@ -773,7 +776,7 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t cbm_free_result(local_cache[j]); } } - free(local_cache); + cbm_free(CBM_MEM_CLASS_EXTRACT, local_cache); } return CBM_NOT_FOUND; } @@ -839,14 +842,14 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t /* Studio Export XML is transformed to one cacheable aggregate so later * passes see the same calls/usages/semantic carriers as native UDL. */ CBMFileResult *result = - cbm_pipeline_test_force_extract_null(ctx, lang) ? NULL : - lang == CBM_LANG_OBJECTSCRIPT_EXPORT + cbm_pipeline_test_force_extract_null(ctx, lang) ? NULL + : lang == CBM_LANG_OBJECTSCRIPT_EXPORT ? cbm_pipeline_extract_objectscript_export(source, source_len, ctx->project_name, rel, ctx->macro_table, NULL) - : cbm_extract_file_ex_with_tests( - source, source_len, lang, ctx->project_name, rel, CBM_EXTRACT_BUDGET, NULL, - NULL /* no extra defines or include paths */, ctx->macro_table, NULL, - ctx->test_declarations); + : cbm_extract_file_ex_with_tests(source, source_len, lang, ctx->project_name, rel, + CBM_EXTRACT_BUDGET, NULL, + NULL /* no extra defines or include paths */, + ctx->macro_table, NULL, ctx->test_declarations); free(source); if (!cbm_pipeline_test_extraction_ok(ctx, lang, result)) { @@ -881,6 +884,14 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t ctx->pipeline, rel, result->error_ranges ? result->error_ranges : "unknown", result->parse_unusable ? "parse_unusable" : "parse_partial"); } + if (result->test_declarations_degraded) { + /* Its configured test forms could not be mapped: indexed without + * configured test roles (degraded per file, not the index). */ + cbm_pipeline_add_file_error( + ctx->pipeline, rel, + cbm_test_extract_status_message(result->test_declarations_degraded_status), + "test_declarations"); + } /* Create nodes for each definition */ for (int d = 0; d < result->defs.count; d++) { @@ -943,7 +954,7 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t cbm_free_result(local_cache[i]); } } - free(local_cache); + cbm_free(CBM_MEM_CLASS_EXTRACT, local_cache); } } @@ -957,7 +968,7 @@ configured_failure: for (int j = 0; j < file_count; j++) { cbm_free_result(local_cache[j]); } - free(local_cache); + cbm_free(CBM_MEM_CLASS_EXTRACT, local_cache); } return CBM_PIPELINE_ABORT_PRESERVE_DB; } diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 5b63ec871..a3c891276 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -16,6 +16,7 @@ #include "pipeline/pass_lsp_cross.h" #include "pipeline/lsp_surface.h" +#include "foundation/mem_core.h" #include "result_spill.h" #include "pipeline/pipeline_internal.h" #include "pipeline/lsp_resolve.h" @@ -613,7 +614,7 @@ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMArena *are * first actual collector spilled-load boundary, without timing. */ #if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS bool force_null = owner_walk_required && ctx && - (ctx->test_fault_mask & CBM_PIPELINE_TEST_FAULT_COLLECT_SPILL_NULL); + (ctx->test_fault_mask & CBM_PIPELINE_TEST_FAULT_COLLECT_SPILL_NULL); fr = force_null ? NULL : cbm_result_spill_load(spill, fi); #else fr = cbm_result_spill_load(spill, fi); @@ -1332,9 +1333,9 @@ void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int * resolution as a separate map, so pass NULL/0 and let the LSP * fall back to its own #include scan. */ if (r->has_test_definition_owners) { - if (!cbm_run_c_lsp_cross_with_test_owners( - &scratch, source, source_len, module_qn, cpp_mode, defs, def_count, - NULL, NULL, 0, tree, &out, CBM_SOURCE_ORIGIN_RAW, r)) { + if (!cbm_run_c_lsp_cross_with_test_owners(&scratch, source, source_len, module_qn, + cpp_mode, defs, def_count, NULL, NULL, 0, + tree, &out, CBM_SOURCE_ORIGIN_RAW, r)) { cbm_pipeline_test_owner_error(r); pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch); return; /* out may be partial: never append it */ @@ -1510,17 +1511,17 @@ void cbm_pxc_dispatch_file(CBMLanguage lang, CBMFileResult *result, const char * case CBM_LANG_CUDA: if (result->has_test_definition_owners) { if (!cbm_run_c_lsp_cross_with_registry_with_test_owners( - &scratch, source, source_len, def_module, (lang != CBM_LANG_C), - &overlay, imp_keys, imp_vals, imp_count, result->cached_tree, - &out, CBM_SOURCE_ORIGIN_RAW, result)) { + &scratch, source, source_len, def_module, (lang != CBM_LANG_C), &overlay, + imp_keys, imp_vals, imp_count, result->cached_tree, &out, + CBM_SOURCE_ORIGIN_RAW, result)) { cbm_pipeline_test_owner_error(result); pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch); return; /* no append and no fallback on an invalid proof */ } } else { cbm_run_c_lsp_cross_with_registry(&scratch, source, source_len, def_module, - (lang != CBM_LANG_C), &overlay, imp_keys, imp_vals, - imp_count, result->cached_tree, &out); + (lang != CBM_LANG_C), &overlay, imp_keys, + imp_vals, imp_count, result->cached_tree, &out); } used_prebuilt = true; break; @@ -1692,9 +1693,8 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * } bool owner_walk_required = atomic_load(&ctx->test_definition_owners_seen) != 0; char cross_disabled[CBM_SZ_16]; - if (owner_walk_required && - cbm_safe_getenv("CBM_DISABLE_LSP_CROSS", cross_disabled, - sizeof(cross_disabled), NULL) != NULL) { + if (owner_walk_required && cbm_safe_getenv("CBM_DISABLE_LSP_CROSS", cross_disabled, + sizeof(cross_disabled), NULL) != NULL) { atomic_store(&ctx->test_declarations_failed, 1); return CBM_PIPELINE_ABORT_PRESERVE_DB; } @@ -1709,8 +1709,9 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * /* Per-file module QN cache so we don't recompute it once per def + once * per call. cbm_pipeline_fqn_module mallocs; freed at end. */ - char **def_modules = cbm_pipeline_test_force_def_modules_null(ctx) ? NULL : - (char **)calloc((size_t)file_count, sizeof(char *)); + char **def_modules = cbm_pipeline_test_force_def_modules_null(ctx) + ? NULL + : (char **)calloc((size_t)file_count, sizeof(char *)); if (!def_modules) { cbm_log_error("pass.err", "pass", "lsp_cross", "phase", "alloc"); if (have_rust) { @@ -1725,7 +1726,8 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * } int def_count = 0; - int *def_starts = (int *)calloc((size_t)file_count + 1, sizeof(int)); + int *def_starts = + (int *)cbm_calloc(CBM_MEM_CLASS_EXTRACT, ((size_t)file_count + 1) * sizeof(int)); /* The defs own their strings in the caller-owned seq_cross_arena, which * the registries and later passes already borrow from (see below). */ if (!ctx->seq_cross_arena_live) { @@ -1737,7 +1739,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * ctx->project_name, def_modules, &def_count, def_starts); if (def_count < 0 || (owner_walk_required && (!all_defs || def_count <= 0))) { atomic_store(&ctx->test_declarations_failed, 1); - free(def_starts); + cbm_free(CBM_MEM_CLASS_EXTRACT, def_starts); free(all_defs); for (int i = 0; i < file_count; i++) { free(def_modules[i]); @@ -1760,7 +1762,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * cbm_pipeline_set_lsp_surfaces(ctx->pipeline, surface_rows, surface_count); } } - free(def_starts); + cbm_free(CBM_MEM_CLASS_EXTRACT, def_starts); /* Shared prepare (mirrors run_parallel_pipeline): inverted module-def * index + per-language shared registries, built ONCE for the whole pass. @@ -1882,8 +1884,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * "files_skipped_no_lsp", itoa_buf(skipped_no_lsp), "files_skipped_no_source", itoa_buf(skipped_no_source), "defs_total", itoa_buf(def_count), "lsp_calls", itoa_buf(per_lang_calls)); - return atomic_load(&ctx->test_declarations_failed) - ? CBM_PIPELINE_ABORT_PRESERVE_DB : 0; + return atomic_load(&ctx->test_declarations_failed) ? CBM_PIPELINE_ABORT_PRESERVE_DB : 0; } /* ── Per-module def index (gopls "package summary" pattern) ──── */ @@ -2129,7 +2130,8 @@ CBMLSPDef *cbm_pxc_filter_defs_for_file(const CBMModuleDefIndex *idx, CBMLSPDef return NULL; } - bool *selected = (bool *)calloc((size_t)idx->def_count, sizeof(*selected)); + bool *selected = + (bool *)cbm_calloc(CBM_MEM_CLASS_EXTRACT, (size_t)idx->def_count * sizeof(*selected)); if (!selected) { return NULL; } @@ -2146,14 +2148,14 @@ CBMLSPDef *cbm_pxc_filter_defs_for_file(const CBMModuleDefIndex *idx, CBMLSPDef } if (total == 0) { - free(selected); + cbm_free(CBM_MEM_CLASS_EXTRACT, selected); *out_success = true; return NULL; } CBMLSPDef *out = (CBMLSPDef *)malloc((size_t)total * sizeof(CBMLSPDef)); if (!out) { - free(selected); + cbm_free(CBM_MEM_CLASS_EXTRACT, selected); return NULL; } @@ -2165,6 +2167,6 @@ CBMLSPDef *cbm_pxc_filter_defs_for_file(const CBMModuleDefIndex *idx, CBMLSPDef } *out_count = n; *out_success = true; - free(selected); + cbm_free(CBM_MEM_CLASS_EXTRACT, selected); return out; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 0332b41c3..0180364b8 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -485,9 +485,9 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) * pass_definitions.c::build_def_props — keep both in sync. */ const bool is_fn = def->label && (strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0); - const char *test_role = def->test_role == CBM_TEST_ROLE_CASE ? ",\"test_role\":\"case\"" + const char *test_role = def->test_role == CBM_TEST_ROLE_CASE ? ",\"test_role\":\"case\"" : def->test_role == CBM_TEST_ROLE_SUITE ? ",\"test_role\":\"suite\"" - : ""; + : ""; int n; if (is_fn) { n = snprintf(buf, bufsize, @@ -508,7 +508,8 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) "{\"complexity\":%d,\"lines\":%d,\"is_exported\":%s,\"is_test\":%s," "\"is_entry_point\":%s%s", def->complexity, def->lines, def->is_exported ? "true" : "false", - def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false", test_role); + def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false", + test_role); } if (n <= 0 || (size_t)n >= bufsize) { buf[0] = '\0'; @@ -1249,15 +1250,15 @@ static void extract_worker(int worker_id, void *ctx_ptr) { * generated classes are composed before entering the common registry * and resolution lifecycle. */ CBMFileResult *result = - cbm_pipeline_test_force_extract_null(ec->pctx, fi->language) ? NULL : - fi->language == CBM_LANG_OBJECTSCRIPT_EXPORT + cbm_pipeline_test_force_extract_null(ec->pctx, fi->language) ? NULL + : fi->language == CBM_LANG_OBJECTSCRIPT_EXPORT ? cbm_pipeline_extract_objectscript_export(source, source_len, ec->project_name, fi->rel_path, ec->macro_table, ec->return_type_table) - : cbm_extract_file_ex_with_tests( - source, source_len, fi->language, ec->project_name, fi->rel_path, - CBM_EXTRACT_BUDGET, NULL, NULL, ec->macro_table, - ec->return_type_table, ec->pctx->test_declarations); + : cbm_extract_file_ex_with_tests(source, source_len, fi->language, ec->project_name, + fi->rel_path, CBM_EXTRACT_BUDGET, NULL, NULL, + ec->macro_table, ec->return_type_table, + ec->pctx->test_declarations); uint64_t file_elapsed_ms = (extract_now_ns() - file_t0) / PP_USEC_PER_MS; @@ -1307,6 +1308,12 @@ static void extract_worker(int worker_id, void *ctx_ptr) { pp_err_add(errs, fi->rel_path, result->error_ranges ? result->error_ranges : "unknown", result->parse_unusable ? "parse_unusable" : "parse_partial"); } + if (result->test_declarations_degraded) { + /* Degraded per file, not the index; see pass_definitions.c. */ + pp_err_add(errs, fi->rel_path, + cbm_test_extract_status_message(result->test_declarations_degraded_status), + "test_declarations"); + } /* A truncated walk is a coverage gap like a partial parse, and until now * it was the only one we kept to ourselves: result->walk_truncated was * set and never read by anything, so a file the walk abandoned halfway @@ -3854,8 +3861,8 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { } if (result->has_test_definition_owners && - (!rc->all_defs || rc->def_count <= 0 || !cbm_pxc_has_cross_lsp(lang) || - is_generated || result->lsp_skipped)) { + (!rc->all_defs || rc->def_count <= 0 || !cbm_pxc_has_cross_lsp(lang) || is_generated || + result->lsp_skipped)) { cbm_pipeline_test_owner_error(result); (void)cbm_pipeline_test_result_ok(rc->pctx, result); continue; @@ -3884,7 +3891,8 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { (jvm_cross_lsp || rust_workspace_cross_lsp || pending_lsp_site || qualified_lsp_sites < semantic_sites)) || unowned_c_member) && - !is_generated) || result->has_test_definition_owners; + !is_generated) || + result->has_test_definition_owners; /* Skip files with nothing else to resolve and no cross-LSP work. */ if (result->calls.count == 0 && result->usages.count == 0 && result->throws.count == 0 && @@ -4047,7 +4055,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { atomic_fetch_add_explicit(&rc->time_ns_semantic, extract_now_ns() - _ph_t0, memory_order_relaxed); -resolve_file_cleanup: + resolve_file_cleanup: cbm_registry_reach_cache_end(); cbm_registry_import_map_cache_end(); cbm_registry_resolve_cache_end(); diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index b9782a983..b3fdbb68b 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -670,10 +670,12 @@ static CBMFileResult *sem_get_or_extract(cbm_pipeline_ctx_t *ctx, int file_idx, } return NULL; } - CBMFileResult *r = cbm_pipeline_test_force_extract_null(ctx, fi->language) ? NULL : - cbm_extract_file_ex_with_tests( - source, source_len, fi->language, ctx->project_name, fi->rel_path, - CBM_EXTRACT_BUDGET, NULL, NULL, NULL, NULL, ctx->test_declarations); + CBMFileResult *r = + cbm_pipeline_test_force_extract_null(ctx, fi->language) + ? NULL + : cbm_extract_file_ex_with_tests(source, source_len, fi->language, ctx->project_name, + fi->rel_path, CBM_EXTRACT_BUDGET, NULL, NULL, NULL, + NULL, ctx->test_declarations); cbm_free(CBM_MEM_CLASS_SEMANTIC, source); (void)cbm_pipeline_test_extraction_ok(ctx, fi->language, r); if (r) { diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 169dbeffc..f60378ff2 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -437,10 +437,11 @@ int cbm_pipeline_pass_usages(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *fil errors++; continue; } - result = cbm_pipeline_test_force_extract_null(ctx, files[i].language) ? NULL : - cbm_extract_file_ex_with_tests( - source, source_len, files[i].language, ctx->project_name, rel, - CBM_EXTRACT_BUDGET, NULL, NULL, NULL, NULL, ctx->test_declarations); + result = cbm_pipeline_test_force_extract_null(ctx, files[i].language) + ? NULL + : cbm_extract_file_ex_with_tests( + source, source_len, files[i].language, ctx->project_name, rel, + CBM_EXTRACT_BUDGET, NULL, NULL, NULL, NULL, ctx->test_declarations); free(source); if (!cbm_pipeline_test_extraction_ok(ctx, files[i].language, result)) { cbm_free_result(result); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 21d8b5541..5aebed6c0 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -286,6 +286,11 @@ struct cbm_pipeline_frozen { cbm_pipeline_frozen_status_t failure; cbm_file_hash_t *baseline; int baseline_count; + /* The classified file list, owned by the arena, sorted by path; has_list + * false: discover the source root. */ + bool has_list; + cbm_pipeline_frozen_file_t *listed; + size_t listed_count; char canonical_root[CBM_SZ_4K]; char canonical_parent[CBM_SZ_4K]; #if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS @@ -295,7 +300,9 @@ struct cbm_pipeline_frozen { #endif }; -bool cbm_pipeline_is_frozen(const cbm_pipeline_t *p) { return p && p->frozen; } +bool cbm_pipeline_is_frozen(const cbm_pipeline_t *p) { + return p && p->frozen; +} int cbm_pipeline_frozen_checkpoint(cbm_pipeline_t *p) { if (!p || !p->frozen) @@ -401,11 +408,136 @@ static bool frozen_config_span_valid(const cbm_pipeline_frozen_inputs_t *in) { return in->config_state == CBM_USERCONFIG_SOURCE_PRESENT && in->config_bytes; } +/* A relative path made of non-empty segments other than "." and "..", with + * '/' as the only separator. */ +static bool frozen_rel_path_valid(const char *rel) { + if (!rel || !rel[0] || strlen(rel) >= CBM_SZ_4K || strchr(rel, '\\')) { + return false; + } + const char *segment = rel; + for (;;) { + const char *slash = strchr(segment, '/'); + size_t len = slash ? (size_t)(slash - segment) : strlen(segment); + if (len == 0 || (len == 1 && segment[0] == '.') || + (len == 2 && segment[0] == '.' && segment[1] == '.')) { + return false; + } + if (!slash) { + return true; + } + segment = slash + 1; + } +} + +static bool frozen_files_valid(const cbm_pipeline_frozen_inputs_t *in) { + if (!in->files) { + return in->file_count == 0; + } + if (in->file_count > (size_t)INT_MAX) { + return false; + } + for (size_t i = 0; i < in->file_count; i++) { + if (!frozen_rel_path_valid(in->files[i].rel_path) || in->files[i].language < 0 || + in->files[i].language > CBM_LANG_COUNT) { + return false; + } + } + return true; +} + static bool frozen_inputs_valid(const cbm_pipeline_frozen_inputs_t *in) { return in && frozen_absolute(in->source_root) && frozen_absolute(in->candidate_db_path) && frozen_string(in->project, true) && cbm_validate_project_name(in->project) && frozen_git_valid(in->pinned_git) && frozen_policy_valid(&in->resource_policy) && - frozen_config_span_valid(in); + frozen_config_span_valid(in) && frozen_files_valid(in); +} + +static int frozen_listed_cmp(const void *left, const void *right) { + const cbm_pipeline_frozen_file_t *a = left; + const cbm_pipeline_frozen_file_t *b = right; + return strcmp(a->rel_path, b->rel_path); +} + +/* Copy the list into the owner's arena, sorted; a path listed twice is + * INVALID (two languages for one file have no single meaning). */ +static cbm_pipeline_frozen_status_t frozen_copy_list(cbm_pipeline_frozen_t *owner, + const cbm_pipeline_frozen_inputs_t *in) { + owner->has_list = in->files != NULL; + if (!owner->has_list || in->file_count == 0) { + return CBM_PIPELINE_FROZEN_OK; + } + owner->listed = cbm_arena_calloc(&owner->arena, in->file_count * sizeof(*owner->listed)); + if (!owner->listed) { + return CBM_PIPELINE_FROZEN_OOM; + } + for (size_t i = 0; i < in->file_count; i++) { + owner->listed[i].language = in->files[i].language; + owner->listed[i].rel_path = cbm_arena_strdup(&owner->arena, in->files[i].rel_path); + if (!owner->listed[i].rel_path) { + return CBM_PIPELINE_FROZEN_OOM; + } + } + owner->listed_count = in->file_count; + qsort(owner->listed, owner->listed_count, sizeof(*owner->listed), frozen_listed_cmp); + for (size_t i = 1; i < owner->listed_count; i++) { + if (strcmp(owner->listed[i - 1].rel_path, owner->listed[i].rel_path) == 0) { + return CBM_PIPELINE_FROZEN_INVALID; + } + } + return CBM_PIPELINE_FROZEN_OK; +} + +bool cbm_pipeline_frozen_has_file_list(const cbm_pipeline_t *p) { + return cbm_pipeline_is_frozen(p) && p->frozen->has_list; +} + +int cbm_pipeline_frozen_listed_files(cbm_pipeline_t *p, cbm_file_info_t **out, int *count) { + *out = NULL; + *count = 0; + if (!cbm_pipeline_frozen_has_file_list(p)) { + return CBM_NOT_FOUND; + } + cbm_pipeline_frozen_t *owner = p->frozen; + cbm_file_info_t *files = + owner->listed_count ? calloc(owner->listed_count, sizeof(*files)) : NULL; + if (owner->listed_count && !files) { + owner->failure = CBM_PIPELINE_FROZEN_OOM; + return CBM_NOT_FOUND; + } + int n = 0; + for (size_t i = 0; i < owner->listed_count; i++) { + const cbm_pipeline_frozen_file_t *listed = &owner->listed[i]; + if (listed->language == CBM_LANG_COUNT) { + continue; /* no supported language: discovery does not index it either */ + } + char path[CBM_SZ_4K]; + int len = snprintf(path, sizeof(path), "%s/%s", p->repo_path, listed->rel_path); + /* The UTF-8-safe size probe; a directory is not a listed file. */ + int64_t size = + len > 0 && (size_t)len < sizeof(path) && !cbm_is_dir(path) ? cbm_file_size(path) : -1; + bool regular = size >= 0; + if (!regular || cbm_pipeline_frozen_checkpoint(p)) { + if (!regular && owner->failure == CBM_PIPELINE_FROZEN_OK) { + owner->failure = CBM_PIPELINE_FROZEN_INPUT_CHANGED; + } + cbm_discover_free(files, n); + return CBM_NOT_FOUND; + } + files[n].path = strdup(path); + files[n].rel_path = strdup(listed->rel_path); + files[n].language = listed->language; + files[n].size = size; + if (!files[n].path || !files[n].rel_path) { + n++; + cbm_discover_free(files, n); + owner->failure = CBM_PIPELINE_FROZEN_OOM; + return CBM_NOT_FOUND; + } + n++; + } + *out = files; + *count = n; + return 0; } static bool frozen_copy_git(cbm_git_context_t *to, const cbm_git_context_t *from) { @@ -480,8 +612,8 @@ static cbm_pipeline_frozen_status_t frozen_git_props(cbm_pipeline_frozen_t *owne return CBM_PIPELINE_FROZEN_OK; } -static cbm_pipeline_frozen_status_t -frozen_allocate_pipeline(cbm_pipeline_frozen_t *owner, const cbm_pipeline_frozen_inputs_t *in) { +static cbm_pipeline_frozen_status_t frozen_allocate_pipeline( + cbm_pipeline_frozen_t *owner, const cbm_pipeline_frozen_inputs_t *in) { cbm_pipeline_t *p = calloc(1, sizeof(*p)); if (!p) return CBM_PIPELINE_FROZEN_OOM; @@ -523,7 +655,10 @@ cbm_pipeline_frozen_status_t cbm_pipeline_frozen_create(const cbm_pipeline_froze } owner->arena = arena; owner->external_cancelled = inputs->cancelled; - cbm_pipeline_frozen_status_t status = frozen_allocate_pipeline(owner, inputs); + cbm_pipeline_frozen_status_t status = frozen_copy_list(owner, inputs); + if (status == CBM_PIPELINE_FROZEN_OK) { + status = frozen_allocate_pipeline(owner, inputs); + } if (status != CBM_PIPELINE_FROZEN_OK) { cbm_pipeline_frozen_free(owner); return status; @@ -785,11 +920,11 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { p->ignored_count = 0; p->ignored_total = 0; for (int i = 0; i < p->file_errors_count; i++) { - free(p->file_errors[i].path); - free(p->file_errors[i].reason); - free(p->file_errors[i].phase); + cbm_free(CBM_MEM_CLASS_OTHER, p->file_errors[i].path); + cbm_free(CBM_MEM_CLASS_OTHER, p->file_errors[i].reason); + cbm_free(CBM_MEM_CLASS_OTHER, p->file_errors[i].phase); } - free(p->file_errors); + cbm_free(CBM_MEM_CLASS_OTHER, p->file_errors); p->file_errors = NULL; p->file_errors_count = 0; p->file_errors_cap = 0; @@ -855,8 +990,7 @@ const cbm_test_declarations_t *cbm_pipeline_test_declarations(const cbm_pipeline return p ? p->test_declarations : NULL; } -bool cbm_pipeline_project_source_matches(const cbm_pipeline_t *p, - const cbm_userconfig_t *fresh) { +bool cbm_pipeline_project_source_matches(const cbm_pipeline_t *p, const cbm_userconfig_t *fresh) { const char *current = NULL; size_t current_len = 0; cbm_userconfig_source_state_t current_state = @@ -885,8 +1019,8 @@ static bool pipeline_load_test_config(cbm_pipeline_t *p) { if (state == CBM_USERCONFIG_SOURCE_ERROR) { return false; } - p->test_declarations = cbm_test_declarations_parse( - bytes, len, state == CBM_USERCONFIG_SOURCE_ABSENT); + p->test_declarations = + cbm_test_declarations_parse(bytes, len, state == CBM_USERCONFIG_SOURCE_ABSENT); if (!p->test_declarations) { return false; } @@ -934,7 +1068,7 @@ static char *fe_strdup(const char *s) { return NULL; } size_t n = strlen(s) + 1; - char *d = (char *)malloc(n); + char *d = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, n); if (d) { memcpy(d, s, n); } @@ -983,10 +1117,10 @@ static bool frozen_diag_reserve(cbm_pipeline_t *p) { frozen_diag_incomplete(p, false); return false; } - cbm_file_error_t *grown = - frozen_diag_fail_allocation(p, FROZEN_DIAG_GROW) - ? NULL - : realloc(p->file_errors, (size_t)capacity * sizeof(*p->file_errors)); + cbm_file_error_t *grown = frozen_diag_fail_allocation(p, FROZEN_DIAG_GROW) + ? NULL + : cbm_realloc(CBM_MEM_CLASS_OTHER, p->file_errors, + (size_t)capacity * sizeof(*p->file_errors)); if (!grown) { frozen_diag_incomplete(p, true); return false; @@ -1002,7 +1136,9 @@ static char *frozen_diag_copy(cbm_pipeline_t *p, const char *source, unsigned al frozen_diag_incomplete(p, false); return NULL; } - char *copy = frozen_diag_fail_allocation(p, allocation) ? NULL : malloc(length + 1); + char *copy = frozen_diag_fail_allocation(p, allocation) + ? NULL + : cbm_alloc(CBM_MEM_CLASS_OTHER, length + 1); if (!copy) { frozen_diag_incomplete(p, true); return NULL; @@ -1012,8 +1148,16 @@ static char *frozen_diag_copy(cbm_pipeline_t *p, const char *source, unsigned al } static void frozen_add_file_error(cbm_pipeline_t *p, const char *path, const char *reason, - const char *phase) { - p->frozen->diagnostic_flags |= CBM_PIPELINE_FROZEN_FILE_DIAGNOSTIC; + const char *phase) { + /* A partial parse is a parse gap the selection handles per file (user + * decision 2026-10-04); every other file diagnostic refuses the candidate. */ + bool parse_gap = phase && strcmp(phase, "parse_partial") == 0; + /* A file indexed without configured test roles is not refused either + * (user decision 2026-10-04): the selection reads tests by its own model. */ + bool test_gap = phase && strcmp(phase, "test_declarations") == 0; + p->frozen->diagnostic_flags |= parse_gap ? CBM_PIPELINE_FROZEN_PARSE_GAP + : test_gap ? CBM_PIPELINE_FROZEN_TEST_DECLARATION_GAP + : CBM_PIPELINE_FROZEN_FILE_DIAGNOSTIC; if (!path || !reason || !phase) { frozen_diag_incomplete(p, false); return; @@ -1026,9 +1170,9 @@ static void frozen_add_file_error(cbm_pipeline_t *p, const char *path, const cha for (unsigned i = 0; i < 3; i++) { *copies[i] = frozen_diag_copy(p, sources[i], FROZEN_DIAG_PATH + i); if (!*copies[i]) { - free(row.path); - free(row.reason); - free(row.phase); + cbm_free(CBM_MEM_CLASS_OTHER, row.path); + cbm_free(CBM_MEM_CLASS_OTHER, row.reason); + cbm_free(CBM_MEM_CLASS_OTHER, row.phase); return; } } @@ -1046,8 +1190,8 @@ void cbm_pipeline_add_file_error(cbm_pipeline_t *p, const char *path, const char } if (p->file_errors_count >= p->file_errors_cap) { int ncap = p->file_errors_cap ? p->file_errors_cap * 2 : 16; - cbm_file_error_t *grown = - (cbm_file_error_t *)realloc(p->file_errors, (size_t)ncap * sizeof(*grown)); + cbm_file_error_t *grown = (cbm_file_error_t *)cbm_realloc( + CBM_MEM_CLASS_OTHER, p->file_errors, (size_t)ncap * sizeof(*grown)); if (!grown) { /* Never abort indexing just to record a skip — drop this record. */ return; @@ -1951,8 +2095,7 @@ static int run_sequential_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, for (int si = 0; si < PL_SEQ_PASSES && rc == 0; si++) { cbm_clock_gettime(CLOCK_MONOTONIC, t); int pr = seq_passes[si].fn(ctx, files, file_count); - if (pr == CBM_PIPELINE_ABORT_PRESERVE_DB || - atomic_load(&ctx->test_declarations_failed)) { + if (pr == CBM_PIPELINE_ABORT_PRESERVE_DB || atomic_load(&ctx->test_declarations_failed)) { rc = CBM_PIPELINE_ABORT_PRESERVE_DB; } else if (pr != 0 && !seq_passes[si].ignore_err) { rc = pr; @@ -2467,7 +2610,7 @@ static char *stage_lock_sidecar_path(const char *stage_path) { if (len > SIZE_MAX - sizeof(suffix)) { return NULL; } - char *sidecar = (char *)malloc(len + sizeof(suffix)); + char *sidecar = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, len + sizeof(suffix)); if (!sidecar) { return NULL; } @@ -2485,7 +2628,7 @@ int cbm_pipeline_stage_lock_hold(const char *stage_path) { return -1; } int fd = cbm_lockfile_open(sidecar, true); - free(sidecar); + cbm_free(CBM_MEM_CLASS_OTHER, sidecar); return fd; } @@ -2501,7 +2644,7 @@ void cbm_pipeline_stage_lock_drop(const char *stage_path, int lock_fd) { cbm_lockfile_close(lock_fd); if (sidecar) { (void)cbm_unlink(sidecar); - free(sidecar); + cbm_free(CBM_MEM_CLASS_OTHER, sidecar); } } @@ -2512,7 +2655,7 @@ static int stage_lock_drop_checked(const char *path, int fd) { return CBM_NOT_FOUND; cbm_path_info_t info; bool absent = cbm_path_info_utf8(sidecar, &info) == CBM_PATH_INFO_ABSENT; - free(sidecar); + cbm_free(CBM_MEM_CLASS_OTHER, sidecar); return absent ? 0 : CBM_NOT_FOUND; } @@ -3341,9 +3484,12 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { p->ignored_files = NULL; p->ignored_count = 0; p->ignored_total = 0; - int rc = cbm_discover_ex2(p->repo_path, &opts, &files, &file_count, &p->excluded_dirs, - &p->excluded_count, &p->ignored_files, &p->ignored_count, - &p->ignored_total); + /* A frozen build with a classified file list walks no directory. */ + int rc = cbm_pipeline_frozen_has_file_list(p) + ? cbm_pipeline_frozen_listed_files(p, &files, &file_count) + : cbm_discover_ex2(p->repo_path, &opts, &files, &file_count, &p->excluded_dirs, + &p->excluded_count, &p->ignored_files, &p->ignored_count, + &p->ignored_total); if (rc != 0) { cbm_log_error("pipeline.err", "phase", "discover", "rc", itoa_buf(rc)); } @@ -3409,9 +3555,11 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { p->ignored_total = 0; opts.mode = p->mode; - rc = cbm_discover_ex2(p->repo_path, &opts, &files, &file_count, &p->excluded_dirs, - &p->excluded_count, &p->ignored_files, &p->ignored_count, - &p->ignored_total); + rc = cbm_pipeline_frozen_has_file_list(p) + ? cbm_pipeline_frozen_listed_files(p, &files, &file_count) + : cbm_discover_ex2(p->repo_path, &opts, &files, &file_count, &p->excluded_dirs, + &p->excluded_count, &p->ignored_files, &p->ignored_count, + &p->ignored_total); cbm_log_info("pipeline.rediscover", "requested_mode", pipeline_mode_name(p->requested_mode), "effective_mode", pipeline_mode_name(p->mode), "files", itoa_buf(file_count)); if (rc != 0 || check_cancel(p)) { @@ -3891,7 +4039,7 @@ static void sweep_one_stage(const char *stage_path) { errno = 0; int lock_fd = cbm_lockfile_open(sidecar, false); int probe_errno = errno; - free(sidecar); + cbm_free(CBM_MEM_CLASS_OTHER, sidecar); if (lock_fd < 0 && probe_errno != ENOENT) { bool live = probe_errno == EAGAIN || probe_errno == EACCES; #if defined(EWOULDBLOCK) && EWOULDBLOCK != EAGAIN @@ -4244,7 +4392,9 @@ static cbm_pipeline_frozen_status_t frozen_diagnostic_status(const cbm_pipeline_ unsigned flags = p->frozen->diagnostic_flags; if (flags & CBM_PIPELINE_FROZEN_DIAGNOSTIC_OOM) return CBM_PIPELINE_FROZEN_OOM; - return flags ? CBM_PIPELINE_FROZEN_PIPELINE_ERROR : CBM_PIPELINE_FROZEN_OK; + unsigned recorded = (unsigned)CBM_PIPELINE_FROZEN_PARSE_GAP | + (unsigned)CBM_PIPELINE_FROZEN_TEST_DECLARATION_GAP; + return (flags & ~recorded) ? CBM_PIPELINE_FROZEN_PIPELINE_ERROR : CBM_PIPELINE_FROZEN_OK; } static cbm_pipeline_frozen_status_t frozen_run_status(cbm_pipeline_t *p, int rc) { @@ -4263,7 +4413,7 @@ static cbm_pipeline_frozen_status_t frozen_run_status(cbm_pipeline_t *p, int rc) static cbm_pipeline_frozen_status_t frozen_run_stage(cbm_pipeline_t *p, const char *stage) { char *destination = p->db_path; - char *stage_copy = strdup(stage); + char *stage_copy = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, stage); if (!stage_copy) return CBM_PIPELINE_FROZEN_OOM; p->db_path = stage_copy; @@ -4271,7 +4421,7 @@ static cbm_pipeline_frozen_status_t frozen_run_stage(cbm_pipeline_t *p, const ch p->final_existed = false; int rc = cbm_pipeline_run_staged(p); p->db_path = destination; - free(stage_copy); + cbm_free(CBM_MEM_CLASS_OTHER, stage_copy); return frozen_run_status(p, rc); } diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 7be2c88d6..3aae16c25 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -16,6 +16,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24 }; #include #include #include "pipeline/lsp_surface.h" +#include "foundation/mem_core.h" #include "pipeline/pass_lsp_cross.h" #include "sqlite3.h" #include "yyjson/yyjson.h" @@ -778,8 +779,13 @@ static int frozen_fresh_manifest(cbm_pipeline_t *p, cbm_file_hash_t **out, int * int ignored_total = 0; const cbm_userconfig_t *previous = cbm_get_user_lang_config(); cbm_set_user_lang_config(cbm_pipeline_frozen_config(p)); - int rc = cbm_discover_ex2(cbm_pipeline_repo_path(p), &opts, &files, &count, &excluded, - &excluded_count, &ignored, &ignored_count, &ignored_total); + /* With a classified file list the reconciliation re-hashes exactly the + * listed files: a file added to the source root since is not compared, + * because it is not indexed either. */ + int rc = cbm_pipeline_frozen_has_file_list(p) + ? cbm_pipeline_frozen_listed_files(p, &files, &count) + : cbm_discover_ex2(cbm_pipeline_repo_path(p), &opts, &files, &count, &excluded, + &excluded_count, &ignored, &ignored_count, &ignored_total); if (rc == 0) rc = cbm_pipeline_build_frozen_manifest(p, files, count, excluded, excluded_count, out, out_count); @@ -1476,9 +1482,8 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed bool legacy_cross_arena_live = false; bool owner_walk_required = atomic_load(&ctx->test_definition_owners_seen) != 0; char cross_disabled[CBM_SZ_16]; - if (owner_walk_required && - cbm_safe_getenv("CBM_DISABLE_LSP_CROSS", cross_disabled, - sizeof(cross_disabled), NULL) != NULL) { + if (owner_walk_required && cbm_safe_getenv("CBM_DISABLE_LSP_CROSS", cross_disabled, + sizeof(cross_disabled), NULL) != NULL) { atomic_store(&ctx->test_declarations_failed, 1); rc = CBM_PIPELINE_ABORT_PRESERVE_DB; goto cross_resolve_cleanup; @@ -1486,9 +1491,11 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed if (closure) { /* Fresh surfaces for the re-parsed files, from the same collect * path a full build uses; then registries over stored + fresh. */ - def_modules = cbm_pipeline_test_force_def_modules_null(ctx) ? NULL : - (char **)calloc((size_t)ci, sizeof(char *)); - int *def_starts = (int *)calloc((size_t)ci + 1, sizeof(int)); + def_modules = cbm_pipeline_test_force_def_modules_null(ctx) + ? NULL + : (char **)calloc((size_t)ci, sizeof(char *)); + int *def_starts = + (int *)cbm_calloc(CBM_MEM_CLASS_EXTRACT, ((size_t)ci + 1) * sizeof(int)); int fresh_count = 0; CBMLSPDef *fresh_defs = def_modules && def_starts @@ -1499,7 +1506,7 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed if (fresh_count < 0 || (owner_walk_required && (!fresh_defs || fresh_count <= 0))) { /* Do not substitute the previous generation's definitions for * configured owners that failed fresh preparation. */ - free(def_starts); + cbm_free(CBM_MEM_CLASS_EXTRACT, def_starts); free(fresh_defs); closure->def_modules = def_modules; closure->def_module_count = def_modules ? ci : 0; @@ -1515,7 +1522,7 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed closure->fresh_rows = NULL; closure->fresh_count = 0; } - free(def_starts); + cbm_free(CBM_MEM_CLASS_EXTRACT, def_starts); all_def_count = closure->base_def_count + fresh_count; if (all_def_count > 0) { all_defs = (CBMLSPDef *)cbm_arena_alloc(&closure->arena, @@ -1571,13 +1578,12 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed * Only configured owners require this additional cross walk. */ cbm_arena_init(&legacy_cross_arena); legacy_cross_arena_live = true; - def_modules = cbm_arena_alloc(&legacy_cross_arena, - (size_t)ci * sizeof(*def_modules)); + def_modules = cbm_arena_alloc(&legacy_cross_arena, (size_t)ci * sizeof(*def_modules)); if (def_modules) { memset(def_modules, 0, (size_t)ci * sizeof(*def_modules)); - all_defs = cbm_pxc_collect_all_defs( - ctx, &legacy_cross_arena, cache, changed_files, ci, - ctx->project_name, def_modules, &all_def_count, NULL); + all_defs = + cbm_pxc_collect_all_defs(ctx, &legacy_cross_arena, cache, changed_files, ci, + ctx->project_name, def_modules, &all_def_count, NULL); } if (!all_defs || all_def_count <= 0) { atomic_store(&ctx->test_declarations_failed, 1); @@ -1587,12 +1593,11 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed module_def_index = cbm_pxc_build_module_def_index(all_defs, all_def_count); } cbm_clock_gettime(CLOCK_MONOTONIC, &t); - rc = cbm_parallel_resolve(ctx, changed_files, ci, cache, &shared_ids, worker_count, - all_defs, all_def_count, - closure ? closure->def_modules : def_modules, - module_def_index, registries_arg); + rc = cbm_parallel_resolve( + ctx, changed_files, ci, cache, &shared_ids, worker_count, all_defs, all_def_count, + closure ? closure->def_modules : def_modules, module_def_index, registries_arg); -cross_resolve_cleanup: + cross_resolve_cleanup: if (module_def_index) { cbm_pxc_free_module_def_index(module_def_index); } @@ -1867,8 +1872,10 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project, cbm_default_worker_count(true)); } if (rc == 0) { - char **def_modules = (char **)calloc((size_t)probe_count, sizeof(char *)); - int *def_starts = (int *)calloc((size_t)probe_count + 1, sizeof(int)); + char **def_modules = + (char **)cbm_calloc(CBM_MEM_CLASS_EXTRACT, (size_t)probe_count * sizeof(char *)); + int *def_starts = + (int *)cbm_calloc(CBM_MEM_CLASS_EXTRACT, ((size_t)probe_count + 1) * sizeof(int)); int def_count = 0; CBMLSPDef *defs = NULL; CBMArena probe_arena; @@ -1885,12 +1892,12 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project, } free(defs); cbm_arena_destroy(&probe_arena); - free(def_starts); + cbm_free(CBM_MEM_CLASS_EXTRACT, def_starts); if (def_modules) { for (int i = 0; i < probe_count; i++) { free(def_modules[i]); } - free(def_modules); + cbm_free(CBM_MEM_CLASS_EXTRACT, def_modules); } } if (cache) { @@ -2086,10 +2093,9 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p } probe_files[i] = *info; } - int probe_rc = n_changed > 0 - ? closure_probe_surfaces(p, project, probe_files, n_changed, - &probe_rows, &probe_count) - : 0; + int probe_rc = n_changed > 0 ? closure_probe_surfaces(p, project, probe_files, n_changed, + &probe_rows, &probe_count) + : 0; if (probe_rc != 0) { cbm_ht_free(rows_by_path); if (probe_rc == CBM_PIPELINE_ABORT_PRESERVE_DB) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 0f96c0b19..7186fd345 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -39,6 +39,15 @@ typedef enum { CBM_PIPELINE_FROZEN_PIPELINE_ERROR } cbm_pipeline_frozen_status_t; +/* One file of a classified pinned ledger: a path relative to source_root + * ('/'-separated; no empty, "." or ".." segment) and the language the ledger + * gave it. CBM_LANG_COUNT: the file has no supported language and is not + * indexed, as discovery does not index it. */ +typedef struct { + const char *rel_path; + CBMLanguage language; +} cbm_pipeline_frozen_file_t; + typedef struct { /* Explicit native absolute paths. Source root and destination parent are * private, caller-owned directories for the entire owner lifetime. @@ -72,6 +81,15 @@ typedef struct { * consumers may see only the internal latch until the next such sample. */ atomic_int *cancelled; + + /* Optional: the files to index, from a classified pinned ledger (see + * test_impact_classify.h). With a list the build walks no directory: it + * indexes exactly these files under these languages, and the pre-commit + * reconciliation re-hashes exactly them, so a file added to source_root + * after the ledger was made is never indexed and a listed file that is not + * there is INPUT_CHANGED. NULL with 0 discovers source_root. Copied. */ + const cbm_pipeline_frozen_file_t *files; + size_t file_count; } cbm_pipeline_frozen_inputs_t; /* Clear *out first. Deep-copy/validate all input; no Git/config filesystem @@ -92,6 +110,14 @@ cbm_pipeline_frozen_status_t cbm_pipeline_frozen_create(const cbm_pipeline_froze */ cbm_pipeline_frozen_status_t cbm_pipeline_frozen_build(cbm_pipeline_frozen_t *owner); +/* Inside a frozen build: whether the owner was given a file list, and that + * list as discovery records (absolute path under the source root, the listed + * language, the size on disk), sorted by relative path, files without a + * language left out. A listed file that is not a regular file records + * INPUT_CHANGED and returns CBM_NOT_FOUND. Free with cbm_discover_free. */ +bool cbm_pipeline_frozen_has_file_list(const cbm_pipeline_t *p); +int cbm_pipeline_frozen_listed_files(cbm_pipeline_t *p, cbm_file_info_t **out, int *count); + /* Thread-safe while owner is alive; sets the internal atomic immediately. * No concurrent free. This does not await a sample of the external flag. */ void cbm_pipeline_frozen_cancel(cbm_pipeline_frozen_t *owner); @@ -103,19 +129,25 @@ void cbm_pipeline_frozen_cancel(cbm_pipeline_frozen_t *owner); */ const cbm_pipeline_t *cbm_pipeline_frozen_diagnostics(const cbm_pipeline_frozen_t *owner); -/* Monotone negative observations; zero is NOT a completeness certificate. */ +/* Monotone negative observations; zero is NOT a completeness certificate. + * PARSE_GAP alone does not refuse the candidate: a file that parsed only + * partially stays in the graph with its unparsed ranges recorded, and the + * selection treats a change to it as a parse gap (the file is seeded whole and + * its language's lane runs whole). Every other bit refuses publication. */ enum { CBM_PIPELINE_FROZEN_FILE_DIAGNOSTIC = 1, CBM_PIPELINE_FROZEN_DIAGNOSTIC_INCOMPLETE = 2, - CBM_PIPELINE_FROZEN_DIAGNOSTIC_OOM = 4 + CBM_PIPELINE_FROZEN_DIAGNOSTIC_OOM = 4, + CBM_PIPELINE_FROZEN_PARSE_GAP = 8, + /* A file indexed without configured test roles (degraded per file). */ + CBM_PIPELINE_FROZEN_TEST_DECLARATION_GAP = 16 }; /* Clears supplied output first. Returns false for NULL owner/output or while * BUILDING; otherwise copies the mask. No overlap with build is permitted. * Evidence survives failed/invalid retries until owner free; no reset API. */ -bool cbm_pipeline_frozen_negative_evidence(const cbm_pipeline_frozen_t *owner, - unsigned *out_flags); +bool cbm_pipeline_frozen_negative_evidence(const cbm_pipeline_frozen_t *owner, unsigned *out_flags); /* Borrowed copied path only after build returned OK; otherwise NULL. */ const char *cbm_pipeline_frozen_candidate_path(const cbm_pipeline_frozen_t *owner); @@ -354,16 +386,20 @@ static inline void cbm_pipeline_test_owner_error(CBMFileResult *result) { result->test_declarations_status = CBM_TEST_EXTRACT_UNSUPPORTED_FORM; result->test_declaration_index = -1; result->test_declaration_line = 0; + if (!result->has_error) { + result->test_error_only = true; + } result->has_error = true; - result->error_msg = cbm_arena_strdup(&result->arena, - "configured test body ownership could not be verified"); + result->error_msg = + cbm_arena_strdup(&result->arena, "configured test body ownership could not be verified"); if (!result->error_msg) { result->test_declarations_status = CBM_TEST_EXTRACT_OOM; } + cbm_test_declarations_degrade(result); /* per file, not the index */ } static inline bool cbm_pipeline_test_result_ok(cbm_pipeline_ctx_t *ctx, - const CBMFileResult *result) { + const CBMFileResult *result) { if (result && result->test_declarations_status != CBM_TEST_EXTRACT_OK) { atomic_store(&ctx->test_declarations_failed, 1); return false; @@ -380,8 +416,8 @@ static inline bool cbm_pipeline_test_result_ok(cbm_pipeline_ctx_t *ctx, static inline bool cbm_pipeline_test_definitions_apply(const cbm_pipeline_ctx_t *ctx, CBMLanguage language) { const cbm_test_declarations_t *snapshot = ctx ? ctx->test_declarations : NULL; - if (!snapshot || (language != CBM_LANG_C && language != CBM_LANG_CPP && - language != CBM_LANG_CUDA)) { + if (!snapshot || + (language != CBM_LANG_C && language != CBM_LANG_CPP && language != CBM_LANG_CUDA)) { return false; } bool enabled = false; @@ -396,21 +432,19 @@ static inline bool cbm_pipeline_test_definitions_apply(const cbm_pipeline_ctx_t continue; } const char *name = items[i].language; - if (name && ((language == CBM_LANG_C && - (strcmp(name, "c") == 0 || strcmp(name, "C") == 0)) || - (language == CBM_LANG_CPP && - (strcmp(name, "cpp") == 0 || strcmp(name, "c++") == 0 || - strcmp(name, "C++") == 0)) || - (language == CBM_LANG_CUDA && - (strcmp(name, "cuda") == 0 || strcmp(name, "CUDA") == 0)))) { + if (name && + ((language == CBM_LANG_C && (strcmp(name, "c") == 0 || strcmp(name, "C") == 0)) || + (language == CBM_LANG_CPP && + (strcmp(name, "cpp") == 0 || strcmp(name, "c++") == 0 || strcmp(name, "C++") == 0)) || + (language == CBM_LANG_CUDA && + (strcmp(name, "cuda") == 0 || strcmp(name, "CUDA") == 0)))) { return true; } } return false; } -static inline bool cbm_pipeline_test_extraction_ok(cbm_pipeline_ctx_t *ctx, - CBMLanguage language, +static inline bool cbm_pipeline_test_extraction_ok(cbm_pipeline_ctx_t *ctx, CBMLanguage language, const CBMFileResult *result) { if (!result && cbm_pipeline_test_definitions_apply(ctx, language)) { atomic_store(&ctx->test_declarations_failed, 1); @@ -1190,8 +1224,7 @@ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *row const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p); const cbm_test_declarations_t *cbm_pipeline_test_declarations(const cbm_pipeline_t *p); /* Without an active run snapshot this only validates fresh's source state. */ -bool cbm_pipeline_project_source_matches(const cbm_pipeline_t *p, - const cbm_userconfig_t *fresh); +bool cbm_pipeline_project_source_matches(const cbm_pipeline_t *p, const cbm_userconfig_t *fresh); const cbm_index_resource_policy_t *cbm_pipeline_resource_policy(const cbm_pipeline_t *p); cbm_index_resource_violation_t *cbm_pipeline_resource_violation(cbm_pipeline_t *p); atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p); diff --git a/src/store/store.c b/src/store/store.c index 19ff6ac01..ae7c06b16 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -115,7 +115,7 @@ static int bind_text(sqlite3_stmt *s, int col, const char *v) { struct cbm_store { sqlite3 *db; const void *progress_owner; /* exclusive native progress guard token */ - const char *db_path; /* heap-allocated, or NULL for :memory: */ + const char *db_path; /* heap-allocated, or NULL for :memory: */ char errbuf[CBM_SZ_512]; /* Prepared statements (lazily initialized, cached for lifetime) */ @@ -1342,7 +1342,7 @@ int cbm_store_rollback(cbm_store_t *s) { * for an externally installed handler; direct replacement during this borrow * is outside the request-owned connection contract. */ static bool store_progress_acquire(cbm_store_t *s, const void *owner, int interval, - int (*callback)(void *), void *context) { + int (*callback)(void *), void *context) { if (!s || !s->db || !owner || s->progress_owner) { return false; } @@ -1460,7 +1460,8 @@ static int store_scope_cleanup(cbm_store_read_scope_t *scope) { scope->transaction = false; } if (scope->query_only_changed && scope->query_only_saved) { - const char *sql = scope->saved_query_only ? "PRAGMA query_only=ON;" : "PRAGMA query_only=OFF;"; + const char *sql = + scope->saved_query_only ? "PRAGMA query_only=ON;" : "PRAGMA query_only=OFF;"; int restored = -1; if (exec_sql(s, sql) != CBM_STORE_OK || !store_scope_pragma_get(s, "PRAGMA query_only;", &restored) || @@ -1548,8 +1549,8 @@ int cbm_store_read_scope_open(cbm_store_t *s, cbm_store_cancel_fn cancel, void * } /* BEGIN alone is not a snapshot. This real main-table read opens one even * for an empty graph; finalizing the statement retains the transaction. */ - if (sqlite3_prepare_v2(s->db, "SELECT id FROM main.nodes LIMIT 1;", CBM_NOT_FOUND, - &scope->pin, NULL) != SQLITE_OK) { + if (sqlite3_prepare_v2(s->db, "SELECT id FROM main.nodes LIMIT 1;", CBM_NOT_FOUND, &scope->pin, + NULL) != SQLITE_OK) { goto fail; } int step = sqlite3_step(scope->pin); @@ -1985,12 +1986,12 @@ int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project, bool base_transaction = false; bool target_transaction = false; int progress_interval = graph_compare_progress_interval(); - if (!store_progress_acquire(base_store, &progress, progress_interval, - graph_compare_progress, &progress)) { + if (!store_progress_acquire(base_store, &progress, progress_interval, graph_compare_progress, + &progress)) { return CBM_STORE_ERR; } - if (!store_progress_acquire(target_store, &progress, progress_interval, - graph_compare_progress, &progress)) { + if (!store_progress_acquire(target_store, &progress, progress_interval, graph_compare_progress, + &progress)) { store_progress_release(base_store, &progress); return CBM_STORE_ERR; } diff --git a/src/store/store_graph_digest.c b/src/store/store_graph_digest.c index 48f4ac76f..9baac3c39 100644 --- a/src/store/store_graph_digest.c +++ b/src/store/store_graph_digest.c @@ -44,51 +44,55 @@ typedef struct { #define GD_B GD_ALLOW_BLOB static const gd_column_t gd_projects[] = { - {"name", GD_T, 0, 1}, {"indexed_at", GD_T, 0, 0}, {"root_path", GD_T, 0, 0} -}; -static const gd_column_t gd_file_hashes[] = { - {"project", GD_T, 0, 1}, {"rel_path", GD_T, 0, 2}, {"sha256", GD_T, 0, 0}, - {"mtime_ns", GD_I, 0, 0}, {"size", GD_I, 0, 0} -}; -static const gd_column_t gd_nodes[] = { - {"id", GD_I, 0, 1}, {"project", GD_T, 0, 0}, {"label", GD_T, 0, 0}, - {"name", GD_T, 0, 0}, {"qualified_name", GD_T, 0, 0}, - {"file_path", GD_T | GD_N, 0, 0}, {"start_line", GD_I | GD_N, 0, 0}, - {"end_line", GD_I | GD_N, 0, 0}, {"properties", GD_T | GD_N, 0, 0} -}; -static const gd_column_t gd_edges[] = { - {"id", GD_I, 0, 1}, {"project", GD_T, 0, 0}, {"source_id", GD_I, 0, 0}, - {"target_id", GD_I, 0, 0}, {"type", GD_T, 0, 0}, - {"properties", GD_T | GD_N, 0, 0}, {"url_path_gen", GD_T | GD_N, 2, 0}, - {"local_name_gen", GD_T | GD_N, 2, 0} -}; -static const gd_column_t gd_project_summaries[] = { - {"project", GD_T, 0, 1}, {"summary", GD_T, 0, 0}, {"source_hash", GD_T, 0, 0}, - {"created_at", GD_T, 0, 0}, {"updated_at", GD_T, 0, 0} -}; + {"name", GD_T, 0, 1}, {"indexed_at", GD_T, 0, 0}, {"root_path", GD_T, 0, 0}}; +static const gd_column_t gd_file_hashes[] = {{"project", GD_T, 0, 1}, + {"rel_path", GD_T, 0, 2}, + {"sha256", GD_T, 0, 0}, + {"mtime_ns", GD_I, 0, 0}, + {"size", GD_I, 0, 0}}; +static const gd_column_t gd_nodes[] = {{"id", GD_I, 0, 1}, + {"project", GD_T, 0, 0}, + {"label", GD_T, 0, 0}, + {"name", GD_T, 0, 0}, + {"qualified_name", GD_T, 0, 0}, + {"file_path", GD_T | GD_N, 0, 0}, + {"start_line", GD_I | GD_N, 0, 0}, + {"end_line", GD_I | GD_N, 0, 0}, + {"properties", GD_T | GD_N, 0, 0}}; +static const gd_column_t gd_edges[] = {{"id", GD_I, 0, 1}, + {"project", GD_T, 0, 0}, + {"source_id", GD_I, 0, 0}, + {"target_id", GD_I, 0, 0}, + {"type", GD_T, 0, 0}, + {"properties", GD_T | GD_N, 0, 0}, + {"url_path_gen", GD_T | GD_N, 2, 0}, + {"local_name_gen", GD_T | GD_N, 2, 0}}; +static const gd_column_t gd_project_summaries[] = {{"project", GD_T, 0, 1}, + {"summary", GD_T, 0, 0}, + {"source_hash", GD_T, 0, 0}, + {"created_at", GD_T, 0, 0}, + {"updated_at", GD_T, 0, 0}}; static const gd_column_t gd_lsp_surface[] = { - {"project", GD_T, 0, 1}, {"rel_path", GD_T, 0, 2}, {"surface_sha", GD_T, 0, 0}, - {"defs_json", GD_T, 0, 0}, {"ref_bloom", GD_B | GD_N, 0, 0}, - {"config_ctx", GD_T, 0, 0} -}; -static const gd_column_t gd_index_coverage[] = { - {"project", GD_T, 0, 1}, {"rel_path", GD_T, 0, 2}, {"kind", GD_T, 0, 3}, - {"detail", GD_T | GD_N, 0, 0} -}; -static const gd_column_t gd_index_coverage_meta[] = { - {"project", GD_T, 0, 1}, {"generation", GD_T, 0, 0}, {"index_mode", GD_T, 0, 0}, - {"recorded_at", GD_T, 0, 0}, {"recording_status", GD_T, 0, 0}, - {"ignored_files_stored", GD_I, 0, 0}, {"ignored_files_total", GD_I, 0, 0}, - {"coverage_version", GD_I, 0, 0}, {"hash_records_complete", GD_I, 0, 0} -}; -static const gd_column_t gd_store_meta[] = { - {"k", GD_T, 0, 1}, {"v", GD_T, 0, 0} -}; + {"project", GD_T, 0, 1}, {"rel_path", GD_T, 0, 2}, {"surface_sha", GD_T, 0, 0}, + {"defs_json", GD_T, 0, 0}, {"ref_bloom", GD_B | GD_N, 0, 0}, {"config_ctx", GD_T, 0, 0}}; +static const gd_column_t gd_index_coverage[] = {{"project", GD_T, 0, 1}, + {"rel_path", GD_T, 0, 2}, + {"kind", GD_T, 0, 3}, + {"detail", GD_T | GD_N, 0, 0}}; +static const gd_column_t gd_index_coverage_meta[] = {{"project", GD_T, 0, 1}, + {"generation", GD_T, 0, 0}, + {"index_mode", GD_T, 0, 0}, + {"recorded_at", GD_T, 0, 0}, + {"recording_status", GD_T, 0, 0}, + {"ignored_files_stored", GD_I, 0, 0}, + {"ignored_files_total", GD_I, 0, 0}, + {"coverage_version", GD_I, 0, 0}, + {"hash_records_complete", GD_I, 0, 0}}; +static const gd_column_t gd_store_meta[] = {{"k", GD_T, 0, 1}, {"v", GD_T, 0, 0}}; /* These descriptors and SQL strings are immutable; all other state is local. */ static const gd_table_t gd_tables[GD_TABLE_COUNT] = { - {"projects", gd_projects, GD_COLUMNS(gd_projects), - "PRAGMA main.table_xinfo('projects');", + {"projects", gd_projects, GD_COLUMNS(gd_projects), "PRAGMA main.table_xinfo('projects');", "SELECT name,indexed_at,root_path FROM main.projects ORDER BY CAST(name AS BLOB) ASC;"}, {"file_hashes", gd_file_hashes, GD_COLUMNS(gd_file_hashes), "PRAGMA main.table_xinfo('file_hashes');", @@ -119,8 +123,7 @@ static const gd_table_t gd_tables[GD_TABLE_COUNT] = { "ORDER BY CAST(project AS BLOB) ASC;"}, {"store_meta", gd_store_meta, GD_COLUMNS(gd_store_meta), "PRAGMA main.table_xinfo('store_meta');", - "SELECT k,v FROM main.store_meta ORDER BY CAST(k AS BLOB) ASC;"} -}; + "SELECT k,v FROM main.store_meta ORDER BY CAST(k AS BLOB) ASC;"}}; #undef GD_B #undef GD_N @@ -150,9 +153,9 @@ static bool gd_fail(gd_context_t *g, cbm_store_graph_digest_status_t status) { if (g->status == CBM_STORE_GRAPH_DIGEST_OK) { g->status = status; if (g->scope) { - (void)cbm_store_read_scope_fail( - g->scope, status == CBM_STORE_GRAPH_DIGEST_CANCELLED ? - CBM_STORE_CANCELLED : CBM_STORE_ERR); + (void)cbm_store_read_scope_fail(g->scope, status == CBM_STORE_GRAPH_DIGEST_CANCELLED + ? CBM_STORE_CANCELLED + : CBM_STORE_ERR); } } return false; @@ -162,8 +165,8 @@ static bool gd_check(gd_context_t *g) { if (g->scope) { int status = cbm_store_read_scope_check(g->scope); if (status != CBM_STORE_OK) { - return gd_fail(g, status == CBM_STORE_CANCELLED ? - CBM_STORE_GRAPH_DIGEST_CANCELLED : CBM_STORE_GRAPH_DIGEST_ERROR); + return gd_fail(g, status == CBM_STORE_CANCELLED ? CBM_STORE_GRAPH_DIGEST_CANCELLED + : CBM_STORE_GRAPH_DIGEST_ERROR); } } return g->status == CBM_STORE_GRAPH_DIGEST_OK; @@ -486,8 +489,7 @@ static bool gd_table_header(gd_context_t *g, int index, bool present) { } for (int i = 0; i < table->column_count; i++) { const gd_column_t *column = &table->columns[i]; - if (!gd_literal(g, column->name) || !gd_u8(g, column->types) || - !gd_u8(g, column->kind)) { + if (!gd_literal(g, column->name) || !gd_u8(g, column->types) || !gd_u8(g, column->kind)) { return false; } } @@ -506,8 +508,7 @@ static bool gd_table_header(gd_context_t *g, int index, bool present) { return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); } gd_text_t name, type, sql; - if (!gd_read_text(g, 0, &name) || !gd_read_text(g, 1, &type) || - !gd_read_text(g, 2, &sql)) { + if (!gd_read_text(g, 0, &name) || !gd_read_text(g, 1, &type) || !gd_read_text(g, 2, &sql)) { return false; } if (!gd_text_eq(name, table->name) || !gd_text_eq(type, "table")) { @@ -564,11 +565,20 @@ static bool gd_column_value(gd_context_t *g, int index, const gd_column_t *colum int sql_type = sqlite3_column_type(g->stmt, index); unsigned char tag; switch (sql_type) { - case SQLITE_NULL: tag = GD_NULL; break; - case SQLITE_INTEGER: tag = GD_INTEGER; break; - case SQLITE_TEXT: tag = GD_TEXT; break; - case SQLITE_BLOB: tag = GD_BLOB; break; - default: return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); + case SQLITE_NULL: + tag = GD_NULL; + break; + case SQLITE_INTEGER: + tag = GD_INTEGER; + break; + case SQLITE_TEXT: + tag = GD_TEXT; + break; + case SQLITE_BLOB: + tag = GD_BLOB; + break; + default: + return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); } if (!(column->types & (1u << tag))) { return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA); @@ -583,8 +593,7 @@ static bool gd_column_value(gd_context_t *g, int index, const gd_column_t *colum } if (tag == GD_TEXT) { gd_text_t value; - return gd_read_text(g, index, &value) && - gd_value(g, tag, value.data, value.length); + return gd_read_text(g, index, &value) && gd_value(g, tag, value.data, value.length); } const void *blob = sqlite3_column_blob(g->stmt, index); int length = sqlite3_column_bytes(g->stmt, index); @@ -655,10 +664,8 @@ static bool gd_project_argument(gd_context_t *g, const unsigned char *project, s } cbm_store_graph_digest_status_t cbm_store_graph_digest( - cbm_store_read_scope_t *scope, - const unsigned char *project, size_t project_len, - const cbm_store_graph_digest_limits_t *limits, - cbm_store_graph_digest_t *out) { + cbm_store_read_scope_t *scope, const unsigned char *project, size_t project_len, + const cbm_store_graph_digest_limits_t *limits, cbm_store_graph_digest_t *out) { if (out) { memset(out, 0, sizeof(*out)); } diff --git a/src/store/store_graph_digest.h b/src/store/store_graph_digest.h index 545086473..004ed7840 100644 --- a/src/store/store_graph_digest.h +++ b/src/store/store_graph_digest.h @@ -52,9 +52,7 @@ typedef struct { * authenticity, source identity, extraction completeness or permission to * narrow selection. Do not reuse it as certification for another read scope. */ cbm_store_graph_digest_status_t cbm_store_graph_digest( - cbm_store_read_scope_t *scope, - const unsigned char *project, size_t project_len, - const cbm_store_graph_digest_limits_t *limits, - cbm_store_graph_digest_t *out); + cbm_store_read_scope_t *scope, const unsigned char *project, size_t project_len, + const cbm_store_graph_digest_limits_t *limits, cbm_store_graph_digest_t *out); #endif /* CBM_STORE_GRAPH_DIGEST_H */ diff --git a/src/store/store_impact.c b/src/store/store_impact.c index a0e32c898..40c3f75a9 100644 --- a/src/store/store_impact.c +++ b/src/store/store_impact.c @@ -31,21 +31,21 @@ enum { static const char IMPACT_HANDLES[] = "HANDLES"; static const char IMPACT_ROUTE_LABEL[] = "Route"; static const char *const IMPACT_ROUTE_IN[IMPACT_ROUTE_IN_COUNT] = {"HTTP_CALLS", "ASYNC_CALLS", - "GRPC_CALLS"}; + "GRPC_CALLS"}; struct cbm_impact_walk { sqlite3 *db; cbm_store_read_scope_t *scope; /* borrowed; NULL preserves legacy path */ - int status; /* sticky, keep count intact for allocated-row cleanup */ + int status; /* sticky, keep count intact for allocated-row cleanup */ char *project; char **edge_types; /* owned copies: a hit's via_edge points into these */ int edge_type_count; bool follow_routes; int max_hops; /* 0 = fixpoint */ - int64_t max_id; /* largest node id when the walk opened */ - int32_t *slot; /* [max_id + 1]: index into hits + 1; 0 = not reached */ - uint8_t *sink; /* [max_id + 1]: reached, never expanded */ + int64_t max_id; /* largest node id when the walk opened */ + int32_t *slot; /* [max_id + 1]: index into hits + 1; 0 = not reached */ + uint8_t *sink; /* [max_id + 1]: reached, never expanded */ cbm_impact_hit_t *hits; int count; @@ -91,8 +91,9 @@ static int impact_fail(cbm_impact_walk_t *w, int status) { return CBM_STORE_ERR; } if (w->status == CBM_STORE_OK) { - w->status = w->scope ? cbm_store_read_scope_fail(w->scope, status) : - (status == CBM_STORE_CANCELLED ? CBM_STORE_CANCELLED : CBM_STORE_ERR); + w->status = w->scope + ? cbm_store_read_scope_fail(w->scope, status) + : (status == CBM_STORE_CANCELLED ? CBM_STORE_CANCELLED : CBM_STORE_ERR); } sqlite3_reset(w->stmt_node); sqlite3_reset(w->stmt_in); @@ -120,8 +121,8 @@ static bool impact_visible(const cbm_impact_walk_t *w) { /* Scoped sorting polls inside comparisons and heap work. Each swap is atomic * with respect to cancellation so the array remains a permutation for cleanup. * The legacy path retains its existing qsort and comparators exactly. */ -static bool impact_sort_compare(cbm_impact_walk_t *w, const void *a, const void *b, - bool hits, int *order) { +static bool impact_sort_compare(cbm_impact_walk_t *w, const void *a, const void *b, bool hits, + int *order) { if (!impact_check(w)) { return false; } @@ -165,8 +166,8 @@ static void impact_sort_swap(unsigned char *a, unsigned char *b, size_t size) { } } -static bool impact_sift(cbm_impact_walk_t *w, unsigned char *items, size_t count, - size_t root, size_t size, bool hits) { +static bool impact_sift(cbm_impact_walk_t *w, unsigned char *items, size_t count, size_t root, + size_t size, bool hits) { while (root < count / 2) { if (!impact_check(w)) { return false; @@ -174,8 +175,8 @@ static bool impact_sift(cbm_impact_walk_t *w, unsigned char *items, size_t count size_t child = root * 2 + 1; int order; if (child + 1 < count) { - if (!impact_sort_compare(w, items + child * size, items + (child + 1) * size, - hits, &order)) { + if (!impact_sort_compare(w, items + child * size, items + (child + 1) * size, hits, + &order)) { return false; } if (order < 0) { @@ -314,8 +315,8 @@ static impact_add_t impact_add_hit(cbm_impact_walk_t *w, impact_frontier_list_t /* Follow one edge type from `from` (a frontier hit) to every neighbour not * reached yet. stmt selects the far end of the edges of that type. */ -static bool impact_expand(cbm_impact_walk_t *w, sqlite3_stmt *stmt, int from, - const char *edge_type, impact_frontier_list_t *next) { +static bool impact_expand(cbm_impact_walk_t *w, sqlite3_stmt *stmt, int from, const char *edge_type, + impact_frontier_list_t *next) { if (!impact_check(w) || w->hits[from].hop == INT_MAX) { return false; } @@ -323,7 +324,8 @@ static bool impact_expand(cbm_impact_walk_t *w, sqlite3_stmt *stmt, int from, int hop = w->hits[from].hop + SKIP_ONE; if (sqlite3_reset(stmt) != SQLITE_OK || sqlite3_bind_int64(stmt, IMPACT_BIND_ID, from_id) != SQLITE_OK || - sqlite3_bind_text(stmt, IMPACT_BIND_TYPE, edge_type, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK) { + sqlite3_bind_text(stmt, IMPACT_BIND_TYPE, edge_type, CBM_NOT_FOUND, SQLITE_STATIC) != + SQLITE_OK) { return false; } int rc; @@ -379,8 +381,7 @@ static bool impact_expand_level(cbm_impact_walk_t *w, const impact_frontier_list return false; } int hit = frontier->items[f].hit; - if (w->sink[w->hits[hit].id] || - strcmp(w->hits[hit].label, IMPACT_ROUTE_LABEL) != 0) { + if (w->sink[w->hits[hit].id] || strcmp(w->hits[hit].label, IMPACT_ROUTE_LABEL) != 0) { continue; } if (!impact_expand(w, w->stmt_in, hit, IMPACT_ROUTE_IN[t], next)) { @@ -419,8 +420,8 @@ void cbm_impact_walk_close(cbm_impact_walk_t *w) { static bool impact_copy_policy(cbm_impact_walk_t *w, const cbm_impact_policy_t *policy) { w->project = cbm_mem_strdup(CBM_MEM_CLASS_STORE, policy->project); - w->edge_types = (char **)cbm_calloc(CBM_MEM_CLASS_STORE, - (size_t)policy->edge_type_count * sizeof(char *)); + w->edge_types = + (char **)cbm_calloc(CBM_MEM_CLASS_STORE, (size_t)policy->edge_type_count * sizeof(char *)); if (!w->project || !w->edge_types) { return false; } @@ -444,9 +445,8 @@ static bool impact_copy_policy(cbm_impact_walk_t *w, const cbm_impact_policy_t * static bool impact_prepare(cbm_impact_walk_t *w) { sqlite3_stmt *max_stmt = NULL; - if (!impact_check(w) || - sqlite3_prepare_v2(w->db, "SELECT COALESCE(MAX(id), 0) FROM nodes", CBM_NOT_FOUND, - &max_stmt, NULL) != SQLITE_OK) { + if (!impact_check(w) || sqlite3_prepare_v2(w->db, "SELECT COALESCE(MAX(id), 0) FROM nodes", + CBM_NOT_FOUND, &max_stmt, NULL) != SQLITE_OK) { return false; } bool have_max = sqlite3_step(max_stmt) == SQLITE_ROW; @@ -454,8 +454,8 @@ static bool impact_prepare(cbm_impact_walk_t *w) { w->max_id = sqlite3_column_int64(max_stmt, 0); } int finalized = sqlite3_finalize(max_stmt); - if (!have_max || finalized != SQLITE_OK || !impact_check(w) || - w->max_id < 0 || w->max_id >= INT32_MAX) { + if (!have_max || finalized != SQLITE_OK || !impact_check(w) || w->max_id < 0 || + w->max_id >= INT32_MAX) { return false; } /* The whole per-node state, sized once from the graph: whether the walk @@ -466,18 +466,22 @@ static bool impact_prepare(cbm_impact_walk_t *w) { if (!w->slot || !w->sink) { return false; } - if (!impact_check(w) || sqlite3_prepare_v2(w->db, + if (!impact_check(w) || + sqlite3_prepare_v2(w->db, "SELECT qualified_name, label FROM nodes WHERE id = ?1 AND project = ?2", CBM_NOT_FOUND, &w->stmt_node, NULL) != SQLITE_OK || - !impact_check(w) || sqlite3_prepare_v2(w->db, "SELECT source_id FROM edges WHERE target_id = ?1 AND type = ?2", + !impact_check(w) || + sqlite3_prepare_v2(w->db, "SELECT source_id FROM edges WHERE target_id = ?1 AND type = ?2", CBM_NOT_FOUND, &w->stmt_in, NULL) != SQLITE_OK || - !impact_check(w) || sqlite3_prepare_v2(w->db, "SELECT target_id FROM edges WHERE source_id = ?1 AND type = ?2", + !impact_check(w) || + sqlite3_prepare_v2(w->db, "SELECT target_id FROM edges WHERE source_id = ?1 AND type = ?2", CBM_NOT_FOUND, &w->stmt_out, NULL) != SQLITE_OK) { return false; } /* The project never changes for the life of the walk. */ return sqlite3_bind_text(w->stmt_node, IMPACT_BIND_TYPE, w->project, CBM_NOT_FOUND, - SQLITE_STATIC) == SQLITE_OK && impact_check(w); + SQLITE_STATIC) == SQLITE_OK && + impact_check(w); } static int impact_open(cbm_store_t *s, cbm_store_read_scope_t *scope, @@ -492,8 +496,8 @@ static int impact_open(cbm_store_t *s, cbm_store_read_scope_t *scope, } } sqlite3 *db = cbm_store_get_db(s); - if (!db || !out || !policy || !policy->project || !policy->project[0] || - !policy->edge_types || policy->edge_type_count <= 0) { + if (!db || !out || !policy || !policy->project || !policy->project[0] || !policy->edge_types || + policy->edge_type_count <= 0) { return scope ? cbm_store_read_scope_fail(scope, CBM_STORE_ERR) : CBM_STORE_ERR; } cbm_impact_walk_t *w = (cbm_impact_walk_t *)cbm_calloc(CBM_MEM_CLASS_STORE, sizeof(*w)); @@ -516,8 +520,8 @@ int cbm_impact_walk_open(cbm_store_t *s, const cbm_impact_policy_t *policy, return impact_open(s, NULL, policy, out); } -int cbm_impact_walk_open_scoped(cbm_store_read_scope_t *scope, - const cbm_impact_policy_t *policy, cbm_impact_walk_t **out) { +int cbm_impact_walk_open_scoped(cbm_store_read_scope_t *scope, const cbm_impact_policy_t *policy, + cbm_impact_walk_t **out) { if (!scope) { if (out) { *out = NULL; @@ -529,8 +533,7 @@ int cbm_impact_walk_open_scoped(cbm_store_read_scope_t *scope, /* True when `id` names a node of the walk's project. */ static bool impact_node_exists(cbm_impact_walk_t *w, int64_t id) { - if (!impact_check(w) || !impact_known_id(w, id) || - sqlite3_reset(w->stmt_node) != SQLITE_OK || + if (!impact_check(w) || !impact_known_id(w, id) || sqlite3_reset(w->stmt_node) != SQLITE_OK || sqlite3_bind_int64(w->stmt_node, IMPACT_BIND_ID, id) != SQLITE_OK) { return false; } diff --git a/src/store/store_impact.h b/src/store/store_impact.h index fce10bd84..a32f8aa32 100644 --- a/src/store/store_impact.h +++ b/src/store/store_impact.h @@ -61,8 +61,8 @@ int cbm_impact_walk_open(cbm_store_t *s, const cbm_impact_policy_t *policy, /* Same walk within a pinned read scope, borrowed until close. All work, * including ordering, checks cancellation. The first walk error invalidates * this walk and its scope. Close the walk before closing the scope. */ -int cbm_impact_walk_open_scoped(cbm_store_read_scope_t *scope, - const cbm_impact_policy_t *policy, cbm_impact_walk_t **out); +int cbm_impact_walk_open_scoped(cbm_store_read_scope_t *scope, const cbm_impact_policy_t *policy, + cbm_impact_walk_t **out); /* Nodes that are reached but never expanded: a test case that reaches the * change is selected, and what calls the test case is not impacted by it. May diff --git a/tests/test_language.c b/tests/test_language.c index dc2bcf7d3..286733c7f 100644 --- a/tests/test_language.c +++ b/tests/test_language.c @@ -1314,6 +1314,180 @@ TEST(lang_all_have_names) { PASS(); } +/* ── One classifier for a name plus its first bytes ───────────────── + * Discovery reads files from disk; the pinned test-impact inventory reads git + * blobs. Both must give every file the same language, so both go through + * cbm_language_classify with the bytes cbm_language_probe_bytes asks for. */ + +/* The file `name` with `content`, written byte for byte (no text-mode + * conversion on any platform). */ +static bool write_exact(const char *path, const char *content, size_t len) { + FILE *f = fopen(path, "wb"); + if (!f) { + return false; + } + bool ok = fwrite(content, 1, len, f) == len; + return fclose(f) == 0 && ok; +} + +/* The classifier on the first probe_bytes(name) bytes of `content`, as the + * pinned inventory calls it. */ +static CBMLanguage classify_content(const char *name, const char *content, size_t len) { + size_t probe = cbm_language_probe_bytes(name); + size_t head = len < probe ? len : probe; + return cbm_language_classify(name, (const unsigned char *)content, head, len > head, true); +} + +typedef CBMLanguage (*path_probe_fn)(const char *path); + +TEST(lang_probe_bytes_follow_the_name) { + ASSERT_EQ(cbm_language_probe_bytes("main.go"), 0); + ASSERT_EQ(cbm_language_probe_bytes("Makefile"), 0); + ASSERT_EQ(cbm_language_probe_bytes("Foo.m"), 4096); + ASSERT_EQ(cbm_language_probe_bytes("Foo.cls"), 4096); + ASSERT_EQ(cbm_language_probe_bytes("foo.inc"), 4096); + ASSERT_EQ(cbm_language_probe_bytes("Form1.frm"), 4096); + ASSERT_EQ(cbm_language_probe_bytes("App.res"), 4096); + ASSERT_EQ(cbm_language_probe_bytes("Widget.cfc"), 16384); + ASSERT_EQ(cbm_language_probe_bytes("pom.xml"), 255); + /* An unknown name falls back to a shebang probe of the first line. */ + ASSERT_EQ(cbm_language_probe_bytes("run-tests"), 255); + ASSERT_EQ(cbm_language_probe_bytes("package.json"), 0); + PASS(); +} + +/* Every content rule, through the classifier and through the path probe + * discovery used before: both must agree, and agree with the expectation. */ +TEST(lang_classify_matches_every_content_rule) { + static const struct { + const char *name; + const char *content; + CBMLanguage expect; + path_probe_fn path_probe; /* NULL: no public path probe for this rule */ + } cases[] = { + {"a.m", "#import \n@interface A\n@end\n", CBM_LANG_OBJC, + cbm_disambiguate_m}, + {"b.m", "function f(x)\n return x;\nend function;\n", CBM_LANG_MAGMA, cbm_disambiguate_m}, + {"c.m", "intrinsic Foo(x) -> RngIntElt\n{}\n", CBM_LANG_MAGMA, cbm_disambiguate_m}, + {"d.m", "% a comment\nx = 1;\n", CBM_LANG_MATLAB, cbm_disambiguate_m}, + {"e.cls", "VERSION 1.0 CLASS\r\nAttribute VB_Name = \"W\"\r\n", CBM_LANG_COUNT, + cbm_disambiguate_cls}, + {"f.cls", "/// doc\nClass Demo.Person Extends %Persistent\n{\n}\n", + CBM_LANG_OBJECTSCRIPT_UDL, cbm_disambiguate_cls}, + {"g.cls", "public class Account { }\n", CBM_LANG_APEX, cbm_disambiguate_cls}, + {"h.inc", "ROUTINE MyMacros [Type=INC]\n", CBM_LANG_OBJECTSCRIPT_ROUTINE, + cbm_disambiguate_inc}, + {"i.inc", "#define Max 10\n", CBM_LANG_OBJECTSCRIPT_ROUTINE, cbm_disambiguate_inc}, + {"j.inc", "# a BitBake comment\nSRC_URI = \"x\"\n", CBM_LANG_BITBAKE, cbm_disambiguate_inc}, + {"k.cfc", "\n\n\n", CBM_LANG_CFML, + cbm_disambiguate_cfc}, + {"l.cfc", "\ncomponent {}\n", CBM_LANG_CFSCRIPT, + cbm_disambiguate_cfc}, + {"m.cfc", "select 1\n", CBM_LANG_CFML, cbm_disambiguate_cfc}, + {"n.cfc", "component {\n function f() {}\n}\n", CBM_LANG_CFSCRIPT, cbm_disambiguate_cfc}, + {"o.frm", "VERSION 5.00\r\nBegin VB.Form Form1\r\n", CBM_LANG_COUNT, cbm_disambiguate_frm}, + {"p.frm", "#procedure foo\nLocal F = a;\n", CBM_LANG_FORM, cbm_disambiguate_frm}, + {"q.res", "RSRC\0\0\0\1binary", CBM_LANG_COUNT, cbm_disambiguate_res}, + {"r.res", "let x = 1\n", CBM_LANG_RESCRIPT, cbm_disambiguate_res}, + {"s.xml", "\n\n", + CBM_LANG_OBJECTSCRIPT_EXPORT, NULL}, + {"t.xml", "\n\n", CBM_LANG_XML, NULL}, + {"package.json", "{}\n", CBM_LANG_COUNT, NULL}, + {"data.json", "{}\n", CBM_LANG_JSON, NULL}, + {"u", "#!/usr/bin/env python3\nprint(1)\n", CBM_LANG_PYTHON, cbm_language_from_shebang}, + {"v", "#!/bin/sh\r\necho hi\r\n", CBM_LANG_BASH, cbm_language_from_shebang}, + {"w", "#!/usr/bin/env -S node --no-warnings\n", CBM_LANG_JAVASCRIPT, + cbm_language_from_shebang}, + {"x", "#!/usr/bin/env PYTHON=/usr/bin/python python\n", CBM_LANG_COUNT, + cbm_language_from_shebang}, + {"y", "plain text, no shebang\n", CBM_LANG_COUNT, cbm_language_from_shebang}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + /* q.res holds NULs: its length is the literal's, not strlen. */ + size_t len = strcmp(cases[i].name, "q.res") == 0 ? 15 : strlen(cases[i].content); + CBMLanguage got = classify_content(cases[i].name, cases[i].content, len); + if (got != cases[i].expect) { + printf(" %s: classifier %d, expected %d\n", cases[i].name, (int)got, + (int)cases[i].expect); + } + ASSERT_EQ(got, cases[i].expect); + if (cases[i].path_probe) { + char path[512]; + snprintf(path, sizeof(path), "%s/cbm_lang_classify_%s", cbm_tmpdir(), cases[i].name); + ASSERT_TRUE(write_exact(path, cases[i].content, len)); + CBMLanguage by_path = cases[i].path_probe(path); + remove(path); + if (by_path != cases[i].expect) { + printf(" %s: path probe %d, expected %d\n", cases[i].name, (int)by_path, + (int)cases[i].expect); + } + ASSERT_EQ(by_path, cases[i].expect); + } + } + PASS(); +} + +/* Content that cannot be read gets what a failed open gave before: the + * name's default, and no language for a name that needed a shebang. */ +TEST(lang_classify_unreadable_content_keeps_the_name_default) { + ASSERT_EQ(cbm_language_classify("a.m", NULL, 0, false, false), CBM_LANG_MATLAB); + ASSERT_EQ(cbm_language_classify("a.cls", NULL, 0, false, false), CBM_LANG_APEX); + ASSERT_EQ(cbm_language_classify("a.inc", NULL, 0, false, false), CBM_LANG_BITBAKE); + ASSERT_EQ(cbm_language_classify("a.cfc", NULL, 0, false, false), CBM_LANG_CFSCRIPT); + ASSERT_EQ(cbm_language_classify("a.frm", NULL, 0, false, false), CBM_LANG_FORM); + ASSERT_EQ(cbm_language_classify("a.res", NULL, 0, false, false), CBM_LANG_RESCRIPT); + ASSERT_EQ(cbm_language_classify("a.xml", NULL, 0, false, false), CBM_LANG_XML); + ASSERT_EQ(cbm_language_classify("run-tests", NULL, 0, false, false), CBM_LANG_COUNT); + ASSERT_EQ(cbm_language_classify("main.go", NULL, 0, false, false), CBM_LANG_GO); + ASSERT_EQ(cbm_language_classify("package.json", NULL, 0, false, false), CBM_LANG_COUNT); + PASS(); +} + +/* A shebang counts only when the whole first line was seen: a first line that + * runs past the probe is cut, and a cut line names no interpreter. */ +TEST(lang_classify_shebang_needs_the_whole_first_line) { + char line[300]; + memset(line, ' ', sizeof(line)); + memcpy(line, "#!/bin/sh", 9); + /* 255 bytes of first line, and more bytes follow: cut. */ + ASSERT_EQ(cbm_language_classify("tool", (const unsigned char *)line, 255, true, true), + CBM_LANG_COUNT); + /* The same 255 bytes are the whole file: the line is complete. */ + ASSERT_EQ(cbm_language_classify("tool", (const unsigned char *)line, 255, false, true), + CBM_LANG_BASH); + /* The path probe agrees on both shapes. */ + char path[512]; + snprintf(path, sizeof(path), "%s/cbm_lang_classify_long_shebang", cbm_tmpdir()); + ASSERT_TRUE(write_exact(path, line, sizeof(line))); + ASSERT_EQ(cbm_language_from_shebang(path), CBM_LANG_COUNT); + ASSERT_TRUE(write_exact(path, line, 255)); + ASSERT_EQ(cbm_language_from_shebang(path), CBM_LANG_BASH); + remove(path); + PASS(); +} + +/* A probe reads a fixed number of leading bytes and nothing beyond: a marker + * past the probe does not count, on either path. */ +TEST(lang_classify_reads_only_its_probe) { + static char content[5000]; + memset(content, ' ', sizeof(content)); + content[sizeof(content) - 1] = '\0'; + memcpy(content + 4500, "@interface", 10); + size_t len = strlen(content); + ASSERT_EQ(classify_content("late.m", content, len), CBM_LANG_MATLAB); + char path[512]; + snprintf(path, sizeof(path), "%s/cbm_lang_classify_late.m", cbm_tmpdir()); + ASSERT_TRUE(write_exact(path, content, len)); + ASSERT_EQ(cbm_disambiguate_m(path), CBM_LANG_MATLAB); + /* Moved inside the probe it counts. */ + memcpy(content + 100, "@interface", 10); + ASSERT_EQ(classify_content("late.m", content, len), CBM_LANG_OBJC); + ASSERT_TRUE(write_exact(path, content, len)); + ASSERT_EQ(cbm_disambiguate_m(path), CBM_LANG_OBJC); + remove(path); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ SUITE(language) { @@ -1577,4 +1751,9 @@ SUITE(language) { RUN_TEST(lang_ext_sosl); RUN_TEST(lang_all_have_names); + RUN_TEST(lang_probe_bytes_follow_the_name); + RUN_TEST(lang_classify_matches_every_content_rule); + RUN_TEST(lang_classify_unreadable_content_keeps_the_name_default); + RUN_TEST(lang_classify_shebang_needs_the_whole_first_line); + RUN_TEST(lang_classify_reads_only_its_probe); } diff --git a/tests/test_main.c b/tests/test_main.c index d96ef9347..c2a09e4be 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1730,6 +1730,10 @@ extern void suite_test_impact_git(void); extern void suite_test_impact_tree(void); extern void suite_test_impact_tree_read(void); extern void suite_test_impact_inventory(void); +extern void suite_test_impact_classify(void); +extern void suite_test_impact_source(void); +extern void suite_test_impact_seed(void); +extern void suite_test_impact_engine(void); extern void suite_test_impact_runner_filter(void); extern void suite_store_search(void); extern void suite_cypher(void); @@ -2116,6 +2120,10 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(test_impact_tree); RUN_SELECTED_SUITE(test_impact_tree_read); RUN_SELECTED_SUITE(test_impact_inventory); + RUN_SELECTED_SUITE(test_impact_classify); + RUN_SELECTED_SUITE(test_impact_source); + RUN_SELECTED_SUITE(test_impact_seed); + RUN_SELECTED_SUITE(test_impact_engine); RUN_SELECTED_SUITE(test_impact_runner_filter); RUN_SELECTED_SUITE(store_search); RUN_SELECTED_SUITE(store_bulk); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 5daae1c00..e6e057d06 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -15871,6 +15871,30 @@ TEST(detect_changes_product_filter_uses_project_config_and_override) { PASS(); } +/* scope:"tests" answers with the test_impact object (mcp/test_impact_engine.h). + * The selection reads committed changes only: the fixture's edit is in the + * worktree, so against main nothing changed. A config_path that does not + * exist is evidence missing, never a narrower answer: everything runs. */ +TEST(detect_changes_scope_tests_answers_test_impact) { + dc_walk_fixture_t fx; + ASSERT_TRUE(dc_walk_fixture_open(&fx)); + char *text = dc_walk_call(&fx, ",\"scope\":\"tests\""); + ASSERT_NOT_NULL(text); + if (!strstr(text, "\"decision\":\"nothing\"")) { + printf(" %.400s\n", text); + } + ASSERT_TRUE(strncmp(text, "{\"test_impact\":{", 16) == 0); + ASSERT_NOT_NULL(strstr(text, "\"decision\":\"nothing\"")); + free(text); + text = dc_walk_call(&fx, ",\"scope\":\"tests\",\"config_path\":\"missing/config.json\""); + ASSERT_NOT_NULL(text); + ASSERT_NOT_NULL(strstr(text, "\"decision\":\"run_all\"")); + ASSERT_NOT_NULL(strstr(text, "CONFIG_INVALID")); + free(text); + dc_walk_fixture_close(&fx); + PASS(); +} + TEST(detect_changes_fixpoint_walks_past_depth) { dc_walk_fixture_t fx; if (!dc_walk_fixture_open(&fx)) { @@ -21890,6 +21914,7 @@ SUITE(mcp) { RUN_TEST(detect_changes_product_filter_does_not_inflate_fallback); RUN_TEST(detect_changes_seed_params_reject_misuse); RUN_TEST(detect_changes_product_filter_uses_project_config_and_override); + RUN_TEST(detect_changes_scope_tests_answers_test_impact); RUN_TEST(detect_changes_fixpoint_walks_past_depth); RUN_TEST(detect_changes_edge_types_reach_references); RUN_TEST(detect_changes_via_names_the_parent); diff --git a/tests/test_pipeline_conventions.c b/tests/test_pipeline_conventions.c index a851dbfda..ff8d73653 100644 --- a/tests/test_pipeline_conventions.c +++ b/tests/test_pipeline_conventions.c @@ -320,24 +320,38 @@ TEST(pipeline_conventions_absent_and_default_omit_role_properties) { ASSERT_TRUE(setup); ASSERT_TRUE(absent); ASSERT_TRUE(configured); ASSERT_TRUE(cleanup); ASSERT_EQ(rc, 0); ASSERT_TRUE(defaults); PASS(); } -static int pc_typed_failure(bool legacy) { +static bool pc_coverage_kind(const char *db, const char *path, const char *kind) { + cbm_store_t *s = cbm_store_open_path_query(db); + cbm_coverage_row_t *rows = NULL; int count = 0; bool found = false; + if (s && cbm_store_coverage_get_path(s, PC_PROJECT, path, &rows, &count) == CBM_STORE_OK) + for (int i = 0; i < count; i++) found = found || pc_text(rows[i].kind, kind); + cbm_store_free_coverage(rows, count); cbm_store_close(s); return found; +} +/* A configured form that cannot be mapped degrades its FILE, not the index + * (user decision 2026-10-04): the run publishes, the file is indexed without + * configured test roles, and it carries a test_declarations diagnostic. Before, + * one such file aborted the whole index (cbm's own repro matrices did). */ +static int pc_typed_degrade(bool legacy) { pc_env_t env; pc_fixture_t f = {0}; pc_snapshot_t snapshot; bool setup = pc_env_begin(&env, legacy) && pc_fixture_open(&f, legacy); bool control = setup && pc_seed(&f, pc_config0) && pc_snapshot(&f, &snapshot); bool changed = control && pc_source_write(&f, "unit.c", pc_bad_source); cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE; int rc = changed ? pc_run(&f, legacy ? CBM_MODE_FULL : CBM_MODE_FAST, legacy, 0, NULL, NULL, &route) : -999; - bool preserved = changed && pc_preserved(&f, &snapshot); + char now[128]; + bool published = changed && pc_generation(f.db, now) && strcmp(now, snapshot.generation) != 0 && + pc_ordinary_graph(f.db, "preserved") && pc_no_roles(f.db) && + pc_coverage_kind(f.db, "unit.c", "test_declarations"); bool cleanup = pc_close(&f, &env); ASSERT_TRUE(setup); ASSERT_TRUE(control); ASSERT_TRUE(changed); ASSERT_TRUE(cleanup); ASSERT_EQ(route, legacy ? CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL : CBM_INCREMENTAL_ROUTE_FORCED_FULL); - ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); ASSERT_TRUE(preserved); PASS(); + ASSERT_EQ(rc, 0); ASSERT_TRUE(published); PASS(); } -TEST(pipeline_conventions_typed_failure_full_preserves_generation) { - ASSERT_EQ(pc_init_status, 0); return pc_typed_failure(false); +TEST(pipeline_conventions_typed_failure_full_degrades_the_file) { + ASSERT_EQ(pc_init_status, 0); return pc_typed_degrade(false); } -TEST(pipeline_conventions_typed_failure_incremental_preserves_generation) { - ASSERT_EQ(pc_init_status, 0); return pc_typed_failure(true); +TEST(pipeline_conventions_typed_failure_incremental_degrades_the_file) { + ASSERT_EQ(pc_init_status, 0); return pc_typed_degrade(true); } typedef struct { pc_fixture_t *fixture; int called; bool staged, written; } pc_hook_t; @@ -610,8 +624,8 @@ SUITE(pipeline_conventions) { RUN_TEST(pipeline_conventions_parallel_roles_qns_and_calls); RUN_TEST(pipeline_conventions_config_only_change_rebuilds_same_source); RUN_TEST(pipeline_conventions_absent_and_default_omit_role_properties); - RUN_TEST(pipeline_conventions_typed_failure_full_preserves_generation); - RUN_TEST(pipeline_conventions_typed_failure_incremental_preserves_generation); + RUN_TEST(pipeline_conventions_typed_failure_full_degrades_the_file); + RUN_TEST(pipeline_conventions_typed_failure_incremental_degrades_the_file); RUN_TEST(pipeline_conventions_publication_config_mutation_preserves_generation); RUN_TEST(pipeline_conventions_published_owners_survive_join_and_config_edit); RUN_TEST(pipeline_conventions_disabled_required_parallel_crosswalk_preserves_generation); diff --git a/tests/test_pipeline_frozen.c b/tests/test_pipeline_frozen.c index d48338a09..028856d7c 100644 --- a/tests/test_pipeline_frozen.c +++ b/tests/test_pipeline_frozen.c @@ -1264,7 +1264,8 @@ static void pf_diag_before_publish(cbm_pipeline_t *pipeline, const char *stage, unsigned flags = 123; hook->building_getter = !cbm_pipeline_frozen_negative_evidence(hook->owner, &flags) && flags == 0; - char path[] = "ordinary.c", reason[] = "observed fixture diagnostic", phase[] = "parse_partial"; + /* A blocking phase: a partial parse alone no longer refuses publication. */ + char path[] = "ordinary.c", reason[] = "observed fixture diagnostic", phase[] = "extract"; cbm_pipeline_add_file_error(pipeline, path, hook->incomplete ? NULL : reason, phase); memset(path, 'x', sizeof(path) - 1); memset(reason, 'y', sizeof(reason) - 1); @@ -1337,7 +1338,7 @@ static bool pf_diag_run(pf_fixture_t *f, cbm_pipeline_frozen_diag_fault_t fault, pf_text(rows[0].path, hook.followup ? "followup.c" : "ordinary.c") && pf_text(rows[0].reason, hook.followup ? "retained later diagnostic" : "observed fixture diagnostic") && - pf_text(rows[0].phase, hook.followup ? "extract" : "parse_partial"); + pf_text(rows[0].phase, "extract"); bool failed_state = !cbm_pipeline_frozen_candidate_path(owner) && cbm_pipeline_frozen_build(owner) == CBM_PIPELINE_FROZEN_INVALID && @@ -1451,10 +1452,11 @@ TEST(pipeline_frozen_diagnostic_state_and_cancel_precedence) { PASS(); } -/* The recovered one-line macro retains a real parse-coverage diagnostic. - * Keep it as a publication-rejection case while the positive owner/config - * cases use multiline recovered definitions with unambiguous body ranges. */ -TEST(pipeline_frozen_real_parse_diagnostic_blocks_publication) { +/* The recovered one-line macro retains a real parse-coverage diagnostic. A + * partial parse is a parse gap (user decision 2026-10-04): the candidate is + * published, the row is kept, and only the non-blocking PARSE_GAP bit is set, + * so the selection can treat a change to this file as a parse gap. */ +TEST(pipeline_frozen_real_parse_gap_is_recorded_not_refused) { ASSERT_EQ(pf_init_status, 0); static const char one_line[] = "void case_sink(void) {}\nvoid suite_sink(void) {}\n" @@ -1472,12 +1474,12 @@ TEST(pipeline_frozen_real_parse_diagnostic_blocks_publication) { cbm_file_error_t *rows = NULL; int count = 0; cbm_pipeline_get_file_errors(cbm_pipeline_frozen_diagnostics(owner), &rows, &count); - bool recorded = evidence && flags == CBM_PIPELINE_FROZEN_FILE_DIAGNOSTIC && rows && - count == 1 && pf_text(rows[0].path, "unit.pfsource") && + bool recorded = evidence && flags == CBM_PIPELINE_FROZEN_PARSE_GAP && rows && count == 1 && + pf_text(rows[0].path, "unit.pfsource") && pf_text(rows[0].phase, "parse_partial") && pf_text(rows[0].reason, "3-3"); - bool rejected = status == CBM_PIPELINE_FROZEN_PIPELINE_ERROR && - !cbm_pipeline_frozen_candidate_path(owner) && pf_absent(f.db) && - pf_directory_only(f.destination, NULL) && pf_files_equal(f.control, f.saved); + const char *candidate = cbm_pipeline_frozen_candidate_path(owner); + bool published = status == CBM_PIPELINE_FROZEN_OK && candidate && pf_text(candidate, f.db) && + !pf_absent(f.db) && pf_files_equal(f.control, f.saved); cbm_pipeline_frozen_free(owner); bool cleanup = pf_close(&f); ASSERT_TRUE(setup); @@ -1486,12 +1488,144 @@ TEST(pipeline_frozen_real_parse_diagnostic_blocks_publication) { ASSERT_TRUE(ready); ASSERT_TRUE(cleanup); ASSERT_TRUE(recorded); - ASSERT_TRUE(rejected); + ASSERT_TRUE(published); + PASS(); +} + +/* ── A classified file list instead of discovery ──────────────────── */ + +static bool pf_has_qn(const char *db, const char *qn) { + cbm_store_t *store = cbm_store_open_path_query(db); + if (!store) + return false; + cbm_node_t node = {0}; + bool found = cbm_store_find_node_by_qn(store, PF_PROJECT, qn, &node) == CBM_STORE_OK; + cbm_node_free_fields(&node); + cbm_store_close(store); + return found; +} + +static cbm_pipeline_frozen_status_t pf_create_listed(pf_fixture_t *f, + const cbm_pipeline_frozen_file_t *files, + size_t count, cbm_pipeline_frozen_t **out) { + cbm_git_context_t git = pf_git(f); + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, NULL, &git, NULL); + in.files = files; + in.file_count = count; + return cbm_pipeline_frozen_create(&in, out); +} + +/* The build indexes exactly the listed files, under the listed languages: a + * file on disk that is not listed is not indexed, and a listed language wins + * over what the name alone would give. */ +TEST(pipeline_frozen_indexes_exactly_the_listed_files) { + pf_fixture_t f; + cbm_pipeline_frozen_t *owner = NULL; + ASSERT_TRUE(pf_open(&f)); + bool ok = pf_source_write(&f, "listed.c", pf_ordinary) && + pf_source_write(&f, "unlisted.c", "void stray(void) {}\n") && + pf_source_write(&f, "script.foo", "def py_fn():\n return 1\n") && + pf_source_write(&f, "README.md", "# readme\n"); + static const cbm_pipeline_frozen_file_t files[] = { + {"script.foo", CBM_LANG_PYTHON}, + {"listed.c", CBM_LANG_C}, + {"README.md", CBM_LANG_COUNT}, + }; + ok = ok && pf_create_listed(&f, files, 3, &owner) == CBM_PIPELINE_FROZEN_OK && + cbm_pipeline_frozen_build(owner) == CBM_PIPELINE_FROZEN_OK; + bool listed = ok && pf_has_qn(f.db, PF_PROJECT ".listed.ordinary") && + pf_has_qn(f.db, PF_PROJECT ".listed.sink"); + bool unlisted_absent = ok && !pf_has_qn(f.db, PF_PROJECT ".unlisted.stray"); + bool language_from_list = ok && pf_has_qn(f.db, PF_PROJECT ".script.py_fn"); + cbm_pipeline_frozen_free(owner); + ASSERT_TRUE(pf_close(&f)); + ASSERT_TRUE(ok); + ASSERT_TRUE(listed); + ASSERT_TRUE(unlisted_absent); + ASSERT_TRUE(language_from_list); + PASS(); +} + +/* A listed file that is not in the source root means the root is not the + * snapshot the list came from. */ +TEST(pipeline_frozen_missing_listed_file_is_input_changed) { + pf_fixture_t f; + cbm_pipeline_frozen_t *owner = NULL; + ASSERT_TRUE(pf_open(&f)); + bool ok = pf_source_write(&f, "listed.c", pf_ordinary); + static const cbm_pipeline_frozen_file_t files[] = {{"listed.c", CBM_LANG_C}, + {"gone.c", CBM_LANG_C}}; + ok = ok && pf_create_listed(&f, files, 2, &owner) == CBM_PIPELINE_FROZEN_OK; + cbm_pipeline_frozen_status_t status = + ok ? cbm_pipeline_frozen_build(owner) : CBM_PIPELINE_FROZEN_INVALID; + bool no_candidate = ok && cbm_pipeline_frozen_candidate_path(owner) == NULL && pf_absent(f.db); + cbm_pipeline_frozen_free(owner); + ASSERT_TRUE(pf_close(&f)); + ASSERT_TRUE(ok); + ASSERT_EQ(status, CBM_PIPELINE_FROZEN_INPUT_CHANGED); + ASSERT_TRUE(no_candidate); + PASS(); +} + +/* A list that cannot name files in the source root is refused at create. */ +TEST(pipeline_frozen_rejects_malformed_file_lists) { + pf_fixture_t f; + ASSERT_TRUE(pf_open(&f)); + static const struct { + cbm_pipeline_frozen_file_t file; + } bad[] = { + {{"../escape.c", CBM_LANG_C}}, + {{"/abs.c", CBM_LANG_C}}, + {{"", CBM_LANG_C}}, + {{"a//b.c", CBM_LANG_C}}, + {{"./a.c", CBM_LANG_C}}, + {{"a/../b.c", CBM_LANG_C}}, + {{"a\\b.c", CBM_LANG_C}}, + {{NULL, CBM_LANG_C}}, + {{"a.c", (CBMLanguage)-1}}, + {{"a.c", (CBMLanguage)(CBM_LANG_COUNT + 1)}}, + }; + bool all_invalid = true; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + cbm_pipeline_frozen_t *owner = NULL; + cbm_pipeline_frozen_status_t status = pf_create_listed(&f, &bad[i].file, 1, &owner); + if (status != CBM_PIPELINE_FROZEN_INVALID || owner) { + printf(" case %zu: status %d\n", i, (int)status); + all_invalid = false; + } + cbm_pipeline_frozen_free(owner); + } + /* The same path twice. */ + static const cbm_pipeline_frozen_file_t twice[] = {{"a.c", CBM_LANG_C}, {"a.c", CBM_LANG_CPP}}; + cbm_pipeline_frozen_t *owner = NULL; + bool twice_invalid = + pf_create_listed(&f, twice, 2, &owner) == CBM_PIPELINE_FROZEN_INVALID && owner == NULL; + cbm_pipeline_frozen_free(owner); + /* A count without a list. */ + cbm_git_context_t git = pf_git(&f); + cbm_pipeline_frozen_inputs_t in = pf_inputs(&f, NULL, &git, NULL); + in.file_count = 3; + owner = NULL; + bool count_invalid = + cbm_pipeline_frozen_create(&in, &owner) == CBM_PIPELINE_FROZEN_INVALID && owner == NULL; + cbm_pipeline_frozen_free(owner); + /* An empty list is a valid list: it indexes nothing. */ + static const cbm_pipeline_frozen_file_t one[] = {{"a.c", CBM_LANG_C}}; + in.files = one; + in.file_count = 0; + owner = NULL; + bool empty_ok = cbm_pipeline_frozen_create(&in, &owner) == CBM_PIPELINE_FROZEN_OK && owner; + cbm_pipeline_frozen_free(owner); + ASSERT_TRUE(pf_close(&f)); + ASSERT_TRUE(all_invalid); + ASSERT_TRUE(twice_invalid); + ASSERT_TRUE(count_invalid); + ASSERT_TRUE(empty_ok); PASS(); } SUITE(pipeline_frozen) { - RUN_TEST(pipeline_frozen_real_parse_diagnostic_blocks_publication); + RUN_TEST(pipeline_frozen_real_parse_gap_is_recorded_not_refused); pf_init_status = cbm_init(); RUN_TEST(pipeline_frozen_config_snapshot_contract); RUN_TEST(pipeline_frozen_detached_graph_and_owned_context); @@ -1504,5 +1638,8 @@ SUITE(pipeline_frozen) { RUN_TEST(pipeline_frozen_incomplete_diagnostic_is_sticky); RUN_TEST(pipeline_frozen_diagnostic_allocation_failures); RUN_TEST(pipeline_frozen_diagnostic_state_and_cancel_precedence); + RUN_TEST(pipeline_frozen_indexes_exactly_the_listed_files); + RUN_TEST(pipeline_frozen_missing_listed_file_is_input_changed); + RUN_TEST(pipeline_frozen_rejects_malformed_file_lists); cbm_work_arena_release(); } diff --git a/tests/test_test_conventions.c b/tests/test_test_conventions.c index 13a52062b..79862da18 100644 --- a/tests/test_test_conventions.c +++ b/tests/test_test_conventions.c @@ -149,14 +149,23 @@ static bool tc_body_owner(const CBMFileResult *result, const char *source, return calls > 0 && usages > 0; } +/* A configured form the extractor cannot map is detected with its exact + * status and location, then degrades the FILE (user decision 2026-10-04): + * the result reads OK, carries no configured roles, and keeps the issue. */ static bool tc_issue(const CBMFileResult *result, CBMTestExtractStatus status, int index, uint32_t line) { - return result && result->has_error && result->error_msg && result->error_msg[0] && - result->test_declarations_status == status && + return result && result->test_declarations_degraded && + result->test_declarations_degraded_status == status && + result->test_declarations_status == CBM_TEST_EXTRACT_OK && !result->has_error && (index == -2 ? result->test_declaration_index >= 0 : result->test_declaration_index == index) && result->test_declaration_line == line; } +static bool tc_degraded(const CBMFileResult *result) { + return result && result->test_declarations_degraded && + result->test_declarations_degraded_status != CBM_TEST_EXTRACT_OK && + result->test_declarations_status == CBM_TEST_EXTRACT_OK; +} TEST(conventions_null_absent_and_defaults_preserve_legacy) { ASSERT_EQ(tc_init_status, 0); @@ -241,7 +250,7 @@ TEST(conventions_duplicates_coalesce_and_conflicts_fail_typed) { "src/conflict.c", config) : NULL; bool bound = tc_issue(first, CBM_TEST_EXTRACT_AMBIGUOUS, -2, 1) && first->test_declaration_index < 2 && tc_issue(again, CBM_TEST_EXTRACT_AMBIGUOUS, - first->test_declaration_index, 1) && tc_text(first->error_msg, again->error_msg); + first->test_declaration_index, 1); if (!bound) fprintf(stderr, "convention conflict fixture %zu failed\n", i); rejected = bound && rejected; cbm_free_result(first); cbm_free_result(again); cbm_test_declarations_free(config); @@ -415,15 +424,12 @@ TEST(conventions_roles_and_errors_survive_compaction_and_spill) { CBMFileResult *bad = config ? tc_extract("CHECK(only) {}\n", CBM_LANG_C, "src/spill_error.c", config) : NULL; cbm_test_declarations_free(config); - char error_copy[1024] = {0}; bool before = tc_ok(good) && tc_role(good, "CHECK_alive", CBM_TEST_ROLE_CASE) && - tc_issue(bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1) && - strlen(bad->error_msg) < sizeof(error_copy); - if (before) memcpy(error_copy, bad->error_msg, strlen(bad->error_msg) + 1); + tc_issue(bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1); if (good) cbm_result_compact(good); if (bad) cbm_result_compact(bad); bool compacted = tc_ok(good) && tc_role(good, "CHECK_alive", CBM_TEST_ROLE_CASE) && - tc_issue(bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1) && tc_text(bad->error_msg, error_copy); + tc_issue(bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1); const char *temporary = th_mktempdir("cbm-test-conventions"); char directory[512] = {0}; bool dir_ok = temporary && strlen(temporary) < sizeof(directory); @@ -436,15 +442,16 @@ TEST(conventions_roles_and_errors_survive_compaction_and_spill) { if (parked_bad) bad = NULL; CBMFileResult header = {0}; bool header_ok = parked_bad && cbm_result_spill_peek_header(spill, 1, &header) && - header.test_declarations_status == CBM_TEST_EXTRACT_MISSING_ARGUMENT && - header.test_declaration_index == 0 && header.test_declaration_line == 1 && header.has_error; + header.test_declarations_degraded && + header.test_declarations_degraded_status == CBM_TEST_EXTRACT_MISSING_ARGUMENT && + header.test_declarations_status == CBM_TEST_EXTRACT_OK && + header.test_declaration_index == 0 && header.test_declaration_line == 1 && !header.has_error; CBMFileResult *loaded_good = parked_good ? cbm_result_spill_load(spill, 0) : NULL; CBMFileResult *loaded_bad = parked_bad ? cbm_result_spill_load(spill, 1) : NULL; if (spill) cbm_result_spill_close(spill); bool reloaded = tc_ok(loaded_good) && tc_role(loaded_good, "CHECK_alive", CBM_TEST_ROLE_CASE) && tc_def(loaded_good, "CHECK_alive")->is_test && - tc_issue(loaded_bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1) && - tc_text(loaded_bad->error_msg, error_copy); + tc_issue(loaded_bad, CBM_TEST_EXTRACT_MISSING_ARGUMENT, 0, 1); cbm_free_result(good); cbm_free_result(bad); cbm_free_result(loaded_good); cbm_free_result(loaded_bad); int cleanup = dir_ok ? th_rmtree(directory) : -1; @@ -734,8 +741,7 @@ TEST(conventions_cpp_digit_separator_does_not_hide_mapping) { CBMFileResult *bad = config ? tc_extract("int n=1'000;\nCHECK(alpha);\n", CBM_LANG_CPP, "src/digits.cpp", config) : NULL; bool control = tc_ok(good) && tc_only_case(good, "CHECK_alpha"); - bool rejected = bad && bad->has_error && bad->test_declarations_status != CBM_TEST_EXTRACT_OK && - bad->error_msg && bad->error_msg[0] && bad->test_declaration_index == 0 && + bool rejected = tc_degraded(bad) && bad->test_declaration_index == 0 && bad->test_declaration_line == 2; cbm_free_result(good); cbm_free_result(bad); cbm_test_declarations_free(config); ASSERT_TRUE(parsed); ASSERT_TRUE(control); ASSERT_TRUE(rejected); @@ -753,8 +759,7 @@ TEST(conventions_define_multiline_comment_preserves_complete_audit) { "#define N 1 /* ordinary\ncomment\nCHECK(ok) {}\n", CBM_LANG_C, "src/comments.c", config) : NULL; bool control = tc_ok(good) && tc_only_case(good, "CHECK_ok"); - bool rejected = bad && bad->has_error && bad->test_declarations_status != CBM_TEST_EXTRACT_OK && - bad->error_msg && bad->error_msg[0]; + bool rejected = tc_degraded(bad); cbm_free_result(good); cbm_free_result(bad); cbm_test_declarations_free(config); ASSERT_TRUE(parsed); ASSERT_TRUE(control); ASSERT_TRUE(rejected); PASS(); diff --git a/tests/test_test_impact.c b/tests/test_test_impact.c index 596621f42..18f649456 100644 --- a/tests/test_test_impact.c +++ b/tests/test_test_impact.c @@ -10,6 +10,7 @@ #include "test_helpers.h" #include #include +#include #include #include #include @@ -1686,7 +1687,228 @@ TEST(test_selection_inventory_ambiguity_and_conditionals_use_whole_suite) { PASS(); } -TEST(test_selection_unregistered_changes_and_unknown_runner_membership_run_all) { +/* Uncertainty is scoped to the file it was found in. A file the model cannot + * read with certainty, but which defines its own suite, makes that suite + * uncertain (it can only run whole) and leaves every other suite narrowable. + * The same uncertainty in the runner's file (the list of runnable suites) or + * in a file with no suite of its own stays global. cbm has such a file: + * tests/repro/repro_call_argument_matrix_a.c defines its tests by macro. */ +static const char *scoped_main = + "void main(void) { RUN_SELECTED_SUITE(alpha); RUN_SELECTED_SUITE(beta); }\n"; +static const char *scoped_cases = "TEST(stat) {}\nSUITE(alpha) { RUN_TEST(stat); }\n"; +static const char *scoped_macro = + "#define MAKE(n) \\\n TEST(n) {}\nTEST(other) {}\nSUITE(beta) { RUN_TEST(other); }\n"; + +TEST(test_model_uncertainty_is_scoped_to_the_suites_of_its_file) { + tm_source_t scoped[] = {{"tests/cases.c", scoped_cases}, + {"tests/macro.c", scoped_macro}, + {"tests/main.c", scoped_main}}; + cbm_test_model_t *model = model_of(scoped, 3); + ASSERT_NOT_NULL(model); + ASSERT_FALSE(cbm_test_model_complete(model)); + ASSERT_TRUE(cbm_test_model_narrowable(model)); + int n = 0; + const cbm_test_suite_t *suites = cbm_test_model_suites(model, &n); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(suites[0].name, "alpha"); + ASSERT_FALSE(suites[0].uncertain); + ASSERT_STR_EQ(suites[1].name, "beta"); + ASSERT_TRUE(suites[1].uncertain); + cbm_test_model_free(model); + + /* The runner's file is uncertain: some runnable suite may be missing. */ + tm_source_t runner[] = {{"tests/cases.c", scoped_cases}, + {"tests/main.c", "int all = 1; \\\n int more = 2;\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n"}}; + model = model_of(runner, 2); + ASSERT_NOT_NULL(model); + ASSERT_FALSE(cbm_test_model_narrowable(model)); + cbm_test_model_free(model); + + /* An uncertain file with no suite of its own: nothing to scope it to. */ + tm_source_t loose[] = {{"tests/cases.c", scoped_cases}, + {"tests/helper.c", "int helper = 1; \\\n int more = 2;\n"}, + {"tests/main.c", "void main(void) { RUN_SELECTED_SUITE(alpha); }\n"}}; + model = model_of(loose, 3); + ASSERT_NOT_NULL(model); + ASSERT_FALSE(cbm_test_model_narrowable(model)); + cbm_test_model_free(model); + + /* No uncertainty at all: complete and narrowable. */ + tm_source_t clean[] = {{"tests/cases.c", scoped_cases}, + {"tests/main.c", "void main(void) { RUN_SELECTED_SUITE(alpha); }\n"}}; + model = model_of(clean, 2); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_complete(model)); + ASSERT_TRUE(cbm_test_model_narrowable(model)); + cbm_test_model_free(model); + PASS(); +} + +/* A line continuation the lexer reads correctly is no uncertainty: inside a + * preprocessor directive (LF and CRLF) or a string literal. Only one in plain + * code, which can splice tokens, makes the file uncertain. Before this rule, + * any continuation anywhere did, and every cbm test file with a multi-line + * #define (cli, mcp, daemon_runtime, extraction, test_main.c) could never be + * narrowed. */ +TEST(test_model_only_code_continuations_are_uncertain) { + static const struct { + const char *text; + bool complete; + } cases[] = { + {"#define MULTI(x) \\\n do { x; } while (0)\n" + "TEST(a) {}\nSUITE(s) { RUN_TEST(a); }\n" + "void main(void) { RUN_SELECTED_SUITE(s); }\n", + true}, + {"#define MULTI(x) \\\r\n (x)\r\n" + "TEST(a) {}\r\nSUITE(s) { RUN_TEST(a); }\r\n" + "void main(void) { RUN_SELECTED_SUITE(s); }\r\n", + true}, + {"static const char *text = \"a\\\nb\";\n" + "TEST(a) {}\nSUITE(s) { RUN_TEST(a); }\n" + "void main(void) { RUN_SELECTED_SUITE(s); }\n", + true}, + {"TE\\\nST(a) {}\nSUITE(s) { RUN_TEST(a); }\n" + "void main(void) { RUN_SELECTED_SUITE(s); }\n", + false}, + /* a splice that joins a directive word */ + {"#def\\\nine X 1\nTEST(a) {}\nSUITE(s) { RUN_TEST(a); }\n" + "void main(void) { RUN_SELECTED_SUITE(s); }\n", + false}, + /* `*` + splice + `/` closes a block comment, which hides nothing then */ + {"/* note *\\\n/\nTEST(a) {}\n/* end */\nSUITE(s) { RUN_TEST(a); }\n" + "void main(void) { RUN_SELECTED_SUITE(s); }\n", + false}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + tm_source_t one[] = {{"tests/one.c", cases[i].text}}; + cbm_test_model_t *model = model_of(one, 1); + ASSERT_NOT_NULL(model); + if (cbm_test_model_complete(model) != cases[i].complete) { + printf(" case %zu: complete %d\n", i, (int)cbm_test_model_complete(model)); + } + ASSERT_EQ(cbm_test_model_complete(model), cases[i].complete); + if (cases[i].complete) { + int n = 0; + (void)cbm_test_model_registrations(model, &n); + ASSERT_EQ(n, 1); + } + cbm_test_model_free(model); + } + PASS(); +} + +/* The selection runs the uncertain suite whole and still narrows the others. */ +TEST(test_selection_scoped_uncertainty_runs_only_its_suite_whole) { + tm_source_t scoped[] = {{"tests/cases.c", scoped_cases}, + {"tests/macro.c", scoped_macro}, + {"tests/main.c", scoped_main}}; + cbm_test_model_t *model = model_of(scoped, 3); + static const char *rows = "alpha:*\tcomplete\t\t1\nalpha:stat\tcomplete\t\t1\n" + "beta:*\tcomplete\t\t1\nbeta:other\tcomplete\t\t0\n"; + cbm_coverage_map_t *map = selection_map(rows); + ASSERT_NOT_NULL(model); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = {{"tests/cases.c", "stat", true, true, false}}; + int changed[] = {0}; + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + input.reach_count = 1; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count = 0; + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(suites[0].name, "alpha"); + ASSERT_FALSE(suites[0].whole); + ASSERT_STR_EQ(suites[1].name, "beta"); + ASSERT_TRUE(suites[1].whole); + ASSERT_TRUE(suites[1].reasons & CBM_TEST_SELECT_INVENTORY_UNKNOWN); + ASSERT_EQ(selection_case_reasons(result, "alpha", "stat"), CBM_TEST_SELECT_STATIC); + cbm_test_selection_free(result); + cbm_coverage_map_free(map); + cbm_test_model_free(model); + PASS(); +} + +/* A changed test no suite registers runs everything; a runner suite the + * model finds no definition for runs whole (its tests are unknown). */ +/* The answer follows the selection: a model whose uncertainty is scoped to + * some suites (narrowable, not complete) carries those suites whole and + * narrows the rest. Before, the codec demanded a complete model and ran + * everything whenever any suite was uncertain (on cbm: always). */ +TEST(test_result_scoped_uncertainty_is_not_a_global_run_all) { + tm_source_t scoped[] = {{"tests/cases.c", scoped_cases}, + {"tests/macro.c", scoped_macro}, + {"tests/main.c", scoped_main}}; + cbm_test_model_t *model = model_of(scoped, 3); + static const char *rows = "alpha:*\tcomplete\t\t1\nalpha:stat\tcomplete\t\t1\n" + "beta:*\tcomplete\t\t1\nbeta:other\tcomplete\t\t0\n"; + cbm_coverage_map_t *map = selection_map(rows); + ASSERT_NOT_NULL(model); + ASSERT_NOT_NULL(map); + ASSERT_TRUE(cbm_test_model_narrowable(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + cbm_test_reach_t reach[] = {{"tests/cases.c", "stat", true, true, false}}; + int changed[] = {0}; + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + input.reach_count = 1; + cbm_test_selection_t *selection = cbm_test_select(&input); + ASSERT_NOT_NULL(selection); + char dir[512], path[600]; + snprintf(dir, sizeof(dir), "%s/cbm-impact-result-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(dir)); + snprintf(path, sizeof(path), "%s/absent.json", dir); + cbm_test_config_t *config = cbm_test_config_load(path, true); + cbm_test_policy_t *policy = config ? cbm_test_policy_new(config) : NULL; + ASSERT_NOT_NULL(policy); + cbm_test_result_receipt_t receipt = {0}; + receipt.object_format = CBM_TEST_RESULT_OBJECT_SHA1; + receipt.base.format = CBM_TEST_RESULT_OBJECT_SHA1; + receipt.head.format = CBM_TEST_RESULT_OBJECT_SHA1; + receipt.merge_base.format = CBM_TEST_RESULT_OBJECT_SHA1; + receipt.diff_sha256.present = true; + receipt.name_status_sha256.present = true; + const char *hex = cbm_test_policy_digest(policy); + ASSERT_NOT_NULL(hex); + for (size_t i = 0; i < 32; i++) { + unsigned v = 0; + ASSERT_EQ(sscanf(hex + i * 2, "%2x", &v), 1); + receipt.policy_sha256.bytes[i] = (unsigned char)v; + } + receipt.policy_sha256.present = true; + cbm_test_result_input_t in = {.comparison = CBM_TEST_RESULT_COMPARISON_CHANGED, + .selection = selection, + .model = model, + .policy = policy, + .inventory_complete = true, + .activation_complete = true, + .receipt = &receipt}; + cbm_test_result_limits_t limits = {.max_input_bytes = 1048576, + .max_items = 100000, + .max_alloc_bytes = 16777216, + .max_output_bytes = 1048576}; + cbm_test_result_t *result = NULL; + ASSERT_EQ(cbm_test_result_build(&in, &limits, NULL, NULL, &result), CBM_TEST_RESULT_OK); + size_t len = 0; + const char *json = cbm_test_result_json(result, &len); + ASSERT_NOT_NULL(json); + if (!strstr(json, "\"decision\":\"selected\"")) { + printf(" %.300s\n", json); + } + ASSERT_NOT_NULL(strstr(json, "\"decision\":\"selected\"")); + ASSERT_NOT_NULL(strstr(json, "\"run_all_reasons\":[]")); + cbm_test_result_free(result); + cbm_test_policy_free(policy); + cbm_test_config_free(config); + cbm_test_selection_free(selection); + cbm_coverage_map_free(map); + cbm_test_model_free(model); + th_rmtree(dir); + PASS(); +} + +TEST(test_selection_unregistered_changes_run_all_and_undefined_runner_suites_run_whole) { const char *sources[] = { "TEST(changed) {}\nTEST(stat) {}\nSUITE(alpha) { RUN_TEST(stat); }\n" "void main(void) { RUN_SELECTED_SUITE(alpha); }\n", @@ -1705,8 +1927,17 @@ TEST(test_selection_unregistered_changes_and_unknown_runner_membership_run_all) reach[2].changed = false; cbm_test_selection_t *result = cbm_test_select(&input); ASSERT_NOT_NULL(result); - ASSERT_TRUE(cbm_test_selection_run_all(result) & - (i ? CBM_TEST_SELECT_INVENTORY_UNKNOWN : CBM_TEST_SELECT_CHANGED_UNREGISTERED)); + if (i == 0) { + ASSERT_TRUE(cbm_test_selection_run_all(result) & CBM_TEST_SELECT_CHANGED_UNREGISTERED); + } else { + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count = 0; + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(suites[0].name, "alpha"); + ASSERT_TRUE(suites[0].whole); + ASSERT_TRUE(suites[0].reasons & CBM_TEST_SELECT_INVENTORY_UNKNOWN); + } cbm_test_selection_free(result); cbm_test_model_free(model); } @@ -1714,6 +1945,42 @@ TEST(test_selection_unregistered_changes_and_unknown_runner_membership_run_all) PASS(); } +/* A suite this runner never registers (another runner's, like the repro + * board's RUN_SUITE list, or one not built into it) is outside its universe: + * its tests are neither selected nor a reason to run everything, even when + * one of them changed. On cbm, 49 defined suites are such suites; treating + * them as unknown made every selection run everything. */ +TEST(test_selection_other_runners_suites_are_outside_the_selection) { + const char *source = "TEST(stat) {}\nTEST(elsewhere) {}\n" + "SUITE(alpha) { RUN_TEST(stat); }\n" + "SUITE(board) { RUN_TEST(elsewhere); }\n" + "void main(void) { RUN_SELECTED_SUITE(alpha); }\n"; + cbm_test_model_t *model = selection_model(source); + static const char *rows = "alpha:*\tcomplete\t\t1\nalpha:stat\tcomplete\t\t0\n"; + cbm_coverage_map_t *map = selection_map(rows); + ASSERT_NOT_NULL(model); + ASSERT_NOT_NULL(map); + cbm_test_reach_t reach[] = {{"tests/cases.c", "elsewhere", true, true, true}, + {"tests/cases.c", "stat", true, true, false}}; + int changed[] = {0}; + cbm_test_selection_input_t input = selection_input(model, map, reach, changed); + input.reach_count = 2; + cbm_test_selection_t *result = cbm_test_select(&input); + ASSERT_NOT_NULL(result); + ASSERT_EQ(cbm_test_selection_run_all(result), 0); + int count = 0; + const cbm_test_selected_suite_t *suites = cbm_test_selection_suites(result, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(suites[0].name, "alpha"); + ASSERT_FALSE(suites[0].whole); + ASSERT_EQ(selection_case_reasons(result, "alpha", "stat"), + CBM_TEST_SELECT_STATIC | CBM_TEST_SELECT_COVERAGE); + cbm_test_selection_free(result); + cbm_coverage_map_free(map); + cbm_test_model_free(model); + PASS(); +} + TEST(test_selection_invalid_evidence_cannot_produce_a_partial_result) { cbm_test_model_t *model = selection_model(selection_source); cbm_coverage_map_t *map = selection_map(selection_rows); @@ -2231,6 +2498,22 @@ TEST(test_policy_defaults_and_first_match_before_target_resolution) { PASS(); } +/* A change to the selection's own configuration runs everything, in every + * repository, before any project rule could ignore it (review M-4). */ +TEST(test_policy_config_change_runs_all) { + cbm_test_policy_t *policy = + policy_of("{\"test_impact\":{\"version\":1,\"rules\":[{\"id\":\"late\"," + "\"paths\":[\"src/**\"],\"action\":\"ignore\"}]}}"); + ASSERT_NOT_NULL(policy); + ASSERT_TRUE(policy_matches(policy, ".codebase-memory.json", "builtin:test-impact-config")); + ASSERT_TRUE(policy_matches(policy, "sub/.codebase-memory.json", "builtin:test-impact-config")); + const cbm_test_rule_t *rule = NULL; + ASSERT_TRUE(cbm_test_policy_match_path(policy, ".codebase-memory.json", &rule)); + ASSERT_EQ(rule->action, CBM_TEST_RULE_RUN_ALL); + cbm_test_policy_free(policy); + PASS(); +} + TEST(test_policy_lanes_and_semantic_perf_membership) { cbm_test_policy_t *policy = policy_of( "{\"test_impact\":{\"version\":1," @@ -2962,6 +3245,30 @@ TEST(test_model_guard_like_conditions_cannot_certify_unconditional_cases) { PASS(); } +/* `#ifndef X` + `#define X` that does not start the file is the + * define-if-undefined idiom (`WIN32_LEAN_AND_MEAN` in 23 cbm source files), + * not an include guard: an ordinary condition whose contents are conditional. + * It made those files uncertain, and every cbm registry unnarrowable. */ +TEST(test_model_define_if_undefined_is_an_ordinary_condition) { + const char *source = "#include \n" + "#ifndef LEAN\n#define LEAN\n#endif\n" + "TEST(plain) {}\n" + "#ifndef FEATURE\n#define FEATURE\nTEST(gated) {}\n#endif\n" + "SUITE(alpha) { RUN_TEST(plain); RUN_TEST(gated); }\n"; + cbm_test_model_t *model = selection_model(source); + ASSERT_NOT_NULL(model); + ASSERT_TRUE(cbm_test_model_complete(model)); + int count = 0; + const cbm_test_case_t *cases = cbm_test_model_cases(model, &count); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(cases[0].name, "plain"); + ASSERT_FALSE(cases[0].conditional); + ASSERT_STR_EQ(cases[1].name, "gated"); + ASSERT_TRUE(cases[1].conditional); + cbm_test_model_free(model); + PASS(); +} + TEST(test_model_empty_elif_is_not_certified) { const char *branches[] = {"#elif\n", "#elif /* no expression */\n", "#elif // empty\n"}; for (size_t i = 0; i < sizeof(branches) / sizeof(branches[0]); i++) { @@ -3171,6 +3478,39 @@ TEST(test_model_descriptors_argument_failures_have_owned_source_diagnostics) { PASS(); } +/* A function-like macro name that is not followed by '(' is not an invocation: + * the preprocessor leaves it alone, so it can define or register nothing. An + * array or variable that happens to carry a convention's name (cbm itself has + * `static const char *const TEST[]` in src/pipeline/pass_semantic_edges.c) + * must not make the whole registry uncertain. An opened '(' that never closes + * still does. */ +TEST(test_model_descriptors_plain_identifier_with_a_macro_name_is_no_invocation) { + cbm_test_model_t *model = descriptor_model(descriptor_config); + ASSERT_NOT_NULL(model); + const char *source = "static const char *const CASE_AT[] = {\"test\", NULL};\n" + "int USE_AT = 3;\n" + "void *p = &ENTER_AT;\n" + "OTHER_CASE(real) {}\n"; + ASSERT_TRUE( + cbm_test_model_add_source_language(model, "src/not_a_test.c", "c", source, strlen(source))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_TRUE(cbm_test_model_complete(model)); + ASSERT_EQ(cbm_test_model_mapping_status(model, NULL), CBM_TEST_MODEL_MAPPING_OK); + ASSERT_EQ(count_cases(model), 1); + cbm_test_model_free(model); + + model = descriptor_model(descriptor_config); + ASSERT_NOT_NULL(model); + const char *open = "CASE_AT(ignore,"; + ASSERT_TRUE(cbm_test_model_add_source_language(model, "tests/open.c", "c", open, strlen(open))); + ASSERT_TRUE(cbm_test_model_finish(model)); + ASSERT_FALSE(cbm_test_model_complete(model)); + ASSERT_EQ(cbm_test_model_mapping_status(model, NULL), + CBM_TEST_MODEL_MAPPING_MALFORMED_INVOCATION); + cbm_test_model_free(model); + PASS(); +} + TEST(test_model_descriptors_registration_links_stay_in_their_source_file) { cbm_test_model_t *model = descriptor_model(descriptor_config); ASSERT_NOT_NULL(model); @@ -3328,6 +3668,7 @@ SUITE(test_impact) { RUN_TEST(test_model_descriptors_keep_unrenderable_names_and_templates_explicit); RUN_TEST(test_model_descriptors_duplicates_are_idempotent_and_conflicts_never_narrow); RUN_TEST(test_model_descriptors_argument_failures_have_owned_source_diagnostics); + RUN_TEST(test_model_descriptors_plain_identifier_with_a_macro_name_is_no_invocation); RUN_TEST(test_model_descriptors_registration_links_stay_in_their_source_file); RUN_TEST(test_model_descriptors_presets_are_explicit_and_native_gaps_are_visible); RUN_TEST(test_model_descriptors_hidden_configured_macros_cannot_certify_inventory); @@ -3341,6 +3682,7 @@ SUITE(test_impact) { RUN_TEST(test_selection_cannot_override_partial_model_with_complete_input_flag); RUN_TEST(test_model_split_identifiers_cannot_hide_registrations); RUN_TEST(test_model_guard_like_conditions_cannot_certify_unconditional_cases); + RUN_TEST(test_model_define_if_undefined_is_an_ordinary_condition); RUN_TEST(test_model_empty_elif_is_not_certified); RUN_TEST(test_model_null_empty_source_is_safe); @@ -3355,6 +3697,7 @@ SUITE(test_impact) { RUN_TEST(test_policy_embedded_stars_do_not_cross_or_remove_separators); RUN_TEST(test_policy_retains_exact_snapshot_and_owns_result); RUN_TEST(test_policy_defaults_and_first_match_before_target_resolution); + RUN_TEST(test_policy_config_change_runs_all); RUN_TEST(test_policy_lanes_and_semantic_perf_membership); RUN_TEST(test_policy_globs_braces_escapes_and_directory_boundaries); RUN_TEST(test_policy_rejects_invalid_consumed_fields_without_changing_seed_loader); @@ -3374,7 +3717,12 @@ SUITE(test_impact) { RUN_TEST(test_selection_setup_and_rejected_artifacts_select_whole_suites); RUN_TEST(test_selection_incomplete_global_evidence_never_narrows); RUN_TEST(test_selection_inventory_ambiguity_and_conditionals_use_whole_suite); - RUN_TEST(test_selection_unregistered_changes_and_unknown_runner_membership_run_all); + RUN_TEST(test_model_uncertainty_is_scoped_to_the_suites_of_its_file); + RUN_TEST(test_model_only_code_continuations_are_uncertain); + RUN_TEST(test_selection_scoped_uncertainty_runs_only_its_suite_whole); + RUN_TEST(test_result_scoped_uncertainty_is_not_a_global_run_all); + RUN_TEST(test_selection_unregistered_changes_run_all_and_undefined_runner_suites_run_whole); + RUN_TEST(test_selection_other_runners_suites_are_outside_the_selection); RUN_TEST(test_selection_invalid_evidence_cannot_produce_a_partial_result); RUN_TEST(test_selection_is_deterministic_deduplicated_and_owns_strings); RUN_TEST(test_selection_proven_empty_diff_is_distinct_from_unresolved_changes); diff --git a/tests/test_test_impact_classify.c b/tests/test_test_impact_classify.c new file mode 100644 index 000000000..bda9a5632 --- /dev/null +++ b/tests/test_test_impact_classify.c @@ -0,0 +1,388 @@ +/* + * test_test_impact_classify.c — the classified ledger of a pinned HEAD + * inventory (src/mcp/test_impact_classify.c), on real git snapshots. + * + * The ledger must give every kept file the language discovery gives the same + * bytes; a read it cannot make is an error, never a default language. + */ +#include "test_test_impact_inventory_internal.h" + +#include "discover/discover.h" +#include "discover/userconfig.h" +#include "mcp/test_impact_classify.h" + +typedef struct { + const char *path; + const char *content; +} tic_file_t; + +/* A HEAD commit holding exactly `files`, its pinned tree and its inventory. */ +static bool tic_snapshot(if_native *n, const tic_file_t *files, size_t count, + cbm_test_impact_inventory_t **inventory) { + if (!ni_start(n) || !ni_reset_input(n)) { + return false; + } + for (size_t i = 0; i < count; i++) { + if (!if_text(&n->input, files[i].path, files[i].content)) { + return false; + } + } + n->input.limits.max_probe_prefix_bytes = CBM_LANGUAGE_PROBE_MAX; + return ni_commit_input(n) && ni_prepare(n, inventory); +} + +static const cbm_test_impact_classified_t *tic_row(const cbm_test_impact_ledger_t *ledger, + const if_native *n, const char *path) { + size_t count = 0; + const cbm_test_impact_classified_t *rows = cbm_test_impact_ledger_rows(ledger, &count); + for (size_t i = 0; i < count; i++) { + if (strcmp(n->input.paths[rows[i].file_index], path) == 0) { + return &rows[i]; + } + } + return NULL; +} + +/* The language discovery's rule gives the same bytes. */ +static CBMLanguage tic_expected(const char *path, const char *content) { + const char *slash = strrchr(path, '/'); + const char *name = slash ? slash + 1 : path; + size_t len = strlen(content); + size_t probe = cbm_language_probe_bytes(name); + size_t head = len < probe ? len : probe; + return cbm_language_classify(name, (const unsigned char *)content, head, len > head, true); +} + +static const tic_file_t TIC_FILES[] = { + {".gitignore", "drop.c\n"}, + {"a.m", "#import \n@interface A\n@end\n"}, + {"b.m", "% matlab\nx = 1;\n"}, + {"c.cls", "Class Demo.Person Extends %Persistent\n{\n}\n"}, + {"d.xml", "\n\n"}, + {"drop.c", "int dropped;\n"}, + {"package.json", "{}\n"}, + {"plain", "no shebang here\n"}, + {"sub/data.json", "{\"k\": 1}\n"}, + {"tool", "#!/usr/bin/env python3\nprint(1)\n"}, + {"x.go", "package x\n"}, +}; + +TEST(test_impact_classify_gives_discovery_languages) { + if_native n = {0}; + cbm_test_impact_inventory_t *inventory = NULL; + cbm_test_impact_ledger_t *ledger = NULL; + int result = 1; + size_t nfiles = sizeof(TIC_FILES) / sizeof(TIC_FILES[0]); + NI_CHECK(tic_snapshot(&n, TIC_FILES, nfiles, &inventory)); + cbm_inventory_error_t error; + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, &ledger, + &error) == CBM_INVENTORY_OK); + NI_CHECK(ledger && error.status == CBM_INVENTORY_OK); + size_t count = 0; + const cbm_test_impact_classified_t *rows = cbm_test_impact_ledger_rows(ledger, &count); + /* Every kept file once, in file-index order; the ignored one is absent. */ + NI_CHECK(count == nfiles - 1); + for (size_t i = 1; i < count; i++) { + NI_CHECK(rows[i - 1].file_index < rows[i].file_index); + } + NI_CHECK(tic_row(ledger, &n, "drop.c") == NULL); + for (size_t i = 0; i < nfiles; i++) { + if (strcmp(TIC_FILES[i].path, "drop.c") == 0) { + continue; + } + const cbm_test_impact_classified_t *row = tic_row(ledger, &n, TIC_FILES[i].path); + NI_CHECK(row != NULL); + NI_CHECK(row->language == tic_expected(TIC_FILES[i].path, TIC_FILES[i].content)); + } + /* Spot values, so a wrong shared rule cannot pass by agreeing with itself. */ + NI_CHECK(tic_row(ledger, &n, "a.m")->language == CBM_LANG_OBJC); + NI_CHECK(tic_row(ledger, &n, "b.m")->language == CBM_LANG_MATLAB); + NI_CHECK(tic_row(ledger, &n, "c.cls")->language == CBM_LANG_OBJECTSCRIPT_UDL); + NI_CHECK(tic_row(ledger, &n, "d.xml")->language == CBM_LANG_OBJECTSCRIPT_EXPORT); + NI_CHECK(tic_row(ledger, &n, "package.json")->language == CBM_LANG_COUNT); + NI_CHECK(tic_row(ledger, &n, "plain")->language == CBM_LANG_COUNT); + NI_CHECK(tic_row(ledger, &n, "sub/data.json")->language == CBM_LANG_JSON); + NI_CHECK(tic_row(ledger, &n, "tool")->language == CBM_LANG_PYTHON); + NI_CHECK(tic_row(ledger, &n, "x.go")->language == CBM_LANG_GO); + /* Only names that need content were read: a.m b.m c.cls d.xml, and the + * shebang probe for the names without a language (.gitignore plain tool). */ + cbm_test_impact_classify_usage_t usage; + NI_CHECK(cbm_test_impact_ledger_usage(ledger, &usage)); + NI_CHECK(usage.verified_file_reads == 7); + NI_CHECK(memcmp(cbm_test_impact_ledger_manifest(ledger), + cbm_test_impact_inventory_view(inventory)->manifest_sha256, 32) == 0); + result = 0; +done: + cbm_test_impact_ledger_free(ledger); + return ni_finish(&n, inventory, result); +} + +/* Reads and the probe length are charged to what the inventory's limits leave + * after the filter. Running out is a LIMIT for the whole pass, with no ledger, + * and never a default language for the files that were not read. */ +TEST(test_impact_classify_limits_are_errors_not_defaults) { + if_native n = {0}; + cbm_test_impact_inventory_t *inventory = NULL; + cbm_test_impact_ledger_t *ledger = (cbm_test_impact_ledger_t *)(uintptr_t)1; + int result = 1; + NI_CHECK(tic_snapshot(&n, TIC_FILES, sizeof(TIC_FILES) / sizeof(TIC_FILES[0]), &inventory)); + cbm_test_impact_inventory_usage_t used; + NI_CHECK(cbm_test_impact_inventory_usage(inventory, &used)); + cbm_inventory_error_t error; + + /* One read fewer than the seven probes need. The limits live in the + * inventory, so a tighter one needs a fresh inventory. */ + cbm_test_impact_inventory_free(inventory); + inventory = NULL; + n.input.limits.max_verified_file_reads = used.filter.verified_file_reads_reserved + 6; + NI_CHECK(ni_prepare(&n, &inventory)); + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, &ledger, + &error) == CBM_INVENTORY_LIMIT); + NI_CHECK(ledger == NULL && error.status == CBM_INVENTORY_LIMIT); + /* Exactly enough is fine. */ + cbm_test_impact_inventory_free(inventory); + inventory = NULL; + n.input.limits.max_verified_file_reads = used.filter.verified_file_reads_reserved + 7; + NI_CHECK(ni_prepare(&n, &inventory)); + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, &ledger, + &error) == CBM_INVENTORY_OK); + cbm_test_impact_ledger_free(ledger); + ledger = NULL; + + /* A probe longer than the probe limit: the shebang probe needs 255. */ + cbm_test_impact_inventory_free(inventory); + inventory = NULL; + n.input.limits.max_probe_prefix_bytes = 254; + NI_CHECK(ni_prepare(&n, &inventory)); + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, &ledger, + &error) == CBM_INVENTORY_LIMIT); + NI_CHECK(ledger == NULL); + + /* Arguments. */ + NI_CHECK(cbm_test_impact_classify(NULL, n.input.tree, NULL, &n.input.control, &ledger, + &error) == CBM_INVENTORY_INVALID); + NI_CHECK(cbm_test_impact_classify(inventory, NULL, NULL, &n.input.control, &ledger, &error) == + CBM_INVENTORY_INVALID); + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, NULL, + &error) == CBM_INVENTORY_INVALID); + result = 0; +done: + if (ledger != (cbm_test_impact_ledger_t *)(uintptr_t)1) { + cbm_test_impact_ledger_free(ledger); + } + return ni_finish(&n, inventory, result); +} + +/* The snapshot's language config decides, passed explicitly: the process-wide + * config another index may have installed takes no part. */ +TEST(test_impact_classify_uses_the_given_config) { + if_native n = {0}; + cbm_test_impact_inventory_t *inventory = NULL; + cbm_test_impact_ledger_t *with = NULL; + cbm_test_impact_ledger_t *without = NULL; + cbm_userconfig_t *config = NULL; + int result = 1; + static const tic_file_t files[] = {{"script.foo", "x = 1\n"}}; + NI_CHECK(tic_snapshot(&n, files, 1, &inventory)); + /* A config that maps .foo to Python, from a project file in a scratch dir. */ + char dir[512]; + snprintf(dir, sizeof(dir), "%s/cbm-classify-config-XXXXXX", cbm_tmpdir()); + NI_CHECK(cbm_mkdtemp(dir) != NULL); + char file[600]; + snprintf(file, sizeof(file), "%s/.codebase-memory.json", dir); + static const char project[] = "{\"extra_extensions\":{\".foo\":\"python\"}}"; + NI_CHECK(ni_write(&n, file, project, sizeof(project) - 1)); + config = cbm_userconfig_load(dir); + remove(file); + th_rmtree(dir); + NI_CHECK(config != NULL); + NI_CHECK(cbm_userconfig_lookup(config, ".foo") == CBM_LANG_PYTHON); + const cbm_userconfig_t *installed = cbm_get_user_lang_config(); + cbm_inventory_error_t error; + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, config, &n.input.control, &with, + &error) == CBM_INVENTORY_OK); + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, &without, + &error) == CBM_INVENTORY_OK); + NI_CHECK(cbm_get_user_lang_config() == installed); + NI_CHECK(tic_row(with, &n, "script.foo")->language == CBM_LANG_PYTHON); + NI_CHECK(tic_row(without, &n, "script.foo")->language == CBM_LANG_COUNT); + result = 0; +done: + cbm_test_impact_ledger_free(with); + cbm_test_impact_ledger_free(without); + cbm_userconfig_free(config); + return ni_finish(&n, inventory, result); +} + +/* A blob that cannot be read fails the pass: no ledger, the native error, and + * the index of the file. A default language here would index a different + * graph than the one the selection claims to use. */ +TEST(test_impact_classify_read_failure_is_an_error) { + if_native n = {0}; + cbm_test_impact_inventory_t *inventory = NULL; + cbm_test_impact_ledger_t *ledger = (cbm_test_impact_ledger_t *)(uintptr_t)1; + int result = 1; + NI_CHECK(tic_snapshot(&n, TIC_FILES, sizeof(TIC_FILES) / sizeof(TIC_FILES[0]), &inventory)); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + NI_CHECK(cbm_pinned_tree_test_set_read_fault(n.input.tree, CBM_PINNED_TREE_READ_FAULT_READ)); + cbm_inventory_error_t error; + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, &ledger, + &error) == CBM_INVENTORY_IO); + NI_CHECK(ledger == NULL && error.status == CBM_INVENTORY_IO); + /* The first survivor that needs its content is .gitignore, file 0. */ + NI_CHECK(error.file_index == 0); + /* A started failed read leaves the tree disposal-only, as for the filter. */ + NI_CHECK(!cbm_pinned_tree_view(n.input.tree)); + result = 0; +#else + fprintf(stderr, "classification read faults require canonical CBM_ENABLE_TEST_SEAMS\n"); +#endif +done: + if (ledger != (cbm_test_impact_ledger_t *)(uintptr_t)1) { + cbm_test_impact_ledger_free(ledger); + } + return ni_finish(&n, inventory, result); +} + +/* The inventory and the tree must describe the same snapshot. */ +TEST(test_impact_classify_refuses_another_snapshot) { + if_native first = {0}; + if_native second = {0}; + cbm_test_impact_inventory_t *inventory = NULL; + cbm_test_impact_inventory_t *other = NULL; + cbm_test_impact_ledger_t *ledger = (cbm_test_impact_ledger_t *)(uintptr_t)1; + int result = 1; + static const tic_file_t files[] = {{"x.go", "package x\n"}}; + NI_CHECK(tic_snapshot(&first, TIC_FILES, sizeof(TIC_FILES) / sizeof(TIC_FILES[0]), &inventory)); + NI_CHECK(tic_snapshot(&second, files, 1, &other)); + cbm_inventory_error_t error; + NI_CHECK(cbm_test_impact_classify(inventory, second.input.tree, NULL, &first.input.control, + &ledger, &error) == CBM_INVENTORY_CHANGED); + NI_CHECK(ledger == NULL); + /* Each with its own tree is fine. */ + NI_CHECK(cbm_test_impact_classify(other, second.input.tree, NULL, &second.input.control, + &ledger, &error) == CBM_INVENTORY_OK); + result = 0; +done: + if (ledger != (cbm_test_impact_ledger_t *)(uintptr_t)1) { + cbm_test_impact_ledger_free(ledger); + } + result = ni_finish(&second, other, result); + return ni_finish(&first, inventory, result); +} + +/* ── The runnable registry of the same snapshot ─────────────────── */ + +static const tic_file_t TIC_SUITE_FILES[] = { + {"README.md", "# readme\n"}, + {"src/x.c", "int x(void) { return 1; }\n"}, + {"tests/test_alpha.c", "TEST(a1) {\n PASS();\n}\nSUITE(alpha) {\n RUN_TEST(a1);\n}\n"}, + {"tests/test_main.c", "int main(void) {\n RUN_SELECTED_SUITE(alpha);\n return 0;\n}\n"}, +}; + +static const char TIC_C_CBM[] = "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":" + "{\"c-cbm\":true}}}}"; + +/* The registry reads the snapshot's own test sources under the snapshot's + * declarations: the runner suite, the case and its registration. */ +TEST(test_impact_registry_reads_the_snapshot_tests) { + if_native n = {0}; + cbm_test_impact_inventory_t *inventory = NULL; + cbm_test_impact_ledger_t *ledger = NULL; + cbm_test_declarations_t *declarations = NULL; + cbm_test_model_t *model = NULL; + int result = 1; + NI_CHECK(tic_snapshot(&n, TIC_SUITE_FILES, sizeof(TIC_SUITE_FILES) / sizeof(TIC_SUITE_FILES[0]), + &inventory)); + cbm_inventory_error_t error; + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, &ledger, + &error) == CBM_INVENTORY_OK); + declarations = cbm_test_declarations_parse(TIC_C_CBM, sizeof(TIC_C_CBM) - 1, false); + NI_CHECK(declarations != NULL); + NI_CHECK(cbm_test_impact_registry(ledger, inventory, n.input.tree, declarations, + &n.input.control, &model, &error) == CBM_INVENTORY_OK); + NI_CHECK(model && error.status == CBM_INVENTORY_OK); + NI_CHECK(cbm_test_model_complete(model)); + int count = 0; + const cbm_test_runner_suite_t *runner = cbm_test_model_runner_suites(model, &count); + NI_CHECK(count == 1 && strcmp(runner[0].name, "alpha") == 0); + const cbm_test_case_t *cases = cbm_test_model_cases(model, &count); + NI_CHECK(count == 1 && strcmp(cases[0].name, "a1") == 0 && + strcmp(cases[0].file, "tests/test_alpha.c") == 0); + const cbm_test_registration_t *regs = cbm_test_model_registrations(model, &count); + NI_CHECK(count == 1 && strcmp(regs[0].suite, "alpha") == 0 && strcmp(regs[0].test, "a1") == 0 && + regs[0].resolved); + result = 0; +done: + cbm_test_model_free(model); + cbm_test_declarations_free(declarations); + cbm_test_impact_ledger_free(ledger); + return ni_finish(&n, inventory, result); +} + +/* Without conventions the registry names no test at all, and a registry that + * cannot read every source it needs is an error, never a smaller registry. */ +TEST(test_impact_registry_names_nothing_it_cannot_read) { + if_native n = {0}; + cbm_test_impact_inventory_t *inventory = NULL; + cbm_test_impact_ledger_t *ledger = NULL; + cbm_test_declarations_t *absent = NULL; + cbm_test_declarations_t *c_cbm = NULL; + cbm_test_model_t *model = NULL; + int result = 1; + size_t nfiles = sizeof(TIC_SUITE_FILES) / sizeof(TIC_SUITE_FILES[0]); + NI_CHECK(tic_snapshot(&n, TIC_SUITE_FILES, nfiles, &inventory)); + cbm_inventory_error_t error; + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, &ledger, + &error) == CBM_INVENTORY_OK); + absent = cbm_test_declarations_parse(NULL, 0, true); + NI_CHECK(absent != NULL); + NI_CHECK(cbm_test_impact_registry(ledger, inventory, n.input.tree, absent, &n.input.control, + &model, &error) == CBM_INVENTORY_OK); + int count = -1; + (void)cbm_test_model_cases(model, &count); + NI_CHECK(count == 0); + cbm_test_model_free(model); + model = NULL; + + /* Three C sources to read; the reads the filter and the ledger left over + * are cut to two. The limits live in the inventory, so a fresh one. */ + cbm_test_impact_classify_usage_t spent; + NI_CHECK(cbm_test_impact_ledger_usage(ledger, &spent)); + cbm_test_impact_inventory_usage_t used; + NI_CHECK(cbm_test_impact_inventory_usage(inventory, &used)); + cbm_test_impact_ledger_free(ledger); + ledger = NULL; + cbm_test_impact_inventory_free(inventory); + inventory = NULL; + n.input.limits.max_verified_file_reads = + used.filter.verified_file_reads_reserved + spent.verified_file_reads + 2; + NI_CHECK(ni_prepare(&n, &inventory)); + NI_CHECK(cbm_test_impact_classify(inventory, n.input.tree, NULL, &n.input.control, &ledger, + &error) == CBM_INVENTORY_OK); + c_cbm = cbm_test_declarations_parse(TIC_C_CBM, sizeof(TIC_C_CBM) - 1, false); + NI_CHECK(c_cbm != NULL); + model = (cbm_test_model_t *)(uintptr_t)1; + NI_CHECK(cbm_test_impact_registry(ledger, inventory, n.input.tree, c_cbm, &n.input.control, + &model, &error) == CBM_INVENTORY_LIMIT); + NI_CHECK(model == NULL && error.status == CBM_INVENTORY_LIMIT); + result = 0; +done: + if (model != (cbm_test_model_t *)(uintptr_t)1) { + cbm_test_model_free(model); + } + cbm_test_declarations_free(absent); + cbm_test_declarations_free(c_cbm); + cbm_test_impact_ledger_free(ledger); + return ni_finish(&n, inventory, result); +} + +SUITE(test_impact_classify) { + RUN_TEST(test_impact_registry_reads_the_snapshot_tests); + RUN_TEST(test_impact_registry_names_nothing_it_cannot_read); + RUN_TEST(test_impact_classify_gives_discovery_languages); + RUN_TEST(test_impact_classify_limits_are_errors_not_defaults); + RUN_TEST(test_impact_classify_uses_the_given_config); + RUN_TEST(test_impact_classify_read_failure_is_an_error); + RUN_TEST(test_impact_classify_refuses_another_snapshot); +} diff --git a/tests/test_test_impact_engine.c b/tests/test_test_impact_engine.c new file mode 100644 index 000000000..8dbaa2d14 --- /dev/null +++ b/tests/test_test_impact_engine.c @@ -0,0 +1,305 @@ +/* + * test_test_impact_engine.c — detect_changes scope:"tests" end to end + * (src/mcp/test_impact_engine*.c) on small real git repositories: a base + * commit, a change, and the answer for it. + * + * Without an admitted coverage map every runner suite runs whole by design; + * what the static walk found shows in each suite's reasons ("STATIC" for a + * reached suite, "CHANGED" for a changed one). + */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "cli/cli.h" +#include "foundation/compat.h" +#include "foundation/platform.h" +#include "foundation/subprocess.h" +#include "mcp/test_impact_engine.h" + +#include +#include +#include + +typedef struct { + char git[1024]; + char home[1024]; + char repo[1100]; + char work[1100]; + char log[1100]; +} tie_fixture_t; + +static bool tie_git(tie_fixture_t *fx, const char *const *tail) { + const char *argv[32] = {fx->git, + "-C", + fx->repo, + "-c", + "commit.gpgSign=false", + "-c", + "core.autocrlf=false", + "-c", + "user.name=t", + "-c", + "user.email=t@example.invalid"}; + size_t n = 11; + for (size_t i = 0; tail[i] && n + 1 < sizeof(argv) / sizeof(argv[0]); i++) { + argv[n++] = tail[i]; + } + argv[n] = NULL; + cbm_proc_opts_t opts = {.bin = fx->git, + .argv = argv, + .log_file = fx->log, + .strip_git_repo_env = true, + .quiet_timeout_ms = 20000}; + cbm_proc_result_t result = {0}; + return cbm_subprocess_run(&opts, &result) == 0 && result.outcome == CBM_PROC_CLEAN && + result.exit_code == 0; +} + +static bool tie_write(tie_fixture_t *fx, const char *rel, const char *content) { + return th_write_file(TH_PATH(fx->repo, rel), content) == 0; +} + +static bool tie_commit(tie_fixture_t *fx, const char *message) { + const char *add[] = {"add", "-A", NULL}; + const char *commit[] = {"commit", "--quiet", "-m", message, NULL}; + return tie_git(fx, add) && tie_git(fx, commit); +} + +static const char tie_config[] = + "{\"test_impact\":{\"version\":1,\"tests\":{\"presets\":{\"c-cbm\":true}}," + "\"rules\":[{\"id\":\"build\",\"paths\":[\"Makefile\"],\"action\":\"run_all\"}]}}\n"; + +static const char tie_main[] = "#include \"test_framework.h\"\n" + "int main(int argc, char **argv) {\n" + " RUN_SELECTED_SUITE(alpha);\n" + " RUN_SELECTED_SUITE(beta);\n" + " RUN_SELECTED_SUITE(matrix);\n" + " return 0;\n" + "}\n"; + +static const char tie_alpha[] = "int lib_value(void);\n" + "TEST(alpha_uses_lib) {\n" + " return lib_value() == 1 ? 0 : 1;\n" + "}\n" + "TEST(alpha_plain) {\n" + " return 0;\n" + "}\n" + "SUITE(alpha) {\n" + " RUN_TEST(alpha_uses_lib);\n" + " RUN_TEST(alpha_plain);\n" + "}\n"; + +static const char tie_beta[] = "TEST(beta_alone) {\n" + " return 0;\n" + "}\n" + "SUITE(beta) {\n" + " RUN_TEST(beta_alone);\n" + "}\n"; + +/* Tests registered by a macro, as the cbm repro matrices do. */ +static const char tie_matrix[] = "int lib_other(void);\n" + "TEST(matrix_one) {\n" + " return lib_other() == 2 ? 0 : 1;\n" + "}\n" + "#define MATRIX_CASES(X) X(matrix_one)\n" + "#define RUN_ONE(n) RUN_TEST(n);\n" + "SUITE(matrix) {\n" + " MATRIX_CASES(RUN_ONE)\n" + "}\n"; + +static const char tie_lib[] = "int lib_value(void) {\n" + " return 1;\n" + "}\n" + "int lib_other(void) {\n" + " return 2;\n" + "}\n"; + +static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char *extra); + +static bool tie_open(tie_fixture_t *fx) { + return tie_open_with(fx, NULL, NULL); +} + +/* The fixture, plus one more file in the base commit. */ +static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char *extra) { + memset(fx, 0, sizeof(*fx)); + const char *git = cbm_find_cli("git", cbm_get_home_dir()); + const char *home = th_mktempdir("cbm-ti-engine"); + char real[1024]; + if (!git || !home || !realpath(home, real) || strlen(git) >= sizeof(fx->git)) { + return false; + } + snprintf(fx->git, sizeof(fx->git), "%s", git); + snprintf(fx->home, sizeof(fx->home), "%s", real); + snprintf(fx->repo, sizeof(fx->repo), "%s/repo", real); + snprintf(fx->work, sizeof(fx->work), "%s/work", real); + snprintf(fx->log, sizeof(fx->log), "%s/git.log", real); + const char *init[] = {"-c", "init.templateDir=", "init", + "--quiet", "--initial-branch=main", NULL}; + const char *topic[] = {"checkout", "--quiet", "-b", "topic", NULL}; + return th_mkdir_p(fx->repo) == 0 && th_mkdir_p(fx->work) == 0 && chmod(fx->work, 0700) == 0 && + tie_git(fx, init) && tie_write(fx, ".codebase-memory.json", tie_config) && + tie_write(fx, "tests/test_main.c", tie_main) && + tie_write(fx, "tests/test_alpha.c", tie_alpha) && + tie_write(fx, "tests/test_beta.c", tie_beta) && + tie_write(fx, "tests/test_matrix.c", tie_matrix) && + tie_write(fx, "src/lib.c", tie_lib) && tie_write(fx, "Makefile", "all:\n") && + (!extra_path || tie_write(fx, extra_path, extra)) && tie_commit(fx, "base") && + tie_git(fx, topic); +} + +static void tie_close(tie_fixture_t *fx) { + (void)th_rmtree(fx->home); +} + +/* The answer for the topic branch against main, as JSON (owned). */ +static char *tie_answer(tie_fixture_t *fx) { + cbm_test_impact_request_t rq = {.repo_root = fx->repo, + .base_ref = "main", + .work_parent = fx->work, + .deadline_ms = cbm_now_ms() + 120000U}; + cbm_test_result_t *out = NULL; + char diagnostic[512]; + if (cbm_test_impact_run(&rq, &out, diagnostic, sizeof(diagnostic)) != CBM_TEST_IMPACT_OK) { + printf(" engine: %s\n", diagnostic); + return NULL; + } + if (diagnostic[0]) { + printf(" engine note: %s\n", diagnostic); + } + size_t len = 0; + const char *json = cbm_test_result_json(out, &len); + char *copy = json ? strdup(json) : NULL; + cbm_test_result_free(out); + return copy; +} + +/* The "reasons" array of one suite's entry in the answer. */ +static bool tie_suite_has(const char *json, const char *suite, const char *reason) { + char key[128]; + snprintf(key, sizeof(key), "\"suite\":\"%s\"", suite); + const char *at = strstr(json, key); + if (!at) { + return false; + } + const char *end = strstr(at, "\"suite\":\""); + end = end && end != at ? end : strstr(at + strlen(key), "\"suite\":\""); + char want[128]; + snprintf(want, sizeof(want), "\"%s\"", reason); + const char *hit = strstr(at, want); + return hit && (!end || hit < end); +} + +/* A change the walk follows to one test's suite marks that suite reached; + * a suite it does not reach is not. */ +TEST(test_impact_engine_reached_suites_carry_static) { + tie_fixture_t fx; + ASSERT_TRUE(tie_open(&fx)); + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1 + 0;\n}\n" + "int lib_other(void) {\n return 2;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_value")); + char *json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + if (!tie_suite_has(json, "alpha", "STATIC")) { + printf(" %.600s\n", json); + } + ASSERT_TRUE(tie_suite_has(json, "alpha", "STATIC")); + ASSERT_FALSE(tie_suite_has(json, "beta", "STATIC")); + ASSERT_FALSE(tie_suite_has(json, "matrix", "STATIC")); + free(json); + tie_close(&fx); + PASS(); +} + +/* A test a macro registers has no explicit registration: reaching it must + * still select its suite (whole), as the measured reference does. */ +TEST(test_impact_engine_macro_registered_tests_select_their_suite) { + tie_fixture_t fx; + ASSERT_TRUE(tie_open(&fx)); + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1;\n}\n" + "int lib_other(void) {\n return 2 + 0;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_other")); + char *json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + if (!tie_suite_has(json, "matrix", "STATIC")) { + printf(" %.900s\n", json); + } + ASSERT_TRUE(tie_suite_has(json, "matrix", "STATIC")); + ASSERT_FALSE(tie_suite_has(json, "alpha", "STATIC")); + free(json); + tie_close(&fx); + PASS(); +} + +/* A path a run-all rule matches runs everything; so does a change the + * test-runner lane cannot read (M-10); an empty change selects nothing. */ +TEST(test_impact_engine_rules_unmapped_and_empty) { + tie_fixture_t fx; + ASSERT_TRUE(tie_open(&fx)); + char *json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + ASSERT_NOT_NULL(strstr(json, "\"decision\":\"nothing\"")); + free(json); + + ASSERT_TRUE(tie_write(&fx, "Makefile", "all:\n\techo\n")); + ASSERT_TRUE(tie_commit(&fx, "build change")); + json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + ASSERT_NOT_NULL(strstr(json, "\"decision\":\"run_all\"")); + ASSERT_NOT_NULL(strstr(json, "RULE_RUN_ALL")); + free(json); + + const char *reset[] = {"reset", "--quiet", "--hard", "main", NULL}; + ASSERT_TRUE(tie_git(&fx, reset)); + ASSERT_TRUE(tie_write(&fx, "data/table.csv", "a,b\n1,2\n")); + ASSERT_TRUE(tie_commit(&fx, "runtime data")); + json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + ASSERT_NOT_NULL(strstr(json, "UNMAPPED_FILE")); + free(json); + tie_close(&fx); + PASS(); +} + +/* A parse gap hides the edges written in its lines: a helper whose broken + * line calls the changed function has no CALLS edge to it. The gap's text + * still names the function, so the helper is reached, and with it the test + * that calls the helper (per-file PARSE_GAP rule, user decision 2026-10-04). */ +TEST(test_impact_engine_parse_gaps_reach_what_they_name) { + static const char gapped[] = "int lib_value(void);\n" + "static int helper(void) {\n" + " int x = 0;\n" + " x = (lib_value() ;\n" + " return x;\n" + "}\n" + "TEST(gap_case) {\n" + " return helper();\n" + "}\n" + "SUITE(beta) {\n" + " RUN_TEST(gap_case);\n" + "}\n"; + tie_fixture_t fx; + ASSERT_TRUE(tie_open_with(&fx, "tests/test_beta.c", gapped)); + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1 + 0;\n}\n" + "int lib_other(void) {\n return 2;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_value")); + char *json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + if (!tie_suite_has(json, "beta", "STATIC")) { + printf(" %.900s\n", json); + } + ASSERT_TRUE(tie_suite_has(json, "beta", "STATIC")); + free(json); + tie_close(&fx); + PASS(); +} + +SUITE(test_impact_engine) { + RUN_TEST(test_impact_engine_reached_suites_carry_static); + RUN_TEST(test_impact_engine_macro_registered_tests_select_their_suite); + RUN_TEST(test_impact_engine_rules_unmapped_and_empty); + RUN_TEST(test_impact_engine_parse_gaps_reach_what_they_name); +} diff --git a/tests/test_test_impact_inventory_internal.h b/tests/test_test_impact_inventory_internal.h index dac0055a4..d1f85d20a 100644 --- a/tests/test_test_impact_inventory_internal.h +++ b/tests/test_test_impact_inventory_internal.h @@ -15,6 +15,8 @@ bool ni_write(if_native *n, const char *path, const void *bytes, size_t size); bool ni_ambient_change(if_native *n); bool ni_replace_snapshot(if_native *n, bool empty_controls); +bool ni_reset_input(if_native *n); +bool ni_commit_input(if_native *n); bool ni_tree(if_native *n, cbm_git_revision_t revision); bool ni_dependency(if_native *n, cbm_git_revision_t revision); bool ni_start(if_native *n); diff --git a/tests/test_test_impact_inventory_support.c b/tests/test_test_impact_inventory_support.c index 88924cbed..f77579290 100644 --- a/tests/test_test_impact_inventory_support.c +++ b/tests/test_test_impact_inventory_support.c @@ -128,16 +128,28 @@ bool ni_start(if_native *n) { return if_native_start(n) && ni_dependency(n, CBM_GIT_REV_HEAD); } -bool ni_replace_snapshot(if_native *n, bool empty_controls) { +bool ni_reset_input(if_native *n) { if (!if_native_close(n)) return false; cbm_git_facts_free(n->facts); n->facts = NULL; if_init(&n->input); + return true; +} + +bool ni_replace_snapshot(if_native *n, bool empty_controls) { + if (!ni_reset_input(n)) + return false; if (empty_controls && (!if_text(&n->input, ".cbmignore", "") || !if_text(&n->input, ".gitignore", "") || !if_text(&n->input, "source.c", "int source;\n"))) return false; + return ni_commit_input(n); +} + +/* Commit exactly n->input's files as HEAD, then open its facts, pinned tree + * and dependency. Mode 100755 for the third file, as the default fixture. */ +bool ni_commit_input(if_native *n) { const char *clear[] = {"read-tree", "--empty", NULL}; if (!if_native_git(n, clear, NULL, 0)) return false; diff --git a/tests/test_test_impact_seed.c b/tests/test_test_impact_seed.c new file mode 100644 index 000000000..97dd62b73 --- /dev/null +++ b/tests/test_test_impact_seed.c @@ -0,0 +1,295 @@ +/* + * test_test_impact_seed.c — what a change seeds (src/mcp/test_impact_seed.c), + * one test per rule of the measured reference (replay4.py seeding and header + * symbol propagation), on a small in-memory graph with its sources. + */ +#include "test_framework.h" + +#include "mcp/test_impact.h" +#include "mcp/test_impact_seed.h" +#include "mcp/test_impact_source.h" +#include "store/store.h" + +#include +#include + +typedef struct { + cbm_store_t *store; + cbm_ti_graph_t *graph; + cbm_ti_source_t *source; + cbm_test_model_t *model; +} tsd_fixture_t; + +static int64_t tsd_node(tsd_fixture_t *f, const char *label, const char *name, const char *file, + int start, int end) { + char qn[256]; + snprintf(qn, sizeof(qn), "p.%s.%s.%d", file, name, start); + cbm_node_t node = {.project = "p", + .label = label, + .name = name, + .qualified_name = qn, + .file_path = file, + .start_line = start, + .end_line = end}; + return cbm_store_upsert_node(f->store, &node); +} + +/* Sources: pairs of (path, text). Model: the test sources among them. */ +static bool tsd_open(tsd_fixture_t *f, const char *const *files, int count) { + memset(f, 0, sizeof(*f)); + f->store = cbm_store_open_memory(); + if (!f->store || cbm_store_upsert_project(f->store, "p", "/tmp/p") != CBM_STORE_OK) { + return false; + } + f->graph = cbm_ti_graph_new(f->store, "p"); + f->source = cbm_ti_source_new(); + f->model = cbm_test_model_new(cbm_test_conventions_cbm()); + if (!f->graph || !f->source || !f->model) { + return false; + } + for (int i = 0; i < count; i += 2) { + size_t len = strlen(files[i + 1]); + if (!cbm_ti_source_add(f->source, files[i], files[i + 1], len)) { + return false; + } + if (strncmp(files[i], "tests/", 6) == 0 && + !cbm_test_model_add_source(f->model, files[i], files[i + 1], len)) { + return false; + } + } + return cbm_ti_source_finish(f->source) && cbm_test_model_finish(f->model); +} + +static void tsd_close(tsd_fixture_t *f) { + cbm_test_model_free(f->model); + cbm_ti_source_free(f->source); + cbm_ti_graph_free(f->graph); + if (f->store) { + cbm_store_close(f->store); + } +} + +static cbm_ti_seeds_t *tsd_seed(tsd_fixture_t *f, const cbm_ti_change_t *changes, int count) { + cbm_ti_seed_input_t in = {.changes = changes, + .change_count = count, + .model = f->model, + .graph = f->graph, + .source = f->source}; + cbm_ti_seeds_t *out = NULL; + return cbm_ti_seed(&in, &out) == CBM_TI_SEED_OK ? out : NULL; +} + +static bool tsd_seeded(const cbm_ti_seeds_t *s, int64_t id) { + int count = 0; + const int64_t *ids = cbm_ti_seeds_nodes(s, &count); + for (int i = 0; i < count; i++) { + if (ids[i] == id) { + return true; + } + } + return false; +} + +static int tsd_seed_count(const cbm_ti_seeds_t *s) { + int count = 0; + (void)cbm_ti_seeds_nodes(s, &count); + return count; +} + +/* A hunk at head seeds the definitions it overlaps; a comment-only hunk seeds + * nothing; a pure deletion seeds the definition around it; a hunk outside + * every definition seeds its whole file. */ +TEST(test_impact_seed_hunks_follow_the_reference) { + const char *files[] = {"src/a.c", "int g;\nint f(void) {\n return g;\n}\n" + "int h(void) {\n return 1;\n}\n"}; + tsd_fixture_t f; + ASSERT_TRUE(tsd_open(&f, files, 2)); + int64_t g = tsd_node(&f, "Variable", "g", "src/a.c", 1, 1); + int64_t fn = tsd_node(&f, "Function", "f", "src/a.c", 2, 4); + int64_t h = tsd_node(&f, "Function", "h", "src/a.c", 5, 7); + (void)tsd_node(&f, "Module", "a", "src/a.c", 1, 7); + + static const char *const body[] = {" return g + 1;"}; + static const char *const old_body[] = {" return g;"}; + cbm_diff_hunk_t edit = {.start = 3, + .count = 1, + .added = body, + .added_count = 1, + .removed = old_body, + .removed_count = 1}; + cbm_ti_change_t change = {.path = "src/a.c", .hunks = &edit, .hunk_count = 1}; + cbm_ti_seeds_t *s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 1); + ASSERT_TRUE(tsd_seeded(s, fn)); + cbm_ti_seeds_free(s); + + static const char *const comment[] = {" // why"}; + cbm_diff_hunk_t note = {.start = 6, .count = 1, .added = comment, .added_count = 1}; + change.hunks = ¬e; + s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 0); + cbm_ti_seeds_free(s); + + static const char *const removed[] = {" h_extra();"}; + cbm_diff_hunk_t cut = {.start = 5, .count = 0, .removed = removed, .removed_count = 1}; + change.hunks = &cut; + s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 1); + ASSERT_TRUE(tsd_seeded(s, h)); + cbm_ti_seeds_free(s); + + static const char *const include[] = {"#include \"b.h\""}; + cbm_diff_hunk_t top = {.start = 8, .count = 1, .added = include, .added_count = 1}; + change.hunks = ⊤ + s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 3); /* g, f, h: the Module is no definition */ + ASSERT_TRUE(tsd_seeded(s, g) && tsd_seeded(s, fn) && tsd_seeded(s, h)); + cbm_ti_seeds_free(s); + tsd_close(&f); + PASS(); +} + +/* A changed test case or suite body is reported, never seeded. */ +TEST(test_impact_seed_changed_cases_and_suites_are_reported) { + const char *files[] = {"tests/t.c", "TEST(alpha) {\n PASS();\n}\n" + "SUITE(s) {\n RUN_TEST(alpha);\n}\n"}; + tsd_fixture_t f; + ASSERT_TRUE(tsd_open(&f, files, 2)); + (void)tsd_node(&f, "Function", "alpha", "tests/t.c", 1, 3); + (void)tsd_node(&f, "Function", "s", "tests/t.c", 4, 6); + static const char *const line[] = {" ASSERT_TRUE(1);"}; + cbm_diff_hunk_t hunks[] = {{.start = 2, .count = 1, .added = line, .added_count = 1}, + {.start = 5, .count = 1, .added = line, .added_count = 1}}; + cbm_ti_change_t change = {.path = "tests/t.c", .hunks = hunks, .hunk_count = 2}; + cbm_ti_seeds_t *s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 0); + int count = 0; + const int *cases = cbm_ti_seeds_changed_cases(s, &count); + ASSERT_EQ(count, 1); + ASSERT_EQ(cases[0], 0); + const int *suites = cbm_ti_seeds_changed_suites(s, &count); + ASSERT_EQ(count, 1); + ASSERT_EQ(suites[0], 0); + cbm_ti_seeds_free(s); + tsd_close(&f); + PASS(); +} + +/* A name a removed line declared that head no longer defines: every + * definition mentioning it is a seed, a mention at file scope of a source + * file seeds that file whole, and one in a header outside a definition adds + * nothing. A deleted file contributes every name it declared. */ +TEST(test_impact_seed_deleted_names_are_found_by_text) { + const char *files[] = { + "src/a.c", "int user(void) {\n return gone();\n}\n", + "src/b.c", "static void *table[] = {gone};\nint b(void) { return 0; }\n", + "inc/x.h", "int gone(void);\n", + "src/old.c", "int unrelated(void) { return 1; }\n"}; + tsd_fixture_t f; + ASSERT_TRUE(tsd_open(&f, files, 8)); + int64_t user = tsd_node(&f, "Function", "user", "src/a.c", 1, 3); + int64_t b = tsd_node(&f, "Function", "b", "src/b.c", 2, 2); + (void)tsd_node(&f, "Function", "unrelated", "src/old.c", 1, 1); + static const char *const removed[] = {"int gone(void) {", " return 0;", "}"}; + cbm_diff_hunk_t cut = {.start = 1, .count = 0, .removed = removed, .removed_count = 3}; + cbm_ti_change_t change = {.path = "src/old.c", .hunks = &cut, .hunk_count = 1}; + cbm_ti_seeds_t *s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + int count = 0; + const char *const *names = cbm_ti_seeds_deleted_names(s, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(names[0], "gone"); + ASSERT_TRUE(tsd_seeded(s, user)); /* mentions gone inside a definition */ + ASSERT_TRUE(tsd_seeded(s, b)); /* b.c mentions it at file scope: whole file */ + cbm_ti_seeds_free(s); + + cbm_ti_change_t deleted = {.path = "src/gone.c", + .deleted = true, + .base_text = "int gone(void) {\n return 0;\n}\n", + .base_len = strlen("int gone(void) {\n return 0;\n}\n")}; + s = tsd_seed(&f, &deleted, 1); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(tsd_seeded(s, user)); + cbm_ti_seeds_free(s); + tsd_close(&f); + PASS(); +} + +/* A header change names what it changes, closes the names over the headers' + * macros, and seeds what mentions them in files that include the header. */ +TEST(test_impact_seed_header_names_propagate_through_includes_and_macros) { + const char *files[] = {"inc/h.h", "#define LIMIT 4\n#define TWICE (LIMIT * 2)\n", + "src/a.c", "#include \"../inc/h.h\"\nint a(void) { return LIMIT; }\n", + "src/c.c", "#include \"inc/h.h\"\nint c(void) { return TWICE; }\n", + "src/far.c", "int far(void) { return LIMIT; }\n"}; + tsd_fixture_t f; + ASSERT_TRUE(tsd_open(&f, files, 8)); + int64_t limit = tsd_node(&f, "Macro", "LIMIT", "inc/h.h", 1, 1); + int64_t twice = tsd_node(&f, "Macro", "TWICE", "inc/h.h", 2, 2); + int64_t a = tsd_node(&f, "Function", "a", "src/a.c", 2, 2); + int64_t c = tsd_node(&f, "Function", "c", "src/c.c", 2, 2); + int64_t far = tsd_node(&f, "Function", "far", "src/far.c", 1, 1); + static const char *const added[] = {"#define LIMIT 8"}; + static const char *const removed[] = {"#define LIMIT 4"}; + cbm_diff_hunk_t edit = {.start = 1, + .count = 1, + .added = added, + .added_count = 1, + .removed = removed, + .removed_count = 1}; + cbm_ti_change_t change = {.path = "inc/h.h", .hunks = &edit, .hunk_count = 1}; + cbm_ti_seeds_t *s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(tsd_seeded(s, limit)); + ASSERT_TRUE(tsd_seeded(s, twice)); /* the macro whose body names LIMIT */ + ASSERT_TRUE(tsd_seeded(s, a)); /* includes h.h, mentions LIMIT */ + ASSERT_TRUE(tsd_seeded(s, c)); /* includes h.h, mentions TWICE */ + ASSERT_FALSE(tsd_seeded(s, far)); /* mentions LIMIT, never includes h.h */ + int count = 0; + (void)cbm_ti_seeds_file_level_headers(s, &count); + ASSERT_EQ(count, 0); + cbm_ti_seeds_free(s); + tsd_close(&f); + PASS(); +} + +/* A preprocessor conditional, or a line the patterns cannot classify outside + * any definition, makes the header file level: no seeds, an escalation. */ +TEST(test_impact_seed_unclassified_header_lines_escalate_the_file) { + const char *files[] = {"inc/h.h", "#define LIMIT 4\n\n"}; + tsd_fixture_t f; + ASSERT_TRUE(tsd_open(&f, files, 2)); + (void)tsd_node(&f, "Macro", "LIMIT", "inc/h.h", 1, 1); + static const char *const conditional[] = {"#ifdef FEATURE"}; + static const char *const odd[] = {" LIMIT_PLUS_ONE,"}; + cbm_diff_hunk_t cases[] = { + {.start = 2, .count = 1, .added = conditional, .added_count = 1}, + {.start = 2, .count = 1, .added = odd, .added_count = 1}, + }; + for (int i = 0; i < 2; i++) { + cbm_ti_change_t change = {.path = "inc/h.h", .hunks = &cases[i], .hunk_count = 1}; + cbm_ti_seeds_t *s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + int count = 0; + const char *const *headers = cbm_ti_seeds_file_level_headers(s, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(headers[0], "inc/h.h"); + ASSERT_EQ(tsd_seed_count(s), 0); + cbm_ti_seeds_free(s); + } + tsd_close(&f); + PASS(); +} + +SUITE(test_impact_seed) { + RUN_TEST(test_impact_seed_hunks_follow_the_reference); + RUN_TEST(test_impact_seed_changed_cases_and_suites_are_reported); + RUN_TEST(test_impact_seed_deleted_names_are_found_by_text); + RUN_TEST(test_impact_seed_header_names_propagate_through_includes_and_macros); + RUN_TEST(test_impact_seed_unclassified_header_lines_escalate_the_file); +} diff --git a/tests/test_test_impact_source.c b/tests/test_test_impact_source.c new file mode 100644 index 000000000..e76cd0d38 --- /dev/null +++ b/tests/test_test_impact_source.c @@ -0,0 +1,325 @@ +/* + * test_test_impact_source.c — text facts about a snapshot's C-family sources + * (src/mcp/test_impact_source.c). + * + * The line patterns are a hand port of the regular expressions of the + * measured reference (replay4.py). The expected values in the table below are + * what those expressions return for each line (computed with Python's `re`), + * quirks included: a port that disagrees seeds differently from what was + * measured. + */ +#include "test_framework.h" + +#include "mcp/test_impact_source.h" + +#include +#include + +typedef struct { + const char *line; + bool comment; + bool pp; + const char *define_name; + const char *typedef_name; + const char *tag_name; + const char *extern_name; + const char *proto_name; + const char *include_path; +} tis_line_t; + +static const tis_line_t tis_lines[] = { + {"", true, false, "", "", "", "", "", ""}, + {" ", true, false, "", "", "", "", "", ""}, + {"// x", true, false, "", "", "", "", "", ""}, + {" /* x", true, false, "", "", "", "", "", ""}, + {" * x", true, false, "", "", "", "", "", ""}, + {"int x; /* c */", true, false, "", "", "", "", "", ""}, + {"int x;", false, false, "", "", "", "", "", ""}, + {"x */ ", true, false, "", "", "", "", "", ""}, + {"*/", true, false, "", "", "", "", "", ""}, + {"a*/b", false, false, "", "", "", "", "", ""}, + {"\t\r", true, false, "", "", "", "", "", ""}, + {"#if X", false, true, "", "", "", "", "", ""}, + {"#ifdef X", false, true, "", "", "", "", "", ""}, + {" # ifndef X", false, true, "", "", "", "", "", ""}, + {"#elif", false, true, "", "", "", "", "", ""}, + {"#else", false, true, "", "", "", "", "", ""}, + {"#endif", false, true, "", "", "", "", "", ""}, + {"#undef X", false, true, "", "", "", "", "", ""}, + {"#include ", false, true, "", "", "", "", "", ""}, + {"#include\"x\"", false, true, "", "", "", "", "", ""}, + {"#define X", false, false, "X", "", "", "", "", ""}, + {"#iffy", false, false, "", "", "", "", "", ""}, + {"#if(x)", false, true, "", "", "", "", "", ""}, + {"#pragma once", false, false, "", "", "", "", "", ""}, + {"#includes", false, false, "", "", "", "", "", ""}, + {"#define X 1", false, false, "X", "", "", "", "", ""}, + {" # define F(a, b) a##b", false, false, "F", "", "", "", "", ""}, + {"#define", false, false, "", "", "", "", "", ""}, + {"#definex", false, false, "", "", "", "", "", ""}, + {"#define(X)", false, false, "", "", "", "", "", ""}, + {"#define 1X", false, false, "", "", "", "", "", ""}, + {"#define\tX", false, false, "X", "", "", "", "", ""}, + {"#define F(x) f(x)", false, false, "F", "", "", "", "", ""}, + {"typedef int myint;", false, false, "", "myint", "", "", "", ""}, + {"typedef struct foo bar;", false, false, "", "bar", "", "", "", ""}, + {"typedef int arr[3][4];", false, false, "", "", "", "", "", ""}, + {"typedef int arr[3];", false, false, "", "arr", "", "", "", ""}, + {"typedef void (*fn)(int);", false, false, "", "", "", "", "void", ""}, + {"x typedef y;", false, false, "", "y", "", "", "", ""}, + {"typedefs x;", false, false, "", "", "", "", "", ""}, + {"typedef int a, b;", false, false, "", "b", "", "", "", ""}, + {"int typedef_x;", false, false, "", "", "", "", "", ""}, + {"typedef int x ;", false, false, "", "x", "", "", "", ""}, + {"typedef struct { int a; } s_t;", false, false, "", "a", "", "", "", ""}, + {"typedef unsigned long ulong_t [ 8 ] ;", false, false, "", "ulong_t", "", "", "", ""}, + {"struct foo {", false, false, "", "", "foo", "", "", ""}, + {"typedef struct foo {", false, false, "", "", "foo", "", "", ""}, + {" union u{", false, false, "", "", "u", "", "", ""}, + {"enum e {", false, false, "", "", "e", "", "", ""}, + {"struct foo;", false, false, "", "", "", "", "", ""}, + {"structfoo {", false, false, "", "", "", "", "", ""}, + {"struct {", false, false, "", "", "", "", "", ""}, + {"typedef enum color {", false, false, "", "", "color", "", "", ""}, + {"extern int x;", false, false, "", "", "", "x", "", ""}, + {"extern const char *names[];", false, false, "", "", "", "names", "", ""}, + {"extern int f(void);", false, false, "", "", "", "", "f", ""}, + {"extern int a, b;", false, false, "", "", "", "b", "", ""}, + {" extern T x [N] ;", false, false, "", "", "", "x", "", ""}, + {"externs int x;", false, false, "", "", "", "", "", ""}, + {"extern \"C\" {", false, false, "", "", "", "", "", ""}, + {"int foo(void)", false, false, "", "", "", "", "foo", ""}, + {"foo(x)", false, false, "", "", "", "", "", ""}, + {"static void foo (int a)", false, false, "", "", "", "", "foo", ""}, + {" int foo(void)", false, false, "", "", "", "", "", ""}, + {"int *bar(void)", false, false, "", "", "", "", "bar", ""}, + {"a.b(c)", false, false, "", "", "", "", "", ""}, + {"int foo\t(x)", false, false, "", "", "", "", "foo", ""}, + {"return foo(x);", false, false, "", "", "", "", "foo", ""}, + {"static inline T *g (void) {", false, false, "", "", "", "", "g", ""}, + {"#include \"a/b.h\"", false, true, "", "", "", "", "", "a/b.h"}, + {" # include \"x.h\"", false, true, "", "", "", "", "", "x.h"}, + {"#include ", false, true, "", "", "", "", "", ""}, + {"#include\"\"", false, true, "", "", "", "", "", ""}, + {"#include \"../up/y.h\" // c", false, true, "", "", "", "", "", "../up/y.h"}, +}; + +static bool tis_same(const char *line, bool matched, cbm_ti_span_t span, const char *expected) { + if (!*expected) { + return !matched; + } + return matched && span.len == strlen(expected) && + memcmp(line + span.start, expected, span.len) == 0; +} + +/* Every pattern gives on every line what the reference expression gives. */ +TEST(test_impact_source_line_patterns_match_the_reference) { + for (size_t i = 0; i < sizeof(tis_lines) / sizeof(tis_lines[0]); i++) { + const tis_line_t *c = &tis_lines[i]; + const char *s = c->line; + size_t n = strlen(s); + cbm_ti_span_t span = {0}; + size_t body = 0; + bool ok = cbm_ti_line_is_comment(s, n) == c->comment && + cbm_ti_line_is_pp_condition(s, n) == c->pp && + tis_same(s, cbm_ti_match_define(s, n, &span, &body), span, c->define_name) && + tis_same(s, cbm_ti_match_typedef(s, n, &span), span, c->typedef_name) && + tis_same(s, cbm_ti_match_tag_declaration(s, n, &span), span, c->tag_name) && + tis_same(s, cbm_ti_match_extern(s, n, &span), span, c->extern_name) && + tis_same(s, cbm_ti_match_prototype(s, n, &span), span, c->proto_name) && + tis_same(s, cbm_ti_match_include(s, n, &span), span, c->include_path); + if (!ok) { + printf(" line %zu: \"%s\"\n", i, s); + } + ASSERT_TRUE(ok); + } + PASS(); +} + +/* The declaration of a changed header line tries define, typedef, tag, + * extern, prototype in that order. */ +TEST(test_impact_source_declaration_tries_the_patterns_in_order) { + static const struct { + const char *line; + const char *name; + } cases[] = {{"#define F(x) f(x)", "F"}, + {"typedef void (*fn)(int);", "void"}, + {"extern int f(void);", "f"}, + {"typedef struct foo {", "foo"}, + {"int x;", ""}}; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + cbm_ti_span_t span = {0}; + const char *s = cases[i].line; + ASSERT_TRUE( + tis_same(s, cbm_ti_match_declaration(s, strlen(s), &span), span, cases[i].name)); + } + PASS(); +} + +static cbm_ti_source_t *tis_build(const char *const *files, int count) { + cbm_ti_source_t *s = cbm_ti_source_new(); + if (!s) { + return NULL; + } + for (int i = 0; i < count; i += 2) { + if (!cbm_ti_source_add(s, files[i], files[i + 1], strlen(files[i + 1]))) { + cbm_ti_source_free(s); + return NULL; + } + } + if (!cbm_ti_source_finish(s)) { + cbm_ti_source_free(s); + return NULL; + } + return s; +} + +static bool tis_has(const char *const *list, int count, const char *word) { + for (int i = 0; i < count; i++) { + if (strcmp(list[i], word) == 0) { + return true; + } + } + return false; +} + +/* A header's macros carry the identifiers of their body (keywords and the + * macro's own name removed, continuation lines included) and the operands of + * `##`, as the reference collects them. */ +TEST(test_impact_source_macros_carry_body_names_and_pastes) { + const char *files[] = {"inc/m.h", + "#define CASE(tag_) \\\n" + " static int CASE_##tag_ = helper(x2y, 0x1F); \\\n" + " int tag_ ## _end;\n" + "#define PLAIN return NULL\n", + "src/a.c", "#define NOT_A_HEADER helper\n"}; + cbm_ti_source_t *s = tis_build(files, 4); + ASSERT_NOT_NULL(s); + int count = 0; + const cbm_ti_macro_t *macros = cbm_ti_source_macros(s, &count); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(macros[0].name, "CASE"); + /* `tag_` is the parameter: the reference keeps it, as it keeps `x1F` */ + const char *want[] = {"CASE_", "_end", "helper", "tag_", "x1F", "x2y"}; + ASSERT_EQ(macros[0].token_count, 6); + for (int i = 0; i < 6; i++) { + ASSERT_STR_EQ(macros[0].tokens[i], want[i]); + } + ASSERT_EQ(macros[0].paste_prefix_count, 2); + ASSERT_TRUE(tis_has(macros[0].paste_prefixes, 2, "CASE_")); + ASSERT_TRUE(tis_has(macros[0].paste_prefixes, 2, "tag_")); + ASSERT_EQ(macros[0].paste_suffix_count, 0); + ASSERT_STR_EQ(macros[1].name, "PLAIN"); + ASSERT_EQ(macros[1].token_count, 0); + cbm_ti_source_free(s); + PASS(); +} + +/* `x ## y ## z`: the reference reads x and y as prefixes and never z (the + * second `##` was taken by y); `## q` alone is a suffix. */ +TEST(test_impact_source_paste_scan_follows_the_reference) { + const char *files[] = {"p.h", "#define A x ## y ## z\n#define B ## q\n"}; + cbm_ti_source_t *s = tis_build(files, 2); + ASSERT_NOT_NULL(s); + int count = 0; + const cbm_ti_macro_t *macros = cbm_ti_source_macros(s, &count); + ASSERT_EQ(count, 2); + ASSERT_EQ(macros[0].paste_prefix_count, 2); + ASSERT_TRUE(tis_has(macros[0].paste_prefixes, 2, "x")); + ASSERT_TRUE(tis_has(macros[0].paste_prefixes, 2, "y")); + ASSERT_EQ(macros[0].paste_suffix_count, 0); + ASSERT_EQ(macros[1].paste_prefix_count, 0); + ASSERT_EQ(macros[1].paste_suffix_count, 1); + ASSERT_STR_EQ(macros[1].paste_suffixes[0], "q"); + cbm_ti_source_free(s); + PASS(); +} + +/* An include names the file it resolves to from the including directory + * when that file exists, otherwise every file whose path ends with it + * (leading `.` and `/` removed). The closure follows includers transitively. */ +TEST(test_impact_source_include_closure_follows_the_reference) { + const char *files[] = {"src/core/x.h", "int x;\n", + "src/core/y.h", "#include \"x.h\"\n", + "src/app/main.c", "#include \"core/y.h\"\n", + "src/app/other.c", "#include \"../core/x.h\"\n", + "lib/x.h", "int other_x;\n", + "tests/t.c", "#include \"../../x.h\"\n", + "tests/u.c", "#include \n"}; + cbm_ti_source_t *s = tis_build(files, 14); + ASSERT_NOT_NULL(s); + int n = cbm_ti_source_file_count(s); + ASSERT_EQ(n, 7); + bool marked[7] = {false}; + marked[cbm_ti_source_find(s, "src/core/x.h")] = true; + cbm_ti_source_include_closure(s, marked); + /* y.h resolves x.h beside it; main.c reaches y.h through the suffix + * "core/y.h"; other.c through ../core/x.h; t.c's "../../x.h" does not + * exist relative to tests/, so its key "x.h" names BOTH x.h files. */ + ASSERT_TRUE(marked[cbm_ti_source_find(s, "src/core/y.h")]); + ASSERT_TRUE(marked[cbm_ti_source_find(s, "src/app/main.c")]); + ASSERT_TRUE(marked[cbm_ti_source_find(s, "src/app/other.c")]); + ASSERT_TRUE(marked[cbm_ti_source_find(s, "tests/t.c")]); + ASSERT_FALSE(marked[cbm_ti_source_find(s, "lib/x.h")]); + ASSERT_FALSE(marked[cbm_ti_source_find(s, "tests/u.c")]); + cbm_ti_source_free(s); + PASS(); +} + +/* One place per line; comment-led lines and keywords hold none; identifiers + * in strings and trailing comments count (an over-approximation that only + * adds seeds); `0x1F` holds `x1F` as the reference's token pattern reads it. */ +TEST(test_impact_source_occurrences_follow_the_reference) { + const char *files[] = {"b.c", + "int f(void) { return f2(f); }\n" + "// f in a comment line\n" + " * f in a block comment line\n" + "const char *s = \"f\"; /* f */\n" + "int y = 0x1F;\n", + "a.c", "void g(void) { f(); }\n"}; + cbm_ti_source_t *s = tis_build(files, 4); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(cbm_ti_source_build_occurrences(s)); + int count = 0; + const cbm_ti_place_t *places = cbm_ti_source_occurrences(s, "f", 1, &count); + ASSERT_EQ(count, 3); + ASSERT_STR_EQ(cbm_ti_source_path(s, places[0].file), "a.c"); + ASSERT_EQ(places[0].line, 1); + ASSERT_STR_EQ(cbm_ti_source_path(s, places[1].file), "b.c"); + ASSERT_EQ(places[1].line, 1); + ASSERT_EQ(places[2].line, 4); + (void)cbm_ti_source_occurrences(s, "x1F", 3, &count); + ASSERT_EQ(count, 1); + (void)cbm_ti_source_occurrences(s, "int", 3, &count); + ASSERT_EQ(count, 0); + cbm_ti_source_free(s); + PASS(); +} + +/* Lines split as `text.split('\n')`: a final newline leaves an empty line. */ +TEST(test_impact_source_lines_split_like_the_reference) { + const char *files[] = {"x.c", "a\r\nb\n"}; + cbm_ti_source_t *s = tis_build(files, 2); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_ti_source_line_count(s, 0), 3); + size_t len = 0; + const char *line = cbm_ti_source_line(s, 0, 1, &len); + ASSERT_EQ(len, 2); + ASSERT_TRUE(memcmp(line, "a\r", 2) == 0); + (void)cbm_ti_source_line(s, 0, 3, &len); + ASSERT_EQ(len, 0); + ASSERT_NULL(cbm_ti_source_line(s, 0, 4, &len)); + ASSERT_FALSE(cbm_ti_source_add(s, "late.c", "", 0)); + cbm_ti_source_free(s); + PASS(); +} + +SUITE(test_impact_source) { + RUN_TEST(test_impact_source_line_patterns_match_the_reference); + RUN_TEST(test_impact_source_declaration_tries_the_patterns_in_order); + RUN_TEST(test_impact_source_macros_carry_body_names_and_pastes); + RUN_TEST(test_impact_source_paste_scan_follows_the_reference); + RUN_TEST(test_impact_source_include_closure_follows_the_reference); + RUN_TEST(test_impact_source_occurrences_follow_the_reference); + RUN_TEST(test_impact_source_lines_split_like_the_reference); +} diff --git a/tests/test_test_impact_tree.c b/tests/test_test_impact_tree.c index 15765cf46..2a77026b6 100644 --- a/tests/test_test_impact_tree.c +++ b/tests/test_test_impact_tree.c @@ -1135,15 +1135,23 @@ TEST(tree_c_complete_inventory_and_native_names) { PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); PT_CHECK(pt_close_one(&fx.tree)); pt_row rows[2] = {fx.rows[0], fx.rows[0]}; - rows[0].path = "good"; - rows[1].path = "unsupported"; + /* A symlink or a submodule link is left out and counted, as discovery + * never indexes a symlink; the regular file beside it is pinned. The link + * sorts first (rows are in git's order), so the pinned file is not at its + * position in git's own list. */ + rows[0].path = "a-link"; + rows[1].path = "good"; const uint32_t modes[] = {0120000, 0160000}; for (size_t i = 0; i < 2; i++) { - rows[1].mode = modes[i]; - strcpy(rows[1].oid, i == 0 ? fx.rows[0].oid : fx.a); + rows[0].mode = modes[i]; + strcpy(rows[0].oid, i == 0 ? fx.rows[0].oid : fx.a); PT_CHECK(pt_replace_tree(&fx, rows, 2)); options = pt_options(&fx, CBM_GIT_REV_HEAD); - PT_CHECK(pt_clean_failure(&fx, &options, CBM_PINNED_TREE_UNSUPPORTED)); + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + const cbm_pinned_tree_view_t *linked = cbm_pinned_tree_view(fx.tree); + PT_CHECK(linked && linked->file_count == 1 && linked->skipped_link_count == 1 && + strcmp((const char *)linked->files[0].path, "good") == 0); + PT_CHECK(pt_close_one(&fx.tree)); } rows[1] = fx.rows[0]; #ifdef _WIN32 @@ -2533,7 +2541,6 @@ SUITE(test_impact_tree) { RUN_TEST(tree_h_cleanup_owner_and_unrelated_resources); } - /* Independent prefix-read acceptance: no publisher/admission claims. All * mutations are synchronous and restricted to the fixture's owned namespace. */ static bool ptr_setup(pt_fixture *fx, unsigned width) { From 36e58745259e60f58fbb7c5883906dbcb4acba49 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 5 Oct 2026 18:52:26 +0200 Subject: [PATCH 22/44] feat(test-impact): team artifact bundles and admitted coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A main commit can now publish its test-impact bundle — the commit's frozen graph, its per-test coverage map and receipt.json binding both — and a PR selection given the bundle of its merge base, verified by the caller, builds its graph incrementally from the bundle's graph and narrows suites by the admitted map. Without a verified bundle nothing changes: the graph is built from scratch and every reached suite runs whole. Producer and consumer: - `codebase-memory-mcp test-impact publish|select` (in process, no daemon): publish builds the bundle of the checked-out commit, from the previous bundle's graph when that bundle verifies; select is the selection with optional --artifact/--artifact-commit/--artifact-verified. The MCP surface is unchanged. - receipt.json binds the exact imported graph (graph digest), the graph's content and topology, the map's three files and a compatibility digest (platform label + the bytes of test_impact.coverage.compatibility_paths). - A bundle is used only when the caller verified its source, it is the merge base's own, and its imported graph is the one the receipt names; the map is admitted by the existing receipt policy (source, ancestry, content, age, compatibility, metadata). Refusals become rejection reasons in the answer's receipt. - C_eff: a changed function the map knows contributes its own id; one it does not know (new, platform-only, a type or macro) contributes its nearest mapped callers on the static inbound closure; deleted names contribute every mapped function of that name. Graph: - the frozen build accepts a base candidate and repairs it through the incremental closure route; any doubt declines to the full build. Pinned to one worker like the full frozen build. - incremental closure repair skipped the interprocedural complexity pass: re-parsed functions lost transitive_loop_depth/recursive and callers kept stale values. One traversal now serves the in-memory pass and a new store-level recompute that runs before the importance rescore. - test-impact's frozen builds use a fixed display Git context: the per-run pin path and the commit ids made two builds of one tree differ and made every commit a semantic-input change, so repair always declined. - graph content and topology digests (store_content_digest.c): the exact graph digest binds one database (ids, db_uid, generation), never equal across builds; the content digest keys rows by what they say, the topology digest by what selection reads. Measured on this repository (frozen publish, macOS arm64): a two-file C change repairs in 18-20 s instead of 75-80 s with the topology of the full build; a docs-only change repairs with identical content; selections with and without the bundle are identical on all seven replayed pairs (two repaired incrementally, five declined to the full build). Recorded, not fixed: closure repair still differs from a full build in edge PROPERTIES (argument quoting and key order, CONFIGURES args) and in the corpus-wide SIMILAR_TO/SEMANTICALLY_RELATED edges; selection reads neither, so the topology digest is the incremental-equals-full check and the receipt carries both. Tooling: scripts/test-impact/coverage-merge.py builds a push's incremental coverage map (re-run suites verbatim, the rest carried with ids remapped onto the new image's function table), with its contract as test.sh Step 0z7. Signed-off-by: Martin Vogel --- .codebase-memory.json | 12 +- Makefile.cbm | 5 +- scripts/memory-core-baseline.txt | 1 - scripts/test-impact/coverage-merge.py | 136 ++++++ scripts/test.sh | 5 + src/daemon/bootstrap.c | 4 + src/main.c | 5 + src/mcp/test_impact.h | 4 + src/mcp/test_impact_artifact.c | 283 +++++++++++ src/mcp/test_impact_artifact.h | 75 +++ src/mcp/test_impact_cli.c | 185 ++++++++ src/mcp/test_impact_engine.c | 67 ++- src/mcp/test_impact_engine.h | 42 ++ src/mcp/test_impact_engine_artifact.c | 646 ++++++++++++++++++++++++++ src/mcp/test_impact_engine_internal.h | 33 ++ src/mcp/test_impact_engine_snapshot.c | 25 +- src/mcp/test_impact_policy.c | 64 +++ src/pipeline/pass_complexity.c | 527 +++++++++++++++++---- src/pipeline/pipeline.c | 39 +- src/pipeline/pipeline_incremental.c | 10 +- src/pipeline/pipeline_internal.h | 21 + src/store/store.h | 10 + src/store/store_content_digest.c | 178 +++++++ tests/test_coverage_merge.sh | 61 +++ tests/test_daemon_bootstrap.c | 2 + tests/test_main.c | 2 + tests/test_pipeline_frozen.c | 84 ++++ tests/test_test_impact.c | 49 ++ tests/test_test_impact_artifact.c | 168 +++++++ tests/test_test_impact_engine.c | 167 ++++++- 30 files changed, 2783 insertions(+), 127 deletions(-) create mode 100644 scripts/test-impact/coverage-merge.py create mode 100644 src/mcp/test_impact_artifact.c create mode 100644 src/mcp/test_impact_artifact.h create mode 100644 src/mcp/test_impact_cli.c create mode 100644 src/mcp/test_impact_engine_artifact.c create mode 100644 src/store/store_content_digest.c create mode 100644 tests/test_coverage_merge.sh create mode 100644 tests/test_test_impact_artifact.c diff --git a/.codebase-memory.json b/.codebase-memory.json index 855970754..448c14f6f 100644 --- a/.codebase-memory.json +++ b/.codebase-memory.json @@ -44,6 +44,16 @@ "action": "ignore" } ], - "unmapped_files": "run_all" + "unmapped_files": "run_all", + "coverage": { + "compatibility_paths": [ + "tests/test_framework.h", + "tests/test_main.c", + "tests/test_helpers.h", + "Makefile.cbm", + "scripts/test-impact/coverage-map.py", + "scripts/test-impact/coverage-merge.py" + ] + } } } diff --git a/Makefile.cbm b/Makefile.cbm index 3c4e3d2a5..4cf16f90e 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -422,14 +422,14 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp SQLITE_WRITER_SRC = $(CBM_DIR)/sqlite_writer.c # Store module (new) -STORE_SRCS = src/store/store.c src/store/store_impact.c src/store/store_graph_digest.c +STORE_SRCS = src/store/store.c src/store/store_impact.c src/store/store_graph_digest.c src/store/store_content_digest.c # Cypher module (new) CYPHER_SRCS = src/cypher/cypher.c # MCP server module (new) MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c \ - src/mcp/test_impact.c src/mcp/test_impact_diff.c src/mcp/test_impact_classify.c src/mcp/test_impact_source.c src/mcp/test_impact_seed.c src/mcp/test_impact_engine.c src/mcp/test_impact_engine_snapshot.c src/mcp/test_impact_engine_reach.c src/mcp/test_impact_changes.c src/mcp/test_impact_origins.c src/mcp/test_impact_coverage.c src/mcp/test_impact_config.c src/mcp/test_impact_select.c src/mcp/test_impact_policy.c src/mcp/test_impact_git.c src/mcp/test_impact_inventory.c src/mcp/test_impact_inventory_input.c src/mcp/test_impact_tree.c src/mcp/test_impact_tree_audit.c src/mcp/test_impact_tree_read.c src/mcp/test_impact_tree_batch.c src/mcp/test_impact_tree_digest.c src/mcp/test_impact_tree_names.c src/mcp/test_impact_tree_plan.c src/mcp/test_impact_tree_posix.c src/mcp/test_impact_tree_posix_acl.c src/mcp/test_impact_tree_posix_io.c src/mcp/test_impact_tree_util.c src/mcp/test_impact_tree_windows.c src/mcp/test_impact_tree_windows_acl.c src/mcp/test_impact_tree_windows_io.c \ + src/mcp/test_impact.c src/mcp/test_impact_diff.c src/mcp/test_impact_classify.c src/mcp/test_impact_source.c src/mcp/test_impact_seed.c src/mcp/test_impact_engine.c src/mcp/test_impact_engine_snapshot.c src/mcp/test_impact_engine_reach.c src/mcp/test_impact_engine_artifact.c src/mcp/test_impact_cli.c src/mcp/test_impact_artifact.c src/mcp/test_impact_changes.c src/mcp/test_impact_origins.c src/mcp/test_impact_coverage.c src/mcp/test_impact_config.c src/mcp/test_impact_select.c src/mcp/test_impact_policy.c src/mcp/test_impact_git.c src/mcp/test_impact_inventory.c src/mcp/test_impact_inventory_input.c src/mcp/test_impact_tree.c src/mcp/test_impact_tree_audit.c src/mcp/test_impact_tree_read.c src/mcp/test_impact_tree_batch.c src/mcp/test_impact_tree_digest.c src/mcp/test_impact_tree_names.c src/mcp/test_impact_tree_plan.c src/mcp/test_impact_tree_posix.c src/mcp/test_impact_tree_posix_acl.c src/mcp/test_impact_tree_posix_io.c src/mcp/test_impact_tree_util.c src/mcp/test_impact_tree_windows.c src/mcp/test_impact_tree_windows_acl.c src/mcp/test_impact_tree_windows_io.c \ src/mcp/test_impact_result.c src/mcp/test_impact_result_json.c src/mcp/test_impact_result_project.c src/mcp/test_impact_result_receipt.c src/mcp/test_impact_result_rows.c src/mcp/test_impact_result_util.c src/mcp/test_impact_result_validate.c src/mcp/test_impact_result_wire.c # Shared MCP daemon coordination and local transport @@ -709,6 +709,7 @@ TEST_MCP_SRCS = \ tests/test_test_impact_source.c \ tests/test_test_impact_seed.c \ tests/test_test_impact_engine.c \ + tests/test_test_impact_artifact.c \ tests/test_test_impact_inventory_errors.c \ tests/test_test_impact_inventory_support.c \ tests/test_test_impact_runner_filter.c \ diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index c5cbe0727..cf3dabf95 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -59,7 +59,6 @@ src/pipeline/lsp_resolve.h 4 src/pipeline/lsp_surface.c 5 src/pipeline/pass_calls.c 22 src/pipeline/pass_compile_commands.c 17 -src/pipeline/pass_complexity.c 15 src/pipeline/pass_configlink.c 3 src/pipeline/pass_cross_repo.c 8 src/pipeline/pass_definitions.c 11 diff --git a/scripts/test-impact/coverage-merge.py b/scripts/test-impact/coverage-merge.py new file mode 100644 index 000000000..1c4cfd7f9 --- /dev/null +++ b/scripts/test-impact/coverage-merge.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""coverage-merge.py — the incremental coverage map of a main-branch push. + +Merges FRESH (coverage-map.py output for the suites the push re-ran, made with +the new commit's instrumented runner, so its functions.tsv is the new image's +complete function table) into PREVIOUS (the previous published map): + + * a suite FRESH ran keeps FRESH's rows exactly; + * every other suite the new runner still lists is carried from PREVIOUS, + each row's function ids remapped by (source file, name) onto FRESH's + table; a function the new image no longer has drops out of the row, and a + row's status (an incomplete row stays incomplete) is never improved; + * a PREVIOUS suite the new runner no longer lists is dropped. + +The merged meta.json names --commit and recounts every suite, so the C reader +(cbm_coverage_map_metadata_matches) checks it like a full map. When the map was +observed is not decided here: the producer carries the oldest observation of +the previous bundle forward, so an incremental map ages out unless a full +refresh runs (the receipt's 7-day bound). + +Usage: coverage-merge.py --previous DIR --fresh DIR --suites FILE --commit SHA --out DIR +Exit: 0 merged · 1 inconsistent inputs · 2 usage. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys + +SETUP = "*" + + +def fail(message: str) -> None: + print(f"coverage-merge: {message}", file=sys.stderr) + sys.exit(1) + + +def read_functions(path: str) -> tuple[dict[int, tuple[str, str]], list[str]]: + by_id: dict[int, tuple[str, str]] = {} + lines = [] + with open(path, encoding="utf-8") as handle: + for line in handle: + line = line.rstrip("\n") + if not line: + continue + fields = line.split("\t") + if len(fields) != 3 or not fields[0].isdigit(): + fail(f"malformed function row in {path}: {line!r}") + by_id[int(fields[0])] = (fields[1], fields[2]) + lines.append(line) + return by_id, lines + + +def read_tests(path: str) -> list[tuple[str, str, str, str, list[int]]]: + rows = [] + with open(path, encoding="utf-8") as handle: + for line in handle: + line = line.rstrip("\n") + if not line: + continue + fields = line.split("\t") + if len(fields) != 4 or ":" not in fields[0]: + fail(f"malformed test row in {path}: {line!r}") + suite, test = fields[0].split(":", 1) + ids = [int(token) for token in fields[3].split()] if fields[3] else [] + rows.append((suite, test, fields[1], fields[2], ids)) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + for flag in ("previous", "fresh", "suites", "commit", "out"): + parser.add_argument("--" + flag, required=True) + args = parser.parse_args() + + _, fresh_lines = read_functions(os.path.join(args.fresh, "functions.tsv")) + fresh_by_id, _ = read_functions(os.path.join(args.fresh, "functions.tsv")) + previous_by_id, _ = read_functions(os.path.join(args.previous, "functions.tsv")) + fresh_ids = {key: ident for ident, key in fresh_by_id.items()} + with open(os.path.join(args.fresh, "meta.json"), encoding="utf-8") as handle: + fresh_meta = json.load(handle) + with open(os.path.join(args.previous, "meta.json"), encoding="utf-8") as handle: + previous_meta = json.load(handle) + with open(args.suites, encoding="utf-8") as handle: + listed = [line.strip() for line in handle if line.strip()] + + fresh_rows = read_tests(os.path.join(args.fresh, "tests.tsv")) + previous_rows = read_tests(os.path.join(args.previous, "tests.tsv")) + rerun = {row[0] for row in fresh_rows} + if not rerun <= set(listed): + fail("a re-run suite is not listed by the new runner") + + merged = [row for row in fresh_rows] + for suite, test, status, reason, ids in previous_rows: + if suite in rerun or suite not in listed: + continue + mapped = sorted({fresh_ids[previous_by_id[i]] for i in ids + if i in previous_by_id and previous_by_id[i] in fresh_ids}) + merged.append((suite, test, status, reason, mapped)) + merged.sort(key=lambda row: (row[0], row[1])) + + suite_meta = {entry["suite"]: entry for entry in previous_meta.get("suites", [])} + for entry in fresh_meta.get("suites", []): + suite_meta[entry["suite"]] = entry + counts: dict[str, list[int]] = {} + for suite, test, status, _, _ in merged: + tally = counts.setdefault(suite, [0, 0]) + if test != SETUP: + tally[0] += 1 + tally[1] += status != "complete" + suites = [] + for suite in sorted(counts): + entry = dict(suite_meta.get(suite, {"suite": suite, "exit": 0})) + entry["tests"], entry["incomplete"] = counts[suite] + suites.append(entry) + + os.makedirs(args.out, exist_ok=True) + with open(os.path.join(args.out, "functions.tsv"), "w", encoding="utf-8") as handle: + handle.write("".join(line + "\n" for line in fresh_lines)) + with open(os.path.join(args.out, "tests.tsv"), "w", encoding="utf-8") as handle: + for suite, test, status, reason, ids in merged: + handle.write(f"{suite}:{test}\t{status}\t{reason}\t{' '.join(map(str, ids))}\n") + meta = dict(fresh_meta) + meta.update({"commit": args.commit, "tests": sum(c[0] for c in counts.values()), + "incomplete": sum(c[1] for c in counts.values()), "suites": suites}) + with open(os.path.join(args.out, "meta.json"), "w", encoding="utf-8") as handle: + json.dump(meta, handle, indent=1) + handle.write("\n") + print(f"merged: {len(rerun)} suite(s) re-run, " + f"{len(counts) - len(rerun & set(counts))} carried, {len(merged)} rows") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test.sh b/scripts/test.sh index 7bc7f4ea5..91594e581 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -370,6 +370,11 @@ bash "$ROOT/tests/test_test_impact_predict.sh" # child, an unreadable profile, a failed suite) has to come out `incomplete`. echo "=== Step 0z6: per-test coverage map builder contract ===" bash "$ROOT/tests/test_coverage_map.sh" +# Step 0z7: a push's incremental coverage map carries the suites it did not +# re-run onto the new image's function table; a wrong remap would hand the PR +# selection a map that claims tests ran functions they never touched. +echo "=== Step 0z7: incremental coverage map merge contract ===" +bash "$ROOT/tests/test_coverage_merge.sh" if [ "$CONTRACTS_ONLY" -eq 1 ]; then echo "=== test.sh: contracts-only — every contract step passed ===" diff --git a/src/daemon/bootstrap.c b/src/daemon/bootstrap.c index 01076ae48..cb384cd20 100644 --- a/src/daemon/bootstrap.c +++ b/src/daemon/bootstrap.c @@ -178,6 +178,10 @@ cbm_daemon_process_role_t cbm_daemon_process_role(int argc, char *const argv[]) * project, so it needs no daemon. Listed here rather than routed through * the daemon so enrolling a root cannot depend on daemon state. */ "allow-root", + /* test-impact runs its own frozen index of a pinned tree in process + * (CI: publish the team artifact, select tests); it never reads or + * writes the daemon's projects, so it needs no daemon. */ + "test-impact", }; /* Stop at the first top-level mode token. Tool names, flag values, and JSON * following `cli` are opaque user input: a search query named "install" diff --git a/src/main.c b/src/main.c index fd3370c04..67c1212a4 100644 --- a/src/main.c +++ b/src/main.c @@ -33,6 +33,7 @@ #include "daemon/version_cohort.h" #include "mcp/mcp.h" #include "mcp/index_supervisor.h" +#include "mcp/test_impact_engine.h" #include "cli/cli.h" #include "cli/progress_sink.h" #include "foundation/constants.h" @@ -1280,6 +1281,10 @@ static int handle_subcommand(int argc, char **argv, cbm_project_lock_manager_t * cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); return cbm_cmd_hook_augment(argc - i - SKIP_ONE, argv + i + SKIP_ONE); } + if (strcmp(argv[i], "test-impact") == 0) { + cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); + return cbm_test_impact_cli(argc - i - SKIP_ONE, argv + i + SKIP_ONE); + } if (strcmp(argv[i], "install") == 0) { return cbm_cmd_install(argc - i - SKIP_ONE, argv + i + SKIP_ONE); } diff --git a/src/mcp/test_impact.h b/src/mcp/test_impact.h index 7153e0e8d..56cc012b9 100644 --- a/src/mcp/test_impact.h +++ b/src/mcp/test_impact.h @@ -546,6 +546,10 @@ void cbm_test_policy_free(cbm_test_policy_t *policy); const cbm_test_rule_t *cbm_test_policy_rules(const cbm_test_policy_t *policy, int *count); const cbm_test_lane_t *cbm_test_policy_lanes(const cbm_test_policy_t *policy, int *count); const char *cbm_test_policy_digest(const cbm_test_policy_t *policy); +/* test_impact.coverage.compatibility_paths in config order: the files besides + * the measured code whose bytes a coverage map depends on (harness, runner, + * spawn helpers, build flags). NULL with *count 0 when none are configured. */ +const char *const *cbm_test_policy_compatibility_paths(const cbm_test_policy_t *policy, int *count); /* Successful classification returns true and either the first matched rule * or NULL for no match. false is an evaluation error, NEVER permission to diff --git a/src/mcp/test_impact_artifact.c b/src/mcp/test_impact_artifact.c new file mode 100644 index 000000000..dbeb2808c --- /dev/null +++ b/src/mcp/test_impact_artifact.c @@ -0,0 +1,283 @@ +/* + * test_impact_artifact.c — receipt.json and the bundle digests. + */ +#include "mcp/test_impact_artifact.h" + +#include "foundation/compat_fs.h" +#include "foundation/mem_core.h" +#include "foundation/sha256.h" + +#include +#include +#include + +enum { TA_READ_CHUNK = 65536, TA_RECEIPT_MAX = 65536, TA_PATH_CAP = 4096 }; + +/* yyjson allocates through the memory core, so documents and written text + * are released with cbm_free like everything else here. */ +static void *ta_yy_malloc(void *ctx, size_t size) { + (void)ctx; + return cbm_alloc(CBM_MEM_CLASS_OTHER, size); +} + +static void *ta_yy_realloc(void *ctx, void *block, size_t old_size, size_t size) { + (void)ctx; + (void)old_size; + return cbm_realloc(CBM_MEM_CLASS_OTHER, block, size); +} + +static void ta_yy_free(void *ctx, void *block) { + (void)ctx; + cbm_free(CBM_MEM_CLASS_OTHER, block); +} + +static const yyjson_alc ta_yy_alc = {ta_yy_malloc, ta_yy_realloc, ta_yy_free, NULL}; + +static bool ta_hex(const char *text, size_t want_a, size_t want_b) { + size_t n = text ? strlen(text) : 0; + if (n != want_a && n != want_b) { + return false; + } + for (size_t i = 0; i < n; i++) { + if (!((text[i] >= '0' && text[i] <= '9') || (text[i] >= 'a' && text[i] <= 'f'))) { + return false; + } + } + return true; +} + +static void ta_hex_out(const uint8_t digest[CBM_SHA256_DIGEST_LEN], char hex[65]) { + static const char digits[] = "0123456789abcdef"; + for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) { + hex[2 * i] = digits[digest[i] >> 4]; + hex[2 * i + 1] = digits[digest[i] & 15]; + } + hex[64] = '\0'; +} + +static bool ta_path(char *out, const char *dir, const char *name) { + int n = snprintf(out, TA_PATH_CAP, "%s/%s", dir, name); + return n > 0 && n < TA_PATH_CAP; +} + +/* A length-prefixed frame, so no field can run into the next. */ +static void ta_frame(cbm_sha256_ctx *ctx, const void *bytes, uint64_t length) { + uint8_t prefix[8]; + for (int i = 0; i < 8; i++) { + prefix[i] = (uint8_t)(length >> (56 - 8 * i)); + } + cbm_sha256_update(ctx, prefix, sizeof(prefix)); + if (length) { + cbm_sha256_update(ctx, bytes, (size_t)length); + } +} + +/* Feeds a file's exact bytes as one frame; false on a read error. + * *absent: the file does not exist (a frame-free outcome the caller encodes). */ +static bool ta_feed_file(cbm_sha256_ctx *ctx, const char *path, bool framed, bool *absent) { + *absent = false; + FILE *f = cbm_fopen(path, "rb"); + if (!f) { + cbm_path_info_t info; + *absent = cbm_path_info_utf8(path, &info) != 0; + return *absent; + } + unsigned char *buf = cbm_alloc(CBM_MEM_CLASS_OTHER, TA_READ_CHUNK); + if (!buf) { + (void)fclose(f); + return false; + } + /* Size first, so a framed file's prefix is its real length. */ + uint64_t total = 0; + size_t got; + while ((got = fread(buf, 1, TA_READ_CHUNK, f)) > 0) { + total += got; + } + bool ok = !ferror(f) && fseek(f, 0, SEEK_SET) == 0; + if (ok && framed) { + ta_frame(ctx, NULL, 0); /* domain: a present file */ + uint8_t prefix[8]; + for (int i = 0; i < 8; i++) { + prefix[i] = (uint8_t)(total >> (56 - 8 * i)); + } + cbm_sha256_update(ctx, prefix, sizeof(prefix)); + } + uint64_t again = 0; + while (ok && (got = fread(buf, 1, TA_READ_CHUNK, f)) > 0) { + cbm_sha256_update(ctx, buf, got); + again += got; + } + ok = ok && !ferror(f) && again == total; + cbm_free(CBM_MEM_CLASS_OTHER, buf); + ok = fclose(f) == 0 && ok; + return ok; +} + +bool cbm_ti_sha256_file(const char *path, char hex[65]) { + hex[0] = '\0'; + cbm_sha256_ctx ctx; + cbm_sha256_init(&ctx); + bool absent = false; + if (!path || !ta_feed_file(&ctx, path, false, &absent) || absent) { + return false; + } + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(&ctx, digest); + ta_hex_out(digest, hex); + return true; +} + +bool cbm_ti_compatibility_digest(const char *root, const char *const *paths, int path_count, + const char *platform, char hex[65]) { + hex[0] = '\0'; + if (!root || !platform || path_count < 0 || (path_count && !paths)) { + return false; + } + static const char domain[] = "cbm.test_impact.compatibility.v1"; + cbm_sha256_ctx ctx; + cbm_sha256_init(&ctx); + ta_frame(&ctx, domain, sizeof(domain) - 1); + ta_frame(&ctx, platform, strlen(platform)); + for (int i = 0; i < path_count; i++) { + char path[TA_PATH_CAP]; + if (!paths[i] || !*paths[i] || !ta_path(path, root, paths[i])) { + return false; + } + ta_frame(&ctx, paths[i], strlen(paths[i])); + bool absent = false; + if (!ta_feed_file(&ctx, path, true, &absent)) { + return false; + } + if (absent) { + static const char marker[] = "absent"; + ta_frame(&ctx, marker, sizeof(marker) - 1); + } + } + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(&ctx, digest); + ta_hex_out(digest, hex); + return true; +} + +static bool ta_copy_string(yyjson_val *root, const char *key, char *out, size_t cap) { + yyjson_val *v = yyjson_obj_get(root, key); + const char *s = yyjson_is_str(v) ? yyjson_get_str(v) : NULL; + if (!s || strlen(s) >= cap) { + return false; + } + memcpy(out, s, strlen(s) + 1); + return true; +} + +cbm_ti_receipt_status_t cbm_ti_receipt_read(const char *bundle_dir, cbm_ti_receipt_t *out) { + if (!out) { + return CBM_TI_RECEIPT_INVALID; + } + memset(out, 0, sizeof(*out)); + char path[TA_PATH_CAP]; + if (!bundle_dir || !ta_path(path, bundle_dir, CBM_TI_RECEIPT_FILE)) { + return CBM_TI_RECEIPT_INVALID; + } + FILE *f = cbm_fopen(path, "rb"); + if (!f) { + cbm_path_info_t info; + return cbm_path_info_utf8(path, &info) != 0 ? CBM_TI_RECEIPT_ABSENT : CBM_TI_RECEIPT_IO; + } + char *text = cbm_alloc(CBM_MEM_CLASS_OTHER, TA_RECEIPT_MAX + 1); + size_t len = text ? fread(text, 1, TA_RECEIPT_MAX + 1, f) : 0; + bool read_ok = text && !ferror(f) && len <= TA_RECEIPT_MAX; + (void)fclose(f); + if (!read_ok) { + cbm_free(CBM_MEM_CLASS_OTHER, text); + return text ? CBM_TI_RECEIPT_INVALID : CBM_TI_RECEIPT_IO; + } + yyjson_doc *doc = yyjson_read_opts(text, len, 0, &ta_yy_alc, NULL); + cbm_free(CBM_MEM_CLASS_OTHER, text); + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + cbm_ti_receipt_t r = {0}; + char schema[64] = ""; + bool ok = yyjson_is_obj(root) && ta_copy_string(root, "schema", schema, sizeof(schema)) && + strcmp(schema, CBM_TI_RECEIPT_SCHEMA) == 0 && + ta_copy_string(root, "commit", r.commit, sizeof(r.commit)) && + ta_hex(r.commit, 40, 64) && + ta_copy_string(root, "graph_sha256", r.graph_sha256, sizeof(r.graph_sha256)) && + ta_hex(r.graph_sha256, 64, 64) && + ta_copy_string(root, "graph_content_sha256", r.graph_content_sha256, + sizeof(r.graph_content_sha256)) && + ta_hex(r.graph_content_sha256, 64, 64) && + ta_copy_string(root, "graph_topology_sha256", r.graph_topology_sha256, + sizeof(r.graph_topology_sha256)) && + ta_hex(r.graph_topology_sha256, 64, 64) && + ta_copy_string(root, "platform", r.platform, sizeof(r.platform)); + yyjson_val *coverage = ok ? yyjson_obj_get(root, "coverage") : NULL; + if (ok && coverage && !yyjson_is_null(coverage)) { + yyjson_val *oldest = yyjson_obj_get(coverage, "oldest_observation_at"); + r.has_coverage = true; + ok = yyjson_is_obj(coverage) && + ta_copy_string(coverage, "functions_sha256", r.functions_sha256, 65) && + ta_hex(r.functions_sha256, 64, 64) && + ta_copy_string(coverage, "tests_sha256", r.tests_sha256, 65) && + ta_hex(r.tests_sha256, 64, 64) && + ta_copy_string(coverage, "metadata_sha256", r.metadata_sha256, 65) && + ta_hex(r.metadata_sha256, 64, 64) && + ta_copy_string(coverage, "compatibility_sha256", r.compatibility_sha256, 65) && + ta_hex(r.compatibility_sha256, 64, 64) && yyjson_is_int(oldest) && + yyjson_get_sint(oldest) > 0; + r.oldest_observation_at = ok ? yyjson_get_sint(oldest) : 0; + } + yyjson_doc_free(doc); + if (!ok) { + return CBM_TI_RECEIPT_INVALID; + } + *out = r; + return CBM_TI_RECEIPT_OK; +} + +bool cbm_ti_receipt_write(const char *bundle_dir, const cbm_ti_receipt_t *r) { + char path[TA_PATH_CAP]; + char tmp[TA_PATH_CAP]; + if (!bundle_dir || !r || !ta_path(path, bundle_dir, CBM_TI_RECEIPT_FILE) || + !ta_path(tmp, bundle_dir, CBM_TI_RECEIPT_FILE ".tmp")) { + return false; + } + yyjson_mut_doc *doc = yyjson_mut_doc_new(&ta_yy_alc); + yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL; + if (!root) { + yyjson_mut_doc_free(doc); + return false; + } + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_str(doc, root, "schema", CBM_TI_RECEIPT_SCHEMA); + yyjson_mut_obj_add_str(doc, root, "commit", r->commit); + yyjson_mut_obj_add_str(doc, root, "graph_sha256", r->graph_sha256); + yyjson_mut_obj_add_str(doc, root, "graph_content_sha256", r->graph_content_sha256); + yyjson_mut_obj_add_str(doc, root, "graph_topology_sha256", r->graph_topology_sha256); + yyjson_mut_obj_add_str(doc, root, "platform", r->platform); + if (r->has_coverage) { + yyjson_mut_val *coverage = yyjson_mut_obj_add_obj(doc, root, "coverage"); + yyjson_mut_obj_add_str(doc, coverage, "functions_sha256", r->functions_sha256); + yyjson_mut_obj_add_str(doc, coverage, "tests_sha256", r->tests_sha256); + yyjson_mut_obj_add_str(doc, coverage, "metadata_sha256", r->metadata_sha256); + yyjson_mut_obj_add_str(doc, coverage, "compatibility_sha256", r->compatibility_sha256); + yyjson_mut_obj_add_int(doc, coverage, "oldest_observation_at", r->oldest_observation_at); + } else { + yyjson_mut_obj_add_null(doc, root, "coverage"); + } + size_t len = 0; + char *json = yyjson_mut_write_opts(doc, YYJSON_WRITE_PRETTY, &ta_yy_alc, &len, NULL); + yyjson_mut_doc_free(doc); + if (!json) { + return false; + } + FILE *f = cbm_fopen(tmp, "wb"); + bool ok = f && fwrite(json, 1, len, f) == len && fputc('\n', f) != EOF; + cbm_free(CBM_MEM_CLASS_OTHER, json); + if (f) { + ok = fclose(f) == 0 && ok; + } + ok = ok && cbm_rename_replace(tmp, path) == 0; + if (!ok) { + (void)cbm_unlink(tmp); + } + return ok; +} diff --git a/src/mcp/test_impact_artifact.h b/src/mcp/test_impact_artifact.h new file mode 100644 index 000000000..aa11b5a61 --- /dev/null +++ b/src/mcp/test_impact_artifact.h @@ -0,0 +1,75 @@ +/* + * test_impact_artifact.h — The team artifact bundle of one commit. + * + * A bundle travels from the main-branch producer to PR selection (never + * committed). Layout under its directory: + * + * receipt.json this module; the bundle's authority + * .codebase-memory/graph.db.zst the commit's frozen graph (artifact.h) + * .codebase-memory/artifact.json + * coverage/functions.tsv optional coverage map, format 1 + * coverage/tests.tsv + * coverage/meta.json + * + * The receipt binds every part by SHA-256: the graph by the digest of the + * graph as an importer sees it (cbm_store_graph_digest of the imported + * database), the map by the exact bytes of its three files. A digest is + * content binding only; where a bundle came from is established by whoever + * fetched it, never by anything inside it. + */ +#ifndef CBM_TEST_IMPACT_ARTIFACT_H +#define CBM_TEST_IMPACT_ARTIFACT_H + +#include +#include +#include + +#define CBM_TI_RECEIPT_FILE "receipt.json" +#define CBM_TI_RECEIPT_SCHEMA "cbm.test_impact.artifact.v1" +#define CBM_TI_COVERAGE_DIR "coverage" + +typedef struct { + char commit[65]; /* 40 or 64 lowercase hex */ + char graph_sha256[65]; /* 64 lowercase hex */ + /* cbm_store_graph_content_digest of the same graph: equal for equal graphs + * however they were built (the weekly incremental-equals-full check). */ + char graph_content_sha256[65]; + /* cbm_store_graph_topology_digest: what selection reads. The weekly check + * fails when an incremental graph's topology differs from the full one. */ + char graph_topology_sha256[65]; + bool has_coverage; + char functions_sha256[65]; + char tests_sha256[65]; + char metadata_sha256[65]; + char compatibility_sha256[65]; + /* Oldest retained coverage observation (seconds since the epoch). A full + * refresh sets it; an incremental publish carries it forward unchanged. */ + int64_t oldest_observation_at; + char platform[128]; /* producer's platform/toolchain label, informational */ +} cbm_ti_receipt_t; + +typedef enum { + CBM_TI_RECEIPT_OK = 0, + CBM_TI_RECEIPT_ABSENT, /* no receipt.json */ + CBM_TI_RECEIPT_INVALID, /* unreadable shape, unknown schema, a malformed field */ + CBM_TI_RECEIPT_IO, +} cbm_ti_receipt_status_t; + +/* Strict: every field present and well formed, no unknown schema. Clears *out + * first; on any non-OK status *out stays zero. */ +cbm_ti_receipt_status_t cbm_ti_receipt_read(const char *bundle_dir, cbm_ti_receipt_t *out); + +/* Writes receipt.json atomically (temporary file + rename). */ +bool cbm_ti_receipt_write(const char *bundle_dir, const cbm_ti_receipt_t *receipt); + +/* Lowercase SHA-256 hex of a file's exact bytes. false when unreadable. */ +bool cbm_ti_sha256_file(const char *path, char hex[65]); + +/* Versioned digest of what coverage depends on beyond the code it measures: + * the platform/toolchain label and the exact bytes of each listed path under + * root (an absent path is encoded as absent, so adding one changes it). + * Producer and consumer call this same function; equality is the check. */ +bool cbm_ti_compatibility_digest(const char *root, const char *const *paths, int path_count, + const char *platform, char hex[65]); + +#endif /* CBM_TEST_IMPACT_ARTIFACT_H */ diff --git a/src/mcp/test_impact_cli.c b/src/mcp/test_impact_cli.c new file mode 100644 index 000000000..4106d89db --- /dev/null +++ b/src/mcp/test_impact_cli.c @@ -0,0 +1,185 @@ +/* + * test_impact_cli.c — `codebase-memory-mcp test-impact `. + * + * The CI surface of the test-impact engine, in process (no daemon): + * publish builds the team artifact bundle of the checked-out commit; + * select answers which tests to run for the change against a base ref. + * + * A bundle takes part in a selection only with --artifact-verified, which the + * CI job passes after it fetched the bundle from this repository's + * main-branch producer run whose head is --artifact-commit. Nothing inside a + * bundle can assert that. + */ +#include "mcp/test_impact_engine.h" + +#include "foundation/compat_fs.h" +#include "foundation/platform.h" + +#include +#include +#include + +enum { TIC_DEFAULT_DEADLINE_S = 1800 }; + +typedef struct { + const char *name; + const char **value; /* NULL: a flag */ + bool *flag; +} tic_option_t; + +static bool tic_parse(int argc, char **argv, const tic_option_t *options, size_t count) { + for (int i = 0; i < argc; i++) { + const tic_option_t *match = NULL; + for (size_t o = 0; o < count; o++) { + if (strcmp(argv[i], options[o].name) == 0) { + match = &options[o]; + } + } + if (!match) { + (void)fprintf(stderr, "test-impact: unknown argument %s\n", argv[i]); + return false; + } + if (match->flag) { + *match->flag = true; + continue; + } + if (i + 1 >= argc) { + (void)fprintf(stderr, "test-impact: %s needs a value\n", argv[i]); + return false; + } + *match->value = argv[++i]; + } + return true; +} + +static int64_t tic_number(const char *text, int64_t fallback) { + if (!text || !*text) { + return fallback; + } + char *end = NULL; + long long value = strtoll(text, &end, 10); + return end && !*end && value > 0 ? (int64_t)value : -1; +} + +/* /test-impact unless --work names another private parent. */ +static const char *tic_work(const char *requested, char *buf, size_t cap) { + if (requested) { + return requested; + } + snprintf(buf, cap, "%s/test-impact", cbm_resolve_cache_dir()); + return cbm_mkdir_p(buf, 0700) ? buf : NULL; +} + +static int tic_usage(void) { + (void)fprintf(stderr, + "usage: codebase-memory-mcp test-impact publish --repo DIR --out DIR\n" + " [--previous BUNDLE] [--coverage DIR --observed-at EPOCH]\n" + " [--platform LABEL] [--config FILE] [--work DIR] [--deadline-s N]\n" + " codebase-memory-mcp test-impact select --repo DIR --base REF\n" + " [--config FILE] [--artifact BUNDLE --artifact-commit SHA\n" + " --artifact-verified] [--platform LABEL] [--work DIR]\n" + " [--deadline-s N]\n"); + return 2; +} + +static int tic_publish(int argc, char **argv) { + const char *repo = NULL, *out = NULL, *previous = NULL, *coverage = NULL; + const char *observed = NULL, *platform = NULL, *work = NULL, *deadline = NULL; + const char *config = NULL; + const tic_option_t options[] = {{"--config", &config, NULL}, + {"--repo", &repo, NULL}, + {"--out", &out, NULL}, + {"--previous", &previous, NULL}, + {"--coverage", &coverage, NULL}, + {"--observed-at", &observed, NULL}, + {"--platform", &platform, NULL}, + {"--work", &work, NULL}, + {"--deadline-s", &deadline, NULL}}; + if (!tic_parse(argc, argv, options, sizeof(options) / sizeof(options[0])) || !repo || !out) { + return tic_usage(); + } + int64_t observed_at = tic_number(observed, 0); + int64_t seconds = tic_number(deadline, TIC_DEFAULT_DEADLINE_S); + char work_buf[4096]; + const char *work_parent = tic_work(work, work_buf, sizeof(work_buf)); + if (observed_at < 0 || seconds < 0 || !work_parent) { + return tic_usage(); + } + cbm_test_impact_publish_t request = {.repo_root = repo, + .work_parent = work_parent, + .out_dir = out, + .previous_dir = previous, + .coverage_dir = coverage, + .observed_at = observed_at, + .platform = platform, + .config_path = config, + .deadline_ms = cbm_now_ms() + (uint64_t)seconds * 1000ULL}; + char diagnostic[512] = ""; + cbm_test_impact_status_t status = + cbm_test_impact_publish(&request, diagnostic, sizeof(diagnostic)); + (void)fprintf(stderr, "test-impact publish: status %d%s%s\n", (int)status, + diagnostic[0] ? ": " : "", diagnostic); + return status == CBM_TEST_IMPACT_OK ? 0 : 1; +} + +static int tic_select(int argc, char **argv) { + const char *repo = NULL, *base = NULL, *config = NULL, *artifact = NULL; + const char *artifact_commit = NULL, *platform = NULL, *work = NULL, *deadline = NULL; + bool verified = false; + const tic_option_t options[] = {{"--repo", &repo, NULL}, + {"--base", &base, NULL}, + {"--config", &config, NULL}, + {"--artifact", &artifact, NULL}, + {"--artifact-commit", &artifact_commit, NULL}, + {"--artifact-verified", NULL, &verified}, + {"--platform", &platform, NULL}, + {"--work", &work, NULL}, + {"--deadline-s", &deadline, NULL}}; + if (!tic_parse(argc, argv, options, sizeof(options) / sizeof(options[0])) || !repo || !base) { + return tic_usage(); + } + int64_t seconds = tic_number(deadline, TIC_DEFAULT_DEADLINE_S); + char work_buf[4096]; + const char *work_parent = tic_work(work, work_buf, sizeof(work_buf)); + if (seconds < 0 || !work_parent) { + return tic_usage(); + } + cbm_test_impact_request_t request = {.repo_root = repo, + .base_ref = base, + .work_parent = work_parent, + .config_path = config, + .deadline_ms = cbm_now_ms() + (uint64_t)seconds * 1000ULL, + .artifact_dir = artifact, + .artifact_commit = artifact_commit, + .artifact_verified = verified, + .platform = platform}; + cbm_test_result_t *result = NULL; + char diagnostic[512] = ""; + cbm_test_impact_status_t status = + cbm_test_impact_run(&request, &result, diagnostic, sizeof(diagnostic)); + if (diagnostic[0]) { + (void)fprintf(stderr, "test-impact select: %s\n", diagnostic); + } + if (status != CBM_TEST_IMPACT_OK) { + (void)fprintf(stderr, "test-impact select: status %d\n", (int)status); + return 1; + } + size_t length = 0; + const char *json = cbm_test_result_json(result, &length); + bool ok = json && fwrite(json, 1, length, stdout) == length && fputc('\n', stdout) != EOF; + cbm_test_result_free(result); + return ok ? 0 : 1; +} + +int cbm_test_impact_cli(int argc, char **argv) { + if (argc < 1) { + return tic_usage(); + } + if (strcmp(argv[0], "publish") == 0) { + return tic_publish(argc - 1, argv + 1); + } + if (strcmp(argv[0], "select") == 0) { + return tic_select(argc - 1, argv + 1); + } + return tic_usage(); +} diff --git a/src/mcp/test_impact_engine.c b/src/mcp/test_impact_engine.c index 39aece400..391a6d6d7 100644 --- a/src/mcp/test_impact_engine.c +++ b/src/mcp/test_impact_engine.c @@ -43,7 +43,7 @@ bool te_oom(te_ctx_t *c) { return false; } -static void te_note(te_ctx_t *c, const char *text) { +void te_note(te_ctx_t *c, const char *text) { if (c->diagnostic && c->diagnostic_len && !c->diagnostic[0] && text) { snprintf(c->diagnostic, c->diagnostic_len, "%s", text); } @@ -103,7 +103,7 @@ bool cbm_test_impact_find_git(char *out, size_t out_len) { return false; } -static bool te_git(te_ctx_t *c) { +bool te_git(te_ctx_t *c) { char git[4096]; if (!cbm_test_impact_find_git(git, sizeof(git))) { return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GIT_UNAVAILABLE, "no git on PATH") && false; @@ -139,7 +139,7 @@ static bool te_git(te_ctx_t *c) { /* ── Query-time configuration: the merge base's (M-4) ────────────── */ -static bool te_config(te_ctx_t *c) { +bool te_config(te_ctx_t *c) { const char *path = c->rq->config_path; char file[4200] = ""; bool optional = false; @@ -391,7 +391,11 @@ static bool te_select(te_ctx_t *c) { .diff_complete = c->diff_complete, .inventory_complete = c->snapshot_ok, .static_complete = c->static_complete, - .coverage_admitted = false}; + .coverage = c->coverage_admitted ? c->coverage : NULL, + .changed_function_ids = c->cov_ids, + .changed_function_count = c->cov_count, + .coverage_admitted = c->coverage_admitted, + .coverage_changes_complete = c->cov_complete}; c->selection = cbm_test_select(&input); if (!c->selection) { return te_fallback(c, CBM_TEST_RESULT_FALLBACK_SELECTION_UNAVAILABLE, @@ -487,7 +491,7 @@ static void te_receipt(te_ctx_t *c, cbm_test_result_receipt_t *r, *graph_reason = CBM_TEST_RESULT_EVIDENCE_DIAGNOSTICS_INCOMPLETE; r->graph.rejection_reasons = (cbm_test_result_evidence_reasons_t){graph_reason, 1}; } - r->coverage.state = CBM_TEST_RESULT_COVERAGE_UNAVAILABLE; + te_artifact_receipt(c, &r->coverage, te_oid); } static cbm_test_impact_status_t te_answer(te_ctx_t *c, cbm_test_result_t **out) { @@ -548,6 +552,8 @@ static cbm_test_impact_status_t te_answer(te_ctx_t *c, cbm_test_result_t **out) static void te_free(te_ctx_t *c) { cbm_test_selection_free(c->selection); + cbm_coverage_map_free(c->coverage); + cbm_free(CBM_MEM_CLASS_OTHER, c->coverage_meta); cbm_ti_seeds_free(c->seeds); if (c->scope) { (void)cbm_store_read_scope_close(c->scope); @@ -584,10 +590,11 @@ static void te_steps(te_ctx_t *c) { if (!te_classify_rules(c, &need_snapshot, &unmatched) || !need_snapshot) { return; } - if (!te_snapshot(c) || !te_classify_unmatched(c, unmatched) || c->fallback_count) { + if (!te_artifact_graph(c) || !te_snapshot(c) || !te_artifact_coverage(c) || + !te_classify_unmatched(c, unmatched) || c->fallback_count) { return; } - if (!te_open_scope(c) || !te_seed(c) || !te_reach(c)) { + if (!te_open_scope(c) || !te_seed(c) || !te_reach(c) || !te_coverage_ids(c)) { return; } (void)te_select(c); @@ -648,3 +655,49 @@ cbm_test_impact_status_t cbm_test_impact_run(const cbm_test_impact_request_t *re (void)cbm_thread_join(&thread); return job.status; } + +typedef struct { + const cbm_test_impact_publish_t *request; + char *diagnostic; + size_t diagnostic_len; + cbm_test_impact_status_t status; +} te_publish_job_t; + +static void *te_publish_main(void *arg) { + te_publish_job_t *job = arg; + const cbm_test_impact_publish_t *p = job->request; + /* The steps read a selection request; publishing is the empty change of + * HEAD against itself. */ + cbm_test_impact_request_t rq = {.repo_root = p->repo_root, + .base_ref = "HEAD", + .work_parent = p->work_parent, + .config_path = p->config_path, + .deadline_ms = p->deadline_ms}; + te_ctx_t c = {.rq = &rq, .diagnostic = job->diagnostic, .diagnostic_len = job->diagnostic_len}; + cbm_arena_init(&c.arena); + bool ok = te_publish(&c, p); + job->status = c.oom ? CBM_TEST_IMPACT_OOM : ok ? CBM_TEST_IMPACT_OK : CBM_TEST_IMPACT_INVALID; + te_free(&c); + return NULL; +} + +cbm_test_impact_status_t cbm_test_impact_publish(const cbm_test_impact_publish_t *request, + char *diagnostic, size_t diagnostic_len) { + if (diagnostic && diagnostic_len) { + diagnostic[0] = '\0'; + } + if (!request || !request->repo_root || !request->work_parent || !request->out_dir || + !request->deadline_ms) { + return CBM_TEST_IMPACT_INVALID; + } + te_publish_job_t job = {.request = request, + .diagnostic = diagnostic, + .diagnostic_len = diagnostic_len, + .status = CBM_TEST_IMPACT_OOM}; + cbm_thread_t thread; /* a whole frozen index: the default pipeline stack */ + if (cbm_thread_create(&thread, 0, te_publish_main, &job) != 0) { + return CBM_TEST_IMPACT_OOM; + } + (void)cbm_thread_join(&thread); + return job.status; +} diff --git a/src/mcp/test_impact_engine.h b/src/mcp/test_impact_engine.h index 89cc50050..6ce3e51fb 100644 --- a/src/mcp/test_impact_engine.h +++ b/src/mcp/test_impact_engine.h @@ -33,6 +33,20 @@ typedef struct { * (a change cannot loosen its own selection). Otherwise that file. */ const char *config_path; uint64_t deadline_ms; /* absolute cbm_now_ms() deadline */ + + /* Optional team artifact bundle (test_impact_artifact.h) of the merge + * base. It takes part ONLY with artifact_verified: the caller fetched it + * from this repository's main-branch producer whose head is + * artifact_commit. Its graph then becomes the build's incremental base + * and its coverage map may be admitted (receipt policy, age, ancestry, + * compatibility). Without it, or on any doubt, the graph is built from + * scratch and no coverage is admitted: every suite runs whole. */ + const char *artifact_dir; + const char *artifact_commit; + bool artifact_verified; + /* Platform/toolchain label of the leg the answer is for; part of the + * coverage compatibility digest, so a map narrows only its own platform. */ + const char *platform; } cbm_test_impact_request_t; typedef enum { @@ -52,4 +66,32 @@ cbm_test_impact_status_t cbm_test_impact_run(const cbm_test_impact_request_t *re * executable `git` (`git.exe` on Windows) on PATH. false when none. */ bool cbm_test_impact_find_git(char *out, size_t out_len); +/* Builds the team artifact bundle of the checked-out commit of repo_root: + * the commit's frozen graph (incrementally from previous_dir's graph when that + * bundle is given and verifies), the coverage map of this commit when given, + * and receipt.json binding all of it. out_dir must not exist. */ +typedef struct { + const char *repo_root; + const char *work_parent; + const char *out_dir; + const char *previous_dir; /* optional: a bundle of an ancestor */ + const char *coverage_dir; /* optional: functions.tsv, tests.tsv, meta.json */ + /* When the map was recorded (seconds since the epoch). 0 carries the + * previous bundle's oldest observation forward (an incremental merge never + * advances it). */ + int64_t observed_at; + const char *platform; + /* NULL: the commit's own .codebase-memory.json. Otherwise that file, as + * for a selection (its coverage.compatibility_paths go into the receipt). */ + const char *config_path; + uint64_t deadline_ms; +} cbm_test_impact_publish_t; + +cbm_test_impact_status_t cbm_test_impact_publish(const cbm_test_impact_publish_t *request, + char *diagnostic, size_t diagnostic_len); + +/* `codebase-memory-mcp test-impact ...` (test_impact_cli.c); + * argv starts at the subcommand. The process exit code. */ +int cbm_test_impact_cli(int argc, char **argv); + #endif /* CBM_TEST_IMPACT_ENGINE_H */ diff --git a/src/mcp/test_impact_engine_artifact.c b/src/mcp/test_impact_engine_artifact.c new file mode 100644 index 000000000..dee209b89 --- /dev/null +++ b/src/mcp/test_impact_engine_artifact.c @@ -0,0 +1,646 @@ +/* + * test_impact_engine_artifact.c — the team artifact on both ends. + * + * Producer (cbm_test_impact_publish): the main branch builds the bundle of a + * commit — the commit's frozen graph, incrementally from the previous bundle's + * graph when that bundle verifies, its coverage map, and receipt.json. + * + * Consumer (te_artifact_graph, te_artifact_coverage, te_coverage_ids): a PR + * selection given a bundle the caller verified uses the bundle's graph as the + * frozen build's incremental base and may admit its coverage map. Every doubt + * ends in the conservative answer: the graph built from scratch, the map not + * admitted, every suite whole. + */ +#include "mcp/test_impact_engine_internal.h" + +#include "foundation/compat_fs.h" +#include "foundation/mem_core.h" +#include "foundation/platform.h" +#include "mcp/test_impact_artifact.h" +#include "pipeline/artifact.h" +#include "store/store_graph_digest.h" + +#include +#include +#include + +enum { TA_FILE_MAX = 512 << 20 }; + +static void ta_hex(const unsigned char bytes[32], char hex[65]) { + static const char digits[] = "0123456789abcdef"; + for (int i = 0; i < 32; i++) { + hex[2 * i] = digits[bytes[i] >> 4]; + hex[2 * i + 1] = digits[bytes[i] & 15]; + } + hex[64] = '\0'; +} + +static bool ta_unhex(const char *hex, unsigned char bytes[32]) { + if (!hex || strlen(hex) != 64) { + return false; + } + for (int i = 0; i < 64; i++) { + char ch = hex[i]; + int v = ch >= '0' && ch <= '9' ? ch - '0' : ch >= 'a' && ch <= 'f' ? ch - 'a' + 10 : -1; + if (v < 0) { + return false; + } + bytes[i / 2] = (unsigned char)(i % 2 ? (bytes[i / 2] | v) : (v << 4)); + } + return true; +} + +/* The exact graph digest of a database: what an importer of it measures. */ +bool te_db_digest(const char *db_path, char hex[65]) { + hex[0] = '\0'; + cbm_store_t *store = cbm_store_open_path_query(db_path); + if (!store) { + return false; + } + cbm_store_read_scope_t *scope = NULL; + bool ok = cbm_store_read_scope_open(store, NULL, NULL, &scope) == CBM_STORE_OK; + static const unsigned char project[] = "test-impact"; + cbm_store_graph_digest_limits_t limits = {.max_rows = (uint64_t)1 << 32, + .max_framed_bytes = (uint64_t)1 << 40}; + cbm_store_graph_digest_t digest; + ok = ok && cbm_store_graph_digest(scope, project, sizeof(project) - 1, &limits, &digest) == + CBM_STORE_GRAPH_DIGEST_OK; + if (scope && cbm_store_read_scope_close(scope) != CBM_STORE_OK) { + ok = false; + } + cbm_store_close(store); + if (ok) { + ta_hex(digest.sha256, hex); + } + return ok; +} + +/* The graph-content digest (store_content_digest.c): equal for equal graphs + * however they were built; the weekly incremental-equals-full check. */ +static bool te_db_content_digest(const char *db_path, char content[65], char topology[65]) { + content[0] = '\0'; + topology[0] = '\0'; + cbm_store_t *store = cbm_store_open_path_query(db_path); + unsigned char a[32]; + unsigned char b[32]; + bool ok = store && cbm_store_graph_content_digest(store, "test-impact", a) == CBM_STORE_OK && + cbm_store_graph_topology_digest(store, "test-impact", b) == CBM_STORE_OK; + if (store) { + cbm_store_close(store); + } + if (ok) { + ta_hex(a, content); + ta_hex(b, topology); + } + return ok; +} + +/* Imports a bundle's graph to db_path; true only when it is the graph the + * receipt names. */ +static bool te_bundle_import(const char *bundle, const cbm_ti_receipt_t *receipt, + const char *db_path, const char **why) { + if (cbm_artifact_import(bundle, db_path) != 0) { + *why = "the bundle's graph could not be imported"; + return false; + } + char measured[65]; + if (!te_db_digest(db_path, measured)) { + *why = "the bundle's imported graph could not be digested"; + return false; + } + if (strcmp(measured, receipt->graph_sha256) != 0) { + *why = "the bundle's imported graph is not the graph its receipt names"; + return false; + } + return true; +} + +static bool te_read_file(const char *path, char **out, size_t *len) { + *out = NULL; + *len = 0; + FILE *f = cbm_fopen(path, "rb"); + if (!f) { + return false; + } + size_t cap = 65536; + char *buf = cbm_alloc(CBM_MEM_CLASS_OTHER, cap); + size_t n = 0; + bool ok = buf != NULL; + while (ok) { + if (n == cap) { + if (cap >= TA_FILE_MAX) { + ok = false; + break; + } + char *grown = cbm_realloc(CBM_MEM_CLASS_OTHER, buf, cap * 2); + if (!grown) { + ok = false; + break; + } + buf = grown; + cap *= 2; + } + size_t got = fread(buf + n, 1, cap - n, f); + n += got; + if (got == 0) { + ok = !ferror(f); + break; + } + } + (void)fclose(f); + if (!ok) { + cbm_free(CBM_MEM_CLASS_OTHER, buf); + return false; + } + *out = buf; + *len = n; + return true; +} + +/* The coverage map of a bundle (or any directory with the three files). */ +static bool te_load_map(const char *dir, cbm_coverage_map_t **map, char **meta, size_t *meta_len) { + char path[4200]; + char *functions = NULL; + char *tests = NULL; + size_t functions_len = 0; + size_t tests_len = 0; + *map = NULL; + *meta = NULL; + *meta_len = 0; + snprintf(path, sizeof(path), "%s/functions.tsv", dir); + bool ok = te_read_file(path, &functions, &functions_len); + snprintf(path, sizeof(path), "%s/tests.tsv", dir); + ok = ok && te_read_file(path, &tests, &tests_len); + snprintf(path, sizeof(path), "%s/meta.json", dir); + ok = ok && te_read_file(path, meta, meta_len); + *map = ok ? cbm_coverage_map_parse(functions, functions_len, tests, tests_len) : NULL; + cbm_free(CBM_MEM_CLASS_OTHER, functions); + cbm_free(CBM_MEM_CLASS_OTHER, tests); + if (!*map) { + cbm_free(CBM_MEM_CLASS_OTHER, *meta); + *meta = NULL; + *meta_len = 0; + return false; + } + return true; +} + +/* The compatibility digest of this checkout's pinned tree for `platform`, + * over the configured compatibility paths. */ +static bool te_compatibility(te_ctx_t *c, const char *platform, char hex[65]) { + const cbm_pinned_tree_view_t *view = c->tree ? cbm_pinned_tree_view(c->tree) : NULL; + int count = 0; + const char *const *paths = cbm_test_policy_compatibility_paths(c->policy, &count); + return view && platform && *platform && + cbm_ti_compatibility_digest(view->root, paths, count, platform, hex); +} + +/* ── Consumer ──────────────────────────────────────────────────────── */ + +static void te_reject(te_ctx_t *c, cbm_test_result_evidence_reason_t reason, const char *why) { + if (c->coverage_reason_count < + (int)(sizeof(c->coverage_reasons) / sizeof(c->coverage_reasons[0]))) { + bool known = false; + for (int i = 0; i < c->coverage_reason_count; i++) { + known = known || c->coverage_reasons[i] == reason; + } + if (!known) { + c->coverage_reasons[c->coverage_reason_count++] = reason; + } + } + te_note(c, why); +} + +/* Before the snapshot: a verified bundle of the merge base becomes the frozen + * build's incremental base. */ +bool te_artifact_graph(te_ctx_t *c) { + const cbm_test_impact_request_t *rq = c->rq; + if (!rq->artifact_dir) { + return true; + } + c->artifact_requested = true; + if (!rq->artifact_verified || !rq->artifact_commit) { + te_reject(c, CBM_TEST_RESULT_EVIDENCE_SOURCE_UNVERIFIED, + "artifact: the caller did not verify the bundle's source; ignored"); + return true; + } + if (cbm_ti_receipt_read(rq->artifact_dir, &c->receipt) != CBM_TI_RECEIPT_OK) { + te_reject(c, CBM_TEST_RESULT_EVIDENCE_METADATA_INVALID, + "artifact: the bundle's receipt is missing or invalid"); + return true; + } + if (strcmp(c->receipt.commit, rq->artifact_commit) != 0) { + te_reject(c, CBM_TEST_RESULT_EVIDENCE_COMMIT_MISMATCH, + "artifact: the receipt names another commit than the verified source"); + return true; + } + /* v1 admits only the merge base's own bundle: an older ancestor's map + * cannot see what main changed since (recorded follow-up). */ + if (strcmp(c->receipt.commit, c->id->merge_base) != 0) { + te_reject(c, CBM_TEST_RESULT_EVIDENCE_ANCESTRY_UNPROVED, + "artifact: the bundle is not of the merge base"); + return true; + } + snprintf(c->base_db, sizeof(c->base_db), "%s/graph/base.db", c->work_dir); + const char *why = NULL; + if (!te_bundle_import(rq->artifact_dir, &c->receipt, c->base_db, &why)) { + (void)cbm_unlink(c->base_db); + (void)cbm_remove_db_sidecars(c->base_db); + c->base_db[0] = '\0'; + te_reject(c, CBM_TEST_RESULT_EVIDENCE_CONTENT_MISMATCH, why); + return true; + } + c->artifact_graph = true; + return true; +} + +/* After the snapshot: the receipt policy decides whether the map narrows. */ +bool te_artifact_coverage(te_ctx_t *c) { + const cbm_test_impact_request_t *rq = c->rq; + if (!c->artifact_graph) { + return true; + } + if (!c->receipt.has_coverage) { + te_reject(c, CBM_TEST_RESULT_EVIDENCE_PROVIDER_UNAVAILABLE, + "artifact: the bundle carries no coverage map"); + return true; + } + char dir[4200]; + snprintf(dir, sizeof(dir), "%s/%s", rq->artifact_dir, CBM_TI_COVERAGE_DIR); + if (!te_load_map(dir, &c->coverage, &c->coverage_meta, &c->coverage_meta_len)) { + te_reject(c, CBM_TEST_RESULT_EVIDENCE_FORMAT_UNSUPPORTED, + "artifact: the coverage map could not be read"); + return true; + } + char compatibility[65] = ""; + if (!te_compatibility(c, rq->platform, compatibility)) { + te_reject(c, CBM_TEST_RESULT_EVIDENCE_COMPATIBILITY_MISMATCH, + "artifact: no platform to judge the map's compatibility by"); + return true; + } + cbm_coverage_receipt_t receipt = {.commit = c->receipt.commit, + .functions_sha256 = c->receipt.functions_sha256, + .tests_sha256 = c->receipt.tests_sha256, + .metadata_sha256 = c->receipt.metadata_sha256, + .graph_sha256 = c->receipt.graph_sha256, + .compatibility_sha256 = c->receipt.compatibility_sha256, + .oldest_observation_at = c->receipt.oldest_observation_at}; + char measured[65]; + if (!te_db_digest(c->base_db, measured)) { + te_reject(c, CBM_TEST_RESULT_EVIDENCE_CONTENT_MISMATCH, + "artifact: the imported graph could not be digested"); + return true; + } + cbm_coverage_receipt_context_t context = {.source_verified = rq->artifact_verified, + .trusted_commit = rq->artifact_commit, + .ancestor_verified = + strcmp(c->receipt.commit, c->id->merge_base) == 0, + .ancestor_commit = c->receipt.commit, + .ancestor_merge_base = c->id->merge_base, + .merge_base = c->id->merge_base, + .graph_commit = c->receipt.commit, + .graph_sha256 = measured, + .compatibility_sha256 = compatibility, + .now = (int64_t)time(NULL)}; + unsigned reasons = cbm_coverage_map_check_receipt(c->coverage, c->coverage_meta, + c->coverage_meta_len, &receipt, &context); + static const struct { + unsigned bit; + cbm_test_result_evidence_reason_t reason; + } map[] = { + {CBM_COVERAGE_RECEIPT_INVALID, CBM_TEST_RESULT_EVIDENCE_METADATA_INVALID}, + {CBM_COVERAGE_RECEIPT_SOURCE, CBM_TEST_RESULT_EVIDENCE_SOURCE_UNVERIFIED}, + {CBM_COVERAGE_RECEIPT_ANCESTRY, CBM_TEST_RESULT_EVIDENCE_ANCESTRY_UNPROVED}, + {CBM_COVERAGE_RECEIPT_COMMIT, CBM_TEST_RESULT_EVIDENCE_COMMIT_MISMATCH}, + {CBM_COVERAGE_RECEIPT_CONTENT, CBM_TEST_RESULT_EVIDENCE_CONTENT_MISMATCH}, + {CBM_COVERAGE_RECEIPT_AGE, CBM_TEST_RESULT_EVIDENCE_TOO_OLD}, + {CBM_COVERAGE_RECEIPT_COMPATIBILITY, CBM_TEST_RESULT_EVIDENCE_COMPATIBILITY_MISMATCH}, + {CBM_COVERAGE_RECEIPT_METADATA, CBM_TEST_RESULT_EVIDENCE_METADATA_INVALID}}; + for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); i++) { + if (reasons & map[i].bit) { + te_reject(c, map[i].reason, "artifact: the coverage receipt was refused"); + } + } + c->coverage_admitted = reasons == 0; + return true; +} + +/* Fills the coverage half of the answer's receipt. */ +void te_artifact_receipt(te_ctx_t *c, cbm_test_result_coverage_receipt_t *r, + cbm_test_result_oid_t (*oid)(const te_ctx_t *, const char *)) { + memset(r, 0, sizeof(*r)); + if (!c->artifact_requested) { + r->state = CBM_TEST_RESULT_COVERAGE_UNAVAILABLE; + return; + } + r->format = CBM_TEST_RESULT_COVERAGE_FORMAT_FUNCTIONS_V1; + r->artifact = (cbm_test_result_bytes_t){(const unsigned char *)CBM_TI_RECEIPT_SCHEMA, + sizeof(CBM_TI_RECEIPT_SCHEMA) - 1}; + if (c->receipt.commit[0]) { + r->commit = oid(c, c->receipt.commit); + } + struct { + const char *hex; + cbm_test_result_digest_t *out; + } digests[] = {{c->receipt.functions_sha256, &r->identities_sha256}, + {c->receipt.tests_sha256, &r->tests_sha256}, + {c->receipt.metadata_sha256, &r->metadata_sha256}, + {c->receipt.graph_sha256, &r->graph_sha256}, + {c->receipt.compatibility_sha256, &r->compatibility_sha256}}; + for (size_t i = 0; i < sizeof(digests) / sizeof(digests[0]); i++) { + digests[i].out->present = ta_unhex(digests[i].hex, digests[i].out->bytes); + if (!digests[i].out->present) { + memset(digests[i].out->bytes, 0, sizeof(digests[i].out->bytes)); + } + } + if (c->receipt.oldest_observation_at > 0) { + r->oldest_observation_at = + (cbm_test_result_timestamp_t){true, c->receipt.oldest_observation_at}; + } + if (c->coverage_admitted) { + r->state = CBM_TEST_RESULT_COVERAGE_ADMITTED; + return; + } + if (!c->coverage_reason_count) { + te_reject(c, CBM_TEST_RESULT_EVIDENCE_PROVIDER_UNAVAILABLE, NULL); + } + r->state = CBM_TEST_RESULT_COVERAGE_REJECTED; + r->rejection_reasons = + (cbm_test_result_evidence_reasons_t){c->coverage_reasons, (size_t)c->coverage_reason_count}; +} + +/* ── Coverage: the changed function set C_eff (R2.1) ──────────────── + * + * A seed the map knows contributes its own id: the map records every test + * that executed it, spawned children included, so its callers add nothing. + * A seed the map does not know (new code, a type, a macro, a field, code the + * map's image never compiled) contributes the nearest mapped functions on its + * static inbound closure: a test that executes any of them may now reach the + * change. Names deleted at head contribute every mapped function of that + * name. A store failure leaves the set incomplete, which runs everything. */ + +static const char *const te_cov_types[] = {"CALLS", "ASYNC_CALLS", "USAGE", "CALL_REFERENCE", + "READS"}; + +typedef struct { + te_ctx_t *c; + const cbm_coverage_map_t *map; + int *ids; + int count; + int cap; + int64_t *queue; + int queue_len; + int queue_cap; + CBMHashTable *visited; +} te_cov_t; + +static bool te_cov_add(te_cov_t *k, int id) { + if (k->count == k->cap) { + int cap = k->cap ? k->cap * 2 : 64; + int *grown = cbm_arena_alloc(&k->c->arena, (size_t)cap * sizeof(*grown)); + if (!grown) { + return false; + } + if (k->count) { + memcpy(grown, k->ids, (size_t)k->count * sizeof(*grown)); + } + k->ids = grown; + k->cap = cap; + } + k->ids[k->count++] = id; + return true; +} + +static bool te_cov_push(te_cov_t *k, int64_t id) { + char key[24]; + snprintf(key, sizeof(key), "%lld", (long long)id); + if (cbm_ht_get(k->visited, key)) { + return true; + } + char *owned = cbm_arena_strdup(&k->c->arena, key); + if (!owned) { + return false; + } + cbm_ht_set(k->visited, owned, owned); + if (k->queue_len == k->queue_cap) { + int cap = k->queue_cap ? k->queue_cap * 2 : 256; + int64_t *grown = cbm_arena_alloc(&k->c->arena, (size_t)cap * sizeof(*grown)); + if (!grown) { + return false; + } + if (k->queue_len) { + memcpy(grown, k->queue, (size_t)k->queue_len * sizeof(*grown)); + } + k->queue = grown; + k->queue_cap = cap; + } + k->queue[k->queue_len++] = id; + return true; +} + +/* *mapped: the node is a function the map knows (its id was added). */ +static bool te_cov_visit(te_cov_t *k, int64_t id, bool *mapped) { + *mapped = false; + cbm_node_t node = {0}; + if (cbm_store_find_node_by_id(k->c->store, id, &node) != CBM_STORE_OK) { + return false; + } + const cbm_coverage_function_t *f = + node.file_path && node.name + ? cbm_coverage_map_find_function(k->map, node.file_path, node.name) + : NULL; + cbm_node_free_fields(&node); + if (f) { + *mapped = true; + return te_cov_add(k, f->id); + } + return true; +} + +static bool te_cov_expand(te_cov_t *k, int64_t id) { + for (size_t t = 0; t < sizeof(te_cov_types) / sizeof(te_cov_types[0]); t++) { + cbm_edge_t *edges = NULL; + int n = 0; + if (cbm_store_find_edges_by_target_type(k->c->store, id, te_cov_types[t], &edges, &n) != + CBM_STORE_OK) { + return false; + } + bool ok = true; + for (int i = 0; ok && i < n; i++) { + ok = te_cov_push(k, edges[i].source_id); + } + cbm_store_free_edges(edges, n); + if (!ok) { + return false; + } + } + return true; +} + +static int te_cov_int_cmp(const void *a, const void *b) { + int x = *(const int *)a; + int y = *(const int *)b; + return (x > y) - (x < y); +} + +bool te_coverage_ids(te_ctx_t *c) { + c->cov_complete = false; + if (!c->coverage_admitted) { + return true; + } + te_cov_t k = {.c = c, .map = c->coverage, .visited = cbm_ht_create(1024)}; + if (!k.visited) { + return te_oom(c); + } + bool ok = true; + int seed_count = 0; + const int64_t *seeds = cbm_ti_seeds_nodes(c->seeds, &seed_count); + for (int i = 0; ok && i < seed_count; i++) { + ok = te_cov_push(&k, seeds[i]); + } + for (int head = 0; ok && head < k.queue_len; head++) { + bool mapped = false; + ok = te_cov_visit(&k, k.queue[head], &mapped) && + (mapped || te_cov_expand(&k, k.queue[head])); + } + int deleted_count = 0; + const char *const *deleted = cbm_ti_seeds_deleted_names(c->seeds, &deleted_count); + int function_count = 0; + const cbm_coverage_function_t *functions = + cbm_coverage_map_functions(c->coverage, &function_count); + for (int d = 0; ok && d < deleted_count; d++) { + for (int f = 0; ok && f < function_count; f++) { + if (strcmp(functions[f].name, deleted[d]) == 0) { + ok = te_cov_add(&k, functions[f].id); + } + } + } + cbm_ht_free(k.visited); + if (!ok) { + return true; /* incomplete: the selection then runs everything */ + } + if (k.count) { + qsort(k.ids, (size_t)k.count, sizeof(*k.ids), te_cov_int_cmp); + } + int unique = 0; + for (int i = 0; i < k.count; i++) { + if (!unique || k.ids[unique - 1] != k.ids[i]) { + k.ids[unique++] = k.ids[i]; + } + } + c->cov_ids = k.ids; + c->cov_count = unique; + c->cov_complete = true; + return true; +} + +/* ── Producer ──────────────────────────────────────────────────────── */ + +static bool te_copy_file(const char *from, const char *to) { + char *bytes = NULL; + size_t len = 0; + if (!te_read_file(from, &bytes, &len)) { + return false; + } + FILE *out = cbm_fopen(to, "wb"); + bool ok = out && fwrite(bytes, 1, len, out) == len; + if (out) { + ok = fclose(out) == 0 && ok; + } + cbm_free(CBM_MEM_CLASS_OTHER, bytes); + return ok; +} + +static bool te_publish_fail(te_ctx_t *c, const char *why) { + te_note(c, why); + return false; +} + +/* The bundle of the checked-out commit. c->rq is the request te_work_dir, + * te_git, te_config and te_snapshot read (base_ref = HEAD). */ +bool te_publish(te_ctx_t *c, const cbm_test_impact_publish_t *p) { + cbm_path_info_t info; + if (cbm_path_info_utf8(p->out_dir, &info) == 0) { + return te_publish_fail(c, "publish: the output directory already exists"); + } + if (!te_work_dir(c) || !te_git(c) || !te_config(c)) { + return te_publish_fail(c, "publish: git or configuration unavailable"); + } + cbm_ti_receipt_t previous = {0}; + if (p->previous_dir) { + const char *why = NULL; + snprintf(c->base_db, sizeof(c->base_db), "%s/graph/base.db", c->work_dir); + if (cbm_ti_receipt_read(p->previous_dir, &previous) != CBM_TI_RECEIPT_OK || + !te_bundle_import(p->previous_dir, &previous, c->base_db, &why)) { + /* An unusable previous bundle only costs time: build in full. */ + (void)cbm_unlink(c->base_db); + (void)cbm_remove_db_sidecars(c->base_db); + c->base_db[0] = '\0'; + memset(&previous, 0, sizeof(previous)); + } + } + if (!te_snapshot(c)) { + return te_publish_fail(c, "publish: the graph of HEAD could not be built"); + } + /* The store must be closed before export snapshots it. */ + cbm_ti_graph_free(c->graph); + c->graph = NULL; + cbm_store_close(c->store); + c->store = NULL; + if (!cbm_mkdir_p(p->out_dir, 0755) || + cbm_artifact_export(c->candidate_db, p->out_dir, "test-impact", CBM_ARTIFACT_BEST) != 0) { + return te_publish_fail(c, "publish: the graph could not be exported"); + } + cbm_ti_receipt_t r = {0}; + snprintf(r.commit, sizeof(r.commit), "%s", c->id->head); + snprintf(r.platform, sizeof(r.platform), "%s", p->platform ? p->platform : ""); + char check[4200]; + snprintf(check, sizeof(check), "%s/graph/check.db", c->work_dir); + if (cbm_artifact_import(p->out_dir, check) != 0 || !te_db_digest(check, r.graph_sha256) || + !te_db_content_digest(check, r.graph_content_sha256, r.graph_topology_sha256)) { + return te_publish_fail(c, "publish: the exported graph does not import"); + } + (void)cbm_unlink(check); + (void)cbm_remove_db_sidecars(check); + if (p->coverage_dir) { + char dir[4200]; + snprintf(dir, sizeof(dir), "%s/%s", p->out_dir, CBM_TI_COVERAGE_DIR); + static const char *const files[] = {"functions.tsv", "tests.tsv", "meta.json"}; + char *sums[] = {r.functions_sha256, r.tests_sha256, r.metadata_sha256}; + bool ok = cbm_mkdir_p(dir, 0755); + for (size_t i = 0; ok && i < 3; i++) { + char from[4200]; + char to[4200]; + snprintf(from, sizeof(from), "%s/%s", p->coverage_dir, files[i]); + snprintf(to, sizeof(to), "%s/%s", dir, files[i]); + ok = te_copy_file(from, to) && cbm_ti_sha256_file(to, sums[i]); + } + cbm_coverage_map_t *map = NULL; + char *meta = NULL; + size_t meta_len = 0; + ok = ok && te_load_map(dir, &map, &meta, &meta_len) && + cbm_coverage_map_metadata_matches(map, meta, meta_len, r.commit); + cbm_coverage_map_free(map); + cbm_free(CBM_MEM_CLASS_OTHER, meta); + if (!ok) { + return te_publish_fail(c, "publish: the coverage map is not this commit's"); + } + r.oldest_observation_at = p->observed_at > 0 ? p->observed_at + : previous.has_coverage ? previous.oldest_observation_at + : 0; + if (r.oldest_observation_at <= 0 || + !te_compatibility(c, p->platform, r.compatibility_sha256)) { + return te_publish_fail(c, "publish: the map's observation time or platform is unknown"); + } + r.has_coverage = true; + } + if (!cbm_ti_receipt_write(p->out_dir, &r)) { + return te_publish_fail(c, "publish: receipt.json could not be written"); + } + char text[256]; + snprintf(text, sizeof(text), "published %s route=%s topology=%s content=%s", r.commit, + c->route == CBM_PIPELINE_FROZEN_ROUTE_INCREMENTAL ? "incremental" : "full", + r.graph_topology_sha256, r.graph_content_sha256); + te_note(c, text); + return true; +} diff --git a/src/mcp/test_impact_engine_internal.h b/src/mcp/test_impact_engine_internal.h index 2fae9eaf4..604f129d7 100644 --- a/src/mcp/test_impact_engine_internal.h +++ b/src/mcp/test_impact_engine_internal.h @@ -15,6 +15,8 @@ #include "mcp/test_impact_seed.h" #include "mcp/test_impact_source.h" #include "mcp/test_impact_tree.h" +#include "mcp/test_impact_artifact.h" +#include "pipeline/pipeline_internal.h" #include "discover/test_conventions.h" #include "foundation/arena.h" #include "store/store.h" @@ -95,11 +97,33 @@ typedef struct { bool static_complete; cbm_test_selection_t *selection; + + /* Team artifact (test_impact_artifact.h). artifact_graph: a verified + * bundle of the merge base whose imported graph matched its receipt; it is + * the frozen build's incremental base (base_db). */ + bool artifact_requested; + bool artifact_graph; + cbm_ti_receipt_t receipt; + char base_db[4096]; + cbm_pipeline_frozen_route_t route; + cbm_coverage_map_t *coverage; + char *coverage_meta; + size_t coverage_meta_len; + bool coverage_admitted; + cbm_test_result_evidence_reason_t coverage_reasons[16]; + int coverage_reason_count; + int *cov_ids; /* C_eff, ascending (arena) */ + int cov_count; + bool cov_complete; } te_ctx_t; /* Record a run-all reason once; the first diagnostic wins. false only when * out of memory. */ bool te_fallback(te_ctx_t *c, cbm_test_result_fallback_t code, const char *diagnostic); +/* The first diagnostic of the request wins. */ +void te_note(te_ctx_t *c, const char *text); +bool te_git(te_ctx_t *c); +bool te_config(te_ctx_t *c); bool te_grow(void **items, int *cap, int count, size_t size); bool te_oom(te_ctx_t *c); @@ -108,6 +132,15 @@ bool te_work_dir(te_ctx_t *c); bool te_snapshot(te_ctx_t *c); void te_snapshot_free(te_ctx_t *c); +/* test_impact_engine_artifact.c */ +bool te_db_digest(const char *db_path, char hex[65]); +bool te_artifact_graph(te_ctx_t *c); +bool te_artifact_coverage(te_ctx_t *c); +bool te_coverage_ids(te_ctx_t *c); +void te_artifact_receipt(te_ctx_t *c, cbm_test_result_coverage_receipt_t *r, + cbm_test_result_oid_t (*oid)(const te_ctx_t *, const char *)); +bool te_publish(te_ctx_t *c, const cbm_test_impact_publish_t *p); + /* test_impact_engine_reach.c */ bool te_fixtures(te_ctx_t *c, int64_t **extra_seeds, int *extra_count, bool **fixture_cases); bool te_reach(te_ctx_t *c); diff --git a/src/mcp/test_impact_engine_snapshot.c b/src/mcp/test_impact_engine_snapshot.c index 0b5433774..d6b2f2024 100644 --- a/src/mcp/test_impact_engine_snapshot.c +++ b/src/mcp/test_impact_engine_snapshot.c @@ -184,15 +184,21 @@ static bool te_frozen_build(te_ctx_t *c, const char *config_bytes, size_t config .rel_path = (const char *)view->files[rows[i].file_index].path, .language = rows[i].language}; } - /* The context takes mutable strings; these are copies of the pinned facts. */ - char root[4096]; + /* The Git context is display data the build never traverses and the + * engine never reads back. It is fixed, so the candidate is a function of + * the pinned tree alone: the per-run pin path or the commit ids here land + * in the Branch node, make two builds of one tree differ, and turn every + * new commit into a semantic-input change that declines incremental repair + * of the team artifact's graph. The answer's receipt binds the commit. */ + char root[] = "/pinned"; char head[65]; char merge_base[65]; char empty[1] = ""; char branch[] = "pinned"; - snprintf(root, sizeof(root), "%s", view->root); - snprintf(head, sizeof(head), "%s", c->id->head); - snprintf(merge_base, sizeof(merge_base), "%s", c->id->merge_base); + size_t oid_len = strlen(c->id->head) == 64 ? 64 : 40; + memset(head, '0', oid_len); + head[oid_len] = '\0'; + memcpy(merge_base, head, oid_len + 1); cbm_git_context_t git = {.is_git = true, .is_detached = true, .root_exists = true, @@ -214,7 +220,8 @@ static bool te_frozen_build(te_ctx_t *c, const char *config_bytes, size_t config .config_len = config_len, .pinned_git = &git, .files = list, - .file_count = rows_n}; + .file_count = rows_n, + .base_db_path = c->base_db[0] ? c->base_db : NULL}; cbm_pipeline_frozen_t *owner = NULL; cbm_pipeline_frozen_status_t status = cbm_pipeline_frozen_create(&in, &owner); if (status == CBM_PIPELINE_FROZEN_OK) { @@ -237,6 +244,7 @@ static bool te_frozen_build(te_ctx_t *c, const char *config_bytes, size_t config error_count && errors[0].path ? errors[0].path : "", error_count ? " " : "", error_count && errors[0].reason ? errors[0].reason : ""); } + c->route = cbm_pipeline_frozen_route(owner); cbm_pipeline_frozen_free(owner); if (status != CBM_PIPELINE_FROZEN_OK) { return te_snapshot_fail(c, CBM_TEST_RESULT_FALLBACK_GRAPH_REJECTED, @@ -339,6 +347,11 @@ void te_snapshot_free(te_ctx_t *c) { snprintf(side, sizeof(side), "%s-shm", c->candidate_db); te_remove(side); } + if (c->base_db[0]) { + /* The imported team-artifact graph (test_impact_engine_artifact.c). */ + te_remove(c->base_db); + (void)cbm_remove_db_sidecars(c->base_db); + } if (c->work_dir[0]) { char sub[4200]; snprintf(sub, sizeof(sub), "%s/graph", c->work_dir); diff --git a/src/mcp/test_impact_policy.c b/src/mcp/test_impact_policy.c index a85cf5c7c..2b523908c 100644 --- a/src/mcp/test_impact_policy.c +++ b/src/mcp/test_impact_policy.c @@ -9,6 +9,8 @@ enum { TP_MAX_PATTERN = 65536, + TP_MAX_COMPAT_PATHS = 256, + TP_MAX_COMPAT_PATH = 1024, TP_MAX_EXPANSIONS = 4096, TP_MAX_EXPANDED_BYTES = 262144, TP_MAX_BRACE_DEPTH = 16, @@ -70,6 +72,8 @@ struct cbm_test_policy { const char *digest; size_t expansion_count; size_t expanded_bytes; + const char **compat_paths; + int compat_count; }; /* Expansion uses an acyclic graph with shared continuations. Its explicit @@ -719,6 +723,56 @@ static bool tp_add_builtins(cbm_test_policy_t *policy, int first) { return true; } +/* A repository-relative file path: no root, no backslash, no empty, `.` or + * `..` segment, no trailing slash. */ +static bool tp_relative_path(const char *text, size_t len) { + if (!text || !len || len > TP_MAX_COMPAT_PATH || text[0] == '/' || text[len - 1] == '/' || + memchr(text, '\\', len)) + return false; + const char *segment = text; + const char *end = text + len; + while (segment < end) { + const char *slash = memchr(segment, '/', (size_t)(end - segment)); + size_t n = (size_t)((slash ? slash : end) - segment); + if (!n || (n == 1 && segment[0] == '.') || + (n == 2 && segment[0] == '.' && segment[1] == '.')) + return false; + segment += n + 1; + } + return true; +} + +/* `coverage`: what a recorded coverage map depends on beyond the code it + * measures. Strict on keys: an ignored misspelling would make admitting a map + * easier, never harder. */ +static bool tp_parse_coverage(cbm_test_policy_t *policy, yyjson_val *coverage) { + bool valid = true; + yyjson_val *paths = tp_field(coverage, "compatibility_paths", &valid); + if (!valid || yyjson_obj_size(coverage) != (paths ? 1U : 0U) || + (paths && !yyjson_is_arr(paths))) + return false; + size_t count = paths ? yyjson_arr_size(paths) : 0; + if (count > TP_MAX_COMPAT_PATHS) + return false; + policy->compat_paths = + cbm_arena_calloc(&policy->arena, (count ? count : 1) * sizeof(*policy->compat_paths)); + if (!policy->compat_paths) + return false; + size_t index, maximum; + yyjson_val *path; + yyjson_arr_foreach(paths, index, maximum, path) { + size_t len = 0; + const char *text = tp_string(path, &len); + if (!tp_relative_path(text, len)) + return false; + policy->compat_paths[index] = cbm_arena_strdup(&policy->arena, text); + if (!policy->compat_paths[index]) + return false; + } + policy->compat_count = (int)count; + return true; +} + static bool tp_parse_policy(cbm_test_policy_t *policy, yyjson_val *impact) { bool valid = true; yyjson_val *rules = NULL, *lanes = NULL; @@ -727,8 +781,11 @@ static bool tp_parse_policy(cbm_test_policy_t *policy, yyjson_val *impact) { rules = tp_field(impact, "rules", &valid); lanes = tp_field(impact, "lanes", &valid); yyjson_val *unmapped = tp_field(impact, "unmapped_files", &valid); + yyjson_val *coverage = tp_field(impact, "coverage", &valid); if (!valid || !yyjson_is_uint(version) || yyjson_get_uint(version) != 1) return false; + if (coverage && !tp_parse_coverage(policy, coverage)) + return false; if (unmapped) { const char *text = tp_string(unmapped, NULL); if (!text || strcmp(text, "run_all") != 0) @@ -832,6 +889,13 @@ const char *cbm_test_policy_digest(const cbm_test_policy_t *policy) { return policy ? policy->digest : ""; } +const char *const *cbm_test_policy_compatibility_paths(const cbm_test_policy_t *policy, + int *count) { + if (count) + *count = policy ? policy->compat_count : 0; + return policy && policy->compat_count ? (const char *const *)policy->compat_paths : NULL; +} + typedef struct { CBMArena arena; unsigned char *first; diff --git a/src/pipeline/pass_complexity.c b/src/pipeline/pass_complexity.c index ab1574566..614694408 100644 --- a/src/pipeline/pass_complexity.c +++ b/src/pipeline/pass_complexity.c @@ -19,7 +19,11 @@ #include "foundation/log.h" #include "foundation/platform.h" #include "foundation/compat.h" +#include "foundation/arena.h" +#include "foundation/mem_core.h" +#include "store/store.h" #include "cbm.h" +#include "sqlite3.h" #include #include @@ -83,7 +87,7 @@ static void append_complexity_props(cbm_gbuf_node_t *node, int tld, bool recursi return; /* not a JSON object — leave untouched */ } bool empty = (olen == 2); /* "{}" */ - char *neu = malloc(olen + CBM_SZ_64); + char *neu = cbm_alloc(CBM_MEM_CLASS_OTHER, olen + CBM_SZ_64); if (!neu) { return; } @@ -92,11 +96,11 @@ static void append_complexity_props(cbm_gbuf_node_t *node, int tld, bool recursi snprintf(neu + (olen - 1), CBM_SZ_64, "%s\"transitive_loop_depth\":%d,\"recursive\":%s}", empty ? "" : ",", tld, recursive ? "true" : "false"); if (w < 0) { - free(neu); + cbm_free(CBM_MEM_CLASS_OTHER, neu); return; } (void)cbm_gbuf_node_set_properties_json(node, neu); - free(neu); + cbm_free(CBM_MEM_CLASS_OTHER, neu); } /* Content-only node order: qualified_name, then file path and start line. @@ -113,7 +117,13 @@ static const char *str_or_empty(const char *s) { return s ? s : ""; } -static int cmp_node_canonical(const cbm_gbuf_node_t *a, const cbm_gbuf_node_t *b) { +typedef struct { + const char *qualified_name; + const char *file_path; + int start_line; +} tld_key_t; + +static int cmp_key_canonical(const tld_key_t *a, const tld_key_t *b) { int r = strcmp(str_or_empty(a ? a->qualified_name : NULL), str_or_empty(b ? b->qualified_name : NULL)); if (r != 0) { @@ -131,18 +141,80 @@ static int cmp_node_canonical(const cbm_gbuf_node_t *a, const cbm_gbuf_node_t *b return 0; } -static int cmp_seed_canonical(const void *pa, const void *pb) { - return cmp_node_canonical(*(const cbm_gbuf_node_t *const *)pa, - *(const cbm_gbuf_node_t *const *)pb); -} - typedef struct { - const cbm_gbuf_node_t *node; /* NULL for a dangling target id */ + const tld_key_t *key; /* NULL for a dangling target id */ int64_t id; } tld_callee_t; static int cmp_callee_canonical(const void *pa, const void *pb) { - return cmp_node_canonical(((const tld_callee_t *)pa)->node, ((const tld_callee_t *)pb)->node); + return cmp_key_canonical(((const tld_callee_t *)pa)->key, ((const tld_callee_t *)pb)->key); +} + +/* The call graph the traversal reads, whoever gathered it: the graph buffer + * (a full build) or the staging store (the closure-delta incremental route). + * One traversal for both is what keeps an incremental graph equal to a full + * one. Ids index every array directly; first[] is a CSR of CALLS edges by + * source id in gathering order (each frame sorts its callees canonically). */ +typedef struct { + int64_t maxid; + tld_key_t *keys; /* [maxid + 1] */ + bool *present; /* [maxid + 1]: a node exists for the id */ + int *loop_depth; /* [maxid + 1]: seeds only */ + bool *recursive; /* [maxid + 1]: self_recursive, then cycles */ + tld_callee_t *seeds; /* Function/Method nodes */ + int seed_count; + int *first; /* [maxid + 2] */ + int64_t *callee; /* [edge_count] */ + int edge_count; +} tld_graph_t; + +static void tld_graph_free(tld_graph_t *g) { + cbm_free(CBM_MEM_CLASS_OTHER, g->keys); + cbm_free(CBM_MEM_CLASS_OTHER, g->present); + cbm_free(CBM_MEM_CLASS_OTHER, g->loop_depth); + cbm_free(CBM_MEM_CLASS_OTHER, g->recursive); + cbm_free(CBM_MEM_CLASS_OTHER, g->seeds); + cbm_free(CBM_MEM_CLASS_OTHER, g->first); + cbm_free(CBM_MEM_CLASS_OTHER, g->callee); +} + +static bool tld_graph_alloc(tld_graph_t *g, int64_t maxid, int seed_cap, int edge_count) { + size_t sz = (size_t)maxid + 1; + g->maxid = maxid; + g->keys = cbm_calloc(CBM_MEM_CLASS_OTHER, sz * sizeof(*g->keys)); + g->present = cbm_calloc(CBM_MEM_CLASS_OTHER, sz * sizeof(*g->present)); + g->loop_depth = cbm_calloc(CBM_MEM_CLASS_OTHER, sz * sizeof(*g->loop_depth)); + g->recursive = cbm_calloc(CBM_MEM_CLASS_OTHER, sz * sizeof(*g->recursive)); + g->seeds = cbm_calloc(CBM_MEM_CLASS_OTHER, ((size_t)seed_cap + 1) * sizeof(*g->seeds)); + g->first = cbm_calloc(CBM_MEM_CLASS_OTHER, (sz + 1) * sizeof(*g->first)); + g->callee = cbm_calloc(CBM_MEM_CLASS_OTHER, ((size_t)edge_count + 1) * sizeof(*g->callee)); + return g->keys && g->present && g->loop_depth && g->recursive && g->seeds && g->first && + g->callee; +} + +/* Sources must be counted into first[src + 1] before this; turns the counts + * into offsets. */ +static void tld_graph_offsets(tld_graph_t *g) { + for (int64_t id = 1; id <= g->maxid + 1; id++) { + g->first[id] += g->first[id - 1]; + } +} + +static void tld_key_set(tld_graph_t *g, int64_t id, const char *qn, const char *file, int line) { + if (id >= 1 && id <= g->maxid) { + g->keys[id] = (tld_key_t){qn, file, line}; + g->present[id] = true; + } +} + +static void tld_seed(tld_graph_t *g, int64_t id, const char *properties, int seed_cap) { + if (id >= 1 && id <= g->maxid && g->seed_count < seed_cap) { + g->loop_depth[id] = json_get_int(properties, "loop_depth", 0); + g->recursive[id] = json_get_bool(properties, "self_recursive"); + g->seeds[g->seed_count].id = id; + g->seeds[g->seed_count].key = &g->keys[id]; + g->seed_count++; + } } /* Traversal state. `callees` is one bump stack shared by every DFS frame: a @@ -151,62 +223,46 @@ static int cmp_callee_canonical(const void *pa, const void *pb) { * re-entry), so the live slots never exceed the CALLS edge count the stack is * sized for. */ typedef struct { - const cbm_gbuf_t *gb; - int64_t maxid; - int *loop_depth; + tld_graph_t *g; int *tld; char *state; - bool *recursive; - cbm_gbuf_node_t **seeds; /* Function/Method nodes, canonical order */ - int seed_count; - int seed_cap; tld_callee_t *callees; int callee_top; int callee_cap; } tld_ctx_t; -static void tld_ctx_free(tld_ctx_t *cx) { - free(cx->loop_depth); - free(cx->tld); - free(cx->state); - free(cx->recursive); - free(cx->seeds); - free(cx->callees); -} - /* Memoized DFS: tld(id) = loop_depth(id) + max over CALLS-callees of tld(callee). * state: 0=unvisited, 1=in-progress (back-edge -> cycle), 2=done. */ static int tld_dfs(tld_ctx_t *cx, int64_t id, int depth) { - if (id < 1 || id > cx->maxid) { + tld_graph_t *g = cx->g; + if (id < 1 || id > g->maxid) { return 0; } if (cx->state[id] == 2) { return cx->tld[id]; } if (cx->state[id] == 1) { - cx->recursive[id] = true; /* back edge -> call-graph cycle */ + g->recursive[id] = true; /* back edge -> call-graph cycle */ return 0; } if (depth > CBM_TLD_MAX_DEPTH) { - return cx->loop_depth[id]; + return g->loop_depth[id]; } - const cbm_gbuf_edge_t **edges = NULL; - int ne = 0; - cbm_gbuf_find_edges_by_source_type(cx->gb, id, "CALLS", &edges, &ne); + int ne = g->first[id + 1] - g->first[id]; if (ne > cx->callee_cap - cx->callee_top) { - return cx->loop_depth[id]; /* unreachable by construction; same as the depth cap */ + return g->loop_depth[id]; /* unreachable by construction; same as the depth cap */ } cx->state[id] = 1; tld_callee_t *callees = cx->callees + cx->callee_top; int nc = 0; - for (int i = 0; i < ne; i++) { - int64_t c = edges[i]->target_id; + for (int i = g->first[id]; i < g->first[id + 1]; i++) { + int64_t c = g->callee[i]; if (c == id) { - cx->recursive[id] = true; /* direct self-recursion */ + g->recursive[id] = true; /* direct self-recursion */ continue; } callees[nc].id = c; - callees[nc].node = cbm_gbuf_find_by_id(cx->gb, c); + callees[nc].key = c >= 1 && c <= g->maxid && g->present[c] ? &g->keys[c] : NULL; nc++; } cx->callee_top += nc; @@ -219,11 +275,37 @@ static int tld_dfs(tld_ctx_t *cx, int64_t id, int depth) { } } cx->callee_top -= nc; - cx->tld[id] = cx->loop_depth[id] + best; + cx->tld[id] = g->loop_depth[id] + best; cx->state[id] = 2; return cx->tld[id]; } +/* Runs the traversal from every seed in canonical order; *tld_out[id] and + * g->recursive[id] then hold each seed's result. */ +static bool tld_run(tld_graph_t *g, int **tld_out) { + size_t sz = (size_t)g->maxid + 1; + tld_ctx_t cx = {.g = g, .callee_cap = g->edge_count}; + cx.tld = cbm_calloc(CBM_MEM_CLASS_OTHER, sz * sizeof(*cx.tld)); + cx.state = cbm_calloc(CBM_MEM_CLASS_OTHER, sz * sizeof(*cx.state)); + cx.callees = cbm_calloc(CBM_MEM_CLASS_OTHER, ((size_t)g->edge_count + 1) * sizeof(*cx.callees)); + if (!cx.tld || !cx.state || !cx.callees) { + cbm_free(CBM_MEM_CLASS_OTHER, cx.tld); + cbm_free(CBM_MEM_CLASS_OTHER, cx.state); + cbm_free(CBM_MEM_CLASS_OTHER, cx.callees); + return false; + } + qsort(g->seeds, (size_t)g->seed_count, sizeof(*g->seeds), cmp_callee_canonical); + for (int i = 0; i < g->seed_count; i++) { + if (cx.state[g->seeds[i].id] != 2) { + tld_dfs(&cx, g->seeds[i].id, 0); + } + } + cbm_free(CBM_MEM_CLASS_OTHER, cx.state); + cbm_free(CBM_MEM_CLASS_OTHER, cx.callees); + *tld_out = cx.tld; + return true; +} + static int label_count(const cbm_gbuf_t *gb, const char *label) { const cbm_gbuf_node_t **nodes = NULL; int count = 0; @@ -233,75 +315,320 @@ static int label_count(const cbm_gbuf_t *gb, const char *label) { return count; } -static int calls_edge_count(const cbm_gbuf_t *gb) { - const cbm_gbuf_edge_t **edges = NULL; - int count = 0; - if (cbm_gbuf_find_edges_by_type(gb, "CALLS", &edges, &count) != 0) { - return 0; - } - return count; -} - -/* Seed each Function/Method node's loop_depth and self_recursive flag, and - * collect the node as a traversal seed (also the write-back target). The - * self_recursive seed (set at extraction) feeds the final recursive flag; - * tld_dfs additionally ORs in mutual recursion discovered as a call-graph - * cycle. */ -static void seed_loop_depths(tld_ctx_t *cx, const char *label) { - const cbm_gbuf_node_t **nodes = NULL; - int count = 0; - if (cbm_gbuf_find_by_label(cx->gb, label, &nodes, &count) != 0) { - return; - } - for (int i = 0; i < count && cx->seed_count < cx->seed_cap; i++) { - const cbm_gbuf_node_t *n = nodes[i]; - if (n->id >= 1 && n->id <= cx->maxid) { - cx->loop_depth[n->id] = json_get_int(n->properties_json, "loop_depth", 0); - cx->recursive[n->id] = json_get_bool(n->properties_json, "self_recursive"); - cx->seeds[cx->seed_count++] = (cbm_gbuf_node_t *)n; - } - } -} - -void cbm_pipeline_pass_complexity(cbm_pipeline_ctx_t *ctx) { - cbm_gbuf_t *gb = ctx->gbuf; +/* Gathers the graph buffer: every Function/Method node as a seed, every CALLS + * edge, and the key of every node a CALLS edge targets. */ +static bool tld_gather_gbuf(const cbm_gbuf_t *gb, tld_graph_t *g) { /* Node and edge IDs are drawn from one shared counter, so node IDs are NOT * contiguous 1..node_count — they interleave with edge IDs. Size the lookup * arrays by the id ceiling (next_id) so every node id is addressable. */ int64_t maxid = cbm_gbuf_next_id(gb) - 1; - if (maxid < 1) { - return; + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + if (maxid < 1 || cbm_gbuf_find_edges_by_type(gb, "CALLS", &edges, &edge_count) != 0) { + edge_count = 0; } - size_t sz = (size_t)maxid + 1; - tld_ctx_t cx = {0}; - cx.gb = gb; - cx.maxid = maxid; - cx.seed_cap = label_count(gb, "Function") + label_count(gb, "Method"); - cx.callee_cap = calls_edge_count(gb); - cx.loop_depth = calloc(sz, sizeof(int)); - cx.tld = calloc(sz, sizeof(int)); - cx.state = calloc(sz, sizeof(char)); - cx.recursive = calloc(sz, sizeof(bool)); - cx.seeds = calloc((size_t)cx.seed_cap + 1, sizeof(cbm_gbuf_node_t *)); - cx.callees = calloc((size_t)cx.callee_cap + 1, sizeof(tld_callee_t)); - if (!cx.loop_depth || !cx.tld || !cx.state || !cx.recursive || !cx.seeds || !cx.callees) { - tld_ctx_free(&cx); - return; + int seed_cap = label_count(gb, "Function") + label_count(gb, "Method"); + if (maxid < 1 || !tld_graph_alloc(g, maxid, seed_cap, edge_count)) { + return false; } - - seed_loop_depths(&cx, "Function"); - seed_loop_depths(&cx, "Method"); - qsort(cx.seeds, (size_t)cx.seed_count, sizeof(*cx.seeds), cmp_seed_canonical); - - for (int i = 0; i < cx.seed_count; i++) { - cbm_gbuf_node_t *n = cx.seeds[i]; - if (cx.state[n->id] != 2) { - tld_dfs(&cx, n->id, 0); + static const char *const labels[] = {"Function", "Method"}; + for (size_t l = 0; l < sizeof(labels) / sizeof(labels[0]); l++) { + const cbm_gbuf_node_t **nodes = NULL; + int count = 0; + if (cbm_gbuf_find_by_label(gb, labels[l], &nodes, &count) != 0) { + continue; + } + for (int i = 0; i < count; i++) { + const cbm_gbuf_node_t *n = nodes[i]; + tld_key_set(g, n->id, n->qualified_name, n->file_path, n->start_line); + tld_seed(g, n->id, n->properties_json, seed_cap); } - append_complexity_props(n, cx.tld[n->id], cx.recursive[n->id]); } + for (int i = 0; i < edge_count; i++) { + int64_t source = edges[i]->source_id; + int64_t target = edges[i]->target_id; + if (source >= 1 && source <= maxid) { + g->first[source + 1]++; + } + const cbm_gbuf_node_t *t = cbm_gbuf_find_by_id(gb, target); + if (t && !(target >= 1 && target <= maxid && g->present[target])) { + tld_key_set(g, target, t->qualified_name, t->file_path, t->start_line); + } + } + tld_graph_offsets(g); + int *fill = cbm_calloc(CBM_MEM_CLASS_OTHER, ((size_t)maxid + 1) * sizeof(*fill)); + if (!fill) { + return false; + } + for (int i = 0; i < edge_count; i++) { + int64_t source = edges[i]->source_id; + if (source >= 1 && source <= maxid) { + g->callee[g->first[source] + fill[source]++] = edges[i]->target_id; + } + } + cbm_free(CBM_MEM_CLASS_OTHER, fill); + g->edge_count = edge_count; + return true; +} - cbm_log_info("pass.complexity", "functions", itoa_cx(cx.seed_count)); +void cbm_pipeline_pass_complexity(cbm_pipeline_ctx_t *ctx) { + cbm_gbuf_t *gb = ctx->gbuf; + tld_graph_t g = {0}; + int *tld = NULL; + if (!tld_gather_gbuf(gb, &g) || !tld_run(&g, &tld)) { + tld_graph_free(&g); + return; + } + for (int i = 0; i < g.seed_count; i++) { + int64_t id = g.seeds[i].id; + cbm_gbuf_node_t *n = (cbm_gbuf_node_t *)cbm_gbuf_find_by_id(gb, id); + if (n) { + append_complexity_props(n, tld[id], g.recursive[id]); + } + } + cbm_log_info("pass.complexity", "functions", itoa_cx(g.seed_count)); + cbm_free(CBM_MEM_CLASS_OTHER, tld); + tld_graph_free(&g); +} - tld_ctx_free(&cx); +/* ── Store-level recompute (closure-delta incremental) ──────────────── + * + * The closure-delta route patches the staging store with the re-extracted + * files only; transitive_loop_depth and recursive are properties of the whole + * call graph, so a body edit can move them for callers in files that were not + * re-parsed, and the re-parsed nodes arrive without them. Like importance, the + * recompute therefore runs over the complete staging store after the patch, + * with the same traversal as the in-memory pass (only the gathering differs) + * and BEFORE the importance rescore, so the keys land where a full build puts + * them. Existing keys are rewritten in place; absent ones are appended. A + * failure fails the delta route, which then rebuilds in full. */ + +/* properties with transitive_loop_depth/recursive set: rewritten in place + * when present, appended exactly as the in-memory pass appends otherwise. + * NULL: allocation failure or a malformed existing pair. */ +static char *complexity_set_props(const char *old, int tld, bool recursive) { + static const char key[] = "\"transitive_loop_depth\":"; + static const char rec_key[] = ",\"recursive\":"; + const char *props = old ? old : "{}"; + size_t olen = strlen(props); + char value[CBM_SZ_64]; + int w = + snprintf(value, sizeof(value), "%s%d%s%s", key, tld, rec_key, recursive ? "true" : "false"); + if (w < 0 || (size_t)w >= sizeof(value)) { + return NULL; + } + const char *at = strstr(props, key); + size_t head = 0; + size_t tail = 0; /* offset where the old pair ends */ + const char *sep = ""; + if (at) { + const char *p = at + sizeof(key) - 1; + if (*p == '-') { + p++; + } + while (*p >= '0' && *p <= '9') { + p++; + } + if (strncmp(p, rec_key, sizeof(rec_key) - 1) != 0) { + return NULL; + } + p += sizeof(rec_key) - 1; + if (strncmp(p, "true", 4) == 0) { + p += 4; + } else if (strncmp(p, "false", 5) == 0) { + p += 5; + } else { + return NULL; + } + head = (size_t)(at - props); + tail = (size_t)(p - props); + } else { + if (olen < 2 || props[olen - 1] != '}') { + return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, props); /* not an object: untouched */ + } + head = olen - 1; + tail = olen - 1; + sep = olen == 2 ? "" : ","; + } + size_t len = head + strlen(sep) + (size_t)w + (olen - tail); + char *neu = cbm_alloc(CBM_MEM_CLASS_OTHER, len + 1); + if (!neu) { + return NULL; + } + memcpy(neu, props, head); + size_t off = head; + memcpy(neu + off, sep, strlen(sep)); + off += strlen(sep); + memcpy(neu + off, value, (size_t)w); + off += (size_t)w; + memcpy(neu + off, props + tail, olen - tail); + neu[len] = '\0'; + return neu; +} + +/* UDF: cbm_cx_set(properties, tld, recursive). */ +static void sql_complexity_set(sqlite3_context *c, int argc, sqlite3_value **argv) { + if (argc != 3) { + sqlite3_result_error(c, "cbm_cx_set arity", -1); + return; + } + char *neu = complexity_set_props((const char *)sqlite3_value_text(argv[0]), + sqlite3_value_int(argv[1]), sqlite3_value_int(argv[2]) != 0); + if (!neu) { + sqlite3_result_error(c, "cbm_cx_set: malformed complexity properties", -1); + return; + } + sqlite3_result_text(c, neu, -1, SQLITE_TRANSIENT); + cbm_free(CBM_MEM_CLASS_OTHER, neu); +} + +static bool tld_step_int64(sqlite3_stmt *st, int64_t *out) { + if (sqlite3_step(st) != SQLITE_ROW) { + return false; + } + *out = sqlite3_column_int64(st, 0); + return true; +} + +/* Gathers the staging store the way tld_gather_gbuf gathers the buffer. Key + * strings are copied into `arena`. */ +static bool tld_gather_store(sqlite3 *db, const char *project, CBMArena *arena, tld_graph_t *g) { + static const char kMax[] = "SELECT COALESCE(MAX(id), 0) FROM nodes WHERE project = ?1"; + static const char kCounts[] = + "SELECT (SELECT COUNT(*) FROM nodes WHERE project = ?1 AND label IN " + "('Function','Method')), (SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = " + "'CALLS')"; + static const char kNodes[] = + "SELECT id, label, qualified_name, file_path, start_line, properties FROM nodes " + "WHERE project = ?1 AND (label IN ('Function','Method') OR id IN (SELECT target_id " + "FROM edges WHERE project = ?1 AND type = 'CALLS'))"; + static const char kEdges[] = + "SELECT source_id, target_id FROM edges WHERE project = ?1 AND type = 'CALLS' ORDER BY id"; + sqlite3_stmt *st = NULL; + int64_t maxid = 0; + bool ok = sqlite3_prepare_v2(db, kMax, -1, &st, NULL) == SQLITE_OK && + sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT) == SQLITE_OK && + tld_step_int64(st, &maxid); + sqlite3_finalize(st); + st = NULL; + int seed_cap = 0; + int edge_count = 0; + ok = ok && sqlite3_prepare_v2(db, kCounts, -1, &st, NULL) == SQLITE_OK && + sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_step(st) == SQLITE_ROW; + if (ok) { + seed_cap = sqlite3_column_int(st, 0); + edge_count = sqlite3_column_int(st, 1); + } + sqlite3_finalize(st); + st = NULL; + if (!ok || maxid < 1) { + return ok; /* an empty project: nothing to recompute */ + } + if (!tld_graph_alloc(g, maxid, seed_cap, edge_count)) { + return false; + } + ok = sqlite3_prepare_v2(db, kNodes, -1, &st, NULL) == SQLITE_OK && + sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT) == SQLITE_OK; + int rc = SQLITE_ROW; + while (ok && (rc = sqlite3_step(st)) == SQLITE_ROW) { + int64_t id = sqlite3_column_int64(st, 0); + const char *label = (const char *)sqlite3_column_text(st, 1); + const char *qn = (const char *)sqlite3_column_text(st, 2); + const char *file = (const char *)sqlite3_column_text(st, 3); + char *qn_copy = qn ? cbm_arena_strdup(arena, qn) : NULL; + char *file_copy = file ? cbm_arena_strdup(arena, file) : NULL; + ok = (!qn || qn_copy) && (!file || file_copy) && id >= 1 && id <= maxid; + if (!ok) { + break; + } + tld_key_set(g, id, qn_copy, file_copy, sqlite3_column_int(st, 4)); + if (label && (strcmp(label, "Function") == 0 || strcmp(label, "Method") == 0)) { + tld_seed(g, id, (const char *)sqlite3_column_text(st, 5), seed_cap); + } + } + ok = ok && rc == SQLITE_DONE; + sqlite3_finalize(st); + st = NULL; + int64_t *sources = + ok ? cbm_alloc(CBM_MEM_CLASS_OTHER, ((size_t)edge_count + 1) * sizeof(*sources)) : NULL; + ok = ok && sources && sqlite3_prepare_v2(db, kEdges, -1, &st, NULL) == SQLITE_OK && + sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT) == SQLITE_OK; + int n = 0; + while (ok && (rc = sqlite3_step(st)) == SQLITE_ROW) { + int64_t source = sqlite3_column_int64(st, 0); + int64_t target = sqlite3_column_int64(st, 1); + ok = n < edge_count && source >= 1 && source <= maxid; + if (ok) { + sources[n] = source; + g->callee[n] = target; + g->first[source + 1]++; + n++; + } + } + ok = ok && rc == SQLITE_DONE && n == edge_count; + sqlite3_finalize(st); + if (ok) { + /* Rows arrived in edge order; place each one by its source. */ + tld_graph_offsets(g); + int64_t *ordered = cbm_alloc(CBM_MEM_CLASS_OTHER, ((size_t)n + 1) * sizeof(*ordered)); + int *fill = cbm_calloc(CBM_MEM_CLASS_OTHER, ((size_t)maxid + 1) * sizeof(*fill)); + ok = ordered && fill; + for (int i = 0; ok && i < n; i++) { + ordered[g->first[sources[i]] + fill[sources[i]]++] = g->callee[i]; + } + if (ok) { + memcpy(g->callee, ordered, (size_t)n * sizeof(*ordered)); + } + cbm_free(CBM_MEM_CLASS_OTHER, ordered); + cbm_free(CBM_MEM_CLASS_OTHER, fill); + } + cbm_free(CBM_MEM_CLASS_OTHER, sources); + g->edge_count = n; + return ok; +} + +int cbm_pipeline_complexity_recompute_store(cbm_store_t *store, const char *project) { + sqlite3 *db = store && project ? cbm_store_get_db(store) : NULL; + if (!db) { + return -1; + } + if (sqlite3_create_function(db, "cbm_cx_set", 3, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, + sql_complexity_set, NULL, NULL) != SQLITE_OK) { + cbm_log_error("complexity.store_udf_failed", "err", sqlite3_errmsg(db)); + return -1; + } + CBMArena arena; + cbm_arena_init_lazy(&arena, CBM_ARENA_DEFAULT_BLOCK_SIZE); + tld_graph_t g = {0}; + int *tld = NULL; + bool ok = tld_gather_store(db, project, &arena, &g) && (!g.maxid || tld_run(&g, &tld)); + if (ok && g.seed_count && cbm_store_begin(store) == CBM_STORE_OK) { + sqlite3_stmt *st = NULL; + ok = sqlite3_prepare_v2(db, + "UPDATE nodes SET properties = cbm_cx_set(properties, ?1, ?2) " + "WHERE id = ?3", + -1, &st, NULL) == SQLITE_OK; + for (int i = 0; ok && i < g.seed_count; i++) { + int64_t id = g.seeds[i].id; + ok = sqlite3_bind_int(st, 1, tld[id]) == SQLITE_OK && + sqlite3_bind_int(st, 2, g.recursive[id] ? 1 : 0) == SQLITE_OK && + sqlite3_bind_int64(st, 3, id) == SQLITE_OK && sqlite3_step(st) == SQLITE_DONE && + sqlite3_reset(st) == SQLITE_OK; + } + sqlite3_finalize(st); + if (!ok) { + cbm_log_error("complexity.store_update_failed", "err", sqlite3_errmsg(db)); + (void)cbm_store_rollback(store); + } else { + ok = cbm_store_commit(store) == CBM_STORE_OK; + } + } else if (ok && g.seed_count) { + ok = false; + } + cbm_log_info("pass.complexity_store", "functions", itoa_cx(g.seed_count)); + cbm_free(CBM_MEM_CLASS_OTHER, tld); + tld_graph_free(&g); + cbm_arena_destroy(&arena); + return ok ? 0 : -1; } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 5aebed6c0..139c68cf3 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -291,6 +291,9 @@ struct cbm_pipeline_frozen { bool has_list; cbm_pipeline_frozen_file_t *listed; size_t listed_count; + /* Arena copy of inputs->base_db_path; NULL builds from scratch. */ + const char *base_db_path; + cbm_pipeline_frozen_route_t route; char canonical_root[CBM_SZ_4K]; char canonical_parent[CBM_SZ_4K]; #if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS @@ -449,7 +452,8 @@ static bool frozen_inputs_valid(const cbm_pipeline_frozen_inputs_t *in) { return in && frozen_absolute(in->source_root) && frozen_absolute(in->candidate_db_path) && frozen_string(in->project, true) && cbm_validate_project_name(in->project) && frozen_git_valid(in->pinned_git) && frozen_policy_valid(&in->resource_policy) && - frozen_config_span_valid(in) && frozen_files_valid(in); + frozen_config_span_valid(in) && frozen_files_valid(in) && + (!in->base_db_path || frozen_absolute(in->base_db_path)); } static int frozen_listed_cmp(const void *left, const void *right) { @@ -656,6 +660,12 @@ cbm_pipeline_frozen_status_t cbm_pipeline_frozen_create(const cbm_pipeline_froze owner->arena = arena; owner->external_cancelled = inputs->cancelled; cbm_pipeline_frozen_status_t status = frozen_copy_list(owner, inputs); + if (status == CBM_PIPELINE_FROZEN_OK && inputs->base_db_path) { + owner->base_db_path = cbm_arena_strdup(&owner->arena, inputs->base_db_path); + if (!owner->base_db_path) { + status = CBM_PIPELINE_FROZEN_OOM; + } + } if (status == CBM_PIPELINE_FROZEN_OK) { status = frozen_allocate_pipeline(owner, inputs); } @@ -676,6 +686,11 @@ const cbm_pipeline_t *cbm_pipeline_frozen_diagnostics(const cbm_pipeline_frozen_ return owner ? owner->pipeline : NULL; } +cbm_pipeline_frozen_route_t cbm_pipeline_frozen_route(const cbm_pipeline_frozen_t *owner) { + return owner && owner->state == FROZEN_CANDIDATE ? owner->route + : CBM_PIPELINE_FROZEN_ROUTE_FULL; +} + bool cbm_pipeline_frozen_negative_evidence(const cbm_pipeline_frozen_t *owner, unsigned *out_flags) { if (out_flags) @@ -3521,9 +3536,13 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { } /* Check for existing DB → try incremental or delete for reindex */ - rc = p->frozen ? CBM_PIPELINE_FORCE_FULL_REINDEX - : try_incremental_or_delete_db(p, files, file_count, baseline_manifest, - baseline_count, mode_promoted); + rc = p->frozen && !p->frozen->base_db_path + ? CBM_PIPELINE_FORCE_FULL_REINDEX + : try_incremental_or_delete_db(p, files, file_count, baseline_manifest, baseline_count, + mode_promoted); + if (p->frozen && rc >= 0) { + p->frozen->route = CBM_PIPELINE_FROZEN_ROUTE_INCREMENTAL; + } if (rc == CBM_PIPELINE_ABORT_PRESERVE_DB || rc == CBM_PIPELINE_PERSIST_FAILED) { goto cleanup; } @@ -4417,8 +4436,16 @@ static cbm_pipeline_frozen_status_t frozen_run_stage(cbm_pipeline_t *p, const ch if (!stage_copy) return CBM_PIPELINE_FROZEN_OOM; p->db_path = stage_copy; - p->existing_generation = false; - p->final_existed = false; + /* A base candidate becomes the stage's existing generation, so the route + * below may repair it instead of rebuilding. */ + bool based = p->frozen->base_db_path != NULL; + if (based && cbm_store_backup_path(p->frozen->base_db_path, stage_copy) != CBM_STORE_OK) { + p->db_path = destination; + cbm_free(CBM_MEM_CLASS_OTHER, stage_copy); + return CBM_PIPELINE_FROZEN_IO; + } + p->existing_generation = based; + p->final_existed = based; int rc = cbm_pipeline_run_staged(p); p->db_path = destination; cbm_free(CBM_MEM_CLASS_OTHER, stage_copy); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 3aae16c25..f2d5d603f 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1412,7 +1412,7 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed * a full build takes, which is what makes its output converge. */ #define MIN_FILES_FOR_PARALLEL_INCR 50 - int worker_count = cbm_default_worker_count(true); + int worker_count = cbm_pipeline_is_frozen(ctx->pipeline) ? 1 : cbm_default_worker_count(true); bool use_parallel = closure != NULL || (worker_count > SKIP_ONE && ci > MIN_FILES_FOR_PARALLEL_INCR); @@ -2515,6 +2515,14 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char * FORCE_FULL_REINDEX, so the run degrades to a correct full rebuild * rather than publishing wrong scores. */ cbm_clock_gettime(CLOCK_MONOTONIC, &t); + if (cbm_pipeline_complexity_recompute_store(staging, project) != 0) { + cbm_log_error("delta.err", "phase", "complexity_recompute"); + goto out; + } + cbm_log_info("pass.timing", "pass", "delta_complexity_store", "elapsed_ms", + itoa_buf((int)elapsed_ms(t))); + + cbm_clock_gettime(CLOCK_MONOTONIC, &t); if (cbm_pipeline_importance_recompute_store(staging, project) != 0) { cbm_log_error("delta.err", "phase", "importance_recompute"); goto out; diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 7186fd345..e0c37f781 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -90,8 +90,22 @@ typedef struct { * there is INPUT_CHANGED. NULL with 0 discovers source_root. Copied. */ const cbm_pipeline_frozen_file_t *files; size_t file_count; + + /* Optional: an earlier candidate of the SAME project label (the team + * artifact's graph, imported). The build copies it into its stage and routes + * through the incremental closure repair; every doubt there declines to the + * full build, so the candidate is the full build's graph either way. + * Absolute, a regular SQLite file outside source_root; never modified. + * NULL builds from scratch. Copied. */ + const char *base_db_path; } cbm_pipeline_frozen_inputs_t; +/* Which route built the candidate. A base whose repair declined is FULL. */ +typedef enum { + CBM_PIPELINE_FROZEN_ROUTE_FULL = 0, + CBM_PIPELINE_FROZEN_ROUTE_INCREMENTAL = 1, +} cbm_pipeline_frozen_route_t; + /* Clear *out first. Deep-copy/validate all input; no Git/config filesystem * reads, no pipeline/global changes, no directory/DB creation. Caller input * buffers may be destroyed after return. NULL result on any failure. @@ -128,6 +142,8 @@ void cbm_pipeline_frozen_cancel(cbm_pipeline_frozen_t *owner); * pass this to a mutating ordinary-pipeline API. No completeness getter exists. */ const cbm_pipeline_t *cbm_pipeline_frozen_diagnostics(const cbm_pipeline_frozen_t *owner); +/* FULL until a build produced a candidate; then the route that built it. */ +cbm_pipeline_frozen_route_t cbm_pipeline_frozen_route(const cbm_pipeline_frozen_t *owner); /* Monotone negative observations; zero is NOT a completeness certificate. * PARSE_GAP alone does not refuse the candidate: a file that parsed only @@ -1070,6 +1086,11 @@ void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score); * a delta-route failure and falls back to a full rebuild. */ int cbm_pipeline_importance_recompute_store(cbm_store_t *store, const char *project); +/* transitive_loop_depth/recursive over the complete staging store of the + * closure-delta route (the in-memory pass's traversal, gathered from SQL). + * Runs before the importance rescore. 0 on success. */ +int cbm_pipeline_complexity_recompute_store(cbm_store_t *store, const char *project); + /* Work counters for the importance pass (pass_importance.c). Deltas are read * by the complexity suite's linearity gate; never reset by the pass itself, so * nested/repeated runs compose. g_importance_name_visits counts same-name-group diff --git a/src/store/store.h b/src/store/store.h index 6a90c0073..291faabdd 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -414,6 +414,16 @@ void cbm_store_close(cbm_store_t *s); /* Get the underlying sqlite3 handle (for testing only). */ struct sqlite3 *cbm_store_get_db(cbm_store_t *s); +/* The digest of a project's graph CONTENT (store_content_digest.c): rows keyed + * by what they say, never by row id, database identity, generation or mtime. + * Two graphs of the same tree, built incrementally or in full, are equal + * exactly when their content digests are. out: 32 raw SHA-256 bytes. */ +int cbm_store_graph_content_digest(cbm_store_t *s, const char *project, unsigned char out[32]); +/* The part of the content test selection reads: node identity, every edge by + * endpoints and type without properties, except the corpus-statistical + * SIMILAR_TO / SEMANTICALLY_RELATED; file hashes; index coverage. */ +int cbm_store_graph_topology_digest(cbm_store_t *s, const char *project, unsigned char out[32]); + /* Get the last error message (static string, valid until next call). */ const char *cbm_store_error(cbm_store_t *s); diff --git a/src/store/store_content_digest.c b/src/store/store_content_digest.c new file mode 100644 index 000000000..c9287adee --- /dev/null +++ b/src/store/store_content_digest.c @@ -0,0 +1,178 @@ +/* + * store_content_digest.c — the digest of a project's graph CONTENT. + * + * cbm_store_graph_digest binds one database exactly: row ids, the random + * db_uid, generation counters and file mtimes included. That is right for + * proving an imported copy is the published one, and useless for asking + * whether two independently built graphs are the same graph. This digest + * answers the second question: every row is keyed by what it says, never by + * where it was stored. Nodes by their own columns; edges by the content keys + * of their endpoints; file hashes by path, content hash and size; LSP + * surfaces, coverage rows and summaries without their timestamps. An + * incremental index equals a full one exactly when their content digests are + * equal. + */ +#include "store/store.h" + +#include "foundation/sha256.h" +#include "sqlite3.h" + +#include +#include +#include +#include + +typedef struct { + const char *name; + const char *sql; /* ?1 = project; every column is hashed, rows in this order */ +} sc_query_t; + +static const sc_query_t sc_queries[] = { + {"nodes", "SELECT label,name,qualified_name,file_path,start_line,end_line,properties " + "FROM main.nodes WHERE project=?1 ORDER BY 3,1,4,5,6,2,7;"}, + {"edges", "SELECT s.label,s.qualified_name,s.file_path,s.start_line,e.type,t.label," + "t.qualified_name,t.file_path,t.start_line,e.properties,e.url_path_gen," + "e.local_name_gen FROM main.edges e JOIN main.nodes s ON s.id=e.source_id " + "JOIN main.nodes t ON t.id=e.target_id WHERE e.project=?1 " + "ORDER BY 2,1,3,4,5,7,6,8,9,10,11,12;"}, + {"dangling_edges", "SELECT COUNT(*) FROM main.edges e WHERE e.project=?1 AND (NOT EXISTS " + "(SELECT 1 FROM main.nodes n WHERE n.id=e.source_id) OR NOT EXISTS " + "(SELECT 1 FROM main.nodes n WHERE n.id=e.target_id));"}, + {"file_hashes", "SELECT rel_path,sha256,size FROM main.file_hashes WHERE project=?1 " + "ORDER BY 1,2,3;"}, + {"lsp_surface", "SELECT rel_path,surface_sha,defs_json,ref_bloom,config_ctx FROM " + "main.lsp_surface WHERE project=?1 ORDER BY 1,2,3,4,5;"}, + {"index_coverage", "SELECT rel_path,kind,detail FROM main.index_coverage WHERE project=?1 " + "ORDER BY 1,2,3;"}, + {"index_coverage_meta", "SELECT index_mode,recording_status,ignored_files_stored," + "ignored_files_total,coverage_version,hash_records_complete FROM " + "main.index_coverage_meta WHERE project=?1;"}, + {"project_summaries", "SELECT summary,source_hash FROM main.project_summaries WHERE " + "project=?1;"}, +}; + +/* What test selection reads: node identity and every edge by its endpoints + * and type, but no edge properties, and not the two corpus-statistical edge + * types (similarity over the whole corpus, which the closure-repair route + * recomputes only around the repaired files). */ +static const sc_query_t sc_topology_queries[] = { + {"nodes", "SELECT label,name,qualified_name,file_path,start_line,end_line " + "FROM main.nodes WHERE project=?1 ORDER BY 3,1,4,5,6,2;"}, + {"edges", "SELECT s.label,s.qualified_name,s.file_path,s.start_line,e.type,t.label," + "t.qualified_name,t.file_path,t.start_line,e.url_path_gen,e.local_name_gen " + "FROM main.edges e JOIN main.nodes s ON s.id=e.source_id " + "JOIN main.nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type NOT IN " + "('SIMILAR_TO','SEMANTICALLY_RELATED') ORDER BY 2,1,3,4,5,7,6,8,9,10,11;"}, + {"file_hashes", "SELECT rel_path,sha256,size FROM main.file_hashes WHERE project=?1 " + "ORDER BY 1,2,3;"}, + {"index_coverage", "SELECT rel_path,kind,detail FROM main.index_coverage WHERE project=?1 " + "ORDER BY 1,2,3;"}, +}; + +static void sc_frame(cbm_sha256_ctx *ctx, unsigned char tag, const void *bytes, uint64_t length) { + unsigned char head[9]; + head[0] = tag; + for (int i = 0; i < 8; i++) { + head[1 + i] = (unsigned char)(length >> (56 - 8 * i)); + } + cbm_sha256_update(ctx, head, sizeof(head)); + if (length) { + cbm_sha256_update(ctx, bytes, (size_t)length); + } +} + +static bool sc_table(sqlite3 *db, const sc_query_t *q, const char *project, cbm_sha256_ctx *ctx) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, q->sql, -1, &st, NULL) != SQLITE_OK) { + return false; + } + bool ok = sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT) == SQLITE_OK; + sc_frame(ctx, 'T', q->name, strlen(q->name)); + uint64_t rows = 0; + int rc = SQLITE_ROW; + while (ok && (rc = sqlite3_step(st)) == SQLITE_ROW) { + int columns = sqlite3_column_count(st); + unsigned char width[4] = {(unsigned char)(columns >> 24), (unsigned char)(columns >> 16), + (unsigned char)(columns >> 8), (unsigned char)columns}; + sc_frame(ctx, 'R', width, sizeof(width)); + for (int c = 0; c < columns; c++) { + switch (sqlite3_column_type(st, c)) { + case SQLITE_NULL: + sc_frame(ctx, 'N', NULL, 0); + break; + case SQLITE_INTEGER: { + char text[32]; + long long value = (long long)sqlite3_column_int64(st, c); + int n = snprintf(text, sizeof(text), "%lld", value); + sc_frame(ctx, 'I', text, (uint64_t)n); + break; + } + case SQLITE_FLOAT: { + char text[64]; + double value = sqlite3_column_double(st, c); + int n = snprintf(text, sizeof(text), "%.17g", value); + sc_frame(ctx, 'F', text, (uint64_t)n); + break; + } + case SQLITE_BLOB: + sc_frame(ctx, 'B', sqlite3_column_blob(st, c), + (uint64_t)sqlite3_column_bytes(st, c)); + break; + default: + sc_frame(ctx, 'S', sqlite3_column_text(st, c), + (uint64_t)sqlite3_column_bytes(st, c)); + break; + } + } + rows++; + } + ok = ok && rc == SQLITE_DONE; + sqlite3_finalize(st); + unsigned char count[8]; + for (int i = 0; i < 8; i++) { + count[i] = (unsigned char)(rows >> (56 - 8 * i)); + } + sc_frame(ctx, 'C', count, sizeof(count)); + return ok; +} + +static int sc_digest(cbm_store_t *s, const char *project, const char *domain, + const sc_query_t *queries, size_t query_count, + unsigned char out[CBM_SHA256_DIGEST_LEN]) { + sqlite3 *db = s && project ? cbm_store_get_db(s) : NULL; + if (!db || !out) { + return CBM_STORE_ERR; + } + cbm_sha256_ctx ctx; + cbm_sha256_init(&ctx); + sc_frame(&ctx, 'D', domain, strlen(domain)); + /* One read transaction, so every table is read from the same snapshot. */ + bool own = sqlite3_get_autocommit(db) != 0; + if (own && sqlite3_exec(db, "BEGIN", NULL, NULL, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + bool ok = true; + for (size_t i = 0; ok && i < query_count; i++) { + ok = sc_table(db, &queries[i], project, &ctx); + } + if (own && sqlite3_exec(db, ok ? "COMMIT" : "ROLLBACK", NULL, NULL, NULL) != SQLITE_OK) { + ok = false; + } + if (!ok) { + return CBM_STORE_ERR; + } + cbm_sha256_final(&ctx, out); + return CBM_STORE_OK; +} + +int cbm_store_graph_content_digest(cbm_store_t *s, const char *project, + unsigned char out[CBM_SHA256_DIGEST_LEN]) { + return sc_digest(s, project, "cbm.graph.content.v1", sc_queries, + sizeof(sc_queries) / sizeof(sc_queries[0]), out); +} + +int cbm_store_graph_topology_digest(cbm_store_t *s, const char *project, + unsigned char out[CBM_SHA256_DIGEST_LEN]) { + return sc_digest(s, project, "cbm.graph.topology.v1", sc_topology_queries, + sizeof(sc_topology_queries) / sizeof(sc_topology_queries[0]), out); +} diff --git a/tests/test_coverage_merge.sh b/tests/test_coverage_merge.sh new file mode 100644 index 000000000..61c1bb818 --- /dev/null +++ b/tests/test_coverage_merge.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# test_coverage_merge.sh — contract of scripts/test-impact/coverage-merge.py, +# the incremental coverage map of a main-branch push: re-run suites come from +# the fresh map verbatim, every other listed suite is carried with its function +# ids remapped by (file, name) onto the new table, a function the new image no +# longer has drops out, an unlisted suite is dropped, and the metadata is +# recounted so the C reader checks it like a full map. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MERGE="$ROOT/scripts/test-impact/coverage-merge.py" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } + +mkdir -p "$TMP/previous" "$TMP/fresh" +# Previous image: f, g, gone. Suite a covers f+g, b covers g+gone, old is gone. +printf '0\tsrc/x.c\tf\n1\tsrc/x.c\tg\n2\tsrc/y.c\tgone\n' > "$TMP/previous/functions.tsv" +printf 'a:*\tcomplete\t\t\na:t1\tcomplete\t\t0 1\nb:*\tcomplete\t\t\nb:t2\tincomplete\tkilled child\t1 2\nold:*\tcomplete\t\t\nold:t3\tcomplete\t\t0\n' \ + > "$TMP/previous/tests.tsv" +cat > "$TMP/previous/meta.json" <<'EOF' +{"format": 1, "commit": "1111111111111111111111111111111111111111", "platform": "p", + "llvm_profdata": "v", "functions": 3, "functions_compiled": 3, "tests": 3, "incomplete": 1, + "suites": [{"suite": "a", "tests": 1, "incomplete": 0, "exit": 0, "wall_s": 1.0}, + {"suite": "b", "tests": 1, "incomplete": 1, "exit": 0, "wall_s": 2.0}, + {"suite": "old", "tests": 1, "incomplete": 0, "exit": 0, "wall_s": 3.0}]} +EOF +# New image: ids shifted, `gone` removed, `h` new. Only suite a re-ran. +printf '0\tsrc/x.c\th\n1\tsrc/x.c\tf\n2\tsrc/x.c\tg\n' > "$TMP/fresh/functions.tsv" +printf 'a:*\tcomplete\t\t\na:t1\tcomplete\t\t0 1\n' > "$TMP/fresh/tests.tsv" +cat > "$TMP/fresh/meta.json" <<'EOF' +{"format": 1, "commit": "2222222222222222222222222222222222222222", "platform": "p", + "llvm_profdata": "v", "functions": 3, "functions_compiled": 3, "tests": 1, "incomplete": 0, + "suites": [{"suite": "a", "tests": 1, "incomplete": 0, "exit": 0, "wall_s": 1.5}]} +EOF +printf 'a\nb\n' > "$TMP/listed.txt" + +python3 "$MERGE" --previous "$TMP/previous" --fresh "$TMP/fresh" --suites "$TMP/listed.txt" \ + --commit 3333333333333333333333333333333333333333 --out "$TMP/out" > /dev/null + +cmp -s "$TMP/out/functions.tsv" "$TMP/fresh/functions.tsv" || fail "the function table is not the new image's" +expected="$(printf 'a:*\tcomplete\t\t\na:t1\tcomplete\t\t0 1\nb:*\tcomplete\t\t\nb:t2\tincomplete\tkilled child\t2\n')" +[ "$(cat "$TMP/out/tests.tsv")" = "$expected" ] || fail "merged rows: $(cat "$TMP/out/tests.tsv")" +python3 - "$TMP/out/meta.json" <<'EOF' || fail "merged metadata" +import json, sys +meta = json.load(open(sys.argv[1])) +assert meta["commit"] == "3" * 40, meta["commit"] +assert (meta["tests"], meta["incomplete"]) == (2, 1), (meta["tests"], meta["incomplete"]) +suites = {s["suite"]: s for s in meta["suites"]} +assert sorted(suites) == ["a", "b"], sorted(suites) +assert (suites["a"]["tests"], suites["a"]["wall_s"]) == (1, 1.5), suites["a"] +assert (suites["b"]["tests"], suites["b"]["incomplete"]) == (1, 1), suites["b"] +EOF + +# A re-run suite the new runner does not list is an inconsistent input. +printf 'b\n' > "$TMP/listed-wrong.txt" +if python3 "$MERGE" --previous "$TMP/previous" --fresh "$TMP/fresh" --suites "$TMP/listed-wrong.txt" \ + --commit 3333333333333333333333333333333333333333 --out "$TMP/out2" > /dev/null 2>&1; then + fail "a re-run suite missing from the runner's list was accepted" +fi +echo "coverage merge: all cases passed" diff --git a/tests/test_daemon_bootstrap.c b/tests/test_daemon_bootstrap.c index 5e4ef2873..040459c7b 100644 --- a/tests/test_daemon_bootstrap.c +++ b/tests/test_daemon_bootstrap.c @@ -304,6 +304,8 @@ TEST(daemon_bootstrap_classifies_stateless_commands_without_client) { char *install[] = {"codebase-memory-mcp", "install", "--dry-run", NULL}; char *uninstall[] = {"codebase-memory-mcp", "uninstall", NULL}; char *update[] = {"codebase-memory-mcp", "update", "-n", NULL}; + char *test_impact[] = {"codebase-memory-mcp", "test-impact", "select", NULL}; + ASSERT_EQ(classify(3, test_impact), CBM_DAEMON_PROCESS_STATELESS); ASSERT_EQ(classify(2, version), CBM_DAEMON_PROCESS_STATELESS); ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS); ASSERT_EQ(classify(3, install), CBM_DAEMON_PROCESS_STATELESS); diff --git a/tests/test_main.c b/tests/test_main.c index c2a09e4be..5f1014628 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1734,6 +1734,7 @@ extern void suite_test_impact_classify(void); extern void suite_test_impact_source(void); extern void suite_test_impact_seed(void); extern void suite_test_impact_engine(void); +extern void suite_test_impact_artifact(void); extern void suite_test_impact_runner_filter(void); extern void suite_store_search(void); extern void suite_cypher(void); @@ -2124,6 +2125,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(test_impact_source); RUN_SELECTED_SUITE(test_impact_seed); RUN_SELECTED_SUITE(test_impact_engine); + RUN_SELECTED_SUITE(test_impact_artifact); RUN_SELECTED_SUITE(test_impact_runner_filter); RUN_SELECTED_SUITE(store_search); RUN_SELECTED_SUITE(store_bulk); diff --git a/tests/test_pipeline_frozen.c b/tests/test_pipeline_frozen.c index 028856d7c..c4fe336d1 100644 --- a/tests/test_pipeline_frozen.c +++ b/tests/test_pipeline_frozen.c @@ -1624,6 +1624,89 @@ TEST(pipeline_frozen_rejects_malformed_file_lists) { PASS(); } +/* A base candidate (the team artifact's graph) is repaired incrementally when + * the closure route can, rebuilt when it declines, and either way the result + * is exactly the graph a build from scratch makes of the same tree. */ +/* Two independent builds never share row ids or a database identity, so they + * are compared by graph content. */ +static bool pf_digest(const char *db, unsigned char out[32]) { + cbm_store_t *store = cbm_store_open_path_query(db); + if (!store) + return false; + bool ok = cbm_store_graph_content_digest(store, PF_PROJECT, out) == CBM_STORE_OK; + cbm_store_close(store); + return ok; +} + +/* Builds the source tree into `keep` (renamed from the candidate); base may be + * NULL. *route receives the route that built it. */ +static bool pf_build_into(pf_fixture_t *f, const char *base, const char *keep, + cbm_pipeline_frozen_route_t *route) { + cbm_git_context_t git = pf_git(f); + cbm_pipeline_frozen_inputs_t in = pf_inputs(f, NULL, &git, NULL); + in.base_db_path = base; + cbm_pipeline_frozen_t *owner = NULL; + bool ok = cbm_pipeline_frozen_create(&in, &owner) == CBM_PIPELINE_FROZEN_OK && + pf_build_expected_ok(owner) == CBM_PIPELINE_FROZEN_OK; + *route = cbm_pipeline_frozen_route(owner); + cbm_pipeline_frozen_free(owner); + return ok && rename(f->db, keep) == 0; +} + +TEST(pipeline_frozen_base_repairs_or_rebuilds_to_the_full_graph) { + ASSERT_EQ(pf_init_status, 0); + pf_fixture_t f = {0}; + ASSERT_TRUE(pf_open(&f)); + char base[1024], repaired[1024], full[1024], grown[1024], grown_full[1024]; + ASSERT_TRUE(pf_path(base, sizeof(base), f.home, "base.db")); + ASSERT_TRUE(pf_path(repaired, sizeof(repaired), f.home, "repaired.db")); + ASSERT_TRUE(pf_path(full, sizeof(full), f.home, "full.db")); + ASSERT_TRUE(pf_path(grown, sizeof(grown), f.home, "grown.db")); + ASSERT_TRUE(pf_path(grown_full, sizeof(grown_full), f.home, "grown_full.db")); + ASSERT_TRUE(pf_source_write(&f, "helper.c", "int helper(int x) { return x + 1; }\n")); + ASSERT_TRUE( + pf_source_write(&f, "use.c", "int helper(int x);\nint use(void) { return helper(1); }\n")); + cbm_pipeline_frozen_route_t route = CBM_PIPELINE_FROZEN_ROUTE_INCREMENTAL; + ASSERT_TRUE(pf_build_into(&f, NULL, base, &route)); + ASSERT_EQ(route, CBM_PIPELINE_FROZEN_ROUTE_FULL); + + /* A body edit: the closure route repairs the base. */ + ASSERT_TRUE(pf_source_write(&f, "helper.c", "int helper(int x) { return x + 2; }\n")); + ASSERT_TRUE(pf_build_into(&f, base, repaired, &route)); + ASSERT_EQ(route, CBM_PIPELINE_FROZEN_ROUTE_INCREMENTAL); + ASSERT_TRUE(pf_build_into(&f, NULL, full, &route)); + ASSERT_EQ(route, CBM_PIPELINE_FROZEN_ROUTE_FULL); + unsigned char a[32], b[32], c[32]; + ASSERT_TRUE(pf_digest(repaired, a)); + ASSERT_TRUE(pf_digest(full, b)); + ASSERT_MEM_EQ(a, b, 32); + ASSERT_TRUE(pf_digest(base, c)); /* the digest sees the edit at all */ + ASSERT_TRUE(memcmp(a, c, 32) != 0); + + /* A new definition name: yesterday's graph cannot know its referencers, so + * the route declines and the build is the full one. */ + ASSERT_TRUE(pf_source_write(&f, "helper.c", + "int helper(int x) { return x + 2; }\nint extra(void) { " + "return 3; }\n")); + ASSERT_TRUE(pf_build_into(&f, base, grown, &route)); + ASSERT_EQ(route, CBM_PIPELINE_FROZEN_ROUTE_FULL); + ASSERT_TRUE(pf_build_into(&f, NULL, grown_full, &route)); + ASSERT_TRUE(pf_digest(grown, a)); + ASSERT_TRUE(pf_digest(grown_full, b)); + ASSERT_MEM_EQ(a, b, 32); + + /* The base is read, never written. */ + char again[1024]; + ASSERT_TRUE(pf_path(again, sizeof(again), f.home, "again.db")); + unsigned char before[32], after[32]; + ASSERT_TRUE(pf_digest(base, before)); + ASSERT_TRUE(pf_build_into(&f, base, again, &route)); + ASSERT_TRUE(pf_digest(base, after)); + ASSERT_MEM_EQ(before, after, 32); + ASSERT_TRUE(pf_close(&f)); + PASS(); +} + SUITE(pipeline_frozen) { RUN_TEST(pipeline_frozen_real_parse_gap_is_recorded_not_refused); pf_init_status = cbm_init(); @@ -1641,5 +1724,6 @@ SUITE(pipeline_frozen) { RUN_TEST(pipeline_frozen_indexes_exactly_the_listed_files); RUN_TEST(pipeline_frozen_missing_listed_file_is_input_changed); RUN_TEST(pipeline_frozen_rejects_malformed_file_lists); + RUN_TEST(pipeline_frozen_base_repairs_or_rebuilds_to_the_full_graph); cbm_work_arena_release(); } diff --git a/tests/test_test_impact.c b/tests/test_test_impact.c index 18f649456..35869dafd 100644 --- a/tests/test_test_impact.c +++ b/tests/test_test_impact.c @@ -2500,6 +2500,54 @@ TEST(test_policy_defaults_and_first_match_before_target_resolution) { /* A change to the selection's own configuration runs everything, in every * repository, before any project rule could ignore it (review M-4). */ +/* test_impact.coverage.compatibility_paths: kept in order; strict on keys + * (an ignored misspelling would make admitting a coverage map easier) and on + * paths (repository-relative, no escaping segment). */ +TEST(test_policy_coverage_compatibility_paths_are_strict) { + cbm_test_policy_t *policy = + policy_of("{\"test_impact\":{\"version\":1,\"coverage\":{\"compatibility_paths\":" + "[\"tests/test_framework.h\",\"Makefile.cbm\"]}}}"); + ASSERT_NOT_NULL(policy); + int count = 0; + const char *const *paths = cbm_test_policy_compatibility_paths(policy, &count); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(paths[0], "tests/test_framework.h"); + ASSERT_STR_EQ(paths[1], "Makefile.cbm"); + cbm_test_policy_free(policy); + + policy = policy_of("{\"test_impact\":{\"version\":1}}"); + ASSERT_NOT_NULL(policy); + ASSERT_NULL(cbm_test_policy_compatibility_paths(policy, &count)); + ASSERT_EQ(count, 0); + cbm_test_policy_free(policy); + + static const char *const refused[] = { + "{\"compatability_paths\":[\"a\"]}", + "{\"compatibility_paths\":[\"a\"],\"extra\":1}", + "{\"compatibility_paths\":\"a\"}", + "{\"compatibility_paths\":[\"/etc/passwd\"]}", + "{\"compatibility_paths\":[\"../outside\"]}", + "{\"compatibility_paths\":[\"a/./b\"]}", + "{\"compatibility_paths\":[\"a//b\"]}", + "{\"compatibility_paths\":[\"dir/\"]}", + "{\"compatibility_paths\":[\"a\\\\b\"]}", + "{\"compatibility_paths\":[\"\"]}", + "{\"compatibility_paths\":[7]}", + "[]", + }; + for (size_t i = 0; i < sizeof(refused) / sizeof(refused[0]); i++) { + char body[256]; + snprintf(body, sizeof(body), "{\"test_impact\":{\"version\":1,\"coverage\":%s}}", + refused[i]); + cbm_test_policy_t *bad = policy_of(body); + if (bad) { + printf(" accepted: %s\n", refused[i]); + } + ASSERT_NULL(bad); + } + PASS(); +} + TEST(test_policy_config_change_runs_all) { cbm_test_policy_t *policy = policy_of("{\"test_impact\":{\"version\":1,\"rules\":[{\"id\":\"late\"," @@ -3697,6 +3745,7 @@ SUITE(test_impact) { RUN_TEST(test_policy_embedded_stars_do_not_cross_or_remove_separators); RUN_TEST(test_policy_retains_exact_snapshot_and_owns_result); RUN_TEST(test_policy_defaults_and_first_match_before_target_resolution); + RUN_TEST(test_policy_coverage_compatibility_paths_are_strict); RUN_TEST(test_policy_config_change_runs_all); RUN_TEST(test_policy_lanes_and_semantic_perf_membership); RUN_TEST(test_policy_globs_braces_escapes_and_directory_boundaries); diff --git a/tests/test_test_impact_artifact.c b/tests/test_test_impact_artifact.c new file mode 100644 index 000000000..c92ebc390 --- /dev/null +++ b/tests/test_test_impact_artifact.c @@ -0,0 +1,168 @@ +/* + * test_test_impact_artifact.c — the team artifact receipt and its digests. + */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "mcp/test_impact_artifact.h" + +#include +#include + +static bool tia_write(const char *dir, const char *name, const char *text) { + char path[1024]; + snprintf(path, sizeof(path), "%s/%s", dir, name); + FILE *f = fopen(path, "wb"); + if (!f) { + return false; + } + bool ok = fputs(text, f) >= 0; + return fclose(f) == 0 && ok; +} + +static cbm_ti_receipt_t tia_receipt(bool coverage) { + cbm_ti_receipt_t r = {0}; + memset(r.commit, 'a', 40); + memset(r.graph_sha256, 'b', 64); + memset(r.graph_content_sha256, '9', 64); + memset(r.graph_topology_sha256, '8', 64); + snprintf(r.platform, sizeof(r.platform), "linux-x86_64-clang"); + r.has_coverage = coverage; + if (coverage) { + memset(r.functions_sha256, 'c', 64); + memset(r.tests_sha256, 'd', 64); + memset(r.metadata_sha256, 'e', 64); + memset(r.compatibility_sha256, 'f', 64); + r.oldest_observation_at = 1791150000; + } + return r; +} + +TEST(test_impact_artifact_receipt_round_trips) { + char *dir = th_mktempdir("cbm_tia_rt"); + ASSERT_NOT_NULL(dir); + for (int coverage = 0; coverage < 2; coverage++) { + cbm_ti_receipt_t in = tia_receipt(coverage); + ASSERT_TRUE(cbm_ti_receipt_write(dir, &in)); + cbm_ti_receipt_t out; + ASSERT_EQ(cbm_ti_receipt_read(dir, &out), CBM_TI_RECEIPT_OK); + ASSERT_STR_EQ(out.commit, in.commit); + ASSERT_STR_EQ(out.graph_sha256, in.graph_sha256); + ASSERT_STR_EQ(out.graph_content_sha256, in.graph_content_sha256); + ASSERT_STR_EQ(out.graph_topology_sha256, in.graph_topology_sha256); + ASSERT_STR_EQ(out.platform, in.platform); + ASSERT_EQ(out.has_coverage, in.has_coverage); + ASSERT_STR_EQ(out.functions_sha256, in.functions_sha256); + ASSERT_STR_EQ(out.compatibility_sha256, in.compatibility_sha256); + ASSERT_EQ(out.oldest_observation_at, in.oldest_observation_at); + } + th_rmtree(dir); + PASS(); +} + +TEST(test_impact_artifact_receipt_is_strict) { + char *dir = th_mktempdir("cbm_tia_strict"); + ASSERT_NOT_NULL(dir); + cbm_ti_receipt_t out; + ASSERT_EQ(cbm_ti_receipt_read(dir, &out), CBM_TI_RECEIPT_ABSENT); + static const char *const bad[] = { + "not json", + "{}", + /* unknown schema */ + "{\"schema\":\"cbm.test_impact.artifact.v2\",\"commit\":\"" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"graph_sha256\":\"" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"graph_content_" + "sha256\":\"9999999999999999999999999999999999999999999999999999999999999999\"," + "\"graph_topology_sha256\":" + "\"8888888888888888888888888888888888888888888888888888888888888888\",\"platform\":\"p\"," + "\"coverage\":null}", + /* a commit of the wrong length */ + "{\"schema\":\"cbm.test_impact.artifact.v1\",\"commit\":\"abc\",\"graph_sha256\":\"" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"graph_content_" + "sha256\":\"9999999999999999999999999999999999999999999999999999999999999999\"," + "\"graph_topology_sha256\":" + "\"8888888888888888888888888888888888888888888888888888888888888888\",\"platform\":\"p\"," + "\"coverage\":null}", + /* uppercase digest */ + "{\"schema\":\"cbm.test_impact.artifact.v1\",\"commit\":\"" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"graph_sha256\":\"" + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\",\"graph_content_" + "sha256\":\"9999999999999999999999999999999999999999999999999999999999999999\"," + "\"graph_topology_sha256\":" + "\"8888888888888888888888888888888888888888888888888888888888888888\",\"platform\":\"p\"," + "\"coverage\":null}", + /* coverage without its observation time */ + "{\"schema\":\"cbm.test_impact.artifact.v1\",\"commit\":\"" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"graph_sha256\":\"" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"graph_content_" + "sha256\":\"9999999999999999999999999999999999999999999999999999999999999999\"," + "\"graph_topology_sha256\":" + "\"8888888888888888888888888888888888888888888888888888888888888888\",\"platform\":\"p\"," + "\"coverage\":{\"functions_sha256\":\"" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"tests_sha256\":\"" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"metadata_sha256\":\"" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"," + "\"compatibility_sha256\":\"" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"}}", + }; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + ASSERT_TRUE(tia_write(dir, CBM_TI_RECEIPT_FILE, bad[i])); + ASSERT_EQ(cbm_ti_receipt_read(dir, &out), CBM_TI_RECEIPT_INVALID); + ASSERT_EQ(out.commit[0], '\0'); + } + th_rmtree(dir); + PASS(); +} + +TEST(test_impact_artifact_compatibility_digest_binds_every_input) { + char *dir = th_mktempdir("cbm_tia_compat"); + ASSERT_NOT_NULL(dir); + ASSERT_TRUE(tia_write(dir, "harness.h", "int x;\n")); + const char *paths[] = {"harness.h", "missing.mk"}; + char base[65], again[65], other[65]; + ASSERT_TRUE(cbm_ti_compatibility_digest(dir, paths, 2, "linux", base)); + ASSERT_TRUE(cbm_ti_compatibility_digest(dir, paths, 2, "linux", again)); + ASSERT_STR_EQ(base, again); + /* another platform */ + ASSERT_TRUE(cbm_ti_compatibility_digest(dir, paths, 2, "darwin", other)); + ASSERT_TRUE(strcmp(base, other) != 0); + /* a byte of a listed file */ + ASSERT_TRUE(tia_write(dir, "harness.h", "int y;\n")); + ASSERT_TRUE(cbm_ti_compatibility_digest(dir, paths, 2, "linux", other)); + ASSERT_TRUE(strcmp(base, other) != 0); + ASSERT_TRUE(tia_write(dir, "harness.h", "int x;\n")); + /* an absent listed file appearing, even empty */ + ASSERT_TRUE(tia_write(dir, "missing.mk", "")); + ASSERT_TRUE(cbm_ti_compatibility_digest(dir, paths, 2, "linux", other)); + ASSERT_TRUE(strcmp(base, other) != 0); + /* the list itself */ + ASSERT_TRUE(cbm_ti_compatibility_digest(dir, paths, 1, "linux", other)); + ASSERT_TRUE(strcmp(base, other) != 0); + /* an empty listed path is refused, not hashed */ + const char *empty[] = {""}; + ASSERT_FALSE(cbm_ti_compatibility_digest(dir, empty, 1, "linux", other)); + th_rmtree(dir); + PASS(); +} + +TEST(test_impact_artifact_file_digest_is_exact_bytes) { + char *dir = th_mktempdir("cbm_tia_sha"); + ASSERT_NOT_NULL(dir); + ASSERT_TRUE(tia_write(dir, "f", "abc")); + char path[1024]; + snprintf(path, sizeof(path), "%s/f", dir); + char hex[65]; + ASSERT_TRUE(cbm_ti_sha256_file(path, hex)); + ASSERT_STR_EQ(hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + snprintf(path, sizeof(path), "%s/absent", dir); + ASSERT_FALSE(cbm_ti_sha256_file(path, hex)); + th_rmtree(dir); + PASS(); +} + +SUITE(test_impact_artifact) { + RUN_TEST(test_impact_artifact_receipt_round_trips); + RUN_TEST(test_impact_artifact_receipt_is_strict); + RUN_TEST(test_impact_artifact_compatibility_digest_binds_every_input); + RUN_TEST(test_impact_artifact_file_digest_is_exact_bytes); +} diff --git a/tests/test_test_impact_engine.c b/tests/test_test_impact_engine.c index 8dbaa2d14..7bae6388b 100644 --- a/tests/test_test_impact_engine.c +++ b/tests/test_test_impact_engine.c @@ -19,6 +19,7 @@ #include #include #include +#include typedef struct { char git[1024]; @@ -152,12 +153,18 @@ static void tie_close(tie_fixture_t *fx) { (void)th_rmtree(fx->home); } -/* The answer for the topic branch against main, as JSON (owned). */ -static char *tie_answer(tie_fixture_t *fx) { +/* The answer for the topic branch against main, as JSON (owned); with a + * team artifact bundle when one is given. */ +static char *tie_answer_with(tie_fixture_t *fx, const char *bundle, const char *commit, + bool verified) { cbm_test_impact_request_t rq = {.repo_root = fx->repo, .base_ref = "main", .work_parent = fx->work, - .deadline_ms = cbm_now_ms() + 120000U}; + .deadline_ms = cbm_now_ms() + 120000U, + .artifact_dir = bundle, + .artifact_commit = commit, + .artifact_verified = verified, + .platform = "fixture"}; cbm_test_result_t *out = NULL; char diagnostic[512]; if (cbm_test_impact_run(&rq, &out, diagnostic, sizeof(diagnostic)) != CBM_TEST_IMPACT_OK) { @@ -174,6 +181,10 @@ static char *tie_answer(tie_fixture_t *fx) { return copy; } +static char *tie_answer(tie_fixture_t *fx) { + return tie_answer_with(fx, NULL, NULL, false); +} + /* The "reasons" array of one suite's entry in the answer. */ static bool tie_suite_has(const char *json, const char *suite, const char *reason) { char key[128]; @@ -297,9 +308,159 @@ TEST(test_impact_engine_parse_gaps_reach_what_they_name) { PASS(); } +/* The base commit's object id, read from the branch ref (never packed here). */ +static bool tie_base_sha(tie_fixture_t *fx, char sha[65]) { + char path[1200]; + snprintf(path, sizeof(path), "%s/.git/refs/heads/main", fx->repo); + FILE *f = fopen(path, "rb"); + if (!f) { + return false; + } + size_t n = fread(sha, 1, 64, f); + (void)fclose(f); + while (n && (sha[n - 1] == '\n' || sha[n - 1] == '\r')) { + n--; + } + sha[n] = '\0'; + return n == 40 || n == 64; +} + +/* A coverage map of the base: lib_value runs only under alpha_uses_lib, + * lib_other only under matrix_one; every setup row is complete and empty. */ +static bool tie_coverage(tie_fixture_t *fx, const char *sha, char *dir, size_t cap) { + snprintf(dir, cap, "%s/coverage", fx->home); + char meta[1024]; + snprintf(meta, sizeof(meta), + "{\"format\":1,\"commit\":\"%s\",\"functions\":2,\"tests\":4," + "\"incomplete\":0,\"platform\":\"fixture\",\"llvm_profdata\":\"fixture\"," + "\"suites\":[{\"suite\":\"alpha\",\"tests\":2,\"incomplete\":0,\"exit\":0}," + "{\"suite\":\"beta\",\"tests\":1,\"incomplete\":0,\"exit\":0}," + "{\"suite\":\"matrix\",\"tests\":1,\"incomplete\":0,\"exit\":0}]}\n", + sha); + return th_mkdir_p(dir) == 0 && + th_write_file(TH_PATH(dir, "functions.tsv"), + "0\tsrc/lib.c\tlib_value\n1\tsrc/lib.c\tlib_other\n") == 0 && + th_write_file(TH_PATH(dir, "tests.tsv"), + "alpha:*\tcomplete\t\t\nalpha:alpha_plain\tcomplete\t\t\n" + "alpha:alpha_uses_lib\tcomplete\t\t0\nbeta:*\tcomplete\t\t\n" + "beta:beta_alone\tcomplete\t\t\nmatrix:*\tcomplete\t\t\n" + "matrix:matrix_one\tcomplete\t\t1\n") == 0 && + th_write_file(TH_PATH(dir, "meta.json"), meta) == 0; +} + +/* The team artifact: the merge base's bundle, verified by the caller, is the + * graph base and its admitted map narrows a suite to the tests that ran the + * changed function; the same bundle unverified is ignored, and without a + * bundle every suite stays whole. */ +TEST(test_impact_engine_admitted_coverage_narrows_only_when_verified) { + tie_fixture_t fx; + ASSERT_TRUE(tie_open(&fx)); + char sha[65]; + char coverage[1200]; + char bundle[1200]; + ASSERT_TRUE(tie_base_sha(&fx, sha)); + ASSERT_TRUE(tie_coverage(&fx, sha, coverage, sizeof(coverage))); + snprintf(bundle, sizeof(bundle), "%s/bundle", fx.home); + cbm_test_impact_publish_t publish = {.repo_root = fx.repo, + .work_parent = fx.work, + .out_dir = bundle, + .coverage_dir = coverage, + .observed_at = (int64_t)time(NULL), + .platform = "fixture", + .deadline_ms = cbm_now_ms() + 120000U}; + char diagnostic[512] = ""; + cbm_test_impact_status_t published = + cbm_test_impact_publish(&publish, diagnostic, sizeof(diagnostic)); + if (published != CBM_TEST_IMPACT_OK) { + printf(" publish: %s\n", diagnostic); + } + ASSERT_EQ(published, CBM_TEST_IMPACT_OK); + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1 + 0;\n}\n" + "int lib_other(void) {\n return 2;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_value")); + + char *plain = tie_answer(&fx); + char *verified = tie_answer_with(&fx, bundle, sha, true); + char *unverified = tie_answer_with(&fx, bundle, sha, false); + ASSERT_NOT_NULL(plain); + ASSERT_NOT_NULL(verified); + ASSERT_NOT_NULL(unverified); + if (!strstr(verified, "\"mode\":\"tests\",\"suite\":\"alpha\"")) { + printf(" %.1200s\n", verified); + } + /* Narrowed: alpha to the one test that ran lib_value; beta not at all. */ + ASSERT_NOT_NULL(strstr(verified, "\"mode\":\"tests\",\"suite\":\"alpha\"")); + ASSERT_NOT_NULL(strstr(verified, "\"test\":\"alpha_uses_lib\"")); + ASSERT_NULL(strstr(verified, "\"test\":\"alpha_plain\"")); + ASSERT_NULL(strstr(verified, "\"suite\":\"beta\"")); + /* Unverified and absent: no narrowing. */ + ASSERT_NOT_NULL(strstr(unverified, "\"mode\":\"whole\",\"suite\":\"beta\"")); + ASSERT_NOT_NULL(strstr(unverified, "SOURCE_UNVERIFIED")); + ASSERT_NOT_NULL(strstr(plain, "\"mode\":\"whole\",\"suite\":\"beta\"")); + free(plain); + free(verified); + free(unverified); + tie_close(&fx); + PASS(); +} + +/* A changed function the map does not know (here: not in the map's image, + * as platform code would not be) reaches the coverage of its nearest mapped + * callers, so the tests that ran lib_value are selected BY COVERAGE when only + * lib_win changed. */ +static const char tie_lib_win[] = "int lib_win(void) {\n" + " return 0;\n" + "}\n" + "int lib_value(void) {\n" + " return 1 + lib_win();\n" + "}\n" + "int lib_other(void) {\n" + " return 2;\n" + "}\n"; + +TEST(test_impact_engine_coverage_reaches_unmapped_code_through_mapped_callers) { + tie_fixture_t fx; + ASSERT_TRUE(tie_open_with(&fx, "src/lib.c", tie_lib_win)); + char sha[65]; + char coverage[1200]; + char bundle[1200]; + ASSERT_TRUE(tie_base_sha(&fx, sha)); + ASSERT_TRUE(tie_coverage(&fx, sha, coverage, sizeof(coverage))); + snprintf(bundle, sizeof(bundle), "%s/bundle", fx.home); + cbm_test_impact_publish_t publish = {.repo_root = fx.repo, + .work_parent = fx.work, + .out_dir = bundle, + .coverage_dir = coverage, + .observed_at = (int64_t)time(NULL), + .platform = "fixture", + .deadline_ms = cbm_now_ms() + 120000U}; + char diagnostic[512] = ""; + ASSERT_EQ(cbm_test_impact_publish(&publish, diagnostic, sizeof(diagnostic)), + CBM_TEST_IMPACT_OK); + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_win(void) {\n return 0 + 0;\n}\n" + "int lib_value(void) {\n return 1 + lib_win();\n}\n" + "int lib_other(void) {\n return 2;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_win only")); + char *verified = tie_answer_with(&fx, bundle, sha, true); + ASSERT_NOT_NULL(verified); + const char *covered = "\"id\":\"alpha:alpha_uses_lib\",\"line\":2,\"reasons\":[\"COVERAGE\""; + if (!strstr(verified, covered)) { + printf(" %.1200s\n", verified); + } + ASSERT_NOT_NULL(strstr(verified, covered)); + ASSERT_NULL(strstr(verified, "\"test\":\"alpha_plain\"")); + free(verified); + tie_close(&fx); + PASS(); +} + SUITE(test_impact_engine) { RUN_TEST(test_impact_engine_reached_suites_carry_static); RUN_TEST(test_impact_engine_macro_registered_tests_select_their_suite); RUN_TEST(test_impact_engine_rules_unmapped_and_empty); RUN_TEST(test_impact_engine_parse_gaps_reach_what_they_name); + RUN_TEST(test_impact_engine_admitted_coverage_narrows_only_when_verified); + RUN_TEST(test_impact_engine_coverage_reaches_unmapped_code_through_mapped_callers); } From 776bb119ac3f921804428c67694d39f792d78d1f Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 5 Oct 2026 18:52:31 +0200 Subject: [PATCH 23/44] ci(test-impact): team artifact producer, PR shadow and the PR-CI narrowing switch Producer. test-impact-artifact.yml publishes, on every push to main, the commit's team artifact bundle: its frozen graph, built incrementally from the previous bundle, and its per-test coverage map, re-run only for the suites the push selects (scripts/ci/test-impact-publish.sh). A weekly run rebuilds both from scratch and fails when the incremental graph's topology differs from the full one. Bundles are Actions artifacts kept 30 days, never committed. Shadow. The report-only test-impact-shadow job also builds the checkout and runs `test-impact select` against the base side of the PR merge commit (scripts/ci/test-impact-select.sh). A bundle is used only when it comes from a successful main-branch producer run for exactly that commit (scripts/ci/test-impact-bundle.sh). The job stays continue-on-error and outside ci-ok. Narrowing switch, PR CI only. While the repository variable TEST_IMPACT_MODE is `gate`, a select-tests job turns the engine's answer into the tests the test-unix and test-windows legs run. Unset, nothing changes. It fails open by construction: - scripts/test-impact/selection.py is the only place an answer becomes a runner filter. Only a well-formed selected lane whose filter matches its own suite list narrows; run-all answers, a lane that selects nothing, unknown schemas or tokens, and any crash run everything. - `test` passes a selection only from a successful, narrowed select-tests run; ci-ok does not need select-tests. A broken selection costs minutes, never coverage. - scripts/run-tests-parallel.sh launches only the selected suites and keeps every guard on the selected list. A selected suite the build does not register fails the run, unless the engine saw its registration as conditional; then it is dropped with a notice, exactly as a full run of that build never runs it. Each runner judges its own tokens, so a selected test that does not exist fails its suite. - The shard manifest records the selection's digest, and verify-shard-union.sh requires every shard of a leg to agree on it. - Dry runs and releases never pass a selection (lane-wiring contract). Also fixed on the way: - publish crashed on any run-all push: a run-all lane carries "suites": null. - the git-facts hostile-diff probe re-ran the runner with the parent's CBM_TEST_ONLY_FILE still set; under a narrowed run naming its suite it re-ran the spawning test, which spawned again. The child now clears it (a new test fails without that). - the select summary crashed on the same null suites. Contracts: tests/test_test_impact_selection.sh (Step 0z8), tests/test_harness_selection.sh (Step 2c, the real runner through the real harness), and new cases in the lane-gate and lane-wiring contracts. Signed-off-by: Martin Vogel --- .github/workflows/_test.yml | 20 +++ .github/workflows/pr.yml | 66 +++++++++- .github/workflows/test-impact-artifact.yml | 85 +++++++++++++ scripts/ci/test-impact-bundle.sh | 62 ++++++++++ scripts/ci/test-impact-publish.sh | 114 ++++++++++++++++++ scripts/ci/test-impact-select.sh | 102 ++++++++++++++++ scripts/ci/verify-shard-union.sh | 16 ++- scripts/run-tests-parallel.sh | 60 ++++++++- scripts/test-impact/selection.py | 134 +++++++++++++++++++++ scripts/test.sh | 38 +++++- tests/test_harness_selection.sh | 103 ++++++++++++++++ tests/test_lane_gate_contract.sh | 11 ++ tests/test_lane_wiring_contract.sh | 37 +++++- tests/test_test_impact_git.c | 55 ++++++++- tests/test_test_impact_selection.sh | 92 ++++++++++++++ 15 files changed, 984 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/test-impact-artifact.yml create mode 100644 scripts/ci/test-impact-bundle.sh create mode 100644 scripts/ci/test-impact-publish.sh create mode 100644 scripts/ci/test-impact-select.sh create mode 100644 scripts/test-impact/selection.py create mode 100644 tests/test_harness_selection.sh create mode 100644 tests/test_test_impact_selection.sh diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index d250dd031..766db9d97 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -24,6 +24,10 @@ on: 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 + test_selection: + description: 'PR test selection (smart CI): the artifact the select-tests job uploaded; the test-unix and test-windows legs run only its tests. Empty = every test (dry run, release, and any PR the selection does not narrow)' + type: string + default: '' permissions: contents: read @@ -217,12 +221,20 @@ jobs: ccache-test-${{ matrix.os }}-${{ matrix.cc }}-${{ github.ref }}- ccache-test-${{ matrix.os }}-${{ matrix.cc }}- + - name: Fetch the test selection + if: ${{ inputs.test_selection != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.test_selection }} + path: ${{ runner.temp }}/test-selection + - name: Test run: scripts/test.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} env: CBM_TEST_SHARD: ${{ matrix.shard }} CBM_TEST_LEG: ${{ matrix.os }}-${{ matrix.cc }} CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }} + CBM_TEST_SELECTION_DIR: ${{ inputs.test_selection != '' && format('{0}/test-selection', runner.temp) || '' }} CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 1500M @@ -539,6 +551,13 @@ jobs: ccache-test-${{ matrix.os }}-${{ matrix.msystem }}-${{ github.ref }}- ccache-test-${{ matrix.os }}-${{ matrix.msystem }}- + - name: Fetch the test selection + if: ${{ inputs.test_selection != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.test_selection }} + path: ${{ runner.temp }}/test-selection + - name: Test shell: msys2 {0} # AddressSanitizer is unavailable on native ARM64 Windows (LLVM ships no @@ -566,6 +585,7 @@ jobs: CBM_TEST_SHARD: ${{ matrix.shard }} CBM_TEST_LEG: ${{ matrix.os }}-${{ matrix.msystem }} CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }} + CBM_TEST_SELECTION_DIR: ${{ inputs.test_selection != '' && format('{0}/test-selection', runner.temp) || '' }} CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 1500M diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index da13aea51..7af2efa5e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -44,12 +44,58 @@ jobs: with: lanes: ${{ needs.changes.outputs.lanes }} + # ── Smart CI, PR CI only: run only the tests this change can reach. + # Active only while the repository variable TEST_IMPACT_MODE is `gate`; + # unset (shadow) every test runs and test-impact-shadow only reports. + # test-impact-select.sh --gate narrows only on a well-formed answer: a + # failure of this job, a run-all answer or a lane that selects nothing + # leaves `test` without a selection, and every test runs. ci-ok does not + # need this job: when it breaks, PRs cost more minutes, never coverage. + # Dry runs and releases never pass a selection. + select-tests: + needs: [changes] + if: ${{ vars.TEST_IMPACT_MODE == 'gate' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + actions: read # the merge base's team-artifact bundle (test-impact-artifact.yml) + outputs: + selection: ${{ steps.select.outputs.selection }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The PR merge commit and its base parent: the change to select for. + fetch-depth: 2 + persist-credentials: false + - name: Build this checkout's engine + run: | + sudo apt-get update && sudo apt-get install -y zlib1g-dev + scripts/build.sh CC=gcc CXX=g++ + - name: Select the tests + id: select + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" --gate "$RUNNER_TEMP/test-selection" + - name: Upload the selection + if: ${{ steps.select.outputs.selection == 'narrowed' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-selection + path: ${{ runner.temp }}/test-selection + if-no-files-found: error + overwrite: true + test: - needs: [changes, lint] + needs: [changes, lint, select-tests] if: ${{ !cancelled() && needs.lint.result == 'success' }} uses: ./.github/workflows/_test.yml with: lanes: ${{ needs.changes.outputs.lanes }} + # Smart CI: only a successful select-tests run that narrowed passes a + # selection; skipped (shadow), failed, run-all and nothing-selected + # answers all run every test. + test_selection: ${{ needs.select-tests.result == 'success' && needs.select-tests.outputs.selection == 'narrowed' && 'test-selection' || '' }} # 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 @@ -227,8 +273,11 @@ jobs: # ci-ok's needs: it can never block or pass a merge. ── test-impact-shadow: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 continue-on-error: true + permissions: + contents: read + actions: read # the merge base's team-artifact bundle (test-impact-artifact.yml) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -250,6 +299,17 @@ jobs: 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" + # The engine (test-impact select) is this checkout's own code: the + # released binary predates it, so the checkout is built either way. + - name: Build this checkout's engine + if: ${{ steps.release.outcome != 'failure' }} + run: | + sudo apt-get update && sudo apt-get install -y zlib1g-dev + scripts/build.sh CC=gcc CXX=g++ + - name: Select with the team artifact (engine shadow) + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" - name: Upload the prediction if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -259,6 +319,8 @@ jobs: ${{ runner.temp }}/test-impact/prediction.json ${{ runner.temp }}/test-impact/selection.json ${{ runner.temp }}/test-impact/changed-files.txt + ${{ runner.temp }}/test-impact/selection-engine.json + ${{ runner.temp }}/test-impact/summary-engine.md if-no-files-found: warn # ── Docs-only and non-product PRs (tiers T0a, T0b) run the static contract diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml new file mode 100644 index 000000000..9a13d9ad0 --- /dev/null +++ b/.github/workflows/test-impact-artifact.yml @@ -0,0 +1,85 @@ +# Team artifact for test selection (smart CI, report-only until the oracles +# and the shadow comparison are green). Every push to main publishes the +# commit's bundle — its frozen graph, built incrementally from the previous +# bundle, and its per-test coverage map, re-run only for the suites the push +# selects — as the Actions artifact test-impact-. A weekly run rebuilds +# both from scratch and fails when the incremental graph differs from the full +# one. PR selection fetches the bundle of its merge base +# (scripts/ci/test-impact-bundle.sh) and never narrows on anything else. +name: test-impact-artifact + +on: + push: + branches: [main] + schedule: + - cron: "23 3 * * 0" # weekly full refresh, Sunday 03:23 UTC + workflow_dispatch: + inputs: + mode: + description: incremental or full + type: choice + options: [incremental, full] + default: full + +permissions: + contents: read + actions: read # fetch the previous bundle + +concurrency: + # Bundles chain: each push builds on the previous one, so runs never overlap + # and are never cancelled half-way. + group: test-impact-artifact + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-24.04 + timeout-minutes: 120 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + persist-credentials: false + + - name: Install build deps and LLVM 21 (coverage toolchain) + run: | + sudo apt-get update && sudo apt-get install -y zlib1g-dev + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc + echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list + sudo apt-get update + sudo apt-get install -y clang-21 llvm-21 libclang-rt-21-dev + + - name: Build the production binary and the coverage runner + run: | + scripts/build.sh CC=gcc CXX=g++ + make -f Makefile.cbm test-runner-cov COV_LLVM_BIN=/usr/lib/llvm-21/bin -j"$(nproc)" + + - name: Publish the bundle + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + BEFORE: ${{ github.event.before }} + REQUESTED: ${{ inputs.mode }} + # The selection lane excludes @perf and PR CI skips perf, so their + # (slowest, longest under coverage) suites are neither mapped nor + # listed: the runner then neither lists nor runs them. + CBM_SKIP_PERF: "1" + run: | + mode=incremental + if [ "$EVENT" = schedule ] || [ "$REQUESTED" = full ]; then mode=full; fi + before="$(git rev-parse HEAD^1)" + if [ "$EVENT" = push ] && [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then + before="$BEFORE" + fi + scripts/ci/test-impact-publish.sh --binary build/c/codebase-memory-mcp \ + --runner build/cov/test-runner --llvm-bin /usr/lib/llvm-21/bin \ + --out "$RUNNER_TEMP/bundle" --mode "$mode" --before "$before" + + - name: Upload the bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-impact-${{ github.sha }} + path: ${{ runner.temp }}/bundle + include-hidden-files: true # the graph lives in .codebase-memory/ + retention-days: 30 + if-no-files-found: error diff --git a/scripts/ci/test-impact-bundle.sh b/scripts/ci/test-impact-bundle.sh new file mode 100644 index 000000000..bc83e01a0 --- /dev/null +++ b/scripts/ci/test-impact-bundle.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# test-impact-bundle.sh — fetch the team artifact bundle of one main commit. +# +# The bundle (codebase-memory-mcp test-impact publish; src/mcp/test_impact_artifact.h) +# is published by .github/workflows/test-impact-artifact.yml as the Actions +# artifact `test-impact-`. A selection may pass --artifact-verified ONLY +# for a bundle this script fetched: it accepts nothing but a SUCCESSFUL run of +# that workflow, in THIS repository, on the main branch, whose head is exactly +# the requested commit — the provenance the receipt itself can never assert. +# +# Usage: scripts/ci/test-impact-bundle.sh --fetch SHA DIR +# Download the bundle of SHA into DIR (created). Needs GH_TOKEN with +# actions:read; $GH overrides the gh command, $GITHUB_REPOSITORY the +# repository (OWNER/NAME). +# Exit: 0 = fetched and verified · 3 = no admissible bundle for SHA · +# 1 = the tooling broke · 2 = usage error. +set -euo pipefail + +GH="${GH:-gh}" +REPO="${GITHUB_REPOSITORY:-DeusData/codebase-memory-mcp}" +WORKFLOW="test-impact-artifact.yml" + +usage() { sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; } + +[ $# -eq 3 ] && [ "$1" = "--fetch" ] || { usage >&2; exit 2; } +SHA="$2" +DIR="$3" +case "$SHA" in + *[!0-9a-f]* | "") echo "test-impact-bundle: not a lowercase commit id: $SHA" >&2; exit 2 ;; +esac +[ ${#SHA} -eq 40 ] || [ ${#SHA} -eq 64 ] || { echo "test-impact-bundle: bad id length" >&2; exit 2; } + +# The newest successful main-branch run of the producer for exactly this head. +run=$("$GH" api -X GET "repos/$REPO/actions/workflows/$WORKFLOW/runs" \ + -f branch=main -f status=success -f head_sha="$SHA" -f per_page=20 \ + --jq '[.workflow_runs[] + | select(.head_sha == "'"$SHA"'" and .head_branch == "main" + and .repository.full_name == "'"$REPO"'" + and .head_repository.full_name == "'"$REPO"'" + and (.event == "push" or .event == "schedule" + or .event == "workflow_dispatch"))] + | sort_by(.run_started_at) | last | .id // empty') || exit 1 +if [ -z "$run" ]; then + echo "test-impact-bundle: no successful producer run for $SHA" >&2 + exit 3 +fi +mkdir -p "$DIR" +if ! "$GH" run download "$run" --repo "$REPO" --name "test-impact-$SHA" --dir "$DIR"; then + echo "test-impact-bundle: run $run has no artifact test-impact-$SHA" >&2 + exit 3 +fi +# The receipt must name the commit the run was for; the engine checks the rest. +if ! python3 - "$DIR/receipt.json" "$SHA" <<'PY' +import json, sys +receipt = json.load(open(sys.argv[1])) +sys.exit(0 if receipt.get("commit") == sys.argv[2] else 1) +PY +then + echo "test-impact-bundle: the receipt does not name $SHA" >&2 + exit 3 +fi +echo "test-impact-bundle: run $run, bundle of $SHA in $DIR" diff --git a/scripts/ci/test-impact-publish.sh b/scripts/ci/test-impact-publish.sh new file mode 100644 index 000000000..8e9dbeeb6 --- /dev/null +++ b/scripts/ci/test-impact-publish.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# test-impact-publish.sh — publish the team artifact bundle of the checked-out +# main commit (.github/workflows/test-impact-artifact.yml). +# +# Incremental (a push): the previous commit's bundle is the graph base and its +# coverage map the starting point; only the suites the push's own selection +# picks are re-run with coverage and merged in (scripts/test-impact/ +# coverage-merge.py). The oldest observation is carried forward, so an +# incremental map ages out unless a full refresh runs. +# Full (weekly, manual, or no usable previous bundle): every suite is re-run +# and the graph is built from scratch. A weekly run ALSO builds the graph +# incrementally from the previous bundle and fails when the two graphs' +# content digests differ: an incremental index must equal a full one. +# +# Usage: scripts/ci/test-impact-publish.sh --binary PATH --runner PATH --out DIR +# --mode incremental|full [--before SHA] [--llvm-bin DIR] [--platform LABEL] +# Exit: 0 = published · 1 = failed · 2 = usage error. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BINARY="" RUNNER="" OUT="" MODE="" BEFORE="" LLVM_BIN="" PLATFORM="linux-x86_64-clang21" +while [ $# -gt 0 ]; do + case "$1" in + --binary) BINARY="$2"; shift 2 ;; + --runner) RUNNER="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --mode) MODE="$2"; shift 2 ;; + --before) BEFORE="$2"; shift 2 ;; + --llvm-bin) LLVM_BIN="$2"; shift 2 ;; + --platform) PLATFORM="$2"; shift 2 ;; + *) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2 ;; + esac +done +[ -n "$BINARY" ] && [ -n "$RUNNER" ] && [ -n "$OUT" ] || exit 2 +case "$MODE" in incremental | full) ;; *) exit 2 ;; esac + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +# A private daemon rendezvous: the socket path must fit sun_path. +export CBM_RUNTIME_DIR="$(mktemp -d /tmp/cbm-rt.XXXXXX)" +export CBM_CACHE_DIR="$WORK/cache" +mkdir -p "$WORK/engine" && chmod 700 "$WORK/engine" +HEAD_SHA="$(git -C "$ROOT" rev-parse HEAD)" +PREVIOUS="" + +if [ -n "$BEFORE" ]; then + if scripts/ci/test-impact-bundle.sh --fetch "$BEFORE" "$WORK/previous"; then + PREVIOUS="$WORK/previous" + fi +fi +if [ "$MODE" = incremental ] && [ -z "$PREVIOUS" ]; then + echo "test-impact-publish: no previous bundle for ${BEFORE:-?}: full refresh" + MODE=full +fi + +map() { # out [suite...] + local out="$1"; shift + local args=() + for suite in "$@"; do args+=(--suite "$suite"); done + python3 "$ROOT/scripts/test-impact/coverage-map.py" --runner "$RUNNER" --out "$out" \ + ${LLVM_BIN:+--llvm-bin "$LLVM_BIN"} ${args[@]+"${args[@]}"} +} + +OBSERVED="" +if [ "$MODE" = full ]; then + OBSERVED="$(date +%s)" + map "$WORK/coverage" +else + # The push's own selection decides what to re-run (whole suites). + "$BINARY" test-impact select --repo "$ROOT" --base "$BEFORE" --work "$WORK/engine" \ + --artifact "$PREVIOUS" --artifact-commit "$BEFORE" --artifact-verified \ + --platform "$PLATFORM" > "$WORK/selection.json" + python3 "$ROOT/scripts/test-impact/selection.py" suites "$WORK/selection.json" > "$WORK/suites.txt" + "$RUNNER" --list-suites > "$WORK/listed.txt" + if grep -qx '\*' "$WORK/suites.txt"; then + OBSERVED="$(date +%s)" + map "$WORK/coverage" + else + mapfile -t rerun < <(sort -u "$WORK/suites.txt") + map "$WORK/fresh" ${rerun[@]+"${rerun[@]}"} + python3 "$ROOT/scripts/test-impact/coverage-merge.py" --previous "$PREVIOUS/coverage" \ + --fresh "$WORK/fresh" --suites "$WORK/listed.txt" --commit "$HEAD_SHA" \ + --out "$WORK/coverage" + fi +fi + +publish() { # out [previous] + "$BINARY" test-impact publish --repo "$ROOT" --out "$1" --work "$WORK/engine" \ + --coverage "$WORK/coverage" ${OBSERVED:+--observed-at "$OBSERVED"} \ + --platform "$PLATFORM" ${2:+--previous "$2"} +} +if [ "$MODE" = full ]; then + publish "$OUT" + if [ -n "$PREVIOUS" ]; then + publish "$WORK/incremental" "$PREVIOUS" + field() { python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))[sys.argv[2]])' "$1" "$2"; } + # Topology (what selection reads) must be equal; edge properties and the + # corpus-statistical edges are reported, not gated (recorded defects). + full_topology="$(field "$OUT/receipt.json" graph_topology_sha256)" + incr_topology="$(field "$WORK/incremental/receipt.json" graph_topology_sha256)" + if [ "$full_topology" != "$incr_topology" ]; then + echo "test-impact-publish: incremental topology differs from the full one ($incr_topology != $full_topology)" >&2 + exit 1 + fi + if [ "$(field "$OUT/receipt.json" graph_content_sha256)" != \ + "$(field "$WORK/incremental/receipt.json" graph_content_sha256)" ]; then + echo "::warning::incremental graph content (edge properties, similarity edges) differs from the full one; topology is equal" + fi + echo "test-impact-publish: incremental topology equals the full one ($full_topology)" + fi +else + publish "$OUT" "$PREVIOUS" +fi +echo "test-impact-publish: bundle of $HEAD_SHA in $OUT ($MODE)" diff --git a/scripts/ci/test-impact-select.sh b/scripts/ci/test-impact-select.sh new file mode 100644 index 000000000..39208683e --- /dev/null +++ b/scripts/ci/test-impact-select.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# test-impact-select.sh — the PR selection with the team artifact: what +# `codebase-memory-mcp test-impact select` runs for this change. +# +# Shadow (default): REPORT-ONLY; pr.yml's test-impact-shadow job runs it +# continue-on-error and ci-ok does not need it. +# --gate DIR (pr.yml's select-tests job, only when the repository variable +# TEST_IMPACT_MODE is `gate`): also turns the answer into what the test legs +# run (scripts/test-impact/selection.py) — DIR/test-only.txt and +# DIR/optional-suites.txt for a narrowed selection — and writes +# `selection=narrowed` or `selection=all` to $GITHUB_OUTPUT. Anything but a +# well-formed narrowed answer, a lane that selects nothing included, is `all`. +# +# The base is the base side of the PR merge commit (HEAD^1 by default). Its +# bundle is fetched with scripts/ci/test-impact-bundle.sh, which only accepts +# the main-branch producer's own run for exactly that commit; only then does +# the selection pass --artifact-verified. Without a bundle the engine builds +# the graph itself and admits no coverage: every reached suite runs whole. +# +# Usage: scripts/ci/test-impact-select.sh --binary PATH --out DIR [--base REV] +# [--platform LABEL] [--gate DIR] +# Writes DIR/selection-engine.json and DIR/summary-engine.md (appended +# to $GITHUB_STEP_SUMMARY when set). +# Exit: 0 = selection written · 1 = the tooling broke · 2 = usage error. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BINARY="" OUT="" BASE="HEAD^1" PLATFORM="linux-x86_64-clang21" GATE="" +while [ $# -gt 0 ]; do + case "$1" in + --binary) BINARY="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; + --platform) PLATFORM="$2"; shift 2 ;; + --gate) GATE="$2"; shift 2 ;; + *) sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2 ;; + esac +done +[ -n "$BINARY" ] && [ -n "$OUT" ] || exit 2 + +mkdir -p "$OUT" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +export CBM_RUNTIME_DIR="$(mktemp -d /tmp/cbm-rt.XXXXXX)" # sun_path must fit +export CBM_CACHE_DIR="$WORK/cache" +mkdir -p "$WORK/engine" && chmod 700 "$WORK/engine" +base="$(git -C "$ROOT" rev-parse "$BASE")" + +artifact=() +bundle_rc=0 +"$ROOT/scripts/ci/test-impact-bundle.sh" --fetch "$base" "$WORK/bundle" || bundle_rc=$? +if [ "$bundle_rc" -eq 0 ]; then + artifact=(--artifact "$WORK/bundle" --artifact-commit "$base" --artifact-verified) +elif [ "$bundle_rc" -ne 3 ]; then + # No bundle means no coverage: more tests run, never fewer. Say so. + echo "::warning::test-impact bundle fetch failed (exit $bundle_rc): selecting without coverage" +fi +started=$(date +%s) +"$BINARY" test-impact select --repo "$ROOT" --base "$base" --work "$WORK/engine" \ + --platform "$PLATFORM" ${artifact[@]+"${artifact[@]}"} > "$OUT/selection-engine.json" +elapsed=$(( $(date +%s) - started )) + +python3 - "$OUT/selection-engine.json" "$elapsed" "${#artifact[@]}" > "$OUT/summary-engine.md" <<'PY' +import json, sys +answer = json.load(open(sys.argv[1])) +coverage = answer.get("receipt", {}).get("coverage", {}) +lines = ["### test-impact engine", "", + f"- decision: `{answer.get('decision')}` in {sys.argv[2]} s", + f"- team artifact: {'fetched' if sys.argv[3] != '0' else 'none'}; coverage " + f"`{coverage.get('state')}` {coverage.get('rejection_reasons') or ''}"] +for lane in answer.get("lanes") or []: + suites = lane.get("suites") or [] + whole = [s["suite"] for s in suites if s.get("mode") == "whole"] + narrowed = {s["suite"]: len(s.get("tests") or []) for s in suites if s.get("mode") == "tests"} + lines.append(f"- lane `{lane.get('lane')}` ({lane.get('decision')}): {len(whole)} whole, " + f"{len(narrowed)} narrowed ({sum(narrowed.values())} tests)") +print("\n".join(lines)) +PY + +if [ -n "$GATE" ]; then + mkdir -p "$GATE" + selection=all + filter_rc=0 + python3 "$ROOT/scripts/test-impact/selection.py" filter "$OUT/selection-engine.json" \ + --lane unit --out "$GATE/test-only.txt" --optional-out "$GATE/optional-suites.txt" || + filter_rc=$? + if [ "$filter_rc" -eq 0 ]; then + selection=narrowed + echo "- PR CI runs $(wc -l < "$GATE/test-only.txt" | tr -d ' ') selection tokens" \ + >> "$OUT/summary-engine.md" + else + rm -f "$GATE/test-only.txt" "$GATE/optional-suites.txt" + echo "- PR CI runs every test (selection exit $filter_rc)" >> "$OUT/summary-engine.md" + fi + if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "selection=$selection" >> "$GITHUB_OUTPUT" + fi +fi +cat "$OUT/summary-engine.md" +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + cat "$OUT/summary-engine.md" >> "$GITHUB_STEP_SUMMARY" +fi diff --git a/scripts/ci/verify-shard-union.sh b/scripts/ci/verify-shard-union.sh index b2c69d0ff..433d164bb 100755 --- a/scripts/ci/verify-shard-union.sh +++ b/scripts/ci/verify-shard-union.sh @@ -8,7 +8,9 @@ # For every leg it asserts: all shards agree on the shard count, indices form # exactly 1..n, every shard saw the same full suite list, and the UNION of the # shard slices equals that list — a rename/re-shard can never silently drop a -# suite (gate-quality loss) without failing here. +# suite (gate-quality loss) without failing here. A narrowed leg (PR CI test +# selection) records the selected list and its selection_sha256; all shards +# must agree on that too. # # 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 @@ -20,7 +22,7 @@ set -eu case "${1:-}" in -h | --help) - sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; esac @@ -78,6 +80,16 @@ for leg in $(grep -h '^leg=' $files | sort -u | sed 's/^leg=//'); do rc=1 continue fi + # A narrowed leg: every shard must have applied the same test selection + # (two selections can name the same suites and different tests). + sel_sha=$(for f in $leg_files; do + grep -h '^selection_sha256=' "$f" || echo "selection_sha256=none" + done | sort -u) + if [ "$(printf '%s\n' "$sel_sha" | wc -l)" -ne 1 ]; then + echo "FAIL: $leg shards applied different test selections" >&2 + rc=1 + continue + fi union_sha=$(for f in $leg_files; do sed -n '/^--- slice ---$/,$p' "$f" | tail -n +2 done | sort | sha256sum | awk '{print $1}') diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index d28829540..2607b5d7d 100644 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -35,7 +35,10 @@ if [ -z "$JOBS" ]; then fi fi -LOGDIR="$(dirname "$RUNNER")/test-logs" +# CBM_TEST_LOG_DIR: a private log directory (tests/test_harness_selection.sh +# drives this harness before the real run and must not touch its logs). Keep +# it relative: the native Windows runner reads the selection file below it. +LOGDIR="${CBM_TEST_LOG_DIR:-$(dirname "$RUNNER")/test-logs}" rm -rf "$LOGDIR" mkdir -p "$LOGDIR" @@ -129,6 +132,54 @@ if [ "$NSUITES" -lt 1 ] || grep -qvE '^[a-z0-9_]+$' "$SUITES_FILE"; then exit 1 fi +# Test selection (smart CI; PR CI only, scripts/test-impact/selection.py). +# CBM_TEST_SELECTION_FILE lists `suite` / `suite:test` tokens. Only the suites +# it names are launched, and every launched runner judges its own tokens +# through CBM_TEST_ONLY_FILE, so a selected test the runner cannot find fails +# that suite. A selected suite this build does not register fails the run, +# unless CBM_TEST_SELECTION_OPTIONAL names it (registered only in some builds, +# e.g. the coverage runner): then it is dropped with a notice, exactly as a +# full run of this build never runs it. Everything below — sharding, the +# manifest, the union guard — then works on the selected list. Unset: the +# full list, unchanged. +SELECTION="none" +if [ -n "${CBM_TEST_SELECTION_FILE:-}" ]; then + SEL_TOKENS="$LOGDIR/selection-tokens.txt" + if ! tr -d '\r' < "$CBM_TEST_SELECTION_FILE" > "$SEL_TOKENS"; then + echo "FAIL: cannot read the test selection $CBM_TEST_SELECTION_FILE" >&2 + exit 1 + fi + if [ ! -s "$SEL_TOKENS" ] || grep -qvE '^[A-Za-z0-9_]+(:[A-Za-z0-9_]+)?$' "$SEL_TOKENS"; then + echo "FAIL: test selection empty or malformed: $CBM_TEST_SELECTION_FILE" >&2 + exit 1 + fi + cut -d: -f1 "$SEL_TOKENS" | sort -u > "$LOGDIR/selection-suites.txt" + if [ -n "${CBM_TEST_SELECTION_OPTIONAL:-}" ]; then + tr -d '\r' < "$CBM_TEST_SELECTION_OPTIONAL" | sort -u > "$LOGDIR/selection-optional.txt" + else + : > "$LOGDIR/selection-optional.txt" + fi + ABSENT=$(comm -23 "$LOGDIR/selection-suites.txt" <(sort -u "$SUITES_FILE")) + REQUIRED_ABSENT=$(comm -23 <(printf '%s\n' $ABSENT | sed '/^$/d' | sort -u) "$LOGDIR/selection-optional.txt") + if [ -n "$REQUIRED_ABSENT" ]; then + echo "FAIL: the test selection names suites this build does not register:" $REQUIRED_ABSENT >&2 + exit 1 + fi + [ -z "$ABSENT" ] || echo "selection: conditional suites not in this build, not run:" $ABSENT + # The runner's filter keeps only this build's suites (an unknown suite's + # token would fail every process); the suite list keeps list order. + awk 'NR == FNR { listed[$0] = 1; next } { split($0, p, ":") } p[1] in listed' \ + "$SUITES_FILE" "$SEL_TOKENS" > "$LOGDIR/selection.txt" + awk 'NR == FNR { selected[$0] = 1; next } $0 in selected' \ + "$LOGDIR/selection-suites.txt" "$SUITES_FILE" > "$SUITES_FILE.selected" + mv "$SUITES_FILE.selected" "$SUITES_FILE" + SELECTION="$(sort "$LOGDIR/selection.txt" | { sha256sum 2>/dev/null || shasum -a 256; } | awk '{print $1}')" + echo "selection: $(wc -l < "$SUITES_FILE" | tr -d ' ') of $NSUITES suites, $(wc -l < "$LOGDIR/selection.txt" | tr -d ' ') tokens ($SELECTION)" + NSUITES=$(wc -l < "$SUITES_FILE" | tr -d ' ') + export CBM_TEST_ONLY_FILE="$LOGDIR/selection.txt" + unset CBM_TEST_ONLY +fi + # CBM_TEST_SHARD="i/N" runs this invocation's deterministic slice of the # suite list so CI can spread one platform's suites across N runner jobs. # Unset (or "1/1") selects everything — the sharding path is inert unless a @@ -246,6 +297,7 @@ NSHARD=$(wc -l < "$SHARD_EXPECT" | tr -d ' ') echo "leg=${CBM_TEST_LEG:-local}" echo "shard=${SHARD_INDEX}/${SHARD_TOTAL}" echo "list_sha256=$(sort "$SUITES_FILE" | { sha256sum 2>/dev/null || shasum -a 256; } | awk '{print $1}')" + echo "selection_sha256=$SELECTION" echo "--- slice ---" cat "$SHARD_EXPECT" } > "$LOGDIR/shard-manifest.txt" @@ -344,7 +396,11 @@ for f in $(grep -v ' rc=0 ' "$RESULTS_FILE" | awk '{print $1}'); do done echo "────────────────────────────────────────────" -echo " $TOTAL_PASS passed, $TOTAL_FAIL failed, $TOTAL_SKIP skipped ($NSUITES suites, $JOBS jobs)" +if [ "$SELECTION" = none ]; then + echo " $TOTAL_PASS passed, $TOTAL_FAIL failed, $TOTAL_SKIP skipped ($NSUITES suites, $JOBS jobs)" +else + echo " $TOTAL_PASS passed, $TOTAL_FAIL failed, $TOTAL_SKIP skipped ($NSUITES selected suites, $JOBS jobs; selection $SELECTION)" +fi echo "────────────────────────────────────────────" if [ "$TOTAL_FAIL" -gt 0 ] || [ "$BAD_RC" -gt 0 ]; then diff --git a/scripts/test-impact/selection.py b/scripts/test-impact/selection.py new file mode 100644 index 000000000..f4a8ff08b --- /dev/null +++ b/scripts/test-impact/selection.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Turn a test-impact answer (`codebase-memory-mcp test-impact select`) into +what a CI consumer runs. + + selection.py suites ANSWER + One line per suite whose tests the answer selects, or a single `*` when a + lane runs everything. The team-artifact producer re-runs exactly these + suites with coverage (scripts/ci/test-impact-publish.sh). + + selection.py filter ANSWER --lane LANE --out FILE [--optional-out FILE2] + exit 0 narrowed: FILE holds the lane's CBM_TEST_ONLY_FILE lines, and + FILE2 the selected suites the engine saw registered only + conditionally (CONDITIONAL): a build may legitimately lack them + (scripts/run-tests-parallel.sh, CBM_TEST_SELECTION_OPTIONAL) + exit 11 the lane selects nothing: FILE is empty + anything else: run everything (FILE and FILE2 are removed) + +It fails safe by construction: a run-all answer, a lane it cannot find, an +unknown schema or filter encoding, a token that is not `suite` or +`suite:test`, and a filter that disagrees with the lane's own suite list all +mean "run everything", and so does any crash (exit 1). +""" +import argparse +import json +import os +import re +import sys + +SCHEMAS = {"cbm.test_impact.v0", "cbm.test_impact.v1"} +TOKEN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?$") +NARROWED, NOTHING, RUN_ALL = 0, 11, 10 + + +def suites(answer): + if answer.get("decision") == "run_all": + return ["*"] + out = [] + for lane in answer.get("lanes") or []: + if lane.get("decision") == "run_all" or lane.get("suites") is None: + return ["*"] + out += [suite["suite"] for suite in lane["suites"]] + return sorted(set(out)) + + +def run_all(why): + print(f"selection: run everything: {why}", file=sys.stderr) + return RUN_ALL + + +def lane_filter(answer, name): + """-> (exit code, lines)""" + if answer.get("schema") not in SCHEMAS: + return run_all(f"unknown schema {answer.get('schema')!r}"), [] + if answer.get("decision") == "run_all": + return run_all("the answer runs everything"), [] + lanes = [lane for lane in answer.get("lanes") or [] if lane.get("lane") == name] + if len(lanes) != 1: + return run_all(f"lane {name!r} appears {len(lanes)} times"), [] + lane = lanes[0] + decision = lane.get("decision") + if decision == "skip": + if lane.get("suites") or lane.get("runner_filter") is not None: + return run_all("a skipped lane that still lists suites or a filter"), [] + return NOTHING, [] + if decision != "selected" or not isinstance(lane.get("suites"), list): + return run_all(f"lane decision {decision!r}"), [] + flt = lane.get("runner_filter") + if not isinstance(flt, dict) or flt.get("encoding") != "cbm-test-only-lines" or \ + not isinstance(flt.get("value"), str): + return run_all("no cbm-test-only-lines filter"), [] + lines = [line for line in flt["value"].split("\n") if line] + bad = [line for line in lines if not TOKEN.match(line)] + if not lines or bad: + return run_all(f"filter tokens unusable ({bad[:3] or 'empty'})"), [] + expected = set() + for suite in lane["suites"]: + if suite.get("mode") == "whole": + expected.add(suite["suite"]) + elif suite.get("mode") == "tests" and suite.get("tests"): + expected |= {f"{suite['suite']}:{test['test']}" for test in suite["tests"]} + else: + return run_all(f"suite {suite.get('suite')!r} has mode {suite.get('mode')!r}"), [] + if set(lines) != expected: + return run_all("the filter disagrees with the lane's suite list"), [] + return NARROWED, lines + + +def conditional_suites(answer, name): + """The lane's selected suites whose registration the engine saw as conditional.""" + for lane in answer.get("lanes") or []: + if lane.get("lane") == name: + return sorted({suite["suite"] for suite in lane.get("suites") or [] + if "CONDITIONAL" in (suite.get("whole_reason") or [])}) + return [] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = parser.add_subparsers(dest="command", required=True) + s = sub.add_parser("suites") + s.add_argument("answer") + f = sub.add_parser("filter") + f.add_argument("answer") + f.add_argument("--lane", required=True) + f.add_argument("--out", required=True) + f.add_argument("--optional-out") + args = parser.parse_args() + if args.command == "suites": + with open(args.answer, encoding="utf-8") as handle: + print("\n".join(suites(json.load(handle)))) + return 0 + for stale in (args.out, args.optional_out): + if stale and os.path.exists(stale): + os.remove(stale) + try: + with open(args.answer, encoding="utf-8") as handle: + answer = json.load(handle) + except (OSError, ValueError) as error: + return run_all(f"unreadable answer ({error})") + if not isinstance(answer, dict): + return run_all("the answer is not an object") + code, lines = lane_filter(answer, args.lane) + if code in (NARROWED, NOTHING): + with open(args.out, "w", encoding="utf-8") as handle: + handle.write("".join(line + "\n" for line in lines)) + if args.optional_out: + optional = conditional_suites(answer, args.lane) if code == NARROWED else [] + with open(args.optional_out, "w", encoding="utf-8") as handle: + handle.write("".join(suite + "\n" for suite in optional)) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test.sh b/scripts/test.sh index 91594e581..5774b659f 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -54,6 +54,11 @@ Environment: CBM_RUN_HANG_TEST=1 Opt-in C++ index-hang regression (#410, needs prod). CBM_NO_CCACHE=1 Disable the content-verified compiler cache. CBM_TEST_SHARD/_LEG Set by CI's sharded legs; leave unset locally. + CBM_TEST_SELECTION_DIR PR CI's test selection (smart CI): a directory with + test-only.txt (suite / suite:test lines) and + optional-suites.txt. Narrows the parallel suite step + only; contract steps and CBM_TEST_SEQUENTIAL=1 runs + keep running everything. Examples: scripts/test.sh # the full venue leg (what CI runs) @@ -375,6 +380,10 @@ bash "$ROOT/tests/test_coverage_map.sh" # selection a map that claims tests ran functions they never touched. echo "=== Step 0z7: incremental coverage map merge contract ===" bash "$ROOT/tests/test_coverage_merge.sh" +# Step 0z8: the one place a test-impact answer becomes what PR CI runs. Only a +# well-formed, self-consistent selection may narrow; everything else runs all. +echo "=== Step 0z8: test selection (answer -> runner filter) contract ===" +bash "$ROOT/tests/test_test_impact_selection.sh" if [ "$CONTRACTS_ONLY" -eq 1 ]; then echo "=== test.sh: contracts-only — every contract step passed ===" @@ -402,11 +411,38 @@ assert_test_runner_build_config "$BUILD_DIR/test-runner" "$EXPECTED_SANITIZED" # 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" +# Step 2c: PR CI's test selection (smart CI) through the parallel harness — the +# selected suites only, unknown suites and tests fail, conditional ones drop. +echo "=== Step 2c: parallel-harness test selection regression ===" +CBM_TEST_RUNNER="$ROOT/$BUILD_DIR/test-runner" bash "$ROOT/tests/test_harness_selection.sh" + +# PR CI's test selection (smart CI, the select-tests job) narrows the parallel +# suite step and nothing else (scripts/run-tests-parallel.sh). A selection that +# was promised but is missing is a plumbing fault: fail, never run silently. +SELECTION_ENV=() +if [ -n "${CBM_TEST_SELECTION_DIR:-}" ]; then + selection_dir="$CBM_TEST_SELECTION_DIR" + if command -v cygpath > /dev/null 2>&1; then + selection_dir="$(cygpath -u "$selection_dir")" + fi + if [ ! -s "$selection_dir/test-only.txt" ]; then + echo "FAIL: CBM_TEST_SELECTION_DIR=$CBM_TEST_SELECTION_DIR holds no test-only.txt" >&2 + exit 1 + fi + SELECTION_ENV=(CBM_TEST_SELECTION_FILE="$selection_dir/test-only.txt") + if [ -f "$selection_dir/optional-suites.txt" ]; then + SELECTION_ENV+=(CBM_TEST_SELECTION_OPTIONAL="$selection_dir/optional-suites.txt") + fi + if [ "${CBM_TEST_SEQUENTIAL:-0}" = "1" ]; then + echo "test.sh: CBM_TEST_SEQUENTIAL=1 runs every test; the selection narrows the parallel harness only" + fi +fi if [ "${CBM_TEST_SEQUENTIAL:-0}" = "1" ]; then make -f Makefile.cbm test ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"} else - make -f Makefile.cbm test-par ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"} + env ${SELECTION_ENV[@]+"${SELECTION_ENV[@]}"} \ + make -f Makefile.cbm test-par ${MAKE_ARGS[@]+"${MAKE_ARGS[@]}"} fi # Step 4: C++ large-TU index-hang regression guard (#410). Runs the PROD binary diff --git a/tests/test_harness_selection.sh b/tests/test_harness_selection.sh new file mode 100644 index 000000000..bc8a7f9b7 --- /dev/null +++ b/tests/test_harness_selection.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# PR CI's test selection in the parallel harness (scripts/run-tests-parallel.sh, +# CBM_TEST_SELECTION_FILE / CBM_TEST_SELECTION_OPTIONAL), driven over the +# runner just built — the same binary and scheduler every leg runs: +# 1. only the selected suites are launched, each runner applies the tokens +# (a narrowed suite deselects its other tests), and the shard manifest +# records the selected list and the selection's digest; +# 2. a selected suite this build does not register FAILS the run, unless the +# selection marks it conditional, when it is dropped with a notice; +# 3. a malformed selection and a selected test the build does not have FAIL. +# A selection that quietly ran less than it named would report green for work +# that never ran. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +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 +# Relative paths, as the real run uses them: the native Windows runner reads +# the selection file the harness writes under its log directory. +cd "${ROOT}" +RUNNER_REL="${RUNNER#"${ROOT}"/}" +LOGS="$(dirname "${RUNNER_REL}")/test-logs-selection" + +# Anything inherited from the caller would decide the cases below. +unset CBM_TEST_ONLY CBM_TEST_ONLY_FILE CBM_TEST_SELECTION_FILE CBM_TEST_SELECTION_OPTIONAL +unset CBM_TEST_SHARD CBM_TEST_LEG + +NAMED_SUITE=dyn_array +OTHER_SUITE=str_intern +OTHER_TEST="$(awk -F'[()]' '/^[[:space:]]*RUN_TEST\(/ { print $2; exit }' "tests/test_${OTHER_SUITE}.c")" +OTHER_TOTAL="$(awk '/^[[:space:]]*RUN_TEST\(/ { n++ } END { print n + 0 }' "tests/test_${OTHER_SUITE}.c")" +if [[ -z "${OTHER_TEST}" || "${OTHER_TOTAL}" -lt 2 ]]; then + echo "fixture suite changed shape: ${OTHER_SUITE}=${OTHER_TOTAL}" >&2 + exit 2 +fi + +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/cbm-selection.XXXXXX")" +trap 'rm -rf "${tmpdir}" "${LOGS}"' EXIT +FAILURES=0 +fail() { + echo "FAIL: $*" >&2 + FAILURES=$((FAILURES + 1)) +} + +# harness [optional suites] -> RC, OUT +harness() { + printf '%b' "$2" > "${tmpdir}/$1.sel" + local optional=() + if [[ -n "${3:-}" ]]; then + printf '%b' "$3" > "${tmpdir}/$1.opt" + optional=(CBM_TEST_SELECTION_OPTIONAL="${tmpdir}/$1.opt") + fi + RC=0 + OUT="$(env CBM_TEST_LOG_DIR="${LOGS}" CBM_TEST_SELECTION_FILE="${tmpdir}/$1.sel" \ + ${optional[@]+"${optional[@]}"} \ + bash scripts/run-tests-parallel.sh "${RUNNER_REL}" 2 2>&1 | tr -d '\r')" || RC=$? +} +ran_suites() { + awk '{ print $1 }' "${LOGS}/results.txt" | sort | tr '\n' ' ' +} + +# 1. Narrowed: one whole suite, one suite by a single test. +harness narrowed "${NAMED_SUITE}\n${OTHER_SUITE}:${OTHER_TEST}\n" +[[ "${RC}" -eq 0 ]] || fail "a valid selection failed (exit ${RC}): ${OUT}" +[[ "$(ran_suites)" == "${NAMED_SUITE} ${OTHER_SUITE} " ]] || + fail "launched suites: $(ran_suites)" +grep -q "deselected" "${LOGS}/${OTHER_SUITE}.log" || + fail "${OTHER_SUITE} ran unnarrowed: $(tail -3 "${LOGS}/${OTHER_SUITE}.log")" +grep -qE '^ *1 passed' "${LOGS}/${OTHER_SUITE}.log" || + fail "${OTHER_SUITE} did not run exactly its selected test" +[[ "$(sed -n '/^--- slice ---$/,$p' "${LOGS}/shard-manifest.txt" | tail -n +2 | sort | tr '\n' ' ')" == \ + "${NAMED_SUITE} ${OTHER_SUITE} " ]] || fail "the manifest slice is not the selected list" +grep -qE '^selection_sha256=[0-9a-f]{64}$' "${LOGS}/shard-manifest.txt" || + fail "the manifest does not record the selection digest" + +# 2. A selected suite this build does not register. +harness unknown_suite "${NAMED_SUITE}\nno_such_suite\n" +[[ "${RC}" -ne 0 && "${OUT}" == *"does not register"*"no_such_suite"* ]] || + fail "an unregistered selected suite did not fail the run (exit ${RC})" +harness optional_suite "${NAMED_SUITE}\nno_such_suite\n" "no_such_suite\n" +[[ "${RC}" -eq 0 && "${OUT}" == *"not in this build, not run: no_such_suite"* ]] || + fail "a conditional suite absent from this build was not dropped (exit ${RC}): ${OUT}" +[[ "$(ran_suites)" == "${NAMED_SUITE} " ]] || fail "optional case launched: $(ran_suites)" + +# 3. Malformed selections and unknown tests fail. +harness malformed "${NAMED_SUITE} extra\n" +[[ "${RC}" -ne 0 ]] || fail "a malformed selection was accepted" +harness empty "" +[[ "${RC}" -ne 0 ]] || fail "an empty selection was accepted" +harness unknown_test "${OTHER_SUITE}:no_such_test_in_this_build\n" +[[ "${RC}" -ne 0 ]] || fail "a selected test the build does not have did not fail the run" + +if [[ "${FAILURES}" -ne 0 ]]; then + echo "test selection harness: ${FAILURES} case(s) failed" >&2 + exit 1 +fi +echo "test selection harness: all cases passed" diff --git a/tests/test_lane_gate_contract.sh b/tests/test_lane_gate_contract.sh index a3eaf928c..da70d43e1 100755 --- a/tests/test_lane_gate_contract.sh +++ b/tests/test_lane_gate_contract.sh @@ -143,6 +143,7 @@ manifest() { # dir leg shard slice-suites... echo "leg=$leg" echo "shard=$shard" echo "list_sha256=$sha" + [ -z "${SEL:-}" ] || echo "selection_sha256=$SEL" echo "--- slice ---" printf '%s\n' "$@" } >"$dir/shard-manifest.txt" @@ -151,6 +152,13 @@ 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 +# A narrowed leg (smart CI): the shards must agree on the selection applied. +SEL=s1 manifest "$WORK/selsame/m1" ubuntu-latest-gcc 1/2 a b +SEL=s1 manifest "$WORK/selsame/m2" ubuntu-latest-gcc 2/2 c +SEL=s1 manifest "$WORK/seldiff/m1" ubuntu-latest-gcc 1/2 a b +SEL=s2 manifest "$WORK/seldiff/m2" ubuntu-latest-gcc 2/2 c +SEL=s1 manifest "$WORK/selhalf/m1" ubuntu-latest-gcc 1/2 a b +manifest "$WORK/selhalf/m2" ubuntu-latest-gcc 2/2 c union "no manifests, no selection given (dry run / release)" 1 "$WORK/empty" union "no manifests, selection 'all'" 1 "$WORK/empty" --lanes all @@ -161,6 +169,9 @@ union "no manifests but the windows leg selected" 1 "$WORK/empty" --lanes '["lin 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' +union "shards that applied one test selection" 0 "$WORK/selsame" --lanes '["lint","security-static","unix-x86"]' +union "shards that applied different test selections" 1 "$WORK/seldiff" --lanes '["lint","security-static","unix-x86"]' +union "a narrowed shard next to a full one" 1 "$WORK/selhalf" --lanes '["lint","security-static","unix-x86"]' if [ "$failures" -ne 0 ]; then echo "LANE GATE CONTRACT VIOLATED: $failures case(s)" >&2 diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index 31a2b00b8..bd5f26b30 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -14,6 +14,11 @@ # 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. +# 4. the test selection (smart CI) is PR-only and fails open: _test.yml's +# test_selection defaults to empty and only test-unix / test-windows +# read it; dry runs and releases never pass it; pr.yml's select-tests +# runs only under vars.TEST_IMPACT_MODE == 'gate', ci-ok does not need it, +# and `test` passes a selection only from a successful narrowed run. # Text-level checks (no YAML library on every leg), like the other workflow # contracts. # @@ -60,9 +65,9 @@ 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") + require(not re.search(r"(?m)^\s+(lanes|shard_profile|test_selection):", body), + f"{caller} job {job} passes a lane or test selection to {called.group(1)}; " + f"dry runs and releases must run every lane and every test") 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") @@ -116,6 +121,32 @@ 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") +# 4. the test selection: PR-only, fails open. +m = re.search(r"(?ms)^ test_selection:\n(.*?)(?=^ \S|^\S|\Z)", text("_test.yml")) +require(m is not None and re.search(r"(?m)^ default: ''$", m.group(1)) is not None, + "_test.yml must declare a test_selection input defaulting to '' (every test)") +readers = sorted(job for job, body in test_wf.items() if "inputs.test_selection" in body) +require(readers == ["test-unix", "test-windows"], + f"only test-unix and test-windows may read test_selection, found {readers}") +for job in ("test-unix", "test-windows"): + require("CBM_TEST_SELECTION_DIR: ${{ inputs.test_selection != '' && " in test_wf.get(job, ""), + f"_test.yml {job} must hand the selection to scripts/test.sh as CBM_TEST_SELECTION_DIR " + f"only when one was passed") +select = pr.get("select-tests", "") +require(re.search(r"(?m)^ if: \$\{\{ vars\.TEST_IMPACT_MODE == 'gate' \}\}$", select) is not None, + "pr.yml select-tests must run only when vars.TEST_IMPACT_MODE == 'gate'") +require("scripts/ci/test-impact-select.sh" in select and "--gate" in select, + "pr.yml select-tests must run scripts/ci/test-impact-select.sh --gate") +require("select-tests" not in needs, + "ci-ok must not need select-tests: a broken selection runs every test, never blocks") +test_job = pr.get("test", "") +require(re.search(r"(?m)^ needs: \[changes, lint, select-tests\]$", test_job) is not None + and "!cancelled()" in test_job, + "pr.yml test must need select-tests and still run when it is skipped or failed") +require("test_selection: ${{ needs.select-tests.result == 'success' && " + "needs.select-tests.outputs.selection == 'narrowed' && 'test-selection' || '' }}" in test_job, + "pr.yml test must pass a selection only from a successful, narrowed select-tests run") + # 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", diff --git a/tests/test_test_impact_git.c b/tests/test_test_impact_git.c index 498a846cc..46c4ced08 100644 --- a/tests/test_test_impact_git.c +++ b/tests/test_test_impact_git.c @@ -60,8 +60,12 @@ int tf_maybe_run_git_facts_diff_probe(int argc, char **argv) { } if (argc == 2 && argv && strcmp(argv[1], "__cbm_git_facts_hostile_diff_probe") == 0) { /* Only this isolated child mutates its environment, before harness threads start. - * Re-enter the existing exact-diff test; the parent process remains untouched. */ + * Re-enter the existing exact-diff test; the parent process remains untouched. + * An inherited CBM_TEST_ONLY_FILE (a narrowed CI run) is cleared: the runner + * unions it with CBM_TEST_ONLY, and one naming this suite would re-run the + * spawning test, which spawns again. */ if (cbm_setenv("GIT_DIFF_OPTS", "--unified=999", 1) != 0 || + cbm_setenv("CBM_TEST_ONLY_FILE", "", 1) != 0 || cbm_setenv( "CBM_TEST_ONLY", "test_impact_git:test_git_facts_diff_uses_merge_base_zero_context_and_nul_metadata", @@ -945,6 +949,54 @@ TEST(test_git_facts_inherited_diff_options_cannot_override_zero_context) { PASS(); } +/* A narrowed CI run hands every runner process a CBM_TEST_ONLY_FILE. The + * hostile-diff child must still run exactly its own test: the runner unions + * the file with CBM_TEST_ONLY, so an inherited file would add its tests, and + * one naming this suite would make the child re-run the spawning test. */ +#define GF_OTHER_TEST "test_git_facts_rejects_malformed_options_and_expected_head_mismatch" +TEST(test_git_facts_hostile_probe_ignores_an_inherited_selection_file) { + const char *home = th_mktempdir("cbm-git-diff-only"); + ASSERT_NOT_NULL(home); + char log[GF_PATH]; + char only[GF_PATH]; + static const char token[] = "test_impact_git:" GF_OTHER_TEST "\n"; + bool prepared = gf_runner_binary && gf_path(log, home, "child.log") && + gf_path(only, home, "only.txt") && gf_write(only, token, sizeof(token) - 1); + const char *inherited = getenv("CBM_TEST_ONLY_FILE"); + char *saved = inherited ? cbm_strdup(inherited) : NULL; + bool set = prepared && (!inherited || saved) && cbm_setenv("CBM_TEST_ONLY_FILE", only, 1) == 0; + const char *argv[] = {gf_runner_binary, "__cbm_git_facts_hostile_diff_probe", NULL}; + cbm_proc_opts_t options = { + .bin = gf_runner_binary, .argv = argv, .log_file = log, .quiet_timeout_ms = 60000}; + cbm_proc_result_t result = {0}; + int launched = set ? cbm_subprocess_run(&options, &result) : -1; + bool restored = saved ? cbm_setenv("CBM_TEST_ONLY_FILE", saved, 1) == 0 + : cbm_unsetenv("CBM_TEST_ONLY_FILE") == 0; + free(saved); + char detail[16384] = ""; + FILE *file = launched == 0 ? cbm_fopen(log, "rb") : NULL; + if (file) { + size_t n = fread(detail, 1, sizeof(detail) - 1, file); + detail[n] = '\0'; + (void)fclose(file); + } + bool own_test_only = launched == 0 && result.outcome == CBM_PROC_CLEAN && + result.exit_code == 0 && + strstr(detail, "test_git_facts_diff_uses_merge_base_zero_context") && + !strstr(detail, GF_OTHER_TEST); + if (!own_test_only && launched == 0) { + fprintf(stderr, "Isolated hostile-diff child: %s\n", detail); + } + int cleanup = th_rmtree(home); + ASSERT_EQ(cleanup, 0); + ASSERT_TRUE(set); + ASSERT_TRUE(restored); + ASSERT_EQ(launched, 0); + ASSERT_TRUE(own_test_only); + PASS(); +} +#undef GF_OTHER_TEST + /* Independent D1 inventory tests. Reuse the native local-object gf_* fixture * helpers above; construct byte-only names in Git objects, never on disk. */ typedef struct { @@ -2340,6 +2392,7 @@ SUITE(test_impact_git) { RUN_TEST(test_git_facts_inventory_cancellation_clears_prefix_and_latches); RUN_TEST(test_git_facts_inventory_output_limit_has_no_prefix_and_latches); RUN_TEST(test_git_facts_inherited_diff_options_cannot_override_zero_context); + RUN_TEST(test_git_facts_hostile_probe_ignores_an_inherited_selection_file); RUN_TEST(test_git_facts_gitfile_redirection_cannot_certify_false_ancestry); RUN_TEST(test_git_facts_pins_actual_merge_base_and_both_refs); RUN_TEST(test_git_facts_ref_index_and_worktree_mutation_cannot_change_snapshot); diff --git a/tests/test_test_impact_selection.sh b/tests/test_test_impact_selection.sh new file mode 100644 index 000000000..dfdb616cd --- /dev/null +++ b/tests/test_test_impact_selection.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# test_test_impact_selection.sh — contract of scripts/test-impact/selection.py, +# the only place a test-impact answer becomes what CI runs. `filter` narrows +# (exit 0, the lane's CBM_TEST_ONLY_FILE lines) only for a well-formed +# selected lane whose filter matches its own suite list; a skipped lane runs +# nothing (exit 11); everything else — run-all answers, unknown schemas or +# encodings, bad tokens, a filter that disagrees with the suites, an +# unreadable answer — must run everything (any other exit, no file left). +# `suites` lists what the team-artifact producer re-runs, `*` for run-all. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SEL="$ROOT/scripts/test-impact/selection.py" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } + +answer() { # name lane-json + printf '{"schema":"cbm.test_impact.v0","decision":"selected","lanes":[%s]}\n' "$2" > "$TMP/$1.json" +} +filter_exit() { # name -> exit code of `filter` + local rc=0 + python3 "$SEL" filter "$TMP/$1.json" --lane unit --out "$TMP/$1.only" 2> /dev/null || rc=$? + echo "$rc" +} + +# Narrowed: one whole suite, one suite by tests. +answer narrowed '{"lane":"unit","decision":"selected","suites":[ + {"suite":"alpha","mode":"whole","whole_reason":["STATIC"]}, + {"suite":"beta","mode":"tests","tests":[{"test":"b_one"},{"test":"b_two"}]}], + "runner_filter":{"encoding":"cbm-test-only-lines","value":"alpha\nbeta:b_one\nbeta:b_two\n"}}' +[ "$(filter_exit narrowed)" = 0 ] || fail "a well-formed selection did not narrow" +[ "$(cat "$TMP/narrowed.only")" = "$(printf 'alpha\nbeta:b_one\nbeta:b_two')" ] || + fail "narrowed filter lines differ: $(cat "$TMP/narrowed.only")" +[ "$(python3 "$SEL" suites "$TMP/narrowed.json")" = "$(printf 'alpha\nbeta')" ] || + fail "suites of a narrowed answer" + +# --optional-out: the selected suites registered only conditionally. +answer conditional '{"lane":"unit","decision":"selected","suites":[ + {"suite":"alpha","mode":"whole","whole_reason":["CONDITIONAL","STATIC"]}, + {"suite":"gamma","mode":"whole","whole_reason":["STATIC"]}], + "runner_filter":{"encoding":"cbm-test-only-lines","value":"alpha\ngamma\n"}}' +rc=0 +python3 "$SEL" filter "$TMP/conditional.json" --lane unit --out "$TMP/conditional.only" \ + --optional-out "$TMP/conditional.optional" 2> /dev/null || rc=$? +[ "$rc" = 0 ] || fail "a selection with a conditional suite did not narrow" +[ "$(cat "$TMP/conditional.optional")" = alpha ] || + fail "optional suites: $(cat "$TMP/conditional.optional")" +echo alpha > "$TMP/stale.optional" +answer stale '{"lane":"unit","decision":"run_all","suites":null,"runner_filter":null}' +rc=0 +python3 "$SEL" filter "$TMP/stale.json" --lane unit --out "$TMP/stale.only" \ + --optional-out "$TMP/stale.optional" 2> /dev/null || rc=$? +[ "$rc" != 0 ] && [ ! -e "$TMP/stale.optional" ] || fail "a run-all answer left an optional-suites file" + +# Skipped lane: nothing to run, an empty file. +answer skipped '{"lane":"unit","decision":"skip","suites":[],"runner_filter":null}' +[ "$(filter_exit skipped)" = 11 ] || fail "a skipped lane did not report nothing-selected" +[ -f "$TMP/skipped.only" ] && [ ! -s "$TMP/skipped.only" ] || fail "a skipped lane must leave an empty file" +[ -z "$(python3 "$SEL" suites "$TMP/skipped.json")" ] || fail "suites of a skipped lane" + +# Every one of these must run everything and leave no filter file. +printf '{"schema":"cbm.test_impact.v0","decision":"run_all","lanes":[{"lane":"unit","decision":"run_all","suites":null,"runner_filter":null}]}\n' \ + > "$TMP/run_all.json" +answer lane_run_all '{"lane":"unit","decision":"run_all","suites":null,"runner_filter":null}' +printf '{"schema":"cbm.test_impact.v9","decision":"selected","lanes":[]}\n' > "$TMP/schema.json" +answer other_lane '{"lane":"integration","decision":"selected","suites":[{"suite":"alpha","mode":"whole"}], + "runner_filter":{"encoding":"cbm-test-only-lines","value":"alpha\n"}}' +answer encoding '{"lane":"unit","decision":"selected","suites":[{"suite":"alpha","mode":"whole"}], + "runner_filter":{"encoding":"csv","value":"alpha"}}' +answer no_filter '{"lane":"unit","decision":"selected","suites":[{"suite":"alpha","mode":"whole"}],"runner_filter":null}' +answer bad_token '{"lane":"unit","decision":"selected","suites":[{"suite":"alpha","mode":"whole"}], + "runner_filter":{"encoding":"cbm-test-only-lines","value":"alpha\n--all\n"}}' +answer disagrees '{"lane":"unit","decision":"selected","suites":[{"suite":"alpha","mode":"whole"},{"suite":"beta","mode":"whole"}], + "runner_filter":{"encoding":"cbm-test-only-lines","value":"alpha\n"}}' +answer empty_tests '{"lane":"unit","decision":"selected","suites":[{"suite":"alpha","mode":"tests","tests":[]}], + "runner_filter":{"encoding":"cbm-test-only-lines","value":"alpha\n"}}' +answer skip_with_suites '{"lane":"unit","decision":"skip","suites":[{"suite":"alpha","mode":"whole"}],"runner_filter":null}' +printf '{"schema":"cbm.test_impact.v0","decision":"selected","lanes":[' > "$TMP/truncated.json" +for name in run_all lane_run_all schema other_lane encoding no_filter bad_token disagrees empty_tests \ + skip_with_suites truncated missing; do + rc="$(filter_exit "$name")" + case "$rc" in 0 | 11) fail "$name narrowed (exit $rc) instead of running everything" ;; esac + [ ! -e "$TMP/$name.only" ] || fail "$name left a filter file behind" +done +# A stale filter from an earlier run is removed, never reused. +echo "alpha" > "$TMP/run_all.only" +[ "$(filter_exit run_all)" != 0 ] && [ ! -e "$TMP/run_all.only" ] || fail "a stale filter file survived" + +[ "$(python3 "$SEL" suites "$TMP/run_all.json")" = "*" ] || fail "suites of a run-all answer" +[ "$(python3 "$SEL" suites "$TMP/lane_run_all.json")" = "*" ] || fail "suites of a run-all lane (suites: null)" +echo "test-impact selection: all cases passed" From 6a29a19c236ecd1faa46fe6d1664ab7ae1d858b1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 5 Oct 2026 23:55:25 +0200 Subject: [PATCH 24/44] fix(test-impact): build and run on Linux GCC, git before 2.44, LeakSanitizer PR CI on the first push of these commits found what no local leg had built yet: - test_impact_result_validate.c: initialize the count GCC's -Wmaybe-uninitialized flags (-Werror on every Linux build: smoke, shadow, analyze). - tests: split one-line `if` chains GCC reports as misleading indentation, and drop a redundant `= {0}` it reads as missing braces (the GCC test runner did not compile). - tests/test_test_impact_engine.c: realpath() into a PATH_MAX buffer; glibc's _FORTIFY_SOURCE aborts on anything smaller, as test_index_resilience.c already notes. - test_impact_git.c: pass --no-lazy-fetch only when the git executable takes it (git 2.44+, probed once per session with the option on --version). Older git rejected it and every engine command failed; Ubuntu 24.04 ships git 2.43. `-c protocol.allow=never` already turns a lazy fetch into an error there, which is what the option asks for. - test_impact_engine.c: the selection and publish job threads release their thread-local node-type bitset cache before they end, as the parallel workers and the MCP index thread do. LeakSanitizer reported it (488 bytes in 13 allocations, test-diag); the Linux arm64 ASan leg reproduced exactly that and is clean with the fix. Signed-off-by: Martin Vogel --- src/mcp/test_impact_engine.c | 6 ++++++ src/mcp/test_impact_git.c | 23 +++++++++++++++++++++-- src/mcp/test_impact_result_validate.c | 2 +- tests/test_pipeline_frozen.c | 1 - tests/test_store_graph_digest.c | 7 +++++-- tests/test_test_impact_engine.c | 9 ++++++++- tests/test_test_impact_git.c | 7 +++++-- 7 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/mcp/test_impact_engine.c b/src/mcp/test_impact_engine.c index 391a6d6d7..93a16f226 100644 --- a/src/mcp/test_impact_engine.c +++ b/src/mcp/test_impact_engine.c @@ -13,6 +13,7 @@ #include "foundation/sha256.h" #include "store/store_graph_digest.h" #include "store/store_impact.h" +#include "helpers.h" /* cbm_kind_in_set_free_cache — the job thread's cache teardown */ #include #include @@ -621,6 +622,10 @@ static void *te_job_main(void *arg) { status = CBM_TEST_IMPACT_CANCELLED; } te_free(&c); + /* The snapshot build extracted on THIS thread, which ends here: its + * thread-local node-type bitset cache goes with it (as the MCP index + * thread and the parallel workers release theirs). */ + cbm_kind_in_set_free_cache(); job->status = status; return NULL; } @@ -678,6 +683,7 @@ static void *te_publish_main(void *arg) { bool ok = te_publish(&c, p); job->status = c.oom ? CBM_TEST_IMPACT_OOM : ok ? CBM_TEST_IMPACT_OK : CBM_TEST_IMPACT_INVALID; te_free(&c); + cbm_kind_in_set_free_cache(); /* this thread's extraction cache, as in te_job_main */ return NULL; } diff --git a/src/mcp/test_impact_git.c b/src/mcp/test_impact_git.c index 299ae03f2..b30a933d7 100644 --- a/src/mcp/test_impact_git.c +++ b/src/mcp/test_impact_git.c @@ -60,6 +60,7 @@ struct cbm_git_facts { cbm_git_facts_error_t stopped; gf_batch_budget_t *batch_budget; /* borrowed stack state during one serialized call */ gf_inventory_cache_t inventory_cache[2]; + bool no_lazy_fetch; /* the executable takes --no-lazy-fetch (gf_probe_no_lazy_fetch) */ }; typedef struct { @@ -486,7 +487,6 @@ static bool gf_run_input(cbm_git_facts_t *facts, const char *const *arguments, i "--no-replace-objects", "--no-pager", "--literal-pathspecs", - "--no-lazy-fetch", "-C", facts->options.root, "-c", @@ -496,11 +496,13 @@ static bool gf_run_input(cbm_git_facts_t *facts, const char *const *arguments, i size_t prefix_count = sizeof(prefix) / sizeof(prefix[0]); size_t directory_count = facts->command_git_dir ? 4 : 0; if (count < 1 || count >= GF_ARG_CAP || - (size_t)count + prefix_count + directory_count + (size_t)facts->git_pin_count + 1 > + (size_t)count + prefix_count + directory_count + (size_t)facts->git_pin_count + 2 > GF_ARG_CAP) return gf_error(error, CBM_GIT_FACTS_INVALID, "Invalid internal Git command"); for (size_t i = 0; i < prefix_count; i++) argv[argc++] = prefix[i]; + if (facts->no_lazy_fetch) + argv[argc++] = "--no-lazy-fetch"; if (facts->command_git_dir) { argv[argc++] = "--git-dir"; argv[argc++] = facts->command_git_dir; @@ -907,6 +909,22 @@ static bool gf_open_identity(cbm_git_facts_t *facts, cbm_git_facts_error_t *erro return gf_gate(facts, error); } +/* --no-lazy-fetch (git 2.44) keeps an object missing from a partial clone + * from being fetched. Older git rejects the option, which failed every engine + * command (Ubuntu 24.04 ships git 2.43). There `-c protocol.allow=never` + * already turns such a fetch into an error, which is what the option asks + * for, so it is passed only when the executable accepts it: one probe per + * facts session, outside the command budget, output discarded. */ +static bool gf_probe_no_lazy_fetch(const char *git) { + const char *argv[] = {git, "--no-lazy-fetch", "--version", NULL}; + cbm_proc_opts_t options = {.bin = git, + .argv = argv, + .cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS, + .strip_git_repo_env = true}; + cbm_proc_result_t result = {0}; + return cbm_subprocess_run(&options, &result) == 0 && result.outcome == CBM_PROC_CLEAN; +} + cbm_git_facts_t *cbm_git_facts_open(const cbm_git_facts_options_t *options, cbm_git_facts_error_t *error) { cbm_git_facts_error_t local; @@ -959,6 +977,7 @@ cbm_git_facts_t *cbm_git_facts_open(const cbm_git_facts_options_t *options, cbm_git_facts_free(facts); return NULL; } + facts->no_lazy_fetch = gf_probe_no_lazy_fetch(facts->options.git_executable); if (!gf_open_identity(facts, error)) { cbm_git_facts_free(facts); return NULL; diff --git a/src/mcp/test_impact_result_validate.c b/src/mcp/test_impact_result_validate.c index ac5200741..132598cf6 100644 --- a/src/mcp/test_impact_result_validate.c +++ b/src/mcp/test_impact_result_validate.c @@ -27,7 +27,7 @@ static bool tir_label(tir_context *c, const char *s, tir_bytes *out) { static bool tir_string_array(tir_context *c, const char *const *strings, int count, tir_bytes **saved) { - size_t n; + size_t n = 0; if (!tir_count(c, strings, count, &n)) return false; tir_bytes *copy = saved ? tir_alloc(c, n, sizeof(*copy)) : NULL; diff --git a/tests/test_pipeline_frozen.c b/tests/test_pipeline_frozen.c index c4fe336d1..bb6b120e5 100644 --- a/tests/test_pipeline_frozen.c +++ b/tests/test_pipeline_frozen.c @@ -258,7 +258,6 @@ static cbm_pipeline_frozen_inputs_t pf_inputs(pf_fixture_t *f, const char *confi .config_bytes = config, .config_len = config ? strlen(config) : 0, .pinned_git = git, - .resource_policy = {0}, .cancelled = cancelled}; } static cbm_pipeline_frozen_status_t pf_create(pf_fixture_t *f, const char *config, diff --git a/tests/test_store_graph_digest.c b/tests/test_store_graph_digest.c index 6e69fa9b8..abb32ba7c 100644 --- a/tests/test_store_graph_digest.c +++ b/tests/test_store_graph_digest.c @@ -420,9 +420,12 @@ TEST(store_graph_digest_invalid_arguments_missing_project_and_initial_latches) { int opened=s && collation?cbm_store_read_scope_open(s,NULL,NULL,&scope):CBM_STORE_ERR; cbm_store_graph_digest_limits_t lim=gd_room;cbm_store_graph_digest_t out;memset(&out,0xa5,sizeof(out)); const unsigned char *p=(const unsigned char *)"p";size_t len=1; - if(which==2)p=NULL;if(which==3)len=0;if(which==4)len=(size_t)INT_MAX+1; + if(which==2)p=NULL; + if(which==3)len=0; + if(which==4)len=(size_t)INT_MAX+1; if(which==5){p=(const unsigned char *)"p\0q";len=3;} - if(which==7)lim.max_rows=0;if(which==8)lim.max_framed_bytes=0; + if(which==7)lim.max_rows=0; + if(which==8)lim.max_framed_bytes=0; if(which==9)lim.max_framed_bytes=UINT64_MAX/8+1; if(which==10)p=(const unsigned char *)"P"; int rc=opened==CBM_STORE_OK?cbm_store_graph_digest(which==0?NULL:scope,p,len,which==6?NULL:&lim,which==1?NULL:&out):-1; diff --git a/tests/test_test_impact_engine.c b/tests/test_test_impact_engine.c index 7bae6388b..8d735340b 100644 --- a/tests/test_test_impact_engine.c +++ b/tests/test_test_impact_engine.c @@ -16,11 +16,18 @@ #include "foundation/subprocess.h" #include "mcp/test_impact_engine.h" +#include #include #include #include #include +/* glibc's _FORTIFY_SOURCE realpath() aborts unless the output buffer is at + * least PATH_MAX bytes, independent of the actual path length. */ +#ifndef PATH_MAX +#define PATH_MAX 4096 +#endif + typedef struct { char git[1024]; char home[1024]; @@ -126,7 +133,7 @@ static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char memset(fx, 0, sizeof(*fx)); const char *git = cbm_find_cli("git", cbm_get_home_dir()); const char *home = th_mktempdir("cbm-ti-engine"); - char real[1024]; + char real[PATH_MAX]; if (!git || !home || !realpath(home, real) || strlen(git) >= sizeof(fx->git)) { return false; } diff --git a/tests/test_test_impact_git.c b/tests/test_test_impact_git.c index 46c4ced08..ed8483de5 100644 --- a/tests/test_test_impact_git.c +++ b/tests/test_test_impact_git.c @@ -2002,7 +2002,9 @@ TEST(test_git_blob_batch_validates_full_selection_and_empty_requests) { gfb_error(facts,&request,NULL,CBM_GIT_FACTS_INVALID) && ok; for(int i=0;control && i<3;i++) { cbm_git_blob_batch_limits_t zero=limits; - if(i==0)zero.max_entries=0;if(i==1)zero.max_input_bytes=0;if(i==2)zero.max_arena_bytes=0; + if(i==0)zero.max_entries=0; + if(i==1)zero.max_input_bytes=0; + if(i==2)zero.max_arena_bytes=0; ok=gfb_error(facts,&request,&zero,CBM_GIT_FACTS_INVALID) && ok; } bool no_out=control && !cbm_git_facts_read_blob_batch(facts,&request,&limits,NULL,&error) && @@ -2265,7 +2267,8 @@ static cbm_git_bytes_t gfb_concat(CBMArena *arena,cbm_git_bytes_t a,cbm_git_byte if(a.length>SIZE_MAX-b.length)return (cbm_git_bytes_t){NULL,0}; size_t size=a.length+b.length;unsigned char *bytes=cbm_arena_alloc(arena,size?size:1); if(!bytes)return (cbm_git_bytes_t){NULL,0}; - if(a.length)memcpy(bytes,a.data,a.length);if(b.length)memcpy(bytes+a.length,b.data,b.length); + if(a.length)memcpy(bytes,a.data,a.length); + if(b.length)memcpy(bytes+a.length,b.data,b.length); return (cbm_git_bytes_t){bytes,size}; } From 496a292f0e92cbda09595e4eee41e385a908e62e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 00:14:25 +0200 Subject: [PATCH 25/44] fix(ci): commit the test-impact scripts executable pr.yml and test-impact-artifact.yml run scripts/ci/test-impact-select.sh, -bundle.sh and -publish.sh as commands, but they were committed 100644 (core.fileMode is false in this clone, so the disk mode never reached the index), and the PR shadow job failed with "Permission denied" (exit 126). tests/test_script_exec_bit_contract.sh reports exactly this; it skipped the scripts locally while they were still untracked. Signed-off-by: Martin Vogel --- scripts/ci/test-impact-bundle.sh | 0 scripts/ci/test-impact-publish.sh | 0 scripts/ci/test-impact-select.sh | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/ci/test-impact-bundle.sh mode change 100644 => 100755 scripts/ci/test-impact-publish.sh mode change 100644 => 100755 scripts/ci/test-impact-select.sh diff --git a/scripts/ci/test-impact-bundle.sh b/scripts/ci/test-impact-bundle.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/test-impact-publish.sh b/scripts/ci/test-impact-publish.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/test-impact-select.sh b/scripts/ci/test-impact-select.sh old mode 100644 new mode 100755 From ec0bc46089db40ad200816b9e5b696640f0ec030 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 01:25:23 +0200 Subject: [PATCH 26/44] fix(test): keep the profile-text fixtures byte-exact on Windows tests/test_profile_text_parser.py checks the SHA-256 of the llvm-profdata text captures in tests/fixtures/profile_text/. With git's Windows default (core.autocrlf=true) they checked out as CRLF and all four hashes failed on test-windows. Mark them -text, as the Chialisp corpus already is. Signed-off-by: Martin Vogel --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index 1b2d4fb75..e3a07d711 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,3 +15,7 @@ scripts/hooks/pre-commit text eol=lf # Chialisp grammar corpus: one case asserts CRLF comment termination, so this # file must keep its exact bytes on every platform (no eol conversion). (#919) tools/tree-sitter-chialisp/test/corpus/comments.txt -text + +# llvm-profdata text captures: tests/test_profile_text_parser.py checks their +# SHA-256, so they keep their exact bytes on every platform (no eol conversion). +tests/fixtures/profile_text/*.proftext -text From 42a932020092cb014f5fa3338f0ccfdf1e9a329a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 02:44:56 +0200 Subject: [PATCH 27/44] graph: one identity per conditional definition, SPAWNS edges, no cross-language guesses The test-impact engine selects tests by walking the graph, so it can only be as right as the graph. Three gaps made it wrong: One identity for a definition's mutually exclusive variants - A definition written once per #if/#else branch, per #[cfg], per platform file (foo_linux.c / foo_win.c, platform directories) or per Go build tag is ONE node. The graph buffer keeps every variant's span in a `variants` node property (definition labels only), so a change in any variant maps to the definition. - Extraction now sees definitions inside conditional blocks where it skipped them: C++/C#/ObjC class members under #if (one cursor walk, conditional block types per language in lang_specs), Elixir defs under if/unless, Fortran .F90/.F95/.F03/.F08, Rust #[cfg] twins (no more `name#cfg(...)` QNs that lost every call), C platform files and directories (non-static file-scope functions). - Haskell: the vendored scanner reads only an #if group's first branch. The grammar stays upstream; every further branch is parsed as its own same-length view (cpp_branch_views.c) and adds what the raw parse missed. haskell/bytestring: no new partial parses, +27 functions, IMPORTS 322 -> 329, 131 definitions with variant spans. - Incremental closure repair re-parses every file of such a definition when one of them changes (variant partners through DEFINES), so a repair equals a full index. No cross-language name guesses - A name-only resolution (unique_name, suffix_match, qualified_suffix, field_type_hint, and same_module across files that only share a path stem) never binds a symbol another language defines. - Bash imports are the files a script sources: every top-level command was read as an import of its name (`set -e` imported "set", bound to a TSX method in 137 scripts here). - This repository: cross-language CALLS 5,916 -> 5,091; the remainder are C/C++ pairs, builtins and same-stem module collisions (recorded). SPAWNS edges (per language, like the HTTP client patterns) - A call that starts a program (subprocess.run, exec.Command, child_process.spawn, posix_spawn, system, Process.Start, Command::new, ... about 30 languages, through the file's own imports) gets a SPAWNS edge to a Process node named after the program (literal first word, or ) instead of a CALLS guess. No target resolution. Both resolvers ask at the same point (parity test). The engine follows the graph - Seeds map changed lines through every variant span; a test written once per #if branch is one test; when a change reaches a non-test `main`, the tests that spawn processes are reached too (the process runs main, and main reaches everything). Signed-off-by: Martin Vogel --- Makefile.cbm | 7 +- README.md | 3 +- internal/cbm/cbm.c | 100 ++++ internal/cbm/cpp_branch_views.c | 183 +++++++ internal/cbm/cpp_branch_views.h | 34 ++ internal/cbm/extract_defs.c | 191 ++++--- internal/cbm/extract_imports.c | 16 +- internal/cbm/extract_unified.c | 10 +- internal/cbm/helpers.c | 140 ++++++ internal/cbm/helpers.h | 11 + internal/cbm/lang_specs.c | 22 + internal/cbm/lang_specs.h | 7 + internal/cbm/spawn_patterns.c | 744 ++++++++++++++++++++++++++++ internal/cbm/spawn_patterns.h | 71 +++ src/cli/cli.c | 2 +- src/discover/language.c | 7 +- src/graph_buffer/graph_buffer.c | 191 ++++++- src/mcp/test_impact_engine_reach.c | 93 +++- src/mcp/test_impact_seed.c | 65 ++- src/mcp/test_impact_seed.h | 4 + src/pipeline/pass_calls.c | 25 + src/pipeline/pass_definitions.c | 6 +- src/pipeline/pass_parallel.c | 37 +- src/pipeline/pass_spawns.c | 115 +++++ src/pipeline/pipeline.h | 35 +- src/pipeline/pipeline_incremental.c | 140 +++++- src/pipeline/pipeline_internal.h | 24 + src/pipeline/registry.c | 233 ++++++++- src/store/store.c | 151 ++++-- src/store/store.h | 18 + tests/test_conditional_variants.c | 635 ++++++++++++++++++++++++ tests/test_extraction.c | 36 +- tests/test_lang_contract.c | 25 +- tests/test_main.c | 44 +- tests/test_pipeline.c | 228 +++++++++ tests/test_registry.c | 110 ++++ tests/test_spawns.c | 376 ++++++++++++++ tests/test_test_impact_engine.c | 120 ++++- tests/test_test_impact_seed.c | 85 ++++ 39 files changed, 4130 insertions(+), 214 deletions(-) create mode 100644 internal/cbm/cpp_branch_views.c create mode 100644 internal/cbm/cpp_branch_views.h create mode 100644 internal/cbm/spawn_patterns.c create mode 100644 internal/cbm/spawn_patterns.h create mode 100644 src/pipeline/pass_spawns.c create mode 100644 tests/test_conditional_variants.c create mode 100644 tests/test_spawns.c diff --git a/Makefile.cbm b/Makefile.cbm index e249e1686..59febbbfc 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -375,12 +375,14 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/extract_dbt.c \ $(CBM_DIR)/sql_values.c \ $(CBM_DIR)/helpers.c \ + $(CBM_DIR)/cpp_branch_views.c \ $(CBM_DIR)/result_compact.c \ $(CBM_DIR)/result_spill.c \ $(CBM_DIR)/lang_specs.c \ $(CBM_DIR)/macro_table.c \ $(CBM_DIR)/iris_export_xml.c \ - $(CBM_DIR)/service_patterns.c + $(CBM_DIR)/service_patterns.c \ + $(CBM_DIR)/spawn_patterns.c # LSP resolvers (compiled as one unit via lsp_all.c) LSP_SRCS = $(CBM_DIR)/lsp_all.c @@ -481,6 +483,7 @@ PIPELINE_SRCS = \ src/pipeline/pass_parallel.c \ src/pipeline/pass_definitions.c \ src/pipeline/pass_calls.c \ + src/pipeline/pass_spawns.c \ src/pipeline/pass_lsp_cross.c \ src/pipeline/pass_usages.c \ src/pipeline/pass_semantic.c \ @@ -790,7 +793,7 @@ TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c TEST_RUST_LSP_SRCS = tests/test_rust_lsp.c -TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/test_lang_contract.c tests/test_edge_imports.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_node_creation_probe.c tests/test_edge_types_probe.c tests/test_convergence_probe.c tests/test_matrix_known_classes.c tests/test_matrix_new_constructs.c tests/test_grammar_probe_a.c tests/test_grammar_probe_b.c tests/test_grammar_probe_c.c tests/test_grammar_probe_d.c tests/test_grammar_probe_e.c tests/test_grammar_probe_f.c tests/test_grammar_probe_g.c +TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/test_lang_contract.c tests/test_edge_imports.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_node_creation_probe.c tests/test_edge_types_probe.c tests/test_convergence_probe.c tests/test_matrix_known_classes.c tests/test_matrix_new_constructs.c tests/test_grammar_probe_a.c tests/test_grammar_probe_b.c tests/test_grammar_probe_c.c tests/test_grammar_probe_d.c tests/test_grammar_probe_e.c tests/test_grammar_probe_f.c tests/test_grammar_probe_g.c tests/test_conditional_variants.c tests/test_spawns.c TEST_TRACES_SRCS = tests/test_traces.c diff --git a/README.md b/README.md index f644775a7..da4d824e0 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ The install script placed beside the binary is **reported, not deleted** — uni - `USAGE` — an identifier is used, but a unique callable target is not proven (including ambiguous or complex expressions) - `IMPORTS`, `DEFINES`, `IMPLEMENTS`, `INHERITS` - `HTTP_CALLS`, `ASYNC_CALLS` (cross-service) +- `SPAWNS` — the caller starts another program (`subprocess.run`, `exec.Command`, `posix_spawn`, `Process.Start`, ...); the target is a `Process` node named after the program, or `` when the program is not a literal - `EMITS`, `LISTENS_ON` (channels) - `DATA_FLOWS` with arg-to-param mapping + field access chains - `SIMILAR_TO` (MinHash + LSH near-clone detection, Jaccard scored) @@ -740,7 +741,7 @@ mode. `index_status` keeps describing the published graph and its freshness. ### Edge Types -`CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `HTTP_CALLS`, `ASYNC_CALLS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `REFERENCES_FILE`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH` +`CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `HTTP_CALLS`, `ASYNC_CALLS`, `SPAWNS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `REFERENCES_FILE`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH` ### Qualified Names diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 458bf6955..52393419b 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -22,6 +22,7 @@ #include "lsp/rust_lsp.h" #include "preprocessor.h" #include "sql_values.h" // #1735: literal INSERT rows kept out of the SQL parse +#include "cpp_branch_views.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" // cbm_fopen — crash-supervisor per-file marker write #include "foundation/hash_table.h" // CBMHashTable — crash-supervisor quarantine set @@ -2146,6 +2147,102 @@ static bool cbm_sql_values_exclusion_on(const char *rel_path) { return true; } +/* Drop the entries a branch view re-extracted from code outside the + * conditionals: a definition with a QN the file already has at the same line, + * an import with the same name and path. */ +static void cpp_view_drop_seen(CBMFileResult *r, int defs_before, int imports_before) { + int w = defs_before; + for (int i = defs_before; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + bool seen = false; + for (int j = 0; j < w && !seen; j++) { + const CBMDefinition *e = &r->defs.items[j]; + seen = e->start_line == d->start_line && e->qualified_name && d->qualified_name && + strcmp(e->qualified_name, d->qualified_name) == 0; + } + if (!seen) { + r->defs.items[w++] = *d; + } + } + r->defs.count = w; + w = imports_before; + for (int i = imports_before; i < r->imports.count; i++) { + const CBMImport *m = &r->imports.items[i]; + bool seen = false; + for (int j = 0; j < w && !seen; j++) { + const CBMImport *e = &r->imports.items[j]; + seen = e->local_name && m->local_name && e->module_path && m->module_path && + strcmp(e->local_name, m->local_name) == 0 && + strcmp(e->module_path, m->module_path) == 0; + } + if (!seen) { + r->imports.items[w++] = *m; + } + } + r->imports.count = w; +} + +/* Haskell: the scanner parses the first branch of an #if group and swallows + * the rest up to #endif as one token, so what is written once per + * configuration (a definition, an import, a call in the other branch) was + * seen in its first variant only. Every further branch is parsed as its own + * view (cpp_branch_views.h: same bytes and lines, the other branches and the + * conditional lines blanked) and extracted into the same result, under the + * raw parse's budget. A definition at another line under a QN the file + * already has is that definition's variant; the graph keeps one identity with + * both spans. Calls and usages outside the conditionals come back as + * duplicates, which the pipeline dedups by caller and callee. The raw parse + * alone decides the file's parse status. */ +static void extract_cpp_branch_views(const CBMExtractCtx *raw, const TSLanguage *ts_lang, + int64_t timeout_micros) { + int views = cbm_cpp_branch_view_count(raw->arena, raw->source, raw->source_len); + for (int v = 1; v <= views; v++) { + char *view = cbm_cpp_branch_view(raw->arena, raw->source, raw->source_len, v); + TSParser *parser = view ? get_thread_parser(ts_lang, raw->language) : NULL; + if (!parser) { + return; + } + ts_parser_reset(parser); + CBMStringInput input = {view, (uint32_t)raw->source_len}; + TSInput ts_input = {&input, cbm_string_read, TSInputEncodingUTF8, NULL}; + TSParseOptions opts = {0}; + CBMParseBudget budget = {0}; // cppcheck-suppress unreadVariable + if (timeout_micros > 0) { + uint64_t budget_ns = (uint64_t)timeout_micros * USEC_TO_NSEC; + budget.cpu_deadline_ns = cbm_thread_cpu_time_ns() + budget_ns; + budget.wall_ceiling_ns = now_ns() + budget_ns * CBM_PARSE_WALL_CEILING_FACTOR; + opts.payload = &budget; + opts.progress_callback = cbm_timeout_cb; + } + TSTree *tree = ts_parser_parse_with_options(parser, NULL, ts_input, opts); + if (!tree) { + return; + } + CBMExtractCtx vctx = { + .arena = raw->arena, + .scratch = raw->scratch, + .result = raw->result, + .source = view, + .source_len = raw->source_len, + .language = raw->language, + .project = raw->project, + .rel_path = raw->rel_path, + .module_qn = raw->module_qn, + .root = ts_tree_root_node(tree), + .macro_table = raw->macro_table, + .return_type_table = raw->return_type_table, + .walk_budget_nodes = cbm_walk_max_nodes(), + }; + int defs_before = raw->result->defs.count; + int imports_before = raw->result->imports.count; + cbm_extract_definitions(&vctx); + cbm_extract_imports(&vctx); + cbm_extract_unified(&vctx); + cpp_view_drop_seen(raw->result, defs_before, imports_before); + ts_tree_delete(tree); + } +} + static CBMFileResult *extract_file_ex_body(const char *source, int source_len, CBMLanguage language, const char *project, const char *rel_path, int64_t timeout_micros, const char **extra_defines, @@ -2353,6 +2450,9 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C cbm_test_declarations_finish(&ctx); cbm_extract_imports(&ctx); cbm_extract_unified(&ctx); + if (cbm_lang_needs_cpp_branch_views(language)) { + extract_cpp_branch_views(&ctx, ts_lang, timeout_micros); + } result->tree_nodes = ts_node_descendant_count(root); result->walk_nodes_visited = ctx.walk_nodes_visited; if (ctx.walk_budget_exhausted) { diff --git a/internal/cbm/cpp_branch_views.c b/internal/cbm/cpp_branch_views.c new file mode 100644 index 000000000..fd776bf23 --- /dev/null +++ b/internal/cbm/cpp_branch_views.c @@ -0,0 +1,183 @@ +/* + * cpp_branch_views.c — one parse view per #if/#else branch (see the header). + */ +#include "cpp_branch_views.h" + +#include + +enum { CPP_LINE_CODE, CPP_LINE_START, CPP_LINE_ELSE, CPP_LINE_END }; + +enum { CPP_NEST_MAX = 64 }; + +bool cbm_lang_needs_cpp_branch_views(CBMLanguage lang) { + return lang == CBM_LANG_HASKELL; +} + +static bool cpp_word_char(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; +} + +// Classify the line at src[pos..end): a conditional directive is a '#' in +// column 0, optional blanks, then the directive word — the same lines the +// Haskell scanner treats as conditionals. +static int cpp_line_kind(const char *src, int pos, int end) { + if (pos >= end || src[pos] != '#') { + return CPP_LINE_CODE; + } + int p = pos + 1; + while (p < end && (src[p] == ' ' || src[p] == '\t')) { + p++; + } + int w = p; + while (w < end && cpp_word_char(src[w])) { + w++; + } + static const struct { + const char *word; + int kind; + } words[] = { + {"if", CPP_LINE_START}, {"ifdef", CPP_LINE_START}, {"ifndef", CPP_LINE_START}, + {"else", CPP_LINE_ELSE}, {"elif", CPP_LINE_ELSE}, {"elifdef", CPP_LINE_ELSE}, + {"elifndef", CPP_LINE_ELSE}, {"endif", CPP_LINE_END}, + }; + size_t n = (size_t)(w - p); + for (size_t i = 0; i < sizeof(words) / sizeof(words[0]); i++) { + if (strlen(words[i].word) == n && memcmp(src + p, words[i].word, n) == 0) { + return words[i].kind; + } + } + return CPP_LINE_CODE; +} + +static int cpp_line_end(const char *src, int pos, int len) { + const char *nl = memchr(src + pos, '\n', (size_t)(len - pos)); + return nl ? (int)(nl - src) : len; +} + +typedef struct { + int group; // groups are numbered in order of their #if line + int branch; // branch the current line is in + bool outer; // the enclosing context is kept +} cpp_frame_t; + +typedef struct { + cpp_frame_t frames[CPP_NEST_MAX]; + int depth; + int overflow; // groups nested past CPP_NEST_MAX are not tracked + int next; +} cpp_nest_t; + +// Track one line. Returns the innermost tracked group when the line is one of +// its #else/#elif lines, else NULL. +static cpp_frame_t *cpp_nest_step(cpp_nest_t *n, int kind, bool active) { + switch (kind) { + case CPP_LINE_START: + if (n->depth < CPP_NEST_MAX && n->overflow == 0) { + n->frames[n->depth++] = (cpp_frame_t){n->next, 0, active}; + } else { + n->overflow++; + } + n->next++; + return NULL; + case CPP_LINE_ELSE: + if (n->overflow > 0 || n->depth == 0) { + return NULL; + } + n->frames[n->depth - 1].branch++; + return &n->frames[n->depth - 1]; + case CPP_LINE_END: + if (n->overflow > 0) { + n->overflow--; + } else if (n->depth > 0) { + n->depth--; + } + return NULL; + default: + return NULL; + } +} + +// Branch count per group. Returns the group count, or -1 on allocation failure. +static int cpp_group_branches(CBMArena *a, const char *src, int len, int **out) { + int groups = 0; + for (int pos = 0; pos < len;) { + int end = cpp_line_end(src, pos, len); + groups += cpp_line_kind(src, pos, end) == CPP_LINE_START; + pos = end + 1; + } + *out = NULL; + if (groups == 0) { + return 0; + } + int *branches = cbm_arena_alloc(a, sizeof(int) * (size_t)groups); + if (!branches) { + return -1; + } + for (int g = 0; g < groups; g++) { + branches[g] = 1; + } + cpp_nest_t nest = {.depth = 0}; + for (int pos = 0; pos < len;) { + int end = cpp_line_end(src, pos, len); + cpp_frame_t *frame = cpp_nest_step(&nest, cpp_line_kind(src, pos, end), true); + if (frame) { + branches[frame->group] = frame->branch + 1; + } + pos = end + 1; + } + *out = branches; + return groups; +} + +int cbm_cpp_branch_view_count(CBMArena *a, const char *src, int len) { + int *branches = NULL; + int groups = cpp_group_branches(a, src, len, &branches); + int most = 1; + for (int g = 0; g < groups; g++) { + if (branches[g] > most) { + most = branches[g]; + } + } + int views = most - 1; + return views > CBM_CPP_BRANCH_VIEWS_MAX ? CBM_CPP_BRANCH_VIEWS_MAX : views; +} + +// The current line belongs to the view: every enclosing group sits in the +// branch the view keeps. +static bool cpp_view_keeps(const cpp_nest_t *n, const int *branches, int view) { + if (n->depth == 0) { + return true; + } + const cpp_frame_t *top = &n->frames[n->depth - 1]; + int nb = branches[top->group]; + int kept = 0; + if (nb >= 2) { + kept = view < nb - 1 ? view : nb - 1; + } + return top->outer && top->branch == kept; +} + +char *cbm_cpp_branch_view(CBMArena *a, const char *src, int len, int view) { + int *branches = NULL; + int groups = cpp_group_branches(a, src, len, &branches); + char *buf = groups >= 0 ? cbm_arena_alloc(a, (size_t)len + 1) : NULL; + if (!buf) { + return NULL; + } + memcpy(buf, src, (size_t)len); + buf[len] = '\0'; + cpp_nest_t nest = {.depth = 0}; + for (int pos = 0; pos < len;) { + int end = cpp_line_end(src, pos, len); + bool kept = cpp_view_keeps(&nest, branches, view); + int kind = cpp_line_kind(src, pos, end); + cpp_nest_step(&nest, kind, kept); + if (kind != CPP_LINE_CODE || !kept) { + for (int i = pos; i < end; i++) { + buf[i] = buf[i] == '\r' ? '\r' : ' '; + } + } + pos = end + 1; + } + return buf; +} diff --git a/internal/cbm/cpp_branch_views.h b/internal/cbm/cpp_branch_views.h new file mode 100644 index 000000000..3ef114c1e --- /dev/null +++ b/internal/cbm/cpp_branch_views.h @@ -0,0 +1,34 @@ +#ifndef CBM_CPP_BRANCH_VIEWS_H +#define CBM_CPP_BRANCH_VIEWS_H + +#include "cbm.h" + +/* + * Branch views of a source whose grammar parses only the first branch of an + * #if/#else group (the Haskell scanner swallows everything from #else to + * #endif as one token, so a definition written once per configuration is seen + * in its first variant only). + * + * A view keeps the source byte for byte and line for line: the branch it + * selects in every group stays, the other branches and all conditional lines + * (#if/#ifdef/#ifndef/#else/#elif.../#endif) become spaces, newlines stay. So + * every span extracted from a view is a raw-source span. + * + * View v (1-based) selects branch min(v, branches - 1) of a group with at + * least two branches, and the only branch of a group without one. + */ + +#define CBM_CPP_BRANCH_VIEWS_MAX 3 + +// True when `lang`'s grammar parses only the first branch of a conditional. +bool cbm_lang_needs_cpp_branch_views(CBMLanguage lang); + +// Number of views the source needs (0 when no group has a second branch), +// at most CBM_CPP_BRANCH_VIEWS_MAX. +int cbm_cpp_branch_view_count(CBMArena *a, const char *src, int len); + +// View `view` (1..count) as an arena buffer of exactly `len` bytes plus a NUL, +// or NULL on allocation failure. +char *cbm_cpp_branch_view(CBMArena *a, const char *src, int len, int view); + +#endif diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index d1e45b13a..55cd6f7af 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3561,12 +3561,6 @@ static const char **extract_decorators(CBMArena *a, TSNode node, const char *sou return result; } -/* Rust: two same-named functions guarded by mutually-exclusive #[cfg(...)] - * attributes both parse as distinct function_item nodes and otherwise receive - * the SAME qualified_name, so the second graph upsert silently overwrites the - * first and one branch is lost (#495). Fold the cfg predicate into the QN so - * each cfg-gated twin gets a DISTINCT, predicate-encoding QN. Returns the - * (possibly suffixed) QN; the original QN when no cfg attribute is present. */ /* Rust: mark a function as a test when it carries a test attribute (#855). * cbm's test detection is otherwise file-path-based (cbm_is_test_file: * *_test.rs / test_*), so inline #[test]/#[tokio::test] functions inside a @@ -3596,32 +3590,6 @@ static bool rust_def_is_test(const char *const *decorators) { return false; } -static const char *rust_cfg_qualified_name(CBMArena *a, const char *base_qn, - const char *const *decorators) { - if (!decorators) { - return base_qn; - } - for (int i = 0; decorators[i]; i++) { - const char *cfg = strstr(decorators[i], "cfg("); - if (!cfg) { - continue; - } - /* Build a compact predicate suffix from the cfg(...) text, dropping - * whitespace and quotes so the QN stays readable and stable. */ - char buf[CBM_SZ_256]; - size_t bi = 0; - for (const char *p = cfg; *p && bi + 1 < sizeof(buf); p++) { - if (*p == ' ' || *p == '\t' || *p == '"' || *p == '\'') { - continue; - } - buf[bi++] = *p; - } - buf[bi] = '\0'; - return cbm_arena_sprintf(a, "%s#%s", base_qn, buf); - } - return base_qn; -} - // Extract base class name text from a single base_class child node. static char *extract_cpp_base_text(CBMArena *a, TSNode bc, const char *source) { const char *bk = ts_node_type(bc); @@ -6261,6 +6229,14 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * proj.myapp.db.Func, not proj.myapp.db.conn.Func). Other langs unchanged. */ def.qualified_name = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, qn_name, ctx->language); + /* C-family platform variants (foo_win.c / foo_posix.c, src/unix/ vs + * src/win/): an external-linkage file-scope function takes its + * platform-neutral QN, so its variants are one definition. The call-scope + * twin in extract_unified.c applies the same rule. */ + if (!ctx->enclosing_class_qn) { + def.qualified_name = cbm_platform_variant_qn(a, ctx->language, ctx->project, ctx->rel_path, + qn_name, def.qualified_name, node); + } /* A free function declared inside a namespace (C++/C#/PHP) is qualified by * the namespace scope the def walk carries (enclosing_class_qn was extended * by is_namespace_scope_kind), so `ns::serialize` is `proj.file.ns.serialize` @@ -6385,10 +6361,13 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec def.decorators = extract_decorators(a, node, ctx->source, ctx->language, spec); extract_route_from_decorators(a, node, ctx->source, spec, &def.route_path, &def.route_method); - // Rust: disambiguate cfg-gated twin functions by folding the #[cfg(...)] - // predicate into the QN so both branches survive the graph upsert (#495). + // Rust: cfg-gated twins (#[cfg(windows)] fn f / #[cfg(not(windows))] fn f) + // keep the plain QN: they are variants of ONE definition, and the graph + // keeps every twin's span and the union of their bodies' calls on one node + // (graph_buffer.c, "Definition variants"). #495's lost branch is a variant + // now; the old per-twin `f#cfg(...)` QNs left the bodies' calls without a + // source node (calls are scoped to the plain QN). if (ctx->language == CBM_LANG_RUST) { - def.qualified_name = rust_cfg_qualified_name(a, def.qualified_name, def.decorators); def.is_test = rust_def_is_test(def.decorators); } @@ -7820,20 +7799,94 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, } // Extract methods from an ObjC implementation_definition node. -static void extract_objc_impl_methods(CBMExtractCtx *ctx, TSNode impl_node, const char *class_qn, - const CBMLangSpec *spec) { - uint32_t nc = ts_node_child_count(impl_node); - for (uint32_t j = 0; j < nc; j++) { - TSNode inner = ts_node_child(impl_node, j); - if (ts_node_is_null(inner)) { +/* Members of a class body, flattened through conditional blocks: a method, + * field or variable inside #if/#ifdef/#else/#elif (cbm_conditional_block_types: + * C, C++, CUDA, Objective-C, C#, ...) is still a member of its class, and its + * #else twin is a variant of the same member (graph_buffer.c, "Definition + * variants"). One tree cursor: linear in the body's width, no recursion. */ +enum { MEMBER_CONDITIONAL_DEPTH_MAX = 16 }; + +typedef struct { + TSTreeCursor cursor; + const char **conditional; + int depth; /* conditional blocks entered below the body */ + bool started; + bool named; +} member_iter_t; + +static void member_iter_init(member_iter_t *it, TSNode body, CBMLanguage lang, bool named) { + it->cursor = ts_tree_cursor_new(body); + it->conditional = cbm_conditional_block_types(lang); + it->depth = 0; + it->started = false; + it->named = named; +} + +static void member_iter_done(member_iter_t *it) { + ts_tree_cursor_delete(&it->cursor); +} + +static bool member_iter_advance(member_iter_t *it) { + if (!it->started) { + it->started = true; + return ts_tree_cursor_goto_first_child(&it->cursor); + } + while (!ts_tree_cursor_goto_next_sibling(&it->cursor)) { + if (it->depth == 0) { + return false; + } + ts_tree_cursor_goto_parent(&it->cursor); + it->depth--; + } + return true; +} + +static bool member_iter_next(member_iter_t *it, TSNode *out) { + for (bool have = member_iter_advance(it); have; have = member_iter_advance(it)) { + TSNode node = ts_tree_cursor_current_node(&it->cursor); + while (it->conditional && it->depth < MEMBER_CONDITIONAL_DEPTH_MAX && + cbm_kind_in_set(node, it->conditional) && + ts_tree_cursor_goto_first_child(&it->cursor)) { + it->depth++; + node = ts_tree_cursor_current_node(&it->cursor); + } + if (it->conditional && cbm_kind_in_set(node, it->conditional)) { + continue; /* an empty (or too deeply nested) block holds no member */ + } + if (it->named && !ts_node_is_named(node)) { continue; } - if (cbm_kind_in_set(inner, spec->function_node_types)) { - TSNode nm = resolve_method_name(inner, ctx->language); - if (!ts_node_is_null(nm)) { - push_method_def(ctx, inner, impl_node, class_qn, spec, nm); + *out = node; + return true; + } + return false; +} + +/* Objective-C wraps every @implementation member in an implementation_definition, + * and a #if/#else block inside @implementation wraps each branch's members in + * another one: walk those nested wrappers too (worklist, no recursion). */ +static void extract_objc_impl_methods(CBMExtractCtx *ctx, TSNode impl_node, const char *class_qn, + const CBMLangSpec *spec) { + TSNode pending[MEMBER_CONDITIONAL_DEPTH_MAX]; + int count = 0; + pending[count++] = impl_node; + while (count > 0) { + TSNode cur = pending[--count]; + member_iter_t it; + member_iter_init(&it, cur, ctx->language, false); + TSNode inner; + while (member_iter_next(&it, &inner)) { + if (cbm_kind_in_set(inner, spec->function_node_types)) { + TSNode nm = resolve_method_name(inner, ctx->language); + if (!ts_node_is_null(nm)) { + push_method_def(ctx, inner, impl_node, class_qn, spec, nm); + } + } else if (count < MEMBER_CONDITIONAL_DEPTH_MAX && + strcmp(ts_node_type(inner), "implementation_definition") == 0) { + pending[count++] = inner; } } + member_iter_done(&it); } } @@ -7845,12 +7898,10 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c return; } - uint32_t count = ts_node_child_count(body); - for (uint32_t i = 0; i < count; i++) { - TSNode child = ts_node_child(body, i); - if (ts_node_is_null(child)) { - continue; - } + member_iter_t it; + member_iter_init(&it, body, ctx->language, false); + TSNode child; + while (member_iter_next(&it, &child)) { if (ctx->language == CBM_LANG_OBJC && strcmp(ts_node_type(child), "implementation_definition") == 0) { @@ -7939,6 +7990,7 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c push_method_def(ctx, method_node, class_node, class_qn, spec, name_node); } + member_iter_done(&it); } // --- Rust impl block extraction --- @@ -8153,6 +8205,22 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp if (strcmp(macro, "def") == 0 || strcmp(macro, "defp") == 0 || strcmp(macro, "defmacro") == 0) { extract_elixir_func_def(ctx, cur, macro); + } else if (strcmp(macro, "if") == 0 || strcmp(macro, "unless") == 0) { + /* A compile-time if/unless in a module body defines functions + * under a condition: its do and else branches hold variants of the + * same definitions (graph_buffer.c, "Definition variants"). The + * grammar nests the else_block inside the do_block. */ + TSNode branch = cbm_find_child_by_kind(cur, "do_block"); + for (int pass = 0; pass < 2 && !ts_node_is_null(branch); pass++) { + uint32_t bc = ts_node_child_count(branch); + for (int bi = (int)bc - SKIP_CHAR; bi >= 0; bi--) { + TSNode bchild = ts_node_child(branch, (uint32_t)bi); + if (!ts_node_is_null(bchild) && strcmp(ts_node_type(bchild), "call") == 0) { + ts_nstack_push(&stack, bchild); + } + } + branch = cbm_find_child_by_kind(branch, "else_block"); + } } else if (strcmp(macro, "defmodule") == 0) { TSNode do_block = emit_elixir_module_class(ctx, cur); if (!ts_node_is_null(do_block)) { @@ -9811,10 +9879,10 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch } CBMArena *a = ctx->arena; - uint32_t count = ts_node_named_child_count(body); - for (uint32_t i = 0; i < count; i++) { - TSNode child = ts_node_named_child(body, i); - + member_iter_t it; + member_iter_init(&it, body, ctx->language, true); + TSNode child; + while (member_iter_next(&it, &child)) { // ObjectScript UDL wraps each member in a class_statement node. if (ctx->language == CBM_LANG_OBJECTSCRIPT_UDL && strcmp(ts_node_type(child), "class_statement") == 0 && @@ -10128,6 +10196,7 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch cbm_defs_push(&ctx->result->defs, a, def); } + member_iter_done(&it); } // Extract class-level variables (field declarations inside class bodies) @@ -10146,13 +10215,15 @@ static void extract_class_variables(CBMExtractCtx *ctx, TSNode class_node, const * push_var_def_qn); saved/restored so module-level minting stays bare. */ const char *saved_parent = ctx->var_parent_class; ctx->var_parent_class = class_qn; - uint32_t count = ts_node_named_child_count(body); - for (uint32_t i = 0; i < count; i++) { - TSNode child = ts_node_named_child(body, i); + member_iter_t it; + member_iter_init(&it, body, ctx->language, true); + TSNode child; + while (member_iter_next(&it, &child)) { if (cbm_kind_in_set(child, spec->variable_node_types)) { extract_var_names(ctx, child, spec); } } + member_iter_done(&it); ctx->var_parent_class = saved_parent; } @@ -10312,8 +10383,10 @@ static void push_nested_class_nodes(TSNode body, const CBMLangSpec *spec, wd_sta wd_push(s, child, enclosing_qn); } else { const char *ck = ts_node_type(child); + /* A nested class inside #if/#else is still nested here. */ if (strcmp(ck, "field_declaration") == 0 || - strcmp(ck, "template_declaration") == 0 || strcmp(ck, "declaration") == 0) { + strcmp(ck, "template_declaration") == 0 || strcmp(ck, "declaration") == 0 || + cbm_kind_in_set(child, cbm_conditional_block_types(spec->language))) { ts_nstack_push(&nc_stack, child); } } diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index dde0495b8..0875c8912 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -47,7 +47,7 @@ static void parse_lisp_imports(CBMExtractCtx *ctx); static void parse_starlark_imports(CBMExtractCtx *ctx); static void parse_tcl_imports(CBMExtractCtx *ctx); static void parse_teal_imports(CBMExtractCtx *ctx); -static void parse_zsh_imports(CBMExtractCtx *ctx); +static void parse_shell_source_imports(CBMExtractCtx *ctx); static void parse_css_imports(CBMExtractCtx *ctx); static void parse_html_imports(CBMExtractCtx *ctx); static void parse_cmake_imports(CBMExtractCtx *ctx); @@ -2187,10 +2187,13 @@ static void parse_teal_imports(CBMExtractCtx *ctx) { } } -// --- Zsh imports --- +// --- Bash / Zsh imports --- // source file / . file — `command` nodes whose command_name is "source" or ".". -// The argument field carries the sourced path. -static void parse_zsh_imports(CBMExtractCtx *ctx) { +// The argument field carries the sourced path. Every other command is a call, +// not an import: read as an import, `set -e` imported "set", which the import +// resolver then bound to whatever project symbol is named set (a TSX method), +// and every `set` call in the script followed it. +static void parse_shell_source_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; TSNodeStack stack; ts_nstack_init(&stack, ctx, CBM_SZ_512); @@ -3049,8 +3052,7 @@ void cbm_extract_imports(CBMExtractCtx *ctx) { parse_generic_imports(ctx, "call"); break; case CBM_LANG_BASH: - // source/. commands - parse_generic_imports(ctx, "command"); + parse_shell_source_imports(ctx); break; case CBM_LANG_ZIG: parse_zig_imports(ctx); @@ -3120,7 +3122,7 @@ void cbm_extract_imports(CBMExtractCtx *ctx) { parse_teal_imports(ctx); break; case CBM_LANG_ZSH: - parse_zsh_imports(ctx); + parse_shell_source_imports(ctx); break; case CBM_LANG_CMAKE: parse_cmake_imports(ctx); diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index f7f5e2d4f..4e83ca900 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1110,9 +1110,13 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, qn_name); } /* Java/Go: directory-based module so this enclosing-func QN matches the def - * QN and the LSP caller_qn (the lsp_resolve join keys on exact equality). */ - return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, qn_name, - ctx->language); + * QN and the LSP caller_qn (the lsp_resolve join keys on exact equality). + * C-family platform variants share their platform-neutral QN — the same + * rule extract_func_def applies, so in-body calls source to the def. */ + const char *qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, qn_name, + ctx->language); + return cbm_platform_variant_qn(ctx->arena, ctx->language, ctx->project, ctx->rel_path, qn_name, + qn, node); } // Compute class QN for scope tracking. diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 055a4cca9..5b2ae1320 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1678,6 +1678,146 @@ char *cbm_fqn_compute_source_lang(CBMArena *a, const char *project, const char * return cbm_arena_sprintf(a, "%s.%s", module, name); } +/* Operating systems and CPU architectures only: a token names a platform, so a + * path segment or stem suffix made of one marks a platform alternative. Vague + * words (generic, default, common, stub) are deliberately absent. */ +static const char *const platform_tokens[] = { + "win", "win32", "win64", "windows", "wince", "nt", "posix", "unix", + "linux", "darwin", "mac", "macos", "macosx", "osx", "apple", "ios", + "android", "bsd", "freebsd", "openbsd", "netbsd", "dragonfly", "solaris", "sunos", + "aix", "hpux", "haiku", "fuchsia", "cygwin", "mingw", "msvc", "wasm", + "wasi", "emscripten", "x86", "x86_64", "x64", "amd64", "i386", "i686", + "ia32", "arm", "arm64", "aarch64", "armv7", "riscv", "riscv64", "mips", + "mips64", "ppc", "ppc64", "powerpc", "s390", "s390x", "sparc", "sparc64", + "loongarch", "loongarch64", NULL}; + +static bool platform_token_eq(const char *s, size_t len) { + for (int i = 0; platform_tokens[i]; i++) { + const char *t = platform_tokens[i]; + size_t tl = strlen(t); + if (tl != len) { + continue; + } + size_t k = 0; + while (k < len && tolower((unsigned char)s[k]) == t[k]) { + k++; + } + if (k == len) { + return true; + } + } + return false; +} + +/* Length of `stem` without a trailing `_` / `-` / `.` + * platform suffix (the longest one), or `len` when it has none. */ +static size_t platform_stem_len(const char *stem, size_t len) { + size_t best = len; + for (size_t sep = 1; sep < len; sep++) { + char c = stem[sep - 1]; + if ((c == '_' || c == '-' || c == '.') && platform_token_eq(stem + sep, len - sep)) { + if (sep - 1 < best) { + best = sep - 1; + } + } + } + return best; +} + +static bool c_family_variant_lang(CBMLanguage lang) { + return lang == CBM_LANG_C || lang == CBM_LANG_CPP || lang == CBM_LANG_CUDA || + lang == CBM_LANG_OBJC; +} + +static bool func_is_static(TSNode func_node) { + uint32_t n = ts_node_child_count(func_node); + for (uint32_t i = 0; i < n; i++) { + TSNode child = ts_node_child(func_node, i); + if (strcmp(ts_node_type(child), "storage_class_specifier") == 0 && + ts_node_child_count(child) > 0 && + strcmp(ts_node_type(ts_node_child(child, 0)), "static") == 0) { + return true; + } + } + return false; +} + +const char *cbm_platform_variant_qn(CBMArena *a, CBMLanguage lang, const char *project, + const char *rel_path, const char *name, const char *qn, + TSNode func_node) { + if (!c_family_variant_lang(lang) || !rel_path || !name || !name[0] || !qn || + ts_node_is_null(func_node) || func_is_static(func_node)) { + return qn; + } + char *plain = cbm_fqn_compute(a, project, rel_path, name); + if (!plain || strcmp(plain, qn) != 0) { + return qn; /* namespaced or otherwise scoped: not a file-scope function */ + } + /* Rebuild the path without platform tokens: a directory segment that is + * one is dropped; a filename stem loses its platform suffix, and a stem + * that IS a token leaves the function at its directory. */ + size_t len = strlen(rel_path); + char *neutral = (char *)cbm_arena_alloc(a, len + SKIP_ONE); + if (!neutral) { + return qn; + } + size_t out = 0; + bool changed = false; + bool dir_level = false; + const char *seg = rel_path; + while (*seg) { + const char *slash = strchr(seg, '/'); + size_t seg_len = slash ? (size_t)(slash - seg) : strlen(seg); + if (slash) { + if (platform_token_eq(seg, seg_len)) { + changed = true; + } else { + memcpy(neutral + out, seg, seg_len); + out += seg_len; + neutral[out++] = '/'; + } + seg = slash + SKIP_ONE; + continue; + } + const char *dot = NULL; + for (const char *p = seg + seg_len; p > seg; p--) { + if (p[-1] == '.') { + dot = p - 1; + break; + } + } + size_t stem_len = dot && dot > seg ? (size_t)(dot - seg) : seg_len; + size_t keep = platform_token_eq(seg, stem_len) ? 0 : platform_stem_len(seg, stem_len); + if (keep != stem_len) { + changed = true; + } + if (keep == 0) { + dir_level = true; /* the function lives at the directory */ + if (out > 0 && neutral[out - SKIP_ONE] == '/') { + out--; + } + } else { + memcpy(neutral + out, seg, keep); + out += keep; + memcpy(neutral + out, seg + stem_len, seg_len - stem_len); + out += seg_len - stem_len; + } + break; + } + neutral[out] = '\0'; + if (!changed) { + return qn; + } + if (dir_level) { + /* No file stem left: the folder form keeps every directory segment + * (cbm_fqn_compute would strip a dotted segment as an extension). */ + char *folder = cbm_fqn_folder(a, project ? project : "", neutral); + return folder ? cbm_arena_sprintf(a, "%s.%s", folder, name) : qn; + } + char *variant = cbm_fqn_compute(a, project, neutral, name); + return variant ? variant : qn; +} + char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir) { // project.dir1.dir2 size_t proj_len = strlen(project); diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 00e3d30dc..a6fd923f6 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -250,6 +250,17 @@ char *cbm_fqn_module_source_lang(CBMArena *a, const char *project, const char *r char *cbm_fqn_compute_source_lang(CBMArena *a, const char *project, const char *rel_path, const char *name, CBMLanguage lang); +// C-family platform variants: the same external-linkage function defined in +// files that differ only by a platform token (foo_win.c / foo_posix.c, +// src/unix/fs.c / src/win/fs.c) is ONE function with platform variants — two +// external definitions of one name cannot link into one program, so they are +// alternatives by construction. Returns the platform-neutral QN (the path with +// its platform tokens removed) for a non-static file-scope function of +// C/C++/CUDA/ObjC whose `qn` is the plain file-stem form; otherwise `qn`. +const char *cbm_platform_variant_qn(CBMArena *a, CBMLanguage lang, const char *project, + const char *rel_path, const char *name, const char *qn, + TSNode func_node); + // Folder QN: project.dir_parts char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir); diff --git a/internal/cbm/lang_specs.c b/internal/cbm/lang_specs.c index cfd363c71..2c385cf33 100644 --- a/internal/cbm/lang_specs.c +++ b/internal/cbm/lang_specs.c @@ -2737,6 +2737,28 @@ static const CBMLangSpec lang_specs[CBM_LANG_COUNT] = { _Static_assert(sizeof(lang_specs) / sizeof(lang_specs[0]) == CBM_LANG_COUNT, "lang_specs array size must match CBM_LANG_COUNT"); +/* Conditional-compilation blocks per grammar (the `*_in_field_declaration_list` + * / `*_in_top_level` forms are aliased to these names by the grammars). */ +static const char *c_family_conditional_types[] = { + "preproc_if", "preproc_ifdef", "preproc_else", "preproc_elif", "preproc_elifdef", NULL}; + +const char **cbm_conditional_block_types(CBMLanguage lang) { + switch (lang) { + case CBM_LANG_C: + case CBM_LANG_CPP: + case CBM_LANG_CUDA: + case CBM_LANG_OBJC: + case CBM_LANG_CSHARP: + case CBM_LANG_GLSL: + case CBM_LANG_HLSL: + case CBM_LANG_ISPC: + case CBM_LANG_SLANG: + return c_family_conditional_types; + default: + return NULL; + } +} + const CBMLangSpec *cbm_lang_spec(CBMLanguage lang) { if (lang < 0 || lang >= CBM_LANG_COUNT) { return NULL; diff --git a/internal/cbm/lang_specs.h b/internal/cbm/lang_specs.h index ab823557b..722ff84e1 100644 --- a/internal/cbm/lang_specs.h +++ b/internal/cbm/lang_specs.h @@ -48,6 +48,13 @@ typedef struct { // language rows; the table lives in extract_calls.c next to the dispatch code. const char **cbm_string_dispatch_suffixes(CBMLanguage lang); +// Returns a NULL-terminated list of node types that wrap conditionally +// compiled alternatives (#if/#ifdef/#else/#elif blocks) for a language, or +// NULL when the grammar has none. A definition inside one is still a member +// of its enclosing scope; its #else twin is a variant of the same definition. +// Side table for the same -Wmissing-field-initializers reason as above. +const char **cbm_conditional_block_types(CBMLanguage lang); + // Get the language spec for a given language. Returns NULL for unsupported. const CBMLangSpec *cbm_lang_spec(CBMLanguage lang); diff --git a/internal/cbm/spawn_patterns.c b/internal/cbm/spawn_patterns.c new file mode 100644 index 000000000..dc9b53950 --- /dev/null +++ b/internal/cbm/spawn_patterns.c @@ -0,0 +1,744 @@ +/* + * spawn_patterns.c — process-spawning call sites, per language (see the header). + * + * Spellings come from what the extractor records as the callee (checked per + * language against real calls): Rust `Command::new`, Go `exec.Command`, Java + * `Runtime.getRuntime().exec`, a constructor as its class name. + */ +#include "spawn_patterns.h" +#include "foundation/constants.h" + +#include +#include + +#define Q CBM_SPAWN_QUALIFIED +#define U CBM_SPAWN_BARE +#define I CBM_SPAWN_BARE_IMPORTED +#define S CBM_SPAWN_STRING_RECEIVER + +/* libc / POSIX / Win32. posix_spawn and _spawn* take the path second; + * CreateProcess names the program first or only in its command line. */ +static const cbm_spawn_api_t c_family[] = { + {"system", NULL, U, 0, -1}, {"std::system", NULL, U, 0, -1}, + {"_wsystem", NULL, U, 0, -1}, {"popen", NULL, U, 0, -1}, + {"_popen", NULL, U, 0, -1}, {"_wpopen", NULL, U, 0, -1}, + {"execl", NULL, U, 0, -1}, {"execle", NULL, U, 0, -1}, + {"execlp", NULL, U, 0, -1}, {"execv", NULL, U, 0, -1}, + {"execve", NULL, U, 0, -1}, {"execvp", NULL, U, 0, -1}, + {"execvpe", NULL, U, 0, -1}, {"fexecve", NULL, U, 0, -1}, + {"posix_spawn", NULL, U, 1, -1}, {"posix_spawnp", NULL, U, 1, -1}, + {"_execl", NULL, U, 0, -1}, {"_execv", NULL, U, 0, -1}, + {"_execlp", NULL, U, 0, -1}, {"_execvp", NULL, U, 0, -1}, + {"_spawnl", NULL, U, 1, -1}, {"_spawnle", NULL, U, 1, -1}, + {"_spawnlp", NULL, U, 1, -1}, {"_spawnv", NULL, U, 1, -1}, + {"_spawnve", NULL, U, 1, -1}, {"_spawnvp", NULL, U, 1, -1}, + {"_wspawnv", NULL, U, 1, -1}, {"_wspawnvp", NULL, U, 1, -1}, + {"CreateProcess", NULL, U, 0, 1}, {"CreateProcessA", NULL, U, 0, 1}, + {"CreateProcessW", NULL, U, 0, 1}, {"ShellExecute", NULL, U, 2, -1}, + {"ShellExecuteA", NULL, U, 2, -1}, {"ShellExecuteW", NULL, U, 2, -1}, + {"ShellExecuteExA", NULL, U, 0, -1}, {"ShellExecuteExW", NULL, U, 0, -1}, + {"WinExec", NULL, U, 0, -1}, {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t python[] = { + {"subprocess.run", NULL, Q, 0, -1}, + {"subprocess.call", NULL, Q, 0, -1}, + {"subprocess.check_call", NULL, Q, 0, -1}, + {"subprocess.check_output", NULL, Q, 0, -1}, + {"subprocess.Popen", NULL, Q, 0, -1}, + {"subprocess.getoutput", NULL, Q, 0, -1}, + {"subprocess.getstatusoutput", NULL, Q, 0, -1}, + {"os.system", NULL, Q, 0, -1}, + {"os.popen", NULL, Q, 0, -1}, + {"os.startfile", NULL, Q, 0, -1}, + {"os.execl", NULL, Q, 0, -1}, + {"os.execle", NULL, Q, 0, -1}, + {"os.execlp", NULL, Q, 0, -1}, + {"os.execlpe", NULL, Q, 0, -1}, + {"os.execv", NULL, Q, 0, -1}, + {"os.execve", NULL, Q, 0, -1}, + {"os.execvp", NULL, Q, 0, -1}, + {"os.execvpe", NULL, Q, 0, -1}, + {"os.posix_spawn", NULL, Q, 0, -1}, + {"os.posix_spawnp", NULL, Q, 0, -1}, + {"os.spawnl", NULL, Q, 1, -1}, + {"os.spawnle", NULL, Q, 1, -1}, + {"os.spawnlp", NULL, Q, 1, -1}, + {"os.spawnlpe", NULL, Q, 1, -1}, + {"os.spawnv", NULL, Q, 1, -1}, + {"os.spawnve", NULL, Q, 1, -1}, + {"os.spawnvp", NULL, Q, 1, -1}, + {"os.spawnvpe", NULL, Q, 1, -1}, + {"asyncio.create_subprocess_exec", NULL, Q, 0, -1}, + {"asyncio.create_subprocess_shell", NULL, Q, 0, -1}, + {"pexpect.spawn", NULL, Q, 0, -1}, + {"pexpect.run", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +/* Node's child_process (also behind `node:`), the destructured-require form, + * execa / cross-spawn, and the Bun and Deno globals. */ +static const cbm_spawn_api_t js_family[] = { + {"child_process.spawn", NULL, Q, 0, -1}, + {"child_process.spawnSync", NULL, Q, 0, -1}, + {"child_process.exec", NULL, Q, 0, -1}, + {"child_process.execSync", NULL, Q, 0, -1}, + {"child_process.execFile", NULL, Q, 0, -1}, + {"child_process.execFileSync", NULL, Q, 0, -1}, + {"child_process.fork", NULL, Q, 0, -1}, + {"spawn", "child_process", I, 0, -1}, + {"spawnSync", "child_process", I, 0, -1}, + {"exec", "child_process", I, 0, -1}, + {"execSync", "child_process", I, 0, -1}, + {"execFile", "child_process", I, 0, -1}, + {"execFileSync", "child_process", I, 0, -1}, + {"fork", "child_process", I, 0, -1}, + {"execa.execa", NULL, Q, 0, -1}, + {"execa.execaSync", NULL, Q, 0, -1}, + {"execa.execaCommand", NULL, Q, 0, -1}, + {"execa.execaCommandSync", NULL, Q, 0, -1}, + {"cross-spawn.spawn", NULL, Q, 0, -1}, + {"cross-spawn.sync", NULL, Q, 0, -1}, + {"Bun.spawn", NULL, Q, 0, -1}, + {"Bun.spawnSync", NULL, Q, 0, -1}, + {"Deno.Command", NULL, Q, 0, -1}, + {"Deno.run", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t go[] = { + {"os/exec.Command", NULL, Q, 0, -1}, + {"os/exec.CommandContext", NULL, Q, 1, -1}, + {"golang.org/x/sys/execabs.Command", NULL, Q, 0, -1}, + {"golang.org/x/sys/execabs.CommandContext", NULL, Q, 1, -1}, + {"os.StartProcess", NULL, Q, 0, -1}, + {"syscall.Exec", NULL, Q, 0, -1}, + {"syscall.ForkExec", NULL, Q, 0, -1}, + {"syscall.StartProcess", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t rust[] = { + {"std::process::Command::new", NULL, Q, 0, -1}, + {"tokio::process::Command::new", NULL, Q, 0, -1}, + {"async_process::Command::new", NULL, Q, 0, -1}, + {"async_std::process::Command::new", NULL, Q, 0, -1}, + {"smol::process::Command::new", NULL, Q, 0, -1}, + {"duct::cmd", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +/* java.lang is implicit, so the class name alone is the spelling. */ +static const cbm_spawn_api_t jvm[] = { + {"ProcessBuilder", NULL, U, 0, -1}, + {"java.lang.ProcessBuilder", NULL, Q, 0, -1}, + {"Runtime.getRuntime().exec", NULL, U, 0, -1}, + {".execute", NULL, S, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t dotnet[] = { + {"Process.Start", NULL, U, 0, -1}, {"System.Diagnostics.Process.Start", NULL, Q, 0, -1}, + {"ProcessStartInfo", NULL, U, 0, -1}, {"System.Diagnostics.ProcessStartInfo", NULL, Q, 0, -1}, + {"Cli.Wrap", NULL, U, 0, -1}, {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t ruby[] = { + {"system", NULL, U, 0, -1}, {"exec", NULL, U, 0, -1}, + {"spawn", NULL, U, 0, -1}, {"Kernel.system", NULL, U, 0, -1}, + {"Kernel.exec", NULL, U, 0, -1}, {"Kernel.spawn", NULL, U, 0, -1}, + {"Process.spawn", NULL, U, 0, -1}, {"Process.exec", NULL, U, 0, -1}, + {"IO.popen", NULL, U, 0, -1}, {"PTY.spawn", NULL, U, 0, -1}, + {"Open3.popen2", NULL, U, 0, -1}, {"Open3.popen2e", NULL, U, 0, -1}, + {"Open3.popen3", NULL, U, 0, -1}, {"Open3.capture2", NULL, U, 0, -1}, + {"Open3.capture2e", NULL, U, 0, -1}, {"Open3.capture3", NULL, U, 0, -1}, + {"Open3.pipeline", NULL, U, 0, -1}, {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t php[] = { + {"exec", NULL, U, 0, -1}, + {"shell_exec", NULL, U, 0, -1}, + {"system", NULL, U, 0, -1}, + {"passthru", NULL, U, 0, -1}, + {"proc_open", NULL, U, 0, -1}, + {"popen", NULL, U, 0, -1}, + {"pcntl_exec", NULL, U, 0, -1}, + {"Symfony\\Component\\Process\\Process", NULL, Q, 0, -1}, + {"Symfony\\Component\\Process\\Process::fromShellCommandline", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t perl[] = { + {"system", NULL, U, 0, -1}, + {"exec", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t lua[] = { + {"os.execute", NULL, U, 0, -1}, + {"io.popen", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t r_lang[] = { + {"system", NULL, U, 0, -1}, + {"system2", NULL, U, 0, -1}, + {"shell", NULL, U, 0, -1}, + {"processx::run", NULL, Q, 0, -1}, + {"sys::exec_wait", NULL, Q, 0, -1}, + {"sys::exec_internal", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t elixir[] = { + {"System.cmd", NULL, U, 0, -1}, + {"System.shell", NULL, U, 0, -1}, + {"Port.open", NULL, U, 0, CBM_SPAWN_ANY_LITERAL}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t erlang[] = { + {"os:cmd", NULL, Q, 0, -1}, + {"open_port", NULL, U, 0, -1}, + {"erlang:open_port", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t haskell[] = { + {"callProcess", "System.Process", I, 0, -1}, + {"callCommand", "System.Process", I, 0, -1}, + {"readProcess", "System.Process", I, 0, -1}, + {"readProcessWithExitCode", "System.Process", I, 0, -1}, + {"readCreateProcess", "System.Process", I, 0, -1}, + {"readCreateProcessWithExitCode", "System.Process", I, 0, -1}, + {"createProcess", "System.Process", I, 0, -1}, + {"spawnProcess", "System.Process", I, 0, -1}, + {"spawnCommand", "System.Process", I, 0, -1}, + {"system", "System.Process", I, 0, -1}, + {"rawSystem", "System.Process", I, 0, -1}, + {"runProcess", "System.Process", I, 0, -1}, + {"runCommand", "System.Process", I, 0, -1}, + {"runInteractiveProcess", "System.Process", I, 0, -1}, + {"runInteractiveCommand", "System.Process", I, 0, -1}, + {"runProcess", "System.Process.Typed", I, 0, -1}, + {"readProcess", "System.Process.Typed", I, 0, -1}, + {"startProcess", "System.Process.Typed", I, 0, -1}, + {"system", "System.Cmd", I, 0, -1}, + {"rawSystem", "System.Cmd", I, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t dlang[] = { + {"execute", "std.process", I, 0, -1}, + {"executeShell", "std.process", I, 0, -1}, + {"spawnProcess", "std.process", I, 0, -1}, + {"spawnShell", "std.process", I, 0, -1}, + {"pipeProcess", "std.process", I, 0, -1}, + {"pipeShell", "std.process", I, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +/* Foundation's Process: the program is a property set after construction, + * so a bare construction is a spawn site with a dynamic program. */ +static const cbm_spawn_api_t swift[] = { + {"Process.launchedProcess", NULL, U, 0, -1}, + {"Process.run", NULL, U, 0, -1}, + {"Process", "Foundation", I, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t crystal[] = { + {"Process.run", NULL, U, 0, -1}, {"Process.new", NULL, U, 0, -1}, + {"Process.exec", NULL, U, 0, -1}, {"system", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t zig[] = { + {"std.process.Child.init", NULL, Q, 0, -1}, + {"std.process.Child.run", NULL, Q, 0, CBM_SPAWN_ANY_LITERAL}, + {"std.ChildProcess.init", NULL, Q, 0, -1}, + {"std.ChildProcess.run", NULL, Q, 0, CBM_SPAWN_ANY_LITERAL}, + {"std.ChildProcess.exec", NULL, Q, 0, CBM_SPAWN_ANY_LITERAL}, + {"std.process.execv", NULL, Q, 1, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t ocaml[] = { + {"Sys.command", NULL, U, 0, -1}, + {"Unix.system", NULL, U, 0, -1}, + {"Unix.create_process", NULL, U, 0, -1}, + {"Unix.create_process_env", NULL, U, 0, -1}, + {"Unix.open_process", NULL, U, 0, -1}, + {"Unix.open_process_in", NULL, U, 0, -1}, + {"Unix.open_process_out", NULL, U, 0, -1}, + {"Unix.open_process_full", NULL, U, 0, -1}, + {"Unix.open_process_args", NULL, U, 0, -1}, + {"Unix.execv", NULL, U, 0, -1}, + {"Unix.execvp", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t clojure[] = { + {"clojure.java.shell/sh", NULL, Q, 0, -1}, + {"sh", "clojure.java.shell", I, 0, -1}, + {"babashka.process/process", NULL, Q, 0, -1}, + {"babashka.process/shell", NULL, Q, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t tcl[] = { + {"exec", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t fortran[] = { + {"execute_command_line", NULL, U, 0, -1}, + {"system", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t julia[] = { + {"run", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +static const cbm_spawn_api_t powershell[] = { + {"Start-Process", NULL, U, 0, -1}, + {NULL, NULL, 0, 0, -1}, +}; + +#undef Q +#undef U +#undef I +#undef S + +static const cbm_spawn_api_t *spawn_table(CBMLanguage lang) { + switch (lang) { + case CBM_LANG_C: + case CBM_LANG_CPP: + case CBM_LANG_CUDA: + case CBM_LANG_OBJC: + return c_family; + case CBM_LANG_PYTHON: + return python; + case CBM_LANG_JAVASCRIPT: + case CBM_LANG_TYPESCRIPT: + case CBM_LANG_TSX: + case CBM_LANG_ARKTS: + return js_family; + case CBM_LANG_GO: + return go; + case CBM_LANG_RUST: + return rust; + case CBM_LANG_JAVA: + case CBM_LANG_KOTLIN: + case CBM_LANG_SCALA: + case CBM_LANG_GROOVY: + return jvm; + case CBM_LANG_CSHARP: + case CBM_LANG_FSHARP: + return dotnet; + case CBM_LANG_RUBY: + return ruby; + case CBM_LANG_PHP: + return php; + case CBM_LANG_PERL: + return perl; + case CBM_LANG_LUA: + case CBM_LANG_LUAU: + case CBM_LANG_TEAL: + return lua; + case CBM_LANG_R: + return r_lang; + case CBM_LANG_ELIXIR: + return elixir; + case CBM_LANG_ERLANG: + return erlang; + case CBM_LANG_HASKELL: + return haskell; + case CBM_LANG_DLANG: + return dlang; + case CBM_LANG_SWIFT: + return swift; + case CBM_LANG_CRYSTAL: + return crystal; + case CBM_LANG_ZIG: + return zig; + case CBM_LANG_OCAML: + return ocaml; + case CBM_LANG_CLOJURE: + return clojure; + case CBM_LANG_TCL: + return tcl; + case CBM_LANG_FORTRAN: + return fortran; + case CBM_LANG_JULIA: + return julia; + case CBM_LANG_POWERSHELL: + return powershell; + default: + return NULL; + } +} + +/* An import value without Node's `node:` scheme. */ +static const char spawn_node_scheme[] = "node:"; +static const char *spawn_module(const char *val) { + size_t n = sizeof(spawn_node_scheme) - 1; + return strncmp(val, spawn_node_scheme, n) == 0 ? val + n : val; +} + +static const char *const spawn_separators[] = {"::", ".", ":", "/", "\\", NULL}; + +/* Length of the callee's head (before its first separator) and the + * separator's offset, or 0 when the callee has none. */ +static size_t spawn_head_len(const char *callee) { + size_t best = 0; + for (int i = 0; spawn_separators[i]; i++) { + const char *at = strstr(callee, spawn_separators[i]); + if (at && at > callee && (best == 0 || (size_t)(at - callee) < best)) { + best = (size_t)(at - callee); + } + } + return best; +} + +/* True when `val` names `name` as its last segment ("subprocess.run" for + * run, "java.lang.ProcessBuilder" for ProcessBuilder). */ +static bool spawn_val_names(const char *val, const char *name) { + size_t vl = strlen(val); + size_t nl = strlen(name); + if (vl <= nl || strcmp(val + vl - nl, name) != 0) { + return false; + } + char sep = val[vl - nl - 1]; + return sep == '.' || sep == ':' || sep == '/' || sep == '\\'; +} + +/* The callee as its import makes it: `sp.run` -> subprocess.run (an alias), + * `run` -> subprocess.run (a member import), `spawn` -> child_process.spawn + * (a named import mapped to its module). Empty when no import applies. */ +static void spawn_expand(const char *callee, const CBMImport *imports, int count, char *out, + size_t n) { + out[0] = '\0'; + size_t head = spawn_head_len(callee); + size_t key_len = head ? head : strlen(callee); + for (int i = 0; i < count; i++) { + const char *key = imports[i].local_name; + if (!key || !imports[i].module_path || strlen(key) != key_len || + strncmp(key, callee, key_len) != 0) { + continue; + } + const char *val = spawn_module(imports[i].module_path); + if (head) { + snprintf(out, n, "%s%s", val, callee + head); + } else if (spawn_val_names(val, callee)) { + snprintf(out, n, "%s", val); + } else { + snprintf(out, n, "%s.%s", val, callee); + } + return; + } +} + +static bool spawn_imports_module(const CBMImport *imports, int count, const char *module) { + size_t ml = strlen(module); + for (int i = 0; i < count; i++) { + if (!imports[i].module_path) { + continue; + } + const char *val = spawn_module(imports[i].module_path); + if (strncmp(val, module, ml) == 0 && + (val[ml] == '\0' || val[ml] == '.' || val[ml] == ':' || val[ml] == '/')) { + return true; + } + } + return false; +} + +/* Groovy `"git status".execute`: a quoted receiver, then the api suffix. */ +static bool spawn_string_receiver(const char *callee, const char *suffix) { + size_t cl = strlen(callee); + size_t sl = strlen(suffix); + return (callee[0] == '"' || callee[0] == '\'') && cl > sl + 1 && + strcmp(callee + cl - sl, suffix) == 0 && callee[cl - sl - 1] == callee[0]; +} + +static bool spawn_entry_matches(const cbm_spawn_api_t *e, const char *callee, const char *expanded, + const CBMImport *imports, int count) { + switch (e->rule) { + case CBM_SPAWN_QUALIFIED: + return strcmp(callee, e->api) == 0 || (expanded[0] && strcmp(expanded, e->api) == 0); + case CBM_SPAWN_BARE: + return strcmp(callee, e->api) == 0; + case CBM_SPAWN_BARE_IMPORTED: + return strcmp(callee, e->api) == 0 && spawn_imports_module(imports, count, e->module); + case CBM_SPAWN_STRING_RECEIVER: + return spawn_string_receiver(callee, e->api); + default: + return false; + } +} + +const cbm_spawn_api_t *cbm_spawn_match(CBMLanguage lang, const char *callee, + const CBMImport *imports, int count) { + const cbm_spawn_api_t *table = spawn_table(lang); + if (!table || !callee || !callee[0]) { + return NULL; + } + if (!imports) { + count = 0; + } + char expanded[CBM_SZ_512]; + spawn_expand(callee, imports, count, expanded, sizeof(expanded)); + for (const cbm_spawn_api_t *e = table; e->api; e++) { + if (spawn_entry_matches(e, callee, expanded, imports, count)) { + return e; + } + } + return NULL; +} + +/* The first quoted literal in `text` ("..", '..' or `..`), copied without + * its quotes. False when there is none. */ +static bool spawn_first_literal(const char *text, char *out, size_t n) { + for (const char *p = text; *p; p++) { + if (*p != '"' && *p != '\'' && *p != '`') { + continue; + } + const char *end = p + 1; + while (*end && *end != *p) { + end += (*end == '\\' && end[1]) ? 2 : 1; + } + if (!*end) { + return false; + } + size_t len = (size_t)(end - p - 1); + if (len >= n) { + len = n - 1; + } + memcpy(out, p + 1, len); + out[len] = '\0'; + return true; + } + return false; +} + +static const char *spawn_arg_expr(const CBMCall *call, int index) { + for (int i = 0; i < call->arg_count; i++) { + if (call->args[i].index == index && call->args[i].expr) { + return call->args[i].expr; + } + } + return NULL; +} + +static bool spawn_word_char(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; +} + +static const char *spawn_skip_space(const char *p) { + while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') { + p++; + } + return p; +} + +/* Past a keyword label in front of an argument (`launchPath: `, `args=`). */ +static const char *spawn_skip_label(const char *p) { + const char *q = p; + while (spawn_word_char(*q)) { + q++; + } + if (q == p) { + return p; + } + q = spawn_skip_space(q); + if ((*q == ':' && q[1] != ':') || (*q == '=' && q[1] != '=')) { + return spawn_skip_space(q + 1); + } + return p; +} + +/* A string literal at the start of `p`, after a prefix such as r, b, f, L + * or @ (r"..", L"..", @".."). */ +static bool spawn_leading_literal(const char *p, char *out, size_t n) { + for (int i = 0; i < 2 && *p && strchr("rRbBuUfFL@$", *p); i++) { + p++; + } + return (*p == '"' || *p == '\'' || *p == '`') && spawn_first_literal(p, out, n); +} + +/* What may stand before a collection's bracket: nothing, Zig's `&.` / `.`, + * Java's `new String[]`, or a collection constructor. A name before `[` is a + * subscript (`record["argv"]`) and any other call's arguments are not the + * command (`os.path.join("bin", tool)`). */ +static bool spawn_collection_prefix(const char *p, size_t len) { + static const char *const ctors[] = { + "listOf", "arrayOf", "mutableListOf", "List.of", "Arrays.asList", "vec!", "c", "Cmd", NULL}; + if (len == 0 || (len <= 2 && strspn(p, "&.") == len) || + (len > 2 && strncmp(p + len - 2, "[]", 2) == 0 && strncmp(p, "new ", 4) == 0)) { + return true; + } + for (int i = 0; ctors[i]; i++) { + if (strlen(ctors[i]) == len && strncmp(p, ctors[i], len) == 0) { + return true; + } + } + return false; +} + +/* The first element of a collection written at `p` ([..], (..), {..}, + * listOf(..), &.{..}), copied into `out`. False when `p` is no collection. */ +static bool spawn_first_element(const char *p, char *out, size_t n) { + const char *open = p; + while (*open && (spawn_word_char(*open) || strchr("!.&:<> ", *open) || + (open[0] == '[' && open[1] == ']'))) { + open += (open[0] == '[') ? 2 : 1; + } + if ((*open != '[' && *open != '(' && *open != '{') || + !spawn_collection_prefix(p, (size_t)(open - p))) { + return false; + } + p = open; + const char *start = spawn_skip_space(p + 1); + int depth = 0; + const char *end = start; + for (; *end; end++) { + if (*end == '"' || *end == '\'' || *end == '`') { + const char *close = strchr(end + 1, *end); + if (!close) { + break; + } + end = close; + } else if (strchr("[({", *end)) { + depth++; + } else if (strchr("])}", *end)) { + if (depth-- == 0) { + break; + } + } else if (*end == ',' && depth == 0) { + break; + } + } + size_t len = (size_t)(end - start); + if (len == 0 || len >= n) { + return false; + } + memcpy(out, start, len); + out[len] = '\0'; + return true; +} + +/* The program literal an argument spells: a leading literal, or the first + * element of a collection when that element is one. A program held in a + * variable (`[binary, "--version"]`) is no literal: the arguments after it + * are not the program. */ +static bool spawn_expr_literal(const char *expr, char *out, size_t n) { + char element[CBM_SZ_256]; + for (int depth = 0; depth < 3; depth++) { + expr = spawn_skip_label(spawn_skip_space(expr)); + if (spawn_leading_literal(expr, out, n)) { + return true; + } + if (!spawn_first_element(expr, element, sizeof(element))) { + return false; + } + expr = element; + } + return false; +} + +/* A literal from the argument at `index`. Tcl words are literals unquoted; + * elsewhere an unquoted argument counts only when the extractor itself read + * it as the call's string (Perl's `system("make")`). */ +static bool spawn_arg_literal(const CBMCall *call, CBMLanguage lang, int index, bool anywhere, + char *out, size_t n) { + const char *expr = spawn_arg_expr(call, index); + if (!expr) { + if (call->arg_count == 0 && index == 0 && call->first_string_arg) { + snprintf(out, n, "%s", call->first_string_arg); + return true; + } + return false; + } + if (anywhere ? spawn_first_literal(expr, out, n) : spawn_expr_literal(expr, out, n)) { + return true; + } + bool bareword = lang == CBM_LANG_TCL && strpbrk(expr, "$[") == NULL; + if (bareword || (call->first_string_arg && strcmp(expr, call->first_string_arg) == 0)) { + snprintf(out, n, "%s", expr); + return true; + } + return false; +} + +/* A shell word that is not the program: an environment assignment + * (`LC_ALL=C make`) or the `env` that runs the program after them + * (`/usr/bin/env make`). */ +static bool spawn_prefix_word(const char *word, size_t len) { + static const char env[] = "env"; + size_t env_len = sizeof(env) - 1; + if (len >= env_len && strncmp(word + len - env_len, env, env_len) == 0 && + (len == env_len || word[len - env_len - 1] == '/')) { + return true; + } + size_t i = 0; + while (i < len && spawn_word_char(word[i])) { + i++; + } + return i > 0 && i < len && word[i] == '='; +} + +/* The program word of a command line: its first word that is not an + * environment prefix, without directories. Anything built at run time is not + * a program name. */ +static void spawn_program_word(const char *literal, char *buf, size_t n) { + size_t len = 0; + for (;;) { + while (*literal == ' ' || *literal == '\t') { + literal++; + } + len = strcspn(literal, " \t\r\n"); + if (!spawn_prefix_word(literal, len) || !literal[len]) { + break; + } + literal += len; + } + const char *start = literal; + for (size_t i = 0; i < len; i++) { + if (literal[i] == '/' || literal[i] == '\\') { + start = literal + i + 1; + } + } + len -= (size_t)(start - literal); + bool dynamic = len == 0 || len >= CBM_SPAWN_PROGRAM_MAX || len >= n; + for (size_t i = 0; i < len && !dynamic; i++) { + dynamic = strchr("$%{}`()<>|;&*?[]\"'", start[i]) != NULL; + } + if (dynamic) { + snprintf(buf, n, "%s", CBM_SPAWN_DYNAMIC); + return; + } + memcpy(buf, start, len); + buf[len] = '\0'; +} + +void cbm_spawn_program(const cbm_spawn_api_t *api, CBMLanguage lang, const CBMCall *call, char *buf, + size_t n) { + char literal[CBM_SZ_256]; + bool found = false; + if (api->rule == CBM_SPAWN_STRING_RECEIVER) { + found = spawn_first_literal(call->callee_name, literal, sizeof(literal)); + } else { + bool anywhere = api->alt == CBM_SPAWN_ANY_LITERAL; + found = spawn_arg_literal(call, lang, api->arg, anywhere, literal, sizeof(literal)) || + (api->alt >= 0 && + spawn_arg_literal(call, lang, api->alt, false, literal, sizeof(literal))); + } + if (!found) { + snprintf(buf, n, "%s", CBM_SPAWN_DYNAMIC); + return; + } + spawn_program_word(literal, buf, n); +} diff --git a/internal/cbm/spawn_patterns.h b/internal/cbm/spawn_patterns.h new file mode 100644 index 000000000..bd7119bdc --- /dev/null +++ b/internal/cbm/spawn_patterns.h @@ -0,0 +1,71 @@ +/* + * spawn_patterns.h — process-spawning call sites, per language. + * + * Like the HTTP client tables in service_patterns.h: a call that starts + * another program (subprocess.run, exec.Command, posix_spawn, Process.Start, + * Command::new, ...) becomes a SPAWNS edge to a Process node named after the + * program it starts. Nothing resolves the program to code: the edge records + * that the caller leaves the graph through a process boundary, and which + * program it starts when the call spells it as a literal. + */ +#ifndef CBM_SPAWN_PATTERNS_H +#define CBM_SPAWN_PATTERNS_H + +#include "cbm.h" + +#include +#include + +/* How a table spelling matches a call. */ +typedef enum { + /* The full spelling, written out or reached through the file's imports + * (`sp.run` with `import subprocess as sp`, `run` from + * `from subprocess import run`, Go `exec.Command` from "os/exec"). */ + CBM_SPAWN_QUALIFIED = 0, + /* A bare name (`system`, `popen`, `exec`): the spelling alone. A + * definition of that name in the calling module (or one the call reaches + * through an import) still makes it an ordinary call. */ + CBM_SPAWN_BARE, + /* A bare name that counts when the file imports `module` + * (Haskell `callProcess` with System.Process, a destructured + * `const { spawn } = require("child_process")`). */ + CBM_SPAWN_BARE_IMPORTED, + /* Groovy `"git status".execute()`: the receiver is the command line. */ + CBM_SPAWN_STRING_RECEIVER, +} cbm_spawn_rule_t; + +typedef struct { + const char *api; /* canonical spelling: "subprocess.run", "os/exec.Command", "system" */ + const char *module; /* the import a CBM_SPAWN_BARE_IMPORTED name needs, else NULL */ + unsigned char rule; /* cbm_spawn_rule_t */ + signed char arg; /* argument holding the program or command line */ + signed char alt; /* second place to look, -1 for none (CreateProcess: name or line), + * or CBM_SPAWN_ANY_LITERAL */ +} cbm_spawn_api_t; + +/* `alt` value: the program is the first literal anywhere in the argument, + * which is an options tuple or struct (Elixir {:spawn, "cat"}, Zig + * .{ .argv = &.{"ls"} }). Elsewhere only a leading literal or a collection's + * first element is the program. */ +#define CBM_SPAWN_ANY_LITERAL (-2) + +/* The spawn API a call names, or NULL. `imports` are the calling file's own + * import statements (local name -> module path) as extracted, external + * modules included: subprocess or os/exec never resolve to project nodes, so + * the resolver's import map does not hold them. Whether the call is a spawn + * after all is the resolver's to decide: an evidence-backed resolution onto + * project code (an import, the same module, the LSP; never a name guess) + * makes it an ordinary call (src/pipeline/pass_spawns.c). */ +const cbm_spawn_api_t *cbm_spawn_match(CBMLanguage lang, const char *callee, + const CBMImport *imports, int count); + +/* The program a matched spawn starts: the first literal in its program + * argument, that literal's first word, without directories + * ("/usr/bin/git status" -> "git"). CBM_SPAWN_DYNAMIC when the program is + * not a literal. Writes into buf (at least CBM_SPAWN_PROGRAM_MAX bytes). */ +#define CBM_SPAWN_DYNAMIC "" +#define CBM_SPAWN_PROGRAM_MAX 64 +void cbm_spawn_program(const cbm_spawn_api_t *api, CBMLanguage lang, const CBMCall *call, char *buf, + size_t n); + +#endif diff --git a/src/cli/cli.c b/src/cli/cli.c index df51e723b..93df2dabe 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1564,7 +1564,7 @@ static const char skill_content[] = "`check_index_coverage`, `manage_adr`, `ingest_traces`\n" "\n" "## Edge Types\n" - "CALLS, HTTP_CALLS, ASYNC_CALLS, DATA_FLOWS, IMPORTS, DEFINES, DEFINES_METHOD,\n" + "CALLS, HTTP_CALLS, ASYNC_CALLS, SPAWNS, DATA_FLOWS, IMPORTS, DEFINES, DEFINES_METHOD,\n" "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, CALL_REFERENCE, CONFIGURES, REFERENCES_FILE,\n" "FILE_CHANGES_WITH, SIMILAR_TO, SEMANTICALLY_RELATED, CONTAINS_FILE, CONTAINS_FOLDER,\n" "CONTAINS_PACKAGE\n" diff --git a/src/discover/language.c b/src/discover/language.c index 342655e23..116cec024 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -122,11 +122,16 @@ static const ext_entry_t EXT_TABLE[] = { {".frm", CBM_LANG_FORM}, {".prc", CBM_LANG_FORM}, - /* Fortran */ + /* Fortran. Upper-case suffixes are the C-preprocessed spellings of the + * same free-form sources (#ifdef blocks); lookup is case-sensitive. */ {".f03", CBM_LANG_FORTRAN}, {".f08", CBM_LANG_FORTRAN}, {".f90", CBM_LANG_FORTRAN}, {".f95", CBM_LANG_FORTRAN}, + {".F03", CBM_LANG_FORTRAN}, + {".F08", CBM_LANG_FORTRAN}, + {".F90", CBM_LANG_FORTRAN}, + {".F95", CBM_LANG_FORTRAN}, /* GLSL */ {".frag", CBM_LANG_GLSL}, diff --git a/src/graph_buffer/graph_buffer.c b/src/graph_buffer/graph_buffer.c index e55eb28f0..51af15e8a 100644 --- a/src/graph_buffer/graph_buffer.c +++ b/src/graph_buffer/graph_buffer.c @@ -835,6 +835,162 @@ void cbm_gbuf_set_next_id(cbm_gbuf_t *gb, int64_t next_id) { gb->next_id = next_id; } +/* ── Definition variants ───────────────────────────────────────────── + * One name defined more than once under mutually exclusive conditions — + * C-family #if/#else, Rust #[cfg], build-tagged files, a definition under an + * `if` — is ONE identity: one QN, one node, whose edges are the union of + * every variant's body. The collision rule below keeps one canonical winner + * for name, file, lines and properties; every variant's span survives in the + * node's `variants` property ([{"file_path","start_line","end_line"}], + * sorted, unique), so a change in any variant maps to the node. Recorded only + * between definitions of the same label with real spans: a different kind + * sharing the QN (C: struct vs function) is no variant, a span on line 0 is + * no location (delta-route proxies), and a full tie is the same entity. */ +static void *gb_yy_malloc(void *ctx, size_t size) { + (void)ctx; + return cbm_alloc(CBM_MEM_CLASS_GBUF_STRING, size); +} + +static void *gb_yy_realloc(void *ctx, void *block, size_t old_size, size_t size) { + (void)ctx; + (void)old_size; + return cbm_realloc(CBM_MEM_CLASS_GBUF_STRING, block, size); +} + +static void gb_yy_free(void *ctx, void *block) { + (void)ctx; + cbm_free(CBM_MEM_CLASS_GBUF_STRING, block); +} + +static const yyjson_alc gb_yy_alc = {gb_yy_malloc, gb_yy_realloc, gb_yy_free, NULL}; + +enum { GB_VARIANTS_MAX = 64 }; + +typedef struct { + const char *file; + int start; + int end; +} gb_span_t; + +static bool gb_variant_label(const char *label) { + static const char *const labels[] = {"Function", "Method", "Class", "Interface", "Enum", + "Struct", "Trait", "Type", "Macro"}; + for (size_t i = 0; label && i < sizeof(labels) / sizeof(labels[0]); i++) { + if (strcmp(label, labels[i]) == 0) { + return true; + } + } + return false; +} + +static int gb_span_cmp(const void *a, const void *b) { + const gb_span_t *x = a; + const gb_span_t *y = b; + int c = strcmp(x->file, y->file); + if (c == 0) { + c = (x->start > y->start) - (x->start < y->start); + } + if (c == 0) { + c = (x->end > y->end) - (x->end < y->end); + } + return c; +} + +/* A definition's spans: its `variants` property, else its own span. */ +static int gb_spans_of(yyjson_doc *doc, const char *file, int start, int end, gb_span_t *out, + int n) { + yyjson_val *list = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), "variants") : NULL; + if (yyjson_is_arr(list) && yyjson_arr_size(list) > 0) { + size_t i; + size_t max; + yyjson_val *v; + yyjson_arr_foreach(list, i, max, v) { + const char *f = yyjson_get_str(yyjson_obj_get(v, "file_path")); + int s = (int)yyjson_get_int(yyjson_obj_get(v, "start_line")); + int e = (int)yyjson_get_int(yyjson_obj_get(v, "end_line")); + if (f && s > 0 && n < GB_VARIANTS_MAX) { + out[n++] = (gb_span_t){f, s, e}; + } + } + return n; + } + if (start > 0 && n < GB_VARIANTS_MAX) { + out[n++] = (gb_span_t){file ? file : "", start, end}; + } + return n; +} + +/* The survivor's properties with the union of both definitions' variant + * spans. Returns a GBUF_STRING-owned JSON string, or NULL when there is + * nothing to record (fewer than two distinct spans) or on any failure — the + * survivor then keeps its own properties: a lost variants list costs + * precision, never data. */ +static char *gb_variants_props(const char *survivor_props, const char *a_props, const char *a_file, + int a_start, int a_end, const char *b_props, const char *b_file, + int b_start, int b_end) { + if (a_start <= 0 || b_start <= 0) { + return NULL; + } + yyjson_doc *a_doc = + a_props ? yyjson_read_opts((char *)a_props, strlen(a_props), 0, &gb_yy_alc, NULL) : NULL; + yyjson_doc *b_doc = + b_props ? yyjson_read_opts((char *)b_props, strlen(b_props), 0, &gb_yy_alc, NULL) : NULL; + gb_span_t spans[GB_VARIANTS_MAX * 2]; + int n = gb_spans_of(a_doc, a_file, a_start, a_end, spans, 0); + n = gb_spans_of(b_doc, b_file, b_start, b_end, spans, n); + qsort(spans, (size_t)n, sizeof(spans[0]), gb_span_cmp); + int unique = 0; + for (int i = 0; i < n; i++) { + if (unique == 0 || gb_span_cmp(&spans[unique - 1], &spans[i]) != 0) { + spans[unique++] = spans[i]; + } + } + char *out = NULL; + if (unique >= 2) { + yyjson_mut_doc *mut = yyjson_mut_doc_new(&gb_yy_alc); + yyjson_doc *s_doc = survivor_props + ? yyjson_read_opts((char *)survivor_props, strlen(survivor_props), + 0, &gb_yy_alc, NULL) + : NULL; + yyjson_mut_val *root = s_doc && yyjson_is_obj(yyjson_doc_get_root(s_doc)) + ? yyjson_val_mut_copy(mut, yyjson_doc_get_root(s_doc)) + : yyjson_mut_obj(mut); + yyjson_mut_val *list = yyjson_mut_arr(mut); + for (int i = 0; root && list && i < unique; i++) { + yyjson_mut_val *v = yyjson_mut_arr_add_obj(mut, list); + yyjson_mut_obj_add_strcpy(mut, v, "file_path", spans[i].file); + yyjson_mut_obj_add_int(mut, v, "start_line", spans[i].start); + yyjson_mut_obj_add_int(mut, v, "end_line", spans[i].end); + } + if (mut && root && list) { + yyjson_mut_obj_remove_key(root, "variants"); + yyjson_mut_obj_add_val(mut, root, "variants", list); + yyjson_mut_doc_set_root(mut, root); + out = yyjson_mut_write_opts(mut, 0, &gb_yy_alc, NULL, NULL); + } + yyjson_doc_free(s_doc); + yyjson_mut_doc_free(mut); + } + yyjson_doc_free(a_doc); + yyjson_doc_free(b_doc); + return out; +} + +/* Record the variant spans of a same-QN collision on the surviving node. */ +static void gb_record_variants(cbm_gbuf_node_t *survivor, const char *a_label, const char *a_props, + const char *a_file, int a_start, int a_end, const char *b_label, + const char *b_props, const char *b_file, int b_start, int b_end) { + if (!gb_variant_label(a_label) || !b_label || strcmp(a_label, b_label) != 0) { + return; + } + char *merged = gb_variants_props(survivor->properties_json, a_props, a_file, a_start, a_end, + b_props, b_file, b_start, b_end); + if (merged) { + cbm_free(CBM_MEM_CLASS_GBUF_STRING, survivor->properties_json); + survivor->properties_json = merged; + } +} + /* ── Node operations ─────────────────────────────────────────────── */ int64_t cbm_gbuf_upsert_node(cbm_gbuf_t *gb, const char *label, const char *name, @@ -892,8 +1048,19 @@ int64_t cbm_gbuf_upsert_node(cbm_gbuf_t *gb, const char *label, const char *name c = strcmp(existing->label ? existing->label : "", label ? label : ""); } if (c > 0) { + gb_record_variants(existing, existing->label, existing->properties_json, + existing->file_path, existing->start_line, existing->end_line, label, + properties_json, file_path, start_line, end_line); return existing->id; /* existing entity is the canonical winner */ } + /* The arrival wins: its properties, plus every variant span both carry. */ + char *variant_props = NULL; + if (gb_variant_label(label) && existing->label && strcmp(existing->label, label) == 0) { + variant_props = + gb_variants_props(properties_json, existing->properties_json, existing->file_path, + existing->start_line, existing->end_line, properties_json, + file_path, start_line, end_line); + } /* Update in-place. name/properties are strdup'd BEFORE freeing old ones * (callers may pass existing->name as an argument). label/file_path are * interned: gb_intern returns a stable pool pointer (idempotent even when @@ -903,7 +1070,9 @@ int64_t cbm_gbuf_upsert_node(cbm_gbuf_t *gb, const char *label, const char *name * label/name — cbm_gbuf_find_by_label/name then missed or mis-listed it, * which is how the flickering Function set reached the semantic pass). */ char *new_name = heap_strdup(name); - char *new_props = properties_json ? heap_strdup(properties_json) : NULL; + char *new_props = variant_props ? variant_props + : properties_json ? heap_strdup(properties_json) + : NULL; const char *new_label_interned = gb_intern(gb, label); bool label_changed = !existing->label || !new_label_interned || strcmp(existing->label, new_label_interned) != 0; @@ -1493,7 +1662,22 @@ static void merge_update_existing(cbm_gbuf_t *dst, cbm_gbuf_node_t *existing, c = strcmp(existing->label ? existing->label : "", sn->label ? sn->label : ""); } bool sn_wins = c <= 0; + if (!sn_wins) { + gb_record_variants(existing, existing->label, existing->properties_json, + existing->file_path, existing->start_line, existing->end_line, + sn->label, sn->properties_json, sn->file_path, sn->start_line, + sn->end_line); + } if (sn_wins) { + /* src's properties, plus every variant span both carry. */ + char *variant_props = NULL; + if (gb_variant_label(sn->label) && existing->label && + strcmp(existing->label, sn->label) == 0) { + variant_props = gb_variants_props(sn->properties_json, existing->properties_json, + existing->file_path, existing->start_line, + existing->end_line, sn->properties_json, + sn->file_path, sn->start_line, sn->end_line); + } /* Keep the secondary indexes consistent when the surviving * label/name changes (the old code left the node listed under its * original label/name, so find_by_label/name mis-listed it). */ @@ -1518,9 +1702,10 @@ static void merge_update_existing(cbm_gbuf_t *dst, cbm_gbuf_node_t *existing, existing->file_path = (char *)gb_intern(dst, sn->file_path); existing->start_line = sn->start_line; existing->end_line = sn->end_line; - if (sn->properties_json) { + if (variant_props || sn->properties_json) { cbm_free(CBM_MEM_CLASS_GBUF_STRING, existing->properties_json); - existing->properties_json = heap_strdup(sn->properties_json); + existing->properties_json = + variant_props ? variant_props : heap_strdup(sn->properties_json); } if (label_changed) { node_ptr_array_t *by_label = get_or_create_node_array( diff --git a/src/mcp/test_impact_engine_reach.c b/src/mcp/test_impact_engine_reach.c index a3f96d0ba..dd581a4f5 100644 --- a/src/mcp/test_impact_engine_reach.c +++ b/src/mcp/test_impact_engine_reach.c @@ -22,8 +22,10 @@ * reached, never expanded (their caller is the runner's main). */ #define TE_RUNNER_ROLE_PREFIX "tf_maybe_run_" -static const char *const te_walk_types[] = {"CALLS", "ASYNC_CALLS", "USAGE", "CALL_REFERENCE", - "READS"}; +/* SPAWNS only carries reach from a Process node, which enters the walk + * through te_spawn_lift alone. */ +static const char *const te_walk_types[] = {"CALLS", "ASYNC_CALLS", "USAGE", + "CALL_REFERENCE", "READS", "SPAWNS"}; typedef struct { int64_t *items; @@ -47,6 +49,10 @@ static bool te_is_function(const cbm_ti_node_t *n) { * definition starts on the line of its TEST(...) / SUITE(...) — graphs name * them differently (`x` without configured declarations, `TEST_x` with) — * else every function named `name`. *count of them, ids appended. */ +static bool te_named_match(const cbm_ti_node_t *n, int pass, int line, const char *name) { + return te_is_function(n) && (pass == 0 ? n->start_line == line : strcmp(n->name, name) == 0); +} + static bool te_named(te_ctx_t *c, const char *file, const char *name, int line, te_ids_t *out, int *count) { bool ok = true; @@ -59,10 +65,17 @@ static bool te_named(te_ctx_t *c, const char *file, const char *name, int line, *count = 0; for (int pass = 0; pass < 2 && *count == 0; pass++) { for (int i = 0; i < n; i++) { - bool match = - te_is_function(&nodes[i]) && - (pass == 0 ? nodes[i].start_line == line : strcmp(nodes[i].name, name) == 0); - if (!match) { + /* Any span of a definition names it by its start line, the + * primary one or a variant (graph_buffer.c "Definition + * variants"); each definition counts once. */ + if (!te_named_match(&nodes[i], pass, line, name)) { + continue; + } + bool counted = false; + for (int k = 0; k < i && !counted; k++) { + counted = nodes[k].id == nodes[i].id && te_named_match(&nodes[k], pass, line, name); + } + if (counted) { continue; } (*count)++; @@ -239,7 +252,7 @@ static bool te_runner_sinks(te_ctx_t *c, te_sinks_t *s) { false; } for (int k = 0; k < count; k++) { - if (!te_is_function(&nodes[k])) { + if (!te_is_function(&nodes[k]) || nodes[k].variant_span) { continue; } bool main_fn = strcmp(nodes[k].name, "main") == 0; @@ -440,7 +453,7 @@ static bool te_gap_seeds(te_ctx_t *c, const te_gap_t *g, te_ids_t *out) { int n = 0; const cbm_ti_node_t *nodes = ok ? cbm_ti_graph_file(c->graph, g->file, &n, &ok) : NULL; for (int i = 0; ok && i < n; i++) { - ok = te_ids_push(c, out, nodes[i].id); + ok = nodes[i].variant_span || te_ids_push(c, out, nodes[i].id); } return ok || te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, "graph read failed"); } @@ -519,7 +532,9 @@ static bool te_reach_rows(te_ctx_t *c, cbm_impact_walk_t *walk, const bool *fixt int changed_n = 0; const int *changed = cbm_ti_seeds_changed_cases(c->seeds, &changed_n); c->reach = cbm_arena_calloc(&c->arena, (size_t)(n ? n : 1) * sizeof(*c->reach)); - if (!c->reach) { + /* The graph node each row's name mapped to (0 = none or several). */ + int64_t *row_id = cbm_arena_calloc(&c->arena, (size_t)(n ? n : 1) * sizeof(*row_id)); + if (!c->reach || !row_id) { return te_oom(c); } for (int i = 0; i < n; i++) { @@ -540,6 +555,7 @@ static bool te_reach_rows(te_ctx_t *c, cbm_impact_walk_t *walk, const bool *fixt for (int k = 0; k < ids.count; k++) { reached = reached || cbm_impact_walk_reached(walk, ids.items[k]); } + int64_t id = count == 1 && ids.count == 1 ? ids.items[0] : 0; cbm_free(CBM_MEM_CLASS_EXTRACT, ids.items); bool was_changed = fixture_cases[i]; for (int k = 0; k < changed_n; k++) { @@ -553,12 +569,18 @@ static bool te_reach_rows(te_ctx_t *c, cbm_impact_walk_t *walk, const bool *fixt continue; } if (at >= 0) { - /* the same name twice in one file: ambiguous, never narrowed */ - c->reach[at].mapped = false; + /* The same name twice in one file is ambiguous and never narrowed, + * unless both are the same graph node: one test written once per + * #if/#elif branch is one definition with variants (graph_buffer.c + * "Definition variants"), as the graph already counts it. */ + if (id == 0 || row_id[at] != id) { + c->reach[at].mapped = false; + } c->reach[at].reached = c->reach[at].reached || reached; c->reach[at].changed = c->reach[at].changed || was_changed; continue; } + row_id[c->reach_count] = id; c->reach[c->reach_count++] = (cbm_test_reach_t){.file = cases[i].file, .test = cases[i].name, .mapped = count == 1, @@ -666,6 +688,52 @@ static bool te_header_triggers(te_ctx_t *c) { return true; } +/* ── Spawned processes (src/pipeline/pass_spawns.c) ───────────────── + * A test that starts a program runs code no CALLS edge leads to: the program + * runs its main, and main reaches everything. The graph does not resolve + * which program a Process node is, so when the walk reaches a `main` outside + * the tests (the product's entry), every Process node joins the walk as a + * further seed, and SPAWNS, walked from the process to whoever starts it, + * carries the reach to the tests that spawn. More, never fewer. */ +static bool te_spawn_lift(te_ctx_t *c, cbm_impact_walk_t *walk) { + cbm_node_t *mains = NULL; + int main_count = 0; + if (cbm_store_find_nodes_by_name(c->store, "test-impact", "main", &mains, &main_count) != + CBM_STORE_OK) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, "graph read failed") && + false; + } + bool product_main = false; + for (int i = 0; i < main_count && !product_main; i++) { + product_main = mains[i].label && strcmp(mains[i].label, "Function") == 0 && + mains[i].file_path && !te_test_file(c, mains[i].file_path) && + cbm_impact_walk_reached(walk, mains[i].id); + } + cbm_store_free_nodes(mains, main_count); + if (!product_main) { + return true; + } + cbm_node_t *processes = NULL; + int process_count = 0; + if (cbm_store_find_nodes_by_label(c->store, "test-impact", "Process", &processes, + &process_count) != CBM_STORE_OK) { + return te_fallback(c, CBM_TEST_RESULT_FALLBACK_GRAPH_UNAVAILABLE, "graph read failed") && + false; + } + te_ids_t seeds = {0}; + bool ok = true; + for (int i = 0; ok && i < process_count; i++) { + ok = te_ids_push(c, &seeds, processes[i].id); + } + cbm_store_free_nodes(processes, process_count); + if (ok && seeds.count && cbm_impact_walk_run(walk, seeds.items, seeds.count) != CBM_STORE_OK) { + ok = te_fallback(c, CBM_TEST_RESULT_FALLBACK_ENGINE_SATURATED, "the impact walk failed") && + false; + } + cbm_free(CBM_MEM_CLASS_EXTRACT, seeds.items); + return ok; +} + bool te_reach(te_ctx_t *c) { int64_t *extra = NULL; int extra_count = 0; @@ -683,6 +751,9 @@ bool te_reach(te_ctx_t *c) { } } } + /* After the runner check: the runner re-executing itself is a spawn too, + * and reaching its main through that is no change to the runner. */ + ok = ok && te_spawn_lift(c, walk); ok = ok && te_reach_rows(c, walk, fixture_cases) && te_suite_triggers(c, walk) && te_rule_triggers(c) && te_header_triggers(c); c->static_complete = ok; diff --git a/src/mcp/test_impact_seed.c b/src/mcp/test_impact_seed.c index ccade1138..113c1ab7e 100644 --- a/src/mcp/test_impact_seed.c +++ b/src/mcp/test_impact_seed.c @@ -89,8 +89,8 @@ struct cbm_ti_graph { }; static bool ts_seedless(const char *label) { - static const char *const seedless[] = {"File", "Folder", "Project", "Module", - "Package", "Section", "Route", "Resource"}; + static const char *const seedless[] = {"File", "Folder", "Project", "Module", "Package", + "Section", "Route", "Process", "Resource"}; for (size_t i = 0; i < sizeof(seedless) / sizeof(seedless[0]); i++) { if (label && strcmp(label, seedless[i]) == 0) { return true; @@ -146,32 +146,69 @@ static int ts_node_compare(const void *left, const void *right) { return a->id < b->id ? -1 : (a->id > b->id ? 1 : 0); } +static bool ts_node_from_row(cbm_ti_graph_t *g, cbm_ti_node_t *n, const cbm_node_t *row) { + n->id = row->id; + n->label = cbm_arena_strdup(&g->arena, row->label ? row->label : ""); + n->name = cbm_arena_strdup(&g->arena, row->name ? row->name : ""); + n->qualified_name = cbm_arena_strdup(&g->arena, row->qualified_name ? row->qualified_name : ""); + n->start_line = row->start_line; + n->end_line = row->end_line; + n->variant_span = false; + return n->label && n->name && n->qualified_name; +} + +static bool ts_has_span(const cbm_ti_node_t *nodes, int count, const cbm_node_t *row) { + for (int i = 0; i < count; i++) { + if (nodes[i].id == row->id && nodes[i].start_line == row->start_line && + nodes[i].end_line == row->end_line) { + return true; + } + } + return false; +} + +/* The file's definitions, each with every span it has in the file: a + * definition written once per #if branch or platform file is ONE node + * (graph_buffer.c "Definition variants") whose own span is one variant, so + * its other variants here (other branches, or a node whose file_path names + * another platform file) join as further spans of the same node. A change + * in any variant then seeds that definition. */ static ts_file_t *ts_load_file(cbm_ti_graph_t *g, const char *file) { cbm_node_t *rows = NULL; int count = 0; if (cbm_store_find_nodes_by_file(g->store, g->project, file, &rows, &count) != CBM_STORE_OK) { return NULL; } + cbm_node_t *spans = NULL; + int span_count = 0; + if (cbm_store_find_variant_spans_by_file(g->store, g->project, file, &spans, &span_count) != + CBM_STORE_OK) { + cbm_store_free_nodes(rows, count); + return NULL; + } + int total = count + span_count; ts_file_t *f = cbm_arena_calloc(&g->arena, sizeof(*f)); - cbm_ti_node_t *nodes = cbm_arena_alloc(&g->arena, (size_t)(count ? count : 1) * sizeof(*nodes)); + cbm_ti_node_t *nodes = cbm_arena_alloc(&g->arena, (size_t)(total ? total : 1) * sizeof(*nodes)); const char *key = cbm_arena_strdup(&g->arena, file); bool ok = f && nodes && key; int kept = 0; for (int i = 0; ok && i < count; i++) { - if (ts_seedless(rows[i].label)) { - continue; + if (!ts_seedless(rows[i].label)) { + ok = ts_node_from_row(g, &nodes[kept++], &rows[i]); + } + } + for (int i = 0; ok && i < span_count; i++) { + if (!ts_seedless(spans[i].label) && !ts_has_span(nodes, kept, &spans[i])) { + bool listed = false; + for (int j = 0; j < kept && !listed; j++) { + listed = nodes[j].id == spans[i].id; + } + ok = ts_node_from_row(g, &nodes[kept], &spans[i]); + nodes[kept++].variant_span = listed; } - cbm_ti_node_t *n = &nodes[kept++]; - n->id = rows[i].id; - n->label = cbm_arena_strdup(&g->arena, rows[i].label ? rows[i].label : ""); - n->name = cbm_arena_strdup(&g->arena, rows[i].name ? rows[i].name : ""); - n->qualified_name = - cbm_arena_strdup(&g->arena, rows[i].qualified_name ? rows[i].qualified_name : ""); - n->start_line = rows[i].start_line; - n->end_line = rows[i].end_line; - ok = n->label && n->name && n->qualified_name; } cbm_store_free_nodes(rows, count); + cbm_store_free_nodes(spans, span_count); if (!ok) { return NULL; } diff --git a/src/mcp/test_impact_seed.h b/src/mcp/test_impact_seed.h index 23e97114b..ce0a5c6ab 100644 --- a/src/mcp/test_impact_seed.h +++ b/src/mcp/test_impact_seed.h @@ -41,6 +41,10 @@ typedef struct { const char *qualified_name; int start_line; int end_line; + /* A further span of a definition already listed (another #if branch or + * platform-file variant, graph_buffer.c "Definition variants"): it maps + * lines to the node, but enumerating definitions counts the node once. */ + bool variant_span; } cbm_ti_node_t; /* Borrows the store until free. Nodes of a file are read once, on first use. */ diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 855e67363..a85ad7dca 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -686,6 +686,15 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM res.confidence = lsp->confidence; res.strategy = lsp->strategy; res.candidate_count = 1; + /* An LSP answer on a synthetic builtin (``) is + * not project code: a spawn spelling still spawns. The parallel + * pass keeps the LSP answer in `res` and asks the same question. + * MUST match pass_parallel.c. */ + cbm_pipeline_spawn_t spawn; + if (cbm_pipeline_spawn_site(ctx->gbuf, lang, call, imports, &res, &spawn)) { + cbm_pipeline_emit_spawn(ctx->gbuf, source_node, call, &spawn); + return SKIP_ONE; + } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, false, route_mount); return SKIP_ONE; @@ -755,6 +764,14 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM if (!rust_external) { res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count); + /* Cross-language veto: a name-only guess never binds another + * language's symbol (a JS app.get must not become Python's + * builtins.dict.get). The vetoed answer becomes EMPTY here, before + * the fallbacks below, so the route/HTTP classification still runs. + * MUST match pass_parallel.c. */ + if (cbm_registry_name_guess_vetoed(ctx->registry, lang, &res)) { + res = (cbm_resolution_t){0}; + } } /* `p->open(fd)`: the registry does not split a callee on the arrow, and a * member is not a name it could resolve anyway. The object's type decides, @@ -768,6 +785,14 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM call->enclosing_func_qn, call->callee_name, cbm_c_member_rule_file(lang, rel), &res) != NULL; } + /* A call that starts another program (subprocess.run, exec.Command, + * posix_spawn): a SPAWNS edge to its Process node instead of a CALLS + * edge. MUST match pass_parallel.c. */ + cbm_pipeline_spawn_t spawn; + if (cbm_pipeline_spawn_site(ctx->gbuf, lang, call, imports, &res, &spawn)) { + cbm_pipeline_emit_spawn(ctx->gbuf, source_node, call, &spawn); + return SKIP_ONE; + } if (!res.qualified_name || res.qualified_name[0] == '\0') { /* Resolution is empty when the callee belongs to an EXTERNAL client * library whose source is not in the indexed tree (e.g. `requests.get`, diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 4fc6dde05..113c25228 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -381,7 +381,7 @@ static void pd_add_file_doc(const cbm_gbuf_node_t *file_node, const char *doc) { * `file_doc`, the file's own doc (passed with its first def only), goes on the * File node. */ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const char *rel, - const char *file_doc) { + const char *file_doc, CBMLanguage lang) { if (!def->qualified_name || !def->name) { return; } @@ -401,7 +401,7 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed * through the same predicate, so the three registries cannot diverge. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label); + cbm_registry_add_lang(ctx->registry, def->name, def->qualified_name, def->label, lang); } char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); @@ -956,7 +956,7 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t /* Create nodes for each definition */ const char *file_doc = result->module_doc; /* goes with the first def */ for (int d = 0; d < result->defs.count; d++) { - process_def(ctx, &result->defs.items[d], rel, file_doc); + process_def(ctx, &result->defs.items[d], rel, file_doc, lang); file_doc = NULL; total_defs++; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 6a9b1b7de..d7847305a 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1730,7 +1730,7 @@ int cbm_parallel_extract(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, * once per file by the caller: computing the file QN and finding its node for * every definition was 700 k allocations and lookups on the Go corpus. */ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, - int64_t file_node_id, int *reg_entries) { + int64_t file_node_id, CBMLanguage lang, int *reg_entries) { int edges = 0; if (!def->name || !def->qualified_name || !def->label) { return 0; @@ -1738,7 +1738,7 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d /* Registry membership is defined ONCE by cbm_label_is_registry_symbol * (helpers.c) — see pass_definitions.c for the per-label rationale. */ if (cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label); + cbm_registry_add_lang(ctx->registry, def->name, def->qualified_name, def->label, lang); (*reg_entries)++; } const cbm_gbuf_node_t *def_node = cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name); @@ -1933,8 +1933,8 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t free(file_qn); pp_add_file_doc(file_node, result->module_doc); for (int d = 0; d < result->defs.count; d++) { - defines_edges += - register_and_link_def(ctx, &result->defs.items[d], file_node_id, ®_entries); + defines_edges += register_and_link_def(ctx, &result->defs.items[d], file_node_id, + files[i].language, ®_entries); } } @@ -3186,6 +3186,13 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB _rc_t0 = extract_now_ns(); try_field_type_hint(rc, &res, call->callee_name, source_node->id); + /* Cross-language veto — the same predicate as pass_calls.c, applied + * AFTER the field-type hint so the hint may still re-pick a compatible + * candidate first, and BEFORE the empty-resolution fallbacks below so a + * vetoed call still reaches route/HTTP classification. */ + if (cbm_registry_name_guess_vetoed(rc->registry, lang, &res)) { + res = (cbm_resolution_t){0}; + } atomic_fetch_add_explicit(&rc->time_ns_rc_hint, extract_now_ns() - _rc_t0, memory_order_relaxed); @@ -3281,19 +3288,29 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } } + /* A call that starts another program (subprocess.run, exec.Command, + * posix_spawn): a SPAWNS edge to its Process node instead of a CALLS + * edge. `res` holds the LSP answer when there is one, as the + * sequential pass asks inside its LSP branch. MUST match pass_calls.c. */ + cbm_pipeline_spawn_t spawn; + if (cbm_pipeline_spawn_site(rc->main_gbuf, lang, call, &result->imports, &res, &spawn)) { + cbm_pipeline_emit_spawn(ws->local_edge_buf, source_node, call, &spawn); + continue; + } + if (!res.qualified_name || res.qualified_name[0] == '\0') { if (cbm_service_pattern_route_method(call->callee_name) != NULL) { cbm_resolution_t fake_res = {.qualified_name = call->callee_name, .confidence = PP_HALF_CONF, .strategy = "callee_suffix"}; - /* #2053: an LSP-external Rust call (`map.get(k)` on a std - * HashMap) reaches this branch only because its registry - * fallback was skipped. Without a route path the plain-CALLS - * fall-through would bind source -> source, a fabricated - * self-call, so it keeps only the route/service edges. */ + /* The target here is the SOURCE itself (nothing resolved), so + * the plain-CALLS fall-through for a route-verb call without a + * route path (`cache.get(key)`) would be a self-loop; that holds + * for #2053's LSP-external Rust calls too. Suppress it always; + * route/HTTP/service classification is unchanged. */ emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, - imp_count, rust_external, route_mount); + imp_count, true, route_mount); } else if (cbm_service_pattern_is_global_fetch(call->callee_name)) { /* Native `fetch()` (#856): only the global API once resolution * has failed to find a local/imported `fetch`. Call the low-level diff --git a/src/pipeline/pass_spawns.c b/src/pipeline/pass_spawns.c new file mode 100644 index 000000000..062ea4039 --- /dev/null +++ b/src/pipeline/pass_spawns.c @@ -0,0 +1,115 @@ +/* + * pass_spawns.c — SPAWNS edges: a call that starts another program, to the + * Process node of that program (internal/cbm/spawn_patterns.h). + * + * Shared by both call resolvers (pass_calls.c, pass_parallel.c), which ask + * at the same point — after registry resolution and the cross-language veto, + * before the empty-resolution fallbacks — so they emit the same edges. + */ +#include "pipeline/pipeline_internal.h" +#include "graph_buffer/graph_buffer.h" +#include "foundation/constants.h" +#include "foundation/str_util.h" +#include "spawn_patterns.h" + +#include +#include + +/* Edge confidence: a site read from the source, or only from the + * macro-expanded buffer of a C-family file. */ +static const double SPAWN_CONF_SOURCE = 1.0; +static const double SPAWN_CONF_EXPANDED = 0.5; + +/* Strategies that only guess by name: they never make a spawn spelling an + * ordinary call (`subprocess.run` must not become the project's `run`, nor + * a C `system(...)` the static `system` of another file). */ +static bool spawn_name_guess(const char *strategy) { + static const char *const guesses[] = {"unique_name", "suffix_match", "qualified_suffix", + "field_type_hint", "fuzzy", NULL}; + for (int i = 0; strategy && guesses[i]; i++) { + if (strcmp(strategy, guesses[i]) == 0) { + return true; + } + } + return false; +} + +/* Labels of code a call can run: a function, or a type it constructs. */ +static bool spawn_code_label(const char *label) { + static const char *const labels[] = {"Function", "Method", "Class", "Struct", "Interface", + "Trait", "Enum", "Type", "Macro", NULL}; + for (int i = 0; label && labels[i]; i++) { + if (strcmp(label, labels[i]) == 0) { + return true; + } + } + return false; +} + +/* The call reached code in the project's own sources. A synthetic builtin + * (a `` node), a QN without a node, and a Variable are not: + * `const { spawn } = require("child_process")` mints a Variable `spawn` that + * the call resolves to by same module, and it is the import binding. For a + * qualified spelling, same_module is a guess too: it matched the suffix + * alone, so `subprocess.run` inside a module's own `def run` found that + * `run`. */ +static bool spawn_resolved_to_project(const cbm_gbuf_t *gbuf, const cbm_resolution_t *res, + bool qualified) { + if (!res || !res->qualified_name || !res->qualified_name[0]) { + return false; + } + const cbm_gbuf_node_t *node = cbm_gbuf_find_by_qn(gbuf, res->qualified_name); + if (!node || !node->file_path || !node->file_path[0] || node->file_path[0] == '<' || + !spawn_code_label(node->label)) { + return false; + } + bool guess = spawn_name_guess(res->strategy) || + (qualified && res->strategy && strcmp(res->strategy, "same_module") == 0); + return !guess; +} + +bool cbm_pipeline_spawn_site(const cbm_gbuf_t *gbuf, CBMLanguage lang, const CBMCall *call, + const CBMImportArray *imports, const cbm_resolution_t *res, + cbm_pipeline_spawn_t *out) { + const cbm_spawn_api_t *api = cbm_spawn_match( + lang, call->callee_name, imports ? imports->items : NULL, imports ? imports->count : 0); + bool qualified = strpbrk(call->callee_name, ".:/\\") != NULL; + if (!api || spawn_resolved_to_project(gbuf, res, qualified)) { + return false; + } + out->api = api->api; + cbm_spawn_program(api, lang, call, out->program, sizeof(out->program)); + return true; +} + +void cbm_pipeline_emit_spawn(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, + const cbm_pipeline_spawn_t *spawn) { + char program[CBM_SZ_128]; + char api[CBM_SZ_256]; + cbm_json_escape(program, (int)sizeof(program), spawn->program); + cbm_json_escape(api, (int)sizeof(api), spawn->api); + char qn[CBM_SZ_128]; + snprintf(qn, sizeof(qn), "__process__%s", spawn->program); + char node_props[CBM_SZ_256]; + snprintf(node_props, sizeof(node_props), "{\"program\":\"%s\"}", program); + int64_t process_id = + cbm_gbuf_upsert_node(gbuf, "Process", spawn->program, qn, "", 0, 0, node_props); + if (process_id <= 0) { + return; + } + /* C-family files are extracted twice; a call from the macro-expanded + * buffer carries an expanded-buffer line, not a source line. It records + * no line and less confidence, so the raw site's edge (which wins on + * confidence when both exist) keeps the source line. */ + char edge_props[CBM_SZ_512]; + if (call->source_origin == CBM_SOURCE_ORIGIN_RAW) { + snprintf(edge_props, sizeof(edge_props), + "{\"api\":\"%s\",\"program\":\"%s\",\"line\":%d,\"confidence\":%.2f}", api, + program, call->start_line, SPAWN_CONF_SOURCE); + } else { + snprintf(edge_props, sizeof(edge_props), + "{\"api\":\"%s\",\"program\":\"%s\",\"confidence\":%.2f}", api, program, + SPAWN_CONF_EXPANDED); + } + cbm_gbuf_insert_edge(gbuf, source->id, process_id, "SPAWNS", edge_props); +} diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 0d013a8c3..fd2233fc9 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -235,10 +235,28 @@ typedef struct { cbm_registry_t *cbm_registry_new(void); void cbm_registry_free(cbm_registry_t *r); -/* Register a function/method/class. All strings are copied. */ +/* Register a function/method/class. All strings are copied. The entry's + * language is unknown, so name-based resolution never filters it out. */ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label); +/* As cbm_registry_add, recording `lang`: the DETECTED language of the file + * that defines the symbol (CBM_LANG_COUNT = unknown), which the cross-language + * veto (cbm_registry_name_guess_vetoed) checks against the caller. */ +void cbm_registry_add_lang(cbm_registry_t *r, const char *name, const char *qualified_name, + const char *label, CBMLanguage lang); + +/* True when code in `caller` can call a symbol defined in `target` by bare + * name: same language, same interop family (JS/TS + script hosts, C/C++/CUDA/ + * ObjC, JVM, .NET, ...; the table lives in registry.c), or Swift with the C + * family. An unknown language (CBM_LANG_COUNT) on either side is compatible. */ +bool cbm_lang_resolution_compatible(CBMLanguage caller, CBMLanguage target); + +/* Language of a synthetic definition source minted by a language's own LSP + * layer ("" -> Python, "" -> Kotlin); + * CBM_LANG_COUNT for anything else. */ +CBMLanguage cbm_registry_synthetic_path_language(const char *file_path); + /* Resolve a callee name using prioritized strategies. * import_map: NULL-terminated array of {local_name, resolved_qn} pairs, or NULL. * Returns result with qualified_name="" if unresolved. @@ -250,6 +268,21 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count); +/* Cross-language veto for a CALLS resolution made by a caller written in + * `caller_lang` (its file's detected language). True when `res` is a + * name-only answer (qualified_suffix, unique_name, suffix_match, the + * parallel field_type_hint re-pick, or same_module: two files of different + * languages with the same path stem share a module QN) whose target's recorded language the + * caller cannot call (cbm_lang_resolution_compatible): a JS call must never + * bind a Python symbol by spelling alone. Callers treat a vetoed answer as + * EMPTY before their empty-resolution fallbacks (route registration, HTTP + * clients), so those still classify the call. A veto, not a re-pick: the + * strategy chain is unchanged. Import-map and lsp_* answers are never + * vetoed; CBM_LANG_COUNT never vetoes. pass_calls.c and pass_parallel.c + * MUST apply it identically. Pure; unit-tested in test_registry.c. */ +bool cbm_registry_name_guess_vetoed(const cbm_registry_t *r, CBMLanguage caller_lang, + const cbm_resolution_t *res); + /* Relation-permitting resolve for SQL FROM/JOIN lineage usages ONLY — the one * consumer allowed to bind Table/View targets. Uncached (the per-file resolve * cache stores the default variant's relation-vetoed answers). */ diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 4549e8d13..b60f8bdec 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1254,12 +1254,52 @@ static bool incr_label_is_registry_symbol(const char *label) { * project's definition symbols so the resolver can match cross-file symbols * during incremental. Mirrors the full-index registry contents exactly so an * incremental re-resolve picks the same nodes a full reindex would. */ +/* Each symbol is seeded with its file's DETECTED language, exactly what the + * full index recorded (pass_definitions.c / pass_parallel.c), so the cross- + * language veto judges an incremental re-resolve the same way. Stored nodes carry no + * language, so it comes from the current discovery (rel_path -> language); + * synthetic LSP sources () map by their fixed path, which is + * also why a builtin stub that outlives the last .py file stays Python. */ +typedef struct { + cbm_registry_t *registry; + CBMHashTable *lang_by_path; /* rel_path -> (CBMLanguage + 1); keys borrowed */ +} registry_seed_t; + static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { - cbm_registry_t *r = (cbm_registry_t *)userdata; + registry_seed_t *seed = (registry_seed_t *)userdata; if (!incr_label_is_registry_symbol(node->label)) { return; } - cbm_registry_add(r, node->name, node->qualified_name, node->label); + CBMLanguage lang = cbm_registry_synthetic_path_language(node->file_path); + if (lang == CBM_LANG_COUNT && seed->lang_by_path && node->file_path) { + void *v = cbm_ht_get(seed->lang_by_path, node->file_path); + if (v) { + lang = (CBMLanguage)((uintptr_t)v - SKIP_ONE); + } + } + cbm_registry_add_lang(seed->registry, node->name, node->qualified_name, node->label, lang); +} + +/* Seed the registry from every registry symbol in the graph buffer, with + * languages taken from the discovered files (see registry_visitor). */ +static void registry_seed_from_gbuf(cbm_registry_t *registry, const cbm_gbuf_t *gbuf, + const cbm_file_info_t *files, int file_count) { + registry_seed_t seed = {.registry = registry, .lang_by_path = NULL}; + if (files && file_count > 0) { + seed.lang_by_path = cbm_ht_create((size_t)file_count * PAIR_LEN); + } + if (seed.lang_by_path) { + for (int i = 0; i < file_count; i++) { + if (files[i].rel_path) { + cbm_ht_set(seed.lang_by_path, files[i].rel_path, + (void *)((uintptr_t)files[i].language + SKIP_ONE)); + } + } + } + cbm_gbuf_foreach_node(gbuf, registry_visitor, &seed); + if (seed.lang_by_path) { + cbm_ht_free(seed.lang_by_path); + } } static void free_incremental_result_cache(CBMFileResult **cache, int count) { @@ -1294,6 +1334,9 @@ enum { * always worth attempting; the percentage governs at scale, where it is * the honest signal that a rebuild costs less than a repair. */ CLOSURE_BUDGET_FLOOR_FILES = 8, + /* Variant partners of partners: real chains are one step (a header and + * its source, a set of platform files); a longer one declines. */ + CLOSURE_VARIANT_ROUNDS_MAX = 8, }; typedef struct { @@ -1975,6 +2018,84 @@ static void closure_seed_governed(CBMHashTable *closure_set, const cbm_file_info } } +static bool closure_path_listed(char **paths, int count, const char *path) { + for (int i = 0; i < count; i++) { + if (strcmp(paths[i], path) == 0) { + return true; + } + } + return false; +} + +/* Variant partners (graph_buffer.c "Definition variants"): a definition + * written in several files (platform files, a struct's header declaration + * and its source definition, Go build-tagged files) is ONE node whose + * file_path names one of them, with a DEFINES edge from every file that + * writes a variant. Re-parsing one of those files alone rebuilds the node + * without the others' spans and outbound edges, or leaves a changed + * partner's stale span behind; so every file of such a node joins the + * closure, to a fixpoint. `paths` is the delta (changed, then deleted from + * index n_changed on); `dependents` already joined. A deleted partner is in + * the delta already; any other partner outside the discovery declines, as a + * dependent would. Returns the decline reason, or NULL. */ +static const char *closure_add_variant_partners(cbm_store_t *store, const char *project, + CBMHashTable *closure_set, + CBMHashTable *files_by_path, char **paths, + int path_count, int n_changed, char **dependents, + int dependent_count) { + int cap = path_count + dependent_count + CBM_SZ_64; + const char **pending = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)cap * sizeof(char *)); + if (!pending) { + return "alloc"; + } + int n = 0; + for (int i = 0; i < path_count; i++) { + pending[n++] = paths[i]; + } + for (int i = 0; i < dependent_count; i++) { + pending[n++] = dependents[i]; + } + const char *decline = NULL; + for (int round = 0; n > 0 && !decline; round++) { + char **partners = NULL; + int pc = 0; + if (round == CLOSURE_VARIANT_ROUNDS_MAX) { + decline = "variant_partner_rounds"; + } else if (cbm_store_get_variant_partner_files(store, project, pending, n, &partners, + &pc) != CBM_STORE_OK) { + decline = "variant_partner_query_failed"; + } + n = 0; + for (int i = 0; i < pc && !decline; i++) { + const cbm_file_info_t *fi = cbm_ht_get(files_by_path, partners[i]); + if (!fi) { + if (!closure_path_listed(paths + n_changed, path_count - n_changed, partners[i])) { + decline = "variant_partner_not_discovered"; + } + continue; + } + if (cbm_ht_get(closure_set, fi->rel_path)) { + continue; + } + cbm_ht_set(closure_set, fi->rel_path, (void *)fi->rel_path); + if (n == cap) { + cap *= PAIR_LEN; + const char **grown = + cbm_realloc(CBM_MEM_CLASS_OTHER, pending, (size_t)cap * sizeof(char *)); + if (!grown) { + decline = "alloc"; + break; + } + pending = grown; + } + pending[n++] = fi->rel_path; + } + cbm_store_free_dependent_files(partners, pc); + } + cbm_free(CBM_MEM_CLASS_OTHER, pending); + return decline; +} + /* Decide whether this manifest delta is closure-repairable, and if so build * the plan. Returns 1 with *plan filled, or 0 (decline → FULL) with the * reason logged. Every early exit declines; nothing here mutates state. */ @@ -2194,6 +2315,12 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p cbm_ht_set(closure_set, dependents[i], dependents[i]); } } + decline = + closure_add_variant_partners(store, project, closure_set, files_by_path, changed_paths, + n_changed + n_deleted, n_changed, dependents, dependent_count); + if (decline) { + goto done; + } /* closure_count == 0 is legitimate: a deleted-only delta with no * dependents has nothing to re-parse, but the purge itself still needs * the executor. */ @@ -2265,7 +2392,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char closure_plan_t *plan, cbm_file_info_t *changed_files, int ci, char **deleted, int deleted_count, cbm_file_hash_t *mode_skipped, int mode_skipped_count, cbm_coverage_row_t *old_cov, int old_cov_count, - cbm_store_t *route_store, struct timespec t0) { + cbm_store_t *route_store, const cbm_file_info_t *files, int file_count, + struct timespec t0) { struct timespec t; cbm_store_close(route_store); @@ -2335,7 +2463,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char if (!registry) { goto out; } - cbm_gbuf_foreach_node(gbuf, registry_visitor, registry); + registry_seed_from_gbuf(registry, gbuf, files, file_count); cbm_log_info("delta.preseed_done", "registry", itoa_buf(cbm_registry_size(registry)), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); @@ -2928,7 +3056,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil return run_closure_delta(p, db_path, project, baseline_manifest, baseline_count, &closure_plan, changed_files, ci, deleted, deleted_count, mode_skipped, mode_skipped_count, old_cov, old_cov_count, store, - t0); + files, file_count, t0); } struct timespec t; @@ -3035,7 +3163,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil /* Step 3-5: Registry + extract + resolve */ cbm_registry_t *registry = cbm_registry_new(); cbm_clock_gettime(CLOCK_MONOTONIC, &t); - cbm_gbuf_foreach_node(existing, registry_visitor, registry); + registry_seed_from_gbuf(registry, existing, files, file_count); cbm_log_info("incremental.registry_seed", "symbols", itoa_buf(cbm_registry_size(registry)), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index c7c992b68..440fe865d 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -17,6 +17,7 @@ #include "discover/test_conventions.h" #include "git/git_context.h" #include "foundation/hash_table.h" +#include "spawn_patterns.h" #include "cbm.h" #include "lsp/go_lsp.h" /* CBMLSPDef for cbm_parallel_resolve cross-LSP inputs */ #include @@ -572,6 +573,29 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje const char **imp_vals, int imp_count, const CBMCall *call, char *out, size_t out_sz); +/* A spawn site (pass_spawns.c): the API the call names and the program it + * starts (CBM_SPAWN_DYNAMIC when not a literal). */ +typedef struct { + const char *api; + char program[CBM_SPAWN_PROGRAM_MAX]; +} cbm_pipeline_spawn_t; + +/* True when `call` starts another program (spawn_patterns.h) and `res`, the + * registry answer after the cross-language veto, does not make it a call into + * the project's own code. `imports` are the file's extracted imports, not the + * resolver's import map: that one holds only imports that resolved to project + * nodes, and subprocess or os/exec never do. Both call resolvers ask at the + * same point and, on true, emit cbm_pipeline_emit_spawn INSTEAD of a CALLS + * edge. */ +bool cbm_pipeline_spawn_site(const cbm_gbuf_t *gbuf, CBMLanguage lang, const CBMCall *call, + const CBMImportArray *imports, const cbm_resolution_t *res, + cbm_pipeline_spawn_t *out); + +/* The Process node of the spawned program (QN __process__, one per + * program) and the SPAWNS edge from `source` to it. */ +void cbm_pipeline_emit_spawn(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, + const cbm_pipeline_spawn_t *spawn); + /* Resolve an import to its in-graph target node, or NULL if unresolvable. * * Resolution order (first hit wins): diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 9b5dc2af2..a970039e3 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -84,8 +84,14 @@ typedef struct { int cap; uint8_t *is_test; /* is_test_qn(items[i]), one byte per entry */ int is_test_cap; + /* Language of the file that defined items[i] (a CBMLanguage; CBM_LANG_COUNT + * = unknown, compatible with every caller), one byte per entry. Grown in + * lockstep with is_test, so is_test_cap bounds it too. */ + uint8_t *lang; } qn_array_t; +_Static_assert(CBM_LANG_COUNT <= UINT8_MAX, "CBMLanguage must fit the per-entry lang byte"); + struct cbm_registry { /* Interned label strings (<=~30 distinct labels; owned here, freed in * _free). The exact map's VALUES point into this pool instead of one @@ -724,6 +730,152 @@ static bool c_cpp_family(CBMLanguage lang) { return lang == CBM_LANG_C || lang == CBM_LANG_CPP; } +/* ── Cross-language resolution compatibility ───────────────────────── + * A name-based registry guess (qualified_suffix / unique_name / suffix_match) + * carries no import or type evidence, so its answer only stands when the + * caller's language can actually call the target by that name + * (cbm_registry_name_guess_vetoed). same_module joins them: module QNs drop + * the extension, so tests/x.c and tests/x.py share one module, and a call in + * the C file found the Python function by name inside it. Families are the interop + * groups the resolvers already honour, plus the well-known interop pairs: + * - JS family: JS/TS/TSX/ArkTS (the #725 and #1928 guards) and the script + * hosts whose calls ARE JS/TS (HTML/Vue/Svelte/Astro, the weak-member + * guard's embedded-script set; QML embeds JS and imports .js files). + * - C family: C/C++ (.h maps to C++, the #1928 guard), plus CUDA and ObjC, + * which call C/C++ declarations from shared headers directly. + * - JVM: Java/Kotlin/Scala/Groovy call each other's classes directly (the + * JVM cross-file LSP pairs Java with Kotlin). + * - .NET: C#/F#. PHP: PHP/Blade. Go: Go/templ (templ compiles to Go). + * Lua: Lua/Luau/Teal. BEAM: Erlang/Elixir/Gleam. Shell: Bash/Zsh. + * SQL: SQL/PL-SQL. Salesforce: Apex/SOQL/SOSL. HDL: Verilog/SystemVerilog. + * CFML: tag and script dialects. ObjectScript: UDL/routine/export. + * - Swift additionally calls the C family (C and ObjC headers import into + * Swift as native declarations), and Python calls ObjectScript (IRIS + * Embedded Python, iris.cls(...), #1260) and back. + * Every other language is its own family. An unknown language on either side + * (CBM_LANG_COUNT) is compatible with everything: without evidence, no filter. + * The languages are the DETECTED languages of the calling file and of the + * defining file (not a filename guess: .m is MATLAB by extension but ObjC by + * content), so full, parallel and incremental registries agree. */ +enum { + XL_FAM_NONE = 0, + XL_FAM_JS, + XL_FAM_C, + XL_FAM_JVM, + XL_FAM_DOTNET, + XL_FAM_PHP, + XL_FAM_GO, + XL_FAM_LUA, + XL_FAM_BEAM, + XL_FAM_SHELL, + XL_FAM_SQL, + XL_FAM_SALESFORCE, + XL_FAM_HDL, + XL_FAM_CFML, + XL_FAM_OBJECTSCRIPT, +}; + +static int xl_family(CBMLanguage lang) { + switch (lang) { + case CBM_LANG_JAVASCRIPT: + case CBM_LANG_TYPESCRIPT: + case CBM_LANG_TSX: + case CBM_LANG_ARKTS: + case CBM_LANG_HTML: + case CBM_LANG_VUE: + case CBM_LANG_SVELTE: + case CBM_LANG_ASTRO: + case CBM_LANG_QML: + return XL_FAM_JS; + case CBM_LANG_C: + case CBM_LANG_CPP: + case CBM_LANG_CUDA: + case CBM_LANG_OBJC: + return XL_FAM_C; + case CBM_LANG_JAVA: + case CBM_LANG_KOTLIN: + case CBM_LANG_SCALA: + case CBM_LANG_GROOVY: + return XL_FAM_JVM; + case CBM_LANG_CSHARP: + case CBM_LANG_FSHARP: + return XL_FAM_DOTNET; + case CBM_LANG_PHP: + case CBM_LANG_BLADE: + return XL_FAM_PHP; + case CBM_LANG_GO: + case CBM_LANG_TEMPL: + return XL_FAM_GO; + case CBM_LANG_LUA: + case CBM_LANG_LUAU: + case CBM_LANG_TEAL: + return XL_FAM_LUA; + case CBM_LANG_ERLANG: + case CBM_LANG_ELIXIR: + case CBM_LANG_GLEAM: + return XL_FAM_BEAM; + case CBM_LANG_BASH: + case CBM_LANG_ZSH: + return XL_FAM_SHELL; + case CBM_LANG_SQL: + case CBM_LANG_PLSQL: + return XL_FAM_SQL; + case CBM_LANG_APEX: + case CBM_LANG_SOQL: + case CBM_LANG_SOSL: + return XL_FAM_SALESFORCE; + case CBM_LANG_VERILOG: + case CBM_LANG_SYSTEMVERILOG: + return XL_FAM_HDL; + case CBM_LANG_CFML: + case CBM_LANG_CFSCRIPT: + return XL_FAM_CFML; + case CBM_LANG_OBJECTSCRIPT_UDL: + case CBM_LANG_OBJECTSCRIPT_ROUTINE: + case CBM_LANG_OBJECTSCRIPT_EXPORT: + return XL_FAM_OBJECTSCRIPT; + default: + return XL_FAM_NONE; + } +} + +bool cbm_lang_resolution_compatible(CBMLanguage caller, CBMLanguage target) { + if (caller < 0 || caller >= CBM_LANG_COUNT || target < 0 || target >= CBM_LANG_COUNT) { + return true; /* unknown on either side: no evidence to filter on */ + } + if (caller == target) { + return true; + } + int cf = xl_family(caller); + int tf = xl_family(target); + if (cf != XL_FAM_NONE && cf == tf) { + return true; + } + /* Swift imports C and ObjC headers as native declarations. IRIS Embedded + * Python and ObjectScript call each other's classes by name + * (iris.cls("Pkg.A").Run(), #1260). */ + return (caller == CBM_LANG_SWIFT && tf == XL_FAM_C) || + (target == CBM_LANG_SWIFT && cf == XL_FAM_C) || + (caller == CBM_LANG_PYTHON && tf == XL_FAM_OBJECTSCRIPT) || + (target == CBM_LANG_PYTHON && cf == XL_FAM_OBJECTSCRIPT); +} + +/* Synthetic definition sources minted by a language's own LSP layer + * (py_builtins.c, kotlin_builtins.c). They have no real file to detect, but + * their language is a fact of the injecting LSP. */ +CBMLanguage cbm_registry_synthetic_path_language(const char *file_path) { + if (!file_path) { + return CBM_LANG_COUNT; + } + if (strcmp(file_path, "") == 0) { + return CBM_LANG_PYTHON; + } + if (strcmp(file_path, "") == 0) { + return CBM_LANG_KOTLIN; + } + return CBM_LANG_COUNT; +} + static const char *path_basename(const char *path) { if (!path || !path[0]) { return path; @@ -918,6 +1070,7 @@ static void free_qn_array(const char *key, void *value, void *ud) { if (arr) { /* items borrow the exact map's keys — freed there, not here */ cbm_free(CBM_MEM_CLASS_DYN_ARRAY, arr->is_test); + cbm_free(CBM_MEM_CLASS_DYN_ARRAY, arr->lang); cbm_da_free(arr); free(arr); } @@ -946,7 +1099,8 @@ void cbm_registry_free(cbm_registry_t *r) { * * No array dedup needed: cbm_registry_add's exact-map check guarantees the QN * is new, and it calls this at most once per distinct key. */ -static void index_under_name(cbm_registry_t *r, const char *key, const char *owned_qn) { +static void index_under_name(cbm_registry_t *r, const char *key, const char *owned_qn, + CBMLanguage lang) { qn_array_t *arr = cbm_ht_get(r->by_name, key); if (!arr) { arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t)); @@ -963,16 +1117,31 @@ static void index_under_name(cbm_registry_t *r, const char *key, const char *own cbm_realloc(CBM_MEM_CLASS_DYN_ARRAY, arr->is_test, (size_t)want * sizeof(uint8_t)); if (grown) { arr->is_test = grown; - arr->is_test_cap = want; + uint8_t *grown_lang = + cbm_realloc(CBM_MEM_CLASS_DYN_ARRAY, arr->lang, (size_t)want * sizeof(uint8_t)); + /* Both side arrays share is_test_cap: on failure it stays below + * count, so this name serves neither cached test verdicts nor + * language filtering, exactly the unfiltered answer. */ + if (grown_lang) { + arr->lang = grown_lang; + arr->is_test_cap = want; + } } } if (arr->count <= arr->is_test_cap) { arr->is_test[arr->count - SKIP_ONE] = is_test_qn(owned_qn) ? 1 : 0; + arr->lang[arr->count - SKIP_ONE] = + (uint8_t)(lang >= 0 && lang < CBM_LANG_COUNT ? lang : CBM_LANG_COUNT); } } void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label) { + cbm_registry_add_lang(r, name, qualified_name, label, CBM_LANG_COUNT); +} + +void cbm_registry_add_lang(cbm_registry_t *r, const char *name, const char *qualified_name, + const char *label, CBMLanguage lang) { if (!r || !qualified_name || !label) { return; } @@ -1011,10 +1180,11 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified * The derived key is the one every language has always used and it stays * unconditional, so a grammar whose QNs carry no '#' is byte-identical to * before this commit. The second key exists for one shape: a fenced tail is - * a literal string no bare callee is ever written as. rust_cfg_qualified_name - * mints a `#[cfg(test)]` twin as "proj.lib.add#cfg(test)", simple_name() has - * no '#' handling, so the derived key filed that function under the whole - * string "add#cfg(test)" where no bare `add` callee could reach it. + * a literal string no bare callee is ever written as. A QN such as + * "proj.lib.add#cfg(test)" (Rust cfg twins were minted that way until they + * became variants of one definition) would otherwise be filed under the + * whole string "add#cfg(test)" — simple_name() has no '#' handling — where + * no bare `add` callee could reach it. * * Gating on the fence rather than on the file's language is what makes * "unchanged for every other grammar" checkable instead of asserted. Every @@ -1034,9 +1204,9 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified const char *derived = simple_name(qualified_name); const char *primary = name && cbm_qn_callable_base_len_named(owned_qn, name) < strlen(owned_qn) ? name : derived; - index_under_name(r, primary, owned_qn); - /* '#' is a QN fence, and extract_defs.c's rust_cfg_qualified_name is the - * only thing in the tree that mints one today. A grammar that starts + index_under_name(r, primary, owned_qn, lang); + /* '#' is a QN fence; no extractor mints one for a definition today (Rust + * cfg twins are variants of one plain QN). A grammar that starts * minting a '#' opts into this second key by doing so, whatever it means by * the fence: its symbols become reachable under the passed name as well, * and they share that name's bucket with everything else filed under it. @@ -1044,7 +1214,7 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified * signature-qualified callable is filed under `name` already, and its * capped suffix may carry a '#' of its own. */ if (name && name[0] && strchr(derived, '#') && strcmp(name, primary) != 0) { - index_under_name(r, name, owned_qn); + index_under_name(r, name, owned_qn, lang); } } @@ -1505,6 +1675,49 @@ static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const ch return res; } +/* The recorded language of the registry entry `qn` (the owned key pointer a + * resolution returns), or CBM_LANG_COUNT when unknown. The by-name bucket is + * at most REG_MAX_CANDIDATES long whenever a name strategy produced `qn`. */ +static CBMLanguage registry_entry_language(const cbm_registry_t *r, const char *qn) { + const qn_array_t *arr = cbm_ht_get(r->by_name, simple_name(qn)); + if (!arr || !arr->lang || arr->is_test_cap < arr->count) { + return CBM_LANG_COUNT; + } + for (int i = 0; i < arr->count; i++) { + if (arr->items[i] == qn || strcmp(arr->items[i], qn) == 0) { + return (CBMLanguage)arr->lang[i]; + } + } + return CBM_LANG_COUNT; +} + +/* The cross-language veto (see cbm_lang_resolution_compatible). A name-only + * answer carries no import or module evidence, so it only stands when the + * caller's language can call the target: a JS `app.get` must not bind + * Python's builtins.dict.get because it is the one "get" in the project. + * Callers turn a vetoed answer into an EMPTY resolution BEFORE their empty- + * resolution fallbacks, so route registration / HTTP classification still + * run. A veto, not a re-pick among the remaining candidates: re-picking + * promotes the next same-language short-name guess (measured on n8n: +3.1k + * CALLS, 88% of them jest `describe()` bound to a project method). + * field_type_hint is included: it re-picks by type-name substring across ALL + * candidates, language-blind. */ +bool cbm_registry_name_guess_vetoed(const cbm_registry_t *r, CBMLanguage caller_lang, + const cbm_resolution_t *res) { + if (!r || !res || caller_lang < 0 || caller_lang >= CBM_LANG_COUNT || !res->qualified_name || + !res->qualified_name[0] || !res->strategy) { + return false; + } + if (strcmp(res->strategy, "unique_name") != 0 && strcmp(res->strategy, "suffix_match") != 0 && + strcmp(res->strategy, "qualified_suffix") != 0 && + strcmp(res->strategy, "field_type_hint") != 0 && + strcmp(res->strategy, "same_module") != 0) { + return false; + } + return !cbm_lang_resolution_compatible(caller_lang, + registry_entry_language(r, res->qualified_name)); +} + cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count) { diff --git a/src/store/store.c b/src/store/store.c index e82c3cd4d..375834a25 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -4721,6 +4721,42 @@ static char *store_like_dot_suffix(const char *text, const char *tail) { return pat; } +int cbm_store_find_variant_spans_by_file(cbm_store_t *s, const char *project, const char *file_path, + cbm_node_t **out, int *count) { + *out = NULL; + *count = 0; + if (!s || !s->db || !project || !file_path) { + return CBM_STORE_ERR; + } + /* The file's own nodes with variants (idx_nodes_file), and the nodes of + * other files it writes a variant of (its File/Module DEFINES edges); + * then each one's variant spans that lie in this file. */ + const char *sql = + "SELECT n.id, n.project, n.label, n.name, n.qualified_name, n.file_path," + " CAST(json_extract(v.value, '$.start_line') AS INTEGER)," + " CAST(json_extract(v.value, '$.end_line') AS INTEGER), n.properties" + " FROM nodes n, json_each(n.properties, '$.variants') v" + " WHERE n.id IN (" + " SELECT id FROM nodes WHERE project = ?1 AND file_path = ?2" + " AND json_array_length(properties, '$.variants') > 1" + " UNION SELECT tgt.id FROM nodes src" + " CROSS JOIN edges e ON e.source_id = src.id" + " CROSS JOIN nodes tgt ON tgt.id = e.target_id" + " WHERE src.project = ?1 AND src.file_path = ?2 AND src.label IN ('File','Module')" + " AND e.type = 'DEFINES' AND tgt.file_path <> ?2" + " AND json_array_length(tgt.properties, '$.variants') > 1)" + " AND json_extract(v.value, '$.file_path') = ?2" + " ORDER BY n.id, 7, 8"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "variant spans prepare"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + bind_text(stmt, ST_COL_2, file_path); + return store_collect_nodes(s, stmt, NULL, NULL, out, count); +} + int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const char *suffix, cbm_node_t **out, int *count) { *out = NULL; @@ -5349,43 +5385,40 @@ void cbm_store_free_lsp_surfaces(cbm_lsp_surface_row_t *rows, int count) { free(rows); } -/* One chunk of the dependent-file query. Binding an IN list needs one - * placeholder per element, so the SQL is assembled per chunk; the chunk cap +/* One chunk of a file-path query over a list of files. Binding an IN list + * needs one placeholder per element, so the SQL is assembled per chunk from + * `parts`, with the placeholder list ?2..?n+1 between consecutive parts (a + * query may name the list more than once); ?1 is the project. The chunk cap * keeps it bounded. Dedup across chunks happens in the caller's hash set. */ enum { DEPFILE_CHUNK = 200 }; -static int dependent_files_chunk(cbm_store_t *s, const char *project, - const char *const *target_files, int chunk_count, - CBMHashTable *seen, char ***out, int *out_count, int *out_cap) { - char sql[CBM_SZ_4K]; - int pos = snprintf(sql, sizeof(sql), - /* CROSS JOIN pins nodes-first (see the delta snapshot query: - * the free planner walks every project edge instead). */ - "SELECT DISTINCT src.file_path FROM nodes tgt" - " CROSS JOIN edges e ON e.target_id = tgt.id" - " CROSS JOIN nodes src ON e.source_id = src.id" - " WHERE tgt.project = ?1 AND tgt.file_path IN ("); - for (int i = 0; i < chunk_count; i++) { - pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, "%s?%d", i ? "," : "", i + ST_COL_2); +static int file_query_chunk(cbm_store_t *s, const char *const *parts, int nparts, const char *what, + const char *project, const char *const *target_files, int chunk_count, + CBMHashTable *seen, char ***out, int *out_count, int *out_cap) { + char sql[CBM_SZ_8K]; + sql[0] = '\0'; + if (nparts < 1) { + return CBM_STORE_ERR; + } + int pos = 0; + for (int part = 0; part < nparts; part++) { + if (part > 0) { + for (int i = 0; i < chunk_count; i++) { + pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, "%s?%d", i ? "," : "", + i + ST_COL_2); + if ((size_t)pos >= sizeof(sql)) { + return CBM_STORE_ERR; + } + } + } + pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, "%s", parts[part]); if ((size_t)pos >= sizeof(sql)) { return CBM_STORE_ERR; } } - /* Structural containment is not resolution: a Folder/Project container - * (whose file_path is a properties placeholder, not a real file) or a - * CONTAINS_* edge says nothing about who consumed the target's - * definitions, and the closure planner would otherwise decline on a - * "dependent" no discovery can ever produce. */ - pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, - ") AND src.file_path <> '' AND src.file_path IS NOT NULL" - " AND src.label NOT IN ('Folder','Project')" - " AND e.type NOT IN ('CONTAINS_FILE','CONTAINS_FOLDER')"); - if ((size_t)pos >= sizeof(sql)) { - return CBM_STORE_ERR; - } sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "dependent_files prepare"); + store_set_error_sqlite(s, what); return CBM_STORE_ERR; } bind_text(stmt, ST_COL_1, project); @@ -5420,16 +5453,18 @@ static int dependent_files_chunk(cbm_store_t *s, const char *project, return step_rc == SQLITE_DONE ? CBM_STORE_OK : CBM_STORE_ERR; } -int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, - const char *const *target_files, int target_count, char ***out, - int *out_count) { +/* Run `parts` over every chunk of target_files; the targets themselves are + * excluded from the result. */ +static int file_query(cbm_store_t *s, const char *const *parts, int nparts, const char *what, + const char *project, const char *const *target_files, int target_count, + char ***out, int *out_count) { *out = NULL; *out_count = 0; if (!s || !project || target_count <= 0) { return target_count == 0 ? CBM_STORE_OK : CBM_STORE_ERR; } /* Exclude the targets themselves up front: an edge between two files of - * the closure adds nothing, and the caller wants "who ELSE consumed". */ + * the closure adds nothing, and the caller wants "who ELSE". */ CBMHashTable *seen = cbm_ht_create((size_t)target_count * PAIR_LEN); if (!seen) { return CBM_STORE_ERR; @@ -5446,8 +5481,8 @@ int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, if (chunk > DEPFILE_CHUNK) { chunk = DEPFILE_CHUNK; } - rc = dependent_files_chunk(s, project, target_files + off, chunk, seen, &files, &count, - &cap); + rc = file_query_chunk(s, parts, nparts, what, project, target_files + off, chunk, seen, + &files, &count, &cap); } cbm_ht_free(seen); if (rc != CBM_STORE_OK) { @@ -5459,6 +5494,54 @@ int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, return CBM_STORE_OK; } +int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, + const char *const *target_files, int target_count, char ***out, + int *out_count) { + static const char *const parts[] = { + /* CROSS JOIN pins nodes-first (see the delta snapshot query: the + * free planner walks every project edge instead). */ + "SELECT DISTINCT src.file_path FROM nodes tgt" + " CROSS JOIN edges e ON e.target_id = tgt.id" + " CROSS JOIN nodes src ON e.source_id = src.id" + " WHERE tgt.project = ?1 AND tgt.file_path IN (", + /* Structural containment is not resolution: a Folder/Project + * container (whose file_path is a properties placeholder, not a real + * file) or a CONTAINS_* edge says nothing about who consumed the + * target's definitions, and the closure planner would otherwise + * decline on a "dependent" no discovery can ever produce. */ + ") AND src.file_path <> '' AND src.file_path IS NOT NULL" + " AND src.label NOT IN ('Folder','Project')" + " AND e.type NOT IN ('CONTAINS_FILE','CONTAINS_FOLDER')"}; + return file_query(s, parts, 2, "dependent_files prepare", project, target_files, target_count, + out, out_count); +} + +int cbm_store_get_variant_partner_files(cbm_store_t *s, const char *project, + const char *const *files, int count, char ***out, + int *out_count) { + static const char *const parts[] = { + /* A file that writes a variant of a node whose file_path is + * another file. */ + "SELECT tgt.file_path FROM nodes src" + " CROSS JOIN edges e ON e.source_id = src.id" + " CROSS JOIN nodes tgt ON e.target_id = tgt.id" + " WHERE src.project = ?1 AND src.label IN ('File','Module') AND e.type = 'DEFINES'" + " AND tgt.file_path <> src.file_path AND tgt.file_path <> ''" + " AND json_array_length(tgt.properties, '$.variants') > 1" + " AND src.file_path IN (", + /* The other files that write a variant of a node in the file. */ + ") UNION SELECT src.file_path FROM nodes tgt" + " CROSS JOIN edges e ON e.target_id = tgt.id" + " CROSS JOIN nodes src ON e.source_id = src.id" + " WHERE tgt.project = ?1 AND e.type = 'DEFINES' AND src.label IN ('File','Module')" + " AND src.file_path <> tgt.file_path AND src.file_path <> ''" + " AND json_array_length(tgt.properties, '$.variants') > 1" + " AND tgt.file_path IN (", + ")"}; + return file_query(s, parts, 3, "variant_partner_files prepare", project, files, count, out, + out_count); +} + void cbm_store_free_dependent_files(char **files, int count) { if (!files) { return; diff --git a/src/store/store.h b/src/store/store.h index 7f98baeef..fd690b5da 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -99,6 +99,14 @@ typedef struct { const char *config_ctx; /* governing-config context hash ("" = none) */ } cbm_lsp_surface_row_t; +/* The definition variants that lie in a file (graph_buffer.c "Definition + * variants"): one row per variant span in `file_path`, as the node it + * belongs to with start_line/end_line set to that span. Covers the file's + * own nodes (their other #if branches) and nodes whose file_path names + * another file that the file writes a variant of. */ +int cbm_store_find_variant_spans_by_file(cbm_store_t *s, const char *project, const char *file_path, + cbm_node_t **out, int *count); + /* Find nodes overlapping a line range in a file (excludes Module/Package). */ int cbm_store_find_nodes_by_file_overlap(cbm_store_t *s, const char *project, const char *file_path, int start_line, int end_line, cbm_node_t **out, @@ -170,6 +178,16 @@ int cbm_store_get_dependent_files(cbm_store_t *s, const char *project, int *out_count); void cbm_store_free_dependent_files(char **files, int count); +/* Variant partners for closure-repair routing: the files that write another + * variant of a definition written in any of `files` (one node for a + * definition's platform files, header declaration and source definition, Go + * build-tagged files; a DEFINES edge from each file's File node). Excludes + * `files` themselves; served by idx_nodes_file + the edge source/target + * indexes. Free with cbm_store_free_dependent_files. */ +int cbm_store_get_variant_partner_files(cbm_store_t *s, const char *project, + const char *const *files, int count, char ***out, + int *out_count); + /* Find edges whose properties contain a url_path matching the keyword. */ int cbm_store_find_edges_by_url_path(cbm_store_t *s, const char *project, const char *keyword, cbm_edge_t **out, int *count); diff --git a/tests/test_conditional_variants.c b/tests/test_conditional_variants.c new file mode 100644 index 000000000..6c05c373c --- /dev/null +++ b/tests/test_conditional_variants.c @@ -0,0 +1,635 @@ +/* + * test_conditional_variants.c — one definition with mutually exclusive + * variants is ONE identity in the graph, in every language. + * + * A variant is a second definition of the same name that only one build or + * run ever sees: C-family #if/#else, C#/F# #if, Rust #[cfg], Swift #if, + * Go build-tagged files, D version blocks, Erlang -ifdef, Haskell CPP, + * Fortran .F90 preprocessing, Verilog `ifdef, Elixir/Python/Ruby/PHP + * definitions under an `if`, and C platform files (foo_win.c / foo_posix.c). + * + * Every fixture defines `pick` twice — variant A calls `alpha`, variant B + * calls `beta` — plus a `caller` that calls `pick`. The graph must hold: + * - exactly one node for `pick` (no per-variant twins, no condition text + * in its qualified name); + * - CALLS from that node to alpha AND beta (every variant's body counts); + * - a CALLS edge from caller to it; + * - its `variants` property listing every variant's span, so a change in + * any variant maps to the definition. + * Fixtures avoid each language's unrelated extraction gaps (Ruby and Pascal + * calls carry parentheses; Haskell calls are applications). + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +typedef struct { + const char *name; + const char *content; +} cv_file_t; + +typedef struct { + char tmpdir[256]; + char dbpath[1024]; + char *project; + cbm_mcp_server_t *srv; + cbm_store_t *store; +} cv_proj_t; + +static bool cv_write(const char *root, const cv_file_t *file) { + char path[700]; + snprintf(path, sizeof(path), "%s/%s", root, file->name); + char *slash = strrchr(path, '/'); + if (slash && slash > path + strlen(root)) { + *slash = '\0'; + cbm_mkdir_p(path, 0755); + *slash = '/'; + } + FILE *f = cbm_fopen(path, "wb"); + if (!f) { + return false; + } + bool ok = fputs(file->content, f) >= 0; + return fclose(f) == 0 && ok; +} + +static bool cv_index(cv_proj_t *p, const cv_file_t *files, int count) { + memset(p, 0, sizeof(*p)); + snprintf(p->tmpdir, sizeof(p->tmpdir), "%s", th_mktempdir("cbm-cv")); + if (!p->tmpdir[0]) { + return false; + } + for (int i = 0; i < count; i++) { + if (!cv_write(p->tmpdir, &files[i])) { + return false; + } + } + p->project = cbm_project_name_from_path(p->tmpdir); + if (!p->project) { + return false; + } + /* Where the MCP server writes the project DB: CBM_CACHE_DIR, else the + * per-user cache (same resolution as test_lang_contract.c). */ + char cache[700]; + const char *configured = getenv("CBM_CACHE_DIR"); + const char *home = getenv("HOME"); + if (configured && configured[0]) { + snprintf(cache, sizeof(cache), "%s", configured); + } else { + snprintf(cache, sizeof(cache), "%s/.cache/codebase-memory-mcp", home ? home : "/tmp"); + } + cbm_mkdir_p(cache, 0755); + snprintf(p->dbpath, sizeof(p->dbpath), "%s/%s.db", cache, p->project); + unlink(p->dbpath); + p->srv = cbm_mcp_server_new(NULL); + if (!p->srv) { + return false; + } + char args[800]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", p->tmpdir); + free(cbm_mcp_handle_tool(p->srv, "index_repository", args)); + p->store = cbm_store_open_path(p->dbpath); + return p->store != NULL; +} + +static void cv_cleanup(cv_proj_t *p) { + if (p->store) { + cbm_store_close(p->store); + } + if (p->srv) { + cbm_mcp_server_free(p->srv); + } + if (p->dbpath[0]) { + char side[1100]; + unlink(p->dbpath); + snprintf(side, sizeof(side), "%s-wal", p->dbpath); + unlink(side); + snprintf(side, sizeof(side), "%s-shm", p->dbpath); + unlink(side); + } + free(p->project); + if (p->tmpdir[0]) { + th_rmtree(p->tmpdir); + } + memset(p, 0, sizeof(*p)); +} + +/* One integer from SQL with text parameters; -1 on any query failure. */ +static int cv_count(cv_proj_t *p, const char *sql, const char *a, const char *b) { + sqlite3 *db = cbm_store_get_db(p->store); + sqlite3_stmt *stmt = NULL; + if (!db || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + sqlite3_finalize(stmt); + return -1; + } + int n = sqlite3_bind_parameter_count(stmt); + const char *values[] = {p->project, a, b}; + for (int i = 0; i < n && i < 3; i++) { + sqlite3_bind_text(stmt, i + 1, values[i], -1, SQLITE_TRANSIENT); + } + int count = sqlite3_step(stmt) == SQLITE_ROW ? sqlite3_column_int(stmt, 0) : -1; + sqlite3_finalize(stmt); + return count; +} + +#define CV_DEF_LABELS "('Function','Method','Module','Class')" + +typedef struct { + int nodes; /* distinct `pick` definitions */ + int clean_names; /* of those, qualified names without condition text */ + int to_alpha; /* CALLS pick -> alpha */ + int to_beta; /* CALLS pick -> beta */ + int from_caller; /* CALLS caller -> pick */ + int variant_spans; /* entries of pick's `variants` property */ +} cv_result_t; + +static cv_result_t cv_measure(cv_proj_t *p, const char *pick, const char *alpha, const char *beta, + const char *caller) { + cv_result_t r; + r.nodes = cv_count(p, + "SELECT COUNT(DISTINCT qualified_name) FROM nodes WHERE project=?1 " + "AND name=?2 AND label IN " CV_DEF_LABELS, + pick, NULL); + r.clean_names = cv_count( + p, + "SELECT COUNT(*) FROM nodes WHERE project=?1 AND name=?2 AND label IN " CV_DEF_LABELS + " AND qualified_name NOT GLOB '*[#[]()]*'", + pick, NULL); + const char *calls = "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id " + "JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='CALLS' " + "AND s.name=?2 AND t.name=?3"; + r.to_alpha = cv_count(p, calls, pick, alpha); + r.to_beta = cv_count(p, calls, pick, beta); + r.from_caller = cv_count(p, calls, caller, pick); + r.variant_spans = + cv_count(p, + "SELECT COALESCE(MAX(json_array_length(properties,'$.variants')),0) " + "FROM nodes WHERE project=?1 AND name=?2 AND label IN " CV_DEF_LABELS, + pick, NULL); + return r; +} + +static int cv_expect(const char *lang, cv_result_t r) { + bool ok = r.nodes == 1 && r.clean_names == 1 && r.to_alpha >= 1 && r.to_beta >= 1 && + r.from_caller >= 1 && r.variant_spans >= 2; + if (!ok) { + fprintf( + stderr, " [%s] nodes=%d clean=%d ->alpha=%d ->beta=%d caller->=%d variant_spans=%d\n", + lang, r.nodes, r.clean_names, r.to_alpha, r.to_beta, r.from_caller, r.variant_spans); + } + return ok; +} + +static int cv_run(const char *lang, const cv_file_t *files, int count, const char *pick, + const char *alpha, const char *beta, const char *caller) { + cv_proj_t p; + bool indexed = cv_index(&p, files, count); + int ok = indexed && cv_expect(lang, cv_measure(&p, pick, alpha, beta, caller)); + if (!indexed) { + fprintf(stderr, " [%s] fixture could not be indexed\n", lang); + } + cv_cleanup(&p); + return ok; +} + +#define CV_ONE(lang, path, src) \ + do { \ + static const cv_file_t files_[] = {{path, src}}; \ + ASSERT_TRUE(cv_run(lang, files_, 1, "pick", "alpha", "beta", "caller")); \ + } while (0) + +TEST(cv_c_preprocessor) { + CV_ONE("c", "pick.c", + "void alpha(void) {}\nvoid beta(void) {}\n#ifdef _WIN32\nvoid pick(void) { alpha(); }\n" + "#else\nvoid pick(void) { beta(); }\n#endif\nvoid caller(void) { pick(); }\n"); + PASS(); +} + +TEST(cv_cpp_elif) { + CV_ONE( + "cpp", "pick.cpp", + "void alpha() {}\nvoid beta() {}\n#if defined(_WIN32)\nvoid pick() { alpha(); }\n" + "#elif defined(__linux__)\nvoid pick() { beta(); }\n#endif\nvoid caller() { pick(); }\n"); + PASS(); +} + +TEST(cv_cpp_class_members) { + CV_ONE("cpp_member", "pick.cpp", + "struct P {\n void alpha() {}\n void beta() {}\n#ifdef _WIN32\n void pick() { " + "alpha(); }\n" + "#else\n void pick() { beta(); }\n#endif\n void caller() { pick(); }\n};\n"); + PASS(); +} + +TEST(cv_objc_methods) { + CV_ONE("objc", "P.m", + "@implementation P\n- (void)alpha {}\n- (void)beta {}\n#if TARGET_OS_IPHONE\n" + "- (void)pick { alpha(); }\n#else\n- (void)pick { beta(); }\n#endif\n" + "- (void)caller { pick(); }\n@end\nvoid alpha(void) {}\nvoid beta(void) {}\n" + "void pick(void);\n"); + PASS(); +} + +TEST(cv_cuda) { + CV_ONE( + "cuda", "pick.cu", + "__device__ void alpha() {}\n__device__ void beta() {}\n#ifdef USE_FAST\n" + "__device__ void pick() { alpha(); }\n#else\n__device__ void pick() { beta(); }\n#endif\n" + "__device__ void caller() { pick(); }\n"); + PASS(); +} + +TEST(cv_csharp_class_members) { + static const cv_file_t files[] = { + {"Picker.cs", "class Picker {\n static void Alpha() {}\n static void Beta() {}\n" + "#if WINDOWS\n static void Pick() { Alpha(); }\n#else\n" + " static void Pick() { Beta(); }\n#endif\n" + " static void Caller() { Pick(); }\n}\n"}}; + ASSERT_TRUE(cv_run("csharp", files, 1, "Pick", "Alpha", "Beta", "Caller")); + PASS(); +} + +TEST(cv_fsharp) { + CV_ONE("fsharp", "Pick.fs", + "module Pick\nlet alpha () = ()\nlet beta () = ()\n#if WINDOWS\nlet pick () = alpha ()\n" + "#else\nlet pick () = beta ()\n#endif\nlet caller () = pick ()\n"); + PASS(); +} + +TEST(cv_rust_cfg_free_functions) { + static const cv_file_t files[] = { + {"Cargo.toml", "[package]\nname = \"cv\"\nversion = \"0.1.0\"\n"}, + {"src/lib.rs", "fn alpha() {}\nfn beta() {}\n#[cfg(windows)]\nfn pick() { alpha(); }\n" + "#[cfg(not(windows))]\nfn pick() { beta(); }\nfn caller() { pick(); }\n"}}; + ASSERT_TRUE(cv_run("rust", files, 2, "pick", "alpha", "beta", "caller")); + PASS(); +} + +TEST(cv_rust_cfg_methods) { + static const cv_file_t files[] = { + {"Cargo.toml", "[package]\nname = \"cv\"\nversion = \"0.1.0\"\n"}, + {"src/lib.rs", + "struct P;\nfn alpha() {}\nfn beta() {}\nimpl P {\n #[cfg(target_os = \"linux\")]\n" + " fn pick(&self) { alpha(); }\n #[cfg(not(target_os = \"linux\"))]\n" + " fn pick(&self) { beta(); }\n fn caller(&self) { self.pick(); }\n}\n"}}; + ASSERT_TRUE(cv_run("rust_impl", files, 2, "pick", "alpha", "beta", "caller")); + PASS(); +} + +TEST(cv_go_build_tag_files) { + static const cv_file_t files[] = { + {"go.mod", "module example.com/cv\n\ngo 1.22\n"}, + {"pick/pick_windows.go", "//go:build windows\npackage pick\n\nfunc Pick() { alpha() }\n"}, + {"pick/pick_posix.go", "//go:build !windows\npackage pick\n\nfunc Pick() { beta() }\n"}, + {"pick/common.go", + "package pick\n\nfunc alpha() {}\nfunc beta() {}\nfunc Caller() { Pick() }\n"}}; + ASSERT_TRUE(cv_run("go", files, 4, "Pick", "alpha", "beta", "Caller")); + PASS(); +} + +TEST(cv_swift) { + CV_ONE("swift", "pick.swift", + "func alpha() {}\nfunc beta() {}\n#if os(Windows)\nfunc pick() { alpha() }\n#else\n" + "func pick() { beta() }\n#endif\nfunc caller() { pick() }\n"); + PASS(); +} + +TEST(cv_python_if) { + CV_ONE("python", "pick.py", + "import sys\ndef alpha(): pass\ndef beta(): pass\nif sys.platform == \"win32\":\n" + " def pick():\n alpha()\nelse:\n def pick():\n beta()\n" + "def caller():\n pick()\n"); + PASS(); +} + +TEST(cv_ruby_if) { + CV_ONE( + "ruby", "pick.rb", + "def alpha(); end\ndef beta(); end\nif RUBY_PLATFORM =~ /mswin/\n def pick\n alpha()\n" + " end\nelse\n def pick\n beta()\n end\nend\ndef caller\n pick()\nend\n"); + PASS(); +} + +TEST(cv_php_if) { + CV_ONE("php", "pick.php", + " ok.\nbeta() -> ok.\n-ifdef(WINDOWS).\n" + "pick() -> alpha().\n-else.\npick() -> beta().\n-endif.\ncaller() -> pick().\n"); + PASS(); +} + +TEST(cv_haskell_cpp) { + CV_ONE("haskell", "Pick.hs", + "{-# LANGUAGE CPP #-}\nmodule Pick where\nalpha :: Int -> Int\nalpha x = x\n" + "beta :: Int -> Int\nbeta x = x\n#if defined(mingw32_HOST_OS)\npick :: Int -> Int\n" + "pick x = alpha x\n#else\npick :: Int -> Int\npick x = beta x\n#endif\n" + "caller :: Int -> Int\ncaller x = pick x\n"); + PASS(); +} + +/* The first branch calls neither target: alpha is reached only through the + * #elif branch, beta only through the nested group's # else inside #else. */ +TEST(cv_haskell_cpp_elif_nested) { + CV_ONE("haskell", "Pick.hs", + "{-# LANGUAGE CPP #-}\nmodule Pick where\nalpha :: Int -> Int\nalpha x = x\n" + "beta :: Int -> Int\nbeta x = x\n#if defined(mingw32_HOST_OS)\npick :: Int -> Int\n" + "pick x = x\n#elif defined(darwin_HOST_OS)\npick :: Int -> Int\npick x = alpha x\n" + "#else\n# if WORD_SIZE_IN_BITS == 64\npick :: Int -> Int\npick x = x\n# else\n" + "pick :: Int -> Int\npick x = beta x\n# endif\n#endif\n" + "caller :: Int -> Int\ncaller x = pick x\n"); + PASS(); +} + +TEST(cv_fortran_preprocessed) { + CV_ONE("fortran", "pick.F90", + "module pickmod\ncontains\n subroutine alpha()\n end subroutine alpha\n" + " subroutine beta()\n end subroutine beta\n#ifdef WINDOWS\n subroutine pick()\n" + " call alpha()\n end subroutine pick\n#else\n subroutine pick()\n call beta()\n" + " end subroutine pick\n#endif\n subroutine caller()\n call pick()\n" + " end subroutine caller\nend module pickmod\n"); + PASS(); +} + +TEST(cv_pascal_ifdef) { + CV_ONE("pascal", "pick.pas", + "unit pick;\ninterface\nprocedure caller;\nimplementation\nprocedure alpha; begin end;\n" + "procedure beta; begin end;\n{$IFDEF WINDOWS}\nprocedure pick; begin alpha(); end;\n" + "{$ELSE}\nprocedure pick; begin beta(); end;\n{$ENDIF}\n" + "procedure caller; begin pick(); end;\nend.\n"); + PASS(); +} + +TEST(cv_julia_static_if) { + CV_ONE("julia", "pick.jl", + "alpha() = nothing\nbeta() = nothing\n@static if Sys.iswindows()\n pick() = alpha()\n" + "else\n pick() = beta()\nend\ncaller() = pick()\n"); + PASS(); +} + +TEST(cv_elixir_compile_time_if) { + CV_ONE("elixir", "pick.ex", + "defmodule Pick do\n def alpha, do: :ok\n def beta, do: :ok\n" + " if Code.ensure_loaded?(:win32) do\n def pick, do: alpha()\n else\n" + " def pick, do: beta()\n end\n def caller, do: pick()\nend\n"); + PASS(); +} + +/* Verilog: the identity only. Module instantiation produces no CALLS edge in + * this graph even without any `ifdef (a general Verilog gap, not a variant + * one), so the body and caller edges are not this suite's to assert. */ +TEST(cv_verilog_ifdef) { + static const cv_file_t files[] = { + {"pick.sv", "`ifdef FAST\nmodule pick(input a); alpha u(); endmodule\n`else\n" + "module pick(input a); beta u(); endmodule\n`endif\nmodule alpha(); endmodule\n" + "module beta(); endmodule\nmodule caller(); pick p(.a(1'b0)); endmodule\n"}}; + cv_proj_t p; + ASSERT_TRUE(cv_index(&p, files, 1)); + cv_result_t r = cv_measure(&p, "pick", "alpha", "beta", "caller"); + cv_cleanup(&p); + ASSERT_EQ(r.nodes, 1); + ASSERT_EQ(r.clean_names, 1); + ASSERT_GTE(r.variant_spans, 2); + PASS(); +} + +/* C platform files: the same function in foo_win.c and foo_posix.c is one + * definition with two variants, and a caller in a shared file binds to it. */ +TEST(cv_c_platform_files) { + static const cv_file_t files[] = { + {"src/plat_win.c", "void alpha(void);\nvoid pick(void) { alpha(); }\n"}, + {"src/plat_posix.c", "void beta(void);\nvoid pick(void) { beta(); }\n"}, + {"src/plat.c", "void alpha(void) {}\nvoid beta(void) {}\nvoid pick(void);\n" + "void caller(void) { pick(); }\n"}}; + ASSERT_TRUE(cv_run("c_platform_files", files, 3, "pick", "alpha", "beta", "caller")); + PASS(); +} + +/* Platform directories (libuv's src/unix/ and src/win/) are platform variants + * the same way platform suffixes are. */ +TEST(cv_c_platform_directories) { + static const cv_file_t files[] = { + {"src/unix/fs.c", "void alpha(void);\nvoid pick(void) { alpha(); }\n"}, + {"src/win/fs.c", "void beta(void);\nvoid pick(void) { beta(); }\n"}, + {"src/core.c", "void alpha(void) {}\nvoid beta(void) {}\nvoid pick(void);\n" + "void caller(void) { pick(); }\n"}}; + ASSERT_TRUE(cv_run("c_platform_dirs", files, 3, "pick", "alpha", "beta", "caller")); + PASS(); +} + +/* Not variants: static functions are file-local even in platform files, and + * same-named external functions in files without a platform token may belong + * to different programs. Each keeps its own node. */ +TEST(cv_c_non_variants_keep_their_nodes) { + static const cv_file_t files[] = {{"src/plat_win.c", "static void pick(void) {}\n"}, + {"src/plat_posix.c", "static void pick(void) {}\n"}, + {"tools/one.c", "void helper(void) {}\n"}, + {"tools/two.c", "void helper(void) {}\n"}}; + cv_proj_t p; + ASSERT_TRUE(cv_index(&p, files, 4)); + int picks = cv_count(&p, + "SELECT COUNT(DISTINCT qualified_name) FROM nodes WHERE project=?1 " + "AND name='pick' AND label='Function'", + NULL, NULL); + int helpers = cv_count(&p, + "SELECT COUNT(DISTINCT qualified_name) FROM nodes WHERE project=?1 " + "AND name='helper' AND label='Function'", + NULL, NULL); + cv_cleanup(&p); + ASSERT_EQ(picks, 2); + ASSERT_EQ(helpers, 2); + PASS(); +} + +/* A call that names a function no definition of its own language provides + * must not bind to a same-named definition in another language. */ +TEST(cv_no_cross_language_name_binding) { + static const cv_file_t files[] = { + {"go.mod", "module example.com/cv\n\ngo 1.22\n"}, + {"pick/pick.go", "package pick\n\nfunc Pick() {}\n"}, + {"cs/Caller.cs", "class C {\n static void Caller() { Pick(); }\n}\n"}}; + cv_proj_t p; + ASSERT_TRUE(cv_index(&p, files, 3)); + /* Positive controls: both definitions are in the graph, so a missing + * edge below is the rule at work, not an empty index. */ + int go_pick = cv_count(&p, + "SELECT COUNT(*) FROM nodes WHERE project=?1 AND name='Pick' " + "AND file_path LIKE '%.go'", + NULL, NULL); + int cs_caller = cv_count(&p, + "SELECT COUNT(*) FROM nodes WHERE project=?1 AND name='Caller' " + "AND file_path LIKE '%.cs'", + NULL, NULL); + int crossing = + cv_count(&p, + "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id " + "JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='CALLS' " + "AND s.name='Caller' AND t.file_path LIKE '%.go'", + NULL, NULL); + cv_cleanup(&p); + ASSERT_EQ(go_pick, 1); + ASSERT_EQ(cs_caller, 1); + ASSERT_EQ(crossing, 0); + PASS(); +} + +/* ── Incremental: a definition written in several files ─────────────── + * Closure repair re-parses only the changed files and their recorded + * dependents. `pick` written in two platform files is ONE node; editing + * either file must re-parse the other too, or the repair drops the other + * variant's span and calls (when the edited file holds the node) or keeps + * the edited file's stale ones. After each edit the repaired graph must be + * the graph a full index of the same tree builds. */ + +static bool cv_pipeline(const char *repo, const char *db, cbm_incremental_route_t *route) { + cbm_pipeline_t *p = cbm_pipeline_new(repo, db, CBM_MODE_FULL); + if (!p) { + return false; + } + bool ok = cbm_pipeline_set_project_name(p, "cvinc"); + cbm_pipeline_set_persistence(p, false); + ok = ok && cbm_pipeline_run(p) == 0; + if (route) { + *route = cbm_pipeline_incremental_test_last_route(); + } + cbm_pipeline_free(p); + return ok; +} + +/* CALLS edges by name plus pick's variant spans, one line each. */ +static bool cv_signature(const char *db_path, char *out, size_t n) { + sqlite3 *db = NULL; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + const char *sql = + "SELECT line FROM (SELECT s.name || '>' || t.name AS line FROM edges e " + "JOIN nodes s ON s.id=e.source_id JOIN nodes t ON t.id=e.target_id WHERE e.type='CALLS' " + "UNION ALL SELECT 'variants ' || json_extract(properties,'$.variants') FROM nodes " + "WHERE name='pick' AND label='Function') ORDER BY line"; + sqlite3_stmt *stmt = NULL; + bool ok = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK; + out[0] = '\0'; + while (ok && sqlite3_step(stmt) == SQLITE_ROW) { + const char *line = (const char *)sqlite3_column_text(stmt, 0); + size_t used = strlen(out); + snprintf(out + used, n - used, "%s\n", line ? line : "?"); + } + sqlite3_finalize(stmt); + sqlite3_close(db); + return ok; +} + +/* Rewrite `name` in `repo`, repair the incremental DB, index the same tree + * from scratch, and compare. */ +static bool cv_edit_matches_full(const char *repo, const char *dbdir, const char *name, + const char *content, char *sig, size_t n) { + char inc_db[600]; + char full_db[600]; + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", dbdir); + snprintf(full_db, sizeof(full_db), "%s/full.db", dbdir); + static char full_sig[8192]; + const cv_file_t file = {name, content}; + cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE; + bool ok = cv_write(repo, &file) && cv_pipeline(repo, inc_db, &route); + if (route != CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR) { + fprintf(stderr, " [incremental %s] route %d, not closure repair\n", name, (int)route); + ok = false; + } + unlink(full_db); + ok = ok && cv_pipeline(repo, full_db, NULL) && cv_signature(inc_db, sig, n) && + cv_signature(full_db, full_sig, sizeof(full_sig)); + if (ok && strcmp(sig, full_sig) != 0) { + fprintf(stderr, " [incremental %s] repaired:\n%s full:\n%s", name, sig, full_sig); + ok = false; + } + return ok; +} + +TEST(cv_incremental_variant_partners) { + char repo[300]; + char dbdir[300]; + snprintf(repo, sizeof(repo), "%s", th_mktempdir("cbm-cvinc")); + snprintf(dbdir, sizeof(dbdir), "%s", th_mktempdir("cbm-cvdb")); + ASSERT_TRUE(repo[0] && dbdir[0]); + static const cv_file_t files[] = { + {"util.c", "void alpha(void) {}\nvoid beta(void) {}\nvoid gamma_fn(void) {}\n"}, + {"pick_linux.c", "void pick(void) { alpha(); }\n"}, + {"pick_win.c", "void pick(void) { beta(); }\n"}, + {"caller.c", "void caller(void) { pick(); }\n"}, + }; + for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); i++) { + ASSERT_TRUE(cv_write(repo, &files[i])); + } + char inc_db[600]; + snprintf(inc_db, sizeof(inc_db), "%s/inc.db", dbdir); + ASSERT_TRUE(cv_pipeline(repo, inc_db, NULL)); + static char sig[8192]; + /* Edit each variant file in turn (one of them holds the node). */ + ASSERT_TRUE(cv_edit_matches_full(repo, dbdir, "pick_linux.c", + "void pick(void) { alpha(); gamma_fn(); }\n", sig, + sizeof(sig))); + ASSERT_TRUE(strstr(sig, "pick>beta\n") && strstr(sig, "pick>gamma_fn\n")); + ASSERT_TRUE(cv_edit_matches_full(repo, dbdir, "pick_win.c", "void pick(void) {\n beta();\n}\n", + sig, sizeof(sig))); + ASSERT_TRUE(strstr(sig, "pick>alpha\n") && strstr(sig, "pick>beta\n")); + th_rmtree(repo); + th_rmtree(dbdir); + PASS(); +} + +SUITE(conditional_variants) { + RUN_TEST(cv_c_preprocessor); + RUN_TEST(cv_cpp_elif); + RUN_TEST(cv_cpp_class_members); + RUN_TEST(cv_objc_methods); + RUN_TEST(cv_cuda); + RUN_TEST(cv_csharp_class_members); + RUN_TEST(cv_fsharp); + RUN_TEST(cv_rust_cfg_free_functions); + RUN_TEST(cv_rust_cfg_methods); + RUN_TEST(cv_go_build_tag_files); + RUN_TEST(cv_swift); + RUN_TEST(cv_python_if); + RUN_TEST(cv_ruby_if); + RUN_TEST(cv_php_if); + RUN_TEST(cv_d_version); + RUN_TEST(cv_erlang_ifdef); + RUN_TEST(cv_haskell_cpp); + RUN_TEST(cv_haskell_cpp_elif_nested); + RUN_TEST(cv_fortran_preprocessed); + RUN_TEST(cv_pascal_ifdef); + RUN_TEST(cv_julia_static_if); + RUN_TEST(cv_elixir_compile_time_if); + RUN_TEST(cv_verilog_ifdef); + RUN_TEST(cv_c_platform_files); + RUN_TEST(cv_c_platform_directories); + RUN_TEST(cv_c_non_variants_keep_their_nodes); + RUN_TEST(cv_no_cross_language_name_binding); + RUN_TEST(cv_incremental_variant_partners); +} diff --git a/tests/test_extraction.c b/tests/test_extraction.c index de1136b1a..5a4c7e9eb 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3758,6 +3758,32 @@ TEST(go_cgo_pseudo_import_dropped) { PASS(); } +/* A Bash script imports the files it sources and nothing else. Every top-level + * command used to be read as an import of its command name (`set -e` imported + * "set", `source x.sh` imported "source"), which the import resolver bound to + * any project symbol of that name; the sourced paths themselves were lost. */ +TEST(bash_imports_are_sourced_files_only) { + CBMFileResult *r = extract("#!/usr/bin/env bash\nset -euo pipefail\nsource ./lib/util.sh\n" + ". \"$DIR/other.sh\"\ncd build\nmake all\n" + "if true; then\n source nested.sh\nfi\n", + CBM_LANG_BASH, "t", "run.sh"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(r->imports.count, 3); + ASSERT(has_import(r, "lib/util.sh")); + ASSERT(has_import(r, "other.sh")); + ASSERT(has_import(r, "nested.sh")); + static const char *const commands[] = {"set", "source", ".", "cd", "make"}; + for (int i = 0; i < r->imports.count; i++) { + ASSERT_NOT_NULL(r->imports.items[i].module_path); + for (size_t c = 0; c < sizeof(commands) / sizeof(commands[0]); c++) { + ASSERT_TRUE(strcmp(r->imports.items[i].module_path, commands[c]) != 0); + } + } + cbm_free_result(r); + PASS(); +} + /* #1935: Go struct fields were never extracted — find_class_body() returns the * struct_type node, whose only named child is a field_declaration_list, so the * member loop matched nothing and every field was silently skipped (0 Field @@ -5248,10 +5274,11 @@ TEST(swift_non_url_constructor_untouched_issue1892) { * the per-file constant map and resolved at the call site, for both return * statements and arrow expression bodies. */ TEST(extract_ts_await_generic_call_issue2210) { - CBMFileResult *r = extract("function parseJsonBody() { return {} as T; }\n" - "async function plain() { return await parseJsonBody(); }\n" - "async function generic() { return await parseJsonBody(); }\n", - CBM_LANG_TYPESCRIPT, "t", "await.ts"); + CBMFileResult *r = + extract("function parseJsonBody() { return {} as T; }\n" + "async function plain() { return await parseJsonBody(); }\n" + "async function generic() { return await parseJsonBody(); }\n", + CBM_LANG_TYPESCRIPT, "t", "await.ts"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); ASSERT_EQ(count_calls_named(r, "parseJsonBody"), 2); @@ -9748,6 +9775,7 @@ SUITE(extraction) { RUN_TEST(js_imports); RUN_TEST(go_imports); RUN_TEST(go_cgo_pseudo_import_dropped); + RUN_TEST(bash_imports_are_sourced_files_only); RUN_TEST(extract_go_struct_fields_have_nodes); RUN_TEST(java_imports); RUN_TEST(rust_imports); diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index 6eb993564..0ca52c349 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -463,11 +463,10 @@ TEST(contract_python_relative_import) { /* Python: relative aliased from-import must CALLS the real def (execute). */ TEST(contract_python_aliased_from_import_calls) { - static const LangFile f[] = { - {"gate.py", "def execute(x):\n return x\n"}, - {"router.py", - "from .gate import execute as bridge_execute\n\n\n" - "def submit_task(y):\n return bridge_execute(y)\n"}}; + static const LangFile f[] = {{"gate.py", "def execute(x):\n return x\n"}, + {"router.py", + "from .gate import execute as bridge_execute\n\n\n" + "def submit_task(y):\n return bridge_execute(y)\n"}}; LangProj lp; cbm_store_t *store = lang_index_files(&lp, f, 2); ASSERT_TRUE(store != NULL); @@ -560,10 +559,8 @@ TEST(contract_python_absolute_aliased_from_import_calls) { /* Python: unresolvable module + alias must emit no CALLS (no invented edge). */ TEST(contract_python_ghost_module_aliased_from_import_no_calls) { - static const LangFile f[] = { - {"router.py", - "from ghost_module import nope as alias\n\n\n" - "def submit_task(y):\n return alias(y)\n"}}; + static const LangFile f[] = {{"router.py", "from ghost_module import nope as alias\n\n\n" + "def submit_task(y):\n return alias(y)\n"}}; LangProj lp; cbm_store_t *store = lang_index_files(&lp, f, 1); ASSERT_TRUE(store != NULL); @@ -1073,6 +1070,7 @@ static const char *ALL_EDGE_TYPES[] = {"CALLS", "REFERENCES_FILE", "SEMANTICALLY_RELATED", "SIMILAR_TO", + "SPAWNS", "TESTS_FILE", "TESTS", "TRPC_CALLS", @@ -1760,10 +1758,11 @@ TEST(contract_edge_parallel_service_edges) { if (store) { sqlite3_stmt *stmt = NULL; sqlite3 *db = cbm_store_get_db(store); - if (db && sqlite3_prepare_v2(db, - "SELECT count(*) FROM edges WHERE properties IS NOT NULL " - "AND properties != '' AND json_valid(properties)=0;", - -1, &stmt, NULL) == SQLITE_OK && + if (db && + sqlite3_prepare_v2(db, + "SELECT count(*) FROM edges WHERE properties IS NOT NULL " + "AND properties != '' AND json_valid(properties)=0;", + -1, &stmt, NULL) == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) { invalid_props = sqlite3_column_int(stmt, 0); } diff --git a/tests/test_main.c b/tests/test_main.c index 7428194f2..6ede15bb7 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -12,22 +12,22 @@ int tf_deselected_count = 0; #include "test_framework.h" #include "test_helpers.h" #include "test_daemon_runtime_contract.h" -#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ -#include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ -#include "foundation/constants.h" /* CBM_SZ_4K — forced stderr buffer */ -#include "foundation/log.h" /* crash-durable worker log probe */ -#include "foundation/mem.h" /* cbm_mem_init — worker budget */ -#include "foundation/log.h" /* worker liveness heartbeat probe */ -#include "foundation/platform.h" /* cbm_file_exists — blocking-git marker */ +#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ +#include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ +#include "foundation/constants.h" /* CBM_SZ_4K — forced stderr buffer */ +#include "foundation/log.h" /* crash-durable worker log probe */ +#include "foundation/mem.h" /* cbm_mem_init — worker budget */ +#include "foundation/log.h" /* worker liveness heartbeat probe */ +#include "foundation/platform.h" /* cbm_file_exists — blocking-git marker */ #include "foundation/test_selection.h" /* shared executable selection token bound */ -#include "daemon/bootstrap.h" /* runner rendezvous isolation */ -#include "daemon/runtime.h" /* bounded worker response probe */ -#include "daemon/ipc.h" /* Windows private-lock re-exec probe */ -#include "daemon/version_cohort.h" /* Windows crash-turnover re-exec probe */ -#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ -#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ -#include "ui/http_server.h" /* deleted-self executable probe */ -#include "result_spill.h" /* pinned free disk: spill verdicts ignore the host disk */ +#include "daemon/bootstrap.h" /* runner rendezvous isolation */ +#include "daemon/runtime.h" /* bounded worker response probe */ +#include "daemon/ipc.h" /* Windows private-lock re-exec probe */ +#include "daemon/version_cohort.h" /* Windows crash-turnover re-exec probe */ +#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ +#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ +#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 @@ -49,11 +49,11 @@ int tf_deselected_count = 0; #include #endif #ifdef CBM_TEST_COVERAGE -#include /* the libc functions behind the profile-keeping ones */ -#include /* the marker of a forked child */ -#include /* non-consuming child-failure observation */ -#include /* pthread_atfork: a forked child names its own profile */ -#include /* execl */ +#include /* the libc functions behind the profile-keeping ones */ +#include /* the marker of a forked child */ +#include /* non-consuming child-failure observation */ +#include /* pthread_atfork: a forked child names its own profile */ +#include /* execl */ #include /* reject detectable failure-channel descriptor replacement */ #endif #endif @@ -1853,6 +1853,8 @@ extern void suite_security(void); extern void suite_yaml(void); extern void suite_integration(void); extern void suite_lang_contract(void); +extern void suite_conditional_variants(void); +extern void suite_spawns(void); extern void suite_edge_imports(void); extern void suite_edge_structural(void); extern void suite_lsp_resolution_probe(void); @@ -2312,6 +2314,8 @@ int main(int argc, char **argv) { /* Per-language graph contracts (node/edge types, attribution, no-crash) */ RUN_SELECTED_SUITE(lang_contract); + RUN_SELECTED_SUITE(conditional_variants); + RUN_SELECTED_SUITE(spawns); RUN_SELECTED_SUITE(edge_imports); RUN_SELECTED_SUITE(edge_structural); RUN_SELECTED_SUITE(lsp_resolution_probe); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 15ff1bdbf..929fbd760 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1999,6 +1999,231 @@ TEST(pipeline_objectscript_export_incremental_matches_full_relationships) { PASS(); } +/* Cross-language unique_name leak. Every Python file injects a few builtin + * stub definitions (builtins.dict.get, builtins.len, ...; see + * internal/cbm/lsp/py_builtins.c) into the project registry. A JS Express + * registration `app.get('/users', listUsers)` has no LSP target, falls + * through to the registry, and its short name "get" had exactly one + * candidate project-wide: Python's builtins.dict.get (strategy unique_name). + * The non-empty resolution skipped the route-registration fallback (it runs + * only on an EMPTY resolution), and the weak-member guard then dropped the + * plain CALLS edge, so the Route node and its HANDLES edge vanished -- on the + * sequential and the parallel path alike, and on an incremental reindex whose + * registry is re-seeded from stored nodes (the builtin stubs survive there + * even after the last .py file is gone). + * + * The same leak fabricates CALLS edges on both paths: a bare JS `print(msg)` + * bound to Python's builtins.print by unique_name. `cache.get(key)` guards the + * other side of the veto: an unresolved route verb without a route path must + * not become a CALLS self-loop on the parallel path. + * + * The Python control proves the builtins stay reachable from Python itself: + * lookup() -> builtins.dict.get (lsp_generic_method) and -> builtins.len. */ +typedef struct { + int run_rc; + bool store_opened; + int routes; /* Route node "/users" */ + int handles; /* listUsers -[HANDLES]-> "/users" */ + int py_dict_get; /* lookup -[CALLS]-> get */ + int py_len; /* lookup -[CALLS]-> len */ + int js_print; /* report -[CALLS]-> print (JS global, never Python's) */ + int self_loop; /* readCache -[CALLS]-> readCache (unresolved route verb) */ +} XlangRouteObservation; + +static XlangRouteObservation observe_xlang_route(const char *repo_path, const char *db_name) { + XlangRouteObservation o = {.run_rc = -1, + .routes = -1, + .handles = -1, + .py_dict_get = -1, + .py_len = -1, + .js_print = -1, + .self_loop = -1}; + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s", repo_path, db_name); + cbm_pipeline_t *pipeline = cbm_pipeline_new(repo_path, db_path, CBM_MODE_FULL); + if (!pipeline) { + return o; + } + o.run_rc = cbm_pipeline_run(pipeline); + const char *project = cbm_pipeline_project_name(pipeline); + cbm_store_t *store = cbm_store_open_path(db_path); + o.store_opened = store != NULL; + if (store && project) { + o.routes = named_node_count(store, project, "/users"); + o.handles = named_edge_count(store, project, "HANDLES", "listUsers", "/users"); + o.py_dict_get = named_edge_count(store, project, "CALLS", "lookup", "get"); + o.py_len = named_edge_count(store, project, "CALLS", "lookup", "len"); + o.js_print = named_edge_count(store, project, "CALLS", "report", "print"); + o.self_loop = named_edge_count(store, project, "CALLS", "readCache", "readCache"); + } + if (store) { + cbm_store_close(store); + } + cbm_pipeline_free(pipeline); + return o; +} + +static const char XLANG_APP_JS[] = "const express = require('express');\n" + "const app = express();\n" + "\n" + "function listUsers(req, res) {\n" + " res.json([]);\n" + "}\n" + "\n" + "app.get('/users', listUsers);\n" + "\n" + "function report(msg) {\n" + " print(msg);\n" + "}\n" + "\n" + "function readCache(cache, key) {\n" + " return cache.get(key);\n" + "}\n" + "module.exports = { app, report, readCache };\n"; + +static const char XLANG_TOOL_PY[] = "def lookup(xs):\n" + " d = {\"k\": 1}\n" + " n = len(xs)\n" + " return d.get(\"k\"), n\n"; + +/* Writes app.js + tool.py + `pad` JS filler files (the fillers only select the + * parallel path: more than MIN_FILES_FOR_PARALLEL files). 0 on success. */ +static int write_xlang_route_fixture(const char *dir, int pad) { + char path[512]; + snprintf(path, sizeof(path), "%s/app.js", dir); + if (th_write_file(path, XLANG_APP_JS) != 0) { + return -1; + } + snprintf(path, sizeof(path), "%s/tool.py", dir); + if (th_write_file(path, XLANG_TOOL_PY) != 0) { + return -1; + } + for (int i = 0; i < pad; i++) { + char source[128]; + snprintf(path, sizeof(path), "%s/xlang_pad_%02d.js", dir, i); + snprintf(source, sizeof(source), "function xlangPad%02d() { return %d; }\n", i, i); + if (th_write_file(path, source) != 0) { + return -1; + } + } + return 0; +} + +/* Saves and overrides the two worker-selection variables. */ +typedef struct { + char *workers; + char *single; +} XlangEnvSave; + +static XlangEnvSave xlang_env_select(bool parallel) { + XlangEnvSave save = {0}; + const char *w = getenv("CBM_WORKERS"); + const char *s = getenv("CBM_INDEX_SINGLE_THREAD"); + save.workers = w ? strdup(w) : NULL; + save.single = s ? strdup(s) : NULL; + if (parallel) { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + } else { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + } + return save; +} + +static void xlang_env_restore(XlangEnvSave *save) { + if (save->workers) { + cbm_setenv("CBM_WORKERS", save->workers, 1); + free(save->workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + if (save->single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", save->single, 1); + free(save->single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + save->workers = NULL; + save->single = NULL; +} + +static XlangRouteObservation run_xlang_route_case(bool parallel, int pad) { + XlangRouteObservation o = {.run_rc = -1}; + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_xlang_route_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + return o; + } + if (write_xlang_route_fixture(tmp, pad) == 0) { + XlangEnvSave save = xlang_env_select(parallel); + o = observe_xlang_route(tmp, "xlang.db"); + xlang_env_restore(&save); + } + th_rmtree(tmp); + return o; +} + +TEST(pipeline_js_route_not_bound_to_python_builtin_sequential) { + XlangRouteObservation o = run_xlang_route_case(false, 0); + ASSERT_EQ(o.run_rc, 0); + ASSERT_TRUE(o.store_opened); + ASSERT_EQ(o.routes, 1); + ASSERT_EQ(o.handles, 1); + ASSERT_EQ(o.py_dict_get, 1); + ASSERT_EQ(o.py_len, 1); + ASSERT_EQ(o.js_print, 0); + ASSERT_EQ(o.self_loop, 0); + PASS(); +} + +TEST(pipeline_js_route_not_bound_to_python_builtin_parallel) { + XlangRouteObservation o = run_xlang_route_case(true, 55); + ASSERT_EQ(o.run_rc, 0); + ASSERT_TRUE(o.store_opened); + ASSERT_EQ(o.routes, 1); + ASSERT_EQ(o.handles, 1); + ASSERT_EQ(o.py_dict_get, 1); + ASSERT_EQ(o.py_len, 1); + ASSERT_EQ(o.js_print, 0); + ASSERT_EQ(o.self_loop, 0); + PASS(); +} + +/* Incremental: the registry is re-seeded from stored nodes, where the Python + * builtin stubs outlive the last .py file (their file_path is synthetic). */ +TEST(pipeline_js_route_not_bound_to_python_builtin_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_xlang_incr_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + if (write_xlang_route_fixture(tmp, 0) != 0) { + th_rmtree(tmp); + FAIL("fixture"); + } + XlangEnvSave save = xlang_env_select(false); + XlangRouteObservation initial = observe_xlang_route(tmp, "xlang-incr.db"); + char path[512]; + snprintf(path, sizeof(path), "%s/tool.py", tmp); + int rm_rc = remove(path); + snprintf(path, sizeof(path), "%s/app.js", tmp); + int append_rc = th_append_file(path, "// edited: re-resolve this file\n"); + XlangRouteObservation incremental = observe_xlang_route(tmp, "xlang-incr.db"); + xlang_env_restore(&save); + th_rmtree(tmp); + + ASSERT_EQ(rm_rc, 0); + ASSERT_EQ(append_rc, 0); + ASSERT_EQ(initial.run_rc, 0); + ASSERT_EQ(incremental.run_rc, 0); + ASSERT_TRUE(incremental.store_opened); + ASSERT_EQ(incremental.routes, 1); + ASSERT_EQ(incremental.handles, 1); + ASSERT_EQ(incremental.js_print, 0); + ASSERT_EQ(incremental.self_loop, 0); + PASS(); +} + static bool test_buffer_appendf(char *buffer, size_t capacity, size_t *used, const char *format, ...) { if (!buffer || !used || *used >= capacity) { @@ -18151,6 +18376,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_incremental_preserves_cross_file_calls); RUN_TEST(pipeline_objectscript_export_preserves_calls_sequential_parallel); RUN_TEST(pipeline_objectscript_export_incremental_matches_full_relationships); + RUN_TEST(pipeline_js_route_not_bound_to_python_builtin_sequential); + RUN_TEST(pipeline_js_route_not_bound_to_python_builtin_parallel); + RUN_TEST(pipeline_js_route_not_bound_to_python_builtin_incremental); RUN_TEST(pipeline_objectscript_export_aggregate_exceeds_arena_block_table); #if defined(CBM_COVERAGE_MARKER_TEST_API) && CBM_COVERAGE_MARKER_TEST_API RUN_TEST(pipeline_objectscript_export_range_join_keeps_one_trailing_marker); diff --git a/tests/test_registry.c b/tests/test_registry.c index 1671f628e..bcd589a7d 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -961,6 +961,113 @@ TEST(registry_tie_break_is_independent_of_registration_order) { PASS(); } +TEST(lang_resolution_compatible_families) { + /* Same language and the interop families the resolvers already honour. */ + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_PYTHON, CBM_LANG_PYTHON)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_JAVASCRIPT, CBM_LANG_TYPESCRIPT)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_VUE, CBM_LANG_TSX)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_C, CBM_LANG_CPP)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_OBJC, CBM_LANG_C)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_KOTLIN, CBM_LANG_JAVA)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_SWIFT, CBM_LANG_OBJC)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_OBJC, CBM_LANG_SWIFT)); + /* Unknown on either side: no evidence, no filter. */ + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_COUNT, CBM_LANG_PYTHON)); + ASSERT_TRUE(cbm_lang_resolution_compatible(CBM_LANG_JAVASCRIPT, CBM_LANG_COUNT)); + /* Across families: never by name alone. */ + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_JAVASCRIPT, CBM_LANG_PYTHON)); + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_PYTHON, CBM_LANG_TSX)); + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_GO, CBM_LANG_C)); + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_SWIFT, CBM_LANG_KOTLIN)); + ASSERT_FALSE(cbm_lang_resolution_compatible(CBM_LANG_MAKEFILE, CBM_LANG_C)); + /* Synthetic LSP sources carry their language. */ + ASSERT_EQ(cbm_registry_synthetic_path_language(""), CBM_LANG_PYTHON); + ASSERT_EQ(cbm_registry_synthetic_path_language(""), CBM_LANG_KOTLIN); + ASSERT_EQ(cbm_registry_synthetic_path_language("app.py"), CBM_LANG_COUNT); + ASSERT_EQ(cbm_registry_synthetic_path_language(NULL), CBM_LANG_COUNT); + PASS(); +} + +/* Resolve with the unfiltered chain, then apply the cross-language veto the + * way pass_calls.c / pass_parallel.c do. */ +static cbm_resolution_t xl_resolve(const cbm_registry_t *r, CBMLanguage lang, const char *callee, + const char *module_qn) { + cbm_resolution_t res = cbm_registry_resolve(r, callee, module_qn, NULL, NULL, 0); + if (cbm_registry_name_guess_vetoed(r, lang, &res)) { + res = (cbm_resolution_t){0}; + } + return res; +} + +TEST(name_guess_vetoed_across_languages) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_lang(r, "get", "builtins.dict.get", "Method", CBM_LANG_PYTHON); + cbm_registry_add_lang(r, "patch", "proj.web.Panel.patch", "Function", CBM_LANG_TSX); + cbm_registry_add_lang(r, "patch", "proj.api.client.patch", "Function", CBM_LANG_PYTHON); + cbm_registry_add(r, "legacy", "proj.x.legacy", "Function"); /* unknown language */ + + /* JS `app.get`: the only "get" is Python's -> EMPTY, not unique_name. */ + cbm_resolution_t res = xl_resolve(r, CBM_LANG_JAVASCRIPT, "app.get", "proj.app"); + ASSERT_TRUE(res.qualified_name == NULL || res.qualified_name[0] == '\0'); + /* Python keeps its own builtin. */ + res = xl_resolve(r, CBM_LANG_PYTHON, "d.get", "proj.tool"); + ASSERT_STR_EQ(res.qualified_name, "builtins.dict.get"); + ASSERT_STR_EQ(res.strategy, "unique_name"); + /* Two same-named candidates: the chain still picks by import distance; + * the answer stands only for a caller that can call the winner. */ + res = xl_resolve(r, CBM_LANG_PYTHON, "patch", "proj.api.x"); + ASSERT_STR_EQ(res.qualified_name, "proj.api.client.patch"); + ASSERT_STR_EQ(res.strategy, "suffix_match"); + res = xl_resolve(r, CBM_LANG_TYPESCRIPT, "patch", "proj.api.x"); + ASSERT_TRUE(res.qualified_name == NULL || res.qualified_name[0] == '\0'); + res = xl_resolve(r, CBM_LANG_TYPESCRIPT, "patch", "proj.web.ui"); + ASSERT_STR_EQ(res.qualified_name, "proj.web.Panel.patch"); + /* Unknown-language entries and an unknown caller are never vetoed. */ + res = xl_resolve(r, CBM_LANG_GO, "legacy", "proj.go"); + ASSERT_STR_EQ(res.qualified_name, "proj.x.legacy"); + res = xl_resolve(r, CBM_LANG_COUNT, "app.get", "proj.app"); + ASSERT_STR_EQ(res.qualified_name, "builtins.dict.get"); + + /* Evidence-backed strategies are never vetoed, whatever the language. */ + cbm_resolution_t imp = {.qualified_name = "builtins.dict.get", + .strategy = "import_map", + .confidence = 0.95, + .candidate_count = 1}; + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_JAVASCRIPT, &imp)); + imp.strategy = "lsp_generic_method"; + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_JAVASCRIPT, &imp)); + /* The parallel field-type hint re-picks language-blind: vetoed too. */ + imp.strategy = "field_type_hint"; + ASSERT_TRUE(cbm_registry_name_guess_vetoed(r, CBM_LANG_JAVASCRIPT, &imp)); + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_PYTHON, &imp)); + cbm_registry_free(r); + PASS(); +} + +/* tests/cov.c and tests/cov.py share the module QN proj.tests.cov (module QNs + * drop the extension): a same_module answer across languages is that + * collision, not evidence. Within the caller's language it stands. */ +TEST(same_module_vetoed_across_languages) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_lang(r, "reducer_worker", "proj.tests.cov.reducer_worker", "Function", + CBM_LANG_PYTHON); + cbm_registry_add_lang(r, "reducer_worker", "proj.other.reducer_worker", "Function", + CBM_LANG_PYTHON); + cbm_registry_add_lang(r, "stat_child", "proj.tests.cov.stat_child", "Function", CBM_LANG_C); + + cbm_resolution_t res = + cbm_registry_resolve(r, "reducer_worker", "proj.tests.cov", NULL, NULL, 0); + ASSERT_STR_EQ(res.strategy, "same_module"); + ASSERT_TRUE(cbm_registry_name_guess_vetoed(r, CBM_LANG_C, &res)); + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_PYTHON, &res)); + + res = cbm_registry_resolve(r, "stat_child", "proj.tests.cov", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.tests.cov.stat_child"); + ASSERT_FALSE(cbm_registry_name_guess_vetoed(r, CBM_LANG_C, &res)); + cbm_registry_free(r); + PASS(); +} + TEST(cross_language_suffix_match_drops_py_vs_js) { /* #725: two same-named symbols in different languages. suffix_match is the * strategy that collapses them; unique_name is #1572 and must stay. */ @@ -1473,6 +1580,9 @@ SUITE(registry) { RUN_TEST(perl_suppress_drops_weak_builtin_and_method_matches); RUN_TEST(perl_suppress_keeps_high_confidence_and_genuine_calls); RUN_TEST(cross_language_suffix_match_drops_py_vs_js); + RUN_TEST(lang_resolution_compatible_families); + RUN_TEST(name_guess_vetoed_across_languages); + RUN_TEST(same_module_vetoed_across_languages); RUN_TEST(cross_language_config_caller_drops_unique_name_too); RUN_TEST(registry_tie_break_is_independent_of_registration_order); RUN_TEST(cross_language_ref_drops_go_vs_c); diff --git a/tests/test_spawns.c b/tests/test_spawns.c new file mode 100644 index 000000000..c42b31a3e --- /dev/null +++ b/tests/test_spawns.c @@ -0,0 +1,376 @@ +/* + * test_spawns.c — SPAWNS edges: a call that starts another program, to the + * Process node of that program (internal/cbm/spawn_patterns.h, + * src/pipeline/pass_spawns.c). + * + * Unit tests pin how a call is recognised (spellings, the file's imports, + * bare names only when nothing in the project answers) and which program it + * starts (a literal, a collection's first element, never an argument after a + * variable program). Pipeline tests pin the edges in an indexed repo and that + * the sequential and parallel resolvers emit the same ones. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include "spawn_patterns.h" + +#include +#include +#include +#include + +/* ── Recognition ─────────────────────────────────────────────────── */ + +static const char *sp_api(CBMLanguage lang, const char *callee, const CBMImport *imports, + int count) { + const cbm_spawn_api_t *api = cbm_spawn_match(lang, callee, imports, count); + return api ? api->api : NULL; +} + +TEST(spawn_match_through_python_imports) { + const CBMImport imports[] = {{.local_name = "sp", .module_path = "subprocess"}, + {.local_name = "run", .module_path = "subprocess.run"}, + {.local_name = "os", .module_path = "os"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "sp.check_output", imports, 3), + "subprocess.check_output"); + ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "run", imports, 3), "subprocess.run"); + ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "subprocess.Popen", NULL, 0), "subprocess.Popen"); + ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "os.spawnv", imports, 3), "os.spawnv"); + /* A bare `run` is not subprocess.run without the import that says so. */ + ASSERT_NULL(sp_api(CBM_LANG_PYTHON, "run", NULL, 0)); + ASSERT_NULL(sp_api(CBM_LANG_PYTHON, "system", NULL, 0)); + PASS(); +} + +TEST(spawn_match_through_go_rust_js_imports) { + const CBMImport go[] = {{.local_name = "exec", .module_path = "os/exec"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_GO, "exec.Command", go, 1), "os/exec.Command"); + ASSERT_STR_EQ(sp_api(CBM_LANG_GO, "exec.CommandContext", go, 1), "os/exec.CommandContext"); + ASSERT_NULL(sp_api(CBM_LANG_GO, "exec.Command", NULL, 0)); + + const CBMImport rust[] = {{.local_name = "Command", .module_path = "std::process::Command"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_RUST, "Command::new", rust, 1), "std::process::Command::new"); + ASSERT_NULL(sp_api(CBM_LANG_RUST, "Command::new", NULL, 0)); + + /* `import { spawn } from "node:child_process"`, `const cp = require(..)`, + * and a destructured require that records only the module. */ + const CBMImport named[] = {{.local_name = "spawn", .module_path = "node:child_process"}, + {.local_name = "cp", .module_path = "child_process"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_TYPESCRIPT, "spawn", named, 2), "child_process.spawn"); + ASSERT_STR_EQ(sp_api(CBM_LANG_JAVASCRIPT, "cp.execSync", named, 2), "child_process.execSync"); + const CBMImport destructured[] = { + {.local_name = "child_process", .module_path = "child_process"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_JAVASCRIPT, "execFile", destructured, 1), "execFile"); + ASSERT_NULL(sp_api(CBM_LANG_JAVASCRIPT, "execFile", NULL, 0)); + PASS(); +} + +/* Bare names: libc-style ones by spelling (a definition of the name in the + * calling module still wins, see spawns_in_the_graph), import-gated ones only + * with their import. */ +TEST(spawn_match_bare_names) { + ASSERT_STR_EQ(sp_api(CBM_LANG_C, "system", NULL, 0), "system"); + ASSERT_STR_EQ(sp_api(CBM_LANG_CPP, "std::system", NULL, 0), "std::system"); + ASSERT_STR_EQ(sp_api(CBM_LANG_RUBY, "spawn", NULL, 0), "spawn"); + const CBMImport hs[] = {{.local_name = "Process", .module_path = "System.Process"}}; + ASSERT_STR_EQ(sp_api(CBM_LANG_HASKELL, "callProcess", hs, 1), "callProcess"); + ASSERT_NULL(sp_api(CBM_LANG_HASKELL, "callProcess", NULL, 0)); + ASSERT_NULL(sp_api(CBM_LANG_GO, "system", NULL, 0)); + ASSERT_NULL(sp_api(CBM_LANG_C, "systemd_notify", NULL, 0)); + PASS(); +} + +/* ── The program ─────────────────────────────────────────────────── */ + +/* Program of `callee(arg0, arg1)` as the extractor records it. */ +static const char *sp_program(CBMLanguage lang, const char *callee, const char *arg0, + const char *arg1, const CBMImport *imports, int count, char *buf) { + CBMCallArg args[2] = {{.expr = arg0, .index = 0}, {.expr = arg1, .index = 1}}; + CBMCall call = {.callee_name = callee, .args = args, .arg_count = arg1 ? 2 : (arg0 ? 1 : 0)}; + const cbm_spawn_api_t *api = cbm_spawn_match(lang, callee, imports, count); + if (!api) { + return "(no match)"; + } + cbm_spawn_program(api, lang, &call, buf, CBM_SPAWN_PROGRAM_MAX); + return buf; +} + +TEST(spawn_program_rules) { + char b[CBM_SPAWN_PROGRAM_MAX]; + const CBMLanguage py = CBM_LANG_PYTHON; + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "[\"git\", \"status\"]", NULL, NULL, 0, b), + "git"); + /* The program is the variable; the arguments after it are not. */ + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "[binary, \"--version\"]", NULL, NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ(sp_program(py, "subprocess.Popen", "record[\"argv\"]", NULL, NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "os.path.join(\"bin\", tool)", NULL, NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "f\"{tool} --x\"", NULL, NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ(sp_program(py, "subprocess.run", "args=[\"make\", \"all\"]", NULL, NULL, 0, b), + "make"); + ASSERT_STR_EQ(sp_program(py, "os.system", "\"rm -rf build\"", NULL, NULL, 0, b), "rm"); + /* Environment prefixes and directories are not the program. */ + ASSERT_STR_EQ(sp_program(CBM_LANG_C, "popen", "\"LC_ALL=C ps -eo pid\"", "\"r\"", NULL, 0, b), + "ps"); + ASSERT_STR_EQ( + sp_program(CBM_LANG_C, "system", "\"/usr/bin/env FOO=1 make all\"", NULL, NULL, 0, b), + "make"); + /* posix_spawnp names the program second; CreateProcess in its command + * line when the application name is NULL. */ + ASSERT_STR_EQ(sp_program(CBM_LANG_C, "posix_spawnp", "&pid", "\"git\"", NULL, 0, b), "git"); + ASSERT_STR_EQ(sp_program(CBM_LANG_C, "CreateProcessW", "NULL", "L\"cmd /c dir\"", NULL, 0, b), + "cmd"); + ASSERT_STR_EQ(sp_program(CBM_LANG_C, "execvp", "argv[0]", "argv", NULL, 0, b), + CBM_SPAWN_DYNAMIC); + ASSERT_STR_EQ( + sp_program(CBM_LANG_KOTLIN, "ProcessBuilder", "listOf(\"git\", \"log\")", NULL, NULL, 0, b), + "git"); + ASSERT_STR_EQ(sp_program(CBM_LANG_SWIFT, "Process.launchedProcess", "launchPath: \"/bin/ls\"", + "arguments: []", NULL, 0, b), + "ls"); + /* Options tuples / structs: the program is the literal inside. */ + ASSERT_STR_EQ(sp_program(CBM_LANG_ELIXIR, "Port.open", "{:spawn, \"cat\"}", "[]", NULL, 0, b), + "cat"); + /* Groovy: the receiver is the command line. */ + ASSERT_STR_EQ(sp_program(CBM_LANG_GROOVY, "\"git status\".execute", NULL, NULL, NULL, 0, b), + "git"); + PASS(); +} + +/* ── In the graph ────────────────────────────────────────────────── */ + +typedef struct { + const char *name; + const char *content; +} sp_file_t; + +typedef struct { + char tmpdir[256]; + char dbpath[1024]; + char *project; + cbm_mcp_server_t *srv; + cbm_store_t *store; +} sp_proj_t; + +static bool sp_write(const char *root, const char *name, const char *content) { + char path[700]; + snprintf(path, sizeof(path), "%s/%s", root, name); + char *slash = strrchr(path, '/'); + if (slash && slash > path + strlen(root)) { + *slash = '\0'; + cbm_mkdir_p(path, 0755); + *slash = '/'; + } + FILE *f = cbm_fopen(path, "wb"); + if (!f) { + return false; + } + bool ok = fputs(content, f) >= 0; + return fclose(f) == 0 && ok; +} + +/* Index `files`, plus `pad` trivial Python files (enough of them moves the + * index onto the parallel resolver, MIN_FILES_FOR_PARALLEL). */ +static bool sp_index(sp_proj_t *p, const sp_file_t *files, int count, int pad) { + memset(p, 0, sizeof(*p)); + snprintf(p->tmpdir, sizeof(p->tmpdir), "%s", th_mktempdir("cbm-sp")); + if (!p->tmpdir[0]) { + return false; + } + for (int i = 0; i < count; i++) { + if (!sp_write(p->tmpdir, files[i].name, files[i].content)) { + return false; + } + } + for (int i = 0; i < pad; i++) { + char name[64]; + char body[96]; + snprintf(name, sizeof(name), "pad/p%d.py", i); + snprintf(body, sizeof(body), "def pad_%d():\n return %d\n", i, i); + if (!sp_write(p->tmpdir, name, body)) { + return false; + } + } + p->project = cbm_project_name_from_path(p->tmpdir); + if (!p->project) { + return false; + } + char cache[700]; + const char *configured = getenv("CBM_CACHE_DIR"); + const char *home = getenv("HOME"); + if (configured && configured[0]) { + snprintf(cache, sizeof(cache), "%s", configured); + } else { + snprintf(cache, sizeof(cache), "%s/.cache/codebase-memory-mcp", home ? home : "/tmp"); + } + cbm_mkdir_p(cache, 0755); + snprintf(p->dbpath, sizeof(p->dbpath), "%s/%s.db", cache, p->project); + unlink(p->dbpath); + p->srv = cbm_mcp_server_new(NULL); + if (!p->srv) { + return false; + } + char args[800]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", p->tmpdir); + free(cbm_mcp_handle_tool(p->srv, "index_repository", args)); + p->store = cbm_store_open_path(p->dbpath); + return p->store != NULL; +} + +static void sp_cleanup(sp_proj_t *p) { + if (p->store) { + cbm_store_close(p->store); + } + if (p->srv) { + cbm_mcp_server_free(p->srv); + } + if (p->dbpath[0]) { + char side[1100]; + unlink(p->dbpath); + snprintf(side, sizeof(side), "%s-wal", p->dbpath); + unlink(side); + snprintf(side, sizeof(side), "%s-shm", p->dbpath); + unlink(side); + } + free(p->project); + if (p->tmpdir[0]) { + th_rmtree(p->tmpdir); + } + memset(p, 0, sizeof(*p)); +} + +static int sp_count(sp_proj_t *p, const char *sql, const char *a, const char *b) { + sqlite3 *db = cbm_store_get_db(p->store); + sqlite3_stmt *stmt = NULL; + if (!db || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + sqlite3_finalize(stmt); + return -1; + } + int n = sqlite3_bind_parameter_count(stmt); + const char *values[] = {p->project, a, b}; + for (int i = 0; i < n && i < 3; i++) { + sqlite3_bind_text(stmt, i + 1, values[i], -1, SQLITE_TRANSIENT); + } + int count = sqlite3_step(stmt) == SQLITE_ROW ? sqlite3_column_int(stmt, 0) : -1; + sqlite3_finalize(stmt); + return count; +} + +/* SPAWNS edges from the definition named `caller` to the Process `program`. */ +static int sp_spawns(sp_proj_t *p, const char *caller, const char *program) { + return sp_count(p, + "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id " + "JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='SPAWNS' " + "AND s.name=?2 AND t.name=?3 AND t.label='Process'", + caller, program); +} + +static int sp_calls(sp_proj_t *p, const char *caller, const char *callee) { + return sp_count(p, + "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id " + "JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='CALLS' " + "AND s.name=?2 AND t.name=?3", + caller, callee); +} + +/* Every SPAWNS edge as "caller>program|api" lines, sorted, into `out`. */ +static bool sp_edge_set(sp_proj_t *p, char *out, size_t n) { + sqlite3 *db = cbm_store_get_db(p->store); + sqlite3_stmt *stmt = NULL; + const char *sql = "SELECT s.name || '>' || t.name || '|' || json_extract(e.properties,'$.api') " + "FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON " + "t.id=e.target_id WHERE e.project=?1 AND e.type='SPAWNS' ORDER BY 1"; + if (!db || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + sqlite3_finalize(stmt); + return false; + } + sqlite3_bind_text(stmt, 1, p->project, -1, SQLITE_TRANSIENT); + out[0] = '\0'; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char *row = (const char *)sqlite3_column_text(stmt, 0); + size_t used = strlen(out); + snprintf(out + used, n - used, "%s\n", row ? row : "?"); + } + sqlite3_finalize(stmt); + return true; +} + +static const sp_file_t SP_FILES[] = { + /* subprocess.run inside the module's own `def run`: the resolver's + * suffix guess (same_module) must not make it a call to `run`. */ + {"tools.py", "import subprocess\n\n\ndef run(cmd):\n return subprocess.run(cmd)\n\n\n" + "def build():\n subprocess.check_output([\"make\", \"all\"])\n"}, + {"native.c", "#include \nint clean(void) { return system(\"rm -rf build\"); }\n"}, + /* A project-defined `system` answers the bare name: a call, no spawn. */ + {"own.c", "static int system(const char *c) { return c != 0; }\n" + "int use_own(void) { return system(\"x\"); }\n"}, + {"deploy.go", "package main\n\nimport \"os/exec\"\n\n" + "func deploy() { exec.Command(\"kubectl\", \"apply\").Run() }\n"}, + {"launch.js", "const { spawn } = require(\"child_process\");\n" + "function start() { spawn(\"node\", [\"server.js\"]); }\n"}, +}; +#define SP_FILE_COUNT ((int)(sizeof(SP_FILES) / sizeof(SP_FILES[0]))) + +TEST(spawns_in_the_graph) { + sp_proj_t p; + ASSERT_TRUE(sp_index(&p, SP_FILES, SP_FILE_COUNT, 0)); + ASSERT_EQ(sp_spawns(&p, "build", "make"), 1); + ASSERT_EQ(sp_spawns(&p, "run", CBM_SPAWN_DYNAMIC), 1); + ASSERT_EQ(sp_spawns(&p, "clean", "rm"), 1); + ASSERT_EQ(sp_spawns(&p, "deploy", "kubectl"), 1); + ASSERT_EQ(sp_spawns(&p, "start", "node"), 1); + /* The spawn replaces the CALLS edge a guess would have made. */ + ASSERT_EQ(sp_calls(&p, "build", "run"), 0); + /* A project-defined system() is code, not a process. */ + ASSERT_EQ(sp_calls(&p, "use_own", "system"), 1); + ASSERT_EQ(sp_count(&p, + "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id WHERE " + "e.project=?1 AND e.type='SPAWNS' AND s.name=?2", + "use_own", NULL), + 0); + /* One Process node per program, named and qualified by it. */ + ASSERT_EQ(sp_count(&p, + "SELECT COUNT(*) FROM nodes WHERE label='Process' AND name=?2 AND " + "qualified_name='__process__make'", + "make", NULL), + 1); + sp_cleanup(&p); + PASS(); +} + +/* The sequential (few files) and parallel (past MIN_FILES_FOR_PARALLEL) + * resolvers ask at the same point and emit the same SPAWNS edges. */ +TEST(spawns_parallel_matches_sequential) { + static char seq[4096]; + static char par[4096]; + sp_proj_t p; + ASSERT_TRUE(sp_index(&p, SP_FILES, SP_FILE_COUNT, 0)); + ASSERT_TRUE(sp_edge_set(&p, seq, sizeof(seq))); + sp_cleanup(&p); + ASSERT_TRUE(sp_index(&p, SP_FILES, SP_FILE_COUNT, 60)); + ASSERT_TRUE(sp_edge_set(&p, par, sizeof(par))); + sp_cleanup(&p); + ASSERT_TRUE(seq[0] != '\0'); + if (strcmp(seq, par) != 0) { + fprintf(stderr, " sequential:\n%s parallel:\n%s", seq, par); + } + ASSERT_STR_EQ(seq, par); + PASS(); +} + +SUITE(spawns) { + RUN_TEST(spawn_match_through_python_imports); + RUN_TEST(spawn_match_through_go_rust_js_imports); + RUN_TEST(spawn_match_bare_names); + RUN_TEST(spawn_program_rules); + RUN_TEST(spawns_in_the_graph); + RUN_TEST(spawns_parallel_matches_sequential); +} diff --git a/tests/test_test_impact_engine.c b/tests/test_test_impact_engine.c index 8d735340b..02ce23b79 100644 --- a/tests/test_test_impact_engine.c +++ b/tests/test_test_impact_engine.c @@ -122,14 +122,20 @@ static const char tie_lib[] = "int lib_value(void) {\n" " return 2;\n" "}\n"; -static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char *extra); +static bool tie_open_files(tie_fixture_t *fx, const char *const *extra, int extra_count); static bool tie_open(tie_fixture_t *fx) { - return tie_open_with(fx, NULL, NULL); + return tie_open_files(fx, NULL, 0); } /* The fixture, plus one more file in the base commit. */ static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char *extra) { + const char *const files[] = {extra_path, extra}; + return tie_open_files(fx, files, 1); +} + +/* The fixture, plus (path, content) pairs written over it in the base commit. */ +static bool tie_open_files(tie_fixture_t *fx, const char *const *extra, int extra_count) { memset(fx, 0, sizeof(*fx)); const char *git = cbm_find_cli("git", cbm_get_home_dir()); const char *home = th_mktempdir("cbm-ti-engine"); @@ -145,15 +151,18 @@ static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char const char *init[] = {"-c", "init.templateDir=", "init", "--quiet", "--initial-branch=main", NULL}; const char *topic[] = {"checkout", "--quiet", "-b", "topic", NULL}; - return th_mkdir_p(fx->repo) == 0 && th_mkdir_p(fx->work) == 0 && chmod(fx->work, 0700) == 0 && - tie_git(fx, init) && tie_write(fx, ".codebase-memory.json", tie_config) && - tie_write(fx, "tests/test_main.c", tie_main) && - tie_write(fx, "tests/test_alpha.c", tie_alpha) && - tie_write(fx, "tests/test_beta.c", tie_beta) && - tie_write(fx, "tests/test_matrix.c", tie_matrix) && - tie_write(fx, "src/lib.c", tie_lib) && tie_write(fx, "Makefile", "all:\n") && - (!extra_path || tie_write(fx, extra_path, extra)) && tie_commit(fx, "base") && - tie_git(fx, topic); + bool ok = th_mkdir_p(fx->repo) == 0 && th_mkdir_p(fx->work) == 0 && + chmod(fx->work, 0700) == 0 && tie_git(fx, init) && + tie_write(fx, ".codebase-memory.json", tie_config) && + tie_write(fx, "tests/test_main.c", tie_main) && + tie_write(fx, "tests/test_alpha.c", tie_alpha) && + tie_write(fx, "tests/test_beta.c", tie_beta) && + tie_write(fx, "tests/test_matrix.c", tie_matrix) && + tie_write(fx, "src/lib.c", tie_lib) && tie_write(fx, "Makefile", "all:\n"); + for (int i = 0; ok && i < extra_count; i++) { + ok = tie_write(fx, extra[2 * i], extra[2 * i + 1]); + } + return ok && tie_commit(fx, "base") && tie_git(fx, topic); } static void tie_close(tie_fixture_t *fx) { @@ -463,7 +472,96 @@ TEST(test_impact_engine_coverage_reaches_unmapped_code_through_mapped_callers) { PASS(); } +/* One test written once per #if branch is one graph node (graph_buffer.c + * "Definition variants"): the engine maps it as the graph counts it, never as + * an ambiguous name. */ +static const char tie_alpha_variants[] = "int lib_value(void);\n" + "#ifdef ALPHA_WIDE\n" + "TEST(alpha_uses_lib) {\n" + " return lib_value() == 1 ? 0 : 1;\n" + "}\n" + "#else\n" + "TEST(alpha_uses_lib) {\n" + " return lib_value() > 0 ? 0 : 1;\n" + "}\n" + "#endif\n" + "TEST(alpha_plain) {\n" + " return 0;\n" + "}\n" + "SUITE(alpha) {\n" + " RUN_TEST(alpha_uses_lib);\n" + " RUN_TEST(alpha_plain);\n" + "}\n"; + +TEST(test_impact_engine_variant_tests_are_one_test) { + tie_fixture_t fx; + ASSERT_TRUE(tie_open_with(&fx, "tests/test_alpha.c", tie_alpha_variants)); + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1 + 0;\n}\n" + "int lib_other(void) {\n return 2;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_value")); + char *json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + if (!tie_suite_has(json, "alpha", "STATIC") || tie_suite_has(json, "alpha", "UNMAPPED")) { + printf(" %.900s\n", json); + } + ASSERT_TRUE(tie_suite_has(json, "alpha", "STATIC")); + ASSERT_FALSE(tie_suite_has(json, "alpha", "UNMAPPED")); + free(json); + tie_close(&fx); + PASS(); +} + +/* A test that starts a program runs code no CALLS edge leads to; the program + * runs main, and main reaches everything. A change that reaches the product's + * main reaches the tests that spawn; one that does not, does not. */ +static const char tie_spawn_main[] = "int lib_value(void);\n" + "int main(void) {\n" + " return lib_value();\n" + "}\n"; +static const char tie_spawn_beta[] = "#include \n" + "TEST(beta_alone) {\n" + " return system(\"./app --version\");\n" + "}\n" + "SUITE(beta) {\n" + " RUN_TEST(beta_alone);\n" + "}\n"; + +TEST(test_impact_engine_spawning_tests_follow_main) { + const char *const extra[] = {"src/main.c", tie_spawn_main, "tests/test_beta.c", tie_spawn_beta}; + tie_fixture_t fx; + ASSERT_TRUE(tie_open_files(&fx, extra, 2)); + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1 + 0;\n}\n" + "int lib_other(void) {\n return 2;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_value")); + char *json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + if (!tie_suite_has(json, "beta", "STATIC")) { + printf(" %.900s\n", json); + } + ASSERT_TRUE(tie_suite_has(json, "alpha", "STATIC")); + ASSERT_TRUE(tie_suite_has(json, "beta", "STATIC")); + ASSERT_FALSE(tie_suite_has(json, "matrix", "STATIC")); + free(json); + + /* lib_other: main never calls it, so the spawning test is not reached. */ + ASSERT_TRUE(tie_write(&fx, "src/lib.c", + "int lib_value(void) {\n return 1;\n}\n" + "int lib_other(void) {\n return 2 + 0;\n}\n")); + ASSERT_TRUE(tie_commit(&fx, "change lib_other instead")); + json = tie_answer(&fx); + ASSERT_NOT_NULL(json); + ASSERT_TRUE(tie_suite_has(json, "matrix", "STATIC")); + ASSERT_FALSE(tie_suite_has(json, "beta", "STATIC")); + free(json); + tie_close(&fx); + PASS(); +} + SUITE(test_impact_engine) { + RUN_TEST(test_impact_engine_variant_tests_are_one_test); + RUN_TEST(test_impact_engine_spawning_tests_follow_main); RUN_TEST(test_impact_engine_reached_suites_carry_static); RUN_TEST(test_impact_engine_macro_registered_tests_select_their_suite); RUN_TEST(test_impact_engine_rules_unmapped_and_empty); diff --git a/tests/test_test_impact_seed.c b/tests/test_test_impact_seed.c index 97dd62b73..47f3c9c01 100644 --- a/tests/test_test_impact_seed.c +++ b/tests/test_test_impact_seed.c @@ -286,8 +286,93 @@ TEST(test_impact_seed_unclassified_header_lines_escalate_the_file) { PASS(); } +/* One definition with variants (graph_buffer.c "Definition variants") is one + * node holding one variant's span; the others are in its `variants` + * property, in the same file (#if branches) or in another file that DEFINES + * it too (platform files). A hunk in ANY variant seeds that node. */ +TEST(test_impact_seed_variant_spans_seed_their_definition) { + static const char pick_src[] = "#ifdef _WIN32\nvoid pick(void) {\n beta();\n}\n#else\n" + "void pick(void) {\n alpha();\n}\n#endif\n"; + const char *files[] = {"src/pick.c", pick_src, + "src/plat_linux.c", "void plat(void) {\n alpha();\n}\n", + "src/plat_win.c", "void plat(void) {\n beta();\n}\n"}; + tsd_fixture_t f; + ASSERT_TRUE(tsd_open(&f, files, 6)); + cbm_node_t pick = {.project = "p", + .label = "Function", + .name = "pick", + .qualified_name = "p.src.pick.pick", + .file_path = "src/pick.c", + .start_line = 2, + .end_line = 4, + .properties_json = "{\"variants\":[" + "{\"file_path\":\"src/pick.c\",\"start_line\":2," + "\"end_line\":4}," + "{\"file_path\":\"src/pick.c\",\"start_line\":6," + "\"end_line\":8}]}"}; + int64_t pick_id = cbm_store_upsert_node(f.store, &pick); + cbm_node_t plat = {.project = "p", + .label = "Function", + .name = "plat", + .qualified_name = "p.src.plat.plat", + .file_path = "src/plat_linux.c", + .start_line = 1, + .end_line = 3, + .properties_json = "{\"variants\":[" + "{\"file_path\":\"src/plat_linux.c\",\"start_line\":1," + "\"end_line\":3}," + "{\"file_path\":\"src/plat_win.c\",\"start_line\":1," + "\"end_line\":3}]}"}; + int64_t plat_id = cbm_store_upsert_node(f.store, &plat); + cbm_node_t win_file = {.project = "p", + .label = "File", + .name = "plat_win.c", + .qualified_name = "p.src.plat_win.c.__file__", + .file_path = "src/plat_win.c"}; + int64_t win_file_id = cbm_store_upsert_node(f.store, &win_file); + cbm_edge_t defines = { + .project = "p", .source_id = win_file_id, .target_id = plat_id, .type = "DEFINES"}; + ASSERT_TRUE(pick_id > 0 && plat_id > 0 && win_file_id > 0); + ASSERT_TRUE(cbm_store_insert_edge(f.store, &defines) > 0); + + /* The #else branch (lines 6-8). */ + static const char *const added[] = {" alpha2();"}; + static const char *const removed[] = {" alpha();"}; + cbm_diff_hunk_t branch = {.start = 7, + .count = 1, + .added = added, + .added_count = 1, + .removed = removed, + .removed_count = 1}; + cbm_ti_change_t change = {.path = "src/pick.c", .hunks = &branch, .hunk_count = 1}; + cbm_ti_seeds_t *s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 1); + ASSERT_TRUE(tsd_seeded(s, pick_id)); + cbm_ti_seeds_free(s); + + /* The Windows platform file, whose variant lives on plat_linux.c's node. */ + static const char *const win_added[] = {" beta2();"}; + static const char *const win_removed[] = {" beta();"}; + cbm_diff_hunk_t win = {.start = 2, + .count = 1, + .added = win_added, + .added_count = 1, + .removed = win_removed, + .removed_count = 1}; + cbm_ti_change_t win_change = {.path = "src/plat_win.c", .hunks = &win, .hunk_count = 1}; + s = tsd_seed(&f, &win_change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 1); + ASSERT_TRUE(tsd_seeded(s, plat_id)); + cbm_ti_seeds_free(s); + tsd_close(&f); + PASS(); +} + SUITE(test_impact_seed) { RUN_TEST(test_impact_seed_hunks_follow_the_reference); + RUN_TEST(test_impact_seed_variant_spans_seed_their_definition); RUN_TEST(test_impact_seed_changed_cases_and_suites_are_reported); RUN_TEST(test_impact_seed_deleted_names_are_found_by_text); RUN_TEST(test_impact_seed_header_names_propagate_through_includes_and_macros); From 6893b5277dd22b727266bce020fe92767ff5b4cd Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 02:47:47 +0200 Subject: [PATCH 28/44] fix(ci): hash the shard union portably verify-shard-union.sh piped the shard slices into sha256sum, which macOS runners do not ship. The new lane-gate contract cases run the script on every venue, so test-unix (macos-14) failed with "sha256sum: command not found" (GATE-QUALITY LOSS on a complete leg). Fall back to shasum -a 256, as run-tests-parallel.sh already does. Signed-off-by: Martin Vogel --- scripts/ci/verify-shard-union.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/verify-shard-union.sh b/scripts/ci/verify-shard-union.sh index 433d164bb..8e4acf931 100755 --- a/scripts/ci/verify-shard-union.sh +++ b/scripts/ci/verify-shard-union.sh @@ -92,7 +92,7 @@ for leg in $(grep -h '^leg=' $files | sort -u | sed 's/^leg=//'); do fi union_sha=$(for f in $leg_files; do sed -n '/^--- slice ---$/,$p' "$f" | tail -n +2 - done | sort | sha256sum | awk '{print $1}') + done | sort | { sha256sum 2>/dev/null || shasum -a 256; } | awk '{print $1}') if [ "$union_sha" != "$list_sha" ]; then echo "FAIL: $leg union of shard slices != full suite list (GATE-QUALITY LOSS)" >&2 rc=1 From a5c7ae9789bfdf4f34f4a420e4550e7ebb329327 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 03:19:26 +0200 Subject: [PATCH 29/44] fix(subprocess): a child that already exited is a finished run, not a failed spawn After posix_spawn the parent re-asserts the child's process group. A short command can exit before the parent gets there; macOS then answers ESRCH for the exited, unreaped child to both setpgid and getpgid, and the run was reported as SPAWN_FAILED (errno 3). macOS CI read `git write-tree` that way under load (macos-15-intel and the LeakSanitizer leg). The group was already set before exec, by the spawn attributes or by the child itself, so an exited child is accepted when waitid(WNOWAIT) shows it waiting to be reaped; the supervision then collects its real exit status. A test seam holds the parent until the child has exited, which makes that order deterministic: subprocess_run_child_exited_before_group_check is red without the fix (SPAWN_FAILED) and green with it, on macOS and Linux. The test file is also reflowed to the project's clang-format, as on main. Signed-off-by: Martin Vogel --- src/foundation/subprocess.c | 28 ++++++++++++- src/foundation/subprocess.h | 5 +++ tests/test_subprocess.c | 80 +++++++++++++++++++++++-------------- 3 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c index f53bb4067..7a900e73e 100644 --- a/src/foundation/subprocess.c +++ b/src/foundation/subprocess.c @@ -1120,6 +1120,10 @@ static int g_force_spawn_eagain = 0; void cbm_subprocess_force_spawn_eagain_for_testing(int attempts) { g_force_spawn_eagain = attempts > 0 ? attempts : 0; } +static int g_hold_until_exit = 0; +void cbm_subprocess_hold_parent_until_child_exits_for_testing(int spawns) { + g_hold_until_exit = spawns > 0 ? spawns : 0; +} int cbm_subprocess_pending_spawn_eagain_for_testing(void) { return g_force_spawn_eagain; } @@ -1396,6 +1400,14 @@ static int cbm_posix_spawn_apple(cbm_subprocess_t *process, int input, int outpu } #endif +/* The child exited and waits to be reaped (WNOWAIT leaves it for the + * supervision to collect with its exit status). */ +static bool cbm_posix_child_exited(pid_t pid) { + siginfo_t info; + memset(&info, 0, sizeof(info)); + return waitid(P_PID, (id_t)pid, &info, WEXITED | WNOHANG | WNOWAIT) == 0 && info.si_pid == pid; +} + static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { int input_flags = O_RDONLY; #ifdef O_CLOEXEC @@ -1522,12 +1534,24 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { (void)close(stdout_capture); } +#ifdef CBM_ENABLE_TEST_SEAMS + if (g_hold_until_exit > 0) { + g_hold_until_exit--; + siginfo_t held; + (void)waitid(P_PID, (id_t)pid, &held, WEXITED | WNOWAIT); + } +#endif /* Parent and child both establish the group, removing scheduler-order races. * If the child won and already execed, EACCES is accepted only after proving - * that its process group is the expected isolated one. */ + * that its process group is the expected isolated one. A child that already + * EXITED is a finished run, not a failed spawn: macOS no longer shows an + * unreaped child to setpgid/getpgid (ESRCH), and on a loaded machine a short + * command (`git write-tree`) finishes before the parent gets here. Its group + * was set before exec, by the spawn attributes or by the child itself; the + * supervision below reaps it with its real exit status. */ bool contained = setpgid(pid, pid) == 0; if (!contained && (errno == EACCES || errno == EPERM || errno == ESRCH)) { - contained = getpgid(pid) == pid; + contained = getpgid(pid) == pid || cbm_posix_child_exited(pid); } if (!contained) { (void)kill(pid, SIGKILL); diff --git a/src/foundation/subprocess.h b/src/foundation/subprocess.h index e6fd096bd..94f6f006e 100644 --- a/src/foundation/subprocess.h +++ b/src/foundation/subprocess.h @@ -206,6 +206,11 @@ bool cbm_build_win_cmd_payload(char *buf, size_t cap, const char *cmd_executable * of hoping a loaded machine reproduces it. Test builds only. */ void cbm_subprocess_force_spawn_eagain_for_testing(int attempts); int cbm_subprocess_pending_spawn_eagain_for_testing(void); +/* Hold the parent of the next N POSIX spawns until the child has exited (left + * unreaped) before it checks the child's process group: the order a loaded + * machine produces when a short command finishes before the parent runs + * again. Test builds only. */ +void cbm_subprocess_hold_parent_until_child_exits_for_testing(int spawns); #endif /* How the POSIX fork+exec child closed its inherited descriptors (#1484). */ diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index f80d42606..0ca7b0d39 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -137,6 +137,24 @@ TEST(subprocess_run_exit_nonzero) { #endif } +/* A short command can finish before its parent checks the child's process + * group; macOS then answers ESRCH for the exited, unreaped child. That is a + * finished run with its own exit status, never a spawn failure (macOS CI read + * `git write-tree` as SPAWN_FAILED errno 3 under load). The seam holds the + * parent until the child has exited: that order, deterministically. */ +TEST(subprocess_run_child_exited_before_group_check) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX process groups"); +#else + cbm_subprocess_hold_parent_until_child_exits_for_testing(1); + cbm_proc_result_t r = run_sh("exit 3", 0); + cbm_subprocess_hold_parent_until_child_exits_for_testing(0); + ASSERT_EQ(r.outcome, CBM_PROC_EXIT_NONZERO); + ASSERT_EQ(r.exit_code, 3); + PASS(); +#endif +} + /* Daemon background helpers intentionally invoke fixed tool names such as * `curl` and `git`. A shell-free spawn must still perform the normal PATH * lookup for a name without a directory separator; exact binary paths keep @@ -1844,8 +1862,7 @@ static bool sp_stdin_clean(int rc, const cbm_proc_result_t *result) { * may be destroyed, but a nonquiescent tree's fixture is intentionally retained. */ static bool sp_stdin_safe_run(int rc, const cbm_proc_result_t *result) { return result->tree_quiesced || - (rc == -1 && result->outcome == CBM_PROC_SPAWN_FAILED && - !result->supervision_failed); + (rc == -1 && result->outcome == CBM_PROC_SPAWN_FAILED && !result->supervision_failed); } static bool sp_stdin_finish(cbm_subprocess_t *process, cbm_proc_result_t *result) { @@ -1860,8 +1877,7 @@ static bool sp_stdin_finish(cbm_subprocess_t *process, cbm_proc_result_t *result } static int sp_stdin_run(cbm_proc_opts_t *opts, cbm_proc_result_t *result) { - const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "echo", "", - NULL}; + const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "echo", "", NULL}; opts->bin = sp_stdout_test_binary; opts->argv = argv; opts->quiet_timeout_ms = 10000; /* Finite containment guard, not a timing oracle. */ @@ -1884,12 +1900,10 @@ static sp_stdin_rejection_t sp_stdin_check_rejection(const char *binary, const c const char *valid_empty_input) { sp_stdin_rejection_t check = {.safe_cleanup = true}; (void)cbm_unlink(marker); - check.prepared = sp_stdin_write(out, sp_stdin_out_sentinel, - sizeof(sp_stdin_out_sentinel) - 1) && - sp_stdin_write(log, sp_stdin_log_sentinel, - sizeof(sp_stdin_log_sentinel) - 1) && - sp_stdout_file_equals(valid_empty_input, "", 0) && - !cbm_file_exists(marker); + check.prepared = + sp_stdin_write(out, sp_stdin_out_sentinel, sizeof(sp_stdin_out_sentinel) - 1) && + sp_stdin_write(log, sp_stdin_log_sentinel, sizeof(sp_stdin_log_sentinel) - 1) && + sp_stdout_file_equals(valid_empty_input, "", 0) && !cbm_file_exists(marker); if (!check.prepared) return check; const char *argv[] = {binary, "__cbm_subprocess_stdin_probe", "stamp", marker, NULL}; @@ -1922,8 +1936,7 @@ static sp_stdin_rejection_t sp_stdin_check_rejection(const char *binary, const c cbm_proc_result_t result = {0}; rc = cbm_subprocess_run(&opts, &result); check.safe_cleanup = sp_stdin_safe_run(rc, &result); - check.reusable = sp_stdin_clean(rc, &result) && - sp_stdout_file_equals(out, "", 0) && + check.reusable = sp_stdin_clean(rc, &result) && sp_stdout_file_equals(out, "", 0) && sp_stdout_file_equals(log, sp_stderr_text, sizeof(sp_stderr_text) - 1) && sp_stdout_file_equals(valid_empty_input, "", 0); return check; @@ -1938,16 +1951,15 @@ static int sp_stdin_probe(int argc, char **argv) { return 101; #endif if (argc == 8 && strcmp(argv[2], "reject") == 0) { - sp_stdin_rejection_t check = sp_stdin_check_rejection( - argv[0], argv[3], argv[4], argv[5], argv[6], argv[7]); + sp_stdin_rejection_t check = + sp_stdin_check_rejection(argv[0], argv[3], argv[4], argv[5], argv[6], argv[7]); if (!check.safe_cleanup) return 125; /* Parent must retain files if nested containment failed. */ if (!check.prepared) return 102; if (!check.reusable) return 103; - return check.rejected && check.no_handle && check.outputs_unchanged && - check.no_child_marker + return check.rejected && check.no_handle && check.outputs_unchanged && check.no_child_marker ? 0 : 104; } @@ -1977,8 +1989,7 @@ static int sp_stdin_probe(int argc, char **argv) { _lseek(cbm_fileno(stdin), 0, SEEK_SET) != 0) return 107; #else - if (read(cbm_fileno(stdin), &witness, 1) != 1 || - lseek(cbm_fileno(stdin), 0, SEEK_SET) != 0) + if (read(cbm_fileno(stdin), &witness, 1) != 1 || lseek(cbm_fileno(stdin), 0, SEEK_SET) != 0) return 107; #endif const char *child_argv[] = {argv[0], "__cbm_subprocess_stdin_probe", "echo", "", NULL}; @@ -2040,8 +2051,13 @@ TEST(subprocess_stdin_null_is_eof_even_with_readable_parent_input) { unsigned char input[SP_STDIN_BYTES]; sp_stdin_payload(input); bool prepared = sp_stdin_write(fx.other, input, sizeof(input)); - const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "parent-eof", - fx.other, fx.out, fx.log, NULL}; + const char *argv[] = {sp_stdout_test_binary, + "__cbm_subprocess_stdin_probe", + "parent-eof", + fx.other, + fx.out, + fx.log, + NULL}; cbm_proc_opts_t opts = {.bin = sp_stdout_test_binary, .argv = argv, .quiet_timeout_ms = 20000}; cbm_proc_result_t result = {0}; int rc = prepared ? cbm_subprocess_run(&opts, &result) : -1; @@ -2070,8 +2086,7 @@ TEST(subprocess_stdin_empty_regular_file_is_success) { cbm_proc_result_t result = {0}; int rc = prepared ? sp_stdin_run(&opts, &result) : -1; bool safe = !prepared || sp_stdin_safe_run(rc, &result); - bool exact = sp_stdout_file_equals(fx.out, "", 0) && - sp_stdout_file_equals(fx.other, "", 0) && + bool exact = sp_stdout_file_equals(fx.out, "", 0) && sp_stdout_file_equals(fx.other, "", 0) && sp_stdout_file_equals(fx.log, sp_stderr_text, sizeof(sp_stderr_text) - 1); int cleanup = safe ? th_rmtree(fx.root) : -1; ASSERT_EQ(cleanup, 0); @@ -2182,9 +2197,8 @@ TEST(subprocess_stdin_caller_path_mutation_before_gate_release) { bool safe = !process || sp_stdin_finish(process, &result); bool exact = sp_stdout_file_equals(fx.out, input, sizeof(input)); bool original_kept = sp_stdout_file_equals(fx.other, input, sizeof(input)); - bool alternate_kept = prepared && - sp_stdout_file_equals(alternate, alternate_bytes, - sizeof(alternate_bytes) - 1); + bool alternate_kept = + prepared && sp_stdout_file_equals(alternate, alternate_bytes, sizeof(alternate_bytes) - 1); int cleanup = safe ? th_rmtree(fx.root) : -1; ASSERT_EQ(cleanup, 0); ASSERT_TRUE(prepared); @@ -2202,8 +2216,8 @@ TEST(subprocess_stdin_rejects_invalid_paths_before_outputs) { sp_stdout_fixture_t fx; ASSERT_TRUE(sp_stdout_fixture_open(&fx)); char missing[SP_STDOUT_PATH]; - bool prepared = sp_stdout_path(missing, fx.root, "missing-input") && - sp_stdin_write(fx.other, "", 0); + bool prepared = + sp_stdout_path(missing, fx.root, "missing-input") && sp_stdin_write(fx.other, "", 0); const char *paths[] = {"", missing, fx.root}; sp_stdin_rejection_t checks[3] = {{0}}; bool safe = true; @@ -2274,8 +2288,15 @@ TEST(subprocess_stdin_posix_fifo_and_symlink_reject_without_blocking_runner) { bool safe = true; for (int i = 0; kinds && safe && i < 2; i++) { (void)cbm_unlink(fx.gate); - const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "reject", - paths[i], fx.out, fx.log, fx.gate, fx.other, NULL}; + const char *argv[] = {sp_stdout_test_binary, + "__cbm_subprocess_stdin_probe", + "reject", + paths[i], + fx.out, + fx.log, + fx.gate, + fx.other, + NULL}; /* The probe can itself block inside spawn on a broken blocking FIFO * open. Only that isolated process is then killed by this safety guard. * No elapsed-time threshold is used as evidence of correct rejection. */ @@ -2323,6 +2344,7 @@ SUITE(subprocess) { RUN_TEST(subprocess_outcome_str); RUN_TEST(subprocess_run_clean); RUN_TEST(subprocess_run_exit_nonzero); + RUN_TEST(subprocess_run_child_exited_before_group_check); RUN_TEST(subprocess_run_resolves_literal_binary_name_from_path); RUN_TEST(subprocess_run_crash_is_crash); RUN_TEST(subprocess_run_hang_is_hang); From 64999462b628d038764a8c2b2824a7d3244846b5 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 03:19:38 +0200 Subject: [PATCH 30/44] fix(test): the Windows leg runs the test-impact contracts, and its verdict cannot be faked The full Windows leg on the VM stopped at the coverage-map contract and was reported green. Two defects, one hiding the other: - The VM verdict counted the completion marker anywhere in the log. The isolated-venue contract's stubbed entry prints that marker, and a capacity case prints "expected=0 passed=True", so a leg that stopped at a later contract, with no suite run, read as complete. The marker now counts only as the log's last step header, which is where scripts/test.sh prints it; the verdict contract pins the observed shape (14 cases). - Under the native python3 the Windows legs install, the test-impact contracts failed: the coverage-map builder cannot start the leg's bash fakes, the scripts wrote CRLF, and cmp (diffutils) is not installed there. selection.py, coverage-merge.py and coverage-map.py now write LF on every host, so their outputs are byte-identical across platforms; the two cmp checks compare bytes in the shell; the builder's functional leg is skipped under a native Windows python with the reason and what was tried (the map is built only by the ubuntu publish job), while both pure-python parser tests still run there. Production was not affected by the CRLF: run-tests-parallel.sh strips CR from the selection and the runner trims its tokens. Signed-off-by: Martin Vogel --- scripts/test-impact/coverage-map.py | 6 +++--- scripts/test-impact/coverage-merge.py | 6 +++--- scripts/test-impact/selection.py | 7 +++++-- test-infrastructure/vm/vm-run-tests.sh | 8 +++++++- tests/test_coverage_map.sh | 18 +++++++++++++++--- tests/test_coverage_merge.sh | 5 ++++- tests/test_harness_test_only.sh | 4 +++- tests/test_vm_verdict_contract.sh | 15 ++++++++++++++- 8 files changed, 54 insertions(+), 15 deletions(-) diff --git a/scripts/test-impact/coverage-map.py b/scripts/test-impact/coverage-map.py index 1ef0c9290..b0a5f6771 100644 --- a/scripts/test-impact/coverage-map.py +++ b/scripts/test-impact/coverage-map.py @@ -323,10 +323,10 @@ def main(): if not args.keep_raw: shutil.rmtree(raw, ignore_errors=True) - with open(os.path.join(out, "functions.tsv"), "w", encoding="utf-8") as handle: + with open(os.path.join(out, "functions.tsv"), "w", encoding="utf-8", newline="\n") as handle: for ident, source, short in functions.rows(): handle.write(f"{ident}\t{source}\t{short}\n") - with open(os.path.join(out, "tests.tsv"), "w", encoding="utf-8") as handle: + with open(os.path.join(out, "tests.tsv"), "w", encoding="utf-8", newline="\n") as handle: handle.write("\n".join(test_rows) + "\n") meta = { "format": 1, @@ -339,7 +339,7 @@ def main(): "incomplete": sum(entry["incomplete"] for entry in suite_meta), "suites": suite_meta, } - with open(os.path.join(out, "meta.json"), "w", encoding="utf-8") as handle: + with open(os.path.join(out, "meta.json"), "w", encoding="utf-8", newline="\n") as handle: json.dump(meta, handle, indent=1) handle.write("\n") print(f"map: {meta['tests']} tests, {meta['incomplete']} incomplete, " diff --git a/scripts/test-impact/coverage-merge.py b/scripts/test-impact/coverage-merge.py index 1c4cfd7f9..0a49961f2 100644 --- a/scripts/test-impact/coverage-merge.py +++ b/scripts/test-impact/coverage-merge.py @@ -116,15 +116,15 @@ def main() -> int: suites.append(entry) os.makedirs(args.out, exist_ok=True) - with open(os.path.join(args.out, "functions.tsv"), "w", encoding="utf-8") as handle: + with open(os.path.join(args.out, "functions.tsv"), "w", encoding="utf-8", newline="\n") as handle: handle.write("".join(line + "\n" for line in fresh_lines)) - with open(os.path.join(args.out, "tests.tsv"), "w", encoding="utf-8") as handle: + with open(os.path.join(args.out, "tests.tsv"), "w", encoding="utf-8", newline="\n") as handle: for suite, test, status, reason, ids in merged: handle.write(f"{suite}:{test}\t{status}\t{reason}\t{' '.join(map(str, ids))}\n") meta = dict(fresh_meta) meta.update({"commit": args.commit, "tests": sum(c[0] for c in counts.values()), "incomplete": sum(c[1] for c in counts.values()), "suites": suites}) - with open(os.path.join(args.out, "meta.json"), "w", encoding="utf-8") as handle: + with open(os.path.join(args.out, "meta.json"), "w", encoding="utf-8", newline="\n") as handle: json.dump(meta, handle, indent=1) handle.write("\n") print(f"merged: {len(rerun)} suite(s) re-run, " diff --git a/scripts/test-impact/selection.py b/scripts/test-impact/selection.py index f4a8ff08b..2c0350ecf 100644 --- a/scripts/test-impact/selection.py +++ b/scripts/test-impact/selection.py @@ -106,6 +106,9 @@ def main(): f.add_argument("--optional-out") args = parser.parse_args() if args.command == "suites": + # LF on every host, like the files below: a Windows python would + # otherwise write CRLF. + sys.stdout.reconfigure(newline="\n") with open(args.answer, encoding="utf-8") as handle: print("\n".join(suites(json.load(handle)))) return 0 @@ -121,11 +124,11 @@ def main(): return run_all("the answer is not an object") code, lines = lane_filter(answer, args.lane) if code in (NARROWED, NOTHING): - with open(args.out, "w", encoding="utf-8") as handle: + with open(args.out, "w", encoding="utf-8", newline="\n") as handle: handle.write("".join(line + "\n" for line in lines)) if args.optional_out: optional = conditional_suites(answer, args.lane) if code == NARROWED else [] - with open(args.optional_out, "w", encoding="utf-8") as handle: + with open(args.optional_out, "w", encoding="utf-8", newline="\n") as handle: handle.write("".join(suite + "\n" for suite in optional)) return code diff --git a/test-infrastructure/vm/vm-run-tests.sh b/test-infrastructure/vm/vm-run-tests.sh index 57565bf6a..d3672d3bd 100644 --- a/test-infrastructure/vm/vm-run-tests.sh +++ b/test-infrastructure/vm/vm-run-tests.sh @@ -69,7 +69,13 @@ vm_verdict() { local complete failed_total=$(grep -Eo '[0-9]+ failed' "$log" | grep -Eo '^[0-9]+' | awk '{s += $1} END {print s + 0}') - complete=$(grep -c '=== All tests passed ===' "$log") + # The marker counts only as the last step header of the log: the full leg's + # own contract steps echo runner-shaped lines (a stubbed entry prints the + # marker), so a marker followed by another step is that step's output. + complete=0 + if [ "$(grep -E '^=== ' "$log" | tail -n 1 | tr -d '\r')" = '=== All tests passed ===' ]; then + complete=1 + fi if [ "${failed_total:-0}" -gt 0 ]; then echo "GUARD: the log reports $failed_total failed test(s) (runner rc=$rc)" >&2 return 1 diff --git a/tests/test_coverage_map.sh b/tests/test_coverage_map.sh index c91b61675..b2af5fd1b 100644 --- a/tests/test_coverage_map.sh +++ b/tests/test_coverage_map.sh @@ -37,6 +37,21 @@ fi # Strict LLVM text records retain zero-only identities and full counter values. PYTHONDONTWRITEBYTECODE=1 "${PYTHON}" "${ROOT}/tests/test_profile_text_parser.py" +# Pure single-image v2 producer. +PYTHONDONTWRITEBYTECODE=1 "${PYTHON}" "${ROOT}/tests/test_profile_map_v2.py" + +if "${PYTHON}" -c 'import sys; sys.exit(sys.platform != "win32")'; then + # WHY: the map is built only by the ubuntu-24.04 publish job + # (.github/workflows/test-impact-artifact.yml), and this leg's runner and + # LLVM tools are bash fakes. Tried: the leg under the native (mingw) python3 + # the Windows test legs install, on the Windows VM -- CreateProcess cannot + # start an extensionless shebang script, the builder stops at its tool + # check ("not found: .../bin/llvm-profdata", exit 2), and every assertion + # after it fails for that one reason, so the run proves nothing about how + # the publish job builds a map. + echo "SKIP: builder functional leg needs a POSIX python (native Windows python3); profile-text contract passed" + exit 0 +fi WORK="$(mktemp -d)" trap 'rm -rf "${WORK}"' EXIT @@ -213,6 +228,3 @@ if [[ "${FAILED}" -ne 0 ]]; then exit 1 fi echo "coverage map builder: all cases passed" - -# Pure single-image v2 producer; legacy parser and builder controls ran above. -PYTHONDONTWRITEBYTECODE=1 "${PYTHON}" "${ROOT}/tests/test_profile_map_v2.py" diff --git a/tests/test_coverage_merge.sh b/tests/test_coverage_merge.sh index 61c1bb818..62827f189 100644 --- a/tests/test_coverage_merge.sh +++ b/tests/test_coverage_merge.sh @@ -38,7 +38,10 @@ printf 'a\nb\n' > "$TMP/listed.txt" python3 "$MERGE" --previous "$TMP/previous" --fresh "$TMP/fresh" --suites "$TMP/listed.txt" \ --commit 3333333333333333333333333333333333333333 --out "$TMP/out" > /dev/null -cmp -s "$TMP/out/functions.tsv" "$TMP/fresh/functions.tsv" || fail "the function table is not the new image's" +# Byte-exact, trailing newlines included (no cmp: diffutils is not on the +# Windows legs). +[ "$(cat "$TMP/out/functions.tsv"; echo .)" = "$(cat "$TMP/fresh/functions.tsv"; echo .)" ] || + fail "the function table is not the new image's" expected="$(printf 'a:*\tcomplete\t\t\na:t1\tcomplete\t\t0 1\nb:*\tcomplete\t\t\nb:t2\tincomplete\tkilled child\t2\n')" [ "$(cat "$TMP/out/tests.tsv")" = "$expected" ] || fail "merged rows: $(cat "$TMP/out/tests.tsv")" python3 - "$TMP/out/meta.json" <<'EOF' || fail "merged metadata" diff --git a/tests/test_harness_test_only.sh b/tests/test_harness_test_only.sh index 9c6ed6635..a8c6d24ab 100644 --- a/tests/test_harness_test_only.sh +++ b/tests/test_harness_test_only.sh @@ -147,7 +147,9 @@ finish 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 +# Byte-exact, trailing newlines included (no cmp: diffutils is not on the +# Windows legs). +if [ "$(cat "${tmpdir}/unfiltered.out"; echo .)" != "$(cat "${OUT}"; echo .)" ]; then echo " FAIL ${CASE}: output differs from the unfiltered run" CASE_OK=0 fi diff --git a/tests/test_vm_verdict_contract.sh b/tests/test_vm_verdict_contract.sh index 275f865e8..0aa9b4d13 100755 --- a/tests/test_vm_verdict_contract.sh +++ b/tests/test_vm_verdict_contract.sh @@ -34,6 +34,15 @@ printf ' 7863 passed, 1 failed\n=== All tests passed ===\n' > "$WORK/red_with_m printf ' 100 passed, 0 failed\n 200 passed, 3 failed\n=== All tests passed ===\n' > "$WORK/red_later_suite" printf ' 7925 passed, 0 failed, 74 skipped\n' > "$WORK/no_marker" printf 'configure: something exploded\n' > "$WORK/no_summary" +# The full leg's own contract steps print runner-shaped lines: a stubbed +# canonical entry echoes the marker, a capacity case prints "expected=0 +# passed=True". On 2026-10-06 a leg that stopped at a later step with no suite +# run was reported green from exactly these lines. +printf '%s\n' '=== Step 0y2: isolated Windows venue contract ===' \ + 'canonical-test ' '=== All tests passed ===' \ + 'capacity_accept exit=0 expected=0 passed=True' \ + '=== Step 0z6: per-test coverage map builder contract ===' \ + 'FAIL: missing output: tests.tsv' > "$WORK/marker_before_last_step" failures=0 expect() { # description log rc want [mode] @@ -60,6 +69,10 @@ expect "failures in a LATER suite are still counted" red_later_suite 0 1 # Incomplete runs are never green: a leg that stopped after one suite has a # summary but no marker. expect "a summary without the completion marker is red" no_marker 0 1 +# The marker is the last thing the full leg prints; one followed by another +# step came from that step's output, not from the end of the leg. +expect "a marker before the last step is red with a lost rc" marker_before_last_step 1 1 +expect "a marker before the last step is red with rc 0" marker_before_last_step 0 1 # No summary at all keeps its own distinct code, so "never ran" stays # distinguishable from "ran and failed". @@ -82,4 +95,4 @@ if [ "$failures" -gt 0 ]; then echo "VM verdict contract VIOLATED: $failures case(s)" >&2 exit 1 fi -echo "VM verdict contract passed (12 cases: lost exit status, false green, partial runs, iteration mode)" +echo "VM verdict contract passed (14 cases: lost exit status, false green, partial runs, iteration mode)" From 8a531cd398bfad96cd71006d2670379bcad6bcac Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 03:36:33 +0200 Subject: [PATCH 31/44] fix(test): a failed Windows preflight contract leaves its reason in the leg log The isolated-venue contract ran the native preflight contract under `set -e`, so a failure ended the script before its log was printed, and the directory it announced as retained is swept by the next win.sh call's preflight. A full Windows leg failed there once (not reproduced: three runs under the same protected temp root passed) and left nothing to attribute. The native log is now always printed, and on failure each case's output too, with PowerShell's UTF-16 bytes dropped so the log stays text. The VM verdict also reads the log as text (grep -a): a NUL or a UTF-16 byte-order mark made grep call the log binary and print no matches with -o, so failures after such bytes were counted as zero -- an iteration-mode run reporting "2 failed" with a lost exit status was green. The verdict contract pins that case (15 cases). Signed-off-by: Martin Vogel --- test-infrastructure/vm/vm-run-tests.sh | 8 +++++--- tests/test_vm_isolation_contract.sh | 19 +++++++++++++++++-- tests/test_vm_verdict_contract.sh | 8 +++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/test-infrastructure/vm/vm-run-tests.sh b/test-infrastructure/vm/vm-run-tests.sh index d3672d3bd..ee45b7539 100644 --- a/test-infrastructure/vm/vm-run-tests.sh +++ b/test-infrastructure/vm/vm-run-tests.sh @@ -60,20 +60,22 @@ vm_verdict() { local log="$1" local rc="$2" local mode="${3:-full}" - if ! grep -Eq '[0-9]+ passed' "$log"; then + # -a: a NUL or UTF-16 byte anywhere makes grep call the log binary and + # print no matches, which would count its failures as zero. + if ! grep -aEq '[0-9]+ passed' "$log"; then echo "GUARD: test runner produced no completion summary — the suites did" \ "not validly run; treating as failure (runner rc=$rc)" >&2 return 90 fi local failed_total local complete - failed_total=$(grep -Eo '[0-9]+ failed' "$log" | grep -Eo '^[0-9]+' | + failed_total=$(grep -aEo '[0-9]+ failed' "$log" | grep -Eo '^[0-9]+' | awk '{s += $1} END {print s + 0}') # The marker counts only as the last step header of the log: the full leg's # own contract steps echo runner-shaped lines (a stubbed entry prints the # marker), so a marker followed by another step is that step's output. complete=0 - if [ "$(grep -E '^=== ' "$log" | tail -n 1 | tr -d '\r')" = '=== All tests passed ===' ]; then + if [ "$(grep -aE '^=== ' "$log" | tail -n 1 | tr -d '\r')" = '=== All tests passed ===' ]; then complete=1 fi if [ "${failed_total:-0}" -gt 0 ]; then diff --git a/tests/test_vm_isolation_contract.sh b/tests/test_vm_isolation_contract.sh index 9c7f5236f..e1fc49144 100644 --- a/tests/test_vm_isolation_contract.sh +++ b/tests/test_vm_isolation_contract.sh @@ -16,14 +16,29 @@ env -u CBM_VM_RUNNER -u CBM_VM_SOAK_BINARY bash \ "$task_repo/tests/test_vm_isolated_dispatch.sh" "$task_repo" "$task_contract_dir/dispatch" case "$(uname -s)" in MINGW* | MSYS* | CYGWIN*) + # The log is printed before the verdict: the retained directory does not + # outlive the next win.sh call (its preflight sweeps cbm-* roots), so the + # leg's own log is the only post-mortem a failure leaves. + task_native_rc=0 MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoLogo -NoProfile -NonInteractive \ -ExecutionPolicy Bypass \ -File "$(cygpath -w "$task_repo/tests/test_windows_preflight_contract.ps1")" \ -ScriptPath "$(cygpath -w "$task_repo/scripts/ci/clean-test-residue.ps1")" \ -OutputDirectory "$(cygpath -w "$task_contract_dir/native")" \ - > "$task_contract_dir/native.log" 2>&1 + > "$task_contract_dir/native.log" 2>&1 || task_native_rc=$? cat "$task_contract_dir/native.log" - grep -Fq 'WINDOWS_CHECK_ONLY_COMPLETE failures=0 sentinels=2' "$task_contract_dir/native.log" + if [ "$task_native_rc" -ne 0 ] || + ! grep -Fq 'WINDOWS_CHECK_ONLY_COMPLETE failures=0 sentinels=2' "$task_contract_dir/native.log"; then + echo "native preflight contract failed (exit $task_native_rc); per-case output:" >&2 + for task_case_file in "$task_contract_dir"/native/*.out "$task_contract_dir"/native/*.err; do + [ -f "$task_case_file" ] || continue + echo "--- ${task_case_file##*/}" >&2 + # PowerShell 5.1 redirects in UTF-16LE: drop the NULs and the + # byte-order mark, which would make the leg log read as binary. + LC_ALL=C tr -d '\000\376\377' <"$task_case_file" >&2 + done + exit 1 + fi ;; *) # Native PowerShell/DACL behavior is covered by this same contract on the diff --git a/tests/test_vm_verdict_contract.sh b/tests/test_vm_verdict_contract.sh index 0aa9b4d13..835797a25 100755 --- a/tests/test_vm_verdict_contract.sh +++ b/tests/test_vm_verdict_contract.sh @@ -43,6 +43,10 @@ printf '%s\n' '=== Step 0y2: isolated Windows venue contract ===' \ 'capacity_accept exit=0 expected=0 passed=True' \ '=== Step 0z6: per-test coverage map builder contract ===' \ 'FAIL: missing output: tests.tsv' > "$WORK/marker_before_last_step" +# A NUL or a UTF-16 byte-order mark anywhere (PowerShell 5.1 redirects in +# UTF-16LE) makes grep call the log binary and print no matches with -o: the +# failures after it were counted as zero. +printf ' 10 passed, 0 failed\n\377\376b\000o\000m\000\n 5 passed, 2 failed\n' > "$WORK/binary_bytes" failures=0 expect() { # description log rc want [mode] @@ -73,6 +77,8 @@ expect "a summary without the completion marker is red" no_marker 0 1 # step came from that step's output, not from the end of the leg. expect "a marker before the last step is red with a lost rc" marker_before_last_step 1 1 expect "a marker before the last step is red with rc 0" marker_before_last_step 0 1 +# Bytes grep calls binary must not hide the failures after them. +expect "failures after binary bytes are red in iteration mode" binary_bytes 0 1 iteration # No summary at all keeps its own distinct code, so "never ran" stays # distinguishable from "ran and failed". @@ -95,4 +101,4 @@ if [ "$failures" -gt 0 ]; then echo "VM verdict contract VIOLATED: $failures case(s)" >&2 exit 1 fi -echo "VM verdict contract passed (14 cases: lost exit status, false green, partial runs, iteration mode)" +echo "VM verdict contract passed (15 cases: lost exit status, false green, partial runs, iteration mode)" From 8887437e6d537c66a69d2cdf6f8f904a6d41afb8 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 03:53:49 +0200 Subject: [PATCH 32/44] fix(test): build the test-impact suites on Windows Two Windows-only compile errors, hidden until now because the Windows leg stopped at its contract steps before building the runner: - test_test_impact.c calls cbm_find_cli under _WIN32 without including cli/cli.h (an implicit declaration, an error in clang). - test_test_impact_engine.c canonicalizes the fixture's temp dir with realpath, which Windows does not have. It now uses _fullpath there, with forward slashes like the paths the fixture builds from it, the same split test_daemon_ipc.c makes. Signed-off-by: Martin Vogel --- tests/test_test_impact.c | 1 + tests/test_test_impact_engine.c | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_test_impact.c b/tests/test_test_impact.c index 35869dafd..c88bdf909 100644 --- a/tests/test_test_impact.c +++ b/tests/test_test_impact.c @@ -8,6 +8,7 @@ */ #include "test_framework.h" #include "test_helpers.h" +#include #include #include #include diff --git a/tests/test_test_impact_engine.c b/tests/test_test_impact_engine.c index 02ce23b79..535b4aa20 100644 --- a/tests/test_test_impact_engine.c +++ b/tests/test_test_impact_engine.c @@ -134,13 +134,24 @@ static bool tie_open_with(tie_fixture_t *fx, const char *extra_path, const char return tie_open_files(fx, files, 1); } +/* A directory's canonical form: realpath on POSIX (macOS temp dirs sit behind + * the /var -> /private/var link), _fullpath on Windows, which has no realpath, + * with forward slashes like every path the fixture builds from it. */ +static bool tie_full_path(char out[PATH_MAX], const char *path) { +#ifdef _WIN32 + return path && _fullpath(out, path, PATH_MAX) != NULL && cbm_normalize_path_sep(out) != NULL; +#else + return path && realpath(path, out) != NULL; +#endif +} + /* The fixture, plus (path, content) pairs written over it in the base commit. */ static bool tie_open_files(tie_fixture_t *fx, const char *const *extra, int extra_count) { memset(fx, 0, sizeof(*fx)); const char *git = cbm_find_cli("git", cbm_get_home_dir()); const char *home = th_mktempdir("cbm-ti-engine"); char real[PATH_MAX]; - if (!git || !home || !realpath(home, real) || strlen(git) >= sizeof(fx->git)) { + if (!git || !home || !tie_full_path(real, home) || strlen(git) >= sizeof(fx->git)) { return false; } snprintf(fx->git, sizeof(fx->git), "%s", git); From bf7ed74f7fe445720d104cf07f1db29d21b81618 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 05:58:38 +0200 Subject: [PATCH 33/44] fix(test): start the runner again by its image path, not argv[0] The subprocess capture probes, the runner-filter tests and the git-facts diff probe start (or copy) the test runner itself, by argv[0]. On Windows that is the caller's spelling: the native python scheduler (run-test-wave.py) finds the runner by search and passes it without ".exe", and an exact spawn or copy of that path fails. Started that way, 12 subprocess tests failed and the runner-filter tests with them; started with ".exe", the same build passed. tf_runner_image() returns the module path on Windows (cbm_module_path_utf8) and argv[0] elsewhere; the three sites use it. Signed-off-by: Martin Vogel --- tests/test_main.c | 25 ++++++++++++++++++++++++- tests/test_subprocess.c | 3 ++- tests/test_test_impact_git.c | 3 ++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/test_main.c b/tests/test_main.c index 6ede15bb7..0aa3bebc2 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -42,6 +42,7 @@ int tf_deselected_count = 0; #include #include /* #798 follow-up: socket-isolation re-exec probe */ #include +#include "foundation/win_utf8.h" /* cbm_module_path_utf8 — the runner's own image */ #else #include #ifdef __APPLE__ @@ -1891,11 +1892,33 @@ extern int tf_maybe_run_subprocess_stdout_probe(int argc, char **argv); extern void tf_test_impact_runner_filter_set_binary(const char *path); extern int tf_maybe_run_git_facts_diff_probe(int argc, char **argv); +/* The runner's own image, for the tests that start or copy it again. argv[0] + * is the caller's spelling, and a Windows parent that found the runner by + * search (the native python scheduler, scripts/run-test-wave.py) passes it + * without ".exe", which an exact spawn or copy of that path cannot open. */ +const char *tf_runner_image(int argc, char **argv); +const char *tf_runner_image(int argc, char **argv) { +#ifdef _WIN32 + static char image[4096]; + if (!image[0]) { + char *module = cbm_module_path_utf8(); + if (module && strlen(module) < sizeof(image)) { + memcpy(image, module, strlen(module) + 1); + } + free(module); + } + if (image[0]) { + return image; + } +#endif + return argc > 0 && argv ? argv[0] : NULL; +} + int main(int argc, char **argv) { /* #2003: never let a caller's GIT_DIR/GIT_INDEX_FILE/... redirect fixture * git commands at the caller's real repository. */ th_clear_git_repo_env(); - tf_test_impact_runner_filter_set_binary(argc > 0 && argv ? argv[0] : NULL); + tf_test_impact_runner_filter_set_binary(tf_runner_image(argc, argv)); #ifdef CBM_TEST_COVERAGE tf_coverage_process_init(); #ifndef _WIN32 diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index 0ca7b0d39..8df9fe92f 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -1507,12 +1507,13 @@ static void sp_stdout_payload(unsigned char bytes[SP_STDOUT_BYTES]) { /* Called before normal harness setup. Normal entry remembers argv[0], whose * storage lasts through main; the private exact argv grammar never runs suites. */ static int sp_stdin_probe(int argc, char **argv); +const char *tf_runner_image(int argc, char **argv); /* test_main.c */ int tf_maybe_run_subprocess_stdout_probe(int argc, char **argv); int tf_maybe_run_subprocess_stdout_probe(int argc, char **argv) { if (argc >= 2 && argv && strcmp(argv[1], "__cbm_subprocess_stdin_probe") == 0) return sp_stdin_probe(argc, argv); if (argc < 2 || !argv || strcmp(argv[1], "__cbm_subprocess_stdout_probe") != 0) { - sp_stdout_test_binary = argc > 0 && argv ? argv[0] : NULL; + sp_stdout_test_binary = tf_runner_image(argc, argv); return -1; } if (argc != 4 || (strcmp(argv[2], "binary") != 0 && strcmp(argv[2], "text") != 0 && diff --git a/tests/test_test_impact_git.c b/tests/test_test_impact_git.c index ed8483de5..5828a99e4 100644 --- a/tests/test_test_impact_git.c +++ b/tests/test_test_impact_git.c @@ -53,6 +53,7 @@ static bool gf_write(const char *path, const void *bytes, size_t length) { /* Native executable fixture for Git's optional diff/textconv command. Extra * arguments added by Git are deliberately ignored. Normal harness entry keeps * argv[0] for a private executable copy; neither mode changes the environment. */ +const char *tf_runner_image(int argc, char **argv); /* test_main.c */ int tf_maybe_run_git_facts_diff_probe(int argc, char **argv); int tf_maybe_run_git_facts_diff_probe(int argc, char **argv) { if (argc >= 2 && argv && strcmp(argv[1], "__cbm_git_facts_diff_probe") == 0) { @@ -74,7 +75,7 @@ int tf_maybe_run_git_facts_diff_probe(int argc, char **argv) { } argv[1] = "test_impact_git"; } - gf_runner_binary = argc > 0 && argv ? argv[0] : NULL; + gf_runner_binary = tf_runner_image(argc, argv); return -1; } From 2cbd25dabfe2d0cca863b8d2039d32e1647e838f Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 05:58:39 +0200 Subject: [PATCH 34/44] fix(test-impact): read MSYS2 and Cygwin git's metadata paths on Windows Git facts ask `git rev-parse --path-format=absolute` for the top level, the git directory and the common directory, and use them for native file checks (grafts, shallow). MSYS2 git -- the git the Windows legs install -- answers in its own namespace (/c/Users/...), which is not a Windows absolute path, so every git-facts open failed with "Git metadata path is not absolute" and the engine ran everything (41 test_impact_git failures). The drive form maps one to one onto C:/Users/... (Cygwin: /cygdrive/c/...), and is translated. Any other path of that namespace (/tmp, /usr) needs the runtime's mount table and is still refused. Signed-off-by: Martin Vogel --- src/mcp/test_impact_git.c | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/mcp/test_impact_git.c b/src/mcp/test_impact_git.c index b30a933d7..4a48f60a4 100644 --- a/src/mcp/test_impact_git.c +++ b/src/mcp/test_impact_git.c @@ -715,6 +715,22 @@ static bool gf_resolve(cbm_git_facts_t *facts, const char *ref, char out[65], gf_oid_line(facts, &result.out, out, error); } +#ifdef _WIN32 +/* MSYS2 and Cygwin git print absolute paths in their own namespace: + * /c/Users/x (Cygwin: /cygdrive/c/Users/x). The drive form maps one to one + * onto C:/Users/x, which the native file checks below need. Any other path of + * that namespace (/tmp, /usr) needs the runtime's mount table, so it stays + * unmapped and is refused as not absolute. */ +static const char *gf_native_metadata_path(cbm_git_facts_t *facts, const char *path) { + const char *drive = strncmp(path, "/cygdrive/", 10) == 0 ? path + 9 : path; + bool letter = (drive[1] >= 'A' && drive[1] <= 'Z') || (drive[1] >= 'a' && drive[1] <= 'z'); + if (drive[0] != '/' || !letter || (drive[2] != '/' && drive[2] != '\0')) + return path; + char upper = (char)(drive[1] >= 'a' ? drive[1] - 'a' + 'A' : drive[1]); + return cbm_arena_sprintf(&facts->arena, "%c:/%s", upper, drive[2] ? drive + 3 : ""); +} +#endif + static bool gf_rev_metadata(cbm_git_facts_t *facts, const char *option, bool absolute, const char **out, cbm_git_facts_error_t *error) { const char *args[] = {"rev-parse", "--path-format=absolute", option}; @@ -722,6 +738,10 @@ static bool gf_rev_metadata(cbm_git_facts_t *facts, const char *option, bool abs if (!gf_run(facts, args, 3, false, &result, error) || !gf_owned_line(facts, &result.out, out, error)) return false; +#ifdef _WIN32 + if (absolute && !(*out = gf_native_metadata_path(facts, *out))) + return gf_error(error, CBM_GIT_FACTS_OOM, "Cannot retain Git metadata path"); +#endif if (absolute && !gf_absolute(*out)) return gf_error(error, CBM_GIT_FACTS_COMMAND, "Git metadata path is not absolute"); return true; From 4f709c27077634398c833363494cf705be413cce Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 05:58:39 +0200 Subject: [PATCH 35/44] fix(test-impact): accept the owner-only DACL as Windows stores it The pinned tree requires its private parent to be owner-only: a protected DACL, the current user as owner, one full-access grant to that user. It accepted only the single-entry encoding (OI|CI). cbm_mkdir_p stamps a fresh directory with exactly that grant, but written with generic rights, and Windows stores it as an effective entry plus an inherit-only one (icacls: user:(F) and user:(OI)(CI)(IO)(F)). The work directory production creates was therefore refused ("pinning HEAD failed: protected owner-only DACL policy mismatch"), and every Windows selection ran everything. Both encodings are now accepted: one or two entries, each an allow entry for the current user with full access and only inheritance flags, at least one applying to the object itself. Any other principal, deny entry, partial mask or flag is still refused. tree_e_windows_split_owner_dacl_is_owner_only creates the parent with cbm_mkdir_p and pins on it (red before this change), pins on the split form written explicitly, and is refused once the inherit-only entry names Everyone. Signed-off-by: Martin Vogel --- src/mcp/test_impact_tree_windows_acl.c | 57 +++++++++++++++------- tests/test_test_impact_tree.c | 65 ++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/src/mcp/test_impact_tree_windows_acl.c b/src/mcp/test_impact_tree_windows_acl.c index 93aa1725c..833f6f0d1 100644 --- a/src/mcp/test_impact_tree_windows_acl.c +++ b/src/mcp/test_impact_tree_windows_acl.c @@ -59,6 +59,33 @@ bool tpt_win_security(tpt_context *c) { return true; } +/* One DACL entry of the owner-only policy: an allow entry for the current + * user with full access and no flag but inheritance; 1 yes, 0 no, -1 broken. */ +static int tpt_owner_ace(PACL dacl, DWORD index, PSID sid, bool *effective) { + void *raw = NULL; + if (!GetAce(dacl, index, &raw)) + return -1; + ACCESS_ALLOWED_ACE *ace = raw; + if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE) + return 0; + size_t prefix = offsetof(ACCESS_ALLOWED_ACE, SidStart); + if (ace->Header.AceSize < prefix + 8) + return -1; + BYTE inherit = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE | INHERIT_ONLY_ACE; + if ((ace->Header.AceFlags & ~inherit) || + (ace->Mask != FILE_ALL_ACCESS && ace->Mask != GENERIC_ALL)) + return 0; + const SID *principal = (const SID *)&ace->SidStart; + size_t sid_size = 8 + (size_t)principal->SubAuthorityCount * sizeof(DWORD); + if (sid_size > ace->Header.AceSize - prefix || !IsValidSid(&ace->SidStart)) + return -1; + if (!EqualSid(&ace->SidStart, sid)) + return 0; + if (!(ace->Header.AceFlags & INHERIT_ONLY_ACE)) + *effective = true; + return 1; +} + static int tpt_owner_acl(cbm_pinned_tree_t *t, PSECURITY_DESCRIPTOR sd) { PSID owner = NULL; PACL dacl = NULL; @@ -80,25 +107,19 @@ static int tpt_owner_acl(cbm_pinned_tree_t *t, PSECURITY_DESCRIPTOR sd) { ACL_SIZE_INFORMATION info; if (!GetAclInformation(dacl, &info, sizeof(info), AclSizeInformation)) return -1; - if (info.AceCount != 1) + /* Windows stores one inheritable full-access grant either as a single + * OI|CI entry or, when it was written with generic rights (cbm_mkdir_p's + * owner stamp), as an effective entry plus an inherit-only one. Both are + * the owner-only policy; at least one entry must apply to the object. */ + if (info.AceCount < 1 || info.AceCount > 2) return 0; - void *raw = NULL; - if (!GetAce(dacl, 0, &raw)) - return -1; - ACCESS_ALLOWED_ACE *ace = raw; - if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE) - return 0; - size_t prefix = offsetof(ACCESS_ALLOWED_ACE, SidStart); - if (ace->Header.AceSize < prefix + 8) - return -1; - if ((ace->Header.AceFlags & ~(OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE)) || - (ace->Mask != FILE_ALL_ACCESS && ace->Mask != GENERIC_ALL)) - return 0; - const SID *principal = (const SID *)&ace->SidStart; - size_t sid_size = 8 + (size_t)principal->SubAuthorityCount * sizeof(DWORD); - if (sid_size > ace->Header.AceSize - prefix || !IsValidSid(&ace->SidStart)) - return -1; - return EqualSid(&ace->SidStart, sid) ? 1 : 0; + bool effective = false; + for (DWORD i = 0; i < info.AceCount; i++) { + int allowed = tpt_owner_ace(dacl, i, sid, &effective); + if (allowed != 1) + return allowed; + } + return effective ? 1 : 0; } bool tpt_native_acl(tpt_context *c, tpt_object *o, unsigned mode, bool changed) { diff --git a/tests/test_test_impact_tree.c b/tests/test_test_impact_tree.c index 2a77026b6..8e8e62e25 100644 --- a/tests/test_test_impact_tree.c +++ b/tests/test_test_impact_tree.c @@ -223,6 +223,31 @@ static bool pt_windows_acl(pt_fixture *fx, const char *path, bool extra) { &descriptor); } +/* The encoding Windows stores for an inheritable generic grant: an effective + * full-access entry for the owner plus an inherit-only one, here naming + * `inherited` (the owner, or another principal for the negative). */ +static bool pt_windows_split_acl(pt_fixture *fx, const char *path, PSID inherited) { + PSID owner = pt_current_sid(fx); + wchar_t wide[PT_PATH]; + unsigned char acl_bytes[1024]; + ACL *acl = (ACL *)acl_bytes; + SECURITY_DESCRIPTOR descriptor; + return owner && inherited && pt_wide(path, wide) && + InitializeAcl(acl, sizeof(acl_bytes), ACL_REVISION) && + AddAccessAllowedAceEx(acl, ACL_REVISION, 0, FILE_ALL_ACCESS, owner) && + AddAccessAllowedAceEx(acl, ACL_REVISION, + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE | INHERIT_ONLY_ACE, + FILE_ALL_ACCESS, inherited) && + InitializeSecurityDescriptor(&descriptor, SECURITY_DESCRIPTOR_REVISION) && + SetSecurityDescriptorOwner(&descriptor, owner, FALSE) && + SetSecurityDescriptorDacl(&descriptor, TRUE, acl, FALSE) && + SetSecurityDescriptorControl(&descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED) && + SetFileSecurityW(wide, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION | + PROTECTED_DACL_SECURITY_INFORMATION, + &descriptor); +} + static bool pt_owner_is_current(PSID owner) { HANDLE token = NULL; union { @@ -1771,6 +1796,45 @@ done: return pt_finish(&fx, result); } +/* The work parent production makes is owner-only: cbm_mkdir_p stamps a fresh + * directory with one inheritable grant for the owner, which Windows stores as + * an effective entry plus an inherit-only one. The policy refused that form, + * so the engine on Windows ran everything (pinning HEAD failed). The same + * split naming another principal is still refused. */ +TEST(tree_e_windows_split_owner_dacl_is_owner_only) { +#ifndef _WIN32 + SKIP_PLATFORM("Win32 DACL encoding"); +#else + pt_fixture fx = {0}; + int result = 1; + char stamped[PT_PATH] = {0}; + unsigned char world[SECURITY_MAX_SID_SIZE]; + DWORD world_length = sizeof(world); + PT_CHECK(pt_setup_step("setup:init", pt_init(&fx, 40)) && + pt_setup_step("setup:populate", pt_populate(&fx)) && + pt_setup_step("setup:facts", pt_open_facts(&fx)) && + pt_setup_step("setup:control", pt_fact_control(&fx))); + PT_CHECK(pt_join(stamped, fx.home, "stamped-parent") && cbm_mkdir_p(stamped, 0700)); + cbm_pinned_tree_options_t options = pt_options(&fx, CBM_GIT_REV_HEAD); + options.private_parent = stamped; + cbm_pinned_tree_error_t error; + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_close_one(&fx.tree)); + PT_CHECK(pt_windows_split_acl(&fx, stamped, pt_current_sid(&fx))); + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_OK); + PT_CHECK(pt_close_one(&fx.tree)); + PT_CHECK(CreateWellKnownSid(WinWorldSid, NULL, world, &world_length)); + PT_CHECK(pt_windows_split_acl(&fx, stamped, world)); + PT_CHECK(cbm_pinned_tree_create(&options, &fx.tree, &error) == CBM_PINNED_TREE_UNSUPPORTED && + !fx.tree); + result = 0; +done: + if (stamped[0] && !pt_windows_split_acl(&fx, stamped, pt_current_sid(&fx))) + result = 1; + return pt_finish(&fx, result); +#endif +} + typedef struct { size_t calls, stop; } pt_cancel; @@ -2536,6 +2600,7 @@ SUITE(test_impact_tree) { RUN_TEST(tree_c_complete_inventory_and_native_names); RUN_TEST(tree_d_native_collision_and_exact_spelling); RUN_TEST(tree_e_native_permissions_and_acl_policy); + RUN_TEST(tree_e_windows_split_owner_dacl_is_owner_only); RUN_TEST(tree_f_resource_limits_and_local_control); RUN_TEST(tree_g_verify_state_and_complete_inventory); RUN_TEST(tree_h_cleanup_owner_and_unrelated_resources); From bb1f6238e7e3470bce038269274067ef96f0e98c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 05:58:39 +0200 Subject: [PATCH 36/44] fix(test-impact): report a Windows case collision as a collision A tree with a file `A` and a directory `a` cannot be materialized on a case-insensitive volume. On Windows, creating the file over the existing directory answers ERROR_ACCESS_DENIED, not ERROR_FILE_EXISTS, so the pinned tree reported an I/O failure where tree_d_native_collision_and_exact_spelling expects CBM_PINNED_TREE_COLLISION (observed: error 5 on the create). An access-denied create whose attempted name is present is now the same collision; one whose name is absent stays an I/O failure. Signed-off-by: Martin Vogel --- src/mcp/test_impact_tree_windows.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/mcp/test_impact_tree_windows.c b/src/mcp/test_impact_tree_windows.c index 617aa42bb..ba0c089a2 100644 --- a/src/mcp/test_impact_tree_windows.c +++ b/src/mcp/test_impact_tree_windows.c @@ -2,7 +2,13 @@ #ifdef _WIN32 bool tpt_win_error(tpt_context *c, DWORD e, bool creating) { - if (creating && (e == ERROR_ALREADY_EXISTS || e == ERROR_FILE_EXISTS)) + /* Creating a file over a directory of the same (case-folded) name answers + * ERROR_ACCESS_DENIED rather than "exists"; with the attempted name + * present it is the same collision. Both create sites leave that name in + * wide_path. */ + bool present = creating && e == ERROR_ACCESS_DENIED && + GetFileAttributesW(c->tree->wide_path) != INVALID_FILE_ATTRIBUTES; + if (creating && (e == ERROR_ALREADY_EXISTS || e == ERROR_FILE_EXISTS || present)) return tpt_fail(c, CBM_PINNED_TREE_COLLISION, "exclusive native name already exists"); if (e == ERROR_NOT_ENOUGH_MEMORY || e == ERROR_OUTOFMEMORY) return tpt_fail(c, CBM_PINNED_TREE_OOM, "native allocation failed"); From ac80a6a6a8cbac0ab49b69dc24ab1188c091575e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 18:07:16 +0200 Subject: [PATCH 37/44] fix(spawns): keep the security audit's system( pattern out of a comment scripts/security-audit.sh blocks any `[^a-z_]system(` in a source file that is not on the allow-list, comments included. A comment in pass_spawns.c spelled a C call of `system(...)`, so security-static failed on the SPAWNS pass, which never calls it. The comment now names the library function without the call syntax; no allow-list entry, since there is no call. Signed-off-by: Martin Vogel --- src/pipeline/pass_spawns.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipeline/pass_spawns.c b/src/pipeline/pass_spawns.c index 062ea4039..7c0dff192 100644 --- a/src/pipeline/pass_spawns.c +++ b/src/pipeline/pass_spawns.c @@ -22,7 +22,7 @@ static const double SPAWN_CONF_EXPANDED = 0.5; /* Strategies that only guess by name: they never make a spawn spelling an * ordinary call (`subprocess.run` must not become the project's `run`, nor - * a C `system(...)` the static `system` of another file). */ + * a call of the C library's `system` the static `system` of another file). */ static bool spawn_name_guess(const char *strategy) { static const char *const guesses[] = {"unique_name", "suffix_match", "qualified_suffix", "field_type_hint", "fuzzy", NULL}; From a0c4ca253f5c23106c9948dc663226b02d26eed3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 18:07:16 +0200 Subject: [PATCH 38/44] fix(test): keep the profile-map golden files byte-exact on Windows tests/test_profile_map_v2.py compares the producer's LF output with tests/fixtures/profile_map_v2/*.tsv byte for byte. The hosted Windows runners check out with core.autocrlf=true, so the fixtures arrived with CRLF and ten ProfileMapV2Tests failed on test-windows (x86_64). The fixtures now carry -text, as the profile-text captures already do (whose contract passed on the same runner). The test had never run on Windows before: it sat behind the coverage-map builder leg, which stopped first. Signed-off-by: Martin Vogel --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitattributes b/.gitattributes index e3a07d711..d474291f3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,3 +19,6 @@ tools/tree-sitter-chialisp/test/corpus/comments.txt -text # llvm-profdata text captures: tests/test_profile_text_parser.py checks their # SHA-256, so they keep their exact bytes on every platform (no eol conversion). tests/fixtures/profile_text/*.proftext -text +# Golden profile-map outputs: tests/test_profile_map_v2.py compares the +# producer's LF bytes against them, so they keep their exact bytes too. +tests/fixtures/profile_map_v2/*.tsv -text From fdd500f1ebba7c4c5affcd5e4d904e70e4c2ebb2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 18:07:16 +0200 Subject: [PATCH 39/44] test(inventory): name the failing stage of the native inventory fixture inventory_a_native_head_verified_controls failed once on macos-15-intel CI (`if_dependency_control() && if_native_start(&n) && if_native_dependency(&n)`) and left nothing to attribute: every failure path of the native fixture returned false silently. It does not reproduce on Apple Silicon (40/40 green). The three steps are now separate checks, and each silent path prints one line: the Git spawn failure with errno, the facts and inventory status with their diagnostic, a differing inventory or view row, and a short prefix read. No verdict changes. Signed-off-by: Martin Vogel --- tests/test_inventory_filter_native.c | 4 ++- tests/test_inventory_filter_native_fixture.c | 36 +++++++++++++++----- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/tests/test_inventory_filter_native.c b/tests/test_inventory_filter_native.c index d0e539466..6e4191fb0 100644 --- a/tests/test_inventory_filter_native.c +++ b/tests/test_inventory_filter_native.c @@ -6,7 +6,9 @@ int if_case_native(void) { if_native n = {0}; int result = 1; - IF_CHECK(if_dependency_control() && if_native_start(&n) && if_native_dependency(&n)); + IF_CHECK(if_dependency_control()); + IF_CHECK(if_native_start(&n)); + IF_CHECK(if_native_dependency(&n)); /* Dependency witnesses finish before the first new-feature assertion. */ IF_CHECK(if_prepare(&n.input)); const if_row rows[] = { diff --git a/tests/test_inventory_filter_native_fixture.c b/tests/test_inventory_filter_native_fixture.c index 43dd4ba9c..c7e7073f2 100644 --- a/tests/test_inventory_filter_native_fixture.c +++ b/tests/test_inventory_filter_native_fixture.c @@ -22,8 +22,11 @@ static bool if_native_write(const char *path, const void *bytes, size_t length) } static bool if_native_run(if_native *n, const cbm_proc_opts_t *options) { cbm_subprocess_t *process = NULL; - if (cbm_subprocess_spawn(options, &process) != 0) + if (cbm_subprocess_spawn(options, &process) != 0) { + fprintf(stderr, "inventory native Git spawn failed errno=%d (%s)\n", errno, + options->argv[5] ? options->argv[5] : "?"); return false; + } uint64_t deadline = cbm_now_ms() + 60000; bool cancelled = false; cbm_proc_result_t result = {0}; @@ -175,19 +178,27 @@ bool if_native_start(if_native *n) { n->unquiesced = true; const cbm_git_facts_identity_t *identity = cbm_git_facts_identity(n->facts); if (!identity || error.status != CBM_GIT_FACTS_OK || strcmp(identity->head, n->head) || - strcmp(identity->merge_base, n->head)) + strcmp(identity->merge_base, n->head)) { + fprintf(stderr, "inventory native facts status=%d identity=%d diagnostic=%s\n", + error.status, identity != NULL, error.diagnostic); return false; + } cbm_git_tree_inventory_t inventory; if (!cbm_git_facts_inventory(n->facts, CBM_GIT_REV_HEAD, &inventory, &error) || inventory.count != n->input.source.file_count) { if (error.status == CBM_GIT_FACTS_SUPERVISION) n->unquiesced = true; + fprintf(stderr, "inventory native inventory status=%d diagnostic=%s\n", error.status, + error.diagnostic); return false; } for (size_t i = 0; i < inventory.count; i++) if (strcmp(inventory.entries[i].path, n->input.paths[i]) || - strcmp(inventory.entries[i].oid, n->input.files[i].oid)) + strcmp(inventory.entries[i].oid, n->input.files[i].oid)) { + fprintf(stderr, "inventory native inventory row %zu differs: %s\n", i, + inventory.entries[i].path); return false; + } return if_native_tree(n); } bool if_native_tree(if_native *n) { @@ -213,12 +224,17 @@ bool if_native_tree(if_native *n) { } const cbm_pinned_tree_view_t *v = cbm_pinned_tree_view(n->input.tree); if (!v || v->revision != CBM_GIT_REV_HEAD || strcmp(v->commit, n->head) || - v->file_count != n->input.source.file_count) + v->file_count != n->input.source.file_count) { + fprintf(stderr, "inventory native view differs: files=%zu\n", v ? v->file_count : 0); return false; + } for (size_t i = 0; i < v->file_count; i++) { if (strcmp((const char *)v->files[i].path, n->input.paths[i]) || - memcmp(v->files[i].content_sha256, n->input.files[i].content_sha256, 32)) + memcmp(v->files[i].content_sha256, n->input.files[i].content_sha256, 32)) { + fprintf(stderr, "inventory native view row %zu differs: %s\n", i, + (const char *)v->files[i].path); return false; + } cbm_inventory_file_t *f = &n->input.files[i]; f->git_mode = v->files[i].git_mode; memcpy(f->oid, v->files[i].oid, 65); @@ -238,10 +254,14 @@ bool if_native_dependency(if_native *n) { size_t copied = SIZE_MAX; cbm_pinned_tree_error_t error; cbm_pinned_tree_control_t control = {.deadline_ms = UINT64_MAX}; - if (cbm_pinned_tree_read_prefix(n->input.tree, i, 16384, bytes, sizeof(bytes), &copied, - &control, &error) != CBM_PINNED_TREE_OK || - copied != n->input.files[i].content_length || memcmp(bytes, n->input.bytes[i], copied)) + cbm_pinned_tree_status_t read = cbm_pinned_tree_read_prefix( + n->input.tree, i, 16384, bytes, sizeof(bytes), &copied, &control, &error); + if (read != CBM_PINNED_TREE_OK || copied != n->input.files[i].content_length || + memcmp(bytes, n->input.bytes[i], copied)) { + fprintf(stderr, "inventory native read %zu status=%d copied=%zu want=%llu\n", i, read, + copied, (unsigned long long)n->input.files[i].content_length); return false; + } } const cbm_pinned_tree_view_t *view = cbm_pinned_tree_view(n->input.tree); return view && view->file_count == 10; From dd598ef4262454763503974bdbcac1c53783b83b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 18:07:16 +0200 Subject: [PATCH 40/44] fix(test-impact): a changed code line is never a comment line Seeding drops a hunk whose lines are all comments. The check took any line starting with `*` for a block-comment line and any line ending in a block comment's close for a comment, so a dereference (`*out = f(a, b, true);`) and code with a trailing comment counted as comments and their edits seeded nothing. On the oracle, PR #2158 changed two `*out_* = extract_route_path_from_args(...)` lines in scan_route_annotations. Both hunks were dropped, the function was never a seed, and its coverage id never reached the changed set. The six artifact_reconcile_* tests that execute it, recorded as complete coverage rows of the merge-base map, were missed (6 per job, two jobs: the only misses among 191 jobs). With the check fixed, all six are selected for COVERAGE again; until now the new graph walk had reached them as STATIC only by chance. A line now counts as a comment only when it is nothing but comment: blank, `//`, a block opened with nothing after its close, `*` followed by whitespace or the end, a lone close, or the closing line of a block opened earlier. `*name`, `**`, `*(` and code before a trailing comment are code. When unsure the check says code, which only seeds more. Signed-off-by: Martin Vogel --- src/mcp/test_impact_source.c | 49 +++++++++++++++++++++++++---- tests/test_test_impact_seed.c | 56 +++++++++++++++++++++++++++++++++ tests/test_test_impact_source.c | 40 ++++++++++++++++++++++- 3 files changed, 138 insertions(+), 7 deletions(-) diff --git a/src/mcp/test_impact_source.c b/src/mcp/test_impact_source.c index 25a1ffca8..6fb66dfeb 100644 --- a/src/mcp/test_impact_source.c +++ b/src/mcp/test_impact_source.c @@ -127,17 +127,54 @@ bool cbm_ti_is_keyword(const char *word, size_t len) { /* ── Line patterns ─────────────────────────────────────────────────── */ -/* `^\s*(//.*|/\*.*|\*.*|.*\*\/\s*)?$` */ +/* The offset of the first "*" + "/" in s[from..to), or `to`. */ +static size_t ti_comment_close(const char *s, size_t from, size_t to) { + for (size_t i = from; i + 1 < to; i++) { + if (s[i] == '*' && s[i + 1] == '/') { + return i; + } + } + return to; +} + +/* A line that is nothing but comment: blank; `//`; a block opened here with + * nothing after its first close; a block-comment line (`*` then whitespace or + * the end); a close with nothing after it; or the closing line of a block + * opened earlier (ends in the close, opens none). Anything else is code: a + * line starting `*name`, `**` or `*(` dereferences, and code before a + * trailing comment is a change. The reference pattern + * `^\s*(//.*|/\*.*|\*.*|.*\*\/\s*)?$` took both for comments, and a PR's + * `*out = f(..., true);` edits seeded nothing. A line-local view cannot tell + * `* text` from a multiplication continuation; the project's format keeps + * binary operators at line ends, so that spelling stays a comment line. */ bool cbm_ti_line_is_comment(const char *s, size_t n) { size_t i = ti_skip_space(s, 0, n); - if (i == n || s[i] == '*') { - return true; - } - if (s[i] == '/' && i + 1 < n && (s[i + 1] == '/' || s[i + 1] == '*')) { + if (i == n) { return true; } size_t e = ti_trim_end(s, i, n); - return e - i >= 2 && s[e - 2] == '*' && s[e - 1] == '/'; + if (s[i] == '/' && i + 1 < e && s[i + 1] == '/') { + return true; + } + if (s[i] == '/' && i + 1 < e && s[i + 1] == '*') { + size_t close = ti_comment_close(s, i + 2, e); + return close == e || close + 2 == e; + } + if (s[i] == '*') { + if (i + 1 < e && s[i + 1] == '/') { + return i + 2 == e; + } + return i + 1 == e || ti_space(s[i + 1]); + } + if (e - i >= 2 && s[e - 2] == '*' && s[e - 1] == '/') { + for (size_t k = i; k + 1 < e; k++) { + if (s[k] == '/' && s[k + 1] == '*') { + return false; + } + } + return true; + } + return false; } /* `^\s*#\s*(if|ifdef|ifndef|elif|else|endif|undef|include)\b` */ diff --git a/tests/test_test_impact_seed.c b/tests/test_test_impact_seed.c index 47f3c9c01..9ac19dc29 100644 --- a/tests/test_test_impact_seed.c +++ b/tests/test_test_impact_seed.c @@ -153,6 +153,61 @@ TEST(test_impact_seed_hunks_follow_the_reference) { PASS(); } +/* A line that starts with a dereference is code, and so is code with a + * trailing comment: each such change seeds its definition. The comment + * filter read `*out = f(...)` as a block-comment line, so a PR's two real + * edits in scan_route_annotations seeded nothing, and six tests that execute + * it were not selected (oracle job pr2158). A comment-only edit still + * seeds nothing. */ +TEST(test_impact_seed_dereference_and_trailing_comment_lines_are_code) { + const char *files[] = {"src/a.c", "void f(int *out) {\n *out = g(1);\n}\n" + "int h(void) {\n return 1; /* one */\n}\n"}; + tsd_fixture_t f; + ASSERT_TRUE(tsd_open(&f, files, 2)); + int64_t fn = tsd_node(&f, "Function", "f", "src/a.c", 1, 3); + int64_t h = tsd_node(&f, "Function", "h", "src/a.c", 4, 6); + + static const char *const deref[] = {" *out = g(1, true);"}; + static const char *const old_deref[] = {" *out = g(1);"}; + cbm_diff_hunk_t edit = {.start = 2, + .count = 1, + .added = deref, + .added_count = 1, + .removed = old_deref, + .removed_count = 1}; + cbm_ti_change_t change = {.path = "src/a.c", .hunks = &edit, .hunk_count = 1}; + cbm_ti_seeds_t *s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 1); + ASSERT_TRUE(tsd_seeded(s, fn)); + cbm_ti_seeds_free(s); + + static const char *const value[] = {" return 2; /* one */"}; + static const char *const old_value[] = {" return 1; /* one */"}; + cbm_diff_hunk_t trailing = {.start = 5, + .count = 1, + .added = value, + .added_count = 1, + .removed = old_value, + .removed_count = 1}; + change.hunks = &trailing; + s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 1); + ASSERT_TRUE(tsd_seeded(s, h)); + cbm_ti_seeds_free(s); + + static const char *const note[] = {" * why the dereference stays"}; + cbm_diff_hunk_t comment = {.start = 2, .count = 1, .added = note, .added_count = 1}; + change.hunks = &comment; + s = tsd_seed(&f, &change, 1); + ASSERT_NOT_NULL(s); + ASSERT_EQ(tsd_seed_count(s), 0); + cbm_ti_seeds_free(s); + tsd_close(&f); + PASS(); +} + /* A changed test case or suite body is reported, never seeded. */ TEST(test_impact_seed_changed_cases_and_suites_are_reported) { const char *files[] = {"tests/t.c", "TEST(alpha) {\n PASS();\n}\n" @@ -372,6 +427,7 @@ TEST(test_impact_seed_variant_spans_seed_their_definition) { SUITE(test_impact_seed) { RUN_TEST(test_impact_seed_hunks_follow_the_reference); + RUN_TEST(test_impact_seed_dereference_and_trailing_comment_lines_are_code); RUN_TEST(test_impact_seed_variant_spans_seed_their_definition); RUN_TEST(test_impact_seed_changed_cases_and_suites_are_reported); RUN_TEST(test_impact_seed_deleted_names_are_found_by_text); diff --git a/tests/test_test_impact_source.c b/tests/test_test_impact_source.c index e76cd0d38..7c7c4002c 100644 --- a/tests/test_test_impact_source.c +++ b/tests/test_test_impact_source.c @@ -33,7 +33,8 @@ static const tis_line_t tis_lines[] = { {"// x", true, false, "", "", "", "", "", ""}, {" /* x", true, false, "", "", "", "", "", ""}, {" * x", true, false, "", "", "", "", "", ""}, - {"int x; /* c */", true, false, "", "", "", "", "", ""}, + /* Code with a trailing comment is code (the reference called it a comment). */ + {"int x; /* c */", false, false, "", "", "", "", "", ""}, {"int x;", false, false, "", "", "", "", "", ""}, {"x */ ", true, false, "", "", "", "", "", ""}, {"*/", true, false, "", "", "", "", "", ""}, @@ -136,6 +137,42 @@ TEST(test_impact_source_line_patterns_match_the_reference) { PASS(); } +/* Only a line that is nothing but comment counts as one: a change to any + * other line is a change to code. A dereference at the start of a line read + * as a block-comment line once and dropped a real edit's seed. */ +TEST(test_impact_source_only_whole_comment_lines_are_comments) { + static const struct { + const char *line; + bool comment; + } cases[] = { + {" *out_jax_path = extract_route_path_from_args(a, args, source, true);", + false}, + {"*out = 1;", false}, + {" **pp = q;", false}, + {" *(p + 1) = 0;", false}, + {" x = f(); /* why */", false}, + {"/* a */ x = 1;", false}, + {"*/ x = 1;", false}, + {" * a block-comment line", true}, + {" *", true}, + {" */", true}, + {"\t*\tindented */", true}, + {"/* whole */", true}, + {"/** doc", true}, + {" end of the comment */", true}, + {"// line", true}, + {"", true}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + if (cbm_ti_line_is_comment(cases[i].line, strlen(cases[i].line)) != cases[i].comment) { + printf(" comment-line case %zu: \"%s\" expected %d\n", i, cases[i].line, + cases[i].comment); + FAIL("a line was classified wrongly"); + } + } + PASS(); +} + /* The declaration of a changed header line tries define, typedef, tag, * extern, prototype in that order. */ TEST(test_impact_source_declaration_tries_the_patterns_in_order) { @@ -316,6 +353,7 @@ TEST(test_impact_source_lines_split_like_the_reference) { SUITE(test_impact_source) { RUN_TEST(test_impact_source_line_patterns_match_the_reference); + RUN_TEST(test_impact_source_only_whole_comment_lines_are_comments); RUN_TEST(test_impact_source_declaration_tries_the_patterns_in_order); RUN_TEST(test_impact_source_macros_carry_body_names_and_pastes); RUN_TEST(test_impact_source_paste_scan_follows_the_reference); From 35605c5f0ceaf7014577a025e699946f1b473803 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 19:57:37 +0200 Subject: [PATCH 41/44] test(test-impact): name the failing Git facts call in the blob-batch fixtures macos-15-intel CI fails a different Git-backed fixture on each run of this branch (spawn ESRCH on 6a29a19c, the native inventory fixture on bb1f6238, two test_impact_git blob-batch checks on dd598ef4: shallow_before and the budget variants' controls), and these left nothing to attribute: the facts open, the inventory control and the blob read returned false silently. They pass on Apple Silicon, Linux and Windows. The open, inventory and blob-batch failures of these fixtures now print their stage and the Git facts error (status, exit code, diagnostic). No verdict changes. Signed-off-by: Martin Vogel --- tests/test_test_impact_git.c | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/test_test_impact_git.c b/tests/test_test_impact_git.c index 5828a99e4..304e3a8bb 100644 --- a/tests/test_test_impact_git.c +++ b/tests/test_test_impact_git.c @@ -1839,13 +1839,28 @@ static bool gfb_matches(gfb_fixture_t *f, cbm_git_revision_t rev, const size_t * return true; } +/* A failed Git facts call names its stage and error: these fixtures fail + * intermittently on macos-15-intel CI only, and a bare ASSERT left nothing to + * attribute. */ +static void gfb_report(const char *stage, const cbm_git_facts_error_t *e) { + fprintf(stderr, "Git facts %s failed: status=%d exit=%d diagnostic=%.300s\n", stage, e->status, + e->exit_code, e->diagnostic); +} + static bool gfb_inventory_control(gfb_fixture_t *f, cbm_git_facts_t *facts) { cbm_git_facts_error_t error; + memset(&error, 0, sizeof(error)); cbm_git_tree_inventory_t inventory={0}; const cbm_git_facts_identity_t *id=cbm_git_facts_identity(facts); - return id && strcmp(id->merge_base,f->git.q)==0 && strcmp(id->merge_base,f->git.p)!=0 && - cbm_git_facts_inventory(facts,CBM_GIT_REV_HEAD,&inventory,&error) && - gfi_matches(&inventory,f->expected,GFB_COUNT); + if (!id || strcmp(id->merge_base,f->git.q)!=0 || strcmp(id->merge_base,f->git.p)==0) { + fprintf(stderr, "Git facts identity control failed: identity=%d\n", id != NULL); + return false; + } + if (!cbm_git_facts_inventory(facts,CBM_GIT_REV_HEAD,&inventory,&error)) { + gfb_report("inventory", &error); + return false; + } + return gfi_matches(&inventory,f->expected,GFB_COUNT); } static bool gfb_good(gfb_fixture_t *f, cbm_git_facts_t *facts, cbm_git_revision_t rev, @@ -1854,8 +1869,10 @@ static bool gfb_good(gfb_fixture_t *f, cbm_git_facts_t *facts, cbm_git_revision_ cbm_git_blob_batch_request_t request={rev,indices,count}; cbm_git_blob_batch_t out=gfb_poisoned(); cbm_git_facts_error_t error; memset(&error,0xa5,sizeof(error)); - bool ok=cbm_git_facts_read_blob_batch(facts,&request,limits,&out,&error) && - gfa_clean_error(&error) && gfb_matches(f,rev,indices,count,&out); + bool read=cbm_git_facts_read_blob_batch(facts,&request,limits,&out,&error); + if (!read || !gfa_clean_error(&error)) + gfb_report(read ? "blob batch (unclean error)" : "blob batch", &error); + bool ok=read && gfa_clean_error(&error) && gfb_matches(f,rev,indices,count,&out); if (keep) *keep=out; return ok; } @@ -1929,6 +1946,7 @@ TEST(test_git_blob_batch_pinned_revisions_and_cache_history_guards) { bool restored=removed && gf_ref(&f.git,"refs/heads/topic",f.git.head) && gf_ref(&f.git,"refs/heads/base",f.git.base); opts=gf_options(&f.git);facts=restored?cbm_git_facts_open(&opts,&error):NULL; + if (restored && !facts) gfb_report("open (shallow control)", &error); bool shallow_before=facts && gfb_good(&f,facts,CBM_GIT_REV_HEAD,&index,1,&limits,NULL); n=snprintf(graft,sizeof(graft),"%s\n",f.git.q); bool shallow_written=restored && n>0 && (size_t)n Date: Tue, 6 Oct 2026 21:42:18 +0200 Subject: [PATCH 42/44] fix(subprocess): a child that is exiting at the group check is a finished run macOS answers ESRCH to setpgid and getpgid for our own unreaped child from the moment it starts to exit, before waitid can report it. a5c7ae97 accepted a child that had already exited, probed with waitid(WNOHANG|WNOWAIT); in the window before the child is reapable that probe said "not exited", and the spawn was reported as SPAWN_FAILED errno 3. macos-15-intel CI kept hitting it in a different Git-backed fixture on each run; with the new diagnostics it named itself: `git config --local core.protectNTFS false`, outcome=5 errno=3. A child of ours that setpgid/getpgid cannot find is not running: it has exited or is exiting. The parent now waits until it is reapable (WNOWAIT leaves it for the supervision, which reaps it with its real exit status); the wait ends with the child's exit. EACCES/EPERM keep the group check. A test seam makes the parent observe the exiting state (ESRCH, not yet reapable): subprocess_run_child_exiting_at_group_check is SPAWN_FAILED without this change and exit status 3 with it. Signed-off-by: Martin Vogel --- src/foundation/subprocess.c | 55 ++++++++++++++++++++++++++++--------- src/foundation/subprocess.h | 4 +++ tests/test_subprocess.c | 18 ++++++++++++ 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c index 7a900e73e..80daeccf2 100644 --- a/src/foundation/subprocess.c +++ b/src/foundation/subprocess.c @@ -1124,6 +1124,10 @@ static int g_hold_until_exit = 0; void cbm_subprocess_hold_parent_until_child_exits_for_testing(int spawns) { g_hold_until_exit = spawns > 0 ? spawns : 0; } +static int g_observe_exiting = 0; +void cbm_subprocess_observe_exiting_child_for_testing(int spawns) { + g_observe_exiting = spawns > 0 ? spawns : 0; +} int cbm_subprocess_pending_spawn_eagain_for_testing(void) { return g_force_spawn_eagain; } @@ -1400,12 +1404,22 @@ static int cbm_posix_spawn_apple(cbm_subprocess_t *process, int input, int outpu } #endif -/* The child exited and waits to be reaped (WNOWAIT leaves it for the - * supervision to collect with its exit status). */ -static bool cbm_posix_child_exited(pid_t pid) { - siginfo_t info; - memset(&info, 0, sizeof(info)); - return waitid(P_PID, (id_t)pid, &info, WEXITED | WNOHANG | WNOWAIT) == 0 && info.si_pid == pid; +/* Our own unreaped child that setpgid/getpgid cannot find (ESRCH) is not + * running: it has exited, or is exiting -- macOS stops finding a child as it + * starts to exit, before waitid can report it. Wait until it is reapable; + * the wait ends with its exit, and WNOWAIT leaves it for the supervision to + * collect with its exit status. */ +static bool cbm_posix_child_reapable(pid_t pid) { + for (;;) { + siginfo_t info; + memset(&info, 0, sizeof(info)); + if (waitid(P_PID, (id_t)pid, &info, WEXITED | WNOWAIT) == 0) { + return info.si_pid == pid; + } + if (errno != EINTR) { + return false; + } + } } static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { @@ -1543,15 +1557,30 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { #endif /* Parent and child both establish the group, removing scheduler-order races. * If the child won and already execed, EACCES is accepted only after proving - * that its process group is the expected isolated one. A child that already - * EXITED is a finished run, not a failed spawn: macOS no longer shows an - * unreaped child to setpgid/getpgid (ESRCH), and on a loaded machine a short - * command (`git write-tree`) finishes before the parent gets here. Its group - * was set before exec, by the spawn attributes or by the child itself; the + * that its process group is the expected isolated one. A child that has + * EXITED, or is exiting, is a finished run, not a failed spawn: macOS no + * longer shows such an unreaped child to setpgid/getpgid (ESRCH), and on a + * loaded machine a short command (`git config`) gets there before the + * parent does. Its group was set before exec, by the spawn attributes or by + * the child itself; the parent waits until it is reapable, and the * supervision below reaps it with its real exit status. */ - bool contained = setpgid(pid, pid) == 0; + bool observe_exiting = false; +#ifdef CBM_ENABLE_TEST_SEAMS + if (g_observe_exiting > 0) { + g_observe_exiting--; + observe_exiting = true; + } +#endif + bool contained = !observe_exiting && setpgid(pid, pid) == 0; + if (observe_exiting) { + errno = ESRCH; + } if (!contained && (errno == EACCES || errno == EPERM || errno == ESRCH)) { - contained = getpgid(pid) == pid || cbm_posix_child_exited(pid); + bool missing = errno == ESRCH; + contained = !observe_exiting && getpgid(pid) == pid; + if (!contained && (missing || errno == ESRCH)) { + contained = cbm_posix_child_reapable(pid); + } } if (!contained) { (void)kill(pid, SIGKILL); diff --git a/src/foundation/subprocess.h b/src/foundation/subprocess.h index 94f6f006e..5d482d5ba 100644 --- a/src/foundation/subprocess.h +++ b/src/foundation/subprocess.h @@ -211,6 +211,10 @@ int cbm_subprocess_pending_spawn_eagain_for_testing(void); * machine produces when a short command finishes before the parent runs * again. Test builds only. */ void cbm_subprocess_hold_parent_until_child_exits_for_testing(int spawns); +/* For the next N POSIX spawns, the parent's group check observes what macOS + * shows for a child that is EXITING: setpgid and getpgid answer ESRCH while + * waitid does not report it yet. Test builds only. */ +void cbm_subprocess_observe_exiting_child_for_testing(int spawns); #endif /* How the POSIX fork+exec child closed its inherited descriptors (#1484). */ diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index 8df9fe92f..41dc4d4fa 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -155,6 +155,23 @@ TEST(subprocess_run_child_exited_before_group_check) { #endif } +/* The window before that: macOS answers ESRCH for a child that has started + * exiting, before waitid can report it. A non-blocking "has it exited?" + * probe said no there, and macos-15-intel CI read a short `git config` as + * SPAWN_FAILED errno 3. The seam makes the parent observe exactly that. */ +TEST(subprocess_run_child_exiting_at_group_check) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX process groups"); +#else + cbm_subprocess_observe_exiting_child_for_testing(1); + cbm_proc_result_t r = run_sh("exit 3", 0); + cbm_subprocess_observe_exiting_child_for_testing(0); + ASSERT_EQ(r.outcome, CBM_PROC_EXIT_NONZERO); + ASSERT_EQ(r.exit_code, 3); + PASS(); +#endif +} + /* Daemon background helpers intentionally invoke fixed tool names such as * `curl` and `git`. A shell-free spawn must still perform the normal PATH * lookup for a name without a directory separator; exact binary paths keep @@ -2346,6 +2363,7 @@ SUITE(subprocess) { RUN_TEST(subprocess_run_clean); RUN_TEST(subprocess_run_exit_nonzero); RUN_TEST(subprocess_run_child_exited_before_group_check); + RUN_TEST(subprocess_run_child_exiting_at_group_check); RUN_TEST(subprocess_run_resolves_literal_binary_name_from_path); RUN_TEST(subprocess_run_crash_is_crash); RUN_TEST(subprocess_run_hang_is_hang); From 962d5b9c4f8c445526be79ad440f614ff5e51332 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 22:22:26 +0200 Subject: [PATCH 43/44] ci: re-sync PR #2458 after a dropped push event A GitHub incident (pull requests and webhooks degraded, 2026-10-06 ~19:57Z) dropped the pull_request event for e775c7f5: the branch moved, the PR head did not, and no PR run started. This empty commit is a new push for the PR to pick up; it changes no file. Signed-off-by: Martin Vogel From a2242e10f8df80059063edbf17cb96ca3cfb7cca Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 6 Oct 2026 23:54:42 +0200 Subject: [PATCH 44/44] test(test-impact): the Git fixture waits for Git, no quiet timeout decides The Git facts fixture ran every Git command with a 10 s quiet timeout. The diff-driver controls run `git diff --ext-diff` / `--textconv` with a fresh copy of the test runner as the driver; on a loaded host that copy takes over 10 s to start and finish, Git prints nothing meanwhile, and the timeout killed it (outcome HANG). test-windows (x86_64) failed test_git_ancestor_changes_history_and_external_driver_guards_apply that way (controls[0]), and locally 12 concurrent runs of that test failed 8 times, every failure HANG at ~10,020 ms. Without the quiet timeout the same run failed 0 of 12. The fixture now waits for Git to finish; the suite's own ceiling stays the backstop. No fixture caller expects a hang. Signed-off-by: Martin Vogel --- tests/test_test_impact_git.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_test_impact_git.c b/tests/test_test_impact_git.c index 304e3a8bb..85b26951d 100644 --- a/tests/test_test_impact_git.c +++ b/tests/test_test_impact_git.c @@ -120,12 +120,16 @@ static bool gf_git_exit(gf_fixture_t *fx, const char *const *tail, int expected_ argv[n++] = tail[i]; } argv[n] = NULL; + /* No quiet timeout: the fixture waits for Git to finish, and the suite's + * own ceiling is the backstop. A diff-driver control runs a fresh copy of + * the test runner under `git diff`; on a loaded or Windows host that copy + * took over 10 s to start and finish, and a 10 s quiet timeout killed Git + * (outcome HANG) -- the timeout, not the assertion, decided the test. */ cbm_proc_opts_t opts = {.bin = fx->git, .argv = argv, .stdout_file = fx->capture, .log_file = fx->log, - .strip_git_repo_env = true, - .quiet_timeout_ms = 10000}; + .strip_git_repo_env = true}; cbm_proc_result_t result = {0}; cbm_proc_outcome_t expected_outcome = expected_exit == 0 ? CBM_PROC_CLEAN : CBM_PROC_EXIT_NONZERO;