drep-ai 3.1.5

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
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
//! The deterministic layer of `drep check`.
//!
//! Every configured tool runs once per language, over the union of files of
//! that language. The two join operations that have to live here:
//!
//! - **Tool → files**: a `ToolStatus::Unavailable` outcome is per-tool, but
//!   `CheckOutcome::failures` is per-file. The mapping is built once,
//!   keyed by `(language, tool)`, so every file in the batch gets the same
//!   `ToolUnavailable` reason. The orchestrator unions this into the LLM
//!   layer's failures.
//! - **Skipped vs. Unavailable**: a tool that is not configured for the
//!   project is `Skipped` and contributes nothing — it never appears in
//!   `failures`. A tool that is configured but cannot run is `Unavailable`
//!   and contributes one failure per file.
//!
//! Tool invocation is batched, not per-file: a project with twenty Python
//! files would otherwise pay twenty `ruff` process starts. The batch lives
//! in `run_tool`; this module just decides which files to pass.

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

use futures::stream::{self, StreamExt};

use crate::analysis::findings::Finding;
use crate::analysis::result::{FailureReason, union_failures};
use crate::cli::check::input::Work;
use crate::languages;
use crate::languages::runner::{self};
use crate::languages::spec::ToolSpec;

const TOOL_PROCESS_CONCURRENCY: usize = 4;

/// Run every configured deterministic tool against the work set and return
/// its findings.
///
/// `failures` is filled in place: every file in a batch whose tool ran with
/// `ToolStatus::Unavailable` lands there with a `FailureReason::ToolUnavailable`.
/// The orchestrator unions this with the LLM layer's failures; the first
/// reason wins on a key collision, matching `AnalysisResult::merge`.
pub async fn run(
    work: &Work,
    root: &Path,
) -> (
    Vec<Finding>,
    BTreeMap<PathBuf, FailureReason>,
    BTreeSet<PathBuf>,
) {
    let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
    let (tasks, unconfigured) = plan_tasks(work, root);
    // In staged and branch modes a linter reads the working tree, so a task
    // that would read content the reviewed commit does not hold is refused
    // rather than run: each of its files fails with the differing paths named,
    // and the commit is not waved through on a lint of code it does not
    // contain. With no uncommitted paths - the ordinary case, and paths mode -
    // this changes nothing.
    let base = runner::absolute(root);
    let differences = Differences {
        differing: differing_paths(&base, &work.uncommitted),
        index_only: differing_paths(&base, &work.index_only),
    };
    let (tasks, refused) = partition_uncommitted(tasks, &base, &differences);
    for (task, paths) in refused {
        let reason = FailureReason::UncommittedChanges {
            tool: task.spec.name.to_owned(),
            paths,
        };
        fail_all(
            task.files.into_iter().map(|file| file.original),
            &reason,
            &mut failures,
        );
    }
    fail_uncommitted_markers(unconfigured, &base, &differences, &mut failures);
    let (serial, parallel): (Vec<_>, Vec<_>) = tasks
        .into_iter()
        .partition(|task| task.spec.serial_in_repository);
    let parallel = stream::iter(parallel)
        .map(|task| run_one(task, root))
        .buffer_unordered(TOOL_PROCESS_CONCURRENCY)
        .collect::<Vec<_>>();
    let serial = async {
        let mut outcomes = Vec::with_capacity(serial.len());
        for task in serial {
            outcomes.push(run_one(task, root).await);
        }
        outcomes
    };
    let (mut outcomes, serial_outcomes) = tokio::join!(parallel, serial);
    outcomes.extend(serial_outcomes);
    let mut findings = Vec::new();
    let mut compiled = BTreeSet::new();
    for (outcome, files) in outcomes {
        merge_outcome(outcome, files, &mut failures, &mut findings, &mut compiled);
    }
    (findings, failures, compiled)
}

