opi 0.10.0

Operations Interface — a project control center for your terminal
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
//! Project checks.
//!
//! `opi` implements none of these. It detects which tool a project uses, runs
//! it, and reports what came back. TypeScript errors come from `tsc`, lint
//! findings from Biome or ESLint, secrets from the scanner the project already
//! depends on.
//!
//! Which tools exist here was measured rather than assumed: across 133 real
//! projects Biome appeared in 51%, `tsc` in 41% and the secret scanner in 22%,
//! while Knip — prominent in the original plan — appeared in one. The command
//! each check runs is the one those projects already write in their scripts,
//! which is how `vitest run` got picked over a bare `vitest` that would sit in
//! watch mode forever.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};

use runemark::Verdict;

use crate::manifest::Manifest;
use crate::project::PackageManager;
use crate::workspace::Member;

/// One thing that can be checked, and the tool that checks it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Check {
    /// What is being checked, in the user's terms.
    pub name: &'static str,
    /// Which tool answers for it. Shown, so a result is never anonymous.
    pub tool: &'static str,
    /// Which package it belongs to, in a workspace. `None` is the root.
    pub scope: Option<String>,
    /// The executable in `node_modules/.bin`, or `yarn` when resolved through
    /// it instead — see [`resolve`].
    program: PathBuf,
    args: Vec<&'static str>,
    /// Where the tool runs. A member's checks run in the member, which is the
    /// only place its config and its sources make sense.
    dir: PathBuf,
}

impl Check {
    /// The command to run to see the tool's full output.
    ///
    /// Health shows an excerpt; this is how to get the rest without having to
    /// work out which tool ran and with what.
    pub fn command_line(&self) -> String {
        let program = self.program.file_name().map_or_else(
            || self.tool.to_owned(),
            |name| name.to_string_lossy().into_owned(),
        );
        std::iter::once(program)
            .chain(self.args.iter().map(|arg| (*arg).to_owned()))
            .collect::<Vec<_>>()
            .join(" ")
    }

    /// What to call this check on screen.
    pub fn label(&self) -> String {
        match &self.scope {
            Some(scope) => format!("{} ({scope})", self.name),
            None => self.name.to_owned(),
        }
    }
}

/// How a check turned out.
#[derive(Debug, Clone)]
pub struct Report {
    pub check: Check,
    pub verdict: Verdict,
    pub duration: Duration,
    /// Everything the tool wrote, kept whole.
    ///
    /// A parser that guesses at a tool's format is a parser that breaks on the
    /// next release of it; the raw output is what a developer needs anyway.
    pub output: String,
}

impl Report {
    pub fn passed(&self) -> bool {
        self.verdict == Verdict::Passed
    }

    /// Whether the tool itself could not be started.
    pub fn unusable(&self) -> bool {
        self.verdict == Verdict::ActionRequired
    }
}

/// A check's tool, and the arguments that make it report without changing
/// anything.
struct Candidate {
    name: &'static str,
    tool: &'static str,
    /// Package that has to be a dependency for this tool to apply.
    package: &'static str,
    args: &'static [&'static str],
}

