Skip to content

Commit b96342b

Browse files
Eilodonclaude
andauthored
Claude/calm product truth contract 2sxory (#55)
* test(server): unit-test caller_set_digest; ci: gate B2 call-graph quality nightly caller_set_digest is the pure function WS-2 Phase 2's TOCTOU guard hashes caller sets with, but had zero direct tests (only exercised end-to-end via 2 gate-behavior integration tests on one fixed caller list). Add 3 focused tests locking in its documented order/dedup-independence guarantee and its sensitivity to a real set change. Also close a real gap found while auditing this repo's own benchmark suite: none of B2/B3/B4/B6/B7's correctness numbers were wired into any CI gate (thresholds.toml only covers architectural fitness metrics). B2 (self-repo Rust call-graph precision/recall vs a rust-analyzer SCIP oracle) is the one benchmark cheap enough to run nightly -- self-contained, pure-stdlib Python, no external corpus clone. Adds a b2-call-graph-quality job to the existing scip-nightly workflow (reusing its rust-analyzer install) plus scripts/check-b2-thresholds.sh, which fails the job if precision/recall/per-tier precision drop below floors set with headroom under the last real measurement. B7 (task-correctness) stays manual-only -- it clones and builds 6 external repos per run, too expensive to gate on a schedule without further infrastructure investment. * fix(indexer): resolve receiver-qualified calls through a whole-module import binding Real, reproducible gap found by benchmarks/b7_task_correctness (express): `utils.setCharset(...)` after `var utils = require('../lib/utils')` was completely invisible to callers()/blast_radius, while the destructured sibling call `setCharset(...)` (bare identifier) resolved fine. Root cause: extract_file_data's tier-1 (ConservativeResolver::resolve_tier1) only checks ctx.import_map keyed by the CALLEE name, and tier-2 (resolve_tier2) only checks ctx.type_map (a real type annotation) or self/this -- neither ever asks whether the RECEIVER itself is a name this file imported as a whole module (CommonJS require(), Python bare `import os`, etc). Adds a new fallback branch in extract_file_data: when a receiver-qualified call is still Textual after tier-1/tier-2/the C# tier-2 fallback, check if the receiver is a key in ctx.import_map: if so, derive a module_hint from the imported module path's last segment (module_path_last_segment, next to the existing module_hint_of) and let resolve_sites_to_edges's existing file-stem filter narrow the already name-matched candidate list -- the exact same mechanism Rust's `crate::module::function()` already uses. Purely additive and fail-open: only fires when nothing else resolved the call, only narrows an existing candidate set (never fabricates one), and falls through to today's behavior unchanged when the hint matches nothing. New regression test constructs a real fixture with a same-named decoy in a different file (different basename) to prove this actually improves precision (Ambiguous 2-candidate -> exactly 1 correct edge), not just that it compiles. Verified by temporarily disabling the new branch and confirming the test fails without it. Full calm-core suite (957 tests) green, clippy -D warnings clean. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81a65c1 commit b96342b

5 files changed

Lines changed: 280 additions & 1 deletion

File tree

.github/workflows/scip-nightly.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ jobs:
5454
cargo test -p calm-cli --features scip-overlay -- --ignored \
5555
calm_index_cli_upgrades_a_real_edge_on_the_fixture
5656
57+
# B2 (benchmarks/b2_call_graph_quality) -- self-repo Rust call-graph
58+
# precision/recall against a rust-analyzer SCIP oracle. Self-contained (no
59+
# external corpus clone, pure-stdlib Python script) so unlike B7 it's
60+
# cheap enough to gate nightly rather than staying manual-only -- see
61+
# scripts/check-b2-thresholds.sh for the floors and why they were picked.
62+
# Piggybacks this job's own rust-analyzer install (same reasoning as the
63+
# calm-cli step in scip-rust above) rather than getting a separate one.
64+
b2-call-graph-quality:
65+
runs-on: ubuntu-latest
66+
steps:
67+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
68+
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
69+
with:
70+
components: rust-analyzer
71+
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
72+
with:
73+
key: b2-call-graph-quality
74+
- name: Build calm-cli with scip-overlay (needed for the hidden scip-dump subcommand)
75+
run: cargo build --release -p calm-cli --features scip-overlay
76+
- name: Run B2 benchmark (self-repo)
77+
run: python3 benchmarks/b2_call_graph_quality/run_benchmark.py
78+
- name: Check B2 results against regression floors
79+
run: scripts/check-b2-thresholds.sh
80+
5781
scip-go:
5882
runs-on: ubuntu-latest
5983
steps:

crates/calm-core/src/indexer/parser.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,6 +1531,29 @@ fn module_hint_of(raw: &str) -> Option<String> {
15311531
}
15321532
}
15331533

