trusty-review 0.3.3

Fast local PR-review service for trusty-tools — orchestrates LLM-backed code 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
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
//! Tests for the review prompt builder.
//!
//! Why: extracted from `prompt.rs` to keep that file under the 500-line cap
//! while preserving full test coverage.
//! What: system prompt policy checks, prefix stripping, context-block inclusion,
//! response_schema presence, and structured-output language assertions.
//! Test: included as `#[cfg(test)] mod tests` from `prompt.rs`.

use super::*;

fn sample_meta() -> ReviewPrMeta {
    ReviewPrMeta {
        title: "Add authentication".to_string(),
        body: String::new(),
        author: "alice".to_string(),
        url: "https://github.com/acme/backend/pull/42".to_string(),
    }
}

fn empty_context() -> ReviewContext {
    ReviewContext::default()
}

#[test]
fn system_prompt_contains_policy() {
    let prompt = reviewer_system_prompt();
    assert!(
        prompt.contains("default verdict is APPROVE"),
        "system prompt must state APPROVE-default policy"
    );
    assert!(
        prompt.contains("REQUEST_CHANGES requires ALL THREE"),
        "system prompt must specify the REQUEST_CHANGES gate"
    );
    assert!(
        prompt.contains("BLOCK"),
        "system prompt must describe the BLOCK tier"
    );
    // With forced structured output, the schema is passed as response_schema
    // rather than embedded in the system prompt as a JSON fence.
    assert!(
        prompt.contains("verdict"),
        "system prompt must mention the verdict field"
    );
}

/// Verify the system prompt contains the severity anchors added in grading calibration.
///
/// Why: Fix 5 — explicit severity anchors guide the model to escalate correctly.
/// Without them the model tends to under-rate Critical/High findings as Medium/Low.
/// What: asserts key severity anchor phrases are present in the system prompt.
/// Test: no network.
#[test]
fn system_prompt_contains_severity_anchors() {
    let prompt = reviewer_system_prompt();
    assert!(
        prompt.contains("critical"),
        "system prompt must define the 'critical' severity anchor"
    );
    assert!(
        prompt.contains("severity=critical"),
        "system prompt must instruct model to assign severity=critical for BLOCK issues"
    );
    assert!(
        prompt.contains("Compile-break rule"),
        "system prompt must contain the compile-break BLOCK rule"
    );
    assert!(
        prompt.contains("under-rate"),
        "system prompt must warn against under-rating blocking issues"
    );
}

/// Verify the compile-break BLOCK rule is present in the system prompt.
///
/// Why: Fix 3 — the model must know that removing a symbol while leaving
/// call-sites is a compile-time regression warranting BLOCK.
/// What: asserts the specific compile-break rule text is in the prompt.
/// Test: no network.
#[test]
fn system_prompt_contains_compile_break_rule() {
    let prompt = reviewer_system_prompt();
    assert!(
        prompt.contains("REMOVES a symbol"),
        "system prompt must describe the removed-symbol compile-break pattern"
    );
    assert!(
        prompt.contains("compile-time regression"),
        "system prompt must name it a compile-time regression"
    );
}

/// Regression test: a `bedrock/`-prefixed reviewer_model must be stripped
/// before being set on `LlmRequest.model`.
///
/// Why: guards against Bug 1 regression — BedrockProvider receives the
/// prefixed id as the Converse model parameter, causing HTTP 400.
/// What: passes `bedrock/<id>` to `build_review_prompt` and asserts
/// `LlmRequest.model` is the bare `<id>`.
/// Test: this test itself; no network calls.
#[test]
fn build_review_prompt_strips_bedrock_prefix() {
    let req = build_review_prompt(
        "acme",
        "backend",
        &sample_meta(),
        "+fn x() {}",
        &empty_context(),
        "",
        "bedrock/us.anthropic.claude-sonnet-4-6",
    );
    assert_eq!(
        req.model, "us.anthropic.claude-sonnet-4-6",
        "bedrock/ prefix must be stripped from LlmRequest.model"
    );
}