/// Ordered within each concern, most specific first.
///
/// Only the first candidate of a concern runs. A project with both Biome and
/// Prettier gets one formatting answer, from the tool named in the result,
/// rather than two that can disagree.
const CANDIDATES: &[Candidate] = &[
    // `astro check` understands .astro files that `tsc` alone cannot.
    Candidate {
        name: "TypeScript",
        tool: "astro",
        package: "@astrojs/check",
        args: &["check"],
    },
    Candidate {
        name: "TypeScript",
        tool: "tsc",
        package: "typescript",
        args: &["--noEmit"],
    },
    // Biome checks lint and formatting in one pass.
    Candidate {
        name: "Lint & format",
        tool: "biome",
        package: "@biomejs/biome",
        args: &["check", "."],
    },
    Candidate {
        name: "Lint",
        tool: "eslint",
        package: "eslint",
        args: &["."],
    },
    Candidate {
        name: "Format",
        tool: "prettier",
        package: "prettier",
        args: &["--check", "."],
    },
    // A bare `vitest` watches; health has to terminate.
    Candidate {
        name: "Tests",
        tool: "vitest",
        package: "vitest",
        args: &["run"],
    },
    Candidate {
        name: "Tests",
        tool: "jest",
        package: "jest",
        args: &[],
    },
    Candidate {
        name: "Dead code",
        tool: "fallow",
        package: "fallow",
        args: &["dead-code"],
    },
    Candidate {
        name: "Dead code",
        tool: "knip",
        package: "knip",
        args: &[],
    },
    Candidate {
        name: "Secrets",
        tool: "nosecrets",
        package: "@casoon/nosecrets",
        args: &["scan", "."],
    },
    Candidate {
        name: "Secrets",
        tool: "nosecrets",
        package: "nosecrets",
        args: &["scan", "."],
    },
    Candidate {
        name: "Secrets",
        tool: "secretlint",
        package: "secretlint",
        args: &["**/*"],
    },
];

impl Check {
    /// Every check that applies, for the project and its workspace members.
    ///
    /// Members are checked in place. A monorepo keeps its TypeScript and its
    /// test runner in the packages rather than at the root — measured on a real
    /// workspace, the root declared only Biome, the secret scanner and a dead
    /// code tool, while `typescript` and `@astrojs/check` lived in both apps.
    /// Checking only the root would have reported two checks and missed four.
    pub fn detect_all(
        manifest: &Manifest,
        members: &[Member],
        rust_root: Option<&Path>,
        root: &Path,
        manager: PackageManager,
    ) -> Vec<Self> {
        let mut checks = Self::detect_in(manifest, root, None, manager);
        checks.extend(Self::lockfile(root, manager));
        for member in members {
            checks.extend(Self::detect_in(
                &member.manifest,
                &member.path,
                Some(member.name.clone()),
                manager,
            ));
        }
        if let Some(rust_root) = rust_root {
            checks.extend(Self::cargo(rust_root));
        }
        checks
    }

    /// The checks a Rust project answers.
    ///
    /// Scoped as "rust" even in a Rust-only project: in the twelve
    /// repositories carrying both manifests, "Tests" would otherwise mean two
    /// different things on two lines.
    fn cargo(root: &Path) -> Vec<Self> {
        let build = |name: &'static str, args: Vec<&'static str>| Self {
            name,
            tool: "cargo",
            scope: Some("rust".to_owned()),
            program: PathBuf::from("cargo"),
            args,
            dir: root.to_path_buf(),
        };

        // A library that commits no lockfile has none to be out of step with,
        // and `--locked` would fail for want of one rather than for drift.
        let lockfile = root
            .join("Cargo.lock")
            .exists()
            .then(|| build("Lockfile", crate::cargo::LOCKFILE_CHECK.to_vec()));

        crate::cargo::CHECKS
            .iter()
            .map(|(name, args)| build(name, args.to_vec()))
            .chain(lockfile)
            // The optional ones are separate binaries, so they are offered
            // where they are installed and absent otherwise — the shape
            // `cargo audit` and `cargo outdated` already have.
            .chain(
                crate::cargo::OPTIONAL_CHECKS
                    .iter()
                    .filter(|(_, subcommand, _)| crate::cargo::has_subcommand(subcommand))
                    .map(|(name, _, args)| build(name, args.to_vec())),
            )
            .collect()
    }