1534+
// The rightmost meaningful segment of a module specifier string -- "utils"
1535+
// from "../lib/utils" or "./utils", "os" from "os", "path" from Python's
1536+
// dotted "os.path". Counterpart to `module_hint_of` above for languages
1537+
// that name their module via `ctx.import_map` (populated from a parsed
1538+
// import/require statement) rather than a `::`-delimited path in the call
1539+
// text itself -- see `extract_file_data`'s receiver-import-alias branch,
1540+
// the only caller. Deliberately simple (slash-split, then dot-split,
1541+
// first non-empty segment) to match `module_hint_of`'s own "best-effort
1542+
// hint, not a real module resolver" scope: `resolve_sites_to_edges`
1543+
// treats whatever this returns as a fail-open filter over an
1544+
// already-name-matched candidate list, never a source of new candidates,
1545+
// so an imprecise segment on some exotic module-path shape just narrows
1546+
// nothing (today's behavior) rather than resolving wrong.
1547+
pub fn module_path_last_segment(module_path: &str) -> Option<String> {
1548+
let after_slash = module_path.rsplit('/').next().unwrap_or(module_path);
1549+
let after_dot = after_slash.rsplit('.').next().unwrap_or(after_slash);
1550+
if after_dot.is_empty() {
1551+
None
1552+
} else {
1553+
Some(after_dot.to_string())
1554+
}
1555+
}
1556+
15341557
/// Heuristic: a path segment is "type-like" when it starts with an uppercase
15351558
/// letter, matching Rust/C#/Java/Kotlin/Swift convention for types/classes
15361559
/// (vs. snake_case modules or lowerCamelCase namespaces/packages). Not

