drep-ai 2.6.1

A local commit gate: runs the linters your repo configures, and sends changed code to an LLM for review
Documentation
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
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//! Gating and exit codes: criteria 12-18.
//!
//! Criteria 12-18 plus cache-limit and push-handshake regressions share one
//! precedence table:
//!
//! - Exit 0 (`Clean`): no failures, no blocking findings.
//! - Exit 1 (`FoundIssues`): tool findings always block; LLM findings block
//!   when `--fail-on` admits their severity and is set.
//! - Exit 2 (`Unanalyzed`): any non-cache failure outranks any finding.
//! - Exit 3 (`CacheMiss`): cache-only review found uncached files.
//!
//! Tests 12-17 are in-process: they call `check::run` directly and assert
//! only on the returned `Exit`. Tests 14 and 18 also assert on the
//! rendered output (a finding must be visible in text output, and an
//! unreachable endpoint must not print "No issues found."), so they
//! spawn the drep binary as a subprocess to capture stdout.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use wiremock::MockServer;

use super::support::{check_args as args, run_drep};
use crate::analysis::findings::{Finding, Severity};
use crate::analysis::result::{AnalysisResult, FailureReason};
use crate::cli::check::{self, CheckArgs};
use crate::llm::cache::Cache;
use crate::test_support::mount_sse;
use crate::test_support::sse;
use crate::test_support::write_executable;

// ---------- in-process scaffolding ----------

/// Build a fresh fixture: an empty `TempDir` and a `MockServer` mounted
/// with one SSE response. The fixture does **not** write `drep.toml` -
/// the per-test helper does, so each test names its own endpoint.
async fn setup_mock(body: &str) -> (tempfile::TempDir, MockServer) {
    let dir = tempfile::tempdir().expect("tempdir");
    let server = MockServer::start().await;
    mount_sse(
        &server,
        wiremock::ResponseTemplate::new(200).set_body_raw(sse(&[body]), "text/event-stream"),
    )
    .await;
    (dir, server)
}

/// Run `check` in-process against a cache scoped to this test.
///
/// Goes through `run_with`, not `run`. `run` builds `Cache::default_root()` -
/// the developer's real `~/Library/Caches` - so every in-process test wrote
/// there and could be satisfied by an entry another test left behind. The
/// seam existed for exactly this and had no callers.
async fn run_paths_with(args: &CheckArgs, dir: &Path) -> check::Exit {
    let cache = Cache::new(dir.join("test-cache"), 30, 8 * 1024 * 1024);
    check::run_with(args, dir, cache)
        .await
        .expect("check::run succeeds")
}

/// The common case: one path, default format, no gating threshold.
async fn run_paths(path: std::path::PathBuf, dir: &Path) -> check::Exit {
    run_paths_with(&args(vec![path], None), dir).await
}

#[test]
fn push_warm_requires_cache_misses_and_every_deterministic_input_to_be_clean() {
    assert!(!check::push_warm_eligible(
        &AnalysisResult::default(),
        true,
        true,
        true
    ));

    let cached = AnalysisResult::failed(PathBuf::from("src/lib.py"), FailureReason::CacheMiss);
    assert!(check::push_warm_eligible(&cached, true, true, true));
    assert!(!check::push_warm_eligible(&cached, false, true, true));
    assert!(!check::push_warm_eligible(&cached, true, false, true));
    assert!(!check::push_warm_eligible(&cached, true, true, false));

    let failed = AnalysisResult::failed(
        PathBuf::from("src/lib.py"),
        FailureReason::Unparseable("bad response".to_owned()),
    );
    assert!(!check::push_warm_eligible(&failed, true, true, true));
}

#[test]
fn review_activity_boolean_contracts_cover_each_independent_reason() {
    assert!(check::should_report_unlimited(true, true));
    assert!(!check::should_report_unlimited(false, true));
    assert!(!check::should_report_unlimited(true, false));

    assert!(check::should_report_reset(true, false));
    assert!(check::should_report_reset(false, true));
    assert!(!check::should_report_reset(false, false));
}

