helen 0.1.0

Repository review gate.
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
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
//! Elenchus verification and review gate.

mod artifacts;
mod error;
mod message;
mod process;
mod review;
mod review_context;
mod risk;
mod settings;
mod text;

use self::{
    artifacts::{
        ARTIFACT_DIR, ATTEMPT_DIR, AttemptPaths, ReviewCachePaths, archive_old_attempt_artifacts,
        create_artifact_dirs,
    },
    error::{ElenchusError, Result},
    message::CommitMessage,
    process::{
        checked_command, diff_line_count, git_checked, git_diff_binary_to, git_hash_file,
        git_hash_stdin, git_status, git_status_silent, git_text, is_non_empty_file, meta_contains,
        random_hex, require_on_path, same_file_bytes,
    },
    risk::{RiskState, detect_risk_reasons},
    settings::Settings,
    text::{prefix_lines, shell_quote, short_hash},
};
use std::{
    env, fs,
    path::{Path, PathBuf},
    process::{Command, ExitCode},
};

/// Elenchus command result.
pub struct ElenchusExit {
    /// Process exit code to return to the operating system.
    code: ExitCode,
}

impl ElenchusExit {
    /// Converts this elenchus result into a process exit code.
    #[must_use]
    pub const fn into_exit_code(self) -> ExitCode {
        self.code
    }
}

/// Raw elenchus command arguments after top-level CLI parsing.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Args {
    /// Positional arguments following `alma elenchus`.
    words: Vec<String>,
}

impl Args {
    /// Creates elenchus arguments from CLI positionals.
    #[must_use]
    pub const fn new(words: Vec<String>) -> Self {
        Self { words }
    }
}

/// Parsed elenchus operation.
#[derive(Clone, Debug, Eq, PartialEq)]
enum Request {
    /// Run a fresh elenchus or an environment-token accepted rerun.
    Elenchus {
        /// Conventional Commit subject supplied by the caller.
        message: Option<String>,
    },
    /// Accept the cached passing risky-review for the current exact diff.
    Approve,
}

impl Request {
    /// Parses raw elenchus arguments.
    fn parse(args: Args) -> Result<Self> {
        let Args { words } = args;
        match words.as_slice() {
            [command] if command == "approve" => Ok(Self::Approve),
            [message] => Ok(Self::Elenchus {
                message: Some(message.clone()),
            }),
            [] => Ok(Self::Elenchus { message: None }),
            _ => Err(ElenchusError::usage(
                "error: elenchus expects one commit message or `alma elenchus approve`",
            )),
        }
    }
}

/// Runs the elenchus gate.
#[must_use]
pub fn run(args: Args) -> ElenchusExit {
    let result = Request::parse(args).and_then(|request| Gate::new(request)?.run());
    match result {
        Ok(()) => ElenchusExit {
            code: ExitCode::SUCCESS,
        },
        Err(error) => {
            println!("{error}");
            print_failure_worktree_status();
            ElenchusExit {
                code: ExitCode::from(error.code),
            }
        }
    }
}

/// Prints a best-effort reminder that a failed elenchus may leave local work.
fn print_failure_worktree_status() {
    let Ok(output) = Command::new("git").args(["status", "--short"]).output() else {
        return;
    };
    if !output.status.success() || output.stdout.is_empty() {
        return;
    }

    println!();
    println!("elenchus failed; uncommitted changes remain:");
    print!("{}", String::from_utf8_lossy(&output.stdout));
}

/// Returns the value for a `Label: value` metadata line.
fn metadata_value<'a>(metadata: &'a str, label: &str) -> Option<&'a str> {
    let prefix = format!("{label}: ");
    metadata.lines().find_map(|line| line.strip_prefix(&prefix))
}

/// The mutable state for one elenchus run.
struct Gate {
    /// Validated commit message.
    message: Option<CommitMessage>,
    /// Whether this run is accepting a cached risky-review for the current diff.
    approve: bool,
    /// Environment-derived settings.
    settings: Settings,
    /// Repository root.
    repo_root: PathBuf,
    /// Artifact paths for this attempt.
    paths: AttemptPaths,
    /// Whether `git add -N .` has touched the index.
    index_prepared: bool,
    /// Whether this run committed successfully.
    committed: bool,
}