    /// Whether the lockfile still matches `package.json`, asked of the package
    /// manager that wrote it.
    ///
    /// The one project prerequisite worth a check. Measured across the
    /// repositories here, a declared `packageManager` version, `engines.node`
    /// and `rust-toolchain.toml` never disagreed with what ran — pnpm and
    /// rustup switch versions themselves, and every `engines` range was a
    /// lower bound the installed Node cleared — while 72 of 104 pnpm lockfiles
    /// were out of step with their manifest: a dependency added to
    /// `package.json` and never locked. Every CI that installs with a frozen
    /// lockfile stops there.
    ///
    /// Each command was measured to change nothing and to exit non-zero only
    /// on drift. pnpm's is `--offline` so the commit workflow never waits on
    /// the registry; it spots a mismatched specifier before resolving
    /// anything. yarn has no answer here: `yarn install --immutable` is a full
    /// install, and a check that installs is not a check.
    fn lockfile(root: &Path, manager: PackageManager) -> Option<Self> {
        let args: &[&'static str] = match manager {
            PackageManager::Pnpm => &[
                "install",
                "--frozen-lockfile",
                "--lockfile-only",
                "--ignore-scripts",
                "--offline",
            ],
            PackageManager::Npm => &["ci", "--dry-run", "--ignore-scripts"],
            PackageManager::Bun => &["install", "--frozen-lockfile", "--dry-run"],
            PackageManager::Yarn => return None,
        };
        // Only the manager's own lockfile counts: without one there is
        // nothing to be out of step, and `npm ci` would fail for its absence.
        PackageManager::lockfiles_in(root)
            .iter()
            .any(|(_, found)| *found == manager)
            .then(|| Self {
                name: "Lockfile",
                tool: manager.program(),
                scope: None,
                program: PathBuf::from(manager.program()),
                args: args.to_vec(),
                dir: root.to_path_buf(),
            })
    }

    /// The checks that apply to one package, rooted at `dir`.
    ///
    /// A tool the package does not depend on produces no check at all — an
    /// absent check is honest, a failing one would not be. Whether the tool
    /// is actually installed is, for Yarn, not something a filesystem probe
    /// can answer (see [`resolve`]); there, a missing tool surfaces through
    /// `Check::run` as `ActionRequired` instead of being left out here.
    fn detect_in(
        manifest: &Manifest,
        dir: &Path,
        scope: Option<String>,
        manager: PackageManager,
    ) -> Vec<Self> {
        let mut checks: Vec<Self> = Vec::new();

        for candidate in CANDIDATES {
            if checks.iter().any(|check| check.name == candidate.name) {
                continue;
            }
            if !manifest.depends_on(candidate.package) {
                continue;
            }
            let Some((program, prefix)) = resolve(dir, candidate.tool, manager) else {
                continue;
            };
            checks.push(Self {
                name: candidate.name,
                tool: candidate.tool,
                scope: scope.clone(),
                program,
                args: prefix
                    .into_iter()
                    .chain(candidate.args.iter().copied())
                    .collect(),
                dir: dir.to_path_buf(),
            });
        }

        checks
    }

    /// Runs the tool and reports what it said.
    pub fn run(self) -> Report {
        let started = Instant::now();
        let result = Command::new(&self.program)
            .args(&self.args)
            .current_dir(&self.dir)
            .output();

        let (verdict, output) = match result {
            Ok(output) => {
                let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
                text.push_str(&String::from_utf8_lossy(&output.stderr));
                // A non-zero exit here means the tool has findings, which is a
                // successful run with a result — not a broken tool.
                let verdict = if output.status.success() {
                    Verdict::Passed
                } else {
                    Verdict::Failed
                };
                (verdict, text)
            }
            // A tool that will not start is a broken installation, not a check
            // with findings; the remedy is different and so is the mark.
            Err(error) => (
                Verdict::ActionRequired,
                format!("could not run {}: {error}", self.tool),
            ),
        };

        Report {
            check: self,
            verdict,
            duration: started.elapsed(),
            output: output.trim_end().to_owned(),
        }
    }
}