/// Regression test: an `openrouter/`-prefixed model must also be stripped.
///
/// Why: same Bug 1 pattern; OpenRouter API does not accept the routing prefix.
/// What: passes `openrouter/<id>` and asserts the bare id is used.
/// Test: this test itself; no network calls.
#[test]
fn build_review_prompt_strips_openrouter_prefix() {
    let req = build_review_prompt(
        "acme",
        "backend",
        &sample_meta(),
        "+fn x() {}",
        &empty_context(),
        "",
        "openrouter/openai/gpt-5.4-mini-20260317",
    );
    assert_eq!(
        req.model, "openai/gpt-5.4-mini-20260317",
        "openrouter/ prefix must be stripped from LlmRequest.model"
    );
}

#[test]
fn build_review_prompt_includes_diff() {
    let diff = "+fn hello() { println!(\"hi\"); }\n";
    let req = build_review_prompt(
        "acme",
        "backend",
        &sample_meta(),
        diff,
        &empty_context(),
        "",
        "openai/gpt-5.4-mini-20260317",
    );
    assert_eq!(req.model, "openai/gpt-5.4-mini-20260317");
    assert_eq!(req.messages.len(), 1);
    let content = &req.messages[0].content;
    assert!(
        content.contains("fn hello"),
        "user message must include the diff"
    );
    assert!(
        content.contains("acme/backend"),
        "user message must include owner/repo"
    );
    assert!(
        content.contains("Add authentication"),
        "user message must include PR title"
    );
    assert!((req.temperature - REVIEWER_TEMPERATURE).abs() < f32::EPSILON);
}

#[test]
fn prompt_includes_context_blocks() {
    use crate::integrations::search_client::SearchResult;

    let context = ReviewContext {
        search_results: vec![SearchResult {
            file: "src/auth.rs".to_string(),
            snippet: Some("pub fn verify() {}".to_string()),
            score: 0.9,
            start_line: Some(10),
            end_line: Some(12),
        }],
        complexity_hotspots: vec![ComplexityHotspot {
            file: "src/auth.rs".to_string(),
            function_name: Some("verify".to_string()),
            cyclomatic: 12,
            cognitive: 8,
        }],
        smells: vec![Smell {
            file: "src/auth.rs".to_string(),
            category: "long_method".to_string(),
            severity: "medium".to_string(),
            line: Some(20),
        }],
        apex_results: vec![],
    };

    let req = build_review_prompt(
        "acme",
        "repo",
        &sample_meta(),
        "+fn foo() {}",
        &context,
        "",
        "openai/gpt-5.4-mini-20260317",
    );
    let content = &req.messages[0].content;
    assert!(
        content.contains("Related code"),
        "user message must include search context section"
    );
    assert!(
        content.contains("pub fn verify"),
        "user message must include search snippet"
    );
    assert!(
        content.contains("Complexity hotspots"),
        "user message must include hotspot section"
    );
    assert!(
        content.contains("Code smells"),
        "user message must include smells section"
    );
}

/// Verify external context markdown is embedded into the user message.
///
/// Why: the context orchestrator renders `## Related <source>` markdown that the
/// prompt builder must append verbatim before the structured-output instruction
/// (Phase 6, #550); a regression here would silently drop JIRA/Confluence/GH
/// enrichment from the reviewer prompt.
/// What: passes a non-empty `external_context` block and asserts it appears in
/// the user message ahead of the closing instruction.
/// Test: this test; no network.
#[test]
fn prompt_includes_external_context() {
    let external = "## Related JIRA tickets\n\n- **PROJ-1 — Add auth** — In Progress\n";
    let req = build_review_prompt(
        "acme",
        "backend",
        &sample_meta(),
        "+fn x() {}",
        &empty_context(),
        external,
        "openai/gpt-5.4-mini-20260317",
    );
    let content = &req.messages[0].content;
    assert!(
        content.contains("## Related JIRA tickets"),
        "external context heading must be embedded"
    );
    assert!(
        content.contains("PROJ-1 — Add auth"),
        "external context bullet must be embedded"
    );
    // The closing instruction must come AFTER the external context.
    let ext_pos = content.find("Related JIRA tickets").unwrap();
    let instr_pos = content.find("populate the structured response").unwrap();
    assert!(
        ext_pos < instr_pos,
        "external context must precede the closing instruction"
    );
}

