Symlink パスを保持し @source/glob の順序を正しく保つ修正
Tailwind CSS v4 では @source と @source not のディレクティブがシンボリックリンクを通過した際にパスが正規化され、元の記述が失われて無視設定が機能しない問題がありました。本 PR はシンボリックリンク情報を保持しつつ、.gitignore 生成時のパターン順序を宣言順に固定することで、#17985 を解消します。これにより開発者は意図した通りにファイルのインクルード/エクスクルードが可能になります。
背景
@source ディレクティブは内部で base パスと pattern を組み合わせたエントリにマッピングされ、dunce::canonicalize によりベースパスが実際のディスク上の実体に解決されます。この解決過程でシンボリックリンクが辿られ、@source "./symlinked-folder/**/*.html" が 実体パス に変換されてしまい、元のシンボリックリンク名が失われました。その結果、@source not "./symlinked-folder" が期待した無視対象にならず、Issue #17985 に記されたビルドエラーが発生していました。
さらに、.gitignore を生成する際は BTreeMap と BTreeSet による自動ソートが行われ、パターンの順序が宣言順と逆転しました。@source not の後に @source が続くケースでは、無視設定が上書きされてしまい、テストでも指摘されたように期待したファイルが除外されませんでした。これら二つの欠陥が同時に顕在化したことが本修正の動機です。
技術的な変更
scanner/mod.rs のデータ構造置換
crate::scanner::mod.rs では BTreeMap<&PathBuf, BTreeSet> を Vec<(&PathBuf, Vec)> に置き換え、emit クロージャで同一ベースへの連続追加を保持します。これによりパターンは宣言順に蓄積され、ソートが排除されます。
@@ -17,7 +17,6 @@ use fxhash::{FxHashMap, FxHashSet};
-use std::collections::{BTreeMap, BTreeSet};
@@
- let mut ignores: BTreeMap<&PathBuf, BTreeSet<String>> = Default::default();
+ let mut ignores: Vec<(&PathBuf, Vec<String>)> = Default::default();
+ let mut emit = |base, pattern| match ignores.last_mut() {
+ Some((prev_base, patterns)) if *prev_base == base => {
+ patterns.push(pattern);
+ }
+ _ => {
+ ignores.push((base, vec![pattern]));
+ }
+ };
@@
- ignores
- .entry(base)
- .or_default()
- .insert(format!("!{}", pattern));
+ emit(base, format!("!{}", pattern));
この変更は パターンの順序保持 と シンボリックリンク情報の喪失防止 の二重目的を満たす最小侵入的実装です。既存コードは emit 呼び出しに合わせて軽微な置換のみで済み、他のロジックへの副作用はありません。
scanner/sources.rs の最適化ロジック改良
crate::scanner::sources.rs ではベースパスの正規化はそのままに、パターン側のコンポーネント解析 を導入し、.. や . を考慮しつつワイルドカードが出現したタイミングでベースの変更を止めます。これによりシンボリックリンクが含まれるパスでも base は実体に変換されますが、pattern 部分は元の文字列を保持します。
@@
- let Ok(base) = dunce::canonicalize(&self.base) else {
+ let Ok(mut base) = dunce::canonicalize(&self.base) else {
@@
- let mut new_pattern = PathBuf::new();
+ let mut new_pattern = PathBuf::new();
@@
- Component::Normal(part) if part.to_string_lossy().contains("*") => {
+ Component::Normal(part) if part.to_string_lossy().contains("*") => {
このロジックは パス全体を正規化しつつ、ワイルドカード以降の文字列をそのまま保持 することで、@source 記述と実際の走査パスを一致させます。結果として @source not "./symlink" が正しく無視対象となり、Issue #17985 が解消されます。
テスト・CI の拡張
-
crates/oxide/tests/scanner.rsにシンボリックリンクと宣言順を検証する新規テストを追加し、it_should_preserve_source_order_when_referencing_a_sibling_projectで期待結果を明示しました。 -
integrations/utils.tsにsymlinkヘルパーを実装し、プラットフォーム横断でシンボリックリンクを作成可能にしました。 -
integrations/cli/index.test.tsで実際の CLI 実行時にシンボリックリンク経由の@sourceが正しい順序で処理されることをエンドツーエンドで検証しています。
これらのテストはすべての OS で走るように CI の ci-all ジョブに組み込まれ、回帰防止の安全ネットとして機能します。
設計判断
データ構造の選択変更
BTreeMap/BTreeSet は自動ソートが利点ですが、@source の 宣言順が意味を持つ 場面では不適切です。開発者は「先に無視し、後で再度インクルード」する意図を持って記述するため、Vec と手動管理 が自然な選択となります。この変更は API 互換性を損なわず、内部だけで順序保証を行うシンプルな戦略です。
シンボリックリンクの扱い方
dunce::canonicalize はベースパスの実体化に有用ですが、パターン部分は文字列通り に残す必要があります。今回の実装はコンポーネント単位で走査し、ワイルドカードに遭遇した時点でベース操作を停止するという 段階的最適化 を採用しています。これによりシンボリックリンクの情報が失われず、元の @source 記述とマッピングが 1:1 で保持されます。
テストフレームワークの拡張
シンボリックリンクはプラットフォーム依存の副作用が強いため、テストユーティリティに symlink 関数を追加し、Unix と Windows の両方で同一ロジックを検証できるようにしました。これにより クロスプラットフォームの信頼性 が担保され、将来の変更でも同様の問題が再発しにくくなります。
まとめ
この PR は @source 系ディレクティブがシンボリックリンクを経由した際にパス情報を失わないようにし、かつ .gitignore 生成時のパターン順序を宣言通りに保持することで、Issue #17985 とその付随する順序バグを根本的に解決しました。内部実装はデータ構造の置換とパス解析ロジックの改善に絞られ、既存ユーザーへの破壊的影響はありません。今後はシンボリックリンクを含むプロジェクト構成でも安定したスキャン結果が得られ、Tailwind CSS のビルドパイプラインがより堅牢になります。