/// Configure ruff and install a local stub that emits one F401 finding.
fn install_fake_ruff(dir: &Path) {
    let bin = dir.join("venv/bin/ruff");
    std::fs::create_dir_all(bin.parent().expect("ruff parent")).expect("bin dir");
    write_executable(
        &bin,
        "#!/bin/sh\nprintf '%s' '[{\"code\":\"F401\",\"filename\":\"src/lib.py\",\"location\":{\"row\":1,\"column\":1},\"message\":\"unused import\"}]'\n",
    );
    std::fs::write(dir.join("pyproject.toml"), "").expect("pyproject");
    std::fs::create_dir_all(dir.join("src")).expect("src dir");
    std::fs::write(dir.join("src/lib.py"), "import os\n").expect("lib.py");
}

// ---------- 12 ----------

/// Criterion 12: a clean run returns `Exit::Clean`.
///
/// "Clean" requires *both* layers to come back empty and no failures to
/// land on any file. The mock returns no issues; the project has no
/// `pyproject.toml` so ruff is `Skipped`; there are no read failures.
/// Either layer reporting a finding or a failure breaks the assertion.
#[tokio::test]
async fn clean_run_returns_clean() {
    let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
    std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");

    let exit = run_paths(dir.path().join("lib.py"), dir.path()).await;
    assert_eq!(exit, check::Exit::Clean, "clean run must return Clean");
    assert_eq!(exit.code(), 0, "Clean must map to exit 0");
}

#[tokio::test]
async fn a_completed_run_enforces_the_cache_size_ceiling() {
    let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
    let source = dir.path().join("lib.py");
    std::fs::write(&source, "x = 1\n").expect("lib.py");
    let cache_root = dir.path().join("tiny-cache");
    let cache = Cache::new(cache_root.clone(), 30, 1);

    let exit = check::run_with(&args(vec![source], None), dir.path(), cache)
        .await
        .expect("check succeeds");

    assert_eq!(exit, check::Exit::Clean);
    let bytes = crate::test_support::two_level_tree_size(&cache_root);
    assert!(bytes <= 1, "cache retained {bytes} bytes past its ceiling");
}

/// A cache-only run must never contact the provider. Its distinct exit code is
/// what lets the pre-push hook warm the cache, stop the current push, and ask
/// Git to reconnect for the fast retry instead of resuming a stale transport.
#[tokio::test]
async fn cache_only_miss_exits_3_without_contacting_the_provider() {
    let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
    let source = dir.path().join("lib.py");
    std::fs::write(&source, "x = 1\n").expect("lib.py");
    let mut args = args(vec![source], None);
    args.cache_only = true;

    let exit = run_paths_with(&args, dir.path()).await;

    assert_eq!(exit, check::Exit::CacheMiss);
    assert_eq!(exit.code(), 3);
    assert_eq!(crate::test_support::request_count(&server).await, 0);
}

/// Push-gate mode performs the cold review, deliberately exits 3 before Git
/// resumes its old connection, then lets the immediate cached retry through.
#[tokio::test]
async fn push_gate_warms_once_then_the_cached_retry_is_clean() {
    let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
    let source = dir.path().join("lib.py");
    std::fs::write(&source, "x = 1\n").expect("lib.py");
    let cache = Cache::new(dir.path().join("push-cache"), 30, 8 * 1024 * 1024);
    let mut args = args(vec![source], None);
    args.push_gate = true;

    let first = check::run_with(&args, dir.path(), cache.clone())
        .await
        .expect("cold push gate");
    let second = check::run_with(&args, dir.path(), cache)
        .await
        .expect("warm push gate");

    assert_eq!(first, check::Exit::CacheMiss);
    assert_eq!(second, check::Exit::Clean);
    assert_eq!(crate::test_support::request_count(&server).await, 1);
}

/// Exit 3 alone cannot distinguish a successful warm from an internal cache
/// miss that accidentally survived the live pass. The real binary must print
/// the reconnect instruction and must not render that stale marker as failed
/// analysis.
#[tokio::test(flavor = "multi_thread")]
async fn a_successful_push_warm_renders_only_the_reconnect_instruction() {
    let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
    std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");

    let output = run_drep(dir.path(), &["check", "--push-gate", "lib.py"]);
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert_eq!(output.status.code(), Some(3), "stdout: {stdout}");
    assert!(stdout.contains("Run git push again"), "stdout: {stdout}");
    assert!(
        !stdout.contains("could not be analyzed"),
        "stdout: {stdout}"
    );
    assert!(!stdout.contains("not cached"), "stdout: {stdout}");
}

