drep-ai 3.0.0

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
//! `drep doctor` - report what drep can actually do in this repository.
//!
//! Adoption question, not a debugging one. Before trusting drep as a gate, a
//! user wants to know the real coverage here: which languages are present,
//! which of their own tools will actually run, and whether the LLM half is
//! configured.
//!
//! **Diagnostic findings never fail `doctor`.** A broken provider or missing
//! tool still returns `Ok(Exit::Clean)`; it is diagnosis, and `drep check` is
//! the gate. Ordinary I/O failures can still be returned when the report
//! itself cannot be written or the platform cannot resolve its user paths.
//!
//! All output goes through a `&mut dyn std::io::Write` so the command is
//! testable without spawning a subprocess. The tests call [`run_to`] directly
//! against a captured buffer.

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

use anyhow::Result;
use clap::Args;

use crate::Exit;
use crate::cli::MachineFiles;
use crate::files;
use crate::languages;

mod llm;
mod site_section;

/// The header underline, exactly 60 characters wide. `write!` cannot express
/// the count cleanly, and the spec pins the exact width: a `=`-string of any
/// other length fails A2.
const HEADER_RULE: &str = "============================================================";

#[derive(Debug, Args)]
pub struct DoctorArgs {
    /// Repository or directory to report on.
    #[arg(value_name = "PATH", default_value = ".")]
    pub path: PathBuf,

    /// Config file to report on. Defaults to `drep.toml` under PATH.
    #[arg(long, value_name = "FILE")]
    pub config: Option<PathBuf>,
}

/// Run the command, writing to stdout. Diagnostic findings return
/// `Ok(Exit::Clean)`; failures to produce the report remain ordinary errors.
pub async fn run(args: &DoctorArgs) -> Result<Exit> {
    let mut out = std::io::stdout().lock();
    classify_output(run_to(&mut out, args).await)
}

/// Treat a reader closing stdout as a clean diagnostic exit while preserving
/// every other report failure.
fn classify_output(result: Result<Exit>) -> Result<Exit> {
    match result {
        Ok(exit) => Ok(exit),
        // `drep doctor | head -5` closes the pipe under us. That is the
        // reader's choice, not a diagnostic failure, and turning it into exit 2
        // would contradict this command's one contract.
        Err(err) if is_broken_pipe(&err) => Ok(Exit::Clean),
        Err(err) => Err(err),
    }
}

/// Whether `err` is the reader having closed the pipe.
fn is_broken_pipe(err: &anyhow::Error) -> bool {
    err.downcast_ref::<std::io::Error>()
        .is_some_and(|io| io.kind() == std::io::ErrorKind::BrokenPipe)
}

/// `run`, writing to an arbitrary sink so tests can capture the report.
pub async fn run_to<W: Write>(out: &mut W, args: &DoctorArgs) -> Result<Exit> {
    run_at(
        out,
        args,
        &MachineFiles {
            auth: &crate::auth::default_path()?,
            policy: &crate::config::site::default_path(),
        },
    )
    .await
}

/// `run_to`, against a named auth store and a named site policy file.
///
/// Both are parameters for the same reason `check`, `init` and `auth` take the
/// store: they are machine-level state, and a test reading the real ones reports
/// whatever the developer happens to have installed. They arrive as one
/// [`MachineFiles`] because two adjacent `&Path` positionals are a
/// transposition the compiler cannot catch.
pub async fn run_at<W: Write>(
    out: &mut W,
    args: &DoctorArgs,
    machine: &MachineFiles<'_>,
) -> Result<Exit> {
    run_at_with_codex(out, args, machine, &crate::llm::codex::current_status).await
}

/// [`run_at`] with the Codex readiness diagnostic injected for tests.
pub(crate) async fn run_at_with_codex<W: Write>(
    out: &mut W,
    args: &DoctorArgs,
    machine: &MachineFiles<'_>,
    codex_probe: &dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
) -> Result<Exit> {
    // `canonicalize` can fail (the path does not exist, or a parent is
    // unreadable). An unreadable path is still worth reporting on - the user
    // has typed something and wants to know what drep sees - so fall back to
    // the path as given rather than erroring out.
    let root = args
        .path
        .canonicalize()
        .unwrap_or_else(|_| args.path.clone());

    writeln!(out, "drep in {}", root.display())?;
    writeln!(out, "{HEADER_RULE}")?;

    let files = files::expand_paths(std::slice::from_ref(&root), files::is_scan_target);
    let file_refs: Vec<&Path> = files.iter().map(PathBuf::as_path).collect();
    let buckets = languages::group_by_language(&file_refs);

    if buckets.is_empty() {
        writeln!(out)?;
        writeln!(out, "No source files drep recognises were found here.")?;
        // The configuration sections still print. "Is my model configured?" is
        // the question a new user most needs answered, and a docs-only repo - or
        // one whose languages drep does not register - is exactly where they
        // are most likely to be asking it. Returning here answered it with
        // silence.
        write_configuration(out, args, &root, &files, machine, codex_probe).await?;
        return Ok(Exit::Clean);
    }

    write_languages_section(out, &buckets)?;
    // The missing list falls out of the same pass that printed the tool
    // lines. Recomputing it afterwards meant asking `tool_status` twice per
    // tool - each call stats the config files and walks PATH - and, worse,
    // left room for the summary to disagree with the lines above it.
    let missing = write_tools_section(out, &buckets, &root)?;
    write_configuration(out, args, &root, &files, machine, codex_probe).await?;

    // Deliberately last, after the LLM block: the user reads their coverage
    // report before being told what is wrong with it.
    if let Some(line) = missing_tools_line(&missing) {
        writeln!(out)?;
        writeln!(out, "{line}")?;
    }

    Ok(Exit::Clean)
}