crates/calm-core/src/indexer/pipeline.rs

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ fn extract_file_data(
576576
if let Some(enc_qn) = enc_qn {
577577
let mut confidence;
578578
let mut target_class: Option<String> = None;
579+
let mut module_hint = c.module_hint.clone();
579580

580581
if c.receiver_is_type_path
581582
&& let Some(receiver) = &c.receiver
@@ -663,6 +664,37 @@ fn extract_file_data(
663664
// runs per-file, in parallel, before it's built).
664665
confidence = EdgeConfidence::Inferred;
665666
target_class = Some(receiver.clone());
667+
} else if confidence == EdgeConfidence::Textual
668+
&& module_hint.is_none()
669+
&& let Some(receiver) = &c.receiver
670+
&& let Some(module_path) = ctx.import_map.get(receiver)
671+
&& let Some(seg) = crate::indexer::parser::module_path_last_segment(module_path)
672+
{
673+
// Whole-module require/import binding (`var utils =
674+
// require('../lib/utils')`, Python `import os`): tier-1
675+
// above only checks `ctx.import_map` keyed by the
676+
// CALLEE name (`setCharset`), and tier-2 only checks
677+
// `ctx.type_map` (a real type annotation) or self/this —
678+
// neither ever asks "is the RECEIVER itself a name this
679+
// file imported as a whole module?", so a call like
680+
// `utils.setCharset(...)` fell all the way through to
681+
// Textual with no receiver-derived signal at all, unlike
682+
// the destructured-import sibling call `setCharset(...)`
683+
// (bare, resolves fine via tier-1's own import_map
684+
// check). Reuses the exact same `module_hint` file-stem
685+
// filter `resolve_sites_to_edges` already applies for
686+
// Rust's `crate::module::function()` (module_hint_of
687+
// above) -- a pure NARROWING filter over the
688+
// already-name-matched `ctx.by_name` candidate list,
689+
// fail-open when the hint matches nothing (identical to
690+
// today's behavior), never a source of new candidates on
691+
// its own. Found live via B7 (benchmarks/
692+
// b7_task_correctness/README.md): express's
693+
// `test/utils.js` calls `utils.setCharset(...)` after
694+
// `var utils = require('../lib/utils')` -- completely
695+
// absent from `callers()`/`blast_radius` before this fix.
696+
confidence = EdgeConfidence::Inferred;
697+
module_hint = Some(seg);
666698
}
667699
}
668700
let callee = aliases.get(&c.callee).unwrap_or(&c.callee).clone();
@@ -683,7 +715,7 @@ fn extract_file_data(
683715
receiver: c.receiver.clone(),
684716
target_class,
685717
looks_option_or_result_chained: c.looks_option_or_result_chained,
686-
module_hint: c.module_hint.clone(),
718+
module_hint,
687719
edge_kind: "call".to_string(),
688720
arg_count: c.arg_count,
689721
});
@@ -5140,6 +5172,75 @@ impl StructB {
51405172
let _ = std::fs::remove_dir_all(&dir);
51415173
}
51425174

5175+
#[test]
5176+
fn test_js_require_namespace_object_property_call_resolves_via_module_hint() {
5177+
// Regression test for the real, reproducible gap found in
5178+
// benchmarks/b7_task_correctness/README.md (express's `test/utils.js`):
5179+
// `utils.setCharset(...)` after `var utils = require('./lib/utils')`
5180+
// was completely invisible to `callers()`/`blast_radius` -- tier-1
5181+
// (extract_file_data) only checks `ctx.import_map` keyed by the CALLEE
5182+
// name, tier-2 only checks `ctx.type_map` (a real type annotation) or
5183+
// self/this, so a call whose RECEIVER is itself a whole-module import
5184+
// binding had no signal at all. See extract_file_data's
5185+
// receiver-import-alias branch (module_path_last_segment).
5186+
//
5187+
// Fixture deliberately includes a same-named DECOY (`other/helpers.js`
5188+
// also exports `setCharset`) with a DIFFERENT file basename than the
5189+
// required module ("helpers" vs "utils") -- without the fix, both
5190+
// candidates survive `resolve_sites_to_edges`'s unscoped by-name
5191+
// fallback (JS gets no same_dir narrowing) and the edge comes out
5192+
// `Ambiguous` with 2 targets; the fix's module_hint ("utils", from the
5193+
// require path) should filter the decoy out by file-stem mismatch,
5194+
// leaving exactly one edge to the real target.
5195+
let dir = std::env::temp_dir().join(format!("ci_idx_js_require_ns_{}", std::process::id()));
5196+
let _ = std::fs::remove_dir_all(&dir);
5197+
std::fs::create_dir_all(dir.join("lib")).unwrap();
5198+
std::fs::create_dir_all(dir.join("other")).unwrap();
5199+
std::fs::write(
5200+
dir.join("lib/utils.js"),
5201+
"exports.setCharset = function (type, charset) {\n return type + '; charset=' + charset;\n};\n",
5202+
)
5203+
.unwrap();
5204+
std::fs::write(
5205+
dir.join("other/helpers.js"),
5206+
"exports.setCharset = function (type, charset) {\n return 'decoy';\n};\n",
5207+
)
5208+
.unwrap();
5209+
std::fs::write(
5210+
dir.join("caller.js"),
5211+
"var utils = require('./lib/utils');\nfunction run() {\n utils.setCharset('text/html', 'utf-8');\n}\n",
5212+
)
5213+
.unwrap();
5214+
5215+
let mut conn = Connection::open_in_memory().unwrap();
5216+
init_db(&conn).unwrap();
5217+
run_indexing_pipeline(&mut conn, &dir, dummy_phase()).unwrap();
5218+
5219+
let mut stmt = conn
5220+
.prepare(
5221+
"SELECT to_symbol, edge_confidence FROM call_edges \
5222+
WHERE from_symbol = 'caller.js::run' AND to_symbol LIKE '%setCharset'",
5223+
)
5224+
.unwrap();
5225+
let rows: Vec<(String, String)> = stmt
5226+
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
5227+
.unwrap()
5228+
.map(|r| r.unwrap())
5229+
.collect();
5230+
5231+
assert_eq!(
5232+
rows,
5233+
vec![(
5234+
"lib/utils.js::setCharset".to_string(),
5235+
"inferred".to_string()
5236+
)],
5237+
"utils.setCharset(...) via `var utils = require(...)` must resolve to exactly \
5238+
lib/utils.js::setCharset (not the other/helpers.js decoy, not Ambiguous): {rows:?}"
5239+
);
5240+
5241+
let _ = std::fs::remove_dir_all(&dir);
5242+
}
5243+
51435244
#[test]
51445245
// P1.2 end-to-end DoD: all 4 steps together on one small PHP project —
51455246
// require_once resolves its import_edge; a typed property's