// ---------- 13 ----------

/// Criterion 13: a tool finding blocks with no `--fail-on`.
///
/// The mock is mounted so the LLM layer comes back clean; the project is
/// configured for ruff and the fake binary emits one finding. The gate
/// honours the tool's verdict without an allow-list.
#[tokio::test]
async fn tool_finding_blocks_with_no_fail_on() {
    let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));

    install_fake_ruff(dir.path());

    let exit = run_paths(dir.path().join("src/lib.py"), dir.path()).await;
    assert_eq!(
        exit,
        check::Exit::FoundIssues,
        "a tool finding with no --fail-on must return FoundIssues"
    );
    assert_eq!(exit.code(), 1, "FoundIssues must map to exit 1");
}

/// A cache-only miss is operationally softer than a deterministic finding:
/// retrying cannot make the project's own tool finding disappear.
#[tokio::test]
async fn tool_findings_outrank_cache_only_misses() {
    let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
    install_fake_ruff(dir.path());
    let mut check_args = args(vec![dir.path().join("src/lib.py")], None);
    check_args.cache_only = true;

    let exit = run_paths_with(&check_args, dir.path()).await;

    assert_eq!(exit, check::Exit::FoundIssues);
    assert_eq!(crate::test_support::request_count(&server).await, 0);
}

// ---------- 14 (subprocess) ----------

/// Criterion 14: an LLM finding at severity `error` does **not** block
/// with no `--fail-on`, but it still appears in the rendered output.
///
/// Asserting only the exit would also hold for an implementation that
/// silently drops LLM findings; asserting only the output would also hold
/// for one that classifies them as blocking. Both halves in one test is
/// what makes "inform, not gate" observable end-to-end.
#[test]
fn llm_finding_does_not_block_without_fail_on_but_renders() {
    // Subprocess so the test can capture stdout. The mock is mounted on a
    // wiremock server inside this process; the spawned binary hits it
    // over HTTP exactly as production would.
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("runtime");

    let (dir, _server) = runtime.block_on(async {
        let dir = tempfile::tempdir().expect("tempdir");
        let server = MockServer::start().await;
        let body = r#"{"issues":[{"line":1,"severity":"critical","category":"bug","message":"something smells"}]}"#;
        mount_sse(
            &server,
            wiremock::ResponseTemplate::new(200)
                .set_body_raw(sse(&[body]), "text/event-stream"),
        )
        .await;
        crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
        std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");
        (dir, server)
    });

    let output = run_drep(dir.path(), &["check", "lib.py"]);
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert_eq!(
        output.status.code(),
        Some(check::Exit::Clean.code() as i32),
        "no --fail-on means LLM findings inform, not block; stderr was {:?}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        stdout.contains("something smells"),
        "the LLM finding must appear in rendered output, got {stdout:?}"
    );
    // We don't assert the exact format here - that's criterion 19's job.
    // We only assert the message reaches the user.
}

// ---------- 15 ----------

/// Criterion 15: the same LLM finding at severity `error` blocks under
/// `--fail-on error`.
///
/// Pairs with criterion 14: the only thing that changes between the two
/// is the flag, and the gate must respond to it.
#[tokio::test]
async fn llm_finding_at_error_blocks_under_fail_on_error() {
    let (dir, server) = setup_mock(
        r#"{"issues":[{"line":1,"severity":"critical","category":"bug","message":"bad"}]}"#,
    )
    .await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
    std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");

    let args = args(vec![dir.path().join("lib.py")], Some(Severity::Error));
    let exit = run_paths_with(&args, dir.path()).await;
    assert_eq!(
        exit,
        check::Exit::FoundIssues,
        "an error-level LLM finding under --fail-on error must block"
    );
}

// ---------- 16 ----------

/// Criterion 16: an LLM finding at severity `warning` does not block
/// under `--fail-on error`.
///
/// `warning < error`, so it is below the threshold and stays
/// informational. A gate that admitted anything at or above `error` (i.e.,
/// also admitted `warning`) would fail this test.
#[tokio::test]
async fn llm_finding_at_warning_does_not_block_under_fail_on_error() {
    let (dir, server) = setup_mock(
        r#"{"issues":[{"line":1,"severity":"medium","category":"style","message":"meh"}]}"#,
    )
    .await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));
    std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");

    let args = args(vec![dir.path().join("lib.py")], Some(Severity::Error));
    let exit = run_paths_with(&args, dir.path()).await;
    assert_eq!(
        exit,
        check::Exit::Clean,
        "warning under --fail-on error must not block"
    );
}