/// One deterministic-tool invocation: the spec and the files it should be
/// invoked with. The tool name is on the spec, so it is not duplicated here.
struct PlannedTask {
    spec: &'static ToolSpec,
    workspace_root: PathBuf,
    files: Vec<PlannedFile>,
}

/// A (tool, file) pair whose tool found no configured workspace on disk.
///
/// Ordinarily dropped - an unconfigured project has not opted into the tool.
/// Kept here because in staged mode the marker may be missing from the
/// working tree while the committing index still holds it, and silently
/// skipping the file then reports as clean a file the commit's own
/// configuration would have linted.
struct Unconfigured {
    spec: &'static ToolSpec,
    /// The file as the caller named it, which the failure reports.
    file: PathBuf,
    /// The same file absolute, which the marker comparison resolves against.
    absolute: PathBuf,
}

struct PlannedFile {
    original: PathBuf,
    absolute: PathBuf,
    argument: String,
}

/// Run one planned task and return its outcome alongside the files it was
/// given. The files list is moved back out so the caller can map the
/// outcome back to per-file failures without re-borrowing.
async fn run_one(task: PlannedTask, root: &Path) -> (runner::ToolOutcome, Vec<PathBuf>) {
    let PlannedTask {
        spec,
        workspace_root,
        files,
        ..
    } = task;
    let mut arguments = Vec::with_capacity(files.len());
    let mut originals = Vec::with_capacity(files.len());
    let mut original_by_absolute = BTreeMap::new();
    for file in files {
        arguments.push(file.argument);
        originals.push(file.original.clone());
        original_by_absolute.insert(file.absolute, file.original);
    }
    let mut outcome = runner::run_tool_at(spec, root, &workspace_root, &arguments).await;
    // The canonical index is the second spelling `retain_requested` also
    // compares: a tool deriving paths from its own resolved cwd answers
    // through symlinks drep left alone. It is built on the first finding that
    // misses in exact form, because under an ordinary checkout none ever does
    // and resolving every planned file up front spends a `realpath` each to
    // answer a question nothing asks.
    let mut original_by_canonical: Option<BTreeMap<PathBuf, PathBuf>> = None;
    for finding in &mut outcome.findings {
        let joined = runner::joined_reported(&workspace_root, &finding.file_path);
        let original = match original_by_absolute.get(&joined) {
            Some(original) => Some(original),
            None => joined.canonicalize().ok().and_then(|resolved| {
                original_by_canonical
                    .get_or_insert_with(|| {
                        original_by_absolute
                            .iter()
                            .filter_map(|(absolute, original)| {
                                Some((absolute.canonicalize().ok()?, original.clone()))
                            })
                            .collect()
                    })
                    .get(&resolved)
            }),
        };
        if let Some(original) = original {
            finding.file_path = original.to_string_lossy().into_owned();
        }
    }
    (outcome, originals)
}

/// One uncommitted path, as the user names it (which a failure reports) and
/// as a lexically normal absolute path (which the comparisons use). Named
/// from a subdirectory, an entry climbs with `..`, which a plain join leaves
/// in place.
///
/// Comparisons are lexical on absolute paths, never canonicalized: a symlinked
/// checkout is a spelling the rest of this module deliberately leaves alone,
/// and `realpath` would answer about a different path than the one the tool
/// opens.
struct Differing {
    absolute: PathBuf,
    named: PathBuf,
}

/// What the working tree does not hold of the commit: paths it holds
/// otherwise (`differing`), and configuration a sparse checkout left out of
/// it (`index_only`), which no tool can read but which still configures one.
struct Differences {
    differing: Vec<Differing>,
    index_only: Vec<Differing>,
}