/// Verify an empty external context block adds nothing.
///
/// Why: out of the box (no Atlassian/GitHub creds) the orchestrator returns an
/// empty string; the prompt must not emit a stray blank section.
/// What: passes an empty `external_context` and asserts no `## Related ` heading
/// for an external source appears.
/// Test: this test; no network.
#[test]
fn prompt_empty_external_context_adds_nothing() {
    let req = build_review_prompt(
        "o",
        "r",
        &sample_meta(),
        "+fn x() {}",
        &empty_context(),
        "   \n",
        "openai/gpt-5.4-mini-20260317",
    );
    let content = &req.messages[0].content;
    assert!(!content.contains("Related JIRA"));
    assert!(!content.contains("Related Confluence"));
    assert!(!content.contains("Related GitHub issues"));
}

#[test]
fn prompt_empty_context_omits_sections() {
    let req = build_review_prompt(
        "o",
        "r",
        &sample_meta(),
        "+fn x() {}",
        &empty_context(),
        "",
        "openai/gpt-5.4-nano-20260317",
    );
    let content = &req.messages[0].content;
    assert!(
        !content.contains("Related code"),
        "empty context must not include search section"
    );
    assert!(
        !content.contains("Complexity hotspots"),
        "empty context must not include hotspot section"
    );
}

/// Verify that `build_review_prompt` includes `response_schema` for structured output.
///
/// Why: if `response_schema` is absent, the provider uses free text and the
/// fail-safe APPROVE problem returns (Haiku always fail-safes; Sonnet sometimes does).
/// What: asserts `LlmRequest.response_schema` is `Some` and the schema name
/// matches the expected constant.
/// Test: no network.
#[test]
fn build_review_prompt_includes_response_schema() {
    let req = build_review_prompt(
        "acme",
        "backend",
        &sample_meta(),
        "+fn x() {}",
        &empty_context(),
        "",
        "us.anthropic.claude-sonnet-4-6",
    );
    let schema = req
        .response_schema
        .expect("response_schema must be set on every review prompt");
    assert_eq!(
        schema.name, "review_output",
        "schema name must be review_output"
    );
    assert!(schema.schema.is_object(), "schema must be a JSON object");
    let props = &schema.schema["properties"];
    assert!(
        props["verdict"].is_object(),
        "schema must have verdict property"
    );
    assert!(
        props["findings"].is_object(),
        "schema must have findings property"
    );
}

/// Verify the system prompt no longer contains fence-based output instructions.
///
/// Why: with forced structured output, the model must populate the structured
/// response fields, not emit a fenced JSON block.  Fence instructions confuse
/// models that try to literally wrap their output in backticks.
/// What: asserts the system prompt does not contain the old "```json" fence
/// instruction, and does contain the new "structured response" wording.
/// Test: no network.
#[test]
fn system_prompt_uses_structured_output_language() {
    let prompt = reviewer_system_prompt();
    assert!(
        !prompt.contains("```json"),
        "system prompt must not contain the old fenced JSON block instruction"
    );
    assert!(
        prompt.contains("structured response"),
        "system prompt must use structured-response language"
    );
}

#[test]
fn prompt_local_diff_mode_no_pr_metadata() {
    // In --local-diff mode, pr_meta has empty fields.
    let meta = ReviewPrMeta::default();
    let req = build_review_prompt(
        "local",
        "local",
        &meta,
        "+fn local_fn() {}",
        &empty_context(),
        "",
        "openai/gpt-5.4-mini-20260317",
    );
    let content = &req.messages[0].content;
    assert!(content.contains("local_fn"));
}