// ---------- 17 ----------

/// Criterion 17: a failure outranks a finding.
///
/// Constructing the precedence test from inside `check::run` is awkward:
/// the orchestrator's first-wins rule means the failure axis only
/// appears when reading failed. The cleanest expression of the
/// precedence is to call `gate` directly with both populated. `gate` is
/// not exported, so this test goes through `check::run` with a file that
/// the input layer will refuse to read AND a tool that emits a finding.
/// The exit must be `Unanalyzed` (2), not `FoundIssues` (1).
#[tokio::test]
async fn failure_outranks_finding() {
    let (dir, server) = setup_mock(r#"{"issues": []}"#).await;
    crate::test_support::write_drep_toml(dir.path(), &format!("{}/v1", server.uri()));

    // One file produces a tool finding.
    install_fake_ruff(dir.path());
    // The second file is non-UTF-8: a binary blob. The input layer
    // rejects it before either the deterministic or the LLM layer can
    // touch it, and the orchestrator records it as a read failure.
    std::fs::write(dir.path().join("src/bad.py"), [0xFFu8, 0xFE]).expect("bad.py");

    let args = args(
        vec![dir.path().join("src/lib.py"), dir.path().join("src/bad.py")],
        None,
    );
    let exit = run_paths_with(&args, dir.path()).await;
    assert_eq!(
        exit,
        check::Exit::Unanalyzed,
        "a run with a blocking tool finding AND an unanalyzed file must \
         exit 2, not 1"
    );
    assert_eq!(exit.code(), 2, "Unanalyzed must map to exit 2");
}

// ---------- 18 (subprocess) ----------

/// Criterion 18: a run whose only problem is an unreachable LLM endpoint
/// exits 2, and its text output does not contain `No issues found.`
///
/// "No issues found." would imply a clean pass; the run is not clean.
/// Asserting both halves together makes it impossible to satisfy the
/// test by reporting `Unanalyzed` while still printing the misleading
/// clean line - or by suppressing the clean line while silently exiting
/// 0.
#[test]
fn unreachable_endpoint_exits_2_and_does_not_print_clean() {
    // Ask the OS for an unused loopback port, then close it before drep runs.
    // Removing proxy variables in `run_drep` keeps this request local.
    let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback");
    let port = listener.local_addr().expect("local address").port();
    drop(listener);
    let dir = tempfile::tempdir().expect("tempdir");
    crate::test_support::write_drep_toml(dir.path(), &format!("http://127.0.0.1:{port}/v1"));
    std::fs::write(dir.path().join("lib.py"), "x = 1\n").expect("lib.py");

    let output = run_drep(dir.path(), &["check", "lib.py"]);
    let stdout = String::from_utf8_lossy(&output.stdout);

    assert_eq!(
        output.status.code(),
        Some(check::Exit::Unanalyzed.code() as i32),
        "an unreachable endpoint must exit 2, stderr was {:?}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        !stdout.contains("No issues found."),
        "an unanalyzed run must not print 'No issues found.', got {stdout:?}"
    );
}

#[test]
fn successful_compilation_suppresses_only_explicit_compile_failure_claims() {
    let finding = |path: &str, compile_failure: bool| Finding {
        kind: "bug".to_owned(),
        severity: Severity::Error,
        file_path: path.to_owned(),
        line: 1,
        column: None,
        message: "review".to_owned(),
        suggestion: None,
        asserts_compile_failure: compile_failure,
        fingerprint: None,
    };
    let mut findings = vec![
        finding("src/compiled.rs", true),
        finding("src/compiled.rs", false),
        finding("src/unchecked.rs", true),
    ];
    let compiled = BTreeSet::from([PathBuf::from("src/compiled.rs")]);

    check::suppress_disproved_compile_claims(&mut findings, &compiled);

    assert_eq!(findings.len(), 2);
    assert!(
        findings
            .iter()
            .any(|finding| !finding.asserts_compile_failure)
    );
    assert!(
        findings
            .iter()
            .any(|finding| finding.file_path == "src/unchecked.rs")
    );
}

// ---------- shared helpers ----------