Skip to content

Commit 34ac221

Browse files
authored
feat(check): redefine --no-lint for type-check-only workflow (#1444)
## Summary Redefines `vp check --no-lint` so it runs Oxlint's `--type-check-only` path when `lint.options.typeCheck` is enabled in `vite.config.ts`, and makes `vp check --no-fmt --no-lint` the natural type-check-only invocation. Replaces the abandoned PR #1417 approach (sidecar `.mjs` + `--no-type-check` CLI flag), which conflicts with upstream PR #1378 handing config resolution back to oxlint/oxfmt. Related: closes #1275 for the lint-rule-noise-free type-error triage workflow. ## Behavior matrix | Command | Behavior | | ----------------------------------------------- | ------------------------------------------------ | | `vp check --no-lint` (typeCheck=true) | Runs `--type-check-only`; lint rules suppressed | | `vp check --no-lint` (typeCheck=false/missing) | Skips lint phase entirely (existing behavior) | | `vp check --no-fmt --no-lint` (typeCheck=true) | Type-check only | | `vp check --no-fmt --no-lint` (typeCheck=false) | `error: No checks enabled` with remediation hint | | `vp check --fix --no-lint` (typeCheck=true) | fmt `--fix` + read-only type-check diagnostics | `--no-type-check` CLI flag is **not** introduced — deferred to a follow-up once oxlint exposes a symmetric OFF-direction flag for type-check. ## Key design notes - **typeAware prerequisite.** Type-check only runs when both `lint.options.typeAware` and `lint.options.typeCheck` are true, matching the `--type-check` / `--type-aware` flag dependency in `rfcs/check-command.md` ("If `--no-type-aware` is set, `--type-check` is also implicitly disabled"). - **`--fix --no-lint` on a typeCheck-enabled project** runs fmt `--fix` and then type-check as read-only diagnostics. On type error, fmt's completion pass line is still surfaced so lint-staged / pre-commit flows can tell the working tree was mutated before the failure (previously swallowed). - **No sidecar.** Config resolution is left to oxlint/oxfmt per PR #1378.
1 parent 1cd6165 commit 34ac221

38 files changed

Lines changed: 308 additions & 48 deletions

File tree

crates/vite_global_cli/src/help.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,10 @@ fn delegated_help_doc(command: &str) -> Option<HelpDoc> {
760760
vec![
761761
row("--fix", "Auto-fix format and lint issues"),
762762
row("--no-fmt", "Skip format check"),
763-
row("--no-lint", "Skip lint check"),
763+
row(
764+
"--no-lint",
765+
"Skip lint rules; type-check still runs when `lint.options.typeCheck` is true",
766+
),
764767
row(
765768
"--no-error-on-unmatched-pattern",
766769
"Do not exit with error when pattern is unmatched",

docs/guide/check.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ We recommend turning `typeCheck` on so `vp check` becomes the single command for
1414

1515
```bash
1616
vp check
17-
vp check --fix # Format and run autofixers.
17+
vp check --fix # Format and run autofixers.
18+
vp check --no-fmt # Skip format; run lint (and type-check if enabled).
19+
vp check --no-lint # Skip lint rules; keep type-check when enabled.
20+
vp check --no-fmt --no-lint # Type-check only (requires `typeCheck` enabled).
1821
```
1922

2023
## Configuration

packages/cli/binding/src/check/analysis.rs

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,41 +39,61 @@ pub(super) struct LintFailure {
3939
pub(super) enum LintMessageKind {
4040
LintOnly,
4141
LintAndTypeCheck,
42+
TypeCheckOnly,
4243
}
4344

4445
impl LintMessageKind {
45-
pub(super) fn from_lint_config(lint_config: Option<&serde_json::Value>) -> Self {
46-
let type_check_enabled = lint_config
47-
.and_then(|config| config.get("options"))
48-
.and_then(|options| options.get("typeCheck"))
49-
.and_then(serde_json::Value::as_bool)
50-
.unwrap_or(false);
51-
52-
if type_check_enabled { Self::LintAndTypeCheck } else { Self::LintOnly }
46+
pub(super) fn from_flags(lint_enabled: bool, type_check_enabled: bool) -> Self {
47+
match (lint_enabled, type_check_enabled) {
48+
(true, true) => Self::LintAndTypeCheck,
49+
(true, false) => Self::LintOnly,
50+
(false, true) => Self::TypeCheckOnly,
51+
(false, false) => unreachable!(
52+
"from_flags called with (false, false); caller must guard on run_lint_phase"
53+
),
54+
}
5355
}
5456

5557
pub(super) fn success_label(self) -> &'static str {
5658
match self {
5759
Self::LintOnly => "Found no warnings or lint errors",
5860
Self::LintAndTypeCheck => "Found no warnings, lint errors, or type errors",
61+
Self::TypeCheckOnly => "Found no type errors",
5962
}
6063
}
6164

6265
pub(super) fn warning_heading(self) -> &'static str {
6366
match self {
6467
Self::LintOnly => "Lint warnings found",
6568
Self::LintAndTypeCheck => "Lint or type warnings found",
69+
Self::TypeCheckOnly => "Type warnings found",
6670
}
6771
}
6872

6973
pub(super) fn issue_heading(self) -> &'static str {
7074
match self {
7175
Self::LintOnly => "Lint issues found",
7276
Self::LintAndTypeCheck => "Lint or type issues found",
77+
Self::TypeCheckOnly => "Type errors found",
7378
}
7479
}
7580
}
7681

82+
/// `typeCheck` requires `typeAware` as a prerequisite — oxlint's type-aware
83+
/// analysis must be on for TypeScript diagnostics to surface.
84+
pub(super) fn lint_config_type_check_enabled(lint_config: Option<&serde_json::Value>) -> bool {
85+
let options = lint_config.and_then(|config| config.get("options"));
86+
let type_aware = options
87+
.and_then(|options| options.get("typeAware"))
88+
.and_then(serde_json::Value::as_bool)
89+
.unwrap_or(false);
90+
let type_check = options
91+
.and_then(|options| options.get("typeCheck"))
92+
.and_then(serde_json::Value::as_bool)
93+
.unwrap_or(false);
94+
type_aware && type_check
95+
}
96+
7797
fn parse_check_summary(line: &str) -> Option<CheckSummary> {
7898
let rest = line.strip_prefix("Finished in ")?;
7999
let (duration, rest) = rest.split_once(" on ")?;
@@ -227,29 +247,65 @@ pub(super) fn analyze_lint_output(output: &str) -> Option<Result<LintSuccess, Li
227247
mod tests {
228248
use serde_json::json;
229249

230-
use super::LintMessageKind;
250+
use super::{LintMessageKind, lint_config_type_check_enabled};
231251

232252
#[test]
233253
fn lint_message_kind_defaults_to_lint_only_without_typecheck() {
234-
assert_eq!(LintMessageKind::from_lint_config(None), LintMessageKind::LintOnly);
235-
assert_eq!(
236-
LintMessageKind::from_lint_config(Some(&json!({ "options": {} }))),
237-
LintMessageKind::LintOnly
238-
);
254+
assert!(!lint_config_type_check_enabled(None));
255+
assert!(!lint_config_type_check_enabled(Some(&json!({ "options": {} }))));
256+
assert_eq!(LintMessageKind::from_flags(true, false), LintMessageKind::LintOnly);
239257
}
240258

241259
#[test]
242260
fn lint_message_kind_detects_typecheck_from_vite_config() {
243-
let kind = LintMessageKind::from_lint_config(Some(&json!({
261+
let config = json!({
244262
"options": {
245263
"typeAware": true,
246264
"typeCheck": true
247265
}
248-
})));
266+
});
249267

268+
assert!(lint_config_type_check_enabled(Some(&config)));
269+
270+
let kind = LintMessageKind::from_flags(true, true);
250271
assert_eq!(kind, LintMessageKind::LintAndTypeCheck);
251272
assert_eq!(kind.success_label(), "Found no warnings, lint errors, or type errors");
252273
assert_eq!(kind.warning_heading(), "Lint or type warnings found");
253274
assert_eq!(kind.issue_heading(), "Lint or type issues found");
254275
}
276+
277+
#[test]
278+
fn lint_message_kind_type_check_only_labels() {
279+
let kind = LintMessageKind::from_flags(false, true);
280+
assert_eq!(kind, LintMessageKind::TypeCheckOnly);
281+
assert_eq!(kind.success_label(), "Found no type errors");
282+
assert_eq!(kind.warning_heading(), "Type warnings found");
283+
assert_eq!(kind.issue_heading(), "Type errors found");
284+
}
285+
286+
#[test]
287+
fn lint_config_type_check_enabled_rejects_non_bool_values() {
288+
assert!(!lint_config_type_check_enabled(Some(&json!({
289+
"options": { "typeAware": true, "typeCheck": "true" }
290+
}))));
291+
assert!(!lint_config_type_check_enabled(Some(&json!({
292+
"options": { "typeAware": true, "typeCheck": 1 }
293+
}))));
294+
assert!(!lint_config_type_check_enabled(Some(&json!({
295+
"options": { "typeAware": true, "typeCheck": null }
296+
}))));
297+
}
298+
299+
#[test]
300+
fn lint_config_type_check_requires_type_aware_prerequisite() {
301+
assert!(!lint_config_type_check_enabled(Some(&json!({
302+
"options": { "typeCheck": true }
303+
}))));
304+
assert!(!lint_config_type_check_enabled(Some(&json!({
305+
"options": { "typeAware": false, "typeCheck": true }
306+
}))));
307+
assert!(!lint_config_type_check_enabled(Some(&json!({
308+
"options": { "typeAware": true, "typeCheck": false }
309+
}))));
310+
}
255311
}

packages/cli/binding/src/check/mod.rs

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ use vite_task::ExitStatus;
1010

1111
use self::analysis::{
1212
LintMessageKind, analyze_fmt_check_output, analyze_lint_output, format_count, format_elapsed,
13-
print_error_block, print_pass_line, print_stdout_block, print_summary_line,
13+
lint_config_type_check_enabled, print_error_block, print_pass_line, print_stdout_block,
14+
print_summary_line,
1415
};
1516
use crate::cli::{
1617
CapturedCommandOutput, SubcommandResolver, SynthesizableSubcommand, resolve_and_capture_output,
@@ -28,14 +29,6 @@ pub(crate) async fn execute_check(
2829
cwd: &AbsolutePathBuf,
2930
cwd_arc: &Arc<AbsolutePath>,
3031
) -> Result<ExitStatus, Error> {
31-
if no_fmt && no_lint {
32-
output::error("No checks enabled");
33-
print_summary_line(
34-
"`vp check` did not run because both `--no-fmt` and `--no-lint` were set",
35-
);
36-
return Ok(ExitStatus(1));
37-
}
38-
3932
let mut status = ExitStatus::SUCCESS;
4033
let has_paths = !paths.is_empty();
4134
// In --fix mode with file paths (the lint-staged use case), implicitly suppress
@@ -46,6 +39,18 @@ pub(crate) async fn execute_check(
4639
let mut deferred_lint_pass: Option<(String, String)> = None;
4740
let resolved_vite_config = resolver.resolve_universal_vite_config().await?;
4841

42+
let type_check_enabled = lint_config_type_check_enabled(resolved_vite_config.lint.as_ref());
43+
let lint_enabled = !no_lint;
44+
let run_lint_phase = lint_enabled || type_check_enabled;
45+
46+
if no_fmt && !run_lint_phase {
47+
output::error("No checks enabled");
48+
print_summary_line(
49+
"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.",
50+
);
51+
return Ok(ExitStatus(1));
52+
}
53+
4954
if !no_fmt {
5055
let mut args = if fix { vec![] } else { vec!["--check".to_string()] };
5156
if suppress_unmatched {
@@ -109,7 +114,7 @@ pub(crate) async fn execute_check(
109114
}
110115
}
111116

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

130-
if !no_lint {
131-
let lint_message_kind =
132-
LintMessageKind::from_lint_config(resolved_vite_config.lint.as_ref());
135+
if run_lint_phase {
136+
let lint_message_kind = LintMessageKind::from_flags(lint_enabled, type_check_enabled);
133137
let mut args = Vec::new();
134-
if fix {
138+
// oxlint cannot auto-fix type diagnostics, so `--fix` is dropped on the
139+
// type-check-only path.
140+
if fix && lint_enabled {
135141
args.push("--fix".to_string());
136142
}
137143
// `vp check` parses oxlint's human-readable summary output to print
@@ -140,6 +146,9 @@ pub(crate) async fn execute_check(
140146
// parser think linting never started. Force the default reporter here so the
141147
// captured output is stable across local and CI environments.
142148
args.push("--format=default".to_string());
149+
if !lint_enabled && type_check_enabled {
150+
args.push("--type-check-only".to_string());
151+
}
143152
if suppress_unmatched {
144153
args.push("--no-error-on-unmatched-pattern".to_string());
145154
}
@@ -206,13 +215,18 @@ pub(crate) async fn execute_check(
206215
}
207216
}
208217
if status != ExitStatus::SUCCESS {
218+
// Surface fmt `--fix` completion before bailing so users can see
219+
// the working tree was mutated before the lint/type-check error.
220+
if fix && !no_fmt {
221+
flush_deferred_pass_lines(&mut fmt_fix_started, &mut deferred_lint_pass);
222+
}
209223
return Ok(status);
210224
}
211225
}
212226

213227
// Re-run fmt after lint --fix, since lint fixes can break formatting
214-
// (e.g. the curly rule adding braces to if-statements)
215-
if fix && !no_fmt && !no_lint {
228+
// (e.g. the curly rule adding braces to if-statements).
229+
if fix && !no_fmt && lint_enabled {
216230
let mut args = Vec::new();
217231
if suppress_unmatched {
218232
args.push("--no-error-on-unmatched-pattern".to_string());
@@ -240,20 +254,33 @@ pub(crate) async fn execute_check(
240254
);
241255
return Ok(status);
242256
}
243-
if let Some(started) = fmt_fix_started {
244-
print_pass_line(
245-
"Formatting completed for checked files",
246-
Some(&format!("({})", format_elapsed(started.elapsed()))),
247-
);
248-
}
249-
if let Some((message, detail)) = deferred_lint_pass.take() {
250-
print_pass_line(&message, Some(&detail));
251-
}
257+
flush_deferred_pass_lines(&mut fmt_fix_started, &mut deferred_lint_pass);
258+
}
259+
260+
// Type-check-only mode skips the re-fmt block above, so flush deferred
261+
// pass lines here.
262+
if fix && !no_fmt && run_lint_phase && !lint_enabled {
263+
flush_deferred_pass_lines(&mut fmt_fix_started, &mut deferred_lint_pass);
252264
}
253265

254266
Ok(status)
255267
}
256268

269+
fn flush_deferred_pass_lines(
270+
fmt_fix_started: &mut Option<Instant>,
271+
deferred_lint_pass: &mut Option<(String, String)>,
272+
) {
273+
if let Some(started) = fmt_fix_started.take() {
274+
print_pass_line(
275+
"Formatting completed for checked files",
276+
Some(&format!("({})", format_elapsed(started.elapsed()))),
277+
);
278+
}
279+
if let Some((message, detail)) = deferred_lint_pass.take() {
280+
print_pass_line(&message, Some(&detail));
281+
}
282+
}
283+
257284
/// Combine stdout and stderr from a captured command output.
258285
fn combine_output(captured: CapturedCommandOutput) -> (ExitStatus, String) {
259286
let combined = if captured.stderr.is_empty() {

packages/cli/binding/src/cli/types.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ pub enum SynthesizableSubcommand {
9090
/// Skip format check
9191
#[arg(long = "no-fmt")]
9292
no_fmt: bool,
93-
/// Skip lint check
93+
/// Skip lint rules; type-check still runs when `lint.options.typeCheck` is true
9494
#[arg(long = "no-lint")]
9595
no_lint: bool,
9696
/// Do not exit with error when pattern is unmatched

packages/cli/snap-tests-global/command-check-help/snap.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Run format, lint, and type checks.
66
Options:
77
--fix Auto-fix format and lint issues
88
--no-fmt Skip format check
9-
--no-lint Skip lint check
9+
--no-lint Skip lint rules; type-check still runs when `lint.options.typeCheck` is true
1010
--no-error-on-unmatched-pattern Do not exit with error when pattern is unmatched
1111
-h, --help Print help
1212

@@ -26,7 +26,7 @@ Run format, lint, and type checks.
2626
Options:
2727
--fix Auto-fix format and lint issues
2828
--no-fmt Skip format check
29-
--no-lint Skip lint check
29+
--no-lint Skip lint rules; type-check still runs when `lint.options.typeCheck` is true
3030
--no-error-on-unmatched-pattern Do not exit with error when pattern is unmatched
3131
-h, --help Print help
3232

@@ -46,7 +46,7 @@ Run format, lint, and type checks.
4646
Options:
4747
--fix Auto-fix format and lint issues
4848
--no-fmt Skip format check
49-
--no-lint Skip lint check
49+
--no-lint Skip lint rules; type-check still runs when `lint.options.typeCheck` is true
5050
--no-error-on-unmatched-pattern Do not exit with error when pattern is unmatched
5151
-h, --help Print help
5252

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[1]> vp check --no-fmt --no-lint
22
error: No checks enabled
33

4-
`vp check` did not run because both `--no-fmt` and `--no-lint` were set
4+
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.

packages/cli/snap-tests/check-fix-lint-error-not-swallowed/snap.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ error: Lint issues found
1010
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.
1111

1212
Found 1 error and 0 warnings in 1 file (<variable>ms, <variable> threads)
13+
pass: Formatting completed for checked files (<variable>ms)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "check-fix-no-fmt-no-lint-typecheck",
3+
"version": "0.0.0",
4+
"private": true
5+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
> vp check --fix --no-fmt --no-lint
2+
pass: Found no type errors in 2 files (<variable>ms, <variable> threads)

0 commit comments

Comments
 (0)