/// The two configuration blocks, in the one order they are ever printed in.
///
/// Called from both report shapes so that order is stated once. The policy block
/// comes first because it governs the chain the block below it describes, and the
/// policy file is loaded once here rather than in each block: two loads of the
/// same file could disagree about it within one report.
///
/// The marker refusal is evaluated here too, through the same
/// `SiteConfig::refusal_among` the gate consults and against the directories of
/// the source files doctor found, so the report cannot describe a policy that
/// would behave differently at the gate. Its error is carried rather than
/// propagated: `drep check` fails closed on a policy it cannot evaluate, and this
/// is the command someone runs to find out why.
///
/// The answer is then handed to the LLM block, because a refusal governs it too:
/// `check` never mints a credential for a refused repository, and a `doctor` that
/// ran the helper anyway would prompt for an approval - and spend a real
/// credential call - on behalf of a repository whose review is refused.
async fn write_configuration<W: Write>(
    out: &mut W,
    args: &DoctorArgs,
    root: &Path,
    source_files: &[PathBuf],
    machine: &MachineFiles<'_>,
    codex_probe: &dyn Fn() -> Result<crate::llm::codex::CodexStatus, String>,
) -> Result<()> {
    let site = crate::config::site::load(machine.policy);
    let in_effect = site.as_ref().ok().and_then(Option::as_ref);
    let refusal = match in_effect {
        Some(site) => {
            let directories = doctor_policy_directories(root, source_files);
            site.refusal_among(&directories, machine.policy).await
        }
        None => Ok(None),
    };
    site_section::write_site_section(out, machine.policy, &site, &refusal)?;
    llm::write_llm_section(
        out,
        args,
        root,
        machine.auth,
        in_effect,
        Semantic::of(&site, &refusal),
        codex_probe,
    )
    .await
}

/// Repository-discovery starting points for the source doctor found.
///
/// `files::expand_paths` walks nested repositories, so asking only `root`
/// reports permission for a run that `check` refuses on an inner file. When no
/// recognized source exists, keep the root-level diagnostic: an operator still
/// needs to see that this checkout is marked even though there is currently no
/// semantic payload.
fn doctor_policy_directories(root: &Path, source_files: &[PathBuf]) -> BTreeSet<PathBuf> {
    if source_files.is_empty() {
        return BTreeSet::from([root.to_path_buf()]);
    }
    source_files
        .iter()
        .filter_map(|file| file.parent().map(Path::to_path_buf))
        .collect()
}

/// What the policy said about semantic review in this repository.
///
/// Three states, because the LLM block used to be told two. It received a
/// `bool` computed as `matches!(refusal, Ok(Some(_)))`, next to an
/// `Option<&SiteConfig>` computed as `site.as_ref().ok().flatten()`, and both
/// flattens sent the same answer for "permitted" and for "could not be
/// evaluated": a policy file that would not load, and a marker probe whose
/// repository root would not resolve. `check` exits 2 on either
/// (`config::site::load`'s `?` in `check::run_against`, and
/// `SiteConfigError::MarkerRootUnresolved`), so answering "not refused" is how
/// `doctor` came to run `api_key_command` - spending a real credential call and
/// triggering whatever approval sits behind it - for a repository whose review
/// never happens, and then print that the credential works. A check that did not
/// run, reported as a pass, in the command whose whole contract is what will
/// actually run here.
///
/// The policy itself still travels separately, because the concurrency clamp is
/// reported from it in every one of these states: a ceiling still applies to a
/// repository whose semantic review is refused.
#[derive(Clone, Copy)]
pub(super) enum Semantic {
    /// No policy, or a policy that permits this repository. The only state in
    /// which anything here may spend a credential.
    Permitted,
    /// A marker refuses semantic review at this repository's root.
    Refused,
    /// The policy could not be evaluated. `check` fails closed; this command
    /// exists to say why, not to proceed as though it had.
    Unevaluable,
}