crates/calm-server/src/tools/common.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,3 +935,55 @@ pub(crate) struct RelatedNoteOutput {
935935
/// confident freshness read).
936936
pub(crate) staleness: &'static str,
937937
}
938+
939+
#[cfg(test)]
940+
mod caller_set_digest_tests {
941+
use super::CalmServer;
942+
943+
// WS-2 Phase 2's whole safety guarantee rests on `caller_set_digest`
944+
// hashing the SET of callers, not the list -- these lock in the
945+
// property the docstring promises but no existing test isolates
946+
// (the 2 integration tests in tools.rs exercise the gate end-to-end
947+
// on one fixed caller list, never a reordered/duplicated one).
948+
949+
#[test]
950+
fn order_and_duplicates_do_not_change_the_digest() {
951+
let a = vec![
952+
"b.rs::caller_one".to_string(),
953+
"a.rs::caller_two".to_string(),
954+
];
955+
let b = vec![
956+
"a.rs::caller_two".to_string(),
957+
"a.rs::caller_two".to_string(),
958+
"b.rs::caller_one".to_string(),
959+
];
960+
assert_eq!(
961+
CalmServer::caller_set_digest(&a),
962+
CalmServer::caller_set_digest(&b),
963+
"same underlying caller set, different order/duplicates -- must hash identically"
964+
);
965+
}
966+
967+
#[test]
968+
fn a_real_change_in_the_caller_set_changes_the_digest() {
969+
let before = vec!["a.rs::caller_one".to_string()];
970+
let after = vec![
971+
"a.rs::caller_one".to_string(),
972+
"b.rs::caller_two".to_string(),
973+
];
974+
assert_ne!(
975+
CalmServer::caller_set_digest(&before),
976+
CalmServer::caller_set_digest(&after),
977+
"adding a real caller must change the digest -- this is exactly what STALE_CALLER_SET detects"
978+
);
979+
}
980+
981+
#[test]
982+
fn empty_caller_set_is_deterministic() {
983+
let empty: Vec<String> = Vec::new();
984+
assert_eq!(
985+
CalmServer::caller_set_digest(&empty),
986+
CalmServer::caller_set_digest(&empty)
987+
);
988+
}
989+
}