impl Gate {
    /// Creates a elenchus gate in the current repository.
    fn new(request: Request) -> Result<Self> {
        let _git_path = require_on_path("git")?;
        let _cargo_path = require_on_path("cargo")?;
        let (message, approve) = match request {
            Request::Elenchus { message } => (Some(CommitMessage::parse(message)?), false),
            Request::Approve => (None, true),
        };

        let repo_root = PathBuf::from(git_text(["rev-parse", "--show-toplevel"])?);
        env::set_current_dir(&repo_root).map_err(|error| {
            ElenchusError::failure(format!(
                "error: failed to enter repo root {}: {error}",
                repo_root.display()
            ))
        })?;
        create_artifact_dirs()?;

        Ok(Self {
            message,
            approve,
            settings: Settings::from_env()?,
            repo_root,
            paths: AttemptPaths::new()?,
            index_prepared: false,
            committed: false,
        })
    }

    /// Runs the complete elenchus gate.
    fn run(&mut self) -> Result<()> {
        println!("==> elenchus: validating working tree");
        println!("==> elenchus: reviewer setup: deferred until review is needed");

        self.validate_git_state()?;
        self.prepare_index()?;

        println!("==> elenchus: recording diff");
        let base_head = git_text(["rev-parse", "HEAD"])?;
        git_diff_binary_to(&self.paths.diff)?;
        let diff_fingerprint = git_hash_file(&self.paths.diff)?;
        let cache_paths = ReviewCachePaths::new(&diff_fingerprint);
        cache_paths.create_dir()?;
        if self.approve {
            self.load_approval_from_cache(&cache_paths)?;
        }
        let commit_message_hash = git_hash_stdin(self.message().as_str())?;

        let mut risk = RiskState::new();
        self.validate_risk_token(
            &mut risk,
            &base_head,
            &commit_message_hash,
            &diff_fingerprint,
            &cache_paths,
        )?;
        if risk.override_accepted {
            println!(
                "==> elenchus: risky-change override: {}",
                risk.override_for_summary
            );
        }

        let changed_lines = diff_line_count()?;
        println!("changed lines: {changed_lines}");
        if changed_lines > self.settings.max_lines && !risk.override_accepted {
            return Err(ElenchusError::failure(format!(
                "error: diff is too large for automatic elenchus: {changed_lines} lines > {}\n\
                 Split the work or raise ELENCHUS_MAX_LINES for a fresh elenchus review.",
                self.settings.max_lines
            )));
        }

        println!("==> elenchus: checking risky changes");
        let changed_files = git_text(["diff", "--name-only"])?;
        risk.reasons = detect_risk_reasons(&changed_files, &git_text(["diff", "-U0"])?);
        Self::report_risk(&risk);

        self.run_local_checks()?;
        println!("==> elenchus: verifying checks did not mutate the worktree");
        git_diff_binary_to(&self.paths.post_checks_diff)?;
        if !same_file_bytes(&self.paths.diff, &self.paths.post_checks_diff)? {
            return Err(ElenchusError::failure(
                "error: working tree changed while running checks\n\
                 This can happen if tests/codegen/snapshots modified files.\n\
                 Review and rerun elenchus.",
            ));
        }

        self.run_or_reuse_review(&mut risk, &changed_files, &cache_paths, &diff_fingerprint)?;
        self.pause_for_risk_if_needed(
            &mut risk,
            &cache_paths,
            &base_head,
            &commit_message_hash,
            &diff_fingerprint,
        )?;
        self.stage_and_commit(&risk)?;

        Ok(())
    }

    /// Returns the validated commit message for paths that require one.
    const fn message(&self) -> &CommitMessage {
        self.message
            .as_ref()
            .expect("elenchus message is resolved before use")
    }