impl Differences {
    /// The paths among both that name one of `spec`'s markers in
    /// `directories`, directly or through a link, sorted.
    fn markers(&self, spec: &ToolSpec, directories: &BTreeSet<PathBuf>) -> Vec<PathBuf> {
        let links = marker_links(spec, directories);
        let is_marker = |path: &Differing| {
            names_marker(spec, directories, &path.absolute) || links.contains(&path.absolute)
        };
        let mut paths = named_where(&self.differing, is_marker);
        paths.extend(named_where(&self.index_only, is_marker));
        paths.sort();
        paths
    }
}

/// The uncommitted paths in the order `uncommitted` holds them, which is
/// sorted, so every subset taken from them is too.
fn differing_paths(base: &Path, uncommitted: &BTreeSet<PathBuf>) -> Vec<Differing> {
    uncommitted
        .iter()
        .map(|named| Differing {
            absolute: runner::lexically_normal(&base.join(named)),
            named: named.clone(),
        })
        .collect()
}

/// The paths among `differing` that `reads` accepts, as the user names them,
/// sorted.
fn named_where(differing: &[Differing], reads: impl Fn(&Differing) -> bool) -> Vec<PathBuf> {
    differing
        .iter()
        .filter(|path| reads(path))
        .map(|path| path.named.clone())
        .collect()
}

/// Split `tasks` into those that may run and those that would read
/// working-tree content the commit does not hold, with the differing paths
/// each refused task would read.
fn partition_uncommitted(
    tasks: Vec<PlannedTask>,
    base: &Path,
    differences: &Differences,
) -> (Vec<PlannedTask>, Vec<(PlannedTask, Vec<PathBuf>)>) {
    if differences.differing.is_empty() && differences.index_only.is_empty() {
        return (tasks, Vec::new());
    }
    let mut runnable = Vec::new();
    let mut refused = Vec::new();
    for task in tasks {
        let reads = task_uncommitted_reads(&task, base, differences);
        if reads.is_empty() {
            runnable.push(task);
        } else {
            refused.push((task, reads));
        }
    }
    (runnable, refused)
}

/// The differing paths `task` would read, as the user names them, sorted.
///
/// A tool that reads beyond the files it is given (`ToolSpec::reads_other_sources`:
/// every whole-project tool, and ShellCheck following `source`, eslint and
/// cppcheck following imports and includes) is refused by any uncommitted
/// path: a path dependency, an import or a sourced helper can sit anywhere in
/// the repository, under any name, and in another language drep registers (a
/// C++ file includes a C header, TypeScript imports JavaScript). A tool that
/// reads only its files reads its batch and its config markers, through any
/// symlink, in any directory from a batch file's own up to the root: the workspace is the
/// nearest configured one on disk, and a nearer marker the working tree no
/// longer holds can be the commit's configuration.
fn task_uncommitted_reads(
    task: &PlannedTask,
    base: &Path,
    differences: &Differences,
) -> Vec<PathBuf> {
    let directories =
        marker_directories(task.files.iter().map(|file| file.absolute.as_path()), base);
    let files: Vec<PathBuf> = task
        .files
        .iter()
        .map(|file| runner::lexically_normal(&file.absolute))
        .collect();
    // A marker a sparse checkout left out is still the commit's configuration,
    // and a batch file it left out is one the tool would never see, however it
    // reads, while the task would report that file checked.
    let mut reads = differences.markers(task.spec, &directories);
    reads.extend(named_where(&differences.index_only, |path| {
        files.contains(&path.absolute)
    }));
    if task.spec.reads_other_sources {
        reads.extend(named_where(&differences.differing, |_| true));
    } else {
        let targets = canonical_reads(task, &directories);
        reads.extend(named_where(&differences.differing, |path| {
            files.contains(&path.absolute)
                || path
                    .absolute
                    .canonicalize()
                    .is_ok_and(|target| targets.contains(&target))
        }));
    }
    reads.sort();
    reads.dedup();
    reads
}

