1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
//! T3 Cluster H1 — CLI surface tests for `sqry context-propagation`.
//!
//! Per `docs/development/go-error-context-buildtags/CLI_INTEGRATION.md`
//! §2.3 (post Cluster G-ext iter-2 contract refresh): 8 tests covering
//! default scope, mode filter, file scope (resolved + not-in-index),
//! invalid mode, no-index, JSON schema, zero leaks. The handler lives
//! at `sqry-cli/src/commands/context_propagation.rs` and mirrors the
//! Cluster G MCP tool at
//! `sqry-mcp/src/execution/tools/context_propagation.rs`.
mod common;
use common::sqry_bin;
use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn fixture_root(sub: &str) -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace = PathBuf::from(manifest_dir).parent().unwrap().to_path_buf();
workspace.join("test-fixtures").join("go").join(sub)
}
/// Copy every regular file in `src` into `dst` (recursively).
///
/// Skips dot-prefixed entries (`.sqry/`, `.git/`, …) so a leftover
/// dev-loop index in the canonical fixture tree never bleeds into the
/// tempdir. Without this skip, `sqry index` short-circuits with
/// "Index already exists" and the test asserts against a stale
/// snapshot. Discovered while closing codex iter-3 concern 5 (Cluster
/// H1c).
fn copy_fixture(src: &Path, dst: &Path) {
for entry in fs::read_dir(src).expect("read fixture dir") {
let entry = entry.expect("read fixture entry");
let path = entry.path();
let name = path.file_name().expect("fixture filename");
if name.to_string_lossy().starts_with('.') {
continue;
}
let target = dst.join(name);
if path.is_dir() {
fs::create_dir_all(&target).expect("mkdir target");
copy_fixture(&path, &target);
} else {
fs::copy(&path, &target).expect("copy fixture file");
}
}
}
fn indexed_context_propagation_workspace() -> TempDir {
let temp = TempDir::new().expect("tempdir");
copy_fixture(&fixture_root("context_propagation"), temp.path());
// `--force` is defensive: `copy_fixture` already skips dotdirs, but
// `--force` keeps the test robust against future fixture layouts
// (e.g. cache directories without a dot prefix).
Command::new(sqry_bin())
.arg("index")
.arg("--force")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.success();
temp
}
/// Default scope (global, mode=all) against the full context_propagation
/// fixture must succeed (exit 0). The CLI surface contract this test
/// pins is "default scope + default mode is dispatched correctly and
/// returns a non-error response"; the actual leak counts depend on the
/// Go plugin's `TypeOf` emission for stdlib `context.Context` and on
/// the cross-file resolution shipped by Cluster C/D, which can be 0 in
/// some configurations. A zero-leak result is therefore a *valid*
/// finding for this CLI-surface test (the underlying semantic
/// coverage lives in `sqry-db/src/queries/context_propagation.rs`
/// unit tests, which run against synthetic graphs).
#[test]
fn cli_context_propagation_default_scope_succeeds() {
let temp = indexed_context_propagation_workspace();
let assert = Command::new(sqry_bin())
.arg("context-propagation")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
// Either we surface leaks (one of the mode labels appears) OR we
// surface the documented zero-leaks line. Anything else is a CLI
// surface regression.
let has_leak_line = stdout.contains("break_site")
|| stdout.contains("http_handler_leak")
|| stdout.contains("unthreaded_goroutine");
let has_zero_line = stdout.contains("no context-propagation leaks");
assert!(
has_leak_line || has_zero_line,
"default-scope context-propagation must surface either leaks or the empty-result text line; stdout={stdout:?}",
);
}
/// `--mode http-handler-leak` must filter the surfaced set to handler
/// leaks only. The CLI's text output formats each leak with the mode
/// label in brackets, so absence of `break_site` / `unthreaded_goroutine`
/// labels in stdout is the negative invariant.
#[test]
fn cli_context_propagation_mode_filter_http_handler_only() {
let temp = indexed_context_propagation_workspace();
let assert = Command::new(sqry_bin())
.arg("context-propagation")
.arg("--mode")
.arg("http-handler-leak")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
// With --mode http-handler-leak we never emit `break_site` /
// `unthreaded_goroutine` lines in the text output's mode column.
assert!(
!stdout.contains("[break_site]"),
"mode filter must exclude break_site mode; stdout={stdout:?}",
);
assert!(
!stdout.contains("[unthreaded_goroutine]"),
"mode filter must exclude unthreaded_goroutine mode; stdout={stdout:?}",
);
}
/// `--scope file:<path>` must restrict leaks to those whose caller
/// function lives in that file. We use `break_site.go` (resolved
/// relative to the workspace root by `parse_scope`).
#[test]
fn cli_context_propagation_scope_file_filters_correctly() {
let temp = indexed_context_propagation_workspace();
let assert = Command::new(sqry_bin())
.arg("context-propagation")
.arg("--scope")
.arg("file:break_site.go")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
// Scope confined to break_site.go must not surface leaks from
// unrelated fixtures.
assert!(
!stdout.contains("http_handler"),
"file-scoped query must NOT surface http_handler leaks; stdout={stdout:?}",
);
assert!(
!stdout.contains("LaunchExpensive"),
"file-scoped query must NOT surface goroutine_leak fixtures; stdout={stdout:?}",
);
}
/// Unknown `--mode` is rejected by clap's `ValueEnum` machinery → exit 2.
#[test]
fn cli_context_propagation_invalid_mode_exits_2() {
let temp = indexed_context_propagation_workspace();
let assert = Command::new(sqry_bin())
.arg("context-propagation")
.arg("--mode")
.arg("not_a_mode")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.failure();
assert.code(2);
}
/// `--scope file:<path>` against a workspace with no `.sqry-index`
/// returns exit 3 with a "No .sqry-index found" diagnostic. The handler
/// short-circuits via `std::process::exit(EXIT_NO_INDEX)` before the
/// anyhow layer can re-classify (per `CLI_INTEGRATION.md` §1.3).
#[test]
fn cli_context_propagation_no_index_exits_3() {
let temp = TempDir::new().expect("tempdir");
Command::new(sqry_bin())
.arg("context-propagation")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.failure()
.code(3)
.stderr(predicate::str::contains("No .sqry-index"));
}
/// `--json` output must deserialise into the `ContextLeakHit` schema
/// from `CLI_INTEGRATION.md` §1.3: caller / callee / mode /
/// caller_file / call_site / caller_ctx_param. Per the iter-2 schema
/// rebind, keys are qualified names + file paths (NOT NodeIds).
#[test]
fn cli_context_propagation_json_schema_match() {
let temp = indexed_context_propagation_workspace();
let assert = Command::new(sqry_bin())
.arg("--json")
.arg("context-propagation")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("context-propagation --json must emit JSON");
let arr = parsed
.as_array()
.expect("context-propagation --json must emit a JSON array");
for hit in arr {
for field in ["caller", "callee", "mode", "caller_file", "call_site"] {
assert!(
hit.get(field).is_some(),
"each ContextLeakHit must carry `{field}`; hit={hit:?}",
);
}
let call_site = hit
.get("call_site")
.and_then(|v| v.as_object())
.expect("call_site must be an object");
for nested in [
"file",
"start_line",
"start_column",
"end_line",
"end_column",
] {
assert!(
call_site.contains_key(nested),
"call_site must carry `{nested}`; call_site={call_site:?}",
);
}
// caller_ctx_param is optional but the key must be present
// (serde does not skip None on this struct).
assert!(
hit.get("caller_ctx_param").is_some(),
"caller_ctx_param key must be present (may be null); hit={hit:?}",
);
}
}
/// A workspace with no context leaks must exit 0 (zero leaks is a
/// valid finding, NOT an error). Use a tiny non-leaky Go file.
#[test]
fn cli_context_propagation_zero_leaks_exits_0() {
let temp = TempDir::new().expect("tempdir");
fs::write(temp.path().join("go.mod"), "module x\n\ngo 1.22\n").expect("write go.mod");
fs::write(
temp.path().join("plain.go"),
"package x\n\nfunc Plain() int { return 1 }\n",
)
.expect("write plain.go");
Command::new(sqry_bin())
.arg("index")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.success();
Command::new(sqry_bin())
.arg("context-propagation")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.success();
}
/// `--scope file:<not-indexed>` against an indexed workspace returns
/// exit 0 + empty result set. Matches the MCP handler's silent
/// short-circuit at
/// `sqry-mcp/src/execution/tools/context_propagation.rs:83-86`.
/// Documented Cluster G-ext iter-2 clarification in
/// CLI_INTEGRATION.md §1.3.
#[test]
fn cli_context_propagation_scope_file_not_in_index_exits_0() {
let temp = indexed_context_propagation_workspace();
let assert = Command::new(sqry_bin())
.arg("context-propagation")
.arg("--scope")
.arg("file:does-not-exist.go")
.arg(temp.path())
.env("NO_COLOR", "1")
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(
stdout.contains("no context-propagation leaks"),
"file-not-in-index must surface the empty-result text line; stdout={stdout:?}",
);
}