/// Finds `tool` in a `node_modules/.bin` at or above `root`.
///
/// Workspaces hoist their binaries to the root, so a member package has none
/// of its own.
fn binary(root: &Path, tool: &str) -> Option<PathBuf> {
    root.ancestors()
        .map(|dir| dir.join("node_modules").join(".bin").join(tool))
        .find(|path| path.exists())
}

/// How to start `tool`: a binary path, or — for Yarn — `yarn run <tool>`.
///
/// Yarn's default linker (Plug'n'Play, Yarn ≥ 2) never creates
/// `node_modules`, so [`binary`] alone finds nothing installed under an
/// ordinary Yarn 4 project even though the tool is there. `yarn run <tool>`
/// resolves a binary exposed by a dependency the same way a declared script
/// would, PnP or not, and works for Yarn Classic too — so the filesystem
/// probe is tried first and only Yarn falls back to it. If the tool is
/// genuinely not installed, that surfaces once the check runs, as the same
/// `ActionRequired` a missing binary already produces elsewhere.
fn resolve(
    dir: &Path,
    tool: &'static str,
    manager: PackageManager,
) -> Option<(PathBuf, Vec<&'static str>)> {
    if let Some(program) = binary(dir, tool) {
        return Some((program, Vec::new()));
    }
    (manager == PackageManager::Yarn).then(|| (PathBuf::from("yarn"), vec!["run", tool]))
}