/// Where each of `spec`'s markers in `directories` that is a symlink points,
/// followed lexically link by link, so a link whose target the working tree
/// no longer holds still names it: git reports the deleted target, not the
/// unchanged link, and the tool, finding no marker on disk, is not configured
/// by it. A glob marker names no one file to follow.
fn marker_links(spec: &ToolSpec, directories: &BTreeSet<PathBuf>) -> BTreeSet<PathBuf> {
    let mut targets = BTreeSet::new();
    for directory in directories {
        for name in spec
            .config_files
            .iter()
            .filter(|name| !name.starts_with("*."))
        {
            let mut link = directory.join(name);
            // A target already found ends the chain, so a cycle cannot loop.
            while let Ok(target) = std::fs::read_link(&link) {
                let next = runner::lexically_normal(&match link.parent() {
                    Some(parent) => parent.join(&target),
                    None => target,
                });
                if !targets.insert(next.clone()) {
                    break;
                }
                link = next;
            }
        }
    }
    targets
}

/// What a tool that reads only its files opens, followed through any symlink:
/// its batch files and the config marker it finds in each of `directories`.
/// Git names a symlink's target when the target differs, not the link a batch
/// holds, so a differing path is matched by its canonical target too, as the
/// findings a tool reports through a symlinked checkout are.
fn canonical_reads(task: &PlannedTask, directories: &BTreeSet<PathBuf>) -> BTreeSet<PathBuf> {
    let markers = directories.iter().filter_map(|directory| {
        runner::configured_marker(task.spec, directory).map(|name| directory.join(name))
    });
    task.files
        .iter()
        .map(|file| file.absolute.clone())
        .chain(markers)
        .filter_map(|path| path.canonicalize().ok())
        .collect()
}

/// Every directory a tool's config markers for `files` can be rooted in: each
/// file's own directory and every one above it within `base`.
fn marker_directories<'a>(files: impl Iterator<Item = &'a Path>, base: &Path) -> BTreeSet<PathBuf> {
    files
        .filter_map(Path::parent)
        .flat_map(|directory| runner::ancestors_within(directory, base))
        .collect()
}

/// Whether `path` is one of `spec`'s config markers rooted in one of
/// `directories`. A marker can name a nested path (Checkstyle's
/// `config/checkstyle/checkstyle.xml`), so each directory is tried as its
/// root rather than only the marker's own parent.
fn names_marker(spec: &ToolSpec, directories: &BTreeSet<PathBuf>, path: &Path) -> bool {
    directories.iter().any(|directory| {
        path.starts_with(directory)
            && spec
                .config_files
                .iter()
                .any(|name| runner::marker_names_path(directory, name, path))
    })
}

/// Fail the file of each pair whose tool found no configured workspace when
/// one of the tool's config markers is uncommitted in the file's directory or
/// a directory above it within the root.
///
/// Such a pair is ordinarily dropped: the project has not opted in. But in
/// staged mode the marker can be absent from the working tree while the
/// committing index still holds it, and then the tool that should have gated
/// the file would be silently skipped.
fn fail_uncommitted_markers(
    unconfigured: Vec<Unconfigured>,
    base: &Path,
    differences: &Differences,
    failures: &mut BTreeMap<PathBuf, FailureReason>,
) {
    if differences.differing.is_empty() && differences.index_only.is_empty() {
        return;
    }
    for pair in unconfigured {
        let directories = marker_directories(std::iter::once(pair.absolute.as_path()), base);
        let paths = differences.markers(pair.spec, &directories);
        if !paths.is_empty() {
            let reason = FailureReason::UncommittedChanges {
                tool: pair.spec.name.to_owned(),
                paths,
            };
            fail_all([pair.file], &reason, failures);
        }
    }
}

