Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/vite_global_cli/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,10 @@ fn delegated_help_doc(command: &str) -> Option<HelpDoc> {
vec![
row("--fix", "Auto-fix format and lint issues"),
row("--no-fmt", "Skip format check"),
row("--no-lint", "Skip lint check"),
row(
"--no-lint",
"Skip lint rules; type-check still runs when `lint.options.typeCheck` is true",
),
row(
"--no-error-on-unmatched-pattern",
"Do not exit with error when pattern is unmatched",
Expand Down
7 changes: 6 additions & 1 deletion docs/guide/check.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ We recommend turning `typeCheck` on so `vp check` becomes the single command for

```bash
vp check
vp check --fix # Format and run autofixers.
vp check --fix # Format and run autofixers.
vp check --no-fmt # Skip format; run lint (and type-check if enabled).
vp check --no-lint # Skip lint rules; keep type-check when enabled.
vp check --no-fmt --no-lint # Type-check only (requires `typeCheck` enabled).
```

When `lint.options.typeCheck` is enabled, `--no-lint` keeps type diagnostics by forwarding Oxlint's `--type-check-only` flag — useful for triaging type errors without lint noise. If `typeCheck` is not enabled, `--no-lint` simply skips the lint phase altogether, and `vp check --no-fmt --no-lint` exits with `No checks enabled` (enable `lint.options.typeCheck` to use the type-check-only invocation).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"by forwarding Oxlint's --type-check-only flag — useful for triaging type errors without lint noise."

This, to me feels like unnecessary info - it doesn't really mater how it works.

Interested to here @ fengmk2 @ cpojer 's thoughts


## Configuration

`vp check` uses the same configuration you already define for linting and formatting:
Expand Down
88 changes: 72 additions & 16 deletions packages/cli/binding/src/check/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,41 +39,61 @@ pub(super) struct LintFailure {
pub(super) enum LintMessageKind {
LintOnly,
LintAndTypeCheck,
TypeCheckOnly,
}

impl LintMessageKind {
pub(super) fn from_lint_config(lint_config: Option<&serde_json::Value>) -> Self {
let type_check_enabled = lint_config
.and_then(|config| config.get("options"))
.and_then(|options| options.get("typeCheck"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);

if type_check_enabled { Self::LintAndTypeCheck } else { Self::LintOnly }
pub(super) fn from_flags(lint_enabled: bool, type_check_enabled: bool) -> Self {
match (lint_enabled, type_check_enabled) {
(true, true) => Self::LintAndTypeCheck,
(true, false) => Self::LintOnly,
(false, true) => Self::TypeCheckOnly,
(false, false) => unreachable!(
"from_flags called with (false, false); caller must guard on run_lint_phase"
),
}
}

pub(super) fn success_label(self) -> &'static str {
match self {
Self::LintOnly => "Found no warnings or lint errors",
Self::LintAndTypeCheck => "Found no warnings, lint errors, or type errors",
Self::TypeCheckOnly => "Found no type errors",
}
}

pub(super) fn warning_heading(self) -> &'static str {
match self {
Self::LintOnly => "Lint warnings found",
Self::LintAndTypeCheck => "Lint or type warnings found",
Self::TypeCheckOnly => "Type warnings found",
}
}

pub(super) fn issue_heading(self) -> &'static str {
match self {
Self::LintOnly => "Lint issues found",
Self::LintAndTypeCheck => "Lint or type issues found",
Self::TypeCheckOnly => "Type errors found",
}
}
}

/// `typeCheck` requires `typeAware` as a prerequisite — oxlint's type-aware
/// analysis must be on for TypeScript diagnostics to surface.
pub(super) fn lint_config_type_check_enabled(lint_config: Option<&serde_json::Value>) -> bool {
let options = lint_config.and_then(|config| config.get("options"));
let type_aware = options
.and_then(|options| options.get("typeAware"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let type_check = options
.and_then(|options| options.get("typeCheck"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
type_aware && type_check
}

fn parse_check_summary(line: &str) -> Option<CheckSummary> {
let rest = line.strip_prefix("Finished in ")?;
let (duration, rest) = rest.split_once(" on ")?;
Expand Down Expand Up @@ -227,29 +247,65 @@ pub(super) fn analyze_lint_output(output: &str) -> Option<Result<LintSuccess, Li
mod tests {
use serde_json::json;

use super::LintMessageKind;
use super::{LintMessageKind, lint_config_type_check_enabled};

#[test]
fn lint_message_kind_defaults_to_lint_only_without_typecheck() {
assert_eq!(LintMessageKind::from_lint_config(None), LintMessageKind::LintOnly);
assert_eq!(
LintMessageKind::from_lint_config(Some(&json!({ "options": {} }))),
LintMessageKind::LintOnly
);
assert!(!lint_config_type_check_enabled(None));
assert!(!lint_config_type_check_enabled(Some(&json!({ "options": {} }))));
assert_eq!(LintMessageKind::from_flags(true, false), LintMessageKind::LintOnly);
}

#[test]
fn lint_message_kind_detects_typecheck_from_vite_config() {
let kind = LintMessageKind::from_lint_config(Some(&json!({
let config = json!({
"options": {
"typeAware": true,
"typeCheck": true
}
})));
});

assert!(lint_config_type_check_enabled(Some(&config)));

let kind = LintMessageKind::from_flags(true, true);
assert_eq!(kind, LintMessageKind::LintAndTypeCheck);
assert_eq!(kind.success_label(), "Found no warnings, lint errors, or type errors");
assert_eq!(kind.warning_heading(), "Lint or type warnings found");
assert_eq!(kind.issue_heading(), "Lint or type issues found");
}

#[test]
fn lint_message_kind_type_check_only_labels() {
let kind = LintMessageKind::from_flags(false, true);
assert_eq!(kind, LintMessageKind::TypeCheckOnly);
assert_eq!(kind.success_label(), "Found no type errors");
assert_eq!(kind.warning_heading(), "Type warnings found");
assert_eq!(kind.issue_heading(), "Type errors found");
}

#[test]
fn lint_config_type_check_enabled_rejects_non_bool_values() {
assert!(!lint_config_type_check_enabled(Some(&json!({
"options": { "typeAware": true, "typeCheck": "true" }
}))));
assert!(!lint_config_type_check_enabled(Some(&json!({
"options": { "typeAware": true, "typeCheck": 1 }
}))));
assert!(!lint_config_type_check_enabled(Some(&json!({
"options": { "typeAware": true, "typeCheck": null }
}))));
}

#[test]
fn lint_config_type_check_requires_type_aware_prerequisite() {
assert!(!lint_config_type_check_enabled(Some(&json!({
"options": { "typeCheck": true }
}))));
assert!(!lint_config_type_check_enabled(Some(&json!({
"options": { "typeAware": false, "typeCheck": true }
}))));
assert!(!lint_config_type_check_enabled(Some(&json!({
"options": { "typeAware": true, "typeCheck": false }
}))));
}
}
77 changes: 52 additions & 25 deletions packages/cli/binding/src/check/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use vite_task::ExitStatus;

use self::analysis::{
LintMessageKind, analyze_fmt_check_output, analyze_lint_output, format_count, format_elapsed,
print_error_block, print_pass_line, print_stdout_block, print_summary_line,
lint_config_type_check_enabled, print_error_block, print_pass_line, print_stdout_block,
print_summary_line,
};
use crate::cli::{
CapturedCommandOutput, SubcommandResolver, SynthesizableSubcommand, resolve_and_capture_output,
Expand All @@ -28,14 +29,6 @@ pub(crate) async fn execute_check(
cwd: &AbsolutePathBuf,
cwd_arc: &Arc<AbsolutePath>,
) -> Result<ExitStatus, Error> {
if no_fmt && no_lint {
output::error("No checks enabled");
print_summary_line(
"`vp check` did not run because both `--no-fmt` and `--no-lint` were set",
);
return Ok(ExitStatus(1));
}

let mut status = ExitStatus::SUCCESS;
let has_paths = !paths.is_empty();
// In --fix mode with file paths (the lint-staged use case), implicitly suppress
Expand All @@ -46,6 +39,18 @@ pub(crate) async fn execute_check(
let mut deferred_lint_pass: Option<(String, String)> = None;
let resolved_vite_config = resolver.resolve_universal_vite_config().await?;

let type_check_enabled = lint_config_type_check_enabled(resolved_vite_config.lint.as_ref());
let lint_enabled = !no_lint;
let run_lint_phase = lint_enabled || type_check_enabled;

if no_fmt && !run_lint_phase {
output::error("No checks enabled");
print_summary_line(
"Enable `lint.options.typeCheck` in vite.config.ts to use `vp check --no-fmt --no-lint` for type-check only, or drop a flag to re-enable fmt/lint.",
);
return Ok(ExitStatus(1));
}

if !no_fmt {
let mut args = if fix { vec![] } else { vec!["--check".to_string()] };
if suppress_unmatched {
Expand Down Expand Up @@ -109,7 +114,7 @@ pub(crate) async fn execute_check(
}
}

if fix && no_lint && status == ExitStatus::SUCCESS {
if fix && !run_lint_phase && status == ExitStatus::SUCCESS {
print_pass_line(
"Formatting completed for checked files",
Some(&format!("({})", format_elapsed(fmt_start.elapsed()))),
Expand All @@ -127,11 +132,12 @@ pub(crate) async fn execute_check(
}
}

if !no_lint {
let lint_message_kind =
LintMessageKind::from_lint_config(resolved_vite_config.lint.as_ref());
if run_lint_phase {
let lint_message_kind = LintMessageKind::from_flags(lint_enabled, type_check_enabled);
let mut args = Vec::new();
if fix {
// oxlint cannot auto-fix type diagnostics, so `--fix` is dropped on the
// type-check-only path.
if fix && lint_enabled {
args.push("--fix".to_string());
}
// `vp check` parses oxlint's human-readable summary output to print
Expand All @@ -140,6 +146,9 @@ pub(crate) async fn execute_check(
// parser think linting never started. Force the default reporter here so the
// captured output is stable across local and CI environments.
args.push("--format=default".to_string());
if !lint_enabled && type_check_enabled {
args.push("--type-check-only".to_string());
}
if suppress_unmatched {
args.push("--no-error-on-unmatched-pattern".to_string());
}
Expand Down Expand Up @@ -207,13 +216,18 @@ pub(crate) async fn execute_check(
}
}
if status != ExitStatus::SUCCESS {
// Surface fmt `--fix` completion before bailing so users can see
// the working tree was mutated before the lint/type-check error.
if fix && !no_fmt {
flush_deferred_pass_lines(&mut fmt_fix_started, &mut deferred_lint_pass);
}
return Ok(status);
}
}

// Re-run fmt after lint --fix, since lint fixes can break formatting
// (e.g. the curly rule adding braces to if-statements)
if fix && !no_fmt && !no_lint {
// (e.g. the curly rule adding braces to if-statements).
if fix && !no_fmt && lint_enabled {
let mut args = Vec::new();
if suppress_unmatched {
args.push("--no-error-on-unmatched-pattern".to_string());
Expand Down Expand Up @@ -241,20 +255,33 @@ pub(crate) async fn execute_check(
);
return Ok(status);
}
if let Some(started) = fmt_fix_started {
print_pass_line(
"Formatting completed for checked files",
Some(&format!("({})", format_elapsed(started.elapsed()))),
);
}
if let Some((message, detail)) = deferred_lint_pass.take() {
print_pass_line(&message, Some(&detail));
}
flush_deferred_pass_lines(&mut fmt_fix_started, &mut deferred_lint_pass);
}

// Type-check-only mode skips the re-fmt block above, so flush deferred
// pass lines here.
if fix && !no_fmt && run_lint_phase && !lint_enabled {
flush_deferred_pass_lines(&mut fmt_fix_started, &mut deferred_lint_pass);
}

Ok(status)
}

fn flush_deferred_pass_lines(
fmt_fix_started: &mut Option<Instant>,
deferred_lint_pass: &mut Option<(String, String)>,
) {
if let Some(started) = fmt_fix_started.take() {
print_pass_line(
"Formatting completed for checked files",
Some(&format!("({})", format_elapsed(started.elapsed()))),
);
}
if let Some((message, detail)) = deferred_lint_pass.take() {
print_pass_line(&message, Some(&detail));
}
}

/// Combine stdout and stderr from a captured command output.
fn combine_output(captured: CapturedCommandOutput) -> (ExitStatus, String) {
let combined = if captured.stderr.is_empty() {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/binding/src/cli/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ pub enum SynthesizableSubcommand {
/// Skip format check
#[arg(long = "no-fmt")]
no_fmt: bool,
/// Skip lint check
/// Skip lint rules; type-check still runs when `lint.options.typeCheck` is true
#[arg(long = "no-lint")]
no_lint: bool,
/// Do not exit with error when pattern is unmatched
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/snap-tests-global/command-check-help/snap.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Run format, lint, and type checks.
Options:
--fix Auto-fix format and lint issues
--no-fmt Skip format check
--no-lint Skip lint check
--no-lint Skip lint rules; type-check still runs when `lint.options.typeCheck` is true
--no-error-on-unmatched-pattern Do not exit with error when pattern is unmatched
-h, --help Print help

Expand All @@ -30,7 +30,7 @@ Run format, lint, and type checks.
Options:
--fix Auto-fix format and lint issues
--no-fmt Skip format check
--no-lint Skip lint check
--no-lint Skip lint rules; type-check still runs when `lint.options.typeCheck` is true
--no-error-on-unmatched-pattern Do not exit with error when pattern is unmatched
-h, --help Print help

Expand All @@ -52,7 +52,7 @@ Run format, lint, and type checks.
Options:
--fix Auto-fix format and lint issues
--no-fmt Skip format check
--no-lint Skip lint check
--no-lint Skip lint rules; type-check still runs when `lint.options.typeCheck` is true
--no-error-on-unmatched-pattern Do not exit with error when pattern is unmatched
-h, --help Print help

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/snap-tests/check-all-skipped/snap.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[1]> vp check --no-fmt --no-lint
error: No checks enabled

`vp check` did not run because both `--no-fmt` and `--no-lint` were set
Enable `lint.options.typeCheck` in vite.config.ts to use `vp check --no-fmt --no-lint` for type-check only, or drop a flag to re-enable fmt/lint.
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ error: Lint issues found
help: Avoid eval(). For JSON parsing use JSON.parse(); for dynamic property access use bracket notation (obj[key]); for other cases refactor to avoid evaluating strings as code.

Found 1 error and 0 warnings in 1 file (<variable>ms, <variable> threads)
pass: Formatting completed for checked files (<variable>ms)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "check-fix-no-fmt-no-lint-typecheck",
"version": "0.0.0",
"private": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
> vp check --fix --no-fmt --no-lint
pass: Found no type errors in 2 files (<variable>ms, <variable> threads)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const value: number = 42;
export { value };
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"env": {
"VITE_DISABLE_AUTO_INSTALL": "1"
},
"commands": ["vp check --fix --no-fmt --no-lint"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
lint: {
options: {
typeAware: true,
typeCheck: true,
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "check-fix-no-lint-typecheck-fail",
"version": "0.0.0",
"private": true
}
Loading
Loading