    /// Loads the reviewed message and risk token for `alma elenchus approve`.
    fn load_approval_from_cache(&mut self, cache_paths: &ReviewCachePaths) -> Result<()> {
        let metadata = fs::read_to_string(&cache_paths.meta).map_err(|error| {
            ElenchusError::failure(format!(
                "error: no elenchus approval cache for the current diff: {error}\n\
                 Run `alma elenchus \"<message>\"` first and review the paused risky diff."
            ))
        })?;
        let token = metadata_value(&metadata, "Risk acceptance token").ok_or_else(|| {
            ElenchusError::failure(
                "error: elenchus approval cache is missing its risk token\n\
                 Rerun the original elenchus to refresh the approval cache.",
            )
        })?;
        let message = metadata_value(&metadata, "Commit message").ok_or_else(|| {
            ElenchusError::failure(
                "error: elenchus approval cache is missing its reviewed commit message\n\
                 Rerun the original elenchus to refresh the approval cache.",
            )
        })?;

        self.message = Some(CommitMessage::parse(Some(message.to_owned()))?);
        self.settings.risk_accepted_token = Some(token.to_owned());
        println!("==> elenchus: approval: using cached review for current diff");
        Ok(())
    }

    /// Validates git state before the index is touched.
    fn validate_git_state(&self) -> Result<()> {
        let git_dir = PathBuf::from(git_text(["rev-parse", "--git-dir"])?);
        let git_dir = if git_dir.is_absolute() {
            git_dir
        } else {
            self.repo_root.join(git_dir)
        };
        if git_dir.join("rebase-merge").is_dir()
            || git_dir.join("rebase-apply").is_dir()
            || git_dir.join("MERGE_HEAD").is_file()
            || git_dir.join("CHERRY_PICK_HEAD").is_file()
        {
            return Err(ElenchusError::failure(
                "error: merge/rebase/cherry-pick in progress; refusing elenchus",
            ));
        }

        let current_branch = git_text(["branch", "--show-current"])?;
        if !self.settings.commit_policy.is_dry_run()
            && (current_branch == "main" || current_branch == "master")
            && !self.settings.main_branch_policy.allows_main()
        {
            return Err(ElenchusError::failure(format!(
                "error: refusing automatic elenchus commit on {current_branch}\n\
                 Set ELENCHUS_ALLOW_MAIN=1 to override."
            )));
        }

        if git_status(["check-ignore", "-q", ".helen/elenchus/test-ignore"])?.code() != Some(0) {
            println!("warning: {ARTIFACT_DIR} is not ignored");
            println!("Consider adding '.helen/elenchus/' to .gitignore.");
        }

        if git_status(["diff", "--cached", "--quiet", "--exit-code"])?.code() != Some(0) {
            return Err(ElenchusError::failure(
                "error: index already has staged changes\n\
                 Please unstage them or commit them before running elenchus:\n\
                   git restore --staged .",
            ));
        }

        Ok(())
    }

    /// Prepares intent-to-add entries so untracked files appear in the diff.
    fn prepare_index(&mut self) -> Result<()> {
        git_checked(["add", "-N", "."])?;
        self.index_prepared = true;

        if git_status(["diff", "--quiet", "--exit-code"])?.code() == Some(0) {
            return Err(ElenchusError::failure(
                "error: no working tree changes to elenchus",
            ));
        }

        Ok(())
    }

    /// Validates an accepted risky-change token, if one was supplied.
    fn validate_risk_token(
        &self,
        risk: &mut RiskState,
        base_head: &str,
        commit_message_hash: &str,
        diff_fingerprint: &str,
        cache_paths: &ReviewCachePaths,
    ) -> Result<()> {
        let Some(token) = self.settings.risk_accepted_token.as_deref() else {
            return Ok(());
        };

        let review_cache_hash = if is_non_empty_file(&cache_paths.review) {
            git_hash_file(&cache_paths.review)?
        } else {
            String::new()
        };
        let token_prefix = format!(
            "alma-risk:{}:{}:{}:",
            short_hash(diff_fingerprint),
            short_hash(commit_message_hash),
            short_hash(&review_cache_hash)
        );
        let token_valid = !review_cache_hash.is_empty()
            && is_non_empty_file(&cache_paths.review)
            && is_non_empty_file(&cache_paths.meta)
            && meta_contains(&cache_paths.meta, &format!("Base HEAD: {base_head}"))?
            && meta_contains(
                &cache_paths.meta,
                &format!("Commit message hash: {commit_message_hash}"),
            )?
            && meta_contains(
                &cache_paths.meta,
                &format!("Diff fingerprint: {diff_fingerprint}"),
            )?
            && meta_contains(
                &cache_paths.meta,
                &format!("Review hash: {review_cache_hash}"),
            )?
            && meta_contains(
                &cache_paths.meta,
                &format!("Risk acceptance token: {token}"),
            )?
            && token.starts_with(&token_prefix);

        if !token_valid {
            return Err(ElenchusError::failure(
                "error: accepted risk token does not match a reviewed passing cache for the current diff\n\
                 Rerun the original elenchus command to perform a fresh review.",
            ));
        }

        risk.override_accepted = true;
        risk.override_for_summary = if self.approve {
            "elenchus-approve"
        } else {
            "accepted-token"
        };
        token.clone_into(&mut risk.acceptance_token);
        risk.review_cache_hash = review_cache_hash;
        Ok(())
    }

