1use crate::types::{Assertion, AssertionCheck};
22use std::collections::HashMap;
23use std::path::Path;
24use std::time::{Duration, Instant};
25
26const PER_COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
28
29const OVERALL_BUDGET: Duration = Duration::from_secs(180);
32
33#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum AssertionLintOutcome {
37 PassedOnBase,
42 FailedOnBase,
45 CouldNotVerdict,
48 NotLinted,
51}
52
53impl AssertionLintOutcome {
54 pub fn is_author_bug_suspect(&self) -> bool {
57 matches!(
58 self,
59 AssertionLintOutcome::PassedOnBase | AssertionLintOutcome::CouldNotVerdict
60 )
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct AssertionLint {
67 pub id: String,
68 pub command: String,
69 pub outcome: AssertionLintOutcome,
70 pub output_tail: String,
71}
72
73#[derive(Debug, Clone)]
75pub struct ContractLintReport {
76 pub results: Vec<AssertionLint>,
77 pub tree_clean_at_base: bool,
80}
81
82impl ContractLintReport {
83 pub fn suspects(&self) -> Vec<&AssertionLint> {
85 self.results
86 .iter()
87 .filter(|r| r.outcome.is_author_bug_suspect())
88 .collect()
89 }
90
91 pub fn is_empty(&self) -> bool {
93 self.results.is_empty()
94 }
95
96 pub fn summary(&self) -> String {
101 let mut parts: Vec<String> = Vec::new();
102
103 if !self.tree_clean_at_base {
104 parts.push(
105 "note: contract lint ran against a working tree with uncommitted changes; \
106 results may not reflect the pristine base"
107 .to_string(),
108 );
109 }
110
111 let suspects: Vec<&AssertionLint> = self.suspects();
112 if !suspects.is_empty() {
113 let list = suspects
114 .iter()
115 .map(|a| format!("[{}] {}", a.id, a.command))
116 .collect::<Vec<_>>()
117 .join("; ");
118 parts.push(format!(
119 "author-bug suspects (already pass / no verdict on the untouched base): {list}"
120 ));
121 }
122
123 let expected: Vec<&AssertionLint> = self
124 .results
125 .iter()
126 .filter(|r| r.outcome == AssertionLintOutcome::FailedOnBase)
127 .collect();
128 if !expected.is_empty() {
129 let list = expected
130 .iter()
131 .map(|a| format!("[{}] {}", a.id, a.command))
132 .collect::<Vec<_>>()
133 .join("; ");
134 parts.push(format!("base-expected-to-fail (benign): {list}"));
135 }
136
137 parts.join("\n")
138 }
139}
140
141pub fn classify(ran_to_completion: bool, exited_success: bool) -> AssertionLintOutcome {
150 if !ran_to_completion {
151 AssertionLintOutcome::CouldNotVerdict
152 } else if exited_success {
153 AssertionLintOutcome::PassedOnBase
154 } else {
155 AssertionLintOutcome::FailedOnBase
156 }
157}
158
159pub fn lint_env(
169 scratch: &Path,
170 base_sha: Option<&str>,
171 passthrough: &[String],
172) -> HashMap<String, String> {
173 let mut env = crate::agent_env::contract_command_env(scratch, base_sha, passthrough);
174 env.insert("GIT_CONFIG_COUNT".to_string(), "1".to_string());
175 env.insert("GIT_CONFIG_KEY_0".to_string(), "core.hooksPath".to_string());
176 env.insert("GIT_CONFIG_VALUE_0".to_string(), "/dev/null".to_string());
177 env
178}
179
180pub(crate) fn run_contract_lint(
185 cwd: &Path,
186 scratch: &Path,
187 base_sha: Option<&str>,
188 contract: &[Assertion],
189 tree_clean_at_base: bool,
190 passthrough: &[String],
191 sandbox: &crate::command_exec::GateSandbox,
192) -> ContractLintReport {
193 run_contract_lint_with_limits(
194 cwd,
195 scratch,
196 base_sha,
197 contract,
198 tree_clean_at_base,
199 PER_COMMAND_TIMEOUT,
200 OVERALL_BUDGET,
201 passthrough,
202 sandbox,
203 )
204}
205
206#[allow(clippy::too_many_arguments)]
210pub(crate) fn run_contract_lint_with_limits(
211 cwd: &Path,
212 scratch: &Path,
213 base_sha: Option<&str>,
214 contract: &[Assertion],
215 tree_clean_at_base: bool,
216 per_command: Duration,
217 overall: Duration,
218 passthrough: &[String],
219 sandbox: &crate::command_exec::GateSandbox,
220) -> ContractLintReport {
221 let env = lint_env(scratch, base_sha, passthrough);
222 let overall_start = Instant::now();
223 let mut results = Vec::new();
224
225 for assertion in contract {
226 if assertion.check != AssertionCheck::Command {
227 continue;
228 }
229 let Some(command) = assertion.command.as_deref() else {
230 continue;
231 };
232
233 if overall_start.elapsed() >= overall {
234 results.push(AssertionLint {
235 id: assertion.id.clone(),
236 command: command.to_string(),
237 outcome: AssertionLintOutcome::NotLinted,
238 output_tail: String::new(),
239 });
240 continue;
241 }
242
243 let (code, output_tail) = crate::command_exec::run_shell_command_sandboxed_blocking(
244 cwd,
245 command,
246 per_command,
247 &env,
248 sandbox,
249 );
250 let outcome = classify(code.is_some(), code == Some(0));
251 results.push(AssertionLint {
252 id: assertion.id.clone(),
253 command: command.to_string(),
254 outcome,
255 output_tail,
256 });
257 }
258
259 ContractLintReport {
260 results,
261 tree_clean_at_base,
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::test_shell::{sleep_millis, FAIL, SUCCEED};
269
270 fn run_lint(cwd: &Path, base_sha: Option<&str>, contract: &[Assertion]) -> ContractLintReport {
271 let scratch = tempfile::tempdir().unwrap();
272 run_contract_lint(
273 cwd,
274 scratch.path(),
275 base_sha,
276 contract,
277 true,
278 &[],
279 &crate::command_exec::GateSandbox::Disabled,
280 )
281 }
282
283 fn command_assertion(id: &str, command: &str) -> Assertion {
284 Assertion {
285 id: id.to_string(),
286 statement: format!("statement for {id}"),
287 check: AssertionCheck::Command,
288 command: Some(command.to_string()),
289 negative_control: None,
290 pty_script: None,
291 }
292 }
293
294 fn judgement_assertion(id: &str) -> Assertion {
295 Assertion {
296 id: id.to_string(),
297 statement: format!("statement for {id}"),
298 check: AssertionCheck::AgentJudgement,
299 command: None,
300 negative_control: None,
301 pty_script: None,
302 }
303 }
304
305 #[test]
306 fn approval_lint_summary_notes_dirty_tree_at_base() {
307 let dirty = ContractLintReport {
311 results: vec![AssertionLint {
312 id: "a1".to_string(),
313 command: "true".to_string(),
314 outcome: AssertionLintOutcome::PassedOnBase,
315 output_tail: String::new(),
316 }],
317 tree_clean_at_base: false,
318 };
319 let summary = dirty.summary();
320 assert!(
321 summary.contains(
322 "note: contract lint ran against a working tree with uncommitted changes"
323 ),
324 "{summary}"
325 );
326
327 let clean = ContractLintReport {
328 tree_clean_at_base: true,
329 ..dirty
330 };
331 assert!(
332 !clean.summary().contains("uncommitted changes"),
333 "{}",
334 clean.summary()
335 );
336 }
337
338 #[test]
339 fn approval_lint_classify_polarity() {
340 assert_eq!(classify(true, true), AssertionLintOutcome::PassedOnBase);
341 assert_eq!(classify(true, false), AssertionLintOutcome::FailedOnBase);
342 assert_eq!(classify(false, true), AssertionLintOutcome::CouldNotVerdict);
343 assert_eq!(
344 classify(false, false),
345 AssertionLintOutcome::CouldNotVerdict
346 );
347
348 assert!(AssertionLintOutcome::PassedOnBase.is_author_bug_suspect());
349 assert!(AssertionLintOutcome::CouldNotVerdict.is_author_bug_suspect());
350 assert!(!AssertionLintOutcome::FailedOnBase.is_author_bug_suspect());
351 assert!(!AssertionLintOutcome::NotLinted.is_author_bug_suspect());
352 }
353
354 #[test]
355 fn approval_lint_env_has_base_sha_and_disables_hooks() {
356 let scratch = tempfile::tempdir().unwrap();
357 let with_sha = lint_env(scratch.path(), Some("deadbeef"), &[]);
358 assert_eq!(
359 with_sha.get("KRANZ_BASE_SHA").map(String::as_str),
360 Some("deadbeef")
361 );
362 assert_eq!(
363 with_sha.get("GIT_CONFIG_COUNT").map(String::as_str),
364 Some("1")
365 );
366 assert_eq!(
367 with_sha.get("GIT_CONFIG_KEY_0").map(String::as_str),
368 Some("core.hooksPath")
369 );
370 assert_eq!(
371 with_sha.get("GIT_CONFIG_VALUE_0").map(String::as_str),
372 Some("/dev/null")
373 );
374
375 let without_sha = lint_env(scratch.path(), None, &[]);
376 assert!(!without_sha.contains_key("KRANZ_BASE_SHA"));
377 assert_eq!(
378 without_sha.get("GIT_CONFIG_COUNT").map(String::as_str),
379 Some("1")
380 );
381 assert_eq!(
382 without_sha.get("GIT_CONFIG_KEY_0").map(String::as_str),
383 Some("core.hooksPath")
384 );
385 assert_eq!(
386 without_sha.get("GIT_CONFIG_VALUE_0").map(String::as_str),
387 Some("/dev/null")
388 );
389 }
390
391 #[test]
392 fn approval_lint_runner_buckets_true_false() {
393 let contract = vec![
394 command_assertion("a1", SUCCEED),
395 command_assertion("a2", FAIL),
396 judgement_assertion("a3"),
397 ];
398 let report = run_lint(&std::env::temp_dir(), None, &contract);
399
400 assert_eq!(report.results.len(), 2);
401
402 let a1 = report.results.iter().find(|r| r.id == "a1").unwrap();
403 assert_eq!(a1.outcome, AssertionLintOutcome::PassedOnBase);
404
405 let a2 = report.results.iter().find(|r| r.id == "a2").unwrap();
406 assert_eq!(a2.outcome, AssertionLintOutcome::FailedOnBase);
407
408 let suspects = report.suspects();
409 assert_eq!(suspects.len(), 1);
410 assert_eq!(suspects[0].id, "a1");
411 }
412
413 #[test]
414 fn approval_lint_runner_times_out_slow_command() {
415 let contract = vec![command_assertion("a1", &sleep_millis(5_000))];
416 let scratch = tempfile::tempdir().unwrap();
417 let start = Instant::now();
418 let report = run_contract_lint_with_limits(
419 &std::env::temp_dir(),
420 scratch.path(),
421 None,
422 &contract,
423 true,
424 Duration::from_millis(200),
425 Duration::from_secs(600),
426 &[],
427 &crate::command_exec::GateSandbox::Disabled,
428 );
429 let elapsed = start.elapsed();
430
431 assert!(
432 elapsed < Duration::from_secs(2),
433 "runner should not wait for the full sleep, took {elapsed:?}"
434 );
435 assert_eq!(report.results.len(), 1);
436 assert_eq!(
437 report.results[0].outcome,
438 AssertionLintOutcome::CouldNotVerdict
439 );
440 assert!(report.results[0].outcome.is_author_bug_suspect());
441 }
442
443 #[test]
444 fn approval_lint_runner_budget_skips_remainder() {
445 let contract = vec![
446 command_assertion("a1", &sleep_millis(200)),
447 command_assertion("a2", SUCCEED),
448 command_assertion("a3", FAIL),
449 ];
450 let scratch = tempfile::tempdir().unwrap();
451 let report = run_contract_lint_with_limits(
452 &std::env::temp_dir(),
453 scratch.path(),
454 None,
455 &contract,
456 true,
457 Duration::from_secs(600),
458 Duration::from_millis(50),
459 &[],
460 &crate::command_exec::GateSandbox::Disabled,
461 );
462
463 assert_eq!(report.results.len(), 3);
464 let a2 = report.results.iter().find(|r| r.id == "a2").unwrap();
465 let a3 = report.results.iter().find(|r| r.id == "a3").unwrap();
466 assert_eq!(a2.outcome, AssertionLintOutcome::NotLinted);
467 assert_eq!(a3.outcome, AssertionLintOutcome::NotLinted);
468 assert!(!a2.outcome.is_author_bug_suspect());
469 assert!(!a3.outcome.is_author_bug_suspect());
470 }
471
472 #[test]
473 fn approval_lint_runner_flags_inverted_lockfile_grep_shape() {
474 if !crate::sandbox::command_available("grep") {
493 crate::test_capability::skip(
494 crate::test_capability::capability::GREP,
495 "no grep on PATH; the inverted-polarity shape is unexercised here",
496 );
497 return;
498 }
499 let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
500 .parent()
501 .and_then(Path::parent)
502 .expect("crates/engine has a workspace root two levels up")
503 .to_path_buf();
504 assert!(
505 repo_root.join("Cargo.lock").is_file(),
506 "expected {:?} to contain Cargo.lock",
507 repo_root
508 );
509
510 let contract = vec![command_assertion(
511 "a6",
512 r#"grep -L '^name = "tokio"' Cargo.lock"#,
513 )];
514 let report = run_lint(&repo_root, None, &contract);
515
516 assert_eq!(report.results.len(), 1);
517 let a6 = &report.results[0];
518 assert_eq!(a6.outcome, AssertionLintOutcome::PassedOnBase);
519 assert!(a6.outcome.is_author_bug_suspect());
520
521 let suspects = report.suspects();
522 assert_eq!(suspects.len(), 1);
523 assert_eq!(suspects[0].id, "a6");
524 }
525
526 #[tokio::test]
527 async fn approval_lint_runner_safe_under_tokio() {
528 let contract = vec![
529 command_assertion("a1", SUCCEED),
530 command_assertion("a2", FAIL),
531 ];
532 let report = run_lint(&std::env::temp_dir(), None, &contract);
533 assert_eq!(report.results.len(), 2);
534 }
535
536 #[cfg(unix)]
541 #[test]
542 fn approval_lint_command_cannot_see_ambient_secrets() {
543 let _poison = crate::agent_env::EnvTestGuard::engage(&[("GH_TOKEN", "hunter2-lint")]);
544
545 let contract = vec![command_assertion(
546 "a1",
547 "test -z \"$GH_TOKEN\" && env | grep -c hunter2-lint | grep -q '^0$'",
548 )];
549 let report = run_lint(&std::env::temp_dir(), None, &contract);
550
551 assert_eq!(report.results.len(), 1);
552 assert_eq!(
553 report.results[0].outcome,
554 AssertionLintOutcome::PassedOnBase,
555 "the poisoned ambient GH_TOKEN must be cleared from the lint env: {}",
556 report.results[0].output_tail
557 );
558 }
559}