/// Plan the per-language, per-tool batches, alongside the (tool, file)
/// pairs that found no configured workspace.
///
/// "Per-language, per-tool" because the same tool can be configured for two
/// languages, and the spec list lives on the language, not globally. A
/// tool that appears in two languages' specs is run twice, once per
/// language, so the bins are disjoint.
///
/// An unconfigured pair is recorded rather than discarded: in staged mode
/// the marker may be missing from the working tree while the committing
/// index holds it, and the caller needs the pair to tell "the project never
/// opted in" from "the commit opted in and the working tree disagrees".
fn plan_tasks(work: &Work, root: &Path) -> (Vec<PlannedTask>, Vec<Unconfigured>) {
    // The bucketing itself is `languages::group_by_language`, so `doctor` and
    // `check` cannot disagree about which languages a repository contains -
    // doctor's whole job is to predict what check will do. What stays here is
    // only the part specific to this layer: reading one path per file out of
    // its hunks, and fanning each bucket out across that language's tools.
    //
    // `lint_only` is folded in alongside. A file too large for the LLM still
    // has a path, and these tools read the file themselves - excluding it
    // would silence ruff on a file purely because the model could not read it.
    let paths: Vec<&Path> = work
        .by_file
        .iter()
        .filter_map(|hunks| hunks.first())
        .map(|hunk| hunk.file_path.as_path())
        .chain(work.lint_only.iter().map(PathBuf::as_path))
        .collect();

    let repository_root = runner::absolute(root);
    let mut tasks = Vec::new();
    let mut unconfigured = Vec::new();
    for (language, files) in languages::group_by_language(&paths) {
        for spec in language.tools {
            let mut workspaces: BTreeMap<PathBuf, Vec<PlannedFile>> = BTreeMap::new();
            for file in &files {
                let absolute = if file.is_absolute() {
                    (*file).to_path_buf()
                } else {
                    repository_root.join(file)
                };
                let Some(workspace_root) = runner::configuration_root(spec, &repository_root, file)
                else {
                    unconfigured.push(Unconfigured {
                        spec,
                        file: (*file).to_path_buf(),
                        absolute,
                    });
                    continue;
                };
                let Ok(relative) = absolute.strip_prefix(&workspace_root) else {
                    continue;
                };
                let argument = relative.to_string_lossy().into_owned();
                workspaces
                    .entry(workspace_root)
                    .or_default()
                    .push(PlannedFile {
                        original: (*file).to_path_buf(),
                        absolute,
                        argument,
                    });
            }
            tasks.extend(
                workspaces
                    .into_iter()
                    .map(|(workspace_root, files)| PlannedTask {
                        spec,
                        workspace_root,
                        files,
                    }),
            );
        }
    }
    (tasks, unconfigured)
}

/// Apply one tool outcome: append findings, and — for `Unavailable` —
/// record the per-file failure the orchestrator's exit-2 contract rests on.
fn merge_outcome(
    outcome: runner::ToolOutcome,
    files: Vec<PathBuf>,
    failures: &mut BTreeMap<PathBuf, FailureReason>,
    findings: &mut Vec<Finding>,
    compiled: &mut BTreeSet<PathBuf>,
) {
    if outcome.compilation_succeeded {
        compiled.extend(files.iter().cloned());
    }
    match outcome.status {
        runner::ToolStatus::Ok => {
            findings.extend(outcome.findings);
        }
        runner::ToolStatus::Skipped => {
            // The project has no opinion here. Nothing to do.
        }
        runner::ToolStatus::Unavailable => {
            let reason = FailureReason::ToolUnavailable {
                tool: outcome.tool.to_owned(),
                detail: outcome.detail,
            };
            fail_all(files, &reason, failures);
        }
    }
}

/// Record `reason` for every one of `files`, the first reason winning on a collision as everywhere in the orchestrator.
fn fail_all(
    files: impl IntoIterator<Item = PathBuf>,
    reason: &FailureReason,
    failures: &mut BTreeMap<PathBuf, FailureReason>,
) {
    let batch: BTreeMap<PathBuf, FailureReason> = files
        .into_iter()
        .map(|file| (file, reason.clone()))
        .collect();
    union_failures(failures, batch);
}