/// Verify the schema enum contains exactly the five board grades with UNKNOWN.
///
/// Why: if UNKNOWN is missing from the schema the model cannot emit it and
/// will fall back to guessing; if N/A is present the board calibration breaks
/// because N/A is not a board grade.
/// What: inspects the `verdict.enum` array in `review_response_schema` and
/// asserts all five board grades are present and N/A is absent.
/// Test: no network.
#[test]
fn review_output_schema_enum_matches_board_grades() {
    let schema = review_response_schema();
    let verdict_enum = &schema.schema["properties"]["verdict"]["enum"];
    let values: Vec<&str> = verdict_enum
        .as_array()
        .expect("verdict enum must be an array")
        .iter()
        .map(|v| v.as_str().expect("enum value must be a string"))
        .collect();

    assert!(values.contains(&"APPROVE"), "schema must have APPROVE");
    assert!(values.contains(&"APPROVE*"), "schema must have APPROVE*");
    assert!(
        values.contains(&"REQUEST_CHANGES"),
        "schema must have REQUEST_CHANGES"
    );
    assert!(values.contains(&"BLOCK"), "schema must have BLOCK");
    assert!(
        values.contains(&"UNKNOWN"),
        "schema must have UNKNOWN (not N/A)"
    );
    assert!(
        !values.contains(&"N/A"),
        "schema must NOT have N/A (not a board grade)"
    );
    assert_eq!(values.len(), 5, "schema must have exactly 5 board grades");
}

/// Verify the system prompt describes UNKNOWN.
///
/// Why: the model must know what UNKNOWN means and when to use it; if it is
/// absent from the prompt the model may invent usage semantics.
/// What: asserts the system prompt contains "UNKNOWN" and does not contain "N/A"
/// as a verdict grade.
/// Test: no network.
#[test]
fn system_prompt_describes_unknown_grade() {
    let prompt = reviewer_system_prompt();
    assert!(
        prompt.contains("UNKNOWN"),
        "system prompt must describe the UNKNOWN grade"
    );
    // N/A is no longer a board grade — it must not appear as a verdict option.
    assert!(
        !prompt.contains("N/A"),
        "system prompt must not list N/A as a verdict option"
    );
}

// ── APEX prompt tests (Phase 6 PR-B, REV-420) ───────────────────────────────
/// ReviewContext with apex_results ⇒ heading, file, snippet, citation hint, ordering.
/// Why/What: guards the APEX block from silent omission. Test: no network.
#[test]
fn prompt_includes_apex_context_when_present() {
    use crate::integrations::apex_context::ApexContextResult;
    let ctx = ReviewContext {
        apex_results: vec![ApexContextResult {
            file: "apex/auth-spec.md".to_string(),
            snippet: "Token expiry must be checked.".to_string(),
            score: 0.88,
            start_line: Some(42),
        }],
        ..Default::default()
    };
    let content = build_review_prompt(
        "acme",
        "backend",
        &sample_meta(),
        "+fn x() {}",
        &ctx,
        "",
        "openai/gpt-5.4-mini-20260317",
    )
    .messages[0]
        .content
        .clone();
    assert!(content.contains("Related APEX product specs"));
    assert!(content.contains("apex/auth-spec.md"));
    assert!(content.contains("Token expiry must be checked"));
    assert!(content.contains("[apex:"));
    let apex_pos = content.find("Related APEX product specs").unwrap();
    let instr_pos = content.find("populate the structured response").unwrap();
    assert!(
        apex_pos < instr_pos,
        "APEX section must precede closing instruction"
    );
}

/// Empty apex_results ⇒ no APEX section.
/// Why/What: default config (APEX disabled) must not emit a stray heading. Test: no network.
#[test]
fn prompt_no_apex_section_when_empty() {
    let content = build_review_prompt(
        "acme",
        "backend",
        &sample_meta(),
        "+fn x() {}",
        &empty_context(),
        "",
        "openai/gpt-5.4-mini-20260317",
    )
    .messages[0]
        .content
        .clone();
    assert!(!content.contains("Related APEX product specs"));
    assert!(!content.contains("[apex:"));
}