/// Runs `checks` concurrently, handing each report back as it finishes.
///
/// Concurrency is bounded: six Node processes at once can be slower than three
/// on a laptop, and a health check that pins the machine is worse than one that
/// takes a moment longer.
pub fn run_all(checks: Vec<Check>, mut on_report: impl FnMut(&Report)) -> Vec<Report> {
    use std::sync::mpsc;

    let limit = std::thread::available_parallelism()
        .map_or(2, std::num::NonZero::get)
        .clamp(1, 4);

    let mut reports = Vec::with_capacity(checks.len());
    let mut queue = checks.into_iter();
    let (sender, receiver) = mpsc::channel();
    let mut running = 0usize;

    std::thread::scope(|scope| {
        loop {
            while running < limit {
                let Some(check) = queue.next() else { break };
                let sender = sender.clone();
                running += 1;
                scope.spawn(move || {
                    let _ = sender.send(check.run());
                });
            }

            if running == 0 {
                break;
            }

            let Ok(report) = receiver.recv() else { break };
            running -= 1;
            on_report(&report);
            reports.push(report);
        }
    });

    // Slowest last would order by accident; by label it is the same every run.
    reports.sort_by_key(|report| report.check.label());
    reports
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn resolve_prefers_a_filesystem_binary_over_yarn() {
        let dir = tempfile::tempdir().expect("temp dir");
        let bin = dir.path().join("node_modules").join(".bin");
        fs::create_dir_all(&bin).expect("mkdir");
        fs::write(bin.join("eslint"), "").expect("write");

        let (program, prefix) =
            resolve(dir.path(), "eslint", PackageManager::Yarn).expect("found on disk");
        assert_eq!(program, bin.join("eslint"));
        assert!(prefix.is_empty(), "a real binary needs no yarn prefix");
    }

    #[test]
    fn resolve_falls_back_to_yarn_run_when_nothing_is_on_disk() {
        // Yarn's PnP linker never writes node_modules, so this is the normal
        // case for a Yarn >= 2 project, not an edge case.
        let dir = tempfile::tempdir().expect("temp dir");
        let (program, prefix) =
            resolve(dir.path(), "eslint", PackageManager::Yarn).expect("resolved through yarn");
        assert_eq!(program, PathBuf::from("yarn"));
        assert_eq!(prefix, ["run", "eslint"]);
    }

    #[test]
    fn resolve_does_not_guess_for_other_managers() {
        let dir = tempfile::tempdir().expect("temp dir");
        for manager in [
            PackageManager::Npm,
            PackageManager::Pnpm,
            PackageManager::Bun,
        ] {
            assert!(
                resolve(dir.path(), "eslint", manager).is_none(),
                "{manager:?} has no node_modules here and no yarn fallback to try"
            );
        }
    }

    #[test]
    fn a_yarn_pnp_project_still_gets_its_checks() {
        // The bug this pins: a Yarn 4 project using Plug'n'Play has no
        // node_modules/.bin, so the old binary-only lookup found nothing and
        // Health reported zero checks even though `yarn eslint` runs fine.
        let dir = tempfile::tempdir().expect("temp dir");
        fs::write(
            dir.path().join("package.json"),
            r#"{"name":"pnp","devDependencies":{"eslint":"9.0.0"}}"#,
        )
        .expect("write");
        let manifest = Manifest::load(dir.path()).expect("manifest");

        let checks = Check::detect_in(&manifest, dir.path(), None, PackageManager::Yarn);
        let lint = checks
            .iter()
            .find(|check| check.name == "Lint")
            .expect("eslint check is offered under Yarn PnP");
        assert_eq!(lint.command_line(), "yarn run eslint .");
    }

    #[test]
    fn a_lockfile_is_checked_by_the_manager_that_wrote_it() {
        let dir = tempfile::tempdir().expect("temp dir");
        fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("write");

        let check = Check::lockfile(dir.path(), PackageManager::Pnpm).expect("checked");
        assert_eq!(
            check.command_line(),
            "pnpm install --frozen-lockfile --lockfile-only --ignore-scripts --offline"
        );
    }

    #[test]
    fn no_lockfile_means_no_lockfile_check() {
        // `npm ci` without a package-lock.json fails for its absence, which
        // is not drift.
        let dir = tempfile::tempdir().expect("temp dir");
        assert!(Check::lockfile(dir.path(), PackageManager::Npm).is_none());
    }

    #[test]
    fn another_managers_lockfile_is_not_this_ones() {
        let dir = tempfile::tempdir().expect("temp dir");
        fs::write(dir.path().join("package-lock.json"), "{}").expect("write");
        assert!(Check::lockfile(dir.path(), PackageManager::Pnpm).is_none());
    }

    #[test]
    fn yarn_gets_no_lockfile_check() {
        // Its only strict form is a full install, and a check that installs
        // is not a check.
        let dir = tempfile::tempdir().expect("temp dir");
        fs::write(dir.path().join("yarn.lock"), "").expect("write");
        assert!(Check::lockfile(dir.path(), PackageManager::Yarn).is_none());
    }

    #[test]
    fn a_cargo_lockfile_is_checked_only_where_one_is_committed() {
        let dir = tempfile::tempdir().expect("temp dir");
        let named = |checks: Vec<Check>| checks.iter().any(|check| check.name == "Lockfile");

        assert!(
            !named(Check::cargo(dir.path())),
            "a library without Cargo.lock"
        );

        fs::write(dir.path().join("Cargo.lock"), "").expect("write");
        let checks = Check::cargo(dir.path());
        let lockfile = checks
            .iter()
            .find(|check| check.name == "Lockfile")
            .expect("checked");
        assert_eq!(lockfile.label(), "Lockfile (rust)");
        assert_eq!(
            lockfile.command_line(),
            "cargo metadata --locked --format-version 1"
        );
    }

    #[test]
    fn the_same_pnp_project_offers_nothing_under_npm() {
        // Without a real node_modules, npm has no fallback to reach for —
        // the Yarn path must not leak into the other managers.
        let dir = tempfile::tempdir().expect("temp dir");
        fs::write(
            dir.path().join("package.json"),
            r#"{"name":"solo","devDependencies":{"eslint":"9.0.0"}}"#,
        )
        .expect("write");
        let manifest = Manifest::load(dir.path()).expect("manifest");

        let checks = Check::detect_in(&manifest, dir.path(), None, PackageManager::Npm);
        assert!(
            checks.is_empty(),
            "no installed eslint, no yarn to fall back to"
        );
    }
}