    /// Prints risk diagnostics.
    fn report_risk(risk: &RiskState) {
        if risk.has_reasons() && !risk.override_accepted {
            println!(
                "warning: risky change detected; elenchus will stop before commit unless accepted"
            );
            for reason in &risk.reasons {
                println!(" - {reason}");
            }
            println!();
            println!(
                "Checks and read-only review will still run. If they pass and a human approves the risk,"
            );
            println!("elenchus will print a copyable approval command after the passing review.");
        }

        if risk.has_reasons() && risk.override_accepted {
            println!("==> elenchus: risky changes accepted for review/commit");
            for reason in &risk.reasons {
                println!(" - {reason}");
            }
        }
    }

    /// Runs local formatting, linting, and test checks.
    fn run_local_checks(&self) -> Result<()> {
        println!("==> elenchus: formatting check");
        checked_command("cargo", ["fmt", "--all", "--check"])?;

        println!("==> elenchus: clippy");
        checked_command(
            "cargo",
            [
                "clippy",
                "--workspace",
                "--all-targets",
                "--all-features",
                "--",
                "-D",
                "warnings",
            ],
        )?;

        if self.settings.test_policy.skips_tests() {
            println!("==> elenchus: tests skipped via ELENCHUS_SKIP_TESTS=1");
        } else {
            println!("==> elenchus: tests: {}", self.settings.test_cmd);
            checked_command("bash", ["-lc", self.settings.test_cmd.as_str()])?;
        }

        Ok(())
    }

    /// Pauses a risky change after passing review until a human accepts the token.
    fn pause_for_risk_if_needed(
        &self,
        risk: &mut RiskState,
        cache_paths: &ReviewCachePaths,
        base_head: &str,
        commit_message_hash: &str,
        diff_fingerprint: &str,
    ) -> Result<()> {
        if !risk.has_reasons() || risk.override_accepted {
            return Ok(());
        }

        let review_cache_hash = git_hash_file(&cache_paths.review)?;
        let token = format!(
            "alma-risk:{}:{}:{}:{}",
            short_hash(diff_fingerprint),
            short_hash(commit_message_hash),
            short_hash(&review_cache_hash),
            random_hex()?
        );
        risk.acceptance_token = token;
        fs::write(
            &cache_paths.meta,
            format!(
                "Diff fingerprint: {diff_fingerprint}\n\
                 Base HEAD: {base_head}\n\
                 Commit message: {}\n\
                 Commit message hash: {commit_message_hash}\n\
                 Review hash: {review_cache_hash}\n\
                 Risk acceptance token: {}\n\
                 Review cache: {}\n",
                self.message().as_str(),
                risk.acceptance_token,
                cache_paths.review.display()
            ),
        )
        .map_err(|error| {
            ElenchusError::failure(format!(
                "error: failed to write review cache metadata: {error}"
            ))
        })?;

        println!(
            "elenchus paused: risky change passed checks/review but needs human acceptance before commit"
        );
        for reason in &risk.reasons {
            println!(" - {reason}");
        }
        println!();
        println!("review: {}", self.paths.review_out.display());
        println!();
        println!("After reviewing the diff and reviewer output, approve this exact diff with:");
        println!("  alma elenchus approve");
        println!();
        println!("Approval token for audit/scripts:");
        println!("  APPROVE_ELENCHUS_RISK {}", risk.acceptance_token);
        println!(
            "  ELENCHUS_RISK_ACCEPTED={} alma elenchus {}",
            shell_quote(&risk.acceptance_token),
            shell_quote(self.message().as_str())
        );

        Err(ElenchusError::failure(String::new()))
    }