impl Semantic {
    /// Why semantic setup is not attempted, when policy did not permit it.
    pub(super) fn skip_reason(self) -> Option<&'static str> {
        match self {
            Self::Permitted => None,
            Self::Refused => {
                Some("not attempted, because site policy refuses semantic review here")
            }
            Self::Unevaluable => {
                Some("not attempted, because the site policy above could not be evaluated")
            }
        }
    }

    /// Collapse the two results the report already holds into the one verdict
    /// that governs whether a credential may be spent.
    ///
    /// A load failure is checked before a probe failure only because the probe
    /// cannot have run without a loaded policy; either one is the same answer.
    fn of(
        site: &Result<
            Option<crate::config::site::SiteConfig>,
            crate::config::site::SiteConfigError,
        >,
        refusal: &Result<
            Option<crate::config::site::Refusal>,
            crate::config::site::SiteConfigError,
        >,
    ) -> Self {
        match (site, refusal) {
            (Err(_), _) | (_, Err(_)) => Self::Unevaluable,
            (Ok(_), Ok(Some(_))) => Self::Refused,
            (Ok(_), Ok(None)) => Self::Permitted,
        }
    }
}

/// Build the trailing "configured tool(s) are missing" line, or `None` when
/// there is nothing to report.
///
/// Extracted so A4 can pin the rendering independently of the runner's
/// availability on a particular developer machine.
fn missing_tools_line(missing: &[&str]) -> Option<String> {
    if missing.is_empty() {
        return None;
    }
    Some(format!(
        "{} configured tool(s) are missing: {}. drep exits 2 rather than reporting those files clean.",
        missing.len(),
        missing.join(", "),
    ))
}

/// `Languages found:` block, one line per detected language.
fn write_languages_section<W: Write>(
    out: &mut W,
    buckets: &[(&'static languages::spec::LanguageSupport, Vec<&Path>)],
) -> Result<()> {
    writeln!(out)?;
    writeln!(out, "Languages found:")?;
    for (language, paths) in buckets {
        writeln!(out, "  {}: {} file(s)", language.display_name, paths.len())?;
    }
    Ok(())
}

/// `Deterministic checks (these gate):` block. Tool status comes from
/// `runner::tool_status` so `doctor` cannot disagree with `check` about
/// whether a tool will run.
///
/// Returns the names of the tools that were `Unavailable`, so the trailing
/// summary is built from the same statuses that were printed rather than from
/// a second round of `tool_status` calls.
fn write_tools_section<W: Write>(
    out: &mut W,
    buckets: &[(&'static languages::spec::LanguageSupport, Vec<&Path>)],
    root: &Path,
) -> Result<Vec<&'static str>> {
    writeln!(out)?;
    writeln!(out, "Deterministic checks (these gate):")?;
    let mut missing: Vec<&'static str> = Vec::new();
    for (language, paths) in buckets {
        if language.tools.is_empty() {
            writeln!(out, "  {}: no tools wired up yet", language.display_name)?;
            continue;
        }
        for spec in language.tools {
            let roots: BTreeSet<PathBuf> = paths
                .iter()
                .filter_map(|path| languages::runner::configuration_root(spec, root, path))
                .collect();
            let outcome = if roots.is_empty() {
                languages::runner::tool_status(spec, root)
            } else {
                workspace_tool_status(spec, root, &roots)
            };
            writeln!(out, "  {}: {}", spec.name, outcome.detail)?;
            // `Skipped` is the project exercising a choice, not a problem.
            // Rendering it as one trains users to ignore the report.
            if matches!(outcome.status, languages::runner::ToolStatus::Unavailable)
                && !missing.contains(&spec.name)
            {
                // Deduplicated: `eslint` belongs to both JavaScript and
                // TypeScript, so a repo with both and no eslint binary
                // otherwise reported "2 configured tool(s) are missing:
                // eslint, eslint" - a count that overstates the problem and a
                // list that reads like a bug.
                missing.push(spec.name);
            }
        }
    }
    Ok(missing)
}

fn workspace_tool_status(
    spec: &'static languages::spec::ToolSpec,
    root: &Path,
    roots: &BTreeSet<PathBuf>,
) -> languages::runner::ToolOutcome {
    let statuses: Vec<_> = roots
        .iter()
        .map(|workspace| languages::runner::tool_status_at(spec, root, workspace))
        .collect();
    if let Some(unavailable) = statuses
        .iter()
        .find(|outcome| matches!(outcome.status, languages::runner::ToolStatus::Unavailable))
    {
        return unavailable.clone();
    }
    let detail = if roots.len() == 1 && roots.contains(&root.to_path_buf()) {
        "ready".to_owned()
    } else {
        format!("ready in {} workspace(s)", roots.len())
    };
    languages::runner::ToolOutcome {
        tool: spec.name,
        status: languages::runner::ToolStatus::Ok,
        findings: Vec::new(),
        detail,
        compilation_succeeded: false,
    }
}

#[cfg(test)]
mod unit_tests;

/// Acceptance tests live in their own directory under `tests/`, declared
/// from this module. The directory has its own `mod.rs` so the files there
/// are reachable by name - a Rust file no `mod` declaration reaches is never
/// compiled, and a test file that is never compiled looks exactly like a
/// passing one.
#[cfg(test)]
mod tests;