diff --git a/.github/actions/deploy-docs/action.yml b/.github/actions/deploy-docs/action.yml index 2a2839a6da..90f13b5111 100644 --- a/.github/actions/deploy-docs/action.yml +++ b/.github/actions/deploy-docs/action.yml @@ -34,8 +34,12 @@ inputs: runs: using: 'composite' steps: + # docs/vite.config.ts uses the task cache format of this release. Once a + # release requires task cache settings under `cache`, remove this pin and + # the one in build-docs-fork-preview.yml, and update docs/vite.config.ts. - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: + version: 1.0.0-rc.0 cache: true working-directory: docs cache-dependency-path: docs/pnpm-lock.yaml diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml index d6314ec670..7fced42207 100644 --- a/.github/workflows/build-docs-fork-preview.yml +++ b/.github/workflows/build-docs-fork-preview.yml @@ -85,8 +85,10 @@ jobs: # Build the same commit that workflow_run.head_sha identifies. ref: ${{ github.event.pull_request.head.sha }} + # Keep in sync with the pin in .github/actions/deploy-docs/action.yml. - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 with: + version: 1.0.0-rc.0 working-directory: docs cache: false diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/manual.config.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/manual.config.txt new file mode 100644 index 0000000000..0ba6cbe7f6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/manual.config.txt @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite-plus'; + +const shared = { env: ['NODE_ENV'] }; + +export default defineConfig({ + run: { + tasks: { + build: { ...shared, command: 'vp build', input: ['src/**'] }, + dev: { command: 'vp dev', cache: false, env: ['PORT'] }, + check: { command: 'vp check', env: ['CI'] }, + }, + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/package.json new file mode 100644 index 0000000000..7510521fa5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-task-cache-fields", + "devDependencies": { + "vite-plus": "0.2.0" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots.toml new file mode 100644 index 0000000000..a7bc4d9e41 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots.toml @@ -0,0 +1,21 @@ +[[case]] +name = "migration_task_cache_fields" +vp = "global" +comment = "Move task cache settings under cache on an existing Vite+ project without --full, then keep them on a second migration." +steps = [ + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, +] + +[[case]] +name = "migration_task_cache_fields_manual" +vp = "global" +comment = "Warn about tasks whose cache settings cannot be moved safely, move the rest, and keep warning without migrating again." +steps = [ + { argv = ["vpt", "cp", "manual.config.txt", "vite.config.ts"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, + { argv = ["vp", "migrate", "--no-interactive"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots/migration_task_cache_fields.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots/migration_task_cache_fields.md new file mode 100644 index 0000000000..52d8c35964 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots/migration_task_cache_fields.md @@ -0,0 +1,98 @@ +# migration_task_cache_fields + +Move task cache settings under cache on an existing Vite+ project without --full, then keep them on a second migration. + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 config update applied +• Task cache settings moved under `cache` +• Package manager settings configured +``` + +## `vpt print-file vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + run: { + tasks: { + build: { + command: 'vp build', + cache: { + // Rebuild when the deploy target changes. + env: ['DEPLOY_TARGET'], + untrackedEnv: ['CI'], + input: [{ auto: true }, '!dist/**'], + output: ['dist/**'], // restored on cache hits + }, + }, + typecheck: { + command: 'tsc --noEmit', + cache: { + env: ['TSC_MODE'], + }, + }, + lint: { + command: 'vp lint', + cache: { + env: ['LINT_LEVEL'], + input: ['src/**'], + }, + }, + }, + }, +}); +``` + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + run: { + tasks: { + build: { + command: 'vp build', + cache: { + // Rebuild when the deploy target changes. + env: ['DEPLOY_TARGET'], + untrackedEnv: ['CI'], + input: [{ auto: true }, '!dist/**'], + output: ['dist/**'], // restored on cache hits + }, + }, + typecheck: { + command: 'tsc --noEmit', + cache: { + env: ['TSC_MODE'], + }, + }, + lint: { + command: 'vp lint', + cache: { + env: ['LINT_LEVEL'], + input: ['src/**'], + }, + }, + }, + }, +}); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots/migration_task_cache_fields_manual.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots/migration_task_cache_fields_manual.md new file mode 100644 index 0000000000..ba2c90aa7c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots/migration_task_cache_fields_manual.md @@ -0,0 +1,50 @@ +# migration_task_cache_fields_manual + +Warn about tasks whose cache settings cannot be moved safely, move the rest, and keep warning without migrating again. + +## `vpt cp manual.config.txt vite.config.ts` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 config update applied +• Task cache settings moved under `cache` +• Package manager settings configured +! Warnings: + - vite.config.ts: Move `env`, `untrackedEnv`, `input`, and `output` under `cache` manually in tasks `build`, `dev`; they were left unchanged. See https://viteplus.dev/config/run#cache +``` + +## `vpt print-file vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; + +const shared = { env: ['NODE_ENV'] }; + +export default defineConfig({ + run: { + tasks: { + build: { ...shared, command: 'vp build', input: ['src/**'] }, + dev: { command: 'vp dev', cache: false, env: ['PORT'] }, + check: { command: 'vp check', cache: { env: ['CI'] } }, + }, + }, +}); +``` + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +vite.config.ts: Move `env`, `untrackedEnv`, `input`, and `output` under `cache` manually in tasks `build`, `dev`; they were left unchanged. See https://viteplus.dev/config/run#cache +This project is already using Vite+! Happy coding! +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/vite.config.ts new file mode 100644 index 0000000000..d1043691c1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + run: { + tasks: { + build: { + command: 'vp build', + // Rebuild when the deploy target changes. + env: ['DEPLOY_TARGET'], + untrackedEnv: ['CI'], + input: [{ auto: true }, '!dist/**'], + output: ['dist/**'], // restored on cache hits + }, + typecheck: { + command: 'tsc --noEmit', + cache: true, + env: ['TSC_MODE'], + }, + lint: { + command: 'vp lint', + cache: { + env: ['LINT_LEVEL'], + }, + input: ['src/**'], + }, + }, + }, +}); diff --git a/crates/vp_migration/src/lib.rs b/crates/vp_migration/src/lib.rs index 69813c5175..5b350c8644 100644 --- a/crates/vp_migration/src/lib.rs +++ b/crates/vp_migration/src/lib.rs @@ -15,6 +15,7 @@ mod package; mod prettier; mod script_rewrite; mod source_analysis; +mod task_cache; mod vite_config; pub use file_walker::{WalkResult, find_ts_files, is_directory_gitignored}; @@ -24,6 +25,7 @@ pub use import_rewriter::{ }; pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts}; pub use source_analysis::analyze_migration_source; +pub use task_cache::{TaskCacheMigrationResult, migrate_task_cache_config}; pub use vite_config::{ MergeResult, has_config_key, merge_json_config, merge_tsdown_config, remove_config_key, upsert_json_config, wrap_lazy_plugins, diff --git a/crates/vp_migration/src/task_cache.rs b/crates/vp_migration/src/task_cache.rs new file mode 100644 index 0000000000..e9b884433a --- /dev/null +++ b/crates/vp_migration/src/task_cache.rs @@ -0,0 +1,677 @@ +use std::{ops::Range, path::Path}; + +use ast_grep_core::{Doc, Node}; +use ast_grep_language::{LanguageExt, SupportLang}; +use vp_error::Error; + +use crate::{ + pack_config::can_edit_object, + vite_config::{is_direct_recognized_config_object, pair_key_matches}, +}; + +/// Task settings that Vite Task only accepts inside a task's `cache` object. +const CACHE_FIELDS: [&str; 4] = ["env", "untrackedEnv", "input", "output"]; + +type Edit = (Range, String); + +/// A moved property with its leading comments, and the line comment after it. +struct Item { + text: String, + comment: Option, +} + +/// Result of moving task cache settings under `cache`. +#[derive(Debug)] +pub struct TaskCacheMigrationResult { + /// The updated vite config content + pub content: String, + /// Whether any changes were made + pub updated: bool, + /// Tasks that set cache settings outside `cache` but could not be updated + pub manual_tasks: Vec, +} + +/// Move `env`, `untrackedEnv`, `input`, and `output` from the top level of +/// each `run.tasks` entry into its `cache` object without evaluating user code. +/// +/// Only static task objects in a direct config object are updated. Tasks with +/// spreads, computed, escaped, or duplicate keys, comments the edit cannot +/// place, or a `cache` value other than `true` or an object literal are +/// reported in `manual_tasks` and left unchanged. Moved settings keep their +/// text and indentation; the formatter nests them inside `cache`. +pub fn migrate_task_cache_config( + vite_config_path: &Path, +) -> Result { + let content = std::fs::read_to_string(vite_config_path)?; + Ok(migrate_task_cache_config_content(&content)) +} + +fn migrate_task_cache_config_content(content: &str) -> TaskCacheMigrationResult { + let grep = SupportLang::TypeScript.ast_grep(content); + let mut edits = Vec::new(); + let mut manual_tasks = Vec::new(); + for task in grep.root().dfs().filter(|node| node.kind() == "object") { + let Some(name) = task_name(&task) else { continue }; + let fields: Vec<_> = task.children().filter(is_cache_field).collect(); + if fields.is_empty() { + continue; + } + match move_fields_under_cache(content, &task, &fields) { + Some(task_edits) => edits.extend(task_edits), + None => manual_tasks.push(name), + } + } + + if edits.is_empty() { + return TaskCacheMigrationResult { + content: content.to_owned(), + updated: false, + manual_tasks, + }; + } + edits.sort_by_key(|(range, _)| std::cmp::Reverse(range.start)); + let mut updated = content.to_owned(); + for (range, replacement) in edits { + updated.replace_range(range, &replacement); + } + TaskCacheMigrationResult { content: updated, updated: true, manual_tasks } +} + +/// Returns the task name when `object` is a task in `run.tasks` of a direct +/// config object. +fn task_name(object: &Node<'_, D>) -> Option { + let task = value_pair(object)?; + let tasks = task.parent().filter(|node| node.kind() == "object")?; + let tasks_pair = value_pair(&tasks).filter(|pair| has_key(pair, "tasks"))?; + let run = tasks_pair.parent().filter(|node| node.kind() == "object")?; + let run_pair = value_pair(&run).filter(|pair| has_key(pair, "run"))?; + let config = run_pair.parent().filter(|node| node.kind() == "object")?; + // `is_direct_recognized_config_object` looks through `satisfies` but not `as`. + let mut asserted = config.clone(); + while let Some(parent) = asserted.parent().filter(|node| node.kind() == "as_expression") { + asserted = parent; + } + if !is_direct_recognized_config_object(&asserted) + || config.ancestors().any(|ancestor| ancestor.kind() == "object") + { + return None; + } + let key = task.field("key")?; + Some(key.text().trim_matches(['\'', '"']).to_owned()) +} + +/// Returns the pair whose value is `node`, looking through parentheses and +/// type assertions. +fn value_pair<'a, D: Doc>(node: &Node<'a, D>) -> Option> { + let mut value = node.clone(); + loop { + let parent = value.parent()?; + match parent.kind().as_ref() { + "parenthesized_expression" | "satisfies_expression" | "as_expression" => { + value = parent; + } + "pair" => { + return parent + .field("value") + .is_some_and(|pair_value| pair_value.range() == value.range()) + .then_some(parent); + } + _ => return None, + } + } +} + +fn has_key(pair: &Node<'_, D>, name: &str) -> bool { + pair.field("key").is_some_and(|key| pair_key_matches(&key, name)) +} + +/// The static name of a property. Escaped string keys have no name here, so +/// they cannot hide a cache setting or a conflict. +fn property_name(property: &Node<'_, D>) -> Option { + match property.kind().as_ref() { + "pair" | "method_definition" => { + let key = property.field("key").or_else(|| property.field("name"))?; + match key.kind().as_ref() { + "property_identifier" => Some(key.text().into_owned()), + "string" if !key.text().contains('\\') => { + Some(key.text().trim_matches(['\'', '"']).to_owned()) + } + _ => None, + } + } + "shorthand_property_identifier" => Some(property.text().into_owned()), + _ => None, + } +} + +fn is_cache_field(property: &Node<'_, D>) -> bool { + property_name(property).is_some_and(|name| CACHE_FIELDS.contains(&name.as_str())) +} + +fn find_property<'a, D: Doc>(object: &Node<'a, D>, name: &str) -> Option> { + object.children().find(|child| property_name(child).is_some_and(|key| key == name)) +} + +/// Whether every property of `object` has a unique static name. +fn can_migrate_object(object: &Node<'_, D>) -> bool { + can_edit_object(object) + && object.children().all(|child| { + !matches!(child.kind().as_ref(), "pair" | "method_definition") + || property_name(&child).is_some() + }) +} + +fn move_fields_under_cache<'a, D: Doc>( + content: &str, + task: &Node<'a, D>, + fields: &[Node<'a, D>], +) -> Option> { + if !can_migrate_object(task) + || fields + .iter() + .any(|field| field.kind() == "method_definition" || has_comment_before_comma(field)) + { + return None; + } + let multiline = task.text().contains('\n'); + let cache = find_property(task, "cache"); + let indent = line_indent(content, cache.as_ref().unwrap_or(&fields[0]).range().start); + let items: Vec = fields.iter().map(|field| moved_item(content, field)).collect(); + + let mut edits = Vec::new(); + let removed = if let Some(cache) = cache { + let value = cache.field("value")?; + match value.kind().as_ref() { + "true" => edits.push((value.range(), object_text(&items, &indent, multiline))), + "object" => { + edits.push(merge_into_object(content, &value, fields, &items, &indent, multiline)?) + } + _ => return None, + } + fields + } else { + let first = &fields[0]; + let mut replacement = format!("cache: {}", object_text(&items, &indent, multiline)); + let end = match trailing_line_comment(first) { + // The comment moves into the object, so keep the comma it followed. + Some(comment) => { + if trailing_comma(first).is_some() { + replacement.push(','); + } + comment.range().end + } + None => first.range().end, + }; + edits.push((leading_comment_start(first)..end, replacement)); + &fields[1..] + }; + // Adjacent removals can share a comma, so merge overlapping ranges. + let mut removals: Vec> = removed.iter().map(removal_range).collect(); + removals.sort_by_key(|range| range.start); + let mut merged: Vec> = Vec::new(); + for range in removals { + match merged.last_mut() { + Some(last) if range.start <= last.end => last.end = last.end.max(range.end), + _ => merged.push(range), + } + } + // Removing the last property leaves the comma before it trailing. + if let (Some(field), Some(range)) = (removed.last(), merged.last_mut()) + && trailing_comma(field).is_none() + && let Some(comma) = task.children().filter(|node| node.range().end <= range.start).last() + && comma.kind() == "," + { + range.start = comma.range().start; + } + edits.extend(merged.into_iter().map(|range| (range, String::new()))); + Some(edits) +} + +/// Appends `items` to an existing `cache` object. Returns `None` when the +/// object's properties conflict with the moved fields or a comment follows +/// its last property. +fn merge_into_object( + content: &str, + cache: &Node<'_, D>, + fields: &[Node<'_, D>], + items: &[Item], + indent: &str, + multiline: bool, +) -> Option { + if !can_migrate_object(cache) + || fields.iter().any(|field| { + property_name(field).is_some_and(|name| find_property(cache, &name).is_some()) + }) + { + return None; + } + let inner: Vec<_> = + cache.children().filter(|child| !matches!(child.kind().as_ref(), "{" | "}")).collect(); + let Some(last) = inner.last() else { + return Some((cache.range(), object_text(items, indent, multiline))); + }; + let has_comma = last.kind() == ","; + let property = if has_comma { inner.iter().rev().nth(1)? } else { last }; + if property.kind() == "comment" { + return None; + } + let insert_at = last.range().end; + let mut text = if has_comma { String::new() } else { ",".to_owned() }; + if multiline { + let indent = line_indent(content, property.range().start); + for item in items { + text.push_str(&format!("\n{indent}{}", item_line(item))); + } + // A moved line comment must not swallow the closing brace. + if !content[insert_at..cache.range().end].contains('\n') { + text.push('\n'); + text.push_str(&indent); + } + } else { + text.push(' '); + text.push_str(&inline_items(items)); + if has_comma { + text.push(','); + } + } + Some((insert_at..insert_at, text)) +} + +fn object_text(items: &[Item], indent: &str, multiline: bool) -> String { + if !multiline { + return format!("{{ {} }}", inline_items(items)); + } + let mut text = String::from("{"); + for item in items { + text.push_str(&format!("\n{indent}{}", item_line(item))); + } + text.push('\n'); + text.push_str(indent); + text.push('}'); + text +} + +fn item_line(item: &Item) -> String { + match &item.comment { + Some(comment) => format!("{}, {comment}", item.text), + None => format!("{},", item.text), + } +} + +/// Items of a single-line task, which has no line comments to carry. +fn inline_items(items: &[Item]) -> String { + items.iter().map(|item| item.text.as_str()).collect::>().join(", ") +} + +/// The field as written, with its leading comments and trailing line comment. +fn moved_item(content: &str, field: &Node<'_, D>) -> Item { + Item { + text: content[leading_comment_start(field)..field.range().end].to_owned(), + comment: trailing_line_comment(field).map(|comment| comment.text().into_owned()), + } +} + +/// Whether a comment sits between `property` and its comma. +fn has_comment_before_comma(property: &Node<'_, D>) -> bool { + property.next().is_some_and(|next| next.kind() == "comment") + && property + .next_all() + .find(|node| node.kind() != "comment") + .is_some_and(|node| node.kind() == ",") +} + +/// A `//` comment on the line where `property` or its comma ends. +fn trailing_line_comment<'a, D: Doc>(property: &Node<'a, D>) -> Option> { + let mut before = property.clone(); + let mut next = property.next()?; + if next.kind() == "," { + before = next; + next = before.next()?; + } + (next.kind() == "comment" + && next.text().starts_with("//") + && next.start_pos().line() == before.end_pos().line()) + .then_some(next) +} + +/// Start of the comments on their own lines directly above `property`. +fn leading_comment_start(property: &Node<'_, D>) -> usize { + let mut start = property.range().start; + let mut current = property.clone(); + while let Some(previous) = current.prev() { + if previous.kind() != "comment" { + break; + } + // A comment on the same line as the token before it belongs to that token. + if previous + .prev() + .is_some_and(|before| before.end_pos().line() == previous.start_pos().line()) + { + break; + } + start = previous.range().start; + current = previous; + } + start +} + +/// Range that removes a property with its comments and trailing comma. +fn removal_range(property: &Node<'_, D>) -> Range { + let start = leading_comment_start(property); + let from = property + .prev_all() + .find(|node| node.range().end <= start) + .map_or(start, |node| node.range().end); + let to = trailing_line_comment(property) + .or_else(|| trailing_comma(property)) + .map_or(property.range().end, |node| node.range().end); + from..to +} + +fn trailing_comma<'a, D: Doc>(property: &Node<'a, D>) -> Option> { + property.next().filter(|node| node.kind() == ",") +} + +fn line_indent(content: &str, offset: usize) -> String { + let line_start = content[..offset].rfind('\n').map_or(0, |index| index + 1); + content[line_start..].chars().take_while(|c| matches!(c, ' ' | '\t')).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn migrate(input: &str) -> TaskCacheMigrationResult { + let result = migrate_task_cache_config_content(input); + let again = migrate_task_cache_config_content(&result.content); + assert!(!again.updated, "migration must be idempotent:\n{}", result.content); + let grep = SupportLang::TypeScript.ast_grep(&result.content); + assert!( + !grep.root().dfs().any(|node| node.is_error() || node.is_missing()), + "invalid output:\n{}", + result.content + ); + result + } + + fn migrate_tasks(tasks: &str) -> String { + let input = format!("export default defineConfig({{ run: {{ tasks: {tasks} }} }});"); + let result = migrate(&input); + assert!(result.manual_tasks.is_empty(), "{:?}", result.manual_tasks); + result.content + } + + #[test] + fn moves_fields_into_a_new_cache_object() { + assert_eq!( + migrate_tasks( + "{ build: { command: 'tsc', env: ['NODE_ENV'], untrackedEnv: ['CI'], input: ['src/**'], output: ['dist/**'] } }" + ), + "export default defineConfig({ run: { tasks: { build: { command: 'tsc', cache: { env: ['NODE_ENV'], untrackedEnv: ['CI'], input: ['src/**'], output: ['dist/**'] } } } } });" + ); + } + + #[test] + fn replaces_cache_true() { + assert_eq!( + migrate_tasks("{ build: { env: ['A'], command: 'tsc', cache: true, input: [] } }"), + "export default defineConfig({ run: { tasks: { build: { command: 'tsc', cache: { env: ['A'], input: [] } } } } });" + ); + } + + #[test] + fn merges_into_an_existing_cache_object() { + assert_eq!( + migrate_tasks("{ build: { command: 'tsc', cache: { env: ['A'] }, input: [] } }"), + "export default defineConfig({ run: { tasks: { build: { command: 'tsc', cache: { env: ['A'], input: [] } } } } });" + ); + assert_eq!( + migrate_tasks("{ build: { command: 'tsc', cache: {}, output: [] } }"), + "export default defineConfig({ run: { tasks: { build: { command: 'tsc', cache: { output: [] } } } } });" + ); + } + + #[test] + fn keeps_shorthand_properties_and_quoted_keys() { + assert_eq!( + migrate_tasks("{ 'build:site': { command: 'tsc', untrackedEnv, 'input': inputs } }"), + "export default defineConfig({ run: { tasks: { 'build:site': { command: 'tsc', cache: { untrackedEnv, 'input': inputs } } } } });" + ); + } + + #[test] + fn keeps_multiline_fields_and_comments_as_written() { + let input = r"export default { + run: { + tasks: { + 'build:site': { + command: 'vitepress build', + // The docs URLs depend on the deploy target. + env: ['DOCS_SITE_ORIGIN'], + dependsOn: ['lint'], // runs first + input: [ + { auto: true }, + '!.vitepress/dist/**', + ], + output: ['.vitepress/dist/**'] + }, + }, + }, +}; +"; + let expected = r"export default { + run: { + tasks: { + 'build:site': { + command: 'vitepress build', + cache: { + // The docs URLs depend on the deploy target. + env: ['DOCS_SITE_ORIGIN'], + input: [ + { auto: true }, + '!.vitepress/dist/**', + ], + output: ['.vitepress/dist/**'], + }, + dependsOn: ['lint'], // runs first + }, + }, + }, +}; +"; + assert_eq!(migrate(input).content, expected); + } + + #[test] + fn replaces_multiline_cache_true_and_extends_existing_objects() { + let input = r"export default defineConfig({ + run: { + tasks: { + hello: { + command: 'node hello.mjs', + untrackedEnv: ['FOO'], + cache: true, + }, + lint: { + command: 'vp lint', + cache: { + env: ['A'], + }, + input: ['src/**'], + }, + }, + }, +}); +"; + let expected = r"export default defineConfig({ + run: { + tasks: { + hello: { + command: 'node hello.mjs', + cache: { + untrackedEnv: ['FOO'], + }, + }, + lint: { + command: 'vp lint', + cache: { + env: ['A'], + input: ['src/**'], + }, + }, + }, + }, +}); +"; + assert_eq!(migrate(input).content, expected); + } + + #[test] + fn moves_leading_and_trailing_comments() { + let input = r"export default defineConfig({ + run: { + tasks: { + test: { + // Fingerprint the test mode. + env: ['MODE'], // same-line note + command: 'vp test', + cache: {}, + /* restore coverage */ + output: ['coverage/**'], + }, + last: { + command: 'x', + env: ['A'] // without a comma + }, + }, + }, +}); +"; + let expected = r"export default defineConfig({ + run: { + tasks: { + test: { + command: 'vp test', + cache: { + // Fingerprint the test mode. + env: ['MODE'], // same-line note + /* restore coverage */ + output: ['coverage/**'], + }, + }, + last: { + command: 'x', + cache: { + env: ['A'], // without a comma + } + }, + }, + }, +}); +"; + assert_eq!(migrate(input).content, expected); + } + + #[test] + fn keeps_the_closing_brace_after_a_moved_line_comment() { + let input = "export default { run: { tasks: { build: {\n command: 'x',\n cache: { env: ['A'] },\n input: ['src/**'], // note\n} } } };"; + let expected = "export default { run: { tasks: { build: {\n command: 'x',\n cache: { env: ['A'],\n input: ['src/**'], // note\n },\n} } } };"; + assert_eq!(migrate(input).content, expected); + } + + #[test] + fn keeps_multiline_template_literals_unchanged() { + let input = "export default { run: { tasks: { build: {\n command: 'x',\n env: [`A\nB`],\n} } } };"; + let actual = migrate(input).content; + assert!(actual.contains("env: [`A\nB`],"), "{actual}"); + } + + #[test] + fn supports_callbacks_and_type_assertions() { + for input in [ + "export default defineConfig(() => ({ run: { tasks: { build: { command: 'x', env: ['A'] } } } }));", + "export default defineConfig(({ mode }) => { return { run: { tasks: { build: { command: 'x', env: ['A'] } } } }; });", + "export default { run: { tasks: { build: { command: 'x', env: ['A'] } satisfies Task } } } satisfies UserConfig;", + "export default defineConfig({ run: { tasks: ({ build: { command: 'x', env: ['A'] } }) } });", + "export default defineConfig({ run: { tasks: { build: { command: 'x', env: ['A'] } } } } as UserConfig);", + "export default { run: { tasks: { build: { command: 'x', env: ['A'] } } } } as UserConfig;", + ] { + let actual = migrate(input).content; + assert!(actual.contains("cache: { env: ['A'] }"), "{actual}"); + } + } + + #[test] + fn ignores_objects_outside_run_tasks() { + for input in [ + "export default defineConfig({ build: { rollupOptions: { input: 'src/index.ts', output: { dir: 'dist' } } } });", + "export default defineConfig({ run: { cache: { tasks: true }, env: ['A'] } });", + "export default defineConfig({ plugins: [{ config() { return { run: { tasks: { build: { env: ['A'] } } } }; } }] });", + "export default defineConfig({ test: { run: { tasks: { build: { env: ['A'] } } } } });", + "const config = { run: { tasks: { build: { command: 'x', env: ['A'] } } } }; export default config;", + "export default defineConfig({ run: { tasks: { build: 'tsc', check: ['vp lint', 'vp build'] } } });", + "export default defineConfig({ run: { tasks: { build: { command: 'x', cache: { env: ['A'] } } } } });", + ] { + let result = migrate(input); + assert_eq!(result.content, input); + assert!(result.manual_tasks.is_empty(), "{input}"); + } + } + + #[test] + fn reports_tasks_that_need_manual_migration() { + let input = r"export default defineConfig({ + run: { + tasks: { + spread: { ...shared, command: 'x', env: ['A'] }, + disabled: { command: 'x', cache: false, input: [] }, + dynamic: { command: 'x', cache: isCI, input: [] }, + conflict: { command: 'x', cache: { env: ['B'] }, env: ['A'] }, + escapedConflict: { command: 'x', cache: { '\u0065nv': ['B'] }, env: ['A'] }, + escapedKey: { command: 'x', 'unt\u0072ackedEnv': [], env: ['A'] }, + computed: { [key]: 'x', env: ['A'] }, + duplicate: { command: 'x', env: ['A'], env: ['B'] }, + shorthand: { command: 'x', cache, env: ['A'] }, + spreadCache: { command: 'x', cache: { ...base }, env: ['A'] }, + method: { command: 'x', input() { return []; } }, + commentBeforeComma: { command: 'x', env: ['A'] /* why */, input: [] }, + lineCommentBeforeComma: { + command: 'x', + env: ['A'] // note + , input: [], + }, + commentInCache: { command: 'x', cache: { env: ['B'] /* why */ }, input: [] }, + ok: { command: 'x', env: ['A'] }, + }, + }, +}); +"; + let result = migrate(input); + assert_eq!( + result.manual_tasks, + [ + "spread", + "disabled", + "dynamic", + "conflict", + "escapedConflict", + "escapedKey", + "computed", + "duplicate", + "shorthand", + "spreadCache", + "method", + "commentBeforeComma", + "lineCommentBeforeComma", + "commentInCache", + ] + ); + assert_eq!( + result.content.replace( + "ok: { command: 'x', cache: { env: ['A'] } }", + "ok: { command: 'x', env: ['A'] }" + ), + input + ); + } +} diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index ff97eb5f77..a6fd7911b5 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -52,6 +52,46 @@ When `external` accompanies either `skipNodeModulesBundle` form, static matchers The transform does not evaluate configuration code. Objects with spreads, computed keys, or duplicate keys, and conflicting old and new options require manual review. Dynamic boolean selectors remain unchanged. Unrelated Vite and plugin options remain unchanged. Run `vp pack` after migration to check the result. Node.js requirements, TypeScript module resolution, and programmatic `build()` return values require separate review. +## Task Cache Configuration + +`vp migrate` moves `env`, `untrackedEnv`, `input`, and `output` from the top level of each task into the task's [`cache`](/config/run#cache) object. It updates static `run.tasks` objects in `vite.config.*`. This also runs on existing Vite+ projects without `--full`, including workspace packages. + +| Previous task config | Updated task config | +| -------------------- | ---------------------------------------------- | +| No `cache` | Adds `cache` with the moved settings | +| `cache: true` | Replaces `true` with the moved settings | +| `cache: { ... }` | Adds the moved settings to the existing object | + +```ts +// Before +build: { + command: 'vp build', + env: ['NODE_ENV'], + input: [{ auto: true }, '!dist/**'], +}, + +// After +build: { + command: 'vp build', + cache: { + env: ['NODE_ENV'], + input: [{ auto: true }, '!dist/**'], + }, +}, +``` + +Moved settings keep their text and indentation, and the formatting step described in [After the Migration](#after-the-migration) indents them inside `cache`. Comments on their own line above a moved setting, and `//` comments that follow it on the same line, move with it. + +The transform does not evaluate configuration code. It leaves a task unchanged and prints a manual-migration warning when the task has: + +- spreads, or computed, escaped, or duplicate keys; +- a moved setting written as a method, such as `input() { ... }`; +- a comment between a moved setting and its comma, or after the last setting in `cache`; +- a moved setting that already exists in `cache`; +- a `cache` value other than `true` or an object literal. + +With `cache: false`, the moved settings would have no effect, so decide whether to remove them or enable caching. On a project that is otherwise up to date, `vp migrate` prints these warnings without running the rest of the migration. + ## Dependency Rules What happens to each toolchain dependency, at a glance: diff --git a/docs/vite.config.ts b/docs/vite.config.ts index ab261a2e66..989119a081 100644 --- a/docs/vite.config.ts +++ b/docs/vite.config.ts @@ -1,8 +1,8 @@ export default { run: { tasks: { - // Docs deploys build with the released `vp` from setup-vp. Keep the cache - // settings at the top level until a release requires them under `cache`. + // Docs builds pin `vp` in .github/actions/deploy-docs/action.yml. Keep the + // cache settings at the top level until that pin requires them under `cache`. 'build:site': { command: 'vitepress build', // The docs and installer URLs depend on the explicit origin or Workers diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index 694923bd2f..c7f15d6d41 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -1052,6 +1052,7 @@ module.exports.hasConfigKey = nativeBinding.hasConfigKey; module.exports.isDirectoryGitignored = nativeBinding.isDirectoryGitignored; module.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig; module.exports.mergeTsdownConfig = nativeBinding.mergeTsdownConfig; +module.exports.migrateTaskCacheConfig = nativeBinding.migrateTaskCacheConfig; module.exports.parseConfigArgs = nativeBinding.parseConfigArgs; module.exports.parseCreateArgs = nativeBinding.parseCreateArgs; module.exports.parseHooksArgs = nativeBinding.parseHooksArgs; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 07595a206e..cf63e763d3 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3770,6 +3770,12 @@ export interface MigrateArgs { full?: boolean; } +/** + * Move `env`, `untrackedEnv`, `input`, and `output` from the top level of + * each static `run.tasks` entry into its `cache` object. + */ +export declare function migrateTaskCacheConfig(viteConfigPath: string): TaskCacheConfigResult; + export declare function parseConfigArgs(argv: Array): ParseConfigArgsOutcome; export type ParseConfigArgsOutcome = @@ -4014,6 +4020,16 @@ export interface StagedArgs { verbose?: boolean; } +/** Result of moving task cache settings under `cache` */ +export interface TaskCacheConfigResult { + /** The updated vite config content */ + content: string; + /** Whether any changes were made */ + updated: boolean; + /** Tasks that set cache settings outside `cache` but could not be updated */ + manualTasks: Array; +} + /** * Set the value of a top-level config key in a vite config file (upsert) * diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 25ad469086..5a98034ee0 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -291,6 +291,31 @@ pub fn wrap_lazy_plugins(vite_config_path: String) -> Result, +} + +/// Move `env`, `untrackedEnv`, `input`, and `output` from the top level of +/// each static `run.tasks` entry into its `cache` object. +#[napi] +pub fn migrate_task_cache_config(vite_config_path: String) -> Result { + let result = vp_migration::migrate_task_cache_config(Path::new(&vite_config_path)) + .map_err(anyhow::Error::from)?; + + Ok(TaskCacheConfigResult { + content: result.content, + updated: result.updated, + manual_tasks: result.manual_tasks, + }) +} + /// Rewrite imports in all TypeScript/JavaScript files under a directory /// /// This function finds all TypeScript and JavaScript files in the specified directory diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index f8f68f5056..ec7cd5cdef 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -8774,6 +8774,8 @@ describe('existing Vite+ core migration finalization', () => { tsconfigTypes: true, imports: true, tsdownConfig: false, + taskCacheConfig: false, + taskCacheWarnings: [], }); const pkg = readJson(path.join(tmpDir, 'package.json')) as { @@ -8851,6 +8853,8 @@ export default defineConfig({ tsconfigTypes: false, imports: true, tsdownConfig: true, + taskCacheConfig: false, + taskCacheWarnings: [], }); expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain( "import tsdownConfig from './tsdown.config.js';", @@ -8868,6 +8872,8 @@ export default defineConfig({ tsconfigTypes: false, imports: false, tsdownConfig: false, + taskCacheConfig: false, + taskCacheWarnings: [], }); }); @@ -8905,11 +8911,188 @@ export default defineConfig({ entry: 'src/index.ts' }); tsconfigTypes: false, imports: true, tsdownConfig: false, + taskCacheConfig: false, + taskCacheWarnings: [], }); expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toBe(originalViteConfig); expect(report.tsdownImportCount).toBe(0); expect(report.manualSteps).toEqual([]); }); + + it('moves task cache settings under cache in the root and workspace packages', () => { + const appDir = path.join(tmpDir, 'packages', 'app'); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'root', devDependencies: { 'vite-plus': 'latest' } }, null, 2), + ); + fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'app' }, null, 2)); + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + run: { + tasks: { + build: { + command: 'vp build', + env: ['NODE_ENV'], + output: ['dist/**'], + }, + }, + }, +}); +`, + ); + fs.writeFileSync( + path.join(appDir, 'vite.config.ts'), + `export default { + run: { tasks: { test: { command: 'vp test', cache: true, input: ['src/**'] } } }, +}; +`, + ); + const workspaceInfo = { + ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + isMonorepo: true, + packages: [{ name: 'app', path: 'packages/app' }], + }; + const report = createMigrationReport(); + + expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true, report)).toMatchObject({ + taskCacheConfig: true, + }); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain(` build: { + command: 'vp build', + cache: { + env: ['NODE_ENV'], + output: ['dist/**'], + }, + },`); + expect(fs.readFileSync(path.join(appDir, 'vite.config.ts'), 'utf8')).toContain( + "test: { command: 'vp test', cache: { input: ['src/**'] } }", + ); + expect(report.migratedTaskCacheConfigCount).toBe(2); + expect(report.warnings).toEqual([]); + + expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true, report)).toMatchObject({ + taskCacheConfig: false, + taskCacheWarnings: [], + }); + expect(report.migratedTaskCacheConfigCount).toBe(2); + }); + + it('warns about task cache settings that need manual migration', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'test', devDependencies: { 'vite-plus': 'latest' } }, null, 2), + ); + const viteConfig = `import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + run: { + tasks: { + build: { ...shared, command: 'vp build', env: ['NODE_ENV'] }, + dev: { command: 'vp dev', cache: false, input: ['src/**'] }, + }, + }, +}); +`; + fs.writeFileSync(path.join(tmpDir, 'vite.config.ts'), viteConfig); + const report = createMigrationReport(); + + const result = finalizeCoreMigrationForExistingVitePlus( + makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + true, + report, + ); + + expect(result.taskCacheConfig).toBe(false); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toBe(viteConfig); + expect(report.migratedTaskCacheConfigCount).toBe(0); + // Review items stay out of the report so an up-to-date project can exit early. + expect(report.warnings).toEqual([]); + expect(result.taskCacheWarnings).toHaveLength(1); + expect(result.taskCacheWarnings[0]).toContain( + 'vite.config.ts: Move `env`, `untrackedEnv`, `input`, and `output` under `cache` manually in tasks `build`, `dev`; they were left unchanged.', + ); + expect(result.taskCacheWarnings[0]).toContain('/config/run#cache'); + }); +}); + +describe('rewriteStandaloneProject — task cache settings', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-task-cache-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'test', devDependencies: { vite: '^7.0.0' } }), + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('moves task cache settings under cache during a fresh migration', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite'; + +export default defineConfig({ + run: { tasks: { build: { command: 'vite build', untrackedEnv: ['CI'] } } }, +}); +`, + ); + const report = createMigrationReport(); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + true, + true, + report, + ); + + const viteConfig = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8'); + expect(viteConfig).toContain( + "build: { command: 'vite build', cache: { untrackedEnv: ['CI'] } }", + ); + expect(report.migratedTaskCacheConfigCount).toBe(1); + }); + + it('moves task cache settings in monorepo packages', () => { + const appDir = path.join(tmpDir, 'apps', 'web'); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'root', workspaces: ['apps/*'], devDependencies: { vite: '^7.0.0' } }), + ); + fs.writeFileSync( + path.join(appDir, 'package.json'), + JSON.stringify({ name: 'web', devDependencies: { vite: '^7.0.0' } }), + ); + fs.writeFileSync( + path.join(appDir, 'vite.config.ts'), + `export default { + run: { tasks: { build: { command: 'vite build', input: ['src/**'] } } }, +}; +`, + ); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + workspaceInfo.isMonorepo = true; + workspaceInfo.workspacePatterns = ['apps/*']; + workspaceInfo.parentDirs = ['apps']; + workspaceInfo.packages = [{ name: 'web', path: 'apps/web' }]; + const report = createMigrationReport(); + + rewriteMonorepo(workspaceInfo, true, true, report); + + expect(fs.readFileSync(path.join(appDir, 'vite.config.ts'), 'utf8')).toContain( + "build: { command: 'vite build', cache: { input: ['src/**'] } }", + ); + expect(report.migratedTaskCacheConfigCount).toBe(1); + }); }); // Regression: templates such as `create-fate` ship a populated vite.config.ts diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 00b0e5113f..630520386b 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -534,7 +534,8 @@ function showMigrationSummary(options: { report.inlinedLintStagedConfigCount + report.removedConfigCount + report.tsdownImportCount + - report.wrappedPluginConfigCount; + report.wrappedPluginConfigCount + + report.migratedTaskCacheConfigCount; log( `${styleText('magenta', '◇')} ${updatedExistingVitePlus ? 'Updated' : 'Migrated'} ${accent(projectLabel)} to Vite+ ${VITE_PLUS_VERSION}`, @@ -615,6 +616,9 @@ function showMigrationSummary(options: { `${styleText('gray', '•')} Inline Vite plugins wrapped with lazyPlugins for check/lint/fmt`, ); } + if (report.migratedTaskCacheConfigCount > 0) { + log(`${styleText('gray', '•')} Task cache settings moved under \`cache\``); + } if (report.gitHooksConfigured) { log(`${styleText('gray', '•')} Git hooks configured`); } @@ -1174,7 +1178,8 @@ async function main() { coreMigrationResult.scripts || coreMigrationResult.tsconfigTypes || coreMigrationResult.imports || - coreMigrationResult.tsdownConfig + coreMigrationResult.tsdownConfig || + coreMigrationResult.taskCacheConfig ) { didMigrate = true; } @@ -1211,12 +1216,18 @@ async function main() { if (vitestV5Preflight) { prompts.log.warn(vitestV5Preflight); } + for (const warning of coreMigrationResult.taskCacheWarnings) { + prompts.log.warn(warning); + } if (skippedSetupCandidates) { log(FULL_MIGRATION_HINT); } prompts.outro(`This project is already using Vite+! ${accent('Happy coding!')}`); return; } + for (const warning of coreMigrationResult.taskCacheWarnings) { + addMigrationWarning(report, warning); + } const setupOptions = getExistingVitePlusSetupOptions(options, fullSetup); const plan = await collectMigrationSetupPlan( diff --git a/packages/cli/src/migration/migrator/core-finalization.ts b/packages/cli/src/migration/migrator/core-finalization.ts index 3b6ca2b1be..d57f2b556a 100644 --- a/packages/cli/src/migration/migrator/core-finalization.ts +++ b/packages/cli/src/migration/migrator/core-finalization.ts @@ -9,6 +9,7 @@ import { dropDeadOxlintPluginsDependency, hasTsconfigTypesToRewrite, mergeTsdownConfigFile, + migrateTaskCacheConfigInViteConfig, rewriteAllImports, rewriteTsconfigTypes, } from '../migrator.ts'; @@ -84,6 +85,10 @@ export type CoreMigrationFinalizationResult = { tsconfigTypes: boolean; imports: boolean; tsdownConfig: boolean; + taskCacheConfig: boolean; + // Kept out of the report so that review items alone do not make an + // up-to-date project run the rest of the migration. + taskCacheWarnings: string[]; }; function getCoreMigrationProjectPaths(workspaceInfo: CoreMigrationWorkspace): string[] { @@ -150,6 +155,8 @@ export function finalizeCoreMigrationForExistingVitePlus( tsconfigTypes: false, imports: false, tsdownConfig: false, + taskCacheConfig: false, + taskCacheWarnings: [], }; if (pending.scripts) { @@ -178,5 +185,11 @@ export function finalizeCoreMigrationForExistingVitePlus( result.tsdownConfig = mergeTsdownConfigFile(projectPath, silent, report) || result.tsdownConfig; } + for (const projectPath of projectPaths) { + result.taskCacheConfig = + migrateTaskCacheConfigInViteConfig(projectPath, silent, report, result.taskCacheWarnings) || + result.taskCacheConfig; + } + return result; } diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index 7e0b0ddb64..6f520c906c 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -29,6 +29,7 @@ import { mergeViteConfigFiles, migratePnpmOverridesToWorkspaceYaml, migratePnpmSettingsToWorkspaceYaml, + migrateTaskCacheConfigInViteConfig, pnpmSupportsWorkspaceSettings, supportsCatalog, projectListsRequiredVitestPeer, @@ -340,6 +341,7 @@ export function rewriteStandaloneProject( rewriteAllImports(projectPath, silent, report, true, oxlintOwnerDirs); dropDeadOxlintPluginsDependency(projectPath, workspaceInfo.packages, originalOxlintDependencies); wrapLazyPluginsInViteConfig(projectPath, silent, report); + migrateTaskCacheConfigInViteConfig(projectPath, silent, report); // set package manager setPackageManager(projectPath, workspaceInfo.downloadPackageManager); } @@ -492,8 +494,11 @@ export function rewriteMonorepo( originalOxlintDependencies, ); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); + migrateTaskCacheConfigInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { - wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); + const projectPath = path.join(workspaceInfo.rootDir, pkg.path); + wrapLazyPluginsInViteConfig(projectPath, silent, report); + migrateTaskCacheConfigInViteConfig(projectPath, silent, report); } // set package manager setPackageManager(workspaceInfo.rootDir, workspaceInfo.downloadPackageManager); @@ -617,5 +622,6 @@ export function rewriteMonorepoProject( if (!deferLazyPluginWrapping) { wrapLazyPluginsInViteConfig(projectPath, silent, report); + migrateTaskCacheConfigInViteConfig(projectPath, silent, report); } } diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 3ad7fc78c6..a96e182b64 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -9,6 +9,7 @@ import { hasConfigKey, mergeJsonConfig, mergeTsdownConfig, + migrateTaskCacheConfig, rewriteImportsInDirectory, rewriteScripts, wrapLazyPlugins, @@ -538,6 +539,52 @@ export function wrapLazyPluginsInViteConfig( } } +/** + * Move `env`, `untrackedEnv`, `input`, and `output` from the top level of + * each `run.tasks` entry into its `cache` object, as Vite Task requires. + * Tasks that need manual changes are warned about, or collected into + * `manualWarnings` when the caller reports them later. + */ +export function migrateTaskCacheConfigInViteConfig( + projectPath: string, + silent = false, + report?: MigrationReport, + manualWarnings?: string[], +): boolean { + const configs = detectConfigs(projectPath); + if (!configs.viteConfig) { + return false; + } + + const viteConfigPath = path.join(projectPath, configs.viteConfig); + const result = migrateTaskCacheConfig(viteConfigPath); + if (result.manualTasks.length > 0) { + const tasks = result.manualTasks.map((task) => `\`${task}\``).join(', '); + const warning = `${displayRelative(viteConfigPath)}: Move \`env\`, \`untrackedEnv\`, \`input\`, and \`output\` under \`cache\` manually in ${ + result.manualTasks.length === 1 ? 'task' : 'tasks' + } ${tasks}; ${result.manualTasks.length === 1 ? 'it was' : 'they were'} left unchanged. See ${documentationUrl('/config/run#cache')}`; + if (manualWarnings) { + manualWarnings.push(warning); + } else { + warnMigration(warning, report); + } + } + if (!result.updated) { + return false; + } + + fs.writeFileSync(viteConfigPath, result.content); + if (report) { + report.migratedTaskCacheConfigCount++; + } + if (!silent) { + prompts.log.success( + `✔ Moved task cache settings under \`cache\` in ${displayRelative(viteConfigPath)}`, + ); + } + return true; +} + /** * Rewrite imports in all TypeScript/JavaScript files under a directory * This rewrites vite/vitest imports to @voidzero-dev/vite-plus diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 2225ef596c..eebf554c9f 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -18,6 +18,7 @@ export interface MigrationReport { removedConfigCount: number; tsdownImportCount: number; wrappedPluginConfigCount: number; + migratedTaskCacheConfigCount: number; rewrittenImportFileCount: number; preservedUpstreamVitestImportFileCount: number; rewrittenImportErrors: Array<{ path: string; message: string }>; @@ -43,6 +44,7 @@ export function createMigrationReport(): MigrationReport { removedConfigCount: 0, tsdownImportCount: 0, wrappedPluginConfigCount: 0, + migratedTaskCacheConfigCount: 0, rewrittenImportFileCount: 0, preservedUpstreamVitestImportFileCount: 0, rewrittenImportErrors: [],