    /// Stages, optionally commits, and writes the summary artifact.
    fn stage_and_commit(&mut self, risk: &RiskState) -> Result<()> {
        println!("==> elenchus: staging changes");
        git_checked(["add", "-A"])?;
        let _status = git_status_silent(["restore", "--staged", ARTIFACT_DIR])?;

        if git_status(["diff", "--cached", "--quiet", "--exit-code"])?.code() == Some(0) {
            return Err(ElenchusError::failure(
                "error: no staged changes after excluding elenchus logs",
            ));
        }

        if self.settings.commit_policy.is_dry_run() {
            git_checked(["reset", "-q", "--", "."])?;
            self.archive_old_artifacts();
            println!("dry run: elenchus passed, but not committing");
            return Ok(());
        }

        println!("==> elenchus: committing");
        git_checked(["commit", "-m", self.message().as_str()])?;
        self.committed = true;

        let commit_sha = git_text(["rev-parse", "--short", "HEAD"])?;
        self.write_summary(&commit_sha, risk)?;
        self.archive_old_artifacts();

        println!();
        println!("elenchus passed");
        println!("committed: {commit_sha} {}", self.message().as_str());
        println!("review: {}", self.paths.review_out.display());
        println!("summary: {}", self.paths.summary.display());

        Ok(())
    }

    /// Archives older per-attempt elenchus artifacts.
    fn archive_old_artifacts(&self) {
        let result = archive_old_attempt_artifacts(
            Path::new(ATTEMPT_DIR),
            &self.paths.stamp,
            self.settings.retained_attempts,
        );
        match result {
            Ok(report) if report.archived_any() => {
                println!(
                    "==> elenchus: archived {} old elenchus attempt(s), {} file(s)",
                    report.attempts, report.files
                );
            }
            Ok(_) => {}
            Err(error) => {
                println!("warning: failed to archive old elenchus artifacts: {error}");
            }
        }
    }

    /// Writes the elenchus summary artifact.
    fn write_summary(&self, commit_sha: &str, risk: &RiskState) -> Result<()> {
        let test_summary = if self.settings.test_policy.skips_tests() {
            format!("{}: skipped", self.settings.test_cmd)
        } else {
            format!("{}: passed", self.settings.test_cmd)
        };
        let review = fs::read_to_string(&self.paths.review_out).map_err(|error| {
            ElenchusError::failure(format!("error: failed to read review output: {error}"))
        })?;
        fs::write(
            &self.paths.summary,
            format!(
                "# Elenchus {}\n\n\
                 Commit: {commit_sha}\n\
                 Message: {}\n\n\
                 Checks:\n\
                 - cargo fmt --all --check\n\
                 - cargo clippy --workspace --all-targets --all-features -- -D warnings\n\
                 - {test_summary}\n\n\
                 Risk:\n\
                 - Override: {}\n\
                 - Acceptance token: {}\n\
                 - Reasons:\n{}\n\n\
                Review:\n\
                 - Reused prior pass: {}\n{}",
                self.paths.stamp,
                self.message().as_str(),
                risk.override_for_summary,
                risk.acceptance_token,
                prefix_lines(&risk.reasons_for_review(), "  "),
                usize::from(risk.review_reused),
                prefix_lines(&review, "> ")
            ),
        )
        .map_err(|error| {
            ElenchusError::failure(format!("error: failed to write elenchus summary: {error}"))
        })
    }
}

impl Drop for Gate {
    /// Restores the index if the elenchus stops before commit.
    fn drop(&mut self) {
        if self.index_prepared && !self.committed {
            let _status = Command::new("git")
                .args(["reset", "-q", "--", "."])
                .current_dir(&self.repo_root)
                .status();
        }
    }
}