scripts/check-b2-thresholds.sh

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bash
2+
# Regression gate for B2 (benchmarks/b2_call_graph_quality) -- the Rust
3+
# call-graph precision/recall benchmark against a rust-analyzer SCIP oracle.
4+
#
5+
# Written 2026-08-04 after an audit found this repo's benchmark suite had
6+
# rigorous methodology but ZERO of its correctness numbers (B2/B3/B4/B6/B7)
7+
# wired into any CI gate -- thresholds.toml only covers architectural
8+
# fitness metrics (hotspot risk, boundaries, config drift). A real indexer
9+
# regression (e.g. the F1 anonymous-callback JS bug this repo already found
10+
# and fixed once, commit 34918c4) could ship silently on the Rust resolver
11+
# with nothing to catch it. This closes that gap for B2 specifically --
12+
# it's the one benchmark in the suite that's self-contained (self-repo only,
13+
# no external corpus clone, no multi-language build matrix) and therefore
14+
# cheap enough to run in CI, unlike B7 (clones + builds 6 external repos per
15+
# run) which stays a manual/periodic benchmark for cost reasons.
16+
#
17+
# Floors below are set with headroom under the last real measurement
18+
# (benchmarks/b2_call_graph_quality/README.md's "Kết quả đo lần đầu" table),
19+
# same margin-not-exact-match reasoning thresholds.toml's own
20+
# max_hotspot_risk comment already documents for this repo: catch a real
21+
# regression, don't flake on ordinary noise.
22+
# measured -> floor
23+
# recall 0.193 -> 0.15 (resolver intentionally does no type
24+
# inference; recall moving at all is
25+
# expected as Tier-0/2 evolves -- this
26+
# floor exists to catch a collapse, not
27+
# to lock the number down)
28+
# precision 0.795 -> 0.70
29+
# inferred precision 0.967 -> 0.85
30+
# resolved precision 0.935 -> 0.80
31+
# textual precision 0.514 -> 0.40 (already the weakest tier by design --
32+
# agents are told to trust it least --
33+
# but a further collapse is still a
34+
# real regression worth catching)
35+
#
36+
# Usage:
37+
# scripts/check-b2-thresholds.sh [path-to-results.json]
38+
# (default: benchmarks/b2_call_graph_quality/results.json)
39+
set -euo pipefail
40+
cd "$(dirname "$0")/.."
41+
42+
results="${1:-benchmarks/b2_call_graph_quality/results.json}"
43+
44+
if [ ! -f "$results" ]; then
45+
echo "check-b2-thresholds: $results missing -- run benchmarks/b2_call_graph_quality/run_benchmark.py first" >&2
46+
exit 1
47+
fi
48+
49+
if ! command -v jq >/dev/null 2>&1; then
50+
echo "check-b2-thresholds: jq not found -- install it first" >&2
51+
exit 1
52+
fi
53+
54+
fail=0
55+
56+
check() {
57+
local label="$1" floor="$2" actual="$3"
58+
if [ "$actual" = "null" ]; then
59+
echo "check-b2-thresholds: $label missing from $results (expected a number)" >&2
60+
fail=1
61+
return
62+
fi
63+
if ! awk -v a="$actual" -v f="$floor" 'BEGIN { exit !(a+0 >= f+0) }'; then
64+
echo "check-b2-thresholds: $label = $actual, below floor $floor" >&2
65+
fail=1
66+
fi
67+
}
68+
69+
check "overall recall" 0.15 "$(jq -r '.recall' "$results")"
70+
check "overall precision" 0.70 "$(jq -r '.precision' "$results")"
71+
check "inferred-tier precision" 0.85 "$(jq -r '.by_confidence.inferred.precision // "null"' "$results")"
72+
check "resolved-tier precision" 0.80 "$(jq -r '.by_confidence.resolved.precision // "null"' "$results")"
73+
check "textual-tier precision" 0.40 "$(jq -r '.by_confidence.textual.precision // "null"' "$results")"
74+
75+
if [ "$fail" -ne 0 ]; then
76+
echo "check-b2-thresholds: regression detected -- see floors and rationale in this script's header" >&2
77+
exit 1
78+
fi
79+
echo "check-b2-thresholds: all B2 metrics at or above their floor."

0 commit comments

Comments
 (0)